mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(wiki): archived pages drawer in Wiki UI
This commit is contained in:
parent
51566a47a4
commit
8ef523f953
@ -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")
|
||||
|
||||
@ -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 页面默认不返回。
|
||||
|
||||
@ -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`),
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -1354,6 +1354,9 @@ export default {
|
||||
systemPageHint: 'overview / log 是知识库自带的系统页,无法删除',
|
||||
lockedPageBadge: '已锁定',
|
||||
lockedPageHint: '该页被手动锁定,AI 工具与批量操作不会修改或删除',
|
||||
archivedSection: '已归档',
|
||||
noArchived: '没有已归档的页面',
|
||||
unarchive: '恢复',
|
||||
pageTypes: {
|
||||
concept: '概念',
|
||||
person: '人物',
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user