编辑器 Markdown 工具栏 + 后台主题切换移入侧栏 footer

- EditorView:新增工具栏(粗体/斜体/删除线/行内码/代码块/链接/图片等选区插入),移除封面 URL 输入框
- AdminLayout:删除无样式的 .theme-zone,ThemeSwitcher 并入 footer 行(左账号信息、右主题按钮)
This commit is contained in:
Sakurasan
2026-09-21 22:42:47 +08:00
parent a721e26e45
commit 3918df5bfd
3 changed files with 351 additions and 35 deletions
+5 -5
View File
@@ -128,12 +128,12 @@ watch(() => route.path, () => loadCounts())
<span>查看前台</span>
</a>
</nav>
<div class="theme-zone">
<ThemeSwitcher variant="inline" />
</div>
<div class="footer">
<div class="who">{{ session.user || 'admin' }}</div>
<button class="leave" @click="logout">退出登录 →</button>
<div class="meta">
<div class="who">{{ session.user || 'admin' }}</div>
<button class="leave" @click="logout">退出登录 →</button>
</div>
<ThemeSwitcher variant="inline" />
</div>
</aside>
+258 -29
View File
@@ -369,21 +369,187 @@ function removeTag(t) {
}
// ---------- cover ----------
const coverInput = ref('')
function setCover() {
const v = coverInput.value.trim()
if (!v) return
form.cover_url = v
coverInput.value = ''
}
function clearCover() {
form.cover_url = ''
}
// preview mode — render markdown to HTML for the side panel
const previewHtml = computed(() => marked.parse(form.content_md || '', { breaks: true }))
// ---------- 工具栏:Markdown 模式插入 + 通用动作 ----------
const mdPane = ref(null)
const showLinkInput = ref(false)
const showImageInput = ref(false)
const linkText = ref('')
const linkUrl = ref('')
const imageUrl = ref('')
const imageAlt = ref('')
function insertAtCursor(text, selStart, selEnd) {
const before = form.content_md.slice(0, selStart)
const after = form.content_md.slice(selEnd)
form.content_md = before + text + after
nextTick(() => {
if (!mdPane.value) return
const newPos = selStart + text.length
mdPane.value.focus()
mdPane.value.setSelectionRange(newPos, newPos)
})
}
function withSelection(fn) {
const el = mdPane.value
const start = el ? el.selectionStart : form.content_md.length
const end = el ? el.selectionEnd : form.content_md.length
fn(start, end)
}
function tbBold() {
withSelection((s, e) => {
const sel = form.content_md.slice(s, e) || '加粗'
insertAtCursor(`**${sel}**`, s, e)
})
}
function tbItalic() {
withSelection((s, e) => {
const sel = form.content_md.slice(s, e) || '斜体'
insertAtCursor(`*${sel}*`, s, e)
})
}
function tbStrike() {
withSelection((s, e) => {
const sel = form.content_md.slice(s, e) || '删除'
insertAtCursor(`~~${sel}~~`, s, e)
})
}
function tbCode() {
withSelection((s, e) => {
const sel = form.content_md.slice(s, e) || 'code'
insertAtCursor('`' + sel + '`', s, e)
})
}
function tbCodeBlock() {
withSelection((s, e) => {
const sel = form.content_md.slice(s, e) || '代码块'
insertAtCursor('\n```\n' + sel + '\n```\n', s, e)
})
}
function tbH2() {
withSelection((s, e) => {
const sel = form.content_md.slice(s, e) || '二级标题'
insertAtCursor(`\n## ${sel}\n`, s, e)
})
}
function tbH3() {
withSelection((s, e) => {
const sel = form.content_md.slice(s, e) || '三级标题'
insertAtCursor(`\n### ${sel}\n`, s, e)
})
}
function tbQuote() {
withSelection((s, e) => {
const sel = form.content_md.slice(s, e) || '引用'
insertAtCursor('\n> ' + sel.replace(/\n/g, '\n> ') + '\n', s, e)
})
}
function tbUl() {
withSelection((s, e) => {
const sel = form.content_md.slice(s, e) || '列表项'
insertAtCursor(
'\n' + sel.split('\n').map((l) => '- ' + l).join('\n') + '\n',
s,
e
)
})
}
function tbOl() {
withSelection((s, e) => {
const sel = form.content_md.slice(s, e) || '列表项'
insertAtCursor(
'\n' +
sel
.split('\n')
.map((l, i) => (i + 1) + '. ' + l)
.join('\n') +
'\n',
s,
e
)
})
}
function tbTask() {
withSelection((s, e) => {
const sel = form.content_md.slice(s, e) || '任务'
insertAtCursor('\n- [ ] ' + sel + '\n', s, e)
})
}
function tbHr() {
withSelection((s, e) => insertAtCursor('\n---\n', s, e))
}
function openLink() {
showLinkInput.value = true
linkText.value = ''
linkUrl.value = ''
}
function commitLink() {
if (!linkUrl.value) return
const text = linkText.value || linkUrl.value
const md = `[${text}](${linkUrl.value})`
withSelection((s, e) => insertAtCursor(md, s, e))
showLinkInput.value = false
}
function openImage() {
showImageInput.value = true
imageUrl.value = ''
imageAlt.value = ''
}
function commitImage() {
if (!imageUrl.value) return
const md = `![${imageAlt.value || ''}](${imageUrl.value})`
withSelection((s, e) => insertAtCursor(md, s, e))
showImageInput.value = false
}
function insertDate() {
withSelection((s, e) => insertAtCursor(new Date().toISOString().slice(0, 10), s, e))
}
// 工具栏按钮的统一定义(用作 v-for 渲染)
const tbGroups = computed(() => [
{
label: '格式',
items: [
{ key: 'b', label: 'B', title: '加粗', run: tbBold, show: mode.value === 'md' },
{ key: 'i', label: 'I', title: '斜体', run: tbItalic, italic: true, show: mode.value === 'md' },
{ key: 's', label: 'S', title: '删除线', run: tbStrike, show: mode.value === 'md' },
{ key: 'code', label: '<>', title: '行内代码', run: tbCode, mono: true, show: mode.value === 'md' },
{ key: 'cb', label: '```', title: '代码块', run: tbCodeBlock, mono: true, show: mode.value === 'md' }
]
},
{
label: '结构',
items: [
{ key: 'h2', label: 'H2', title: '二级标题', run: tbH2, show: mode.value === 'md' },
{ key: 'h3', label: 'H3', title: '三级标题', run: tbH3, show: mode.value === 'md' },
{ key: 'q', label: '"', title: '引用', run: tbQuote, show: mode.value === 'md' },
{ key: 'ul', label: '•', title: '无序列表', run: tbUl, show: mode.value === 'md' },
{ key: 'ol', label: '1.', title: '有序列表', run: tbOl, show: mode.value === 'md' },
{ key: 'task', label: '☐', title: '任务列表', run: tbTask, show: mode.value === 'md' },
{ key: 'hr', label: '—', title: '分隔线', run: tbHr, show: mode.value === 'md' }
]
},
{
label: '插入',
items: [
{ key: 'link', label: '🔗', title: '链接', run: openLink, show: mode.value === 'md' },
{ key: 'img', label: '🖼', title: '图片', run: openImage, show: mode.value === 'md' },
{ key: 'date', label: '📅', title: '插入今天日期', run: insertDate, show: mode.value === 'md' }
]
}
])
</script>
<template>
@@ -416,26 +582,6 @@ const previewHtml = computed(() => marked.parse(form.content_md || '', { breaks:
<div class="editor-grid">
<div>
<!-- cover -->
<div class="cover-block" :class="{ has: form.cover_url }">
<div v-if="form.cover_url" class="preview" :style="{ backgroundImage: `url(${form.cover_url})` }">
<button class="remove" @click="clearCover">移除封面</button>
</div>
<div v-else class="placeholder">+ 添加封面图(输入 URL)</div>
<div v-if="!form.cover_url" class="input-row">
<input
v-model="coverInput"
placeholder="https://…"
type="url"
inputmode="url"
aria-label="封面图 URL"
spellcheck="false"
@keydown.enter.prevent="setCover"
/>
<button @click="setCover">设置</button>
</div>
</div>
<!-- kind switch + title -->
<div style="display: flex; gap: 6px; align-items: center; margin-bottom: 10px;">
<button class="chip" :class="{ on: form.kind === 'long' }" @click="form.kind = 'long'">长文</button>
@@ -457,13 +603,79 @@ const previewHtml = computed(() => marked.parse(form.content_md || '', { breaks:
:placeholder="form.kind === 'short' ? '标题(可留空,仅作归档索引)' : '标题'"
/>
<!-- 工具栏:仅 Markdown 模式显示 -->
<div v-if="mode === 'md'" class="toolbar">
<div v-for="g in tbGroups" :key="g.label" class="tb-group">
<span class="tb-label">{{ g.label }}</span>
<button
v-for="it in g.items.filter((i) => i.show)"
:key="it.key"
type="button"
class="tb-btn"
:class="{ italic: it.italic, mono: it.mono }"
:title="it.title"
:aria-label="it.title"
@click="it.run()"
>{{ it.label }}</button>
</div>
</div>
<!-- 链接/图片输入条 -->
<div v-if="showLinkInput" class="tb-popover">
<input
v-model="linkText"
placeholder="链接文字(可省)"
class="input"
spellcheck="false"
aria-label="链接文字"
@keydown.enter="commitLink"
/>
<input
v-model="linkUrl"
placeholder="https://…"
type="url"
inputmode="url"
class="input"
spellcheck="false"
aria-label="链接 URL"
@keydown.enter="commitLink"
/>
<button class="btn btn-primary" @click="commitLink">插入</button>
<button class="btn" @click="showLinkInput = false">取消</button>
</div>
<div v-if="showImageInput" class="tb-popover">
<input
v-model="imageAlt"
placeholder="alt 文字"
class="input"
spellcheck="false"
aria-label="图片 alt"
@keydown.enter="commitImage"
/>
<input
v-model="imageUrl"
placeholder="图片 URL"
type="url"
inputmode="url"
class="input"
spellcheck="false"
aria-label="图片 URL"
@keydown.enter="commitImage"
/>
<button class="btn btn-primary" @click="commitImage">插入</button>
<button class="btn" @click="showImageInput = false">取消</button>
</div>
<!-- editor area -->
<div v-show="mode === 'wysiwyg'" ref="editorEl"></div>
<textarea
v-show="mode === 'md'"
ref="mdPane"
class="md-pane"
v-model="form.content_md"
placeholder="直接写 Markdown…"
aria-label="Markdown 正文"
></textarea>
<div class="editor-stats">
@@ -491,6 +703,23 @@ const previewHtml = computed(() => marked.parse(form.content_md || '', { breaks:
<!-- meta -->
<div class="panel" style="padding: 16px 18px;">
<div class="field">
<label for="cover">封面图</label>
<div v-if="form.cover_url" class="cover-mini" :style="{ backgroundImage: `url(${form.cover_url})` }">
<button class="x" @click="clearCover" aria-label="移除封面">×</button>
</div>
<input
id="cover"
v-model="form.cover_url"
placeholder="封面图 URL(可省)"
type="url"
inputmode="url"
spellcheck="false"
aria-label="封面图 URL"
/>
<p style="margin: 4px 0 0; font-size: 12px; color: var(--admin-muted);">回车或失焦即生效;前台卡片与详情页头图会用到。</p>
</div>
<div class="field">
<label>类型</label>
<p style="margin: 0; font-size: 14px;">{{ form.kind === 'short' ? '短文' : '长文' }}</p>
+88 -1
View File
@@ -1161,6 +1161,14 @@ h4 {
padding: 14px 18px;
font-size: 12.5px;
color: var(--admin-muted);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.admin-side .footer .meta {
min-width: 0;
}
.admin-side .footer .who {
@@ -1868,7 +1876,7 @@ h4 {
width: 100%;
min-height: 460px;
border: 1px solid var(--admin-line);
border-radius: 0 3px 3px 3px;
border-radius: 3px;
background: var(--admin-card);
padding: 18px 22px;
font-family: var(--mono);
@@ -1883,6 +1891,85 @@ h4 {
border-color: var(--admin-accent);
}
/* ---------- 工具栏:Markdown 模式 ---------- */
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 6px 8px;
border: 1px solid var(--admin-line);
border-bottom: 0;
border-radius: 3px 3px 0 0;
background: var(--admin-paper-sunken);
}
.tb-group {
display: inline-flex;
align-items: center;
gap: 2px;
}
.tb-group + .tb-group {
border-left: 1px solid var(--admin-line);
padding-left: 10px;
margin-left: 2px;
}
.tb-label {
font-size: 11px;
letter-spacing: 0.06em;
color: var(--admin-muted);
margin-right: 4px;
text-transform: uppercase;
}
.tb-btn {
width: 28px;
height: 28px;
border: 1px solid transparent;
border-radius: 3px;
background: transparent;
color: var(--admin-ink-soft);
cursor: pointer;
font-size: 12.5px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
}
.tb-btn:hover {
background: var(--admin-card);
border-color: var(--admin-line);
color: var(--admin-ink);
}
.tb-btn.italic {
font-style: italic;
}
.tb-btn.mono {
font-family: var(--mono);
font-size: 11.5px;
}
.tb-popover {
display: flex;
gap: 6px;
padding: 8px;
border: 1px solid var(--admin-line);
border-top: 0;
background: var(--admin-paper-sunken);
}
.tb-popover .input {
flex: 1;
font-size: 13px;
}
.outline {
position: sticky;
top: 84px;