The map was one picture of one floor. A second floor was added, the blueprint changed size, and machines moved, so a position now records WHICH DRAWING its coordinates belong to. Buildings and levels (ADR-017). Each level owns its blueprint per theme and its own native pixel size; assets.mapx/mapy are pixels of assets.levelid, not of the site. A position whose level is unknown renders "level unknown" and is never drawn on the default level, because a marker on the wrong floor plan looks entirely correct while pointing at the wrong place. Repositioning in bulk: filter by unplaced, needs-review or level, search, place, confirm. Landmark recalibration solves the transform PER AXIS from landmark pairs and never from image dimensions - the canvas grew taller without rescaling, so a dimension-derived scale would stretch Y by 1.57 and be wrong everywhere. It defaults to a dry run, reports what would land off the drawing, snapshots before applying, and clears mapverifiedat because a transform is a guess awaiting review. Snapshots restore, including the level and the review state, and a restore snapshots first so an undo is undoable. Search: gaugelabreference was matched only for measuring tools and maintenancereference was matched nowhere at all, for any asset type, while Settings happily offers both identifiers on machines and PCs. A tag an operator is told to record has to be findable or it is a write-only field. USB devices and printed items were unreachable from search entirely - neither is an asset, so the generic asset search could not see them and no searcher existed; they now match on serial, asset tag, label, bin code and gage-lab tag, honouring isactive, with Settings toggles and result labels to match. The retired-application rule was half a rule: GET /api/knowledgebase hid articles whose topic application is retired while global search still returned them and printed the retired application as the subject. A filter is only real if every path that reaches the row applies it. Contract to 0.20.0 (additive): Asset gained levelid and mapverifiedat, Location gained levelid, and resolve_asset_position returns the levelid belonging to whichever source supplied the coordinates. The five plugins that write a map position are re-pinned. The install-list text format gained levelid as a NINTH field, appended, because the shipped Pascal installer reads fields 0-7 by index. That installer still compiles in one drawing's dimensions and bundles one blueprint, so its map is accurate for the default level only; /api/maplevels is deliberately unauthenticated so it can read both at runtime once rebuilt. Recorded in PRINTER-INSTALLER.md section 6 along with the other known gaps. Migration 7d33 converts an existing single-map site into one building and one default level carrying the old map_* settings, then assigns every placed asset and location to it. Nothing moves on screen. Old settings rows are kept so a rollback still finds them. Verified end to end on MySQL 5.6 from a production-shaped database.
279 lines
7.7 KiB
Vue
279 lines
7.7 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
|
|
</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()
|
|
])
|
|
})
|
|
|
|
// 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 || []
|
|
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, showhidden: true }) // 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 = 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>
|