@akairos/typeset-studio 0.2.4 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -3
- package/dist/assets/index-JCv_QmUn.css +1 -0
- package/dist/assets/index-X9ezH_RG.js +317 -0
- package/dist/index.html +2 -2
- package/dist/lib/adapter.js +1 -1
- package/dist/lib/{canvas-renderer-cxnar75n.js → canvas-renderer-4jg80agt.js} +1 -1
- package/dist/lib/{index-zdhqg5f0.js → index-bf1zs3mv.js} +291 -54
- package/dist/lib/{index-t9w3p2p9.js → index-hegsbxc0.js} +71 -3
- package/dist/lib/{index-qhpfzwj4.js → index-p8ttqbs6.js} +3 -3
- package/dist/lib/index.js +3 -3
- package/dist/lib/{render-xxd348m1.js → render-3043w6s6.js} +2 -2
- package/package.json +3 -3
- package/src/adapter.ts +1 -1
- package/src/core/canvas-draw.ts +53 -43
- package/src/core/canvas-presets.ts +37 -0
- package/src/core/canvas-renderer.test.ts +56 -0
- package/src/core/canvas-style.ts +149 -0
- package/src/core/engine.ts +2 -0
- package/src/core/types.ts +30 -3
- package/src/index.ts +4 -0
- package/src/server/index.ts +1 -1
- package/src/server/routes/slots.test.ts +22 -0
- package/src/server/routes/templates.ts +15 -0
- package/src/web/App.tsx +27 -7
- package/src/web/components/CanvasStyleInspector.tsx +95 -0
- package/src/web/components/Header.tsx +24 -9
- package/src/web/components/ParamsInspector.tsx +6 -1
- package/src/web/components/SaveTemplateModal.tsx +106 -0
- package/src/web/components/TemplateDrawer.tsx +323 -0
- package/src/web/hooks/useTypeset.ts +3 -1
- package/dist/assets/index-3Ko1UCBU.css +0 -1
- package/dist/assets/index-C9J0f4Lf.js +0 -317
- package/src/web/components/TemplateModal.tsx +0 -393
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { BORDER_LABELS, DEFAULT_BORDER, DEFAULT_PATTERN, PATTERN_LABELS } from "../../core/canvas-style";
|
|
3
|
+
import type { CanvasFill, CanvasStyle, TypesetConfig } from "../../core/types";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
config: TypesetConfig;
|
|
7
|
+
onChange: (style: CanvasStyle) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const control = "w-full rounded border border-[#dfdfdf] dark:border-[#333333] bg-white dark:bg-[#141414] px-2 py-1 text-[11px]";
|
|
11
|
+
|
|
12
|
+
// 装饰配置独立保存,切换背景不会改变字体和正文排版。
|
|
13
|
+
export function CanvasStyleInspector({ config, onChange }: Props) {
|
|
14
|
+
const style = config.canvasStyle || {};
|
|
15
|
+
const fill = style.fill || config.coverTheme?.background.fill || { type: "solid", color: config.theme.bgColor };
|
|
16
|
+
const pattern = style.pattern || config.coverTheme?.decoration || DEFAULT_PATTERN;
|
|
17
|
+
const border = style.border || DEFAULT_BORDER;
|
|
18
|
+
const updateFill = (next: CanvasFill) => onChange({ ...style, fill: next });
|
|
19
|
+
const updatePattern = (partial: Partial<typeof pattern>) => onChange({ ...style, pattern: { ...pattern, ...partial } });
|
|
20
|
+
const updateBorder = (partial: Partial<typeof border>) => onChange({ ...style, border: { ...border, ...partial } });
|
|
21
|
+
const colorInput = (label: string, value: string, update: (value: string) => void) => (
|
|
22
|
+
<label className="flex items-center justify-between gap-2">
|
|
23
|
+
<span>{label}</span><span className="flex items-center gap-1.5 font-mono">
|
|
24
|
+
<span>{value}</span><input aria-label={label} type="color" value={value} onChange={(event) => update(event.target.value)} className="h-6 w-7 cursor-pointer bg-transparent" />
|
|
25
|
+
</span>
|
|
26
|
+
</label>
|
|
27
|
+
);
|
|
28
|
+
const slider = (label: string, value: number, min: number, max: number, update: (value: number) => void, step = 1) => (
|
|
29
|
+
<label className="flex flex-col gap-1">
|
|
30
|
+
<span className="flex justify-between"><span>{label}</span><span className="font-mono">{step < 1 ? `${Math.round(value * 100)}%` : value}</span></span>
|
|
31
|
+
<input aria-label={label} type="range" min={min} max={max} step={step} value={value} onChange={(event) => update(Number(event.target.value))} className="w-full accent-emerald-600" />
|
|
32
|
+
</label>
|
|
33
|
+
);
|
|
34
|
+
const switchFill = (type: CanvasFill["type"]) => {
|
|
35
|
+
const color = fill.type === "solid" ? fill.color : fill.stops[0].color;
|
|
36
|
+
const stops = fill.type === "solid" ? [{ color, offset: 0 }, { color: config.theme.accentColor, offset: 100 }] : fill.stops;
|
|
37
|
+
updateFill(type === "solid" ? { type, color } : type === "linear" ? { type, angle: 90, stops } : { type, position: { x: 50, y: 30 }, stops });
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<section className="border-b border-[#f0f0f0] dark:border-[#1c1c1c] p-3.5 text-xs">
|
|
42
|
+
<div className="mb-3 font-semibold text-[#171717] dark:text-white">背景、花纹与边框</div>
|
|
43
|
+
<div className="flex flex-col gap-3 text-[11px] text-[#666666] dark:text-[#aaaaaa]">
|
|
44
|
+
<label className="flex flex-col gap-1">背景类型
|
|
45
|
+
<select className={control} value={fill.type} onChange={(event) => switchFill(event.target.value as CanvasFill["type"])}>
|
|
46
|
+
<option value="solid">纯色</option><option value="linear">线性渐变</option><option value="radial">径向渐变</option>
|
|
47
|
+
</select>
|
|
48
|
+
</label>
|
|
49
|
+
{fill.type === "solid" ? colorInput("背景颜色", fill.color, (color) => updateFill({ type: "solid", color })) : <>
|
|
50
|
+
<div className="flex flex-col gap-2 rounded border border-[#eaeaea] dark:border-[#333333] p-2">
|
|
51
|
+
{fill.stops.map((stop, index) => <div key={index} className="flex flex-col gap-1.5">
|
|
52
|
+
<div className="flex items-center gap-2">
|
|
53
|
+
<label className="flex flex-1 items-center gap-1.5">
|
|
54
|
+
<input aria-label={`渐变颜色 ${index + 1}`} type="color" value={stop.color} onChange={(event) => updateFill({ ...fill, stops: fill.stops.map((item, i) => i === index ? { ...item, color: event.target.value } : item) })} className="h-6 w-7 bg-transparent" />
|
|
55
|
+
<span className="font-mono">{stop.color}</span>
|
|
56
|
+
</label>
|
|
57
|
+
<button type="button" disabled={fill.stops.length <= 2} onClick={() => updateFill({ ...fill, stops: fill.stops.filter((_, i) => i !== index) })} className="disabled:opacity-30" aria-label={`删除渐变颜色 ${index + 1}`}>移除</button>
|
|
58
|
+
</div>
|
|
59
|
+
{slider(`节点 ${index + 1} 位置`, stop.offset, 0, 100, (offset) => updateFill({ ...fill, stops: fill.stops.map((item, i) => i === index ? { ...item, offset } : item) }))}
|
|
60
|
+
</div>)}
|
|
61
|
+
<button type="button" disabled={fill.stops.length >= 8} onClick={() => updateFill({ ...fill, stops: [...fill.stops, { color: config.theme.accentColor, offset: 50 }] })} className="rounded border border-[#dfdfdf] dark:border-[#333333] py-1 disabled:opacity-30">添加颜色节点</button>
|
|
62
|
+
</div>
|
|
63
|
+
{fill.type === "linear" ? slider("渐变角度", fill.angle, 0, 360, (angle) => updateFill({ ...fill, angle })) : <>
|
|
64
|
+
{slider("渐变中心 X", fill.position.x, 0, 100, (x) => updateFill({ ...fill, position: { ...fill.position, x } }))}
|
|
65
|
+
{slider("渐变中心 Y", fill.position.y, 0, 100, (y) => updateFill({ ...fill, position: { ...fill.position, y } }))}
|
|
66
|
+
</>}
|
|
67
|
+
</>}
|
|
68
|
+
<label className="flex flex-col gap-1">几何花纹
|
|
69
|
+
<select className={control} value={pattern.pattern} onChange={(event) => updatePattern({ pattern: event.target.value as typeof pattern.pattern })}>
|
|
70
|
+
{Object.entries(PATTERN_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
|
71
|
+
</select>
|
|
72
|
+
</label>
|
|
73
|
+
{pattern.pattern !== "none" && <>
|
|
74
|
+
{colorInput("花纹颜色", pattern.color, (color) => updatePattern({ color }))}
|
|
75
|
+
{slider("花纹透明度", pattern.opacity, 0, 1, (opacity) => updatePattern({ opacity }), 0.01)}
|
|
76
|
+
{slider("花纹间距", pattern.size, 16, 240, (size) => updatePattern({ size }))}
|
|
77
|
+
</>}
|
|
78
|
+
<label className="flex flex-col gap-1">边框样式
|
|
79
|
+
<select className={control} value={border.style} onChange={(event) => updateBorder({ style: event.target.value as typeof border.style })}>
|
|
80
|
+
{Object.entries(BORDER_LABELS).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
|
|
81
|
+
</select>
|
|
82
|
+
</label>
|
|
83
|
+
{border.style !== "none" && <>
|
|
84
|
+
{colorInput("边框颜色", border.color, (color) => updateBorder({ color }))}
|
|
85
|
+
{border.style === "gradient" && colorInput("边框渐变终色", border.endColor, (endColor) => updateBorder({ endColor }))}
|
|
86
|
+
{slider("边框粗细", border.width, 1, 24, (width) => updateBorder({ width }))}
|
|
87
|
+
{slider("边框内缩", border.inset, 0, 200, (inset) => updateBorder({ inset }))}
|
|
88
|
+
{border.style !== "corners" && slider("边框圆角", border.radius, 0, 200, (radius) => updateBorder({ radius }))}
|
|
89
|
+
{slider("边框透明度", border.opacity, 0, 1, (opacity) => updateBorder({ opacity }), 0.01)}
|
|
90
|
+
</>}
|
|
91
|
+
<p className="leading-relaxed text-[#888888]">配置随模板保存,作用于每页。背景图会覆盖底色与花纹;边框绘制在最上层。</p>
|
|
92
|
+
</div>
|
|
93
|
+
</section>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { Download, FolderOpen, Key, Maximize2, Moon, RefreshCw, Sun, ZoomIn, ZoomOut } from "lucide-react";
|
|
2
|
+
import { Download, FolderOpen, Key, Maximize2, Moon, RefreshCw, Save, Sun, ZoomIn, ZoomOut } from "lucide-react";
|
|
3
3
|
import { AspectRatioType } from "../../core/types";
|
|
4
4
|
|
|
5
5
|
interface HeaderProps {
|
|
@@ -10,6 +10,9 @@ interface HeaderProps {
|
|
|
10
10
|
onAutoFit?: () => void;
|
|
11
11
|
isAutoFit?: boolean;
|
|
12
12
|
onOpenTemplates: () => void;
|
|
13
|
+
isTemplateDrawerOpen: boolean;
|
|
14
|
+
templateName?: string;
|
|
15
|
+
onSaveTemplate: () => void;
|
|
13
16
|
onOpenAuth: () => void;
|
|
14
17
|
onExport: () => void;
|
|
15
18
|
isExporting: boolean;
|
|
@@ -34,6 +37,9 @@ export const Header: React.FC<HeaderProps> = ({
|
|
|
34
37
|
onAutoFit,
|
|
35
38
|
isAutoFit,
|
|
36
39
|
onOpenTemplates,
|
|
40
|
+
isTemplateDrawerOpen,
|
|
41
|
+
templateName,
|
|
42
|
+
onSaveTemplate,
|
|
37
43
|
onOpenAuth,
|
|
38
44
|
onExport,
|
|
39
45
|
isExporting,
|
|
@@ -45,17 +51,17 @@ export const Header: React.FC<HeaderProps> = ({
|
|
|
45
51
|
return (
|
|
46
52
|
<header className="h-14 border-b border-[#eaeaea] dark:border-[#222222] bg-white/80 dark:bg-[#000000]/80 backdrop-blur-md px-4 flex items-center justify-between select-none z-30 shrink-0 transition-colors">
|
|
47
53
|
{/* Brand & Status */}
|
|
48
|
-
<div className="flex items-center gap-3">
|
|
54
|
+
<div className="flex min-w-0 items-center gap-3">
|
|
49
55
|
<div className="flex items-center gap-2 font-semibold text-[#171717] dark:text-white tracking-tight text-sm">
|
|
50
56
|
<span className="flex items-center justify-center w-6 h-6 rounded bg-black dark:bg-white text-white dark:text-black font-black text-xs shadow-sm">
|
|
51
57
|
▲
|
|
52
58
|
</span>
|
|
53
|
-
<span>Typeset Studio</span>
|
|
59
|
+
<span className="hidden 2xl:inline">Typeset Studio</span>
|
|
54
60
|
</div>
|
|
55
|
-
<span className="text-xs text-[#888888] dark:text-[#555555] font-mono hidden
|
|
56
|
-
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-full bg-[#f5f5f5] dark:bg-[#111111] border border-[#e5e5e5] dark:border-[#222222] text-[11px] text-emerald-600 dark:text-[#00e676]">
|
|
57
|
-
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 dark:bg-[#00e676]
|
|
58
|
-
<span className="
|
|
61
|
+
<span className="text-xs text-[#888888] dark:text-[#555555] font-mono hidden 2xl:inline">v0.2.6</span>
|
|
62
|
+
<div title={templateName || "未命名模板"} className="flex min-w-0 items-center gap-1.5 px-2 py-0.5 rounded-full bg-[#f5f5f5] dark:bg-[#111111] border border-[#e5e5e5] dark:border-[#222222] text-[11px] text-emerald-600 dark:text-[#00e676]">
|
|
63
|
+
<span className="w-1.5 h-1.5 shrink-0 rounded-full bg-emerald-500 dark:bg-[#00e676]" />
|
|
64
|
+
<span aria-live="polite" className="max-w-40 truncate">{templateName || "未命名模板"}</span>
|
|
59
65
|
</div>
|
|
60
66
|
</div>
|
|
61
67
|
|
|
@@ -73,7 +79,7 @@ export const Header: React.FC<HeaderProps> = ({
|
|
|
73
79
|
: "text-[#666666] dark:text-[#888888] hover:text-[#171717] dark:hover:text-[#cccccc] hover:bg-[#eaeaea] dark:hover:bg-[#141414]"
|
|
74
80
|
}`}
|
|
75
81
|
>
|
|
76
|
-
<span>{item.label}</span>
|
|
82
|
+
<span className="hidden xl:inline">{item.label}</span>
|
|
77
83
|
<span
|
|
78
84
|
className={`text-[10px] font-mono px-1 py-0.2 rounded ${
|
|
79
85
|
isActive
|
|
@@ -146,15 +152,24 @@ export const Header: React.FC<HeaderProps> = ({
|
|
|
146
152
|
)}
|
|
147
153
|
</button>
|
|
148
154
|
|
|
149
|
-
{/*
|
|
155
|
+
{/* 模板库开关 */}
|
|
150
156
|
<button
|
|
151
157
|
onClick={onOpenTemplates}
|
|
158
|
+
aria-expanded={isTemplateDrawerOpen}
|
|
152
159
|
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-[#eaeaea] dark:border-[#222222] bg-white dark:bg-[#0a0a0a] hover:bg-[#f5f5f5] dark:hover:bg-[#161616] text-xs text-[#333333] dark:text-[#cccccc] hover:text-black dark:hover:text-white transition-all btn-vercel"
|
|
153
160
|
>
|
|
154
161
|
<FolderOpen className="w-3.5 h-3.5 text-[#777777] dark:text-[#888888]" />
|
|
155
162
|
<span>模板库</span>
|
|
156
163
|
</button>
|
|
157
164
|
|
|
165
|
+
<button
|
|
166
|
+
onClick={onSaveTemplate}
|
|
167
|
+
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border border-[#eaeaea] dark:border-[#222222] bg-white dark:bg-[#0a0a0a] hover:bg-[#f5f5f5] dark:hover:bg-[#161616] text-xs text-[#333333] dark:text-[#cccccc] transition-all btn-vercel"
|
|
168
|
+
>
|
|
169
|
+
<Save className="w-3.5 h-3.5" />
|
|
170
|
+
<span>保存模板</span>
|
|
171
|
+
</button>
|
|
172
|
+
|
|
158
173
|
{/* Export image button */}
|
|
159
174
|
<button
|
|
160
175
|
onClick={onExport}
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
TypesetConfig,
|
|
19
19
|
} from "../../core/types";
|
|
20
20
|
import { useLocalFonts } from "../hooks/useLocalFonts";
|
|
21
|
+
import { CanvasStyleInspector } from "./CanvasStyleInspector";
|
|
21
22
|
|
|
22
23
|
interface ParamsInspectorProps {
|
|
23
24
|
config: TypesetConfig;
|
|
@@ -27,6 +28,7 @@ interface ParamsInspectorProps {
|
|
|
27
28
|
onUpdateSafeArea: (partial: Partial<AlgorithmParams["safeArea"]>) => void;
|
|
28
29
|
onUpdateBranding: (footer: string, watermark: string) => void;
|
|
29
30
|
onUpdateChrome: (partial: Partial<TypesetChromeConfig>) => void;
|
|
31
|
+
onUpdateCanvasStyle: (style: NonNullable<TypesetConfig["canvasStyle"]>) => void;
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
const PRESET_FONT_OPTIONS = [
|
|
@@ -48,6 +50,7 @@ export const ParamsInspector: React.FC<ParamsInspectorProps> = ({
|
|
|
48
50
|
onUpdateSafeArea,
|
|
49
51
|
onUpdateBranding,
|
|
50
52
|
onUpdateChrome,
|
|
53
|
+
onUpdateCanvasStyle,
|
|
51
54
|
}) => {
|
|
52
55
|
const { algorithm, theme, typography } = config;
|
|
53
56
|
const { fonts: localFonts, loading: loadingFonts, refreshFonts } = useLocalFonts();
|
|
@@ -479,7 +482,7 @@ export const ParamsInspector: React.FC<ParamsInspectorProps> = ({
|
|
|
479
482
|
<div className="flex items-center gap-1.5">
|
|
480
483
|
<input
|
|
481
484
|
type="color"
|
|
482
|
-
value={theme.bgColor}
|
|
485
|
+
value={config.canvasStyle?.fill?.type === "solid" ? config.canvasStyle.fill.color : theme.bgColor}
|
|
483
486
|
onChange={(e) => onUpdateTheme({ bgColor: e.target.value })}
|
|
484
487
|
className="w-6 h-6 rounded cursor-pointer bg-transparent border-0"
|
|
485
488
|
/>
|
|
@@ -491,6 +494,8 @@ export const ParamsInspector: React.FC<ParamsInspectorProps> = ({
|
|
|
491
494
|
</div>
|
|
492
495
|
</div>
|
|
493
496
|
|
|
497
|
+
<CanvasStyleInspector config={config} onChange={onUpdateCanvasStyle} />
|
|
498
|
+
|
|
494
499
|
{/* Section 6: Header, Footer & Chrome Decor */}
|
|
495
500
|
<div className="p-3.5">
|
|
496
501
|
<div className="flex items-center gap-2 text-xs font-semibold text-[#171717] dark:text-white mb-3">
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Save, X } from "lucide-react";
|
|
3
|
+
import { validateTemplateAssets } from "../../core/template";
|
|
4
|
+
import { TemplateSlot, TypesetConfig } from "../../core/types";
|
|
5
|
+
|
|
6
|
+
interface SaveTemplateModalProps {
|
|
7
|
+
isOpen: boolean;
|
|
8
|
+
onClose: () => void;
|
|
9
|
+
currentConfig: TypesetConfig;
|
|
10
|
+
onSaved: (template: TypesetConfig) => void;
|
|
11
|
+
adminKey: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const SaveTemplateModal: React.FC<SaveTemplateModalProps> = ({
|
|
15
|
+
isOpen, onClose, currentConfig, onSaved, adminKey,
|
|
16
|
+
}) => {
|
|
17
|
+
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
18
|
+
const [templateName, setTemplateName] = useState("");
|
|
19
|
+
const [saveSlot, setSaveSlot] = useState<TemplateSlot | "none">("none");
|
|
20
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
21
|
+
const [error, setError] = useState<string | null>(null);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
if (isOpen) {
|
|
25
|
+
setTemplateName(currentConfig.name || "我的自定义模板");
|
|
26
|
+
setSaveSlot(currentConfig.slot || (currentConfig.aspectRatio === "wechat-cover-2.35" ? "article_cover" : "newspic_content"));
|
|
27
|
+
setError(null);
|
|
28
|
+
dialogRef.current?.showModal();
|
|
29
|
+
} else {
|
|
30
|
+
dialogRef.current?.close();
|
|
31
|
+
}
|
|
32
|
+
}, [isOpen]);
|
|
33
|
+
|
|
34
|
+
// 始终另存为新模板,保留当前图片与排版,不覆盖来源模板。
|
|
35
|
+
const handleSave = async (event: React.FormEvent) => {
|
|
36
|
+
event.preventDefault();
|
|
37
|
+
if (!templateName.trim() || isSaving) return;
|
|
38
|
+
setIsSaving(true);
|
|
39
|
+
setError(null);
|
|
40
|
+
try {
|
|
41
|
+
validateTemplateAssets(currentConfig.images);
|
|
42
|
+
const res = await fetch("/api/templates", {
|
|
43
|
+
method: "POST",
|
|
44
|
+
headers: {
|
|
45
|
+
"Content-Type": "application/json",
|
|
46
|
+
...(adminKey ? { "x-admin-key": adminKey } : {}),
|
|
47
|
+
},
|
|
48
|
+
body: JSON.stringify({
|
|
49
|
+
...currentConfig,
|
|
50
|
+
id: undefined,
|
|
51
|
+
name: templateName.trim(),
|
|
52
|
+
slot: saveSlot === "none" ? undefined : saveSlot,
|
|
53
|
+
}),
|
|
54
|
+
});
|
|
55
|
+
const data = await res.json();
|
|
56
|
+
if (!res.ok || !data.ok) throw new Error(data.message || data.error || "保存模板失败");
|
|
57
|
+
onSaved(data.template);
|
|
58
|
+
onClose();
|
|
59
|
+
} catch (err) {
|
|
60
|
+
setError(err instanceof Error ? err.message : "保存模板请求异常");
|
|
61
|
+
} finally {
|
|
62
|
+
setIsSaving(false);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<dialog
|
|
68
|
+
ref={dialogRef}
|
|
69
|
+
aria-labelledby="save-template-title"
|
|
70
|
+
onCancel={(event) => {
|
|
71
|
+
event.preventDefault();
|
|
72
|
+
if (!isSaving) onClose();
|
|
73
|
+
}}
|
|
74
|
+
className="m-auto w-[calc(100%-2rem)] max-w-sm p-0 rounded-xl border border-[#eaeaea] dark:border-[#262626] bg-white dark:bg-[#0f0f0f] text-[#171717] dark:text-white shadow-2xl backdrop:bg-black/40"
|
|
75
|
+
>
|
|
76
|
+
<form onSubmit={handleSave}>
|
|
77
|
+
<div className="flex items-center justify-between px-5 py-3.5 border-b border-[#eaeaea] dark:border-[#222222]">
|
|
78
|
+
<h2 id="save-template-title" className="text-sm font-semibold">保存模板</h2>
|
|
79
|
+
<button type="button" onClick={onClose} disabled={isSaving} aria-label="关闭保存模板" className="p-1 rounded hover:bg-black/5 dark:hover:bg-white/10 disabled:opacity-50">
|
|
80
|
+
<X className="w-4 h-4" />
|
|
81
|
+
</button>
|
|
82
|
+
</div>
|
|
83
|
+
<div className="p-5 flex flex-col gap-4">
|
|
84
|
+
<label className="flex flex-col gap-2 text-xs">
|
|
85
|
+
模板名称
|
|
86
|
+
<input autoFocus required value={templateName} onChange={(event) => setTemplateName(event.target.value)} placeholder="输入模板名称" className="bg-transparent border border-[#d4d4d4] dark:border-[#333333] rounded px-3 py-2 focus:outline-none focus:border-emerald-500" />
|
|
87
|
+
</label>
|
|
88
|
+
<label className="flex flex-col gap-2 text-xs">
|
|
89
|
+
保存到槽位
|
|
90
|
+
<select value={saveSlot} onChange={(event) => setSaveSlot(event.target.value as TemplateSlot | "none")} className="bg-white dark:bg-[#141414] border border-[#d4d4d4] dark:border-[#333333] rounded px-3 py-2">
|
|
91
|
+
<option value="article_cover">文章封面 (2.35:1)</option>
|
|
92
|
+
<option value="newspic_cover">贴图封面 (3:4)</option>
|
|
93
|
+
<option value="newspic_content">贴图内容 (3:4)</option>
|
|
94
|
+
<option value="none">通用模板</option>
|
|
95
|
+
</select>
|
|
96
|
+
</label>
|
|
97
|
+
{error && <p role="alert" className="text-xs text-red-500">{error}</p>}
|
|
98
|
+
<button type="submit" disabled={isSaving || !templateName.trim()} className="flex items-center justify-center gap-2 px-3 py-2 rounded bg-black text-white dark:bg-white dark:text-black text-xs font-semibold disabled:opacity-50">
|
|
99
|
+
<Save className="w-3.5 h-3.5" />
|
|
100
|
+
{isSaving ? "保存中..." : "保存模板"}
|
|
101
|
+
</button>
|
|
102
|
+
</div>
|
|
103
|
+
</form>
|
|
104
|
+
</dialog>
|
|
105
|
+
);
|
|
106
|
+
};
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import React, { useEffect, useState } from "react";
|
|
2
|
+
import { Check, Trash2, X, Sparkles, Star } from "lucide-react";
|
|
3
|
+
import { TemplateSlot, TypesetConfig } from "../../core/types";
|
|
4
|
+
|
|
5
|
+
interface TemplateDrawerProps {
|
|
6
|
+
isOpen: boolean;
|
|
7
|
+
onClose: () => void;
|
|
8
|
+
refreshKey: number;
|
|
9
|
+
onApplyTemplate: (tpl: TypesetConfig) => void;
|
|
10
|
+
adminKey: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SlotsResponse {
|
|
14
|
+
ok: boolean;
|
|
15
|
+
account: string;
|
|
16
|
+
message?: string;
|
|
17
|
+
error?: string;
|
|
18
|
+
slots: {
|
|
19
|
+
article_cover?: { id: string; template?: TypesetConfig };
|
|
20
|
+
newspic_cover?: { id: string; template?: TypesetConfig };
|
|
21
|
+
newspic_content?: { id: string; template?: TypesetConfig };
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const TemplateDrawer: React.FC<TemplateDrawerProps> = ({
|
|
26
|
+
isOpen,
|
|
27
|
+
onClose,
|
|
28
|
+
refreshKey,
|
|
29
|
+
onApplyTemplate,
|
|
30
|
+
adminKey,
|
|
31
|
+
}) => {
|
|
32
|
+
const [templates, setTemplates] = useState<TypesetConfig[]>([]);
|
|
33
|
+
const [slotsData, setSlotsData] = useState<SlotsResponse["slots"]>({});
|
|
34
|
+
const [account, setAccount] = useState<string>("default");
|
|
35
|
+
const [activeSlotFilter, setActiveSlotFilter] = useState<"all" | TemplateSlot>("all");
|
|
36
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
37
|
+
const [error, setError] = useState<string | null>(null);
|
|
38
|
+
|
|
39
|
+
const fetchSlots = async (acc = account) => {
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetch(`/api/templates/slots?account=${encodeURIComponent(acc)}`, { headers: { "x-admin-key": adminKey } });
|
|
42
|
+
const data: SlotsResponse = await res.json();
|
|
43
|
+
if (!res.ok || !data.ok) throw new Error(data.message || data.error || "获取槽位失败");
|
|
44
|
+
if (data.ok && data.slots) {
|
|
45
|
+
setSlotsData(data.slots);
|
|
46
|
+
}
|
|
47
|
+
} catch (err) {
|
|
48
|
+
setError(err instanceof Error ? err.message : "模板请求失败");
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const handleSetSlotDefault = async (slot: TemplateSlot, templateId: string) => {
|
|
53
|
+
try {
|
|
54
|
+
const res = await fetch("/api/templates/slots", {
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: {
|
|
57
|
+
"Content-Type": "application/json",
|
|
58
|
+
...(adminKey ? { "x-admin-key": adminKey } : {}),
|
|
59
|
+
},
|
|
60
|
+
body: JSON.stringify({ slot, templateId, account }),
|
|
61
|
+
});
|
|
62
|
+
const data = await res.json();
|
|
63
|
+
if (!res.ok || !data.ok) throw new Error(data.message || data.error || "设置默认模板失败");
|
|
64
|
+
if (data.ok) {
|
|
65
|
+
await fetchSlots(account);
|
|
66
|
+
}
|
|
67
|
+
} catch (err) {
|
|
68
|
+
setError(err instanceof Error ? err.message : "模板请求失败");
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const fetchTemplates = async () => {
|
|
73
|
+
setIsLoading(true);
|
|
74
|
+
setError(null);
|
|
75
|
+
try {
|
|
76
|
+
const res = await fetch("/api/templates", {
|
|
77
|
+
headers: adminKey ? { "x-admin-key": adminKey } : {},
|
|
78
|
+
});
|
|
79
|
+
const data = await res.json();
|
|
80
|
+
if (res.ok && data.ok && Array.isArray(data.templates)) {
|
|
81
|
+
setTemplates(data.templates);
|
|
82
|
+
} else {
|
|
83
|
+
setError(data.message || "获取模板失败,请检查密钥");
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
setError("连接后端服务失败");
|
|
87
|
+
} finally {
|
|
88
|
+
setIsLoading(false);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
if (isOpen) {
|
|
94
|
+
fetchTemplates();
|
|
95
|
+
fetchSlots(account);
|
|
96
|
+
}
|
|
97
|
+
}, [isOpen, account, adminKey, refreshKey]);
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (!isOpen) return;
|
|
101
|
+
const handleEscape = (event: KeyboardEvent) => {
|
|
102
|
+
if (event.key === "Escape" && !document.querySelector("dialog[open]")) onClose();
|
|
103
|
+
};
|
|
104
|
+
window.addEventListener("keydown", handleEscape);
|
|
105
|
+
return () => window.removeEventListener("keydown", handleEscape);
|
|
106
|
+
}, [isOpen, onClose]);
|
|
107
|
+
|
|
108
|
+
const handleDelete = async (id: string) => {
|
|
109
|
+
try {
|
|
110
|
+
const res = await fetch(`/api/templates/${encodeURIComponent(id)}`, {
|
|
111
|
+
method: "DELETE",
|
|
112
|
+
headers: adminKey ? { "x-admin-key": adminKey } : {},
|
|
113
|
+
});
|
|
114
|
+
const data = await res.json();
|
|
115
|
+
if (!res.ok || !data.ok) throw new Error(data.message || data.error || "删除模板失败");
|
|
116
|
+
setTemplates((prev) => prev.filter((t) => t.id !== id));
|
|
117
|
+
await fetchSlots(account);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
setError(err instanceof Error ? err.message : "模板请求失败");
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
if (!isOpen) return null;
|
|
124
|
+
|
|
125
|
+
const filteredTemplates = templates.filter((tpl) => {
|
|
126
|
+
if (activeSlotFilter === "all") return true;
|
|
127
|
+
return tpl.slot === activeSlotFilter;
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<aside aria-label="模板库" className="absolute inset-y-0 right-0 z-40 w-80 max-w-full bg-white dark:bg-[#0f0f0f] border-l border-[#eaeaea] dark:border-[#262626] flex flex-col shadow-xl overflow-hidden">
|
|
132
|
+
{/* Header */}
|
|
133
|
+
<div className="flex items-center justify-between px-5 py-3.5 border-b border-[#eaeaea] dark:border-[#222222]">
|
|
134
|
+
<div className="flex items-center gap-2 font-semibold text-sm text-[#171717] dark:text-white">
|
|
135
|
+
<Sparkles className="w-4 h-4 text-emerald-500 dark:text-emerald-400" />
|
|
136
|
+
<span>模板库</span>
|
|
137
|
+
</div>
|
|
138
|
+
<button
|
|
139
|
+
onClick={onClose}
|
|
140
|
+
aria-label="关闭模板库"
|
|
141
|
+
className="text-[#888888] hover:text-black dark:hover:text-white p-1 rounded-md hover:bg-[#f5f5f5] dark:hover:bg-[#1a1a1a]"
|
|
142
|
+
>
|
|
143
|
+
<X className="w-4 h-4" />
|
|
144
|
+
</button>
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
{/* Content */}
|
|
148
|
+
<div className="p-3 flex-1 min-h-0 flex flex-col gap-4 overflow-y-auto">
|
|
149
|
+
{error && (
|
|
150
|
+
<div role="alert" className="text-xs text-red-500 bg-red-500/10 p-2 rounded border border-red-500/20">{error}</div>
|
|
151
|
+
)}
|
|
152
|
+
|
|
153
|
+
{/* Account and Slot Filters */}
|
|
154
|
+
<div className="flex flex-col gap-2">
|
|
155
|
+
<div className="flex items-center justify-between text-xs">
|
|
156
|
+
<div className="flex items-center gap-1.5 font-medium text-[#666666] dark:text-[#888888]">
|
|
157
|
+
<span>槽位账号:</span>
|
|
158
|
+
<select
|
|
159
|
+
value={account}
|
|
160
|
+
onChange={(e) => setAccount(e.target.value)}
|
|
161
|
+
className="bg-white dark:bg-[#141414] border border-[#d4d4d4] dark:border-[#2a2a2a] rounded px-2 py-0.5 text-xs text-black dark:text-white focus:outline-none font-mono"
|
|
162
|
+
>
|
|
163
|
+
<option value="default">default (主账号)</option>
|
|
164
|
+
<option value="ancientone">ancientone</option>
|
|
165
|
+
</select>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
|
|
169
|
+
{/* Slot Category Tabs */}
|
|
170
|
+
<div className="flex flex-wrap items-center gap-1 text-xs">
|
|
171
|
+
<button
|
|
172
|
+
onClick={() => setActiveSlotFilter("all")}
|
|
173
|
+
className={`px-2.5 py-1 rounded text-xs transition-colors font-medium ${
|
|
174
|
+
activeSlotFilter === "all"
|
|
175
|
+
? "bg-black text-white dark:bg-white dark:text-black"
|
|
176
|
+
: "text-[#666666] dark:text-[#888888] hover:bg-[#f5f5f5] dark:hover:bg-[#1c1c1c]"
|
|
177
|
+
}`}
|
|
178
|
+
>
|
|
179
|
+
全部
|
|
180
|
+
</button>
|
|
181
|
+
<button
|
|
182
|
+
onClick={() => setActiveSlotFilter("article_cover")}
|
|
183
|
+
className={`px-2.5 py-1 rounded text-xs transition-colors font-medium ${
|
|
184
|
+
activeSlotFilter === "article_cover"
|
|
185
|
+
? "bg-black text-white dark:bg-white dark:text-black"
|
|
186
|
+
: "text-[#666666] dark:text-[#888888] hover:bg-[#f5f5f5] dark:hover:bg-[#1c1c1c]"
|
|
187
|
+
}`}
|
|
188
|
+
>
|
|
189
|
+
文章封面
|
|
190
|
+
</button>
|
|
191
|
+
<button
|
|
192
|
+
onClick={() => setActiveSlotFilter("newspic_cover")}
|
|
193
|
+
className={`px-2.5 py-1 rounded text-xs transition-colors font-medium ${
|
|
194
|
+
activeSlotFilter === "newspic_cover"
|
|
195
|
+
? "bg-black text-white dark:bg-white dark:text-black"
|
|
196
|
+
: "text-[#666666] dark:text-[#888888] hover:bg-[#f5f5f5] dark:hover:bg-[#1c1c1c]"
|
|
197
|
+
}`}
|
|
198
|
+
>
|
|
199
|
+
贴图封面
|
|
200
|
+
</button>
|
|
201
|
+
<button
|
|
202
|
+
onClick={() => setActiveSlotFilter("newspic_content")}
|
|
203
|
+
className={`px-2.5 py-1 rounded text-xs transition-colors font-medium ${
|
|
204
|
+
activeSlotFilter === "newspic_content"
|
|
205
|
+
? "bg-black text-white dark:bg-white dark:text-black"
|
|
206
|
+
: "text-[#666666] dark:text-[#888888] hover:bg-[#f5f5f5] dark:hover:bg-[#1c1c1c]"
|
|
207
|
+
}`}
|
|
208
|
+
>
|
|
209
|
+
贴图内容
|
|
210
|
+
</button>
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
{/* Template List */}
|
|
215
|
+
<div className="flex flex-col gap-2">
|
|
216
|
+
{isLoading ? (
|
|
217
|
+
<div className="text-center py-8 text-xs text-[#888888] dark:text-[#555555]">正在加载模板...</div>
|
|
218
|
+
) : filteredTemplates.length === 0 ? (
|
|
219
|
+
<div className="text-center py-8 text-xs text-[#888888] dark:text-[#555555]">暂无匹配模板</div>
|
|
220
|
+
) : (
|
|
221
|
+
filteredTemplates.map((tpl) => {
|
|
222
|
+
const isArticleCoverDefault = slotsData.article_cover?.id === tpl.id;
|
|
223
|
+
const isNewspicCoverDefault = slotsData.newspic_cover?.id === tpl.id;
|
|
224
|
+
const isNewspicContentDefault = slotsData.newspic_content?.id === tpl.id;
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<div
|
|
228
|
+
key={tpl.id}
|
|
229
|
+
className="flex flex-col justify-between p-3 rounded-lg bg-[#fafafa] dark:bg-[#141414] border border-[#eaeaea] dark:border-[#222222] hover:border-[#cccccc] dark:hover:border-[#333333] transition-all gap-2 group"
|
|
230
|
+
>
|
|
231
|
+
<div className="flex items-center gap-3">
|
|
232
|
+
<div
|
|
233
|
+
className="w-5 h-5 rounded border border-[#d4d4d4] dark:border-[#333333] shrink-0"
|
|
234
|
+
style={{ backgroundColor: tpl.theme.bgColor }}
|
|
235
|
+
/>
|
|
236
|
+
<div>
|
|
237
|
+
<div className="flex items-center gap-1.5 flex-wrap">
|
|
238
|
+
<span className="text-xs font-semibold text-[#171717] dark:text-white">{tpl.name}</span>
|
|
239
|
+
{tpl.slot && (
|
|
240
|
+
<span className="text-[10px] px-1.5 py-0.2 rounded bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/20 font-medium">
|
|
241
|
+
{tpl.slot === "article_cover"
|
|
242
|
+
? "文章封面"
|
|
243
|
+
: tpl.slot === "newspic_cover"
|
|
244
|
+
? "贴图封面"
|
|
245
|
+
: "贴图内容"}
|
|
246
|
+
</span>
|
|
247
|
+
)}
|
|
248
|
+
{isArticleCoverDefault && (
|
|
249
|
+
<span className="text-[10px] px-1.5 py-0.2 rounded bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20 font-medium flex items-center gap-0.5">
|
|
250
|
+
<Star className="w-2.5 h-2.5 fill-amber-500" /> 文章封面默认
|
|
251
|
+
</span>
|
|
252
|
+
)}
|
|
253
|
+
{isNewspicCoverDefault && (
|
|
254
|
+
<span className="text-[10px] px-1.5 py-0.2 rounded bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20 font-medium flex items-center gap-0.5">
|
|
255
|
+
<Star className="w-2.5 h-2.5 fill-amber-500" /> 贴图封面默认
|
|
256
|
+
</span>
|
|
257
|
+
)}
|
|
258
|
+
{isNewspicContentDefault && (
|
|
259
|
+
<span className="text-[10px] px-1.5 py-0.2 rounded bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 font-medium flex items-center gap-0.5">
|
|
260
|
+
<Star className="w-2.5 h-2.5 fill-emerald-500" /> 贴图内容默认
|
|
261
|
+
</span>
|
|
262
|
+
)}
|
|
263
|
+
</div>
|
|
264
|
+
<div className="text-[10px] text-[#888888] dark:text-[#666666] font-mono mt-0.5">
|
|
265
|
+
{tpl.aspectRatio} • 字号: {tpl.algorithm.baseFontSize}px
|
|
266
|
+
{tpl.images && tpl.images.length > 0 && ` • 配图: ${tpl.images.length}个`}
|
|
267
|
+
</div>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
272
|
+
{/* Set as slot default selector */}
|
|
273
|
+
{tpl.id && (
|
|
274
|
+
<select
|
|
275
|
+
value=""
|
|
276
|
+
onChange={(e) => {
|
|
277
|
+
if (e.target.value) {
|
|
278
|
+
handleSetSlotDefault(e.target.value as TemplateSlot, tpl.id!);
|
|
279
|
+
}
|
|
280
|
+
}}
|
|
281
|
+
className="text-[11px] bg-white dark:bg-[#1a1a1a] border border-[#d4d4d4] dark:border-[#333333] text-[#666666] dark:text-[#aaaaaa] rounded px-2 py-1 focus:outline-none"
|
|
282
|
+
title="为此账号设为某个槽位的默认模板"
|
|
283
|
+
>
|
|
284
|
+
<option value="">设为槽位默认...</option>
|
|
285
|
+
<option value="article_cover">设为「文章封面默认」</option>
|
|
286
|
+
<option value="newspic_cover">设为「贴图封面默认」</option>
|
|
287
|
+
<option value="newspic_content">设为「贴图内容默认」</option>
|
|
288
|
+
</select>
|
|
289
|
+
)}
|
|
290
|
+
|
|
291
|
+
<button
|
|
292
|
+
onClick={() => {
|
|
293
|
+
onApplyTemplate(tpl);
|
|
294
|
+
}}
|
|
295
|
+
className="flex items-center gap-1 px-2.5 py-1 bg-white dark:bg-[#1f1f1f] hover:bg-emerald-50 dark:hover:bg-emerald-500/20 text-[#333333] dark:text-[#cccccc] hover:text-emerald-700 dark:hover:text-emerald-400 border border-[#d4d4d4] dark:border-[#2a2a2a] hover:border-emerald-500/40 rounded text-xs transition-all font-medium"
|
|
296
|
+
>
|
|
297
|
+
<Check className="w-3 h-3" />
|
|
298
|
+
<span>套用</span>
|
|
299
|
+
</button>
|
|
300
|
+
|
|
301
|
+
{tpl.id &&
|
|
302
|
+
!tpl.id.startsWith("tpl_wechat_") &&
|
|
303
|
+
!tpl.id.startsWith("tpl_longform_") &&
|
|
304
|
+
!tpl.id.startsWith("tpl_square_") &&
|
|
305
|
+
!tpl.id.startsWith("tpl_wide_") && (
|
|
306
|
+
<button
|
|
307
|
+
onClick={() => handleDelete(tpl.id!)}
|
|
308
|
+
className="p-1 text-[#888888] dark:text-[#555555] hover:text-red-500 rounded hover:bg-[#f0f0f0] dark:hover:bg-[#1f1f1f]"
|
|
309
|
+
title="删除自定义模板"
|
|
310
|
+
>
|
|
311
|
+
<Trash2 className="w-3.5 h-3.5" />
|
|
312
|
+
</button>
|
|
313
|
+
)}
|
|
314
|
+
</div>
|
|
315
|
+
</div>
|
|
316
|
+
);
|
|
317
|
+
})
|
|
318
|
+
)}
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
</aside>
|
|
322
|
+
);
|
|
323
|
+
};
|