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