feat: 实现后台文章导入功能并支持解析 Hugo Frontmatter
This commit is contained in:
1 parent
60caa08228
commit
26e2439eb4
4 files changed
+169
-15
No files matched your search
@@ -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 {
|
||||
// 更新文章表
|
||||
await db.prepare(
|
||||
`UPDATE posts
|
||||
SET title = ?, slug = ?, content = ?, cover = ?, category_id = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`
|
||||
).bind(title, slug, content, cover, categoryId, postId).run();
|
||||
if (publishedAt) {
|
||||
await db.prepare(
|
||||
`UPDATE posts
|
||||
SET title = ?, slug = ?, content = ?, cover = ?, category_id = ?, published_at = ?, updated_at = ?
|
||||
WHERE id = ?`
|
||||
).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();
|
||||
|
||||
@@ -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>
|
||||
<a href="/admin/posts" class="btn btn-secondary">← 返回</a>
|
||||
<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">← 返回</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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
<a href="/admin/posts" class="btn btn-secondary">← 返回</a>
|
||||
<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">← 返回</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>
|
||||
|
||||
Reference in new issue
Block a user