mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 04:18:17 +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));
|
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 ====================
|
// ==================== Processing ====================
|
||||||
|
|
||||||
@RequireWorkspaceRole("member")
|
@RequireWorkspaceRole("member")
|
||||||
|
|||||||
@ -67,6 +67,21 @@ public class WikiPageService {
|
|||||||
.toList();
|
.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)。
|
* 列出知识库的所有页面(不含 content)。
|
||||||
* RFC-051 PR-7: archived 页面默认不返回。
|
* RFC-051 PR-7: archived 页面默认不返回。
|
||||||
|
|||||||
@ -448,6 +448,14 @@ export const wikiApi = {
|
|||||||
getBacklinks: (kbId: number, slug: string) =>
|
getBacklinks: (kbId: number, slug: string) =>
|
||||||
http.get(`/wiki/knowledge-bases/${kbId}/pages/${encodeURIComponent(slug)}/backlinks`),
|
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
|
// Processing
|
||||||
processKB: (kbId: number) => http.post(`/wiki/knowledge-bases/${kbId}/process`),
|
processKB: (kbId: number) => http.post(`/wiki/knowledge-bases/${kbId}/process`),
|
||||||
getProcessingStatus: (kbId: number) => http.get(`/wiki/knowledge-bases/${kbId}/processing-status`),
|
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',
|
systemPageHint: 'overview / log are built-in system pages — they can\'t be deleted',
|
||||||
lockedPageBadge: 'Locked',
|
lockedPageBadge: 'Locked',
|
||||||
lockedPageHint: 'This page is locked — AI tools and batch operations skip it',
|
lockedPageHint: 'This page is locked — AI tools and batch operations skip it',
|
||||||
|
archivedSection: 'Archived',
|
||||||
|
noArchived: 'No archived pages',
|
||||||
|
unarchive: 'Restore',
|
||||||
pageTypes: {
|
pageTypes: {
|
||||||
concept: 'Concepts',
|
concept: 'Concepts',
|
||||||
person: 'People',
|
person: 'People',
|
||||||
|
|||||||
@ -1354,6 +1354,9 @@ export default {
|
|||||||
systemPageHint: 'overview / log 是知识库自带的系统页,无法删除',
|
systemPageHint: 'overview / log 是知识库自带的系统页,无法删除',
|
||||||
lockedPageBadge: '已锁定',
|
lockedPageBadge: '已锁定',
|
||||||
lockedPageHint: '该页被手动锁定,AI 工具与批量操作不会修改或删除',
|
lockedPageHint: '该页被手动锁定,AI 工具与批量操作不会修改或删除',
|
||||||
|
archivedSection: '已归档',
|
||||||
|
noArchived: '没有已归档的页面',
|
||||||
|
unarchive: '恢复',
|
||||||
pageTypes: {
|
pageTypes: {
|
||||||
concept: '概念',
|
concept: '概念',
|
||||||
person: '人物',
|
person: '人物',
|
||||||
|
|||||||
@ -159,6 +159,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="groupedPages.length === 0" class="empty-hint">{{ t('wiki.noPages') }}</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>
|
</div>
|
||||||
|
|
||||||
<!-- Flat search results -->
|
<!-- Flat search results -->
|
||||||
@ -271,7 +312,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, onMounted, watch, nextTick } from 'vue'
|
import { ref, reactive, computed, onMounted, watch, nextTick } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
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 { wikiApi } from '@/api/index'
|
||||||
import RawMaterialPanel from './components/RawMaterialPanel.vue'
|
import RawMaterialPanel from './components/RawMaterialPanel.vue'
|
||||||
import WikiPageViewer from './components/WikiPageViewer.vue'
|
import WikiPageViewer from './components/WikiPageViewer.vue'
|
||||||
@ -325,6 +366,46 @@ const groupPageLimit = reactive<Record<string, number>>({})
|
|||||||
// Which groups are collapsed
|
// Which groups are collapsed
|
||||||
const collapsedGroups = reactive<Set<string>>(new Set())
|
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) {
|
function toggleGroup(type: string) {
|
||||||
if (collapsedGroups.has(type)) collapsedGroups.delete(type)
|
if (collapsedGroups.has(type)) collapsedGroups.delete(type)
|
||||||
else collapsedGroups.add(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". */
|
/* 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; }
|
.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 {
|
.load-more-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user