Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ce07fa7bf |
@@ -3,7 +3,7 @@
|
||||
* 后端路由: /api/v1/cover-templates
|
||||
*/
|
||||
import apiClient from "./client"
|
||||
import type { CoverTemplate } from "@/pages/generate/types/cover"
|
||||
import type { CoverTemplate, CoverEditorConfig } from "@/pages/generate/types/cover"
|
||||
|
||||
export interface CoverTemplateListResponse {
|
||||
items: CoverTemplate[]
|
||||
@@ -12,14 +12,7 @@ export interface CoverTemplateListResponse {
|
||||
|
||||
export interface CoverTemplateCreateRequest {
|
||||
name: string
|
||||
config?: {
|
||||
background_enabled?: boolean
|
||||
background_color?: string
|
||||
portrait_enabled?: boolean
|
||||
title_text?: string
|
||||
subtitle_text?: string
|
||||
mask_enabled?: boolean
|
||||
}
|
||||
config?: CoverEditorConfig
|
||||
}
|
||||
|
||||
export type CoverTemplateUpdateRequest = Partial<CoverTemplateCreateRequest>
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
import React, { useState } from "react"
|
||||
import type { CoverTemplate } from "../../types/cover"
|
||||
import React, { useState, useCallback, useRef } from "react"
|
||||
import { Slider, Switch, Select, InputNumber } from "antd"
|
||||
import type {
|
||||
CoverTemplate,
|
||||
CoverEditorConfig,
|
||||
TextStyleConfig,
|
||||
TextBackground,
|
||||
TextDirection,
|
||||
StrokeStyle,
|
||||
TextBgShape,
|
||||
} from "../../types/cover"
|
||||
import { DEFAULT_EDITOR_CONFIG, PRESET_FONTS, SYSTEM_FONTS, ALL_FONTS } from "../../types/cover"
|
||||
import Modal from "@/components/ui/Modal"
|
||||
import Button from "@/components/ui/Button"
|
||||
|
||||
/* ── Props ── */
|
||||
interface CoverEditorModalProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
@@ -10,18 +21,375 @@ interface CoverEditorModalProps {
|
||||
onSave: (template: CoverTemplate) => void
|
||||
}
|
||||
|
||||
interface SectionState {
|
||||
basic: boolean
|
||||
portrait: boolean
|
||||
background: boolean
|
||||
title: boolean
|
||||
subtitle: boolean
|
||||
mask: boolean
|
||||
/* ── Section expand/collapse keys ── */
|
||||
type SectionKey = "basic" | "portrait" | "background" | "title" | "subtitle" | "mask"
|
||||
|
||||
/* ── Color picker sub-component ── */
|
||||
const ColorPicker: React.FC<{ value: string; onChange: (v: string) => void }> = ({
|
||||
value,
|
||||
onChange,
|
||||
}) => (
|
||||
<div className="xx-ce-color-picker">
|
||||
<input type="color" value={value} onChange={(e) => onChange(e.target.value)} />
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
maxLength={7}
|
||||
className="xx-ce-color-hex"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
/* ── Position pair sub-component ── */
|
||||
const PositionPair: React.FC<{
|
||||
x: number
|
||||
y: number
|
||||
onChange: (pos: { x: number; y: number }) => void
|
||||
}> = ({ x, y, onChange }) => (
|
||||
<div className="xx-ce-position">
|
||||
<InputNumber
|
||||
size="small"
|
||||
value={x}
|
||||
step={0.1}
|
||||
suffix="%"
|
||||
controls={false}
|
||||
onChange={(v) => onChange({ x: v ?? 0, y })}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
<InputNumber
|
||||
size="small"
|
||||
value={y}
|
||||
step={0.1}
|
||||
suffix="%"
|
||||
controls={false}
|
||||
onChange={(v) => onChange({ x, y: v ?? 0 })}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
/* ── Font Select options ── */
|
||||
const fontOptions = [
|
||||
...PRESET_FONTS.map((f) => ({
|
||||
label: (
|
||||
<span>
|
||||
<span className="xx-ce-font-dot xx-ce-font-dot--preset" />
|
||||
<span style={{ fontFamily: f.family }}>{f.name}</span>
|
||||
</span>
|
||||
),
|
||||
value: f.name,
|
||||
})),
|
||||
...SYSTEM_FONTS.map((f) => ({
|
||||
label: (
|
||||
<span>
|
||||
<span className="xx-ce-font-dot xx-ce-font-dot--system" />
|
||||
<span style={{ fontFamily: f.family }}>{f.name}</span>
|
||||
</span>
|
||||
),
|
||||
value: f.name,
|
||||
})),
|
||||
]
|
||||
|
||||
/* ── Find font family string from name ── */
|
||||
const getFontFamily = (name: string): string => {
|
||||
const found = ALL_FONTS.find((f) => f.name === name)
|
||||
return found ? found.family : "sans-serif"
|
||||
}
|
||||
|
||||
/* ── Text style panel (shared between title & subtitle) ── */
|
||||
const TextStylePanel: React.FC<{
|
||||
config: TextStyleConfig
|
||||
onChange: (c: TextStyleConfig) => void
|
||||
placeholder: string
|
||||
}> = ({ config, onChange, placeholder }) => {
|
||||
const upd = <K extends keyof TextStyleConfig>(key: K, val: TextStyleConfig[K]) =>
|
||||
onChange({ ...config, [key]: val })
|
||||
const updBg = <K extends keyof TextBackground>(key: K, val: TextBackground[K]) =>
|
||||
upd("background", { ...config.background, [key]: val })
|
||||
|
||||
return (
|
||||
<div className="xx-ce-text-panel">
|
||||
{/* 文字内容 */}
|
||||
<div className="xx-ce-row">
|
||||
<div className="xx-ce-readonly-text">{config.text || placeholder}</div>
|
||||
<div className="xx-ce-hint">在基本信息中设置文案内容,此处显示拆分结果</div>
|
||||
</div>
|
||||
|
||||
{/* 字体 */}
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">字体</label>
|
||||
<Select
|
||||
value={config.fontFamily}
|
||||
onChange={(v) => upd("fontFamily", v)}
|
||||
options={fontOptions}
|
||||
style={{ width: "100%" }}
|
||||
popupClassName="xx-ce-font-select-dropdown"
|
||||
/>
|
||||
<div className="xx-ce-hint">绿色圆点=预置字体 | 蓝色圆点=系统字体(35种可用字体)</div>
|
||||
</div>
|
||||
|
||||
{/* 字号 */}
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">字号: {config.fontSize}px</label>
|
||||
<Slider
|
||||
min={20}
|
||||
max={200}
|
||||
step={1}
|
||||
value={config.fontSize}
|
||||
onChange={(v) => upd("fontSize", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 字重 */}
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">字重: {config.fontWeight}</label>
|
||||
<Slider
|
||||
min={100}
|
||||
max={1000}
|
||||
step={100}
|
||||
value={config.fontWeight}
|
||||
onChange={(v) => upd("fontWeight", v)}
|
||||
marks={{ 100: "100", 500: "500", 700: "700", 900: "900", 1000: "1000" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 文字方向 */}
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">文字方向</label>
|
||||
<div className="xx-ce-radio-group">
|
||||
{(["horizontal", "vertical"] as TextDirection[]).map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
className={`xx-ce-radio-btn ${config.direction === d ? "active" : ""}`}
|
||||
onClick={() => upd("direction", d)}
|
||||
>
|
||||
{d === "horizontal" ? "横排" : "竖排"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 每行字数 */}
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">每行字数: {config.charsPerLine}</label>
|
||||
<Slider
|
||||
min={1}
|
||||
max={20}
|
||||
step={1}
|
||||
value={config.charsPerLine}
|
||||
onChange={(v) => upd("charsPerLine", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 字符间距 */}
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">字符间距: {config.letterSpacing}px</label>
|
||||
<Slider
|
||||
min={-10}
|
||||
max={50}
|
||||
step={1}
|
||||
value={config.letterSpacing}
|
||||
onChange={(v) => upd("letterSpacing", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 行间距 */}
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">行间距: {config.lineHeight}px</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={200}
|
||||
step={1}
|
||||
value={config.lineHeight}
|
||||
onChange={(v) => upd("lineHeight", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 颜色 */}
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">颜色</label>
|
||||
<ColorPicker value={config.color} onChange={(v) => upd("color", v)} />
|
||||
</div>
|
||||
|
||||
{/* 描边颜色 */}
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">描边颜色</label>
|
||||
<ColorPicker value={config.strokeColor} onChange={(v) => upd("strokeColor", v)} />
|
||||
</div>
|
||||
|
||||
{/* 描边粗细 */}
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">描边粗细: {config.strokeWidth}px</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={20}
|
||||
step={1}
|
||||
value={config.strokeWidth}
|
||||
onChange={(v) => upd("strokeWidth", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 多层阴影 */}
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">多层阴影</label>
|
||||
<div className="xx-ce-shadow-actions">
|
||||
<button
|
||||
className="xx-ce-add-shadow-btn"
|
||||
onClick={() =>
|
||||
upd("shadows", [
|
||||
...config.shadows,
|
||||
{ color: "#000000", offsetX: 2, offsetY: 2, blur: 4 },
|
||||
])
|
||||
}
|
||||
>
|
||||
+ 添加阴影层
|
||||
</button>
|
||||
<button className="xx-ce-preset-shadow-btn">预设效果</button>
|
||||
</div>
|
||||
<div className="xx-ce-sub-row">
|
||||
<label className="xx-ce-label">传统阴影</label>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={config.traditionalShadow}
|
||||
onChange={(v) => upd("traditionalShadow", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 位置 */}
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">位置</label>
|
||||
<PositionPair
|
||||
x={config.position.x}
|
||||
y={config.position.y}
|
||||
onChange={(pos) => upd("position", pos)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 旋转角度 */}
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">旋转角度: {config.rotation}°</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={360}
|
||||
step={1}
|
||||
value={config.rotation}
|
||||
onChange={(v) => upd("rotation", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 文字背景 */}
|
||||
<div className="xx-ce-row">
|
||||
<div className="xx-ce-switch-row">
|
||||
<label className="xx-ce-label">文字背景</label>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={config.background.enabled}
|
||||
onChange={(v) => updBg("enabled", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.background.enabled && (
|
||||
<div className="xx-ce-text-bg-section">
|
||||
{/* 背景颜色 */}
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">背景颜色</label>
|
||||
<ColorPicker value={config.background.color} onChange={(v) => updBg("color", v)} />
|
||||
</div>
|
||||
|
||||
{/* 透明度 */}
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">透明度: {config.background.opacity}%</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={config.background.opacity}
|
||||
onChange={(v) => updBg("opacity", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 形状 */}
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">形状</label>
|
||||
<Select
|
||||
value={config.background.shape}
|
||||
onChange={(v) => updBg("shape", v as TextBgShape)}
|
||||
style={{ width: "100%" }}
|
||||
options={[
|
||||
{ label: "矩形", value: "rectangle" },
|
||||
{ label: "自定义多边形", value: "polygon" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{config.background.shape === "polygon" && (
|
||||
<div className="xx-ce-hint">提示: 点击背景区域添加顶点,拖拽调整位置,右键删除顶点</div>
|
||||
)}
|
||||
|
||||
{/* 大小 */}
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">大小</label>
|
||||
<div className="xx-ce-position">
|
||||
<InputNumber
|
||||
size="small"
|
||||
value={config.background.width}
|
||||
step={0.1}
|
||||
suffix="%"
|
||||
controls={false}
|
||||
onChange={(v) => updBg("width", v ?? 0)}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
<InputNumber
|
||||
size="small"
|
||||
value={config.background.height}
|
||||
step={0.1}
|
||||
suffix="%"
|
||||
controls={false}
|
||||
onChange={(v) => updBg("height", v ?? 0)}
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 位置偏移 */}
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">位置偏移</label>
|
||||
<PositionPair
|
||||
x={config.background.posX}
|
||||
y={config.background.posY}
|
||||
onChange={(pos) => {
|
||||
updBg("posX", pos.x)
|
||||
updBg("posY", pos.y)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 旋转角度 */}
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">旋转角度: {config.background.rotation}°</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={360}
|
||||
step={1}
|
||||
value={config.background.rotation}
|
||||
onChange={(v) => updBg("rotation", v)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ══════════════ Main Component ══════════════ */
|
||||
const CoverEditorModal: React.FC<CoverEditorModalProps> = ({ open, onClose, template, onSave }) => {
|
||||
const [name, setName] = useState(template?.name || "")
|
||||
const [sections, setSections] = useState<SectionState>({
|
||||
const initCfg = template?.config ?? DEFAULT_EDITOR_CONFIG
|
||||
const [name, setName] = useState(template?.name ?? "")
|
||||
const [cfg, setCfg] = useState<CoverEditorConfig>({ ...initCfg })
|
||||
const [sections, setSections] = useState<Record<SectionKey, boolean>>({
|
||||
basic: true,
|
||||
portrait: false,
|
||||
background: false,
|
||||
@@ -29,34 +397,90 @@ const CoverEditorModal: React.FC<CoverEditorModalProps> = ({ open, onClose, temp
|
||||
subtitle: true,
|
||||
mask: false,
|
||||
})
|
||||
const fileRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const toggleSection = (key: keyof SectionState) => {
|
||||
setSections((prev) => ({ ...prev, [key]: !prev[key] }))
|
||||
}
|
||||
const upd = useCallback(
|
||||
<K extends keyof CoverEditorConfig>(key: K, val: CoverEditorConfig[K]) =>
|
||||
setCfg((prev) => ({ ...prev, [key]: val })),
|
||||
[],
|
||||
)
|
||||
|
||||
const updTitle = useCallback(
|
||||
(c: TextStyleConfig) => setCfg((prev) => ({ ...prev, title: c })),
|
||||
[],
|
||||
)
|
||||
const updSubtitle = useCallback(
|
||||
(c: TextStyleConfig) => setCfg((prev) => ({ ...prev, subtitle: c })),
|
||||
[],
|
||||
)
|
||||
|
||||
const toggle = (key: SectionKey) => setSections((p) => ({ ...p, [key]: !p[key] }))
|
||||
|
||||
const handleSave = () => {
|
||||
if (!template) return
|
||||
onSave({ ...template, name })
|
||||
const result: CoverTemplate = template
|
||||
? { ...template, name, config: cfg }
|
||||
: {
|
||||
id: "",
|
||||
name,
|
||||
is_system: false,
|
||||
thumbnail_url: "",
|
||||
created_at: "",
|
||||
config: cfg,
|
||||
}
|
||||
onSave(result)
|
||||
onClose()
|
||||
}
|
||||
|
||||
/* ── canvas helpers ── */
|
||||
const scaleFont = (px: number) => Math.round((px / 1200) * 225 * 100) / 100
|
||||
|
||||
const renderTextStyle = (tc: TextStyleConfig): React.CSSProperties => {
|
||||
const style: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
left: `${tc.position.x}%`,
|
||||
top: `${tc.position.y}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${tc.rotation}deg)`,
|
||||
fontFamily: getFontFamily(tc.fontFamily),
|
||||
fontSize: scaleFont(tc.fontSize),
|
||||
fontWeight: tc.fontWeight,
|
||||
color: tc.color,
|
||||
letterSpacing: scaleFont(tc.letterSpacing),
|
||||
lineHeight: scaleFont(tc.lineHeight),
|
||||
WebkitTextStroke:
|
||||
tc.strokeWidth > 0 ? `${scaleFont(tc.strokeWidth)}px ${tc.strokeColor}` : undefined,
|
||||
whiteSpace: tc.direction === "vertical" ? "pre-wrap" : "nowrap",
|
||||
writingMode: tc.direction === "vertical" ? "vertical-rl" : undefined,
|
||||
zIndex: 3,
|
||||
}
|
||||
if (tc.shadows.length > 0) {
|
||||
style.textShadow = tc.shadows
|
||||
.map(
|
||||
(s) =>
|
||||
`${scaleFont(s.offsetX)}px ${scaleFont(s.offsetY)}px ${scaleFont(s.blur)}px ${s.color}`,
|
||||
)
|
||||
.join(", ")
|
||||
}
|
||||
return style
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
width={1000}
|
||||
width={1050}
|
||||
title="自定义封面编辑器"
|
||||
centered
|
||||
footer={null}
|
||||
>
|
||||
<div className="xx-cover-editor-header">
|
||||
{/* ── Header ── */}
|
||||
<div className="xx-ce-header">
|
||||
<input
|
||||
className="xx-cover-editor-name-input"
|
||||
className="xx-ce-name-input"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="输入模板名称"
|
||||
/>
|
||||
<div className="xx-cover-editor-header-actions">
|
||||
<div className="xx-ce-header-actions">
|
||||
<Button buttonType="ghost" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
@@ -66,55 +490,425 @@ const CoverEditorModal: React.FC<CoverEditorModalProps> = ({ open, onClose, temp
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xx-cover-editor-layout">
|
||||
{/* 左侧折叠面板 */}
|
||||
<div className="xx-cover-editor-left">
|
||||
{[
|
||||
{ key: "basic" as const, label: "基础设置" },
|
||||
{ key: "portrait" as const, label: "人像设置" },
|
||||
{ key: "background" as const, label: "背景设置", toggle: true },
|
||||
{ key: "title" as const, label: "主标题" },
|
||||
{ key: "subtitle" as const, label: "副标题" },
|
||||
{ key: "mask" as const, label: "蒙版", toggle: true },
|
||||
].map((item) => (
|
||||
<div key={item.key} className="xx-cover-editor-section">
|
||||
<div
|
||||
className="xx-cover-editor-section-header"
|
||||
onClick={() => toggleSection(item.key)}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
<span>{sections[item.key] ? "▾" : "▸"}</span>
|
||||
</div>
|
||||
{sections[item.key] && (
|
||||
<div className="xx-cover-editor-section-body">
|
||||
{item.toggle ? (
|
||||
<label style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<input type="checkbox" defaultChecked={false} />
|
||||
已开启
|
||||
</label>
|
||||
) : (
|
||||
<span style={{ color: "var(--text-tertiary)" }}>暂无配置项</span>
|
||||
{/* ── Layout ── */}
|
||||
<div className="xx-ce-layout">
|
||||
{/* ── Left Panel ── */}
|
||||
<div className="xx-ce-left">
|
||||
{/* 1. 基础设置 */}
|
||||
<div className="xx-ce-section">
|
||||
<div className="xx-ce-section-header" onClick={() => toggle("basic")}>
|
||||
<span>基础设置</span>
|
||||
<span>{sections.basic ? "▾" : "▸"}</span>
|
||||
</div>
|
||||
{sections.basic && (
|
||||
<div className="xx-ce-section-body">
|
||||
{/* 背景模糊 */}
|
||||
<div className="xx-ce-switch-item">
|
||||
<div className="xx-ce-switch-row">
|
||||
<span>背景模糊</span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={cfg.blurEnabled}
|
||||
onChange={(v) => upd("blurEnabled", v)}
|
||||
/>
|
||||
</div>
|
||||
{cfg.blurEnabled && (
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">模糊度: {cfg.blurAmount}</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={20}
|
||||
step={1}
|
||||
value={cfg.blurAmount}
|
||||
onChange={(v) => upd("blurAmount", v)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 人物描边 */}
|
||||
<div className="xx-ce-switch-item">
|
||||
<div className="xx-ce-switch-row">
|
||||
<span>人物描边</span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={cfg.personStrokeEnabled}
|
||||
onChange={(v) => upd("personStrokeEnabled", v)}
|
||||
/>
|
||||
</div>
|
||||
{cfg.personStrokeEnabled && (
|
||||
<>
|
||||
<div className="xx-ce-row">
|
||||
<div className="xx-ce-radio-group">
|
||||
{(["solid", "dashed"] as StrokeStyle[]).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
className={`xx-ce-radio-btn ${cfg.personStrokeStyle === s ? "active" : ""}`}
|
||||
onClick={() => upd("personStrokeStyle", s)}
|
||||
>
|
||||
{s === "solid" ? "实线" : "虚线"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">描边颜色</label>
|
||||
<ColorPicker
|
||||
value={cfg.personStrokeColor}
|
||||
onChange={(v) => upd("personStrokeColor", v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">描边粗细: {cfg.personStrokeWidth}px</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={20}
|
||||
step={1}
|
||||
value={cfg.personStrokeWidth}
|
||||
onChange={(v) => upd("personStrokeWidth", v)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 文字自动拆分 */}
|
||||
<div className="xx-ce-switch-item">
|
||||
<div className="xx-ce-switch-row">
|
||||
<span>文字自动拆分</span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={cfg.autoSplitEnabled}
|
||||
onChange={(v) => upd("autoSplitEnabled", v)}
|
||||
/>
|
||||
</div>
|
||||
{cfg.autoSplitEnabled && (
|
||||
<>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">主标题最大字数: {cfg.titleMaxChars}</label>
|
||||
<Slider
|
||||
min={1}
|
||||
max={15}
|
||||
step={1}
|
||||
value={cfg.titleMaxChars}
|
||||
onChange={(v) => upd("titleMaxChars", v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">
|
||||
副标题最大字数: {cfg.subtitleMaxChars}
|
||||
</label>
|
||||
<Slider
|
||||
min={1}
|
||||
max={20}
|
||||
step={1}
|
||||
value={cfg.subtitleMaxChars}
|
||||
onChange={(v) => upd("subtitleMaxChars", v)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 2. 人像设置 */}
|
||||
<div className="xx-ce-section">
|
||||
<div className="xx-ce-section-header" onClick={() => toggle("portrait")}>
|
||||
<span>人像设置</span>
|
||||
<div className="xx-ce-header-right">
|
||||
<span className="xx-ce-status-text">已开启</span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={cfg.portraitEnabled}
|
||||
onChange={(v) => upd("portraitEnabled", v)}
|
||||
onClick={(_, e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{sections.portrait && (
|
||||
<div className="xx-ce-section-body">
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">大小: {cfg.portraitSize}%</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={cfg.portraitSize}
|
||||
onChange={(v) => upd("portraitSize", v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">位置</label>
|
||||
<PositionPair
|
||||
x={cfg.portraitPosition.x}
|
||||
y={cfg.portraitPosition.y}
|
||||
onChange={(pos) => upd("portraitPosition", pos)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 3. 背景设置 */}
|
||||
<div className="xx-ce-section">
|
||||
<div className="xx-ce-section-header" onClick={() => toggle("background")}>
|
||||
<span>背景设置</span>
|
||||
<div className="xx-ce-header-right">
|
||||
<span className="xx-ce-status-text">已开启</span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={cfg.backgroundEnabled}
|
||||
onChange={(v) => upd("backgroundEnabled", v)}
|
||||
onClick={(_, e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{sections.background && (
|
||||
<div className="xx-ce-section-body">
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">大小: {cfg.backgroundSize}%</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={cfg.backgroundSize}
|
||||
onChange={(v) => upd("backgroundSize", v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">位置</label>
|
||||
<PositionPair
|
||||
x={cfg.backgroundPosition.x}
|
||||
y={cfg.backgroundPosition.y}
|
||||
onChange={(pos) => upd("backgroundPosition", pos)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 4. 主标题 */}
|
||||
<div className="xx-ce-section">
|
||||
<div className="xx-ce-section-header" onClick={() => toggle("title")}>
|
||||
<span>主标题</span>
|
||||
<span>{sections.title ? "▾" : "▸"}</span>
|
||||
</div>
|
||||
{sections.title && (
|
||||
<div className="xx-ce-section-body">
|
||||
<TextStylePanel config={cfg.title} onChange={updTitle} placeholder="主标题文字" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 5. 副标题 */}
|
||||
<div className="xx-ce-section">
|
||||
<div className="xx-ce-section-header" onClick={() => toggle("subtitle")}>
|
||||
<span>副标题</span>
|
||||
<span>{sections.subtitle ? "▾" : "▸"}</span>
|
||||
</div>
|
||||
{sections.subtitle && (
|
||||
<div className="xx-ce-section-body">
|
||||
<TextStylePanel
|
||||
config={cfg.subtitle}
|
||||
onChange={updSubtitle}
|
||||
placeholder="副标题文字"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 6. 蒙版 */}
|
||||
<div className="xx-ce-section">
|
||||
<div className="xx-ce-section-header" onClick={() => toggle("mask")}>
|
||||
<span>蒙版</span>
|
||||
<div className="xx-ce-header-right">
|
||||
<span className="xx-ce-status-text">已开启</span>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={cfg.maskEnabled}
|
||||
onChange={(v) => upd("maskEnabled", v)}
|
||||
onClick={(_, e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{sections.mask && (
|
||||
<div className="xx-ce-section-body">
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">蒙版图片</label>
|
||||
<div className="xx-ce-file-row">
|
||||
<input
|
||||
className="xx-ce-file-name"
|
||||
readOnly
|
||||
value={cfg.maskImage || "未选择文件"}
|
||||
/>
|
||||
<button className="xx-ce-file-btn" onClick={() => fileRef.current?.click()}>
|
||||
选择
|
||||
</button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (f) upd("maskImage", f.name)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">大小: {cfg.maskSize}%</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={cfg.maskSize}
|
||||
onChange={(v) => upd("maskSize", v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">位置</label>
|
||||
<PositionPair
|
||||
x={cfg.maskPosition.x}
|
||||
y={cfg.maskPosition.y}
|
||||
onChange={(pos) => upd("maskPosition", pos)}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">颜色</label>
|
||||
<ColorPicker value={cfg.maskColor} onChange={(v) => upd("maskColor", v)} />
|
||||
</div>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">透明度: {cfg.maskOpacity}%</label>
|
||||
<Slider
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={cfg.maskOpacity}
|
||||
onChange={(v) => upd("maskOpacity", v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="xx-ce-row">
|
||||
<label className="xx-ce-label">形状</label>
|
||||
<Select
|
||||
value={cfg.maskShape}
|
||||
onChange={(v) => upd("maskShape", v)}
|
||||
style={{ width: "100%" }}
|
||||
options={[
|
||||
{ label: "矩形", value: "矩形" },
|
||||
{ label: "圆形", value: "圆形" },
|
||||
{ label: "渐变", value: "渐变" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧画布预览 */}
|
||||
<div className="xx-cover-editor-right">
|
||||
<div className="xx-cover-editor-canvas">
|
||||
{/* 人像占位 */}
|
||||
<div className="xx-cover-editor-portrait">
|
||||
{/* 四角拖拽手柄 */}
|
||||
<span className="xx-cover-editor-handle" style={{ top: -4, left: -4 }} />
|
||||
<span className="xx-cover-editor-handle" style={{ top: -4, right: -4 }} />
|
||||
<span className="xx-cover-editor-handle" style={{ bottom: -4, left: -4 }} />
|
||||
<span className="xx-cover-editor-handle" style={{ bottom: -4, right: -4 }} />
|
||||
{/* ── Right Preview ── */}
|
||||
<div className="xx-ce-right">
|
||||
<div className="xx-ce-canvas-wrap">
|
||||
{/* decorative anchor dots */}
|
||||
<span className="xx-ce-anchor-dot" style={{ top: "10%", left: "-12px" }} />
|
||||
<span className="xx-ce-anchor-dot" style={{ top: "50%", right: "-12px" }} />
|
||||
<span className="xx-ce-anchor-dot" style={{ bottom: "10%", left: "-12px" }} />
|
||||
|
||||
<div className="xx-ce-canvas">
|
||||
{/* Background */}
|
||||
{cfg.backgroundEnabled && (
|
||||
<div
|
||||
className="xx-ce-el-bg"
|
||||
style={{
|
||||
width: `${cfg.backgroundSize}%`,
|
||||
height: `${cfg.backgroundSize}%`,
|
||||
left: `${cfg.backgroundPosition.x}%`,
|
||||
top: `${cfg.backgroundPosition.y}%`,
|
||||
transform: "translate(-50%, -50%)",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Portrait placeholder */}
|
||||
{cfg.portraitEnabled && (
|
||||
<div
|
||||
className="xx-ce-el-portrait"
|
||||
style={{
|
||||
width: `${cfg.portraitSize}%`,
|
||||
height: `${cfg.portraitSize * 0.6}%`,
|
||||
left: `${cfg.portraitPosition.x}%`,
|
||||
top: `${cfg.portraitPosition.y}%`,
|
||||
transform: "translate(-50%, -50%)",
|
||||
borderStyle: cfg.personStrokeEnabled ? cfg.personStrokeStyle : "solid",
|
||||
borderColor: cfg.personStrokeEnabled ? cfg.personStrokeColor : "#333",
|
||||
borderWidth: cfg.personStrokeEnabled
|
||||
? `${Math.max(1, cfg.personStrokeWidth / 4)}px`
|
||||
: "2px",
|
||||
filter: cfg.blurEnabled ? `blur(${cfg.blurAmount / 4}px)` : undefined,
|
||||
}}
|
||||
>
|
||||
{/* 8 handles */}
|
||||
{[0, 1, 2, 3, 4, 5, 6, 7].map((i) => (
|
||||
<span key={i} className={`xx-ce-handle xx-ce-handle--${i}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div style={renderTextStyle(cfg.title)}>
|
||||
{cfg.title.background.enabled && (
|
||||
<div
|
||||
className="xx-ce-text-bg"
|
||||
style={{
|
||||
backgroundColor: cfg.title.background.color,
|
||||
opacity: cfg.title.background.opacity / 100,
|
||||
width: `${cfg.title.background.width}%`,
|
||||
height: `${cfg.title.background.height}%`,
|
||||
left: `${cfg.title.background.posX}%`,
|
||||
top: `${cfg.title.background.posY}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${cfg.title.background.rotation}deg)`,
|
||||
position: "absolute",
|
||||
border:
|
||||
cfg.title.background.shape === "polygon" ? "1px dashed #999" : undefined,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{cfg.title.text || "主标题文字"}
|
||||
</div>
|
||||
|
||||
{/* Subtitle */}
|
||||
<div style={renderTextStyle(cfg.subtitle)}>
|
||||
{cfg.subtitle.background.enabled && (
|
||||
<div
|
||||
className="xx-ce-text-bg"
|
||||
style={{
|
||||
backgroundColor: cfg.subtitle.background.color,
|
||||
opacity: cfg.subtitle.background.opacity / 100,
|
||||
width: `${cfg.subtitle.background.width}%`,
|
||||
height: `${cfg.subtitle.background.height}%`,
|
||||
left: `${cfg.subtitle.background.posX}%`,
|
||||
top: `${cfg.subtitle.background.posY}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${cfg.subtitle.background.rotation}deg)`,
|
||||
position: "absolute",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{cfg.subtitle.text || "副标题文字"}
|
||||
</div>
|
||||
|
||||
{/* Mask overlay */}
|
||||
{cfg.maskEnabled && (
|
||||
<div
|
||||
className="xx-ce-el-mask"
|
||||
style={{
|
||||
backgroundColor: cfg.maskColor,
|
||||
opacity: cfg.maskOpacity / 100,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* 文字占位 */}
|
||||
<div className="xx-cover-editor-title-placeholder">主标题文字</div>
|
||||
<div className="xx-cover-editor-subtitle-placeholder">副标题文字</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3335,3 +3335,388 @@
|
||||
grid-template-columns: minmax(0, 360px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
自定义封面编辑器 (Cover Editor Modal) — xx-ce-*
|
||||
================================================================ */
|
||||
|
||||
/* Header */
|
||||
.xx-ce-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.xx-ce-name-input {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
font-size: 14px;
|
||||
margin-bottom: 12px;
|
||||
outline: none;
|
||||
}
|
||||
.xx-ce-name-input:focus {
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
.xx-ce-header-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Layout */
|
||||
.xx-ce-layout {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
min-height: 500px;
|
||||
}
|
||||
.xx-ce-left {
|
||||
width: 300px;
|
||||
flex-shrink: 0;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.xx-ce-right {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8px;
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
/* Section / collapsible panels */
|
||||
.xx-ce-section {
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.xx-ce-section-header {
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: #f0f4ff;
|
||||
user-select: none;
|
||||
}
|
||||
.xx-ce-section-header:hover {
|
||||
background: #e8edf8;
|
||||
}
|
||||
.xx-ce-section-body {
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
.xx-ce-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.xx-ce-status-text {
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Rows / labels */
|
||||
.xx-ce-row {
|
||||
margin: 12px 0;
|
||||
}
|
||||
.xx-ce-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #374151;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.xx-ce-hint {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.xx-ce-sub-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.xx-ce-switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.xx-ce-switch-item {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
.xx-ce-switch-item:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
/* Color picker */
|
||||
.xx-ce-color-picker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.xx-ce-color-picker input[type="color"] {
|
||||
width: 32px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
}
|
||||
.xx-ce-color-picker input[type="color"]::-webkit-color-swatch-wrapper {
|
||||
padding: 1px;
|
||||
}
|
||||
.xx-ce-color-picker input[type="color"]::-webkit-color-swatch {
|
||||
border: none;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.xx-ce-color-hex {
|
||||
width: 70px;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
/* Position pair */
|
||||
.xx-ce-position {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.xx-ce-position .ant-input-number {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Radio button group */
|
||||
.xx-ce-radio-group {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
}
|
||||
.xx-ce-radio-btn {
|
||||
padding: 4px 14px;
|
||||
font-size: 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.xx-ce-radio-btn:first-child {
|
||||
border-radius: 4px 0 0 4px;
|
||||
}
|
||||
.xx-ce-radio-btn:last-child {
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
.xx-ce-radio-btn + .xx-ce-radio-btn {
|
||||
border-left: none;
|
||||
}
|
||||
.xx-ce-radio-btn.active {
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
.xx-ce-radio-btn.active + .xx-ce-radio-btn {
|
||||
border-left: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
/* Font select dots */
|
||||
.xx-ce-font-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.xx-ce-font-dot--preset {
|
||||
background: #10b981;
|
||||
}
|
||||
.xx-ce-font-dot--system {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
/* Shadow actions */
|
||||
.xx-ce-shadow-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.xx-ce-add-shadow-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.xx-ce-add-shadow-btn:hover {
|
||||
background: #6d28d9;
|
||||
}
|
||||
.xx-ce-preset-shadow-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Text background sub-section */
|
||||
.xx-ce-text-bg-section {
|
||||
margin-top: 8px;
|
||||
padding: 8px;
|
||||
background: #fafafa;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
/* Readonly text display */
|
||||
.xx-ce-readonly-text {
|
||||
padding: 6px 10px;
|
||||
background: #eff6ff;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: #1e40af;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* Mask file row */
|
||||
.xx-ce-file-row {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
.xx-ce-file-name {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background: #f9fafb;
|
||||
color: #6b7280;
|
||||
}
|
||||
.xx-ce-file-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.xx-ce-file-btn:hover {
|
||||
border-color: #7c3aed;
|
||||
color: #7c3aed;
|
||||
}
|
||||
|
||||
/* ── Canvas / Preview ── */
|
||||
.xx-ce-canvas-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.xx-ce-canvas {
|
||||
width: 225px;
|
||||
height: 400px;
|
||||
background: #ddd;
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.xx-ce-anchor-dot {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #ef4444;
|
||||
border-radius: 50%;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* Portrait element */
|
||||
.xx-ce-el-portrait {
|
||||
position: absolute;
|
||||
background: #a8d4f0;
|
||||
border: 2px solid #333;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* 8 handles: 0=TL 1=T 2=TR 3=R 4=BR 5=B 6=BL 7=L */
|
||||
.xx-ce-handle {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: #3b82f6;
|
||||
border: 1px solid #fff;
|
||||
z-index: 10;
|
||||
}
|
||||
.xx-ce-handle--0 { top: -4px; left: -4px; }
|
||||
.xx-ce-handle--1 { top: -4px; left: 50%; margin-left: -4px; }
|
||||
.xx-ce-handle--2 { top: -4px; right: -4px; }
|
||||
.xx-ce-handle--3 { top: 50%; right: -4px; margin-top: -4px; }
|
||||
.xx-ce-handle--4 { bottom: -4px; right: -4px; }
|
||||
.xx-ce-handle--5 { bottom: -4px; left: 50%; margin-left: -4px; }
|
||||
.xx-ce-handle--6 { bottom: -4px; left: -4px; }
|
||||
.xx-ce-handle--7 { top: 50%; left: -4px; margin-top: -4px; }
|
||||
|
||||
/* Background element */
|
||||
.xx-ce-el-bg {
|
||||
position: absolute;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Mask overlay */
|
||||
.xx-ce-el-mask {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Text background shape in canvas */
|
||||
.xx-ce-text-bg {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* Antd Slider overrides for editor */
|
||||
.xx-ce-section-body .ant-slider {
|
||||
margin: 4px 0 8px;
|
||||
}
|
||||
.xx-ce-section-body .ant-slider-rail {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
.xx-ce-section-body .ant-slider-track {
|
||||
background: #3b82f6;
|
||||
}
|
||||
.xx-ce-section-body .ant-slider-handle::after {
|
||||
box-shadow: 0 0 0 2px #3b82f6;
|
||||
}
|
||||
.xx-ce-section-body .ant-slider-mark-text {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Antd Select dropdown font dots */
|
||||
.xx-ce-font-select-dropdown .ant-select-item-option-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Canvas text elements — ensure proper stacking */
|
||||
.xx-ce-canvas > div {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,238 @@ export const DEFAULT_COVER_CONFIG: CoverConfig = {
|
||||
thumbnail_url: "",
|
||||
}
|
||||
|
||||
/** 文字方向 */
|
||||
export type TextDirection = "horizontal" | "vertical"
|
||||
|
||||
/** 文字背景形状 */
|
||||
export type TextBgShape = "rectangle" | "polygon"
|
||||
|
||||
/** 描边样式 */
|
||||
export type StrokeStyle = "solid" | "dashed"
|
||||
|
||||
/** 阴影层 */
|
||||
export interface ShadowLayer {
|
||||
color: string
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
blur: number
|
||||
}
|
||||
|
||||
/** 文字位置 */
|
||||
export interface TextPosition {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
/** 文字背景配置 */
|
||||
export interface TextBackground {
|
||||
enabled: boolean
|
||||
color: string
|
||||
opacity: number
|
||||
shape: TextBgShape
|
||||
width: number
|
||||
height: number
|
||||
posX: number
|
||||
posY: number
|
||||
rotation: number
|
||||
}
|
||||
|
||||
/** 文字样式配置(主标题/副标题共用) */
|
||||
export interface TextStyleConfig {
|
||||
text: string
|
||||
fontFamily: string
|
||||
fontSize: number
|
||||
fontWeight: number
|
||||
direction: TextDirection
|
||||
charsPerLine: number
|
||||
letterSpacing: number
|
||||
lineHeight: number
|
||||
color: string
|
||||
strokeColor: string
|
||||
strokeWidth: number
|
||||
shadows: ShadowLayer[]
|
||||
traditionalShadow: boolean
|
||||
position: TextPosition
|
||||
rotation: number
|
||||
background: TextBackground
|
||||
}
|
||||
|
||||
/** 编辑器完整配置 */
|
||||
export interface CoverEditorConfig {
|
||||
// 基础设置
|
||||
blurEnabled: boolean
|
||||
blurAmount: number
|
||||
personStrokeEnabled: boolean
|
||||
personStrokeStyle: StrokeStyle
|
||||
personStrokeColor: string
|
||||
personStrokeWidth: number
|
||||
autoSplitEnabled: boolean
|
||||
titleMaxChars: number
|
||||
subtitleMaxChars: number
|
||||
|
||||
// 人像设置
|
||||
portraitEnabled: boolean
|
||||
portraitSize: number
|
||||
portraitPosition: TextPosition
|
||||
|
||||
// 背景设置
|
||||
backgroundEnabled: boolean
|
||||
backgroundSize: number
|
||||
backgroundPosition: TextPosition
|
||||
|
||||
// 主标题
|
||||
title: TextStyleConfig
|
||||
|
||||
// 副标题
|
||||
subtitle: TextStyleConfig
|
||||
|
||||
// 蒙版
|
||||
maskEnabled: boolean
|
||||
maskImage: string
|
||||
maskSize: number
|
||||
maskPosition: TextPosition
|
||||
maskColor: string
|
||||
maskOpacity: number
|
||||
maskShape: string
|
||||
}
|
||||
|
||||
/** 默认主标题配置 */
|
||||
export const DEFAULT_TITLE_CONFIG: TextStyleConfig = {
|
||||
text: "主标题文字",
|
||||
fontFamily: "思源黑体",
|
||||
fontSize: 120,
|
||||
fontWeight: 700,
|
||||
direction: "horizontal",
|
||||
charsPerLine: 10,
|
||||
letterSpacing: 24,
|
||||
lineHeight: 144,
|
||||
color: "#FFD700",
|
||||
strokeColor: "#000000",
|
||||
strokeWidth: 3,
|
||||
shadows: [],
|
||||
traditionalShadow: false,
|
||||
position: { x: 50, y: 30 },
|
||||
rotation: 0,
|
||||
background: {
|
||||
enabled: false,
|
||||
color: "#FFFFFF",
|
||||
opacity: 25,
|
||||
shape: "polygon",
|
||||
width: 30,
|
||||
height: 10,
|
||||
posX: 50,
|
||||
posY: 50,
|
||||
rotation: 0,
|
||||
},
|
||||
}
|
||||
|
||||
/** 默认副标题配置 */
|
||||
export const DEFAULT_SUBTITLE_CONFIG: TextStyleConfig = {
|
||||
text: "副标题文字",
|
||||
fontFamily: "思源黑体",
|
||||
fontSize: 82,
|
||||
fontWeight: 500,
|
||||
direction: "horizontal",
|
||||
charsPerLine: 17,
|
||||
letterSpacing: 23,
|
||||
lineHeight: 72,
|
||||
color: "#FFFFFF",
|
||||
strokeColor: "#000000",
|
||||
strokeWidth: 1,
|
||||
shadows: [],
|
||||
traditionalShadow: false,
|
||||
position: { x: 50, y: 70 },
|
||||
rotation: 0,
|
||||
background: {
|
||||
enabled: true,
|
||||
color: "#000000",
|
||||
opacity: 70,
|
||||
shape: "rectangle",
|
||||
width: 100,
|
||||
height: 20,
|
||||
posX: 50,
|
||||
posY: 80,
|
||||
rotation: 0,
|
||||
},
|
||||
}
|
||||
|
||||
/** 默认编辑器配置 */
|
||||
export const DEFAULT_EDITOR_CONFIG: CoverEditorConfig = {
|
||||
blurEnabled: false,
|
||||
blurAmount: 10,
|
||||
personStrokeEnabled: false,
|
||||
personStrokeStyle: "solid",
|
||||
personStrokeColor: "#FFFFFF",
|
||||
personStrokeWidth: 8,
|
||||
autoSplitEnabled: false,
|
||||
titleMaxChars: 4,
|
||||
subtitleMaxChars: 10,
|
||||
|
||||
portraitEnabled: true,
|
||||
portraitSize: 80,
|
||||
portraitPosition: { x: 50, y: 50 },
|
||||
|
||||
backgroundEnabled: true,
|
||||
backgroundSize: 90,
|
||||
backgroundPosition: { x: 50, y: 50 },
|
||||
|
||||
title: DEFAULT_TITLE_CONFIG,
|
||||
subtitle: DEFAULT_SUBTITLE_CONFIG,
|
||||
|
||||
maskEnabled: true,
|
||||
maskImage: "",
|
||||
maskSize: 100,
|
||||
maskPosition: { x: 50, y: 50 },
|
||||
maskColor: "#000000",
|
||||
maskOpacity: 100,
|
||||
maskShape: "矩形",
|
||||
}
|
||||
|
||||
/** 预置字体 */
|
||||
export const PRESET_FONTS = [
|
||||
{ name: "思源黑体", family: "'Noto Sans SC', sans-serif" },
|
||||
{ name: "斗鱼追光体2.0", family: "'DouYu ZhuangGuangTi', sans-serif" },
|
||||
{ name: "抖音美好体", family: "'DouYin MeiHaoTi', sans-serif" },
|
||||
]
|
||||
|
||||
/** 系统字体 */
|
||||
export const SYSTEM_FONTS = [
|
||||
{ name: "Arial", family: "Arial, sans-serif" },
|
||||
{ name: "Helvetica", family: "Helvetica, sans-serif" },
|
||||
{ name: "Times New Roman", family: "'Times New Roman', serif" },
|
||||
{ name: "Georgia", family: "Georgia, serif" },
|
||||
{ name: "Verdana", family: "Verdana, sans-serif" },
|
||||
{ name: "Tahoma", family: "Tahoma, sans-serif" },
|
||||
{ name: "Impact", family: "Impact, sans-serif" },
|
||||
{ name: "Comic Sans MS", family: "'Comic Sans MS', cursive" },
|
||||
{ name: "Courier New", family: "'Courier New', monospace" },
|
||||
{ name: "微软雅黑", family: "'Microsoft YaHei', sans-serif" },
|
||||
{ name: "宋体", family: "SimSun, serif" },
|
||||
{ name: "黑体", family: "SimHei, sans-serif" },
|
||||
{ name: "楷体", family: "KaiTi, serif" },
|
||||
{ name: "仿宋", family: "FangSong, serif" },
|
||||
{ name: "Trebuchet MS", family: "'Trebuchet MS', sans-serif" },
|
||||
{ name: "Lucida Console", family: "'Lucida Console', monospace" },
|
||||
{ name: "Palatino", family: "Palatino, serif" },
|
||||
{ name: "Garamond", family: "Garamond, serif" },
|
||||
{ name: "Bookman", family: "Bookman, serif" },
|
||||
{ name: "Avant Garde", family: "'Avant Garde', sans-serif" },
|
||||
{ name: "Calibri", family: "Calibri, sans-serif" },
|
||||
{ name: "Cambria", family: "Cambria, serif" },
|
||||
{ name: "Candara", family: "Candara, sans-serif" },
|
||||
{ name: "Consolas", family: "Consolas, monospace" },
|
||||
{ name: "Constantia", family: "Constantia, serif" },
|
||||
{ name: "Corbel", family: "Corbel, sans-serif" },
|
||||
{ name: "Franklin Gothic", family: "'Franklin Gothic', sans-serif" },
|
||||
{ name: "Gill Sans", family: "'Gill Sans', sans-serif" },
|
||||
{ name: "Optima", family: "Optima, sans-serif" },
|
||||
{ name: "Futura", family: "Futura, sans-serif" },
|
||||
{ name: "Rockwell", family: "Rockwell, serif" },
|
||||
]
|
||||
|
||||
/** 所有字体列表 */
|
||||
export const ALL_FONTS = [...PRESET_FONTS, ...SYSTEM_FONTS]
|
||||
|
||||
/** 封面模板 */
|
||||
export interface CoverTemplate {
|
||||
id: string
|
||||
@@ -39,12 +271,5 @@ export interface CoverTemplate {
|
||||
thumbnail_url: string
|
||||
is_system: boolean
|
||||
created_at: string
|
||||
config?: {
|
||||
background_enabled?: boolean
|
||||
background_color?: string
|
||||
portrait_enabled?: boolean
|
||||
title_text?: string
|
||||
subtitle_text?: string
|
||||
mask_enabled?: boolean
|
||||
}
|
||||
config?: CoverEditorConfig
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user