首页素材装修模块完成

This commit is contained in:
2026-06-30 17:06:45 +08:00
parent 37203cdbaf
commit b48979d4c4
32 changed files with 5279 additions and 15 deletions
+2
View File
@@ -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>
+21 -14
View File
@@ -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 }),
}; };
+181 -1
View File
@@ -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;
+247
View File
@@ -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;
}
+47
View File
@@ -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 ###
+2
View File
@@ -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_configskey=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-10position 支持九宫格和 customcustom 需要 custom_x_ratio/custom_y_ratio"
"size_mode=ratio 使用 width_ratiosize_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_urlstatus=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"}
+2
View File
@@ -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,
)
+21
View File
@@ -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。
+1
View File
@@ -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 *
+91
View File
@@ -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,
}
+2
View File
@@ -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")
+301
View File
@@ -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-101=0.110=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-101=0.110=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 对象前必须显式刷新。
本模块模型继承 TimestampMixinupdated_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()