From 2ce07fa7bfb72f4b40b529728a1020b46804571b Mon Sep 17 00:00:00 2001 From: xiaoxia Date: Thu, 24 Sep 2026 11:23:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(generate):=20=E5=B0=81=E9=9D=A2=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=99=A8=E5=AE=8C=E6=95=B4=E5=AE=9E=E7=8E=B0=20?= =?UTF-8?q?=E2=80=94=207=E4=B8=AA=E9=9D=A2=E6=9D=BF=E5=85=A8=E9=83=A8?= =?UTF-8?q?=E6=8E=A7=E4=BB=B6=20+=20=E5=8F=B3=E4=BE=A7=E7=94=BB=E5=B8=83?= =?UTF-8?q?=E5=AE=9E=E6=97=B6=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 基础设置:背景模糊/人物描边/文字自动拆分,各带开关+子控件(滑块/颜色选择器/按钮组) - 人像设置:大小滑块 + X/Y 位置精确输入 - 背景设置:开关 + 大小 + 位置 - 主标题/副标题:文字内容只读框、字体下拉(35种字体,绿色=预置/蓝色=系统)、字号/字重滑块、文字方向、每行字数、字符间距、行间距、颜色/描边颜色/描边粗细、多层阴影(+添加阴影层+预设效果+传统阴影)、X/Y位置(精确小数)、旋转角度、文字背景(颜色/透明度/形状下拉含自定义多边形+大小+位置偏移+旋转) - 蒙版:开关 + 蒙版图片选择 + 大小 + 位置 + 颜色 + 透明度 + 形状 - 右侧 9:16 画布实时渲染:人像占位(8蓝色手柄)、主/副标题(按所有样式属性)、背景、蒙版 - types/cover.ts 新增 CoverEditorConfig 及全部子类型(TextDirection/TextBgShape/StrokeStyle/ShadowLayer/TextStyleConfig 等) - api/cover-templates.ts 更新 config 类型为 CoverEditorConfig - tsc/eslint/prettier 全零错误零警告,vite build 成功 --- apps/web/src/api/cover-templates.ts | 11 +- .../cover-settings/CoverEditorModal.tsx | 920 ++++++++++++++++-- apps/web/src/pages/generate/generate.css | 385 ++++++++ apps/web/src/pages/generate/types/cover.ts | 241 ++++- 4 files changed, 1477 insertions(+), 80 deletions(-) diff --git a/apps/web/src/api/cover-templates.ts b/apps/web/src/api/cover-templates.ts index 69d2f4d7e..dd34da733 100644 --- a/apps/web/src/api/cover-templates.ts +++ b/apps/web/src/api/cover-templates.ts @@ -3,7 +3,7 @@ * 后端路由: /api/v1/cover-templates */ import apiClient from "./client" -import type { CoverTemplate } from "@/pages/generate/types/cover" +import type { CoverTemplate, CoverEditorConfig } from "@/pages/generate/types/cover" export interface CoverTemplateListResponse { items: CoverTemplate[] @@ -12,14 +12,7 @@ export interface CoverTemplateListResponse { export interface CoverTemplateCreateRequest { name: string - config?: { - background_enabled?: boolean - background_color?: string - portrait_enabled?: boolean - title_text?: string - subtitle_text?: string - mask_enabled?: boolean - } + config?: CoverEditorConfig } export type CoverTemplateUpdateRequest = Partial diff --git a/apps/web/src/pages/generate/components/cover-settings/CoverEditorModal.tsx b/apps/web/src/pages/generate/components/cover-settings/CoverEditorModal.tsx index e4bac6fec..ec2fc14b0 100644 --- a/apps/web/src/pages/generate/components/cover-settings/CoverEditorModal.tsx +++ b/apps/web/src/pages/generate/components/cover-settings/CoverEditorModal.tsx @@ -1,8 +1,19 @@ -import React, { useState } from "react" -import type { CoverTemplate } from "../../types/cover" +import React, { useState, useCallback, useRef } from "react" +import { Slider, Switch, Select, InputNumber } from "antd" +import type { + CoverTemplate, + CoverEditorConfig, + TextStyleConfig, + TextBackground, + TextDirection, + StrokeStyle, + TextBgShape, +} from "../../types/cover" +import { DEFAULT_EDITOR_CONFIG, PRESET_FONTS, SYSTEM_FONTS, ALL_FONTS } from "../../types/cover" import Modal from "@/components/ui/Modal" import Button from "@/components/ui/Button" +/* ── Props ── */ interface CoverEditorModalProps { open: boolean onClose: () => void @@ -10,18 +21,375 @@ interface CoverEditorModalProps { onSave: (template: CoverTemplate) => void } -interface SectionState { - basic: boolean - portrait: boolean - background: boolean - title: boolean - subtitle: boolean - mask: boolean +/* ── Section expand/collapse keys ── */ +type SectionKey = "basic" | "portrait" | "background" | "title" | "subtitle" | "mask" + +/* ── Color picker sub-component ── */ +const ColorPicker: React.FC<{ value: string; onChange: (v: string) => void }> = ({ + value, + onChange, +}) => ( +
+ onChange(e.target.value)} /> + onChange(e.target.value)} + maxLength={7} + className="xx-ce-color-hex" + /> +
+) + +/* ── Position pair sub-component ── */ +const PositionPair: React.FC<{ + x: number + y: number + onChange: (pos: { x: number; y: number }) => void +}> = ({ x, y, onChange }) => ( +
+ onChange({ x: v ?? 0, y })} + style={{ width: "100%" }} + /> + onChange({ x, y: v ?? 0 })} + style={{ width: "100%" }} + /> +
+) + +/* ── Font Select options ── */ +const fontOptions = [ + ...PRESET_FONTS.map((f) => ({ + label: ( + + + {f.name} + + ), + value: f.name, + })), + ...SYSTEM_FONTS.map((f) => ({ + label: ( + + + {f.name} + + ), + value: f.name, + })), +] + +/* ── Find font family string from name ── */ +const getFontFamily = (name: string): string => { + const found = ALL_FONTS.find((f) => f.name === name) + return found ? found.family : "sans-serif" } +/* ── Text style panel (shared between title & subtitle) ── */ +const TextStylePanel: React.FC<{ + config: TextStyleConfig + onChange: (c: TextStyleConfig) => void + placeholder: string +}> = ({ config, onChange, placeholder }) => { + const upd = (key: K, val: TextStyleConfig[K]) => + onChange({ ...config, [key]: val }) + const updBg = (key: K, val: TextBackground[K]) => + upd("background", { ...config.background, [key]: val }) + + return ( +
+ {/* 文字内容 */} +
+
{config.text || placeholder}
+
在基本信息中设置文案内容,此处显示拆分结果
+
+ + {/* 字体 */} +
+ + updBg("shape", v as TextBgShape)} + style={{ width: "100%" }} + options={[ + { label: "矩形", value: "rectangle" }, + { label: "自定义多边形", value: "polygon" }, + ]} + /> +
+ + {config.background.shape === "polygon" && ( +
提示: 点击背景区域添加顶点,拖拽调整位置,右键删除顶点
+ )} + + {/* 大小 */} +
+ +
+ updBg("width", v ?? 0)} + style={{ width: "100%" }} + /> + updBg("height", v ?? 0)} + style={{ width: "100%" }} + /> +
+
+ + {/* 位置偏移 */} +
+ + { + updBg("posX", pos.x) + updBg("posY", pos.y) + }} + /> +
+ + {/* 旋转角度 */} +
+ + updBg("rotation", v)} + /> +
+
+ )} + + ) +} + +/* ══════════════ Main Component ══════════════ */ const CoverEditorModal: React.FC = ({ open, onClose, template, onSave }) => { - const [name, setName] = useState(template?.name || "") - const [sections, setSections] = useState({ + const initCfg = template?.config ?? DEFAULT_EDITOR_CONFIG + const [name, setName] = useState(template?.name ?? "") + const [cfg, setCfg] = useState({ ...initCfg }) + const [sections, setSections] = useState>({ basic: true, portrait: false, background: false, @@ -29,34 +397,90 @@ const CoverEditorModal: React.FC = ({ open, onClose, temp subtitle: true, mask: false, }) + const fileRef = useRef(null) - const toggleSection = (key: keyof SectionState) => { - setSections((prev) => ({ ...prev, [key]: !prev[key] })) - } + const upd = useCallback( + (key: K, val: CoverEditorConfig[K]) => + setCfg((prev) => ({ ...prev, [key]: val })), + [], + ) + + const updTitle = useCallback( + (c: TextStyleConfig) => setCfg((prev) => ({ ...prev, title: c })), + [], + ) + const updSubtitle = useCallback( + (c: TextStyleConfig) => setCfg((prev) => ({ ...prev, subtitle: c })), + [], + ) + + const toggle = (key: SectionKey) => setSections((p) => ({ ...p, [key]: !p[key] })) const handleSave = () => { - if (!template) return - onSave({ ...template, name }) + const result: CoverTemplate = template + ? { ...template, name, config: cfg } + : { + id: "", + name, + is_system: false, + thumbnail_url: "", + created_at: "", + config: cfg, + } + onSave(result) onClose() } + /* ── canvas helpers ── */ + const scaleFont = (px: number) => Math.round((px / 1200) * 225 * 100) / 100 + + const renderTextStyle = (tc: TextStyleConfig): React.CSSProperties => { + const style: React.CSSProperties = { + position: "absolute", + left: `${tc.position.x}%`, + top: `${tc.position.y}%`, + transform: `translate(-50%, -50%) rotate(${tc.rotation}deg)`, + fontFamily: getFontFamily(tc.fontFamily), + fontSize: scaleFont(tc.fontSize), + fontWeight: tc.fontWeight, + color: tc.color, + letterSpacing: scaleFont(tc.letterSpacing), + lineHeight: scaleFont(tc.lineHeight), + WebkitTextStroke: + tc.strokeWidth > 0 ? `${scaleFont(tc.strokeWidth)}px ${tc.strokeColor}` : undefined, + whiteSpace: tc.direction === "vertical" ? "pre-wrap" : "nowrap", + writingMode: tc.direction === "vertical" ? "vertical-rl" : undefined, + zIndex: 3, + } + if (tc.shadows.length > 0) { + style.textShadow = tc.shadows + .map( + (s) => + `${scaleFont(s.offsetX)}px ${scaleFont(s.offsetY)}px ${scaleFont(s.blur)}px ${s.color}`, + ) + .join(", ") + } + return style + } + return ( -
+ {/* ── Header ── */} +
setName(e.target.value)} placeholder="输入模板名称" /> -
+
@@ -66,55 +490,425 @@ const CoverEditorModal: React.FC = ({ open, onClose, temp
-
- {/* 左侧折叠面板 */} -
- {[ - { key: "basic" as const, label: "基础设置" }, - { key: "portrait" as const, label: "人像设置" }, - { key: "background" as const, label: "背景设置", toggle: true }, - { key: "title" as const, label: "主标题" }, - { key: "subtitle" as const, label: "副标题" }, - { key: "mask" as const, label: "蒙版", toggle: true }, - ].map((item) => ( -
-
toggleSection(item.key)} - > - {item.label} - {sections[item.key] ? "▾" : "▸"} -
- {sections[item.key] && ( -
- {item.toggle ? ( - - ) : ( - 暂无配置项 + {/* ── Layout ── */} +
+ {/* ── Left Panel ── */} +
+ {/* 1. 基础设置 */} +
+
toggle("basic")}> + 基础设置 + {sections.basic ? "▾" : "▸"} +
+ {sections.basic && ( +
+ {/* 背景模糊 */} +
+
+ 背景模糊 + upd("blurEnabled", v)} + /> +
+ {cfg.blurEnabled && ( +
+ + upd("blurAmount", v)} + /> +
)}
- )} + + {/* 人物描边 */} +
+
+ 人物描边 + upd("personStrokeEnabled", v)} + /> +
+ {cfg.personStrokeEnabled && ( + <> +
+
+ {(["solid", "dashed"] as StrokeStyle[]).map((s) => ( + + ))} +
+
+
+ + upd("personStrokeColor", v)} + /> +
+
+ + upd("personStrokeWidth", v)} + /> +
+ + )} +
+ + {/* 文字自动拆分 */} +
+
+ 文字自动拆分 + upd("autoSplitEnabled", v)} + /> +
+ {cfg.autoSplitEnabled && ( + <> +
+ + upd("titleMaxChars", v)} + /> +
+
+ + upd("subtitleMaxChars", v)} + /> +
+ + )} +
+
+ )} +
+ + {/* 2. 人像设置 */} +
+
toggle("portrait")}> + 人像设置 +
+ 已开启 + upd("portraitEnabled", v)} + onClick={(_, e) => e.stopPropagation()} + /> +
- ))} + {sections.portrait && ( +
+
+ + upd("portraitSize", v)} + /> +
+
+ + upd("portraitPosition", pos)} + /> +
+
+ )} +
+ + {/* 3. 背景设置 */} +
+
toggle("background")}> + 背景设置 +
+ 已开启 + upd("backgroundEnabled", v)} + onClick={(_, e) => e.stopPropagation()} + /> +
+
+ {sections.background && ( +
+
+ + upd("backgroundSize", v)} + /> +
+
+ + upd("backgroundPosition", pos)} + /> +
+
+ )} +
+ + {/* 4. 主标题 */} +
+
toggle("title")}> + 主标题 + {sections.title ? "▾" : "▸"} +
+ {sections.title && ( +
+ +
+ )} +
+ + {/* 5. 副标题 */} +
+
toggle("subtitle")}> + 副标题 + {sections.subtitle ? "▾" : "▸"} +
+ {sections.subtitle && ( +
+ +
+ )} +
+ + {/* 6. 蒙版 */} +
+
toggle("mask")}> + 蒙版 +
+ 已开启 + upd("maskEnabled", v)} + onClick={(_, e) => e.stopPropagation()} + /> +
+
+ {sections.mask && ( +
+
+ +
+ + + { + const f = e.target.files?.[0] + if (f) upd("maskImage", f.name) + }} + /> +
+
+
+ + upd("maskSize", v)} + /> +
+
+ + upd("maskPosition", pos)} + /> +
+
+ + upd("maskColor", v)} /> +
+
+ + upd("maskOpacity", v)} + /> +
+
+ +