新增: Phase2 阶段收尾(Sprint 1-20)
重构:删 5 零引用 crate(df-evolve/plugin/stages/task/traceability)+ 清死模块、ai.rs 拆 11 子 module、ai.ts 拆 6 composable、i18n 拆目录 功能:知识库全栈(df-project/scan + CRUD + 时间线 + 前端)、Settings 拆分、appSettings KV 迁移、模型池、LLM 并发 Semaphore 修复:审批持久化根治、ConditionEngine 默认拒绝、NodeRegistry unimplemented 清除、promote 补偿删除、工具结果截断 50KB、路径校验防 symlink 逃逸 文档:B-03 人工审批设计、决策记录三分档、规格契约自检、经验记录、todo 看板、PROGRESS 更新 详见 PROGRESS.md。src-tauri/儿童每日打卡应用/ 与本项目无关,已排除。
This commit is contained in:
534
src/stores/ai.ts
534
src/stores/ai.ts
@@ -1,15 +1,35 @@
|
||||
//! AI 聊天 Store — 管理对话状态、流式渲染、工具审批、对话管理
|
||||
//! AI 聊天 Store — 模块级单例 state + useAiStore() 统一入口
|
||||
//!
|
||||
//! 架构(路线A:state 单例 + 逻辑外移到 composables):
|
||||
//! - 本文件只保留模块级单例 reactive state(19 字段)+ useAiStore() 统一入口
|
||||
//! - 所有方法搬到 src/composables/ai/*,通过 ...useAiXxx(state) 展开到 useAiStore() 返回值
|
||||
//! - 组件用法不变:`const { state, sendMessage, ... } = useAiStore()` 零改动
|
||||
//! - composables 之间通过模块级 import 直接互调(events↔send↔stream 耦合)
|
||||
//!
|
||||
//! composables 职责:
|
||||
//! - useAiEvents startListener/stopListener/handleEvent/flushCurrentText/findToolCall/friendlyError/notifyConversationChanged
|
||||
//! - useAiStream onStreamTimeout/resetStreamWatchdog/clearStreamWatchdog + STREAM_TIMEOUT_MS
|
||||
//! - useAiSend sendMessage/approveToolCall/drainQueue/cancelQueued/clearQueue/stopChat
|
||||
//! - useAiConversations loadConversations/newConversation/switchConversation/deleteConversation/renameConversation/archiveConversation/toggleArchivedFold/toggleSidebar
|
||||
//! - useAiWindow detachPanel/reattachPanel/resumeInDetached/closeDetachedWindow/dockDetached/syncToMain/startFollowMain/stopFollowMain
|
||||
//! - useAiPanel togglePanel/toggleMaximize/loadProviders/loadSkills/setProvider/clearChat + restoreUiState/persistUiState
|
||||
//!
|
||||
//! 注意:
|
||||
//! - 各 composable 模块加载时会执行其顶层副作用(useAiPanel.restoreUiState + watch appSettings),
|
||||
//! 故 useAiStore() 之外无需手动 init;以下 import 即触发恢复逻辑
|
||||
//! - state 为模块级单例,全应用共享同一引用
|
||||
|
||||
import { reactive } from 'vue'
|
||||
import { listen, emit } from '@tauri-apps/api/event'
|
||||
import { aiApi } from '../api'
|
||||
import type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, AiToolCallInfo } from '../api/types'
|
||||
import type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, AiToolCallInfo, SkillInfo } from '../api/types'
|
||||
import { useAiEvents } from '../composables/ai/useAiEvents'
|
||||
import { useAiStream } from '../composables/ai/useAiStream'
|
||||
import { useAiSend } from '../composables/ai/useAiSend'
|
||||
import { useAiConversations } from '../composables/ai/useAiConversations'
|
||||
import { useAiWindow } from '../composables/ai/useAiWindow'
|
||||
import { useAiPanel } from '../composables/ai/useAiPanel'
|
||||
|
||||
let _unlistenAiEvent: (() => void) | null = null
|
||||
let _unlistenConvChanged: (() => void) | null = null
|
||||
let _msgCounter = 0
|
||||
|
||||
const state = reactive({
|
||||
/// 模块级单例 state — 全应用共享(composables 通过 `import { state } from '../../stores/ai'` 取用)
|
||||
export const state = reactive({
|
||||
messages: [] as AiMessage[],
|
||||
streaming: false,
|
||||
currentText: '',
|
||||
@@ -29,481 +49,35 @@ const state = reactive({
|
||||
detached: false,
|
||||
// 吸附跟随中
|
||||
docked: false,
|
||||
// 本机技能(`/` 联想)
|
||||
skills: [] as SkillInfo[],
|
||||
// 待发送队列(生成中发的消息排队,AiCompleted 后自动续发)
|
||||
queue: [] as { text: string; skill?: string }[],
|
||||
// 归档分组折叠态(默认折叠)
|
||||
archivedCollapsed: true,
|
||||
// token 用量展示(受 df-show-token-usage 开关控制;lastTokenUsage=最近一条回复,convTokenTotal=当前对话累计)
|
||||
lastTokenUsage: null as { prompt: number; completion: number; total: number } | null,
|
||||
convTokenTotal: null as { prompt: number; completion: number; total: number } | null,
|
||||
})
|
||||
|
||||
// 旁注:此处不再保留 type-only 导出(AiChatEvent 等),因组件直接从 api/types import。
|
||||
// 若有外部模块仍从本文件 import 这些类型,下方 re-export 兜底:
|
||||
export type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, AiToolCallInfo, SkillInfo }
|
||||
|
||||
/**
|
||||
* AI Store 统一入口 — 返回单例 state + 全部方法。
|
||||
*
|
||||
* 组件零改动:解构 shape 与拆分前完全一致。每次调用都重新展开 composable 方法
|
||||
* (composable 内部全部是模块级单例实现,展开的只是引用,无重复实例化开销)。
|
||||
*/
|
||||
export function useAiStore() {
|
||||
async function startListener() {
|
||||
if (_unlistenAiEvent) return
|
||||
_unlistenAiEvent = await aiApi.onEvent(handleEvent)
|
||||
_unlistenConvChanged = await listen('ai-conversation-changed', () => {
|
||||
loadConversations()
|
||||
})
|
||||
}
|
||||
|
||||
function stopListener() {
|
||||
_unlistenAiEvent?.()
|
||||
_unlistenConvChanged?.()
|
||||
_unlistenAiEvent = null
|
||||
_unlistenConvChanged = null
|
||||
}
|
||||
|
||||
function notifyConversationChanged() {
|
||||
emit('ai-conversation-changed', {})
|
||||
}
|
||||
|
||||
/** 后端原始错误转用户友好提示 */
|
||||
function friendlyError(raw: string): string {
|
||||
if (/404|not\s*found/i.test(raw)) return '调用失败:接口地址或模型不存在,请检查 Provider 配置'
|
||||
if (/401|403|unauthorized|api[_\s-]?key/i.test(raw)) return '调用失败:API Key 无效或无权限'
|
||||
if (/timeout|超时/i.test(raw)) return '响应超时,请重试'
|
||||
if (/network|connection|ECONN|网络|连接/i.test(raw)) return '网络连接失败,请检查网络'
|
||||
return raw
|
||||
}
|
||||
|
||||
function handleEvent(event: AiChatEvent) {
|
||||
const convId = event.conversation_id
|
||||
// 首次收到事件时同步当前对话 id(后端自动建对话的场景)
|
||||
if (convId && !state.activeConversationId) state.activeConversationId = convId
|
||||
// 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误时刷新侧边栏
|
||||
const isCurrent = !convId || convId === state.activeConversationId
|
||||
if (!isCurrent) {
|
||||
if (event.type === 'AiCompleted' || event.type === 'AiError') {
|
||||
state.generatingConvId = null
|
||||
loadConversations()
|
||||
}
|
||||
return
|
||||
}
|
||||
// 标记正在生成的对话(完成/错误事件除外)
|
||||
if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError') {
|
||||
state.generatingConvId = convId
|
||||
}
|
||||
switch (event.type) {
|
||||
case 'AiTextDelta':
|
||||
state.currentText += event.delta
|
||||
break
|
||||
|
||||
case 'AiAgentRound': {
|
||||
// Agent 循环新一轮:保存当前文本到上一条 assistant 消息,新建空 assistant 消息
|
||||
if (state.currentText) {
|
||||
const lastMsg = state.messages[state.messages.length - 1]
|
||||
if (lastMsg && lastMsg.role === 'assistant') {
|
||||
lastMsg.content = state.currentText
|
||||
}
|
||||
}
|
||||
state.currentText = ''
|
||||
state.messages.push({
|
||||
id: `ai-${++_msgCounter}`,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiToolCallStarted': {
|
||||
const info: AiToolCallInfo = {
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
args: event.args,
|
||||
status: 'running',
|
||||
}
|
||||
const lastMsg = state.messages[state.messages.length - 1]
|
||||
if (lastMsg && lastMsg.role === 'assistant') {
|
||||
lastMsg.toolCalls = lastMsg.toolCalls || []
|
||||
lastMsg.toolCalls.push(info)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiToolCallCompleted': {
|
||||
const tc = findToolCall(event.id)
|
||||
if (tc) {
|
||||
tc.status = 'completed'
|
||||
tc.result = event.result
|
||||
}
|
||||
state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id)
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiApprovalRequired': {
|
||||
const info: AiToolCallInfo = {
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
args: event.args,
|
||||
status: 'pending_approval',
|
||||
}
|
||||
state.pendingApprovals.push(info)
|
||||
const tc = findToolCall(event.id)
|
||||
if (tc) tc.status = 'pending_approval'
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiApprovalResult': {
|
||||
if (!event.approved) {
|
||||
const tc = findToolCall(event.id)
|
||||
if (tc) tc.status = 'rejected'
|
||||
state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiCompleted': {
|
||||
if (state.currentText) {
|
||||
const lastMsg = state.messages[state.messages.length - 1]
|
||||
if (lastMsg && lastMsg.role === 'assistant') {
|
||||
lastMsg.content = state.currentText
|
||||
}
|
||||
}
|
||||
state.currentText = ''
|
||||
state.streaming = false
|
||||
state.generatingConvId = null
|
||||
// 清理分离窗口生成态快照
|
||||
localStorage.removeItem('df-ai-gen')
|
||||
localStorage.removeItem('df-ai-text')
|
||||
loadConversations()
|
||||
notifyConversationChanged()
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiError': {
|
||||
state.streaming = false
|
||||
state.generatingConvId = null
|
||||
state.currentText = ''
|
||||
localStorage.removeItem('df-ai-gen')
|
||||
localStorage.removeItem('df-ai-text')
|
||||
state.messages.push({
|
||||
id: `err-${++_msgCounter}`,
|
||||
role: 'assistant',
|
||||
content: friendlyError(event.error),
|
||||
isError: true,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findToolCall(id: string): AiToolCallInfo | undefined {
|
||||
for (const msg of state.messages) {
|
||||
if (msg.toolCalls) {
|
||||
const tc = msg.toolCalls.find(t => t.id === id)
|
||||
if (tc) return tc
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function sendMessage(text: string) {
|
||||
if (!text.trim() || state.streaming) return
|
||||
|
||||
state.messages.push({
|
||||
id: `user-${++_msgCounter}`,
|
||||
role: 'user',
|
||||
content: text.trim(),
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
state.messages.push({
|
||||
id: `ai-${++_msgCounter}`,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
state.streaming = true
|
||||
state.currentText = ''
|
||||
|
||||
await startListener()
|
||||
const raw = localStorage.getItem('df-ai-language') || 'auto'
|
||||
const lang = raw === 'auto'
|
||||
? (localStorage.getItem('df-language') || 'zh-CN')
|
||||
: raw
|
||||
await aiApi.sendMessage(text.trim(), lang)
|
||||
}
|
||||
|
||||
async function approveToolCall(toolCallId: string, approved: boolean) {
|
||||
await aiApi.approve(toolCallId, approved)
|
||||
}
|
||||
|
||||
async function loadProviders() {
|
||||
state.providers = await aiApi.listProviders()
|
||||
}
|
||||
|
||||
async function setProvider(providerId: string) {
|
||||
await aiApi.setProvider(providerId)
|
||||
state.activeProvider = providerId
|
||||
}
|
||||
|
||||
async function clearChat() {
|
||||
await aiApi.clearChat()
|
||||
state.messages = []
|
||||
state.currentText = ''
|
||||
state.pendingApprovals = []
|
||||
state.streaming = false
|
||||
}
|
||||
|
||||
/** 停止当前生成:仅发停止信号,streaming 状态由后端 AiCompleted 事件收尾 */
|
||||
async function stopChat() {
|
||||
await aiApi.stopChat()
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
state.panelOpen = !state.panelOpen
|
||||
}
|
||||
|
||||
function toggleMaximize() {
|
||||
state.maximized = !state.maximized
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════
|
||||
// 对话管理
|
||||
// ══════════════════════════════════════
|
||||
|
||||
async function loadConversations() {
|
||||
try {
|
||||
state.conversations = await aiApi.listConversations()
|
||||
} catch {
|
||||
// 静默失败,不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
async function newConversation() {
|
||||
const result = await aiApi.createConversation()
|
||||
state.activeConversationId = result.id
|
||||
state.messages = []
|
||||
state.currentText = ''
|
||||
state.pendingApprovals = []
|
||||
state.streaming = false
|
||||
await loadConversations()
|
||||
notifyConversationChanged()
|
||||
}
|
||||
|
||||
async function switchConversation(id: string) {
|
||||
// 允许生成中切换:后台继续生成,事件按 conversation_id 路由不污染当前视图
|
||||
const detail = await aiApi.switchConversation(id)
|
||||
state.activeConversationId = id
|
||||
|
||||
try {
|
||||
const rawMsgs = typeof detail.messages === 'string'
|
||||
? JSON.parse(detail.messages)
|
||||
: detail.messages
|
||||
|
||||
// 构建 tool_call_id → tool_result 映射,用于回填工具执行结果
|
||||
const toolResultMap = new Map<string, string>()
|
||||
for (const m of rawMsgs) {
|
||||
if (m.role === 'tool' && m.tool_call_id) {
|
||||
toolResultMap.set(m.tool_call_id, m.content || '')
|
||||
}
|
||||
}
|
||||
|
||||
state.messages = rawMsgs
|
||||
.filter((m: any) => m.role !== 'tool')
|
||||
.map((m: any, i: number) => ({
|
||||
id: `loaded-${i}`,
|
||||
role: m.role,
|
||||
content: m.content || '',
|
||||
timestamp: Date.now(),
|
||||
toolCalls: m.tool_calls?.map((tc: any) => ({
|
||||
id: tc.id,
|
||||
name: tc.function?.name || '',
|
||||
args: typeof tc.function?.arguments === 'string'
|
||||
? JSON.parse(tc.function.arguments || '{}')
|
||||
: tc.function?.arguments || {},
|
||||
status: 'completed' as const,
|
||||
result: toolResultMap.get(tc.id),
|
||||
})),
|
||||
}))
|
||||
} catch {
|
||||
state.messages = []
|
||||
}
|
||||
|
||||
state.currentText = ''
|
||||
state.pendingApprovals = []
|
||||
}
|
||||
|
||||
async function deleteConversation(id: string) {
|
||||
await aiApi.deleteConversation(id)
|
||||
if (state.activeConversationId === id) {
|
||||
state.activeConversationId = null
|
||||
state.messages = []
|
||||
}
|
||||
await loadConversations()
|
||||
notifyConversationChanged()
|
||||
}
|
||||
|
||||
async function renameConversation(id: string, title: string) {
|
||||
await aiApi.renameConversation(id, title)
|
||||
// 本地同步更新侧栏摘要标题
|
||||
const conv = state.conversations.find(c => c.id === id)
|
||||
if (conv) conv.title = title
|
||||
notifyConversationChanged()
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
state.sidebarOpen = !state.sidebarOpen
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════
|
||||
// 窗口模式
|
||||
// ══════════════════════════════════════
|
||||
|
||||
async function detachPanel() {
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const existing = await WebviewWindow.getByLabel('ai-detached')
|
||||
if (existing) {
|
||||
await existing.setFocus()
|
||||
return
|
||||
}
|
||||
// 快照当前生成态,供分离窗口接管(保持正在进行的对话)
|
||||
if (state.streaming && state.generatingConvId) {
|
||||
localStorage.setItem('df-ai-gen', state.generatingConvId)
|
||||
localStorage.setItem('df-ai-text', state.currentText)
|
||||
}
|
||||
const convId = state.activeConversationId
|
||||
const sep = convId ? `?conv=${encodeURIComponent(convId)}` : ''
|
||||
const url = window.location.origin + window.location.pathname + '#/ai-detached' + sep
|
||||
const win = new WebviewWindow('ai-detached', {
|
||||
url,
|
||||
title: 'DevFlow AI',
|
||||
width: 520,
|
||||
height: 700,
|
||||
minWidth: 360,
|
||||
minHeight: 400,
|
||||
center: true,
|
||||
decorations: true,
|
||||
})
|
||||
win.once('tauri://created', () => {
|
||||
state.panelOpen = false
|
||||
})
|
||||
win.once('tauri://error', (e) => {
|
||||
console.error('[AI] 窗口创建失败:', e)
|
||||
state.detached = false
|
||||
})
|
||||
win.once('tauri://destroyed', () => {
|
||||
state.detached = false
|
||||
state.docked = false
|
||||
stopFollowMain()
|
||||
})
|
||||
state.detached = true
|
||||
}
|
||||
|
||||
async function reattachPanel() {
|
||||
stopFollowMain()
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const win = await WebviewWindow.getByLabel('ai-detached')
|
||||
if (win) {
|
||||
await win.close()
|
||||
}
|
||||
state.detached = false
|
||||
state.docked = false
|
||||
state.panelOpen = true
|
||||
localStorage.removeItem('df-ai-gen')
|
||||
localStorage.removeItem('df-ai-text')
|
||||
}
|
||||
|
||||
/** 分离窗口挂载时接管主窗口当前对话(含生成中态) */
|
||||
async function resumeInDetached(convId: string | null) {
|
||||
const id = convId || state.activeConversationId
|
||||
if (id) await switchConversation(id)
|
||||
const gen = localStorage.getItem('df-ai-gen')
|
||||
if (gen) {
|
||||
// 接管正在生成的对话:恢复已生成文本并补占位,后续 delta 继续追加
|
||||
state.currentText = localStorage.getItem('df-ai-text') || ''
|
||||
state.messages.push({
|
||||
id: `ai-${++_msgCounter}`,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
state.streaming = true
|
||||
state.generatingConvId = gen
|
||||
}
|
||||
}
|
||||
|
||||
async function closeDetachedWindow() {
|
||||
state.docked = false
|
||||
stopFollowMain()
|
||||
localStorage.removeItem('df-ai-gen')
|
||||
localStorage.removeItem('df-ai-text')
|
||||
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
await getCurrentWebviewWindow().close()
|
||||
}
|
||||
|
||||
/** 吸附/取消吸附:AI 窗口跟随主窗口右侧 */
|
||||
async function dockDetached() {
|
||||
// 取消吸附
|
||||
if (state.docked) {
|
||||
state.docked = false
|
||||
stopFollowMain()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await syncToMain()
|
||||
state.docked = true
|
||||
startFollowMain()
|
||||
} catch (e) {
|
||||
console.error('[AI] 吸附失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 同步 AI 窗口位置到主窗口右侧(保留 4px 间隙) */
|
||||
async function syncToMain() {
|
||||
const { WebviewWindow, getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const { PhysicalPosition, PhysicalSize } = await import('@tauri-apps/api/dpi')
|
||||
const mainWin = await WebviewWindow.getByLabel('main')
|
||||
const aiWin = getCurrentWebviewWindow()
|
||||
if (!mainWin) return
|
||||
const pos = await mainWin.outerPosition()
|
||||
const size = await mainWin.innerSize()
|
||||
await aiWin.setPosition(new PhysicalPosition(pos.x + size.width + 4, pos.y))
|
||||
await aiWin.setSize(new PhysicalSize(600, size.height))
|
||||
}
|
||||
|
||||
// ── 主窗口事件跟随 ──
|
||||
let _unlistenMove: (() => void) | null = null
|
||||
let _unlistenResize: (() => void) | null = null
|
||||
|
||||
async function startFollowMain() {
|
||||
stopFollowMain()
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const mainWin = await WebviewWindow.getByLabel('main')
|
||||
if (!mainWin) return
|
||||
_unlistenMove = await mainWin.onMoved(() => { syncToMain() })
|
||||
_unlistenResize = await mainWin.onResized(() => { syncToMain() })
|
||||
}
|
||||
|
||||
function stopFollowMain() {
|
||||
_unlistenMove?.()
|
||||
_unlistenResize?.()
|
||||
_unlistenMove = null
|
||||
_unlistenResize = null
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
sendMessage,
|
||||
approveToolCall,
|
||||
loadProviders,
|
||||
setProvider,
|
||||
clearChat,
|
||||
stopChat,
|
||||
togglePanel,
|
||||
toggleMaximize,
|
||||
startListener,
|
||||
stopListener,
|
||||
// 对话管理
|
||||
loadConversations,
|
||||
newConversation,
|
||||
switchConversation,
|
||||
deleteConversation,
|
||||
renameConversation,
|
||||
toggleSidebar,
|
||||
// 窗口模式
|
||||
detachPanel,
|
||||
reattachPanel,
|
||||
closeDetachedWindow,
|
||||
dockDetached,
|
||||
resumeInDetached,
|
||||
...useAiEvents(),
|
||||
...useAiStream(),
|
||||
...useAiSend(),
|
||||
...useAiConversations(),
|
||||
...useAiWindow(),
|
||||
...useAiPanel(),
|
||||
}
|
||||
}
|
||||
|
||||
138
src/stores/appSettings.ts
Normal file
138
src/stores/appSettings.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
//! 应用设置 KV Store — localStorage → SQLite 迁移的统一持久化层
|
||||
//!
|
||||
//! 后端 `app_settings` 表的 value 永远是 JSON 字符串。本层职责:
|
||||
//! - 缓存已解析(反序列化)值,按 key 索引,组件同步读
|
||||
//! - 写操作立即更新缓存(乐观),并按 key debounce ~300ms 合并连续快速写
|
||||
//! - 组合式 `useSetting` 让组件像用 ref 一样用 v-model 绑定某个 key
|
||||
//!
|
||||
//! 注意:此为「KV 偏好持久化」层,与已有的 `stores/settings.ts`(mock 的 AI 提供商/
|
||||
//! 连接/通用配置列表)是两件事 —— 那个文件承载静态展示数据,本文件承载运行时偏好,
|
||||
//! 故单独成文,互不干扰。
|
||||
|
||||
import { reactive, ref, watch, type Ref } from 'vue'
|
||||
import { settingsApi } from '../api/settings'
|
||||
|
||||
/// 按 key 合并连续写的 debounce 时长(毫秒)
|
||||
const SET_DEBOUNCE_MS = 300
|
||||
|
||||
/// 已解析值的响应式缓存 —— 用 reactive 对象而非 Map,使 `useSetting` 的 watch 能
|
||||
/// 在 `loadAll` / `set` / `remove` 写入时被触发,从而同步刷新组件视图
|
||||
const cache = reactive<Record<string, unknown>>({})
|
||||
|
||||
/// 按 key 的 pending 写定时器:连续 set 在窗口内只发最后一次到 SQLite
|
||||
const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
/// 载荷队列:debounce 触发时取最新值(而非定时器创建时的快照)
|
||||
const pendingValues = new Map<string, unknown>()
|
||||
|
||||
/** 启动时一次性拉回全部 key/value 并解析填充缓存(App.vue 调用) */
|
||||
async function loadAll(): Promise<void> {
|
||||
const all = await settingsApi.getAll()
|
||||
for (const [k, raw] of Object.entries(all)) {
|
||||
cache[k] = parse(raw)
|
||||
}
|
||||
}
|
||||
|
||||
/** 同步取已解析值,未命中或缓存未加载时返回 defaultValue */
|
||||
function get<T>(key: string, defaultValue: T): T {
|
||||
const v = cache[key]
|
||||
return v === undefined ? defaultValue : (v as T)
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入某 key:立即更新缓存(乐观,视图无延迟),再 debounce 后落库。
|
||||
* 同 key 连续快速写(如拖拽/输入)在窗口内合并,只把最后一次值 JSON.stringify 发后端。
|
||||
*/
|
||||
async function set(key: string, value: unknown): Promise<void> {
|
||||
cache[key] = value
|
||||
pendingValues.set(key, value)
|
||||
|
||||
const existing = pendingTimers.get(key)
|
||||
if (existing) clearTimeout(existing)
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
pendingTimers.delete(key)
|
||||
const latest = pendingValues.get(key)
|
||||
pendingValues.delete(key)
|
||||
// 落库失败仅记日志:缓存已是最新值,降级为内存态,避免回滚造成 UI 与库不一致
|
||||
settingsApi
|
||||
.set(key, JSON.stringify(latest))
|
||||
.catch((e) => console.error(`[appSettings] 写入 ${key} 失败:`, e))
|
||||
}, SET_DEBOUNCE_MS)
|
||||
pendingTimers.set(key, timer)
|
||||
}
|
||||
|
||||
/** 删除某 key:清缓存 + 取消 pending 写 + 调后端删除 */
|
||||
async function remove(key: string): Promise<void> {
|
||||
delete cache[key]
|
||||
pendingValues.delete(key)
|
||||
const t = pendingTimers.get(key)
|
||||
if (t) {
|
||||
clearTimeout(t)
|
||||
pendingTimers.delete(key)
|
||||
}
|
||||
await settingsApi.delete(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* 组合式:把某 key 绑定到一个 ref,组件可直接 v-model。
|
||||
* 读走缓存(同步、响应式),写触发 debounced set。
|
||||
*
|
||||
* 实现说明:用普通 `ref` 持有当前值,`watch(cache[key])` 单向同步缓存 → ref,
|
||||
* 这样 `loadAll` 异步填充缓存后能正确刷新组件视图(customRef 的 trigger 不会被
|
||||
* reactive cache 的异步 mutation 自动唤起,故改用 watch 显式同步)。
|
||||
*
|
||||
* @example
|
||||
* const theme = useSetting('df-theme', 'dark')
|
||||
* // 模板里 <a-switch v-model="theme" /> —— 改动自动落库
|
||||
*/
|
||||
function useSetting<T>(key: string, defaultValue: T): Ref<T> {
|
||||
const r = ref(get<T>(key, defaultValue)) as Ref<T>
|
||||
|
||||
// 缓存 → ref:loadAll/set/remove 改 cache[key] 时同步到 ref
|
||||
watch(
|
||||
() => cache[key],
|
||||
(v) => {
|
||||
// 写回值与当前 ref 不同时才更新,避免组件 set 后被 watch 回写造成抖动
|
||||
const next = v === undefined ? defaultValue : (v as T)
|
||||
if (!Object.is(next, r.value)) r.value = next
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// ref → 缓存(及落库):组件改 .value 时触发 debounced set
|
||||
watch(
|
||||
r,
|
||||
(v) => {
|
||||
// 仅当与缓存当前值不同时才写,避免 watch 回写触发循环
|
||||
if (!Object.is(v, cache[key])) void set(key, v)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析后端原始值:JSON.parse 失败时回退为原字符串(兼容历史非 JSON 数据)。
|
||||
* null/空串直接返回,避免 `JSON.parse('')` 抛错。
|
||||
*/
|
||||
function parse(raw: string): unknown {
|
||||
if (raw === null || raw === '') return raw
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
export function useAppSettingsStore() {
|
||||
return {
|
||||
cache,
|
||||
loadAll,
|
||||
get,
|
||||
set,
|
||||
remove,
|
||||
useSetting,
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
export { useProjectStore } from './project'
|
||||
|
||||
export { useKnowledgeStore } from './knowledge'
|
||||
export type { KnowledgeItem } from './knowledge'
|
||||
|
||||
|
||||
export { useSettingsStore } from './settings'
|
||||
export type { AIProvider, Connection, GeneralSettings } from './settings'
|
||||
|
||||
export { useAppSettingsStore } from './appSettings'
|
||||
|
||||
@@ -1,79 +1,156 @@
|
||||
import { reactive, computed } from 'vue'
|
||||
import { knowledgeApi } from '../api'
|
||||
import type {
|
||||
KnowledgeRecord,
|
||||
KnowledgeDetailPayload,
|
||||
KnowledgeEventRecord,
|
||||
CreateKnowledgeInput,
|
||||
UpdateKnowledgeInput,
|
||||
KnowledgeConfig,
|
||||
} from '../api/types'
|
||||
|
||||
export interface KnowledgeItem {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
category: string
|
||||
tags: string[]
|
||||
reuseCount: number
|
||||
score: number
|
||||
updatedAt: string
|
||||
}
|
||||
// ── 全局响应式状态(单例) ──
|
||||
|
||||
const state = reactive({
|
||||
items: [
|
||||
// 审查规则
|
||||
{ id: 1, title: 'SQL 注入防护检查', description: '所有 SQL 拼接必须使用参数化查询,禁止字符串拼接用户输入', category: 'review', tags: ['安全', 'SQL', 'Go'], reuseCount: 23, score: 95, updatedAt: '3 天前' },
|
||||
{ id: 2, title: '错误处理规范', description: '禁止吞掉 error,必须向上传播或记录日志', category: 'review', tags: ['Go', '规范', '错误处理'], reuseCount: 18, score: 88, updatedAt: '1 周前' },
|
||||
{ id: 3, title: 'API 响应格式一致性', description: '统一使用 { code, message, data } 结构,HTTP 状态码语义正确', category: 'review', tags: ['API', '规范'], reuseCount: 15, score: 82, updatedAt: '5 天前' },
|
||||
// Prompt 模板
|
||||
{ id: 4, title: 'SQL 查询生成 Prompt', description: '根据自然语言生成 SQL,包含表结构上下文和示例输出格式', category: 'prompt', tags: ['SQL', 'LLM', '模板'], reuseCount: 142, score: 91, updatedAt: '昨天' },
|
||||
{ id: 5, title: '代码审查 Prompt', description: 'AI 代码审查专用 Prompt,涵盖安全、性能、可维护性维度', category: 'prompt', tags: ['代码审查', 'LLM'], reuseCount: 87, score: 85, updatedAt: '3 天前' },
|
||||
{ id: 6, title: 'API 文档生成 Prompt', description: '从 Handler 代码自动生成 API 文档的 Prompt 模板', category: 'prompt', tags: ['文档', 'LLM', '自动生成'], reuseCount: 34, score: 78, updatedAt: '1 周前' },
|
||||
// 踩坑经验
|
||||
{ id: 7, title: 'Go context 传递陷阱', description: 'goroutine 中必须传递 context 而非创建新的,否则超时控制失效', category: 'pitfall', tags: ['Go', '并发', 'context'], reuseCount: 12, score: 92, updatedAt: '2 天前' },
|
||||
{ id: 8, title: 'Vue 3 作用域坑', description: 'v-for 内 ref 绑定不会自动响应式,需使用数组形式', category: 'pitfall', tags: ['Vue', '前端', '响应式'], reuseCount: 8, score: 75, updatedAt: '1 周前' },
|
||||
{ id: 9, title: 'Docker 网络模式选择', description: 'host 模式在 Mac/Win 上无效,必须用端口映射', category: 'pitfall', tags: ['Docker', '网络'], reuseCount: 6, score: 70, updatedAt: '2 周前' },
|
||||
// 诊断知识
|
||||
{ id: 10, title: 'MySQL 慢查询诊断流程', description: 'EXPLAIN → 索引检查 → 慢查询日志分析 → 优化建议', category: 'diagnosis', tags: ['MySQL', '性能', '诊断'], reuseCount: 31, score: 89, updatedAt: '4 天前' },
|
||||
{ id: 11, title: 'Go 内存泄漏排查', description: 'pprof heap 分析 → goroutine 泄漏检查 → GC 调优', category: 'diagnosis', tags: ['Go', '内存', 'pprof'], reuseCount: 9, score: 83, updatedAt: '1 周前' },
|
||||
{ id: 12, title: '前端白屏诊断', description: 'Console 错误 → 网络请求 → 路由配置 → 构建产物检查', category: 'diagnosis', tags: ['前端', '调试', 'Vue'], reuseCount: 14, score: 80, updatedAt: '5 天前' },
|
||||
// 部署经验
|
||||
{ id: 13, title: 'Go 二进制热更新', description: 'kill + mv + nohup 启动,无需 systemd 的轻量部署方案', category: 'deploy', tags: ['Go', '部署', 'Linux'], reuseCount: 19, score: 86, updatedAt: '昨天' },
|
||||
{ id: 14, title: 'SCP 上传最佳实践', description: '使用 ssh-proxy upload 代替 scp,支持配置化管理', category: 'deploy', tags: ['SCP', '部署', '工具'], reuseCount: 11, score: 72, updatedAt: '3 天前' },
|
||||
{ id: 15, title: 'Nginx 反向代理配置', description: 'u-ask 的 Nginx 配置模板,含 WebSocket 支持和 gzip', category: 'deploy', tags: ['Nginx', '配置', '反向代理'], reuseCount: 7, score: 77, updatedAt: '1 周前' },
|
||||
] as KnowledgeItem[],
|
||||
|
||||
categories: [
|
||||
{ key: 'all', label: '全部', icon: '📦' },
|
||||
{ key: 'review', label: '审查规则', icon: '🔍' },
|
||||
{ key: 'prompt', label: 'Prompt模板', icon: '💬' },
|
||||
{ key: 'pitfall', label: '踩坑经验', icon: '⚠️' },
|
||||
{ key: 'diagnosis', label: '诊断知识', icon: '🩺' },
|
||||
{ key: 'deploy', label: '部署经验', icon: '🚀' },
|
||||
],
|
||||
items: [] as KnowledgeRecord[],
|
||||
candidates: [] as KnowledgeRecord[],
|
||||
config: null as KnowledgeConfig | null,
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
})
|
||||
|
||||
export function useKnowledgeStore() {
|
||||
const items = computed(() => state.items)
|
||||
// 7 种知识类型(snake_case,与后端 KnowledgeKind 对齐)
|
||||
// 仅存 key + icon;展示文案(label)走 i18n: $t('knowledge.kind.<key>')
|
||||
export const KNOWLEDGE_KINDS = [
|
||||
{ key: 'review_rule', icon: '🔍' },
|
||||
{ key: 'prompt_template', icon: '💬' },
|
||||
{ key: 'pitfall', icon: '⚠️' },
|
||||
{ key: 'architecture_pattern', icon: '🏛️' },
|
||||
{ key: 'diagnosis', icon: '🩺' },
|
||||
{ key: 'deployment_note', icon: '🚀' },
|
||||
{ key: 'workflow_optimization', icon: '⚡' },
|
||||
] as const
|
||||
|
||||
const getByCategory = (category: string) =>
|
||||
computed(() =>
|
||||
category === 'all'
|
||||
? state.items
|
||||
: state.items.filter(i => i.category === category)
|
||||
)
|
||||
|
||||
const search = (query: string) =>
|
||||
computed(() => {
|
||||
if (!query.trim()) return state.items
|
||||
const q = query.toLowerCase()
|
||||
return state.items.filter(i =>
|
||||
i.title.toLowerCase().includes(q) ||
|
||||
i.tags.some(t => t.toLowerCase().includes(q)) ||
|
||||
i.description.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
const getCategoryCount = (key: string) =>
|
||||
key === 'all' ? state.items.length : state.items.filter(i => i.category === key).length
|
||||
|
||||
return {
|
||||
items,
|
||||
categories: computed(() => state.categories),
|
||||
getByCategory,
|
||||
search,
|
||||
getCategoryCount,
|
||||
/** 解析 tags JSON 字符串为 string[] */
|
||||
export function parseTags(tags: string | null): string[] {
|
||||
if (!tags) return []
|
||||
try {
|
||||
const arr = JSON.parse(tags)
|
||||
return Array.isArray(arr) ? arr : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function useKnowledgeStore() {
|
||||
// ── 加载列表(默认排除 archived) ──
|
||||
async function loadList(status?: string) {
|
||||
state.loading = true
|
||||
state.error = null
|
||||
try {
|
||||
state.items = await knowledgeApi.list(status ?? null)
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '加载知识库失败'
|
||||
} finally {
|
||||
state.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 加载审核收件箱(候选列表) ──
|
||||
async function loadCandidates() {
|
||||
try {
|
||||
state.candidates = await knowledgeApi.listCandidates()
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '加载收件箱失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function search(query: string, kind?: string) {
|
||||
state.loading = true
|
||||
state.error = null
|
||||
try {
|
||||
state.items = await knowledgeApi.search({ query, kind })
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '检索失败'
|
||||
} finally {
|
||||
state.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function create(input: CreateKnowledgeInput) {
|
||||
const record = await knowledgeApi.create(input)
|
||||
// 手动录入默认 candidate,刷新收件箱
|
||||
await loadCandidates()
|
||||
return record
|
||||
}
|
||||
|
||||
async function updateStatus(id: string, status: string) {
|
||||
await knowledgeApi.updateStatus(id, status)
|
||||
// 从 items 和 candidates 中同步移除
|
||||
state.items = state.items.filter(k => k.id !== id)
|
||||
state.candidates = state.candidates.filter(k => k.id !== id)
|
||||
}
|
||||
|
||||
async function archive(id: string) {
|
||||
await knowledgeApi.archive(id)
|
||||
state.items = state.items.filter(k => k.id !== id)
|
||||
state.candidates = state.candidates.filter(k => k.id !== id)
|
||||
}
|
||||
|
||||
// ── 配置 ──
|
||||
async function loadConfig() {
|
||||
try {
|
||||
state.config = await knowledgeApi.getConfig()
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '加载配置失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig(config: KnowledgeConfig) {
|
||||
await knowledgeApi.saveConfig(config)
|
||||
state.config = config
|
||||
}
|
||||
|
||||
async function extractNow() {
|
||||
await knowledgeApi.extractNow()
|
||||
await loadCandidates()
|
||||
}
|
||||
|
||||
// ── 生命线:详情 / 编辑 / 事件查询(按需调用,不入全局 state) ──
|
||||
async function getDetail(id: string): Promise<KnowledgeDetailPayload> {
|
||||
return knowledgeApi.getDetail(id)
|
||||
}
|
||||
|
||||
async function update(id: string, input: UpdateKnowledgeInput): Promise<KnowledgeRecord> {
|
||||
const updated = await knowledgeApi.update(id, input)
|
||||
// 同步刷新内存中的列表/收件箱条目
|
||||
const patch = (arr: KnowledgeRecord[]) => {
|
||||
const idx = arr.findIndex(k => k.id === id)
|
||||
if (idx >= 0) arr[idx] = { ...arr[idx], ...updated }
|
||||
}
|
||||
patch(state.items)
|
||||
patch(state.candidates)
|
||||
return updated
|
||||
}
|
||||
|
||||
async function getEvents(knowledgeId: string, eventType?: string, limit?: number): Promise<KnowledgeEventRecord[]> {
|
||||
return knowledgeApi.events(knowledgeId, eventType, limit)
|
||||
}
|
||||
|
||||
// 候选数量(供侧栏 badge 用)
|
||||
const candidateCount = computed(() => state.candidates.length)
|
||||
|
||||
return reactive({
|
||||
// 状态用 getter 实时读 state(防快照不跟随,同 project store 模式)
|
||||
get items() { return state.items },
|
||||
get candidates() { return state.candidates },
|
||||
get config() { return state.config },
|
||||
get loading() { return state.loading },
|
||||
get error() { return state.error },
|
||||
candidateCount,
|
||||
// actions
|
||||
loadList, loadCandidates, search, create, updateStatus, archive,
|
||||
loadConfig, saveConfig, extractNow,
|
||||
getDetail, update, getEvents,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { ProjectRecord, TaskRecord, IdeaRecord, WorkflowRecord, WorkflowEve
|
||||
|
||||
const state = reactive({
|
||||
projects: [] as ProjectRecord[],
|
||||
deletedProjects: [] as ProjectRecord[],
|
||||
tasks: [] as TaskRecord[],
|
||||
ideas: [] as IdeaRecord[],
|
||||
workflowExecutions: [] as WorkflowRecord[],
|
||||
@@ -24,7 +25,7 @@ const state = reactive({
|
||||
|
||||
let _eventUnlisten: (() => void) | null = null
|
||||
|
||||
export function useProjectStore() {
|
||||
function createStore() {
|
||||
// ── 项目 CRUD ──
|
||||
async function loadProjects() {
|
||||
state.loading = true
|
||||
@@ -38,10 +39,18 @@ export function useProjectStore() {
|
||||
}
|
||||
}
|
||||
|
||||
async function createProject(name: string, description = '', ideaId?: string) {
|
||||
const record = await projectApi.create({ name, description, idea_id: ideaId })
|
||||
state.projects.push(record)
|
||||
return record
|
||||
// 清除错误状态(供 toast 显示后重置,允许连续同值错误再次触发 watch)
|
||||
function clearError() { state.error = null }
|
||||
|
||||
async function createProject(name: string, description = '', ideaId?: string, path?: string, stack?: string) {
|
||||
try {
|
||||
const record = await projectApi.create({ name, description, idea_id: ideaId, path, stack })
|
||||
state.projects.push(record)
|
||||
return record
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '创建项目失败'
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProject(id: string, field: string, value: string) {
|
||||
@@ -52,9 +61,48 @@ export function useProjectStore() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 重定位项目目录(后端重探测 stack,返回最新记录并更新本地状态) */
|
||||
async function relocateProjectPath(id: string, newPath: string) {
|
||||
const record = await projectApi.relocatePath(id, newPath)
|
||||
const idx = state.projects.findIndex(p => p.id === id)
|
||||
if (idx >= 0) state.projects[idx] = record
|
||||
return record
|
||||
}
|
||||
|
||||
async function deleteProject(id: string) {
|
||||
await projectApi.delete(id)
|
||||
state.projects = state.projects.filter(p => p.id !== id)
|
||||
try {
|
||||
await projectApi.delete(id) // 软删 → 回收站(可恢复)
|
||||
state.projects = state.projects.filter(p => p.id !== id)
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '删除项目失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDeletedProjects() {
|
||||
try {
|
||||
state.deletedProjects = await projectApi.listDeleted()
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '加载回收站失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreProject(id: string) {
|
||||
try {
|
||||
await projectApi.restore(id)
|
||||
state.deletedProjects = state.deletedProjects.filter(p => p.id !== id)
|
||||
await loadProjects() // 恢复后刷新活跃列表
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '恢复项目失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function purgeProject(id: string) {
|
||||
try {
|
||||
await projectApi.purge(id) // 彻底删(级联物理删,不可恢复)
|
||||
state.deletedProjects = state.deletedProjects.filter(p => p.id !== id)
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '彻底删除失败'
|
||||
}
|
||||
}
|
||||
|
||||
// ── 任务 CRUD ──
|
||||
@@ -67,9 +115,14 @@ export function useProjectStore() {
|
||||
}
|
||||
|
||||
async function createTask(input: { project_id: string; title: string; description?: string; priority?: number; branch_name?: string; assignee?: string }) {
|
||||
const record = await taskApi.create(input)
|
||||
state.tasks.push(record)
|
||||
return record
|
||||
try {
|
||||
const record = await taskApi.create(input)
|
||||
state.tasks.push(record)
|
||||
return record
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '创建任务失败'
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTask(id: string, field: string, value: string) {
|
||||
@@ -81,8 +134,12 @@ export function useProjectStore() {
|
||||
}
|
||||
|
||||
async function deleteTask(id: string) {
|
||||
await taskApi.delete(id)
|
||||
state.tasks = state.tasks.filter(t => t.id !== id)
|
||||
try {
|
||||
await taskApi.delete(id)
|
||||
state.tasks = state.tasks.filter(t => t.id !== id)
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '删除任务失败'
|
||||
}
|
||||
}
|
||||
|
||||
// ── 想法 CRUD ──
|
||||
@@ -95,9 +152,14 @@ export function useProjectStore() {
|
||||
}
|
||||
|
||||
async function createIdea(input: { title: string; description?: string; priority?: number; tags?: string; source?: string }) {
|
||||
const record = await ideaApi.create(input)
|
||||
state.ideas.push(record)
|
||||
return record
|
||||
try {
|
||||
const record = await ideaApi.create(input)
|
||||
state.ideas.push(record)
|
||||
return record
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '创建想法失败'
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function updateIdea(id: string, field: string, value: string) {
|
||||
@@ -109,8 +171,25 @@ export function useProjectStore() {
|
||||
}
|
||||
|
||||
async function deleteIdea(id: string) {
|
||||
await ideaApi.delete(id)
|
||||
state.ideas = state.ideas.filter(i => i.id !== id)
|
||||
try {
|
||||
await ideaApi.delete(id)
|
||||
state.ideas = state.ideas.filter(i => i.id !== id)
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '删除想法失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function evaluateIdea(id: string) {
|
||||
const record = await ideaApi.evaluate(id)
|
||||
const idx = state.ideas.findIndex(i => i.id === id)
|
||||
if (idx >= 0) state.ideas[idx] = record
|
||||
return record
|
||||
}
|
||||
|
||||
async function promoteIdea(id: string) {
|
||||
const res = await ideaApi.promote(id)
|
||||
await loadIdeas() // 后端已回写 status=promoted/promoted_to,刷新列表
|
||||
return res
|
||||
}
|
||||
|
||||
// ── 工作流 ──
|
||||
@@ -129,11 +208,14 @@ export function useProjectStore() {
|
||||
async function startEventListener() {
|
||||
if (_eventUnlisten) return _eventUnlisten
|
||||
_eventUnlisten = await workflowApi.onEvent((payload) => {
|
||||
state.liveEvents.push(payload)
|
||||
|
||||
// 处理人工审批请求
|
||||
if (payload.event.type === 'HumanApprovalRequest') {
|
||||
state.pendingApproval = payload.event.data as typeof state.pendingApproval
|
||||
try {
|
||||
state.liveEvents.push(payload)
|
||||
// 处理人工审批请求
|
||||
if (payload.event?.type === 'HumanApprovalRequest') {
|
||||
state.pendingApproval = payload.event.data as typeof state.pendingApproval
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('处理工作流事件失败:', e, payload)
|
||||
}
|
||||
})
|
||||
return _eventUnlisten
|
||||
@@ -182,20 +264,24 @@ export function useProjectStore() {
|
||||
}))
|
||||
|
||||
return reactive({
|
||||
// reactive state — 直接引用 state 属性,已经是响应式的
|
||||
projects: state.projects,
|
||||
tasks: state.tasks,
|
||||
ideas: state.ideas,
|
||||
workflowExecutions: state.workflowExecutions,
|
||||
liveEvents: state.liveEvents,
|
||||
loading: state.loading,
|
||||
error: state.error,
|
||||
// 状态用 getter 实时读 state —— 直接 ideas: state.ideas 会快照引用,
|
||||
// loadIdeas 等重新赋值 state.ideas 时返回对象的 ideas 属性不跟随,刷新后视图为空
|
||||
get projects() { return state.projects },
|
||||
get tasks() { return state.tasks },
|
||||
get ideas() { return state.ideas },
|
||||
get workflowExecutions() { return state.workflowExecutions },
|
||||
get liveEvents() { return state.liveEvents },
|
||||
get loading() { return state.loading },
|
||||
get error() { return state.error },
|
||||
clearError,
|
||||
get deletedProjects() { return state.deletedProjects },
|
||||
// project actions
|
||||
loadProjects, createProject, updateProject, deleteProject,
|
||||
loadProjects, createProject, updateProject, deleteProject, relocateProjectPath,
|
||||
loadDeletedProjects, restoreProject, purgeProject,
|
||||
// task actions
|
||||
loadTasks, createTask, updateTask, deleteTask,
|
||||
// idea actions
|
||||
loadIdeas, createIdea, updateIdea, deleteIdea,
|
||||
loadIdeas, createIdea, updateIdea, deleteIdea, evaluateIdea, promoteIdea,
|
||||
// workflow actions
|
||||
runWorkflow, loadWorkflowExecutions, startEventListener, stopEventListener, clearLiveEvents,
|
||||
approveHumanApproval, pendingApproval: computed(() => state.pendingApproval),
|
||||
@@ -203,3 +289,13 @@ export function useProjectStore() {
|
||||
stats,
|
||||
})
|
||||
}
|
||||
|
||||
type ProjectStore = ReturnType<typeof createStore>
|
||||
let _storeInstance: ProjectStore | null = null
|
||||
|
||||
/** 项目/任务/想法/工作流 全局状态(单例,多组件复用同一 reactive 包装) */
|
||||
export function useProjectStore(): ProjectStore {
|
||||
if (_storeInstance) return _storeInstance
|
||||
_storeInstance = createStore()
|
||||
return _storeInstance
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user