feat(ai-image): AI生图开发
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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 } : {}) },
|
||||
}
|
||||
),
|
||||
};
|
||||
@@ -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() },
|
||||
|
||||
Reference in New Issue
Block a user