feat: 添加文章特色图功能并优化后台图库管理

This commit is contained in:
seahi committed 2026-07-29 23:03:34 +08:00
1 parent 8af6ae8864
commit 59e036af0e
16 files changed
+568 -66

No files matched your search

+1
View File
@@ -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
View File
@@ -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>
+85
View File
@@ -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>
+1 -1
View File
@@ -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();
+6 -1
View File
@@ -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>
+53
View File
@@ -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 });
}
};
+66
View File
@@ -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 });
}
};
+7 -10
View File
@@ -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>
+5 -4
View File
@@ -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();
+8 -3
View File
@@ -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;
+4 -3
View File
@@ -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;
+10 -5
View File
@@ -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;
+9 -35
View File
@@ -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));
+9 -4
View File
@@ -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');
}
+5
View File
@@ -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">