编辑器 Markdown 工具栏 + 后台主题切换移入侧栏 footer
- EditorView:新增工具栏(粗体/斜体/删除线/行内码/代码块/链接/图片等选区插入),移除封面 URL 输入框 - AdminLayout:删除无样式的 .theme-zone,ThemeSwitcher 并入 footer 行(左账号信息、右主题按钮)
This commit is contained in:
@@ -128,12 +128,12 @@ watch(() => route.path, () => loadCounts())
|
|||||||
<span>查看前台</span>
|
<span>查看前台</span>
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="theme-zone">
|
|
||||||
<ThemeSwitcher variant="inline" />
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
<div class="footer">
|
||||||
<div class="who">{{ session.user || 'admin' }}</div>
|
<div class="meta">
|
||||||
<button class="leave" @click="logout">退出登录 →</button>
|
<div class="who">{{ session.user || 'admin' }}</div>
|
||||||
|
<button class="leave" @click="logout">退出登录 →</button>
|
||||||
|
</div>
|
||||||
|
<ThemeSwitcher variant="inline" />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
@@ -369,21 +369,187 @@ function removeTag(t) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------- cover ----------
|
// ---------- cover ----------
|
||||||
const coverInput = ref('')
|
|
||||||
|
|
||||||
function setCover() {
|
|
||||||
const v = coverInput.value.trim()
|
|
||||||
if (!v) return
|
|
||||||
form.cover_url = v
|
|
||||||
coverInput.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearCover() {
|
function clearCover() {
|
||||||
form.cover_url = ''
|
form.cover_url = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// preview mode — render markdown to HTML for the side panel
|
// preview mode — render markdown to HTML for the side panel
|
||||||
const previewHtml = computed(() => marked.parse(form.content_md || '', { breaks: true }))
|
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 = ``
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -416,26 +582,6 @@ const previewHtml = computed(() => marked.parse(form.content_md || '', { breaks:
|
|||||||
|
|
||||||
<div class="editor-grid">
|
<div class="editor-grid">
|
||||||
<div>
|
<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 -->
|
<!-- kind switch + title -->
|
||||||
<div style="display: flex; gap: 6px; align-items: center; margin-bottom: 10px;">
|
<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>
|
<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' ? '标题(可留空,仅作归档索引)' : '标题'"
|
: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 -->
|
<!-- editor area -->
|
||||||
<div v-show="mode === 'wysiwyg'" ref="editorEl"></div>
|
<div v-show="mode === 'wysiwyg'" ref="editorEl"></div>
|
||||||
<textarea
|
<textarea
|
||||||
v-show="mode === 'md'"
|
v-show="mode === 'md'"
|
||||||
|
ref="mdPane"
|
||||||
class="md-pane"
|
class="md-pane"
|
||||||
v-model="form.content_md"
|
v-model="form.content_md"
|
||||||
placeholder="直接写 Markdown…"
|
placeholder="直接写 Markdown…"
|
||||||
|
aria-label="Markdown 正文"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
|
||||||
<div class="editor-stats">
|
<div class="editor-stats">
|
||||||
@@ -491,6 +703,23 @@ const previewHtml = computed(() => marked.parse(form.content_md || '', { breaks:
|
|||||||
|
|
||||||
<!-- meta -->
|
<!-- meta -->
|
||||||
<div class="panel" style="padding: 16px 18px;">
|
<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">
|
<div class="field">
|
||||||
<label>类型</label>
|
<label>类型</label>
|
||||||
<p style="margin: 0; font-size: 14px;">{{ form.kind === 'short' ? '短文' : '长文' }}</p>
|
<p style="margin: 0; font-size: 14px;">{{ form.kind === 'short' ? '短文' : '长文' }}</p>
|
||||||
|
|||||||
+88
-1
@@ -1161,6 +1161,14 @@ h4 {
|
|||||||
padding: 14px 18px;
|
padding: 14px 18px;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
color: var(--admin-muted);
|
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 {
|
.admin-side .footer .who {
|
||||||
@@ -1868,7 +1876,7 @@ h4 {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 460px;
|
min-height: 460px;
|
||||||
border: 1px solid var(--admin-line);
|
border: 1px solid var(--admin-line);
|
||||||
border-radius: 0 3px 3px 3px;
|
border-radius: 3px;
|
||||||
background: var(--admin-card);
|
background: var(--admin-card);
|
||||||
padding: 18px 22px;
|
padding: 18px 22px;
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
@@ -1883,6 +1891,85 @@ h4 {
|
|||||||
border-color: var(--admin-accent);
|
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 {
|
.outline {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 84px;
|
top: 84px;
|
||||||
|
|||||||
Reference in New Issue
Block a user