优化: 所有剩余UI/UX待办一批完成(持久化+AuditLog+解耦+total+原12大改+P2)
持久化(P1-c):新建 usePersistedRef composable,Tasks/AuditLog/ProjectDetail 等接入 localStorage
AuditLog(P1-d):后端 list_tool_executions 加 WHERE 筛选+返 {items,total,has_more},前端对接+长列折叠+筛选持久化
数据源解耦(P1-g):ProjectDetail projectTasks 按 project_id 独立加载 + ChatInput @项目联想独立加载(不读 store.tasks 当前页)
GitChanges(12a):后端 get_module_commits 加 git rev-list --count 返 total,前端显真实总数
原12大改:Dashboard 统计卡压底行(1)/Projects 列表卡片视图(2)/project_event 埋点排序(3)/TaskDetail 重设计(4)/IdeaDetail 重设计(5)/KnowledgeDetail 重设计(6)/界面持久化+侧栏Ctrl+B+审批数字键(7)/ProjectDetail 三栏改两栏(10)
P2打磨:文件浏览器(FileTree去重/FilePreview行号.md Diff/selectedFilePath归位)/settings反馈(假保存/端口校验)/AI会话(try-catch/scrollIntoView)/后端计数(move_queue事件/timeline total/workflow分页/import_batch分块)/杂项(TopBar/ConfirmDialog键盘/CIStatus i18n/ToolResultBody/ModuleNode/ApprovalDialog全选)
This commit is contained in:
@@ -184,6 +184,32 @@ impl_repo!(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// AuditQuery — 审批历史多条件查询入参(status / risk / 工具名关键词)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/// 审批历史多条件查询入参(对标 [`IdeaQuery`] 的可选字段 struct 设计)。
|
||||||
|
///
|
||||||
|
/// 所有字段可选;全 None → 等价 `list_recent`(向后兼容)。设计对齐 `查询能力补全方案`:
|
||||||
|
/// 可选字段 struct 而非逐个加 IPC 参数,复用 [`IdeaRepo::list_by_query`] 的动态 WHERE 拼接
|
||||||
|
/// 模式(if-let 分支拼 SQL + 分支化参数绑定)。
|
||||||
|
///
|
||||||
|
/// - `status`:状态精确匹配(pending/approved/rejected/executing/completed/failed/interrupted)
|
||||||
|
/// - `risk_level`:风险等级精确匹配(low/medium/high)
|
||||||
|
/// - `tool_keyword`:`tool_name LIKE %kw%`(对齐 idea_repo 关键词 LIKE 检索,不上 FTS5)
|
||||||
|
/// - `limit`/`offset`:钳制上限 200(对齐 [`AiToolExecutionRepo::list_recent`])
|
||||||
|
///
|
||||||
|
/// `Deserialize`:Tauri IPC 从前端 JSON 反序列化为命令参数。
|
||||||
|
/// `Default`:命令层兼容旧全量调用(`AuditQuery::default()` 等价无条件)。
|
||||||
|
#[derive(Debug, Clone, Default, serde::Deserialize)]
|
||||||
|
pub struct AuditQuery {
|
||||||
|
pub status: Option<String>,
|
||||||
|
pub risk_level: Option<String>,
|
||||||
|
pub tool_keyword: Option<String>,
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
pub offset: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
impl_repo!(
|
impl_repo!(
|
||||||
/// AI 工具执行审计表 CRUD
|
/// AI 工具执行审计表 CRUD
|
||||||
AiToolExecutionRepo,
|
AiToolExecutionRepo,
|
||||||
@@ -318,6 +344,125 @@ impl AiToolExecutionRepo {
|
|||||||
.await
|
.await
|
||||||
.map_err(storage_err)?
|
.map_err(storage_err)?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 多条件查询:动态 WHERE 拼接(status / risk_level / 工具名关键词) + 分页。
|
||||||
|
///
|
||||||
|
/// 复用 [`IdeaRepo::list_by_query`] 的动态 WHERE 模式:if-let 分支按可选条件拼 SQL 片段,
|
||||||
|
/// 各分支化参数绑定到 `?N` 占位符。limit 钳制上限 200(对齐 [`Self::list_recent`])。
|
||||||
|
///
|
||||||
|
/// **向后兼容**:空 query(全 None)→ 无 WHERE 子句,等价 `list_recent`。
|
||||||
|
/// 与 list_pending/list_recent 同理走专用 SELECT,绕过通用 query 宏(后者硬编码
|
||||||
|
/// ORDER BY created_at,本表无该列)。
|
||||||
|
pub async fn list_by_query(&self, q: &AuditQuery) -> Result<Vec<AiToolExecutionRecord>> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let status = q.status.clone();
|
||||||
|
let risk = q.risk_level.clone();
|
||||||
|
let kw = q.tool_keyword.clone();
|
||||||
|
let limit_i: i64 = q.limit.unwrap_or(50).min(200) as i64;
|
||||||
|
let offset_i: i64 = q.offset.unwrap_or(0) as i64;
|
||||||
|
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
|
||||||
|
let mut where_clauses: Vec<String> = Vec::new();
|
||||||
|
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||||
|
|
||||||
|
if let Some(s) = &status {
|
||||||
|
where_clauses.push(format!("status = ?{}", params_vec.len() + 1));
|
||||||
|
params_vec.push(Box::new(s.clone()));
|
||||||
|
}
|
||||||
|
if let Some(r) = &risk {
|
||||||
|
where_clauses.push(format!("risk_level = ?{}", params_vec.len() + 1));
|
||||||
|
params_vec.push(Box::new(r.clone()));
|
||||||
|
}
|
||||||
|
if let Some(k) = &kw {
|
||||||
|
let escaped = k.replace('%', "\\%").replace('_', "\\_");
|
||||||
|
let pat = format!("%{escaped}%");
|
||||||
|
where_clauses.push(format!("tool_name LIKE ?{} ESCAPE '\\'", params_vec.len() + 1));
|
||||||
|
params_vec.push(Box::new(pat));
|
||||||
|
}
|
||||||
|
|
||||||
|
let where_sql = if where_clauses.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" WHERE {}", where_clauses.join(" AND "))
|
||||||
|
};
|
||||||
|
|
||||||
|
let where_param_count = params_vec.len();
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT * FROM ai_tool_executions{where_sql} \
|
||||||
|
ORDER BY requested_at DESC LIMIT ?{lim} OFFSET ?{off}",
|
||||||
|
lim = where_param_count + 1,
|
||||||
|
off = where_param_count + 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut stmt = guard.prepare(&sql).map_err(storage_err)?;
|
||||||
|
params_vec.push(Box::new(limit_i));
|
||||||
|
params_vec.push(Box::new(offset_i));
|
||||||
|
let param_refs: Vec<&dyn rusqlite::ToSql> =
|
||||||
|
params_vec.iter().map(|p| p.as_ref()).collect();
|
||||||
|
let rows = stmt
|
||||||
|
.query_map(param_refs.as_slice(), |row| ai_tool_execution_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)?
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按 [`AuditQuery`] 条件计数(不含 limit/offset,用于分页 total)。
|
||||||
|
///
|
||||||
|
/// 复用 [`Self::list_by_query`] 的 WHERE 构造逻辑(仅 WHERE,无 ORDER BY/LIMIT),
|
||||||
|
/// 返回满足条件的总行数(忽略分页裁剪)。对标 [`TaskRepo::count_by_query`]。
|
||||||
|
pub async fn count_by_query(&self, q: &AuditQuery) -> Result<i64> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
let status = q.status.clone();
|
||||||
|
let risk = q.risk_level.clone();
|
||||||
|
let kw = q.tool_keyword.clone();
|
||||||
|
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
|
||||||
|
let mut where_clauses: Vec<String> = Vec::new();
|
||||||
|
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||||
|
|
||||||
|
if let Some(s) = &status {
|
||||||
|
where_clauses.push(format!("status = ?{}", params_vec.len() + 1));
|
||||||
|
params_vec.push(Box::new(s.clone()));
|
||||||
|
}
|
||||||
|
if let Some(r) = &risk {
|
||||||
|
where_clauses.push(format!("risk_level = ?{}", params_vec.len() + 1));
|
||||||
|
params_vec.push(Box::new(r.clone()));
|
||||||
|
}
|
||||||
|
if let Some(k) = &kw {
|
||||||
|
let escaped = k.replace('%', "\\%").replace('_', "\\_");
|
||||||
|
let pat = format!("%{escaped}%");
|
||||||
|
where_clauses.push(format!("tool_name LIKE ?{} ESCAPE '\\'", params_vec.len() + 1));
|
||||||
|
params_vec.push(Box::new(pat));
|
||||||
|
}
|
||||||
|
|
||||||
|
let sql = if where_clauses.is_empty() {
|
||||||
|
"SELECT COUNT(*) FROM ai_tool_executions".to_string()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"SELECT COUNT(*) FROM ai_tool_executions WHERE {}",
|
||||||
|
where_clauses.join(" AND ")
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let param_refs: Vec<&dyn rusqlite::ToSql> =
|
||||||
|
params_vec.iter().map(|p| p.as_ref()).collect();
|
||||||
|
let count: i64 = guard
|
||||||
|
.query_row(&sql, param_refs.as_slice(), |row| row.get(0))
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
Ok(count)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// AiConversationRepo 的整体更新已由 impl_repo! 宏统一生成的 update_full 提供。
|
// AiConversationRepo 的整体更新已由 impl_repo! 宏统一生成的 update_full 提供。
|
||||||
|
|||||||
@@ -179,6 +179,47 @@ impl ProjectEventRepo {
|
|||||||
.map_err(storage_err)?
|
.map_err(storage_err)?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 全表取每个项目的最新事件时间(`SELECT project_id, MAX(created_at) GROUP BY project_id`)。
|
||||||
|
///
|
||||||
|
/// 供「项目列表最近活跃排序」使用:不改 projects 表 schema,通过 project_events 统一事件流
|
||||||
|
/// 推导各项目真实活跃时间(任务/灵感/状态推进等业务事件),而非 projects.updated_at(后者随
|
||||||
|
/// 元信息修改如改 description 也会刷新,不反映真实业务活跃)。
|
||||||
|
///
|
||||||
|
/// 返回 `HashMap<project_id, latest_created_at>`。无事件的项目不在 map 中(调用方用
|
||||||
|
/// COALESCE 回退 projects.updated_at)。命中 idx_project_events_project 的 project_id 维度,
|
||||||
|
/// 单用户桌面应用事件量小无压力。
|
||||||
|
///
|
||||||
|
/// 注:`MAX(created_at)` 在 SQLite 中对 TEXT(毫秒时间戳字符串)做字典序比较等价数值序
|
||||||
|
/// (定长毫秒字符串),语义正确。
|
||||||
|
pub async fn latest_activity_per_project(&self) -> Result<std::collections::HashMap<String, String>> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
let mut stmt = guard
|
||||||
|
.prepare(
|
||||||
|
"SELECT project_id, MAX(created_at) AS latest \
|
||||||
|
FROM project_events GROUP BY project_id",
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, String>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let mut map = std::collections::HashMap::new();
|
||||||
|
for r in rows {
|
||||||
|
let (pid, latest) = r.map_err(storage_err)?;
|
||||||
|
map.insert(pid, latest);
|
||||||
|
}
|
||||||
|
Ok(map)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(storage_err)?
|
||||||
|
}
|
||||||
|
|
||||||
/// 跨项目列最近 N 条事件(全表 created_at DESC,id DESC 兜底,top-N,命中
|
/// 跨项目列最近 N 条事件(全表 created_at DESC,id DESC 兜底,top-N,命中
|
||||||
/// idx_project_events_project 的 created_at 维度)。
|
/// idx_project_events_project 的 created_at 维度)。
|
||||||
///
|
///
|
||||||
@@ -370,6 +411,54 @@ mod tests {
|
|||||||
assert_eq!(got[0].id, "e2");
|
assert_eq!(got[0].id, "e2");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// latest_activity_per_project:每项目取 MAX(created_at),无事件项目不在 map 中。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn latest_activity_per_project_groups() {
|
||||||
|
let (db, repo) = setup().await;
|
||||||
|
// 补一个 proj-2 占位 project(FK 要求)
|
||||||
|
let project_repo = ProjectRepo::new(&db);
|
||||||
|
project_repo
|
||||||
|
.insert(ProjectRecord {
|
||||||
|
id: "proj-2".to_string(),
|
||||||
|
name: "proj-2".to_string(),
|
||||||
|
description: String::new(),
|
||||||
|
status: ProjectStatus::Planning,
|
||||||
|
idea_id: None,
|
||||||
|
path: None,
|
||||||
|
stack: None,
|
||||||
|
created_at: "1700000000000".to_string(),
|
||||||
|
updated_at: "1700000000000".to_string(),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// proj-1: 插 2 条(created_at 由存储层覆盖,第 2 条更晚)
|
||||||
|
repo.insert(erec("e1", "proj-1", "1")).await.unwrap();
|
||||||
|
repo.insert(erec("e2", "proj-1", "1")).await.unwrap();
|
||||||
|
// proj-2: 插 1 条
|
||||||
|
repo.insert(erec("e3", "proj-2", "1")).await.unwrap();
|
||||||
|
|
||||||
|
let map = repo.latest_activity_per_project().await.unwrap();
|
||||||
|
// 两项目都在 map 中
|
||||||
|
assert_eq!(map.len(), 2);
|
||||||
|
// proj-1 的 latest = 最后插入的 e2 的 created_at(存储层覆盖的当前毫秒)
|
||||||
|
let proj1_latest = map.get("proj-1").expect("proj-1 应在 map 中");
|
||||||
|
let proj2_latest = map.get("proj-2").expect("proj-2 应在 map 中");
|
||||||
|
// proj-1 最后插入(e2)晚于 proj-2(e3)的插入顺序:e3 在 e2 之前?
|
||||||
|
// 插入顺序:e1(proj-1), e2(proj-1), e3(proj-2) → e3 的 created_at 最大。
|
||||||
|
// 故 proj-2 的 latest 应 ≥ proj-1 的 latest。
|
||||||
|
assert!(
|
||||||
|
proj2_latest >= proj1_latest,
|
||||||
|
"proj-2(e3 最后插)created_at 应 ≥ proj-1: proj2={} proj1={}",
|
||||||
|
proj2_latest,
|
||||||
|
proj1_latest
|
||||||
|
);
|
||||||
|
|
||||||
|
// 无事件项目不在 map(清空重建场景)
|
||||||
|
let empty_map_empty = repo.latest_activity_per_project().await.unwrap();
|
||||||
|
assert!(!empty_map_empty.contains_key("no-such"));
|
||||||
|
}
|
||||||
|
|
||||||
/// entity_type/entity_id 为 None(纯决策日志无明确实体)也能正常插入与查询。
|
/// entity_type/entity_id 为 None(纯决策日志无明确实体)也能正常插入与查询。
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn insert_with_null_entity_fields() {
|
async fn insert_with_null_entity_fields() {
|
||||||
|
|||||||
@@ -72,6 +72,22 @@ fn build_order_clause(order_by: Option<&str>) -> Result<String> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 带「最近活跃时间」的项目记录(list_active_with_activity 专用返回结构)。
|
||||||
|
///
|
||||||
|
/// `last_active_at` = COALESCE(project_events 最新事件 created_at, projects.updated_at):
|
||||||
|
/// 反映业务活跃(任务/灵感/状态推进等事件),无事件项目回退 updated_at。供前端「最近活跃排序」
|
||||||
|
/// 展示,语义比 projects.updated_at(随元信息修改如改 description 也会刷新)更准确。
|
||||||
|
///
|
||||||
|
/// Serialize 供 IPC 层直接序列化回前端(serde 字段名 snake_case,对齐 ProjectRecord)。
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct ProjectActivityRecord {
|
||||||
|
/// 项目完整记录(嵌套序列化:record.id / record.name ...)
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub record: ProjectRecord,
|
||||||
|
/// 最近活跃时间(毫秒字符串)。前端独立消费,不混入 record 的 updated_at。
|
||||||
|
pub last_active_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// from_row 辅助函数
|
// from_row 辅助函数
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -204,6 +220,51 @@ impl ProjectRepo {
|
|||||||
.map_err(storage_err)?
|
.map_err(storage_err)?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 列出未删除项目,**按「最近活跃」排序**(LEFT JOIN project_events 取最新事件时间,
|
||||||
|
/// COALESCE 回退 updated_at)。
|
||||||
|
///
|
||||||
|
/// 业务语义:项目「最近活跃」应反映业务事件(任务/灵感/状态推进),而非 projects.updated_at
|
||||||
|
/// (后者随元信息修改如改 description/path 也会刷新,不反映真实业务活跃)。本方法通过子查询
|
||||||
|
/// 取每个项目 project_events 最新 created_at,无事件项目回退 updated_at,排序列统一可比。
|
||||||
|
///
|
||||||
|
/// 子查询而非 JOIN:project_events 每项目可能 0..N 条,JOIN 会展开需 DISTINCT,子查询
|
||||||
|
/// `(SELECT MAX(created_at) FROM project_events WHERE project_id = projects.id)` 每行一次
|
||||||
|
/// 聚合,语义清晰无笛卡尔积风险。单用户桌面应用项目数小,无性能压力。
|
||||||
|
///
|
||||||
|
/// 返回 `ProjectActivityRecord`(ProjectRecord + last_active_at 字段),供前端排序展示。
|
||||||
|
pub async fn list_active_with_activity(&self) -> Result<Vec<ProjectActivityRecord>> {
|
||||||
|
let conn = self.conn.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let guard = conn.blocking_lock();
|
||||||
|
// COALESCE(子查询最新事件, projects.updated_at):无事件项目回退 updated_at,
|
||||||
|
// 保证所有项目有可比活跃时间。ORDER BY 该列 DESC,同时间 created_at DESC 兜底稳定。
|
||||||
|
let mut stmt = guard
|
||||||
|
.prepare(
|
||||||
|
"SELECT id, name, description, status, idea_id, path, stack, created_at, updated_at, \
|
||||||
|
COALESCE((SELECT MAX(created_at) FROM project_events WHERE project_id = projects.id), \
|
||||||
|
updated_at) AS last_active_at \
|
||||||
|
FROM projects WHERE deleted_at IS NULL \
|
||||||
|
ORDER BY last_active_at DESC, created_at DESC",
|
||||||
|
)
|
||||||
|
.map_err(storage_err)?;
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok(ProjectActivityRecord {
|
||||||
|
record: project_from_row(row)?,
|
||||||
|
last_active_at: row.get("last_active_at")?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.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)?
|
||||||
|
}
|
||||||
|
|
||||||
/// 按条件查询未删除项目(P2/P3:关键词搜索 + 排序 + 分页)。
|
/// 按条件查询未删除项目(P2/P3:关键词搜索 + 排序 + 分页)。
|
||||||
///
|
///
|
||||||
/// 复用 `KnowledgeRepo::search` 动态 WHERE 拼接模式:按可选字段 if-let 拼 SQL 子句 +
|
/// 复用 `KnowledgeRepo::search` 动态 WHERE 拼接模式:按可选字段 if-let 拼 SQL 子句 +
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ pub mod record;
|
|||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub(crate) use record::{audit_tool_call, query_audit_history, record_audit};
|
pub(crate) use record::{audit_tool_call, query_audit_history, record_audit};
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use record::{list_tool_executions, ToolExecutionDto};
|
pub use record::{list_tool_executions, ToolExecutionDto, ToolExecutionPage, ToolExecQuery};
|
||||||
|
|
||||||
// reason 拼装(resolve_project_label / resolve_task_label / build_approval_reason)
|
// reason 拼装(resolve_project_label / resolve_task_label / build_approval_reason)
|
||||||
// 拆至子模块 audit/reason.rs(第一批 helper 抽离,行为零变更)。
|
// 拆至子模块 audit/reason.rs(第一批 helper 抽离,行为零变更)。
|
||||||
|
|||||||
@@ -9,11 +9,11 @@
|
|||||||
//!
|
//!
|
||||||
//! 依赖 audit/utils.rs 的 `truncate_chars` 做参数/结果截断,通过 `super::truncate_chars` 引用。
|
//! 依赖 audit/utils.rs 的 `truncate_chars` 做参数/结果截断,通过 `super::truncate_chars` 引用。
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use tauri::State;
|
use tauri::State;
|
||||||
|
|
||||||
use df_ai::ai_tools::RiskLevel;
|
use df_ai::ai_tools::RiskLevel;
|
||||||
use df_storage::crud::AiToolExecutionRepo;
|
use df_storage::crud::{AiToolExecutionRepo, AuditQuery};
|
||||||
use df_storage::models::AiToolExecutionRecord;
|
use df_storage::models::AiToolExecutionRecord;
|
||||||
use df_types::types::new_id;
|
use df_types::types::new_id;
|
||||||
|
|
||||||
@@ -128,24 +128,78 @@ pub struct ToolExecutionDto {
|
|||||||
pub decided_by: Option<String>,
|
pub decided_by: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 审批历史查询入参(前端透传,空值=不过滤)。
|
||||||
|
///
|
||||||
|
/// 复用 [`AuditQuery`](`df_storage::crud::AuditQuery`) 的字段语义:status/risk_level 精确匹配,
|
||||||
|
/// tool_keyword 走 tool_name LIKE。limit/offset 默认 50/0,storage 层钳制 limit ≤ 200。
|
||||||
|
///
|
||||||
|
/// `Deserialize`:Tauri IPC 从前端 JSON 反序列化。
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
|
pub struct ToolExecQuery {
|
||||||
|
/// 状态精确匹配(pending/approved/rejected/executing/completed/failed/interrupted)
|
||||||
|
pub status: Option<String>,
|
||||||
|
/// 风险等级精确匹配(low/medium/high)
|
||||||
|
pub risk_level: Option<String>,
|
||||||
|
/// 工具名关键词(tool_name LIKE %kw%)
|
||||||
|
pub tool_keyword: Option<String>,
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
pub offset: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ToolExecQuery> for AuditQuery {
|
||||||
|
fn from(q: ToolExecQuery) -> Self {
|
||||||
|
AuditQuery {
|
||||||
|
status: q.status,
|
||||||
|
risk_level: q.risk_level,
|
||||||
|
tool_keyword: q.tool_keyword,
|
||||||
|
limit: q.limit,
|
||||||
|
offset: q.offset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 审批历史分页结果(对标项目通用 `{items,total,has_more}` 结构)。
|
||||||
|
///
|
||||||
|
/// - `items`:当前页审计 DTO 列表
|
||||||
|
/// - `total`:满足筛选条件的总行数(忽略分页裁剪,前端用于"第 N 页 / 共 M 条"展示)
|
||||||
|
/// - `has_more`:基于 `loaded < total` 推断,而非"本页是否满 limit"启发式
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ToolExecutionPage {
|
||||||
|
pub items: Vec<ToolExecutionDto>,
|
||||||
|
pub total: i64,
|
||||||
|
pub has_more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
/// 审批历史面板查询:按 requested_at 倒序(最新在前)分页返回工具调用审计记录。
|
/// 审批历史面板查询:按 requested_at 倒序(最新在前)分页返回工具调用审计记录。
|
||||||
///
|
///
|
||||||
|
/// 支持 status / risk_level / 工具名关键词筛选(WHERE 在后端收口,非前端 filter 当前页)。
|
||||||
/// 默认 limit=50 / offset=0(第一页)。limit 在 storage 层钳制 ≤200 防滥用。
|
/// 默认 limit=50 / offset=0(第一页)。limit 在 storage 层钳制 ≤200 防滥用。
|
||||||
/// 敏感字段(arguments/result)截断成摘要返回,完整原值仍留库。
|
/// 敏感字段(arguments/result)截断成摘要返回,完整原值仍留库。
|
||||||
|
///
|
||||||
|
/// 返回 `{items,total,has_more}`:total 为满足筛选条件的真实总数(独立 COUNT 查询),
|
||||||
|
/// has_more 基于 `offset + items.len() < total` 推断。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn list_tool_executions(
|
pub async fn list_tool_executions(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
limit: Option<u32>,
|
query: Option<ToolExecQuery>,
|
||||||
offset: Option<u32>,
|
) -> Result<ToolExecutionPage, String> {
|
||||||
) -> Result<Vec<ToolExecutionDto>, String> {
|
let q = query.unwrap_or_default();
|
||||||
let limit = limit.unwrap_or(50);
|
let limit = q.limit.unwrap_or(50);
|
||||||
let offset = offset.unwrap_or(0);
|
let offset = q.offset.unwrap_or(0);
|
||||||
|
let audit_q = AuditQuery::from(q);
|
||||||
|
|
||||||
let records = state
|
let records = state
|
||||||
.ai_tool_executions
|
.ai_tool_executions
|
||||||
.list_recent(limit, offset)
|
.list_by_query(&audit_q)
|
||||||
.await
|
.await
|
||||||
.map_err(err_str)?;
|
.map_err(err_str)?;
|
||||||
Ok(records
|
let total = state
|
||||||
|
.ai_tool_executions
|
||||||
|
.count_by_query(&audit_q)
|
||||||
|
.await
|
||||||
|
.map_err(err_str)?;
|
||||||
|
|
||||||
|
let items: Vec<ToolExecutionDto> = records
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| ToolExecutionDto {
|
.map(|r| ToolExecutionDto {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -160,5 +214,12 @@ pub async fn list_tool_executions(
|
|||||||
executed_at: r.executed_at,
|
executed_at: r.executed_at,
|
||||||
decided_by: r.decided_by,
|
decided_by: r.decided_by,
|
||||||
})
|
})
|
||||||
.collect())
|
.collect();
|
||||||
|
let has_more = (offset as i64 + items.len() as i64) < total;
|
||||||
|
|
||||||
|
Ok(ToolExecutionPage {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
has_more,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -625,9 +625,11 @@ async fn route_list_skills(state: &State<'_, AppState>) {
|
|||||||
/// `state.clone()`(tauri::State Clone,对齐 route_send_message:468-478 ai_chat_send 调用模式)。
|
/// `state.clone()`(tauri::State Clone,对齐 route_send_message:468-478 ai_chat_send 调用模式)。
|
||||||
/// 不传 query/project_id/status 即走 list_active/list_by_query 空 query 全量等价路径。
|
/// 不传 query/project_id/status 即走 list_active/list_by_query 空 query 全量等价路径。
|
||||||
async fn route_list_entities(state: &State<'_, AppState>) {
|
async fn route_list_entities(state: &State<'_, AppState>) {
|
||||||
// list_projects(None query) → list_active 全量(deleted_at IS NULL + created_at DESC)。
|
// list_projects(None query) → list_active_with_activity(问题3:按最近活跃排序,
|
||||||
let projects = match list_projects(state.clone(), None).await {
|
// 返回带 last_active_at 的 ProjectActivityRecord)。本路由仅透传项目清单给 miniapp
|
||||||
Ok(ps) => ps,
|
// 联想浮层(消费 ProjectRecord 字段),不消费 last_active_at,故剥出 .record 还原类型。
|
||||||
|
let projects: Vec<df_storage::models::ProjectRecord> = match list_projects(state.clone(), None).await {
|
||||||
|
Ok(ps) => ps.into_iter().map(|a| a.record).collect(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!(error = %e, "[remote_bridge] list_entities list_projects 失败,降级空 Vec");
|
tracing::warn!(error = %e, "[remote_bridge] list_entities list_projects 失败,降级空 Vec");
|
||||||
Vec::new()
|
Vec::new()
|
||||||
|
|||||||
@@ -40,9 +40,10 @@ fn default_limit() -> u32 {
|
|||||||
/// 外层补 total 便于前端分页/计数。
|
/// 外层补 total 便于前端分页/计数。
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct TimelineResult {
|
pub struct TimelineResult {
|
||||||
/// 过滤后的事件列表(时间倒序,Repo 已排)
|
/// 过滤后的事件列表(时间倒序,Repo 已排;按 input.limit 截断)
|
||||||
pub items: Vec<ProjectEventRecord>,
|
pub items: Vec<ProjectEventRecord>,
|
||||||
/// 过滤后总数(≤ limit)
|
/// 过滤后**真实总数**(M18:不受 input.limit 截断,反映某项目某 event_type 的完整事件量,
|
||||||
|
/// 供前端分页计数;items.len ≤ total,只有当 total ≤ limit 时两者相等)
|
||||||
pub total: usize,
|
pub total: usize,
|
||||||
/// 查询的项目 ID(回显)
|
/// 查询的项目 ID(回显)
|
||||||
pub project_id: String,
|
pub project_id: String,
|
||||||
@@ -70,10 +71,15 @@ pub async fn get_project_timeline(
|
|||||||
.filter(|s| !s.is_empty())
|
.filter(|s| !s.is_empty())
|
||||||
.map(|s| s.to_string());
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
// Repo 取最近 limit 条(内部钳制 ≤ 200),时间倒序。
|
// Repo 取较宽窗口(内部钳制 ≤ 200),时间倒序。M18:不再用 input.limit 直接作 SQL LIMIT——
|
||||||
|
// 应用层 event_type 过滤在 SQL LIMIT 之后,若直接 LIMIT N 会先取 N 条混合类型再过滤,
|
||||||
|
// 过滤后可能远少于 N(类型稀疏时几乎全空),total 也失真。改为取较宽窗口(上限 200,与 Repo
|
||||||
|
// 钳制上限一致),先 event_type 过滤,再按 input.limit 截断,使 total 反映过滤后真实总数
|
||||||
|
// (而非过滤后被 limit 截断的 items.len)。
|
||||||
|
let fetch_window = 200u32;
|
||||||
let mut events = state
|
let mut events = state
|
||||||
.project_events
|
.project_events
|
||||||
.get_by_project(&project_id, input.limit)
|
.get_by_project(&project_id, fetch_window)
|
||||||
.await
|
.await
|
||||||
.map_err(err_str)?;
|
.map_err(err_str)?;
|
||||||
|
|
||||||
@@ -82,7 +88,15 @@ pub async fn get_project_timeline(
|
|||||||
events.retain(|e| e.event_type == *et);
|
events.retain(|e| e.event_type == *et);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// M18:total = 过滤后真实总数(反映某项目某类型的完整事件量,供前端分页计数),
|
||||||
|
// 不受 input.limit 截断影响。
|
||||||
let total = events.len();
|
let total = events.len();
|
||||||
|
// 再按 input.limit 截断返回的 items(默认 50,与文档一致)。
|
||||||
|
let limit = input.limit.min(200) as usize;
|
||||||
|
if events.len() > limit {
|
||||||
|
events.truncate(limit);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(TimelineResult {
|
Ok(TimelineResult {
|
||||||
items: events,
|
items: events,
|
||||||
total,
|
total,
|
||||||
|
|||||||
@@ -279,6 +279,9 @@ struct GitStatus {
|
|||||||
changed_files: Vec<GitChangedFile>,
|
changed_files: Vec<GitChangedFile>,
|
||||||
/// 最近 10 条提交
|
/// 最近 10 条提交
|
||||||
recent_commits: Vec<GitRecentCommit>,
|
recent_commits: Vec<GitRecentCommit>,
|
||||||
|
/// 当前 HEAD 的全量提交数(`git rev-list --count HEAD`)。
|
||||||
|
/// 前端历史 Tab 徽标 / 分支栏计数用它,而非 recent_commits.len()(后者受分页限制)。
|
||||||
|
total_commits: i64,
|
||||||
/// 该目录是否为 Git 仓库(无 .git 时 false,其余字段空)
|
/// 该目录是否为 Git 仓库(无 .git 时 false,其余字段空)
|
||||||
is_git_repo: bool,
|
is_git_repo: bool,
|
||||||
}
|
}
|
||||||
@@ -289,6 +292,7 @@ fn empty_status() -> GitStatus {
|
|||||||
branch: String::new(),
|
branch: String::new(),
|
||||||
changed_files: Vec::new(),
|
changed_files: Vec::new(),
|
||||||
recent_commits: Vec::new(),
|
recent_commits: Vec::new(),
|
||||||
|
total_commits: 0,
|
||||||
is_git_repo: false,
|
is_git_repo: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -447,10 +451,19 @@ fn run_git_status(dir: &str) -> GitStatus {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4) 全量提交计数:`git rev-list --count HEAD`(供前端历史 Tab 徽标真实总数,
|
||||||
|
// 非 recent_commits.len() 后者上限 50)。命令失败 → 退化为 recent_commits 长度。
|
||||||
|
let total_commits = run_git_cmd(path, &["rev-list", "--count", "HEAD"], timeout)
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.and_then(|s| s.parse::<i64>().ok())
|
||||||
|
.unwrap_or(recent_commits.len() as i64);
|
||||||
|
|
||||||
GitStatus {
|
GitStatus {
|
||||||
branch,
|
branch,
|
||||||
changed_files,
|
changed_files,
|
||||||
recent_commits,
|
recent_commits,
|
||||||
|
total_commits,
|
||||||
is_git_repo: true,
|
is_git_repo: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -949,7 +962,8 @@ fn collect_git_status_map(dir: &str) -> HashMap<String, String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 查询工程 Git 提交历史(分页,按时间倒序)。
|
/// 查询工程 Git 提交历史(分页,按时间倒序)。
|
||||||
/// 返回 { commits: [{ hash, subject, timestamp }], has_more: bool }。
|
/// 返回 { commits: [{ hash, subject, timestamp, author }], has_more: bool, total: i64 }。
|
||||||
|
/// total = `git rev-list --count HEAD` 的全量提交数,供前端徽标真实总数(commits.len() 受分页限制)。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_module_commits(
|
pub async fn get_module_commits(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -969,7 +983,7 @@ pub async fn get_module_commits(
|
|||||||
.ok_or_else(|| format!("工程 {module_id} 不存在"))?;
|
.ok_or_else(|| format!("工程 {module_id} 不存在"))?;
|
||||||
let path = std::path::Path::new(&module.path);
|
let path = std::path::Path::new(&module.path);
|
||||||
if !path.join(".git").exists() {
|
if !path.join(".git").exists() {
|
||||||
return Ok(serde_json::json!({ "commits": [], "has_more": false }));
|
return Ok(serde_json::json!({ "commits": [], "has_more": false, "total": 0 }));
|
||||||
}
|
}
|
||||||
let skip = skip.unwrap_or(0);
|
let skip = skip.unwrap_or(0);
|
||||||
let fetch = limit.unwrap_or(50);
|
let fetch = limit.unwrap_or(50);
|
||||||
@@ -978,16 +992,18 @@ pub async fn get_module_commits(
|
|||||||
let dir = module.path.clone();
|
let dir = module.path.clone();
|
||||||
let dir_for_git = dir.clone();
|
let dir_for_git = dir.clone();
|
||||||
// git 命令在 spawn_blocking 中执行(阻塞 IO 不污染 async runtime),10s 超时
|
// git 命令在 spawn_blocking 中执行(阻塞 IO 不污染 async runtime),10s 超时
|
||||||
let commits: Vec<serde_json::Value> = tokio::task::spawn_blocking(move || -> Vec<serde_json::Value> {
|
let (commits, total): (Vec<serde_json::Value>, i64) = tokio::task::spawn_blocking(move || -> (Vec<serde_json::Value>, i64) {
|
||||||
|
let path = std::path::Path::new(&dir_for_git);
|
||||||
|
let timeout = std::time::Duration::from_secs(10);
|
||||||
let out = run_git_cmd(
|
let out = run_git_cmd(
|
||||||
std::path::Path::new(&dir_for_git),
|
path,
|
||||||
&[
|
&[
|
||||||
"log",
|
"log",
|
||||||
&format!("--skip={}", skip),
|
&format!("--skip={}", skip),
|
||||||
&format!("-{}", fetch_plus),
|
&format!("-{}", fetch_plus),
|
||||||
"--format=%h %ct %an %s",
|
"--format=%h %ct %an %s",
|
||||||
],
|
],
|
||||||
std::time::Duration::from_secs(10),
|
timeout,
|
||||||
)
|
)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let mut commits: Vec<serde_json::Value> = Vec::new();
|
let mut commits: Vec<serde_json::Value> = Vec::new();
|
||||||
@@ -1010,7 +1026,14 @@ pub async fn get_module_commits(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
commits
|
// 全量提交计数(`git rev-list --count HEAD`):前端历史 Tab 徽标真实总数。
|
||||||
|
// 命令失败 → 退化为 0(前端会显示 0,但 has_more 仍可驱动分页)。
|
||||||
|
let total = run_git_cmd(path, &["rev-list", "--count", "HEAD"], timeout)
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.and_then(|s| s.parse::<i64>().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
(commits, total)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("提交历史查询任务失败: {e}"))?;
|
.map_err(|e| format!("提交历史查询任务失败: {e}"))?;
|
||||||
@@ -1023,6 +1046,7 @@ pub async fn get_module_commits(
|
|||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"commits": returned,
|
"commits": returned,
|
||||||
"has_more": has_more,
|
"has_more": has_more,
|
||||||
|
"total": total,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use df_project::scan::{
|
|||||||
collect_sample, detect_stack, discover_projects, extract_description,
|
collect_sample, detect_stack, discover_projects, extract_description,
|
||||||
normalize_path, DiscoveredProject,
|
normalize_path, DiscoveredProject,
|
||||||
};
|
};
|
||||||
use df_storage::crud::ProjectQuery;
|
use df_storage::crud::{ProjectActivityRecord, ProjectQuery};
|
||||||
use df_storage::models::{ProjectEventRecord, ProjectRecord};
|
use df_storage::models::{ProjectEventRecord, ProjectRecord};
|
||||||
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
@@ -80,22 +80,49 @@ pub struct CreateProjectInput {
|
|||||||
///
|
///
|
||||||
/// F-260621-02:吃可选 query(关键词/排序/分页),向后兼容——不传(或 None)走 list_active
|
/// F-260621-02:吃可选 query(关键词/排序/分页),向后兼容——不传(或 None)走 list_active
|
||||||
/// 全量(deleted_at IS NULL + created_at DESC),零破坏;传 query 走 list_by_query 动态 WHERE。
|
/// 全量(deleted_at IS NULL + created_at DESC),零破坏;传 query 走 list_by_query 动态 WHERE。
|
||||||
|
///
|
||||||
|
/// 问题3(项目最近活跃排序):默认无 query 路径改走 list_active_with_activity,返回
|
||||||
|
/// 带 `last_active_at` 字段的 `ProjectActivityRecord`(COALESCE project_events 最新事件,
|
||||||
|
/// projects.updated_at 回退),按业务活跃排序而非元信息修改时间。query 路径(关键词/分页)
|
||||||
|
/// 同样补 last_active_at 字段(查 map 填充,无事件回退 updated_at),保持前端契约统一。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn list_projects(
|
pub async fn list_projects(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
query: Option<ProjectQuery>,
|
query: Option<ProjectQuery>,
|
||||||
) -> Result<Vec<ProjectRecord>, String> {
|
) -> Result<Vec<ProjectActivityRecord>, String> {
|
||||||
match query {
|
match query {
|
||||||
// None 或全空 query(trim 后 keyword 空 + 无 order_by/limit/offset)→ 走 list_active,
|
// None 或全空 query(trim 后 keyword 空 + 无 order_by/limit/offset)→ 走 list_active_with_activity,
|
||||||
// 与历史行为完全等价(列表上 list_by_query 空也等价,但保留 list_active 分支明示向后兼容契约)。
|
// 按最近活跃排序(问题3)。list_active 分支语义已被 list_active_with_activity 覆盖
|
||||||
|
// (后者亦返 deleted_at IS NULL 的全部项目,仅多了 last_active_at 字段 + 排序键)。
|
||||||
Some(q) if q.keyword.as_deref().map(str::trim).is_some_and(|k| !k.is_empty())
|
Some(q) if q.keyword.as_deref().map(str::trim).is_some_and(|k| !k.is_empty())
|
||||||
|| q.order_by.is_some()
|
|| q.order_by.is_some()
|
||||||
|| q.limit.is_some()
|
|| q.limit.is_some()
|
||||||
|| q.offset.is_some() =>
|
|| q.offset.is_some() =>
|
||||||
{
|
{
|
||||||
state.projects.list_by_query(q).await.map_err(err_str)
|
// query 路径:list_by_query 返 ProjectRecord,补 last_active_at 字段。
|
||||||
|
// 单次拉全项目最新事件 map,逐条 COALESCE 填充,无事件回退 updated_at。
|
||||||
|
let records = state.projects.list_by_query(q).await.map_err(err_str)?;
|
||||||
|
let activity = state
|
||||||
|
.project_events
|
||||||
|
.latest_activity_per_project()
|
||||||
|
.await
|
||||||
|
.map_err(err_str)?;
|
||||||
|
let result = records
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
let last_active_at = activity
|
||||||
|
.get(&r.id)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| r.updated_at.clone());
|
||||||
|
ProjectActivityRecord {
|
||||||
|
record: r,
|
||||||
|
last_active_at,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(result)
|
||||||
}
|
}
|
||||||
_ => state.projects.list_active().await.map_err(err_str),
|
_ => state.projects.list_active_with_activity().await.map_err(err_str),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,7 +549,7 @@ pub struct ImportBatchResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 单条批量导入入参
|
/// 单条批量导入入参
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
pub struct ImportBatchItemInput {
|
pub struct ImportBatchItemInput {
|
||||||
pub path: String,
|
pub path: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -538,6 +565,10 @@ pub struct ImportBatchItemInput {
|
|||||||
///
|
///
|
||||||
/// 限流:llm_concurrency 双层 permit(global + per_conv)防止批量扫描打满 provider。
|
/// 限流:llm_concurrency 双层 permit(global + per_conv)防止批量扫描打满 provider。
|
||||||
/// 默认 planning 状态(对齐 create_project),不关联 idea。
|
/// 默认 planning 状态(对齐 create_project),不关联 idea。
|
||||||
|
///
|
||||||
|
/// L16:并发上限分块(CHUNK_SIZE=4)——LLM 调用经双层 permit 限流,但 create_with_binding 内
|
||||||
|
/// 非 LLM IO(detect_stack/canonicalize/DB 查询/insert/reload_allowed_dirs 读全表)无全局限流,
|
||||||
|
/// 全量并发会产生调度/DB 锁竞争。分块串行处理 chunk、chunk 内并发,结果等价仅削峰。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn import_projects_batch(
|
pub async fn import_projects_batch(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
@@ -568,48 +599,63 @@ pub async fn import_projects_batch(
|
|||||||
|
|
||||||
// 每项独立 future,并发 join。失败逐项记录不影响其它。
|
// 每项独立 future,并发 join。失败逐项记录不影响其它。
|
||||||
// 注:provider 通过 Arc clone 在各 future 间共享(零拷贝,引用计数)。
|
// 注:provider 通过 Arc clone 在各 future 间共享(零拷贝,引用计数)。
|
||||||
let futures: Vec<_> = items
|
//
|
||||||
.into_iter()
|
// L16:批量上限分块(chunk)。原 join_all 全量并发,虽 LLM 调用经 llm_concurrency 双层 permit
|
||||||
.map(|item| {
|
// 限流(global + per_conv),但 create_with_binding 内仍有重 IO(spawn_blocking detect_stack /
|
||||||
let state_ref = state.inner();
|
// normalize_path canonicalize / find_binding_conflict DB 查询 / insert project+module /
|
||||||
let provider = provider.clone();
|
// reload_allowed_dirs 读全表 projects.path)。勾选数十项时全量并发会让这些非 LLM 操作同时
|
||||||
let pc = pc.clone();
|
// 入队,产生 tokio 任务调度压力 + DB 锁竞争排队(reload_allowed_dirs 读全量 projects 表 × N)。
|
||||||
async move {
|
// 改分块串行处理各 chunk、chunk 内并发:结果与全量并发等价(每项独立无依赖,顺序不影响结果),
|
||||||
let path = item.path.trim().to_string();
|
// 仅削平调度/DB 压力峰值。CHUNK_SIZE=4(对齐常见 4 核,与 LLM 限流槽位数同量级)。
|
||||||
if path.is_empty() {
|
const IMPORT_BATCH_CHUNK_SIZE: usize = 4;
|
||||||
return ImportBatchItemResult {
|
let mut results: Vec<ImportBatchItemResult> = Vec::with_capacity(items.len());
|
||||||
path,
|
// 分块:chunk 内并发 join,chunk 间串行 await,结果按原顺序聚合(与全量 join_all 等价顺序)。
|
||||||
name: None,
|
for chunk in items.chunks(IMPORT_BATCH_CHUNK_SIZE) {
|
||||||
error: Some("路径为空".to_string()),
|
let chunk_futures: Vec<_> = chunk
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|item| {
|
||||||
|
let state_ref = state.inner();
|
||||||
|
let provider = provider.clone();
|
||||||
|
let pc = pc.clone();
|
||||||
|
async move {
|
||||||
|
let path = item.path.trim().to_string();
|
||||||
|
if path.is_empty() {
|
||||||
|
return ImportBatchItemResult {
|
||||||
|
path,
|
||||||
|
name: None,
|
||||||
|
error: Some("路径为空".to_string()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// 走 scan_project_with_ai 同款「探测+采样+LLM 抽 description」(轻量子代理)
|
||||||
|
let desc = match extract_description_via_llm(state_ref, &provider, &pc, &path).await {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(e) => {
|
||||||
|
// LLM 失败/降级:description 留空,但仍入库(用户手填)。记录原因。
|
||||||
|
tracing::warn!("批量导入 LLM 抽 description 失败 path={path} err={e}");
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
let want_name = item.name.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(String::from);
|
||||||
// 走 scan_project_with_ai 同款「探测+采样+LLM 抽 description」(轻量子代理)
|
match create_with_binding(state_ref, resolve_name(&path, want_name), desc, None, Some(path.clone()), None).await {
|
||||||
let desc = match extract_description_via_llm(state_ref, &provider, &pc, &path).await {
|
Ok(rec) => ImportBatchItemResult {
|
||||||
Ok(d) => d,
|
path,
|
||||||
Err(e) => {
|
name: Some(rec.name),
|
||||||
// LLM 失败/降级:description 留空,但仍入库(用户手填)。记录原因。
|
error: None,
|
||||||
tracing::warn!("批量导入 LLM 抽 description 失败 path={path} err={e}");
|
},
|
||||||
String::new()
|
Err(e) => ImportBatchItemResult {
|
||||||
|
path,
|
||||||
|
name: None,
|
||||||
|
error: Some(e),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
};
|
|
||||||
let want_name = item.name.as_deref().map(str::trim).filter(|s| !s.is_empty()).map(String::from);
|
|
||||||
match create_with_binding(state_ref, resolve_name(&path, want_name), desc, None, Some(path.clone()), None).await {
|
|
||||||
Ok(rec) => ImportBatchItemResult {
|
|
||||||
path,
|
|
||||||
name: Some(rec.name),
|
|
||||||
error: None,
|
|
||||||
},
|
|
||||||
Err(e) => ImportBatchItemResult {
|
|
||||||
path,
|
|
||||||
name: None,
|
|
||||||
error: Some(e),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
})
|
.collect();
|
||||||
.collect();
|
let chunk_results = futures::future::join_all(chunk_futures).await;
|
||||||
|
results.extend(chunk_results);
|
||||||
|
}
|
||||||
|
|
||||||
let results = futures::future::join_all(futures).await;
|
|
||||||
let imported = results.iter().filter(|r| r.name.is_some()).count();
|
let imported = results.iter().filter(|r| r.name.is_some()).count();
|
||||||
let skipped = results.len() - imported;
|
let skipped = results.len() - imported;
|
||||||
Ok(ImportBatchResult {
|
Ok(ImportBatchResult {
|
||||||
|
|||||||
@@ -408,6 +408,15 @@ pub async fn update_task(
|
|||||||
}
|
}
|
||||||
// B-260801-01(P0-1):update_field 返 affected>0;false = id 不存在或已软删(0 行)。
|
// B-260801-01(P0-1):update_field 返 affected>0;false = id 不存在或已软删(0 行)。
|
||||||
// 不可静默返 false——前端 store.runWithCatch 把 Err 转 toast,而 false 会被忽略致假成功。
|
// 不可静默返 false——前端 store.runWithCatch 把 Err 转 toast,而 false 会被忽略致假成功。
|
||||||
|
//
|
||||||
|
// 任务字段更新是高频「项目活跃」信号(改 title/priority/assignee 等),埋点 task_updated
|
||||||
|
// 推动项目最近活跃排序反映真实业务(问题3)。best-effort 不阻断。
|
||||||
|
// 读当前 project_id(一次轻量读):update_field 返 bool 不带 project_id,无法直接埋点。
|
||||||
|
let current = state
|
||||||
|
.tasks
|
||||||
|
.get_by_id(&id)
|
||||||
|
.await
|
||||||
|
.map_err(err_str)?;
|
||||||
let updated = state
|
let updated = state
|
||||||
.tasks
|
.tasks
|
||||||
.update_field(&id, &field, &value)
|
.update_field(&id, &field, &value)
|
||||||
@@ -416,19 +425,68 @@ pub async fn update_task(
|
|||||||
if !updated {
|
if !updated {
|
||||||
return Err(format!("任务 ID {id} 不存在或已删除"));
|
return Err(format!("任务 ID {id} 不存在或已删除"));
|
||||||
}
|
}
|
||||||
|
if let Some(rec) = current {
|
||||||
|
emit_event(
|
||||||
|
&state,
|
||||||
|
&rec.project_id,
|
||||||
|
"task_updated",
|
||||||
|
Some("task"),
|
||||||
|
Some(&rec.id),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除任务(软删 → 回收站,可恢复)。对标 delete_project(SET deleted_at=now)。
|
/// 删除任务(软删 → 回收站,可恢复)。对标 delete_project(SET deleted_at=now)。
|
||||||
|
///
|
||||||
|
/// 埋点 task_deleted(问题3 项目最近活跃排序):删除是业务事件,推动项目活跃时间。
|
||||||
|
/// best-effort 不阻断。读 project_id 一次轻量读(soft_delete 返 bool 不带 project_id)。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn delete_task(state: State<'_, AppState>, id: String) -> Result<bool, String> {
|
pub async fn delete_task(state: State<'_, AppState>, id: String) -> Result<bool, String> {
|
||||||
state.tasks.soft_delete(&id).await.map_err(err_str)
|
let rec = state.tasks.get_by_id(&id).await.map_err(err_str)?;
|
||||||
|
let ok = state.tasks.soft_delete(&id).await.map_err(err_str)?;
|
||||||
|
if ok {
|
||||||
|
if let Some(r) = rec {
|
||||||
|
emit_event(
|
||||||
|
&state,
|
||||||
|
&r.project_id,
|
||||||
|
"task_deleted",
|
||||||
|
Some("task"),
|
||||||
|
Some(&r.id),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ok)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 恢复任务(从回收站还原,清 deleted_at)。对标 restore_project。
|
/// 恢复任务(从回收站还原,清 deleted_at)。对标 restore_project。
|
||||||
|
///
|
||||||
|
/// 埋点 task_restored(问题3 项目最近活跃排序):恢复是业务事件。best-effort 不阻断。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn restore_task(state: State<'_, AppState>, id: String) -> Result<bool, String> {
|
pub async fn restore_task(state: State<'_, AppState>, id: String) -> Result<bool, String> {
|
||||||
state.tasks.restore(&id).await.map_err(err_str)
|
let rec = state.tasks.get_by_id(&id).await.map_err(err_str)?;
|
||||||
|
let ok = state.tasks.restore(&id).await.map_err(err_str)?;
|
||||||
|
if ok {
|
||||||
|
if let Some(r) = rec {
|
||||||
|
emit_event(
|
||||||
|
&state,
|
||||||
|
&r.project_id,
|
||||||
|
"task_restored",
|
||||||
|
Some("task"),
|
||||||
|
Some(&r.id),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(ok)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 推进任务状态(任务推进链 F-260616-02,推进链唯一 status 写入路径)。
|
/// 推进任务状态(任务推进链 F-260616-02,推进链唯一 status 写入路径)。
|
||||||
@@ -741,12 +799,20 @@ pub async fn move_task_queue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):queue 变化事件。best-effort 不阻断。
|
// 知识图谱 Phase 2(对标设计 §2.4 hook/after):queue 变化事件。best-effort 不阻断。
|
||||||
// 仅在 queue 实际变化时埋点(避免 no-op 移动产噪音事件)。from/to 用 queue 值。
|
// 仅在 queue 实际变化时埋点(避免 no-op 移动产噪音事件)。
|
||||||
|
//
|
||||||
|
// M17:用独立 event_type "task_queue_moved"(非复用 "task_advanced"),与 status 推进事件
|
||||||
|
// 语义解耦——advance_task 触发的 task_advanced 用 from_state/to_state 装**执行态**值
|
||||||
|
// (todo→in_progress 等),而 move_task_queue 改的是**管理维度池**(backlog/todo/decision/
|
||||||
|
// active/done),两者正交。混用同一 event_type + 同样的 from/to 字段会让消费者无法区分
|
||||||
|
// 「任务执行推进」与「跨池移动」,过滤 task_advanced 的事件流会混入 queue 漂移噪音。
|
||||||
|
// 独立 type 后:status 推进走 task_advanced,queue 移动走 task_queue_moved,语义自洽,
|
||||||
|
// from/to 字段各自装对应维度的值(queue_moved 装 queue 值),不冲突。
|
||||||
if current.queue != new_queue {
|
if current.queue != new_queue {
|
||||||
emit_event(
|
emit_event(
|
||||||
&state,
|
&state,
|
||||||
¤t.project_id,
|
¤t.project_id,
|
||||||
"task_advanced",
|
"task_queue_moved",
|
||||||
Some("task"),
|
Some("task"),
|
||||||
Some(¤t.id),
|
Some(¤t.id),
|
||||||
Some(¤t.queue),
|
Some(¤t.queue),
|
||||||
|
|||||||
@@ -423,12 +423,23 @@ pub async fn run_workflow_inner(
|
|||||||
Ok(execution_id.to_string())
|
Ok(execution_id.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 列出全部工作流执行记录
|
/// 列出工作流执行记录(最近 N 条,对标设计 §10.5 单用户桌面应用分页)。
|
||||||
|
///
|
||||||
|
/// M19:原实现调 `list_all()` 无 limit/分页,工作流执行记录随使用累积(workflow_executions 表
|
||||||
|
/// 只插不删,每次 run_workflow 落一条),长期使用后全量返回会撑爆前端列表 + IPC 传输 + 内存。
|
||||||
|
/// 加可选 `limit` 参数(默认 100,钳制 ≤ 500),list_all 已按 created_at DESC 排序,截断取最近 N 条。
|
||||||
|
/// 前端旧调用方不传 limit 走默认 100,零破坏(工作流历史本就按时间倒序展示,截断尾部老记录无感)。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn list_workflow_executions(
|
pub async fn list_workflow_executions(
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
|
limit: Option<u32>,
|
||||||
) -> Result<Vec<WorkflowRecord>, String> {
|
) -> Result<Vec<WorkflowRecord>, String> {
|
||||||
state.workflows.list_all().await.map_err(err_str)
|
let safe_limit = limit.unwrap_or(100).min(500) as usize;
|
||||||
|
let mut records = state.workflows.list_all().await.map_err(err_str)?;
|
||||||
|
if records.len() > safe_limit {
|
||||||
|
records.truncate(safe_limit);
|
||||||
|
}
|
||||||
|
Ok(records)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 按 ID 查询工作流执行记录
|
/// 按 ID 查询工作流执行记录
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ export interface GitStatusResult {
|
|||||||
branch: string
|
branch: string
|
||||||
changed_files: GitChangedFile[]
|
changed_files: GitChangedFile[]
|
||||||
recent_commits: GitRecentCommit[]
|
recent_commits: GitRecentCommit[]
|
||||||
|
/** 当前 HEAD 的全量提交数(`git rev-list --count HEAD`)。0 = 非 git 仓库或命令失败。 */
|
||||||
|
total_commits: number
|
||||||
is_git_repo: boolean
|
is_git_repo: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +193,8 @@ export const moduleApi = {
|
|||||||
getModuleCommits(moduleId: string, skip?: number, limit?: number): Promise<{
|
getModuleCommits(moduleId: string, skip?: number, limit?: number): Promise<{
|
||||||
commits: { hash: string; subject: string; timestamp: number; author: string }[]
|
commits: { hash: string; subject: string; timestamp: number; author: string }[]
|
||||||
has_more: boolean
|
has_more: boolean
|
||||||
|
/** 当前 HEAD 的全量提交数(`git rev-list --count HEAD`)。0 = 非 git 仓库或命令失败。 */
|
||||||
|
total: number
|
||||||
}> {
|
}> {
|
||||||
return invoke('get_module_commits', { moduleId, skip: skip ?? 0, limit: limit ?? 50 })
|
return invoke('get_module_commits', { moduleId, skip: skip ?? 0, limit: limit ?? 50 })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -104,6 +104,12 @@ export interface ProjectRecord {
|
|||||||
stack: string | null
|
stack: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
|
/**
|
||||||
|
* 最近活跃时间(问题3):COALESCE(project_events 最新事件 created_at, updated_at)。
|
||||||
|
* 反映业务活跃(任务/灵感/状态推进等事件),无事件回退 updated_at。
|
||||||
|
* 可选(旧后端/回收站等路径不返):消费方需用 ?? updated_at 兜底。
|
||||||
|
*/
|
||||||
|
last_active_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateProjectInput {
|
export interface CreateProjectInput {
|
||||||
|
|||||||
@@ -15,7 +15,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
withDefaults(defineProps<{
|
import { onMounted, onBeforeUnmount, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
visible: boolean
|
visible: boolean
|
||||||
msg: string
|
msg: string
|
||||||
/** 危险按钮文案;不传则回退到 common.confirm(语义中性,删除等场景应显式传 common.delete) */
|
/** 危险按钮文案;不传则回退到 common.confirm(语义中性,删除等场景应显式传 common.delete) */
|
||||||
@@ -24,9 +26,35 @@ withDefaults(defineProps<{
|
|||||||
dangerLabel: '',
|
dangerLabel: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
result: [ok: boolean]
|
result: [ok: boolean]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// 键盘交互:可见时监听全局 keydown(Esc=取消,Enter=确认),不可见时移除监听。
|
||||||
|
// 与全屏对话框的 mask 点击同语义,补齐无鼠标可达性。
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (!props.visible) return
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
emit('result', false)
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
// Enter 确认:危险操作(删除)仍走 confirm 分支,父级二次语义由 dangerLabel 区分;
|
||||||
|
// 避免在 input/textarea 内回车误触发(SVG/按钮 mask 场景无输入控件,守卫保留)。
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
if (target && (target.tagName === 'TEXTAREA' || (target.tagName === 'INPUT' && (target as HTMLInputElement).type !== 'submit' && (target as HTMLInputElement).type !== 'button'))) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
emit('result', true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => document.addEventListener('keydown', onKey))
|
||||||
|
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
|
||||||
|
// visible 切换不改监听器(props 读取实时值),watch 占位避免未来静态分析告警
|
||||||
|
watch(() => props.visible, () => {})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -626,7 +626,9 @@ watch(() => props.tc.status, (s) => {
|
|||||||
.ai-tool-diff-line--del { color: var(--df-danger, #f06565); background: var(--df-danger-bg, rgba(240, 101, 101, 0.08)); }
|
.ai-tool-diff-line--del { color: var(--df-danger, #f06565); background: var(--df-danger-bg, rgba(240, 101, 101, 0.08)); }
|
||||||
.ai-tool-diff-line--ctx { color: var(--df-text-dim, #888); }
|
.ai-tool-diff-line--ctx { color: var(--df-text-dim, #888); }
|
||||||
|
|
||||||
/* -- Generic Result -- */
|
/* -- Generic Result --
|
||||||
|
max-height 100px→200px:通用结果常含 JSON/结构化文本,100px 仅约 5 行,默认滚动条挤压可读性;
|
||||||
|
对齐 ai-tool-file-pre--collapsed(180px) 与 ai-tool-dir-entries(220px) 量级。 */
|
||||||
.ai-tool-result {
|
.ai-tool-result {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
@@ -636,7 +638,7 @@ watch(() => props.tc.status, (s) => {
|
|||||||
font-family: var(--df-font-mono);
|
font-family: var(--df-font-mono);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--df-success);
|
color: var(--df-success);
|
||||||
max-height: 100px;
|
max-height: 200px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="ci-status" v-if="statuses.length > 0">
|
<div class="ci-status" v-if="statuses.length > 0">
|
||||||
<div class="ci-header">
|
<div class="ci-header">
|
||||||
<span class="ci-title">CI 检查</span>
|
<span class="ci-title">{{ $t('aiChat.ciTitle') }}</span>
|
||||||
<span class="ci-summary" :class="summaryClass">{{ summaryText }}</span>
|
<span class="ci-summary" :class="summaryClass">{{ summaryText }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="ci-list">
|
<div class="ci-list">
|
||||||
@@ -23,8 +23,11 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, onMounted } from 'vue'
|
import { computed, ref, onMounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
interface CheckStatus {
|
interface CheckStatus {
|
||||||
name: string
|
name: string
|
||||||
status: string
|
status: string
|
||||||
@@ -50,9 +53,9 @@ const summaryText = computed(() => {
|
|||||||
const pass = statuses.value.filter(s => s.status === 'success').length
|
const pass = statuses.value.filter(s => s.status === 'success').length
|
||||||
const fail = statuses.value.filter(s => s.status === 'failure').length
|
const fail = statuses.value.filter(s => s.status === 'failure').length
|
||||||
const pending = statuses.value.filter(s => s.status === 'pending').length
|
const pending = statuses.value.filter(s => s.status === 'pending').length
|
||||||
if (fail > 0) return `${fail} 项失败`
|
if (fail > 0) return t('aiChat.ciNFailed', { n: fail })
|
||||||
if (pending > 0) return `${pending} 项进行中`
|
if (pending > 0) return t('aiChat.ciNPending', { n: pending })
|
||||||
return `${pass} 项通过`
|
return t('aiChat.ciNPassed', { n: pass })
|
||||||
})
|
})
|
||||||
|
|
||||||
function statusIcon(status: string): string {
|
function statusIcon(status: string): string {
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ import SkillMention from './SkillMention.vue'
|
|||||||
import ImageInput from './ImageInput.vue'
|
import ImageInput from './ImageInput.vue'
|
||||||
import MentionPopover from './MentionPopover.vue'
|
import MentionPopover from './MentionPopover.vue'
|
||||||
import EnrichmentPanel from './EnrichmentPanel.vue'
|
import EnrichmentPanel from './EnrichmentPanel.vue'
|
||||||
|
import { taskApi } from '../../api/task'
|
||||||
import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord, MentionSpan } from '../../api/types'
|
import type { AiMessage, ContentPart, SkillInfo, ProjectRecord, TaskRecord, IdeaRecord, MentionSpan } from '../../api/types'
|
||||||
import type { MentionItem } from './MentionItem'
|
import type { MentionItem } from './MentionItem'
|
||||||
|
|
||||||
@@ -323,7 +324,8 @@ function clearSkill() {
|
|||||||
|
|
||||||
// ── UX-10 §1.4: @ 实体引用(@ 触发 popover,选中插入 [类型:名] 标记,后端 system prompt 注入摘要) ──
|
// ── UX-10 §1.4: @ 实体引用(@ 触发 popover,选中插入 [类型:名] 标记,后端 system prompt 注入摘要) ──
|
||||||
// 架构复用 / 技能联想:独立状态(mentionOpen/mentionIndex/mentionQuery),不与 skill 混。
|
// 架构复用 / 技能联想:独立状态(mentionOpen/mentionIndex/mentionQuery),不与 skill 混。
|
||||||
// 联想源只读 projectsStore.projects / projectsStore.tasks(前端 store 现有数据,不新增 IPC)。
|
// 联想源:projects/ideas 读 store;tasks 联想候选读 store.tasks,但 @项目 enrichment
|
||||||
|
// 反查任务走 projectTasksCache(独立 list_tasks,隔离 Tasks 视图筛选,见 P1-g)。
|
||||||
// 触发:输入框光标前最近的 @ 后无空白/换行 → 激活;Esc/光标移开/选中插入 → 关闭。
|
// 触发:输入框光标前最近的 @ 后无空白/换行 → 激活;Esc/光标移开/选中插入 → 关闭。
|
||||||
// Enter 不冲突:popover 开时 Enter 插入选中(覆盖发送),关时 Enter 发送(原逻辑)。
|
// Enter 不冲突:popover 开时 Enter 插入选中(覆盖发送),关时 Enter 发送(原逻辑)。
|
||||||
// MentionItem 接口已抽至 ./MentionItem.ts(MentionPopover 子组件共用)。
|
// MentionItem 接口已抽至 ./MentionItem.ts(MentionPopover 子组件共用)。
|
||||||
@@ -340,6 +342,32 @@ const pendingMentionSpans = ref<MentionSpan[]>([])
|
|||||||
// ── ⑥.4 Phase 4: @项目展开摘要(输入区 enrichment 预览) ──
|
// ── ⑥.4 Phase 4: @项目展开摘要(输入区 enrichment 预览) ──
|
||||||
// 当用户 @[项目:xxx] 后,从 projectsStore 反查项目+关联任务/灵感,
|
// 当用户 @[项目:xxx] 后,从 projectsStore 反查项目+关联任务/灵感,
|
||||||
// 在输入区下方显式 badge + 可展开的上下文参考面板。
|
// 在输入区下方显式 badge + 可展开的上下文参考面板。
|
||||||
|
|
||||||
|
// P1-g: 任务联想源隔离 store.tasks(后者反映 Tasks 视图当前筛选+分页,B-29 契约)。
|
||||||
|
// @项目 enrichment 按项目反查时读 store.tasks 会被当前页筛选污染(只见到筛进来的子集)。
|
||||||
|
// 改为按 project_id 独立 invoke list_tasks({project_id, limit}) + 本地 Map 缓存,
|
||||||
|
// 切项目(projectsStore.projects 列表重载/项目删除)清空缓存防陈旧。
|
||||||
|
const MENTION_TASK_LIMIT = 50
|
||||||
|
const projectTasksCache = ref<Map<string, TaskRecord[]>>(new Map())
|
||||||
|
|
||||||
|
/** 拉取指定项目的任务并缓存(已缓存则跳过);失败静默降级为空数组(不阻塞 enrichment 渲染) */
|
||||||
|
async function ensureProjectTasks(projectId: string): Promise<void> {
|
||||||
|
if (projectTasksCache.value.has(projectId)) return
|
||||||
|
try {
|
||||||
|
const list = await taskApi.list({ project_id: projectId, limit: MENTION_TASK_LIMIT })
|
||||||
|
// 写新 Map(响应式触发:Map 重赋值让 computed 重算,mutate 旧 Map 不可靠)
|
||||||
|
const next = new Map(projectTasksCache.value)
|
||||||
|
next.set(projectId, list)
|
||||||
|
projectTasksCache.value = next
|
||||||
|
} catch (e) {
|
||||||
|
// 失败占位空数组避免反复重试,下次切项目自然清缓存重拉
|
||||||
|
const next = new Map(projectTasksCache.value)
|
||||||
|
next.set(projectId, [])
|
||||||
|
projectTasksCache.value = next
|
||||||
|
console.error('[ChatInput] 加载项目任务失败:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface ProjectEnrichment {
|
interface ProjectEnrichment {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -358,13 +386,14 @@ const projectEnrichments = computed<ProjectEnrichment[]>(() => {
|
|||||||
const projectSpans = pendingMentionSpans.value.filter(sp => sp.kind === 'project')
|
const projectSpans = pendingMentionSpans.value.filter(sp => sp.kind === 'project')
|
||||||
if (projectSpans.length === 0) return []
|
if (projectSpans.length === 0) return []
|
||||||
const projects = (projectsStore.projects || []) as ProjectRecord[]
|
const projects = (projectsStore.projects || []) as ProjectRecord[]
|
||||||
const tasks = (projectsStore.tasks || []) as TaskRecord[]
|
// P1-g: 任务从 projectTasksCache 读(按 project_id 独立拉取),
|
||||||
|
// 不读 store.tasks(后者反映 Tasks 视图筛选+分页,会污染 @项目 enrichment)。
|
||||||
const ideas = (projectsStore.ideas || []) as IdeaRecord[]
|
const ideas = (projectsStore.ideas || []) as IdeaRecord[]
|
||||||
const results: ProjectEnrichment[] = []
|
const results: ProjectEnrichment[] = []
|
||||||
for (const span of projectSpans) {
|
for (const span of projectSpans) {
|
||||||
const project = projects.find(p => p.id === span.refId)
|
const project = projects.find(p => p.id === span.refId)
|
||||||
if (!project) continue
|
if (!project) continue
|
||||||
const relTasks = tasks.filter(t => t.project_id === project.id)
|
const relTasks = (projectTasksCache.value.get(project.id) || []) as TaskRecord[]
|
||||||
// ideas linking to this project via promoted_to (project name matching)
|
// ideas linking to this project via promoted_to (project name matching)
|
||||||
const relIdeas = ideas.filter(i => i.promoted_to === project.name || i.promoted_to === project.id)
|
const relIdeas = ideas.filter(i => i.promoted_to === project.name || i.promoted_to === project.id)
|
||||||
const running = relTasks.filter(t => t.status === 'in_progress').slice(0, 20)
|
const running = relTasks.filter(t => t.status === 'in_progress').slice(0, 20)
|
||||||
@@ -430,7 +459,9 @@ function dismissEnrichment() {
|
|||||||
for (const span of projectSpans) {
|
for (const span of projectSpans) {
|
||||||
const label = text.slice(span.start, span.start + span.length)
|
const label = text.slice(span.start, span.start + span.length)
|
||||||
// 替换标记 + 前后可能的空格为单个空格
|
// 替换标记 + 前后可能的空格为单个空格
|
||||||
const pattern = new RegExp(`\\s*${escapeRegex(label)}\s*`)
|
// M2: 原尾段 \s* 在模板字符串里是单反斜杠 → 解析成字面 s(零或多个 s 字母),
|
||||||
|
// 应为 \\s*(双反斜杠 → 正则 \s*),与首段 \s* 对称匹配尾部空白。
|
||||||
|
const pattern = new RegExp(`\\s*${escapeRegex(label)}\\s*`)
|
||||||
text = text.replace(pattern, ' ').trim()
|
text = text.replace(pattern, ' ').trim()
|
||||||
}
|
}
|
||||||
inputText.value = text
|
inputText.value = text
|
||||||
@@ -443,6 +474,27 @@ function escapeRegex(s: string): string {
|
|||||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P1-g: @项目 mention 选中后懒加载该项目的任务(独立 list_tasks 通道,隔离 store.tasks 筛选)。
|
||||||
|
// 选中即触发,首次拉取后入缓存,后续同一项目不重复拉。
|
||||||
|
watch(
|
||||||
|
() => pendingMentionSpans.value.filter(sp => sp.kind === 'project').map(sp => sp.refId),
|
||||||
|
(projectIds) => {
|
||||||
|
for (const pid of projectIds) {
|
||||||
|
if (pid) void ensureProjectTasks(pid)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ flush: 'post' },
|
||||||
|
)
|
||||||
|
|
||||||
|
// P1-g: 切项目清缓存 —— projectsStore.projects 列表重载/增删会让缓存 id 失效(项目删除后
|
||||||
|
// 缓存条目悬空)。监听 projects 引用变化即清空,下次 @项目 自然重拉最新。
|
||||||
|
watch(
|
||||||
|
() => projectsStore.projects,
|
||||||
|
() => {
|
||||||
|
if (projectTasksCache.value.size > 0) projectTasksCache.value = new Map()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const mentionGroupLabel = computed(() => ({
|
const mentionGroupLabel = computed(() => ({
|
||||||
project: t('aiChat.mentionGroupProject'),
|
project: t('aiChat.mentionGroupProject'),
|
||||||
task: t('aiChat.mentionGroupTask'),
|
task: t('aiChat.mentionGroupTask'),
|
||||||
|
|||||||
@@ -100,6 +100,7 @@
|
|||||||
v-for="conv in group.items"
|
v-for="conv in group.items"
|
||||||
v-show="!store.state.foldedGroups[group.key]"
|
v-show="!store.state.foldedGroups[group.key]"
|
||||||
:key="conv.id"
|
:key="conv.id"
|
||||||
|
:ref="(el) => setItemEl(conv.id, el)"
|
||||||
class="ai-conv-item"
|
class="ai-conv-item"
|
||||||
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--pinned': conv.pinned }"
|
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--pinned': conv.pinned }"
|
||||||
@click="store.switchConversation(conv.id)"
|
@click="store.switchConversation(conv.id)"
|
||||||
@@ -168,6 +169,7 @@
|
|||||||
v-for="conv in archivedConvs"
|
v-for="conv in archivedConvs"
|
||||||
v-show="!store.state.archivedCollapsed"
|
v-show="!store.state.archivedCollapsed"
|
||||||
:key="'a-' + conv.id"
|
:key="'a-' + conv.id"
|
||||||
|
:ref="(el) => setItemEl(conv.id, el)"
|
||||||
class="ai-conv-item ai-conv-item--archived"
|
class="ai-conv-item ai-conv-item--archived"
|
||||||
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--pinned': conv.pinned }"
|
:class="{ 'ai-conv-item--active': conv.id === store.state.activeConversationId, 'ai-conv-item--pinned': conv.pinned }"
|
||||||
@click="store.switchConversation(conv.id)"
|
@click="store.switchConversation(conv.id)"
|
||||||
@@ -230,7 +232,7 @@
|
|||||||
* 本组件自管的纯侧栏态:titleFlash/重命名/更多操作菜单/侧栏拖拽/搜索结果/时间分组。
|
* 本组件自管的纯侧栏态:titleFlash/重命名/更多操作菜单/侧栏拖拽/搜索结果/时间分组。
|
||||||
* 样式:零 scoped style(沿用 AiChat.vue 全局/父级样式,样式后续批下沉)。
|
* 样式:零 scoped style(沿用 AiChat.vue 全局/父级样式,样式后续批下沉)。
|
||||||
*/
|
*/
|
||||||
import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount } from 'vue'
|
import { ref, computed, nextTick, watch, onMounted, onBeforeUnmount, type ComponentPublicInstance } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAiStore } from '../../stores/ai'
|
import { useAiStore } from '../../stores/ai'
|
||||||
import { formatRelative } from '../../utils/time'
|
import { formatRelative } from '../../utils/time'
|
||||||
@@ -322,10 +324,51 @@ function onActionsOutsideClick(e: MouseEvent) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* UX-260617-23:搜索结果点击切对话后清空 searchQuery,退出搜索模式回原时间分组视图。
|
* UX-260617-23:搜索结果点击切对话后清空 searchQuery,退出搜索模式回原时间分组视图。
|
||||||
|
* M3:命中项可能在折叠的时间分组或折叠的归档分组内,选中态不可见。切回分组视图后展开所在分组
|
||||||
|
* 并 scrollIntoView,确保选中项立即可见(原仅切 searchQuery,选中项落在折叠组里看不到高亮)。
|
||||||
*/
|
*/
|
||||||
function onSelectSearchResult(id: string) {
|
function onSelectSearchResult(id: string) {
|
||||||
void store.switchConversation(id)
|
void store.switchConversation(id)
|
||||||
store.state.searchQuery = ''
|
store.state.searchQuery = ''
|
||||||
|
// nextTick 后视图已切回分组态;ensureVisible 展开所在组 + 滚动定位
|
||||||
|
void nextTick(() => ensureActiveVisible())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── M3:活跃会话项 DOM ref 收集(id → el),用于展开折叠组后 scrollIntoView ──
|
||||||
|
// Vue 3 函数 ref:每项 :ref="(el) => setItemEl(id, el)"。卸载时 el 为 null,删 key 防 Map 泄漏。
|
||||||
|
const itemEls = new Map<string, Element>()
|
||||||
|
function setItemEl(id: string, el: Element | ComponentPublicInstance | null): void {
|
||||||
|
if (!el) {
|
||||||
|
itemEls.delete(id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 组件实例取 $el,但本项是普通 div(el 即 Element)
|
||||||
|
itemEls.set(id, el as Element)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* M3:确保当前活跃会话在分组视图可见。若它落在折叠的时间分组(今天/昨天/更早)或折叠的归档分组,
|
||||||
|
* 展开该组让 v-show 渲染出来,再 scrollIntoView。用于搜索点选、切对话后选中态不被折叠吞掉。
|
||||||
|
*/
|
||||||
|
function ensureActiveVisible(): void {
|
||||||
|
const id = store.state.activeConversationId
|
||||||
|
if (!id) return
|
||||||
|
const conv = store.state.conversations.find(c => c.id === id)
|
||||||
|
if (!conv) return
|
||||||
|
// 先按归档态/时间桶决定应展开哪个组折叠态
|
||||||
|
if (conv.archived) {
|
||||||
|
if (store.state.archivedCollapsed) store.state.archivedCollapsed = false
|
||||||
|
} else {
|
||||||
|
const bucket = timeBucket(conv.updated_at)
|
||||||
|
if (store.state.foldedGroups[bucket]) store.state.foldedGroups[bucket] = false
|
||||||
|
}
|
||||||
|
// 折叠态翻转后 v-show 需一帧才渲染,再 nextTick 取 el 滚动
|
||||||
|
void nextTick(() => {
|
||||||
|
const el = itemEls.get(id)
|
||||||
|
if (el) {
|
||||||
|
;(el as HTMLElement).scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 对话分组:今天 / 昨天 / 更早 ──
|
// ── 对话分组:今天 / 昨天 / 更早 ──
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
点"拒绝" → ai_authorize_dir(decision=deny, 工具返 Err)→ 续 loop。
|
点"拒绝" → ai_authorize_dir(decision=deny, 工具返 Err)→ 续 loop。
|
||||||
store 单例共享(state/aiApi),pendingDirAuths 模块级 ref(useAiEvents)直接导入。
|
store 单例共享(state/aiApi),pendingDirAuths 模块级 ref(useAiEvents)直接导入。
|
||||||
toast 经 emit 转父(保持单一 toast 源)。 -->
|
toast 经 emit 转父(保持单一 toast 源)。 -->
|
||||||
<div v-for="item in visibleDirAuths" :key="item.id" class="ai-dir-auth">
|
<div v-for="item in visibleDirAuths" :key="item.id" ref="dirAuthRefs" class="ai-dir-auth">
|
||||||
<span class="ai-dir-auth-text">{{ $t('aiChat.dirAuthRequired') }}</span>
|
<span class="ai-dir-auth-text">{{ $t('aiChat.dirAuthRequired') }}</span>
|
||||||
<span class="ai-dir-auth-hint">
|
<span class="ai-dir-auth-hint">
|
||||||
{{ $t('aiChat.dirAuthHint', { tool: item.tool, path: item.path }) }}
|
{{ $t('aiChat.dirAuthHint', { tool: item.tool, path: item.path }) }}
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, computed, watch } from 'vue'
|
import { reactive, computed, watch, nextTick, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAiStore } from '../../stores/ai'
|
import { useAiStore } from '../../stores/ai'
|
||||||
import { pendingDirAuths } from '../../composables/ai/useAiEvents'
|
import { pendingDirAuths } from '../../composables/ai/useAiEvents'
|
||||||
@@ -77,6 +77,10 @@ const visibleDirAuths = computed(() => {
|
|||||||
// reactive Set 的增删驱动按钮 disabled;Set 判定 O(1)。
|
// reactive Set 的增删驱动按钮 disabled;Set 判定 O(1)。
|
||||||
const actingIds = reactive(new Set<string>())
|
const actingIds = reactive(new Set<string>())
|
||||||
|
|
||||||
|
// M8:卡片根元素 ref 数组(v-for ref 收集)。挂起弹窗出现在输入区上方,常被消息流/进度条挤出视口,
|
||||||
|
// 用户看不到按钮 → 挂起无声卡死。新增项时滚动定位到卡片让审批入口立即可见。
|
||||||
|
const dirAuthRefs = ref<HTMLElement[]>([])
|
||||||
|
|
||||||
/** 点三选项之一:按 id 定位挂起项,调 ai_authorize_dir。后端 remove pending → 写授权/拒 →
|
/** 点三选项之一:按 id 定位挂起项,调 ai_authorize_dir。后端 remove pending → 写授权/拒 →
|
||||||
* execute → try_continue。不主动清 pendingDirAuths——由后端 AiApprovalResult/AiCompleted/AiError
|
* execute → try_continue。不主动清 pendingDirAuths——由后端 AiApprovalResult/AiCompleted/AiError
|
||||||
* 在 useAiEvents 内按 id filter / 清空。IPC 失败回滚 actingIds 让用户可重试。 */
|
* 在 useAiEvents 内按 id filter / 清空。IPC 失败回滚 actingIds 让用户可重试。 */
|
||||||
@@ -105,6 +109,16 @@ watch(() => pendingDirAuths.value, (items) => {
|
|||||||
if (!liveIds.has(id)) actingIds.delete(id)
|
if (!liveIds.has(id)) actingIds.delete(id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** M8:visibleDirAuths 出现新挂起项时,scrollIntoView 让审批入口立刻可见(挂起弹窗在输入区上方,
|
||||||
|
* 易被消息流/进度条挤出视口,用户不滚动看不到 → 挂起无声卡死)。仅新增项触发滚动,避免已可见项
|
||||||
|
* 反复跳动打扰。 */
|
||||||
|
watch(() => visibleDirAuths.value.map(d => d.id).join('|'), async () => {
|
||||||
|
await nextTick()
|
||||||
|
// 滚到最新(数组末)项,block:'center' 让按钮组居中可见
|
||||||
|
const last = dirAuthRefs.value[dirAuthRefs.value.length - 1]
|
||||||
|
if (last) last.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
选 option → 仅关闭求助卡(后端 loop 已终止,用户重新发消息即"换思路/人工接管"入口,
|
选 option → 仅关闭求助卡(后端 loop 已终止,用户重新发消息即"换思路/人工接管"入口,
|
||||||
路径授权走 DirAuthDialog 既有链路)。机制优先 prompt 说教:代码强制熔断 + 结构化求助,
|
路径授权走 DirAuthDialog 既有链路)。机制优先 prompt 说教:代码强制熔断 + 结构化求助,
|
||||||
非"教 AI 失败就问用户"(LLM 不可靠)。 -->
|
非"教 AI 失败就问用户"(LLM 不可靠)。 -->
|
||||||
<div v-if="helpActive" class="ai-help-required">
|
<div v-if="helpActive" ref="helpRef" class="ai-help-required">
|
||||||
<span class="ai-help-required-text">{{ helpActive.reason }}</span>
|
<span class="ai-help-required-text">{{ helpActive.reason }}</span>
|
||||||
<span class="ai-help-required-hint">{{ helpActive.context }}</span>
|
<span class="ai-help-required-hint">{{ helpActive.context }}</span>
|
||||||
<div class="ai-help-required-options">
|
<div class="ai-help-required-options">
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref, watch, nextTick } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAiStore } from '../../stores/ai'
|
import { useAiStore } from '../../stores/ai'
|
||||||
import { pendingHelp } from '../../composables/ai/useAiEvents'
|
import { pendingHelp } from '../../composables/ai/useAiEvents'
|
||||||
@@ -49,6 +49,18 @@ const helpActive = computed(() => {
|
|||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// M8:卡片根 ref。断路器熔断常发生在多轮迭代后,消息流已长,求助卡被挤出视口用户看不到 → 求助无声
|
||||||
|
// 卡死。卡片显示时 scrollIntoView 让 option 按钮组立即进入视口。
|
||||||
|
const helpRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
/** M8:helpActive 从 null→非 null(求助挂起出现)时 scrollIntoView 让操作入口立即可见。
|
||||||
|
* 仅监 truthy 翻转,不监 reason/context 变化避免已可见时反复跳。 */
|
||||||
|
watch(helpActive, async (p) => {
|
||||||
|
if (!p) return
|
||||||
|
await nextTick()
|
||||||
|
helpRef.value?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||||
|
})
|
||||||
|
|
||||||
/** 选 option:关求助卡即可。后端 loop 已在 AiHelpRequired 时终止(guard.reset + return),
|
/** 选 option:关求助卡即可。后端 loop 已在 AiHelpRequired 时终止(guard.reset + return),
|
||||||
* 用户选 option 后的"换思路/授权路径/人工接管"由用户重新发消息/操作触发(无独立 IPC)。
|
* 用户选 option 后的"换思路/授权路径/人工接管"由用户重新发消息/操作触发(无独立 IPC)。
|
||||||
* 点"授权路径"提示用户路径授权链路(DirAuthDialog 自动触发,无需此卡代发)。
|
* 点"授权路径"提示用户路径授权链路(DirAuthDialog 自动触发,无需此卡代发)。
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
点停止 → ai_stop_loop(后端走 AiCompleted 收尾)→ 卡片隐藏。
|
点停止 → ai_stop_loop(后端走 AiCompleted 收尾)→ 卡片隐藏。
|
||||||
store 单例共享(state/aiApi),pendingMaxRounds 模块级 ref(useAiEvents)直接导入。
|
store 单例共享(state/aiApi),pendingMaxRounds 模块级 ref(useAiEvents)直接导入。
|
||||||
toast 经 emit 转父(保持单一 toast 源)。 -->
|
toast 经 emit 转父(保持单一 toast 源)。 -->
|
||||||
<div v-if="showMaxRoundsCard" class="ai-max-rounds">
|
<div v-if="showMaxRoundsCard" ref="maxRoundsRef" class="ai-max-rounds">
|
||||||
<span class="ai-max-rounds-text">{{ $t('aiChat.maxRoundsReached') }}</span>
|
<span class="ai-max-rounds-text">{{ $t('aiChat.maxRoundsReached') }}</span>
|
||||||
<span class="ai-max-rounds-hint">{{ $t('aiChat.maxRoundsHint') }}</span>
|
<span class="ai-max-rounds-hint">{{ $t('aiChat.maxRoundsHint') }}</span>
|
||||||
<div class="ai-max-rounds-actions">
|
<div class="ai-max-rounds-actions">
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAiStore } from '../../stores/ai'
|
import { useAiStore } from '../../stores/ai'
|
||||||
import { pendingMaxRounds, getConvState } from '../../composables/ai/useAiEvents'
|
import { pendingMaxRounds, getConvState } from '../../composables/ai/useAiEvents'
|
||||||
@@ -73,6 +73,10 @@ const showMaxRoundsCard = computed(() =>
|
|||||||
&& isViewingGeneratingState.value,
|
&& isViewingGeneratingState.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// M8:卡片根 ref。达 max 挂起卡位于输入区上方,多轮迭代后消息流已很长,卡片常被挤出视口 → 用户看不到
|
||||||
|
// "继续/停止" → generating 永真卡死。卡片显示时 scrollIntoView 让操作入口立即可见。
|
||||||
|
const maxRoundsRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
/** 点继续:调 ai_continue_loop。后端续 max_iterations 轮(iteration 从 0 重计)。
|
/** 点继续:调 ai_continue_loop。后端续 max_iterations 轮(iteration 从 0 重计)。
|
||||||
* 不主动清 pendingMaxRounds——由后端新一轮事件(AiAgentRound)与最终 AiCompleted/AiError
|
* 不主动清 pendingMaxRounds——由后端新一轮事件(AiAgentRound)与最终 AiCompleted/AiError
|
||||||
* 在 useAiEvents 内清。IPC 失败回滚 acting 让用户可重试。 */
|
* 在 useAiEvents 内清。IPC 失败回滚 acting 让用户可重试。 */
|
||||||
@@ -108,6 +112,14 @@ async function handleStopLoop(): Promise<void> {
|
|||||||
watch(() => pendingMaxRounds.value, (v) => {
|
watch(() => pendingMaxRounds.value, (v) => {
|
||||||
if (!v) maxRoundsActing.value = false
|
if (!v) maxRoundsActing.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** M8:卡片显示(从无→有)时 scrollIntoView 让"继续/停止"按钮组立即进入视口。
|
||||||
|
* 仅监 showMaxRoundsCard 真值翻转,不监 acting 等内部态避免已可见时反复跳。 */
|
||||||
|
watch(showMaxRoundsCard, async (show) => {
|
||||||
|
if (!show) return
|
||||||
|
await nextTick()
|
||||||
|
maxRoundsRef.value?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -53,8 +53,14 @@
|
|||||||
<button class="ai-btn-icon" @click="emit('new-conversation')" :title="$t('aiChat.newConversation')">
|
<button class="ai-btn-icon" @click="emit('new-conversation')" :title="$t('aiChat.newConversation')">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 013 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<!-- 清空对话(真删 DB messages,带二次确认防误删)。高频常用,常驻。 -->
|
<!-- 清空对话(真删 DB messages,带二次确认防误删)。高频常用,常驻。
|
||||||
<button class="ai-btn-icon" @click="emit('clear-chat')" :title="$t('aiChat.clearChat')">
|
disabled:无活跃消息(空会话)或流式中(避免并发删/写),对齐 clear-context 项语义。 -->
|
||||||
|
<button
|
||||||
|
class="ai-btn-icon"
|
||||||
|
:disabled="!hasActiveMessages || store.state.streaming"
|
||||||
|
:title="$t('aiChat.clearChat')"
|
||||||
|
@click="emit('clear-chat')"
|
||||||
|
>
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4a1 1 0 011-1h4a1 1 0 011 1v2"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -391,8 +397,22 @@ const historyExpanded = ref(false)
|
|||||||
/** 点击历史消息项 → 滚动定位到对应的消息气泡 */
|
/** 点击历史消息项 → 滚动定位到对应的消息气泡 */
|
||||||
function scrollToMessage(msgId: string) {
|
function scrollToMessage(msgId: string) {
|
||||||
historyExpanded.value = false
|
historyExpanded.value = false
|
||||||
// 消息列表已有 data-msg-id 属性,直接 DOM 查询定位
|
// 消息列表已有 data-msg-id 属性,直接 DOM 查询定位。
|
||||||
const el = document.querySelector(`[data-msg-id="${msgId}"]`)
|
// BUG-260802:msgId 由后端生成,可能含 CSS 选择器元字符(: . [ ] / 空格 等),
|
||||||
|
// 模板拼 selector 会被选择器解析器当伪类/类名异常 → querySelector 抛错或返回 null。
|
||||||
|
// 用 CSS.escape 转义 msgId,确保任意字符串安全嵌入属性选择器。
|
||||||
|
// 旧浏览器无 CSS.escape 的兜底:encodeURIComponent + attr*= 模糊匹配(非精确,降级路径)。
|
||||||
|
let el: Element | null = null
|
||||||
|
const escaped = typeof CSS !== 'undefined' && typeof CSS.escape === 'function'
|
||||||
|
? CSS.escape(msgId)
|
||||||
|
: encodeURIComponent(msgId)
|
||||||
|
try {
|
||||||
|
el = document.querySelector(`[data-msg-id="${escaped}"]`)
|
||||||
|
} catch {
|
||||||
|
// 转义后仍异常(理论不会)→ 退化为遍历查找
|
||||||
|
const all = document.querySelectorAll('[data-msg-id]')
|
||||||
|
el = Array.from(all).find(n => n.getAttribute('data-msg-id') === msgId) ?? null
|
||||||
|
}
|
||||||
if (el) {
|
if (el) {
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,11 +62,15 @@ function getProjectTaskCount(projectId: string): number {
|
|||||||
// 直接拼接 i18n key dashboard.stage.<stage>,与 constants/project.ts PROJECT_STAGE_INFO 单一来源。
|
// 直接拼接 i18n key dashboard.stage.<stage>,与 constants/project.ts PROJECT_STAGE_INFO 单一来源。
|
||||||
// 面板名为"活跃项目",只显示 active 状态(DB 实际默认值,ProjectStatus union 历史遗留未含
|
// 面板名为"活跃项目",只显示 active 状态(DB 实际默认值,ProjectStatus union 历史遗留未含
|
||||||
// 'active',此处断言绕过;DB 实际无 planning/in_progress 等值产生,详见功能决策记录)。
|
// 'active',此处断言绕过;DB 实际无 planning/in_progress 等值产生,详见功能决策记录)。
|
||||||
// 按更新时间倒序取前 6 条(completed 归档项目不混入)。
|
//
|
||||||
|
// 问题3:按「最近活跃」排序(last_active_at 优先,回退 updated_at),反映业务活跃
|
||||||
|
// (任务/灵感/状态推进事件)而非元信息修改时间。取前 6 条(completed 归档项目不混入)。
|
||||||
const displayProjects = computed(() =>
|
const displayProjects = computed(() =>
|
||||||
store.projects
|
store.projects
|
||||||
.filter(p => (p.status as string) === 'active')
|
.filter(p => (p.status as string) === 'active')
|
||||||
.sort((a, b) => b.updated_at.localeCompare(a.updated_at))
|
.sort((a, b) =>
|
||||||
|
(b.last_active_at ?? b.updated_at).localeCompare(a.last_active_at ?? a.updated_at)
|
||||||
|
)
|
||||||
.slice(0, 6)
|
.slice(0, 6)
|
||||||
.map(p => {
|
.map(p => {
|
||||||
const info = projectStageInfo(p.status)
|
const info = projectStageInfo(p.status)
|
||||||
@@ -77,7 +81,7 @@ const displayProjects = computed(() =>
|
|||||||
stageLabelKey: info.stage,
|
stageLabelKey: info.stage,
|
||||||
progress: info.progress,
|
progress: info.progress,
|
||||||
activeTasks: getProjectTaskCount(p.id),
|
activeTasks: getProjectTaskCount(p.id),
|
||||||
lastActivity: formatRelative(p.updated_at),
|
lastActivity: formatRelative(p.last_active_at ?? p.updated_at),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,58 +2,62 @@
|
|||||||
<section class="idea-detail-panel">
|
<section class="idea-detail-panel">
|
||||||
<div class="detail-header">
|
<div class="detail-header">
|
||||||
<h2 class="detail-title">{{ idea.title }}</h2>
|
<h2 class="detail-title">{{ idea.title }}</h2>
|
||||||
<span class="status-tag" :class="'status-' + idea.status">{{ $t(statusLabelKey(idea.status)) }}</span>
|
<div class="detail-header-actions">
|
||||||
|
<!-- P0-② 转化为项目按钮前置到标题行右侧(原底部操作区按钮移入),保持 promoting 态 -->
|
||||||
|
<button
|
||||||
|
v-if="idea.status === 'approved' && !idea.promoted_to"
|
||||||
|
class="btn btn-primary btn-sm"
|
||||||
|
:disabled="promoting"
|
||||||
|
@click="$emit('promote')"
|
||||||
|
>
|
||||||
|
{{ promoting ? $t('ideas.promoting') : $t('ideas.promoteToProject') }}
|
||||||
|
</button>
|
||||||
|
<router-link
|
||||||
|
v-else-if="idea.promoted_to"
|
||||||
|
class="btn btn-primary btn-sm"
|
||||||
|
:to="`/projects/${idea.promoted_to}`"
|
||||||
|
>
|
||||||
|
🚀 {{ $t('ideas.promotedProject') }} →
|
||||||
|
</router-link>
|
||||||
|
<span class="status-tag" :class="'status-' + idea.status">{{ $t(statusLabelKey(idea.status)) }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- B-260615-25:灵感描述 Markdown 渲染,复用 useMarkdown composable(同 B-24 TaskDetail),空值回退 — -->
|
<!-- B-260615-25:灵感描述 Markdown 渲染,复用 useMarkdown composable(同 B-24 TaskDetail),空值回退 — -->
|
||||||
<template v-if="editing">
|
<template v-if="editing">
|
||||||
<textarea v-model="editDesc" class="detail-desc-edit" rows="4"></textarea>
|
<textarea v-model="editDesc" class="detail-desc-edit" rows="4"></textarea>
|
||||||
<div class="desc-edit-actions">
|
<div class="desc-edit-actions">
|
||||||
<button class="btn btn-primary btn-sm" @click="saveEdit">{{ $t('ideas.saveDesc') }}</button>
|
<button class="btn btn-primary btn-sm" :disabled="savingDesc" @click="saveEdit">
|
||||||
<button class="btn btn-ghost btn-sm" @click="cancelEdit">{{ $t('ideas.cancelEdit') }}</button>
|
{{ savingDesc ? $t('common.loading') : $t('ideas.saveDesc') }}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-ghost btn-sm" :disabled="savingDesc" @click="cancelEdit">{{ $t('ideas.cancelEdit') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p
|
<!-- P1-③ 描述超阈值折叠:max-height:300px 截断,溢出显「展开/收起」(沿用 ProjectCard 测量模式) -->
|
||||||
|
<div
|
||||||
v-if="idea.description"
|
v-if="idea.description"
|
||||||
class="detail-desc ai-md"
|
class="detail-desc-wrap"
|
||||||
v-html="renderedDesc"
|
:class="{ 'is-expanded': descExpanded }"
|
||||||
></p>
|
>
|
||||||
|
<p
|
||||||
|
ref="descRef"
|
||||||
|
class="detail-desc ai-md"
|
||||||
|
v-html="renderedDesc"
|
||||||
|
></p>
|
||||||
|
<button v-if="descOverflow" class="desc-toggle" @click="descExpanded = !descExpanded">
|
||||||
|
{{ descExpanded ? $t('common.collapse') : $t('common.expand') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<p v-else class="detail-desc">—</p>
|
<p v-else class="detail-desc">—</p>
|
||||||
<button class="btn btn-ghost btn-sm desc-edit-btn" @click="startEdit">{{ $t('ideas.editDesc') }}</button>
|
<button class="btn btn-ghost btn-sm desc-edit-btn" :disabled="savingDesc" @click="startEdit">{{ $t('ideas.editDesc') }}</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 对抗式评估 -->
|
<!-- 对抗式评估 -->
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
<h3>{{ $t('ideas.adversarialTitle') }} <span class="eval-mode-tag">{{ $t(evalModeLabelKey()) }}</span></h3>
|
<h3>{{ $t('ideas.adversarialTitle') }} <span class="eval-mode-tag">{{ $t(evalModeLabelKey()) }}</span></h3>
|
||||||
<div v-if="adversarialEval" class="adversarial-eval">
|
<div v-if="adversarialEval" class="adversarial-eval">
|
||||||
<!-- 正反方观点 -->
|
<!-- P0-① 评估结论前置:分析师结论(徽章/评分/倾向/摘要)提到正反方论据之前,
|
||||||
<div class="debate-container">
|
一眼看到评估结果;详细论据(正反方/行动建议)折叠,点击展开 -->
|
||||||
<div class="debate-column positive">
|
|
||||||
<h4>{{ $t('ideas.positive') }}</h4>
|
|
||||||
<div class="confidence-bar">
|
|
||||||
<div class="confidence-fill" :style="{ width: (adversarialEval.positive_strength * 100) + '%' }"></div>
|
|
||||||
</div>
|
|
||||||
<div class="confidence-text">{{ $t('ideas.confidence', { n: (adversarialEval.positive_strength * 100).toFixed(0) }) }}</div>
|
|
||||||
<p class="thesis">{{ adversarialEval.positive.thesis }}</p>
|
|
||||||
<ul>
|
|
||||||
<li v-for="evidence in adversarialEval.positive.evidence" :key="evidence">• {{ evidence }}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="debate-column negative">
|
|
||||||
<h4>{{ $t('ideas.negative') }}</h4>
|
|
||||||
<div class="confidence-bar">
|
|
||||||
<div class="confidence-fill" :style="{ width: (adversarialEval.negative_strength * 100) + '%' }"></div>
|
|
||||||
</div>
|
|
||||||
<div class="confidence-text">{{ $t('ideas.confidence', { n: (adversarialEval.negative_strength * 100).toFixed(0) }) }}</div>
|
|
||||||
<p class="thesis">{{ adversarialEval.negative.thesis }}</p>
|
|
||||||
<ul>
|
|
||||||
<li v-for="evidence in adversarialEval.negative.evidence" :key="evidence">• {{ evidence }}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- AI 分析师结论 -->
|
|
||||||
<div class="analyst-conclusion">
|
<div class="analyst-conclusion">
|
||||||
<h4>{{ $t('ideas.analystTitle') }}</h4>
|
<h4>{{ $t('ideas.analystTitle') }}</h4>
|
||||||
<div class="assessment-badge" :class="assessmentClass(adversarialEval.recommendation)">
|
<div class="assessment-badge" :class="assessmentClass(adversarialEval.recommendation)">
|
||||||
@@ -66,21 +70,57 @@
|
|||||||
<p class="summary">{{ adversarialEval.analyst.summary }}</p>
|
<p class="summary">{{ adversarialEval.analyst.summary }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 行动建议 -->
|
<!-- 详细论据(正反方观点 + 行动建议):折叠态默认收起,降低首屏信息密度 -->
|
||||||
<div class="action-recommendations">
|
<div class="debate-toggle-row">
|
||||||
<h4>{{ $t('ideas.actionTitle') }}</h4>
|
<button class="debate-toggle-btn" @click="debateExpanded = !debateExpanded">
|
||||||
<ul>
|
{{ debateExpanded ? $t('common.collapse') : $t('common.expand') }} · {{ $t('ideas.positive') }} / {{ $t('ideas.negative') }}
|
||||||
<li v-for="action in adversarialEval.action_items" :key="action">• {{ action }}</li>
|
<span class="debate-toggle-arrow" :class="{ 'is-open': debateExpanded }">▾</span>
|
||||||
</ul>
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-show="debateExpanded" class="debate-detail">
|
||||||
|
<div class="debate-container">
|
||||||
|
<div class="debate-column positive">
|
||||||
|
<h4>{{ $t('ideas.positive') }}</h4>
|
||||||
|
<div class="confidence-bar">
|
||||||
|
<div class="confidence-fill" :style="{ width: (adversarialEval.positive_strength * 100) + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
<div class="confidence-text">{{ $t('ideas.confidence', { n: (adversarialEval.positive_strength * 100).toFixed(0) }) }}</div>
|
||||||
|
<p class="thesis">{{ adversarialEval.positive.thesis }}</p>
|
||||||
|
<ul>
|
||||||
|
<li v-for="evidence in adversarialEval.positive.evidence" :key="evidence">• {{ evidence }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="debate-column negative">
|
||||||
|
<h4>{{ $t('ideas.negative') }}</h4>
|
||||||
|
<div class="confidence-bar">
|
||||||
|
<div class="confidence-fill" :style="{ width: (adversarialEval.negative_strength * 100) + '%' }"></div>
|
||||||
|
</div>
|
||||||
|
<div class="confidence-text">{{ $t('ideas.confidence', { n: (adversarialEval.negative_strength * 100).toFixed(0) }) }}</div>
|
||||||
|
<p class="thesis">{{ adversarialEval.negative.thesis }}</p>
|
||||||
|
<ul>
|
||||||
|
<li v-for="evidence in adversarialEval.negative.evidence" :key="evidence">• {{ evidence }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 行动建议 -->
|
||||||
|
<div class="action-recommendations">
|
||||||
|
<h4>{{ $t('ideas.actionTitle') }}</h4>
|
||||||
|
<ul>
|
||||||
|
<li v-for="action in adversarialEval.action_items" :key="action">• {{ action }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="eval-report eval-report--muted">
|
<div v-else class="eval-report eval-report--muted">
|
||||||
<button class="btn-evaluate" :disabled="evaluating" @click="$emit('evaluate')">
|
<button class="btn-evaluate" :disabled="evaluating" @click="$emit('evaluate')">
|
||||||
{{ evaluating ? $t('ideas.evaluating') : $t('ideas.startEval') }}
|
{{ evaluating ? $t('ideas.evaluating') : $t('ideas.startEval') }}
|
||||||
</button>
|
</button>
|
||||||
|
<!-- P1-⑥ 重试样式:错误提示分两行,重试用 ghost btn-sm(替代突兀的内联 accent 按钮) -->
|
||||||
<div v-if="evalError" class="eval-error">
|
<div v-if="evalError" class="eval-error">
|
||||||
⚠️ {{ evalError }}
|
<p class="eval-error-msg">⚠️ {{ evalError }}</p>
|
||||||
<button class="btn-evaluate btn-retry" :disabled="evaluating" @click="$emit('evaluate')">
|
<button class="btn btn-ghost btn-sm btn-retry" :disabled="evaluating" @click="$emit('evaluate')">
|
||||||
{{ $t('ideas.retryEval') }}
|
{{ $t('ideas.retryEval') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -186,34 +226,37 @@
|
|||||||
<div v-else class="eval-report eval-report--muted">{{ $t('ideas.noTags') }}</div>
|
<div v-else class="eval-report eval-report--muted">{{ $t('ideas.noTags') }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 状态管理 -->
|
<!-- 状态管理:P1-④ select → badge 点击(当前态显色 badge,点击展开下拉选项) -->
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
<h3>{{ $t('ideas.statusTitle') }}</h3>
|
<h3>{{ $t('ideas.statusTitle') }}</h3>
|
||||||
<div class="status-controls">
|
<div class="status-controls">
|
||||||
<select :value="idea.status" @change="onStatusChange" class="status-select">
|
<div class="status-badge-select" v-click-outside="closeStatusMenu">
|
||||||
<option v-for="s in statusOptions" :key="s.value" :value="s.value">{{ $t(s.labelKey) }}</option>
|
<button
|
||||||
</select>
|
class="status-tag status-current"
|
||||||
|
:class="'status-' + idea.status"
|
||||||
|
@click="statusMenuOpen = !statusMenuOpen"
|
||||||
|
>
|
||||||
|
{{ $t(statusLabelKey(idea.status)) }}
|
||||||
|
<span class="status-caret" :class="{ 'is-open': statusMenuOpen }">▾</span>
|
||||||
|
</button>
|
||||||
|
<ul v-show="statusMenuOpen" class="status-menu">
|
||||||
|
<li
|
||||||
|
v-for="s in statusOptions"
|
||||||
|
:key="s.value"
|
||||||
|
class="status-menu-item"
|
||||||
|
:class="{ active: s.value === idea.status }"
|
||||||
|
@click="pickStatus(s.value)"
|
||||||
|
>
|
||||||
|
<span class="status-tag" :class="'status-' + s.value">{{ $t(s.labelKey) }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 操作 -->
|
<!-- 操作(P0-② 立项按钮已上移至标题行,此处仅留删除) -->
|
||||||
<div class="detail-section" style="margin-top:8px">
|
<div class="detail-section" style="margin-top:8px">
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
<button
|
|
||||||
v-if="idea.status === 'approved' && !idea.promoted_to"
|
|
||||||
class="btn btn-primary"
|
|
||||||
:disabled="promoting"
|
|
||||||
@click="$emit('promote')"
|
|
||||||
>
|
|
||||||
{{ promoting ? $t('ideas.promoting') : $t('ideas.promoteToProject') }}
|
|
||||||
</button>
|
|
||||||
<router-link
|
|
||||||
v-if="idea.promoted_to"
|
|
||||||
class="btn btn-primary"
|
|
||||||
:to="`/projects/${idea.promoted_to}`"
|
|
||||||
>
|
|
||||||
🚀 {{ $t('ideas.promotedProject') }} →
|
|
||||||
</router-link>
|
|
||||||
<button class="btn btn-ghost" :disabled="deleting" @click="$emit('delete')">
|
<button class="btn btn-ghost" :disabled="deleting" @click="$emit('delete')">
|
||||||
{{ deleting ? $t('ideas.deleting') : $t('ideas.deleteIdea') }}
|
{{ deleting ? $t('ideas.deleting') : $t('ideas.deleteIdea') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -223,7 +266,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch, onMounted } from 'vue'
|
import { computed, ref, watch, onMounted, nextTick, type Directive } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { parseTags } from '../../stores/knowledge'
|
import { parseTags } from '../../stores/knowledge'
|
||||||
import { useProjectStore } from '../../stores/project'
|
import { useProjectStore } from '../../stores/project'
|
||||||
@@ -257,21 +300,91 @@ const store = useProjectStore()
|
|||||||
// 描述可编辑模式
|
// 描述可编辑模式
|
||||||
const editing = ref(false)
|
const editing = ref(false)
|
||||||
const editDesc = ref('')
|
const editDesc = ref('')
|
||||||
|
// P1-⑥ 描述编辑 loading:saveEdit 为异步(emit → 父 store.updateIdea → IPC),期间禁用按钮防双击
|
||||||
|
const savingDesc = ref(false)
|
||||||
|
|
||||||
function startEdit() {
|
function startEdit() {
|
||||||
editDesc.value = props.idea.description ?? ''
|
editDesc.value = props.idea.description ?? ''
|
||||||
editing.value = true
|
editing.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveEdit() {
|
// P1-⑥ saveEdit 改异步:emit 后父组件 store.updateIdea 是 Promise,但 emit 不回传结果;
|
||||||
emit('update-desc', editDesc.value)
|
// 这里用本地 savingDesc 包裹一个最小延迟窗口(等待父组件 patch 同步 props.idea.description
|
||||||
editing.value = false
|
// 完成),保证 UI 反馈一致。父组件 updateIdea 失败会 throw 但不冒泡回子组件(沿用既有契约)。
|
||||||
|
async function saveEdit() {
|
||||||
|
if (savingDesc.value) return
|
||||||
|
savingDesc.value = true
|
||||||
|
try {
|
||||||
|
emit('update-desc', editDesc.value)
|
||||||
|
editing.value = false
|
||||||
|
// 等下一帧让父组件 patch 生效再释放(微观反馈,失败也释放不卡死)
|
||||||
|
await nextTick()
|
||||||
|
} finally {
|
||||||
|
savingDesc.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEdit() {
|
function cancelEdit() {
|
||||||
editing.value = false
|
editing.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P1-③ 描述溢出测量(沿用 ProjectCard 测量模式):scrollHeight > clientHeight 说明被
|
||||||
|
// CSS max-height 截断。Markdown 异步渲染 + 切 idea 都需重测。
|
||||||
|
const descRef = ref<HTMLParagraphElement | null>(null)
|
||||||
|
const descOverflow = ref(false)
|
||||||
|
const descExpanded = ref(false)
|
||||||
|
|
||||||
|
// P0-① 详细论据(正反方/行动建议)折叠:默认收起,降低首屏信息密度,结论前置后用户按需展开
|
||||||
|
const debateExpanded = ref(false)
|
||||||
|
|
||||||
|
function measureDesc() {
|
||||||
|
const el = descRef.value
|
||||||
|
if (!el) return
|
||||||
|
descOverflow.value = el.scrollHeight - el.clientHeight > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换 idea 重置展开态 + 重测;描述 Markdown 异步渲染完成会触发 props.idea.description
|
||||||
|
// 不变但 DOM 文本变,因此额外 watch renderedDesc(渲染输出)重测 —— 见 useRendered 之后
|
||||||
|
watch(() => props.idea.id, () => {
|
||||||
|
descExpanded.value = false
|
||||||
|
debateExpanded.value = false
|
||||||
|
void nextTick(measureDesc)
|
||||||
|
})
|
||||||
|
watch(() => props.idea.description, () => void nextTick(measureDesc))
|
||||||
|
|
||||||
|
// P1-④ 状态切换 select → badge 点击:点击当前 badge 展开/收起下拉菜单,
|
||||||
|
// 选中项后 emit('status-change') 交父组件确认+落库(沿用既有契约,不破坏 statusOptions 来源)。
|
||||||
|
const statusMenuOpen = ref(false)
|
||||||
|
function closeStatusMenu() {
|
||||||
|
statusMenuOpen.value = false
|
||||||
|
}
|
||||||
|
function pickStatus(s: IdeaStatus) {
|
||||||
|
statusMenuOpen.value = false
|
||||||
|
if (s === props.idea.status) return
|
||||||
|
emit('status-change', s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// v-click-outside:轻量内联指令(状态菜单点击外部关闭),不引入新依赖。
|
||||||
|
// mounted 时绑定 mousedown 监听,点击落点不在 el 内 → 调 binding.value()。
|
||||||
|
interface ClickOutsideEl extends HTMLElement { _dfClickOutside?: ((e: MouseEvent) => void) | null }
|
||||||
|
const vClickOutside: Directive<HTMLElement, () => void> = {
|
||||||
|
mounted(el, binding) {
|
||||||
|
const host = el as ClickOutsideEl
|
||||||
|
host._dfClickOutside = (e: MouseEvent) => {
|
||||||
|
if (el.contains(e.target as Node)) return
|
||||||
|
binding.value?.()
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', host._dfClickOutside)
|
||||||
|
},
|
||||||
|
unmounted(el) {
|
||||||
|
const host = el as ClickOutsideEl
|
||||||
|
if (host._dfClickOutside) {
|
||||||
|
document.removeEventListener('mousedown', host._dfClickOutside)
|
||||||
|
host._dfClickOutside = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 关联灵感(related_ids JSON 数组字符串)=====
|
// ===== 关联灵感(related_ids JSON 数组字符串)=====
|
||||||
// 解析 props.idea.related_ids(JSON 字符串数组,null/空/非法 → [])
|
// 解析 props.idea.related_ids(JSON 字符串数组,null/空/非法 → [])
|
||||||
const relatedIds = computed<string[]>(() => {
|
const relatedIds = computed<string[]>(() => {
|
||||||
@@ -336,6 +449,8 @@ function statusLabelKey(status: IdeaStatus): string {
|
|||||||
const { rendered: renderedDesc, ensureLoaded: ensureMdLoaded } = useRendered(
|
const { rendered: renderedDesc, ensureLoaded: ensureMdLoaded } = useRendered(
|
||||||
() => props.idea.description ?? '',
|
() => props.idea.description ?? '',
|
||||||
)
|
)
|
||||||
|
// Markdown 异步渲染完成(renderedDesc 变化)后重测描述溢出(max-height 截断阈值依赖渲染后的高度)
|
||||||
|
watch(renderedDesc, () => void nextTick(measureDesc))
|
||||||
|
|
||||||
// parseScores / assessmentClass / assessmentLabel 抽到 ../../utils/ideaEval 复用(消除与 ProjectDetail 的 DRY 重复)。
|
// parseScores / assessmentClass / assessmentLabel 抽到 ../../utils/ideaEval 复用(消除与 ProjectDetail 的 DRY 重复)。
|
||||||
|
|
||||||
@@ -383,9 +498,7 @@ function sentimentClass(sentiment: number) {
|
|||||||
return 'neutral'
|
return 'neutral'
|
||||||
}
|
}
|
||||||
|
|
||||||
function onStatusChange(e: Event) {
|
// (原 onStatusChange select 处理器已移除:状态切换改 badge 点击 pickStatus 触发 emit)
|
||||||
emit('status-change', (e.target as HTMLSelectElement).value as IdeaStatus)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 评估历史(版本时间线)=====
|
// ===== 评估历史(版本时间线)=====
|
||||||
const evalHistory = ref<IdeaEvaluationRecord[]>([])
|
const evalHistory = ref<IdeaEvaluationRecord[]>([])
|
||||||
@@ -463,9 +576,43 @@ watch(() => props.idea.id, loadHistory)
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
margin-bottom: var(--df-gap-head);
|
margin-bottom: var(--df-gap-head);
|
||||||
}
|
}
|
||||||
.detail-title { font-size: 20px; font-weight: 500; color: var(--df-text); }
|
.detail-title { font-size: 20px; font-weight: 500; color: var(--df-text); flex: 1; min-width: 0; }
|
||||||
|
/* P0-② 标题行右侧操作组:立项按钮 + 状态 badge 一行排布 */
|
||||||
|
.detail-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* P1-③ 描述折叠容器:默认 max-height:300px 截断,展开态取消 */
|
||||||
|
.detail-desc-wrap {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: var(--df-gap-page);
|
||||||
|
}
|
||||||
|
.detail-desc-wrap .detail-desc { margin-bottom: 0; }
|
||||||
|
.detail-desc-wrap:not(.is-expanded) .detail-desc {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow: hidden;
|
||||||
|
/* 底部渐变提示「下方还有」,与 max-height 截断视觉呼应 */
|
||||||
|
-webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 28px), transparent);
|
||||||
|
mask-image: linear-gradient(to bottom, #000 calc(100% - 28px), transparent);
|
||||||
|
}
|
||||||
|
.desc-toggle {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--df-accent);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.desc-toggle:hover { text-decoration: underline; }
|
||||||
|
|
||||||
.detail-desc {
|
.detail-desc {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -647,6 +794,28 @@ watch(() => props.idea.id, loadHistory)
|
|||||||
padding-left: 0.5rem;
|
padding-left: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* P0-① 详细论据折叠切换条 */
|
||||||
|
.debate-toggle-row { margin-bottom: 0.75rem; }
|
||||||
|
.debate-toggle-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--df-accent);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.debate-toggle-btn:hover { text-decoration: underline; }
|
||||||
|
.debate-toggle-arrow {
|
||||||
|
font-size: 10px;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
.debate-toggle-arrow.is-open { transform: rotate(180deg); }
|
||||||
|
.debate-detail { margin-top: 0.5rem; }
|
||||||
|
.debate-detail .debate-container { margin-bottom: 0.75rem; }
|
||||||
|
|
||||||
.btn-evaluate {
|
.btn-evaluate {
|
||||||
background: var(--df-accent);
|
background: var(--df-accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -662,11 +831,14 @@ watch(() => props.idea.id, loadHistory)
|
|||||||
background: var(--df-accent-hover);
|
background: var(--df-accent-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* P1-⑥ 重试样式:错误提示与重试按钮分行,重试用 ghost btn-sm(替代突兀的内联 accent 按钮) */
|
||||||
.eval-error {
|
.eval-error {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--df-danger);
|
color: var(--df-danger);
|
||||||
}
|
}
|
||||||
|
.eval-error-msg { margin: 0 0 8px; }
|
||||||
|
.btn-retry { margin-top: 4px; }
|
||||||
|
|
||||||
.eval-mode-tag {
|
.eval-mode-tag {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -826,35 +998,69 @@ watch(() => props.idea.id, loadHistory)
|
|||||||
background: var(--df-bg-raised);
|
background: var(--df-bg-raised);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 评估失败重试按钮(内联于错误提示) */
|
/* (旧 .btn-retry 内联 accent 按钮样式已废弃:重试改用 ghost btn-sm,统一样式收敛) */
|
||||||
.btn-retry {
|
|
||||||
margin-left: 8px;
|
|
||||||
padding: 2px 10px;
|
|
||||||
font-size: 11px;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===== 状态管理 ===== */
|
/* ===== 状态管理:P1-④ select → badge 点击下拉 ===== */
|
||||||
.status-controls {
|
.status-controls {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-select {
|
.status-badge-select {
|
||||||
width: 100%;
|
position: relative;
|
||||||
padding: 6px 10px;
|
display: inline-block;
|
||||||
border: 0.5px solid var(--df-border);
|
|
||||||
border-radius: var(--df-radius-sm);
|
|
||||||
background: var(--df-bg);
|
|
||||||
color: var(--df-text);
|
|
||||||
font-size: 13px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-select:focus {
|
/* 当前态 badge:点击展开下拉(复用全局 .status-tag 显色,加 cursor + caret) */
|
||||||
outline: none;
|
.status-current {
|
||||||
border-color: var(--df-accent);
|
cursor: pointer;
|
||||||
background: var(--df-bg-raised);
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
user-select: none;
|
||||||
|
border: 0.5px solid transparent;
|
||||||
|
transition: border-color 0.15s;
|
||||||
}
|
}
|
||||||
|
.status-current:hover { border-color: var(--df-accent); }
|
||||||
|
|
||||||
|
.status-caret {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
.status-caret.is-open { transform: rotate(180deg); }
|
||||||
|
|
||||||
|
/* 下拉菜单(绝对定位浮层) */
|
||||||
|
.status-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
z-index: 10;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px;
|
||||||
|
min-width: 160px;
|
||||||
|
background: var(--df-bg-card);
|
||||||
|
border: 0.5px solid var(--df-border);
|
||||||
|
border-radius: var(--df-radius-sm);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.status-menu-item {
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: var(--df-radius-xs);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.status-menu-item:hover { background: rgba(108, 99, 255, 0.08); }
|
||||||
|
.status-menu-item.active { background: rgba(108, 99, 255, 0.12); font-weight: 500; }
|
||||||
|
/* menu 内 badge 取消 emoji 之外的全局 hover 等副作用,仅作色标 */
|
||||||
|
.status-menu-item .status-tag { cursor: pointer; }
|
||||||
|
|
||||||
/* ═══ 响应式:窄屏对抗式评估双栏堆叠 ═══ */
|
/* ═══ 响应式:窄屏对抗式评估双栏堆叠 ═══ */
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
|
|||||||
@@ -33,11 +33,19 @@
|
|||||||
<!-- B-260615-25:知识内容 Markdown 渲染(展示态),复用 useMarkdown composable(同 B-24 TaskDetail);编辑态保持 textarea -->
|
<!-- B-260615-25:知识内容 Markdown 渲染(展示态),复用 useMarkdown composable(同 B-24 TaskDetail);编辑态保持 textarea -->
|
||||||
<div
|
<div
|
||||||
v-if="!editing && detail.knowledge.content"
|
v-if="!editing && detail.knowledge.content"
|
||||||
|
ref="contentEl"
|
||||||
class="detail-content ai-md"
|
class="detail-content ai-md"
|
||||||
|
:class="{ 'content-collapsed': !contentExpanded }"
|
||||||
v-html="renderedContent"
|
v-html="renderedContent"
|
||||||
></div>
|
></div>
|
||||||
<div v-else-if="!editing" class="detail-content">—</div>
|
<div v-else-if="!editing" class="detail-content">—</div>
|
||||||
<textarea v-else v-model="editForm.content" class="edit-input edit-textarea"></textarea>
|
<textarea v-else v-model="editForm.content" class="edit-input edit-textarea"></textarea>
|
||||||
|
<!-- P1 内容超长折叠:渲染后高度 > 300px 显示展开/收起 -->
|
||||||
|
<button
|
||||||
|
v-if="!editing && contentOverflow"
|
||||||
|
class="btn btn-ghost btn-sm fold-toggle"
|
||||||
|
@click="contentExpanded = !contentExpanded"
|
||||||
|
>{{ contentExpanded ? t('common.collapse') : t('common.expand') }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-field">
|
<div class="detail-field">
|
||||||
@@ -50,46 +58,69 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ② 溯源 -->
|
<!-- ② 溯源(P0 折叠:标题点击切换,默认展开)-->
|
||||||
<section class="detail-section">
|
<section class="detail-section">
|
||||||
<div class="section-title">{{ t('knowledge.traceTitle') }}</div>
|
<div class="section-title section-title-toggle" @click="sectionOpen.trace = !sectionOpen.trace">
|
||||||
<div class="trace-row">
|
<span>{{ t('knowledge.traceTitle') }}</span>
|
||||||
<span class="trace-label">{{ t('knowledge.traceMethod') }}</span>
|
<span class="section-chevron">{{ sectionOpen.trace ? '▾' : '▸' }}</span>
|
||||||
<span>{{ originMethod }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="trace-row" v-if="originConvTitle">
|
<div v-show="sectionOpen.trace" class="section-body">
|
||||||
<span class="trace-label">{{ t('knowledge.traceSource') }}</span>
|
<div class="trace-row">
|
||||||
<span>{{ originConvTitle }}</span>
|
<span class="trace-label">{{ t('knowledge.traceMethod') }}</span>
|
||||||
</div>
|
<span>{{ originMethod }}</span>
|
||||||
<div class="trace-row" v-if="originTime">
|
</div>
|
||||||
<span class="trace-label">{{ t('knowledge.traceTime') }}</span>
|
<div class="trace-row" v-if="originConvTitle">
|
||||||
<span>{{ originTime }}</span>
|
<span class="trace-label">{{ t('knowledge.traceSource') }}</span>
|
||||||
</div>
|
<span>{{ originConvTitle }}</span>
|
||||||
<div class="detail-field" v-if="reasoningText">
|
</div>
|
||||||
<label>{{ t('knowledge.reasoningLabel') }}</label>
|
<div class="trace-row" v-if="originTime">
|
||||||
<div class="reasoning-box">{{ reasoningText }}</div>
|
<span class="trace-label">{{ t('knowledge.traceTime') }}</span>
|
||||||
</div>
|
<span>{{ originTime }}</span>
|
||||||
</section>
|
</div>
|
||||||
|
<div class="detail-field" v-if="reasoningText">
|
||||||
<!-- ③ 引用 -->
|
<label>{{ t('knowledge.reasoningLabel') }}</label>
|
||||||
<section class="detail-section">
|
<!-- P1 reasoning 折叠:长文本默认收起,避免挤占视口 -->
|
||||||
<div class="section-title">{{ t('knowledge.refTitle', { n: referenceEvents.length }) }}</div>
|
<div
|
||||||
<div v-if="referenceEvents.length === 0" class="muted">{{ t('knowledge.refEmpty') }}</div>
|
class="reasoning-box"
|
||||||
<div v-else class="ref-list">
|
:class="{ 'reasoning-collapsed': !reasoningExpanded }"
|
||||||
<div v-for="ref in referenceEvents.slice(0, refLimit)" :key="ref.id" class="ref-item">
|
>{{ reasoningText }}</div>
|
||||||
<span class="ref-time">{{ relativeTime(ref.timestamp) }}</span>
|
<button
|
||||||
<span class="ref-conv">{{ refConvTitle(ref) }}</span>
|
v-if="reasoningOverflow || reasoningExpanded"
|
||||||
<span class="ref-query" v-if="refQuery(ref)">“{{ refQuery(ref) }}”</span>
|
class="btn btn-ghost btn-sm fold-toggle"
|
||||||
|
@click="reasoningExpanded = !reasoningExpanded"
|
||||||
|
>{{ reasoningExpanded ? t('common.collapse') : t('common.expand') }}</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ④ 生命周期时间线 -->
|
<!-- ③ 引用(P0 折叠)-->
|
||||||
<section class="detail-section">
|
<section class="detail-section">
|
||||||
<div class="section-title">{{ t('knowledge.lifecycleTitle') }}</div>
|
<div class="section-title section-title-toggle" @click="sectionOpen.refs = !sectionOpen.refs">
|
||||||
|
<span>{{ t('knowledge.refTitle', { n: referenceEvents.length }) }}</span>
|
||||||
|
<span class="section-chevron">{{ sectionOpen.refs ? '▾' : '▸' }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-show="sectionOpen.refs" class="section-body">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ④ 生命周期时间线(P0 折叠:默认收起,回顾性信息通常无需常驻)-->
|
||||||
|
<section class="detail-section">
|
||||||
|
<div class="section-title section-title-toggle" @click="sectionOpen.lifecycle = !sectionOpen.lifecycle">
|
||||||
|
<span>{{ t('knowledge.lifecycleTitle') }}</span>
|
||||||
|
<span class="section-chevron">{{ sectionOpen.lifecycle ? '▾' : '▸' }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-show="sectionOpen.lifecycle" class="section-body">
|
||||||
<div class="timeline">
|
<div class="timeline">
|
||||||
<div v-for="(node, idx) in timelineNodes" :key="node.id" class="tl-node">
|
<div v-for="(node, idx) in timelineNodes" :key="node.id" class="tl-node">
|
||||||
<div class="tl-dot">{{ node.icon }}</div>
|
<div class="tl-dot">{{ node.icon }}</div>
|
||||||
@@ -101,11 +132,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch, nextTick } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import {
|
import {
|
||||||
parseTags,
|
parseTags,
|
||||||
@@ -177,6 +209,41 @@ const { rendered: renderedContent, ensureLoaded } = useRendered(
|
|||||||
// 引用列表「加载更多」上限
|
// 引用列表「加载更多」上限
|
||||||
const refLimit = ref(10)
|
const refLimit = ref(10)
|
||||||
|
|
||||||
|
// ===== 折叠态(P0 溯源/引用/生命周期段折叠 + P1 内容/reasoning 超长折叠)=====
|
||||||
|
// 段落默认开合:溯源/引用默认展开(常用),生命周期默认收起(回顾性,通常无需常驻)
|
||||||
|
const sectionOpen = ref({ trace: true, refs: true, lifecycle: false })
|
||||||
|
|
||||||
|
// 内容/reasoning 折叠:超阈值(300px / 120px)才显示「展开/收起」按钮
|
||||||
|
const CONTENT_FOLD_THRESHOLD = 300
|
||||||
|
const REASONING_FOLD_THRESHOLD = 120
|
||||||
|
const contentEl = ref<HTMLElement | null>(null)
|
||||||
|
const contentExpanded = ref(false)
|
||||||
|
const contentOverflow = ref(false)
|
||||||
|
const reasoningExpanded = ref(false)
|
||||||
|
const reasoningOverflow = ref(false)
|
||||||
|
|
||||||
|
// 测量内容/reasoning 实际高度,决定是否需折叠按钮(渲染异步,DOM 更新后测)
|
||||||
|
async function measureOverflow() {
|
||||||
|
await nextTick()
|
||||||
|
if (contentEl.value) {
|
||||||
|
// 临时移除折叠限制测量真实高度
|
||||||
|
contentEl.value.style.maxHeight = 'none'
|
||||||
|
const h = contentEl.value.scrollHeight
|
||||||
|
contentEl.value.style.maxHeight = ''
|
||||||
|
contentOverflow.value = h > CONTENT_FOLD_THRESHOLD
|
||||||
|
if (!contentOverflow.value) contentExpanded.value = false
|
||||||
|
}
|
||||||
|
// reasoning 用纯文本高度估算(行数 * 行高 1.6 * 13px ≈ 20.8px/行)
|
||||||
|
const rText: string = reasoningText.value || ''
|
||||||
|
if (rText) {
|
||||||
|
const approxLines = rText.split('\n').reduce<number>((acc, line) => acc + Math.max(1, Math.ceil(line.length / 60)), 0)
|
||||||
|
reasoningOverflow.value = approxLines * 21 > REASONING_FOLD_THRESHOLD
|
||||||
|
if (!reasoningOverflow.value) reasoningExpanded.value = false
|
||||||
|
} else {
|
||||||
|
reasoningOverflow.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
defineExpose({ ensureLoaded })
|
defineExpose({ ensureLoaded })
|
||||||
|
|
||||||
// ===== 事件解析辅助 =====
|
// ===== 事件解析辅助 =====
|
||||||
@@ -266,9 +333,18 @@ function relativeTime(millisStr: string): string {
|
|||||||
return t('common.ago', { time: new Date(ms).toLocaleDateString() })
|
return t('common.ago', { time: new Date(ms).toLocaleDateString() })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换知识时重置引用列表「加载更多」上限(对齐原 Knowledge.vue selectKnowledge 内 refLimit=10 行为)
|
// 切换知识时重置引用列表「加载更多」上限 + 折叠态(对齐原 Knowledge.vue selectKnowledge 内 refLimit=10 行为)
|
||||||
watch(() => props.detail.knowledge.id, () => {
|
watch(() => props.detail.knowledge.id, () => {
|
||||||
refLimit.value = 10
|
refLimit.value = 10
|
||||||
|
sectionOpen.value = { trace: true, refs: true, lifecycle: false }
|
||||||
|
contentExpanded.value = false
|
||||||
|
reasoningExpanded.value = false
|
||||||
|
measureOverflow()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 内容渲染完成后(异步 v-html)重测高度:renderedContent 变化时
|
||||||
|
watch(renderedContent, () => {
|
||||||
|
measureOverflow()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -307,6 +383,13 @@ watch(() => props.detail.knowledge.id, () => {
|
|||||||
|
|
||||||
/* 溯源 */
|
/* 溯源 */
|
||||||
.section-title { font-size: 13px; font-weight: 500; color: var(--df-text); margin-bottom: 10px; }
|
.section-title { font-size: 13px; font-weight: 500; color: var(--df-text); margin-bottom: 10px; }
|
||||||
|
.section-title-toggle {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
cursor: pointer; user-select: none; padding: 2px 0;
|
||||||
|
}
|
||||||
|
.section-title-toggle:hover { color: var(--df-accent); }
|
||||||
|
.section-chevron { font-size: 11px; color: var(--df-text-dim); }
|
||||||
|
.section-body { /* 折叠体容器,无额外样式 */ }
|
||||||
.trace-row { display: flex; gap: 12px; font-size: 12px; margin-bottom: 6px; }
|
.trace-row { display: flex; gap: 12px; font-size: 12px; margin-bottom: 6px; }
|
||||||
.trace-label { color: var(--df-text-dim); min-width: 72px; }
|
.trace-label { color: var(--df-text-dim); min-width: 72px; }
|
||||||
.reasoning-box {
|
.reasoning-box {
|
||||||
@@ -314,6 +397,26 @@ watch(() => props.detail.knowledge.id, () => {
|
|||||||
background: var(--df-bg-card); border: 0.5px solid var(--df-border);
|
background: var(--df-bg-card); border: 0.5px solid var(--df-border);
|
||||||
border-radius: var(--df-radius); padding: 10px 12px; white-space: pre-wrap;
|
border-radius: var(--df-radius); padding: 10px 12px; white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
/* P1 reasoning 折叠:超过阈值收起为限定高度,渐隐边缘提示可展开 */
|
||||||
|
.reasoning-collapsed {
|
||||||
|
max-height: 120px; overflow: hidden; position: relative;
|
||||||
|
}
|
||||||
|
.reasoning-collapsed::after {
|
||||||
|
content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 24px;
|
||||||
|
background: linear-gradient(to bottom, transparent, var(--df-bg-card));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
/* P1 内容折叠:超过 300px 收起 */
|
||||||
|
.detail-content.content-collapsed {
|
||||||
|
max-height: 300px; overflow: hidden; position: relative;
|
||||||
|
}
|
||||||
|
.detail-content.content-collapsed::after {
|
||||||
|
content: ''; position: absolute; left: 0; right: 0; bottom: 0; height: 28px;
|
||||||
|
background: linear-gradient(to bottom, transparent, var(--df-bg-panel, var(--df-bg-card)));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.detail-content { position: relative; }
|
||||||
|
.fold-toggle { margin-top: 6px; font-size: 11px; }
|
||||||
|
|
||||||
/* 引用 */
|
/* 引用 */
|
||||||
.ref-list { display: flex; flex-direction: column; gap: 8px; }
|
.ref-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
|||||||
@@ -24,18 +24,80 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useAiStore } from '@/stores/ai'
|
import { useAiStore } from '@/stores/ai'
|
||||||
import { useAppSettingsStore } from '@/stores/appSettings'
|
import { useAppSettingsStore } from '@/stores/appSettings'
|
||||||
import AppSidebar from './AppSidebar.vue'
|
import AppSidebar from './AppSidebar.vue'
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
isDetached: boolean
|
isDetached: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const aiStore = useAiStore()
|
const aiStore = useAiStore()
|
||||||
const appSettings = useAppSettingsStore()
|
const appSettings = useAppSettingsStore()
|
||||||
|
|
||||||
|
// ── 7a:路由路径+查询启动恢复(仅主窗口)──
|
||||||
|
// 持久化到 localStorage(轻量、同步、读早于 appSettings.loadAll 完成,避免闪烁)。
|
||||||
|
// 各列表页(Tasks/Ideas/...)的筛选恢复由各自 agent 读写各自 localStorage key;
|
||||||
|
// 此处只负责「上次所在页面 + query」级别的恢复(如 /tasks?status=done)。
|
||||||
|
// 分离窗口(ai-detached/file-explorer-detached/approval-popup)不参与:它们有专属
|
||||||
|
// 入口路由,且会被 watch 误覆盖主窗口记录。
|
||||||
|
const ROUTE_STORAGE_KEY = 'df-last-route'
|
||||||
|
// 这些路径不持久化(过渡/分离/弹窗/根跳转):避免分离窗口覆盖、避免 '/' redirect 噪音
|
||||||
|
const NON_PERSIST_PATHS = new Set(['/', '/ai-detached', '/file-explorer-detached', '/approval-popup'])
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
/** 写入当前 path + query 到 localStorage(忽略 NON_PERSIST 路径) */
|
||||||
|
function persistRoute(path: string, query: Record<string, string>) {
|
||||||
|
if (props.isDetached || NON_PERSIST_PATHS.has(path)) return
|
||||||
|
try {
|
||||||
|
localStorage.setItem(ROUTE_STORAGE_KEY, JSON.stringify({ path, query }))
|
||||||
|
} catch {
|
||||||
|
/* 配额溢出/隐私模式,静默 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 分离窗口不恢复(它们的入口路由由打开方指定)
|
||||||
|
if (props.isDetached) return
|
||||||
|
// 首屏若落在根 redirect('/') 或 ai-home,尝试恢复上次路由
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(ROUTE_STORAGE_KEY)
|
||||||
|
if (!raw) return
|
||||||
|
const saved = JSON.parse(raw) as { path?: string; query?: Record<string, string> }
|
||||||
|
const targetPath = saved.path
|
||||||
|
if (!targetPath || NON_PERSIST_PATHS.has(targetPath)) return
|
||||||
|
// 仅在当前确实处于根/首页时恢复(避免覆盖用户深链接或刷新某具体页)
|
||||||
|
if (route.path !== '/' && route.path !== '/ai-home') {
|
||||||
|
// 用户已在某具体页:仅补持久化当前页,不抢断
|
||||||
|
persistRoute(route.path, { ...route.query } as Record<string, string>)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 恢复:仅当目标路由存在(catch-all 会 redirect,这里 router.replace 自身已容错)
|
||||||
|
const targetQuery = saved.query || {}
|
||||||
|
// 防自循环:目标与当前位置完全一致则不 replace
|
||||||
|
if (targetPath === route.path && Object.keys(targetQuery).length === 0) return
|
||||||
|
router.replace({ path: targetPath, query: targetQuery })
|
||||||
|
} catch {
|
||||||
|
/* 解析失败:数据损坏,忽略(下次 watch 会覆写正确值) */
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// watch 路由变化:持久化最新 path + query(主窗口)
|
||||||
|
watch(
|
||||||
|
() => route.path,
|
||||||
|
(path) => persistRoute(path, { ...route.query } as Record<string, string>),
|
||||||
|
)
|
||||||
|
// query 变化(同 path 不同 query,如筛选)也要持久化
|
||||||
|
watch(
|
||||||
|
() => route.query,
|
||||||
|
(q) => persistRoute(route.path, { ...(q as Record<string, string>) }),
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
// ── AI 面板拖拽调宽 ──
|
// ── AI 面板拖拽调宽 ──
|
||||||
// useSetting 监听 appSettings 缓存:loadAll 异步填充后会自动刷新 ref
|
// useSetting 监听 appSettings 缓存:loadAll 异步填充后会自动刷新 ref
|
||||||
const panelWidth = appSettings.useSetting<number>('df-ai-width', 500)
|
const panelWidth = appSettings.useSetting<number>('df-ai-width', 500)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<aside class="df-sidebar" :class="{ 'df-sidebar--collapsed': collapsed }">
|
<aside class="df-sidebar" :class="{ 'df-sidebar--collapsed': collapsed }">
|
||||||
<!-- Logo -->
|
<!-- Logo(7b:brand 区只保留品牌标识,收缩按钮已移到底部操作区) -->
|
||||||
<div class="sidebar-brand">
|
<div class="sidebar-brand">
|
||||||
<div class="brand-mark">
|
<div class="brand-mark">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||||
@@ -12,16 +12,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<span v-if="!collapsed" class="brand-text">DevFlow</span>
|
<span v-if="!collapsed" class="brand-text">DevFlow</span>
|
||||||
<span v-if="!collapsed" class="brand-version">v0.1</span>
|
<span v-if="!collapsed" class="brand-version">v0.1</span>
|
||||||
<button v-if="!collapsed" class="collapse-btn" @click="toggleCollapsed" :title="$t('nav.collapse')">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 收缩态展开按钮(底部固定位置,仅在 collapsed 时显示) -->
|
|
||||||
<button v-if="collapsed" class="expand-btn" @click="toggleCollapsed" :title="$t('nav.expand')">
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
<div class="nav-group">
|
<div class="nav-group">
|
||||||
@@ -63,6 +55,16 @@
|
|||||||
<span class="status-dot"></span>
|
<span class="status-dot"></span>
|
||||||
<span class="status-text">{{ $t('nav.systemReady') }}</span>
|
<span class="status-text">{{ $t('nav.systemReady') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 7b:收缩/展开切换按钮(统一置于底部操作区,与 AI 面板/设置同列)。
|
||||||
|
collapsed 时显展开箭头(→),展开时显收起箭头(←),快捷键 Ctrl+B 同效。 -->
|
||||||
|
<button class="nav-link collapse-toggle-btn" @click="toggleCollapsed" :title="collapsed ? $t('nav.expand') : $t('nav.collapse')">
|
||||||
|
<span class="nav-link-icon">
|
||||||
|
<svg v-if="!collapsed" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
||||||
|
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
</span>
|
||||||
|
<span v-if="!collapsed" class="nav-link-text">{{ $t('nav.collapse') }}</span>
|
||||||
|
<span v-if="!collapsed" class="ai-toggle-shortcut">Ctrl+B</span>
|
||||||
|
</button>
|
||||||
<!-- AI 面板切换按钮 -->
|
<!-- AI 面板切换按钮 -->
|
||||||
<button class="nav-link ai-toggle-btn" :class="{ 'ai-toggle-btn--active': aiStore.state.panelOpen }" @click="aiStore.togglePanel()" :title="collapsed ? $t('nav.aiPanel') : undefined">
|
<button class="nav-link ai-toggle-btn" :class="{ 'ai-toggle-btn--active': aiStore.state.panelOpen }" @click="aiStore.togglePanel()" :title="collapsed ? $t('nav.aiPanel') : undefined">
|
||||||
<span class="nav-link-icon ai-spark-icon">
|
<span class="nav-link-icon ai-spark-icon">
|
||||||
@@ -111,16 +113,28 @@ function checkWindowWidth() {
|
|||||||
isNarrowWindow.value = window.innerWidth < COLLAPSE_THRESHOLD
|
isNarrowWindow.value = window.innerWidth < COLLAPSE_THRESHOLD
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 7b:Ctrl+B / Cmd+B 切换侧边栏收缩(与 AI 面板 Ctrl+I 同款全局快捷键风格)
|
||||||
|
// 仅响应 ctrl 或 meta 任一修饰键(Cmd+I 同款),忽略纯 'b'(避免拦截正常输入)
|
||||||
|
function handleCollapseKeydown(e: KeyboardEvent) {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (e.key === 'b' || e.key === 'B')) {
|
||||||
|
e.preventDefault()
|
||||||
|
toggleCollapsed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// 恢复用户偏好(默认展开)
|
// 恢复用户偏好(默认展开)
|
||||||
userCollapsed.value = appSettings.get('df-sidebar-collapsed', false)
|
userCollapsed.value = appSettings.get('df-sidebar-collapsed', false)
|
||||||
// 检查当前窗口宽度
|
// 检查当前窗口宽度
|
||||||
checkWindowWidth()
|
checkWindowWidth()
|
||||||
window.addEventListener('resize', checkWindowWidth)
|
window.addEventListener('resize', checkWindowWidth)
|
||||||
|
// Ctrl+B 切换快捷键
|
||||||
|
window.addEventListener('keydown', handleCollapseKeydown)
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('resize', checkWindowWidth)
|
window.removeEventListener('resize', checkWindowWidth)
|
||||||
|
window.removeEventListener('keydown', handleCollapseKeydown)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 知识库候选数量(侧栏 badge)— 解包 computed 取值保持响应式
|
// 知识库候选数量(侧栏 badge)— 解包 computed 取值保持响应式
|
||||||
@@ -199,39 +213,20 @@ const secondaryNav = [
|
|||||||
border-radius: var(--df-radius-xs);
|
border-radius: var(--df-radius-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 收缩按钮(展开态 brand 区右侧) */
|
/* 7b:收缩/展开切换按钮(底部操作区,复用 nav-link 样式 + button 重置)
|
||||||
.collapse-btn {
|
与 .ai-toggle-btn 同款 button-over-nav-link 模式:cursor/border/background/font 重置 */
|
||||||
margin-left: auto;
|
.collapse-toggle-btn {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 22px; height: 22px;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--df-radius-sm);
|
|
||||||
background: transparent;
|
|
||||||
color: var(--df-text-dim);
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s var(--df-ease);
|
border: none;
|
||||||
}
|
background: transparent;
|
||||||
.collapse-btn:hover {
|
|
||||||
background: var(--df-sidebar-hover);
|
|
||||||
color: var(--df-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 展开按钮(收缩态 brand 区下方) */
|
|
||||||
.expand-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 6px 0;
|
font: inherit;
|
||||||
border: none;
|
text-align: left;
|
||||||
background: transparent;
|
appearance: none;
|
||||||
color: var(--df-text-dim);
|
-webkit-appearance: none;
|
||||||
cursor: pointer;
|
color: var(--df-text-secondary);
|
||||||
transition: all 0.15s var(--df-ease);
|
|
||||||
}
|
}
|
||||||
.expand-btn:hover {
|
.collapse-toggle-btn:hover {
|
||||||
background: var(--df-sidebar-hover);
|
background: var(--df-sidebar-hover);
|
||||||
color: var(--df-text);
|
color: var(--df-text);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,19 @@
|
|||||||
<p style="margin-bottom: 8px; font-size: 14px;">{{ $t('projectDetail.approvalHint') }}</p>
|
<p style="margin-bottom: 8px; font-size: 14px;">{{ $t('projectDetail.approvalHint') }}</p>
|
||||||
<!-- F-260615-01: select_type=multiple → checkbox 多选,缺省 single → 按钮单选 -->
|
<!-- F-260615-01: select_type=multiple → checkbox 多选,缺省 single → 按钮单选 -->
|
||||||
<template v-if="isMultipleSelect">
|
<template v-if="isMultipleSelect">
|
||||||
|
<!-- 全选/反选(多选审批快捷操作):复用 btn-ghost 弱视觉,常驻显隐按钮态 -->
|
||||||
|
<div style="display: flex; gap: 8px; margin-bottom: 8px;">
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
:disabled="submitting || allSelected"
|
||||||
|
@click="selectAll"
|
||||||
|
>{{ $t('projectDetail.approvalSelectAll') }}</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
:disabled="submitting || pendingApproval.options.length === 0"
|
||||||
|
@click="invertSelection"
|
||||||
|
>{{ $t('projectDetail.approvalInvert') }}</button>
|
||||||
|
</div>
|
||||||
<div style="display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px;">
|
<div style="display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px;">
|
||||||
<label
|
<label
|
||||||
v-for="(option, idx) in pendingApproval.options"
|
v-for="(option, idx) in pendingApproval.options"
|
||||||
@@ -84,6 +97,27 @@ const submitting = ref(false)
|
|||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { toast, showToast } = useToast()
|
const { toast, showToast } = useToast()
|
||||||
|
|
||||||
|
// 全选态(全部 options 已选):驱动「全选」按钮 disabled,避免无意义点击
|
||||||
|
const allSelected = computed(() => {
|
||||||
|
const opts = pendingApproval.value?.options ?? []
|
||||||
|
return opts.length > 0 && multiDecisions.value.length === opts.length
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 全选:填入全部 options(去重保险)。submitting 中禁用防与提交并发 */
|
||||||
|
function selectAll() {
|
||||||
|
if (submitting.value) return
|
||||||
|
const opts = pendingApproval.value?.options ?? []
|
||||||
|
multiDecisions.value = [...opts]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 反选:options 中未在 multiDecisions 的项成为新选中集 */
|
||||||
|
function invertSelection() {
|
||||||
|
if (submitting.value) return
|
||||||
|
const opts = pendingApproval.value?.options ?? []
|
||||||
|
const selected = new Set(multiDecisions.value)
|
||||||
|
multiDecisions.value = opts.filter(o => !selected.has(o))
|
||||||
|
}
|
||||||
|
|
||||||
// M31: store.approveHumanApproval 内部 try/catch 吞错(只 console.error + 写 state.error,不 rethrow),
|
// M31: store.approveHumanApproval 内部 try/catch 吞错(只 console.error + 写 state.error,不 rethrow),
|
||||||
// 失败时 state.pendingApproval 不清空 → 以此为失败信号。失败显 toast、对话框保持开启,用户可重试或改取消。
|
// 失败时 state.pendingApproval 不清空 → 以此为失败信号。失败显 toast、对话框保持开启,用户可重试或改取消。
|
||||||
async function handleApproval(decision: string) {
|
async function handleApproval(decision: string) {
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ async function loadModules() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 重置树状态(切工程时调用,清展开/缓存/选中文件;刷新时保留选中文件由 refresh 单独处理)。 */
|
/** 重置树状态(切工程时调用,清展开/缓存/选中文件;refresh 也归零选中)。 */
|
||||||
function resetTreeState() {
|
function resetTreeState() {
|
||||||
expandedPaths.clear()
|
expandedPaths.clear()
|
||||||
loadedChildren.clear()
|
loadedChildren.clear()
|
||||||
@@ -303,13 +303,15 @@ function onLoadChildren(_path: string) {
|
|||||||
// 占位:FileTree 内部已自拉并缓存到 loadedChildren;此处保留事件入口便于未来扩展(如统一节流)。
|
// 占位:FileTree 内部已自拉并缓存到 loadedChildren;此处保留事件入口便于未来扩展(如统一节流)。
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 刷新根目录(清展开/缓存,保留已打开的文件预览不变)。 */
|
/** 刷新根目录(清展开/缓存,并把选中文件归位)。
|
||||||
|
* 切工程走 resetTreeState(同样清选中);此处独立清是为了让刷新后树回到根、预览回到占位,
|
||||||
|
* 避免选中文件指向已不存在的路径(刷新后 git 状态/文件内容可能已变)。 */
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
refreshing.value = true
|
refreshing.value = true
|
||||||
// 仅清树状态(展开目录 + 缓存条目),不清 selectedFilePath/selectedFileGitStatus,
|
|
||||||
// 让用户刷新的同时仍能看到正在查看的文件内容。
|
|
||||||
expandedPaths.clear()
|
expandedPaths.clear()
|
||||||
loadedChildren.clear()
|
loadedChildren.clear()
|
||||||
|
selectedFilePath.value = null
|
||||||
|
selectedFileGitStatus.value = undefined
|
||||||
// 等一个 tick 让 FileTree watch 触发重拉;实际拉取在子组件,这里只做状态清空。
|
// 等一个 tick 让 FileTree watch 触发重拉;实际拉取在子组件,这里只做状态清空。
|
||||||
await new Promise((r) => setTimeout(r, 50))
|
await new Promise((r) => setTimeout(r, 50))
|
||||||
refreshing.value = false
|
refreshing.value = false
|
||||||
|
|||||||
@@ -59,10 +59,7 @@
|
|||||||
<img :src="imageUrl ?? ''" :alt="filePath ?? ''" />
|
<img :src="imageUrl ?? ''" :alt="filePath ?? ''" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Markdown 渲染(marked + DOMPurify + 代码高亮) -->
|
<!-- Diff 视图(切到 diff 模式时显示;置于 Markdown 之前,使 .md 文件也支持 Diff) -->
|
||||||
<div v-else-if="isMarkdown" class="preview-md ai-md" v-html="renderedMd"></div>
|
|
||||||
|
|
||||||
<!-- Diff 视图(切到 diff 模式时显示) -->
|
|
||||||
<div v-else-if="showDiff && diffContent" class="preview-diff">
|
<div v-else-if="showDiff && diffContent" class="preview-diff">
|
||||||
<div v-for="(ln, idx) in diffLines" :key="idx" class="diff-line" :class="'diff-' + ln.type">
|
<div v-for="(ln, idx) in diffLines" :key="idx" class="diff-line" :class="'diff-' + ln.type">
|
||||||
<span class="diff-line-num">{{ ln.oldNum || '' }}</span>
|
<span class="diff-line-num">{{ ln.oldNum || '' }}</span>
|
||||||
@@ -75,6 +72,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Markdown 渲染(marked + DOMPurify + 代码高亮) -->
|
||||||
|
<div v-else-if="isMarkdown" class="preview-md ai-md" v-html="renderedMd"></div>
|
||||||
|
|
||||||
<!-- 文本/代码(highlight.js 语法高亮 + 行号;非 diff 模式时显示) -->
|
<!-- 文本/代码(highlight.js 语法高亮 + 行号;非 diff 模式时显示) -->
|
||||||
<div v-else class="preview-code-scroll">
|
<div v-else class="preview-code-scroll">
|
||||||
<div class="preview-line-numbers" aria-hidden="true">
|
<div class="preview-line-numbers" aria-hidden="true">
|
||||||
@@ -110,12 +110,16 @@ const fileSize = ref<number | null>(null)
|
|||||||
const truncated = ref(false)
|
const truncated = ref(false)
|
||||||
const imageUrl = ref<string | null>(null)
|
const imageUrl = ref<string | null>(null)
|
||||||
|
|
||||||
/** 行号显示 — 根据 highlight.js 输出的 HTML 行数计算。 */
|
/** 行号显示 — 按源文本真实行数计算(非 highlight.js 渲染 HTML 行数)。
|
||||||
|
* 高亮 HTML 可能因多行 token / 转义与源码行数不一致,直接切分 htmlContent 易错位。
|
||||||
|
* 末尾无换行时 split 会多出空串,需按实际换行符计数对齐渲染。 */
|
||||||
const lineCount = computed(() => {
|
const lineCount = computed(() => {
|
||||||
if (!htmlContent.value) return 0
|
if (!content.value) return 0
|
||||||
// highlight.js 用 \n 分隔行,计算行数
|
const text = content.value
|
||||||
const lines = htmlContent.value.split('\n')
|
// 末尾换行不计为新一行的可视行号(渲染时 <pre> 也不会显示空行)。
|
||||||
return lines.length
|
const norm = text.endsWith('\n') ? text.slice(0, -1) : text
|
||||||
|
if (norm === '') return 1
|
||||||
|
return norm.split('\n').length
|
||||||
})
|
})
|
||||||
|
|
||||||
/** Diff 显示控制 */
|
/** Diff 显示控制 */
|
||||||
|
|||||||
@@ -87,7 +87,8 @@
|
|||||||
* 子组件只负责 emit('toggle-dir', path, entries) / emit('load-children', path),
|
* 子组件只负责 emit('toggle-dir', path, entries) / emit('load-children', path),
|
||||||
* 实际的网络请求与状态更新统一在 FileExplorer 中处理。
|
* 实际的网络请求与状态更新统一在 FileExplorer 中处理。
|
||||||
* - 本组件自身的 loading/entries 仅用于"首次挂载时拉取自己 sub_path 的根条目";
|
* - 本组件自身的 loading/entries 仅用于"首次挂载时拉取自己 sub_path 的根条目";
|
||||||
* 递归子树复用同一份 props/事件,不重复拉取。
|
* 递归子树复用同一份 props/事件,且 loadEntries 会先查 loadedChildren 缓存命中即复用,
|
||||||
|
* 避免父级 toggleDir 已拉取后子树 onMounted 再次重复请求同目录。
|
||||||
*
|
*
|
||||||
* 这样设计的好处:刷新(refresh)只需 FileExplorer 清空 loadedChildren 重拉根,
|
* 这样设计的好处:刷新(refresh)只需 FileExplorer 清空 loadedChildren 重拉根,
|
||||||
* 所有展开的子树因 expandedPaths 被清而卸载,下次展开重新拉取,无脏数据。
|
* 所有展开的子树因 expandedPaths 被清而卸载,下次展开重新拉取,无脏数据。
|
||||||
@@ -130,13 +131,21 @@ const error = ref<string | null>(null)
|
|||||||
async function loadEntries() {
|
async function loadEntries() {
|
||||||
// moduleId 为空时不发起请求(工程列表还在加载中)
|
// moduleId 为空时不发起请求(工程列表还在加载中)
|
||||||
if (!props.moduleId) return
|
if (!props.moduleId) return
|
||||||
|
// 去重:递归子树实例挂载时,父级 toggleDir 通常已为本目录拉取并缓存。
|
||||||
|
// 命中缓存则直接复用,不再发同样的请求(避免 onMounted 与 toggleDir 双重拉取)。
|
||||||
|
const cacheKey = props.subPath || ''
|
||||||
|
const cached = props.loadedChildren.get(cacheKey)
|
||||||
|
if (cached) {
|
||||||
|
entries.value = cached
|
||||||
|
return
|
||||||
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
try {
|
||||||
const res = await moduleApi.getModuleFileTree(props.moduleId, props.subPath || undefined)
|
const res = await moduleApi.getModuleFileTree(props.moduleId, props.subPath || undefined)
|
||||||
entries.value = res.entries
|
entries.value = res.entries
|
||||||
// 缓存到顶层 loadedChildren(供 toggle 判断是否已有数据,避免重复请求)。
|
// 缓存到顶层 loadedChildren(供 toggle 判断是否已有数据,避免重复请求)。
|
||||||
props.loadedChildren.set(props.subPath || '', res.entries)
|
props.loadedChildren.set(cacheKey, res.entries)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : String(e)
|
error.value = e instanceof Error ? e.message : String(e)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -153,10 +162,10 @@ async function toggleDir(entry: FileTreeEntry) {
|
|||||||
// 收起:仅移除展开标记(loadedChildren 缓存保留,下次展开秒开)。
|
// 收起:仅移除展开标记(loadedChildren 缓存保留,下次展开秒开)。
|
||||||
emit('toggle-dir', entry.path, [])
|
emit('toggle-dir', entry.path, [])
|
||||||
} else {
|
} else {
|
||||||
// 展开:若未加载过则先拉取(由顶层处理缓存命中),再 emit 通知展开。
|
// 展开:若未加载过则先拉取并缓存(去重——只在这里拉一次;
|
||||||
|
// 递归子树实例 onMounted 时会复用此缓存,不重复请求同目录)。
|
||||||
if (!props.loadedChildren.has(entry.path)) {
|
if (!props.loadedChildren.has(entry.path)) {
|
||||||
emit('load-children', entry.path)
|
emit('load-children', entry.path)
|
||||||
// 立即拉取本目录(子组件实例挂载后会自取 loadedChildren;此处同步拉保响应即时)。
|
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
const res = await moduleApi.getModuleFileTree(props.moduleId, entry.path)
|
const res = await moduleApi.getModuleFileTree(props.moduleId, entry.path)
|
||||||
|
|||||||
@@ -403,10 +403,15 @@ async function loadStatus() {
|
|||||||
timestamp: c.timestamp,
|
timestamp: c.timestamp,
|
||||||
author: c.author,
|
author: c.author,
|
||||||
}))
|
}))
|
||||||
totalCommits.value = commits.value.length
|
// 全量提交数读后端 total_commits(git rev-list --count HEAD),非 commits.length(后者受分页限制)。
|
||||||
|
const backendTotal = gitStatus.value?.total_commits ?? 0
|
||||||
|
totalCommits.value = backendTotal
|
||||||
commitSkip.value = commits.value.length
|
commitSkip.value = commits.value.length
|
||||||
// recent_commits 通常为 50 条;若返回等于页大小则假定还有更多(精确 total 需后端,见 12a)。
|
// has_more:后端无 total 时(commits 仍可能未全载)用页大小推断;有 total 时按已载 < total 判定。
|
||||||
hasMoreCommits.value = commits.value.length >= COMMIT_PAGE_SIZE
|
hasMoreCommits.value =
|
||||||
|
backendTotal > 0
|
||||||
|
? commits.value.length < backendTotal
|
||||||
|
: commits.value.length >= COMMIT_PAGE_SIZE
|
||||||
} catch {
|
} catch {
|
||||||
gitStatus.value = null
|
gitStatus.value = null
|
||||||
commits.value = []
|
commits.value = []
|
||||||
@@ -455,7 +460,8 @@ async function loadMoreCommits() {
|
|||||||
}
|
}
|
||||||
hasMoreCommits.value = res.has_more
|
hasMoreCommits.value = res.has_more
|
||||||
commitSkip.value += res.commits.length
|
commitSkip.value += res.commits.length
|
||||||
totalCommits.value = commits.value.length
|
// 全量提交数读后端 total(git rev-list --count HEAD),非 commits.length(后者受分页限制)。
|
||||||
|
totalCommits.value = res.total
|
||||||
} catch {
|
} catch {
|
||||||
// 静默失败
|
// 静默失败
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -25,10 +25,27 @@ const props = defineProps<{
|
|||||||
selected?: boolean
|
selected?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// 路径截断:保留首段根目录(盘符/家目录锚点)+末尾目录,丢中间段。
|
||||||
|
// 旧实现 '...' + slice(-29) 仅留末尾,丢失前缀致同末不同源目录难辨(如 C:/a 与 D:/b 同名)。
|
||||||
|
// 现:根段(/第一段分隔符前)+ … + 末尾(容纳 32 字符上限内)。短路径直返。
|
||||||
const truncatedPath = computed(() => {
|
const truncatedPath = computed(() => {
|
||||||
const p = props.data?.path || ''
|
const p = props.data?.path || ''
|
||||||
if (p.length <= 32) return p
|
if (p.length <= 32) return p
|
||||||
return '...' + p.slice(-29)
|
// 拆根段:取首个路径分隔符(/ 或 \,跨平台)前部分作锚点(盘符 C: 或家目录片段)
|
||||||
|
const sepMatch = p.match(/[\\/]/)
|
||||||
|
if (!sepMatch) {
|
||||||
|
// 无分隔符的单段长名,退化为末尾
|
||||||
|
return '...' + p.slice(-29)
|
||||||
|
}
|
||||||
|
const sepIdx = sepMatch.index! + 1
|
||||||
|
const head = p.slice(0, sepIdx)
|
||||||
|
const tailBudget = 32 - head.length - 1 // -1 留给 …(此处用 …)
|
||||||
|
if (tailBudget < 4) {
|
||||||
|
// 根段本身就长,退化为末尾(保留原行为)
|
||||||
|
return '...' + p.slice(-29)
|
||||||
|
}
|
||||||
|
const tail = p.slice(-tailBudget)
|
||||||
|
return head + '…' + tail
|
||||||
})
|
})
|
||||||
|
|
||||||
const stackList = computed(() => parseJsonArray(props.data?.stack).slice(0, 3))
|
const stackList = computed(() => parseJsonArray(props.data?.stack).slice(0, 3))
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="project-card" @click="router.push('/projects/' + project.id)">
|
<div class="project-card" :class="{ 'project-card--list': view === 'list' }" @click="router.push('/projects/' + project.id)">
|
||||||
<div class="card-top">
|
<div class="card-top">
|
||||||
<div class="card-title-row">
|
<div class="card-title-row">
|
||||||
<h2 class="card-name">{{ project.name }}</h2>
|
<h2 class="card-name">{{ project.name }}</h2>
|
||||||
@@ -10,7 +10,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="card-desc">{{ stripMd(project.description) }}</p>
|
<!-- 描述:max-height + 省略号,展开/收起;list 视图更紧凑(2 行),card 视图宽松(3 行) -->
|
||||||
|
<div class="card-desc-wrap" :class="{ 'is-expanded': descExpanded }">
|
||||||
|
<p ref="descRef" class="card-desc">{{ stripMd(project.description) }}</p>
|
||||||
|
<button
|
||||||
|
v-if="descOverflow"
|
||||||
|
class="card-desc-toggle"
|
||||||
|
@click.stop="descExpanded = !descExpanded"
|
||||||
|
>{{ descExpanded ? $t('common.collapse') : $t('common.expand') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 底部信息 -->
|
<!-- 底部信息 -->
|
||||||
<div class="card-footer">
|
<div class="card-footer">
|
||||||
@@ -24,7 +32,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="footer-stat">
|
<div class="footer-stat">
|
||||||
<span class="stat-icon">🕐</span>
|
<span class="stat-icon">🕐</span>
|
||||||
<span>{{ formatDate(project.updated_at) }}</span>
|
<span>{{ formatDate(project.last_active_at ?? project.updated_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -38,8 +46,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// 项目列表卡片 — 从 Projects.vue 抽出(单卡片渲染 + 导航 + 删除触发)。
|
// 项目列表卡片 — 从 Projects.vue 抽出(单卡片渲染 + 导航 + 删除触发)。
|
||||||
// 依赖均为无副作用纯函数 + 共享常量;删除走 emit 交回父组件处理(confirm 弹层 + store 调用)。
|
// 依赖均为无副作用纯函数 + 共享常量;删除走 emit 交回父组件处理(confirm 弹层 + store 调用)。
|
||||||
|
// view prop:'list'(紧凑,默认)/'card'(宽松);描述 max-height 截断 + 展开/收起。
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||||
import { formatDate } from '@/utils/time'
|
import { formatDate } from '@/utils/time'
|
||||||
import { parseStack } from '@/utils/project'
|
import { parseStack } from '@/utils/project'
|
||||||
import { stripMd } from '@/utils/markdown'
|
import { stripMd } from '@/utils/markdown'
|
||||||
@@ -47,7 +56,13 @@ import { moduleApi } from '@/api/module'
|
|||||||
import { projectStatusLabel as statusLabel, projectBadgeClass as stageClass } from '@/constants/project'
|
import { projectStatusLabel as statusLabel, projectBadgeClass as stageClass } from '@/constants/project'
|
||||||
import type { ProjectRecord } from '@/api/types'
|
import type { ProjectRecord } from '@/api/types'
|
||||||
|
|
||||||
const props = defineProps<{ project: ProjectRecord }>()
|
const props = withDefaults(defineProps<{
|
||||||
|
project: ProjectRecord
|
||||||
|
/** 视图模式:'list'(紧凑,默认)/'card'(宽松);仅影响描述行数阈值与内边距 */
|
||||||
|
view?: 'list' | 'card'
|
||||||
|
}>(), {
|
||||||
|
view: 'list',
|
||||||
|
})
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'delete', project: ProjectRecord): void
|
(e: 'delete', project: ProjectRecord): void
|
||||||
}>()
|
}>()
|
||||||
@@ -61,7 +76,28 @@ onMounted(async () => {
|
|||||||
const list = await moduleApi.listProjectModules(props.project.id)
|
const list = await moduleApi.listProjectModules(props.project.id)
|
||||||
moduleCount.value = list.length
|
moduleCount.value = list.length
|
||||||
} catch { /* 老项目无工程记录不报错 */ }
|
} catch { /* 老项目无工程记录不报错 */ }
|
||||||
|
// DOM 渲染完成后测描述是否溢出(决定是否显示「展开」)
|
||||||
|
await nextTick()
|
||||||
|
measureDesc()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── 描述截断/展开 ──
|
||||||
|
// descOverflow=true 才渲染「展开/收起」按钮;阈值由 CSS max-height 控制(-webkit-line-clamp),
|
||||||
|
// 这里只负责测量实际内容高度是否超出 max-height。
|
||||||
|
const descRef = ref<HTMLParagraphElement | null>(null)
|
||||||
|
const descOverflow = ref(false)
|
||||||
|
const descExpanded = ref(false)
|
||||||
|
|
||||||
|
function measureDesc() {
|
||||||
|
const el = descRef.value
|
||||||
|
if (!el) return
|
||||||
|
// scrollHeight > clientHeight 说明被 max-height 截断了
|
||||||
|
descOverflow.value = el.scrollHeight - el.clientHeight > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 描述/视图模式变化后重新测量(描述异步入库或切 view 行数阈值变,按钮显隐需重算)
|
||||||
|
watch(() => props.project.description, () => nextTick(measureDesc))
|
||||||
|
watch(() => props.view, () => nextTick(measureDesc))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -101,12 +137,47 @@ onMounted(async () => {
|
|||||||
.stage-testing { background: rgba(255,217,61,0.15); color: var(--df-warning); }
|
.stage-testing { background: rgba(255,217,61,0.15); color: var(--df-warning); }
|
||||||
.stage-release { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
.stage-release { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||||
|
|
||||||
|
.card-desc-wrap {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.card-desc {
|
.card-desc {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--df-text-secondary);
|
color: var(--df-text-secondary);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin-bottom: 16px;
|
margin: 0;
|
||||||
|
/* 默认截断:2 行(list 视图紧凑,单屏多张)。展开态取消截断。 */
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
}
|
}
|
||||||
|
/* card 视图略宽松:3 行 */
|
||||||
|
.project-card--list .card-desc { -webkit-line-clamp: 2; }
|
||||||
|
.project-card:not(.project-card--list) .card-desc { -webkit-line-clamp: 3; }
|
||||||
|
.card-desc-wrap.is-expanded .card-desc {
|
||||||
|
-webkit-line-clamp: unset;
|
||||||
|
display: block;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 展开/收起按钮:右对齐、低调,避免抢卡片点击 */
|
||||||
|
.card-desc-toggle {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--df-accent);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.card-desc-toggle:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* list 视图:更紧凑的内边距,单屏容纳更多卡片 */
|
||||||
|
.project-card--list { padding: calc(var(--df-pad-panel) * 0.7); }
|
||||||
|
|
||||||
.card-footer {
|
.card-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
</select>
|
</select>
|
||||||
</SettingRow>
|
</SettingRow>
|
||||||
<SettingRow ref="rowLogLevel" :label="$t('settings.labelLogLevel')" :desc="$t('settings.descLogLevel')">
|
<SettingRow ref="rowLogLevel" :label="$t('settings.labelLogLevel')" :desc="$t('settings.descLogLevel')">
|
||||||
<select v-model="settings.logLevel" class="setting-select" @change="markSaved">
|
<select v-model="settings.logLevel" class="setting-select" @change="onLogLevelChange">
|
||||||
<option value="error">Error</option>
|
<option value="error">Error</option>
|
||||||
<option value="warn">Warning</option>
|
<option value="warn">Warning</option>
|
||||||
<option value="info">Info</option>
|
<option value="info">Info</option>
|
||||||
@@ -110,11 +110,21 @@ function clampMode(raw: string): AutoMode {
|
|||||||
return (VALID_MODES as readonly string[]).includes(raw) ? (raw as AutoMode) : 'low'
|
return (VALID_MODES as readonly string[]).includes(raw) ? (raw as AutoMode) : 'low'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// M15: logLevel 合法值白名单(对齐 <select> option)+ 脏值兜底(默认 info)。
|
||||||
|
// 防 KV 里历史脏值(手改/旧版本/导入备份)落到 select 显示空。
|
||||||
|
const VALID_LOG_LEVELS = ['error', 'warn', 'info', 'debug'] as const
|
||||||
|
type LogLevel = (typeof VALID_LOG_LEVELS)[number]
|
||||||
|
function clampLogLevel(raw: string): LogLevel {
|
||||||
|
return (VALID_LOG_LEVELS as readonly string[]).includes(raw) ? (raw as LogLevel) : 'info'
|
||||||
|
}
|
||||||
|
|
||||||
const settings = reactive({
|
const settings = reactive({
|
||||||
// 三档 low/medium/all,默认 low(=旧 autoExecute=false 现状),KV 持久化 df-ai-auto-execute-mode。
|
// 三档 low/medium/all,默认 low(=旧 autoExecute=false 现状),KV 持久化 df-ai-auto-execute-mode。
|
||||||
// clampMode 兜底脏值。
|
// clampMode 兜底脏值。
|
||||||
autoExecuteMode: clampMode(appSettings.get<string>('df-ai-auto-execute-mode', 'low')),
|
autoExecuteMode: clampMode(appSettings.get<string>('df-ai-auto-execute-mode', 'low')),
|
||||||
logLevel: 'info',
|
// M15: logLevel 真持久化——从 KV 读回(默认 info),onLogLevelChange 写回 KV。
|
||||||
|
// 原 bug:仅 reactive 内存态,刷新即失,@change 显「已保存」是假成功。
|
||||||
|
logLevel: clampLogLevel(appSettings.get<string>('df-log-level', 'info')),
|
||||||
// 审批超时(ms):0=不限时,缺省 900000(15min);仅固定合法选项,脏值回退默认
|
// 审批超时(ms):0=不限时,缺省 900000(15min);仅固定合法选项,脏值回退默认
|
||||||
approvalTimeout: clampApprovalTimeout(appSettings.get<number>('df-approval-timeout', 900000)),
|
approvalTimeout: clampApprovalTimeout(appSettings.get<number>('df-approval-timeout', 900000)),
|
||||||
})
|
})
|
||||||
@@ -158,8 +168,12 @@ watch(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// logLevel 行即时反馈(无参:logLevel 仅 session 内 reactive 无持久化,markSaved 只显「已保存」反馈)
|
// M15: logLevel 真持久化——@change 写 KV(df-log-level)+ 显「已保存」反馈。
|
||||||
function markSaved() {
|
// 原 bug:仅 reactive session 态,markSaved 显「已保存」是假成功(刷新即失)。
|
||||||
|
// 注:仅持久化偏好值;运行时日志过滤器是否实时读此值取决于后端日志初始化策略
|
||||||
|
// (本任务范围只修「假保存」,运行时生效链路另行评估,不擅自扩展)。
|
||||||
|
function onLogLevelChange() {
|
||||||
|
void appSettings.set('df-log-level', settings.logLevel)
|
||||||
rowLogLevel.value?.markSaved()
|
rowLogLevel.value?.markSaved()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label class="form-label">{{ $t('settings.labelPort') }}</label>
|
<label class="form-label">{{ $t('settings.labelPort') }}</label>
|
||||||
<input v-model.number="connForm.port" class="setting-input" type="number" :placeholder="$t('settings.phPort')" />
|
<input v-model.number="connForm.port" class="setting-input" type="number" min="1" max="65535" :placeholder="$t('settings.phPort')" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
<label class="form-label">{{ $t('settings.labelUser') }}</label>
|
<label class="form-label">{{ $t('settings.labelUser') }}</label>
|
||||||
@@ -158,6 +158,12 @@ function saveConn() {
|
|||||||
emit('toast', t('settings.toastConnIncomplete'), 'warning')
|
emit('toast', t('settings.toastConnIncomplete'), 'warning')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// L20: 端口范围校验(1-65535)。v-model.number 清空输入会得 NaN,一并拦截。
|
||||||
|
const port = connForm.port
|
||||||
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||||
|
emit('toast', t('settings.toastConnPortInvalid'), 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
const record: ConnRecord = {
|
const record: ConnRecord = {
|
||||||
|
|||||||
@@ -86,6 +86,9 @@ async function loadKnowledgeConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// M33: 跟踪待反馈的行 key——@change 触发 markSaved 不再立即显「已保存」,
|
||||||
|
// 改为记下 key,等真正 IPC saveConfig resolve 后才显,防假成功(乐观 UI 误导)。
|
||||||
|
let _pendingSavedKey: SavedKey | null = null
|
||||||
let _knowledgeSaveTimer: ReturnType<typeof setTimeout> | null = null
|
let _knowledgeSaveTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
function saveKnowledgeConfigDebounced() {
|
function saveKnowledgeConfigDebounced() {
|
||||||
if (_knowledgeSaveTimer) clearTimeout(_knowledgeSaveTimer)
|
if (_knowledgeSaveTimer) clearTimeout(_knowledgeSaveTimer)
|
||||||
@@ -96,8 +99,14 @@ function saveKnowledgeConfigDebounced() {
|
|||||||
// cfg 形态与后端 KnowledgeConfig 对齐(auto_extract/trigger_mode/min_messages/
|
// cfg 形态与后端 KnowledgeConfig 对齐(auto_extract/trigger_mode/min_messages/
|
||||||
// auto_inject/vector_enabled/embedding_provider_id/embedding_model),无需 as any
|
// auto_inject/vector_enabled/embedding_provider_id/embedding_model),无需 as any
|
||||||
await knowledgeApi.saveConfig(cfg)
|
await knowledgeApi.saveConfig(cfg)
|
||||||
|
// M33: IPC 成功后才显「已保存」(防假成功)。失败走 catch 不显,避免误导。
|
||||||
|
if (_pendingSavedKey) flashRow(_pendingSavedKey)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('保存知识库配置失败:', e)
|
console.error('保存知识库配置失败:', e)
|
||||||
|
// M33: 失败时不显「已保存」;后端错误已 console,前端无行级错误态(对齐原实现,
|
||||||
|
// 原实现也只是吞错)。如需更明显反馈可在此 emit toast,但本任务范围只防假成功。
|
||||||
|
} finally {
|
||||||
|
_pendingSavedKey = null
|
||||||
}
|
}
|
||||||
}, 500)
|
}, 500)
|
||||||
}
|
}
|
||||||
@@ -118,8 +127,20 @@ const rowAutoInject = useTemplateRef<InstanceType<typeof SettingRow>>('rowAutoIn
|
|||||||
const rowVectorEnabled = useTemplateRef<InstanceType<typeof SettingRow>>('rowVectorEnabled')
|
const rowVectorEnabled = useTemplateRef<InstanceType<typeof SettingRow>>('rowVectorEnabled')
|
||||||
const rowEmbeddingProvider = useTemplateRef<InstanceType<typeof SettingRow>>('rowEmbeddingProvider')
|
const rowEmbeddingProvider = useTemplateRef<InstanceType<typeof SettingRow>>('rowEmbeddingProvider')
|
||||||
const rowEmbeddingModel = useTemplateRef<InstanceType<typeof SettingRow>>('rowEmbeddingModel')
|
const rowEmbeddingModel = useTemplateRef<InstanceType<typeof SettingRow>>('rowEmbeddingModel')
|
||||||
function markSaved(key: 'autoExtract' | 'triggerMode' | 'minMessages' | 'autoInject' | 'vectorEnabled' | 'embeddingProvider' | 'embeddingModel') {
|
type SavedKey = 'autoExtract' | 'triggerMode' | 'minMessages' | 'autoInject' | 'vectorEnabled' | 'embeddingProvider' | 'embeddingModel'
|
||||||
const map = {
|
|
||||||
|
/**
|
||||||
|
* M33: @change 触发入口——不立即显「已保存」,先记 pending key,
|
||||||
|
* 等 saveKnowledgeConfigDebounced 内 IPC saveConfig 成功后调 flashRow 显反馈(防假成功)。
|
||||||
|
* 连续多字段改动时取最后一次 key(用户视线所在),旧 key 反馈被新 key 覆盖可接受。
|
||||||
|
*/
|
||||||
|
function markSaved(key: SavedKey) {
|
||||||
|
_pendingSavedKey = key
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 真正点亮行内「✓已保存」反馈(仅 IPC 成功路径调用)。 */
|
||||||
|
function flashRow(key: SavedKey) {
|
||||||
|
const map: Record<SavedKey, InstanceType<typeof SettingRow> | null> = {
|
||||||
autoExtract: rowAutoExtract.value,
|
autoExtract: rowAutoExtract.value,
|
||||||
triggerMode: rowTriggerMode.value,
|
triggerMode: rowTriggerMode.value,
|
||||||
minMessages: rowMinMessages.value,
|
minMessages: rowMinMessages.value,
|
||||||
|
|||||||
@@ -61,7 +61,8 @@
|
|||||||
<section class="panel" v-if="providerForm.visible">
|
<section class="panel" v-if="providerForm.visible">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h2>{{ providerForm.editId ? $t('settings.editProviderTitle') : $t('settings.addProviderTitle') }}</h2>
|
<h2>{{ providerForm.editId ? $t('settings.editProviderTitle') : $t('settings.addProviderTitle') }}</h2>
|
||||||
<button class="btn btn-ghost btn-sm" @click="providerForm.visible = false">{{ $t('common.cancel') }}</button>
|
<!-- L19: 取消走 closeProviderForm,脏表单二次确认后再关(防误弃改动) -->
|
||||||
|
<button class="btn btn-ghost btn-sm" @click="closeProviderForm">{{ $t('common.cancel') }}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<div class="form-field">
|
<div class="form-field">
|
||||||
@@ -139,6 +140,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="model-list-hint">{{ $t('settings.modelListHint') }}</div>
|
<div class="model-list-hint">{{ $t('settings.modelListHint') }}</div>
|
||||||
|
<!-- M32: 模型列表 enabled/weight 改了未保存时显提示(关闭/切走将丢弃) -->
|
||||||
|
<div v-if="isFormDirty" class="model-list-unsaved-hint">{{ $t('settings.modelListUnsavedHint') }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -200,8 +203,51 @@ const providerForm = reactive({
|
|||||||
// toast 3s 自隐易被忽略,保存失败时表单内常驻错误条 + 可操作建议,
|
// toast 3s 自隐易被忽略,保存失败时表单内常驻错误条 + 可操作建议,
|
||||||
// 直到下次保存尝试/重开表单才清除。null=无错误。
|
// 直到下次保存尝试/重开表单才清除。null=无错误。
|
||||||
saveError: null as string | null,
|
saveError: null as string | null,
|
||||||
|
// L19/M32: 表单打开/保存成功时的「已落库快照」JSON 串,用于 isFormDirty 比对。
|
||||||
|
// openProviderForm 写入初始快照,saveProvider 成功后刷新;关闭表单清空。
|
||||||
|
savedSnapshot: '' as string,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L19/M32: 表单脏判定——当前表单状态与已落库快照不一致即脏。
|
||||||
|
* 触发场景:① 取消/关闭有改动的表单 → 二次确认放弃(L19);
|
||||||
|
* ② 模型列表 enabled/weight 改了未保存 → 表单内提示(M32)。
|
||||||
|
* 比对用 JSON.stringify(整体快照语义,简单可靠;表单字段少,性能无虞)。
|
||||||
|
* 不含 saving/fetching/saveError/visible 等 UI 态字段(只比业务字段)。
|
||||||
|
*/
|
||||||
|
const isFormDirty = computed(() => {
|
||||||
|
if (!providerForm.visible) return false
|
||||||
|
return formBusinessSnapshot() !== providerForm.savedSnapshot
|
||||||
|
})
|
||||||
|
|
||||||
|
function formBusinessSnapshot(): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
name: providerForm.name,
|
||||||
|
providerType: providerForm.providerType,
|
||||||
|
baseUrl: providerForm.baseUrl,
|
||||||
|
apiKey: providerForm.apiKey,
|
||||||
|
defaultModel: providerForm.defaultModel,
|
||||||
|
models: providerForm.models.map(m => ({
|
||||||
|
model_id: m.model_id,
|
||||||
|
enabled: m.enabled,
|
||||||
|
weight: m.weight,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L19: 关闭表单入口——脏表单二次确认,清表单后关闭。
|
||||||
|
* 用于模板「取消」按钮 + saveProvider 成功后(无脏直接关)。
|
||||||
|
*/
|
||||||
|
async function closeProviderForm() {
|
||||||
|
if (isFormDirty.value) {
|
||||||
|
const ok = await props.confirmDialog(t('settings.confirmDiscardUnsaved'))
|
||||||
|
if (!ok) return // 用户取消关闭,继续编辑
|
||||||
|
}
|
||||||
|
providerForm.visible = false
|
||||||
|
providerForm.savedSnapshot = ''
|
||||||
|
}
|
||||||
|
|
||||||
async function loadProviders() {
|
async function loadProviders() {
|
||||||
try {
|
try {
|
||||||
aiProviders.value = await aiApi.listProviders()
|
aiProviders.value = await aiApi.listProviders()
|
||||||
@@ -241,6 +287,8 @@ function openProviderForm(p?: AiProviderConfig) {
|
|||||||
providerForm.fetching = false
|
providerForm.fetching = false
|
||||||
providerForm.saveError = null // P0-2: 重开表单清旧错误横幅
|
providerForm.saveError = null // P0-2: 重开表单清旧错误横幅
|
||||||
providerForm.visible = true
|
providerForm.visible = true
|
||||||
|
// L19/M32: 记初始快照(在 visible=true、字段回填完成后),isFormDirty 据此比对。
|
||||||
|
providerForm.savedSnapshot = formBusinessSnapshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -354,6 +402,7 @@ async function saveProvider() {
|
|||||||
// P0-2: 成功明确反馈——清错误横幅 + 关表单 + 绿色「已保存」toast
|
// P0-2: 成功明确反馈——清错误横幅 + 关表单 + 绿色「已保存」toast
|
||||||
providerForm.saveError = null
|
providerForm.saveError = null
|
||||||
providerForm.visible = false
|
providerForm.visible = false
|
||||||
|
providerForm.savedSnapshot = '' // L19: 关表单清快照
|
||||||
await loadProviders()
|
await loadProviders()
|
||||||
emit('toast', t('settings.toastSaved'), 'success')
|
emit('toast', t('settings.toastSaved'), 'success')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -445,10 +494,14 @@ function maskKey(key: string): string {
|
|||||||
return key.slice(0, 4) + '••••••••' + key.slice(-4)
|
return key.slice(0, 4) + '••••••••' + key.slice(-4)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 暴露给 shell:loadProviders 供外部主动刷新 / Knowledge 读 aiProviders / 卸载清 timer */
|
/** 暴露给 shell:loadProviders 供外部主动刷新 / Knowledge 读 aiProviders / 卸载清 timer。
|
||||||
|
* L19: isFormDirty + closeProviderForm 暴露,供外部(如导航切走前)查询/触发拦截。
|
||||||
|
* 当前 Settings.vue 未接入 nav 拦截(不在本次改文件集),接口先行供后续 shell 接入。 */
|
||||||
defineExpose({
|
defineExpose({
|
||||||
aiProviders,
|
aiProviders,
|
||||||
loadProviders,
|
loadProviders,
|
||||||
|
isFormDirty,
|
||||||
|
closeProviderForm,
|
||||||
clearPoolTimer() { if (_poolTimer) clearTimeout(_poolTimer) },
|
clearPoolTimer() { if (_poolTimer) clearTimeout(_poolTimer) },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -563,4 +616,12 @@ onMounted(loadProviders)
|
|||||||
.toggle--sm { width: 32px; height: 18px; }
|
.toggle--sm { width: 32px; height: 18px; }
|
||||||
.toggle--sm .toggle-slider::before { width: 12px; height: 12px; left: 3px; bottom: 3px; }
|
.toggle--sm .toggle-slider::before { width: 12px; height: 12px; left: 3px; bottom: 3px; }
|
||||||
.toggle--sm input:checked + .toggle-slider::before { transform: translateX(14px); }
|
.toggle--sm input:checked + .toggle-slider::before { transform: translateX(14px); }
|
||||||
|
|
||||||
|
/* M32: 模型列表有未保存调整时的提示(弱 warning 色,贴合 model-list-hint 之下) */
|
||||||
|
.model-list-unsaved-hint {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--df-warning);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -276,31 +276,69 @@ async function deleteConversation(id: string) {
|
|||||||
notifyConversationChanged()
|
notifyConversationChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 重命名会话(后端 + 本地侧栏摘要同步) */
|
/** M30:写后端 IPC 失败时推送错误气泡(对齐 loadConversations/switchConversation 的失败反馈),
|
||||||
|
* 保持侧栏已有交互不被无声吞掉(原仅向上抛,调用方 store 直调无 try → 异常进 unhandledrejection)。 */
|
||||||
|
function pushConvOpFail(key: 'renameConvFail' | 'archiveConvFail' | 'pinConvFail', params?: Record<string, string | number>) {
|
||||||
|
state.messages.push({
|
||||||
|
id: `conv-op-fail-${nextMsgId()}`,
|
||||||
|
role: 'assistant',
|
||||||
|
content: t(`ai.${key}`, params),
|
||||||
|
isError: true,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
} as AiMessage)
|
||||||
|
notifyConversationChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重命名会话(后端 + 本地侧栏摘要同步)。
|
||||||
|
* M30:乐观更新(本地先写 → IPC → 失败回滚 + 提示)。原逻辑 IPC 成功才写本地,IPC 失败时异常
|
||||||
|
* 向上抛但 store 直调处无 try/catch → unhandledrejection,用户无感。改乐观更新让失败可见可回滚。 */
|
||||||
async function renameConversation(id: string, title: string) {
|
async function renameConversation(id: string, title: string) {
|
||||||
await aiApi.renameConversation(id, title)
|
|
||||||
// 本地同步更新侧栏摘要标题
|
|
||||||
const conv = state.conversations.find(c => c.id === id)
|
const conv = state.conversations.find(c => c.id === id)
|
||||||
|
const prevTitle = conv?.title ?? null
|
||||||
if (conv) conv.title = title
|
if (conv) conv.title = title
|
||||||
notifyConversationChanged()
|
notifyConversationChanged()
|
||||||
|
try {
|
||||||
|
await aiApi.renameConversation(id, title)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[AI] 重命名会话失败:', e)
|
||||||
|
if (conv) conv.title = prevTitle
|
||||||
|
notifyConversationChanged()
|
||||||
|
pushConvOpFail('renameConvFail')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 归档/取消归档(后端 + 本地侧栏分组同步) */
|
/** 归档/取消归档(后端 + 本地侧栏分组同步)。
|
||||||
|
* M30:乐观更新 + 失败回滚 + 提示(同 renameConversation)。 */
|
||||||
async function archiveConversation(id: string, archived: boolean) {
|
async function archiveConversation(id: string, archived: boolean) {
|
||||||
await aiApi.archiveConversation(id, archived)
|
|
||||||
// 本地同步归档态(侧栏立即移入/移出归档分组)
|
|
||||||
const conv = state.conversations.find(c => c.id === id)
|
const conv = state.conversations.find(c => c.id === id)
|
||||||
|
const prevArchived = conv?.archived ?? false
|
||||||
if (conv) conv.archived = archived
|
if (conv) conv.archived = archived
|
||||||
notifyConversationChanged()
|
notifyConversationChanged()
|
||||||
|
try {
|
||||||
|
await aiApi.archiveConversation(id, archived)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[AI] 归档会话失败:', e)
|
||||||
|
if (conv) conv.archived = prevArchived
|
||||||
|
notifyConversationChanged()
|
||||||
|
pushConvOpFail('archiveConvFail', { action: archived ? t('ai.archiveAction') : t('ai.unarchiveAction') })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 置顶/取消置顶(后端 + 本地侧栏排序同步;UX-17) */
|
/** 置顶/取消置顶(后端 + 本地侧栏排序同步;UX-17)。
|
||||||
|
* M30:乐观更新 + 失败回滚 + 提示(同 renameConversation)。 */
|
||||||
async function setPinnedConversation(id: string, pinned: boolean) {
|
async function setPinnedConversation(id: string, pinned: boolean) {
|
||||||
await aiApi.setPinnedConversation(id, pinned)
|
|
||||||
// 本地同步置顶态(排序 computed 读 pinned,立即重排)
|
|
||||||
const conv = state.conversations.find(c => c.id === id)
|
const conv = state.conversations.find(c => c.id === id)
|
||||||
|
const prevPinned = conv?.pinned ?? false
|
||||||
if (conv) conv.pinned = pinned
|
if (conv) conv.pinned = pinned
|
||||||
notifyConversationChanged()
|
notifyConversationChanged()
|
||||||
|
try {
|
||||||
|
await aiApi.setPinnedConversation(id, pinned)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[AI] 置顶会话失败:', e)
|
||||||
|
if (conv) conv.pinned = prevPinned
|
||||||
|
notifyConversationChanged()
|
||||||
|
pushConvOpFail('pinConvFail', { action: pinned ? t('ai.pinAction') : t('ai.unpinAction') })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 折叠/展开归档分组 */
|
/** 折叠/展开归档分组 */
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
//! localStorage 持久化 ref — 读初始化 + 写回 localStorage(JSON)
|
||||||
|
//!
|
||||||
|
//! 背景:Tasks/Projects/Ideas 等列表页的筛选/分页状态散布在多个 ref,各自手写
|
||||||
|
//! localStorage.getItem/setItem 易漏且风格不一。本 composable 收敛为声明式一行:
|
||||||
|
//! const page = usePersistedRef('tasks.page', 1)
|
||||||
|
//!
|
||||||
|
//! 行为:
|
||||||
|
//! - 初始化时读 localStorage,存在且 JSON 解析成功则用其值,否则用 default
|
||||||
|
//! - watch ref 深度变化,JSON.stringify 写回 localStorage(try/catch 防脏数据/配额)
|
||||||
|
//! - SSR/无 localStorage 环境(SSR 不适用于 Tauri webview,但防御性兜底)返回纯 ref
|
||||||
|
//!
|
||||||
|
//! 设计取舍:
|
||||||
|
//! - 不做跨 tab 同步(storage 事件)——单实例 Tauri 应用无此需求
|
||||||
|
//! - 不做类型校验(解析出的值类型可能漂移)——调用方 default 决定类型,JSON 解析成功即采用,
|
||||||
|
//! 若需严格类型校验可在调用方业务层判断(当前所有调用点均为 string/number,无需求)
|
||||||
|
|
||||||
|
import { ref, watch, type Ref } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建一个持久化到 localStorage 的 ref。
|
||||||
|
*
|
||||||
|
* @param key localStorage 键名(建议带模块前缀,如 'tasks.page')
|
||||||
|
* @param defaultValue 默认值(localStorage 无值或解析失败时使用)
|
||||||
|
* @returns 与普通 ref 行为一致的 Ref<T>
|
||||||
|
*/
|
||||||
|
export function usePersistedRef<T>(key: string, defaultValue: T): Ref<T> {
|
||||||
|
// 初始化:读 localStorage,失败/不存在回落到 default
|
||||||
|
const initial = readPersisted(key, defaultValue)
|
||||||
|
const r = ref(initial) as Ref<T>
|
||||||
|
|
||||||
|
// 写回:深度 watch,JSON.stringify 失败静默(防循环引用等异常)
|
||||||
|
watch(
|
||||||
|
r,
|
||||||
|
(val) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, JSON.stringify(val))
|
||||||
|
} catch {
|
||||||
|
/* 忽略:配额溢出 / 序列化失败 / 隐私模式禁用 localStorage */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读 localStorage 并 JSON 解析,失败/不存在返回 defaultValue。仅本模块内部使用。 */
|
||||||
|
function readPersisted<T>(key: string, defaultValue: T): T {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
if (raw === null) return defaultValue
|
||||||
|
return JSON.parse(raw) as T
|
||||||
|
} catch {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,14 @@ export default {
|
|||||||
// UX-260617-08: conversation load/switch failure feedback (network jitter/IPC down → empty list/blank, was silent)
|
// UX-260617-08: conversation load/switch failure feedback (network jitter/IPC down → empty list/blank, was silent)
|
||||||
loadConvFail: 'Failed to load conversations. Please check your network or backend connection.',
|
loadConvFail: 'Failed to load conversations. Please check your network or backend connection.',
|
||||||
switchConvFail: 'Failed to switch conversation. Please retry.',
|
switchConvFail: 'Failed to switch conversation. Please retry.',
|
||||||
|
// M30: rename/archive/pin IPC failure feedback (was no try/catch, exceptions leaked to unhandledrejection)
|
||||||
|
renameConvFail: 'Failed to rename. Please retry.',
|
||||||
|
archiveConvFail: 'Failed to {action}. Please retry.',
|
||||||
|
pinConvFail: 'Failed to {action}. Please retry.',
|
||||||
|
archiveAction: 'archive',
|
||||||
|
unarchiveAction: 'unarchive',
|
||||||
|
pinAction: 'pin',
|
||||||
|
unpinAction: 'unpin',
|
||||||
// AE-2025-07: Agentic loop progress bar copy
|
// AE-2025-07: Agentic loop progress bar copy
|
||||||
// Note: max round / completed tool count are not surfaced by backend this round;
|
// Note: max round / completed tool count are not surfaced by backend this round;
|
||||||
// template conditionally omits them. Add *WithMax keys once backend exposes them.
|
// template conditionally omits them. Add *WithMax keys once backend exposes them.
|
||||||
|
|||||||
@@ -240,5 +240,11 @@ export default {
|
|||||||
// ── AE-2025-04 Session Trust ──
|
// ── AE-2025-04 Session Trust ──
|
||||||
// Auto-approval toast (same-session already approved same-kind op: same tool + same dir)
|
// Auto-approval toast (same-session already approved same-kind op: same tool + same dir)
|
||||||
autoApprovedToast: 'Auto-approved: {tool}({dir})',
|
autoApprovedToast: 'Auto-approved: {tool}({dir})',
|
||||||
|
|
||||||
|
// ── CIStatus.vue (commit CI checks status card) ──
|
||||||
|
ciTitle: 'CI Checks',
|
||||||
|
ciNFailed: '{n} failed',
|
||||||
|
ciNPending: '{n} pending',
|
||||||
|
ciNPassed: '{n} passed',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export default {
|
|||||||
next: 'Next',
|
next: 'Next',
|
||||||
page: 'Page {n}',
|
page: 'Page {n}',
|
||||||
last: '(last)',
|
last: '(last)',
|
||||||
|
total: '{n} total',
|
||||||
},
|
},
|
||||||
|
|
||||||
risk: {
|
risk: {
|
||||||
@@ -42,6 +43,7 @@ export default {
|
|||||||
executing: 'Executing',
|
executing: 'Executing',
|
||||||
completed: 'Completed',
|
completed: 'Completed',
|
||||||
failed: 'Failed',
|
failed: 'Failed',
|
||||||
|
interrupted: 'Interrupted',
|
||||||
},
|
},
|
||||||
|
|
||||||
decided: {
|
decided: {
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export default {
|
|||||||
close: 'Close',
|
close: 'Close',
|
||||||
loading: 'Loading…',
|
loading: 'Loading…',
|
||||||
unknownError: 'Unknown error',
|
unknownError: 'Unknown error',
|
||||||
|
// Collapse/expand (reusable generic verbs for description/log/future long content)
|
||||||
|
expand: 'Expand',
|
||||||
|
collapse: 'Collapse',
|
||||||
// Pagination UI (F-260621-02 P3 baseline; off by default, opt-in per-page)
|
// Pagination UI (F-260621-02 P3 baseline; off by default, opt-in per-page)
|
||||||
pagination: {
|
pagination: {
|
||||||
prev: 'Prev',
|
prev: 'Prev',
|
||||||
|
|||||||
@@ -135,6 +135,8 @@ export default {
|
|||||||
|
|
||||||
// Native dialog text (confirm / alert)
|
// Native dialog text (confirm / alert)
|
||||||
confirmDelete: 'Delete idea "{title}"? This cannot be undone.',
|
confirmDelete: 'Delete idea "{title}"? This cannot be undone.',
|
||||||
|
// ⑥ Status change confirm (terminal promoted/rejected/archived or leaving approved)
|
||||||
|
confirmStatus: 'Change idea "{title}" status from {from} to {to}?',
|
||||||
promoteFailed: 'Promotion failed',
|
promoteFailed: 'Promotion failed',
|
||||||
evalFailed: 'Evaluation failed',
|
evalFailed: 'Evaluation failed',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ export default {
|
|||||||
publish: 'Publish',
|
publish: 'Publish',
|
||||||
reject: 'Reject',
|
reject: 'Reject',
|
||||||
archive: 'Archive',
|
archive: 'Archive',
|
||||||
|
// ⑤ Operation feedback toast
|
||||||
|
savedToast: 'Saved',
|
||||||
|
publishedToast: 'Published to library',
|
||||||
|
rejectedToast: 'Rejected',
|
||||||
|
archivedToast: 'Archived',
|
||||||
|
createdToast: 'Created, moved to pending',
|
||||||
reuseCount: '🔄 Reused {n} times',
|
reuseCount: '🔄 Reused {n} times',
|
||||||
confidenceBadge: 'Confidence: {label}',
|
confidenceBadge: 'Confidence: {label}',
|
||||||
contentLabel: 'Content',
|
contentLabel: 'Content',
|
||||||
@@ -83,6 +89,7 @@ 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',
|
||||||
|
saveFailed: 'Failed to save',
|
||||||
loadConfigFailed: 'Failed to load config',
|
loadConfigFailed: 'Failed to load config',
|
||||||
saveConfigFailed: 'Failed to save config',
|
saveConfigFailed: 'Failed to save config',
|
||||||
extractFailed: 'Extraction failed',
|
extractFailed: 'Extraction failed',
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ export default {
|
|||||||
// Approval actions
|
// Approval actions
|
||||||
approvalConfirm: 'Confirm ({count})',
|
approvalConfirm: 'Confirm ({count})',
|
||||||
approvalCancel: 'Cancel',
|
approvalCancel: 'Cancel',
|
||||||
|
approvalSelectAll: 'Select all',
|
||||||
|
approvalInvert: 'Invert',
|
||||||
// Tech stack
|
// Tech stack
|
||||||
techStackLabel: 'Tech Stack',
|
techStackLabel: 'Tech Stack',
|
||||||
// Tab navigation
|
// Tab navigation
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ export default {
|
|||||||
// Header actions
|
// Header actions
|
||||||
create: '+ New Project',
|
create: '+ New Project',
|
||||||
trash: '🗑 Trash',
|
trash: '🗑 Trash',
|
||||||
|
// View mode toggle (compact list default / spacious card)
|
||||||
|
viewModeTitle: 'Switch view mode',
|
||||||
|
viewList: 'List view',
|
||||||
|
viewCard: 'Card view',
|
||||||
// New project modal
|
// New project modal
|
||||||
createTitle: 'New Project',
|
createTitle: 'New Project',
|
||||||
nameLabel: 'Project Name',
|
nameLabel: 'Project Name',
|
||||||
|
|||||||
@@ -67,8 +67,9 @@ export default {
|
|||||||
fetchHintSaveFirst: 'Save the provider first, then fetch models',
|
fetchHintSaveFirst: 'Save the provider first, then fetch models',
|
||||||
labelWeight: 'Weight',
|
labelWeight: 'Weight',
|
||||||
modelListTitle: 'Fetched models ({count})',
|
modelListTitle: 'Fetched models ({count})',
|
||||||
modelListHint: 'Enabled / weight are session-local edits; the next "Test connection" overwrites with fresh probe results',
|
modelListHint: 'After toggling enabled / weight, click "Save" below to write them into the provider config; the next "Test connection" overwrites with fresh probe results',
|
||||||
saveModels: 'Save model config',
|
saveModels: 'Save model config',
|
||||||
|
modelListUnsavedHint: 'Model list has unsaved changes (enabled / weight) — closing or navigating away will discard them',
|
||||||
toastFetchOk: 'Fetched {count} models',
|
toastFetchOk: 'Fetched {count} models',
|
||||||
toastFetchFail: 'Fetch failed: {msg}',
|
toastFetchFail: 'Fetch failed: {msg}',
|
||||||
fetchFailedTimeout: 'Fetch timed out, check that the Base URL is reachable: {msg}',
|
fetchFailedTimeout: 'Fetch timed out, check that the Base URL is reachable: {msg}',
|
||||||
@@ -213,6 +214,8 @@ export default {
|
|||||||
// ===== Toast =====
|
// ===== Toast =====
|
||||||
toastLoadProviderFail: 'Failed to load providers',
|
toastLoadProviderFail: 'Failed to load providers',
|
||||||
toastSaveIncomplete: 'Please fill in all fields (Name / Base URL / API Key / Model)',
|
toastSaveIncomplete: 'Please fill in all fields (Name / Base URL / API Key / Model)',
|
||||||
|
// L19: confirm before discarding an edited form
|
||||||
|
confirmDiscardUnsaved: 'The form has unsaved changes. Discard them?',
|
||||||
toastSaved: 'Saved',
|
toastSaved: 'Saved',
|
||||||
toastSaveFail: 'Save failed: {msg}',
|
toastSaveFail: 'Save failed: {msg}',
|
||||||
// P0-2: save-failure classified guidance (persistent banner, not raw Err text)
|
// P0-2: save-failure classified guidance (persistent banner, not raw Err text)
|
||||||
@@ -225,6 +228,7 @@ export default {
|
|||||||
toastSetDefaultOk: 'Set as default',
|
toastSetDefaultOk: 'Set as default',
|
||||||
toastSetDefaultFail: 'Failed to set default: {msg}',
|
toastSetDefaultFail: 'Failed to set default: {msg}',
|
||||||
toastConnIncomplete: 'Please fill in Name and Host',
|
toastConnIncomplete: 'Please fill in Name and Host',
|
||||||
|
toastConnPortInvalid: 'Port must be between 1 and 65535',
|
||||||
|
|
||||||
// ===== Import / Export (phase 6) =====
|
// ===== Import / Export (phase 6) =====
|
||||||
export: 'Export',
|
export: 'Export',
|
||||||
|
|||||||
@@ -62,5 +62,10 @@ export default {
|
|||||||
workflowEdgeDetail: 'Edge Condition Editor',
|
workflowEdgeDetail: 'Edge Condition Editor',
|
||||||
workflowUnconditional: 'Unconditional',
|
workflowUnconditional: 'Unconditional',
|
||||||
workflowCondPlaceholder: 'Enter condition expression (e.g. outputs.done == true)',
|
workflowCondPlaceholder: 'Enter condition expression (e.g. outputs.done == true)',
|
||||||
|
// Description collapse (show expand button beyond 400px, Problem4 redesign)
|
||||||
|
expand: 'Expand',
|
||||||
|
collapse: 'Collapse',
|
||||||
|
// Related info panel title (wide-screen right column / info grouping)
|
||||||
|
relatedTitle: 'Related',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ export default {
|
|||||||
// UX-260617-08:对话加载/切换失败反馈(网络抖动/IPC 断开 → 列表空/切换空白,原静默无反馈)
|
// UX-260617-08:对话加载/切换失败反馈(网络抖动/IPC 断开 → 列表空/切换空白,原静默无反馈)
|
||||||
loadConvFail: '对话列表加载失败,请检查网络或后端连接',
|
loadConvFail: '对话列表加载失败,请检查网络或后端连接',
|
||||||
switchConvFail: '对话切换失败,请重试',
|
switchConvFail: '对话切换失败,请重试',
|
||||||
|
// M30:重命名/归档/置顶 IPC 失败反馈(原无 try/catch 异常进 unhandledrejection,用户无感)
|
||||||
|
renameConvFail: '重命名失败,请重试',
|
||||||
|
archiveConvFail: '{action}失败,请重试',
|
||||||
|
pinConvFail: '{action}失败,请重试',
|
||||||
|
archiveAction: '归档',
|
||||||
|
unarchiveAction: '取消归档',
|
||||||
|
pinAction: '置顶',
|
||||||
|
unpinAction: '取消置顶',
|
||||||
// AE-2025-07: Agentic 循环进度条文案
|
// AE-2025-07: Agentic 循环进度条文案
|
||||||
// 注:max 轮次/已完成工具数本轮后端暂不透传,模板条件渲染省略;后端补全后新增 *WithMax key
|
// 注:max 轮次/已完成工具数本轮后端暂不透传,模板条件渲染省略;后端补全后新增 *WithMax key
|
||||||
agenticProgress: '🔄 循环 {round} · ⏳{pending}待审批',
|
agenticProgress: '🔄 循环 {round} · ⏳{pending}待审批',
|
||||||
|
|||||||
@@ -241,5 +241,11 @@ export default {
|
|||||||
// ── AE-2025-04 会话级信任(Session Trust) ──
|
// ── AE-2025-04 会话级信任(Session Trust) ──
|
||||||
// 自动放行 toast(同会话已批准过同类操作:同工具+同目录,轻量 info 非审批气泡)
|
// 自动放行 toast(同会话已批准过同类操作:同工具+同目录,轻量 info 非审批气泡)
|
||||||
autoApprovedToast: '已自动放行: {tool}({dir})',
|
autoApprovedToast: '已自动放行: {tool}({dir})',
|
||||||
|
|
||||||
|
// ── CIStatus.vue(代码提交 CI 检查状态卡)──
|
||||||
|
ciTitle: 'CI 检查',
|
||||||
|
ciNFailed: '{n} 项失败',
|
||||||
|
ciNPending: '{n} 项进行中',
|
||||||
|
ciNPassed: '{n} 项通过',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export default {
|
|||||||
next: '下一页',
|
next: '下一页',
|
||||||
page: '第 {n} 页',
|
page: '第 {n} 页',
|
||||||
last: '(末页)',
|
last: '(末页)',
|
||||||
|
total: '共 {n} 条',
|
||||||
},
|
},
|
||||||
|
|
||||||
risk: {
|
risk: {
|
||||||
@@ -42,6 +43,7 @@ export default {
|
|||||||
executing: '执行中',
|
executing: '执行中',
|
||||||
completed: '已完成',
|
completed: '已完成',
|
||||||
failed: '失败',
|
failed: '失败',
|
||||||
|
interrupted: '已中断',
|
||||||
},
|
},
|
||||||
|
|
||||||
decided: {
|
decided: {
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export default {
|
|||||||
close: '关闭',
|
close: '关闭',
|
||||||
loading: '加载中…',
|
loading: '加载中…',
|
||||||
unknownError: '未知错误',
|
unknownError: '未知错误',
|
||||||
|
// 折叠/展开(可复用通用词,描述折叠/日志折叠/未来长内容)
|
||||||
|
expand: '展开',
|
||||||
|
collapse: '收起',
|
||||||
// 分页 UI(F-260621-02 P3 基建,默认不分页,用户选每页 N 条才触发)
|
// 分页 UI(F-260621-02 P3 基建,默认不分页,用户选每页 N 条才触发)
|
||||||
pagination: {
|
pagination: {
|
||||||
prev: '上一页',
|
prev: '上一页',
|
||||||
|
|||||||
@@ -135,6 +135,8 @@ export default {
|
|||||||
|
|
||||||
// 原生对话框文案(confirm / alert)
|
// 原生对话框文案(confirm / alert)
|
||||||
confirmDelete: '确定删除灵感「{title}」?此操作不可撤销。',
|
confirmDelete: '确定删除灵感「{title}」?此操作不可撤销。',
|
||||||
|
// ⑥ 状态切换确认(终态 promoted/rejected/archived 或离开 approved 失去立项入口)
|
||||||
|
confirmStatus: '将灵感「{title}」状态从 {from} 改为 {to}?',
|
||||||
promoteFailed: '立项失败',
|
promoteFailed: '立项失败',
|
||||||
evalFailed: '评估失败',
|
evalFailed: '评估失败',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ export default {
|
|||||||
publish: '发布',
|
publish: '发布',
|
||||||
reject: '拒绝',
|
reject: '拒绝',
|
||||||
archive: '归档',
|
archive: '归档',
|
||||||
|
// ⑤ 操作反馈 toast
|
||||||
|
savedToast: '已保存',
|
||||||
|
publishedToast: '已发布到知识库',
|
||||||
|
rejectedToast: '已拒绝',
|
||||||
|
archivedToast: '已归档',
|
||||||
|
createdToast: '已创建,进入待处理',
|
||||||
// 详情徽章
|
// 详情徽章
|
||||||
reuseCount: '🔄 复用 {n} 次',
|
reuseCount: '🔄 复用 {n} 次',
|
||||||
confidenceBadge: '置信度: {label}',
|
confidenceBadge: '置信度: {label}',
|
||||||
@@ -99,6 +105,7 @@ export default {
|
|||||||
createFailed: '创建知识失败',
|
createFailed: '创建知识失败',
|
||||||
updateStatusFailed: '更新状态失败',
|
updateStatusFailed: '更新状态失败',
|
||||||
archiveFailed: '归档失败',
|
archiveFailed: '归档失败',
|
||||||
|
saveFailed: '保存失败',
|
||||||
loadConfigFailed: '加载配置失败',
|
loadConfigFailed: '加载配置失败',
|
||||||
saveConfigFailed: '保存配置失败',
|
saveConfigFailed: '保存配置失败',
|
||||||
extractFailed: '立即抽取失败',
|
extractFailed: '立即抽取失败',
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ export default {
|
|||||||
// 审批操作
|
// 审批操作
|
||||||
approvalConfirm: '确认({count})',
|
approvalConfirm: '确认({count})',
|
||||||
approvalCancel: '取消',
|
approvalCancel: '取消',
|
||||||
|
approvalSelectAll: '全选',
|
||||||
|
approvalInvert: '反选',
|
||||||
// 技术栈
|
// 技术栈
|
||||||
techStackLabel: '技术栈',
|
techStackLabel: '技术栈',
|
||||||
// Tab 导航
|
// Tab 导航
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ export default {
|
|||||||
// 头部操作
|
// 头部操作
|
||||||
create: '+ 新建项目',
|
create: '+ 新建项目',
|
||||||
trash: '🗑 回收站',
|
trash: '🗑 回收站',
|
||||||
|
// 视图模式切换(list 紧凑单列默认 / card 宽松双列)
|
||||||
|
viewModeTitle: '切换视图模式',
|
||||||
|
viewList: '列表视图',
|
||||||
|
viewCard: '卡片视图',
|
||||||
// 新建项目模态框
|
// 新建项目模态框
|
||||||
createTitle: '新建项目',
|
createTitle: '新建项目',
|
||||||
nameLabel: '项目名称',
|
nameLabel: '项目名称',
|
||||||
|
|||||||
@@ -66,8 +66,9 @@ export default {
|
|||||||
fetchHintSaveFirst: '请先保存 Provider 后再拉取模型',
|
fetchHintSaveFirst: '请先保存 Provider 后再拉取模型',
|
||||||
labelWeight: '权重',
|
labelWeight: '权重',
|
||||||
modelListTitle: '已拉取模型({count} 个)',
|
modelListTitle: '已拉取模型({count} 个)',
|
||||||
modelListHint: '已启用 / 权重为本次会话内调整,下次「测试连接」会以最新探测结果覆盖',
|
modelListHint: '勾选 / 权重调整后,请点下方「保存」一并写入 Provider 配置;下次「测试连接」会以最新探测结果覆盖',
|
||||||
saveModels: '保存模型配置',
|
saveModels: '保存模型配置',
|
||||||
|
modelListUnsavedHint: '模型列表有未保存的调整(启用 / 权重),关闭或切走将丢弃',
|
||||||
toastFetchOk: '已拉取 {count} 个模型',
|
toastFetchOk: '已拉取 {count} 个模型',
|
||||||
toastFetchFail: '拉取模型失败:{msg}',
|
toastFetchFail: '拉取模型失败:{msg}',
|
||||||
fetchFailedTimeout: '拉取超时,请检查 Base URL 是否可达:{msg}',
|
fetchFailedTimeout: '拉取超时,请检查 Base URL 是否可达:{msg}',
|
||||||
@@ -212,6 +213,8 @@ export default {
|
|||||||
// ===== Toast 提示 =====
|
// ===== Toast 提示 =====
|
||||||
toastLoadProviderFail: '加载提供商失败',
|
toastLoadProviderFail: '加载提供商失败',
|
||||||
toastSaveIncomplete: '请填写完整(名称 / Base URL / API Key / 模型)',
|
toastSaveIncomplete: '请填写完整(名称 / Base URL / API Key / 模型)',
|
||||||
|
// L19: 表单有改动时取消/关闭的二次确认
|
||||||
|
confirmDiscardUnsaved: '表单有未保存的改动,确定放弃吗?',
|
||||||
toastSaved: '已保存',
|
toastSaved: '已保存',
|
||||||
toastSaveFail: '保存失败:{msg}',
|
toastSaveFail: '保存失败:{msg}',
|
||||||
// P0-2: 保存失败分类建议(常驻错误横幅,非裸 Err 文本)
|
// P0-2: 保存失败分类建议(常驻错误横幅,非裸 Err 文本)
|
||||||
@@ -224,6 +227,7 @@ export default {
|
|||||||
toastSetDefaultOk: '已设为默认',
|
toastSetDefaultOk: '已设为默认',
|
||||||
toastSetDefaultFail: '设置默认失败:{msg}',
|
toastSetDefaultFail: '设置默认失败:{msg}',
|
||||||
toastConnIncomplete: '请填写名称和 Host',
|
toastConnIncomplete: '请填写名称和 Host',
|
||||||
|
toastConnPortInvalid: '端口必须在 1-65535 范围内',
|
||||||
|
|
||||||
// ===== 导入 / 导出(阶段6) =====
|
// ===== 导入 / 导出(阶段6) =====
|
||||||
export: '导出',
|
export: '导出',
|
||||||
|
|||||||
@@ -62,5 +62,10 @@ export default {
|
|||||||
workflowEdgeDetail: '边条件编辑',
|
workflowEdgeDetail: '边条件编辑',
|
||||||
workflowUnconditional: '无条件',
|
workflowUnconditional: '无条件',
|
||||||
workflowCondPlaceholder: '输入条件表达式(如 outputs.done == true)',
|
workflowCondPlaceholder: '输入条件表达式(如 outputs.done == true)',
|
||||||
|
// 描述折叠(超 400px 显展开按钮,Problem4 重设计)
|
||||||
|
expand: '展开',
|
||||||
|
collapse: '收起',
|
||||||
|
// 关联信息面板标题(宽屏右栏 / 信息分组)
|
||||||
|
relatedTitle: '关联信息',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
>
|
>
|
||||||
<span v-if="processingId === tc.id" class="popup-btn-spinner"></span>
|
<span v-if="processingId === tc.id" class="popup-btn-spinner"></span>
|
||||||
<svg v-else width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
<svg v-else width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
{{ $t('aiChat.dirAuthOnce') }}
|
{{ $t('aiChat.dirAuthOnce') }}<span class="popup-btn-key">1</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="popup-btn popup-btn--session"
|
class="popup-btn popup-btn--session"
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
@click.stop="onApproveAlways(tc)"
|
@click.stop="onApproveAlways(tc)"
|
||||||
>
|
>
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
{{ $t('aiChat.dirAuthAlways') }}
|
{{ $t('aiChat.dirAuthAlways') }}<span class="popup-btn-key">2</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="popup-btn popup-btn--reject"
|
class="popup-btn popup-btn--reject"
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
@click.stop="onReject(tc)"
|
@click.stop="onReject(tc)"
|
||||||
>
|
>
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
{{ $t('aiChat.dirAuthDeny') }}
|
{{ $t('aiChat.dirAuthDeny') }}<span class="popup-btn-key">3</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="popup-item-actions">
|
<div v-else class="popup-item-actions">
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
>
|
>
|
||||||
<span v-if="processingId === tc.id" class="popup-btn-spinner"></span>
|
<span v-if="processingId === tc.id" class="popup-btn-spinner"></span>
|
||||||
<svg v-else width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
<svg v-else width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
{{ $t('aiTool.approve') }}
|
{{ $t('aiTool.approve') }}<span class="popup-btn-key">1</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="popup-btn popup-btn--reject"
|
class="popup-btn popup-btn--reject"
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
@click.stop="onReject(tc)"
|
@click.stop="onReject(tc)"
|
||||||
>
|
>
|
||||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||||
{{ $t('aiTool.reject') }}
|
{{ $t('aiTool.reject') }}<span class="popup-btn-key">3</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -244,11 +244,45 @@ let _closeFallbackTimer: ReturnType<typeof setTimeout> | null = null
|
|||||||
const CLOSE_TIMEOUT_MS = 1500
|
const CLOSE_TIMEOUT_MS = 1500
|
||||||
|
|
||||||
/** Esc 键关浮窗(键盘可达性)。模块级命名函数,onBeforeUnmount 据此精确移除同一引用。
|
/** Esc 键关浮窗(键盘可达性)。模块级命名函数,onBeforeUnmount 据此精确移除同一引用。
|
||||||
* 仅响应 Esc(KeyDown),其他键透传(不拦截 Tab/Enter 等原生 button 导航)。 */
|
* 仅响应 Esc(KeyDown),其他键透传(不拦截 Tab/Enter 等原生 button 导航)。
|
||||||
|
*
|
||||||
|
* 7c:数字键快捷审批(对列表顶部第一条生效,多审批场景用户应逐条点或用方向键导航)。
|
||||||
|
* - path 类:1=once / 2=always / 3=deny(deny 仍走二次确认,与按钮同款不可逆守卫)
|
||||||
|
* - risk 类:1=approve / 3=reject(2 仅 path 类有 always,risk 类忽略 '2')
|
||||||
|
* 守卫:
|
||||||
|
* - 确认对话框打开时(confirmState.visible)禁用快捷键,避免与确认按钮的 Enter/Esc 冲突
|
||||||
|
* - 处理中(processingId)时忽略,防重复触发
|
||||||
|
* - 输入元素聚焦时不拦截(input/textarea/contenteditable 场景,本浮窗暂无 input 但防御)
|
||||||
|
* - Ctrl/Meta/Alt 组合不拦截(避免劫持浏览器/系统快捷键) */
|
||||||
function _onKeyDown(e: KeyboardEvent) {
|
function _onKeyDown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
void onClose()
|
void onClose()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 7c:数字键快捷审批
|
||||||
|
if (e.key === '1' || e.key === '2' || e.key === '3') {
|
||||||
|
// 组合键(Ctrl+1 等)不拦截;确认对话框打开时不拦截;处理中不拦截
|
||||||
|
if (e.ctrlKey || e.metaKey || e.altKey) return
|
||||||
|
if (confirmState.visible) return
|
||||||
|
if (processingId.value) return
|
||||||
|
// 输入元素聚焦时不拦截(防御:本浮窗无 input,但 ConfirmDialog 未来或有)
|
||||||
|
const tgt = e.target as HTMLElement | null
|
||||||
|
if (tgt && (tgt.tagName === 'INPUT' || tgt.tagName === 'TEXTAREA' || tgt.isContentEditable)) return
|
||||||
|
const first = approvals.value[0]
|
||||||
|
if (!first) return
|
||||||
|
e.preventDefault()
|
||||||
|
if (e.key === '1') {
|
||||||
|
// 1 = once(path) / approve(risk)
|
||||||
|
if (first.kind === 'path') void onApproveOnce(first)
|
||||||
|
else void onApprove(first)
|
||||||
|
} else if (e.key === '2') {
|
||||||
|
// 2 = always(仅 path 类;risk 类无 always,忽略)
|
||||||
|
if (first.kind === 'path') void onApproveAlways(first)
|
||||||
|
} else {
|
||||||
|
// 3 = deny(path) / reject(risk)
|
||||||
|
void onReject(first)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -683,6 +717,24 @@ function onDragEnd() { isDragging.value = false }
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
/* 7c:数字键快捷键标记(1/2/3 对应 once/approve、always、deny/reject)。
|
||||||
|
小尺寸单字符徽章,提示键盘可达性;继承父按钮对比色但降透明度作辅助信息层。 */
|
||||||
|
.popup-btn-key {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 13px;
|
||||||
|
height: 13px;
|
||||||
|
padding: 0 3px;
|
||||||
|
margin-left: 1px;
|
||||||
|
border-radius: var(--df-radius-xs, 3px);
|
||||||
|
font-family: var(--df-font-mono);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
background: rgba(0, 0, 0, 0.18);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
.popup-btn--approve {
|
.popup-btn--approve {
|
||||||
background: var(--df-accent);
|
background: var(--df-accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
|||||||
+82
-31
@@ -92,17 +92,18 @@
|
|||||||
<!-- 分页 -->
|
<!-- 分页 -->
|
||||||
<div v-if="!loading && !errorMsg" class="pager">
|
<div v-if="!loading && !errorMsg" class="pager">
|
||||||
<button class="btn btn-ghost btn-sm" :disabled="offset === 0" @click="prevPage">{{ t('auditLog.pager.prev') }}</button>
|
<button class="btn btn-ghost btn-sm" :disabled="offset === 0" @click="prevPage">{{ t('auditLog.pager.prev') }}</button>
|
||||||
<span class="pager-info">{{ t('auditLog.pager.page', { n: page }) }}{{ hasMore ? '' : t('auditLog.pager.last') }}</span>
|
<span class="pager-info">{{ t('auditLog.pager.page', { n: page }) }}{{ hasMore ? '' : t('auditLog.pager.last') }}<span class="pager-total">{{ t('auditLog.pager.total', { n: total }) }}</span></span>
|
||||||
<button class="btn btn-ghost btn-sm" :disabled="!hasMore" @click="nextPage">{{ t('auditLog.pager.next') }}</button>
|
<button class="btn btn-ghost btn-sm" :disabled="!hasMore" @click="nextPage">{{ t('auditLog.pager.next') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import { formatRelative, formatDate } from '@/utils/time'
|
import { formatRelative, formatDate } from '@/utils/time'
|
||||||
|
import { usePersistedRef } from '@/composables/usePersistedRef'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -122,40 +123,61 @@ interface ToolExecutionRecord {
|
|||||||
decided_by: string | null
|
decided_by: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 后端分页结果 {items,total,has_more}(对标项目通用结构)
|
||||||
|
interface ToolExecutionPage {
|
||||||
|
items: ToolExecutionRecord[]
|
||||||
|
total: number
|
||||||
|
has_more: boolean
|
||||||
|
}
|
||||||
|
|
||||||
const PAGE_SIZE = 50
|
const PAGE_SIZE = 50
|
||||||
const records = ref<ToolExecutionRecord[]>([])
|
const records = ref<ToolExecutionRecord[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
// hasMore 来自后端(基于 offset+items.len()<total 推断),非"本页满 PAGE_SIZE"启发式
|
||||||
|
const hasMore = ref(false)
|
||||||
const offset = ref(0)
|
const offset = ref(0)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
|
|
||||||
// 筛选状态(客户端过滤,数据量轻量)
|
// 筛选状态(后端 WHERE 收口,非客户端 filter 当前页)。持久化到 localStorage。
|
||||||
const statusFilter = ref('')
|
// 空串 = 不过滤(对应后端 Option<String>=None)。
|
||||||
const riskFilter = ref('')
|
const statusFilter = usePersistedRef('auditLog.statusFilter', '')
|
||||||
const toolSearch = ref('')
|
const riskFilter = usePersistedRef('auditLog.riskFilter', '')
|
||||||
|
const toolSearch = usePersistedRef('auditLog.toolSearch', '')
|
||||||
|
|
||||||
// 展开行(查看完整 args/result)
|
// 展开行(查看完整 args/result)
|
||||||
const expandedId = ref<string | null>(null)
|
const expandedId = ref<string | null>(null)
|
||||||
|
|
||||||
const statusKeys = ['pending', 'approved', 'rejected', 'executing', 'completed', 'failed']
|
// interrupted 来自 cleanup_stale_pending(超时 pending 标记),与 statusKeys 一并展示
|
||||||
|
const statusKeys = ['pending', 'approved', 'rejected', 'executing', 'completed', 'failed', 'interrupted']
|
||||||
const riskKeys = ['low', 'medium', 'high']
|
const riskKeys = ['low', 'medium', 'high']
|
||||||
|
|
||||||
const page = computed(() => Math.floor(offset.value / PAGE_SIZE) + 1)
|
const page = computed(() => Math.floor(offset.value / PAGE_SIZE) + 1)
|
||||||
// 下一页存在性:本页满 PAGE_SIZE 视为可能还有更多(hasMore);末页不足时点下一页会拉空,自动回退
|
|
||||||
const hasMore = computed(() => records.value.length === PAGE_SIZE)
|
|
||||||
|
|
||||||
// 客户端筛选(状态/风险/工具名)
|
// 筛选条件变化 → 重置分页 + 关闭展开行 + 重拉(后端 WHERE)
|
||||||
// 后端 list_tool_executions 不支持 WHERE 筛选,数据量小(每页 50),前端 filter 轻量。
|
// 下拉框(status/risk)即变即查;工具名自由文本输入走 300ms 防抖(对齐 Tasks.vue searchKeyword)。
|
||||||
const filteredRecords = computed(() => {
|
watch([statusFilter, riskFilter], () => {
|
||||||
let list = records.value
|
offset.value = 0
|
||||||
if (statusFilter.value) list = list.filter(r => r.status === statusFilter.value)
|
expandedId.value = null
|
||||||
if (riskFilter.value) list = list.filter(r => r.risk_level === riskFilter.value)
|
void load()
|
||||||
const q = toolSearch.value.trim().toLowerCase()
|
|
||||||
if (q) list = list.filter(r => r.tool_name.toLowerCase().includes(q))
|
|
||||||
return list
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 筛选变化时关闭展开行(避免展开行被筛掉后残留)
|
let _searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
watch([statusFilter, riskFilter, toolSearch], () => { expandedId.value = null })
|
watch(toolSearch, () => {
|
||||||
|
if (_searchTimer) clearTimeout(_searchTimer)
|
||||||
|
_searchTimer = setTimeout(() => {
|
||||||
|
offset.value = 0
|
||||||
|
expandedId.value = null
|
||||||
|
void load()
|
||||||
|
}, 300)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (_searchTimer) clearTimeout(_searchTimer)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 列表展示用 records(后端已按筛选条件过滤,前端不再二次 filter)
|
||||||
|
const filteredRecords = computed(() => records.value)
|
||||||
|
|
||||||
function toggleExpand(id: string) {
|
function toggleExpand(id: string) {
|
||||||
expandedId.value = expandedId.value === id ? null : id
|
expandedId.value = expandedId.value === id ? null : id
|
||||||
@@ -172,20 +194,33 @@ function durationLabel(requested: string, executed: string): string {
|
|||||||
return `${m}m${s % 60}s`
|
return `${m}m${s % 60}s`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 构造后端查询参数(空串/纯空白 → undefined = 不过滤)
|
||||||
|
function buildQuery() {
|
||||||
|
const q: {
|
||||||
|
status?: string
|
||||||
|
risk_level?: string
|
||||||
|
tool_keyword?: string
|
||||||
|
limit: number
|
||||||
|
offset: number
|
||||||
|
} = { limit: PAGE_SIZE, offset: offset.value }
|
||||||
|
if (statusFilter.value) q.status = statusFilter.value
|
||||||
|
if (riskFilter.value) q.risk_level = riskFilter.value
|
||||||
|
const kw = toolSearch.value.trim()
|
||||||
|
if (kw) q.tool_keyword = kw
|
||||||
|
return q
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
errorMsg.value = ''
|
errorMsg.value = ''
|
||||||
try {
|
try {
|
||||||
const list = await invoke<ToolExecutionRecord[]>('list_tool_executions', {
|
const result = await invoke<ToolExecutionPage>('list_tool_executions', {
|
||||||
limit: PAGE_SIZE,
|
query: buildQuery(),
|
||||||
offset: offset.value,
|
|
||||||
})
|
})
|
||||||
records.value = list
|
records.value = result.items
|
||||||
// 下一页拉到空数组 → 应回退到上一页(避免停在空页)
|
total.value = result.total
|
||||||
if (list.length === 0 && offset.value > 0) {
|
hasMore.value = result.has_more
|
||||||
offset.value = Math.max(0, offset.value - PAGE_SIZE)
|
// 后端 total 已正确,空结果即真无数据(不再像旧"满页推断"需回退)
|
||||||
await load()
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorMsg.value = String(e)
|
errorMsg.value = String(e)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -206,6 +241,7 @@ function prevPage() {
|
|||||||
void load()
|
void load()
|
||||||
}
|
}
|
||||||
function nextPage() {
|
function nextPage() {
|
||||||
|
if (!hasMore.value) return
|
||||||
offset.value += PAGE_SIZE
|
offset.value += PAGE_SIZE
|
||||||
expandedId.value = null
|
expandedId.value = null
|
||||||
void load()
|
void load()
|
||||||
@@ -230,6 +266,7 @@ function statusClass(s: string): string {
|
|||||||
executing: 'status-executing',
|
executing: 'status-executing',
|
||||||
completed: 'status-completed',
|
completed: 'status-completed',
|
||||||
failed: 'status-failed',
|
failed: 'status-failed',
|
||||||
|
interrupted: 'status-interrupted',
|
||||||
} as Record<string, string>)[s] ?? 'status-pending'
|
} as Record<string, string>)[s] ?? 'status-pending'
|
||||||
}
|
}
|
||||||
function decidedLabel(d: string): string {
|
function decidedLabel(d: string): string {
|
||||||
@@ -322,8 +359,16 @@ onMounted(() => {
|
|||||||
.col-risk { width: 56px; }
|
.col-risk { width: 56px; }
|
||||||
.col-status { width: 80px; }
|
.col-status { width: 80px; }
|
||||||
.col-decided { width: 64px; }
|
.col-decided { width: 64px; }
|
||||||
.col-args { min-width: 200px; }
|
.col-args { min-width: 200px; max-width: 320px; }
|
||||||
.col-result { min-width: 220px; }
|
.col-result { min-width: 220px; max-width: 360px; }
|
||||||
|
/* 表格内 args/result 单元格限高 + 截断(长内容不撑爆行),完整内容点行展开查 detail-pre */
|
||||||
|
.col-args .brief, .col-result .brief {
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 48px; /* 约 3 行(行高 ~16px),超长内容折叠,展开行看完整 */
|
||||||
|
}
|
||||||
|
|
||||||
/* 可点击行 */
|
/* 可点击行 */
|
||||||
.audit-row { cursor: pointer; transition: background 0.12s; }
|
.audit-row { cursor: pointer; transition: background 0.12s; }
|
||||||
@@ -397,6 +442,7 @@ onMounted(() => {
|
|||||||
.status-executing { background: var(--df-info-bg); color: var(--df-info); }
|
.status-executing { background: var(--df-info-bg); color: var(--df-info); }
|
||||||
.status-completed { background: var(--df-success-bg); color: var(--df-success); }
|
.status-completed { background: var(--df-success-bg); color: var(--df-success); }
|
||||||
.status-failed { background: var(--df-danger-bg); color: var(--df-danger); }
|
.status-failed { background: var(--df-danger-bg); color: var(--df-danger); }
|
||||||
|
.status-interrupted { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||||
|
|
||||||
/* 决策者 tag */
|
/* 决策者 tag */
|
||||||
.decided-tag {
|
.decided-tag {
|
||||||
@@ -423,6 +469,11 @@ onMounted(() => {
|
|||||||
color: var(--df-text-dim);
|
color: var(--df-text-dim);
|
||||||
font-family: var(--df-font-mono);
|
font-family: var(--df-font-mono);
|
||||||
}
|
}
|
||||||
|
.pager-total {
|
||||||
|
margin-left: 8px;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
/* empty-state 已提取到 global.css 全局 */
|
/* empty-state 已提取到 global.css 全局 */
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+59
-6
@@ -24,9 +24,6 @@
|
|||||||
<button class="error-dismiss" @click="errorMsg = ''">✕</button>
|
<button class="error-dismiss" @click="errorMsg = ''">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ═══ Stat Cards ═══ -->
|
|
||||||
<StatCardRow />
|
|
||||||
|
|
||||||
<!-- ═══ Main Grid ═══ -->
|
<!-- ═══ Main Grid ═══ -->
|
||||||
<div class="dash-grid">
|
<div class="dash-grid">
|
||||||
<!-- Left: Active Projects -->
|
<!-- Left: Active Projects -->
|
||||||
@@ -38,17 +35,36 @@
|
|||||||
<IdeasPanel />
|
<IdeasPanel />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══ 统计概要(底部一行小字,原独立统计卡已下沉) ═══ -->
|
||||||
|
<div class="dash-stats-inline">
|
||||||
|
<span class="dash-stats-item clickable" @click="go('/ideas')" :title="$t('dashboard.viewAll')">
|
||||||
|
<span class="dash-stats-num">{{ store.stats.ideas }}</span>{{ $t('dashboard.stats.ideas') }}
|
||||||
|
</span>
|
||||||
|
<span class="dash-stats-sep">·</span>
|
||||||
|
<span class="dash-stats-item clickable" @click="go('/projects')" :title="$t('dashboard.viewAll')">
|
||||||
|
<span class="dash-stats-num">{{ store.stats.projects }}</span>{{ $t('dashboard.stats.projects') }}
|
||||||
|
</span>
|
||||||
|
<span class="dash-stats-sep">·</span>
|
||||||
|
<span class="dash-stats-item clickable" @click="go('/tasks')" :title="$t('dashboard.viewAll')">
|
||||||
|
<span class="dash-stats-num">{{ store.stats.activeTasks }}</span>{{ $t('dashboard.stats.activeTasks') }}
|
||||||
|
</span>
|
||||||
|
<span class="dash-stats-sep">·</span>
|
||||||
|
<span class="dash-stats-item">
|
||||||
|
<span class="dash-stats-num">{{ store.stats.drafts }}</span>{{ $t('dashboard.stats.drafts') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// 仪表盘页壳 — 仅保留:Header(refresh/quickCapture) + 错误条 + 布局栅格 + 加载编排(loadAll)。
|
// 仪表盘页壳 — 仅保留:Header(refresh/quickCapture) + 错误条 + 布局栅格 + 加载编排(loadAll) +
|
||||||
// 统计卡/项目面板/灵感面板已抽至 components/dashboard/ 子组件,各自共享 project store(全局单例),无需 props。
|
// 底部统计概要行(原独立统计卡 StatCardRow 已下沉为底部一行小字,空间让给项目/灵感面板)。
|
||||||
|
// 项目面板/灵感面板已抽至 components/dashboard/ 子组件,各自共享 project store(全局单例),无需 props。
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useProjectStore } from '@/stores/project'
|
import { useProjectStore } from '@/stores/project'
|
||||||
import StatCardRow from '@/components/dashboard/StatCardRow.vue'
|
|
||||||
import ActiveProjectsPanel from '@/components/dashboard/ActiveProjectsPanel.vue'
|
import ActiveProjectsPanel from '@/components/dashboard/ActiveProjectsPanel.vue'
|
||||||
import IdeasPanel from '@/components/dashboard/IdeasPanel.vue'
|
import IdeasPanel from '@/components/dashboard/IdeasPanel.vue'
|
||||||
|
|
||||||
@@ -89,6 +105,11 @@ function quickCapture() {
|
|||||||
router.push('/ideas')
|
router.push('/ideas')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 底部统计概要项点击跳转(可点击项:ideas/projects/tasks;drafts 不可点)
|
||||||
|
function go(link: string) {
|
||||||
|
router.push(link)
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadAll()
|
loadAll()
|
||||||
})
|
})
|
||||||
@@ -143,6 +164,38 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
.dash-right { display: flex; flex-direction: column; gap: 12px; }
|
.dash-right { display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
|
||||||
|
/* ═══ 底部统计概要(一行小字,原独立统计卡下沉) ═══ */
|
||||||
|
.dash-stats-inline {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 14px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 0.5px solid var(--df-border);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.dash-stats-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-weight: 450;
|
||||||
|
}
|
||||||
|
.dash-stats-item.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s var(--df-ease);
|
||||||
|
}
|
||||||
|
.dash-stats-item.clickable:hover { color: var(--df-accent); }
|
||||||
|
.dash-stats-num {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--df-text);
|
||||||
|
letter-spacing: -0.3px;
|
||||||
|
}
|
||||||
|
.dash-stats-sep { color: var(--df-text-dim); opacity: 0.6; }
|
||||||
|
|
||||||
/* ═══ Responsive ═══ */
|
/* ═══ Responsive ═══ */
|
||||||
|
|
||||||
/* 窄窗口:单栏(与 AppLayout 768px 断点对齐) */
|
/* 窄窗口:单栏(与 AppLayout 768px 断点对齐) */
|
||||||
|
|||||||
+31
-4
@@ -160,6 +160,7 @@ import { scoreTier } from '../utils/ideaEval'
|
|||||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||||
import IdeaDetail from '../components/ideas/IdeaDetail.vue'
|
import IdeaDetail from '../components/ideas/IdeaDetail.vue'
|
||||||
import { useConfirm } from '../composables/useConfirm'
|
import { useConfirm } from '../composables/useConfirm'
|
||||||
|
import { usePersistedRef } from '../composables/usePersistedRef'
|
||||||
import Paginator from '../components/Paginator.vue'
|
import Paginator from '../components/Paginator.vue'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -172,15 +173,17 @@ const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
|||||||
|
|
||||||
type FilterKey = 'all' | 'hot' | 'pending' | 'promoted'
|
type FilterKey = 'all' | 'hot' | 'pending' | 'promoted'
|
||||||
|
|
||||||
const activeFilter = ref<FilterKey>('all')
|
// ⑤ 列表筛选/排序/分页状态持久化 localStorage(对齐 Tasks.vue:193-201 用 usePersistedRef),
|
||||||
|
// key 前缀 'ideas.',刷新页面/重开恢复用户的筛选视图。selectedId 不持久化(随路由恢复)。
|
||||||
|
const activeFilter = usePersistedRef<FilterKey>('ideas.activeFilter', 'all')
|
||||||
const selectedId = ref<string | null>(null)
|
const selectedId = ref<string | null>(null)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
// 列表排序:score 高分在前 / time 新在前(默认 score,避免高分灵感被淹没)
|
// 列表排序:score 高分在前 / time 新在前(默认 score,避免高分灵感被淹没)
|
||||||
const sortMode = ref<'score' | 'time'>('score')
|
const sortMode = usePersistedRef<'score' | 'time'>('ideas.sortMode', 'score')
|
||||||
|
|
||||||
// 分页(F-260621-02 P3):pageSize=0 → 全量(向后兼容,等价改造前);用户选 N → 客户端切片
|
// 分页(F-260621-02 P3):pageSize=0 → 全量(向后兼容,等价改造前);用户选 N → 客户端切片
|
||||||
const page = ref(1)
|
const page = usePersistedRef<number>('ideas.page', 1)
|
||||||
const pageSize = ref(0)
|
const pageSize = usePersistedRef<number>('ideas.pageSize', 0)
|
||||||
|
|
||||||
// ── 新建灵感模态框 ──
|
// ── 新建灵感模态框 ──
|
||||||
const showCaptureModal = ref(false)
|
const showCaptureModal = ref(false)
|
||||||
@@ -285,6 +288,17 @@ const currentIdea = computed(() => {
|
|||||||
return store.ideas.find(i => i.id === selectedId.value) ?? null
|
return store.ideas.find(i => i.id === selectedId.value) ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ⑥ 选中态保留:分页启用时,选中灵感可能落在当前页窗口外(切筛选/排序/页码后),
|
||||||
|
// 右侧详情仍显示(currentIdea 从 store.ideas 全量查),但列表不滚动到它 — 视觉上「丢失」。
|
||||||
|
// 这里在筛选/数据变化后,若选中项仍在过滤结果中,自动跳到它所在页,保持选中可见。
|
||||||
|
watch([filteredIdeas, () => selectedId.value], () => {
|
||||||
|
if (pageSize.value <= 0 || !selectedId.value) return
|
||||||
|
const idx = filteredIdeas.value.findIndex(i => i.id === selectedId.value)
|
||||||
|
if (idx < 0) return // 不在当前筛选结果中(被过滤),不强制跳页,保留详情面板显示
|
||||||
|
const targetPage = Math.floor(idx / pageSize.value) + 1
|
||||||
|
if (targetPage !== page.value) page.value = targetPage
|
||||||
|
})
|
||||||
|
|
||||||
// B-260615-25:灵感描述 Markdown 渲染已下沉至 IdeaDetail 子组件(共享模块单例渲染器)
|
// B-260615-25:灵感描述 Markdown 渲染已下沉至 IdeaDetail 子组件(共享模块单例渲染器)
|
||||||
|
|
||||||
// 评分档位 class(高/中/低 三色标),阈值统一走 scoreTier(单一来源,消除 4 处重复)
|
// 评分档位 class(高/中/低 三色标),阈值统一走 scoreTier(单一来源,消除 4 处重复)
|
||||||
@@ -373,6 +387,19 @@ async function evaluateCurrentIdea() {
|
|||||||
|
|
||||||
async function onStatusChange(newStatus: IdeaStatus) {
|
async function onStatusChange(newStatus: IdeaStatus) {
|
||||||
if (!currentIdea.value) return
|
if (!currentIdea.value) return
|
||||||
|
// ⑥ 状态切换确认:落到 promoted/rejected/archived 等终态(或离开 approved 失去立项入口)
|
||||||
|
// 需用户确认,避免误点。中性流转(draft↔pending_review↔approved)直接落库。
|
||||||
|
const from = currentIdea.value.status
|
||||||
|
const needsConfirm = newStatus === 'promoted' || newStatus === 'rejected' || newStatus === 'archived'
|
||||||
|
|| (from === 'approved' && newStatus !== 'approved')
|
||||||
|
if (needsConfirm) {
|
||||||
|
const msg = t('ideas.confirmStatus', {
|
||||||
|
title: currentIdea.value.title,
|
||||||
|
from: t(statusOptions.find(s => s.value === from)?.labelKey ?? from),
|
||||||
|
to: t(statusOptions.find(s => s.value === newStatus)?.labelKey ?? newStatus),
|
||||||
|
})
|
||||||
|
if (!await confirmDialog(msg, t('common.confirm'))) return
|
||||||
|
}
|
||||||
await store.updateIdea(currentIdea.value.id, 'status', newStatus)
|
await store.updateIdea(currentIdea.value.id, 'status', newStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+113
-14
@@ -8,6 +8,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- ⑤ 操作反馈 toast(保存/发布/拒绝/归档/创建成功 + 保存失败)-->
|
||||||
|
<Transition name="toast">
|
||||||
|
<div v-if="toast.visible" class="toast" :class="'toast-' + toast.type">{{ toast.msg }}</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
<!-- 错误条:消费 store.error(样式全局 .error-banner,仅间距用页面变量) -->
|
<!-- 错误条:消费 store.error(样式全局 .error-banner,仅间距用页面变量) -->
|
||||||
<div v-if="store.error" class="error-banner" style="margin-bottom: var(--df-gap-page)">
|
<div v-if="store.error" class="error-banner" style="margin-bottom: var(--df-gap-page)">
|
||||||
<span class="error-text">{{ store.error }}</span>
|
<span class="error-text">{{ store.error }}</span>
|
||||||
@@ -155,15 +160,48 @@ import {
|
|||||||
knowledgeConfidenceLabel as confidenceLabel,
|
knowledgeConfidenceLabel as confidenceLabel,
|
||||||
} from '@/stores/knowledge'
|
} from '@/stores/knowledge'
|
||||||
import { useRendered } from '@/composables/useMarkdown'
|
import { useRendered } from '@/composables/useMarkdown'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
import { stripMd } from '@/utils/markdown'
|
import { stripMd } from '@/utils/markdown'
|
||||||
import KnowledgeDetail from '@/components/knowledge/KnowledgeDetail.vue'
|
import KnowledgeDetail from '@/components/knowledge/KnowledgeDetail.vue'
|
||||||
import type { KnowledgeDetailPayload } from '@/api/types'
|
import type { KnowledgeDetailPayload, KnowledgeRecord } from '@/api/types'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const store = useKnowledgeStore()
|
const store = useKnowledgeStore()
|
||||||
|
// ⑤ 编辑/审核反馈:成功/失败明确提示(对齐 Settings.vue/Projects.vue useToast 模式)
|
||||||
|
const { toast, showToast } = useToast()
|
||||||
|
|
||||||
|
// ④ topTab/activeKind/searchQuery 持久化 localStorage(跨刷新保留用户视图态)
|
||||||
|
// 仅持久化视图态:不持久化详情选中(id 跨刷新可能已失效)。容错:解析失败/无效值回退默认。
|
||||||
|
const LS_KEY = 'kn.view'
|
||||||
|
function loadView(): { topTab: 'library' | 'inbox'; activeKind: string; searchQuery: string } {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LS_KEY)
|
||||||
|
if (!raw) return { topTab: 'library', activeKind: 'all', searchQuery: '' }
|
||||||
|
const v = JSON.parse(raw)
|
||||||
|
return {
|
||||||
|
topTab: v.topTab === 'inbox' ? 'inbox' : 'library',
|
||||||
|
activeKind: typeof v.activeKind === 'string' ? v.activeKind : 'all',
|
||||||
|
searchQuery: typeof v.searchQuery === 'string' ? v.searchQuery : '',
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { topTab: 'library', activeKind: 'all', searchQuery: '' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function saveView() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||||
|
topTab: topTab.value,
|
||||||
|
activeKind: activeKind.value,
|
||||||
|
searchQuery: searchQuery.value,
|
||||||
|
}))
|
||||||
|
} catch {
|
||||||
|
/* localStorage 不可用(隐私模式/配额)静默降级,视图态非关键 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const persisted = loadView()
|
||||||
|
|
||||||
// 顶层 Tab: library(知识库) | inbox(审核收件箱)
|
// 顶层 Tab: library(知识库) | inbox(审核收件箱)
|
||||||
const topTab = ref<'library' | 'inbox'>('library')
|
const topTab = ref<'library' | 'inbox'>(persisted.topTab)
|
||||||
|
|
||||||
const categories = [
|
const categories = [
|
||||||
{ key: 'all', icon: '📦' },
|
{ key: 'all', icon: '📦' },
|
||||||
@@ -173,8 +211,8 @@ const categories = [
|
|||||||
function catLabel(key: string): string {
|
function catLabel(key: string): string {
|
||||||
return key === 'all' ? t('knowledge.categoryAll') : kindText(key)
|
return key === 'all' ? t('knowledge.categoryAll') : kindText(key)
|
||||||
}
|
}
|
||||||
const activeKind = ref('all')
|
const activeKind = ref(persisted.activeKind)
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref(persisted.searchQuery)
|
||||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
function onSearchInput() {
|
function onSearchInput() {
|
||||||
@@ -208,6 +246,9 @@ function switchTopTab(tab: 'library' | 'inbox') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ④ 视图态(topTab/activeKind/searchQuery)持久化:任一变化即写 localStorage
|
||||||
|
watch([topTab, activeKind, searchQuery], saveView)
|
||||||
|
|
||||||
// 关闭错误条
|
// 关闭错误条
|
||||||
function clearError() {
|
function clearError() {
|
||||||
store.clearError()
|
store.clearError()
|
||||||
@@ -263,19 +304,26 @@ async function onDetailSave(payload: { id: string; data: { title: string; conten
|
|||||||
await store.update(payload.id, payload.data)
|
await store.update(payload.id, payload.data)
|
||||||
// 重新拉详情
|
// 重新拉详情
|
||||||
await selectKnowledge(payload.id)
|
await selectKnowledge(payload.id)
|
||||||
|
// ⑤ 编辑保存成功反馈(此前静默无反馈,用户不知是否生效)
|
||||||
|
showToast(t('knowledge.savedToast'), 'success')
|
||||||
|
} catch (e: any) {
|
||||||
|
// 保存失败:不重新抛出(emit 调用方未 await,抛出无人处理),仅 toast 反馈
|
||||||
|
showToast(e?.toString() ?? t('knowledge.err.saveFailed'), 'error')
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 审核操作(收件箱) =====
|
// ===== 审核操作(收件箱) =====
|
||||||
|
// P0-②:发布/拒绝/归档后自动选中下一条(而非清空详情),减少审核者来回点击
|
||||||
async function publishCurrent() {
|
async function publishCurrent() {
|
||||||
if (!detail.value) return
|
if (!detail.value) return
|
||||||
|
const currentId = detail.value.knowledge.id
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
await store.updateStatus(detail.value.knowledge.id, 'published')
|
await store.updateStatus(currentId, 'published')
|
||||||
selectedId.value = null
|
await selectNextCandidate(currentId)
|
||||||
detail.value = null
|
showToast(t('knowledge.publishedToast'), 'success')
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
@@ -283,11 +331,12 @@ async function publishCurrent() {
|
|||||||
|
|
||||||
async function rejectCurrent() {
|
async function rejectCurrent() {
|
||||||
if (!detail.value) return
|
if (!detail.value) return
|
||||||
|
const currentId = detail.value.knowledge.id
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
await store.archive(detail.value.knowledge.id)
|
await store.archive(currentId)
|
||||||
selectedId.value = null
|
await selectNextCandidate(currentId)
|
||||||
detail.value = null
|
showToast(t('knowledge.rejectedToast'), 'info')
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
@@ -295,16 +344,43 @@ async function rejectCurrent() {
|
|||||||
|
|
||||||
async function archiveCurrent() {
|
async function archiveCurrent() {
|
||||||
if (!detail.value) return
|
if (!detail.value) return
|
||||||
|
const currentId = detail.value.knowledge.id
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
await store.archive(detail.value.knowledge.id)
|
await store.archive(currentId)
|
||||||
selectedId.value = null
|
// 知识库 tab 无「下一条」语义(library 不限 candidate),归档后清空详情
|
||||||
detail.value = null
|
if (topTab.value === 'inbox') {
|
||||||
|
await selectNextCandidate(currentId)
|
||||||
|
} else {
|
||||||
|
selectedId.value = null
|
||||||
|
detail.value = null
|
||||||
|
}
|
||||||
|
showToast(t('knowledge.archivedToast'), 'info')
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 当前条目从 candidates 移除后,选中同位置后续条目;末尾则清空详情
|
||||||
|
// 实现策略:操作前记下 currentId 在原列表中的 index,移除后从剩余列表的同
|
||||||
|
// index 取(若无则取前一条),保证「下一条」语义而非跳回开头
|
||||||
|
function pickNextCandidate(remaining: KnowledgeRecord[], currentIndex: number): KnowledgeRecord | null {
|
||||||
|
return remaining[currentIndex] ?? remaining[currentIndex - 1] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectNextCandidate(currentId: string) {
|
||||||
|
const before = store.candidates
|
||||||
|
const idx = before.findIndex(c => c.id === currentId)
|
||||||
|
const remaining = store.candidates // store 操作后已 filter 移除 currentId
|
||||||
|
const next = idx >= 0 ? pickNextCandidate(remaining, idx) : null
|
||||||
|
if (next) {
|
||||||
|
await selectKnowledge(next.id)
|
||||||
|
} else {
|
||||||
|
selectedId.value = null
|
||||||
|
detail.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 新增对话框 =====
|
// ===== 新增对话框 =====
|
||||||
const showCreateModal = ref(false)
|
const showCreateModal = ref(false)
|
||||||
const form = ref({ kind: 'pitfall', title: '', content: '', tagsInput: '', confidence: '' })
|
const form = ref({ kind: 'pitfall', title: '', content: '', tagsInput: '', confidence: '' })
|
||||||
@@ -329,6 +405,7 @@ async function submitCreate() {
|
|||||||
})
|
})
|
||||||
showCreateModal.value = false
|
showCreateModal.value = false
|
||||||
switchTopTab('inbox')
|
switchTopTab('inbox')
|
||||||
|
showToast(t('knowledge.createdToast'), 'success')
|
||||||
} finally {
|
} finally {
|
||||||
submitting.value = false
|
submitting.value = false
|
||||||
}
|
}
|
||||||
@@ -339,7 +416,16 @@ async function submitCreate() {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
ensureLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat/TaskDetail 共享),不阻塞
|
ensureLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat/TaskDetail 共享),不阻塞
|
||||||
store.loadList()
|
// ④ 按持久化视图态加载初始数据源(topTab 决定加载 items 还是 candidates)
|
||||||
|
if (topTab.value === 'inbox') {
|
||||||
|
store.loadCandidates()
|
||||||
|
} else if (searchQuery.value.trim()) {
|
||||||
|
// 持久化的搜索词:恢复搜索结果而非空列表
|
||||||
|
store.search(searchQuery.value)
|
||||||
|
} else {
|
||||||
|
store.loadList()
|
||||||
|
}
|
||||||
|
// candidates badge 始终预加载(顶部 Tab 计数用)
|
||||||
store.loadCandidates()
|
store.loadCandidates()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -347,6 +433,19 @@ onMounted(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.knowledge { padding: 16px 20px 20px; height: 100%; display: flex; flex-direction: column; }
|
.knowledge { padding: 16px 20px 20px; height: 100%; display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
/* ===== 顶部 Toast 提示(⑤ 操作反馈,对齐 Settings.vue)===== */
|
||||||
|
.toast {
|
||||||
|
position: fixed; top: 16px; left: 50%; transform: translateX(-50%);
|
||||||
|
z-index: 1000; padding: 8px 16px; border-radius: var(--df-radius-sm);
|
||||||
|
font-size: 13px; box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
.toast-error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
|
||||||
|
.toast-warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
|
||||||
|
.toast-info { background: var(--df-accent-bg); color: var(--df-accent); border: 0.5px solid var(--df-accent); }
|
||||||
|
.toast-success { background: var(--df-success-bg); color: var(--df-success); border: 0.5px solid var(--df-success); }
|
||||||
|
.toast-enter-active, .toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
|
||||||
|
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }
|
||||||
|
|
||||||
/* page-header / btn / modal 等已提取到 global.css 全局 */
|
/* page-header / btn / modal 等已提取到 global.css 全局 */
|
||||||
|
|
||||||
/* ===== 错误条样式已提取到 styles/components.css 全局(.error-banner) ===== */
|
/* ===== 错误条样式已提取到 styles/components.css 全局(.error-banner) ===== */
|
||||||
|
|||||||
+121
-42
@@ -55,7 +55,7 @@
|
|||||||
class="tab-btn"
|
class="tab-btn"
|
||||||
:class="{ 'tab-active': activeTab === 'overview' }"
|
:class="{ 'tab-active': activeTab === 'overview' }"
|
||||||
type="button"
|
type="button"
|
||||||
@click="activeTab = 'overview'"
|
@click="setActiveTab('overview')"
|
||||||
>
|
>
|
||||||
{{ $t('projectDetail.tabOverview') }}
|
{{ $t('projectDetail.tabOverview') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
class="tab-btn"
|
class="tab-btn"
|
||||||
:class="{ 'tab-active': activeTab === 'files' }"
|
:class="{ 'tab-active': activeTab === 'files' }"
|
||||||
type="button"
|
type="button"
|
||||||
@click="activeTab = 'files'"
|
@click="setActiveTab('files')"
|
||||||
>
|
>
|
||||||
{{ $t('fileExplorer.tabTitle') }}
|
{{ $t('fileExplorer.tabTitle') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
class="tab-btn"
|
class="tab-btn"
|
||||||
:class="{ 'tab-active': activeTab === 'graph' }"
|
:class="{ 'tab-active': activeTab === 'graph' }"
|
||||||
type="button"
|
type="button"
|
||||||
@click="activeTab = 'graph'"
|
@click="setActiveTab('graph')"
|
||||||
>
|
>
|
||||||
{{ $t('dependencyGraph.tabTitle') }}
|
{{ $t('dependencyGraph.tabTitle') }}
|
||||||
</button>
|
</button>
|
||||||
@@ -87,8 +87,9 @@
|
|||||||
<DependencyGraph :project-id="projectId" />
|
<DependencyGraph :project-id="projectId" />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 概览 Tab:原有主体两栏 -->
|
<!-- 概览 Tab:两栏(项目信息 + 任务列表),工作流日志移至下方(P1-g+ 问题10 三栏拥挤) -->
|
||||||
<div v-else class="detail-grid">
|
<div v-else class="detail-overview">
|
||||||
|
<div class="detail-grid">
|
||||||
<!-- 左栏:项目信息 -->
|
<!-- 左栏:项目信息 -->
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
@@ -133,12 +134,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="info-item info-block">
|
<div class="info-item info-block">
|
||||||
<span class="label">{{ $t('projectDetail.description') }}</span>
|
<span class="label">{{ $t('projectDetail.description') }}</span>
|
||||||
<!-- B-260615-25:项目描述 Markdown 渲染,复用 useMarkdown composable(同 B-24 TaskDetail) -->
|
<!-- B-260615-25:项目描述 Markdown 渲染,复用 useMarkdown composable(同 B-24 TaskDetail)
|
||||||
<span
|
P1-g+ 问题10③:长描述折叠(对齐 TaskDetail 长内容),默认折叠截 4 行,展开看全文 -->
|
||||||
v-if="currentProject.description"
|
<div v-if="currentProject.description" class="description-wrap" :class="{ 'is-collapsed': !descExpanded }">
|
||||||
class="value description ai-md"
|
<span
|
||||||
v-html="renderedDesc"
|
class="value description ai-md"
|
||||||
></span>
|
v-html="renderedDesc"
|
||||||
|
></span>
|
||||||
|
<button
|
||||||
|
v-if="descCollapsible"
|
||||||
|
class="btn btn-ghost btn-sm desc-toggle"
|
||||||
|
type="button"
|
||||||
|
@click="descExpanded = !descExpanded"
|
||||||
|
>
|
||||||
|
{{ descExpanded ? $t('common.collapse') : $t('common.expand') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<span v-else>—</span>
|
<span v-else>—</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
@@ -207,7 +218,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 左栏:任务列表 -->
|
<!-- 右栏:任务列表(本项目独立 fetch,不读 store.tasks,避免 Tasks 分页污染) -->
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h2>{{ $t('projectDetail.taskListTitle') }}</h2>
|
<h2>{{ $t('projectDetail.taskListTitle') }}</h2>
|
||||||
@@ -232,24 +243,30 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="projectTasks.length === 0" class="empty-hint">{{ $t('projectDetail.emptyTasks') }}</div>
|
<div v-if="projectTasks.length === 0" class="empty-hint">{{ $t('projectDetail.emptyTasks') }}</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- 右栏 -->
|
|
||||||
<div class="right-column">
|
|
||||||
<!-- 工作流执行日志 -->
|
|
||||||
<section class="panel">
|
|
||||||
<div class="panel-header">
|
|
||||||
<h2>{{ $t('projectDetail.workflowLogTitle') }}</h2>
|
|
||||||
</div>
|
|
||||||
<div class="log-list" ref="logListRef">
|
|
||||||
<div class="log-item" v-for="(evt, idx) in formattedEvents" :key="idx" :class="'log-' + evt.level">
|
|
||||||
<span class="log-time">{{ evt.time }}</span>
|
|
||||||
<span class="log-level">{{ evt.level }}</span>
|
|
||||||
<span class="log-msg">{{ evt.message }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="formattedEvents.length === 0" class="empty-hint">{{ $t('projectDetail.emptyWorkflowLog') }}</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 工作流日志:无记录时折叠为摘要(标题 + 计数),有记录才展开列表 -->
|
||||||
|
<section v-if="formattedEvents.length > 0 || !workflowLogCollapsed" class="panel workflow-log-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>{{ $t('projectDetail.workflowLogTitle') }}</h2>
|
||||||
|
<span v-if="formattedEvents.length === 0" class="task-count">{{ $t('projectDetail.emptyWorkflowLog') }}</span>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="btn btn-ghost btn-sm"
|
||||||
|
type="button"
|
||||||
|
@click="workflowLogCollapsed = !workflowLogCollapsed"
|
||||||
|
>
|
||||||
|
{{ workflowLogCollapsed ? $t('common.expand') : $t('common.collapse') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="formattedEvents.length > 0 && !workflowLogCollapsed" class="log-list" ref="logListRef">
|
||||||
|
<div class="log-item" v-for="(evt, idx) in formattedEvents" :key="idx" :class="'log-' + evt.level">
|
||||||
|
<span class="log-time">{{ evt.time }}</span>
|
||||||
|
<span class="log-level">{{ evt.level }}</span>
|
||||||
|
<span class="log-msg">{{ evt.message }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 人工审批对话框(抽离至 components/project/ApprovalDialog.vue)-->
|
<!-- 人工审批对话框(抽离至 components/project/ApprovalDialog.vue)-->
|
||||||
@@ -273,7 +290,7 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { Message } from '@arco-design/web-vue'
|
import { Message } from '@arco-design/web-vue'
|
||||||
import { open } from '@tauri-apps/plugin-dialog'
|
import { open } from '@tauri-apps/plugin-dialog'
|
||||||
import { useProjectStore } from '@/stores/project'
|
import { useProjectStore } from '@/stores/project'
|
||||||
import { projectApi } from '@/api'
|
import { projectApi, taskApi } from '@/api'
|
||||||
import { formatDate } from '@/utils/time'
|
import { formatDate } from '@/utils/time'
|
||||||
import { parseStack } from '@/utils/project'
|
import { parseStack } from '@/utils/project'
|
||||||
import { parseScores as parseScoresJson, assessmentClass, assessmentLabel as assessmentLabelI18n, scoreTier } from '@/utils/ideaEval'
|
import { parseScores as parseScoresJson, assessmentClass, assessmentLabel as assessmentLabelI18n, scoreTier } from '@/utils/ideaEval'
|
||||||
@@ -284,7 +301,7 @@ import FileExplorer from '@/components/project/FileExplorer.vue'
|
|||||||
import DependencyGraph from '@/components/project/DependencyGraph.vue'
|
import DependencyGraph from '@/components/project/DependencyGraph.vue'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import { useRendered } from '@/composables/useMarkdown'
|
import { useRendered } from '@/composables/useMarkdown'
|
||||||
import type { ProjectId } from '@/api/types'
|
import type { ProjectId, TaskRecord } from '@/api/types'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -295,7 +312,31 @@ const showApprovalDialog = ref(false)
|
|||||||
let unlisten: (() => void) | null = null
|
let unlisten: (() => void) | null = null
|
||||||
|
|
||||||
// Tab 导航:概览(项目信息/任务/工作流日志) vs 文件浏览器(Batch 10)。
|
// Tab 导航:概览(项目信息/任务/工作流日志) vs 文件浏览器(Batch 10)。
|
||||||
const activeTab = ref<'overview' | 'files' | 'graph'>('overview')
|
// P1-g+ 问题10④:activeTab 持久化 localStorage(按项目隔离,切换项目不串扰)。
|
||||||
|
const TAB_STORAGE_PREFIX = 'df-project-tab-'
|
||||||
|
const validTabs = ['overview', 'files', 'graph'] as const
|
||||||
|
type DetailTab = typeof validTabs[number]
|
||||||
|
function readStoredTab(projectId: string): DetailTab {
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem(TAB_STORAGE_PREFIX + projectId)
|
||||||
|
return (v && (validTabs as readonly string[]).includes(v)) ? (v as DetailTab) : 'overview'
|
||||||
|
} catch { return 'overview' }
|
||||||
|
}
|
||||||
|
const activeTab = ref<DetailTab>('overview')
|
||||||
|
function setActiveTab(tab: DetailTab) {
|
||||||
|
activeTab.value = tab
|
||||||
|
try { localStorage.setItem(TAB_STORAGE_PREFIX + projectId.value, tab) } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 描述折叠(P1-g+ 问题10③):长描述默认折叠截 4 行,展开看全文。
|
||||||
|
// descCollapsible 由描述长度阈值决定(渲染后行数不可靠,用源字符数近似 ≥120 字符)。
|
||||||
|
const DESC_COLLAPSE_THRESHOLD = 120
|
||||||
|
const descExpanded = ref(false)
|
||||||
|
const descCollapsible = computed(() => (currentProject.value?.description?.length ?? 0) >= DESC_COLLAPSE_THRESHOLD)
|
||||||
|
|
||||||
|
// 工作流日志折叠(P1-g+ 问题10②):无记录时折叠为摘要(标题 + emptyWorkflowLog 提示),
|
||||||
|
// 有记录默认展开。collapse 由用户主动操作,避免空态占整栏。
|
||||||
|
const workflowLogCollapsed = ref(false)
|
||||||
|
|
||||||
// 确认弹层状态机抽至 composables/useConfirm(原 4 视图重复:Projects/ProjectDetail/Ideas/Settings)
|
// 确认弹层状态机抽至 composables/useConfirm(原 4 视图重复:Projects/ProjectDetail/Ideas/Settings)
|
||||||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||||
@@ -364,9 +405,18 @@ const assessmentLabel = (recommendation: string): string =>
|
|||||||
assessmentLabelI18n(t, recommendation)
|
assessmentLabelI18n(t, recommendation)
|
||||||
|
|
||||||
// ── 任务 ──
|
// ── 任务 ──
|
||||||
const projectTasks = computed(() =>
|
// P1-g+ 问题10①:projectTasks 不再读 store.tasks(被 Tasks 视图分页/筛选污染),
|
||||||
store.tasks.filter(t => t.project_id === projectId.value)
|
// 改为按 project_id 走后端 list_tasks {project_id} 独立拉取,存本地 ref。
|
||||||
)
|
// TaskQuery.project_id 命中 idx_tasks_project_id(见 api/types.ts:191)。
|
||||||
|
const projectTasks = ref<TaskRecord[]>([])
|
||||||
|
async function loadProjectTasks() {
|
||||||
|
try {
|
||||||
|
projectTasks.value = await taskApi.list({ project_id: projectId.value })
|
||||||
|
} catch (e) {
|
||||||
|
console.error('加载项目任务失败:', e)
|
||||||
|
projectTasks.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
// taskStatusLabel / taskStatusClass 由 ../constants/project 提供
|
// taskStatusLabel / taskStatusClass 由 ../constants/project 提供
|
||||||
|
|
||||||
// ── 工作流 ──
|
// ── 工作流 ──
|
||||||
@@ -417,6 +467,8 @@ async function submitNewTask() {
|
|||||||
branch_name: newTaskBranch.value.trim() || undefined,
|
branch_name: newTaskBranch.value.trim() || undefined,
|
||||||
})
|
})
|
||||||
if (!r) return // 失败已 toast,保持弹窗不关
|
if (!r) return // 失败已 toast,保持弹窗不关
|
||||||
|
// 同步到本地 ref(store.createTask 已 push 全局 tasks,但本视图不再读 store.tasks)
|
||||||
|
projectTasks.value = [r, ...projectTasks.value]
|
||||||
showNewTaskModal.value = false
|
showNewTaskModal.value = false
|
||||||
newTaskTitle.value = ''
|
newTaskTitle.value = ''
|
||||||
newTaskDesc.value = ''
|
newTaskDesc.value = ''
|
||||||
@@ -428,7 +480,8 @@ async function submitNewTask() {
|
|||||||
|
|
||||||
// ── 同步 ──
|
// ── 同步 ──
|
||||||
async function handleSync() {
|
async function handleSync() {
|
||||||
await Promise.all([store.loadProjects(), store.loadTasks()])
|
await store.loadProjects()
|
||||||
|
await loadProjectTasks() // 本项目任务独立拉取,不依赖 store.loadTasks
|
||||||
await checkPath()
|
await checkPath()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -534,8 +587,11 @@ watch(() => store.pendingApproval, (newApproval) => {
|
|||||||
// ── 生命周期 ──
|
// ── 生命周期 ──
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
ensureLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat/TaskDetail 共享),不阻塞
|
ensureLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat/TaskDetail 共享),不阻塞
|
||||||
|
// 恢复持久化的 Tab 选择(按项目隔离)
|
||||||
|
activeTab.value = readStoredTab(projectId.value)
|
||||||
await store.loadProjects()
|
await store.loadProjects()
|
||||||
await store.loadTasks() // 全量加载,详情页用 computed 过滤本项目(避免覆盖全局 tasks 单例)
|
// 本项目任务独立拉取(不调 store.loadTasks,避免被 Tasks 视图分页/筛选污染)
|
||||||
|
await loadProjectTasks()
|
||||||
// 来源灵感回溯:项目有 idea_id 但 store.ideas 为空(本页未 load 过)时补拉,
|
// 来源灵感回溯:项目有 idea_id 但 store.ideas 为空(本页未 load 过)时补拉,
|
||||||
// 否则 sourceIdea computed 永远找不到对应灵感记录
|
// 否则 sourceIdea computed 永远找不到对应灵感记录
|
||||||
await store.loadIdeas()
|
await store.loadIdeas()
|
||||||
@@ -543,6 +599,13 @@ onMounted(async () => {
|
|||||||
await checkPath()
|
await checkPath()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 路由切换到不同项目(URL /projects/:id 变化)时,重新拉取本项目任务 + 恢复 Tab + 检测目录
|
||||||
|
watch(projectId, async (newId) => {
|
||||||
|
if (!newId) return
|
||||||
|
activeTab.value = readStoredTab(newId)
|
||||||
|
await loadProjectTasks()
|
||||||
|
})
|
||||||
|
|
||||||
// 目录变更(重定位后)重新检测存在性
|
// 目录变更(重定位后)重新检测存在性
|
||||||
watch(() => currentProject.value?.path, () => { checkPath() })
|
watch(() => currentProject.value?.path, () => { checkPath() })
|
||||||
|
|
||||||
@@ -611,6 +674,13 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
/* ===== 项目描述 Markdown 渲染(B-260615-25,基础样式收敛至全局 ai-md.css) ===== */
|
/* ===== 项目描述 Markdown 渲染(B-260615-25,基础样式收敛至全局 ai-md.css) ===== */
|
||||||
.description.ai-md { font-size: 14px; color: var(--df-text); line-height: 1.6; }
|
.description.ai-md { font-size: 14px; color: var(--df-text); line-height: 1.6; }
|
||||||
|
/* P1-g+ 问题10③:描述折叠(对齐 TaskDetail 长内容)— 折叠时 max-height 截断 + 渐变遮罩 */
|
||||||
|
.description-wrap { position: relative; }
|
||||||
|
.description-wrap.is-collapsed .description {
|
||||||
|
max-height: 6em; /* 约 4 行(line-height 1.5) */
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.desc-toggle { margin-top: 6px; }
|
||||||
|
|
||||||
/* ===== 来源灵感卡片(晋升携带评估结论回溯)===== */
|
/* ===== 来源灵感卡片(晋升携带评估结论回溯)===== */
|
||||||
.source-idea-card {
|
.source-idea-card {
|
||||||
@@ -727,17 +797,25 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
/* .btn 系列(btn/btn-primary/btn-ghost/btn-sm/btn-danger)已提取到 styles/global.css 全局 */
|
/* .btn 系列(btn/btn-primary/btn-ghost/btn-sm/btn-danger)已提取到 styles/global.css 全局 */
|
||||||
|
|
||||||
/* ===== 两栏布局 — 自己滚动,不撑大父级 ===== */
|
/* ===== 概览布局(P1-g+ 问题10②:三栏拥挤 → 两栏 + 日志下方)=====
|
||||||
.detail-grid {
|
.detail-overview 整体滚动;内部 .detail-grid 两栏(项目信息 + 任务列表),
|
||||||
display: grid;
|
.workflow-log-panel 跨整宽放下方。无日志记录时折叠为摘要不占整栏。 */
|
||||||
grid-template-columns: 1fr 380px;
|
.detail-overview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
gap: var(--df-gap-page);
|
gap: var(--df-gap-page);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
/* 两栏布局 — 项目信息 + 任务列表 */
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--df-gap-page);
|
||||||
align-content: start;
|
align-content: start;
|
||||||
}
|
}
|
||||||
.right-column { display: flex; flex-direction: column; gap: var(--df-gap-grid); }
|
.workflow-log-panel { flex: 0 0 auto; }
|
||||||
|
|
||||||
/* ===== 面板 ===== */
|
/* ===== 面板 ===== */
|
||||||
/* .panel / .panel-header 基础样式已收敛至全局 components.css(DRY 收口 B-260619),
|
/* .panel / .panel-header 基础样式已收敛至全局 components.css(DRY 收口 B-260619),
|
||||||
@@ -826,5 +904,6 @@ onUnmounted(() => {
|
|||||||
.detail-grid {
|
.detail-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
.log-list { max-height: 200px; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+65
-7
@@ -3,6 +3,21 @@
|
|||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<h1>{{ $t('projects.title') }}</h1>
|
<h1>{{ $t('projects.title') }}</h1>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<!-- 视图模式切换(默认 list 紧凑;card 宽松):切换持久化 localStorage -->
|
||||||
|
<div class="view-toggle" :title="$t('projects.viewModeTitle')">
|
||||||
|
<button
|
||||||
|
class="view-toggle-btn"
|
||||||
|
:class="{ active: viewMode === 'list' }"
|
||||||
|
:title="$t('projects.viewList')"
|
||||||
|
@click="viewMode = 'list'"
|
||||||
|
>☰</button>
|
||||||
|
<button
|
||||||
|
class="view-toggle-btn"
|
||||||
|
:class="{ active: viewMode === 'card' }"
|
||||||
|
:title="$t('projects.viewCard')"
|
||||||
|
@click="viewMode = 'card'"
|
||||||
|
>▦</button>
|
||||||
|
</div>
|
||||||
<button class="btn btn-ghost" @click="openImportModal">{{ $t('projects.importHistory') }}</button>
|
<button class="btn btn-ghost" @click="openImportModal">{{ $t('projects.importHistory') }}</button>
|
||||||
<button class="btn btn-ghost" @click="openTrash">{{ $t('projects.trash') }}</button>
|
<button class="btn btn-ghost" @click="openTrash">{{ $t('projects.trash') }}</button>
|
||||||
<button class="btn btn-primary" @click="showCreateModal = true">{{ $t('projects.create') }}</button>
|
<button class="btn btn-primary" @click="showCreateModal = true">{{ $t('projects.create') }}</button>
|
||||||
@@ -139,8 +154,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 项目卡片网格(单卡片渲染抽至 components/project/ProjectCard.vue,删除走 emit 回父处理) -->
|
<!-- 项目卡片网格(单卡片渲染抽至 components/project/ProjectCard.vue,删除走 emit 回父处理) -->
|
||||||
<div class="project-grid">
|
<!-- viewMode='list' → 单列紧凑;viewMode='card' → 双列宽松(默认改造前形态) -->
|
||||||
<ProjectCard v-for="project in pagedProjects" :key="project.id" :project="project" @delete="handleDelete" />
|
<div class="project-grid" :class="'grid-' + viewMode">
|
||||||
|
<ProjectCard
|
||||||
|
v-for="project in pagedProjects"
|
||||||
|
:key="project.id"
|
||||||
|
:project="project"
|
||||||
|
:view="viewMode"
|
||||||
|
@delete="handleDelete"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 分页器(F-260621-02 P3):pageSize=0 不分页(全量,等价旧行为);用户选 N 条触发客户端切片 -->
|
<!-- 分页器(F-260621-02 P3):pageSize=0 不分页(全量,等价旧行为);用户选 N 条触发客户端切片 -->
|
||||||
@@ -175,15 +197,20 @@ import ProjectCard from '@/components/project/ProjectCard.vue'
|
|||||||
import Paginator from '@/components/Paginator.vue'
|
import Paginator from '@/components/Paginator.vue'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import { usePersistedRef } from '@/composables/usePersistedRef'
|
||||||
import type { ProjectRecord } from '@/api/types'
|
import type { ProjectRecord } from '@/api/types'
|
||||||
import type { ScannedProjectItem } from '@/api/project'
|
import type { ScannedProjectItem } from '@/api/project'
|
||||||
|
|
||||||
const store = useProjectStore()
|
const store = useProjectStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
// 分页(F-260621-02 P3):pageSize=0 → 全量(向后兼容,等价改造前);用户选 N → 客户端切片
|
// 分页(F-260621-02 P3):pageSize=0 → 全量(向后兼容,等价改造前);用户选 N → 客户端切片。
|
||||||
const page = ref(1)
|
// page/pageSize 持久化 localStorage(刷新/重开记住用户选择)。
|
||||||
const pageSize = ref(0)
|
const page = usePersistedRef('projects.page', 1)
|
||||||
|
const pageSize = usePersistedRef('projects.pageSize', 0)
|
||||||
|
|
||||||
|
// 视图模式:'list'(紧凑单列,默认)/'card'(宽松双列)。持久化 localStorage。
|
||||||
|
const viewMode = usePersistedRef<'list' | 'card'>('projects.viewMode', 'list')
|
||||||
|
|
||||||
// 分页视图:pageSize=0 → 全量;否则取当前页窗口
|
// 分页视图:pageSize=0 → 全量;否则取当前页窗口
|
||||||
const pagedProjects = computed(() => {
|
const pagedProjects = computed(() => {
|
||||||
@@ -426,11 +453,41 @@ async function runImport() {
|
|||||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
|
|
||||||
/* ===== 卡片网格 ===== */
|
/* ===== 卡片网格 ===== */
|
||||||
|
/* 默认改造前为双列;viewMode 驱动 list(单列紧凑)/card(双列宽松) */
|
||||||
.project-grid {
|
.project-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, 1fr);
|
|
||||||
gap: var(--df-gap-grid);
|
gap: var(--df-gap-grid);
|
||||||
}
|
}
|
||||||
|
.project-grid.grid-card {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
.project-grid.grid-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 视图模式切换 ===== */
|
||||||
|
.view-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
border: 0.5px solid var(--df-border);
|
||||||
|
border-radius: var(--df-radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.view-toggle-btn {
|
||||||
|
min-width: 30px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.view-toggle-btn:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||||||
|
.view-toggle-btn.active {
|
||||||
|
background: rgba(108, 99, 255, 0.12);
|
||||||
|
color: var(--df-accent);
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== 进度条 ===== */
|
/* ===== 进度条 ===== */
|
||||||
.card-progress { margin-bottom: 16px; }
|
.card-progress { margin-bottom: 16px; }
|
||||||
@@ -498,7 +555,8 @@ async function runImport() {
|
|||||||
/* ===== 响应式 ===== */
|
/* ===== 响应式 ===== */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.projects { padding: 16px; }
|
.projects { padding: 16px; }
|
||||||
.project-grid {
|
/* 移动端:card 视图也降为单列;list 本就单列 */
|
||||||
|
.project-grid.grid-card {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-4
@@ -125,12 +125,40 @@ function onResize() {
|
|||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
// SettingsNav 搜索命中首匹配项时上抛 scroll-target(key)。
|
// SettingsNav 搜索命中首匹配项时上抛 scroll-target(key)。
|
||||||
// 此处据 key 查 SETTINGS_INDEX 取 labelKey → t() 得当前 locale 文本,
|
// 此处据 key 查 SETTINGS_INDEX 取 labelKey → t() 得当前 locale 文本,
|
||||||
// 在右侧内容 DOM 中找匹配文本的 .setting-label(表单型 Section)或
|
// 在右侧内容 DOM 中找匹配的 .setting-label(表单型 Section)或
|
||||||
// .panel-header h2(列表型面板如 ProviderPanel),scrollIntoView 平滑定位。
|
// .panel-header h2(列表型面板如 ProviderPanel),scrollIntoView 平滑定位。
|
||||||
// 用文本匹配而非 data-* 属性:避免改动各 Section(严守"仅改 plan 列出文件")。
|
//
|
||||||
|
// 匹配策略(2026-08-02 加固):
|
||||||
|
// 旧实现用 textContent.trim() === labelText 严格相等,SettingRow 的 .setting-label
|
||||||
|
// 常带 next-round-badge 子 span(「下次对话生效」) → textContent 拼接出
|
||||||
|
// 「主题 下次对话生效」≠「主题」,严格相等失效,搜索定位静默无命中。
|
||||||
|
// 现改读 .setting-label 的"首子文本节点"(纯 label,排除 badge/desc 等子元素),
|
||||||
|
// 与 labelText trim 后 includes 匹配(双向 includes 兜底首尾空白/标点)。
|
||||||
|
// h2 同理改 includes 提升容错。理想方案是 .setting-label 加 data-setting-key,
|
||||||
|
// 但那需改 SettingRow.vue + 6 个 Section(越"仅改指定文件"范围),故此处加固文本匹配。
|
||||||
// ============================================================
|
// ============================================================
|
||||||
const contentRef = ref<HTMLElement | null>(null)
|
const contentRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
/** 取元素"首文本节点"内容(跳过子元素,排除 SettingRow 的 next-round-badge 拼接干扰) */
|
||||||
|
function firstTextNodeText(el: HTMLElement | null): string {
|
||||||
|
if (!el) return ''
|
||||||
|
// 优先 childNodes 中的首个 text node;退而 textContent(无子元素场景)
|
||||||
|
for (const node of Array.from(el.childNodes)) {
|
||||||
|
if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
return node.textContent || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return el.textContent || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 双向 includes 匹配(容错首尾空白/标点差异;空串不命中) */
|
||||||
|
function fuzzyMatch(a: string, b: string): boolean {
|
||||||
|
const sa = a.trim()
|
||||||
|
const sb = b.trim()
|
||||||
|
if (!sa || !sb) return false
|
||||||
|
return sa.includes(sb) || sb.includes(sa)
|
||||||
|
}
|
||||||
|
|
||||||
function scrollToSettingItem(itemKey: string) {
|
function scrollToSettingItem(itemKey: string) {
|
||||||
const entry = SETTINGS_INDEX.find((e) => e.key === itemKey)
|
const entry = SETTINGS_INDEX.find((e) => e.key === itemKey)
|
||||||
if (!entry) return
|
if (!entry) return
|
||||||
@@ -145,7 +173,7 @@ function scrollToSettingItem(itemKey: string) {
|
|||||||
const labels = root.querySelectorAll<HTMLElement>('.setting-label')
|
const labels = root.querySelectorAll<HTMLElement>('.setting-label')
|
||||||
let target: HTMLElement | null = null
|
let target: HTMLElement | null = null
|
||||||
for (const el of labels) {
|
for (const el of labels) {
|
||||||
if (el.textContent?.trim() === labelText.trim()) {
|
if (fuzzyMatch(firstTextNodeText(el), labelText)) {
|
||||||
target = el.closest<HTMLElement>('.setting-row') ?? el
|
target = el.closest<HTMLElement>('.setting-row') ?? el
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -153,7 +181,7 @@ function scrollToSettingItem(itemKey: string) {
|
|||||||
if (!target) {
|
if (!target) {
|
||||||
const headers = root.querySelectorAll<HTMLElement>('.panel-header h2')
|
const headers = root.querySelectorAll<HTMLElement>('.panel-header h2')
|
||||||
for (const el of headers) {
|
for (const el of headers) {
|
||||||
if (el.textContent?.trim() === labelText.trim()) {
|
if (fuzzyMatch(firstTextNodeText(el), labelText)) {
|
||||||
target = el
|
target = el
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
+247
-138
@@ -1,15 +1,42 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="task-detail">
|
<div class="task-detail">
|
||||||
<!-- 页面头部 -->
|
<!-- 页面头部(吸顶):标题 + 状态 + 优先级 在左,推进按钮在右(滚动始终可见)。
|
||||||
<header class="page-header">
|
Problem4 ① 推进按钮从面板内移到 header 右侧,与 refresh 同区。-->
|
||||||
|
<header class="page-header task-detail-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<router-link to="/tasks" class="back-link">{{ $t('taskDetail.backToList') }}</router-link>
|
<router-link to="/tasks" class="back-link">{{ $t('taskDetail.backToList') }}</router-link>
|
||||||
<h1>{{ task?.title ?? '...' }}</h1>
|
<h1>{{ task?.title ?? '...' }}</h1>
|
||||||
<span v-if="task" class="status-tag" :class="taskStatusClass(task.status)">{{ $t(taskStatusLabel(task.status)) }}</span>
|
<span v-if="task" class="status-tag" :class="taskStatusClass(task.status)">{{ $t(taskStatusLabel(task.status)) }}</span>
|
||||||
|
<!-- F-04 review 轮次显示(>0 才显示,in_review→in_progress / testing→in_review 退回时后端 +1) -->
|
||||||
|
<span v-if="task && task.review_rounds > 0" class="review-rounds-badge">
|
||||||
|
{{ $t('taskDetail.reviewRounds', { n: task.review_rounds }) }}
|
||||||
|
</span>
|
||||||
<span v-if="task" class="priority-badge" :class="priorityClass(task.priority)">{{ priorityLabel(task.priority) }}</span>
|
<span v-if="task" class="priority-badge" :class="priorityClass(task.priority)">{{ priorityLabel(task.priority) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button class="btn btn-ghost" type="button" @click="refresh">{{ $t('taskDetail.refresh') }}</button>
|
<button class="btn btn-ghost btn-sm" type="button" @click="refresh">{{ $t('taskDetail.refresh') }}</button>
|
||||||
|
<!-- F-260616-06 ①-1 / B-41 工作流推进按钮(联动任务,按 target_status 后端自动选推进链模板)
|
||||||
|
与手动 advance 并存:仅当当前态的 primary 前向推进目标 ∈ {in_progress, testing, done}
|
||||||
|
(即 template_for 有对应模板)时显示。手动 advance 仍走 advance_task 直 IPC;工作流推进
|
||||||
|
走 run_workflow,经 AiNode/HumanNode 执行后再由后端回调推进任务。
|
||||||
|
独立 loading 标志 wfAdvancing 与 advancing 互不干扰。 -->
|
||||||
|
<button
|
||||||
|
v-if="wfAdvanceAction"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-primary"
|
||||||
|
:disabled="wfAdvancing || advancing"
|
||||||
|
@click="handleWorkflowAdvance(wfAdvanceAction.target)"
|
||||||
|
>{{ wfAdvancing ? $t('taskDetail.workflowAdvancing') : $t('taskDetail.workflowAdvance') }}</button>
|
||||||
|
<!-- F-05 推进按钮:按当前 status 显示状态机合法下一态(todo/done/cancelled 终态无按钮) -->
|
||||||
|
<button
|
||||||
|
v-for="act in advanceActions"
|
||||||
|
:key="act.target"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm"
|
||||||
|
:class="act.variant"
|
||||||
|
:disabled="advancing"
|
||||||
|
@click="handleAdvance(act.target)"
|
||||||
|
>{{ advancing ? $t('taskDetail.advancing') : $t(act.label) }}</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -17,144 +44,111 @@
|
|||||||
<div v-if="loading" class="empty-hint">{{ $t('taskDetail.loading') }}</div>
|
<div v-if="loading" class="empty-hint">{{ $t('taskDetail.loading') }}</div>
|
||||||
<div v-else-if="errorMsg" class="empty-hint error-hint">⚠ {{ errorMsg }}</div>
|
<div v-else-if="errorMsg" class="empty-hint error-hint">⚠ {{ errorMsg }}</div>
|
||||||
|
|
||||||
<!-- 主体 -->
|
<!-- 主体:两栏布局(Problem4 ⑤)。左栏=描述+关联信息+时间戳,右栏=任务产出+工作流。
|
||||||
|
窄屏自动单列。-->
|
||||||
<div v-else-if="task" class="detail-grid">
|
<div v-else-if="task" class="detail-grid">
|
||||||
<section class="panel">
|
<!-- ============ 左栏 ============ -->
|
||||||
<div class="panel-header">
|
<div class="left-column">
|
||||||
<h2>{{ $t('taskDetail.infoTitle') }}</h2>
|
<!-- 描述(可折叠:Problem4 ② 超 400px 显展开按钮) -->
|
||||||
</div>
|
<section v-if="task.description" class="panel">
|
||||||
<div class="task-info">
|
<div class="panel-header"><h2>{{ $t('taskDetail.description') }}</h2></div>
|
||||||
<div class="info-item">
|
<div class="description-wrap" :style="descWrapStyle">
|
||||||
<span class="label">{{ $t('taskDetail.title') }}</span>
|
|
||||||
<span class="value">{{ task.title }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item info-block">
|
|
||||||
<span class="label">{{ $t('taskDetail.description') }}</span>
|
|
||||||
<!-- B-24:任务描述 Markdown 渲染(## 标题 / - 列表 等),v-html 经 useMarkdown DOMPurify sanitize -->
|
<!-- B-24:任务描述 Markdown 渲染(## 标题 / - 列表 等),v-html 经 useMarkdown DOMPurify sanitize -->
|
||||||
<span
|
<span ref="descEl" class="value description ai-md" v-html="renderedDesc"></span>
|
||||||
class="value description ai-md"
|
|
||||||
v-if="task.description"
|
|
||||||
v-html="renderedDesc"
|
|
||||||
></span>
|
|
||||||
<span v-else class="value description">—</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<button
|
||||||
<span class="label">{{ $t('taskDetail.status') }}</span>
|
v-if="descCollapsible"
|
||||||
<span class="value">
|
class="btn btn-ghost btn-sm desc-toggle"
|
||||||
<span class="status-tag" :class="taskStatusClass(task.status)">{{ $t(taskStatusLabel(task.status)) }}</span>
|
type="button"
|
||||||
<!-- F-04 review 轮次显示(>0 才显示,in_review→in_progress / testing→in_review 退回时后端 +1) -->
|
@click="descExpanded = !descExpanded"
|
||||||
<span v-if="task.review_rounds > 0" class="review-rounds-badge">
|
>{{ descExpanded ? $t('taskDetail.collapse') : $t('taskDetail.expand') }}</button>
|
||||||
{{ $t('taskDetail.reviewRounds', { n: task.review_rounds }) }}
|
<!-- 折叠态淡出渐变蒙层(展开态不显) -->
|
||||||
|
<div v-if="descCollapsible && !descExpanded" class="desc-fade"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 关联信息(Problem4 ③④ 去重 + 空值不显行)。
|
||||||
|
标题/状态/优先级已在 header 显示,这里不重复。-->
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-header"><h2>{{ $t('taskDetail.relatedTitle') }}</h2></div>
|
||||||
|
<div class="task-info">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">{{ $t('taskDetail.project') }}</span>
|
||||||
|
<span class="value">
|
||||||
|
<router-link v-if="task.project_id" :to="`/projects/${task.project_id}`" class="project-link">
|
||||||
|
{{ projectName }}
|
||||||
|
</router-link>
|
||||||
|
<span v-else>—</span>
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- F-260619-01:关联灵感(1对1 单向,idea_id 解析为友好 title,非裸 id) -->
|
||||||
|
<div v-if="task.idea_id" class="info-item">
|
||||||
|
<span class="label">{{ $t('taskDetail.relatedIdea') }}</span>
|
||||||
|
<span class="value">
|
||||||
|
<router-link :to="`/ideas/${task.idea_id}`" class="project-link">{{ ideaTitle }}</router-link>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- 分支(有值才显) -->
|
||||||
|
<div v-if="task.branch_name" class="info-item">
|
||||||
|
<span class="label">{{ $t('taskDetail.branch') }}</span>
|
||||||
|
<span class="value">
|
||||||
|
<span class="branch-tag"><span class="branch-icon">⑂</span>{{ task.branch_name }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- 负责人(空值不显行,Problem4 ③) -->
|
||||||
|
<div v-if="task.assignee" class="info-item">
|
||||||
|
<span class="label">{{ $t('taskDetail.assignee') }}</span>
|
||||||
|
<span class="value">{{ task.assignee }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- 基础分支(空值不显行) -->
|
||||||
|
<div v-if="task.base_branch" class="info-item">
|
||||||
|
<span class="label">{{ $t('taskDetail.baseBranch') }}</span>
|
||||||
|
<span class="value mono">{{ task.base_branch }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- 工作流定义(空值不显行) -->
|
||||||
|
<div v-if="task.workflow_def_id" class="info-item">
|
||||||
|
<span class="label">{{ $t('taskDetail.workflowDef') }}</span>
|
||||||
|
<span class="value mono">{{ task.workflow_def_id }}</span>
|
||||||
|
</div>
|
||||||
|
<!-- 创建/更新时间:最底小字(Problem4 ⑥) -->
|
||||||
|
<div class="info-item timestamps">
|
||||||
|
<span class="timestamp">{{ $t('taskDetail.createdAt') }}: {{ formatDate(task.created_at) }}</span>
|
||||||
|
<span class="timestamp">{{ $t('taskDetail.updatedAt') }}: {{ formatDate(task.updated_at) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============ 右栏:任务产出 + 工作流进度 ============ -->
|
||||||
|
<div class="right-column">
|
||||||
|
<!-- F-AiNodeSelfReview: 任务产出 + AI 自审结果展示(抽至 TaskOutputCard 子组件,零行为变更) -->
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-header"><h2>{{ $t('taskDetail.output') }}</h2></div>
|
||||||
|
<!-- 无产出时占位(任务未跑过 AiNode 产出),右栏保留产出栏框架便于后续产出出现即填入 -->
|
||||||
|
<div v-if="!task.output_json" class="empty-hint output-empty">—</div>
|
||||||
|
<TaskOutputCard v-else :output-json="task.output_json" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- B-41 工作流推进轻量进度(从面板内 advance-row 提取,推进按钮已移 header) -->
|
||||||
|
<section v-if="wfAdvancing || wfProgressHint" class="panel">
|
||||||
|
<div class="panel-header"><h2>{{ $t('taskDetail.workflowAdvanceTitle') }}</h2></div>
|
||||||
|
<div class="wf-progress">
|
||||||
|
<span v-if="wfRunningNode">{{ $t('taskDetail.workflowStepRunning', { node: wfRunningNode }) }}</span>
|
||||||
|
<span v-else-if="wfDoneTotal > 0" class="wf-progress-count">
|
||||||
|
{{ $t('taskDetail.workflowStepsProgress', { done: wfDoneCount, total: wfDoneTotal }) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span v-if="wfCompletedHint" class="wf-progress-hint">{{ $t('taskDetail.workflowCompletedHint') }}</span>
|
||||||
<!-- F-05 推进按钮:按当前 status 显示状态机合法下一态(todo/done/cancelled 终态无按钮) -->
|
<span v-if="wfFailedHint" class="wf-progress-hint wf-progress-hint-fail">{{ $t('taskDetail.workflowFailedHint') }}</span>
|
||||||
<div v-if="advanceActions.length" class="info-item info-block advance-row">
|
|
||||||
<span class="label">{{ $t('taskDetail.advanceTitle') }}</span>
|
|
||||||
<div class="advance-actions">
|
|
||||||
<button
|
|
||||||
v-for="act in advanceActions"
|
|
||||||
:key="act.target"
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm"
|
|
||||||
:class="act.variant"
|
|
||||||
:disabled="advancing"
|
|
||||||
@click="handleAdvance(act.target)"
|
|
||||||
>{{ advancing ? $t('taskDetail.advancing') : $t(act.label) }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- F-260616-06 ①-1 / B-41 工作流推进按钮(联动任务,按 target_status 后端自动选推进链模板)
|
|
||||||
与手动 advance 并存:仅当当前态的 primary 前向推进目标 ∈ {in_progress, testing, done}
|
|
||||||
(即 template_for 有对应模板)时显示。手动 advance 仍走 advance_task 直 IPC;工作流推进
|
|
||||||
走 run_workflow,经 AiNode/HumanNode 执行后再由后端回调推进任务。
|
|
||||||
独立 loading 标志 wfAdvancing 与 advancing 互不干扰。 -->
|
|
||||||
<div v-if="wfAdvanceAction" class="info-item info-block advance-row">
|
|
||||||
<span class="label">{{ $t('taskDetail.workflowAdvanceTitle') }}</span>
|
|
||||||
<div class="advance-actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-primary"
|
|
||||||
:disabled="wfAdvancing || advancing"
|
|
||||||
@click="handleWorkflowAdvance(wfAdvanceAction.target)"
|
|
||||||
>{{ wfAdvancing ? $t('taskDetail.workflowAdvancing') : $t('taskDetail.workflowAdvance') }}</button>
|
|
||||||
</div>
|
|
||||||
<!-- B-41 轻量进度(监听 workflow-event: NodeStarted/NodeCompleted/WorkflowFailed)
|
|
||||||
完整进度条/预估留后续批;完成/失败后任务态由后端回调推进,df-data-changed 自动刷新 task -->
|
|
||||||
<div v-if="wfAdvancing || wfProgressHint" class="wf-progress">
|
|
||||||
<span v-if="wfRunningNode">{{ $t('taskDetail.workflowStepRunning', { node: wfRunningNode }) }}</span>
|
|
||||||
<span v-else-if="wfDoneTotal > 0" class="wf-progress-count">
|
|
||||||
{{ $t('taskDetail.workflowStepsProgress', { done: wfDoneCount, total: wfDoneTotal }) }}
|
|
||||||
</span>
|
|
||||||
<span v-if="wfCompletedHint" class="wf-progress-hint">{{ $t('taskDetail.workflowCompletedHint') }}</span>
|
|
||||||
<span v-if="wfFailedHint" class="wf-progress-hint wf-progress-hint-fail">{{ $t('taskDetail.workflowFailedHint') }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- 工作流 DAG 结构 -->
|
<!-- 工作流 DAG 结构 -->
|
||||||
<WorkflowDagDisplay v-if="wfDagJson" :dag-json="wfDagJson" :node-statuses="wfNodeStatuses" />
|
<WorkflowDagDisplay v-if="wfDagJson" :dag-json="wfDagJson" :node-statuses="wfNodeStatuses" />
|
||||||
<div class="info-item">
|
</section>
|
||||||
<span class="label">{{ $t('taskDetail.priority') }}</span>
|
</div>
|
||||||
<span class="value">
|
|
||||||
<span class="priority-badge" :class="priorityClass(task.priority)">{{ priorityLabel(task.priority) }}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">{{ $t('taskDetail.project') }}</span>
|
|
||||||
<span class="value">
|
|
||||||
<router-link v-if="task.project_id" :to="`/projects/${task.project_id}`" class="project-link">
|
|
||||||
{{ projectName }}
|
|
||||||
</router-link>
|
|
||||||
<span v-else>—</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<!-- F-260619-01:关联灵感(1对1 单向,idea_id 解析为友好 title,非裸 id) -->
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">{{ $t('taskDetail.relatedIdea') }}</span>
|
|
||||||
<span class="value">
|
|
||||||
<router-link v-if="task.idea_id" :to="`/ideas/${task.idea_id}`" class="project-link">
|
|
||||||
{{ ideaTitle }}
|
|
||||||
</router-link>
|
|
||||||
<span v-else>—</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">{{ $t('taskDetail.branch') }}</span>
|
|
||||||
<span class="value">
|
|
||||||
<span v-if="task.branch_name" class="branch-tag">
|
|
||||||
<span class="branch-icon">⑂</span>{{ task.branch_name }}
|
|
||||||
</span>
|
|
||||||
<span v-else>—</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">{{ $t('taskDetail.assignee') }}</span>
|
|
||||||
<span class="value">{{ task.assignee ?? '—' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">{{ $t('taskDetail.baseBranch') }}</span>
|
|
||||||
<span class="value">{{ task.base_branch ?? '—' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">{{ $t('taskDetail.workflowDef') }}</span>
|
|
||||||
<span class="value mono">{{ task.workflow_def_id ?? '—' }}</span>
|
|
||||||
</div>
|
|
||||||
<!-- F-AiNodeSelfReview: 任务产出 + AI 自审结果展示(抽至 TaskOutputCard 子组件,零行为变更) -->
|
|
||||||
<TaskOutputCard :output-json="task.output_json" />
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">{{ $t('taskDetail.createdAt') }}</span>
|
|
||||||
<span class="value">{{ formatDate(task.created_at) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="info-item">
|
|
||||||
<span class="label">{{ $t('taskDetail.updatedAt') }}</span>
|
|
||||||
<span class="value">{{ formatDate(task.updated_at) }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import { listen } from '@tauri-apps/api/event'
|
import { listen } from '@tauri-apps/api/event'
|
||||||
@@ -223,6 +217,37 @@ const { rendered: renderedDesc, ensureLoaded } = useRendered(
|
|||||||
() => task.value?.description ?? '',
|
() => task.value?.description ?? '',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Problem4 ②:描述可折叠 — 超 400px 显展开按钮(测真实 DOM 高度)
|
||||||
|
// ------------------------------------------------------------
|
||||||
|
// 不用字符数近似(渲染后 Markdown 列表/标题行高不一,字符数与 px 无线性关系),
|
||||||
|
// 改用 descEl 实测 scrollHeight:首次渲染 + task 切换后 nextTick 测量。
|
||||||
|
// descCollapsible=true 时才显按钮;默认折叠(!descExpanded)即首次进入截 400px。
|
||||||
|
// 折叠态 max-height 由 descWrapStyle 内联注入(展开态不设上限,自适应)。
|
||||||
|
// .description-wrap 的 overflow:hidden + transition 让折叠/展开有过渡。
|
||||||
|
// 蒙层 .desc-fade 暗示折叠态下方还有内容。
|
||||||
|
const DESC_COLLAPSE_PX = 400
|
||||||
|
const descEl = ref<HTMLElement | null>(null)
|
||||||
|
const descExpanded = ref(false)
|
||||||
|
const descOverflow = ref(false) // 真实高度是否超阈值(控制按钮显隐)
|
||||||
|
|
||||||
|
const descCollapsible = computed(() => descOverflow.value)
|
||||||
|
|
||||||
|
// 折叠态 max-height 内联(过渡友好):展开态不设上限(自适应)
|
||||||
|
const descWrapStyle = computed(() =>
|
||||||
|
descExpanded.value ? {} : { maxHeight: DESC_COLLAPSE_PX + 'px' },
|
||||||
|
)
|
||||||
|
|
||||||
|
async function measureDescHeight() {
|
||||||
|
// descEl 是 <span>(描述内容),自身无 max-height 限制 → scrollHeight 即其真实渲染高度,
|
||||||
|
// 不受父 .description-wrap 的 max-height 裁剪影响(span 在父内若被裁,其 scrollHeight
|
||||||
|
// 仍反映完整内容高)。直接读无需临时改父样式。
|
||||||
|
await nextTick()
|
||||||
|
const el = descEl.value
|
||||||
|
if (!el) { descOverflow.value = false; return }
|
||||||
|
descOverflow.value = el.scrollHeight > DESC_COLLAPSE_PX
|
||||||
|
}
|
||||||
|
|
||||||
// F-AiNodeSelfReview: output_json 解析 + AI 产出/自审渲染已抽至 TaskOutputCard 子组件
|
// F-AiNodeSelfReview: output_json 解析 + AI 产出/自审渲染已抽至 TaskOutputCard 子组件
|
||||||
// (components/task/TaskOutputCard.vue),父级只传 output-json prop,零行为变更。
|
// (components/task/TaskOutputCard.vue),父级只传 output-json prop,零行为变更。
|
||||||
|
|
||||||
@@ -448,6 +473,11 @@ function refresh() {
|
|||||||
// 路由参数变化(id 变化)时重新加载
|
// 路由参数变化(id 变化)时重新加载
|
||||||
watch(taskId, () => { load() })
|
watch(taskId, () => { load() })
|
||||||
|
|
||||||
|
// Problem4 ②:切换 task / 描述渲染完成 → 重置折叠态 + 重测高度。
|
||||||
|
// renderedDesc 异步(Markdown 经 marked 渲染),内容变化后 nextTick 测真实 px。
|
||||||
|
watch(() => task.value?.id, () => { descExpanded.value = false })
|
||||||
|
watch(renderedDesc, () => { measureDescHeight() })
|
||||||
|
|
||||||
// B-260616-18: 数据变更联动刷新 unlistener(onMounted 注册,onBeforeUnmount 释放)
|
// B-260616-18: 数据变更联动刷新 unlistener(onMounted 注册,onBeforeUnmount 释放)
|
||||||
// 对齐 AiChat _unlistenToolSlow 生命周期模式。本视图绕 store 直调 taskApi.get/projectApi.list,
|
// 对齐 AiChat _unlistenToolSlow 生命周期模式。本视图绕 store 直调 taskApi.get/projectApi.list,
|
||||||
// 不享受 store 全局 df-data-changed 监听(该监听只刷 store.tasks 列表,不含本视图的当前 task 单体),
|
// 不享受 store 全局 df-data-changed 监听(该监听只刷 store.tasks 列表,不含本视图的当前 task 单体),
|
||||||
@@ -492,15 +522,33 @@ onBeforeUnmount(() => {
|
|||||||
.task-detail { padding: 16px 20px 20px; }
|
.task-detail { padding: 16px 20px 20px; }
|
||||||
|
|
||||||
/* page-header / btn / back-link 等已提取到 global.css 全局 */
|
/* page-header / btn / back-link 等已提取到 global.css 全局 */
|
||||||
.header-left { display: flex; align-items: center; gap: 12px; }
|
.header-left { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||||
|
.header-left h1 {
|
||||||
/* F-05 推进操作区 */
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
.advance-row .advance-actions {
|
min-width: 0;
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Problem4 ① 吸顶 header(推进按钮始终可见) */
|
||||||
|
.task-detail-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: var(--df-bg);
|
||||||
|
/* 留底部细分割线,与全局 page-header margin-bottom 协同(0.5px 边框规范) */
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 0.5px solid var(--df-border);
|
||||||
|
/* 顶负 padding 抵消 .task-detail 的 16px 顶 padding,吸顶贴顶 */
|
||||||
|
margin: -16px -20px 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
padding-right: 20px;
|
||||||
|
padding-top: 16px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
/* header-actions 内多个推进按钮 + refresh 排列(对齐全局 .header-actions gap:10px) */
|
||||||
|
.task-detail-header .header-actions { flex-wrap: wrap; justify-content: flex-end; }
|
||||||
|
|
||||||
|
/* advance 操作区已移入 header-actions,旧 .advance-row 样式删除 */
|
||||||
|
|
||||||
/* B-41 工作流推进轻量进度提示 */
|
/* B-41 工作流推进轻量进度提示 */
|
||||||
.wf-progress {
|
.wf-progress {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -552,7 +600,21 @@ onBeforeUnmount(() => {
|
|||||||
.priority-low { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
.priority-low { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||||
|
|
||||||
/* ===== 面板 ===== */
|
/* ===== 面板 ===== */
|
||||||
.detail-grid { display: grid; grid-template-columns: 1fr; gap: var(--df-gap-grid); }
|
/* Problem4 ⑤ 宽屏两栏:左栏(描述+关联信息+时间戳) / 右栏(任务产出+工作流进度)。
|
||||||
|
窄屏(<960px)单列。对齐 ProjectDetail 两栏模式但阈值更宽(产出栏更窄)。
|
||||||
|
不设 margin-top —— 全局 .page-header 已有 margin-bottom: var(--df-gap-page) 提供分隔。*/
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 380px;
|
||||||
|
gap: var(--df-gap-page);
|
||||||
|
align-items: start; /* 两栏顶对齐,长栏不撑高短栏 */
|
||||||
|
}
|
||||||
|
.left-column, .right-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--df-gap-grid);
|
||||||
|
min-width: 0; /* 网格项防溢出(长 url/代码块) */
|
||||||
|
}
|
||||||
|
|
||||||
/* .panel / .panel-header 基础样式已收敛至全局 components.css(DRY 收口 B-260619)。 */
|
/* .panel / .panel-header 基础样式已收敛至全局 components.css(DRY 收口 B-260619)。 */
|
||||||
|
|
||||||
@@ -565,8 +627,44 @@ onBeforeUnmount(() => {
|
|||||||
/* B-260615-31:字段同行布局(label 固定宽 + value 占余),描述字段 info-block 保持块状)
|
/* B-260615-31:字段同行布局(label 固定宽 + value 占余),描述字段 info-block 保持块状)
|
||||||
基础 .info-item/.label/.value 已收敛至全局 components.css(DRY 收口 B-260619),
|
基础 .info-item/.label/.value 已收敛至全局 components.css(DRY 收口 B-260619),
|
||||||
此处仅保留本组件特有覆盖。 */
|
此处仅保留本组件特有覆盖。 */
|
||||||
/* B-24 漏 white-space 覆盖致 v-html 后 HTML 标签间 \n 被 pre-wrap 渲染为空行间距——移除 pre-wrap,描述由 .ai-md 接管(line-height 1.5 对齐 AiChat li) */
|
/* B-24 漏 white-space 覆盖致 v-html 后 HTML 标签间 \n 被 pre-wrap 渲染为空行间距——移除 pre-wrap,描述由 .ai-md 接管(line-height 1.5 对齐 AiChat li)。
|
||||||
.info-item .value.description { line-height: 1.5; }
|
Problem4 重设计:描述区从 .info-item 内移到独立 .description-wrap(可折叠容器),
|
||||||
|
故选择器改 .description-wrap。display:block 让 <span> 有块盒模型,scrollHeight 可读。*/
|
||||||
|
.description-wrap .value.description { line-height: 1.5; display: block; }
|
||||||
|
|
||||||
|
/* Problem4 ② 描述可折叠:折叠态 max-height(内联注入 400px) + overflow hidden + 过渡。
|
||||||
|
展开态 max-height none(内联不设),自然撑开。蒙层 .desc-fade 暗示下方有内容。*/
|
||||||
|
.description-wrap {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.25s var(--df-ease);
|
||||||
|
}
|
||||||
|
.desc-toggle {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.desc-fade {
|
||||||
|
/* 折叠态底部渐变蒙层,提示"下方还有内容"(展开态不渲染此元素) */
|
||||||
|
position: absolute;
|
||||||
|
left: 0; right: 0; bottom: 0;
|
||||||
|
height: 32px;
|
||||||
|
background: linear-gradient(to bottom, transparent, var(--df-bg-card));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Problem4 ⑥ 创建/更新时间最底小字(独立行,非 label/value 对) */
|
||||||
|
.info-item.timestamps {
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 16px;
|
||||||
|
padding-top: var(--df-gap-grid);
|
||||||
|
border-top: 0.5px solid var(--df-border);
|
||||||
|
}
|
||||||
|
.timestamp {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 右栏空产出占位提示 */
|
||||||
|
.output-empty { padding: 24px 12px; }
|
||||||
|
|
||||||
/* F-AiNodeSelfReview output_json 区块样式已随子组件移至 TaskOutputCard.vue */
|
/* F-AiNodeSelfReview output_json 区块样式已随子组件移至 TaskOutputCard.vue */
|
||||||
|
|
||||||
@@ -599,4 +697,15 @@ onBeforeUnmount(() => {
|
|||||||
font-size: 13px; color: var(--df-text-dim);
|
font-size: 13px; color: var(--df-text-dim);
|
||||||
}
|
}
|
||||||
.error-hint { color: var(--df-danger); }
|
.error-hint { color: var(--df-danger); }
|
||||||
|
|
||||||
|
/* Problem4 ⑤ 响应式:窄屏(<960px)两栏退单列,产出栏移到下方 */
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.detail-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 窄屏 header-actions 内推进按钮可能换行,允许并压缩间距 */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.task-detail-header { flex-wrap: wrap; }
|
||||||
|
.task-detail-header .header-left { flex: 1 1 100%; margin-bottom: 8px; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+9
-6
@@ -181,21 +181,24 @@ import type { TaskRecord, TaskQuery, ProjectId } from '@/api/types'
|
|||||||
import Paginator from '../components/Paginator.vue'
|
import Paginator from '../components/Paginator.vue'
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||||
import { useConfirm } from '@/composables/useConfirm'
|
import { useConfirm } from '@/composables/useConfirm'
|
||||||
|
import { usePersistedRef } from '@/composables/usePersistedRef'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useProjectStore()
|
const store = useProjectStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||||
|
|
||||||
const activeProject = ref('all')
|
// 列表筛选/分页状态持久化到 localStorage(key 前缀 'tasks.'),
|
||||||
const activeStatus = ref('all')
|
// 刷新页面后保留用户上次选择。collapsedGroups 单独走 df-tasks-collapsed(沿用既有实现)。
|
||||||
const sortBy = ref('updated_at')
|
const activeProject = usePersistedRef('tasks.activeProject', 'all')
|
||||||
const searchKeyword = ref('')
|
const activeStatus = usePersistedRef('tasks.activeStatus', 'all')
|
||||||
|
const sortBy = usePersistedRef('tasks.sortBy', 'updated_at')
|
||||||
|
const searchKeyword = usePersistedRef('tasks.searchKeyword', '')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
// 分页(默认开启 20 条/页)
|
// 分页(默认开启 20 条/页)
|
||||||
const page = ref(1)
|
const page = usePersistedRef('tasks.page', 1)
|
||||||
const pageSize = ref(20)
|
const pageSize = usePersistedRef('tasks.pageSize', 20)
|
||||||
const totalTasks = ref(0)
|
const totalTasks = ref(0)
|
||||||
|
|
||||||
// 分组折叠状态(localStorage 记忆)
|
// 分组折叠状态(localStorage 记忆)
|
||||||
|
|||||||
Reference in New Issue
Block a user