合并 master 到跳一跳分支
合入 origin/master 最新变更 解决跳一跳提示词测试与图集去背冲突
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import App from './App';
|
||||
import { AuthUiContext } from './components/auth/AuthUiContext';
|
||||
import type { PlatformEntryFlowShellProps } from './components/platform-entry';
|
||||
import {
|
||||
APP_HISTORY_STATE_KEY,
|
||||
resolveInitialSelectionStageFromPath,
|
||||
} from './routing/appPageRoutes';
|
||||
import {
|
||||
canUseNativeHostCapability,
|
||||
resetHostRuntimeCacheForTest,
|
||||
} from './services/host-bridge/hostBridge';
|
||||
import { resetNativeAppHostBridgeForTest } from './services/host-bridge/nativeAppHostBridge';
|
||||
|
||||
const appTitleMock = vi.hoisted(() => ({
|
||||
syncAppTitle: vi.fn(),
|
||||
}));
|
||||
|
||||
function mockMatchMedia(matches: boolean) {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
vi.mock('./services/appTitle', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./services/appTitle')>();
|
||||
return {
|
||||
...actual,
|
||||
syncAppTitle: appTitleMock.syncAppTitle,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./components/platform-entry/PlatformEntryFlowShell', () => ({
|
||||
PlatformEntryFlowShell: ({
|
||||
handleCustomWorldSelect,
|
||||
setSelectionStage,
|
||||
selectionStage,
|
||||
}: PlatformEntryFlowShellProps) => (
|
||||
<div>
|
||||
<div data-testid="selection-stage">{selectionStage}</div>
|
||||
<div data-testid="share-capability">
|
||||
{canUseNativeHostCapability('share.open') ? 'enabled' : 'disabled'}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectionStage('puzzle-agent-workspace')}
|
||||
>
|
||||
打开拼图创作
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
handleCustomWorldSelect(
|
||||
{
|
||||
id: 'profile-1',
|
||||
name: '潮雾列岛',
|
||||
} as Parameters<
|
||||
PlatformEntryFlowShellProps['handleCustomWorldSelect']
|
||||
>[0],
|
||||
)
|
||||
}
|
||||
>
|
||||
进入 RPG
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setSelectionStage('image-editor', {
|
||||
path: '/editor/canvas?projectid=project-from-test',
|
||||
})
|
||||
}
|
||||
>
|
||||
打开最近项目
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./RpgRuntimeApp', () => ({
|
||||
RpgRuntimeApp: ({ onExitRuntime }: { onExitRuntime: () => void }) => (
|
||||
<button type="button" onClick={onExitRuntime}>
|
||||
退出 RPG
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
function renderApp() {
|
||||
return render(
|
||||
<AuthUiContext.Provider
|
||||
value={{
|
||||
canAccessProtectedData: false,
|
||||
isHydratingSettings: false,
|
||||
isPersistingSettings: false,
|
||||
logout: vi.fn(async () => undefined),
|
||||
musicVolume: 0.6,
|
||||
openAccountModal: vi.fn(),
|
||||
openLoginModal: vi.fn(),
|
||||
openSettingsModal: vi.fn(),
|
||||
platformTheme: 'light',
|
||||
requireAuth: vi.fn(),
|
||||
setCurrentUser: vi.fn(),
|
||||
setMusicVolume: vi.fn(),
|
||||
setPlatformTheme: vi.fn(),
|
||||
settingsError: null,
|
||||
user: null,
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
appTitleMock.syncAppTitle.mockReset();
|
||||
window.history.replaceState(null, '', '/');
|
||||
delete window.__TAURI__;
|
||||
delete window.ReactNativeWebView;
|
||||
resetHostRuntimeCacheForTest();
|
||||
resetNativeAppHostBridgeForTest();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('resolveInitialSelectionStageFromPath', () => {
|
||||
test('桌面端根路径进入创作主页', () => {
|
||||
expect(resolveInitialSelectionStageFromPath('/', true)).toBe(
|
||||
'creation-home',
|
||||
);
|
||||
});
|
||||
|
||||
test('移动端根路径仍进入平台首页', () => {
|
||||
expect(resolveInitialSelectionStageFromPath('/', false)).toBe('platform');
|
||||
});
|
||||
|
||||
test('显式创作路由保持原目标阶段', () => {
|
||||
expect(resolveInitialSelectionStageFromPath('/creation/puzzle', true)).toBe(
|
||||
'puzzle-agent-workspace',
|
||||
);
|
||||
expect(resolveInitialSelectionStageFromPath('/creation', true)).toBe(
|
||||
'creation-home',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('App title sync', () => {
|
||||
test('主站阶段变化会同步浏览器与宿主标题', () => {
|
||||
renderApp();
|
||||
|
||||
expect(appTitleMock.syncAppTitle).toHaveBeenLastCalledWith('陶泥儿');
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开拼图创作' }));
|
||||
});
|
||||
|
||||
expect(appTitleMock.syncAppTitle).toHaveBeenLastCalledWith(
|
||||
'拼图创作 - 陶泥儿',
|
||||
);
|
||||
});
|
||||
|
||||
test('RPG runtime 进入和退出时同步窗口标题', async () => {
|
||||
renderApp();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '进入 RPG' }));
|
||||
});
|
||||
|
||||
expect(appTitleMock.syncAppTitle).toHaveBeenLastCalledWith(
|
||||
'RPG 运行中 - 陶泥儿',
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '退出 RPG' }));
|
||||
});
|
||||
|
||||
expect(appTitleMock.syncAppTitle).toHaveBeenLastCalledWith('陶泥儿');
|
||||
});
|
||||
|
||||
test('启动时回读宿主 runtime 后刷新壳能力 UI', async () => {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/?clientRuntime=native_app&hostShell=tauri_desktop',
|
||||
);
|
||||
window.__TAURI__ = {
|
||||
core: {
|
||||
invoke: vi.fn(async (_command, args) => {
|
||||
const request = (args as { request: { id: string } }).request;
|
||||
return {
|
||||
bridge: 'GenarrativeHostBridge',
|
||||
version: 1,
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: {
|
||||
shell: 'tauri_desktop',
|
||||
platform: 'linux',
|
||||
hostVersion: '0.1.0',
|
||||
bridgeVersion: 1,
|
||||
capabilities: ['host.getRuntime', 'share.open'],
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
renderApp();
|
||||
|
||||
expect(screen.getByTestId('share-capability').textContent).toBe(
|
||||
'disabled',
|
||||
);
|
||||
await screen.findByText('enabled');
|
||||
expect(screen.getByTestId('share-capability').textContent).toBe(
|
||||
'enabled',
|
||||
);
|
||||
});
|
||||
|
||||
test('原生壳直达二级页面时补齐 H5 返回锚点', async () => {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/creation/puzzle?clientRuntime=native_app&hostShell=tauri_desktop&hostCapabilities=host.events,navigation.canGoBack',
|
||||
);
|
||||
window.__TAURI__ = {
|
||||
core: {
|
||||
invoke: vi.fn(async (_command, args) => {
|
||||
const request = (args as { request: { id: string } }).request;
|
||||
return {
|
||||
bridge: 'GenarrativeHostBridge',
|
||||
version: 1,
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: {
|
||||
shell: 'tauri_desktop',
|
||||
platform: 'linux',
|
||||
hostVersion: '0.1.0',
|
||||
bridgeVersion: 1,
|
||||
capabilities: ['host.events', 'navigation.canGoBack'],
|
||||
},
|
||||
};
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
renderApp();
|
||||
|
||||
expect(screen.getByTestId('selection-stage').textContent).toBe(
|
||||
'puzzle-agent-workspace',
|
||||
);
|
||||
expect(window.location.pathname).toBe('/creation/puzzle');
|
||||
expect(window.location.search).toBe(
|
||||
'?clientRuntime=native_app&hostShell=tauri_desktop&hostCapabilities=host.events,navigation.canGoBack',
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(canUseNativeHostCapability('navigation.canGoBack')).toBe(true);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
window.history.back();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.pathname).toBe('/');
|
||||
});
|
||||
expect(window.location.search).toBe(
|
||||
'?clientRuntime=native_app&hostShell=tauri_desktop&hostCapabilities=host.events%2Cnavigation.canGoBack',
|
||||
);
|
||||
expect(screen.getByTestId('selection-stage').textContent).toBe('platform');
|
||||
});
|
||||
});
|
||||
|
||||
describe('App navigation history', () => {
|
||||
test('桌面端首页 URL 渲染为创作主页阶段', () => {
|
||||
mockMatchMedia(true);
|
||||
window.history.replaceState(null, '', '/');
|
||||
|
||||
renderApp();
|
||||
|
||||
expect(screen.getByTestId('selection-stage').textContent).toBe(
|
||||
'creation-home',
|
||||
);
|
||||
});
|
||||
|
||||
test('缺少 projectid 的项目画布直达会替换回创作页', () => {
|
||||
window.history.replaceState(null, '', '/editor/canvas');
|
||||
const replaceStateSpy = vi.spyOn(window.history, 'replaceState');
|
||||
|
||||
renderApp();
|
||||
|
||||
expect(screen.getByTestId('selection-stage').textContent).toBe(
|
||||
'creation-home',
|
||||
);
|
||||
expect(window.location.pathname).toBe('/creation');
|
||||
expect(window.location.search).toBe('');
|
||||
expect(replaceStateSpy).toHaveBeenCalledWith(
|
||||
{ [APP_HISTORY_STATE_KEY]: true },
|
||||
'',
|
||||
'/creation',
|
||||
);
|
||||
|
||||
replaceStateSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('带 projectid 的项目画布直达保持编辑器阶段', () => {
|
||||
mockMatchMedia(false);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/editor/canvas?projectid=project-from-url',
|
||||
);
|
||||
|
||||
renderApp();
|
||||
|
||||
expect(screen.getByTestId('selection-stage').textContent).toBe(
|
||||
'image-editor',
|
||||
);
|
||||
expect(window.location.pathname).toBe('/editor/canvas');
|
||||
expect(window.location.search).toBe('?projectid=project-from-url');
|
||||
});
|
||||
|
||||
test('历史回到缺少 projectid 的项目画布时会替换回创作页', async () => {
|
||||
mockMatchMedia(false);
|
||||
window.history.replaceState(null, '', '/creation');
|
||||
|
||||
renderApp();
|
||||
|
||||
window.history.pushState(null, '', '/editor/canvas');
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('selection-stage').textContent).toBe(
|
||||
'creation-home',
|
||||
);
|
||||
expect(window.location.pathname).toBe('/creation');
|
||||
expect(window.location.search).toBe('');
|
||||
});
|
||||
|
||||
test('项目画布导航只写入一次带 projectid 的历史记录', async () => {
|
||||
mockMatchMedia(true);
|
||||
window.history.replaceState(null, '', '/creation');
|
||||
const pushStateSpy = vi.spyOn(window.history, 'pushState');
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderApp();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '打开最近项目' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.pathname).toBe('/editor/canvas');
|
||||
expect(window.location.search).toBe('?projectid=project-from-test');
|
||||
});
|
||||
expect(pushStateSpy).toHaveBeenCalledTimes(1);
|
||||
expect(pushStateSpy).toHaveBeenCalledWith(
|
||||
{ [APP_HISTORY_STATE_KEY]: true },
|
||||
'',
|
||||
'/editor/canvas?projectid=project-from-test',
|
||||
);
|
||||
|
||||
window.history.back();
|
||||
await waitFor(() => {
|
||||
expect(window.location.pathname).toBe('/creation');
|
||||
expect(window.location.search).toBe('');
|
||||
});
|
||||
|
||||
pushStateSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
+112
-8
@@ -9,20 +9,33 @@ import {
|
||||
|
||||
import { useAuthUi } from './components/auth/AuthUiContext';
|
||||
import { PlatformEntryFlowShell } from './components/platform-entry/PlatformEntryFlowShell';
|
||||
import { getInitialPlatformDesktopLayout } from './components/platform-entry/platformEntryResponsive';
|
||||
import type {
|
||||
CustomWorldRuntimeLaunchOptions,
|
||||
SelectionStage,
|
||||
} from './components/platform-entry/platformEntryTypes';
|
||||
import { useHostNavigationCanGoBack } from './hooks/useHostNavigationCanGoBack';
|
||||
import type { HydratedSavedGameSnapshot } from './persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
APP_RUNTIME_ROUTES,
|
||||
isAppHistoryState,
|
||||
normalizeAppPath,
|
||||
pushAppHistoryPath,
|
||||
replaceAppHistoryPath,
|
||||
readPublicWorkCodeFromLocationSearch,
|
||||
resolveInitialSelectionStageFromPath,
|
||||
resolvePathForSelectionStage,
|
||||
resolveSelectionStageFromPath,
|
||||
shouldRedirectEditorCanvasWithoutProject,
|
||||
} from './routing/appPageRoutes';
|
||||
import type { RpgRuntimeAppIntent } from './RpgRuntimeApp';
|
||||
import {
|
||||
resolveAppTitleForSelectionStage,
|
||||
syncAppTitle,
|
||||
} from './services/appTitle';
|
||||
import {
|
||||
refreshNativeAppHostRuntime,
|
||||
subscribeHostRuntimeChange,
|
||||
} from './services/host-bridge/hostBridge';
|
||||
import type { CustomWorldProfile } from './types';
|
||||
|
||||
const RpgRuntimeApp = lazy(async () => {
|
||||
@@ -50,16 +63,38 @@ function isRpgRuntimeRoute(pathname: string) {
|
||||
);
|
||||
}
|
||||
|
||||
function resolveInitialAppSelectionStage() {
|
||||
if (
|
||||
shouldRedirectEditorCanvasWithoutProject(
|
||||
window.location.pathname,
|
||||
window.location.search,
|
||||
)
|
||||
) {
|
||||
replaceAppHistoryPath('/creation');
|
||||
return 'creation-home';
|
||||
}
|
||||
|
||||
return resolveInitialSelectionStageFromPath(
|
||||
window.location.pathname,
|
||||
getInitialPlatformDesktopLayout(),
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const authUi = useAuthUi();
|
||||
const runtimeIntentTokenRef = useRef(0);
|
||||
const hasHostNavigationAnchorRef = useRef(
|
||||
isAppHistoryState(window.history.state),
|
||||
);
|
||||
const hostNavigation = useHostNavigationCanGoBack();
|
||||
const [runtimeIntent, setRuntimeIntent] =
|
||||
useState<RpgRuntimeAppIntent | null>(null);
|
||||
const [, setHostRuntimeRevision] = useState(0);
|
||||
const [isRuntimeActive, setIsRuntimeActive] = useState(() =>
|
||||
isRpgRuntimeRoute(window.location.pathname),
|
||||
);
|
||||
const [selectionStage, setRawSelectionStage] = useState<SelectionStage>(() =>
|
||||
resolveSelectionStageFromPath(window.location.pathname),
|
||||
const [selectionStage, setRawSelectionStage] = useState<SelectionStage>(
|
||||
resolveInitialAppSelectionStage,
|
||||
);
|
||||
const [runtimeReturnStage, setRuntimeReturnStage] =
|
||||
useState<SelectionStage>('platform');
|
||||
@@ -67,13 +102,42 @@ export default function App() {
|
||||
readPublicWorkCodeFromLocationSearch(window.location.search),
|
||||
);
|
||||
|
||||
const setSelectionStage = useCallback((stage: SelectionStage) => {
|
||||
setRawSelectionStage(stage);
|
||||
pushAppHistoryPath(resolvePathForSelectionStage(stage));
|
||||
const setSelectionStage = useCallback(
|
||||
(stage: SelectionStage, options?: { path?: string }) => {
|
||||
setRawSelectionStage(stage);
|
||||
pushAppHistoryPath(options?.path ?? resolvePathForSelectionStage(stage));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribeHostRuntimeChange(() => {
|
||||
setHostRuntimeRevision((revision) => revision + 1);
|
||||
});
|
||||
|
||||
void refreshNativeAppHostRuntime();
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const syncStageFromHistory = () => {
|
||||
hasHostNavigationAnchorRef.current = isAppHistoryState(
|
||||
window.history.state,
|
||||
);
|
||||
|
||||
if (
|
||||
shouldRedirectEditorCanvasWithoutProject(
|
||||
window.location.pathname,
|
||||
window.location.search,
|
||||
)
|
||||
) {
|
||||
replaceAppHistoryPath('/creation');
|
||||
setIsRuntimeActive(false);
|
||||
setRawSelectionStage('creation-home');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isRpgRuntimeRoute(window.location.pathname)) {
|
||||
setIsRuntimeActive(true);
|
||||
return;
|
||||
@@ -81,7 +145,10 @@ export default function App() {
|
||||
|
||||
setIsRuntimeActive(false);
|
||||
setRawSelectionStage(
|
||||
resolveSelectionStageFromPath(window.location.pathname),
|
||||
resolveInitialSelectionStageFromPath(
|
||||
window.location.pathname,
|
||||
getInitialPlatformDesktopLayout(),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@@ -89,6 +156,31 @@ export default function App() {
|
||||
return () => window.removeEventListener('popstate', syncStageFromHistory);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!hostNavigation.isSupported ||
|
||||
hostNavigation.canGoBack ||
|
||||
isRuntimeActive ||
|
||||
selectionStage === 'platform' ||
|
||||
isAppHistoryState(window.history.state) ||
|
||||
hasHostNavigationAnchorRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPath = normalizeAppPath(window.location.pathname);
|
||||
const currentSearch = window.location.search;
|
||||
|
||||
hasHostNavigationAnchorRef.current = true;
|
||||
replaceAppHistoryPath('/');
|
||||
pushAppHistoryPath(`${currentPath}${currentSearch}`);
|
||||
}, [
|
||||
hostNavigation.canGoBack,
|
||||
hostNavigation.isSupported,
|
||||
isRuntimeActive,
|
||||
selectionStage,
|
||||
]);
|
||||
|
||||
const createRuntimeIntent = useCallback(
|
||||
(intent: Omit<RpgRuntimeAppIntent, 'token'>) => {
|
||||
runtimeIntentTokenRef.current += 1;
|
||||
@@ -133,6 +225,18 @@ export default function App() {
|
||||
authUi?.platformTheme === 'dark'
|
||||
? 'platform-theme--dark'
|
||||
: 'platform-theme--light';
|
||||
const isImageEditorStage = selectionStage === 'image-editor';
|
||||
const platformShellSurfaceClass = isImageEditorStage
|
||||
? 'bg-white p-0'
|
||||
: 'bg-[image:var(--platform-body-fill)] p-2 sm:p-4';
|
||||
|
||||
useEffect(() => {
|
||||
syncAppTitle(
|
||||
isRuntimeActive
|
||||
? 'RPG 运行中 - 陶泥儿'
|
||||
: resolveAppTitleForSelectionStage(selectionStage),
|
||||
);
|
||||
}, [isRuntimeActive, selectionStage]);
|
||||
|
||||
if (isRuntimeActive) {
|
||||
return (
|
||||
@@ -150,7 +254,7 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`platform-ui-shell platform-viewport-shell platform-theme ${platformThemeClass} flex flex-col overflow-hidden bg-[image:var(--platform-body-fill)] p-2 font-sans text-[var(--platform-text-strong)] sm:p-4`}
|
||||
className={`platform-ui-shell platform-viewport-shell platform-theme ${platformThemeClass} flex flex-col overflow-hidden ${platformShellSurfaceClass} font-sans text-[var(--platform-text-strong)]`}
|
||||
>
|
||||
<PlatformEntryFlowShell
|
||||
selectionStage={selectionStage}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useState } from 'react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import * as customWorldCoverAssetService from '../services/customWorldCoverAssetService';
|
||||
import * as hostBridgeServices from '../services/host-bridge/hostBridge';
|
||||
import * as rpgCreationAssetClient from '../services/rpg-creation/rpgCreationAssetClient';
|
||||
import type {
|
||||
CustomWorldNpc,
|
||||
@@ -29,6 +30,8 @@ import {
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
vi.mock('../data/characterPresets', async () => {
|
||||
@@ -159,6 +162,11 @@ vi.mock('../services/customWorldCoverAssetService', () => ({
|
||||
uploadCustomWorldCoverImage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../services/host-bridge/hostBridge', () => ({
|
||||
canUseNativeHostCapability: vi.fn(() => false),
|
||||
importHostImageFile: vi.fn(),
|
||||
}));
|
||||
|
||||
function createBackstoryReveal() {
|
||||
return {
|
||||
publicSummary: '公开背景',
|
||||
@@ -1741,6 +1749,108 @@ test('开局场景列表与详情幕预览复用同一套幕级图片', async ()
|
||||
).toBe('/generated-custom-world-scenes/camp-act-2.png');
|
||||
});
|
||||
|
||||
test('场景图片参考图在原生壳内优先走 HostBridge 图片导入并进入生成 payload', async () => {
|
||||
vi.mocked(hostBridgeServices.canUseNativeHostCapability).mockImplementation(
|
||||
(capability) => capability === 'file.importImage',
|
||||
);
|
||||
vi.mocked(hostBridgeServices.importHostImageFile).mockResolvedValue({
|
||||
action: 'selected',
|
||||
fileName: 'native-scene-reference.png',
|
||||
base64Data:
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII=',
|
||||
mimeType: 'image/png',
|
||||
bytes: 68,
|
||||
});
|
||||
mockedRpgCreationAssetClient.generateSceneImage.mockClear();
|
||||
mockedRpgCreationAssetClient.generateSceneImage.mockResolvedValue({
|
||||
imageSrc: '/generated-custom-world-scenes/native-reference-scene.png',
|
||||
assetId: 'asset-native-reference-scene',
|
||||
model: 'wan2.2-t2i-flash',
|
||||
size: '1280*720',
|
||||
taskId: 'task-native-reference-scene',
|
||||
prompt: '带参考图的场景图',
|
||||
});
|
||||
|
||||
class MockFileReader {
|
||||
result: string | null = null;
|
||||
error: Error | null = null;
|
||||
onload: null | (() => void) = null;
|
||||
onerror: null | (() => void) = null;
|
||||
|
||||
readAsDataURL() {
|
||||
this.result =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII=';
|
||||
this.onload?.();
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
|
||||
const inputClickSpy = vi
|
||||
.spyOn(HTMLInputElement.prototype, 'click')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<LandmarkEditorFlowHarness />);
|
||||
|
||||
const firstActCard = getSceneActCard(0);
|
||||
await user.click(
|
||||
within(firstActCard).getByRole('button', { name: '配置背景' }),
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: 'AI生成' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('智能生成:沉钟栈桥')).toBeTruthy();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText('上传自定义参考图'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hostBridgeServices.importHostImageFile).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText('已载入自定义参考图')).toBeTruthy();
|
||||
});
|
||||
expect(inputClickSpy).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '开始生成' }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
mockedRpgCreationAssetClient.generateSceneImage,
|
||||
).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const payload =
|
||||
mockedRpgCreationAssetClient.generateSceneImage.mock.calls[0]?.[0];
|
||||
expect(payload?.referenceImageSrc).toMatch(/^data:image\/png;base64,/u);
|
||||
});
|
||||
|
||||
test('场景图片参考图取消原生导入时不触发浏览器文件输入', async () => {
|
||||
vi.mocked(hostBridgeServices.canUseNativeHostCapability).mockImplementation(
|
||||
(capability) => capability === 'file.importImage',
|
||||
);
|
||||
vi.mocked(hostBridgeServices.importHostImageFile).mockResolvedValue(false);
|
||||
const inputClickSpy = vi
|
||||
.spyOn(HTMLInputElement.prototype, 'click')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<LandmarkEditorFlowHarness />);
|
||||
|
||||
const firstActCard = getSceneActCard(0);
|
||||
await user.click(
|
||||
within(firstActCard).getByRole('button', { name: '配置背景' }),
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: 'AI生成' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('智能生成:沉钟栈桥')).toBeTruthy();
|
||||
});
|
||||
|
||||
await user.click(screen.getByText('上传自定义参考图'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hostBridgeServices.importHostImageFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(inputClickSpy).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText('已载入自定义参考图')).toBeNull();
|
||||
});
|
||||
|
||||
test('开局场景幕背景智能生成复用当前幕图片和幕级提示词', async () => {
|
||||
mockedRpgCreationAssetClient.generateSceneImage.mockClear();
|
||||
mockedRpgCreationAssetClient.generateSceneImage.mockResolvedValue({
|
||||
@@ -2194,3 +2304,149 @@ test('作品封面上传会先进入 16:9 裁剪面板再提交到后端', async
|
||||
'/generated-custom-world-covers/world-1/uploaded/cover.webp',
|
||||
);
|
||||
});
|
||||
|
||||
test('作品封面上传在原生壳内优先走 HostBridge 图片导入', async () => {
|
||||
vi.mocked(hostBridgeServices.canUseNativeHostCapability).mockImplementation(
|
||||
(capability) => capability === 'file.importImage',
|
||||
);
|
||||
vi.mocked(hostBridgeServices.importHostImageFile).mockResolvedValue({
|
||||
action: 'selected',
|
||||
fileName: 'native-cover.png',
|
||||
base64Data:
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII=',
|
||||
mimeType: 'image/png',
|
||||
bytes: 68,
|
||||
});
|
||||
|
||||
class MockFileReader {
|
||||
result: string | null = null;
|
||||
error: Error | null = null;
|
||||
onload: null | (() => void) = null;
|
||||
onerror: null | (() => void) = null;
|
||||
|
||||
readAsDataURL() {
|
||||
this.result =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII=';
|
||||
this.onload?.();
|
||||
}
|
||||
}
|
||||
|
||||
class MockImage {
|
||||
onload: null | (() => void) = null;
|
||||
onerror: null | (() => void) = null;
|
||||
naturalWidth = 1920;
|
||||
naturalHeight = 1080;
|
||||
|
||||
set src(_value: string) {
|
||||
this.onload?.();
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
|
||||
vi.stubGlobal('Image', MockImage as unknown as typeof Image);
|
||||
const inputClickSpy = vi
|
||||
.spyOn(HTMLInputElement.prototype, 'click')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<CoverEditorFlowHarness />);
|
||||
|
||||
await user.click(screen.getByText('上传封面'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hostBridgeServices.importHostImageFile).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText('裁剪上传封面')).toBeTruthy();
|
||||
});
|
||||
expect(inputClickSpy).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByRole('img', { name: '上传封面裁剪预览' }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('作品封面取消原生导入时不触发浏览器文件输入', async () => {
|
||||
vi.mocked(hostBridgeServices.canUseNativeHostCapability).mockImplementation(
|
||||
(capability) => capability === 'file.importImage',
|
||||
);
|
||||
vi.mocked(hostBridgeServices.importHostImageFile).mockResolvedValue(false);
|
||||
const inputClickSpy = vi
|
||||
.spyOn(HTMLInputElement.prototype, 'click')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<CoverEditorFlowHarness />);
|
||||
|
||||
await user.click(screen.getByText('上传封面'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hostBridgeServices.importHostImageFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(inputClickSpy).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText('裁剪上传封面')).toBeNull();
|
||||
});
|
||||
|
||||
test('作品封面参考图在原生壳内优先走 HostBridge 图片导入', async () => {
|
||||
vi.mocked(hostBridgeServices.canUseNativeHostCapability).mockImplementation(
|
||||
(capability) => capability === 'file.importImage',
|
||||
);
|
||||
vi.mocked(hostBridgeServices.importHostImageFile).mockResolvedValue({
|
||||
action: 'selected',
|
||||
fileName: 'native-cover-reference.png',
|
||||
base64Data:
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII=',
|
||||
mimeType: 'image/png',
|
||||
bytes: 68,
|
||||
});
|
||||
|
||||
class MockFileReader {
|
||||
result: string | null = null;
|
||||
error: Error | null = null;
|
||||
onload: null | (() => void) = null;
|
||||
onerror: null | (() => void) = null;
|
||||
|
||||
readAsDataURL() {
|
||||
this.result =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO7+7aQAAAAASUVORK5CYII=';
|
||||
this.onload?.();
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
|
||||
const inputClickSpy = vi
|
||||
.spyOn(HTMLInputElement.prototype, 'click')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<CoverEditorFlowHarness />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'AI 生成' }));
|
||||
await user.click(screen.getByText('上传封面参考图'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hostBridgeServices.importHostImageFile).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText('已载入封面参考图')).toBeTruthy();
|
||||
});
|
||||
expect(inputClickSpy).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('img', { name: '封面参考图' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('作品封面参考图取消原生导入时不触发浏览器文件输入', async () => {
|
||||
vi.mocked(hostBridgeServices.canUseNativeHostCapability).mockImplementation(
|
||||
(capability) => capability === 'file.importImage',
|
||||
);
|
||||
vi.mocked(hostBridgeServices.importHostImageFile).mockResolvedValue(false);
|
||||
const inputClickSpy = vi
|
||||
.spyOn(HTMLInputElement.prototype, 'click')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
const user = userEvent.setup();
|
||||
render(<CoverEditorFlowHarness />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'AI 生成' }));
|
||||
await user.click(screen.getByText('上传封面参考图'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hostBridgeServices.importHostImageFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(inputClickSpy).not.toHaveBeenCalled();
|
||||
expect(screen.queryByText('已载入封面参考图')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -8,12 +8,14 @@ type ResolvedAssetImageProps = Omit<
|
||||
'src'
|
||||
> & {
|
||||
src?: string | null;
|
||||
objectKey?: string | null;
|
||||
fallbackSrc?: string | null;
|
||||
refreshKey?: string | number | null;
|
||||
};
|
||||
|
||||
export function ResolvedAssetImage({
|
||||
src,
|
||||
objectKey,
|
||||
fallbackSrc,
|
||||
alt,
|
||||
refreshKey,
|
||||
@@ -21,6 +23,7 @@ export function ResolvedAssetImage({
|
||||
...rest
|
||||
}: ResolvedAssetImageProps) {
|
||||
const { resolvedUrl, isResolving, shouldResolve } = useResolvedAssetReadUrl(src, {
|
||||
objectKey,
|
||||
refreshKey,
|
||||
});
|
||||
const normalizedSource = src?.trim() ?? '';
|
||||
|
||||
@@ -7,17 +7,20 @@ type ResolvedAssetVideoProps = Omit<
|
||||
'src'
|
||||
> & {
|
||||
src?: string | null;
|
||||
objectKey?: string | null;
|
||||
fallbackSrc?: string | null;
|
||||
refreshKey?: string | number | null;
|
||||
};
|
||||
|
||||
export function ResolvedAssetVideo({
|
||||
src,
|
||||
objectKey,
|
||||
fallbackSrc,
|
||||
refreshKey,
|
||||
...rest
|
||||
}: ResolvedAssetVideoProps) {
|
||||
const { resolvedUrl } = useResolvedAssetReadUrl(src, {
|
||||
objectKey,
|
||||
refreshKey,
|
||||
});
|
||||
const finalSrc = resolvedUrl || fallbackSrc?.trim() || '';
|
||||
|
||||
@@ -2,14 +2,20 @@
|
||||
|
||||
import { act, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { AuthSessionSummary, AuthUser } from '../../services/authService';
|
||||
import { LEGAL_CONSENT_STORAGE_KEY } from '../common/legalDocuments';
|
||||
import { AuthGate, setAuthGateReloadForTest } from './AuthGate';
|
||||
import {
|
||||
AuthGate,
|
||||
setAuthGateBrowserReloadForTest,
|
||||
setAuthGateReloadForTest,
|
||||
} from './AuthGate';
|
||||
import { useAuthUi } from './AuthUiContext';
|
||||
|
||||
const browserReloadMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
const authMocks = vi.hoisted(() => ({
|
||||
authEntry: vi.fn(),
|
||||
changePassword: vi.fn(),
|
||||
@@ -26,8 +32,6 @@ const authMocks = vi.hoisted(() => ({
|
||||
getAuthAuditLogs: vi.fn(),
|
||||
getAuthRiskBlocks: vi.fn(),
|
||||
getAuthSessions: vi.fn(),
|
||||
isWechatMiniProgramWebViewRuntime: vi.fn(() => false),
|
||||
requestWechatMiniProgramPhoneLogin: vi.fn(),
|
||||
revokeAuthSessions: vi.fn(),
|
||||
sendPhoneLoginCode: vi.fn(),
|
||||
startWechatLogin: vi.fn(),
|
||||
@@ -62,14 +66,10 @@ vi.mock('../../services/authService', () => ({
|
||||
getCurrentAuthUser: authMocks.getCurrentAuthUser,
|
||||
getAuthSessions: authMocks.getAuthSessions,
|
||||
getCaptchaChallengeFromError: vi.fn(() => null),
|
||||
isWechatMiniProgramWebViewRuntime:
|
||||
authMocks.isWechatMiniProgramWebViewRuntime,
|
||||
liftAuthRiskBlock: vi.fn(),
|
||||
loginWithPhoneCode: authMocks.loginWithPhoneCode,
|
||||
logoutAllAuthSessions: authMocks.logoutAllAuthSessions,
|
||||
logoutAuthUser: authMocks.logoutAuthUser,
|
||||
requestWechatMiniProgramPhoneLogin:
|
||||
authMocks.requestWechatMiniProgramPhoneLogin,
|
||||
redeemRegistrationInviteCode: authMocks.redeemRegistrationInviteCode,
|
||||
resetPassword: authMocks.resetPassword,
|
||||
revokeAuthSessions: authMocks.revokeAuthSessions,
|
||||
@@ -78,6 +78,28 @@ vi.mock('../../services/authService', () => ({
|
||||
startWechatLogin: authMocks.startWechatLogin,
|
||||
}));
|
||||
|
||||
const hostBridgeMocks = vi.hoisted(() => ({
|
||||
getHostRuntime: vi.fn(() => ({
|
||||
kind: 'browser',
|
||||
clientType: null as string | null,
|
||||
clientRuntime: null as string | null,
|
||||
hostShell: null as string | null,
|
||||
hostPlatform: null as string | null,
|
||||
hostVersion: null as string | null,
|
||||
hostCapabilities: [],
|
||||
nativeRuntimeTrusted: false,
|
||||
miniProgramEnv: null as string | null,
|
||||
})),
|
||||
requestHostLogin: vi.fn(),
|
||||
reloadHostWebView: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/host-bridge/hostBridge', () => ({
|
||||
getHostRuntime: hostBridgeMocks.getHostRuntime,
|
||||
requestHostLogin: hostBridgeMocks.requestHostLogin,
|
||||
reloadHostWebView: hostBridgeMocks.reloadHostWebView,
|
||||
}));
|
||||
|
||||
vi.mock('../../hooks/useGameSettings', () => ({
|
||||
useGameSettings: () => ({
|
||||
musicVolume: 0.42,
|
||||
@@ -118,6 +140,7 @@ beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
window.history.replaceState(null, '', '/');
|
||||
setAuthGateReloadForTest(vi.fn());
|
||||
setAuthGateBrowserReloadForTest(browserReloadMock);
|
||||
authMocks.consumeAuthCallbackResult.mockReturnValue(null);
|
||||
authMocks.ensureStoredAccessToken.mockResolvedValue('jwt-existing-token');
|
||||
authMocks.getStoredAccessToken.mockReturnValue('');
|
||||
@@ -165,12 +188,24 @@ beforeEach(() => {
|
||||
expiresInSeconds: 300,
|
||||
});
|
||||
authMocks.startWechatLogin.mockResolvedValue(undefined);
|
||||
authMocks.isWechatMiniProgramWebViewRuntime.mockReturnValue(false);
|
||||
authMocks.requestWechatMiniProgramPhoneLogin.mockResolvedValue(true);
|
||||
hostBridgeMocks.getHostRuntime.mockReturnValue({
|
||||
kind: 'browser',
|
||||
clientType: null,
|
||||
clientRuntime: null,
|
||||
hostShell: null,
|
||||
hostPlatform: null,
|
||||
hostVersion: null,
|
||||
hostCapabilities: [],
|
||||
nativeRuntimeTrusted: false,
|
||||
miniProgramEnv: null,
|
||||
});
|
||||
hostBridgeMocks.requestHostLogin.mockResolvedValue(true);
|
||||
hostBridgeMocks.reloadHostWebView.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setAuthGateReloadForTest(null);
|
||||
setAuthGateBrowserReloadForTest(null);
|
||||
});
|
||||
|
||||
async function acceptLegalConsent(
|
||||
@@ -250,6 +285,16 @@ function AccountPanelProbe() {
|
||||
);
|
||||
}
|
||||
|
||||
function AutoOpenLoginProbe() {
|
||||
const authUi = useAuthUi();
|
||||
|
||||
useEffect(() => {
|
||||
authUi?.openLoginModal();
|
||||
}, [authUi]);
|
||||
|
||||
return <div>编辑器内容</div>;
|
||||
}
|
||||
|
||||
test('auth gate keeps platform content visible when phone login is available', async () => {
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: ['phone'],
|
||||
@@ -266,6 +311,22 @@ test('auth gate keeps platform content visible when phone login is available', a
|
||||
expect(screen.queryByText('先登录账号,再同步你的冒险进度。')).toBeNull();
|
||||
});
|
||||
|
||||
test('auth gate portals page-level login requests above fullscreen content', async () => {
|
||||
const { container } = render(
|
||||
<AuthGate>
|
||||
<div className="image-canvas-editor">全屏画布</div>
|
||||
<AutoOpenLoginProbe />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('编辑器内容')).toBeTruthy();
|
||||
const dialog = await screen.findByRole('dialog', { name: '账号入口' });
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
expect(dialog.parentElement?.parentElement).toBe(document.body);
|
||||
expect(dialog.parentElement?.className).toContain('z-[120]');
|
||||
});
|
||||
|
||||
test('auth gate waits for refresh cookie rotation before exposing restored user content', async () => {
|
||||
let resolveToken!: (token: string) => void;
|
||||
const tokenPromise = new Promise<string>((resolve) => {
|
||||
@@ -445,7 +506,17 @@ test('auth gate opens a login modal for protected actions and resumes after logi
|
||||
|
||||
test('auth gate uses mini program auth bridge instead of opening login modal in mini program runtime', async () => {
|
||||
const user = userEvent.setup();
|
||||
authMocks.isWechatMiniProgramWebViewRuntime.mockReturnValue(true);
|
||||
hostBridgeMocks.getHostRuntime.mockReturnValue({
|
||||
kind: 'wechat_mini_program',
|
||||
clientType: null,
|
||||
clientRuntime: 'wechat_mini_program',
|
||||
hostShell: null,
|
||||
hostPlatform: null,
|
||||
hostVersion: null,
|
||||
hostCapabilities: [],
|
||||
nativeRuntimeTrusted: false,
|
||||
miniProgramEnv: null,
|
||||
});
|
||||
authMocks.getAuthLoginOptions.mockResolvedValue({
|
||||
availableLoginMethods: ['phone', 'wechat'],
|
||||
});
|
||||
@@ -459,13 +530,11 @@ test('auth gate uses mini program auth bridge instead of opening login modal in
|
||||
await user.click(await screen.findByRole('button', { name: '进入作品' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(authMocks.requestWechatMiniProgramPhoneLogin).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
expect(hostBridgeMocks.requestHostLogin).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(authMocks.startWechatLogin).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole('dialog', { name: '账号入口' })).toBeNull();
|
||||
expect(authMocks.isWechatMiniProgramWebViewRuntime).toHaveBeenCalled();
|
||||
expect(hostBridgeMocks.getHostRuntime).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('login modal requires first-time legal consent before sms login', async () => {
|
||||
@@ -751,6 +820,56 @@ test('logout withdraws user context before backend request finishes', async () =
|
||||
expect(reload).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('auth state reload uses native host webview reload before browser reload', async () => {
|
||||
const user = userEvent.setup();
|
||||
setAuthGateReloadForTest(null);
|
||||
hostBridgeMocks.reloadHostWebView.mockResolvedValueOnce(true);
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: mockUser,
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<LogoutStateProbe />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '退出登录' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hostBridgeMocks.reloadHostWebView).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(browserReloadMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('auth state reload falls back to browser reload when native host cannot reload', async () => {
|
||||
const user = userEvent.setup();
|
||||
setAuthGateReloadForTest(null);
|
||||
hostBridgeMocks.reloadHostWebView.mockResolvedValueOnce(false);
|
||||
authMocks.getCurrentAuthUser.mockResolvedValue({
|
||||
user: mockUser,
|
||||
availableLoginMethods: ['phone'],
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthGate>
|
||||
<LogoutStateProbe />
|
||||
</AuthGate>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('当前用户:测试玩家')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '退出登录' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hostBridgeMocks.reloadHostWebView).toHaveBeenCalledTimes(1);
|
||||
expect(browserReloadMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('auth gate shows sms send feedback in the login modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
@@ -32,19 +34,22 @@ import {
|
||||
getAuthSessions,
|
||||
getCaptchaChallengeFromError,
|
||||
getCurrentAuthUser,
|
||||
isWechatMiniProgramWebViewRuntime,
|
||||
liftAuthRiskBlock,
|
||||
loginWithPhoneCode,
|
||||
logoutAllAuthSessions,
|
||||
logoutAuthUser,
|
||||
redeemRegistrationInviteCode,
|
||||
requestWechatMiniProgramPhoneLogin,
|
||||
resetPassword,
|
||||
revokeAuthSessions,
|
||||
sendPhoneLoginCode,
|
||||
setStoredLastLoginPhone,
|
||||
startWechatLogin,
|
||||
} from '../../services/authService';
|
||||
import {
|
||||
getHostRuntime,
|
||||
reloadHostWebView,
|
||||
requestHostLogin,
|
||||
} from '../../services/host-bridge/hostBridge';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { AccountModal } from './AccountModal';
|
||||
import { AuthUiContext, type PlatformSettingsSection } from './AuthUiContext';
|
||||
@@ -66,12 +71,31 @@ type AuthStatus =
|
||||
|
||||
const REQUIRED_LOGIN_METHODS: AuthLoginMethod[] = ['phone', 'password'];
|
||||
|
||||
let reloadCurrentPageForAuthStateChange = () => {
|
||||
let reloadBrowserPageForAuthStateChange = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
function reloadHostPageForAuthStateChange() {
|
||||
void reloadHostWebView()
|
||||
.then((handled) => {
|
||||
if (!handled) {
|
||||
reloadBrowserPageForAuthStateChange();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
reloadBrowserPageForAuthStateChange();
|
||||
});
|
||||
}
|
||||
|
||||
let reloadCurrentPageForAuthStateChange = reloadHostPageForAuthStateChange;
|
||||
|
||||
export function setAuthGateReloadForTest(handler: (() => void) | null) {
|
||||
reloadCurrentPageForAuthStateChange =
|
||||
handler ?? reloadHostPageForAuthStateChange;
|
||||
}
|
||||
|
||||
export function setAuthGateBrowserReloadForTest(handler: (() => void) | null) {
|
||||
reloadBrowserPageForAuthStateChange =
|
||||
handler ??
|
||||
(() => {
|
||||
window.location.reload();
|
||||
@@ -328,7 +352,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
const requestMiniProgramLogin = useCallback(() => {
|
||||
setWechatLoading(true);
|
||||
setError('');
|
||||
void requestWechatMiniProgramPhoneLogin()
|
||||
void requestHostLogin()
|
||||
.catch((miniProgramError) => {
|
||||
setError(
|
||||
miniProgramError instanceof Error
|
||||
@@ -349,7 +373,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
}
|
||||
|
||||
pendingProtectedActionRef.current = postLoginAction ?? null;
|
||||
if (isWechatMiniProgramWebViewRuntime()) {
|
||||
if (getHostRuntime().kind === 'wechat_mini_program') {
|
||||
setShowLoginModal(false);
|
||||
requestMiniProgramLogin();
|
||||
return;
|
||||
@@ -761,7 +785,7 @@ export function AuthGate({ children }: AuthGateProps) {
|
||||
<PlatformActionButton
|
||||
className="mt-5"
|
||||
onClick={() => {
|
||||
window.location.reload();
|
||||
reloadCurrentPageForAuthStateChange();
|
||||
}}
|
||||
>
|
||||
重新尝试
|
||||
|
||||
@@ -50,6 +50,11 @@ test('绑定手机号表单复用平台输入和字段标题', async () => {
|
||||
expect(screen.getByText('当前登录身份:微信旅人').className).toContain(
|
||||
'platform-subpanel',
|
||||
);
|
||||
expect(
|
||||
document
|
||||
.querySelector('.selection-hero-brand__image')
|
||||
?.getAttribute('src'),
|
||||
).toBe('/branding/taonier-product-ip.png');
|
||||
|
||||
await user.type(phoneInput, '13800000000');
|
||||
await user.type(codeInput, '123456');
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
import type { PlatformTheme } from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { AuthCaptchaChallenge, AuthUser } from '../../services/authService';
|
||||
import { BRAND_ASSETS } from '../../uiAssets';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformFieldLabel } from '../common/PlatformFieldLabel';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
@@ -67,7 +68,16 @@ export function BindPhoneScreen({
|
||||
<div className="platform-auth-card grid w-full max-w-4xl overflow-hidden rounded-[28px] md:grid-cols-[1.05fr_0.95fr]">
|
||||
<div className="border-b border-[var(--platform-subpanel-border)] bg-[linear-gradient(135deg,rgba(204,117,76,0.18),rgba(240,203,169,0.16))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
|
||||
<div className="selection-hero-brand selection-hero-brand--left">
|
||||
<div className="selection-hero-brand__title">陶泥儿</div>
|
||||
<div className="selection-hero-brand__lockup">
|
||||
<img
|
||||
src={BRAND_ASSETS.taonierProductIp}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
className="selection-hero-brand__image"
|
||||
/>
|
||||
<div className="selection-hero-brand__title">陶泥儿</div>
|
||||
</div>
|
||||
<div className="selection-hero-brand__subtitle">视觉叙事 RPG</div>
|
||||
</div>
|
||||
<p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-[var(--platform-cool-text)]">
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
AuthLoginMethod,
|
||||
} from '../../services/authService';
|
||||
import { getStoredLastLoginPhone } from '../../services/authService';
|
||||
import { isWechatMiniProgramWebViewRuntime } from '../../services/authService';
|
||||
import { getHostRuntime } from '../../services/host-bridge/hostBridge';
|
||||
import { LegalDocumentModal } from '../common/LegalDocumentModal';
|
||||
import {
|
||||
getLegalDocument,
|
||||
@@ -96,7 +96,7 @@ export function LoginScreen({
|
||||
const passwordLoginEnabled = true;
|
||||
const phoneLoginEnabled = true;
|
||||
const wechatLoginEnabled = availableLoginMethods.includes('wechat');
|
||||
const miniProgramRuntime = isWechatMiniProgramWebViewRuntime();
|
||||
const miniProgramRuntime = getHostRuntime().kind === 'wechat_mini_program';
|
||||
const [activeLoginTab, setActiveLoginTab] = useState<LoginTab>('phone');
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { PlatformAuthModalShell } from './PlatformAuthModalShell';
|
||||
test('renders auth modal shell with platform theme and auth card chrome', () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(
|
||||
const { container } = render(
|
||||
<PlatformAuthModalShell
|
||||
title="账号入口"
|
||||
platformTheme="light"
|
||||
@@ -22,6 +22,8 @@ test('renders auth modal shell with platform theme and auth card chrome', () =>
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: '账号入口' });
|
||||
|
||||
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
||||
expect(document.body.contains(dialog)).toBe(true);
|
||||
expect(dialog.parentElement?.className).toContain('platform-theme--light');
|
||||
expect(dialog.className).toContain('platform-modal-shell');
|
||||
expect(dialog.className).toContain('platform-auth-card');
|
||||
|
||||
@@ -55,7 +55,6 @@ export function PlatformAuthModalShell({
|
||||
closeVariant="platformIcon"
|
||||
closeOnBackdrop
|
||||
closeOnEscape={false}
|
||||
portal={false}
|
||||
size={size}
|
||||
showHeader={showHeader}
|
||||
zIndexClassName={zIndexClassName}
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
regenerateBarkBattleImageAsset,
|
||||
uploadBarkBattleAsset,
|
||||
} from '../../services/bark-battle-creation';
|
||||
import {
|
||||
canUseNativeHostCapability,
|
||||
importHostImageFile,
|
||||
} from '../../services/host-bridge/hostBridge';
|
||||
import { BarkBattleResultView } from './BarkBattleResultView';
|
||||
|
||||
vi.mock('../../services/bark-battle-creation', () => ({
|
||||
@@ -15,6 +19,11 @@ vi.mock('../../services/bark-battle-creation', () => ({
|
||||
uploadBarkBattleAsset: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/host-bridge/hostBridge', () => ({
|
||||
canUseNativeHostCapability: vi.fn(),
|
||||
importHostImageFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: ({
|
||||
src,
|
||||
@@ -41,6 +50,13 @@ const draft = {
|
||||
};
|
||||
|
||||
describe('BarkBattleResultView', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(uploadBarkBattleAsset).mockReset();
|
||||
vi.mocked(regenerateBarkBattleImageAsset).mockReset();
|
||||
vi.mocked(canUseNativeHostCapability).mockReset();
|
||||
vi.mocked(importHostImageFile).mockReset();
|
||||
});
|
||||
|
||||
it('exposes draft preview actions before publish', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onStartTestRun = vi.fn();
|
||||
@@ -168,6 +184,153 @@ describe('BarkBattleResultView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('imports replacement image assets through native HostBridge', async () => {
|
||||
const user = userEvent.setup();
|
||||
const inputClickSpy = vi
|
||||
.spyOn(HTMLInputElement.prototype, 'click')
|
||||
.mockImplementation(() => undefined);
|
||||
const onDraftChange = vi.fn();
|
||||
vi.mocked(canUseNativeHostCapability).mockImplementation(
|
||||
(capability) => capability === 'file.importImage',
|
||||
);
|
||||
vi.mocked(importHostImageFile).mockResolvedValue({
|
||||
action: 'selected',
|
||||
fileName: '玩家形象.png',
|
||||
base64Data: 'aW1hZ2U=',
|
||||
mimeType: 'image/png',
|
||||
bytes: 5,
|
||||
});
|
||||
vi.mocked(uploadBarkBattleAsset).mockResolvedValue({
|
||||
assetObjectId: 'asset-player-1',
|
||||
assetKind: 'bark_battle_player_character_image',
|
||||
objectKey: 'generated-bark-battle-assets/player.png',
|
||||
assetSrc: '/generated-bark-battle-assets/player.png',
|
||||
});
|
||||
|
||||
try {
|
||||
render(
|
||||
<BarkBattleResultView
|
||||
draft={draft}
|
||||
onBack={() => {}}
|
||||
onDraftChange={onDraftChange}
|
||||
onStartTestRun={() => {}}
|
||||
onPublish={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const playerSlot = screen
|
||||
.getByRole('heading', { name: '玩家形象' })
|
||||
.closest('article');
|
||||
expect(playerSlot).toBeTruthy();
|
||||
await user.click(
|
||||
within(playerSlot as HTMLElement).getByRole('button', {
|
||||
name: '上传',
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(uploadBarkBattleAsset).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
slot: 'player-character',
|
||||
draftId: 'bark-battle-draft-1',
|
||||
file: expect.any(File),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const uploadedFile = vi.mocked(uploadBarkBattleAsset).mock.calls[0]?.[0]
|
||||
.file;
|
||||
expect(importHostImageFile).toHaveBeenCalledTimes(1);
|
||||
expect(uploadedFile?.name).toBe('玩家形象.png');
|
||||
expect(uploadedFile?.type).toBe('image/png');
|
||||
expect(uploadedFile?.size).toBe(5);
|
||||
expect(inputClickSpy).not.toHaveBeenCalled();
|
||||
expect(onDraftChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
playerCharacterImageSrc: '/generated-bark-battle-assets/player.png',
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
inputClickSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps native image cancellation inside shell flow', async () => {
|
||||
const user = userEvent.setup();
|
||||
const inputClickSpy = vi
|
||||
.spyOn(HTMLInputElement.prototype, 'click')
|
||||
.mockImplementation(() => undefined);
|
||||
vi.mocked(canUseNativeHostCapability).mockImplementation(
|
||||
(capability) => capability === 'file.importImage',
|
||||
);
|
||||
vi.mocked(importHostImageFile).mockResolvedValue(false);
|
||||
|
||||
try {
|
||||
render(
|
||||
<BarkBattleResultView
|
||||
draft={draft}
|
||||
onBack={() => {}}
|
||||
onDraftChange={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
onPublish={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const playerSlot = screen
|
||||
.getByRole('heading', { name: '玩家形象' })
|
||||
.closest('article');
|
||||
expect(playerSlot).toBeTruthy();
|
||||
await user.click(
|
||||
within(playerSlot as HTMLElement).getByRole('button', {
|
||||
name: '上传',
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(importHostImageFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(uploadBarkBattleAsset).not.toHaveBeenCalled();
|
||||
expect(inputClickSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
inputClickSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to browser picker without native image capability', async () => {
|
||||
const user = userEvent.setup();
|
||||
const inputClickSpy = vi
|
||||
.spyOn(HTMLInputElement.prototype, 'click')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
try {
|
||||
render(
|
||||
<BarkBattleResultView
|
||||
draft={draft}
|
||||
onBack={() => {}}
|
||||
onDraftChange={() => {}}
|
||||
onStartTestRun={() => {}}
|
||||
onPublish={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const playerSlot = screen
|
||||
.getByRole('heading', { name: '玩家形象' })
|
||||
.closest('article');
|
||||
expect(playerSlot).toBeTruthy();
|
||||
await user.click(
|
||||
within(playerSlot as HTMLElement).getByRole('button', {
|
||||
name: '上传',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(inputClickSpy).toHaveBeenCalledTimes(1);
|
||||
expect(importHostImageFile).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
inputClickSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not render the raw object key or asset path in the slot summary', () => {
|
||||
render(
|
||||
<BarkBattleResultView
|
||||
|
||||
@@ -24,6 +24,10 @@ import {
|
||||
regenerateBarkBattleImageAsset,
|
||||
uploadBarkBattleAsset,
|
||||
} from '../../services/bark-battle-creation';
|
||||
import {
|
||||
canUseNativeHostCapability,
|
||||
importHostImageFile,
|
||||
} from '../../services/host-bridge/hostBridge';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformPillBadge } from '../common/PlatformPillBadge';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
@@ -103,6 +107,20 @@ function getSlotAssetSrc(
|
||||
return '';
|
||||
}
|
||||
|
||||
function base64ImageToFile(
|
||||
base64Data: string,
|
||||
fileName: string,
|
||||
mimeType: string,
|
||||
) {
|
||||
const binary = atob(base64Data);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
|
||||
return new File([bytes], fileName, { type: mimeType });
|
||||
}
|
||||
|
||||
function ResultActionButton({
|
||||
children,
|
||||
disabled,
|
||||
@@ -140,15 +158,14 @@ function BarkBattleAssetSlotControl({
|
||||
onError: (message: string | null) => void;
|
||||
}) {
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [isImportingImage, setIsImportingImage] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
const assetSrc = getSlotAssetSrc(draft, slot);
|
||||
const assetStatus = assetSrc ? '已替换' : '未替换';
|
||||
|
||||
const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.currentTarget.files?.[0] ?? null;
|
||||
event.currentTarget.value = '';
|
||||
if (!file) {
|
||||
const uploadFile = async (file: File) => {
|
||||
if (isUploading || isRegenerating) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -169,6 +186,49 @@ function BarkBattleAssetSlotControl({
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.currentTarget.files?.[0] ?? null;
|
||||
event.currentTarget.value = '';
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
await uploadFile(file);
|
||||
};
|
||||
|
||||
const openUploadPicker = () => {
|
||||
if (disabled || isImportingImage || isUploading || isRegenerating) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canUseNativeHostCapability('file.importImage')) {
|
||||
void (async () => {
|
||||
setIsImportingImage(true);
|
||||
try {
|
||||
const importedImage = await importHostImageFile();
|
||||
if (!importedImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
await uploadFile(
|
||||
base64ImageToFile(
|
||||
importedImage.base64Data,
|
||||
importedImage.fileName,
|
||||
importedImage.mimeType,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setIsImportingImage(false);
|
||||
}
|
||||
})().catch((error) => {
|
||||
onError(error instanceof Error ? error.message : '上传素材失败。');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleRegenerate = async () => {
|
||||
setIsRegenerating(true);
|
||||
onError(null);
|
||||
@@ -187,7 +247,7 @@ function BarkBattleAssetSlotControl({
|
||||
}
|
||||
};
|
||||
|
||||
const isSlotBusy = isUploading || isRegenerating;
|
||||
const isSlotBusy = isImportingImage || isUploading || isRegenerating;
|
||||
|
||||
return (
|
||||
<PlatformSubpanel
|
||||
@@ -218,12 +278,13 @@ function BarkBattleAssetSlotControl({
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="hidden"
|
||||
disabled={disabled || isSlotBusy}
|
||||
aria-label={`上传${SLOT_LABELS[slot]}文件`}
|
||||
onChange={handleUpload}
|
||||
/>
|
||||
<PlatformActionButton
|
||||
disabled={disabled || isSlotBusy}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onClick={openUploadPicker}
|
||||
tone="secondary"
|
||||
size="xs"
|
||||
shape="pill"
|
||||
|
||||
@@ -4,10 +4,20 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
resetHostRuntimeCacheForTest,
|
||||
setHostRuntimeCacheForTest,
|
||||
} from '../../services/host-bridge/hostBridge';
|
||||
import { resetNativeAppHostBridgeForTest } from '../../services/host-bridge/nativeAppHostBridge';
|
||||
import type { CreativeAudioAsset } from './creativeAudioFileAsset';
|
||||
import { CreativeAudioInputPanel } from './CreativeAudioInputPanel';
|
||||
|
||||
type TestAudioAsset = CreativeAudioAsset;
|
||||
type ExportableTestAudioAsset = TestAudioAsset & {
|
||||
blob: Blob;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
const originalMediaRecorder = globalThis.MediaRecorder;
|
||||
const originalMediaDevices = navigator.mediaDevices;
|
||||
@@ -18,6 +28,10 @@ afterEach(() => {
|
||||
configurable: true,
|
||||
value: originalMediaDevices,
|
||||
});
|
||||
window.history.replaceState(null, '', '/');
|
||||
delete window.__TAURI__;
|
||||
resetNativeAppHostBridgeForTest();
|
||||
resetHostRuntimeCacheForTest();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -34,6 +48,28 @@ function buildAsset(overrides: Partial<TestAudioAsset> = {}): TestAudioAsset {
|
||||
};
|
||||
}
|
||||
|
||||
function buildExportableAsset(
|
||||
overrides: Partial<ExportableTestAudioAsset> = {},
|
||||
): ExportableTestAudioAsset {
|
||||
return {
|
||||
...buildAsset(),
|
||||
blob: new Blob(['audio'], { type: 'audio/wav' }),
|
||||
fileName: 'hit.wav',
|
||||
mimeType: 'audio/wav',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function trustNativeHostRuntime(capabilities: Parameters<typeof setHostRuntimeCacheForTest>[0]['capabilities']) {
|
||||
setHostRuntimeCacheForTest({
|
||||
shell: 'tauri_desktop',
|
||||
platform: 'linux',
|
||||
hostVersion: '0.1.0',
|
||||
bridgeVersion: 1,
|
||||
capabilities,
|
||||
});
|
||||
}
|
||||
|
||||
function renderPanel(
|
||||
overrides: Partial<
|
||||
ComponentProps<typeof CreativeAudioInputPanel<TestAudioAsset>>
|
||||
@@ -159,6 +195,178 @@ test('上传音频成功后清空错误并写入资产', async () => {
|
||||
expect(onError).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
test('原生 App 宿主可用时上传按钮走 HostBridge 音频导入', async () => {
|
||||
const invoke = vi.fn(
|
||||
async (_command: string, args?: Record<string, unknown>) => {
|
||||
const request = (args as { request: { id: string } }).request;
|
||||
return {
|
||||
bridge: 'GenarrativeHostBridge',
|
||||
version: 1,
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: {
|
||||
action: 'selected',
|
||||
fileName: 'hit.webm',
|
||||
base64Data: 'YXVkaW8=',
|
||||
mimeType: 'audio/webm',
|
||||
bytes: 5,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/?clientRuntime=native_app&hostCapabilities=file.importAudio',
|
||||
);
|
||||
trustNativeHostRuntime(['file.importAudio']);
|
||||
window.__TAURI__ = {
|
||||
core: {
|
||||
invoke: async <Result,>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => (await invoke(command, args)) as Result,
|
||||
},
|
||||
};
|
||||
const { readFileAsAsset, onAssetChange, onError } = renderPanel();
|
||||
|
||||
fireEvent.click(screen.getByText('上传'));
|
||||
|
||||
await waitFor(() => expect(readFileAsAsset).toHaveBeenCalledTimes(1));
|
||||
const [file, source] = readFileAsAsset.mock.calls[0]!;
|
||||
expect(file).toBeInstanceOf(File);
|
||||
expect((file as File).name).toBe('hit.webm');
|
||||
expect((file as File).type).toBe('audio/webm');
|
||||
expect(source).toBe('uploaded');
|
||||
await waitFor(() => expect(onAssetChange).toHaveBeenCalledTimes(1));
|
||||
expect(onError).toHaveBeenCalledWith(null);
|
||||
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
||||
request: expect.objectContaining({
|
||||
method: 'file.importAudio',
|
||||
timeoutMs: 30000,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
test('原生 App 宿主可用且当前音频为本地资产时可以导出音频', async () => {
|
||||
const invoke = vi.fn(
|
||||
async (_command: string, args?: Record<string, unknown>) => {
|
||||
const request = (args as { request: { id: string } }).request;
|
||||
return {
|
||||
bridge: 'GenarrativeHostBridge',
|
||||
version: 1,
|
||||
id: request.id,
|
||||
ok: true,
|
||||
result: {
|
||||
action: 'saved',
|
||||
fileName: 'hit.wav',
|
||||
bytes: 5,
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/?clientRuntime=native_app&hostCapabilities=file.exportAudio',
|
||||
);
|
||||
trustNativeHostRuntime(['file.exportAudio']);
|
||||
window.__TAURI__ = {
|
||||
core: {
|
||||
invoke: async <Result,>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => (await invoke(command, args)) as Result,
|
||||
},
|
||||
};
|
||||
const { onError } = renderPanel({
|
||||
asset: buildExportableAsset(),
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '导出' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
||||
request: expect.objectContaining({
|
||||
method: 'file.exportAudio',
|
||||
payload: {
|
||||
fileName: 'hit.wav',
|
||||
base64Data: 'YXVkaW8=',
|
||||
mimeType: 'audio/wav',
|
||||
},
|
||||
timeoutMs: 30000,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(onError).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
test('非本地音频资产或未声明能力时不显示导出入口', () => {
|
||||
const { rerender } = renderPanel({
|
||||
asset: buildExportableAsset(),
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('button', { name: '导出' })).toBeNull();
|
||||
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/?clientRuntime=native_app&hostCapabilities=file.exportAudio',
|
||||
);
|
||||
trustNativeHostRuntime(['file.exportAudio']);
|
||||
rerender(
|
||||
<CreativeAudioInputPanel<TestAudioAsset>
|
||||
title="敲击音效"
|
||||
defaultLabel="默认木鱼音"
|
||||
asset={buildAsset({ audioSrc: '/generated/hit.wav' })}
|
||||
buildRecordedFileName={() => 'recorded-hit.webm'}
|
||||
onAssetChange={() => {}}
|
||||
onError={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: '导出' })).toBeNull();
|
||||
});
|
||||
|
||||
test('导出音频失败时提示错误', async () => {
|
||||
const invoke = vi.fn(
|
||||
async (_command: string, args?: Record<string, unknown>) => {
|
||||
const request = (args as { request: { id: string } }).request;
|
||||
return {
|
||||
bridge: 'GenarrativeHostBridge',
|
||||
version: 1,
|
||||
id: request.id,
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'host_error',
|
||||
message: '系统保存失败。',
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/?clientRuntime=native_app&hostCapabilities=file.exportAudio',
|
||||
);
|
||||
trustNativeHostRuntime(['file.exportAudio']);
|
||||
window.__TAURI__ = {
|
||||
core: {
|
||||
invoke: async <Result,>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => (await invoke(command, args)) as Result,
|
||||
},
|
||||
};
|
||||
const { onError } = renderPanel({
|
||||
asset: buildExportableAsset(),
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '导出' }));
|
||||
|
||||
await waitFor(() => expect(onError).toHaveBeenCalledWith('系统保存失败。'));
|
||||
});
|
||||
|
||||
test('上传音频失败时提示错误且不写入资产', async () => {
|
||||
const readFileAsAsset = vi.fn(async () => {
|
||||
throw new Error('音频最长 1 秒。');
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Mic, Pause, Upload } from 'lucide-react';
|
||||
import { Download, Mic, Pause, Upload } from 'lucide-react';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
canUseNativeHostCapability,
|
||||
exportHostAudioFile,
|
||||
type HostFileImportAudioResult,
|
||||
importHostAudioFile,
|
||||
} from '../../services/host-bridge/hostBridge';
|
||||
import {
|
||||
type CreativeAudioAsset,
|
||||
readCreativeAudioFileAsAsset,
|
||||
@@ -24,6 +30,67 @@ type CreativeAudioInputPanelProps<TAsset extends CreativeAudioAsset> = {
|
||||
) => Promise<TAsset>;
|
||||
};
|
||||
|
||||
type ExportableCreativeAudioAsset = CreativeAudioAsset & {
|
||||
blob?: Blob;
|
||||
fileName?: string;
|
||||
mimeType?: string;
|
||||
};
|
||||
|
||||
const HOST_EXPORT_AUDIO_MIME_TYPES = new Set([
|
||||
'audio/mpeg',
|
||||
'audio/mp4',
|
||||
'audio/wav',
|
||||
'audio/ogg',
|
||||
'audio/webm',
|
||||
]);
|
||||
|
||||
function resolveExportableAudioAsset<TAsset extends CreativeAudioAsset>(
|
||||
asset: TAsset | null,
|
||||
) {
|
||||
const candidate = asset as ExportableCreativeAudioAsset | null;
|
||||
if (
|
||||
!candidate?.blob ||
|
||||
!(candidate.blob instanceof Blob) ||
|
||||
!candidate.fileName?.trim() ||
|
||||
!candidate.mimeType?.trim() ||
|
||||
!HOST_EXPORT_AUDIO_MIME_TYPES.has(candidate.mimeType)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
blob: candidate.blob,
|
||||
fileName: candidate.fileName.trim(),
|
||||
mimeType: candidate.mimeType as
|
||||
| 'audio/mpeg'
|
||||
| 'audio/mp4'
|
||||
| 'audio/wav'
|
||||
| 'audio/ogg'
|
||||
| 'audio/webm',
|
||||
};
|
||||
}
|
||||
|
||||
function blobToBase64Data(blob: Blob) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(new Error('音频导出失败。'));
|
||||
reader.onload = () => {
|
||||
if (typeof reader.result !== 'string') {
|
||||
reject(new Error('音频导出失败。'));
|
||||
return;
|
||||
}
|
||||
|
||||
const base64Data = reader.result.split(',')[1] ?? '';
|
||||
if (!base64Data) {
|
||||
reject(new Error('音频导出失败。'));
|
||||
return;
|
||||
}
|
||||
resolve(base64Data);
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
|
||||
disabled = false,
|
||||
title,
|
||||
@@ -38,6 +105,49 @@ export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const recorderRef = useRef<MediaRecorder | null>(null);
|
||||
const chunksRef = useRef<BlobPart[]>([]);
|
||||
const canImportHostAudio = canUseNativeHostCapability('file.importAudio');
|
||||
const canExportHostAudio = canUseNativeHostCapability('file.exportAudio');
|
||||
const exportableAsset = resolveExportableAudioAsset(asset);
|
||||
|
||||
const hostAudioImportResultToFile = (result: HostFileImportAudioResult) => {
|
||||
const binary = atob(result.base64Data);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
|
||||
return new File([bytes], result.fileName, {
|
||||
type: result.mimeType,
|
||||
});
|
||||
};
|
||||
|
||||
const importHostAudioAsUploadedAsset = async () => {
|
||||
const result = await importHostAudioFile();
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
const file = hostAudioImportResultToFile(result);
|
||||
const nextAsset = await readFileAsAsset(file, 'uploaded');
|
||||
onError(null);
|
||||
onAssetChange(nextAsset);
|
||||
};
|
||||
|
||||
const exportHostAudioAsset = async () => {
|
||||
if (!exportableAsset) {
|
||||
return;
|
||||
}
|
||||
|
||||
const base64Data = await blobToBase64Data(exportableAsset.blob);
|
||||
const exported = await exportHostAudioFile({
|
||||
fileName: exportableAsset.fileName,
|
||||
base64Data,
|
||||
mimeType: exportableAsset.mimeType,
|
||||
});
|
||||
if (exported) {
|
||||
onError(null);
|
||||
}
|
||||
};
|
||||
|
||||
const startRecording = async () => {
|
||||
if (disabled || isRecording) {
|
||||
@@ -109,17 +219,40 @@ export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
|
||||
}
|
||||
titleVariant="strong"
|
||||
actions={
|
||||
asset ? (
|
||||
<PlatformActionButton
|
||||
onClick={() => onAssetChange(null)}
|
||||
disabled={disabled}
|
||||
tone="ghost"
|
||||
size="xs"
|
||||
className="min-h-0"
|
||||
>
|
||||
重置
|
||||
</PlatformActionButton>
|
||||
) : null
|
||||
<div className="flex items-center gap-2">
|
||||
{canExportHostAudio && exportableAsset ? (
|
||||
<PlatformActionButton
|
||||
onClick={() => {
|
||||
void exportHostAudioAsset().catch((caughtError) => {
|
||||
onError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: '音频导出失败。',
|
||||
);
|
||||
});
|
||||
}}
|
||||
disabled={disabled}
|
||||
tone="ghost"
|
||||
size="xs"
|
||||
className="min-h-0 gap-1"
|
||||
title="导出音频"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
导出
|
||||
</PlatformActionButton>
|
||||
) : null}
|
||||
{asset ? (
|
||||
<PlatformActionButton
|
||||
onClick={() => onAssetChange(null)}
|
||||
disabled={disabled}
|
||||
tone="ghost"
|
||||
size="xs"
|
||||
className="min-h-0"
|
||||
>
|
||||
重置
|
||||
</PlatformActionButton>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
bodyClassName="mt-3 flex flex-wrap items-center gap-2"
|
||||
>
|
||||
@@ -129,6 +262,23 @@ export function CreativeAudioInputPanel<TAsset extends CreativeAudioAsset>({
|
||||
className={`min-h-10 cursor-pointer gap-2 px-3 ${
|
||||
disabled ? 'pointer-events-none opacity-55' : ''
|
||||
}`}
|
||||
onClick={(event) => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
if (!canImportHostAudio) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
void importHostAudioAsUploadedAsset().catch((caughtError) => {
|
||||
onError(
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: '音频读取失败。',
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
上传
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,33 @@
|
||||
import { History, ImagePlus, Loader2, Sparkles, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Camera,
|
||||
History,
|
||||
ImagePlus,
|
||||
Loader2,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { type ReactNode, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
canUseNativeHostCapability,
|
||||
captureHostImageFile,
|
||||
type HostFileImportImageResult,
|
||||
importHostImageFile,
|
||||
subscribeHostImageDrop,
|
||||
} from '../../services/host-bridge/hostBridge';
|
||||
import { puzzleReferenceImageDataUrlToFile } from '../../services/puzzleReferenceImage';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { PlatformActionButton } from './PlatformActionButton';
|
||||
import { PlatformFieldLabel } from './PlatformFieldLabel';
|
||||
import { PlatformIconBadge } from './PlatformIconBadge';
|
||||
import { PlatformIconButton } from './PlatformIconButton';
|
||||
import { PlatformImagePreviewModal } from './PlatformImagePreviewModal';
|
||||
import { PlatformPillBadge } from './PlatformPillBadge';
|
||||
import { PlatformPillSwitch } from './PlatformPillSwitch';
|
||||
import { PlatformStatusMessage } from './PlatformStatusMessage';
|
||||
import { PlatformTextField } from './PlatformTextField';
|
||||
import { PlatformUploadPreviewCard } from './PlatformUploadPreviewCard';
|
||||
import { UnifiedConfirmDialog } from './UnifiedConfirmDialog';
|
||||
import { UnifiedModal } from './UnifiedModal';
|
||||
|
||||
export type CreativeImageInputReferenceImage = {
|
||||
id: string;
|
||||
@@ -52,6 +67,7 @@ export type CreativeImageInputPanelProps = {
|
||||
uploadedImageSrc: string;
|
||||
uploadedImageAlt: string;
|
||||
uploadedImageRefreshKey?: string | number | null;
|
||||
mainImagePreviewZIndexClassName?: string;
|
||||
mainImageMeta?: ReactNode;
|
||||
mainImageInputId: string;
|
||||
mainImageAccept?: string;
|
||||
@@ -85,6 +101,13 @@ export type CreativeImageInputPanelProps = {
|
||||
const DEFAULT_IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp';
|
||||
const DEFAULT_PROMPT_REFERENCE_LIMIT = 5;
|
||||
|
||||
function hostImageImportResultToFile(result: HostFileImportImageResult) {
|
||||
return puzzleReferenceImageDataUrlToFile(
|
||||
`data:${result.mimeType};base64,${result.base64Data}`,
|
||||
result.fileName,
|
||||
);
|
||||
}
|
||||
|
||||
export function CreativeImageInputPanel({
|
||||
className = '',
|
||||
fillHeight = true,
|
||||
@@ -100,6 +123,7 @@ export function CreativeImageInputPanel({
|
||||
uploadedImageSrc,
|
||||
uploadedImageAlt,
|
||||
uploadedImageRefreshKey = null,
|
||||
mainImagePreviewZIndexClassName = 'z-[82]',
|
||||
mainImageMeta = null,
|
||||
mainImageInputId,
|
||||
mainImageAccept = DEFAULT_IMAGE_ACCEPT,
|
||||
@@ -129,7 +153,9 @@ export function CreativeImageInputPanel({
|
||||
onHistoryClick,
|
||||
onSubmit,
|
||||
}: CreativeImageInputPanelProps) {
|
||||
const mainImageCardRef = useRef<HTMLDivElement | null>(null);
|
||||
const mainImageInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const promptReferenceInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [previewReferenceImage, setPreviewReferenceImage] =
|
||||
useState<CreativeImageInputReferenceImage | null>(null);
|
||||
const [isMainImagePreviewOpen, setIsMainImagePreviewOpen] = useState(false);
|
||||
@@ -149,6 +175,11 @@ export function CreativeImageInputPanel({
|
||||
mainImageClickMode === 'preview' && Boolean(uploadedImageSrc);
|
||||
const shouldShowMainImageUploadButton =
|
||||
isMainImageUploadEnabled && shouldPreviewMainImage;
|
||||
const canImportHostImage = canUseNativeHostCapability('file.importImage');
|
||||
const canCaptureHostImage = canUseNativeHostCapability('file.captureImage');
|
||||
const canReceiveHostImageDrop =
|
||||
canUseNativeHostCapability('file.imageDropped');
|
||||
const promptReferenceInputId = `${mainImageInputId}-prompt-reference`;
|
||||
|
||||
useEffect(() => {
|
||||
if (uploadedImageSrc) {
|
||||
@@ -169,6 +200,50 @@ export function CreativeImageInputPanel({
|
||||
}
|
||||
}, [previewReferenceImage, promptReferenceImages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canReceiveHostImageDrop || disabled || !isMainImageUploadEnabled) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return subscribeHostImageDrop((payload) => {
|
||||
const card = mainImageCardRef.current;
|
||||
const position = payload.position;
|
||||
if (!card || !position) {
|
||||
return;
|
||||
}
|
||||
|
||||
const bounds = card.getBoundingClientRect();
|
||||
const isInsideCard =
|
||||
position.x >= bounds.left &&
|
||||
position.x <= bounds.right &&
|
||||
position.y >= bounds.top &&
|
||||
position.y <= bounds.bottom;
|
||||
if (!isInsideCard) {
|
||||
return;
|
||||
}
|
||||
|
||||
const topElement =
|
||||
typeof document.elementFromPoint === 'function'
|
||||
? document.elementFromPoint(position.x, position.y)
|
||||
: null;
|
||||
// 中文注释:桌面拖入是窗口级事件;只让坐标命中的最上层主图槽位消费,避免多个创作面板同时接收同一张图。
|
||||
if (topElement && !card.contains(topElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
onMainImageFileSelect(hostImageImportResultToFile(payload));
|
||||
} catch {
|
||||
// 中文注释:宿主已校验图片类型和体积;这里仅兜住浏览器 File 构造异常,保持当前表单状态。
|
||||
}
|
||||
});
|
||||
}, [
|
||||
canReceiveHostImageDrop,
|
||||
disabled,
|
||||
isMainImageUploadEnabled,
|
||||
onMainImageFileSelect,
|
||||
]);
|
||||
|
||||
const bodyClassName = fillHeight
|
||||
? 'creative-image-input-panel__body puzzle-creation-form-body flex min-h-0 flex-1 flex-col overflow-hidden pr-0 lg:overflow-y-auto lg:pr-1'
|
||||
: 'creative-image-input-panel__body puzzle-creation-form-body flex flex-none flex-col overflow-visible pr-0 lg:pr-1';
|
||||
@@ -186,6 +261,107 @@ export function CreativeImageInputPanel({
|
||||
? 'creative-image-input-panel__image-card puzzle-image-upload-card relative aspect-square h-full min-h-[14rem] max-h-full max-w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_12px_28px_rgba(15,23,42,0.08)] transition sm:min-h-[18rem] lg:h-auto lg:w-full'
|
||||
: 'creative-image-input-panel__image-card puzzle-image-upload-card relative aspect-square w-full min-h-[14rem] max-w-full overflow-hidden rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/90 shadow-[0_12px_28px_rgba(15,23,42,0.08)] transition sm:min-h-[18rem]';
|
||||
|
||||
const importHostImageAsFile = async () => {
|
||||
const result = await importHostImageFile();
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
return hostImageImportResultToFile(result);
|
||||
};
|
||||
|
||||
const captureHostImageAsFile = async () => {
|
||||
const result = await captureHostImageFile();
|
||||
if (!result) {
|
||||
return null;
|
||||
}
|
||||
return hostImageImportResultToFile(result);
|
||||
};
|
||||
|
||||
const handleMainImageUploadClick = () => {
|
||||
if (disabled || !isMainImageUploadEnabled) {
|
||||
return;
|
||||
}
|
||||
if (!canImportHostImage) {
|
||||
mainImageInputRef.current?.click();
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const file = await importHostImageAsFile();
|
||||
if (file) {
|
||||
onMainImageFileSelect(file);
|
||||
}
|
||||
} catch {
|
||||
// 中文注释:宿主导入失败时不再弹浏览器文件框,避免权限失败后重复打扰用户。
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const handleMainImageCaptureClick = () => {
|
||||
if (disabled || !isMainImageUploadEnabled || !canCaptureHostImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const file = await captureHostImageAsFile();
|
||||
if (file) {
|
||||
onMainImageFileSelect(file);
|
||||
}
|
||||
} catch {
|
||||
// 中文注释:相机拍摄失败或用户取消时保持当前表单状态,不回落到相册或文件框。
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const handlePromptReferenceUploadClick = () => {
|
||||
if (
|
||||
promptReferenceUploadDisabled ||
|
||||
!shouldShowPromptReferences ||
|
||||
!onPromptReferenceFilesSelect
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!canImportHostImage) {
|
||||
promptReferenceInputRef.current?.click();
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const file = await importHostImageAsFile();
|
||||
if (file) {
|
||||
onPromptReferenceFilesSelect([file]);
|
||||
}
|
||||
} catch {
|
||||
// 中文注释:宿主导入失败时保持当前表单状态,由外层错误通道继续承接后续重试。
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const handlePromptReferenceCaptureClick = () => {
|
||||
if (
|
||||
promptReferenceUploadDisabled ||
|
||||
!shouldShowPromptReferences ||
|
||||
!onPromptReferenceFilesSelect ||
|
||||
!canCaptureHostImage
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const file = await captureHostImageAsFile();
|
||||
if (file) {
|
||||
onPromptReferenceFilesSelect([file]);
|
||||
}
|
||||
} catch {
|
||||
// 中文注释:相机拍摄失败不打断当前创作输入,用户可继续选择上传或重试拍摄。
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`creative-image-input-panel flex min-h-0 flex-col ${
|
||||
@@ -210,7 +386,7 @@ export function CreativeImageInputPanel({
|
||||
{labels.imageField}
|
||||
</PlatformFieldLabel>
|
||||
<div className={imageFrameClassName}>
|
||||
<div className={imageCardClassName}>
|
||||
<div ref={mainImageCardRef} className={imageCardClassName}>
|
||||
{isMainImageUploadEnabled ? (
|
||||
<input
|
||||
ref={mainImageInputRef}
|
||||
@@ -238,21 +414,28 @@ export function CreativeImageInputPanel({
|
||||
onClick={() => setIsMainImagePreviewOpen(true)}
|
||||
/>
|
||||
) : isMainImageUploadEnabled ? (
|
||||
<label
|
||||
htmlFor={mainImageInputId}
|
||||
className={`absolute inset-0 z-0 cursor-pointer ${disabled ? 'cursor-not-allowed' : ''}`}
|
||||
<button
|
||||
type="button"
|
||||
className={`absolute inset-0 z-0 border-0 bg-transparent p-0 ${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
disabled={disabled}
|
||||
aria-label={
|
||||
uploadedImageSrc
|
||||
? labels.replaceImage
|
||||
: labels.uploadImage
|
||||
}
|
||||
title={
|
||||
uploadedImageSrc
|
||||
? labels.replaceImage
|
||||
: labels.uploadImage
|
||||
}
|
||||
onClick={handleMainImageUploadClick}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{uploadedImageSrc
|
||||
? labels.replaceImage
|
||||
: labels.uploadImage}
|
||||
</span>
|
||||
</label>
|
||||
</button>
|
||||
) : null}
|
||||
{uploadedImageSrc ? (
|
||||
<ResolvedAssetImage
|
||||
@@ -278,7 +461,7 @@ export function CreativeImageInputPanel({
|
||||
label={labels.replaceImage}
|
||||
title={labels.replaceImage}
|
||||
disabled={disabled}
|
||||
onClick={() => mainImageInputRef.current?.click()}
|
||||
onClick={handleMainImageUploadClick}
|
||||
icon={<ImagePlus className="h-4 w-4" />}
|
||||
className="absolute bottom-3 right-3 z-10 h-10 w-10"
|
||||
/>
|
||||
@@ -296,6 +479,19 @@ export function CreativeImageInputPanel({
|
||||
<span>历史</span>
|
||||
</PlatformIconButton>
|
||||
) : null}
|
||||
{isMainImageUploadEnabled && canCaptureHostImage ? (
|
||||
<PlatformIconButton
|
||||
variant="surfaceFloating"
|
||||
label="拍摄图片"
|
||||
title="拍摄图片"
|
||||
disabled={disabled}
|
||||
onClick={handleMainImageCaptureClick}
|
||||
icon={<Camera className="h-3.5 w-3.5" />}
|
||||
className={`absolute top-3 z-10 h-10 w-10 ${
|
||||
shouldShowHistoryButton ? 'right-[4.75rem]' : 'right-3'
|
||||
}`}
|
||||
/>
|
||||
) : null}
|
||||
{canEditMainImage && uploadedImageSrc && canToggleAiRedraw ? (
|
||||
<PlatformPillSwitch
|
||||
label="AI重绘"
|
||||
@@ -321,16 +517,18 @@ export function CreativeImageInputPanel({
|
||||
className="absolute left-3 top-3 z-10 h-10 w-10"
|
||||
/>
|
||||
) : isMainImageUploadEnabled && !uploadedImageSrc ? (
|
||||
<label
|
||||
htmlFor={mainImageInputId}
|
||||
className={`absolute bottom-9 left-1/2 z-10 -translate-x-1/2 whitespace-nowrap text-center text-sm font-black text-[var(--platform-text-strong)] drop-shadow-[0_1px_0_rgba(255,255,255,0.82)] transition hover:text-[var(--platform-accent)] sm:bottom-10 ${
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={handleMainImageUploadClick}
|
||||
className={`absolute bottom-9 left-1/2 z-10 -translate-x-1/2 whitespace-nowrap border-0 bg-transparent p-0 text-center text-sm font-black text-[var(--platform-text-strong)] drop-shadow-[0_1px_0_rgba(255,255,255,0.82)] transition hover:text-[var(--platform-accent)] sm:bottom-10 ${
|
||||
disabled
|
||||
? 'cursor-not-allowed opacity-55'
|
||||
: 'cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
{labels.emptyImageHint}
|
||||
</label>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -368,39 +566,65 @@ export function CreativeImageInputPanel({
|
||||
{imageModelPicker}
|
||||
{shouldShowPromptReferences &&
|
||||
onPromptReferenceFilesSelect ? (
|
||||
<PlatformIconButton
|
||||
asChild="label"
|
||||
variant="surfaceFloating"
|
||||
label={labels.promptReferenceUpload}
|
||||
title={labels.promptReferenceUpload}
|
||||
icon={
|
||||
<>
|
||||
<ImagePlus className="h-4 w-4" />
|
||||
<input
|
||||
type="file"
|
||||
accept={mainImageAccept}
|
||||
multiple
|
||||
aria-label={labels.promptReferenceUpload}
|
||||
<>
|
||||
<input
|
||||
ref={promptReferenceInputRef}
|
||||
id={promptReferenceInputId}
|
||||
type="file"
|
||||
accept={mainImageAccept}
|
||||
multiple
|
||||
aria-label={labels.promptReferenceUpload}
|
||||
disabled={promptReferenceUploadDisabled}
|
||||
onChange={(event) => {
|
||||
const files = Array.from(
|
||||
event.currentTarget.files ?? [],
|
||||
);
|
||||
event.currentTarget.value = '';
|
||||
if (files.length > 0) {
|
||||
onPromptReferenceFilesSelect(files);
|
||||
}
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
<div className="absolute bottom-3 right-3 z-10 flex gap-2">
|
||||
{canCaptureHostImage ? (
|
||||
<PlatformIconButton
|
||||
variant="surfaceFloating"
|
||||
label="拍摄图片"
|
||||
title="拍摄图片"
|
||||
disabled={promptReferenceUploadDisabled}
|
||||
onChange={(event) => {
|
||||
const files = Array.from(
|
||||
event.currentTarget.files ?? [],
|
||||
);
|
||||
event.currentTarget.value = '';
|
||||
if (files.length > 0) {
|
||||
onPromptReferenceFilesSelect(files);
|
||||
}
|
||||
}}
|
||||
className="sr-only"
|
||||
onClick={handlePromptReferenceCaptureClick}
|
||||
icon={<Camera className="h-3.5 w-3.5" />}
|
||||
className="h-8 w-8 border-[var(--platform-subpanel-border)] bg-white/96 hover:bg-[var(--platform-subpanel-fill)]"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
className={`absolute bottom-3 right-3 z-10 h-8 w-8 border-[var(--platform-subpanel-border)] bg-white/96 hover:bg-[var(--platform-subpanel-fill)] ${
|
||||
promptReferenceUploadDisabled
|
||||
? 'cursor-not-allowed opacity-55'
|
||||
: 'cursor-pointer'
|
||||
}`}
|
||||
/>
|
||||
) : null}
|
||||
{canImportHostImage ? (
|
||||
<PlatformIconButton
|
||||
variant="surfaceFloating"
|
||||
label={labels.promptReferenceUpload}
|
||||
title={labels.promptReferenceUpload}
|
||||
disabled={promptReferenceUploadDisabled}
|
||||
onClick={handlePromptReferenceUploadClick}
|
||||
icon={<ImagePlus className="h-4 w-4" />}
|
||||
className="h-8 w-8 border-[var(--platform-subpanel-border)] bg-white/96 hover:bg-[var(--platform-subpanel-fill)]"
|
||||
/>
|
||||
) : (
|
||||
<PlatformIconButton
|
||||
asChild="label"
|
||||
htmlFor={promptReferenceInputId}
|
||||
variant="surfaceFloating"
|
||||
label={labels.promptReferenceUpload}
|
||||
title={labels.promptReferenceUpload}
|
||||
icon={<ImagePlus className="h-4 w-4" />}
|
||||
className={`h-8 w-8 border-[var(--platform-subpanel-border)] bg-white/96 hover:bg-[var(--platform-subpanel-fill)] ${
|
||||
promptReferenceUploadDisabled
|
||||
? 'cursor-not-allowed opacity-55'
|
||||
: 'cursor-pointer'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{shouldShowPromptReferences &&
|
||||
@@ -490,58 +714,28 @@ export function CreativeImageInputPanel({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<UnifiedModal
|
||||
<PlatformImagePreviewModal
|
||||
open={Boolean(previewReferenceImage)}
|
||||
title={previewReferenceImage?.label ?? labels.promptReferencePreviewAlt}
|
||||
onClose={() => setPreviewReferenceImage(null)}
|
||||
imageSrc={previewReferenceImage?.imageSrc ?? null}
|
||||
imageAlt={labels.promptReferencePreviewAlt}
|
||||
closeLabel={labels.closePromptReferencePreview}
|
||||
closeVariant="profileCompact"
|
||||
size="lg"
|
||||
zIndexClassName="z-[80]"
|
||||
overlayClassName="px-4 py-6"
|
||||
panelClassName="platform-remap-surface rounded-[1.35rem] p-3 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
||||
headerClassName="mb-3 items-center border-b-0 px-1 py-0"
|
||||
titleClassName="text-sm font-black"
|
||||
bodyClassName="px-0 py-0"
|
||||
>
|
||||
{previewReferenceImage ? (
|
||||
<div className="max-h-[72vh] overflow-hidden rounded-[1rem] bg-black/5">
|
||||
<ResolvedAssetImage
|
||||
src={previewReferenceImage.imageSrc}
|
||||
alt={labels.promptReferencePreviewAlt}
|
||||
className="h-full max-h-[72vh] w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</UnifiedModal>
|
||||
onClose={() => setPreviewReferenceImage(null)}
|
||||
/>
|
||||
|
||||
<UnifiedModal
|
||||
<PlatformImagePreviewModal
|
||||
open={isMainImagePreviewOpen && Boolean(uploadedImageSrc)}
|
||||
title={labels.previewMainImage ?? uploadedImageAlt}
|
||||
onClose={() => setIsMainImagePreviewOpen(false)}
|
||||
imageSrc={uploadedImageSrc}
|
||||
imageAlt={uploadedImageAlt}
|
||||
refreshKey={uploadedImageRefreshKey}
|
||||
closeLabel={
|
||||
labels.closeMainImagePreview ?? labels.closePromptReferencePreview
|
||||
}
|
||||
closeVariant="profileCompact"
|
||||
size="xl"
|
||||
zIndexClassName="z-[82]"
|
||||
overlayClassName="px-4 py-6"
|
||||
panelClassName="platform-remap-surface rounded-[1.35rem] p-3 shadow-[0_24px_70px_rgba(15,23,42,0.22)]"
|
||||
headerClassName="mb-3 items-center border-b-0 px-1 py-0"
|
||||
titleClassName="text-sm font-black"
|
||||
bodyClassName="px-0 py-0"
|
||||
>
|
||||
{uploadedImageSrc ? (
|
||||
<div className="max-h-[82vh] overflow-hidden rounded-[1rem] bg-black/5">
|
||||
<ResolvedAssetImage
|
||||
src={uploadedImageSrc}
|
||||
refreshKey={uploadedImageRefreshKey}
|
||||
alt={uploadedImageAlt}
|
||||
className="h-full max-h-[82vh] w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</UnifiedModal>
|
||||
zIndexClassName={mainImagePreviewZIndexClassName}
|
||||
onClose={() => setIsMainImagePreviewOpen(false)}
|
||||
/>
|
||||
|
||||
<UnifiedConfirmDialog
|
||||
open={isRemoveImageConfirmOpen}
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { FloatingFeedbackEntry } from './FloatingFeedbackEntry';
|
||||
import {
|
||||
clampFloatingFeedbackPosition,
|
||||
FLOATING_FEEDBACK_FORM_URL,
|
||||
resolveFloatingFeedbackContactPanelOffset,
|
||||
resolveInitialFloatingFeedbackPosition,
|
||||
} from './floatingFeedbackEntryModel';
|
||||
|
||||
function mockMatchMedia(matches: boolean) {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
function mockViewport(width: number, height: number) {
|
||||
Object.defineProperty(window, 'innerWidth', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: width,
|
||||
});
|
||||
Object.defineProperty(window, 'innerHeight', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: height,
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('desktop renders floating feedback entry near lower left', () => {
|
||||
mockMatchMedia(true);
|
||||
mockViewport(1440, 900);
|
||||
|
||||
render(<FloatingFeedbackEntry />);
|
||||
|
||||
const button = screen.getByRole('button', { name: '反馈点我' });
|
||||
const shell = button.closest('.floating-feedback-entry-shell') as HTMLElement;
|
||||
expect(button.className).toContain('floating-feedback-entry');
|
||||
expect(shell.style.left).toBe('24px');
|
||||
expect(shell.style.top).toBe('746.4px');
|
||||
expect(button.querySelector('img')?.getAttribute('src')).toBe(
|
||||
'/branding/taonier-feedback-entry.png',
|
||||
);
|
||||
});
|
||||
|
||||
test('hover shows contact QR images and mouse leave hides them', () => {
|
||||
mockMatchMedia(true);
|
||||
mockViewport(1440, 900);
|
||||
|
||||
render(<FloatingFeedbackEntry />);
|
||||
|
||||
const button = screen.getByRole('button', { name: '反馈点我' });
|
||||
expect(document.querySelector('.floating-feedback-entry-contact-panel')).toBeNull();
|
||||
|
||||
fireEvent.pointerEnter(button);
|
||||
|
||||
const panel = document.querySelector('.floating-feedback-entry-contact-panel');
|
||||
expect(panel).not.toBeNull();
|
||||
expect(
|
||||
(panel as HTMLElement).style.getPropertyValue(
|
||||
'--floating-feedback-contact-offset',
|
||||
),
|
||||
).toBe('141.2px');
|
||||
const images = Array.from(panel?.querySelectorAll('img') ?? []);
|
||||
expect(images.map((image) => image.getAttribute('src'))).toEqual([
|
||||
'/branding/taonier-feedback-qq.png',
|
||||
'/branding/taonier-feedback-wechat.png',
|
||||
]);
|
||||
|
||||
fireEvent.pointerLeave(button);
|
||||
|
||||
expect(document.querySelector('.floating-feedback-entry-contact-panel')).toBeNull();
|
||||
});
|
||||
|
||||
test('mobile does not render feedback entry', () => {
|
||||
mockMatchMedia(false);
|
||||
mockViewport(390, 780);
|
||||
|
||||
render(<FloatingFeedbackEntry />);
|
||||
|
||||
expect(screen.queryByRole('button', { name: '反馈点我' })).toBeNull();
|
||||
});
|
||||
|
||||
test('click opens feedback form in a new page', () => {
|
||||
mockMatchMedia(true);
|
||||
mockViewport(1440, 900);
|
||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null);
|
||||
|
||||
render(<FloatingFeedbackEntry />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '反馈点我' }));
|
||||
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
FLOATING_FEEDBACK_FORM_URL,
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
});
|
||||
|
||||
test('hover contact panel does not block click opening feedback form', () => {
|
||||
mockMatchMedia(true);
|
||||
mockViewport(1440, 900);
|
||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null);
|
||||
|
||||
render(<FloatingFeedbackEntry />);
|
||||
|
||||
const button = screen.getByRole('button', { name: '反馈点我' });
|
||||
fireEvent.pointerEnter(button);
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(document.querySelector('.floating-feedback-entry-contact-panel')).not.toBeNull();
|
||||
expect(openSpy).toHaveBeenCalledWith(
|
||||
FLOATING_FEEDBACK_FORM_URL,
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
});
|
||||
|
||||
test('drag keeps entry fully inside the viewport and does not open form', () => {
|
||||
mockMatchMedia(true);
|
||||
mockViewport(360, 320);
|
||||
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null);
|
||||
|
||||
render(<FloatingFeedbackEntry />);
|
||||
|
||||
const button = screen.getByRole('button', { name: '反馈点我' });
|
||||
fireEvent.mouseEnter(button);
|
||||
expect(document.querySelector('.floating-feedback-entry-contact-panel')).not.toBeNull();
|
||||
fireEvent.mouseDown(button, {
|
||||
button: 0,
|
||||
clientX: 40,
|
||||
clientY: 220,
|
||||
});
|
||||
expect(document.querySelector('.floating-feedback-entry-contact-panel')).toBeNull();
|
||||
fireEvent.mouseMove(window, {
|
||||
clientX: 900,
|
||||
clientY: 900,
|
||||
});
|
||||
fireEvent.mouseUp(window, {
|
||||
clientX: 900,
|
||||
clientY: 900,
|
||||
});
|
||||
expect(document.querySelector('.floating-feedback-entry-contact-panel')).not.toBeNull();
|
||||
fireEvent.click(button);
|
||||
|
||||
const shell = button.closest('.floating-feedback-entry-shell') as HTMLElement;
|
||||
expect(shell.style.left).toBe('282.4px');
|
||||
expect(shell.style.top).toBe('242.4px');
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('floating feedback position helpers clamp to visible viewport', () => {
|
||||
expect(
|
||||
clampFloatingFeedbackPosition(
|
||||
{ left: -100, top: 1000 },
|
||||
{ width: 360, height: 320 },
|
||||
),
|
||||
).toEqual({ left: 20, top: 242.4 });
|
||||
expect(
|
||||
resolveInitialFloatingFeedbackPosition({ width: 1440, height: 900 }),
|
||||
).toEqual({ left: 24, top: 746.4 });
|
||||
expect(
|
||||
resolveFloatingFeedbackContactPanelOffset(
|
||||
{ left: 20, top: 100 },
|
||||
{ width: 1440 },
|
||||
),
|
||||
).toBe(145.2);
|
||||
expect(
|
||||
resolveFloatingFeedbackContactPanelOffset(
|
||||
{ left: 20, top: 100 },
|
||||
{ width: 360 },
|
||||
),
|
||||
).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,336 @@
|
||||
import {
|
||||
type CSSProperties,
|
||||
type MouseEvent,
|
||||
type PointerEvent,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { BRAND_ASSETS } from '../../uiAssets';
|
||||
import {
|
||||
clampFloatingFeedbackPosition,
|
||||
FLOATING_FEEDBACK_DESKTOP_QUERY,
|
||||
FLOATING_FEEDBACK_DRAG_CLICK_SUPPRESS_MS,
|
||||
FLOATING_FEEDBACK_DRAG_CLICK_THRESHOLD,
|
||||
FLOATING_FEEDBACK_FORM_URL,
|
||||
FLOATING_FEEDBACK_INITIAL_LEFT,
|
||||
FLOATING_FEEDBACK_MARGIN,
|
||||
type FloatingFeedbackPosition,
|
||||
resolveFloatingFeedbackContactPanelOffset,
|
||||
resolveInitialFloatingFeedbackPosition,
|
||||
} from './floatingFeedbackEntryModel';
|
||||
|
||||
type FloatingFeedbackDragState = {
|
||||
pointerId: number;
|
||||
source: 'mouse' | 'pointer';
|
||||
startClientX: number;
|
||||
startClientY: number;
|
||||
startLeft: number;
|
||||
startTop: number;
|
||||
hasMoved: boolean;
|
||||
};
|
||||
|
||||
function isSameFloatingFeedbackPointer(
|
||||
currentPointerId: number,
|
||||
nextPointerId: number,
|
||||
) {
|
||||
if (!Number.isFinite(currentPointerId) || !Number.isFinite(nextPointerId)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return currentPointerId === nextPointerId;
|
||||
}
|
||||
|
||||
function shouldUseMouseDragFallback() {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.PointerEvent !== 'function'
|
||||
);
|
||||
}
|
||||
|
||||
function getViewportSize() {
|
||||
return {
|
||||
width: window.innerWidth || document.documentElement.clientWidth,
|
||||
height: window.innerHeight || document.documentElement.clientHeight,
|
||||
};
|
||||
}
|
||||
|
||||
function getInitialDesktopVisibility() {
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
typeof window.matchMedia !== 'function'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.matchMedia(FLOATING_FEEDBACK_DESKTOP_QUERY).matches;
|
||||
}
|
||||
|
||||
export function FloatingFeedbackEntry() {
|
||||
const [isDesktopVisible, setIsDesktopVisible] = useState(
|
||||
getInitialDesktopVisibility,
|
||||
);
|
||||
const [isPointerOverEntry, setIsPointerOverEntry] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [position, setPosition] = useState<FloatingFeedbackPosition>(() =>
|
||||
typeof window === 'undefined'
|
||||
? { left: FLOATING_FEEDBACK_INITIAL_LEFT, top: FLOATING_FEEDBACK_MARGIN }
|
||||
: resolveInitialFloatingFeedbackPosition(getViewportSize()),
|
||||
);
|
||||
const dragStateRef = useRef<FloatingFeedbackDragState | null>(null);
|
||||
const suppressClickUntilRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
typeof window === 'undefined' ||
|
||||
typeof window.matchMedia !== 'function'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia(FLOATING_FEEDBACK_DESKTOP_QUERY);
|
||||
const updateVisibility = (event?: MediaQueryListEvent) => {
|
||||
setIsDesktopVisible(event?.matches ?? mediaQuery.matches);
|
||||
};
|
||||
|
||||
updateVisibility();
|
||||
|
||||
if (typeof mediaQuery.addEventListener === 'function') {
|
||||
mediaQuery.addEventListener('change', updateVisibility);
|
||||
return () => mediaQuery.removeEventListener('change', updateVisibility);
|
||||
}
|
||||
|
||||
mediaQuery.addListener(updateVisibility);
|
||||
return () => mediaQuery.removeListener(updateVisibility);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const clampToViewport = () => {
|
||||
setPosition((current) =>
|
||||
clampFloatingFeedbackPosition(current, getViewportSize()),
|
||||
);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', clampToViewport);
|
||||
return () => window.removeEventListener('resize', clampToViewport);
|
||||
}, []);
|
||||
|
||||
const updateDrag = (
|
||||
pointerId: number,
|
||||
source: FloatingFeedbackDragState['source'],
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
) => {
|
||||
const dragState = dragStateRef.current;
|
||||
if (
|
||||
!dragState ||
|
||||
dragState.source !== source ||
|
||||
!isSameFloatingFeedbackPointer(dragState.pointerId, pointerId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = clientX - dragState.startClientX;
|
||||
const deltaY = clientY - dragState.startClientY;
|
||||
if (
|
||||
!dragState.hasMoved &&
|
||||
Math.hypot(deltaX, deltaY) >= FLOATING_FEEDBACK_DRAG_CLICK_THRESHOLD
|
||||
) {
|
||||
dragState.hasMoved = true;
|
||||
}
|
||||
|
||||
setPosition(
|
||||
clampFloatingFeedbackPosition(
|
||||
{
|
||||
left: dragState.startLeft + deltaX,
|
||||
top: dragState.startTop + deltaY,
|
||||
},
|
||||
getViewportSize(),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const stopDragById = (
|
||||
pointerId: number,
|
||||
source: FloatingFeedbackDragState['source'],
|
||||
) => {
|
||||
const dragState = dragStateRef.current;
|
||||
if (
|
||||
!dragState ||
|
||||
dragState.source !== source ||
|
||||
!isSameFloatingFeedbackPointer(dragState.pointerId, pointerId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
suppressClickUntilRef.current = dragState.hasMoved
|
||||
? Date.now() + FLOATING_FEEDBACK_DRAG_CLICK_SUPPRESS_MS
|
||||
: 0;
|
||||
dragStateRef.current = null;
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerMove = (event: globalThis.PointerEvent) => {
|
||||
const dragState = dragStateRef.current;
|
||||
if (
|
||||
!dragState ||
|
||||
!isSameFloatingFeedbackPointer(dragState.pointerId, event.pointerId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateDrag(event.pointerId, 'pointer', event.clientX, event.clientY);
|
||||
};
|
||||
|
||||
const stopPointerDrag = (event: globalThis.PointerEvent) => {
|
||||
stopDragById(event.pointerId, 'pointer');
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', handlePointerMove);
|
||||
window.addEventListener('pointerup', stopPointerDrag);
|
||||
window.addEventListener('pointercancel', stopPointerDrag);
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handlePointerMove);
|
||||
window.removeEventListener('pointerup', stopPointerDrag);
|
||||
window.removeEventListener('pointercancel', stopPointerDrag);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (event: globalThis.MouseEvent) => {
|
||||
const dragState = dragStateRef.current;
|
||||
if (!dragState || dragState.source !== 'mouse') {
|
||||
return;
|
||||
}
|
||||
|
||||
updateDrag(Number.NaN, 'mouse', event.clientX, event.clientY);
|
||||
};
|
||||
|
||||
const stopMouseDrag = () => {
|
||||
stopDragById(Number.NaN, 'mouse');
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', stopMouseDrag);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', stopMouseDrag);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startPointerDrag = (event: PointerEvent<HTMLButtonElement>) => {
|
||||
if (event.button > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
setIsDragging(true);
|
||||
dragStateRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
source: 'pointer',
|
||||
startClientX: event.clientX,
|
||||
startClientY: event.clientY,
|
||||
startLeft: position.left,
|
||||
startTop: position.top,
|
||||
hasMoved: false,
|
||||
};
|
||||
};
|
||||
|
||||
const startMouseDrag = (event: MouseEvent<HTMLButtonElement>) => {
|
||||
if (!shouldUseMouseDragFallback() || event.button > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDragging(true);
|
||||
dragStateRef.current = {
|
||||
pointerId: Number.NaN,
|
||||
source: 'mouse',
|
||||
startClientX: event.clientX,
|
||||
startClientY: event.clientY,
|
||||
startLeft: position.left,
|
||||
startTop: position.top,
|
||||
hasMoved: false,
|
||||
};
|
||||
};
|
||||
|
||||
const openFeedbackForm = () => {
|
||||
if (suppressClickUntilRef.current >= Date.now()) {
|
||||
suppressClickUntilRef.current = 0;
|
||||
dragStateRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
window.open(FLOATING_FEEDBACK_FORM_URL, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
if (!isDesktopVisible || typeof document === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shouldShowContactPanel = isPointerOverEntry && !isDragging;
|
||||
const contactPanelOffset = resolveFloatingFeedbackContactPanelOffset(
|
||||
position,
|
||||
getViewportSize(),
|
||||
);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="floating-feedback-entry-shell"
|
||||
style={{
|
||||
left: `${position.left}px`,
|
||||
top: `${position.top}px`,
|
||||
}}
|
||||
>
|
||||
{shouldShowContactPanel ? (
|
||||
<div
|
||||
className="floating-feedback-entry-contact-panel"
|
||||
style={
|
||||
{
|
||||
'--floating-feedback-contact-offset': `${contactPanelOffset}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<img
|
||||
src={BRAND_ASSETS.taonierFeedbackQq}
|
||||
alt=""
|
||||
draggable={false}
|
||||
/>
|
||||
<img
|
||||
src={BRAND_ASSETS.taonierFeedbackWechat}
|
||||
alt=""
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="floating-feedback-entry"
|
||||
aria-label="反馈点我"
|
||||
onPointerEnter={() => setIsPointerOverEntry(true)}
|
||||
onPointerLeave={() => setIsPointerOverEntry(false)}
|
||||
onPointerDown={startPointerDrag}
|
||||
onMouseEnter={() => setIsPointerOverEntry(true)}
|
||||
onMouseLeave={() => setIsPointerOverEntry(false)}
|
||||
onMouseDown={startMouseDrag}
|
||||
onPointerMove={(event) =>
|
||||
updateDrag(event.pointerId, 'pointer', event.clientX, event.clientY)
|
||||
}
|
||||
onPointerUp={(event) => stopDragById(event.pointerId, 'pointer')}
|
||||
onPointerCancel={(event) => stopDragById(event.pointerId, 'pointer')}
|
||||
onClick={openFeedbackForm}
|
||||
>
|
||||
<img
|
||||
src={BRAND_ASSETS.taonierFeedbackEntry}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
draggable={false}
|
||||
/>
|
||||
</button>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { PlatformBatchActionToolbar } from './PlatformBatchActionToolbar';
|
||||
|
||||
describe('PlatformBatchActionToolbar', () => {
|
||||
it('renders a labelled toolbar around batch actions', () => {
|
||||
render(
|
||||
<PlatformBatchActionToolbar label="素材批量操作">
|
||||
<button type="button">删除</button>
|
||||
</PlatformBatchActionToolbar>,
|
||||
);
|
||||
|
||||
const toolbar = screen.getByRole('toolbar', { name: '素材批量操作' });
|
||||
|
||||
expect(toolbar).toBeTruthy();
|
||||
expect(toolbar.className).toContain('platform-batch-action-toolbar');
|
||||
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
type PlatformBatchActionToolbarProps = Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
'children'
|
||||
> & {
|
||||
children: ReactNode;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 平台批量操作浮动工具栏。
|
||||
* 只统一容器 chrome 与 toolbar 语义,具体选择逻辑和动作按钮由业务组件传入。
|
||||
*/
|
||||
export function PlatformBatchActionToolbar({
|
||||
children,
|
||||
label = '批量操作',
|
||||
className,
|
||||
...divProps
|
||||
}: PlatformBatchActionToolbarProps) {
|
||||
return (
|
||||
<div
|
||||
{...divProps}
|
||||
className={['platform-batch-action-toolbar', className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
role="toolbar"
|
||||
aria-label={label}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { expect, test } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import { PlatformEmptyState } from './PlatformEmptyState';
|
||||
|
||||
@@ -71,3 +71,29 @@ test('supports dark editor dashed empty state', () => {
|
||||
expect(emptyState.className).toContain('bg-black/20');
|
||||
expect(emptyState.className).toContain('text-[var(--platform-text-soft)]');
|
||||
});
|
||||
|
||||
test('supports button empty state for empty collection actions', () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<PlatformEmptyState
|
||||
asChild="button"
|
||||
surface="subpanel"
|
||||
size="panel"
|
||||
className="local-empty-action"
|
||||
onClick={onClick}
|
||||
>
|
||||
新建项目
|
||||
</PlatformEmptyState>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: '新建项目' });
|
||||
|
||||
expect(button.tagName).toBe('BUTTON');
|
||||
expect(button.getAttribute('type')).toBe('button');
|
||||
expect(button.className).toContain('platform-empty-state');
|
||||
expect(button.className).toContain('local-empty-action');
|
||||
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -1,19 +1,41 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react';
|
||||
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from 'react';
|
||||
|
||||
type PlatformEmptyStateSurface = 'soft' | 'dashed' | 'subpanel' | 'editorDark';
|
||||
type PlatformEmptyStateSize = 'compact' | 'panel' | 'inline';
|
||||
type PlatformEmptyStateTone = 'base' | 'soft';
|
||||
|
||||
type PlatformEmptyStateProps = Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
'children'
|
||||
> & {
|
||||
type PlatformEmptyStateBaseProps = {
|
||||
children: ReactNode;
|
||||
surface?: PlatformEmptyStateSurface;
|
||||
size?: PlatformEmptyStateSize;
|
||||
tone?: PlatformEmptyStateTone;
|
||||
};
|
||||
|
||||
type PlatformEmptyStateDivProps = Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
'children'
|
||||
> & {
|
||||
asChild?: false;
|
||||
} & PlatformEmptyStateBaseProps;
|
||||
|
||||
type PlatformEmptyStateButtonProps = Omit<
|
||||
ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
'children'
|
||||
> & {
|
||||
asChild: 'button';
|
||||
} & PlatformEmptyStateBaseProps;
|
||||
|
||||
type PlatformEmptyStateProps =
|
||||
| PlatformEmptyStateDivProps
|
||||
| PlatformEmptyStateButtonProps;
|
||||
|
||||
type PlatformEmptyStateClassNameOptions = {
|
||||
surface: PlatformEmptyStateSurface;
|
||||
size: PlatformEmptyStateSize;
|
||||
tone: PlatformEmptyStateTone;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const PLATFORM_EMPTY_STATE_SURFACE_CLASS: Record<
|
||||
PlatformEmptyStateSurface,
|
||||
string
|
||||
@@ -44,30 +66,57 @@ const PLATFORM_EMPTY_STATE_TONE_CLASS: Record<PlatformEmptyStateTone, string> =
|
||||
* 平台通用空态和轻量加载态。
|
||||
* 收口平台列表、作品架和素材选择弹窗中重复的空面板外观。
|
||||
*/
|
||||
function getPlatformEmptyStateClassName({
|
||||
surface,
|
||||
size,
|
||||
tone,
|
||||
className,
|
||||
}: PlatformEmptyStateClassNameOptions) {
|
||||
return [
|
||||
'min-w-0',
|
||||
'platform-empty-state',
|
||||
PLATFORM_EMPTY_STATE_SURFACE_CLASS[surface],
|
||||
PLATFORM_EMPTY_STATE_SIZE_CLASS[size],
|
||||
PLATFORM_EMPTY_STATE_TONE_CLASS[tone],
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export function PlatformEmptyState({
|
||||
children,
|
||||
surface = 'soft',
|
||||
size = 'compact',
|
||||
tone,
|
||||
className,
|
||||
...divProps
|
||||
asChild,
|
||||
...emptyStateProps
|
||||
}: PlatformEmptyStateProps) {
|
||||
const resolvedTone =
|
||||
tone ?? (surface === 'subpanel' || size === 'inline' ? 'soft' : 'base');
|
||||
const emptyStateClassName = getPlatformEmptyStateClassName({
|
||||
surface,
|
||||
size,
|
||||
tone: resolvedTone,
|
||||
className,
|
||||
});
|
||||
|
||||
if (asChild === 'button') {
|
||||
const { type = 'button', ...buttonProps } =
|
||||
emptyStateProps as ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
return (
|
||||
<button {...buttonProps} type={type} className={emptyStateClassName}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...divProps}
|
||||
className={[
|
||||
'min-w-0',
|
||||
'platform-empty-state',
|
||||
PLATFORM_EMPTY_STATE_SURFACE_CLASS[surface],
|
||||
PLATFORM_EMPTY_STATE_SIZE_CLASS[size],
|
||||
PLATFORM_EMPTY_STATE_TONE_CLASS[resolvedTone],
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
{...(emptyStateProps as HTMLAttributes<HTMLDivElement>)}
|
||||
className={emptyStateClassName}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
PlatformFloatingMenu,
|
||||
PlatformFloatingMenuItem,
|
||||
} from './PlatformFloatingMenu';
|
||||
|
||||
describe('PlatformFloatingMenu', () => {
|
||||
it('renders menu items with accessible menu semantics', async () => {
|
||||
const onRename = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(
|
||||
<PlatformFloatingMenu label="项目菜单" placement="bottom-end">
|
||||
<PlatformFloatingMenuItem onClick={onRename}>
|
||||
重命名
|
||||
</PlatformFloatingMenuItem>
|
||||
</PlatformFloatingMenu>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('menu', { name: '项目菜单' })).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('menuitem', { name: '重命名' }));
|
||||
|
||||
expect(onRename).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('keeps pointer events from leaking to canvas-style parents', () => {
|
||||
const onParentPointerDown = vi.fn();
|
||||
const onMenuPointerDown = vi.fn();
|
||||
|
||||
render(
|
||||
<div onPointerDown={onParentPointerDown}>
|
||||
<PlatformFloatingMenu
|
||||
label="项目菜单"
|
||||
placement="bottom-end"
|
||||
onPointerDown={onMenuPointerDown}
|
||||
>
|
||||
<PlatformFloatingMenuItem>重命名</PlatformFloatingMenuItem>
|
||||
</PlatformFloatingMenu>
|
||||
</div>,
|
||||
);
|
||||
|
||||
fireEvent.pointerDown(screen.getByRole('menu', { name: '项目菜单' }));
|
||||
|
||||
expect(onMenuPointerDown).toHaveBeenCalledOnce();
|
||||
expect(onParentPointerDown).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user