Files
DevFlow/src/stores/knowledge.ts
T
lxy 3d8b755229 重构: 前端DRY收口+后端测试类型对齐
- 新增 useToast composable: 消除 AiChat/Settings/Projects 4处 toast 重复
  统一默认3000ms(Projects原4000ms为操作类提示保留参数覆盖)
- 新增 utils/json.ts parseJsonArray: 消除 parseStack/parseTags/ModuleNode
  3处JSON字符串数组解析重复
- 新增 utils/html.ts escapeHtml: 消除 useMarkdown/FilePreview 2处重复
- ProjectDetail score-bar 内联三元改用 scoreTier(消除最后一处阈值硬编码)
- ConversationSidebar 删除 formatTime 透传包装(直接用 formatRelative)
- 清理死代码: parseTs/stringifyError/ErrorSink/_Unused 改私有或删除
  wrapNakedDiff 改私有(无外部 import)
- ModuleNode shortPath 改名 truncatedPath(与 useToolCard.shortPath 语义不同)
2026-07-03 00:18:21 +08:00

237 lines
8.7 KiB
TypeScript

import { reactive, computed } from 'vue'
import { knowledgeApi } from '@/api'
import { t } from '@/i18n/i18n-helpers'
import { runWithCatch, runWithCatchGuarded } from '@/composables/useStoreAction'
import { parseJsonArray } from '@/utils/json'
import type {
KnowledgeRecord,
KnowledgeDetailPayload,
KnowledgeEventRecord,
CreateKnowledgeInput,
UpdateKnowledgeInput,
KnowledgeConfig,
} from '@/api/types'
// ── 全局响应式状态(单例) ──
const state = reactive({
items: [] as KnowledgeRecord[],
candidates: [] as KnowledgeRecord[],
config: null as KnowledgeConfig | null,
loading: false,
error: null as string | null,
})
// ── 异步竞态保护(请求序号,类似 ai.ts messages 的 switch token 模式) ──
// 快速连点(切换筛选/连续检索/重连)时,旧响应晚到会覆盖新响应。序号自增,
// 请求发起时捕获序号,响应回时比对:非最新请求的响应直接丢弃,只取最新。
// 拆两套序号:loadList/search 都写 state.items 共用 _itemsSeq;loadCandidates 写
// state.candidates 用独立 _candidatesSeq。各自防竞态,互不丢弃。
// (P1 修复:原先三者共用 _itemsReqSeq,onMounted 并发 loadList+loadCandidates 会
// 让后发起的 loadCandidates ++ 覆盖 loadList 的 seq,导致 loadList 响应被误判为
// 旧响应丢弃,首次进入列表必空。)
let _itemsSeq = 0
let _candidatesSeq = 0
let _configReqSeq = 0
// 7 种知识类型(snake_case,与后端 KnowledgeKind 对齐)
// 仅存 key + icon;展示文案(label)走 i18n: $t('knowledge.kind.<key>')
export const KNOWLEDGE_KINDS = [
{ key: 'review_rule', icon: '🔍' },
{ key: 'prompt_template', icon: '💬' },
{ key: 'pitfall', icon: '⚠️' },
{ key: 'architecture_pattern', icon: '🏛️' },
{ key: 'diagnosis', icon: '🩺' },
{ key: 'deployment_note', icon: '🚀' },
{ key: 'workflow_optimization', icon: '⚡' },
] as const
/** 解析 tags JSON 字符串为 string[] */
export function parseTags(tags: string | null | undefined): string[] {
return parseJsonArray(tags)
}
// ── 知识 label 文案辅助(列表 Knowledge.vue 与详情 KnowledgeDetail.vue 共用) ──
// 原两文件各内联一份重复实现(SW-260618-20 详情迁移时复制),提取至此单一来源。
// 全部依赖 i18n,故用模块级 t(同文件 parseTags 之上的全局 t)。
/** 知识类型文案(无 icon):${icon} ${text} 组合的文本部分,亦供下拉选项 */
export function kindText(key: string): string {
return t(`knowledge.kind.${key}`)
}
/** 知识类型带 icon 文案(列表/详情卡片);未知 key 回退原值 */
export function kindLabel(key: string): string {
const k = KNOWLEDGE_KINDS.find(c => c.key === key)
return k ? `${k.icon} ${kindText(key)}` : key
}
/** 知识类型 icon;未知回退通用图标 */
export function kindIcon(key: string): string {
return KNOWLEDGE_KINDS.find(c => c.key === key)?.icon ?? '📄'
}
/** 知识状态文案;$t(key) 命中失败(无该 status key)回退原值,避免裸露 key */
export function knowledgeStatusLabel(status: string): string {
const key = `knowledge.status.${status}`
const msg = t(key)
return msg === key ? status : msg
}
/** 知识置信度文案;$t(key) 命中失败回退原值 */
export function knowledgeConfidenceLabel(c: string): string {
const key = `knowledge.confidence.${c}`
const msg = t(key)
return msg === key ? c : msg
}
export function useKnowledgeStore() {
// ── 加载列表(library:默认仅 published,F-260616-02 决策 a) ──
// 后端 knowledge_list 默认收窄为 published,无需前端传 status。
// 显式 status 仍透传(如 status='archived' 查归档)。
async function loadList(status?: string) {
const seq = ++_itemsSeq
state.loading = true
state.error = null
try {
const result = await knowledgeApi.list(status ?? null)
if (seq !== _itemsSeq) return // 旧响应丢弃,只取最新
state.items = result
} catch (e: any) {
if (seq !== _itemsSeq) return // 旧请求的失败不污染当前视图
state.error = e?.toString() ?? t('knowledge.err.loadFailed')
} finally {
if (seq === _itemsSeq) state.loading = false
}
}
// ── 加载待处理收件箱(candidate + pending_review,F-260616-02 决策 a) ──
// 后端 knowledge_list_candidates 已收两种待处理状态,前端调用不变。
async function loadCandidates() {
const seq = ++_candidatesSeq
try {
const result = await knowledgeApi.listCandidates()
if (seq !== _candidatesSeq) return // 旧响应丢弃,只取最新
state.candidates = result
} catch (e: any) {
if (seq !== _candidatesSeq) return
state.error = e?.toString() ?? t('knowledge.err.loadInboxFailed')
}
}
async function search(query: string, kind?: string) {
const seq = ++_itemsSeq
state.loading = true
state.error = null
try {
const result = await knowledgeApi.search({ query, kind })
if (seq !== _itemsSeq) return // 旧响应丢弃,只取最新
state.items = result
} catch (e: any) {
if (seq !== _itemsSeq) return
state.error = e?.toString() ?? t('knowledge.err.searchFailed')
} finally {
if (seq === _itemsSeq) state.loading = false
}
}
async function create(input: CreateKnowledgeInput) {
const record = await runWithCatch(state, t('knowledge.err.createFailed'), async () => {
const r = await knowledgeApi.create(input)
// 手动录入默认 candidate,刷新收件箱。刷新失败不污染 create 成功语义(独立 console.warn)。
try {
state.candidates = await knowledgeApi.listCandidates()
} catch (e: unknown) {
console.warn('create 后刷新收件箱失败(非阻塞)', e)
}
return r
})
return record ?? null
}
async function updateStatus(id: string, status: string) {
await runWithCatch(state, t('knowledge.err.updateStatusFailed'), async () => {
await knowledgeApi.updateStatus(id, status)
// 从 items 和 candidates 中同步移除
state.items = state.items.filter(k => k.id !== id)
state.candidates = state.candidates.filter(k => k.id !== id)
})
}
async function archive(id: string) {
await runWithCatch(state, t('knowledge.err.archiveFailed'), async () => {
await knowledgeApi.archive(id)
state.items = state.items.filter(k => k.id !== id)
state.candidates = state.candidates.filter(k => k.id !== id)
})
}
// ── 配置 ──
async function loadConfig() {
const seq = ++_configReqSeq
await runWithCatchGuarded(state, t('knowledge.err.loadConfigFailed'), () => seq === _configReqSeq, async () => {
const result = await knowledgeApi.getConfig()
if (seq !== _configReqSeq) return // 旧响应丢弃
state.config = result
})
}
async function saveConfig(config: KnowledgeConfig) {
await runWithCatch(state, t('knowledge.err.saveConfigFailed'), async () => {
await knowledgeApi.saveConfig(config)
state.config = config
})
}
async function extractNow() {
await runWithCatch(state, t('knowledge.err.extractFailed'), async () => {
await knowledgeApi.extractNow()
await loadCandidates()
})
}
// ── 生命线:详情 / 编辑 / 事件查询(按需调用,不入全局 state) ──
async function getDetail(id: string): Promise<KnowledgeDetailPayload> {
return knowledgeApi.getDetail(id)
}
async function update(id: string, input: UpdateKnowledgeInput): Promise<KnowledgeRecord> {
const updated = await knowledgeApi.update(id, input)
// 同步刷新内存中的列表/收件箱条目
const patch = (arr: KnowledgeRecord[]) => {
const idx = arr.findIndex(k => k.id === id)
if (idx >= 0) arr[idx] = { ...arr[idx], ...updated }
}
patch(state.items)
patch(state.candidates)
return updated
}
async function getEvents(knowledgeId: string, eventType?: string, limit?: number): Promise<KnowledgeEventRecord[]> {
return knowledgeApi.events(knowledgeId, eventType, limit)
}
// 候选数量(供侧栏 badge 用)
const candidateCount = computed(() => state.candidates.length)
// 清除错误条(供 Knowledge.vue error-banner 关闭按钮调用)
function clearError() {
state.error = null
}
return reactive({
// 状态用 getter 实时读 state(防快照不跟随,同 project store 模式)
get items() { return state.items },
get candidates() { return state.candidates },
get config() { return state.config },
get loading() { return state.loading },
get error() { return state.error },
candidateCount,
// actions
loadList, loadCandidates, search, create, updateStatus, archive,
loadConfig, saveConfig, extractNow,
getDetail, update, getEvents,
clearError,
})
}