优化: 知识检索同项目加权与拒绝池
This commit is contained in:
@@ -547,16 +547,24 @@ impl IdeaRepo {
|
|||||||
.transaction()
|
.transaction()
|
||||||
.map_err(storage_err)?;
|
.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<String> = tx
|
let old_raw: Option<String> = tx
|
||||||
.query_row(
|
.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],
|
params![subject_id],
|
||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)
|
)
|
||||||
.optional()
|
.optional()
|
||||||
.map_err(storage_err)?
|
.map_err(storage_err)?
|
||||||
.flatten();
|
.flatten()
|
||||||
|
.ok_or_else(|| {
|
||||||
|
df_types::error::Error::Storage(format!(
|
||||||
|
"灵感 {subject_id} 不存在或已删除,无法建立关联"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
// 2. 解析新旧集合
|
// 2. 解析新旧集合
|
||||||
let old_set: std::collections::HashSet<String> =
|
let old_set: std::collections::HashSet<String> =
|
||||||
@@ -815,6 +823,82 @@ impl KnowledgeRepo {
|
|||||||
.map_err(storage_err)?
|
.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<Vec<KnowledgeRecord>> {
|
||||||
|
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<String> = Vec::with_capacity(keywords.len());
|
||||||
|
let mut where_parts: Vec<String> = Vec::with_capacity(keywords.len());
|
||||||
|
let mut order_terms: Vec<String> = 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
|
/// 按状态列出(审核收件箱用): 按 confidence 语义排序(high>medium>low),次按 created_at
|
||||||
///
|
///
|
||||||
/// 用 CASE WHEN 替代纯 TEXT 排序(字典序 high>low>medium 非预期语义)。
|
/// 用 CASE WHEN 替代纯 TEXT 排序(字典序 high>low>medium 非预期语义)。
|
||||||
@@ -1544,6 +1628,76 @@ mod tests {
|
|||||||
assert_eq!(hits[0].id, "pub1");
|
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]
|
#[tokio::test]
|
||||||
async fn search_keywords_like_wildcards_escaped() {
|
async fn search_keywords_like_wildcards_escaped() {
|
||||||
let repo = setup_repo().await;
|
let repo = setup_repo().await;
|
||||||
|
|||||||
@@ -46,15 +46,21 @@ pub struct KnowledgeSearchInput {
|
|||||||
|
|
||||||
/// 状态转换合法矩阵校验
|
/// 状态转换合法矩阵校验
|
||||||
///
|
///
|
||||||
/// candidate → pending_review | published | archived
|
/// candidate → pending_review | published | rejected | archived
|
||||||
/// pending_review → published | archived
|
/// pending_review → published | rejected | archived
|
||||||
/// published → archived
|
/// published → archived
|
||||||
|
/// rejected → candidate(重新送审;rejected 独立分池语义:拒绝≠归档,
|
||||||
|
/// 被否决的知识可修改后重审,也可留在拒绝池沉淀——与 archived 分开可见)
|
||||||
|
/// archived → published(归档箱恢复;2026-10-05 归档功能尾巴——原 archived 为汇点,
|
||||||
|
/// 归档即不可逆,与项目/任务/灵感/会话归档「可逆语义」不一致)
|
||||||
/// (其他组合非法)
|
/// (其他组合非法)
|
||||||
fn validate_transition(from: &str, to: &str) -> Result<(), String> {
|
fn validate_transition(from: &str, to: &str) -> Result<(), String> {
|
||||||
let legal = match from {
|
let legal = match from {
|
||||||
"candidate" => matches!(to, "pending_review" | "published" | "archived"),
|
"candidate" => matches!(to, "pending_review" | "published" | "rejected" | "archived"),
|
||||||
"pending_review" => matches!(to, "published" | "archived"),
|
"pending_review" => matches!(to, "published" | "rejected" | "archived"),
|
||||||
"published" => matches!(to, "archived"),
|
"published" => matches!(to, "archived"),
|
||||||
|
"rejected" => matches!(to, "candidate"),
|
||||||
|
"archived" => matches!(to, "published"),
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
if legal {
|
if legal {
|
||||||
@@ -500,6 +506,8 @@ mod tests {
|
|||||||
fn pending_review_legal_transitions() {
|
fn pending_review_legal_transitions() {
|
||||||
assert!(validate_transition("pending_review", "published").is_ok());
|
assert!(validate_transition("pending_review", "published").is_ok());
|
||||||
assert!(validate_transition("pending_review", "archived").is_ok());
|
assert!(validate_transition("pending_review", "archived").is_ok());
|
||||||
|
// 分池:审核否决走 rejected(独立池),不再混入 archived
|
||||||
|
assert!(validate_transition("pending_review", "rejected").is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -524,9 +532,32 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn archived_is_terminal() {
|
fn archived_restore_to_published_legal() {
|
||||||
// 归档为终态,任何转换都非法
|
// 归档箱恢复(2026-10-05 归档功能尾巴):archived → published 唯一出边
|
||||||
for to in ["candidate", "pending_review", "published", "archived"] {
|
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(),
|
assert!(validate_transition("archived", to).is_err(),
|
||||||
"archived → {to} 应为非法");
|
"archived → {to} 应为非法");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ function toggleExpanded(current: boolean) {
|
|||||||
:title="enrichmentExpanded ? $t('aiChat.enrichmentCollapse') : $t('aiChat.enrichmentExpand')"
|
:title="enrichmentExpanded ? $t('aiChat.enrichmentCollapse') : $t('aiChat.enrichmentExpand')"
|
||||||
:aria-label="enrichmentExpanded ? $t('aiChat.enrichmentCollapse') : $t('aiChat.enrichmentExpand')"
|
:aria-label="enrichmentExpanded ? $t('aiChat.enrichmentCollapse') : $t('aiChat.enrichmentExpand')"
|
||||||
>
|
>
|
||||||
<svg class="ai-enrichment-chevron" width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
|
<svg aria-hidden="true" class="ai-enrichment-chevron" width="10" height="10" viewBox="0 0 10 10" fill="currentColor">
|
||||||
<path d="M2 3l3 4 3-4" />
|
<path d="M2 3l3 4 3-4" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="ai-enrichment-badge">{{ enrichmentBadgeText }}</span>
|
<span class="ai-enrichment-badge">{{ enrichmentBadgeText }}</span>
|
||||||
|
|||||||
@@ -13,6 +13,14 @@
|
|||||||
<button class="btn btn-primary btn-sm" :disabled="submitting" @click="emit('publish')">{{ t('knowledge.publish') }}</button>
|
<button class="btn btn-primary btn-sm" :disabled="submitting" @click="emit('publish')">{{ t('knowledge.publish') }}</button>
|
||||||
<button class="btn btn-ghost btn-sm" :disabled="submitting" @click="emit('reject')">{{ t('knowledge.reject') }}</button>
|
<button class="btn btn-ghost btn-sm" :disabled="submitting" @click="emit('reject')">{{ t('knowledge.reject') }}</button>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- 归档箱:恢复发布(archived→published 唯一出边,可逆语义对齐项目/任务归档) -->
|
||||||
|
<template v-else-if="topTab === 'archived'">
|
||||||
|
<button class="btn btn-primary btn-sm" :disabled="submitting" @click="emit('restore')">{{ t('knowledge.restore') }}</button>
|
||||||
|
</template>
|
||||||
|
<!-- 拒绝池:重新送审(rejected→candidate 唯一出边,修改后重审) -->
|
||||||
|
<template v-else-if="topTab === 'rejected'">
|
||||||
|
<button class="btn btn-primary btn-sm" :disabled="submitting" @click="emit('resubmit')">{{ t('knowledge.resubmit') }}</button>
|
||||||
|
</template>
|
||||||
<button v-else class="btn btn-ghost btn-sm" :disabled="submitting" @click="emit('archive')">{{ t('knowledge.archive') }}</button>
|
<button v-else class="btn btn-ghost btn-sm" :disabled="submitting" @click="emit('archive')">{{ t('knowledge.archive') }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-actions" v-else>
|
<div class="detail-actions" v-else>
|
||||||
@@ -155,7 +163,7 @@ import type { KnowledgeDetailPayload, KnowledgeEventRecord } from '@/api/types'
|
|||||||
// 编辑保存与审核操作通过 emit 回传父级执行 IPC(publish/reject/archive/save)。
|
// 编辑保存与审核操作通过 emit 回传父级执行 IPC(publish/reject/archive/save)。
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
detail: KnowledgeDetailPayload
|
detail: KnowledgeDetailPayload
|
||||||
topTab: 'library' | 'inbox'
|
topTab: 'library' | 'inbox' | 'rejected' | 'archived'
|
||||||
submitting: boolean
|
submitting: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -164,6 +172,8 @@ const emit = defineEmits<{
|
|||||||
(e: 'publish'): void
|
(e: 'publish'): void
|
||||||
(e: 'reject'): void
|
(e: 'reject'): void
|
||||||
(e: 'archive'): void
|
(e: 'archive'): void
|
||||||
|
(e: 'restore'): void
|
||||||
|
(e: 'resubmit'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ import { setContextCallbacks } from './useAiEvents'
|
|||||||
// 若需 per-window 独立压缩态需改为事件同步/inject-provide,改造成本大,暂不处理。
|
// 若需 per-window 独立压缩态需改为事件同步/inject-provide,改造成本大,暂不处理。
|
||||||
const isCompressing = ref(false)
|
const isCompressing = ref(false)
|
||||||
|
|
||||||
|
// B4 自动压缩可见信号:AiAutoCompressed 到达时置 true,4s 后自灭。输入区 hint 行消费,
|
||||||
|
// 长会话用户对「上下文何时被压缩」有一次性感知(不打断 LLM 流,不弹 toast)。
|
||||||
|
const autoCompressedFlash = ref(false)
|
||||||
|
let autoCompressedFlashTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 注入上下文生命周期回调到统一事件分发器(幂等)。
|
* 注入上下文生命周期回调到统一事件分发器(幂等)。
|
||||||
*
|
*
|
||||||
@@ -49,8 +54,12 @@ async function initContextListener(callbacks: {
|
|||||||
callbacks.onCompressed?.(convId, summary)
|
callbacks.onCompressed?.(convId, summary)
|
||||||
},
|
},
|
||||||
onAutoCompressed: () => {
|
onAutoCompressed: () => {
|
||||||
// loop 自动压缩静默——仅复位 loading(冗余兜底,Auto 路径前端 loading 本就 false)
|
// loop 自动压缩:B4 可见信号——置 flash(输入区 hint 行短暂显示「上下文已自动压缩」),
|
||||||
|
// 4s 自灭;仍不弹 toast、不刷新视图(不打断 LLM 流,消息本就可见非折叠困惑)。
|
||||||
isCompressing.value = false
|
isCompressing.value = false
|
||||||
|
autoCompressedFlash.value = true
|
||||||
|
if (autoCompressedFlashTimer) clearTimeout(autoCompressedFlashTimer)
|
||||||
|
autoCompressedFlashTimer = setTimeout(() => { autoCompressedFlash.value = false }, 4000)
|
||||||
},
|
},
|
||||||
onCleared: (convId) => callbacks.onCleared?.(convId),
|
onCleared: (convId) => callbacks.onCleared?.(convId),
|
||||||
onError: (convId, message) => {
|
onError: (convId, message) => {
|
||||||
@@ -108,6 +117,8 @@ export function useAiContext() {
|
|||||||
return {
|
return {
|
||||||
// 状态
|
// 状态
|
||||||
isCompressing,
|
isCompressing,
|
||||||
|
// B4: 自动压缩完成一次性可见信号(输入区 hint 行消费,4s 自灭)
|
||||||
|
autoCompressedFlash,
|
||||||
// 操作
|
// 操作
|
||||||
clearContext,
|
clearContext,
|
||||||
compressContext,
|
compressContext,
|
||||||
|
|||||||
@@ -6,20 +6,30 @@ export default {
|
|||||||
library: 'Library',
|
library: 'Library',
|
||||||
// inbox = pending items (candidate + pending_review); library is published-only (F-260616-02 decision a)
|
// inbox = pending items (candidate + pending_review); library is published-only (F-260616-02 decision a)
|
||||||
inbox: 'Pending',
|
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...',
|
searchPlaceholder: 'Search title, tags, content...',
|
||||||
categoryAll: 'All',
|
categoryAll: 'All',
|
||||||
emptyInbox: '✅ No pending items',
|
emptyInbox: '✅ No pending items',
|
||||||
emptyLibrary: '📭 No matching knowledge',
|
emptyLibrary: '📭 No matching knowledge',
|
||||||
|
emptyArchived: '🗄️ Archive box is empty',
|
||||||
|
emptyRejected: '🚫 No rejected knowledge',
|
||||||
edit: '✏️ Edit',
|
edit: '✏️ Edit',
|
||||||
publish: 'Publish',
|
publish: 'Publish',
|
||||||
reject: 'Reject',
|
reject: 'Reject',
|
||||||
archive: 'Archive',
|
archive: 'Archive',
|
||||||
|
restore: 'Restore',
|
||||||
|
resubmit: 'Resubmit',
|
||||||
// ⑤ Operation feedback toast
|
// ⑤ Operation feedback toast
|
||||||
savedToast: 'Saved',
|
savedToast: 'Saved',
|
||||||
publishedToast: 'Published to library',
|
publishedToast: 'Published to library',
|
||||||
rejectedToast: 'Rejected',
|
rejectedToast: 'Rejected, moved to rejected pool',
|
||||||
archivedToast: 'Archived',
|
archivedToast: 'Archived',
|
||||||
|
restoredToast: 'Restored to library',
|
||||||
|
resubmittedToast: 'Resubmitted, moved to pending',
|
||||||
createdToast: 'Created, moved to pending',
|
createdToast: 'Created, moved to pending',
|
||||||
reuseCount: '🔄 Reused {n} times',
|
reuseCount: '🔄 Reused {n} times',
|
||||||
confidenceBadge: 'Confidence: {label}',
|
confidenceBadge: 'Confidence: {label}',
|
||||||
@@ -66,6 +76,7 @@ export default {
|
|||||||
pending_review: 'Reviewing',
|
pending_review: 'Reviewing',
|
||||||
published: 'Published',
|
published: 'Published',
|
||||||
archived: 'Archived',
|
archived: 'Archived',
|
||||||
|
rejected: 'Rejected',
|
||||||
},
|
},
|
||||||
confidence: {
|
confidence: {
|
||||||
high: 'High',
|
high: 'High',
|
||||||
@@ -89,6 +100,9 @@ export default {
|
|||||||
createFailed: 'Failed to create knowledge',
|
createFailed: 'Failed to create knowledge',
|
||||||
updateStatusFailed: 'Failed to update status',
|
updateStatusFailed: 'Failed to update status',
|
||||||
archiveFailed: 'Failed to archive',
|
archiveFailed: 'Failed to archive',
|
||||||
|
restoreFailed: 'Failed to restore',
|
||||||
|
rejectFailed: 'Failed to reject',
|
||||||
|
resubmitFailed: 'Failed to resubmit',
|
||||||
saveFailed: 'Failed to save',
|
saveFailed: 'Failed to save',
|
||||||
loadConfigFailed: 'Failed to load config',
|
loadConfigFailed: 'Failed to load config',
|
||||||
saveConfigFailed: 'Failed to save config',
|
saveConfigFailed: 'Failed to save config',
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ export default {
|
|||||||
library: '知识库',
|
library: '知识库',
|
||||||
// inbox = 待处理收件箱(candidate + pending_review);library 纯 published(F-260616-02 决策 a)
|
// inbox = 待处理收件箱(candidate + pending_review);library 纯 published(F-260616-02 决策 a)
|
||||||
inbox: '待处理',
|
inbox: '待处理',
|
||||||
|
// 归档箱(2026-10-05 归档功能尾巴):低频可见可恢复
|
||||||
|
archived: '归档箱',
|
||||||
|
// 拒绝池(2026-10-05 分池):审核否决独立可见
|
||||||
|
rejected: '已拒绝',
|
||||||
},
|
},
|
||||||
// 搜索
|
// 搜索
|
||||||
searchPlaceholder: '搜索标题、标签、内容...',
|
searchPlaceholder: '搜索标题、标签、内容...',
|
||||||
@@ -15,16 +19,22 @@ export default {
|
|||||||
// 列表空状态
|
// 列表空状态
|
||||||
emptyInbox: '✅ 暂无待处理条目',
|
emptyInbox: '✅ 暂无待处理条目',
|
||||||
emptyLibrary: '📭 没有匹配的知识',
|
emptyLibrary: '📭 没有匹配的知识',
|
||||||
|
emptyArchived: '🗄️ 归档箱为空',
|
||||||
|
emptyRejected: '🚫 没有被拒绝的知识',
|
||||||
// 详情头部操作
|
// 详情头部操作
|
||||||
edit: '✏️ 编辑',
|
edit: '✏️ 编辑',
|
||||||
publish: '发布',
|
publish: '发布',
|
||||||
reject: '拒绝',
|
reject: '拒绝',
|
||||||
archive: '归档',
|
archive: '归档',
|
||||||
|
restore: '恢复发布',
|
||||||
|
resubmit: '重新送审',
|
||||||
// ⑤ 操作反馈 toast
|
// ⑤ 操作反馈 toast
|
||||||
savedToast: '已保存',
|
savedToast: '已保存',
|
||||||
publishedToast: '已发布到知识库',
|
publishedToast: '已发布到知识库',
|
||||||
rejectedToast: '已拒绝',
|
rejectedToast: '已拒绝,进入拒绝池',
|
||||||
archivedToast: '已归档',
|
archivedToast: '已归档',
|
||||||
|
restoredToast: '已恢复到知识库',
|
||||||
|
resubmittedToast: '已重新送审,进入待处理',
|
||||||
createdToast: '已创建,进入待处理',
|
createdToast: '已创建,进入待处理',
|
||||||
// 详情徽章
|
// 详情徽章
|
||||||
reuseCount: '🔄 复用 {n} 次',
|
reuseCount: '🔄 复用 {n} 次',
|
||||||
@@ -80,6 +90,7 @@ export default {
|
|||||||
pending_review: '审核中',
|
pending_review: '审核中',
|
||||||
published: '已发布',
|
published: '已发布',
|
||||||
archived: '已归档',
|
archived: '已归档',
|
||||||
|
rejected: '已拒绝',
|
||||||
},
|
},
|
||||||
// 置信度标签
|
// 置信度标签
|
||||||
confidence: {
|
confidence: {
|
||||||
@@ -105,6 +116,9 @@ export default {
|
|||||||
createFailed: '创建知识失败',
|
createFailed: '创建知识失败',
|
||||||
updateStatusFailed: '更新状态失败',
|
updateStatusFailed: '更新状态失败',
|
||||||
archiveFailed: '归档失败',
|
archiveFailed: '归档失败',
|
||||||
|
restoreFailed: '恢复失败',
|
||||||
|
rejectFailed: '拒绝失败',
|
||||||
|
resubmitFailed: '重新送审失败',
|
||||||
saveFailed: '保存失败',
|
saveFailed: '保存失败',
|
||||||
loadConfigFailed: '加载配置失败',
|
loadConfigFailed: '加载配置失败',
|
||||||
saveConfigFailed: '保存配置失败',
|
saveConfigFailed: '保存配置失败',
|
||||||
|
|||||||
+73
-1
@@ -17,6 +17,8 @@ import type {
|
|||||||
const state = reactive({
|
const state = reactive({
|
||||||
items: [] as KnowledgeRecord[],
|
items: [] as KnowledgeRecord[],
|
||||||
candidates: [] as KnowledgeRecord[],
|
candidates: [] as KnowledgeRecord[],
|
||||||
|
archived: [] as KnowledgeRecord[],
|
||||||
|
rejected: [] as KnowledgeRecord[],
|
||||||
config: null as KnowledgeConfig | null,
|
config: null as KnowledgeConfig | null,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null as string | null,
|
error: null as string | null,
|
||||||
@@ -161,11 +163,78 @@ export function useKnowledgeStore() {
|
|||||||
return ok === true
|
return ok === true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 分池:审核否决(候选/审核中 → rejected 独立池,不混归档箱)
|
||||||
|
async function reject(id: string): Promise<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<boolean> {
|
async function archive(id: string): Promise<boolean> {
|
||||||
const ok = await runWithCatch(state, t('knowledge.err.archiveFailed'), async () => {
|
const ok = await runWithCatch(state, t('knowledge.err.archiveFailed'), async () => {
|
||||||
await knowledgeApi.archive(id)
|
await knowledgeApi.archive(id)
|
||||||
state.items = state.items.filter(k => k.id !== id)
|
state.items = state.items.filter(k => k.id !== id)
|
||||||
state.candidates = state.candidates.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<boolean> {
|
||||||
|
// 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 true
|
||||||
})
|
})
|
||||||
return ok === true
|
return ok === true
|
||||||
@@ -228,12 +297,15 @@ export function useKnowledgeStore() {
|
|||||||
// 状态用 getter 实时读 state(防快照不跟随,同 project store 模式)
|
// 状态用 getter 实时读 state(防快照不跟随,同 project store 模式)
|
||||||
get items() { return state.items },
|
get items() { return state.items },
|
||||||
get candidates() { return state.candidates },
|
get candidates() { return state.candidates },
|
||||||
|
get archived() { return state.archived },
|
||||||
|
get rejected() { return state.rejected },
|
||||||
get config() { return state.config },
|
get config() { return state.config },
|
||||||
get loading() { return state.loading },
|
get loading() { return state.loading },
|
||||||
get error() { return state.error },
|
get error() { return state.error },
|
||||||
candidateCount,
|
candidateCount,
|
||||||
// actions
|
// actions
|
||||||
loadList, loadCandidates, search, create, updateStatus, archive,
|
loadList, loadCandidates, search, create, updateStatus, archive, loadArchived, restore,
|
||||||
|
reject, loadRejected, resubmit,
|
||||||
loadConfig, saveConfig, extractNow,
|
loadConfig, saveConfig, extractNow,
|
||||||
getDetail, update, getEvents,
|
getDetail, update, getEvents,
|
||||||
clearError,
|
clearError,
|
||||||
|
|||||||
+65
-6
@@ -28,6 +28,16 @@
|
|||||||
{{ $t('knowledge.tab.inbox') }}
|
{{ $t('knowledge.tab.inbox') }}
|
||||||
<span v-if="store.candidates.length" class="inbox-badge">{{ store.candidates.length }}</span>
|
<span v-if="store.candidates.length" class="inbox-badge">{{ store.candidates.length }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<!-- 归档箱(2026-10-05 归档功能尾巴):低频可见可恢复,与回收站/正式库分池 -->
|
||||||
|
<button class="top-tab" :class="{ active: topTab === 'archived' }" @click="switchTopTab('archived')">
|
||||||
|
🗄️ {{ $t('knowledge.tab.archived') }}
|
||||||
|
<span v-if="store.archived.length" class="inbox-badge">{{ store.archived.length }}</span>
|
||||||
|
</button>
|
||||||
|
<!-- 拒绝池(2026-10-05 分池):审核否决独立可见,与归档分池 -->
|
||||||
|
<button class="top-tab" :class="{ active: topTab === 'rejected' }" @click="switchTopTab('rejected')">
|
||||||
|
🚫 {{ $t('knowledge.tab.rejected') }}
|
||||||
|
<span v-if="store.rejected.length" class="inbox-badge">{{ store.rejected.length }}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 左右分栏: 列表 + 详情 -->
|
<!-- 左右分栏: 列表 + 详情 -->
|
||||||
@@ -83,7 +93,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="listItems.length === 0" class="list-empty">
|
<div v-if="listItems.length === 0" class="list-empty">
|
||||||
<div>{{ topTab === 'inbox' ? $t('knowledge.emptyInbox') : $t('knowledge.emptyLibrary') }}</div>
|
<div>{{ topTab === 'inbox' ? $t('knowledge.emptyInbox') : topTab === 'rejected' ? $t('knowledge.emptyRejected') : topTab === 'archived' ? $t('knowledge.emptyArchived') : $t('knowledge.emptyLibrary') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,6 +109,8 @@
|
|||||||
@publish="publishCurrent"
|
@publish="publishCurrent"
|
||||||
@reject="rejectCurrent"
|
@reject="rejectCurrent"
|
||||||
@archive="archiveCurrent"
|
@archive="archiveCurrent"
|
||||||
|
@restore="restoreCurrent"
|
||||||
|
@resubmit="resubmitCurrent"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-else class="detail-empty">
|
<div v-else class="detail-empty">
|
||||||
@@ -174,7 +186,7 @@ const { toast, showToast } = useToast()
|
|||||||
// topTab/activeKind/searchQuery 持久化 localStorage(跨刷新保留用户视图态)
|
// topTab/activeKind/searchQuery 持久化 localStorage(跨刷新保留用户视图态)
|
||||||
// 仅持久化视图态:不持久化详情选中(id 跨刷新可能已失效)。usePersistedRef 无类型校验,
|
// 仅持久化视图态:不持久化详情选中(id 跨刷新可能已失效)。usePersistedRef 无类型校验,
|
||||||
// 解析失败/无效值回退 default,与 Tasks/Ideas 一致。
|
// 解析失败/无效值回退 default,与 Tasks/Ideas 一致。
|
||||||
const topTab = usePersistedRef<'library' | 'inbox'>('knowledge.topTab', 'library')
|
const topTab = usePersistedRef<'library' | 'inbox' | 'rejected' | 'archived'>('knowledge.topTab', 'library')
|
||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
{ key: 'all', icon: '📦' },
|
{ key: 'all', icon: '📦' },
|
||||||
@@ -208,12 +220,16 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function switchTopTab(tab: 'library' | 'inbox') {
|
function switchTopTab(tab: 'library' | 'inbox' | 'rejected' | 'archived') {
|
||||||
topTab.value = tab
|
topTab.value = tab
|
||||||
selectedId.value = null
|
selectedId.value = null
|
||||||
detail.value = null
|
detail.value = null
|
||||||
if (tab === 'inbox') {
|
if (tab === 'inbox') {
|
||||||
store.loadCandidates()
|
store.loadCandidates()
|
||||||
|
} else if (tab === 'rejected') {
|
||||||
|
store.loadRejected()
|
||||||
|
} else if (tab === 'archived') {
|
||||||
|
store.loadArchived()
|
||||||
} else {
|
} else {
|
||||||
store.loadList()
|
store.loadList()
|
||||||
}
|
}
|
||||||
@@ -226,7 +242,10 @@ function clearError() {
|
|||||||
|
|
||||||
// 左侧列表(随 Tab 切换数据源 + 知识库的分类过滤)
|
// 左侧列表(随 Tab 切换数据源 + 知识库的分类过滤)
|
||||||
const listItems = computed(() => {
|
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') {
|
if (topTab.value === 'library' && activeKind.value !== 'all') {
|
||||||
return src.filter(i => i.kind === activeKind.value)
|
return src.filter(i => i.kind === activeKind.value)
|
||||||
}
|
}
|
||||||
@@ -312,9 +331,10 @@ async function rejectCurrent() {
|
|||||||
const idx = store.candidates.findIndex(c => c.id === currentId)
|
const idx = store.candidates.findIndex(c => c.id === currentId)
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
const ok = await store.archive(currentId)
|
// 分池(2026-10-05):审核否决走 rejected 独立池,与 archived 分开
|
||||||
|
const ok = await store.reject(currentId)
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
showToast(t('knowledge.err.archiveFailed'), 'error')
|
showToast(t('knowledge.err.rejectFailed'), 'error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await selectNextCandidate(idx)
|
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 移除后,选中同位置后续条目;末尾则清空详情
|
// 当前条目从 candidates 移除后,选中同位置后续条目;末尾则清空详情
|
||||||
// 实现策略:操作前记下 currentId 在原列表中的 index,移除后从剩余列表的同
|
// 实现策略:操作前记下 currentId 在原列表中的 index,移除后从剩余列表的同
|
||||||
// index 取(若无则取前一条),保证「下一条」语义而非跳回开头
|
// index 取(若无则取前一条),保证「下一条」语义而非跳回开头
|
||||||
@@ -510,6 +568,7 @@ onMounted(() => {
|
|||||||
.st-pending_review { background: rgba(108,159,255,0.15); color: var(--df-info); }
|
.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-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-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-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-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); }
|
.conf-low { background: rgba(255,107,107,0.15); color: var(--df-danger); padding: 1px 6px; border-radius: var(--df-radius-xs); }
|
||||||
|
|||||||
Reference in New Issue
Block a user