合并编辑器素材库分支
合入 codex/editor-asset-library 最新改动 保留音视频 OSS 素材库保存与自定义播放器交互 合并画布素材标签编辑与视频换签渲染逻辑 通过后端编译和角色抠图回归测试
This commit is contained in:
@@ -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
@@ -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
Reference in New Issue
Block a user