Files
code-utils/frontend/src/components/CommandPalette.vue
2026-08-14 07:52:01 +08:00

104 lines
3.6 KiB
Vue

<script setup>
import { computed, onMounted, onUnmounted, ref, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Search, Folder, ListTodo, TicketCheck, Bot, CornerDownLeft } from 'lucide-vue-next'
import { call } from '../api'
import { useAppStore } from '../store'
const store = useAppStore()
const router = useRouter()
const { t } = useI18n()
const q = ref('')
const hits = ref([])
const sel = ref(0)
const busy = ref(false)
const searched = ref(false)
const input = ref(null)
const listEl = ref(null)
let timer = null
const kindMeta = {
project: { icon: Folder, label: 'kindProject' },
todo: { icon: ListTodo, label: 'kindTodo' },
ticket: { icon: TicketCheck, label: 'kindTicket' },
conversation: { icon: Bot, label: 'kindConversation' }
}
const groups = computed(() => {
const by = {}
const g = []
for (const h of hits.value) {
if (!by[h.kind]) { by[h.kind] = { kind: h.kind, items: [] }; g.push(by[h.kind]) }
by[h.kind].items.push(h)
}
return g
})
watch(q, () => {
clearTimeout(timer)
timer = setTimeout(search, 180)
})
async function search() {
const s = q.value.trim()
if (!s) { hits.value = []; sel.value = 0; searched.value = false; return }
busy.value = true
try {
hits.value = await call('GlobalSearch', s) || []
sel.value = 0
searched.value = true
} catch { hits.value = [] }
busy.value = false
}
function close() { store.paletteOpen = false }
function go(h) {
close()
if (h.kind === 'project') router.push(`/project/${h.id}`)
else if (h.kind === 'todo') router.push('/todos')
else if (h.kind === 'ticket') router.push('/tickets')
else router.push({ path: '/ai', query: { conversation: h.id } })
}
function onKey(e) {
if (e.key === 'Escape') { e.preventDefault(); close() }
else if (e.key === 'ArrowDown') { e.preventDefault(); move(1) }
else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1) }
else if (e.key === 'Enter' && hits.value[sel.value]) { e.preventDefault(); go(hits.value[sel.value]) }
}
function move(d) {
if (!hits.value.length) return
sel.value = (sel.value + d + hits.value.length) % hits.value.length
nextTick(() => listEl.value?.querySelector('.cp-item.active')?.scrollIntoView({ block: 'nearest' }))
}
const flatIndex = h => hits.value.indexOf(h)
onMounted(() => {
addEventListener('keydown', onKey)
nextTick(() => input.value?.focus())
})
onUnmounted(() => { removeEventListener('keydown', onKey); clearTimeout(timer) })
</script>
<template>
<div class="cp-overlay" @click.self="close">
<section class="cp-panel popover-glass">
<label class="cp-input">
<Search />
<input ref="input" v-model="q" :placeholder="t('searchPlaceholder')" spellcheck="false" />
<kbd>Esc</kbd>
</label>
<div v-if="groups.length" ref="listEl" class="cp-list">
<div v-for="g in groups" :key="g.kind" class="cp-group">
<small>{{ t(kindMeta[g.kind].label) }}</small>
<button v-for="h in g.items" :key="g.kind + h.id" class="cp-item" :class="{ active: flatIndex(h) === sel }"
@mouseenter="sel = flatIndex(h)" @click="go(h)">
<component :is="kindMeta[g.kind].icon" />
<span class="cp-title">{{ h.title || '—' }}</span>
<span v-if="h.sub" class="cp-sub">{{ h.sub }}</span>
<CornerDownLeft v-if="flatIndex(h) === sel" class="cp-enter" />
</button>
</div>
</div>
<p v-else-if="searched && !busy" class="cp-empty">{{ t('searchEmpty') }}</p>
<p v-else class="cp-empty muted">{{ t('searchHint') }}</p>
</section>
</div>
</template>