修复资源卡片媒体预览发不出请求:线上 category 改回原生读取分支
- 新增 projectResourceMediaPreviewCategory:从卡片预览类型派生原生侧线上 category(art / audio),与画布分区栏目轴解耦 - read_local_project_media_preview 调用点不再传 job.resource.category:该字段自 6 类资产分类轴落地后是画布栏目(unclassified / ui-interaction / …),传给原生侧会被 read_local_project_media_preview_at 以「媒体预览类别只支持 art 或 audio」直接拒绝 - 该回归导致所有走媒体分支的卡片(GIF / SVG / AVIF / BMP / MP4 / WebM / MOV 与音频)读不出预览,只显示占位图标 - previewReadErrorMessage 与 resourceReadKindLabel 改用卡片预览类型判定文案,不再依赖已换语义的 category - 废弃 useProjectResourceCardPreviews 对 projectResourceDisplayKind 的引用,错误文案与读取路由共用同一分类口径 - 预览 Hook 用例补「扩展名图片按 art 分支发出请求」与「音频仅在播放意图后按 audio 分支发出请求」两条断言,直接钉住原生侧线上取值 - 应用层媒体用例补 icon.svg 的 category 必须是 art 的断言,并修正原先描述「媒体读取改传画布栏目」的过期注释
This commit is contained in:
@@ -133,6 +133,9 @@ export function projectResourceCardPreviewEvictionIdentities(
|
||||
return Array.from(evicted);
|
||||
}
|
||||
|
||||
/** `read_local_project_media_preview` 只认这两个线上取值,与画布栏目轴无关。 */
|
||||
export type ProjectResourceMediaPreviewCategory = 'art' | 'audio';
|
||||
|
||||
const rasterImageExtension = /\.(png|jpe?g|webp)$/iu;
|
||||
const extendedImageExtension = /\.(gif|svg|avif|bmp)$/iu;
|
||||
const videoExtension = /\.(mp4|webm|mov)$/iu;
|
||||
@@ -177,6 +180,22 @@ export function projectResourceCardPreviewKind(
|
||||
return 'placeholder';
|
||||
}
|
||||
|
||||
/**
|
||||
* `read_local_project_media_preview` 的线上 `category` 入参:只按文件类型分美术 / 音频两支。
|
||||
*
|
||||
* 该参数在原生侧只接受 `art` / `audio`(见 `commands.rs` 的
|
||||
* `read_local_project_media_preview_at`),它是**文件读取分支**,不是画布分区栏目。
|
||||
* `ProjectResource.category` 自 6 类资产分类轴落地后已经是画布栏目(`unclassified` /
|
||||
* `ui-interaction` / …),把它当线上分类传给原生侧会被直接拒绝
|
||||
* (`媒体预览类别只支持 art 或 audio`),扩展名图片、视频与音频卡片因此全部读不出预览。
|
||||
* 这里从卡片预览类型重新派生,保持与分区栏目解耦。
|
||||
*/
|
||||
export function projectResourceMediaPreviewCategory(
|
||||
resource: ProjectResource,
|
||||
): ProjectResourceMediaPreviewCategory {
|
||||
return projectResourceCardPreviewKind(resource) === 'audio' ? 'audio' : 'art';
|
||||
}
|
||||
|
||||
export function projectResourceCardPreviewIdentity(input: {
|
||||
projectPath: string;
|
||||
projectId: string;
|
||||
|
||||
+18
-10
@@ -24,15 +24,14 @@ import {
|
||||
projectResourceCardPreviewEvictionIdentities,
|
||||
projectResourceCardPreviewIdentity,
|
||||
projectResourceCardPreviewImageDimensions,
|
||||
type ProjectResourceCardPreviewKind,
|
||||
projectResourceCardPreviewKind,
|
||||
type ProjectResourceCardPreviewPayload,
|
||||
type ProjectResourceCardPreviewState,
|
||||
type ProjectResourceCardPreviewTransportPayload,
|
||||
projectResourceMediaPreviewCategory,
|
||||
} from './resourceCardPreviewModel';
|
||||
import {
|
||||
type ProjectResource,
|
||||
projectResourceDisplayKind,
|
||||
} from './resourceProjectionModel';
|
||||
import type { ProjectResource } from './resourceProjectionModel';
|
||||
|
||||
type PreviewRequestReason = 'visible' | 'detail' | 'play';
|
||||
|
||||
@@ -61,8 +60,10 @@ type ObservedPreviewCard = {
|
||||
resource: ProjectResource;
|
||||
};
|
||||
|
||||
function resourceReadKindLabel(resource: ProjectResource) {
|
||||
const kind = projectResourceDisplayKind(resource);
|
||||
function resourceReadKindLabel(
|
||||
resource: ProjectResource,
|
||||
kind: ProjectResourceCardPreviewKind,
|
||||
) {
|
||||
if (kind === 'document') {
|
||||
return '文档';
|
||||
}
|
||||
@@ -74,18 +75,20 @@ function resourceReadKindLabel(resource: ProjectResource) {
|
||||
|
||||
function previewReadErrorMessage(
|
||||
resource: ProjectResource,
|
||||
kind: ProjectResourceCardPreviewKind,
|
||||
error: unknown,
|
||||
): { error: string; retryable: boolean } {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const kindLabel = resourceReadKindLabel(resource, kind);
|
||||
if (message.includes('项目权限策略要求用户确认')) {
|
||||
return {
|
||||
error: `当前项目策略要求先确认读取${resourceReadKindLabel(resource)},确认后请关闭详情并重试`,
|
||||
error: `当前项目策略要求先确认读取${kindLabel},确认后请关闭详情并重试`,
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
if (message.includes('项目权限策略拒绝执行')) {
|
||||
return {
|
||||
error: `当前项目策略不允许读取${resourceReadKindLabel(resource)},调整策略后请关闭详情并重试`,
|
||||
error: `当前项目策略不允许读取${kindLabel},调整策略后请关闭详情并重试`,
|
||||
retryable: true,
|
||||
};
|
||||
}
|
||||
@@ -398,7 +401,7 @@ export function useProjectResourceCardPreviews(input: {
|
||||
{
|
||||
projectPath: input.projectPath,
|
||||
relativePath: job.resource.path,
|
||||
category: job.resource.category,
|
||||
category: projectResourceMediaPreviewCategory(job.resource),
|
||||
scopeId: job.scopeId,
|
||||
requestId: createProjectResourcePreviewRequestId(),
|
||||
},
|
||||
@@ -426,6 +429,7 @@ export function useProjectResourceCardPreviews(input: {
|
||||
}
|
||||
activeReadsRef.current.count += 1;
|
||||
pendingIdentitiesRef.current.set(job.identity, job.scopeEpoch);
|
||||
const jobPreviewKind = projectResourceCardPreviewKind(job.resource);
|
||||
publishPreview(job.identity, { status: 'loading' });
|
||||
void readPreview(job)
|
||||
.then((transport) => {
|
||||
@@ -450,7 +454,11 @@ export function useProjectResourceCardPreviews(input: {
|
||||
isCurrentJob(job) &&
|
||||
!isProjectResourcePreviewCancellation(error)
|
||||
) {
|
||||
const failure = previewReadErrorMessage(job.resource, error);
|
||||
const failure = previewReadErrorMessage(
|
||||
job.resource,
|
||||
jobPreviewKind,
|
||||
error,
|
||||
);
|
||||
publishPreview(job.identity, {
|
||||
status: 'failed',
|
||||
...failure,
|
||||
|
||||
@@ -1163,6 +1163,13 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
resolve(process.cwd(), 'apps/ai-game-creator-shell/src/styles.css'),
|
||||
'utf8',
|
||||
);
|
||||
const tsxSource = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
'apps/ai-game-creator-shell/src/view/project-development/index.tsx',
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
// 卡片(z-index 25)与栏目标题栏(30)都画在铺满资源区的场景里:工具条必须比场景更高,
|
||||
// 否则放开 display 也只是"画了但看不见、点不到"。
|
||||
const sceneZIndex = styleNumber(
|
||||
@@ -1181,6 +1188,35 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
const notice = styleRuleBody(styles, '\\.game-resource-live-notice');
|
||||
expect(notice).toMatch(/position:\s*relative/u);
|
||||
expect(styleNumber(notice, 'z-index')).toBeGreaterThan(sceneZIndex);
|
||||
|
||||
// 但"在场景之上"只是不被盖住,不等于两者不重叠:上一版把搜索框常态居中浮在画本顶上,
|
||||
// 分页态栏目标题栏正好被压在同一水平带上。所以还要钉住"工具条带独占顶部那条带"。
|
||||
const scene = styleRuleBody(styles, '\\.game-resource-book-scene');
|
||||
expect(scene).toMatch(/inset:\s*var\(--game-resource-book-tools-height\)/u);
|
||||
|
||||
// 管理区给工具条带留出的高度与场景让出的顶边必须是同一个变量,否则两者会再次互相压住。
|
||||
const manager = styleRuleBody(styles, '\\.game-resource-manager');
|
||||
expect(manager).toMatch(
|
||||
/padding-top:\s*var\(--game-resource-book-tools-height\)/u,
|
||||
);
|
||||
|
||||
// 工具条带是绝对定位的独立排布行,不是流内浮层;搜索框按整行宽度算,
|
||||
// 不与任何栏目标题栏共享同一水平带。
|
||||
const tools = styleRuleBody(styles, '\\.game-resource-book-tools');
|
||||
expect(tools).toMatch(/position:\s*absolute/u);
|
||||
expect(styleNumber(tools, 'top')).toBe(0);
|
||||
|
||||
// 搜到的是场景里真正的栏目标题栏(分页态被激活时铺满整行),它必须整体排在工具条带下面。
|
||||
const titlebar = styleRuleBody(
|
||||
styles,
|
||||
'\\.game-resource-book-scene-titlebar',
|
||||
);
|
||||
expect(styleNumber(titlebar, 'z-index')).toBeLessThan(
|
||||
styleNumber(tools, 'z-index'),
|
||||
);
|
||||
|
||||
expect(tsxSource).toContain('game-resource-book-tools');
|
||||
expect(tsxSource).not.toContain('game-resource-book-scene-titlebar');
|
||||
});
|
||||
|
||||
it('keeps the outline dock readable without hover', () => {
|
||||
@@ -2552,8 +2588,9 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
};
|
||||
}
|
||||
if (command === 'read_local_project_media_preview') {
|
||||
// 媒体读取的 `category` 现在传资源所在的功能栏目(svg 为 ui-interaction、
|
||||
// 视频与音频为 unclassified),不能再按旧栏目 `art` 分流,改按路径返回。
|
||||
// 媒体读取的 `category` 是原生侧的**文件读取分支**(只认 art / audio),
|
||||
// 不是资源所在的画布栏目:栏目取值(unclassified / ui-interaction / …)
|
||||
// 传过去会被 `read_local_project_media_preview_at` 直接拒绝。
|
||||
if (args?.relativePath === 'assets/bgm.mp3') {
|
||||
return {
|
||||
path: 'assets/bgm.mp3',
|
||||
@@ -2616,6 +2653,16 @@ export function registerProjectWorkbenchFoundationTests() {
|
||||
);
|
||||
expect(screen.getByRole('toolbar', { name: '图片工具栏' })).not.toBeNull();
|
||||
expect(screen.queryByRole('dialog', { name: 'design.md' })).toBeNull();
|
||||
// SVG 在「UI 交互」栏目,但它走的是原生侧的美术读取分支:线上 `category`
|
||||
// 必须是 art,不能是栏目取值 ui-interaction,否则原生侧直接拒绝、卡片读不出预览。
|
||||
expect(
|
||||
invoke.mock.calls.some(
|
||||
([command, args]) =>
|
||||
command === 'read_local_project_media_preview' &&
|
||||
args?.relativePath === 'assets/icon.svg' &&
|
||||
args?.category === 'art',
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// 视频(animation)与音频(bgm)都不在 canonical kind 目录里,落在「待归类」栏目。
|
||||
await openResourceBookCategory('待归类');
|
||||
|
||||
@@ -165,7 +165,10 @@ describe('useProjectResourceCardPreviews', () => {
|
||||
'read_local_project_media_preview',
|
||||
expect.objectContaining({
|
||||
relativePath: art.path,
|
||||
category: 'unclassified',
|
||||
// 线上合同的读取分支取值,不是资源所在的画布栏目:Rust 侧
|
||||
// `read_local_project_media_preview_at` 只接受 art / audio,传栏目值
|
||||
// (unclassified / ui-interaction / …)会被直接拒绝,卡片永远读不出预览。
|
||||
category: 'art',
|
||||
}),
|
||||
);
|
||||
expect(result.current.imageDimensionsByResourceId.get(art.id)).toEqual({
|
||||
@@ -174,6 +177,79 @@ describe('useProjectResourceCardPreviews', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('sends the art read branch for extended images regardless of their canvas section', async () => {
|
||||
const art = resource('extended-art', {
|
||||
path: 'assets/extended-art.gif',
|
||||
mediaType: 'image/gif',
|
||||
// 6 类资产分类轴落地后的画布栏目取值,与线上读取分支是两个轴。
|
||||
category: 'unclassified',
|
||||
});
|
||||
const invoke = vi.fn(async () => preview(art.path, 'image/gif'));
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
const canvasRef = { current: document.createElement('div') };
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useProjectResourceCardPreviews({
|
||||
projectPath: '/tmp/preview-extended-art',
|
||||
projectId: 'preview-extended-art',
|
||||
mode: 'dependency',
|
||||
resources: [art],
|
||||
canvasRef,
|
||||
eagerPreviewLimit: 1,
|
||||
}),
|
||||
);
|
||||
const identity = result.current.identityByResourceId.get(art.id)!;
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.previews.get(identity)?.status).toBe('loaded'),
|
||||
);
|
||||
const mediaCall = invoke.mock.calls.find(
|
||||
([command]) => command === 'read_local_project_media_preview',
|
||||
);
|
||||
expect(mediaCall?.[1]).toMatchObject({
|
||||
relativePath: art.path,
|
||||
category: 'art',
|
||||
});
|
||||
});
|
||||
|
||||
it('sends the audio read branch for a play request from an audio-section card', async () => {
|
||||
const audio = resource('play-audio', {
|
||||
path: 'assets/play-audio.mp3',
|
||||
mediaType: 'audio/mpeg',
|
||||
subtype: 'background-music',
|
||||
category: 'audio',
|
||||
});
|
||||
const invoke = vi.fn(async () => preview(audio.path, 'audio/mpeg'));
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
const canvasRef = { current: document.createElement('div') };
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useProjectResourceCardPreviews({
|
||||
projectPath: '/tmp/preview-play-audio',
|
||||
projectId: 'preview-play-audio',
|
||||
mode: 'dependency',
|
||||
resources: [audio],
|
||||
canvasRef,
|
||||
// 音频不预读:没有播放意图前必须一次 IPC 都不发。
|
||||
eagerPreviewLimit: 12,
|
||||
}),
|
||||
);
|
||||
const identity = result.current.identityByResourceId.get(audio.id)!;
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
|
||||
act(() => result.current.requestPreview(audio, identity, 'play'));
|
||||
await waitFor(() =>
|
||||
expect(result.current.previews.get(identity)?.status).toBe('loaded'),
|
||||
);
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'read_local_project_media_preview',
|
||||
expect.objectContaining({
|
||||
relativePath: audio.path,
|
||||
category: 'audio',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('only treats the exact native cancellation category as cancellation', () => {
|
||||
const cancellation = 'project-resource-preview-scope-cancelled';
|
||||
expect(isProjectResourcePreviewCancellation(cancellation)).toBe(true);
|
||||
|
||||
Reference in New Issue
Block a user