diff --git a/apps/web/package.json b/apps/web/package.json index 9c099e7..8b57d5e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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" diff --git a/apps/web/src/App.css b/apps/web/src/App.css index bac5f05..a00a567 100644 --- a/apps/web/src/App.css +++ b/apps/web/src/App.css @@ -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); +} diff --git a/apps/web/src/components/WatermarkPreview.tsx b/apps/web/src/components/WatermarkPreview.tsx new file mode 100644 index 0000000..e0f18f7 --- /dev/null +++ b/apps/web/src/components/WatermarkPreview.tsx @@ -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 +> = { + 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; + 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 ( +
+ {config.image_asset ? ( + imageBroken || !previewUrl ? ( + 图片预览已过期或不可用 + ) : ( + 水印图片 setImageBroken(true)} + /> + ) + ) : ( + + {config.text_content || '文字水印'} + + )} +
+ ); +}; + +export default WatermarkPreview; diff --git a/apps/web/src/layouts/PlatformSelector.tsx b/apps/web/src/layouts/PlatformSelector.tsx index ccade89..9816529 100644 --- a/apps/web/src/layouts/PlatformSelector.tsx +++ b/apps/web/src/layouts/PlatformSelector.tsx @@ -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; diff --git a/apps/web/src/pages/shops/ShopsPage.tsx b/apps/web/src/pages/shops/ShopsPage.tsx index 3572929..8b06247 100644 --- a/apps/web/src/pages/shops/ShopsPage.tsx +++ b/apps/web/src/pages/shops/ShopsPage.tsx @@ -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 = { deleted: { color: 'default', text: '已删除' }, }; -const CREDENTIAL_STATUS_MAP: Record = { - 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(); + // 水印模板选项(工作空间全量活动模板,量小一次拉取) + const [watermarkTemplates, setWatermarkTemplates] = useState([]); + const bindingBeforeRef = useRef(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('已保存,新密钥验证进行中,稍后可刷新查看结果'); + // 水印绑定是独立接口,仅在与打开弹窗时不同才提交 + 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(); } - 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 ( - {status.text} - {store.status !== 'verifying' && ( - {credential.text} - )} - {store.last_error && ( - {view.text} + {view.note && ( + - - + {view.note} + )} ); @@ -408,9 +505,7 @@ const ShopsPage = () => { dataIndex: 'updated_at', width: 170, render: (value: string) => ( - - {formatDateTime(value)} - + {formatDateTime(value)} ), }, { @@ -503,11 +598,7 @@ const ShopsPage = () => { destroyOnHidden maskClosable={false} > - - form={createForm} - layout="vertical" - onFinish={handleCreate} - > + form={createForm} layout="vertical" onFinish={handleCreate}> { name="client_id" label="Client ID" required - rules={[{ required: true, whitespace: true, message: '请输入平台 Client ID' }]} + rules={[ + { required: true, whitespace: true, message: '请输入平台 Client ID' }, + ]} > @@ -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 = () => { > ({ + value: template.id, + label: template.name, + }))} + /> + {editingStore.platform_client_id}(平台与账户不可修改) diff --git a/apps/web/src/pages/watermarks/WatermarksPage.tsx b/apps/web/src/pages/watermarks/WatermarksPage.tsx index 3676ba2..fa894bc 100644 --- a/apps/web/src/pages/watermarks/WatermarksPage.tsx +++ b/apps/web/src/pages/watermarks/WatermarksPage.tsx @@ -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 = () => ( - -); +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 = { + 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([]); + const [nextCursor, setNextCursor] = useState(null); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + + const [fonts, setFonts] = useState>([]); + + // 筛选:输入草稿 + 点击「查询」后生效的已应用条件(纯客户端过滤) + const [kindDraft, setKindDraft] = useState('all'); + const [nameDraft, setNameDraft] = useState(''); + const [dateDraft, setDateDraft] = useState<[string, string] | null>(null); + const [kindFilter, setKindFilter] = useState('all'); + const [nameFilter, setNameFilter] = useState(''); + const [dateFilter, setDateFilter] = useState<[string, string] | null>(null); + + const [selectedIds, setSelectedIds] = useState([]); + const [batchDeleting, setBatchDeleting] = useState(false); + + const [editorOpen, setEditorOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [editing, setEditing] = useState(null); + const [previewing, setPreviewing] = useState(null); + const [form] = Form.useForm(); + + // 图片水印:已上传(或既有)的素材;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>({}); + + // 实时预览:随渲染相关字段变化 + const [previewValues, setPreviewValues] = useState(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 => { + 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 = {}; + 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 = {}; + 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 = [ + '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['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.text}; + }, + }, + { + 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 ? ( + {template.name} + ) : ( + + + + ); + } + return ( +
+
{template.config.text_content}
+ + {template.config.font_code}, {template.config.font_size_px}px, ■ + +
+ ); + }, + }, + { + 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) => ( + {formatDateTime(value)} + ), + }, + { + title: '操作', + key: 'actions', + width: 190, + render: (_, template) => ( + + + + + + ), + }, + ]; + + const previewConfig = useMemo>(() => { + 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 ( +
+ + + + + } + > + + + value={kindDraft} + onChange={setKindDraft} + style={{ width: 130 }} + options={[ + { value: 'all', label: '全部类型' }, + { value: 'text', label: '文字水印' }, + { value: 'image', label: '图片水印' }, + ]} + /> + setNameDraft(event.target.value)} + style={{ width: 180 }} + allowClear + /> + + setDateDraft( + dateString[0] && dateString[1] + ? [dateString[0], dateString[1]] + : null + ) + } + /> + + + + + + rowKey="id" + columns={columns} + dataSource={filtered} + loading={loading} + pagination={false} + rowSelection={{ + selectedRowKeys: selectedIds, + onChange: (keys) => setSelectedIds(keys.map(String)), + }} + locale={{ emptyText: '暂无水印模板,点击右上角「添加模板」创建' }} + /> + {nextCursor && ( +
+ +
+ )} +
+ + setEditorOpen(false)} + footer={null} + width={720} + destroyOnHidden + maskClosable={false} + > + + form={form} + layout="vertical" + onValuesChange={(_, values) => setPreviewValues(values as WatermarkFormValues)} + onFinish={handleSave} + > + + + + + + + + +