feat: 实现文章管理功能,支持发布、编辑与删除
This commit is contained in:
1 parent
b3456b8916
commit
b4b2907d77
6 files changed
+1100
No files matched your search
@@ -0,0 +1,74 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ platform }) => {
|
||||
const db = platform?.env?.DB;
|
||||
|
||||
if (!db) {
|
||||
return {
|
||||
posts: []
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
let results;
|
||||
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
|
||||
FROM posts p
|
||||
LEFT JOIN users u ON p.author_id = u.id
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
ORDER BY p.published_at DESC`
|
||||
).all();
|
||||
results = queryRes.results;
|
||||
} catch (e) {
|
||||
// Fallback query if categories table or category_id column is missing
|
||||
const queryRes = await db.prepare(
|
||||
`SELECT p.id, p.title, p.slug, p.published_at as date, u.username as author
|
||||
FROM posts p
|
||||
LEFT JOIN users u ON p.author_id = u.id
|
||||
ORDER BY p.published_at DESC`
|
||||
).all();
|
||||
results = queryRes.results.map(r => ({ ...r, category: '未分类' }));
|
||||
}
|
||||
|
||||
// Format date for display
|
||||
const formattedPosts = results.map((post) => {
|
||||
const d = new Date(post.date as string);
|
||||
const year = d.getFullYear();
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
|
||||
return {
|
||||
...post,
|
||||
dateStr: `${year}-${month}-${day}`
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
posts: formattedPosts
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Error fetching admin posts list:', err);
|
||||
return { posts: [] };
|
||||
}
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
delete: async ({ request, platform }) => {
|
||||
const db = platform?.env?.DB;
|
||||
if (!db) return { success: false, error: 'Database not found' };
|
||||
|
||||
const data = await request.formData();
|
||||
const id = data.get('id');
|
||||
|
||||
if (!id) return { success: false, error: 'Missing ID' };
|
||||
|
||||
try {
|
||||
await db.prepare('DELETE FROM posts WHERE id = ?').bind(id).run();
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
console.error('Error deleting post:', err);
|
||||
return { success: false, error: 'Failed to delete post' };
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,194 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
let { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<div class="admin-page-header">
|
||||
<h2>文章管理</h2>
|
||||
<a href="/admin/posts/new" class="btn btn-primary">发布新文章</a>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
{#if data.posts.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>还没有发布任何文章,现在就开始写作吧!</p>
|
||||
</div>
|
||||
{:else}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>标题</th>
|
||||
<th>分类</th>
|
||||
<th>别名 (Slug)</th>
|
||||
<th>作者</th>
|
||||
<th>发布时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.posts as post}
|
||||
<tr>
|
||||
<td>{post.id}</td>
|
||||
<td>
|
||||
<strong>{post.title}</strong>
|
||||
<a href="/posts/{post.slug}" target="_blank" class="preview-link" title="前台预览">
|
||||
<svg width="12" height="12" 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.slug}</span></td>
|
||||
<td>{post.author || '未知'}</td>
|
||||
<td>{post.dateStr}</td>
|
||||
<td class="actions">
|
||||
<a href="/admin/posts/{post.id}" class="action-btn edit">编辑</a>
|
||||
<form action="?/delete" method="POST" class="inline-form" on:submit|preventDefault={(e) => {
|
||||
if(confirm('确定要删除这篇文章吗?操作不可恢复。')) e.currentTarget.submit();
|
||||
}}>
|
||||
<input type="hidden" name="id" value={post.id} />
|
||||
<button type="submit" class="action-btn delete">删除</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.admin-page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
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);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 8px 18px;
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--text-color);
|
||||
background-color: transparent;
|
||||
color: var(--text-color);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.preview-link {
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
margin-left: 6px;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.preview-link:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.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.edit {
|
||||
margin-right: 14px;
|
||||
}
|
||||
|
||||
.action-btn.edit:hover {
|
||||
color: var(--accent-color, #bd6262);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -0,0 +1,85 @@
|
||||
import { error, fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, platform }) => {
|
||||
const db = platform?.env?.DB;
|
||||
const postId = params.id;
|
||||
|
||||
if (!db) {
|
||||
throw error(500, '数据库未连接');
|
||||
}
|
||||
|
||||
try {
|
||||
const post = await db.prepare(
|
||||
'SELECT id, title, slug, excerpt, content, category_id FROM posts WHERE id = ?'
|
||||
).bind(postId).first<{ id: number; title: string; slug: string; excerpt: string; content: string; category_id: number | null }>();
|
||||
|
||||
if (!post) {
|
||||
throw error(404, '文章不存在');
|
||||
}
|
||||
|
||||
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: 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);
|
||||
|
||||
return {
|
||||
post,
|
||||
tags: allTags as unknown as { id: number; name: string }[],
|
||||
categories: allCategories as unknown as { id: number; name: string }[],
|
||||
selectedTagIds
|
||||
};
|
||||
} catch (err: any) {
|
||||
if (err?.status === 404) throw err;
|
||||
console.error('Error loading post for edit:', err);
|
||||
throw error(500, '读取文章详情失败');
|
||||
}
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
update: async ({ params, request, platform }) => {
|
||||
const db = platform?.env?.DB;
|
||||
const postId = params.id;
|
||||
|
||||
if (!db) return fail(500, { error: '数据库未连接' });
|
||||
|
||||
const formData = await request.formData();
|
||||
const title = formData.get('title')?.toString().trim();
|
||||
const slug = formData.get('slug')?.toString().trim();
|
||||
const excerpt = formData.get('excerpt')?.toString().trim() || '';
|
||||
const content = formData.get('content')?.toString() || '';
|
||||
const categoryId = formData.get('category_id') ? Number(formData.get('category_id')) : null;
|
||||
const selectedTags = formData.getAll('tags').map(t => Number(t));
|
||||
|
||||
if (!title || !slug || !content) {
|
||||
return fail(400, { error: '标题、URL 别名和文章正文为必填项' });
|
||||
}
|
||||
|
||||
try {
|
||||
// 更新文章表
|
||||
await db.prepare(
|
||||
`UPDATE posts
|
||||
SET title = ?, slug = ?, excerpt = ?, content = ?, category_id = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`
|
||||
).bind(title, slug, excerpt, content, categoryId, postId).run();
|
||||
|
||||
// 清除原有标签并重新绑定
|
||||
await db.prepare('DELETE FROM post_tags WHERE post_id = ?').bind(postId).run();
|
||||
|
||||
if (selectedTags.length > 0) {
|
||||
for (const tagId of selectedTags) {
|
||||
await db.prepare('INSERT INTO post_tags (post_id, tag_id) VALUES (?, ?)').bind(postId, tagId).run();
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error updating post:', err);
|
||||
if (err?.message?.includes('UNIQUE constraint failed')) {
|
||||
return fail(400, { error: 'URL 别名 (Slug) 已被其他文章占用' });
|
||||
}
|
||||
return fail(500, { error: '保存修改失败' });
|
||||
}
|
||||
|
||||
throw redirect(303, '/admin/posts');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,350 @@
|
||||
<script lang="ts">
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let title = $state(data.post.title);
|
||||
let slug = $state(data.post.slug);
|
||||
let excerpt = $state(data.post.excerpt || '');
|
||||
let content = $state(data.post.content || '');
|
||||
let selectedTags = $state<number[]>(data.selectedTagIds || []);
|
||||
|
||||
function toggleTag(tagId: number) {
|
||||
if (selectedTags.includes(tagId)) {
|
||||
selectedTags = selectedTags.filter(id => id !== tagId);
|
||||
} else {
|
||||
selectedTags = [...selectedTags, tagId];
|
||||
}
|
||||
}
|
||||
|
||||
function handleTitleInput(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
title = target.value;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="admin-page-header">
|
||||
<h2>编辑</h2>
|
||||
<a href="/admin/posts" class="btn btn-secondary">← 返回</a>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="error-alert">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if form?.success}
|
||||
<div class="success-alert">
|
||||
文章更新成功!
|
||||
<a href="/{data.post.slug}" target="_blank" class="preview-link">预览文章 ↗</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form method="POST" action="?/update" class="post-form">
|
||||
<div class="form-grid">
|
||||
<div class="form-main">
|
||||
<div class="form-group inline-group">
|
||||
<label for="title">标题 *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
bind:value={title}
|
||||
oninput={handleTitleInput}
|
||||
placeholder="输入标题..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="excerpt">摘要</label>
|
||||
<textarea
|
||||
id="excerpt"
|
||||
name="excerpt"
|
||||
bind:value={excerpt}
|
||||
rows="3"
|
||||
placeholder="简短介绍..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="content">正文 *</label>
|
||||
<textarea
|
||||
id="content"
|
||||
name="content"
|
||||
bind:value={content}
|
||||
rows="16"
|
||||
placeholder="使用 Markdown 撰写..."
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-sidebar">
|
||||
<div class="sidebar-box">
|
||||
<h3>保存</h3>
|
||||
<button type="submit" class="btn btn-primary submit-btn">保存</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-box">
|
||||
<h3>别名 *</h3>
|
||||
<input
|
||||
type="text"
|
||||
id="slug"
|
||||
name="slug"
|
||||
class="select-input"
|
||||
bind:value={slug}
|
||||
placeholder="hello-world"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-box">
|
||||
<h3>分类</h3>
|
||||
<select name="category_id" class="select-input">
|
||||
<option value="" selected={!data.post.category_id}>-- 无 --</option>
|
||||
{#each data.categories as category}
|
||||
<option value={category.id} selected={data.post.category_id === category.id}>{category.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if data.tags.length > 0}
|
||||
<div class="sidebar-box">
|
||||
<h3>标签</h3>
|
||||
<div class="tags-list">
|
||||
{#each data.tags as tag}
|
||||
<label class="tag-item">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="tags"
|
||||
value={tag.id}
|
||||
checked={selectedTags.includes(tag.id)}
|
||||
onchange={() => toggleTag(tag.id)}
|
||||
/>
|
||||
<span>{tag.name}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.admin-page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
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);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
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);
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--accent-color, #bd6262);
|
||||
border-color: var(--accent-color, #bd6262);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
border-color: var(--accent-color, #bd6262);
|
||||
color: var(--accent-color, #bd6262);
|
||||
}
|
||||
|
||||
.error-alert {
|
||||
padding: 12px 16px;
|
||||
background-color: transparent;
|
||||
border-left: 4px solid #d9534f;
|
||||
color: #d9534f;
|
||||
margin-bottom: 30px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-main {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.form-sidebar {
|
||||
position: fixed;
|
||||
top: 120px;
|
||||
right: calc((100vw - 1024px) / 2 - 280px);
|
||||
width: 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@media (max-width: 1580px) {
|
||||
.form-sidebar {
|
||||
right: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.form-sidebar {
|
||||
position: static;
|
||||
width: 100%;
|
||||
margin-top: 40px;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.form-group.inline-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.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.inline-group label {
|
||||
margin-bottom: 0;
|
||||
width: 55px; /* Fixed width for alignment */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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,
|
||||
.select-input:focus {
|
||||
outline: none;
|
||||
border-bottom-color: var(--accent-color, #bd6262);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: 0;
|
||||
background: rgba(0, 0, 0, 0.015);
|
||||
font-family: inherit;
|
||||
font-size: 1.05rem;
|
||||
color: var(--text-color);
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
resize: vertical;
|
||||
line-height: 1.8;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.select-input {
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
border: none;
|
||||
border-bottom: 2px solid rgba(0, 0, 0, 0.1);
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
color: var(--text-color);
|
||||
box-sizing: border-box;
|
||||
transition: border-bottom-color 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color, #bd6262);
|
||||
box-shadow: 0 0 0 3px rgba(189, 98, 98, 0.08);
|
||||
}
|
||||
|
||||
.sidebar-box {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar-box 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;
|
||||
}
|
||||
|
||||
.tags-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tag-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tag-item input[type="checkbox"] {
|
||||
accent-color: var(--accent-color, #bd6262);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ platform }) => {
|
||||
const db = platform?.env?.DB;
|
||||
let tags: { id: number; name: string }[] = [];
|
||||
let categories: { id: number; name: 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();
|
||||
|
||||
tags = tagResults as unknown as { id: number; name: string }[];
|
||||
categories = catResults as unknown as { id: number; name: string }[];
|
||||
} catch (err) {
|
||||
console.error('Error fetching tags or categories:', err);
|
||||
}
|
||||
}
|
||||
return { tags, categories };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ request, platform }) => {
|
||||
const db = platform?.env?.DB;
|
||||
if (!db) return fail(500, { error: '数据库未连接' });
|
||||
|
||||
const formData = await request.formData();
|
||||
const title = formData.get('title')?.toString().trim();
|
||||
const slug = formData.get('slug')?.toString().trim();
|
||||
const excerpt = formData.get('excerpt')?.toString().trim() || '';
|
||||
const content = formData.get('content')?.toString() || '';
|
||||
const categoryId = formData.get('category_id') ? Number(formData.get('category_id')) : null;
|
||||
const selectedTags = formData.getAll('tags').map(t => Number(t));
|
||||
|
||||
if (!title || !slug || !content) {
|
||||
return fail(400, { error: '标题、URL 别名和文章正文为必填项' });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await db.prepare(
|
||||
`INSERT INTO posts (title, slug, excerpt, content, author_id, category_id, published_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 1, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
RETURNING id`
|
||||
).bind(title, slug, excerpt, content, categoryId).first<{ id: number }>();
|
||||
|
||||
const postId = res?.id;
|
||||
|
||||
if (postId && selectedTags.length > 0) {
|
||||
for (const tagId of selectedTags) {
|
||||
await db.prepare('INSERT INTO post_tags (post_id, tag_id) VALUES (?, ?)').bind(postId, tagId).run();
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Error creating post:', err);
|
||||
if (err?.message?.includes('UNIQUE constraint failed')) {
|
||||
return fail(400, { error: 'URL 别名 (Slug) 已被占用,请使用其他别名' });
|
||||
}
|
||||
return fail(500, { error: '保存文章失败' });
|
||||
}
|
||||
|
||||
throw redirect(303, '/admin/posts');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,333 @@
|
||||
<script lang="ts">
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let title = $state('');
|
||||
let slug = $state('');
|
||||
let excerpt = $state('');
|
||||
let content = $state('');
|
||||
|
||||
// 自动从标题生成 Slug 简易逻辑
|
||||
function handleTitleInput() {
|
||||
if (!slug) {
|
||||
slug = title
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\u4e00-\u9fa5\s-]/g, '')
|
||||
.replace(/\s+/g, '-');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="admin-page-header">
|
||||
<h2>新文章</h2>
|
||||
<a href="/admin/posts" class="btn btn-secondary">← 返回</a>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<div class="error-alert">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form method="POST" class="post-form">
|
||||
<div class="form-grid">
|
||||
<div class="form-main">
|
||||
<div class="form-group inline-group">
|
||||
<label for="title">标题 *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
bind:value={title}
|
||||
oninput={handleTitleInput}
|
||||
placeholder="输入标题..."
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="excerpt">摘要</label>
|
||||
<textarea
|
||||
id="excerpt"
|
||||
name="excerpt"
|
||||
bind:value={excerpt}
|
||||
rows="3"
|
||||
placeholder="简短介绍..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="content">正文 *</label>
|
||||
<textarea
|
||||
id="content"
|
||||
name="content"
|
||||
bind:value={content}
|
||||
rows="16"
|
||||
placeholder="使用 Markdown 撰写..."
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-sidebar">
|
||||
<div class="sidebar-box">
|
||||
<h3>发布</h3>
|
||||
<button type="submit" class="btn btn-primary submit-btn">发布</button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-box">
|
||||
<h3>别名 *</h3>
|
||||
<input
|
||||
type="text"
|
||||
id="slug"
|
||||
name="slug"
|
||||
class="select-input"
|
||||
bind:value={slug}
|
||||
placeholder="hello-world"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-box">
|
||||
<h3>分类</h3>
|
||||
<select name="category_id" class="select-input">
|
||||
<option value="">-- 无 --</option>
|
||||
{#each data.categories as category}
|
||||
<option value={category.id}>{category.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if data.tags.length > 0}
|
||||
<div class="sidebar-box">
|
||||
<h3>标签</h3>
|
||||
<div class="tags-list">
|
||||
{#each data.tags as tag}
|
||||
<label class="tag-item">
|
||||
<input type="checkbox" name="tags" value={tag.id} />
|
||||
<span>{tag.name}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.admin-page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
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);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
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);
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--accent-color, #bd6262);
|
||||
border-color: var(--accent-color, #bd6262);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
border-color: var(--accent-color, #bd6262);
|
||||
color: var(--accent-color, #bd6262);
|
||||
}
|
||||
|
||||
.error-alert {
|
||||
padding: 12px 16px;
|
||||
background-color: transparent;
|
||||
border-left: 4px solid #d9534f;
|
||||
color: #d9534f;
|
||||
margin-bottom: 30px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.form-main {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.form-sidebar {
|
||||
position: fixed;
|
||||
top: 120px;
|
||||
right: calc((100vw - 1024px) / 2 - 280px);
|
||||
width: 260px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@media (max-width: 1580px) {
|
||||
.form-sidebar {
|
||||
right: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.form-sidebar {
|
||||
position: static;
|
||||
width: 100%;
|
||||
margin-top: 40px;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.form-group.inline-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.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.inline-group label {
|
||||
margin-bottom: 0;
|
||||
width: 55px; /* Fixed width for alignment */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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,
|
||||
.select-input:focus {
|
||||
outline: none;
|
||||
border-bottom-color: var(--accent-color, #bd6262);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: 0;
|
||||
background: rgba(0, 0, 0, 0.015);
|
||||
font-family: inherit;
|
||||
font-size: 1.05rem;
|
||||
color: var(--text-color);
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
resize: vertical;
|
||||
line-height: 1.8;
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.select-input {
|
||||
width: 100%;
|
||||
padding: 10px 0;
|
||||
border: none;
|
||||
border-bottom: 2px solid rgba(0, 0, 0, 0.1);
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: 1rem;
|
||||
color: var(--text-color);
|
||||
box-sizing: border-box;
|
||||
transition: border-bottom-color 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-color, #bd6262);
|
||||
box-shadow: 0 0 0 3px rgba(189, 98, 98, 0.08);
|
||||
}
|
||||
|
||||
.sidebar-box {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sidebar-box 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;
|
||||
}
|
||||
|
||||
.tags-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tag-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tag-item input[type="checkbox"] {
|
||||
accent-color: var(--accent-color, #bd6262);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user