feat(ai-image): AI生图开发

This commit is contained in:
2026-09-27 22:40:52 +08:00
parent e670d06ca6
commit a29dd92676
105 changed files with 9943 additions and 361 deletions
+105
View File
@@ -6,6 +6,12 @@ import type {
ProductUploadImage,
ProductImportedImage,
ImportProductImageRequest,
AspectRatioDerivative,
WatermarkDerivative,
CreateMediaAiEditRequest,
MediaAiEdit,
ProductImageTask,
ProductImageDerivativeRecord,
} from '@atlas/contracts';
import { api } from '@/services/api';
@@ -15,6 +21,32 @@ export const WATERMARK_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
/** 异步操作查询,契约见 docs/api/conventions.md §5(GET /api/operations/{operation_id}) */
export const mediaApi = {
createAiEdit: (assetId: string, payload: CreateMediaAiEditRequest) =>
api.post<MediaAiEdit>(`/media-assets/${assetId}/ai-edits`, payload,
{ headers: { 'Idempotency-Key': crypto.randomUUID() } }),
readAiEdit: (id: string) => api.get<MediaAiEdit>(`/media-ai-edits/${id}`),
listAiEdits: (productId: string) => api.get<MediaAiEdit[]>(`/media-ai-edits?product_id=${encodeURIComponent(productId)}`),
listProductImageDerivatives: (productId: string) =>
api.get<ProductImageDerivativeRecord[]>(`/products/${encodeURIComponent(productId)}/image-derivatives`),
listProductImageTasks: (productId: string) =>
api.getList<ProductImageTask>(`/products/${encodeURIComponent(productId)}/image-tasks?limit=100`)
.then((response) => response.data),
cancelAiEdit: (id: string) => api.post<{ id: string; status: MediaAiEdit['status'] }>(
`/media-ai-edits/${id}/cancel`, {}, { headers: { 'Idempotency-Key': crypto.randomUUID() } }),
createThreeFour: (assetId: string, productId: string) =>
api.post<AspectRatioDerivative>(
`/media-assets/${assetId}/aspect-ratio-derivatives`,
{ preset: '3:4', fit: 'contain', product_id: productId },
{ headers: { 'Idempotency-Key': crypto.randomUUID() } }
),
readDerivative: (derivativeId: string) =>
api.get<AspectRatioDerivative>(`/media-derivatives/${derivativeId}`),
createWatermarkDerivative: (assetId: string, templateId: string, productId: string) =>
api.post<WatermarkDerivative>(`/media-assets/${assetId}/watermark-derivatives`,
{ watermark_template_id: templateId, product_id: productId },
{ headers: { 'Idempotency-Key': crypto.randomUUID() } }),
readWatermarkDerivative: (derivativeId: string) =>
api.get<WatermarkDerivative>(`/media-watermark-derivatives/${derivativeId}`),
listProductUploads: () => api.get<ProductUploadImage[]>('/media-assets/product-uploads'),
listProductImports: () => api.get<ProductImportedImage[]>('/media-assets/product-imports'),
importProductImage: (payload: ImportProductImageRequest) =>
@@ -40,8 +72,81 @@ export const mediaApi = {
{ purpose: 'preview' },
{ headers: { 'Idempotency-Key': crypto.randomUUID() } }
),
createDownloadUrl: (assetId: string) =>
api.post<MediaAccessUrl>(
`/media-assets/${assetId}/access-urls`,
{ purpose: 'download' },
{ headers: { 'Idempotency-Key': crypto.randomUUID() } }
),
};
/** 图片任务提交后通知商品页刷新右下角任务列表。 */
export function notifyProductImageTaskSubmitted(productId: string): void {
window.dispatchEvent(new CustomEvent('atlas:product-image-task-submitted', { detail: { productId } }));
}
/** 图片编辑错误码对应可操作的提示,不向前端透传供应商原始响应。 */
export function mediaAiEditFailureMessage(code: string | null): string {
if (code === 'AI_IMAGE_CONTENT_REJECTED')
return 'Pcore 未能完成这次生图;可能是输入或生成结果被审核拦截。可尝试其他图片或调整编辑要求。';
if (code === 'AI_IMAGE_PROVIDER_FAILED')
return 'Pcore 图片任务失败,请稍后重试。';
return '图片生成失败,请重试。';
}
const aiEditFailureStageLabels = {
task_setup: '任务初始化', input_preparation: '读取与准备原图',
provider_submission: '提交 Pcore 生图请求', provider_polling: '查询 Pcore 生图结果',
result_download: '获取生成图片', result_processing: 'Atlas 处理生成图片',
result_storage: 'Atlas 保存生成图片',
} as const;
export function mediaAiEditFailureDiagnosis(failure: {
failure_source: 'pcore' | 'atlas' | null;
failure_stage: keyof typeof aiEditFailureStageLabels | null;
failure_detail: string | null;
}): string {
if (!failure.failure_source && !failure.failure_stage) return '失败阶段未记录(历史任务)';
const source = failure.failure_source === 'pcore' ? 'Pcore 接口' : 'Atlas 自身处理';
const stage = failure.failure_stage ? aiEditFailureStageLabels[failure.failure_stage] : '未知步骤';
return `失败来源:${source} · 失败步骤:${stage}${failure.failure_detail ? ` · 详情:${failure.failure_detail}` : ''}`;
}
/** 下载地址由对象存储返回 attachment 响应头,跨域图片也会保存为文件。 */
export async function downloadMediaAsset(assetId: string): Promise<void> {
const access = await mediaApi.createDownloadUrl(assetId);
const link = document.createElement('a');
link.href = access.url;
link.rel = 'noopener noreferrer';
document.body.appendChild(link);
link.click();
link.remove();
}
/** 派生任务可能跨越多次 Worker 重试;页面只使用成功返回的正式素材 ID。 */
export async function createThreeFourImage(assetId: string, productId: string): Promise<AspectRatioDerivative> {
const created = await mediaApi.createThreeFour(assetId, productId);
for (let attempt = 0; attempt < 120; attempt += 1) {
const current = await mediaApi.readDerivative(created.id);
if (current.status === 'succeeded' && current.output_asset) return current;
if (current.status === 'failed') throw new Error('图片处理失败,请重试');
await new Promise<void>((resolve) => window.setTimeout(resolve, 1_500));
}
throw new Error('图片仍在处理中,请稍后重试');
}
/** 应用水印创建独立素材,调用方在选图弹窗中替换预览。 */
export async function createWatermarkedImage(assetId: string, templateId: string, productId: string): Promise<WatermarkDerivative> {
const created = await mediaApi.createWatermarkDerivative(assetId, templateId, productId);
for (let attempt = 0; attempt < 120; attempt += 1) {
const current = await mediaApi.readWatermarkDerivative(created.id);
if (current.status === 'succeeded' && current.output_asset) return current;
if (current.status === 'failed') throw new Error('水印处理失败,请重试');
await new Promise<void>((resolve) => window.setTimeout(resolve, 1_500));
}
throw new Error('水印仍在处理中,请稍后重试');
}
/** 浏览器端计算文件的 SHA-256 十六进制摘要(complete 校验用) */
export async function sha256Hex(file: File): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer());
+59
View File
@@ -0,0 +1,59 @@
import type {
CursorPaginationMeta,
OnlineProduct,
OnlineProductSyncRun,
OnlineProductsMeta,
} from '@atlas/contracts';
import { api } from './api';
export type OnlineProductFilters = {
q: string;
visibility: 'all' | 'visible' | 'invisible' | 'unknown';
source?: 'atlas_published' | 'external_sync';
moderation_status?: string;
};
export const onlineProductsApi = {
list: (platformId: string, storeId: string, filters: OnlineProductFilters, cursor?: string) =>
api.getList<OnlineProduct, OnlineProductsMeta>('/platform-listings', {
headers: { 'X-Atlas-Platform-Id': platformId },
params: {
store_id: storeId,
limit: 20,
...(cursor ? { cursor } : {}),
...(filters.q.trim() ? { q: filters.q.trim() } : {}),
visibility: filters.visibility,
...(filters.source ? { source: filters.source } : {}),
...(filters.moderation_status
? { moderation_status: filters.moderation_status }
: {}),
},
}),
get: (platformId: string, listingId: string) =>
api.get<OnlineProduct>(`/platform-listings/${listingId}`, {
headers: { 'X-Atlas-Platform-Id': platformId },
}),
createSync: (platformId: string, storeId: string) =>
api.post<{ sync_run: OnlineProductSyncRun }>(
`/stores/${storeId}/platform-product-sync-runs`,
{ reason: 'manual_refresh' },
{
headers: {
'X-Atlas-Platform-Id': platformId,
'Idempotency-Key': crypto.randomUUID(),
},
}
),
getSyncRun: (platformId: string, runId: string) =>
api.get<OnlineProductSyncRun>(`/platform-product-sync-runs/${runId}`, {
headers: { 'X-Atlas-Platform-Id': platformId },
}),
listSyncRuns: (platformId: string, storeId: string, cursor?: string) =>
api.getList<OnlineProductSyncRun, CursorPaginationMeta>(
`/stores/${storeId}/platform-product-sync-runs`,
{
headers: { 'X-Atlas-Platform-Id': platformId },
params: { limit: 20, ...(cursor ? { cursor } : {}) },
}
),
};
+7
View File
@@ -32,6 +32,13 @@ export const watermarksApi = {
get: (templateId: string) => api.get<WatermarkTemplate>(`/watermark-templates/${templateId}`),
getDefault: () => api.get<WatermarkTemplate | null>('/watermark-templates/default'),
setDefault: (templateId: string) =>
api.put<WatermarkTemplate>(`/watermark-templates/${templateId}/default`, {}, {
headers: { 'Idempotency-Key': crypto.randomUUID() },
}),
create: (payload: CreateWatermarkTemplateRequest) =>
api.post<WatermarkTemplate>('/watermark-templates', payload, {
headers: { 'Idempotency-Key': crypto.randomUUID() },