Relocate applications, geenforce, knowledgebase, and machines - each owns only its own views dir, so a clean move to plugins/<name>/frontend/ (views/ + routes.js, core imports rewritten to @/). geenforce's entryForm.js helper + its vitest spec move with it (ManifestEditor imports it as a sibling). Machinery fixes this batch surfaced: - routes.gen.js codegen uses namespace imports (import * as p_x). A route file without a `toplevel` export is undefined on the namespace instead of a strict- ESM missing-binding build error. - vitest gains a `pretest` stage so plugin-frontend specs (now under plugins/<name>/frontend/) run from their staged copy in src/.plugins-staged/. Verified live: GE-Enforce (the most complex, uses the entryForm sibling helper) renders fully from its staged frontend. Build + 58 vitest + naming green.
264 lines
7.2 KiB
Vue
264 lines
7.2 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="loadArticles">
|
|
<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
|
|
</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'
|
|
|
|
const loading = ref(true)
|
|
const articles = ref([])
|
|
const topics = ref([])
|
|
const stats = ref(null)
|
|
const { page, search, setPage, setSearch } = useListQuery({ onChange: 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()
|
|
])
|
|
})
|
|
|
|
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 || []
|
|
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 {
|
|
const response = await applicationsApi.list({ perpage: 1000 })
|
|
topics.value = response.data.data || []
|
|
} 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>
|