feat: 在文章与页面后台侧边栏集成媒体附件管理功能
continuous-integration/drone/push Build is passing

This commit is contained in:
seahi committed 2026-09-11 18:16:11 +08:00
1 parent ff7eac407a
commit 827ceccdf0
4 files changed
+395 -285

No files matched your search

+42 -17
View File
@@ -7,6 +7,8 @@
import TextInput from '$lib/components/admin/TextInput.svelte'; import TextInput from '$lib/components/admin/TextInput.svelte';
import Button from '$lib/components/admin/Button.svelte'; import Button from '$lib/components/admin/Button.svelte';
import Alert from '$lib/components/admin/Alert.svelte'; import Alert from '$lib/components/admin/Alert.svelte';
import Tabs from '$lib/components/admin/Tabs.svelte';
import ArticleAttachments from '$lib/components/admin/ArticleAttachments.svelte';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
let { data, form }: { data: PageData; form: ActionData } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
@@ -16,6 +18,9 @@
let slug = $state(initialData.page.slug); let slug = $state(initialData.page.slug);
let content = $state(initialData.page.content); let content = $state(initialData.page.content);
let mode = $state<'edit' | 'preview'>('edit'); let mode = $state<'edit' | 'preview'>('edit');
let sidebarTab = $state('settings');
let sessionUploads = $state<{ url: string; key: string; filename: string }[]>([]);
</script> </script>
<div class="admin-page-header"> <div class="admin-page-header">
@@ -71,7 +76,10 @@
<div class="editor-body"> <div class="editor-body">
{#if mode === 'edit'} {#if mode === 'edit'}
<MarkdownEditor bind:value={content} /> <MarkdownEditor
bind:value={content}
onUpload={(img) => (sessionUploads = [...sessionUploads, img])}
/>
{:else} {:else}
<div class="preview-area"> <div class="preview-area">
<MarkdownRenderer {content} /> <MarkdownRenderer {content} />
@@ -82,23 +90,40 @@
</div> </div>
<div class="form-sidebar"> <div class="form-sidebar">
<div class="sidebar-box"> <Tabs
<h3>更新</h3> tabs={[
<Button type="submit" variant="primary" fullWidth class="submit-btn">保存修改</Button> { id: 'settings', label: '设置' },
</div> { id: 'media', label: '媒体附件' }
]}
bind:activeTab={sidebarTab}
fullWidth={true}
small={true}
/>
<div class="sidebar-box"> {#if sidebarTab === 'settings'}
<h3>别名 *</h3> <div class="sidebar-box">
<TextInput <h3>更新</h3>
type="text" <Button type="submit" variant="primary" fullWidth class="submit-btn">保存修改</Button>
id="slug" </div>
name="slug"
bind:value={slug} <div class="sidebar-box">
placeholder="hello-world" <h3>别名 *</h3>
required <TextInput
variant="underline" type="text"
/> id="slug"
</div> name="slug"
bind:value={slug}
placeholder="hello-world"
required
variant="underline"
/>
</div>
{:else}
<div class="sidebar-box">
<h3>当前页面附件</h3>
<ArticleAttachments bind:content bind:sessionUploads />
</div>
{/if}
</div> </div>
</div> </div>
</form> </form>
+53 -28
View File
@@ -7,6 +7,8 @@
import TextInput from '$lib/components/admin/TextInput.svelte'; import TextInput from '$lib/components/admin/TextInput.svelte';
import Button from '$lib/components/admin/Button.svelte'; import Button from '$lib/components/admin/Button.svelte';
import Alert from '$lib/components/admin/Alert.svelte'; import Alert from '$lib/components/admin/Alert.svelte';
import Tabs from '$lib/components/admin/Tabs.svelte';
import ArticleAttachments from '$lib/components/admin/ArticleAttachments.svelte';
import { createSlugAutoHandler } from '$lib/utils/slugify'; import { createSlugAutoHandler } from '$lib/utils/slugify';
@@ -19,6 +21,9 @@
let isSlugUserModified = $state(false); let isSlugUserModified = $state(false);
let isGenerating = $state(false); let isGenerating = $state(false);
let sidebarTab = $state('settings');
let sessionUploads = $state<{ url: string; key: string; filename: string }[]>([]);
const slugHandler = createSlugAutoHandler({ const slugHandler = createSlugAutoHandler({
getSlug: () => slug, getSlug: () => slug,
setSlug: (val) => (slug = val), setSlug: (val) => (slug = val),
@@ -80,7 +85,10 @@
<div class="editor-body"> <div class="editor-body">
{#if mode === 'edit'} {#if mode === 'edit'}
<MarkdownEditor bind:value={content} /> <MarkdownEditor
bind:value={content}
onUpload={(img) => (sessionUploads = [...sessionUploads, img])}
/>
{:else} {:else}
<div class="preview-area"> <div class="preview-area">
<MarkdownRenderer {content} /> <MarkdownRenderer {content} />
@@ -91,34 +99,51 @@
</div> </div>
<div class="form-sidebar"> <div class="form-sidebar">
<div class="sidebar-box"> <Tabs
<h3>发布</h3> tabs={[
<Button type="submit" variant="primary" fullWidth class="submit-btn">发布</Button> { id: 'settings', label: '设置' },
</div> { id: 'media', label: '媒体附件' }
]}
bind:activeTab={sidebarTab}
fullWidth={true}
small={true}
/>
<div class="sidebar-box"> {#if sidebarTab === 'settings'}
<h3 style="display: flex; justify-content: space-between; align-items: center;"> <div class="sidebar-box">
<span>别名 *</span> <h3>发布</h3>
<button <Button type="submit" variant="primary" fullWidth class="submit-btn">发布</Button>
type="button" </div>
style="background: none; border: none; padding: 0; color: #666; font-size: 0.8rem; cursor: pointer; outline: none;"
onclick={() => slugHandler.forceGenerate(title)} <div class="sidebar-box">
disabled={isGenerating} <h3 style="display: flex; justify-content: space-between; align-items: center;">
> <span>别名 *</span>
{isGenerating ? '正在生成...' : '重新生成'} <button
</button> type="button"
</h3> style="background: none; border: none; padding: 0; color: #666; font-size: 0.8rem; cursor: pointer; outline: none;"
<TextInput onclick={() => slugHandler.forceGenerate(title)}
type="text" disabled={isGenerating}
id="slug" >
name="slug" {isGenerating ? '正在生成...' : '重新生成'}
bind:value={slug} </button>
oninput={handleSlugInput} </h3>
placeholder="about" <TextInput
required type="text"
variant="underline" id="slug"
/> name="slug"
</div> bind:value={slug}
oninput={handleSlugInput}
placeholder="about"
required
variant="underline"
/>
</div>
{:else}
<div class="sidebar-box">
<h3>当前页面附件</h3>
<ArticleAttachments bind:content bind:sessionUploads />
</div>
{/if}
</div> </div>
</div> </div>
</form> </form>
+143 -113
View File
@@ -11,6 +11,8 @@
import TextInput from '$lib/components/admin/TextInput.svelte'; import TextInput from '$lib/components/admin/TextInput.svelte';
import Button from '$lib/components/admin/Button.svelte'; import Button from '$lib/components/admin/Button.svelte';
import Alert from '$lib/components/admin/Alert.svelte'; import Alert from '$lib/components/admin/Alert.svelte';
import Tabs from '$lib/components/admin/Tabs.svelte';
import ArticleAttachments from '$lib/components/admin/ArticleAttachments.svelte';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
let { data, form }: { data: PageData; form: ActionData } = $props(); let { data, form }: { data: PageData; form: ActionData } = $props();
@@ -30,6 +32,9 @@
let tagsList = $state(initialData.tags || []); let tagsList = $state(initialData.tags || []);
let seriesList = $state(initialData.series || []); let seriesList = $state(initialData.series || []);
let sidebarTab = $state('settings');
let sessionUploads = $state<{ url: string; key: string; filename: string }[]>([]);
function toggleTag(tagId: number) { function toggleTag(tagId: number) {
if (selectedTags.includes(tagId)) { if (selectedTags.includes(tagId)) {
selectedTags = selectedTags.filter((id) => id !== tagId); selectedTags = selectedTags.filter((id) => id !== tagId);
@@ -155,7 +160,10 @@
<div class="editor-body"> <div class="editor-body">
{#if mode === 'edit'} {#if mode === 'edit'}
<MarkdownEditor bind:value={content} /> <MarkdownEditor
bind:value={content}
onUpload={(img) => (sessionUploads = [...sessionUploads, img])}
/>
{:else} {:else}
<div class="preview-area"> <div class="preview-area">
<MarkdownRenderer {content} /> <MarkdownRenderer {content} />
@@ -166,126 +174,148 @@
</div> </div>
<div class="form-sidebar"> <div class="form-sidebar">
<div class="sidebar-box"> <Tabs
<h3>保存</h3> tabs={[
<Button type="submit" variant="primary" fullWidth class="submit-btn">保存</Button> { id: 'settings', label: '设置' },
</div> { id: 'media', label: '媒体附件' }
]}
bind:activeTab={sidebarTab}
fullWidth={true}
small={true}
/>
<FeaturedImagePicker bind:cover adaptiveHeight previewable /> {#if sidebarTab === 'settings'}
<div class="sidebar-box">
<div class="sidebar-box"> <h3>保存</h3>
<h3>别名 *</h3> <Button type="submit" variant="primary" fullWidth class="submit-btn">保存</Button>
<TextInput
type="text"
id="slug"
name="slug"
bind:value={slug}
placeholder="hello-world"
required
variant="underline"
/>
</div>
<div class="sidebar-box">
<h3>分类</h3>
<SelectDropdown
name="category_id"
label="分类"
options={data.categories.map((category) => ({
value: category.id,
label: category.name
}))}
bind:value={selectedCategoryId}
/>
</div>
<div class="sidebar-box">
<h3>{$page.data.aliases?.series || '系列'}</h3>
<SelectDropdown
name="series_id"
label={$page.data.aliases?.series || '系列'}
options={seriesList.map((series) => ({ value: series.id, label: series.name }))}
bind:value={selectedSeriesId}
/>
<TextInput
type="number"
name="series_weight"
style="margin-top: 10px;"
bind:value={seriesWeight}
placeholder="顺序(留空自动追加)"
disabled={!selectedSeriesId}
step="1"
variant="underline"
/>
</div>
<div class="sidebar-box">
<h3>标签</h3>
<div class="selected-tags-vertical">
{#each selectedTags as tagId (tagId)}
{#if tagsList.find((t) => t.id === tagId)}
{@const tag = tagsList.find((t) => t.id === tagId)}
<div class="selected-tag-row">
<span>{tag?.name}</span>
<button
type="button"
class="remove-tag-btn"
onclick={() => toggleTag(tagId)}
title="移除标签"
><svg
viewBox="0 0 24 24"
width="14"
height="14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"
></line></svg
></button
>
<input type="hidden" name="tags" value={tagId} />
</div>
{/if}
{/each}
</div> </div>
<div class="tag-input-container"> <div class="sidebar-box">
<h3>别名 *</h3>
<TextInput <TextInput
type="text" type="text"
class="tag-input-new" id="slug"
placeholder="+ 添加标签..." name="slug"
bind:value={newTagInput} bind:value={slug}
onkeydown={handleAddTag} placeholder="hello-world"
onfocus={() => (showTagDropdown = true)} required
onblur={() => setTimeout(() => (showTagDropdown = false), 200)}
disabled={isAddingTag}
autocomplete="off"
variant="underline" variant="underline"
/> />
{#if showTagDropdown && filteredTags.length > 0}
<div class="tag-autocomplete-dropdown">
{#each filteredTags as tag (tag.id || tag)}
<button
type="button"
class="tag-dropdown-item"
onclick={() => {
if (!selectedTags.includes(tag.id)) {
selectedTags = [...selectedTags, tag.id];
}
newTagInput = '';
showTagDropdown = false;
}}
>
{tag.name}
</button>
{/each}
</div>
{/if}
</div> </div>
</div>
<div class="sidebar-box">
<h3>分类</h3>
<SelectDropdown
name="category_id"
label="分类"
options={data.categories.map((category) => ({
value: category.id,
label: category.name
}))}
bind:value={selectedCategoryId}
/>
</div>
<div class="sidebar-box">
<h3>{$page.data.aliases?.series || '系列'}</h3>
<SelectDropdown
name="series_id"
label={$page.data.aliases?.series || '系列'}
options={seriesList.map((series) => ({ value: series.id, label: series.name }))}
bind:value={selectedSeriesId}
/>
<TextInput
type="number"
name="series_weight"
style="margin-top: 10px;"
bind:value={seriesWeight}
placeholder="顺序(留空自动追加)"
disabled={!selectedSeriesId}
step="1"
variant="underline"
/>
</div>
<div class="sidebar-box">
<h3>标签</h3>
<div class="selected-tags-vertical">
{#each selectedTags as tagId (tagId)}
{#if tagsList.find((t) => t.id === tagId)}
{@const tag = tagsList.find((t) => t.id === tagId)}
<div class="selected-tag-row">
<span>{tag?.name}</span>
<button
type="button"
class="remove-tag-btn"
onclick={() => toggleTag(tagId)}
title="移除标签"
><svg
viewBox="0 0 24 24"
width="14"
height="14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
><line x1="18" y1="6" x2="6" y2="18"></line><line
x1="6"
y1="6"
x2="18"
y2="18"
></line></svg
></button
>
<input type="hidden" name="tags" value={tagId} />
</div>
{/if}
{/each}
</div>
<div class="tag-input-container">
<TextInput
type="text"
class="tag-input-new"
placeholder="+ 添加标签..."
bind:value={newTagInput}
onkeydown={handleAddTag}
onfocus={() => (showTagDropdown = true)}
onblur={() => setTimeout(() => (showTagDropdown = false), 200)}
disabled={isAddingTag}
autocomplete="off"
variant="underline"
/>
{#if showTagDropdown && filteredTags.length > 0}
<div class="tag-autocomplete-dropdown">
{#each filteredTags as tag (tag.id || tag)}
<button
type="button"
class="tag-dropdown-item"
onclick={() => {
if (!selectedTags.includes(tag.id)) {
selectedTags = [...selectedTags, tag.id];
}
newTagInput = '';
showTagDropdown = false;
}}
>
{tag.name}
</button>
{/each}
</div>
{/if}
</div>
</div>
{:else}
<div class="sidebar-box">
<FeaturedImagePicker bind:cover adaptiveHeight previewable />
</div>
<div class="sidebar-box">
<h3>当前文章附件</h3>
<ArticleAttachments bind:content bind:sessionUploads />
</div>
{/if}
</div> </div>
</div> </div>
</form> </form>
+157 -127
View File
@@ -12,6 +12,8 @@
import TextInput from '$lib/components/admin/TextInput.svelte'; import TextInput from '$lib/components/admin/TextInput.svelte';
import Button from '$lib/components/admin/Button.svelte'; import Button from '$lib/components/admin/Button.svelte';
import Alert from '$lib/components/admin/Alert.svelte'; import Alert from '$lib/components/admin/Alert.svelte';
import Tabs from '$lib/components/admin/Tabs.svelte';
import ArticleAttachments from '$lib/components/admin/ArticleAttachments.svelte';
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import { createSlugAutoHandler } from '$lib/utils/slugify'; import { createSlugAutoHandler } from '$lib/utils/slugify';
@@ -44,6 +46,9 @@
setIsGenerating: (val) => (isGenerating = val) setIsGenerating: (val) => (isGenerating = val)
}); });
let sidebarTab = $state('settings');
let sessionUploads = $state<{ url: string; key: string; filename: string }[]>([]);
function handleTitleInput() { function handleTitleInput() {
slugHandler.onNameInput(title); slugHandler.onNameInput(title);
} }
@@ -173,7 +178,10 @@
<div class="editor-body"> <div class="editor-body">
{#if mode === 'edit'} {#if mode === 'edit'}
<MarkdownEditor bind:value={content} /> <MarkdownEditor
bind:value={content}
onUpload={(img) => (sessionUploads = [...sessionUploads, img])}
/>
{:else} {:else}
<div class="preview-area"> <div class="preview-area">
<MarkdownRenderer {content} /> <MarkdownRenderer {content} />
@@ -184,140 +192,162 @@
</div> </div>
<div class="form-sidebar"> <div class="form-sidebar">
<div class="sidebar-box"> <Tabs
<h3>发布</h3> tabs={[
<Button type="submit" variant="primary" fullWidth class="publish-btn"> { id: 'settings', label: '设置' },
发布{$page.data.aliases?.post || '文章'} { id: 'media', label: '媒体附件' }
</Button> ]}
</div> bind:activeTab={sidebarTab}
fullWidth={true}
small={true}
/>
<FeaturedImagePicker bind:cover adaptiveHeight previewable /> {#if sidebarTab === 'settings'}
<div class="sidebar-box">
<div class="sidebar-box"> <h3>发布</h3>
<h3 style="display: flex; justify-content: space-between; align-items: center;"> <Button type="submit" variant="primary" fullWidth class="publish-btn">
<span>别名 *</span> 发布{$page.data.aliases?.post || '文章'}
<button </Button>
type="button"
style="background: none; border: none; padding: 0; color: #666; font-size: 0.8rem; cursor: pointer; outline: none;"
title="生成英文 Slug"
onclick={() => slugHandler.forceGenerate(title)}
disabled={isGenerating}
>
{isGenerating ? '正在生成...' : '重新生成'}
</button>
</h3>
<TextInput
type="text"
id="slug"
name="slug"
bind:value={slug}
oninput={handleSlugInput}
placeholder="hello-world"
required
variant="underline"
/>
</div>
<div class="sidebar-box">
<h3>分类</h3>
<SelectDropdown
name="category_id"
label="分类"
options={data.categories.map((category) => ({
value: category.id,
label: category.name
}))}
bind:value={selectedCategoryId}
/>
</div>
<div class="sidebar-box">
<h3>{$page.data.aliases?.series || '系列'}</h3>
<SelectDropdown
name="series_id"
label={$page.data.aliases?.series || '系列'}
options={seriesList.map((series) => ({ value: series.id, label: series.name }))}
bind:value={selectedSeriesId}
/>
<TextInput
type="number"
name="series_weight"
style="margin-top: 10px;"
bind:value={seriesWeight}
placeholder="顺序(留空自动追加)"
disabled={!selectedSeriesId}
step="1"
variant="underline"
/>
</div>
<div class="sidebar-box">
<h3>标签</h3>
<div class="selected-tags-vertical">
{#each selectedTags as tagId (tagId)}
{#if tagsList.find((t) => t.id === tagId)}
{@const tag = tagsList.find((t) => t.id === tagId)}
<div class="selected-tag-row">
<span>{tag?.name}</span>
<button
type="button"
class="remove-tag-btn"
onclick={() => toggleTag(tagId)}
title="移除标签"
><svg
viewBox="0 0 24 24"
width="14"
height="14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"
></line></svg
></button
>
<input type="hidden" name="tags" value={tagId} />
</div>
{/if}
{/each}
</div> </div>
<div class="tag-input-container"> <div class="sidebar-box">
<h3 style="display: flex; justify-content: space-between; align-items: center;">
<span>别名 *</span>
<button
type="button"
style="background: none; border: none; padding: 0; color: #666; font-size: 0.8rem; cursor: pointer; outline: none;"
title="生成英文 Slug"
onclick={() => slugHandler.forceGenerate(title)}
disabled={isGenerating}
>
{isGenerating ? '正在生成...' : '重新生成'}
</button>
</h3>
<TextInput <TextInput
type="text" type="text"
class="tag-input-new" id="slug"
placeholder="+ 添加标签..." name="slug"
bind:value={newTagInput} bind:value={slug}
onkeydown={handleAddTag} oninput={handleSlugInput}
onfocus={() => (showTagDropdown = true)} placeholder="hello-world"
onblur={() => setTimeout(() => (showTagDropdown = false), 200)} required
disabled={isAddingTag}
autocomplete="off"
variant="underline" variant="underline"
/> />
{#if showTagDropdown && filteredTags.length > 0}
<div class="tag-autocomplete-dropdown">
{#each filteredTags as tag (tag.id || tag)}
<button
type="button"
class="tag-dropdown-item"
onclick={() => {
if (!selectedTags.includes(tag.id)) {
selectedTags = [...selectedTags, tag.id];
}
newTagInput = '';
showTagDropdown = false;
}}
>
{tag.name}
</button>
{/each}
</div>
{/if}
</div> </div>
</div>
<div class="sidebar-box">
<h3>分类</h3>
<SelectDropdown
name="category_id"
label="分类"
options={data.categories.map((category) => ({
value: category.id,
label: category.name
}))}
bind:value={selectedCategoryId}
/>
</div>
<div class="sidebar-box">
<h3>{$page.data.aliases?.series || '系列'}</h3>
<SelectDropdown
name="series_id"
label={$page.data.aliases?.series || '系列'}
options={seriesList.map((series) => ({ value: series.id, label: series.name }))}
bind:value={selectedSeriesId}
/>
<TextInput
type="number"
name="series_weight"
style="margin-top: 10px;"
bind:value={seriesWeight}
placeholder="顺序(留空自动追加)"
disabled={!selectedSeriesId}
step="1"
variant="underline"
/>
</div>
<div class="sidebar-box">
<h3>标签</h3>
<div class="selected-tags-vertical">
{#each selectedTags as tagId (tagId)}
{#if tagsList.find((t) => t.id === tagId)}
{@const tag = tagsList.find((t) => t.id === tagId)}
<div class="selected-tag-row">
<span>{tag?.name}</span>
<button
type="button"
class="remove-tag-btn"
onclick={() => toggleTag(tagId)}
title="移除标签"
><svg
viewBox="0 0 24 24"
width="14"
height="14"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
><line x1="18" y1="6" x2="6" y2="18"></line><line
x1="6"
y1="6"
x2="18"
y2="18"
></line></svg
></button
>
<input type="hidden" name="tags" value={tagId} />
</div>
{/if}
{/each}
</div>
<div class="tag-input-container">
<TextInput
type="text"
class="tag-input-new"
placeholder="+ 添加标签..."
bind:value={newTagInput}
onkeydown={handleAddTag}
onfocus={() => (showTagDropdown = true)}
onblur={() => setTimeout(() => (showTagDropdown = false), 200)}
disabled={isAddingTag}
autocomplete="off"
variant="underline"
/>
{#if showTagDropdown && filteredTags.length > 0}
<div class="tag-autocomplete-dropdown">
{#each filteredTags as tag (tag.id || tag)}
<button
type="button"
class="tag-dropdown-item"
onclick={() => {
if (!selectedTags.includes(tag.id)) {
selectedTags = [...selectedTags, tag.id];
}
newTagInput = '';
showTagDropdown = false;
}}
>
{tag.name}
</button>
{/each}
</div>
{/if}
</div>
</div>
{:else}
<div class="sidebar-box">
<FeaturedImagePicker bind:cover adaptiveHeight previewable />
</div>
<div class="sidebar-box">
<h3>当前文章附件</h3>
<ArticleAttachments bind:content bind:sessionUploads />
</div>
{/if}
</div> </div>
</div> </div>
</form> </form>