mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +08:00
feat(wiki): PR-8 frontend protection badges + delete-button gating
This commit is contained in:
parent
1e62dbad47
commit
4ed88ebbf9
@ -1340,6 +1340,10 @@ export default {
|
|||||||
loadMore: 'Load {n} more',
|
loadMore: 'Load {n} more',
|
||||||
filteredByRaw: 'Filtered by source material',
|
filteredByRaw: 'Filtered by source material',
|
||||||
clearFilter: 'Clear filter',
|
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: {
|
pageTypes: {
|
||||||
concept: 'Concepts',
|
concept: 'Concepts',
|
||||||
person: 'People',
|
person: 'People',
|
||||||
|
|||||||
@ -1350,6 +1350,10 @@ export default {
|
|||||||
loadMore: '加载更多 {n} 条',
|
loadMore: '加载更多 {n} 条',
|
||||||
filteredByRaw: '按原始材料过滤',
|
filteredByRaw: '按原始材料过滤',
|
||||||
clearFilter: '清除过滤',
|
clearFilter: '清除过滤',
|
||||||
|
systemPageBadge: '系统页',
|
||||||
|
systemPageHint: 'overview / log 是知识库自带的系统页,无法删除',
|
||||||
|
lockedPageBadge: '已锁定',
|
||||||
|
lockedPageHint: '该页被手动锁定,AI 工具与批量操作不会修改或删除',
|
||||||
pageTypes: {
|
pageTypes: {
|
||||||
concept: '概念',
|
concept: '概念',
|
||||||
person: '人物',
|
person: '人物',
|
||||||
|
|||||||
@ -46,10 +46,22 @@ export interface WikiPage {
|
|||||||
version: number
|
version: number
|
||||||
lastUpdatedBy: string
|
lastUpdatedBy: string
|
||||||
pageType?: string | null
|
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
|
createTime: string
|
||||||
updateTime: 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', () => {
|
export const useWikiStore = defineStore('wiki', () => {
|
||||||
const knowledgeBases = ref<WikiKB[]>([])
|
const knowledgeBases = ref<WikiKB[]>([])
|
||||||
const currentKB = ref<WikiKB | null>(null)
|
const currentKB = ref<WikiKB | null>(null)
|
||||||
|
|||||||
@ -29,9 +29,20 @@
|
|||||||
<el-icon><SetUp /></el-icon>
|
<el-icon><SetUp /></el-icon>
|
||||||
{{ t('wiki.page.repair') }}
|
{{ t('wiki.page.repair') }}
|
||||||
</button>
|
</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') }}
|
{{ t('common.delete') }}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
@ -79,7 +90,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted } from 'vue'
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
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 { wikiApi } from '@/api/index'
|
||||||
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
|
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
|
||||||
import { Link, SetUp } from '@element-plus/icons-vue'
|
import { Link, SetUp } from '@element-plus/icons-vue'
|
||||||
@ -97,6 +108,11 @@ const backlinks = ref<WikiPage[]>([])
|
|||||||
const citationDrawerOpen = ref(false)
|
const citationDrawerOpen = ref(false)
|
||||||
const enrichToast = ref('')
|
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(() => {
|
const renderedContent = computed(() => {
|
||||||
if (!store.currentPage?.content) return ''
|
if (!store.currentPage?.content) return ''
|
||||||
// Build a lookup map: title (normalized) → slug, for resolving [[Title]] links
|
// 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-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); }
|
.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 */
|
/* Summary */
|
||||||
.page-summary { padding: 16px 20px; background: var(--mc-bg-muted); border-radius: 12px; border-left: 3px solid var(--mc-primary); }
|
.page-summary { padding: 16px 20px; background: var(--mc-bg-muted); border-radius: 12px; border-left: 3px solid var(--mc-primary); }
|
||||||
|
|||||||
@ -121,20 +121,29 @@
|
|||||||
<div v-if="!collapsedGroups.has(group.type)" class="group-items">
|
<div v-if="!collapsedGroups.has(group.type)" class="group-items">
|
||||||
<div
|
<div
|
||||||
v-for="page in paginatedGroupPages(group)" :key="page.slug"
|
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)"
|
@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
|
<input
|
||||||
v-if="batchMode"
|
v-if="batchMode"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:checked="selectedSlugs.includes(page.slug)"
|
:checked="selectedSlugs.includes(page.slug)"
|
||||||
|
:disabled="isProtectedPage(page)"
|
||||||
class="page-checkbox"
|
class="page-checkbox"
|
||||||
@click.stop="toggleSelect(page.slug)"
|
@click.stop="!isProtectedPage(page) && toggleSelect(page.slug)"
|
||||||
/>
|
/>
|
||||||
<div class="page-item-body">
|
<div class="page-item-body">
|
||||||
<div class="page-item-title">{{ page.title }}</div>
|
<div class="page-item-title">{{ page.title }}</div>
|
||||||
<div class="page-item-meta">
|
<div class="page-item-meta">
|
||||||
<span v-if="page.lastUpdatedBy === 'manual'" class="edit-dot manual" title="Manual edit"></span>
|
<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>
|
<span class="meta-text">v{{ page.version }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -156,20 +165,27 @@
|
|||||||
<div class="page-list" v-else>
|
<div class="page-list" v-else>
|
||||||
<div
|
<div
|
||||||
v-for="page in paginatedSearch" :key="page.slug"
|
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)"
|
@click="batchMode ? toggleSelect(page.slug) : openPage(page.slug)"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-if="batchMode"
|
v-if="batchMode"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:checked="selectedSlugs.includes(page.slug)"
|
:checked="selectedSlugs.includes(page.slug)"
|
||||||
|
:disabled="isProtectedPage(page)"
|
||||||
class="page-checkbox"
|
class="page-checkbox"
|
||||||
@click.stop="toggleSelect(page.slug)"
|
@click.stop="!isProtectedPage(page) && toggleSelect(page.slug)"
|
||||||
/>
|
/>
|
||||||
<div class="page-item-body">
|
<div class="page-item-body">
|
||||||
<div class="page-item-title">{{ page.title }}</div>
|
<div class="page-item-title">{{ page.title }}</div>
|
||||||
<div class="page-item-meta">
|
<div class="page-item-meta">
|
||||||
<span class="type-chip">{{ formatGroupLabel(page.pageType || 'other') }}</span>
|
<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>
|
<span class="meta-text">v{{ page.version }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -255,7 +271,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 } from '@/stores/useWikiStore'
|
import { useWikiStore, isProtectedPage } 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'
|
||||||
@ -631,6 +647,15 @@ onMounted(() => {
|
|||||||
.edit-dot { width: 5px; height: 5px; border-radius: 9999px; flex-shrink: 0; }
|
.edit-dot { width: 5px; height: 5px; border-radius: 9999px; flex-shrink: 0; }
|
||||||
.edit-dot.manual { background: var(--mc-primary); }
|
.edit-dot.manual { background: var(--mc-primary); }
|
||||||
.page-checkbox { flex-shrink: 0; cursor: pointer; }
|
.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 {
|
.load-more-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user