|
|
@ -3,6 +3,7 @@ import { ref, computed, onMounted } from 'vue' |
|
|
import { useRoute, useRouter } from 'vue-router' |
|
|
import { useRoute, useRouter } from 'vue-router' |
|
|
import { articleAdminApi, articleApi, tagApi, mediaApi } from '@/api/client' |
|
|
import { articleAdminApi, articleApi, tagApi, mediaApi } from '@/api/client' |
|
|
import type { ArticleDetail, Tag } from '@/generated/api' |
|
|
import type { ArticleDetail, Tag } from '@/generated/api' |
|
|
|
|
|
import QuillEditor from '@/components/QuillEditor.vue' |
|
|
|
|
|
|
|
|
const route = useRoute() |
|
|
const route = useRoute() |
|
|
const router = useRouter() |
|
|
const router = useRouter() |
|
|
@ -19,45 +20,6 @@ const loading = ref(false) |
|
|
const saving = ref(false) |
|
|
const saving = ref(false) |
|
|
const error = ref<string | null>(null) |
|
|
const error = ref<string | null>(null) |
|
|
const uploading = ref(false) |
|
|
const uploading = ref(false) |
|
|
const pasting = ref(false) |
|
|
|
|
|
|
|
|
|
|
|
async function uploadFile(file: File): Promise<string | null> { |
|
|
|
|
|
try { |
|
|
|
|
|
const resp = await mediaApi.apiMediaUploadPost(file, 'images') |
|
|
|
|
|
const stored = resp.data.data |
|
|
|
|
|
return stored?.url ?? null |
|
|
|
|
|
} catch (e) { |
|
|
|
|
|
console.error(e) |
|
|
|
|
|
error.value = '图片上传失败' |
|
|
|
|
|
return null |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
async function onPaste(event: ClipboardEvent) { |
|
|
|
|
|
const items = event.clipboardData?.items |
|
|
|
|
|
if (!items) return |
|
|
|
|
|
const files: File[] = [] |
|
|
|
|
|
for (const item of items) { |
|
|
|
|
|
if (item.type.startsWith('image/')) { |
|
|
|
|
|
const file = item.getAsFile() |
|
|
|
|
|
if (file) files.push(file) |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
if (files.length === 0) return |
|
|
|
|
|
event.preventDefault() |
|
|
|
|
|
pasting.value = true |
|
|
|
|
|
error.value = null |
|
|
|
|
|
try { |
|
|
|
|
|
for (const file of files) { |
|
|
|
|
|
const url = await uploadFile(file) |
|
|
|
|
|
if (url) { |
|
|
|
|
|
content.value += `\n\n` |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
} finally { |
|
|
|
|
|
pasting.value = false |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
async function loadTags() { |
|
|
async function loadTags() { |
|
|
try { |
|
|
try { |
|
|
@ -128,6 +90,10 @@ async function onUpload(event: Event) { |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
async function removeCover() { |
|
|
|
|
|
coverImage.value = '' |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
async function onSubmit() { |
|
|
async function onSubmit() { |
|
|
if (!title.value.trim() || !content.value.trim()) { |
|
|
if (!title.value.trim() || !content.value.trim()) { |
|
|
error.value = '标题和内容为必填项' |
|
|
error.value = '标题和内容为必填项' |
|
|
@ -170,39 +136,56 @@ onMounted(() => { |
|
|
</script> |
|
|
</script> |
|
|
|
|
|
|
|
|
<template> |
|
|
<template> |
|
|
<div> |
|
|
|
|
|
<h1 class="page-title">{{ isEdit ? '编辑文章' : '新建文章' }}</h1> |
|
|
|
|
|
|
|
|
<div v-if="loading" class="hint-text">加载中...</div> |
|
|
|
|
|
<div v-else class="editor-wrapper"> |
|
|
<p v-if="error" class="error-text">{{ error }}</p> |
|
|
<p v-if="error" class="error-text">{{ error }}</p> |
|
|
<p v-if="loading" class="hint-text">加载中...</p> |
|
|
|
|
|
|
|
|
|
|
|
<form v-else class="editor-form" @submit.prevent="onSubmit"> |
|
|
|
|
|
<label class="field-label" for="title">标题</label> |
|
|
|
|
|
<input id="title" v-model="title" class="form-input" type="text" maxlength="200" /> |
|
|
|
|
|
|
|
|
<div class="editor-layout"> |
|
|
|
|
|
<div class="editor-main"> |
|
|
|
|
|
<input |
|
|
|
|
|
id="title" |
|
|
|
|
|
v-model="title" |
|
|
|
|
|
class="title-input" |
|
|
|
|
|
type="text" |
|
|
|
|
|
placeholder="文章标题" |
|
|
|
|
|
maxlength="200" |
|
|
|
|
|
/> |
|
|
|
|
|
<QuillEditor v-model="content" /> |
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
<label class="field-label" for="summary">摘要</label> |
|
|
|
|
|
<textarea id="summary" v-model="summary" class="form-textarea" rows="3" maxlength="500"></textarea> |
|
|
|
|
|
|
|
|
<aside class="editor-sidebar"> |
|
|
|
|
|
<div class="side-section"> |
|
|
|
|
|
<h3 class="side-title">发布设置</h3> |
|
|
|
|
|
<button class="btn-primary" type="button" :disabled="saving" @click="onSubmit"> |
|
|
|
|
|
{{ saving ? '保存中...' : isEdit ? '更新文章' : '发布文章' }} |
|
|
|
|
|
</button> |
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
<label class="field-label" for="content">内容</label> |
|
|
|
|
|
|
|
|
<div class="side-section"> |
|
|
|
|
|
<h3 class="side-title">摘要</h3> |
|
|
<textarea |
|
|
<textarea |
|
|
id="content" |
|
|
|
|
|
v-model="content" |
|
|
|
|
|
class="form-textarea" |
|
|
|
|
|
rows="16" |
|
|
|
|
|
:placeholder="pasting ? '图片上传中...' : '支持粘贴图片自动上传为 Markdown 图片语法'" |
|
|
|
|
|
@paste="onPaste" |
|
|
|
|
|
></textarea> |
|
|
|
|
|
|
|
|
|
|
|
<label class="field-label" for="cover">封面图 URL</label> |
|
|
|
|
|
<div class="cover-row"> |
|
|
|
|
|
<input id="cover" v-model="coverImage" class="form-input" type="text" /> |
|
|
|
|
|
|
|
|
v-model="summary" |
|
|
|
|
|
class="summary-input" |
|
|
|
|
|
rows="4" |
|
|
|
|
|
maxlength="500" |
|
|
|
|
|
placeholder="文章摘要(可选)" |
|
|
|
|
|
/> |
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
|
|
|
<div class="side-section"> |
|
|
|
|
|
<h3 class="side-title">封面图</h3> |
|
|
|
|
|
<div v-if="coverImage" class="cover-preview-wrap"> |
|
|
|
|
|
<img :src="coverImage" class="cover-preview" alt="封面" /> |
|
|
|
|
|
<button class="btn-remove" type="button" @click="removeCover">移除</button> |
|
|
|
|
|
</div> |
|
|
<label class="upload-btn"> |
|
|
<label class="upload-btn"> |
|
|
{{ uploading ? '上传中...' : '上传' }} |
|
|
|
|
|
|
|
|
{{ uploading ? '上传中...' : '选择图片' }} |
|
|
<input type="file" accept="image/*" class="upload-input" @change="onUpload" /> |
|
|
<input type="file" accept="image/*" class="upload-input" @change="onUpload" /> |
|
|
</label> |
|
|
</label> |
|
|
</div> |
|
|
</div> |
|
|
<img v-if="coverImage" :src="coverImage" class="cover-preview" alt="封面预览" /> |
|
|
|
|
|
|
|
|
|
|
|
<label class="field-label">标签</label> |
|
|
|
|
|
|
|
|
<div class="side-section"> |
|
|
|
|
|
<h3 class="side-title">标签</h3> |
|
|
<div class="tag-picker"> |
|
|
<div class="tag-picker"> |
|
|
<label v-for="tag in tags" :key="tag.id" class="tag-option"> |
|
|
<label v-for="tag in tags" :key="tag.id" class="tag-option"> |
|
|
<input type="checkbox" :checked="selectedTagIds.includes(tag.id)" @change="toggleTag(tag.id)" /> |
|
|
<input type="checkbox" :checked="selectedTagIds.includes(tag.id)" @change="toggleTag(tag.id)" /> |
|
|
@ -210,81 +193,161 @@ onMounted(() => { |
|
|
</label> |
|
|
</label> |
|
|
<span v-if="tags.length === 0" class="hint-text">暂无标签</span> |
|
|
<span v-if="tags.length === 0" class="hint-text">暂无标签</span> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="form-actions"> |
|
|
|
|
|
<button class="btn-primary" type="submit" :disabled="saving"> |
|
|
|
|
|
{{ saving ? '保存中...' : '保存' }} |
|
|
|
|
|
</button> |
|
|
|
|
|
<router-link to="/admin/articles" class="btn-plain">取消</router-link> |
|
|
|
|
|
|
|
|
<div class="side-section"> |
|
|
|
|
|
<router-link to="/admin/articles" class="btn-cancel">取消</router-link> |
|
|
|
|
|
</div> |
|
|
|
|
|
</aside> |
|
|
</div> |
|
|
</div> |
|
|
</form> |
|
|
|
|
|
</div> |
|
|
</div> |
|
|
</template> |
|
|
</template> |
|
|
|
|
|
|
|
|
<style scoped> |
|
|
<style scoped> |
|
|
.page-title { |
|
|
|
|
|
font-family: var(--song-font-display); |
|
|
|
|
|
font-weight: 400; |
|
|
|
|
|
margin: 0 0 16px; |
|
|
|
|
|
|
|
|
.editor-wrapper { |
|
|
|
|
|
max-width: 1200px; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.error-text { |
|
|
|
|
|
color: var(--ac-danger); |
|
|
|
|
|
margin-bottom: 12px; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.hint-text { |
|
|
|
|
|
color: var(--ac-text-disabled); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.editor-layout { |
|
|
|
|
|
display: flex; |
|
|
|
|
|
gap: 24px; |
|
|
|
|
|
align-items: flex-start; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.editor-main { |
|
|
|
|
|
flex: 1; |
|
|
|
|
|
min-width: 0; |
|
|
|
|
|
display: flex; |
|
|
|
|
|
flex-direction: column; |
|
|
|
|
|
gap: 16px; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.title-input { |
|
|
|
|
|
width: 100%; |
|
|
|
|
|
padding: 12px 16px; |
|
|
|
|
|
border: 1px solid var(--ac-border); |
|
|
|
|
|
border-radius: var(--song-radius-l); |
|
|
|
|
|
font-family: 'Noto Serif SC', 'SimSun', 'STSong', serif; |
|
|
font-size: 22px; |
|
|
font-size: 22px; |
|
|
|
|
|
font-weight: 600; |
|
|
|
|
|
background: var(--ac-bg-card); |
|
|
|
|
|
color: var(--ac-text); |
|
|
|
|
|
outline: none; |
|
|
|
|
|
transition: border-color var(--song-dur-1) var(--song-ease); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.editor-form { |
|
|
|
|
|
|
|
|
.title-input:focus { |
|
|
|
|
|
border-color: var(--ac-primary); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.title-input::placeholder { |
|
|
|
|
|
color: var(--ac-text-disabled); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.editor-sidebar { |
|
|
|
|
|
width: 280px; |
|
|
|
|
|
flex-shrink: 0; |
|
|
|
|
|
display: flex; |
|
|
|
|
|
flex-direction: column; |
|
|
|
|
|
gap: 16px; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.side-section { |
|
|
background: var(--ac-bg-card); |
|
|
background: var(--ac-bg-card); |
|
|
border: 1px solid var(--ac-border); |
|
|
border: 1px solid var(--ac-border); |
|
|
border-radius: var(--song-radius-l); |
|
|
border-radius: var(--song-radius-l); |
|
|
padding: 20px; |
|
|
|
|
|
|
|
|
padding: 16px; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.field-label { |
|
|
|
|
|
display: block; |
|
|
|
|
|
margin: 14px 0 6px; |
|
|
|
|
|
|
|
|
.side-title { |
|
|
|
|
|
font-family: var(--song-font-serif); |
|
|
font-size: 14px; |
|
|
font-size: 14px; |
|
|
color: var(--ac-text-secondary); |
|
|
|
|
|
|
|
|
font-weight: 600; |
|
|
|
|
|
margin: 0 0 12px; |
|
|
|
|
|
color: var(--ac-text); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.form-input { |
|
|
|
|
|
|
|
|
.btn-primary { |
|
|
width: 100%; |
|
|
width: 100%; |
|
|
padding: 10px 12px; |
|
|
|
|
|
border: 1px solid var(--ac-input-border); |
|
|
|
|
|
|
|
|
padding: 10px 22px; |
|
|
|
|
|
border: none; |
|
|
border-radius: var(--song-radius-l); |
|
|
border-radius: var(--song-radius-l); |
|
|
|
|
|
background: var(--ac-primary-strong); |
|
|
|
|
|
color: var(--ac-primary-text); |
|
|
|
|
|
cursor: pointer; |
|
|
font-size: 14px; |
|
|
font-size: 14px; |
|
|
background: var(--ac-bg-card); |
|
|
|
|
|
color: var(--ac-text); |
|
|
|
|
|
transition: border-color var(--song-dur-1) var(--song-ease); |
|
|
|
|
|
|
|
|
transition: opacity var(--song-dur-1) var(--song-ease); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.form-input:focus, |
|
|
|
|
|
.form-textarea:focus { |
|
|
|
|
|
border-color: var(--ac-primary); |
|
|
|
|
|
outline: none; |
|
|
|
|
|
|
|
|
.btn-primary:hover:not(:disabled) { |
|
|
|
|
|
opacity: 0.9; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.form-textarea { |
|
|
|
|
|
|
|
|
.btn-primary:disabled { |
|
|
|
|
|
opacity: 0.6; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.summary-input { |
|
|
width: 100%; |
|
|
width: 100%; |
|
|
padding: 10px 12px; |
|
|
|
|
|
|
|
|
padding: 8px 12px; |
|
|
border: 1px solid var(--ac-input-border); |
|
|
border: 1px solid var(--ac-input-border); |
|
|
border-radius: var(--song-radius-l); |
|
|
border-radius: var(--song-radius-l); |
|
|
font-size: 14px; |
|
|
|
|
|
|
|
|
font-size: 13px; |
|
|
|
|
|
line-height: 1.5; |
|
|
resize: vertical; |
|
|
resize: vertical; |
|
|
font-family: inherit; |
|
|
font-family: inherit; |
|
|
background: var(--ac-bg-card); |
|
|
background: var(--ac-bg-card); |
|
|
color: var(--ac-text); |
|
|
color: var(--ac-text); |
|
|
|
|
|
outline: none; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.cover-row { |
|
|
|
|
|
display: flex; |
|
|
|
|
|
gap: 10px; |
|
|
|
|
|
|
|
|
.summary-input:focus { |
|
|
|
|
|
border-color: var(--ac-primary); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.cover-preview-wrap { |
|
|
|
|
|
margin-bottom: 8px; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.cover-preview { |
|
|
|
|
|
width: 100%; |
|
|
|
|
|
max-height: 120px; |
|
|
|
|
|
object-fit: cover; |
|
|
|
|
|
border-radius: var(--song-radius-m); |
|
|
|
|
|
border: 1px solid var(--ac-border); |
|
|
|
|
|
margin-bottom: 6px; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.btn-remove { |
|
|
|
|
|
border: none; |
|
|
|
|
|
background: none; |
|
|
|
|
|
color: var(--ac-danger-ink); |
|
|
|
|
|
font-size: 12px; |
|
|
|
|
|
cursor: pointer; |
|
|
|
|
|
padding: 0; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.btn-remove:hover { |
|
|
|
|
|
text-decoration: underline; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.upload-btn { |
|
|
.upload-btn { |
|
|
flex-shrink: 0; |
|
|
|
|
|
padding: 10px 16px; |
|
|
|
|
|
|
|
|
display: inline-block; |
|
|
|
|
|
padding: 8px 14px; |
|
|
background: var(--ac-primary-bg); |
|
|
background: var(--ac-primary-bg); |
|
|
color: var(--ac-primary-ink); |
|
|
color: var(--ac-primary-ink); |
|
|
border-radius: var(--song-radius-l); |
|
|
|
|
|
|
|
|
border-radius: var(--song-radius-m); |
|
|
cursor: pointer; |
|
|
cursor: pointer; |
|
|
font-size: 14px; |
|
|
|
|
|
|
|
|
font-size: 13px; |
|
|
transition: background var(--song-dur-1) var(--song-ease); |
|
|
transition: background var(--song-dur-1) var(--song-ease); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
@ -296,73 +359,34 @@ onMounted(() => { |
|
|
display: none; |
|
|
display: none; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.cover-preview { |
|
|
|
|
|
max-width: 220px; |
|
|
|
|
|
margin-top: 10px; |
|
|
|
|
|
border-radius: var(--song-radius-l); |
|
|
|
|
|
border: 1px solid var(--ac-border); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.tag-picker { |
|
|
.tag-picker { |
|
|
display: flex; |
|
|
display: flex; |
|
|
flex-wrap: wrap; |
|
|
|
|
|
gap: 12px; |
|
|
|
|
|
padding: 4px 0; |
|
|
|
|
|
|
|
|
flex-direction: column; |
|
|
|
|
|
gap: 6px; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.tag-option { |
|
|
.tag-option { |
|
|
display: inline-flex; |
|
|
display: inline-flex; |
|
|
align-items: center; |
|
|
align-items: center; |
|
|
gap: 4px; |
|
|
|
|
|
font-size: 14px; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.form-actions { |
|
|
|
|
|
margin-top: 20px; |
|
|
|
|
|
display: flex; |
|
|
|
|
|
gap: 12px; |
|
|
|
|
|
align-items: center; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.btn-primary { |
|
|
|
|
|
padding: 10px 22px; |
|
|
|
|
|
border: none; |
|
|
|
|
|
border-radius: var(--song-radius-l); |
|
|
|
|
|
background: var(--ac-primary-strong); |
|
|
|
|
|
color: var(--ac-primary-text); |
|
|
|
|
|
|
|
|
gap: 6px; |
|
|
|
|
|
font-size: 13px; |
|
|
cursor: pointer; |
|
|
cursor: pointer; |
|
|
font-size: 14px; |
|
|
|
|
|
transition: opacity var(--song-dur-1) var(--song-ease); |
|
|
|
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.btn-primary:hover:not(:disabled) { |
|
|
|
|
|
opacity: 0.9; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.btn-primary:disabled { |
|
|
|
|
|
opacity: 0.6; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.btn-plain { |
|
|
|
|
|
padding: 9px 16px; |
|
|
|
|
|
|
|
|
.btn-cancel { |
|
|
|
|
|
display: block; |
|
|
|
|
|
text-align: center; |
|
|
|
|
|
padding: 8px; |
|
|
border: 1px solid var(--ac-input-border); |
|
|
border: 1px solid var(--ac-input-border); |
|
|
border-radius: var(--song-radius-l); |
|
|
border-radius: var(--song-radius-l); |
|
|
color: var(--ac-text-secondary); |
|
|
color: var(--ac-text-secondary); |
|
|
text-decoration: none; |
|
|
text-decoration: none; |
|
|
font-size: 14px; |
|
|
|
|
|
|
|
|
font-size: 13px; |
|
|
transition: border-color var(--song-dur-1) var(--song-ease), color var(--song-dur-1) var(--song-ease); |
|
|
transition: border-color var(--song-dur-1) var(--song-ease), color var(--song-dur-1) var(--song-ease); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.btn-plain:hover { |
|
|
|
|
|
|
|
|
.btn-cancel:hover { |
|
|
border-color: var(--ac-primary); |
|
|
border-color: var(--ac-primary); |
|
|
color: var(--ac-primary-ink); |
|
|
color: var(--ac-primary-ink); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
.error-text { |
|
|
|
|
|
color: var(--ac-danger); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
.hint-text { |
|
|
|
|
|
color: var(--ac-text-disabled); |
|
|
|
|
|
} |
|
|
|
|
|
</style> |
|
|
</style> |