后端: - ORDER BY 白名单(sanitizeOrder)堵住 ?order= SQL 注入,补回归测试 - 登录限速(每 IP 10 次失败/10 分钟 429)、TLS/反代下 Secure cookie、NewAPI 构造器 - Delete/setTags/MergeTags/DeleteTag 包事务;Archive 去 500 篇上限 - 列表接口裁剪:不传 content_md,长文 content_html 截 600,新增 content_len;health 探 DB 前端: - EditorView 路由复用串写修复(RouterView :key + sync watch 回写原文章) - v-html 出口统一过 DOMPurify(sanitizeHtml),stripTags 改 DOMParser - 列表竞态防护(Home/Tag/Posts 请求序号)、TagView 分页修复 - 侧栏接口 30s 缓存去重;one:unauthorized 监听器泄漏修复 - 删 styles.css 498 行重复块;移除 tailwind/marked/vue-tsc 死依赖;CommandPalette a11y 语义
144 lines
4.4 KiB
Vue
144 lines
4.4 KiB
Vue
<script setup>
|
|
import { computed, nextTick, onMounted, onBeforeUnmount, ref } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { adminApi } from '../api'
|
|
|
|
const emit = defineEmits(['close'])
|
|
const router = useRouter()
|
|
|
|
const query = ref('')
|
|
const inputEl = ref(null)
|
|
const idx = ref(0)
|
|
const recentDrafts = ref([])
|
|
|
|
const baseCommands = [
|
|
{ id: 'go-new', label: '写新文章', hint: 'N', action: () => router.push('/admin/new') },
|
|
{ id: 'go-dashboard', label: '总览', hint: 'G D', action: () => router.push('/admin') },
|
|
{ id: 'go-posts', label: '文章列表', hint: 'G P', action: () => router.push('/admin/posts') },
|
|
{ id: 'go-tags', label: '标签管理', hint: 'G T', action: () => router.push('/admin/tags') },
|
|
{ id: 'go-settings', label: '设置', hint: 'G S', action: () => router.push('/admin/settings') },
|
|
{ id: 'go-site', label: '前台', hint: 'G W', action: () => (window.location.href = '/') }
|
|
]
|
|
|
|
async function loadContext() {
|
|
try {
|
|
const d = await adminApi.dashboard()
|
|
recentDrafts.value = (d.recent_drafts || []).map((p) => ({
|
|
id: 'edit-' + p.id,
|
|
label: p.title || '(无题草稿)',
|
|
hint: p.kind === 'short' ? '短文' : '长文',
|
|
action: () => router.push('/admin/' + p.id)
|
|
}))
|
|
} catch (e) {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
// Flatten items into [{sec, item, idx}] so we can render sections but still
|
|
// track a single cursor across the whole list.
|
|
const flat = computed(() => {
|
|
const q = query.value.trim().toLowerCase()
|
|
const sections = [
|
|
{ name: '命令', items: baseCommands },
|
|
{ name: '最近草稿', items: recentDrafts.value }
|
|
]
|
|
const out = []
|
|
for (const sec of sections) {
|
|
for (const it of sec.items) {
|
|
if (q && !it.label.toLowerCase().includes(q)) continue
|
|
out.push({ section: sec.name, item: it })
|
|
}
|
|
}
|
|
return out
|
|
})
|
|
|
|
// Re-group for display, while preserving the flat indices.
|
|
const sections = computed(() => {
|
|
const out = []
|
|
let i = 0
|
|
let lastName = null
|
|
for (const e of flat.value) {
|
|
if (e.section !== lastName) {
|
|
out.push({ name: e.section, entries: [] })
|
|
lastName = e.section
|
|
}
|
|
out[out.length - 1].entries.push({ ...e.item, _idx: i })
|
|
i++
|
|
}
|
|
return out
|
|
})
|
|
|
|
function run(item) {
|
|
item.action()
|
|
emit('close')
|
|
}
|
|
|
|
function onKey(e) {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault()
|
|
emit('close')
|
|
} else if (e.key === 'ArrowDown') {
|
|
e.preventDefault()
|
|
idx.value = Math.min(flat.value.length - 1, idx.value + 1)
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault()
|
|
idx.value = Math.max(0, idx.value - 1)
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
const it = flat.value[idx.value]
|
|
if (it) run(it.item)
|
|
} else if (e.key === 'Tab') {
|
|
// 面板内只有一个输入框,Tab 不外逃
|
|
e.preventDefault()
|
|
inputEl.value?.focus()
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await loadContext()
|
|
await nextTick()
|
|
inputEl.value?.focus()
|
|
window.addEventListener('keydown', onKey)
|
|
})
|
|
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
|
|
</script>
|
|
|
|
<template>
|
|
<div class="cmd-overlay" role="dialog" aria-modal="true" aria-label="命令面板" @click.self="emit('close')">
|
|
<div class="cmd-panel">
|
|
<input
|
|
ref="inputEl"
|
|
v-model="query"
|
|
placeholder="输入命令、文章标题…"
|
|
aria-label="搜索命令和文章"
|
|
role="combobox"
|
|
aria-expanded="true"
|
|
aria-controls="cmd-listbox"
|
|
:aria-activedescendant="flat.length ? 'cmd-opt-' + idx : undefined"
|
|
@input="idx = 0"
|
|
/>
|
|
<div class="cmd-list" id="cmd-listbox" role="listbox">
|
|
<template v-for="(sec, si) in sections" :key="si">
|
|
<div style="padding: 8px 18px 4px; font-size: 11px; color: var(--admin-muted); letter-spacing: 0.08em; text-transform: uppercase;">
|
|
{{ sec.name }}
|
|
</div>
|
|
<div
|
|
v-for="(it, ii) in sec.entries"
|
|
:key="it.id"
|
|
class="row"
|
|
:class="{ on: idx === it._idx }"
|
|
role="option"
|
|
:id="'cmd-opt-' + it._idx"
|
|
:aria-selected="idx === it._idx"
|
|
@click="run(it)"
|
|
@mouseenter="idx = it._idx"
|
|
>
|
|
<span class="label">{{ it.label }}</span>
|
|
<span class="hint">{{ it.hint }}</span>
|
|
</div>
|
|
</template>
|
|
<div v-if="!flat.length" class="empty">没找到匹配的命令</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template> |