539 lines
22 KiB
Vue
539 lines
22 KiB
Vue
<template>
|
|
<div class="knowledge">
|
|
<!-- 页面头部 -->
|
|
<header class="page-header">
|
|
<h1>{{ $t('knowledge.title') }}</h1>
|
|
<div class="header-actions">
|
|
<button class="btn btn-primary" @click="openCreateModal">{{ $t('knowledge.add') }}</button>
|
|
</div>
|
|
</header>
|
|
|
|
<!-- ⑤ 操作反馈 toast(保存/发布/拒绝/归档/创建成功 + 保存失败)-->
|
|
<Transition name="toast">
|
|
<div v-if="toast.visible" class="toast" :class="'toast-' + toast.type">{{ toast.msg }}</div>
|
|
</Transition>
|
|
|
|
<!-- 错误条:消费 store.error(样式全局 .error-banner,仅间距用页面变量) -->
|
|
<div v-if="store.error" class="error-banner" style="margin-bottom: var(--df-gap-page)">
|
|
<span class="error-text">{{ store.error }}</span>
|
|
<button class="error-dismiss" @click="clearError">✕</button>
|
|
</div>
|
|
|
|
<!-- 顶层 Tab: 知识库 / 审核收件箱 -->
|
|
<div class="top-tabs">
|
|
<button class="top-tab" :class="{ active: topTab === 'library' }" @click="switchTopTab('library')">
|
|
{{ $t('knowledge.tab.library') }}
|
|
</button>
|
|
<button class="top-tab" :class="{ active: topTab === 'inbox' }" @click="switchTopTab('inbox')">
|
|
{{ $t('knowledge.tab.inbox') }}
|
|
<span v-if="store.candidates.length" class="inbox-badge">{{ store.candidates.length }}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 左右分栏: 列表 + 详情 -->
|
|
<div class="kn-layout">
|
|
<!-- ===== 左侧:列表 ===== -->
|
|
<div class="kn-list-panel">
|
|
<!-- 搜索栏(仅知识库) -->
|
|
<div v-if="topTab === 'library'" class="search-bar">
|
|
<input
|
|
v-model="searchQuery"
|
|
type="text"
|
|
class="search-input"
|
|
:placeholder="$t('knowledge.searchPlaceholder')"
|
|
@input="onSearchInput"
|
|
/>
|
|
</div>
|
|
|
|
<!-- 分类(仅知识库) -->
|
|
<div v-if="topTab === 'library'" class="category-tabs">
|
|
<button
|
|
v-for="cat in categories"
|
|
:key="cat.key"
|
|
class="cat-chip"
|
|
:class="{ 'cat-active': activeKind === cat.key }"
|
|
@click="activeKind = cat.key"
|
|
>
|
|
<span>{{ cat.icon }}</span>
|
|
<span>{{ catLabel(cat.key) }}</span>
|
|
<span class="cat-count">{{ getCategoryCount(cat.key) }}</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- 卡片列表 -->
|
|
<div class="list-cards">
|
|
<div v-if="store.loading" class="list-empty">{{ $t('common.loading') }}</div>
|
|
<div
|
|
v-for="item in listItems"
|
|
:key="item.id"
|
|
class="kn-card"
|
|
:class="{ selected: selectedId === item.id }"
|
|
@click="selectKnowledge(item.id)"
|
|
>
|
|
<div class="kn-card-head">
|
|
<span class="kn-card-title">{{ item.title }}</span>
|
|
<span class="kn-card-status" :class="'st-' + item.status">{{ statusLabel(item.status) }}</span>
|
|
</div>
|
|
<div class="kn-card-desc">{{ stripMd(item.content) }}</div>
|
|
<div class="kn-card-meta">
|
|
<span class="meta-kind">{{ kindLabel(item.kind) }}</span>
|
|
<span v-if="item.confidence" :class="'conf-' + item.confidence">{{ confidenceLabel(item.confidence) }}</span>
|
|
<span class="meta-reuse">🔄 {{ item.reuse_count }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="listItems.length === 0" class="list-empty">
|
|
<div>{{ topTab === 'inbox' ? $t('knowledge.emptyInbox') : $t('knowledge.emptyLibrary') }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ===== 右侧:详情(KnowledgeDetail 子组件,拆分)===== -->
|
|
<div class="kn-detail-panel">
|
|
<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>
|
|
<div>{{ $t('knowledge.detailEmptyHint') }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ===== 新增知识对话框 ===== -->
|
|
<div v-if="showCreateModal" class="modal-overlay" @click.self="closeCreateModal">
|
|
<div class="modal-box">
|
|
<div class="modal-title">{{ $t('knowledge.createTitle') }}</div>
|
|
<div class="form-row">
|
|
<label>{{ $t('knowledge.kindLabel') }}</label>
|
|
<select v-model="form.kind" class="form-select">
|
|
<option v-for="k in KNOWLEDGE_KINDS" :key="k.key" :value="k.key">{{ k.icon }} {{ kindText(k.key) }}</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-row">
|
|
<label>{{ $t('knowledge.titleLabel') }}</label>
|
|
<input v-model="form.title" type="text" class="form-input" :placeholder="$t('knowledge.titlePlaceholder')" />
|
|
</div>
|
|
<div class="form-row">
|
|
<label>{{ $t('knowledge.contentLabel') }}</label>
|
|
<textarea v-model="form.content" class="form-input form-textarea" :placeholder="$t('knowledge.contentPlaceholder')"></textarea>
|
|
</div>
|
|
<div class="form-row">
|
|
<label>{{ $t('knowledge.tagsFieldLabel') }}</label>
|
|
<input v-model="form.tagsInput" type="text" class="form-input" :placeholder="$t('knowledge.tagsFieldPlaceholder')" />
|
|
</div>
|
|
<div class="form-row">
|
|
<label>{{ $t('knowledge.confidenceLabel') }}</label>
|
|
<select v-model="form.confidence" class="form-select">
|
|
<option value="">{{ $t('knowledge.confidenceNone') }}</option>
|
|
<option value="high">{{ $t('knowledge.confidence.high') }}</option>
|
|
<option value="medium">{{ $t('knowledge.confidence.medium') }}</option>
|
|
<option value="low">{{ $t('knowledge.confidence.low') }}</option>
|
|
</select>
|
|
</div>
|
|
<div class="modal-actions">
|
|
<button class="btn btn-ghost" @click="closeCreateModal">{{ $t('common.cancel') }}</button>
|
|
<button class="btn btn-primary" @click="submitCreate" :disabled="submitting || !form.title.trim()">{{ $t('knowledge.create') }}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import {
|
|
useKnowledgeStore,
|
|
KNOWLEDGE_KINDS,
|
|
kindText,
|
|
kindLabel,
|
|
knowledgeStatusLabel as statusLabel,
|
|
knowledgeConfidenceLabel as confidenceLabel,
|
|
} from '@/stores/knowledge'
|
|
import { useRendered } from '@/composables/useMarkdown'
|
|
import { useToast } from '@/composables/useToast'
|
|
import { usePersistedRef } from '@/composables/usePersistedRef'
|
|
import { stripMd } from '@/utils/markdown'
|
|
import KnowledgeDetail from '@/components/knowledge/KnowledgeDetail.vue'
|
|
import type { KnowledgeDetailPayload, KnowledgeRecord } from '@/api/types'
|
|
|
|
const { t } = useI18n()
|
|
const store = useKnowledgeStore()
|
|
// ⑤ 编辑/审核反馈:成功/失败明确提示(对齐 Settings.vue/Projects.vue useToast 模式)
|
|
const { toast, showToast } = useToast()
|
|
|
|
// topTab/activeKind/searchQuery 持久化 localStorage(跨刷新保留用户视图态)
|
|
// 仅持久化视图态:不持久化详情选中(id 跨刷新可能已失效)。usePersistedRef 无类型校验,
|
|
// 解析失败/无效值回退 default,与 Tasks/Ideas 一致。
|
|
const topTab = usePersistedRef<'library' | 'inbox'>('knowledge.topTab', 'library')
|
|
|
|
const categories = [
|
|
{ key: 'all', icon: '📦' },
|
|
...KNOWLEDGE_KINDS.map(k => ({ key: k.key, icon: k.icon })),
|
|
]
|
|
// 分类 chip 的展示文案:"全部" 走 categoryAll,其余走 knowledge.kind.<key>
|
|
function catLabel(key: string): string {
|
|
return key === 'all' ? t('knowledge.categoryAll') : kindText(key)
|
|
}
|
|
const activeKind = usePersistedRef('knowledge.activeKind', 'all')
|
|
const searchQuery = usePersistedRef('knowledge.searchQuery', '')
|
|
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
|
|
|
function onSearchInput() {
|
|
if (searchTimer) clearTimeout(searchTimer)
|
|
searchTimer = setTimeout(() => {
|
|
if (searchQuery.value.trim()) {
|
|
store.search(searchQuery.value)
|
|
} else {
|
|
store.loadList()
|
|
}
|
|
}, 300)
|
|
}
|
|
|
|
// 组件卸载清搜索 debounce timer:防卸载后 300ms 内 timer 触发,向单例 store 写入
|
|
// 已离开视图的搜索结果/列表(污染下次进入时的列表态)。对齐 Settings.vue toast timer 清理模式。
|
|
onUnmounted(() => {
|
|
if (searchTimer) {
|
|
clearTimeout(searchTimer)
|
|
searchTimer = null
|
|
}
|
|
})
|
|
|
|
function switchTopTab(tab: 'library' | 'inbox') {
|
|
topTab.value = tab
|
|
selectedId.value = null
|
|
detail.value = null
|
|
if (tab === 'inbox') {
|
|
store.loadCandidates()
|
|
} else {
|
|
store.loadList()
|
|
}
|
|
}
|
|
|
|
// 关闭错误条
|
|
function clearError() {
|
|
store.clearError()
|
|
}
|
|
|
|
// 左侧列表(随 Tab 切换数据源 + 知识库的分类过滤)
|
|
const listItems = computed(() => {
|
|
const src = topTab.value === 'inbox' ? store.candidates : store.items
|
|
if (topTab.value === 'library' && activeKind.value !== 'all') {
|
|
return src.filter(i => i.kind === activeKind.value)
|
|
}
|
|
return src
|
|
})
|
|
|
|
function getCategoryCount(key: string): number {
|
|
if (key === 'all') return store.items.length
|
|
return store.items.filter(i => i.kind === key).length
|
|
}
|
|
|
|
// ===== 详情选中 + 加载 =====
|
|
const selectedId = ref<string | null>(null)
|
|
const detail = ref<KnowledgeDetailPayload | null>(null)
|
|
// Markdown 渲染器预热保留在父级(模块单例,与 AiChat/TaskDetail/KnowledgeDetail 共享),
|
|
// 详情内容渲染由 KnowledgeDetail 子组件通过 useRendered 消费,此处仅 ensureLoaded 预热不阻塞首屏
|
|
const { ensureLoaded } = useRendered(() => '')
|
|
|
|
async function selectKnowledge(id: string) {
|
|
selectedId.value = id
|
|
try {
|
|
detail.value = await store.getDetail(id)
|
|
} catch (e: any) {
|
|
console.error('加载详情失败', e)
|
|
}
|
|
}
|
|
|
|
// 列表数据变化时(如刷新),若选中项已被移除则清空详情
|
|
watch(listItems, items => {
|
|
if (selectedId.value && !items.some(i => i.id === selectedId.value)) {
|
|
selectedId.value = null
|
|
detail.value = null
|
|
}
|
|
})
|
|
|
|
// ===== 编辑(详情保存由 KnowledgeDetail 子组件 emit('save') 回传)=====
|
|
// 异步操作禁用态(IPC 期间防双击重复提交):onDetailSave/publishCurrent/rejectCurrent/archiveCurrent/submitCreate 共用
|
|
const submitting = ref(false)
|
|
|
|
// 子组件已完成表单校验(title 非空 + tags 解析),父级只负责 IPC + 刷新详情
|
|
async function onDetailSave(payload: { id: string; data: { title: string; content: string; tags: string; confidence: string } }) {
|
|
if (!detail.value) return
|
|
submitting.value = true
|
|
try {
|
|
await store.update(payload.id, payload.data)
|
|
// 重新拉详情
|
|
await selectKnowledge(payload.id)
|
|
// ⑤ 编辑保存成功反馈(此前静默无反馈,用户不知是否生效)
|
|
showToast(t('knowledge.savedToast'), 'success')
|
|
} catch (e: any) {
|
|
// 保存失败:不重新抛出(emit 调用方未 await,抛出无人处理),仅 toast 反馈
|
|
showToast(e?.toString() ?? t('knowledge.err.saveFailed'), 'error')
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
// ===== 审核操作(收件箱) =====
|
|
// P0-②:发布/拒绝/归档后自动选中下一条(而非清空详情),减少审核者来回点击
|
|
// index 须在 store 操作前快照(操作后 candidates 已 filter 移除 currentId,findIndex 恒 -1)
|
|
// 操作失败(updateStatus/archive 返 false)toast 报错 + 中止推进(不 selectNext,不清详情)
|
|
async function publishCurrent() {
|
|
if (!detail.value) return
|
|
const currentId = detail.value.knowledge.id
|
|
const idx = store.candidates.findIndex(c => c.id === currentId)
|
|
submitting.value = true
|
|
try {
|
|
const ok = await store.updateStatus(currentId, 'published')
|
|
if (!ok) {
|
|
showToast(t('knowledge.err.updateStatusFailed'), 'error')
|
|
return
|
|
}
|
|
await selectNextCandidate(idx)
|
|
showToast(t('knowledge.publishedToast'), 'success')
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
async function rejectCurrent() {
|
|
if (!detail.value) return
|
|
const currentId = detail.value.knowledge.id
|
|
const idx = store.candidates.findIndex(c => c.id === currentId)
|
|
submitting.value = true
|
|
try {
|
|
const ok = await store.archive(currentId)
|
|
if (!ok) {
|
|
showToast(t('knowledge.err.archiveFailed'), 'error')
|
|
return
|
|
}
|
|
await selectNextCandidate(idx)
|
|
showToast(t('knowledge.rejectedToast'), 'info')
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
async function archiveCurrent() {
|
|
if (!detail.value) return
|
|
const currentId = detail.value.knowledge.id
|
|
const idx = store.candidates.findIndex(c => c.id === currentId)
|
|
submitting.value = true
|
|
try {
|
|
const ok = await store.archive(currentId)
|
|
if (!ok) {
|
|
showToast(t('knowledge.err.archiveFailed'), 'error')
|
|
return
|
|
}
|
|
// 知识库 tab 无「下一条」语义(library 不限 candidate),归档后清空详情
|
|
if (topTab.value === 'inbox') {
|
|
await selectNextCandidate(idx)
|
|
} else {
|
|
selectedId.value = null
|
|
detail.value = null
|
|
}
|
|
showToast(t('knowledge.archivedToast'), 'info')
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
// 当前条目从 candidates 移除后,选中同位置后续条目;末尾则清空详情
|
|
// 实现策略:操作前记下 currentId 在原列表中的 index,移除后从剩余列表的同
|
|
// index 取(若无则取前一条),保证「下一条」语义而非跳回开头
|
|
function pickNextCandidate(remaining: KnowledgeRecord[], currentIndex: number): KnowledgeRecord | null {
|
|
return remaining[currentIndex] ?? remaining[currentIndex - 1] ?? null
|
|
}
|
|
|
|
async function selectNextCandidate(currentIndex: number) {
|
|
const remaining = store.candidates // 操作后已 filter 移除 currentId
|
|
const next = currentIndex >= 0 ? pickNextCandidate(remaining, currentIndex) : null
|
|
if (next) {
|
|
await selectKnowledge(next.id)
|
|
} else {
|
|
selectedId.value = null
|
|
detail.value = null
|
|
}
|
|
}
|
|
|
|
// ===== 新增对话框 =====
|
|
const showCreateModal = ref(false)
|
|
const form = ref({ kind: 'pitfall', title: '', content: '', tagsInput: '', confidence: '' })
|
|
|
|
function openCreateModal() {
|
|
form.value = { kind: 'pitfall', title: '', content: '', tagsInput: '', confidence: '' }
|
|
showCreateModal.value = true
|
|
}
|
|
function closeCreateModal() {
|
|
showCreateModal.value = false
|
|
}
|
|
async function submitCreate() {
|
|
submitting.value = true
|
|
try {
|
|
const tags = form.value.tagsInput.split(',').map(t => t.trim()).filter(Boolean)
|
|
const r = await store.create({
|
|
kind: form.value.kind,
|
|
title: form.value.title.trim(),
|
|
content: form.value.content.trim(),
|
|
tags: JSON.stringify(tags),
|
|
confidence: form.value.confidence || undefined,
|
|
})
|
|
if (!r) return // 失败保持弹窗不关(错误走 error-banner)
|
|
showCreateModal.value = false
|
|
switchTopTab('inbox')
|
|
showToast(t('knowledge.createdToast'), 'success')
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
// ===== 列表卡片标签辅助(详情相关 label/时间解析已迁移至 KnowledgeDetail 子组件)=====
|
|
// kindText/kindLabel/statusLabel/confidenceLabel 由 @/stores/knowledge 提供(列表/详情共用,单一来源)
|
|
|
|
onMounted(() => {
|
|
ensureLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat/TaskDetail 共享),不阻塞
|
|
// ④ 按持久化视图态加载初始数据源(topTab 决定加载 items 还是 candidates)
|
|
if (topTab.value === 'inbox') {
|
|
store.loadCandidates()
|
|
} else if (searchQuery.value.trim()) {
|
|
// 持久化的搜索词:恢复搜索结果而非空列表
|
|
store.search(searchQuery.value)
|
|
} else {
|
|
store.loadList()
|
|
}
|
|
// candidates badge 始终预加载(顶部 Tab 计数用)
|
|
store.loadCandidates()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.knowledge { padding: 16px 20px 20px; height: 100%; display: flex; flex-direction: column; container-type: inline-size; }
|
|
|
|
/* ===== 顶部 Toast 提示(⑤ 操作反馈,对齐 Settings.vue)===== */
|
|
.toast {
|
|
position: fixed; top: 16px; left: 50%; transform: translateX(-50%);
|
|
z-index: 1000; padding: 8px 16px; border-radius: var(--df-radius-sm);
|
|
font-size: 13px; box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
|
}
|
|
.toast-error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
|
|
.toast-warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
|
|
.toast-info { background: var(--df-accent-bg); color: var(--df-accent); border: 0.5px solid var(--df-accent); }
|
|
.toast-success { background: var(--df-success-bg); color: var(--df-success); border: 0.5px solid var(--df-success); }
|
|
.toast-enter-active, .toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
|
|
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }
|
|
|
|
/* page-header / btn / modal 等已提取到 global.css 全局 */
|
|
|
|
/* ===== 错误条样式已提取到 styles/components.css 全局(.error-banner) ===== */
|
|
|
|
/* ===== 顶层 Tab ===== */
|
|
.top-tabs { display: flex; gap: 4px; margin-bottom: var(--df-gap-page); border-bottom: 0.5px solid var(--df-border); }
|
|
.top-tab {
|
|
padding: 8px 16px; border: none; background: transparent; color: var(--df-text-secondary);
|
|
font-size: 13px; cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.15s;
|
|
display: flex; align-items: center; gap: 6px;
|
|
}
|
|
.top-tab:hover { color: var(--df-text); }
|
|
.top-tab.active { color: var(--df-accent); border-bottom-color: var(--df-accent); }
|
|
.inbox-badge {
|
|
font-size: 11px; background: var(--df-danger); color: #fff;
|
|
padding: 1px 6px; border-radius: var(--df-radius); min-width: 16px; text-align: center;
|
|
}
|
|
|
|
/* ===== 左右分栏 ===== */
|
|
.kn-layout {
|
|
display: grid; grid-template-columns: minmax(240px, 360px) minmax(280px, 1fr); gap: var(--df-gap-page);
|
|
flex: 1; min-height: 0;
|
|
}
|
|
.kn-list-panel, .kn-detail-panel {
|
|
background: var(--df-bg-panel, var(--df-bg-card));
|
|
border: 0.5px solid var(--df-border);
|
|
border-radius: var(--df-radius-lg);
|
|
overflow: hidden; display: flex; flex-direction: column;
|
|
}
|
|
.kn-detail-panel { overflow-y: auto; padding: 20px; min-width: 0; }
|
|
|
|
/* 左侧搜索 + 分类 */
|
|
.search-bar { padding: 12px 12px 0; }
|
|
.search-input {
|
|
width: 100%; padding: 8px 12px; background: var(--df-bg-card);
|
|
border: 0.5px solid var(--df-border); border-radius: var(--df-radius);
|
|
color: var(--df-text); font-size: 13px; outline: none; box-sizing: border-box;
|
|
}
|
|
.search-input:focus { border-color: var(--df-accent); }
|
|
.category-tabs { display: flex; flex-wrap: wrap; gap: 4px; padding: 10px 12px; }
|
|
.cat-chip {
|
|
display: flex; align-items: center; gap: 4px; padding: 4px 8px;
|
|
border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm);
|
|
background: transparent; color: var(--df-text-secondary); font-size: 12px; cursor: pointer;
|
|
}
|
|
.cat-chip:hover { background: var(--df-bg-card); }
|
|
.cat-active { background: var(--df-accent); color: #fff; border-color: var(--df-accent); }
|
|
.cat-count { font-size: 10px; opacity: 0.8; }
|
|
|
|
/* 左侧卡片列表 */
|
|
.list-cards { flex: 1; overflow-y: auto; padding: 8px 12px 12px; display: flex; flex-direction: column; gap: 8px; }
|
|
.kn-card {
|
|
background: var(--df-bg-card); border: 0.5px solid var(--df-border);
|
|
border-radius: var(--df-radius); padding: 10px 12px; cursor: pointer; transition: all 0.15s;
|
|
}
|
|
.kn-card:hover { border-color: var(--df-accent); }
|
|
.kn-card.selected { border-color: var(--df-accent); box-shadow: inset 3px 0 0 var(--df-accent); }
|
|
.kn-card-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 6px; }
|
|
.kn-card-title { font-size: 13px; font-weight: 500; color: var(--df-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
.kn-card-desc {
|
|
font-size: 12px; color: var(--df-text-secondary); line-height: 1.4; margin-bottom: 6px;
|
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
|
}
|
|
.kn-card-meta { display: flex; gap: 8px; align-items: center; font-size: 11px; color: var(--df-text-dim); flex-wrap: wrap; }
|
|
.meta-kind { background: rgba(90,99,128,0.2); padding: 1px 6px; border-radius: var(--df-radius-sm); }
|
|
.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; }
|
|
|
|
/* 状态徽章 / 置信度(列表卡片复用,详情由子组件 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); }
|
|
.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); }
|
|
|
|
/* form-row / form-input / form-select / form-textarea 已随新建/编辑弹层随 KnowledgeDetail 子组件迁移,此处仅保留本壳所需特有覆盖。 */
|
|
.form-row { margin-bottom: 12px; }
|
|
.form-row label { display: block; font-size: 12px; color: var(--df-text-secondary); margin-bottom: 4px; }
|
|
.form-input, .form-select {
|
|
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;
|
|
}
|
|
.form-input:focus, .form-select:focus { border-color: var(--df-accent); }
|
|
.form-textarea { min-height: 80px; resize: vertical; font-family: inherit; }
|
|
|
|
/* ===== 响应式:窄屏单列布局(详情窄屏样式随 KnowledgeDetail 子组件迁移) ===== */
|
|
/* 双闸:@media 管窗口本身窄;@container 管「视口不窄但主区被 AI 面板挤压」场景
|
|
(AI 面板平分内容时 .knowledge 实际宽度 < 620px,media query 感知不到)。 */
|
|
@media (max-width: 760px) {
|
|
.kn-layout { grid-template-columns: 1fr; }
|
|
}
|
|
@container (max-width: 620px) {
|
|
.kn-layout { grid-template-columns: 1fr; }
|
|
.kn-detail-panel { overflow-y: visible; }
|
|
}
|
|
</style>
|