feat(wiki-ui): advanced management panel for page types, layers, permissions, watcher and pipelines

This commit is contained in:
matevip 2026-05-31 08:00:06 +08:00
parent e18e0f1029
commit 4cde845e27
5 changed files with 709 additions and 1 deletions

View File

@ -758,7 +758,7 @@ export const wikiApi = {
http.put(`/wiki/knowledge-bases/${id}/config`, { content }),
// Directory Scan
setSourceDirectory: (id: number, path: string) =>
setSourceDirectory: (id: string | number, path: string) =>
http.put(`/wiki/knowledge-bases/${id}/source-directory`, { path }),
scanDirectory: (id: number) => http.post(`/wiki/knowledge-bases/${id}/scan`),
@ -903,6 +903,51 @@ export const wikiApi = {
http.post(`/wiki/transformations/runs/${runId}/save-as-page`),
cancelTransformationRun: (runId: number) =>
http.post(`/wiki/transformations/runs/${runId}/cancel`),
// ---- PageType Profile (REQ-1) ----
getPageTypeProfile: (kbId: string | number) =>
http.get(`/wiki/knowledge-bases/${kbId}/page-type-profile`),
savePageTypeProfile: (kbId: string | number, config: string, name?: string) =>
http.put(`/wiki/knowledge-bases/${kbId}/page-type-profile`, { config, name }),
validatePageTypeProfile: (kbId: string | number, config: string) =>
http.post(`/wiki/knowledge-bases/${kbId}/page-type-profile/validate`, { config }),
resetPageTypeProfile: (kbId: string | number) =>
http.post(`/wiki/knowledge-bases/${kbId}/page-type-profile/reset-default`),
// ---- Agent pageType permissions (REQ-3) ----
listPageTypePermissions: (kbId: string | number, agentId: string | number) =>
http.get(`/wiki/knowledge-bases/${kbId}/agents/${agentId}/page-type-permissions`),
savePageTypePermission: (kbId: string | number, agentId: string | number, row: {
pageType: string
canRead?: number
canCreate?: number
canUpdate?: number
canDelete?: number
writePolicy?: 'allow' | 'deny' | 'approval_required'
}) =>
http.post(`/wiki/knowledge-bases/${kbId}/agents/${agentId}/page-type-permissions`, row),
deletePageTypePermission: (kbId: string | number, agentId: string | number, id: string | number) =>
http.delete(`/wiki/knowledge-bases/${kbId}/agents/${agentId}/page-type-permissions/${id}`),
// ---- Source watcher (REQ-4) ----
getSourceWatcher: (kbId: string | number) =>
http.get(`/wiki/knowledge-bases/${kbId}/source-watcher`),
triggerSourceWatcher: (kbId: string | number) =>
http.post(`/wiki/knowledge-bases/${kbId}/source-watcher/scan`),
// ---- Pipelines (REQ-5) ----
listPipelines: (kbId: string | number) =>
http.get(`/wiki/knowledge-bases/${kbId}/pipelines`),
savePipeline: (kbId: string | number, config: string, format: 'yaml' | 'json' = 'yaml') =>
http.post(`/wiki/knowledge-bases/${kbId}/pipelines`, { config, format }),
validatePipeline: (kbId: string | number, config: string, format: 'yaml' | 'json' = 'yaml') =>
http.post(`/wiki/knowledge-bases/${kbId}/pipelines/validate`, { config, format }),
deletePipeline: (kbId: string | number, id: string | number) =>
http.delete(`/wiki/knowledge-bases/${kbId}/pipelines/${id}`),
listPipelineRuns: (kbId: string | number, id: string | number) =>
http.get(`/wiki/knowledge-bases/${kbId}/pipelines/${id}/runs`),
getPipelineRun: (kbId: string | number, runId: string | number) =>
http.get(`/wiki/knowledge-bases/${kbId}/pipeline-runs/${runId}`),
}
// ==================== Workspace (Team) ====================

View File

@ -2113,6 +2113,81 @@ export default {
lastError: 'Last error',
footer: 'Toggling wiki.hot_cache.enabled off short-circuits the injection path. The "Rebuild now" button bypasses the debounce window for easier debugging.',
},
adv: {
tab: 'Advanced',
validate: 'Validate',
validOk: 'Validation passed',
profile: {
tab: 'Page Types',
title: 'Page Type Profile',
desc: 'Define this KB\'s page types, their knowledge layer, route/create/merge stage instructions and content templates. Injected into the ingest pipeline prompts.',
builtin: 'Built-in default',
placeholder: 'Paste the pageType profile JSON here…',
reset: 'Reset to default',
resetConfirm: 'Reset to the built-in default profile? Your custom config will be cleared.',
},
layers: {
tab: 'Layers & Stale',
title: 'Knowledge Layers & Stale State',
desc: 'When a fact-layer page is updated, experience pages that depend on it are marked stale to flag they need review.',
fact: 'Fact',
experience: 'Experience',
other: 'Other',
stale: 'Stale',
empty: 'This knowledge base has no pages yet.',
page: 'Page',
layer: 'Layer',
status: 'Status',
staleTag: 'Needs review',
fresh: 'Fresh',
},
perm: {
tab: 'Permissions',
title: 'Agent Page Type Permissions',
desc: 'Configure per-pageType read/create/update/delete access for an agent in this KB; writes can require approval. No rules = allow all; once any rule exists, uncovered types are fail-safe denied.',
agent: 'Agent',
selectAgent: 'Select an agent…',
pageType: 'Page Type',
policy: 'Write policy',
noRows: 'This agent has no permission rules (allow all).',
pageTypeHint: 'page type, * for default',
addRule: 'Add rule',
deleteConfirm: 'Delete the permission rule for "{type}"?',
},
watcher: {
tab: 'Watcher',
title: 'Source Directory Watcher',
desc: 'Bind a local directory as a knowledge source; the watcher scans for new/changed files at an interval and ingests them automatically. You can also trigger a one-off scan.',
enabled: 'Watcher enabled',
active: 'Active',
interval: 'Scan interval',
sourceType: 'Source type',
directory: 'Source directory',
dirHint: 'absolute path, e.g. /data/docs',
availableTypes: 'Available source types',
scanNow: 'Scan now',
scanDone: 'Scan complete',
scanResult: 'Scanned {scanned}, added {added}, skipped {skipped}, errors {errors}',
},
pipeline: {
tab: 'Pipelines',
title: 'Custom Pipelines',
desc: 'Define triggers (e.g. a page type reaching a count threshold, page created) and multi-step processing (llm / skill) in YAML. Once saved, the backend runs them on the trigger condition.',
name: 'Name',
trigger: 'Trigger',
enabled: 'Enabled',
runs: 'Runs',
empty: 'No pipeline definitions yet.',
editor: 'YAML definition',
editorHint: 'name: my-pipeline\ntrigger:\n type: page_type_count\n page_type: episode\n threshold: 10\nsteps:\n - executor: llm\n prompt: …',
saveDef: 'Save definition',
deleteConfirm: 'Delete pipeline "{name}"?',
runsFor: 'Runs for "{name}"',
status: 'Status',
startedAt: 'Started at',
noRuns: 'No runs yet.',
},
},
searchPages: 'Search pages...',
dropFiles: 'Drop files here or click to upload',
dropToUpload: 'Release to upload',

View File

@ -2125,6 +2125,81 @@ export default {
lastError: '最近一次错误',
footer: '关闭 wiki.hot_cache.enabled 后注入路径短路;该面板的"立即重建"会绕过去抖,方便调试。',
},
adv: {
tab: '高级管理',
validate: '校验',
validOk: '校验通过',
profile: {
tab: '页面类型',
title: '页面类型 Profile',
desc: '定义本知识库的页面类型pageType、所属知识分层、路由/创建/合并阶段指令与内容模板。会注入到摄取流水线的各阶段提示词。',
builtin: '内置默认',
placeholder: '在此粘贴 pageType profile 的 JSON…',
reset: '重置为默认',
resetConfirm: '确认重置为内置默认 profile当前自定义配置会被清除。',
},
layers: {
tab: '分层 & 失效',
title: '知识分层与失效状态',
desc: 'fact事实层更新后会把依赖它的 experience经验页面标记为 stale提示需要复核。',
fact: '事实层',
experience: '经验层',
other: '其它',
stale: '已失效',
empty: '该知识库暂无页面。',
page: '页面',
layer: '分层',
status: '状态',
staleTag: '待复核',
fresh: '正常',
},
perm: {
tab: '权限 & 审批',
title: 'Agent 页面类型权限',
desc: '为指定 Agent 配置在本知识库下按 pageType 的读/创建/更新/删除权限;写操作可设为需审批。无规则时默认放行,一旦存在规则则未覆盖的类型按失败安全(拒绝)处理。',
agent: 'Agent',
selectAgent: '选择一个 Agent…',
pageType: '页面类型',
policy: '写策略',
noRows: '该 Agent 暂无权限规则(默认放行)。',
pageTypeHint: '页面类型,* 表示默认',
addRule: '新增规则',
deleteConfirm: '删除 "{type}" 的权限规则?',
},
watcher: {
tab: '变更监测',
title: '源目录变更监测',
desc: '关联一个本地目录作为知识来源,监测器会按间隔扫描新增/变更文件并自动摄取。也可手动触发一次扫描。',
enabled: '监测开关',
active: '已激活',
interval: '扫描间隔',
sourceType: '源类型',
directory: '源目录',
dirHint: '绝对路径,例如 /data/docs',
availableTypes: '可用源类型',
scanNow: '立即扫描',
scanDone: '扫描完成',
scanResult: '扫描 {scanned} 个,新增 {added},跳过 {skipped},错误 {errors}',
},
pipeline: {
tab: '流水线',
title: '自定义流水线',
desc: '用 YAML 定义触发器如某类型页面累计到阈值、页面创建与多步处理llm / skill。保存后由后台按触发条件自动执行。',
name: '名称',
trigger: '触发器',
enabled: '启用',
runs: '运行记录',
empty: '暂无流水线定义。',
editor: 'YAML 定义',
editorHint: 'name: my-pipeline\ntrigger:\n type: page_type_count\n page_type: episode\n threshold: 10\nsteps:\n - executor: llm\n prompt: …',
saveDef: '保存定义',
deleteConfirm: '删除流水线 "{name}"',
runsFor: '"{name}" 的运行记录',
status: '状态',
startedAt: '开始时间',
noRuns: '暂无运行记录。',
},
},
searchPages: '搜索页面...',
dropFiles: '拖拽文件到此处或点击上传',
dropToUpload: '松开即开始上传',

View File

@ -0,0 +1,507 @@
<template>
<div class="adv-panel">
<!-- Sub-navigation across the five advanced management surfaces -->
<div class="adv-subtabs">
<button
v-for="s in sections" :key="s.key"
class="adv-subtab" :class="{ active: section === s.key }"
@click="switchSection(s.key)"
>{{ s.label }}</button>
</div>
<!-- ===================== REQ-1: PageType Profile ===================== -->
<section v-if="section === 'profile'" class="adv-section">
<header class="adv-head">
<div>
<h3>{{ t('wiki.adv.profile.title') }}</h3>
<p class="adv-desc">{{ t('wiki.adv.profile.desc') }}</p>
</div>
<span v-if="profile.builtinDefault" class="badge badge-muted">{{ t('wiki.adv.profile.builtin') }}</span>
<span v-else class="badge badge-ok">v{{ profile.version }}</span>
</header>
<textarea
v-model="profile.config"
class="code-editor" spellcheck="false"
:placeholder="t('wiki.adv.profile.placeholder')"
></textarea>
<div v-if="profile.issues.length" class="issue-box">
<div v-for="(iss, i) in profile.issues" :key="i" class="issue-line"> {{ iss }}</div>
</div>
<div class="adv-actions">
<button class="btn-ghost" @click="validateProfile" :disabled="profile.busy">{{ t('wiki.adv.validate') }}</button>
<button class="btn-ghost danger" @click="resetProfile" :disabled="profile.busy">{{ t('wiki.adv.profile.reset') }}</button>
<button class="btn-primary" @click="saveProfile" :disabled="profile.busy">{{ t('common.save') }}</button>
</div>
</section>
<!-- ===================== REQ-2: Layers & Stale ===================== -->
<section v-else-if="section === 'layers'" class="adv-section">
<header class="adv-head">
<div>
<h3>{{ t('wiki.adv.layers.title') }}</h3>
<p class="adv-desc">{{ t('wiki.adv.layers.desc') }}</p>
</div>
<button class="btn-ghost" @click="loadLayers" :disabled="layers.busy">{{ t('common.refresh') }}</button>
</header>
<div class="layer-stats">
<div class="stat-chip"><b>{{ layerGroups.fact.length }}</b> {{ t('wiki.adv.layers.fact') }}</div>
<div class="stat-chip"><b>{{ layerGroups.experience.length }}</b> {{ t('wiki.adv.layers.experience') }}</div>
<div class="stat-chip"><b>{{ layerGroups.other.length }}</b> {{ t('wiki.adv.layers.other') }}</div>
<div class="stat-chip stale"><b>{{ staleCount }}</b> {{ t('wiki.adv.layers.stale') }}</div>
</div>
<div v-if="layers.pages.length === 0 && !layers.busy" class="empty-hint">{{ t('wiki.adv.layers.empty') }}</div>
<table v-else class="adv-table">
<thead><tr>
<th>{{ t('wiki.adv.layers.page') }}</th>
<th>{{ t('wiki.adv.layers.layer') }}</th>
<th>{{ t('wiki.adv.layers.status') }}</th>
</tr></thead>
<tbody>
<tr v-for="p in layers.pages" :key="p.id" :class="{ 'row-stale': isStale(p) }">
<td class="cell-title">{{ p.title || p.slug }}</td>
<td><span class="layer-tag" :class="layerClass(p.knowledgeLayer)">{{ p.knowledgeLayer || '—' }}</span></td>
<td>
<span v-if="isStale(p)" class="badge badge-warn" :title="staleReason(p)">{{ t('wiki.adv.layers.staleTag') }}</span>
<span v-else class="badge badge-ok">{{ t('wiki.adv.layers.fresh') }}</span>
</td>
</tr>
</tbody>
</table>
</section>
<!-- ===================== REQ-3: Permissions ===================== -->
<section v-else-if="section === 'permissions'" class="adv-section">
<header class="adv-head">
<div>
<h3>{{ t('wiki.adv.perm.title') }}</h3>
<p class="adv-desc">{{ t('wiki.adv.perm.desc') }}</p>
</div>
</header>
<div class="perm-agent-row">
<label>{{ t('wiki.adv.perm.agent') }}</label>
<select v-model="perm.agentId" class="form-input compact" @change="loadPermissions">
<option value="">{{ t('wiki.adv.perm.selectAgent') }}</option>
<option v-for="a in agents" :key="a.id" :value="String(a.id)">{{ a.name }}</option>
</select>
</div>
<template v-if="perm.agentId">
<table class="adv-table">
<thead><tr>
<th>{{ t('wiki.adv.perm.pageType') }}</th>
<th>R</th><th>C</th><th>U</th><th>D</th>
<th>{{ t('wiki.adv.perm.policy') }}</th>
<th></th>
</tr></thead>
<tbody>
<tr v-for="row in perm.rows" :key="row.id">
<td class="cell-title">{{ row.pageType }}</td>
<td>{{ flag(row.canRead) }}</td>
<td>{{ flag(row.canCreate) }}</td>
<td>{{ flag(row.canUpdate) }}</td>
<td>{{ flag(row.canDelete) }}</td>
<td><span class="badge" :class="policyClass(row.writePolicy)">{{ row.writePolicy }}</span></td>
<td><button class="link-danger" @click="deletePermission(row)">{{ t('common.delete') }}</button></td>
</tr>
<tr v-if="perm.rows.length === 0"><td colspan="7" class="empty-hint">{{ t('wiki.adv.perm.noRows') }}</td></tr>
</tbody>
</table>
<div class="perm-add">
<input v-model.trim="perm.draft.pageType" class="form-input compact" :placeholder="t('wiki.adv.perm.pageTypeHint')" />
<label class="ck"><input type="checkbox" v-model="perm.draft.canRead" /> R</label>
<label class="ck"><input type="checkbox" v-model="perm.draft.canCreate" /> C</label>
<label class="ck"><input type="checkbox" v-model="perm.draft.canUpdate" /> U</label>
<label class="ck"><input type="checkbox" v-model="perm.draft.canDelete" /> D</label>
<select v-model="perm.draft.writePolicy" class="form-input compact">
<option value="allow">allow</option>
<option value="approval_required">approval_required</option>
<option value="deny">deny</option>
</select>
<button class="btn-primary" @click="savePermission" :disabled="!perm.draft.pageType">{{ t('wiki.adv.perm.addRule') }}</button>
</div>
</template>
</section>
<!-- ===================== REQ-4: Source Watcher ===================== -->
<section v-else-if="section === 'watcher'" class="adv-section">
<header class="adv-head">
<div>
<h3>{{ t('wiki.adv.watcher.title') }}</h3>
<p class="adv-desc">{{ t('wiki.adv.watcher.desc') }}</p>
</div>
<button class="btn-ghost" @click="loadWatcher" :disabled="watcher.busy">{{ t('common.refresh') }}</button>
</header>
<div class="kv-grid">
<div class="kv"><span>{{ t('wiki.adv.watcher.enabled') }}</span><b>{{ watcher.data.watcherEnabled ? t('common.yes') : t('common.no') }}</b></div>
<div class="kv"><span>{{ t('wiki.adv.watcher.active') }}</span><b>{{ watcher.data.active ? t('common.yes') : t('common.no') }}</b></div>
<div class="kv"><span>{{ t('wiki.adv.watcher.interval') }}</span><b>{{ watcher.data.intervalMs ? (watcher.data.intervalMs / 1000) + 's' : '—' }}</b></div>
<div class="kv"><span>{{ t('wiki.adv.watcher.sourceType') }}</span><b>{{ watcher.data.sourceType || '—' }}</b></div>
</div>
<label class="field-label">{{ t('wiki.adv.watcher.directory') }}</label>
<div class="dir-row">
<input v-model.trim="watcher.dir" class="form-input" :placeholder="t('wiki.adv.watcher.dirHint')" />
<button class="btn-ghost" @click="saveDirectory" :disabled="watcher.busy">{{ t('common.save') }}</button>
</div>
<p v-if="watcher.data.availableSourceTypes?.length" class="adv-desc">
{{ t('wiki.adv.watcher.availableTypes') }}: {{ watcher.data.availableSourceTypes.join(', ') }}
</p>
<div class="adv-actions">
<button class="btn-primary" @click="triggerScan" :disabled="watcher.busy || !watcher.data.active">{{ t('wiki.adv.watcher.scanNow') }}</button>
</div>
<div v-if="watcher.lastScan" class="issue-box ok">
{{ t('wiki.adv.watcher.scanResult', { scanned: watcher.lastScan.scanned, added: watcher.lastScan.added, skipped: watcher.lastScan.skipped, errors: watcher.lastScan.errors }) }}
</div>
</section>
<!-- ===================== REQ-5: Pipeline ===================== -->
<section v-else-if="section === 'pipeline'" class="adv-section">
<header class="adv-head">
<div>
<h3>{{ t('wiki.adv.pipeline.title') }}</h3>
<p class="adv-desc">{{ t('wiki.adv.pipeline.desc') }}</p>
</div>
<button class="btn-ghost" @click="loadPipelines" :disabled="pipeline.busy">{{ t('common.refresh') }}</button>
</header>
<table class="adv-table">
<thead><tr>
<th>{{ t('wiki.adv.pipeline.name') }}</th>
<th>{{ t('wiki.adv.pipeline.trigger') }}</th>
<th>{{ t('wiki.adv.pipeline.enabled') }}</th>
<th></th>
</tr></thead>
<tbody>
<tr v-for="d in pipeline.defs" :key="d.id">
<td class="cell-title">{{ d.name }}</td>
<td>{{ d.triggerType }}</td>
<td>{{ d.enabled ? '✓' : '—' }}</td>
<td class="cell-actions">
<button class="link" @click="viewRuns(d)">{{ t('wiki.adv.pipeline.runs') }}</button>
<button class="link-danger" @click="deletePipeline(d)">{{ t('common.delete') }}</button>
</td>
</tr>
<tr v-if="pipeline.defs.length === 0"><td colspan="4" class="empty-hint">{{ t('wiki.adv.pipeline.empty') }}</td></tr>
</tbody>
</table>
<div v-if="pipeline.runsFor" class="runs-box">
<div class="runs-head">
<b>{{ t('wiki.adv.pipeline.runsFor', { name: pipeline.runsFor.name }) }}</b>
<button class="link" @click="pipeline.runsFor = null">{{ t('common.close') }}</button>
</div>
<table class="adv-table compact">
<thead><tr><th>#</th><th>{{ t('wiki.adv.pipeline.status') }}</th><th>{{ t('wiki.adv.pipeline.startedAt') }}</th></tr></thead>
<tbody>
<tr v-for="r in pipeline.runs" :key="r.id">
<td>{{ r.id }}</td>
<td><span class="badge" :class="runClass(r.status)">{{ r.status }}</span></td>
<td>{{ r.createTime }}</td>
</tr>
<tr v-if="pipeline.runs.length === 0"><td colspan="3" class="empty-hint">{{ t('wiki.adv.pipeline.noRuns') }}</td></tr>
</tbody>
</table>
</div>
<label class="field-label">{{ t('wiki.adv.pipeline.editor') }}</label>
<textarea
v-model="pipeline.config"
class="code-editor" spellcheck="false"
:placeholder="t('wiki.adv.pipeline.editorHint')"
></textarea>
<div v-if="pipeline.issues.length" class="issue-box">
<div v-for="(iss, i) in pipeline.issues" :key="i" class="issue-line"> {{ iss }}</div>
</div>
<div class="adv-actions">
<button class="btn-ghost" @click="validatePipeline" :disabled="pipeline.busy">{{ t('wiki.adv.validate') }}</button>
<button class="btn-primary" @click="savePipeline" :disabled="pipeline.busy">{{ t('wiki.adv.pipeline.saveDef') }}</button>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWikiStore } from '@/stores/useWikiStore'
import { useAgentStore } from '@/stores/useAgentStore'
import { wikiApi } from '@/api/index'
import { mcToast } from '@/composables/useMcToast'
import { mcConfirm } from '@/components/common/useConfirm'
const { t } = useI18n()
const store = useWikiStore()
const agentStore = useAgentStore()
// kbId stays a string for its whole lifecycle (Snowflake precision rule).
const kbId = computed(() => (store.currentKB ? String(store.currentKB.id) : ''))
const agents = computed(() => agentStore.agents)
const section = ref<'profile' | 'layers' | 'permissions' | 'watcher' | 'pipeline'>('profile')
const sections = computed(() => [
{ key: 'profile' as const, label: t('wiki.adv.profile.tab') },
{ key: 'layers' as const, label: t('wiki.adv.layers.tab') },
{ key: 'permissions' as const, label: t('wiki.adv.perm.tab') },
{ key: 'watcher' as const, label: t('wiki.adv.watcher.tab') },
{ key: 'pipeline' as const, label: t('wiki.adv.pipeline.tab') },
])
const loaded = reactive<Record<string, boolean>>({})
function switchSection(key: typeof section.value) {
section.value = key
if (loaded[key]) return
loaded[key] = true
if (key === 'layers') loadLayers()
else if (key === 'permissions') { if (agents.value.length === 0) agentStore.fetchAgents() }
else if (key === 'watcher') loadWatcher()
else if (key === 'pipeline') loadPipelines()
}
function unwrap(res: any) { return res?.data ?? res }
function errMsg(e: any, fallback: string) { return e?.response?.data?.message || fallback }
// ---- REQ-1 Profile ----
const profile = reactive({ config: '', version: 0, builtinDefault: true, issues: [] as string[], busy: false })
async function loadProfile() {
if (!kbId.value) return
try {
const d = unwrap(await wikiApi.getPageTypeProfile(kbId.value))
profile.config = typeof d.config === 'string' ? d.config : JSON.stringify(d.config, null, 2)
profile.version = d.version
profile.builtinDefault = d.builtinDefault
} catch (e: any) { mcToast.error(errMsg(e, 'Load profile failed')) }
}
async function validateProfile() {
profile.busy = true
try {
const d = unwrap(await wikiApi.validatePageTypeProfile(kbId.value, profile.config))
profile.issues = d.issues || []
if (d.valid) mcToast.success(t('wiki.adv.validOk'))
} catch (e: any) { mcToast.error(errMsg(e, 'Validate failed')) } finally { profile.busy = false }
}
async function saveProfile() {
profile.busy = true
try {
await wikiApi.savePageTypeProfile(kbId.value, profile.config)
mcToast.success(t('common.saved'))
profile.issues = []
await loadProfile()
} catch (e: any) { mcToast.error(errMsg(e, 'Save failed')) } finally { profile.busy = false }
}
async function resetProfile() {
if (!(await mcConfirm({ title: t('wiki.adv.profile.reset'), message: t('wiki.adv.profile.resetConfirm'), tone: 'danger' }))) return
profile.busy = true
try {
await wikiApi.resetPageTypeProfile(kbId.value)
mcToast.success(t('common.saved'))
await loadProfile()
} catch (e: any) { mcToast.error(errMsg(e, 'Reset failed')) } finally { profile.busy = false }
}
// ---- REQ-2 Layers & Stale ----
const layers = reactive({ pages: [] as any[], busy: false })
async function loadLayers() {
if (!kbId.value) return
layers.busy = true
try {
layers.pages = unwrap(await wikiApi.listPages(kbId.value as any)) || []
} catch (e: any) { mcToast.error(errMsg(e, 'Load pages failed')) } finally { layers.busy = false }
}
function isStale(p: any) { return p.stale === 1 || p.stale === true }
const staleCount = computed(() => layers.pages.filter(isStale).length)
const layerGroups = computed(() => {
const g = { fact: [] as any[], experience: [] as any[], other: [] as any[] }
for (const p of layers.pages) {
if (p.knowledgeLayer === 'fact') g.fact.push(p)
else if (p.knowledgeLayer === 'experience') g.experience.push(p)
else g.other.push(p)
}
return g
})
function layerClass(l?: string) { return l === 'fact' ? 'tag-fact' : l === 'experience' ? 'tag-exp' : 'tag-other' }
function staleReason(p: any) {
try { return p.staleReasonJson ? JSON.stringify(JSON.parse(p.staleReasonJson)) : '' } catch { return p.staleReasonJson || '' }
}
// ---- REQ-3 Permissions ----
const perm = reactive({
agentId: '',
rows: [] as any[],
draft: { pageType: '', canRead: true, canCreate: false, canUpdate: false, canDelete: false, writePolicy: 'approval_required' as 'allow' | 'deny' | 'approval_required' },
})
async function loadPermissions() {
if (!perm.agentId || !kbId.value) { perm.rows = []; return }
try {
perm.rows = unwrap(await wikiApi.listPageTypePermissions(kbId.value, perm.agentId)) || []
} catch (e: any) { mcToast.error(errMsg(e, 'Load permissions failed')) }
}
async function savePermission() {
if (!perm.draft.pageType) return
try {
await wikiApi.savePageTypePermission(kbId.value, perm.agentId, {
pageType: perm.draft.pageType,
canRead: perm.draft.canRead ? 1 : 0,
canCreate: perm.draft.canCreate ? 1 : 0,
canUpdate: perm.draft.canUpdate ? 1 : 0,
canDelete: perm.draft.canDelete ? 1 : 0,
writePolicy: perm.draft.writePolicy,
})
mcToast.success(t('common.saved'))
perm.draft.pageType = ''
await loadPermissions()
} catch (e: any) { mcToast.error(errMsg(e, 'Save failed')) }
}
async function deletePermission(row: any) {
if (!(await mcConfirm({ title: t('common.delete'), message: t('wiki.adv.perm.deleteConfirm', { type: row.pageType }), tone: 'danger' }))) return
try {
await wikiApi.deletePageTypePermission(kbId.value, perm.agentId, String(row.id))
await loadPermissions()
} catch (e: any) { mcToast.error(errMsg(e, 'Delete failed')) }
}
function flag(v: any) { return v ? '✓' : '·' }
function policyClass(p?: string) { return p === 'allow' ? 'badge-ok' : p === 'deny' ? 'badge-warn' : 'badge-muted' }
// ---- REQ-4 Watcher ----
const watcher = reactive({ data: {} as any, dir: '', lastScan: null as any, busy: false })
async function loadWatcher() {
if (!kbId.value) return
watcher.busy = true
try {
watcher.data = unwrap(await wikiApi.getSourceWatcher(kbId.value)) || {}
watcher.dir = watcher.data.sourceDirectory || ''
} catch (e: any) { mcToast.error(errMsg(e, 'Load watcher failed')) } finally { watcher.busy = false }
}
async function saveDirectory() {
watcher.busy = true
try {
await wikiApi.setSourceDirectory(kbId.value, watcher.dir)
mcToast.success(t('common.saved'))
await loadWatcher()
} catch (e: any) { mcToast.error(errMsg(e, 'Save failed')) } finally { watcher.busy = false }
}
async function triggerScan() {
watcher.busy = true
try {
watcher.lastScan = unwrap(await wikiApi.triggerSourceWatcher(kbId.value))
mcToast.success(t('wiki.adv.watcher.scanDone'))
} catch (e: any) { mcToast.error(errMsg(e, 'Scan failed')) } finally { watcher.busy = false }
}
// ---- REQ-5 Pipeline ----
const pipeline = reactive({ defs: [] as any[], config: '', issues: [] as string[], runsFor: null as any, runs: [] as any[], busy: false })
async function loadPipelines() {
if (!kbId.value) return
pipeline.busy = true
try {
pipeline.defs = unwrap(await wikiApi.listPipelines(kbId.value)) || []
} catch (e: any) { mcToast.error(errMsg(e, 'Load pipelines failed')) } finally { pipeline.busy = false }
}
async function validatePipeline() {
pipeline.busy = true
try {
const d = unwrap(await wikiApi.validatePipeline(kbId.value, pipeline.config))
pipeline.issues = d.issues || []
if (d.valid) mcToast.success(t('wiki.adv.validOk'))
} catch (e: any) { mcToast.error(errMsg(e, 'Validate failed')) } finally { pipeline.busy = false }
}
async function savePipeline() {
pipeline.busy = true
try {
await wikiApi.savePipeline(kbId.value, pipeline.config)
mcToast.success(t('common.saved'))
pipeline.issues = []
await loadPipelines()
} catch (e: any) { mcToast.error(errMsg(e, 'Save failed')) } finally { pipeline.busy = false }
}
async function deletePipeline(d: any) {
if (!(await mcConfirm({ title: t('common.delete'), message: t('wiki.adv.pipeline.deleteConfirm', { name: d.name }), tone: 'danger' }))) return
try {
await wikiApi.deletePipeline(kbId.value, String(d.id))
if (pipeline.runsFor && String(pipeline.runsFor.id) === String(d.id)) pipeline.runsFor = null
await loadPipelines()
} catch (e: any) { mcToast.error(errMsg(e, 'Delete failed')) }
}
async function viewRuns(d: any) {
pipeline.runsFor = d
try {
pipeline.runs = unwrap(await wikiApi.listPipelineRuns(kbId.value, String(d.id))) || []
} catch (e: any) { mcToast.error(errMsg(e, 'Load runs failed')) }
}
function runClass(s?: string) {
if (s === 'succeeded' || s === 'success') return 'badge-ok'
if (s === 'failed' || s === 'error') return 'badge-warn'
return 'badge-muted'
}
onMounted(() => { loaded.profile = true; loadProfile() })
</script>
<style scoped>
.adv-panel { display: flex; flex-direction: column; gap: 14px; height: 100%; }
.adv-subtabs { display: inline-flex; gap: 4px; padding: 4px; background: var(--mc-bg-muted); border-radius: 12px; border: 1px solid var(--mc-border-light); align-self: flex-start; flex-wrap: wrap; }
.adv-subtab { padding: 6px 13px; border: none; background: none; cursor: pointer; font-size: 13px; color: var(--mc-text-secondary); border-radius: 9px; font-weight: 500; transition: all 0.15s; }
.adv-subtab:hover { color: var(--mc-text-primary); }
.adv-subtab.active { color: var(--mc-primary); background: var(--mc-bg-elevated); font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
.adv-section { display: flex; flex-direction: column; gap: 12px; overflow-y: auto; padding-right: 2px; }
.adv-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.adv-head h3 { margin: 0; font-size: 15px; font-weight: 700; color: var(--mc-text-primary); }
.adv-desc { margin: 4px 0 0; font-size: 12px; color: var(--mc-text-tertiary); line-height: 1.5; }
.code-editor { width: 100%; min-height: 240px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; line-height: 1.6; padding: 12px; border: 1px solid var(--mc-border); border-radius: 12px; background: var(--mc-bg-muted); color: var(--mc-text-primary); resize: vertical; outline: none; box-sizing: border-box; }
.code-editor:focus { border-color: var(--mc-primary); }
.adv-actions { display: flex; gap: 10px; justify-content: flex-end; }
.btn-primary { padding: 8px 16px; background: linear-gradient(135deg, var(--mc-primary), var(--mc-primary-hover)); color: #fff; border: none; border-radius: 11px; font-size: 13px; font-weight: 600; cursor: pointer; }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-ghost { padding: 8px 14px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 11px; font-size: 13px; cursor: pointer; }
.btn-ghost.danger { color: #c0392b; border-color: rgba(192,57,43,0.3); }
.btn-ghost:disabled { opacity: 0.5; cursor: not-allowed; }
.badge { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: 11px; font-weight: 600; }
.badge-ok { background: rgba(24,74,69,0.12); color: #184a45; }
.badge-warn { background: rgba(192,57,43,0.12); color: #c0392b; }
.badge-muted { background: var(--mc-bg-muted); color: var(--mc-text-secondary); }
.issue-box { border: 1px solid rgba(192,57,43,0.3); background: rgba(192,57,43,0.06); border-radius: 10px; padding: 10px 12px; font-size: 12px; color: #c0392b; }
.issue-box.ok { border-color: rgba(24,74,69,0.3); background: rgba(24,74,69,0.06); color: #184a45; }
.issue-line { line-height: 1.6; }
.adv-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.adv-table th { text-align: left; padding: 8px 10px; color: var(--mc-text-tertiary); font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: 0.03em; border-bottom: 1px solid var(--mc-border-light); }
.adv-table td { padding: 8px 10px; border-bottom: 1px solid var(--mc-border-light); color: var(--mc-text-primary); }
.adv-table.compact td, .adv-table.compact th { padding: 6px 10px; }
.cell-title { font-weight: 600; }
.cell-actions { display: flex; gap: 12px; }
.row-stale { background: rgba(192,57,43,0.04); }
.layer-tag { padding: 2px 8px; border-radius: 6px; font-size: 11px; font-weight: 600; }
.tag-fact { background: rgba(24,74,69,0.12); color: #184a45; }
.tag-exp { background: rgba(217,109,70,0.14); color: #b8552f; }
.tag-other { background: var(--mc-bg-muted); color: var(--mc-text-secondary); }
.layer-stats { display: flex; gap: 10px; flex-wrap: wrap; }
.stat-chip { padding: 8px 14px; background: var(--mc-bg-muted); border-radius: 10px; font-size: 12px; color: var(--mc-text-secondary); }
.stat-chip b { font-size: 16px; color: var(--mc-text-primary); margin-right: 4px; }
.stat-chip.stale b { color: #c0392b; }
.perm-agent-row { display: flex; align-items: center; gap: 10px; }
.perm-agent-row label, .field-label { font-size: 12px; font-weight: 600; color: var(--mc-text-secondary); }
.perm-add { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding-top: 8px; }
.ck { display: inline-flex; align-items: center; gap: 4px; font-size: 12px; color: var(--mc-text-secondary); }
.form-input { padding: 8px 12px; border: 1px solid var(--mc-border); border-radius: 10px; font-size: 13px; background: var(--mc-bg-muted); color: var(--mc-text-primary); outline: none; box-sizing: border-box; }
.form-input:focus { border-color: var(--mc-primary); }
.form-input.compact { padding: 6px 10px; }
.link { background: none; border: none; color: var(--mc-primary); cursor: pointer; font-size: 12px; padding: 0; }
.link-danger { background: none; border: none; color: #c0392b; cursor: pointer; font-size: 12px; padding: 0; }
.empty-hint { color: var(--mc-text-tertiary); font-size: 13px; text-align: center; padding: 18px; }
.kv-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; }
.kv { display: flex; flex-direction: column; gap: 3px; padding: 10px 12px; background: var(--mc-bg-muted); border-radius: 10px; }
.kv span { font-size: 11px; color: var(--mc-text-tertiary); }
.kv b { font-size: 14px; color: var(--mc-text-primary); }
.dir-row { display: flex; gap: 8px; }
.dir-row .form-input { flex: 1; }
.runs-box { border: 1px solid var(--mc-border-light); border-radius: 12px; padding: 12px; background: var(--mc-bg-muted); }
.runs-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
</style>

View File

@ -55,6 +55,10 @@
<div v-if="activeTab === 'transformations'" class="tab-content">
<TransformationsPanel />
</div>
<div v-if="activeTab === 'advanced'" class="tab-content">
<WikiAdvancedPanel />
</div>
</div>
</div>
</div>
@ -72,6 +76,7 @@ import WikiConfig from './WikiConfig.vue'
import WikiGraphView from './WikiGraphView.vue'
import HotCachePanel from './HotCachePanel.vue'
import TransformationsPanel from './TransformationsPanel.vue'
import WikiAdvancedPanel from './WikiAdvancedPanel.vue'
import WikiWorkspaceHeader from './WikiWorkspaceHeader.vue'
import WikiPageSidebar from './WikiPageSidebar.vue'
import WikiBrokenLinksBanner from './WikiBrokenLinksBanner.vue'
@ -107,6 +112,7 @@ const tabs = computed(() => {
if (canManageWiki.value) {
list.push({ key: 'config', label: t('wiki.config') })
list.push({ key: 'transformations', label: t('wiki.transformations.tab') })
list.push({ key: 'advanced', label: t('wiki.adv.tab') })
}
list.push({ key: 'hotCache', label: t('wiki.hotCache.tab') })
return list