refactor: 统一后台 CRUD 路由的数据获取与操作方式
生产环境部署 - BlogFlare 至 Cloudflare Pages / 构建与自动化上线 (push) Failing after 6m4s

This commit is contained in:
seahi committed 2026-08-07 12:53:44 +08:00
1 parent c902e5858d
commit 9f5c7c6e99
24 files changed
+1516 -659

No files matched your search

+11 -9
View File
@@ -1,9 +1,11 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals, platform }) => {
const db = platform?.env?.DB;
let aliases = {
const aliases = {
post: '文章',
page: '页面',
category: '分类',
@@ -11,17 +13,17 @@ export const load: LayoutServerLoad = async ({ locals, platform }) => {
series: '系列'
};
let site = {
const site = {
title: 'BlogFlare',
description: 'A high-performance blog powered by Cloudflare D1, R2, and KV.'
};
if (db) {
try {
const { results } = await db.prepare(
"SELECT key, value FROM settings WHERE key LIKE 'alias.%' OR key LIKE 'site.%'"
).all();
const { results } = await db
.prepare("SELECT key, value FROM settings WHERE key LIKE 'alias.%' OR key LIKE 'site.%'")
.all();
if (results && results.length > 0) {
results.forEach((row: any) => {
if (row.key.startsWith('alias.')) {
@@ -37,8 +39,8 @@ export const load: LayoutServerLoad = async ({ locals, platform }) => {
}
});
}
} catch (e) {
console.error('Failed to load settings', e);
} catch (err: any) {
console.error('Failed to load settings', err);
}
}
+56 -24
View File
@@ -1,8 +1,9 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import Toast from '$lib/components/Toast.svelte';
let { children } = $props();
let scrolled = $state(false);
@@ -24,38 +25,69 @@
<header class="header {scrolled ? 'scrolled' : ''}">
<div class="container wide">
<a href="/admin" class="brand">{$page.data.site?.title || 'BlogFlare'} Admin</a>
<a href={resolve('/admin')} class="brand">{$page.data.site?.title || 'BlogFlare'} Admin</a>
<nav>
<a href="/admin" class={$page.url.pathname === '/admin' ? 'active' : ''}>仪表盘</a>
<a href={resolve('/admin')} class={$page.url.pathname === '/admin' ? 'active' : ''}>仪表盘</a>
<div class="nav-dropdown">
<span class="nav-dropdown-trigger {$page.url.pathname === '/admin/posts/new' || $page.url.pathname === '/admin/pages/new' ? 'active' : ''}">
<span
class="nav-dropdown-trigger {$page.url.pathname === '/admin/posts/new' ||
$page.url.pathname === '/admin/pages/new'
? 'active'
: ''}"
>
撰写
</span>
<div class="dropdown-menu">
<a href="/admin/posts/new">撰写{$page.data.aliases?.post || '文章'}</a>
<a href="/admin/pages/new">创建{$page.data.aliases?.page || '页面'}</a>
</div>
</div>
<div class="nav-dropdown">
<span class="nav-dropdown-trigger {($page.url.pathname.startsWith('/admin/posts') && $page.url.pathname !== '/admin/posts/new') || $page.url.pathname.startsWith('/admin/pages') && $page.url.pathname !== '/admin/pages/new' || $page.url.pathname.startsWith('/admin/categories') || $page.url.pathname.startsWith('/admin/tags') || $page.url.pathname.startsWith('/admin/series') || $page.url.pathname.startsWith('/admin/files') ? 'active' : ''}">
管理
</span>
<div class="dropdown-menu">
<a href="/admin/posts">{$page.data.aliases?.post || '文章'}</a>
<a href="/admin/pages">{$page.data.aliases?.page || '页面'}</a>
<a href="/admin/categories">{$page.data.aliases?.category || '分类'}</a>
<a href="/admin/tags">{$page.data.aliases?.tag || '标签'}</a>
<a href="/admin/series">{$page.data.aliases?.series || '系列'}</a>
<a href="/admin/files">文件</a>
<a href={resolve('/admin/posts/new')}>撰写{$page.data.aliases?.post || '文章'}</a>
<a href={resolve('/admin/pages/new')}>创建{$page.data.aliases?.page || '页面'}</a>
</div>
</div>
<a href="/admin/settings" class={$page.url.pathname.startsWith('/admin/settings') ? 'active' : ''}>设置</a>
<div class="nav-dropdown">
<span
class="nav-dropdown-trigger {($page.url.pathname.startsWith('/admin/posts') &&
$page.url.pathname !== '/admin/posts/new') ||
($page.url.pathname.startsWith('/admin/pages') &&
$page.url.pathname !== '/admin/pages/new') ||
$page.url.pathname.startsWith('/admin/categories') ||
$page.url.pathname.startsWith('/admin/tags') ||
$page.url.pathname.startsWith('/admin/series') ||
$page.url.pathname.startsWith('/admin/files')
? 'active'
: ''}"
>
管理
</span>
<div class="dropdown-menu">
<a href={resolve('/admin/posts')}>{$page.data.aliases?.post || '文章'}</a>
<a href={resolve('/admin/pages')}>{$page.data.aliases?.page || '页面'}</a>
<a href={resolve('/admin/categories')}>{$page.data.aliases?.category || '分类'}</a>
<a href={resolve('/admin/tags')}>{$page.data.aliases?.tag || '标签'}</a>
<a href={resolve('/admin/series')}>{$page.data.aliases?.series || '系列'}</a>
<a href={resolve('/admin/files')}>文件</a>
</div>
</div>
<a
href={resolve('/admin/settings')}
class={$page.url.pathname.startsWith('/admin/settings') ? 'active' : ''}>设置</a
>
</nav>
<div class="user-info">
<a href="/" target="_blank" class="site-link" title="访问前台">
<svg width="14" height="14" 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 href={resolve('/')} target="_blank" class="site-link" title="访问前台">
<svg
width="14"
height="14"
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
>
<span>站点首页</span>
</a>
<span class="user-email">{$page.data.user?.email || 'Admin'}</span>
@@ -85,7 +117,7 @@
font-size: 0.9rem;
color: var(--header-text);
}
.site-link {
display: inline-flex;
align-items: center;
+8 -4
View File
@@ -1,8 +1,10 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ platform }) => {
const db = platform?.env?.DB;
let postCount = 0;
let totalWords = 0;
let tagCount = 0;
@@ -12,19 +14,21 @@ export const load: PageServerLoad = async ({ platform }) => {
if (db) {
try {
const postResult = await db.prepare('SELECT COUNT(*) as count, SUM(LENGTH(content)) as totalWords FROM posts').first();
const postResult = await db
.prepare('SELECT COUNT(*) as count, SUM(LENGTH(content)) as totalWords FROM posts')
.first();
const tagResult = await db.prepare('SELECT COUNT(*) as count FROM tags').first();
const categoryResult = await db.prepare('SELECT COUNT(*) as count FROM categories').first();
const seriesResult = await db.prepare('SELECT COUNT(*) as count FROM series').first();
const pageResult = await db.prepare('SELECT COUNT(*) as count FROM pages').first();
postCount = (postResult?.count as number) || 0;
totalWords = (postResult?.totalWords as number) || 0;
tagCount = (tagResult?.count as number) || 0;
categoryCount = (categoryResult?.count as number) || 0;
seriesCount = (seriesResult?.count as number) || 0;
pageCount = (pageResult?.count as number) || 0;
} catch (err) {
} catch (err: any) {
console.error('Error fetching admin dashboard stats:', err);
}
}
+107 -20
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PageData } from './$types';
import { page } from '$app/stores';
let { data }: { data: PageData } = $props();
@@ -6,7 +7,9 @@
<div class="dashboard">
<h2>仪表盘</h2>
<p class="welcome">欢迎回来,<strong>{data.user?.email || '管理员'}</strong>!这是您的后台概览。</p>
<p class="welcome">
欢迎回来,<strong>{data.user?.email || '管理员'}</strong>!这是您的后台概览。
</p>
<div class="stats-grid">
<div class="stat-card">
@@ -14,8 +17,22 @@
<h3>{$page.data.aliases?.post || '文章'}总数</h3>
<div class="number">{data.postCount}</div>
</div>
<a href="/admin/posts" class="action-link">
管理{$page.data.aliases?.post || '文章'} <svg class="arrow-icon" xmlns="http://www.w3.org/2000/svg" width="1.3em" height="1.3em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="7" y1="17" x2="17" y2="7"></line><polyline points="7 7 17 7 17 17"></polyline></svg>
<a href={resolve('/admin/posts')} class="action-link">
管理{$page.data.aliases?.post || '文章'}
<svg
class="arrow-icon"
xmlns="http://www.w3.org/2000/svg"
width="1.3em"
height="1.3em"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
><line x1="7" y1="17" x2="17" y2="7"></line><polyline points="7 7 17 7 17 17"
></polyline></svg
>
</a>
</div>
@@ -24,38 +41,94 @@
<h3>{$page.data.aliases?.post || '文章'}总字数</h3>
<div class="number">{(data.totalWords || 0).toLocaleString()}</div>
</div>
<a href="/admin/posts/new" class="action-link">
撰写新{$page.data.aliases?.post || '文章'} <svg class="arrow-icon" xmlns="http://www.w3.org/2000/svg" width="1.3em" height="1.3em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="7" y1="17" x2="17" y2="7"></line><polyline points="7 7 17 7 17 17"></polyline></svg>
<a href={resolve('/admin/posts/new')} class="action-link">
撰写新{$page.data.aliases?.post || '文章'}
<svg
class="arrow-icon"
xmlns="http://www.w3.org/2000/svg"
width="1.3em"
height="1.3em"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
><line x1="7" y1="17" x2="17" y2="7"></line><polyline points="7 7 17 7 17 17"
></polyline></svg
>
</a>
</div>
<div class="stat-card">
<div class="stat-body">
<h3>{$page.data.aliases?.page || '页面'}总数</h3>
<div class="number">{data.pageCount}</div>
</div>
<a href="/admin/pages" class="action-link">
管理{$page.data.aliases?.page || '页面'} <svg class="arrow-icon" xmlns="http://www.w3.org/2000/svg" width="1.3em" height="1.3em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="7" y1="17" x2="17" y2="7"></line><polyline points="7 7 17 7 17 17"></polyline></svg>
<a href={resolve('/admin/pages')} class="action-link">
管理{$page.data.aliases?.page || '页面'}
<svg
class="arrow-icon"
xmlns="http://www.w3.org/2000/svg"
width="1.3em"
height="1.3em"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
><line x1="7" y1="17" x2="17" y2="7"></line><polyline points="7 7 17 7 17 17"
></polyline></svg
>
</a>
</div>
<div class="stat-card">
<div class="stat-body">
<h3>{$page.data.aliases?.category || '分类'}总数</h3>
<div class="number">{data.categoryCount}</div>
</div>
<a href="/admin/categories" class="action-link">
管理{$page.data.aliases?.category || '分类'} <svg class="arrow-icon" xmlns="http://www.w3.org/2000/svg" width="1.3em" height="1.3em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="7" y1="17" x2="17" y2="7"></line><polyline points="7 7 17 7 17 17"></polyline></svg>
<a href={resolve('/admin/categories')} class="action-link">
管理{$page.data.aliases?.category || '分类'}
<svg
class="arrow-icon"
xmlns="http://www.w3.org/2000/svg"
width="1.3em"
height="1.3em"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
><line x1="7" y1="17" x2="17" y2="7"></line><polyline points="7 7 17 7 17 17"
></polyline></svg
>
</a>
</div>
<div class="stat-card">
<div class="stat-body">
<h3>{$page.data.aliases?.tag || '标签'}总数</h3>
<div class="number">{data.tagCount}</div>
</div>
<a href="/admin/tags" class="action-link">
管理{$page.data.aliases?.tag || '标签'} <svg class="arrow-icon" xmlns="http://www.w3.org/2000/svg" width="1.3em" height="1.3em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="7" y1="17" x2="17" y2="7"></line><polyline points="7 7 17 7 17 17"></polyline></svg>
<a href={resolve('/admin/tags')} class="action-link">
管理{$page.data.aliases?.tag || '标签'}
<svg
class="arrow-icon"
xmlns="http://www.w3.org/2000/svg"
width="1.3em"
height="1.3em"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
><line x1="7" y1="17" x2="17" y2="7"></line><polyline points="7 7 17 7 17 17"
></polyline></svg
>
</a>
</div>
@@ -64,8 +137,22 @@
<h3>{$page.data.aliases?.series || '系列'}总数</h3>
<div class="number">{data.seriesCount}</div>
</div>
<a href="/admin/series" class="action-link">
管理{$page.data.aliases?.series || '系列'} <svg class="arrow-icon" xmlns="http://www.w3.org/2000/svg" width="1.3em" height="1.3em" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="7" y1="17" x2="17" y2="7"></line><polyline points="7 7 17 7 17 17"></polyline></svg>
<a href={resolve('/admin/series')} class="action-link">
管理{$page.data.aliases?.series || '系列'}
<svg
class="arrow-icon"
xmlns="http://www.w3.org/2000/svg"
width="1.3em"
height="1.3em"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
><line x1="7" y1="17" x2="17" y2="7"></line><polyline points="7 7 17 7 17 17"
></polyline></svg
>
</a>
</div>
</div>
@@ -81,7 +168,7 @@
}
.welcome {
color: var(--text-muted, rgba(0,0,0,0.6));
color: var(--text-muted, rgba(0, 0, 0, 0.6));
margin-bottom: 2.5rem;
font-size: 1.05rem;
}
@@ -103,7 +190,7 @@
justify-content: space-between;
align-items: center;
}
.stat-card:hover {
border-color: var(--accent-color, #419b38);
}
@@ -116,14 +203,14 @@
.stat-card h3 {
margin: 0 0 12px 0;
font-size: 1.1rem;
color: var(--text-muted, rgba(0,0,0,0.6));
color: var(--text-muted, rgba(0, 0, 0, 0.6));
font-weight: 500;
}
.stat-card .number {
font-size: 3.2rem;
font-weight: 700;
color: var(--text-color, rgba(0,0,0,0.86));
color: var(--text-color, rgba(0, 0, 0, 0.86));
margin-bottom: 0;
line-height: 1;
}
+27 -11
View File
@@ -1,9 +1,11 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url, platform }) => {
const db = platform?.env?.DB;
if (!db) return json({ error: '数据库未连接' }, { status: 500 });
const page = parseInt(url.searchParams.get('page') || '1');
@@ -12,29 +14,39 @@ export const GET: RequestHandler = async ({ url, platform }) => {
try {
// Get R2 domain setting
const domainRes = await db.prepare('SELECT value FROM settings WHERE key = ?').bind('r2.public_domain').first();
const domainRes = await db
.prepare('SELECT value FROM settings WHERE key = ?')
.bind('r2.public_domain')
.first();
const r2Domain = domainRes?.value || 'https://assets.yourdomain.com';
// Count total images
const countRes = await db.prepare("SELECT count(*) as total FROM attachments WHERE mime_type LIKE 'image/%'").first();
const countRes = await db
.prepare("SELECT count(*) as total FROM attachments WHERE mime_type LIKE 'image/%'")
.first();
const total = (countRes?.total as number) || 0;
// Fetch paginated images
const { results } = await db.prepare(`
const { results } = await db
.prepare(
`
SELECT id, filename, key, mime_type, size, created_at
FROM attachments
WHERE mime_type LIKE 'image/%'
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`).bind(limit, offset).all();
`
)
.bind(limit, offset)
.all();
const images = results.map(r => ({
const images = results.map((r) => ({
...r,
url: `${r2Domain}/${r.key}`
}));
return json({
success: true,
return json({
success: true,
images,
pagination: {
total,
@@ -43,9 +55,13 @@ export const GET: RequestHandler = async ({ url, platform }) => {
totalPages: Math.ceil(total / limit)
}
});
} catch (err: any) {
if (err?.message?.includes('no such table')) {
return json({ success: true, images: [], pagination: { total: 0, page: 1, limit, totalPages: 0 } });
} catch (err: unknown) {
if ((err as any)?.message?.includes('no such table')) {
return json({
success: true,
images: [],
pagination: { total: 0, page: 1, limit, totalPages: 0 }
});
}
console.error('Error fetching images:', err);
return json({ error: '读取图片列表失败' }, { status: 500 });
+49 -24
View File
@@ -1,57 +1,79 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unused-vars */
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
async function ensureCategoriesTable(db: any) {
try {
await db.prepare(`
await db
.prepare(
`
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE
)
`).run();
`
)
.run();
try {
await db.prepare('ALTER TABLE posts ADD COLUMN category_id INTEGER').run();
} catch {
} catch (err: any) {
// Column might already exist
}
} catch (e) {
console.error('Failed to ensure categories table:', e);
} catch (err: any) {
console.error('Failed to ensure categories table:', err);
}
}
export const load: PageServerLoad = async ({ platform }) => {
const db = platform?.env?.DB;
const db = platform?.env?.DB as any;
if (!db) {
return { categories: [] };
}
try {
const { results } = await db.prepare(
`SELECT c.id, c.name, c.slug, COUNT(p.id) as post_count
const { results } = await db
.prepare(
`SELECT c.id, c.name, c.slug, COUNT(p.id) as post_count
FROM categories c
LEFT JOIN posts p ON c.id = p.category_id
GROUP BY c.id, c.name, c.slug
ORDER BY c.name ASC`
).all();
)
.all();
return {
categories: results as unknown as { id: number; name: string; slug: string; post_count: number }[]
categories: results as unknown as {
id: number;
name: string;
slug: string;
post_count: number;
}[]
};
} catch (err: any) {
if (err?.message?.includes('no such table')) {
} catch (err: unknown) {
if ((err as any)?.message?.includes('no such table')) {
await ensureCategoriesTable(db);
try {
const { results } = await db.prepare(
`SELECT c.id, c.name, c.slug, COUNT(p.id) as post_count
const { results } = await db
.prepare(
`SELECT c.id, c.name, c.slug, COUNT(p.id) as post_count
FROM categories c
LEFT JOIN posts p ON c.id = p.category_id
GROUP BY c.id, c.name, c.slug
ORDER BY c.name ASC`
).all();
return { categories: results as unknown as { id: number; name: string; slug: string; post_count: number }[] };
} catch (e) {
)
.all();
return {
categories: results as unknown as {
id: number;
name: string;
slug: string;
post_count: number;
}[]
};
} catch (err: any) {
return { categories: [] };
}
}
@@ -62,7 +84,7 @@ export const load: PageServerLoad = async ({ platform }) => {
export const actions: Actions = {
create: async ({ request, platform }) => {
const db = platform?.env?.DB;
const db = platform?.env?.DB as any;
if (!db) return fail(500, { error: '数据库未连接' });
const formData = await request.formData();
@@ -80,18 +102,21 @@ export const actions: Actions = {
try {
await db.prepare('INSERT INTO categories (name, slug) VALUES (?, ?)').bind(name, slug).run();
return { success: true };
} catch (err: any) {
if (err?.message?.includes('no such table')) {
} catch (err: unknown) {
if ((err as any)?.message?.includes('no such table')) {
await ensureCategoriesTable(db);
try {
await db.prepare('INSERT INTO categories (name, slug) VALUES (?, ?)').bind(name, slug).run();
await db
.prepare('INSERT INTO categories (name, slug) VALUES (?, ?)')
.bind(name, slug)
.run();
return { success: true };
} catch (e: any) {
return fail(500, { error: '创建分类失败: ' + e.message });
}
}
console.error('Error creating category:', err);
if (err?.message?.includes('UNIQUE constraint failed')) {
if ((err as any)?.message?.includes('UNIQUE constraint failed')) {
return fail(400, { error: '分类名称或 URL 别名 (Slug) 已存在' });
}
return fail(500, { error: '创建分类失败' });
@@ -99,7 +124,7 @@ export const actions: Actions = {
},
delete: async ({ request, platform }) => {
const db = platform?.env?.DB;
const db = platform?.env?.DB as any;
if (!db) return fail(500, { error: '数据库未连接' });
const formData = await request.formData();
@@ -110,7 +135,7 @@ export const actions: Actions = {
try {
await db.prepare('DELETE FROM categories WHERE id = ?').bind(id).run();
return { success: true };
} catch (err) {
} catch (err: any) {
console.error('Error deleting category:', err);
return { success: false, error: '删除分类失败' };
}
+25 -19
View File
@@ -43,13 +43,7 @@
</div>
<div class="form-group">
<label for="slug">别名</label>
<input
type="text"
id="slug"
name="slug"
bind:value={slug}
placeholder="例如: tech"
/>
<input type="text" id="slug" name="slug" bind:value={slug} placeholder="例如: tech" />
</div>
<button type="submit" class="btn btn-primary">创建</button>
</form>
@@ -73,16 +67,26 @@
</tr>
</thead>
<tbody>
{#each data.categories as category}
{#each data.categories as category (category.id || category)}
<tr>
<td>{category.id}</td>
<td><strong>{category.name}</strong></td>
<td><span class="badge">{category.slug}</span></td>
<td><span class="badge">{category.post_count} 篇文章</span></td>
<td>
<form action="?/delete" method="POST" class="inline-form" onsubmit={(e) => {
if(!confirm(`确定要删除${$page.data.aliases?.category || '分类'} "${category.name}" 吗?删除后相关文章将归为未${$page.data.aliases?.category || '分类'}。`)) e.preventDefault();
}}>
<form
action="?/delete"
method="POST"
class="inline-form"
onsubmit={(e) => {
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>
</form>
@@ -159,7 +163,7 @@
letter-spacing: 0.5px;
}
.form-group input[type="text"] {
.form-group input[type='text'] {
width: 100%;
padding: 10px 0;
border: none;
@@ -172,7 +176,7 @@
transition: border-bottom-color 0.3s ease;
}
.form-group input[type="text"]:focus {
.form-group input[type='text']:focus {
outline: none;
border-bottom-color: var(--accent-color, #419b38);
}
@@ -217,9 +221,10 @@
text-align: left;
}
.data-table th, .data-table td {
.data-table th,
.data-table td {
padding: 14px 18px;
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));
}
.data-table th {
@@ -235,10 +240,9 @@
}
.data-table tbody tr:hover {
background-color: rgba(0,0,0,0.02);
background-color: rgba(0, 0, 0, 0.02);
}
.inline-form {
display: inline-block;
margin: 0;
@@ -260,7 +264,9 @@
color: var(--text-color);
cursor: pointer;
text-decoration: none;
transition: color 0.2s, text-decoration-color 0.2s;
transition:
color 0.2s,
text-decoration-color 0.2s;
}
.action-btn.delete:hover {
@@ -272,6 +278,6 @@
.empty-state {
padding: 50px 20px;
text-align: center;
color: var(--text-muted, rgba(0,0,0,0.6));
color: var(--text-muted, rgba(0, 0, 0, 0.6));
}
</style>
+15 -8
View File
@@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { PageServerLoad } from './$types';
interface PageListRow {
@@ -17,13 +19,15 @@ export const load: PageServerLoad = async ({ platform }) => {
}
try {
const queryRes = await db.prepare(
`SELECT p.id, p.title, p.slug, p.published_at as date, u.username as author
const queryRes = await db
.prepare(
`SELECT p.id, p.title, p.slug, p.published_at as date, u.username as author
FROM pages p
LEFT JOIN users u ON p.author_id = u.id
ORDER BY p.published_at DESC`
).all<PageListRow>();
)
.all<PageListRow>();
const results = queryRes.results;
// Format date for display
@@ -32,7 +36,7 @@ export const load: PageServerLoad = async ({ platform }) => {
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return {
...page,
dateStr: `${year}-${month}-${day}`
@@ -42,7 +46,7 @@ export const load: PageServerLoad = async ({ platform }) => {
return {
pages: formattedPages
};
} catch (err) {
} catch (err: any) {
console.error('Error fetching admin pages list:', err);
return { pages: [] };
}
@@ -60,9 +64,12 @@ export const actions = {
try {
const placeholders = ids.map(() => '?').join(',');
await db.prepare(`DELETE FROM pages WHERE id IN (${placeholders})`).bind(...ids).run();
await db
.prepare(`DELETE FROM pages WHERE id IN (${placeholders})`)
.bind(...ids)
.run();
return { success: true };
} catch (err) {
} catch (err: any) {
console.error('Error deleting page:', err);
return { success: false, error: 'Failed to delete page' };
}
+76 -41
View File
@@ -1,48 +1,59 @@
<script lang="ts">
import { resolve } from '$app/paths';
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.pages.map(p => p.id.toString());
selectedIds = data.pages.map((p) => p.id.toString());
} else {
selectedIds = [];
}
}
let isAllSelected = $derived(data.pages.length > 0 && selectedIds.length === data.pages.length);
</script>
<div class="admin-page-header">
<h2>{$page.data.aliases?.page || '页面'}管理</h2>
<a href="/admin/pages/new" class="btn btn-primary">新建{$page.data.aliases?.page || '页面'}</a>
<a href={resolve('/admin/pages/new')} class="btn btn-primary"
>新建{$page.data.aliases?.page || '页面'}</a
>
</div>
{#if data.pages.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?.page || '项目'}吗?操作不可恢复。`)) {
{#if data.pages.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?.page || '项目'}吗?操作不可恢复。`
)
) {
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}
}}
>
{#each selectedIds as id (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.pages.length === 0}
<div class="empty-state">
@@ -60,26 +71,52 @@
<th>别名 (Slug)</th>
<th>作者</th>
<th>发布时间</th>
</tr>
</thead>
<tbody>
{#each data.pages as pageItem}
{#each data.pages as pageItem (pageItem.id || pageItem)}
<tr>
<td class="checkbox-col">
<input type="checkbox" name="ids" value={pageItem.id.toString()} bind:group={selectedIds} />
<input
type="checkbox"
name="ids"
value={pageItem.id.toString()}
bind:group={selectedIds}
/>
</td>
<td>{pageItem.id}</td>
<td>
<a href="/admin/pages/{pageItem.id}" class="title-link"><strong>{pageItem.title}</strong></a>
<a href="/{pageItem.slug}" target="_blank" class="preview-link" title="前台预览">
<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 href={resolve('/admin/pages/{pageItem.id}')} class="title-link"
><strong>{pageItem.title}</strong></a
>
<a
href={resolve('/{pageItem.slug}')}
target="_blank"
class="preview-link"
title="前台预览"
>
<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">{pageItem.slug}</span></td>
<td>{pageItem.author || '未知'}</td>
<td>{pageItem.dateStr}</td>
</tr>
{/each}
</tbody>
@@ -140,9 +177,10 @@
text-align: left;
}
.data-table th, .data-table td {
.data-table th,
.data-table td {
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));
}
.data-table th {
@@ -158,7 +196,7 @@
}
.data-table tbody tr:hover {
background-color: rgba(0,0,0,0.02);
background-color: rgba(0, 0, 0, 0.02);
}
.preview-link {
@@ -174,12 +212,10 @@
opacity: 1;
}
.empty-state {
padding: 50px 20px;
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 {
@@ -188,7 +224,7 @@
gap: 15px;
margin-bottom: 15px;
padding: 10px 15px;
background: rgba(0,0,0,0.02);
background: rgba(0, 0, 0, 0.02);
height: 54px;
box-sizing: border-box;
}
@@ -200,7 +236,7 @@
}
.selected-count.empty {
color: var(--text-muted, rgba(0,0,0,0.4));
color: var(--text-muted, rgba(0, 0, 0, 0.4));
font-weight: normal;
}
@@ -209,7 +245,7 @@
text-align: center;
}
.checkbox-col input[type="checkbox"] {
.checkbox-col input[type='checkbox'] {
width: 16px;
height: 16px;
accent-color: var(--accent-color, #419b38);
@@ -238,10 +274,9 @@
background-color: #d9534f;
color: white;
}
.btn-sm {
padding: 4px 12px;
font-size: 0.85rem;
}
</style>
+16 -10
View File
@@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
@@ -10,9 +12,10 @@ export const load: PageServerLoad = async ({ params, platform }) => {
}
try {
const page = await db.prepare(
'SELECT id, title, slug, content FROM pages WHERE id = ?'
).bind(pageId).first<{ id: number; title: string; slug: string; content: string; }>();
const page = await db
.prepare('SELECT id, title, slug, content FROM pages WHERE id = ?')
.bind(pageId)
.first<{ id: number; title: string; slug: string; content: string }>();
if (!page) {
throw error(404, '页面不存在');
@@ -21,8 +24,8 @@ export const load: PageServerLoad = async ({ params, platform }) => {
return {
page
};
} catch (err: any) {
if (err?.status === 404) throw err;
} catch (err: unknown) {
if ((err as any)?.status === 404) throw err;
console.error('Error loading page for edit:', err);
throw error(500, '读取页面详情失败');
}
@@ -45,14 +48,17 @@ export const actions: Actions = {
}
try {
await db.prepare(
`UPDATE pages
await db
.prepare(
`UPDATE pages
SET title = ?, slug = ?, content = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`
).bind(title, slug, content, pageId).run();
} catch (err: any) {
)
.bind(title, slug, content, pageId)
.run();
} catch (err: unknown) {
console.error('Error updating page:', err);
if (err?.message?.includes('UNIQUE constraint failed')) {
if ((err as any)?.message?.includes('UNIQUE constraint failed')) {
return fail(400, { error: 'URL 别名 (Slug) 已被其他页面占用' });
}
return fail(500, { error: '保存修改失败' });
+31 -14
View File
@@ -1,21 +1,29 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { page } from '$app/stores';
import type { PageData, ActionData } from './$types';
import MarkdownRenderer from '$lib/components/MarkdownRenderer.svelte';
import { untrack } from 'svelte';
let { data, form }: { data: PageData; form: ActionData } = $props();
const initialData = $state.snapshot(untrack(() => data));
let title = $state(data.page.title);
let slug = $state(data.page.slug);
let content = $state(data.page.content);
let title = $state(initialData.page.title);
let slug = $state(initialData.page.slug);
let content = $state(initialData.page.content);
let mode = $state<'edit' | 'preview'>('edit');
</script>
<div class="admin-page-header">
<h2>编辑{$page.data.aliases?.page || '页面'}</h2>
<div class="header-actions">
<a href="/{data.page.slug}" target="_blank" class="btn btn-secondary" style="margin-right: 12px;">前台预览</a>
<a href="/admin/pages" class="btn btn-secondary">&larr; 返回</a>
<a
href={resolve('/{data.page.slug}')}
target="_blank"
class="btn btn-secondary"
style="margin-right: 12px;">前台预览</a
>
<a href={resolve('/admin/pages')} class="btn btn-secondary">&larr; 返回</a>
</div>
</div>
@@ -44,11 +52,19 @@
<div class="editor-header">
<label for="content">正文 *</label>
<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>
<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 class="editor-body">
{#if mode === 'edit'}
<textarea
@@ -58,8 +74,7 @@
rows="16"
style="min-height: 400px;"
placeholder="支持 Markdown 格式..."
required
></textarea>
required></textarea>
{:else}
<div class="preview-area">
<MarkdownRenderer {content} />
@@ -222,7 +237,7 @@
.form-grid {
grid-template-columns: 1fr;
}
.form-sidebar {
position: static;
flex-direction: row;
@@ -245,7 +260,7 @@
letter-spacing: 0.5px;
}
.form-group input[type="text"],
.form-group input[type='text'],
.form-group textarea {
width: 100%;
padding: 12px 14px;
@@ -256,7 +271,9 @@
font-size: 1.05rem;
color: var(--text-color);
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 {
@@ -265,7 +282,7 @@
min-height: 120px;
}
.form-group input[type="text"]:focus,
.form-group input[type='text']:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent-color, #419b38);
+10 -5
View File
@@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
@@ -20,13 +22,16 @@ export const actions: Actions = {
}
try {
await db.prepare(
`INSERT INTO pages (title, slug, content, author_id, published_at, updated_at)
await db
.prepare(
`INSERT INTO pages (title, slug, content, author_id, published_at, updated_at)
VALUES (?, ?, ?, 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`
).bind(title, slug, content).run();
} catch (err: any) {
)
.bind(title, slug, content)
.run();
} catch (err: unknown) {
console.error('Error creating page:', err);
if (err?.message?.includes('UNIQUE constraint failed')) {
if ((err as any)?.message?.includes('UNIQUE constraint failed')) {
return fail(400, { error: 'URL 别名 (Slug) 已被占用,请使用其他别名' });
}
return fail(500, { error: '保存页面失败' });
+20 -10
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { page } from '$app/stores';
import type { ActionData } from './$types';
import MarkdownRenderer from '$lib/components/MarkdownRenderer.svelte';
@@ -23,7 +24,7 @@
<div class="admin-page-header">
<h2>新建独立{$page.data.aliases?.page || '页面'}</h2>
<a href="/admin/pages" class="btn btn-secondary">&larr; 返回</a>
<a href={resolve('/admin/pages')} class="btn btn-secondary">&larr; 返回</a>
</div>
{#if form?.error}
@@ -52,11 +53,19 @@
<div class="editor-header">
<label for="content">正文 *</label>
<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>
<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 class="editor-body">
{#if mode === 'edit'}
<textarea
@@ -66,8 +75,7 @@
rows="16"
style="min-height: 400px;"
placeholder="支持 Markdown 格式..."
required
></textarea>
required></textarea>
{:else}
<div class="preview-area">
<MarkdownRenderer {content} />
@@ -225,7 +233,7 @@
.form-grid {
grid-template-columns: 1fr;
}
.form-sidebar {
position: static;
flex-direction: row;
@@ -248,7 +256,7 @@
letter-spacing: 0.5px;
}
.form-group input[type="text"],
.form-group input[type='text'],
.form-group textarea {
width: 100%;
padding: 12px 14px;
@@ -259,7 +267,9 @@
font-size: 1.05rem;
color: var(--text-color);
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 {
@@ -268,7 +278,7 @@
min-height: 120px;
}
.form-group input[type="text"]:focus,
.form-group input[type='text']:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent-color, #419b38);
+21 -12
View File
@@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unused-vars */
import type { PageServerLoad } from './$types';
interface PostListRow {
@@ -21,25 +23,29 @@ export const load: PageServerLoad = async ({ platform }) => {
try {
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,
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, s.name as series
FROM posts p
LEFT JOIN users u ON p.author_id = u.id
LEFT JOIN categories c ON p.category_id = c.id
LEFT JOIN series s ON p.series_id = s.id
ORDER BY p.published_at DESC`
).all<PostListRow>();
)
.all<PostListRow>();
results = queryRes.results;
} catch (e) {
} catch (err: any) {
// Fallback query if categories table or category_id column is missing
const queryRes = await db.prepare(
`SELECT p.id, p.title, p.slug, p.published_at as date, u.username as author
const queryRes = await db
.prepare(
`SELECT p.id, p.title, p.slug, p.published_at as date, u.username as author
FROM posts p
LEFT JOIN users u ON p.author_id = u.id
ORDER BY p.published_at DESC`
).all<PostListRow>();
results = queryRes.results.map(r => ({ ...r, category: '未分类', series: '' }));
)
.all<PostListRow>();
results = queryRes.results.map((r) => ({ ...r, category: '未分类', series: '' }));
}
// Format date for display
@@ -48,7 +54,7 @@ export const load: PageServerLoad = async ({ platform }) => {
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return {
...post,
dateStr: `${year}-${month}-${day}`
@@ -58,7 +64,7 @@ export const load: PageServerLoad = async ({ platform }) => {
return {
posts: formattedPosts
};
} catch (err) {
} catch (err: any) {
console.error('Error fetching admin posts list:', err);
return { posts: [] };
}
@@ -76,9 +82,12 @@ export const actions = {
try {
const placeholders = ids.map(() => '?').join(',');
await db.prepare(`DELETE FROM posts WHERE id IN (${placeholders})`).bind(...ids).run();
await db
.prepare(`DELETE FROM posts WHERE id IN (${placeholders})`)
.bind(...ids)
.run();
return { success: true };
} catch (err) {
} catch (err: any) {
console.error('Error deleting post:', err);
return { success: false, error: 'Failed to delete post' };
}
+78 -43
View File
@@ -1,48 +1,59 @@
<script lang="ts">
import { resolve } from '$app/paths';
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());
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>{$page.data.aliases?.post || '文章'}管理</h2>
<a href="/admin/posts/new" class="btn btn-primary">发布新{$page.data.aliases?.post || '文章'}</a>
<a href={resolve('/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 || '项目'}吗?操作不可恢复。`)) {
{#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}
}}
>
{#each selectedIds as id (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">
@@ -62,28 +73,54 @@
<th>别名 (Slug)</th>
<th>作者</th>
<th>发布时间</th>
</tr>
</thead>
<tbody>
{#each data.posts as post}
{#each data.posts as post (post.id || post)}
<tr>
<td class="checkbox-col">
<input type="checkbox" name="ids" value={post.id.toString()} bind:group={selectedIds} />
<input
type="checkbox"
name="ids"
value={post.id.toString()}
bind:group={selectedIds}
/>
</td>
<td>{post.id}</td>
<td>
<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="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 href={resolve('/admin/posts/{post.id}')} class="title-link"
><strong>{post.title}</strong></a
>
<a
href={resolve('/posts/{post.slug}')}
target="_blank"
class="preview-link"
title="前台预览"
>
<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>{post.series || '—'}</td>
<td><span class="badge">{post.category || '未分类'}</span></td>
<td>{post.series || '—'}</td>
<td><span class="badge">{post.slug}</span></td>
<td>{post.author || '未知'}</td>
<td>{post.dateStr}</td>
</tr>
{/each}
</tbody>
@@ -144,9 +181,10 @@
text-align: left;
}
.data-table th, .data-table td {
.data-table th,
.data-table td {
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));
}
.data-table th {
@@ -162,7 +200,7 @@
}
.data-table tbody tr:hover {
background-color: rgba(0,0,0,0.02);
background-color: rgba(0, 0, 0, 0.02);
}
.preview-link {
@@ -178,12 +216,10 @@
opacity: 1;
}
.empty-state {
padding: 50px 20px;
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 {
@@ -192,7 +228,7 @@
gap: 15px;
margin-bottom: 15px;
padding: 10px 15px;
background: rgba(0,0,0,0.02);
background: rgba(0, 0, 0, 0.02);
height: 54px;
box-sizing: border-box;
}
@@ -204,7 +240,7 @@
}
.selected-count.empty {
color: var(--text-muted, rgba(0,0,0,0.4));
color: var(--text-muted, rgba(0, 0, 0, 0.4));
font-weight: normal;
}
@@ -213,7 +249,7 @@
text-align: center;
}
.checkbox-col input[type="checkbox"] {
.checkbox-col input[type='checkbox'] {
width: 16px;
height: 16px;
accent-color: var(--accent-color, #419b38);
@@ -242,10 +278,9 @@
background-color: #d9534f;
color: white;
}
.btn-sm {
padding: 4px 12px;
font-size: 0.85rem;
}
</style>
+82 -24
View File
@@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { error, fail, isHttpError, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import {
@@ -16,19 +18,42 @@ export const load: PageServerLoad = async ({ params, platform }) => {
}
try {
const post = await db.prepare(
'SELECT id, title, slug, content, cover, category_id, series_id, series_weight, published_at, updated_at FROM posts WHERE id = ?'
).bind(postId).first<{ id: number; title: string; slug: string; content: string; cover: string | null; category_id: number | null; series_id: number | null; series_weight: number | null; published_at?: string; updated_at?: string }>();
const post = await db
.prepare(
'SELECT id, title, slug, content, cover, category_id, series_id, series_weight, published_at, updated_at FROM posts WHERE id = ?'
)
.bind(postId)
.first<{
id: number;
title: string;
slug: string;
content: string;
cover: string | null;
category_id: number | null;
series_id: number | null;
series_weight: number | null;
published_at?: string;
updated_at?: string;
}>();
if (!post) {
throw error(404, '文章不存在');
}
const { results: allTags } = await db.prepare('SELECT id, name FROM tags ORDER BY name ASC').all();
const { results: allCategories } = await db.prepare('SELECT id, name FROM categories ORDER BY name ASC').all();
const { results: allSeries } = await db.prepare('SELECT id, name, slug FROM series ORDER BY name COLLATE NOCASE ASC').all();
const { results: allTags } = await db
.prepare('SELECT id, name FROM tags ORDER BY name ASC')
.all();
const { results: allCategories } = await db
.prepare('SELECT id, name FROM categories ORDER BY name ASC')
.all();
const { results: allSeries } = await db
.prepare('SELECT id, name, slug FROM series ORDER BY name COLLATE NOCASE ASC')
.all();
const { results: selectedTagRows } = await db.prepare('SELECT tag_id FROM post_tags WHERE post_id = ?').bind(postId).all<{ tag_id: number }>();
const { results: selectedTagRows } = await db
.prepare('SELECT tag_id FROM post_tags WHERE post_id = ?')
.bind(postId)
.all<{ tag_id: number }>();
const selectedTagIds = selectedTagRows.map((row) => row.tag_id);
return {
@@ -58,7 +83,7 @@ export const actions: Actions = {
const content = formData.get('content')?.toString() || '';
const cover = formData.get('cover')?.toString().trim() || 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));
const publishedAt = formData.get('published_at')?.toString().trim();
const updatedAt = formData.get('updated_at')?.toString().trim() || new Date().toISOString();
@@ -76,35 +101,68 @@ export const actions: Actions = {
parseSeriesWeight(formData.get('series_weight')),
postId
);
} catch (err) {
} catch (err: any) {
if (err instanceof SeriesValidationError) return fail(400, { error: err.message });
throw err;
}
try {
// 更新文章表
// 改为 D1 batch 事务批量更新正文、清除老标签并原子绑定新标签
const stmts = [];
if (publishedAt) {
await db.prepare(
`UPDATE posts
SET title = ?, slug = ?, content = ?, cover = ?, category_id = ?, series_id = ?, series_weight = ?, published_at = ?, updated_at = ?
WHERE id = ?`
).bind(title, slug, content, cover, categoryId, seriesId, seriesWeight, publishedAt, updatedAt, postId).run();
stmts.push(
db
.prepare(
`UPDATE posts
SET title = ?, slug = ?, content = ?, cover = ?, category_id = ?, series_id = ?, series_weight = ?, published_at = ?, updated_at = ?
WHERE id = ?`
)
.bind(
title,
slug,
content,
cover,
categoryId,
seriesId,
seriesWeight,
publishedAt,
updatedAt,
postId
)
);
} else {
await db.prepare(
`UPDATE posts
SET title = ?, slug = ?, content = ?, cover = ?, category_id = ?, series_id = ?, series_weight = ?, updated_at = ?
WHERE id = ?`
).bind(title, slug, content, cover, categoryId, seriesId, seriesWeight, updatedAt, postId).run();
stmts.push(
db
.prepare(
`UPDATE posts
SET title = ?, slug = ?, content = ?, cover = ?, category_id = ?, series_id = ?, series_weight = ?, updated_at = ?
WHERE id = ?`
)
.bind(
title,
slug,
content,
cover,
categoryId,
seriesId,
seriesWeight,
updatedAt,
postId
)
);
}
// 清除原有标签并重新绑定
await db.prepare('DELETE FROM post_tags WHERE post_id = ?').bind(postId).run();
stmts.push(db.prepare('DELETE FROM post_tags WHERE post_id = ?').bind(postId));
if (selectedTags.length > 0) {
for (const tagId of selectedTags) {
await db.prepare('INSERT INTO post_tags (post_id, tag_id) VALUES (?, ?)').bind(postId, tagId).run();
stmts.push(
db.prepare('INSERT INTO post_tags (post_id, tag_id) VALUES (?, ?)').bind(postId, tagId)
);
}
}
await db.batch(stmts);
} catch (err: unknown) {
console.error('Error updating post:', err);
if (err instanceof Error && err.message.includes('UNIQUE constraint failed')) {
+276 -104
View File
@@ -1,4 +1,7 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unused-vars */
import { resolve } from '$app/paths';
import { page } from '$app/stores';
import type { PageData, ActionData } from './$types';
import { deserialize } from '$app/forms';
@@ -6,27 +9,29 @@
import FeaturedImagePicker from '$lib/components/admin/FeaturedImagePicker.svelte';
import SelectDropdown from '$lib/components/admin/SelectDropdown.svelte';
import { parseHugoMarkdown } from '$lib/hugoParser';
import { untrack } from 'svelte';
let { data, form }: { data: PageData; form: ActionData } = $props();
const initialData = $state.snapshot(untrack(() => data));
let title = $state(data.post.title);
let slug = $state(data.post.slug);
let content = $state(data.post.content || '');
let cover = $state(data.post.cover || '');
let title = $state(initialData.post.title);
let slug = $state(initialData.post.slug);
let content = $state(initialData.post.content || '');
let cover = $state(initialData.post.cover || '');
let mode = $state<'edit' | 'preview'>('edit');
let publishedAt = $state(data.post.published_at || '');
let updatedAt = $state(data.post.updated_at || '');
let selectedCategoryId = $state<number | string>(data.post.category_id ?? '');
let selectedSeriesId = $state<number | string>(data.post.series_id ?? '');
let seriesWeight = $state<number | string>(data.post.series_weight ?? '');
let publishedAt = $state(initialData.post.published_at || '');
let updatedAt = $state(initialData.post.updated_at || '');
let selectedCategoryId = $state<number | string>(initialData.post.category_id ?? '');
let selectedSeriesId = $state<number | string>(initialData.post.series_id ?? '');
let seriesWeight = $state<number | string>(initialData.post.series_weight ?? '');
let fileInput: HTMLInputElement;
let selectedTags = $state<number[]>(data.selectedTagIds || []);
let tagsList = $state(data.tags || []);
let seriesList = $state(data.series || []);
let selectedTags = $state<number[]>(initialData.selectedTagIds || []);
let tagsList = $state(initialData.tags || []);
let seriesList = $state(initialData.series || []);
function toggleTag(tagId: number) {
if (selectedTags.includes(tagId)) {
selectedTags = selectedTags.filter(id => id !== tagId);
selectedTags = selectedTags.filter((id) => id !== tagId);
} else {
selectedTags = [...selectedTags, tagId];
}
@@ -40,13 +45,13 @@
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));
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));
return available.filter((t) => t.name.toLowerCase().includes(input));
})()
);
@@ -71,7 +76,7 @@
try {
const formData = new FormData();
formData.append('name', val);
const res = await fetch('/admin/tags?/create', {
method: 'POST',
body: formData,
@@ -80,15 +85,15 @@
}
});
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);
} catch (err: any) {
console.error('Failed to create tag', err);
} finally {
isAddingTag = false;
newTagInput = '';
@@ -105,18 +110,21 @@
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = textarea.value.substring(start, end);
const insertion = selectedText ? (before + selectedText + after) : (before + defaultText + after);
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);
textarea.setSelectionRange(
start + before.length,
start + before.length + defaultText.length
);
}
}, 0);
}
@@ -124,7 +132,7 @@
function insertMore() {
insertAtCursor('\n<!--more-->\n', '', '');
}
let showAdmonitionMenu = $state(false);
const admonitionTypes = [
{ type: 'note', title: '注意', label: '注意 (Note)' },
@@ -167,7 +175,7 @@
// 1. 分类处理:遭遇未知新分类名称时跳过,保留由用户抉择或处理
if (imported.categories.length > 0) {
const targetName = imported.categories[0].trim().toLowerCase();
const matchedCat = data.categories.find(c => c.name.trim().toLowerCase() === targetName);
const matchedCat = data.categories.find((c) => c.name.trim().toLowerCase() === targetName);
if (matchedCat) {
selectedCategoryId = matchedCat.id;
}
@@ -177,11 +185,15 @@
seriesWeight = imported.series_weight ?? '';
if (imported.series.length > 0) {
if (imported.series.length > 1) {
alert(`检测到多个系列:${imported.series.join('、')}。BlogFlare 每篇文章只能属于一个系列,本次将选择第一个系列。`);
alert(
`检测到多个系列:${imported.series.join('、')}。BlogFlare 每篇文章只能属于一个系列,本次将选择第一个系列。`
);
}
const seriesName = imported.series[0].trim();
let matchedSeries = seriesList.find(item => item.name.trim().toLowerCase() === seriesName.toLowerCase());
let matchedSeries = seriesList.find(
(item) => item.name.trim().toLowerCase() === seriesName.toLowerCase()
);
if (!matchedSeries && seriesName) {
const formData = new FormData();
formData.append('name', seriesName);
@@ -190,13 +202,21 @@
body: formData,
headers: { 'x-sveltekit-action': 'true' }
});
const actionResult = deserialize(await response.text()) as any;
if (actionResult.type === 'success' && actionResult.data?.series) {
const createdSeries = actionResult.data.series as { id: number; name: string; slug: string };
const createdSeries = actionResult.data.series as {
id: number;
name: string;
slug: string;
};
matchedSeries = createdSeries;
seriesList = [...seriesList, createdSeries];
} else {
alert(actionResult.data?.error || `无法创建系列“${seriesName}”,请在系列管理中检查名称和 URL 别名。`);
alert(
actionResult.data?.error ||
`无法创建系列“${seriesName}”,请在系列管理中检查名称和 URL 别名。`
);
}
}
if (matchedSeries) selectedSeriesId = matchedSeries.id;
@@ -206,7 +226,7 @@
for (const tagName of imported.tags) {
const val = tagName.trim();
if (!val) continue;
const existingTag = tagsList.find(t => t.name.toLowerCase() === val.toLowerCase());
const existingTag = tagsList.find((t) => t.name.toLowerCase() === val.toLowerCase());
if (existingTag) {
if (!selectedTags.includes(existingTag.id)) {
selectedTags = [...selectedTags, existingTag.id];
@@ -220,33 +240,41 @@
body: formData,
headers: { 'x-sveltekit-action': 'true' }
});
const actionRes = deserialize(await res.text()) as any;
if (actionRes.type === 'success' && actionRes.data?.tag) {
tagsList = [...tagsList, actionRes.data.tag];
selectedTags = [...selectedTags, actionRes.data.tag.id];
}
} catch(e) {
console.error('自动拓展建构新标签错付:', e);
} catch (err: any) {
console.error('自动拓展建构新标签错付:', err);
}
}
}
target.value = '';
} catch (err) {
console.error('导入报错:', err);
} catch (err: any) {
console.error('导入报错:');
alert('文档内部解析受断,核验是否带有完整及恰当规范的内容格式');
}
}
</script>
<div class="admin-page-header">
<h2>编辑</h2>
<div class="header-actions">
<button type="button" class="btn btn-secondary" onclick={() => fileInput?.click()}>导入 Markdown</button>
<a href="/admin/posts" class="btn btn-secondary">&larr; 返回</a>
<button type="button" class="btn btn-secondary" onclick={() => fileInput?.click()}
>导入 Markdown</button
>
<a href={resolve('/admin/posts')} class="btn btn-secondary">&larr; 返回</a>
</div>
</div>
<input type="file" bind:this={fileInput} accept=".md,.markdown,.txt" style="display:none" onchange={handleImportMarkdown} />
<input
type="file"
bind:this={fileInput}
accept=".md,.markdown,.txt"
style="display:none"
onchange={handleImportMarkdown}
/>
{#if form?.error}
<div class="error-alert">
@@ -275,43 +303,164 @@
<div class="form-group">
<div class="editor-header">
<label for="content">正文 *</label>
<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('![', '](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 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('![', '](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 (item.type)}
<button
type="button"
class="dropdown-item"
onclick={() => insertAdmonition(item.type, item.title)}
>
{item.label}
</button>
{/each}
</div>
{/if}
</div>
{/if}
<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>
<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>
<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>
@@ -325,8 +474,7 @@
rows="16"
style="min-height: 400px;"
placeholder="支持 Markdown 格式..."
required
></textarea>
required></textarea>
{:else}
<div class="preview-area">
<MarkdownRenderer {content} />
@@ -362,7 +510,10 @@
<SelectDropdown
name="category_id"
label="分类"
options={data.categories.map((category) => ({ value: category.id, label: category.name }))}
options={data.categories.map((category) => ({
value: category.id,
label: category.name
}))}
bind:value={selectedCategoryId}
/>
</div>
@@ -389,12 +540,29 @@
<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)}
{#each selectedTags as tagId (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>
<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}
@@ -408,22 +576,26 @@
placeholder="+ 添加标签..."
bind:value={newTagInput}
onkeydown={handleAddTag}
onfocus={() => showTagDropdown = true}
onblur={() => setTimeout(() => showTagDropdown = false, 200)}
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;
}}>
{#each filteredTags as tag (tag.id || 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}
@@ -431,7 +603,6 @@
{/if}
</div>
</div>
</div>
</div>
</form>
@@ -554,7 +725,6 @@
overflow-y: auto;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 280px;
@@ -580,7 +750,7 @@
.form-grid {
grid-template-columns: 1fr;
}
.form-sidebar {
position: static;
flex-direction: row;
@@ -604,9 +774,8 @@
text-transform: uppercase;
}
.form-group input[type="text"],
.form-group input[type='text'],
.form-group textarea {
width: 100%;
padding: 12px 14px;
border: 1px solid rgba(0, 0, 0, 0.12);
@@ -616,7 +785,9 @@
font-size: 1.05rem;
color: var(--text-color);
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 {
@@ -625,7 +796,7 @@
min-height: 120px;
}
.form-group input[type="text"]:focus,
.form-group input[type='text']:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent-color, #419b38);
@@ -704,7 +875,7 @@
opacity: 0.7;
padding: 0 4px;
}
.remove-tag-btn:hover {
opacity: 1;
color: #d9534f;
@@ -733,7 +904,7 @@
.tag-dropdown-item {
background: transparent;
border: none;
border-bottom: 1px solid rgba(0,0,0,0.1);
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
padding: 8px 12px;
text-align: left;
font-family: inherit;
@@ -753,7 +924,6 @@
color: var(--bg-color);
}
.editor-header-right {
display: flex;
align-items: center;
@@ -785,7 +955,7 @@
.md-btn:hover {
opacity: 1;
color: var(--accent-color, #419b38);
background-color: rgba(0,0,0,0.03);
background-color: rgba(0, 0, 0, 0.03);
}
.md-btn svg {
@@ -813,7 +983,10 @@
.dropdown-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 99;
}
@@ -834,7 +1007,7 @@
.dropdown-item {
background: transparent;
border: none;
border-bottom: 1px solid rgba(0,0,0,0.1);
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
padding: 8px 12px;
text-align: left;
font-family: inherit;
@@ -844,7 +1017,7 @@
font-weight: 600;
transition: all 0.2s ease;
}
.dropdown-item:last-child {
border-bottom: none;
}
@@ -853,5 +1026,4 @@
background: var(--text-color);
color: var(--bg-color, #ffffff);
}
</style>
+44 -16
View File
@@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
import {
@@ -15,14 +17,20 @@ export const load: PageServerLoad = async ({ platform }) => {
if (db) {
try {
const { results: tagResults } = await db.prepare('SELECT id, name FROM tags ORDER BY name ASC').all();
const { results: catResults } = await db.prepare('SELECT id, name FROM categories ORDER BY name ASC').all();
const { results: seriesResults } = await db.prepare('SELECT id, name, slug FROM series ORDER BY name COLLATE NOCASE ASC').all();
const { results: tagResults } = await db
.prepare('SELECT id, name FROM tags ORDER BY name ASC')
.all();
const { results: catResults } = await db
.prepare('SELECT id, name FROM categories ORDER BY name ASC')
.all();
const { results: seriesResults } = await db
.prepare('SELECT id, name, slug FROM series ORDER BY name COLLATE NOCASE ASC')
.all();
tags = tagResults as unknown as { id: number; name: string }[];
categories = catResults as unknown as { id: number; name: string }[];
series = seriesResults as unknown as { id: number; name: string; slug: string }[];
} catch (err) {
} catch (err: any) {
console.error('Error fetching tags or categories:', err);
}
}
@@ -40,7 +48,7 @@ export const actions: Actions = {
const content = formData.get('content')?.toString() || '';
const cover = formData.get('cover')?.toString().trim() || 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));
const publishedAt = formData.get('published_at')?.toString().trim() || new Date().toISOString();
const updatedAt = formData.get('updated_at')?.toString().trim() || publishedAt;
@@ -57,25 +65,45 @@ export const actions: Actions = {
seriesId,
parseSeriesWeight(formData.get('series_weight'))
);
} catch (err) {
} catch (err: any) {
if (err instanceof SeriesValidationError) return fail(400, { error: err.message });
throw err;
}
try {
const res = await db.prepare(
`INSERT INTO posts (title, slug, content, cover, author_id, category_id, series_id, series_weight, published_at, updated_at)
VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?, ?)
RETURNING id`
).bind(title, slug, content, cover, categoryId, seriesId, seriesWeight, publishedAt, updatedAt).first<{ id: number }>();
// 将创建新文章及所有绑定标签打包在 D1 原生 batch 中作为原子事务提交,杜绝不完整中间状态
const stmts = [
db
.prepare(
`INSERT INTO posts (title, slug, content, cover, author_id, category_id, series_id, series_weight, published_at, updated_at)
VALUES (?, ?, ?, ?, 1, ?, ?, ?, ?, ?)`
)
.bind(
title,
slug,
content,
cover,
categoryId,
seriesId,
seriesWeight,
publishedAt,
updatedAt
)
];
const postId = res?.id;
if (postId && selectedTags.length > 0) {
if (selectedTags.length > 0) {
for (const tagId of selectedTags) {
await db.prepare('INSERT INTO post_tags (post_id, tag_id) VALUES (?, ?)').bind(postId, tagId).run();
stmts.push(
db
.prepare(
'INSERT INTO post_tags (post_id, tag_id) VALUES ((SELECT id FROM posts WHERE slug = ?), ?)'
)
.bind(slug, tagId)
);
}
}
await db.batch(stmts);
} catch (err: unknown) {
console.error('Error creating post:', err);
if (err instanceof Error && err.message.includes('UNIQUE constraint failed')) {
+270 -95
View File
@@ -1,4 +1,7 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unused-vars */
import { resolve } from '$app/paths';
import { page } from '$app/stores';
import type { PageData, ActionData } from './$types';
import { deserialize } from '$app/forms';
@@ -6,8 +9,10 @@
import FeaturedImagePicker from '$lib/components/admin/FeaturedImagePicker.svelte';
import SelectDropdown from '$lib/components/admin/SelectDropdown.svelte';
import { parseHugoMarkdown } from '$lib/hugoParser';
import { untrack } from 'svelte';
let { data, form }: { data: PageData; form: ActionData } = $props();
const initialData = $state.snapshot(untrack(() => data));
let title = $state('');
let slug = $state('');
@@ -21,8 +26,8 @@
let seriesWeight = $state<string | number>('');
let fileInput: HTMLInputElement;
let tagsList = $state(data.tags || []);
let seriesList = $state(data.series || []);
let tagsList = $state(initialData.tags || []);
let seriesList = $state(initialData.series || []);
// 自动从标题生成 Slug 简易逻辑
function handleTitleInput() {
@@ -37,7 +42,7 @@
let selectedTags = $state<number[]>([]);
function toggleTag(tagId: number) {
if (selectedTags.includes(tagId)) {
selectedTags = selectedTags.filter(id => id !== tagId);
selectedTags = selectedTags.filter((id) => id !== tagId);
} else {
selectedTags = [...selectedTags, tagId];
}
@@ -46,13 +51,13 @@
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));
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));
return available.filter((t) => t.name.toLowerCase().includes(input));
})()
);
@@ -77,7 +82,7 @@
try {
const formData = new FormData();
formData.append('name', val);
const res = await fetch('/admin/tags?/create', {
method: 'POST',
body: formData,
@@ -86,15 +91,15 @@
}
});
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);
} catch (err: any) {
console.error('Failed to create tag', err);
} finally {
isAddingTag = false;
newTagInput = '';
@@ -111,18 +116,21 @@
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selectedText = textarea.value.substring(start, end);
const insertion = selectedText ? (before + selectedText + after) : (before + defaultText + after);
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);
textarea.setSelectionRange(
start + before.length,
start + before.length + defaultText.length
);
}
}, 0);
}
@@ -130,7 +138,7 @@
function insertMore() {
insertAtCursor('\n<!--more-->\n', '', '');
}
let showAdmonitionMenu = $state(false);
const admonitionTypes = [
{ type: 'note', title: '注意', label: '注意 (Note)' },
@@ -173,7 +181,7 @@
// 1. 分类匹配:严格遵守“遇到全新未知的分类名跳过,由用户决定”的规范
if (imported.categories.length > 0) {
const targetName = imported.categories[0].trim().toLowerCase();
const matchedCat = data.categories.find(c => c.name.trim().toLowerCase() === targetName);
const matchedCat = data.categories.find((c) => c.name.trim().toLowerCase() === targetName);
if (matchedCat) {
selectedCategoryId = matchedCat.id;
}
@@ -183,11 +191,15 @@
seriesWeight = imported.series_weight ?? '';
if (imported.series.length > 0) {
if (imported.series.length > 1) {
alert(`检测到多个系列:${imported.series.join('、')}。BlogFlare 每篇文章只能属于一个系列,本次将选择第一个系列。`);
alert(
`检测到多个系列:${imported.series.join('、')}。BlogFlare 每篇文章只能属于一个系列,本次将选择第一个系列。`
);
}
const seriesName = imported.series[0].trim();
let matchedSeries = seriesList.find(item => item.name.trim().toLowerCase() === seriesName.toLowerCase());
let matchedSeries = seriesList.find(
(item) => item.name.trim().toLowerCase() === seriesName.toLowerCase()
);
if (!matchedSeries && seriesName) {
const formData = new FormData();
formData.append('name', seriesName);
@@ -196,13 +208,21 @@
body: formData,
headers: { 'x-sveltekit-action': 'true' }
});
const actionResult = deserialize(await response.text()) as any;
if (actionResult.type === 'success' && actionResult.data?.series) {
const createdSeries = actionResult.data.series as { id: number; name: string; slug: string };
const createdSeries = actionResult.data.series as {
id: number;
name: string;
slug: string;
};
matchedSeries = createdSeries;
seriesList = [...seriesList, createdSeries];
} else {
alert(actionResult.data?.error || `无法创建系列“${seriesName}”,请在系列管理中检查名称和 URL 别名。`);
alert(
actionResult.data?.error ||
`无法创建系列“${seriesName}”,请在系列管理中检查名称和 URL 别名。`
);
}
}
if (matchedSeries) selectedSeriesId = matchedSeries.id;
@@ -212,7 +232,7 @@
for (const tagName of imported.tags) {
const val = tagName.trim();
if (!val) continue;
const existingTag = tagsList.find(t => t.name.toLowerCase() === val.toLowerCase());
const existingTag = tagsList.find((t) => t.name.toLowerCase() === val.toLowerCase());
if (existingTag) {
if (!selectedTags.includes(existingTag.id)) {
selectedTags = [...selectedTags, existingTag.id];
@@ -226,33 +246,41 @@
body: formData,
headers: { 'x-sveltekit-action': 'true' }
});
const actionRes = deserialize(await res.text()) as any;
if (actionRes.type === 'success' && actionRes.data?.tag) {
tagsList = [...tagsList, actionRes.data.tag];
selectedTags = [...selectedTags, actionRes.data.tag.id];
}
} catch(e) {
console.error('新建导入对应新标签时出错:', e);
} catch (err: any) {
console.error('新建导入对应新标签时出错:', err);
}
}
}
target.value = '';
} catch (err) {
console.error('导入转换出现错误:', err);
} catch (err: any) {
console.error('导入转换出现错误:');
alert('解析文档内容受困,请稍后检查所填文书');
}
}
</script>
<div class="admin-page-header">
<h2>新{$page.data.aliases?.post || '文章'}</h2>
<div class="header-actions">
<button type="button" class="btn btn-secondary" onclick={() => fileInput?.click()}>导入 Markdown</button>
<a href="/admin/posts" class="btn btn-secondary">&larr; 返回</a>
<button type="button" class="btn btn-secondary" onclick={() => fileInput?.click()}
>导入 Markdown</button
>
<a href={resolve('/admin/posts')} class="btn btn-secondary">&larr; 返回</a>
</div>
</div>
<input type="file" bind:this={fileInput} accept=".md,.markdown,.txt" style="display:none" onchange={handleImportMarkdown} />
<input
type="file"
bind:this={fileInput}
accept=".md,.markdown,.txt"
style="display:none"
onchange={handleImportMarkdown}
/>
{#if form?.error}
<div class="error-alert">
@@ -281,43 +309,164 @@
<div class="form-group">
<div class="editor-header">
<label for="content">正文 *</label>
<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('![', '](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 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('![', '](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 (item.type)}
<button
type="button"
class="dropdown-item"
onclick={() => insertAdmonition(item.type, item.title)}
>
{item.label}
</button>
{/each}
</div>
{/if}
</div>
{/if}
<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>
<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>
<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>
@@ -331,8 +480,7 @@
rows="16"
style="min-height: 400px;"
placeholder="支持 Markdown 格式..."
required
></textarea>
required></textarea>
{:else}
<div class="preview-area">
<MarkdownRenderer {content} />
@@ -345,9 +493,11 @@
<div class="form-sidebar">
<div class="sidebar-box">
<h3>发布</h3>
<button type="submit" class="btn btn-primary publish-btn">发布{$page.data.aliases?.post || '文章'}</button>
<button type="submit" class="btn btn-primary publish-btn"
>发布{$page.data.aliases?.post || '文章'}</button
>
</div>
<FeaturedImagePicker bind:cover adaptiveHeight previewable />
<div class="sidebar-box">
@@ -368,7 +518,10 @@
<SelectDropdown
name="category_id"
label="分类"
options={data.categories.map((category) => ({ value: category.id, label: category.name }))}
options={data.categories.map((category) => ({
value: category.id,
label: category.name
}))}
bind:value={selectedCategoryId}
/>
</div>
@@ -395,12 +548,29 @@
<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)}
{#each selectedTags as tagId (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>
<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}
@@ -414,22 +584,26 @@
placeholder="+ 添加标签..."
bind:value={newTagInput}
onkeydown={handleAddTag}
onfocus={() => showTagDropdown = true}
onblur={() => setTimeout(() => showTagDropdown = false, 200)}
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;
}}>
{#each filteredTags as tag (tag.id || 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}
@@ -437,7 +611,6 @@
{/if}
</div>
</div>
</div>
</div>
</form>
@@ -585,7 +758,7 @@
.form-grid {
grid-template-columns: 1fr;
}
.form-sidebar {
position: static;
flex-direction: row;
@@ -609,9 +782,8 @@
text-transform: uppercase;
}
.form-group input[type="text"],
.form-group input[type='text'],
.form-group textarea {
width: 100%;
padding: 12px 14px;
border: 1px solid rgba(0, 0, 0, 0.12);
@@ -621,7 +793,9 @@
font-size: 1.05rem;
color: var(--text-color);
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 {
@@ -630,7 +804,7 @@
min-height: 120px;
}
.form-group input[type="text"]:focus,
.form-group input[type='text']:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent-color, #419b38);
@@ -709,7 +883,7 @@
opacity: 0.7;
padding: 0 4px;
}
.remove-tag-btn:hover {
opacity: 1;
color: #d9534f;
@@ -738,7 +912,7 @@
.tag-dropdown-item {
background: transparent;
border: none;
border-bottom: 1px solid rgba(0,0,0,0.1);
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
padding: 8px 12px;
text-align: left;
font-family: inherit;
@@ -758,7 +932,6 @@
color: var(--bg-color);
}
.editor-header-right {
display: flex;
align-items: center;
@@ -790,7 +963,7 @@
.md-btn:hover {
opacity: 1;
color: var(--accent-color, #419b38);
background-color: rgba(0,0,0,0.03);
background-color: rgba(0, 0, 0, 0.03);
}
.md-btn svg {
@@ -818,7 +991,10 @@
.dropdown-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 99;
}
@@ -839,7 +1015,7 @@
.dropdown-item {
background: transparent;
border: none;
border-bottom: 1px solid rgba(0,0,0,0.1);
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
padding: 8px 12px;
text-align: left;
font-family: inherit;
@@ -849,7 +1025,7 @@
font-weight: 600;
transition: all 0.2s ease;
}
.dropdown-item:last-child {
border-bottom: none;
}
@@ -858,5 +1034,4 @@
background: var(--text-color);
color: var(--bg-color, #ffffff);
}
</style>
+4 -2
View File
@@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
@@ -38,7 +40,7 @@ export const load: PageServerLoad = async ({ platform }) => {
post_count: number;
}>
};
} catch (err) {
} catch (err: any) {
console.error('Error fetching series:', err);
return { series: [] };
}
@@ -118,7 +120,7 @@ export const actions: Actions = {
db.prepare('DELETE FROM series WHERE id = ?').bind(id)
]);
return { success: true };
} catch (err) {
} catch (err: any) {
console.error('Error deleting series:', err);
return fail(500, { error: '删除系列失败' });
}
+42 -23
View File
@@ -1,24 +1,26 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { fail } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
export const load: PageServerLoad = async ({ locals, platform }) => {
const db = platform?.env?.DB;
let settings: Record<string, string> = {};
const settings: Record<string, string> = {};
if (db) {
try {
const { results } = await db.prepare("SELECT key, value FROM settings").all();
const { results } = await db.prepare('SELECT key, value FROM settings').all();
results.forEach((row: any) => {
settings[row.key] = row.value;
});
} catch (e) {
console.error('Failed to load settings', e);
} catch (err: any) {
console.error('Failed to load settings', err);
}
}
return {
user: locals.user,
platformEnv: !!db ? 'Cloudflare D1 (Connected)' : 'Local Mock',
platformEnv: db ? 'Cloudflare D1 (Connected)' : 'Local Mock',
settings
};
};
@@ -31,21 +33,32 @@ export const actions: Actions = {
const data = await request.formData();
const updates = [
{ key: 'site.title', value: data.get('site_title')?.toString() || 'BlogFlare' },
{ key: 'site.description', value: data.get('site_description')?.toString() || 'A high-performance blog powered by Cloudflare D1, R2, and KV.' },
{ key: 'r2.public_domain', value: data.get('r2_public_domain')?.toString() || 'https://assets.yourdomain.com' }
{
key: 'site.description',
value:
data.get('site_description')?.toString() ||
'A high-performance blog powered by Cloudflare D1, R2, and KV.'
},
{
key: 'r2.public_domain',
value: data.get('r2_public_domain')?.toString() || 'https://assets.yourdomain.com'
}
];
try {
for (const { key, value } of updates) {
await db.prepare(
`INSERT INTO settings (key, value, updated_at)
await db
.prepare(
`INSERT INTO settings (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`
).bind(key, value).run();
)
.bind(key, value)
.run();
}
return { success: true };
} catch (e) {
console.error('Failed to update site info', e);
} catch (err: any) {
console.error('Failed to update site info', err);
return fail(500, { error: '保存站点信息失败' });
}
},
@@ -64,15 +77,18 @@ export const actions: Actions = {
try {
for (const { key, value } of updates) {
await db.prepare(
`INSERT INTO settings (key, value, updated_at)
await db
.prepare(
`INSERT INTO settings (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`
).bind(key, value).run();
)
.bind(key, value)
.run();
}
return { success: true };
} catch (e) {
console.error('Failed to update aliases', e);
} catch (err: any) {
console.error('Failed to update aliases', err);
return fail(500, { error: '保存失败' });
}
},
@@ -91,15 +107,18 @@ export const actions: Actions = {
try {
for (const { key, value } of updates) {
await db.prepare(
`INSERT INTO settings (key, value, updated_at)
await db
.prepare(
`INSERT INTO settings (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP`
).bind(key, value).run();
)
.bind(key, value)
.run();
}
return { success: true };
} catch (e) {
console.error('Failed to update appearance', e);
} catch (err: any) {
console.error('Failed to update appearance', err);
return fail(500, { error: '保存外观偏好失败' });
}
}
+211 -121
View File
@@ -4,12 +4,14 @@
import { addToast } from '$lib/stores/toast.svelte';
import Tabs from '$lib/components/admin/Tabs.svelte';
import ImagePicker from '$lib/components/admin/FeaturedImagePicker.svelte';
import { untrack } from 'svelte';
let { data, form }: { data: PageData; form: ActionData } = $props();
const initialData = $state.snapshot(untrack(() => data));
let activeTab = $state('basic');
let avatarEnabled = $state(data.settings['site.avatar_enabled'] !== 'false');
let avatarUrl = $state(data.settings['site.avatar_url'] || '');
let avatarEnabled = $state(initialData.settings['site.avatar_enabled'] !== 'false');
let avatarUrl = $state(initialData.settings['site.avatar_url'] || '');
$effect(() => {
if (form?.success) {
@@ -24,146 +26,234 @@
<h2>系统设置</h2>
</div>
<Tabs
<Tabs
tabs={[
{ id: 'basic', label: '基础信息' },
{ id: 'appearance', label: '外观与主页' },
{ id: 'terms', label: '术语设定' },
{ id: 'system', label: '系统与安全' }
]}
bind:activeTab
]}
bind:activeTab
/>
<div class="settings-container">
{#if activeTab === 'basic'}
<div class="settings-card full-width">
<h3>站点基础信息</h3>
<p class="desc">设置您的网站名称和一句话描述,这些信息将展示在首页和浏览器标题中。</p>
<form method="POST" action="?/updateSiteInfo" use:enhance={() => { return async ({ update }) => { update({ reset: false }); }; }} class="alias-form">
<div class="form-row">
<div class="form-group">
<label for="site_title">网站标题</label>
<input type="text" id="site_title" name="site_title" value={data.settings['site.title'] || 'BlogFlare'} required>
<div class="settings-card full-width">
<h3>站点基础信息</h3>
<p class="desc">设置您的网站名称和一句话描述,这些信息将展示在首页和浏览器标题中。</p>
<form
method="POST"
action="?/updateSiteInfo"
use:enhance={() => {
return async ({ update }) => {
update({ reset: false });
};
}}
class="alias-form"
>
<div class="form-row">
<div class="form-group">
<label for="site_title">网站标题</label>
<input
type="text"
id="site_title"
name="site_title"
value={data.settings['site.title'] || 'BlogFlare'}
required
/>
</div>
<div class="form-group">
<label for="site_description">站点描述</label>
<input
type="text"
id="site_description"
name="site_description"
value={data.settings['site.description'] ||
'A high-performance blog powered by Cloudflare D1, R2, and KV.'}
required
/>
</div>
<div class="form-group">
<label for="r2_public_domain">R2 自定义域名 (用于访问附件)</label>
<input
type="text"
id="r2_public_domain"
name="r2_public_domain"
value={data.settings['r2.public_domain'] || 'https://assets.yourdomain.com'}
placeholder="例如: https://assets.mydomain.com"
/>
<small class="form-hint">请确保不要以斜杠 / 结尾</small>
</div>
</div>
<div class="form-group">
<label for="site_description">站点描述</label>
<input type="text" id="site_description" name="site_description" value={data.settings['site.description'] || 'A high-performance blog powered by Cloudflare D1, R2, and KV.'} required>
<div class="form-actions">
<button type="submit" class="btn btn-primary">保存</button>
</div>
<div class="form-group">
<label for="r2_public_domain">R2 自定义域名 (用于访问附件)</label>
<input type="text" id="r2_public_domain" name="r2_public_domain" value={data.settings['r2.public_domain'] || 'https://assets.yourdomain.com'} placeholder="例如: https://assets.mydomain.com">
<small class="form-hint">请确保不要以斜杠 / 结尾</small>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">保存</button>
</div>
</form>
</div>
</form>
</div>
{/if}
{#if activeTab === 'appearance'}
<div class="settings-card full-width">
<h3>首页顶部主图与外观偏好</h3>
<p class="desc">针对站点的主页展现风貌(如顶部站点头像/主图的开启或关闭、自定义挑选等)进行专属配置。</p>
<form method="POST" action="?/updateAppearance" use:enhance={() => { return async ({ update }) => { update({ reset: false }); }; }} class="alias-form">
<div class="form-group">
<label class="toggle-label">
<input type="checkbox" name="site_avatar_enabled_cb" bind:checked={avatarEnabled} />
<input type="hidden" name="site_avatar_enabled" value={avatarEnabled ? 'true' : 'false'} />
<span class="toggle-text">在网站首页顶部标题栏显示主图 / 站点头像</span>
</label>
</div>
<div class="settings-card full-width">
<h3>首页顶部主图与外观偏好</h3>
<p class="desc">
针对站点的主页展现风貌(如顶部站点头像/主图的开启或关闭、自定义挑选等)进行专属配置。
</p>
{#if avatarEnabled}
<form
method="POST"
action="?/updateAppearance"
use:enhance={() => {
return async ({ update }) => {
update({ reset: false });
};
}}
class="alias-form"
>
<div class="form-group">
<label for="picker">主页显示图片</label>
<div class="picker-container">
<ImagePicker bind:cover={avatarUrl} title="" inputName="site_avatar_url" aspectRatio="1/1" />
</div>
<label class="toggle-label">
<input type="checkbox" name="site_avatar_enabled_cb" bind:checked={avatarEnabled} />
<input
type="hidden"
name="site_avatar_enabled"
value={avatarEnabled ? 'true' : 'false'}
/>
<span class="toggle-text">在网站首页顶部标题栏显示主图 / 站点头像</span>
</label>
</div>
{:else}
<input type="hidden" name="site_avatar_url" value={avatarUrl} />
{/if}
<div class="form-actions">
<button type="submit" class="btn btn-primary">保存外观偏好</button>
</div>
</form>
</div>
{#if avatarEnabled}
<div class="form-group">
<label for="picker">主页显示图片</label>
<div class="picker-container">
<ImagePicker
bind:cover={avatarUrl}
title=""
inputName="site_avatar_url"
aspectRatio="1/1"
/>
</div>
</div>
{:else}
<input type="hidden" name="site_avatar_url" value={avatarUrl} />
{/if}
<div class="form-actions">
<button type="submit" class="btn btn-primary">保存外观偏好</button>
</div>
</form>
</div>
{/if}
{#if activeTab === 'system'}
<div class="settings-card">
<h3>系统信息</h3>
<div class="info-row">
<span class="label">主题风格</span>
<span class="value">Typecho Theme VOID</span>
<div class="settings-card">
<h3>系统信息</h3>
<div class="info-row">
<span class="label">主题风格</span>
<span class="value">Typecho Theme VOID</span>
</div>
<div class="info-row">
<span class="label">驱动框架</span>
<span class="value">SvelteKit 5 + Cloudflare</span>
</div>
</div>
<div class="info-row">
<span class="label">驱动框架</span>
<span class="value">SvelteKit 5 + Cloudflare</span>
</div>
</div>
<div class="settings-card">
<h3>安全与环境</h3>
<div class="info-row">
<span class="label">当前管理员</span>
<span class="value">{data.user?.email || '未知用户'}</span>
<div class="settings-card">
<h3>安全与环境</h3>
<div class="info-row">
<span class="label">当前管理员</span>
<span class="value">{data.user?.email || '未知用户'}</span>
</div>
<div class="info-row">
<span class="label">数据库绑定</span>
<span class="value"><span class="badge">{data.platformEnv}</span></span>
</div>
<div class="info-row">
<span class="label">鉴权模式</span>
<span class="value">Cloudflare Access (Zero Trust)</span>
</div>
</div>
<div class="info-row">
<span class="label">数据库绑定</span>
<span class="value"><span class="badge">{data.platformEnv}</span></span>
</div>
<div class="info-row">
<span class="label">鉴权模式</span>
<span class="value">Cloudflare Access (Zero Trust)</span>
</div>
</div>
{/if}
{#if activeTab === 'terms'}
<div class="settings-card full-width">
<h3>个性化术语设定</h3>
<p class="desc">您可以根据部署场景(如教学系统、文档库)自定义后台各模块的显示名称,例如将“文章”改为“实验手册”。</p>
<form method="POST" action="?/updateAliases" use:enhance={() => { return async ({ update }) => { update({ reset: false }); }; }} class="alias-form">
<div class="form-row">
<div class="form-group">
<label for="alias_post">文章</label>
<input type="text" id="alias_post" name="alias_post" value={data.settings['alias.post'] || '文章'} required>
</div>
<div class="form-group">
<label for="alias_page">页面</label>
<input type="text" id="alias_page" name="alias_page" value={data.settings['alias.page'] || '页面'} required>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="alias_category">分类</label>
<input type="text" id="alias_category" name="alias_category" value={data.settings['alias.category'] || '分类'} required>
</div>
<div class="form-group">
<label for="alias_tag">标签</label>
<input type="text" id="alias_tag" name="alias_tag" value={data.settings['alias.tag'] || '标签'} required>
</div>
<div class="form-group">
<label for="alias_series">系列</label>
<input type="text" id="alias_series" name="alias_series" value={data.settings['alias.series'] || '系列'} required>
</div>
</div>
<div class="settings-card full-width">
<h3>个性化术语设定</h3>
<p class="desc">
您可以根据部署场景(如教学系统、文档库)自定义后台各模块的显示名称,例如将“文章”改为“实验手册”。
</p>
<div class="form-actions">
<button type="submit" class="btn btn-primary">保存</button>
</div>
</form>
</div>
<form
method="POST"
action="?/updateAliases"
use:enhance={() => {
return async ({ update }) => {
update({ reset: false });
};
}}
class="alias-form"
>
<div class="form-row">
<div class="form-group">
<label for="alias_post">文章</label>
<input
type="text"
id="alias_post"
name="alias_post"
value={data.settings['alias.post'] || '文章'}
required
/>
</div>
<div class="form-group">
<label for="alias_page">页面</label>
<input
type="text"
id="alias_page"
name="alias_page"
value={data.settings['alias.page'] || '页面'}
required
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="alias_category">分类</label>
<input
type="text"
id="alias_category"
name="alias_category"
value={data.settings['alias.category'] || '分类'}
required
/>
</div>
<div class="form-group">
<label for="alias_tag">标签</label>
<input
type="text"
id="alias_tag"
name="alias_tag"
value={data.settings['alias.tag'] || '标签'}
required
/>
</div>
<div class="form-group">
<label for="alias_series">系列</label>
<input
type="text"
id="alias_series"
name="alias_series"
value={data.settings['alias.series'] || '系列'}
required
/>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">保存</button>
</div>
</form>
</div>
{/if}
</div>
@@ -202,14 +292,14 @@
.settings-card h3 {
margin: 0 0 12px 0;
font-size: 1.2rem;
border-bottom: 1px solid var(--border-color, rgba(0,0,0,0.1));
border-bottom: 1px solid var(--border-color, rgba(0, 0, 0, 0.1));
padding-bottom: 10px;
color: var(--text-color);
}
.settings-card .desc {
font-size: 0.9rem;
color: var(--text-muted, rgba(0,0,0,0.6));
color: var(--text-muted, rgba(0, 0, 0, 0.6));
margin-bottom: 24px;
line-height: 1.5;
}
@@ -219,7 +309,7 @@
justify-content: space-between;
align-items: center;
padding: 14px 0;
border-bottom: 1px dashed var(--border-color, rgba(0,0,0,0.08));
border-bottom: 1px dashed var(--border-color, rgba(0, 0, 0, 0.08));
font-size: 0.95rem;
gap: 15px;
}
@@ -229,7 +319,7 @@
}
.info-row .label {
color: var(--text-muted, rgba(0,0,0,0.6));
color: var(--text-muted, rgba(0, 0, 0, 0.6));
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
@@ -252,7 +342,7 @@
display: flex;
gap: 20px;
}
@media (max-width: 768px) {
.form-row {
flex-direction: column;
@@ -331,7 +421,7 @@
color: var(--text-color);
}
.toggle-label input[type="checkbox"] {
.toggle-label input[type='checkbox'] {
width: 18px;
height: 18px;
cursor: pointer;
+15 -8
View File
@@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
@@ -10,18 +12,20 @@ export const load: PageServerLoad = async ({ platform }) => {
try {
// 查询所有标签以及对应的文章数量
const { results } = await db.prepare(
`SELECT t.id, t.name, COUNT(pt.post_id) as post_count
const { results } = await db
.prepare(
`SELECT t.id, t.name, COUNT(pt.post_id) as post_count
FROM tags t
LEFT JOIN post_tags pt ON t.id = pt.tag_id
GROUP BY t.id, t.name
ORDER BY t.name ASC`
).all();
)
.all();
return {
tags: results as unknown as { id: number; name: string; post_count: number }[]
};
} catch (err) {
} catch (err: any) {
console.error('Error fetching tags:', err);
return { tags: [] };
}
@@ -41,12 +45,15 @@ export const actions: Actions = {
try {
// 在 D1 中获取刚插入的 ID,可以通过 RETURNING id 语法
const { results } = await db.prepare('INSERT INTO tags (name) VALUES (?) RETURNING id').bind(name).all();
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: unknown) {
console.error('Error creating tag:', err);
if (err?.message?.includes('UNIQUE constraint failed')) {
if ((err as any)?.message?.includes('UNIQUE constraint failed')) {
return fail(400, { error: '标签名称已存在' });
}
return fail(500, { error: '创建标签失败' });
@@ -65,7 +72,7 @@ export const actions: Actions = {
try {
await db.prepare('DELETE FROM tags WHERE id = ?').bind(id).run();
return { success: true };
} catch (err) {
} catch (err: any) {
console.error('Error deleting tag:', err);
return { success: false, error: '删除标签失败' };
}
+22 -12
View File
@@ -54,15 +54,23 @@
</tr>
</thead>
<tbody>
{#each data.tags as tag}
{#each data.tags as tag (tag.id || tag)}
<tr>
<td>{tag.id}</td>
<td><strong>{tag.name}</strong></td>
<td><span class="badge">{tag.post_count} 篇文章</span></td>
<td>
<form action="?/delete" method="POST" class="inline-form" onsubmit={(e) => {
if(!confirm(`确定要删除${$page.data.aliases?.tag || '标签'} "${tag.name}" 吗?`)) e.preventDefault();
}}>
<form
action="?/delete"
method="POST"
class="inline-form"
onsubmit={(e) => {
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>
</form>
@@ -139,7 +147,7 @@
letter-spacing: 0.5px;
}
.form-group input[type="text"] {
.form-group input[type='text'] {
width: 100%;
padding: 10px 0;
border: none;
@@ -152,7 +160,7 @@
transition: border-bottom-color 0.3s ease;
}
.form-group input[type="text"]:focus {
.form-group input[type='text']:focus {
outline: none;
border-bottom-color: var(--accent-color, #419b38);
}
@@ -197,9 +205,10 @@
text-align: left;
}
.data-table th, .data-table td {
.data-table th,
.data-table td {
padding: 14px 18px;
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));
}
.data-table th {
@@ -215,10 +224,9 @@
}
.data-table tbody tr:hover {
background-color: rgba(0,0,0,0.02);
background-color: rgba(0, 0, 0, 0.02);
}
.inline-form {
display: inline-block;
margin: 0;
@@ -240,7 +248,9 @@
color: var(--text-color);
cursor: pointer;
text-decoration: none;
transition: color 0.2s, text-decoration-color 0.2s;
transition:
color 0.2s,
text-decoration-color 0.2s;
}
.action-btn.delete:hover {
@@ -252,6 +262,6 @@
.empty-state {
padding: 50px 20px;
text-align: center;
color: var(--text-muted, rgba(0,0,0,0.6));
color: var(--text-muted, rgba(0, 0, 0, 0.6));
}
</style>