feat: 完成 SKU 图片选择及素材导入后端

This commit is contained in:
R524809
2026-09-24 17:46:34 +08:00
parent d24ce8f4e0
commit e670d06ca6
26 changed files with 2287 additions and 119 deletions
@@ -0,0 +1,87 @@
export function detectImage(
bytes: Buffer,
maxDimension = 8192,
): {
contentType: 'image/png' | 'image/jpeg' | 'image/webp';
width: number;
height: number;
} | null {
if (
bytes.length >= 24 &&
bytes
.subarray(0, 8)
.equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
) {
return dimensions(
'image/png',
bytes.readUInt32BE(16),
bytes.readUInt32BE(20),
maxDimension,
);
}
if (
bytes.length >= 12 &&
bytes.subarray(0, 4).toString() === 'RIFF' &&
bytes.subarray(8, 12).toString() === 'WEBP'
) {
const type = bytes.subarray(12, 16).toString();
if (type === 'VP8X' && bytes.length >= 30) {
return dimensions(
'image/webp',
1 + bytes.readUIntLE(24, 3),
1 + bytes.readUIntLE(27, 3),
maxDimension,
);
}
if (
type === 'VP8 ' &&
bytes.length >= 30 &&
bytes[23] === 0x9d &&
bytes[24] === 0x01 &&
bytes[25] === 0x2a
) {
return dimensions(
'image/webp',
bytes.readUInt16LE(26) & 0x3fff,
bytes.readUInt16LE(28) & 0x3fff,
maxDimension,
);
}
}
if (bytes.length > 4 && bytes[0] === 0xff && bytes[1] === 0xd8) {
for (let offset = 2; offset + 9 < bytes.length;) {
if (bytes[offset] !== 0xff) return null;
const marker = bytes[offset + 1];
const length = bytes.readUInt16BE(offset + 2);
if (length < 2 || offset + 2 + length > bytes.length) return null;
if (
[
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb,
0xcd, 0xce, 0xcf,
].includes(marker)
) {
return dimensions(
'image/jpeg',
bytes.readUInt16BE(offset + 7),
bytes.readUInt16BE(offset + 5),
maxDimension,
);
}
offset += 2 + length;
}
}
return null;
}
function dimensions(
contentType: 'image/png' | 'image/jpeg' | 'image/webp',
width: number,
height: number,
maxDimension: number,
) {
return width >= 1 &&
height >= 1 &&
width <= maxDimension &&
height <= maxDimension
? { contentType, width, height }
: null;
}
+30
View File
@@ -3,6 +3,7 @@ import {
Controller,
Headers,
Inject,
Get,
Param,
Post,
Req,
@@ -17,6 +18,7 @@ import {
completeMediaUploadIntentRequestSchema,
createMediaAccessUrlRequestSchema,
createMediaUploadIntentRequestSchema,
importProductImageRequestSchema,
type Workspace,
} from '@atlas/contracts';
import { z } from 'zod';
@@ -33,6 +35,34 @@ export class MediaController {
@Inject(MediaService) private readonly media: MediaService,
) {}
@Get('media-assets/product-uploads')
@ApiOkResponse({ description: '最近上传的商品图片及短时预览地址' })
public async productUploads(@CurrentWorkspace() workspace: Workspace) {
return { data: await this.media.productUploads(workspace.id) };
}
@Get('media-assets/product-imports')
@ApiOkResponse({ description: '最近导入的网络商品图片及短时预览地址' })
public async productImports(@CurrentWorkspace() workspace: Workspace) {
return { data: await this.media.productImports(workspace.id) };
}
@Post('media-assets/product-imports')
@ApiCreatedResponse({ description: '已下载并校验网络图片,创建正式素材' })
public async importProductImage(
@Body() body: unknown,
@Headers('idempotency-key') key: string | undefined,
@Req() request: AuthenticatedRequest,
@CurrentWorkspace() workspace: Workspace,
) {
return (await this.media.importProductImage(
workspace.id,
request.auth.userId,
idempotencyKey(key),
parse(importProductImageRequestSchema, body),
)).body;
}
@Post('media/upload-intents')
@ApiCreatedResponse({ description: '已创建一次性预签名上传意图' })
public async createIntent(
+151 -84
View File
@@ -12,15 +12,19 @@ import { Inject, Injectable } from '@nestjs/common';
import type {
CompleteMediaUploadIntentRequest,
CreateMediaUploadIntentRequest,
ImportProductImageRequest,
} from '@atlas/contracts';
import type { Database, DatabaseTransaction } from '@atlas/database';
import { ApiError } from '../common/api-error.js';
import { IdempotencyService } from '../common/idempotency.service.js';
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
import { DATABASE } from '../database/database.module.js';
import { detectImage } from './media-image-validation.js';
import { downloadProductImage } from './product-image-download.js';
type IntentRow = {
id: string;
purpose: 'watermark_image' | 'product_image';
storage_provider: string;
bucket: string;
object_key: string;
@@ -80,16 +84,17 @@ export class MediaService {
async (tx) => {
await this.ensureBucket();
const id = randomUUID();
const objectKey = `uploads/${workspaceId}/watermark-images/${id}`;
const objectKey = `uploads/${workspaceId}/${input.purpose === 'product_image' ? 'product-images' : 'watermark-images'}/${id}`;
const expiresAt = new Date(Date.now() + 10 * 60_000);
await tx.query(
`INSERT INTO atlas.media_upload_intents
(id, workspace_id, purpose, storage_provider, bucket, object_key, expected_content_type,
expected_byte_size, expected_checksum_sha256, expires_at, created_by_user_id)
VALUES ($1, current_setting('app.workspace_id')::uuid, 'watermark_image', 'local_minio',
$2, $3, $4, $5, $6, $7, $8)`,
VALUES ($1, current_setting('app.workspace_id')::uuid, $2, 'local_minio',
$3, $4, $5, $6, $7, $8, $9)`,
[
id,
input.purpose,
this.config.OBJECT_STORAGE_BUCKET,
objectKey,
input.content_type,
@@ -282,12 +287,150 @@ export class MediaService {
});
}
/** 返回工作空间最近上传的商品素材,预览 URL 仅在本次响应中签发。 */
public async productUploads(workspaceId: string) {
return this.database.workspaceTransaction(workspaceId, async (tx) => {
const uploads = await tx.query<AssetRow & { bucket: string; object_key: string }>(
`SELECT a.id, a.content_type, a.byte_size::text, a.width_px, a.height_px,
a.created_at, a.bucket, a.object_key, max(i.completed_at) AS uploaded_at
FROM atlas.media_assets a
JOIN atlas.media_upload_intents i ON i.asset_id = a.id
WHERE i.purpose = 'product_image' AND i.status = 'ready'
GROUP BY a.id
ORDER BY uploaded_at DESC, a.id DESC
LIMIT 100`,
);
const expiresAt = new Date(Date.now() + 10 * 60_000).toISOString();
return Promise.all(uploads.rows.map(async (asset) => ({
id: asset.id,
content_type: asset.content_type,
byte_size: Number(asset.byte_size),
width_px: asset.width_px,
height_px: asset.height_px,
created_at: asset.created_at instanceof Date
? asset.created_at.toISOString()
: asset.created_at,
preview_url: await getSignedUrl(
this.s3,
new GetObjectCommand({ Bucket: asset.bucket, Key: asset.object_key }),
{ expiresIn: 600 },
),
preview_url_expires_at: expiresAt,
})));
});
}
/** 网络图片先做公网下载与内容校验,再登记为工作空间正式素材。 */
public async importProductImage(
workspaceId: string,
userId: string,
key: string,
input: ImportProductImageRequest,
) {
return this.idempotency.run(
{ workspaceId, userId, method: 'POST', path: '/api/media-assets/product-imports', key, request: input },
async (tx) => {
const image = await downloadProductImage(input.url);
const checksum = createHash('sha256').update(image.bytes).digest('hex');
const existing = await tx.query<{ id: string }>(
'SELECT id FROM atlas.media_assets WHERE checksum_sha256 = $1',
[checksum],
);
let assetId = existing.rows[0]?.id ?? randomUUID();
if (!existing.rows[0]) {
await this.ensureBucket();
const objectKey = `product-imports/${workspaceId}/${checksum}`;
await this.s3.send(new PutObjectCommand({
Bucket: this.config.OBJECT_STORAGE_BUCKET,
Key: objectKey,
Body: image.bytes,
ContentType: image.contentType,
}));
const inserted = await tx.query<{ id: string }>(
`INSERT INTO atlas.media_assets
(id, workspace_id, storage_provider, bucket, object_key, content_type,
byte_size, checksum_sha256, width_px, height_px)
VALUES ($1, current_setting('app.workspace_id')::uuid, 'local_minio',
$2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (workspace_id, checksum_sha256) DO NOTHING
RETURNING id`,
[assetId, this.config.OBJECT_STORAGE_BUCKET, objectKey, image.contentType,
image.bytes.length, checksum, image.width, image.height],
);
assetId = inserted.rows[0]?.id ?? (await tx.query<{ id: string }>(
'SELECT id FROM atlas.media_assets WHERE checksum_sha256 = $1',
[checksum],
)).rows[0].id;
}
await tx.query(
`INSERT INTO atlas.product_image_imports
(workspace_id, source_url, asset_id, created_by_user_id)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2, $3)
ON CONFLICT (workspace_id, source_url)
DO UPDATE SET asset_id = EXCLUDED.asset_id, updated_at = now()`,
[input.url, assetId, userId],
);
const asset = await this.requireAsset(tx, assetId);
const stored = await tx.query<{ bucket: string; object_key: string }>(
'SELECT bucket, object_key FROM atlas.media_assets WHERE id = $1',
[assetId],
);
const expiresAt = new Date(Date.now() + 10 * 60_000);
const previewUrl = await getSignedUrl(this.s3, new GetObjectCommand({
Bucket: stored.rows[0].bucket,
Key: stored.rows[0].object_key,
}), { expiresIn: 600 });
return {
status: 201,
body: { data: { ...asset, source_url: input.url,
preview_url: previewUrl, preview_url_expires_at: expiresAt.toISOString() } },
};
},
);
}
public async productImports(workspaceId: string) {
return this.database.workspaceTransaction(workspaceId, async (tx) => {
const imports = await tx.query<AssetRow & {
bucket: string;
object_key: string;
source_url: string;
}>(
`SELECT a.id, a.content_type, a.byte_size::text, a.width_px, a.height_px,
a.created_at, a.bucket, a.object_key, i.source_url
FROM (
SELECT DISTINCT ON (asset_id) asset_id, source_url, updated_at, id
FROM atlas.product_image_imports
ORDER BY asset_id, updated_at DESC, id DESC
) i
JOIN atlas.media_assets a ON a.id = i.asset_id
ORDER BY i.updated_at DESC, i.id DESC
LIMIT 100`,
);
const expiresAt = new Date(Date.now() + 10 * 60_000).toISOString();
return Promise.all(imports.rows.map(async (asset) => ({
id: asset.id,
content_type: asset.content_type,
byte_size: Number(asset.byte_size),
width_px: asset.width_px,
height_px: asset.height_px,
created_at: asset.created_at instanceof Date
? asset.created_at.toISOString() : asset.created_at,
source_url: asset.source_url,
preview_url: await getSignedUrl(this.s3, new GetObjectCommand({
Bucket: asset.bucket, Key: asset.object_key,
}), { expiresIn: 600 }),
preview_url_expires_at: expiresAt,
})));
});
}
private async lockIntent(
tx: DatabaseTransaction,
intentId: string,
): Promise<IntentRow> {
const rows = await tx.query<IntentRow>(
`SELECT id, storage_provider, bucket, object_key, expected_content_type, expected_byte_size,
`SELECT id, purpose, storage_provider, bucket, object_key, expected_content_type, expected_byte_size,
expected_checksum_sha256, status, expires_at, asset_id
FROM atlas.media_upload_intents WHERE id = $1 FOR UPDATE`,
[intentId],
@@ -335,7 +478,8 @@ export class MediaService {
'尚未找到已上传文件',
);
}
if (!head.ContentLength || head.ContentLength > 5 * 1024 * 1024) {
const byteLimit = intent.purpose === 'product_image' ? 20 * 1024 * 1024 : 5 * 1024 * 1024;
if (!head.ContentLength || head.ContentLength > byteLimit) {
throw new ApiError(
422,
'UPLOAD_VALIDATION_FAILED',
@@ -370,7 +514,7 @@ export class MediaService {
for await (const chunk of object.Body as AsyncIterable<Uint8Array>) {
byteSize += chunk.byteLength;
// HeadObject 和 GetObject 之间对象可能被替换;读取时也必须有硬上限。
if (byteSize > 5 * 1024 * 1024) {
if (byteSize > byteLimit) {
throw new ApiError(
422,
'UPLOAD_VALIDATION_FAILED',
@@ -380,7 +524,7 @@ export class MediaService {
chunks.push(Buffer.from(chunk));
}
const bytes = Buffer.concat(chunks, byteSize);
const detected = detectImage(bytes);
const detected = detectImage(bytes, intent.purpose === 'product_image' ? 10_000 : 8192);
if (
!detected ||
detected.contentType !== intent.expected_content_type
@@ -458,80 +602,3 @@ function apiErrorCode(error: unknown): string | undefined {
? apiError.code
: undefined;
}
function detectImage(bytes: Buffer): {
contentType: 'image/png' | 'image/jpeg' | 'image/webp';
width: number;
height: number;
} | null {
if (
bytes.length >= 24 &&
bytes
.subarray(0, 8)
.equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
) {
return dimensions(
'image/png',
bytes.readUInt32BE(16),
bytes.readUInt32BE(20),
);
}
if (
bytes.length >= 12 &&
bytes.subarray(0, 4).toString() === 'RIFF' &&
bytes.subarray(8, 12).toString() === 'WEBP'
) {
const type = bytes.subarray(12, 16).toString();
if (type === 'VP8X' && bytes.length >= 30) {
return dimensions(
'image/webp',
1 + bytes.readUIntLE(24, 3),
1 + bytes.readUIntLE(27, 3),
);
}
if (
type === 'VP8 ' &&
bytes.length >= 30 &&
bytes[23] === 0x9d &&
bytes[24] === 0x01 &&
bytes[25] === 0x2a
) {
return dimensions(
'image/webp',
bytes.readUInt16LE(26) & 0x3fff,
bytes.readUInt16LE(28) & 0x3fff,
);
}
}
if (bytes.length > 4 && bytes[0] === 0xff && bytes[1] === 0xd8) {
for (let offset = 2; offset + 9 < bytes.length;) {
if (bytes[offset] !== 0xff) return null;
const marker = bytes[offset + 1];
const length = bytes.readUInt16BE(offset + 2);
if (length < 2 || offset + 2 + length > bytes.length) return null;
if (
[
0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb,
0xcd, 0xce, 0xcf,
].includes(marker)
) {
return dimensions(
'image/jpeg',
bytes.readUInt16BE(offset + 7),
bytes.readUInt16BE(offset + 5),
);
}
offset += 2 + length;
}
}
return null;
}
function dimensions(
contentType: 'image/png' | 'image/jpeg' | 'image/webp',
width: number,
height: number,
) {
return width >= 1 && height >= 1 && width <= 8192 && height <= 8192
? { contentType, width, height }
: null;
}
@@ -0,0 +1,137 @@
import { lookup } from 'node:dns/promises';
import { request as httpsRequest } from 'node:https';
import type { IncomingMessage } from 'node:http';
import { isIP } from 'node:net';
import { ApiError } from '../common/api-error.js';
import { detectImage } from './media-image-validation.js';
const MAX_BYTES = 5 * 1024 * 1024;
function invalidImage(): never {
throw new ApiError(
422,
'PRODUCT_IMAGE_IMPORT_INVALID',
'网络图片链接无效或图片不符合要求',
);
}
export function publicIpv4(address: string) {
if (isIP(address) !== 4) return false;
const [a, b, c] = address.split('.').map(Number);
return !(
a === 0 ||
a === 10 ||
a === 127 ||
a >= 224 ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 &&
(b === 168 || (b === 0 && c === 0) || (b === 0 && c === 2))) ||
(a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
(a === 203 && b === 0 && c === 113)
);
}
/** 每次重定向重新解析域名,并将 HTTPS 连接固定到已检查的公网 IPv4。 */
async function requestPublicImage(url: URL): Promise<IncomingMessage> {
if (
url.protocol !== 'https:' ||
url.username ||
url.password ||
url.port ||
isIP(url.hostname)
)
invalidImage();
const addresses = await lookup(url.hostname, {
all: true,
family: 4,
}).catch(() => []);
if (
!addresses.length ||
addresses.some((item) => !publicIpv4(item.address))
)
invalidImage();
const address = addresses[0].address;
return new Promise<IncomingMessage>((resolve, reject) => {
const request = httpsRequest(
url,
{
lookup: (_hostname, _options, callback) =>
callback(null, address, 4),
headers: {
Accept: 'image/jpeg,image/png',
'User-Agent': 'Atlas-Image-Importer/0.1',
},
timeout: 15_000,
},
resolve,
);
request.on('error', reject);
request.on('timeout', () =>
request.destroy(new Error('网络图片下载超时')),
);
request.end();
});
}
/** 仅导入公开 HTTPS 的 JPG/PNG,硬限制 5 MiB 并验证文件内容。 */
export async function downloadProductImage(source: string) {
let url = new URL(source);
for (let redirect = 0; redirect < 4; redirect += 1) {
let response: IncomingMessage;
try {
response = await requestPublicImage(url);
} catch (error) {
if (error instanceof ApiError) throw error;
invalidImage();
}
if ([301, 302, 303, 307, 308].includes(response.statusCode ?? 0)) {
const location = response.headers.location;
response.resume();
if (!location) invalidImage();
url = new URL(location, url);
continue;
}
if (
response.statusCode !== 200 ||
Number(response.headers['content-length'] ?? 0) > MAX_BYTES
) {
response.resume();
invalidImage();
}
const chunks: Buffer[] = [];
let total = 0;
try {
for await (const chunk of response) {
total += chunk.length;
if (total > MAX_BYTES) {
response.destroy();
invalidImage();
}
chunks.push(Buffer.from(chunk));
}
} catch (error) {
if (error instanceof ApiError) throw error;
invalidImage();
}
const bytes = Buffer.concat(chunks, total);
const detected = detectImage(bytes, 10_000);
if (
!detected ||
!['image/jpeg', 'image/png'].includes(detected.contentType)
)
invalidImage();
const declared = response.headers['content-type']
?.split(';', 1)[0]
?.toLowerCase();
if (declared && declared !== detected.contentType) invalidImage();
return {
bytes,
contentType: detected.contentType,
width: detected.width,
height: detected.height,
};
}
invalidImage();
}
@@ -1378,6 +1378,23 @@ export class ProductEditorService {
],
)
: { rows: [] };
const uploaded = assetIds.length
? await transaction.query<{ asset_id: string }>(
`SELECT DISTINCT asset_id FROM atlas.media_upload_intents
WHERE purpose = 'product_image' AND status = 'ready'
AND asset_id = ANY($1::uuid[])`,
[assetIds],
)
: { rows: [] };
const uploadedIds = new Set(uploaded.rows.map((item) => item.asset_id));
const imported = assetIds.length
? await transaction.query<{ asset_id: string }>(
`SELECT DISTINCT asset_id FROM atlas.product_image_imports
WHERE asset_id = ANY($1::uuid[])`,
[assetIds],
)
: { rows: [] };
const importedIds = new Set(imported.rows.map((item) => item.asset_id));
const candidateVariants = new Map<string, Set<string | null>>();
for (const candidate of candidates.rows) {
const variants =
@@ -1416,6 +1433,8 @@ export class ProductEditorService {
);
if (existingByVariant.get(variant.variantId)?.has(assetId))
continue;
if (uploadedIds.has(assetId)) continue;
if (importedIds.has(assetId)) continue;
const allowedFor = candidateVariants.get(assetId);
if (
!allowedFor?.has(null) &&
@@ -1424,7 +1443,7 @@ export class ProductEditorService {
throw new ApiError(
422,
'LISTING_MEDIA_NOT_SELECTABLE',
'只能选择当前采集快照中该 SKU 或商品通用候选已转存的图片',
'只能选择已上传、已导入或当前采集快照中该 SKU 可用的已转存图片',
);
}
}
+100
View File
@@ -0,0 +1,100 @@
import { createHash } from 'node:crypto';
import { describe, expect, it, vi } from 'vitest';
import { MediaService } from '../src/media/media.service.js';
vi.mock('../src/media/product-image-download.js', async (importOriginal) => {
const original =
await importOriginal<
typeof import('../src/media/product-image-download.js')
>();
return {
...original,
downloadProductImage: vi.fn(async () => ({
bytes: Buffer.from('validated-image'),
contentType: 'image/png',
width: 800,
height: 800,
})),
};
});
describe('网络商品图片导入', () => {
it('复用同工作空间素材并返回正式 ID 与短时预览地址', async () => {
const assetId = '7c910684-7070-4f36-8a7b-b10bd91acd79';
const bytes = Buffer.from('validated-image');
const checksum = createHash('sha256').update(bytes).digest('hex');
const query = vi.fn(async (sql: string, args?: unknown[]) => {
if (sql.includes('WHERE checksum_sha256 = $1')) {
expect(args).toEqual([checksum]);
return { rows: [{ id: assetId }] };
}
if (sql.includes('INSERT INTO atlas.product_image_imports')) {
expect(args?.[0]).toBe('https://example.com/photo.png');
expect(args?.[1]).toBe(assetId);
return { rows: [] };
}
if (sql.includes('SELECT id, content_type')) {
return {
rows: [
{
id: assetId,
content_type: 'image/png',
byte_size: '15',
width_px: 800,
height_px: 800,
created_at: new Date('2026-09-24T00:00:00Z'),
},
],
};
}
if (sql.includes('SELECT bucket, object_key')) {
return {
rows: [
{
bucket: 'test',
object_key: 'product-imports/existing.png',
},
],
};
}
throw new Error(`未知 SQL: ${sql}`);
});
const run = vi.fn(
async (
request: { path: string; key: string },
callback: (tx: { query: typeof query }) => Promise<unknown>,
) => {
expect(request.path).toBe('/api/media-assets/product-imports');
expect(request.key).toBe('idempotency-key');
return callback({ query });
},
);
const service = new MediaService(
{ run } as unknown as ConstructorParameters<typeof MediaService>[0],
{} as ConstructorParameters<typeof MediaService>[1],
{
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
OBJECT_STORAGE_BUCKET: 'test',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 'test',
objectStorageSecretKey: 'test',
} as ConstructorParameters<typeof MediaService>[2],
);
const result = await service.importProductImage(
'workspace-id',
'user-id',
'idempotency-key',
{ url: 'https://example.com/photo.png' },
);
const body = result.body as {
data: { id: string; source_url: string; preview_url: string };
};
expect(result.status).toBe(201);
expect(body.data).toMatchObject({
id: assetId,
source_url: 'https://example.com/photo.png',
});
expect(body.data.preview_url).toContain('product-imports/existing.png');
expect(query).toHaveBeenCalledTimes(4);
});
});
@@ -0,0 +1,228 @@
import { createHash } from 'node:crypto';
import { GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import {
createMediaUploadIntentRequestSchema,
importProductImageRequestSchema,
} from '@atlas/contracts';
import { describe, expect, it, vi } from 'vitest';
import { MediaService } from '../src/media/media.service.js';
import {
downloadProductImage,
publicIpv4,
} from '../src/media/product-image-download.js';
function serviceWithObject(bytes: Buffer) {
const service = new MediaService(
{} as ConstructorParameters<typeof MediaService>[0],
{} as ConstructorParameters<typeof MediaService>[1],
{
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
OBJECT_STORAGE_BUCKET: 'test',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 'test',
objectStorageSecretKey: 'test',
} as ConstructorParameters<typeof MediaService>[2],
);
const send = vi.fn(async (command: unknown) => {
if (command instanceof HeadObjectCommand) {
return { ContentLength: bytes.length, ContentType: 'image/png' };
}
if (command instanceof GetObjectCommand) {
return {
Body: (async function* () {
yield bytes;
})(),
};
}
throw new Error('未知 S3 命令');
});
Object.assign(service, { s3: { send } });
return service as unknown as {
inspectObject: (intent: Record<string, unknown>) => Promise<{
width: number;
height: number;
checksum: string;
}>;
};
}
function png(width: number, height: number) {
const bytes = Buffer.alloc(24);
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(bytes);
bytes.writeUInt32BE(width, 16);
bytes.writeUInt32BE(height, 20);
return bytes;
}
describe('商品图片上传策略', () => {
it('创建商品图片上传意图时持久化用途并签发独立对象路径', async () => {
const query = vi.fn(async (_sql: string, _params?: unknown[]) => ({
rows: [],
}));
const run = vi.fn(
async (
_request: unknown,
callback: (tx: { query: typeof query }) => Promise<unknown>,
) => callback({ query }),
);
const service = new MediaService(
{ run } as unknown as ConstructorParameters<typeof MediaService>[0],
{} as ConstructorParameters<typeof MediaService>[1],
{
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
OBJECT_STORAGE_BUCKET: 'test',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 'test',
objectStorageSecretKey: 'test',
} as ConstructorParameters<typeof MediaService>[2],
);
Object.assign(service, { ensureBucket: async () => undefined });
const result = await service.createIntent(
'workspace-id',
'user-id',
'key',
{
purpose: 'product_image',
filename: 'sku.png',
content_type: 'image/png',
byte_size: 1024,
checksum_sha256: 'a'.repeat(64),
},
);
const args = query.mock.calls[0]?.[1] as unknown[];
expect(args[1]).toBe('product_image');
expect(args[3]).toContain('/product-images/');
const body = result.body as { data: { upload: { url: string } } };
expect(body.data.upload.url).toContain('product-images');
});
it('商品图允许 20 MiB,水印图仍限制 5 MiB', () => {
const request = {
filename: 'sku.png',
content_type: 'image/png',
byte_size: 6 * 1024 * 1024,
checksum_sha256: 'a'.repeat(64),
};
expect(
createMediaUploadIntentRequestSchema.safeParse({
...request,
purpose: 'product_image',
}).success,
).toBe(true);
expect(
createMediaUploadIntentRequestSchema.safeParse({
...request,
purpose: 'watermark_image',
}).success,
).toBe(false);
expect(
createMediaUploadIntentRequestSchema.safeParse({
...request,
purpose: 'product_image',
byte_size: 21 * 1024 * 1024,
}).success,
).toBe(false);
});
it('商品图校验接受 9000px,水印图仍拒绝超过 8192px', async () => {
const bytes = png(9000, 9000);
const service = serviceWithObject(bytes);
const intent = {
bucket: 'test',
object_key: 'product.png',
expected_content_type: 'image/png',
purpose: 'product_image',
};
await expect(service.inspectObject(intent)).resolves.toMatchObject({
width: 9000,
height: 9000,
checksum: createHash('sha256').update(bytes).digest('hex'),
});
await expect(
service.inspectObject({ ...intent, purpose: 'watermark_image' }),
).rejects.toThrow();
});
it('上传图片列表仅查询当前工作空间已完成的商品素材', async () => {
const query = vi.fn(async (sql: string) => {
expect(sql).toContain("i.purpose = 'product_image'");
expect(sql).toContain("i.status = 'ready'");
return {
rows: [
{
id: '7c910684-7070-4f36-8a7b-b10bd91acd79',
content_type: 'image/png',
byte_size: '24',
width_px: 800,
height_px: 800,
created_at: new Date('2026-09-24T00:00:00Z'),
bucket: 'test',
object_key: 'uploads/test.png',
},
],
};
});
const workspaceTransaction = vi.fn(
async (
workspaceId: string,
callback: (tx: { query: typeof query }) => Promise<unknown>,
) => {
expect(workspaceId).toBe('workspace-id');
return callback({ query });
},
);
const service = new MediaService(
{} as ConstructorParameters<typeof MediaService>[0],
{ workspaceTransaction } as unknown as ConstructorParameters<
typeof MediaService
>[1],
{
OBJECT_STORAGE_ENDPOINT: 'http://127.0.0.1:9000',
OBJECT_STORAGE_BUCKET: 'test',
objectStorageForcePathStyle: true,
objectStorageAccessKey: 'test',
objectStorageSecretKey: 'test',
} as ConstructorParameters<typeof MediaService>[2],
);
const uploads = await service.productUploads('workspace-id');
expect(uploads).toHaveLength(1);
expect(uploads[0]).toMatchObject({
id: '7c910684-7070-4f36-8a7b-b10bd91acd79',
byte_size: 24,
});
expect(uploads[0].preview_url).toContain('uploads/test.png');
});
it('网络导入只允许 HTTPS 图片链接,拒绝内网地址', async () => {
expect(
importProductImageRequestSchema.safeParse({
url: 'https://example.com/image.jpg',
}).success,
).toBe(true);
expect(
importProductImageRequestSchema.safeParse({
url: 'http://example.com/image.jpg',
}).success,
).toBe(false);
expect(
importProductImageRequestSchema.safeParse({
url: 'https://example.com/image.svg',
}).success,
).toBe(false);
expect(publicIpv4('8.8.8.8')).toBe(true);
for (const address of [
'127.0.0.1',
'10.1.2.3',
'172.16.0.1',
'192.168.1.1',
'169.254.1.1',
'100.64.0.1',
'224.0.0.1',
]) {
expect(publicIpv4(address)).toBe(false);
}
await expect(
downloadProductImage('https://127.0.0.1/image.jpg'),
).rejects.toThrow();
});
});
+83
View File
@@ -132,6 +132,14 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
'DELETE FROM atlas.collection_asset_transfers WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.product_image_imports WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.media_upload_intents WHERE workspace_id = $1',
[workspaceId],
);
await tx.query(
'DELETE FROM atlas.product_variants WHERE workspace_id = $1',
[workspaceId],
@@ -1900,6 +1908,81 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
after.body.data.variants[0].media.map((item: any) => item.asset_id),
[asset],
);
const additionalAssets = await database.workspaceTransaction(workspaceId, async (tx) => {
const ids: string[] = [];
for (let index = 0; index < 29; index += 1) {
const checksum = randomUUID().replaceAll('-', '').repeat(2);
const objectKey = `m36/${randomUUID()}.png`;
const created = await tx.query<{ id: string }>(
`INSERT INTO atlas.media_assets
(workspace_id, storage_provider, bucket, object_key, content_type,
byte_size, checksum_sha256, width_px, height_px)
VALUES (current_setting('app.workspace_id')::uuid, 'test', 'test', $1,
'image/png', 1024, $2, 1200, 900) RETURNING id`,
[objectKey, checksum],
);
const assetId = created.rows[0].id;
ids.push(assetId);
if (index === 0) {
await tx.query(
`INSERT INTO atlas.media_upload_intents
(workspace_id, purpose, storage_provider, bucket, object_key,
expected_content_type, expected_byte_size, expected_checksum_sha256,
status, asset_id, expires_at, completed_at)
VALUES (current_setting('app.workspace_id')::uuid, 'product_image',
'test', 'test', $1, 'image/png', 1024, $2,
'ready', $3, now() + interval '1 hour', now())`,
[objectKey, checksum, assetId],
);
} else {
await tx.query(
`INSERT INTO atlas.product_image_imports
(workspace_id, source_url, asset_id)
VALUES (current_setting('app.workspace_id')::uuid, $1, $2)`,
[`https://example.test/${randomUUID()}.png`, assetId],
);
}
}
return ids;
});
const thirtyImages = {
...payload,
variants: payload.variants.map((item: any) => ({
...item,
media_asset_ids: item.id === variant.id ? [asset, ...additionalAssets] : [],
})),
};
const expanded = await request(`/api/products/${first.productId}/editor-draft`, {
method: 'PUT',
headers: headers({
'X-Atlas-Platform-Id': platformId,
'Idempotency-Key': randomUUID(),
'If-Match': `"${saved.body.data.product_revision}:${saved.body.data.profile_revision}"`,
}),
body: JSON.stringify(thirtyImages),
});
assert.equal(expanded.response.status, 200);
const expandedSnapshot = await request(`/api/products/${first.productId}/editor`, {
headers: headers({ 'X-Atlas-Platform-Id': platformId }),
});
assert.equal(expandedSnapshot.body.data.variants[0].media.length, 30);
const uploaded = await request('/api/media-assets/product-uploads', {
headers: headers(),
});
assert.equal(uploaded.response.status, 200);
assert.ok(uploaded.body.data.some((item: any) => item.id === additionalAssets[0]));
const imported = await request('/api/media-assets/product-imports', {
headers: headers(),
});
assert.equal(imported.response.status, 200);
assert.ok(imported.body.data.some((item: any) => item.id === additionalAssets[1]));
const privateImport = await request('/api/media-assets/product-imports', {
method: 'POST',
headers: headers({ 'Idempotency-Key': randomUUID() }),
body: JSON.stringify({ url: 'https://127.0.0.1/private.png' }),
});
assert.equal(privateImport.response.status, 422);
assert.equal(privateImport.body.error.code, 'PRODUCT_IMAGE_IMPORT_INVALID');
});
it('独立保存采集描述会持久化覆盖值并保持幂等', async () => {
@@ -1062,7 +1062,7 @@ const ProductEditPage = () => {
modal.info({
title: `${name}将在下一阶段接入`,
content:
'本轮只实现前端工作台。生成结果会以候选形式展示,必须由用户确认才会写入草稿,不会自动覆盖当前内容。',
'当前已支持 SKU 图片上传、网络导入和采集图转存。AI 生图与图片编辑将在后续接入。',
okText: '知道了',
});
const validSupplyUrl =
@@ -1419,7 +1419,7 @@ const ProductEditPage = () => {
onCompareCopy={(field) => setComparison({ variantId: null, field })}
/>
<ProductVariantSection
<ProductVariantSection
form={form}
snapshot={snapshot}
collectionRecord={collectionRecord}
@@ -1444,6 +1444,7 @@ const ProductEditPage = () => {
skuCopyTaskId.current = null;
}}
onCompareCopy={(variantId, field) => setComparison({ variantId, field })}
onImagesChanged={() => setDirty(true)}
/>
<ProductOtherSection
@@ -205,6 +205,12 @@
flex: 1;
border-top: 1px dashed #d9d4df;
}
.sku-media-local-note {
display: block;
margin-bottom: 10px;
color: #8d789c;
font-size: 12px;
}
.sku-media-grid {
display: grid;
/* 每行固定八个图片位;主图跨两列、两行,其余图片按单格自动续排。 */
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import {
Button,
Card,
@@ -30,6 +30,9 @@ import type {
} from '@atlas/contracts';
import { SectionTitle } from './SectionTitle';
import { DynamicAttributeFields } from './DynamicAttributeFields';
import { SkuImageEditorModal, type SkuEditorImage } from './SkuImageEditorModal';
import { resolveSkuImageSelection } from './skuImageSelection';
import { mediaApi, uploadProductImage } from '@/services/media';
import './ProductVariantSection.css';
type EditorFormValues = SaveProductEditorDraftRequest;
@@ -131,6 +134,7 @@ type Props = {
usesVariantTitle: boolean;
onAiCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void;
onCompareCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void;
onImagesChanged: () => void;
};
/**
@@ -157,11 +161,43 @@ export function ProductVariantSection({
usesVariantTitle,
onAiCopy,
onCompareCopy,
onImagesChanged,
}: Props) {
// 实时监听变体草稿,驱动已选图片和主图回显。
const draftVariants = (Form.useWatch('variants', form) ?? []) as VariantValues[];
// 展开状态按 Form.List 的 field.key(而非下标)记录,增删/复制 SKU 后不会错位。
const [expandedVariantKeys, setExpandedVariantKeys] = useState<Set<number>>(() => new Set());
// 页面内保存已选预览,正式选图顺序同步到 Form 的 media_asset_ids。
const [localImagesByVariant, setLocalImagesByVariant] = useState<Record<string, SkuEditorImage[]>>({});
const [assetPreviewUrls, setAssetPreviewUrls] = useState<Record<string, string>>({});
useEffect(() => setLocalImagesByVariant({}), [snapshot]);
const [imageEditorTarget, setImageEditorTarget] = useState<{
key: string;
name: string;
images: SkuEditorImage[];
candidates: CollectionItemInspection['asset_transfers'];
} | null>(null);
const localObjectUrls = useRef<string[]>([]);
useEffect(() => () => localObjectUrls.current.forEach((url) => URL.revokeObjectURL(url)), []);
useEffect(() => {
let active = true;
const refresh = async () => {
const ids = [...new Set(snapshot.variants.flatMap((variant) =>
variant.media.filter((media) => !media.source_url).map((media) => media.asset_id)
))];
const previews = await Promise.allSettled(ids.map((id) => mediaApi.createPreviewUrl(id)));
if (!active) return;
setAssetPreviewUrls(Object.fromEntries(previews.flatMap((result, index) =>
result.status === 'fulfilled' ? [[ids[index], result.value.url]] : []
)));
};
void refresh();
const interval = window.setInterval(() => void refresh(), 8 * 60_000);
return () => {
active = false;
window.clearInterval(interval);
};
}, [snapshot]);
// SKU 变体属性统一放入展开后的“SKU 信息”,必填状态仍由 schema 决定。
const skuInfoAspectDefinitions = activeAttributeSchema
.filter(
@@ -175,6 +211,7 @@ export function ProductVariantSection({
definition.is_aspect && !definition.editor_field && isColorNameExpression(definition)
);
return (
<>
<Card className="editor-section sku-section" bordered={false}>
<SectionTitle
number="03"
@@ -237,20 +274,25 @@ export function ProductVariantSection({
: [];
const selectedAssetIds =
draftVariants[field.name]?.media_asset_ids ?? [];
const selectedImages = selectedAssetIds
.map((assetId) => ({
assetId,
url:
imageCandidates.find(
(candidate) => candidate.asset_id === assetId
)?.source_url ??
original?.media.find(
(media) => media.asset_id === assetId
)?.source_url,
}))
.filter((image): image is { assetId: string; url: string } =>
Boolean(image.url)
);
const variantKey = sourceId ?? variantValue?.client_key ?? String(field.key);
const savedImages = selectedAssetIds
.map((assetId): SkuEditorImage | null => {
const candidate = imageCandidates.find(
(item) => item.asset_id === assetId
);
const url = candidate?.source_url ?? original?.media.find(
(media) => media.asset_id === assetId
)?.source_url ?? assetPreviewUrls[assetId];
return url ? {
id: candidate ? `collection:${candidate.id}` : `asset:${assetId}`,
url,
label: '已选 SKU 图片',
origin: 'saved',
assetId,
} : null;
})
.filter((image): image is SkuEditorImage => image !== null);
const selectedImages = localImagesByVariant[variantKey] ?? savedImages;
const copyVariant = () =>
add({
...variantValue,
@@ -425,11 +467,16 @@ export function ProductVariantSection({
<Typography.Title level={5}>
SKU 图片
</Typography.Title>
{Object.hasOwn(localImagesByVariant, variantKey) && (
<Typography.Text className="sku-media-local-note">
当前图片仅在本页预览,刷新后恢复
</Typography.Text>
)}
<div className="sku-media-grid">
{selectedImages.map((image, imageIndex) => (
<div
className={`sku-media-item ${imageIndex === 0 ? 'sku-media-item--main' : ''}`}
key={image.assetId}
key={image.id}
>
{imageIndex === 0 && (
<span className="sku-media-main-badge">
@@ -439,10 +486,15 @@ export function ProductVariantSection({
<Image src={image.url} alt="已选 SKU 图片" />
</div>
))}
{/* 图片选择交互将在后续接入。 */}
<Button
className={`sku-media-picker ${selectedImages.length ? '' : 'sku-media-picker--empty'}`}
type="dashed"
onClick={() => setImageEditorTarget({
key: variantKey,
name: variantValue?.display_name || `SKU ${index + 1}`,
images: selectedImages,
candidates: imageCandidates,
})}
>
选择图片
</Button>
@@ -703,5 +755,43 @@ export function ProductVariantSection({
}}
</Form.List>
</Card>
<SkuImageEditorModal
open={!!imageEditorTarget}
skuKey={imageEditorTarget?.key ?? ''}
skuName={imageEditorTarget?.name ?? ''}
initialImages={imageEditorTarget?.images ?? []}
collectionImages={imageEditorTarget?.candidates ?? []}
onCancel={() => setImageEditorTarget(null)}
onConfirm={async (images) => {
if (!imageEditorTarget) return;
const assetIds = await resolveSkuImageSelection(images, collectionRecord?.id ?? null);
const variantIndex = (form.getFieldValue('variants') as VariantValues[])
.findIndex((variant) => (variant.id ?? variant.client_key) === imageEditorTarget.key);
if (variantIndex < 0) throw new Error('SKU 已变化,请重新打开图片选择');
form.setFieldValue(['variants', variantIndex, 'media_asset_ids'], assetIds);
setLocalImagesByVariant((current) => ({
...current,
[imageEditorTarget.key]: images.map((image, index) => ({
...image,
assetId: assetIds[index],
})),
}));
onImagesChanged();
setImageEditorTarget(null);
}}
onUploadFile={async (file) => {
const asset = await uploadProductImage(file);
const url = URL.createObjectURL(file);
localObjectUrls.current.push(url);
return {
id: `asset:${asset.id}`,
url,
label: file.name,
origin: 'upload',
assetId: asset.id,
};
}}
/>
</>
);
}
@@ -0,0 +1,112 @@
import type { DragEvent, KeyboardEvent, MouseEvent } from 'react';
import { Button, Dropdown, Tooltip } from 'antd';
import type { MenuProps } from 'antd';
import {
CheckCircleOutlined,
DownOutlined,
EyeOutlined,
MinusCircleOutlined,
} from '@ant-design/icons';
export type SkuEditorImage = {
id: string;
url: string;
label: string;
origin: 'saved' | 'collection' | 'upload' | 'network';
assetId?: string;
transferId?: string;
sourceUrl?: string;
};
type Props = {
image: SkuEditorImage;
mode: 'selected' | 'source';
selected: boolean;
cover?: boolean;
menu: MenuProps;
onToggle: () => void;
onPreview: () => void;
onRemove?: () => void;
onDragStart?: () => void;
onDragOver?: (event: DragEvent<HTMLDivElement>) => void;
onDrop?: (event: DragEvent<HTMLDivElement>) => void;
};
/** 左右两列复用图片卡片,选中、预览和编辑操作保持一致。 */
export function SkuImageCard({
image,
mode,
selected,
cover,
menu,
onToggle,
onPreview,
onRemove,
onDragStart,
onDragOver,
onDrop,
}: Props) {
const isSelectedColumn = mode === 'selected';
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onToggle();
}
};
const stop = (event: MouseEvent<HTMLElement>) => event.stopPropagation();
return (
<div
className={`${isSelectedColumn ? 'sku-image-selected-card' : 'sku-image-source-card'} ${cover ? 'is-cover' : ''} ${selected ? (isSelectedColumn ? 'is-marked' : 'is-selected') : ''}`}
role="button"
tabIndex={0}
aria-label={`${isSelectedColumn ? '选择' : selected ? '移除' : '加入'}${image.label}`}
aria-pressed={selected}
draggable={isSelectedColumn}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
onClick={onToggle}
onKeyDown={handleKeyDown}
>
<img src={image.url} alt={image.label} loading="lazy" draggable={false} />
{cover && <span className="sku-image-cover-badge">主图</span>}
{!isSelectedColumn && selected && (
<Tooltip title="已加入">
<CheckCircleOutlined className="sku-image-source-selected" />
</Tooltip>
)}
{isSelectedColumn && (
<Tooltip title="从已选图片移出">
<Button
className="sku-image-remove"
type="text"
icon={<MinusCircleOutlined />}
aria-label={`移出${image.label}`}
onClick={(event) => {
stop(event);
onRemove?.();
}}
/>
</Tooltip>
)}
<div className="sku-image-card-actions" onKeyDown={(event) => event.stopPropagation()}>
<Tooltip title="放大查看">
<Button
type="text"
icon={<EyeOutlined />}
aria-label={`放大查看${image.label}`}
onClick={(event) => {
stop(event);
onPreview();
}}
/>
</Tooltip>
<Dropdown menu={menu} trigger={['hover']} placement="bottomRight">
<Button type="text" onClick={stop}>
编辑 <DownOutlined />
</Button>
</Dropdown>
</div>
</div>
);
}
@@ -0,0 +1,309 @@
.sku-image-editor-modal .ant-modal-container {
display: flex;
height: min(800px, calc(100dvh - 48px));
flex-direction: column;
padding: 0;
overflow: hidden;
border-radius: 14px;
}
.sku-image-editor-modal .ant-modal-header {
flex: 0 0 auto;
margin: 0;
padding: 18px 26px;
border-bottom: 1px solid #ebe9f1;
}
.sku-image-editor-modal .ant-modal-title > div {
display: flex;
align-items: baseline;
gap: 14px;
}
.sku-image-editor-modal .ant-modal-title strong {
color: #22212b;
font-size: 20px;
}
.sku-image-editor-modal .ant-modal-title span {
overflow: hidden;
color: #888396;
font-size: 13px;
font-weight: 400;
text-overflow: ellipsis;
white-space: nowrap;
}
.sku-image-editor-modal .ant-modal-close {
top: 17px;
right: 18px;
}
.sku-image-editor-modal .ant-modal-body {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
padding: 0 24px 18px;
}
.sku-image-editor-modal .ant-modal-footer {
flex: 0 0 auto;
margin: 0;
padding: 14px 24px;
border-top: 1px solid #ebe9f1;
}
.sku-image-editor-note {
flex: 0 0 auto;
margin: 16px 0 12px;
padding: 9px 13px;
border: 1px solid #e9e2ff;
border-radius: 8px;
background: #f8f5ff;
color: #695d86;
font-size: 12px;
}
.sku-image-editor-columns {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 24px;
min-height: 0;
flex: 1 1 auto;
}
.sku-image-editor-left,
.sku-image-editor-right {
display: flex;
min-width: 0;
min-height: 0;
flex-direction: column;
}
.sku-image-editor-left {
padding-right: 24px;
border-right: 1px solid #ece9f1;
}
.sku-image-editor-toolbar {
display: flex;
align-items: center;
gap: 10px;
min-height: 42px;
}
.sku-image-editor-toolbar .ant-btn:first-of-type {
margin-right: auto;
}
.sku-image-editor-summary {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 16px;
margin: 20px 0 14px;
flex: 0 0 auto;
}
.sku-image-editor-summary strong {
color: #252332;
font-size: 16px;
}
.sku-image-editor-summary span {
color: #8d879e;
font-size: 12px;
}
.sku-image-editor-scroll {
min-height: 0;
flex: 1 1 auto;
overflow-y: auto;
scrollbar-color: #d1ccd9 transparent;
scrollbar-width: thin;
}
.sku-image-editor-right .ant-tabs {
display: flex;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
}
.sku-image-editor-right .ant-tabs-nav {
flex: 0 0 auto;
}
.sku-image-editor-right .ant-tabs-body-holder,
.sku-image-editor-right .ant-tabs-body {
min-height: 0;
flex: 1 1 auto;
}
.sku-image-editor-right .ant-tabs-body-holder {
overflow: hidden;
}
.sku-image-editor-right .ant-tabs-body {
height: 100%;
}
.sku-image-editor-right .ant-tabs-content {
height: 100%;
overflow-y: auto;
scrollbar-color: #d1ccd9 transparent;
scrollbar-width: thin;
}
.sku-image-selected-grid,
.sku-image-source-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-auto-flow: row;
gap: 12px;
padding: 3px;
}
.sku-image-selected-card,
.sku-image-source-card {
position: relative;
min-width: 0;
aspect-ratio: 3 / 4;
overflow: hidden;
border: 2px solid #e8e5ed;
border-radius: 9px;
background: #f6f5f8;
cursor: pointer;
}
.sku-image-selected-card.is-cover {
grid-column: span 2;
grid-row: span 2;
}
.sku-image-selected-card.is-marked,
.sku-image-source-card.is-selected {
border-color: #7062e9;
box-shadow: 0 0 0 2px rgb(112 98 233 / 12%);
}
.sku-image-selected-card > img,
.sku-image-source-card > img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.sku-image-cover-badge {
position: absolute;
top: 8px;
left: 8px;
padding: 2px 7px;
border-radius: 5px;
background: #7062e9;
color: #fff;
font-size: 11px;
}
.sku-image-source-selected {
position: absolute;
top: 6px;
left: 6px;
z-index: 2;
display: flex;
width: 24px;
height: 24px;
align-items: center;
justify-content: center;
padding: 0;
border-radius: 5px;
background: rgb(255 255 255 / 94%);
color: #7062e9;
font-size: 16px;
}
.sku-image-remove.ant-btn {
position: absolute;
top: 6px;
right: 6px;
z-index: 2;
width: 24px;
height: 24px;
min-width: 24px;
padding: 0;
border-radius: 5px;
background: rgb(255 255 255 / 94%);
color: #26232d;
}
.sku-image-remove.ant-btn:hover {
background: rgb(255 255 255 / 94%) !important;
color: #c34456 !important;
}
.sku-image-card-actions {
position: absolute;
right: 0;
bottom: 0;
left: 0;
display: flex;
justify-content: space-between;
align-items: center;
min-height: 34px;
padding: 0 4px;
background: rgb(36 34 44 / 68%);
}
.sku-image-card-actions .ant-btn {
color: #fff;
}
.sku-image-card-actions .ant-btn:hover {
color: #fff !important;
background: rgb(255 255 255 / 14%) !important;
}
.sku-image-selected-card.is-cover .sku-image-card-actions {
min-height: 42px;
}
.sku-image-editor-right .ant-tabs-nav {
margin: 0 0 16px;
}
.sku-image-editor-right .ant-tabs-tab {
padding: 9px 0;
}
.sku-image-collection-collapse {
margin: 0 2px 12px 0;
}
.sku-image-collection-collapse .ant-collapse-content-box {
padding: 12px !important;
}
.sku-image-right-tab {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
}
.sku-image-network-controls,
.sku-image-upload-controls {
display: flex;
flex: 0 0 auto;
align-items: flex-start;
gap: 10px;
margin: 0 2px 16px 0;
}
.sku-image-network-controls {
flex-direction: column;
}
.sku-image-network-textarea.ant-input {
height: 164px;
line-height: 22px;
resize: none;
}
.sku-image-upload-controls {
align-items: center;
flex-wrap: wrap;
}
.sku-image-upload-controls > span {
color: #8d879e;
font-size: 12px;
}
.sku-image-right-gallery {
min-height: 0;
flex: 1 1 auto;
overflow-y: auto;
scrollbar-color: #d1ccd9 transparent;
scrollbar-width: thin;
}
@media (max-width: 860px) {
.sku-image-editor-columns {
grid-template-columns: 1fr;
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
gap: 22px;
}
.sku-image-editor-left {
padding-right: 0;
padding-bottom: 22px;
border-right: 0;
border-bottom: 1px solid #ece9f1;
}
}
@media (max-width: 500px) {
.sku-image-editor-modal .ant-modal-body {
padding: 0 14px 14px;
}
.sku-image-selected-grid,
.sku-image-source-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.sku-image-editor-toolbar {
flex-wrap: wrap;
}
}
@@ -0,0 +1,385 @@
import { useEffect, useRef, useState } from 'react';
import { App as AntdApp, Button, Dropdown, Empty, Image, Modal } from 'antd';
import type { MenuProps } from 'antd';
import { CheckOutlined, DownOutlined, UploadOutlined } from '@ant-design/icons';
import type { CollectionItemInspection } from '@atlas/contracts';
import { apiErrorMessage } from '@/services/api';
import { SkuImageCard, type SkuEditorImage } from './SkuImageCard';
import { SkuImageSourceTabs } from './SkuImageSourceTabs';
import { uploadFileError, useSkuImageLibrary } from './useSkuImageLibrary';
import './SkuImageEditorModal.css';
type CollectionImage = CollectionItemInspection['asset_transfers'][number];
export type { SkuEditorImage } from './SkuImageCard';
type Props = {
open: boolean;
skuKey: string;
skuName: string;
initialImages: SkuEditorImage[];
collectionImages: CollectionImage[];
onCancel: () => void;
onConfirm: (images: SkuEditorImage[]) => Promise<void>;
onUploadFile: (file: File) => Promise<SkuEditorImage>;
};
const MAX_IMAGES = 30;
function downloadImage(image: SkuEditorImage) {
const link = document.createElement('a');
link.href = image.url;
link.download = image.label || 'sku-image';
link.target = '_blank';
link.rel = 'noopener noreferrer';
document.body.appendChild(link);
link.click();
link.remove();
}
function sameImage(left: SkuEditorImage, right: SkuEditorImage) {
return left.id === right.id || !!(left.assetId && left.assetId === right.assetId);
}
/** 图片选择弹窗管理交互状态;素材上传与草稿写入由外层服务完成。 */
export function SkuImageEditorModal({
open,
skuKey,
skuName,
initialImages,
collectionImages,
onCancel,
onConfirm,
onUploadFile,
}: Props) {
const { message } = AntdApp.useApp();
const [images, setImages] = useState<SkuEditorImage[]>(initialImages);
const [markedIds, setMarkedIds] = useState<string[]>([]);
const [activeTab, setActiveTab] = useState('collection');
const [confirming, setConfirming] = useState(false);
const {
networkUrls,
setNetworkUrls,
networkAdding,
networkImages,
uploadedImages,
uploading,
uploadFile,
uploadToRight,
addNetworkImages,
} = useSkuImageLibrary(open, skuKey, images, onUploadFile);
const [preview, setPreview] = useState<{ images: SkuEditorImage[]; index: number } | null>(
null
);
const fileInput = useRef<HTMLInputElement>(null);
const dragIndex = useRef<number | null>(null);
useEffect(() => {
if (!open) return;
setImages(initialImages);
setMarkedIds([]);
setActiveTab('collection');
setPreview(null);
}, [open, skuKey]);
const addImage = (image: SkuEditorImage) => {
const existing = images.find((item) => sameImage(item, image));
if (existing) {
setImages((current) => current.filter((item) => item.id !== existing.id));
setMarkedIds((current) => current.filter((id) => id !== existing.id));
return;
}
if (images.length >= MAX_IMAGES) {
message.warning(`每个 SKU 最多选择 ${MAX_IMAGES} 张图片`);
return;
}
setImages((current) => [...current, image]);
};
const removeImages = (ids: string[]) => {
setImages((current) => current.filter((image) => !ids.includes(image.id)));
setMarkedIds((current) => current.filter((id) => !ids.includes(id)));
};
const setCover = (image: SkuEditorImage) => {
if (!images.some((item) => sameImage(item, image)) && images.length >= MAX_IMAGES) {
message.warning(`每个 SKU 最多选择 ${MAX_IMAGES} 张图片`);
return;
}
setImages((current) => {
const selected = current.find((item) => sameImage(item, image)) ?? image;
return [selected, ...current.filter((item) => !sameImage(item, image))];
});
};
const markImage = (id: string) =>
setMarkedIds((current) =>
current.includes(id) ? current.filter((item) => item !== id) : [...current, id]
);
const pendingAction = (label: string) => message.info(`${label}将在后续接入图片处理服务`);
const onBatchAction: MenuProps['onClick'] = ({ key }) => {
if (!markedIds.length) {
message.info('请先点击左侧图片进行选择');
return;
}
if (key === 'delete') removeImages(markedIds);
else if (key === 'download')
images.filter((image) => markedIds.includes(image.id)).forEach(downloadImage);
else pendingAction(String(key));
};
const batchMenu: MenuProps = {
onClick: onBatchAction,
items: [
{ key: '添加水印', label: '添加水印' },
{ key: '一键 3:4', label: '一键 3:4' },
{ key: '批量翻译', label: '批量翻译' },
{ key: 'download', label: '下载' },
{ type: 'divider' },
{ key: 'delete', label: '移出已选', danger: true },
],
};
const imageMenu = (image: SkuEditorImage): MenuProps => ({
onClick: ({ key, domEvent }) => {
domEvent.stopPropagation();
if (key === 'cover') setCover(image);
else if (key === 'download') downloadImage(image);
else pendingAction(String(key));
},
items: [
{ key: 'AI 编辑', label: 'AI 编辑' },
{ key: '添加水印', label: '添加水印' },
{ key: '一键 3:4', label: '一键 3:4' },
{ key: 'cover', label: '设为封图' },
{ key: 'download', label: '下载' },
],
});
const uploadFiles = async (files: FileList | null) => {
if (!files) return;
const available = MAX_IMAGES - images.length;
const accepted = Array.from(files).filter((file) => {
const error = uploadFileError(file);
if (error) message.warning(error);
return !error;
});
if (accepted.length > available) message.warning(`每个 SKU 最多选择 ${MAX_IMAGES} 张图片`);
for (const file of accepted.slice(0, available)) {
try {
const image = await uploadFile(file);
setImages((current) =>
current.some((item) => item.assetId === image.assetId)
? current
: [...current, image]
);
} catch (error) {
message.error(`${file.name} 上传失败:${apiErrorMessage(error)}`);
}
}
if (fileInput.current) fileInput.current.value = '';
};
const sourceImages = (role: string): SkuEditorImage[] =>
collectionImages
.filter((image) => image.role === role)
.map((image) => ({
id: `collection:${image.id}`,
url: image.source_url,
label:
image.role === 'source_main'
? '采集主图'
: image.role === 'source_sku'
? '采集 SKU 图'
: '采集详情图',
origin: 'collection' as const,
assetId: image.asset_id ?? undefined,
transferId: image.id,
}));
const showPreview = (items: SkuEditorImage[], index: number) =>
setPreview({ images: items, index });
const renderSourceImages = (items: SkuEditorImage[]) =>
items.length ? (
<div className="sku-image-source-grid">
{items.map((image, index) => {
const selected = images.some((item) => sameImage(item, image));
return (
<SkuImageCard
key={image.id}
image={image}
mode="source"
selected={selected}
menu={imageMenu(image)}
onToggle={() => addImage(image)}
onPreview={() => showPreview(items, index)}
/>
);
})}
</div>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无图片" />
);
const confirmSelection = async () => {
setConfirming(true);
try {
await onConfirm(images);
} catch (error) {
message.error(apiErrorMessage(error));
} finally {
setConfirming(false);
}
};
return (
<>
<Modal
className="sku-image-editor-modal"
open={open}
title={
<div>
<strong>SKU 图片选择</strong>
<span>{skuName}</span>
</div>
}
width="min(1480px, 96vw)"
onCancel={() => {
if (!confirming) onCancel();
}}
closable={!confirming}
maskClosable={!confirming}
footer={[
<Button key="cancel" onClick={onCancel} disabled={confirming}>
取消
</Button>,
<Button
key="confirm"
type="primary"
loading={confirming}
disabled={uploading}
onClick={() => void confirmSelection()}
>
确认
</Button>,
]}
centered
>
<div className="sku-image-editor-note">
第一张为主图。确认后将选图写入当前编辑草稿,点击页面“保存”后持久化。
</div>
<div className="sku-image-editor-columns">
<section className="sku-image-editor-left" aria-label="已选 SKU 图片">
<div className="sku-image-editor-toolbar">
<input
ref={fileInput}
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
hidden
onChange={(event) => void uploadFiles(event.target.files)}
/>
<Button
type="primary"
icon={<UploadOutlined />}
loading={uploading}
onClick={() => fileInput.current?.click()}
>
上传图片
</Button>
<Button
type="text"
icon={
images.length > 0 && markedIds.length === images.length ? (
<CheckOutlined />
) : undefined
}
onClick={() =>
setMarkedIds(
markedIds.length === images.length
? []
: images.map((image) => image.id)
)
}
disabled={!images.length}
>
全选
</Button>
<Dropdown menu={batchMenu} trigger={['click']}>
<Button>
批量操作 <DownOutlined />
</Button>
</Dropdown>
</div>
<div className="sku-image-editor-summary">
<strong>
已选图片({images.length} / {MAX_IMAGES})
</strong>
<span>第一张为主图,拖动可调整顺序</span>
</div>
<div className="sku-image-editor-scroll">
{images.length ? (
<div className="sku-image-selected-grid">
{images.map((image, index) => (
<SkuImageCard
key={image.id}
image={image}
mode="selected"
selected={markedIds.includes(image.id)}
cover={index === 0}
menu={imageMenu(image)}
onToggle={() => markImage(image.id)}
onPreview={() => showPreview(images, index)}
onRemove={() => removeImages([image.id])}
onDragStart={() => {
dragIndex.current = index;
}}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault();
const from = dragIndex.current;
if (from === null || from === index) return;
setImages((current) => {
const next = [...current];
const [moved] = next.splice(from, 1);
next.splice(index, 0, moved);
return next;
});
dragIndex.current = null;
}}
/>
))}
</div>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="暂无已选图片,从右侧选择或上传"
/>
)}
</div>
</section>
<section className="sku-image-editor-right" aria-label="可选图片">
<SkuImageSourceTabs
activeTab={activeTab}
onTabChange={setActiveTab}
sourceImages={sourceImages}
renderImages={renderSourceImages}
networkImages={networkImages}
networkUrls={networkUrls}
onNetworkUrlsChange={setNetworkUrls}
networkAdding={networkAdding}
onAddNetworkImages={() => void addNetworkImages()}
uploadedImages={uploadedImages}
uploading={uploading}
onUploadFile={uploadToRight}
/>
</section>
</div>
</Modal>
<Image.PreviewGroup
items={preview?.images.map((image) => image.url) ?? []}
preview={{
open: !!preview,
current: preview?.index ?? 0,
onOpenChange: (visible) => {
if (!visible) setPreview(null);
},
onChange: (current) =>
setPreview((previous) =>
previous ? { ...previous, index: current } : null
),
}}
/>
</>
);
}
@@ -0,0 +1,146 @@
import type { ReactNode } from 'react';
import { Button, Collapse, Empty, Input, Tabs, Upload } from 'antd';
import { UploadOutlined } from '@ant-design/icons';
import type { SkuEditorImage } from './SkuImageCard';
const NETWORK_PLACEHOLDER = `请输入图片链接,每行一个(仅支持 JPG、PNG 格式):
https://example.com/image1.jpg
https://example.com/image2.png
• 请确保图片链接可以正常访问
• 建议图片大小不超过 5MB
• 自动过滤重复和无效链接`;
type Props = {
activeTab: string;
onTabChange: (tab: string) => void;
sourceImages: (role: string) => SkuEditorImage[];
renderImages: (images: SkuEditorImage[]) => ReactNode;
networkImages: SkuEditorImage[];
networkUrls: string;
onNetworkUrlsChange: (value: string) => void;
networkAdding: boolean;
onAddNetworkImages: () => void;
uploadedImages: SkuEditorImage[];
uploading: boolean;
onUploadFile: (file: File) => Promise<false | typeof Upload.LIST_IGNORE>;
};
/** 图片来源分区只负责展示和输入,选中及素材状态由弹窗控制。 */
export function SkuImageSourceTabs({
activeTab,
onTabChange,
sourceImages,
renderImages,
networkImages,
networkUrls,
onNetworkUrlsChange,
networkAdding,
onAddNetworkImages,
uploadedImages,
uploading,
onUploadFile,
}: Props) {
return (
<Tabs
activeKey={activeTab}
onChange={onTabChange}
items={[
{
key: 'ai',
label: 'AI 生图记录',
children: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="暂无 AI 生图记录"
/>
),
},
{
key: 'collection',
label: '采集图片',
children: (
<Collapse
className="sku-image-collection-collapse"
defaultActiveKey={['source_main', 'source_sku', 'source_detail']}
items={[
{
key: 'source_main',
label: '主图',
children: renderImages(sourceImages('source_main')),
},
{
key: 'source_sku',
label: 'SKU 图',
children: renderImages(sourceImages('source_sku')),
},
{
key: 'source_detail',
label: '详情图',
children: renderImages(sourceImages('source_detail')),
},
]}
/>
),
},
{
key: 'network',
label: '网络图片',
children: (
<div className="sku-image-right-tab">
<div className="sku-image-network-controls">
<Input.TextArea
className="sku-image-network-textarea"
rows={7}
placeholder={NETWORK_PLACEHOLDER}
value={networkUrls}
onChange={(event) => onNetworkUrlsChange(event.target.value)}
aria-label="网络图片链接,每行一个"
/>
<Button
type="primary"
loading={networkAdding}
disabled={!networkUrls.trim()}
onClick={onAddNetworkImages}
>
添加图片链接
</Button>
</div>
<div className="sku-image-right-gallery">
{renderImages(networkImages)}
</div>
</div>
),
},
{
key: 'upload',
label: '上传图片',
children: (
<div className="sku-image-right-tab">
<div className="sku-image-upload-controls">
<Upload
accept="image/jpeg,image/png,image/webp"
multiple
showUploadList={false}
beforeUpload={onUploadFile}
>
<Button
type="primary"
icon={<UploadOutlined />}
loading={uploading}
>
上传图片
</Button>
</Upload>
<span>图片先放在右侧,点击图片后加入左侧已选区</span>
</div>
<div className="sku-image-right-gallery">
{renderImages(uploadedImages)}
</div>
</div>
),
},
]}
/>
);
}
@@ -0,0 +1,58 @@
import type { CollectionItemInspection } from '@atlas/contracts';
import { collectionApi } from '@/services/collection';
import type { SkuEditorImage } from './SkuImageCard';
const wait = (milliseconds: number) =>
new Promise<void>((resolve) => window.setTimeout(resolve, milliseconds));
/** 确认时转存已选采集图;草稿只接受已校验的正式素材 ID。 */
export async function resolveSkuImageSelection(
images: SkuEditorImage[],
collectionItemId: string | null
): Promise<string[]> {
const transferIds = [
...new Set(
images
.filter((image) => !image.assetId && image.transferId)
.map((image) => image.transferId!)
),
];
const resolved = new Map<string, string>();
if (transferIds.length) {
if (!collectionItemId) throw new Error('缺少采集记录,无法转存所选图片');
const current = await collectionApi.inspection(collectionItemId);
const pending = transferIds.filter(
(id) =>
current.asset_transfers.find((item) => item.id === id)?.status ===
'pending_selection'
);
if (pending.length) {
await collectionApi.materializeAssets(collectionItemId, { transfer_ids: pending });
}
for (let attempt = 0; attempt < 45; attempt += 1) {
const inspection = await collectionApi.inspection(collectionItemId);
for (const id of transferIds) {
const transfer = inspection.asset_transfers.find((item) => item.id === id);
if (transfer?.status === 'failed') {
throw new Error('采集图片转存失败,请稍后重试');
}
if (transfer?.status === 'succeeded' && transfer.asset_id) {
resolved.set(id, transfer.asset_id);
}
}
if (resolved.size === transferIds.length) break;
await wait(1_000);
}
if (resolved.size !== transferIds.length) {
throw new Error('采集图片仍在转存,请稍后重新确认');
}
}
const assetIds = images.map(
(image) => image.assetId ?? (image.transferId ? resolved.get(image.transferId) : undefined)
);
if (assetIds.some((id) => !id)) throw new Error('有图片尚未转存,无法写入草稿');
if (new Set(assetIds).size !== assetIds.length) {
throw new Error('所选图片包含重复素材,请移除重复项');
}
return assetIds as string[];
}
@@ -0,0 +1,215 @@
import { useEffect, useRef, useState } from 'react';
import { App as AntdApp, Upload } from 'antd';
import { apiErrorMessage } from '@/services/api';
import { mediaApi } from '@/services/media';
import type { SkuEditorImage } from './SkuImageCard';
export function uploadFileError(file: File) {
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
return `${file.name} 不是 JPG、PNG 或 WebP 图片`;
}
if (file.size > 20 * 1024 * 1024) return `${file.name} 超过 20 MB`;
return null;
}
function networkImageUrl(value: string) {
try {
const url = new URL(value);
if (url.protocol !== 'https:' || url.username || url.password || url.port) return null;
if (!/\.(?:jpe?g|png)$/i.test(url.pathname)) return null;
url.hash = '';
return url.toString();
} catch {
return null;
}
}
/** 工作空间候选图库:网络导入、上传、重开弹窗后的短时预览刷新。 */
export function useSkuImageLibrary(
open: boolean,
skuKey: string,
selectedImages: SkuEditorImage[],
onUploadFile: (file: File) => Promise<SkuEditorImage>
) {
const { message } = AntdApp.useApp();
const [networkUrls, setNetworkUrls] = useState('');
const [networkAdding, setNetworkAdding] = useState(false);
const [networkImages, setNetworkImages] = useState<SkuEditorImage[]>([]);
const [uploadedImages, setUploadedImages] = useState<SkuEditorImage[]>([]);
const [uploadCount, setUploadCount] = useState(0);
const fingerprints = useRef(new Set<string>());
const networkRequest = useRef(0);
useEffect(() => {
networkRequest.current += 1;
setNetworkUrls('');
setNetworkAdding(false);
}, [open, skuKey]);
useEffect(() => {
if (!open) return;
let active = true;
const refresh = () =>
void mediaApi
.listProductUploads()
.then((uploads) => {
if (!active) return;
const loaded = uploads.map((asset) => ({
id: `asset:${asset.id}`,
url: asset.preview_url,
label: '上传图片',
origin: 'upload' as const,
assetId: asset.id,
}));
setUploadedImages((current) => [
...current.filter(
(image) => !loaded.some((item) => item.assetId === image.assetId)
),
...loaded,
]);
})
.catch((error) => {
if (active) message.warning(`上传图片列表加载失败:${apiErrorMessage(error)}`);
});
refresh();
const interval = window.setInterval(refresh, 8 * 60_000);
return () => {
active = false;
window.clearInterval(interval);
};
}, [open, message]);
useEffect(() => {
if (!open) return;
let active = true;
const refresh = () =>
void mediaApi
.listProductImports()
.then((imports) => {
if (!active) return;
const loaded = imports.map((asset) => ({
id: `asset:${asset.id}`,
url: asset.preview_url,
label: new URL(asset.source_url).pathname.split('/').pop() || '网络图片',
origin: 'network' as const,
assetId: asset.id,
sourceUrl: asset.source_url,
}));
setNetworkImages((current) => [
...current.filter(
(image) => !loaded.some((item) => item.assetId === image.assetId)
),
...loaded,
]);
})
.catch((error) => {
if (active) message.warning(`网络图片列表加载失败:${apiErrorMessage(error)}`);
});
refresh();
const interval = window.setInterval(refresh, 8 * 60_000);
return () => {
active = false;
window.clearInterval(interval);
};
}, [open, message]);
const uploadFile = async (file: File) => {
setUploadCount((count) => count + 1);
try {
return await onUploadFile(file);
} finally {
setUploadCount((count) => count - 1);
}
};
const uploadToRight = async (file: File) => {
const error = uploadFileError(file);
if (error) {
message.warning(error);
return Upload.LIST_IGNORE;
}
const fingerprint = `${file.name}:${file.size}:${file.lastModified}`;
if (fingerprints.current.has(fingerprint)) {
message.info(`${file.name} 已在上传图片列表中`);
return Upload.LIST_IGNORE;
}
fingerprints.current.add(fingerprint);
try {
const image = await uploadFile(file);
setUploadedImages((current) =>
current.some((item) => item.assetId === image.assetId)
? current
: [image, ...current]
);
} catch (uploadError) {
fingerprints.current.delete(fingerprint);
message.error(`${file.name} 上传失败:${apiErrorMessage(uploadError)}`);
}
return Upload.LIST_IGNORE;
};
const addNetworkImages = async () => {
if (!networkUrls.trim() || networkAdding) return;
const request = ++networkRequest.current;
const seen = new Set([
...networkImages.map((image) => image.sourceUrl ?? image.url),
...selectedImages.map((image) => image.sourceUrl ?? image.url),
]);
const candidates: string[] = [];
let skipped = 0;
for (const line of networkUrls
.split(/\r?\n/)
.map((item) => item.trim())
.filter(Boolean)) {
const url = networkImageUrl(line);
if (!url || seen.has(url)) {
skipped += 1;
continue;
}
seen.add(url);
candidates.push(url);
}
if (!candidates.length) {
message.warning('没有可添加的 JPG 或 PNG 图片链接');
return;
}
setNetworkAdding(true);
const results = await Promise.allSettled(
candidates.map((url) => mediaApi.importProductImage({ url }))
);
if (request !== networkRequest.current) return;
const valid = results.flatMap((result) =>
result.status === 'fulfilled' ? [result.value] : []
);
skipped += results.length - valid.length;
setNetworkImages((current) => {
const added = valid.map((asset) => ({
id: `asset:${asset.id}`,
url: asset.preview_url,
label: new URL(asset.source_url).pathname.split('/').pop() || '网络图片',
origin: 'network' as const,
assetId: asset.id,
sourceUrl: asset.source_url,
}));
return [
...added,
...current.filter((image) => !added.some((item) => item.id === image.id)),
];
});
setNetworkUrls('');
setNetworkAdding(false);
if (valid.length) {
message.success(
`已导入 ${valid.length} 张网络图片${skipped ? `,过滤 ${skipped} 条重复或无效链接` : ''}`
);
} else message.warning(`未找到可访问的图片链接,已过滤 ${skipped} 条`);
};
return {
networkUrls,
setNetworkUrls,
networkAdding,
networkImages,
uploadedImages,
uploading: uploadCount > 0,
uploadFile,
uploadToRight,
addNetworkImages,
};
}
+22 -1
View File
@@ -3,6 +3,9 @@ import type {
MediaAccessUrl,
MediaAsset,
MediaUploadIntent,
ProductUploadImage,
ProductImportedImage,
ImportProductImageRequest,
} from '@atlas/contracts';
import { api } from '@/services/api';
@@ -12,6 +15,12 @@ export const WATERMARK_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
/** 异步操作查询,契约见 docs/api/conventions.md §5(GET /api/operations/{operation_id}) */
export const mediaApi = {
listProductUploads: () => api.get<ProductUploadImage[]>('/media-assets/product-uploads'),
listProductImports: () => api.get<ProductImportedImage[]>('/media-assets/product-imports'),
importProductImage: (payload: ImportProductImageRequest) =>
api.post<ProductImportedImage>('/media-assets/product-imports', payload, {
headers: { 'Idempotency-Key': crypto.randomUUID() },
}),
createUploadIntent: (payload: CreateMediaUploadIntentRequest) =>
api.post<MediaUploadIntent>('/media/upload-intents', payload, {
headers: { 'Idempotency-Key': crypto.randomUUID() },
@@ -47,9 +56,21 @@ export async function sha256Hex(file: File): Promise<string> {
* 返回已通过校验的素材,可直接用于图片水印模板的 image_asset_id。
*/
export async function uploadWatermarkImage(file: File): Promise<MediaAsset> {
return uploadImage(file, 'watermark_image');
}
/** 商品图片沿用素材直传协议,完成服务端校验后才可写入 SKU 草稿。 */
export async function uploadProductImage(file: File): Promise<MediaAsset> {
return uploadImage(file, 'product_image');
}
async function uploadImage(
file: File,
purpose: CreateMediaUploadIntentRequest['purpose']
): Promise<MediaAsset> {
const checksumSha256 = await sha256Hex(file);
const intent = await mediaApi.createUploadIntent({
purpose: 'watermark_image',
purpose,
filename: file.name,
content_type: file.type as CreateMediaUploadIntentRequest['content_type'],
byte_size: file.size,