feat: 在文章管理中支持关联系列及设置排序
This commit is contained in:
1 parent
2780add6a8
commit
495e453237
6 files changed
+184
-23
No files matched your search
@@ -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
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
<th>ID</th>
|
||||
<th>标题</th>
|
||||
<th>分类</th>
|
||||
<th>{$page.data.aliases?.series || '系列'}</th>
|
||||
<th>别名 (Slug)</th>
|
||||
<th>作者</th>
|
||||
<th>发布时间</th>
|
||||
@@ -78,6 +79,7 @@
|
||||
</a>
|
||||
</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>
|
||||
|
||||
@@ -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: '保存修改失败' });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: '保存文章失败' });
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user