优化: 所有剩余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:
@@ -276,31 +276,69 @@ async function deleteConversation(id: string) {
|
||||
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) {
|
||||
await aiApi.renameConversation(id, title)
|
||||
// 本地同步更新侧栏摘要标题
|
||||
const conv = state.conversations.find(c => c.id === id)
|
||||
const prevTitle = conv?.title ?? null
|
||||
if (conv) conv.title = title
|
||||
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) {
|
||||
await aiApi.archiveConversation(id, archived)
|
||||
// 本地同步归档态(侧栏立即移入/移出归档分组)
|
||||
const conv = state.conversations.find(c => c.id === id)
|
||||
const prevArchived = conv?.archived ?? false
|
||||
if (conv) conv.archived = archived
|
||||
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) {
|
||||
await aiApi.setPinnedConversation(id, pinned)
|
||||
// 本地同步置顶态(排序 computed 读 pinned,立即重排)
|
||||
const conv = state.conversations.find(c => c.id === id)
|
||||
const prevPinned = conv?.pinned ?? false
|
||||
if (conv) conv.pinned = pinned
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user