feat(watermark): 水印前后端代码修改
This commit is contained in:
@@ -13,6 +13,7 @@
|
|||||||
"@atlas/contracts": "workspace:*",
|
"@atlas/contracts": "workspace:*",
|
||||||
"antd": "6.6.2",
|
"antd": "6.6.2",
|
||||||
"axios": "1.20.0",
|
"axios": "1.20.0",
|
||||||
|
"dayjs": "1.11.23",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
"react-dom": "19.2.8",
|
"react-dom": "19.2.8",
|
||||||
"react-router-dom": "7.18.3"
|
"react-router-dom": "7.18.3"
|
||||||
|
|||||||
@@ -86,3 +86,90 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-top: 8px;
|
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;
|
||||||
@@ -8,13 +8,7 @@ import { usePlatform } from '@/services/platform';
|
|||||||
* 首次使用默认选中目录第一家(Ozon 优先),选择结果仅作为非敏感 UI 偏好本地记忆。
|
* 首次使用默认选中目录第一家(Ozon 优先),选择结果仅作为非敏感 UI 偏好本地记忆。
|
||||||
*/
|
*/
|
||||||
const PlatformSelector = () => {
|
const PlatformSelector = () => {
|
||||||
const {
|
const { platforms, currentPlatform, selectPlatform, context, contextLoading } = usePlatform();
|
||||||
platforms,
|
|
||||||
currentPlatform,
|
|
||||||
selectPlatform,
|
|
||||||
context,
|
|
||||||
contextLoading,
|
|
||||||
} = usePlatform();
|
|
||||||
|
|
||||||
if (platforms.length === 0) return null;
|
if (platforms.length === 0) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
App as AntdApp,
|
App as AntdApp,
|
||||||
Button,
|
Button,
|
||||||
@@ -24,12 +24,13 @@ import {
|
|||||||
MoreOutlined,
|
MoreOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
WarningFilled,
|
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { Store } from '@atlas/contracts';
|
import type { Store, WatermarkTemplate } from '@atlas/contracts';
|
||||||
import { apiErrorCode, apiErrorMessage } from '@/services/api';
|
import { apiErrorCode, apiErrorMessage } from '@/services/api';
|
||||||
import { storesApi } from '@/services/stores';
|
import { storesApi } from '@/services/stores';
|
||||||
|
import { watermarksApi } from '@/services/watermarks';
|
||||||
|
import { operationsApi, OPERATION_TERMINAL_STATUSES } from '@/services/operations';
|
||||||
import { usePlatform } from '@/services/platform';
|
import { usePlatform } from '@/services/platform';
|
||||||
import './ShopsPage.css';
|
import './ShopsPage.css';
|
||||||
|
|
||||||
@@ -42,20 +43,62 @@ const STORE_STATUS_MAP: Record<string, { color: string; text: string }> = {
|
|||||||
deleted: { color: 'default', text: '已删除' },
|
deleted: { color: 'default', text: '已删除' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const CREDENTIAL_STATUS_MAP: Record<string, { color: string; text: string }> = {
|
interface StoreStatusView {
|
||||||
unknown: { color: 'default', text: '未验证' },
|
color: string;
|
||||||
valid: { color: 'green', text: '凭据有效' },
|
text: string;
|
||||||
invalid: { color: 'red', text: '凭据无效' },
|
/** 状态附注,如「等待平台验证结果」或凭据验证的脱敏原因 */
|
||||||
expired: { color: 'red', text: '已过期' },
|
note?: string;
|
||||||
rotation_required: { color: 'gold', text: '待轮换' },
|
}
|
||||||
};
|
|
||||||
|
/**
|
||||||
|
* 把门店生命周期状态(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 {
|
function formatDateTime(value: string | null): string {
|
||||||
if (!value) return '—';
|
if (!value) return '—';
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
return Number.isNaN(date.getTime())
|
return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString('zh-CN', { hour12: false });
|
||||||
? '—'
|
|
||||||
: date.toLocaleString('zh-CN', { hour12: false });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CreateFormValues {
|
interface CreateFormValues {
|
||||||
@@ -72,6 +115,7 @@ interface EditFormValues {
|
|||||||
currency_code: string;
|
currency_code: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
api_key?: string;
|
api_key?: string;
|
||||||
|
watermark_template_id?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -103,6 +147,10 @@ const ShopsPage = () => {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [editForm] = Form.useForm<EditFormValues>();
|
const [editForm] = Form.useForm<EditFormValues>();
|
||||||
|
|
||||||
|
// 水印模板选项(工作空间全量活动模板,量小一次拉取)
|
||||||
|
const [watermarkTemplates, setWatermarkTemplates] = useState<WatermarkTemplate[]>([]);
|
||||||
|
const bindingBeforeRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const platformId = currentPlatform?.id ?? null;
|
const platformId = currentPlatform?.id ?? null;
|
||||||
const currencyOptions = useMemo(
|
const currencyOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -148,6 +196,14 @@ const ShopsPage = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 水印模板选项与平台无关(工作空间全量),挂载后拉取一次
|
||||||
|
useEffect(() => {
|
||||||
|
void watermarksApi
|
||||||
|
.list()
|
||||||
|
.then((result) => setWatermarkTemplates(result.data))
|
||||||
|
.catch(() => setWatermarkTemplates([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
/** 任何变更后同时刷新列表与页头默认门店摘要 */
|
/** 任何变更后同时刷新列表与页头默认门店摘要 */
|
||||||
const refreshAll = useCallback(async () => {
|
const refreshAll = useCallback(async () => {
|
||||||
if (!platformId) return;
|
if (!platformId) return;
|
||||||
@@ -155,12 +211,44 @@ const ShopsPage = () => {
|
|||||||
await refreshContext();
|
await refreshContext();
|
||||||
}, [platformId, loadStores, 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 = () => {
|
const openCreate = () => {
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
createForm.setFieldsValue({
|
createForm.setFieldsValue({
|
||||||
is_default: stores.length === 0,
|
is_default: stores.length === 0,
|
||||||
currency_code:
|
currency_code: currentPlatform?.default_currency_code ?? currencyOptions[0]?.value,
|
||||||
currentPlatform?.default_currency_code ?? currencyOptions[0]?.value,
|
|
||||||
});
|
});
|
||||||
setCreateOpen(true);
|
setCreateOpen(true);
|
||||||
};
|
};
|
||||||
@@ -169,7 +257,7 @@ const ShopsPage = () => {
|
|||||||
if (!currentPlatform) return;
|
if (!currentPlatform) return;
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
await storesApi.create({
|
const result = await storesApi.create({
|
||||||
platform_id: currentPlatform.id,
|
platform_id: currentPlatform.id,
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
client_id: values.client_id.trim(),
|
client_id: values.client_id.trim(),
|
||||||
@@ -178,9 +266,11 @@ const ShopsPage = () => {
|
|||||||
is_default: values.is_default,
|
is_default: values.is_default,
|
||||||
note: values.note?.trim() ? values.note.trim() : null,
|
note: values.note?.trim() ? values.note.trim() : null,
|
||||||
});
|
});
|
||||||
message.success('店铺已创建,凭据验证进行中,稍后可刷新查看结果');
|
message.success('店铺已创建,正在验证凭据,结果将自动刷新');
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
await refreshAll();
|
await refreshAll();
|
||||||
|
// 轮询验证操作至终态后自动刷新,无需手动刷新页面
|
||||||
|
if (result?.operation) void pollOperation(result.operation.id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
message.error(apiErrorMessage(err));
|
message.error(apiErrorMessage(err));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -190,19 +280,25 @@ const ShopsPage = () => {
|
|||||||
|
|
||||||
const openEdit = (store: Store) => {
|
const openEdit = (store: Store) => {
|
||||||
setEditingStore(store);
|
setEditingStore(store);
|
||||||
|
bindingBeforeRef.current = store.watermark_template?.id ?? null;
|
||||||
editForm.setFieldsValue({
|
editForm.setFieldsValue({
|
||||||
name: store.name,
|
name: store.name,
|
||||||
currency_code: store.currency_code,
|
currency_code: store.currency_code,
|
||||||
note: store.note ?? '',
|
note: store.note ?? '',
|
||||||
|
watermark_template_id: store.watermark_template?.id ?? null,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditSave = async (values: EditFormValues) => {
|
const handleEditSave = async (values: EditFormValues) => {
|
||||||
if (!editingStore) return;
|
if (!editingStore) return;
|
||||||
|
const storeId = editingStore.id;
|
||||||
|
const bindingBefore = bindingBeforeRef.current;
|
||||||
|
const bindingAfter = values.watermark_template_id ?? null;
|
||||||
|
const bindingChanged = bindingBefore !== bindingAfter;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const result = await storesApi.update(
|
const result = await storesApi.update(
|
||||||
editingStore.id,
|
storeId,
|
||||||
{
|
{
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
currency_code: values.currency_code,
|
currency_code: values.currency_code,
|
||||||
@@ -212,13 +308,20 @@ const ShopsPage = () => {
|
|||||||
},
|
},
|
||||||
{ revision: editingStore.revision }
|
{ revision: editingStore.revision }
|
||||||
);
|
);
|
||||||
if (result && 'operation' in result) {
|
// 水印绑定是独立接口,仅在与打开弹窗时不同才提交
|
||||||
message.success('已保存,新密钥验证进行中,稍后可刷新查看结果');
|
if (bindingChanged) {
|
||||||
} else {
|
await storesApi.bindWatermark(storeId, bindingAfter);
|
||||||
message.success('店铺已保存');
|
|
||||||
}
|
}
|
||||||
|
if (result && 'operation' in result && result.operation) {
|
||||||
|
message.success('已保存,正在验证新密钥,结果将自动刷新');
|
||||||
setEditingStore(null);
|
setEditingStore(null);
|
||||||
await refreshAll();
|
await refreshAll();
|
||||||
|
void pollOperation(result.operation.id);
|
||||||
|
} else {
|
||||||
|
message.success('店铺已保存');
|
||||||
|
setEditingStore(null);
|
||||||
|
await refreshAll();
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 乐观锁版本过期:提示后刷新列表,用户重新编辑拿到新 revision
|
// 乐观锁版本过期:提示后刷新列表,用户重新编辑拿到新 revision
|
||||||
if (axios.isAxiosError(err) && err.response?.status === 409) {
|
if (axios.isAxiosError(err) && err.response?.status === 409) {
|
||||||
@@ -253,14 +356,16 @@ const ShopsPage = () => {
|
|||||||
|
|
||||||
const handleVerify = (store: Store) =>
|
const handleVerify = (store: Store) =>
|
||||||
withPending(store, async () => {
|
withPending(store, async () => {
|
||||||
await storesApi.requestCredentialVerification(store.id);
|
const result = await storesApi.requestCredentialVerification(store.id);
|
||||||
message.success('已发起凭据验证,稍后可刷新查看结果');
|
message.success('已发起凭据验证,结果将自动刷新');
|
||||||
|
if (result?.operation) void pollOperation(result.operation.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSyncWarehouses = (store: Store) =>
|
const handleSyncWarehouses = (store: Store) =>
|
||||||
withPending(store, async () => {
|
withPending(store, async () => {
|
||||||
await storesApi.requestWarehouseSync(store.id);
|
const result = await storesApi.requestWarehouseSync(store.id);
|
||||||
message.success('已发起仓库同步,稍后可刷新查看结果');
|
message.success('已发起仓库同步,结果将自动刷新');
|
||||||
|
if (result?.operation) void pollOperation(result.operation.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleDelete = (store: Store) => {
|
const handleDelete = (store: Store) => {
|
||||||
@@ -340,26 +445,18 @@ const ShopsPage = () => {
|
|||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
width: 200,
|
width: 200,
|
||||||
render: (_, store) => {
|
render: (_, store) => {
|
||||||
const status = STORE_STATUS_MAP[store.status] ?? {
|
const view = deriveStoreStatusView(store);
|
||||||
color: 'default',
|
|
||||||
text: store.status,
|
|
||||||
};
|
|
||||||
const credential = CREDENTIAL_STATUS_MAP[store.credential_status] ?? {
|
|
||||||
color: 'default',
|
|
||||||
text: store.credential_status,
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<Space size={4} wrap>
|
<Space size={4} wrap>
|
||||||
<Tag color={status.color}>{status.text}</Tag>
|
<Tag color={view.color}>{view.text}</Tag>
|
||||||
{store.status !== 'verifying' && (
|
{view.note && (
|
||||||
<Tag color={credential.color}>{credential.text}</Tag>
|
<Typography.Text
|
||||||
)}
|
type="secondary"
|
||||||
{store.last_error && (
|
style={{ fontSize: 12, maxWidth: 180 }}
|
||||||
<Tooltip
|
ellipsis={{ tooltip: view.note }}
|
||||||
title={`${store.last_error.code}:${store.last_error.message}`}
|
|
||||||
>
|
>
|
||||||
<WarningFilled style={{ color: '#faad14' }} />
|
{view.note}
|
||||||
</Tooltip>
|
</Typography.Text>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
);
|
);
|
||||||
@@ -408,9 +505,7 @@ const ShopsPage = () => {
|
|||||||
dataIndex: 'updated_at',
|
dataIndex: 'updated_at',
|
||||||
width: 170,
|
width: 170,
|
||||||
render: (value: string) => (
|
render: (value: string) => (
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">{formatDateTime(value)}</Typography.Text>
|
||||||
{formatDateTime(value)}
|
|
||||||
</Typography.Text>
|
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -503,11 +598,7 @@ const ShopsPage = () => {
|
|||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
maskClosable={false}
|
maskClosable={false}
|
||||||
>
|
>
|
||||||
<Form<CreateFormValues>
|
<Form<CreateFormValues> form={createForm} layout="vertical" onFinish={handleCreate}>
|
||||||
form={createForm}
|
|
||||||
layout="vertical"
|
|
||||||
onFinish={handleCreate}
|
|
||||||
>
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="name"
|
name="name"
|
||||||
label="店铺名称"
|
label="店铺名称"
|
||||||
@@ -520,7 +611,9 @@ const ShopsPage = () => {
|
|||||||
name="client_id"
|
name="client_id"
|
||||||
label="Client ID"
|
label="Client ID"
|
||||||
required
|
required
|
||||||
rules={[{ required: true, whitespace: true, message: '请输入平台 Client ID' }]}
|
rules={[
|
||||||
|
{ required: true, whitespace: true, message: '请输入平台 Client ID' },
|
||||||
|
]}
|
||||||
>
|
>
|
||||||
<Input maxLength={120} placeholder="平台后台的 Client ID" />
|
<Input maxLength={120} placeholder="平台后台的 Client ID" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -528,7 +621,9 @@ const ShopsPage = () => {
|
|||||||
name="api_key"
|
name="api_key"
|
||||||
label="API Key"
|
label="API Key"
|
||||||
required
|
required
|
||||||
rules={[{ required: true, whitespace: true, message: '请输入平台 API Key' }]}
|
rules={[
|
||||||
|
{ required: true, whitespace: true, message: '请输入平台 API Key' },
|
||||||
|
]}
|
||||||
extra="仅用于服务端加密保存与凭据验证,不会出现在日志或响应中"
|
extra="仅用于服务端加密保存与凭据验证,不会出现在日志或响应中"
|
||||||
>
|
>
|
||||||
{/* 契约上限 8192(packages/contracts/src/stores.ts),不自行收紧 */}
|
{/* 契约上限 8192(packages/contracts/src/stores.ts),不自行收紧 */}
|
||||||
@@ -546,11 +641,7 @@ const ShopsPage = () => {
|
|||||||
>
|
>
|
||||||
<Select options={currencyOptions} placeholder="选择币种" />
|
<Select options={currencyOptions} placeholder="选择币种" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item name="is_default" label="设为默认店铺" valuePropName="checked">
|
||||||
name="is_default"
|
|
||||||
label="设为默认店铺"
|
|
||||||
valuePropName="checked"
|
|
||||||
>
|
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
@@ -614,6 +705,21 @@ const ShopsPage = () => {
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</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">
|
<Form.Item label="Client ID">
|
||||||
<Typography.Text type="secondary">
|
<Typography.Text type="secondary">
|
||||||
{editingStore.platform_client_id}(平台与账户不可修改)
|
{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';
|
||||||
|
|
||||||
/** 水印管理:维护水印模板并绑定到店铺(业务模块尚未实现) */
|
type WatermarkPositionValue = (typeof WATERMARK_POSITION_OPTIONS)[number]['value'];
|
||||||
const WatermarksPage = () => (
|
type WatermarkKind = 'text' | 'image';
|
||||||
<PagePlaceholder title="水印管理" description="维护水印模板并绑定到店铺,模块开发中。" />
|
|
||||||
);
|
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;
|
export default WatermarksPage;
|
||||||
|
|||||||
@@ -110,6 +110,9 @@ export const api = {
|
|||||||
post: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
post: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||||||
apiClient.post<T>(url, data, config).then((res) => unwrapEnvelope<T>(res)),
|
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) =>
|
patch: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) =>
|
||||||
apiClient.patch<T>(url, data, config).then((res) => unwrapEnvelope<T>(res)),
|
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 */
|
/** 提取业务错误码(如 VERSION_CONFLICT、DEFAULT_STORE_REPLACEMENT_REQUIRED),无则返回 null */
|
||||||
export function apiErrorCode(error: unknown): string | null {
|
export function apiErrorCode(error: unknown): string | null {
|
||||||
if (!axios.isAxiosError(error)) return null;
|
if (!axios.isAxiosError(error)) return null;
|
||||||
const code = (error.response?.data as { error?: { code?: unknown } } | undefined)?.error
|
const code = (error.response?.data as { error?: { code?: unknown } } | undefined)?.error?.code;
|
||||||
?.code;
|
|
||||||
return typeof code === 'string' ? code : null;
|
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 (
|
return (
|
||||||
<PlatformContextContext.Provider value={value}>
|
<PlatformContextContext.Provider value={value}>{children}</PlatformContextContext.Provider>
|
||||||
{children}
|
|
||||||
</PlatformContextContext.Provider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,11 +39,7 @@ export const storesApi = {
|
|||||||
* 编辑:If-Match 乐观锁。未传 api_key 返回更新后的店铺;
|
* 编辑:If-Match 乐观锁。未传 api_key 返回更新后的店铺;
|
||||||
* 传入新 api_key 返回 202 与验证 operation(响应仍是 { store, operation })。
|
* 传入新 api_key 返回 202 与验证 operation(响应仍是 { store, operation })。
|
||||||
*/
|
*/
|
||||||
update: (
|
update: (storeId: string, payload: UpdateStoreRequest, options: UpdateStoreOptions) =>
|
||||||
storeId: string,
|
|
||||||
payload: UpdateStoreRequest,
|
|
||||||
options: UpdateStoreOptions
|
|
||||||
) =>
|
|
||||||
api.patch<Store | StoreOperationResponse>(`/stores/${storeId}`, payload, {
|
api.patch<Store | StoreOperationResponse>(`/stores/${storeId}`, payload, {
|
||||||
headers: {
|
headers: {
|
||||||
'If-Match': `"${options.revision}"`,
|
'If-Match': `"${options.revision}"`,
|
||||||
@@ -71,4 +67,17 @@ export const storesApi = {
|
|||||||
api.delete<void>(`/stores/${storeId}`, {
|
api.delete<void>(`/stores/${storeId}`, {
|
||||||
headers: { 'Idempotency-Key': crypto.randomUUID() },
|
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() },
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -33,3 +33,35 @@ export type CreateMediaUploadIntentRequest = z.infer<
|
|||||||
export type CompleteMediaUploadIntentRequest = z.infer<
|
export type CompleteMediaUploadIntentRequest = z.infer<
|
||||||
typeof completeMediaUploadIntentRequestSchema
|
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;
|
||||||
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const opacity = z
|
|||||||
const scaleRatio = z
|
const scaleRatio = z
|
||||||
.string()
|
.string()
|
||||||
.regex(/^(0\.([1-9]|[1-9]\d{1,3})|1(\.\d{1,4})?|2(\.0{1,4})?)$/, '缩放比例必须在 0.1 到 2 之间');
|
.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([
|
export const watermarkPositionSchema = z.enum([
|
||||||
'top_left', 'top_center', 'top_right', 'middle_left', 'center',
|
'top_left', 'top_center', 'top_right', 'middle_left', 'center',
|
||||||
'middle_right', 'bottom_left', 'bottom_center', 'bottom_right',
|
'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 CreateWatermarkTemplateRequest = z.infer<typeof createWatermarkTemplateRequestSchema>;
|
||||||
export type UpdateWatermarkTemplateRequest = z.infer<typeof updateWatermarkTemplateRequestSchema>;
|
export type UpdateWatermarkTemplateRequest = z.infer<typeof updateWatermarkTemplateRequestSchema>;
|
||||||
export type ListWatermarkTemplatesQuery = z.infer<typeof listWatermarkTemplatesQuerySchema>;
|
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;
|
||||||
|
};
|
||||||
|
|||||||
Generated
+926
-2603
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user