feat: 添加文章附件管理组件及编辑器上传回调支持
This commit is contained in:
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: 
|
||||||
|
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: 
|
||||||
|
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>
|
||||||
@@ -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('`, file.name || '图片');
|
insertAtCursor('`, 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');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user