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
+1239 -382

No files matched your search

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