feat(web-water): 水印设置页面开发

This commit is contained in:
R524809
2026-09-04 15:57:16 +08:00
parent 009082d0f4
commit e277754588
15 changed files with 1514 additions and 80 deletions
+1
View File
@@ -13,6 +13,7 @@
"@atlas/contracts": "workspace:*",
"antd": "6.6.2",
"axios": "1.20.0",
"dayjs": "1.11.23",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-router-dom": "7.18.3"
+87
View File
@@ -86,3 +86,90 @@
gap: 12px;
margin-top: 8px;
}
/* ── 水印管理 ── */
.watermarks-load-more {
display: flex;
justify-content: center;
margin-top: 16px;
}
/* 3x3 水印位置九宫格 */
.position-grid {
display: grid;
grid-template-columns: repeat(3, 44px);
grid-auto-rows: 44px;
gap: 8px;
}
.position-grid-cell {
border: 1px solid var(--border-color);
border-radius: 6px;
background: #f5f5f5;
cursor: pointer;
transition: all 0.2s;
padding: 0;
}
.position-grid-cell:hover {
border-color: var(--primary-light);
}
.position-grid-cell.active {
border-color: var(--primary-color);
background: rgba(139, 92, 246, 0.12);
}
/* 水印预览:模拟透明底图的棋盘格 */
.watermark-preview {
display: flex;
padding: 12px;
box-sizing: border-box;
border: 1px dashed var(--border-color);
border-radius: 4px;
background-image: conic-gradient(#ececec 0 25%, #ffffff 0 50%, #ececec 0 75%, #ffffff 0);
background-size: 24px 24px;
overflow: hidden;
}
.watermark-form-actions {
display: flex;
justify-content: center;
gap: 12px;
margin-top: 8px;
}
/* ── 水印图片上传框 ── */
.watermark-image-box {
width: 120px;
height: 120px;
border: 1px dashed var(--border-color);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
overflow: hidden;
transition: border-color 0.2s;
position: relative;
background: #fafafa;
}
.watermark-image-box:hover {
border-color: var(--primary-color);
}
.watermark-image-box img {
width: 100%;
height: 100%;
object-fit: contain;
}
.watermark-image-uploading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.7);
}
@@ -0,0 +1,97 @@
import { useState } from 'react';
import type { CSSProperties } from 'react';
import { Typography } from 'antd';
import type { WatermarkPosition } from '@atlas/contracts';
import type { WatermarkTemplateConfig } from '@atlas/contracts';
/** 九宫格位置选项,顺序与编辑器 3x3 网格一致 */
export const WATERMARK_POSITION_OPTIONS: Array<{
value: WatermarkPosition;
label: string;
row: 0 | 1 | 2;
col: 0 | 1 | 2;
}> = [
{ value: 'top_left', label: '左上角', row: 0, col: 0 },
{ value: 'top_center', label: '上居中', row: 0, col: 1 },
{ value: 'top_right', label: '右上角', row: 0, col: 2 },
{ value: 'middle_left', label: '左居中', row: 1, col: 0 },
{ value: 'center', label: '正居中', row: 1, col: 1 },
{ value: 'middle_right', label: '右居中', row: 1, col: 2 },
{ value: 'bottom_left', label: '左下角', row: 2, col: 0 },
{ value: 'bottom_center', label: '下居中', row: 2, col: 1 },
{ value: 'bottom_right', label: '右下角', row: 2, col: 2 },
];
export const watermarkPositionLabel = (position: string): string =>
WATERMARK_POSITION_OPTIONS.find((option) => option.value === position)?.label ?? position;
const POSITION_FLEX: Record<
WatermarkPosition,
Pick<CSSProperties, 'justifyContent' | 'alignItems'>
> = {
top_left: { justifyContent: 'flex-start', alignItems: 'flex-start' },
top_center: { justifyContent: 'center', alignItems: 'flex-start' },
top_right: { justifyContent: 'flex-end', alignItems: 'flex-start' },
middle_left: { justifyContent: 'flex-start', alignItems: 'center' },
center: { justifyContent: 'center', alignItems: 'center' },
middle_right: { justifyContent: 'flex-end', alignItems: 'center' },
bottom_left: { justifyContent: 'flex-start', alignItems: 'flex-end' },
bottom_center: { justifyContent: 'center', alignItems: 'flex-end' },
bottom_right: { justifyContent: 'flex-end', alignItems: 'flex-end' },
};
interface WatermarkPreviewProps {
/** 允许传入部分字段:编辑弹窗按当前表单值实时渲染 */
config: Partial<WatermarkTemplateConfig>;
width?: number;
height?: number;
}
/**
* 按模板 config 在内置演示底图上实时渲染水印效果(docs/api/watermarks-and-assets.md §3.1),
* 不调用服务端渲染;图片使用响应中的短时 preview_url,过期时提示重新读取。
*/
const WatermarkPreview = ({ config, width = 320, height = 420 }: WatermarkPreviewProps) => {
const [imageBroken, setImageBroken] = useState(false);
const position = config.position ?? 'bottom_right';
const flex = POSITION_FLEX[position] ?? POSITION_FLEX.bottom_right;
const previewUrl = config.image_asset?.preview_url;
return (
<div
className="watermark-preview"
style={{ width, height, ...flex, opacity: Number(config.opacity ?? '1') }}
>
{config.image_asset ? (
imageBroken || !previewUrl ? (
<Typography.Text type="secondary">图片预览已过期或不可用</Typography.Text>
) : (
<img
src={previewUrl}
alt="水印图片"
style={{
// scale_ratio 以底图短边为基准的视觉近似:1.0 ≈ 底图宽度的 60%
width: Math.round(Number(config.scale_ratio ?? '1') * width * 0.6),
maxWidth: '80%',
}}
onError={() => setImageBroken(true)}
/>
)
) : (
<span
style={{
fontSize: config.font_size_px ?? 24,
fontWeight: 600,
color: '#111827',
whiteSpace: 'nowrap',
textShadow: '0 0 2px rgba(255,255,255,0.8)',
}}
>
{config.text_content || '文字水印'}
</span>
)}
</div>
);
};
export default WatermarkPreview;
+1 -7
View File
@@ -8,13 +8,7 @@ import { usePlatform } from '@/services/platform';
* 首次使用默认选中目录第一家(Ozon 优先),选择结果仅作为非敏感 UI 偏好本地记忆。
*/
const PlatformSelector = () => {
const {
platforms,
currentPlatform,
selectPlatform,
context,
contextLoading,
} = usePlatform();
const { platforms, currentPlatform, selectPlatform, context, contextLoading } = usePlatform();
if (platforms.length === 0) return null;
+163 -57
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
App as AntdApp,
Button,
@@ -24,12 +24,13 @@ import {
MoreOutlined,
PlusOutlined,
ReloadOutlined,
WarningFilled,
} from '@ant-design/icons';
import axios from 'axios';
import type { Store } from '@atlas/contracts';
import type { Store, WatermarkTemplate } from '@atlas/contracts';
import { apiErrorCode, apiErrorMessage } from '@/services/api';
import { storesApi } from '@/services/stores';
import { watermarksApi } from '@/services/watermarks';
import { operationsApi, OPERATION_TERMINAL_STATUSES } from '@/services/operations';
import { usePlatform } from '@/services/platform';
import './ShopsPage.css';
@@ -42,20 +43,62 @@ const STORE_STATUS_MAP: Record<string, { color: string; text: string }> = {
deleted: { color: 'default', text: '已删除' },
};
const CREDENTIAL_STATUS_MAP: Record<string, { color: string; text: string }> = {
unknown: { color: 'default', text: '未验证' },
valid: { color: 'green', text: '凭据有效' },
invalid: { color: 'red', text: '凭据无效' },
expired: { color: 'red', text: '已过期' },
rotation_required: { color: 'gold', text: '待轮换' },
interface StoreStatusView {
color: string;
text: string;
/** 状态附注,如「等待平台验证结果」或凭据验证的脱敏原因 */
note?: string;
}
/**
* 把门店生命周期状态(status)与凭据状态(credential_status)派生为单一展示状态,
* 避免两个内部状态并列渲染造成「验证中 + 未验证」的误导:
* - verifying(或凭据仍 unknown,Atlas 尚未拿到验证结果):只显示「验证中」并附注
* - active 且凭据 valid:正常
* - 凭据验证失败:401 → 凭据无效;403 → 访问被拒绝(提示检查 Client ID/API Key 归属与权限)
* - 同步异常:直接展示 last_error.message——HTTP 400/404/422、超时/429/5xx 等
* 验证请求失败不代表 API Key 无效(OZON_VERIFICATION_UNAVAILABLE 的消息即「暂时无法验证」)
* 未穷举的组合按凭据状态(过期/待轮换)或生命周期兜底。
*/
function deriveStoreStatusView(store: Store): StoreStatusView {
const { status, credential_status: credential, last_error } = store;
if (status === 'verifying' || credential === 'unknown') {
return { color: 'gold', text: '验证中', note: '等待平台验证结果' };
}
if (status === 'credential_invalid' || credential === 'invalid') {
if (last_error?.code === 'OZON_CREDENTIAL_FORBIDDEN') {
return {
color: 'red',
text: '访问被拒绝',
note: '请检查 Client ID、API Key 是否同账户,以及密钥是否有读取权限',
};
}
return { color: 'red', text: '凭据无效' };
}
if (status === 'sync_error') {
return {
color: 'orange',
text: '同步异常',
note: last_error?.message ?? '同步过程出现异常,可重新发起仓库同步或凭据验证',
};
}
if (status === 'active' && credential === 'valid') {
return { color: 'green', text: '正常' };
}
if (credential === 'expired') {
return { color: 'red', text: '凭据已过期' };
}
if (credential === 'rotation_required') {
return { color: 'gold', text: '凭据待轮换' };
}
const fallback = STORE_STATUS_MAP[status];
return fallback ?? { color: 'default', text: status };
}
function formatDateTime(value: string | null): string {
if (!value) return '—';
const date = new Date(value);
return Number.isNaN(date.getTime())
? '—'
: date.toLocaleString('zh-CN', { hour12: false });
return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString('zh-CN', { hour12: false });
}
interface CreateFormValues {
@@ -72,6 +115,7 @@ interface EditFormValues {
currency_code: string;
note?: string;
api_key?: string;
watermark_template_id?: string | null;
}
/**
@@ -103,6 +147,10 @@ const ShopsPage = () => {
const [saving, setSaving] = useState(false);
const [editForm] = Form.useForm<EditFormValues>();
// 水印模板选项(工作空间全量活动模板,量小一次拉取)
const [watermarkTemplates, setWatermarkTemplates] = useState<WatermarkTemplate[]>([]);
const bindingBeforeRef = useRef<string | null>(null);
const platformId = currentPlatform?.id ?? null;
const currencyOptions = useMemo(
() =>
@@ -148,6 +196,14 @@ const ShopsPage = () => {
}
};
// 水印模板选项与平台无关(工作空间全量),挂载后拉取一次
useEffect(() => {
void watermarksApi
.list()
.then((result) => setWatermarkTemplates(result.data))
.catch(() => setWatermarkTemplates([]));
}, []);
/** 任何变更后同时刷新列表与页头默认门店摘要 */
const refreshAll = useCallback(async () => {
if (!platformId) return;
@@ -155,12 +211,44 @@ const ShopsPage = () => {
await refreshContext();
}, [platformId, loadStores, refreshContext]);
// 组件卸载后停止轮询
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
/**
* 轮询异步验证/同步操作至终态,随后刷新列表。
* 页面最终状态以刷新后的店铺资源为准(成败原因看店铺 last_error),不以 operation 为准;
* 超时未终态则停止轮询并刷新一次,用户可手动刷新继续观察。
*/
const pollOperation = useCallback(
async (operationId: string) => {
const intervalMs = 2500;
const maxAttempts = 40;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
await new Promise((resolve) => setTimeout(resolve, intervalMs));
if (!mountedRef.current) return;
try {
const operation = await operationsApi.get(operationId);
if (OPERATION_TERMINAL_STATUSES.has(operation.status)) break;
} catch {
// 单次查询失败(网络抖动等)忽略,继续下一轮
}
}
if (mountedRef.current) await refreshAll();
},
[refreshAll]
);
const openCreate = () => {
createForm.resetFields();
createForm.setFieldsValue({
is_default: stores.length === 0,
currency_code:
currentPlatform?.default_currency_code ?? currencyOptions[0]?.value,
currency_code: currentPlatform?.default_currency_code ?? currencyOptions[0]?.value,
});
setCreateOpen(true);
};
@@ -169,7 +257,7 @@ const ShopsPage = () => {
if (!currentPlatform) return;
setCreating(true);
try {
await storesApi.create({
const result = await storesApi.create({
platform_id: currentPlatform.id,
name: values.name.trim(),
client_id: values.client_id.trim(),
@@ -178,9 +266,11 @@ const ShopsPage = () => {
is_default: values.is_default,
note: values.note?.trim() ? values.note.trim() : null,
});
message.success('店铺已创建,凭据验证进行中,稍后可刷新查看结果');
message.success('店铺已创建,正在验证凭据,结果将自动刷新');
setCreateOpen(false);
await refreshAll();
// 轮询验证操作至终态后自动刷新,无需手动刷新页面
if (result?.operation) void pollOperation(result.operation.id);
} catch (err) {
message.error(apiErrorMessage(err));
} finally {
@@ -190,19 +280,25 @@ const ShopsPage = () => {
const openEdit = (store: Store) => {
setEditingStore(store);
bindingBeforeRef.current = store.watermark_template?.id ?? null;
editForm.setFieldsValue({
name: store.name,
currency_code: store.currency_code,
note: store.note ?? '',
watermark_template_id: store.watermark_template?.id ?? null,
});
};
const handleEditSave = async (values: EditFormValues) => {
if (!editingStore) return;
const storeId = editingStore.id;
const bindingBefore = bindingBeforeRef.current;
const bindingAfter = values.watermark_template_id ?? null;
const bindingChanged = bindingBefore !== bindingAfter;
setSaving(true);
try {
const result = await storesApi.update(
editingStore.id,
storeId,
{
name: values.name.trim(),
currency_code: values.currency_code,
@@ -212,13 +308,20 @@ const ShopsPage = () => {
},
{ revision: editingStore.revision }
);
if (result && 'operation' in result) {
message.success('已保存,新密钥验证进行中,稍后可刷新查看结果');
} else {
message.success('店铺已保存');
// 水印绑定是独立接口,仅在与打开弹窗时不同才提交
if (bindingChanged) {
await storesApi.bindWatermark(storeId, bindingAfter);
}
if (result && 'operation' in result && result.operation) {
message.success('已保存,正在验证新密钥,结果将自动刷新');
setEditingStore(null);
await refreshAll();
void pollOperation(result.operation.id);
} else {
message.success('店铺已保存');
setEditingStore(null);
await refreshAll();
}
} catch (err) {
// 乐观锁版本过期:提示后刷新列表,用户重新编辑拿到新 revision
if (axios.isAxiosError(err) && err.response?.status === 409) {
@@ -253,14 +356,16 @@ const ShopsPage = () => {
const handleVerify = (store: Store) =>
withPending(store, async () => {
await storesApi.requestCredentialVerification(store.id);
message.success('已发起凭据验证,稍后可刷新查看结果');
const result = await storesApi.requestCredentialVerification(store.id);
message.success('已发起凭据验证,结果将自动刷新');
if (result?.operation) void pollOperation(result.operation.id);
});
const handleSyncWarehouses = (store: Store) =>
withPending(store, async () => {
await storesApi.requestWarehouseSync(store.id);
message.success('已发起仓库同步,稍后可刷新查看结果');
const result = await storesApi.requestWarehouseSync(store.id);
message.success('已发起仓库同步,结果将自动刷新');
if (result?.operation) void pollOperation(result.operation.id);
});
const handleDelete = (store: Store) => {
@@ -340,26 +445,18 @@ const ShopsPage = () => {
dataIndex: 'status',
width: 200,
render: (_, store) => {
const status = STORE_STATUS_MAP[store.status] ?? {
color: 'default',
text: store.status,
};
const credential = CREDENTIAL_STATUS_MAP[store.credential_status] ?? {
color: 'default',
text: store.credential_status,
};
const view = deriveStoreStatusView(store);
return (
<Space size={4} wrap>
<Tag color={status.color}>{status.text}</Tag>
{store.status !== 'verifying' && (
<Tag color={credential.color}>{credential.text}</Tag>
)}
{store.last_error && (
<Tooltip
title={`${store.last_error.code}:${store.last_error.message}`}
<Tag color={view.color}>{view.text}</Tag>
{view.note && (
<Typography.Text
type="secondary"
style={{ fontSize: 12, maxWidth: 180 }}
ellipsis={{ tooltip: view.note }}
>
<WarningFilled style={{ color: '#faad14' }} />
</Tooltip>
{view.note}
</Typography.Text>
)}
</Space>
);
@@ -408,9 +505,7 @@ const ShopsPage = () => {
dataIndex: 'updated_at',
width: 170,
render: (value: string) => (
<Typography.Text type="secondary">
{formatDateTime(value)}
</Typography.Text>
<Typography.Text type="secondary">{formatDateTime(value)}</Typography.Text>
),
},
{
@@ -503,11 +598,7 @@ const ShopsPage = () => {
destroyOnHidden
maskClosable={false}
>
<Form<CreateFormValues>
form={createForm}
layout="vertical"
onFinish={handleCreate}
>
<Form<CreateFormValues> form={createForm} layout="vertical" onFinish={handleCreate}>
<Form.Item
name="name"
label="店铺名称"
@@ -520,7 +611,9 @@ const ShopsPage = () => {
name="client_id"
label="Client ID"
required
rules={[{ required: true, whitespace: true, message: '请输入平台 Client ID' }]}
rules={[
{ required: true, whitespace: true, message: '请输入平台 Client ID' },
]}
>
<Input maxLength={120} placeholder="平台后台的 Client ID" />
</Form.Item>
@@ -528,7 +621,9 @@ const ShopsPage = () => {
name="api_key"
label="API Key"
required
rules={[{ required: true, whitespace: true, message: '请输入平台 API Key' }]}
rules={[
{ required: true, whitespace: true, message: '请输入平台 API Key' },
]}
extra="仅用于服务端加密保存与凭据验证,不会出现在日志或响应中"
>
{/* 契约上限 8192(packages/contracts/src/stores.ts),不自行收紧 */}
@@ -546,11 +641,7 @@ const ShopsPage = () => {
>
<Select options={currencyOptions} placeholder="选择币种" />
</Form.Item>
<Form.Item
name="is_default"
label="设为默认店铺"
valuePropName="checked"
>
<Form.Item name="is_default" label="设为默认店铺" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item
@@ -614,6 +705,21 @@ const ShopsPage = () => {
autoComplete="new-password"
/>
</Form.Item>
<Form.Item
name="watermark_template_id"
label="水印模板"
extra="作为商品图片编辑时的推荐水印;可随时更换或清空"
>
<Select
allowClear
placeholder="暂不绑定"
notFoundContent="暂无水印模板,可在水印管理页创建"
options={watermarkTemplates.map((template) => ({
value: template.id,
label: template.name,
}))}
/>
</Form.Item>
<Form.Item label="Client ID">
<Typography.Text type="secondary">
{editingStore.platform_client_id}(平台与账户不可修改)
@@ -1,8 +1,934 @@
import PagePlaceholder from '@/components/PagePlaceholder';
import dayjs from 'dayjs';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
App as AntdApp,
Button,
Card,
DatePicker,
Form,
Input,
InputNumber,
Modal,
Select,
Slider,
Space,
Spin,
Table,
Tag,
Tooltip,
Typography,
Upload,
} from 'antd';
import type { TableProps, UploadFile } from 'antd';
import {
CopyOutlined,
FileImageOutlined,
PlusOutlined,
ReloadOutlined,
RestOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import type {
UpdateWatermarkTemplateRequest,
WatermarkTemplate,
WatermarkTemplateConfig,
} from '@atlas/contracts';
import WatermarkPreview, {
WATERMARK_POSITION_OPTIONS,
watermarkPositionLabel,
} from '@/components/WatermarkPreview';
import { apiErrorDetails, apiErrorMessage } from '@/services/api';
import { watermarksApi } from '@/services/watermarks';
import { mediaApi } from '@/services/media';
import {
uploadWatermarkImage,
WATERMARK_IMAGE_ACCEPT,
WATERMARK_IMAGE_MAX_BYTES,
} from '@/services/media';
/** 水印管理:维护水印模板并绑定到店铺(业务模块尚未实现) */
const WatermarksPage = () => (
<PagePlaceholder title="水印管理" description="维护水印模板并绑定到店铺,模块开发中。" />
type WatermarkPositionValue = (typeof WATERMARK_POSITION_OPTIONS)[number]['value'];
type WatermarkKind = 'text' | 'image';
interface WatermarkFormValues {
name: string;
kind: WatermarkKind;
text_content: string;
font_code: string;
font_size_px: number;
opacityPercent: number;
scalePercent: number;
position: WatermarkPositionValue;
}
type KindFilter = 'all' | 'text' | 'image';
const KIND_TAG: Record<string, { color: string; text: string }> = {
image: { color: 'green', text: '图片水印' },
text: { color: 'blue', text: '文字水印' },
};
const percentToOpacity = (percent: number): string => {
const value = percent / 100;
return value === 0 ? '0' : value === 1 ? '1' : String(Number(value.toFixed(4)));
};
const percentToScale = (percent: number): string => {
const value = percent / 100;
return value === 2 ? '2' : String(Number(value.toFixed(4)));
};
const formatDateTime = (value: string): string =>
new Date(value).toLocaleString('zh-CN', { hour12: false });
/**
* 水印管理页,契约见 docs/api/watermarks-and-assets.md。
* 列表接口只有 cursor/limit 参数,类型/名称/日期筛选为纯客户端过滤;
* 图片水印经三步直传创建素材(services/media.ts),编辑弹窗按 config 实时渲染预览。
*/
const WatermarksPage = () => {
const { message, modal } = AntdApp.useApp();
const [templates, setTemplates] = useState<WatermarkTemplate[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [fonts, setFonts] = useState<Array<{ code: string; display_name: string }>>([]);
// 筛选:输入草稿 + 点击「查询」后生效的已应用条件(纯客户端过滤)
const [kindDraft, setKindDraft] = useState<KindFilter>('all');
const [nameDraft, setNameDraft] = useState('');
const [dateDraft, setDateDraft] = useState<[string, string] | null>(null);
const [kindFilter, setKindFilter] = useState<KindFilter>('all');
const [nameFilter, setNameFilter] = useState('');
const [dateFilter, setDateFilter] = useState<[string, string] | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [batchDeleting, setBatchDeleting] = useState(false);
const [editorOpen, setEditorOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [editing, setEditing] = useState<WatermarkTemplate | null>(null);
const [previewing, setPreviewing] = useState<WatermarkTemplate | null>(null);
const [form] = Form.useForm<WatermarkFormValues>();
// 图片水印:已上传(或既有)的素材;previewUrl 用于本地/短时预览
const [uploadingImage, setUploadingImage] = useState(false);
const [imageAsset, setImageAsset] = useState<{ id: string; previewUrl: string } | null>(null);
// 图片素材短时预览 URL 缓存(列表不返回 preview_url,按需经 access-urls 换取)
const [assetPreviewUrls, setAssetPreviewUrls] = useState<Record<string, string>>({});
// 实时预览:随渲染相关字段变化
const [previewValues, setPreviewValues] = useState<WatermarkFormValues | null>(null);
const loadTemplates = useCallback(
async (cursor?: string) => {
if (!cursor) setLoading(true);
else setLoadingMore(true);
try {
const result = await watermarksApi.list(cursor);
setTemplates((prev) => (cursor ? [...prev, ...result.data] : result.data));
setNextCursor(result.meta.has_more ? result.meta.next_cursor : null);
} catch (err) {
message.error(apiErrorMessage(err));
} finally {
setLoading(false);
setLoadingMore(false);
}
},
[message]
);
useEffect(() => {
void loadTemplates();
void watermarksApi
.fonts()
.then(setFonts)
.catch(() => setFonts([]));
}, [loadTemplates]);
// 为图片模板按需换取短时预览 URL(缓存后 10 分钟内复用)
useEffect(() => {
const missing = templates.filter(
(template) =>
template.kind === 'image' &&
template.config.image_asset &&
!assetPreviewUrls[template.config.image_asset.id]
);
for (const template of missing) {
const assetId = template.config.image_asset!.id;
mediaApi
.createPreviewUrl(assetId)
.then(({ url }) => setAssetPreviewUrls((prev) => ({ ...prev, [assetId]: url })))
.catch(() => undefined);
}
}, [templates, assetPreviewUrls]);
const filtered = useMemo(
() =>
templates.filter((template) => {
if (kindFilter !== 'all' && template.kind !== kindFilter) return false;
if (nameFilter && !template.name.includes(nameFilter.trim())) return false;
if (dateFilter) {
const created = template.created_at.slice(0, 10);
const [start, end] = dateFilter;
if (start && created < start) return false;
if (end && created > end) return false;
}
return true;
}),
[templates, kindFilter, nameFilter, dateFilter]
);
/** 创建/编辑成功后就地更新列表数据(服务端返回完整模板资源) */
const applyTemplate = (template: WatermarkTemplate) => {
setTemplates((prev) => {
const index = prev.findIndex((item) => item.id === template.id);
if (index === -1) return [template, ...prev];
const next = [...prev];
next[index] = template;
return next;
});
};
const openCreate = () => {
setEditing(null);
setImageAsset(null);
const initial: WatermarkFormValues = {
name: '',
kind: 'text',
text_content: '',
font_code: fonts[0]?.code ?? '',
font_size_px: 24,
opacityPercent: 80,
scalePercent: 25,
position: 'bottom_right',
};
form.setFieldsValue(initial);
setPreviewValues(initial);
setEditorOpen(true);
};
const openEdit = (template: WatermarkTemplate) => {
setEditing(template);
const config = template.config;
const assetId = config.image_asset?.id ?? config.image_asset_id ?? null;
setImageAsset(
template.kind === 'image' && assetId
? { id: assetId, previewUrl: assetPreviewUrls[assetId] ?? '' }
: null
);
const initial: WatermarkFormValues = {
name: template.name,
kind: template.kind,
text_content: config.text_content ?? '',
font_code: config.font_code ?? fonts[0]?.code ?? '',
font_size_px: config.font_size_px ?? 24,
opacityPercent: Math.round(Number(config.opacity ?? '0.8') * 100),
scalePercent: Math.round(Number(config.scale_ratio ?? '0.25') * 100),
position: config.position,
};
form.setFieldsValue(initial);
setPreviewValues(initial);
setEditorOpen(true);
};
const handleBeforeUpload = async (file: UploadFile): Promise<boolean> => {
const okTypes = ['image/png', 'image/jpeg', 'image/webp'];
if (!okTypes.includes(file.type ?? '')) {
message.error('仅支持 JPG、PNG、WebP 格式图片');
return false;
}
if ((file.size ?? 0) > WATERMARK_IMAGE_MAX_BYTES) {
message.error('图片大小不能超过 5MB');
return false;
}
setUploadingImage(true);
try {
const asset = await uploadWatermarkImage(file as unknown as File);
setImageAsset({
id: asset.id,
previewUrl: URL.createObjectURL(file as unknown as File),
});
message.success('图片已上传并通过校验');
} catch (err) {
message.error(apiErrorMessage(err));
} finally {
setUploadingImage(false);
}
// 阻止 antd 自动上传:上传由三步直传流程接管
return false;
};
const handleSave = async (values: WatermarkFormValues) => {
if (values.kind === 'image' && !imageAsset) {
message.error('请先上传水印图片');
return;
}
setSaving(true);
try {
// 滑杆字段在类型切换后可能缺值,统一兜底默认值,避免 NaN
const opacity = percentToOpacity(values.opacityPercent ?? 80);
const scaleRatio = percentToScale(values.scalePercent ?? 25);
// 编辑时只提交有变化的字段:渲染参数未变时仅改名,
// 服务端不会创建新的渲染版本(docs/api/watermarks-and-assets.md §3.3);
// 切换类型必须提交新类型的完整 config。
if (editing) {
let payload: UpdateWatermarkTemplateRequest;
if (editing.kind !== values.kind) {
// 切换类型:提交新类型完整 config
let config: UpdateWatermarkTemplateRequest['config'];
if (values.kind === 'text') {
config = {
text_content: values.text_content?.trim() ?? '',
font_code: values.font_code,
font_size_px: values.font_size_px,
opacity,
position: values.position,
};
} else {
config = {
image_asset_id: imageAsset?.id ?? '',
scale_ratio: scaleRatio,
opacity,
position: values.position,
};
}
payload = { kind: values.kind, config };
if (values.name.trim() !== editing.name) {
payload.name = values.name.trim();
}
} else if (values.kind === 'text') {
const current = editing.config;
const diff: UpdateWatermarkTemplateRequest = {};
if (values.name.trim() !== editing.name) {
diff.name = values.name.trim();
}
const configPatch: NonNullable<UpdateWatermarkTemplateRequest['config']> = {};
if (values.text_content?.trim() !== current.text_content) {
configPatch.text_content = values.text_content?.trim() ?? null;
}
if (values.font_code !== current.font_code) {
configPatch.font_code = values.font_code;
}
if (values.font_size_px !== current.font_size_px) {
configPatch.font_size_px = values.font_size_px;
}
if (Number(opacity) !== Number(current.opacity)) {
configPatch.opacity = opacity;
}
if (values.position !== current.position) {
configPatch.position = values.position;
}
if (Object.keys(configPatch).length > 0) {
diff.config = configPatch;
}
payload = diff;
} else {
const current = editing.config;
const diff: UpdateWatermarkTemplateRequest = {};
if (values.name.trim() !== editing.name) {
diff.name = values.name.trim();
}
const configPatch: NonNullable<UpdateWatermarkTemplateRequest['config']> = {};
if (imageAsset && imageAsset.id !== current.image_asset?.id) {
configPatch.image_asset_id = imageAsset.id;
}
if (percentToScale(values.scalePercent) !== current.scale_ratio) {
configPatch.scale_ratio = scaleRatio;
}
if (Number(opacity) !== Number(current.opacity)) {
configPatch.opacity = opacity;
}
if (values.position !== current.position) {
configPatch.position = values.position;
}
if (Object.keys(configPatch).length > 0) {
diff.config = configPatch;
}
payload = diff;
}
if (Object.keys(payload).length === 0) {
message.info('内容没有变化,无需保存');
setEditorOpen(false);
return;
}
const template = await watermarksApi.update(editing.id, payload, {
revision: editing.revision,
});
applyTemplate(template);
message.success('水印模板已保存');
} else if (values.kind === 'text') {
const template = await watermarksApi.create({
name: values.name.trim(),
kind: 'text',
config: {
text_content: values.text_content.trim(),
font_code: values.font_code,
font_size_px: values.font_size_px,
opacity,
position: values.position,
},
});
applyTemplate(template);
message.success('水印模板已创建');
} else {
const template = await watermarksApi.create({
name: values.name.trim(),
kind: 'image',
config: {
image_asset_id: imageAsset?.id ?? '',
scale_ratio: scaleRatio,
opacity,
position: values.position,
},
});
applyTemplate(template);
message.success('水印模板已创建');
}
setEditorOpen(false);
} catch (err) {
if (axios.isAxiosError(err) && err.response?.status === 409) {
message.error('模板已被其他操作更新,请关闭后重新打开编辑');
} else {
const details = apiErrorDetails(err);
const knownFields: Array<keyof WatermarkFormValues> = [
'name',
'kind',
'text_content',
'font_code',
'font_size_px',
'opacityPercent',
'scalePercent',
'position',
];
if (details.length > 0) {
const fieldErrors = [];
for (const item of details) {
const field = item.field.replace(/^config\./, '');
if (knownFields.includes(field as keyof WatermarkFormValues)) {
fieldErrors.push({
name: field as keyof WatermarkFormValues,
errors: [item.message],
});
} else {
message.error(item.message);
}
}
if (fieldErrors.length > 0) {
form.setFields(fieldErrors);
}
} else {
message.error(apiErrorMessage(err));
}
}
} finally {
setSaving(false);
}
};
/** 查看效果前刷新图片短时预览 URL(10 分钟有效) */
const openPreview = async (template: WatermarkTemplate) => {
if (template.kind === 'image') {
const assetId = template.config.image_asset?.id ?? template.config.image_asset_id ?? '';
// 缓存优先,缺失/过期时重新换取短时预览 URL
let url = assetPreviewUrls[assetId];
if (!url) {
try {
const access = await mediaApi.createPreviewUrl(assetId);
url = access.url;
setAssetPreviewUrls((prev) => ({ ...prev, [assetId]: url }));
} catch {
// 换取失败按原样打开,图片渲染失败会显示不可用提示
}
}
setPreviewing({
...template,
config: {
...template.config,
image_asset: {
id: assetId,
preview_url: url ?? '',
preview_url_expires_at: '',
},
},
});
return;
}
setPreviewing(template);
};
const confirmDelete = (targets: WatermarkTemplate[]) => {
const label =
targets.length === 1 ? `「${targets[0].name}」` : `${targets.length} 个水印模板`;
modal.confirm({
title: '删除水印模板',
content: `将删除${label}并解除所有店铺绑定,已生成的图片不受影响。确定删除吗?`,
okText: '删除',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
if (targets.length === 1) {
await watermarksApi.remove(targets[0].id);
message.success('水印模板已删除');
} else {
setBatchDeleting(true);
let failed = 0;
for (const template of targets) {
try {
await watermarksApi.remove(template.id);
} catch (err) {
failed += 1;
message.error(`${template.name}:${apiErrorMessage(err)}`);
}
}
setBatchDeleting(false);
if (failed === 0) {
message.success(`已删除 ${targets.length} 个模板`);
} else {
message.warning(`已删除 ${targets.length - failed} 个,${failed} 个失败`);
}
}
setSelectedIds([]);
await loadTemplates();
},
});
};
const columns: TableProps<WatermarkTemplate>['columns'] = [
{
title: '序号',
key: 'index',
width: 70,
render: (_, _record, index) => index + 1,
},
{
title: '模板名称',
dataIndex: 'name',
width: 220,
ellipsis: true,
},
{
title: '水印类型',
dataIndex: 'kind',
width: 110,
render: (kind: string) => {
const tag = KIND_TAG[kind] ?? { color: 'default', text: kind };
return <Tag color={tag.color}>{tag.text}</Tag>;
},
},
{
title: '水印内容',
key: 'content',
render: (_, template) => {
if (template.kind === 'image') {
const assetId =
template.config.image_asset?.id ?? template.config.image_asset_id;
const previewUrl = assetId ? assetPreviewUrls[assetId] : undefined;
return previewUrl ? (
<img
src={previewUrl}
alt={template.name}
style={{ width: 48, height: 48, objectFit: 'contain', borderRadius: 4 }}
/>
) : (
<Tooltip title="正在获取图片预览">
<FileImageOutlined style={{ fontSize: 20, color: '#9ca3af' }} />
</Tooltip>
);
}
return (
<div>
<div style={{ fontWeight: 600 }}>{template.config.text_content}</div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{template.config.font_code}, {template.config.font_size_px}px, ■
</Typography.Text>
</div>
);
},
},
{
title: '水印位置',
key: 'position',
width: 110,
render: (_, template) => watermarkPositionLabel(template.config.position),
},
{
title: '版本',
dataIndex: 'current_version',
width: 70,
render: (version: number) => `v${version}`,
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 170,
render: (value: string) => (
<Typography.Text type="secondary">{formatDateTime(value)}</Typography.Text>
),
},
{
title: '操作',
key: 'actions',
width: 190,
render: (_, template) => (
<Space size={4}>
<Button type="link" size="small" onClick={() => void openPreview(template)}>
查看效果
</Button>
<Button type="link" size="small" onClick={() => openEdit(template)}>
编辑
</Button>
<Button
type="link"
size="small"
danger
onClick={() => confirmDelete([template])}
>
删除
</Button>
</Space>
),
},
];
const previewConfig = useMemo<Partial<WatermarkTemplateConfig>>(() => {
if (!previewValues) return {};
if (previewValues.kind === 'image') {
return {
image_asset: {
id: imageAsset?.id ?? '',
preview_url: imageAsset?.previewUrl ?? '',
preview_url_expires_at: '',
},
scale_ratio: percentToScale(previewValues.scalePercent),
opacity: percentToOpacity(previewValues.opacityPercent),
position: previewValues.position,
};
}
return {
text_content: previewValues.text_content,
font_code: previewValues.font_code,
font_size_px: previewValues.font_size_px,
opacity: percentToOpacity(previewValues.opacityPercent),
position: previewValues.position,
};
}, [previewValues, imageAsset]);
return (
<div className="watermarks-page">
<Card
title="水印管理"
extra={
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void loadTemplates()} />
<Button
danger
icon={<RestOutlined />}
disabled={selectedIds.length === 0 || batchDeleting}
loading={batchDeleting}
onClick={() => {
const targets = templates.filter((item) =>
selectedIds.includes(item.id)
);
confirmDelete(targets);
}}
>
批量删除
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
添加模板
</Button>
</Space>
}
>
<Space wrap style={{ marginBottom: 16 }} size={8}>
<Select<KindFilter>
value={kindDraft}
onChange={setKindDraft}
style={{ width: 130 }}
options={[
{ value: 'all', label: '全部类型' },
{ value: 'text', label: '文字水印' },
{ value: 'image', label: '图片水印' },
]}
/>
<Input
placeholder="请输入模板名称"
value={nameDraft}
onChange={(event) => setNameDraft(event.target.value)}
style={{ width: 180 }}
allowClear
/>
<DatePicker.RangePicker
value={
dateDraft
? ([dayjsOrNull(dateDraft[0]), dayjsOrNull(dateDraft[1])] as never)
: null
}
onChange={(_, dateString) =>
setDateDraft(
dateString[0] && dateString[1]
? [dateString[0], dateString[1]]
: null
)
}
/>
<Button
type="primary"
onClick={() => {
setKindFilter(kindDraft);
setNameFilter(nameDraft.trim());
setDateFilter(dateDraft);
}}
>
查询
</Button>
<Button
onClick={() => {
setKindDraft('all');
setNameDraft('');
setDateDraft(null);
setKindFilter('all');
setNameFilter('');
setDateFilter(null);
}}
>
重 置
</Button>
</Space>
<Table<WatermarkTemplate>
rowKey="id"
columns={columns}
dataSource={filtered}
loading={loading}
pagination={false}
rowSelection={{
selectedRowKeys: selectedIds,
onChange: (keys) => setSelectedIds(keys.map(String)),
}}
locale={{ emptyText: '暂无水印模板,点击右上角「添加模板」创建' }}
/>
{nextCursor && (
<div className="watermarks-load-more">
<Button
loading={loadingMore}
onClick={() => void loadTemplates(nextCursor)}
>
加载更多
</Button>
</div>
)}
</Card>
<Modal
title={editing ? '编辑水印模板' : '添加水印模板'}
open={editorOpen}
onCancel={() => setEditorOpen(false)}
footer={null}
width={720}
destroyOnHidden
maskClosable={false}
>
<Form<WatermarkFormValues>
form={form}
layout="vertical"
onValuesChange={(_, values) => setPreviewValues(values as WatermarkFormValues)}
onFinish={handleSave}
>
<Form.Item
name="name"
label="模板名称"
required
rules={[{ required: true, whitespace: true, message: '请输入模板名称' }]}
>
<Input maxLength={120} placeholder="例如:右下角品牌水印" />
</Form.Item>
<Form.Item name="kind" label="水印类型" required>
<Select
options={[
{ value: 'text', label: '文字水印' },
{ value: 'image', label: '图片水印' },
]}
/>
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, next) => prev.kind !== next.kind}>
{({ getFieldValue }) =>
getFieldValue('kind') === 'image' ? (
<>
<Form.Item label="上传图片" required>
<Upload
accept={WATERMARK_IMAGE_ACCEPT}
showUploadList={false}
beforeUpload={handleBeforeUpload}
disabled={uploadingImage}
>
<div className="watermark-image-box">
{imageAsset?.previewUrl ? (
<img
src={imageAsset.previewUrl}
alt="水印图片"
/>
) : (
<FileImageOutlined
style={{ fontSize: 28, color: '#9ca3af' }}
/>
)}
{uploadingImage && (
<div className="watermark-image-uploading">
<Spin size="small" />
</div>
)}
</div>
</Upload>
</Form.Item>
<Typography.Paragraph
type="secondary"
style={{ fontSize: 12, marginTop: -8 }}
>
推荐使用透明底的 PNG 格式图片;支持 JPG、PNG、WebP,不超过
5MB
</Typography.Paragraph>
<Form.Item
name="scalePercent"
label="图片缩放"
preserve={false}
>
<Slider
min={10}
max={200}
step={1}
tooltip={{ formatter: (value) => `${value ?? 0}%` }}
/>
</Form.Item>
</>
) : (
<>
<Form.Item
name="text_content"
label="水印文字"
required
rules={[
{
required: true,
whitespace: true,
message: '请输入水印文字',
},
]}
>
<Input maxLength={200} placeholder="例如:Atlas Store" />
</Form.Item>
<Form.Item
name="font_code"
label="字体"
required
preserve={false}
>
<Select
placeholder="选择字体"
notFoundContent="渲染服务暂无可用字体"
options={fonts.map((font) => ({
value: font.code,
label: font.display_name,
}))}
/>
</Form.Item>
<Form.Item
name="font_size_px"
label="字号(px)"
required
rules={[{ required: true, message: '请输入字号' }]}
>
<InputNumber
min={12}
max={60}
precision={0}
style={{ width: 140 }}
/>
</Form.Item>
</>
)
}
</Form.Item>
<Form.Item name="opacityPercent" label="不透明度">
<Slider
min={0}
max={100}
step={1}
tooltip={{ formatter: (value) => `${value ?? 0}%` }}
/>
</Form.Item>
<Form.Item label="水印位置" required>
<Form.Item name="position" noStyle>
<PositionGrid />
</Form.Item>
</Form.Item>
<Form.Item label="预览效果">
<WatermarkPreview config={previewConfig} />
</Form.Item>
<div className="watermark-form-actions">
<Button type="primary" htmlType="submit" loading={saving}>
保 存
</Button>
<Button onClick={() => setEditorOpen(false)}>取 消</Button>
</div>
</Form>
</Modal>
<Modal
title={previewing ? `查看效果:${previewing.name}` : '查看效果'}
open={!!previewing}
onCancel={() => setPreviewing(null)}
footer={null}
width={420}
destroyOnHidden
>
{previewing && <WatermarkPreview config={previewing.config} />}
</Modal>
</div>
);
};
const dayjsOrNull = (value: string) => {
const parsed = dayjs(value);
return parsed.isValid() ? parsed : null;
};
/** 3x3 水印位置九宫格选择器:选中格高亮,旁边显示位置名称 */
const PositionGrid = ({
value,
onChange,
}: {
value?: WatermarkPositionValue;
onChange?: (value: WatermarkPositionValue) => void;
}) => {
const selected = WATERMARK_POSITION_OPTIONS.find((option) => option.value === value);
return (
<Space size={12} align="center">
<div className="position-grid">
{WATERMARK_POSITION_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className={`position-grid-cell${option.value === value ? ' active' : ''}`}
aria-label={option.label}
onClick={() => onChange?.(option.value)}
/>
))}
</div>
<Typography.Text type="secondary">{positionGridLabel(selected?.label)}</Typography.Text>
</Space>
);
};
const positionGridLabel = (label?: string) => label ?? '请选择位置';
export default WatermarksPage;
+4 -2
View File
@@ -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;
}
+71
View File
@@ -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;
}
+10
View File
@@ -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}`),
};
+1 -3
View File
@@ -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>
);
}
+14 -5
View File
@@ -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() },
}
),
};
+58
View File
@@ -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() },
}),
};
+32
View File
@@ -33,3 +33,35 @@ export type CreateMediaUploadIntentRequest = z.infer<
export type CompleteMediaUploadIntentRequest = z.infer<
typeof completeMediaUploadIntentRequestSchema
>;
// ── 只读响应类型(由前端 AI 按 docs/api/watermarks-and-assets.md §1 补充,后端请复核) ──
/** 预签名直传端点:前端必须按返回的 method/url/headers 原样上传 */
export type MediaUploadEndpoint = {
method: string;
url: string;
headers: Record<string, string>;
};
/** POST /api/media/upload-intents 响应 */
export type MediaUploadIntent = {
upload_id: string;
expires_at: string;
upload: MediaUploadEndpoint;
};
/** 校验通过后创建/复用的素材 */
export type MediaAsset = {
id: string;
content_type: string;
byte_size: number;
width_px: number | null;
height_px: number | null;
created_at: string;
};
/** POST /api/media-assets/{asset_id}/access-urls 响应(短时预览 URL) */
export type MediaAccessUrl = {
url: string;
expires_at: string;
};
+40
View File
@@ -7,6 +7,7 @@ const opacity = z
const scaleRatio = z
.string()
.regex(/^(0\.([1-9]|[1-9]\d{1,3})|1(\.\d{1,4})?|2(\.0{1,4})?)$/, '缩放比例必须在 0.1 到 2 之间');
export type WatermarkPosition = z.infer<typeof watermarkPositionSchema>;
export const watermarkPositionSchema = z.enum([
'top_left', 'top_center', 'top_right', 'middle_left', 'center',
'middle_right', 'bottom_left', 'bottom_center', 'bottom_right',
@@ -68,3 +69,42 @@ export const bindStoreWatermarkRequestSchema = z.object({
export type CreateWatermarkTemplateRequest = z.infer<typeof createWatermarkTemplateRequestSchema>;
export type UpdateWatermarkTemplateRequest = z.infer<typeof updateWatermarkTemplateRequestSchema>;
export type ListWatermarkTemplatesQuery = z.infer<typeof listWatermarkTemplatesQuerySchema>;
// ── 只读响应类型(由前端 AI 按 docs/api/watermarks-and-assets.md §2/§3 补充,后端请复核) ──
/** GET /api/watermark-fonts 返回的渲染服务可用字体 */
export type WatermarkFont = {
code: string;
display_name: string;
};
/** 图片水印模板引用的素材摘要(含短时预览 URL,过期后重新读取列表/详情) */
export type WatermarkTemplateImageAsset = {
id: string;
preview_url: string;
preview_url_expires_at: string;
};
/** 模板当前版本的渲染配置;kind=text 时为文字字段,kind=image 时为图片字段 */
export type WatermarkTemplateConfig = {
text_content?: string;
font_code?: string;
font_size_px?: number;
image_asset_id?: string;
image_asset?: WatermarkTemplateImageAsset;
opacity?: string;
scale_ratio?: string;
position: WatermarkPosition;
};
/** 水印模板读取形状(revision 用于 If-Match 乐观锁;current_version 随渲染参数变更递增) */
export type WatermarkTemplate = {
id: string;
name: string;
kind: 'text' | 'image';
current_version: number;
config: WatermarkTemplateConfig;
created_at: string;
updated_at: string;
revision: number;
};
+3
View File
@@ -124,6 +124,9 @@ importers:
axios:
specifier: 1.20.0
version: 1.20.0(debug@4.4.3)
dayjs:
specifier: 1.11.23
version: 1.11.23
react:
specifier: 19.2.8
version: 19.2.8