feat: 增加文件管理系统及独立页面管理功能

This commit is contained in:
seahi committed 2026-07-29 10:49:01 +08:00
1 parent 068b3dbbbb
commit 5bb5e56b89
8 files changed
+1477

No files matched your search

+123
View File
@@ -0,0 +1,123 @@
import { fail } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
async function ensureAttachmentsTable(db: any) {
try {
await db.prepare(`
CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
key TEXT NOT NULL UNIQUE,
mime_type TEXT,
size INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`).run();
} catch (e) {
console.error('Failed to ensure attachments table:', e);
}
}
export const load: PageServerLoad = async ({ platform }) => {
const db = platform?.env?.DB;
if (!db) {
return { files: [], r2Domain: '' };
}
try {
// Get R2 domain setting
const domainRes = await db.prepare('SELECT value FROM settings WHERE key = ?').bind('r2.public_domain').first();
const r2Domain = domainRes?.value || 'https://assets.yourdomain.com';
const { results } = await db.prepare(
`SELECT id, filename, key, mime_type, size, created_at
FROM attachments
ORDER BY created_at DESC`
).all();
return {
files: results as { id: number; filename: string; key: string; mime_type: string; size: number; created_at: string }[],
r2Domain
};
} catch (err: any) {
if (err?.message?.includes('no such table')) {
await ensureAttachmentsTable(db);
return { files: [], r2Domain: 'https://assets.yourdomain.com' };
}
console.error('Error fetching attachments:', err);
return { files: [], r2Domain: 'https://assets.yourdomain.com' };
}
};
export const actions: Actions = {
upload: async ({ request, platform }) => {
const db = platform?.env?.DB;
const r2 = platform?.env?.R2;
if (!db || !r2) return fail(500, { error: '数据库或 R2 存储未连接' });
const formData = await request.formData();
const file = formData.get('file') as File;
if (!file || !(file instanceof File)) {
return fail(400, { error: '没有提供有效的文件' });
}
// Generate unique key (timestamp + random string + original name)
const ext = file.name.split('.').pop() || '';
const randomStr = Math.random().toString(36).substring(2, 8);
const key = `${Date.now()}-${randomStr}.${ext}`;
try {
// Upload to R2
await r2.put(key, file.stream(), {
httpMetadata: { contentType: file.type }
});
// Save metadata to DB
await ensureAttachmentsTable(db); // Defensive checking
await db.prepare(
'INSERT INTO attachments (filename, key, mime_type, size) VALUES (?, ?, ?, ?)'
).bind(file.name, key, file.type, file.size).run();
return { success: true };
} catch (err: any) {
console.error('Error uploading file:', err);
return fail(500, { error: '文件上传失败: ' + err.message });
}
},
delete: async ({ request, platform }) => {
const db = platform?.env?.DB;
const r2 = platform?.env?.R2;
if (!db || !r2) return fail(500, { error: '数据库或 R2 存储未连接' });
const formData = await request.formData();
const id = formData.get('id');
if (!id) return fail(400, { error: '缺少文件 ID' });
try {
// Get key first
const fileRes = await db.prepare('SELECT key FROM attachments WHERE id = ?').bind(id).first();
if (!fileRes) {
return fail(404, { error: '文件未找到' });
}
const key = fileRes.key as string;
// Delete from R2
await r2.delete(key);
// Delete from DB
await db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run();
return { success: true };
} catch (err: any) {
console.error('Error deleting file:', err);
return fail(500, { error: '删除文件失败' });
}
}
};
+316
View File
@@ -0,0 +1,316 @@
<script lang="ts">
import { enhance } from '$app/forms';
import type { ActionData, PageData } from './$types';
let { data, form } = $props<{ data: PageData; form: ActionData }>();
let isUploading = $state(false);
let fileInput: HTMLInputElement;
function formatBytes(bytes: number, decimals = 2) {
if (!+bytes) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}
function copyMarkdown(file: any) {
const url = `${data.r2Domain}/${file.key}`;
const isImage = file.mime_type?.startsWith('image/');
const markdown = isImage ? `![${file.filename}](${url})` : `[${file.filename}](${url})`;
navigator.clipboard.writeText(markdown).then(() => {
alert('Markdown 链接已复制到剪贴板!');
}).catch(err => {
console.error('Failed to copy: ', err);
alert('复制失败,请手动复制。');
});
}
function handleFileChange() {
if (fileInput.files && fileInput.files.length > 0) {
isUploading = true;
// HTML5 form submit automatically triggers enhance
fileInput.form?.requestSubmit();
}
}
</script>
<svelte:head>
<title>文件管理 - BlogFlare Admin</title>
</svelte:head>
<div class="admin-page-header">
<h2>文件管理</h2>
</div>
{#if form?.error}
<div class="alert error">{form.error}</div>
{/if}
{#if form?.success}
<div class="alert success">操作成功!</div>
{/if}
<div class="upload-zone">
<form method="POST" action="?/upload" enctype="multipart/form-data" use:enhance={() => {
isUploading = true;
return async ({ update }) => {
isUploading = false;
await update();
};
}}>
<button type="button" class="upload-box" onclick={() => fileInput.click()}>
<svg viewBox="0 0 24 24" width="48" height="48" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="17 8 12 3 7 8"></polyline>
<line x1="12" y1="3" x2="12" y2="15"></line>
</svg>
<h3>{isUploading ? '正在上传...' : '点击或拖拽文件上传'}</h3>
<p>存储于 Cloudflare R2</p>
<input
type="file"
name="file"
bind:this={fileInput}
onchange={handleFileChange}
style="display: none;"
disabled={isUploading}
/>
</button>
</form>
</div>
<div class="files-grid">
{#each data.files as file}
{@const url = `${data.r2Domain}/${file.key}`}
{@const isImage = file.mime_type?.startsWith('image/')}
<div class="file-card">
<div class="file-preview">
{#if isImage}
<img src={url} alt={file.filename} loading="lazy" />
{:else}
<div class="file-icon">
<svg viewBox="0 0 24 24" width="48" height="48" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"></path>
<polyline points="13 2 13 9 20 9"></polyline>
</svg>
</div>
{/if}
</div>
<div class="file-info">
<div class="file-name" title={file.filename}>{file.filename}</div>
<div class="file-meta">
<span>{formatBytes(file.size)}</span>
<span>{new Date(file.created_at).toLocaleDateString()}</span>
</div>
</div>
<div class="file-actions">
<button class="action-btn copy-btn" onclick={() => copyMarkdown(file)} title="复制 Markdown">
复制链接
</button>
<form method="POST" action="?/delete" use:enhance={() => {
return async ({ update }) => {
await update();
};
}}>
<input type="hidden" name="id" value={file.id} />
<button class="action-btn delete-btn" type="submit" onclick={(e) => {
if(!confirm('确定要删除此文件吗?此操作不可恢复。')) e.preventDefault();
}} title="删除文件">
删除
</button>
</form>
</div>
</div>
{/each}
{#if data.files.length === 0}
<div class="empty-state">
暂无文件,点击上方按钮上传
</div>
{/if}
</div>
<style>
.admin-page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
border-bottom: 3px solid var(--text-color);
padding-bottom: 15px;
}
.admin-page-header h2 {
margin: 0;
font-size: 2rem;
font-weight: 800;
}
.alert {
padding: 12px 15px;
margin-bottom: 20px;
border: 2px solid var(--text-color);
font-weight: 600;
}
.alert.error {
background-color: #ffcccc;
color: #cc0000;
}
.alert.success {
background-color: #d4edda;
color: #155724;
}
.upload-zone {
margin-bottom: 30px;
}
.upload-box {
width: 100%;
border: 3px dashed var(--text-color);
padding: 40px 20px;
text-align: center;
cursor: pointer;
background: var(--bg-color);
transition: all 0.2s;
display: block;
font-family: inherit;
color: inherit;
}
.upload-box:hover {
background: rgba(0,0,0,0.02);
border-color: var(--accent-color, #419b38);
color: var(--accent-color, #419b38);
}
.upload-box h3 {
margin: 15px 0 5px 0;
font-size: 1.2rem;
}
.upload-box p {
margin: 0;
color: var(--text-muted);
font-size: 0.9rem;
}
.files-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 20px;
}
.file-card {
border: 2px solid var(--text-color);
background: var(--bg-color);
display: flex;
flex-direction: column;
box-shadow: 4px 4px 0 rgba(0,0,0,1);
transition: transform 0.2s, box-shadow 0.2s;
}
.file-card:hover {
transform: translate(-2px, -2px);
box-shadow: 6px 6px 0 rgba(0,0,0,1);
}
.file-preview {
height: 150px;
border-bottom: 2px solid var(--text-color);
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
overflow: hidden;
}
.file-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.file-icon {
color: var(--text-muted);
}
.file-info {
padding: 12px;
flex-grow: 1;
}
.file-name {
font-weight: 700;
font-size: 0.95rem;
margin-bottom: 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-meta {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: var(--text-muted);
font-weight: 500;
}
.file-actions {
display: flex;
border-top: 2px solid var(--text-color);
}
.file-actions form {
flex: 1;
display: flex;
}
.action-btn {
flex: 1;
background: transparent;
border: none;
padding: 10px 0;
font-family: inherit;
font-weight: 700;
cursor: pointer;
color: var(--text-color);
transition: all 0.2s;
}
.action-btn:hover {
background: var(--text-color);
color: var(--bg-color);
}
.copy-btn {
border-right: 2px solid var(--text-color);
}
.delete-btn {
width: 100%;
}
.delete-btn:hover {
background: #ff4444;
color: white;
}
.empty-state {
grid-column: 1 / -1;
text-align: center;
padding: 40px;
border: 2px solid var(--text-color);
font-weight: 600;
color: var(--text-muted);
}
</style>
+70
View File
@@ -0,0 +1,70 @@
import type { PageServerLoad } from './$types';
interface PageListRow {
id: number;
title: string;
slug: string;
date: string;
author: string | null;
}
export const load: PageServerLoad = async ({ platform }) => {
const db = platform?.env?.DB;
if (!db) {
return {
pages: []
};
}
try {
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>();
const results = queryRes.results;
// Format date for display
const formattedPages = results.map((page) => {
const d = new Date(page.date as string);
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}`
};
});
return {
pages: formattedPages
};
} catch (err) {
console.error('Error fetching admin pages list:', err);
return { pages: [] };
}
};
export const actions = {
delete: async ({ request, platform }) => {
const db = platform?.env?.DB;
if (!db) return { success: false, error: 'Database not found' };
const data = await request.formData();
const ids = data.getAll('ids');
if (!ids || ids.length === 0) return { success: false, error: 'Missing IDs' };
try {
const placeholders = ids.map(() => '?').join(',');
await db.prepare(`DELETE FROM pages WHERE id IN (${placeholders})`).bind(...ids).run();
return { success: true };
} catch (err) {
console.error('Error deleting page:', err);
return { success: false, error: 'Failed to delete page' };
}
}
};
+247
View File
@@ -0,0 +1,247 @@
<script lang="ts">
import { page } from '$app/stores';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let selectedIds = $state<string[]>([]);
function toggleAll(e: Event) {
const target = e.target as HTMLInputElement;
if (target.checked) {
selectedIds = data.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>
</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 || '项目'}吗?操作不可恢复。`)) {
e.preventDefault();
}
}}>
{#each selectedIds as id}
<input type="hidden" name="ids" value={id} />
{/each}
<button type="submit" class="btn btn-danger btn-sm">批量删除</button>
</form>
{:else}
<span class="selected-count empty">未选择项目</span>
{/if}
</div>
{/if}
<div class="table-container">
{#if data.pages.length === 0}
<div class="empty-state">
<p>还没有发布任何{$page.data.aliases?.page || '页面'},现在就开始写作吧!</p>
</div>
{:else}
<table class="data-table">
<thead>
<tr>
<th class="checkbox-col">
<input type="checkbox" checked={isAllSelected} onchange={toggleAll} />
</th>
<th>ID</th>
<th>标题</th>
<th>别名 (Slug)</th>
<th>作者</th>
<th>发布时间</th>
</tr>
</thead>
<tbody>
{#each data.pages as pageItem}
<tr>
<td class="checkbox-col">
<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>
</td>
<td><span class="badge">{pageItem.slug}</span></td>
<td>{pageItem.author || '未知'}</td>
<td>{pageItem.dateStr}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
<style>
.admin-page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
border-bottom: 2px solid var(--text-color);
padding-bottom: 12px;
}
.admin-page-header h2 {
margin: 0;
font-size: 2rem;
color: var(--text-color);
}
.btn {
display: inline-block;
padding: 8px 18px;
text-decoration: none;
font-size: 0.95rem;
font-weight: 600;
transition: all 0.2s;
cursor: pointer;
border: 2px solid var(--text-color);
background-color: transparent;
color: var(--text-color);
font-family: inherit;
}
.btn-primary {
background-color: var(--text-color);
color: var(--bg-color, #ffffff);
}
.btn-primary:hover {
background-color: var(--accent-color, #419b38);
border-color: var(--accent-color, #419b38);
color: white;
}
.table-container {
background: transparent;
border: 2px solid var(--text-color);
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
.data-table th, .data-table td {
padding: 10px 16px;
border-bottom: 1px solid var(--border-color, rgba(0,0,0,0.08));
}
.data-table th {
background-color: transparent;
color: var(--text-color);
font-weight: 700;
font-size: 0.95rem;
border-bottom: 2px solid var(--text-color);
}
.data-table tr:last-child td {
border-bottom: none;
}
.data-table tbody tr:hover {
background-color: rgba(0,0,0,0.02);
}
.preview-link {
text-decoration: none;
margin-left: 6px;
opacity: 0.5;
transition: opacity 0.2s;
display: inline-block;
vertical-align: middle;
}
.preview-link:hover {
opacity: 1;
}
.empty-state {
padding: 50px 20px;
text-align: center;
color: var(--text-muted, rgba(0,0,0,0.6));
}
.batch-actions {
display: flex;
align-items: center;
gap: 15px;
margin-bottom: 15px;
padding: 10px 15px;
background: rgba(0,0,0,0.02);
height: 54px;
box-sizing: border-box;
}
.selected-count {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-color);
}
.selected-count.empty {
color: var(--text-muted, rgba(0,0,0,0.4));
font-weight: normal;
}
.checkbox-col {
width: 40px;
text-align: center;
}
.checkbox-col input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent-color, #419b38);
cursor: pointer;
}
.title-link {
color: var(--text-color);
text-decoration: none;
transition: color 0.2s;
}
.title-link:hover {
color: var(--accent-color, #419b38);
text-decoration: underline;
text-underline-offset: 4px;
}
.btn-danger {
background-color: transparent;
color: #d9534f;
border-color: #d9534f;
}
.btn-danger:hover {
background-color: #d9534f;
color: white;
}
.btn-sm {
padding: 4px 12px;
font-size: 0.85rem;
}
</style>
@@ -0,0 +1,63 @@
import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params, platform }) => {
const db = platform?.env?.DB;
const pageId = params.id;
if (!db) {
throw error(500, '数据库未连接');
}
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; }>();
if (!page) {
throw error(404, '页面不存在');
}
return {
page
};
} catch (err: any) {
if (err?.status === 404) throw err;
console.error('Error loading page for edit:', err);
throw error(500, '读取页面详情失败');
}
};
export const actions: Actions = {
update: async ({ params, request, platform }) => {
const db = platform?.env?.DB;
const pageId = params.id;
if (!db) return fail(500, { error: '数据库未连接' });
const formData = await request.formData();
const title = formData.get('title')?.toString().trim();
const slug = formData.get('slug')?.toString().trim();
const content = formData.get('content')?.toString() || '';
if (!title || !slug || !content) {
return fail(400, { error: '标题、URL 别名和正文为必填项' });
}
try {
await db.prepare(
`UPDATE pages
SET title = ?, slug = ?, content = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?`
).bind(title, slug, content, pageId).run();
} catch (err: any) {
console.error('Error updating page:', err);
if (err?.message?.includes('UNIQUE constraint failed')) {
return fail(400, { error: 'URL 别名 (Slug) 已被其他页面占用' });
}
return fail(500, { error: '保存修改失败' });
}
throw redirect(303, '/admin/pages');
}
};
+309
View File
@@ -0,0 +1,309 @@
<script lang="ts">
import { page } from '$app/stores';
import type { PageData, ActionData } from './$types';
import MarkdownRenderer from '$lib/components/MarkdownRenderer.svelte';
let { data, form }: { data: PageData; form: ActionData } = $props();
let title = $state(data.page.title);
let slug = $state(data.page.slug);
let content = $state(data.page.content);
let mode = $state<'edit' | 'preview'>('edit');
</script>
<div class="admin-page-header">
<h2>编辑{$page.data.aliases?.page || '页面'}</h2>
<div class="header-actions">
<a href="/{data.page.slug}" target="_blank" class="btn btn-secondary" style="margin-right: 12px;">前台预览</a>
<a href="/admin/pages" class="btn btn-secondary">&larr; 返回</a>
</div>
</div>
{#if form?.error}
<div class="error-alert">
{form.error}
</div>
{/if}
<form action="?/update" method="POST" class="post-form">
<div class="form-grid">
<div class="form-main">
<div class="form-group">
<label for="title">标题 *</label>
<input
type="text"
id="title"
name="title"
bind:value={title}
placeholder="输入标题..."
required
/>
</div>
<div class="form-group">
<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>
</div>
</div>
<div class="editor-body">
{#if mode === 'edit'}
<textarea
id="content"
name="content"
bind:value={content}
rows="16"
style="min-height: 400px;"
placeholder="支持 Markdown 格式..."
required
></textarea>
{:else}
<div class="preview-area">
<MarkdownRenderer {content} />
</div>
{/if}
</div>
</div>
</div>
<div class="form-sidebar">
<div class="sidebar-box">
<h3>更新</h3>
<button type="submit" class="btn btn-primary submit-btn">保存修改</button>
</div>
<div class="sidebar-box">
<h3>别名 *</h3>
<input
type="text"
id="slug"
name="slug"
class="sidebar-input"
bind:value={slug}
placeholder="hello-world"
required
/>
</div>
</div>
</div>
</form>
<style>
.admin-page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
border-bottom: 2px solid var(--text-color);
padding-bottom: 12px;
}
.admin-page-header h2 {
margin: 0;
font-size: 2rem;
color: var(--text-color);
}
.header-actions {
display: flex;
align-items: center;
}
.btn {
display: inline-block;
padding: 10px 20px;
text-decoration: none;
font-size: 0.95rem;
font-weight: 600;
transition: all 0.3s ease;
cursor: pointer;
border: 1px solid var(--text-color);
background-color: transparent;
color: var(--text-color);
font-family: inherit;
text-transform: uppercase;
letter-spacing: 1px;
}
.btn-primary {
background-color: var(--text-color);
color: var(--bg-color, #ffffff);
width: 100%;
text-align: center;
}
.btn-primary:hover {
background-color: var(--accent-color, #419b38);
border-color: var(--accent-color, #419b38);
color: white;
}
.btn-secondary:hover {
border-color: var(--accent-color, #419b38);
color: var(--accent-color, #419b38);
}
.error-alert {
padding: 12px 16px;
background-color: transparent;
border-left: 4px solid #d9534f;
color: #d9534f;
margin-bottom: 30px;
font-weight: 600;
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 6px;
}
.editor-tabs {
display: flex;
gap: 8px;
}
.tab-btn {
background: none;
border: none;
padding: 4px 12px;
font-size: 0.85rem;
font-weight: 600;
color: rgba(0, 0, 0, 0.4);
cursor: pointer;
border-radius: 4px;
transition: all 0.2s ease;
}
.tab-btn:hover {
background: rgba(0, 0, 0, 0.05);
}
.tab-btn.active {
color: var(--accent-color, #419b38);
background: rgba(65, 155, 56, 0.1);
}
.preview-area {
padding: 20px;
border: 1px solid rgba(0, 0, 0, 0.12);
background: rgba(0, 0, 0, 0.015);
min-height: 400px;
max-height: 600px;
overflow-y: auto;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 280px;
gap: 40px;
align-items: start;
}
.form-main {
width: 100%;
}
.form-sidebar {
position: sticky;
top: 80px;
width: 100%;
display: flex;
flex-direction: column;
gap: 30px;
z-index: 10;
}
@media (max-width: 900px) {
.form-grid {
grid-template-columns: 1fr;
}
.form-sidebar {
position: static;
flex-direction: row;
flex-wrap: wrap;
gap: 20px;
margin-top: 20px;
}
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-weight: 700;
margin-bottom: 6px;
color: var(--text-color);
font-size: 0.9rem;
letter-spacing: 0.5px;
}
.form-group input[type="text"],
.form-group textarea {
width: 100%;
padding: 12px 14px;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 0;
background: rgba(0, 0, 0, 0.015);
font-family: inherit;
font-size: 1.05rem;
color: var(--text-color);
box-sizing: border-box;
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.form-group textarea {
resize: vertical;
line-height: 1.8;
min-height: 120px;
}
.form-group input[type="text"]:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent-color, #419b38);
box-shadow: 0 0 0 3px rgba(65, 155, 56, 0.08);
}
.sidebar-input:focus {
outline: none;
border-bottom-color: var(--accent-color, #419b38);
}
.sidebar-input {
width: 100%;
padding: 10px 0;
border: none;
border-bottom: 2px solid rgba(0, 0, 0, 0.1);
background: transparent;
font-family: inherit;
font-size: 1rem;
color: var(--text-color);
box-sizing: border-box;
transition: border-bottom-color 0.3s ease;
}
.sidebar-box {
background: transparent;
border: none;
border-radius: 0;
padding: 0;
}
.sidebar-box h3 {
margin: 0 0 20px 0;
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-color);
border-bottom: none;
}
</style>
@@ -0,0 +1,37 @@
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
return {};
};
export const actions: Actions = {
default: async ({ request, platform }) => {
const db = platform?.env?.DB;
if (!db) return fail(500, { error: '数据库未连接' });
const formData = await request.formData();
const title = formData.get('title')?.toString().trim();
const slug = formData.get('slug')?.toString().trim();
const content = formData.get('content')?.toString() || '';
if (!title || !slug || !content) {
return fail(400, { error: '标题、URL 别名和正文为必填项' });
}
try {
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) {
console.error('Error creating page:', err);
if (err?.message?.includes('UNIQUE constraint failed')) {
return fail(400, { error: 'URL 别名 (Slug) 已被占用,请使用其他别名' });
}
return fail(500, { error: '保存页面失败' });
}
throw redirect(303, '/admin/pages');
}
};
+312
View File
@@ -0,0 +1,312 @@
<script lang="ts">
import { page } from '$app/stores';
import type { ActionData } from './$types';
import MarkdownRenderer from '$lib/components/MarkdownRenderer.svelte';
let { form }: { form: ActionData } = $props();
let title = $state('');
let slug = $state('');
let content = $state('');
let mode = $state<'edit' | 'preview'>('edit');
// 自动从标题生成 Slug 简易逻辑
function handleTitleInput() {
if (!slug) {
slug = title
.toLowerCase()
.replace(/[^\w\u4e00-\u9fa5\s-]/g, '')
.replace(/\s+/g, '-');
}
}
</script>
<div class="admin-page-header">
<h2>新建独立{$page.data.aliases?.page || '页面'}</h2>
<a href="/admin/pages" class="btn btn-secondary">&larr; 返回</a>
</div>
{#if form?.error}
<div class="error-alert">
{form.error}
</div>
{/if}
<form method="POST" class="post-form">
<div class="form-grid">
<div class="form-main">
<div class="form-group">
<label for="title">标题 *</label>
<input
type="text"
id="title"
name="title"
bind:value={title}
oninput={handleTitleInput}
placeholder="输入标题..."
required
/>
</div>
<div class="form-group">
<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>
</div>
</div>
<div class="editor-body">
{#if mode === 'edit'}
<textarea
id="content"
name="content"
bind:value={content}
rows="16"
style="min-height: 400px;"
placeholder="支持 Markdown 格式..."
required
></textarea>
{:else}
<div class="preview-area">
<MarkdownRenderer {content} />
</div>
{/if}
</div>
</div>
</div>
<div class="form-sidebar">
<div class="sidebar-box">
<h3>发布</h3>
<button type="submit" class="btn btn-primary submit-btn">发布</button>
</div>
<div class="sidebar-box">
<h3>别名 *</h3>
<input
type="text"
id="slug"
name="slug"
class="sidebar-input"
bind:value={slug}
placeholder="about"
required
/>
</div>
</div>
</div>
</form>
<style>
.admin-page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 25px;
border-bottom: 2px solid var(--text-color);
padding-bottom: 12px;
}
.admin-page-header h2 {
margin: 0;
font-size: 2rem;
color: var(--text-color);
}
.btn {
display: inline-block;
padding: 10px 20px;
text-decoration: none;
font-size: 0.95rem;
font-weight: 600;
transition: all 0.3s ease;
cursor: pointer;
border: 1px solid var(--text-color);
background-color: transparent;
color: var(--text-color);
font-family: inherit;
text-transform: uppercase;
letter-spacing: 1px;
}
.btn-primary {
background-color: var(--text-color);
color: var(--bg-color, #ffffff);
width: 100%;
text-align: center;
}
.btn-primary:hover {
background-color: var(--accent-color, #419b38);
border-color: var(--accent-color, #419b38);
color: white;
}
.btn-secondary:hover {
border-color: var(--accent-color, #419b38);
color: var(--accent-color, #419b38);
}
.error-alert {
padding: 12px 16px;
background-color: transparent;
border-left: 4px solid #d9534f;
color: #d9534f;
margin-bottom: 30px;
font-weight: 600;
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 6px;
}
.editor-tabs {
display: flex;
gap: 8px;
}
.tab-btn {
background: none;
border: none;
padding: 4px 12px;
font-size: 0.85rem;
font-weight: 600;
color: rgba(0, 0, 0, 0.4);
cursor: pointer;
border-radius: 4px;
transition: all 0.2s ease;
}
.tab-btn:hover {
background: rgba(0, 0, 0, 0.05);
}
.tab-btn.active {
color: var(--accent-color, #419b38);
background: rgba(65, 155, 56, 0.1);
}
.preview-area {
padding: 20px;
border: 1px solid rgba(0, 0, 0, 0.12);
background: rgba(0, 0, 0, 0.015);
min-height: 400px;
max-height: 600px;
overflow-y: auto;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 280px;
gap: 40px;
align-items: start;
}
.form-main {
width: 100%;
}
.form-sidebar {
position: sticky;
top: 80px;
width: 100%;
display: flex;
flex-direction: column;
gap: 30px;
z-index: 10;
}
@media (max-width: 900px) {
.form-grid {
grid-template-columns: 1fr;
}
.form-sidebar {
position: static;
flex-direction: row;
flex-wrap: wrap;
gap: 20px;
margin-top: 20px;
}
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-weight: 700;
margin-bottom: 6px;
color: var(--text-color);
font-size: 0.9rem;
letter-spacing: 0.5px;
}
.form-group input[type="text"],
.form-group textarea {
width: 100%;
padding: 12px 14px;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 0;
background: rgba(0, 0, 0, 0.015);
font-family: inherit;
font-size: 1.05rem;
color: var(--text-color);
box-sizing: border-box;
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.form-group textarea {
resize: vertical;
line-height: 1.8;
min-height: 120px;
}
.form-group input[type="text"]:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent-color, #419b38);
box-shadow: 0 0 0 3px rgba(65, 155, 56, 0.08);
}
.sidebar-input:focus {
outline: none;
border-bottom-color: var(--accent-color, #419b38);
}
.sidebar-input {
width: 100%;
padding: 10px 0;
border: none;
border-bottom: 2px solid rgba(0, 0, 0, 0.1);
background: transparent;
font-family: inherit;
font-size: 1rem;
color: var(--text-color);
box-sizing: border-box;
transition: border-bottom-color 0.3s ease;
}
.sidebar-box {
background: transparent;
border: none;
border-radius: 0;
padding: 0;
}
.sidebar-box h3 {
margin: 0 0 20px 0;
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--text-color);
border-bottom: none;
}
</style>