Files
xiaoxia-saas/apps/web/src/pages/generate/components/cover-settings/CoverEditorModal.tsx
T
xiaoxia c32065207a
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 12s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (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 / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 43s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m0s
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 / PR Build Web Image (pull_request) Successful in 1m34s
CI/CD Pipeline / Build Staging API Image (push) Successful in 54s
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 / Build Staging Web Image (push) Successful in 40s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m23s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m25s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 4m34s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 5m6s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m23s
CI/CD Pipeline / Validate - Style (push) Successful in 5m43s
CI/CD Pipeline / CI Gate (pull_request) Successful in 1s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) 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 / Canary Release to Production (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 7m2s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 7m21s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 55s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 2m16s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m48s
CI/CD Pipeline / Validate - Security (push) Successful in 12m13s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m9s
CI/CD Pipeline / Unit Tests (push) Successful in 13m46s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
feat(generate): 封面编辑器完整实现 — 7个面板全部控件 + 右侧画布实时预览 (#2025)
feat: 封面编辑器完整实现
2026-09-24 11:28:34 +08:00

920 lines
32 KiB
TypeScript

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
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={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 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,
title: true,
subtitle: true,
mask: false,
})
const fileRef = useRef<HTMLInputElement>(null)
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 = () => {
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={1050}
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.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>
{/* ── 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>
</div>
</div>
</Modal>
)
}
export default CoverEditorModal