feat: 添加文章附件管理组件及编辑器上传回调支持

This commit is contained in:
seahi committed 2026-09-11 18:16:11 +08:00
1 parent e719ea1300
commit ff7eac407a
2 files changed
+276 -1

No files matched your search

@@ -0,0 +1,264 @@
<script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import { addToast } from '$lib/stores/toast.svelte';
import { confirmState } from '$lib/stores/confirm.svelte';
import { SvelteSet, SvelteMap } from 'svelte/reactivity';
let {
content = $bindable(''),
sessionUploads = $bindable<{ url: string; key: string; filename: string }[]>([])
} = $props<{
content?: string;
sessionUploads?: { url: string; key: string; filename: string }[];
}>();
let isDeleting = $state(false);
// Parse URLs from Markdown content
let parsedUrls = $derived(
(() => {
const urls = new SvelteSet<string>();
if (!content) return Array.from(urls);
// Match markdown images: ![alt](url)
const mdImgRegex = /!\[.*?\]\((.*?)\)/g;
let match;
while ((match = mdImgRegex.exec(content)) !== null) {
if (match[1]) urls.add(match[1].trim());
}
// Match HTML images: <img src="url" />
const htmlImgRegex = /<img[^>]+src="([^">]+)"/g;
while ((match = htmlImgRegex.exec(content)) !== null) {
if (match[1]) urls.add(match[1].trim());
}
return Array.from(urls);
})()
);
// Combine parsed URLs and session uploads
let attachments = $derived(
(() => {
const map = new SvelteMap<
string,
{ url: string; filename: string; source: 'session' | 'content' }
>();
// Add session uploads first
for (const item of sessionUploads) {
map.set(item.url, { url: item.url, filename: item.filename, source: 'session' });
}
// Add parsed URLs if they don't exist
for (const url of parsedUrls) {
if (!map.has(url)) {
// We don't know the exact filename from just the URL, so we extract the last part
const filename = url.split('/').pop()?.split('?')[0] || 'unknown_image';
map.set(url, { url, filename, source: 'content' });
} else {
// It's in both session and content
const existing = map.get(url)!;
existing.source = 'content';
}
}
return Array.from(map.values());
})()
);
function escapeRegExp(string: string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
}
async function handleDelete(attachment: { url: string; filename: string }) {
if (isDeleting) return;
const confirmed = await confirmState.show(
'确定要彻底删除该附件吗?此操作将从服务器和文章正文中删除此文件。'
);
if (!confirmed) return;
isDeleting = true;
try {
const res = await fetch('/admin/api/upload', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ url: attachment.url })
});
const data = (await res.json()) as any;
if (data.success) {
addToast('附件已成功删除', 'success');
// 1. Remove from sessionUploads
sessionUploads = sessionUploads.filter((item: any) => item.url !== attachment.url);
// 2. Remove from content
if (content) {
const escapedUrl = escapeRegExp(attachment.url);
// Remove markdown image: ![...](url)
const mdRegex = new RegExp(`!\\[.*?\\]\\(\\s*${escapedUrl}\\s*\\)`, 'g');
content = content.replace(mdRegex, '');
// Remove HTML image: <img ... src="url" ... />
const htmlRegex = new RegExp(`<img[^>]+src="${escapedUrl}"[^>]*>`, 'g');
content = content.replace(htmlRegex, '');
}
} else {
addToast(data.error || '删除失败', 'error');
}
} catch (err) {
console.error(err);
addToast('网络错误,删除失败', 'error');
} finally {
isDeleting = false;
}
}
</script>
<div class="article-attachments">
{#if attachments.length === 0}
<div class="empty-state">当前文章暂无附件</div>
{:else}
<div class="attachment-list">
{#each attachments as attachment (attachment.url)}
<div class="attachment-item">
<div class="preview">
<img src={attachment.url} alt={attachment.filename} loading="lazy" />
</div>
<div class="info">
<div class="filename" title={attachment.filename}>{attachment.filename}</div>
<div class="status">
{#if attachment.source === 'session'}
<span class="badge badge-new">本次上传 (未引用)</span>
{:else}
<span class="badge badge-ref">正文引用</span>
{/if}
</div>
</div>
<button
type="button"
class="btn-delete"
onclick={() => handleDelete(attachment)}
disabled={isDeleting}
title="彻底删除"
>
删除
</button>
</div>
{/each}
</div>
{/if}
</div>
<style>
.article-attachments {
margin-top: 10px;
}
.empty-state {
text-align: center;
padding: 20px;
background: rgba(0, 0, 0, 0.02);
border: 1px dashed var(--text-color);
color: var(--text-muted);
font-size: 0.9rem;
}
.attachment-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.attachment-item {
display: flex;
align-items: center;
padding: 8px;
border: 1px solid rgba(0, 0, 0, 0.1);
background: var(--bg-color);
gap: 12px;
transition: border-color 0.2s;
}
.attachment-item:hover {
border-color: var(--text-color);
}
.preview {
width: 48px;
height: 48px;
flex-shrink: 0;
border-radius: 4px;
overflow: hidden;
background: #f5f5f5;
}
.preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.filename {
font-size: 0.85rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.status {
font-size: 0.75rem;
}
.badge {
padding: 2px 6px;
border-radius: 2px;
font-weight: 600;
}
.badge-new {
background: rgba(240, 173, 78, 0.2);
color: #d68315;
}
.badge-ref {
background: rgba(65, 155, 56, 0.15);
color: var(--accent-color, #419b38);
}
.btn-delete {
background: transparent;
border: 1px solid rgba(217, 83, 79, 0.5);
color: #d9534f;
padding: 4px 10px;
font-size: 0.8rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-delete:hover {
background: #d9534f;
color: white;
border-color: #d9534f;
}
.btn-delete:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
+12 -1
View File
@@ -8,7 +8,15 @@
import { placeholder as cmPlaceholder } from '@codemirror/view'; import { placeholder as cmPlaceholder } from '@codemirror/view';
import { addToast } from '$lib/stores/toast.svelte'; import { addToast } from '$lib/stores/toast.svelte';
let { value = $bindable(''), placeholderText = '支持 Markdown 格式...' } = $props(); let {
value = $bindable(''),
placeholderText = '支持 Markdown 格式...',
onUpload
} = $props<{
value?: string;
placeholderText?: string;
onUpload?: (file: { url: string; key: string; filename: string }) => void;
}>();
let editorContainer: HTMLDivElement; let editorContainer: HTMLDivElement;
let view: EditorView; let view: EditorView;
@@ -36,6 +44,9 @@
if (data.success) { if (data.success) {
insertAtCursor('![', `](${data.url})`, file.name || '图片'); insertAtCursor('![', `](${data.url})`, file.name || '图片');
addToast('图片上传成功', 'success'); addToast('图片上传成功', 'success');
if (onUpload) {
onUpload({ url: data.url, key: data.key, filename: data.filename });
}
} else { } else {
addToast(data.error || '上传失败', 'error'); addToast(data.error || '上传失败', 'error');
} }