feat: 实现后台文章导入功能并支持解析 Hugo Frontmatter

This commit is contained in:
seahi committed 2026-08-05 16:16:25 +08:00
1 parent 60caa08228
commit 26e2439eb4
4 files changed
+164 -10

No files matched your search

+14 -4
View File
@@ -11,8 +11,8 @@ export const load: PageServerLoad = async ({ params, platform }) => {
try {
const post = await db.prepare(
'SELECT id, title, slug, content, cover, category_id FROM posts WHERE id = ?'
).bind(postId).first<{ id: number; title: string; slug: string; content: string; cover: string | null; category_id: number | null }>();
'SELECT id, title, slug, content, cover, category_id, published_at, updated_at FROM posts WHERE id = ?'
).bind(postId).first<{ id: number; title: string; slug: string; content: string; cover: string | null; category_id: number | null; published_at?: string; updated_at?: string }>();
if (!post) {
throw error(404, '文章不存在');
@@ -51,6 +51,8 @@ export const actions: Actions = {
const cover = formData.get('cover')?.toString().trim() || null;
const categoryId = formData.get('category_id') ? Number(formData.get('category_id')) : null;
const selectedTags = formData.getAll('tags').map(t => Number(t));
const publishedAt = formData.get('published_at')?.toString().trim();
const updatedAt = formData.get('updated_at')?.toString().trim() || new Date().toISOString();
if (!title || !slug || !content) {
return fail(400, { error: '标题、URL 别名和文章正文为必填项' });
@@ -58,11 +60,19 @@ export const actions: Actions = {
try {
// 更新文章表
if (publishedAt) {
await db.prepare(
`UPDATE posts
SET title = ?, slug = ?, content = ?, cover = ?, category_id = ?, updated_at = CURRENT_TIMESTAMP
SET title = ?, slug = ?, content = ?, cover = ?, category_id = ?, published_at = ?, updated_at = ?
WHERE id = ?`
).bind(title, slug, content, cover, categoryId, postId).run();
).bind(title, slug, content, cover, categoryId, publishedAt, updatedAt, postId).run();
} else {
await db.prepare(
`UPDATE posts
SET title = ?, slug = ?, content = ?, cover = ?, category_id = ?, updated_at = ?
WHERE id = ?`
).bind(title, slug, content, cover, categoryId, updatedAt, postId).run();
}
// 清除原有标签并重新绑定
await db.prepare('DELETE FROM post_tags WHERE post_id = ?').bind(postId).run();
+74 -3
View File
@@ -4,6 +4,7 @@
import { deserialize } from '$app/forms';
import MarkdownRenderer from '$lib/components/MarkdownRenderer.svelte';
import FeaturedImagePicker from '$lib/components/admin/FeaturedImagePicker.svelte';
import { parseHugoMarkdown } from '$lib/hugoParser';
let { data, form }: { data: PageData; form: ActionData } = $props();
@@ -12,6 +13,10 @@
let content = $state(data.post.content || '');
let cover = $state(data.post.cover || '');
let mode = $state<'edit' | 'preview'>('edit');
let publishedAt = $state(data.post.published_at || '');
let updatedAt = $state(data.post.updated_at || '');
let selectedCategoryId = $state<number | string>(data.post.category_id ?? '');
let fileInput: HTMLInputElement;
let selectedTags = $state<number[]>(data.selectedTagIds || []);
let tagsList = $state(data.tags || []);
@@ -139,12 +144,76 @@
showAdmonitionMenu = false;
}
async function handleImportMarkdown(e: Event) {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
try {
const rawText = await file.text();
const imported = parseHugoMarkdown(rawText, file.name);
title = imported.title;
slug = imported.slug;
content = imported.content;
if (imported.cover) cover = imported.cover;
publishedAt = imported.published_at;
updatedAt = imported.updated_at;
// 1. 分类处理:遭遇未知新分类名称时跳过,保留由用户抉择或处理
if (imported.categories.length > 0) {
const targetName = imported.categories[0].trim().toLowerCase();
const matchedCat = data.categories.find(c => c.name.trim().toLowerCase() === targetName);
if (matchedCat) {
selectedCategoryId = matchedCat.id;
}
}
// 2. 标签处理:同步点选或即刻新增入参
for (const tagName of imported.tags) {
const val = tagName.trim();
if (!val) continue;
const existingTag = tagsList.find(t => t.name.toLowerCase() === val.toLowerCase());
if (existingTag) {
if (!selectedTags.includes(existingTag.id)) {
selectedTags = [...selectedTags, existingTag.id];
}
} else {
try {
const formData = new FormData();
formData.append('name', val);
const res = await fetch('/admin/tags?/create', {
method: 'POST',
body: formData,
headers: { 'x-sveltekit-action': 'true' }
});
const actionRes = deserialize(await res.text()) as any;
if (actionRes.type === 'success' && actionRes.data?.tag) {
tagsList = [...tagsList, actionRes.data.tag];
selectedTags = [...selectedTags, actionRes.data.tag.id];
}
} catch(e) {
console.error('自动拓展建构新标签错付:', e);
}
}
}
target.value = '';
} catch (err) {
console.error('导入报错:', err);
alert('文档内部解析受断,核验是否带有完整及恰当规范的内容格式');
}
}
</script>
<div class="admin-page-header">
<h2>编辑</h2>
<div style="display: flex; align-items: center; gap: 12px;">
<button type="button" class="btn btn-secondary" onclick={() => fileInput?.click()}>📁 导入 Markdown</button>
<a href="/admin/posts" class="btn btn-secondary">&larr; 返回</a>
</div>
</div>
<input type="file" bind:this={fileInput} accept=".md,.markdown,.txt" style="display:none" onchange={handleImportMarkdown} />
{#if form?.error}
<div class="error-alert">
@@ -153,6 +222,8 @@
{/if}
<form method="POST" action="?/update" class="post-form">
<input type="hidden" name="published_at" value={publishedAt} />
<input type="hidden" name="updated_at" value={updatedAt} />
<div class="form-grid">
<div class="form-main">
<div class="form-group">
@@ -255,10 +326,10 @@
<div class="sidebar-box">
<h3>分类</h3>
<select name="category_id" class="select-input">
<option value="" selected={!data.post.category_id}>-- 无 --</option>
<select name="category_id" class="select-input" bind:value={selectedCategoryId}>
<option value="">-- 无 --</option>
{#each data.categories as category}
<option value={category.id} selected={data.post.category_id === category.id}>{category.name}</option>
<option value={category.id}>{category.name}</option>
{/each}
</select>
</div>
+4 -2
View File
@@ -32,6 +32,8 @@ export const actions: Actions = {
const cover = formData.get('cover')?.toString().trim() || null;
const categoryId = formData.get('category_id') ? Number(formData.get('category_id')) : null;
const selectedTags = formData.getAll('tags').map(t => Number(t));
const publishedAt = formData.get('published_at')?.toString().trim() || new Date().toISOString();
const updatedAt = formData.get('updated_at')?.toString().trim() || publishedAt;
if (!title || !slug || !content) {
return fail(400, { error: '标题、URL 别名和文章正文为必填项' });
@@ -40,9 +42,9 @@ export const actions: Actions = {
try {
const res = await db.prepare(
`INSERT INTO posts (title, slug, content, cover, author_id, category_id, published_at, updated_at)
VALUES (?, ?, ?, ?, 1, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
VALUES (?, ?, ?, ?, 1, ?, ?, ?)
RETURNING id`
).bind(title, slug, content, cover, categoryId).first<{ id: number }>();
).bind(title, slug, content, cover, categoryId, publishedAt, updatedAt).first<{ id: number }>();
const postId = res?.id;
+72 -1
View File
@@ -4,6 +4,7 @@
import { deserialize } from '$app/forms';
import MarkdownRenderer from '$lib/components/MarkdownRenderer.svelte';
import FeaturedImagePicker from '$lib/components/admin/FeaturedImagePicker.svelte';
import { parseHugoMarkdown } from '$lib/hugoParser';
let { data, form }: { data: PageData; form: ActionData } = $props();
@@ -12,6 +13,10 @@
let content = $state('');
let cover = $state('');
let mode = $state<'edit' | 'preview'>('edit');
let publishedAt = $state('');
let updatedAt = $state('');
let selectedCategoryId = $state<string | number>('');
let fileInput: HTMLInputElement;
let tagsList = $state(data.tags || []);
@@ -145,12 +150,76 @@
showAdmonitionMenu = false;
}
async function handleImportMarkdown(e: Event) {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
try {
const rawText = await file.text();
const imported = parseHugoMarkdown(rawText, file.name);
title = imported.title;
slug = imported.slug;
content = imported.content;
if (imported.cover) cover = imported.cover;
publishedAt = imported.published_at;
updatedAt = imported.updated_at;
// 1. 分类匹配:严格遵守“遇到全新未知的分类名跳过,由用户决定”的规范
if (imported.categories.length > 0) {
const targetName = imported.categories[0].trim().toLowerCase();
const matchedCat = data.categories.find(c => c.name.trim().toLowerCase() === targetName);
if (matchedCat) {
selectedCategoryId = matchedCat.id;
}
}
// 2. 标签匹配:库有即点取,不知则自主申请向库内补充建好
for (const tagName of imported.tags) {
const val = tagName.trim();
if (!val) continue;
const existingTag = tagsList.find(t => t.name.toLowerCase() === val.toLowerCase());
if (existingTag) {
if (!selectedTags.includes(existingTag.id)) {
selectedTags = [...selectedTags, existingTag.id];
}
} else {
try {
const formData = new FormData();
formData.append('name', val);
const res = await fetch('/admin/tags?/create', {
method: 'POST',
body: formData,
headers: { 'x-sveltekit-action': 'true' }
});
const actionRes = deserialize(await res.text()) as any;
if (actionRes.type === 'success' && actionRes.data?.tag) {
tagsList = [...tagsList, actionRes.data.tag];
selectedTags = [...selectedTags, actionRes.data.tag.id];
}
} catch(e) {
console.error('新建导入对应新标签时出错:', e);
}
}
}
target.value = '';
} catch (err) {
console.error('导入转换出现错误:', err);
alert('解析文档内容受困,请稍后检查所填文书');
}
}
</script>
<div class="admin-page-header">
<h2>新{$page.data.aliases?.post || '文章'}</h2>
<div style="display: flex; align-items: center; gap: 12px;">
<button type="button" class="btn btn-secondary" onclick={() => fileInput?.click()}>📁 导入 Markdown</button>
<a href="/admin/posts" class="btn btn-secondary">&larr; 返回</a>
</div>
</div>
<input type="file" bind:this={fileInput} accept=".md,.markdown,.txt" style="display:none" onchange={handleImportMarkdown} />
{#if form?.error}
<div class="error-alert">
@@ -159,6 +228,8 @@
{/if}
<form method="POST" class="post-form">
<input type="hidden" name="published_at" value={publishedAt} />
<input type="hidden" name="updated_at" value={updatedAt} />
<div class="form-grid">
<div class="form-main">
<div class="form-group">
@@ -261,7 +332,7 @@
<div class="sidebar-box">
<h3>分类</h3>
<select name="category_id" class="select-input">
<select name="category_id" class="select-input" bind:value={selectedCategoryId}>
<option value="">-- 无 --</option>
{#each data.categories as category}
<option value={category.id}>{category.name}</option>