feat(wiki): PR-8 frontend protection badges + delete-button gating

This commit is contained in:
matevip 2026-04-25 19:02:32 +08:00
parent 1e62dbad47
commit 4ed88ebbf9
5 changed files with 92 additions and 8 deletions

View File

@ -1340,6 +1340,10 @@ export default {
loadMore: 'Load {n} more',
filteredByRaw: 'Filtered by source material',
clearFilter: 'Clear filter',
systemPageBadge: 'System',
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',
pageTypes: {
concept: 'Concepts',
person: 'People',

View File

@ -1350,6 +1350,10 @@ export default {
loadMore: '加载更多 {n} 条',
filteredByRaw: '按原始材料过滤',
clearFilter: '清除过滤',
systemPageBadge: '系统页',
systemPageHint: 'overview / log 是知识库自带的系统页,无法删除',
lockedPageBadge: '已锁定',
lockedPageHint: '该页被手动锁定AI 工具与批量操作不会修改或删除',
pageTypes: {
concept: '概念',
person: '人物',

View File

@ -46,10 +46,22 @@ export interface WikiPage {
version: number
lastUpdatedBy: string
pageType?: string | null
// RFC-051 PR-2: locked=1 blocks AI/tool/UI deletion (combined with pageType=system
// for the built-in overview/log pages, but users can lock any page).
locked?: number | null
// RFC-051 PR-7: archived=1 hides the page from default list/search/related.
archived?: number | null
createTime: string
updateTime: string
}
/** RFC-051 PR-8: shared protection check used by viewer + list to gate delete UI. */
export function isProtectedPage(page: WikiPage | null | undefined): boolean {
if (!page) return false
if (page.pageType === 'system') return true
return page.locked === 1
}
export const useWikiStore = defineStore('wiki', () => {
const knowledgeBases = ref<WikiKB[]>([])
const currentKB = ref<WikiKB | null>(null)

View File

@ -29,9 +29,20 @@
<el-icon><SetUp /></el-icon>
{{ t('wiki.page.repair') }}
</button>
<button v-if="!editing" class="btn-secondary btn-sm btn-delete" @click="handleDelete">
<!-- RFC-051 PR-8: hide delete on protected pages (system / locked). -->
<button
v-if="!editing && !isProtected"
class="btn-secondary btn-sm btn-delete"
@click="handleDelete"
>
{{ t('common.delete') }}
</button>
<span v-if="!editing && isSystem" class="system-badge" :title="t('wiki.systemPageHint')">
{{ t('wiki.systemPageBadge') }}
</span>
<span v-if="!editing && isLockedNotSystem" class="locked-badge" :title="t('wiki.lockedPageHint')">
{{ t('wiki.lockedPageBadge') }}
</span>
</div>
<!-- Content -->
@ -79,7 +90,7 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWikiStore, type WikiPage } from '@/stores/useWikiStore'
import { useWikiStore, isProtectedPage, type WikiPage } from '@/stores/useWikiStore'
import { wikiApi } from '@/api/index'
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
import { Link, SetUp } from '@element-plus/icons-vue'
@ -97,6 +108,11 @@ const backlinks = ref<WikiPage[]>([])
const citationDrawerOpen = ref(false)
const enrichToast = ref('')
// RFC-051 PR-8: protection state for delete-button gating + badge rendering.
const isSystem = computed(() => store.currentPage?.pageType === 'system')
const isProtected = computed(() => isProtectedPage(store.currentPage))
const isLockedNotSystem = computed(() => isProtected.value && !isSystem.value)
const renderedContent = computed(() => {
if (!store.currentPage?.content) return ''
// Build a lookup map: title (normalized) slug, for resolving [[Title]] links
@ -208,7 +224,30 @@ onMounted(() => {
.btn-secondary.btn-delete:hover { background: var(--el-color-danger-light-9, #fef0f0); border-color: var(--el-color-danger-light-5, #fab6b6); }
.btn-secondary.btn-action { color: var(--mc-primary); }
.page-actions-bar { display: flex; gap: 8px; flex-wrap: wrap; }
.page-actions-bar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
/* RFC-051 PR-8: protection badges shown next to action buttons. */
.system-badge,
.locked-badge {
display: inline-flex;
align-items: center;
padding: 2px 9px;
border-radius: 99px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
user-select: none;
}
.system-badge {
background: var(--mc-primary-bg);
color: var(--mc-primary);
border: 1px solid var(--mc-primary);
}
.locked-badge {
background: var(--mc-bg-elevated);
color: var(--mc-text-secondary);
border: 1px solid var(--mc-border-light);
}
/* Summary */
.page-summary { padding: 16px 20px; background: var(--mc-bg-muted); border-radius: 12px; border-left: 3px solid var(--mc-primary); }

View File

@ -121,20 +121,29 @@
<div v-if="!collapsedGroups.has(group.type)" class="group-items">
<div
v-for="page in paginatedGroupPages(group)" :key="page.slug"
class="page-item" :class="{ active: !batchMode && store.currentPage?.slug === page.slug }"
class="page-item"
:class="{
active: !batchMode && store.currentPage?.slug === page.slug,
'page-item--system': page.pageType === 'system'
}"
@click="batchMode ? toggleSelect(page.slug) : openPage(page.slug)"
>
<!-- RFC-051 PR-8: batch checkbox is disabled for protected pages so a stray
batch-delete can't drag in overview/log or any locked page. -->
<input
v-if="batchMode"
type="checkbox"
:checked="selectedSlugs.includes(page.slug)"
:disabled="isProtectedPage(page)"
class="page-checkbox"
@click.stop="toggleSelect(page.slug)"
@click.stop="!isProtectedPage(page) && toggleSelect(page.slug)"
/>
<div class="page-item-body">
<div class="page-item-title">{{ page.title }}</div>
<div class="page-item-meta">
<span v-if="page.lastUpdatedBy === 'manual'" class="edit-dot manual" title="Manual edit"></span>
<span v-if="page.pageType === 'system'" class="page-flag page-flag--system">{{ t('wiki.systemPageBadge') }}</span>
<span v-else-if="page.locked === 1" class="page-flag page-flag--locked">{{ t('wiki.lockedPageBadge') }}</span>
<span class="meta-text">v{{ page.version }}</span>
</div>
</div>
@ -156,20 +165,27 @@
<div class="page-list" v-else>
<div
v-for="page in paginatedSearch" :key="page.slug"
class="page-item" :class="{ active: !batchMode && store.currentPage?.slug === page.slug }"
class="page-item"
:class="{
active: !batchMode && store.currentPage?.slug === page.slug,
'page-item--system': page.pageType === 'system'
}"
@click="batchMode ? toggleSelect(page.slug) : openPage(page.slug)"
>
<input
v-if="batchMode"
type="checkbox"
:checked="selectedSlugs.includes(page.slug)"
:disabled="isProtectedPage(page)"
class="page-checkbox"
@click.stop="toggleSelect(page.slug)"
@click.stop="!isProtectedPage(page) && toggleSelect(page.slug)"
/>
<div class="page-item-body">
<div class="page-item-title">{{ page.title }}</div>
<div class="page-item-meta">
<span class="type-chip">{{ formatGroupLabel(page.pageType || 'other') }}</span>
<span v-if="page.pageType === 'system'" class="page-flag page-flag--system">{{ t('wiki.systemPageBadge') }}</span>
<span v-else-if="page.locked === 1" class="page-flag page-flag--locked">{{ t('wiki.lockedPageBadge') }}</span>
<span class="meta-text">v{{ page.version }}</span>
</div>
</div>
@ -255,7 +271,7 @@
<script setup lang="ts">
import { ref, reactive, computed, onMounted, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWikiStore } from '@/stores/useWikiStore'
import { useWikiStore, isProtectedPage } from '@/stores/useWikiStore'
import { wikiApi } from '@/api/index'
import RawMaterialPanel from './components/RawMaterialPanel.vue'
import WikiPageViewer from './components/WikiPageViewer.vue'
@ -631,6 +647,15 @@ onMounted(() => {
.edit-dot { width: 5px; height: 5px; border-radius: 9999px; flex-shrink: 0; }
.edit-dot.manual { background: var(--mc-primary); }
.page-checkbox { flex-shrink: 0; cursor: pointer; }
.page-checkbox:disabled { cursor: not-allowed; opacity: 0.4; }
/* RFC-051 PR-8: protection flag chips inside the page-item meta line. */
.page-flag { font-size: 10px; padding: 1px 6px; border-radius: 99px; font-weight: 600; }
.page-flag--system { background: var(--mc-primary-bg); color: var(--mc-primary); border: 1px solid var(--mc-primary); }
.page-flag--locked { background: var(--mc-bg-elevated); color: var(--mc-text-secondary); border: 1px solid var(--mc-border-light); }
/* 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; }
.load-more-btn {
width: 100%;