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