修复图片编辑器生图登录提示
调整编辑器 API 鉴权策略,允许通过 refresh cookie 静默补 access token 真实生图遇到未授权时显示登录提示,不再直接暴露 requestId 补充编辑器组件和客户端测试,并更新方案文档与跟踪记录
This commit is contained in:
@@ -63,3 +63,4 @@
|
|||||||
- 2026-06-12 生成工具修正:移除拼图素材的生成图 mock 元数据,使其作为普通素材显示;底部生成工具改为先打开生成图片对话框,再进入生成中状态并把生成结果加入画布,生成结果保留元数据与修改入口。
|
- 2026-06-12 生成工具修正:移除拼图素材的生成图 mock 元数据,使其作为普通素材显示;底部生成工具改为先打开生成图片对话框,再进入生成中状态并把生成结果加入画布,生成结果保留元数据与修改入口。
|
||||||
- 2026-06-13 真实生图修正:`/api/editor/images/generations` 和 `/api/editor/images/edits` 统一走 api-server VectorEngine `gpt-image-2` BFF;前端不再创建 mock 成功图,生成 / 修改失败会留在对话框内显示错误;生成图右上角 `{}` 元数据按钮可直接点击打开元数据窗口。
|
- 2026-06-13 真实生图修正:`/api/editor/images/generations` 和 `/api/editor/images/edits` 统一走 api-server VectorEngine `gpt-image-2` BFF;前端不再创建 mock 成功图,生成 / 修改失败会留在对话框内显示错误;生成图右上角 `{}` 元数据按钮可直接点击打开元数据窗口。
|
||||||
- 2026-06-13 素材库修正:素材栏按文件夹分组,文件夹支持折叠和新建;上传入口可定向到当前文件夹,上传素材进入素材库并支持删除,内置素材只保留添加和重命名。
|
- 2026-06-13 素材库修正:素材栏按文件夹分组,文件夹支持折叠和新建;上传入口可定向到当前文件夹,上传素材进入素材库并支持删除,内置素材只保留添加和重命名。
|
||||||
|
- 2026-06-13 生图鉴权修正:编辑器工程和真实生图请求不再使用禁止 refresh 的局部鉴权策略,可通过 refresh cookie 静默补 access token;真实生图遇到 401 / 403 时弹窗显示“请先登录后再生成图片”,不再暴露后端 requestId 主文案。
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
- 图片文件本体继续走 OSS,浏览器读取私有 generated 对象仍经 `/api/assets/read-url` 换签。
|
- 图片文件本体继续走 OSS,浏览器读取私有 generated 对象仍经 `/api/assets/read-url` 换签。
|
||||||
- 资源表只保存资源元数据;图层位置、尺寸、缩放、层级和选中所需 ID 保存在 `editor_project` 的布局 JSON。
|
- 资源表只保存资源元数据;图层位置、尺寸、缩放、层级和选中所需 ID 保存在 `editor_project` 的布局 JSON。
|
||||||
- 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。
|
- 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。
|
||||||
- 未登录用户可以使用本地演示态,但不触发工程自动保存。
|
- 未登录用户可以使用本地演示态,但不触发工程自动保存;真实图片生成 / 修改需要登录。编辑器 API 请求允许使用 refresh cookie 静默补 access token,但 401 / 403 只在编辑器局部提示登录,不清空整站登录态,也不把后端 requestId 直接作为生图弹窗主文案。
|
||||||
|
|
||||||
## 后端接口
|
## 后端接口
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
|
|||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { ApiClientError } from '../../services/apiClient';
|
||||||
import { ImageCanvasEditorView } from './ImageCanvasEditorView';
|
import { ImageCanvasEditorView } from './ImageCanvasEditorView';
|
||||||
|
|
||||||
const generateEditorImageMock = vi.hoisted(() => vi.fn());
|
const generateEditorImageMock = vi.hoisted(() => vi.fn());
|
||||||
@@ -341,6 +342,30 @@ describe('ImageCanvasEditorView', () => {
|
|||||||
expect(screen.queryByAltText(/画布图片:生成图片/)).toBeNull();
|
expect(screen.queryByAltText(/画布图片:生成图片/)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('asks the user to log in when real generation is unauthorized', async () => {
|
||||||
|
generateEditorImageMock.mockRejectedValueOnce(
|
||||||
|
new ApiClientError({
|
||||||
|
message: '未授权访问(requestId: web-login-required)',
|
||||||
|
status: 401,
|
||||||
|
code: 'UNAUTHORIZED',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
render(<ImageCanvasEditorView />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '生成工具' }));
|
||||||
|
fireEvent.change(screen.getByLabelText('生成提示词'), {
|
||||||
|
target: { value: '一张需要登录生成的图' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '生成' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('alert').textContent).toBe(
|
||||||
|
'请先登录后再生成图片',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(screen.queryByText(/requestId/u)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('switches tools and restores the previous tool after holding Space', async () => {
|
it('switches tools and restores the previous tool after holding Space', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<ImageCanvasEditorView />);
|
render(<ImageCanvasEditorView />);
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
loadOrCreateRecentEditorProject,
|
loadOrCreateRecentEditorProject,
|
||||||
saveEditorProjectLayout,
|
saveEditorProjectLayout,
|
||||||
} from '../../services/image-editor/editorProjectClient';
|
} from '../../services/image-editor/editorProjectClient';
|
||||||
|
import { ApiClientError } from '../../services/apiClient';
|
||||||
|
|
||||||
type EditorAsset = {
|
type EditorAsset = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -427,6 +428,19 @@ function getPointerId(event: ReactPointerEvent<HTMLElement>) {
|
|||||||
return Number.isFinite(nativeId) ? nativeId : -1;
|
return Number.isFinite(nativeId) ? nativeId : -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveImageGenerationErrorMessage(error: unknown) {
|
||||||
|
if (
|
||||||
|
error instanceof ApiClientError &&
|
||||||
|
(error.status === 401 || error.status === 403)
|
||||||
|
) {
|
||||||
|
return '请先登录后再生成图片';
|
||||||
|
}
|
||||||
|
|
||||||
|
return error instanceof Error && error.message.trim()
|
||||||
|
? error.message
|
||||||
|
: '生成图片失败';
|
||||||
|
}
|
||||||
|
|
||||||
export function ImageCanvasEditorView() {
|
export function ImageCanvasEditorView() {
|
||||||
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
|
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
|
||||||
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
@@ -1016,10 +1030,7 @@ export function ImageCanvasEditorView() {
|
|||||||
...dialog,
|
...dialog,
|
||||||
prompt: normalizedPrompt,
|
prompt: normalizedPrompt,
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
errorMessage:
|
errorMessage: resolveImageGenerationErrorMessage(error),
|
||||||
error instanceof Error && error.message.trim()
|
|
||||||
? error.message
|
|
||||||
: '生成图片失败',
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ describe('editorProjectClient', () => {
|
|||||||
'/api/editor/projects/recent',
|
'/api/editor/projects/recent',
|
||||||
{ method: 'GET' },
|
{ method: 'GET' },
|
||||||
'读取图片画布工程失败',
|
'读取图片画布工程失败',
|
||||||
expect.objectContaining({ authImpact: 'local' }),
|
expect.objectContaining({
|
||||||
|
clearAuthOnUnauthorized: false,
|
||||||
|
notifyAuthStateChange: false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -70,7 +73,10 @@ describe('editorProjectClient', () => {
|
|||||||
body: JSON.stringify({ title: '未命名画布' }),
|
body: JSON.stringify({ title: '未命名画布' }),
|
||||||
}),
|
}),
|
||||||
'创建图片画布工程失败',
|
'创建图片画布工程失败',
|
||||||
expect.objectContaining({ authImpact: 'local' }),
|
expect.objectContaining({
|
||||||
|
clearAuthOnUnauthorized: false,
|
||||||
|
notifyAuthStateChange: false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -102,7 +108,10 @@ describe('editorProjectClient', () => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
'保存图片画布工程失败',
|
'保存图片画布工程失败',
|
||||||
expect.objectContaining({ authImpact: 'local' }),
|
expect.objectContaining({
|
||||||
|
clearAuthOnUnauthorized: false,
|
||||||
|
notifyAuthStateChange: false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -149,7 +158,10 @@ describe('editorProjectClient', () => {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
'创建图片画布资源失败',
|
'创建图片画布资源失败',
|
||||||
expect.objectContaining({ authImpact: 'local' }),
|
expect.objectContaining({
|
||||||
|
clearAuthOnUnauthorized: false,
|
||||||
|
notifyAuthStateChange: false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -175,7 +187,10 @@ describe('editorProjectClient', () => {
|
|||||||
body: JSON.stringify({ title: '角色设定板' }),
|
body: JSON.stringify({ title: '角色设定板' }),
|
||||||
}),
|
}),
|
||||||
'创建图片画布工程失败',
|
'创建图片画布工程失败',
|
||||||
expect.objectContaining({ authImpact: 'local' }),
|
expect.objectContaining({
|
||||||
|
clearAuthOnUnauthorized: false,
|
||||||
|
notifyAuthStateChange: false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -204,7 +219,8 @@ describe('editorProjectClient', () => {
|
|||||||
}),
|
}),
|
||||||
'生成图片失败',
|
'生成图片失败',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
authImpact: 'local',
|
clearAuthOnUnauthorized: false,
|
||||||
|
notifyAuthStateChange: false,
|
||||||
timeoutMs: 1_200_000,
|
timeoutMs: 1_200_000,
|
||||||
retry: { maxRetries: 0 },
|
retry: { maxRetries: 0 },
|
||||||
}),
|
}),
|
||||||
@@ -242,7 +258,8 @@ describe('editorProjectClient', () => {
|
|||||||
}),
|
}),
|
||||||
'修改图片失败',
|
'修改图片失败',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
authImpact: 'local',
|
clearAuthOnUnauthorized: false,
|
||||||
|
notifyAuthStateChange: false,
|
||||||
timeoutMs: 1_200_000,
|
timeoutMs: 1_200_000,
|
||||||
retry: { maxRetries: 0 },
|
retry: { maxRetries: 0 },
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ const EDITOR_IMAGE_GENERATION_API = '/api/editor/images/generations';
|
|||||||
const EDITOR_IMAGE_EDIT_API = '/api/editor/images/edits';
|
const EDITOR_IMAGE_EDIT_API = '/api/editor/images/edits';
|
||||||
const DEFAULT_PROJECT_TITLE = '未命名画布';
|
const DEFAULT_PROJECT_TITLE = '未命名画布';
|
||||||
const EDITOR_PROJECT_REQUEST_OPTIONS = {
|
const EDITOR_PROJECT_REQUEST_OPTIONS = {
|
||||||
authImpact: 'local' as const,
|
clearAuthOnUnauthorized: false,
|
||||||
|
notifyAuthStateChange: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EditorCanvasViewport = {
|
export type EditorCanvasViewport = {
|
||||||
|
|||||||
Reference in New Issue
Block a user