Correction only helped on the global search page, and people search from the list they are already on. Extending it turned out to be a shape question rather than a volume one: thirty routes take a search parameter across a dozen files, so carrying a suggestion in each of their responses is a large change today and one more thing every future plugin author has to remember. So the suggestion moved to ITS OWN ENDPOINT, /api/search/suggest, which any page can call after rendering no rows. A page that has not adopted it shows nothing, which is exactly what it showed before - nothing breaks by omission. The plumbing lives in useListQuery, which already owned the search term, so a list needs three lines: take `suggestion` and `reportCount` from the composable, call reportCount(rows.length) after a fetch, and drop SearchSuggestion into the empty state it already has. A list that never calls reportCount never offers a suggestion. Wired: global search, machines, printers, PCs, network devices, measuring tools, knowledge base, vendors. NOT wired, deliberately: the type and reference lists (machine types, PC types, VLANs, subnets, operating systems and the rest), which are small controlled vocabularies nobody typo-searches, and USB, whose empty state has a different shape and wants doing by hand rather than by pattern. TRAP FOUND WHILE WIRING IT, and left commented in every page: applying a suggestion by calling setSearch alone updates the box and the URL and does NOT reload the list. setSearch only syncs the URL, and the watcher that would reload is suppressed because search.value already holds the new term - the same trap the global search page documents in performSearch. Each page calls its own load function directly. The composable guards a stale answer arriving after a newer search was typed, never offers back the word that was typed, and swallows its own errors: a search that found nothing is already the answer, and failing to improve on it is not worth an error in front of anyone. The route-parity gate caught the new endpoint being served without an entry in docs/api-inventory.json, which is hand-written on purpose; added, and the spec regenerated from it (283 paths, 418 operations). That regeneration also carries the openapi version to 0.12.0, left over from the release.
294 lines
8.3 KiB
Vue
294 lines
8.3 KiB
Vue
<template>
|
|
<div>
|
|
<div class="page-header">
|
|
<div class="header-left">
|
|
<h2>Knowledge Base</h2>
|
|
<span v-if="stats" class="stats-badge">
|
|
{{ stats.totalarticles }} articles | {{ stats.totalclicks.toLocaleString() }} total clicks
|
|
</span>
|
|
</div>
|
|
<router-link to="/knowledgebase/new" class="btn btn-primary">Add Article</router-link>
|
|
</div>
|
|
|
|
<!-- Filters -->
|
|
<div class="filters">
|
|
<input
|
|
v-model="search"
|
|
type="text"
|
|
class="form-control"
|
|
placeholder="Search articles..."
|
|
@input="debouncedSearch"
|
|
/>
|
|
<select v-model="topicFilter" class="form-control" @change="applyFilter">
|
|
<option value="">All Topics</option>
|
|
<option
|
|
v-for="app in topics"
|
|
:key="app.appid"
|
|
:value="app.appid"
|
|
>
|
|
{{ app.appname }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div v-if="loading" class="loading">Loading...</div>
|
|
|
|
<template v-else>
|
|
<div class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th class="sortable" @click="toggleSort('topic')">
|
|
Topic
|
|
<span v-if="sort === 'topic'" class="sort-arrow">{{ order === 'asc' ? '▲' : '▼' }}</span>
|
|
</th>
|
|
<th class="sortable" @click="toggleSort('description')">
|
|
Description
|
|
<span v-if="sort === 'description'" class="sort-arrow">{{ order === 'asc' ? '▲' : '▼' }}</span>
|
|
</th>
|
|
<th class="sortable" style="width: 100px;" @click="toggleSort('clicks')">
|
|
Clicks
|
|
<span v-if="sort === 'clicks'" class="sort-arrow">{{ order === 'asc' ? '▲' : '▼' }}</span>
|
|
</th>
|
|
<th style="width: 120px;">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="article in articles" :key="article.linkid">
|
|
<td>
|
|
<router-link
|
|
v-if="article.application"
|
|
:to="`/applications/${article.application.appid}`"
|
|
>
|
|
{{ article.application.appname }}
|
|
</router-link>
|
|
<span v-else class="text-muted">-</span>
|
|
</td>
|
|
<td>
|
|
<a
|
|
href="#"
|
|
class="article-link"
|
|
@click.prevent="openArticle(article)"
|
|
:title="article.linkurl"
|
|
>
|
|
{{ truncate(article.shortdescription, 95) }}
|
|
</a>
|
|
</td>
|
|
<td style="text-align: center; font-weight: 500;">{{ article.clicks }}</td>
|
|
<td class="actions">
|
|
<router-link :to="`/knowledgebase/${article.linkid}`" class="btn btn-sm btn-secondary">
|
|
View
|
|
</router-link>
|
|
<router-link :to="`/knowledgebase/${article.linkid}/edit`" class="btn btn-sm btn-secondary">
|
|
Edit
|
|
</router-link>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="articles.length === 0">
|
|
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
|
No articles found
|
|
<SearchSuggestion :suggestion="suggestion" @pick="applySuggestion" />
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<PaginationBar
|
|
:page="page"
|
|
:totalPages="totalPages"
|
|
:perPage="perPage"
|
|
@update:page="goToPage"
|
|
@update:perPage="changePerPage"
|
|
/>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import { knowledgebaseApi, applicationsApi } from '@/api'
|
|
import PaginationBar from '@/components/PaginationBar.vue'
|
|
import { useListQuery } from '@/composables/listQuery'
|
|
import SearchSuggestion from '@/components/SearchSuggestion.vue'
|
|
|
|
const loading = ref(true)
|
|
const articles = ref([])
|
|
const topics = ref([])
|
|
const stats = ref(null)
|
|
const { page, search, setPage, setSearch, suggestion, reportCount } = useListQuery({ onChange: loadArticles })
|
|
|
|
// Take the offered spelling. setSearch only syncs the URL and the watcher
|
|
// that reloads is suppressed because search.value already holds the new
|
|
// term, so load directly.
|
|
function applySuggestion(word) {
|
|
search.value = word
|
|
setSearch(word)
|
|
loadArticles()
|
|
}
|
|
const perPage = ref(20)
|
|
const totalPages = ref(1)
|
|
const topicFilter = ref('')
|
|
const sort = ref('clicks')
|
|
const order = ref('desc')
|
|
|
|
let searchTimeout = null
|
|
|
|
onMounted(async () => {
|
|
await Promise.all([
|
|
loadArticles(),
|
|
loadTopics(),
|
|
loadStats()
|
|
])
|
|
})
|
|
|
|
// A filter change must go back to page 1. Selecting a filter while on page 5
|
|
// asked the server for page 5 of a result set that now has one page, and the
|
|
// list came back empty as though the filter matched nothing.
|
|
//
|
|
// setPage(1) writes the URL, which the composable's watcher picks up and
|
|
// answers with onChange - so calling the loader as well would fetch twice.
|
|
// Load directly only when already on page 1, where nothing changes and the
|
|
// watcher stays silent.
|
|
function applyFilter() {
|
|
if (page.value > 1) setPage(1)
|
|
else loadArticles()
|
|
}
|
|
|
|
async function loadArticles() {
|
|
loading.value = true
|
|
try {
|
|
const params = {
|
|
page: page.value,
|
|
perpage: perPage.value,
|
|
sort: sort.value,
|
|
order: order.value
|
|
}
|
|
if (search.value) params.search = search.value
|
|
if (topicFilter.value) params.appid = topicFilter.value
|
|
|
|
const response = await knowledgebaseApi.list(params)
|
|
articles.value = response.data.data || []
|
|
reportCount(articles.value.length)
|
|
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
|
} catch (error) {
|
|
console.error('Error loading articles:', error)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function loadTopics() {
|
|
try {
|
|
// listAll, not list: the backend clamps perpage to 100 without saying so,
|
|
// and the catalogue is past that, so topics sorting late in the alphabet
|
|
// were silently missing from this filter.
|
|
// isactive is the only filter that applies to a topic: ishidden governs
|
|
// whether an application shows on the tiles page, which says nothing about
|
|
// whether it can be the subject of an article.
|
|
topics.value = await applicationsApi.listAll({ showhidden: true })
|
|
} catch (error) {
|
|
console.error('Error loading topics:', error)
|
|
}
|
|
}
|
|
|
|
async function loadStats() {
|
|
try {
|
|
const response = await knowledgebaseApi.getStats()
|
|
stats.value = response.data.data
|
|
} catch (error) {
|
|
console.error('Error loading stats:', error)
|
|
}
|
|
}
|
|
|
|
function debouncedSearch() {
|
|
clearTimeout(searchTimeout)
|
|
searchTimeout = setTimeout(() => {
|
|
setSearch(search.value)
|
|
loadArticles()
|
|
}, 300)
|
|
}
|
|
|
|
function goToPage(p) {
|
|
setPage(p)
|
|
loadArticles()
|
|
}
|
|
|
|
function changePerPage(newPerPage) {
|
|
perPage.value = newPerPage
|
|
setPage(1)
|
|
loadArticles()
|
|
}
|
|
|
|
function toggleSort(column) {
|
|
if (sort.value === column) {
|
|
order.value = order.value === 'desc' ? 'asc' : 'desc'
|
|
} else {
|
|
sort.value = column
|
|
order.value = 'desc'
|
|
}
|
|
loadArticles()
|
|
}
|
|
|
|
function truncate(text, length) {
|
|
if (!text) return ''
|
|
if (text.length <= length) return text
|
|
return text.substring(0, length) + '...'
|
|
}
|
|
|
|
async function openArticle(article) {
|
|
try {
|
|
await knowledgebaseApi.trackClick(article.linkid)
|
|
article.clicks = (article.clicks || 0) + 1
|
|
if (stats.value) {
|
|
stats.value.totalclicks++
|
|
}
|
|
if (article.linkurl) {
|
|
window.open(article.linkurl, '_blank')
|
|
}
|
|
} catch (error) {
|
|
console.error('Error tracking click:', error)
|
|
if (article.linkurl) {
|
|
window.open(article.linkurl, '_blank')
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
/* Knowledge Base specific styles only */
|
|
.header-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.header-left h2 {
|
|
margin: 0;
|
|
}
|
|
|
|
.stats-badge {
|
|
background: var(--bg);
|
|
color: var(--link);
|
|
padding: 0.5rem 1rem;
|
|
border-radius: 6px;
|
|
font-size: 1rem;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.article-link {
|
|
color: var(--text);
|
|
}
|
|
|
|
.article-link:hover {
|
|
color: var(--link);
|
|
}
|
|
|
|
.text-muted {
|
|
color: var(--text-light);
|
|
}
|
|
</style>
|