Files
xiaoxia-saas/apps/web/src/components/cover/CoverEditorModal.tsx
T
Coze Agent f741a0e76e
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
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 / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (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 / 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 / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m43s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 1m56s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m2s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m3s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 3m27s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 4m7s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Failing after 4m50s
AI Code Review / AI Code Review (pull_request) Successful in 6m58s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 10m20s
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 / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 8m35s
refactor(shared): 抽取封面选择为公共组件CoverSelector,智能剪辑/AI数字人复用
- 新增公共目录 src/components/cover/(在原 useSharedCover/cover.css 基础上补全):
  - CoverSelector.tsx:公共主组件,支持inline/modal两种模式、props(showAutoGenerate/showTemplate/showUpload/canGenerate/aspectRatio/hint/onChange/onAutoGenerate/onTemplateChange等),自管理loading/上传状态
  - CoverSettingsModal.tsx:封面模板选择弹窗(卡片网格+系统标签+编辑/删除)
  - CoverEditorModal.tsx:封面模板自定义编辑器(7面板:基础/人像/背景/主副标题/蒙版)
  - CoverModeSelector/FrameCoverPicker/UploadCoverPicker:子组件
  - types.ts:CoverTemplate/CoverEditorConfig/TextStyleConfig/ALL_FONTS等公共类型+默认值(从pages/generate/types/cover迁移)
  - uploadCover.ts:OSS直传公共工具(uploadCoverToOSS/uploadCoverWithPreview),blob预览→OSS上传两阶段
  - cover.css:新增cs-*前缀样式,保证公共组件自带样式不依赖页面CSS
  - index.ts:barrel统一导出
- 智能剪辑 Step6CoverSettings:
  - 单视频场景改为使用公共CoverSelector(inline模式),自动生成/模板/上传/预览统一
  - 批量场景保留useBatchCovers+卡片网格逻辑,模板选择通过嵌入一个只开showTemplate的CoverSelector实现
  - props解构消除hooks exhaustive-deps警告
- AI数字人 ModalCoverSelect:
  - 由原来自行拼接布局+内联按钮+弹窗改为使用公共CoverSelector(modal模式)
  - 保持AiAvatarCoverConfig回调签名不变
- 旧路径(pages/generate/components/cover-settings/*、pages/generate/types/cover.ts)改为re-export wrapper,保持向后兼容
- api/cover-templates.ts 和 useSharedCover.ts 改为从公共层import类型,消除pages反向依赖
- tsc/eslint/prettier/vite build全通过,CoverSelector独立打包为72KB懒加载chunk
2026-09-27 21:35:34 +08:00

1167 lines
41 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useCallback, useRef, useEffect } from "react"
import { Slider, Switch, Select, InputNumber, Input } from "antd"
import type {
CoverTemplate,
CoverEditorConfig,
TextStyleConfig,
TextDirection,
StrokeStyle,
} from "./types"
import { DEFAULT_EDITOR_CONFIG, ALL_FONTS } from "./types"
import Modal from "@/components/ui/Modal"
import Button from "@/components/ui/Button"
import "@/components/cover/cover.css"
/** 安全合并模板 config 与默认配置(深拷贝 title/subtitle/shadows/background 等嵌套对象,避免共享引用 & 字段缺失) */
const mergeEditorConfig = (partial?: Partial<CoverEditorConfig> | null): CoverEditorConfig => {
const def = DEFAULT_EDITOR_CONFIG
const src = partial || {}
/** 兼容老模板:老版本 background 有 posX/posY/rotation,新版改为 offsetY(相对文字位置偏移)。
* 老模板黑底默认 posY 通常是 50(与文字对齐)或 80(副标题偏下),统一归一为 offsetY=0,
* 因为新版背景位置已自动跟随文字位置,offsetY 仅做相对微调。 */
const normalizeBg = (bg: Record<string, unknown> | undefined) => {
if (!bg) return {}
// 兼容老模板字段:posX/posY/rotation 在新版中已改为 offsetY(背景位置自动跟随文字)
const normalized = { ...bg }
delete (normalized as Record<string, unknown>).posX
delete (normalized as Record<string, unknown>).posY
delete (normalized as Record<string, unknown>).rotation
if (normalized.offsetY == null) normalized.offsetY = 0
return normalized
}
const mergeText = (
base: TextStyleConfig,
patch?: Partial<TextStyleConfig> | null,
): TextStyleConfig => ({
...base,
...(patch || {}),
position: { ...base.position, ...(patch?.position || {}) },
background: {
...base.background,
...normalizeBg(patch?.background as Record<string, unknown> | undefined),
},
shadows: Array.isArray(patch?.shadows) ? [...patch!.shadows] : [...base.shadows],
})
return {
...def,
...src,
title: mergeText(def.title, src.title),
subtitle: mergeText(def.subtitle, src.subtitle),
portraitPosition: { ...def.portraitPosition, ...(src.portraitPosition || {}) },
backgroundPosition: { ...def.backgroundPosition, ...(src.backgroundPosition || {}) },
maskPosition: { ...def.maskPosition, ...(src.maskPosition || {}) },
}
}
/* ── Props ── */
interface CoverEditorModalProps {
open: boolean
onClose: () => void
template: CoverTemplate | null
onSave: (template: CoverTemplate) => void
}
/* ── 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={1}
suffix="%"
controls={false}
onChange={(v) => onChange({ x: v ?? 0, y })}
style={{ width: "100%" }}
/>
<InputNumber
size="small"
value={y}
step={1}
suffix="%"
controls={false}
onChange={(v) => onChange({ x, y: v ?? 0 })}
style={{ width: "100%" }}
/>
</div>
)
/* ── Font Select options(合并预设+系统,用圆点颜色区分 tag) ── */
const fontDotClass = (tag?: string) => {
if (tag === "preset") return "xx-ce-font-dot xx-ce-font-dot--preset"
if (tag === "hand") return "xx-ce-font-dot xx-ce-font-dot--hand"
if (tag === "serif") return "xx-ce-font-dot xx-ce-font-dot--serif"
if (tag === "mono") return "xx-ce-font-dot xx-ce-font-dot--mono"
return "xx-ce-font-dot xx-ce-font-dot--system"
}
const fontOptions = ALL_FONTS.map((f) => ({
label: (
<span>
<span className={fontDotClass(f.tag)} />
<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 | undefined | null
onChange: (c: TextStyleConfig) => void
placeholder: string
}> = ({ config, onChange, placeholder }) => {
if (!config) return null
const upd = <K extends keyof TextStyleConfig>(key: K, val: TextStyleConfig[K]) =>
onChange({ ...config, [key]: val })
return (
<div className="xx-ce-text-panel">
{/* 文字内容 - 改为可输入 */}
<div className="xx-ce-row">
<label className="xx-ce-label">文字内容</label>
<Input.TextArea
value={config.text}
onChange={(e) => upd("text", e.target.value)}
placeholder={placeholder}
autoSize={{ minRows: 1, maxRows: 3 }}
size="small"
maxLength={50}
showCount
/>
</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"
size="small"
/>
</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={{ 400: "常规", 700: "粗", 900: "黑" }}
/>
</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)}
type="button"
>
{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-switch-item">
<div className="xx-ce-switch-row">
<span>文字描边</span>
<Switch
size="small"
checked={config.strokeWidth > 0}
onChange={(v) => upd("strokeWidth", v ? 3 : 0)}
/>
</div>
{config.strokeWidth > 0 && (
<>
<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>
{/* 位置 */}
<div className="xx-ce-row">
<label className="xx-ce-label">位置(X/Y %)</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={-180}
max={180}
step={1}
value={config.rotation}
onChange={(v) => upd("rotation", v)}
/>
</div>
{/* 文字背景(剪映样式) */}
<div className="xx-ce-switch-item">
<div className="xx-ce-switch-row">
<span>文字背景</span>
<Switch
size="small"
checked={config.background?.enabled ?? false}
onChange={(v) => upd("background", { ...config.background, enabled: v })}
/>
</div>
{config.background?.enabled && (
<div style={{ marginTop: 8, paddingLeft: 4 }}>
<div className="xx-ce-row">
<label className="xx-ce-label">形状</label>
<div className="xx-ce-radio-group">
<button
type="button"
className={`xx-ce-radio-btn ${config.background.shape === "rectangle" ? "active" : ""}`}
onClick={() =>
upd("background", {
...config.background,
shape: "rectangle" as const,
})
}
>
矩形
</button>
<button
type="button"
className={`xx-ce-radio-btn ${config.background.shape === "polygon" ? "active" : ""}`}
onClick={() =>
upd("background", {
...config.background,
shape: "polygon" as const,
})
}
>
圆角
</button>
</div>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">背景颜色</label>
<ColorPicker
value={config.background.color || "#000000"}
onChange={(v) => upd("background", { ...config.background, 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) => upd("background", { ...config.background, opacity: v })}
/>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">
圆角:{" "}
{config.background.shape === "rectangle"
? 0
: Math.round((config.background.height ?? 20) / 2)}
px
</label>
<Slider
min={0}
max={50}
step={1}
value={
config.background.shape === "rectangle"
? 0
: Math.round((config.background.height ?? 20) / 2)
}
disabled={config.background.shape === "rectangle"}
onChange={(v) => {
// 圆角近似:通过 height 控制
void v
}}
/>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">宽度: {config.background.width}%</label>
<Slider
min={20}
max={200}
step={2}
value={config.background.width}
onChange={(v) => upd("background", { ...config.background, width: v })}
/>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">高度: {config.background.height}%</label>
<Slider
min={5}
max={80}
step={1}
value={config.background.height}
onChange={(v) => upd("background", { ...config.background, height: v })}
/>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">上下偏移: {config.background.offsetY ?? 0}%</label>
<Slider
min={-30}
max={30}
step={1}
value={config.background.offsetY ?? 0}
onChange={(v) => upd("background", { ...config.background, offsetY: v })}
/>
</div>
</div>
)}
</div>
</div>
)
}
/* ══════════════ Main Component ══════════════ */
const CoverEditorModal: React.FC<CoverEditorModalProps> = ({ open, onClose, template, onSave }) => {
const initCfg = mergeEditorConfig(template?.config)
const [name, setName] = useState(template?.name ?? "")
const [cfg, setCfg] = useState<CoverEditorConfig>(() => mergeEditorConfig(template?.config))
const [sections, setSections] = useState<Record<SectionKey, boolean>>({
basic: true,
portrait: false,
background: true,
title: true,
subtitle: false,
mask: false,
})
const fileRef = useRef<HTMLInputElement>(null)
const bgFileRef = useRef<HTMLInputElement>(null)
const portraitFileRef = useRef<HTMLInputElement>(null)
const [bgImageUrl, setBgImageUrl] = useState<string>(initCfg.backgroundImage || "")
const [portraitImageUrl, setPortraitImageUrl] = useState<string>(initCfg.portraitImage || "")
/* ── 文字拖拽状态 ── */
const canvasRef = useRef<HTMLDivElement | null>(null)
const dragRef = useRef<null | {
target: "title" | "subtitle"
startX: number
startY: number
startPosX: number
startPosY: number
}>(null)
const handleTextMouseDown = (
e: React.MouseEvent<HTMLDivElement>,
target: "title" | "subtitle",
) => {
e.preventDefault()
e.stopPropagation()
const tc = target === "title" ? cfg.title : cfg.subtitle
if (!tc) return
dragRef.current = {
target,
startX: e.clientX,
startY: e.clientY,
startPosX: tc.position.x,
startPosY: tc.position.y,
}
const onMove = (ev: MouseEvent) => {
const d = dragRef.current
if (!d || !canvasRef.current) return
const rect = canvasRef.current.getBoundingClientRect()
const dx = ((ev.clientX - d.startX) / rect.width) * 100
const dy = ((ev.clientY - d.startY) / rect.height) * 100
const newX = Math.max(0, Math.min(100, d.startPosX + dx))
const newY = Math.max(0, Math.min(100, d.startPosY + dy))
if (d.target === "title") {
setCfg((prev) => ({
...prev,
title: { ...prev.title, position: { x: newX, y: newY } },
}))
} else {
setCfg((prev) => ({
...prev,
subtitle: { ...prev.subtitle, position: { x: newX, y: newY } },
}))
}
}
const onUp = () => {
dragRef.current = null
window.removeEventListener("mousemove", onMove)
window.removeEventListener("mouseup", onUp)
}
window.addEventListener("mousemove", onMove)
window.addEventListener("mouseup", onUp)
}
// reset state when modal opens with a new template
useEffect(() => {
if (open) {
const c = mergeEditorConfig(template?.config)
setCfg(c)
setName(template?.name ?? "")
setBgImageUrl(c.backgroundImage || "")
setPortraitImageUrl(c.portraitImage || "")
setSections({
basic: true,
portrait: false,
background: true,
title: true,
subtitle: false,
mask: false,
})
}
}, [open, template])
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 handleFile = (
file: File | undefined,
setter: (url: string) => void,
cfgKey: "backgroundImage" | "portraitImage",
) => {
if (!file) return
const url = URL.createObjectURL(file)
setter(url)
upd(cfgKey, url)
}
const handleSave = () => {
if (!name.trim()) {
// antd message unavailable here, use a simple alert fallback; better import
// but since we don't import message in this file, guard with default
setName(name.trim() || "我的封面模板")
}
const finalName = name.trim() || "我的封面模板"
const result: CoverTemplate = template
? { ...template, name: finalName, config: cfg }
: {
id: "",
name: finalName,
is_system: false,
thumbnail_url: "",
created_at: "",
config: cfg,
}
onSave(result)
onClose()
}
/* ── canvas helpers (scale from 1200px design to 225px preview) ── */
const PREVIEW_W = 225
const DESIGN_W = 1200
const SCALE = PREVIEW_W / DESIGN_W // 0.1875
const s = (px: number) => Math.round(px * SCALE * 100) / 100
const renderTextStyle = (tc: TextStyleConfig | undefined | null): React.CSSProperties => {
if (!tc) return {}
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: s(tc.fontSize),
fontWeight: tc.fontWeight,
color: tc.color,
letterSpacing: s(tc.letterSpacing),
lineHeight: s(tc.lineHeight) > 0 ? `${s(tc.lineHeight)}px` : undefined,
WebkitTextStroke:
tc.strokeWidth > 0 ? `${Math.max(0.5, s(tc.strokeWidth))}px ${tc.strokeColor}` : undefined,
whiteSpace: tc.direction === "vertical" ? "pre-wrap" : "pre",
writingMode: tc.direction === "vertical" ? "vertical-rl" : undefined,
zIndex: 4,
textAlign: "center",
userSelect: "none",
cursor: "grab",
padding: 0,
pointerEvents: "auto",
}
if (tc.shadows.length > 0) {
style.textShadow = tc.shadows
.map((sh) => `${s(sh.offsetX)}px ${s(sh.offsetY)}px ${s(sh.blur)}px ${sh.color}`)
.join(", ")
}
return style
}
const renderTextBgStyle = (tc: TextStyleConfig | undefined | null): React.CSSProperties => {
if (!tc?.background?.enabled) return { display: "none" }
const bg = tc.background
// 背景位置跟随文字:left/top 对齐文字中心,用 offsetY(-50~50% 相对文字位置)做上下微调
// 这样文字拖拽时背景会自动跟随,不需要独立的位置控制
const radius = bg.shape === "polygon" ? `${Math.max(4, Math.round(bg.height / 4))}px` : "0"
const alpha = Math.max(0, Math.min(1, bg.opacity / 100))
const hex = (bg.color || "#000000").replace("#", "")
let r = 0,
g = 0,
b = 0
if (hex.length === 6) {
r = parseInt(hex.substring(0, 2), 16)
g = parseInt(hex.substring(2, 4), 16)
b = parseInt(hex.substring(4, 6), 16)
}
const rgba = `rgba(${r}, ${g}, ${b}, ${alpha})`
// bg.offsetY 是相对文字位置的上下偏移(-50~50,单位%画布高度),默认 0 表示与文字中心对齐
const offsetY = typeof bg.offsetY === "number" ? bg.offsetY : 0
return {
position: "absolute",
left: `${tc.position.x}%`,
top: `calc(${tc.position.y}% + ${offsetY}%)`,
width: `${bg.width}%`,
height: `${bg.height}%`,
transform: "translate(-50%, -50%)",
background: rgba,
borderRadius: radius,
zIndex: 3,
pointerEvents: "none",
}
}
const renderTextLines = (tc: TextStyleConfig | undefined | null): string => {
if (!tc) return ""
const raw = tc.text || ""
if (tc.direction === "vertical") return raw
const per = Math.max(1, tc.charsPerLine)
const lines: string[] = []
for (let i = 0; i < raw.length; i += per) lines.push(raw.slice(i, i + per))
return lines.join("\n")
}
return (
<Modal
open={open}
onCancel={onClose}
width={1080}
title="自定义封面编辑器"
centered
footer={null}
>
{/* ── Header ── */}
<div className="xx-ce-header">
<input
className="xx-ce-name-input"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="输入模板名称(如:我的爆款封面)"
/>
<div className="xx-ce-header-actions">
<Button buttonType="ghost" onClick={onClose}>
取消
</Button>
<Button buttonType="primary" onClick={handleSave}>
保存模板
</Button>
</div>
</div>
{/* ── 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.autoSplitEnabled}
onChange={(v) => upd("autoSplitEnabled", v)}
/>
</div>
{cfg.autoSplitEnabled && (
<>
<div className="xx-ce-row">
<label className="xx-ce-label">主标题每行字数: {cfg.titleMaxChars}</label>
<Slider
min={2}
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={2}
max={20}
step={1}
value={cfg.subtitleMaxChars}
onChange={(v) => upd("subtitleMaxChars", v)}
/>
</div>
</>
)}
</div>
<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>
)}
</div>
{/* 2. 背景 */}
<div className="xx-ce-section">
<div className="xx-ce-section-header" onClick={() => toggle("background")}>
<span>🎨 背景设置</span>
<div className="xx-ce-header-right" onClick={(e) => e.stopPropagation()}>
<Switch
size="small"
checked={cfg.backgroundEnabled}
onChange={(v) => upd("backgroundEnabled", v)}
/>
</div>
</div>
{sections.background && cfg.backgroundEnabled && (
<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={bgImageUrl ? "已选择图片" : "未选择图片"}
/>
<button
className="xx-ce-file-btn"
type="button"
onClick={() => bgFileRef.current?.click()}
>
上传
</button>
{bgImageUrl && (
<button
className="xx-ce-file-btn"
type="button"
onClick={() => {
setBgImageUrl("")
upd("backgroundImage", "")
}}
>
清除
</button>
)}
<input
ref={bgFileRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) =>
handleFile(e.target.files?.[0], setBgImageUrl, "backgroundImage")
}
/>
</div>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">背景颜色(无图片时使用)</label>
<ColorPicker
value={cfg.backgroundColor || "#1a1a2e"}
onChange={(v) => upd("backgroundColor", v)}
/>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">大小: {cfg.backgroundSize}%</label>
<Slider
min={50}
max={150}
step={1}
value={cfg.backgroundSize}
onChange={(v) => upd("backgroundSize", v)}
/>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">位置(X/Y %)</label>
<PositionPair
x={cfg.backgroundPosition.x}
y={cfg.backgroundPosition.y}
onChange={(pos) => upd("backgroundPosition", pos)}
/>
</div>
</div>
)}
</div>
{/* 3. 主标题 */}
<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 || DEFAULT_EDITOR_CONFIG.title}
onChange={updTitle}
placeholder="输入主标题文字"
/>
</div>
)}
</div>
{/* 4. 副标题 */}
<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 || DEFAULT_EDITOR_CONFIG.subtitle}
onChange={updSubtitle}
placeholder="输入副标题文字"
/>
</div>
)}
</div>
{/* 5. 人像 */}
<div className="xx-ce-section">
<div className="xx-ce-section-header" onClick={() => toggle("portrait")}>
<span>👤 人像设置</span>
<div className="xx-ce-header-right" onClick={(e) => e.stopPropagation()}>
<Switch
size="small"
checked={cfg.portraitEnabled}
onChange={(v) => upd("portraitEnabled", v)}
/>
</div>
</div>
{sections.portrait && cfg.portraitEnabled && (
<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={portraitImageUrl ? "已选择图片" : "未选择图片"}
/>
<button
className="xx-ce-file-btn"
type="button"
onClick={() => portraitFileRef.current?.click()}
>
上传
</button>
{portraitImageUrl && (
<button
className="xx-ce-file-btn"
type="button"
onClick={() => {
setPortraitImageUrl("")
upd("portraitImage", "")
}}
>
清除
</button>
)}
<input
ref={portraitFileRef}
type="file"
accept="image/*"
style={{ display: "none" }}
onChange={(e) =>
handleFile(e.target.files?.[0], setPortraitImageUrl, "portraitImage")
}
/>
</div>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">大小: {cfg.portraitSize}%</label>
<Slider
min={10}
max={100}
step={1}
value={cfg.portraitSize}
onChange={(v) => upd("portraitSize", v)}
/>
</div>
<div className="xx-ce-row">
<label className="xx-ce-label">位置(X/Y %)</label>
<PositionPair
x={cfg.portraitPosition.x}
y={cfg.portraitPosition.y}
onChange={(pos) => upd("portraitPosition", pos)}
/>
</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((st) => (
<button
key={st}
className={`xx-ce-radio-btn ${cfg.personStrokeStyle === st ? "active" : ""}`}
onClick={() => upd("personStrokeStyle", st)}
type="button"
>
{st === "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>
)}
</div>
{/* 6. 蒙版 */}
<div className="xx-ce-section">
<div className="xx-ce-section-header" onClick={() => toggle("mask")}>
<span>🌓 蒙版</span>
<div className="xx-ce-header-right" onClick={(e) => e.stopPropagation()}>
<Switch
size="small"
checked={cfg.maskEnabled}
onChange={(v) => upd("maskEnabled", v)}
/>
</div>
</div>
{sections.mask && cfg.maskEnabled && (
<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"
type="button"
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">蒙版颜色</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">大小: {cfg.maskSize}%</label>
<Slider
min={0}
max={100}
step={1}
value={cfg.maskSize}
onChange={(v) => upd("maskSize", v)}
/>
</div>
</div>
)}
</div>
</div>
{/* ── Right Preview ── */}
<div className="xx-ce-right">
<div className="xx-ce-canvas-wrap">
<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" ref={canvasRef}>
{/* base background layer */}
<div
className="xx-ce-canvas-base"
style={{
position: "absolute",
inset: 0,
background: bgImageUrl
? `url(${bgImageUrl}) center/cover no-repeat`
: cfg.backgroundColor
? cfg.backgroundColor
: "linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)",
filter: cfg.blurEnabled ? `blur(${cfg.blurAmount / 4}px)` : undefined,
zIndex: 0,
}}
/>
{/* Background decoration */}
{cfg.backgroundEnabled && !bgImageUrl && (
<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%)",
zIndex: 1,
}}
/>
)}
{/* Portrait */}
{cfg.portraitEnabled && (
<div
className="xx-ce-el-portrait"
style={{
width: `${cfg.portraitSize}%`,
aspectRatio: "3/4",
height: "auto",
left: `${cfg.portraitPosition.x}%`,
top: `${cfg.portraitPosition.y}%`,
transform: "translate(-50%, -50%)",
borderStyle: cfg.personStrokeEnabled ? cfg.personStrokeStyle : "none",
borderColor: cfg.personStrokeEnabled ? cfg.personStrokeColor : "transparent",
borderWidth: cfg.personStrokeEnabled
? `${Math.max(1, s(cfg.personStrokeWidth))}px`
: "0",
background: portraitImageUrl
? `url(${portraitImageUrl}) center/contain no-repeat`
: "linear-gradient(135deg, #a8d4f0, #7ab8e0)",
zIndex: 2,
boxSizing: "border-box",
}}
/>
)}
{/* Title bg */}
{cfg.title?.background?.enabled && <div style={renderTextBgStyle(cfg.title)} />}
{/* Subtitle bg */}
{cfg.subtitle?.background?.enabled && <div style={renderTextBgStyle(cfg.subtitle)} />}
{/* Title (draggable) */}
{cfg.title && (
<div
style={renderTextStyle(cfg.title)}
onMouseDown={(e) => handleTextMouseDown(e, "title")}
>
{renderTextLines(cfg.title)}
</div>
)}
{/* Subtitle (draggable) */}
{cfg.subtitle && (
<div
style={renderTextStyle(cfg.subtitle)}
onMouseDown={(e) => handleTextMouseDown(e, "subtitle")}
>
{renderTextLines(cfg.subtitle)}
</div>
)}
{/* Mask overlay */}
{cfg.maskEnabled && (
<div
className="xx-ce-el-mask"
style={{
backgroundColor: cfg.maskColor,
opacity: cfg.maskOpacity / 100,
}}
/>
)}
</div>
</div>
<div className="xx-ce-preview-tip">👁️ 实时预览(9:16 竖版)</div>
</div>
</div>
</Modal>
)
}
export default CoverEditorModal