feat(wiki): split KB management vs reading into separate workspace views (#309)

The KB workspace previously stacked all seven surfaces in one tab strip.
Split them by intent: a gear on each library card opens the management view
(raw materials, config, transformations, advanced, hot cache), while clicking
the card body opens the reading view (pages + graph). The reading view drives
page/graph via a header segmented control with the page tree shown only for
the page viewer; the two views share loaded data and toggle without refetch.

Closes matevip/mateclaw#308
This commit is contained in:
倪程伟 2026-06-10 13:40:57 +08:00 committed by GitHub
parent 7478491652
commit 347e42b795
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 245 additions and 36 deletions

View File

@ -2074,7 +2074,11 @@ export default {
deleteKBConfirm: 'Delete knowledge base "{name}"? This will also remove {raws} raw materials and {pages} wiki pages. This action cannot be undone.',
deleteKBSuccess: 'Deleted knowledge base "{name}"',
deleteKBFailed: 'Failed to delete knowledge base',
manageKB: 'Manage knowledge base',
toManage: 'Manage',
toReading: 'View wiki',
},
manageBadge: 'Management',
selectPage: 'Select a page from the sidebar',
pageKicker: 'Knowledge Page',
confirmDelete: 'Delete page "{title}"? This cannot be undone.',

View File

@ -2086,7 +2086,11 @@ export default {
deleteKBConfirm: '确认删除知识库「{name}」?将一并清除 {raws} 个原始材料和 {pages} 个 Wiki 页面,此操作不可撤销。',
deleteKBSuccess: '已删除知识库「{name}」',
deleteKBFailed: '删除知识库失败',
manageKB: '管理知识库',
toManage: '管理',
toReading: '查看 Wiki',
},
manageBadge: '管理',
selectPage: '从左侧选择一个页面查看',
pageKicker: '知识页面',
confirmDelete: '确认删除页面「{title}」?此操作不可撤销。',

View File

@ -126,6 +126,11 @@ export interface WikiLintJob {
export const useWikiStore = defineStore('wiki', () => {
const knowledgeBases = ref<WikiKB[]>([])
const currentKB = ref<WikiKB | null>(null)
// Which face of an opened KB to show: 'browse' = reading surfaces (pages +
// graph), 'manage' = build/config surfaces (raw materials, config,
// transformations, advanced, hot cache). Both views share the data loaded by
// selectKB, so switching between them is a flag flip with no refetch.
const workspaceMode = ref<'browse' | 'manage'>('browse')
const rawMaterials = ref<WikiRawMaterial[]>([])
const pages = ref<WikiPage[]>([])
const currentPage = ref<WikiPage | null>(null)
@ -172,7 +177,8 @@ export const useWikiStore = defineStore('wiki', () => {
}
}
async function selectKB(id: number) {
async function selectKB(id: number, mode: 'browse' | 'manage' = 'browse') {
workspaceMode.value = mode
const res: any = await wikiApi.getKB(id)
currentKB.value = res.data || res
// pageRefs refresh in parallel with materials + pages — the viewer needs
@ -239,9 +245,16 @@ export const useWikiStore = defineStore('wiki', () => {
}
}
// Switch between reading/management faces of an already-open KB without
// refetching — both share the data selectKB already loaded.
function setWorkspaceMode(mode: 'browse' | 'manage') {
workspaceMode.value = mode
}
function backToLibrary() {
currentKB.value = null
currentPage.value = null
workspaceMode.value = 'browse'
rawMaterials.value = []
pages.value = []
pageRefs.value = []
@ -440,6 +453,7 @@ export const useWikiStore = defineStore('wiki', () => {
return {
knowledgeBases,
currentKB,
workspaceMode,
rawMaterials,
pages,
currentPage,
@ -456,6 +470,7 @@ export const useWikiStore = defineStore('wiki', () => {
selectKB,
createKB,
deleteKB,
setWorkspaceMode,
backToLibrary,
fetchRawMaterials,
fetchPages,

View File

@ -11,7 +11,22 @@
<button
v-if="canManageWiki"
type="button"
class="kb-card-delete"
class="kb-card-action kb-card-manage"
:title="t('wiki.library.manageKB')"
:aria-label="t('wiki.library.manageKB')"
@click.stop="$emit('manage', kb.id)"
@keydown.enter.stop
@keydown.space.stop
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
<button
v-if="canManageWiki"
type="button"
class="kb-card-action kb-card-delete"
:title="t('wiki.library.deleteKB')"
:aria-label="t('wiki.library.deleteKB')"
@click.stop="$emit('delete', kb)"
@ -72,6 +87,7 @@ const props = defineProps<{
defineEmits<{
(e: 'open', id: number): void
(e: 'manage', id: number): void
(e: 'delete', kb: WikiKB): void
}>()
@ -111,10 +127,9 @@ const relative = computed(() => relativeTime(props.kb.updateTime, locale.value.s
}
.kb-card:focus-visible { outline: 2px solid var(--mc-primary); outline-offset: 2px; }
.kb-card-delete {
.kb-card-action {
position: absolute;
top: 10px;
right: 10px;
width: 28px;
height: 28px;
display: inline-flex;
@ -131,11 +146,27 @@ const relative = computed(() => relativeTime(props.kb.updateTime, locale.value.s
padding: 0;
z-index: 1;
}
.kb-card:hover .kb-card-delete,
.kb-card:focus-within .kb-card-delete {
.kb-card-delete { right: 10px; }
.kb-card-manage { right: 44px; }
.kb-card:hover .kb-card-action,
.kb-card:focus-within .kb-card-action {
opacity: 1;
transform: translateY(0);
}
.kb-card-manage:hover {
color: var(--mc-primary);
border-color: var(--mc-primary);
background: var(--mc-primary-bg);
}
.kb-card-manage:focus-visible {
outline: 2px solid var(--mc-primary);
outline-offset: 2px;
opacity: 1;
transform: translateY(0);
}
.kb-card-delete:hover {
color: var(--mc-danger);
border-color: rgba(245, 108, 108, 0.45);

View File

@ -66,6 +66,7 @@
:kb="kb"
:failed-job-count="kbStats[kb.id]?.failedJobCount || 0"
@open="$emit('open', $event)"
@manage="$emit('manage', $event)"
@delete="$emit('delete', $event)"
/>
</div>
@ -104,6 +105,7 @@ const props = defineProps<{
defineEmits<{
(e: 'open', id: number): void
(e: 'manage', id: number): void
(e: 'create'): void
(e: 'delete', kb: WikiKB): void
}>()

View File

@ -1,22 +1,44 @@
<template>
<div class="wiki-workspace">
<WikiWorkspaceHeader :kb="kb" @back="store.backToLibrary()" />
<WikiWorkspaceHeader
:kb="kb"
:mode="store.workspaceMode"
:can-manage="canManageWiki"
:reading-tab="activeTab === 'graph' ? 'graph' : 'pages'"
@back="store.backToLibrary()"
@switch-mode="store.setWorkspaceMode($event)"
@switch-reading="activeTab = $event"
/>
<!--
Broken-link banner surfaces lint state across every tab so the user
can trigger a scan or view results from any browsing context. Pinned
between the header and the tab layout so it's the first thing they see
after entering the KB.
Broken-link banner surfaces lint state so the user can trigger a scan
or view results. Only shown in the reading (browse) view; the management
view is config/raw surfaces where page-lint state isn't relevant.
-->
<WikiBrokenLinksBanner @view="brokenPanelOpen = true" />
<WikiBrokenLinksPanel :open="brokenPanelOpen" @close="brokenPanelOpen = false" />
<template v-if="store.workspaceMode === 'browse'">
<WikiBrokenLinksBanner @view="brokenPanelOpen = true" />
<WikiBrokenLinksPanel :open="brokenPanelOpen" @close="brokenPanelOpen = false" />
</template>
<div class="wiki-layout">
<WikiPageSidebar @open-page="onOpenPage" />
<!--
Page directory tree reading-view navigation for the page viewer only.
Hidden on the graph (the graph is its own navigation) and in manage
mode, so the content area spans full width in both.
-->
<WikiPageSidebar
v-if="store.workspaceMode === 'browse' && activeTab === 'pages'"
@open-page="onOpenPage"
/>
<div class="wiki-content mc-surface-card">
<div class="wiki-content-body">
<div class="content-tabs">
<!--
Tab strip is the management-view navigation only. In the reading
view the page/graph switch lives in the header (segmented control),
so no tab strip here.
-->
<div v-if="store.workspaceMode === 'manage'" class="content-tabs">
<button
v-for="tab in tabs" :key="tab.key"
class="tab-btn" :class="{ active: activeTab === tab.key }"
@ -92,35 +114,39 @@ const workspace = useWorkspaceStore()
// the processing-config and transformations tabs are management surfaces.
const canManageWiki = computed(() => workspace.can('manage:wiki'))
const activeTab = ref('raw')
const activeTab = ref('pages')
const brokenPanelOpen = ref(false)
// When a page becomes the currentPage (e.g. via the global wikilink click
// handler that lands on /wiki?kbId=X&slug=Y), switch the tab to 'pages' so
// the viewer is the thing the user sees. Without this, the workspace stays
// on the default 'raw' tab and the page silently loads off-screen.
// the viewer is the thing the user sees. Only meaningful in the reading view
// the management view has no 'pages' tab.
watch(() => store.currentPage, (page) => {
if (page) activeTab.value = 'pages'
if (page && store.workspaceMode === 'browse') activeTab.value = 'pages'
})
// Management-view tab strip. The reading view (page + graph) is driven by the
// header's segmented control instead, so it has no tabs here. Entry into manage
// mode is gated by manage:wiki, but guard here too so a read-only viewer never
// sees management tabs even if the mode is somehow forced.
const tabs = computed(() => {
const list = [
if (!canManageWiki.value) return []
return [
{ key: 'raw', label: t('wiki.rawMaterials') },
{ key: 'pages', label: t('wiki.pages') },
{ key: 'graph', label: t('wiki.graph.tab') },
{ key: 'config', label: t('wiki.config') },
{ key: 'transformations', label: t('wiki.transformations.tab') },
{ key: 'advanced', label: t('wiki.adv.tab') },
{ key: 'hotCache', label: t('wiki.hotCache.tab') },
]
if (canManageWiki.value) {
list.push({ key: 'config', label: t('wiki.config') })
list.push({ key: 'transformations', label: t('wiki.transformations.tab') })
list.push({ key: 'advanced', label: t('wiki.adv.tab') })
}
list.push({ key: 'hotCache', label: t('wiki.hotCache.tab') })
return list
})
// When the user picks a different KB from the library, snap back to the
// raw-materials tab so they don't land on stale state from the previous KB.
watch(() => store.currentKB?.id, () => { activeTab.value = 'raw' })
// Snap to each view's default tab whenever the KB or the view mode changes, so
// the user never lands on a stale tab (or one that doesn't exist in this mode).
watch(
() => [store.currentKB?.id, store.workspaceMode],
() => { activeTab.value = store.workspaceMode === 'manage' ? 'raw' : 'pages' },
{ immediate: true },
)
async function onOpenPage(slug: string) {
if (!store.currentKB) return

View File

@ -10,11 +10,64 @@
<div class="workspace-title-row">
<div class="workspace-icon" :style="iconStyle">{{ initial }}</div>
<div class="workspace-title-text">
<h1 class="workspace-title">{{ kb.name }}</h1>
<div class="workspace-title-line">
<h1 class="workspace-title">{{ kb.name }}</h1>
<span v-if="mode === 'manage'" class="workspace-mode-badge">{{ t('wiki.manageBadge') }}</span>
</div>
<p v-if="kb.description" class="workspace-desc">{{ kb.description }}</p>
</div>
</div>
</div>
<div v-if="mode === 'browse'" class="reading-toggle" role="tablist">
<button
type="button"
class="reading-toggle-btn"
:class="{ active: readingTab === 'pages' }"
role="tab"
:aria-selected="readingTab === 'pages'"
@click="$emit('switch-reading', 'pages')"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
{{ t('wiki.pages') }}
</button>
<button
type="button"
class="reading-toggle-btn"
:class="{ active: readingTab === 'graph' }"
role="tab"
:aria-selected="readingTab === 'graph'"
@click="$emit('switch-reading', 'graph')"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="5" cy="6" r="2.5"/><circle cx="19" cy="6" r="2.5"/><circle cx="12" cy="18" r="2.5"/><path d="M7 7.5 10.5 16M17 7.5 13.5 16M6.7 6h10.6"/></svg>
{{ t('wiki.graph.tab') }}
</button>
</div>
<button
v-if="mode === 'browse' && canManage"
type="button"
class="mode-switch-btn"
:title="t('wiki.library.toManage')"
@click="$emit('switch-mode', 'manage')"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
{{ t('wiki.library.toManage') }}
</button>
<button
v-else-if="mode === 'manage'"
type="button"
class="mode-switch-btn"
:title="t('wiki.library.toReading')"
@click="$emit('switch-mode', 'browse')"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
</svg>
{{ t('wiki.library.toReading') }}
</button>
<div class="workspace-meta">
<span class="workspace-stat">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
@ -35,8 +88,17 @@ import { useI18n } from 'vue-i18n'
import type { WikiKB } from '@/stores/useWikiStore'
import { kbAccent, kbAccentFg, kbInitial } from '../utils/kbVisual'
const props = defineProps<{ kb: WikiKB }>()
defineEmits<{ (e: 'back'): void }>()
const props = defineProps<{
kb: WikiKB
mode: 'browse' | 'manage'
canManage: boolean
readingTab: 'pages' | 'graph'
}>()
defineEmits<{
(e: 'back'): void
(e: 'switch-mode', mode: 'browse' | 'manage'): void
(e: 'switch-reading', tab: 'pages' | 'graph'): void
}>()
const { t } = useI18n()
@ -89,6 +151,66 @@ const iconStyle = computed(() => ({
flex-shrink: 0;
}
.workspace-title-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.workspace-title-line { display: flex; align-items: center; gap: 8px; min-width: 0; }
.workspace-mode-badge {
flex-shrink: 0;
font-size: 11px;
font-weight: 600;
color: var(--mc-primary);
background: var(--mc-primary-bg);
border: 1px solid var(--mc-primary);
padding: 1px 8px;
border-radius: 9999px;
line-height: 1.5;
}
.reading-toggle {
display: inline-flex;
gap: 4px;
padding: 4px;
background: var(--mc-bg-muted);
border: 1px solid var(--mc-border-light);
border-radius: 12px;
}
.reading-toggle-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: none;
background: none;
cursor: pointer;
font-size: 13px;
font-weight: 500;
color: var(--mc-text-secondary);
border-radius: 9px;
transition: all 0.15s;
}
.reading-toggle-btn:hover { color: var(--mc-text-primary); }
.reading-toggle-btn.active {
color: var(--mc-primary);
background: var(--mc-bg-elevated);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
font-weight: 600;
}
.mode-switch-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: var(--mc-bg-elevated);
border: 1px solid var(--mc-border-light);
border-radius: 10px;
color: var(--mc-text-secondary);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.mode-switch-btn:hover {
color: var(--mc-primary);
border-color: var(--mc-primary);
background: var(--mc-primary-bg);
}
.workspace-title {
font-size: 22px;
font-weight: 700;

View File

@ -8,6 +8,7 @@
:kb-stats="kbStats"
:loading="store.loading"
@open="enterKB"
@manage="enterKBManage"
@create="showCreateKB = true"
@delete="handleDeleteKB"
/>
@ -82,7 +83,11 @@ const newKBName = ref('')
const newKBDesc = ref('')
async function enterKB(id: number) {
await store.selectKB(id)
await store.selectKB(id, 'browse')
}
async function enterKBManage(id: number) {
await store.selectKB(id, 'manage')
}
async function handleCreateKB() {