List pages kept the current page in local state, so clicking into an asset and hitting Back remounted the list at page 1. A shared useListQuery composable now mirrors the page (and search term) into the URL query via router.replace across all 18 list pages, so Back restores the page you were on and lists are deep-linkable. Page 1 with no search stays a bare path; changing a filter resets to page 1; unrelated query keys are preserved. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
|