feat(web-water): 水印设置页面开发
This commit is contained in:
@@ -110,6 +110,9 @@ export const api = {
|
||||
post: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||||
apiClient.post<T>(url, data, config).then((res) => unwrapEnvelope<T>(res)),
|
||||
|
||||
put: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||||
apiClient.put<T>(url, data, config).then((res) => unwrapEnvelope<T>(res)),
|
||||
|
||||
patch: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||||
apiClient.patch<T>(url, data, config).then((res) => unwrapEnvelope<T>(res)),
|
||||
|
||||
@@ -133,8 +136,7 @@ export function apiErrorMessage(error: unknown): string {
|
||||
/** 提取业务错误码(如 VERSION_CONFLICT、DEFAULT_STORE_REPLACEMENT_REQUIRED),无则返回 null */
|
||||
export function apiErrorCode(error: unknown): string | null {
|
||||
if (!axios.isAxiosError(error)) return null;
|
||||
const code = (error.response?.data as { error?: { code?: unknown } } | undefined)?.error
|
||||
?.code;
|
||||
const code = (error.response?.data as { error?: { code?: unknown } } | undefined)?.error?.code;
|
||||
return typeof code === 'string' ? code : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import type {
|
||||
CreateMediaUploadIntentRequest,
|
||||
MediaAccessUrl,
|
||||
MediaAsset,
|
||||
MediaUploadIntent,
|
||||
} from '@atlas/contracts';
|
||||
import { api } from '@/services/api';
|
||||
|
||||
/** 水印图片的用途与限制(docs/api/watermarks-and-assets.md §1) */
|
||||
export const WATERMARK_IMAGE_ACCEPT = '.jpg,.jpeg,.png,.webp';
|
||||
export const WATERMARK_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
/** 异步操作查询,契约见 docs/api/conventions.md §5(GET /api/operations/{operation_id}) */
|
||||
export const mediaApi = {
|
||||
createUploadIntent: (payload: CreateMediaUploadIntentRequest) =>
|
||||
api.post<MediaUploadIntent>('/media/upload-intents', payload, {
|
||||
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
||||
}),
|
||||
|
||||
completeUpload: (uploadId: string, checksumSha256: string) =>
|
||||
api.post<{ asset: MediaAsset }>(
|
||||
`/media/upload-intents/${uploadId}/complete`,
|
||||
{ checksum_sha256: checksumSha256 },
|
||||
{ headers: { 'Idempotency-Key': crypto.randomUUID() } }
|
||||
),
|
||||
|
||||
/** 短时预览 URL:过期后重新请求,不能写回业务表或长期缓存 */
|
||||
createPreviewUrl: (assetId: string) =>
|
||||
api.post<MediaAccessUrl>(
|
||||
`/media-assets/${assetId}/access-urls`,
|
||||
{ purpose: 'preview' },
|
||||
{ headers: { 'Idempotency-Key': crypto.randomUUID() } }
|
||||
),
|
||||
};
|
||||
|
||||
/** 浏览器端计算文件的 SHA-256 十六进制摘要(complete 校验用) */
|
||||
export async function sha256Hex(file: File): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', await file.arrayBuffer());
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片水印素材三步直传(docs/api/watermarks-and-assets.md §1):
|
||||
* 创建上传意图 → 按返回的 method/url/headers 原样 PUT 到对象存储 → 完成校验换取素材。
|
||||
* 返回已通过校验的素材,可直接用于图片水印模板的 image_asset_id。
|
||||
*/
|
||||
export async function uploadWatermarkImage(file: File): Promise<MediaAsset> {
|
||||
const checksumSha256 = await sha256Hex(file);
|
||||
const intent = await mediaApi.createUploadIntent({
|
||||
purpose: 'watermark_image',
|
||||
filename: file.name,
|
||||
content_type: file.type as CreateMediaUploadIntentRequest['content_type'],
|
||||
byte_size: file.size,
|
||||
checksum_sha256: checksumSha256,
|
||||
});
|
||||
|
||||
// 必须以服务端返回的 method、URL 与 headers 原样上传,不能自行拼接对象路径
|
||||
const putResponse = await fetch(intent.upload.url, {
|
||||
method: intent.upload.method,
|
||||
headers: intent.upload.headers,
|
||||
body: file,
|
||||
});
|
||||
if (!putResponse.ok) {
|
||||
throw new Error(`图片上传到对象存储失败(HTTP ${putResponse.status})`);
|
||||
}
|
||||
|
||||
const completed = await mediaApi.completeUpload(intent.upload_id, checksumSha256);
|
||||
return completed.asset;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Operation } from '@atlas/contracts';
|
||||
import { api } from '@/services/api';
|
||||
|
||||
/** operation 的终态集合;页面最终状态以刷新后的店铺资源为准,不以 operation 为准 */
|
||||
export const OPERATION_TERMINAL_STATUSES = new Set(['succeeded', 'failed', 'cancelled']);
|
||||
|
||||
/** 异步操作查询,契约见 docs/api/conventions.md §5(GET /api/operations/{operation_id}) */
|
||||
export const operationsApi = {
|
||||
get: (operationId: string) => api.get<Operation>(`/operations/${operationId}`),
|
||||
};
|
||||
@@ -130,9 +130,7 @@ export function PlatformProvider({ children }: { children: ReactNode }) {
|
||||
);
|
||||
|
||||
return (
|
||||
<PlatformContextContext.Provider value={value}>
|
||||
{children}
|
||||
</PlatformContextContext.Provider>
|
||||
<PlatformContextContext.Provider value={value}>{children}</PlatformContextContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -39,11 +39,7 @@ export const storesApi = {
|
||||
* 编辑:If-Match 乐观锁。未传 api_key 返回更新后的店铺;
|
||||
* 传入新 api_key 返回 202 与验证 operation(响应仍是 { store, operation })。
|
||||
*/
|
||||
update: (
|
||||
storeId: string,
|
||||
payload: UpdateStoreRequest,
|
||||
options: UpdateStoreOptions
|
||||
) =>
|
||||
update: (storeId: string, payload: UpdateStoreRequest, options: UpdateStoreOptions) =>
|
||||
api.patch<Store | StoreOperationResponse>(`/stores/${storeId}`, payload, {
|
||||
headers: {
|
||||
'If-Match': `"${options.revision}"`,
|
||||
@@ -71,4 +67,17 @@ export const storesApi = {
|
||||
api.delete<void>(`/stores/${storeId}`, {
|
||||
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
||||
}),
|
||||
|
||||
/**
|
||||
* 绑定水印模板,契约见 docs/api/stores-and-platforms.md(PUT /api/stores/{id}/watermark-binding)。
|
||||
* 传 null 表示「暂不绑定」;模板必须是当前工作空间的活动模板,返回店铺水印摘要。
|
||||
*/
|
||||
bindWatermark: (storeId: string, templateId: string | null) =>
|
||||
api.put<Store>(
|
||||
`/stores/${storeId}/watermark-binding`,
|
||||
{ template_id: templateId },
|
||||
{
|
||||
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
||||
}
|
||||
),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type {
|
||||
CreateWatermarkTemplateRequest,
|
||||
UpdateWatermarkTemplateRequest,
|
||||
WatermarkFont,
|
||||
WatermarkTemplate,
|
||||
} from '@atlas/contracts';
|
||||
import { api } from '@/services/api';
|
||||
import type { CursorPaginationMeta } from '@atlas/contracts';
|
||||
|
||||
export interface WatermarkListResult {
|
||||
data: WatermarkTemplate[];
|
||||
meta: CursorPaginationMeta;
|
||||
}
|
||||
|
||||
export interface UpdateWatermarkOptions {
|
||||
/** 提交时的乐观锁版本,取自模板 revision */
|
||||
revision: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 水印模板接口,契约见 docs/api/watermarks-and-assets.md §2/§3。
|
||||
* 列表/详情的图片模板携带短时 preview_url,过期后重新读取即可。
|
||||
*/
|
||||
export const watermarksApi = {
|
||||
/** 渲染服务已部署的可用字体;字体 code 是模板的稳定保存值 */
|
||||
fonts: () => api.get<WatermarkFont[]>('/watermark-fonts'),
|
||||
|
||||
list: (cursor?: string) =>
|
||||
api.getList<WatermarkTemplate, CursorPaginationMeta>('/watermark-templates', {
|
||||
params: { limit: 100, ...(cursor ? { cursor } : {}) },
|
||||
}),
|
||||
|
||||
get: (templateId: string) => api.get<WatermarkTemplate>(`/watermark-templates/${templateId}`),
|
||||
|
||||
create: (payload: CreateWatermarkTemplateRequest) =>
|
||||
api.post<WatermarkTemplate>('/watermark-templates', payload, {
|
||||
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
||||
}),
|
||||
|
||||
/** 渲染参数变更由服务端创建不可变版本并递增 current_version;仅改名不创建版本 */
|
||||
update: (
|
||||
templateId: string,
|
||||
payload: UpdateWatermarkTemplateRequest,
|
||||
options: UpdateWatermarkOptions
|
||||
) =>
|
||||
api.patch<WatermarkTemplate>(`/watermark-templates/${templateId}`, payload, {
|
||||
headers: {
|
||||
'If-Match': `"${options.revision}"`,
|
||||
'Idempotency-Key': crypto.randomUUID(),
|
||||
},
|
||||
}),
|
||||
|
||||
/** 软删除并解除所有店铺绑定,返回 204 */
|
||||
remove: (templateId: string) =>
|
||||
api.delete<void>(`/watermark-templates/${templateId}`, {
|
||||
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
||||
}),
|
||||
};
|
||||
Reference in New Issue
Block a user