From 9e81182b4f6511084c50d24a9cdf9f4b1e5f52cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Mon, 5 Oct 2026 23:41:27 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20=E7=9F=A5=E8=AF=86?= =?UTF-8?q?=E6=A3=80=E7=B4=A2=E5=90=8C=E9=A1=B9=E7=9B=AE=E5=8A=A0=E6=9D=83?= =?UTF-8?q?=E4=B8=8E=E6=8B=92=E7=BB=9D=E6=B1=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/df-storage/src/crud/idea_repo.rs | 160 ++++++++++++++++++- src-tauri/src/commands/knowledge.rs | 45 +++++- src/components/ai/EnrichmentPanel.vue | 2 +- src/components/knowledge/KnowledgeDetail.vue | 12 +- src/composables/ai/useAiContext.ts | 13 +- src/i18n/en/knowledge.ts | 16 +- src/i18n/zh-CN/knowledge.ts | 16 +- src/stores/knowledge.ts | 74 ++++++++- src/views/Knowledge.vue | 71 +++++++- 9 files changed, 387 insertions(+), 22 deletions(-) diff --git a/crates/df-storage/src/crud/idea_repo.rs b/crates/df-storage/src/crud/idea_repo.rs index ab8e971..90d9926 100644 --- a/crates/df-storage/src/crud/idea_repo.rs +++ b/crates/df-storage/src/crud/idea_repo.rs @@ -547,16 +547,24 @@ impl IdeaRepo { .transaction() .map_err(storage_err)?; - // 1. 读主体当前 related_ids + // 1. 读主体当前 related_ids — 同段校验主体存在且未软删(deleted_at IS NULL)。 + // 治走查🟡:原仅读 related_ids,主体不存在/已软删时步骤 5 UPDATE 静默 affected=0 + // 仍提交,而步骤 3/4 已改写目标侧 related_ids → 「目标有关联、主体无记录」单向脏关联。 + // 查无立即 Err 回滚整个事务,目标侧零改写。 let old_raw: Option = tx .query_row( - "SELECT related_ids FROM ideas WHERE id = ?1", + "SELECT related_ids FROM ideas WHERE id = ?1 AND deleted_at IS NULL", params![subject_id], |row| row.get(0), ) .optional() .map_err(storage_err)? - .flatten(); + .flatten() + .ok_or_else(|| { + df_types::error::Error::Storage(format!( + "灵感 {subject_id} 不存在或已删除,无法建立关联" + )) + })?; // 2. 解析新旧集合 let old_set: std::collections::HashSet = @@ -815,6 +823,82 @@ impl KnowledgeRepo { .map_err(storage_err)? } + /// 项目维度软排序检索(跨会话召回最小闭环,D2 拍板:软排序非硬过滤)。 + /// + /// 排序语义:同项目(source_project = ?)第一档 > 全局(IS NULL)第二档 > 异项目第三档; + /// 同档内保持原有 title 命中分 → reuse_count → created_at 顺序。`limit` 不变只改顺序, + /// token 膨胀为零。`project_id = None` 时行为与 [`Self::search_keywords`] 逐字一致(回归门)。 + /// + /// 与 [`Self::search_keywords`] 的关系:后者是本方法的 project_id=None 特例(旧签名 + /// 保留给 MCP 等外部调用方,内部转调本方法,零重复实现)。 + pub async fn search_keywords_scoped( + &self, + keywords: &[String], + include_candidates: bool, + limit: usize, + project_id: Option<&str>, + ) -> Result> { + if project_id.is_none() { + return self.search_keywords(keywords, include_candidates, limit).await; + } + if keywords.is_empty() { + return Ok(Vec::new()); + } + let project_id = project_id.unwrap().to_string(); + let conn = self.conn.clone(); + // 每个关键词占一个编号占位符(1..n);project_id 占 ?(n+1),WHERE 的 ORDER BY CASE + // 与三档投影各复用同号(SQLite 允许 ?N 复用)。patterns 只装关键词一份。 + let mut patterns: Vec = Vec::with_capacity(keywords.len()); + let mut where_parts: Vec = Vec::with_capacity(keywords.len()); + let mut order_terms: Vec = Vec::with_capacity(keywords.len() + 1); + for (i, kw) in keywords.iter().enumerate() { + let n = i + 1; + where_parts.push(format!( + "(title LIKE ?{n} ESCAPE '|' OR content LIKE ?{n} ESCAPE '|')" + )); + order_terms.push(format!("CASE WHEN title LIKE ?{n} ESCAPE '|' THEN 1 ELSE 0 END")); + patterns.push(format!("%{}%", escape_like(kw))); + } + // 项目档位表达式复用 ?(n+1):0=同项目 1=全局(NULL) 2=异项目 + // 档位是**第一排序键**(ASC:0 最前),title 命分是同档内第二键(DESC)—— + // 若档位与 title 分相加,档位会被 title 分稀释(异项目 title 高分压过同项目)。 + let pid_n = keywords.len() + 1; + let project_tier = + format!("CASE WHEN source_project = ?{pid_n} THEN 0 WHEN source_project IS NULL THEN 1 ELSE 2 END"); + let title_terms = order_terms.join(" + "); + let status_clause = if include_candidates { + "status IN ('published','candidate')" + } else { + "status = 'published'" + }; + let limit_i = limit as i64; + let sql = format!( + "SELECT {KNOWLEDGE_COLS} FROM knowledges \ + WHERE {status_clause} AND ({}) \ + ORDER BY ({project_tier}) ASC, ({title_terms}) DESC, reuse_count DESC, created_at DESC LIMIT ?{}", + where_parts.join(" OR "), + pid_n + 1, + ); + tokio::task::spawn_blocking(move || { + let guard = conn.blocking_lock(); + let mut param_refs: Vec<&dyn rusqlite::ToSql> = + patterns.iter().map(|p| p as &dyn rusqlite::ToSql).collect(); + param_refs.push(&project_id); + param_refs.push(&limit_i); + let mut stmt = guard.prepare(&sql).map_err(storage_err)?; + let rows = stmt + .query_map(param_refs.as_slice(), |row| knowledge_from_row(row)) + .map_err(storage_err)?; + let mut results = Vec::new(); + for r in rows { + results.push(r.map_err(storage_err)?); + } + Ok(results) + }) + .await + .map_err(storage_err)? + } + /// 按状态列出(审核收件箱用): 按 confidence 语义排序(high>medium>low),次按 created_at /// /// 用 CASE WHEN 替代纯 TEXT 排序(字典序 high>low>medium 非预期语义)。 @@ -1544,6 +1628,76 @@ mod tests { assert_eq!(hits[0].id, "pub1"); } + // ---------- search_keywords_scoped(跨会话召回 D2 软排序) ---------- + + /// 同项目知识排前:同档内 title 命分定序,异档同项目压过异项目高复用 + #[tokio::test] + async fn scoped_same_project_ranks_above_foreign_despite_reuse() { + let repo = setup_repo().await; + // 外项目知识:标题命中 + 复用 999(档位应压过复用) + let mut foreign = krec("foreign", "Rust 所有权", "...", "lesson", "published", None, 999); + foreign.source_project = Some("other-proj".to_string()); + repo.insert(foreign).await.unwrap(); + // 同项目(proj-1,krec 默认):内容命中 + 复用 1 + repo.insert(krec("same", "无关标题", "聊聊 Rust 所有权", "lesson", "published", None, 1)) + .await + .unwrap(); + + let kws = vec!["所有权".to_string(), "Rust".to_string()]; + let hits = repo + .search_keywords_scoped(&kws, false, 10, Some("proj-1")) + .await + .unwrap(); + assert_eq!(hits.len(), 2); + assert_eq!(hits[0].id, "same", "同项目知识应压过异项目(即便后者 title 命中+高复用)"); + assert_eq!(hits[1].id, "foreign"); + } + + /// 全局知识(NULL)居中档:同项目 > 全局 > 异项目 + #[tokio::test] + async fn scoped_global_null_tier_between_same_and_foreign() { + let repo = setup_repo().await; + let mut foreign = krec("foreign", "Rust 异步", "...", "lesson", "published", None, 1); + foreign.source_project = Some("other-proj".to_string()); + repo.insert(foreign).await.unwrap(); + let mut global = krec("global", "Rust 生命周期", "...", "lesson", "published", None, 1); + global.source_project = None; + repo.insert(global).await.unwrap(); + repo.insert(krec("same", "Rust 借用", "...", "lesson", "published", None, 1)) + .await + .unwrap(); + + let kws = vec!["Rust".to_string()]; + let hits = repo + .search_keywords_scoped(&kws, false, 10, Some("proj-1")) + .await + .unwrap(); + let ids: Vec<_> = hits.iter().map(|h| h.id.as_str()).collect(); + assert_eq!(ids, vec!["same", "global", "foreign"], "三档顺序: 同项目>全局>异项目"); + } + + /// 回归门:project_id=None 与旧 search_keywords 行为逐字一致(同输入同顺序) + #[tokio::test] + async fn scoped_none_project_matches_legacy_search_keywords() { + let repo = setup_repo().await; + let mut foreign = krec("foreign", "Rust 异步", "...", "lesson", "published", None, 50); + foreign.source_project = Some("other-proj".to_string()); + repo.insert(foreign).await.unwrap(); + repo.insert(krec("same", "Rust 借用", "...", "lesson", "published", None, 1)) + .await + .unwrap(); + + let kws = vec!["Rust".to_string()]; + let legacy = repo.search_keywords(&kws, false, 10).await.unwrap(); + let scoped = repo + .search_keywords_scoped(&kws, false, 10, None) + .await + .unwrap(); + let a: Vec<_> = legacy.iter().map(|h| h.id.as_str()).collect(); + let b: Vec<_> = scoped.iter().map(|h| h.id.as_str()).collect(); + assert_eq!(a, b, "project_id=None 应与旧路径行为逐字一致(回归门)"); + } + #[tokio::test] async fn search_keywords_like_wildcards_escaped() { let repo = setup_repo().await; diff --git a/src-tauri/src/commands/knowledge.rs b/src-tauri/src/commands/knowledge.rs index 6639d3f..20436bd 100644 --- a/src-tauri/src/commands/knowledge.rs +++ b/src-tauri/src/commands/knowledge.rs @@ -46,15 +46,21 @@ pub struct KnowledgeSearchInput { /// 状态转换合法矩阵校验 /// -/// candidate → pending_review | published | archived -/// pending_review → published | archived +/// candidate → pending_review | published | rejected | archived +/// pending_review → published | rejected | archived /// published → archived +/// rejected → candidate(重新送审;rejected 独立分池语义:拒绝≠归档, +/// 被否决的知识可修改后重审,也可留在拒绝池沉淀——与 archived 分开可见) +/// archived → published(归档箱恢复;2026-10-05 归档功能尾巴——原 archived 为汇点, +/// 归档即不可逆,与项目/任务/灵感/会话归档「可逆语义」不一致) /// (其他组合非法) fn validate_transition(from: &str, to: &str) -> Result<(), String> { let legal = match from { - "candidate" => matches!(to, "pending_review" | "published" | "archived"), - "pending_review" => matches!(to, "published" | "archived"), + "candidate" => matches!(to, "pending_review" | "published" | "rejected" | "archived"), + "pending_review" => matches!(to, "published" | "rejected" | "archived"), "published" => matches!(to, "archived"), + "rejected" => matches!(to, "candidate"), + "archived" => matches!(to, "published"), _ => false, }; if legal { @@ -500,6 +506,8 @@ mod tests { fn pending_review_legal_transitions() { assert!(validate_transition("pending_review", "published").is_ok()); assert!(validate_transition("pending_review", "archived").is_ok()); + // 分池:审核否决走 rejected(独立池),不再混入 archived + assert!(validate_transition("pending_review", "rejected").is_ok()); } #[test] @@ -524,9 +532,32 @@ mod tests { } #[test] - fn archived_is_terminal() { - // 归档为终态,任何转换都非法 - for to in ["candidate", "pending_review", "published", "archived"] { + fn archived_restore_to_published_legal() { + // 归档箱恢复(2026-10-05 归档功能尾巴):archived → published 唯一出边 + assert!(validate_transition("archived", "published").is_ok()); + } + + #[test] + fn rejected_legal_transitions() { + // 分池语义:rejected 唯一出边=回 candidate 重新送审(修改后重审) + assert!(validate_transition("candidate", "rejected").is_ok()); + assert!(validate_transition("pending_review", "rejected").is_ok()); + assert!(validate_transition("rejected", "candidate").is_ok()); + } + + #[test] + fn rejected_illegal_transitions() { + // 拒绝池不可直接发布(必须走重审 candidate → published)、不可归档/自转 + for to in ["published", "pending_review", "archived", "rejected"] { + assert!(validate_transition("rejected", to).is_err(), + "rejected → {to} 应为非法"); + } + } + + #[test] + fn archived_illegal_transitions() { + // 归档态除恢复发布外均非法(不回 candidate/pending_review,不自转) + for to in ["candidate", "pending_review", "archived"] { assert!(validate_transition("archived", to).is_err(), "archived → {to} 应为非法"); } diff --git a/src/components/ai/EnrichmentPanel.vue b/src/components/ai/EnrichmentPanel.vue index 5c9de39..870db35 100644 --- a/src/components/ai/EnrichmentPanel.vue +++ b/src/components/ai/EnrichmentPanel.vue @@ -46,7 +46,7 @@ function toggleExpanded(current: boolean) { :title="enrichmentExpanded ? $t('aiChat.enrichmentCollapse') : $t('aiChat.enrichmentExpand')" :aria-label="enrichmentExpanded ? $t('aiChat.enrichmentCollapse') : $t('aiChat.enrichmentExpand')" > - + {{ enrichmentBadgeText }} diff --git a/src/components/knowledge/KnowledgeDetail.vue b/src/components/knowledge/KnowledgeDetail.vue index 1d799da..d981556 100644 --- a/src/components/knowledge/KnowledgeDetail.vue +++ b/src/components/knowledge/KnowledgeDetail.vue @@ -13,6 +13,14 @@ + + + +
@@ -155,7 +163,7 @@ import type { KnowledgeDetailPayload, KnowledgeEventRecord } from '@/api/types' // 编辑保存与审核操作通过 emit 回传父级执行 IPC(publish/reject/archive/save)。 const props = defineProps<{ detail: KnowledgeDetailPayload - topTab: 'library' | 'inbox' + topTab: 'library' | 'inbox' | 'rejected' | 'archived' submitting: boolean }>() @@ -164,6 +172,8 @@ const emit = defineEmits<{ (e: 'publish'): void (e: 'reject'): void (e: 'archive'): void + (e: 'restore'): void + (e: 'resubmit'): void }>() const { t } = useI18n() diff --git a/src/composables/ai/useAiContext.ts b/src/composables/ai/useAiContext.ts index 9716c32..124bc7c 100644 --- a/src/composables/ai/useAiContext.ts +++ b/src/composables/ai/useAiContext.ts @@ -31,6 +31,11 @@ import { setContextCallbacks } from './useAiEvents' // 若需 per-window 独立压缩态需改为事件同步/inject-provide,改造成本大,暂不处理。 const isCompressing = ref(false) +// B4 自动压缩可见信号:AiAutoCompressed 到达时置 true,4s 后自灭。输入区 hint 行消费, +// 长会话用户对「上下文何时被压缩」有一次性感知(不打断 LLM 流,不弹 toast)。 +const autoCompressedFlash = ref(false) +let autoCompressedFlashTimer: ReturnType | null = null + /** * 注入上下文生命周期回调到统一事件分发器(幂等)。 * @@ -49,8 +54,12 @@ async function initContextListener(callbacks: { callbacks.onCompressed?.(convId, summary) }, onAutoCompressed: () => { - // loop 自动压缩静默——仅复位 loading(冗余兜底,Auto 路径前端 loading 本就 false) + // loop 自动压缩:B4 可见信号——置 flash(输入区 hint 行短暂显示「上下文已自动压缩」), + // 4s 自灭;仍不弹 toast、不刷新视图(不打断 LLM 流,消息本就可见非折叠困惑)。 isCompressing.value = false + autoCompressedFlash.value = true + if (autoCompressedFlashTimer) clearTimeout(autoCompressedFlashTimer) + autoCompressedFlashTimer = setTimeout(() => { autoCompressedFlash.value = false }, 4000) }, onCleared: (convId) => callbacks.onCleared?.(convId), onError: (convId, message) => { @@ -108,6 +117,8 @@ export function useAiContext() { return { // 状态 isCompressing, + // B4: 自动压缩完成一次性可见信号(输入区 hint 行消费,4s 自灭) + autoCompressedFlash, // 操作 clearContext, compressContext, diff --git a/src/i18n/en/knowledge.ts b/src/i18n/en/knowledge.ts index e816130..23a0756 100644 --- a/src/i18n/en/knowledge.ts +++ b/src/i18n/en/knowledge.ts @@ -6,20 +6,30 @@ export default { library: 'Library', // inbox = pending items (candidate + pending_review); library is published-only (F-260616-02 decision a) inbox: 'Pending', + // Archive box (2026-10-05 archive tail): low-frequency visible & restorable + archived: 'Archive Box', + // Rejected pool (2026-10-05 split): review rejections, separate from archive + rejected: 'Rejected', }, searchPlaceholder: 'Search title, tags, content...', categoryAll: 'All', emptyInbox: '✅ No pending items', emptyLibrary: '📭 No matching knowledge', + emptyArchived: '🗄️ Archive box is empty', + emptyRejected: '🚫 No rejected knowledge', edit: '✏️ Edit', publish: 'Publish', reject: 'Reject', archive: 'Archive', + restore: 'Restore', + resubmit: 'Resubmit', // ⑤ Operation feedback toast savedToast: 'Saved', publishedToast: 'Published to library', - rejectedToast: 'Rejected', + rejectedToast: 'Rejected, moved to rejected pool', archivedToast: 'Archived', + restoredToast: 'Restored to library', + resubmittedToast: 'Resubmitted, moved to pending', createdToast: 'Created, moved to pending', reuseCount: '🔄 Reused {n} times', confidenceBadge: 'Confidence: {label}', @@ -66,6 +76,7 @@ export default { pending_review: 'Reviewing', published: 'Published', archived: 'Archived', + rejected: 'Rejected', }, confidence: { high: 'High', @@ -89,6 +100,9 @@ export default { createFailed: 'Failed to create knowledge', updateStatusFailed: 'Failed to update status', archiveFailed: 'Failed to archive', + restoreFailed: 'Failed to restore', + rejectFailed: 'Failed to reject', + resubmitFailed: 'Failed to resubmit', saveFailed: 'Failed to save', loadConfigFailed: 'Failed to load config', saveConfigFailed: 'Failed to save config', diff --git a/src/i18n/zh-CN/knowledge.ts b/src/i18n/zh-CN/knowledge.ts index 4ae3980..65689e5 100644 --- a/src/i18n/zh-CN/knowledge.ts +++ b/src/i18n/zh-CN/knowledge.ts @@ -7,6 +7,10 @@ export default { library: '知识库', // inbox = 待处理收件箱(candidate + pending_review);library 纯 published(F-260616-02 决策 a) inbox: '待处理', + // 归档箱(2026-10-05 归档功能尾巴):低频可见可恢复 + archived: '归档箱', + // 拒绝池(2026-10-05 分池):审核否决独立可见 + rejected: '已拒绝', }, // 搜索 searchPlaceholder: '搜索标题、标签、内容...', @@ -15,16 +19,22 @@ export default { // 列表空状态 emptyInbox: '✅ 暂无待处理条目', emptyLibrary: '📭 没有匹配的知识', + emptyArchived: '🗄️ 归档箱为空', + emptyRejected: '🚫 没有被拒绝的知识', // 详情头部操作 edit: '✏️ 编辑', publish: '发布', reject: '拒绝', archive: '归档', + restore: '恢复发布', + resubmit: '重新送审', // ⑤ 操作反馈 toast savedToast: '已保存', publishedToast: '已发布到知识库', - rejectedToast: '已拒绝', + rejectedToast: '已拒绝,进入拒绝池', archivedToast: '已归档', + restoredToast: '已恢复到知识库', + resubmittedToast: '已重新送审,进入待处理', createdToast: '已创建,进入待处理', // 详情徽章 reuseCount: '🔄 复用 {n} 次', @@ -80,6 +90,7 @@ export default { pending_review: '审核中', published: '已发布', archived: '已归档', + rejected: '已拒绝', }, // 置信度标签 confidence: { @@ -105,6 +116,9 @@ export default { createFailed: '创建知识失败', updateStatusFailed: '更新状态失败', archiveFailed: '归档失败', + restoreFailed: '恢复失败', + rejectFailed: '拒绝失败', + resubmitFailed: '重新送审失败', saveFailed: '保存失败', loadConfigFailed: '加载配置失败', saveConfigFailed: '保存配置失败', diff --git a/src/stores/knowledge.ts b/src/stores/knowledge.ts index 8503292..1c2e477 100644 --- a/src/stores/knowledge.ts +++ b/src/stores/knowledge.ts @@ -17,6 +17,8 @@ import type { const state = reactive({ items: [] as KnowledgeRecord[], candidates: [] as KnowledgeRecord[], + archived: [] as KnowledgeRecord[], + rejected: [] as KnowledgeRecord[], config: null as KnowledgeConfig | null, loading: false, error: null as string | null, @@ -161,11 +163,78 @@ export function useKnowledgeStore() { return ok === true } + // 分池:审核否决(候选/审核中 → rejected 独立池,不混归档箱) + async function reject(id: string): Promise { + const ok = await runWithCatch(state, t('knowledge.err.rejectFailed'), async () => { + await knowledgeApi.updateStatus(id, 'rejected') + state.items = state.items.filter(k => k.id !== id) + state.candidates = state.candidates.filter(k => k.id !== id) + state.rejected = state.rejected.filter(k => k.id !== id) + return true + }) + return ok === true + } + + // 分池恢复:rejected → candidate 重新送审(拒绝池唯一出边) + async function resubmit(id: string): Promise { + const ok = await runWithCatch(state, t('knowledge.err.resubmitFailed'), async () => { + await knowledgeApi.updateStatus(id, 'candidate') + state.rejected = state.rejected.filter(k => k.id !== id) + return true + }) + return ok === true + } + async function archive(id: string): Promise { const ok = 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) + state.archived = state.archived.filter(k => k.id !== id) + return true + }) + return ok === true + } + + // ── 归档箱(2026-10-05 归档功能尾巴):查归档 + 恢复发布 ── + async function loadArchived() { + const seq = ++_itemsSeq + state.loading = true + state.error = null + try { + const result = await knowledgeApi.list('archived') + if (seq !== _itemsSeq) return + state.archived = result + } catch (e: any) { + if (seq !== _itemsSeq) return + state.error = e?.toString() ?? t('knowledge.err.loadFailed') + } finally { + if (seq === _itemsSeq) state.loading = false + } + } + + // 拒绝池(2026-10-05 分池):与归档箱分开可见 + async function loadRejected() { + const seq = ++_itemsSeq + state.loading = true + state.error = null + try { + const result = await knowledgeApi.list('rejected') + if (seq !== _itemsSeq) return + state.rejected = result + } catch (e: any) { + if (seq !== _itemsSeq) return + state.error = e?.toString() ?? t('knowledge.err.loadFailed') + } finally { + if (seq === _itemsSeq) state.loading = false + } + } + + async function restore(id: string): Promise { + // archived → published 状态机恢复边(后端 validate_transition 已放行) + const ok = await runWithCatch(state, t('knowledge.err.restoreFailed'), async () => { + await knowledgeApi.updateStatus(id, 'published') + state.archived = state.archived.filter(k => k.id !== id) return true }) return ok === true @@ -228,12 +297,15 @@ export function useKnowledgeStore() { // 状态用 getter 实时读 state(防快照不跟随,同 project store 模式) get items() { return state.items }, get candidates() { return state.candidates }, + get archived() { return state.archived }, + get rejected() { return state.rejected }, get config() { return state.config }, get loading() { return state.loading }, get error() { return state.error }, candidateCount, // actions - loadList, loadCandidates, search, create, updateStatus, archive, + loadList, loadCandidates, search, create, updateStatus, archive, loadArchived, restore, + reject, loadRejected, resubmit, loadConfig, saveConfig, extractNow, getDetail, update, getEvents, clearError, diff --git a/src/views/Knowledge.vue b/src/views/Knowledge.vue index 0f832c8..fb3c220 100644 --- a/src/views/Knowledge.vue +++ b/src/views/Knowledge.vue @@ -28,6 +28,16 @@ {{ $t('knowledge.tab.inbox') }} {{ store.candidates.length }} + + + +
@@ -83,7 +93,7 @@
-
{{ topTab === 'inbox' ? $t('knowledge.emptyInbox') : $t('knowledge.emptyLibrary') }}
+
{{ topTab === 'inbox' ? $t('knowledge.emptyInbox') : topTab === 'rejected' ? $t('knowledge.emptyRejected') : topTab === 'archived' ? $t('knowledge.emptyArchived') : $t('knowledge.emptyLibrary') }}
@@ -99,6 +109,8 @@ @publish="publishCurrent" @reject="rejectCurrent" @archive="archiveCurrent" + @restore="restoreCurrent" + @resubmit="resubmitCurrent" />
@@ -174,7 +186,7 @@ const { toast, showToast } = useToast() // topTab/activeKind/searchQuery 持久化 localStorage(跨刷新保留用户视图态) // 仅持久化视图态:不持久化详情选中(id 跨刷新可能已失效)。usePersistedRef 无类型校验, // 解析失败/无效值回退 default,与 Tasks/Ideas 一致。 -const topTab = usePersistedRef<'library' | 'inbox'>('knowledge.topTab', 'library') +const topTab = usePersistedRef<'library' | 'inbox' | 'rejected' | 'archived'>('knowledge.topTab', 'library') const categories = [ { key: 'all', icon: '📦' }, @@ -208,12 +220,16 @@ onUnmounted(() => { } }) -function switchTopTab(tab: 'library' | 'inbox') { +function switchTopTab(tab: 'library' | 'inbox' | 'rejected' | 'archived') { topTab.value = tab selectedId.value = null detail.value = null if (tab === 'inbox') { store.loadCandidates() + } else if (tab === 'rejected') { + store.loadRejected() + } else if (tab === 'archived') { + store.loadArchived() } else { store.loadList() } @@ -226,7 +242,10 @@ function clearError() { // 左侧列表(随 Tab 切换数据源 + 知识库的分类过滤) const listItems = computed(() => { - const src = topTab.value === 'inbox' ? store.candidates : store.items + const src = topTab.value === 'inbox' ? store.candidates + : topTab.value === 'rejected' ? store.rejected + : topTab.value === 'archived' ? store.archived + : store.items if (topTab.value === 'library' && activeKind.value !== 'all') { return src.filter(i => i.kind === activeKind.value) } @@ -312,9 +331,10 @@ async function rejectCurrent() { const idx = store.candidates.findIndex(c => c.id === currentId) submitting.value = true try { - const ok = await store.archive(currentId) + // 分池(2026-10-05):审核否决走 rejected 独立池,与 archived 分开 + const ok = await store.reject(currentId) if (!ok) { - showToast(t('knowledge.err.archiveFailed'), 'error') + showToast(t('knowledge.err.rejectFailed'), 'error') return } await selectNextCandidate(idx) @@ -348,6 +368,44 @@ async function archiveCurrent() { } } +// ===== 归档箱恢复(2026-10-05 归档功能尾巴) ===== +async function restoreCurrent() { + if (!detail.value) return + const currentId = detail.value.knowledge.id + submitting.value = true + try { + const ok = await store.restore(currentId) + if (!ok) { + showToast(t('knowledge.err.restoreFailed'), 'error') + return + } + selectedId.value = null + detail.value = null + showToast(t('knowledge.restoredToast'), 'success') + } finally { + submitting.value = false + } +} + +// ===== 拒绝池恢复(2026-10-05 分池):rejected → candidate 重新送审 ===== +async function resubmitCurrent() { + if (!detail.value) return + const currentId = detail.value.knowledge.id + submitting.value = true + try { + const ok = await store.resubmit(currentId) + if (!ok) { + showToast(t('knowledge.err.resubmitFailed'), 'error') + return + } + selectedId.value = null + detail.value = null + showToast(t('knowledge.resubmittedToast'), 'success') + } finally { + submitting.value = false + } +} + // 当前条目从 candidates 移除后,选中同位置后续条目;末尾则清空详情 // 实现策略:操作前记下 currentId 在原列表中的 index,移除后从剩余列表的同 // index 取(若无则取前一条),保证「下一条」语义而非跳回开头 @@ -510,6 +568,7 @@ onMounted(() => { .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); } +.st-rejected { background: rgba(240,84,110,0.15); color: var(--df-danger); } .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); }