feat: add edutainment drawing and visual package flows

This commit is contained in:
2026-05-14 14:17:10 +08:00
parent 10e8beea80
commit e444266e1e
109 changed files with 8788 additions and 996 deletions

View File

@@ -0,0 +1,102 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { requestJson } from '../apiClient';
import {
createBabyLoveDrawingMagicImage,
listLocalBabyLoveDrawings,
saveBabyLoveDrawing,
} from './babyDrawingClient';
vi.mock('../apiClient', () => ({
requestJson: vi.fn(),
}));
const requestJsonMock = vi.mocked(requestJson);
describe('babyDrawingClient', () => {
beforeEach(() => {
const store = new Map<string, string>();
vi.stubGlobal('window', {
localStorage: {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => {
store.set(key, value);
},
},
});
vi.stubGlobal('crypto', {
randomUUID: () => '11111111-2222-3333-4444-555555555555',
});
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-05-13T08:00:00.000Z'));
requestJsonMock.mockReset();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
test('saves original drawing only when magic image is absent', () => {
const response = saveBabyLoveDrawing({
originalImageSrc: 'data:image/png;base64,original',
magicImageSrc: null,
strokeTrace: [],
});
expect(response.record).toMatchObject({
templateName: '宝贝爱画',
originalImageSrc: 'data:image/png;base64,original',
magicImageSrc: null,
saveMode: 'original-only',
themeTags: ['寓教于乐', '宝贝爱画'],
});
expect(listLocalBabyLoveDrawings()).toHaveLength(1);
});
test('saves original and magic image together after magic generation', () => {
const response = saveBabyLoveDrawing({
originalImageSrc: 'data:image/png;base64,original',
magicImageSrc: 'data:image/png;base64,magic',
strokeTrace: [
{
strokeId: 'stroke-1',
tool: 'brush',
color: '#ef4444',
points: [{ x: 0.1, y: 0.2, t: 1 }],
},
],
});
expect(response.record.saveMode).toBe('original-and-magic');
expect(response.record.magicImageSrc).toBe('data:image/png;base64,magic');
expect(listLocalBabyLoveDrawings()[0]?.strokeTrace).toHaveLength(1);
});
test('creates magic image through backend image-2 proxy', async () => {
requestJsonMock.mockResolvedValueOnce({
magicImageSrc: 'data:image/png;base64,magic',
generationProvider: 'vector-engine-gpt-image-2',
prompt: '绘本风格',
});
const payload = {
originalImageSrc: 'data:image/png;base64,original',
strokeTrace: [],
};
const response = await createBabyLoveDrawingMagicImage(payload);
expect(response.magicImageSrc).toContain('magic');
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/creation/edutainment/baby-love-drawing/magic',
expect.objectContaining({
method: 'POST',
body: JSON.stringify(payload),
}),
'生成宝贝爱画魔法图片失败',
expect.objectContaining({
timeoutMs: 180000,
}),
);
});
});

View File

@@ -0,0 +1,128 @@
import type {
BabyLoveDrawingRecord,
CreateBabyLoveDrawingMagicRequest,
CreateBabyLoveDrawingMagicResponse,
SaveBabyLoveDrawingRequest,
SaveBabyLoveDrawingResponse,
} from '../../../packages/shared/src/contracts/edutainmentBabyDrawing';
import {
BABY_LOVE_DRAWING_EDUTAINMENT_TAG,
BABY_LOVE_DRAWING_TEMPLATE_ID,
BABY_LOVE_DRAWING_TEMPLATE_NAME,
normalizeBabyLoveDrawingTags,
} from '../../../packages/shared/src/contracts/edutainmentBabyDrawing';
import { type ApiRetryOptions, requestJson } from '../apiClient';
const STORAGE_KEY = 'genarrative.edutainmentBabyDrawing.localDrawings.v1';
const BABY_LOVE_DRAWING_MAGIC_API =
'/api/creation/edutainment/baby-love-drawing/magic';
const BABY_LOVE_DRAWING_MAGIC_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 800,
maxDelayMs: 2000,
retryUnsafeMethods: true,
};
type LocalDrawingStore = Record<string, BabyLoveDrawingRecord>;
function canUseLocalStorage() {
return (
typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
);
}
function readLocalDrawingStore(): LocalDrawingStore {
if (!canUseLocalStorage()) {
return {};
}
try {
const rawValue = window.localStorage.getItem(STORAGE_KEY);
if (!rawValue) {
return {};
}
const parsed = JSON.parse(rawValue) as LocalDrawingStore;
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
function writeLocalDrawingStore(store: LocalDrawingStore) {
if (!canUseLocalStorage()) {
return;
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
}
function createLocalId(prefix: string) {
const randomPart =
typeof crypto !== 'undefined' && 'randomUUID' in crypto
? crypto.randomUUID().replace(/-/gu, '')
: Math.random().toString(36).slice(2);
return `${prefix}-${Date.now().toString(36)}-${randomPart.slice(0, 12)}`;
}
function saveRecordToLocalStore(record: BabyLoveDrawingRecord) {
const store = readLocalDrawingStore();
store[record.drawingId] = record;
writeLocalDrawingStore(store);
}
export function listLocalBabyLoveDrawings() {
return Object.values(readLocalDrawingStore()).sort(
(left, right) =>
new Date(right.updatedAt).getTime() - new Date(left.updatedAt).getTime(),
);
}
export function saveBabyLoveDrawing(
payload: SaveBabyLoveDrawingRequest,
): SaveBabyLoveDrawingResponse {
const now = new Date().toISOString();
const magicImageSrc = payload.magicImageSrc?.trim() || null;
const record: BabyLoveDrawingRecord = {
drawingId: createLocalId('baby-love-drawing'),
templateId: BABY_LOVE_DRAWING_TEMPLATE_ID,
templateName: BABY_LOVE_DRAWING_TEMPLATE_NAME,
originalImageSrc: payload.originalImageSrc,
magicImageSrc,
strokeTrace: payload.strokeTrace,
saveMode: magicImageSrc ? 'original-and-magic' : 'original-only',
themeTags: normalizeBabyLoveDrawingTags([
BABY_LOVE_DRAWING_EDUTAINMENT_TAG,
BABY_LOVE_DRAWING_TEMPLATE_NAME,
]),
createdAt: now,
updatedAt: now,
};
saveRecordToLocalStore(record);
return { record };
}
export async function createBabyLoveDrawingMagicImage(
payload: CreateBabyLoveDrawingMagicRequest,
) {
return requestJson<CreateBabyLoveDrawingMagicResponse>(
BABY_LOVE_DRAWING_MAGIC_API,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
'生成宝贝爱画魔法图片失败',
{
retry: BABY_LOVE_DRAWING_MAGIC_RETRY,
timeoutMs: 180000,
},
);
}
export const babyDrawingClient = {
createMagicImage: createBabyLoveDrawingMagicImage,
listLocalDrawings: listLocalBabyLoveDrawings,
saveDrawing: saveBabyLoveDrawing,
};

View File

@@ -0,0 +1 @@
export * from './babyDrawingClient';