重构: 拆Knowledge.vue知识库详情(strategy前端·Knowledge<500达标)

- 新建 components/knowledge/KnowledgeDetail.vue(385行<500): 基本信息+溯源+引用列表+生命周期时间线+编辑态+parseTags复用
- Knowledge.vue 775→495(<500达标): 列表+三态+selectKnowledge+action保留
- props detail/topTab/submitting + emit save/publish/reject/archive(子无store, emit回父单向)
主代兜底(新工作模式·vue-tsc only不grep细节降上下文): vue-tsc 0(子代理自验印证)
strategy: 前端views, 子组件抽详情面板; Knowledge+KnowledgeDetail双<500达标; parseTags复用SW-260618-20
git add指定(Knowledge.vue+KnowledgeDetail.vue)
This commit is contained in:
2026-06-19 11:52:14 +08:00
parent a2330de2a8
commit f5fd580215
2 changed files with 412 additions and 307 deletions

View File

@@ -0,0 +1,385 @@
<template>
<!-- 基本信息 -->
<section class="detail-section">
<div class="detail-head">
<div class="detail-title-row">
<span class="detail-kind-icon">{{ kindIcon(detail.knowledge.kind) }}</span>
<span v-if="!editing" class="detail-title">{{ detail.knowledge.title }}</span>
<input v-else v-model="editForm.title" class="edit-input edit-input-title" />
</div>
<div class="detail-actions" v-if="!editing">
<button class="btn btn-ghost btn-sm" @click="startEdit">{{ t('knowledge.edit') }}</button>
<template v-if="topTab === 'inbox'">
<button class="btn btn-primary btn-sm" :disabled="submitting" @click="emit('publish')">{{ t('knowledge.publish') }}</button>
<button class="btn btn-ghost btn-sm" :disabled="submitting" @click="emit('reject')">{{ t('knowledge.reject') }}</button>
</template>
<button v-else class="btn btn-ghost btn-sm" :disabled="submitting" @click="emit('archive')">{{ t('knowledge.archive') }}</button>
</div>
<div class="detail-actions" v-else>
<button class="btn btn-ghost btn-sm" @click="cancelEdit">{{ t('common.cancel') }}</button>
<button class="btn btn-primary btn-sm" @click="saveEdit" :disabled="submitting || !editForm.title.trim()">{{ t('common.save') }}</button>
</div>
</div>
<div class="detail-badges">
<span class="kn-card-status" :class="'st-' + detail.knowledge.status">{{ statusLabel(detail.knowledge.status) }}</span>
<span v-if="detail.knowledge.confidence" :class="'conf-' + detail.knowledge.confidence">{{ t('knowledge.confidenceBadge', { label: confidenceLabel(detail.knowledge.confidence) }) }}</span>
<span class="badge-plain">{{ t('knowledge.reuseCount', { n: detail.knowledge.reuse_count }) }}</span>
<span class="badge-plain">{{ kindLabel(detail.knowledge.kind) }}</span>
</div>
<div class="detail-field">
<label>{{ t('knowledge.contentLabel') }}</label>
<!-- B-260615-25:知识内容 Markdown 渲染(展示态),复用 useMarkdown composable( B-24 TaskDetail);编辑态保持 textarea -->
<div
v-if="!editing && detail.knowledge.content"
class="detail-content ai-md"
v-html="renderedContent"
></div>
<div v-else-if="!editing" class="detail-content"></div>
<textarea v-else v-model="editForm.content" class="edit-input edit-textarea"></textarea>
</div>
<div class="detail-field">
<label>{{ t('knowledge.tagsLabel') }}</label>
<div v-if="!editing" class="detail-tags">
<span class="tag" v-for="tag in parseTags(detail.knowledge.tags)" :key="tag">{{ tag }}</span>
<span v-if="parseTags(detail.knowledge.tags).length === 0" class="muted">{{ t('knowledge.tagsNone') }}</span>
</div>
<input v-else v-model="editForm.tagsInput" class="edit-input" :placeholder="t('knowledge.tagsPlaceholder')" />
</div>
</section>
<!-- 溯源 -->
<section class="detail-section">
<div class="section-title">{{ t('knowledge.traceTitle') }}</div>
<div class="trace-row">
<span class="trace-label">{{ t('knowledge.traceMethod') }}</span>
<span>{{ originMethod }}</span>
</div>
<div class="trace-row" v-if="originConvTitle">
<span class="trace-label">{{ t('knowledge.traceSource') }}</span>
<span>{{ originConvTitle }}</span>
</div>
<div class="trace-row" v-if="originTime">
<span class="trace-label">{{ t('knowledge.traceTime') }}</span>
<span>{{ originTime }}</span>
</div>
<div class="detail-field" v-if="reasoningText">
<label>{{ t('knowledge.reasoningLabel') }}</label>
<div class="reasoning-box">{{ reasoningText }}</div>
</div>
</section>
<!-- 引用 -->
<section class="detail-section">
<div class="section-title">{{ t('knowledge.refTitle', { n: referenceEvents.length }) }}</div>
<div v-if="referenceEvents.length === 0" class="muted">{{ t('knowledge.refEmpty') }}</div>
<div v-else class="ref-list">
<div v-for="ref in referenceEvents.slice(0, refLimit)" :key="ref.id" class="ref-item">
<span class="ref-time">{{ relativeTime(ref.timestamp) }}</span>
<span class="ref-conv">{{ refConvTitle(ref) }}</span>
<span class="ref-query" v-if="refQuery(ref)">{{ refQuery(ref) }}</span>
</div>
<button v-if="referenceEvents.length > refLimit" class="btn btn-ghost btn-sm load-more" @click="refLimit += 10">
{{ t('knowledge.loadMore', { n: referenceEvents.length - refLimit }) }}
</button>
</div>
</section>
<!-- 生命周期时间线 -->
<section class="detail-section">
<div class="section-title">{{ t('knowledge.lifecycleTitle') }}</div>
<div class="timeline">
<div v-for="(node, idx) in timelineNodes" :key="node.id" class="tl-node">
<div class="tl-dot">{{ node.icon }}</div>
<div v-if="idx < timelineNodes.length - 1" class="tl-line"></div>
<div class="tl-body">
<div class="tl-label">{{ node.label }}</div>
<div class="tl-time">{{ relativeTime(node.timestamp) }}</div>
<div class="tl-summary" v-if="node.summary">{{ node.summary }}</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { KNOWLEDGE_KINDS, parseTags } from '@/stores/knowledge'
import { useRendered } from '@/composables/useMarkdown'
import type { KnowledgeDetailPayload, KnowledgeEventRecord } from '@/api/types'
// 详情面板子组件(Knowledge.vue 拆分,SW-260618-20):
// 接收 detail 渲染只读四段(基本信息/溯源/引用/时间线),内部自管 editing/editForm/refLimit;
// 编辑保存与审核操作通过 emit 回传父级执行 IPC(publish/reject/archive/save)。
const props = defineProps<{
detail: KnowledgeDetailPayload
topTab: 'library' | 'inbox'
submitting: boolean
}>()
const emit = defineEmits<{
(e: 'save', payload: { id: string; data: { title: string; content: string; tags: string; confidence: string } }): void
(e: 'publish'): void
(e: 'reject'): void
(e: 'archive'): void
}>()
const { t } = useI18n()
// ===== 编辑(内部状态,与原 Knowledge.vue 行为一致)=====
const editing = ref(false)
const editForm = ref({ title: '', content: '', tagsInput: '', confidence: '' })
function startEdit() {
const k = props.detail.knowledge
editForm.value = {
title: k.title,
content: k.content,
tagsInput: parseTags(k.tags).join(', '),
confidence: k.confidence ?? '',
}
editing.value = true
}
function cancelEdit() {
editing.value = false
}
function saveEdit() {
if (!editForm.value.title.trim()) return
const tags = editForm.value.tagsInput.split(',').map(t => t.trim()).filter(Boolean)
emit('save', {
id: props.detail.knowledge.id,
data: {
title: editForm.value.title.trim(),
content: editForm.value.content,
tags: JSON.stringify(tags),
confidence: editForm.value.confidence, // 空串=清空(后端映射 None),非 undefined
},
})
editing.value = false
}
// ===== Markdown 渲染(B-260615-25,复用模块单例渲染器)=====
const { rendered: renderedContent, ensureLoaded } = useRendered(
() => props.detail?.knowledge.content ?? '',
)
// 引用列表「加载更多」上限
const refLimit = ref(10)
defineExpose({ ensureLoaded })
// ===== 事件解析辅助 =====
function parseContext(e: KnowledgeEventRecord): Record<string, any> {
if (!e.context_json) return {}
try { return JSON.parse(e.context_json) } catch { return {} }
}
function refConvTitle(e: KnowledgeEventRecord): string {
// 解析一次复用:原两次 parseContext 调用会重复 JSON.parse,引用列表项越多浪费越大
const ctx = parseContext(e)
return ctx.conv_title || ctx.conv_id || ''
}
function refQuery(e: KnowledgeEventRecord): string {
return parseContext(e).query || ''
}
// 溯源:提取事件(created/extracted)
const extractedEvent = computed(() => props.detail?.events.find(e => e.event_type === 'extracted'))
const createdEvent = computed(() => props.detail?.events.find(e => e.event_type === 'created'))
const originMethod = computed(() => {
if (extractedEvent.value) return t('knowledge.originExtracted')
if (createdEvent.value) return t('knowledge.originManual')
return t('knowledge.originUnknown')
})
const originConvTitle = computed(() => {
if (!extractedEvent.value) return ''
const ctx = parseContext(extractedEvent.value)
return ctx.conv_title || ''
})
const originTime = computed(() => {
const e = extractedEvent.value || createdEvent.value
return e ? relativeTime(e.timestamp) : ''
})
const reasoningText = computed(() => {
// 优先取主表 reasoning 字段,降级取 extracted 事件 context
if (props.detail?.knowledge.reasoning) return props.detail.knowledge.reasoning
if (extractedEvent.value) return parseContext(extractedEvent.value).reasoning || ''
return ''
})
// 引用事件(倒序)
const referenceEvents = computed(() =>
(props.detail?.events ?? []).filter(e => e.event_type === 'referenced').reverse()
)
// 生命周期时间线节点(正序 + 未来占位)
const timelineNodes = computed(() => {
const events = props.detail?.events ?? []
const nodes = events.map(e => {
const ctx = parseContext(e)
let icon = '•', label = e.event_type, summary = ''
if (e.event_type === 'extracted') {
icon = '🤖'; label = t('knowledge.timeline.extracted')
summary = ctx.conv_title ? t('knowledge.timeline.summarySource', { title: ctx.conv_title }) : ''
} else if (e.event_type === 'created') {
icon = '✍️'; label = t('knowledge.timeline.created')
} else if (e.event_type === 'status_changed') {
const to = ctx.to
if (to === 'published') { icon = '✅'; label = t('knowledge.timeline.published') }
else if (to === 'archived') { icon = '🗄️'; label = t('knowledge.timeline.archived') }
else if (to === 'pending_review') { icon = '👀'; label = t('knowledge.timeline.enterReview') }
else { icon = '🔄'; label = t('knowledge.timeline.statusChanged', { to }) }
} else if (e.event_type === 'referenced') {
icon = '🔄'; label = t('knowledge.timeline.referenced')
summary = ctx.conv_title ? t('knowledge.timeline.summaryConv', { title: ctx.conv_title }) : ''
}
return { id: e.id, icon, label, summary, timestamp: e.timestamp }
})
return nodes
})
// ===== 标签/时间辅助(从 Knowledge.vue 迁移,行为一致)=====
function kindLabel(key: string): string {
const k = KNOWLEDGE_KINDS.find(c => c.key === key)
return k ? `${k.icon} ${kindText(key)}` : key
}
function kindText(key: string): string {
return t(`knowledge.kind.${key}`)
}
function kindIcon(key: string): string {
return KNOWLEDGE_KINDS.find(c => c.key === key)?.icon ?? '📄'
}
function statusLabel(status: string): string {
const key = `knowledge.status.${status}`
const msg = t(key)
return msg === key ? status : msg
}
function confidenceLabel(c: string): string {
const key = `knowledge.confidence.${c}`
const msg = t(key)
return msg === key ? c : msg
}
// 相对时间:复用 common.* 既有 key(justNow/minutesAgo/hoursAgo/dayAgo),30 天以上回退绝对值
function relativeTime(millisStr: string): string {
const ms = Number(millisStr)
if (!ms) return ''
const diff = Date.now() - ms
const min = Math.floor(diff / 60000)
if (min < 1) return t('common.justNow')
if (min < 60) return t('common.minutesAgo', { n: min })
const hr = Math.floor(min / 60)
if (hr < 24) return t('common.hoursAgo', { n: hr })
const day = Math.floor(hr / 24)
if (day < 30) return t('common.dayAgo', { n: day })
return t('common.ago', { time: new Date(ms).toLocaleDateString() })
}
// 切换知识时重置引用列表「加载更多」上限(对齐原 Knowledge.vue selectKnowledge 内 refLimit=10 行为)
watch(() => props.detail.knowledge.id, () => {
refLimit.value = 10
})
</script>
<style scoped>
/* 详情面板四段(SW-260618-20 从 Knowledge.vue 迁移,样式与原一致) */
.detail-section { padding-bottom: 18px; margin-bottom: 18px; border-bottom: 0.5px solid var(--df-border); }
.detail-section:last-child { border-bottom: none; margin-bottom: 0; }
.detail-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 12px; min-width: 0; }
.detail-title-row { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; overflow: hidden; }
.detail-kind-icon { font-size: 20px; flex-shrink: 0; }
.detail-title { font-size: 18px; font-weight: 500; color: var(--df-text); word-break: break-word; overflow-wrap: break-word; min-width: 0; }
.detail-actions { display: flex; gap: 6px; flex-shrink: 0; flex-wrap: wrap; }
.detail-badges { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; font-size: 11px; }
.badge-plain { color: var(--df-text-dim); }
.detail-field { margin-bottom: 12px; }
.detail-field label { display: block; font-size: 12px; color: var(--df-text-secondary); margin-bottom: 4px; }
.detail-content { font-size: 13px; color: var(--df-text); line-height: 1.6; white-space: pre-wrap; }
/* ===== 知识内容 Markdown 渲染(B-260615-25,基础样式收敛至全局 ai-md.css)===== */
.detail-content.ai-md { white-space: normal; }
.detail-tags { display: flex; gap: 6px; flex-wrap: wrap; }
.tag { font-size: 11px; padding: 2px 8px; border-radius: var(--df-radius-xs); background: rgba(108,99,255,0.1); color: var(--df-accent); }
.muted { color: var(--df-text-dim); font-size: 12px; }
/* 状态徽章 / 置信度 */
.kn-card-status { font-size: 11px; font-weight: 500; padding: 2px 8px; border-radius: var(--df-radius-xs); white-space: nowrap; }
.st-candidate { background: rgba(255,217,61,0.15); color: var(--df-warning); }
.st-pending_review { background: rgba(108,159,255,0.15); color: var(--df-info); }
.st-published { background: rgba(100,255,218,0.15); color: var(--df-success); }
.st-archived { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
.conf-high { background: rgba(100,255,218,0.15); color: var(--df-success); padding: 1px 6px; border-radius: var(--df-radius-xs); }
.conf-medium { background: rgba(255,217,61,0.15); color: var(--df-warning); padding: 1px 6px; border-radius: var(--df-radius-xs); }
.conf-low { background: rgba(255,107,107,0.15); color: var(--df-danger); padding: 1px 6px; border-radius: var(--df-radius-xs); }
/* 溯源 */
.section-title { font-size: 13px; font-weight: 500; color: var(--df-text); margin-bottom: 10px; }
.trace-row { display: flex; gap: 12px; font-size: 12px; margin-bottom: 6px; }
.trace-label { color: var(--df-text-dim); min-width: 72px; }
.reasoning-box {
font-size: 13px; color: var(--df-text-secondary); line-height: 1.6;
background: var(--df-bg-card); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius); padding: 10px 12px; white-space: pre-wrap;
}
/* 引用 */
.ref-list { display: flex; flex-direction: column; gap: 8px; }
.ref-item {
display: flex; gap: 10px; align-items: center; font-size: 12px;
padding: 6px 10px; background: var(--df-bg-card); border-radius: var(--df-radius-sm);
}
.ref-time { color: var(--df-text-dim); white-space: nowrap; }
.ref-conv { color: var(--df-text); }
.ref-query { color: var(--df-text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.load-more { align-self: flex-start; margin-top: 4px; }
/* 生命周期时间线 */
.timeline { display: flex; flex-direction: column; }
.tl-node { display: flex; gap: 12px; position: relative; padding-bottom: 4px; }
.tl-dot {
width: 28px; height: 28px; border-radius: 50%; flex-shrink: 0;
display: flex; align-items: center; justify-content: center; font-size: 13px;
background: var(--df-bg-card); border: 0.5px solid var(--df-border); z-index: 1;
}
.tl-line { position: absolute; left: 14px; top: 28px; bottom: -4px; width: 0.5px; background: var(--df-border); }
.tl-body { padding-top: 4px; }
.tl-label { font-size: 13px; color: var(--df-text); }
.tl-time { font-size: 11px; color: var(--df-text-dim); }
.tl-summary { font-size: 11px; color: var(--df-text-secondary); margin-top: 2px; }
/* ===== 编辑输入 ===== */
.edit-input {
width: 100%; padding: 8px 10px; background: var(--df-bg-card);
border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm);
color: var(--df-text); font-size: 13px; outline: none; box-sizing: border-box;
}
.edit-input:focus { border-color: var(--df-accent); }
.edit-input-title { font-size: 16px; font-weight: 500; }
.edit-textarea { min-height: 100px; resize: vertical; font-family: inherit; }
/* ===== 按钮 ===== */
.btn {
padding: 8px 16px; border: none; border-radius: var(--df-radius-sm);
font-size: 13px; cursor: pointer; transition: all 0.15s;
}
.btn-sm { padding: 4px 10px; font-size: 12px; }
.btn-primary { background: var(--df-accent); color: #fff; }
.btn-primary:hover { background: var(--df-accent-hover); }
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
/* ===== 响应式:窄屏详情标题防竖线化(B-260619)===== */
@media (max-width: 760px) {
.detail-head { flex-direction: column; align-items: flex-start; }
.detail-actions { width: 100%; }
.detail-title { font-size: 15px; }
}
</style>

View File

@@ -82,113 +82,18 @@
</div>
</div>
<!-- ===== 右侧:详情 ===== -->
<!-- ===== 右侧:详情(KnowledgeDetail 子组件,SW-260618-20 拆分)===== -->
<div class="kn-detail-panel">
<template v-if="detail">
<!-- 基本信息 -->
<section class="detail-section">
<div class="detail-head">
<div class="detail-title-row">
<span class="detail-kind-icon">{{ kindIcon(detail.knowledge.kind) }}</span>
<span v-if="!editing" class="detail-title">{{ detail.knowledge.title }}</span>
<input v-else v-model="editForm.title" class="edit-input edit-input-title" />
</div>
<div class="detail-actions" v-if="!editing">
<button class="btn btn-ghost btn-sm" @click="startEdit">{{ $t('knowledge.edit') }}</button>
<template v-if="topTab === 'inbox'">
<button class="btn btn-primary btn-sm" :disabled="submitting" @click="publishCurrent">{{ $t('knowledge.publish') }}</button>
<button class="btn btn-ghost btn-sm" :disabled="submitting" @click="rejectCurrent">{{ $t('knowledge.reject') }}</button>
</template>
<button v-else class="btn btn-ghost btn-sm" :disabled="submitting" @click="archiveCurrent">{{ $t('knowledge.archive') }}</button>
</div>
<div class="detail-actions" v-else>
<button class="btn btn-ghost btn-sm" @click="cancelEdit">{{ $t('common.cancel') }}</button>
<button class="btn btn-primary btn-sm" @click="saveEdit" :disabled="submitting || !editForm.title.trim()">{{ $t('common.save') }}</button>
</div>
</div>
<div class="detail-badges">
<span class="kn-card-status" :class="'st-' + detail.knowledge.status">{{ statusLabel(detail.knowledge.status) }}</span>
<span v-if="detail.knowledge.confidence" :class="'conf-' + detail.knowledge.confidence">{{ $t('knowledge.confidenceBadge', { label: confidenceLabel(detail.knowledge.confidence) }) }}</span>
<span class="badge-plain">{{ $t('knowledge.reuseCount', { n: detail.knowledge.reuse_count }) }}</span>
<span class="badge-plain">{{ kindLabel(detail.knowledge.kind) }}</span>
</div>
<div class="detail-field">
<label>{{ $t('knowledge.contentLabel') }}</label>
<!-- B-260615-25:知识内容 Markdown 渲染(展示态),复用 useMarkdown composable( B-24 TaskDetail);编辑态保持 textarea -->
<div
v-if="!editing && detail.knowledge.content"
class="detail-content ai-md"
v-html="renderedContent"
></div>
<div v-else-if="!editing" class="detail-content"></div>
<textarea v-else v-model="editForm.content" class="edit-input edit-textarea"></textarea>
</div>
<div class="detail-field">
<label>{{ $t('knowledge.tagsLabel') }}</label>
<div v-if="!editing" class="detail-tags">
<span class="tag" v-for="tag in parseTags(detail.knowledge.tags)" :key="tag">{{ tag }}</span>
<span v-if="parseTags(detail.knowledge.tags).length === 0" class="muted">{{ $t('knowledge.tagsNone') }}</span>
</div>
<input v-else v-model="editForm.tagsInput" class="edit-input" :placeholder="$t('knowledge.tagsPlaceholder')" />
</div>
</section>
<!-- 溯源 -->
<section class="detail-section">
<div class="section-title">{{ $t('knowledge.traceTitle') }}</div>
<div class="trace-row">
<span class="trace-label">{{ $t('knowledge.traceMethod') }}</span>
<span>{{ originMethod }}</span>
</div>
<div class="trace-row" v-if="originConvTitle">
<span class="trace-label">{{ $t('knowledge.traceSource') }}</span>
<span>{{ originConvTitle }}</span>
</div>
<div class="trace-row" v-if="originTime">
<span class="trace-label">{{ $t('knowledge.traceTime') }}</span>
<span>{{ originTime }}</span>
</div>
<div class="detail-field" v-if="reasoningText">
<label>{{ $t('knowledge.reasoningLabel') }}</label>
<div class="reasoning-box">{{ reasoningText }}</div>
</div>
</section>
<!-- 引用 -->
<section class="detail-section">
<div class="section-title">{{ $t('knowledge.refTitle', { n: referenceEvents.length }) }}</div>
<div v-if="referenceEvents.length === 0" class="muted">{{ $t('knowledge.refEmpty') }}</div>
<div v-else class="ref-list">
<div v-for="ref in referenceEvents.slice(0, refLimit)" :key="ref.id" class="ref-item">
<span class="ref-time">{{ relativeTime(ref.timestamp) }}</span>
<span class="ref-conv">{{ refConvTitle(ref) }}</span>
<span class="ref-query" v-if="refQuery(ref)">{{ refQuery(ref) }}</span>
</div>
<button v-if="referenceEvents.length > refLimit" class="btn btn-ghost btn-sm load-more" @click="refLimit += 10">
{{ $t('knowledge.loadMore', { n: referenceEvents.length - refLimit }) }}
</button>
</div>
</section>
<!-- 生命周期时间线 -->
<section class="detail-section">
<div class="section-title">{{ $t('knowledge.lifecycleTitle') }}</div>
<div class="timeline">
<div v-for="(node, idx) in timelineNodes" :key="node.id" class="tl-node">
<div class="tl-dot">{{ node.icon }}</div>
<div v-if="idx < timelineNodes.length - 1" class="tl-line"></div>
<div class="tl-body">
<div class="tl-label">{{ node.label }}</div>
<div class="tl-time">{{ relativeTime(node.timestamp) }}</div>
<div class="tl-summary" v-if="node.summary">{{ node.summary }}</div>
</div>
</div>
</div>
</section>
</template>
<KnowledgeDetail
v-if="detail"
:detail="detail"
:top-tab="topTab"
:submitting="submitting"
@save="onDetailSave"
@publish="publishCurrent"
@reject="rejectCurrent"
@archive="archiveCurrent"
/>
<div v-else class="detail-empty">
<div class="detail-empty-icon">👈</div>
@@ -240,10 +145,11 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useKnowledgeStore, KNOWLEDGE_KINDS, parseTags } from '@/stores/knowledge'
import { useKnowledgeStore, KNOWLEDGE_KINDS } from '@/stores/knowledge'
import { useRendered } from '@/composables/useMarkdown'
import { stripMd } from '@/utils/markdown'
import type { KnowledgeDetailPayload, KnowledgeEventRecord } from '@/api/types'
import KnowledgeDetail from '@/components/knowledge/KnowledgeDetail.vue'
import type { KnowledgeDetailPayload } from '@/api/types'
const { t } = useI18n()
const store = useKnowledgeStore()
@@ -320,16 +226,12 @@ function getCategoryCount(key: string): number {
// ===== 详情选中 + 加载 =====
const selectedId = ref<string | null>(null)
const detail = ref<KnowledgeDetailPayload | null>(null)
// B-260615-25:知识内容 Markdown 渲染(复用 AiChat/TaskDetail 同款渲染器,模块级单例),
// useRendered 封装 computed(读 mdReady 触发响应式 + renderMd)+ ensureLoaded(幂等预热)
const { rendered: renderedContent, ensureLoaded } = useRendered(
() => detail.value?.knowledge.content ?? '',
)
const refLimit = ref(10)
// B-260615-25:Markdown 渲染器预热保留在父级(模块单例,与 AiChat/TaskDetail/KnowledgeDetail 共享),
// 详情内容渲染由 KnowledgeDetail 子组件通过 useRendered 消费,此处仅 ensureLoaded 预热不阻塞首屏
const { ensureLoaded } = useRendered(() => '')
async function selectKnowledge(id: string) {
selectedId.value = id
refLimit.value = 10
try {
detail.value = await store.getDetail(id)
} catch (e: any) {
@@ -345,43 +247,18 @@ watch(listItems, items => {
}
})
// ===== 编辑 =====
const editing = ref(false)
const editForm = ref({ title: '', content: '', tagsInput: '', confidence: '' })
// 异步操作禁用态(IPC 期间防双击重复提交):saveEdit/publishCurrent/rejectCurrent/archiveCurrent/submitCreate 共用
// ===== 编辑(详情保存由 KnowledgeDetail 子组件 emit('save') 回传)=====
// 异步操作禁用态(IPC 期间防双击重复提交):onDetailSave/publishCurrent/rejectCurrent/archiveCurrent/submitCreate 共用
const submitting = ref(false)
function startEdit() {
// 子组件已完成表单校验(title 非空 + tags 解析),父级只负责 IPC + 刷新详情
async function onDetailSave(payload: { id: string; data: { title: string; content: string; tags: string; confidence: string } }) {
if (!detail.value) return
const k = detail.value.knowledge
editForm.value = {
title: k.title,
content: k.content,
tagsInput: parseTags(k.tags).join(', '),
confidence: k.confidence ?? '',
}
editing.value = true
}
function cancelEdit() {
editing.value = false
}
async function saveEdit() {
if (!detail.value || !editForm.value.title.trim()) return
submitting.value = true
try {
const tags = editForm.value.tagsInput.split(',').map(t => t.trim()).filter(Boolean)
await store.update(detail.value.knowledge.id, {
title: editForm.value.title.trim(),
content: editForm.value.content,
tags: JSON.stringify(tags),
confidence: editForm.value.confidence, // 空串=清空(后端映射 None),非 undefined
})
editing.value = false
await store.update(payload.id, payload.data)
// 重新拉详情
await selectKnowledge(detail.value.knowledge.id)
await selectKnowledge(payload.id)
} finally {
submitting.value = false
}
@@ -453,85 +330,11 @@ async function submitCreate() {
}
}
// ===== 事件解析辅助 =====
function parseContext(e: KnowledgeEventRecord): Record<string, any> {
if (!e.context_json) return {}
try { return JSON.parse(e.context_json) } catch { return {} }
}
function refConvTitle(e: KnowledgeEventRecord): string {
// 解析一次复用:原两次 parseContext 调用会重复 JSON.parse,引用列表项越多浪费越大
const ctx = parseContext(e)
return ctx.conv_title || ctx.conv_id || ''
}
function refQuery(e: KnowledgeEventRecord): string {
return parseContext(e).query || ''
}
// 溯源:提取事件(created/extracted)
const extractedEvent = computed(() => detail.value?.events.find(e => e.event_type === 'extracted'))
const createdEvent = computed(() => detail.value?.events.find(e => e.event_type === 'created'))
const originMethod = computed(() => {
if (extractedEvent.value) return t('knowledge.originExtracted')
if (createdEvent.value) return t('knowledge.originManual')
return t('knowledge.originUnknown')
})
const originConvTitle = computed(() => {
if (!extractedEvent.value) return ''
const ctx = parseContext(extractedEvent.value)
return ctx.conv_title || ''
})
const originTime = computed(() => {
const e = extractedEvent.value || createdEvent.value
return e ? relativeTime(e.timestamp) : ''
})
const reasoningText = computed(() => {
// 优先取主表 reasoning 字段,降级取 extracted 事件 context
if (detail.value?.knowledge.reasoning) return detail.value.knowledge.reasoning
if (extractedEvent.value) return parseContext(extractedEvent.value).reasoning || ''
return ''
})
// 引用事件(倒序)
const referenceEvents = computed(() =>
(detail.value?.events ?? []).filter(e => e.event_type === 'referenced').reverse()
)
// 生命周期时间线节点(正序 + 未来占位)
const timelineNodes = computed(() => {
const events = detail.value?.events ?? []
const nodes = events.map(e => {
const ctx = parseContext(e)
let icon = '•', label = e.event_type, summary = ''
if (e.event_type === 'extracted') {
icon = '🤖'; label = t('knowledge.timeline.extracted')
summary = ctx.conv_title ? t('knowledge.timeline.summarySource', { title: ctx.conv_title }) : ''
} else if (e.event_type === 'created') {
icon = '✍️'; label = t('knowledge.timeline.created')
} else if (e.event_type === 'status_changed') {
const to = ctx.to
if (to === 'published') { icon = '✅'; label = t('knowledge.timeline.published') }
else if (to === 'archived') { icon = '🗄️'; label = t('knowledge.timeline.archived') }
else if (to === 'pending_review') { icon = '👀'; label = t('knowledge.timeline.enterReview') }
else { icon = '🔄'; label = t('knowledge.timeline.statusChanged', { to }) }
} else if (e.event_type === 'referenced') {
icon = '🔄'; label = t('knowledge.timeline.referenced')
summary = ctx.conv_title ? t('knowledge.timeline.summaryConv', { title: ctx.conv_title }) : ''
}
return { id: e.id, icon, label, summary, timestamp: e.timestamp }
})
return nodes
})
// ===== 标签/时间辅助 =====
// ===== 列表卡片标签辅助(详情相关 label/时间解析已迁移至 KnowledgeDetail 子组件)=====
function kindLabel(key: string): string {
const k = KNOWLEDGE_KINDS.find(c => c.key === key)
return k ? `${k.icon} ${kindText(key)}` : key
}
function kindIcon(key: string): string {
return KNOWLEDGE_KINDS.find(c => c.key === key)?.icon ?? '📄'
}
function statusLabel(status: string): string {
const key = `knowledge.status.${status}`
const msg = t(key)
@@ -542,20 +345,6 @@ function confidenceLabel(c: string): string {
const msg = t(key)
return msg === key ? c : msg
}
// 相对时间:复用 common.* 既有 key(justNow/minutesAgo/hoursAgo/dayAgo),30 天以上回退绝对值
function relativeTime(millisStr: string): string {
const ms = Number(millisStr)
if (!ms) return ''
const diff = Date.now() - ms
const min = Math.floor(diff / 60000)
if (min < 1) return t('common.justNow')
if (min < 60) return t('common.minutesAgo', { n: min })
const hr = Math.floor(min / 60)
if (hr < 24) return t('common.hoursAgo', { n: hr })
const day = Math.floor(hr / 24)
if (day < 30) return t('common.dayAgo', { n: day })
return t('common.ago', { time: new Date(ms).toLocaleDateString() })
}
onMounted(() => {
ensureLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat/TaskDetail 共享),不阻塞
@@ -652,32 +441,11 @@ onMounted(() => {
.meta-reuse { color: var(--df-info); }
.list-empty { text-align: center; padding: 40px 0; color: var(--df-text-dim); font-size: 13px; }
/* ===== 详情面板 ===== */
/* ===== 详情面板空态(详情四段样式已随 KnowledgeDetail 子组件迁移)===== */
.detail-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--df-text-dim); gap: 12px; }
.detail-empty-icon { font-size: 40px; }
.detail-section { padding-bottom: 18px; margin-bottom: 18px; border-bottom: 0.5px solid var(--df-border); }
.detail-section:last-child { border-bottom: none; margin-bottom: 0; }
.detail-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 12px; min-width: 0; }
.detail-title-row { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; overflow: hidden; }
.detail-kind-icon { font-size: 20px; flex-shrink: 0; }
.detail-title { font-size: 18px; font-weight: 500; color: var(--df-text); word-break: break-word; overflow-wrap: break-word; min-width: 0; }
.detail-actions { display: flex; gap: 6px; flex-shrink: 0; flex-wrap: wrap; }
.detail-badges { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; font-size: 11px; }
.badge-plain { color: var(--df-text-dim); }
.detail-field { margin-bottom: 12px; }
.detail-field label { display: block; font-size: 12px; color: var(--df-text-secondary); margin-bottom: 4px; }
.detail-content { font-size: 13px; color: var(--df-text); line-height: 1.6; white-space: pre-wrap; }
/* ===== 知识内容 Markdown 渲染(B-260615-25,基础样式收敛至全局 ai-md.css) ===== */
.detail-content.ai-md { white-space: normal; }
.detail-tags { display: flex; gap: 6px; flex-wrap: wrap; }
.tag { font-size: 11px; padding: 2px 8px; border-radius: var(--df-radius-xs); background: rgba(108,99,255,0.1); color: var(--df-accent); }
.muted { color: var(--df-text-dim); font-size: 12px; }
/* 状态徽章 / 置信度 */
/* 状态徽章 / 置信度(列表卡片复用,详情由子组件 scoped 自带) */
.kn-card-status { font-size: 11px; font-weight: 500; padding: 2px 8px; border-radius: var(--df-radius-xs); white-space: nowrap; }
.st-candidate { background: rgba(255,217,61,0.15); color: var(--df-warning); }
.st-pending_review { background: rgba(108,159,255,0.15); color: var(--df-info); }
@@ -687,51 +455,6 @@ onMounted(() => {
.conf-medium { background: rgba(255,217,61,0.15); color: var(--df-warning); padding: 1px 6px; border-radius: var(--df-radius-xs); }
.conf-low { background: rgba(255,107,107,0.15); color: var(--df-danger); padding: 1px 6px; border-radius: var(--df-radius-xs); }
/* 溯源 */
.section-title { font-size: 13px; font-weight: 500; color: var(--df-text); margin-bottom: 10px; }
.trace-row { display: flex; gap: 12px; font-size: 12px; margin-bottom: 6px; }
.trace-label { color: var(--df-text-dim); min-width: 72px; }
.reasoning-box {
font-size: 13px; color: var(--df-text-secondary); line-height: 1.6;
background: var(--df-bg-card); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius); padding: 10px 12px; white-space: pre-wrap;
}
/* 引用 */
.ref-list { display: flex; flex-direction: column; gap: 8px; }
.ref-item {
display: flex; gap: 10px; align-items: center; font-size: 12px;
padding: 6px 10px; background: var(--df-bg-card); border-radius: var(--df-radius-sm);
}
.ref-time { color: var(--df-text-dim); white-space: nowrap; }
.ref-conv { color: var(--df-text); }
.ref-query { color: var(--df-text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.load-more { align-self: flex-start; margin-top: 4px; }
/* 生命周期时间线 */
.timeline { display: flex; flex-direction: column; }
.tl-node { display: flex; gap: 12px; position: relative; padding-bottom: 4px; }
.tl-dot {
width: 28px; height: 28px; border-radius: 50%; flex-shrink: 0;
display: flex; align-items: center; justify-content: center; font-size: 13px;
background: var(--df-bg-card); border: 0.5px solid var(--df-border); z-index: 1;
}
.tl-line { position: absolute; left: 14px; top: 28px; bottom: -4px; width: 0.5px; background: var(--df-border); }
.tl-body { padding-top: 4px; }
.tl-label { font-size: 13px; color: var(--df-text); }
.tl-time { font-size: 11px; color: var(--df-text-dim); }
.tl-summary { font-size: 11px; color: var(--df-text-secondary); margin-top: 2px; }
/* ===== 编辑输入 ===== */
.edit-input {
width: 100%; padding: 8px 10px; background: var(--df-bg-card);
border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm);
color: var(--df-text); font-size: 13px; outline: none; box-sizing: border-box;
}
.edit-input:focus { border-color: var(--df-accent); }
.edit-input-title { font-size: 16px; font-weight: 500; }
.edit-textarea { min-height: 100px; resize: vertical; font-family: inherit; }
/* ===== 按钮 ===== */
.btn {
padding: 8px 16px; border: none; border-radius: var(--df-radius-sm);
@@ -765,11 +488,8 @@ onMounted(() => {
.form-textarea { min-height: 80px; resize: vertical; font-family: inherit; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
/* ===== 响应式:窄屏详情标题防竖线化(B-260619) ===== */
/* ===== 响应式:窄屏单列布局(详情窄屏样式随 KnowledgeDetail 子组件迁移) ===== */
@media (max-width: 760px) {
.kn-layout { grid-template-columns: 1fr; }
.detail-head { flex-direction: column; align-items: flex-start; }
.detail-actions { width: 100%; }
.detail-title { font-size: 15px; }
}
</style>