feat: 添加分类与标签的管理功能

This commit is contained in:
seahi committed 2026-07-26 17:52:39 +08:00
1 parent b4b2907d77
commit 19520f5efb
4 files changed
+724

No files matched your search

+115
View File
@@ -0,0 +1,115 @@
async function ensureCategoriesTable(db: any) {
try {
await db.prepare(`
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE
)
`).run();
try {
await db.prepare('ALTER TABLE posts ADD COLUMN category_id INTEGER').run();
} catch {
// Column might already exist
}
} catch (e) {
console.error('Failed to ensure categories table:', e);
}
}
export const load: PageServerLoad = async ({ platform }) => {
const db = platform?.env?.DB;
if (!db) {
return { categories: [] };
}
try {
const { results } = await db.prepare(
`SELECT c.id, c.name, c.slug, COUNT(p.id) as post_count
FROM categories c
LEFT JOIN posts p ON c.id = p.category_id
GROUP BY c.id, c.name, c.slug
ORDER BY c.name ASC`
).all();
return {
categories: results as unknown as { id: number; name: string; slug: string; post_count: number }[]
};
} catch (err: any) {
if (err?.message?.includes('no such table')) {
await ensureCategoriesTable(db);
try {
const { results } = await db.prepare(
`SELECT c.id, c.name, c.slug, COUNT(p.id) as post_count
FROM categories c
LEFT JOIN posts p ON c.id = p.category_id
GROUP BY c.id, c.name, c.slug
ORDER BY c.name ASC`
).all();
return { categories: results as unknown as { id: number; name: string; slug: string; post_count: number }[] };
} catch (e) {
return { categories: [] };
}
}
console.error('Error fetching categories:', err);
return { categories: [] };
}
};
export const actions: Actions = {
create: async ({ request, platform }) => {
const db = platform?.env?.DB;
if (!db) return fail(500, { error: '数据库未连接' });
const formData = await request.formData();
const name = formData.get('name')?.toString().trim();
let slug = formData.get('slug')?.toString().trim();
if (!name) {
return fail(400, { error: '分类名称不能为空' });
}
if (!slug) {
slug = name.toLowerCase().replace(/\s+/g, '-');
}
try {
await db.prepare('INSERT INTO categories (name, slug) VALUES (?, ?)').bind(name, slug).run();
return { success: true };
} catch (err: any) {
if (err?.message?.includes('no such table')) {
await ensureCategoriesTable(db);
try {
await db.prepare('INSERT INTO categories (name, slug) VALUES (?, ?)').bind(name, slug).run();
return { success: true };
} catch (e: any) {
return fail(500, { error: '创建分类失败: ' + e.message });
}
}
console.error('Error creating category:', err);
if (err?.message?.includes('UNIQUE constraint failed')) {
return fail(400, { error: '分类名称或 URL 别名 (Slug) 已存在' });
}
return fail(500, { error: '创建分类失败' });
}
},
delete: async ({ request, platform }) => {
const db = platform?.env?.DB;
if (!db) return fail(500, { error: '数据库未连接' });
const formData = await request.formData();
const id = formData.get('id');
if (!id) return fail(400, { error: '缺少分类 ID' });
try {
await db.prepare('DELETE FROM categories WHERE id = ?').bind(id).run();
return { success: true };
} catch (err) {
console.error('Error deleting category:', err);
return { success: false, error: '删除分类失败' };
}
}
};
+279
View File
@@ -0,0 +1,279 @@
<script lang="ts">
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let name = $state('');
let slug = $state('');
function handleNameInput() {
if (!slug) {
slug = name.toLowerCase().replace(/\s+/g, '-');
}
}
</script>
<div class="admin-page-header">
<h2>分类管理</h2>
</div>
{#if form?.error}
<div class="error-alert">
{form.error}
</div>
{/if}
<div class="categories-grid-layout">
<!-- 左侧:新建分类卡片 -->
<div class="create-category-card">
<h3>新分类</h3>
<form method="POST" action="?/create">
<div class="form-group">
<label for="name">名称 *</label>
<input
type="text"
id="name"
name="name"
bind:value={name}
oninput={handleNameInput}
placeholder="例如: 技术分享"
required
/>
</div>
<div class="form-group">
<label for="slug">别名</label>
<input
type="text"
id="slug"
name="slug"
bind:value={slug}
placeholder="例如: tech"
/>
</div>
<button type="submit" class="btn btn-primary">创建</button>
</form>
</div>
<!-- 右侧:分类列表 -->
<div class="table-container">
{#if data.categories.length === 0}
<div class="empty-state">
<p>暂无任何分类,在左侧添加一个吧!</p>
</div>
{:else}
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>分类名称</th>
<th>别名 (Slug)</th>
<th>关联文章数</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{#each data.categories as category}
<tr>
<td>{category.id}</td>
<td><strong>{category.name}</strong></td>
<td><span class="badge">{category.slug}</span></td>
<td><span class="badge">{category.post_count} 篇文章</span></td>
<td>
<form action="?/delete" method="POST" class="inline-form" onsubmit={(e) => {
if(!confirm(`确定要删除分类 "${category.name}" 吗?删除后相关文章将归为未分类。`)) e.preventDefault();
}}>
<input type="hidden" name="id" value={category.id} />
<button type="submit" class="action-btn delete">删除</button>
</form>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div>
<style>
.admin-page-header {
margin-bottom: 25px;
border-bottom: 2px solid var(--text-color);
padding-bottom: 12px;
}
.admin-page-header h2 {
margin: 0;
font-size: 2rem;
color: var(--text-color);
}
.error-alert {
padding: 12px 16px;
background-color: #fdf7f7;
border: 2px solid #d9534f;
color: #d9534f;
margin-bottom: 20px;
font-weight: 600;
}
.categories-grid-layout {
display: grid;
grid-template-columns: 300px 1fr;
gap: 30px;
}
@media (max-width: 768px) {
.categories-grid-layout {
grid-template-columns: 1fr;
}
}
.create-category-card {
background: transparent;
border: none;
padding: 0;
height: fit-content;
}
.create-category-card h3 {
margin: 0 0 20px 0;
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-color);
border-bottom: none;
}
.form-group {
margin-bottom: 25px;
}
.form-group label {
display: block;
font-weight: 700;
margin-bottom: 12px;
color: var(--text-color);
font-size: 0.9rem;
letter-spacing: 0.5px;
}
.form-group input[type="text"] {
width: 100%;
padding: 10px 0;
border: none;
border-bottom: 2px solid rgba(0, 0, 0, 0.1);
background: transparent;
font-family: inherit;
font-size: 1.05rem;
color: var(--text-color);
box-sizing: border-box;
transition: border-bottom-color 0.3s ease;
}
.form-group input[type="text"]:focus {
outline: none;
border-bottom-color: var(--accent-color, #bd6262);
}
.btn {
display: inline-block;
width: 100%;
padding: 10px 20px;
text-decoration: none;
font-size: 0.95rem;
font-weight: 600;
transition: all 0.3s ease;
cursor: pointer;
border: 1px solid var(--text-color);
background-color: transparent;
color: var(--text-color);
font-family: inherit;
text-transform: uppercase;
letter-spacing: 1px;
}
.btn-primary {
background-color: var(--text-color);
color: var(--bg-color, #ffffff);
}
.btn-primary:hover {
background-color: var(--accent-color, #bd6262);
border-color: var(--accent-color, #bd6262);
color: white;
}
.table-container {
background: transparent;
border: 2px solid var(--text-color);
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
.data-table th, .data-table td {
padding: 14px 18px;
border-bottom: 1px solid var(--border-color, rgba(0,0,0,0.08));
}
.data-table th {
background-color: transparent;
color: var(--text-color);
font-weight: 700;
font-size: 0.95rem;
border-bottom: 2px solid var(--text-color);
}
.data-table tr:last-child td {
border-bottom: none;
}
.data-table tbody tr:hover {
background-color: rgba(0,0,0,0.02);
}
.actions {
white-space: nowrap;
}
.inline-form {
display: inline-block;
margin: 0;
padding: 0;
vertical-align: baseline;
}
.action-btn {
display: inline-block;
background: transparent;
border: none;
padding: 0;
margin: 0;
font-family: inherit;
font-size: 0.9rem;
font-weight: 500;
line-height: 1;
vertical-align: baseline;
color: var(--text-color);
cursor: pointer;
text-decoration: none;
transition: color 0.2s, text-decoration-color 0.2s;
}
.action-btn.delete:hover {
color: #d9534f;
text-decoration: underline;
text-underline-offset: 4px;
}
.empty-state {
padding: 50px 20px;
text-align: center;
color: var(--text-muted, rgba(0,0,0,0.6));
}
</style>
+71
View File
@@ -0,0 +1,71 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ platform }) => {
const db = platform?.env?.DB;
if (!db) {
return { tags: [] };
}
try {
// 查询所有标签以及对应的文章数量
const { results } = await db.prepare(
`SELECT t.id, t.name, COUNT(pt.post_id) as post_count
FROM tags t
LEFT JOIN post_tags pt ON t.id = pt.tag_id
GROUP BY t.id, t.name
ORDER BY t.name ASC`
).all();
return {
tags: results as unknown as { id: number; name: string; post_count: number }[]
};
} catch (err) {
console.error('Error fetching tags:', err);
return { tags: [] };
}
};
export const actions: Actions = {
create: async ({ request, platform }) => {
const db = platform?.env?.DB;
if (!db) return fail(500, { error: '数据库未连接' });
const formData = await request.formData();
const name = formData.get('name')?.toString().trim();
if (!name) {
return fail(400, { error: '标签名称不能为空' });
}
try {
await db.prepare('INSERT INTO tags (name) VALUES (?)').bind(name).run();
return { success: true };
} catch (err: any) {
console.error('Error creating tag:', err);
if (err?.message?.includes('UNIQUE constraint failed')) {
return fail(400, { error: '标签名称已存在' });
}
return fail(500, { error: '创建标签失败' });
}
},
delete: async ({ request, platform }) => {
const db = platform?.env?.DB;
if (!db) return fail(500, { error: '数据库未连接' });
const formData = await request.formData();
const id = formData.get('id');
if (!id) return fail(400, { error: '缺少标签 ID' });
try {
await db.prepare('DELETE FROM tags WHERE id = ?').bind(id).run();
return { success: true };
} catch (err) {
console.error('Error deleting tag:', err);
return { success: false, error: '删除标签失败' };
}
}
};
+259
View File
@@ -0,0 +1,259 @@
<script lang="ts">
import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let newTagName = $state('');
</script>
<div class="admin-page-header">
<h2>标签管理</h2>
</div>
{#if form?.error}
<div class="error-alert">
{form.error}
</div>
{/if}
<div class="tags-grid-layout">
<!-- 左侧:新建标签卡片 -->
<div class="create-tag-card">
<h3>新标签</h3>
<form method="POST" action="?/create">
<div class="form-group">
<label for="name">名称 *</label>
<input
type="text"
id="name"
name="name"
bind:value={newTagName}
placeholder="例如: SvelteKit"
required
/>
</div>
<button type="submit" class="btn btn-primary">创建</button>
</form>
</div>
<!-- 右侧:现有标签列表 -->
<div class="table-container">
{#if data.tags.length === 0}
<div class="empty-state">
<p>暂无任何标签,在左侧添加一个吧!</p>
</div>
{:else}
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>标签名称</th>
<th>关联文章数</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{#each data.tags as tag}
<tr>
<td>{tag.id}</td>
<td><strong>{tag.name}</strong></td>
<td><span class="badge">{tag.post_count} 篇文章</span></td>
<td>
<form action="?/delete" method="POST" class="inline-form" onsubmit={(e) => {
if(!confirm(`确定要删除标签 "${tag.name}" 吗?`)) e.preventDefault();
}}>
<input type="hidden" name="id" value={tag.id} />
<button type="submit" class="action-btn delete">删除</button>
</form>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div>
<style>
.admin-page-header {
margin-bottom: 25px;
border-bottom: 2px solid var(--text-color);
padding-bottom: 12px;
}
.admin-page-header h2 {
margin: 0;
font-size: 2rem;
color: var(--text-color);
}
.error-alert {
padding: 12px 16px;
background-color: #fdf7f7;
border: 2px solid #d9534f;
color: #d9534f;
margin-bottom: 20px;
font-weight: 600;
}
.tags-grid-layout {
display: grid;
grid-template-columns: 300px 1fr;
gap: 30px;
}
@media (max-width: 768px) {
.tags-grid-layout {
grid-template-columns: 1fr;
}
}
.create-tag-card {
background: transparent;
border: none;
padding: 0;
height: fit-content;
}
.create-tag-card h3 {
margin: 0 0 20px 0;
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-color);
border-bottom: none;
}
.form-group {
margin-bottom: 25px;
}
.form-group label {
display: block;
font-weight: 700;
margin-bottom: 12px;
color: var(--text-color);
font-size: 0.9rem;
letter-spacing: 0.5px;
}
.form-group input[type="text"] {
width: 100%;
padding: 10px 0;
border: none;
border-bottom: 2px solid rgba(0, 0, 0, 0.1);
background: transparent;
font-family: inherit;
font-size: 1.05rem;
color: var(--text-color);
box-sizing: border-box;
transition: border-bottom-color 0.3s ease;
}
.form-group input[type="text"]:focus {
outline: none;
border-bottom-color: var(--accent-color, #bd6262);
}
.btn {
display: inline-block;
width: 100%;
padding: 10px 20px;
text-decoration: none;
font-size: 0.95rem;
font-weight: 600;
transition: all 0.3s ease;
cursor: pointer;
border: 1px solid var(--text-color);
background-color: transparent;
color: var(--text-color);
font-family: inherit;
text-transform: uppercase;
letter-spacing: 1px;
}
.btn-primary {
background-color: var(--text-color);
color: var(--bg-color, #ffffff);
}
.btn-primary:hover {
background-color: var(--accent-color, #bd6262);
border-color: var(--accent-color, #bd6262);
color: white;
}
.table-container {
background: transparent;
border: 2px solid var(--text-color);
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
.data-table th, .data-table td {
padding: 14px 18px;
border-bottom: 1px solid var(--border-color, rgba(0,0,0,0.08));
}
.data-table th {
background-color: transparent;
color: var(--text-color);
font-weight: 700;
font-size: 0.95rem;
border-bottom: 2px solid var(--text-color);
}
.data-table tr:last-child td {
border-bottom: none;
}
.data-table tbody tr:hover {
background-color: rgba(0,0,0,0.02);
}
.actions {
white-space: nowrap;
}
.inline-form {
display: inline-block;
margin: 0;
padding: 0;
vertical-align: baseline;
}
.action-btn {
display: inline-block;
background: transparent;
border: none;
padding: 0;
margin: 0;
font-family: inherit;
font-size: 0.9rem;
font-weight: 500;
line-height: 1;
vertical-align: baseline;
color: var(--text-color);
cursor: pointer;
text-decoration: none;
transition: color 0.2s, text-decoration-color 0.2s;
}
.action-btn.delete:hover {
color: #d9534f;
text-decoration: underline;
text-underline-offset: 4px;
}
.empty-state {
padding: 50px 20px;
text-align: center;
color: var(--text-muted, rgba(0,0,0,0.6));
}
</style>