feat: 完善文章、分类与标签的增删改查及批量处理功能

This commit is contained in:
seahi committed 2026-07-29 10:49:02 +08:00
1 parent 5bb5e56b89
commit 9de389a1aa
10 files changed
+1138 -318

No files matched your search

@@ -1,3 +1,6 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
async function ensureCategoriesTable(db: any) { async function ensureCategoriesTable(db: any) {
try { try {
await db.prepare(` await db.prepare(`
+11 -10
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores';
import type { PageData, ActionData } from './$types'; import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
@@ -14,7 +15,7 @@
</script> </script>
<div class="admin-page-header"> <div class="admin-page-header">
<h2>分类管理</h2> <h2>{$page.data.aliases?.category || '分类'}管理</h2>
</div> </div>
{#if form?.error} {#if form?.error}
@@ -24,9 +25,9 @@
{/if} {/if}
<div class="categories-grid-layout"> <div class="categories-grid-layout">
<!-- 左侧:新建分类卡片 --> <!-- 左侧:新建{$page.data.aliases?.category || '分类'}卡片 -->
<div class="create-category-card"> <div class="create-category-card">
<h3>新分类</h3> <h3>新{$page.data.aliases?.category || '分类'}</h3>
<form method="POST" action="?/create"> <form method="POST" action="?/create">
<div class="form-group"> <div class="form-group">
<label for="name">名称 *</label> <label for="name">名称 *</label>
@@ -54,18 +55,18 @@
</form> </form>
</div> </div>
<!-- 右侧:分类列表 --> <!-- 右侧:{$page.data.aliases?.category || '分类'}列表 -->
<div class="table-container"> <div class="table-container">
{#if data.categories.length === 0} {#if data.categories.length === 0}
<div class="empty-state"> <div class="empty-state">
<p>暂无任何分类,在左侧添加一个吧!</p> <p>暂无任何{$page.data.aliases?.category || '分类'},在左侧添加一个吧!</p>
</div> </div>
{:else} {:else}
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>分类名称</th> <th>{$page.data.aliases?.category || '分类'}名称</th>
<th>别名 (Slug)</th> <th>别名 (Slug)</th>
<th>关联文章数</th> <th>关联文章数</th>
<th>操作</th> <th>操作</th>
@@ -80,7 +81,7 @@
<td><span class="badge">{category.post_count} 篇文章</span></td> <td><span class="badge">{category.post_count} 篇文章</span></td>
<td> <td>
<form action="?/delete" method="POST" class="inline-form" onsubmit={(e) => { <form action="?/delete" method="POST" class="inline-form" onsubmit={(e) => {
if(!confirm(`确定要删除分类 "${category.name}" 吗?删除后相关文章将归为未分类。`)) e.preventDefault(); if(!confirm(`确定要删除${$page.data.aliases?.category || '分类'} "${category.name}" 吗?删除后相关文章将归为未${$page.data.aliases?.category || '分类'}。`)) e.preventDefault();
}}> }}>
<input type="hidden" name="id" value={category.id} /> <input type="hidden" name="id" value={category.id} />
<button type="submit" class="action-btn delete">删除</button> <button type="submit" class="action-btn delete">删除</button>
@@ -173,7 +174,7 @@
.form-group input[type="text"]:focus { .form-group input[type="text"]:focus {
outline: none; outline: none;
border-bottom-color: var(--accent-color, #bd6262); border-bottom-color: var(--accent-color, #419b38);
} }
.btn { .btn {
@@ -199,8 +200,8 @@
} }
.btn-primary:hover { .btn-primary:hover {
background-color: var(--accent-color, #bd6262); background-color: var(--accent-color, #419b38);
border-color: var(--accent-color, #bd6262); border-color: var(--accent-color, #419b38);
color: white; color: white;
} }
+15 -6
View File
@@ -1,5 +1,13 @@
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
interface PostListRow {
id: number;
title: string;
slug: string;
date: string;
author: string | null;
category?: string;
}
export const load: PageServerLoad = async ({ platform }) => { export const load: PageServerLoad = async ({ platform }) => {
const db = platform?.env?.DB; const db = platform?.env?.DB;
@@ -10,7 +18,7 @@ export const load: PageServerLoad = async ({ platform }) => {
} }
try { try {
let results; 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
@@ -18,7 +26,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
LEFT JOIN categories c ON p.category_id = c.id LEFT JOIN categories c ON p.category_id = c.id
ORDER BY p.published_at DESC` ORDER BY p.published_at DESC`
).all(); ).all<PostListRow>();
results = queryRes.results; results = queryRes.results;
} catch (e) { } catch (e) {
// Fallback query if categories table or category_id column is missing // Fallback query if categories table or category_id column is missing
@@ -27,7 +35,7 @@ export const load: PageServerLoad = async ({ platform }) => {
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
ORDER BY p.published_at DESC` ORDER BY p.published_at DESC`
).all(); ).all<PostListRow>();
results = queryRes.results.map(r => ({ ...r, category: '未分类' })); results = queryRes.results.map(r => ({ ...r, category: '未分类' }));
} }
@@ -59,12 +67,13 @@ export const actions = {
if (!db) return { success: false, error: 'Database not found' }; if (!db) return { success: false, error: 'Database not found' };
const data = await request.formData(); const data = await request.formData();
const id = data.get('id'); const ids = data.getAll('ids');
if (!id) return { success: false, error: 'Missing ID' }; if (!ids || ids.length === 0) return { success: false, error: 'Missing IDs' };
try { try {
await db.prepare('DELETE FROM posts WHERE id = ?').bind(id).run(); const placeholders = ids.map(() => '?').join(',');
await db.prepare(`DELETE FROM posts WHERE id IN (${placeholders})`).bind(...ids).run();
return { success: true }; return { success: true };
} catch (err) { } catch (err) {
console.error('Error deleting post:', err); console.error('Error deleting post:', err);
+116 -61
View File
@@ -1,54 +1,87 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores';
import type { PageData } from './$types'; import type { PageData } from './$types';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
let selectedIds = $state<string[]>([]);
function toggleAll(e: Event) {
const target = e.target as HTMLInputElement;
if (target.checked) {
selectedIds = data.posts.map(p => p.id.toString());
} else {
selectedIds = [];
}
}
let isAllSelected = $derived(data.posts.length > 0 && selectedIds.length === data.posts.length);
</script> </script>
<div class="admin-page-header"> <div class="admin-page-header">
<h2>文章管理</h2> <h2>{$page.data.aliases?.post || '文章'}管理</h2>
<a href="/admin/posts/new" class="btn btn-primary">发布新文章</a> <a href="/admin/posts/new" class="btn btn-primary">发布新{$page.data.aliases?.post || '文章'}</a>
</div> </div>
{#if data.posts.length > 0}
<div class="batch-actions">
{#if selectedIds.length > 0}
<span class="selected-count">已选择 {selectedIds.length} 项</span>
<form action="?/delete" method="POST" class="inline-form" onsubmit={(e) => {
if(!confirm(`确定要删除选中的 ${selectedIds.length} 篇${$page.data.aliases?.post || '项目'}吗?操作不可恢复。`)) {
e.preventDefault();
}
}}>
{#each selectedIds as id}
<input type="hidden" name="ids" value={id} />
{/each}
<button type="submit" class="btn btn-danger btn-sm">批量删除</button>
</form>
{:else}
<span class="selected-count empty">未选择项目</span>
{/if}
</div>
{/if}
<div class="table-container"> <div class="table-container">
{#if data.posts.length === 0} {#if data.posts.length === 0}
<div class="empty-state"> <div class="empty-state">
<p>还没有发布任何文章,现在就开始写作吧!</p> <p>还没有发布任何{$page.data.aliases?.post || '文章'},现在就开始写作吧!</p>
</div> </div>
{:else} {:else}
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th class="checkbox-col">
<input type="checkbox" checked={isAllSelected} onchange={toggleAll} />
</th>
<th>ID</th> <th>ID</th>
<th>标题</th> <th>标题</th>
<th>分类</th> <th>分类</th>
<th>别名 (Slug)</th> <th>别名 (Slug)</th>
<th>作者</th> <th>作者</th>
<th>发布时间</th> <th>发布时间</th>
<th>操作</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each data.posts as post} {#each data.posts as post}
<tr> <tr>
<td class="checkbox-col">
<input type="checkbox" name="ids" value={post.id.toString()} bind:group={selectedIds} />
</td>
<td>{post.id}</td> <td>{post.id}</td>
<td> <td>
<strong>{post.title}</strong> <a href="/admin/posts/{post.id}" class="title-link"><strong>{post.title}</strong></a>
<a href="/posts/{post.slug}" target="_blank" class="preview-link" title="前台预览"> <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> <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> </a>
</td> </td>
<td><span class="badge">{post.category || '未分类'}</span></td> <td><span class="badge">{post.category || '未分类'}</span></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>
<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> </tr>
{/each} {/each}
</tbody> </tbody>
@@ -92,8 +125,8 @@
} }
.btn-primary:hover { .btn-primary:hover {
background-color: var(--accent-color, #bd6262); background-color: var(--accent-color, #419b38);
border-color: var(--accent-color, #bd6262); border-color: var(--accent-color, #419b38);
color: white; color: white;
} }
@@ -110,7 +143,7 @@
} }
.data-table th, .data-table td { .data-table th, .data-table td {
padding: 14px 18px; padding: 10px 16px;
border-bottom: 1px solid var(--border-color, rgba(0,0,0,0.08)); border-bottom: 1px solid var(--border-color, rgba(0,0,0,0.08));
} }
@@ -132,63 +165,85 @@
.preview-link { .preview-link {
text-decoration: none; text-decoration: none;
font-size: 0.85rem;
margin-left: 6px; margin-left: 6px;
opacity: 0.5; opacity: 0.5;
transition: opacity 0.2s; transition: opacity 0.2s;
display: inline-block;
vertical-align: middle;
} }
.preview-link:hover { .preview-link:hover {
opacity: 1; 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 { .empty-state {
padding: 50px 20px; padding: 50px 20px;
text-align: center; text-align: center;
color: var(--text-muted, rgba(0,0,0,0.6)); color: var(--text-muted, rgba(0,0,0,0.6));
} }
.batch-actions {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 15px;
padding: 10px 15px;
background: rgba(0,0,0,0.02);
height: 54px;
box-sizing: border-box;
}
.selected-count {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-color);
}
.selected-count.empty {
color: var(--text-muted, rgba(0,0,0,0.4));
font-weight: normal;
}
.checkbox-col {
width: 40px;
text-align: center;
}
.checkbox-col input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent-color, #419b38);
cursor: pointer;
}
.title-link {
color: var(--text-color);
text-decoration: none;
transition: color 0.2s;
}
.title-link:hover {
color: var(--accent-color, #419b38);
text-decoration: underline;
text-underline-offset: 4px;
}
.btn-danger {
background-color: transparent;
color: #d9534f;
border-color: #d9534f;
}
.btn-danger:hover {
background-color: #d9534f;
color: white;
}
.btn-sm {
padding: 4px 12px;
font-size: 0.85rem;
}
</style> </style>
+5 -6
View File
@@ -11,8 +11,8 @@ export const load: PageServerLoad = async ({ params, platform }) => {
try { try {
const post = await db.prepare( const post = await db.prepare(
'SELECT id, title, slug, excerpt, content, category_id FROM posts WHERE id = ?' 'SELECT id, title, slug, content, category_id FROM posts WHERE id = ?'
).bind(postId).first<{ id: number; title: string; slug: string; excerpt: string; content: string; category_id: number | null }>(); ).bind(postId).first<{ id: number; title: string; slug: string; content: string; category_id: number | null }>();
if (!post) { if (!post) {
throw error(404, '文章不存在'); throw error(404, '文章不存在');
@@ -46,8 +46,7 @@ export const actions: Actions = {
const formData = await request.formData(); const formData = await request.formData();
const title = formData.get('title')?.toString().trim(); const title = formData.get('title')?.toString().trim();
const slug = formData.get('slug')?.toString().trim(); const slug = formData.get('slug')?.toString().trim() || '';
const excerpt = formData.get('excerpt')?.toString().trim() || '';
const content = formData.get('content')?.toString() || ''; const content = formData.get('content')?.toString() || '';
const categoryId = formData.get('category_id') ? Number(formData.get('category_id')) : null; const categoryId = formData.get('category_id') ? Number(formData.get('category_id')) : null;
const selectedTags = formData.getAll('tags').map(t => Number(t)); const selectedTags = formData.getAll('tags').map(t => Number(t));
@@ -60,9 +59,9 @@ export const actions: Actions = {
// 更新文章表 // 更新文章表
await db.prepare( await db.prepare(
`UPDATE posts `UPDATE posts
SET title = ?, slug = ?, excerpt = ?, content = ?, category_id = ?, updated_at = CURRENT_TIMESTAMP SET title = ?, slug = ?, content = ?, category_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?` WHERE id = ?`
).bind(title, slug, excerpt, content, categoryId, postId).run(); ).bind(title, slug, content, categoryId, postId).run();
// 清除原有标签并重新绑定 // 清除原有标签并重新绑定
await db.prepare('DELETE FROM post_tags WHERE post_id = ?').bind(postId).run(); await db.prepare('DELETE FROM post_tags WHERE post_id = ?').bind(postId).run();
+481 -115
View File
@@ -1,13 +1,17 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores';
import type { PageData, ActionData } from './$types'; import type { PageData, ActionData } from './$types';
import { deserialize } from '$app/forms';
import MarkdownRenderer from '$lib/components/MarkdownRenderer.svelte';
let { data, form }: { data: PageData; form: ActionData } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
let title = $state(data.post.title); let title = $state(data.post.title);
let slug = $state(data.post.slug); let slug = $state(data.post.slug);
let excerpt = $state(data.post.excerpt || '');
let content = $state(data.post.content || ''); let content = $state(data.post.content || '');
let mode = $state<'edit' | 'preview'>('edit');
let selectedTags = $state<number[]>(data.selectedTagIds || []); let selectedTags = $state<number[]>(data.selectedTagIds || []);
let tagsList = $state(data.tags || []);
function toggleTag(tagId: number) { function toggleTag(tagId: number) {
if (selectedTags.includes(tagId)) { if (selectedTags.includes(tagId)) {
@@ -21,6 +25,118 @@
const target = e.target as HTMLInputElement; const target = e.target as HTMLInputElement;
title = target.value; title = target.value;
} }
let newTagInput = $state('');
let isAddingTag = $state(false);
let showTagDropdown = $state(false);
let filteredTags = $derived(
(() => {
const input = newTagInput.trim().toLowerCase();
const available = tagsList.filter(t => !selectedTags.includes(t.id));
if (!input) return available.slice(0, 5);
return available.filter(t => t.name.toLowerCase().includes(input));
})()
);
async function handleAddTag(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault(); // 防止整个表单提交
const val = newTagInput.trim();
if (!val || isAddingTag) return;
// 检查是否已存在同名标签
const existingTag = tagsList.find((t) => t.name.toLowerCase() === val.toLowerCase());
if (existingTag) {
if (!selectedTags.includes(existingTag.id)) {
selectedTags = [...selectedTags, existingTag.id];
}
newTagInput = '';
showTagDropdown = false;
return;
}
isAddingTag = true;
try {
const formData = new FormData();
formData.append('name', val);
const res = await fetch('/admin/tags?/create', {
method: 'POST',
body: formData,
headers: {
'x-sveltekit-action': 'true'
}
});
const result = deserialize(await res.text()) as any;
if (result.type === 'success' && result.data?.tag) {
// 将新标签加入列表
tagsList = [...tagsList, result.data.tag];
// 自动选中新标签
selectedTags = [...selectedTags, result.data.tag.id];
}
} catch(e) {
console.error('Failed to create tag', e);
} finally {
isAddingTag = false;
newTagInput = '';
showTagDropdown = false;
}
}
}
// Markdown 工具栏功能
function insertAtCursor(before: string, after: string = '', defaultText: string = '') {
const textarea = document.getElementById('content') as HTMLTextAreaElement;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = textarea.value.substring(start, end);
const insertion = selectedText ? (before + selectedText + after) : (before + defaultText + after);
content = textarea.value.substring(0, start) + insertion + textarea.value.substring(end);
// 恢复焦点并选中文字
setTimeout(() => {
textarea.focus();
if (selectedText) {
textarea.setSelectionRange(start, start + insertion.length);
} else {
textarea.setSelectionRange(start + before.length, start + before.length + defaultText.length);
}
}, 0);
}
function insertMore() {
insertAtCursor('\n<!--more-->\n', '', '');
}
let showAdmonitionMenu = $state(false);
const admonitionTypes = [
{ type: 'note', title: '注意', label: '注意 (Note)' },
{ type: 'abstract', title: '摘要', label: '摘要 (Abstract)' },
{ type: 'info', title: '信息', label: '信息 (Info)' },
{ type: 'tip', title: '技巧', label: '技巧 (Tip)' },
{ type: 'success', title: '成功', label: '成功 (Success)' },
{ type: 'question', title: '问题', label: '问题 (Question)' },
{ type: 'warning', title: '警告', label: '警告 (Warning)' },
{ type: 'failure', title: '失败', label: '失败 (Failure)' },
{ type: 'danger', title: '危险', label: '危险 (Danger)' },
{ type: 'bug', title: 'Bug', label: 'Bug' },
{ type: 'example', title: '示例', label: '示例 (Example)' },
{ type: 'quote', title: '引用', label: '引用 (Quote)' }
];
function insertAdmonition(type: string, title: string) {
const openTag = `{{< admonition ${type} "${title}" >}}\n`;
const closeTag = `\n{{< /admonition >}}`;
insertAtCursor('\n' + openTag, closeTag, '在这里输入提示内容...');
showAdmonitionMenu = false;
}
</script> </script>
<div class="admin-page-header"> <div class="admin-page-header">
@@ -34,17 +150,10 @@
</div> </div>
{/if} {/if}
{#if form?.success}
<div class="success-alert">
文章更新成功!
<a href="/{data.post.slug}" target="_blank" class="preview-link">预览文章 &#8599;</a>
</div>
{/if}
<form method="POST" action="?/update" class="post-form"> <form method="POST" action="?/update" class="post-form">
<div class="form-grid"> <div class="form-grid">
<div class="form-main"> <div class="form-main">
<div class="form-group inline-group"> <div class="form-group">
<label for="title">标题 *</label> <label for="title">标题 *</label>
<input <input
type="text" type="text"
@@ -58,26 +167,66 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="excerpt">摘要</label> <div class="editor-header">
<textarea <label for="content">正文 *</label>
id="excerpt"
name="excerpt"
bind:value={excerpt}
rows="3"
placeholder="简短介绍..."
></textarea>
</div>
<div class="form-group"> <div class="editor-header-right">
<label for="content">正文 *</label> {#if mode === 'edit'}
<textarea <div class="md-toolbar">
id="content" <button type="button" class="md-btn" onclick={() => insertAtCursor('**', '**', '粗体')} title="加粗"><svg viewBox="0 0 24 24"><path d="M6 4h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path><path d="M6 12h10a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path></svg></button>
name="content" <button type="button" class="md-btn" onclick={() => insertAtCursor('*', '*', '斜体')} title="斜体"><svg viewBox="0 0 24 24"><line x1="19" y1="4" x2="10" y2="4"></line><line x1="14" y1="20" x2="5" y2="20"></line><line x1="15" y1="4" x2="9" y2="20"></line></svg></button>
bind:value={content} <button type="button" class="md-btn" onclick={() => insertAtCursor('~~', '~~', '删除线')} title="删除线"><svg viewBox="0 0 24 24"><path d="M16 4H9a3 3 0 0 0-2.83 4"></path><path d="M14 12a4 4 0 0 1 0 8H6"></path><line x1="4" y1="12" x2="20" y2="12"></line></svg></button>
rows="16" <button type="button" class="md-btn" onclick={() => insertAtCursor('### ', '', '标题')} title="标题"><svg viewBox="0 0 24 24"><path d="M4 12h16M4 18V6M20 18V6"></path></svg></button>
placeholder="使用 Markdown 撰写..." <div class="md-divider"></div>
required <button type="button" class="md-btn" onclick={() => insertAtCursor('- ', '', '列表项')} title="无序列表"><svg viewBox="0 0 24 24"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg></button>
></textarea> <button type="button" class="md-btn" onclick={() => insertAtCursor('[', '](https://)', '链接文字')} title="链接"><svg viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg></button>
<button type="button" class="md-btn" onclick={() => insertAtCursor('![', '](https://)', '图片描述')} title="图片"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><polygon points="6 21, 10 13, 13 16.5, 16.5 9.5, 19 21" style="fill: currentColor; stroke: currentColor; stroke-linejoin: round; stroke-width: 1.5;"></polygon></svg></button>
<button type="button" class="md-btn" onclick={() => insertAtCursor('```\n', '\n```', 'code')} title="代码块"><svg viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg></button>
<div class="md-divider"></div>
<div class="toolbar-dropdown-wrapper">
{#if showAdmonitionMenu}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_interactive_supports_focus -->
<div class="dropdown-overlay" onclick={() => showAdmonitionMenu = false} role="button"></div>
{/if}
<button type="button" class="md-btn" onclick={() => showAdmonitionMenu = !showAdmonitionMenu} title="提示框 (Admonition)"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg></button>
{#if showAdmonitionMenu}
<div class="toolbar-dropdown">
{#each admonitionTypes as item}
<button type="button" class="dropdown-item" onclick={() => insertAdmonition(item.type, item.title)}>
{item.label}
</button>
{/each}
</div>
{/if}
</div>
<button type="button" class="md-btn" onclick={insertMore} title="插入摘要分割线"><svg viewBox="0 0 24 24"><polyline points="5 5 5 9 19 9 19 5"></polyline><polyline points="5 19 5 15 19 15 19 19"></polyline><line x1="4" y1="12" x2="20" y2="12" stroke-dasharray="3 3" stroke-linecap="butt"></line></svg></button>
</div>
{/if}
<div class="editor-tabs">
<button type="button" class="tab-btn {mode === 'edit' ? 'active' : ''}" onclick={() => mode = 'edit'}>编辑</button>
<button type="button" class="tab-btn {mode === 'preview' ? 'active' : ''}" onclick={() => mode = 'preview'}>预览</button>
</div>
</div>
</div>
<div class="editor-body">
{#if mode === 'edit'}
<textarea
id="content"
name="content"
bind:value={content}
rows="16"
style="min-height: 400px;"
placeholder="支持 Markdown 格式..."
required
></textarea>
{:else}
<div class="preview-area">
<MarkdownRenderer {content} />
</div>
{/if}
</div>
</div> </div>
</div> </div>
@@ -93,7 +242,7 @@
type="text" type="text"
id="slug" id="slug"
name="slug" name="slug"
class="select-input" class="sidebar-input"
bind:value={slug} bind:value={slug}
placeholder="hello-world" placeholder="hello-world"
required required
@@ -110,25 +259,51 @@
</select> </select>
</div> </div>
{#if data.tags.length > 0} <div class="sidebar-box">
<div class="sidebar-box"> <h3>标签</h3>
<h3>标签</h3> <div class="selected-tags-vertical">
<div class="tags-list"> {#each selectedTags as tagId}
{#each data.tags as tag} {#if tagsList.find(t => t.id === tagId)}
<label class="tag-item"> {@const tag = tagsList.find(t => t.id === tagId)}
<input <div class="selected-tag-row">
type="checkbox" <span>{tag?.name}</span>
name="tags" <button type="button" class="remove-tag-btn" onclick={() => toggleTag(tagId)} title="移除标签"><svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
value={tag.id} <input type="hidden" name="tags" value={tagId} />
checked={selectedTags.includes(tag.id)} </div>
onchange={() => toggleTag(tag.id)} {/if}
/> {/each}
<span>{tag.name}</span>
</label>
{/each}
</div>
</div> </div>
{/if}
<div class="tag-input-container">
<input
type="text"
class="sidebar-input tag-input-new"
placeholder="+ 添加标签..."
bind:value={newTagInput}
onkeydown={handleAddTag}
onfocus={() => showTagDropdown = true}
onblur={() => setTimeout(() => showTagDropdown = false, 200)}
disabled={isAddingTag}
autocomplete="off"
/>
{#if showTagDropdown && filteredTags.length > 0}
<div class="tag-autocomplete-dropdown">
{#each filteredTags as tag}
<button type="button" class="tag-dropdown-item" onclick={() => {
if (!selectedTags.includes(tag.id)) {
selectedTags = [...selectedTags, tag.id];
}
newTagInput = '';
showTagDropdown = false;
}}>
{tag.name}
</button>
{/each}
</div>
{/if}
</div>
</div>
</div> </div>
</div> </div>
</form> </form>
@@ -173,14 +348,14 @@
} }
.btn-primary:hover { .btn-primary:hover {
background-color: var(--accent-color, #bd6262); background-color: var(--accent-color, #419b38);
border-color: var(--accent-color, #bd6262); border-color: var(--accent-color, #419b38);
color: white; color: white;
} }
.btn-secondary:hover { .btn-secondary:hover {
border-color: var(--accent-color, #bd6262); border-color: var(--accent-color, #419b38);
color: var(--accent-color, #bd6262); color: var(--accent-color, #419b38);
} }
.error-alert { .error-alert {
@@ -192,90 +367,105 @@
font-weight: 600; font-weight: 600;
} }
.form-grid { .editor-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.editor-tabs {
display: flex;
gap: 8px;
}
.tab-btn {
background: none;
border: none;
padding: 4px 12px;
font-size: 0.85rem;
font-weight: 600;
color: rgba(0, 0, 0, 0.4);
cursor: pointer;
border-radius: 4px;
transition: all 0.2s ease;
}
.tab-btn:hover {
background: rgba(0, 0, 0, 0.05);
}
.tab-btn.active {
color: var(--accent-color, #419b38);
background: rgba(65, 155, 56, 0.1);
}
.preview-area {
padding: 20px;
border: 1px solid rgba(0, 0, 0, 0.12);
background: rgba(0, 0, 0, 0.015);
min-height: 400px;
max-height: 600px;
overflow-y: auto;
}
.editor-container {
display: block; display: block;
} }
.form-grid {
display: grid;
grid-template-columns: 1fr 280px;
gap: 40px;
align-items: start;
}
.form-main { .form-main {
max-width: 800px; width: 100%;
margin: 0 auto;
} }
.form-sidebar { .form-sidebar {
position: fixed; position: sticky;
top: 120px; top: 80px;
right: calc((100vw - 1024px) / 2 - 280px); width: 100%;
width: 260px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 30px; gap: 30px;
z-index: 10; z-index: 10;
} }
@media (max-width: 1580px) { @media (max-width: 900px) {
.form-sidebar { .form-grid {
right: 20px; grid-template-columns: 1fr;
} }
}
@media (max-width: 1200px) {
.form-sidebar { .form-sidebar {
position: static; position: static;
width: 100%;
margin-top: 40px;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
gap: 40px; gap: 20px;
margin-top: 20px;
} }
} }
.form-group { .form-group {
margin-bottom: 30px; margin-bottom: 20px;
}
.form-group.inline-group {
display: flex;
align-items: center;
gap: 20px;
} }
.form-group label { .form-group label {
display: block; display: block;
font-weight: 700; font-weight: 700;
margin-bottom: 12px; margin-bottom: 6px;
color: var(--text-color); color: var(--text-color);
font-size: 0.9rem; font-size: 0.9rem;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.form-group.inline-group label { .form-group input[type="text"],
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 { .form-group textarea {
width: 100%; width: 100%;
padding: 16px; padding: 12px 14px;
border: 1px solid rgba(0, 0, 0, 0.12); border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 0; border-radius: 0;
background: rgba(0, 0, 0, 0.015); background: rgba(0, 0, 0, 0.015);
@@ -284,12 +474,29 @@
color: var(--text-color); color: var(--text-color);
box-sizing: border-box; box-sizing: border-box;
transition: border-color 0.3s ease, box-shadow 0.3s ease; transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.form-group textarea {
resize: vertical; resize: vertical;
line-height: 1.8; line-height: 1.8;
min-height: 120px; min-height: 120px;
} }
.select-input { .form-group input[type="text"]:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent-color, #419b38);
box-shadow: 0 0 0 3px rgba(65, 155, 56, 0.08);
}
.select-input:focus,
.sidebar-input:focus {
outline: none;
border-bottom-color: var(--accent-color, #419b38);
}
.select-input,
.sidebar-input {
width: 100%; width: 100%;
padding: 10px 0; padding: 10px 0;
border: none; border: none;
@@ -300,13 +507,16 @@
color: var(--text-color); color: var(--text-color);
box-sizing: border-box; box-sizing: border-box;
transition: border-bottom-color 0.3s ease; transition: border-bottom-color 0.3s ease;
}
.select-input {
cursor: pointer; cursor: pointer;
} }
.form-group textarea:focus { .form-group textarea:focus {
outline: none; outline: none;
border-color: var(--accent-color, #bd6262); border-color: var(--accent-color, #419b38);
box-shadow: 0 0 0 3px rgba(189, 98, 98, 0.08); box-shadow: 0 0 0 3px rgba(65, 155, 56, 0.08);
} }
.sidebar-box { .sidebar-box {
@@ -326,25 +536,181 @@
border-bottom: none; border-bottom: none;
} }
.tags-list { .selected-tags-vertical {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 6px;
margin-bottom: 15px;
} }
.tag-item { .selected-tag-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 10px;
border: 1px solid var(--text-color);
background: transparent;
font-weight: 600;
font-size: 0.9rem;
}
.remove-tag-btn {
background: transparent;
border: none;
color: var(--text-color);
font-size: 1.1rem;
cursor: pointer;
opacity: 0.7;
padding: 0 4px;
}
.remove-tag-btn:hover {
opacity: 1;
color: #d9534f;
}
.tag-input-container {
position: relative;
}
.tag-autocomplete-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-color);
border: 1px solid var(--text-color);
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.15);
z-index: 50;
max-height: 200px;
overflow-y: auto;
display: flex;
flex-direction: column;
margin-top: 4px;
}
.tag-dropdown-item {
background: transparent;
border: none;
border-bottom: 1px solid rgba(0,0,0,0.1);
padding: 8px 12px;
text-align: left;
font-family: inherit;
font-size: 0.85rem;
color: var(--text-color);
cursor: pointer;
font-weight: 600;
transition: all 0.2s ease;
}
.tag-dropdown-item:last-child {
border-bottom: none;
}
.tag-dropdown-item:hover {
background: var(--text-color);
color: var(--bg-color);
}
.editor-header-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 15px;
cursor: pointer;
font-size: 0.95rem;
} }
.tag-item input[type="checkbox"] { .md-toolbar {
accent-color: var(--accent-color, #bd6262); display: flex;
width: 16px; align-items: center;
height: 16px; gap: 4px;
margin: 0;
cursor: pointer;
} }
.md-btn {
background: transparent;
border: none;
padding: 4px;
cursor: pointer;
color: var(--text-color);
opacity: 0.65;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 2px;
}
.md-btn:hover {
opacity: 1;
color: var(--accent-color, #419b38);
background-color: rgba(0,0,0,0.03);
}
.md-btn svg {
width: 18px;
height: 18px;
stroke: currentColor;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
fill: none;
}
.md-divider {
width: 2px;
height: 16px;
background: var(--text-color);
opacity: 0.15;
margin: 0 4px;
}
.toolbar-dropdown-wrapper {
position: relative;
display: inline-block;
}
.dropdown-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
z-index: 99;
}
.toolbar-dropdown {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
background: var(--bg-color, #ffffff);
border: 1px solid var(--text-color);
z-index: 100;
width: 140px;
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
}
.dropdown-item {
background: transparent;
border: none;
border-bottom: 1px solid rgba(0,0,0,0.1);
padding: 8px 12px;
text-align: left;
font-family: inherit;
font-size: 0.85rem;
color: var(--text-color);
cursor: pointer;
font-weight: 600;
transition: all 0.2s ease;
}
.dropdown-item:last-child {
border-bottom: none;
}
.dropdown-item:hover {
background: var(--text-color);
color: var(--bg-color, #ffffff);
}
</style> </style>
+4 -5
View File
@@ -27,8 +27,7 @@ export const actions: Actions = {
const formData = await request.formData(); const formData = await request.formData();
const title = formData.get('title')?.toString().trim(); const title = formData.get('title')?.toString().trim();
const slug = formData.get('slug')?.toString().trim(); const slug = formData.get('slug')?.toString().trim() || '';
const excerpt = formData.get('excerpt')?.toString().trim() || '';
const content = formData.get('content')?.toString() || ''; const content = formData.get('content')?.toString() || '';
const categoryId = formData.get('category_id') ? Number(formData.get('category_id')) : null; const categoryId = formData.get('category_id') ? Number(formData.get('category_id')) : null;
const selectedTags = formData.getAll('tags').map(t => Number(t)); const selectedTags = formData.getAll('tags').map(t => Number(t));
@@ -39,10 +38,10 @@ export const actions: Actions = {
try { try {
const res = await db.prepare( const res = await db.prepare(
`INSERT INTO posts (title, slug, excerpt, content, author_id, category_id, published_at, updated_at) `INSERT INTO posts (title, slug, content, author_id, category_id, published_at, updated_at)
VALUES (?, ?, ?, ?, 1, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) VALUES (?, ?, ?, 1, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id` RETURNING id`
).bind(title, slug, excerpt, content, categoryId).first<{ id: number }>(); ).bind(title, slug, content, categoryId).first<{ id: number }>();
const postId = res?.id; const postId = res?.id;
+488 -103
View File
@@ -1,12 +1,17 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores';
import type { PageData, ActionData } from './$types'; import type { PageData, ActionData } from './$types';
import { deserialize } from '$app/forms';
import MarkdownRenderer from '$lib/components/MarkdownRenderer.svelte';
let { data, form }: { data: PageData; form: ActionData } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
let title = $state(''); let title = $state('');
let slug = $state(''); let slug = $state('');
let excerpt = $state('');
let content = $state(''); let content = $state('');
let mode = $state<'edit' | 'preview'>('edit');
let tagsList = $state(data.tags || []);
// 自动从标题生成 Slug 简易逻辑 // 自动从标题生成 Slug 简易逻辑
function handleTitleInput() { function handleTitleInput() {
@@ -17,10 +22,131 @@
.replace(/\s+/g, '-'); .replace(/\s+/g, '-');
} }
} }
let selectedTags = $state<number[]>([]);
function toggleTag(tagId: number) {
if (selectedTags.includes(tagId)) {
selectedTags = selectedTags.filter(id => id !== tagId);
} else {
selectedTags = [...selectedTags, tagId];
}
}
let newTagInput = $state('');
let isAddingTag = $state(false);
let showTagDropdown = $state(false);
let filteredTags = $derived(
(() => {
const input = newTagInput.trim().toLowerCase();
const available = tagsList.filter(t => !selectedTags.includes(t.id));
if (!input) return available.slice(0, 5);
return available.filter(t => t.name.toLowerCase().includes(input));
})()
);
async function handleAddTag(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault(); // 防止整个表单提交
const val = newTagInput.trim();
if (!val || isAddingTag) return;
// 检查是否已存在同名标签
const existingTag = tagsList.find((t) => t.name.toLowerCase() === val.toLowerCase());
if (existingTag) {
if (!selectedTags.includes(existingTag.id)) {
selectedTags = [...selectedTags, existingTag.id];
}
newTagInput = '';
showTagDropdown = false;
return;
}
isAddingTag = true;
try {
const formData = new FormData();
formData.append('name', val);
const res = await fetch('/admin/tags?/create', {
method: 'POST',
body: formData,
headers: {
'x-sveltekit-action': 'true'
}
});
const result = deserialize(await res.text()) as any;
if (result.type === 'success' && result.data?.tag) {
// 将新标签加入列表
tagsList = [...tagsList, result.data.tag];
// 自动选中新标签
selectedTags = [...selectedTags, result.data.tag.id];
}
} catch(e) {
console.error('Failed to create tag', e);
} finally {
isAddingTag = false;
newTagInput = '';
showTagDropdown = false;
}
}
}
// Markdown 工具栏功能
function insertAtCursor(before: string, after: string = '', defaultText: string = '') {
const textarea = document.getElementById('content') as HTMLTextAreaElement;
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = textarea.value.substring(start, end);
const insertion = selectedText ? (before + selectedText + after) : (before + defaultText + after);
content = textarea.value.substring(0, start) + insertion + textarea.value.substring(end);
// 恢复焦点并选中文字
setTimeout(() => {
textarea.focus();
if (selectedText) {
textarea.setSelectionRange(start, start + insertion.length);
} else {
textarea.setSelectionRange(start + before.length, start + before.length + defaultText.length);
}
}, 0);
}
function insertMore() {
insertAtCursor('\n<!--more-->\n', '', '');
}
let showAdmonitionMenu = $state(false);
const admonitionTypes = [
{ type: 'note', title: '注意', label: '注意 (Note)' },
{ type: 'abstract', title: '摘要', label: '摘要 (Abstract)' },
{ type: 'info', title: '信息', label: '信息 (Info)' },
{ type: 'tip', title: '技巧', label: '技巧 (Tip)' },
{ type: 'success', title: '成功', label: '成功 (Success)' },
{ type: 'question', title: '问题', label: '问题 (Question)' },
{ type: 'warning', title: '警告', label: '警告 (Warning)' },
{ type: 'failure', title: '失败', label: '失败 (Failure)' },
{ type: 'danger', title: '危险', label: '危险 (Danger)' },
{ type: 'bug', title: 'Bug', label: 'Bug' },
{ type: 'example', title: '示例', label: '示例 (Example)' },
{ type: 'quote', title: '引用', label: '引用 (Quote)' }
];
function insertAdmonition(type: string, title: string) {
const openTag = `{{< admonition ${type} "${title}" >}}\n`;
const closeTag = `\n{{< /admonition >}}`;
insertAtCursor('\n' + openTag, closeTag, '在这里输入提示内容...');
showAdmonitionMenu = false;
}
</script> </script>
<div class="admin-page-header"> <div class="admin-page-header">
<h2>新文章</h2> <h2>新{$page.data.aliases?.post || '文章'}</h2>
<a href="/admin/posts" class="btn btn-secondary">&larr; 返回</a> <a href="/admin/posts" class="btn btn-secondary">&larr; 返回</a>
</div> </div>
@@ -33,7 +159,7 @@
<form method="POST" class="post-form"> <form method="POST" class="post-form">
<div class="form-grid"> <div class="form-grid">
<div class="form-main"> <div class="form-main">
<div class="form-group inline-group"> <div class="form-group">
<label for="title">标题 *</label> <label for="title">标题 *</label>
<input <input
type="text" type="text"
@@ -47,26 +173,66 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="excerpt">摘要</label> <div class="editor-header">
<textarea <label for="content">正文 *</label>
id="excerpt"
name="excerpt"
bind:value={excerpt}
rows="3"
placeholder="简短介绍..."
></textarea>
</div>
<div class="form-group"> <div class="editor-header-right">
<label for="content">正文 *</label> {#if mode === 'edit'}
<textarea <div class="md-toolbar">
id="content" <button type="button" class="md-btn" onclick={() => insertAtCursor('**', '**', '粗体')} title="加粗"><svg viewBox="0 0 24 24"><path d="M6 4h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path><path d="M6 12h10a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"></path></svg></button>
name="content" <button type="button" class="md-btn" onclick={() => insertAtCursor('*', '*', '斜体')} title="斜体"><svg viewBox="0 0 24 24"><line x1="19" y1="4" x2="10" y2="4"></line><line x1="14" y1="20" x2="5" y2="20"></line><line x1="15" y1="4" x2="9" y2="20"></line></svg></button>
bind:value={content} <button type="button" class="md-btn" onclick={() => insertAtCursor('~~', '~~', '删除线')} title="删除线"><svg viewBox="0 0 24 24"><path d="M16 4H9a3 3 0 0 0-2.83 4"></path><path d="M14 12a4 4 0 0 1 0 8H6"></path><line x1="4" y1="12" x2="20" y2="12"></line></svg></button>
rows="16" <button type="button" class="md-btn" onclick={() => insertAtCursor('### ', '', '标题')} title="标题"><svg viewBox="0 0 24 24"><path d="M4 12h16M4 18V6M20 18V6"></path></svg></button>
placeholder="使用 Markdown 撰写..." <div class="md-divider"></div>
required <button type="button" class="md-btn" onclick={() => insertAtCursor('- ', '', '列表项')} title="无序列表"><svg viewBox="0 0 24 24"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg></button>
></textarea> <button type="button" class="md-btn" onclick={() => insertAtCursor('[', '](https://)', '链接文字')} title="链接"><svg viewBox="0 0 24 24"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg></button>
<button type="button" class="md-btn" onclick={() => insertAtCursor('![', '](https://)', '图片描述')} title="图片"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><polygon points="6 21, 10 13, 13 16.5, 16.5 9.5, 19 21" style="fill: currentColor; stroke: currentColor; stroke-linejoin: round; stroke-width: 1.5;"></polygon></svg></button>
<button type="button" class="md-btn" onclick={() => insertAtCursor('```\n', '\n```', 'code')} title="代码块"><svg viewBox="0 0 24 24"><polyline points="16 18 22 12 16 6"></polyline><polyline points="8 6 2 12 8 18"></polyline></svg></button>
<div class="md-divider"></div>
<div class="toolbar-dropdown-wrapper">
{#if showAdmonitionMenu}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_interactive_supports_focus -->
<div class="dropdown-overlay" onclick={() => showAdmonitionMenu = false} role="button"></div>
{/if}
<button type="button" class="md-btn" onclick={() => showAdmonitionMenu = !showAdmonitionMenu} title="提示框 (Admonition)"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg></button>
{#if showAdmonitionMenu}
<div class="toolbar-dropdown">
{#each admonitionTypes as item}
<button type="button" class="dropdown-item" onclick={() => insertAdmonition(item.type, item.title)}>
{item.label}
</button>
{/each}
</div>
{/if}
</div>
<button type="button" class="md-btn" onclick={insertMore} title="插入摘要分割线"><svg viewBox="0 0 24 24"><polyline points="5 5 5 9 19 9 19 5"></polyline><polyline points="5 19 5 15 19 15 19 19"></polyline><line x1="4" y1="12" x2="20" y2="12" stroke-dasharray="3 3" stroke-linecap="butt"></line></svg></button>
</div>
{/if}
<div class="editor-tabs">
<button type="button" class="tab-btn {mode === 'edit' ? 'active' : ''}" onclick={() => mode = 'edit'}>编辑</button>
<button type="button" class="tab-btn {mode === 'preview' ? 'active' : ''}" onclick={() => mode = 'preview'}>预览</button>
</div>
</div>
</div>
<div class="editor-body">
{#if mode === 'edit'}
<textarea
id="content"
name="content"
bind:value={content}
rows="16"
style="min-height: 400px;"
placeholder="支持 Markdown 格式..."
required
></textarea>
{:else}
<div class="preview-area">
<MarkdownRenderer {content} />
</div>
{/if}
</div>
</div> </div>
</div> </div>
@@ -82,7 +248,7 @@
type="text" type="text"
id="slug" id="slug"
name="slug" name="slug"
class="select-input" class="sidebar-input"
bind:value={slug} bind:value={slug}
placeholder="hello-world" placeholder="hello-world"
required required
@@ -99,19 +265,51 @@
</select> </select>
</div> </div>
{#if data.tags.length > 0} <div class="sidebar-box">
<div class="sidebar-box"> <h3>标签</h3>
<h3>标签</h3> <div class="selected-tags-vertical">
<div class="tags-list"> {#each selectedTags as tagId}
{#each data.tags as tag} {#if tagsList.find(t => t.id === tagId)}
<label class="tag-item"> {@const tag = tagsList.find(t => t.id === tagId)}
<input type="checkbox" name="tags" value={tag.id} /> <div class="selected-tag-row">
<span>{tag.name}</span> <span>{tag?.name}</span>
</label> <button type="button" class="remove-tag-btn" onclick={() => toggleTag(tagId)} title="移除标签"><svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
{/each} <input type="hidden" name="tags" value={tagId} />
</div> </div>
{/if}
{/each}
</div> </div>
{/if}
<div class="tag-input-container">
<input
type="text"
class="sidebar-input tag-input-new"
placeholder="+ 添加标签..."
bind:value={newTagInput}
onkeydown={handleAddTag}
onfocus={() => showTagDropdown = true}
onblur={() => setTimeout(() => showTagDropdown = false, 200)}
disabled={isAddingTag}
autocomplete="off"
/>
{#if showTagDropdown && filteredTags.length > 0}
<div class="tag-autocomplete-dropdown">
{#each filteredTags as tag}
<button type="button" class="tag-dropdown-item" onclick={() => {
if (!selectedTags.includes(tag.id)) {
selectedTags = [...selectedTags, tag.id];
}
newTagInput = '';
showTagDropdown = false;
}}>
{tag.name}
</button>
{/each}
</div>
{/if}
</div>
</div>
</div> </div>
</div> </div>
</form> </form>
@@ -156,14 +354,14 @@
} }
.btn-primary:hover { .btn-primary:hover {
background-color: var(--accent-color, #bd6262); background-color: var(--accent-color, #419b38);
border-color: var(--accent-color, #bd6262); border-color: var(--accent-color, #419b38);
color: white; color: white;
} }
.btn-secondary:hover { .btn-secondary:hover {
border-color: var(--accent-color, #bd6262); border-color: var(--accent-color, #419b38);
color: var(--accent-color, #bd6262); color: var(--accent-color, #419b38);
} }
.error-alert { .error-alert {
@@ -175,90 +373,101 @@
font-weight: 600; font-weight: 600;
} }
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.editor-tabs {
display: flex;
gap: 8px;
}
.tab-btn {
background: none;
border: none;
padding: 4px 12px;
font-size: 0.85rem;
font-weight: 600;
color: rgba(0, 0, 0, 0.4);
cursor: pointer;
border-radius: 4px;
transition: all 0.2s ease;
}
.tab-btn:hover {
background: rgba(0, 0, 0, 0.05);
}
.tab-btn.active {
color: var(--accent-color, #419b38);
background: rgba(65, 155, 56, 0.1);
}
.preview-area {
padding: 20px;
border: 1px solid rgba(0, 0, 0, 0.12);
background: rgba(0, 0, 0, 0.015);
min-height: 400px;
max-height: 600px;
overflow-y: auto;
}
.form-grid { .form-grid {
display: block; display: grid;
grid-template-columns: 1fr 280px;
gap: 40px;
align-items: start;
} }
.form-main { .form-main {
max-width: 800px; width: 100%;
margin: 0 auto;
} }
.form-sidebar { .form-sidebar {
position: fixed; position: sticky;
top: 120px; top: 80px;
right: calc((100vw - 1024px) / 2 - 280px); width: 100%;
width: 260px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 30px; gap: 30px;
z-index: 10; z-index: 10;
} }
@media (max-width: 1580px) { @media (max-width: 900px) {
.form-sidebar { .form-grid {
right: 20px; grid-template-columns: 1fr;
} }
}
@media (max-width: 1200px) {
.form-sidebar { .form-sidebar {
position: static; position: static;
width: 100%;
margin-top: 40px;
flex-direction: row; flex-direction: row;
flex-wrap: wrap; flex-wrap: wrap;
gap: 40px; gap: 20px;
margin-top: 20px;
} }
} }
.form-group { .form-group {
margin-bottom: 30px; margin-bottom: 20px;
}
.form-group.inline-group {
display: flex;
align-items: center;
gap: 20px;
} }
.form-group label { .form-group label {
display: block; display: block;
font-weight: 700; font-weight: 700;
margin-bottom: 12px; margin-bottom: 6px;
color: var(--text-color); color: var(--text-color);
font-size: 0.9rem; font-size: 0.9rem;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.form-group.inline-group label { .form-group input[type="text"],
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 { .form-group textarea {
width: 100%; width: 100%;
padding: 16px; padding: 12px 14px;
border: 1px solid rgba(0, 0, 0, 0.12); border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 0; border-radius: 0;
background: rgba(0, 0, 0, 0.015); background: rgba(0, 0, 0, 0.015);
@@ -267,12 +476,29 @@
color: var(--text-color); color: var(--text-color);
box-sizing: border-box; box-sizing: border-box;
transition: border-color 0.3s ease, box-shadow 0.3s ease; transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.form-group textarea {
resize: vertical; resize: vertical;
line-height: 1.8; line-height: 1.8;
min-height: 120px; min-height: 120px;
} }
.select-input { .form-group input[type="text"]:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent-color, #419b38);
box-shadow: 0 0 0 3px rgba(65, 155, 56, 0.08);
}
.select-input:focus,
.sidebar-input:focus {
outline: none;
border-bottom-color: var(--accent-color, #419b38);
}
.select-input,
.sidebar-input {
width: 100%; width: 100%;
padding: 10px 0; padding: 10px 0;
border: none; border: none;
@@ -283,13 +509,16 @@
color: var(--text-color); color: var(--text-color);
box-sizing: border-box; box-sizing: border-box;
transition: border-bottom-color 0.3s ease; transition: border-bottom-color 0.3s ease;
}
.select-input {
cursor: pointer; cursor: pointer;
} }
.form-group textarea:focus { .form-group textarea:focus {
outline: none; outline: none;
border-color: var(--accent-color, #bd6262); border-color: var(--accent-color, #419b38);
box-shadow: 0 0 0 3px rgba(189, 98, 98, 0.08); box-shadow: 0 0 0 3px rgba(65, 155, 56, 0.08);
} }
.sidebar-box { .sidebar-box {
@@ -309,25 +538,181 @@
border-bottom: none; border-bottom: none;
} }
.tags-list { .selected-tags-vertical {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 6px;
margin-bottom: 15px;
} }
.tag-item { .selected-tag-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 10px;
border: 1px solid var(--text-color);
background: transparent;
font-weight: 600;
font-size: 0.9rem;
}
.remove-tag-btn {
background: transparent;
border: none;
color: var(--text-color);
font-size: 1.1rem;
cursor: pointer;
opacity: 0.7;
padding: 0 4px;
}
.remove-tag-btn:hover {
opacity: 1;
color: #d9534f;
}
.tag-input-container {
position: relative;
}
.tag-autocomplete-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--bg-color);
border: 1px solid var(--text-color);
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.15);
z-index: 50;
max-height: 200px;
overflow-y: auto;
display: flex;
flex-direction: column;
margin-top: 4px;
}
.tag-dropdown-item {
background: transparent;
border: none;
border-bottom: 1px solid rgba(0,0,0,0.1);
padding: 8px 12px;
text-align: left;
font-family: inherit;
font-size: 0.85rem;
color: var(--text-color);
cursor: pointer;
font-weight: 600;
transition: all 0.2s ease;
}
.tag-dropdown-item:last-child {
border-bottom: none;
}
.tag-dropdown-item:hover {
background: var(--text-color);
color: var(--bg-color);
}
.editor-header-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 15px;
cursor: pointer;
font-size: 0.95rem;
} }
.tag-item input[type="checkbox"] { .md-toolbar {
accent-color: var(--accent-color, #bd6262); display: flex;
width: 16px; align-items: center;
height: 16px; gap: 4px;
margin: 0;
cursor: pointer;
} }
.md-btn {
background: transparent;
border: none;
padding: 4px;
cursor: pointer;
color: var(--text-color);
opacity: 0.65;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 2px;
}
.md-btn:hover {
opacity: 1;
color: var(--accent-color, #419b38);
background-color: rgba(0,0,0,0.03);
}
.md-btn svg {
width: 18px;
height: 18px;
stroke: currentColor;
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
fill: none;
}
.md-divider {
width: 2px;
height: 16px;
background: var(--text-color);
opacity: 0.15;
margin: 0 4px;
}
.toolbar-dropdown-wrapper {
position: relative;
display: inline-block;
}
.dropdown-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
z-index: 99;
}
.toolbar-dropdown {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
background: var(--bg-color, #ffffff);
border: 1px solid var(--text-color);
z-index: 100;
width: 140px;
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
}
.dropdown-item {
background: transparent;
border: none;
border-bottom: 1px solid rgba(0,0,0,0.1);
padding: 8px 12px;
text-align: left;
font-family: inherit;
font-size: 0.85rem;
color: var(--text-color);
cursor: pointer;
font-weight: 600;
transition: all 0.2s ease;
}
.dropdown-item:last-child {
border-bottom: none;
}
.dropdown-item:hover {
background: var(--text-color);
color: var(--bg-color, #ffffff);
}
</style> </style>
+4 -2
View File
@@ -40,8 +40,10 @@ export const actions: Actions = {
} }
try { try {
await db.prepare('INSERT INTO tags (name) VALUES (?)').bind(name).run(); // 在 D1 中获取刚插入的 ID,可以通过 RETURNING id 语法
return { success: true }; const { results } = await db.prepare('INSERT INTO tags (name) VALUES (?) RETURNING id').bind(name).all();
const newId = results[0]?.id;
return { success: true, tag: { id: newId, name } };
} catch (err: any) { } catch (err: any) {
console.error('Error creating tag:', err); console.error('Error creating tag:', err);
if (err?.message?.includes('UNIQUE constraint failed')) { if (err?.message?.includes('UNIQUE constraint failed')) {
+11 -10
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores';
import type { PageData, ActionData } from './$types'; import type { PageData, ActionData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
@@ -7,7 +8,7 @@
</script> </script>
<div class="admin-page-header"> <div class="admin-page-header">
<h2>标签管理</h2> <h2>{$page.data.aliases?.tag || '标签'}管理</h2>
</div> </div>
{#if form?.error} {#if form?.error}
@@ -17,9 +18,9 @@
{/if} {/if}
<div class="tags-grid-layout"> <div class="tags-grid-layout">
<!-- 左侧:新建标签卡片 --> <!-- 左侧:新建{$page.data.aliases?.tag || '标签'}卡片 -->
<div class="create-tag-card"> <div class="create-tag-card">
<h3>新标签</h3> <h3>新{$page.data.aliases?.tag || '标签'}</h3>
<form method="POST" action="?/create"> <form method="POST" action="?/create">
<div class="form-group"> <div class="form-group">
<label for="name">名称 *</label> <label for="name">名称 *</label>
@@ -36,18 +37,18 @@
</form> </form>
</div> </div>
<!-- 右侧:现有标签列表 --> <!-- 右侧:现有{$page.data.aliases?.tag || '标签'}列表 -->
<div class="table-container"> <div class="table-container">
{#if data.tags.length === 0} {#if data.tags.length === 0}
<div class="empty-state"> <div class="empty-state">
<p>暂无任何标签,在左侧添加一个吧!</p> <p>暂无任何{$page.data.aliases?.tag || '标签'},在左侧添加一个吧!</p>
</div> </div>
{:else} {:else}
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>标签名称</th> <th>{$page.data.aliases?.tag || '标签'}名称</th>
<th>关联文章数</th> <th>关联文章数</th>
<th>操作</th> <th>操作</th>
</tr> </tr>
@@ -60,7 +61,7 @@
<td><span class="badge">{tag.post_count} 篇文章</span></td> <td><span class="badge">{tag.post_count} 篇文章</span></td>
<td> <td>
<form action="?/delete" method="POST" class="inline-form" onsubmit={(e) => { <form action="?/delete" method="POST" class="inline-form" onsubmit={(e) => {
if(!confirm(`确定要删除标签 "${tag.name}" 吗?`)) e.preventDefault(); if(!confirm(`确定要删除${$page.data.aliases?.tag || '标签'} "${tag.name}" 吗?`)) e.preventDefault();
}}> }}>
<input type="hidden" name="id" value={tag.id} /> <input type="hidden" name="id" value={tag.id} />
<button type="submit" class="action-btn delete">删除</button> <button type="submit" class="action-btn delete">删除</button>
@@ -153,7 +154,7 @@
.form-group input[type="text"]:focus { .form-group input[type="text"]:focus {
outline: none; outline: none;
border-bottom-color: var(--accent-color, #bd6262); border-bottom-color: var(--accent-color, #419b38);
} }
.btn { .btn {
@@ -179,8 +180,8 @@
} }
.btn-primary:hover { .btn-primary:hover {
background-color: var(--accent-color, #bd6262); background-color: var(--accent-color, #419b38);
border-color: var(--accent-color, #bd6262); border-color: var(--accent-color, #419b38);
color: white; color: white;
} }