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

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

No files matched your search

+5 -2
View File
@@ -7,6 +7,7 @@ interface PostListRow {
date: string;
author: string | null;
category?: string;
series?: string;
}
export const load: PageServerLoad = async ({ platform }) => {
const db = platform?.env?.DB;
@@ -21,10 +22,12 @@ export const load: PageServerLoad = async ({ platform }) => {
let results: PostListRow[];
try {
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
LEFT JOIN users u ON p.author_id = u.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`
).all<PostListRow>();
results = queryRes.results;
@@ -36,7 +39,7 @@ export const load: PageServerLoad = async ({ platform }) => {
LEFT JOIN users u ON p.author_id = u.id
ORDER BY p.published_at DESC`
).all<PostListRow>();
results = queryRes.results.map(r => ({ ...r, category: '未分类' }));
results = queryRes.results.map(r => ({ ...r, category: '未分类', series: '' }));
}
// Format date for display
+3 -1
View File
@@ -58,6 +58,7 @@
<th>ID</th>
<th>标题</th>
<th>分类</th>
<th>{$page.data.aliases?.series || '系列'}</th>
<th>别名 (Slug)</th>
<th>作者</th>
<th>发布时间</th>
@@ -77,7 +78,8 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>
</a>
</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>{post.author || '未知'}</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 {
parseSeriesId,
parseSeriesWeight,
resolveSeriesWeight,
SeriesValidationError
} from '$lib/server/series';
export const load: PageServerLoad = async ({ params, platform }) => {
const db = platform?.env?.DB;
@@ -11,8 +17,8 @@ export const load: PageServerLoad = async ({ params, platform }) => {
try {
const post = await db.prepare(
'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 }>();
'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; series_id: number | null; series_weight: number | null; published_at?: string; updated_at?: string }>();
if (!post) {
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: 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 selectedTagIds = selectedTagRows.map((r: any) => r.tag_id as number);
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((row) => row.tag_id);
return {
post,
tags: allTags 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
};
} catch (err: any) {
if (err?.status === 404) throw err;
} catch (err: unknown) {
if (isHttpError(err, 404)) throw err;
console.error('Error loading post for edit:', err);
throw error(500, '读取文章详情失败');
}
@@ -58,20 +66,35 @@ export const actions: Actions = {
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 {
// 更新文章表
if (publishedAt) {
await db.prepare(
`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 = ?`
).bind(title, slug, content, cover, categoryId, publishedAt, updatedAt, postId).run();
).bind(title, slug, content, cover, categoryId, seriesId, seriesWeight, publishedAt, updatedAt, postId).run();
} else {
await db.prepare(
`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 = ?`
).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();
}
}
} catch (err: any) {
} catch (err: unknown) {
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(500, { error: '保存修改失败' });
+56 -1
View File
@@ -16,9 +16,12 @@
let publishedAt = $state(data.post.published_at || '');
let updatedAt = $state(data.post.updated_at || '');
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 selectedTags = $state<number[]>(data.selectedTagIds || []);
let tagsList = $state(data.tags || []);
let seriesList = $state(data.series || []);
function toggleTag(tagId: number) {
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) {
const val = tagName.trim();
if (!val) continue;
@@ -331,6 +363,25 @@
</select>
</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">
<h3>标签</h3>
<div class="selected-tags-vertical">
@@ -611,6 +662,10 @@
border-bottom: none;
}
.series-weight-input {
margin-top: 10px;
}
.selected-tags-vertical {
display: flex;
flex-direction: column;
+29 -6
View File
@@ -1,23 +1,32 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import {
parseSeriesId,
parseSeriesWeight,
resolveSeriesWeight,
SeriesValidationError
} from '$lib/server/series';
export const load: PageServerLoad = async ({ platform }) => {
const db = platform?.env?.DB;
let tags: { id: number; name: string }[] = [];
let categories: { id: number; name: string }[] = [];
let series: { id: number; name: string; slug: string }[] = [];
if (db) {
try {
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: 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 }[];
categories = catResults as unknown as { id: number; name: string }[];
series = seriesResults as unknown as { id: number; name: string; slug: string }[];
} catch (err) {
console.error('Error fetching tags or categories:', err);
}
}
return { tags, categories };
return { tags, categories, series };
};
export const actions: Actions = {
@@ -39,12 +48,26 @@ export const actions: Actions = {
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 {
const res = await db.prepare(
`INSERT INTO posts (title, slug, content, cover, author_id, category_id, published_at, updated_at)
VALUES (?, ?, ?, ?, 1, ?, ?, ?)
`INSERT INTO posts (title, slug, content, cover, author_id, category_id, series_id, series_weight, published_at, updated_at)
VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?, ?)
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;
@@ -53,9 +76,9 @@ export const actions: Actions = {
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);
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(500, { error: '保存文章失败' });
+56 -1
View File
@@ -16,9 +16,12 @@
let publishedAt = $state('');
let updatedAt = $state('');
let selectedCategoryId = $state<string | number>('');
let selectedSeriesId = $state<string | number>('');
let seriesWeight = $state<string | number>('');
let fileInput: HTMLInputElement;
let tagsList = $state(data.tags || []);
let seriesList = $state(data.series || []);
// 自动从标题生成 Slug 简易逻辑
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) {
const val = tagName.trim();
if (!val) continue;
@@ -337,6 +369,25 @@
</select>
</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">
<h3>标签</h3>
<div class="selected-tags-vertical">
@@ -616,6 +667,10 @@
border-bottom: none;
}
.series-weight-input {
margin-top: 10px;
}
.selected-tags-vertical {
display: flex;
flex-direction: column;