11
This commit is contained in:
32
src/components/cover/CoverCollapsibleSection.vue
Normal file
32
src/components/cover/CoverCollapsibleSection.vue
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: { type: String, required: true },
|
||||||
|
defaultOpen: { type: Boolean, default: true },
|
||||||
|
headerExtra: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const open = ref(props.defaultOpen);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rounded-lg border border-slate-600/60 bg-slate-800/40">
|
||||||
|
<div
|
||||||
|
class="flex cursor-pointer items-center justify-between p-3 transition-colors hover:bg-slate-700/30"
|
||||||
|
@click="open = !open"
|
||||||
|
>
|
||||||
|
<div class="flex flex-1 items-center gap-3">
|
||||||
|
<h4 class="text-sm font-semibold text-slate-100">{{ title }}</h4>
|
||||||
|
<slot v-if="headerExtra" name="header-extra" @click.stop />
|
||||||
|
</div>
|
||||||
|
<i
|
||||||
|
class="pi text-slate-400 transition-transform"
|
||||||
|
:class="open ? 'pi-chevron-up' : 'pi-chevron-down'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-show="open" class="space-y-3 border-t border-slate-600/40 px-3 pb-3 pt-2">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
326
src/components/cover/CoverEditorPreview.vue
Normal file
326
src/components/cover/CoverEditorPreview.vue
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import {
|
||||||
|
COVER_CANVAS_HEIGHT,
|
||||||
|
COVER_CANVAS_WIDTH,
|
||||||
|
COVER_PREVIEW_SCALE,
|
||||||
|
} from "../../config/coverTemplateDefaults.js";
|
||||||
|
import {
|
||||||
|
layoutPersonBox,
|
||||||
|
layoutSubtitle,
|
||||||
|
layoutTitle,
|
||||||
|
} from "../../utils/coverPreviewLayout.js";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
config: { type: Object, required: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
"updatePersonPosition",
|
||||||
|
"updateTitlePosition",
|
||||||
|
"updateSubtitlePosition",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const previewW = COVER_CANVAS_WIDTH * COVER_PREVIEW_SCALE;
|
||||||
|
const previewH = COVER_CANVAS_HEIGHT * COVER_PREVIEW_SCALE;
|
||||||
|
|
||||||
|
const dragTarget = ref(null);
|
||||||
|
const dragEl = ref(null);
|
||||||
|
const dragStart = ref(null);
|
||||||
|
const isDragging = ref(false);
|
||||||
|
|
||||||
|
const showPerson = computed(() => props.config.extractPerson !== false);
|
||||||
|
|
||||||
|
const titleLayout = computed(() => {
|
||||||
|
const cfg = {
|
||||||
|
...props.config,
|
||||||
|
titleText: props.config.titleText?.trim() || "主标题",
|
||||||
|
};
|
||||||
|
return layoutTitle(cfg, COVER_PREVIEW_SCALE);
|
||||||
|
});
|
||||||
|
|
||||||
|
const subtitleLayout = computed(() => {
|
||||||
|
const cfg = {
|
||||||
|
...props.config,
|
||||||
|
subtitleText: props.config.subtitleText?.trim() || "副标题",
|
||||||
|
};
|
||||||
|
return layoutSubtitle(cfg, COVER_PREVIEW_SCALE);
|
||||||
|
});
|
||||||
|
|
||||||
|
const personBox = computed(() =>
|
||||||
|
layoutPersonBox(props.config, COVER_PREVIEW_SCALE),
|
||||||
|
);
|
||||||
|
|
||||||
|
function titleShadowCss(layers) {
|
||||||
|
const active = (layers || []).filter((l) => l?.enabled !== false);
|
||||||
|
if (!active.length) return "none";
|
||||||
|
return active
|
||||||
|
.map((l) => {
|
||||||
|
const a = (Number(l.opacity) ?? 100) / 100;
|
||||||
|
const hex = String(l.color || "#000").replace("#", "");
|
||||||
|
const r = parseInt(hex.slice(0, 2), 16) || 0;
|
||||||
|
const g = parseInt(hex.slice(2, 4), 16) || 0;
|
||||||
|
const b = parseInt(hex.slice(4, 6), 16) || 0;
|
||||||
|
return `rgba(${r},${g},${b},${a}) ${l.offsetX || 0}px ${l.offsetY || 0}px ${l.blur || 0}px`;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {"title"|"subtitle"} prefix
|
||||||
|
*/
|
||||||
|
function charStyle(prefix, item, fontSize) {
|
||||||
|
const c = props.config;
|
||||||
|
const layers =
|
||||||
|
prefix === "title" ? c.titleShadowLayers : c.subtitleShadowLayers;
|
||||||
|
const hasText = Boolean(String(c[`${prefix}Text`] || "").trim());
|
||||||
|
return {
|
||||||
|
left: `${item.x}px`,
|
||||||
|
top: `${item.y}px`,
|
||||||
|
transform: "translate(-50%, -50%)",
|
||||||
|
fontFamily: `${c[`${prefix}FontFamily`] || "Microsoft YaHei"}, sans-serif`,
|
||||||
|
fontSize: `${fontSize}px`,
|
||||||
|
fontWeight: c[`${prefix}FontWeight`] || 500,
|
||||||
|
color: hasText ? c[`${prefix}Color`] || "#fff" : "rgb(148 163 184)",
|
||||||
|
WebkitTextStroke: c[`${prefix}StrokeWidth`]
|
||||||
|
? `${c[`${prefix}StrokeWidth`]}px ${c[`${prefix}StrokeColor`] || "#000"}`
|
||||||
|
: undefined,
|
||||||
|
textShadow: titleShadowCss(layers),
|
||||||
|
lineHeight: 1,
|
||||||
|
whiteSpace: "pre",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function boxOverlayStyle(box, rotation = 0) {
|
||||||
|
return {
|
||||||
|
left: `${box.left}px`,
|
||||||
|
top: `${box.top}px`,
|
||||||
|
width: `${Math.max(box.width, 24)}px`,
|
||||||
|
height: `${Math.max(box.height, 24)}px`,
|
||||||
|
transform: rotation ? `rotate(${rotation}deg)` : undefined,
|
||||||
|
transformOrigin: "center center",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPos(posKey) {
|
||||||
|
const pos = props.config[posKey];
|
||||||
|
if (pos && typeof pos === "object" && "x" in pos && "y" in pos) {
|
||||||
|
return { x: Number(pos.x) || 50, y: Number(pos.y) || 50 };
|
||||||
|
}
|
||||||
|
return { x: 50, y: 50 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitPosition(target, pos) {
|
||||||
|
const clamped = {
|
||||||
|
x: Math.max(0, Math.min(100, Math.round(pos.x * 10) / 10)),
|
||||||
|
y: Math.max(0, Math.min(100, Math.round(pos.y * 10) / 10)),
|
||||||
|
};
|
||||||
|
if (target === "person") emit("updatePersonPosition", clamped);
|
||||||
|
else if (target === "title") emit("updateTitlePosition", clamped);
|
||||||
|
else if (target === "subtitle") emit("updateSubtitlePosition", clamped);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(event, target) {
|
||||||
|
const el = event.currentTarget;
|
||||||
|
if (!(el instanceof HTMLElement)) return;
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const posKey =
|
||||||
|
target === "person"
|
||||||
|
? "personPosition"
|
||||||
|
: target === "title"
|
||||||
|
? "titlePosition"
|
||||||
|
: "subtitlePosition";
|
||||||
|
|
||||||
|
dragTarget.value = target;
|
||||||
|
dragEl.value = el;
|
||||||
|
isDragging.value = true;
|
||||||
|
dragStart.value = {
|
||||||
|
clientX: event.clientX,
|
||||||
|
clientY: event.clientY,
|
||||||
|
pos: getPos(posKey),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
el.setPointerCapture(event.pointerId);
|
||||||
|
} catch {
|
||||||
|
/* 部分环境不支持 */
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("pointermove", onPointerMove, { passive: false });
|
||||||
|
window.addEventListener("pointerup", onPointerUp);
|
||||||
|
window.addEventListener("pointercancel", onPointerUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(event) {
|
||||||
|
if (!dragTarget.value || !dragStart.value) return;
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const dx = event.clientX - dragStart.value.clientX;
|
||||||
|
const dy = event.clientY - dragStart.value.clientY;
|
||||||
|
const nx = dragStart.value.pos.x + (dx / previewW) * 100;
|
||||||
|
const ny = dragStart.value.pos.y + (dy / previewH) * 100;
|
||||||
|
emitPosition(dragTarget.value, { x: nx, y: ny });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp(event) {
|
||||||
|
if (dragEl.value && event?.pointerId != null) {
|
||||||
|
try {
|
||||||
|
dragEl.value.releasePointerCapture(event.pointerId);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dragTarget.value = null;
|
||||||
|
dragEl.value = null;
|
||||||
|
dragStart.value = null;
|
||||||
|
isDragging.value = false;
|
||||||
|
window.removeEventListener("pointermove", onPointerMove);
|
||||||
|
window.removeEventListener("pointerup", onPointerUp);
|
||||||
|
window.removeEventListener("pointercancel", onPointerUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
const personBorderStyle = computed(() => {
|
||||||
|
const c = props.config;
|
||||||
|
if (!c.personBorderEnabled) return {};
|
||||||
|
const w = Math.max(2, (c.personBorderWidth || 4) * COVER_PREVIEW_SCALE * 0.15);
|
||||||
|
const color = c.personBorderColor || "#fff";
|
||||||
|
if (c.personBorderStyle === "dashed") {
|
||||||
|
return { outline: `${w}px dashed ${color}`, outlineOffset: "2px" };
|
||||||
|
}
|
||||||
|
return { outline: `${w}px solid ${color}`, outlineOffset: "2px" };
|
||||||
|
});
|
||||||
|
|
||||||
|
function overlayClass(target) {
|
||||||
|
const active = isDragging.value && dragTarget.value === target;
|
||||||
|
return [
|
||||||
|
"cover-drag-overlay",
|
||||||
|
active ? "cover-drag-overlay--active" : "",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex h-full flex-col">
|
||||||
|
<div class="mb-3 flex justify-end gap-2">
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex flex-1 items-center justify-center overflow-visible rounded-lg bg-slate-700/50 p-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="cover-preview-canvas relative bg-gradient-to-b from-slate-500 to-slate-800 shadow-lg"
|
||||||
|
:style="{ width: `${previewW}px`, height: `${previewH}px` }"
|
||||||
|
>
|
||||||
|
<!-- 主标题:逐字定位(与 Python 横/竖排一致) -->
|
||||||
|
<span
|
||||||
|
v-for="(item, i) in titleLayout.chars"
|
||||||
|
:key="`t-${i}`"
|
||||||
|
class="pointer-events-none absolute select-none"
|
||||||
|
:style="charStyle('title', item, titleLayout.fontSize)"
|
||||||
|
>{{ item.char }}</span>
|
||||||
|
|
||||||
|
<!-- 副标题 -->
|
||||||
|
<span
|
||||||
|
v-for="(item, i) in subtitleLayout.chars"
|
||||||
|
:key="`s-${i}`"
|
||||||
|
class="pointer-events-none absolute select-none"
|
||||||
|
:style="charStyle('subtitle', item, subtitleLayout.fontSize)"
|
||||||
|
>{{ item.char }}</span>
|
||||||
|
|
||||||
|
<!-- 人像拖拽区 -->
|
||||||
|
<div
|
||||||
|
v-if="showPerson"
|
||||||
|
:class="overlayClass('person')"
|
||||||
|
class="cover-drag-overlay cover-drag-overlay--person"
|
||||||
|
:style="{
|
||||||
|
...boxOverlayStyle(personBox),
|
||||||
|
...personBorderStyle,
|
||||||
|
zIndex: 71,
|
||||||
|
}"
|
||||||
|
@pointerdown="onPointerDown($event, 'person')"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="pointer-events-none flex h-full flex-col items-center justify-center gap-1 p-2 text-center"
|
||||||
|
>
|
||||||
|
<span class="text-[11px] font-medium text-blue-100">人像</span>
|
||||||
|
<span class="text-[10px] text-blue-200/80">拖拽移动</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 主标题拖拽区 -->
|
||||||
|
<div
|
||||||
|
:class="overlayClass('title')"
|
||||||
|
class="cover-drag-overlay cover-drag-overlay--title"
|
||||||
|
:style="{
|
||||||
|
...boxOverlayStyle(titleLayout.box, config.titleRotation || 0),
|
||||||
|
zIndex: 80,
|
||||||
|
}"
|
||||||
|
@pointerdown="onPointerDown($event, 'title')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- 副标题拖拽区 -->
|
||||||
|
<div
|
||||||
|
:class="overlayClass('subtitle')"
|
||||||
|
class="cover-drag-overlay cover-drag-overlay--subtitle"
|
||||||
|
:style="{
|
||||||
|
...boxOverlayStyle(
|
||||||
|
subtitleLayout.box,
|
||||||
|
config.subtitleRotation || 0,
|
||||||
|
),
|
||||||
|
zIndex: 75,
|
||||||
|
}"
|
||||||
|
@pointerdown="onPointerDown($event, 'subtitle')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-center text-xs text-slate-500">
|
||||||
|
在预览区按住蓝色/绿色框拖拽调整位置;左侧数值会同步更新
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cover-preview-canvas {
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-drag-overlay {
|
||||||
|
position: absolute;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
border: 2px dashed;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-drag-overlay:active,
|
||||||
|
.cover-drag-overlay--active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-drag-overlay--person {
|
||||||
|
border-color: rgb(96 165 250 / 0.9);
|
||||||
|
background: rgb(59 130 246 / 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-drag-overlay--title {
|
||||||
|
border-color: rgb(52 211 153 / 0.85);
|
||||||
|
background: rgb(16 185 129 / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-drag-overlay--subtitle {
|
||||||
|
border-color: rgb(250 204 21 / 0.85);
|
||||||
|
background: rgb(234 179 8 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-drag-overlay--active {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px rgb(255 255 255 / 0.5),
|
||||||
|
0 0 0 4px rgb(59 130 246 / 0.45);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
221
src/components/cover/CoverPreviewCard.vue
Normal file
221
src/components/cover/CoverPreviewCard.vue
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed } from "vue";
|
||||||
|
import { isAdvancedCoverConfig } from "../../config/coverTemplates.js";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
template: { type: Object, required: true },
|
||||||
|
isSelected: { type: Boolean, default: false },
|
||||||
|
isSystem: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["select", "edit", "clone", "export", "delete"]);
|
||||||
|
|
||||||
|
const cfg = computed(() => props.template?.config || {});
|
||||||
|
|
||||||
|
const previewStyle = computed(() => {
|
||||||
|
const c = cfg.value;
|
||||||
|
const pos = c.titlePosition || "bottom";
|
||||||
|
const align =
|
||||||
|
pos === "top" ? "flex-start" : pos === "center" ? "center" : "flex-end";
|
||||||
|
const accent = c.titleFontColor || c.titleColor || "#ffffff";
|
||||||
|
const advanced = isAdvancedCoverConfig(c);
|
||||||
|
return {
|
||||||
|
justifyContent: align,
|
||||||
|
background: advanced
|
||||||
|
? "linear-gradient(160deg, #1e293b 0%, #334155 45%, #0f172a 100%)"
|
||||||
|
: "linear-gradient(180deg, #475569 0%, #1e293b 55%, #0f172a 100%)",
|
||||||
|
"--title-color": accent,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const titleSample = computed(() => {
|
||||||
|
const c = cfg.value;
|
||||||
|
if (c.extractPerson) return "人物封面";
|
||||||
|
if (c.titleStrokeWidth) return "描边标题";
|
||||||
|
return "封面标题";
|
||||||
|
});
|
||||||
|
|
||||||
|
const metaLine = computed(() => {
|
||||||
|
const c = cfg.value;
|
||||||
|
const parts = [];
|
||||||
|
if (c.extractPerson) parts.push("抠图");
|
||||||
|
if (c.backgroundBlurEnabled || c.blurBackground) parts.push("虚化");
|
||||||
|
if (c.titleStrokeWidth) parts.push("描边");
|
||||||
|
if (!parts.length) parts.push("快速");
|
||||||
|
return parts.join(" · ");
|
||||||
|
});
|
||||||
|
|
||||||
|
function onThumbError(event) {
|
||||||
|
event.target.style.display = "none";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="cover-template-item"
|
||||||
|
:class="{ selected: isSelected }"
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
@click="emit('select', template)"
|
||||||
|
@keydown.enter="emit('select', template)"
|
||||||
|
>
|
||||||
|
<div class="template-thumbnail">
|
||||||
|
<img
|
||||||
|
v-if="template.thumbnailPath"
|
||||||
|
:src="template.thumbnailPath"
|
||||||
|
:alt="template.name"
|
||||||
|
@error="onThumbError"
|
||||||
|
/>
|
||||||
|
<div v-else class="placeholder" :style="previewStyle">
|
||||||
|
<span class="sample-title">{{ titleSample }}</span>
|
||||||
|
<span class="sample-meta">{{ metaLine }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="template-details">
|
||||||
|
<div class="template-name">
|
||||||
|
{{ template.name }}
|
||||||
|
<span v-if="isSystem" class="system-tag">系统模板</span>
|
||||||
|
</div>
|
||||||
|
<div class="template-info">
|
||||||
|
<span v-if="template.description">{{ template.description }}</span>
|
||||||
|
<span v-if="template.createdAt">
|
||||||
|
创建: {{ new Date(template.createdAt).toLocaleDateString("zh-CN") }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-buttons" @click.stop>
|
||||||
|
<Button label="编辑" size="small" severity="secondary" rounded @click="emit('edit', template)" />
|
||||||
|
<Button label="复制" size="small" severity="secondary" rounded @click="emit('clone', template)" />
|
||||||
|
<Button label="导出" size="small" severity="secondary" rounded @click="emit('export', template)" />
|
||||||
|
<Button
|
||||||
|
v-if="!isSystem"
|
||||||
|
label="删除"
|
||||||
|
size="small"
|
||||||
|
severity="danger"
|
||||||
|
rounded
|
||||||
|
text
|
||||||
|
@click="emit('delete', template)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cover-template-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 2px solid rgb(51 65 85 / 0.6);
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s ease, background-color 0.2s ease;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-template-item:hover {
|
||||||
|
border-color: rgb(96 165 250 / 0.7);
|
||||||
|
background-color: rgb(30 58 138 / 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-template-item.selected {
|
||||||
|
border-color: rgb(59 130 246);
|
||||||
|
background-color: rgb(30 64 175 / 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-thumbnail {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 9 / 16;
|
||||||
|
min-height: 200px;
|
||||||
|
max-height: min(46vh, 360px);
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: rgb(15 23 42);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-thumbnail img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 12% 8% 14%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sample-title {
|
||||||
|
font-size: clamp(14px, 4vw, 20px);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--title-color, #fff);
|
||||||
|
text-shadow: 0 2px 8px rgb(0 0 0 / 0.5);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sample-meta {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgb(148 163 184);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgb(226 232 240);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-tag {
|
||||||
|
color: rgb(250 204 21);
|
||||||
|
font-size: 10px;
|
||||||
|
background: rgb(250 204 21 / 0.12);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgb(100 116 139);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons :deep(.p-button) {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 64px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
100
src/components/cover/CoverShadowLayersEditor.vue
Normal file
100
src/components/cover/CoverShadowLayersEditor.vue
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<script setup>
|
||||||
|
const layers = defineModel("layers", { type: Array, default: () => [] });
|
||||||
|
|
||||||
|
const presets = [
|
||||||
|
{
|
||||||
|
label: "经典三层",
|
||||||
|
layers: [
|
||||||
|
{ enabled: true, offsetX: -2, offsetY: -2, blur: 12, color: "#000000", opacity: 100 },
|
||||||
|
{ enabled: true, offsetX: 2, offsetY: 2, blur: 16, color: "#000000", opacity: 100 },
|
||||||
|
{ enabled: true, offsetX: 0, offsetY: 0, blur: 14, color: "#000000", opacity: 100 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "柔和双层",
|
||||||
|
layers: [
|
||||||
|
{ enabled: true, offsetX: -1, offsetY: -1, blur: 4, color: "#FFFFFF", opacity: 60 },
|
||||||
|
{ enabled: true, offsetX: 2, offsetY: 2, blur: 2, color: "#000000", opacity: 65 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function addLayer() {
|
||||||
|
layers.value = [
|
||||||
|
...(layers.value || []),
|
||||||
|
{ enabled: true, offsetX: 0, offsetY: 0, blur: 8, color: "#000000", opacity: 100 },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLayer(index) {
|
||||||
|
layers.value = (layers.value || []).filter((_, i) => i !== index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPreset(preset) {
|
||||||
|
layers.value = preset.layers.map((l) => ({ ...l }));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span class="text-sm font-medium text-slate-200">多层阴影</span>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<Button label="添加阴影层" size="small" @click="addLayer" />
|
||||||
|
<Button
|
||||||
|
v-for="p in presets"
|
||||||
|
:key="p.label"
|
||||||
|
:label="p.label"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
outlined
|
||||||
|
@click="applyPreset(p)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(layer, index) in layers"
|
||||||
|
:key="index"
|
||||||
|
class="rounded-lg border border-slate-600/50 bg-slate-900/50 p-3"
|
||||||
|
>
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<span class="text-xs font-medium text-slate-300">阴影层 {{ index + 1 }}</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ToggleSwitch v-model="layer.enabled" />
|
||||||
|
<Button
|
||||||
|
icon="pi pi-trash"
|
||||||
|
size="small"
|
||||||
|
severity="danger"
|
||||||
|
text
|
||||||
|
@click="removeLayer(index)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label class="mb-0.5 block text-xs text-slate-500">X: {{ layer.offsetX }}px</label>
|
||||||
|
<input v-model.number="layer.offsetX" type="range" min="-20" max="20" step="1" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-0.5 block text-xs text-slate-500">Y: {{ layer.offsetY }}px</label>
|
||||||
|
<input v-model.number="layer.offsetY" type="range" min="-20" max="20" step="1" class="w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<label class="mb-0.5 block text-xs text-slate-500">模糊: {{ layer.blur }}px</label>
|
||||||
|
<input v-model.number="layer.blur" type="range" min="0" max="30" step="1" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label class="mb-0.5 block text-xs text-slate-500">颜色</label>
|
||||||
|
<input v-model="layer.color" type="color" class="h-7 w-full cursor-pointer rounded border border-slate-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-0.5 block text-xs text-slate-500">透明度: {{ layer.opacity }}%</label>
|
||||||
|
<input v-model.number="layer.opacity" type="range" min="0" max="100" step="5" class="w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
455
src/components/cover/CoverTemplateDialog.vue
Normal file
455
src/components/cover/CoverTemplateDialog.vue
Normal file
@@ -0,0 +1,455 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import { storeToRefs } from "pinia";
|
||||||
|
import { useWorkflowStore } from "../../stores/workflow.js";
|
||||||
|
import CoverPreviewCard from "./CoverPreviewCard.vue";
|
||||||
|
import CoverTemplateEditor from "./CoverTemplateEditor.vue";
|
||||||
|
import {
|
||||||
|
cloneCoverTemplate,
|
||||||
|
createBlankCoverTemplate,
|
||||||
|
downloadCoverTemplatesJson,
|
||||||
|
getCoverTemplateById,
|
||||||
|
isSystemCoverTemplateId,
|
||||||
|
loadAllCoverTemplates,
|
||||||
|
loadCustomCoverTemplates,
|
||||||
|
parseImportedCoverTemplates,
|
||||||
|
saveCustomCoverTemplates,
|
||||||
|
upsertCustomCoverTemplate,
|
||||||
|
} from "../../services/coverTemplateCatalog.js";
|
||||||
|
import { createCoverEditDraft } from "../../utils/coverTemplateDraft.js";
|
||||||
|
import { chooseCoverMaterial } from "../../services/coverGenerate.js";
|
||||||
|
|
||||||
|
const visible = defineModel("visible", { type: Boolean, default: false });
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
selectedId: { type: String, default: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["apply", "update:selectedId"]);
|
||||||
|
|
||||||
|
const workflow = useWorkflowStore();
|
||||||
|
const { coverCustomVideoPath, titleGenerated } = storeToRefs(workflow);
|
||||||
|
|
||||||
|
const templates = ref([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const applying = ref(false);
|
||||||
|
const feedback = ref({ severity: "", message: "" });
|
||||||
|
const fileInputRef = ref(null);
|
||||||
|
const pendingSelectedId = ref("");
|
||||||
|
|
||||||
|
const editMode = ref(false);
|
||||||
|
const editingDraft = ref(null);
|
||||||
|
|
||||||
|
const selectedTemplateId = computed({
|
||||||
|
get: () => pendingSelectedId.value || props.selectedId,
|
||||||
|
set: (id) => {
|
||||||
|
pendingSelectedId.value = id;
|
||||||
|
emit("update:selectedId", id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const materialFileName = computed(() => {
|
||||||
|
const p = String(coverCustomVideoPath.value || "").trim();
|
||||||
|
if (!p) return "";
|
||||||
|
const parts = p.replace(/\\/g, "/").split("/");
|
||||||
|
return parts[parts.length - 1] || p;
|
||||||
|
});
|
||||||
|
|
||||||
|
function showMsg(severity, message) {
|
||||||
|
feedback.value = { severity, message };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshTemplates() {
|
||||||
|
loading.value = true;
|
||||||
|
feedback.value = { severity: "", message: "" };
|
||||||
|
try {
|
||||||
|
templates.value = loadAllCoverTemplates();
|
||||||
|
if (!pendingSelectedId.value && props.selectedId) {
|
||||||
|
pendingSelectedId.value = props.selectedId;
|
||||||
|
}
|
||||||
|
if (!selectedTemplateId.value && templates.value.length) {
|
||||||
|
selectedTemplateId.value = templates.value[0].id;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
showMsg("error", `加载模板失败:${msg}`);
|
||||||
|
templates.value = [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(visible, (open) => {
|
||||||
|
if (open) {
|
||||||
|
editMode.value = false;
|
||||||
|
editingDraft.value = null;
|
||||||
|
pendingSelectedId.value = props.selectedId || workflow.coverTemplateId || "default";
|
||||||
|
refreshTemplates();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function selectTemplate(template) {
|
||||||
|
selectedTemplateId.value = template.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPickMaterial() {
|
||||||
|
feedback.value = { severity: "", message: "" };
|
||||||
|
const result = await chooseCoverMaterial(workflow);
|
||||||
|
if (result?.message) showMsg(result.ok ? "success" : "error", result.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClearMaterial() {
|
||||||
|
workflow.coverCustomVideoPath = "";
|
||||||
|
showMsg("success", "已清除自定义素材");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onExportAll() {
|
||||||
|
downloadCoverTemplatesJson(templates.value, "cover-templates-all.json");
|
||||||
|
showMsg("success", `已导出全部模板(共 ${templates.value.length} 个)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onExportOne(template) {
|
||||||
|
downloadCoverTemplatesJson(
|
||||||
|
[template],
|
||||||
|
`cover-template-${(template.name || template.id).replace(/[\s/\\:*?"<>|]/g, "_")}.json`,
|
||||||
|
);
|
||||||
|
showMsg("success", `已导出「${template.name}」`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImportClick() {
|
||||||
|
fileInputRef.value?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onImportFile(event) {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
event.target.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
try {
|
||||||
|
const imported = await parseImportedCoverTemplates(file);
|
||||||
|
const custom = loadCustomCoverTemplates();
|
||||||
|
const merged = [...custom];
|
||||||
|
for (const item of imported) {
|
||||||
|
const idx = merged.findIndex((t) => t.id === item.id);
|
||||||
|
if (idx >= 0) merged[idx] = item;
|
||||||
|
else merged.push(item);
|
||||||
|
}
|
||||||
|
saveCustomCoverTemplates(merged);
|
||||||
|
await refreshTemplates();
|
||||||
|
showMsg("success", `已导入 ${imported.length} 个模板`);
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
showMsg("error", `导入失败:${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNewTemplate() {
|
||||||
|
enterEditMode(createBlankCoverTemplate(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onClone(template) {
|
||||||
|
const created = cloneCoverTemplate(template);
|
||||||
|
enterEditMode(created, true);
|
||||||
|
showMsg("success", `已复制为「${created.name}」,请编辑后保存`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDelete(template) {
|
||||||
|
if (isSystemCoverTemplateId(template.id)) {
|
||||||
|
showMsg("error", "系统模板不可删除");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!window.confirm(`确定删除模板「${template.name}」?`)) return;
|
||||||
|
const custom = loadCustomCoverTemplates().filter((t) => t.id !== template.id);
|
||||||
|
saveCustomCoverTemplates(custom);
|
||||||
|
if (selectedTemplateId.value === template.id) {
|
||||||
|
selectedTemplateId.value = "default";
|
||||||
|
}
|
||||||
|
refreshTemplates();
|
||||||
|
showMsg("success", "模板已删除");
|
||||||
|
}
|
||||||
|
|
||||||
|
function enterEditMode(template, isNew = false) {
|
||||||
|
const base = isNew ? template : JSON.parse(JSON.stringify(template));
|
||||||
|
editingDraft.value = createCoverEditDraft(base, titleGenerated.value || "");
|
||||||
|
if (isNew && editingDraft.value) {
|
||||||
|
editingDraft.value.id = base.id || `cover_custom_${Date.now()}`;
|
||||||
|
}
|
||||||
|
editMode.value = true;
|
||||||
|
feedback.value = { severity: "", message: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEdit(template) {
|
||||||
|
if (isSystemCoverTemplateId(template.id)) {
|
||||||
|
const cloned = cloneCoverTemplate(template);
|
||||||
|
enterEditMode(cloned, true);
|
||||||
|
showMsg("success", "系统模板已复制为可编辑副本,保存后将加入自定义模板");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
enterEditMode(template);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editMode.value = false;
|
||||||
|
editingDraft.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmSaveTemplate() {
|
||||||
|
const name = String(editingDraft.value?.name || "").trim();
|
||||||
|
if (!name) {
|
||||||
|
showMsg("error", "请输入模板名称");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const saved = upsertCustomCoverTemplate({
|
||||||
|
...editingDraft.value,
|
||||||
|
name,
|
||||||
|
config: { ...editingDraft.value.config },
|
||||||
|
});
|
||||||
|
selectedTemplateId.value = saved.id;
|
||||||
|
editMode.value = false;
|
||||||
|
editingDraft.value = null;
|
||||||
|
refreshTemplates();
|
||||||
|
showMsg("success", `模板「${saved.name}」已保存`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onApply() {
|
||||||
|
if (!selectedTemplateId.value) {
|
||||||
|
showMsg("error", "请选择一个模板");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applying.value = true;
|
||||||
|
try {
|
||||||
|
const tpl = getCoverTemplateById(selectedTemplateId.value);
|
||||||
|
if (!tpl) {
|
||||||
|
showMsg("error", "获取模板失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
workflow.coverTemplateId = tpl.id;
|
||||||
|
workflow.coverTemplate = JSON.parse(JSON.stringify(tpl));
|
||||||
|
emit("apply", tpl);
|
||||||
|
visible.value = false;
|
||||||
|
showMsg("success", "封面配置已应用");
|
||||||
|
} finally {
|
||||||
|
applying.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDialog() {
|
||||||
|
visible.value = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="visible"
|
||||||
|
header="封面设置"
|
||||||
|
modal
|
||||||
|
:style="{ width: editMode ? 'min(1280px, 98vw)' : 'min(1120px, 96vw)' }"
|
||||||
|
:content-style="{ padding: 0 }"
|
||||||
|
:closable="!applying"
|
||||||
|
>
|
||||||
|
<div class="cover-settings-container">
|
||||||
|
<template v-if="!editMode">
|
||||||
|
<div class="control-bar">
|
||||||
|
<div class="control-left">
|
||||||
|
<h3 class="control-title">素材和模板</h3>
|
||||||
|
</div>
|
||||||
|
<div class="control-actions">
|
||||||
|
<Button
|
||||||
|
label="选择素材文件"
|
||||||
|
size="small"
|
||||||
|
icon="pi pi-upload"
|
||||||
|
@click="onPickMaterial"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="coverCustomVideoPath"
|
||||||
|
label="清除"
|
||||||
|
size="small"
|
||||||
|
severity="danger"
|
||||||
|
outlined
|
||||||
|
icon="pi pi-times"
|
||||||
|
@click="onClearMaterial"
|
||||||
|
/>
|
||||||
|
<span class="divider" aria-hidden="true" />
|
||||||
|
<Button
|
||||||
|
label="导出全部"
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
icon="pi pi-download"
|
||||||
|
:disabled="!templates.length"
|
||||||
|
@click="onExportAll"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="导入模板"
|
||||||
|
size="small"
|
||||||
|
outlined
|
||||||
|
icon="pi pi-upload"
|
||||||
|
@click="onImportClick"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="创建新模板"
|
||||||
|
size="small"
|
||||||
|
icon="pi pi-plus"
|
||||||
|
@click="onNewTemplate"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="materialFileName" class="selected-file">
|
||||||
|
✓ 已选择素材: {{ materialFileName }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="feedback.message && !editMode"
|
||||||
|
class="mx-4 text-sm"
|
||||||
|
:class="feedback.severity === 'error' ? 'text-red-400' : 'text-emerald-400'"
|
||||||
|
>
|
||||||
|
{{ feedback.message }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<div v-if="loading" class="py-12 text-center text-sm text-slate-400">
|
||||||
|
正在加载封面模板…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="templates.length" class="template-list">
|
||||||
|
<CoverPreviewCard
|
||||||
|
v-for="tpl in templates"
|
||||||
|
:key="tpl.id"
|
||||||
|
:template="tpl"
|
||||||
|
:is-selected="selectedTemplateId === tpl.id"
|
||||||
|
:is-system="tpl.is_system === 1 || tpl.isSystem || isSystemCoverTemplateId(tpl.id)"
|
||||||
|
@select="selectTemplate"
|
||||||
|
@edit="onEdit"
|
||||||
|
@clone="onClone"
|
||||||
|
@export="onExportOne"
|
||||||
|
@delete="onDelete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="empty-state">
|
||||||
|
<p class="mb-3 text-slate-400">暂无模板</p>
|
||||||
|
<Button label="创建第一个模板" size="small" @click="onNewTemplate" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<Button label="取消" size="small" severity="secondary" text @click="closeDialog" />
|
||||||
|
<Button
|
||||||
|
label="应用配置"
|
||||||
|
size="small"
|
||||||
|
icon="pi pi-check"
|
||||||
|
:loading="applying"
|
||||||
|
@click="onApply"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="min-h-0 flex-1 overflow-hidden px-2 py-2">
|
||||||
|
<CoverTemplateEditor
|
||||||
|
v-model:draft="editingDraft"
|
||||||
|
:reference-text="titleGenerated || ''"
|
||||||
|
@save="confirmSaveTemplate"
|
||||||
|
@cancel="cancelEdit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
class="hidden"
|
||||||
|
@change="onImportFile"
|
||||||
|
/>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cover-settings-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: min(92vh, 900px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px 8px;
|
||||||
|
border-bottom: 1px solid rgb(51 65 85 / 0.5);
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgb(226 232 240);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 20px;
|
||||||
|
background: rgb(71 85 105);
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-file {
|
||||||
|
padding: 0 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgb(52 211 153);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0 16px 8px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
max-height: min(62vh, 560px);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 6px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-list::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-list::-webkit-scrollbar-thumb {
|
||||||
|
background: rgb(71 85 105);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 48px 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 16px 14px;
|
||||||
|
border-top: 1px solid rgb(51 65 85 / 0.5);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
546
src/components/cover/CoverTemplateEditor.vue
Normal file
546
src/components/cover/CoverTemplateEditor.vue
Normal file
@@ -0,0 +1,546 @@
|
|||||||
|
<script setup>
|
||||||
|
import { computed, watch } from "vue";
|
||||||
|
import CoverCollapsibleSection from "./CoverCollapsibleSection.vue";
|
||||||
|
import CoverShadowLayersEditor from "./CoverShadowLayersEditor.vue";
|
||||||
|
import CoverEditorPreview from "./CoverEditorPreview.vue";
|
||||||
|
import { COVER_FONT_OPTIONS } from "../../config/coverTemplateDefaults.js";
|
||||||
|
import { syncCoverTextsFromReference } from "../../utils/coverTemplateDraft.js";
|
||||||
|
|
||||||
|
const draft = defineModel("draft", { type: Object, required: true });
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
referenceText: { type: String, default: "" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["save", "cancel"]);
|
||||||
|
|
||||||
|
const config = computed({
|
||||||
|
get: () => draft.value?.config || {},
|
||||||
|
set: (val) => {
|
||||||
|
if (draft.value) draft.value.config = val;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [
|
||||||
|
props.referenceText,
|
||||||
|
config.value.autoSplitText,
|
||||||
|
config.value.titleMaxLength,
|
||||||
|
config.value.subtitleMaxLength,
|
||||||
|
],
|
||||||
|
() => {
|
||||||
|
if (draft.value) syncCoverTextsFromReference(draft.value, props.referenceText);
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function updatePosition(key, pos) {
|
||||||
|
if (!draft.value) return;
|
||||||
|
const next = { ...draft.value.config };
|
||||||
|
next[key] = { x: pos.x, y: pos.y };
|
||||||
|
draft.value.config = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSave() {
|
||||||
|
emit("save");
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancel() {
|
||||||
|
emit("cancel");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="draft" class="flex h-[80vh] gap-4 overflow-hidden">
|
||||||
|
<!-- 左侧设置 -->
|
||||||
|
<div class="w-80 shrink-0 overflow-y-auto pr-1">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm font-medium text-slate-200">模板名称</label>
|
||||||
|
<InputText v-model="draft.name" class="w-full" placeholder="输入模板名称" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CoverCollapsibleSection title="基础设置">
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<label class="text-sm text-slate-300">背景模糊</label>
|
||||||
|
<ToggleSwitch v-model="config.backgroundBlurEnabled" />
|
||||||
|
</div>
|
||||||
|
<div v-if="config.backgroundBlurEnabled">
|
||||||
|
<label class="mb-1 block text-xs text-slate-400">
|
||||||
|
模糊度: {{ config.backgroundBlurIntensity }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="config.backgroundBlurIntensity"
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<label class="text-sm text-slate-300">人物描边</label>
|
||||||
|
<ToggleSwitch v-model="config.personBorderEnabled" />
|
||||||
|
</div>
|
||||||
|
<div v-if="config.personBorderEnabled" class="space-y-2">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs text-slate-400">描边样式</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
label="实线"
|
||||||
|
size="small"
|
||||||
|
:severity="config.personBorderStyle === 'solid' ? 'primary' : 'secondary'"
|
||||||
|
:outlined="config.personBorderStyle !== 'solid'"
|
||||||
|
@click="config.personBorderStyle = 'solid'"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="虚线"
|
||||||
|
size="small"
|
||||||
|
:severity="config.personBorderStyle === 'dashed' ? 'primary' : 'secondary'"
|
||||||
|
:outlined="config.personBorderStyle !== 'dashed'"
|
||||||
|
@click="config.personBorderStyle = 'dashed'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs text-slate-400">描边颜色</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
v-model="config.personBorderColor"
|
||||||
|
type="color"
|
||||||
|
class="h-8 w-12 cursor-pointer rounded border border-slate-600"
|
||||||
|
/>
|
||||||
|
<InputText v-model="config.personBorderColor" class="flex-1 text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs text-slate-400">
|
||||||
|
描边粗细: {{ config.personBorderWidth }}px
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="config.personBorderWidth"
|
||||||
|
type="range"
|
||||||
|
min="2"
|
||||||
|
max="200"
|
||||||
|
step="2"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<label class="text-sm text-slate-300">文字自动拆分</label>
|
||||||
|
<ToggleSwitch v-model="config.autoSplitText" />
|
||||||
|
</div>
|
||||||
|
<div v-if="config.autoSplitText" class="space-y-2">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs text-slate-400">
|
||||||
|
主标题最大字数: {{ config.titleMaxLength }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="config.titleMaxLength"
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="20"
|
||||||
|
step="1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs text-slate-400">
|
||||||
|
副标题最大字数: {{ config.subtitleMaxLength }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="config.subtitleMaxLength"
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="20"
|
||||||
|
step="1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CoverCollapsibleSection>
|
||||||
|
|
||||||
|
<CoverCollapsibleSection title="人像设置">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs text-slate-400">大小: {{ config.personSize }}%</label>
|
||||||
|
<input
|
||||||
|
v-model.number="config.personSize"
|
||||||
|
type="range"
|
||||||
|
min="10"
|
||||||
|
max="100"
|
||||||
|
step="5"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs text-slate-400">位置</label>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-slate-500">X: {{ config.personPosition?.x }}%</label>
|
||||||
|
<InputNumber
|
||||||
|
v-model="config.personPosition.x"
|
||||||
|
:min="0"
|
||||||
|
:max="100"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-slate-500">Y: {{ config.personPosition?.y }}%</label>
|
||||||
|
<InputNumber
|
||||||
|
v-model="config.personPosition.y"
|
||||||
|
:min="0"
|
||||||
|
:max="100"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CoverCollapsibleSection>
|
||||||
|
|
||||||
|
<CoverCollapsibleSection title="背景设置" :header-extra="true">
|
||||||
|
<template #header-extra>
|
||||||
|
<div class="flex items-center gap-2" @click.stop>
|
||||||
|
<span class="text-xs text-slate-400">
|
||||||
|
{{ config.backgroundEnabled ? "已开启" : "已关闭" }}
|
||||||
|
</span>
|
||||||
|
<ToggleSwitch v-model="config.backgroundEnabled" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-if="config.backgroundEnabled" class="space-y-2">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs text-slate-400">背景缩放: {{ config.backgroundSize }}%</label>
|
||||||
|
<input
|
||||||
|
v-model.number="config.backgroundSize"
|
||||||
|
type="range"
|
||||||
|
min="50"
|
||||||
|
max="150"
|
||||||
|
step="5"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CoverCollapsibleSection>
|
||||||
|
|
||||||
|
<CoverCollapsibleSection title="主标题" :default-open="true">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm text-slate-300">文字内容</label>
|
||||||
|
<div
|
||||||
|
class="flex min-h-10 items-center rounded border border-slate-600 bg-slate-900/50 px-3 py-2 text-sm text-slate-300"
|
||||||
|
>
|
||||||
|
{{ config.titleText || "(暂无,请在步骤 04 生成标题)" }}
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-slate-500">
|
||||||
|
在步骤 04 设置文案,此处显示拆分结果
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs text-slate-400">字体</label>
|
||||||
|
<Select
|
||||||
|
v-model="config.titleFontFamily"
|
||||||
|
:options="COVER_FONT_OPTIONS"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs text-slate-400">字号: {{ config.titleFontSize }}px</label>
|
||||||
|
<input
|
||||||
|
v-model.number="config.titleFontSize"
|
||||||
|
type="range"
|
||||||
|
min="20"
|
||||||
|
max="500"
|
||||||
|
step="5"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs text-slate-400">字重: {{ config.titleFontWeight }}</label>
|
||||||
|
<input
|
||||||
|
v-model.number="config.titleFontWeight"
|
||||||
|
type="range"
|
||||||
|
min="100"
|
||||||
|
max="1000"
|
||||||
|
step="50"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs text-slate-400">文字方向</label>
|
||||||
|
<div class="flex gap-4 text-sm text-slate-300">
|
||||||
|
<label class="flex items-center gap-1">
|
||||||
|
<RadioButton v-model="config.titleDirection" input-id="title-h" value="horizontal" />
|
||||||
|
<span for="title-h">横排</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1">
|
||||||
|
<RadioButton v-model="config.titleDirection" input-id="title-v" value="vertical" />
|
||||||
|
<span for="title-v">竖排</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs text-slate-400">
|
||||||
|
每行字数: {{ config.titleMaxCharsPerLine }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="config.titleMaxCharsPerLine"
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="50"
|
||||||
|
step="1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs text-slate-400">
|
||||||
|
字符间距: {{ config.titleCharSpacing }}px
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="config.titleCharSpacing"
|
||||||
|
type="range"
|
||||||
|
min="-50"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs text-slate-400">
|
||||||
|
行间距: {{ config.titleLineSpacing }}px
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="config.titleLineSpacing"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="300"
|
||||||
|
step="1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs text-slate-400">颜色</label>
|
||||||
|
<input v-model="config.titleColor" type="color" class="h-8 w-full cursor-pointer rounded border border-slate-600" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs text-slate-400">描边颜色</label>
|
||||||
|
<input v-model="config.titleStrokeColor" type="color" class="h-8 w-full cursor-pointer rounded border border-slate-600" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs text-slate-400">
|
||||||
|
描边粗细: {{ config.titleStrokeWidth }}px
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="config.titleStrokeWidth"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="10"
|
||||||
|
step="1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CoverShadowLayersEditor v-model:layers="config.titleShadowLayers" />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs text-slate-400">位置</label>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-slate-500">X: {{ config.titlePosition?.x }}%</label>
|
||||||
|
<InputNumber v-model="config.titlePosition.x" :min="0" :max="100" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-slate-500">Y: {{ config.titlePosition?.y }}%</label>
|
||||||
|
<InputNumber v-model="config.titlePosition.y" :min="0" :max="100" class="w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs text-slate-400">旋转角度: {{ config.titleRotation }}°</label>
|
||||||
|
<input
|
||||||
|
v-model.number="config.titleRotation"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="360"
|
||||||
|
step="1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="border-t border-slate-600/50 pt-3">
|
||||||
|
<div class="mb-2 flex items-center justify-between">
|
||||||
|
<span class="text-sm text-slate-300">文字背景</span>
|
||||||
|
<ToggleSwitch v-model="config.titleBackgroundEnabled" />
|
||||||
|
</div>
|
||||||
|
<div v-if="config.titleBackgroundEnabled" class="space-y-2">
|
||||||
|
<input v-model="config.titleBackgroundColor" type="color" class="h-8 w-full rounded border border-slate-600" />
|
||||||
|
<label class="text-xs text-slate-400">不透明度: {{ config.titleBackgroundOpacity }}%</label>
|
||||||
|
<input
|
||||||
|
v-model.number="config.titleBackgroundOpacity"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
step="5"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CoverCollapsibleSection>
|
||||||
|
|
||||||
|
<CoverCollapsibleSection title="副标题" :default-open="false">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm text-slate-300">文字内容</label>
|
||||||
|
<div
|
||||||
|
class="rounded border border-slate-600 bg-slate-900/50 px-3 py-2 text-sm text-slate-300"
|
||||||
|
>
|
||||||
|
{{ config.subtitleText || "(暂无)" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs text-slate-400">字体</label>
|
||||||
|
<Select
|
||||||
|
v-model="config.subtitleFontFamily"
|
||||||
|
:options="COVER_FONT_OPTIONS"
|
||||||
|
option-label="label"
|
||||||
|
option-value="value"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs text-slate-400">字号: {{ config.subtitleFontSize }}px</label>
|
||||||
|
<input
|
||||||
|
v-model.number="config.subtitleFontSize"
|
||||||
|
type="range"
|
||||||
|
min="12"
|
||||||
|
max="400"
|
||||||
|
step="5"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs text-slate-400">字重: {{ config.subtitleFontWeight }}</label>
|
||||||
|
<input
|
||||||
|
v-model.number="config.subtitleFontWeight"
|
||||||
|
type="range"
|
||||||
|
min="100"
|
||||||
|
max="1000"
|
||||||
|
step="50"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs text-slate-400">文字方向</label>
|
||||||
|
<div class="flex gap-4 text-sm text-slate-300">
|
||||||
|
<label class="flex items-center gap-1">
|
||||||
|
<RadioButton v-model="config.subtitleDirection" input-id="sub-h" value="horizontal" />
|
||||||
|
横排
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-1">
|
||||||
|
<RadioButton v-model="config.subtitleDirection" input-id="sub-v" value="vertical" />
|
||||||
|
竖排
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs text-slate-400">
|
||||||
|
每行字数: {{ config.subtitleMaxCharsPerLine }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model.number="config.subtitleMaxCharsPerLine"
|
||||||
|
type="range"
|
||||||
|
min="1"
|
||||||
|
max="50"
|
||||||
|
step="1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs text-slate-400">颜色</label>
|
||||||
|
<input v-model="config.subtitleColor" type="color" class="h-8 w-full rounded border border-slate-600" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CoverShadowLayersEditor v-model:layers="config.subtitleShadowLayers" />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs text-slate-400">位置</label>
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-slate-500">X: {{ config.subtitlePosition?.x }}%</label>
|
||||||
|
<InputNumber v-model="config.subtitlePosition.x" :min="0" :max="100" class="w-full" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs text-slate-500">Y: {{ config.subtitlePosition?.y }}%</label>
|
||||||
|
<InputNumber v-model="config.subtitlePosition.y" :min="0" :max="100" class="w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs text-slate-400">旋转: {{ config.subtitleRotation }}°</label>
|
||||||
|
<input
|
||||||
|
v-model.number="config.subtitleRotation"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="360"
|
||||||
|
step="1"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CoverCollapsibleSection>
|
||||||
|
|
||||||
|
<CoverCollapsibleSection title="蒙版" :header-extra="true" :default-open="false">
|
||||||
|
<template #header-extra>
|
||||||
|
<ToggleSwitch v-model="config.maskEnabled" @click.stop />
|
||||||
|
</template>
|
||||||
|
<p v-if="!config.maskEnabled" class="text-xs text-slate-500">开启后可配置蒙版(生成时生效)</p>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-xs text-slate-400">大小: {{ config.maskSize }}%</label>
|
||||||
|
<input v-model.number="config.maskSize" type="range" min="10" max="200" class="w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CoverCollapsibleSection>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧预览 -->
|
||||||
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
|
<CoverEditorPreview
|
||||||
|
:config="config"
|
||||||
|
@update-person-position="updatePosition('personPosition', $event)"
|
||||||
|
@update-title-position="updatePosition('titlePosition', $event)"
|
||||||
|
@update-subtitle-position="updatePosition('subtitlePosition', $event)"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<Button label="取消" severity="secondary" outlined @click="onCancel" />
|
||||||
|
<Button label="保存模板" icon="pi pi-save" @click="onSave" />
|
||||||
|
</template>
|
||||||
|
</CoverEditorPreview>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -95,7 +95,7 @@ async function onGenerateTitleTags() {
|
|||||||
<div class="dashboard-field-label mb-1 flex items-center justify-between gap-2">
|
<div class="dashboard-field-label mb-1 flex items-center justify-between gap-2">
|
||||||
<span>生成的标题(可编辑)</span>
|
<span>生成的标题(可编辑)</span>
|
||||||
<span v-if="titleLineCount > 1" class="text-xs font-normal text-slate-500">
|
<span v-if="titleLineCount > 1" class="text-xs font-normal text-slate-500">
|
||||||
共 {{ titleLineCount }} 条建议,可选用其一或自行修改
|
共 {{ titleLineCount }} 条建议;封面/发布取首条,超过 5 条时随机取一行
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
import { useWorkflowStore } from "../../stores/workflow.js";
|
import { useWorkflowStore } from "../../stores/workflow.js";
|
||||||
import { COVER_TEMPLATES } from "../../config/coverTemplates.js";
|
import CoverTemplateDialog from "../cover/CoverTemplateDialog.vue";
|
||||||
|
|
||||||
const workflow = useWorkflowStore();
|
const workflow = useWorkflowStore();
|
||||||
const {
|
const {
|
||||||
@@ -17,11 +17,6 @@ const {
|
|||||||
const feedback = ref({ severity: "", message: "" });
|
const feedback = ref({ severity: "", message: "" });
|
||||||
const settingsVisible = ref(false);
|
const settingsVisible = ref(false);
|
||||||
|
|
||||||
const templateOptions = COVER_TEMPLATES.map((t) => ({
|
|
||||||
label: t.label,
|
|
||||||
value: t.id,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const hasSourceVideo = computed(
|
const hasSourceVideo = computed(
|
||||||
() => Boolean(processedVideoPath.value || generatedVideoPath.value),
|
() => Boolean(processedVideoPath.value || generatedVideoPath.value),
|
||||||
);
|
);
|
||||||
@@ -45,17 +40,10 @@ async function onGenerateCover() {
|
|||||||
showFeedback(await workflow.generateCover());
|
showFeedback(await workflow.generateCover());
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onPickMaterial() {
|
function onSettingsApplied() {
|
||||||
feedback.value = { severity: "", message: "" };
|
|
||||||
const result = await workflow.chooseCoverMaterial();
|
|
||||||
if (result?.message) showFeedback(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applySettings() {
|
|
||||||
settingsVisible.value = false;
|
|
||||||
feedback.value = {
|
feedback.value = {
|
||||||
severity: "success",
|
severity: "success",
|
||||||
message: "封面设置已保存,可点击「自动生成封面」",
|
message: "封面设置已应用,可点击「自动生成封面」",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -78,6 +66,19 @@ function applySettings() {
|
|||||||
@click="settingsVisible = true"
|
@click="settingsVisible = true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="workflow.coverTemplate?.name || workflow.coverTemplateLabel"
|
||||||
|
class="mt-2 truncate text-xs text-slate-400"
|
||||||
|
>
|
||||||
|
当前模板:{{ workflow.coverTemplate?.name || workflow.coverTemplateLabel }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
v-if="workflow.coverMaterialFileName"
|
||||||
|
class="mt-0.5 truncate text-xs text-emerald-400/90"
|
||||||
|
>
|
||||||
|
素材:{{ workflow.coverMaterialFileName }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
v-if="!hasSourceVideo"
|
v-if="!hasSourceVideo"
|
||||||
class="mt-2 text-xs text-amber-400/90"
|
class="mt-2 text-xs text-amber-400/90"
|
||||||
@@ -112,67 +113,10 @@ function applySettings() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog
|
<CoverTemplateDialog
|
||||||
v-model:visible="settingsVisible"
|
v-model:visible="settingsVisible"
|
||||||
header="封面设置"
|
v-model:selected-id="coverTemplateId"
|
||||||
modal
|
@apply="onSettingsApplied"
|
||||||
:style="{ width: '24rem' }"
|
|
||||||
>
|
|
||||||
<div class="flex flex-col gap-3">
|
|
||||||
<div>
|
|
||||||
<div class="dashboard-field-label mb-2">样式模板</div>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<div
|
|
||||||
v-for="opt in templateOptions"
|
|
||||||
:key="opt.value"
|
|
||||||
class="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<RadioButton
|
|
||||||
v-model="coverTemplateId"
|
|
||||||
:input-id="`cover-tpl-${opt.value}`"
|
|
||||||
:value="opt.value"
|
|
||||||
/>
|
/>
|
||||||
<label :for="`cover-tpl-${opt.value}`" class="cursor-pointer text-sm">
|
|
||||||
{{ opt.label }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div class="dashboard-field-label mb-1">封面素材视频(可选)</div>
|
|
||||||
<p class="dashboard-muted mb-2 text-xs">
|
|
||||||
不选择时从当前口播视频第 3 秒抽帧;可上传其他视频作为封面底图。
|
|
||||||
「虚化/抠图」类模板需 bundled Python 运行时(首次较慢)。
|
|
||||||
</p>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<Button
|
|
||||||
label="选择视频"
|
|
||||||
size="small"
|
|
||||||
outlined
|
|
||||||
:disabled="coverGenerating"
|
|
||||||
@click="onPickMaterial"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
v-if="workflow.coverCustomVideoPath"
|
|
||||||
label="清除"
|
|
||||||
size="small"
|
|
||||||
severity="secondary"
|
|
||||||
text
|
|
||||||
@click="workflow.coverCustomVideoPath = ''"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
v-if="workflow.coverMaterialFileName"
|
|
||||||
class="mt-1 truncate text-xs text-slate-400"
|
|
||||||
>
|
|
||||||
{{ workflow.coverMaterialFileName }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<template #footer>
|
|
||||||
<Button label="确定" size="small" @click="applySettings" />
|
|
||||||
</template>
|
|
||||||
</Dialog>
|
|
||||||
</DashboardCard>
|
</DashboardCard>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
168
src/config/coverTemplateDefaults.js
Normal file
168
src/config/coverTemplateDefaults.js
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
/** 与 Electron CoverCustomEditor 默认配置对齐(advanced_cover_generator.py) */
|
||||||
|
|
||||||
|
export const COVER_CANVAS_WIDTH = 1080;
|
||||||
|
export const COVER_CANVAS_HEIGHT = 1440;
|
||||||
|
export const COVER_PREVIEW_SCALE = 0.4;
|
||||||
|
|
||||||
|
export const COVER_FONT_OPTIONS = [
|
||||||
|
{ label: "黑体", value: "SimHei" },
|
||||||
|
{ label: "宋体", value: "SimSun" },
|
||||||
|
{ label: "微软雅黑", value: "Microsoft YaHei" },
|
||||||
|
{ label: "Arial", value: "Arial" },
|
||||||
|
{ label: "Times New Roman", value: "Times New Roman" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Record<string, unknown>} [overrides]
|
||||||
|
*/
|
||||||
|
export function createDefaultCoverTemplateConfig(overrides = {}) {
|
||||||
|
return {
|
||||||
|
backgroundEnabled: true,
|
||||||
|
backgroundBlurEnabled: true,
|
||||||
|
backgroundBlurIntensity: 44,
|
||||||
|
blurBackground: true,
|
||||||
|
extractPerson: true,
|
||||||
|
|
||||||
|
personBorderEnabled: true,
|
||||||
|
personBorderStyle: "solid",
|
||||||
|
personBorderColor: "#FFFFFF",
|
||||||
|
personBorderWidth: 8,
|
||||||
|
personBorderDashLength: null,
|
||||||
|
personBorderGapLength: null,
|
||||||
|
personSize: 50,
|
||||||
|
personPosition: { x: 54.6, y: 34.2 },
|
||||||
|
personRotation: 0,
|
||||||
|
|
||||||
|
backgroundSize: 100,
|
||||||
|
backgroundPosition: { x: 50, y: 50 },
|
||||||
|
|
||||||
|
autoSplitText: true,
|
||||||
|
titleMaxLength: 14,
|
||||||
|
subtitleMaxLength: 16,
|
||||||
|
|
||||||
|
titleText: "",
|
||||||
|
titleFontFamily: "Microsoft YaHei",
|
||||||
|
titleFontSize: 110,
|
||||||
|
titleFontWeight: 100,
|
||||||
|
titleColor: "#FFFFFF",
|
||||||
|
titleStrokeColor: "#000000",
|
||||||
|
titleStrokeWidth: 0,
|
||||||
|
titleShadowColor: "#000000",
|
||||||
|
titleShadowOffsetX: 0,
|
||||||
|
titleShadowOffsetY: 0,
|
||||||
|
titleShadowBlur: 0,
|
||||||
|
titleShadowEnabled: false,
|
||||||
|
titleShadowLayers: [
|
||||||
|
{ enabled: true, offsetX: -2, offsetY: -2, blur: 12, color: "#000000", opacity: 100 },
|
||||||
|
{ enabled: true, offsetX: 2, offsetY: 2, blur: 16, color: "#000000", opacity: 100 },
|
||||||
|
{ enabled: true, offsetX: 0, offsetY: 0, blur: 14, color: "#000000", opacity: 100 },
|
||||||
|
],
|
||||||
|
titlePosition: { x: 39.8, y: 69.4 },
|
||||||
|
titleRotation: 0,
|
||||||
|
titleDirection: "horizontal",
|
||||||
|
titleCharSpacing: 12,
|
||||||
|
titleLineSpacing: 118,
|
||||||
|
titleMaxCharsPerLine: 10,
|
||||||
|
|
||||||
|
titleBackgroundEnabled: false,
|
||||||
|
titleBackgroundColor: "#000000",
|
||||||
|
titleBackgroundOpacity: 70,
|
||||||
|
titleBackgroundShape: "rectangle",
|
||||||
|
titleBackgroundSize: { width: 30, height: 10 },
|
||||||
|
titleBackgroundPosition: { x: 50, y: 30 },
|
||||||
|
titleBackgroundPoints: [],
|
||||||
|
titleBackgroundRadius: 10,
|
||||||
|
titleBackgroundRotation: 0,
|
||||||
|
|
||||||
|
subtitleText: "",
|
||||||
|
subtitleFontFamily: "Microsoft YaHei",
|
||||||
|
subtitleFontSize: 117,
|
||||||
|
subtitleFontWeight: 500,
|
||||||
|
subtitleColor: "#EAF273",
|
||||||
|
subtitleStrokeColor: "#000000",
|
||||||
|
subtitleStrokeWidth: 0,
|
||||||
|
subtitleShadowColor: "#000000",
|
||||||
|
subtitleShadowOffsetX: 0,
|
||||||
|
subtitleShadowOffsetY: 0,
|
||||||
|
subtitleShadowBlur: 0,
|
||||||
|
subtitleShadowEnabled: false,
|
||||||
|
subtitleShadowLayers: [
|
||||||
|
{ enabled: true, offsetX: -2, offsetY: -2, blur: 2, color: "#FFFFFF", opacity: 60 },
|
||||||
|
{ enabled: true, offsetX: 2, offsetY: 2, blur: 0, color: "#000000", opacity: 65 },
|
||||||
|
{ enabled: true, offsetX: 0, offsetY: 0, blur: 4, color: "#000000", opacity: 50 },
|
||||||
|
],
|
||||||
|
subtitlePosition: { x: 58.8, y: 77.8 },
|
||||||
|
subtitleRotation: 0,
|
||||||
|
subtitleDirection: "horizontal",
|
||||||
|
subtitleCharSpacing: 12,
|
||||||
|
subtitleLineSpacing: 72,
|
||||||
|
subtitleMaxCharsPerLine: 15,
|
||||||
|
|
||||||
|
subtitleBackgroundEnabled: false,
|
||||||
|
subtitleBackgroundColor: "#000000",
|
||||||
|
subtitleBackgroundOpacity: 70,
|
||||||
|
subtitleBackgroundShape: "rectangle",
|
||||||
|
subtitleBackgroundSize: { width: 30, height: 10 },
|
||||||
|
subtitleBackgroundPosition: { x: 50, y: 60 },
|
||||||
|
subtitleBackgroundPoints: [],
|
||||||
|
subtitleBackgroundRadius: 10,
|
||||||
|
subtitleBackgroundRotation: 0,
|
||||||
|
|
||||||
|
maskEnabled: false,
|
||||||
|
maskImagePath: undefined,
|
||||||
|
maskSize: 100,
|
||||||
|
maskPosition: { x: 50, y: 50 },
|
||||||
|
maskColor: undefined,
|
||||||
|
maskOpacity: 100,
|
||||||
|
maskShape: "rectangle",
|
||||||
|
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简易预设 → 高级编辑器配置
|
||||||
|
* @param {Record<string, unknown>} simple
|
||||||
|
*/
|
||||||
|
export function migrateSimpleCoverConfig(simple) {
|
||||||
|
if (!simple || typeof simple !== "object") {
|
||||||
|
return createDefaultCoverTemplateConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (simple.titlePosition && typeof simple.titlePosition === "object") {
|
||||||
|
return createDefaultCoverTemplateConfig({ ...simple });
|
||||||
|
}
|
||||||
|
|
||||||
|
const pos = String(simple.titlePosition || "bottom");
|
||||||
|
const titleY = pos === "top" ? 22 : pos === "center" ? 50 : 80;
|
||||||
|
|
||||||
|
const base = createDefaultCoverTemplateConfig({
|
||||||
|
backgroundBlurEnabled: Boolean(simple.backgroundBlurEnabled ?? simple.blurBackground),
|
||||||
|
blurBackground: Boolean(simple.backgroundBlurEnabled ?? simple.blurBackground),
|
||||||
|
extractPerson: Boolean(simple.extractPerson),
|
||||||
|
personBorderEnabled: Boolean(simple.personOutlineColor || simple.personOutlineWidth),
|
||||||
|
personBorderColor: simple.personOutlineColor || "#FFD700",
|
||||||
|
personBorderWidth: simple.personOutlineWidth ?? 8,
|
||||||
|
personSize: simple.personSize ?? 80,
|
||||||
|
titleFontSize: simple.titleFontSize ?? 110,
|
||||||
|
titleColor: simple.titleFontColor || simple.titleColor || "#FFFFFF",
|
||||||
|
titleFontFamily: simple.titleFontFamily || "Microsoft YaHei",
|
||||||
|
titleStrokeWidth: simple.titleStrokeWidth ?? 0,
|
||||||
|
titleStrokeColor: simple.titleStrokeColor || "#000000",
|
||||||
|
titlePosition: { x: 50, y: titleY },
|
||||||
|
subtitlePosition: { x: 50, y: Math.min(titleY + 12, 92) },
|
||||||
|
});
|
||||||
|
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Record<string, unknown>} config
|
||||||
|
*/
|
||||||
|
export function isFullCoverTemplateConfig(config) {
|
||||||
|
return Boolean(
|
||||||
|
config?.titlePosition &&
|
||||||
|
typeof config.titlePosition === "object" &&
|
||||||
|
"x" in config.titlePosition,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
/** 封面样式预设(简易 ffmpeg + 高级 Python 模板) */
|
/** 封面样式预设(简易 ffmpeg + 高级 Python 模板) */
|
||||||
|
|
||||||
|
import { resolveStep4TitleForCover } from "../utils/coverTextSplit.js";
|
||||||
|
|
||||||
export const COVER_TEMPLATES = [
|
export const COVER_TEMPLATES = [
|
||||||
{
|
{
|
||||||
id: "default",
|
id: "default",
|
||||||
@@ -75,11 +77,15 @@ export function getCoverTemplate(templateId) {
|
|||||||
*/
|
*/
|
||||||
export function isAdvancedCoverConfig(config) {
|
export function isAdvancedCoverConfig(config) {
|
||||||
if (!config || typeof config !== "object") return false;
|
if (!config || typeof config !== "object") return false;
|
||||||
|
if (config.titlePosition && typeof config.titlePosition === "object") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
config.blurBackground !== undefined ||
|
config.blurBackground !== undefined ||
|
||||||
config.extractPerson !== undefined ||
|
config.extractPerson !== undefined ||
|
||||||
config.personOutlineColor !== undefined ||
|
config.personOutlineColor !== undefined ||
|
||||||
config.personOutlineWidth !== undefined ||
|
config.personOutlineWidth !== undefined ||
|
||||||
|
config.personBorderEnabled !== undefined ||
|
||||||
config.maskImagePath !== undefined ||
|
config.maskImagePath !== undefined ||
|
||||||
Boolean(config.titleFontFamily) ||
|
Boolean(config.titleFontFamily) ||
|
||||||
config.titleBackgroundEnabled !== undefined ||
|
config.titleBackgroundEnabled !== undefined ||
|
||||||
@@ -102,11 +108,8 @@ export function buildCoverEffectStyle(tpl, extra = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} titleGenerated
|
* @param {string} titleGenerated 步骤 04 标题(可多行建议)
|
||||||
*/
|
*/
|
||||||
export function pickCoverTitleText(titleGenerated) {
|
export function pickCoverTitleText(titleGenerated) {
|
||||||
const raw = String(titleGenerated || "").trim();
|
return resolveStep4TitleForCover(titleGenerated);
|
||||||
if (!raw) return "";
|
|
||||||
const line = raw.split(/\r?\n/)[0].trim();
|
|
||||||
return line.slice(0, 80);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,19 +6,45 @@ import {
|
|||||||
getCoverTemplate,
|
getCoverTemplate,
|
||||||
pickCoverTitleText,
|
pickCoverTitleText,
|
||||||
} from "../config/coverTemplates.js";
|
} from "../config/coverTemplates.js";
|
||||||
|
import { getCoverTemplateById } from "./coverTemplateCatalog.js";
|
||||||
|
import { normalizeCoverConfigForGenerator } from "../utils/coverTemplateDraft.js";
|
||||||
|
import { splitCoverReferenceText } from "../utils/coverTextSplit.js";
|
||||||
|
|
||||||
const COVER_SCRIPT = "cover_generate.js";
|
const COVER_SCRIPT = "cover_generate.js";
|
||||||
|
|
||||||
|
const MATERIAL_IMAGE_EXT = new Set(["jpg", "jpeg", "png", "bmp", "webp"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} filePath
|
||||||
|
*/
|
||||||
|
export function isCoverMaterialImage(filePath) {
|
||||||
|
const ext = String(filePath || "").split(".").pop()?.toLowerCase() || "";
|
||||||
|
return MATERIAL_IMAGE_EXT.has(ext);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @returns {Promise<string | null>}
|
* @returns {Promise<string | null>}
|
||||||
*/
|
*/
|
||||||
export async function pickCoverMaterialVideo() {
|
export async function pickCoverMaterialFile() {
|
||||||
const selected = await open({
|
const selected = await open({
|
||||||
multiple: false,
|
multiple: false,
|
||||||
filters: [
|
filters: [
|
||||||
{
|
{
|
||||||
name: "视频",
|
name: "素材文件",
|
||||||
extensions: ["mp4", "mov", "mkv", "avi", "webm"],
|
extensions: [
|
||||||
|
"mp4",
|
||||||
|
"avi",
|
||||||
|
"mov",
|
||||||
|
"mkv",
|
||||||
|
"webm",
|
||||||
|
"flv",
|
||||||
|
"wmv",
|
||||||
|
"jpg",
|
||||||
|
"jpeg",
|
||||||
|
"png",
|
||||||
|
"bmp",
|
||||||
|
"webp",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -27,6 +53,9 @@ export async function pickCoverMaterialVideo() {
|
|||||||
return String(selected);
|
return String(selected);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated 使用 pickCoverMaterialFile */
|
||||||
|
export const pickCoverMaterialVideo = pickCoverMaterialFile;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} filePath
|
* @param {string} filePath
|
||||||
*/
|
*/
|
||||||
@@ -53,8 +82,24 @@ export async function executeGenerateCover(store) {
|
|||||||
return { ok: false, message: "请先在步骤 04 生成标题文字" };
|
return { ok: false, message: "请先在步骤 04 生成标题文字" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const tpl = getCoverTemplate(store.coverTemplateId || "default");
|
const stored = store.coverTemplate;
|
||||||
const effectStyle = buildCoverEffectStyle(tpl, {
|
const catalogTpl = getCoverTemplateById(store.coverTemplateId || "default");
|
||||||
|
const presetTpl = getCoverTemplate(store.coverTemplateId || "default");
|
||||||
|
let rawStyle =
|
||||||
|
stored?.config && typeof stored.config === "object"
|
||||||
|
? { ...stored.config }
|
||||||
|
: catalogTpl?.config
|
||||||
|
? { ...catalogTpl.config }
|
||||||
|
: { ...presetTpl };
|
||||||
|
const split = splitCoverReferenceText(store.titleGenerated, {
|
||||||
|
autoSplit: rawStyle.autoSplitText !== false,
|
||||||
|
titleMaxLength: Number(rawStyle.titleMaxLength) || 14,
|
||||||
|
subtitleMaxLength: Number(rawStyle.subtitleMaxLength) || 16,
|
||||||
|
});
|
||||||
|
const effectStyle = normalizeCoverConfigForGenerator({
|
||||||
|
...rawStyle,
|
||||||
|
titleText: rawStyle.titleText || split.title || titleText,
|
||||||
|
subtitleText: rawStyle.subtitleText || split.subtitle,
|
||||||
customVideoPath: store.coverCustomVideoPath || "",
|
customVideoPath: store.coverCustomVideoPath || "",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -106,8 +151,14 @@ export async function executeGenerateCover(store) {
|
|||||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||||
*/
|
*/
|
||||||
export async function chooseCoverMaterial(store) {
|
export async function chooseCoverMaterial(store) {
|
||||||
const path = await pickCoverMaterialVideo();
|
const path = await pickCoverMaterialFile();
|
||||||
if (!path) return { ok: false, message: "" };
|
if (!path) return { ok: false, message: "" };
|
||||||
store.coverCustomVideoPath = path;
|
store.coverCustomVideoPath = path;
|
||||||
return { ok: true, message: "已选择封面素材视频" };
|
const isImage = isCoverMaterialImage(path);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
message: isImage
|
||||||
|
? "图片文件已选择,将直接用于模板生成"
|
||||||
|
: "视频文件已选择",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
222
src/services/coverTemplateCatalog.js
Normal file
222
src/services/coverTemplateCatalog.js
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import { COVER_TEMPLATES } from "../config/coverTemplates.js";
|
||||||
|
import {
|
||||||
|
createDefaultCoverTemplateConfig,
|
||||||
|
isFullCoverTemplateConfig,
|
||||||
|
migrateSimpleCoverConfig,
|
||||||
|
} from "../config/coverTemplateDefaults.js";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "aiclient_custom_cover_templates";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} CoverTemplateItem
|
||||||
|
* @property {string} id
|
||||||
|
* @property {string} name
|
||||||
|
* @property {string} [description]
|
||||||
|
* @property {Record<string, unknown>} config
|
||||||
|
* @property {boolean} [isSystem]
|
||||||
|
* @property {number} [is_system]
|
||||||
|
* @property {number} [createdAt]
|
||||||
|
* @property {number} [updatedAt]
|
||||||
|
* @property {string} [thumbnailPath]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {typeof COVER_TEMPLATES[number]} tpl
|
||||||
|
*/
|
||||||
|
function systemTemplateFromPreset(tpl) {
|
||||||
|
const { id, label, ...simple } = tpl;
|
||||||
|
const config = ["advanced_blur", "advanced_outline", "pil_stroke"].includes(id)
|
||||||
|
? migrateSimpleCoverConfig(simple)
|
||||||
|
: simple;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: label,
|
||||||
|
description: describeCoverPreset(tpl),
|
||||||
|
config,
|
||||||
|
isSystem: true,
|
||||||
|
is_system: 1,
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新建空白高级模板
|
||||||
|
*/
|
||||||
|
export function createBlankCoverTemplate() {
|
||||||
|
const now = Date.now();
|
||||||
|
return {
|
||||||
|
id: `cover_custom_${now}`,
|
||||||
|
name: "新模板",
|
||||||
|
description: "",
|
||||||
|
config: createDefaultCoverTemplateConfig(),
|
||||||
|
isSystem: false,
|
||||||
|
is_system: 0,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {typeof COVER_TEMPLATES[number]} tpl
|
||||||
|
*/
|
||||||
|
function describeCoverPreset(tpl) {
|
||||||
|
if (tpl.extractPerson) return "人物抠图与高级排版";
|
||||||
|
if (tpl.titleStrokeWidth) return "描边标题(PIL)";
|
||||||
|
if (tpl.titlePosition === "top") return "顶部标题(ffmpeg 快速)";
|
||||||
|
if (tpl.titlePosition === "center") return "居中标题(ffmpeg 快速)";
|
||||||
|
return "底部标题(ffmpeg 快速)";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {CoverTemplateItem[]}
|
||||||
|
*/
|
||||||
|
export function loadSystemCoverTemplates() {
|
||||||
|
return COVER_TEMPLATES.map(systemTemplateFromPreset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {CoverTemplateItem[]}
|
||||||
|
*/
|
||||||
|
export function loadCustomCoverTemplates() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return [];
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {CoverTemplateItem[]} templates
|
||||||
|
*/
|
||||||
|
export function saveCustomCoverTemplates(templates) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(templates));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {CoverTemplateItem[]}
|
||||||
|
*/
|
||||||
|
export function loadAllCoverTemplates() {
|
||||||
|
return [...loadSystemCoverTemplates(), ...loadCustomCoverTemplates()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {CoverTemplateItem | null}
|
||||||
|
*/
|
||||||
|
export function getCoverTemplateById(id) {
|
||||||
|
return loadAllCoverTemplates().find((t) => t.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} id
|
||||||
|
*/
|
||||||
|
export function isSystemCoverTemplateId(id) {
|
||||||
|
return COVER_TEMPLATES.some((t) => t.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {CoverTemplateItem} template
|
||||||
|
* @returns {CoverTemplateItem}
|
||||||
|
*/
|
||||||
|
export function upsertCustomCoverTemplate(template) {
|
||||||
|
const custom = loadCustomCoverTemplates();
|
||||||
|
const now = Date.now();
|
||||||
|
const config = isFullCoverTemplateConfig(template.config)
|
||||||
|
? template.config
|
||||||
|
: migrateSimpleCoverConfig(template.config || {});
|
||||||
|
const item = {
|
||||||
|
...template,
|
||||||
|
config,
|
||||||
|
isSystem: false,
|
||||||
|
is_system: 0,
|
||||||
|
createdAt: template.createdAt || now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
const idx = custom.findIndex((t) => t.id === item.id);
|
||||||
|
if (idx >= 0) custom[idx] = item;
|
||||||
|
else custom.unshift(item);
|
||||||
|
saveCustomCoverTemplates(custom);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {CoverTemplateItem} template
|
||||||
|
*/
|
||||||
|
export function cloneCoverTemplate(template) {
|
||||||
|
const now = Date.now();
|
||||||
|
return {
|
||||||
|
...JSON.parse(JSON.stringify(template)),
|
||||||
|
id: `cover_custom_${now}`,
|
||||||
|
name: `${template.name} 副本`,
|
||||||
|
isSystem: false,
|
||||||
|
is_system: 0,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {CoverTemplateItem[]} templates
|
||||||
|
*/
|
||||||
|
export function downloadCoverTemplatesJson(
|
||||||
|
templates,
|
||||||
|
filename = "cover-templates-all.json",
|
||||||
|
) {
|
||||||
|
const blob = new Blob(
|
||||||
|
[
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
version: "1.0.0",
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
coverTemplates: templates,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
{ type: "application/json" },
|
||||||
|
);
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {File} file
|
||||||
|
* @returns {Promise<CoverTemplateItem[]>}
|
||||||
|
*/
|
||||||
|
export async function parseImportedCoverTemplates(file) {
|
||||||
|
const text = await file.text();
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
const list = data.coverTemplates || data.templates || data;
|
||||||
|
if (!Array.isArray(list)) throw new Error("无效的模板文件格式");
|
||||||
|
return list.map((t, i) => ({
|
||||||
|
...t,
|
||||||
|
id: t.id || `cover_imported_${Date.now()}_${i}`,
|
||||||
|
isSystem: false,
|
||||||
|
is_system: 0,
|
||||||
|
createdAt: t.createdAt || Date.now(),
|
||||||
|
updatedAt: t.updatedAt || Date.now(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {number} [ts]
|
||||||
|
*/
|
||||||
|
export function formatCoverTemplateDate(ts) {
|
||||||
|
if (!ts) return "";
|
||||||
|
return new Date(ts).toLocaleString("zh-CN", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
import { resolveStep4TitleForCover } from "../utils/coverTextSplit.js";
|
||||||
|
|
||||||
export const PUBLISH_PLATFORM_DEFS = [
|
export const PUBLISH_PLATFORM_DEFS = [
|
||||||
{ key: "douyin", label: "抖音" },
|
{ key: "douyin", label: "抖音" },
|
||||||
{ key: "kuaishou", label: "快手" },
|
{ key: "kuaishou", label: "快手" },
|
||||||
@@ -181,7 +183,7 @@ export async function executePublishVideo(store, { autoPublish = true } = {}) {
|
|||||||
return { ok: false, message: "请先在步骤 06 生成封面" };
|
return { ok: false, message: "请先在步骤 06 生成封面" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const title = String(store.titleGenerated || "").trim();
|
const title = resolveStep4TitleForCover(store.titleGenerated);
|
||||||
if (!title) {
|
if (!title) {
|
||||||
return { ok: false, message: "请先在步骤 04 生成标题" };
|
return { ok: false, message: "请先在步骤 04 生成标题" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import {
|
|||||||
chooseCoverMaterial,
|
chooseCoverMaterial,
|
||||||
executeGenerateCover,
|
executeGenerateCover,
|
||||||
} from "../services/coverGenerate.js";
|
} from "../services/coverGenerate.js";
|
||||||
import { COVER_TEMPLATES } from "../config/coverTemplates.js";
|
import { getCoverTemplateById } from "../services/coverTemplateCatalog.js";
|
||||||
import {
|
import {
|
||||||
executeOneClickPipeline,
|
executeOneClickPipeline,
|
||||||
stopOneClickPipeline,
|
stopOneClickPipeline,
|
||||||
@@ -301,7 +301,10 @@ export const useWorkflowStore = defineStore("workflow", {
|
|||||||
|
|
||||||
coverTemplateId: "default",
|
coverTemplateId: "default",
|
||||||
|
|
||||||
/** 封面素材视频(可选,覆盖口播视频抽帧) */
|
/** 当前选中的完整封面模板(封面设置弹窗应用后写入) */
|
||||||
|
coverTemplate: null,
|
||||||
|
|
||||||
|
/** 封面素材视频/图片(可选,覆盖口播视频抽帧) */
|
||||||
coverCustomVideoPath: "",
|
coverCustomVideoPath: "",
|
||||||
|
|
||||||
|
|
||||||
@@ -378,10 +381,9 @@ export const useWorkflowStore = defineStore("workflow", {
|
|||||||
},
|
},
|
||||||
|
|
||||||
coverTemplateLabel: (state) => {
|
coverTemplateLabel: (state) => {
|
||||||
const tpl = COVER_TEMPLATES.find(
|
if (state.coverTemplate?.name) return state.coverTemplate.name;
|
||||||
(t) => t.id === (state.coverTemplateId || "default"),
|
const tpl = getCoverTemplateById(state.coverTemplateId || "default");
|
||||||
);
|
return tpl?.name || "未选择";
|
||||||
return tpl?.label || "未选择";
|
|
||||||
},
|
},
|
||||||
|
|
||||||
coverMaterialFileName: (state) => {
|
coverMaterialFileName: (state) => {
|
||||||
|
|||||||
198
src/utils/coverPreviewLayout.js
Normal file
198
src/utils/coverPreviewLayout.js
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
/**
|
||||||
|
* 封面编辑器预览布局(对齐 Electron CoverCustomEditor + advanced_cover_generator.py)
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
COVER_CANVAS_HEIGHT,
|
||||||
|
COVER_CANVAS_WIDTH,
|
||||||
|
COVER_PREVIEW_SCALE,
|
||||||
|
} from "../config/coverTemplateDefaults.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} text
|
||||||
|
* @param {number} maxPerLine
|
||||||
|
*/
|
||||||
|
export function splitCoverTitleLines(text, maxPerLine) {
|
||||||
|
const raw = String(text || "");
|
||||||
|
if (!raw || maxPerLine < 1) return [];
|
||||||
|
const lines = [];
|
||||||
|
for (let i = 0; i < raw.length; i += maxPerLine) {
|
||||||
|
lines.push(raw.slice(i, i + maxPerLine));
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ x: number, y: number }} pos
|
||||||
|
*/
|
||||||
|
function pctToPx(pos, canvasW, canvasH) {
|
||||||
|
return {
|
||||||
|
x: (Number(pos?.x ?? 50) / 100) * canvasW,
|
||||||
|
y: (Number(pos?.y ?? 50) / 100) * canvasH,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 横排标题逐字坐标(与 Python 横排分支一致)
|
||||||
|
* @param {Record<string, unknown>} config
|
||||||
|
* @param {number} [scale]
|
||||||
|
*/
|
||||||
|
export function layoutHorizontalTitle(config, scale = COVER_PREVIEW_SCALE) {
|
||||||
|
const canvasW = COVER_CANVAS_WIDTH * scale;
|
||||||
|
const canvasH = COVER_CANVAS_HEIGHT * scale;
|
||||||
|
const fontSize = (Number(config.titleFontSize) || 60) * scale;
|
||||||
|
const charSpacing =
|
||||||
|
config.titleCharSpacing != null
|
||||||
|
? Number(config.titleCharSpacing)
|
||||||
|
: Math.round(fontSize * 0.2);
|
||||||
|
const lineSpacing =
|
||||||
|
config.titleLineSpacing != null
|
||||||
|
? Number(config.titleLineSpacing)
|
||||||
|
: Math.round(fontSize * 1.2);
|
||||||
|
const maxChars = Number(config.titleMaxCharsPerLine) || 10;
|
||||||
|
const text = String(config.titleText || "");
|
||||||
|
const lines = splitCoverTitleLines(text, maxChars);
|
||||||
|
|
||||||
|
const center = pctToPx(config.titlePosition, canvasW, canvasH);
|
||||||
|
let { x: textX, y: textY } = center;
|
||||||
|
// 与 advanced_cover_generator.py TITLE HEIGHT ADJUST 一致(整段 mm 包围盒高度)
|
||||||
|
textY -= fontSize / 2;
|
||||||
|
|
||||||
|
const totalHeight =
|
||||||
|
lines.length > 0
|
||||||
|
? (lines.length - 1) * (fontSize + lineSpacing) + fontSize
|
||||||
|
: fontSize;
|
||||||
|
const maxLineChars = Math.max(...lines.map((l) => l.length), 1);
|
||||||
|
const totalWidth =
|
||||||
|
(maxLineChars - 1) * (fontSize + charSpacing) + fontSize;
|
||||||
|
|
||||||
|
const startX = textX - totalWidth / 2;
|
||||||
|
const startY = textY - totalHeight / 2;
|
||||||
|
|
||||||
|
const chars = [];
|
||||||
|
for (let row = 0; row < lines.length; row += 1) {
|
||||||
|
const line = lines[row];
|
||||||
|
const lineY = startY + row * (fontSize + lineSpacing) + fontSize / 2;
|
||||||
|
for (let col = 0; col < line.length; col += 1) {
|
||||||
|
chars.push({
|
||||||
|
char: line[col],
|
||||||
|
x: startX + col * (fontSize + charSpacing) + fontSize / 2,
|
||||||
|
y: lineY,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
chars,
|
||||||
|
fontSize,
|
||||||
|
box: {
|
||||||
|
left: startX,
|
||||||
|
top: startY,
|
||||||
|
width: totalWidth,
|
||||||
|
height: totalHeight,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 竖排标题逐字坐标
|
||||||
|
*/
|
||||||
|
export function layoutVerticalTitle(config, scale = COVER_PREVIEW_SCALE) {
|
||||||
|
const canvasW = COVER_CANVAS_WIDTH * scale;
|
||||||
|
const canvasH = COVER_CANVAS_HEIGHT * scale;
|
||||||
|
const fontSize = (Number(config.titleFontSize) || 60) * scale;
|
||||||
|
const charSpacing =
|
||||||
|
config.titleCharSpacing != null
|
||||||
|
? Number(config.titleCharSpacing)
|
||||||
|
: Math.round(fontSize * 0.2);
|
||||||
|
const lineSpacing =
|
||||||
|
config.titleLineSpacing != null
|
||||||
|
? Number(config.titleLineSpacing)
|
||||||
|
: Math.round(fontSize * 1.2);
|
||||||
|
const maxChars = Number(config.titleMaxCharsPerLine) || 10;
|
||||||
|
const text = String(config.titleText || "");
|
||||||
|
|
||||||
|
const center = pctToPx(config.titlePosition, canvasW, canvasH);
|
||||||
|
let { x: textX, y: textY } = center;
|
||||||
|
textY -= fontSize / 2;
|
||||||
|
|
||||||
|
const cols = splitCoverTitleLines(text, maxChars);
|
||||||
|
const totalWidth =
|
||||||
|
cols.length > 0 ? (cols.length - 1) * lineSpacing + fontSize : fontSize;
|
||||||
|
const maxColChars = Math.max(...cols.map((c) => c.length), 1);
|
||||||
|
const totalHeight =
|
||||||
|
(maxColChars - 1) * (fontSize + charSpacing) + fontSize;
|
||||||
|
|
||||||
|
const startX = textX - totalWidth / 2;
|
||||||
|
const startY = textY - totalHeight / 2;
|
||||||
|
|
||||||
|
const chars = [];
|
||||||
|
for (let col = 0; col < cols.length; col += 1) {
|
||||||
|
const column = cols[col];
|
||||||
|
const colX = startX + col * lineSpacing + fontSize / 2;
|
||||||
|
for (let row = 0; row < column.length; row += 1) {
|
||||||
|
chars.push({
|
||||||
|
char: column[row],
|
||||||
|
x: colX,
|
||||||
|
y: startY + row * (fontSize + charSpacing) + fontSize / 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
chars,
|
||||||
|
fontSize,
|
||||||
|
box: { left: startX, top: startY, width: totalWidth, height: totalHeight },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Record<string, unknown>} config
|
||||||
|
*/
|
||||||
|
export function layoutTitle(config, scale = COVER_PREVIEW_SCALE) {
|
||||||
|
if (config.titleDirection === "vertical") {
|
||||||
|
return layoutVerticalTitle(config, scale);
|
||||||
|
}
|
||||||
|
return layoutHorizontalTitle(config, scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Record<string, unknown>} config
|
||||||
|
*/
|
||||||
|
export function layoutSubtitle(config, scale = COVER_PREVIEW_SCALE) {
|
||||||
|
const subConfig = {
|
||||||
|
...config,
|
||||||
|
titleText: config.subtitleText,
|
||||||
|
titleFontSize: config.subtitleFontSize,
|
||||||
|
titleFontFamily: config.subtitleFontFamily,
|
||||||
|
titleFontWeight: config.subtitleFontWeight,
|
||||||
|
titleColor: config.subtitleColor,
|
||||||
|
titleStrokeWidth: config.subtitleStrokeWidth,
|
||||||
|
titleStrokeColor: config.subtitleStrokeColor,
|
||||||
|
titleShadowLayers: config.subtitleShadowLayers,
|
||||||
|
titlePosition: config.subtitlePosition,
|
||||||
|
titleDirection: config.subtitleDirection,
|
||||||
|
titleCharSpacing: config.subtitleCharSpacing,
|
||||||
|
titleLineSpacing: config.subtitleLineSpacing,
|
||||||
|
titleMaxCharsPerLine: config.subtitleMaxCharsPerLine,
|
||||||
|
titleRotation: config.subtitleRotation,
|
||||||
|
};
|
||||||
|
return layoutTitle(subConfig, scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人像占位框(中心锚点 + personSize 相对画布高度)
|
||||||
|
*/
|
||||||
|
export function layoutPersonBox(config, scale = COVER_PREVIEW_SCALE) {
|
||||||
|
const canvasW = COVER_CANVAS_WIDTH * scale;
|
||||||
|
const canvasH = COVER_CANVAS_HEIGHT * scale;
|
||||||
|
const center = pctToPx(config.personPosition, canvasW, canvasH);
|
||||||
|
const sizePct = Number(config.personSize) || 50;
|
||||||
|
const h = (canvasH * sizePct) / 100;
|
||||||
|
const w = h * 0.75;
|
||||||
|
return {
|
||||||
|
left: center.x - w / 2,
|
||||||
|
top: center.y - h / 2,
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
};
|
||||||
|
}
|
||||||
113
src/utils/coverTemplateDraft.js
Normal file
113
src/utils/coverTemplateDraft.js
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import {
|
||||||
|
createDefaultCoverTemplateConfig,
|
||||||
|
isFullCoverTemplateConfig,
|
||||||
|
migrateSimpleCoverConfig,
|
||||||
|
} from "../config/coverTemplateDefaults.js";
|
||||||
|
import { splitCoverReferenceText } from "./coverTextSplit.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ id?: string, name?: string, description?: string, config?: Record<string, unknown> }} template
|
||||||
|
* @param {string} [referenceText]
|
||||||
|
*/
|
||||||
|
export function createCoverEditDraft(template, referenceText = "") {
|
||||||
|
const baseConfig = isFullCoverTemplateConfig(template?.config)
|
||||||
|
? createDefaultCoverTemplateConfig({ ...template.config })
|
||||||
|
: migrateSimpleCoverConfig(template?.config || {});
|
||||||
|
|
||||||
|
const draft = {
|
||||||
|
id: template?.id || "",
|
||||||
|
name: template?.name || "新模板",
|
||||||
|
description: template?.description || "",
|
||||||
|
config: baseConfig,
|
||||||
|
};
|
||||||
|
|
||||||
|
syncCoverTextsFromReference(draft, referenceText);
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {{ config: Record<string, unknown> }} draft
|
||||||
|
* @param {string} referenceText
|
||||||
|
*/
|
||||||
|
export function syncCoverTextsFromReference(draft, referenceText) {
|
||||||
|
const cfg = draft.config;
|
||||||
|
const { title, subtitle } = splitCoverReferenceText(referenceText, {
|
||||||
|
autoSplit: cfg.autoSplitText !== false,
|
||||||
|
titleMaxLength: Number(cfg.titleMaxLength) || 14,
|
||||||
|
subtitleMaxLength: Number(cfg.subtitleMaxLength) || 16,
|
||||||
|
});
|
||||||
|
cfg.titleText = title;
|
||||||
|
cfg.subtitleText = subtitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 与 Electron CoverCustomEditor 保存时一致的 titles 结构
|
||||||
|
* @param {Record<string, unknown>} config
|
||||||
|
*/
|
||||||
|
export function buildCoverTitlesConfig(config) {
|
||||||
|
const c = config || {};
|
||||||
|
return {
|
||||||
|
main: {
|
||||||
|
text: c.titleText || "",
|
||||||
|
maxLength: c.titleMaxCharsPerLine ?? 10,
|
||||||
|
fontSize: c.titleFontSize,
|
||||||
|
fontFamily: c.titleFontFamily || "Microsoft YaHei",
|
||||||
|
fontWeight: c.titleFontWeight ?? 700,
|
||||||
|
color: c.titleColor || "#FFFFFF",
|
||||||
|
strokeColor: c.titleStrokeColor || "#000000",
|
||||||
|
strokeWidth: c.titleStrokeWidth ?? 0,
|
||||||
|
shadowColor: c.titleShadowColor || "#000000",
|
||||||
|
shadowOffsetX: c.titleShadowOffsetX ?? 0,
|
||||||
|
shadowOffsetY: c.titleShadowOffsetY ?? 0,
|
||||||
|
shadowBlur: c.titleShadowBlur ?? 0,
|
||||||
|
shadowEnabled: Boolean(c.titleShadowEnabled),
|
||||||
|
shadowLayers: Array.isArray(c.titleShadowLayers) ? c.titleShadowLayers : [],
|
||||||
|
position: c.titlePosition || { x: 50, y: 80 },
|
||||||
|
direction: c.titleDirection || "horizontal",
|
||||||
|
charSpacing: c.titleCharSpacing,
|
||||||
|
lineSpacing: c.titleLineSpacing,
|
||||||
|
rotation: c.titleRotation ?? 0,
|
||||||
|
backgroundRotation: c.titleBackgroundRotation ?? 0,
|
||||||
|
},
|
||||||
|
sub: {
|
||||||
|
text: c.subtitleText || "",
|
||||||
|
maxLength: c.subtitleMaxCharsPerLine ?? 15,
|
||||||
|
fontSize: c.subtitleFontSize,
|
||||||
|
fontFamily: c.subtitleFontFamily || "Microsoft YaHei",
|
||||||
|
fontWeight: c.subtitleFontWeight ?? 500,
|
||||||
|
color: c.subtitleColor || "#FFFFFF",
|
||||||
|
strokeColor: c.subtitleStrokeColor || "#000000",
|
||||||
|
strokeWidth: c.subtitleStrokeWidth ?? 0,
|
||||||
|
shadowColor: c.subtitleShadowColor || "#000000",
|
||||||
|
shadowOffsetX: c.subtitleShadowOffsetX ?? 0,
|
||||||
|
shadowOffsetY: c.subtitleShadowOffsetY ?? 0,
|
||||||
|
shadowBlur: c.subtitleShadowBlur ?? 0,
|
||||||
|
shadowEnabled: Boolean(c.subtitleShadowEnabled),
|
||||||
|
shadowLayers: Array.isArray(c.subtitleShadowLayers)
|
||||||
|
? c.subtitleShadowLayers
|
||||||
|
: [],
|
||||||
|
position: c.subtitlePosition || { x: 50, y: 90 },
|
||||||
|
direction: c.subtitleDirection || "horizontal",
|
||||||
|
charSpacing: c.subtitleCharSpacing,
|
||||||
|
lineSpacing: c.subtitleLineSpacing,
|
||||||
|
rotation: c.subtitleRotation ?? 0,
|
||||||
|
backgroundRotation: c.subtitleBackgroundRotation ?? 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出给 Python 生成器的配置(补全兼容字段)
|
||||||
|
* @param {Record<string, unknown>} config
|
||||||
|
*/
|
||||||
|
export function normalizeCoverConfigForGenerator(config) {
|
||||||
|
const c = { ...config };
|
||||||
|
c.blurBackground = Boolean(c.backgroundBlurEnabled ?? c.blurBackground);
|
||||||
|
c.extractPerson = c.extractPerson !== false;
|
||||||
|
if (c.personBorderEnabled && !c.personOutlineColor) {
|
||||||
|
c.personOutlineColor = c.personBorderColor;
|
||||||
|
c.personOutlineWidth = c.personBorderWidth;
|
||||||
|
}
|
||||||
|
c.titles = buildCoverTitlesConfig(c);
|
||||||
|
return c;
|
||||||
|
}
|
||||||
83
src/utils/coverTextSplit.js
Normal file
83
src/utils/coverTextSplit.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/** 步骤 04 默认建议条数 */
|
||||||
|
export const STEP4_TITLE_SUGGESTION_COUNT = 5;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析步骤 04 标题文本为行列表
|
||||||
|
* @param {string} titleGenerated
|
||||||
|
*/
|
||||||
|
export function parseStep4TitleLines(titleGenerated) {
|
||||||
|
return String(titleGenerated || "")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 步骤 04 生成多条标题建议(换行分隔)。
|
||||||
|
* - 1~5 行:取第一行作为封面/发布用标题(其余为备选,可在步骤 04 调整顺序)
|
||||||
|
* - 超过 5 行:随机选取一行
|
||||||
|
* @param {string} titleGenerated
|
||||||
|
* @param {{ random?: () => number }} [opts] 便于测试注入
|
||||||
|
*/
|
||||||
|
export function resolveStep4TitleForCover(titleGenerated, opts = {}) {
|
||||||
|
const lines = parseStep4TitleLines(titleGenerated);
|
||||||
|
if (!lines.length) return "";
|
||||||
|
|
||||||
|
const random = opts.random ?? Math.random;
|
||||||
|
const picked =
|
||||||
|
lines.length > STEP4_TITLE_SUGGESTION_COUNT
|
||||||
|
? lines[Math.floor(random() * lines.length)]
|
||||||
|
: lines[0];
|
||||||
|
|
||||||
|
return picked.slice(0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 封面标题/副标题拆分:先解析步骤 04 单行标题,再按模板规则拆主/副标题
|
||||||
|
* @param {string} text
|
||||||
|
* @param {{ autoSplit?: boolean, titleMaxLength?: number, subtitleMaxLength?: number }} opts
|
||||||
|
*/
|
||||||
|
export function splitCoverReferenceText(
|
||||||
|
text,
|
||||||
|
{ autoSplit = true, titleMaxLength = 14, subtitleMaxLength = 16 } = {},
|
||||||
|
) {
|
||||||
|
const single = resolveStep4TitleForCover(text);
|
||||||
|
if (!single) return { title: "", subtitle: "" };
|
||||||
|
|
||||||
|
if (!autoSplit || single.length <= titleMaxLength) {
|
||||||
|
return { title: single.slice(0, titleMaxLength), subtitle: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const breakAt = findSplitIndex(single, titleMaxLength);
|
||||||
|
return {
|
||||||
|
title: single.slice(0, breakAt).trim().slice(0, titleMaxLength),
|
||||||
|
subtitle: single.slice(breakAt).trim().slice(0, subtitleMaxLength),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} text
|
||||||
|
* @param {number} maxLen
|
||||||
|
*/
|
||||||
|
function findSplitIndex(text, maxLen) {
|
||||||
|
const punct = /[,,。!?;、\s]/;
|
||||||
|
for (let i = Math.min(maxLen, text.length - 1); i > 2; i -= 1) {
|
||||||
|
if (punct.test(text[i])) return i + 1;
|
||||||
|
}
|
||||||
|
return Math.min(maxLen, Math.ceil(text.length / 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按每行字数折行(预览用)
|
||||||
|
* @param {string} text
|
||||||
|
* @param {number} maxPerLine
|
||||||
|
*/
|
||||||
|
export function wrapCoverLine(text, maxPerLine) {
|
||||||
|
const raw = String(text || "");
|
||||||
|
if (!raw || maxPerLine < 1) return [];
|
||||||
|
const lines = [];
|
||||||
|
for (let i = 0; i < raw.length; i += maxPerLine) {
|
||||||
|
lines.push(raw.slice(i, i + maxPerLine));
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user