feat(wiki): archived pages drawer in Wiki UI

This commit is contained in:
matevip 2026-04-25 19:02:33 +08:00
parent 51566a47a4
commit 8ef523f953
6 changed files with 206 additions and 1 deletions

View File

@ -361,6 +361,40 @@ public class WikiController {
return R.ok(pageService.getBacklinks(kbId, slug));
}
// RFC-051 PR-7 follow-up: archive surfaces. Default-list is filtered, so the UI
// needs a dedicated endpoint to enumerate archived pages and a way to flip the
// flag via REST (the agent tools wiki_archive_page / wiki_unarchive_page already
// exist, but the admin UI shouldn't have to go through agent plumbing).
@RequireWorkspaceRole("viewer")
@Operation(summary = "列出知识库中所有 archived=1 的页面(不含 content")
@GetMapping("/knowledge-bases/{kbId}/pages/archived")
public R<List<WikiPageEntity>> listArchivedPages(@PathVariable Long kbId,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId);
return R.ok(pageService.listArchivedByKbId(kbId));
}
@RequireWorkspaceRole("admin")
@Operation(summary = "归档单个页面(软归档;可恢复)")
@PostMapping("/knowledge-bases/{kbId}/pages/{slug}/archive")
public R<Map<String, Object>> archivePage(@PathVariable Long kbId, @PathVariable String slug,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId);
boolean changed = pageService.setArchived(kbId, slug, true);
return R.ok(Map.of("slug", slug, "archived", true, "changed", changed));
}
@RequireWorkspaceRole("admin")
@Operation(summary = "取消归档")
@PostMapping("/knowledge-bases/{kbId}/pages/{slug}/unarchive")
public R<Map<String, Object>> unarchivePage(@PathVariable Long kbId, @PathVariable String slug,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId);
boolean changed = pageService.setArchived(kbId, slug, false);
return R.ok(Map.of("slug", slug, "archived", false, "changed", changed));
}
// ==================== Processing ====================
@RequireWorkspaceRole("member")

View File

@ -67,6 +67,21 @@ public class WikiPageService {
.toList();
}
/**
* RFC-051 PR-7 follow-up: list ONLY archived pages the inverse of the
* default {@link #listByKbId} filter. Used by the admin UI's "show archived"
* panel so users can see what they archived and recover it.
*/
public List<WikiPageEntity> listArchivedByKbId(Long kbId) {
List<WikiPageEntity> pages = pageMapper.selectList(
new LambdaQueryWrapper<WikiPageEntity>()
.eq(WikiPageEntity::getKbId, kbId)
.eq(WikiPageEntity::getArchived, 1)
.orderByDesc(WikiPageEntity::getUpdateTime));
pages.forEach(p -> p.setContent(null));
return pages;
}
/**
* 列出知识库的所有页面不含 content
* RFC-051 PR-7: archived 页面默认不返回

View File

@ -448,6 +448,14 @@ export const wikiApi = {
getBacklinks: (kbId: number, slug: string) =>
http.get(`/wiki/knowledge-bases/${kbId}/pages/${encodeURIComponent(slug)}/backlinks`),
// RFC-051 PR-7: archived pages
listArchivedPages: (kbId: number) =>
http.get(`/wiki/knowledge-bases/${kbId}/pages/archived`),
archivePage: (kbId: number, slug: string) =>
http.post(`/wiki/knowledge-bases/${kbId}/pages/${encodeURIComponent(slug)}/archive`),
unarchivePage: (kbId: number, slug: string) =>
http.post(`/wiki/knowledge-bases/${kbId}/pages/${encodeURIComponent(slug)}/unarchive`),
// Processing
processKB: (kbId: number) => http.post(`/wiki/knowledge-bases/${kbId}/process`),
getProcessingStatus: (kbId: number) => http.get(`/wiki/knowledge-bases/${kbId}/processing-status`),

View File

@ -1344,6 +1344,9 @@ export default {
systemPageHint: 'overview / log are built-in system pages — they can\'t be deleted',
lockedPageBadge: 'Locked',
lockedPageHint: 'This page is locked — AI tools and batch operations skip it',
archivedSection: 'Archived',
noArchived: 'No archived pages',
unarchive: 'Restore',
pageTypes: {
concept: 'Concepts',
person: 'People',

View File

@ -1354,6 +1354,9 @@ export default {
systemPageHint: 'overview / log 是知识库自带的系统页,无法删除',
lockedPageBadge: '已锁定',
lockedPageHint: '该页被手动锁定AI 工具与批量操作不会修改或删除',
archivedSection: '已归档',
noArchived: '没有已归档的页面',
unarchive: '恢复',
pageTypes: {
concept: '概念',
person: '人物',

View File

@ -159,6 +159,47 @@
</div>
</div>
<div v-if="groupedPages.length === 0" class="empty-hint">{{ t('wiki.noPages') }}</div>
<!-- RFC-051 PR-7 follow-up: archived pages drawer at the bottom of the list. -->
<div class="archived-section">
<button
class="archived-toggle"
:disabled="archivedLoading"
@click="toggleArchived"
>
<svg
class="archived-chevron"
:class="{ expanded: archivedOpen }"
width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
><polyline points="9 18 15 12 9 6"/></svg>
{{ t('wiki.archivedSection') }}
<span v-if="archivedPages.length > 0" class="archived-count">{{ archivedPages.length }}</span>
</button>
<div v-if="archivedOpen" class="archived-items">
<div v-if="archivedLoading" class="archived-empty">{{ t('common.loading') }}</div>
<div v-else-if="archivedPages.length === 0" class="archived-empty">{{ t('wiki.noArchived') }}</div>
<div v-else
v-for="page in archivedPages" :key="'arc:' + page.slug"
class="archived-item"
@click="openPage(page.slug)"
>
<div class="archived-item-body">
<div class="archived-item-title">{{ page.title }}</div>
<div class="archived-item-meta">v{{ page.version }} · {{ page.slug }}</div>
</div>
<button
class="archived-restore-btn"
:title="t('wiki.unarchive')"
@click.stop="restoreArchivedPage(page.slug)"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="3 7 12 12 21 7"/><path d="M3 7v10l9 5 9-5V7"/>
</svg>
{{ t('wiki.unarchive') }}
</button>
</div>
</div>
</div>
</div>
<!-- Flat search results -->
@ -271,7 +312,7 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWikiStore, isProtectedPage } from '@/stores/useWikiStore'
import { useWikiStore, isProtectedPage, type WikiPage } from '@/stores/useWikiStore'
import { wikiApi } from '@/api/index'
import RawMaterialPanel from './components/RawMaterialPanel.vue'
import WikiPageViewer from './components/WikiPageViewer.vue'
@ -325,6 +366,46 @@ const groupPageLimit = reactive<Record<string, number>>({})
// Which groups are collapsed
const collapsedGroups = reactive<Set<string>>(new Set())
// RFC-051 PR-7 follow-up: archived pages drawer state.
const archivedOpen = ref(false)
const archivedLoading = ref(false)
const archivedPages = ref<WikiPage[]>([])
async function toggleArchived() {
archivedOpen.value = !archivedOpen.value
if (archivedOpen.value && archivedPages.value.length === 0 && store.currentKB) {
archivedLoading.value = true
try {
const res: any = await wikiApi.listArchivedPages(store.currentKB.id)
archivedPages.value = (res?.data || res || []) as WikiPage[]
} catch (e) {
console.error('[Wiki] Failed to load archived pages', e)
archivedPages.value = []
} finally {
archivedLoading.value = false
}
}
}
async function restoreArchivedPage(slug: string) {
if (!store.currentKB) return
try {
await wikiApi.unarchivePage(store.currentKB.id, slug)
archivedPages.value = archivedPages.value.filter(p => p.slug !== slug)
// Bring the page back into the main list cache.
await store.fetchPages(store.currentKB.id)
} catch (e: any) {
console.error('[Wiki] Unarchive failed', e)
alert(e?.message || 'Unarchive failed')
}
}
// Reset archived drawer when KB changes so we re-fetch on first open in the new KB.
watch(() => store.currentKB?.id, () => {
archivedOpen.value = false
archivedPages.value = []
})
function toggleGroup(type: string) {
if (collapsedGroups.has(type)) collapsedGroups.delete(type)
else collapsedGroups.add(type)
@ -657,6 +738,67 @@ onMounted(() => {
/* Subtle accent on the row itself so system pages read as "managed by the system". */
.page-item--system { border-left: 2px solid var(--mc-primary); padding-left: 6px; }
/* RFC-051 PR-7 follow-up: archived drawer at the bottom of the page list. */
.archived-section { margin-top: 12px; border-top: 1px dashed var(--mc-border-light); padding-top: 8px; }
.archived-toggle {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 6px 4px;
background: none;
border: none;
font-size: 11px;
color: var(--mc-text-tertiary);
cursor: pointer;
text-align: left;
}
.archived-toggle:hover { color: var(--mc-text-secondary); }
.archived-toggle:disabled { cursor: wait; opacity: 0.5; }
.archived-chevron { transition: transform 0.15s; flex-shrink: 0; }
.archived-chevron.expanded { transform: rotate(90deg); }
.archived-count {
margin-left: auto;
padding: 0 6px;
background: var(--mc-bg-sunken);
border-radius: 99px;
font-weight: 600;
font-size: 10px;
}
.archived-items { display: flex; flex-direction: column; gap: 2px; padding-top: 4px; }
.archived-empty { padding: 6px 4px; font-size: 11px; font-style: italic; color: var(--mc-text-tertiary); }
.archived-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 6px;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.1s, background 0.1s;
}
.archived-item:hover { opacity: 1; background: var(--mc-bg-muted); }
.archived-item-body { flex: 1; min-width: 0; }
.archived-item-title { font-size: 12px; font-weight: 500; color: var(--mc-text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.archived-item-meta { font-size: 10px; color: var(--mc-text-tertiary); margin-top: 1px; }
.archived-restore-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
border: 1px solid var(--mc-border-light);
border-radius: 6px;
background: none;
color: var(--mc-text-secondary);
font-size: 11px;
cursor: pointer;
flex-shrink: 0;
transition: all 0.15s;
}
.archived-restore-btn:hover { color: var(--mc-primary); border-color: var(--mc-primary); background: var(--mc-primary-bg); }
.load-more-btn {
width: 100%;
padding: 6px;