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
- 新增公共目录 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
1167 lines
41 KiB
TypeScript
1167 lines
41 KiB
TypeScript
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
|