Search: refactor into modular helpers, add IP/hostname/notification/ vendor/model/type search, smart auto-redirects for exact matches, ServiceNOW prefix detection, filter buttons with counts, share links with highlight support, and dark mode badge colors. Map: fix N+1 queries with eager loading (248->28 queries), switch to canvas-rendered circleMarkers for better performance with 500+ assets. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
535 lines
12 KiB
Vue
535 lines
12 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<h2>Search Results</h2>
|
|
<span v-if="results.length" class="results-count">
|
|
{{ totalAll || results.length }} result{{ (totalAll || results.length) !== 1 ? 's' : '' }} for "{{ query }}"
|
|
</span>
|
|
</div>
|
|
|
|
<div class="search-box">
|
|
<input
|
|
v-model="searchInput"
|
|
type="text"
|
|
class="form-control"
|
|
placeholder="Search machines, applications, knowledge base, IPs, hostnames..."
|
|
@keyup.enter="performSearch"
|
|
/>
|
|
<button class="btn btn-primary" @click="performSearch">Search</button>
|
|
</div>
|
|
|
|
<div v-if="results.length" class="filter-buttons">
|
|
<button
|
|
v-for="filter in filterList"
|
|
:key="filter.key"
|
|
class="filter-btn"
|
|
:class="{ active: activeFilter === filter.key }"
|
|
@click="activeFilter = filter.key"
|
|
>
|
|
{{ filter.label }}
|
|
<span class="filter-count">{{ getFilterCount(filter.key) }}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div v-if="loading" class="loading">Searching...</div>
|
|
|
|
<template v-else-if="query">
|
|
<div v-if="filteredResults.length === 0 && results.length > 0" class="no-results">
|
|
No {{ activeFilter }} results for "{{ query }}"
|
|
<button class="btn btn-secondary" style="margin-top: 0.5rem;" @click="activeFilter = 'all'">Show all results</button>
|
|
</div>
|
|
|
|
<div v-else-if="results.length === 0" class="no-results">
|
|
No results found for "{{ query }}"
|
|
</div>
|
|
|
|
<div v-else class="results-list">
|
|
<div
|
|
v-for="result in filteredResults"
|
|
:key="`${result.type}-${result.id}`"
|
|
:id="`result-${result.type}-${result.id}`"
|
|
class="result-item"
|
|
:class="{ highlighted: highlightId === `${result.type}-${result.id}` }"
|
|
>
|
|
<span class="result-type" :class="result.type">{{ typeLabel(result.type) }}</span>
|
|
<div class="result-content">
|
|
<router-link v-if="result.type !== 'knowledgebase'" :to="result.url" class="result-title">
|
|
{{ result.title }}
|
|
</router-link>
|
|
<a
|
|
v-else
|
|
href="#"
|
|
class="result-title"
|
|
@click.prevent="openKBArticle(result)"
|
|
>
|
|
{{ result.title }}
|
|
</a>
|
|
<div class="result-meta">
|
|
<span v-if="result.subtitle" class="result-subtitle">{{ result.subtitle }}</span>
|
|
<span v-if="result.location" class="result-location">{{ result.location }}</span>
|
|
<span v-if="result.ticketnumber" class="result-ticket">{{ result.ticketnumber }}</span>
|
|
<span v-if="result.iscurrent" class="badge badge-success">Active</span>
|
|
</div>
|
|
</div>
|
|
<button
|
|
class="share-btn"
|
|
@click="shareResult(result)"
|
|
:title="copiedId === `${result.type}-${result.id}` ? 'Copied!' : 'Copy link'"
|
|
>
|
|
{{ copiedId === `${result.type}-${result.id}` ? 'Copied' : 'Share' }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<div v-else class="no-results">
|
|
Enter a search term to find machines, applications, printers, knowledge base articles, IPs, and more.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { searchApi, knowledgebaseApi } from '../api'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
|
|
const loading = ref(false)
|
|
const results = ref([])
|
|
const query = ref('')
|
|
const searchInput = ref('')
|
|
const activeFilter = ref('all')
|
|
const typeCounts = ref({})
|
|
const totalAll = ref(0)
|
|
const highlightId = ref(null)
|
|
const copiedId = ref(null)
|
|
|
|
const typeLabels = {
|
|
machine: 'Equipment',
|
|
equipment: 'Equipment',
|
|
pc: 'PC',
|
|
computer: 'PC',
|
|
application: 'App',
|
|
knowledgebase: 'KB',
|
|
printer: 'Printer',
|
|
network_device: 'Network',
|
|
employee: 'Employee',
|
|
notification: 'Notice',
|
|
subnet: 'Subnet'
|
|
}
|
|
|
|
const filterTypeMap = {
|
|
all: null,
|
|
equipment: ['equipment'],
|
|
computers: ['computer'],
|
|
printers: ['printer'],
|
|
network: ['network_device', 'subnet'],
|
|
applications: ['application'],
|
|
knowledgebase: ['knowledgebase'],
|
|
notifications: ['notification'],
|
|
employees: ['employee']
|
|
}
|
|
|
|
const filterList = [
|
|
{ key: 'all', label: 'All' },
|
|
{ key: 'equipment', label: 'Equipment' },
|
|
{ key: 'computers', label: 'PCs' },
|
|
{ key: 'printers', label: 'Printers' },
|
|
{ key: 'network', label: 'Network' },
|
|
{ key: 'applications', label: 'Apps' },
|
|
{ key: 'knowledgebase', label: 'KB' },
|
|
{ key: 'notifications', label: 'Notices' },
|
|
{ key: 'employees', label: 'Employees' }
|
|
]
|
|
|
|
function typeLabel(type) {
|
|
return typeLabels[type] || type
|
|
}
|
|
|
|
function getFilterCount(filterKey) {
|
|
if (filterKey === 'all') return totalAll.value || results.value.length
|
|
const types = filterTypeMap[filterKey]
|
|
if (!types) return 0
|
|
return types.reduce((sum, t) => sum + (typeCounts.value[t] || 0), 0)
|
|
}
|
|
|
|
const filteredResults = computed(() => {
|
|
if (activeFilter.value === 'all') return results.value
|
|
const types = filterTypeMap[activeFilter.value]
|
|
if (!types) return results.value
|
|
return results.value.filter(r => types.includes(r.type))
|
|
})
|
|
|
|
async function search(q) {
|
|
if (!q || q.length < 2) {
|
|
results.value = []
|
|
return
|
|
}
|
|
|
|
loading.value = true
|
|
activeFilter.value = 'all'
|
|
|
|
try {
|
|
const response = await searchApi.search(q)
|
|
const data = response.data.data
|
|
|
|
// Handle ServiceNOW redirect
|
|
if (data?.redirect?.type === 'servicenow') {
|
|
window.open(data.redirect.url, '_blank')
|
|
query.value = q
|
|
results.value = []
|
|
loading.value = false
|
|
return
|
|
}
|
|
|
|
// Smart redirect - auto-navigate to exact match
|
|
if (data?.redirect) {
|
|
router.replace(data.redirect.url)
|
|
return
|
|
}
|
|
|
|
results.value = data?.results || []
|
|
typeCounts.value = data?.counts || {}
|
|
totalAll.value = data?.total_all || results.value.length
|
|
query.value = q
|
|
} catch (error) {
|
|
console.error('Search error:', error)
|
|
results.value = []
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
function performSearch() {
|
|
if (searchInput.value.trim()) {
|
|
router.push({ path: '/search', query: { q: searchInput.value.trim() } })
|
|
}
|
|
}
|
|
|
|
async function openKBArticle(result) {
|
|
try {
|
|
await knowledgebaseApi.trackClick(result.id)
|
|
if (result.linkurl) {
|
|
window.open(result.linkurl, '_blank')
|
|
} else {
|
|
router.push(result.url)
|
|
}
|
|
} catch (error) {
|
|
console.error('Error tracking click:', error)
|
|
if (result.linkurl) {
|
|
window.open(result.linkurl, '_blank')
|
|
} else {
|
|
router.push(result.url)
|
|
}
|
|
}
|
|
}
|
|
|
|
function shareResult(result) {
|
|
const url = new URL(window.location.href)
|
|
url.searchParams.set('highlight', `${result.type}-${result.id}`)
|
|
navigator.clipboard.writeText(url.toString()).then(() => {
|
|
copiedId.value = `${result.type}-${result.id}`
|
|
setTimeout(() => { copiedId.value = null }, 2000)
|
|
})
|
|
}
|
|
|
|
function scrollToHighlight() {
|
|
if (highlightId.value) {
|
|
nextTick(() => {
|
|
const el = document.getElementById(`result-${highlightId.value}`)
|
|
if (el) {
|
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
setTimeout(() => { highlightId.value = null }, 3000)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
const q = route.query.q
|
|
const hl = route.query.highlight
|
|
if (hl) highlightId.value = hl
|
|
if (q) {
|
|
searchInput.value = q
|
|
search(q)
|
|
}
|
|
})
|
|
|
|
watch(() => route.query.q, (newQ) => {
|
|
if (newQ) {
|
|
searchInput.value = newQ
|
|
const hl = route.query.highlight
|
|
if (hl) highlightId.value = hl
|
|
search(newQ)
|
|
}
|
|
})
|
|
|
|
watch(results, () => {
|
|
scrollToHighlight()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.page-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.page-header h2 {
|
|
margin: 0;
|
|
}
|
|
|
|
.results-count {
|
|
color: var(--text-light);
|
|
font-size: 0.9rem;
|
|
}
|
|
|
|
.search-box {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.search-box input {
|
|
flex: 1;
|
|
}
|
|
|
|
.filter-buttons {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.375rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
|
|
.filter-btn {
|
|
padding: 0.3rem 0.6rem;
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
cursor: pointer;
|
|
font-size: 0.8rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.3rem;
|
|
}
|
|
|
|
.filter-btn.active {
|
|
background: var(--primary);
|
|
color: #fff;
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.filter-btn:hover:not(.active) {
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.filter-count {
|
|
background: rgba(128, 128, 128, 0.15);
|
|
padding: 0.1rem 0.35rem;
|
|
border-radius: 8px;
|
|
font-size: 0.75rem;
|
|
min-width: 1.25rem;
|
|
text-align: center;
|
|
}
|
|
|
|
.filter-btn.active .filter-count {
|
|
background: rgba(255, 255, 255, 0.25);
|
|
}
|
|
|
|
.no-results {
|
|
text-align: center;
|
|
color: var(--text-light);
|
|
padding: 2rem;
|
|
}
|
|
|
|
.results-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.result-item {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 1rem;
|
|
padding: 0.75rem;
|
|
border-bottom: 1px solid var(--border);
|
|
transition: background 0.3s ease;
|
|
}
|
|
|
|
.result-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.result-item.highlighted {
|
|
background: rgba(65, 129, 255, 0.08);
|
|
border-left: 3px solid var(--primary);
|
|
}
|
|
|
|
.result-type {
|
|
font-size: 0.7rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
padding: 0.2rem 0.45rem;
|
|
border-radius: 4px;
|
|
min-width: 65px;
|
|
text-align: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.result-type.machine,
|
|
.result-type.equipment {
|
|
background: #e3f2fd;
|
|
color: #1565c0;
|
|
}
|
|
|
|
.result-type.pc,
|
|
.result-type.computer {
|
|
background: #e8f5e9;
|
|
color: #2e7d32;
|
|
}
|
|
|
|
.result-type.application {
|
|
background: #fff3e0;
|
|
color: #e65100;
|
|
}
|
|
|
|
.result-type.knowledgebase {
|
|
background: #f3e5f5;
|
|
color: #7b1fa2;
|
|
}
|
|
|
|
.result-type.printer {
|
|
background: #fce4ec;
|
|
color: #c2185b;
|
|
}
|
|
|
|
.result-type.network_device {
|
|
background: #fff8e1;
|
|
color: #f57f17;
|
|
}
|
|
|
|
.result-type.employee {
|
|
background: #e0f2f1;
|
|
color: #00695c;
|
|
}
|
|
|
|
.result-type.notification {
|
|
background: #e8eaf6;
|
|
color: #283593;
|
|
}
|
|
|
|
.result-type.subnet {
|
|
background: #fbe9e7;
|
|
color: #bf360c;
|
|
}
|
|
|
|
.result-content {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.result-title {
|
|
color: var(--link);
|
|
text-decoration: none;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.result-title:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.result-meta {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.75rem;
|
|
margin-top: 0.25rem;
|
|
font-size: 0.8rem;
|
|
color: var(--text-light);
|
|
}
|
|
|
|
.result-subtitle {
|
|
color: var(--text-light);
|
|
}
|
|
|
|
.result-location::before {
|
|
content: '\1F4CD ';
|
|
}
|
|
|
|
.result-ticket {
|
|
font-family: monospace;
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.share-btn {
|
|
background: var(--bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
color: var(--text-light);
|
|
font-size: 0.7rem;
|
|
padding: 0.2rem 0.4rem;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.share-btn:hover {
|
|
color: var(--primary);
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
@media (prefers-color-scheme: dark) {
|
|
.result-type.machine,
|
|
.result-type.equipment {
|
|
background: rgba(21, 101, 192, 0.2);
|
|
color: #64b5f6;
|
|
}
|
|
|
|
.result-type.pc,
|
|
.result-type.computer {
|
|
background: rgba(46, 125, 50, 0.2);
|
|
color: #81c784;
|
|
}
|
|
|
|
.result-type.application {
|
|
background: rgba(230, 81, 0, 0.2);
|
|
color: #ffb74d;
|
|
}
|
|
|
|
.result-type.knowledgebase {
|
|
background: rgba(123, 31, 162, 0.2);
|
|
color: #ce93d8;
|
|
}
|
|
|
|
.result-type.printer {
|
|
background: rgba(194, 24, 91, 0.2);
|
|
color: #f48fb1;
|
|
}
|
|
|
|
.result-type.network_device {
|
|
background: rgba(245, 127, 23, 0.2);
|
|
color: #ffd54f;
|
|
}
|
|
|
|
.result-type.employee {
|
|
background: rgba(0, 105, 92, 0.2);
|
|
color: #80cbc4;
|
|
}
|
|
|
|
.result-type.notification {
|
|
background: rgba(40, 53, 147, 0.2);
|
|
color: #9fa8da;
|
|
}
|
|
|
|
.result-type.subnet {
|
|
background: rgba(191, 54, 12, 0.2);
|
|
color: #ffab91;
|
|
}
|
|
}
|
|
</style>
|