补充是否公开资源的逻辑,修改精选列表展示逻辑

新增画布项目资源公开展示开关和公开精选资源接口。

左侧素材右键菜单支持公开展示勾选与删除操作。

陶泥儿精选改为读取全站公开生成资源并过滤同源同媒体副本。

拖拽生成素材入画布时保留源资源关系并复用同源资源。

同步 SpacetimeDB 迁移、绑定、前端测试和相关文档。
This commit is contained in:
2026-06-24 17:25:19 +08:00
parent 4a12b090b5
commit 32fc4089cb
44 changed files with 1689 additions and 190 deletions
@@ -3,12 +3,13 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ContextType } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AuthUiContext } from '../auth/AuthUiContext';
import { CreationLandingView } from './CreationLandingView';
const listEditorProjectsMock = vi.hoisted(() => vi.fn());
const listPublicEditorProjectResourcesMock = vi.hoisted(() => vi.fn());
const createEditorProjectMock = vi.hoisted(() => vi.fn());
type AuthValue = NonNullable<ContextType<typeof AuthUiContext>>;
@@ -45,6 +46,7 @@ function createAuthValue(overrides: Partial<AuthValue> = {}): AuthValue {
vi.mock('../../services/image-editor/editorProjectClient', () => ({
listEditorProjects: listEditorProjectsMock,
listPublicEditorProjectResources: listPublicEditorProjectResourcesMock,
createEditorProject: createEditorProjectMock,
}));
@@ -88,8 +90,13 @@ function renderCreationLanding({
}
describe('CreationLandingView', () => {
beforeEach(() => {
listPublicEditorProjectResourcesMock.mockResolvedValue([]);
});
afterEach(() => {
listEditorProjectsMock.mockReset();
listPublicEditorProjectResourcesMock.mockReset();
createEditorProjectMock.mockReset();
});
@@ -212,28 +219,24 @@ describe('CreationLandingView', () => {
it('renders generated project resources in showcase tabs without mock rows', async () => {
const user = userEvent.setup();
listEditorProjectsMock.mockResolvedValueOnce([
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
{
...projectItems[0]!,
resources: [
{
resourceId: 'resource-character',
projectId: 'project-newest',
label: '角色英雄',
imageSrc: 'data:image/png;base64,character',
width: 512,
height: 512,
sourceType: 'generated',
prompt: 'character hero prompt',
actualPrompt: null,
model: 'character-model',
provider: 'provider',
taskId: 'task-1',
assetKind: 'character',
authorName: '创作者A',
priceMudPoints: 20,
},
],
resourceId: 'resource-character',
projectId: 'project-newest',
label: '角色英雄',
imageSrc: 'data:image/png;base64,character',
width: 512,
height: 512,
sourceType: 'generated',
prompt: 'character hero prompt',
actualPrompt: null,
model: 'character-model',
provider: 'provider',
taskId: 'task-1',
assetKind: 'character',
authorName: '创作者A',
priceMudPoints: 20,
},
]);
@@ -251,55 +254,51 @@ describe('CreationLandingView', () => {
it('opens a fullscreen asset preview modal with thumbnail navigation', async () => {
const user = userEvent.setup();
listEditorProjectsMock.mockResolvedValueOnce([
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
{
...projectItems[0]!,
resources: [
{
resourceId: 'resource-character',
projectId: 'project-newest',
label: '角色英雄',
imageSrc: 'data:image/png;base64,character',
width: 512,
height: 512,
sourceType: 'generated',
prompt: 'character hero prompt',
actualPrompt: null,
model: 'character-model',
provider: 'provider',
taskId: 'task-1',
assetKind: 'character',
authorName: '创作者A',
priceMudPoints: 20,
},
{
resourceId: 'resource-animation',
projectId: 'project-newest',
label: '待机动画',
imageSrc: 'data:image/png;base64,animation',
width: 512,
height: 512,
sourceType: 'generated',
prompt: 'idle animation prompt',
actualPrompt: null,
model: 'seedance2.0-fast',
provider: 'ark',
taskId: 'task-2',
assetKind: 'character-animation',
priceMudPoints: 40,
generationInputs: {
fields: [{ title: '动作描述', value: '待机动作' }],
references: [
{
title: '角色图片',
label: '角色英雄',
refType: 'project-resource',
refId: 'resource-character',
},
],
resourceId: 'resource-character',
projectId: 'project-newest',
label: '角色英雄',
imageSrc: 'data:image/png;base64,character',
width: 512,
height: 512,
sourceType: 'generated',
prompt: 'character hero prompt',
actualPrompt: null,
model: 'character-model',
provider: 'provider',
taskId: 'task-1',
assetKind: 'character',
authorName: '创作者A',
priceMudPoints: 20,
},
{
resourceId: 'resource-animation',
projectId: 'project-newest',
label: '待机动画',
imageSrc: 'data:image/png;base64,animation',
width: 512,
height: 512,
sourceType: 'generated',
prompt: 'idle animation prompt',
actualPrompt: null,
model: 'seedance2.0-fast',
provider: 'ark',
taskId: 'task-2',
assetKind: 'character-animation',
priceMudPoints: 40,
generationInputs: {
fields: [{ title: '动作描述', value: '待机动作' }],
references: [
{
title: '角色图片',
label: '角色英雄',
refType: 'project-resource',
refId: 'resource-character',
},
},
],
],
},
},
]);
@@ -325,58 +324,49 @@ describe('CreationLandingView', () => {
});
it('uses Taonier featured as the generated project resource waterfall instead of creation entries', async () => {
listEditorProjectsMock.mockResolvedValueOnce([
listEditorProjectsMock.mockResolvedValueOnce(projectItems);
listPublicEditorProjectResourcesMock.mockResolvedValueOnce([
{
...projectItems[0]!,
resources: [
{
resourceId: 'resource-pack-a',
projectId: 'project-newest',
label: '素材包 A',
imageSrc: 'data:image/png;base64,a',
width: 512,
height: 640,
sourceType: 'generated',
prompt: '素材包提示词',
actualPrompt: null,
model: null,
provider: null,
taskId: null,
},
{
resourceId: 'resource-uploaded',
projectId: 'project-newest',
label: '上传素材',
imageSrc: 'data:image/png;base64,uploaded',
width: 512,
height: 512,
sourceType: 'uploaded',
prompt: null,
actualPrompt: null,
model: null,
provider: null,
taskId: null,
},
],
resourceId: 'resource-pack-a',
projectId: 'project-newest',
label: '素材包 A',
imageSrc: 'data:image/png;base64,a',
width: 512,
height: 640,
sourceType: 'generated',
prompt: '素材包提示词',
actualPrompt: null,
model: null,
provider: null,
taskId: null,
},
{
...projectItems[1]!,
resources: [
{
resourceId: 'resource-pack-b',
projectId: 'project-old',
label: '素材包 B',
imageSrc: 'data:image/png;base64,b',
width: 512,
height: 512,
sourceType: 'generated',
prompt: '第二组素材',
actualPrompt: null,
model: null,
provider: null,
taskId: null,
},
],
resourceId: 'resource-uploaded',
projectId: 'project-newest',
label: '上传素材',
imageSrc: 'data:image/png;base64,uploaded',
width: 512,
height: 512,
sourceType: 'uploaded',
prompt: null,
actualPrompt: null,
model: null,
provider: null,
taskId: null,
},
{
resourceId: 'resource-pack-b',
projectId: 'project-old',
label: '素材包 B',
imageSrc: 'data:image/png;base64,b',
width: 512,
height: 512,
sourceType: 'generated',
prompt: '第二组素材',
actualPrompt: null,
model: null,
provider: null,
taskId: null,
},
]);
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import {
createEditorProject,
listPublicEditorProjectResources,
listEditorProjects,
type EditorProjectResourceSnapshot,
type EditorProjectSnapshot,
@@ -364,6 +365,7 @@ export function CreationLandingView({
[],
);
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
const [isLoadingShowcase, setIsLoadingShowcase] = useState(false);
const [isCreatingProject, setIsCreatingProject] = useState(false);
const [projectError, setProjectError] = useState<string | null>(null);
const [projectShowcaseResources, setProjectShowcaseResources] = useState<
@@ -379,14 +381,11 @@ export function CreationLandingView({
const refreshRecentProjects = useCallback(() => {
if (!isAuthenticated) {
setRecentProjects([]);
setProjectShowcaseResources([]);
setProjectError(null);
setAssetError(null);
return;
}
setIsLoadingProjects(true);
setProjectError(null);
setAssetError(null);
listEditorProjects()
.then((projects) => {
const sortedProjects = [...projects].sort(
@@ -395,9 +394,6 @@ export function CreationLandingView({
new Date(left.updatedAt).getTime(),
);
setRecentProjects(sortedProjects.slice(0, 4));
setProjectShowcaseResources(
sortedProjects.flatMap((project) => project.resources ?? []),
);
})
.catch((error: unknown) => {
if (isUnauthorizedError(error)) {
@@ -409,7 +405,6 @@ export function CreationLandingView({
setProjectError(
message,
);
setAssetError(message);
})
.finally(() => setIsLoadingProjects(false));
}, [authUi, isAuthenticated]);
@@ -418,6 +413,24 @@ export function CreationLandingView({
refreshRecentProjects();
}, [refreshRecentProjects]);
const refreshShowcaseResources = useCallback(() => {
setIsLoadingShowcase(true);
setAssetError(null);
listPublicEditorProjectResources()
.then((resources) => setProjectShowcaseResources(resources))
.catch((error: unknown) => {
setProjectShowcaseResources([]);
setAssetError(
error instanceof Error ? error.message : '读取陶泥儿精选失败',
);
})
.finally(() => setIsLoadingShowcase(false));
}, []);
useEffect(() => {
refreshShowcaseResources();
}, [refreshShowcaseResources]);
const createProject = useCallback(() => {
if (authUi && !authUi.user) {
authUi.openLoginModal(createProject);
@@ -444,12 +457,12 @@ export function CreationLandingView({
buildCreationShowcaseItems({
activeTab: activeShowcaseTab,
projectResources: projectShowcaseResources,
fallbackAuthorName: authUi?.user?.displayName,
fallbackAuthorName: null,
}),
[activeShowcaseTab, projectShowcaseResources, authUi?.user?.displayName],
[activeShowcaseTab, projectShowcaseResources],
);
const isShowcaseLoading = isAuthenticated && isLoadingProjects;
const isShowcaseLoading = isLoadingShowcase;
const showcaseEmptyText = '暂无素材';
const renderAssetShowcase = () => {
@@ -53,6 +53,63 @@ describe('creationShowcaseModel', () => {
expect(items[0]?.label).toBe('画布生成素材');
});
it('filters out generated resources that are not public showcase enabled', () => {
const items = buildCreationShowcaseItems({
activeTab: 'packs',
projectResources: [
createResource({
resourceId: 'public-generated',
label: '公开素材',
sourceType: 'generated',
publicShowcaseEnabled: true,
}),
createResource({
resourceId: 'private-generated',
label: '不公开素材',
sourceType: 'generated',
publicShowcaseEnabled: false,
}),
],
});
expect(items).toHaveLength(1);
expect(items[0]?.label).toBe('公开素材');
});
it('deduplicates canvas copies that point back to the same generated resource', () => {
const items = buildCreationShowcaseItems({
activeTab: 'characters',
projectResources: [
createResource({
resourceId: 'resource-original',
label: '原始角色图',
imageSrc: '/generated/character.png',
objectKey: 'generated/character.png',
assetObjectId: 'asset-object-character',
assetKind: 'character',
sourceType: 'generated',
publicShowcaseEnabled: true,
}),
createResource({
resourceId: 'resource-copy',
label: '拖拽副本角色图',
imageSrc:
'https://oss.example.test/generated/character.png?x-oss-signature=1',
objectKey: '/generated/character.png',
assetObjectId: 'asset-object-character',
assetKind: 'character',
sourceType: 'generated',
sourceResourceId: 'resource-original',
publicShowcaseEnabled: true,
}),
],
});
expect(items).toHaveLength(1);
expect(items[0]?.previews).toHaveLength(1);
expect(items[0]?.previews[0]?.id).toBe('resource-original');
});
it('groups pack items by the same referenced spec image and sums known cost', () => {
const items = buildCreationShowcaseItems({
activeTab: 'packs',
@@ -210,15 +210,70 @@ function projectResourceToShowcaseAsset(
} as EditorAssetSnapshot;
}
function normalizeShowcaseMediaRef(value: string | null | undefined) {
const trimmedValue = value?.trim().split(/[?#]/u)[0]?.trim() ?? '';
if (!trimmedValue) {
return '';
}
const pathValue = /^https?:\/\//iu.test(trimmedValue)
? (() => {
try {
return new URL(trimmedValue).pathname;
} catch {
return trimmedValue;
}
})()
: trimmedValue;
return pathValue.replace(/^\/+/u, '');
}
function getProjectResourceMediaKey(resource: EditorProjectResourceSnapshot) {
const assetObjectId = resource.assetObjectId?.trim();
if (assetObjectId) {
return `asset-object:${assetObjectId}`;
}
const objectKey = normalizeShowcaseMediaRef(resource.objectKey);
if (objectKey) {
return `object:${objectKey}`;
}
const imageSrc = normalizeShowcaseMediaRef(resource.imageSrc);
return imageSrc ? `src:${imageSrc}` : '';
}
function isCopiedProjectResource(
resource: EditorProjectResourceSnapshot,
resourceById: Map<string, EditorProjectResourceSnapshot>,
) {
const sourceResourceId = resource.sourceResourceId?.trim();
if (!sourceResourceId) {
return false;
}
const sourceResource = resourceById.get(sourceResourceId);
if (!sourceResource) {
return false;
}
return (
getProjectResourceMediaKey(sourceResource) ===
getProjectResourceMediaKey(resource)
);
}
function buildGeneratedProjectResourceAssets(
resources: EditorProjectResourceSnapshot[],
) {
const seenResourceIds = new Set<string>();
const resourceById = new Map(
resources
.filter((resource) => resource.resourceId.trim())
.map((resource) => [resource.resourceId, resource]),
);
return resources
.filter(
(resource) =>
resource.sourceType === 'generated' && resource.imageSrc.trim(),
resource.publicShowcaseEnabled !== false &&
resource.sourceType === 'generated' &&
resource.imageSrc.trim(),
)
.filter((resource) => {
if (seenResourceIds.has(resource.resourceId)) {
@@ -227,6 +282,7 @@ function buildGeneratedProjectResourceAssets(
seenResourceIds.add(resource.resourceId);
return true;
})
.filter((resource) => !isCopiedProjectResource(resource, resourceById))
.map(projectResourceToShowcaseAsset);
}
@@ -60,6 +60,10 @@ type ImageCanvasAssetFolderSectionViewProps = {
startRenamingAsset: (asset: EditorAsset) => void;
commitAssetRename: (asset: EditorAsset) => void;
deleteUploadedAsset: (asset: EditorAsset) => void;
setAssetPublicShowcaseEnabled: (
asset: EditorAsset,
publicShowcaseEnabled: boolean,
) => void;
toggleAssetSelected: (assetId: string) => void;
addAssetLayer: (asset: EditorAsset) => void;
};
@@ -90,6 +94,7 @@ export function ImageCanvasAssetFolderSectionView({
startRenamingAsset,
commitAssetRename,
deleteUploadedAsset,
setAssetPublicShowcaseEnabled,
toggleAssetSelected,
addAssetLayer,
}: ImageCanvasAssetFolderSectionViewProps) {
@@ -248,6 +253,7 @@ export function ImageCanvasAssetFolderSectionView({
startRenamingAsset={startRenamingAsset}
commitAssetRename={commitAssetRename}
deleteUploadedAsset={deleteUploadedAsset}
setAssetPublicShowcaseEnabled={setAssetPublicShowcaseEnabled}
toggleAssetSelected={toggleAssetSelected}
addAssetLayer={addAssetLayer}
/>
@@ -90,6 +90,10 @@ export type ImageCanvasAssetLibraryPanelViewProps = {
startRenamingAsset: (asset: EditorAsset) => void;
commitAssetRename: (asset: EditorAsset) => void;
deleteUploadedAsset: (asset: EditorAsset) => void;
setAssetPublicShowcaseEnabled: (
asset: EditorAsset,
publicShowcaseEnabled: boolean,
) => void;
toggleAssetSelected: (assetId: string) => void;
addAssetLayer: (asset: EditorAsset) => void;
toggleAllAssetsSelected: () => void;
@@ -136,6 +140,7 @@ export function ImageCanvasAssetLibraryPanelView({
startRenamingAsset,
commitAssetRename,
deleteUploadedAsset,
setAssetPublicShowcaseEnabled,
toggleAssetSelected,
addAssetLayer,
toggleAllAssetsSelected,
@@ -226,6 +231,7 @@ export function ImageCanvasAssetLibraryPanelView({
startRenamingAsset={startRenamingAsset}
commitAssetRename={commitAssetRename}
deleteUploadedAsset={deleteUploadedAsset}
setAssetPublicShowcaseEnabled={setAssetPublicShowcaseEnabled}
toggleAssetSelected={toggleAssetSelected}
addAssetLayer={addAssetLayer}
/>
@@ -56,6 +56,8 @@ function renderAssetRow({
addAssetLayer = vi.fn(),
toggleAssetSelected = vi.fn(),
commitAssetRename = vi.fn(),
deleteUploadedAsset = vi.fn(),
setAssetPublicShowcaseEnabled = vi.fn(),
}: {
asset?: EditorAsset;
isAssetSelectionMode?: boolean;
@@ -65,6 +67,11 @@ function renderAssetRow({
addAssetLayer?: (asset: EditorAsset) => void;
toggleAssetSelected?: (assetId: string) => void;
commitAssetRename?: (asset: EditorAsset) => void;
deleteUploadedAsset?: (asset: EditorAsset) => void;
setAssetPublicShowcaseEnabled?: (
asset: EditorAsset,
publicShowcaseEnabled: boolean,
) => void;
} = {}) {
const props = {
asset,
@@ -82,7 +89,8 @@ function renderAssetRow({
moveAssetToFolder: vi.fn(),
startRenamingAsset: vi.fn(),
commitAssetRename,
deleteUploadedAsset: vi.fn(),
deleteUploadedAsset,
setAssetPublicShowcaseEnabled,
toggleAssetSelected,
addAssetLayer,
};
@@ -207,4 +215,84 @@ describe('ImageCanvasAssetRowView', () => {
'generated-character-drafts/editor/spec.png',
);
});
it('opens asset actions from the right click menu', () => {
const asset = createAsset({
sourceResourceId: 'resource-asset-1',
publicShowcaseEnabled: true,
});
const deleteUploadedAsset = vi.fn();
const setAssetPublicShowcaseEnabled = vi.fn();
renderAssetRow({
asset,
deleteUploadedAsset,
setAssetPublicShowcaseEnabled,
});
expect(
screen.queryByRole('button', { name: '删除素材账号素材A' }),
).toBeNull();
fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), {
clientX: 120,
clientY: 80,
});
const publicMenuItem = screen.getByRole('menuitemcheckbox', {
name: /公开展示该素材/u,
});
expect(publicMenuItem.getAttribute('aria-checked')).toBe('true');
fireEvent.click(publicMenuItem);
expect(setAssetPublicShowcaseEnabled).toHaveBeenCalledWith(asset, false);
fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), {
clientX: 120,
clientY: 80,
});
fireEvent.click(screen.getByRole('menuitem', { name: '删除' }));
expect(deleteUploadedAsset).toHaveBeenCalledWith(asset);
});
it('disables public showcase toggle when the asset is not linked to a project resource', () => {
const setAssetPublicShowcaseEnabled = vi.fn();
renderAssetRow({ setAssetPublicShowcaseEnabled });
fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), {
clientX: 120,
clientY: 80,
});
const publicMenuItem = screen.getByRole('menuitemcheckbox', {
name: /公开展示该素材/u,
});
expect((publicMenuItem as HTMLButtonElement).disabled).toBe(true);
fireEvent.click(publicMenuItem);
expect(setAssetPublicShowcaseEnabled).not.toHaveBeenCalled();
});
it('keeps public showcase toggle enabled for legacy generated assets once a source resource is inferred', () => {
const asset = createAsset({
sourceType: 'generated',
sourceResourceId: 'resource-inferred-from-media',
publicShowcaseEnabled: null,
});
const setAssetPublicShowcaseEnabled = vi.fn();
renderAssetRow({ asset, setAssetPublicShowcaseEnabled });
fireEvent.contextMenu(screen.getByRole('button', { name: '添加账号素材A' }), {
clientX: 120,
clientY: 80,
});
const publicMenuItem = screen.getByRole('menuitemcheckbox', {
name: /公开展示该素材/u,
});
expect((publicMenuItem as HTMLButtonElement).disabled).toBe(false);
expect(publicMenuItem.getAttribute('aria-checked')).toBe('true');
fireEvent.click(publicMenuItem);
expect(setAssetPublicShowcaseEnabled).toHaveBeenCalledWith(asset, false);
});
});
@@ -1,4 +1,5 @@
import { Check, Music2, Pencil, Trash2, Video, X } from 'lucide-react';
import { Check, Music2, Pencil, Video, X } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import { PlatformTextField } from '../common/PlatformTextField';
@@ -41,6 +42,10 @@ export type ImageCanvasAssetRowViewProps = {
startRenamingAsset: (asset: EditorAsset) => void;
commitAssetRename: (asset: EditorAsset) => void;
deleteUploadedAsset: (asset: EditorAsset) => void;
setAssetPublicShowcaseEnabled: (
asset: EditorAsset,
publicShowcaseEnabled: boolean,
) => void;
toggleAssetSelected: (assetId: string) => void;
addAssetLayer: (asset: EditorAsset) => void;
};
@@ -62,12 +67,51 @@ export function ImageCanvasAssetRowView({
startRenamingAsset,
commitAssetRename,
deleteUploadedAsset,
setAssetPublicShowcaseEnabled,
toggleAssetSelected,
addAssetLayer,
}: ImageCanvasAssetRowViewProps) {
const rowRef = useRef<HTMLDivElement | null>(null);
const [menuPosition, setMenuPosition] = useState<{
x: number;
y: number;
} | null>(null);
const isRenaming = renamingAsset?.assetId === asset.id;
const isUploadingAsset = asset.uploadStatus === 'uploading';
const isFailedUpload = asset.uploadStatus === 'failed';
const canShowcaseAsset = Boolean(asset.sourceResourceId);
const publicShowcaseEnabled = asset.publicShowcaseEnabled ?? true;
const closeMenu = useCallback(() => {
setMenuPosition(null);
}, []);
useEffect(() => {
if (!menuPosition) {
return undefined;
}
const handlePointerDown = (event: PointerEvent) => {
if (rowRef.current?.contains(event.target as Node)) {
return;
}
closeMenu();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeMenu();
}
};
window.addEventListener('pointerdown', handlePointerDown);
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('scroll', closeMenu, true);
window.addEventListener('resize', closeMenu);
return () => {
window.removeEventListener('pointerdown', handlePointerDown);
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('scroll', closeMenu, true);
window.removeEventListener('resize', closeMenu);
};
}, [closeMenu, menuPosition]);
const uploadProgress = clamp(asset.uploadProgress ?? 0, 0, 100);
const mediaPreviewMarker = getCanvasMediaPreviewMarker(asset);
const mediaPreviewClassName =
@@ -146,19 +190,22 @@ export function ImageCanvasAssetRowView({
icon={Pencil}
onClick={() => startRenamingAsset(asset)}
/>
{asset.sourceKind === 'uploaded' ? (
<EditorIconButton
label={`删除素材${asset.label}`}
title="删除"
icon={Trash2}
onClick={() => deleteUploadedAsset(asset)}
/>
) : null}
</div>
);
return (
<div data-asset-id={asset.id}>
<div
ref={rowRef}
data-asset-id={asset.id}
onContextMenu={(event) => {
if (isUploadingAsset || isFailedUpload || isRenaming) {
return;
}
event.preventDefault();
event.stopPropagation();
setMenuPosition({ x: event.clientX, y: event.clientY });
}}
>
<SidebarMediaItem
title={asset.label}
detail={mediaDetail}
@@ -350,6 +397,46 @@ export function ImageCanvasAssetRowView({
});
}}
/>
{menuPosition ? (
<div
className="image-canvas-editor__context-menu image-canvas-editor__asset-context-menu"
role="menu"
style={{
left: menuPosition.x,
top: menuPosition.y,
}}
>
<button
type="button"
role="menuitemcheckbox"
aria-checked={publicShowcaseEnabled}
disabled={!canShowcaseAsset}
onClick={() => {
if (!canShowcaseAsset) {
return;
}
setAssetPublicShowcaseEnabled(asset, !publicShowcaseEnabled);
closeMenu();
}}
>
<span aria-hidden="true">{publicShowcaseEnabled ? '✓' : ''}</span>
公开展示该素材
</button>
{asset.sourceKind === 'uploaded' ? (
<button
type="button"
role="menuitem"
className="image-canvas-editor__context-menu-danger"
onClick={() => {
deleteUploadedAsset(asset);
closeMenu();
}}
>
删除
</button>
) : null}
</div>
) : null}
</div>
);
}
@@ -327,18 +327,22 @@ describe('ImageCanvasEditorView asset library integration', () => {
expect(
within(customFolder).getByRole('button', { name: '添加角色草图.png' }),
).toBeTruthy();
expect(
within(customFolder).getByRole('button', {
name: '删除素材角色草图.png',
}),
).toBeTruthy();
});
await user.click(
within(customFolder).getByRole('button', {
name: '删除素材角色草图.png',
}),
const uploadedAssetButton = within(customFolder).getByRole('button', {
name: '添加角色草图.png',
});
const uploadedAssetRow = uploadedAssetButton.closest(
'[data-asset-id="persisted-角色草图.png"]',
);
if (!uploadedAssetRow) {
throw new Error('uploaded asset row should exist');
}
fireEvent.contextMenu(uploadedAssetRow, {
clientX: 120,
clientY: 180,
});
await user.click(screen.getByRole('menuitem', { name: '删除' }));
expect(
screen.queryByRole('button', { name: '添加角色草图.png' }),
@@ -867,7 +871,20 @@ describe('ImageCanvasEditorView asset library integration', () => {
expect(await screen.findByAltText('画布图片:生成资源A')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '删除素材生成资源A' }));
const generatedAssetButton = screen.getByRole('button', {
name: '添加生成资源A',
});
const generatedAssetRow = generatedAssetButton.closest(
'[data-asset-id="asset-generated-a"]',
);
if (!generatedAssetRow) {
throw new Error('generated asset row should exist');
}
fireEvent.contextMenu(generatedAssetRow, {
clientX: 120,
clientY: 180,
});
await user.click(screen.getByRole('menuitem', { name: '删除' }));
await waitFor(() => {
expect(screen.queryByAltText('画布图片:生成资源A')).toBeNull();
@@ -1144,4 +1161,62 @@ describe('ImageCanvasEditorView asset library integration', () => {
expect(createEditorAssetMock).not.toHaveBeenCalled();
});
it('keeps the source resource id when dragging a generated asset onto the viewport', async () => {
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
createTestImageAsset({
assetId: 'asset-generated-character',
label: '生成角色素材',
imageSrc: '/generated/character.png',
objectKey: 'generated/character.png',
assetObjectId: 'asset-object-character',
sourceType: 'generated',
sourceResourceId: 'resource-original-character',
}),
],
});
render(<ImageCanvasEditorView />);
const sourceAsset = await screen.findByRole('button', {
name: '添加生成角色素材',
});
const sourceAssetRow = sourceAsset.closest(
'.image-canvas-editor__asset-row',
);
const viewport = screen.getByLabelText('画布工作区');
const dataTransfer = createDataTransferStub();
if (!sourceAssetRow) {
throw new Error('asset row should exist');
}
fireEvent.dragStart(sourceAssetRow, { dataTransfer });
fireEvent.drop(viewport, {
clientX: 520,
clientY: 300,
dataTransfer,
});
expect(await screen.findByAltText('画布图片:生成角色素材')).toBeTruthy();
await waitFor(() => {
expect(createEditorProjectResourceMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
imageSrc: '/generated/character.png',
sourceType: 'generated',
sourceResourceId: 'resource-original-character',
}),
);
});
expect(createEditorAssetMock).not.toHaveBeenCalled();
});
});
@@ -710,7 +710,8 @@ export function mapAssetLibrarySnapshot(
taskId: asset.taskId ?? undefined,
objectKey: asset.objectKey ?? undefined,
assetObjectId: asset.assetObjectId ?? undefined,
sourceResourceId: asset.sourceResourceId ?? undefined,
sourceResourceId: asset.sourceResourceId ?? null,
publicShowcaseEnabled: asset.publicShowcaseEnabled ?? null,
assetKind: canvasAssetKindOrNull(asset.assetKind),
generationInputs: generationInputsOrNull(asset.generationInputs),
durationSeconds: asset.durationSeconds ?? undefined,
@@ -77,6 +77,7 @@ function createSidebarProps(): ImageCanvasSidebarViewProps {
startRenamingAsset: vi.fn(),
commitAssetRename: vi.fn(),
deleteUploadedAsset: vi.fn(),
setAssetPublicShowcaseEnabled: vi.fn(),
toggleAssetSelected: vi.fn(),
addAssetLayer: vi.fn(),
toggleAllAssetsSelected: vi.fn(),
@@ -48,6 +48,7 @@ export type EditorAsset = {
objectKey?: string;
assetObjectId?: string;
sourceResourceId?: string | null;
publicShowcaseEnabled?: boolean | null;
assetKind?: CanvasAssetKind | null;
generationInputs?: CanvasGenerationInputs | null;
durationSeconds?: number;
@@ -382,6 +382,7 @@ export function ImageCanvasEditorView() {
toggleAssetFolder,
commitNewAssetFolder,
deleteUploadedAsset,
setAssetPublicShowcaseEnabled,
startRenamingFolder,
commitFolderRename,
deleteAssetFolder,
@@ -1317,6 +1318,7 @@ export function ImageCanvasEditorView() {
startRenamingAsset,
commitAssetRename,
deleteUploadedAsset,
setAssetPublicShowcaseEnabled,
toggleAssetSelected,
addAssetLayer,
toggleAllAssetsSelected,
@@ -115,6 +115,7 @@ function createSidebarProps(
startRenamingAsset: vi.fn(),
commitAssetRename: vi.fn(),
deleteUploadedAsset: vi.fn(),
setAssetPublicShowcaseEnabled: vi.fn(),
toggleAssetSelected: vi.fn(),
addAssetLayer: vi.fn(),
toggleAllAssetsSelected: vi.fn(),
@@ -96,6 +96,10 @@ export type ImageCanvasSidebarViewProps = {
startRenamingAsset: (asset: EditorAsset) => void;
commitAssetRename: (asset: EditorAsset) => void;
deleteUploadedAsset: (asset: EditorAsset) => void;
setAssetPublicShowcaseEnabled: (
asset: EditorAsset,
publicShowcaseEnabled: boolean,
) => void;
toggleAssetSelected: (assetId: string) => void;
addAssetLayer: (asset: EditorAsset) => void;
toggleAllAssetsSelected: () => void;
@@ -160,6 +164,7 @@ export function ImageCanvasSidebarView({
startRenamingAsset,
commitAssetRename,
deleteUploadedAsset,
setAssetPublicShowcaseEnabled,
toggleAssetSelected,
addAssetLayer,
toggleAllAssetsSelected,
@@ -313,6 +318,7 @@ export function ImageCanvasSidebarView({
startRenamingAsset={startRenamingAsset}
commitAssetRename={commitAssetRename}
deleteUploadedAsset={deleteUploadedAsset}
setAssetPublicShowcaseEnabled={setAssetPublicShowcaseEnabled}
toggleAssetSelected={toggleAssetSelected}
addAssetLayer={addAssetLayer}
toggleAllAssetsSelected={toggleAllAssetsSelected}
@@ -15,6 +15,7 @@ const deleteEditorAssetFolderMock = vi.hoisted(() => vi.fn());
const loadEditorAssetLibraryMock = vi.hoisted(() => vi.fn());
const updateEditorAssetMock = vi.hoisted(() => vi.fn());
const updateEditorAssetFolderMock = vi.hoisted(() => vi.fn());
const updateEditorProjectResourceShowcaseMock = vi.hoisted(() => vi.fn());
const defaultOpenEditorLoginModal = () => {};
const defaultOnDeleteAssets = () => {};
@@ -31,6 +32,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => {
loadEditorAssetLibrary: loadEditorAssetLibraryMock,
updateEditorAsset: updateEditorAssetMock,
updateEditorAssetFolder: updateEditorAssetFolderMock,
updateEditorProjectResourceShowcase: updateEditorProjectResourceShowcaseMock,
};
});
@@ -107,7 +109,12 @@ function AssetLibraryHarness({
</span>
<span data-testid="assets">
{assetLibrary.assets
.map((asset) => `${asset.id}:${asset.label}:${asset.folderId}`)
.map(
(asset) =>
`${asset.id}:${asset.label}:${asset.folderId}:${
asset.publicShowcaseEnabled ?? 'unset'
}`,
)
.join('|')}
</span>
<span data-testid="active-upload-folder">
@@ -154,6 +161,19 @@ function AssetLibraryHarness({
>
delete asset
</button>
<button
type="button"
onClick={() => {
const target = assetLibrary.assets.find(
(asset) => asset.id === 'asset-a',
);
if (target) {
assetLibrary.setAssetPublicShowcaseEnabled(target, false);
}
}}
>
hide asset from showcase
</button>
<button type="button" onClick={assetLibrary.toggleAllAssetsSelected}>
toggle all
</button>
@@ -186,6 +206,15 @@ describe('useImageCanvasAssetLibrary', () => {
systemDefault: false,
});
updateEditorAssetMock.mockResolvedValue(createAssetSnapshot());
updateEditorProjectResourceShowcaseMock.mockResolvedValue({
resourceId: 'resource-a',
projectId: 'project-1',
imageSrc: 'data:image/png;base64,YQ==',
width: 320,
height: 240,
sourceType: 'generated',
publicShowcaseEnabled: false,
});
deleteEditorAssetMock.mockResolvedValue(createAssetSnapshot());
deleteEditorAssetFolderMock.mockResolvedValue({
folders: [
@@ -236,7 +265,7 @@ describe('useImageCanvasAssetLibrary', () => {
);
});
expect(screen.getByTestId('assets').textContent).toBe(
'asset-a:素材A:project',
'asset-a:素材A:project:unset',
);
expect(screen.getByTestId('active-upload-folder').textContent).toBe(
'project',
@@ -342,7 +371,7 @@ describe('useImageCanvasAssetLibrary', () => {
act(() => screen.getByRole('button', { name: 'move asset' }).click());
expect(screen.getByTestId('assets').textContent).toBe(
'asset-a:素材A:folder-role',
'asset-a:素材A:folder-role:unset',
);
expect(updateEditorAssetMock).toHaveBeenCalledWith('asset-a', {
folderId: 'folder-role',
@@ -393,7 +422,7 @@ describe('useImageCanvasAssetLibrary', () => {
act(() => screen.getByRole('button', { name: 'delete selected' }).click());
expect(screen.getByTestId('assets').textContent).toBe(
'asset-b:素材B:project',
'asset-b:素材B:project:unset',
);
expect(onDeleteAssets).toHaveBeenCalledWith([
expect.objectContaining({ id: 'asset-a' }),
@@ -401,4 +430,112 @@ describe('useImageCanvasAssetLibrary', () => {
expect(deleteEditorAssetMock).toHaveBeenCalledWith('asset-a');
expect(deleteEditorAssetMock).not.toHaveBeenCalledWith('asset-b');
});
it('updates public showcase state through the linked project resource', async () => {
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
createAssetSnapshot({
assetId: 'asset-a',
sourceType: 'generated',
sourceResourceId: 'resource-a',
publicShowcaseEnabled: true,
}),
],
});
render(<AssetLibraryHarness />);
await screen.findByText('素材A');
act(() =>
screen.getByRole('button', { name: 'hide asset from showcase' }).click(),
);
expect(screen.getByTestId('assets').textContent).toBe(
'asset-a:素材A:project:false',
);
expect(updateEditorProjectResourceShowcaseMock).toHaveBeenCalledWith(
'resource-a',
false,
);
});
it('updates legacy generated asset showcase state when backend infers source resource id', async () => {
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
createAssetSnapshot({
assetId: 'asset-a',
sourceType: 'generated',
sourceResourceId: 'resource-inferred',
publicShowcaseEnabled: null,
}),
],
});
render(<AssetLibraryHarness />);
await screen.findByText('素材A');
act(() =>
screen.getByRole('button', { name: 'hide asset from showcase' }).click(),
);
expect(updateEditorProjectResourceShowcaseMock).toHaveBeenCalledWith(
'resource-inferred',
false,
);
});
it('rolls back public showcase state when update fails', async () => {
updateEditorProjectResourceShowcaseMock.mockRejectedValueOnce(
new Error('network'),
);
loadEditorAssetLibraryMock.mockResolvedValueOnce({
folders: [
{
folderId: 'project',
label: '项目素材',
sortOrder: 0,
collapsed: false,
systemDefault: true,
},
],
assets: [
createAssetSnapshot({
assetId: 'asset-a',
sourceType: 'generated',
sourceResourceId: 'resource-a',
publicShowcaseEnabled: true,
}),
],
});
render(<AssetLibraryHarness />);
await screen.findByText('素材A');
act(() =>
screen.getByRole('button', { name: 'hide asset from showcase' }).click(),
);
await waitFor(() => {
expect(screen.getByTestId('assets').textContent).toBe(
'asset-a:素材A:project:true',
);
});
});
});
@@ -17,6 +17,7 @@ import {
loadEditorAssetLibrary,
updateEditorAsset,
updateEditorAssetFolder,
updateEditorProjectResourceShowcase,
} from '../../services/image-editor/editorProjectClient';
import {
EDITOR_ASSET_FOLDERS,
@@ -320,6 +321,38 @@ export function useImageCanvasAssetLibrary({
[onDeleteAssets],
);
const setAssetPublicShowcaseEnabled = useCallback(
(asset: EditorAsset, publicShowcaseEnabled: boolean) => {
if (!asset.sourceResourceId) {
return;
}
const previousValue = asset.publicShowcaseEnabled ?? true;
setAssets((currentAssets) =>
currentAssets.map((currentAsset) =>
currentAsset.id === asset.id
? { ...currentAsset, publicShowcaseEnabled }
: currentAsset,
),
);
updateEditorProjectResourceShowcase(
asset.sourceResourceId,
publicShowcaseEnabled,
).catch(() => {
setAssets((currentAssets) =>
currentAssets.map((currentAsset) =>
currentAsset.id === asset.id
? {
...currentAsset,
publicShowcaseEnabled: previousValue,
}
: currentAsset,
),
);
});
},
[],
);
const startRenamingFolder = useCallback((folder: EditorAssetFolder) => {
setRenamingFolder({
folderId: folder.id,
@@ -558,6 +591,7 @@ export function useImageCanvasAssetLibrary({
toggleAssetFolder,
commitNewAssetFolder,
deleteUploadedAsset,
setAssetPublicShowcaseEnabled,
startRenamingFolder,
commitFolderRename,
deleteAssetFolder,
+11
View File
@@ -7743,6 +7743,17 @@ button.image-canvas-editor__reference-chip:disabled {
color: #a3acba;
}
.image-canvas-editor__asset-context-menu {
z-index: 42;
}
.image-canvas-editor__asset-context-menu button span[aria-hidden='true'] {
display: inline-flex;
width: 1rem;
flex: 0 0 1rem;
justify-content: center;
}
.image-canvas-editor__context-menu hr {
width: 100%;
height: 1px;
@@ -17,6 +17,7 @@ import {
generateEditorSoundEffect,
generateEditorVideo,
listEditorProjects,
listPublicEditorProjectResources,
loadEditorGenerationPricing,
loadEditorAssetLibrary,
loadEditorProject,
@@ -25,6 +26,7 @@ import {
saveEditorProjectLayout,
updateEditorAsset,
updateEditorAssetFolder,
updateEditorProjectResourceShowcase,
} from './editorProjectClient';
const requestJsonMock = vi.hoisted(() => vi.fn());
@@ -212,6 +214,39 @@ describe('editorProjectClient', () => {
);
});
it('loads public showcase project resources without requiring auth', async () => {
requestJsonMock.mockResolvedValueOnce({
resources: [
{
resourceId: 'resource-public-1',
projectId: 'editor-project-1',
label: '公开角色素材',
imageSrc: '/generated-editor-assets/public.png',
width: 512,
height: 512,
sourceType: 'generated',
publicShowcaseEnabled: true,
},
],
});
const resources = await listPublicEditorProjectResources();
expect(resources).toHaveLength(1);
expect(resources[0]?.resourceId).toBe('resource-public-1');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/showcase/resources',
{ method: 'GET' },
'读取陶泥儿精选素材失败',
{
skipAuth: true,
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
},
);
});
it('loads an explicit project by id', async () => {
requestJsonMock.mockResolvedValueOnce({
project: {
@@ -341,6 +376,38 @@ describe('editorProjectClient', () => {
);
});
it('updates project resource showcase visibility', async () => {
requestJsonMock.mockResolvedValueOnce({
resource: {
resourceId: 'resource-generated-1',
projectId: 'editor-project-1',
label: '角色素材',
imageSrc: '/generated-editor-assets/project/image.png',
objectKey: 'generated-editor-assets/project/image.png',
width: 2048,
height: 2048,
sourceType: 'generated',
publicShowcaseEnabled: false,
},
});
const resource = await updateEditorProjectResourceShowcase(
'resource-generated-1',
false,
);
expect(resource.publicShowcaseEnabled).toBe(false);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/project-resources/resource-generated-1/showcase',
expect.objectContaining({
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ publicShowcaseEnabled: false }),
}),
'更新素材公开展示状态失败',
);
});
it('loads and mutates the account-level asset library', async () => {
requestJsonMock
.mockResolvedValueOnce({
@@ -4,6 +4,8 @@ import { EDITOR_REQUEST_RETRY_OPTIONS } from './editorRetryOptions';
const EDITOR_PROJECT_API_BASE = '/api/editor/projects';
const EDITOR_ASSET_API_BASE = '/api/editor/assets';
const EDITOR_SHOWCASE_RESOURCE_API = '/api/editor/showcase/resources';
const EDITOR_PROJECT_RESOURCE_API_BASE = '/api/editor/project-resources';
const EDITOR_IMAGE_GENERATION_API = '/api/editor/images/generations';
const EDITOR_IMAGE_EDIT_API = '/api/editor/images/edits';
const EDITOR_ICON_SPRITESHEET_GENERATION_API =
@@ -72,6 +74,7 @@ export type EditorProjectResourceSnapshot = {
sourceResourceId?: string | null;
assetKind?: string | null;
generationInputs?: EditorAssetGenerationInputs | null;
publicShowcaseEnabled?: boolean;
createdAt?: string;
updatedAt?: string;
};
@@ -105,6 +108,7 @@ export type EditorAssetSnapshot = {
sourceResourceId?: string | null;
assetKind?: string | null;
generationInputs?: EditorAssetGenerationInputs | null;
publicShowcaseEnabled?: boolean | null;
createdAt?: string;
updatedAt?: string;
};
@@ -477,6 +481,10 @@ type EditorProjectResourceResponse = {
resource: EditorProjectResourceSnapshot;
};
type EditorProjectResourceListResponse = {
resources: EditorProjectResourceSnapshot[];
};
type EditorAssetLibraryResponse = {
library: EditorAssetLibrarySnapshot;
};
@@ -516,6 +524,21 @@ export async function listEditorProjects() {
return response.projects;
}
export async function listPublicEditorProjectResources() {
const response = await requestJson<EditorProjectResourceListResponse>(
EDITOR_SHOWCASE_RESOURCE_API,
{ method: 'GET' },
'读取陶泥儿精选素材失败',
{
skipAuth: true,
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
},
);
return response.resources;
}
export async function loadRecentEditorProject() {
return requestJson<EditorProjectRecentResponse>(
`${EDITOR_PROJECT_API_BASE}/recent`,
@@ -612,6 +635,18 @@ export async function createEditorProjectResource(
return response.resource;
}
export async function updateEditorProjectResourceShowcase(
resourceId: string,
publicShowcaseEnabled: boolean,
) {
const response = await requestJson<EditorProjectResourceResponse>(
`${EDITOR_PROJECT_RESOURCE_API_BASE}/${encodeURIComponent(resourceId)}/showcase`,
jsonRequest('PATCH', { publicShowcaseEnabled }),
'更新素材公开展示状态失败',
);
return response.resource;
}
export async function loadEditorAssetLibrary() {
const response = await requestJson<EditorAssetLibraryResponse>(
`${EDITOR_ASSET_API_BASE}/library`,