feat: 完成 SKU 图片选择及素材导入后端
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Headers,
|
Headers,
|
||||||
Inject,
|
Inject,
|
||||||
|
Get,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Req,
|
Req,
|
||||||
@@ -17,6 +18,7 @@ import {
|
|||||||
completeMediaUploadIntentRequestSchema,
|
completeMediaUploadIntentRequestSchema,
|
||||||
createMediaAccessUrlRequestSchema,
|
createMediaAccessUrlRequestSchema,
|
||||||
createMediaUploadIntentRequestSchema,
|
createMediaUploadIntentRequestSchema,
|
||||||
|
importProductImageRequestSchema,
|
||||||
type Workspace,
|
type Workspace,
|
||||||
} from '@atlas/contracts';
|
} from '@atlas/contracts';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
@@ -33,6 +35,34 @@ export class MediaController {
|
|||||||
@Inject(MediaService) private readonly media: MediaService,
|
@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')
|
@Post('media/upload-intents')
|
||||||
@ApiCreatedResponse({ description: '已创建一次性预签名上传意图' })
|
@ApiCreatedResponse({ description: '已创建一次性预签名上传意图' })
|
||||||
public async createIntent(
|
public async createIntent(
|
||||||
|
|||||||
@@ -12,15 +12,19 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||||||
import type {
|
import type {
|
||||||
CompleteMediaUploadIntentRequest,
|
CompleteMediaUploadIntentRequest,
|
||||||
CreateMediaUploadIntentRequest,
|
CreateMediaUploadIntentRequest,
|
||||||
|
ImportProductImageRequest,
|
||||||
} from '@atlas/contracts';
|
} from '@atlas/contracts';
|
||||||
import type { Database, DatabaseTransaction } from '@atlas/database';
|
import type { Database, DatabaseTransaction } from '@atlas/database';
|
||||||
import { ApiError } from '../common/api-error.js';
|
import { ApiError } from '../common/api-error.js';
|
||||||
import { IdempotencyService } from '../common/idempotency.service.js';
|
import { IdempotencyService } from '../common/idempotency.service.js';
|
||||||
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
import { APP_CONFIG, type AppConfig } from '../config/app-config.module.js';
|
||||||
import { DATABASE } from '../database/database.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 = {
|
type IntentRow = {
|
||||||
id: string;
|
id: string;
|
||||||
|
purpose: 'watermark_image' | 'product_image';
|
||||||
storage_provider: string;
|
storage_provider: string;
|
||||||
bucket: string;
|
bucket: string;
|
||||||
object_key: string;
|
object_key: string;
|
||||||
@@ -80,16 +84,17 @@ export class MediaService {
|
|||||||
async (tx) => {
|
async (tx) => {
|
||||||
await this.ensureBucket();
|
await this.ensureBucket();
|
||||||
const id = randomUUID();
|
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);
|
const expiresAt = new Date(Date.now() + 10 * 60_000);
|
||||||
await tx.query(
|
await tx.query(
|
||||||
`INSERT INTO atlas.media_upload_intents
|
`INSERT INTO atlas.media_upload_intents
|
||||||
(id, workspace_id, purpose, storage_provider, bucket, object_key, expected_content_type,
|
(id, workspace_id, purpose, storage_provider, bucket, object_key, expected_content_type,
|
||||||
expected_byte_size, expected_checksum_sha256, expires_at, created_by_user_id)
|
expected_byte_size, expected_checksum_sha256, expires_at, created_by_user_id)
|
||||||
VALUES ($1, current_setting('app.workspace_id')::uuid, 'watermark_image', 'local_minio',
|
VALUES ($1, current_setting('app.workspace_id')::uuid, $2, 'local_minio',
|
||||||
$2, $3, $4, $5, $6, $7, $8)`,
|
$3, $4, $5, $6, $7, $8, $9)`,
|
||||||
[
|
[
|
||||||
id,
|
id,
|
||||||
|
input.purpose,
|
||||||
this.config.OBJECT_STORAGE_BUCKET,
|
this.config.OBJECT_STORAGE_BUCKET,
|
||||||
objectKey,
|
objectKey,
|
||||||
input.content_type,
|
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(
|
private async lockIntent(
|
||||||
tx: DatabaseTransaction,
|
tx: DatabaseTransaction,
|
||||||
intentId: string,
|
intentId: string,
|
||||||
): Promise<IntentRow> {
|
): Promise<IntentRow> {
|
||||||
const rows = await tx.query<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
|
expected_checksum_sha256, status, expires_at, asset_id
|
||||||
FROM atlas.media_upload_intents WHERE id = $1 FOR UPDATE`,
|
FROM atlas.media_upload_intents WHERE id = $1 FOR UPDATE`,
|
||||||
[intentId],
|
[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(
|
throw new ApiError(
|
||||||
422,
|
422,
|
||||||
'UPLOAD_VALIDATION_FAILED',
|
'UPLOAD_VALIDATION_FAILED',
|
||||||
@@ -370,7 +514,7 @@ export class MediaService {
|
|||||||
for await (const chunk of object.Body as AsyncIterable<Uint8Array>) {
|
for await (const chunk of object.Body as AsyncIterable<Uint8Array>) {
|
||||||
byteSize += chunk.byteLength;
|
byteSize += chunk.byteLength;
|
||||||
// HeadObject 和 GetObject 之间对象可能被替换;读取时也必须有硬上限。
|
// HeadObject 和 GetObject 之间对象可能被替换;读取时也必须有硬上限。
|
||||||
if (byteSize > 5 * 1024 * 1024) {
|
if (byteSize > byteLimit) {
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
422,
|
422,
|
||||||
'UPLOAD_VALIDATION_FAILED',
|
'UPLOAD_VALIDATION_FAILED',
|
||||||
@@ -380,7 +524,7 @@ export class MediaService {
|
|||||||
chunks.push(Buffer.from(chunk));
|
chunks.push(Buffer.from(chunk));
|
||||||
}
|
}
|
||||||
const bytes = Buffer.concat(chunks, byteSize);
|
const bytes = Buffer.concat(chunks, byteSize);
|
||||||
const detected = detectImage(bytes);
|
const detected = detectImage(bytes, intent.purpose === 'product_image' ? 10_000 : 8192);
|
||||||
if (
|
if (
|
||||||
!detected ||
|
!detected ||
|
||||||
detected.contentType !== intent.expected_content_type
|
detected.contentType !== intent.expected_content_type
|
||||||
@@ -458,80 +602,3 @@ function apiErrorCode(error: unknown): string | undefined {
|
|||||||
? apiError.code
|
? apiError.code
|
||||||
: undefined;
|
: 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: [] };
|
: { 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>>();
|
const candidateVariants = new Map<string, Set<string | null>>();
|
||||||
for (const candidate of candidates.rows) {
|
for (const candidate of candidates.rows) {
|
||||||
const variants =
|
const variants =
|
||||||
@@ -1416,6 +1433,8 @@ export class ProductEditorService {
|
|||||||
);
|
);
|
||||||
if (existingByVariant.get(variant.variantId)?.has(assetId))
|
if (existingByVariant.get(variant.variantId)?.has(assetId))
|
||||||
continue;
|
continue;
|
||||||
|
if (uploadedIds.has(assetId)) continue;
|
||||||
|
if (importedIds.has(assetId)) continue;
|
||||||
const allowedFor = candidateVariants.get(assetId);
|
const allowedFor = candidateVariants.get(assetId);
|
||||||
if (
|
if (
|
||||||
!allowedFor?.has(null) &&
|
!allowedFor?.has(null) &&
|
||||||
@@ -1424,7 +1443,7 @@ export class ProductEditorService {
|
|||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
422,
|
422,
|
||||||
'LISTING_MEDIA_NOT_SELECTABLE',
|
'LISTING_MEDIA_NOT_SELECTABLE',
|
||||||
'只能选择当前采集快照中该 SKU 或商品通用候选已转存的图片',
|
'只能选择已上传、已导入或当前采集快照中该 SKU 可用的已转存图片',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -132,6 +132,14 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
'DELETE FROM atlas.collection_asset_transfers WHERE workspace_id = $1',
|
'DELETE FROM atlas.collection_asset_transfers WHERE workspace_id = $1',
|
||||||
[workspaceId],
|
[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(
|
await tx.query(
|
||||||
'DELETE FROM atlas.product_variants WHERE workspace_id = $1',
|
'DELETE FROM atlas.product_variants WHERE workspace_id = $1',
|
||||||
[workspaceId],
|
[workspaceId],
|
||||||
@@ -1900,6 +1908,81 @@ describe('商品编辑 M3.0–M3.3 HTTP 集成测试', () => {
|
|||||||
after.body.data.variants[0].media.map((item: any) => item.asset_id),
|
after.body.data.variants[0].media.map((item: any) => item.asset_id),
|
||||||
[asset],
|
[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 () => {
|
it('独立保存采集描述会持久化覆盖值并保持幂等', async () => {
|
||||||
|
|||||||
@@ -1062,7 +1062,7 @@ const ProductEditPage = () => {
|
|||||||
modal.info({
|
modal.info({
|
||||||
title: `${name}将在下一阶段接入`,
|
title: `${name}将在下一阶段接入`,
|
||||||
content:
|
content:
|
||||||
'本轮只实现前端工作台。生成结果会以候选形式展示,必须由用户确认才会写入草稿,不会自动覆盖当前内容。',
|
'当前已支持 SKU 图片上传、网络导入和采集图转存。AI 生图与图片编辑将在后续接入。',
|
||||||
okText: '知道了',
|
okText: '知道了',
|
||||||
});
|
});
|
||||||
const validSupplyUrl =
|
const validSupplyUrl =
|
||||||
@@ -1419,7 +1419,7 @@ const ProductEditPage = () => {
|
|||||||
onCompareCopy={(field) => setComparison({ variantId: null, field })}
|
onCompareCopy={(field) => setComparison({ variantId: null, field })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ProductVariantSection
|
<ProductVariantSection
|
||||||
form={form}
|
form={form}
|
||||||
snapshot={snapshot}
|
snapshot={snapshot}
|
||||||
collectionRecord={collectionRecord}
|
collectionRecord={collectionRecord}
|
||||||
@@ -1444,6 +1444,7 @@ const ProductEditPage = () => {
|
|||||||
skuCopyTaskId.current = null;
|
skuCopyTaskId.current = null;
|
||||||
}}
|
}}
|
||||||
onCompareCopy={(variantId, field) => setComparison({ variantId, field })}
|
onCompareCopy={(variantId, field) => setComparison({ variantId, field })}
|
||||||
|
onImagesChanged={() => setDirty(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ProductOtherSection
|
<ProductOtherSection
|
||||||
|
|||||||
@@ -205,6 +205,12 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
border-top: 1px dashed #d9d4df;
|
border-top: 1px dashed #d9d4df;
|
||||||
}
|
}
|
||||||
|
.sku-media-local-note {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #8d789c;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
.sku-media-grid {
|
.sku-media-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
/* 每行固定八个图片位;主图跨两列、两行,其余图片按单格自动续排。 */
|
/* 每行固定八个图片位;主图跨两列、两行,其余图片按单格自动续排。 */
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -30,6 +30,9 @@ import type {
|
|||||||
} from '@atlas/contracts';
|
} from '@atlas/contracts';
|
||||||
import { SectionTitle } from './SectionTitle';
|
import { SectionTitle } from './SectionTitle';
|
||||||
import { DynamicAttributeFields } from './DynamicAttributeFields';
|
import { DynamicAttributeFields } from './DynamicAttributeFields';
|
||||||
|
import { SkuImageEditorModal, type SkuEditorImage } from './SkuImageEditorModal';
|
||||||
|
import { resolveSkuImageSelection } from './skuImageSelection';
|
||||||
|
import { mediaApi, uploadProductImage } from '@/services/media';
|
||||||
import './ProductVariantSection.css';
|
import './ProductVariantSection.css';
|
||||||
|
|
||||||
type EditorFormValues = SaveProductEditorDraftRequest;
|
type EditorFormValues = SaveProductEditorDraftRequest;
|
||||||
@@ -131,6 +134,7 @@ type Props = {
|
|||||||
usesVariantTitle: boolean;
|
usesVariantTitle: boolean;
|
||||||
onAiCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void;
|
onAiCopy: (variantId: string, field: 'title' | 'tags' | 'description') => void;
|
||||||
onCompareCopy: (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,
|
usesVariantTitle,
|
||||||
onAiCopy,
|
onAiCopy,
|
||||||
onCompareCopy,
|
onCompareCopy,
|
||||||
|
onImagesChanged,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
// 实时监听变体草稿,驱动已选图片和主图回显。
|
// 实时监听变体草稿,驱动已选图片和主图回显。
|
||||||
const draftVariants = (Form.useWatch('variants', form) ?? []) as VariantValues[];
|
const draftVariants = (Form.useWatch('variants', form) ?? []) as VariantValues[];
|
||||||
// 展开状态按 Form.List 的 field.key(而非下标)记录,增删/复制 SKU 后不会错位。
|
// 展开状态按 Form.List 的 field.key(而非下标)记录,增删/复制 SKU 后不会错位。
|
||||||
const [expandedVariantKeys, setExpandedVariantKeys] = useState<Set<number>>(() => new Set());
|
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 决定。
|
// SKU 变体属性统一放入展开后的“SKU 信息”,必填状态仍由 schema 决定。
|
||||||
const skuInfoAspectDefinitions = activeAttributeSchema
|
const skuInfoAspectDefinitions = activeAttributeSchema
|
||||||
.filter(
|
.filter(
|
||||||
@@ -175,6 +211,7 @@ export function ProductVariantSection({
|
|||||||
definition.is_aspect && !definition.editor_field && isColorNameExpression(definition)
|
definition.is_aspect && !definition.editor_field && isColorNameExpression(definition)
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Card className="editor-section sku-section" bordered={false}>
|
<Card className="editor-section sku-section" bordered={false}>
|
||||||
<SectionTitle
|
<SectionTitle
|
||||||
number="03"
|
number="03"
|
||||||
@@ -237,20 +274,25 @@ export function ProductVariantSection({
|
|||||||
: [];
|
: [];
|
||||||
const selectedAssetIds =
|
const selectedAssetIds =
|
||||||
draftVariants[field.name]?.media_asset_ids ?? [];
|
draftVariants[field.name]?.media_asset_ids ?? [];
|
||||||
const selectedImages = selectedAssetIds
|
const variantKey = sourceId ?? variantValue?.client_key ?? String(field.key);
|
||||||
.map((assetId) => ({
|
const savedImages = selectedAssetIds
|
||||||
assetId,
|
.map((assetId): SkuEditorImage | null => {
|
||||||
url:
|
const candidate = imageCandidates.find(
|
||||||
imageCandidates.find(
|
(item) => item.asset_id === assetId
|
||||||
(candidate) => candidate.asset_id === assetId
|
);
|
||||||
)?.source_url ??
|
const url = candidate?.source_url ?? original?.media.find(
|
||||||
original?.media.find(
|
(media) => media.asset_id === assetId
|
||||||
(media) => media.asset_id === assetId
|
)?.source_url ?? assetPreviewUrls[assetId];
|
||||||
)?.source_url,
|
return url ? {
|
||||||
}))
|
id: candidate ? `collection:${candidate.id}` : `asset:${assetId}`,
|
||||||
.filter((image): image is { assetId: string; url: string } =>
|
url,
|
||||||
Boolean(image.url)
|
label: '已选 SKU 图片',
|
||||||
);
|
origin: 'saved',
|
||||||
|
assetId,
|
||||||
|
} : null;
|
||||||
|
})
|
||||||
|
.filter((image): image is SkuEditorImage => image !== null);
|
||||||
|
const selectedImages = localImagesByVariant[variantKey] ?? savedImages;
|
||||||
const copyVariant = () =>
|
const copyVariant = () =>
|
||||||
add({
|
add({
|
||||||
...variantValue,
|
...variantValue,
|
||||||
@@ -425,11 +467,16 @@ export function ProductVariantSection({
|
|||||||
<Typography.Title level={5}>
|
<Typography.Title level={5}>
|
||||||
SKU 图片
|
SKU 图片
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
|
{Object.hasOwn(localImagesByVariant, variantKey) && (
|
||||||
|
<Typography.Text className="sku-media-local-note">
|
||||||
|
当前图片仅在本页预览,刷新后恢复
|
||||||
|
</Typography.Text>
|
||||||
|
)}
|
||||||
<div className="sku-media-grid">
|
<div className="sku-media-grid">
|
||||||
{selectedImages.map((image, imageIndex) => (
|
{selectedImages.map((image, imageIndex) => (
|
||||||
<div
|
<div
|
||||||
className={`sku-media-item ${imageIndex === 0 ? 'sku-media-item--main' : ''}`}
|
className={`sku-media-item ${imageIndex === 0 ? 'sku-media-item--main' : ''}`}
|
||||||
key={image.assetId}
|
key={image.id}
|
||||||
>
|
>
|
||||||
{imageIndex === 0 && (
|
{imageIndex === 0 && (
|
||||||
<span className="sku-media-main-badge">
|
<span className="sku-media-main-badge">
|
||||||
@@ -439,10 +486,15 @@ export function ProductVariantSection({
|
|||||||
<Image src={image.url} alt="已选 SKU 图片" />
|
<Image src={image.url} alt="已选 SKU 图片" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{/* 图片选择交互将在后续接入。 */}
|
|
||||||
<Button
|
<Button
|
||||||
className={`sku-media-picker ${selectedImages.length ? '' : 'sku-media-picker--empty'}`}
|
className={`sku-media-picker ${selectedImages.length ? '' : 'sku-media-picker--empty'}`}
|
||||||
type="dashed"
|
type="dashed"
|
||||||
|
onClick={() => setImageEditorTarget({
|
||||||
|
key: variantKey,
|
||||||
|
name: variantValue?.display_name || `SKU ${index + 1}`,
|
||||||
|
images: selectedImages,
|
||||||
|
candidates: imageCandidates,
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
选择图片
|
选择图片
|
||||||
</Button>
|
</Button>
|
||||||
@@ -703,5 +755,43 @@ export function ProductVariantSection({
|
|||||||
}}
|
}}
|
||||||
</Form.List>
|
</Form.List>
|
||||||
</Card>
|
</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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@ import type {
|
|||||||
MediaAccessUrl,
|
MediaAccessUrl,
|
||||||
MediaAsset,
|
MediaAsset,
|
||||||
MediaUploadIntent,
|
MediaUploadIntent,
|
||||||
|
ProductUploadImage,
|
||||||
|
ProductImportedImage,
|
||||||
|
ImportProductImageRequest,
|
||||||
} from '@atlas/contracts';
|
} from '@atlas/contracts';
|
||||||
import { api } from '@/services/api';
|
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}) */
|
/** 异步操作查询,契约见 docs/api/conventions.md §5(GET /api/operations/{operation_id}) */
|
||||||
export const mediaApi = {
|
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) =>
|
createUploadIntent: (payload: CreateMediaUploadIntentRequest) =>
|
||||||
api.post<MediaUploadIntent>('/media/upload-intents', payload, {
|
api.post<MediaUploadIntent>('/media/upload-intents', payload, {
|
||||||
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
||||||
@@ -47,9 +56,21 @@ export async function sha256Hex(file: File): Promise<string> {
|
|||||||
* 返回已通过校验的素材,可直接用于图片水印模板的 image_asset_id。
|
* 返回已通过校验的素材,可直接用于图片水印模板的 image_asset_id。
|
||||||
*/
|
*/
|
||||||
export async function uploadWatermarkImage(file: File): Promise<MediaAsset> {
|
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 checksumSha256 = await sha256Hex(file);
|
||||||
const intent = await mediaApi.createUploadIntent({
|
const intent = await mediaApi.createUploadIntent({
|
||||||
purpose: 'watermark_image',
|
purpose,
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
content_type: file.type as CreateMediaUploadIntentRequest['content_type'],
|
content_type: file.type as CreateMediaUploadIntentRequest['content_type'],
|
||||||
byte_size: file.size,
|
byte_size: file.size,
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ Headers:`If-Match: "{product_revision}:{profile_revision}"`、`Idempotency-Key
|
|||||||
- `null` 的 SKU 规格字段表示继承父商品;包装物流规格的 `status` 为 `missing`/`ambiguous` 时,不允许定价确认或首次上架,但仍允许保存草稿。发布前服务端必须对每个待发布 SKU 解析 `effective_package_spec`,确认长、宽、高、重量均存在且大于 0 后才可生成 Ozon item。实物规格不用于物流;是否向 SPU 或 SKU 展示由其对应的类目 schema 属性决定,尺寸图仍可使用已确认的有效实物规格。
|
- `null` 的 SKU 规格字段表示继承父商品;包装物流规格的 `status` 为 `missing`/`ambiguous` 时,不允许定价确认或首次上架,但仍允许保存草稿。发布前服务端必须对每个待发布 SKU 解析 `effective_package_spec`,确认长、宽、高、重量均存在且大于 0 后才可生成 Ozon item。实物规格不用于物流;是否向 SPU 或 SKU 展示由其对应的类目 schema 属性决定,尺寸图仍可使用已确认的有效实物规格。
|
||||||
- `target_store_id` 只能是当前工作空间、当前平台的活动店铺。变更目标店铺只更新**尚未导入** listing 的将来发布目标;`imported` listing 永远保留其首次导入时的店铺快照。
|
- `target_store_id` 只能是当前工作空间、当前平台的活动店铺。变更目标店铺只更新**尚未导入** listing 的将来发布目标;`imported` listing 永远保留其首次导入时的店铺快照。
|
||||||
- 类目变化后不匹配的旧属性作为未映射草稿返回 warnings,不能自动进入发布 payload。属性由平台 schema 校验 `type`、必填、字典、多值/复杂属性和 `is_aspect`;客户端不能用任意 JSON 绕过。
|
- 类目变化后不匹配的旧属性作为未映射草稿返回 warnings,不能自动进入发布 payload。属性由平台 schema 校验 `type`、必填、字典、多值/复杂属性和 `is_aspect`;客户端不能用任意 JSON 绕过。
|
||||||
- `media_asset_ids` 是本 SKU 当前平台的最终选图顺序;首期最多 15 张。格式只允许 JPG/PNG/WebP,单文件最多 20 MB,边长超过 10,000 px 拒绝,短边小于 700 px 不得作为首图并给出警告。第一张是主图。
|
- `media_asset_ids` 是本 SKU 当前平台的最终选图顺序;最多 30 张。格式只允许 JPG/PNG/WebP,单文件最多 20 MB,边长超过 10,000 px 拒绝,短边小于 700 px 不得作为首图并给出警告。第一张是主图。已完成的 `product_image` 上传素材和网络导入素材可供同工作空间的 SKU 选择;采集图片仍须先转存,且来源变体须匹配。
|
||||||
- `common.model` 是用户手填或主动 AI 填报的商品型号;采集时允许为空,但每次首次上架前必须非空。`offer_id` 是用户手填或主动 AI 填报的目标平台卖家货号;采集入箱时不填值,生成时不直接复用 `source_sku`。草稿可为空,但每个待首次上架 SKU 的 `offer_id` 必须非空且在同一店铺/平台唯一。AI 填报接口和回填规则见[方案](../product/ai-autofill-plan.md)。
|
- `common.model` 是用户手填或主动 AI 填报的商品型号;采集时允许为空,但每次首次上架前必须非空。`offer_id` 是用户手填或主动 AI 填报的目标平台卖家货号;采集入箱时不填值,生成时不直接复用 `source_sku`。草稿可为空,但每个待首次上架 SKU 的 `offer_id` 必须非空且在同一店铺/平台唯一。AI 填报接口和回填规则见[方案](../product/ai-autofill-plan.md)。
|
||||||
- `common.source_title` 在编辑页显示为“中文商品名”;`profile.title` 显示为“俄文发布标题”,页面保存时必填。为允许先保存采集草稿再使用 AI 填报,API 草稿契约仍接受空标题;发布就绪校验在 `profile.title` 为空时返回 `PUBLISH_TITLE_REQUIRED`。SKU 发布标题 `variants[].title` 为 `null` 时继承 `profile.title`,不回退到 `display_name`;现有 SKU `tags=[]`、`description=null` 保持继承语义。标题、标签、描述分别独立覆盖。中俄对照按 SPU/SKU 与字段随 `editor-draft` 原子保存;SKU 继承项直接引用 SPU 对照,手工俄文覆盖但无匹配中文时标为对照待更新。发布适配器使用 SKU 有效文案仍是后续工作。
|
- `common.source_title` 在编辑页显示为“中文商品名”;`profile.title` 显示为“俄文发布标题”,页面保存时必填。为允许先保存采集草稿再使用 AI 填报,API 草稿契约仍接受空标题;发布就绪校验在 `profile.title` 为空时返回 `PUBLISH_TITLE_REQUIRED`。SKU 发布标题 `variants[].title` 为 `null` 时继承 `profile.title`,不回退到 `display_name`;现有 SKU `tags=[]`、`description=null` 保持继承语义。标题、标签、描述分别独立覆盖。中俄对照按 SPU/SKU 与字段随 `editor-draft` 原子保存;SKU 继承项直接引用 SPU 对照,手工俄文覆盖但无匹配中文时标为对照待更新。发布适配器使用 SKU 有效文案仍是后续工作。
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ Headers:`Idempotency-Key` 必填。
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
首期允许 `image/png`、`image/jpeg`、`image/webp`,最大 5 MiB,图片宽高须为 1–8192 px。服务端按用途校验大小/尺寸策略,生成随机 `object_key` 和 10 分钟有效的预签名上传地址。返回 `201 Created`:
|
允许 `image/png`、`image/jpeg`、`image/webp`。`watermark_image` 最大 5 MiB、宽高 1–8192 px;`product_image` 最大 20 MiB、宽高 1–10000 px。服务端按用途校验大小/尺寸策略,生成随机 `object_key` 和 10 分钟有效的预签名上传地址。返回 `201 Created`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -79,6 +79,14 @@ Headers:`Idempotency-Key` 必填。
|
|||||||
|
|
||||||
返回 10 分钟有效的短时 `url` 与 `expires_at`。仅当前工作空间的素材可签发;前端在 URL 过期后重新请求,不能把 URL 写回业务表或长期缓存。
|
返回 10 分钟有效的短时 `url` 与 `expires_at`。仅当前工作空间的素材可签发;前端在 URL 过期后重新请求,不能把 URL 写回业务表或长期缓存。
|
||||||
|
|
||||||
|
`GET /api/media-assets/product-uploads` 返回当前工作空间最近 100 个已经完成校验的 `product_image` 素材。每项包含 `MediaAsset` 字段及 10 分钟有效的 `preview_url`、`preview_url_expires_at`,供编辑页“上传图片”候选区展示;该地址不得写入草稿。
|
||||||
|
|
||||||
|
### 1.4 网络商品图片导入
|
||||||
|
|
||||||
|
`POST /api/media-assets/product-imports`,需 `Idempotency-Key`,请求 `{ "url": "https://example.com/image.jpg" }`。只接受 HTTPS、无用户信息和自定义端口、路径以 `.jpg`、`.jpeg` 或 `.png` 结尾的链接。服务端在每次重定向时解析域名并把连接固定到已检查的公网 IPv4;最多跟随 3 次重定向,下载上限 5 MiB,校验文件内容、MIME 与最大边长 10,000 px。成功返回 `201`,`data` 为 `ProductImportedImage`:`MediaAsset` 字段、原始 `source_url`、10 分钟有效的 `preview_url` 和 `preview_url_expires_at`。重复 URL 更新当前候选素材;原有 SKU 选图不被自动替换。
|
||||||
|
|
||||||
|
`GET /api/media-assets/product-imports` 返回当前工作空间最近 100 条已导入的网络图片,响应项同上。图片已生成正式素材 ID 后才可进入 SKU 草稿;网络来源地址和预览地址均不得写入 `media_asset_ids`。
|
||||||
|
|
||||||
## 2. 可用字体
|
## 2. 可用字体
|
||||||
|
|
||||||
`GET /api/watermark-fonts`
|
`GET /api/watermark-fonts`
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ AI 输入由两部分组成:一是用户在右侧“采集的商品信息”
|
|||||||
|
|
||||||
点击任何 AI 动作前须先保存草稿,服务端再从当前 revision 组装输入;客户端不得把未保存的尺寸或自行解析的来源 JSON 直接提交给 AI。父商品规格与 SKU 有效规格均以后端读模型为准,SKU 的空覆盖字段按继承规则读取左侧父商品规格。
|
点击任何 AI 动作前须先保存草稿,服务端再从当前 revision 组装输入;客户端不得把未保存的尺寸或自行解析的来源 JSON 直接提交给 AI。父商品规格与 SKU 有效规格均以后端读模型为准,SKU 的空覆盖字段按继承规则读取左侧父商品规格。
|
||||||
|
|
||||||
SKU 图片编辑弹窗左侧是该 SKU 的已选图片(排序/移除),右侧分组展示 AI 生图记录、采集主图、采集 SKU 图和采集详情图。商品级素材可被多个 SKU 选择;明确有稳定来源关联的 SKU 图只展示给对应 SKU。Ozon 首期每个 SKU 最多选择 15 张发布图片,第一张为主图,可拖动排序;平台适配器以自身限制驱动该上限。
|
SKU 图片选择弹窗左侧是该 SKU 的已选图片(排序/移除),右侧分组展示 AI 生图记录、采集主图、采集 SKU 图、采集详情图、网络图片和上传图片。商品级素材可被多个 SKU 选择;明确有稳定来源关联的 SKU 图只展示给对应 SKU。Ozon 首期每个 SKU 最多选择 30 张发布图片,第一张为主图,可拖动排序;平台适配器以自身限制驱动该上限。
|
||||||
|
|
||||||
已选图片悬浮时显示“编辑”菜单,至少包含“添加水印”。点击后在弹窗中选择一个工作空间水印模板,后台创建带水印的派生素材;原图和其他 SKU 的选择不被改写。用户确认后,以派生图替换该 SKU 当前图片位置,并可在同一弹窗撤销为原图。首期不在发布时自动加水印,也不因门店默认水印自动处理图片;后续再支持默认模板的预选、批量应用或自动应用。
|
已选图片悬浮时显示“编辑”菜单,至少包含“添加水印”。点击后在弹窗中选择一个工作空间水印模板,后台创建带水印的派生素材;原图和其他 SKU 的选择不被改写。用户确认后,以派生图替换该 SKU 当前图片位置,并可在同一弹窗撤销为原图。首期不在发布时自动加水印,也不因门店默认水印自动处理图片;后续再支持默认模板的预选、批量应用或自动应用。
|
||||||
|
|
||||||
|
|||||||
+21
-7
@@ -1,6 +1,6 @@
|
|||||||
# Atlas Seller Kit 交接说明
|
# Atlas Seller Kit 交接说明
|
||||||
|
|
||||||
> 更新:2026-09-23。此文是新 AI 对话、架构讨论与开发工作的首读入口。开始开发前,必须先阅读并遵守 [`development-collaboration-contract.md`](./development-collaboration-contract.md) 和仓库 `AGENTS.md`。下方按日期记录的旧方案仅供追溯;当前状态以本节及 [1.39](#139-开发进度与交接2026-09-23商品-ai-填报与编辑页收口) 为准。
|
> 更新:2026-09-24。此文是新 AI 对话、架构讨论与开发工作的首读入口。开始开发前,必须先阅读并遵守 [`development-collaboration-contract.md`](./development-collaboration-contract.md) 和仓库 `AGENTS.md`。下方按日期记录的旧方案仅供追溯;当前状态以本节及最新的 1.40 节为准。
|
||||||
|
|
||||||
## 1. 项目定位
|
## 1. 项目定位
|
||||||
|
|
||||||
@@ -12,13 +12,13 @@ pnpm monorepo 包含 Web、NestJS API、BullMQ Worker、WXT 浏览器扩展、
|
|||||||
|
|
||||||
## 0. 交接结论(先看这里)
|
## 0. 交接结论(先看这里)
|
||||||
|
|
||||||
已完成“采集 → 采集箱 → 商品编辑草稿”的主要数据闭环、平台级类目/属性/字典目录与发布预检。编辑页已接入异步「AI 填报」:基于来源资料与 Atlas 类目树生成类目建议、型号/SKU 货号、证据充分的包装建议及 SPU 中俄文案;单 SKU 文案由用户单独生成并确认应用。AI 结果只回填未保存表单,保存草稿仍是持久化边界。Ozon 动态类目属性可按已选类目加载并手工填写,**当前不由 AI 自动填报**。
|
已完成“采集 → 采集箱 → 商品编辑草稿”的主要数据闭环、平台级类目/属性/字典目录与发布预检。编辑页已接入异步「AI 填报」:基于来源资料与 Atlas 类目树生成类目建议、型号/SKU 货号、证据充分的包装建议及 SPU 中俄文案;单 SKU 文案由用户单独生成并确认应用。AI 结果只回填未保存表单,保存草稿仍是持久化边界。Ozon 动态类目属性可按已选类目加载并手工填写,**当前不由 AI 自动填报**。SKU 图片选择现支持采集候选图、工作空间上传图和网络导入图;在弹窗确认后回填草稿,页面保存后持久化。
|
||||||
|
|
||||||
尚未完成可直接发布的闭环:定价确认、真正的 Ozon 发布 job/import 及结果轮询、在线商品同步仍待开发。AI 类目推荐和文案已能运行,但不能等同于跨来源样本验收;尤其应继续用 1688/淘宝/天猫样本核验 Atlas 类目候选质量,以及俄文文案、包装证据和 SKU 继承行为。AI 生图仍是占位入口。
|
尚未完成可直接发布的闭环:定价确认、真正的 Ozon 发布 job/import 及结果轮询、在线商品同步仍待开发。AI 类目推荐和文案已能运行,但不能等同于跨来源样本验收;尤其应继续用 1688/淘宝/天猫样本核验 Atlas 类目候选质量,以及俄文文案、包装证据和 SKU 继承行为。AI 生图仍是占位入口;图片「编辑」菜单中的 AI 编辑、水印、一键 3:4 等处理动作本轮未接入。
|
||||||
|
|
||||||
当前优先事项:用真实来源样本回归 AI 类目推荐、文案与候选素材;完成定价确认;让首次发布 job 复用既有发布预检,再实现 Ozon import 与结果轮询。平台参考目录目前由平台级参考源和同步任务维护,不能把 9 月 10 日的门店级缓存描述当作现行架构。
|
当前优先事项:用真实来源样本回归 AI 类目推荐、文案与图片选择,补做真实图片上传/网络导入联调;完成定价确认;让首次发布 job 复用既有发布预检,再实现 Ozon import 与结果轮询。平台参考目录目前由平台级参考源和同步任务维护,不能把 9 月 10 日的门店级缓存描述当作现行架构。
|
||||||
|
|
||||||
接手前请先阅读 `git status --short`;截至本次更新,AI 填报涉及 API、Worker、Web、contracts、迁移和文档的改动仍在工作区,其中若干文件未跟踪,不能当作已提交。不要用 `git reset --hard` 或批量清理未跟踪文件。共享 contracts 类型需先构建,再执行 Web/API/Worker 检查。开发验证需要时可按 `AGENTS.md` 先确认进程与端口,再启动或重启 API/Worker;下方旧章节中的“不可重启”说法已失效。
|
接手前请先阅读 `git status --short`,确认当前工作区状态;本轮图片选择的 Web、API、contracts、迁移、测试和文档随本报告一起提交。不要用 `git reset --hard` 或批量清理未跟踪文件。共享 contracts 类型需先构建,再执行 Web/API/Worker 检查。开发验证需要时可按 `AGENTS.md` 先确认进程与端口,再启动或重启 API/Worker;下方旧章节中的“不可重启”说法已失效。
|
||||||
|
|
||||||
### 1.1 开发进度(2026-09-02)
|
### 1.1 开发进度(2026-09-02)
|
||||||
|
|
||||||
@@ -446,6 +446,20 @@ BullMQ Worker(最多 5 次,5 秒起指数退避)
|
|||||||
|
|
||||||
详细字段规则与任务接口见 [`ai-autofill-plan.md`](../product/ai-autofill-plan.md) 和 [`product-editor-and-publishing.md`](../api/product-editor-and-publishing.md)。
|
详细字段规则与任务接口见 [`ai-autofill-plan.md`](../product/ai-autofill-plan.md) 和 [`product-editor-and-publishing.md`](../api/product-editor-and-publishing.md)。
|
||||||
|
|
||||||
|
### 1.40 开发进度与交接(2026-09-24):SKU 图片选择
|
||||||
|
|
||||||
|
**已完成的链路**
|
||||||
|
|
||||||
|
- 新采集商品的 SKU 默认没有已选图片,编辑页显示「选择图片」入口。弹窗标题为「SKU 图片选择」;左侧已选图片最多 30 张,第一张为主图,可移出、调整顺序、批量选择和预览;右侧按 AI 生图记录、采集图片、网络图片、上传图片展示候选图,采集主图/SKU 图/详情图使用默认展开的折叠面板。左右图片区各自滚动;图片卡片与预览复用 Ant Design 组件。弹窗确认只更新当前编辑表单,页面「保存」才持久化选图。
|
||||||
|
- 图片交互拆为 `SkuImageCard`、`SkuImageSourceTabs`、`useSkuImageLibrary`、`skuImageSelection` 与弹窗组件;`ProductVariantSection` 负责 SKU 表单集成。左侧上传完成后进入已选区;右侧上传只进入工作空间候选图库,由用户决定是否加入。网络图片按行导入,过滤重复或无效链接;已选图与候选图通过边框及角标标识状态。
|
||||||
|
- API 扩展现有两阶段素材上传,新增 `product_image` 用途、20 MiB/最大边长 10,000 px 校验,并提供最近 100 张工作空间上传图及短时预览地址。网络图通过 `POST /api/media-assets/product-imports` 导入为正式素材,支持最近 100 条候选记录查询;仅接收 HTTPS JPG/PNG、最多 5 MiB,逐次校验重定向目标并固定到已检查的公网 IPv4,校验实际文件内容与尺寸。来源 URL 保存在独立的工作空间隔离记录中,预览 URL 不写入商品草稿。
|
||||||
|
- 已选素材通过 `media_asset_ids` 顺序随草稿保存;采集候选素材按原有按需转存链路物化,服务端新增对已上传/已导入素材归属及状态的验证。共享契约、服务端校验与数据库顺序约束均调整为每个 SKU 最多 30 张;迁移为 `031_sku_image_limit.sql` 和 `032_product_image_imports.sql`。接口与产品说明见 [`product-editor-and-publishing.md`](../api/product-editor-and-publishing.md)、[`watermarks-and-assets.md`](../api/watermarks-and-assets.md) 和 [`product-ai-editor.md`](../product/product-ai-editor.md)。
|
||||||
|
|
||||||
|
**验证与后续范围**
|
||||||
|
|
||||||
|
- 本地 `031`、`032` 迁移已执行。contracts、database、API、Web TypeScript 检查及 API/Web 生产构建通过;API 媒体单元测试共 6 项、商品编辑集成测试共 11 项通过。集成测试覆盖 30 张选图保存、已上传和网络导入素材的草稿引用、非法内网网络图片链接拒绝。
|
||||||
|
- 尚未用真实外部图片 URL、浏览器直传 MinIO 和完整编辑页操作做端到端回归;上线前需联调短时预览地址刷新、上传失败重试、跨 SKU 选图与重新打开弹窗。AI 生图和图片「编辑」菜单中的 AI 编辑、添加水印、一键 3:4、批量翻译由后续迭代接入;当前菜单仅提供入口提示,设为封图、下载和移出已选可用。
|
||||||
|
|
||||||
## 2. 已确认产品决策
|
## 2. 已确认产品决策
|
||||||
|
|
||||||
| 主题 | 决策 |
|
| 主题 | 决策 |
|
||||||
@@ -509,9 +523,9 @@ User → Workspace → Membership / Role
|
|||||||
|
|
||||||
> 注意:下面的清单不能覆盖 2026-09-18 的 [Ozon 参考目录决策](../architecture/platform-product-reference-catalog.md)。平台目录、后台、同步基础链路和商品编辑器迁移已经实现;旧门店目录只作为兼容/回滚数据保留。
|
> 注意:下面的清单不能覆盖 2026-09-18 的 [Ozon 参考目录决策](../architecture/platform-product-reference-catalog.md)。平台目录、后台、同步基础链路和商品编辑器迁移已经实现;旧门店目录只作为兼容/回滚数据保留。
|
||||||
|
|
||||||
当前执行顺序(2026-09-23):
|
当前执行顺序(2026-09-24):
|
||||||
|
|
||||||
1. 核对 AI 任务迁移、运行进程版本和四类来源的真实样本;回归类目选择、中俄文案(俄文字段不含汉字)、包装证据、SKU 文案继承及候选媒体按需物化。
|
1. 核对 AI 任务迁移、运行进程版本和四类来源的真实样本;回归类目选择、中俄文案(俄文字段不含汉字)、包装证据、SKU 文案继承及候选媒体按需物化,并联调 SKU 图片上传、网络导入与保存。
|
||||||
2. 完成定价确认、平台目录大字典续传与管理员浏览器回归;旧门店目录继续保留,暂不清理。
|
2. 完成定价确认、平台目录大字典续传与管理员浏览器回归;旧门店目录继续保留,暂不清理。
|
||||||
3. 将真正的 `publish-jobs` 创建事务接入已完成的发布预检,固化发布快照后实现 Ozon `/v3/product/import` 与结果轮询,再推进在线商品同步;不得重新实现另一套目录判断。
|
3. 将真正的 `publish-jobs` 创建事务接入已完成的发布预检,固化发布快照后实现 Ozon `/v3/product/import` 与结果轮询,再推进在线商品同步;不得重新实现另一套目录判断。
|
||||||
|
|
||||||
|
|||||||
@@ -5,17 +5,21 @@ const checksum = z
|
|||||||
.regex(/^[a-f0-9]{64}$/, '必须是 SHA-256 十六进制摘要');
|
.regex(/^[a-f0-9]{64}$/, '必须是 SHA-256 十六进制摘要');
|
||||||
export const createMediaUploadIntentRequestSchema = z
|
export const createMediaUploadIntentRequestSchema = z
|
||||||
.object({
|
.object({
|
||||||
purpose: z.literal('watermark_image'),
|
purpose: z.enum(['watermark_image', 'product_image']),
|
||||||
filename: z.string().trim().min(1).max(255),
|
filename: z.string().trim().min(1).max(255),
|
||||||
content_type: z.enum(['image/png', 'image/jpeg', 'image/webp']),
|
content_type: z.enum(['image/png', 'image/jpeg', 'image/webp']),
|
||||||
byte_size: z
|
byte_size: z
|
||||||
.number()
|
.number()
|
||||||
.int()
|
.int()
|
||||||
.min(1)
|
.min(1)
|
||||||
.max(5 * 1024 * 1024),
|
.max(20 * 1024 * 1024),
|
||||||
checksum_sha256: checksum,
|
checksum_sha256: checksum,
|
||||||
})
|
})
|
||||||
.strict();
|
.strict()
|
||||||
|
.refine(
|
||||||
|
(value) => value.purpose !== 'watermark_image' || value.byte_size <= 5 * 1024 * 1024,
|
||||||
|
{ path: ['byte_size'], message: '水印图片不能超过 5 MiB' },
|
||||||
|
);
|
||||||
export const completeMediaUploadIntentRequestSchema = z
|
export const completeMediaUploadIntentRequestSchema = z
|
||||||
.object({
|
.object({
|
||||||
checksum_sha256: checksum,
|
checksum_sha256: checksum,
|
||||||
@@ -26,6 +30,18 @@ export const createMediaAccessUrlRequestSchema = z
|
|||||||
purpose: z.literal('preview'),
|
purpose: z.literal('preview'),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
export const importProductImageRequestSchema = z.object({
|
||||||
|
url: z.url().max(2048).refine((value) => {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
return url.protocol === 'https:' && !url.username && !url.password && !url.port
|
||||||
|
&& /\.(?:jpe?g|png)$/i.test(url.pathname);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, '仅支持 HTTPS 的 JPG 或 PNG 图片链接'),
|
||||||
|
}).strict();
|
||||||
|
export type ImportProductImageRequest = z.infer<typeof importProductImageRequestSchema>;
|
||||||
|
|
||||||
export type CreateMediaUploadIntentRequest = z.infer<
|
export type CreateMediaUploadIntentRequest = z.infer<
|
||||||
typeof createMediaUploadIntentRequestSchema
|
typeof createMediaUploadIntentRequestSchema
|
||||||
@@ -60,6 +76,13 @@ export type MediaAsset = {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 工作空间已上传的商品图片,预览地址过期后需重新读取。 */
|
||||||
|
export type ProductUploadImage = MediaAsset & {
|
||||||
|
preview_url: string;
|
||||||
|
preview_url_expires_at: string;
|
||||||
|
};
|
||||||
|
export type ProductImportedImage = ProductUploadImage & { source_url: string };
|
||||||
|
|
||||||
/** POST /api/media-assets/{asset_id}/access-urls 响应(短时预览 URL) */
|
/** POST /api/media-assets/{asset_id}/access-urls 响应(短时预览 URL) */
|
||||||
export type MediaAccessUrl = {
|
export type MediaAccessUrl = {
|
||||||
url: string;
|
url: string;
|
||||||
|
|||||||
@@ -552,7 +552,7 @@ export const saveProductEditorDraftRequestSchema = z
|
|||||||
offer_id: nullableText(500),
|
offer_id: nullableText(500),
|
||||||
media_asset_ids: z
|
media_asset_ids: z
|
||||||
.array(uuid)
|
.array(uuid)
|
||||||
.max(15)
|
.max(30)
|
||||||
.refine(
|
.refine(
|
||||||
(ids) => new Set(ids).size === ids.length,
|
(ids) => new Set(ids).size === ids.length,
|
||||||
'SKU 图片不能重复',
|
'SKU 图片不能重复',
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
SET search_path TO atlas, public;
|
||||||
|
|
||||||
|
-- SKU 图片上限扩展到 30 张,保持与编辑草稿契约一致。
|
||||||
|
ALTER TABLE product_variant_listing_media
|
||||||
|
DROP CONSTRAINT product_variant_listing_media_sort_order_check;
|
||||||
|
ALTER TABLE product_variant_listing_media
|
||||||
|
ADD CONSTRAINT product_variant_listing_media_sort_order_check
|
||||||
|
CHECK (sort_order >= 0 AND sort_order < 30);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
SET search_path TO atlas, public;
|
||||||
|
|
||||||
|
-- 网络图片导入记录与正式素材分开,保留来源 URL 供候选区回显。
|
||||||
|
CREATE TABLE product_image_imports (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
|
||||||
|
source_url TEXT NOT NULL,
|
||||||
|
asset_id UUID NOT NULL,
|
||||||
|
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
FOREIGN KEY (workspace_id, asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT,
|
||||||
|
UNIQUE (workspace_id, source_url)
|
||||||
|
);
|
||||||
|
CREATE INDEX ix_product_image_imports_recent
|
||||||
|
ON product_image_imports (workspace_id, updated_at DESC, id DESC);
|
||||||
|
ALTER TABLE product_image_imports ENABLE ROW LEVEL SECURITY;
|
||||||
|
ALTER TABLE product_image_imports FORCE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY workspace_isolation ON product_image_imports
|
||||||
|
USING (workspace_id = app_current_workspace_id())
|
||||||
|
WITH CHECK (workspace_id = app_current_workspace_id());
|
||||||
Reference in New Issue
Block a user