Compare commits

...

1 Commits

Author SHA1 Message Date
xiaoxia 2ce07fa7bf feat(generate): 封面编辑器完整实现 — 7个面板全部控件 + 右侧画布实时预览
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / PR Build Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m43s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m46s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m44s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m1s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m14s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 2m12s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 4m31s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 5m9s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 1m5s
AI Code Review / AI Code Review (pull_request) Successful in 7m0s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 4m26s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 11m18s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
- 基础设置:背景模糊/人物描边/文字自动拆分,各带开关+子控件(滑块/颜色选择器/按钮组)
- 人像设置:大小滑块 + 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 成功
2026-09-24 11:23:49 +08:00
4 changed files with 1477 additions and 80 deletions
+2 -9
View File
@@ -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<CoverTemplateCreateRequest>
@@ -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,
}) => (
<div className="xx-ce-color-picker">
<input type="color" value={value} onChange={(e) => onChange(e.target.value)} />
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
maxLength={7}
className="xx-ce-color-hex"
/>
</div>
)
/* ── Position pair sub-component ── */
const PositionPair: React.FC<{
x: number
y: number
onChange: (pos: { x: number; y: number }) => void
}> = ({ x, y, onChange }) => (
<div className="xx-ce-position">
<InputNumber
size="small"
value={x}
step={0.1}
suffix="%"
controls={false}
onChange={(v) => onChange({ x: v ?? 0, y })}
style={{ width: "100%" }}
/>
<InputNumber
size="small"
value={y}
step={0.1}
suffix="%"
controls={false}
onChange={(v) => onChange({ x, y: v ?? 0 })}
style={{ width: "100%" }}
/>
</div>
)
/* ── Font Select options ── */
const fontOptions = [
...PRESET_FONTS.map((f) => ({
label: (
<span>
<span className="xx-ce-font-dot xx-ce-font-dot--preset" />
<span style={{ fontFamily: f.family }}>{f.name}</span>
</span>
),
value: f.name,
})),
...SYSTEM_FONTS.map((f) => ({
label: (
<span>
<span className="xx-ce-font-dot xx-ce-font-dot--system" />
<span style={{ fontFamily: f.family }}>{f.name}</span>
</span>
),
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 = <K extends keyof TextStyleConfig>(key: K, val: TextStyleConfig[K]) =>
onChange({ ...config, [key]: val })
const updBg = <K extends keyof TextBackground>(key: K, val: TextBackground[K]) =>
upd("background", { ...config.background, [key]: val })
return (
<div className="xx-ce-text-panel">
{/* 文字内容 */}
<div className="xx-ce-row">
<div className="xx-ce-readonly-text">{config.text || placeholder}</div>
<div className="xx-ce-hint"></div>
</div>
{/* 字体 */}
<div className="xx-ce-row">
<label className="xx-ce-label"></label>
<Select
value={config.fontFamily}
onChange={(v) => upd("fontFamily", v)}
options={fontOptions}
style={{ width: "100%" }}
popupClassName="xx-ce-font-select-dropdown"
/>
<div className="xx-ce-hint">绿= | =35</div>
</div>
{/* 字号 */}
<div className="xx-ce-row">
<label className="xx-ce-label">: {config.fontSize}px</label>
<Slider
min={20}
max={200}
step={1}
value={config.fontSize}
onChange={(v) => upd("fontSize", v)}
/>
</div>
{/* 字重 */}
<div className="xx-ce-row">
<label className="xx-ce-label">: {config.fontWeight}</label>
<Slider
min={100}
max={1000}
step={100}
value={config.fontWeight}
onChange={(v) => upd("fontWeight", v)}
marks={{ 100: "100", 500: "500", 700: "700", 900: "900", 1000: "1000" }}
/>
</div>
{/* 文字方向 */}
<div className="xx-ce-row">
<label className="xx-ce-label"></label>
<div className="xx-ce-radio-group">
{(["horizontal", "vertical"] as TextDirection[]).map((d) => (
<button
key={d}
className={`xx-ce-radio-btn ${config.direction === d ? "active" : ""}`}
onClick={() => upd("direction", d)}
>
{d === "horizontal" ? "横排" : "竖排"}
</button>
))}
</div>
</div>
{/* 每行字数 */}
<div className="xx-ce-row">
<label className="xx-ce-label">: {config.charsPerLine}</label>
<Slider
min={1}
max={20}
step={1}
value={config.charsPerLine}
onChange={(v) => upd("charsPerLine", v)}
/>
</div>
{/* 字符间距 */}
<div className="xx-ce-row">
<label className="xx-ce-label">: {config.letterSpacing}px</label>
<Slider
min={-10}
max={50}
step={1}
value={config.letterSpacing}
onChange={(v) => upd("letterSpacing", v)}
/>
</div>
{/* 行间距 */}
<div className="xx-ce-row">
<label className="xx-ce-label">: {config.lineHeight}px</label>
<Slider
min={0}
max={200}
step={1}
value={config.lineHeight}
onChange={(v) => upd("lineHeight", v)}
/>
</div>
{/* 颜色 */}
<div className="xx-ce-row">
<label className="xx-ce-label"></label>
<ColorPicker value={config.color} onChange={(v) => upd("color", v)} />
</div>
{/* 描边颜色 */}
<div className="xx-ce-row">
<label className="xx-ce-label"></label>
<ColorPicker value={config.strokeColor} onChange={(v) => upd("strokeColor", v)} />
</div>
{/* 描边粗细 */}
<div className="xx-ce-row">
<label className="xx-ce-label">: {config.strokeWidth}px</label>
<Slider
min={0}
max={20}
step={1}
value={config.strokeWidth}
onChange={(v) => upd("strokeWidth", v)}
/>
</div>
{/* 多层阴影 */}
<div className="xx-ce-row">
<label className="xx-ce-label"></label>
<div className="xx-ce-shadow-actions">
<button
className="xx-ce-add-shadow-btn"
onClick={() =>
upd("shadows", [
...config.shadows,
{ color: "#000000", offsetX: 2, offsetY: 2, blur: 4 },
])
}
>
+
</button>
<button className="xx-ce-preset-shadow-btn"></button>
</div>
<div className="xx-ce-sub-row">
<label className="xx-ce-label"></label>
<Switch
size="small"
checked={config.traditionalShadow}
onChange={(v) => upd("traditionalShadow", v)}
/>
</div>
</div>
{/* 位置 */}
<div className="xx-ce-row">
<label className="xx-ce-label"></label>
<PositionPair
x={config.position.x}
y={config.position.y}
onChange={(pos) => upd("position", pos)}
/>
</div>
{/* 旋转角度 */}
<div className="xx-ce-row">
<label className="xx-ce-label">: {config.rotation}°</label>
<Slider
min={0}
max={360}
step={1}
value={config.rotation}
onChange={(v) => upd("rotation", v)}
/>
</div>
{/* 文字背景 */}
<div className="xx-ce-row">
<div className="xx-ce-switch-row">
<label className="xx-ce-label"></label>
<Switch
size="small"
checked={config.background.enabled}
onChange={(v) => updBg("enabled", v)}
/>
</div>
</div>
{config.background.enabled && (
<div className="xx-ce-text-bg-section">
{/* 背景颜色 */}
<div className="xx-ce-row">
<label className="xx-ce-label"></label>
<ColorPicker value={config.background.color} onChange={(v) => updBg("color", v)} />
</div>
{/* 透明度 */}
<div className="xx-ce-row">
<label className="xx-ce-label">: {config.background.opacity}%</label>
<Slider
min={0}
max={100}
step={1}
value={config.background.opacity}
onChange={(v) => updBg("opacity", v)}
/>
</div>
{/* 形状 */}
<div className="xx-ce-row">
<label className="xx-ce-label"></label>
<Select
value={config.background.shape}
onChange={(v) => updBg("shape", v as TextBgShape)}
style={{ width: "100%" }}
options={[
{ label: "矩形", value: "rectangle" },
{ label: "自定义多边形", value: "polygon" },
]}
/>
</div>
{config.background.shape === "polygon" && (
<div className="xx-ce-hint">提示: 点击背景区域添加顶点</div>
)}
{/* 大小 */}
<div className="xx-ce-row">
<label className="xx-ce-label"></label>
<div className="xx-ce-position">
<InputNumber
size="small"
value={config.background.width}
step={0.1}
suffix="%"
controls={false}
onChange={(v) => updBg("width", v ?? 0)}
style={{ width: "100%" }}
/>
<InputNumber
size="small"
value={config.background.height}
step={0.1}
suffix="%"
controls={false}
onChange={(v) => updBg("height", v ?? 0)}
style={{ width: "100%" }}
/>
</div>
</div>
{/* 位置偏移 */}
<div className="xx-ce-row">
<label className="xx-ce-label"></label>
<PositionPair
x={config.background.posX}
y={config.background.posY}
onChange={(pos) => {
updBg("posX", pos.x)
updBg("posY", pos.y)
}}
/>
</div>
{/* 旋转角度 */}
<div className="xx-ce-row">
<label className="xx-ce-label">: {config.background.rotation}°</label>
<Slider
min={0}
max={360}
step={1}
value={config.background.rotation}
onChange={(v) => updBg("rotation", v)}
/>
</div>
</div>
)}
</div>
)
}
/* ══════════════ Main Component ══════════════ */
const CoverEditorModal: React.FC<CoverEditorModalProps> = ({ open, onClose, template, onSave }) => {
const [name, setName] = useState(template?.name || "")
const [sections, setSections] = useState<SectionState>({
const initCfg = template?.config ?? DEFAULT_EDITOR_CONFIG
const [name, setName] = useState(template?.name ?? "")
const [cfg, setCfg] = useState<CoverEditorConfig>({ ...initCfg })
const [sections, setSections] = useState<Record<SectionKey, boolean>>({
basic: true,
portrait: false,
background: false,
@@ -29,34 +397,90 @@ const CoverEditorModal: React.FC<CoverEditorModalProps> = ({ open, onClose, temp
subtitle: true,
mask: false,
})
const fileRef = useRef<HTMLInputElement>(null)
const toggleSection = (key: keyof SectionState) => {
setSections((prev) => ({ ...prev, [key]: !prev[key] }))
}
const upd = useCallback(
<K extends keyof CoverEditorConfig>(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 (
<Modal
open={open}
onCancel={onClose}
width={1000}
width={1050}
title="自定义封面编辑器"
centered
footer={null}
>
<div className="xx-cover-editor-header">
{/* ── Header ── */}
<div className="xx-ce-header">
<input
className="xx-cover-editor-name-input"
className="xx-ce-name-input"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="输入模板名称"
/>
<div className="xx-cover-editor-header-actions">
<div className="xx-ce-header-actions">
<Button buttonType="ghost" onClick={onClose}>
</Button>
@@ -66,55 +490,425 @@ const CoverEditorModal: React.FC<CoverEditorModalProps> = ({ open, onClose, temp
</div>
</div>
<div className="xx-cover-editor-layout">
{/* 左侧折叠面板 */}
<div className="xx-cover-editor-left">
{[
{ 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) => (
<div key={item.key} className="xx-cover-editor-section">
<div
className="xx-cover-editor-section-header"
onClick={() => toggleSection(item.key)}
>
<span>{item.label}</span>
<span>{sections[item.key] ? "▾" : "▸"}</span>
</div>
{sections[item.key] && (
<div className="xx-cover-editor-section-body">
{item.toggle ? (
<label style={{ display: "flex", alignItems: "center", gap: 6 }}>
<input type="checkbox" defaultChecked={false} />
</label>
) : (
<span style={{ color: "var(--text-tertiary)" }}></span>
{/* ── Layout ── */}
<div className="xx-ce-layout">
{/* ── Left Panel ── */}
<div className="xx-ce-left">
{/* 1. 基础设置 */}
<div className="xx-ce-section">
<div className="xx-ce-section-header" onClick={() => toggle("basic")}>
<span></span>
<span>{sections.basic ? "▾" : ""}</span>
</div>
{sections.basic && (
<div className="xx-ce-section-body">
{/* 背景模糊 */}
<div className="xx-ce-switch-item">
<div className="xx-ce-switch-row">
<span></span>
<Switch
size="small"
checked={cfg.blurEnabled}
onChange={(v) => upd("blurEnabled", v)}
/>
</div>
{cfg.blurEnabled && (
<div className="xx-ce-row">
<label className="xx-ce-label">: {cfg.blurAmount}</label>
<Slider
min={0}
max={20}
step={1}
value={cfg.blurAmount}
onChange={(v) => upd("blurAmount", v)}
/>
</div>
)}
</div>
)}
{/* 人物描边 */}
<div className="xx-ce-switch-item">
<div className="xx-ce-switch-row">
<span></span>
<Switch
size="small"
checked={cfg.personStrokeEnabled}
onChange={(v) => upd("personStrokeEnabled", v)}
/>
</div>
{cfg.personStrokeEnabled && (
<>
<div className="xx-ce-row">
<div className="xx-ce-radio-group">
{(["solid", "dashed"] as StrokeStyle[]).map((s) => (
<button
key={s}
className={`xx-ce-radio-btn ${cfg.personStrokeStyle === s ? "active" : ""}`}
onClick={() => upd("personStrokeStyle", s)}
>
{s === "solid" ? "实线" : "虚线"}
</button>
))}
</div>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label"></label>
<ColorPicker
value={cfg.personStrokeColor}
onChange={(v) => upd("personStrokeColor", v)}
/>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">: {cfg.personStrokeWidth}px</label>
<Slider
min={0}
max={20}
step={1}
value={cfg.personStrokeWidth}
onChange={(v) => upd("personStrokeWidth", v)}
/>
</div>
</>
)}
</div>
{/* 文字自动拆分 */}
<div className="xx-ce-switch-item">
<div className="xx-ce-switch-row">
<span></span>
<Switch
size="small"
checked={cfg.autoSplitEnabled}
onChange={(v) => upd("autoSplitEnabled", v)}
/>
</div>
{cfg.autoSplitEnabled && (
<>
<div className="xx-ce-row">
<label className="xx-ce-label">: {cfg.titleMaxChars}</label>
<Slider
min={1}
max={15}
step={1}
value={cfg.titleMaxChars}
onChange={(v) => upd("titleMaxChars", v)}
/>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">
: {cfg.subtitleMaxChars}
</label>
<Slider
min={1}
max={20}
step={1}
value={cfg.subtitleMaxChars}
onChange={(v) => upd("subtitleMaxChars", v)}
/>
</div>
</>
)}
</div>
</div>
)}
</div>
{/* 2. 人像设置 */}
<div className="xx-ce-section">
<div className="xx-ce-section-header" onClick={() => toggle("portrait")}>
<span></span>
<div className="xx-ce-header-right">
<span className="xx-ce-status-text"></span>
<Switch
size="small"
checked={cfg.portraitEnabled}
onChange={(v) => upd("portraitEnabled", v)}
onClick={(_, e) => e.stopPropagation()}
/>
</div>
</div>
))}
{sections.portrait && (
<div className="xx-ce-section-body">
<div className="xx-ce-row">
<label className="xx-ce-label">: {cfg.portraitSize}%</label>
<Slider
min={0}
max={100}
step={1}
value={cfg.portraitSize}
onChange={(v) => upd("portraitSize", v)}
/>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label"></label>
<PositionPair
x={cfg.portraitPosition.x}
y={cfg.portraitPosition.y}
onChange={(pos) => upd("portraitPosition", pos)}
/>
</div>
</div>
)}
</div>
{/* 3. 背景设置 */}
<div className="xx-ce-section">
<div className="xx-ce-section-header" onClick={() => toggle("background")}>
<span></span>
<div className="xx-ce-header-right">
<span className="xx-ce-status-text"></span>
<Switch
size="small"
checked={cfg.backgroundEnabled}
onChange={(v) => upd("backgroundEnabled", v)}
onClick={(_, e) => e.stopPropagation()}
/>
</div>
</div>
{sections.background && (
<div className="xx-ce-section-body">
<div className="xx-ce-row">
<label className="xx-ce-label">: {cfg.backgroundSize}%</label>
<Slider
min={0}
max={100}
step={1}
value={cfg.backgroundSize}
onChange={(v) => upd("backgroundSize", v)}
/>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label"></label>
<PositionPair
x={cfg.backgroundPosition.x}
y={cfg.backgroundPosition.y}
onChange={(pos) => upd("backgroundPosition", pos)}
/>
</div>
</div>
)}
</div>
{/* 4. 主标题 */}
<div className="xx-ce-section">
<div className="xx-ce-section-header" onClick={() => toggle("title")}>
<span></span>
<span>{sections.title ? "▾" : "▸"}</span>
</div>
{sections.title && (
<div className="xx-ce-section-body">
<TextStylePanel config={cfg.title} onChange={updTitle} placeholder="主标题文字" />
</div>
)}
</div>
{/* 5. 副标题 */}
<div className="xx-ce-section">
<div className="xx-ce-section-header" onClick={() => toggle("subtitle")}>
<span></span>
<span>{sections.subtitle ? "▾" : "▸"}</span>
</div>
{sections.subtitle && (
<div className="xx-ce-section-body">
<TextStylePanel
config={cfg.subtitle}
onChange={updSubtitle}
placeholder="副标题文字"
/>
</div>
)}
</div>
{/* 6. 蒙版 */}
<div className="xx-ce-section">
<div className="xx-ce-section-header" onClick={() => toggle("mask")}>
<span></span>
<div className="xx-ce-header-right">
<span className="xx-ce-status-text"></span>
<Switch
size="small"
checked={cfg.maskEnabled}
onChange={(v) => upd("maskEnabled", v)}
onClick={(_, e) => e.stopPropagation()}
/>
</div>
</div>
{sections.mask && (
<div className="xx-ce-section-body">
<div className="xx-ce-row">
<label className="xx-ce-label"></label>
<div className="xx-ce-file-row">
<input
className="xx-ce-file-name"
readOnly
value={cfg.maskImage || "未选择文件"}
/>
<button className="xx-ce-file-btn" onClick={() => fileRef.current?.click()}>
</button>
<input
ref={fileRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) => {
const f = e.target.files?.[0]
if (f) upd("maskImage", f.name)
}}
/>
</div>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">: {cfg.maskSize}%</label>
<Slider
min={0}
max={100}
step={1}
value={cfg.maskSize}
onChange={(v) => upd("maskSize", v)}
/>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label"></label>
<PositionPair
x={cfg.maskPosition.x}
y={cfg.maskPosition.y}
onChange={(pos) => upd("maskPosition", pos)}
/>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label"></label>
<ColorPicker value={cfg.maskColor} onChange={(v) => upd("maskColor", v)} />
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">: {cfg.maskOpacity}%</label>
<Slider
min={0}
max={100}
step={1}
value={cfg.maskOpacity}
onChange={(v) => upd("maskOpacity", v)}
/>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label"></label>
<Select
value={cfg.maskShape}
onChange={(v) => upd("maskShape", v)}
style={{ width: "100%" }}
options={[
{ label: "矩形", value: "矩形" },
{ label: "圆形", value: "圆形" },
{ label: "渐变", value: "渐变" },
]}
/>
</div>
</div>
)}
</div>
</div>
{/* 右侧画布预览 */}
<div className="xx-cover-editor-right">
<div className="xx-cover-editor-canvas">
{/* 人像占位 */}
<div className="xx-cover-editor-portrait">
{/* 四角拖拽手柄 */}
<span className="xx-cover-editor-handle" style={{ top: -4, left: -4 }} />
<span className="xx-cover-editor-handle" style={{ top: -4, right: -4 }} />
<span className="xx-cover-editor-handle" style={{ bottom: -4, left: -4 }} />
<span className="xx-cover-editor-handle" style={{ bottom: -4, right: -4 }} />
{/* ── Right Preview ── */}
<div className="xx-ce-right">
<div className="xx-ce-canvas-wrap">
{/* decorative anchor dots */}
<span className="xx-ce-anchor-dot" style={{ top: "10%", left: "-12px" }} />
<span className="xx-ce-anchor-dot" style={{ top: "50%", right: "-12px" }} />
<span className="xx-ce-anchor-dot" style={{ bottom: "10%", left: "-12px" }} />
<div className="xx-ce-canvas">
{/* Background */}
{cfg.backgroundEnabled && (
<div
className="xx-ce-el-bg"
style={{
width: `${cfg.backgroundSize}%`,
height: `${cfg.backgroundSize}%`,
left: `${cfg.backgroundPosition.x}%`,
top: `${cfg.backgroundPosition.y}%`,
transform: "translate(-50%, -50%)",
}}
/>
)}
{/* Portrait placeholder */}
{cfg.portraitEnabled && (
<div
className="xx-ce-el-portrait"
style={{
width: `${cfg.portraitSize}%`,
height: `${cfg.portraitSize * 0.6}%`,
left: `${cfg.portraitPosition.x}%`,
top: `${cfg.portraitPosition.y}%`,
transform: "translate(-50%, -50%)",
borderStyle: cfg.personStrokeEnabled ? cfg.personStrokeStyle : "solid",
borderColor: cfg.personStrokeEnabled ? cfg.personStrokeColor : "#333",
borderWidth: cfg.personStrokeEnabled
? `${Math.max(1, cfg.personStrokeWidth / 4)}px`
: "2px",
filter: cfg.blurEnabled ? `blur(${cfg.blurAmount / 4}px)` : undefined,
}}
>
{/* 8 handles */}
{[0, 1, 2, 3, 4, 5, 6, 7].map((i) => (
<span key={i} className={`xx-ce-handle xx-ce-handle--${i}`} />
))}
</div>
)}
{/* Title */}
<div style={renderTextStyle(cfg.title)}>
{cfg.title.background.enabled && (
<div
className="xx-ce-text-bg"
style={{
backgroundColor: cfg.title.background.color,
opacity: cfg.title.background.opacity / 100,
width: `${cfg.title.background.width}%`,
height: `${cfg.title.background.height}%`,
left: `${cfg.title.background.posX}%`,
top: `${cfg.title.background.posY}%`,
transform: `translate(-50%, -50%) rotate(${cfg.title.background.rotation}deg)`,
position: "absolute",
border:
cfg.title.background.shape === "polygon" ? "1px dashed #999" : undefined,
}}
/>
)}
{cfg.title.text || "主标题文字"}
</div>
{/* Subtitle */}
<div style={renderTextStyle(cfg.subtitle)}>
{cfg.subtitle.background.enabled && (
<div
className="xx-ce-text-bg"
style={{
backgroundColor: cfg.subtitle.background.color,
opacity: cfg.subtitle.background.opacity / 100,
width: `${cfg.subtitle.background.width}%`,
height: `${cfg.subtitle.background.height}%`,
left: `${cfg.subtitle.background.posX}%`,
top: `${cfg.subtitle.background.posY}%`,
transform: `translate(-50%, -50%) rotate(${cfg.subtitle.background.rotation}deg)`,
position: "absolute",
}}
/>
)}
{cfg.subtitle.text || "副标题文字"}
</div>
{/* Mask overlay */}
{cfg.maskEnabled && (
<div
className="xx-ce-el-mask"
style={{
backgroundColor: cfg.maskColor,
opacity: cfg.maskOpacity / 100,
}}
/>
)}
</div>
{/* 文字占位 */}
<div className="xx-cover-editor-title-placeholder"></div>
<div className="xx-cover-editor-subtitle-placeholder"></div>
</div>
</div>
</div>
+385
View File
@@ -3335,3 +3335,388 @@
grid-template-columns: minmax(0, 360px);
}
}
/* ================================================================
自定义封面编辑器 (Cover Editor Modal) — xx-ce-*
================================================================ */
/* Header */
.xx-ce-header {
margin-bottom: 16px;
}
.xx-ce-name-input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--border-color, #e5e7eb);
border-radius: var(--radius-sm, 6px);
font-size: 14px;
margin-bottom: 12px;
outline: none;
}
.xx-ce-name-input:focus {
border-color: #7c3aed;
}
.xx-ce-header-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
/* Layout */
.xx-ce-layout {
display: flex;
gap: 20px;
min-height: 500px;
}
.xx-ce-left {
width: 300px;
flex-shrink: 0;
max-height: 70vh;
overflow-y: auto;
}
.xx-ce-right {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
border-radius: 8px;
min-height: 480px;
}
/* Section / collapsible panels */
.xx-ce-section {
border: 1px solid var(--border-color, #e5e7eb);
border-radius: 6px;
margin-bottom: 8px;
}
.xx-ce-section-header {
padding: 10px 12px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
background: #f0f4ff;
user-select: none;
}
.xx-ce-section-header:hover {
background: #e8edf8;
}
.xx-ce-section-body {
padding: 12px;
font-size: 12px;
color: var(--text-secondary, #666);
}
.xx-ce-header-right {
display: flex;
align-items: center;
gap: 6px;
}
.xx-ce-status-text {
font-size: 11px;
font-weight: 400;
color: #3b82f6;
}
/* Rows / labels */
.xx-ce-row {
margin: 12px 0;
}
.xx-ce-label {
display: block;
font-size: 12px;
color: #374151;
margin-bottom: 4px;
}
.xx-ce-hint {
font-size: 11px;
color: #9ca3af;
margin-top: 4px;
}
.xx-ce-sub-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
}
.xx-ce-switch-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.xx-ce-switch-item {
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #f3f4f6;
}
.xx-ce-switch-item:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
/* Color picker */
.xx-ce-color-picker {
display: flex;
align-items: center;
gap: 6px;
}
.xx-ce-color-picker input[type="color"] {
width: 32px;
height: 24px;
padding: 0;
border: 1px solid #d1d5db;
border-radius: 4px;
cursor: pointer;
background: none;
}
.xx-ce-color-picker input[type="color"]::-webkit-color-swatch-wrapper {
padding: 1px;
}
.xx-ce-color-picker input[type="color"]::-webkit-color-swatch {
border: none;
border-radius: 2px;
}
.xx-ce-color-hex {
width: 70px;
padding: 2px 6px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 12px;
font-family: monospace;
}
/* Position pair */
.xx-ce-position {
display: flex;
gap: 8px;
}
.xx-ce-position .ant-input-number {
flex: 1;
}
/* Radio button group */
.xx-ce-radio-group {
display: flex;
gap: 0;
}
.xx-ce-radio-btn {
padding: 4px 14px;
font-size: 12px;
border: 1px solid #d1d5db;
background: #fff;
color: #374151;
cursor: pointer;
transition: all 0.15s;
}
.xx-ce-radio-btn:first-child {
border-radius: 4px 0 0 4px;
}
.xx-ce-radio-btn:last-child {
border-radius: 0 4px 4px 0;
}
.xx-ce-radio-btn + .xx-ce-radio-btn {
border-left: none;
}
.xx-ce-radio-btn.active {
background: #7c3aed;
color: #fff;
border-color: #7c3aed;
}
.xx-ce-radio-btn.active + .xx-ce-radio-btn {
border-left: 1px solid #d1d5db;
}
/* Font select dots */
.xx-ce-font-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
vertical-align: middle;
}
.xx-ce-font-dot--preset {
background: #10b981;
}
.xx-ce-font-dot--system {
background: #3b82f6;
}
/* Shadow actions */
.xx-ce-shadow-actions {
display: flex;
gap: 8px;
margin-top: 4px;
}
.xx-ce-add-shadow-btn {
padding: 4px 10px;
font-size: 12px;
background: #7c3aed;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.xx-ce-add-shadow-btn:hover {
background: #6d28d9;
}
.xx-ce-preset-shadow-btn {
padding: 4px 10px;
font-size: 12px;
background: #fff;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 4px;
cursor: pointer;
}
/* Text background sub-section */
.xx-ce-text-bg-section {
margin-top: 8px;
padding: 8px;
background: #fafafa;
border-radius: 4px;
border: 1px solid #f0f0f0;
}
/* Readonly text display */
.xx-ce-readonly-text {
padding: 6px 10px;
background: #eff6ff;
border-radius: 4px;
font-size: 13px;
color: #1e40af;
margin-bottom: 4px;
}
/* Mask file row */
.xx-ce-file-row {
display: flex;
gap: 6px;
align-items: center;
}
.xx-ce-file-name {
flex: 1;
padding: 4px 8px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 12px;
background: #f9fafb;
color: #6b7280;
}
.xx-ce-file-btn {
padding: 4px 10px;
font-size: 12px;
background: #fff;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
}
.xx-ce-file-btn:hover {
border-color: #7c3aed;
color: #7c3aed;
}
/* ── Canvas / Preview ── */
.xx-ce-canvas-wrap {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.xx-ce-canvas {
width: 225px;
height: 400px;
background: #ddd;
position: relative;
border-radius: 8px;
overflow: hidden;
}
.xx-ce-anchor-dot {
position: absolute;
width: 8px;
height: 8px;
background: #ef4444;
border-radius: 50%;
z-index: 5;
}
/* Portrait element */
.xx-ce-el-portrait {
position: absolute;
background: #a8d4f0;
border: 2px solid #333;
z-index: 2;
}
/* 8 handles: 0=TL 1=T 2=TR 3=R 4=BR 5=B 6=BL 7=L */
.xx-ce-handle {
position: absolute;
width: 8px;
height: 8px;
background: #3b82f6;
border: 1px solid #fff;
z-index: 10;
}
.xx-ce-handle--0 { top: -4px; left: -4px; }
.xx-ce-handle--1 { top: -4px; left: 50%; margin-left: -4px; }
.xx-ce-handle--2 { top: -4px; right: -4px; }
.xx-ce-handle--3 { top: 50%; right: -4px; margin-top: -4px; }
.xx-ce-handle--4 { bottom: -4px; right: -4px; }
.xx-ce-handle--5 { bottom: -4px; left: 50%; margin-left: -4px; }
.xx-ce-handle--6 { bottom: -4px; left: -4px; }
.xx-ce-handle--7 { top: 50%; left: -4px; margin-top: -4px; }
/* Background element */
.xx-ce-el-bg {
position: absolute;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
z-index: 1;
}
/* Mask overlay */
.xx-ce-el-mask {
position: absolute;
inset: 0;
z-index: 4;
pointer-events: none;
}
/* Text background shape in canvas */
.xx-ce-text-bg {
position: absolute;
z-index: -1;
}
/* Antd Slider overrides for editor */
.xx-ce-section-body .ant-slider {
margin: 4px 0 8px;
}
.xx-ce-section-body .ant-slider-rail {
background: #e5e7eb;
}
.xx-ce-section-body .ant-slider-track {
background: #3b82f6;
}
.xx-ce-section-body .ant-slider-handle::after {
box-shadow: 0 0 0 2px #3b82f6;
}
.xx-ce-section-body .ant-slider-mark-text {
font-size: 10px;
}
/* Antd Select dropdown font dots */
.xx-ce-font-select-dropdown .ant-select-item-option-content {
display: flex;
align-items: center;
}
/* Canvas text elements — ensure proper stacking */
.xx-ce-canvas > div {
pointer-events: none;
}
+233 -8
View File
@@ -32,6 +32,238 @@ export const DEFAULT_COVER_CONFIG: CoverConfig = {
thumbnail_url: "",
}
/** 文字方向 */
export type TextDirection = "horizontal" | "vertical"
/** 文字背景形状 */
export type TextBgShape = "rectangle" | "polygon"
/** 描边样式 */
export type StrokeStyle = "solid" | "dashed"
/** 阴影层 */
export interface ShadowLayer {
color: string
offsetX: number
offsetY: number
blur: number
}
/** 文字位置 */
export interface TextPosition {
x: number
y: number
}
/** 文字背景配置 */
export interface TextBackground {
enabled: boolean
color: string
opacity: number
shape: TextBgShape
width: number
height: number
posX: number
posY: number
rotation: number
}
/** 文字样式配置(主标题/副标题共用) */
export interface TextStyleConfig {
text: string
fontFamily: string
fontSize: number
fontWeight: number
direction: TextDirection
charsPerLine: number
letterSpacing: number
lineHeight: number
color: string
strokeColor: string
strokeWidth: number
shadows: ShadowLayer[]
traditionalShadow: boolean
position: TextPosition
rotation: number
background: TextBackground
}
/** 编辑器完整配置 */
export interface CoverEditorConfig {
// 基础设置
blurEnabled: boolean
blurAmount: number
personStrokeEnabled: boolean
personStrokeStyle: StrokeStyle
personStrokeColor: string
personStrokeWidth: number
autoSplitEnabled: boolean
titleMaxChars: number
subtitleMaxChars: number
// 人像设置
portraitEnabled: boolean
portraitSize: number
portraitPosition: TextPosition
// 背景设置
backgroundEnabled: boolean
backgroundSize: number
backgroundPosition: TextPosition
// 主标题
title: TextStyleConfig
// 副标题
subtitle: TextStyleConfig
// 蒙版
maskEnabled: boolean
maskImage: string
maskSize: number
maskPosition: TextPosition
maskColor: string
maskOpacity: number
maskShape: string
}
/** 默认主标题配置 */
export const DEFAULT_TITLE_CONFIG: TextStyleConfig = {
text: "主标题文字",
fontFamily: "思源黑体",
fontSize: 120,
fontWeight: 700,
direction: "horizontal",
charsPerLine: 10,
letterSpacing: 24,
lineHeight: 144,
color: "#FFD700",
strokeColor: "#000000",
strokeWidth: 3,
shadows: [],
traditionalShadow: false,
position: { x: 50, y: 30 },
rotation: 0,
background: {
enabled: false,
color: "#FFFFFF",
opacity: 25,
shape: "polygon",
width: 30,
height: 10,
posX: 50,
posY: 50,
rotation: 0,
},
}
/** 默认副标题配置 */
export const DEFAULT_SUBTITLE_CONFIG: TextStyleConfig = {
text: "副标题文字",
fontFamily: "思源黑体",
fontSize: 82,
fontWeight: 500,
direction: "horizontal",
charsPerLine: 17,
letterSpacing: 23,
lineHeight: 72,
color: "#FFFFFF",
strokeColor: "#000000",
strokeWidth: 1,
shadows: [],
traditionalShadow: false,
position: { x: 50, y: 70 },
rotation: 0,
background: {
enabled: true,
color: "#000000",
opacity: 70,
shape: "rectangle",
width: 100,
height: 20,
posX: 50,
posY: 80,
rotation: 0,
},
}
/** 默认编辑器配置 */
export const DEFAULT_EDITOR_CONFIG: CoverEditorConfig = {
blurEnabled: false,
blurAmount: 10,
personStrokeEnabled: false,
personStrokeStyle: "solid",
personStrokeColor: "#FFFFFF",
personStrokeWidth: 8,
autoSplitEnabled: false,
titleMaxChars: 4,
subtitleMaxChars: 10,
portraitEnabled: true,
portraitSize: 80,
portraitPosition: { x: 50, y: 50 },
backgroundEnabled: true,
backgroundSize: 90,
backgroundPosition: { x: 50, y: 50 },
title: DEFAULT_TITLE_CONFIG,
subtitle: DEFAULT_SUBTITLE_CONFIG,
maskEnabled: true,
maskImage: "",
maskSize: 100,
maskPosition: { x: 50, y: 50 },
maskColor: "#000000",
maskOpacity: 100,
maskShape: "矩形",
}
/** 预置字体 */
export const PRESET_FONTS = [
{ name: "思源黑体", family: "'Noto Sans SC', sans-serif" },
{ name: "斗鱼追光体2.0", family: "'DouYu ZhuangGuangTi', sans-serif" },
{ name: "抖音美好体", family: "'DouYin MeiHaoTi', sans-serif" },
]
/** 系统字体 */
export const SYSTEM_FONTS = [
{ name: "Arial", family: "Arial, sans-serif" },
{ name: "Helvetica", family: "Helvetica, sans-serif" },
{ name: "Times New Roman", family: "'Times New Roman', serif" },
{ name: "Georgia", family: "Georgia, serif" },
{ name: "Verdana", family: "Verdana, sans-serif" },
{ name: "Tahoma", family: "Tahoma, sans-serif" },
{ name: "Impact", family: "Impact, sans-serif" },
{ name: "Comic Sans MS", family: "'Comic Sans MS', cursive" },
{ name: "Courier New", family: "'Courier New', monospace" },
{ name: "微软雅黑", family: "'Microsoft YaHei', sans-serif" },
{ name: "宋体", family: "SimSun, serif" },
{ name: "黑体", family: "SimHei, sans-serif" },
{ name: "楷体", family: "KaiTi, serif" },
{ name: "仿宋", family: "FangSong, serif" },
{ name: "Trebuchet MS", family: "'Trebuchet MS', sans-serif" },
{ name: "Lucida Console", family: "'Lucida Console', monospace" },
{ name: "Palatino", family: "Palatino, serif" },
{ name: "Garamond", family: "Garamond, serif" },
{ name: "Bookman", family: "Bookman, serif" },
{ name: "Avant Garde", family: "'Avant Garde', sans-serif" },
{ name: "Calibri", family: "Calibri, sans-serif" },
{ name: "Cambria", family: "Cambria, serif" },
{ name: "Candara", family: "Candara, sans-serif" },
{ name: "Consolas", family: "Consolas, monospace" },
{ name: "Constantia", family: "Constantia, serif" },
{ name: "Corbel", family: "Corbel, sans-serif" },
{ name: "Franklin Gothic", family: "'Franklin Gothic', sans-serif" },
{ name: "Gill Sans", family: "'Gill Sans', sans-serif" },
{ name: "Optima", family: "Optima, sans-serif" },
{ name: "Futura", family: "Futura, sans-serif" },
{ name: "Rockwell", family: "Rockwell, serif" },
]
/** 所有字体列表 */
export const ALL_FONTS = [...PRESET_FONTS, ...SYSTEM_FONTS]
/** 封面模板 */
export interface CoverTemplate {
id: string
@@ -39,12 +271,5 @@ export interface CoverTemplate {
thumbnail_url: string
is_system: boolean
created_at: string
config?: {
background_enabled?: boolean
background_color?: string
portrait_enabled?: boolean
title_text?: string
subtitle_text?: string
mask_enabled?: boolean
}
config?: CoverEditorConfig
}