merge: master into codex/bark-battle
This commit is contained in:
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -221,6 +221,81 @@ test('creation hub marks generating and newly completed drafts', () => {
|
||||
expect(html).toContain('creation-work-card__spinner');
|
||||
});
|
||||
|
||||
test('creation hub does not mask completed puzzle drafts while a later level image is generating', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
items={[]}
|
||||
puzzleItems={[
|
||||
{
|
||||
workId: 'puzzle-work-session-1',
|
||||
profileId: 'puzzle-profile-session-1',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '测试作者',
|
||||
workTitle: '潮雾拼图草稿',
|
||||
workDescription: '已经有可查看的首关结果。',
|
||||
levelName: '潮雾拼图',
|
||||
summary: '已经有可查看的首关结果。',
|
||||
themeTags: ['潮雾'],
|
||||
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: new Date('2026-04-22T10:00:00.000Z').toISOString(),
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
sourceSessionId: 'puzzle-session-1',
|
||||
generationStatus: 'generating',
|
||||
levels: [
|
||||
{
|
||||
levelId: 'puzzle-level-1',
|
||||
levelName: '潮雾拼图',
|
||||
pictureDescription: '潮雾港口。',
|
||||
pictureReference: null,
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-1',
|
||||
imageSrc: '/generated-puzzle-assets/session/cover.png',
|
||||
assetId: 'asset-1',
|
||||
prompt: '潮雾港口',
|
||||
actualPrompt: null,
|
||||
sourceType: 'generated',
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-1',
|
||||
coverImageSrc: '/generated-puzzle-assets/session/cover.png',
|
||||
coverAssetId: 'asset-1',
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
{
|
||||
levelId: 'puzzle-level-2',
|
||||
levelName: '灯塔',
|
||||
pictureDescription: '灯塔新关卡。',
|
||||
pictureReference: null,
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'generating',
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
onCreateType={noopCreateType}
|
||||
onOpenDraft={() => {}}
|
||||
onEnterPublished={() => {}}
|
||||
entryConfig={testEntryConfig}
|
||||
creationTypes={testCreationTypes}
|
||||
onOpenPuzzleDetail={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).not.toContain('生成中...');
|
||||
expect(html).not.toContain('creation-work-card__spinner');
|
||||
expect(html).toContain('继续创作《潮雾拼图草稿》');
|
||||
});
|
||||
|
||||
test('creation hub published work uses unified list card layout', () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<CustomWorldCreationHub
|
||||
|
||||
@@ -86,6 +86,58 @@ test('buildCreationWorkShelfItems attaches open and delete actions through shelf
|
||||
expect(onDeletePuzzle).toHaveBeenCalledWith(puzzleWork);
|
||||
});
|
||||
|
||||
test('buildCreationWorkShelfItems restores persisted generation state for puzzle and match3d drafts', () => {
|
||||
const items = buildCreationWorkShelfItems({
|
||||
rpgItems: [],
|
||||
bigFishItems: [],
|
||||
puzzleItems: [
|
||||
{
|
||||
workId: 'puzzle:generating',
|
||||
profileId: 'puzzle-profile-generating',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'puzzle-session-generating',
|
||||
authorDisplayName: '测试作者',
|
||||
levelName: '生成中拼图',
|
||||
summary: '退出产品后仍应显示生成中。',
|
||||
themeTags: [],
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'draft',
|
||||
updatedAt: '2026-05-08T00:00:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: false,
|
||||
generationStatus: 'generating',
|
||||
},
|
||||
],
|
||||
match3dItems: [
|
||||
{
|
||||
workId: 'match3d:generating',
|
||||
profileId: 'match3d-profile-generating',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'match3d-session-generating',
|
||||
gameName: '生成中抓鹅',
|
||||
themeText: '糖果厨房',
|
||||
summary: '退出产品后仍应显示生成中。',
|
||||
tags: [],
|
||||
coverImageSrc: null,
|
||||
clearCount: 18,
|
||||
difficulty: 1,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-07T00:00:00.000Z',
|
||||
publishReady: false,
|
||||
generationStatus: 'generating',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(items.find((item) => item.kind === 'puzzle')?.isGenerating).toBe(
|
||||
true,
|
||||
);
|
||||
expect(items.find((item) => item.kind === 'match3d')?.isGenerating).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('buildCreationWorkShelfItems maps baby object match local drafts', () => {
|
||||
const onOpenBabyObjectMatchDetail = vi.fn();
|
||||
const onDeleteBabyObjectMatch = vi.fn();
|
||||
|
||||
@@ -238,13 +238,19 @@ export function buildCreationWorkShelfItems(params: {
|
||||
]
|
||||
.map((item) => {
|
||||
const state = getItemState?.(item);
|
||||
const persistedIsGenerating = isPersistedCreationWorkGenerating(item);
|
||||
return state
|
||||
? {
|
||||
...item,
|
||||
isGenerating: state.isGenerating,
|
||||
isGenerating: Boolean(state.isGenerating || persistedIsGenerating),
|
||||
hasUnreadUpdate: state.hasUnreadUpdate,
|
||||
}
|
||||
: item;
|
||||
: persistedIsGenerating
|
||||
? {
|
||||
...item,
|
||||
isGenerating: true,
|
||||
}
|
||||
: item;
|
||||
})
|
||||
.sort(
|
||||
(left, right) =>
|
||||
@@ -635,7 +641,7 @@ function isCreationTypeReferenceCoverImageSrc(value?: string | null) {
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePuzzleWorkCoverImageSrc(item: PuzzleWorkSummary) {
|
||||
export function resolvePuzzleWorkCoverImageSrc(item: PuzzleWorkSummary) {
|
||||
const directCoverImageSrc = normalizeCoverImageSrc(item.coverImageSrc);
|
||||
if (
|
||||
directCoverImageSrc &&
|
||||
@@ -645,33 +651,44 @@ function resolvePuzzleWorkCoverImageSrc(item: PuzzleWorkSummary) {
|
||||
}
|
||||
|
||||
for (const level of item.levels ?? []) {
|
||||
const levelCoverImageSrc = normalizeCoverImageSrc(level.coverImageSrc);
|
||||
if (
|
||||
levelCoverImageSrc &&
|
||||
!isCreationTypeReferenceCoverImageSrc(levelCoverImageSrc)
|
||||
) {
|
||||
return levelCoverImageSrc;
|
||||
const levelImageSrc = resolvePuzzleLevelCoverImageSrc(level);
|
||||
if (levelImageSrc) {
|
||||
return levelImageSrc;
|
||||
}
|
||||
}
|
||||
|
||||
const selectedCandidateImageSrc =
|
||||
level.selectedCandidateId && level.candidates.length > 0
|
||||
? normalizeCoverImageSrc(
|
||||
level.candidates.find(
|
||||
(candidate) => candidate.candidateId === level.selectedCandidateId,
|
||||
)?.imageSrc,
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolvePuzzleLevelCoverImageSrc(
|
||||
level: NonNullable<PuzzleWorkSummary['levels']>[number],
|
||||
) {
|
||||
const levelCoverImageSrc = normalizeCoverImageSrc(level.coverImageSrc);
|
||||
if (
|
||||
levelCoverImageSrc &&
|
||||
!isCreationTypeReferenceCoverImageSrc(levelCoverImageSrc)
|
||||
) {
|
||||
return levelCoverImageSrc;
|
||||
}
|
||||
|
||||
const selectedCandidateImageSrc =
|
||||
level.selectedCandidateId && level.candidates.length > 0
|
||||
? normalizeCoverImageSrc(
|
||||
level.candidates.find(
|
||||
(candidate) => candidate.candidateId === level.selectedCandidateId,
|
||||
)?.imageSrc,
|
||||
)
|
||||
: null;
|
||||
const fallbackCandidateImageSrc = normalizeCoverImageSrc(
|
||||
level.candidates[level.candidates.length - 1]?.imageSrc,
|
||||
);
|
||||
const candidateImageSrc = selectedCandidateImageSrc || fallbackCandidateImageSrc;
|
||||
: null;
|
||||
const fallbackCandidateImageSrc = normalizeCoverImageSrc(
|
||||
level.candidates[level.candidates.length - 1]?.imageSrc,
|
||||
);
|
||||
const candidateImageSrc = selectedCandidateImageSrc || fallbackCandidateImageSrc;
|
||||
|
||||
if (
|
||||
candidateImageSrc &&
|
||||
!isCreationTypeReferenceCoverImageSrc(candidateImageSrc)
|
||||
) {
|
||||
return candidateImageSrc;
|
||||
}
|
||||
if (
|
||||
candidateImageSrc &&
|
||||
!isCreationTypeReferenceCoverImageSrc(candidateImageSrc)
|
||||
) {
|
||||
return candidateImageSrc;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -793,6 +810,31 @@ function buildPuzzleWorkShelfActions(
|
||||
};
|
||||
}
|
||||
|
||||
function isPersistedCreationWorkGenerating(item: CreationWorkShelfItem) {
|
||||
switch (item.source.kind) {
|
||||
case 'match3d':
|
||||
return item.source.item.generationStatus === 'generating';
|
||||
case 'puzzle':
|
||||
return isPersistedPuzzleDraftGenerating(item.source.item);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isPersistedPuzzleDraftGenerating(item: PuzzleWorkSummary) {
|
||||
if (item.generationStatus !== 'generating') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasUsableCover = Boolean(resolvePuzzleWorkCoverImageSrc(item));
|
||||
const hasReadyLevel = (item.levels ?? []).some((level) =>
|
||||
Boolean(resolvePuzzleLevelCoverImageSrc(level)),
|
||||
);
|
||||
|
||||
// 中文注释:作品架“生成中”只表示初始草稿还没有可查看结果;结果页追加关卡或重绘局部图片不能锁住整张草稿卡。
|
||||
return !hasUsableCover && !hasReadyLevel;
|
||||
}
|
||||
|
||||
function buildRpgWorkShelfActions(
|
||||
item: CustomWorldWorkSummary,
|
||||
adapter: RpgWorkShelfAdapter,
|
||||
|
||||
@@ -1359,7 +1359,7 @@ describe('Match3DResultView', () => {
|
||||
'img[src="/match3d-background-references/pot-fused-reference.png"]',
|
||||
);
|
||||
expect(containerImage).toBeTruthy();
|
||||
expect(containerImage?.className).toContain('w-[min(99vw,34rem)]');
|
||||
expect(containerImage?.className).toContain('w-[min(108vw,38rem)]');
|
||||
expect(containerImage?.className).toContain('-translate-x-1/2');
|
||||
expect(
|
||||
document.querySelector('.animate-spin, [class*="border-l-transparent"]'),
|
||||
|
||||
@@ -52,6 +52,19 @@ import {
|
||||
import { Match3DRuntimeShell } from './Match3DRuntimeShell';
|
||||
import { resolveGeometryAsset } from './match3dVisualAssets';
|
||||
|
||||
const runtimeAudioFeedback = vi.hoisted(() => ({
|
||||
playRuntimeMergeSound: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/runtimeAudioFeedback', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../services/runtimeAudioFeedback')>();
|
||||
return {
|
||||
...actual,
|
||||
playRuntimeMergeSound: runtimeAudioFeedback.playRuntimeMergeSound,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./Match3DPhysicsBoard', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./Match3DPhysicsBoard')>();
|
||||
return {
|
||||
@@ -82,6 +95,7 @@ afterEach(() => {
|
||||
__MATCH3D_KEEP_3D_TEST_RENDER__?: boolean;
|
||||
}
|
||||
).__MATCH3D_KEEP_3D_TEST_RENDER__;
|
||||
runtimeAudioFeedback.playRuntimeMergeSound.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -519,6 +533,318 @@ test('运行态按生成素材的相对尺寸缩放场内和托盘图片', () =>
|
||||
).toBe('scale(0.58)');
|
||||
});
|
||||
|
||||
test('点击物品乐观插入到物品栏同类后面并后移后续物品', async () => {
|
||||
const baseRun = startLocalMatch3DRun(3);
|
||||
const [appleBoard, pearTray, appleTray] = baseRun.items.slice(0, 3);
|
||||
expect(appleBoard && pearTray && appleTray).toBeTruthy();
|
||||
const run: Match3DRunSnapshot = {
|
||||
...baseRun,
|
||||
items: [
|
||||
{
|
||||
...appleBoard!,
|
||||
itemInstanceId: 'apple-3',
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'block-red-2x4',
|
||||
clickable: true,
|
||||
state: 'InBoard',
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
layer: 10,
|
||||
traySlotIndex: null,
|
||||
},
|
||||
{
|
||||
...pearTray!,
|
||||
itemInstanceId: 'apple-1',
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'block-red-2x4',
|
||||
clickable: false,
|
||||
state: 'InTray',
|
||||
traySlotIndex: 0,
|
||||
},
|
||||
{
|
||||
...appleTray!,
|
||||
itemInstanceId: 'apple-2',
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'block-red-2x4',
|
||||
clickable: false,
|
||||
state: 'InTray',
|
||||
traySlotIndex: 1,
|
||||
},
|
||||
{
|
||||
...baseRun.items[3]!,
|
||||
itemInstanceId: 'pear-1',
|
||||
itemTypeId: 'pear',
|
||||
visualKey: 'block-blue-1x2',
|
||||
clickable: false,
|
||||
state: 'InTray',
|
||||
traySlotIndex: 2,
|
||||
},
|
||||
...baseRun.items.slice(4).map((item) => ({
|
||||
...item,
|
||||
clickable: false,
|
||||
state: 'InBoard' as const,
|
||||
})),
|
||||
],
|
||||
traySlots: baseRun.traySlots.map((slot) => {
|
||||
if (slot.slotIndex === 0) {
|
||||
return {
|
||||
slotIndex: 0,
|
||||
itemInstanceId: 'apple-1',
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'block-red-2x4',
|
||||
};
|
||||
}
|
||||
if (slot.slotIndex === 1) {
|
||||
return {
|
||||
slotIndex: 1,
|
||||
itemInstanceId: 'apple-2',
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'block-red-2x4',
|
||||
};
|
||||
}
|
||||
if (slot.slotIndex === 2) {
|
||||
return {
|
||||
slotIndex: 2,
|
||||
itemInstanceId: 'pear-1',
|
||||
itemTypeId: 'pear',
|
||||
visualKey: 'block-blue-1x2',
|
||||
};
|
||||
}
|
||||
return { slotIndex: slot.slotIndex };
|
||||
}),
|
||||
};
|
||||
const onClickItem = vi.fn(async (payload: Match3DClickItemRequest) => ({
|
||||
status: 'Accepted' as const,
|
||||
acceptedItemInstanceId: payload.itemInstanceId,
|
||||
clearedItemInstanceIds: [],
|
||||
run: {
|
||||
...run,
|
||||
snapshotVersion: run.snapshotVersion + 1,
|
||||
},
|
||||
}));
|
||||
const onOptimisticRunChange = vi.fn();
|
||||
render(
|
||||
<Match3DRuntimeShell
|
||||
run={run}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={onOptimisticRunChange}
|
||||
onClickItem={onClickItem}
|
||||
/>,
|
||||
);
|
||||
const board = screen.getByTestId('match3d-board');
|
||||
mockMatch3DBoardRect();
|
||||
mockMatch3DPointerCapture(board);
|
||||
Object.defineProperty(screen.getAllByTestId('match3d-tray-slot')[3]!, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
bottom: 530,
|
||||
height: 56,
|
||||
left: 220,
|
||||
right: 276,
|
||||
top: 474,
|
||||
width: 56,
|
||||
x: 220,
|
||||
y: 474,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
});
|
||||
|
||||
const point = toMatch3DBoardClientPoint(run.items[0]!);
|
||||
fireMatch3DBoardPointer(board, 'pointerdown', point, 14);
|
||||
fireMatch3DBoardPointer(board, 'pointerup', point, 14);
|
||||
|
||||
await waitFor(() => expect(onOptimisticRunChange).toHaveBeenCalled());
|
||||
const optimisticRun = onOptimisticRunChange.mock.calls[0]?.[0] as
|
||||
| Match3DRunSnapshot
|
||||
| undefined;
|
||||
expect(optimisticRun?.traySlots.map((slot) => slot.itemInstanceId ?? null)).toEqual([
|
||||
'apple-1',
|
||||
'apple-2',
|
||||
'apple-3',
|
||||
'pear-1',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
expect(
|
||||
optimisticRun?.items.find((item) => item.itemInstanceId === 'apple-3')
|
||||
?.traySlotIndex,
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
test('三消确认后物品栏播放合成动画并隐藏权威快照中已清除的槽位', async () => {
|
||||
const baseRun = startLocalMatch3DRun(1);
|
||||
const [first, second, third, fourth] = baseRun.items.slice(0, 4);
|
||||
expect(first && second && third).toBeTruthy();
|
||||
const run: Match3DRunSnapshot = {
|
||||
...baseRun,
|
||||
totalItemCount: 4,
|
||||
items: [
|
||||
{
|
||||
...first!,
|
||||
itemInstanceId: 'apple-1',
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'block-red-2x4',
|
||||
state: 'InTray',
|
||||
clickable: false,
|
||||
traySlotIndex: 0,
|
||||
},
|
||||
{
|
||||
...second!,
|
||||
itemInstanceId: 'apple-2',
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'block-red-2x4',
|
||||
state: 'InTray',
|
||||
clickable: false,
|
||||
traySlotIndex: 1,
|
||||
},
|
||||
{
|
||||
...third!,
|
||||
itemInstanceId: 'apple-3',
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'block-red-2x4',
|
||||
state: 'InBoard',
|
||||
clickable: true,
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
layer: 10,
|
||||
traySlotIndex: null,
|
||||
},
|
||||
{
|
||||
...(fourth ?? third!),
|
||||
itemInstanceId: 'pear-1',
|
||||
itemTypeId: 'pear',
|
||||
visualKey: 'block-blue-1x2',
|
||||
state: 'InTray',
|
||||
clickable: false,
|
||||
traySlotIndex: 2,
|
||||
},
|
||||
],
|
||||
traySlots: baseRun.traySlots.map((slot) => {
|
||||
if (slot.slotIndex === 0) {
|
||||
return {
|
||||
slotIndex: 0,
|
||||
itemInstanceId: 'apple-1',
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'block-red-2x4',
|
||||
};
|
||||
}
|
||||
if (slot.slotIndex === 1) {
|
||||
return {
|
||||
slotIndex: 1,
|
||||
itemInstanceId: 'apple-2',
|
||||
itemTypeId: 'apple',
|
||||
visualKey: 'block-red-2x4',
|
||||
};
|
||||
}
|
||||
if (slot.slotIndex === 2) {
|
||||
return {
|
||||
slotIndex: 2,
|
||||
itemInstanceId: 'pear-1',
|
||||
itemTypeId: 'pear',
|
||||
visualKey: 'block-blue-1x2',
|
||||
};
|
||||
}
|
||||
return { slotIndex: slot.slotIndex };
|
||||
}),
|
||||
};
|
||||
const acceptedRun: Match3DRunSnapshot = {
|
||||
...run,
|
||||
snapshotVersion: run.snapshotVersion + 1,
|
||||
clearedItemCount: 3,
|
||||
items: run.items.map((item) =>
|
||||
item.itemTypeId === 'apple'
|
||||
? {
|
||||
...item,
|
||||
state: 'Cleared' as const,
|
||||
clickable: false,
|
||||
traySlotIndex: null,
|
||||
}
|
||||
: { ...item, traySlotIndex: 0 },
|
||||
),
|
||||
traySlots: run.traySlots.map((slot) =>
|
||||
slot.slotIndex === 0
|
||||
? {
|
||||
slotIndex: 0,
|
||||
itemInstanceId: 'pear-1',
|
||||
itemTypeId: 'pear',
|
||||
visualKey: 'block-blue-1x2',
|
||||
}
|
||||
: { slotIndex: slot.slotIndex },
|
||||
),
|
||||
};
|
||||
const onClickItem = vi.fn(async (payload: Match3DClickItemRequest) => ({
|
||||
status: 'Accepted' as const,
|
||||
acceptedItemInstanceId: payload.itemInstanceId,
|
||||
clearedItemInstanceIds: ['apple-1', 'apple-2', 'apple-3'],
|
||||
run: acceptedRun,
|
||||
}));
|
||||
const onOptimisticRunChange = vi.fn((nextRun: Match3DRunSnapshot) => {
|
||||
rerender(
|
||||
<Match3DRuntimeShell
|
||||
run={nextRun}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={onOptimisticRunChange}
|
||||
onClickItem={onClickItem}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
const { rerender } = render(
|
||||
<Match3DRuntimeShell
|
||||
run={run}
|
||||
onBack={vi.fn()}
|
||||
onRestart={vi.fn()}
|
||||
onOptimisticRunChange={onOptimisticRunChange}
|
||||
onClickItem={onClickItem}
|
||||
/>,
|
||||
);
|
||||
const board = screen.getByTestId('match3d-board');
|
||||
mockMatch3DBoardRect();
|
||||
mockMatch3DPointerCapture(board);
|
||||
screen.getAllByTestId('match3d-tray-slot').forEach((slot, index) => {
|
||||
Object.defineProperty(slot, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
bottom: 530,
|
||||
height: 56,
|
||||
left: 52 + index * 58,
|
||||
right: 108 + index * 58,
|
||||
top: 474,
|
||||
width: 56,
|
||||
x: 52 + index * 58,
|
||||
y: 474,
|
||||
toJSON: () => ({}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
const point = toMatch3DBoardClientPoint(run.items[2]!);
|
||||
fireMatch3DBoardPointer(board, 'pointerdown', point, 15);
|
||||
fireMatch3DBoardPointer(board, 'pointerup', point, 15);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('match3d-tray-clear-animation')).toBeTruthy(),
|
||||
);
|
||||
expect(screen.getAllByTestId('match3d-tray-clear-token')).toHaveLength(3);
|
||||
expect(screen.getByTestId('match3d-merge-feedback')).toBeTruthy();
|
||||
expect(screen.queryByTestId('match3d-merge-feedback')?.querySelector('svg')).toBeNull();
|
||||
expect(runtimeAudioFeedback.playRuntimeMergeSound).toHaveBeenCalledTimes(1);
|
||||
const latestRun = onOptimisticRunChange.mock.calls.at(-1)?.[0] as
|
||||
| Match3DRunSnapshot
|
||||
| undefined;
|
||||
expect(latestRun?.traySlots.map((slot) => slot.itemInstanceId ?? null)).toEqual([
|
||||
'pear-1',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
});
|
||||
|
||||
test('点击物品时播放飞入底部栏位动画并使用第一张物品视图', async () => {
|
||||
const run = startLocalMatch3DRun(1);
|
||||
const clickableItem = run.items.find((item) => item.clickable)!;
|
||||
@@ -1025,9 +1351,10 @@ test('运行态会换签并渲染抓大鹅中心容器 UI 图', async () => {
|
||||
const containerImage = screen.getByTestId(
|
||||
'match3d-container-image',
|
||||
) as HTMLImageElement;
|
||||
expect(containerImage.className).toContain('w-[min(99vw,34rem)]');
|
||||
expect(containerImage.className).toContain('w-[min(116vw,42rem)]');
|
||||
expect(containerImage.className).toContain('h-auto');
|
||||
expect(containerImage.className).toContain('left-1/2');
|
||||
expect(containerImage.className).toContain('top-[54%]');
|
||||
expect(containerImage.className).toContain('-translate-x-1/2');
|
||||
expect(screen.getByTestId('match3d-board').className).toContain(
|
||||
'bg-transparent',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import {
|
||||
findMatch3DHitItem,
|
||||
isPointInsideMatch3DItemAlphaHotspot,
|
||||
resolveMatch3DItemSizeScale,
|
||||
type Match3DResolvedImageSourceEntry,
|
||||
} from './match3dHotspot';
|
||||
import { resolveRenderableItemFrame } from './match3dRuntimePresentation';
|
||||
|
||||
function buildMatch3DHotspotRun(): Match3DRunSnapshot {
|
||||
return {
|
||||
runId: 'hotspot-run',
|
||||
profileId: 'hotspot-profile',
|
||||
status: 'Running',
|
||||
snapshotVersion: 1,
|
||||
startedAtMs: 1,
|
||||
durationLimitMs: 600_000,
|
||||
remainingMs: 600_000,
|
||||
clearCount: 1,
|
||||
totalItemCount: 1,
|
||||
clearedItemCount: 0,
|
||||
traySlots: Array.from({ length: 7 }, (_, slotIndex) => ({ slotIndex })),
|
||||
items: [
|
||||
{
|
||||
clickable: true,
|
||||
itemInstanceId: 'alpha-hotspot-item',
|
||||
itemTypeId: 'match3d-type-01',
|
||||
visualKey: 'block-red-2x2',
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
radius: 0.1,
|
||||
layer: 1,
|
||||
state: 'InBoard',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
test('透明像素不作为抓大鹅物品点击热区', () => {
|
||||
const run = buildMatch3DHotspotRun();
|
||||
const item = run.items[0]!;
|
||||
const mask = {
|
||||
width: 4,
|
||||
height: 4,
|
||||
alpha: new Uint8ClampedArray([
|
||||
0, 0, 0, 0,
|
||||
0, 255, 255, 0,
|
||||
0, 255, 255, 0,
|
||||
0, 0, 0, 0,
|
||||
]),
|
||||
};
|
||||
const imageSourceEntriesByType = new Map<string, Match3DResolvedImageSourceEntry[]>([
|
||||
[
|
||||
'match3d-type-01',
|
||||
[
|
||||
{
|
||||
source: '/generated-match3d-assets/item-01.png',
|
||||
resolvedSource: 'https://oss.example.com/item-01.png',
|
||||
},
|
||||
],
|
||||
],
|
||||
]);
|
||||
const alphaHitMasks = new Map([
|
||||
['/generated-match3d-assets/item-01.png', mask],
|
||||
]);
|
||||
const itemSizeByType = new Map([['match3d-type-01', '大' as const]]);
|
||||
const frame = resolveRenderableItemFrame(item);
|
||||
|
||||
expect(
|
||||
findMatch3DHitItem(run, frame.x - frame.radius * 0.6, 0.5, {
|
||||
alphaHitMasks,
|
||||
imageSourceEntriesByType,
|
||||
itemSizeByType,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
findMatch3DHitItem(run, 0.5, 0.5, {
|
||||
alphaHitMasks,
|
||||
imageSourceEntriesByType,
|
||||
itemSizeByType,
|
||||
})?.itemInstanceId,
|
||||
).toBe('alpha-hotspot-item');
|
||||
});
|
||||
|
||||
test('小尺寸物品只在缩放后的非透明主体内命中', () => {
|
||||
const item = buildMatch3DHotspotRun().items[0]!;
|
||||
const mask = {
|
||||
width: 2,
|
||||
height: 2,
|
||||
alpha: new Uint8ClampedArray([255, 255, 255, 255]),
|
||||
};
|
||||
|
||||
expect(
|
||||
isPointInsideMatch3DItemAlphaHotspot({
|
||||
item,
|
||||
pointX: 0.5,
|
||||
pointY: 0.5,
|
||||
mask,
|
||||
itemSize: '小',
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isPointInsideMatch3DItemAlphaHotspot({
|
||||
item,
|
||||
pointX: 0.38,
|
||||
pointY: 0.5,
|
||||
mask,
|
||||
itemSize: '小',
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test('抓大鹅生成物品大和中也会做一定程度缩小', () => {
|
||||
expect(resolveMatch3DItemSizeScale('大')).toBeLessThan(1);
|
||||
expect(resolveMatch3DItemSizeScale('中')).toBeLessThan(0.78);
|
||||
expect(resolveMatch3DItemSizeScale('大')).toBeGreaterThan(
|
||||
resolveMatch3DItemSizeScale('中'),
|
||||
);
|
||||
expect(resolveMatch3DItemSizeScale('中')).toBeGreaterThan(
|
||||
resolveMatch3DItemSizeScale('小'),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import type {
|
||||
Match3DItemSnapshot,
|
||||
Match3DRunSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import { isGeneratedLegacyPath } from '../../services/assetReadUrlService';
|
||||
import {
|
||||
isItemState,
|
||||
resolveRenderableItemFrame,
|
||||
} from './match3dRuntimePresentation';
|
||||
|
||||
export type Match3DGeneratedItemRelativeSize = '大' | '中' | '小';
|
||||
|
||||
export type Match3DAlphaHitMask = {
|
||||
width: number;
|
||||
height: number;
|
||||
alpha: Uint8ClampedArray;
|
||||
};
|
||||
|
||||
export type Match3DResolvedImageSourceEntry = {
|
||||
source: string;
|
||||
resolvedSource: string;
|
||||
};
|
||||
|
||||
const MATCH3D_HIT_ALPHA_THRESHOLD = 8;
|
||||
|
||||
function isPointInsideCircle(
|
||||
pointX: number,
|
||||
pointY: number,
|
||||
item: Match3DItemSnapshot,
|
||||
) {
|
||||
const frame = resolveRenderableItemFrame(item);
|
||||
return Math.hypot(pointX - frame.x, pointY - frame.y) <= frame.radius;
|
||||
}
|
||||
|
||||
function clampMatch3DHitPixelIndex(value: number, size: number) {
|
||||
return Math.min(size - 1, Math.max(0, Math.floor(value * size)));
|
||||
}
|
||||
|
||||
export function resolveMatch3DItemSizeScale(
|
||||
itemSize: Match3DGeneratedItemRelativeSize | undefined,
|
||||
) {
|
||||
if (itemSize === '小') {
|
||||
return 0.58;
|
||||
}
|
||||
if (itemSize === '中') {
|
||||
return 0.68;
|
||||
}
|
||||
return 0.88;
|
||||
}
|
||||
|
||||
function isPointInsideAlphaHitMask(
|
||||
localX: number,
|
||||
localY: number,
|
||||
mask: Match3DAlphaHitMask,
|
||||
itemSize: Match3DGeneratedItemRelativeSize,
|
||||
) {
|
||||
if (
|
||||
mask.width <= 0 ||
|
||||
mask.height <= 0 ||
|
||||
mask.alpha.length < mask.width * mask.height ||
|
||||
localX < 0 ||
|
||||
localX > 1 ||
|
||||
localY < 0 ||
|
||||
localY > 1
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const aspectRatio = mask.width / mask.height;
|
||||
const containWidth = aspectRatio >= 1 ? 1 : aspectRatio;
|
||||
const containHeight = aspectRatio >= 1 ? 1 / aspectRatio : 1;
|
||||
const imageScale = resolveMatch3DItemSizeScale(itemSize);
|
||||
const renderedWidth = containWidth * imageScale;
|
||||
const renderedHeight = containHeight * imageScale;
|
||||
const imageLeft = (1 - renderedWidth) / 2;
|
||||
const imageTop = (1 - renderedHeight) / 2;
|
||||
|
||||
if (
|
||||
localX < imageLeft ||
|
||||
localX > imageLeft + renderedWidth ||
|
||||
localY < imageTop ||
|
||||
localY > imageTop + renderedHeight
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const imageX = (localX - imageLeft) / renderedWidth;
|
||||
const imageY = (localY - imageTop) / renderedHeight;
|
||||
const pixelX = clampMatch3DHitPixelIndex(imageX, mask.width);
|
||||
const pixelY = clampMatch3DHitPixelIndex(imageY, mask.height);
|
||||
return (
|
||||
(mask.alpha[pixelY * mask.width + pixelX] ?? 0) >
|
||||
MATCH3D_HIT_ALPHA_THRESHOLD
|
||||
);
|
||||
}
|
||||
|
||||
export function isPointInsideMatch3DItemAlphaHotspot({
|
||||
item,
|
||||
pointX,
|
||||
pointY,
|
||||
mask,
|
||||
itemSize,
|
||||
}: {
|
||||
item: Match3DItemSnapshot;
|
||||
pointX: number;
|
||||
pointY: number;
|
||||
mask: Match3DAlphaHitMask;
|
||||
itemSize: Match3DGeneratedItemRelativeSize;
|
||||
}) {
|
||||
const frame = resolveRenderableItemFrame(item);
|
||||
const diameter = frame.radius * 2;
|
||||
if (diameter <= 0) {
|
||||
return false;
|
||||
}
|
||||
return isPointInsideAlphaHitMask(
|
||||
(pointX - (frame.x - frame.radius)) / diameter,
|
||||
(pointY - (frame.y - frame.radius)) / diameter,
|
||||
mask,
|
||||
itemSize,
|
||||
);
|
||||
}
|
||||
|
||||
export function hashMatch3DString(value: string) {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
export function resolveMatch3DImageSourceEntryForItem(
|
||||
item: Match3DItemSnapshot,
|
||||
imageSourceEntriesByType: ReadonlyMap<
|
||||
string,
|
||||
readonly Match3DResolvedImageSourceEntry[]
|
||||
>,
|
||||
) {
|
||||
const sources = imageSourceEntriesByType.get(item.itemTypeId);
|
||||
if (!sources || sources.length <= 0) {
|
||||
return null;
|
||||
}
|
||||
return sources[hashMatch3DString(item.itemInstanceId) % sources.length] ?? null;
|
||||
}
|
||||
|
||||
export function findMatch3DHitItem(
|
||||
run: Match3DRunSnapshot,
|
||||
pointX: number,
|
||||
pointY: number,
|
||||
options: {
|
||||
imageSourceEntriesByType?: ReadonlyMap<
|
||||
string,
|
||||
readonly Match3DResolvedImageSourceEntry[]
|
||||
>;
|
||||
alphaHitMasks?: ReadonlyMap<string, Match3DAlphaHitMask>;
|
||||
failedAlphaHitMaskSources?: ReadonlySet<string>;
|
||||
itemSizeByType?: ReadonlyMap<string, Match3DGeneratedItemRelativeSize>;
|
||||
} = {},
|
||||
) {
|
||||
return run.items
|
||||
.filter((item) => {
|
||||
if (
|
||||
!isItemState(item.state, 'in_board') ||
|
||||
!item.clickable ||
|
||||
!isPointInsideCircle(pointX, pointY, item)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const imageSourceEntry = resolveMatch3DImageSourceEntryForItem(
|
||||
item,
|
||||
options.imageSourceEntriesByType ?? new Map(),
|
||||
);
|
||||
const mask = imageSourceEntry
|
||||
? options.alphaHitMasks?.get(imageSourceEntry.source)
|
||||
: null;
|
||||
if (!mask) {
|
||||
return (
|
||||
!imageSourceEntry ||
|
||||
!isGeneratedLegacyPath(imageSourceEntry.source) ||
|
||||
options.failedAlphaHitMaskSources?.has(imageSourceEntry.source) ===
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
return isPointInsideMatch3DItemAlphaHotspot({
|
||||
item,
|
||||
pointX,
|
||||
pointY,
|
||||
mask,
|
||||
itemSize: options.itemSizeByType?.get(item.itemTypeId) ?? '大',
|
||||
});
|
||||
})
|
||||
.sort((left, right) => right.layer - left.layer)[0];
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export const MATCH3D_RUNTIME_GLASS_TRAY_SLOT_CLASS =
|
||||
'relative z-0 h-14 min-w-0 rounded-xl border border-white/52 bg-white/56 p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.44)] sm:h-16';
|
||||
|
||||
export const MATCH3D_RUNTIME_STAGE_CLASS =
|
||||
'relative mt-3 flex min-h-0 flex-1 items-center justify-center';
|
||||
'relative mt-5 flex min-h-0 flex-1 items-center justify-center';
|
||||
|
||||
export const MATCH3D_RUNTIME_BOARD_BASE_CLASS =
|
||||
'relative aspect-square max-w-full';
|
||||
@@ -41,7 +41,7 @@ export const MATCH3D_RUNTIME_BOARD_FALLBACK_CLASS =
|
||||
'overflow-hidden rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)]';
|
||||
|
||||
export const MATCH3D_RUNTIME_CONTAINER_IMAGE_CLASS =
|
||||
'pointer-events-none absolute left-1/2 top-1/2 z-0 h-auto w-[min(99vw,34rem)] max-w-none -translate-x-1/2 -translate-y-1/2 object-contain drop-shadow-[0_22px_42px_rgba(15,23,42,0.28)]';
|
||||
'pointer-events-none absolute left-1/2 top-[54%] z-0 h-auto w-[min(116vw,42rem)] max-w-none -translate-x-1/2 -translate-y-1/2 object-contain drop-shadow-[0_22px_42px_rgba(15,23,42,0.28)]';
|
||||
|
||||
export const MATCH3D_RUNTIME_CONTAINER_PLACEHOLDER_CLASS =
|
||||
'pointer-events-none absolute inset-[7%] z-0 rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,137 @@
|
||||
import { Loader2, Sparkles } from 'lucide-react';
|
||||
|
||||
export type PuzzleOnboardingPhase = 'input' | 'generating' | 'generated';
|
||||
|
||||
type PuzzleOnboardingViewProps = {
|
||||
prompt: string;
|
||||
phase: PuzzleOnboardingPhase;
|
||||
error: string | null;
|
||||
copy: string;
|
||||
onPromptChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onSkip: () => void;
|
||||
};
|
||||
|
||||
export function PuzzleOnboardingView({
|
||||
prompt,
|
||||
phase,
|
||||
error,
|
||||
copy,
|
||||
onPromptChange,
|
||||
onSubmit,
|
||||
onSkip,
|
||||
}: PuzzleOnboardingViewProps) {
|
||||
const isGenerating = phase === 'generating';
|
||||
const isGenerated = phase === 'generated';
|
||||
const canSubmit = Boolean(prompt.trim()) && !isGenerating && !isGenerated;
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-[radial-gradient(circle_at_30%_15%,rgba(251,191,36,0.22),transparent_30%),linear-gradient(135deg,#0f172a,#111827_46%,#1e1b4b)] px-4 py-8 text-white">
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.045)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:38px_38px] opacity-30" />
|
||||
<button
|
||||
type="button"
|
||||
disabled={isGenerating}
|
||||
onClick={onSkip}
|
||||
className="absolute right-4 top-4 z-10 inline-flex min-h-10 items-center justify-center rounded-full border border-white/14 bg-black/24 px-4 text-sm font-black text-white/86 shadow-[0_12px_28px_rgba(0,0,0,0.22)] backdrop-blur transition hover:border-amber-200/45 hover:text-amber-100 disabled:cursor-not-allowed disabled:opacity-45 sm:right-6 sm:top-6"
|
||||
>
|
||||
跳过
|
||||
</button>
|
||||
<section className="relative flex w-full max-w-[34rem] flex-col items-center gap-5 text-center">
|
||||
<div className="grid h-14 w-14 place-items-center rounded-[1.2rem] border border-amber-200/32 bg-amber-200/14 text-amber-100 shadow-[0_18px_48px_rgba(251,191,36,0.18)]">
|
||||
{isGenerating ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-6 w-6" />
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-[2rem] font-black leading-tight sm:text-[2.85rem]">
|
||||
{copy}
|
||||
</h1>
|
||||
<form
|
||||
className="flex w-full flex-col gap-3"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
onSubmit();
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
value={prompt}
|
||||
disabled={isGenerating || isGenerated}
|
||||
onChange={(event) => onPromptChange(event.target.value)}
|
||||
placeholder="把你的梦讲给我听吧"
|
||||
rows={4}
|
||||
className="min-h-32 w-full resize-none rounded-[1.25rem] border border-white/14 bg-black/28 px-4 py-4 text-base font-semibold leading-7 text-white shadow-[0_18px_50px_rgba(0,0,0,0.24)] outline-none backdrop-blur placeholder:text-white/42 focus:border-amber-200/70 focus:ring-2 focus:ring-amber-200/20 disabled:opacity-70"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className="inline-flex min-h-12 items-center justify-center gap-2 rounded-[1rem] bg-amber-200 px-5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
生成
|
||||
</>
|
||||
) : (
|
||||
'生成'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
{error ? (
|
||||
<div className="w-full rounded-[1rem] border border-red-300/30 bg-red-500/14 px-4 py-3 text-sm font-semibold text-red-50">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type PuzzleOnboardingLoginOverlayProps = {
|
||||
isSaving: boolean;
|
||||
error: string | null;
|
||||
copy: string;
|
||||
onLogin: () => void;
|
||||
};
|
||||
|
||||
export function PuzzleOnboardingLoginOverlay({
|
||||
isSaving,
|
||||
error,
|
||||
copy,
|
||||
onLogin,
|
||||
}: PuzzleOnboardingLoginOverlayProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-slate-950/72 px-4 py-6 text-white backdrop-blur-md">
|
||||
<section className="flex w-full max-w-[24rem] flex-col items-center gap-5 rounded-[1.35rem] border border-white/14 bg-slate-950/94 px-5 py-6 text-center shadow-[0_28px_90px_rgba(0,0,0,0.5)]">
|
||||
<div className="grid h-12 w-12 place-items-center rounded-[1rem] bg-amber-200 text-slate-950">
|
||||
{isSaving ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-2xl font-black leading-tight">{copy}</h2>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isSaving}
|
||||
onClick={onLogin}
|
||||
className="inline-flex min-h-12 w-full items-center justify-center gap-2 rounded-[1rem] bg-amber-200 px-5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-45"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
注册账号 / 登录
|
||||
</>
|
||||
) : (
|
||||
'注册账号 / 登录'
|
||||
)}
|
||||
</button>
|
||||
{error ? (
|
||||
<div className="w-full rounded-[1rem] border border-red-300/30 bg-red-500/14 px-4 py-3 text-sm font-semibold text-red-50">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -535,6 +535,60 @@ test('puzzle workspace hides prompt and cost when AI redraw is off', async () =>
|
||||
});
|
||||
});
|
||||
|
||||
test('puzzle workspace submits history image when AI redraw is off', async () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
vi.mocked(puzzleAssetClient.listHistoryAssets).mockResolvedValue([
|
||||
{
|
||||
assetObjectId: 'asset-history-1',
|
||||
assetKind: 'puzzle_cover_image',
|
||||
imageSrc: '/generated-puzzle-assets/history/image.png',
|
||||
ownerUserId: 'user-1',
|
||||
ownerLabel: '账号 user-1',
|
||||
profileId: null,
|
||||
entityId: 'puzzle-session-1',
|
||||
createdAt: '1713686400.000000Z',
|
||||
updatedAt: '1713686400.000000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onCreateFromForm={onCreateFromForm}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择历史图片' }));
|
||||
const picker = await screen.findByRole('dialog', {
|
||||
name: '选择历史图片',
|
||||
});
|
||||
fireEvent.click(
|
||||
await within(picker).findByRole('button', { name: /image\.png/u }),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog', { name: '选择历史图片' })).toBeNull();
|
||||
});
|
||||
|
||||
const aiRedrawSwitch = screen.getByRole('switch', { name: 'AI重绘' });
|
||||
fireEvent.click(aiRedrawSwitch);
|
||||
expect(screen.queryByLabelText('画面AI重绘要求(提示词)')).toBeNull();
|
||||
expect(screen.queryByText('消耗2泥点')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u }));
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith({
|
||||
seedText: '历史素材 · image.png',
|
||||
pictureDescription: '历史素材 · image.png',
|
||||
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
|
||||
referenceImageSrcs: [],
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('puzzle workspace submits uploaded reference image when AI redraw is on', async () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
const uploadedDataUrl = 'data:image/png;base64,uploaded-square';
|
||||
|
||||
@@ -317,6 +317,7 @@ describe('PuzzleResultView', () => {
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
candidateCount: 1,
|
||||
shouldAutoNameLevel: false,
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
summary: '一套雨夜猫街主题拼图。',
|
||||
@@ -466,6 +467,7 @@ describe('PuzzleResultView', () => {
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
candidateCount: 1,
|
||||
shouldAutoNameLevel: true,
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
summary: '一套雨夜猫街主题拼图。',
|
||||
@@ -485,6 +487,42 @@ describe('PuzzleResultView', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('requests automatic level naming when generating an unnamed level image', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1_775_000_000_000);
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
/>,
|
||||
);
|
||||
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByRole('button', { name: /新增关卡/u }));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
fireEvent.change(within(dialog).getByLabelText('画面描述'), {
|
||||
target: { value: '新关卡里有一座发光钟楼。' },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: /生成画面/u }));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: '确认消耗泥点' })).getByRole(
|
||||
'button',
|
||||
{ name: '确定' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_images',
|
||||
levelId: 'puzzle-level-1775000000000-2',
|
||||
promptText: '新关卡里有一座发光钟楼。',
|
||||
shouldAutoNameLevel: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps generation progress visible after closing and reopening level dialog', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
@@ -567,6 +605,180 @@ describe('PuzzleResultView', () => {
|
||||
).toHaveProperty('disabled', true);
|
||||
});
|
||||
|
||||
test('keeps level controls enabled while regenerating the UI background', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
isBusy={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
|
||||
target: { value: '雨夜猫街竖屏拼图UI背景' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: /确认消耗泥点/u })).getByRole(
|
||||
'button',
|
||||
{ name: '确定' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(onExecuteAction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: 'generate_puzzle_ui_background',
|
||||
promptText: '雨夜猫街竖屏拼图UI背景',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('button', { name: /生成中/u }),
|
||||
).toHaveProperty('disabled', true);
|
||||
|
||||
openPuzzleLevelsTab();
|
||||
const addLevelButton = screen.getByRole('button', { name: /新增关卡/u });
|
||||
expect(addLevelButton).toHaveProperty('disabled', false);
|
||||
fireEvent.click(addLevelButton);
|
||||
expect(screen.getByRole('dialog', { name: '关卡详情' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('restores UI background generate button when background generation fails', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
const { rerender } = render(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
isBusy={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '素材配置' }));
|
||||
fireEvent.change(screen.getByLabelText('拼图UI背景提示词'), {
|
||||
target: { value: '雨夜猫街竖屏拼图UI背景' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成UI背景/u }));
|
||||
fireEvent.click(
|
||||
within(screen.getByRole('dialog', { name: /确认消耗泥点/u })).getByRole(
|
||||
'button',
|
||||
{ name: '确定' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /生成中/u })).toHaveProperty(
|
||||
'disabled',
|
||||
true,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<PuzzleResultView
|
||||
session={createSession()}
|
||||
error="UI背景生成失败"
|
||||
onBack={() => {}}
|
||||
onExecuteAction={onExecuteAction}
|
||||
isBusy={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const generateButton = screen.getByRole('button', { name: /生成UI背景/u });
|
||||
expect(generateButton).toHaveProperty('disabled', false);
|
||||
expect(screen.queryByRole('button', { name: /生成中/u })).toBeNull();
|
||||
});
|
||||
|
||||
test('keeps the current level dialog open when another level generation completes', () => {
|
||||
const base = createSession();
|
||||
const firstLevel = base.draft!.levels![0]!;
|
||||
const generatingSecondLevel = {
|
||||
...firstLevel,
|
||||
levelId: 'puzzle-level-2',
|
||||
levelName: '第二关',
|
||||
pictureDescription: '第二关画面正在生成。',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'generating' as const,
|
||||
};
|
||||
const localThirdLevel = {
|
||||
...firstLevel,
|
||||
levelId: 'puzzle-level-3',
|
||||
levelName: '第三关',
|
||||
pictureDescription: '第三关初稿。',
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'idle' as const,
|
||||
};
|
||||
const completedSecondLevel = {
|
||||
...generatingSecondLevel,
|
||||
candidates: [
|
||||
{
|
||||
candidateId: 'candidate-level-2',
|
||||
imageSrc: '/puzzle/level-2.png',
|
||||
assetId: 'asset-level-2',
|
||||
prompt: '第二关画面',
|
||||
actualPrompt: null,
|
||||
sourceType: 'generated' as const,
|
||||
selected: true,
|
||||
},
|
||||
],
|
||||
selectedCandidateId: 'candidate-level-2',
|
||||
coverImageSrc: '/puzzle/level-2.png',
|
||||
coverAssetId: 'asset-level-2',
|
||||
generationStatus: 'ready' as const,
|
||||
};
|
||||
|
||||
const { rerender } = render(
|
||||
<PuzzleResultView
|
||||
session={createSession({
|
||||
draft: {
|
||||
...base.draft!,
|
||||
levels: [firstLevel, generatingSecondLevel, localThirdLevel],
|
||||
},
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
openPuzzleLevelsTab();
|
||||
fireEvent.click(screen.getByText('第三关'));
|
||||
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
fireEvent.change(within(dialog).getByLabelText('画面描述'), {
|
||||
target: { value: '正在编辑第三关的信息。' },
|
||||
});
|
||||
|
||||
rerender(
|
||||
<PuzzleResultView
|
||||
session={createSession({
|
||||
draft: {
|
||||
...base.draft!,
|
||||
levels: [firstLevel, completedSecondLevel],
|
||||
},
|
||||
updatedAt: '2026-05-14T10:00:00.000Z',
|
||||
})}
|
||||
onBack={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const currentDialog = screen.getByRole('dialog', { name: '关卡详情' });
|
||||
expect(within(currentDialog).getByLabelText('关卡名称')).toHaveProperty(
|
||||
'value',
|
||||
'第三关',
|
||||
);
|
||||
expect(within(currentDialog).getByLabelText('画面描述')).toHaveProperty(
|
||||
'value',
|
||||
'正在编辑第三关的信息。',
|
||||
);
|
||||
expect(within(currentDialog).queryByDisplayValue('第二关')).toBeNull();
|
||||
});
|
||||
|
||||
test('publishes with work info and serialized levels', () => {
|
||||
const onExecuteAction = vi.fn();
|
||||
|
||||
@@ -1053,6 +1265,7 @@ describe('PuzzleResultView', () => {
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
candidateCount: 1,
|
||||
shouldAutoNameLevel: false,
|
||||
workTitle: '暖灯猫街作品',
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
summary: '一套雨夜猫街主题拼图。',
|
||||
|
||||
@@ -24,9 +24,9 @@ import type {
|
||||
PuzzleResultDraft,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import { resolvePuzzleUiBackgroundSource } from '../../services/puzzle-runtime/puzzleUiBackgroundSource';
|
||||
import { updatePuzzleWork } from '../../services/puzzle-works';
|
||||
import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works/puzzleHistoryAsset';
|
||||
import { resolvePuzzleUiBackgroundSource } from '../../services/puzzle-runtime/puzzleUiBackgroundSource';
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import PuzzleHistoryAssetPickerDialog from '../puzzle-agent/PuzzleHistoryAssetPickerDialog';
|
||||
@@ -93,6 +93,11 @@ type PuzzleLevelGenerationRuntime = {
|
||||
estimateSeconds: number;
|
||||
};
|
||||
|
||||
type PuzzleUiBackgroundGenerationState = {
|
||||
levelId: string;
|
||||
prompt: string;
|
||||
} | null;
|
||||
|
||||
function resolvePuzzleLevelGenerationProgress(
|
||||
level: PuzzleDraftLevel,
|
||||
runtime: PuzzleLevelGenerationRuntime | null,
|
||||
@@ -1409,16 +1414,22 @@ function PuzzleUiAssetsTab({
|
||||
editState,
|
||||
imageRefreshKey,
|
||||
isBusy,
|
||||
uiBackgroundGeneration,
|
||||
onChange,
|
||||
onGenerate,
|
||||
}: {
|
||||
editState: DraftEditState;
|
||||
imageRefreshKey: string;
|
||||
isBusy: boolean;
|
||||
uiBackgroundGeneration: PuzzleUiBackgroundGenerationState;
|
||||
onChange: (nextState: DraftEditState) => void;
|
||||
onGenerate: (prompt: string) => void;
|
||||
}) {
|
||||
const firstLevel = editState.levels[0] ?? null;
|
||||
const isGeneratingUiBackground = Boolean(
|
||||
firstLevel &&
|
||||
uiBackgroundGeneration?.levelId === firstLevel.levelId,
|
||||
);
|
||||
const formalImageSrc = firstLevel ? resolveLevelFormalImageSrc(firstLevel) : '';
|
||||
const defaultPrompt = buildDefaultPuzzleUiBackgroundPrompt(
|
||||
editState,
|
||||
@@ -1490,21 +1501,30 @@ function PuzzleUiAssetsTab({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!firstLevel || !normalizedPrompt || isBusy}
|
||||
disabled={
|
||||
!firstLevel ||
|
||||
!normalizedPrompt ||
|
||||
isBusy ||
|
||||
isGeneratingUiBackground
|
||||
}
|
||||
onClick={() => {
|
||||
if (!firstLevel || !normalizedPrompt) {
|
||||
return;
|
||||
}
|
||||
setIsCostConfirmOpen(true);
|
||||
}}
|
||||
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 ${!firstLevel || !normalizedPrompt || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
className={`platform-button platform-button--primary min-h-11 justify-center gap-2 px-4 py-3 ${!firstLevel || !normalizedPrompt || isBusy || isGeneratingUiBackground ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
{isBusy ? (
|
||||
{isBusy || isGeneratingUiBackground ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Wand2 className="h-4 w-4" />
|
||||
)}
|
||||
{hasGeneratedUiBackground ? '重新生成' : '生成UI背景'} · {PUZZLE_IMAGE_GENERATION_POINT_COST}泥点
|
||||
{isGeneratingUiBackground
|
||||
? '生成中'
|
||||
: hasGeneratedUiBackground
|
||||
? '重新生成'
|
||||
: '生成UI背景'} · {PUZZLE_IMAGE_GENERATION_POINT_COST}泥点
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1547,7 +1567,12 @@ function PuzzleUiAssetsTab({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!firstLevel || !normalizedPrompt || isBusy}
|
||||
disabled={
|
||||
!firstLevel ||
|
||||
!normalizedPrompt ||
|
||||
isBusy ||
|
||||
isGeneratingUiBackground
|
||||
}
|
||||
onClick={() => {
|
||||
if (!firstLevel || !normalizedPrompt) {
|
||||
return;
|
||||
@@ -1559,7 +1584,7 @@ function PuzzleUiAssetsTab({
|
||||
setIsCostConfirmOpen(false);
|
||||
onGenerate(normalizedPrompt);
|
||||
}}
|
||||
className={`platform-button platform-button--primary justify-center ${!firstLevel || !normalizedPrompt || isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
className={`platform-button platform-button--primary justify-center ${!firstLevel || !normalizedPrompt || isBusy || isGeneratingUiBackground ? 'cursor-not-allowed opacity-55' : ''}`}
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
@@ -1696,6 +1721,7 @@ function PuzzleAssetConfigTab({
|
||||
editState,
|
||||
imageRefreshKey,
|
||||
isBusy,
|
||||
uiBackgroundGeneration,
|
||||
onAssetConfigTabChange,
|
||||
onChange,
|
||||
onGenerateUiBackground,
|
||||
@@ -1704,6 +1730,7 @@ function PuzzleAssetConfigTab({
|
||||
editState: DraftEditState;
|
||||
imageRefreshKey: string;
|
||||
isBusy: boolean;
|
||||
uiBackgroundGeneration: PuzzleUiBackgroundGenerationState;
|
||||
onAssetConfigTabChange: (tab: PuzzleAssetConfigTabId) => void;
|
||||
onChange: (nextState: DraftEditState) => void;
|
||||
onGenerateUiBackground: (prompt: string) => void;
|
||||
@@ -1719,6 +1746,7 @@ function PuzzleAssetConfigTab({
|
||||
editState={editState}
|
||||
imageRefreshKey={imageRefreshKey}
|
||||
isBusy={isBusy}
|
||||
uiBackgroundGeneration={uiBackgroundGeneration}
|
||||
onChange={onChange}
|
||||
onGenerate={onGenerateUiBackground}
|
||||
/>
|
||||
@@ -1829,35 +1857,51 @@ export function PuzzleResultView({
|
||||
const [tagGenerationError, setTagGenerationError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [uiBackgroundGeneration, setUiBackgroundGeneration] =
|
||||
useState<PuzzleUiBackgroundGenerationState>(null);
|
||||
const [generationRuntimeByLevelId, setGenerationRuntimeByLevelId] = useState<
|
||||
Record<string, PuzzleLevelGenerationRuntime>
|
||||
>({});
|
||||
const [generationNowMs, setGenerationNowMs] = useState(() => Date.now());
|
||||
const latestEditStateRef = useRef<DraftEditState | null>(
|
||||
draft ? createDraftEditState(draft) : null,
|
||||
);
|
||||
const savedEditStateRef = useRef<DraftEditState | null>(
|
||||
draft ? createDraftEditState(draft) : null,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
latestEditStateRef.current = editState;
|
||||
}, [editState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
setUiBackgroundGeneration(null);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!draft) {
|
||||
setEditState(null);
|
||||
latestEditStateRef.current = null;
|
||||
setActiveLevelId(null);
|
||||
setUiBackgroundGeneration(null);
|
||||
setAutoSaveState('idle');
|
||||
setAutoSaveError(null);
|
||||
setTagGenerationError(null);
|
||||
return;
|
||||
}
|
||||
const nextState = createDraftEditState(draft);
|
||||
setEditState((currentState) => {
|
||||
const mergedState = mergeDraftEditStateWithIncomingState(
|
||||
currentState,
|
||||
nextState,
|
||||
);
|
||||
savedEditStateRef.current = nextState;
|
||||
return mergedState;
|
||||
});
|
||||
const mergedState = mergeDraftEditStateWithIncomingState(
|
||||
latestEditStateRef.current,
|
||||
nextState,
|
||||
);
|
||||
latestEditStateRef.current = mergedState;
|
||||
savedEditStateRef.current = nextState;
|
||||
setEditState(mergedState);
|
||||
setGenerationRuntimeByLevelId((current) => {
|
||||
const nextRuntimes: Record<string, PuzzleLevelGenerationRuntime> = {};
|
||||
nextState.levels.forEach((level) => {
|
||||
mergedState.levels.forEach((level) => {
|
||||
if (level.generationStatus === 'generating') {
|
||||
nextRuntimes[level.levelId] =
|
||||
current[level.levelId] ?? {
|
||||
@@ -1870,13 +1914,26 @@ export function PuzzleResultView({
|
||||
});
|
||||
setActiveLevelId((currentLevelId) =>
|
||||
currentLevelId &&
|
||||
nextState.levels.some((level) => level.levelId === currentLevelId)
|
||||
mergedState.levels.some((level) => level.levelId === currentLevelId)
|
||||
? currentLevelId
|
||||
: null,
|
||||
);
|
||||
setAutoSaveState('idle');
|
||||
setAutoSaveError(null);
|
||||
setTagGenerationError(null);
|
||||
setUiBackgroundGeneration((current) => {
|
||||
if (
|
||||
current &&
|
||||
mergedState.levels.some(
|
||||
(level) =>
|
||||
level.levelId === current.levelId &&
|
||||
resolvePuzzleUiBackgroundSource(level),
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return current;
|
||||
});
|
||||
}, [draft]);
|
||||
|
||||
const syncedDraft = useMemo(() => {
|
||||
@@ -2156,6 +2213,7 @@ export function PuzzleResultView({
|
||||
editState={editState}
|
||||
imageRefreshKey={imageRefreshKey}
|
||||
isBusy={isBusy}
|
||||
uiBackgroundGeneration={uiBackgroundGeneration}
|
||||
onAssetConfigTabChange={setActiveAssetConfigTab}
|
||||
onChange={setEditState}
|
||||
onGenerateUiBackground={(prompt) => {
|
||||
@@ -2163,6 +2221,10 @@ export function PuzzleResultView({
|
||||
if (!firstLevel) {
|
||||
return;
|
||||
}
|
||||
setUiBackgroundGeneration({
|
||||
levelId: firstLevel.levelId,
|
||||
prompt,
|
||||
});
|
||||
onExecuteAction({
|
||||
action: 'generate_puzzle_ui_background',
|
||||
levelId: firstLevel.levelId,
|
||||
@@ -2249,6 +2311,7 @@ export function PuzzleResultView({
|
||||
imageModel: imageModel ?? PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
aiRedraw: true,
|
||||
candidateCount: 1,
|
||||
shouldAutoNameLevel: !nextLevel.levelName.trim(),
|
||||
workTitle: editState.workTitle.trim(),
|
||||
workDescription: editState.workDescription.trim(),
|
||||
summary: editState.workDescription.trim(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
+127
-1
@@ -281,8 +281,124 @@ body {
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
@keyframes match3d-tray-token-shift {
|
||||
0% {
|
||||
transform: translate3d(
|
||||
var(--match3d-tray-shift-x, 0px),
|
||||
var(--match3d-tray-shift-y, 0px),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.match3d-tray-token-shift {
|
||||
animation: match3d-tray-token-shift 0.24s cubic-bezier(0.2, 0.8, 0.2, 1)
|
||||
both;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@keyframes match3d-tray-token-clear {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate3d(-50%, -50%, 0) scale(1);
|
||||
}
|
||||
|
||||
62% {
|
||||
opacity: 1;
|
||||
transform:
|
||||
translate3d(
|
||||
calc(-50% + var(--match3d-tray-clear-dx, 0px)),
|
||||
calc(-50% + var(--match3d-tray-clear-dy, 0px)),
|
||||
0
|
||||
)
|
||||
scale(0.84);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform:
|
||||
translate3d(
|
||||
calc(-50% + var(--match3d-tray-clear-dx, 0px)),
|
||||
calc(-50% + var(--match3d-tray-clear-dy, 0px)),
|
||||
0
|
||||
)
|
||||
scale(0.38);
|
||||
filter: blur(1px);
|
||||
}
|
||||
}
|
||||
|
||||
.match3d-tray-token-clear {
|
||||
transform: translate3d(-50%, -50%, 0);
|
||||
animation: match3d-tray-token-clear 0.46s cubic-bezier(0.2, 0.72, 0.18, 1)
|
||||
both;
|
||||
transform-origin: center;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
@keyframes match3d-tray-clear-flash {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.2);
|
||||
}
|
||||
|
||||
42% {
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(1.45);
|
||||
}
|
||||
}
|
||||
|
||||
.match3d-tray-clear-flash {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
border-radius: 9999px;
|
||||
background:
|
||||
radial-gradient(circle, rgba(255, 255, 255, 0.95) 0 10%, transparent 12%),
|
||||
radial-gradient(circle, rgba(255, 236, 157, 0.48) 0 42%, transparent 64%);
|
||||
box-shadow:
|
||||
0 0 22px rgba(255, 255, 255, 0.62),
|
||||
0 0 52px rgba(251, 191, 36, 0.34);
|
||||
mix-blend-mode: screen;
|
||||
animation: match3d-tray-clear-flash 0.46s ease-out both;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
@keyframes match3d-merge-feedback-pulse {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.48);
|
||||
}
|
||||
|
||||
42% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(1.28);
|
||||
}
|
||||
}
|
||||
|
||||
.match3d-merge-feedback-pulse {
|
||||
animation: match3d-merge-feedback-pulse 0.52s ease-out both;
|
||||
background:
|
||||
radial-gradient(circle, rgba(255, 255, 255, 0.56) 0 18%, transparent 20%),
|
||||
radial-gradient(circle, rgba(255, 255, 255, 0.28) 0 45%, transparent 68%);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.match3d-token-fly-to-tray {
|
||||
.match3d-token-fly-to-tray,
|
||||
.match3d-tray-token-shift,
|
||||
.match3d-tray-token-clear,
|
||||
.match3d-tray-clear-flash,
|
||||
.match3d-merge-feedback-pulse {
|
||||
animation-duration: 1ms;
|
||||
}
|
||||
}
|
||||
@@ -1110,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%);
|
||||
}
|
||||
@@ -4786,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;
|
||||
|
||||
@@ -45,6 +45,7 @@ test('creation agent action requests are not auto-retried by default', async ()
|
||||
'执行失败',
|
||||
expect.objectContaining({
|
||||
retry: expect.objectContaining({ maxRetries: 0 }),
|
||||
timeoutMs: 1_000_000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@ const DEFAULT_CREATION_AGENT_WRITE_RETRY: ApiRetryOptions = {
|
||||
const DEFAULT_CREATION_AGENT_ACTION_RETRY: ApiRetryOptions = {
|
||||
maxRetries: 0,
|
||||
};
|
||||
const DEFAULT_CREATION_AGENT_ACTION_TIMEOUT_MS = 1_000_000;
|
||||
|
||||
function buildJsonPostInit(payload: unknown): RequestInit {
|
||||
return {
|
||||
@@ -182,7 +183,8 @@ export function createCreationAgentClient<
|
||||
messages.executeAction,
|
||||
{
|
||||
retry: executeActionRetry,
|
||||
timeoutMs: executeActionTimeoutMs,
|
||||
timeoutMs:
|
||||
executeActionTimeoutMs ?? DEFAULT_CREATION_AGENT_ACTION_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user