首页素材装修模块完成
This commit is contained in:
@@ -33,6 +33,7 @@ import AdminShotTaskSetDetail from './pages/AdminShotTaskSetDetail';
|
|||||||
import AdminReplicationProjectDetail from './pages/AdminReplicationProjectDetail';
|
import AdminReplicationProjectDetail from './pages/AdminReplicationProjectDetail';
|
||||||
import AdminVideoPromptSchemaConfig from './pages/AdminVideoPromptSchemaConfig';
|
import AdminVideoPromptSchemaConfig from './pages/AdminVideoPromptSchemaConfig';
|
||||||
import AdminContactRequests from './pages/AdminContactRequests';
|
import AdminContactRequests from './pages/AdminContactRequests';
|
||||||
|
import AdminHomeMaterials from './pages/AdminHomeMaterials';
|
||||||
|
|
||||||
import { useAdminStore } from './store';
|
import { useAdminStore } from './store';
|
||||||
|
|
||||||
@@ -107,6 +108,7 @@ const App = () => {
|
|||||||
<Route path="consume" element={<AdminConsume />} />
|
<Route path="consume" element={<AdminConsume />} />
|
||||||
<Route path="platform" element={<AdminPlatform />} />
|
<Route path="platform" element={<AdminPlatform />} />
|
||||||
<Route path="contact-requests" element={<AdminContactRequests />} />
|
<Route path="contact-requests" element={<AdminContactRequests />} />
|
||||||
|
<Route path="home-materials" element={<AdminHomeMaterials />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Real API client for connecting to the FastAPI backend.
|
* Real API client for connecting to the FastAPI backend.
|
||||||
* Handles auth tokens, request/response encryption, snake_case→camelCase conversion.
|
* Handles auth tokens, request/response encryption, snake_case→camelCase conversion.
|
||||||
|
* Supports both JSON bodies and multipart FormData bodies.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { encrypt, decrypt, isCryptoAvailable } from './crypto';
|
import { encrypt, decrypt, isCryptoAvailable } from './crypto';
|
||||||
@@ -24,6 +25,7 @@ function toCamel(s: string): string {
|
|||||||
function keysToCamel(obj: unknown): unknown {
|
function keysToCamel(obj: unknown): unknown {
|
||||||
if (Array.isArray(obj)) return obj.map(keysToCamel);
|
if (Array.isArray(obj)) return obj.map(keysToCamel);
|
||||||
if (obj !== null && typeof obj === 'object') {
|
if (obj !== null && typeof obj === 'object') {
|
||||||
|
if (obj instanceof File || obj instanceof Blob || obj instanceof FormData) return obj;
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(obj as Record<string, unknown>).map(([k, v]) => [toCamel(k), keysToCamel(v)])
|
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> {
|
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
const { method = 'GET', body, auth = true, encryptBody = USE_ENCRYPTION } = options;
|
const { method = 'GET', body, auth = true, encryptBody = USE_ENCRYPTION } = options;
|
||||||
|
const isFormData = typeof FormData !== 'undefined' && body instanceof FormData;
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {};
|
||||||
'Content-Type': 'application/json',
|
if (!isFormData) headers['Content-Type'] = 'application/json';
|
||||||
};
|
|
||||||
|
|
||||||
if (auth) {
|
if (auth) {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let bodyStr: string | undefined;
|
let requestBody: BodyInit | undefined;
|
||||||
if (body !== undefined) {
|
if (body !== undefined) {
|
||||||
const json = JSON.stringify(body);
|
if (isFormData) {
|
||||||
if (encryptBody) {
|
requestBody = body as FormData;
|
||||||
headers['X-Encrypted'] = 'true';
|
|
||||||
bodyStr = JSON.stringify({ data: await encrypt(json) });
|
|
||||||
} else {
|
} 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}`, {
|
const res = await fetch(`${BASE_URL}/api${path}`, {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
body: bodyStr,
|
body: requestBody,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 204) return undefined as T;
|
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
|
// If the response is encrypted, decrypt it
|
||||||
const hasEncryptedData = parsed && typeof parsed.data === 'string';
|
const hasEncryptedData = parsed && typeof parsed.data === 'string';
|
||||||
if (hasEncryptedData && encryptBody) {
|
if (hasEncryptedData && encryptBody && !isFormData) {
|
||||||
const decrypted = await tryDecrypt(parsed.data);
|
const decrypted = await tryDecrypt(parsed.data);
|
||||||
if (decrypted !== null) {
|
if (decrypted !== null) {
|
||||||
parsed = JSON.parse(decrypted);
|
parsed = JSON.parse(decrypted);
|
||||||
@@ -107,7 +113,8 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
|
|||||||
|
|
||||||
// Handle error responses
|
// Handle error responses
|
||||||
if (!res.ok) {
|
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) {
|
if (res.status === 401) {
|
||||||
clearToken();
|
clearToken();
|
||||||
// Only redirect to login if not already on login page
|
// 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
|
// Convenience methods
|
||||||
export const api = {
|
export const api = {
|
||||||
get: <T>(path: string, auth = true) => apiRequest<T>(path, { auth }),
|
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 }),
|
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 }),
|
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 }),
|
delete: <T>(path: string, auth = true) => apiRequest<T>(path, { method: 'DELETE', auth }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,28 @@ import type {
|
|||||||
AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams,
|
AdminTeam, AdminTeamListResponse, AdminTeamOption, AdminTeamPayload, AdminTeamQueryParams,
|
||||||
} from '../types';
|
} 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 ──────────────────────────────────────────────────
|
// ── Auth ──────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function login(username: string, password: string, captchaToken?: string, rememberMe?: boolean): Promise<User> {
|
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 !== undefined) query.set('page', String(params.page));
|
||||||
if (params.page_size !== undefined) query.set('page_size', String(params.page_size));
|
if (params.page_size !== undefined) query.set('page_size', String(params.page_size));
|
||||||
return api.get(`/material-consumption/admin/list?${query.toString()}`);
|
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;
|
startDate?: string;
|
||||||
endDate?: 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""add home material decoration
|
||||||
|
|
||||||
|
Revision ID: e5f260ed1459
|
||||||
|
Revises: 170005531deb
|
||||||
|
Create Date: 2026-06-30 14:36:51.291905
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = 'e5f260ed1459'
|
||||||
|
down_revision: Union[str, None] = '170005531deb'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('home_material_assets',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('category_id', sa.String(length=32), nullable=False, comment='行业类别ID'),
|
||||||
|
sa.Column('title', sa.String(length=128), nullable=True, comment='素材标题'),
|
||||||
|
sa.Column('media_type', sa.String(length=16), server_default='image', nullable=False, comment='素材类型:image图片,video视频'),
|
||||||
|
sa.Column('original_url', sa.Text(), nullable=False, comment='原始素材URL'),
|
||||||
|
sa.Column('original_storage_path', sa.Text(), nullable=False, comment='原始素材本地路径'),
|
||||||
|
sa.Column('watermarked_url', sa.Text(), nullable=True, comment='水印素材URL'),
|
||||||
|
sa.Column('watermarked_storage_path', sa.Text(), nullable=True, comment='水印素材本地路径'),
|
||||||
|
sa.Column('cover_url', sa.Text(), nullable=True, comment='视频封面URL'),
|
||||||
|
sa.Column('cover_storage_path', sa.Text(), nullable=True, comment='视频封面本地路径'),
|
||||||
|
sa.Column('watermark_id', sa.String(length=32), nullable=True, comment='水印图片ID'),
|
||||||
|
sa.Column('watermark_config_json', sa.Text(), nullable=True, comment='水印配置快照JSON'),
|
||||||
|
sa.Column('status', sa.String(length=32), server_default='draft', nullable=False, comment='处理状态:draft/processing/success/failed'),
|
||||||
|
sa.Column('error_message', sa.Text(), nullable=True, comment='处理失败原因'),
|
||||||
|
sa.Column('width', sa.Integer(), nullable=True, comment='素材宽度'),
|
||||||
|
sa.Column('height', sa.Integer(), nullable=True, comment='素材高度'),
|
||||||
|
sa.Column('duration_seconds', sa.Numeric(precision=10, scale=3), nullable=True, comment='视频时长,图片为空'),
|
||||||
|
sa.Column('file_size_bytes', sa.BigInteger(), server_default='0', nullable=False, comment='原始文件大小'),
|
||||||
|
sa.Column('watermarked_file_size_bytes', sa.BigInteger(), nullable=True, comment='水印后文件大小'),
|
||||||
|
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False, comment='是否前台展示'),
|
||||||
|
sa.Column('sort_order', sa.Integer(), server_default='0', nullable=False, comment='排序,越小越靠前'),
|
||||||
|
sa.Column('processed_at', sa.DateTime(timezone=True), nullable=True, comment='处理完成时间'),
|
||||||
|
sa.Column('created_by', sa.String(length=32), nullable=True, comment='创建管理员ID'),
|
||||||
|
sa.Column('updated_by', sa.String(length=32), nullable=True, comment='更新管理员ID'),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('idx_home_material_assets_admin_list', 'home_material_assets', ['deleted_at', 'category_id', 'media_type', 'status', 'is_active', 'sort_order', 'created_at'], unique=False)
|
||||||
|
op.create_index('idx_home_material_assets_category_sort', 'home_material_assets', ['category_id', 'sort_order', 'created_at'], unique=False)
|
||||||
|
op.create_index('idx_home_material_assets_status', 'home_material_assets', ['status'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_assets_category_id'), 'home_material_assets', ['category_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_assets_created_by'), 'home_material_assets', ['created_by'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_assets_deleted_at'), 'home_material_assets', ['deleted_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_assets_is_active'), 'home_material_assets', ['is_active'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_assets_media_type'), 'home_material_assets', ['media_type'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_assets_processed_at'), 'home_material_assets', ['processed_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_assets_sort_order'), 'home_material_assets', ['sort_order'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_assets_status'), 'home_material_assets', ['status'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_assets_title'), 'home_material_assets', ['title'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_assets_updated_by'), 'home_material_assets', ['updated_by'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_assets_watermark_id'), 'home_material_assets', ['watermark_id'], unique=False)
|
||||||
|
op.create_table('home_material_categories',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=64), nullable=False, comment='行业名称'),
|
||||||
|
sa.Column('key', sa.String(length=64), nullable=False, comment='行业唯一标识,前台可按 key 查询'),
|
||||||
|
sa.Column('description', sa.String(length=255), nullable=True, comment='行业描述'),
|
||||||
|
sa.Column('icon', sa.String(length=64), nullable=True, comment='前端图标名称'),
|
||||||
|
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False, comment='是否启用'),
|
||||||
|
sa.Column('sort_order', sa.Integer(), server_default='0', nullable=False, comment='排序,越小越靠前'),
|
||||||
|
sa.Column('created_by', sa.String(length=32), nullable=True, comment='创建管理员ID'),
|
||||||
|
sa.Column('updated_by', sa.String(length=32), nullable=True, comment='更新管理员ID'),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('idx_home_material_categories_active_sort', 'home_material_categories', ['deleted_at', 'is_active', 'sort_order', 'created_at'], unique=False)
|
||||||
|
op.create_index('idx_home_material_categories_key', 'home_material_categories', ['key'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_categories_created_by'), 'home_material_categories', ['created_by'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_categories_deleted_at'), 'home_material_categories', ['deleted_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_categories_is_active'), 'home_material_categories', ['is_active'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_categories_name'), 'home_material_categories', ['name'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_categories_sort_order'), 'home_material_categories', ['sort_order'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_categories_updated_by'), 'home_material_categories', ['updated_by'], unique=False)
|
||||||
|
op.create_table('home_material_watermarks',
|
||||||
|
sa.Column('id', sa.String(length=32), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=128), nullable=False, comment='水印名称'),
|
||||||
|
sa.Column('file_url', sa.Text(), nullable=False, comment='水印图片URL'),
|
||||||
|
sa.Column('storage_path', sa.Text(), nullable=False, comment='水印图片本地路径'),
|
||||||
|
sa.Column('file_name', sa.String(length=255), nullable=True, comment='原始文件名'),
|
||||||
|
sa.Column('file_size_bytes', sa.BigInteger(), server_default='0', nullable=False, comment='文件大小'),
|
||||||
|
sa.Column('width', sa.Integer(), nullable=True, comment='水印图片宽度'),
|
||||||
|
sa.Column('height', sa.Integer(), nullable=True, comment='水印图片高度'),
|
||||||
|
sa.Column('is_default', sa.Boolean(), server_default='false', nullable=False, comment='是否默认水印'),
|
||||||
|
sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False, comment='是否启用'),
|
||||||
|
sa.Column('created_by', sa.String(length=32), nullable=True, comment='创建管理员ID'),
|
||||||
|
sa.Column('updated_by', sa.String(length=32), nullable=True, comment='更新管理员ID'),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('idx_home_material_watermarks_active', 'home_material_watermarks', ['deleted_at', 'is_active', 'created_at'], unique=False)
|
||||||
|
op.create_index('idx_home_material_watermarks_default', 'home_material_watermarks', ['deleted_at', 'is_default'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_watermarks_created_by'), 'home_material_watermarks', ['created_by'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_watermarks_deleted_at'), 'home_material_watermarks', ['deleted_at'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_watermarks_is_active'), 'home_material_watermarks', ['is_active'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_watermarks_is_default'), 'home_material_watermarks', ['is_default'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_watermarks_name'), 'home_material_watermarks', ['name'], unique=False)
|
||||||
|
op.create_index(op.f('ix_home_material_watermarks_updated_by'), 'home_material_watermarks', ['updated_by'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_home_material_watermarks_updated_by'), table_name='home_material_watermarks')
|
||||||
|
op.drop_index(op.f('ix_home_material_watermarks_name'), table_name='home_material_watermarks')
|
||||||
|
op.drop_index(op.f('ix_home_material_watermarks_is_default'), table_name='home_material_watermarks')
|
||||||
|
op.drop_index(op.f('ix_home_material_watermarks_is_active'), table_name='home_material_watermarks')
|
||||||
|
op.drop_index(op.f('ix_home_material_watermarks_deleted_at'), table_name='home_material_watermarks')
|
||||||
|
op.drop_index(op.f('ix_home_material_watermarks_created_by'), table_name='home_material_watermarks')
|
||||||
|
op.drop_index('idx_home_material_watermarks_default', table_name='home_material_watermarks')
|
||||||
|
op.drop_index('idx_home_material_watermarks_active', table_name='home_material_watermarks')
|
||||||
|
op.drop_table('home_material_watermarks')
|
||||||
|
op.drop_index(op.f('ix_home_material_categories_updated_by'), table_name='home_material_categories')
|
||||||
|
op.drop_index(op.f('ix_home_material_categories_sort_order'), table_name='home_material_categories')
|
||||||
|
op.drop_index(op.f('ix_home_material_categories_name'), table_name='home_material_categories')
|
||||||
|
op.drop_index(op.f('ix_home_material_categories_is_active'), table_name='home_material_categories')
|
||||||
|
op.drop_index(op.f('ix_home_material_categories_deleted_at'), table_name='home_material_categories')
|
||||||
|
op.drop_index(op.f('ix_home_material_categories_created_by'), table_name='home_material_categories')
|
||||||
|
op.drop_index('idx_home_material_categories_key', table_name='home_material_categories')
|
||||||
|
op.drop_index('idx_home_material_categories_active_sort', table_name='home_material_categories')
|
||||||
|
op.drop_table('home_material_categories')
|
||||||
|
op.drop_index(op.f('ix_home_material_assets_watermark_id'), table_name='home_material_assets')
|
||||||
|
op.drop_index(op.f('ix_home_material_assets_updated_by'), table_name='home_material_assets')
|
||||||
|
op.drop_index(op.f('ix_home_material_assets_title'), table_name='home_material_assets')
|
||||||
|
op.drop_index(op.f('ix_home_material_assets_status'), table_name='home_material_assets')
|
||||||
|
op.drop_index(op.f('ix_home_material_assets_sort_order'), table_name='home_material_assets')
|
||||||
|
op.drop_index(op.f('ix_home_material_assets_processed_at'), table_name='home_material_assets')
|
||||||
|
op.drop_index(op.f('ix_home_material_assets_media_type'), table_name='home_material_assets')
|
||||||
|
op.drop_index(op.f('ix_home_material_assets_is_active'), table_name='home_material_assets')
|
||||||
|
op.drop_index(op.f('ix_home_material_assets_deleted_at'), table_name='home_material_assets')
|
||||||
|
op.drop_index(op.f('ix_home_material_assets_created_by'), table_name='home_material_assets')
|
||||||
|
op.drop_index(op.f('ix_home_material_assets_category_id'), table_name='home_material_assets')
|
||||||
|
op.drop_index('idx_home_material_assets_status', table_name='home_material_assets')
|
||||||
|
op.drop_index('idx_home_material_assets_category_sort', table_name='home_material_assets')
|
||||||
|
op.drop_index('idx_home_material_assets_admin_list', table_name='home_material_assets')
|
||||||
|
op.drop_table('home_material_assets')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -3,8 +3,10 @@ from fastapi import APIRouter
|
|||||||
from app.api.admin.video_prompt_schema_config import router as video_prompt_schema_config_router
|
from app.api.admin.video_prompt_schema_config import router as video_prompt_schema_config_router
|
||||||
from app.api.admin.resource_capacity import router as resource_capacity_router
|
from app.api.admin.resource_capacity import router as resource_capacity_router
|
||||||
from app.api.admin.team import router as team_router
|
from app.api.admin.team import router as team_router
|
||||||
|
from app.api.admin.home_material import router as home_material_router
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
router.include_router(video_prompt_schema_config_router)
|
router.include_router(video_prompt_schema_config_router)
|
||||||
router.include_router(resource_capacity_router)
|
router.include_router(resource_capacity_router)
|
||||||
router.include_router(team_router)
|
router.include_router(team_router)
|
||||||
|
router.include_router(home_material_router)
|
||||||
|
|||||||
@@ -0,0 +1,527 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, Path, Query, UploadFile
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.dependencies import get_admin_user, get_db
|
||||||
|
from app.enums.home_material import (
|
||||||
|
HomeMaterialAssetStatus,
|
||||||
|
HomeMaterialMediaType,
|
||||||
|
HomeMaterialOperationEnum,
|
||||||
|
HomeMaterialWatermarkPosition,
|
||||||
|
HomeMaterialWatermarkSizeMode,
|
||||||
|
HomeMaterialWatermarkType,
|
||||||
|
)
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.home_material import (
|
||||||
|
HomeMaterialAssetListOut,
|
||||||
|
HomeMaterialAssetOut,
|
||||||
|
HomeMaterialAssetStatusOut,
|
||||||
|
HomeMaterialAssetUpdate,
|
||||||
|
HomeMaterialCategoryCreate,
|
||||||
|
HomeMaterialCategoryListOut,
|
||||||
|
HomeMaterialCategoryOut,
|
||||||
|
HomeMaterialCategoryUpdate,
|
||||||
|
HomeMaterialConfigOut,
|
||||||
|
HomeMaterialConfigUpdate,
|
||||||
|
HomeMaterialRegenerateWatermarkRequest,
|
||||||
|
HomeMaterialTextWatermarkConfig,
|
||||||
|
HomeMaterialTextWatermarkPreviewRequest,
|
||||||
|
HomeMaterialTextWatermarkPreviewResponse,
|
||||||
|
HomeMaterialUploadResultOut,
|
||||||
|
HomeMaterialWatermarkConfig,
|
||||||
|
HomeMaterialWatermarkListOut,
|
||||||
|
HomeMaterialWatermarkOut,
|
||||||
|
HomeMaterialWatermarkUpdate,
|
||||||
|
)
|
||||||
|
from app.services.home_material import home_material_service
|
||||||
|
from app.services.operation_log import log_operation
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/admin/home-material", tags=["admin-home-material"])
|
||||||
|
|
||||||
|
|
||||||
|
def _detail(**kwargs) -> str:
|
||||||
|
return json.dumps(kwargs, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/config",
|
||||||
|
response_model=HomeMaterialConfigOut,
|
||||||
|
summary="获取首页素材展示配置",
|
||||||
|
description="获取首页素材行业装修展示配置。配置存储于 system_configs,key=home_material_showcase_config。enabled=false 时前台不展示该模块。",
|
||||||
|
)
|
||||||
|
async def get_home_material_config(
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
_ = admin
|
||||||
|
return await home_material_service.get_config(db)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/config",
|
||||||
|
response_model=HomeMaterialConfigOut,
|
||||||
|
summary="保存首页素材展示配置",
|
||||||
|
description="保存首页素材展示开关、标题、副标题、后台是否展示原素材配置,并写入操作日志。",
|
||||||
|
)
|
||||||
|
async def save_home_material_config(
|
||||||
|
req: HomeMaterialConfigUpdate,
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result, before, after = await home_material_service.save_config(db, req)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
HomeMaterialOperationEnum.CONFIG_UPDATE.value,
|
||||||
|
"PUT",
|
||||||
|
"/admin/home-material/config",
|
||||||
|
detail=_detail(before=before, after=after),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/categories",
|
||||||
|
response_model=HomeMaterialCategoryListOut,
|
||||||
|
summary="后台首页素材行业列表",
|
||||||
|
description="分页查询首页素材行业。列表不会连表,会先查行业列表,再按 category_id 批量 group by 查询素材数量后 map 回填。",
|
||||||
|
)
|
||||||
|
async def list_home_material_categories(
|
||||||
|
page: int = Query(1, ge=1, description="页码,默认1。"),
|
||||||
|
page_size: int = Query(20, ge=1, le=200, description="每页数量,默认20,最大200。"),
|
||||||
|
keyword: str | None = Query(None, description="搜索行业名称或 key。"),
|
||||||
|
is_active: bool | None = Query(None, description="是否启用。不传表示全部。"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
_ = admin
|
||||||
|
return await home_material_service.list_categories(db, page=page, page_size=page_size, keyword=keyword, is_active=is_active)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/categories",
|
||||||
|
response_model=HomeMaterialCategoryOut,
|
||||||
|
summary="新增首页素材行业",
|
||||||
|
description="新增首页素材行业类别。key 只允许字母、数字、下划线、中划线,且未软删记录内唯一。",
|
||||||
|
)
|
||||||
|
async def create_home_material_category(
|
||||||
|
req: HomeMaterialCategoryCreate,
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result, after = await home_material_service.create_category(db, req, admin.id)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
HomeMaterialOperationEnum.CATEGORY_CREATE.value,
|
||||||
|
"POST",
|
||||||
|
"/admin/home-material/categories",
|
||||||
|
detail=_detail(after=after),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/categories/{category_id}",
|
||||||
|
response_model=HomeMaterialCategoryOut,
|
||||||
|
summary="修改首页素材行业",
|
||||||
|
description="修改首页素材行业名称、key、描述、图标、启用状态和排序,并记录修改前后快照。",
|
||||||
|
)
|
||||||
|
async def update_home_material_category(
|
||||||
|
req: HomeMaterialCategoryUpdate,
|
||||||
|
category_id: str = Path(..., description="行业ID。"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result, before, after = await home_material_service.update_category(db, category_id, req, admin.id)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
HomeMaterialOperationEnum.CATEGORY_UPDATE.value,
|
||||||
|
"PUT",
|
||||||
|
f"/admin/home-material/categories/{category_id}",
|
||||||
|
detail=_detail(before=before, after=after),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/categories/{category_id}",
|
||||||
|
summary="删除首页素材行业",
|
||||||
|
description="软删首页素材行业。若行业下还有未删除素材,接口会拒绝删除,建议改为禁用。",
|
||||||
|
)
|
||||||
|
async def delete_home_material_category(
|
||||||
|
category_id: str = Path(..., description="行业ID。"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
category, before = await home_material_service.delete_category(db, category_id, admin.id)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
HomeMaterialOperationEnum.CATEGORY_DELETE.value,
|
||||||
|
"DELETE",
|
||||||
|
f"/admin/home-material/categories/{category_id}",
|
||||||
|
detail=_detail(before=before, after={"deleted_at": category.deleted_at}),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"message": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/watermarks",
|
||||||
|
response_model=HomeMaterialWatermarkListOut,
|
||||||
|
summary="后台水印图片列表",
|
||||||
|
description="分页查询首页素材水印图片库。",
|
||||||
|
)
|
||||||
|
async def list_home_material_watermarks(
|
||||||
|
page: int = Query(1, ge=1, description="页码,默认1。"),
|
||||||
|
page_size: int = Query(20, ge=1, le=200, description="每页数量,默认20,最大200。"),
|
||||||
|
is_active: bool | None = Query(None, description="是否启用。不传表示全部。"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
_ = admin
|
||||||
|
return await home_material_service.list_watermarks(db, page=page, page_size=page_size, is_active=is_active)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/watermarks",
|
||||||
|
response_model=HomeMaterialWatermarkOut,
|
||||||
|
summary="上传水印图片",
|
||||||
|
description="上传水印图片到水印库。支持 png/webp/jpg/jpeg。is_default=true 时自动取消其他默认水印。",
|
||||||
|
)
|
||||||
|
async def upload_home_material_watermark(
|
||||||
|
file: UploadFile = File(..., description="水印图片文件,建议 png 或 webp。"),
|
||||||
|
name: str | None = Form(None, description="水印名称,不传使用文件名。"),
|
||||||
|
is_default: bool = Form(False, description="是否设为默认水印。"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result, after = await home_material_service.upload_watermark(db, file=file, name=name, is_default=is_default, admin_id=admin.id)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
HomeMaterialOperationEnum.WATERMARK_UPLOAD.value,
|
||||||
|
"POST",
|
||||||
|
"/admin/home-material/watermarks",
|
||||||
|
detail=_detail(after=after),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/watermarks/{watermark_id}",
|
||||||
|
response_model=HomeMaterialWatermarkOut,
|
||||||
|
summary="修改水印图片信息",
|
||||||
|
description="修改水印名称、默认状态、启用状态。设为默认水印时自动取消其他默认水印。",
|
||||||
|
)
|
||||||
|
async def update_home_material_watermark(
|
||||||
|
req: HomeMaterialWatermarkUpdate,
|
||||||
|
watermark_id: str = Path(..., description="水印ID。"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result, before, after = await home_material_service.update_watermark(db, watermark_id, req, admin.id)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
HomeMaterialOperationEnum.WATERMARK_UPDATE.value,
|
||||||
|
"PUT",
|
||||||
|
f"/admin/home-material/watermarks/{watermark_id}",
|
||||||
|
detail=_detail(before=before, after=after),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/watermarks/{watermark_id}",
|
||||||
|
summary="删除水印图片",
|
||||||
|
description="软删水印图片记录,不物理删除文件;历史素材仍保留已生成的水印素材。",
|
||||||
|
)
|
||||||
|
async def delete_home_material_watermark(
|
||||||
|
watermark_id: str = Path(..., description="水印ID。"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
watermark, before = await home_material_service.delete_watermark(db, watermark_id, admin.id)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
HomeMaterialOperationEnum.WATERMARK_DELETE.value,
|
||||||
|
"DELETE",
|
||||||
|
f"/admin/home-material/watermarks/{watermark_id}",
|
||||||
|
detail=_detail(before=before, after={"deleted_at": watermark.deleted_at}),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"message": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/text-watermark-preview",
|
||||||
|
response_model=HomeMaterialTextWatermarkPreviewResponse,
|
||||||
|
summary="生成重复文字水印精准预览层",
|
||||||
|
description=(
|
||||||
|
"根据素材真实宽高和重复文字水印配置,使用后端固定开源字体生成透明 PNG 预览层 data URL。"
|
||||||
|
"该接口和最终 FFmpeg 叠加前的文字水印层共用同一套渲染逻辑,用于保证预览和实际效果一致。"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def preview_home_material_text_watermark(
|
||||||
|
req: HomeMaterialTextWatermarkPreviewRequest,
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
):
|
||||||
|
_ = admin
|
||||||
|
return await home_material_service.preview_text_watermark_layer(req)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/assets",
|
||||||
|
response_model=HomeMaterialAssetListOut,
|
||||||
|
summary="后台首页素材列表",
|
||||||
|
description="分页查询首页素材。不会连表,先查素材列表,再批量查询行业和水印 map 后回填 category_name/category_key/watermark_name。",
|
||||||
|
)
|
||||||
|
async def list_home_material_assets(
|
||||||
|
page: int = Query(1, ge=1, description="页码,默认1。"),
|
||||||
|
page_size: int = Query(20, ge=1, le=200, description="每页数量,默认20,最大200。"),
|
||||||
|
category_id: str | None = Query(None, description="行业ID。"),
|
||||||
|
media_type: HomeMaterialMediaType | None = Query(None, description="素材类型:image图片,video视频。"),
|
||||||
|
status: HomeMaterialAssetStatus | None = Query(None, description="处理状态:draft草稿,processing处理中,success成功,failed失败。"),
|
||||||
|
is_active: bool | None = Query(None, description="是否前台展示。"),
|
||||||
|
keyword: str | None = Query(None, description="素材标题搜索。"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
_ = admin
|
||||||
|
return await home_material_service.list_assets(
|
||||||
|
db,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
category_id=category_id,
|
||||||
|
media_type=media_type,
|
||||||
|
status=status,
|
||||||
|
is_active=is_active,
|
||||||
|
keyword=keyword,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/assets",
|
||||||
|
response_model=HomeMaterialUploadResultOut,
|
||||||
|
summary="上传首页素材并生成水印",
|
||||||
|
description=(
|
||||||
|
"上传图片或视频素材,并使用水印图片生成水印版本。默认 wait=false 立即返回 processing,前端轮询 /assets/{asset_id}/status。"
|
||||||
|
"可传 watermark_id 使用水印库,也可传 watermark_file 临时上传水印;二者都不传时使用默认水印。"
|
||||||
|
"水印透明度 opacity_level 为 1-10;position 支持九宫格和 custom;custom 需要 custom_x_ratio/custom_y_ratio;"
|
||||||
|
"size_mode=ratio 使用 width_ratio,size_mode=px 使用 width_px。"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def upload_home_material_asset(
|
||||||
|
category_id: str = Form(..., description="行业ID。"),
|
||||||
|
file: UploadFile = File(..., description="图片或视频素材文件。"),
|
||||||
|
media_type: HomeMaterialMediaType = Form(..., description="素材类型:image图片,video视频。"),
|
||||||
|
title: str | None = Form(None, description="素材标题。为空时不再自动回填文件名。"),
|
||||||
|
watermark_type: HomeMaterialWatermarkType = Form(HomeMaterialWatermarkType.IMAGE, description="水印类型:image 图片水印;repeated_text 重复文字水印。"),
|
||||||
|
watermark_id: str | None = Form(None, description="图片水印ID,可选。watermark_type=image 时使用。"),
|
||||||
|
watermark_file: UploadFile | None = File(None, description="临时图片水印,可选。watermark_type=image 时使用。"),
|
||||||
|
opacity_level: int = Form(6, ge=1, le=10, description="图片水印透明度档位,1-10。"),
|
||||||
|
position: HomeMaterialWatermarkPosition = Form(HomeMaterialWatermarkPosition.BOTTOM_RIGHT, description="水印位置。"),
|
||||||
|
custom_x_ratio: float | None = Form(None, ge=0, le=1, description="自定义位置X比例,position=custom时必填。"),
|
||||||
|
custom_y_ratio: float | None = Form(None, ge=0, le=1, description="自定义位置Y比例,position=custom时必填。"),
|
||||||
|
size_mode: HomeMaterialWatermarkSizeMode = Form(HomeMaterialWatermarkSizeMode.RATIO, description="水印尺寸模式:ratio/px。"),
|
||||||
|
width_ratio: float | None = Form(0.18, ge=0.01, le=1, description="size_mode=ratio时使用,水印宽度占素材宽度比例。"),
|
||||||
|
width_px: int | None = Form(None, ge=1, le=10000, description="size_mode=px时使用,固定水印宽度。"),
|
||||||
|
margin_x: int = Form(24, ge=0, le=2000, description="图片水印横向边距。"),
|
||||||
|
margin_y: int = Form(24, ge=0, le=2000, description="图片水印纵向边距。"),
|
||||||
|
text_watermark_text: str | None = Form(None, description="重复文字水印内容。watermark_type=repeated_text 时必填。"),
|
||||||
|
text_watermark_opacity_level: int = Form(2, ge=1, le=10, description="重复文字透明度档位,1-10。"),
|
||||||
|
text_watermark_font_size_px: int = Form(28, ge=8, le=160, description="重复文字字号 px。"),
|
||||||
|
text_watermark_color: str = Form("#ffffff", pattern=r"^#[0-9A-Fa-f]{6}$", description="重复文字颜色,#RRGGBB。"),
|
||||||
|
text_watermark_rotate_deg: int = Form(-30, ge=-90, le=90, description="重复文字旋转角度。"),
|
||||||
|
text_watermark_gap_x: int = Form(220, ge=20, le=2000, description="重复文字横向间距。"),
|
||||||
|
text_watermark_gap_y: int = Form(140, ge=20, le=2000, description="重复文字纵向间距。"),
|
||||||
|
text_watermark_staggered: bool = Form(True, description="重复文字是否交错排列。"),
|
||||||
|
is_active: bool = Form(True, description="是否前台展示。"),
|
||||||
|
sort_order: int = Form(0, ge=0, le=999999, description="排序值。"),
|
||||||
|
wait: bool = Form(False, description="是否等待处理完成。"),
|
||||||
|
wait_timeout_seconds: int = Form(30, ge=1, le=60, description="wait=true时最长等待秒数。"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
text_config = None
|
||||||
|
if watermark_type == HomeMaterialWatermarkType.REPEATED_TEXT:
|
||||||
|
text_config = HomeMaterialTextWatermarkConfig(
|
||||||
|
text=text_watermark_text or "",
|
||||||
|
opacity_level=text_watermark_opacity_level,
|
||||||
|
font_size_px=text_watermark_font_size_px,
|
||||||
|
color=text_watermark_color,
|
||||||
|
rotate_deg=text_watermark_rotate_deg,
|
||||||
|
gap_x=text_watermark_gap_x,
|
||||||
|
gap_y=text_watermark_gap_y,
|
||||||
|
staggered=text_watermark_staggered,
|
||||||
|
)
|
||||||
|
config = HomeMaterialWatermarkConfig(
|
||||||
|
watermark_type=watermark_type,
|
||||||
|
watermark_id=watermark_id,
|
||||||
|
opacity_level=opacity_level,
|
||||||
|
position=position,
|
||||||
|
custom_x_ratio=custom_x_ratio,
|
||||||
|
custom_y_ratio=custom_y_ratio,
|
||||||
|
size_mode=size_mode,
|
||||||
|
width_ratio=width_ratio,
|
||||||
|
width_px=width_px,
|
||||||
|
margin_x=margin_x,
|
||||||
|
margin_y=margin_y,
|
||||||
|
text_watermark=text_config,
|
||||||
|
)
|
||||||
|
result, after = await home_material_service.upload_asset(
|
||||||
|
db,
|
||||||
|
category_id=category_id,
|
||||||
|
file=file,
|
||||||
|
media_type=media_type,
|
||||||
|
title=title,
|
||||||
|
watermark_id=watermark_id,
|
||||||
|
watermark_file=watermark_file,
|
||||||
|
watermark_config=config,
|
||||||
|
is_active=is_active,
|
||||||
|
sort_order=sort_order,
|
||||||
|
admin_id=admin.id,
|
||||||
|
)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
HomeMaterialOperationEnum.ASSET_UPLOAD.value,
|
||||||
|
"POST",
|
||||||
|
"/admin/home-material/assets",
|
||||||
|
detail=_detail(after=after),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
home_material_service.start_watermark_task(result.id)
|
||||||
|
if wait:
|
||||||
|
return await home_material_service.wait_for_asset_result(result.id, wait_timeout_seconds)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/assets/{asset_id}",
|
||||||
|
response_model=HomeMaterialAssetOut,
|
||||||
|
summary="后台首页素材详情",
|
||||||
|
description="查询单个素材详情。详情仍不使用 ORM relationship,会按 category_id/watermark_id 批量查询 map 后组装。",
|
||||||
|
)
|
||||||
|
async def get_home_material_asset(
|
||||||
|
asset_id: str = Path(..., description="素材ID。"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
_ = admin
|
||||||
|
return await home_material_service.get_asset_detail(db, asset_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/assets/{asset_id}/status",
|
||||||
|
response_model=HomeMaterialAssetStatusOut,
|
||||||
|
summary="查询首页素材水印处理状态",
|
||||||
|
description="前端上传或重新生成后轮询本接口,status=success 时展示 watermarked_url,status=failed 时展示 error_message 并允许重新生成。",
|
||||||
|
)
|
||||||
|
async def get_home_material_asset_status(
|
||||||
|
asset_id: str = Path(..., description="素材ID。"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
_ = admin
|
||||||
|
return await home_material_service.get_asset_status(db, asset_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/assets/{asset_id}",
|
||||||
|
response_model=HomeMaterialAssetOut,
|
||||||
|
summary="修改首页素材展示信息",
|
||||||
|
description="只修改行业、标题、启用状态、排序,不重新生成水印。",
|
||||||
|
)
|
||||||
|
async def update_home_material_asset(
|
||||||
|
req: HomeMaterialAssetUpdate,
|
||||||
|
asset_id: str = Path(..., description="素材ID。"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result, before, after = await home_material_service.update_asset(db, asset_id, req, admin.id)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
HomeMaterialOperationEnum.ASSET_UPDATE.value,
|
||||||
|
"PUT",
|
||||||
|
f"/admin/home-material/assets/{asset_id}",
|
||||||
|
detail=_detail(before=before, after=after),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/assets/{asset_id}/regenerate-watermark",
|
||||||
|
response_model=HomeMaterialUploadResultOut,
|
||||||
|
summary="重新生成首页素材水印",
|
||||||
|
description="基于原始素材和新的水印配置重新生成水印文件。默认 wait=false,前端轮询状态接口。失败时保留旧 watermarked_url,但 status=failed。",
|
||||||
|
)
|
||||||
|
async def regenerate_home_material_watermark(
|
||||||
|
req: HomeMaterialRegenerateWatermarkRequest,
|
||||||
|
asset_id: str = Path(..., description="素材ID。"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result, before, after = await home_material_service.prepare_regenerate(db, asset_id, req, admin.id)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
HomeMaterialOperationEnum.ASSET_REGENERATE_WATERMARK.value,
|
||||||
|
"POST",
|
||||||
|
f"/admin/home-material/assets/{asset_id}/regenerate-watermark",
|
||||||
|
detail=_detail(before=before, after=after),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
home_material_service.start_watermark_task(asset_id)
|
||||||
|
if req.wait:
|
||||||
|
return await home_material_service.wait_for_asset_result(asset_id, req.wait_timeout_seconds)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/assets/{asset_id}",
|
||||||
|
summary="删除首页素材",
|
||||||
|
description="软删素材记录,不物理删除原文件和水印文件,避免误删历史展示资源。",
|
||||||
|
)
|
||||||
|
async def delete_home_material_asset(
|
||||||
|
asset_id: str = Path(..., description="素材ID。"),
|
||||||
|
admin: User = Depends(get_admin_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
asset, before = await home_material_service.delete_asset(db, asset_id, admin.id)
|
||||||
|
await log_operation(
|
||||||
|
db,
|
||||||
|
admin.id,
|
||||||
|
admin.username,
|
||||||
|
HomeMaterialOperationEnum.ASSET_DELETE.value,
|
||||||
|
"DELETE",
|
||||||
|
f"/admin/home-material/assets/{asset_id}",
|
||||||
|
detail=_detail(before=before, after={"deleted_at": asset.deleted_at}),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"message": "ok"}
|
||||||
@@ -28,6 +28,7 @@ from app.api.v1.material_consumption import router as material_consumption_route
|
|||||||
from app.api.v1.open_type import router as open_type_router
|
from app.api.v1.open_type import router as open_type_router
|
||||||
from app.api.v1.resources_material import router as resources_material_router
|
from app.api.v1.resources_material import router as resources_material_router
|
||||||
from app.api.v1.contact import router as contact_router
|
from app.api.v1.contact import router as contact_router
|
||||||
|
from app.api.v1.home_materials import router as home_materials_router
|
||||||
from app.api.admin import router as admin_module_router
|
from app.api.admin import router as admin_module_router
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
@@ -59,4 +60,5 @@ api_router.include_router(material_consumption_router)
|
|||||||
api_router.include_router(open_type_router)
|
api_router.include_router(open_type_router)
|
||||||
api_router.include_router(resources_material_router)
|
api_router.include_router(resources_material_router)
|
||||||
api_router.include_router(contact_router)
|
api_router.include_router(contact_router)
|
||||||
|
api_router.include_router(home_materials_router)
|
||||||
api_router.include_router(admin_module_router)
|
api_router.include_router(admin_module_router)
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.dependencies import get_db
|
||||||
|
from app.enums.home_material import HomeMaterialMediaType, HomeMaterialPublicResponseMode
|
||||||
|
from app.schemas.home_material import (
|
||||||
|
HomeMaterialPublicCategoryListOut,
|
||||||
|
HomeMaterialPublicFlatOut,
|
||||||
|
HomeMaterialPublicGroupedOut,
|
||||||
|
)
|
||||||
|
from app.services.home_material import home_material_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/home-materials", tags=["home-materials"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/categories",
|
||||||
|
response_model=HomeMaterialPublicCategoryListOut,
|
||||||
|
summary="获取首页素材行业列表",
|
||||||
|
description=(
|
||||||
|
"获取首页素材行业列表,用于前端渲染行业 Tab/筛选。"
|
||||||
|
"只返回已启用、未软删行业;only_has_assets=true 时只返回存在 success + is_active + watermarked_url 的行业。"
|
||||||
|
"统计素材数量时不连表,先查行业列表,再按 category_id 批量 group by 查询素材数量并 map 回填。"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def get_public_home_material_categories(
|
||||||
|
with_asset_count: bool = Query(True, description="是否返回每个行业下可展示素材数量。"),
|
||||||
|
media_type: HomeMaterialMediaType | None = Query(None, description="统计指定素材类型:image图片,video视频。不传表示全部。"),
|
||||||
|
only_has_assets: bool = Query(True, description="是否只返回有可展示素材的行业。"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await home_material_service.get_public_categories(
|
||||||
|
db,
|
||||||
|
with_asset_count=with_asset_count,
|
||||||
|
media_type=media_type,
|
||||||
|
only_has_assets=only_has_assets,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=HomeMaterialPublicGroupedOut | HomeMaterialPublicFlatOut,
|
||||||
|
summary="获取首页素材展示数据",
|
||||||
|
description=(
|
||||||
|
"获取首页素材展示数据。支持不传行业返回全部行业分组;支持 category_id/category_key/category_ids/category_keys 按行业过滤,"
|
||||||
|
"优先级为 category_id > category_key > category_ids > category_keys。"
|
||||||
|
"response_mode=grouped 时按行业分组并限制每个行业 limit_per_category 条;"
|
||||||
|
"response_mode=flat 时返回平铺分页素材流。前台接口只返回水印素材 url,不返回原始素材 original_url。"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async def get_public_home_materials(
|
||||||
|
category_id: str | None = Query(None, description="单个行业ID。优先级最高。"),
|
||||||
|
category_key: str | None = Query(None, description="单个行业key。未传 category_id 时生效。"),
|
||||||
|
category_ids: str | None = Query(None, description="多个行业ID,英文逗号分隔。未传单行业参数时生效。"),
|
||||||
|
category_keys: str | None = Query(None, description="多个行业key,英文逗号分隔。优先级最低。"),
|
||||||
|
media_type: HomeMaterialMediaType | None = Query(None, description="素材类型筛选:image图片,video视频。不传表示全部。"),
|
||||||
|
limit_per_category: int = Query(8, ge=1, le=50, description="grouped 模式下每个行业最多返回几条素材,默认8,最大50。"),
|
||||||
|
include_empty_categories: bool = Query(False, description="grouped 模式下是否返回无素材行业。默认 false。"),
|
||||||
|
response_mode: HomeMaterialPublicResponseMode = Query(HomeMaterialPublicResponseMode.GROUPED, description="返回模式:grouped按行业分组,flat平铺分页。"),
|
||||||
|
page: int = Query(1, ge=1, description="flat 模式页码,默认1。"),
|
||||||
|
page_size: int = Query(20, ge=1, le=100, description="flat 模式每页数量,默认20,最大100。"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
return await home_material_service.get_public_home_materials(
|
||||||
|
db,
|
||||||
|
category_id=category_id,
|
||||||
|
category_key=category_key,
|
||||||
|
category_ids=category_ids,
|
||||||
|
category_keys=category_keys,
|
||||||
|
media_type=media_type,
|
||||||
|
limit_per_category=limit_per_category,
|
||||||
|
include_empty_categories=include_empty_categories,
|
||||||
|
response_mode=response_mode,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
)
|
||||||
@@ -65,6 +65,27 @@ class Settings(BaseSettings):
|
|||||||
STORAGE_VIDEO_COVER_LOCAL_PATH: str = "./storage/generate/covers"
|
STORAGE_VIDEO_COVER_LOCAL_PATH: str = "./storage/generate/covers"
|
||||||
UPLOAD_LOCAL_PATH: str = "./storage/uploads"
|
UPLOAD_LOCAL_PATH: str = "./storage/uploads"
|
||||||
|
|
||||||
|
# 首页素材行业装修配置。
|
||||||
|
# 文件实际挂载仍走 /uploads,目录在 UPLOAD_LOCAL_PATH/home_materials 下。
|
||||||
|
HOME_MATERIAL_MAX_IMAGE_MB: int = 20
|
||||||
|
HOME_MATERIAL_MAX_VIDEO_MB: int = 300
|
||||||
|
HOME_MATERIAL_MAX_WATERMARK_MB: int = 10
|
||||||
|
HOME_MATERIAL_MAX_VIDEO_DURATION_SECONDS: int = 300
|
||||||
|
HOME_MATERIAL_IMAGE_WATERMARK_CONCURRENCY: int = 4
|
||||||
|
HOME_MATERIAL_VIDEO_WATERMARK_CONCURRENCY: int = 2
|
||||||
|
HOME_MATERIAL_FFMPEG_TIMEOUT_SECONDS: int = 600
|
||||||
|
HOME_MATERIAL_PROCESSING_STALE_MINUTES: int = 30
|
||||||
|
# 重复文字水印配置。
|
||||||
|
# 字体必须使用开源可商用字体文件,推荐 Noto Sans CJK SC / Source Han Sans SC,且前端预览层和后端实际生成均由后端使用该字体渲染。
|
||||||
|
# 不允许自动 fallback 到系统字体,避免预览不一致和字体授权风险。
|
||||||
|
HOME_MATERIAL_TEXT_WATERMARK_FONT: str = "./resource/font/NotoSansCJKsc-Regular.otf"
|
||||||
|
HOME_MATERIAL_TEXT_WATERMARK_FONT_LICENSE: str = "SIL Open Font License 1.1"
|
||||||
|
HOME_MATERIAL_TEXT_WATERMARK_MAX_TEXT_LENGTH: int = 64
|
||||||
|
HOME_MATERIAL_TEXT_WATERMARK_MAX_FONT_SIZE: int = 160
|
||||||
|
HOME_MATERIAL_TEXT_WATERMARK_MAX_GAP: int = 2000
|
||||||
|
HOME_MATERIAL_TEXT_WATERMARK_PREVIEW_MAX_WIDTH: int = 8192
|
||||||
|
HOME_MATERIAL_TEXT_WATERMARK_PREVIEW_MAX_HEIGHT: int = 8192
|
||||||
|
|
||||||
# 本地视频封面截帧配置。
|
# 本地视频封面截帧配置。
|
||||||
# 说明:
|
# 说明:
|
||||||
# - FFMPEG_BIN 为空时自动从系统 PATH 查找 ffmpeg / ffmpeg.exe。
|
# - FFMPEG_BIN 为空时自动从系统 PATH 查找 ffmpeg / ffmpeg.exe。
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ from app.enums.sms import *
|
|||||||
from app.enums.notification import *
|
from app.enums.notification import *
|
||||||
from app.enums.resource_capacity import *
|
from app.enums.resource_capacity import *
|
||||||
from app.enums.team import *
|
from app.enums.team import *
|
||||||
|
from app.enums.home_material import *
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialConfigKeyEnum(StrEnum):
|
||||||
|
"""首页素材装修配置 key。"""
|
||||||
|
|
||||||
|
SHOWCASE_CONFIG = "home_material_showcase_config"
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialMediaType(StrEnum):
|
||||||
|
"""首页素材媒体类型。"""
|
||||||
|
|
||||||
|
IMAGE = "image"
|
||||||
|
VIDEO = "video"
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialAssetStatus(StrEnum):
|
||||||
|
"""首页素材水印处理状态。"""
|
||||||
|
|
||||||
|
DRAFT = "draft"
|
||||||
|
PROCESSING = "processing"
|
||||||
|
SUCCESS = "success"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialWatermarkType(StrEnum):
|
||||||
|
"""首页素材水印类型。"""
|
||||||
|
|
||||||
|
IMAGE = "image"
|
||||||
|
REPEATED_TEXT = "repeated_text"
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialWatermarkPosition(StrEnum):
|
||||||
|
"""水印位置枚举。"""
|
||||||
|
|
||||||
|
TOP_LEFT = "top_left"
|
||||||
|
TOP_CENTER = "top_center"
|
||||||
|
TOP_RIGHT = "top_right"
|
||||||
|
MIDDLE_LEFT = "middle_left"
|
||||||
|
CENTER = "center"
|
||||||
|
MIDDLE_RIGHT = "middle_right"
|
||||||
|
BOTTOM_LEFT = "bottom_left"
|
||||||
|
BOTTOM_CENTER = "bottom_center"
|
||||||
|
BOTTOM_RIGHT = "bottom_right"
|
||||||
|
CUSTOM = "custom"
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialWatermarkSizeMode(StrEnum):
|
||||||
|
"""水印尺寸模式。"""
|
||||||
|
|
||||||
|
RATIO = "ratio"
|
||||||
|
PX = "px"
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialPublicResponseMode(StrEnum):
|
||||||
|
"""前台素材返回模式。"""
|
||||||
|
|
||||||
|
GROUPED = "grouped"
|
||||||
|
FLAT = "flat"
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialOperationEnum(StrEnum):
|
||||||
|
"""后台操作日志 action 枚举。"""
|
||||||
|
|
||||||
|
CONFIG_UPDATE = "更新首页素材展示配置"
|
||||||
|
CATEGORY_CREATE = "新增首页素材行业"
|
||||||
|
CATEGORY_UPDATE = "修改首页素材行业"
|
||||||
|
CATEGORY_DELETE = "删除首页素材行业"
|
||||||
|
WATERMARK_UPLOAD = "上传首页素材水印"
|
||||||
|
WATERMARK_UPDATE = "修改首页素材水印"
|
||||||
|
WATERMARK_DELETE = "删除首页素材水印"
|
||||||
|
ASSET_UPLOAD = "上传首页素材"
|
||||||
|
ASSET_UPDATE = "修改首页素材"
|
||||||
|
ASSET_DELETE = "删除首页素材"
|
||||||
|
ASSET_REGENERATE_WATERMARK = "重新生成首页素材水印"
|
||||||
|
|
||||||
|
|
||||||
|
HOME_MATERIAL_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
|
||||||
|
HOME_MATERIAL_VIDEO_EXTENSIONS = {".mp4", ".mov", ".m4v", ".webm"}
|
||||||
|
HOME_MATERIAL_WATERMARK_EXTENSIONS = {".png", ".webp", ".jpg", ".jpeg"}
|
||||||
|
|
||||||
|
HOME_MATERIAL_DEFAULT_CONFIG = {
|
||||||
|
"enabled": False,
|
||||||
|
"title": "行业素材案例",
|
||||||
|
"subtitle": "精选图片与视频素材展示",
|
||||||
|
"show_original_in_admin": True,
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@ from app.models.shot_replicate_segment import ShotReplicateSegment
|
|||||||
from app.models.user_oauth import UserOAuth
|
from app.models.user_oauth import UserOAuth
|
||||||
from app.models.user_oauth_account import UserOAuthAccount
|
from app.models.user_oauth_account import UserOAuthAccount
|
||||||
from app.models.user_oauth_app import UserOAuthApp
|
from app.models.user_oauth_app import UserOAuthApp
|
||||||
|
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base", "TimestampMixin", "SoftDeleteMixin", "engine", "async_session",
|
"Base", "TimestampMixin", "SoftDeleteMixin", "engine", "async_session",
|
||||||
@@ -44,4 +45,5 @@ __all__ = [
|
|||||||
"ModuleGenerationProject", "ModuleGenerationStep",
|
"ModuleGenerationProject", "ModuleGenerationStep",
|
||||||
"ShotReplicateTaskSet", "ShotReplicateSegment",
|
"ShotReplicateTaskSet", "ShotReplicateSegment",
|
||||||
"UserOAuth", "UserOAuthAccount", "UserOAuthApp",
|
"UserOAuth", "UserOAuthAccount", "UserOAuthApp",
|
||||||
|
"HomeMaterialAsset", "HomeMaterialCategory", "HomeMaterialWatermark",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from app.models.home_material.asset import HomeMaterialAsset
|
||||||
|
from app.models.home_material.category import HomeMaterialCategory
|
||||||
|
from app.models.home_material.watermark import HomeMaterialWatermark
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"HomeMaterialAsset",
|
||||||
|
"HomeMaterialCategory",
|
||||||
|
"HomeMaterialWatermark",
|
||||||
|
]
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, Boolean, DateTime, Index, Integer, Numeric, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.enums.home_material import HomeMaterialAssetStatus, HomeMaterialMediaType
|
||||||
|
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialAsset(Base, TimestampMixin, SoftDeleteMixin):
|
||||||
|
"""首页素材资产。"""
|
||||||
|
|
||||||
|
__tablename__ = "home_material_assets"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_home_material_assets_admin_list", "deleted_at", "category_id", "media_type", "status", "is_active", "sort_order", "created_at"),
|
||||||
|
Index("idx_home_material_assets_category_sort", "category_id", "sort_order", "created_at"),
|
||||||
|
Index("idx_home_material_assets_status", "status"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||||
|
category_id: Mapped[str] = mapped_column(String(32), nullable=False, index=True, comment="行业类别ID")
|
||||||
|
title: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True, comment="素材标题")
|
||||||
|
media_type: Mapped[str] = mapped_column(
|
||||||
|
String(16),
|
||||||
|
default=HomeMaterialMediaType.IMAGE.value,
|
||||||
|
server_default=HomeMaterialMediaType.IMAGE.value,
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="素材类型:image图片,video视频",
|
||||||
|
)
|
||||||
|
original_url: Mapped[str] = mapped_column(Text, nullable=False, comment="原始素材URL")
|
||||||
|
original_storage_path: Mapped[str] = mapped_column(Text, nullable=False, comment="原始素材本地路径")
|
||||||
|
watermarked_url: Mapped[str | None] = mapped_column(Text, nullable=True, comment="水印素材URL")
|
||||||
|
watermarked_storage_path: Mapped[str | None] = mapped_column(Text, nullable=True, comment="水印素材本地路径")
|
||||||
|
cover_url: Mapped[str | None] = mapped_column(Text, nullable=True, comment="视频封面URL")
|
||||||
|
cover_storage_path: Mapped[str | None] = mapped_column(Text, nullable=True, comment="视频封面本地路径")
|
||||||
|
watermark_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True, comment="水印图片ID")
|
||||||
|
watermark_config_json: Mapped[str | None] = mapped_column(Text, nullable=True, comment="水印配置快照JSON")
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(32),
|
||||||
|
default=HomeMaterialAssetStatus.DRAFT.value,
|
||||||
|
server_default=HomeMaterialAssetStatus.DRAFT.value,
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="处理状态:draft/processing/success/failed",
|
||||||
|
)
|
||||||
|
error_message: Mapped[str | None] = mapped_column(Text, nullable=True, comment="处理失败原因")
|
||||||
|
width: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="素材宽度")
|
||||||
|
height: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="素材高度")
|
||||||
|
duration_seconds: Mapped[Decimal | None] = mapped_column(Numeric(10, 3), nullable=True, comment="视频时长,图片为空")
|
||||||
|
file_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0", nullable=False, comment="原始文件大小")
|
||||||
|
watermarked_file_size_bytes: Mapped[int | None] = mapped_column(BigInteger, nullable=True, comment="水印后文件大小")
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true", nullable=False, index=True, comment="是否前台展示")
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0, server_default="0", nullable=False, index=True, comment="排序,越小越靠前")
|
||||||
|
processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True, comment="处理完成时间")
|
||||||
|
created_by: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True, comment="创建管理员ID")
|
||||||
|
updated_by: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True, comment="更新管理员ID")
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Index, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialCategory(Base, TimestampMixin, SoftDeleteMixin):
|
||||||
|
"""首页素材行业类别。"""
|
||||||
|
|
||||||
|
__tablename__ = "home_material_categories"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_home_material_categories_active_sort", "deleted_at", "is_active", "sort_order", "created_at"),
|
||||||
|
Index("idx_home_material_categories_key", "key"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(64), nullable=False, index=True, comment="行业名称")
|
||||||
|
key: Mapped[str] = mapped_column(String(64), nullable=False, comment="行业唯一标识,前台可按 key 查询")
|
||||||
|
description: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="行业描述")
|
||||||
|
icon: Mapped[str | None] = mapped_column(String(64), nullable=True, comment="前端图标名称")
|
||||||
|
is_active: Mapped[bool] = mapped_column(
|
||||||
|
Boolean,
|
||||||
|
default=True,
|
||||||
|
server_default="true",
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="是否启用",
|
||||||
|
)
|
||||||
|
sort_order: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
default=0,
|
||||||
|
server_default="0",
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
comment="排序,越小越靠前",
|
||||||
|
)
|
||||||
|
created_by: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True, comment="创建管理员ID")
|
||||||
|
updated_by: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True, comment="更新管理员ID")
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, Boolean, Index, Integer, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.base import Base, SoftDeleteMixin, TimestampMixin
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialWatermark(Base, TimestampMixin, SoftDeleteMixin):
|
||||||
|
"""首页素材水印图片库。"""
|
||||||
|
|
||||||
|
__tablename__ = "home_material_watermarks"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_home_material_watermarks_active", "deleted_at", "is_active", "created_at"),
|
||||||
|
Index("idx_home_material_watermarks_default", "deleted_at", "is_default"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(128), nullable=False, index=True, comment="水印名称")
|
||||||
|
file_url: Mapped[str] = mapped_column(Text, nullable=False, comment="水印图片URL")
|
||||||
|
storage_path: Mapped[str] = mapped_column(Text, nullable=False, comment="水印图片本地路径")
|
||||||
|
file_name: Mapped[str | None] = mapped_column(String(255), nullable=True, comment="原始文件名")
|
||||||
|
file_size_bytes: Mapped[int] = mapped_column(BigInteger, default=0, server_default="0", nullable=False, comment="文件大小")
|
||||||
|
width: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="水印图片宽度")
|
||||||
|
height: Mapped[int | None] = mapped_column(Integer, nullable=True, comment="水印图片高度")
|
||||||
|
is_default: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false", nullable=False, index=True, comment="是否默认水印")
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true", nullable=False, index=True, comment="是否启用")
|
||||||
|
created_by: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True, comment="创建管理员ID")
|
||||||
|
updated_by: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True, comment="更新管理员ID")
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||||
|
|
||||||
|
from app.enums.home_material import (
|
||||||
|
HomeMaterialAssetStatus,
|
||||||
|
HomeMaterialMediaType,
|
||||||
|
HomeMaterialPublicResponseMode,
|
||||||
|
HomeMaterialWatermarkPosition,
|
||||||
|
HomeMaterialWatermarkSizeMode,
|
||||||
|
HomeMaterialWatermarkType,
|
||||||
|
)
|
||||||
|
from app.schemas.common import NaiveDatetimeOptional
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialConfigOut(BaseModel):
|
||||||
|
enabled: bool = Field(False, description="首页是否展示素材装修模块。false 时前台行业和素材接口返回 enabled=false 且列表为空。")
|
||||||
|
title: str = Field("行业素材案例", max_length=64, description="首页素材模块标题。")
|
||||||
|
subtitle: str = Field("精选图片与视频素材展示", max_length=128, description="首页素材模块副标题。")
|
||||||
|
show_original_in_admin: bool = Field(True, description="后台是否展示原始素材链接。前台接口永远不返回原始素材链接。")
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialConfigUpdate(BaseModel):
|
||||||
|
enabled: bool = Field(False, description="首页是否展示素材装修模块。")
|
||||||
|
title: str = Field("行业素材案例", min_length=1, max_length=64, description="首页素材模块标题。")
|
||||||
|
subtitle: str = Field("精选图片与视频素材展示", max_length=128, description="首页素材模块副标题。")
|
||||||
|
show_original_in_admin: bool = Field(True, description="后台是否展示原始素材链接。")
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialCategoryCreate(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=64, description="行业名称,例如:美妆护肤、食品生鲜。")
|
||||||
|
key: str = Field(..., min_length=1, max_length=64, pattern=r"^[A-Za-z0-9_-]+$", description="行业唯一标识,只允许字母、数字、下划线、中划线;前台可通过 category_key 查询。")
|
||||||
|
description: str | None = Field(None, max_length=255, description="行业描述。")
|
||||||
|
icon: str | None = Field(None, max_length=64, description="前端图标名称,例如 PictureOutlined、VideoCameraOutlined。")
|
||||||
|
is_active: bool = Field(True, description="是否启用。禁用后前台不展示。")
|
||||||
|
sort_order: int = Field(0, ge=0, le=999999, description="排序值,越小越靠前。")
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialCategoryUpdate(HomeMaterialCategoryCreate):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialCategoryOut(BaseModel):
|
||||||
|
id: str = Field(..., description="行业ID。")
|
||||||
|
name: str = Field(..., description="行业名称。")
|
||||||
|
key: str = Field(..., description="行业唯一标识。")
|
||||||
|
description: str | None = Field(None, description="行业描述。")
|
||||||
|
icon: str | None = Field(None, description="前端图标名称。")
|
||||||
|
is_active: bool = Field(True, description="是否启用。")
|
||||||
|
sort_order: int = Field(0, description="排序值。")
|
||||||
|
asset_count: int = Field(0, description="可展示素材总数。后台列表按未删除素材统计,前台按 success + is_active 统计。")
|
||||||
|
image_count: int = Field(0, description="图片素材数量。")
|
||||||
|
video_count: int = Field(0, description="视频素材数量。")
|
||||||
|
created_at: NaiveDatetimeOptional = Field(None, description="创建时间。")
|
||||||
|
updated_at: NaiveDatetimeOptional = Field(None, description="更新时间。")
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialCategoryListOut(BaseModel):
|
||||||
|
items: list[HomeMaterialCategoryOut] = Field(default_factory=list, description="行业列表。")
|
||||||
|
total: int = Field(0, description="符合条件的行业总数。")
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialWatermarkOut(BaseModel):
|
||||||
|
id: str = Field(..., description="水印ID。")
|
||||||
|
name: str = Field(..., description="水印名称。")
|
||||||
|
file_url: str = Field(..., description="水印图片URL。")
|
||||||
|
file_name: str | None = Field(None, description="上传文件名。")
|
||||||
|
file_size_bytes: int = Field(0, description="文件大小,单位字节。")
|
||||||
|
width: int | None = Field(None, description="水印图片宽度。")
|
||||||
|
height: int | None = Field(None, description="水印图片高度。")
|
||||||
|
is_default: bool = Field(False, description="是否默认水印。")
|
||||||
|
is_active: bool = Field(True, description="是否启用。")
|
||||||
|
created_at: NaiveDatetimeOptional = None
|
||||||
|
updated_at: NaiveDatetimeOptional = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialWatermarkUpdate(BaseModel):
|
||||||
|
name: str = Field(..., min_length=1, max_length=128, description="水印名称。")
|
||||||
|
is_default: bool = Field(False, description="是否设为默认水印。设为 true 时会自动取消其他默认水印。")
|
||||||
|
is_active: bool = Field(True, description="是否启用。")
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialWatermarkListOut(BaseModel):
|
||||||
|
items: list[HomeMaterialWatermarkOut] = Field(default_factory=list, description="水印列表。")
|
||||||
|
total: int = Field(0, description="符合条件的水印总数。")
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialTextWatermarkConfig(BaseModel):
|
||||||
|
"""重复文字水印配置。字体固定由后端 HOME_MATERIAL_TEXT_WATERMARK_FONT 指定,不允许前端传字体,避免版权和一致性问题。"""
|
||||||
|
|
||||||
|
text: str = Field(..., min_length=1, max_length=64, description="重复水印文字。")
|
||||||
|
opacity_level: int = Field(2, ge=1, le=10, description="文字透明度档位,1-10;1=0.1,10=1.0。")
|
||||||
|
opacity: float = Field(0.2, ge=0.1, le=1.0, description="实际透明度,后端根据 opacity_level 计算。")
|
||||||
|
font_size_px: int = Field(28, ge=8, le=160, description="字号,单位 px。")
|
||||||
|
color: str = Field("#ffffff", pattern=r"^#[0-9A-Fa-f]{6}$", description="文字颜色,#RRGGBB。")
|
||||||
|
rotate_deg: int = Field(-30, ge=-90, le=90, description="文字旋转角度,单位度。")
|
||||||
|
gap_x: int = Field(220, ge=20, le=2000, description="横向平铺间距。")
|
||||||
|
gap_y: int = Field(140, ge=20, le=2000, description="纵向平铺间距。")
|
||||||
|
staggered: bool = Field(True, description="是否交错排列。")
|
||||||
|
|
||||||
|
@field_validator("text")
|
||||||
|
@classmethod
|
||||||
|
def validate_text(cls, value: str) -> str:
|
||||||
|
value = (value or "").strip()
|
||||||
|
if not value:
|
||||||
|
raise ValueError("重复文字水印内容不能为空")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def fill_opacity(self) -> "HomeMaterialTextWatermarkConfig":
|
||||||
|
self.opacity = round(self.opacity_level / 10, 2)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialWatermarkConfig(BaseModel):
|
||||||
|
watermark_type: HomeMaterialWatermarkType = Field(HomeMaterialWatermarkType.IMAGE, description="水印类型:image=图片水印;repeated_text=重复文字水印。旧数据无该字段时默认 image。")
|
||||||
|
watermark_id: str | None = Field(None, description="图片水印ID。watermark_type=image 时使用。上传素材时可由 watermark_id 指定,也可通过临时 watermark_file 生成。")
|
||||||
|
opacity_level: int = Field(6, ge=1, le=10, description="图片水印透明度档位,1-10;1=0.1,10=1.0。")
|
||||||
|
opacity: float = Field(0.6, ge=0.1, le=1.0, description="图片水印实际透明度,后端根据 opacity_level 计算。")
|
||||||
|
position: HomeMaterialWatermarkPosition = Field(HomeMaterialWatermarkPosition.BOTTOM_RIGHT, description="图片水印位置枚举。")
|
||||||
|
custom_x_ratio: float | None = Field(None, ge=0, le=1, description="图片水印自定义拖拽位置 X 比例,position=custom 时使用,0-1。")
|
||||||
|
custom_y_ratio: float | None = Field(None, ge=0, le=1, description="图片水印自定义拖拽位置 Y 比例,position=custom 时使用,0-1。")
|
||||||
|
size_mode: HomeMaterialWatermarkSizeMode = Field(HomeMaterialWatermarkSizeMode.RATIO, description="图片水印尺寸模式:ratio=按素材宽度比例;px=固定像素宽度。")
|
||||||
|
width_ratio: float | None = Field(0.18, ge=0.01, le=1, description="图片水印 size_mode=ratio 时使用,水印宽度占素材宽度比例,例如 0.18。")
|
||||||
|
width_px: int | None = Field(None, ge=1, le=10000, description="图片水印 size_mode=px 时使用,固定水印宽度。")
|
||||||
|
margin_x: int = Field(24, ge=0, le=2000, description="图片水印横向边距,九宫格位置使用。")
|
||||||
|
margin_y: int = Field(24, ge=0, le=2000, description="图片水印纵向边距,九宫格位置使用。")
|
||||||
|
text_watermark: HomeMaterialTextWatermarkConfig | None = Field(None, description="重复文字水印配置。watermark_type=repeated_text 时必填。")
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_by_type(self) -> "HomeMaterialWatermarkConfig":
|
||||||
|
self.opacity = round(self.opacity_level / 10, 2)
|
||||||
|
if self.watermark_type == HomeMaterialWatermarkType.REPEATED_TEXT:
|
||||||
|
if self.text_watermark is None:
|
||||||
|
raise ValueError("watermark_type=repeated_text 时 text_watermark 必填")
|
||||||
|
self.watermark_id = None
|
||||||
|
return self
|
||||||
|
|
||||||
|
if self.position == HomeMaterialWatermarkPosition.CUSTOM:
|
||||||
|
if self.custom_x_ratio is None or self.custom_y_ratio is None:
|
||||||
|
raise ValueError("position=custom 时 custom_x_ratio 和 custom_y_ratio 必填")
|
||||||
|
if self.size_mode == HomeMaterialWatermarkSizeMode.RATIO and self.width_ratio is None:
|
||||||
|
raise ValueError("size_mode=ratio 时 width_ratio 必填")
|
||||||
|
if self.size_mode == HomeMaterialWatermarkSizeMode.PX and self.width_px is None:
|
||||||
|
raise ValueError("size_mode=px 时 width_px 必填")
|
||||||
|
self.text_watermark = None
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialTextWatermarkPreviewRequest(BaseModel):
|
||||||
|
width: int = Field(..., ge=1, le=8192, description="预览层宽度,建议传素材真实宽度。")
|
||||||
|
height: int = Field(..., ge=1, le=8192, description="预览层高度,建议传素材真实高度。")
|
||||||
|
text_watermark: HomeMaterialTextWatermarkConfig = Field(..., description="重复文字水印配置。")
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialTextWatermarkPreviewResponse(BaseModel):
|
||||||
|
width: int = Field(..., description="预览层宽度。")
|
||||||
|
height: int = Field(..., description="预览层高度。")
|
||||||
|
preview_layer_data_url: str = Field(..., description="透明 PNG 预览层 data URL。")
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialAssetOut(BaseModel):
|
||||||
|
id: str = Field(..., description="素材ID。")
|
||||||
|
category_id: str = Field(..., description="行业ID。")
|
||||||
|
category_name: str | None = Field(None, description="行业名称,列表接口批量查询回填。")
|
||||||
|
category_key: str | None = Field(None, description="行业key,列表接口批量查询回填。")
|
||||||
|
title: str | None = Field(None, description="素材标题。")
|
||||||
|
media_type: HomeMaterialMediaType = Field(..., description="素材类型:image=图片,video=视频。")
|
||||||
|
status: HomeMaterialAssetStatus = Field(..., description="处理状态:draft草稿,processing处理中,success成功,failed失败。")
|
||||||
|
original_url: str | None = Field(None, description="原始素材URL,仅后台返回;前台不返回。")
|
||||||
|
watermarked_url: str | None = Field(None, description="水印素材URL。")
|
||||||
|
cover_url: str | None = Field(None, description="视频封面URL。")
|
||||||
|
watermark_id: str | None = Field(None, description="图片水印ID。重复文字水印素材为空。")
|
||||||
|
watermark_name: str | None = Field(None, description="图片水印名称,列表接口批量查询回填。重复文字水印显示为空。")
|
||||||
|
watermark_config: HomeMaterialWatermarkConfig | dict[str, Any] | None = Field(None, description="水印配置快照。")
|
||||||
|
width: int | None = Field(None, description="素材宽度。")
|
||||||
|
height: int | None = Field(None, description="素材高度。")
|
||||||
|
duration_seconds: Decimal | float | None = Field(None, description="视频时长,图片为空。")
|
||||||
|
file_size_bytes: int = Field(0, description="原始文件大小。")
|
||||||
|
watermarked_file_size_bytes: int | None = Field(None, description="水印后文件大小。")
|
||||||
|
is_active: bool = Field(True, description="是否前台展示。")
|
||||||
|
sort_order: int = Field(0, description="排序值。")
|
||||||
|
error_message: str | None = Field(None, description="失败原因。")
|
||||||
|
processed_at: NaiveDatetimeOptional = Field(None, description="处理完成时间。")
|
||||||
|
created_at: NaiveDatetimeOptional = None
|
||||||
|
updated_at: NaiveDatetimeOptional = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialAssetListOut(BaseModel):
|
||||||
|
items: list[HomeMaterialAssetOut] = Field(default_factory=list, description="素材列表。")
|
||||||
|
total: int = Field(0, description="符合条件的素材总数。")
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialAssetUpdate(BaseModel):
|
||||||
|
category_id: str = Field(..., description="行业ID。")
|
||||||
|
title: str | None = Field(None, max_length=128, description="素材标题。")
|
||||||
|
is_active: bool = Field(True, description="是否前台展示。")
|
||||||
|
sort_order: int = Field(0, ge=0, le=999999, description="排序值。")
|
||||||
|
|
||||||
|
@field_validator("title")
|
||||||
|
@classmethod
|
||||||
|
def clean_title(cls, value: str | None) -> str | None:
|
||||||
|
value = (value or "").strip()
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialRegenerateWatermarkRequest(HomeMaterialWatermarkConfig):
|
||||||
|
wait: bool = Field(False, description="是否等待水印处理完成。默认 false,前端轮询状态。")
|
||||||
|
wait_timeout_seconds: int = Field(30, ge=1, le=60, description="wait=true 时最长等待秒数,最大 60 秒。")
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialAssetStatusOut(BaseModel):
|
||||||
|
id: str = Field(..., description="素材ID。")
|
||||||
|
status: HomeMaterialAssetStatus = Field(..., description="处理状态。")
|
||||||
|
error_message: str | None = Field(None, description="失败原因。")
|
||||||
|
original_url: str | None = Field(None, description="原始素材URL,仅后台返回。")
|
||||||
|
watermarked_url: str | None = Field(None, description="水印素材URL。")
|
||||||
|
cover_url: str | None = Field(None, description="视频封面URL。")
|
||||||
|
processed_at: NaiveDatetimeOptional = Field(None, description="处理完成时间。")
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialPublicCategoryOut(BaseModel):
|
||||||
|
id: str = Field(..., description="行业ID。")
|
||||||
|
name: str = Field(..., description="行业名称。")
|
||||||
|
key: str = Field(..., description="行业key。")
|
||||||
|
icon: str | None = Field(None, description="前端图标名。")
|
||||||
|
description: str | None = Field(None, description="行业描述。")
|
||||||
|
sort_order: int = Field(0, description="排序。")
|
||||||
|
asset_count: int = Field(0, description="可展示素材总数。")
|
||||||
|
image_count: int = Field(0, description="图片素材数量。")
|
||||||
|
video_count: int = Field(0, description="视频素材数量。")
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialPublicCategoryListOut(BaseModel):
|
||||||
|
enabled: bool = Field(False, description="首页素材展示开关。false 时 items 为空。")
|
||||||
|
title: str = Field("行业素材案例", description="首页素材模块标题。")
|
||||||
|
subtitle: str = Field("精选图片与视频素材展示", description="首页素材模块副标题。")
|
||||||
|
items: list[HomeMaterialPublicCategoryOut] = Field(default_factory=list, description="行业列表。")
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialPublicAssetOut(BaseModel):
|
||||||
|
id: str = Field(..., description="素材ID。")
|
||||||
|
title: str | None = Field(None, description="素材标题。")
|
||||||
|
media_type: HomeMaterialMediaType = Field(..., description="素材类型。")
|
||||||
|
url: str = Field(..., description="前台展示URL,永远返回 watermarked_url。")
|
||||||
|
cover_url: str | None = Field(None, description="视频封面URL。")
|
||||||
|
width: int | None = Field(None, description="宽度。")
|
||||||
|
height: int | None = Field(None, description="高度。")
|
||||||
|
duration_seconds: Decimal | float | None = Field(None, description="视频时长。")
|
||||||
|
sort_order: int = Field(0, description="排序。")
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialPublicCategoryGroupOut(BaseModel):
|
||||||
|
category: HomeMaterialPublicCategoryOut
|
||||||
|
assets: list[HomeMaterialPublicAssetOut] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialPublicGroupedOut(BaseModel):
|
||||||
|
enabled: bool = Field(True, description="首页素材展示开关。")
|
||||||
|
title: str = Field("行业素材案例", description="首页素材模块标题。")
|
||||||
|
subtitle: str = Field("精选图片与视频素材展示", description="首页素材模块副标题。")
|
||||||
|
response_mode: HomeMaterialPublicResponseMode = Field(HomeMaterialPublicResponseMode.GROUPED, description="返回模式。")
|
||||||
|
categories: list[HomeMaterialPublicCategoryGroupOut] = Field(default_factory=list, description="行业分组素材。")
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialPublicFlatItemOut(HomeMaterialPublicAssetOut):
|
||||||
|
category_id: str = Field(..., description="行业ID。")
|
||||||
|
category_name: str = Field(..., description="行业名称。")
|
||||||
|
category_key: str = Field(..., description="行业key。")
|
||||||
|
category_icon: str | None = Field(None, description="行业图标。")
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialPublicFlatOut(BaseModel):
|
||||||
|
enabled: bool = Field(True, description="首页素材展示开关。")
|
||||||
|
title: str = Field("行业素材案例", description="首页素材模块标题。")
|
||||||
|
subtitle: str = Field("精选图片与视频素材展示", description="首页素材模块副标题。")
|
||||||
|
response_mode: HomeMaterialPublicResponseMode = Field(HomeMaterialPublicResponseMode.FLAT, description="返回模式。")
|
||||||
|
items: list[HomeMaterialPublicFlatItemOut] = Field(default_factory=list, description="平铺素材列表。")
|
||||||
|
total: int = Field(0, description="总数。")
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialUploadResultOut(BaseModel):
|
||||||
|
id: str = Field(..., description="素材ID。")
|
||||||
|
category_id: str = Field(..., description="行业ID。")
|
||||||
|
title: str | None = Field(None, description="素材标题。")
|
||||||
|
media_type: HomeMaterialMediaType = Field(..., description="素材类型。")
|
||||||
|
status: HomeMaterialAssetStatus = Field(..., description="处理状态。")
|
||||||
|
original_url: str | None = Field(None, description="原始素材URL。")
|
||||||
|
watermarked_url: str | None = Field(None, description="水印素材URL。")
|
||||||
|
cover_url: str | None = Field(None, description="视频封面URL。")
|
||||||
|
watermark_config: HomeMaterialWatermarkConfig | dict[str, Any] | None = Field(None, description="水印配置快照。")
|
||||||
|
message: str = Field("", description="提示消息。")
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from app.services.home_material.service import HomeMaterialService, home_material_service
|
||||||
|
|
||||||
|
__all__ = ["HomeMaterialService", "home_material_service"]
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Any, Iterable
|
||||||
|
|
||||||
|
from sqlalchemy import case, func, or_, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.sql import Select
|
||||||
|
|
||||||
|
from app.enums.home_material import (
|
||||||
|
HomeMaterialAssetStatus,
|
||||||
|
HomeMaterialMediaType,
|
||||||
|
HomeMaterialPublicResponseMode,
|
||||||
|
)
|
||||||
|
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
|
||||||
|
from app.schemas.home_material import (
|
||||||
|
HomeMaterialAssetOut,
|
||||||
|
HomeMaterialCategoryOut,
|
||||||
|
HomeMaterialPublicAssetOut,
|
||||||
|
HomeMaterialPublicCategoryGroupOut,
|
||||||
|
HomeMaterialPublicCategoryOut,
|
||||||
|
HomeMaterialPublicFlatItemOut,
|
||||||
|
HomeMaterialWatermarkOut,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _unique(values: Iterable[str | None]) -> list[str]:
|
||||||
|
return list({v for v in values if v})
|
||||||
|
|
||||||
|
|
||||||
|
def _load_json(value: str | None) -> dict[str, Any] | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = json.loads(value)
|
||||||
|
return data if isinstance(data, dict) else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialQueryService:
|
||||||
|
"""首页素材高性能查询组装层:列表查询 → ID 去重 → 批量查询 → map 组装。"""
|
||||||
|
|
||||||
|
async def batch_categories_map(self, db: AsyncSession, category_ids: Iterable[str | None]) -> dict[str, HomeMaterialCategory]:
|
||||||
|
ids = _unique(category_ids)
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
result = await db.execute(
|
||||||
|
select(HomeMaterialCategory).where(HomeMaterialCategory.id.in_(ids), HomeMaterialCategory.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
return {row.id: row for row in result.scalars().all()}
|
||||||
|
|
||||||
|
async def batch_watermarks_map(self, db: AsyncSession, watermark_ids: Iterable[str | None]) -> dict[str, HomeMaterialWatermark]:
|
||||||
|
ids = _unique(watermark_ids)
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
result = await db.execute(
|
||||||
|
select(HomeMaterialWatermark).where(HomeMaterialWatermark.id.in_(ids), HomeMaterialWatermark.deleted_at.is_(None))
|
||||||
|
)
|
||||||
|
return {row.id: row for row in result.scalars().all()}
|
||||||
|
|
||||||
|
async def asset_counts_map(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
category_ids: Iterable[str] | None = None,
|
||||||
|
*,
|
||||||
|
media_type: HomeMaterialMediaType | str | None = None,
|
||||||
|
public_only: bool = False,
|
||||||
|
) -> dict[str, dict[str, int]]:
|
||||||
|
stmt = select(
|
||||||
|
HomeMaterialAsset.category_id,
|
||||||
|
func.count(HomeMaterialAsset.id).label("asset_count"),
|
||||||
|
func.coalesce(func.sum(case((HomeMaterialAsset.media_type == HomeMaterialMediaType.IMAGE.value, 1), else_=0)), 0).label("image_count"),
|
||||||
|
func.coalesce(func.sum(case((HomeMaterialAsset.media_type == HomeMaterialMediaType.VIDEO.value, 1), else_=0)), 0).label("video_count"),
|
||||||
|
).where(HomeMaterialAsset.deleted_at.is_(None))
|
||||||
|
ids = _unique(category_ids or [])
|
||||||
|
if ids:
|
||||||
|
stmt = stmt.where(HomeMaterialAsset.category_id.in_(ids))
|
||||||
|
if media_type:
|
||||||
|
stmt = stmt.where(HomeMaterialAsset.media_type == HomeMaterialMediaType(media_type).value)
|
||||||
|
if public_only:
|
||||||
|
stmt = stmt.where(
|
||||||
|
HomeMaterialAsset.is_active.is_(True),
|
||||||
|
HomeMaterialAsset.status == HomeMaterialAssetStatus.SUCCESS.value,
|
||||||
|
HomeMaterialAsset.watermarked_url.is_not(None),
|
||||||
|
)
|
||||||
|
stmt = stmt.group_by(HomeMaterialAsset.category_id)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
out: dict[str, dict[str, int]] = {}
|
||||||
|
for row in result.all():
|
||||||
|
out[row.category_id] = {
|
||||||
|
"asset_count": int(row.asset_count or 0),
|
||||||
|
"image_count": int(row.image_count or 0),
|
||||||
|
"video_count": int(row.video_count or 0),
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
def category_to_out(self, category: HomeMaterialCategory, counts: dict[str, int] | None = None) -> HomeMaterialCategoryOut:
|
||||||
|
c = counts or {}
|
||||||
|
return HomeMaterialCategoryOut(
|
||||||
|
id=category.id,
|
||||||
|
name=category.name,
|
||||||
|
key=category.key,
|
||||||
|
description=category.description,
|
||||||
|
icon=category.icon,
|
||||||
|
is_active=category.is_active,
|
||||||
|
sort_order=category.sort_order,
|
||||||
|
asset_count=int(c.get("asset_count", 0)),
|
||||||
|
image_count=int(c.get("image_count", 0)),
|
||||||
|
video_count=int(c.get("video_count", 0)),
|
||||||
|
created_at=category.created_at,
|
||||||
|
updated_at=category.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
def watermark_to_out(self, watermark: HomeMaterialWatermark) -> HomeMaterialWatermarkOut:
|
||||||
|
return HomeMaterialWatermarkOut(
|
||||||
|
id=watermark.id,
|
||||||
|
name=watermark.name,
|
||||||
|
file_url=watermark.file_url,
|
||||||
|
file_name=watermark.file_name,
|
||||||
|
file_size_bytes=watermark.file_size_bytes,
|
||||||
|
width=watermark.width,
|
||||||
|
height=watermark.height,
|
||||||
|
is_default=watermark.is_default,
|
||||||
|
is_active=watermark.is_active,
|
||||||
|
created_at=watermark.created_at,
|
||||||
|
updated_at=watermark.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
def asset_to_out(
|
||||||
|
self,
|
||||||
|
asset: HomeMaterialAsset,
|
||||||
|
*,
|
||||||
|
category_map: dict[str, HomeMaterialCategory] | None = None,
|
||||||
|
watermark_map: dict[str, HomeMaterialWatermark] | None = None,
|
||||||
|
include_original: bool = True,
|
||||||
|
) -> HomeMaterialAssetOut:
|
||||||
|
category = (category_map or {}).get(asset.category_id)
|
||||||
|
watermark = (watermark_map or {}).get(asset.watermark_id or "")
|
||||||
|
return HomeMaterialAssetOut(
|
||||||
|
id=asset.id,
|
||||||
|
category_id=asset.category_id,
|
||||||
|
category_name=category.name if category else None,
|
||||||
|
category_key=category.key if category else None,
|
||||||
|
title=asset.title,
|
||||||
|
media_type=HomeMaterialMediaType(asset.media_type),
|
||||||
|
status=HomeMaterialAssetStatus(asset.status),
|
||||||
|
original_url=asset.original_url if include_original else None,
|
||||||
|
watermarked_url=asset.watermarked_url,
|
||||||
|
cover_url=asset.cover_url,
|
||||||
|
watermark_id=asset.watermark_id,
|
||||||
|
watermark_name=watermark.name if watermark else None,
|
||||||
|
watermark_config=_load_json(asset.watermark_config_json),
|
||||||
|
width=asset.width,
|
||||||
|
height=asset.height,
|
||||||
|
duration_seconds=asset.duration_seconds,
|
||||||
|
file_size_bytes=asset.file_size_bytes,
|
||||||
|
watermarked_file_size_bytes=asset.watermarked_file_size_bytes,
|
||||||
|
is_active=asset.is_active,
|
||||||
|
sort_order=asset.sort_order,
|
||||||
|
error_message=asset.error_message,
|
||||||
|
processed_at=asset.processed_at,
|
||||||
|
created_at=asset.created_at,
|
||||||
|
updated_at=asset.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_admin_assets(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
page: int,
|
||||||
|
page_size: int,
|
||||||
|
category_id: str | None = None,
|
||||||
|
media_type: HomeMaterialMediaType | str | None = None,
|
||||||
|
status: HomeMaterialAssetStatus | str | None = None,
|
||||||
|
is_active: bool | None = None,
|
||||||
|
keyword: str | None = None,
|
||||||
|
include_original: bool = True,
|
||||||
|
) -> tuple[list[HomeMaterialAssetOut], int]:
|
||||||
|
stmt = select(HomeMaterialAsset).where(HomeMaterialAsset.deleted_at.is_(None))
|
||||||
|
count_stmt = select(func.count(HomeMaterialAsset.id)).where(HomeMaterialAsset.deleted_at.is_(None))
|
||||||
|
conditions = []
|
||||||
|
if category_id:
|
||||||
|
conditions.append(HomeMaterialAsset.category_id == category_id)
|
||||||
|
if media_type:
|
||||||
|
conditions.append(HomeMaterialAsset.media_type == HomeMaterialMediaType(media_type).value)
|
||||||
|
if status:
|
||||||
|
conditions.append(HomeMaterialAsset.status == HomeMaterialAssetStatus(status).value)
|
||||||
|
if is_active is not None:
|
||||||
|
conditions.append(HomeMaterialAsset.is_active.is_(is_active))
|
||||||
|
if keyword:
|
||||||
|
conditions.append(HomeMaterialAsset.title.ilike(f"%{keyword}%"))
|
||||||
|
for condition in conditions:
|
||||||
|
stmt = stmt.where(condition)
|
||||||
|
count_stmt = count_stmt.where(condition)
|
||||||
|
total = int((await db.execute(count_stmt)).scalar_one() or 0)
|
||||||
|
result = await db.execute(
|
||||||
|
stmt.order_by(HomeMaterialAsset.sort_order.asc(), HomeMaterialAsset.created_at.desc())
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
assets = result.scalars().all()
|
||||||
|
category_map = await self.batch_categories_map(db, [a.category_id for a in assets])
|
||||||
|
watermark_map = await self.batch_watermarks_map(db, [a.watermark_id for a in assets])
|
||||||
|
return [self.asset_to_out(a, category_map=category_map, watermark_map=watermark_map, include_original=include_original) for a in assets], total
|
||||||
|
|
||||||
|
async def resolve_category_ids(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
category_id: str | None = None,
|
||||||
|
category_key: str | None = None,
|
||||||
|
category_ids: str | None = None,
|
||||||
|
category_keys: str | None = None,
|
||||||
|
active_only: bool = True,
|
||||||
|
) -> list[str] | None:
|
||||||
|
"""解析前台行业筛选参数,优先级:category_id > category_key > category_ids > category_keys。None 表示不限制。"""
|
||||||
|
if category_id:
|
||||||
|
return [category_id]
|
||||||
|
stmt = select(HomeMaterialCategory.id).where(HomeMaterialCategory.deleted_at.is_(None))
|
||||||
|
if active_only:
|
||||||
|
stmt = stmt.where(HomeMaterialCategory.is_active.is_(True))
|
||||||
|
if category_key:
|
||||||
|
result = await db.execute(stmt.where(HomeMaterialCategory.key == category_key).limit(1))
|
||||||
|
cid = result.scalar_one_or_none()
|
||||||
|
return [cid] if cid else []
|
||||||
|
if category_ids:
|
||||||
|
return [v.strip() for v in category_ids.split(",") if v.strip()]
|
||||||
|
if category_keys:
|
||||||
|
keys = [v.strip() for v in category_keys.split(",") if v.strip()]
|
||||||
|
if not keys:
|
||||||
|
return []
|
||||||
|
result = await db.execute(stmt.where(HomeMaterialCategory.key.in_(keys)))
|
||||||
|
return list(result.scalars().all())
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def list_public_categories(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
with_asset_count: bool = True,
|
||||||
|
media_type: HomeMaterialMediaType | str | None = None,
|
||||||
|
only_has_assets: bool = True,
|
||||||
|
) -> list[HomeMaterialPublicCategoryOut]:
|
||||||
|
result = await db.execute(
|
||||||
|
select(HomeMaterialCategory)
|
||||||
|
.where(HomeMaterialCategory.deleted_at.is_(None), HomeMaterialCategory.is_active.is_(True))
|
||||||
|
.order_by(HomeMaterialCategory.sort_order.asc(), HomeMaterialCategory.created_at.desc())
|
||||||
|
)
|
||||||
|
categories = result.scalars().all()
|
||||||
|
counts = await self.asset_counts_map(db, [c.id for c in categories], media_type=media_type, public_only=True) if with_asset_count or only_has_assets else {}
|
||||||
|
items: list[HomeMaterialPublicCategoryOut] = []
|
||||||
|
for category in categories:
|
||||||
|
c = counts.get(category.id, {})
|
||||||
|
if only_has_assets and int(c.get("asset_count", 0)) <= 0:
|
||||||
|
continue
|
||||||
|
items.append(
|
||||||
|
HomeMaterialPublicCategoryOut(
|
||||||
|
id=category.id,
|
||||||
|
name=category.name,
|
||||||
|
key=category.key,
|
||||||
|
icon=category.icon,
|
||||||
|
description=category.description,
|
||||||
|
sort_order=category.sort_order,
|
||||||
|
asset_count=int(c.get("asset_count", 0)),
|
||||||
|
image_count=int(c.get("image_count", 0)),
|
||||||
|
video_count=int(c.get("video_count", 0)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
async def list_public_grouped(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
category_ids: list[str] | None,
|
||||||
|
media_type: HomeMaterialMediaType | str | None,
|
||||||
|
limit_per_category: int,
|
||||||
|
include_empty_categories: bool,
|
||||||
|
) -> list[HomeMaterialPublicCategoryGroupOut]:
|
||||||
|
cat_stmt = select(HomeMaterialCategory).where(HomeMaterialCategory.deleted_at.is_(None), HomeMaterialCategory.is_active.is_(True))
|
||||||
|
if category_ids is not None:
|
||||||
|
if not category_ids:
|
||||||
|
return []
|
||||||
|
cat_stmt = cat_stmt.where(HomeMaterialCategory.id.in_(category_ids))
|
||||||
|
cat_result = await db.execute(cat_stmt.order_by(HomeMaterialCategory.sort_order.asc(), HomeMaterialCategory.created_at.desc()))
|
||||||
|
categories = cat_result.scalars().all()
|
||||||
|
ids = [c.id for c in categories]
|
||||||
|
if not ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
asset_base = select(
|
||||||
|
HomeMaterialAsset.id.label("id"),
|
||||||
|
func.row_number()
|
||||||
|
.over(
|
||||||
|
partition_by=HomeMaterialAsset.category_id,
|
||||||
|
order_by=(HomeMaterialAsset.sort_order.asc(), HomeMaterialAsset.created_at.desc()),
|
||||||
|
)
|
||||||
|
.label("rn"),
|
||||||
|
).where(
|
||||||
|
HomeMaterialAsset.deleted_at.is_(None),
|
||||||
|
HomeMaterialAsset.is_active.is_(True),
|
||||||
|
HomeMaterialAsset.status == HomeMaterialAssetStatus.SUCCESS.value,
|
||||||
|
HomeMaterialAsset.watermarked_url.is_not(None),
|
||||||
|
HomeMaterialAsset.category_id.in_(ids),
|
||||||
|
)
|
||||||
|
if media_type:
|
||||||
|
asset_base = asset_base.where(HomeMaterialAsset.media_type == HomeMaterialMediaType(media_type).value)
|
||||||
|
ranked = asset_base.subquery()
|
||||||
|
asset_result = await db.execute(
|
||||||
|
select(HomeMaterialAsset)
|
||||||
|
.where(HomeMaterialAsset.id.in_(select(ranked.c.id).where(ranked.c.rn <= limit_per_category)))
|
||||||
|
.order_by(HomeMaterialAsset.category_id.asc(), HomeMaterialAsset.sort_order.asc(), HomeMaterialAsset.created_at.desc())
|
||||||
|
)
|
||||||
|
grouped: dict[str, list[HomeMaterialAsset]] = defaultdict(list)
|
||||||
|
for asset in asset_result.scalars().all():
|
||||||
|
grouped[asset.category_id].append(asset)
|
||||||
|
|
||||||
|
out: list[HomeMaterialPublicCategoryGroupOut] = []
|
||||||
|
for category in categories:
|
||||||
|
assets = grouped.get(category.id, [])
|
||||||
|
if not include_empty_categories and not assets:
|
||||||
|
continue
|
||||||
|
out.append(
|
||||||
|
HomeMaterialPublicCategoryGroupOut(
|
||||||
|
id=category.id,
|
||||||
|
name=category.name,
|
||||||
|
key=category.key,
|
||||||
|
icon=category.icon,
|
||||||
|
description=category.description,
|
||||||
|
sort_order=category.sort_order,
|
||||||
|
assets=[
|
||||||
|
HomeMaterialPublicAssetOut(
|
||||||
|
id=a.id,
|
||||||
|
title=a.title,
|
||||||
|
media_type=HomeMaterialMediaType(a.media_type),
|
||||||
|
url=a.watermarked_url or "",
|
||||||
|
cover_url=a.cover_url,
|
||||||
|
width=a.width,
|
||||||
|
height=a.height,
|
||||||
|
duration_seconds=a.duration_seconds,
|
||||||
|
sort_order=a.sort_order,
|
||||||
|
)
|
||||||
|
for a in assets
|
||||||
|
],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
async def list_public_flat(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
category_ids: list[str] | None,
|
||||||
|
media_type: HomeMaterialMediaType | str | None,
|
||||||
|
page: int,
|
||||||
|
page_size: int,
|
||||||
|
) -> tuple[list[HomeMaterialPublicFlatItemOut], int]:
|
||||||
|
stmt = select(HomeMaterialAsset).where(
|
||||||
|
HomeMaterialAsset.deleted_at.is_(None),
|
||||||
|
HomeMaterialAsset.is_active.is_(True),
|
||||||
|
HomeMaterialAsset.status == HomeMaterialAssetStatus.SUCCESS.value,
|
||||||
|
HomeMaterialAsset.watermarked_url.is_not(None),
|
||||||
|
)
|
||||||
|
count_stmt = select(func.count(HomeMaterialAsset.id)).where(
|
||||||
|
HomeMaterialAsset.deleted_at.is_(None),
|
||||||
|
HomeMaterialAsset.is_active.is_(True),
|
||||||
|
HomeMaterialAsset.status == HomeMaterialAssetStatus.SUCCESS.value,
|
||||||
|
HomeMaterialAsset.watermarked_url.is_not(None),
|
||||||
|
)
|
||||||
|
if category_ids is not None:
|
||||||
|
if not category_ids:
|
||||||
|
return [], 0
|
||||||
|
stmt = stmt.where(HomeMaterialAsset.category_id.in_(category_ids))
|
||||||
|
count_stmt = count_stmt.where(HomeMaterialAsset.category_id.in_(category_ids))
|
||||||
|
if media_type:
|
||||||
|
stmt = stmt.where(HomeMaterialAsset.media_type == HomeMaterialMediaType(media_type).value)
|
||||||
|
count_stmt = count_stmt.where(HomeMaterialAsset.media_type == HomeMaterialMediaType(media_type).value)
|
||||||
|
total = int((await db.execute(count_stmt)).scalar_one() or 0)
|
||||||
|
result = await db.execute(
|
||||||
|
stmt.order_by(HomeMaterialAsset.sort_order.asc(), HomeMaterialAsset.created_at.desc())
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
assets = result.scalars().all()
|
||||||
|
category_map = await self.batch_categories_map(db, [a.category_id for a in assets])
|
||||||
|
items: list[HomeMaterialPublicFlatItemOut] = []
|
||||||
|
for asset in assets:
|
||||||
|
category = category_map.get(asset.category_id)
|
||||||
|
if not category:
|
||||||
|
continue
|
||||||
|
items.append(
|
||||||
|
HomeMaterialPublicFlatItemOut(
|
||||||
|
id=asset.id,
|
||||||
|
category_id=category.id,
|
||||||
|
category_name=category.name,
|
||||||
|
category_key=category.key,
|
||||||
|
title=asset.title,
|
||||||
|
media_type=HomeMaterialMediaType(asset.media_type),
|
||||||
|
url=asset.watermarked_url or "",
|
||||||
|
cover_url=asset.cover_url,
|
||||||
|
width=asset.width,
|
||||||
|
height=asset.height,
|
||||||
|
duration_seconds=asset.duration_seconds,
|
||||||
|
sort_order=asset.sort_order,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return items, total
|
||||||
|
|
||||||
|
|
||||||
|
query_service = HomeMaterialQueryService()
|
||||||
@@ -0,0 +1,693 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException, UploadFile, status
|
||||||
|
from sqlalchemy import func, or_, select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.enums.home_material import (
|
||||||
|
HOME_MATERIAL_DEFAULT_CONFIG,
|
||||||
|
HomeMaterialAssetStatus,
|
||||||
|
HomeMaterialConfigKeyEnum,
|
||||||
|
HomeMaterialMediaType,
|
||||||
|
HomeMaterialPublicResponseMode,
|
||||||
|
HomeMaterialWatermarkPosition,
|
||||||
|
HomeMaterialWatermarkSizeMode,
|
||||||
|
HomeMaterialWatermarkType,
|
||||||
|
)
|
||||||
|
from app.models.base import async_session
|
||||||
|
from app.models.home_material import HomeMaterialAsset, HomeMaterialCategory, HomeMaterialWatermark
|
||||||
|
from app.models.system_config import SystemConfig
|
||||||
|
from app.schemas.home_material import (
|
||||||
|
HomeMaterialAssetListOut,
|
||||||
|
HomeMaterialAssetOut,
|
||||||
|
HomeMaterialAssetStatusOut,
|
||||||
|
HomeMaterialAssetUpdate,
|
||||||
|
HomeMaterialCategoryCreate,
|
||||||
|
HomeMaterialCategoryListOut,
|
||||||
|
HomeMaterialCategoryOut,
|
||||||
|
HomeMaterialCategoryUpdate,
|
||||||
|
HomeMaterialConfigOut,
|
||||||
|
HomeMaterialConfigUpdate,
|
||||||
|
HomeMaterialPublicCategoryListOut,
|
||||||
|
HomeMaterialPublicFlatOut,
|
||||||
|
HomeMaterialPublicGroupedOut,
|
||||||
|
HomeMaterialRegenerateWatermarkRequest,
|
||||||
|
HomeMaterialTextWatermarkPreviewRequest,
|
||||||
|
HomeMaterialTextWatermarkPreviewResponse,
|
||||||
|
HomeMaterialUploadResultOut,
|
||||||
|
HomeMaterialWatermarkConfig,
|
||||||
|
HomeMaterialWatermarkListOut,
|
||||||
|
HomeMaterialWatermarkOut,
|
||||||
|
HomeMaterialWatermarkUpdate,
|
||||||
|
)
|
||||||
|
from app.services.home_material.query import query_service
|
||||||
|
from app.services.home_material.storage import storage_service
|
||||||
|
from app.services.home_material.watermark_processor import watermark_processor
|
||||||
|
from app.utils.id_gen import generate_id
|
||||||
|
|
||||||
|
|
||||||
|
def _json_dumps(data: Any) -> str:
|
||||||
|
return json.dumps(data, ensure_ascii=False, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
def _json_loads(value: str | None) -> dict[str, Any] | None:
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
data = json.loads(value)
|
||||||
|
return data if isinstance(data, dict) else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_title(value: str | None) -> str | None:
|
||||||
|
value = (value or "").strip()
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_watermark_config_dict(value: dict[str, Any] | None, fallback_watermark_id: str | None = None) -> dict[str, Any]:
|
||||||
|
"""兼容旧水印配置。旧数据没有 watermark_type 时按 image 处理。"""
|
||||||
|
data = dict(value or {})
|
||||||
|
watermark_type = data.get("watermark_type") or HomeMaterialWatermarkType.IMAGE.value
|
||||||
|
data["watermark_type"] = watermark_type
|
||||||
|
if watermark_type == HomeMaterialWatermarkType.REPEATED_TEXT.value:
|
||||||
|
data["watermark_id"] = None
|
||||||
|
return HomeMaterialWatermarkConfig(**data).model_dump(mode="json")
|
||||||
|
if fallback_watermark_id and not data.get("watermark_id"):
|
||||||
|
data["watermark_id"] = fallback_watermark_id
|
||||||
|
return HomeMaterialWatermarkConfig(**data).model_dump(mode="json")
|
||||||
|
|
||||||
|
|
||||||
|
def _config_to_out(data: dict[str, Any] | None) -> HomeMaterialConfigOut:
|
||||||
|
raw = {**HOME_MATERIAL_DEFAULT_CONFIG, **(data or {})}
|
||||||
|
return HomeMaterialConfigOut(
|
||||||
|
enabled=bool(raw.get("enabled", False)),
|
||||||
|
title=str(raw.get("title") or HOME_MATERIAL_DEFAULT_CONFIG["title"]),
|
||||||
|
subtitle=str(raw.get("subtitle") or HOME_MATERIAL_DEFAULT_CONFIG["subtitle"]),
|
||||||
|
show_original_in_admin=bool(raw.get("show_original_in_admin", True)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _asset_snapshot(asset: HomeMaterialAsset | None) -> dict[str, Any] | None:
|
||||||
|
if not asset:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": asset.id,
|
||||||
|
"category_id": asset.category_id,
|
||||||
|
"title": asset.title,
|
||||||
|
"media_type": asset.media_type,
|
||||||
|
"status": asset.status,
|
||||||
|
"original_url": asset.original_url,
|
||||||
|
"watermarked_url": asset.watermarked_url,
|
||||||
|
"cover_url": asset.cover_url,
|
||||||
|
"watermark_id": asset.watermark_id,
|
||||||
|
"watermark_config": _json_loads(asset.watermark_config_json),
|
||||||
|
"is_active": asset.is_active,
|
||||||
|
"sort_order": asset.sort_order,
|
||||||
|
"deleted_at": asset.deleted_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _category_snapshot(category: HomeMaterialCategory | None) -> dict[str, Any] | None:
|
||||||
|
if not category:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": category.id,
|
||||||
|
"name": category.name,
|
||||||
|
"key": category.key,
|
||||||
|
"description": category.description,
|
||||||
|
"icon": category.icon,
|
||||||
|
"is_active": category.is_active,
|
||||||
|
"sort_order": category.sort_order,
|
||||||
|
"deleted_at": category.deleted_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _watermark_snapshot(watermark: HomeMaterialWatermark | None) -> dict[str, Any] | None:
|
||||||
|
if not watermark:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": watermark.id,
|
||||||
|
"name": watermark.name,
|
||||||
|
"file_url": watermark.file_url,
|
||||||
|
"is_default": watermark.is_default,
|
||||||
|
"is_active": watermark.is_active,
|
||||||
|
"deleted_at": watermark.deleted_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _flush_refresh(db: AsyncSession, obj: Any) -> None:
|
||||||
|
"""
|
||||||
|
写入后立即返回 ORM 对象前必须显式刷新。
|
||||||
|
|
||||||
|
本模块模型继承 TimestampMixin,updated_at 使用 onupdate=func.now()。
|
||||||
|
AsyncSession 下 flush 后直接访问 updated_at/created_at 可能触发隐式 IO,
|
||||||
|
导致 MissingGreenlet。统一 flush + refresh,避免响应组装阶段懒加载。
|
||||||
|
"""
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(obj)
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialService:
|
||||||
|
"""首页素材装修主业务服务。API 层只调用本服务,内部再委托 query/storage/processor。"""
|
||||||
|
|
||||||
|
async def get_config(self, db: AsyncSession) -> HomeMaterialConfigOut:
|
||||||
|
result = await db.execute(
|
||||||
|
select(SystemConfig.value)
|
||||||
|
.where(SystemConfig.key == HomeMaterialConfigKeyEnum.SHOWCASE_CONFIG.value)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
value = result.scalar_one_or_none()
|
||||||
|
if not value:
|
||||||
|
return _config_to_out(None)
|
||||||
|
return _config_to_out(_json_loads(value))
|
||||||
|
|
||||||
|
async def save_config(self, db: AsyncSession, req: HomeMaterialConfigUpdate) -> tuple[HomeMaterialConfigOut, dict[str, Any], dict[str, Any]]:
|
||||||
|
before = await self.get_config(db)
|
||||||
|
after = HomeMaterialConfigOut(**req.model_dump())
|
||||||
|
result = await db.execute(
|
||||||
|
select(SystemConfig)
|
||||||
|
.where(SystemConfig.key == HomeMaterialConfigKeyEnum.SHOWCASE_CONFIG.value)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
config = result.scalar_one_or_none()
|
||||||
|
payload = after.model_dump()
|
||||||
|
if config:
|
||||||
|
config.value = _json_dumps(payload)
|
||||||
|
config.description = "首页素材行业装修展示配置"
|
||||||
|
db.add(config)
|
||||||
|
else:
|
||||||
|
db.add(
|
||||||
|
SystemConfig(
|
||||||
|
id=generate_id(),
|
||||||
|
key=HomeMaterialConfigKeyEnum.SHOWCASE_CONFIG.value,
|
||||||
|
value=_json_dumps(payload),
|
||||||
|
description="首页素材行业装修展示配置",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return after, before.model_dump(), after.model_dump()
|
||||||
|
|
||||||
|
async def list_categories(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
page: int,
|
||||||
|
page_size: int,
|
||||||
|
keyword: str | None = None,
|
||||||
|
is_active: bool | None = None,
|
||||||
|
) -> HomeMaterialCategoryListOut:
|
||||||
|
stmt = select(HomeMaterialCategory).where(HomeMaterialCategory.deleted_at.is_(None))
|
||||||
|
count_stmt = select(func.count(HomeMaterialCategory.id)).where(HomeMaterialCategory.deleted_at.is_(None))
|
||||||
|
conditions = []
|
||||||
|
if keyword:
|
||||||
|
like = f"%{keyword}%"
|
||||||
|
conditions.append(or_(HomeMaterialCategory.name.ilike(like), HomeMaterialCategory.key.ilike(like)))
|
||||||
|
if is_active is not None:
|
||||||
|
conditions.append(HomeMaterialCategory.is_active.is_(is_active))
|
||||||
|
for cond in conditions:
|
||||||
|
stmt = stmt.where(cond)
|
||||||
|
count_stmt = count_stmt.where(cond)
|
||||||
|
total = int((await db.execute(count_stmt)).scalar_one() or 0)
|
||||||
|
result = await db.execute(
|
||||||
|
stmt.order_by(HomeMaterialCategory.sort_order.asc(), HomeMaterialCategory.created_at.desc())
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
categories = result.scalars().all()
|
||||||
|
counts = await query_service.asset_counts_map(db, [c.id for c in categories])
|
||||||
|
return HomeMaterialCategoryListOut(
|
||||||
|
items=[query_service.category_to_out(c, counts.get(c.id)) for c in categories],
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def create_category(self, db: AsyncSession, req: HomeMaterialCategoryCreate, admin_id: str | None) -> tuple[HomeMaterialCategoryOut, dict[str, Any]]:
|
||||||
|
await self._ensure_category_key_available(db, req.key)
|
||||||
|
category = HomeMaterialCategory(
|
||||||
|
id=generate_id(),
|
||||||
|
name=req.name,
|
||||||
|
key=req.key,
|
||||||
|
description=req.description,
|
||||||
|
icon=req.icon,
|
||||||
|
is_active=req.is_active,
|
||||||
|
sort_order=req.sort_order,
|
||||||
|
created_by=admin_id,
|
||||||
|
updated_by=admin_id,
|
||||||
|
)
|
||||||
|
db.add(category)
|
||||||
|
await _flush_refresh(db, category)
|
||||||
|
return query_service.category_to_out(category), _category_snapshot(category) or {}
|
||||||
|
|
||||||
|
async def update_category(self, db: AsyncSession, category_id: str, req: HomeMaterialCategoryUpdate, admin_id: str | None) -> tuple[HomeMaterialCategoryOut, dict[str, Any], dict[str, Any]]:
|
||||||
|
category = await self._get_category(db, category_id)
|
||||||
|
before = _category_snapshot(category) or {}
|
||||||
|
if req.key != category.key:
|
||||||
|
await self._ensure_category_key_available(db, req.key, exclude_id=category_id)
|
||||||
|
category.name = req.name
|
||||||
|
category.key = req.key
|
||||||
|
category.description = req.description
|
||||||
|
category.icon = req.icon
|
||||||
|
category.is_active = req.is_active
|
||||||
|
category.sort_order = req.sort_order
|
||||||
|
category.updated_by = admin_id
|
||||||
|
db.add(category)
|
||||||
|
await _flush_refresh(db, category)
|
||||||
|
after = _category_snapshot(category) or {}
|
||||||
|
return query_service.category_to_out(category), before, after
|
||||||
|
|
||||||
|
async def delete_category(self, db: AsyncSession, category_id: str, admin_id: str | None) -> tuple[HomeMaterialCategory, dict[str, Any]]:
|
||||||
|
category = await self._get_category(db, category_id)
|
||||||
|
count = int((await db.execute(select(func.count(HomeMaterialAsset.id)).where(HomeMaterialAsset.category_id == category_id, HomeMaterialAsset.deleted_at.is_(None)))).scalar_one() or 0)
|
||||||
|
if count > 0:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该行业下仍有素材,请先删除素材或禁用行业")
|
||||||
|
before = _category_snapshot(category) or {}
|
||||||
|
category.deleted_at = datetime.now(timezone.utc)
|
||||||
|
category.updated_by = admin_id
|
||||||
|
db.add(category)
|
||||||
|
return category, before
|
||||||
|
|
||||||
|
async def _ensure_category_key_available(self, db: AsyncSession, key: str, exclude_id: str | None = None) -> None:
|
||||||
|
stmt = select(HomeMaterialCategory.id).where(HomeMaterialCategory.key == key, HomeMaterialCategory.deleted_at.is_(None))
|
||||||
|
if exclude_id:
|
||||||
|
stmt = stmt.where(HomeMaterialCategory.id != exclude_id)
|
||||||
|
exists = (await db.execute(stmt.limit(1))).scalar_one_or_none()
|
||||||
|
if exists:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="行业 key 已存在")
|
||||||
|
|
||||||
|
async def _get_category(self, db: AsyncSession, category_id: str, *, active_only: bool = False) -> HomeMaterialCategory:
|
||||||
|
stmt = select(HomeMaterialCategory).where(HomeMaterialCategory.id == category_id, HomeMaterialCategory.deleted_at.is_(None))
|
||||||
|
if active_only:
|
||||||
|
stmt = stmt.where(HomeMaterialCategory.is_active.is_(True))
|
||||||
|
result = await db.execute(stmt.limit(1))
|
||||||
|
category = result.scalar_one_or_none()
|
||||||
|
if not category:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="行业不存在或已删除")
|
||||||
|
return category
|
||||||
|
|
||||||
|
async def upload_watermark(self, db: AsyncSession, *, file: UploadFile, name: str | None, is_default: bool, admin_id: str | None) -> tuple[HomeMaterialWatermarkOut, dict[str, Any]]:
|
||||||
|
stored = await storage_service.save_watermark_file(file)
|
||||||
|
info = await watermark_processor.probe(stored.storage_path)
|
||||||
|
if is_default:
|
||||||
|
await db.execute(update(HomeMaterialWatermark).where(HomeMaterialWatermark.deleted_at.is_(None)).values(is_default=False))
|
||||||
|
watermark = HomeMaterialWatermark(
|
||||||
|
id=generate_id(),
|
||||||
|
name=name or stored.file_name,
|
||||||
|
file_url=stored.file_url,
|
||||||
|
storage_path=stored.storage_path,
|
||||||
|
file_name=stored.file_name,
|
||||||
|
file_size_bytes=stored.file_size_bytes,
|
||||||
|
width=info.width,
|
||||||
|
height=info.height,
|
||||||
|
is_default=is_default,
|
||||||
|
is_active=True,
|
||||||
|
created_by=admin_id,
|
||||||
|
updated_by=admin_id,
|
||||||
|
)
|
||||||
|
db.add(watermark)
|
||||||
|
await _flush_refresh(db, watermark)
|
||||||
|
return query_service.watermark_to_out(watermark), _watermark_snapshot(watermark) or {}
|
||||||
|
|
||||||
|
async def list_watermarks(self, db: AsyncSession, *, page: int, page_size: int, is_active: bool | None = None) -> HomeMaterialWatermarkListOut:
|
||||||
|
stmt = select(HomeMaterialWatermark).where(HomeMaterialWatermark.deleted_at.is_(None))
|
||||||
|
count_stmt = select(func.count(HomeMaterialWatermark.id)).where(HomeMaterialWatermark.deleted_at.is_(None))
|
||||||
|
if is_active is not None:
|
||||||
|
stmt = stmt.where(HomeMaterialWatermark.is_active.is_(is_active))
|
||||||
|
count_stmt = count_stmt.where(HomeMaterialWatermark.is_active.is_(is_active))
|
||||||
|
total = int((await db.execute(count_stmt)).scalar_one() or 0)
|
||||||
|
result = await db.execute(
|
||||||
|
stmt.order_by(HomeMaterialWatermark.is_default.desc(), HomeMaterialWatermark.created_at.desc())
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
return HomeMaterialWatermarkListOut(items=[query_service.watermark_to_out(w) for w in result.scalars().all()], total=total)
|
||||||
|
|
||||||
|
async def update_watermark(self, db: AsyncSession, watermark_id: str, req: HomeMaterialWatermarkUpdate, admin_id: str | None) -> tuple[HomeMaterialWatermarkOut, dict[str, Any], dict[str, Any]]:
|
||||||
|
watermark = await self._get_watermark(db, watermark_id)
|
||||||
|
before = _watermark_snapshot(watermark) or {}
|
||||||
|
if req.is_default:
|
||||||
|
await db.execute(update(HomeMaterialWatermark).where(HomeMaterialWatermark.deleted_at.is_(None), HomeMaterialWatermark.id != watermark_id).values(is_default=False))
|
||||||
|
watermark.name = req.name
|
||||||
|
watermark.is_default = req.is_default
|
||||||
|
watermark.is_active = req.is_active
|
||||||
|
watermark.updated_by = admin_id
|
||||||
|
db.add(watermark)
|
||||||
|
await _flush_refresh(db, watermark)
|
||||||
|
return query_service.watermark_to_out(watermark), before, _watermark_snapshot(watermark) or {}
|
||||||
|
|
||||||
|
async def delete_watermark(self, db: AsyncSession, watermark_id: str, admin_id: str | None) -> tuple[HomeMaterialWatermark, dict[str, Any]]:
|
||||||
|
watermark = await self._get_watermark(db, watermark_id)
|
||||||
|
before = _watermark_snapshot(watermark) or {}
|
||||||
|
watermark.deleted_at = datetime.now(timezone.utc)
|
||||||
|
watermark.is_active = False
|
||||||
|
watermark.is_default = False
|
||||||
|
watermark.updated_by = admin_id
|
||||||
|
db.add(watermark)
|
||||||
|
return watermark, before
|
||||||
|
|
||||||
|
async def _get_watermark(self, db: AsyncSession, watermark_id: str, *, active_only: bool = False) -> HomeMaterialWatermark:
|
||||||
|
stmt = select(HomeMaterialWatermark).where(HomeMaterialWatermark.id == watermark_id, HomeMaterialWatermark.deleted_at.is_(None))
|
||||||
|
if active_only:
|
||||||
|
stmt = stmt.where(HomeMaterialWatermark.is_active.is_(True))
|
||||||
|
result = await db.execute(stmt.limit(1))
|
||||||
|
watermark = result.scalar_one_or_none()
|
||||||
|
if not watermark:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="水印不存在或已删除")
|
||||||
|
return watermark
|
||||||
|
|
||||||
|
async def upload_asset(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
category_id: str,
|
||||||
|
file: UploadFile,
|
||||||
|
media_type: HomeMaterialMediaType,
|
||||||
|
title: str | None,
|
||||||
|
watermark_id: str | None,
|
||||||
|
watermark_file: UploadFile | None,
|
||||||
|
watermark_config: HomeMaterialWatermarkConfig,
|
||||||
|
is_active: bool,
|
||||||
|
sort_order: int,
|
||||||
|
admin_id: str | None,
|
||||||
|
) -> tuple[HomeMaterialUploadResultOut, dict[str, Any]]:
|
||||||
|
await self._get_category(db, category_id, active_only=False)
|
||||||
|
clean_title = _clean_title(title)
|
||||||
|
watermark_type = watermark_config.watermark_type
|
||||||
|
final_watermark_id: str | None = None
|
||||||
|
if watermark_type == HomeMaterialWatermarkType.IMAGE:
|
||||||
|
final_watermark_id = watermark_id
|
||||||
|
if watermark_file is not None:
|
||||||
|
watermark_out, _ = await self.upload_watermark(db, file=watermark_file, name=f"临时水印-{clean_title or '首页素材'}", is_default=False, admin_id=admin_id)
|
||||||
|
final_watermark_id = watermark_out.id
|
||||||
|
if not final_watermark_id:
|
||||||
|
default_wm = await db.execute(
|
||||||
|
select(HomeMaterialWatermark)
|
||||||
|
.where(HomeMaterialWatermark.deleted_at.is_(None), HomeMaterialWatermark.is_active.is_(True), HomeMaterialWatermark.is_default.is_(True))
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
wm = default_wm.scalar_one_or_none()
|
||||||
|
final_watermark_id = wm.id if wm else None
|
||||||
|
if not final_watermark_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请先选择或上传水印图片")
|
||||||
|
await self._get_watermark(db, final_watermark_id, active_only=True)
|
||||||
|
elif watermark_file is not None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="重复文字水印模式不允许上传图片水印文件")
|
||||||
|
|
||||||
|
stored = await storage_service.save_asset_file(file, media_type)
|
||||||
|
probe = await watermark_processor.probe(stored.storage_path)
|
||||||
|
if media_type == HomeMaterialMediaType.VIDEO:
|
||||||
|
max_duration = int(getattr(settings, "HOME_MATERIAL_MAX_VIDEO_DURATION_SECONDS", 300))
|
||||||
|
if probe.duration_seconds is not None and probe.duration_seconds > max_duration:
|
||||||
|
storage_service.safe_remove(stored.storage_path)
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"视频时长超限,最大 {max_duration} 秒")
|
||||||
|
|
||||||
|
cfg = watermark_config.model_copy(update={"watermark_id": final_watermark_id, "opacity": round(watermark_config.opacity_level / 10, 2)})
|
||||||
|
asset = HomeMaterialAsset(
|
||||||
|
id=generate_id(),
|
||||||
|
category_id=category_id,
|
||||||
|
title=clean_title,
|
||||||
|
media_type=media_type.value,
|
||||||
|
original_url=stored.file_url,
|
||||||
|
original_storage_path=stored.storage_path,
|
||||||
|
watermark_id=final_watermark_id,
|
||||||
|
watermark_config_json=_json_dumps(cfg.model_dump(mode="json")),
|
||||||
|
status=HomeMaterialAssetStatus.PROCESSING.value,
|
||||||
|
width=probe.width,
|
||||||
|
height=probe.height,
|
||||||
|
duration_seconds=probe.duration_seconds,
|
||||||
|
file_size_bytes=stored.file_size_bytes,
|
||||||
|
is_active=is_active,
|
||||||
|
sort_order=sort_order,
|
||||||
|
created_by=admin_id,
|
||||||
|
updated_by=admin_id,
|
||||||
|
)
|
||||||
|
db.add(asset)
|
||||||
|
await _flush_refresh(db, asset)
|
||||||
|
return self._upload_result(asset, message="素材已上传,水印处理中"), _asset_snapshot(asset) or {}
|
||||||
|
|
||||||
|
async def list_assets(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
page: int,
|
||||||
|
page_size: int,
|
||||||
|
category_id: str | None,
|
||||||
|
media_type: HomeMaterialMediaType | str | None,
|
||||||
|
status: HomeMaterialAssetStatus | str | None,
|
||||||
|
is_active: bool | None,
|
||||||
|
keyword: str | None,
|
||||||
|
) -> HomeMaterialAssetListOut:
|
||||||
|
config = await self.get_config(db)
|
||||||
|
items, total = await query_service.list_admin_assets(
|
||||||
|
db,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
category_id=category_id,
|
||||||
|
media_type=media_type,
|
||||||
|
status=status,
|
||||||
|
is_active=is_active,
|
||||||
|
keyword=keyword,
|
||||||
|
include_original=config.show_original_in_admin,
|
||||||
|
)
|
||||||
|
return HomeMaterialAssetListOut(items=items, total=total)
|
||||||
|
|
||||||
|
async def get_asset_detail(self, db: AsyncSession, asset_id: str) -> HomeMaterialAssetOut:
|
||||||
|
asset = await self._get_asset(db, asset_id)
|
||||||
|
category_map = await query_service.batch_categories_map(db, [asset.category_id])
|
||||||
|
watermark_map = await query_service.batch_watermarks_map(db, [asset.watermark_id])
|
||||||
|
config = await self.get_config(db)
|
||||||
|
return query_service.asset_to_out(asset, category_map=category_map, watermark_map=watermark_map, include_original=config.show_original_in_admin)
|
||||||
|
|
||||||
|
async def update_asset(self, db: AsyncSession, asset_id: str, req: HomeMaterialAssetUpdate, admin_id: str | None) -> tuple[HomeMaterialAssetOut, dict[str, Any], dict[str, Any]]:
|
||||||
|
asset = await self._get_asset(db, asset_id)
|
||||||
|
await self._get_category(db, req.category_id)
|
||||||
|
before = _asset_snapshot(asset) or {}
|
||||||
|
asset.category_id = req.category_id
|
||||||
|
asset.title = req.title
|
||||||
|
asset.is_active = req.is_active
|
||||||
|
asset.sort_order = req.sort_order
|
||||||
|
asset.updated_by = admin_id
|
||||||
|
db.add(asset)
|
||||||
|
await _flush_refresh(db, asset)
|
||||||
|
after = _asset_snapshot(asset) or {}
|
||||||
|
return await self.get_asset_detail(db, asset_id), before, after
|
||||||
|
|
||||||
|
async def prepare_regenerate(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
asset_id: str,
|
||||||
|
req: HomeMaterialRegenerateWatermarkRequest,
|
||||||
|
admin_id: str | None,
|
||||||
|
) -> tuple[HomeMaterialUploadResultOut, dict[str, Any], dict[str, Any]]:
|
||||||
|
asset = await self._get_asset(db, asset_id)
|
||||||
|
before = _asset_snapshot(asset) or {}
|
||||||
|
req_data = req.model_dump(exclude={"wait", "wait_timeout_seconds"})
|
||||||
|
if req.watermark_type == HomeMaterialWatermarkType.IMAGE:
|
||||||
|
final_watermark_id = req.watermark_id or asset.watermark_id
|
||||||
|
await self._get_watermark(db, final_watermark_id or "", active_only=True)
|
||||||
|
cfg = HomeMaterialWatermarkConfig(**req_data).model_copy(update={"watermark_id": final_watermark_id})
|
||||||
|
else:
|
||||||
|
cfg = HomeMaterialWatermarkConfig(**req_data).model_copy(update={"watermark_id": None})
|
||||||
|
asset.watermark_id = cfg.watermark_id
|
||||||
|
asset.watermark_config_json = _json_dumps(cfg.model_dump(mode="json"))
|
||||||
|
asset.status = HomeMaterialAssetStatus.PROCESSING.value
|
||||||
|
asset.error_message = None
|
||||||
|
asset.updated_by = admin_id
|
||||||
|
db.add(asset)
|
||||||
|
await _flush_refresh(db, asset)
|
||||||
|
return self._upload_result(asset, message="水印重新生成中"), before, _asset_snapshot(asset) or {}
|
||||||
|
|
||||||
|
async def delete_asset(self, db: AsyncSession, asset_id: str, admin_id: str | None) -> tuple[HomeMaterialAsset, dict[str, Any]]:
|
||||||
|
asset = await self._get_asset(db, asset_id)
|
||||||
|
before = _asset_snapshot(asset) or {}
|
||||||
|
asset.deleted_at = datetime.now(timezone.utc)
|
||||||
|
asset.updated_by = admin_id
|
||||||
|
db.add(asset)
|
||||||
|
return asset, before
|
||||||
|
|
||||||
|
async def get_asset_status(self, db: AsyncSession, asset_id: str) -> HomeMaterialAssetStatusOut:
|
||||||
|
asset = await self._get_asset(db, asset_id)
|
||||||
|
return HomeMaterialAssetStatusOut(
|
||||||
|
id=asset.id,
|
||||||
|
status=HomeMaterialAssetStatus(asset.status),
|
||||||
|
error_message=asset.error_message,
|
||||||
|
original_url=asset.original_url,
|
||||||
|
watermarked_url=asset.watermarked_url,
|
||||||
|
cover_url=asset.cover_url,
|
||||||
|
processed_at=asset.processed_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get_asset(self, db: AsyncSession, asset_id: str) -> HomeMaterialAsset:
|
||||||
|
result = await db.execute(
|
||||||
|
select(HomeMaterialAsset).where(HomeMaterialAsset.id == asset_id, HomeMaterialAsset.deleted_at.is_(None)).limit(1)
|
||||||
|
)
|
||||||
|
asset = result.scalar_one_or_none()
|
||||||
|
if not asset:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="素材不存在或已删除")
|
||||||
|
return asset
|
||||||
|
|
||||||
|
def _upload_result(self, asset: HomeMaterialAsset, *, message: str) -> HomeMaterialUploadResultOut:
|
||||||
|
return HomeMaterialUploadResultOut(
|
||||||
|
id=asset.id,
|
||||||
|
category_id=asset.category_id,
|
||||||
|
title=asset.title,
|
||||||
|
media_type=HomeMaterialMediaType(asset.media_type),
|
||||||
|
status=HomeMaterialAssetStatus(asset.status),
|
||||||
|
original_url=asset.original_url,
|
||||||
|
watermarked_url=asset.watermarked_url,
|
||||||
|
cover_url=asset.cover_url,
|
||||||
|
watermark_config=_json_loads(asset.watermark_config_json),
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def wait_for_asset_result(self, asset_id: str, timeout_seconds: int) -> HomeMaterialUploadResultOut:
|
||||||
|
deadline = asyncio.get_running_loop().time() + max(1, min(timeout_seconds, 60))
|
||||||
|
while True:
|
||||||
|
async with async_session() as db:
|
||||||
|
result = await db.execute(select(HomeMaterialAsset).where(HomeMaterialAsset.id == asset_id).limit(1))
|
||||||
|
asset = result.scalar_one_or_none()
|
||||||
|
if not asset:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="素材不存在")
|
||||||
|
if asset.status in (HomeMaterialAssetStatus.SUCCESS.value, HomeMaterialAssetStatus.FAILED.value):
|
||||||
|
return self._upload_result(asset, message="水印处理完成" if asset.status == HomeMaterialAssetStatus.SUCCESS.value else "水印处理失败")
|
||||||
|
if asyncio.get_running_loop().time() >= deadline:
|
||||||
|
async with async_session() as db:
|
||||||
|
result = await db.execute(select(HomeMaterialAsset).where(HomeMaterialAsset.id == asset_id).limit(1))
|
||||||
|
asset = result.scalar_one()
|
||||||
|
return self._upload_result(asset, message="水印仍在处理中,请继续轮询状态")
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
def start_watermark_task(self, asset_id: str) -> None:
|
||||||
|
asyncio.create_task(self._run_watermark_task(asset_id))
|
||||||
|
|
||||||
|
async def _run_watermark_task(self, asset_id: str) -> None:
|
||||||
|
try:
|
||||||
|
async with async_session() as db:
|
||||||
|
asset = await self._get_asset(db, asset_id)
|
||||||
|
cfg = _normalize_watermark_config_dict(_json_loads(asset.watermark_config_json), asset.watermark_id)
|
||||||
|
watermark_path: str | None = None
|
||||||
|
if cfg.get("watermark_type") == HomeMaterialWatermarkType.IMAGE.value:
|
||||||
|
watermark = await self._get_watermark(db, cfg.get("watermark_id") or asset.watermark_id or "", active_only=True)
|
||||||
|
watermark_path = watermark.storage_path
|
||||||
|
output_path, output_url = storage_service.build_watermarked_target(asset.id, asset.media_type)
|
||||||
|
cover_path = cover_url = None
|
||||||
|
if asset.media_type == HomeMaterialMediaType.VIDEO.value:
|
||||||
|
cover_path, cover_url = storage_service.build_cover_target(asset.id)
|
||||||
|
result = await watermark_processor.apply_watermark(
|
||||||
|
media_type=HomeMaterialMediaType(asset.media_type),
|
||||||
|
source_path=asset.original_storage_path,
|
||||||
|
watermark_path=watermark_path,
|
||||||
|
output_path=output_path,
|
||||||
|
config=cfg,
|
||||||
|
cover_path=cover_path,
|
||||||
|
)
|
||||||
|
asset.watermarked_storage_path = result.output_path
|
||||||
|
asset.watermarked_url = output_url
|
||||||
|
asset.cover_storage_path = result.cover_path
|
||||||
|
asset.cover_url = cover_url if result.cover_path else None
|
||||||
|
asset.width = result.width
|
||||||
|
asset.height = result.height
|
||||||
|
asset.duration_seconds = result.duration_seconds
|
||||||
|
asset.watermarked_file_size_bytes = result.file_size_bytes
|
||||||
|
asset.status = HomeMaterialAssetStatus.SUCCESS.value
|
||||||
|
asset.error_message = None
|
||||||
|
asset.processed_at = datetime.now(timezone.utc)
|
||||||
|
db.add(asset)
|
||||||
|
await db.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
async with async_session() as db:
|
||||||
|
result = await db.execute(select(HomeMaterialAsset).where(HomeMaterialAsset.id == asset_id).limit(1))
|
||||||
|
asset = result.scalar_one_or_none()
|
||||||
|
if asset:
|
||||||
|
asset.status = HomeMaterialAssetStatus.FAILED.value
|
||||||
|
asset.error_message = str(exc)[:4000]
|
||||||
|
db.add(asset)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
async def preview_text_watermark_layer(self, req: HomeMaterialTextWatermarkPreviewRequest) -> HomeMaterialTextWatermarkPreviewResponse:
|
||||||
|
data_url = await asyncio.to_thread(
|
||||||
|
watermark_processor.generate_repeated_text_layer_data_url,
|
||||||
|
width=req.width,
|
||||||
|
height=req.height,
|
||||||
|
text_config=req.text_watermark,
|
||||||
|
)
|
||||||
|
return HomeMaterialTextWatermarkPreviewResponse(
|
||||||
|
width=req.width,
|
||||||
|
height=req.height,
|
||||||
|
preview_layer_data_url=data_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def mark_stale_processing_failed(self, db: AsyncSession) -> int:
|
||||||
|
minutes = int(getattr(settings, "HOME_MATERIAL_PROCESSING_STALE_MINUTES", 30))
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(minutes=minutes)
|
||||||
|
result = await db.execute(
|
||||||
|
update(HomeMaterialAsset)
|
||||||
|
.where(
|
||||||
|
HomeMaterialAsset.deleted_at.is_(None),
|
||||||
|
HomeMaterialAsset.status == HomeMaterialAssetStatus.PROCESSING.value,
|
||||||
|
HomeMaterialAsset.updated_at < cutoff,
|
||||||
|
)
|
||||||
|
.values(status=HomeMaterialAssetStatus.FAILED.value, error_message="处理任务超时或服务重启,请重新生成水印")
|
||||||
|
)
|
||||||
|
return int(result.rowcount or 0)
|
||||||
|
|
||||||
|
async def get_public_categories(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
with_asset_count: bool,
|
||||||
|
media_type: HomeMaterialMediaType | str | None,
|
||||||
|
only_has_assets: bool,
|
||||||
|
) -> HomeMaterialPublicCategoryListOut:
|
||||||
|
config = await self.get_config(db)
|
||||||
|
if not config.enabled:
|
||||||
|
return HomeMaterialPublicCategoryListOut(enabled=False, title=config.title, subtitle=config.subtitle, items=[])
|
||||||
|
items = await query_service.list_public_categories(db, with_asset_count=with_asset_count, media_type=media_type, only_has_assets=only_has_assets)
|
||||||
|
return HomeMaterialPublicCategoryListOut(enabled=True, title=config.title, subtitle=config.subtitle, items=items)
|
||||||
|
|
||||||
|
async def get_public_home_materials(
|
||||||
|
self,
|
||||||
|
db: AsyncSession,
|
||||||
|
*,
|
||||||
|
category_id: str | None,
|
||||||
|
category_key: str | None,
|
||||||
|
category_ids: str | None,
|
||||||
|
category_keys: str | None,
|
||||||
|
media_type: HomeMaterialMediaType | str | None,
|
||||||
|
limit_per_category: int,
|
||||||
|
include_empty_categories: bool,
|
||||||
|
response_mode: HomeMaterialPublicResponseMode,
|
||||||
|
page: int,
|
||||||
|
page_size: int,
|
||||||
|
) -> HomeMaterialPublicGroupedOut | HomeMaterialPublicFlatOut:
|
||||||
|
config = await self.get_config(db)
|
||||||
|
if not config.enabled:
|
||||||
|
if response_mode == HomeMaterialPublicResponseMode.FLAT:
|
||||||
|
return HomeMaterialPublicFlatOut(enabled=False, title=config.title, subtitle=config.subtitle, response_mode=response_mode, items=[], total=0)
|
||||||
|
return HomeMaterialPublicGroupedOut(enabled=False, title=config.title, subtitle=config.subtitle, response_mode=response_mode, categories=[])
|
||||||
|
resolved_ids = await query_service.resolve_category_ids(
|
||||||
|
db,
|
||||||
|
category_id=category_id,
|
||||||
|
category_key=category_key,
|
||||||
|
category_ids=category_ids,
|
||||||
|
category_keys=category_keys,
|
||||||
|
active_only=True,
|
||||||
|
)
|
||||||
|
if response_mode == HomeMaterialPublicResponseMode.FLAT:
|
||||||
|
items, total = await query_service.list_public_flat(db, category_ids=resolved_ids, media_type=media_type, page=page, page_size=page_size)
|
||||||
|
return HomeMaterialPublicFlatOut(enabled=True, title=config.title, subtitle=config.subtitle, response_mode=response_mode, items=items, total=total)
|
||||||
|
categories = await query_service.list_public_grouped(
|
||||||
|
db,
|
||||||
|
category_ids=resolved_ids,
|
||||||
|
media_type=media_type,
|
||||||
|
limit_per_category=limit_per_category,
|
||||||
|
include_empty_categories=include_empty_categories,
|
||||||
|
)
|
||||||
|
return HomeMaterialPublicGroupedOut(enabled=True, title=config.title, subtitle=config.subtitle, response_mode=response_mode, categories=categories)
|
||||||
|
|
||||||
|
|
||||||
|
home_material_service = HomeMaterialService()
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from fastapi import HTTPException, UploadFile, status
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.enums.home_material import (
|
||||||
|
HOME_MATERIAL_IMAGE_EXTENSIONS,
|
||||||
|
HOME_MATERIAL_VIDEO_EXTENSIONS,
|
||||||
|
HOME_MATERIAL_WATERMARK_EXTENSIONS,
|
||||||
|
HomeMaterialMediaType,
|
||||||
|
)
|
||||||
|
from app.utils.id_gen import generate_id
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StoredFile:
|
||||||
|
storage_path: str
|
||||||
|
file_url: str
|
||||||
|
file_name: str
|
||||||
|
file_size_bytes: int
|
||||||
|
suffix: str
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialStorageService:
|
||||||
|
"""首页素材文件存储服务。只处理文件、目录、URL,不访问数据库。"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.upload_root = Path(settings.UPLOAD_LOCAL_PATH).resolve()
|
||||||
|
self.home_root = self.upload_root / "home_materials"
|
||||||
|
|
||||||
|
def _date_dir(self) -> str:
|
||||||
|
return datetime.now().strftime("%Y/%m/%d")
|
||||||
|
|
||||||
|
def _safe_suffix(self, filename: str | None) -> str:
|
||||||
|
return Path(filename or "").suffix.lower()
|
||||||
|
|
||||||
|
def _assert_extension(self, suffix: str, allowed: Iterable[str], label: str) -> None:
|
||||||
|
if suffix not in set(allowed):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"{label}文件类型不支持:{suffix or '无扩展名'}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _limit_mb(self, media_type: HomeMaterialMediaType | str) -> int:
|
||||||
|
if media_type == HomeMaterialMediaType.VIDEO or str(media_type) == HomeMaterialMediaType.VIDEO.value:
|
||||||
|
return int(getattr(settings, "HOME_MATERIAL_MAX_VIDEO_MB", 300))
|
||||||
|
return int(getattr(settings, "HOME_MATERIAL_MAX_IMAGE_MB", 20))
|
||||||
|
|
||||||
|
def _build_target(self, relative_dir: str, suffix: str) -> tuple[Path, str]:
|
||||||
|
file_id = generate_id()
|
||||||
|
relative = Path(relative_dir) / self._date_dir() / f"{file_id}{suffix}"
|
||||||
|
path = self.home_root / relative
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path, f"/uploads/home_materials/{relative.as_posix()}"
|
||||||
|
|
||||||
|
async def save_upload_file(
|
||||||
|
self,
|
||||||
|
file: UploadFile,
|
||||||
|
*,
|
||||||
|
relative_dir: str,
|
||||||
|
allowed_extensions: Iterable[str],
|
||||||
|
label: str,
|
||||||
|
max_mb: int | None = None,
|
||||||
|
) -> StoredFile:
|
||||||
|
suffix = self._safe_suffix(file.filename)
|
||||||
|
self._assert_extension(suffix, allowed_extensions, label)
|
||||||
|
target_path, file_url = self._build_target(relative_dir, suffix)
|
||||||
|
max_bytes = int(max_mb or 0) * 1024 * 1024 if max_mb else None
|
||||||
|
tmp_path = target_path.with_name(target_path.name + ".part")
|
||||||
|
size = 0
|
||||||
|
try:
|
||||||
|
with tmp_path.open("wb") as out:
|
||||||
|
while True:
|
||||||
|
chunk = await file.read(1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
size += len(chunk)
|
||||||
|
if max_bytes and size > max_bytes:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"{label}文件大小超限,最大 {max_mb}MB",
|
||||||
|
)
|
||||||
|
out.write(chunk)
|
||||||
|
os.replace(tmp_path, target_path)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
if tmp_path.exists():
|
||||||
|
tmp_path.unlink()
|
||||||
|
finally:
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
await file.seek(0)
|
||||||
|
|
||||||
|
return StoredFile(
|
||||||
|
storage_path=str(target_path),
|
||||||
|
file_url=file_url,
|
||||||
|
file_name=file.filename or target_path.name,
|
||||||
|
file_size_bytes=size,
|
||||||
|
suffix=suffix,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def save_asset_file(self, file: UploadFile, media_type: HomeMaterialMediaType | str) -> StoredFile:
|
||||||
|
media = HomeMaterialMediaType(media_type)
|
||||||
|
if media == HomeMaterialMediaType.IMAGE:
|
||||||
|
return await self.save_upload_file(
|
||||||
|
file,
|
||||||
|
relative_dir="original/images",
|
||||||
|
allowed_extensions=HOME_MATERIAL_IMAGE_EXTENSIONS,
|
||||||
|
label="图片素材",
|
||||||
|
max_mb=self._limit_mb(media),
|
||||||
|
)
|
||||||
|
return await self.save_upload_file(
|
||||||
|
file,
|
||||||
|
relative_dir="original/videos",
|
||||||
|
allowed_extensions=HOME_MATERIAL_VIDEO_EXTENSIONS,
|
||||||
|
label="视频素材",
|
||||||
|
max_mb=self._limit_mb(media),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def save_watermark_file(self, file: UploadFile) -> StoredFile:
|
||||||
|
return await self.save_upload_file(
|
||||||
|
file,
|
||||||
|
relative_dir="watermarks",
|
||||||
|
allowed_extensions=HOME_MATERIAL_WATERMARK_EXTENSIONS,
|
||||||
|
label="水印图片",
|
||||||
|
max_mb=int(getattr(settings, "HOME_MATERIAL_MAX_WATERMARK_MB", 10)),
|
||||||
|
)
|
||||||
|
|
||||||
|
def build_watermarked_target(self, asset_id: str, media_type: HomeMaterialMediaType | str) -> tuple[str, str]:
|
||||||
|
media = HomeMaterialMediaType(media_type)
|
||||||
|
suffix = ".png" if media == HomeMaterialMediaType.IMAGE else ".mp4"
|
||||||
|
folder = "watermarked/images" if media == HomeMaterialMediaType.IMAGE else "watermarked/videos"
|
||||||
|
relative = Path(folder) / self._date_dir() / f"{asset_id}{suffix}"
|
||||||
|
path = self.home_root / relative
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
return str(path), f"/uploads/home_materials/{relative.as_posix()}"
|
||||||
|
|
||||||
|
def build_cover_target(self, asset_id: str) -> tuple[str, str]:
|
||||||
|
relative = Path("covers") / self._date_dir() / f"{asset_id}.jpg"
|
||||||
|
path = self.home_root / relative
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
return str(path), f"/uploads/home_materials/{relative.as_posix()}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def file_size(path: str | None) -> int | None:
|
||||||
|
if not path:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return Path(path).stat().st_size
|
||||||
|
except FileNotFoundError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def safe_remove(path: str | None) -> None:
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
Path(path).unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
storage_service = HomeMaterialStorageService()
|
||||||
@@ -0,0 +1,622 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
except Exception: # pragma: no cover - 运行时给出明确错误
|
||||||
|
Image = None # type: ignore[assignment]
|
||||||
|
ImageDraw = None # type: ignore[assignment]
|
||||||
|
ImageFont = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.enums.home_material import (
|
||||||
|
HomeMaterialMediaType,
|
||||||
|
HomeMaterialWatermarkPosition,
|
||||||
|
HomeMaterialWatermarkSizeMode,
|
||||||
|
HomeMaterialWatermarkType,
|
||||||
|
)
|
||||||
|
from app.schemas.home_material import HomeMaterialTextWatermarkConfig, HomeMaterialWatermarkConfig
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MediaProbeInfo:
|
||||||
|
width: int | None = None
|
||||||
|
height: int | None = None
|
||||||
|
duration_seconds: Decimal | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WatermarkProcessResult:
|
||||||
|
output_path: str
|
||||||
|
width: int | None
|
||||||
|
height: int | None
|
||||||
|
duration_seconds: Decimal | None
|
||||||
|
file_size_bytes: int
|
||||||
|
cover_path: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class HomeMaterialWatermarkProcessor:
|
||||||
|
"""首页素材 FFmpeg 水印处理器。纯处理层,不访问数据库。"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._image_semaphore = asyncio.Semaphore(int(getattr(settings, "HOME_MATERIAL_IMAGE_WATERMARK_CONCURRENCY", 4)))
|
||||||
|
self._video_semaphore = asyncio.Semaphore(int(getattr(settings, "HOME_MATERIAL_VIDEO_WATERMARK_CONCURRENCY", 2)))
|
||||||
|
|
||||||
|
def _ffmpeg_bin(self) -> str:
|
||||||
|
configured = getattr(settings, "FFMPEG_BIN", "") or ""
|
||||||
|
if configured:
|
||||||
|
return configured
|
||||||
|
found = shutil.which("ffmpeg") or shutil.which("ffmpeg.exe")
|
||||||
|
if not found:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="未找到 ffmpeg,请配置 FFMPEG_BIN 或安装 ffmpeg")
|
||||||
|
return found
|
||||||
|
|
||||||
|
def _ffprobe_bin(self) -> str:
|
||||||
|
configured = getattr(settings, "FFMPEG_BIN", "") or ""
|
||||||
|
if configured:
|
||||||
|
p = Path(configured)
|
||||||
|
candidate = p.with_name("ffprobe.exe" if p.name.endswith(".exe") else "ffprobe")
|
||||||
|
if candidate.exists():
|
||||||
|
return str(candidate)
|
||||||
|
found = shutil.which("ffprobe") or shutil.which("ffprobe.exe")
|
||||||
|
if not found:
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="未找到 ffprobe,请确认 ffmpeg 环境完整")
|
||||||
|
return found
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _run_blocking(args: list[str], timeout: int) -> tuple[str, str]:
|
||||||
|
"""在线程里执行 FFmpeg/ffprobe。
|
||||||
|
|
||||||
|
Windows 下 uvicorn/watchfiles 有概率使用不支持子进程的 SelectorEventLoop,
|
||||||
|
asyncio.create_subprocess_exec 会直接抛 NotImplementedError。这里统一改为
|
||||||
|
subprocess.run + asyncio.to_thread,仍然不会阻塞 Web Server 事件循环。
|
||||||
|
"""
|
||||||
|
creationflags = 0
|
||||||
|
if os.name == "nt":
|
||||||
|
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||||
|
try:
|
||||||
|
completed = subprocess.run(
|
||||||
|
args,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
timeout=timeout,
|
||||||
|
check=False,
|
||||||
|
creationflags=creationflags,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
raise RuntimeError("FFmpeg 处理超时") from exc
|
||||||
|
out = completed.stdout.decode("utf-8", errors="ignore")
|
||||||
|
err = completed.stderr.decode("utf-8", errors="ignore")
|
||||||
|
if completed.returncode != 0:
|
||||||
|
raise RuntimeError(err[-2000:] or f"FFmpeg 退出码异常:{completed.returncode}")
|
||||||
|
return out, err
|
||||||
|
|
||||||
|
async def _run(self, args: list[str], timeout: int | None = None) -> tuple[str, str]:
|
||||||
|
effective_timeout = timeout or int(getattr(settings, "HOME_MATERIAL_FFMPEG_TIMEOUT_SECONDS", 600))
|
||||||
|
return await asyncio.to_thread(self._run_blocking, args, effective_timeout)
|
||||||
|
|
||||||
|
async def probe(self, path: str) -> MediaProbeInfo:
|
||||||
|
args = [
|
||||||
|
self._ffprobe_bin(),
|
||||||
|
"-v",
|
||||||
|
"error",
|
||||||
|
"-select_streams",
|
||||||
|
"v:0",
|
||||||
|
"-show_entries",
|
||||||
|
"stream=width,height,duration",
|
||||||
|
"-of",
|
||||||
|
"json",
|
||||||
|
path,
|
||||||
|
]
|
||||||
|
stdout, _ = await self._run(args, timeout=30)
|
||||||
|
try:
|
||||||
|
data = json.loads(stdout or "{}")
|
||||||
|
stream = (data.get("streams") or [{}])[0]
|
||||||
|
duration_raw = stream.get("duration")
|
||||||
|
return MediaProbeInfo(
|
||||||
|
width=int(stream["width"]) if stream.get("width") is not None else None,
|
||||||
|
height=int(stream["height"]) if stream.get("height") is not None else None,
|
||||||
|
duration_seconds=Decimal(str(duration_raw)).quantize(Decimal("0.001")) if duration_raw not in (None, "N/A") else None,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return MediaProbeInfo()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _value(config: HomeMaterialWatermarkConfig | HomeMaterialTextWatermarkConfig | dict[str, Any], key: str, default: Any = None) -> Any:
|
||||||
|
if isinstance(config, dict):
|
||||||
|
return config.get(key, default)
|
||||||
|
return getattr(config, key, default)
|
||||||
|
|
||||||
|
def _watermark_type(self, config: HomeMaterialWatermarkConfig | dict[str, Any]) -> str:
|
||||||
|
raw = self._value(config, "watermark_type", None) or HomeMaterialWatermarkType.IMAGE.value
|
||||||
|
return HomeMaterialWatermarkType(raw).value
|
||||||
|
|
||||||
|
def _watermark_width(self, source_width: int | None, config: HomeMaterialWatermarkConfig | dict[str, Any]) -> int:
|
||||||
|
size_mode = self._value(config, "size_mode", HomeMaterialWatermarkSizeMode.RATIO.value)
|
||||||
|
if hasattr(size_mode, "value"):
|
||||||
|
size_mode = size_mode.value
|
||||||
|
width_px = self._value(config, "width_px", None)
|
||||||
|
width_ratio = self._value(config, "width_ratio", 0.18) or 0.18
|
||||||
|
if size_mode == HomeMaterialWatermarkSizeMode.PX.value and width_px:
|
||||||
|
return max(1, int(width_px))
|
||||||
|
base_width = source_width or 1080
|
||||||
|
return max(1, int(base_width * float(width_ratio)))
|
||||||
|
|
||||||
|
def _overlay_xy(self, config: HomeMaterialWatermarkConfig | dict[str, Any]) -> tuple[str, str]:
|
||||||
|
position = self._value(config, "position", HomeMaterialWatermarkPosition.BOTTOM_RIGHT.value)
|
||||||
|
if hasattr(position, "value"):
|
||||||
|
position = position.value
|
||||||
|
margin_x = int(self._value(config, "margin_x", 24) or 24)
|
||||||
|
margin_y = int(self._value(config, "margin_y", 24) or 24)
|
||||||
|
custom_x_ratio = self._value(config, "custom_x_ratio", None)
|
||||||
|
custom_y_ratio = self._value(config, "custom_y_ratio", None)
|
||||||
|
|
||||||
|
if position == HomeMaterialWatermarkPosition.CUSTOM.value:
|
||||||
|
x_ratio = float(custom_x_ratio if custom_x_ratio is not None else 0.5)
|
||||||
|
y_ratio = float(custom_y_ratio if custom_y_ratio is not None else 0.5)
|
||||||
|
return f"(main_w-overlay_w)*{x_ratio:.6f}", f"(main_h-overlay_h)*{y_ratio:.6f}"
|
||||||
|
|
||||||
|
positions: dict[str, tuple[str, str]] = {
|
||||||
|
HomeMaterialWatermarkPosition.TOP_LEFT.value: (str(margin_x), str(margin_y)),
|
||||||
|
HomeMaterialWatermarkPosition.TOP_CENTER.value: ("(main_w-overlay_w)/2", str(margin_y)),
|
||||||
|
HomeMaterialWatermarkPosition.TOP_RIGHT.value: (f"main_w-overlay_w-{margin_x}", str(margin_y)),
|
||||||
|
HomeMaterialWatermarkPosition.MIDDLE_LEFT.value: (str(margin_x), "(main_h-overlay_h)/2"),
|
||||||
|
HomeMaterialWatermarkPosition.CENTER.value: ("(main_w-overlay_w)/2", "(main_h-overlay_h)/2"),
|
||||||
|
HomeMaterialWatermarkPosition.MIDDLE_RIGHT.value: (f"main_w-overlay_w-{margin_x}", "(main_h-overlay_h)/2"),
|
||||||
|
HomeMaterialWatermarkPosition.BOTTOM_LEFT.value: (str(margin_x), f"main_h-overlay_h-{margin_y}"),
|
||||||
|
HomeMaterialWatermarkPosition.BOTTOM_CENTER.value: ("(main_w-overlay_w)/2", f"main_h-overlay_h-{margin_y}"),
|
||||||
|
HomeMaterialWatermarkPosition.BOTTOM_RIGHT.value: (f"main_w-overlay_w-{margin_x}", f"main_h-overlay_h-{margin_y}"),
|
||||||
|
}
|
||||||
|
return positions.get(str(position), positions[HomeMaterialWatermarkPosition.BOTTOM_RIGHT.value])
|
||||||
|
|
||||||
|
def _opacity(self, config: HomeMaterialWatermarkConfig | HomeMaterialTextWatermarkConfig | dict[str, Any]) -> float:
|
||||||
|
level = int(self._value(config, "opacity_level", 6) or 6)
|
||||||
|
return min(max(level, 1), 10) / 10
|
||||||
|
|
||||||
|
def _filter_complex_image_watermark(self, source_width: int | None, config: HomeMaterialWatermarkConfig | dict[str, Any]) -> str:
|
||||||
|
wm_width = self._watermark_width(source_width, config)
|
||||||
|
opacity = self._opacity(config)
|
||||||
|
x, y = self._overlay_xy(config)
|
||||||
|
return f"[1:v]format=rgba,colorchannelmixer=aa={opacity:.2f},scale={wm_width}:-1[wm];[0:v][wm]overlay={x}:{y}[v]"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _hex_to_rgb(value: str) -> tuple[int, int, int]:
|
||||||
|
raw = (value or "#ffffff").strip()
|
||||||
|
if not raw.startswith("#") or len(raw) != 7:
|
||||||
|
raw = "#ffffff"
|
||||||
|
try:
|
||||||
|
return int(raw[1:3], 16), int(raw[3:5], 16), int(raw[5:7], 16)
|
||||||
|
except Exception:
|
||||||
|
return 255, 255, 255
|
||||||
|
|
||||||
|
def _font_path(self) -> str:
|
||||||
|
configured = str(getattr(settings, "HOME_MATERIAL_TEXT_WATERMARK_FONT", "") or "").strip()
|
||||||
|
if not configured:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="重复文字水印字体未配置,请配置 HOME_MATERIAL_TEXT_WATERMARK_FONT 为开源可商用字体文件路径,例如 Noto Sans CJK SC / Source Han Sans SC。",
|
||||||
|
)
|
||||||
|
path = Path(configured)
|
||||||
|
if not path.exists() or not path.is_file():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"重复文字水印字体文件不存在或不可读:{configured}",
|
||||||
|
)
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
def _assert_pillow_available(self) -> None:
|
||||||
|
if Image is None or ImageDraw is None or ImageFont is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="重复文字水印依赖 Pillow,请先安装 pillow,并配置 HOME_MATERIAL_TEXT_WATERMARK_FONT。",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _text_config(self, config: HomeMaterialWatermarkConfig | HomeMaterialTextWatermarkConfig | dict[str, Any]) -> HomeMaterialTextWatermarkConfig | dict[str, Any]:
|
||||||
|
if isinstance(config, HomeMaterialTextWatermarkConfig):
|
||||||
|
return config
|
||||||
|
if isinstance(config, HomeMaterialWatermarkConfig):
|
||||||
|
if config.text_watermark is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="重复文字水印配置不能为空")
|
||||||
|
return config.text_watermark
|
||||||
|
text_config = config.get("text_watermark")
|
||||||
|
if not isinstance(text_config, dict):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="重复文字水印配置不能为空")
|
||||||
|
return text_config
|
||||||
|
|
||||||
|
def generate_repeated_text_layer_file(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
text_config: HomeMaterialTextWatermarkConfig | dict[str, Any],
|
||||||
|
output_path: str,
|
||||||
|
) -> str:
|
||||||
|
"""用固定开源字体生成透明重复文字水印层。
|
||||||
|
|
||||||
|
该方法同时用于前端精准预览和最终 FFmpeg overlay,保证前端看到的透明层和实际叠加层来自同一套渲染逻辑。
|
||||||
|
"""
|
||||||
|
self._assert_pillow_available()
|
||||||
|
font_path = self._font_path()
|
||||||
|
|
||||||
|
safe_width = max(1, int(width))
|
||||||
|
safe_height = max(1, int(height))
|
||||||
|
max_text_length = int(getattr(settings, "HOME_MATERIAL_TEXT_WATERMARK_MAX_TEXT_LENGTH", 64))
|
||||||
|
max_font_size = int(getattr(settings, "HOME_MATERIAL_TEXT_WATERMARK_MAX_FONT_SIZE", 160))
|
||||||
|
max_gap = int(getattr(settings, "HOME_MATERIAL_TEXT_WATERMARK_MAX_GAP", 2000))
|
||||||
|
|
||||||
|
text = str(self._value(text_config, "text", "") or "").strip()[:max_text_length]
|
||||||
|
if not text:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="重复文字水印内容不能为空")
|
||||||
|
font_size = max(8, min(max_font_size, int(self._value(text_config, "font_size_px", 28) or 28)))
|
||||||
|
rotate_deg = max(-90, min(90, int(self._value(text_config, "rotate_deg", -30) or 0)))
|
||||||
|
gap_x = max(20, min(max_gap, int(self._value(text_config, "gap_x", 220) or 220)))
|
||||||
|
gap_y = max(20, min(max_gap, int(self._value(text_config, "gap_y", 140) or 140)))
|
||||||
|
staggered = bool(self._value(text_config, "staggered", True))
|
||||||
|
opacity = self._opacity(text_config)
|
||||||
|
r, g, b = self._hex_to_rgb(str(self._value(text_config, "color", "#ffffff") or "#ffffff"))
|
||||||
|
alpha = int(round(opacity * 255))
|
||||||
|
|
||||||
|
font = ImageFont.truetype(font_path, font_size)
|
||||||
|
measure = Image.new("RGBA", (1, 1), (0, 0, 0, 0))
|
||||||
|
measure_draw = ImageDraw.Draw(measure)
|
||||||
|
bbox = measure_draw.textbbox((0, 0), text, font=font)
|
||||||
|
text_w = max(1, bbox[2] - bbox[0])
|
||||||
|
text_h = max(1, bbox[3] - bbox[1])
|
||||||
|
padding = max(8, int(font_size * 0.8))
|
||||||
|
|
||||||
|
text_img = Image.new("RGBA", (text_w + padding * 2, text_h + padding * 2), (0, 0, 0, 0))
|
||||||
|
text_draw = ImageDraw.Draw(text_img)
|
||||||
|
text_draw.text((padding - bbox[0], padding - bbox[1]), text, font=font, fill=(r, g, b, alpha))
|
||||||
|
if rotate_deg:
|
||||||
|
text_img = text_img.rotate(rotate_deg, resample=Image.Resampling.BICUBIC, expand=True)
|
||||||
|
|
||||||
|
layer = Image.new("RGBA", (safe_width, safe_height), (0, 0, 0, 0))
|
||||||
|
tile_w, tile_h = text_img.size
|
||||||
|
step_x = max(gap_x, int(tile_w * 0.8))
|
||||||
|
step_y = max(gap_y, int(tile_h * 0.8))
|
||||||
|
start_x = -tile_w
|
||||||
|
start_y = -tile_h
|
||||||
|
end_x = safe_width + tile_w
|
||||||
|
end_y = safe_height + tile_h
|
||||||
|
|
||||||
|
row = 0
|
||||||
|
y = start_y
|
||||||
|
while y <= end_y:
|
||||||
|
offset = step_x // 2 if staggered and row % 2 == 1 else 0
|
||||||
|
x = start_x + offset
|
||||||
|
while x <= end_x:
|
||||||
|
layer.alpha_composite(text_img, (int(x), int(y)))
|
||||||
|
x += step_x
|
||||||
|
y += step_y
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
target = Path(output_path)
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
layer.save(str(target), format="PNG")
|
||||||
|
return str(target)
|
||||||
|
|
||||||
|
def generate_repeated_text_layer_data_url(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
text_config: HomeMaterialTextWatermarkConfig | dict[str, Any],
|
||||||
|
) -> str:
|
||||||
|
max_width = int(getattr(settings, "HOME_MATERIAL_TEXT_WATERMARK_PREVIEW_MAX_WIDTH", 8192))
|
||||||
|
max_height = int(getattr(settings, "HOME_MATERIAL_TEXT_WATERMARK_PREVIEW_MAX_HEIGHT", 8192))
|
||||||
|
safe_width = max(1, min(max_width, int(width)))
|
||||||
|
safe_height = max(1, min(max_height, int(height)))
|
||||||
|
with tempfile.NamedTemporaryFile(prefix="home_material_text_wm_", suffix=".png", delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
try:
|
||||||
|
self.generate_repeated_text_layer_file(width=safe_width, height=safe_height, text_config=text_config, output_path=tmp_path)
|
||||||
|
raw = Path(tmp_path).read_bytes()
|
||||||
|
return "data:image/png;base64," + base64.b64encode(raw).decode("ascii")
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
Path(tmp_path).unlink(missing_ok=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def apply_watermark(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
media_type: HomeMaterialMediaType | str,
|
||||||
|
source_path: str,
|
||||||
|
watermark_path: str | None,
|
||||||
|
output_path: str,
|
||||||
|
config: HomeMaterialWatermarkConfig | dict[str, Any],
|
||||||
|
cover_path: str | None = None,
|
||||||
|
) -> WatermarkProcessResult:
|
||||||
|
media = HomeMaterialMediaType(media_type)
|
||||||
|
watermark_type = self._watermark_type(config)
|
||||||
|
if media == HomeMaterialMediaType.IMAGE:
|
||||||
|
async with self._image_semaphore:
|
||||||
|
if watermark_type == HomeMaterialWatermarkType.REPEATED_TEXT.value:
|
||||||
|
return await self._apply_image_repeated_text_watermark(source_path, output_path, config)
|
||||||
|
if not watermark_path:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图片水印文件不能为空")
|
||||||
|
return await self._apply_image_watermark(source_path, watermark_path, output_path, config)
|
||||||
|
async with self._video_semaphore:
|
||||||
|
if watermark_type == HomeMaterialWatermarkType.REPEATED_TEXT.value:
|
||||||
|
return await self._apply_video_repeated_text_watermark(source_path, output_path, config, cover_path=cover_path)
|
||||||
|
if not watermark_path:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="图片水印文件不能为空")
|
||||||
|
return await self._apply_video_watermark(source_path, watermark_path, output_path, config, cover_path=cover_path)
|
||||||
|
|
||||||
|
async def _apply_image_watermark(
|
||||||
|
self,
|
||||||
|
source_path: str,
|
||||||
|
watermark_path: str,
|
||||||
|
output_path: str,
|
||||||
|
config: HomeMaterialWatermarkConfig | dict[str, Any],
|
||||||
|
) -> WatermarkProcessResult:
|
||||||
|
source_info = await self.probe(source_path)
|
||||||
|
tmp_path = output_path + ".part"
|
||||||
|
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
filter_complex = self._filter_complex_image_watermark(source_info.width, config)
|
||||||
|
args = [
|
||||||
|
self._ffmpeg_bin(),
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
source_path,
|
||||||
|
"-i",
|
||||||
|
watermark_path,
|
||||||
|
"-filter_complex",
|
||||||
|
filter_complex,
|
||||||
|
"-map",
|
||||||
|
"[v]",
|
||||||
|
"-frames:v",
|
||||||
|
"1",
|
||||||
|
"-f",
|
||||||
|
"image2",
|
||||||
|
"-vcodec",
|
||||||
|
"png",
|
||||||
|
tmp_path,
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
await self._run(args)
|
||||||
|
os.replace(tmp_path, output_path)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(tmp_path):
|
||||||
|
os.remove(tmp_path)
|
||||||
|
output_info = await self.probe(output_path)
|
||||||
|
return WatermarkProcessResult(
|
||||||
|
output_path=output_path,
|
||||||
|
width=output_info.width or source_info.width,
|
||||||
|
height=output_info.height or source_info.height,
|
||||||
|
duration_seconds=None,
|
||||||
|
file_size_bytes=os.path.getsize(output_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _apply_video_watermark(
|
||||||
|
self,
|
||||||
|
source_path: str,
|
||||||
|
watermark_path: str,
|
||||||
|
output_path: str,
|
||||||
|
config: HomeMaterialWatermarkConfig | dict[str, Any],
|
||||||
|
cover_path: str | None = None,
|
||||||
|
) -> WatermarkProcessResult:
|
||||||
|
source_info = await self.probe(source_path)
|
||||||
|
max_duration = int(getattr(settings, "HOME_MATERIAL_MAX_VIDEO_DURATION_SECONDS", 300))
|
||||||
|
if source_info.duration_seconds is not None and source_info.duration_seconds > Decimal(max_duration):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"视频时长超限,最大 {max_duration} 秒")
|
||||||
|
|
||||||
|
tmp_path = output_path + ".part"
|
||||||
|
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
filter_complex = self._filter_complex_image_watermark(source_info.width, config)
|
||||||
|
args = [
|
||||||
|
self._ffmpeg_bin(),
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
source_path,
|
||||||
|
"-i",
|
||||||
|
watermark_path,
|
||||||
|
"-filter_complex",
|
||||||
|
filter_complex,
|
||||||
|
"-map",
|
||||||
|
"[v]",
|
||||||
|
"-map",
|
||||||
|
"0:a?",
|
||||||
|
"-c:v",
|
||||||
|
"libx264",
|
||||||
|
"-preset",
|
||||||
|
"veryfast",
|
||||||
|
"-crf",
|
||||||
|
"23",
|
||||||
|
"-c:a",
|
||||||
|
"copy",
|
||||||
|
"-movflags",
|
||||||
|
"+faststart",
|
||||||
|
"-f",
|
||||||
|
"mp4",
|
||||||
|
tmp_path,
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
await self._run(args)
|
||||||
|
os.replace(tmp_path, output_path)
|
||||||
|
finally:
|
||||||
|
if os.path.exists(tmp_path):
|
||||||
|
os.remove(tmp_path)
|
||||||
|
|
||||||
|
output_info = await self.probe(output_path)
|
||||||
|
generated_cover = None
|
||||||
|
if cover_path:
|
||||||
|
generated_cover = await self.generate_cover(output_path, cover_path)
|
||||||
|
return WatermarkProcessResult(
|
||||||
|
output_path=output_path,
|
||||||
|
width=output_info.width or source_info.width,
|
||||||
|
height=output_info.height or source_info.height,
|
||||||
|
duration_seconds=output_info.duration_seconds or source_info.duration_seconds,
|
||||||
|
file_size_bytes=os.path.getsize(output_path),
|
||||||
|
cover_path=generated_cover,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _apply_image_repeated_text_watermark(
|
||||||
|
self,
|
||||||
|
source_path: str,
|
||||||
|
output_path: str,
|
||||||
|
config: HomeMaterialWatermarkConfig | dict[str, Any],
|
||||||
|
) -> WatermarkProcessResult:
|
||||||
|
source_info = await self.probe(source_path)
|
||||||
|
width = int(source_info.width or 1080)
|
||||||
|
height = int(source_info.height or 1920)
|
||||||
|
tmp_path = output_path + ".part"
|
||||||
|
layer_path = output_path + ".text-layer.png"
|
||||||
|
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.generate_repeated_text_layer_file(width=width, height=height, text_config=self._text_config(config), output_path=layer_path)
|
||||||
|
args = [
|
||||||
|
self._ffmpeg_bin(),
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
source_path,
|
||||||
|
"-i",
|
||||||
|
layer_path,
|
||||||
|
"-filter_complex",
|
||||||
|
"[1:v]format=rgba[wm];[0:v][wm]overlay=0:0[v]",
|
||||||
|
"-map",
|
||||||
|
"[v]",
|
||||||
|
"-frames:v",
|
||||||
|
"1",
|
||||||
|
"-f",
|
||||||
|
"image2",
|
||||||
|
"-vcodec",
|
||||||
|
"png",
|
||||||
|
tmp_path,
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
await self._run(args)
|
||||||
|
os.replace(tmp_path, output_path)
|
||||||
|
finally:
|
||||||
|
for p in (tmp_path, layer_path):
|
||||||
|
if os.path.exists(p):
|
||||||
|
os.remove(p)
|
||||||
|
output_info = await self.probe(output_path)
|
||||||
|
return WatermarkProcessResult(
|
||||||
|
output_path=output_path,
|
||||||
|
width=output_info.width or source_info.width,
|
||||||
|
height=output_info.height or source_info.height,
|
||||||
|
duration_seconds=None,
|
||||||
|
file_size_bytes=os.path.getsize(output_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _apply_video_repeated_text_watermark(
|
||||||
|
self,
|
||||||
|
source_path: str,
|
||||||
|
output_path: str,
|
||||||
|
config: HomeMaterialWatermarkConfig | dict[str, Any],
|
||||||
|
cover_path: str | None = None,
|
||||||
|
) -> WatermarkProcessResult:
|
||||||
|
source_info = await self.probe(source_path)
|
||||||
|
max_duration = int(getattr(settings, "HOME_MATERIAL_MAX_VIDEO_DURATION_SECONDS", 300))
|
||||||
|
if source_info.duration_seconds is not None and source_info.duration_seconds > Decimal(max_duration):
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"视频时长超限,最大 {max_duration} 秒")
|
||||||
|
width = int(source_info.width or 1080)
|
||||||
|
height = int(source_info.height or 1920)
|
||||||
|
tmp_path = output_path + ".part"
|
||||||
|
layer_path = output_path + ".text-layer.png"
|
||||||
|
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.generate_repeated_text_layer_file(width=width, height=height, text_config=self._text_config(config), output_path=layer_path)
|
||||||
|
args = [
|
||||||
|
self._ffmpeg_bin(),
|
||||||
|
"-y",
|
||||||
|
"-i",
|
||||||
|
source_path,
|
||||||
|
"-loop",
|
||||||
|
"1",
|
||||||
|
"-i",
|
||||||
|
layer_path,
|
||||||
|
"-filter_complex",
|
||||||
|
"[1:v]format=rgba[wm];[0:v][wm]overlay=0:0[v]",
|
||||||
|
"-map",
|
||||||
|
"[v]",
|
||||||
|
"-map",
|
||||||
|
"0:a?",
|
||||||
|
"-c:v",
|
||||||
|
"libx264",
|
||||||
|
"-preset",
|
||||||
|
"veryfast",
|
||||||
|
"-crf",
|
||||||
|
"23",
|
||||||
|
"-c:a",
|
||||||
|
"copy",
|
||||||
|
"-movflags",
|
||||||
|
"+faststart",
|
||||||
|
"-shortest",
|
||||||
|
"-f",
|
||||||
|
"mp4",
|
||||||
|
tmp_path,
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
await self._run(args)
|
||||||
|
os.replace(tmp_path, output_path)
|
||||||
|
finally:
|
||||||
|
for p in (tmp_path, layer_path):
|
||||||
|
if os.path.exists(p):
|
||||||
|
os.remove(p)
|
||||||
|
|
||||||
|
output_info = await self.probe(output_path)
|
||||||
|
generated_cover = None
|
||||||
|
if cover_path:
|
||||||
|
generated_cover = await self.generate_cover(output_path, cover_path)
|
||||||
|
return WatermarkProcessResult(
|
||||||
|
output_path=output_path,
|
||||||
|
width=output_info.width or source_info.width,
|
||||||
|
height=output_info.height or source_info.height,
|
||||||
|
duration_seconds=output_info.duration_seconds or source_info.duration_seconds,
|
||||||
|
file_size_bytes=os.path.getsize(output_path),
|
||||||
|
cover_path=generated_cover,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def generate_cover(self, video_path: str, cover_path: str) -> str:
|
||||||
|
tmp_path = cover_path + ".part"
|
||||||
|
Path(cover_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
seek = getattr(settings, "VIDEO_COVER_SEEK_TIME", "00:00:01") or "00:00:01"
|
||||||
|
width = int(getattr(settings, "VIDEO_COVER_WIDTH", 720))
|
||||||
|
args = [
|
||||||
|
self._ffmpeg_bin(),
|
||||||
|
"-y",
|
||||||
|
"-ss",
|
||||||
|
seek,
|
||||||
|
"-i",
|
||||||
|
video_path,
|
||||||
|
"-frames:v",
|
||||||
|
"1",
|
||||||
|
"-vf",
|
||||||
|
f"scale={width}:-2",
|
||||||
|
"-f",
|
||||||
|
"image2",
|
||||||
|
"-vcodec",
|
||||||
|
"mjpeg",
|
||||||
|
tmp_path,
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
await self._run(args, timeout=int(getattr(settings, "VIDEO_COVER_TIMEOUT_SECONDS", 15)))
|
||||||
|
os.replace(tmp_path, cover_path)
|
||||||
|
return cover_path
|
||||||
|
except Exception:
|
||||||
|
if os.path.exists(tmp_path):
|
||||||
|
os.remove(tmp_path)
|
||||||
|
fallback_seek = getattr(settings, "VIDEO_COVER_FALLBACK_SEEK_TIME", "00:00:00") or "00:00:00"
|
||||||
|
fallback_args = args.copy()
|
||||||
|
fallback_args[fallback_args.index(seek)] = fallback_seek
|
||||||
|
await self._run(fallback_args, timeout=int(getattr(settings, "VIDEO_COVER_TIMEOUT_SECONDS", 15)))
|
||||||
|
os.replace(tmp_path, cover_path)
|
||||||
|
return cover_path
|
||||||
|
|
||||||
|
|
||||||
|
watermark_processor = HomeMaterialWatermarkProcessor()
|
||||||
Binary file not shown.
Reference in New Issue
Block a user