编辑器 Markdown 工具栏 + 后台主题切换移入侧栏 footer
- EditorView:新增工具栏(粗体/斜体/删除线/行内码/代码块/链接/图片等选区插入),移除封面 URL 输入框 - AdminLayout:删除无样式的 .theme-zone,ThemeSwitcher 并入 footer 行(左账号信息、右主题按钮)
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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 = ``
|
||||
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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user