合并编辑器素材库分支

合入 codex/editor-asset-library 最新改动
保留音视频 OSS 素材库保存与自定义播放器交互
合并画布素材标签编辑与视频换签渲染逻辑
通过后端编译和角色抠图回归测试
This commit is contained in:
2026-06-19 15:58:20 +08:00
70 changed files with 4418 additions and 273 deletions
@@ -183,7 +183,7 @@ describe('ImageCanvasBasicGenerationComposerView', () => {
expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2');
const submitButton = within(panel).getByRole('button', { name: '生成' });
expect(submitButton.textContent).toBe('生成12泥点');
expect(submitButton.textContent).toBe('生成20泥点');
});
it('clicks the parent generation panel to collapse an opened option panel', () => {
@@ -10,6 +10,7 @@ import type {
UploadTarget,
} from './ImageCanvasEditorTypes';
import { ImageCanvasCharacterGenerationComposerView } from './ImageCanvasCharacterGenerationComposerView';
import { IMAGE_MODEL_GPT_IMAGE_2 } from './ImageCanvasGenerationModel';
function createDialog(
patch: Partial<GenerateDialogState> = {},
@@ -136,6 +137,33 @@ describe('ImageCanvasCharacterGenerationComposerView', () => {
);
});
it('updates the submit price when switching the character image model', () => {
const rememberImageModel = vi.fn();
render(
<CharacterGenerationHarness
initialDialog={createDialog({
imageModel: 'gemini-3.1-flash-image-preview',
aspectRatio: '1:1',
imageSize: '1K',
})}
onRememberImageModel={rememberImageModel}
/>,
);
expect(screen.getByRole('button', { name: '生成' }).textContent).toBe(
'生成12泥点',
);
fireEvent.click(
screen.getByRole('button', { name: '生成图片模型 nanobanana2' }),
);
fireEvent.click(screen.getByRole('button', { name: 'gpt-image-2' }));
expect(rememberImageModel).toHaveBeenCalledWith(IMAGE_MODEL_GPT_IMAGE_2);
expect(screen.getByRole('button', { name: '生成' }).textContent).toBe(
'生成20泥点',
);
});
it('routes character reference menus to canvas picking, spec creation and upload', () => {
const openSpecDialog = vi.fn();
const requestUpload = vi.fn();
@@ -1095,7 +1095,7 @@ describe('ImageCanvasEditorView generation integration', () => {
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'icon-gpt-model-1',
priceMudPoints: 12,
priceMudPoints: 20,
});
render(<ImageCanvasEditorView />);
await screen.findByAltText('画布图片:拼图素材');
@@ -1161,6 +1161,7 @@ describe('ImageCanvasEditorView generation integration', () => {
model: 'gpt-image-2',
aspectRatio: '1:1',
imageSize: '1K',
priceMudPoints: 20,
}),
);
});
@@ -180,6 +180,31 @@ describe('ImageCanvasEditorModel', () => {
});
});
it('hydrates manually selected publication material tags from saved layout', () => {
const hydrated = hydrateLayer(
{
layerId: 'layer-publication',
resourceId: 'resource-publication',
title: '宣发图',
x: 10,
y: 20,
width: 1024,
height: 1024,
originalWidth: 1024,
originalHeight: 1024,
zIndex: 5,
sourceType: 'generated',
assetKind: 'publication-material',
},
new Map([['resource-publication', { imageSrc: '/read/publication.png' }]]),
);
expect(hydrated).toMatchObject({
id: 'layer-publication',
assetKind: 'publication-material',
});
});
it('hydrates audio media type and sound effect asset kind from saved layout', () => {
const hydrated = hydrateLayer(
{
@@ -702,6 +702,7 @@ export function canvasAssetKindOrNull(value: unknown): CanvasAssetKind | null {
value === 'icon' ||
value === 'icon-spritesheet' ||
value === 'icon-spec' ||
value === 'publication-material' ||
value === 'ui-design' ||
value === 'video' ||
value === 'sound-effect' ||
@@ -159,6 +159,7 @@ function createStageProps(): ImageCanvasStageViewProps {
onLayerMouseEnter: vi.fn(),
onLayerMouseLeave: vi.fn(),
onOpenLayerMetadata: vi.fn(),
onUpdateLayerAssetKind: vi.fn(),
onGenerationFramePointerDown: vi.fn(),
onActivateGenerationDialog: vi.fn(),
onCropExpandHandlePointerDown: vi.fn(),
@@ -1031,6 +1031,37 @@ describe('ImageCanvasEditorView', () => {
);
});
it('saves manually selected layer tags in the canvas layout', async () => {
render(<ImageCanvasEditorView />);
const layerButton = await screen.findByRole('button', {
name: '选择拼图素材',
});
fireEvent.click(
within(layerButton).getByRole('button', { name: '素材标签:未知' }),
);
fireEvent.click(
within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole(
'menuitem',
{ name: '角色' },
),
);
await waitFor(() => {
expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith(
'editor-project-default',
expect.objectContaining({
layers: expect.arrayContaining([
expect.objectContaining({
title: '拼图素材',
assetKind: 'character',
}),
]),
}),
);
});
});
it('offers Lovart-style zoom menu commands', async () => {
render(<ImageCanvasEditorView />);
@@ -18,6 +18,7 @@ import { resolveContextMenuPosition } from './ImageCanvasEditorModel';
import { ImageCanvasEditorShellView } from './ImageCanvasEditorShellView';
import type {
AssetPointerDragState,
CanvasAssetKind,
CanvasContextMenuState,
CanvasGenerationDialogState,
CanvasLayer,
@@ -737,6 +738,30 @@ export function ImageCanvasEditorView() {
setPendingGenerationDeleteDialog(null);
removeCanvasGenerationDialog(dialog.id);
}, [pendingGenerationDeleteDialog, removeCanvasGenerationDialog]);
const updateLayerAssetKind = useCallback(
(layerId: string, assetKind: CanvasAssetKind | null) => {
const targetLayer = layersRef.current.find((layer) => layer.id === layerId);
if (!targetLayer || targetLayer.assetKind === assetKind) {
return;
}
captureCanvasHistory();
const nextLayer = {
...targetLayer,
assetKind,
};
setLayers((currentLayers) =>
currentLayers.map((layer) =>
layer.id === layerId ? { ...layer, assetKind } : layer,
),
);
setMetadataLayer((currentLayer) =>
currentLayer?.id === layerId ? nextLayer : currentLayer,
);
setImageContextMenu(null);
setContextMenu(null);
},
[captureCanvasHistory],
);
useImageCanvasKeyboardShortcuts({
generateDialogRef,
@@ -970,6 +995,7 @@ export function ImageCanvasEditorView() {
setMetadataLayer(layer);
selectSingleLayer(layer.id);
},
onUpdateLayerAssetKind: updateLayerAssetKind,
onGenerationFramePointerDown: handleGenerationFramePointerDown,
onActivateGenerationDialog: activateCanvasGenerationDialog,
onCropExpandHandlePointerDown:
@@ -274,7 +274,7 @@ describe('ImageCanvasGenerationComposerView', () => {
panel.querySelector('.image-canvas-editor__generation-composer-footer'),
).toBeTruthy();
expect(within(panel).getByRole('button', { name: '生成' }).textContent).toBe(
'生成12泥点',
'生成20泥点',
);
});
@@ -485,6 +485,9 @@ describe('ImageCanvasGenerationComposerView', () => {
expect(screen.getByRole('menu', { name: '视频模型选项' })).toBeTruthy();
expect(screen.getByLabelText('当前视频模型').textContent).toBe('kling3.0');
expect(screen.getByRole('button', { name: '模型 Kling 3.0' })).toBeTruthy();
expect(screen.getByRole('button', { name: '生成视频' }).textContent).toBe(
'生成150泥点',
);
});
it('生成游戏音效面板使用 Vidu duration 参数且不再显示类型和 BPM', () => {
@@ -517,8 +520,25 @@ describe('ImageCanvasGenerationComposerView', () => {
render(<AudioHarness />);
const panel = screen.getByRole('dialog', { name: '生成游戏音效' });
const footer = panel.querySelector(
'.image-canvas-editor__generation-composer-footer',
);
expect(footer).toBeTruthy();
expect(footer?.children[0]?.className).toContain(
'image-canvas-editor__option-popover-anchor--dimensions',
);
expect(footer?.children[1]?.className).toContain(
'image-canvas-editor__option-popover-anchor--model',
);
expect(footer?.children[2]?.className).toContain(
'image-canvas-editor__generation-submit',
);
expect(within(panel).getByRole('textbox', { name: 'prompt' })).toBeTruthy();
expect(screen.getByLabelText('当前音效模型').textContent).toBe('audio1.0');
const soundModelButton = within(panel).getByRole('button', {
name: '音效模型 Vidu',
});
expect((soundModelButton as HTMLButtonElement).disabled).toBe(true);
expect(within(panel).getByText('Vidu')).toBeTruthy();
expect(within(panel).queryByText(/Suno/u)).toBeNull();
expect(
@@ -570,6 +590,23 @@ describe('ImageCanvasGenerationComposerView', () => {
expect(
within(panel).getByRole('textbox', { name: 'gpt_description_prompt' }),
).toBeTruthy();
const footer = panel.querySelector(
'.image-canvas-editor__generation-composer-footer',
);
expect(footer).toBeTruthy();
expect(footer?.children[0]?.className).toContain(
'image-canvas-editor__option-popover-anchor--model',
);
expect(footer?.children[1]?.className).toContain(
'image-canvas-editor__generation-submit',
);
const backgroundMusicModelButton = within(panel).getByRole('button', {
name: '背景音乐模型 Suno',
});
expect((backgroundMusicModelButton as HTMLButtonElement).disabled).toBe(
true,
);
expect(within(panel).getByText('Suno')).toBeTruthy();
expect(within(panel).queryByText('make_instrumental')).toBeNull();
expect(within(panel).getByRole('button', { name: '生成游戏背景音乐' }).textContent).toBe(
'生成5泥点',
@@ -670,6 +670,10 @@ function ImageCanvasAudioGenerationComposerView({
const soundDuration = normalizeSoundDuration(dialog.soundDurationSeconds);
const soundOptionLabel = `${soundDuration}秒`;
const dialogLabel = isSoundEffect ? '生成游戏音效' : '生成游戏背景音乐';
const fixedModelLabel = isSoundEffect ? currentSoundModel.label : 'Suno';
const fixedModelAriaLabel = isSoundEffect
? `音效模型 ${fixedModelLabel}`
: `背景音乐模型 ${fixedModelLabel}`;
const cost = isSoundEffect
? calculateEditorSoundEffectPrice(soundModel)
: calculateEditorBackgroundMusicPrice(DEFAULT_BACKGROUND_MUSIC_MODEL);
@@ -737,65 +741,65 @@ function ImageCanvasAudioGenerationComposerView({
) : null}
<div className="image-canvas-editor__generation-composer-footer">
{isSoundEffect ? (
<>
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--dimensions">
<PlatformInlineOptionButton
ref={soundModelButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--model"
aria-label={`音效模型 ${currentSoundModel.label}`}
ref={soundOptionsButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--dimensions"
aria-label={`音效时长 ${soundOptionLabel}`}
aria-expanded={isSoundOptionsOpen}
disabled={isGenerating}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() => setIsSoundOptionsOpen((isOpen) => !isOpen)}
>
<span className="image-canvas-editor__model-trigger-label">
<span
className="image-canvas-editor__model-icon"
aria-hidden="true"
>
<Music />
</span>
<span>{currentSoundModel.label}</span>
</span>
{soundOptionLabel}
</PlatformInlineOptionButton>
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--dimensions">
<PlatformInlineOptionButton
ref={soundOptionsButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--dimensions"
aria-label={`音效时长 ${soundOptionLabel}`}
aria-expanded={isSoundOptionsOpen}
disabled={isGenerating}
trailingIcon={<ChevronDown className="h-3 w-3" />}
onClick={() => setIsSoundOptionsOpen((isOpen) => !isOpen)}
>
{soundOptionLabel}
</PlatformInlineOptionButton>
{isSoundOptionsOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__option-popover image-canvas-editor__option-popover--audio-options image-canvas-editor__portal-menu"
label="音效时长选项"
placement="top-start"
style={buildPortalMenuStyle(
soundOptionsButtonRef.current,
'above',
)}
>
<div className="image-canvas-editor__option-popover-items image-canvas-editor__audio-type-options">
{SOUND_EFFECT_DURATION_OPTIONS.map((option) => (
<VideoOptionChoice
key={option}
selected={soundDuration === option}
disabled={isGenerating}
ariaLabel={`时长 ${option}秒`}
onClick={() => updateSoundDuration(option)}
>
{option}秒
</VideoOptionChoice>
))}
</div>
</PlatformFloatingMenu>,
)
: null}
</div>
</>
{isSoundOptionsOpen
? renderEditorPortal(
<PlatformFloatingMenu
className="image-canvas-editor__option-popover image-canvas-editor__option-popover--audio-options image-canvas-editor__portal-menu"
label="音效时长选项"
placement="top-start"
style={buildPortalMenuStyle(
soundOptionsButtonRef.current,
'above',
)}
>
<div className="image-canvas-editor__option-popover-items image-canvas-editor__audio-type-options">
{SOUND_EFFECT_DURATION_OPTIONS.map((option) => (
<VideoOptionChoice
key={option}
selected={soundDuration === option}
disabled={isGenerating}
ariaLabel={`时长 ${option}秒`}
onClick={() => updateSoundDuration(option)}
>
{option}秒
</VideoOptionChoice>
))}
</div>
</PlatformFloatingMenu>,
)
: null}
</div>
) : null}
<div className="image-canvas-editor__option-popover-anchor image-canvas-editor__option-popover-anchor--model image-canvas-editor__readonly-generation-option">
<PlatformInlineOptionButton
ref={soundModelButtonRef}
className="image-canvas-editor__option-cluster image-canvas-editor__option-cluster--model"
aria-label={fixedModelAriaLabel}
disabled
>
<span className="image-canvas-editor__model-trigger-label">
<span
className="image-canvas-editor__model-icon"
aria-hidden="true"
>
<Music />
</span>
<span>{fixedModelLabel}</span>
</span>
</PlatformInlineOptionButton>
</div>
<PlatformActionButton
type="submit"
tone="secondary"
@@ -7,6 +7,7 @@ import { describe, expect, it, vi } from 'vitest';
import type { GenerateDialogState } from './ImageCanvasEditorTypes';
import { ImageCanvasGenerationImageOptionsView } from './ImageCanvasGenerationImageOptionsView';
import {
calculateEditorImageGenerationPrice,
IMAGE_MODEL_GPT_IMAGE_2,
IMAGE_MODEL_NANOBANANA2,
} from './ImageCanvasGenerationModel';
@@ -29,7 +30,7 @@ function ImageOptionsHarness({
setGenerateDialog={setDialog}
includeDimensions
onRememberImageModel={onRememberImageModel}
cost={12}
cost={calculateEditorImageGenerationPrice({ model: dialog.imageModel })}
submitLabel="生成"
submitAriaLabel="生成角色形象"
/>
@@ -160,6 +161,9 @@ describe('ImageCanvasGenerationImageOptionsView', () => {
expect(
screen.getByRole('button', { name: '生成图片模型 gpt-image-2' }),
).toBeTruthy();
expect(screen.getByRole('button', { name: '生成角色形象' }).textContent).toBe(
'生成20泥点',
);
});
it('renders option menus and shows mud point text in the submit button', () => {
@@ -87,6 +87,7 @@ describe('ImageCanvasGenerationModel', () => {
expect(calculateEditorImageModelPrice(DEFAULT_IMAGE_MODEL)).toBe(
EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG[DEFAULT_IMAGE_MODEL],
);
expect(calculateEditorImageModelPrice(IMAGE_MODEL_GPT_IMAGE_2)).toBe(20);
expect(
calculateEditorImageGenerationPrice({
kind: 'character',
@@ -111,8 +112,11 @@ describe('ImageCanvasGenerationModel', () => {
expect(calculateEditorVideoPrice(VIDEO_MODEL_KLING_3, '480p', 4)).toBe(
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_KLING_3]['480p'] * 4,
);
expect(calculateEditorVideoPrice(VIDEO_MODEL_KLING_3, '720p', 5)).toBe(
150,
);
expect(calculateEditorVideoPrice(VIDEO_MODEL_KLING_3_OMNI, '720p', 5)).toBe(
EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG[VIDEO_MODEL_KLING_3_OMNI]['720p'] * 5,
200,
);
expect(calculateCharacterAnimationPrice(CHARACTER_ANIMATION_MODEL, '720p', 6))
.toBe(
@@ -83,7 +83,8 @@ const IMAGE_MODEL_NANOBANANA_ALIASES = new Set([
export const SPEC_GENERATION_MODEL = IMAGE_MODEL_GPT_IMAGE_2;
export const EDITOR_IMAGE_MODEL_MUD_POINT_CONFIG = {
[IMAGE_MODEL_NANOBANANA2]: EDITOR_GENERATION_MUD_POINT_CONFIG.image,
[IMAGE_MODEL_GPT_IMAGE_2]: EDITOR_GENERATION_MUD_POINT_CONFIG.image,
// 中文注释:gpt-image-2 作为更高成本图片模型独立计费,避免切换模型后按钮价格不变。
[IMAGE_MODEL_GPT_IMAGE_2]: 20,
} as const;
export const EDITOR_SPEC_MODEL_MUD_POINT_CONFIG = {
[IMAGE_MODEL_GPT_IMAGE_2]: EDITOR_GENERATION_MUD_POINT_CONFIG.spec,
@@ -284,11 +285,23 @@ const EDITOR_VIDEO_RESOLUTION_RATE_CONFIG = {
'480p': EDITOR_GENERATION_MUD_POINT_CONFIG.videoPerSecond['480p'],
'720p': EDITOR_GENERATION_MUD_POINT_CONFIG.videoPerSecond['720p'],
} as const;
const EDITOR_VIDEO_STANDARD_RESOLUTION_RATE_CONFIG = {
'480p': 12,
'720p': 24,
} as const;
const EDITOR_VIDEO_KLING_RESOLUTION_RATE_CONFIG = {
'480p': 15,
'720p': 30,
} as const;
const EDITOR_VIDEO_KLING_OMNI_RESOLUTION_RATE_CONFIG = {
'480p': 20,
'720p': 40,
} as const;
export const EDITOR_VIDEO_MODEL_MUD_POINT_CONFIG = {
[VIDEO_MODEL_SEEDANCE_2_FAST]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG,
[VIDEO_MODEL_SEEDANCE_2]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG,
[VIDEO_MODEL_KLING_3]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG,
[VIDEO_MODEL_KLING_3_OMNI]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG,
[VIDEO_MODEL_SEEDANCE_2]: EDITOR_VIDEO_STANDARD_RESOLUTION_RATE_CONFIG,
[VIDEO_MODEL_KLING_3]: EDITOR_VIDEO_KLING_RESOLUTION_RATE_CONFIG,
[VIDEO_MODEL_KLING_3_OMNI]: EDITOR_VIDEO_KLING_OMNI_RESOLUTION_RATE_CONFIG,
// 中文注释:前端不展示 Veo 入口,但类型与后端兼容层仍可接收旧布局回放,定价表必须同步覆盖。
[VIDEO_MODEL_VEO_3_1]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG,
[VIDEO_MODEL_VEO_3_1_FAST]: EDITOR_VIDEO_RESOLUTION_RATE_CONFIG,
@@ -186,6 +186,9 @@ describe('ImageCanvasIconSpritesheetComposerView', () => {
expect(rememberImageModel).toHaveBeenCalledWith('gpt-image-2');
expect(screen.getByLabelText('当前模型').textContent).toBe('gpt-image-2');
expect(screen.getByLabelText('当前尺寸').textContent).toBe('1K');
expect(screen.getByRole('button', { name: '生成' }).textContent).toBe(
'生成20泥点',
);
});
it('disables generation controls and renders failure state', () => {
@@ -151,7 +151,7 @@ describe('ImageCanvasQuickEditPanelView', () => {
);
expect(screen.getByRole('button', { name: '生成' }).textContent).toBe(
'生成12泥点',
'生成20泥点',
);
});
@@ -143,6 +143,11 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
);
const submitButton = screen.getByRole('button', { name: '生成UI设计图' });
expect(submitButton.textContent).toBe('生成12泥点');
fireEvent.click(screen.getByRole('button', { name: '生成图片模型 nanobanana2' }));
fireEvent.click(screen.getByRole('button', { name: 'gpt-image-2' }));
expect(screen.getByRole('button', { name: '生成UI设计图' }).textContent).toBe(
'生成20泥点',
);
});
it('renders character spec fields and forwards updates', () => {
@@ -11,6 +11,7 @@ import { ImageCanvasBottomToolbarView } from './ImageCanvasBottomToolbarView';
import { ImageCanvasContextMenusView } from './ImageCanvasContextMenusView';
import type {
CanvasClipboard,
CanvasAssetKind,
CanvasContextMenuState,
CanvasGenerationDialogState,
CanvasLayer,
@@ -88,6 +89,10 @@ export type ImageCanvasStageViewProps = {
onLayerMouseEnter: (layerId: string) => void;
onLayerMouseLeave: (layerId: string) => void;
onOpenLayerMetadata: (layer: CanvasLayer) => void;
onUpdateLayerAssetKind: (
layerId: string,
assetKind: CanvasAssetKind | null,
) => void;
onGenerationFramePointerDown: (
event: ReactPointerEvent<HTMLDivElement>,
dialog: CanvasGenerationDialogState,
@@ -189,6 +194,7 @@ export function ImageCanvasStageView({
onLayerMouseEnter,
onLayerMouseLeave,
onOpenLayerMetadata,
onUpdateLayerAssetKind,
onGenerationFramePointerDown,
onActivateGenerationDialog,
onCropExpandHandlePointerDown,
@@ -271,6 +277,7 @@ export function ImageCanvasStageView({
onLayerMouseEnter={onLayerMouseEnter}
onLayerMouseLeave={onLayerMouseLeave}
onOpenLayerMetadata={onOpenLayerMetadata}
onUpdateLayerAssetKind={onUpdateLayerAssetKind}
onGenerationFramePointerDown={onGenerationFramePointerDown}
onActivateGenerationDialog={onActivateGenerationDialog}
onCropExpandHandlePointerDown={onCropExpandHandlePointerDown}
@@ -69,7 +69,6 @@ function renderWorldView(
onLayerMouseEnter: vi.fn(),
onLayerMouseLeave: vi.fn(),
onOpenLayerMetadata: vi.fn(),
onUpdateLayerAssetKind: vi.fn(),
onGenerationFramePointerDown: vi.fn(),
onActivateGenerationDialog: vi.fn(),
onCropExpandHandlePointerDown: vi.fn(),
@@ -146,27 +145,27 @@ describe('ImageCanvasWorldView', () => {
expect(screen.getByText('角色')).toBeTruthy();
});
it('shows unknown tag for untagged layers and lets users choose a tag', () => {
it('shows kind tag for tagged layers and omits the tag for untagged layers', () => {
const layer = createLayer({ assetKind: null });
const { props } = renderWorldView({ layers: [layer] });
const layerButton = screen.getByRole('button', { name: '选择角色主图' });
const unknownTag = within(layerButton).getByRole('button', {
name: '素材标签:未知',
renderWorldView({
layers: [
layer,
createLayer({
id: 'layer-character',
resourceId: 'resource-character',
title: '角色标签图层',
assetKind: 'character',
}),
],
});
expect(unknownTag.textContent).toBe('未知');
expect(unknownTag.style.getPropertyValue('--image-canvas-editor-inverse-scale')).toBe(
`${1 / props.viewport.scale}`,
);
const layerButton = screen.getByRole('button', { name: '选择角色主图' });
const characterButton = screen.getByRole('button', {
name: '选择角色标签图层',
});
fireEvent.click(unknownTag);
const menu = screen.getByRole('menu', { name: '选择素材标签' });
fireEvent.click(within(menu).getByRole('menuitem', { name: '角色' }));
expect(props.onUpdateLayerAssetKind).toHaveBeenCalledWith(layer.id, 'character');
expect(props.onLayerClick).not.toHaveBeenCalled();
expect(within(layerButton).queryByText('未知')).toBeNull();
expect(within(characterButton).getByText('角色')).toBeTruthy();
});
it('renders snap guides, marquee and floating generation status', () => {
@@ -642,14 +641,37 @@ describe('ImageCanvasWorldView', () => {
const layerButton = screen.getByRole('button', { name: '选择游戏音效' });
const audio = within(layerButton).getByLabelText('画布音频:游戏音效');
expect(audio.tagName.toLowerCase()).toBe('audio');
expect(audio.hasAttribute('controls')).toBe(false);
expect(within(layerButton).queryByAltText('画布图片:游戏音效')).toBeNull();
expect(within(layerButton).getByText('音效')).toBeTruthy();
expect(within(layerButton).queryByText(/时长/u)).toBeNull();
expect(
within(layerButton).getAllByRole('button', { name: /播放|暂停/u }),
).toHaveLength(1);
expect(
within(layerButton).getByRole('button', { name: '播放游戏音效' })
.querySelector('svg')
?.getAttribute('class'),
).toContain('lucide-volume-2');
expect(
layerButton
.querySelector('.image-canvas-editor__audio-card-controls')
?.className.includes(
'image-canvas-editor__audio-card-controls--integrated',
),
).toBe(true);
expect(
layerButton
.querySelector('.image-canvas-editor__audio-card')
?.className.includes(
'image-canvas-editor__audio-card--controls-visible',
),
).toBe(false);
expect(
within(layerButton).getByLabelText('调整游戏音效播放进度'),
).toBeTruthy();
expect(within(layerButton).getByLabelText('静音游戏音效')).toBeTruthy();
expect(within(layerButton).getByLabelText('调整游戏音效音量')).toBeTruthy();
expect(
within(layerButton).queryByText('420 x 120 px'),
).toBeNull();
@@ -696,6 +718,13 @@ describe('ImageCanvasWorldView', () => {
const hoveredLayerButton = screen.getByRole('button', {
name: '选择游戏背景音乐',
});
expect(
hoveredLayerButton
.querySelector('.image-canvas-editor__audio-card')
?.className.includes(
'image-canvas-editor__audio-card--controls-visible',
),
).toBe(true);
expect(
within(hoveredLayerButton)
.getByRole('button', { name: '播放游戏背景音乐' })
File diff suppressed because it is too large Load Diff
@@ -605,7 +605,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-icons',
priceMudPoints: 12,
priceMudPoints: 20,
iconImageSrcs: [
createIconResult('返回按钮', 'data:image/png;base64,back'),
createIconResult('设置按钮', 'data:image/png;base64,setting'),
@@ -647,7 +647,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
referenceImageSrc: 'data:image/png;base64,spec',
iconDescriptions: ['返回按钮', '设置按钮'],
model: 'gpt-image-2',
priceMudPoints: 12,
priceMudPoints: 20,
}),
);
});
@@ -683,7 +683,7 @@ describe('useImageCanvasGenerationSubmissionWorkflow', () => {
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'task-ui-assets',
priceMudPoints: 12,
priceMudPoints: 20,
iconImageSrcs: [
createIconResult('素材 1', 'data:image/png;base64,asset-1'),
],
@@ -0,0 +1,305 @@
import { KeyRound, ShieldCheck, Trash2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type {
ExternalApiKeyCreateResponse,
ExternalApiKeyProfile,
} from '../../../packages/shared/src/contracts/runtime';
import {
createRpgProfileExternalApiKey,
listRpgProfileExternalApiKeys,
revokeRpgProfileExternalApiKey,
} from '../../services/rpg-entry/rpgProfileClient';
import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformProfileContentRow } from '../common/PlatformProfileContentRow';
import { PlatformProfileSkeletonList } from '../common/PlatformProfileSkeletonList';
import { PlatformProfileSummaryHeader } from '../common/PlatformProfileSummaryHeader';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { useCopyFeedback } from '../common/useCopyFeedback';
import { formatPlatformWorldTime } from '../rpg-entry/rpgEntryWorldPresentation';
import { PlatformProfileSecondaryModalShell } from './PlatformProfileModalShell';
type PlatformProfileApiKeysModalProps = {
onClose: () => void;
};
function isActiveExternalApiKey(key: ExternalApiKeyProfile) {
return !key.revokedAt;
}
function buildApiKeyTimeLabel(value: string | null) {
return value ? formatPlatformWorldTime(value) : '尚未使用';
}
function buildApiKeyScopeLabel(scopes: string[]) {
return scopes.length > 0 ? scopes.join(' / ') : '默认权限';
}
/**
* 开发者 API Key 管理弹窗。
* 明文 Key 只保留在本次创建后的组件状态里,关闭弹窗后即丢弃。
*/
export function PlatformProfileApiKeysModal({
onClose,
}: PlatformProfileApiKeysModalProps) {
const [keys, setKeys] = useState<ExternalApiKeyProfile[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [nameInput, setNameInput] = useState('外部 API Key');
const [createdKey, setCreatedKey] =
useState<ExternalApiKeyCreateResponse | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [revokingKeyId, setRevokingKeyId] = useState<string | null>(null);
const { copyState, copyText } = useCopyFeedback();
const activeKeys = useMemo(
() => keys.filter(isActiveExternalApiKey),
[keys],
);
const shouldShowBlockingError = Boolean(error && !isLoading && keys.length === 0);
const loadKeys = useCallback(() => {
setIsLoading(true);
setError(null);
void listRpgProfileExternalApiKeys()
.then((response) => {
setKeys(response.keys);
})
.catch((loadError: unknown) => {
setError(loadError instanceof Error ? loadError.message : '读取 API Key 失败');
})
.finally(() => setIsLoading(false));
}, []);
useEffect(() => {
loadKeys();
}, [loadKeys]);
const createKey = useCallback(() => {
if (isCreating) {
return;
}
setIsCreating(true);
setError(null);
setCreatedKey(null);
void createRpgProfileExternalApiKey(nameInput)
.then((response) => {
setCreatedKey(response);
setKeys((current) => [
response.key,
...current.filter((key) => key.keyId !== response.key.keyId),
]);
setNameInput('外部 API Key');
})
.catch((createError: unknown) => {
setError(
createError instanceof Error ? createError.message : '创建 API Key 失败',
);
})
.finally(() => setIsCreating(false));
}, [isCreating, nameInput]);
const revokeKey = useCallback((keyId: string) => {
setRevokingKeyId(keyId);
setError(null);
void revokeRpgProfileExternalApiKey(keyId)
.then((response) => {
setKeys((current) =>
current.map((key) =>
key.keyId === response.key.keyId ? response.key : key,
),
);
setCreatedKey((current) =>
current?.key.keyId === response.key.keyId ? null : current,
);
})
.catch((revokeError: unknown) => {
setError(
revokeError instanceof Error ? revokeError.message : '撤销 API Key 失败',
);
})
.finally(() => setRevokingKeyId(null));
}, []);
return (
<PlatformProfileSecondaryModalShell
title="开发者 API Key"
onClose={onClose}
closeLabel="关闭开发者 API Key"
closeButtonClassName="bg-white/80"
panelClassName="relative !max-h-[min(92vh,44rem)] !max-w-[34rem] bg-[linear-gradient(180deg,#f8fbff_0%,#ffffff_42%,#f8fafc_100%)] text-zinc-950 shadow-2xl !rounded-[1.35rem] sm:!rounded-[1.35rem]"
contentClassName="relative max-h-[min(92vh,44rem)] overflow-y-auto px-4 pb-5 pt-4 sm:px-5"
>
<PlatformProfileSummaryHeader
kicker="OPENAPI"
title="开发者 API Key"
badge={
<span className="inline-flex items-center gap-1.5 rounded-full bg-white/76 px-3 py-1 text-xs font-black text-sky-700 shadow-sm ring-1 ring-sky-100">
<ShieldCheck className="h-3.5 w-3.5" />
{activeKeys.length} 个可用
</span>
}
/>
<div className="mt-5 rounded-[1.1rem] border border-sky-100 bg-white/82 p-3 shadow-sm">
<div className="flex items-end gap-2">
<label className="min-w-0 flex-1">
<span className="mb-1.5 block text-xs font-black text-zinc-500">
名称
</span>
<PlatformTextField
value={nameInput}
density="compact"
tone="sky"
onChange={(event) => setNameInput(event.target.value)}
/>
</label>
<PlatformActionButton
surface="profile"
size="sm"
className="shrink-0"
disabled={isCreating}
onClick={createKey}
>
<KeyRound className="h-4 w-4" />
{isCreating ? '创建中' : '创建'}
</PlatformActionButton>
</div>
</div>
{createdKey ? (
<PlatformStatusMessage
tone="success"
surface="profile"
size="sm"
className="mt-4 rounded-2xl font-semibold"
>
<div className="text-xs font-black text-emerald-700">新 Key</div>
<div className="mt-2 break-all rounded-xl bg-white/80 px-3 py-2 font-mono text-[12px] text-zinc-800">
{createdKey.apiKey}
</div>
<CopyFeedbackButton
state={copyState}
idleLabel="复制 Key"
actionSurface="profile"
actionTone="primary"
actionSize="xs"
className="mt-3"
onClick={() => void copyText(createdKey.apiKey)}
/>
</PlatformStatusMessage>
) : null}
<PlatformAsyncStatePanel
errorState={
shouldShowBlockingError ? (
<PlatformStatusMessage
tone="error"
surface="profile"
size="xs"
className="mt-4 rounded-2xl font-semibold"
>
<div>{error}</div>
<PlatformActionButton
surface="profile"
size="xs"
className="mt-3"
onClick={loadKeys}
>
重新加载
</PlatformActionButton>
</PlatformStatusMessage>
) : null
}
isLoading={isLoading}
loadingState={
<PlatformProfileSkeletonList
count={3}
containerClassName="mt-5 space-y-3"
itemClassName="h-20 rounded-2xl bg-white/70"
/>
}
isEmpty={keys.length === 0}
emptyState={
<PlatformEmptyState
surface="subpanel"
size="inline"
className="mt-5 py-8"
>
暂无 API Key
</PlatformEmptyState>
}
>
{error ? (
<PlatformStatusMessage
tone="error"
surface="profile"
size="xs"
className="mb-3 rounded-2xl font-semibold"
>
{error}
</PlatformStatusMessage>
) : null}
<div className="mt-5 space-y-2.5">
{keys.map((key) => {
const isActive = isActiveExternalApiKey(key);
const isRevoking = revokingKeyId === key.keyId;
return (
<PlatformProfileContentRow
key={key.keyId}
surface="flat"
radius="xs"
padding="none"
className="flex items-start justify-between gap-3 px-3 py-3 shadow-sm"
>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm font-black text-zinc-900">
{key.name}
</span>
<span
className={`shrink-0 rounded-full px-2 py-0.5 text-[10px] font-black ${
isActive
? 'bg-emerald-50 text-emerald-700'
: 'bg-zinc-100 text-zinc-500'
}`}
>
{isActive ? '可用' : '已撤销'}
</span>
</div>
<div className="mt-1 font-mono text-xs font-semibold text-zinc-500">
{key.keyPrefix}...
</div>
<div className="mt-2 text-[11px] font-semibold text-zinc-400">
{buildApiKeyScopeLabel(key.scopes)}
</div>
<div className="mt-1 text-[11px] font-semibold text-zinc-400">
创建 {buildApiKeyTimeLabel(key.createdAt)} · 最近使用{' '}
{buildApiKeyTimeLabel(key.lastUsedAt)}
</div>
</div>
{isActive ? (
<PlatformActionButton
surface="profile"
tone="danger"
size="xs"
className="shrink-0"
disabled={isRevoking}
onClick={() => revokeKey(key.keyId)}
>
<Trash2 className="h-3.5 w-3.5" />
{isRevoking ? '撤销中' : '撤销'}
</PlatformActionButton>
) : null}
</PlatformProfileContentRow>
);
})}
</div>
</PlatformAsyncStatePanel>
</PlatformProfileSecondaryModalShell>
);
}
@@ -52,6 +52,9 @@ const {
mockGetRpgProfileRechargeCenter,
mockGetRpgProfileTasks,
mockGetRpgProfileWalletLedger,
mockListRpgProfileExternalApiKeys,
mockCreateRpgProfileExternalApiKey,
mockRevokeRpgProfileExternalApiKey,
mockRedeemRpgProfileReferralInviteCode,
mockWatchWechatRpgProfileRechargeOrder,
} = vi.hoisted(() => {
@@ -327,6 +330,46 @@ const {
},
],
})),
mockListRpgProfileExternalApiKeys: vi.fn(async () => ({
keys: [
{
keyId: 'api-key-1',
name: '默认 Key',
keyPrefix: 'tnr_sk_existing_1',
scopes: ['editor:project', 'editor:canvas', 'editor:image-generate'],
createdAt: '2026-06-19T08:00:00Z',
lastUsedAt: null,
revokedAt: null,
updatedAt: '2026-06-19T08:00:00Z',
},
],
})),
mockCreateRpgProfileExternalApiKey: vi.fn(async () => ({
apiKey: 'tnr_sk_created.secret',
key: {
keyId: 'api-key-created',
name: '测试 Key',
keyPrefix: 'tnr_sk_created',
scopes: ['editor:project', 'editor:canvas', 'editor:image-generate'],
createdAt: '2026-06-19T09:00:00Z',
lastUsedAt: null,
revokedAt: null,
updatedAt: '2026-06-19T09:00:00Z',
},
})),
mockRevokeRpgProfileExternalApiKey: vi.fn(async (keyId: string) => ({
key: {
keyId,
name: keyId === 'api-key-created' ? '测试 Key' : '默认 Key',
keyPrefix:
keyId === 'api-key-created' ? 'tnr_sk_created' : 'tnr_sk_existing_1',
scopes: ['editor:project', 'editor:canvas', 'editor:image-generate'],
createdAt: '2026-06-19T08:00:00Z',
lastUsedAt: null,
revokedAt: '2026-06-19T10:00:00Z',
updatedAt: '2026-06-19T10:00:00Z',
},
})),
mockWatchWechatRpgProfileRechargeOrder: vi.fn(async () => null),
};
});
@@ -406,6 +449,9 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
getRpgProfileWalletLedger: mockGetRpgProfileWalletLedger,
claimRpgProfileTaskReward: mockClaimRpgProfileTaskReward,
redeemRpgProfileReferralInviteCode: mockRedeemRpgProfileReferralInviteCode,
listRpgProfileExternalApiKeys: mockListRpgProfileExternalApiKeys,
createRpgProfileExternalApiKey: mockCreateRpgProfileExternalApiKey,
revokeRpgProfileExternalApiKey: mockRevokeRpgProfileExternalApiKey,
getRpgProfileRechargeCenter: mockGetRpgProfileRechargeCenter,
createRpgProfileRechargeOrder: mockCreateRpgProfileRechargeOrder,
confirmWechatRpgProfileRechargeOrder:
@@ -3112,7 +3158,10 @@ test('mobile profile page matches the reference layout sections', async () => {
).toBeNull();
expect(
settingsRegion.querySelectorAll('.platform-profile-settings-row'),
).toHaveLength(1);
).toHaveLength(2);
expect(
within(settingsRegion).getByRole('button', { name: /开发者 API Key/u }),
).toBeTruthy();
expect(
within(settingsRegion).queryByRole('button', { name: /存档/u }),
).toBeNull();
@@ -3575,6 +3624,9 @@ test('profile page shows legal entries and hides archive shortcuts', async () =>
expect(
within(settingsRegion).getByRole('button', { name: /通用设置/u }),
).toBeTruthy();
expect(
within(settingsRegion).getByRole('button', { name: /开发者 API Key/u }),
).toBeTruthy();
expect(
within(settingsRegion).queryByRole('button', { name: /主题设置/u }),
).toBeNull();
@@ -3610,6 +3662,48 @@ test('profile page shows legal entries and hides archive shortcuts', async () =>
expect(await screen.findByRole('dialog', { name: '隐私政策' })).toBeTruthy();
});
test('profile developer api key entry opens management modal and supports create and revoke', async () => {
const user = userEvent.setup();
renderProfileView();
await user.click(screen.getByRole('button', { name: /开发者 API Key/u }));
const dialog = await screen.findByRole('dialog', {
name: '开发者 API Key',
});
expect(mockListRpgProfileExternalApiKeys).toHaveBeenCalledTimes(1);
expect(within(dialog).getByText('默认 Key')).toBeTruthy();
expect(within(dialog).getByText('tnr_sk_existing_1...')).toBeTruthy();
expect(within(dialog).queryByText('tnr_sk_created.secret')).toBeNull();
const nameInput = within(dialog).getByLabelText('名称');
await user.clear(nameInput);
await user.type(nameInput, '测试 Key');
await user.click(within(dialog).getByRole('button', { name: /创建/u }));
await waitFor(() => {
expect(mockCreateRpgProfileExternalApiKey).toHaveBeenCalledWith('测试 Key');
});
expect(await within(dialog).findByText('tnr_sk_created.secret')).toBeTruthy();
expect(within(dialog).getByText('测试 Key')).toBeTruthy();
const revokeButtons = within(dialog).getAllByRole('button', {
name: /撤销/u,
});
expect(revokeButtons.length).toBeGreaterThan(0);
const revokeButton = revokeButtons[0];
if (!revokeButton) {
throw new Error('撤销按钮不存在');
}
await user.click(revokeButton);
await waitFor(() => {
expect(mockRevokeRpgProfileExternalApiKey).toHaveBeenCalled();
});
expect(within(dialog).getByText('已撤销')).toBeTruthy();
});
test('shows a reachable login entry outside mobile recommend tab', async () => {
const user = userEvent.setup();
const openLoginModal = vi.fn();
@@ -12,6 +12,7 @@ import {
Gamepad2,
GitFork,
Heart,
KeyRound,
LogIn,
MessageCircle,
Palette,
@@ -127,6 +128,7 @@ import {
} from '../platform-entry/PlatformProfilePrimitives';
import { PlatformProfileModalShell } from '../platform-entry/PlatformProfileModalShell';
import { PlatformProfileGenerationQueueCard } from '../platform-entry/PlatformProfileGenerationQueueCard';
import { PlatformProfileApiKeysModal } from '../platform-entry/PlatformProfileApiKeysModal';
import { PlatformProfilePlayedWorksModal } from '../platform-entry/PlatformProfilePlayedWorksModal';
import { PlatformProfileQrScannerModal } from '../platform-entry/PlatformProfileQrScannerModal';
import { PlatformProfileRechargeModal } from '../platform-entry/PlatformProfileRechargeModal';
@@ -2651,6 +2653,7 @@ export function RpgEntryHomeView({
});
const [avatarError, setAvatarError] = useState<string | null>(null);
const [isSavingAvatar, setIsSavingAvatar] = useState(false);
const [isApiKeysModalOpen, setIsApiKeysModalOpen] = useState(false);
const currentUser = authUi?.user ?? null;
const isAuthenticated = Boolean(currentUser);
const {
@@ -4456,6 +4459,11 @@ export function RpgEntryHomeView({
icon={Settings}
onClick={() => authUi.openSettingsModal()}
/>
<ProfileSettingsRow
label="开发者 API Key"
icon={KeyRound}
onClick={() => setIsApiKeysModalOpen(true)}
/>
</section>
<ProfileLegalSection onOpenDocument={setActiveLegalDocumentId} />
@@ -4913,6 +4921,11 @@ export function RpgEntryHomeView({
}}
/>
) : null;
const apiKeysModal: ReactNode = isApiKeysModalOpen ? (
<PlatformProfileApiKeysModal
onClose={() => setIsApiKeysModalOpen(false)}
/>
) : null;
if (!isDesktopLayout) {
const isMobileRecommendTab = activeTab === 'home';
@@ -5021,6 +5034,7 @@ export function RpgEntryHomeView({
) : null}
{rewardCodeModal}
{rechargeModal}
{apiKeysModal}
{rechargePaymentResultModal}
{qrScannerModal}
{categoryFilterDialog}
@@ -5178,6 +5192,7 @@ export function RpgEntryHomeView({
</div>
{rewardCodeModal}
{rechargeModal}
{apiKeysModal}
{rechargePaymentResultModal}
{categoryFilterDialog}
{isTaskCenterOpen ? (
+105 -3
View File
@@ -4455,25 +4455,91 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
}
.image-canvas-editor__audio-card-title {
position: relative;
position: absolute;
left: 0.7rem;
top: 0.7rem;
z-index: 2;
overflow: hidden;
max-width: min(70%, calc(100% - 1.4rem));
color: #ffffff;
font-size: 0.74rem;
font-weight: 800;
line-height: 1.1;
text-overflow: ellipsis;
white-space: nowrap;
text-shadow: 0 1px 6px rgba(15, 23, 42, 0.55);
}
.image-canvas-editor__audio-card-controls {
position: relative;
z-index: 2;
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto auto minmax(2rem, 3.8rem);
align-items: center;
gap: 0.34rem;
min-height: 1.85rem;
padding: 0.18rem 0;
color: rgba(255, 255, 255, 0.92);
transform: translateY(calc(100% + 0.7rem));
opacity: 0;
pointer-events: none;
transition:
transform 180ms ease,
opacity 180ms ease;
}
.image-canvas-editor__audio-card--controls-visible
.image-canvas-editor__audio-card-controls {
transform: translateY(0);
opacity: 1;
pointer-events: auto;
}
.image-canvas-editor__audio-card audio {
width: 100%;
height: 1.85rem;
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.image-canvas-editor__audio-card-time {
min-width: 2.2rem;
font-size: 0.58rem;
font-weight: 800;
font-variant-numeric: tabular-nums;
line-height: 1;
text-align: center;
text-shadow: 0 1px 4px rgba(15, 23, 42, 0.42);
}
.image-canvas-editor__audio-card-progress,
.image-canvas-editor__audio-card-volume {
min-width: 0;
height: 0.35rem;
accent-color: #67e8f9;
cursor: pointer;
}
.image-canvas-editor__audio-card-progress:disabled {
cursor: default;
opacity: 0.45;
}
.image-canvas-editor__audio-card-volume-toggle {
display: grid;
width: 1.25rem;
height: 1.25rem;
place-items: center;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
color: #ffffff;
cursor: pointer;
}
.image-canvas-editor__audio-card-volume-toggle:hover {
background: rgba(255, 255, 255, 0.24);
}
.image-canvas-editor__layer--hovered,
@@ -4570,6 +4636,8 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
display: grid;
place-items: center;
color: #c7c7c7;
transform: scale(var(--image-canvas-editor-inverse-scale, 1));
transform-origin: center;
}
.image-canvas-editor__generation-frame--character {
@@ -4920,6 +4988,12 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
transform-origin: top right;
}
.image-canvas-editor__kind-badge--editable {
border: 1px solid rgba(255, 255, 255, 0.72);
cursor: pointer;
pointer-events: auto;
}
.image-canvas-editor__kind-badge--beside-info {
right: var(--image-canvas-editor-beside-kind-offset, 1.95rem);
}
@@ -4964,6 +5038,16 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
box-shadow: 0 8px 18px rgba(219, 39, 119, 0.24);
}
.image-canvas-editor__kind-badge--publication-material {
background: rgba(219, 39, 119, 0.92);
box-shadow: 0 8px 18px rgba(219, 39, 119, 0.24);
}
.image-canvas-editor__kind-badge--unknown {
background: rgba(71, 85, 105, 0.88);
box-shadow: 0 8px 18px rgba(71, 85, 105, 0.2);
}
.image-canvas-editor__kind-badge--video {
background: rgba(37, 99, 235, 0.92);
box-shadow: 0 8px 18px rgba(37, 99, 235, 0.24);
@@ -4975,6 +5059,24 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
box-shadow: 0 8px 18px rgba(79, 70, 229, 0.24);
}
.image-canvas-editor__kind-menu-anchor {
position: absolute;
width: 0;
height: 0;
transform: scale(var(--image-canvas-editor-inverse-scale, 1));
transform-origin: top right;
}
.image-canvas-editor__kind-menu {
max-height: 16rem;
overflow-y: auto;
}
.image-canvas-editor__kind-menu-item--active {
background: #eff6ff;
color: #1d4ed8;
}
.image-canvas-editor__size-badge {
position: absolute;
left: var(--image-canvas-editor-corner-offset, 0.35rem);
@@ -633,7 +633,7 @@ describe('editorProjectClient', () => {
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'icon-spritesheet-task-2',
priceMudPoints: 12,
priceMudPoints: 20,
});
await generateEditorIconSpritesheet({
@@ -642,7 +642,7 @@ describe('editorProjectClient', () => {
model: 'gpt-image-2',
aspectRatio: '1:1',
imageSize: '2K',
priceMudPoints: 12,
priceMudPoints: 20,
});
expect(requestJsonMock).toHaveBeenCalledWith(
@@ -656,7 +656,7 @@ describe('editorProjectClient', () => {
model: 'gpt-image-2',
aspectRatio: '1:1',
imageSize: '2K',
priceMudPoints: 12,
priceMudPoints: 20,
}),
}),
'生成图标素材失败',
@@ -685,7 +685,7 @@ describe('editorProjectClient', () => {
model: 'gpt-image-2',
provider: 'VectorEngine',
taskId: 'ui-asset-extraction-1',
priceMudPoints: 12,
priceMudPoints: 20,
});
const result = await extractEditorUiDesignAssets({

Some files were not shown because too many files have changed in this diff Show More