首页素材装修模块完成
This commit is contained in:
@@ -33,6 +33,7 @@ import AdminShotTaskSetDetail from './pages/AdminShotTaskSetDetail';
|
||||
import AdminReplicationProjectDetail from './pages/AdminReplicationProjectDetail';
|
||||
import AdminVideoPromptSchemaConfig from './pages/AdminVideoPromptSchemaConfig';
|
||||
import AdminContactRequests from './pages/AdminContactRequests';
|
||||
import AdminHomeMaterials from './pages/AdminHomeMaterials';
|
||||
|
||||
import { useAdminStore } from './store';
|
||||
|
||||
@@ -107,6 +108,7 @@ const App = () => {
|
||||
<Route path="consume" element={<AdminConsume />} />
|
||||
<Route path="platform" element={<AdminPlatform />} />
|
||||
<Route path="contact-requests" element={<AdminContactRequests />} />
|
||||
<Route path="home-materials" element={<AdminHomeMaterials />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Real API client for connecting to the FastAPI backend.
|
||||
* Handles auth tokens, request/response encryption, snake_case→camelCase conversion.
|
||||
* Supports both JSON bodies and multipart FormData bodies.
|
||||
*/
|
||||
|
||||
import { encrypt, decrypt, isCryptoAvailable } from './crypto';
|
||||
@@ -24,6 +25,7 @@ function toCamel(s: string): string {
|
||||
function keysToCamel(obj: unknown): unknown {
|
||||
if (Array.isArray(obj)) return obj.map(keysToCamel);
|
||||
if (obj !== null && typeof obj === 'object') {
|
||||
if (obj instanceof File || obj instanceof Blob || obj instanceof FormData) return obj;
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj as Record<string, unknown>).map(([k, v]) => [toCamel(k), keysToCamel(v)])
|
||||
);
|
||||
@@ -54,31 +56,35 @@ async function tryDecrypt(data: string): Promise<string | null> {
|
||||
|
||||
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const { method = 'GET', body, auth = true, encryptBody = USE_ENCRYPTION } = options;
|
||||
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
const headers: Record<string, string> = {};
|
||||
if (!isFormData) headers['Content-Type'] = 'application/json';
|
||||
|
||||
if (auth) {
|
||||
const token = getToken();
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
let bodyStr: string | undefined;
|
||||
let requestBody: BodyInit | undefined;
|
||||
if (body !== undefined) {
|
||||
const json = JSON.stringify(body);
|
||||
if (encryptBody) {
|
||||
headers['X-Encrypted'] = 'true';
|
||||
bodyStr = JSON.stringify({ data: await encrypt(json) });
|
||||
if (isFormData) {
|
||||
requestBody = body as FormData;
|
||||
} else {
|
||||
bodyStr = json;
|
||||
const json = JSON.stringify(body);
|
||||
if (encryptBody) {
|
||||
headers['X-Encrypted'] = 'true';
|
||||
requestBody = JSON.stringify({ data: await encrypt(json) });
|
||||
} else {
|
||||
requestBody = json;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const res = await fetch(`${BASE_URL}/api${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: bodyStr,
|
||||
body: requestBody,
|
||||
});
|
||||
|
||||
if (res.status === 204) return undefined as T;
|
||||
@@ -96,7 +102,7 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
|
||||
|
||||
// If the response is encrypted, decrypt it
|
||||
const hasEncryptedData = parsed && typeof parsed.data === 'string';
|
||||
if (hasEncryptedData && encryptBody) {
|
||||
if (hasEncryptedData && encryptBody && !isFormData) {
|
||||
const decrypted = await tryDecrypt(parsed.data);
|
||||
if (decrypted !== null) {
|
||||
parsed = JSON.parse(decrypted);
|
||||
@@ -107,7 +113,8 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
|
||||
|
||||
// Handle error responses
|
||||
if (!res.ok) {
|
||||
const msg = parsed?.detail || `请求失败 (${res.status})`;
|
||||
const rawMsg = parsed?.detail || parsed?.message || `请求失败 (${res.status})`;
|
||||
const msg = typeof rawMsg === 'string' ? rawMsg : JSON.stringify(rawMsg);
|
||||
if (res.status === 401) {
|
||||
clearToken();
|
||||
// Only redirect to login if not already on login page
|
||||
@@ -125,7 +132,7 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
|
||||
// Convenience methods
|
||||
export const api = {
|
||||
get: <T>(path: string, auth = true) => apiRequest<T>(path, { auth }),
|
||||
post: <T>(path: string, body?: unknown, auth = true) => apiRequest<T>(path, { method: 'POST', body, auth }),
|
||||
put: <T>(path: string, body?: unknown, auth = true) => apiRequest<T>(path, { method: 'PUT', body, auth }),
|
||||
post: <T>(path: string, body?: unknown, auth = true) => apiRequest<T>(path, { method: 'POST', body, auth, encryptBody: body instanceof FormData ? false : USE_ENCRYPTION }),
|
||||
put: <T>(path: string, body?: unknown, auth = true) => apiRequest<T>(path, { method: 'PUT', body, auth, encryptBody: body instanceof FormData ? false : USE_ENCRYPTION }),
|
||||
delete: <T>(path: string, auth = true) => apiRequest<T>(path, { method: 'DELETE', auth }),
|
||||
};
|
||||
|
||||
@@ -17,6 +17,28 @@ import type {
|
||||
AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams,
|
||||
} from '../types';
|
||||
|
||||
import type {
|
||||
HomeMaterialAsset,
|
||||
HomeMaterialAssetQueryParams,
|
||||
HomeMaterialAssetStatusOut,
|
||||
HomeMaterialAssetUpdatePayload,
|
||||
HomeMaterialCategory,
|
||||
HomeMaterialCategoryPayload,
|
||||
HomeMaterialCategoryQueryParams,
|
||||
HomeMaterialConfig,
|
||||
HomeMaterialListResponse,
|
||||
HomeMaterialRegeneratePayload,
|
||||
HomeMaterialTextWatermarkConfig,
|
||||
HomeMaterialTextWatermarkPayload,
|
||||
HomeMaterialTextWatermarkPreviewRequest,
|
||||
HomeMaterialTextWatermarkPreviewResponse,
|
||||
HomeMaterialUploadAssetParams,
|
||||
HomeMaterialUploadResult,
|
||||
HomeMaterialWatermark,
|
||||
HomeMaterialWatermarkPayload,
|
||||
HomeMaterialWatermarkQueryParams,
|
||||
} from '../types';
|
||||
|
||||
// ── Auth ──────────────────────────────────────────────────
|
||||
|
||||
export async function login(username: string, password: string, captchaToken?: string, rememberMe?: boolean): Promise<User> {
|
||||
@@ -748,4 +770,162 @@ export async function getMaterialConsumpList(params: MaterialConsumpParams): Pro
|
||||
if (params.page !== undefined) query.set('page', String(params.page));
|
||||
if (params.page_size !== undefined) query.set('page_size', String(params.page_size));
|
||||
return api.get(`/material-consumption/admin/list?${query.toString()}`);
|
||||
}
|
||||
}
|
||||
// ── 首页素材行业装修 ──────────────────────────────────────
|
||||
|
||||
export async function getHomeMaterialConfig(): Promise<HomeMaterialConfig> {
|
||||
return api.get<HomeMaterialConfig>('/admin/home-material/config');
|
||||
}
|
||||
|
||||
export async function saveHomeMaterialConfig(payload: HomeMaterialConfig): Promise<HomeMaterialConfig> {
|
||||
return api.put<HomeMaterialConfig>('/admin/home-material/config', {
|
||||
enabled: payload.enabled,
|
||||
title: payload.title,
|
||||
subtitle: payload.subtitle,
|
||||
show_original_in_admin: payload.showOriginalInAdmin,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getHomeMaterialCategories(filters?: HomeMaterialCategoryQueryParams): Promise<HomeMaterialListResponse<HomeMaterialCategory>> {
|
||||
const params = new URLSearchParams();
|
||||
setMaybe(params, 'page', filters?.page);
|
||||
setMaybe(params, 'page_size', filters?.pageSize);
|
||||
setMaybe(params, 'keyword', filters?.keyword);
|
||||
setMaybe(params, 'is_active', filters?.isActive);
|
||||
const q = params.toString() ? `?${params}` : '';
|
||||
return api.get(`/admin/home-material/categories${q}`);
|
||||
}
|
||||
|
||||
export async function saveHomeMaterialCategory(payload: HomeMaterialCategoryPayload & { id?: string }): Promise<HomeMaterialCategory> {
|
||||
const body = {
|
||||
name: payload.name,
|
||||
key: payload.key,
|
||||
description: payload.description || null,
|
||||
icon: payload.icon || null,
|
||||
is_active: payload.is_active,
|
||||
sort_order: payload.sort_order,
|
||||
};
|
||||
if (payload.id) return api.put(`/admin/home-material/categories/${payload.id}`, body);
|
||||
return api.post('/admin/home-material/categories', body);
|
||||
}
|
||||
|
||||
export async function deleteHomeMaterialCategory(id: string): Promise<void> {
|
||||
await api.delete(`/admin/home-material/categories/${id}`);
|
||||
}
|
||||
|
||||
export async function getHomeMaterialWatermarks(filters?: HomeMaterialWatermarkQueryParams): Promise<HomeMaterialListResponse<HomeMaterialWatermark>> {
|
||||
const params = new URLSearchParams();
|
||||
setMaybe(params, 'page', filters?.page);
|
||||
setMaybe(params, 'page_size', filters?.pageSize);
|
||||
setMaybe(params, 'is_active', filters?.isActive);
|
||||
const q = params.toString() ? `?${params}` : '';
|
||||
return api.get(`/admin/home-material/watermarks${q}`);
|
||||
}
|
||||
|
||||
export async function uploadHomeMaterialWatermark(file: File, name?: string, isDefault = false): Promise<HomeMaterialWatermark> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
if (name) form.append('name', name);
|
||||
form.append('is_default', String(isDefault));
|
||||
return api.post<HomeMaterialWatermark>('/admin/home-material/watermarks', form);
|
||||
}
|
||||
|
||||
export async function updateHomeMaterialWatermark(id: string, payload: HomeMaterialWatermarkPayload): Promise<HomeMaterialWatermark> {
|
||||
return api.put(`/admin/home-material/watermarks/${id}`, payload);
|
||||
}
|
||||
|
||||
export async function deleteHomeMaterialWatermark(id: string): Promise<void> {
|
||||
await api.delete(`/admin/home-material/watermarks/${id}`);
|
||||
}
|
||||
|
||||
export async function getHomeMaterialAssets(filters?: HomeMaterialAssetQueryParams): Promise<HomeMaterialListResponse<HomeMaterialAsset>> {
|
||||
const params = new URLSearchParams();
|
||||
setMaybe(params, 'page', filters?.page);
|
||||
setMaybe(params, 'page_size', filters?.pageSize);
|
||||
setMaybe(params, 'category_id', filters?.categoryId);
|
||||
setMaybe(params, 'media_type', filters?.mediaType);
|
||||
setMaybe(params, 'status', filters?.status);
|
||||
setMaybe(params, 'is_active', filters?.isActive);
|
||||
setMaybe(params, 'keyword', filters?.keyword);
|
||||
const q = params.toString() ? `?${params}` : '';
|
||||
return api.get(`/admin/home-material/assets${q}`);
|
||||
}
|
||||
|
||||
export async function getHomeMaterialAsset(id: string): Promise<HomeMaterialAsset> {
|
||||
return api.get(`/admin/home-material/assets/${id}`);
|
||||
}
|
||||
|
||||
export async function getHomeMaterialAssetStatus(id: string): Promise<HomeMaterialAssetStatusOut> {
|
||||
return api.get(`/admin/home-material/assets/${id}/status`);
|
||||
}
|
||||
|
||||
function toHomeMaterialTextWatermarkPayload(config?: HomeMaterialTextWatermarkConfig | null): HomeMaterialTextWatermarkPayload | null {
|
||||
if (!config) return null;
|
||||
return {
|
||||
text: config.text,
|
||||
opacity_level: config.opacityLevel,
|
||||
font_size_px: config.fontSizePx,
|
||||
color: config.color,
|
||||
rotate_deg: config.rotateDeg,
|
||||
gap_x: config.gapX,
|
||||
gap_y: config.gapY,
|
||||
staggered: config.staggered,
|
||||
};
|
||||
}
|
||||
|
||||
export async function uploadHomeMaterialAsset(payload: HomeMaterialUploadAssetParams): Promise<HomeMaterialUploadResult> {
|
||||
const form = new FormData();
|
||||
form.append('category_id', payload.categoryId);
|
||||
form.append('file', payload.file);
|
||||
form.append('media_type', payload.mediaType);
|
||||
if (payload.title && payload.title.trim()) form.append('title', payload.title.trim());
|
||||
form.append('watermark_type', payload.watermarkType);
|
||||
|
||||
if (payload.watermarkType === 'image') {
|
||||
if (payload.watermarkId) form.append('watermark_id', payload.watermarkId);
|
||||
if (payload.watermarkFile) form.append('watermark_file', payload.watermarkFile);
|
||||
form.append('opacity_level', String(payload.opacityLevel));
|
||||
form.append('position', payload.position);
|
||||
if (payload.customXRatio !== undefined && payload.customXRatio !== null) form.append('custom_x_ratio', String(payload.customXRatio));
|
||||
if (payload.customYRatio !== undefined && payload.customYRatio !== null) form.append('custom_y_ratio', String(payload.customYRatio));
|
||||
form.append('size_mode', payload.sizeMode);
|
||||
if (payload.widthRatio !== undefined && payload.widthRatio !== null) form.append('width_ratio', String(payload.widthRatio));
|
||||
if (payload.widthPx !== undefined && payload.widthPx !== null) form.append('width_px', String(payload.widthPx));
|
||||
form.append('margin_x', String(payload.marginX));
|
||||
form.append('margin_y', String(payload.marginY));
|
||||
} else {
|
||||
const text = toHomeMaterialTextWatermarkPayload(payload.textWatermark);
|
||||
if (text) {
|
||||
form.append('text_watermark_text', text.text);
|
||||
form.append('text_watermark_opacity_level', String(text.opacity_level));
|
||||
form.append('text_watermark_font_size_px', String(text.font_size_px));
|
||||
form.append('text_watermark_color', text.color);
|
||||
form.append('text_watermark_rotate_deg', String(text.rotate_deg));
|
||||
form.append('text_watermark_gap_x', String(text.gap_x));
|
||||
form.append('text_watermark_gap_y', String(text.gap_y));
|
||||
form.append('text_watermark_staggered', String(text.staggered));
|
||||
}
|
||||
}
|
||||
|
||||
form.append('is_active', String(payload.isActive));
|
||||
form.append('sort_order', String(payload.sortOrder));
|
||||
form.append('wait', String(!!payload.wait));
|
||||
form.append('wait_timeout_seconds', String(payload.waitTimeoutSeconds ?? 30));
|
||||
return api.post<HomeMaterialUploadResult>('/admin/home-material/assets', form);
|
||||
}
|
||||
|
||||
export async function updateHomeMaterialAsset(id: string, payload: HomeMaterialAssetUpdatePayload): Promise<HomeMaterialAsset> {
|
||||
return api.put(`/admin/home-material/assets/${id}`, payload);
|
||||
}
|
||||
|
||||
export async function previewHomeMaterialTextWatermark(payload: HomeMaterialTextWatermarkPreviewRequest): Promise<HomeMaterialTextWatermarkPreviewResponse> {
|
||||
return api.post<HomeMaterialTextWatermarkPreviewResponse>('/admin/home-material/text-watermark-preview', payload);
|
||||
}
|
||||
|
||||
export async function regenerateHomeMaterialWatermark(id: string, payload: HomeMaterialRegeneratePayload): Promise<HomeMaterialUploadResult> {
|
||||
return api.post(`/admin/home-material/assets/${id}/regenerate-watermark`, payload);
|
||||
}
|
||||
|
||||
export async function deleteHomeMaterialAsset(id: string): Promise<void> {
|
||||
await api.delete(`/admin/home-material/assets/${id}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Card, Form, Input, Space, Switch, Tabs, message } from 'antd';
|
||||
import { PlusOutlined, SaveOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
getHomeMaterialCategories,
|
||||
getHomeMaterialConfig,
|
||||
getHomeMaterialWatermarks,
|
||||
saveHomeMaterialConfig,
|
||||
} from '../api';
|
||||
import type { HomeMaterialCategory, HomeMaterialConfig, HomeMaterialWatermark } from '../types';
|
||||
import HomeMaterialAssetTable from './homeMaterials/HomeMaterialAssetTable';
|
||||
import HomeMaterialCategoryPanel from './homeMaterials/HomeMaterialCategoryPanel';
|
||||
import HomeMaterialUploadModal from './homeMaterials/HomeMaterialUploadModal';
|
||||
import WatermarkLibraryModal from './homeMaterials/WatermarkLibraryModal';
|
||||
|
||||
const AdminHomeMaterials: React.FC = () => {
|
||||
const [form] = Form.useForm<HomeMaterialConfig>();
|
||||
const [configLoading, setConfigLoading] = useState(false);
|
||||
const [categories, setCategories] = useState<HomeMaterialCategory[]>([]);
|
||||
const [watermarks, setWatermarks] = useState<HomeMaterialWatermark[]>([]);
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
|
||||
const loadBase = async () => {
|
||||
const [catRes, wmRes] = await Promise.all([
|
||||
getHomeMaterialCategories({ page: 1, pageSize: 200 }),
|
||||
getHomeMaterialWatermarks({ page: 1, pageSize: 200, isActive: true }),
|
||||
]);
|
||||
setCategories(catRes.items);
|
||||
setWatermarks(wmRes.items);
|
||||
};
|
||||
|
||||
const loadConfig = async () => {
|
||||
setConfigLoading(true);
|
||||
try {
|
||||
const cfg = await getHomeMaterialConfig();
|
||||
form.setFieldsValue(cfg);
|
||||
} finally {
|
||||
setConfigLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadConfig();
|
||||
loadBase();
|
||||
}, []);
|
||||
|
||||
const saveConfig = async () => {
|
||||
const values = await form.validateFields();
|
||||
await saveHomeMaterialConfig(values);
|
||||
message.success('配置已保存');
|
||||
};
|
||||
|
||||
const refreshAll = async () => {
|
||||
await loadBase();
|
||||
setReloadKey(k => k + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card style={{ marginBottom: 16 }} loading={configLoading}>
|
||||
<Form form={form} layout="inline" initialValues={{ enabled: false, title: '行业素材案例', subtitle: '精选图片与视频素材展示', showOriginalInAdmin: true }}>
|
||||
<Form.Item name="enabled" label="首页展示" valuePropName="checked"><Switch /></Form.Item>
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入标题' }]}><Input style={{ width: 220 }} /></Form.Item>
|
||||
<Form.Item name="subtitle" label="副标题"><Input style={{ width: 320 }} /></Form.Item>
|
||||
<Form.Item name="showOriginalInAdmin" label="后台展示原素材" valuePropName="checked"><Switch /></Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" icon={<SaveOutlined />} onClick={saveConfig}>保存配置</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="首页素材行业装修"
|
||||
extra={(
|
||||
<Space>
|
||||
<Button onClick={refreshAll}>刷新</Button>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setUploadOpen(true)}>上传素材</Button>
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'assets',
|
||||
label: '素材管理',
|
||||
children: <HomeMaterialAssetTable categories={categories} watermarks={watermarks} reloadKey={reloadKey} />,
|
||||
},
|
||||
{
|
||||
key: 'categories',
|
||||
label: '行业类别',
|
||||
children: <HomeMaterialCategoryPanel onChanged={refreshAll} />,
|
||||
},
|
||||
{
|
||||
key: 'watermarks',
|
||||
label: '水印库',
|
||||
children: <WatermarkLibraryModal embedded onChanged={refreshAll} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<HomeMaterialUploadModal
|
||||
open={uploadOpen}
|
||||
onClose={() => setUploadOpen(false)}
|
||||
categories={categories}
|
||||
watermarks={watermarks}
|
||||
onSuccess={refreshAll}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminHomeMaterials;
|
||||
@@ -0,0 +1,333 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Dropdown, Image, Modal, Select, Space, Table, Tag, message } from 'antd';
|
||||
import { DeleteOutlined, MoreOutlined, ReloadOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
deleteHomeMaterialAsset,
|
||||
getHomeMaterialAssetStatus,
|
||||
getHomeMaterialAssets,
|
||||
regenerateHomeMaterialWatermark,
|
||||
} from '../../api';
|
||||
import type {
|
||||
HomeMaterialAsset,
|
||||
HomeMaterialAssetQueryParams,
|
||||
HomeMaterialCategory,
|
||||
HomeMaterialWatermark,
|
||||
HomeMaterialWatermarkConfig,
|
||||
HomeMaterialTextWatermarkConfig,
|
||||
} from '../../types';
|
||||
import { apiUrl } from '../../utils/resourceUrl';
|
||||
import WatermarkEditor from './WatermarkEditor';
|
||||
|
||||
interface Props {
|
||||
categories: HomeMaterialCategory[];
|
||||
watermarks: HomeMaterialWatermark[];
|
||||
reloadKey?: number;
|
||||
}
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
draft: { text: '草稿', color: 'default' },
|
||||
processing: { text: '处理中', color: 'processing' },
|
||||
success: { text: '成功', color: 'success' },
|
||||
failed: { text: '失败', color: 'error' },
|
||||
};
|
||||
|
||||
const defaultTextWatermark: HomeMaterialTextWatermarkConfig = {
|
||||
text: '民众普康 AI',
|
||||
opacityLevel: 2,
|
||||
fontSizePx: 28,
|
||||
color: '#ffffff',
|
||||
rotateDeg: -30,
|
||||
gapX: 220,
|
||||
gapY: 140,
|
||||
staggered: true,
|
||||
};
|
||||
|
||||
const defaultConfig: HomeMaterialWatermarkConfig = {
|
||||
watermarkType: 'image',
|
||||
watermarkId: null,
|
||||
opacityLevel: 6,
|
||||
position: 'bottom_right',
|
||||
customXRatio: null,
|
||||
customYRatio: null,
|
||||
sizeMode: 'ratio',
|
||||
widthRatio: 0.18,
|
||||
widthPx: null,
|
||||
marginX: 24,
|
||||
marginY: 24,
|
||||
textWatermark: defaultTextWatermark,
|
||||
};
|
||||
|
||||
function normalizeTextConfig(raw: any): HomeMaterialTextWatermarkConfig {
|
||||
return {
|
||||
text: raw?.text || defaultTextWatermark.text,
|
||||
opacityLevel: raw?.opacityLevel ?? raw?.opacity_level ?? defaultTextWatermark.opacityLevel,
|
||||
fontSizePx: raw?.fontSizePx ?? raw?.font_size_px ?? defaultTextWatermark.fontSizePx,
|
||||
color: raw?.color || defaultTextWatermark.color,
|
||||
rotateDeg: raw?.rotateDeg ?? raw?.rotate_deg ?? defaultTextWatermark.rotateDeg,
|
||||
gapX: raw?.gapX ?? raw?.gap_x ?? defaultTextWatermark.gapX,
|
||||
gapY: raw?.gapY ?? raw?.gap_y ?? defaultTextWatermark.gapY,
|
||||
staggered: raw?.staggered ?? defaultTextWatermark.staggered,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeConfig(raw: unknown, fallbackWatermarkId?: string | null): HomeMaterialWatermarkConfig {
|
||||
const data = (raw || {}) as any;
|
||||
const watermarkType = data.watermarkType || data.watermark_type || 'image';
|
||||
return {
|
||||
watermarkType,
|
||||
watermarkId: watermarkType === 'repeated_text' ? null : (data.watermarkId || data.watermark_id || fallbackWatermarkId || null),
|
||||
opacityLevel: data.opacityLevel || data.opacity_level || 6,
|
||||
position: data.position || 'bottom_right',
|
||||
customXRatio: data.customXRatio ?? data.custom_x_ratio ?? null,
|
||||
customYRatio: data.customYRatio ?? data.custom_y_ratio ?? null,
|
||||
sizeMode: data.sizeMode || data.size_mode || 'ratio',
|
||||
widthRatio: data.widthRatio ?? data.width_ratio ?? 0.18,
|
||||
widthPx: data.widthPx ?? data.width_px ?? null,
|
||||
marginX: data.marginX ?? data.margin_x ?? 24,
|
||||
marginY: data.marginY ?? data.margin_y ?? 24,
|
||||
textWatermark: normalizeTextConfig(data.textWatermark || data.text_watermark),
|
||||
};
|
||||
}
|
||||
|
||||
function isValidConfig(config: HomeMaterialWatermarkConfig): boolean {
|
||||
if (config.watermarkType === 'repeated_text') return !!config.textWatermark?.text?.trim();
|
||||
return !!config.watermarkId;
|
||||
}
|
||||
|
||||
const HomeMaterialAssetTable: React.FC<Props> = ({ categories, watermarks, reloadKey }) => {
|
||||
const [items, setItems] = useState<HomeMaterialAsset[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filters, setFilters] = useState<HomeMaterialAssetQueryParams>({ page: 1, pageSize: 20 });
|
||||
const [preview, setPreview] = useState<HomeMaterialAsset | null>(null);
|
||||
const [regen, setRegen] = useState<HomeMaterialAsset | null>(null);
|
||||
const [regenConfig, setRegenConfig] = useState<HomeMaterialWatermarkConfig>(defaultConfig);
|
||||
const [regenSubmitting, setRegenSubmitting] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getHomeMaterialAssets(filters);
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [filters, reloadKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const processing = items.filter(i => i.status === 'processing');
|
||||
if (processing.length === 0) return;
|
||||
const timer = window.setInterval(async () => {
|
||||
const statuses = await Promise.all(processing.map(i => getHomeMaterialAssetStatus(i.id).catch(() => null)));
|
||||
const changed = statuses.some(s => s && s.status !== 'processing');
|
||||
if (changed) load();
|
||||
}, 2500);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [items]);
|
||||
|
||||
const openRegenerate = (row: HomeMaterialAsset) => {
|
||||
setRegen(row);
|
||||
setRegenConfig(normalizeConfig(row.watermarkConfig, row.watermarkId));
|
||||
};
|
||||
|
||||
const getPreviewImageUrl = (row: HomeMaterialAsset) => {
|
||||
if (row.mediaType === 'image') return apiUrl(row.watermarkedUrl || row.originalUrl);
|
||||
return apiUrl(row.coverUrl || '');
|
||||
};
|
||||
|
||||
const getVideoUrl = (row: HomeMaterialAsset) => apiUrl(row.watermarkedUrl || row.originalUrl);
|
||||
|
||||
const confirmDelete = (row: HomeMaterialAsset) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除该素材?',
|
||||
content: row.title || row.id,
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
async onOk() {
|
||||
await deleteHomeMaterialAsset(row.id);
|
||||
message.success('删除成功');
|
||||
await load();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const submitRegenerate = async () => {
|
||||
if (!regen || regenSubmitting) return;
|
||||
if (!isValidConfig(regenConfig)) {
|
||||
message.warning(regenConfig.watermarkType === 'repeated_text' ? '请输入重复文字水印内容' : '请选择水印图片');
|
||||
return;
|
||||
}
|
||||
setRegenSubmitting(true);
|
||||
try {
|
||||
await regenerateHomeMaterialWatermark(regen.id, {
|
||||
watermark_type: regenConfig.watermarkType,
|
||||
watermark_id: regenConfig.watermarkType === 'image' ? regenConfig.watermarkId : null,
|
||||
opacity_level: regenConfig.opacityLevel,
|
||||
position: regenConfig.position,
|
||||
custom_x_ratio: regenConfig.customXRatio,
|
||||
custom_y_ratio: regenConfig.customYRatio,
|
||||
size_mode: regenConfig.sizeMode,
|
||||
width_ratio: regenConfig.widthRatio,
|
||||
width_px: regenConfig.widthPx,
|
||||
margin_x: regenConfig.marginX,
|
||||
margin_y: regenConfig.marginY,
|
||||
text_watermark: regenConfig.watermarkType === 'repeated_text' && regenConfig.textWatermark ? {
|
||||
text: regenConfig.textWatermark.text,
|
||||
opacity_level: regenConfig.textWatermark.opacityLevel,
|
||||
font_size_px: regenConfig.textWatermark.fontSizePx,
|
||||
color: regenConfig.textWatermark.color,
|
||||
rotate_deg: regenConfig.textWatermark.rotateDeg,
|
||||
gap_x: regenConfig.textWatermark.gapX,
|
||||
gap_y: regenConfig.textWatermark.gapY,
|
||||
staggered: regenConfig.textWatermark.staggered,
|
||||
} : null,
|
||||
wait: false,
|
||||
});
|
||||
message.success('已提交重新生成');
|
||||
setRegen(null);
|
||||
await load();
|
||||
} finally {
|
||||
setRegenSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 180 }}
|
||||
placeholder="行业"
|
||||
value={filters.categoryId}
|
||||
onChange={(v) => setFilters(f => ({ ...f, categoryId: v, page: 1 }))}
|
||||
options={categories.map(c => ({ label: c.name, value: c.id }))}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
placeholder="素材类型"
|
||||
value={filters.mediaType}
|
||||
onChange={(v) => setFilters(f => ({ ...f, mediaType: v, page: 1 }))}
|
||||
options={[{ label: '图片', value: 'image' }, { label: '视频', value: 'video' }]}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
placeholder="处理状态"
|
||||
value={filters.status}
|
||||
onChange={(v) => setFilters(f => ({ ...f, status: v, page: 1 }))}
|
||||
options={Object.entries(statusMap).map(([value, item]) => ({ value, label: item.text }))}
|
||||
/>
|
||||
<Button onClick={load}>刷新</Button>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={items}
|
||||
pagination={{ current: filters.page, pageSize: filters.pageSize, total, onChange: (page, pageSize) => setFilters(f => ({ ...f, page, pageSize })) }}
|
||||
scroll={{ x: 1180 }}
|
||||
columns={[
|
||||
{
|
||||
title: '预览',
|
||||
width: 110,
|
||||
render: (_: unknown, row: HomeMaterialAsset) => {
|
||||
const imageUrl = getPreviewImageUrl(row);
|
||||
if (row.mediaType === 'image') {
|
||||
return imageUrl ? <Image width={86} height={64} style={{ objectFit: 'cover', borderRadius: 6 }} src={imageUrl} /> : '-';
|
||||
}
|
||||
const videoUrl = getVideoUrl(row);
|
||||
const posterUrl = apiUrl(row.coverUrl || '');
|
||||
return videoUrl ? (
|
||||
<video
|
||||
src={videoUrl}
|
||||
poster={posterUrl || undefined}
|
||||
muted
|
||||
preload="metadata"
|
||||
style={{ width: 100, height: 64, objectFit: 'cover', borderRadius: 6, background: '#000' }}
|
||||
/>
|
||||
) : '-';
|
||||
},
|
||||
},
|
||||
{ title: '标题', dataIndex: 'title', render: (v: string | null) => v || <span style={{ color: '#999' }}>未命名素材</span> },
|
||||
{ title: '行业', dataIndex: 'categoryName' },
|
||||
{ title: '类型', dataIndex: 'mediaType', render: (v: string) => v === 'image' ? '图片' : '视频' },
|
||||
{ title: '状态', dataIndex: 'status', render: (v: string) => <Tag color={statusMap[v]?.color}>{statusMap[v]?.text || v}</Tag> },
|
||||
{
|
||||
title: '水印',
|
||||
render: (_: unknown, row: HomeMaterialAsset) => {
|
||||
const cfg = normalizeConfig(row.watermarkConfig, row.watermarkId);
|
||||
return cfg.watermarkType === 'repeated_text' ? '重复文字水印' : (row.watermarkName || '图片水印');
|
||||
},
|
||||
},
|
||||
{ title: '排序', dataIndex: 'sortOrder' },
|
||||
{ title: '错误', dataIndex: 'errorMessage', ellipsis: true },
|
||||
{
|
||||
title: '操作',
|
||||
width: 150,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: HomeMaterialAsset) => (
|
||||
<Space size={8}>
|
||||
<Button size="small" onClick={() => setPreview(row)}>查看</Button>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'regenerate', icon: <ReloadOutlined />, label: '重新生成水印' },
|
||||
{ key: 'delete', icon: <DeleteOutlined />, label: '删除素材', danger: true },
|
||||
],
|
||||
onClick: ({ key }) => {
|
||||
if (key === 'regenerate') openRegenerate(row);
|
||||
if (key === 'delete') confirmDelete(row);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button size="small" icon={<MoreOutlined />}>更多</Button>
|
||||
</Dropdown>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Modal title="素材详情" open={!!preview} onCancel={() => setPreview(null)} footer={null} width={900} destroyOnHidden>
|
||||
{preview && (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||
<div><b>原素材:</b>{preview.originalUrl ? apiUrl(preview.originalUrl) : '-'}</div>
|
||||
<div><b>水印素材:</b>{preview.watermarkedUrl ? apiUrl(preview.watermarkedUrl) : '-'}</div>
|
||||
{preview.mediaType === 'image' ? (
|
||||
<Image src={apiUrl(preview.watermarkedUrl || preview.originalUrl)} />
|
||||
) : (
|
||||
<video src={getVideoUrl(preview)} poster={apiUrl(preview.coverUrl || '') || undefined} controls style={{ width: '100%', background: '#000' }} />
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
<Modal
|
||||
title="重新生成水印"
|
||||
open={!!regen}
|
||||
onCancel={() => !regenSubmitting && setRegen(null)}
|
||||
onOk={submitRegenerate}
|
||||
confirmLoading={regenSubmitting}
|
||||
okButtonProps={{ disabled: regenSubmitting || !isValidConfig(regenConfig) }}
|
||||
cancelButtonProps={{ disabled: regenSubmitting }}
|
||||
width={980}
|
||||
destroyOnHidden
|
||||
>
|
||||
{regen && (
|
||||
<WatermarkEditor
|
||||
value={regenConfig}
|
||||
onChange={setRegenConfig}
|
||||
watermarks={watermarks}
|
||||
mediaUrl={apiUrl(regen.originalUrl || regen.watermarkedUrl)}
|
||||
mediaType={regen.mediaType}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomeMaterialAssetTable;
|
||||
@@ -0,0 +1,156 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Table, message } from 'antd';
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import * as AntIcons from '@ant-design/icons';
|
||||
import { deleteHomeMaterialCategory, getHomeMaterialCategories, saveHomeMaterialCategory } from '../../api';
|
||||
import type { HomeMaterialCategory, HomeMaterialCategoryPayload } from '../../types';
|
||||
|
||||
|
||||
const ICON_COMPONENTS = AntIcons as unknown as Record<string, React.ComponentType<{ style?: React.CSSProperties }>>;
|
||||
|
||||
const CATEGORY_ICON_OPTIONS = [
|
||||
{ label: '通用应用', value: 'AppstoreOutlined' },
|
||||
{ label: '图片素材', value: 'PictureOutlined' },
|
||||
{ label: '视频素材', value: 'VideoCameraOutlined' },
|
||||
{ label: '电商购物', value: 'ShoppingOutlined' },
|
||||
{ label: '门店商铺', value: 'ShopOutlined' },
|
||||
{ label: '美妆护肤', value: 'SkinOutlined' },
|
||||
{ label: '餐饮食品', value: 'CoffeeOutlined' },
|
||||
{ label: '家居生活', value: 'HomeOutlined' },
|
||||
{ label: '汽车出行', value: 'CarOutlined' },
|
||||
{ label: '医疗健康', value: 'MedicineBoxOutlined' },
|
||||
{ label: '教育培训', value: 'BookOutlined' },
|
||||
{ label: '数码科技', value: 'LaptopOutlined' },
|
||||
{ label: '旅游定位', value: 'EnvironmentOutlined' },
|
||||
{ label: '摄影影像', value: 'CameraOutlined' },
|
||||
{ label: '礼品活动', value: 'GiftOutlined' },
|
||||
{ label: '热门爆款', value: 'FireOutlined' },
|
||||
{ label: '品牌服务', value: 'CustomerServiceOutlined' },
|
||||
{ label: '营销增长', value: 'RocketOutlined' },
|
||||
{ label: '机构企业', value: 'BankOutlined' },
|
||||
{ label: '娱乐休闲', value: 'SmileOutlined' },
|
||||
];
|
||||
|
||||
function renderIconOption(value?: string | null, label?: string) {
|
||||
if (!value) {
|
||||
return <span style={{ color: '#999' }}>未设置</span>;
|
||||
}
|
||||
const Icon = ICON_COMPONENTS[value];
|
||||
return (
|
||||
<Space size={6}>
|
||||
{Icon ? <Icon /> : null}
|
||||
<span>{label || value}</span>
|
||||
<span style={{ color: '#999' }}>{value}</span>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
const HomeMaterialCategoryPanel: React.FC<{ onChanged?: () => void }> = ({ onChanged }) => {
|
||||
const [items, setItems] = useState<HomeMaterialCategory[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<HomeMaterialCategory | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getHomeMaterialCategories({ page, pageSize: 20 });
|
||||
setItems(res.items);
|
||||
setTotal(res.total);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, [page]);
|
||||
|
||||
const openEdit = (row?: HomeMaterialCategory) => {
|
||||
setEditing(row || null);
|
||||
form.setFieldsValue(row ? {
|
||||
name: row.name,
|
||||
key: row.key,
|
||||
description: row.description,
|
||||
icon: row.icon,
|
||||
is_active: row.isActive,
|
||||
sort_order: row.sortOrder,
|
||||
} : { is_active: true, sort_order: 0 });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
const values = await form.validateFields();
|
||||
await saveHomeMaterialCategory({ ...(values as HomeMaterialCategoryPayload), id: editing?.id });
|
||||
message.success('保存成功');
|
||||
setModalOpen(false);
|
||||
await load();
|
||||
onChanged?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<div />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => openEdit()}>新增行业</Button>
|
||||
</div>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={items}
|
||||
pagination={{ current: page, total, pageSize: 20, onChange: setPage }}
|
||||
columns={[
|
||||
{ title: '行业名称', dataIndex: 'name' },
|
||||
{ title: 'Key', dataIndex: 'key' },
|
||||
{ title: '图标', dataIndex: 'icon', render: (v?: string | null) => renderIconOption(v) },
|
||||
{ title: '启用', dataIndex: 'isActive', render: (v: boolean) => v ? '启用' : '禁用' },
|
||||
{ title: '素材数', dataIndex: 'assetCount' },
|
||||
{ title: '图片/视频', render: (_: unknown, r: HomeMaterialCategory) => `${r.imageCount}/${r.videoCount}` },
|
||||
{ title: '排序', dataIndex: 'sortOrder' },
|
||||
{
|
||||
title: '操作',
|
||||
render: (_: unknown, row: HomeMaterialCategory) => (
|
||||
<Space>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(row)}>编辑</Button>
|
||||
<Popconfirm title="确认删除该行业?" onConfirm={async () => { await deleteHomeMaterialCategory(row.id); message.success('删除成功'); await load(); onChanged?.(); }}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Modal title={editing ? '编辑行业' : '新增行业'} open={modalOpen} onOk={submit} onCancel={() => setModalOpen(false)} destroyOnHidden>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="行业名称" rules={[{ required: true, message: '请输入行业名称' }]}><Input /></Form.Item>
|
||||
<Form.Item name="key" label="行业Key" rules={[{ required: true, message: '请输入行业Key' }, { pattern: /^[A-Za-z0-9_-]+$/, message: '只允许字母、数字、下划线、中划线' }]}><Input /></Form.Item>
|
||||
<Form.Item name="description" label="描述"><Input.TextArea rows={3} /></Form.Item>
|
||||
<Form.Item name="icon" label="图标">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
placeholder="请选择行业图标"
|
||||
optionFilterProp="searchText"
|
||||
filterOption={(input, option) => {
|
||||
const text = String((option as any)?.searchText || '').toLowerCase();
|
||||
const value = String(option?.value || '').toLowerCase();
|
||||
const keyword = input.toLowerCase();
|
||||
return text.includes(keyword) || value.includes(keyword);
|
||||
}}
|
||||
options={CATEGORY_ICON_OPTIONS.map(item => ({
|
||||
value: item.value,
|
||||
searchText: `${item.label} ${item.value}`,
|
||||
label: renderIconOption(item.value, item.label),
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="sort_order" label="排序"><InputNumber min={0} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="is_active" label="启用" valuePropName="checked"><Switch /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomeMaterialCategoryPanel;
|
||||
@@ -0,0 +1,208 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Form, Input, InputNumber, Modal, Select, Switch, Upload, Button, message } from 'antd';
|
||||
import { UploadOutlined } from '@ant-design/icons';
|
||||
import { uploadHomeMaterialAsset } from '../../api';
|
||||
import type { HomeMaterialCategory, HomeMaterialMediaType, HomeMaterialWatermark, HomeMaterialWatermarkConfig } from '../../types';
|
||||
import WatermarkEditor from './WatermarkEditor';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
categories: HomeMaterialCategory[];
|
||||
watermarks: HomeMaterialWatermark[];
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
const defaultConfig: HomeMaterialWatermarkConfig = {
|
||||
watermarkType: 'image',
|
||||
watermarkId: null,
|
||||
opacityLevel: 6,
|
||||
position: 'bottom_right',
|
||||
customXRatio: null,
|
||||
customYRatio: null,
|
||||
sizeMode: 'ratio',
|
||||
widthRatio: 0.18,
|
||||
widthPx: null,
|
||||
marginX: 24,
|
||||
marginY: 24,
|
||||
textWatermark: {
|
||||
text: '民众普康 AI',
|
||||
opacityLevel: 2,
|
||||
fontSizePx: 28,
|
||||
color: '#ffffff',
|
||||
rotateDeg: -30,
|
||||
gapX: 220,
|
||||
gapY: 140,
|
||||
staggered: true,
|
||||
},
|
||||
};
|
||||
|
||||
function isValidConfig(config: HomeMaterialWatermarkConfig): boolean {
|
||||
if (config.watermarkType === 'repeated_text') return !!config.textWatermark?.text?.trim();
|
||||
return !!config.watermarkId;
|
||||
}
|
||||
|
||||
const HomeMaterialUploadModal: React.FC<Props> = ({ open, onClose, categories, watermarks, onSuccess }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [fileList, setFileList] = useState<any[]>([]);
|
||||
const [mediaType, setMediaType] = useState<HomeMaterialMediaType>('image');
|
||||
const [config, setConfig] = useState<HomeMaterialWatermarkConfig>(defaultConfig);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [mediaObjectUrl, setMediaObjectUrl] = useState<string>('');
|
||||
|
||||
const resetState = () => {
|
||||
setFile(null);
|
||||
setFileList([]);
|
||||
setMediaType('image');
|
||||
setMediaObjectUrl('');
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
resetState();
|
||||
const defaultWatermark = watermarks.find(w => w.isDefault) || watermarks[0];
|
||||
setConfig({ ...defaultConfig, watermarkId: defaultWatermark?.id || null });
|
||||
form.setFieldsValue({ media_type: 'image', is_active: true, sort_order: 0, title: undefined });
|
||||
}
|
||||
}, [open, watermarks, form]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (mediaObjectUrl.startsWith('blob:')) URL.revokeObjectURL(mediaObjectUrl);
|
||||
};
|
||||
}, [mediaObjectUrl]);
|
||||
|
||||
const mediaUrl = useMemo(() => mediaObjectUrl || undefined, [mediaObjectUrl]);
|
||||
|
||||
const handleBeforeUpload = (selectedFile: File) => {
|
||||
const isImage = selectedFile.type.startsWith('image/');
|
||||
const isVideo = selectedFile.type.startsWith('video/');
|
||||
|
||||
if (mediaType === 'image' && !isImage) {
|
||||
message.warning('当前素材类型为图片,请选择图片文件');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
if (mediaType === 'video' && !isVideo) {
|
||||
message.warning('当前素材类型为视频,请选择视频文件');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
|
||||
if (mediaObjectUrl.startsWith('blob:')) URL.revokeObjectURL(mediaObjectUrl);
|
||||
const nextUrl = URL.createObjectURL(selectedFile);
|
||||
setFile(selectedFile);
|
||||
setMediaObjectUrl(nextUrl);
|
||||
setFileList([{ uid: selectedFile.name, name: selectedFile.name, status: 'done', originFileObj: selectedFile }]);
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleRemoveFile = () => {
|
||||
setFile(null);
|
||||
setFileList([]);
|
||||
if (mediaObjectUrl.startsWith('blob:')) URL.revokeObjectURL(mediaObjectUrl);
|
||||
setMediaObjectUrl('');
|
||||
};
|
||||
|
||||
const changeMediaType = (value: HomeMaterialMediaType) => {
|
||||
setMediaType(value);
|
||||
handleRemoveFile();
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
if (submitting) return;
|
||||
onClose();
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (submitting) return;
|
||||
const values = await form.validateFields();
|
||||
if (!file) {
|
||||
message.warning('请选择素材文件');
|
||||
return;
|
||||
}
|
||||
if (config.watermarkType === 'image' && !config.watermarkId) {
|
||||
message.warning('请选择水印图片');
|
||||
return;
|
||||
}
|
||||
if (config.watermarkType === 'repeated_text' && !config.textWatermark?.text?.trim()) {
|
||||
message.warning('请输入重复文字水印内容');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await uploadHomeMaterialAsset({
|
||||
categoryId: values.category_id,
|
||||
file,
|
||||
mediaType,
|
||||
title: values.title?.trim() || null,
|
||||
watermarkType: config.watermarkType,
|
||||
watermarkId: config.watermarkType === 'image' ? config.watermarkId : null,
|
||||
opacityLevel: config.opacityLevel,
|
||||
position: config.position,
|
||||
customXRatio: config.customXRatio,
|
||||
customYRatio: config.customYRatio,
|
||||
sizeMode: config.sizeMode,
|
||||
widthRatio: config.widthRatio,
|
||||
widthPx: config.widthPx,
|
||||
marginX: config.marginX,
|
||||
marginY: config.marginY,
|
||||
textWatermark: config.watermarkType === 'repeated_text' ? config.textWatermark : null,
|
||||
isActive: values.is_active,
|
||||
sortOrder: values.sort_order,
|
||||
wait: false,
|
||||
});
|
||||
message.success(res.message || '已提交水印处理');
|
||||
onSuccess();
|
||||
onClose();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="上传首页素材"
|
||||
open={open}
|
||||
onCancel={closeModal}
|
||||
onOk={submit}
|
||||
confirmLoading={submitting}
|
||||
okButtonProps={{ disabled: submitting || !file || !isValidConfig(config) }}
|
||||
cancelButtonProps={{ disabled: submitting }}
|
||||
width={980}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="category_id" label="行业" rules={[{ required: true, message: '请选择行业' }]}>
|
||||
<Select options={categories.map(c => ({ label: c.name, value: c.id }))} disabled={submitting} />
|
||||
</Form.Item>
|
||||
<Form.Item name="media_type" label="素材类型" rules={[{ required: true }]}>
|
||||
<Select
|
||||
value={mediaType}
|
||||
onChange={changeMediaType}
|
||||
disabled={submitting}
|
||||
options={[{ label: '图片', value: 'image' }, { label: '视频', value: 'video' }]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="素材文件" required>
|
||||
<Upload
|
||||
beforeUpload={(f) => handleBeforeUpload(f as File)}
|
||||
onRemove={handleRemoveFile}
|
||||
fileList={fileList}
|
||||
maxCount={1}
|
||||
accept={mediaType === 'image' ? 'image/*' : 'video/*'}
|
||||
disabled={submitting}
|
||||
>
|
||||
<Button icon={<UploadOutlined />} loading={submitting} disabled={submitting}>选择{mediaType === 'image' ? '图片' : '视频'}</Button>
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
<Form.Item name="title" label="素材标题" extra="选填;不填时后台显示“未命名素材”,不会再自动使用文件名作为标题。"><Input disabled={submitting} /></Form.Item>
|
||||
<Form.Item name="sort_order" label="排序"><InputNumber min={0} style={{ width: '100%' }} disabled={submitting} /></Form.Item>
|
||||
<Form.Item name="is_active" label="前台展示" valuePropName="checked"><Switch disabled={submitting} /></Form.Item>
|
||||
</Form>
|
||||
<WatermarkEditor value={config} onChange={setConfig} watermarks={watermarks} mediaUrl={mediaUrl} mediaType={mediaType} />
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomeMaterialUploadModal;
|
||||
@@ -0,0 +1,183 @@
|
||||
import React from 'react';
|
||||
import { Col, Form, Input, InputNumber, Radio, Row, Select, Slider, Switch } from 'antd';
|
||||
import type { HomeMaterialMediaType, HomeMaterialWatermark, HomeMaterialWatermarkConfig } from '../../types';
|
||||
import WatermarkPreview from './WatermarkPreview';
|
||||
|
||||
interface WatermarkEditorProps {
|
||||
value: HomeMaterialWatermarkConfig;
|
||||
onChange: (next: HomeMaterialWatermarkConfig) => void;
|
||||
watermarks: HomeMaterialWatermark[];
|
||||
mediaUrl?: string | null;
|
||||
mediaType?: HomeMaterialMediaType;
|
||||
}
|
||||
|
||||
const positionOptions = [
|
||||
{ label: '左上角', value: 'top_left' },
|
||||
{ label: '顶部居中', value: 'top_center' },
|
||||
{ label: '右上角', value: 'top_right' },
|
||||
{ label: '左侧居中', value: 'middle_left' },
|
||||
{ label: '正中间', value: 'center' },
|
||||
{ label: '右侧居中', value: 'middle_right' },
|
||||
{ label: '左下角', value: 'bottom_left' },
|
||||
{ label: '底部居中', value: 'bottom_center' },
|
||||
{ label: '右下角', value: 'bottom_right' },
|
||||
{ label: '拖拽自定义', value: 'custom' },
|
||||
];
|
||||
|
||||
const defaultTextWatermark = {
|
||||
text: '民众普康 AI',
|
||||
opacityLevel: 2,
|
||||
fontSizePx: 28,
|
||||
color: '#ffffff',
|
||||
rotateDeg: -30,
|
||||
gapX: 220,
|
||||
gapY: 140,
|
||||
staggered: true,
|
||||
};
|
||||
|
||||
const WatermarkEditor: React.FC<WatermarkEditorProps> = ({ value, onChange, watermarks, mediaUrl, mediaType }) => {
|
||||
const patch = (partial: Partial<HomeMaterialWatermarkConfig>) => onChange({ ...value, ...partial });
|
||||
const patchText = (partial: Partial<NonNullable<HomeMaterialWatermarkConfig['textWatermark']>>) => {
|
||||
patch({ textWatermark: { ...defaultTextWatermark, ...(value.textWatermark || {}), ...partial } });
|
||||
};
|
||||
const watermarkUrl = watermarks.find(w => w.id === value.watermarkId)?.fileUrl;
|
||||
const textWatermark = { ...defaultTextWatermark, ...(value.textWatermark || {}) };
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="水印类型" required>
|
||||
<Radio.Group
|
||||
value={value.watermarkType || 'image'}
|
||||
onChange={(e) => {
|
||||
const nextType = e.target.value;
|
||||
patch({
|
||||
watermarkType: nextType,
|
||||
textWatermark: nextType === 'repeated_text' ? textWatermark : null,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Radio.Button value="image">图片水印</Radio.Button>
|
||||
<Radio.Button value="repeated_text">重复文字水印</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
{value.watermarkType === 'repeated_text' ? (
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<Form.Item label="水印文字" required extra="字体由后端固定开源可商用字体渲染,前端预览层和最终生成使用同一后端渲染逻辑。">
|
||||
<Input
|
||||
maxLength={64}
|
||||
showCount
|
||||
value={textWatermark.text}
|
||||
onChange={(e) => patchText({ text: e.target.value })}
|
||||
placeholder="请输入重复水印文字"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label={`文字透明度:${textWatermark.opacityLevel}/10`} required>
|
||||
<Slider min={1} max={10} step={1} value={textWatermark.opacityLevel} onChange={(v) => patchText({ opacityLevel: v })} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="字号(px)" required>
|
||||
<InputNumber min={8} max={160} style={{ width: '100%' }} value={textWatermark.fontSizePx} onChange={(v) => patchText({ fontSizePx: Number(v || 28) })} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="文字颜色" required>
|
||||
<Input value={textWatermark.color} onChange={(e) => patchText({ color: e.target.value || '#ffffff' })} placeholder="#ffffff" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="旋转角度" required>
|
||||
<InputNumber min={-90} max={90} style={{ width: '100%' }} value={textWatermark.rotateDeg} onChange={(v) => patchText({ rotateDeg: Number(v ?? -30) })} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="横向间距" required>
|
||||
<InputNumber min={20} max={2000} style={{ width: '100%' }} value={textWatermark.gapX} onChange={(v) => patchText({ gapX: Number(v || 220) })} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="纵向间距" required>
|
||||
<InputNumber min={20} max={2000} style={{ width: '100%' }} value={textWatermark.gapY} onChange={(v) => patchText({ gapY: Number(v || 140) })} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="交错排列" valuePropName="checked">
|
||||
<Switch checked={textWatermark.staggered} onChange={(checked) => patchText({ staggered: checked })} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
) : (
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item label="水印图片" required>
|
||||
<Select
|
||||
placeholder="请选择水印"
|
||||
value={value.watermarkId || undefined}
|
||||
onChange={(v) => patch({ watermarkId: v })}
|
||||
options={watermarks.map(w => ({ label: `${w.name}${w.isDefault ? '(默认)' : ''}`, value: w.id }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label={`透明度:${value.opacityLevel}/10`} required>
|
||||
<Slider min={1} max={10} step={1} value={value.opacityLevel} onChange={(v) => patch({ opacityLevel: v })} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="水印位置" required>
|
||||
<Select value={value.position} onChange={(v) => patch({ position: v })} options={positionOptions} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item label="大小模式" required>
|
||||
<Select
|
||||
value={value.sizeMode}
|
||||
onChange={(v) => patch({ sizeMode: v })}
|
||||
options={[{ label: '按素材宽度比例', value: 'ratio' }, { label: '固定像素宽度', value: 'px' }]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
{value.sizeMode === 'ratio' ? (
|
||||
<Col span={12}>
|
||||
<Form.Item label="水印宽度比例" required>
|
||||
<InputNumber min={0.01} max={1} step={0.01} style={{ width: '100%' }} value={value.widthRatio ?? 0.18} onChange={(v) => patch({ widthRatio: Number(v || 0.18) })} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
) : (
|
||||
<Col span={12}>
|
||||
<Form.Item label="水印宽度(px)" required>
|
||||
<InputNumber min={1} max={10000} style={{ width: '100%' }} value={value.widthPx ?? 200} onChange={(v) => patch({ widthPx: Number(v || 200) })} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
)}
|
||||
<Col span={6}>
|
||||
<Form.Item label="横向边距">
|
||||
<InputNumber min={0} max={2000} style={{ width: '100%' }} value={value.marginX} onChange={(v) => patch({ marginX: Number(v || 0) })} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Form.Item label="纵向边距">
|
||||
<InputNumber min={0} max={2000} style={{ width: '100%' }} value={value.marginY} onChange={(v) => patch({ marginY: Number(v || 0) })} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
<WatermarkPreview
|
||||
mediaUrl={mediaUrl}
|
||||
mediaType={mediaType}
|
||||
watermarkUrl={watermarkUrl}
|
||||
config={value}
|
||||
onChange={(partial) => patch(partial)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WatermarkEditor;
|
||||
@@ -0,0 +1,207 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Form, Image, Input, Modal, Popconfirm, Space, Switch, Table, Upload, message } from 'antd';
|
||||
import { DeleteOutlined, EditOutlined, PlusOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import { deleteHomeMaterialWatermark, getHomeMaterialWatermarks, updateHomeMaterialWatermark, uploadHomeMaterialWatermark } from '../../api';
|
||||
import type { HomeMaterialWatermark } from '../../types';
|
||||
import { apiUrl } from '../../utils/resourceUrl';
|
||||
|
||||
interface Props {
|
||||
open?: boolean;
|
||||
onClose?: () => void;
|
||||
embedded?: boolean;
|
||||
onChanged?: () => void;
|
||||
}
|
||||
|
||||
const WatermarkLibraryModal: React.FC<Props> = ({ open = true, onClose, embedded = false, onChanged }) => {
|
||||
const [items, setItems] = useState<HomeMaterialWatermark[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<HomeMaterialWatermark | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [fileList, setFileList] = useState<any[]>([]);
|
||||
const [previewUrl, setPreviewUrl] = useState<string>('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getHomeMaterialWatermarks({ page: 1, pageSize: 200 });
|
||||
setItems(res.items);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { if (open) load(); }, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewUrl.startsWith('blob:')) URL.revokeObjectURL(previewUrl);
|
||||
};
|
||||
}, [previewUrl]);
|
||||
|
||||
const resetUploadState = () => {
|
||||
setFile(null);
|
||||
setFileList([]);
|
||||
setPreviewUrl('');
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const openUpload = () => {
|
||||
setEditing(null);
|
||||
resetUploadState();
|
||||
form.setFieldsValue({ name: '', is_default: false, is_active: true });
|
||||
setUploadOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (row: HomeMaterialWatermark) => {
|
||||
setEditing(row);
|
||||
resetUploadState();
|
||||
setPreviewUrl(apiUrl(row.fileUrl));
|
||||
form.setFieldsValue({ name: row.name, is_default: row.isDefault, is_active: row.isActive });
|
||||
setUploadOpen(true);
|
||||
};
|
||||
|
||||
const closeUploadModal = () => {
|
||||
if (submitting) return;
|
||||
setUploadOpen(false);
|
||||
resetUploadState();
|
||||
};
|
||||
|
||||
const handleBeforeUpload = (selectedFile: File) => {
|
||||
if (!selectedFile.type.startsWith('image/')) {
|
||||
message.warning('请选择图片文件');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
|
||||
if (previewUrl.startsWith('blob:')) URL.revokeObjectURL(previewUrl);
|
||||
const nextPreviewUrl = URL.createObjectURL(selectedFile);
|
||||
setFile(selectedFile);
|
||||
setPreviewUrl(nextPreviewUrl);
|
||||
setFileList([{ uid: selectedFile.name, name: selectedFile.name, status: 'done', originFileObj: selectedFile }]);
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleRemoveFile = () => {
|
||||
setFile(null);
|
||||
setFileList([]);
|
||||
if (previewUrl.startsWith('blob:')) URL.revokeObjectURL(previewUrl);
|
||||
setPreviewUrl('');
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (submitting) return;
|
||||
const values = await form.validateFields();
|
||||
|
||||
if (!editing && !file) {
|
||||
message.warning('请选择水印图片');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (editing) {
|
||||
await updateHomeMaterialWatermark(editing.id, { name: values.name, is_default: values.is_default, is_active: values.is_active });
|
||||
} else if (file) {
|
||||
await uploadHomeMaterialWatermark(file, values.name, values.is_default);
|
||||
}
|
||||
|
||||
message.success('保存成功');
|
||||
setUploadOpen(false);
|
||||
resetUploadState();
|
||||
await load();
|
||||
onChanged?.();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const table = (
|
||||
<>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<div />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openUpload}>上传水印</Button>
|
||||
</div>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={items}
|
||||
pagination={{ pageSize: 20 }}
|
||||
columns={[
|
||||
{ title: '预览', dataIndex: 'fileUrl', render: (url: string) => <Image width={80} src={apiUrl(url)} /> },
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{ title: '尺寸', render: (_: unknown, r: HomeMaterialWatermark) => r.width && r.height ? `${r.width}×${r.height}` : '-' },
|
||||
{ title: '默认', dataIndex: 'isDefault', render: (v: boolean) => v ? '是' : '否' },
|
||||
{ title: '启用', dataIndex: 'isActive', render: (v: boolean) => v ? '启用' : '禁用' },
|
||||
{
|
||||
title: '操作',
|
||||
render: (_: unknown, row: HomeMaterialWatermark) => (
|
||||
<Space>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(row)}>编辑</Button>
|
||||
<Popconfirm title="确认删除该水印?" onConfirm={async () => { await deleteHomeMaterialWatermark(row.id); message.success('删除成功'); await load(); onChanged?.(); }}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>删除</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Modal
|
||||
title={editing ? '编辑水印' : '上传水印'}
|
||||
open={uploadOpen}
|
||||
onOk={submit}
|
||||
onCancel={closeUploadModal}
|
||||
confirmLoading={submitting}
|
||||
okButtonProps={{ disabled: submitting || (!editing && !file) }}
|
||||
cancelButtonProps={{ disabled: submitting }}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
{!editing && (
|
||||
<Form.Item label="水印图片" required>
|
||||
<Upload
|
||||
beforeUpload={(f) => handleBeforeUpload(f as File)}
|
||||
onRemove={handleRemoveFile}
|
||||
fileList={fileList}
|
||||
maxCount={1}
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
disabled={submitting}
|
||||
>
|
||||
<Button icon={<UploadOutlined />} loading={submitting} disabled={submitting}>选择图片</Button>
|
||||
</Upload>
|
||||
{previewUrl && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Image
|
||||
src={previewUrl}
|
||||
alt="水印预览"
|
||||
width={180}
|
||||
style={{ maxHeight: 120, objectFit: 'contain', background: '#0f172a', borderRadius: 8, padding: 8 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Form.Item>
|
||||
)}
|
||||
{editing && previewUrl && (
|
||||
<Form.Item label="当前水印">
|
||||
<Image
|
||||
src={previewUrl}
|
||||
alt="当前水印"
|
||||
width={180}
|
||||
style={{ maxHeight: 120, objectFit: 'contain', background: '#0f172a', borderRadius: 8, padding: 8 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="name" label="水印名称" rules={[{ required: true, message: '请输入水印名称' }]}><Input disabled={submitting} /></Form.Item>
|
||||
<Form.Item name="is_default" label="默认水印" valuePropName="checked"><Switch disabled={submitting} /></Form.Item>
|
||||
<Form.Item name="is_active" label="启用" valuePropName="checked"><Switch disabled={submitting} /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
|
||||
if (embedded) return table;
|
||||
return <Modal title="水印库" open={open} onCancel={onClose} footer={null} width={900} destroyOnHidden>{table}</Modal>;
|
||||
};
|
||||
|
||||
export default WatermarkLibraryModal;
|
||||
@@ -0,0 +1,363 @@
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Spin } from 'antd';
|
||||
import { previewHomeMaterialTextWatermark } from '../../api';
|
||||
import { apiUrl } from '../../utils/resourceUrl';
|
||||
import type { HomeMaterialMediaType, HomeMaterialTextWatermarkPayload, HomeMaterialWatermarkConfig } from '../../types';
|
||||
|
||||
interface WatermarkPreviewProps {
|
||||
mediaUrl?: string | null;
|
||||
mediaType?: HomeMaterialMediaType;
|
||||
watermarkUrl?: string | null;
|
||||
config: HomeMaterialWatermarkConfig;
|
||||
onChange?: (patch: Partial<HomeMaterialWatermarkConfig>) => void;
|
||||
}
|
||||
|
||||
interface Size {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const PREVIEW_MAX_HEIGHT = 360;
|
||||
const PREVIEW_MIN_HEIGHT = 280;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(value)) return min;
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
function calcStageSize(containerWidth: number, naturalSize: Size | null): Size {
|
||||
const maxWidth = Math.max(0, containerWidth);
|
||||
if (!maxWidth) return { width: 0, height: PREVIEW_MIN_HEIGHT };
|
||||
|
||||
if (!naturalSize?.width || !naturalSize?.height) {
|
||||
return { width: maxWidth, height: PREVIEW_MIN_HEIGHT };
|
||||
}
|
||||
|
||||
const scale = Math.min(maxWidth / naturalSize.width, PREVIEW_MAX_HEIGHT / naturalSize.height);
|
||||
return {
|
||||
width: Math.max(1, Math.round(naturalSize.width * scale)),
|
||||
height: Math.max(1, Math.round(naturalSize.height * scale)),
|
||||
};
|
||||
}
|
||||
|
||||
function calcWatermarkWidth(config: HomeMaterialWatermarkConfig, stageWidth: number): number {
|
||||
if (config.sizeMode === 'px' && config.widthPx) {
|
||||
return clamp(config.widthPx, 1, Math.max(1, stageWidth));
|
||||
}
|
||||
const ratio = clamp(config.widthRatio ?? 0.18, 0.01, 1);
|
||||
return Math.max(1, stageWidth * ratio);
|
||||
}
|
||||
|
||||
function calcPresetPosition(config: HomeMaterialWatermarkConfig): React.CSSProperties {
|
||||
const marginX = config.marginX ?? 24;
|
||||
const marginY = config.marginY ?? 24;
|
||||
|
||||
switch (config.position) {
|
||||
case 'top_left':
|
||||
return { left: marginX, top: marginY };
|
||||
case 'top_center':
|
||||
return { left: '50%', top: marginY, transform: 'translateX(-50%)' };
|
||||
case 'top_right':
|
||||
return { right: marginX, top: marginY };
|
||||
case 'middle_left':
|
||||
return { left: marginX, top: '50%', transform: 'translateY(-50%)' };
|
||||
case 'center':
|
||||
return { left: '50%', top: '50%', transform: 'translate(-50%, -50%)' };
|
||||
case 'middle_right':
|
||||
return { right: marginX, top: '50%', transform: 'translateY(-50%)' };
|
||||
case 'bottom_left':
|
||||
return { left: marginX, bottom: marginY };
|
||||
case 'bottom_center':
|
||||
return { left: '50%', bottom: marginY, transform: 'translateX(-50%)' };
|
||||
case 'bottom_right':
|
||||
default:
|
||||
return { right: marginX, bottom: marginY };
|
||||
}
|
||||
}
|
||||
|
||||
function toTextPayload(config: HomeMaterialWatermarkConfig): HomeMaterialTextWatermarkPayload | null {
|
||||
const text = config.textWatermark;
|
||||
if (!text || !text.text?.trim()) return null;
|
||||
return {
|
||||
text: text.text.trim(),
|
||||
opacity_level: text.opacityLevel,
|
||||
font_size_px: text.fontSizePx,
|
||||
color: text.color,
|
||||
rotate_deg: text.rotateDeg,
|
||||
gap_x: text.gapX,
|
||||
gap_y: text.gapY,
|
||||
staggered: text.staggered,
|
||||
};
|
||||
}
|
||||
|
||||
const WatermarkPreview: React.FC<WatermarkPreviewProps> = ({
|
||||
mediaUrl,
|
||||
mediaType = 'image',
|
||||
watermarkUrl,
|
||||
config,
|
||||
onChange,
|
||||
}) => {
|
||||
const outerRef = useRef<HTMLDivElement | null>(null);
|
||||
const stageRef = useRef<HTMLDivElement | null>(null);
|
||||
const watermarkRef = useRef<HTMLImageElement | null>(null);
|
||||
const dragOffsetRef = useRef({ x: 0, y: 0 });
|
||||
const previewSeqRef = useRef(0);
|
||||
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const [naturalSize, setNaturalSize] = useState<Size | null>(null);
|
||||
const [watermarkSize, setWatermarkSize] = useState<Size>({ width: 0, height: 0 });
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [textPreviewLayer, setTextPreviewLayer] = useState<string>('');
|
||||
const [textPreviewLoading, setTextPreviewLoading] = useState(false);
|
||||
const [textPreviewError, setTextPreviewError] = useState<string>('');
|
||||
|
||||
const mediaSrc = useMemo(() => apiUrl(mediaUrl), [mediaUrl]);
|
||||
const watermarkSrc = useMemo(() => apiUrl(watermarkUrl), [watermarkUrl]);
|
||||
const stageSize = useMemo(() => calcStageSize(containerWidth, naturalSize), [containerWidth, naturalSize]);
|
||||
const watermarkWidth = useMemo(() => calcWatermarkWidth(config, stageSize.width), [config, stageSize.width]);
|
||||
const isRepeatedText = config.watermarkType === 'repeated_text';
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!outerRef.current) return;
|
||||
const update = () => setContainerWidth(outerRef.current?.clientWidth || 0);
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(outerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!watermarkRef.current) return;
|
||||
const update = () => {
|
||||
const rect = watermarkRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
setWatermarkSize({ width: rect.width, height: rect.height });
|
||||
};
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(watermarkRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [watermarkSrc, watermarkWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
setNaturalSize(null);
|
||||
setTextPreviewLayer('');
|
||||
setTextPreviewError('');
|
||||
}, [mediaSrc, mediaType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isRepeatedText) {
|
||||
setTextPreviewLayer('');
|
||||
setTextPreviewLoading(false);
|
||||
setTextPreviewError('');
|
||||
return;
|
||||
}
|
||||
const textPayload = toTextPayload(config);
|
||||
if (!naturalSize?.width || !naturalSize.height || !textPayload) {
|
||||
setTextPreviewLayer('');
|
||||
return;
|
||||
}
|
||||
|
||||
const seq = ++previewSeqRef.current;
|
||||
setTextPreviewLoading(true);
|
||||
setTextPreviewError('');
|
||||
const timer = window.setTimeout(async () => {
|
||||
try {
|
||||
const res = await previewHomeMaterialTextWatermark({
|
||||
width: naturalSize.width,
|
||||
height: naturalSize.height,
|
||||
text_watermark: textPayload,
|
||||
});
|
||||
if (previewSeqRef.current === seq) {
|
||||
setTextPreviewLayer(res.previewLayerDataUrl);
|
||||
}
|
||||
} catch (e) {
|
||||
if (previewSeqRef.current === seq) {
|
||||
setTextPreviewLayer('');
|
||||
setTextPreviewError(e instanceof Error ? e.message : '文字水印预览生成失败');
|
||||
}
|
||||
} finally {
|
||||
if (previewSeqRef.current === seq) setTextPreviewLoading(false);
|
||||
}
|
||||
}, 300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [config, isRepeatedText, naturalSize]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMove = (e: PointerEvent) => {
|
||||
if (!dragging || !stageRef.current || !onChange || isRepeatedText) return;
|
||||
|
||||
const rect = stageRef.current.getBoundingClientRect();
|
||||
const wmWidth = watermarkSize.width || watermarkWidth;
|
||||
const wmHeight = watermarkSize.height || 1;
|
||||
const availableX = Math.max(1, rect.width - wmWidth);
|
||||
const availableY = Math.max(1, rect.height - wmHeight);
|
||||
const left = clamp(e.clientX - rect.left - dragOffsetRef.current.x, 0, availableX);
|
||||
const top = clamp(e.clientY - rect.top - dragOffsetRef.current.y, 0, availableY);
|
||||
|
||||
onChange({
|
||||
position: 'custom',
|
||||
customXRatio: Number((left / availableX).toFixed(4)),
|
||||
customYRatio: Number((top / availableY).toFixed(4)),
|
||||
});
|
||||
};
|
||||
const onUp = () => setDragging(false);
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
window.addEventListener('pointercancel', onUp);
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
window.removeEventListener('pointercancel', onUp);
|
||||
};
|
||||
}, [dragging, isRepeatedText, onChange, watermarkSize.height, watermarkSize.width, watermarkWidth]);
|
||||
|
||||
const watermarkPositionStyle = useMemo<React.CSSProperties>(() => {
|
||||
if (config.position !== 'custom') return calcPresetPosition(config);
|
||||
|
||||
const wmWidth = watermarkSize.width || watermarkWidth;
|
||||
const wmHeight = watermarkSize.height || 1;
|
||||
const availableX = Math.max(0, stageSize.width - wmWidth);
|
||||
const availableY = Math.max(0, stageSize.height - wmHeight);
|
||||
const xRatio = clamp(config.customXRatio ?? 0.5, 0, 1);
|
||||
const yRatio = clamp(config.customYRatio ?? 0.5, 0, 1);
|
||||
|
||||
return {
|
||||
left: availableX * xRatio,
|
||||
top: availableY * yRatio,
|
||||
transform: 'none',
|
||||
};
|
||||
}, [config, stageSize.height, stageSize.width, watermarkSize.height, watermarkSize.width, watermarkWidth]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={outerRef}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
minHeight: Math.max(PREVIEW_MIN_HEIGHT, stageSize.height),
|
||||
background: '#0f172a',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#94a3b8',
|
||||
}}
|
||||
>
|
||||
{mediaSrc ? (
|
||||
<div
|
||||
ref={stageRef}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: stageSize.width || '100%',
|
||||
height: stageSize.height || PREVIEW_MIN_HEIGHT,
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
background: '#000',
|
||||
}}
|
||||
>
|
||||
{mediaType === 'video' ? (
|
||||
<video
|
||||
src={mediaSrc}
|
||||
controls
|
||||
onLoadedMetadata={(e) => {
|
||||
const target = e.currentTarget;
|
||||
if (target.videoWidth && target.videoHeight) {
|
||||
setNaturalSize({ width: target.videoWidth, height: target.videoHeight });
|
||||
}
|
||||
}}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'fill', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={mediaSrc}
|
||||
alt="素材预览"
|
||||
onLoad={(e) => {
|
||||
const target = e.currentTarget;
|
||||
if (target.naturalWidth && target.naturalHeight) {
|
||||
setNaturalSize({ width: target.naturalWidth, height: target.naturalHeight });
|
||||
}
|
||||
}}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'fill', display: 'block' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isRepeatedText && textPreviewLayer && (
|
||||
<img
|
||||
src={textPreviewLayer}
|
||||
alt="重复文字水印预览"
|
||||
draggable={false}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'fill',
|
||||
zIndex: 2,
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isRepeatedText && (textPreviewLoading || textPreviewError) && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 12,
|
||||
bottom: 12,
|
||||
zIndex: 3,
|
||||
padding: '6px 10px',
|
||||
borderRadius: 8,
|
||||
background: 'rgba(15, 23, 42, 0.82)',
|
||||
color: '#e2e8f0',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{textPreviewLoading ? <><Spin size="small" /> 正在生成精准预览...</> : textPreviewError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isRepeatedText && watermarkSrc && (
|
||||
<img
|
||||
ref={watermarkRef}
|
||||
src={watermarkSrc}
|
||||
alt="水印预览"
|
||||
draggable={false}
|
||||
onLoad={(e) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
setWatermarkSize({ width: rect.width, height: rect.height });
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
if (!onChange) return;
|
||||
e.preventDefault();
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
dragOffsetRef.current = {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top,
|
||||
};
|
||||
setDragging(true);
|
||||
}}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
zIndex: 2,
|
||||
cursor: onChange ? 'move' : 'default',
|
||||
width: watermarkWidth,
|
||||
opacity: (config.opacityLevel ?? 6) / 10,
|
||||
userSelect: 'none',
|
||||
touchAction: 'none',
|
||||
pointerEvents: onChange ? 'auto' : 'none',
|
||||
...watermarkPositionStyle,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span>上传素材后可预览水印效果</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WatermarkPreview;
|
||||
@@ -856,3 +856,250 @@ export interface AdminCreditRecordQueryParams {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
// ── 首页素材行业装修 ──────────────────────────────────────
|
||||
|
||||
export type HomeMaterialMediaType = 'image' | 'video';
|
||||
export type HomeMaterialAssetStatus = 'draft' | 'processing' | 'success' | 'failed';
|
||||
export type HomeMaterialWatermarkType = 'image' | 'repeated_text';
|
||||
export type HomeMaterialWatermarkPosition =
|
||||
| 'top_left'
|
||||
| 'top_center'
|
||||
| 'top_right'
|
||||
| 'middle_left'
|
||||
| 'center'
|
||||
| 'middle_right'
|
||||
| 'bottom_left'
|
||||
| 'bottom_center'
|
||||
| 'bottom_right'
|
||||
| 'custom';
|
||||
export type HomeMaterialWatermarkSizeMode = 'ratio' | 'px';
|
||||
export type HomeMaterialPublicResponseMode = 'grouped' | 'flat';
|
||||
|
||||
export interface HomeMaterialConfig {
|
||||
enabled: boolean;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
showOriginalInAdmin: boolean;
|
||||
}
|
||||
|
||||
export interface HomeMaterialCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
description?: string | null;
|
||||
icon?: string | null;
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
assetCount: number;
|
||||
imageCount: number;
|
||||
videoCount: number;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface HomeMaterialCategoryPayload {
|
||||
name: string;
|
||||
key: string;
|
||||
description?: string | null;
|
||||
icon?: string | null;
|
||||
is_active: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface HomeMaterialWatermark {
|
||||
id: string;
|
||||
name: string;
|
||||
fileUrl: string;
|
||||
fileName?: string | null;
|
||||
fileSizeBytes: number;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
isDefault: boolean;
|
||||
isActive: boolean;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface HomeMaterialWatermarkPayload {
|
||||
name: string;
|
||||
is_default: boolean;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface HomeMaterialTextWatermarkConfig {
|
||||
text: string;
|
||||
opacityLevel: number;
|
||||
opacity?: number;
|
||||
fontSizePx: number;
|
||||
color: string;
|
||||
rotateDeg: number;
|
||||
gapX: number;
|
||||
gapY: number;
|
||||
staggered: boolean;
|
||||
}
|
||||
|
||||
export interface HomeMaterialTextWatermarkPayload {
|
||||
text: string;
|
||||
opacity_level: number;
|
||||
font_size_px: number;
|
||||
color: string;
|
||||
rotate_deg: number;
|
||||
gap_x: number;
|
||||
gap_y: number;
|
||||
staggered: boolean;
|
||||
}
|
||||
|
||||
export interface HomeMaterialWatermarkConfig {
|
||||
watermarkType: HomeMaterialWatermarkType;
|
||||
watermarkId?: string | null;
|
||||
opacityLevel: number;
|
||||
opacity?: number;
|
||||
position: HomeMaterialWatermarkPosition;
|
||||
customXRatio?: number | null;
|
||||
customYRatio?: number | null;
|
||||
sizeMode: HomeMaterialWatermarkSizeMode;
|
||||
widthRatio?: number | null;
|
||||
widthPx?: number | null;
|
||||
marginX: number;
|
||||
marginY: number;
|
||||
textWatermark?: HomeMaterialTextWatermarkConfig | null;
|
||||
}
|
||||
|
||||
export interface HomeMaterialAsset {
|
||||
id: string;
|
||||
categoryId: string;
|
||||
categoryName?: string | null;
|
||||
categoryKey?: string | null;
|
||||
title?: string | null;
|
||||
mediaType: HomeMaterialMediaType;
|
||||
status: HomeMaterialAssetStatus;
|
||||
originalUrl?: string | null;
|
||||
watermarkedUrl?: string | null;
|
||||
coverUrl?: string | null;
|
||||
watermarkId?: string | null;
|
||||
watermarkName?: string | null;
|
||||
watermarkConfig?: HomeMaterialWatermarkConfig | Record<string, unknown> | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
durationSeconds?: number | string | null;
|
||||
fileSizeBytes: number;
|
||||
watermarkedFileSizeBytes?: number | null;
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
errorMessage?: string | null;
|
||||
processedAt?: string | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface HomeMaterialAssetUpdatePayload {
|
||||
category_id: string;
|
||||
title?: string | null;
|
||||
is_active: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface HomeMaterialAssetStatusOut {
|
||||
id: string;
|
||||
status: HomeMaterialAssetStatus;
|
||||
errorMessage?: string | null;
|
||||
originalUrl?: string | null;
|
||||
watermarkedUrl?: string | null;
|
||||
coverUrl?: string | null;
|
||||
processedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface HomeMaterialUploadResult {
|
||||
id: string;
|
||||
categoryId: string;
|
||||
title?: string | null;
|
||||
mediaType: HomeMaterialMediaType;
|
||||
status: HomeMaterialAssetStatus;
|
||||
originalUrl?: string | null;
|
||||
watermarkedUrl?: string | null;
|
||||
coverUrl?: string | null;
|
||||
watermarkConfig?: HomeMaterialWatermarkConfig | Record<string, unknown> | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface HomeMaterialListResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface HomeMaterialCategoryQueryParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
keyword?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface HomeMaterialWatermarkQueryParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface HomeMaterialAssetQueryParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
categoryId?: string;
|
||||
mediaType?: HomeMaterialMediaType;
|
||||
status?: HomeMaterialAssetStatus;
|
||||
isActive?: boolean;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
export interface HomeMaterialUploadAssetParams {
|
||||
categoryId: string;
|
||||
file: File;
|
||||
mediaType: HomeMaterialMediaType;
|
||||
title?: string | null;
|
||||
watermarkType: HomeMaterialWatermarkType;
|
||||
watermarkId?: string | null;
|
||||
watermarkFile?: File | null;
|
||||
opacityLevel: number;
|
||||
position: HomeMaterialWatermarkPosition;
|
||||
customXRatio?: number | null;
|
||||
customYRatio?: number | null;
|
||||
sizeMode: HomeMaterialWatermarkSizeMode;
|
||||
widthRatio?: number | null;
|
||||
widthPx?: number | null;
|
||||
marginX: number;
|
||||
marginY: number;
|
||||
textWatermark?: HomeMaterialTextWatermarkConfig | null;
|
||||
isActive: boolean;
|
||||
sortOrder: number;
|
||||
wait?: boolean;
|
||||
waitTimeoutSeconds?: number;
|
||||
}
|
||||
|
||||
export interface HomeMaterialRegeneratePayload {
|
||||
watermark_type: HomeMaterialWatermarkType;
|
||||
watermark_id?: string | null;
|
||||
opacity_level: number;
|
||||
position: HomeMaterialWatermarkPosition;
|
||||
custom_x_ratio?: number | null;
|
||||
custom_y_ratio?: number | null;
|
||||
size_mode: HomeMaterialWatermarkSizeMode;
|
||||
width_ratio?: number | null;
|
||||
width_px?: number | null;
|
||||
margin_x: number;
|
||||
margin_y: number;
|
||||
text_watermark?: HomeMaterialTextWatermarkPayload | null;
|
||||
wait?: boolean;
|
||||
wait_timeout_seconds?: number;
|
||||
}
|
||||
|
||||
export interface HomeMaterialTextWatermarkPreviewRequest {
|
||||
width: number;
|
||||
height: number;
|
||||
text_watermark: HomeMaterialTextWatermarkPayload;
|
||||
}
|
||||
|
||||
export interface HomeMaterialTextWatermarkPreviewResponse {
|
||||
width: number;
|
||||
height: number;
|
||||
previewLayerDataUrl: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
const RAW_API_BASE = String(import.meta.env.VITE_API_BASE || 'http://localhost:8000')
|
||||
.trim()
|
||||
.replace(/\/$/, '');
|
||||
|
||||
export const RESOURCE_BASE = RAW_API_BASE.replace(/\/api\/?$/i, '').replace(/\/$/, '');
|
||||
|
||||
export const isAbsoluteLikeUrl = (url: string): boolean => (
|
||||
/^(https?:)?\/\//i.test(url) || /^(blob|data):/i.test(url)
|
||||
);
|
||||
|
||||
export const isBlobUrl = (url?: string | null): boolean => !!url && /^blob:/i.test(url.trim());
|
||||
|
||||
export const apiUrl = (url?: string | null): string => {
|
||||
if (!url) return '';
|
||||
const value = String(url).trim();
|
||||
if (!value) return '';
|
||||
if (isAbsoluteLikeUrl(value)) return value;
|
||||
|
||||
if (!RESOURCE_BASE) {
|
||||
return value.startsWith('/') ? value : `/${value}`;
|
||||
}
|
||||
return `${RESOURCE_BASE}${value.startsWith('/') ? value : `/${value}`}`;
|
||||
};
|
||||
|
||||
export const isUrlExpired = (url?: string | null): boolean => {
|
||||
if (!url || isBlobUrl(url)) return false;
|
||||
|
||||
try {
|
||||
const parsed = new URL(apiUrl(url), window.location.origin);
|
||||
const expireValue =
|
||||
parsed.searchParams.get('exp') ||
|
||||
parsed.searchParams.get('expires') ||
|
||||
parsed.searchParams.get('expire') ||
|
||||
parsed.searchParams.get('expires_at') ||
|
||||
parsed.searchParams.get('x-expires');
|
||||
|
||||
if (!expireValue) return false;
|
||||
|
||||
const expireNumber = Number(expireValue);
|
||||
if (!Number.isFinite(expireNumber)) return false;
|
||||
|
||||
const expireMs = expireNumber > 10_000_000_000 ? expireNumber : expireNumber * 1000;
|
||||
return Date.now() >= expireMs;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user