Every detail page read "West Jefferson ShopDB - Machine". Two tabs on two different machines were indistinguishable, a bookmark said nothing about what it pointed at, and a link shared into Teams previewed as the section rather than the thing. The router cannot fix this alone: afterEach runs before the view has fetched anything, so it only ever knows the KIND of page. So a detail view names its record once the record arrives, and both writers go through composables/pageTitle.js. THE ORDERING IS THE WHOLE PROBLEM. The router awaits the site settings to build the "<facility> ShopDB" prefix, so on a cold load it finishes AFTER a view that fetched quickly and would overwrite the good title with the generic one. The route's fullPath is claimed before that await, and the generic write stands down when a record title already belongs to the route being landed on. The reverse case is handled too: a view that names its record BEFORE the facility name arrives composed against the placeholder prefix, so the late resolve rewrites it rather than leaving "ShopDB - CSF16" on a site called West Jefferson. Both are pinned by tests, because both fail only under timing. Covers machines, PCs, printers, network devices, subnets, measuring tools, USB devices, printed parts, applications, employees, knowledge base articles and guides. Printers reuse the page's own displayTitle, which already discards the literal name "NONE" a discovered queue can carry, so the tab and the heading agree. A record with no usable name, or one that failed to load, keeps the section name: "Machine" beats "Machine 4192" for somebody scanning a row of tabs. Long names are cut at a word boundary - knowledge base articles are named with a whole sentence of description, which a browser otherwise truncates mid-word. Reports gained meta.title, which the router already supported and nothing used. That also fixes "Pc Relationships", which the name-derived spelling produced.
215 lines
4.8 KiB
Vue
215 lines
4.8 KiB
Vue
<template>
|
|
<div class="detail-page">
|
|
<div class="page-header">
|
|
<h2>Knowledge Base Article</h2>
|
|
<div class="header-actions">
|
|
<router-link :to="`/knowledgebase/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
|
<router-link to="/knowledgebase" class="btn btn-secondary">Back to List</router-link>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="loading" class="loading">Loading...</div>
|
|
|
|
<template v-else-if="article">
|
|
<div class="card article-card">
|
|
<table class="info-table">
|
|
<tbody>
|
|
<tr>
|
|
<th>Description:</th>
|
|
<td>{{ article.shortdescription }}</td>
|
|
</tr>
|
|
<tr v-if="article.application">
|
|
<th>Topic:</th>
|
|
<td>
|
|
<router-link :to="`/applications/${article.application.appid}`">
|
|
{{ article.application.appname }}
|
|
</router-link>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="article.linkurl">
|
|
<th>URL:</th>
|
|
<td>
|
|
<a href="#" @click.prevent="openArticle">
|
|
{{ article.linkurl }}
|
|
</a>
|
|
</td>
|
|
</tr>
|
|
<tr v-if="article.keywords">
|
|
<th>Keywords:</th>
|
|
<td>{{ article.keywords }}</td>
|
|
</tr>
|
|
<tr>
|
|
<th>Clicks:</th>
|
|
<td>{{ article.clicks }}</td>
|
|
</tr>
|
|
<tr>
|
|
<th>Last Updated:</th>
|
|
<td>{{ formatDate(article.lastupdated) }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
|
|
<hr />
|
|
|
|
<div class="actions-center">
|
|
<a
|
|
v-if="article.linkurl"
|
|
href="#"
|
|
class="btn btn-primary btn-lg"
|
|
@click.prevent="openArticle"
|
|
>
|
|
<span class="btn-icon">↗</span> Open Article
|
|
</a>
|
|
<div v-else class="alert alert-warning">
|
|
This article does not have a URL link defined. Please edit the article to add one.
|
|
</div>
|
|
<router-link :to="`/knowledgebase/${article.linkid}/edit`" class="btn btn-secondary btn-lg">
|
|
<span class="btn-icon">✎</span> Edit
|
|
</router-link>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<div v-else class="card">
|
|
<p style="text-align: center; color: var(--text-light);">Article not found</p>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, onMounted } from 'vue'
|
|
import { useRoute } from 'vue-router'
|
|
import { knowledgebaseApi } from '@/api'
|
|
import { setRecordTitle } from '@/composables/pageTitle'
|
|
|
|
const route = useRoute()
|
|
|
|
const loading = ref(true)
|
|
const article = ref(null)
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
const response = await knowledgebaseApi.get(route.params.id)
|
|
article.value = response.data.data
|
|
setRecordTitle(article.value?.shortdescription)
|
|
} catch (error) {
|
|
console.error('Error loading article:', error)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
})
|
|
|
|
function formatDate(dateStr) {
|
|
if (!dateStr) return '-'
|
|
return new Date(dateStr).toLocaleString()
|
|
}
|
|
|
|
async function openArticle() {
|
|
try {
|
|
await knowledgebaseApi.trackClick(article.value.linkid)
|
|
article.value.clicks = (article.value.clicks || 0) + 1
|
|
// Open the URL after tracking
|
|
if (article.value.linkurl) {
|
|
window.open(article.value.linkurl, '_blank')
|
|
}
|
|
} catch (error) {
|
|
console.error('Error tracking click:', error)
|
|
// Still open even if tracking fails
|
|
if (article.value.linkurl) {
|
|
window.open(article.value.linkurl, '_blank')
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.detail-page {
|
|
max-width: 800px;
|
|
margin: 0 auto;
|
|
}
|
|
|
|
.header-actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.article-card {
|
|
padding: 1.5rem;
|
|
}
|
|
|
|
.info-table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
.info-table th,
|
|
.info-table td {
|
|
padding: 0.75rem;
|
|
text-align: left;
|
|
border: none;
|
|
}
|
|
|
|
.info-table th {
|
|
width: 150px;
|
|
font-weight: 600;
|
|
color: var(--text-light, #666);
|
|
vertical-align: top;
|
|
}
|
|
|
|
.info-table td a {
|
|
color: var(--primary, #1976d2);
|
|
text-decoration: none;
|
|
word-break: break-all;
|
|
overflow-wrap: break-word;
|
|
}
|
|
|
|
.info-table td a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.info-table td {
|
|
word-break: break-word;
|
|
overflow-wrap: break-word;
|
|
max-width: 500px;
|
|
}
|
|
|
|
hr {
|
|
margin: 1.5rem 0;
|
|
border: none;
|
|
border-top: 1px solid var(--border-color, #e5e5e5);
|
|
}
|
|
|
|
.actions-center {
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 1rem;
|
|
margin-top: 1rem;
|
|
}
|
|
|
|
.btn-lg {
|
|
padding: 0.75rem 1.5rem;
|
|
font-size: 1rem;
|
|
}
|
|
|
|
.btn-icon {
|
|
margin-right: 0.5rem;
|
|
}
|
|
|
|
.alert {
|
|
padding: 1rem;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.alert-warning {
|
|
background: #fff3cd;
|
|
color: #856404;
|
|
}
|
|
|
|
@media (prefers-color-scheme: dark) {
|
|
.alert-warning {
|
|
background: #3d3200;
|
|
color: #ffc107;
|
|
}
|
|
}
|
|
</style>
|