515 lines
13 KiB
Svelte
515 lines
13 KiB
Svelte
<script lang="ts">
|
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
import { page } from '$app/stores';
|
|
import type { PageData, ActionData } from './$types';
|
|
import { deserialize } from '$app/forms';
|
|
import { X } from '@lucide/svelte';
|
|
import ContentEditor from '$lib/components/admin/ContentEditor.svelte';
|
|
import { EditorOutline } from '$lib/editor/outline.svelte';
|
|
import FeaturedImagePicker from '$lib/components/admin/FeaturedImagePicker.svelte';
|
|
import SelectDropdown from '$lib/components/admin/SelectDropdown.svelte';
|
|
import TextInput from '$lib/components/admin/TextInput.svelte';
|
|
import Button from '$lib/components/admin/Button.svelte';
|
|
import Alert from '$lib/components/admin/Alert.svelte';
|
|
import EditorSidebarTabs from '$lib/components/admin/EditorSidebarTabs.svelte';
|
|
import ArticleAttachments from '$lib/components/admin/ArticleAttachments.svelte';
|
|
import { untrack } from 'svelte';
|
|
|
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
|
const initialData = $state.snapshot(untrack(() => data));
|
|
|
|
let title = $state(initialData.post.title);
|
|
let slug = $state(initialData.post.slug);
|
|
let content = $state(initialData.post.content || '');
|
|
let cover = $state(initialData.post.cover || '');
|
|
let mode = $state<'edit' | 'preview'>('edit');
|
|
let publishedAt = $state(initialData.post.published_at || '');
|
|
let updatedAt = $state(initialData.post.updated_at || '');
|
|
let selectedCategoryId = $state<number | string>(initialData.post.category_id ?? '');
|
|
let selectedSeriesId = $state<number | string>(initialData.post.series_id ?? '');
|
|
let seriesWeight = $state<number | string>(initialData.post.series_weight ?? '');
|
|
let selectedTags = $state<number[]>(initialData.selectedTagIds || []);
|
|
let tagsList = $state(initialData.tags || []);
|
|
let seriesList = $state(initialData.series || []);
|
|
|
|
const outline = new EditorOutline();
|
|
let sidebarTab = $state('settings');
|
|
let sessionUploads = $state<{ url: string; key: string; filename: string }[]>([]);
|
|
|
|
function toggleTag(tagId: number) {
|
|
if (selectedTags.includes(tagId)) {
|
|
selectedTags = selectedTags.filter((id) => id !== tagId);
|
|
} else {
|
|
selectedTags = [...selectedTags, tagId];
|
|
}
|
|
}
|
|
|
|
function handleTitleInput(e: Event) {
|
|
const target = e.target as HTMLInputElement;
|
|
title = target.value;
|
|
}
|
|
|
|
let newTagInput = $state('');
|
|
let isAddingTag = $state(false);
|
|
let showTagDropdown = $state(false);
|
|
|
|
let filteredTags = $derived(
|
|
(() => {
|
|
const input = newTagInput.trim().toLowerCase();
|
|
const available = tagsList.filter((t) => !selectedTags.includes(t.id));
|
|
if (!input) return available.slice(0, 5);
|
|
return available.filter((t) => t.name.toLowerCase().includes(input));
|
|
})()
|
|
);
|
|
|
|
async function handleAddTag(e: KeyboardEvent) {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault(); // 防止整个表单提交
|
|
const val = newTagInput.trim();
|
|
if (!val || isAddingTag) return;
|
|
|
|
// 检查是否已存在同名标签
|
|
const existingTag = tagsList.find((t) => t.name.toLowerCase() === val.toLowerCase());
|
|
if (existingTag) {
|
|
if (!selectedTags.includes(existingTag.id)) {
|
|
selectedTags = [...selectedTags, existingTag.id];
|
|
}
|
|
newTagInput = '';
|
|
showTagDropdown = false;
|
|
return;
|
|
}
|
|
|
|
isAddingTag = true;
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('name', val);
|
|
|
|
const res = await fetch('/admin/tags?/create', {
|
|
method: 'POST',
|
|
body: formData,
|
|
headers: {
|
|
'x-sveltekit-action': 'true'
|
|
}
|
|
});
|
|
const result = deserialize(await res.text()) as any;
|
|
|
|
if (result.type === 'success' && result.data?.tag) {
|
|
// 将新标签加入列表
|
|
tagsList = [...tagsList, result.data.tag];
|
|
// 自动选中新标签
|
|
selectedTags = [...selectedTags, result.data.tag.id];
|
|
}
|
|
} catch (err: any) {
|
|
console.error('Failed to create tag', err);
|
|
} finally {
|
|
isAddingTag = false;
|
|
newTagInput = '';
|
|
showTagDropdown = false;
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="admin-page-header">
|
|
<h2>编辑</h2>
|
|
<div class="header-actions">
|
|
<Button type="submit" form="post-form" variant="primary">保存</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{#if form?.error}
|
|
<Alert message={form.error} />
|
|
{/if}
|
|
|
|
<form method="POST" action="?/update" id="post-form" class="post-form">
|
|
<!-- 正文由页面统一持有并始终提交,避免切到「预览」时编辑器卸载导致正文丢失 -->
|
|
<input type="hidden" name="content" value={content} />
|
|
<input type="hidden" name="published_at" value={publishedAt} />
|
|
<input type="hidden" name="updated_at" value={updatedAt} />
|
|
<div class="form-grid">
|
|
<div class="form-main">
|
|
<div class="form-group">
|
|
<label for="title">标题 *</label>
|
|
<TextInput
|
|
type="text"
|
|
id="title"
|
|
name="title"
|
|
bind:value={title}
|
|
oninput={handleTitleInput}
|
|
placeholder="输入标题..."
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<div class="editor-header">
|
|
<label for="content">正文 *</label>
|
|
|
|
<div class="editor-header-right">
|
|
<div class="editor-tabs">
|
|
<button
|
|
type="button"
|
|
class="tab-btn {mode === 'edit' ? 'active' : ''}"
|
|
onclick={() => (mode = 'edit')}>编辑</button
|
|
>
|
|
<button
|
|
type="button"
|
|
class="tab-btn {mode === 'preview' ? 'active' : ''}"
|
|
onclick={() => (mode = 'preview')}>预览</button
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="editor-body">
|
|
<ContentEditor
|
|
bind:value={content}
|
|
{mode}
|
|
{outline}
|
|
onUpload={(img) => (sessionUploads = [...sessionUploads, img])}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-sidebar">
|
|
<EditorSidebarTabs {outline} bind:activeTab={sidebarTab} />
|
|
|
|
<!-- 设置面板始终挂载(仅切换显隐),确保别名/分类/系列/标签等字段在提交时始终存在 -->
|
|
<div class="sidebar-panel" class:is-hidden={sidebarTab !== 'settings'}>
|
|
<div class="sidebar-box">
|
|
<h3>别名 *</h3>
|
|
<TextInput
|
|
type="text"
|
|
id="slug"
|
|
name="slug"
|
|
bind:value={slug}
|
|
placeholder="hello-world"
|
|
required
|
|
variant="underline"
|
|
/>
|
|
</div>
|
|
|
|
<div class="sidebar-box">
|
|
<h3>{$page.data.aliases?.category || '分类'}</h3>
|
|
<SelectDropdown
|
|
name="category_id"
|
|
label={$page.data.aliases?.category || '分类'}
|
|
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>{$page.data.aliases?.tag || '标签'}</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="移除{$page.data.aliases?.tag || '标签'}"
|
|
><X size={14} aria-hidden="true" /></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="+ 添加{$page.data.aliases?.tag || '标签'}..."
|
|
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>
|
|
</div>
|
|
|
|
<!-- 媒体面板始终挂载(仅切换显隐),确保特色图的隐藏表单项在表单提交时始终存在 -->
|
|
<div class="sidebar-panel" class:is-hidden={sidebarTab !== 'media'}>
|
|
<div class="sidebar-box">
|
|
<FeaturedImagePicker bind:cover adaptiveHeight previewable />
|
|
</div>
|
|
<div class="sidebar-box">
|
|
<h3>当前{$page.data.aliases?.post || '文章'}附件</h3>
|
|
<ArticleAttachments bind:content bind:sessionUploads />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
<style>
|
|
.admin-page-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 25px;
|
|
border-bottom: 2px solid var(--text-color);
|
|
padding-bottom: 12px;
|
|
}
|
|
|
|
.admin-page-header h2 {
|
|
margin: 0;
|
|
font-size: 2rem;
|
|
color: var(--text-color);
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
|
|
.editor-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.editor-tabs {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
.tab-btn {
|
|
background: none;
|
|
border: none;
|
|
padding: 4px 12px;
|
|
font-size: 0.85rem;
|
|
font-weight: 600;
|
|
color: rgba(0, 0, 0, 0.4);
|
|
cursor: pointer;
|
|
border-radius: 4px;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.tab-btn:hover {
|
|
background: rgba(0, 0, 0, 0.05);
|
|
}
|
|
|
|
.tab-btn.active {
|
|
color: var(--accent-color, #419b38);
|
|
background: rgba(65, 155, 56, 0.1);
|
|
}
|
|
|
|
.form-grid {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1fr) 280px;
|
|
gap: 40px;
|
|
align-items: start;
|
|
}
|
|
|
|
.form-main {
|
|
min-width: 0;
|
|
width: 100%;
|
|
}
|
|
|
|
.form-sidebar {
|
|
position: sticky;
|
|
top: 20px;
|
|
width: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
z-index: 10;
|
|
max-height: calc(100vh - 40px);
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.sidebar-panel {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
}
|
|
|
|
.sidebar-panel.is-hidden {
|
|
display: none;
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
.form-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.form-sidebar {
|
|
position: static;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
margin-top: 20px;
|
|
max-height: none;
|
|
overflow-y: visible;
|
|
}
|
|
}
|
|
|
|
.form-group {
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.form-group label {
|
|
display: block;
|
|
font-weight: 700;
|
|
margin-bottom: 12px;
|
|
color: var(--text-color);
|
|
font-size: 0.9rem;
|
|
letter-spacing: 1px;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.sidebar-box {
|
|
background: transparent;
|
|
border: none;
|
|
border-radius: 0;
|
|
padding: 0;
|
|
}
|
|
|
|
.sidebar-box h3 {
|
|
margin: 0 0 12px 0;
|
|
font-size: 0.9rem;
|
|
font-weight: 700;
|
|
letter-spacing: 1px;
|
|
text-transform: uppercase;
|
|
color: var(--text-color);
|
|
border-bottom: none;
|
|
}
|
|
|
|
.selected-tags-vertical {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.selected-tag-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 6px 10px;
|
|
border: 1px solid var(--text-color);
|
|
background: transparent;
|
|
font-weight: 600;
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.remove-tag-btn {
|
|
background: transparent;
|
|
border: none;
|
|
color: var(--text-color);
|
|
font-size: 1.1rem;
|
|
cursor: pointer;
|
|
opacity: 0.7;
|
|
padding: 0 4px;
|
|
}
|
|
|
|
.remove-tag-btn:hover {
|
|
opacity: 1;
|
|
color: #d9534f;
|
|
}
|
|
|
|
.tag-input-container {
|
|
position: relative;
|
|
}
|
|
|
|
.tag-autocomplete-dropdown {
|
|
position: absolute;
|
|
top: 100%;
|
|
left: 0;
|
|
right: 0;
|
|
background: var(--bg-color);
|
|
border: 1px solid var(--text-color);
|
|
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.15);
|
|
z-index: 50;
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
margin-top: 4px;
|
|
}
|
|
|
|
.tag-dropdown-item {
|
|
background: transparent;
|
|
border: none;
|
|
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
|
padding: 8px 12px;
|
|
text-align: left;
|
|
font-family: inherit;
|
|
font-size: 0.85rem;
|
|
color: var(--text-color);
|
|
cursor: pointer;
|
|
font-weight: 600;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.tag-dropdown-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.tag-dropdown-item:hover {
|
|
background: var(--text-color);
|
|
color: var(--bg-color);
|
|
}
|
|
|
|
.editor-header-right {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 15px;
|
|
}
|
|
</style>
|