feat: 完善文章、分类与标签的增删改查及批量处理功能
This commit is contained in:
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) {
|
||||
try {
|
||||
await db.prepare(`
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
@@ -14,7 +15,7 @@
|
||||
</script>
|
||||
|
||||
<div class="admin-page-header">
|
||||
<h2>分类管理</h2>
|
||||
<h2>{$page.data.aliases?.category || '分类'}管理</h2>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
@@ -24,9 +25,9 @@
|
||||
{/if}
|
||||
|
||||
<div class="categories-grid-layout">
|
||||
<!-- 左侧:新建分类卡片 -->
|
||||
<!-- 左侧:新建{$page.data.aliases?.category || '分类'}卡片 -->
|
||||
<div class="create-category-card">
|
||||
<h3>新分类</h3>
|
||||
<h3>新{$page.data.aliases?.category || '分类'}</h3>
|
||||
<form method="POST" action="?/create">
|
||||
<div class="form-group">
|
||||
<label for="name">名称 *</label>
|
||||
@@ -54,18 +55,18 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:分类列表 -->
|
||||
<!-- 右侧:{$page.data.aliases?.category || '分类'}列表 -->
|
||||
<div class="table-container">
|
||||
{#if data.categories.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>暂无任何分类,在左侧添加一个吧!</p>
|
||||
<p>暂无任何{$page.data.aliases?.category || '分类'},在左侧添加一个吧!</p>
|
||||
</div>
|
||||
{:else}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>分类名称</th>
|
||||
<th>{$page.data.aliases?.category || '分类'}名称</th>
|
||||
<th>别名 (Slug)</th>
|
||||
<th>关联文章数</th>
|
||||
<th>操作</th>
|
||||
@@ -80,7 +81,7 @@
|
||||
<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();
|
||||
if(!confirm(`确定要删除${$page.data.aliases?.category || '分类'} "${category.name}" 吗?删除后相关文章将归为未${$page.data.aliases?.category || '分类'}。`)) e.preventDefault();
|
||||
}}>
|
||||
<input type="hidden" name="id" value={category.id} />
|
||||
<button type="submit" class="action-btn delete">删除</button>
|
||||
@@ -173,7 +174,7 @@
|
||||
|
||||
.form-group input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-bottom-color: var(--accent-color, #bd6262);
|
||||
border-bottom-color: var(--accent-color, #419b38);
|
||||
}
|
||||
|
||||
.btn {
|
||||
@@ -199,8 +200,8 @@
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--accent-color, #bd6262);
|
||||
border-color: var(--accent-color, #bd6262);
|
||||
background-color: var(--accent-color, #419b38);
|
||||
border-color: var(--accent-color, #419b38);
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
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 }) => {
|
||||
const db = platform?.env?.DB;
|
||||
|
||||
@@ -10,7 +18,7 @@ export const load: PageServerLoad = async ({ platform }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
let results;
|
||||
let results: PostListRow[];
|
||||
try {
|
||||
const queryRes = await db.prepare(
|
||||
`SELECT p.id, p.title, p.slug, p.published_at as date, u.username as author, c.name as category
|
||||
@@ -18,7 +26,7 @@ export const load: PageServerLoad = async ({ platform }) => {
|
||||
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();
|
||||
).all<PostListRow>();
|
||||
results = queryRes.results;
|
||||
} catch (e) {
|
||||
// Fallback query if categories table or category_id column is missing
|
||||
@@ -27,7 +35,7 @@ export const load: PageServerLoad = async ({ platform }) => {
|
||||
FROM posts p
|
||||
LEFT JOIN users u ON p.author_id = u.id
|
||||
ORDER BY p.published_at DESC`
|
||||
).all();
|
||||
).all<PostListRow>();
|
||||
results = queryRes.results.map(r => ({ ...r, category: '未分类' }));
|
||||
}
|
||||
|
||||
@@ -59,12 +67,13 @@ export const actions = {
|
||||
if (!db) return { success: false, error: 'Database not found' };
|
||||
|
||||
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 {
|
||||
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 };
|
||||
} catch (err) {
|
||||
console.error('Error deleting post:', err);
|
||||
|
||||
@@ -1,54 +1,87 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import type { PageData } from './$types';
|
||||
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>
|
||||
|
||||
<div class="admin-page-header">
|
||||
<h2>文章管理</h2>
|
||||
<a href="/admin/posts/new" class="btn btn-primary">发布新文章</a>
|
||||
<h2>{$page.data.aliases?.post || '文章'}管理</h2>
|
||||
<a href="/admin/posts/new" class="btn btn-primary">发布新{$page.data.aliases?.post || '文章'}</a>
|
||||
</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">
|
||||
{#if data.posts.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>还没有发布任何文章,现在就开始写作吧!</p>
|
||||
<p>还没有发布任何{$page.data.aliases?.post || '文章'},现在就开始写作吧!</p>
|
||||
</div>
|
||||
{:else}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="checkbox-col">
|
||||
<input type="checkbox" checked={isAllSelected} onchange={toggleAll} />
|
||||
</th>
|
||||
<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 class="checkbox-col">
|
||||
<input type="checkbox" name="ids" value={post.id.toString()} bind:group={selectedIds} />
|
||||
</td>
|
||||
<td>{post.id}</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="前台预览">
|
||||
<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>
|
||||
</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>
|
||||
@@ -92,8 +125,8 @@
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--accent-color, #bd6262);
|
||||
border-color: var(--accent-color, #bd6262);
|
||||
background-color: var(--accent-color, #419b38);
|
||||
border-color: var(--accent-color, #419b38);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@@ -110,7 +143,7 @@
|
||||
}
|
||||
|
||||
.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));
|
||||
}
|
||||
|
||||
@@ -132,63 +165,85 @@
|
||||
|
||||
.preview-link {
|
||||
text-decoration: none;
|
||||
font-size: 0.85rem;
|
||||
margin-left: 6px;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.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));
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -11,8 +11,8 @@ export const load: PageServerLoad = async ({ params, platform }) => {
|
||||
|
||||
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 }>();
|
||||
'SELECT id, title, slug, content, category_id FROM posts WHERE id = ?'
|
||||
).bind(postId).first<{ id: number; title: string; slug: string; content: string; category_id: number | null }>();
|
||||
|
||||
if (!post) {
|
||||
throw error(404, '文章不存在');
|
||||
@@ -46,8 +46,7 @@ export const actions: Actions = {
|
||||
|
||||
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 slug = formData.get('slug')?.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));
|
||||
@@ -60,9 +59,9 @@ export const actions: Actions = {
|
||||
// 更新文章表
|
||||
await db.prepare(
|
||||
`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 = ?`
|
||||
).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();
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
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 title = $state(data.post.title);
|
||||
let slug = $state(data.post.slug);
|
||||
let excerpt = $state(data.post.excerpt || '');
|
||||
let content = $state(data.post.content || '');
|
||||
let mode = $state<'edit' | 'preview'>('edit');
|
||||
let selectedTags = $state<number[]>(data.selectedTagIds || []);
|
||||
let tagsList = $state(data.tags || []);
|
||||
|
||||
function toggleTag(tagId: number) {
|
||||
if (selectedTags.includes(tagId)) {
|
||||
@@ -21,6 +25,118 @@
|
||||
const target = e.target as HTMLInputElement;
|
||||
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>
|
||||
|
||||
<div class="admin-page-header">
|
||||
@@ -34,17 +150,10 @@
|
||||
</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">
|
||||
<div class="form-group">
|
||||
<label for="title">标题 *</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -58,26 +167,66 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="excerpt">摘要</label>
|
||||
<textarea
|
||||
id="excerpt"
|
||||
name="excerpt"
|
||||
bind:value={excerpt}
|
||||
rows="3"
|
||||
placeholder="简短介绍..."
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="editor-header">
|
||||
<label for="content">正文 *</label>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="content">正文 *</label>
|
||||
<textarea
|
||||
id="content"
|
||||
name="content"
|
||||
bind:value={content}
|
||||
rows="16"
|
||||
placeholder="使用 Markdown 撰写..."
|
||||
required
|
||||
></textarea>
|
||||
<div class="editor-header-right">
|
||||
{#if mode === 'edit'}
|
||||
<div class="md-toolbar">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<button type="button" class="md-btn" onclick={() => insertAtCursor('### ', '', '标题')} title="标题"><svg viewBox="0 0 24 24"><path d="M4 12h16M4 18V6M20 18V6"></path></svg></button>
|
||||
<div class="md-divider"></div>
|
||||
<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>
|
||||
<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('', '图片描述')} 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>
|
||||
|
||||
@@ -93,7 +242,7 @@
|
||||
type="text"
|
||||
id="slug"
|
||||
name="slug"
|
||||
class="select-input"
|
||||
class="sidebar-input"
|
||||
bind:value={slug}
|
||||
placeholder="hello-world"
|
||||
required
|
||||
@@ -110,25 +259,51 @@
|
||||
</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 class="sidebar-box">
|
||||
<h3>标签</h3>
|
||||
<div class="selected-tags-vertical">
|
||||
{#each selectedTags as tagId}
|
||||
{#if tagsList.find(t => t.id === tagId)}
|
||||
{@const tag = tagsList.find(t => t.id === tagId)}
|
||||
<div class="selected-tag-row">
|
||||
<span>{tag?.name}</span>
|
||||
<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>
|
||||
<input type="hidden" name="tags" value={tagId} />
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</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>
|
||||
</form>
|
||||
@@ -173,14 +348,14 @@
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--accent-color, #bd6262);
|
||||
border-color: var(--accent-color, #bd6262);
|
||||
background-color: var(--accent-color, #419b38);
|
||||
border-color: var(--accent-color, #419b38);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
border-color: var(--accent-color, #bd6262);
|
||||
color: var(--accent-color, #bd6262);
|
||||
border-color: var(--accent-color, #419b38);
|
||||
color: var(--accent-color, #419b38);
|
||||
}
|
||||
|
||||
.error-alert {
|
||||
@@ -192,90 +367,105 @@
|
||||
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;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 280px;
|
||||
gap: 40px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.form-main {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-sidebar {
|
||||
position: fixed;
|
||||
top: 120px;
|
||||
right: calc((100vw - 1024px) / 2 - 280px);
|
||||
width: 260px;
|
||||
position: sticky;
|
||||
top: 80px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@media (max-width: 1580px) {
|
||||
.form-sidebar {
|
||||
right: 20px;
|
||||
@media (max-width: 900px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.form-sidebar {
|
||||
position: static;
|
||||
width: 100%;
|
||||
margin-top: 40px;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 40px;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.form-group.inline-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 6px;
|
||||
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 input[type="text"],
|
||||
.form-group textarea {
|
||||
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: 0;
|
||||
background: rgba(0, 0, 0, 0.015);
|
||||
@@ -284,12 +474,29 @@
|
||||
color: var(--text-color);
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
line-height: 1.8;
|
||||
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%;
|
||||
padding: 10px 0;
|
||||
border: none;
|
||||
@@ -300,13 +507,16 @@
|
||||
color: var(--text-color);
|
||||
box-sizing: border-box;
|
||||
transition: border-bottom-color 0.3s ease;
|
||||
}
|
||||
|
||||
.select-input {
|
||||
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);
|
||||
border-color: var(--accent-color, #419b38);
|
||||
box-shadow: 0 0 0 3px rgba(65, 155, 56, 0.08);
|
||||
}
|
||||
|
||||
.sidebar-box {
|
||||
@@ -326,25 +536,181 @@
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tags-list {
|
||||
.selected-tags-vertical {
|
||||
display: flex;
|
||||
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;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.tag-item input[type="checkbox"] {
|
||||
accent-color: var(--accent-color, #bd6262);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
.md-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -27,8 +27,7 @@ export const actions: Actions = {
|
||||
|
||||
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 slug = formData.get('slug')?.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));
|
||||
@@ -39,10 +38,10 @@ export const actions: Actions = {
|
||||
|
||||
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)
|
||||
`INSERT INTO posts (title, slug, 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 }>();
|
||||
).bind(title, slug, content, categoryId).first<{ id: number }>();
|
||||
|
||||
const postId = res?.id;
|
||||
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
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 title = $state('');
|
||||
let slug = $state('');
|
||||
let excerpt = $state('');
|
||||
let content = $state('');
|
||||
let mode = $state<'edit' | 'preview'>('edit');
|
||||
|
||||
let tagsList = $state(data.tags || []);
|
||||
|
||||
// 自动从标题生成 Slug 简易逻辑
|
||||
function handleTitleInput() {
|
||||
@@ -17,10 +22,131 @@
|
||||
.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>
|
||||
|
||||
<div class="admin-page-header">
|
||||
<h2>新文章</h2>
|
||||
<h2>新{$page.data.aliases?.post || '文章'}</h2>
|
||||
<a href="/admin/posts" class="btn btn-secondary">← 返回</a>
|
||||
</div>
|
||||
|
||||
@@ -33,7 +159,7 @@
|
||||
<form method="POST" class="post-form">
|
||||
<div class="form-grid">
|
||||
<div class="form-main">
|
||||
<div class="form-group inline-group">
|
||||
<div class="form-group">
|
||||
<label for="title">标题 *</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -47,26 +173,66 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="excerpt">摘要</label>
|
||||
<textarea
|
||||
id="excerpt"
|
||||
name="excerpt"
|
||||
bind:value={excerpt}
|
||||
rows="3"
|
||||
placeholder="简短介绍..."
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="editor-header">
|
||||
<label for="content">正文 *</label>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="content">正文 *</label>
|
||||
<textarea
|
||||
id="content"
|
||||
name="content"
|
||||
bind:value={content}
|
||||
rows="16"
|
||||
placeholder="使用 Markdown 撰写..."
|
||||
required
|
||||
></textarea>
|
||||
<div class="editor-header-right">
|
||||
{#if mode === 'edit'}
|
||||
<div class="md-toolbar">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<button type="button" class="md-btn" onclick={() => insertAtCursor('### ', '', '标题')} title="标题"><svg viewBox="0 0 24 24"><path d="M4 12h16M4 18V6M20 18V6"></path></svg></button>
|
||||
<div class="md-divider"></div>
|
||||
<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>
|
||||
<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('', '图片描述')} 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>
|
||||
|
||||
@@ -82,7 +248,7 @@
|
||||
type="text"
|
||||
id="slug"
|
||||
name="slug"
|
||||
class="select-input"
|
||||
class="sidebar-input"
|
||||
bind:value={slug}
|
||||
placeholder="hello-world"
|
||||
required
|
||||
@@ -99,19 +265,51 @@
|
||||
</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 class="sidebar-box">
|
||||
<h3>标签</h3>
|
||||
<div class="selected-tags-vertical">
|
||||
{#each selectedTags as tagId}
|
||||
{#if tagsList.find(t => t.id === tagId)}
|
||||
{@const tag = tagsList.find(t => t.id === tagId)}
|
||||
<div class="selected-tag-row">
|
||||
<span>{tag?.name}</span>
|
||||
<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>
|
||||
<input type="hidden" name="tags" value={tagId} />
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</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>
|
||||
</form>
|
||||
@@ -156,14 +354,14 @@
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--accent-color, #bd6262);
|
||||
border-color: var(--accent-color, #bd6262);
|
||||
background-color: var(--accent-color, #419b38);
|
||||
border-color: var(--accent-color, #419b38);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
border-color: var(--accent-color, #bd6262);
|
||||
color: var(--accent-color, #bd6262);
|
||||
border-color: var(--accent-color, #419b38);
|
||||
color: var(--accent-color, #419b38);
|
||||
}
|
||||
|
||||
.error-alert {
|
||||
@@ -175,90 +373,101 @@
|
||||
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 {
|
||||
display: block;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 280px;
|
||||
gap: 40px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.form-main {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-sidebar {
|
||||
position: fixed;
|
||||
top: 120px;
|
||||
right: calc((100vw - 1024px) / 2 - 280px);
|
||||
width: 260px;
|
||||
position: sticky;
|
||||
top: 80px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 30px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@media (max-width: 1580px) {
|
||||
.form-sidebar {
|
||||
right: 20px;
|
||||
@media (max-width: 900px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.form-sidebar {
|
||||
position: static;
|
||||
width: 100%;
|
||||
margin-top: 40px;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 40px;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.form-group.inline-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 6px;
|
||||
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 input[type="text"],
|
||||
.form-group textarea {
|
||||
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: 0;
|
||||
background: rgba(0, 0, 0, 0.015);
|
||||
@@ -267,12 +476,29 @@
|
||||
color: var(--text-color);
|
||||
box-sizing: border-box;
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
line-height: 1.8;
|
||||
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%;
|
||||
padding: 10px 0;
|
||||
border: none;
|
||||
@@ -283,13 +509,16 @@
|
||||
color: var(--text-color);
|
||||
box-sizing: border-box;
|
||||
transition: border-bottom-color 0.3s ease;
|
||||
}
|
||||
|
||||
.select-input {
|
||||
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);
|
||||
border-color: var(--accent-color, #419b38);
|
||||
box-shadow: 0 0 0 3px rgba(65, 155, 56, 0.08);
|
||||
}
|
||||
|
||||
.sidebar-box {
|
||||
@@ -309,25 +538,181 @@
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.tags-list {
|
||||
.selected-tags-vertical {
|
||||
display: flex;
|
||||
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;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 0.95rem;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.tag-item input[type="checkbox"] {
|
||||
accent-color: var(--accent-color, #bd6262);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
.md-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -40,8 +40,10 @@ export const actions: Actions = {
|
||||
}
|
||||
|
||||
try {
|
||||
await db.prepare('INSERT INTO tags (name) VALUES (?)').bind(name).run();
|
||||
return { success: true };
|
||||
// 在 D1 中获取刚插入的 ID,可以通过 RETURNING id 语法
|
||||
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) {
|
||||
console.error('Error creating tag:', err);
|
||||
if (err?.message?.includes('UNIQUE constraint failed')) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
@@ -7,7 +8,7 @@
|
||||
</script>
|
||||
|
||||
<div class="admin-page-header">
|
||||
<h2>标签管理</h2>
|
||||
<h2>{$page.data.aliases?.tag || '标签'}管理</h2>
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
@@ -17,9 +18,9 @@
|
||||
{/if}
|
||||
|
||||
<div class="tags-grid-layout">
|
||||
<!-- 左侧:新建标签卡片 -->
|
||||
<!-- 左侧:新建{$page.data.aliases?.tag || '标签'}卡片 -->
|
||||
<div class="create-tag-card">
|
||||
<h3>新标签</h3>
|
||||
<h3>新{$page.data.aliases?.tag || '标签'}</h3>
|
||||
<form method="POST" action="?/create">
|
||||
<div class="form-group">
|
||||
<label for="name">名称 *</label>
|
||||
@@ -36,18 +37,18 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:现有标签列表 -->
|
||||
<!-- 右侧:现有{$page.data.aliases?.tag || '标签'}列表 -->
|
||||
<div class="table-container">
|
||||
{#if data.tags.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>暂无任何标签,在左侧添加一个吧!</p>
|
||||
<p>暂无任何{$page.data.aliases?.tag || '标签'},在左侧添加一个吧!</p>
|
||||
</div>
|
||||
{:else}
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>标签名称</th>
|
||||
<th>{$page.data.aliases?.tag || '标签'}名称</th>
|
||||
<th>关联文章数</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
@@ -60,7 +61,7 @@
|
||||
<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();
|
||||
if(!confirm(`确定要删除${$page.data.aliases?.tag || '标签'} "${tag.name}" 吗?`)) e.preventDefault();
|
||||
}}>
|
||||
<input type="hidden" name="id" value={tag.id} />
|
||||
<button type="submit" class="action-btn delete">删除</button>
|
||||
@@ -153,7 +154,7 @@
|
||||
|
||||
.form-group input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-bottom-color: var(--accent-color, #bd6262);
|
||||
border-bottom-color: var(--accent-color, #419b38);
|
||||
}
|
||||
|
||||
.btn {
|
||||
@@ -179,8 +180,8 @@
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--accent-color, #bd6262);
|
||||
border-color: var(--accent-color, #bd6262);
|
||||
background-color: var(--accent-color, #419b38);
|
||||
border-color: var(--accent-color, #419b38);
|
||||
color: white;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user