feat: 添加文章特色图功能并优化后台图库管理
This commit is contained in:
1 parent
8af6ae8864
commit
59e036af0e
16 files changed
+568
-66
No files matched your search
@@ -29,6 +29,7 @@ CREATE TABLE posts (
|
||||
category_id INTEGER,
|
||||
published_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
cover TEXT,
|
||||
FOREIGN KEY (author_id) REFERENCES users(id),
|
||||
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
+35
@@ -1189,3 +1189,38 @@ footer section p:first-child {
|
||||
content: "#";
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
/* Featured Image Styles */
|
||||
.post-cover {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
overflow: hidden;
|
||||
border-bottom: 1px dashed var(--line-color, rgba(0,0,0,0.1));
|
||||
}
|
||||
|
||||
.post-cover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
transition: transform 0.5s ease;
|
||||
}
|
||||
|
||||
.card-link:hover .post-cover img {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.article-content.has-cover .content-wrap {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.detail-cover {
|
||||
height: 400px;
|
||||
border-bottom: none;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.detail-cover img {
|
||||
border-radius: 8px;
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
<script lang="ts">
|
||||
import { addToast } from '$lib/stores/toast.svelte';
|
||||
import Tabs from './Tabs.svelte';
|
||||
|
||||
let { cover = $bindable('') } = $props<{ cover?: string | null }>();
|
||||
|
||||
let activeTab = $state('url');
|
||||
let isUploading = $state(false);
|
||||
|
||||
let images = $state<any[]>([]);
|
||||
let page = $state(1);
|
||||
let hasMore = $state(true);
|
||||
let isLoadingLibrary = $state(false);
|
||||
|
||||
async function loadLibrary(reset = false) {
|
||||
if (reset) {
|
||||
page = 1;
|
||||
images = [];
|
||||
hasMore = true;
|
||||
}
|
||||
if (!hasMore || isLoadingLibrary) return;
|
||||
|
||||
isLoadingLibrary = true;
|
||||
try {
|
||||
const res = await fetch(`/admin/api/images?page=${page}&limit=12`);
|
||||
const data = await res.json() as any;
|
||||
if (data.success) {
|
||||
images = [...images, ...data.images];
|
||||
if (page >= data.pagination.totalPages) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
page++;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
addToast('加载图库失败', 'error');
|
||||
} finally {
|
||||
isLoadingLibrary = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTabChange(tab: string) {
|
||||
activeTab = tab;
|
||||
if (tab === 'library' && images.length === 0) {
|
||||
loadLibrary(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpload(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
isUploading = true;
|
||||
try {
|
||||
const res = await fetch('/admin/api/upload', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
const data = await res.json() as any;
|
||||
if (data.success) {
|
||||
cover = data.url;
|
||||
addToast('特色图上传成功', 'success');
|
||||
activeTab = 'url';
|
||||
} else {
|
||||
addToast(data.error || '上传失败', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
addToast('网络错误,上传失败', 'error');
|
||||
} finally {
|
||||
isUploading = false;
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="sidebar-section">
|
||||
<h3>特色图</h3>
|
||||
|
||||
{#if cover}
|
||||
<div class="cover-preview">
|
||||
<img src={cover} alt="特色图预览" />
|
||||
<button type="button" class="btn-clear" onclick={() => cover = ''}>移除</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ id: 'url', label: '链接' },
|
||||
{ id: 'upload', label: '直接上传' },
|
||||
{ id: 'library', label: '图库选择' }
|
||||
]}
|
||||
bind:activeTab
|
||||
fullWidth={true}
|
||||
small={true}
|
||||
onchange={handleTabChange}
|
||||
/>
|
||||
|
||||
<div class="tab-content">
|
||||
{#if activeTab === 'url'}
|
||||
<input type="url" placeholder="输入或粘贴图片链接" bind:value={cover} class="full-width-input" />
|
||||
{:else if activeTab === 'upload'}
|
||||
<div class="upload-area" class:disabled={isUploading}>
|
||||
<label>
|
||||
<span>{isUploading ? '正在上传...' : '点击选择图片上传'}</span>
|
||||
<input type="file" accept="image/*" onchange={handleUpload} disabled={isUploading} style="display: none" />
|
||||
</label>
|
||||
</div>
|
||||
{:else if activeTab === 'library'}
|
||||
<div class="library-grid">
|
||||
{#each images as img}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="library-item" onclick={() => { cover = img.url; activeTab = 'url'; }} title={img.filename}>
|
||||
<img src={img.url} alt={img.filename} loading="lazy" />
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{#if hasMore}
|
||||
<button type="button" class="btn-load-more" onclick={() => loadLibrary()} disabled={isLoadingLibrary}>
|
||||
{isLoadingLibrary ? '加载中...' : '加载更多'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if !isLoadingLibrary && images.length === 0}
|
||||
<div class="empty-text">附件库暂无图片</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 隐藏表单项,随大表单一起提交 -->
|
||||
<input type="hidden" name="cover" value={cover || ''} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sidebar-section {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.sidebar-section h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.cover-preview {
|
||||
margin-bottom: 12px;
|
||||
position: relative;
|
||||
border: 2px solid var(--text-color);
|
||||
background: #f5f5f5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cover-preview img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
aspect-ratio: 16/9;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.btn-clear {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
background: #d9534f;
|
||||
color: #fff;
|
||||
border: 2px solid #000;
|
||||
padding: 2px 8px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
box-shadow: 2px 2px 0 rgba(0,0,0,1);
|
||||
}
|
||||
|
||||
.btn-clear:hover {
|
||||
transform: translate(-1px, -1px);
|
||||
box-shadow: 3px 3px 0 rgba(0,0,0,1);
|
||||
}
|
||||
|
||||
.full-width-input {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 2px solid var(--text-color);
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.upload-area label {
|
||||
display: block;
|
||||
border: 2px dashed var(--text-color);
|
||||
padding: 30px 10px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
background: var(--bg-color);
|
||||
transition: background 0.2s;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.upload-area label:hover {
|
||||
background: rgba(0,0,0,0.03);
|
||||
}
|
||||
|
||||
.upload-area.disabled label {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.library-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 5px;
|
||||
margin-bottom: 10px;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.library-item {
|
||||
aspect-ratio: 1;
|
||||
border: 2px solid var(--text-color);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.library-item:hover {
|
||||
border-color: var(--accent-color, #419b38);
|
||||
}
|
||||
|
||||
.library-item img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.btn-load-more {
|
||||
width: 100%;
|
||||
padding: 6px;
|
||||
background: transparent;
|
||||
border: 2px solid var(--text-color);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.btn-load-more:hover {
|
||||
background: var(--text-color);
|
||||
color: var(--bg-color);
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
type Tab = { id: string; label: string };
|
||||
|
||||
let {
|
||||
tabs = [],
|
||||
activeTab = $bindable(),
|
||||
fullWidth = false,
|
||||
small = false,
|
||||
onchange
|
||||
} = $props<{
|
||||
tabs: Tab[];
|
||||
activeTab: string;
|
||||
fullWidth?: boolean;
|
||||
small?: boolean;
|
||||
onchange?: (id: string) => void;
|
||||
}>();
|
||||
|
||||
function select(id: string) {
|
||||
activeTab = id;
|
||||
if (onchange) onchange(id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="tabs-header" class:full-width={fullWidth} class:small={small}>
|
||||
{#each tabs as tab}
|
||||
<button
|
||||
type="button"
|
||||
class="tab-btn"
|
||||
class:active={activeTab === tab.id}
|
||||
onclick={() => select(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tabs-header {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
border-bottom: 2px solid var(--border-color, rgba(0,0,0,0.1));
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tabs-header.full-width {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.tabs-header.full-width .tab-btn {
|
||||
flex: 1;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.tabs-header.small {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.tabs-header.small .tab-btn {
|
||||
font-size: 0.85rem;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 10px 0;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted, rgba(0,0,0,0.5));
|
||||
cursor: pointer;
|
||||
border-bottom: 3px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: var(--text-color);
|
||||
border-bottom-color: var(--accent-color, #419b38);
|
||||
}
|
||||
</style>
|
||||
@@ -27,7 +27,7 @@ export const load: PageServerLoad = async ({ platform }) => {
|
||||
|
||||
try {
|
||||
const { results } = await db.prepare(
|
||||
`SELECT p.id, p.slug, p.title, p.content, p.published_at as date
|
||||
`SELECT p.id, p.slug, p.title, p.content, p.cover, p.published_at as date
|
||||
FROM posts p
|
||||
ORDER BY p.published_at DESC LIMIT 10`
|
||||
).all();
|
||||
|
||||
@@ -12,7 +12,12 @@
|
||||
<div class="post-list">
|
||||
{#each data.posts as post}
|
||||
<a href="/posts/{post.slug}" class="card-link">
|
||||
<article class="card article-content">
|
||||
<article class="card article-content" class:has-cover={!!post.cover}>
|
||||
{#if post.cover}
|
||||
<div class="post-cover">
|
||||
<img src={post.cover} alt={post.title} loading="lazy" />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="content-wrap">
|
||||
<div class="post-meta-index">
|
||||
<time>{post.date}</time>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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');
|
||||
const limit = parseInt(url.searchParams.get('limit') || '20');
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
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';
|
||||
|
||||
// Count total images
|
||||
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(`
|
||||
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();
|
||||
|
||||
const images = results.map(r => ({
|
||||
...r,
|
||||
url: `${r2Domain}/${r.key}`
|
||||
}));
|
||||
|
||||
return json({
|
||||
success: true,
|
||||
images,
|
||||
pagination: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
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 } });
|
||||
}
|
||||
console.error('Error fetching images:', err);
|
||||
return json({ error: '读取图片列表失败' }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } 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 POST: RequestHandler = async ({ request, platform }) => {
|
||||
const db = platform?.env?.DB;
|
||||
const r2 = platform?.env?.R2;
|
||||
|
||||
if (!db || !r2) return json({ error: '数据库或 R2 存储未连接' }, { status: 500 });
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File;
|
||||
|
||||
if (!file || !(file instanceof File)) {
|
||||
return json({ error: '没有提供有效的文件' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Generate timestamp-based filename and robust physical key
|
||||
const ext = file.name.split('.').pop() || '';
|
||||
const randomStr = Math.random().toString(36).substring(2, 8);
|
||||
const now = new Date();
|
||||
const yearMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
|
||||
const yyyymmdd = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
|
||||
const hhmmss = `${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}${String(now.getSeconds()).padStart(2, '0')}`;
|
||||
const newFilename = `${yyyymmdd}_${hhmmss}.${ext}`;
|
||||
const key = `uploads/${yearMonth}/${yyyymmdd}_${hhmmss}_${randomStr}.${ext}`;
|
||||
|
||||
try {
|
||||
// Upload to R2
|
||||
await r2.put(key, file.stream(), {
|
||||
httpMetadata: { contentType: file.type }
|
||||
});
|
||||
|
||||
// Save metadata to DB
|
||||
await ensureAttachmentsTable(db);
|
||||
await db.prepare(
|
||||
'INSERT INTO attachments (filename, key, mime_type, size) VALUES (?, ?, ?, ?)'
|
||||
).bind(newFilename, key, file.type, file.size).run();
|
||||
|
||||
// Get r2Domain
|
||||
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 url = `${r2Domain}/${key}`;
|
||||
|
||||
return json({ success: true, url, key, filename: newFilename });
|
||||
} catch (err: any) {
|
||||
console.error('Error uploading file API:', err);
|
||||
return json({ error: '文件上传失败: ' + err.message }, { status: 500 });
|
||||
}
|
||||
};
|
||||
@@ -40,8 +40,6 @@
|
||||
|
||||
function handleFileChange() {
|
||||
if (fileInput.files && fileInput.files.length > 0) {
|
||||
isUploading = true;
|
||||
// HTML5 form submit automatically triggers enhance
|
||||
fileInput.form?.requestSubmit();
|
||||
}
|
||||
}
|
||||
@@ -73,15 +71,14 @@
|
||||
</svg>
|
||||
<h3>{isUploading ? '正在上传...' : '点击或拖拽文件上传'}</h3>
|
||||
<p>存储于 Cloudflare R2</p>
|
||||
<input
|
||||
type="file"
|
||||
name="file"
|
||||
bind:this={fileInput}
|
||||
onchange={handleFileChange}
|
||||
style="display: none;"
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
name="file"
|
||||
bind:this={fileInput}
|
||||
onchange={handleFileChange}
|
||||
style="display: none;"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ export const load: PageServerLoad = async ({ params, platform }) => {
|
||||
|
||||
try {
|
||||
const post = await db.prepare(
|
||||
'SELECT id, title, slug, content, category_id FROM posts WHERE id = ?'
|
||||
).bind(postId).first<{ id: number; title: string; slug: string; content: string; category_id: number | null }>();
|
||||
'SELECT id, title, slug, content, cover, category_id FROM posts WHERE id = ?'
|
||||
).bind(postId).first<{ id: number; title: string; slug: string; content: string; cover: string | null; category_id: number | null }>();
|
||||
|
||||
if (!post) {
|
||||
throw error(404, '文章不存在');
|
||||
@@ -48,6 +48,7 @@ export const actions: Actions = {
|
||||
const title = formData.get('title')?.toString().trim();
|
||||
const slug = formData.get('slug')?.toString().trim() || '';
|
||||
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));
|
||||
|
||||
@@ -59,9 +60,9 @@ export const actions: Actions = {
|
||||
// 更新文章表
|
||||
await db.prepare(
|
||||
`UPDATE posts
|
||||
SET title = ?, slug = ?, content = ?, category_id = ?, updated_at = CURRENT_TIMESTAMP
|
||||
SET title = ?, slug = ?, content = ?, cover = ?, category_id = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`
|
||||
).bind(title, slug, content, categoryId, postId).run();
|
||||
).bind(title, slug, content, cover, categoryId, postId).run();
|
||||
|
||||
// 清除原有标签并重新绑定
|
||||
await db.prepare('DELETE FROM post_tags WHERE post_id = ?').bind(postId).run();
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
import type { PageData, ActionData } from './$types';
|
||||
import { deserialize } from '$app/forms';
|
||||
import MarkdownRenderer from '$lib/components/MarkdownRenderer.svelte';
|
||||
import FeaturedImagePicker from '$lib/components/admin/FeaturedImagePicker.svelte';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
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 mode = $state<'edit' | 'preview'>('edit');
|
||||
let selectedTags = $state<number[]>(data.selectedTagIds || []);
|
||||
let tagsList = $state(data.tags || []);
|
||||
@@ -236,6 +238,8 @@
|
||||
<button type="submit" class="btn btn-primary submit-btn">保存</button>
|
||||
</div>
|
||||
|
||||
<FeaturedImagePicker bind:cover />
|
||||
|
||||
<div class="sidebar-box">
|
||||
<h3>别名 *</h3>
|
||||
<input
|
||||
@@ -452,10 +456,11 @@
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-color);
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.5px;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
@@ -524,7 +529,7 @@
|
||||
}
|
||||
|
||||
.sidebar-box h3 {
|
||||
margin: 0 0 20px 0;
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
|
||||
@@ -29,6 +29,7 @@ export const actions: Actions = {
|
||||
const title = formData.get('title')?.toString().trim();
|
||||
const slug = formData.get('slug')?.toString().trim() || '';
|
||||
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));
|
||||
|
||||
@@ -38,10 +39,10 @@ export const actions: Actions = {
|
||||
|
||||
try {
|
||||
const res = await db.prepare(
|
||||
`INSERT INTO posts (title, slug, content, author_id, category_id, published_at, updated_at)
|
||||
VALUES (?, ?, ?, 1, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
`INSERT INTO posts (title, slug, content, cover, author_id, category_id, published_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 1, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
|
||||
RETURNING id`
|
||||
).bind(title, slug, content, categoryId).first<{ id: number }>();
|
||||
).bind(title, slug, content, cover, categoryId).first<{ id: number }>();
|
||||
|
||||
const postId = res?.id;
|
||||
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
import type { PageData, ActionData } from './$types';
|
||||
import { deserialize } from '$app/forms';
|
||||
import MarkdownRenderer from '$lib/components/MarkdownRenderer.svelte';
|
||||
import FeaturedImagePicker from '$lib/components/admin/FeaturedImagePicker.svelte';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let title = $state('');
|
||||
let slug = $state('');
|
||||
let content = $state('');
|
||||
let cover = $state('');
|
||||
let mode = $state<'edit' | 'preview'>('edit');
|
||||
|
||||
let tagsList = $state(data.tags || []);
|
||||
@@ -239,10 +241,12 @@
|
||||
<div class="form-sidebar">
|
||||
<div class="sidebar-box">
|
||||
<h3>发布</h3>
|
||||
<button type="submit" class="btn btn-primary submit-btn">发布</button>
|
||||
<button type="submit" class="btn btn-primary publish-btn">发布{$page.data.aliases?.post || '文章'}</button>
|
||||
</div>
|
||||
|
||||
<FeaturedImagePicker bind:cover />
|
||||
|
||||
<div class="sidebar-box">
|
||||
<div class="sidebar-section">
|
||||
<h3>别名 *</h3>
|
||||
<input
|
||||
type="text"
|
||||
@@ -457,10 +461,11 @@
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
margin-bottom: 12px;
|
||||
color: var(--text-color);
|
||||
font-size: 0.9rem;
|
||||
letter-spacing: 0.5px;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.form-group input[type="text"],
|
||||
@@ -529,7 +534,7 @@
|
||||
}
|
||||
|
||||
.sidebar-box h3 {
|
||||
margin: 0 0 20px 0;
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { PageData, ActionData } from './$types';
|
||||
import { enhance } from '$app/forms';
|
||||
import { addToast } from '$lib/stores/toast.svelte';
|
||||
import Tabs from '$lib/components/admin/Tabs.svelte';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
let activeTab = $state('basic');
|
||||
@@ -19,11 +20,14 @@
|
||||
<h2>系统设置</h2>
|
||||
</div>
|
||||
|
||||
<div class="tabs-header">
|
||||
<button class="tab-btn {activeTab === 'basic' ? 'active' : ''}" onclick={() => activeTab = 'basic'}>基础信息</button>
|
||||
<button class="tab-btn {activeTab === 'terms' ? 'active' : ''}" onclick={() => activeTab = 'terms'}>术语设定</button>
|
||||
<button class="tab-btn {activeTab === 'system' ? 'active' : ''}" onclick={() => activeTab = 'system'}>系统与安全</button>
|
||||
</div>
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ id: 'basic', label: '基础信息' },
|
||||
{ id: 'terms', label: '术语设定' },
|
||||
{ id: 'system', label: '系统与安全' }
|
||||
]}
|
||||
bind:activeTab
|
||||
/>
|
||||
|
||||
|
||||
<div class="settings-container">
|
||||
@@ -135,36 +139,6 @@
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.tabs-header {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 2px solid var(--border-color, rgba(0,0,0,0.1));
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 10px 15px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted, rgba(0,0,0,0.5));
|
||||
cursor: pointer;
|
||||
border-bottom: 3px solid transparent;
|
||||
margin-bottom: -2px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: var(--text-color);
|
||||
border-bottom-color: var(--accent-color, #419b38);
|
||||
}
|
||||
|
||||
|
||||
.settings-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
|
||||
@@ -41,13 +41,18 @@ export const load: PageServerLoad = async ({ platform, params }) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const post: any = await db.prepare(
|
||||
`SELECT p.id, p.title, p.content, p.published_at as date, u.username as author
|
||||
FROM posts p
|
||||
JOIN users u ON p.author_id = u.id
|
||||
const postRes = await db.prepare(
|
||||
`SELECT p.id, p.slug, p.title, p.content, p.cover, p.published_at as date,
|
||||
c.name as category_name, c.slug as category_slug,
|
||||
u.username as author_name
|
||||
FROM posts p
|
||||
LEFT JOIN categories c ON p.category_id = c.id
|
||||
LEFT JOIN users u ON p.author_id = u.id
|
||||
WHERE p.slug = ?`
|
||||
).bind(slug).first();
|
||||
|
||||
const post: any = postRes;
|
||||
|
||||
if (!post) {
|
||||
error(404, 'Post not found');
|
||||
}
|
||||
|
||||
@@ -63,6 +63,11 @@
|
||||
<main id="pjax-container">
|
||||
<div class="wrapper container narrow contents-wrap">
|
||||
<article class="article-detail yue">
|
||||
{#if data.post.cover}
|
||||
<div class="post-cover detail-cover">
|
||||
<img src={data.post.cover} alt={data.post.title} />
|
||||
</div>
|
||||
{/if}
|
||||
<div class="banner-title">
|
||||
<h1 class="post-title">{data.post.title}</h1>
|
||||
<p class="post-meta">
|
||||
|
||||
Reference in new issue
Block a user