合并 master 到跳一跳分支

合入 origin/master 最新变更

解决跳一跳提示词测试与图集去背冲突
This commit is contained in:
2026-06-26 21:30:26 +08:00
943 changed files with 266938 additions and 7298 deletions
+381
View File
@@ -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
View File
@@ -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();
});
+3
View File
@@ -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() ?? '';
+3
View File
@@ -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() || '';
+134 -15
View File
@@ -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();
+30 -6
View File
@@ -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');
+11 -1
View File
@@ -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)]">
+2 -2
View File
@@ -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 秒。');
+162 -12
View File
@@ -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
+279 -85
View File
@@ -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);
});
+66 -17
View File
@@ -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