Files
ONE/frontend/src/admin/CommandPalette.vue
T
Sakurasan dd2994189a 安全加固 + 结构清理:修注入/串写/竞态,DOMPurify 上线,后端补事务与 handler 测试
后端:
- 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 语义
2026-09-21 23:59:09 +08:00

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>