新增: Phase2 阶段收尾(Sprint 1-20)
重构:删 5 零引用 crate(df-evolve/plugin/stages/task/traceability)+ 清死模块、ai.rs 拆 11 子 module、ai.ts 拆 6 composable、i18n 拆目录 功能:知识库全栈(df-project/scan + CRUD + 时间线 + 前端)、Settings 拆分、appSettings KV 迁移、模型池、LLM 并发 Semaphore 修复:审批持久化根治、ConditionEngine 默认拒绝、NodeRegistry unimplemented 清除、promote 补偿删除、工具结果截断 50KB、路径校验防 symlink 逃逸 文档:B-03 人工审批设计、决策记录三分档、规格契约自检、经验记录、todo 看板、PROGRESS 更新 详见 PROGRESS.md。src-tauri/儿童每日打卡应用/ 与本项目无关,已排除。
This commit is contained in:
@@ -2,337 +2,685 @@
|
||||
<div class="knowledge">
|
||||
<!-- 页面头部 -->
|
||||
<header class="page-header">
|
||||
<h1>📚 知识库</h1>
|
||||
<h1>{{ $t('knowledge.title') }}</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-primary" @click="addKnowledge">+ 新增知识</button>
|
||||
<button class="btn btn-primary" @click="openCreateModal">{{ $t('knowledge.add') }}</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div class="search-bar">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="搜索知识标题、标签、内容..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 分类标签页 -->
|
||||
<div class="category-tabs">
|
||||
<button
|
||||
v-for="cat in categories"
|
||||
:key="cat.key"
|
||||
class="tab-btn"
|
||||
:class="{ 'tab-active': activeCategory === cat.key }"
|
||||
@click="activeCategory = cat.key"
|
||||
>
|
||||
<span class="tab-icon">{{ cat.icon }}</span>
|
||||
<span class="tab-label">{{ cat.label }}</span>
|
||||
<span class="tab-count">{{ getCategoryCount(cat.key) }}</span>
|
||||
<!-- 顶层 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="knowledge-grid">
|
||||
<div class="knowledge-card" v-for="item in filteredItems" :key="item.id">
|
||||
<div class="card-header">
|
||||
<span class="card-title">{{ item.title }}</span>
|
||||
<span class="card-score" :class="scoreClass(item.score)">{{ item.score }}分</span>
|
||||
<!-- 左右分栏: 列表 + 详情 -->
|
||||
<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 class="card-desc">{{ item.description }}</div>
|
||||
<div class="card-tags">
|
||||
<span class="tag" v-for="tag in item.tags" :key="tag">{{ tag }}</span>
|
||||
|
||||
<!-- 分类(仅知识库) -->
|
||||
<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="card-footer">
|
||||
<span class="card-reuse">🔄 复用 {{ item.reuseCount }} 次</span>
|
||||
<span class="card-category">{{ getCategoryLabel(item.category) }}</span>
|
||||
<span class="card-time">{{ item.updatedAt }}</span>
|
||||
<button class="btn btn-ghost" @click="deleteKnowledge(item.id)">删除</button>
|
||||
|
||||
<!-- 卡片列表 -->
|
||||
<div class="list-cards">
|
||||
<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">{{ 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>
|
||||
|
||||
<!-- ===== 右侧:详情 ===== -->
|
||||
<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" @click="publishCurrent">{{ $t('knowledge.publish') }}</button>
|
||||
<button class="btn btn-ghost btn-sm" @click="rejectCurrent">{{ $t('knowledge.reject') }}</button>
|
||||
</template>
|
||||
<button v-else class="btn btn-ghost btn-sm" @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="!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>
|
||||
<div v-if="!editing" class="detail-content">{{ detail.knowledge.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>
|
||||
|
||||
<div v-else class="detail-empty">
|
||||
<div class="detail-empty-icon">👈</div>
|
||||
<div>{{ $t('knowledge.detailEmptyHint') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="filteredItems.length === 0" class="empty-state">
|
||||
<div class="empty-icon">📭</div>
|
||||
<div class="empty-text">没有找到匹配的知识条目</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="!form.title.trim()">{{ $t('knowledge.create') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useKnowledgeStore, KNOWLEDGE_KINDS, parseTags } from '../stores/knowledge'
|
||||
import type { KnowledgeDetailPayload, KnowledgeEventRecord } from '../api/types'
|
||||
|
||||
interface KnowledgeItem {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
category: string
|
||||
tags: string[]
|
||||
reuseCount: number
|
||||
score: number
|
||||
updatedAt: string
|
||||
}
|
||||
const { t } = useI18n()
|
||||
const store = useKnowledgeStore()
|
||||
|
||||
// 顶层 Tab: library(知识库) | inbox(审核收件箱)
|
||||
const topTab = ref<'library' | 'inbox'>('library')
|
||||
|
||||
const categories = [
|
||||
{ key: 'all', label: '全部', icon: '📦' },
|
||||
{ key: 'review', label: '审查规则', icon: '🔍' },
|
||||
{ key: 'prompt', label: 'Prompt模板', icon: '💬' },
|
||||
{ key: 'pitfall', label: '踩坑经验', icon: '⚠️' },
|
||||
{ key: 'diagnosis', label: '诊断知识', icon: '🩺' },
|
||||
{ key: 'deploy', label: '部署经验', icon: '🚀' },
|
||||
{ key: 'all', icon: '📦' },
|
||||
...KNOWLEDGE_KINDS.map(k => ({ key: k.key, icon: k.icon })),
|
||||
]
|
||||
|
||||
const activeCategory = ref('all')
|
||||
// 分类 chip 的展示文案:"全部" 走 categoryAll,其余走 knowledge.kind.<key>
|
||||
function catLabel(key: string): string {
|
||||
return key === 'all' ? t('knowledge.categoryAll') : kindText(key)
|
||||
}
|
||||
// 纯类型文案(无 icon),用于下拉/分类
|
||||
function kindText(key: string): string {
|
||||
return t(`knowledge.kind.${key}`)
|
||||
}
|
||||
const activeKind = ref('all')
|
||||
const searchQuery = ref('')
|
||||
const knowledgeItems = ref<KnowledgeItem[]>(loadKnowledgeItems())
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// 从 localStorage 加载知识数据
|
||||
function loadKnowledgeItems(): KnowledgeItem[] {
|
||||
const saved = localStorage.getItem('devflow-knowledge')
|
||||
if (saved) {
|
||||
try {
|
||||
return JSON.parse(saved)
|
||||
} catch {
|
||||
// 解析失败,使用默认数据
|
||||
function onSearchInput() {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
if (searchQuery.value.trim()) {
|
||||
store.search(searchQuery.value)
|
||||
} else {
|
||||
store.loadList()
|
||||
}
|
||||
}
|
||||
return getDefaultData()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 获取默认数据
|
||||
function getDefaultData(): KnowledgeItem[] {
|
||||
return [
|
||||
// 审查规则
|
||||
{ id: 1, title: 'SQL 注入防护检查', description: '所有 SQL 拼接必须使用参数化查询,禁止字符串拼接用户输入', category: 'review', tags: ['安全', 'SQL', 'Go'], reuseCount: 23, score: 95, updatedAt: '3 天前' },
|
||||
{ id: 2, title: '错误处理规范', description: '禁止吞掉 error,必须向上传播或记录日志', category: 'review', tags: ['Go', '规范', '错误处理'], reuseCount: 18, score: 88, updatedAt: '1 周前' },
|
||||
{ id: 3, title: 'API 响应格式一致性', description: '统一使用 { code, message, data } 结构, HTTP 状态码语义正确', category: 'review', tags: ['API', '规范'], reuseCount: 15, score: 82, updatedAt: '5 天前' },
|
||||
|
||||
// Prompt 模板
|
||||
{ id: 4, title: 'SQL 查询生成 Prompt', description: '根据自然语言生成 SQL,包含表结构上下文和示例输出格式', category: 'prompt', tags: ['SQL', 'LLM', '模板'], reuseCount: 142, score: 91, updatedAt: '昨天' },
|
||||
{ id: 5, title: '代码审查 Prompt', description: 'AI 代码审查专用 Prompt,涵盖安全、性能、可维护性维度', category: 'prompt', tags: ['代码审查', 'LLM'], reuseCount: 87, score: 85, updatedAt: '3 天前' },
|
||||
{ id: 6, title: 'API 文档生成 Prompt', description: '从 Handler 代码自动生成 API 文档的 Prompt 模板', category: 'prompt', tags: ['文档', 'LLM', '自动生成'], reuseCount: 34, score: 78, updatedAt: '1 周前' },
|
||||
|
||||
// 踩坑经验
|
||||
{ id: 7, title: 'Go context 传递陷阱', description: 'goroutine 中必须传递 context 而非创建新的,否则超时控制失效', category: 'pitfall', tags: ['Go', '并发', 'context'], reuseCount: 12, score: 92, updatedAt: '2 天前' },
|
||||
{ id: 8, title: 'Vue 3 作用域坑', description: 'v-for 内 ref 绑定不会自动响应式,需使用数组形式', category: 'pitfall', tags: ['Vue', '前端', '响应式'], reuseCount: 8, score: 75, updatedAt: '1 周前' },
|
||||
{ id: 9, title: 'Docker 网络模式选择', description: 'host 模式在 Mac/Win 上无效,必须用端口映射', category: 'pitfall', tags: ['Docker', '网络'], reuseCount: 6, score: 70, updatedAt: '2 周前' },
|
||||
|
||||
// 诊断知识
|
||||
{ id: 10, title: 'MySQL 慢查询诊断流程', description: 'EXPLAIN → 索引检查 → 慢查询日志分析 → 优化建议', category: 'diagnosis', tags: ['MySQL', '性能', '诊断'], reuseCount: 31, score: 89, updatedAt: '4 天前' },
|
||||
{ id: 11, title: 'Go 内存泄漏排查', description: 'pprof heap 分析 → goroutine 泄漏检查 → GC 调优', category: 'diagnosis', tags: ['Go', '内存', 'pprof'], reuseCount: 9, score: 83, updatedAt: '1 周前' },
|
||||
{ id: 12, title: '前端白屏诊断', description: 'Console 错误 → 网络请求 → 路由配置 → 构建产物检查', category: 'diagnosis', tags: ['前端', '调试', 'Vue'], reuseCount: 14, score: 80, updatedAt: '5 天前' },
|
||||
|
||||
// 部署经验
|
||||
{ id: 13, title: 'Go 二进制热更新', description: 'kill + mv + nohup 启动,无需 systemd 的轻量部署方案', category: 'deploy', tags: ['Go', '部署', 'Linux'], reuseCount: 19, score: 86, updatedAt: '昨天' },
|
||||
{ id: 14, title: 'SCP 上传最佳实践', description: '使用 ssh-proxy upload 代替 scp,支持配置化管理', category: 'deploy', tags: ['SCP', '部署', '工具'], reuseCount: 11, score: 72, updatedAt: '3 天前' },
|
||||
{ id: 15, title: 'Nginx 反向代理配置', description: 'u-ask 的 Nginx 配置模板,含 WebSocket 支持和 gzip', category: 'deploy', tags: ['Nginx', '配置', '反向代理'], reuseCount: 7, score: 77, updatedAt: '1 周前' },
|
||||
]
|
||||
}
|
||||
|
||||
// 保存到 localStorage
|
||||
function saveToLocalStorage() {
|
||||
localStorage.setItem('devflow-knowledge', JSON.stringify(knowledgeItems.value))
|
||||
}
|
||||
|
||||
// 添加知识
|
||||
function addKnowledge() {
|
||||
// TODO: 实现添加知识的对话框逻辑
|
||||
console.log('添加知识')
|
||||
}
|
||||
|
||||
// 删除知识
|
||||
function deleteKnowledge(id: number) {
|
||||
const index = knowledgeItems.value.findIndex(item => item.id === id)
|
||||
if (index !== -1) {
|
||||
knowledgeItems.value.splice(index, 1)
|
||||
saveToLocalStorage()
|
||||
function switchTopTab(tab: 'library' | 'inbox') {
|
||||
topTab.value = tab
|
||||
selectedId.value = null
|
||||
detail.value = null
|
||||
if (tab === 'inbox') {
|
||||
store.loadCandidates()
|
||||
} else {
|
||||
store.loadList()
|
||||
}
|
||||
}
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
let items = knowledgeItems.value
|
||||
if (activeCategory.value !== 'all') {
|
||||
items = items.filter(i => i.category === activeCategory.value)
|
||||
// 左侧列表(随 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)
|
||||
}
|
||||
if (searchQuery.value.trim()) {
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
items = items.filter(i =>
|
||||
i.title.toLowerCase().includes(q) ||
|
||||
i.tags.some(t => t.toLowerCase().includes(q)) ||
|
||||
i.description.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
return items
|
||||
return src
|
||||
})
|
||||
|
||||
function getCategoryCount(key: string): number {
|
||||
if (key === 'all') return knowledgeItems.value.length
|
||||
return knowledgeItems.value.filter(i => i.category === key).length
|
||||
if (key === 'all') return store.items.length
|
||||
return store.items.filter(i => i.kind === key).length
|
||||
}
|
||||
|
||||
function getCategoryLabel(key: string): string {
|
||||
const cat = categories.find(c => c.key === key)
|
||||
return cat ? cat.label : key
|
||||
// ===== 详情选中 + 加载 =====
|
||||
const selectedId = ref<string | null>(null)
|
||||
const detail = ref<KnowledgeDetailPayload | null>(null)
|
||||
const refLimit = ref(10)
|
||||
|
||||
async function selectKnowledge(id: string) {
|
||||
selectedId.value = id
|
||||
refLimit.value = 10
|
||||
try {
|
||||
detail.value = await store.getDetail(id)
|
||||
} catch (e: any) {
|
||||
console.error('加载详情失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
function scoreClass(score: number): string {
|
||||
if (score >= 90) return 'score-high'
|
||||
if (score >= 75) return 'score-mid'
|
||||
return 'score-low'
|
||||
// 列表数据变化时(如刷新),若选中项已被移除则清空详情
|
||||
watch(listItems, items => {
|
||||
if (selectedId.value && !items.some(i => i.id === selectedId.value)) {
|
||||
selectedId.value = null
|
||||
detail.value = null
|
||||
}
|
||||
})
|
||||
|
||||
// ===== 编辑 =====
|
||||
const editing = ref(false)
|
||||
const editForm = ref({ title: '', content: '', tagsInput: '', confidence: '' })
|
||||
|
||||
function startEdit() {
|
||||
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
|
||||
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 selectKnowledge(detail.value.knowledge.id)
|
||||
}
|
||||
|
||||
// ===== 审核操作(收件箱) =====
|
||||
async function publishCurrent() {
|
||||
if (!detail.value) return
|
||||
await store.updateStatus(detail.value.knowledge.id, 'published')
|
||||
selectedId.value = null
|
||||
detail.value = null
|
||||
}
|
||||
|
||||
async function rejectCurrent() {
|
||||
if (!detail.value) return
|
||||
await store.archive(detail.value.knowledge.id)
|
||||
selectedId.value = null
|
||||
detail.value = null
|
||||
}
|
||||
|
||||
async function archiveCurrent() {
|
||||
if (!detail.value) return
|
||||
await store.archive(detail.value.knowledge.id)
|
||||
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() {
|
||||
const tags = form.value.tagsInput.split(',').map(t => t.trim()).filter(Boolean)
|
||||
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,
|
||||
})
|
||||
showCreateModal.value = false
|
||||
switchTopTab('inbox')
|
||||
}
|
||||
|
||||
// ===== 事件解析辅助 =====
|
||||
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 {
|
||||
return parseContext(e).conv_title || parseContext(e).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
|
||||
})
|
||||
|
||||
// ===== 标签/时间辅助 =====
|
||||
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)
|
||||
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() })
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.loadList()
|
||||
store.loadCandidates()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.knowledge { padding: 16px 20px 20px; }
|
||||
.knowledge { padding: 16px 20px 20px; height: 100%; display: flex; flex-direction: column; }
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: var(--df-gap-page);
|
||||
}
|
||||
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); }
|
||||
.header-actions { display: flex; gap: 8px; }
|
||||
|
||||
/* ===== 顶层 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: 360px 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; }
|
||||
|
||||
/* 左侧搜索 + 分类 */
|
||||
.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; }
|
||||
|
||||
/* ===== 详情面板 ===== */
|
||||
.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; }
|
||||
.detail-title-row { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; }
|
||||
.detail-kind-icon { font-size: 20px; }
|
||||
.detail-title { font-size: 18px; font-weight: 500; color: var(--df-text); word-break: break-word; }
|
||||
.detail-actions { display: flex; gap: 6px; flex-shrink: 0; }
|
||||
|
||||
.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; }
|
||||
.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;
|
||||
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-accent { background: rgba(100,255,218,0.15); color: var(--df-success); border: 0.5px solid rgba(100,255,218,0.3); }
|
||||
.btn-accent:hover { background: rgba(100,255,218,0.25); }
|
||||
.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); }
|
||||
|
||||
/* ===== 搜索栏 ===== */
|
||||
.search-bar { margin-bottom: var(--df-gap-page); }
|
||||
.search-input {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
padding: 10px 16px;
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
color: var(--df-text);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
/* ===== 新增对话框 ===== */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 1000;
|
||||
}
|
||||
.search-input::placeholder { color: var(--df-text-dim); }
|
||||
.search-input:focus { border-color: var(--df-accent); }
|
||||
|
||||
/* ===== 分类标签 ===== */
|
||||
.category-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: var(--df-gap-page);
|
||||
flex-wrap: wrap;
|
||||
.modal-box {
|
||||
background: var(--df-bg-panel, var(--df-bg-card)); border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg); padding: 24px; width: 90%; max-width: 520px;
|
||||
}
|
||||
.tab-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
background: transparent;
|
||||
color: var(--df-text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
.modal-title { font-size: 16px; font-weight: 500; color: var(--df-text); margin-bottom: 16px; }
|
||||
.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;
|
||||
}
|
||||
.tab-btn:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||||
.tab-active {
|
||||
background: var(--df-accent) !important;
|
||||
color: #fff !important;
|
||||
border-color: var(--df-accent) !important;
|
||||
}
|
||||
.tab-icon { font-size: 14px; }
|
||||
.tab-count {
|
||||
font-size: 11px;
|
||||
background: rgba(255,255,255,0.15);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--df-radius);
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
.tab-active .tab-count { background: rgba(255,255,255,0.25); }
|
||||
|
||||
/* ===== 知识卡片网格 ===== */
|
||||
.knowledge-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: var(--df-gap-grid);
|
||||
}
|
||||
.knowledge-card {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
padding: var(--df-pad-panel);
|
||||
transition: border-color 0.15s, transform 0.15s;
|
||||
}
|
||||
.knowledge-card:hover {
|
||||
border-color: var(--df-accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card-title { font-size: 14px; font-weight: 500; color: var(--df-text); }
|
||||
.card-score {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
.score-high { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||
.score-mid { background: rgba(255,217,61,0.15); color: var(--df-warning); }
|
||||
.score-low { background: rgba(255,107,107,0.15); color: var(--df-danger); }
|
||||
|
||||
.card-desc {
|
||||
font-size: 13px;
|
||||
color: var(--df-text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.card-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
|
||||
.tag {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: rgba(108,99,255,0.1);
|
||||
color: var(--df-accent);
|
||||
}
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
.card-reuse { color: var(--df-info); }
|
||||
.card-category {
|
||||
background: rgba(90,99,128,0.2);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
}
|
||||
|
||||
/* ===== 空状态 ===== */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 0;
|
||||
}
|
||||
.empty-icon { font-size: 48px; margin-bottom: 12px; }
|
||||
.empty-text { font-size: 14px; color: var(--df-text-dim); }
|
||||
</style>
|
||||
.form-input:focus, .form-select:focus { border-color: var(--df-accent); }
|
||||
.form-textarea { min-height: 80px; resize: vertical; font-family: inherit; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user