feat: 在文章管理中支持关联系列及设置排序

This commit is contained in:
seahi committed 2026-08-06 12:14:14 +08:00
1 parent 2780add6a8
commit 495e453237
6 files changed
+184 -23

No files matched your search

+5 -2
View File
@@ -7,6 +7,7 @@ interface PostListRow {
date: string; date: string;
author: string | null; author: string | null;
category?: string; category?: string;
series?: string;
} }
export const load: PageServerLoad = async ({ platform }) => { export const load: PageServerLoad = async ({ platform }) => {
const db = platform?.env?.DB; const db = platform?.env?.DB;
@@ -21,10 +22,12 @@ export const load: PageServerLoad = async ({ platform }) => {
let results: PostListRow[]; let results: PostListRow[];
try { try {
const queryRes = await db.prepare( const queryRes = await db.prepare(
`SELECT p.id, p.title, p.slug, p.published_at as date, u.username as author, c.name as category `SELECT p.id, p.title, p.slug, p.published_at as date, u.username as author,
c.name as category, s.name as series
FROM posts p FROM posts p
LEFT JOIN users u ON p.author_id = u.id LEFT JOIN users u ON p.author_id = u.id
LEFT JOIN categories c ON p.category_id = c.id LEFT JOIN categories c ON p.category_id = c.id
LEFT JOIN series s ON p.series_id = s.id
ORDER BY p.published_at DESC` ORDER BY p.published_at DESC`
).all<PostListRow>(); ).all<PostListRow>();
results = queryRes.results; results = queryRes.results;
@@ -36,7 +39,7 @@ export const load: PageServerLoad = async ({ platform }) => {
LEFT JOIN users u ON p.author_id = u.id LEFT JOIN users u ON p.author_id = u.id
ORDER BY p.published_at DESC` ORDER BY p.published_at DESC`
).all<PostListRow>(); ).all<PostListRow>();
results = queryRes.results.map(r => ({ ...r, category: '未分类' })); results = queryRes.results.map(r => ({ ...r, category: '未分类', series: '' }));
} }
// Format date for display // Format date for display
+2
View File
@@ -58,6 +58,7 @@
<th>ID</th> <th>ID</th>
<th>标题</th> <th>标题</th>
<th>分类</th> <th>分类</th>
<th>{$page.data.aliases?.series || '系列'}</th>
<th>别名 (Slug)</th> <th>别名 (Slug)</th>
<th>作者</th> <th>作者</th>
<th>发布时间</th> <th>发布时间</th>
@@ -78,6 +79,7 @@
</a> </a>
</td> </td>
<td><span class="badge">{post.category || '未分类'}</span></td> <td><span class="badge">{post.category || '未分类'}</span></td>
<td>{post.series || '—'}</td>
<td><span class="badge">{post.slug}</span></td> <td><span class="badge">{post.slug}</span></td>
<td>{post.author || '未知'}</td> <td>{post.author || '未知'}</td>
<td>{post.dateStr}</td> <td>{post.dateStr}</td>
+36 -13
View File
@@ -1,5 +1,11 @@
import { error, fail, redirect } from '@sveltejs/kit'; import { error, fail, isHttpError, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import {
parseSeriesId,
parseSeriesWeight,
resolveSeriesWeight,
SeriesValidationError
} from '$lib/server/series';
export const load: PageServerLoad = async ({ params, platform }) => { export const load: PageServerLoad = async ({ params, platform }) => {
const db = platform?.env?.DB; const db = platform?.env?.DB;
@@ -11,8 +17,8 @@ export const load: PageServerLoad = async ({ params, platform }) => {
try { try {
const post = await db.prepare( const post = await db.prepare(
'SELECT id, title, slug, content, cover, category_id, published_at, updated_at FROM posts WHERE id = ?' 'SELECT id, title, slug, content, cover, category_id, series_id, series_weight, 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 }>(); ).bind(postId).first<{ id: number; title: string; slug: string; content: string; cover: string | null; category_id: number | null; series_id: number | null; series_weight: number | null; published_at?: string; updated_at?: string }>();
if (!post) { if (!post) {
throw error(404, '文章不存在'); throw error(404, '文章不存在');
@@ -20,18 +26,20 @@ export const load: PageServerLoad = async ({ params, platform }) => {
const { results: allTags } = await db.prepare('SELECT id, name FROM tags ORDER BY name ASC').all(); const { results: allTags } = await db.prepare('SELECT id, name FROM tags ORDER BY name ASC').all();
const { results: allCategories } = await db.prepare('SELECT id, name FROM categories ORDER BY name ASC').all(); const { results: allCategories } = await db.prepare('SELECT id, name FROM categories ORDER BY name ASC').all();
const { results: allSeries } = await db.prepare('SELECT id, name, slug FROM series ORDER BY name COLLATE NOCASE ASC').all();
const { results: selectedTagRows } = await db.prepare('SELECT tag_id FROM post_tags WHERE post_id = ?').bind(postId).all(); const { results: selectedTagRows } = await db.prepare('SELECT tag_id FROM post_tags WHERE post_id = ?').bind(postId).all<{ tag_id: number }>();
const selectedTagIds = selectedTagRows.map((r: any) => r.tag_id as number); const selectedTagIds = selectedTagRows.map((row) => row.tag_id);
return { return {
post, post,
tags: allTags as unknown as { id: number; name: string }[], tags: allTags as unknown as { id: number; name: string }[],
categories: allCategories as unknown as { id: number; name: string }[], categories: allCategories as unknown as { id: number; name: string }[],
series: allSeries as unknown as { id: number; name: string; slug: string }[],
selectedTagIds selectedTagIds
}; };
} catch (err: any) { } catch (err: unknown) {
if (err?.status === 404) throw err; if (isHttpError(err, 404)) throw err;
console.error('Error loading post for edit:', err); console.error('Error loading post for edit:', err);
throw error(500, '读取文章详情失败'); throw error(500, '读取文章详情失败');
} }
@@ -58,20 +66,35 @@ export const actions: Actions = {
return fail(400, { error: '标题、URL 别名和文章正文为必填项' }); return fail(400, { error: '标题、URL 别名和文章正文为必填项' });
} }
let seriesId: number | null;
let seriesWeight: number | null;
try {
seriesId = parseSeriesId(formData.get('series_id'));
seriesWeight = await resolveSeriesWeight(
db,
seriesId,
parseSeriesWeight(formData.get('series_weight')),
postId
);
} catch (err) {
if (err instanceof SeriesValidationError) return fail(400, { error: err.message });
throw err;
}
try { try {
// 更新文章表 // 更新文章表
if (publishedAt) { if (publishedAt) {
await db.prepare( await db.prepare(
`UPDATE posts `UPDATE posts
SET title = ?, slug = ?, content = ?, cover = ?, category_id = ?, published_at = ?, updated_at = ? SET title = ?, slug = ?, content = ?, cover = ?, category_id = ?, series_id = ?, series_weight = ?, published_at = ?, updated_at = ?
WHERE id = ?` WHERE id = ?`
).bind(title, slug, content, cover, categoryId, publishedAt, updatedAt, postId).run(); ).bind(title, slug, content, cover, categoryId, seriesId, seriesWeight, publishedAt, updatedAt, postId).run();
} else { } else {
await db.prepare( await db.prepare(
`UPDATE posts `UPDATE posts
SET title = ?, slug = ?, content = ?, cover = ?, category_id = ?, updated_at = ? SET title = ?, slug = ?, content = ?, cover = ?, category_id = ?, series_id = ?, series_weight = ?, updated_at = ?
WHERE id = ?` WHERE id = ?`
).bind(title, slug, content, cover, categoryId, updatedAt, postId).run(); ).bind(title, slug, content, cover, categoryId, seriesId, seriesWeight, updatedAt, postId).run();
} }
// 清除原有标签并重新绑定 // 清除原有标签并重新绑定
@@ -82,9 +105,9 @@ export const actions: Actions = {
await db.prepare('INSERT INTO post_tags (post_id, tag_id) VALUES (?, ?)').bind(postId, tagId).run(); await db.prepare('INSERT INTO post_tags (post_id, tag_id) VALUES (?, ?)').bind(postId, tagId).run();
} }
} }
} catch (err: any) { } catch (err: unknown) {
console.error('Error updating post:', err); console.error('Error updating post:', err);
if (err?.message?.includes('UNIQUE constraint failed')) { if (err instanceof Error && err.message.includes('UNIQUE constraint failed')) {
return fail(400, { error: 'URL 别名 (Slug) 已被其他文章占用' }); return fail(400, { error: 'URL 别名 (Slug) 已被其他文章占用' });
} }
return fail(500, { error: '保存修改失败' }); return fail(500, { error: '保存修改失败' });
+56 -1
View File
@@ -16,9 +16,12 @@
let publishedAt = $state(data.post.published_at || ''); let publishedAt = $state(data.post.published_at || '');
let updatedAt = $state(data.post.updated_at || ''); let updatedAt = $state(data.post.updated_at || '');
let selectedCategoryId = $state<number | string>(data.post.category_id ?? ''); let selectedCategoryId = $state<number | string>(data.post.category_id ?? '');
let selectedSeriesId = $state<number | string>(data.post.series_id ?? '');
let seriesWeight = $state<number | string>(data.post.series_weight ?? '');
let fileInput: HTMLInputElement; let fileInput: HTMLInputElement;
let selectedTags = $state<number[]>(data.selectedTagIds || []); let selectedTags = $state<number[]>(data.selectedTagIds || []);
let tagsList = $state(data.tags || []); let tagsList = $state(data.tags || []);
let seriesList = $state(data.series || []);
function toggleTag(tagId: number) { function toggleTag(tagId: number) {
if (selectedTags.includes(tagId)) { if (selectedTags.includes(tagId)) {
@@ -169,7 +172,36 @@
} }
} }
// 2. 标签处理:同步点选或即刻新增入参 selectedSeriesId = '';
seriesWeight = imported.series_weight ?? '';
if (imported.series.length > 0) {
if (imported.series.length > 1) {
alert(`检测到多个系列:${imported.series.join('、')}。BlogFlare 每篇文章只能属于一个系列,本次将选择第一个系列。`);
}
const seriesName = imported.series[0].trim();
let matchedSeries = seriesList.find(item => item.name.trim().toLowerCase() === seriesName.toLowerCase());
if (!matchedSeries && seriesName) {
const formData = new FormData();
formData.append('name', seriesName);
const response = await fetch('/admin/series?/create', {
method: 'POST',
body: formData,
headers: { 'x-sveltekit-action': 'true' }
});
const actionResult = deserialize(await response.text()) as any;
if (actionResult.type === 'success' && actionResult.data?.series) {
const createdSeries = actionResult.data.series as { id: number; name: string; slug: string };
matchedSeries = createdSeries;
seriesList = [...seriesList, createdSeries];
} else {
alert(actionResult.data?.error || `无法创建系列“${seriesName}”,请在系列管理中检查名称和 URL 别名。`);
}
}
if (matchedSeries) selectedSeriesId = matchedSeries.id;
}
// 3. 标签处理:同步点选或即刻新增入参
for (const tagName of imported.tags) { for (const tagName of imported.tags) {
const val = tagName.trim(); const val = tagName.trim();
if (!val) continue; if (!val) continue;
@@ -331,6 +363,25 @@
</select> </select>
</div> </div>
<div class="sidebar-box">
<h3>{$page.data.aliases?.series || '系列'}</h3>
<select name="series_id" class="select-input" bind:value={selectedSeriesId}>
<option value="">-- 无 --</option>
{#each seriesList as series}
<option value={series.id}>{series.name}</option>
{/each}
</select>
<input
type="number"
name="series_weight"
class="sidebar-input series-weight-input"
bind:value={seriesWeight}
placeholder="顺序(留空自动追加)"
disabled={!selectedSeriesId}
step="1"
/>
</div>
<div class="sidebar-box"> <div class="sidebar-box">
<h3>标签</h3> <h3>标签</h3>
<div class="selected-tags-vertical"> <div class="selected-tags-vertical">
@@ -611,6 +662,10 @@
border-bottom: none; border-bottom: none;
} }
.series-weight-input {
margin-top: 10px;
}
.selected-tags-vertical { .selected-tags-vertical {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+29 -6
View File
@@ -1,23 +1,32 @@
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types'; import type { Actions, PageServerLoad } from './$types';
import {
parseSeriesId,
parseSeriesWeight,
resolveSeriesWeight,
SeriesValidationError
} from '$lib/server/series';
export const load: PageServerLoad = async ({ platform }) => { export const load: PageServerLoad = async ({ platform }) => {
const db = platform?.env?.DB; const db = platform?.env?.DB;
let tags: { id: number; name: string }[] = []; let tags: { id: number; name: string }[] = [];
let categories: { id: number; name: string }[] = []; let categories: { id: number; name: string }[] = [];
let series: { id: number; name: string; slug: string }[] = [];
if (db) { if (db) {
try { try {
const { results: tagResults } = await db.prepare('SELECT id, name FROM tags ORDER BY name ASC').all(); const { results: tagResults } = await db.prepare('SELECT id, name FROM tags ORDER BY name ASC').all();
const { results: catResults } = await db.prepare('SELECT id, name FROM categories ORDER BY name ASC').all(); const { results: catResults } = await db.prepare('SELECT id, name FROM categories ORDER BY name ASC').all();
const { results: seriesResults } = await db.prepare('SELECT id, name, slug FROM series ORDER BY name COLLATE NOCASE ASC').all();
tags = tagResults as unknown as { id: number; name: string }[]; tags = tagResults as unknown as { id: number; name: string }[];
categories = catResults as unknown as { id: number; name: string }[]; categories = catResults as unknown as { id: number; name: string }[];
series = seriesResults as unknown as { id: number; name: string; slug: string }[];
} catch (err) { } catch (err) {
console.error('Error fetching tags or categories:', err); console.error('Error fetching tags or categories:', err);
} }
} }
return { tags, categories }; return { tags, categories, series };
}; };
export const actions: Actions = { export const actions: Actions = {
@@ -39,12 +48,26 @@ export const actions: Actions = {
return fail(400, { error: '标题、URL 别名和文章正文为必填项' }); return fail(400, { error: '标题、URL 别名和文章正文为必填项' });
} }
let seriesId: number | null;
let seriesWeight: number | null;
try {
seriesId = parseSeriesId(formData.get('series_id'));
seriesWeight = await resolveSeriesWeight(
db,
seriesId,
parseSeriesWeight(formData.get('series_weight'))
);
} catch (err) {
if (err instanceof SeriesValidationError) return fail(400, { error: err.message });
throw err;
}
try { try {
const res = await db.prepare( const res = await db.prepare(
`INSERT INTO posts (title, slug, content, cover, author_id, category_id, published_at, updated_at) `INSERT INTO posts (title, slug, content, cover, author_id, category_id, series_id, series_weight, published_at, updated_at)
VALUES (?, ?, ?, ?, 1, ?, ?, ?) VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?, ?)
RETURNING id` RETURNING id`
).bind(title, slug, content, cover, categoryId, publishedAt, updatedAt).first<{ id: number }>(); ).bind(title, slug, content, cover, categoryId, seriesId, seriesWeight, publishedAt, updatedAt).first<{ id: number }>();
const postId = res?.id; const postId = res?.id;
@@ -53,9 +76,9 @@ export const actions: Actions = {
await db.prepare('INSERT INTO post_tags (post_id, tag_id) VALUES (?, ?)').bind(postId, tagId).run(); await db.prepare('INSERT INTO post_tags (post_id, tag_id) VALUES (?, ?)').bind(postId, tagId).run();
} }
} }
} catch (err: any) { } catch (err: unknown) {
console.error('Error creating post:', err); console.error('Error creating post:', err);
if (err?.message?.includes('UNIQUE constraint failed')) { if (err instanceof Error && err.message.includes('UNIQUE constraint failed')) {
return fail(400, { error: 'URL 别名 (Slug) 已被占用,请使用其他别名' }); return fail(400, { error: 'URL 别名 (Slug) 已被占用,请使用其他别名' });
} }
return fail(500, { error: '保存文章失败' }); return fail(500, { error: '保存文章失败' });
+56 -1
View File
@@ -16,9 +16,12 @@
let publishedAt = $state(''); let publishedAt = $state('');
let updatedAt = $state(''); let updatedAt = $state('');
let selectedCategoryId = $state<string | number>(''); let selectedCategoryId = $state<string | number>('');
let selectedSeriesId = $state<string | number>('');
let seriesWeight = $state<string | number>('');
let fileInput: HTMLInputElement; let fileInput: HTMLInputElement;
let tagsList = $state(data.tags || []); let tagsList = $state(data.tags || []);
let seriesList = $state(data.series || []);
// 自动从标题生成 Slug 简易逻辑 // 自动从标题生成 Slug 简易逻辑
function handleTitleInput() { function handleTitleInput() {
@@ -175,7 +178,36 @@
} }
} }
// 2. 标签匹配:库有即点取,不知则自主申请向库内补充建好 selectedSeriesId = '';
seriesWeight = imported.series_weight ?? '';
if (imported.series.length > 0) {
if (imported.series.length > 1) {
alert(`检测到多个系列:${imported.series.join('、')}。BlogFlare 每篇文章只能属于一个系列,本次将选择第一个系列。`);
}
const seriesName = imported.series[0].trim();
let matchedSeries = seriesList.find(item => item.name.trim().toLowerCase() === seriesName.toLowerCase());
if (!matchedSeries && seriesName) {
const formData = new FormData();
formData.append('name', seriesName);
const response = await fetch('/admin/series?/create', {
method: 'POST',
body: formData,
headers: { 'x-sveltekit-action': 'true' }
});
const actionResult = deserialize(await response.text()) as any;
if (actionResult.type === 'success' && actionResult.data?.series) {
const createdSeries = actionResult.data.series as { id: number; name: string; slug: string };
matchedSeries = createdSeries;
seriesList = [...seriesList, createdSeries];
} else {
alert(actionResult.data?.error || `无法创建系列“${seriesName}”,请在系列管理中检查名称和 URL 别名。`);
}
}
if (matchedSeries) selectedSeriesId = matchedSeries.id;
}
// 3. 标签匹配:库有即点取,不知则自主申请向库内补充建好
for (const tagName of imported.tags) { for (const tagName of imported.tags) {
const val = tagName.trim(); const val = tagName.trim();
if (!val) continue; if (!val) continue;
@@ -337,6 +369,25 @@
</select> </select>
</div> </div>
<div class="sidebar-box">
<h3>{$page.data.aliases?.series || '系列'}</h3>
<select name="series_id" class="select-input" bind:value={selectedSeriesId}>
<option value="">-- 无 --</option>
{#each seriesList as series}
<option value={series.id}>{series.name}</option>
{/each}
</select>
<input
type="number"
name="series_weight"
class="sidebar-input series-weight-input"
bind:value={seriesWeight}
placeholder="顺序(留空自动追加)"
disabled={!selectedSeriesId}
step="1"
/>
</div>
<div class="sidebar-box"> <div class="sidebar-box">
<h3>标签</h3> <h3>标签</h3>
<div class="selected-tags-vertical"> <div class="selected-tags-vertical">
@@ -616,6 +667,10 @@
border-bottom: none; border-bottom: none;
} }
.series-weight-input {
margin-top: 10px;
}
.selected-tags-vertical { .selected-tags-vertical {
display: flex; display: flex;
flex-direction: column; flex-direction: column;