feat(frontend): 搭建桌面端基础界面与 Workspace

- App Shell 壳层:主导航、次导航、Title Bar、状态栏
- Vault 入口页:最近 Vault、打开/创建 Vault、AI Core 状态
- Workspace 与文件树:浏览、打开、创建笔记/文件夹
- Design Token:浅色/深色主题 CSS Variables
- Contracts / Service / Store / Router 基础架构
- 统一 ApiClient 与 SseClient,对接后端 API 契约
This commit is contained in:
2026-08-29 10:36:16 +08:00
parent 694c1b27c1
commit f9efc4f4a2
42 changed files with 8044 additions and 167 deletions
+145
View File
@@ -0,0 +1,145 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { AgentRun, AgentEvent, ToolDefinition, PermissionRequest, ToolCall } from '@/contracts'
import { mockAgentRuns, mockAgentEvents, mockTools, mockPermissionRequest } from '@/services/agentService'
import * as agentService from '@/services/agentService'
export const useAgentStore = defineStore('agent', () => {
const runs = ref<AgentRun[]>(mockAgentRuns)
const activeRunId = ref<string | null>('run-1')
const events = ref<AgentEvent[]>(mockAgentEvents.filter((e) => e.run_id === 'run-1'))
const tools = ref<ToolDefinition[]>(mockTools)
const isCreating = ref(false)
const isRunning = ref(false)
const permissionRequest = ref<PermissionRequest | null>(null)
const toolCalls = ref<ToolCall[]>([])
const activeRun = computed(() =>
runs.value.find((r) => r.run_id === activeRunId.value) || null
)
const sortedRuns = computed(() =>
[...runs.value].sort((a, b) => (b.started_at || '').localeCompare(a.started_at || ''))
)
const currentStep = computed(() => {
const tc = events.value.filter((e) => e.event === 'ToolCall').length
return tc
})
async function loadTools() {
tools.value = await agentService.listTools()
}
async function loadRuns() {
const resp = await agentService.listAgentRuns()
runs.value = resp.items
}
async function loadRun(runId: string) {
activeRunId.value = runId
events.value = mockAgentEvents.filter((e) => e.run_id === runId)
toolCalls.value = []
for (const evt of events.value) {
if (evt.event === 'ToolCall') {
const data = evt.data as any
toolCalls.value.push({
tool_call_id: data.tool_call_id,
name: data.name,
parameters: data.parameters,
status: data.status || 'completed',
started_at: evt.timestamp,
})
} else if (evt.event === 'ToolResult') {
const data = evt.data as any
const tc = toolCalls.value.find((t) => t.tool_call_id === data.tool_call_id)
if (tc) {
tc.status = data.status
tc.result = data.result
tc.completed_at = evt.timestamp
}
}
}
}
async function createRun(request: agentService.CreateAgentRunRequest) {
isCreating.value = true
try {
const run = await agentService.createAgentRun(request)
runs.value.unshift(run)
activeRunId.value = run.run_id
events.value = [{
event: 'RunStarted',
sequence: 1,
run_id: run.run_id,
data: { task: request.task },
timestamp: new Date().toISOString(),
}]
isRunning.value = true
// Mock events streaming
simulateRun(run.run_id)
return run
} finally {
isCreating.value = false
}
}
function simulateRun(runId: string) {
const runEvents: AgentEvent[] = [
{ event: 'ThinkingDelta', sequence: 2, run_id: runId, data: { text: '我需要先搜索相关笔记...' }, timestamp: new Date().toISOString() },
{ event: 'ToolCall', sequence: 3, run_id: runId, data: { tool_call_id: 'tc-mock-1', name: 'notes.search', parameters: { query: '红黑树', limit: 5 }, status: 'running' }, timestamp: new Date().toISOString() },
{ event: 'ToolResult', sequence: 4, run_id: runId, data: { tool_call_id: 'tc-mock-1', name: 'notes.search', status: 'completed', result: '找到 5 条相关结果' }, timestamp: new Date().toISOString() },
{ event: 'TextDelta', sequence: 5, run_id: runId, data: { text: '根据你的笔记,以下是...' }, timestamp: new Date().toISOString() },
{ event: 'RunCompleted', sequence: 6, run_id: runId, data: { message: 'Task completed successfully' }, timestamp: new Date().toISOString() },
]
let idx = 0
const push = () => {
if (idx >= runEvents.length) {
isRunning.value = false
return
}
events.value.push(runEvents[idx])
idx++
setTimeout(push, 800)
}
setTimeout(push, 500)
}
async function cancelRun(runId: string) {
await agentService.cancelAgentRun(runId)
const run = runs.value.find((r) => r.run_id === runId)
if (run) run.status = 'cancelled'
isRunning.value = false
}
async function respondPermission(decision: 'allow' | 'deny', scope: 'once' | 'session' | 'always' = 'once') {
if (!activeRunId.value || !permissionRequest.value) return
await agentService.respondToPermission(activeRunId.value, permissionRequest.value.request_id, decision, scope)
permissionRequest.value = null
}
function showPermissionDemo() {
permissionRequest.value = mockPermissionRequest
}
return {
runs,
activeRunId,
activeRun,
sortedRuns,
events,
tools,
isCreating,
isRunning,
permissionRequest,
toolCalls,
currentStep,
loadTools,
loadRuns,
loadRun,
createRun,
cancelRun,
respondPermission,
showPermissionDemo,
}
})
+147
View File
@@ -0,0 +1,147 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { ChatMessage, Conversation, Citation } from '@/contracts'
import { mockConversations, mockMessages } from '@/services/chatService'
import type { SseClient } from '@/services/sseClient'
export const useChatStore = defineStore('chat', () => {
const conversations = ref<Conversation[]>(mockConversations)
const activeConversationId = ref<string | null>('conv-1')
const messages = ref<ChatMessage[]>(mockMessages['conv-1'] || [])
const isStreaming = ref(false)
const inputText = ref('')
const useRag = ref(true)
const selectedSkillId = ref<string | null>(null)
const selectedProviderId = ref('mock-provider')
const selectedModel = ref('mock-1')
let sseClient: SseClient | null = null
const activeConversation = computed(() =>
conversations.value.find((c) => c.conversation_id === activeConversationId.value) || null
)
const sortedConversations = computed(() =>
[...conversations.value].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
)
async function setActiveConversation(id: string) {
activeConversationId.value = id
messages.value = mockMessages[id] || []
}
async function sendMessage(text: string) {
if (!text.trim() || isStreaming.value) return
const conversationId = activeConversationId.value || `conv-${Date.now()}`
if (!activeConversationId.value) {
const newConv: Conversation = {
conversation_id,
title: text.slice(0, 30),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
message_count: 0,
}
conversations.value.unshift(newConv)
activeConversationId.value = conversationId
}
const userMsg: ChatMessage = {
message_id: `msg-${Date.now()}`,
conversation_id: conversationId,
role: 'user',
content: text,
created_at: new Date().toISOString(),
}
messages.value.push(userMsg)
inputText.value = ''
isStreaming.value = true
const aiMsg: ChatMessage = {
message_id: `msg-${Date.now() + 1}`,
conversation_id: conversationId,
role: 'assistant',
content: '',
created_at: new Date().toISOString(),
citations: [],
tool_calls: [],
}
messages.value.push(aiMsg)
// Mock streaming
const fullText =
'这是一个模拟的 AI 回复。在实际环境中,这里会通过 SSE 接收后端 AI Core 的流式输出,基于 RAG 引擎和你的知识库生成回答,并附带来源引用。\n\n**要点总结:**\n1. 这是演示用的流式输出\n2. 实际会调用 ModelEvent SSE\n3. 支持 Citation、Tool Call 等事件\n\n你可以在设置中配置真实的模型 Provider 来启用完整功能。'
const citations: Citation[] = [
{
note_id: 'n-rbt',
block_id: 'b1',
file_path: '/数据结构/红黑树.md',
heading_path: '数据结构 / 红黑树 / 概述',
content: '红黑树是一种自平衡二叉搜索树...',
},
]
let i = 0
const interval = setInterval(() => {
if (i >= fullText.length) {
clearInterval(interval)
isStreaming.value = false
aiMsg.citations = citations
return
}
const chunk = fullText.slice(i, i + 3)
aiMsg.content += chunk
i += 3
}, 20)
}
function stopGeneration() {
if (sseClient) {
sseClient.cancel()
sseClient = null
}
isStreaming.value = false
}
function createNewConversation() {
const newConv: Conversation = {
conversation_id: `conv-${Date.now()}`,
title: '新对话',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
message_count: 0,
}
conversations.value.unshift(newConv)
activeConversationId.value = newConv.conversation_id
messages.value = []
}
function deleteConversation(id: string) {
const idx = conversations.value.findIndex((c) => c.conversation_id === id)
if (idx > -1) {
conversations.value.splice(idx, 1)
if (activeConversationId.value === id) {
activeConversationId.value = conversations.value[0]?.conversation_id || null
messages.value = conversations.value[0] ? mockMessages[conversations.value[0].conversation_id] || [] : []
}
}
}
return {
conversations,
activeConversationId,
activeConversation,
sortedConversations,
messages,
isStreaming,
inputText,
useRag,
selectedSkillId,
selectedProviderId,
selectedModel,
setActiveConversation,
sendMessage,
stopGeneration,
createNewConversation,
deleteConversation,
}
})
+122
View File
@@ -0,0 +1,122 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { SaveStatus } from '@/contracts'
import * as workspaceService from '@/services/workspaceService'
export const useEditorStore = defineStore('editor', () => {
const mode = ref<'wysiwyg' | 'source'>('wysiwyg')
const content = ref('')
const saveStatus = ref<SaveStatus>('idle')
const lastSavedAt = ref<string | null>(null)
const currentNoteId = ref<string | null>(null)
const currentFilePath = ref<string | null>(null)
const highlightBlockId = ref<string | null>(null)
const cursorPosition = ref({ line: 0, column: 0 })
const wordCount = computed(() => {
const text = content.value.replace(/[#*`>\-_\[\]()!]/g, '')
return text.trim().length
})
const lineCount = computed(() => content.value.split('\n').length)
function setMode(newMode: 'wysiwyg' | 'source') {
mode.value = newMode
}
function toggleMode() {
mode.value = mode.value === 'wysiwyg' ? 'source' : 'wysiwyg'
}
function updateContent(newContent: string) {
content.value = newContent
if (saveStatus.value === 'saved' || saveStatus.value === 'idle') {
saveStatus.value = 'dirty'
}
}
let saveTimer: ReturnType<typeof setTimeout> | null = null
function scheduleAutoSave(delay = 1500) {
if (saveTimer) clearTimeout(saveTimer)
saveTimer = setTimeout(() => {
void save()
}, delay)
}
async function save() {
if (!currentFilePath.value) return
if (saveStatus.value === 'saving') return
saveStatus.value = 'saving'
try {
await workspaceService.saveFileContent(currentFilePath.value, content.value)
saveStatus.value = 'saved'
lastSavedAt.value = new Date().toISOString()
} catch {
saveStatus.value = 'save_failed'
}
}
async function loadFile(filePath: string) {
currentFilePath.value = filePath
saveStatus.value = 'saving'
try {
content.value = await workspaceService.readFileContent(filePath)
saveStatus.value = 'saved'
lastSavedAt.value = new Date().toISOString()
} catch {
content.value = ''
saveStatus.value = 'idle'
}
highlightBlockId.value = null
}
function highlightBlock(blockId: string) {
highlightBlockId.value = blockId
setTimeout(() => {
if (highlightBlockId.value === blockId) {
highlightBlockId.value = null
}
}, 3000)
}
function setExternalChanged() {
if (saveStatus.value === 'dirty') {
saveStatus.value = 'conflict'
} else {
saveStatus.value = 'external_changed'
}
}
function closeFile() {
if (saveTimer) clearTimeout(saveTimer)
currentFilePath.value = null
currentNoteId.value = null
content.value = ''
saveStatus.value = 'idle'
lastSavedAt.value = null
highlightBlockId.value = null
}
return {
mode,
content,
saveStatus,
lastSavedAt,
currentNoteId,
currentFilePath,
highlightBlockId,
cursorPosition,
wordCount,
lineCount,
setMode,
toggleMode,
updateContent,
scheduleAutoSave,
save,
loadFile,
highlightBlock,
setExternalChanged,
closeFile,
}
})
+11
View File
@@ -0,0 +1,11 @@
export { useWorkspaceStore } from './workspace'
export { useEditorStore } from './editor'
export { useSearchStore } from './search'
export { useChatStore } from './chat'
export { useAgentStore } from './agent'
export { useSkillStore } from './skill'
export { usePluginStore } from './plugin'
export { useTaskStore } from './task'
export { useThemeStore } from './theme'
export { useProviderStore } from './provider'
export { useSettingsStore } from './settings'
+69
View File
@@ -0,0 +1,69 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Plugin } from '@/contracts'
import { mockPlugins } from '@/services/pluginService'
export const usePluginStore = defineStore('plugin', () => {
const plugins = ref<Plugin[]>(mockPlugins)
const selectedPluginId = ref<string | null>(null)
const isLoading = ref(false)
const selectedPlugin = computed(() =>
plugins.value.find((p) => p.plugin_id === selectedPluginId.value) || null
)
const enabledPlugins = computed(() => plugins.value.filter((p) => p.enabled))
const readyPlugins = computed(() => plugins.value.filter((p) => p.status === 'ready'))
const errorPlugins = computed(() => plugins.value.filter((p) => p.status === 'error'))
async function loadPlugins() {
isLoading.value = true
try {
const { listPlugins } = await import('@/services/pluginService')
plugins.value = await listPlugins()
} finally {
isLoading.value = false
}
}
function selectPlugin(pluginId: string | null) {
selectedPluginId.value = pluginId
}
async function enablePlugin(pluginId: string) {
const plugin = plugins.value.find((p) => p.plugin_id === pluginId)
if (plugin) {
plugin.enabled = true
plugin.status = 'ready'
}
}
async function disablePlugin(pluginId: string) {
const plugin = plugins.value.find((p) => p.plugin_id === pluginId)
if (plugin) {
plugin.enabled = false
plugin.status = 'disabled'
}
}
async function uninstallPlugin(pluginId: string) {
const idx = plugins.value.findIndex((p) => p.plugin_id === pluginId)
if (idx > -1) plugins.value.splice(idx, 1)
if (selectedPluginId.value === pluginId) selectedPluginId.value = null
}
return {
plugins,
selectedPluginId,
selectedPlugin,
enabledPlugins,
readyPlugins,
errorPlugins,
isLoading,
loadPlugins,
selectPlugin,
enablePlugin,
disablePlugin,
uninstallPlugin,
}
})
+80
View File
@@ -0,0 +1,80 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { ProviderConfig, ModelInfo } from '@/contracts'
import { mockProviders, mockModels } from '@/services/providerService'
export const useProviderStore = defineStore('provider', () => {
const providers = ref<ProviderConfig[]>(mockProviders)
const modelsByProvider = ref<Record<string, ModelInfo[]>>(mockModels)
const defaultProviderId = ref('mock-provider')
const isLoading = ref(false)
const enabledProviders = computed(() => providers.value.filter((p) => p.enabled))
const defaultProvider = computed(() =>
providers.value.find((p) => p.provider_id === defaultProviderId.value) || null
)
async function loadProviders() {
isLoading.value = true
try {
const { listProviders } = await import('@/services/providerService')
providers.value = await listProviders()
} finally {
isLoading.value = false
}
}
async function loadModels(providerId: string) {
const { listModels } = await import('@/services/providerService')
modelsByProvider.value[providerId] = await listModels(providerId)
}
async function addProvider(data: Omit<ProviderConfig, 'provider_id'> & { api_key?: string }) {
const newProvider: ProviderConfig = {
...data,
provider_id: `prov-${Date.now()}`,
}
providers.value.push(newProvider)
return newProvider
}
async function updateProvider(providerId: string, data: Partial<ProviderConfig>) {
const p = providers.value.find((p) => p.provider_id === providerId)
if (p) Object.assign(p, data)
}
async function deleteProvider(providerId: string) {
const idx = providers.value.findIndex((p) => p.provider_id === providerId)
if (idx > -1) providers.value.splice(idx, 1)
delete modelsByProvider.value[providerId]
}
async function testProvider(providerId: string): Promise<{ success: boolean; latency_ms?: number; error?: string }> {
await new Promise((r) => setTimeout(r, 1000))
const p = providers.value.find((p) => p.provider_id === providerId)
if (p?.enabled && p.has_credential) {
return { success: true, latency_ms: 230 + Math.floor(Math.random() * 200) }
}
return { success: false, error: '认证失败,请检查 API Key' }
}
function setDefaultProvider(providerId: string) {
defaultProviderId.value = providerId
}
return {
providers,
modelsByProvider,
defaultProviderId,
enabledProviders,
defaultProvider,
isLoading,
loadProviders,
loadModels,
addProvider,
updateProvider,
deleteProvider,
testProvider,
setDefaultProvider,
}
})
+78
View File
@@ -0,0 +1,78 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { SearchResult, SearchRequest } from '@/contracts'
import * as searchService from '@/services/searchService'
export const useSearchStore = defineStore('search', () => {
const query = ref('')
const mode = ref<'fts' | 'vector' | 'hybrid'>('hybrid')
const results = ref<SearchResult[]>([])
const total = ref(0)
const isSearching = ref(false)
const selectedIndex = ref(0)
const recentQueries = ref<string[]>(['红黑树', '死锁', 'TCP三次握手'])
const error = ref<string | null>(null)
const vectorUnavailable = ref(false)
async function doSearch(request: SearchRequest) {
query.value = request.query
mode.value = request.mode || 'hybrid'
isSearching.value = true
error.value = null
try {
const resp = await searchService.searchMock(request.query, request.mode || 'hybrid')
results.value = resp.results
total.value = resp.total
selectedIndex.value = 0
} catch (e: any) {
error.value = e.message || '搜索失败'
results.value = []
total.value = 0
} finally {
isSearching.value = false
}
if (request.query && !recentQueries.value.includes(request.query)) {
recentQueries.value.unshift(request.query)
if (recentQueries.value.length > 10) recentQueries.value.pop()
}
}
function clearResults() {
results.value = []
query.value = ''
total.value = 0
selectedIndex.value = 0
error.value = null
}
function selectNext() {
if (selectedIndex.value < results.value.length - 1) selectedIndex.value++
}
function selectPrev() {
if (selectedIndex.value > 0) selectedIndex.value--
}
function setMode(m: 'fts' | 'vector' | 'hybrid') {
mode.value = m
}
return {
query,
mode,
results,
total,
isSearching,
selectedIndex,
recentQueries,
error,
vectorUnavailable,
doSearch,
clearResults,
selectNext,
selectPrev,
setMode,
}
})
+93
View File
@@ -0,0 +1,93 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { AiCoreStatus, IndexStatus } from '@/contracts'
import { mockIndexStatus } from '@/services/indexService'
export const useSettingsStore = defineStore('settings', () => {
// General
const restoreLastVault = ref(true)
const autoSaveInterval = ref(1500)
const language = ref<'zh-CN' | 'en'>('zh-CN')
const appVersion = ref('0.1.0')
const aiCoreVersion = ref('0.1.0')
// Editor
const defaultEditorMode = ref<'wysiwyg' | 'source'>('wysiwyg')
const editorFontSize = ref(15)
const editorLineHeight = ref(1.7)
const editorLineWidth = ref(80)
const spellCheck = ref(false)
// AI Core
const aiCoreStatus = ref<AiCoreStatus>('running')
const aiCoreAddress = ref('http://127.0.0.1:8000')
// Index
const indexStatus = ref<IndexStatus>(mockIndexStatus)
// Permissions
const permissionPolicy = ref<Record<string, 'allow' | 'confirm' | 'deny'>>({
'notes.read': 'allow',
'notes.search': 'allow',
'notes.write': 'confirm',
'notes.delete': 'confirm',
'tasks.read': 'allow',
'tasks.write': 'confirm',
'attachments.read': 'confirm',
'network.request': 'confirm',
'secrets.use': 'confirm',
})
function setAutoSaveInterval(ms: number) {
autoSaveInterval.value = ms
}
function setDefaultEditorMode(mode: 'wysiwyg' | 'source') {
defaultEditorMode.value = mode
}
function setPermission(permission: string, policy: 'allow' | 'confirm' | 'deny') {
permissionPolicy.value[permission] = policy
}
function setAiCoreStatus(status: AiCoreStatus) {
aiCoreStatus.value = status
}
async function restartAiCore(): Promise<boolean> {
aiCoreStatus.value = 'starting'
await new Promise((r) => setTimeout(r, 1500))
aiCoreStatus.value = 'running'
return true
}
async function rebuildIndex(scope: 'full' | 'fts' | 'vector' = 'full') {
indexStatus.value.status = 'indexing'
setTimeout(() => {
indexStatus.value.status = 'idle'
}, 3000)
}
return {
restoreLastVault,
autoSaveInterval,
language,
appVersion,
aiCoreVersion,
defaultEditorMode,
editorFontSize,
editorLineHeight,
editorLineWidth,
spellCheck,
aiCoreStatus,
aiCoreAddress,
indexStatus,
permissionPolicy,
setAutoSaveInterval,
setDefaultEditorMode,
setPermission,
setAiCoreStatus,
restartAiCore,
rebuildIndex,
}
})
+69
View File
@@ -0,0 +1,69 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { Skill } from '@/contracts'
import { mockSkills } from '@/services/skillService'
export const useSkillStore = defineStore('skill', () => {
const skills = ref<Skill[]>(mockSkills)
const selectedSkillId = ref<string | null>(null)
const isLoading = ref(false)
const selectedSkill = computed(() =>
skills.value.find((s) => s.skill_id === selectedSkillId.value) || null
)
const enabledSkills = computed(() => skills.value.filter((s) => s.enabled))
const installedSkills = computed(() => skills.value.filter((s) => s.status !== 'error'))
const readySkills = computed(() => skills.value.filter((s) => s.status === 'ready'))
async function loadSkills() {
isLoading.value = true
try {
const { listSkills } = await import('@/services/skillService')
skills.value = await listSkills()
} finally {
isLoading.value = false
}
}
function selectSkill(skillId: string | null) {
selectedSkillId.value = skillId
}
async function enableSkill(skillId: string) {
const skill = skills.value.find((s) => s.skill_id === skillId)
if (skill) {
skill.enabled = true
skill.status = 'ready'
}
}
async function disableSkill(skillId: string) {
const skill = skills.value.find((s) => s.skill_id === skillId)
if (skill) {
skill.enabled = false
skill.status = 'disabled'
}
}
async function uninstallSkill(skillId: string) {
const idx = skills.value.findIndex((s) => s.skill_id === skillId)
if (idx > -1) skills.value.splice(idx, 1)
if (selectedSkillId.value === skillId) selectedSkillId.value = null
}
return {
skills,
selectedSkillId,
selectedSkill,
enabledSkills,
installedSkills,
readySkills,
isLoading,
loadSkills,
selectSkill,
enableSkill,
disableSkill,
uninstallSkill,
}
})
+89
View File
@@ -0,0 +1,89 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { TaskItem, TaskStatus, TaskPriority, TaskSource } from '@/contracts'
import { mockTasks } from '@/services/taskService'
export const useTaskStore = defineStore('task', () => {
const tasks = ref<TaskItem[]>(mockTasks)
const filterStatus = ref<TaskStatus | 'all'>('all')
const filterPriority = ref<TaskPriority | 'all'>('all')
const filterSource = ref<TaskSource | 'all'>('all')
const isLoading = ref(false)
const filteredTasks = computed(() => {
return tasks.value.filter((t) => {
if (filterStatus.value !== 'all' && t.status !== filterStatus.value) return false
if (filterPriority.value !== 'all' && t.priority !== filterPriority.value) return false
if (filterSource.value !== 'all' && t.source !== filterSource.value) return false
return true
})
})
const todoTasks = computed(() => tasks.value.filter((t) => t.status === 'todo'))
const inProgressTasks = computed(() => tasks.value.filter((t) => t.status === 'in_progress'))
const doneTasks = computed(() => tasks.value.filter((t) => t.status === 'done'))
async function loadTasks() {
isLoading.value = true
try {
const { listTasks } = await import('@/services/taskService')
const resp = await listTasks()
tasks.value = resp.items
} finally {
isLoading.value = false
}
}
async function createTask(data: { title: string; description?: string; priority?: TaskPriority; due_date?: string; note_id?: string }) {
const newTask: TaskItem = {
task_id: `t-${Date.now()}`,
title: data.title,
description: data.description,
status: 'todo',
priority: data.priority || 'medium',
due_date: data.due_date,
note_id: data.note_id,
source: 'user',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}
tasks.value.unshift(newTask)
return newTask
}
async function updateTask(taskId: string, data: Partial<Pick<TaskItem, 'title' | 'description' | 'status' | 'priority' | 'due_date'>>) {
const task = tasks.value.find((t) => t.task_id === taskId)
if (task) {
Object.assign(task, data)
task.updated_at = new Date().toISOString()
}
}
async function deleteTask(taskId: string) {
const idx = tasks.value.findIndex((t) => t.task_id === taskId)
if (idx > -1) tasks.value.splice(idx, 1)
}
function setFilterStatus(s: TaskStatus | 'all') { filterStatus.value = s }
function setFilterPriority(p: TaskPriority | 'all') { filterPriority.value = p }
function setFilterSource(s: TaskSource | 'all') { filterSource.value = s }
return {
tasks,
filterStatus,
filterPriority,
filterSource,
filteredTasks,
todoTasks,
inProgressTasks,
doneTasks,
isLoading,
loadTasks,
createTask,
updateTask,
deleteTask,
setFilterStatus,
setFilterPriority,
setFilterSource,
}
})
+81
View File
@@ -0,0 +1,81 @@
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
import type { ThemeConfig } from '@/contracts'
const builtinThemes: ThemeConfig[] = [
{ theme_id: 'light', name: '浅色', version: '1.0.0', description: '默认浅色主题', is_dark: false, builtin: true },
{ theme_id: 'dark', name: '深色', version: '1.0.0', description: '默认深色主题', is_dark: true, builtin: true },
{ theme_id: 'sepia', name: '护眼', version: '1.0.0', description: '护眼暖色调', is_dark: false, builtin: true },
]
export const useThemeStore = defineStore('theme', () => {
const themes = ref<ThemeConfig[]>(builtinThemes)
const currentThemeId = ref<string>('light')
const fontEditorSize = ref(15)
const fontEditorFamily = ref('system-ui')
const lineHeight = ref(1.7)
const currentTheme = computed(() =>
themes.value.find((t) => t.theme_id === currentThemeId.value) || themes.value[0]
)
const isDark = computed(() => currentTheme.value?.is_dark || false)
function applyTheme(themeId: string) {
const theme = themes.value.find((t) => t.theme_id === themeId)
if (!theme) return
currentThemeId.value = themeId
const root = document.documentElement
if (theme.is_dark) {
root.setAttribute('data-theme', 'dark')
} else if (themeId === 'sepia') {
root.setAttribute('data-theme', 'sepia')
} else {
root.setAttribute('data-theme', 'light')
}
localStorage.setItem('theme', themeId)
}
function initTheme() {
const saved = localStorage.getItem('theme')
if (saved && themes.value.find((t) => t.theme_id === saved)) {
applyTheme(saved)
return
}
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
applyTheme(prefersDark ? 'dark' : 'light')
}
function toggleTheme() {
applyTheme(isDark.value ? 'light' : 'dark')
}
function resetToDefault() {
applyTheme('light')
fontEditorSize.value = 15
fontEditorFamily.value = 'system-ui'
lineHeight.value = 1.7
}
watch(fontEditorSize, (v) => {
document.documentElement.style.setProperty('--font-editor-size', `${v}px`)
})
watch(lineHeight, (v) => {
document.documentElement.style.setProperty('--font-editor-line-height', String(v))
})
return {
themes,
currentThemeId,
currentTheme,
isDark,
fontEditorSize,
fontEditorFamily,
lineHeight,
applyTheme,
initTheme,
toggleTheme,
resetToDefault,
}
})
+132
View File
@@ -0,0 +1,132 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { FileNode } from '@/contracts'
import * as workspaceService from '@/services/workspaceService'
export const useWorkspaceStore = defineStore('workspace', () => {
const vaultPath = ref('')
const vaultName = ref('')
const fileTree = ref<FileNode[]>([])
const openFiles = ref<string[]>([])
const activeFilePath = ref<string | null>(null)
const isLoading = ref(false)
const hasVault = ref(false)
const recentVaults = ref<{ path: string; name: string }[]>([])
const activeFile = computed(() => {
if (!activeFilePath.value) return null
return findNodeByPath(fileTree.value, activeFilePath.value)
})
function findNodeByPath(nodes: FileNode[], path: string): FileNode | null {
for (const node of nodes) {
if (node.path === path) return node
if (node.children) {
const found = findNodeByPath(node.children, path)
if (found) return found
}
}
return null
}
function toggleFolder(path: string) {
const node = findNodeByPath(fileTree.value, path)
if (node && node.type === 'folder') {
node.is_open = !node.is_open
}
}
function openFile(path: string) {
if (!openFiles.value.includes(path)) {
openFiles.value.push(path)
}
activeFilePath.value = path
}
function closeFile(path: string) {
const idx = openFiles.value.indexOf(path)
if (idx > -1) {
openFiles.value.splice(idx, 1)
if (activeFilePath.value === path) {
activeFilePath.value = openFiles.value[Math.min(idx, openFiles.value.length - 1)] || null
}
}
}
function setActiveFile(path: string | null) {
activeFilePath.value = path
}
async function loadRecentVaults() {
recentVaults.value = await workspaceService.getRecentVaults()
}
async function openVault(path: string) {
isLoading.value = true
try {
const info = await workspaceService.openVault(path)
vaultPath.value = info.path
vaultName.value = info.name
fileTree.value = await workspaceService.getFileTree()
hasVault.value = true
} finally {
isLoading.value = false
}
}
async function createVault(path: string, name: string) {
isLoading.value = true
try {
const info = await workspaceService.createVault(path, name)
vaultPath.value = info.path
vaultName.value = info.name
fileTree.value = await workspaceService.getFileTree()
hasVault.value = true
} finally {
isLoading.value = false
}
}
function addFileToTree(parentPath: string, file: FileNode) {
const parent = findNodeByPath(fileTree.value, parentPath)
if (parent?.children) {
parent.children.push(file)
parent.is_open = true
}
}
function removeFromTree(path: string) {
function remove(nodes: FileNode[]): boolean {
for (let i = 0; i < nodes.length; i++) {
if (nodes[i].path === path) {
nodes.splice(i, 1)
return true
}
if (nodes[i].children && remove(nodes[i].children!)) return true
}
return false
}
remove(fileTree.value)
}
return {
vaultPath,
vaultName,
fileTree,
openFiles,
activeFilePath,
activeFile,
isLoading,
hasVault,
recentVaults,
toggleFolder,
openFile,
closeFile,
setActiveFile,
loadRecentVaults,
openVault,
createVault,
addFileToTree,
removeFromTree,
}
})