Add Vue frontend application

This commit is contained in:
2026-03-21 10:13:35 +08:00
parent 6ffa07adde
commit b40a6ebd3a
56 changed files with 10884 additions and 0 deletions

1
frontend/.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_URL=http://localhost:8000

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

14
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

5
frontend/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

18
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,18 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://jarvis-backend:8000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}

2095
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
frontend/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@vueuse/core": "^14.2.1",
"axios": "^1.13.6",
"echarts": "^6.0.0",
"element-plus": "^2.13.6",
"lucide-vue-next": "^0.577.0",
"motion": "^12.38.0",
"pinia": "^3.0.4",
"vue": "^3.5.30",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^25.5.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.9.0",
"typescript": "^5.9.3",
"vite": "^8.0.1",
"vue-tsc": "^2.2.12"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

7
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,7 @@
<template>
<RouterView />
</template>
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>

34
frontend/src/api/agent.ts Normal file
View File

@@ -0,0 +1,34 @@
import api from './index'
export interface AgentStats {
agent_id: string
call_count: number
current_task: string | null
status: 'active' | 'idle' | 'disabled'
}
export interface AgentConfig {
id: string
name: string
role: string
description: string
system_prompt: string
enabled: boolean
}
export const agentApi = {
async getStats(): Promise<AgentStats[]> {
const res = await api.get('/api/agents/stats')
return res.data
},
async getConfig(id: string): Promise<AgentConfig> {
const res = await api.get(`/api/agents/config/${id}`)
return res.data
},
async updateConfig(id: string, data: Partial<AgentConfig>): Promise<AgentConfig> {
const res = await api.put(`/api/agents/config/${id}`, data)
return res.data
},
}

View File

@@ -0,0 +1,44 @@
import api from './index'
export interface Message {
id: string
role: 'user' | 'assistant'
content: string
model?: string
tokens_used?: number
created_at: string
}
export interface Conversation {
id: string
title?: string
message_count: number
created_at: string
updated_at: string
}
export const conversationApi = {
list() {
return api.get<Conversation[]>('/api/conversations')
},
create(title?: string) {
return api.post<Conversation>('/api/conversations', { title })
},
getMessages(conversationId: string) {
return api.get<Message[]>(`/api/conversations/${conversationId}`)
},
delete(conversationId: string) {
return api.delete(`/api/conversations/${conversationId}`)
},
chat(message: string, conversationId?: string, fileIds: string[] = []) {
return api.post('/api/conversations/chat', {
message,
conversation_id: conversationId,
file_ids: fileIds,
})
},
}

View File

@@ -0,0 +1,73 @@
import api from './index'
export interface Document {
id: string
title: string
filename: string
file_type: string
file_size: number
summary?: string
chunk_count: number
is_indexed: boolean
folder_id?: string | null
created_at: string
}
export interface SearchResult {
chunk_id: string
document_id: string
document_title: string
content: string
score: number
metadata_?: string
prev_chunk?: string
next_chunk?: string
}
export interface UploadResponse {
id: string
title: string
chunk_count: number
status: string
}
export const documentApi = {
list(folderId?: string | null) {
return api.get<Document[]>('/api/documents', {
params: folderId ? { folder_id: folderId } : undefined,
})
},
upload(file: File, folderId?: string | null) {
const formData = new FormData()
formData.append('file', file)
if (folderId) {
formData.append('folder_id', folderId)
}
return api.post<UploadResponse>('/api/documents/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
},
get(id: string) {
return api.get<Document>(`/api/documents/${id}`)
},
getChunks(id: string) {
return api.get<any[]>(`/api/documents/${id}/chunks`)
},
delete(id: string) {
return api.delete(`/api/documents/${id}`)
},
search(query: string, top_k = 5, mode = 'hybrid') {
return api.get<SearchResult[]>('/api/documents/search', {
params: { query, top_k, mode },
})
},
getContent(id: string) {
return api.get<string>(`/api/documents/${id}/content`)
},
}

View File

@@ -0,0 +1,47 @@
import api from './index'
export interface FolderCreate {
name: string
parent_id?: string | null
}
export interface FolderUpdate {
name: string
}
export interface FolderItem {
id: string
name: string
parent_id: string | null
created_at: string
updated_at: string
}
export interface FolderTree {
id: string
name: string
parent_id: string | null
children: FolderTree[]
}
export const folderApi = {
// 获取文件夹树
getTree() {
return api.get<FolderTree[]>('/api/folders')
},
// 创建文件夹
create(data: FolderCreate) {
return api.post('/api/folders', data)
},
// 重命名文件夹
rename(id: string, data: FolderUpdate) {
return api.put(`/api/folders/${id}`, data)
},
// 删除文件夹
delete(id: string) {
return api.delete(`/api/folders/${id}`)
},
}

49
frontend/src/api/forum.ts Normal file
View File

@@ -0,0 +1,49 @@
import api from './index'
export interface ForumPost {
id: string
user_id: string
title: string
content: string
category?: string
is_executed: boolean
execution_result?: string
reply_count: number
created_at: string
}
export interface ForumReply {
id: string
post_id: string
user_id?: string
agent_id?: string
content: string
is_ai_reply: boolean
created_at: string
}
export const forumApi = {
listPosts() {
return api.get<ForumPost[]>('/api/forum/posts')
},
createPost(data: { title: string; content: string; category?: string }) {
return api.post<ForumPost>('/api/forum/posts', data)
},
getPost(id: string) {
return api.get<ForumPost>(`/api/forum/posts/${id}`)
},
deletePost(id: string) {
return api.delete(`/api/forum/posts/${id}`)
},
listReplies(postId: string) {
return api.get<ForumReply[]>(`/api/forum/posts/${postId}/replies`)
},
createReply(postId: string, content: string) {
return api.post<ForumReply>(`/api/forum/posts/${postId}/replies`, { content })
},
}

56
frontend/src/api/graph.ts Normal file
View File

@@ -0,0 +1,56 @@
import api from './index'
export interface KGNode {
id: string
name: string
type: string
description?: string
importance: number
created_at?: string
}
export interface KGEdge {
id: string
source: string
target: string
relation: string
weight?: number
}
export interface GraphData {
nodes: KGNode[]
edges: KGEdge[]
stats: {
node_count: number
edge_count: number
}
}
export const graphApi = {
get() {
return api.get<GraphData>('/api/graph')
},
build() {
return api.post('/api/graph/build')
},
getEntityContext(entity: string) {
return api.get<{ context: string }>(`/api/graph/entity/${encodeURIComponent(entity)}`)
},
getSummary() {
return api.get<{ summary: string }>('/api/graph/summary')
},
getNeighbors(nodeId: string, depth = 1) {
return api.get<{ nodes: KGNode[]; edges: KGEdge[] }>(
`/api/graph/neighbors/${nodeId}`,
{ params: { depth } }
)
},
deleteNode(nodeId: string) {
return api.delete(`/api/graph/nodes/${nodeId}`)
},
}

29
frontend/src/api/index.ts Normal file
View File

@@ -0,0 +1,29 @@
import axios from 'axios'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000',
timeout: 30000,
})
// 请求拦截器:添加 Token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// 响应拦截器:处理错误
api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
localStorage.removeItem('access_token')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export default api

View File

@@ -0,0 +1,71 @@
import api from './index'
export type LLMProvider = 'openai' | 'claude' | 'ollama' | 'deepseek' | 'custom'
export type LLMType = 'chat' | 'vlm' | 'embedding' | 'rerank'
export interface LLMModelConfig {
name: string // 模型名称/别名
provider: LLMProvider
model: string
base_url: string
api_key: string
enabled: boolean // 是否启用
}
export interface LLMConfig {
chat?: LLMModelConfig[]
vlm?: LLMModelConfig[]
embedding?: LLMModelConfig[]
rerank?: LLMModelConfig[]
}
export interface SchedulerConfig {
daily_plan_time?: string
forum_scan_interval_minutes?: number
todo_ai_generate_time?: string
enabled?: boolean
}
export interface ProfileUpdate {
full_name?: string
password?: string
current_password?: string
}
export interface SettingsResponse {
profile: {
id: string
email: string
full_name: string
created_at: string
}
llm_config: LLMConfig
scheduler_config: SchedulerConfig
}
export const settingsApi = {
// 获取设置
get() {
return api.get<SettingsResponse>('/api/settings')
},
// 更新资料
updateProfile(data: ProfileUpdate) {
return api.put('/api/settings/profile', data)
},
// 更新 LLM 配置
updateLLM(config: Partial<LLMConfig>) {
return api.put('/api/settings/llm', config)
},
// 测试 LLM 连接
testLLM(data: { type: LLMType } & Omit<LLMModelConfig, 'name' | 'enabled'>) {
return api.post('/api/settings/llm/test', data)
},
// 更新定时任务配置
updateScheduler(config: Partial<SchedulerConfig>) {
return api.put('/api/settings/scheduler', config)
},
}

17
frontend/src/api/stats.ts Normal file
View File

@@ -0,0 +1,17 @@
import axios from '@/api'
export const getSystemHealth = () => axios.get('/stats/system')
export const getConversationStats = (days = 30) =>
axios.get('/stats/conversations', { params: { days } })
export const getKnowledgeStats = (days = 30) =>
axios.get('/stats/knowledge', { params: { days } })
export const getKanbanStats = (days = 30) =>
axios.get('/stats/kanban', { params: { days } })
export const getCommunityStats = (days = 30) =>
axios.get('/stats/community', { params: { days } })
export const getPersonalInsights = () => axios.get('/stats/insights')

35
frontend/src/api/task.ts Normal file
View File

@@ -0,0 +1,35 @@
import api from './index'
export type TaskStatus = 'todo' | 'in_progress' | 'done' | 'cancelled'
export type TaskPriority = 'low' | 'medium' | 'high' | 'urgent'
export interface Task {
id: string
title: string
description?: string
status: TaskStatus
priority: TaskPriority
due_date?: string
completed_at?: string
tags?: string
created_at: string
updated_at: string
}
export const taskApi = {
list(status?: TaskStatus) {
return api.get<Task[]>('/api/tasks', { params: status ? { status } : {} })
},
create(data: { title: string; description?: string; priority?: TaskPriority; due_date?: string }) {
return api.post<Task>('/api/tasks', data)
},
update(id: string, data: Partial<Task>) {
return api.patch<Task>(`/api/tasks/${id}`, data)
},
delete(id: string) {
return api.delete(`/api/tasks/${id}`)
},
}

59
frontend/src/api/todo.ts Normal file
View File

@@ -0,0 +1,59 @@
import api from './index'
export type TodoSource = 'ai_kanban' | 'ai_chat' | 'manual'
export interface Todo {
id: string
title: string
is_completed: boolean
source: TodoSource
source_detail: string | null
todo_date: string
completed_at: string | null
created_at: string
updated_at: string
}
export interface TodoListResponse {
items: Todo[]
total: number
page: number
page_size: number
}
export interface TodoSummary {
date: string
total: number
completed: number
pending: number
}
export const todoApi = {
list(date?: string, page = 1, pageSize = 50) {
return api.get<TodoListResponse>('/api/todos', {
params: date ? { date_str: date, page, page_size: pageSize } : { page, page_size: pageSize },
})
},
create(title: string) {
return api.post<Todo>('/api/todos', { title })
},
update(id: string, data: { title?: string; is_completed?: boolean }) {
return api.patch<Todo>(`/api/todos/${id}`, data)
},
delete(id: string) {
return api.delete(`/api/todos/${id}`)
},
aiGenerate() {
return api.post<TodoListResponse>('/api/todos/ai-generate')
},
summary(date?: string) {
return api.get<TodoSummary>('/api/todos/summary', {
params: date ? { date_str: date } : {},
})
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,176 @@
<script setup lang="ts">
import { ref } from 'vue'
import { type FolderTree } from '@/api/folder'
import { Folder, FolderOpen, ChevronRight, Plus, Edit2, Trash2 } from 'lucide-vue-next'
const props = defineProps<{
folders: FolderTree[]
selectedId?: string | null
onSelect: (folder: FolderTree) => void
onCreate: (parentId: string | null) => void
onRename: (folder: FolderTree) => void
onDelete: (folder: FolderTree) => void
}>()
const expandedIds = ref<Set<string>>(new Set())
function toggleExpand(id: string) {
if (expandedIds.value.has(id)) {
expandedIds.value.delete(id)
} else {
expandedIds.value.add(id)
}
}
function handleContextMenu(e: MouseEvent, folder: FolderTree) {
e.preventDefault()
// 显示右键菜单
}
</script>
<template>
<div class="folder-tree">
<div
v-for="folder in folders"
:key="folder.id"
class="folder-item"
>
<div
class="folder-row"
:class="{ selected: folder.id === selectedId }"
@click="props.onSelect(folder)"
@contextmenu="handleContextMenu($event, folder)"
>
<!-- 展开/折叠箭头 -->
<button
v-if="folder.children?.length"
class="expand-btn"
@click.stop="toggleExpand(folder.id)"
>
<ChevronRight
:size="12"
:class="{ rotated: expandedIds.has(folder.id) }"
/>
</button>
<span v-else class="expand-placeholder"></span>
<!-- 文件夹图标 -->
<FolderOpen v-if="expandedIds.has(folder.id)" :size="14" class="folder-icon" />
<Folder v-else :size="14" class="folder-icon" />
<!-- 文件夹名称 -->
<span class="folder-name">{{ folder.name }}</span>
<!-- 操作按钮 -->
<div class="folder-actions">
<button @click.stop="props.onCreate(folder.id)" title="添加子文件夹">
<Plus :size="12" />
</button>
<button @click.stop="props.onRename(folder)" title="重命名">
<Edit2 :size="12" />
</button>
<button @click.stop="props.onDelete(folder)" title="删除">
<Trash2 :size="12" />
</button>
</div>
</div>
<!-- 子文件夹递归 -->
<div
v-if="folder.children?.length && expandedIds.has(folder.id)"
class="folder-children"
>
<FolderTree
:folders="folder.children"
:selected-id="selectedId"
:on-select="onSelect"
:on-create="onCreate"
:on-rename="onRename"
:on-delete="onDelete"
/>
</div>
</div>
</div>
</template>
<style scoped>
/* sci-fi 风格 */
.folder-tree {
font-family: var(--font-mono);
font-size: 12px;
}
.folder-row {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
}
.folder-row:hover {
background: rgba(0, 245, 212, 0.04);
}
.folder-row.selected {
background: var(--accent-cyan-dim);
border: 1px solid rgba(0, 245, 212, 0.2);
}
.expand-btn {
background: none;
border: none;
padding: 0;
cursor: pointer;
color: var(--text-dim);
display: flex;
align-items: center;
}
.expand-placeholder {
width: 12px;
}
.folder-icon {
color: var(--accent-amber);
flex-shrink: 0;
}
.folder-name {
flex: 1;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.folder-actions {
display: none;
gap: 2px;
}
.folder-row:hover .folder-actions {
display: flex;
}
.folder-actions button {
background: none;
border: none;
padding: 2px;
cursor: pointer;
color: var(--text-dim);
border-radius: 3px;
transition: all var(--transition-fast);
}
.folder-actions button:hover {
color: var(--accent-cyan);
background: rgba(0, 245, 212, 0.1);
}
.folder-children {
padding-left: 16px;
}
</style>

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
import { ref } from 'vue'
import viteLogo from '../assets/vite.svg'
import heroImg from '../assets/hero.png'
import vueLogo from '../assets/vue.svg'
const count = ref(0)
</script>
<template>
<section id="center">
<div class="hero">
<img :src="heroImg" class="base" width="170" height="179" alt="" />
<img :src="vueLogo" class="framework" alt="Vue logo" />
<img :src="viteLogo" class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
</div>
<button class="counter" @click="count++">Count is {{ count }}</button>
</section>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img class="logo" :src="viteLogo" alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://vuejs.org/" target="_blank">
<img class="button-icon" :src="vueLogo" alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg class="button-icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
</template>

View File

@@ -0,0 +1,378 @@
<script setup lang="ts">
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { MessageCircle, BookOpen, Network, LayoutGrid, MessageSquare, LogOut, Cpu, Bot, Activity, CheckSquare, Settings } from 'lucide-vue-next'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const navItems = [
{ name: '沟通系统', path: '/chat', icon: MessageCircle },
{ name: '智能链路', path: '/agents', icon: Bot },
{ name: '资料中枢', path: '/knowledge', icon: BookOpen },
{ name: '关系图谱', path: '/graph', icon: Network },
{ name: '任务矩阵', path: '/kanban', icon: LayoutGrid },
{ name: '事务栈', path: '/todo', icon: CheckSquare },
{ name: '交互广场', path: '/forum', icon: MessageSquare },
{ name: '数据舱', path: '/stats', icon: Activity },
{ name: '系统设置', path: '/settings', icon: Settings },
]
function isActive(path: string) {
return route.path === path
}
function handleLogout() {
auth.logout()
router.push('/login')
}
</script>
<template>
<nav class="sidebar-nav">
<!-- Grid lines decoration -->
<div class="corner-deco top-left"></div>
<div class="corner-deco top-right"></div>
<div class="corner-deco bottom-left"></div>
<div class="corner-deco bottom-right"></div>
<!-- Logo -->
<div class="logo-section">
<div class="logo-icon">
<Cpu :size="22" />
<div class="logo-pulse"></div>
</div>
<div class="logo-text-group">
<span class="logo-name">JARVIS</span>
<span class="logo-sub">AI ASSISTANT v2.0</span>
</div>
</div>
<!-- Status bar -->
<div class="status-bar">
<div class="status-dot"></div>
<span>SYSTEM ONLINE</span>
</div>
<!-- Navigation -->
<ul class="nav-items">
<li v-for="item in navItems" :key="item.path">
<router-link
:to="item.path"
class="nav-link"
:class="{ active: isActive(item.path) }"
>
<span class="nav-indicator"></span>
<component :is="item.icon" :size="18" class="nav-icon" />
<span class="nav-label">{{ item.name }}</span>
<span class="nav-line"></span>
</router-link>
</li>
</ul>
<!-- Footer -->
<div class="nav-footer">
<div class="user-info">
<div class="user-avatar">
<span>{{ auth.user?.email?.[0]?.toUpperCase() || 'U' }}</span>
</div>
<div class="user-details">
<span class="user-name">{{ auth.user?.email?.split('@')[0] || 'Operator' }}</span>
<span class="user-level">LEVEL 1</span>
</div>
</div>
<button class="logout-btn" @click="handleLogout">
<LogOut :size="16" />
<span>LOGOUT</span>
</button>
</div>
</nav>
</template>
<style scoped>
.sidebar-nav {
width: 220px;
min-width: 220px;
height: 100%;
background: var(--bg-panel);
border-right: 1px solid var(--border-dim);
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
}
/* Corner decorations */
.corner-deco {
position: absolute;
width: 16px;
height: 16px;
z-index: 2;
}
.corner-deco::before,
.corner-deco::after {
content: '';
position: absolute;
background: var(--accent-cyan);
}
.corner-deco::before { width: 100%; height: 1px; }
.corner-deco::after { width: 1px; height: 100%; }
.top-left { top: 8px; left: 8px; }
.top-right { top: 8px; right: 8px; transform: scaleX(-1); }
.bottom-left { bottom: 8px; left: 8px; transform: scaleY(-1); }
.bottom-right { bottom: 8px; right: 8px; transform: scale(-1); }
/* Logo */
.logo-section {
display: flex;
align-items: center;
gap: 12px;
padding: 24px 20px 16px;
border-bottom: 1px solid var(--border-dim);
}
.logo-icon {
position: relative;
color: var(--accent-cyan);
filter: drop-shadow(0 0 8px var(--accent-cyan));
animation: float 3s ease-in-out infinite;
}
.logo-pulse {
position: absolute;
inset: -4px;
border-radius: 50%;
background: radial-gradient(circle, rgba(0,245,212,0.15) 0%, transparent 70%);
animation: pulse-glow 2s ease-in-out infinite;
}
.logo-text-group {
display: flex;
flex-direction: column;
}
.logo-name {
font-family: var(--font-display);
font-size: 18px;
font-weight: 700;
letter-spacing: 0.15em;
color: var(--accent-cyan);
text-shadow: var(--glow-cyan);
line-height: 1;
}
.logo-sub {
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 0.2em;
color: var(--text-dim);
margin-top: 3px;
}
/* Status bar */
.status-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 20px;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.15em;
color: var(--accent-green);
border-bottom: 1px solid var(--border-dim);
}
.status-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent-green);
box-shadow: 0 0 6px var(--accent-green);
animation: pulse-glow 1.5s ease-in-out infinite;
}
/* Nav items */
.nav-items {
list-style: none;
padding: 16px 12px;
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.nav-items li { margin-bottom: 2px; }
.nav-link {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: var(--radius-md);
color: var(--text-dim);
text-decoration: none;
font-size: 12px;
letter-spacing: 0.06em;
position: relative;
overflow: hidden;
transition: all var(--transition-mid);
border: 1px solid transparent;
}
.nav-link::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
background: var(--accent-cyan);
opacity: 0;
transition: opacity var(--transition-fast);
box-shadow: 0 0 8px var(--accent-cyan);
}
.nav-indicator {
width: 6px;
height: 6px;
border-radius: 50%;
border: 1px solid var(--text-dim);
flex-shrink: 0;
transition: all var(--transition-fast);
}
.nav-icon {
flex-shrink: 0;
transition: all var(--transition-fast);
}
.nav-label {
font-family: var(--font-mono);
font-weight: 500;
transition: color var(--transition-fast);
}
.nav-line {
flex: 1;
height: 1px;
background: linear-gradient(90deg, var(--border-dim), transparent);
opacity: 0;
transition: opacity var(--transition-fast);
}
.nav-link:hover {
color: var(--text-secondary);
background: rgba(0, 245, 212, 0.04);
border-color: var(--border-dim);
}
.nav-link:hover .nav-indicator {
border-color: var(--accent-cyan);
background: rgba(0,245,212,0.2);
}
.nav-link:hover .nav-line { opacity: 1; }
.nav-link.active {
color: var(--accent-cyan);
background: var(--accent-cyan-dim);
border-color: rgba(0, 245, 212, 0.2);
}
.nav-link.active::before { opacity: 1; }
.nav-link.active .nav-indicator {
border-color: var(--accent-cyan);
background: var(--accent-cyan);
box-shadow: 0 0 8px var(--accent-cyan);
}
.nav-link.active .nav-icon {
filter: drop-shadow(0 0 4px var(--accent-cyan));
color: var(--accent-cyan);
}
.nav-link.active .nav-label {
font-weight: 600;
text-shadow: 0 0 10px var(--accent-cyan-glow);
}
.nav-link.active .nav-line { opacity: 1; }
/* Footer */
.nav-footer {
padding: 16px;
border-top: 1px solid var(--border-dim);
display: flex;
flex-direction: column;
gap: 12px;
}
.user-info {
display: flex;
align-items: center;
gap: 10px;
}
.user-avatar {
width: 34px;
height: 34px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent-cyan-dim), var(--accent-purple-dim));
border: 1px solid var(--border-mid);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-size: 13px;
font-weight: 700;
color: var(--accent-cyan);
flex-shrink: 0;
}
.user-details {
display: flex;
flex-direction: column;
min-width: 0;
}
.user-name {
font-size: 11px;
color: var(--text-secondary);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-level {
font-family: var(--font-display);
font-size: 8px;
letter-spacing: 0.15em;
color: var(--accent-amber);
margin-top: 2px;
}
.logout-btn {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
background: transparent;
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
color: var(--text-dim);
font-size: 10px;
letter-spacing: 0.12em;
justify-content: center;
transition: all var(--transition-fast);
}
.logout-btn:hover {
background: rgba(255, 71, 87, 0.08);
border-color: rgba(255, 71, 87, 0.3);
color: var(--accent-red);
}
</style>

View File

@@ -0,0 +1,123 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{
visible: boolean
}>()
const emit = defineEmits<{
select: [emoji: string]
close: []
}>()
const categories = [
{ key: 'smile', name: '😀', label: '笑脸' },
{ key: 'gesture', name: '👍', label: '手势' },
{ key: 'object', name: '📦', label: '物品' },
{ key: 'symbol', name: '💬', label: '符号' },
]
const emojiData: Record<string, string[]> = {
smile: ['😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '😉', '😌'],
gesture: ['👍', '👎', '👌', '🤌', '🤏', '✌️', '🤞', '🖖', '🤙', '💪', '🙏', '👏'],
object: ['📄', '📁', '🖼️', '📊', '📝', '💾', '📧', '🔗', '📌', '🔍', '💡', '⚡'],
symbol: ['✅', '❌', '⚠️', '🔥', '💯', '🎯', '⭐', '✨', '💬', '🗨️', '❤️', '🧡'],
}
const activeCategory = ref('smile')
function selectEmoji(emoji: string) {
emit('select', emoji)
}
</script>
<template>
<div v-if="visible" class="emoji-picker">
<div class="emoji-tabs">
<button
v-for="cat in categories"
:key="cat.key"
:class="{ active: activeCategory === cat.key }"
@click="activeCategory = cat.key"
:title="cat.label"
>
{{ cat.name }}
</button>
</div>
<div class="emoji-grid">
<button
v-for="emoji in emojiData[activeCategory]"
:key="emoji"
class="emoji-btn"
@click="selectEmoji(emoji)"
>
{{ emoji }}
</button>
</div>
</div>
</template>
<style scoped>
.emoji-picker {
position: absolute;
bottom: 100%;
right: 0;
margin-bottom: 8px;
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-md);
padding: 8px;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.3);
z-index: 100;
min-width: 240px;
}
.emoji-tabs {
display: flex;
gap: 4px;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border-dim);
}
.emoji-tabs button {
flex: 1;
padding: 6px;
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
font-size: 16px;
cursor: pointer;
transition: all var(--transition-fast);
}
.emoji-tabs button:hover {
background: var(--accent-cyan-dim);
}
.emoji-tabs button.active {
background: var(--accent-cyan-dim);
border-color: var(--border-mid);
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 2px;
}
.emoji-btn {
padding: 6px;
background: transparent;
border: none;
border-radius: var(--radius-sm);
font-size: 18px;
cursor: pointer;
transition: all var(--transition-fast);
}
.emoji-btn:hover {
background: var(--accent-cyan-dim);
transform: scale(1.2);
}
</style>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import { computed } from 'vue'
import { FileText, Image, File } from 'lucide-vue-next'
const props = defineProps<{
filename: string
fileType: string
fileSize: number
}>()
const icon = computed(() => {
if (props.fileType.startsWith('image/')) return Image
if (props.fileType.includes('pdf') || props.fileType.includes('document')) return FileText
return File
})
const fileSizeDisplay = computed(() => {
const size = props.fileSize
if (size < 1024) return size + ' B'
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB'
return (size / (1024 * 1024)).toFixed(1) + ' MB'
})
const ext = computed(() => {
const parts = props.filename.split('.')
return parts.length > 1 ? parts.pop()?.toUpperCase() : ''
})
</script>
<template>
<div class="file-message">
<div class="file-icon">
<component :is="icon" :size="24" />
<span v-if="ext" class="file-ext">{{ ext }}</span>
</div>
<div class="file-info">
<span class="file-name">{{ filename }}</span>
<span class="file-size">{{ fileSizeDisplay }}</span>
</div>
</div>
</template>
<style scoped>
.file-message {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--accent-cyan-dim);
border: 1px solid rgba(0, 245, 212, 0.2);
border-radius: var(--radius-md);
min-width: 200px;
max-width: 300px;
}
.file-icon {
position: relative;
color: var(--accent-cyan);
flex-shrink: 0;
}
.file-ext {
position: absolute;
bottom: -2px;
right: -4px;
font-size: 7px;
font-family: var(--font-mono);
font-weight: 700;
color: var(--bg-void);
background: var(--accent-cyan);
padding: 1px 2px;
border-radius: 2px;
}
.file-info {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.file-name {
font-size: 12px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.file-size {
font-size: 10px;
font-family: var(--font-mono);
color: var(--text-dim);
}
</style>

View File

@@ -0,0 +1,74 @@
<script setup lang="ts">
import type { Component } from 'vue'
defineProps<{
icon: Component
label: string
value: string | number
accentColor?: string
}>()
</script>
<template>
<div class="metric-card" :style="{ '--card-accent': accentColor || 'var(--accent-cyan)' }">
<div class="metric-icon">
<component :is="icon" :size="16" />
</div>
<div class="metric-info">
<span class="metric-value">{{ value }}</span>
<span class="metric-label">{{ label }}</span>
</div>
</div>
</template>
<style scoped>
.metric-card {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
transition: all var(--transition-mid);
}
.metric-card:hover {
border-color: var(--card-accent);
box-shadow: 0 0 12px color-mix(in srgb, var(--card-accent) 30%, transparent);
transform: translateY(-2px);
}
.metric-icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--card-accent) 12%, transparent);
color: var(--card-accent);
}
.metric-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.metric-value {
font-family: var(--font-mono);
font-size: 20px;
font-weight: 600;
color: var(--card-accent);
text-shadow: 0 0 10px color-mix(in srgb, var(--card-accent) 40%, transparent);
}
.metric-label {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
letter-spacing: 0.08em;
text-transform: uppercase;
}
</style>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
data: number[]
color?: string
height?: number
maxBars?: number
}>(), {
color: 'var(--accent-cyan)',
height: 32,
maxBars: 7
})
const displayData = computed(() => {
if (props.data.length <= props.maxBars) return props.data
// 采样:取最后 N 个
return props.data.slice(-props.maxBars)
})
const maxValue = computed(() => Math.max(...displayData.value, 1))
</script>
<template>
<div class="mini-bar-chart" :style="{ '--chart-height': height + 'px', '--chart-color': color }">
<div class="bars">
<div
v-for="(value, i) in displayData"
:key="i"
class="bar"
:style="{ height: (value / maxValue * 100) + '%' }"
/>
</div>
</div>
</template>
<style scoped>
.mini-bar-chart {
width: 100%;
height: var(--chart-height);
}
.bars {
display: flex;
align-items: flex-end;
gap: 2px;
height: 100%;
}
.bar {
flex: 1;
background: var(--chart-color);
border-radius: 2px 2px 0 0;
opacity: 0.7;
transition: opacity var(--transition-fast);
min-height: 2px;
}
.bar:hover {
opacity: 1;
}
</style>

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(defineProps<{
data: { date: string; value: number }[]
color?: string
height?: number
maxPoints?: number
}>(), {
color: 'var(--accent-cyan)',
height: 60,
maxPoints: 30
})
const displayData = computed(() => {
if (props.data.length <= props.maxPoints) return props.data
// 采样:均匀抽取
const step = Math.ceil(props.data.length / props.maxPoints)
return props.data.filter((_, i) => i % step === 0)
})
const maxValue = computed(() => Math.max(...displayData.value.map(d => d.value), 1))
const points = computed(() => {
return displayData.value.map((d, i) => {
const x = (i / (displayData.value.length - 1)) * 100
const y = 100 - (d.value / maxValue.value * 100)
return `${x},${y}`
}).join(' ')
})
</script>
<template>
<div class="mini-line-chart" :style="{ '--chart-height': height + 'px', '--chart-color': color }">
<svg :viewBox="`0 0 100 100`" preserveAspectRatio="none" class="chart-svg">
<!-- 背景网格 -->
<line x1="0" y1="50" x2="100" y2="50" class="grid-line" />
<!-- 折线 -->
<polyline :points="points" class="line" />
<!-- 数据点 -->
<circle
v-for="(d, i) in displayData"
:key="i"
:cx="(i / (displayData.length - 1)) * 100"
:cy="100 - (d.value / maxValue * 100)"
r="2"
class="dot"
/>
</svg>
</div>
</template>
<style scoped>
.mini-line-chart {
width: 100%;
height: var(--chart-height);
}
.chart-svg {
width: 100%;
height: 100%;
}
.grid-line {
stroke: var(--border-dim);
stroke-width: 0.5;
}
.line {
fill: none;
stroke: var(--chart-color);
stroke-width: 1.5;
stroke-linecap: round;
stroke-linejoin: round;
opacity: 0.8;
}
.dot {
fill: var(--chart-color);
opacity: 0;
transition: opacity var(--transition-fast);
}
.mini-line-chart:hover .dot {
opacity: 1;
}
</style>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
defineProps<{
title: string
tag?: 'cyan' | 'purple' | 'amber'
}>()
</script>
<template>
<div class="section-header">
<div class="section-title">
<span class="section-slash">//</span>
<span class="section-name">{{ title }}</span>
</div>
<span v-if="tag" class="section-tag" :class="tag">
{{ title.split(' ')[0] }}
</span>
</div>
</template>
<style scoped>
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0 12px;
border-bottom: 1px solid var(--border-dim);
margin-bottom: 16px;
}
.section-title {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.1em;
}
.section-slash {
color: var(--text-dim);
margin-right: 8px;
}
.section-name {
color: var(--text-primary);
text-transform: uppercase;
}
.section-tag {
padding: 3px 10px;
border-radius: 3px;
font-family: var(--font-display);
font-size: 9px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.section-tag.cyan {
background: var(--accent-cyan-dim);
color: var(--accent-cyan);
border: 1px solid rgba(0, 245, 212, 0.2);
}
.section-tag.purple {
background: var(--accent-purple-dim);
color: var(--accent-purple);
border: 1px solid rgba(123, 44, 191, 0.2);
}
.section-tag.amber {
background: var(--accent-amber-dim);
color: var(--accent-amber);
border: 1px solid rgba(249, 168, 37, 0.2);
}
</style>

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
defineProps<{
items: { label: string; value: string | number }[]
columns?: number
}>()
</script>
<template>
<div class="summary-row" :style="{ '--cols': columns || 4 }">
<div v-for="item in items" :key="item.label" class="summary-item">
<span class="summary-value">{{ item.value }}</span>
<span class="summary-label">{{ item.label }}</span>
</div>
</div>
</template>
<style scoped>
.summary-row {
display: grid;
grid-template-columns: repeat(var(--cols), 1fr);
gap: 16px;
margin-bottom: 20px;
}
.summary-item {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 16px;
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
transition: all var(--transition-fast);
}
.summary-item:hover {
border-color: var(--border-mid);
}
.summary-value {
font-family: var(--font-mono);
font-size: 18px;
font-weight: 600;
color: var(--accent-cyan);
text-shadow: 0 0 8px var(--accent-cyan-glow);
}
.summary-label {
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-dim);
letter-spacing: 0.1em;
text-transform: uppercase;
}
</style>

View File

@@ -0,0 +1,74 @@
export interface Agent {
id: string
name: string
role: string // 中文角色名
roleKey: string // 英文 key: master/planner/executor/librarian/analyst
description: string
systemPrompt: string
enabled: boolean
isMaster?: boolean
}
export const DEFAULT_AGENTS: Agent[] = [
{
id: 'master',
name: 'JARVIS',
role: '指挥官',
roleKey: 'master',
description: '中央指挥官,协调所有子 Agent 工作,理解用户意图并分配任务',
systemPrompt: '你是 Jarvis 的主控制核心。你的职责是理解用户的请求,协调规划者、执行者、知识官、分析师四个子 Agent 工作。分析请求,决定调用哪个子 Agent将任务分发并汇总结果反馈给用户。',
enabled: true,
isMaster: true,
},
{
id: 'planner',
name: 'PLANNER',
role: '规划者',
roleKey: 'planner',
description: '制定任务计划,拆解复杂目标为可执行步骤,规划执行顺序',
systemPrompt: '你是规划专家。当用户提出需要规划的任务时,将目标拆解为清晰可执行的步骤列表。为每个步骤标注优先级和预计时间,帮助用户理解任务的全貌。',
enabled: true,
},
{
id: 'executor',
name: 'EXECUTOR',
role: '执行者',
roleKey: 'executor',
description: '调用工具执行具体操作,创建/更新/删除系统资源',
systemPrompt: '你是执行专家。根据规划者的计划,调用相应工具执行具体操作。包括创建文档、创建任务、发送消息、管理日程等操作。执行完成后汇报结果。',
enabled: true,
},
{
id: 'librarian',
name: 'LIBRARIAN',
role: '知识官',
roleKey: 'librarian',
description: '管理知识库和知识图谱,检索相关信息,更新记忆',
systemPrompt: '你是知识管理员。负责管理用户的知识库、文档库和知识图谱。当用户需要搜索信息、添加知识、整理记忆时介入。保持知识的准确性和关联性。',
enabled: true,
},
{
id: 'analyst',
name: 'ANALYST',
role: '分析师',
roleKey: 'analyst',
description: '分析工作数据,生成统计报告,提供洞察建议',
systemPrompt: '你是数据分析师。当用户需要分析、统计、总结数据时介入。查询系统数据,生成可读性强的报告,用数据支持决策。',
enabled: true,
},
]
export const AGENT_RELATIONS: Record<string, string[]> = {
master: ['planner', 'executor', 'librarian', 'analyst'],
planner: [],
executor: [],
librarian: [],
analyst: [],
}
export const RELATION_LABELS: Record<string, string> = {
'master-planner': '调度任务',
'master-executor': '执行指令',
'master-librarian': '查询知识',
'master-analyst': '请求分析',
}

13
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,13 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import router from './router'
import App from './App.vue'
import './style.css'
const app = createApp(App)
app.use(createPinia())
app.use(ElementPlus)
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,83 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
meta: { guest: true },
},
{
path: '/',
component: () => import('@/views/LayoutView.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
redirect: '/chat',
},
{
path: 'chat',
name: 'chat',
component: () => import('@/views/ChatView.vue'),
},
{
path: 'knowledge',
name: 'knowledge',
component: () => import('@/views/KnowledgeView.vue'),
},
{
path: 'graph',
name: 'graph',
component: () => import('@/views/GraphView.vue'),
},
{
path: 'kanban',
name: 'kanban',
component: () => import('@/views/KanbanView.vue'),
},
{
path: 'forum',
name: 'forum',
component: () => import('@/views/ForumView.vue'),
},
{
path: 'agents',
name: 'agents',
component: () => import('@/views/AgentView.vue'),
},
{
path: 'stats',
name: 'stats',
component: () => import('@/views/StatsView.vue'),
},
{
path: 'todo',
name: 'todo',
component: () => import('@/views/TodoView.vue'),
},
{
path: 'settings',
name: 'settings',
component: () => import('@/views/SettingsView.vue'),
},
],
},
],
})
router.beforeEach((to, _from, next) => {
const auth = useAuthStore()
if (to.meta.requiresAuth && !auth.isAuthenticated) {
next('/login')
} else if (to.meta.guest && auth.isAuthenticated) {
next('/chat')
} else {
next()
}
})
export default router

View File

@@ -0,0 +1,44 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '@/api'
export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(localStorage.getItem('access_token'))
const user = ref<{ id: string; email: string; full_name?: string } | null>(null)
const isAuthenticated = computed(() => !!token.value)
async function login(email: string, password: string) {
const formData = new FormData()
formData.append('username', email)
formData.append('password', password)
const response = await api.post('/api/auth/login', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
token.value = response.data.access_token
localStorage.setItem('access_token', response.data.access_token)
await fetchUser()
}
async function fetchUser() {
if (!token.value) return
try {
const response = await api.get('/api/auth/me')
user.value = response.data
} catch {
logout()
}
}
function logout() {
token.value = null
user.value = null
localStorage.removeItem('access_token')
}
if (token.value) {
fetchUser()
}
return { token, user, isAuthenticated, login, logout, fetchUser }
})

View File

@@ -0,0 +1,47 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Message, Conversation } from '@/api/conversation'
export const useConversationStore = defineStore('conversation', () => {
const conversations = ref<Conversation[]>([])
const currentConversationId = ref<string | null>(null)
const messages = ref<Message[]>([])
const isLoading = ref(false)
function setConversations(data: Conversation[]) {
conversations.value = data
}
function setCurrentConversation(id: string) {
currentConversationId.value = id
}
function addMessage(msg: Message) {
messages.value.push(msg)
}
function setMessages(data: Message[]) {
messages.value = data
}
function addConversation(conv: Conversation) {
conversations.value.unshift(conv)
}
function removeConversation(id: string) {
conversations.value = conversations.value.filter((c) => c.id !== id)
}
return {
conversations,
currentConversationId,
messages,
isLoading,
setConversations,
setCurrentConversation,
addMessage,
setMessages,
addConversation,
removeConversation,
}
})

207
frontend/src/style.css Normal file
View File

@@ -0,0 +1,207 @@
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@300;400;500;600;700&family=Share+Tech+Mono&display=swap');
:root {
--bg-void: #03050a;
--bg-deep: #050810;
--bg-panel: #0a0f1a;
--bg-card: #0d1525;
--bg-card-hover: #111d35;
--border-dim: rgba(0, 245, 212, 0.08);
--border-mid: rgba(0, 245, 212, 0.18);
--border-bright: rgba(0, 245, 212, 0.45);
--accent-cyan: #00f5d4;
--accent-cyan-dim: rgba(0, 245, 212, 0.12);
--accent-cyan-glow: rgba(0, 245, 212, 0.35);
--accent-amber: #f9a825;
--accent-amber-dim: rgba(249, 168, 37, 0.12);
--accent-purple: #7b2cbf;
--accent-purple-dim: rgba(123, 44, 191, 0.15);
--accent-red: #ff4757;
--accent-green: #00e676;
--text-primary: #e8f4f8;
--text-secondary: #7eb8c9;
--text-dim: #3d6b7a;
--text-muted: #1e3d4a;
--font-display: 'Orbitron', 'Share Tech Mono', monospace;
--font-mono: 'JetBrains Mono', 'Share Tech Mono', monospace;
--font-body: 'JetBrains Mono', monospace;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--glow-cyan: 0 0 12px rgba(0, 245, 212, 0.4), 0 0 30px rgba(0, 245, 212, 0.15);
--glow-amber: 0 0 12px rgba(249, 168, 37, 0.4), 0 0 30px rgba(249, 168, 37, 0.15);
--transition-fast: 0.15s ease;
--transition-mid: 0.25s ease;
--transition-slow: 0.4s ease;
}
*, *::before, *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #app {
height: 100%;
width: 100%;
overflow: hidden;
}
body {
font-family: var(--font-body);
font-size: 13px;
line-height: 1.6;
background: var(--bg-void);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 3px; height: 3px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--accent-cyan);
border-radius: 2px;
opacity: 0.4;
}
::-webkit-scrollbar-thumb:hover { opacity: 0.8; }
/* ── Selection ── */
::selection {
background: rgba(0, 245, 212, 0.25);
color: var(--accent-cyan);
}
/* ── Focus ── */
:focus-visible {
outline: 1px solid var(--accent-cyan);
outline-offset: 2px;
}
/* ── Animations ── */
@keyframes pulse-glow {
0%, 100% { box-shadow: 0 0 6px var(--accent-cyan-glow), inset 0 0 6px rgba(0,245,212,0.05); }
50% { box-shadow: 0 0 18px var(--accent-cyan-glow), inset 0 0 12px rgba(0,245,212,0.1); }
}
@keyframes scan-line {
0% { transform: translateY(-100%); opacity: 0; }
10% { opacity: 1; }
90% { opacity: 1; }
100% { transform: translateY(100vh); opacity: 0; }
}
@keyframes blink-cursor {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
@keyframes fade-in-up {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes border-flow {
0%, 100% { border-color: var(--border-bright); }
50% { border-color: rgba(0, 245, 212, 0.2); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-4px); }
}
/* ── Grid background ── */
.grid-bg {
background-image:
linear-gradient(rgba(0, 245, 212, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 245, 212, 0.03) 1px, transparent 1px);
background-size: 40px 40px;
}
/* ── Button base ── */
button {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.05em;
cursor: pointer;
border: none;
outline: none;
transition: all var(--transition-fast);
}
button:disabled { opacity: 0.4; cursor: not-allowed; }
/* ── Input base ── */
input, textarea, select {
font-family: var(--font-mono);
font-size: 13px;
color: var(--text-primary);
background: transparent;
border: none;
outline: none;
resize: none;
}
input::placeholder, textarea::placeholder {
color: var(--text-dim);
}
/* ── Holographic card ── */
.holo-card {
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
position: relative;
overflow: hidden;
transition: all var(--transition-mid);
}
.holo-card::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(0,245,212,0.03) 0%, transparent 60%);
pointer-events: none;
}
.holo-card:hover {
border-color: var(--border-mid);
background: var(--bg-card-hover);
box-shadow: var(--glow-cyan);
}
/* ── Accent badge ── */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
font-family: var(--font-display);
}
/* ── Scanline overlay ── */
.scanlines::after {
content: '';
position: fixed;
inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(0, 0, 0, 0.03) 2px,
rgba(0, 0, 0, 0.03) 4px
);
pointer-events: none;
z-index: 9999;
}

View File

@@ -0,0 +1,738 @@
<template>
<div class="agent-view scanlines">
<!-- Background -->
<div class="bg-grid"></div>
<div class="bg-glow"></div>
<div class="bg-particles">
<span v-for="p in bgParticles" :key="p.id" class="bg-particle" :style="p.style"></span>
</div>
<!-- Header -->
<div class="view-header">
<div class="header-title">
<span class="title-bracket">[</span>
<span class="title-text">AGENT COMMAND CENTER</span>
<span class="title-bracket">]</span>
</div>
<div class="header-actions">
<button class="btn-icon" @click="refreshStats" :class="{ spinning: loading }" title="刷新状态">
<RefreshCw :size="14" />
</button>
<button class="btn-add" @click="addModalOpen = true">
<Plus :size="14" />
<span>新增智能体</span>
</button>
<div class="status-bar">
<span class="status-dot" :class="connectionStatus"></span>
<span class="status-label">{{ connectionLabel }}</span>
</div>
</div>
</div>
<!-- Nodes Canvas -->
<div class="nodes-canvas" ref="canvasRef">
<!-- SVG for connection lines (dynamically sized to canvas) -->
<svg class="conn-svg" ref="svgRef">
<defs>
<filter id="lineGlow">
<feGaussianBlur stdDeviation="3" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
<path
v-for="sub in subAgents"
:key="`line-${sub.id}`"
:d="getLinePath(sub.id)"
class="conn-path"
:class="{ active: activeLine === sub.id }"
/>
<circle r="5" class="pulse-dot">
<animateMotion
v-if="firingLine"
:dur="pulseDuration + 'ms'"
:path="getLinePath(firingLine)"
fill="freeze"
@end="onPulseEnd"
/>
</circle>
</svg>
<!-- Master node -->
<div
ref="masterCardRef"
class="node-card node-master"
:class="{ selected: selectedAgentId === 'master' }"
:style="masterNodeStyle"
@click="selectAgent('master')"
>
<div class="node-inner">
<div class="node-corner tl"></div>
<div class="node-corner tr"></div>
<div class="node-corner bl"></div>
<div class="node-corner br"></div>
<div class="node-status" :class="getStatusClass('master')">
<span class="status-ring"></span>
</div>
<div class="node-label">MASTER CORE</div>
<div class="node-name">{{ getAgentName('master') }}</div>
<div class="node-role">{{ getAgentRole('master') }}</div>
<div class="node-desc">{{ getAgentDesc('master') }}</div>
<div class="node-footer">
<span class="node-stat">
<span class="stat-label">调用</span>
<span class="stat-val">{{ agentData['master']?.callCount || 0 }}</span>
</span>
<span v-if="agentData['master']?.currentTask" class="node-task-tag">
{{ agentData['master'].currentTask }}
</span>
</div>
</div>
</div>
<!-- Sub-agent nodes -->
<div
v-for="sub in subAgents"
:key="sub.id"
:ref="el => setSubRef(sub.id, el as HTMLElement)"
class="node-card node-sub"
:class="{ selected: selectedAgentId === sub.id, disabled: !sub.enabled }"
:style="getSubNodeStyle(sub)"
@click="selectAgent(sub.id)"
>
<div class="node-inner">
<div class="node-corner tl"></div>
<div class="node-corner tr"></div>
<div class="node-corner bl"></div>
<div class="node-corner br"></div>
<div class="node-status" :class="getStatusClass(sub.id)">
<span class="status-ring"></span>
</div>
<div class="node-label">{{ sub.name }}</div>
<div class="node-role">{{ sub.role }}</div>
<div class="node-desc">{{ sub.description }}</div>
<div class="node-footer">
<span class="node-stat">
<span class="stat-label">调用</span>
<span class="stat-val">{{ agentData[sub.id]?.callCount || 0 }}</span>
</span>
<span v-if="agentData[sub.id]?.currentTask" class="node-task-tag">
{{ agentData[sub.id].currentTask }}
</span>
<span v-else class="node-idle">待机中</span>
</div>
<div class="rel-label">{{ sub.relLabel }}</div>
</div>
</div>
</div>
<!-- Right Drawer -->
<Transition :css="false" @enter="animateIn" @leave="animateOut">
<div v-if="drawerOpen" class="config-drawer">
<div class="drawer-header">
<span class="drawer-title">// AGENT CONFIGURATION</span>
<button class="btn-close" @click="drawerOpen = false"><X :size="16" /></button>
</div>
<div class="drawer-body" v-if="editAgent">
<div class="form-group">
<label class="form-label">// AGENT NAME</label>
<input v-model="editAgent.name" type="text" class="form-input" />
</div>
<div class="form-group">
<label class="form-label">// ROLE</label>
<input v-model="editAgent.role" type="text" class="form-input" />
</div>
<div class="form-group">
<label class="form-label">// DESCRIPTION</label>
<textarea v-model="editAgent.description" class="form-textarea" rows="2"></textarea>
</div>
<div class="form-group flex-1">
<label class="form-label">// SYSTEM PROMPT</label>
<textarea v-model="editAgent.systemPrompt" class="form-textarea code-textarea" rows="10"></textarea>
</div>
<div class="form-group">
<label class="form-label">// STATUS</label>
<div class="toggle-row">
<span class="toggle-label" :class="{ dim: editAgent.enabled }">DISABLED</span>
<button class="toggle-btn" :class="{ active: editAgent.enabled }" @click="editAgent.enabled = !editAgent.enabled">
<span class="toggle-knob"></span>
</button>
<span class="toggle-label" :class="{ dim: !editAgent.enabled }">ENABLED</span>
</div>
</div>
<div class="drawer-actions">
<button class="btn-secondary" @click="resetConfig">重置</button>
<button class="btn-primary" @click="saveConfig" :disabled="saving">
<span v-if="saving" class="btn-loader"></span>
{{ saving ? '保存中...' : '保存配置' }}
</button>
</div>
</div>
</div>
</Transition>
<!-- Add Modal -->
<Transition :css="false" @enter="animateIn" @leave="animateOut">
<div v-if="addModalOpen" class="modal-overlay" @click.self="addModalOpen = false">
<div class="modal-card">
<div class="modal-header">
<span class="modal-title">// ADD NEW AGENT</span>
<button class="btn-close" @click="addModalOpen = false"><X :size="16" /></button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">// AGENT NAME</label>
<input v-model="newAgent.name" type="text" class="form-input" placeholder="例如: CODER" />
</div>
<div class="form-group">
<label class="form-label">// ROLE KEY (英文唯一标识)</label>
<input v-model="newAgent.roleKey" type="text" class="form-input" placeholder="例如: coder" />
</div>
<div class="form-group">
<label class="form-label">// ROLE</label>
<input v-model="newAgent.role" type="text" class="form-input" placeholder="中文角色名" />
</div>
<div class="form-group">
<label class="form-label">// DESCRIPTION</label>
<textarea v-model="newAgent.description" class="form-textarea" rows="2" placeholder="描述此 Agent 的职责..."></textarea>
</div>
<div class="form-group flex-1">
<label class="form-label">// SYSTEM PROMPT</label>
<textarea v-model="newAgent.systemPrompt" class="form-textarea code-textarea" rows="6" placeholder="输入系统提示词..."></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="addModalOpen = false">取消</button>
<button class="btn-primary" @click="addAgent" :disabled="!newAgent.name || !newAgent.roleKey">创建智能体</button>
</div>
</div>
</div>
</Transition>
<Transition :css="false" @enter="fadeIn" @leave="fadeOut">
<div v-if="drawerOpen" class="drawer-backdrop" @click="drawerOpen = false"></div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { animate, type AnimationControls } from 'motion'
import { RefreshCw, X, Plus } from 'lucide-vue-next'
import { DEFAULT_AGENTS, RELATION_LABELS } from '@/data/agents'
import type { Agent } from '@/data/agents'
import { agentApi } from '@/api/agent'
// ── Node dimensions ──────────────────────────────────────────────
const NODE_W = 200
const NODE_H = 170
const MASTER_TOP = 48 // px from top
const SUB_TOP = 350 // px from top
// Horizontal percentages: master=50%, subs=12.5/37.5/62.5/87.5%
const SUB_XS = [12.5, 37.5, 62.5, 87.5]
// ── Sub-agent static data ────────────────────────────────────────
const subAgents = [
{ id: 'planner', name: 'PLANNER', role: '规划者', description: '制定任务计划,拆解复杂目标为可执行步骤', relLabel: RELATION_LABELS['master-planner'] },
{ id: 'executor', name: 'EXECUTOR', role: '执行者', description: '调用工具执行具体操作,创建/更新/删除系统资源', relLabel: RELATION_LABELS['master-executor'] },
{ id: 'librarian', name: 'LIBRARIAN', role: '知识官', description: '管理知识库和知识图谱,检索相关信息,更新记忆', relLabel: RELATION_LABELS['master-librarian'] },
{ id: 'analyst', name: 'ANALYST', role: '分析师', description: '分析工作数据,生成统计报告,提供洞察建议', relLabel: RELATION_LABELS['master-analyst'] },
]
// ── Refs ────────────────────────────────────────────────────────
const canvasRef = ref<HTMLElement>()
const svgRef = ref<SVGElement>()
const masterCardRef = ref<HTMLElement>()
const subRefs: Record<string, HTMLElement> = {}
const masterAnim = ref<AnimationControls | null>(null)
const subAnims: Record<string, AnimationControls> = {}
const hoverAnims: Record<string, AnimationControls | null> = {}
// Background particles
const bgParticles = Array.from({ length: 60 }, (_, i) => {
const d = 3 + Math.random() * 5
const delay = Math.random() * 4
const o = 0.25 + Math.random() * 0.5
const size = 1 + Math.random() * 2.5
return {
id: i,
style: {
left: `${Math.random() * 98}%`,
top: `${Math.random() * 95}%`,
width: `${size}px`,
height: `${size}px`,
'--d': `${d}s`,
'--delay': `${delay}s`,
'--o': String(o),
opacity: o,
},
}
})
let resizeObserver: ResizeObserver | null = null
const selectedAgentId = ref<string | null>(null)
const drawerOpen = ref(false)
const addModalOpen = ref(false)
const editAgent = ref<{ name: string; role: string; description: string; systemPrompt: string; enabled: boolean } | null>(null)
const newAgent = reactive({ name: '', roleKey: '', role: '', description: '', systemPrompt: '' })
const saving = ref(false)
const loading = ref(false)
const connectionStatus = ref<'connected' | 'disconnected'>('disconnected')
const connectionLabel = computed(() => connectionStatus.value === 'connected' ? '实时同步' : '离线模式')
const agentData = reactive<Record<string, { callCount: number; currentTask: string | null; status: string }>>({})
const activeLine = ref<string | null>(null)
const firingLine = ref<string | null>(null)
const pulseDuration = 600
const localAgents = reactive<Record<string, Agent>>(
Object.fromEntries(DEFAULT_AGENTS.map(a => [a.id, { ...a }]))
)
let pollInterval: ReturnType<typeof setInterval> | null = null
// ── Layout ─────────────────────────────────────────────────────
function pxToSvg(pctX: number, top: number) {
const canvas = canvasRef.value
if (!canvas) return { x: 0, y: top }
return {
x: (pctX / 100) * canvas.clientWidth,
y: top,
}
}
const masterNodeStyle = computed(() => {
const { x } = pxToSvg(50, MASTER_TOP)
return {
left: `${x - NODE_W / 2}px`,
top: `${MASTER_TOP}px`,
width: `${NODE_W}px`,
}
})
function getSubNodeStyle(sub: (typeof subAgents)[0]) {
const idx = subAgents.findIndex(s => s.id === sub.id)
const pct = SUB_XS[idx] ?? 50
const { x } = pxToSvg(pct, SUB_TOP)
return {
left: `${x - NODE_W / 2}px`,
top: `${SUB_TOP}px`,
width: `${NODE_W}px`,
}
}
function getLinePath(subId: string) {
const canvas = canvasRef.value
if (!canvas) return ''
const w = canvas.clientWidth
const idx = subAgents.findIndex(s => s.id === subId)
const subPct = SUB_XS[idx] ?? 50
const masterX = (50 / 100) * w
const masterY = MASTER_TOP + NODE_H / 2
const subX = (subPct / 100) * w
const subY = SUB_TOP + NODE_H / 2
const midY = (masterY + subY) / 2
return `M ${masterX},${masterY} C ${masterX},${midY} ${subX},${midY} ${subX},${subY}`
}
// ── SVG sizing ──────────────────────────────────────────────────
function updateSvgSize() {
const canvas = canvasRef.value
const svg = svgRef.value
if (!canvas || !svg) return
svg.setAttribute('width', String(canvas.clientWidth))
svg.setAttribute('height', String(canvas.clientHeight))
}
// ── Ref helpers ──────────────────────────────────────────────────
function setSubRef(id: string, el: HTMLElement | null) {
if (el) subRefs[id] = el
}
// ── Status / data ────────────────────────────────────────────────
function getStatusClass(agentId: string) {
const data = agentData[agentId]
const agent = localAgents[agentId]
if (!agent?.enabled) return 'disabled'
if (!data) return 'idle'
return data.status === 'active' ? 'active' : 'idle'
}
function getAgentName(id: string) { return localAgents[id]?.name || DEFAULT_AGENTS.find(a => a.id === id)?.name || id.toUpperCase() }
function getAgentRole(id: string) { return localAgents[id]?.role || DEFAULT_AGENTS.find(a => a.id === id)?.role || '' }
function getAgentDesc(id: string) { return localAgents[id]?.description || DEFAULT_AGENTS.find(a => a.id === id)?.description || '' }
// ── Actions ───────────────────────────────────────────────────────
function selectAgent(id: string) {
const agent = localAgents[id]
if (!agent) return
selectedAgentId.value = id
editAgent.value = { name: agent.name, role: agent.role, description: agent.description, systemPrompt: agent.systemPrompt, enabled: agent.enabled }
drawerOpen.value = true
}
function resetConfig() {
const original = DEFAULT_AGENTS.find(a => a.id === selectedAgentId.value)
if (original && editAgent.value) Object.assign(editAgent.value, { name: original.name, role: original.role, description: original.description, systemPrompt: original.systemPrompt, enabled: original.enabled })
}
async function saveConfig() {
if (!editAgent.value || !selectedAgentId.value) return
saving.value = true
try {
if (localAgents[selectedAgentId.value]) Object.assign(localAgents[selectedAgentId.value], editAgent.value)
try {
await agentApi.updateConfig(selectedAgentId.value, { name: editAgent.value!.name, description: editAgent.value!.description, system_prompt: editAgent.value!.systemPrompt, enabled: editAgent.value!.enabled })
} catch { /* local only */ }
drawerOpen.value = false
} finally { saving.value = false }
}
function addAgent() {
if (!newAgent.name || !newAgent.roleKey) return
const id = newAgent.roleKey.toLowerCase().replace(/\s+/g, '_')
if (localAgents[id]) return
localAgents[id] = { id, name: newAgent.name.toUpperCase(), role: newAgent.role, roleKey: id, description: newAgent.description, systemPrompt: newAgent.systemPrompt, enabled: true }
addModalOpen.value = false
}
async function refreshStats() {
loading.value = true
try {
const stats = await agentApi.getStats()
for (const s of stats) {
agentData[s.agent_id] = { callCount: s.call_count, currentTask: s.current_task, status: s.status }
if (s.status === 'active' && s.agent_id !== 'master') { firingLine.value = s.agent_id; activeLine.value = s.agent_id }
}
connectionStatus.value = 'connected'
} catch {
connectionStatus.value = 'disconnected'
agentData['master'] = { callCount: 47, currentTask: '协调子Agent工作', status: 'active' }
agentData['planner'] = { callCount: 12, currentTask: null, status: 'idle' }
agentData['executor'] = { callCount: 8, currentTask: '创建文档', status: 'active' }
agentData['librarian'] = { callCount: 5, currentTask: null, status: 'idle' }
agentData['analyst'] = { callCount: 3, currentTask: null, status: 'idle' }
firingLine.value = 'executor'; activeLine.value = 'executor'
}
loading.value = false
}
function onPulseEnd() { firingLine.value = null; activeLine.value = null }
// ── Motion helpers ───────────────────────────────────────────────
function animateIn(el: Element) { animate(el, { opacity: [0, 1], x: [80, 0] }, { duration: 0.35, easing: [0.4, 0, 0.2, 1] }).play() }
function animateOut(el: Element) { animate(el, { opacity: [1, 0], x: [0, 80] }, { duration: 0.25, easing: [0.4, 0, 1, 1] }).play() }
function fadeIn(el: Element) { animate(el, { opacity: [0, 1] }, { duration: 0.25 }).play() }
function fadeOut(el: Element) { animate(el, { opacity: [1, 0] }, { duration: 0.2 }).play() }
function playEntranceAnimations() {
if (masterCardRef.value) {
masterAnim.value = animate(masterCardRef.value, { opacity: [0, 1], y: [20, 0] }, { duration: 0.6, easing: [0.16, 1, 0.3, 1] })
}
subAgents.forEach((sub, idx) => {
const el = subRefs[sub.id]
if (!el) return
const anim = animate(el, { opacity: [0, 1], y: [20, 0] }, { duration: 0.5, delay: 0.15 + idx * 0.1, easing: [0.16, 1, 0.3, 1] })
subAnims[sub.id] = anim
const hoverAnim = animate(el, { y: [0, -4] }, { duration: 0.2, easing: [0.34, 1.56, 0.64, 1] })
hoverAnim.pause()
hoverAnims[sub.id] = hoverAnim
el.addEventListener('mouseenter', () => {
if (!localAgents[sub.id]?.enabled) return
hoverAnim.direction = 'forward'; hoverAnim.play()
})
el.addEventListener('mouseleave', () => {
hoverAnim.direction = 'reverse'; hoverAnim.play()
})
})
}
// ── Lifecycle ────────────────────────────────────────────────────
onMounted(async () => {
await refreshStats()
pollInterval = setInterval(refreshStats, 5000)
requestAnimationFrame(() => {
updateSvgSize()
playEntranceAnimations()
})
resizeObserver = new ResizeObserver(() => {
updateSvgSize()
})
if (canvasRef.value) resizeObserver.observe(canvasRef.value)
})
onUnmounted(() => {
if (pollInterval) clearInterval(pollInterval)
resizeObserver?.disconnect()
masterAnim.value?.stop()
Object.values(subAnims).forEach(a => a.stop())
Object.values(hoverAnims).forEach(a => a?.stop())
})
</script>
<style scoped>
.agent-view {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
background: var(--bg-void);
}
.bg-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(0,245,212,0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,245,212,0.04) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
z-index: 0;
}
.bg-glow {
position: absolute;
inset: 0;
background: radial-gradient(ellipse 80% 60% at 50% 40%, rgba(0,245,212,0.05) 0%, transparent 70%);
pointer-events: none;
z-index: 0;
}
.bg-particles {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 0;
overflow: hidden;
}
.bg-particle {
position: absolute;
border-radius: 50%;
background: var(--accent-cyan);
box-shadow: 0 0 4px rgba(0,245,212,0.6), 0 0 8px rgba(0,245,212,0.2);
animation: star-twinkle var(--d, 4s) ease-in-out infinite var(--delay, 0s);
}
@keyframes star-twinkle {
0%, 100% { opacity: var(--o, 0.4); transform: scale(1); }
50% { opacity: calc(var(--o, 0.4) * 0.3); transform: scale(0.5); }
}
/* Header */
.view-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 24px;
border-bottom: 1px solid var(--border-dim);
position: relative;
z-index: 10;
background: rgba(5,8,16,0.6);
backdrop-filter: blur(8px);
}
.header-title { font-family: var(--font-display); font-size: 13px; letter-spacing: 0.2em; color: var(--text-primary); }
.title-bracket { color: var(--accent-cyan); opacity: 0.6; }
.header-actions { display: flex; align-items: center; gap: 12px; }
.btn-icon {
width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;
background: var(--bg-card); border: 1px solid var(--border-mid); border-radius: var(--radius-sm);
color: var(--text-secondary); cursor: pointer; transition: all var(--transition-fast);
}
.btn-icon:hover { border-color: var(--accent-cyan); color: var(--accent-cyan); box-shadow: var(--glow-cyan); }
.btn-icon.spinning svg { animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.btn-add {
display: flex; align-items: center; gap: 6px; padding: 6px 14px;
background: rgba(0,245,212,0.08); border: 1px solid rgba(0,245,212,0.3);
border-radius: var(--radius-sm); color: var(--accent-cyan); font-family: var(--font-mono);
font-size: 11px; letter-spacing: 0.1em; cursor: pointer; transition: all var(--transition-fast);
}
.btn-add:hover { background: rgba(0,245,212,0.15); border-color: var(--accent-cyan); box-shadow: var(--glow-cyan); }
.status-bar { display: flex; align-items: center; gap: 6px; font-size: 10px; color: var(--text-dim); letter-spacing: 0.1em; }
.status-dot { width: 6px; height: 6px; border-radius: 50%; }
.status-dot.connected { background: var(--accent-cyan); box-shadow: 0 0 6px var(--accent-cyan); }
.status-dot.disconnected { background: var(--text-dim); }
/* Canvas */
.nodes-canvas {
flex: 1;
position: relative;
overflow: hidden;
}
.conn-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1;
}
.conn-path {
fill: none;
stroke: rgba(0,245,212,0.25);
stroke-width: 1.5;
stroke-dasharray: 5 5;
animation: dash-flow 4s linear infinite;
}
@keyframes dash-flow { to { stroke-dashoffset: -30; } }
.conn-path.active {
stroke: var(--accent-amber);
stroke-opacity: 0.7;
stroke-dasharray: none;
filter: url(#lineGlow);
animation: none;
}
.pulse-dot { fill: var(--accent-amber); filter: drop-shadow(0 0 8px var(--accent-amber)); }
/* Node Cards */
.node-card {
position: absolute;
height: 170px;
z-index: 2;
cursor: pointer;
}
.node-sub.disabled { opacity: 0.35; cursor: not-allowed; }
.node-inner {
width: 100%;
height: 100%;
background: rgba(13,21,37,0.92);
border: 1px solid rgba(0,245,212,0.2);
border-radius: var(--radius-md);
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 3px;
position: relative;
overflow: hidden;
backdrop-filter: blur(12px);
transition: border-color 0.2s, box-shadow 0.2s;
}
.node-master .node-inner {
background: linear-gradient(135deg, rgba(0,245,212,0.06) 0%, rgba(13,21,37,0.95) 100%);
border-color: rgba(0,245,212,0.35);
}
.node-card:hover .node-inner {
border-color: rgba(0,245,212,0.5);
box-shadow: 0 8px 32px rgba(0,245,212,0.15), 0 0 0 1px rgba(0,245,212,0.1);
}
.node-card.selected .node-inner {
border-color: var(--accent-cyan);
box-shadow: 0 0 0 2px rgba(0,245,212,0.3), var(--glow-cyan);
}
.node-corner { position: absolute; width: 10px; height: 10px; opacity: 0.6; }
.node-corner.tl { top: 6px; left: 6px; border-top: 1.5px solid var(--accent-cyan); border-left: 1.5px solid var(--accent-cyan); }
.node-corner.tr { top: 6px; right: 6px; border-top: 1.5px solid var(--accent-cyan); border-right: 1.5px solid var(--accent-cyan); }
.node-corner.bl { bottom: 6px; left: 6px; border-bottom: 1.5px solid var(--accent-cyan); border-left: 1.5px solid var(--accent-cyan); }
.node-corner.br { bottom: 6px; right: 6px; border-bottom: 1.5px solid var(--accent-cyan); border-right: 1.5px solid var(--accent-cyan); }
.node-status { position: absolute; top: 10px; right: 10px; width: 10px; height: 10px; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
.status-ring { width: 8px; height: 8px; border-radius: 50%; }
.node-status.active .status-ring { background: var(--accent-cyan); box-shadow: 0 0 8px var(--accent-cyan); animation: status-pulse 1.5s ease-in-out infinite; }
.node-status.idle .status-ring { background: var(--text-secondary); }
.node-status.disabled .status-ring { background: var(--text-dim); opacity: 0.4; }
@keyframes status-pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
.node-label { font-family: var(--font-display); font-size: 8px; letter-spacing: 0.2em; color: var(--text-dim); margin-bottom: 1px; }
.node-master .node-label { color: rgba(0,245,212,0.5); }
.node-name { font-family: var(--font-display); font-size: 15px; font-weight: 700; letter-spacing: 0.08em; color: var(--accent-cyan); line-height: 1.2; }
.node-master .node-name { font-size: 18px; }
.node-role { font-family: var(--font-mono); font-size: 10px; color: var(--accent-amber); letter-spacing: 0.05em; }
.node-desc {
font-family: var(--font-mono); font-size: 10px; color: var(--text-secondary);
line-height: 1.5; flex: 1; overflow: hidden; display: -webkit-box;
-webkit-line-clamp: 3; -webkit-box-orient: vertical; text-overflow: ellipsis;
}
.node-footer { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 2px; }
.node-stat { display: flex; align-items: center; gap: 4px; font-family: var(--font-mono); font-size: 9px; }
.stat-label { color: var(--text-dim); }
.stat-val { color: var(--accent-cyan); font-weight: 600; }
.node-task-tag {
font-family: var(--font-mono); font-size: 9px; color: var(--accent-amber);
background: rgba(249,168,37,0.1); border: 1px solid rgba(249,168,37,0.2);
border-radius: 3px; padding: 1px 6px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 120px;
}
.node-idle { font-family: var(--font-mono); font-size: 9px; color: var(--text-dim); font-style: italic; }
.rel-label {
position: absolute; font-family: var(--font-mono); font-size: 8px; color: var(--text-dim);
letter-spacing: 0.05em; pointer-events: none; left: 50%; transform: translateX(-50%);
bottom: -20px; white-space: nowrap;
}
.particle { position: absolute; border-radius: 50%; background: var(--accent-cyan); pointer-events: none; }
/* Drawer */
.config-drawer {
position: fixed; top: 0; right: 0; width: 420px; height: 100%;
background: rgba(5,8,16,0.97); border-left: 1px solid var(--border-mid);
backdrop-filter: blur(20px); z-index: 100; display: flex; flex-direction: column;
box-shadow: -10px 0 40px rgba(0,0,0,0.5);
}
.drawer-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 99; }
.drawer-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border-dim); }
.drawer-title { font-family: var(--font-display); font-size: 11px; letter-spacing: 0.15em; color: var(--accent-cyan); }
.btn-close {
width: 28px; height: 28px; display: flex; align-items: center; justify-content: center;
background: transparent; border: 1px solid var(--border-dim); border-radius: var(--radius-sm);
color: var(--text-dim); cursor: pointer; transition: all var(--transition-fast);
}
.btn-close:hover { border-color: var(--accent-red); color: var(--accent-red); }
.drawer-body { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 16px; }
.drawer-body::-webkit-scrollbar { width: 4px; }
.drawer-body::-webkit-scrollbar-thumb { background: var(--border-mid); border-radius: 2px; }
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-group.flex-1 { flex: 1; display: flex; flex-direction: column; }
.form-label { font-family: var(--font-mono); font-size: 9px; letter-spacing: 0.15em; color: var(--text-dim); }
.form-input {
background: var(--bg-card); border: 1px solid var(--border-mid); border-radius: var(--radius-sm);
padding: 10px 12px; color: var(--text-primary); font-family: var(--font-mono); font-size: 12px; outline: none;
transition: border-color var(--transition-fast);
}
.form-input:focus { border-color: var(--accent-cyan); box-shadow: 0 0 0 1px rgba(0,245,212,.1); }
.form-textarea {
background: var(--bg-card); border: 1px solid var(--border-mid); border-radius: var(--radius-sm);
padding: 10px 12px; color: var(--text-primary); font-family: var(--font-mono); font-size: 11px;
outline: none; resize: none; line-height: 1.5; transition: border-color var(--transition-fast);
}
.form-textarea:focus { border-color: var(--accent-cyan); box-shadow: 0 0 0 1px rgba(0,245,212,.1); }
.code-textarea { font-size: 10px; flex: 1; }
.toggle-row { display: flex; align-items: center; gap: 12px; }
.toggle-label { font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.1em; color: var(--accent-cyan); transition: color .2s; }
.toggle-label.dim { color: var(--text-dim); }
.toggle-btn { width: 44px; height: 22px; background: var(--bg-card); border: 1px solid var(--border-mid); border-radius: 11px; padding: 2px; cursor: pointer; transition: all .25s; }
.toggle-btn.active { background: rgba(0,245,212,.15); border-color: var(--accent-cyan); }
.toggle-knob { display: block; width: 16px; height: 16px; border-radius: 50%; background: var(--text-dim); transition: all .25s; }
.toggle-btn.active .toggle-knob { background: var(--accent-cyan); box-shadow: 0 0 8px var(--accent-cyan); transform: translateX(22px); }
.drawer-actions { display: flex; gap: 12px; padding-top: 8px; }
.btn-secondary,.btn-primary {
flex: 1; padding: 10px 16px; border-radius: var(--radius-sm); font-family: var(--font-mono);
font-size: 11px; letter-spacing: 0.1em; cursor: pointer; transition: all var(--transition-fast);
display: flex; align-items: center; justify-content: center; gap: 6px;
}
.btn-secondary { background: transparent; border: 1px solid var(--border-mid); color: var(--text-secondary); }
.btn-secondary:hover { border-color: var(--accent-cyan); color: var(--accent-cyan); }
.btn-primary { background: rgba(0,245,212,.1); border: 1px solid var(--accent-cyan); color: var(--accent-cyan); }
.btn-primary:hover { background: rgba(0,245,212,.2); box-shadow: var(--glow-cyan); }
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-loader { width: 12px; height: 12px; border: 1.5px solid transparent; border-top-color: var(--accent-cyan); border-radius: 50%; animation: spin .6s linear infinite; }
/* Modal */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.7); backdrop-filter: blur(4px);
z-index: 200; display: flex; align-items: center; justify-content: center;
}
.modal-card {
width: 480px; max-height: 80vh; background: rgba(10,15,26,.98); border: 1px solid var(--border-mid);
border-radius: var(--radius-lg); display: flex; flex-direction: column;
box-shadow: 0 20px 60px rgba(0,0,0,.6), 0 0 0 1px rgba(0,245,212,.05);
}
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid var(--border-dim); }
.modal-title { font-family: var(--font-display); font-size: 11px; letter-spacing: 0.15em; color: var(--accent-cyan); }
.modal-body { flex: 1; overflow-y: auto; padding: 20px; display: flex; flex-direction: column; gap: 14px; }
.modal-body::-webkit-scrollbar { width: 4px; }
.modal-body::-webkit-scrollbar-thumb { background: var(--border-mid); border-radius: 2px; }
.modal-footer { display: flex; gap: 12px; padding: 16px 20px; border-top: 1px solid var(--border-dim); }
</style>

View File

@@ -0,0 +1,924 @@
<script setup lang="ts">
import { ref, onMounted, nextTick, watch } from 'vue'
import { useConversationStore } from '@/stores/conversation'
import { conversationApi } from '@/api/conversation'
import { documentApi } from '@/api/document'
import { MessageCircle, Trash2, Send, Sparkles, CornerDownLeft, Paperclip, Smile } from 'lucide-vue-next'
import EmojiPicker from '@/components/chat/EmojiPicker.vue'
import FileMessage from '@/components/chat/FileMessage.vue'
const store = useConversationStore()
const inputMessage = ref('')
const isSending = ref(false)
const chatContainer = ref<HTMLElement>()
const inputRef = ref<HTMLTextAreaElement>()
const isTyping = ref(false)
const fileInputRef = ref<HTMLInputElement>()
const showEmojiPicker = ref(false)
const selectedFiles = ref<{ id: string; name: string; type: string; size: number }[]>([])
async function sendMessage() {
if (!inputMessage.value.trim() || isSending.value) return
isSending.value = true
isTyping.value = true
const text = inputMessage.value.trim()
inputMessage.value = ''
store.addMessage({
id: `temp-${Date.now()}`,
role: 'user',
content: text,
created_at: new Date().toISOString(),
})
await nextTick()
scrollToBottom()
try {
const response = await conversationApi.chat(text, store.currentConversationId || undefined, selectedFiles.value.map(f => f.id))
selectedFiles.value = []
isTyping.value = false
store.addMessage({
id: response.data.message_id,
role: 'assistant',
content: response.data.content,
model: response.data.agent_name,
created_at: new Date().toISOString(),
})
if (!store.currentConversationId) {
store.setCurrentConversation(response.data.conversation_id)
await loadConversations()
}
} catch (e) {
isTyping.value = false
console.error('发送失败:', e)
store.addMessage({
id: `err-${Date.now()}`,
role: 'assistant',
content: '抱歉,连接失败。请检查服务状态。',
created_at: new Date().toISOString(),
})
}
isSending.value = false
await nextTick()
scrollToBottom()
}
async function loadConversations() {
try {
const response = await conversationApi.list()
store.setConversations(response.data)
} catch (e) {
console.error('加载对话列表失败:', e)
}
}
async function selectConversation(id: string) {
store.setCurrentConversation(id)
store.setMessages([])
try {
const response = await conversationApi.getMessages(id)
store.setMessages(response.data)
await nextTick()
scrollToBottom()
} catch (e) {
console.error('加载消息失败:', e)
}
}
async function newConversation() {
store.setCurrentConversation('')
store.setMessages([])
inputRef.value?.focus()
}
async function deleteConversation(id: string, e: Event) {
e.stopPropagation()
try {
await conversationApi.delete(id)
store.removeConversation(id)
if (store.currentConversationId === id) {
store.setCurrentConversation('')
store.setMessages([])
}
} catch (e) {
console.error('删除失败:', e)
}
}
function scrollToBottom() {
if (chatContainer.value) {
chatContainer.value.scrollTop = chatContainer.value.scrollHeight
}
}
function formatTime(dateStr: string) {
const d = new Date(dateStr)
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
function formatConvDate(dateStr: string) {
const d = new Date(dateStr)
const now = new Date()
const diff = now.getTime() - d.getTime()
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
if (diff < 86400000) return '今天'
return d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
}
function autoResize(e: Event) {
const el = e.target as HTMLTextAreaElement
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 120) + 'px'
}
async function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement
if (!input.files?.length) return
for (const file of input.files) {
if (file.size > 10 * 1024 * 1024) {
alert(`文件 ${file.name} 超过10MB限制`)
continue
}
try {
const response = await documentApi.upload(file)
selectedFiles.value.push({
id: response.data.id,
name: file.name,
type: file.type,
size: file.size,
})
} catch (e) {
console.error('上传失败:', e)
alert(`文件 ${file.name} 上传失败`)
}
}
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
function insertEmoji(emoji: string) {
inputMessage.value += emoji
showEmojiPicker.value = false
}
function openFilePicker() {
fileInputRef.value?.click()
}
onMounted(() => {
loadConversations()
inputRef.value?.focus()
})
</script>
<template>
<div class="chat-view">
<!-- Conversation list sidebar -->
<aside class="conv-sidebar">
<div class="conv-sidebar-header">
<div class="section-label">// SESSIONS</div>
<button class="new-chat-btn" @click="newConversation">
<span class="btn-line"></span>
<MessageCircle :size="14" />
<span>NEW SESSION</span>
</button>
</div>
<div class="conv-list">
<div
v-for="conv in store.conversations"
:key="conv.id"
class="conv-item"
:class="{ active: conv.id === store.currentConversationId }"
@click="selectConversation(conv.id)"
>
<div class="conv-item-icon">
<MessageCircle :size="12" />
</div>
<div class="conv-item-body">
<div class="conv-title">{{ conv.title || 'New Conversation' }}</div>
<div class="conv-date">{{ formatConvDate(conv.created_at) }}</div>
</div>
<button class="conv-delete" @click="deleteConversation(conv.id, $event)">
<Trash2 :size="12" />
</button>
<div class="conv-active-line"></div>
</div>
<div v-if="store.conversations.length === 0" class="conv-empty">
<div class="empty-icon"><MessageCircle :size="24" /></div>
<div class="empty-text">No sessions yet</div>
<div class="empty-hint">Start a new conversation</div>
</div>
</div>
</aside>
<!-- Chat area -->
<section class="chat-area">
<!-- Top bar -->
<div class="chat-topbar">
<div class="chat-status">
<div class="status-indicator" :class="{ active: !isSending }"></div>
<span class="status-text">{{ isTyping ? 'PROCESSING...' : 'READY' }}</span>
</div>
<div class="chat-model" v-if="store.messages.length > 0">
<Sparkles :size="12" />
<span>JARVIS v2.0</span>
</div>
</div>
<!-- Messages -->
<div ref="chatContainer" class="messages-area">
<!-- Welcome screen -->
<div v-if="store.messages.length === 0" class="welcome-screen">
<div class="welcome-icon">
<div class="welcome-ring r1"></div>
<div class="welcome-ring r2"></div>
<div class="welcome-ring r3"></div>
<div class="welcome-core">
<Sparkles :size="28" />
</div>
</div>
<div class="welcome-title">JARVIS</div>
<div class="welcome-sub">Personal AI Assistant</div>
<div class="welcome-hint">有什么我可以帮你的</div>
</div>
<!-- Message bubbles -->
<div
v-for="(msg, i) in store.messages"
:key="msg.id"
class="message-row"
:class="msg.role"
:style="{ animationDelay: `${i * 30}ms` }"
>
<div class="msg-avatar">
<span v-if="msg.role === 'user'">{{ '>' }}</span>
<span v-else class="ai-icon">J</span>
</div>
<div class="msg-content">
<div class="msg-meta">
<span class="msg-role">{{ msg.role === 'user' ? 'YOU' : 'JARVIS' }}</span>
<span class="msg-time">{{ formatTime(msg.created_at) }}</span>
</div>
<div class="msg-bubble">{{ msg.content }}</div>
<div v-if="msg.role === 'user' && msg.attachments?.length" class="msg-attachments">
<FileMessage
v-for="att in msg.attachments"
:key="att.id"
:file="att"
/>
</div>
</div>
</div>
<!-- Typing indicator -->
<div v-if="isTyping" class="message-row assistant typing-row">
<div class="msg-avatar"><span class="ai-icon">J</span></div>
<div class="msg-content">
<div class="msg-meta">
<span class="msg-role">JARVIS</span>
</div>
<div class="msg-bubble typing">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
</div>
</div>
</div>
<!-- Input area -->
<div class="input-area">
<div class="input-frame">
<div class="input-corners tl"></div>
<div class="input-corners tr"></div>
<div class="input-corners bl"></div>
<div class="input-corners br"></div>
<textarea
ref="inputRef"
v-model="inputMessage"
placeholder="输入指令,按 Enter 发送..."
:disabled="isSending"
rows="1"
@keydown.enter.exact.prevent="sendMessage"
@input="autoResize"
></textarea>
<input
ref="fileInputRef"
type="file"
multiple
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt"
style="display: none"
@change="handleFileSelect"
/>
<button class="attach-btn" @click="openFilePicker" title="上传文件">
<Paperclip :size="15" />
</button>
<div class="emoji-wrapper">
<button
class="emoji-btn"
:class="{ active: showEmojiPicker }"
@click="showEmojiPicker = !showEmojiPicker"
title="表情包"
>
<Smile :size="15" />
</button>
<EmojiPicker
:visible="showEmojiPicker"
@select="insertEmoji"
@close="showEmojiPicker = false"
/>
</div>
<button
class="send-btn"
:class="{ active: inputMessage.trim() }"
:disabled="!inputMessage.trim() || isSending"
@click="sendMessage"
>
<Send :size="15" />
<CornerDownLeft :size="12" class="enter-hint" />
</button>
</div>
<div class="input-hints">
<span class="hint-item">ENTER 发送</span>
<span class="hint-sep">|</span>
<span class="hint-item">SHIFT+ENTER 换行</span>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.chat-view {
display: flex;
height: 100%;
overflow: hidden;
}
/* ── Conversation Sidebar ── */
.conv-sidebar {
width: 200px;
min-width: 200px;
background: var(--bg-panel);
border-right: 1px solid var(--border-dim);
display: flex;
flex-direction: column;
overflow: hidden;
}
.conv-sidebar-header {
padding: 16px 14px 12px;
border-bottom: 1px solid var(--border-dim);
}
.section-label {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.15em;
color: var(--text-dim);
margin-bottom: 10px;
}
.new-chat-btn {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
background: var(--accent-cyan-dim);
border: 1px solid var(--border-mid);
border-radius: var(--radius-md);
color: var(--accent-cyan);
font-size: 10px;
letter-spacing: 0.1em;
font-weight: 600;
transition: all var(--transition-fast);
position: relative;
overflow: hidden;
}
.new-chat-btn::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(0,245,212,0.1), transparent);
transform: translateX(-100%);
transition: transform 0.4s;
}
.new-chat-btn:hover::before { transform: translateX(100%); }
.new-chat-btn:hover {
background: rgba(0, 245, 212, 0.18);
box-shadow: var(--glow-cyan);
}
.btn-line {
width: 1px;
height: 12px;
background: var(--accent-cyan);
opacity: 0.6;
}
.conv-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.conv-item {
position: relative;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: var(--radius-md);
cursor: pointer;
margin-bottom: 2px;
border: 1px solid transparent;
transition: all var(--transition-fast);
overflow: hidden;
}
.conv-item:hover {
background: rgba(0, 245, 212, 0.04);
border-color: var(--border-dim);
}
.conv-item.active {
background: var(--accent-cyan-dim);
border-color: var(--border-mid);
}
.conv-active-line {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
background: var(--accent-cyan);
opacity: 0;
transition: opacity var(--transition-fast);
box-shadow: 0 0 6px var(--accent-cyan);
}
.conv-item.active .conv-active-line { opacity: 1; }
.conv-item-icon {
color: var(--text-dim);
flex-shrink: 0;
}
.conv-item.active .conv-item-icon { color: var(--accent-cyan); }
.conv-item-body { flex: 1; min-width: 0; }
.conv-title {
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
}
.conv-item.active .conv-title { color: var(--accent-cyan); }
.conv-date {
font-size: 9px;
color: var(--text-dim);
font-family: var(--font-mono);
}
.conv-delete {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
padding: 3px;
opacity: 0;
transition: all var(--transition-fast);
border-radius: 3px;
}
.conv-item:hover .conv-delete { opacity: 1; }
.conv-delete:hover { color: var(--accent-red); background: rgba(255,71,87,0.1); }
.conv-empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 16px;
gap: 8px;
}
.empty-icon { color: var(--text-dim); }
.empty-text { font-size: 12px; color: var(--text-dim); }
.empty-hint { font-size: 10px; color: var(--text-muted); }
/* ── Chat Area ── */
.chat-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.chat-topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 24px;
border-bottom: 1px solid var(--border-dim);
background: rgba(5, 8, 16, 0.6);
backdrop-filter: blur(8px);
}
.chat-status {
display: flex;
align-items: center;
gap: 8px;
}
.status-indicator {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-dim);
}
.status-indicator.active {
background: var(--accent-green);
box-shadow: 0 0 8px var(--accent-green);
animation: pulse-glow 1.5s ease-in-out infinite;
}
.status-text {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.15em;
color: var(--text-dim);
}
.chat-model {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-display);
font-size: 9px;
letter-spacing: 0.1em;
color: var(--accent-amber);
padding: 3px 10px;
border: 1px solid rgba(249, 168, 37, 0.2);
border-radius: 20px;
background: var(--accent-amber-dim);
}
/* ── Messages ── */
.messages-area {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 4px;
}
/* Welcome screen */
.welcome-screen {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding-bottom: 80px;
}
.welcome-icon {
position: relative;
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
color: var(--accent-cyan);
margin-bottom: 8px;
}
.welcome-ring {
position: absolute;
border-radius: 50%;
border: 1px solid var(--accent-cyan);
animation: spin linear infinite;
}
.r1 { width: 80px; height: 80px; opacity: 0.3; animation-duration: 8s; border-style: dashed; }
.r2 { width: 60px; height: 60px; opacity: 0.5; animation-duration: 5s; animation-direction: reverse; }
.r3 { width: 40px; height: 40px; opacity: 0.7; animation-duration: 3s; }
.welcome-core {
position: relative;
z-index: 1;
filter: drop-shadow(0 0 12px var(--accent-cyan));
animation: pulse-glow 2s ease-in-out infinite;
}
.welcome-title {
font-family: var(--font-display);
font-size: 32px;
font-weight: 800;
letter-spacing: 0.2em;
color: var(--accent-cyan);
text-shadow: var(--glow-cyan);
}
.welcome-sub {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.3em;
color: var(--text-dim);
text-transform: uppercase;
}
.welcome-hint {
font-size: 13px;
color: var(--text-dim);
margin-top: 20px;
}
/* Message rows */
.message-row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 8px 0;
animation: fade-in-up 0.3s ease both;
}
.msg-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-size: 12px;
font-weight: 700;
flex-shrink: 0;
margin-top: 4px;
}
.user .msg-avatar {
background: rgba(0, 245, 212, 0.1);
border: 1px solid var(--border-mid);
color: var(--accent-cyan);
}
.assistant .msg-avatar {
background: linear-gradient(135deg, var(--accent-cyan-dim), var(--accent-purple-dim));
border: 1px solid var(--border-bright);
color: var(--accent-cyan);
box-shadow: 0 0 10px var(--accent-cyan-glow);
}
.msg-content {
flex: 1;
min-width: 0;
max-width: 75%;
}
.msg-meta {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 4px;
}
.msg-role {
font-family: var(--font-display);
font-size: 9px;
letter-spacing: 0.15em;
color: var(--text-dim);
}
.msg-time {
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-muted);
}
.msg-bubble {
display: inline-block;
padding: 10px 16px;
border-radius: var(--radius-md);
font-size: 13px;
line-height: 1.65;
word-break: break-word;
white-space: pre-wrap;
}
.user .msg-bubble {
background: var(--accent-cyan-dim);
border: 1px solid rgba(0, 245, 212, 0.2);
border-radius: var(--radius-md) var(--radius-md) 4px var(--radius-md);
color: var(--text-primary);
}
.assistant .msg-bubble {
background: rgba(13, 21, 37, 0.8);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md) var(--radius-md) var(--radius-md) 4px;
color: var(--text-secondary);
backdrop-filter: blur(4px);
}
/* Typing indicator */
.typing .dot {
display: inline-block;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent-cyan);
margin: 0 2px;
animation: typing-bounce 1.2s ease-in-out infinite;
}
.typing .dot:nth-child(2) { animation-delay: 0.2s; }
.typing .dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing-bounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-4px); opacity: 1; }
}
/* ── Input Area ── */
.input-area {
padding: 16px 24px 20px;
border-top: 1px solid var(--border-dim);
background: rgba(5, 8, 16, 0.8);
backdrop-filter: blur(12px);
}
.input-frame {
position: relative;
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-lg);
padding: 12px 16px;
display: flex;
align-items: flex-end;
gap: 12px;
transition: all var(--transition-mid);
}
.input-frame:focus-within {
border-color: var(--accent-cyan);
box-shadow: 0 0 0 1px rgba(0,245,212,0.1), var(--glow-cyan);
}
/* Corner accents */
.input-corners {
position: absolute;
width: 8px;
height: 8px;
pointer-events: none;
}
.input-corners::before,
.input-corners::after {
content: '';
position: absolute;
background: var(--accent-cyan);
}
.input-corners::before { width: 100%; height: 1px; }
.input-corners::after { width: 1px; height: 100%; }
.tl { top: -1px; left: -1px; }
.tr { top: -1px; right: -1px; transform: scaleX(-1); }
.bl { bottom: -1px; left: -1px; transform: scaleY(-1); }
.br { bottom: -1px; right: -1px; transform: scale(-1); }
.input-frame textarea {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.6;
resize: none;
max-height: 120px;
padding: 0;
}
.input-frame textarea::placeholder { color: var(--text-dim); }
.send-btn {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: var(--text-muted);
border: 1px solid transparent;
color: var(--text-dim);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
position: relative;
transition: all var(--transition-fast);
}
.send-btn.active {
background: var(--accent-cyan-dim);
border-color: var(--border-mid);
color: var(--accent-cyan);
}
.send-btn.active:hover {
background: rgba(0, 245, 212, 0.2);
box-shadow: var(--glow-cyan);
transform: scale(1.05);
}
.enter-hint { display: none; }
.input-hints {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
padding-left: 4px;
}
.hint-item {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.1em;
color: var(--text-muted);
}
.hint-sep {
color: var(--text-muted);
font-size: 9px;
}
/* File attachment button */
.attach-btn {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: transparent;
border: 1px solid transparent;
color: var(--text-dim);
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
}
.attach-btn:hover {
background: var(--accent-cyan-dim);
border-color: var(--border-mid);
color: var(--accent-cyan);
}
/* Emoji button */
.emoji-wrapper {
position: relative;
}
.emoji-btn {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: transparent;
border: 1px solid transparent;
color: var(--text-dim);
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
}
.emoji-btn:hover,
.emoji-btn.active {
background: var(--accent-cyan-dim);
border-color: var(--border-mid);
color: var(--accent-cyan);
}
/* Message attachments */
.msg-attachments {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
</style>

View File

@@ -0,0 +1,362 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { forumApi, type ForumPost } from '@/api/forum'
import { Plus, MessageSquare, CheckCircle, Send, X, Radio } from 'lucide-vue-next'
const posts = ref<ForumPost[]>([])
const showCreateForm = ref(false)
const newTitle = ref('')
const newContent = ref('')
const newCategory = ref('discussion')
const isPosting = ref(false)
async function loadPosts() {
try {
const response = await forumApi.listPosts()
posts.value = response.data
} catch (e) { console.error('加载帖子失败:', e) }
}
async function createPost() {
if (!newTitle.value.trim() || !newContent.value.trim()) return
isPosting.value = true
try {
const response = await forumApi.createPost({
title: newTitle.value.trim(),
content: newContent.value.trim(),
category: newCategory.value,
})
posts.value.unshift(response.data)
newTitle.value = ''
newContent.value = ''
showCreateForm.value = false
} catch (e) { console.error('创建失败:', e) }
isPosting.value = false
}
function formatDate(dateStr: string) {
const d = new Date(dateStr)
return d.toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
}
function getCategoryLabel(cat: string) {
const map: Record<string, { label: string; color: string }> = {
discussion: { label: 'DISCUSSION', color: 'var(--accent-cyan)' },
instruction: { label: 'INSTRUCTION', color: 'var(--accent-amber)' },
question: { label: 'QUESTION', color: 'var(--accent-green)' },
}
return map[cat] || map.discussion
}
onMounted(() => { loadPosts() })
</script>
<template>
<div class="forum-view">
<!-- Header -->
<div class="page-header">
<div class="header-left">
<div class="header-icon"><Radio :size="20" /></div>
<div class="header-text">
<h1>COMMAND FORUM</h1>
<span class="header-sub">{{ posts.length }} transmissions</span>
</div>
</div>
<button class="add-btn" @click="showCreateForm = true">
<Plus :size="14" />
NEW POST
</button>
</div>
<!-- Create form -->
<div v-if="showCreateForm" class="create-panel">
<div class="panel-header">
<span>// NEW TRANSMISSION</span>
<button class="close-btn" @click="showCreateForm = false"><X :size="14" /></button>
</div>
<div class="panel-body">
<input v-model="newTitle" placeholder="Transmission title..." class="title-input" />
<textarea v-model="newContent" placeholder="Message content..." rows="5" class="content-input"></textarea>
<div class="panel-footer">
<select v-model="newCategory" class="cat-select">
<option value="discussion">DISCUSSION</option>
<option value="instruction">INSTRUCTION</option>
<option value="question">QUESTION</option>
</select>
<button class="post-btn" @click="createPost" :disabled="isPosting">
<Send v-if="!isPosting" :size="13" />
{{ isPosting ? 'SENDING...' : 'TRANSMIT' }}
</button>
</div>
</div>
</div>
<!-- Posts -->
<div class="posts-list">
<div v-for="post in posts" :key="post.id" class="post-card">
<div class="post-left">
<div
class="post-cat-badge"
:style="{ color: getCategoryLabel(post.category).color, borderColor: getCategoryLabel(post.category).color + '40', background: getCategoryLabel(post.category).color + '10' }"
>
{{ getCategoryLabel(post.category).label }}
</div>
</div>
<div class="post-body">
<div class="post-title">{{ post.title }}</div>
<div class="post-content">{{ post.content }}</div>
<div class="post-footer">
<span class="post-date">{{ formatDate(post.created_at) }}</span>
<div class="post-stats">
<MessageSquare :size="11" />
<span>{{ post.reply_count }}</span>
<CheckCircle v-if="post.is_executed" :size="11" class="executed-icon" />
<span v-if="post.is_executed" class="executed-label">EXECUTED</span>
</div>
</div>
</div>
</div>
<div v-if="posts.length === 0" class="empty-state">
<Radio :size="32" />
<span>No transmissions yet</span>
<span class="empty-sub">Post an instruction or question</span>
</div>
</div>
</div>
</template>
<style scoped>
.forum-view {
height: 100%;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.header-left { display: flex; align-items: center; gap: 14px; }
.header-icon { color: var(--accent-purple); filter: drop-shadow(0 0 8px var(--accent-purple)); }
h1 {
font-family: var(--font-display);
font-size: 20px;
font-weight: 700;
letter-spacing: 0.15em;
color: var(--text-primary);
margin: 0;
}
.header-sub { font-family: var(--font-mono); font-size: 10px; color: var(--text-dim); letter-spacing: 0.1em; }
.add-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: var(--accent-purple-dim);
border: 1px solid rgba(123, 44, 191, 0.3);
border-radius: var(--radius-md);
color: #a855f7;
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.12em;
transition: all var(--transition-fast);
}
.add-btn:hover {
background: rgba(123, 44, 191, 0.2);
box-shadow: 0 0 16px rgba(123, 44, 191, 0.3);
}
/* Create panel */
.create-panel {
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-lg);
overflow: hidden;
animation: fade-in-up 0.2s ease;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: rgba(123, 44, 191, 0.06);
border-bottom: 1px solid var(--border-dim);
font-family: var(--font-display);
font-size: 9px;
letter-spacing: 0.15em;
color: #a855f7;
}
.close-btn {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
padding: 2px;
border-radius: 3px;
transition: all var(--transition-fast);
}
.close-btn:hover { color: var(--accent-red); }
.panel-body { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
.title-input, .content-input {
width: 100%;
padding: 10px 14px;
background: var(--bg-panel);
border: 1px solid var(--border-mid);
border-radius: var(--radius-md);
font-size: 13px;
color: var(--text-primary);
box-sizing: border-box;
}
.title-input:focus, .content-input:focus {
border-color: #a855f7;
box-shadow: 0 0 0 1px rgba(123,44,191,0.1), 0 0 12px rgba(123,44,191,0.15);
}
.content-input { resize: vertical; min-height: 100px; }
.panel-footer {
display: flex;
align-items: center;
gap: 10px;
}
.cat-select {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.1em;
padding: 8px 12px;
background: var(--bg-panel);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
color: var(--text-secondary);
cursor: pointer;
}
.post-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 10px 20px;
background: var(--accent-purple-dim);
border: 1px solid rgba(123, 44, 191, 0.3);
border-radius: var(--radius-md);
color: #a855f7;
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.12em;
transition: all var(--transition-fast);
}
.post-btn:hover:not(:disabled) {
background: rgba(123, 44, 191, 0.2);
box-shadow: 0 0 12px rgba(123,44,191,0.3);
}
/* Posts */
.posts-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.post-card {
display: flex;
gap: 14px;
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-lg);
padding: 16px;
transition: all var(--transition-fast);
animation: fade-in-up 0.3s ease;
}
.post-card:hover {
border-color: var(--border-mid);
background: var(--bg-card-hover);
}
.post-left { display: flex; flex-direction: column; align-items: center; }
.post-cat-badge {
font-family: var(--font-display);
font-size: 8px;
letter-spacing: 0.1em;
padding: 3px 8px;
border: 1px solid;
border-radius: 4px;
white-space: nowrap;
}
.post-body { flex: 1; min-width: 0; }
.post-title {
font-size: 14px;
color: var(--text-primary);
margin-bottom: 6px;
font-weight: 500;
}
.post-content {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: 12px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.post-footer {
display: flex;
align-items: center;
justify-content: space-between;
}
.post-date {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
}
.post-stats {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
}
.executed-icon { color: var(--accent-green); }
.executed-label { color: var(--accent-green); }
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 60px;
gap: 8px;
color: var(--text-dim);
font-size: 13px;
}
.empty-sub { font-size: 11px; color: var(--text-muted); }
</style>

View File

@@ -0,0 +1,468 @@
<script setup lang="ts">
import { ref, onMounted, nextTick } from 'vue'
import { graphApi } from '@/api/graph'
import { Network, RefreshCw, Info, Maximize2, Hexagon } from 'lucide-vue-next'
import type { KGNode, KGEdge } from '@/api/graph'
const nodes = ref<KGNode[]>([])
const edges = ref<KGEdge[]>([])
const stats = ref({ node_count: 0, edge_count: 0 })
const isLoading = ref(false)
const selectedEntity = ref<KGNode | null>(null)
const entityContext = ref('')
const isBuilding = ref(false)
const chartRef = ref<HTMLDivElement>()
const typeColors: Record<string, string> = {
person: '#f87171', concept: '#60a5fa', topic: '#a78bfa',
task: '#fbbf24', event: '#fb923c', document: '#9ca3af', default: '#4b5563',
}
function getColor(type: string) { return typeColors[type] || typeColors.default }
async function loadGraph() {
isLoading.value = true
try {
const response = await graphApi.get()
nodes.value = response.data.nodes
edges.value = response.data.edges
stats.value = response.data.stats
await nextTick()
renderChart()
} catch (e) { console.error('加载图谱失败:', e) }
isLoading.value = false
}
async function buildGraph() {
isBuilding.value = true
try {
await graphApi.build()
setTimeout(() => loadGraph(), 2000)
} catch (e) { console.error('构建失败:', e) }
isBuilding.value = false
}
async function selectEntity(node: KGNode) {
selectedEntity.value = node
entityContext.value = 'LOADING...'
try {
const response = await graphApi.getEntityContext(node.name)
entityContext.value = response.data.context
} catch (e) { entityContext.value = 'Failed to load context' }
}
function renderChart() {
if (!chartRef.value) return
// @ts-ignore
if (!window.echarts) return
// @ts-ignore
const echarts = window.echarts
const chart = echarts.init(chartRef.value, 'dark')
const option = {
backgroundColor: 'transparent',
tooltip: {
trigger: 'item',
backgroundColor: 'rgba(10, 15, 26, 0.95)',
borderColor: 'rgba(0, 245, 212, 0.2)',
textStyle: { color: '#e8f4f8', fontFamily: 'JetBrains Mono, monospace', fontSize: 12 },
formatter: (params: any) => {
if (params.dataType === 'node') {
return `<b style="color:#00f5d4">${params.data.name}</b><br/><span style="color:#7eb8c9">Type: ${params.data.type || 'unknown'}</span>`
}
return `<span style="color:#7eb8c9">${params.data.sourceName}</span> → <span style="color:#a78bfa">${params.data.relation}</span> → <span style="color:#7eb8c9">${params.data.targetName}</span>`
},
},
series: [{
type: 'graph',
layout: 'force',
symbolSize: (_val: unknown, params: any) => 18 + (params.data.importance || 0.5) * 40,
roam: true,
label: {
show: true,
fontSize: 10,
color: '#7eb8c9',
fontFamily: 'JetBrains Mono, monospace',
formatter: (params: any) => params.data.name?.substring(0, 14) || '',
},
lineStyle: { color: 'rgba(0, 245, 212, 0.15)', width: 1.5 },
emphasis: {
focus: 'adjacency',
lineStyle: { width: 3, color: 'rgba(0, 245, 212, 0.5)' },
},
edgeSymbol: ['circle', 'arrow'],
edgeSymbolSize: [4, 8],
data: nodes.value.map(n => ({
id: n.id, name: n.name, type: n.type,
importance: n.importance || 0.5,
itemStyle: {
color: getColor(n.type),
borderColor: getColor(n.type),
borderWidth: 2,
shadowColor: getColor(n.type),
shadowBlur: 8,
},
})),
links: edges.value.map(e => {
const src = nodes.value.find(n => n.id === e.source)
const tgt = nodes.value.find(n => n.id === e.target)
return {
source: e.source, target: e.target,
sourceName: src?.name || '', targetName: tgt?.name || '',
relation: e.relation,
lineStyle: { color: 'rgba(0, 245, 212, 0.15)' },
}
}),
force: { repulsion: 150, gravity: 0.05, edgeLength: [60, 200], layoutAnimation: true },
}],
}
chart.setOption(option)
chart.on('click', (params: any) => {
if (params.dataType === 'node') {
const node = nodes.value.find(n => n.id === params.data.id)
if (node) selectEntity(node)
}
})
}
onMounted(() => {
loadGraph()
const script = document.createElement('script')
script.src = 'https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js'
script.onload = () => renderChart()
document.head.appendChild(script)
})
</script>
<template>
<div class="graph-view">
<!-- Header -->
<div class="page-header">
<div class="header-left">
<div class="header-icon"><Hexagon :size="20" /></div>
<div class="header-text">
<h1>KNOWLEDGE GRAPH</h1>
<span class="header-sub">{{ stats.node_count }} nodes · {{ stats.edge_count }} relations</span>
</div>
</div>
<button class="build-btn" @click="buildGraph" :disabled="isBuilding">
<RefreshCw :size="14" :class="{ spin: isBuilding }" />
{{ isBuilding ? 'BUILDING...' : 'REBUILD' }}
</button>
</div>
<!-- Type legend -->
<div class="type-legend">
<div v-for="(color, type) in typeColors" :key="type" class="legend-item">
<div class="legend-dot" :style="{ background: color, boxShadow: `0 0 6px ${color}` }"></div>
<span>{{ type.toUpperCase() }}</span>
</div>
</div>
<!-- Main area: chart + panel -->
<div class="main-area">
<div class="graph-container">
<div v-if="nodes.length === 0 && !isLoading" class="empty-state">
<div class="empty-icon">
<div class="e-ring r1"></div>
<div class="e-ring r2"></div>
<Network :size="32" />
</div>
<div class="empty-title">NO GRAPH DATA</div>
<div class="empty-sub">Upload documents and rebuild the graph</div>
</div>
<div ref="chartRef" v-show="nodes.length > 0" class="chart-canvas"></div>
</div>
<!-- Entity panel -->
<div class="entity-panel" v-if="selectedEntity">
<div class="panel-title">
<Info :size="14" />
<span>ENTITY DETAIL</span>
<button class="close-panel" @click="selectedEntity = null">×</button>
</div>
<div class="entity-name" :style="{ color: getColor(selectedEntity.type) }">
{{ selectedEntity.name }}
</div>
<div class="entity-type-tag" :style="{ color: getColor(selectedEntity.type), borderColor: getColor(selectedEntity.type) + '40' }">
{{ (selectedEntity.type || 'unknown').toUpperCase() }}
</div>
<div class="entity-context">{{ entityContext }}</div>
</div>
</div>
<!-- Relation list -->
<div class="relations-section" v-if="edges.length > 0">
<div class="section-label">// RELATIONS ({{ edges.length }})</div>
<div class="relations-scroll">
<div v-for="edge in edges" :key="edge.id" class="rel-item">
<span class="rel-node">{{ nodes.find(n => n.id === edge.source)?.name?.slice(0, 16) || edge.source.slice(0, 8) }}</span>
<div class="rel-arrow">
<div class="arrow-line"></div>
<span class="rel-type-badge">{{ edge.relation }}</span>
</div>
<span class="rel-node">{{ nodes.find(n => n.id === edge.target)?.name?.slice(0, 16) || edge.target.slice(0, 8) }}</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.graph-view {
height: 100%;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.header-left { display: flex; align-items: center; gap: 14px; }
.header-icon { color: var(--accent-cyan); filter: drop-shadow(0 0 8px var(--accent-cyan)); }
h1 {
font-family: var(--font-display);
font-size: 20px;
font-weight: 700;
letter-spacing: 0.15em;
color: var(--text-primary);
margin: 0;
}
.header-sub { font-family: var(--font-mono); font-size: 10px; color: var(--text-dim); letter-spacing: 0.1em; }
.build-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: var(--accent-cyan-dim);
border: 1px solid var(--border-mid);
border-radius: var(--radius-md);
color: var(--accent-cyan);
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.12em;
transition: all var(--transition-fast);
}
.build-btn:hover:not(:disabled) {
background: rgba(0, 245, 212, 0.2);
box-shadow: var(--glow-cyan);
}
.spin { animation: spin 1s linear infinite; }
.type-legend {
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.12em;
color: var(--text-dim);
}
.legend-dot {
width: 7px;
height: 7px;
border-radius: 50%;
}
.main-area {
display: flex;
gap: 16px;
flex: 1;
min-height: 450px;
}
.graph-container {
flex: 1;
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-lg);
overflow: hidden;
position: relative;
min-height: 400px;
}
.empty-state {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
}
.empty-icon {
position: relative;
color: var(--text-dim);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
}
.e-ring {
position: absolute;
border-radius: 50%;
border: 1px solid var(--accent-cyan);
opacity: 0.2;
}
.r1 { width: 60px; height: 60px; animation: spin 6s linear infinite; border-style: dashed; }
.r2 { width: 40px; height: 40px; animation: spin 4s linear infinite reverse; }
.empty-title {
font-family: var(--font-display);
font-size: 14px;
letter-spacing: 0.15em;
color: var(--text-dim);
}
.empty-sub {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
}
.chart-canvas {
width: 100%;
height: 100%;
min-height: 400px;
}
/* Entity panel */
.entity-panel {
width: 260px;
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-lg);
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
animation: fade-in-up 0.2s ease;
overflow-y: auto;
}
.panel-title {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-display);
font-size: 9px;
letter-spacing: 0.15em;
color: var(--accent-cyan);
margin-bottom: 4px;
}
.close-panel {
margin-left: auto;
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 0 2px;
}
.close-panel:hover { color: var(--accent-red); }
.entity-name {
font-family: var(--font-display);
font-size: 14px;
font-weight: 700;
letter-spacing: 0.05em;
}
.entity-type-tag {
font-family: var(--font-display);
font-size: 9px;
letter-spacing: 0.12em;
padding: 2px 8px;
border: 1px solid;
border-radius: 4px;
width: fit-content;
}
.entity-context {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.7;
white-space: pre-wrap;
}
/* Relations */
.relations-section { }
.section-label {
font-family: var(--font-display);
font-size: 9px;
letter-spacing: 0.15em;
color: var(--text-dim);
margin-bottom: 10px;
}
.relations-scroll {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 180px;
overflow-y: auto;
}
.rel-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: 10px;
}
.rel-node { color: var(--accent-cyan); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.rel-arrow {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
.arrow-line {
width: 20px;
height: 1px;
background: linear-gradient(90deg, var(--border-mid), var(--accent-cyan), var(--border-mid));
}
.rel-type-badge {
font-size: 8px;
letter-spacing: 0.08em;
color: var(--accent-purple);
padding: 1px 5px;
background: var(--accent-purple-dim);
border-radius: 3px;
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,478 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { taskApi, type Task, type TaskStatus, type TaskPriority } from '@/api/task'
import { Plus, CheckCircle, Circle, Clock, Trash2, Zap } from 'lucide-vue-next'
const tasks = ref<Task[]>([])
const showCreateForm = ref(false)
const newTaskTitle = ref('')
const newTaskPriority = ref<TaskPriority>('medium')
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'))
const priorityConfig: Record<TaskPriority, { color: string; label: string; glow: string }> = {
low: { color: '#4b5563', label: 'LOW', glow: 'rgba(75,85,99,0.3)' },
medium: { color: '#60a5fa', label: 'MED', glow: 'rgba(96,165,250,0.3)' },
high: { color: '#fbbf24', label: 'HIGH', glow: 'rgba(251,191,36,0.3)' },
urgent: { color: '#f87171', label: 'CRIT', glow: 'rgba(248,113,113,0.3)' },
}
async function loadTasks() {
try {
const response = await taskApi.list()
tasks.value = response.data
} catch (e) { console.error('加载任务失败:', e) }
}
async function createTask() {
if (!newTaskTitle.value.trim()) return
try {
const response = await taskApi.create({ title: newTaskTitle.value.trim(), priority: newTaskPriority.value })
tasks.value.unshift(response.data)
newTaskTitle.value = ''
showCreateForm.value = false
} catch (e) { console.error('创建任务失败:', e) }
}
async function updateStatus(task: Task, status: TaskStatus) {
try {
const response = await taskApi.update(task.id, { status })
const index = tasks.value.findIndex((t) => t.id === task.id)
if (index !== -1) tasks.value[index] = response.data
} catch (e) { console.error('更新状态失败:', e) }
}
async function deleteTask(id: string) {
try {
await taskApi.delete(id)
tasks.value = tasks.value.filter((t) => t.id !== id)
} catch (e) { console.error('删除失败:', e) }
}
onMounted(() => { loadTasks() })
</script>
<template>
<div class="kanban-view">
<!-- Header -->
<div class="page-header">
<div class="header-left">
<div class="header-icon"><Zap :size="20" /></div>
<div class="header-text">
<h1>TASK BOARD</h1>
<span class="header-sub">{{ tasks.length }} tasks · {{ doneTasks.length }} completed</span>
</div>
</div>
<button class="add-btn" @click="showCreateForm = true">
<Plus :size="14" />
NEW TASK
</button>
</div>
<!-- Create form -->
<div v-if="showCreateForm" class="create-panel">
<div class="create-inner">
<input
v-model="newTaskTitle"
placeholder="Describe the task..."
@keyup.enter="createTask"
autofocus
/>
<select v-model="newTaskPriority" class="priority-select">
<option value="low">LOW</option>
<option value="medium">MEDIUM</option>
<option value="high">HIGH</option>
<option value="urgent">CRITICAL</option>
</select>
<button class="confirm-btn" @click="createTask">CREATE</button>
<button class="cancel-btn" @click="showCreateForm = false">CANCEL</button>
</div>
</div>
<!-- Board -->
<div class="kanban-board">
<!-- TODO -->
<div class="kanban-col">
<div class="col-header">
<div class="col-title">
<Circle :size="14" />
<span>PENDING</span>
<div class="col-count">{{ todoTasks.length }}</div>
</div>
</div>
<div class="col-line" style="--col-color: #60a5fa"></div>
<div class="col-cards">
<div
v-for="task in todoTasks"
:key="task.id"
class="task-card"
@click="updateStatus(task, 'in_progress')"
>
<div class="task-priority-bar" :style="{ background: priorityConfig[task.priority].color, boxShadow: '0 0 6px ' + priorityConfig[task.priority].glow }"></div>
<div class="task-body">
<div class="task-meta">
<span class="task-priority-tag" :style="{ color: priorityConfig[task.priority].color }">
{{ priorityConfig[task.priority].label }}
</span>
</div>
<div class="task-title">{{ task.title }}</div>
</div>
<button class="task-delete" @click.stop="deleteTask(task.id)">
<Trash2 :size="12" />
</button>
</div>
<div v-if="todoTasks.length === 0" class="col-empty">No pending tasks</div>
</div>
</div>
<!-- IN PROGRESS -->
<div class="kanban-col active-col">
<div class="col-header">
<div class="col-title">
<Clock :size="14" />
<span>IN PROGRESS</span>
<div class="col-count active">{{ inProgressTasks.length }}</div>
</div>
</div>
<div class="col-line" style="--col-color: #fbbf24"></div>
<div class="col-cards">
<div
v-for="task in inProgressTasks"
:key="task.id"
class="task-card"
@click="updateStatus(task, 'done')"
>
<div class="task-priority-bar" :style="{ background: priorityConfig[task.priority].color, boxShadow: '0 0 6px ' + priorityConfig[task.priority].glow }"></div>
<div class="task-body">
<div class="task-meta">
<span class="task-priority-tag" :style="{ color: priorityConfig[task.priority].color }">
{{ priorityConfig[task.priority].label }}
</span>
<span class="active-dot"></span>
</div>
<div class="task-title">{{ task.title }}</div>
</div>
<button class="task-delete" @click.stop="deleteTask(task.id)">
<Trash2 :size="12" />
</button>
</div>
<div v-if="inProgressTasks.length === 0" class="col-empty">No active tasks</div>
</div>
</div>
<!-- DONE -->
<div class="kanban-col">
<div class="col-header">
<div class="col-title">
<CheckCircle :size="14" />
<span>COMPLETED</span>
<div class="col-count">{{ doneTasks.length }}</div>
</div>
</div>
<div class="col-line" style="--col-color: #34d399"></div>
<div class="col-cards">
<div
v-for="task in doneTasks"
:key="task.id"
class="task-card done"
@click="updateStatus(task, 'todo')"
>
<div class="task-priority-bar" :style="{ background: priorityConfig[task.priority].color, boxShadow: '0 0 6px ' + priorityConfig[task.priority].glow, opacity: 0.4 }"></div>
<div class="task-body">
<div class="task-meta">
<span class="task-priority-tag" style="color: var(--text-dim)">
{{ priorityConfig[task.priority].label }}
</span>
</div>
<div class="task-title done-title">{{ task.title }}</div>
</div>
<button class="task-delete" @click.stop="deleteTask(task.id)">
<Trash2 :size="12" />
</button>
</div>
<div v-if="doneTasks.length === 0" class="col-empty">No completed tasks</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.kanban-view {
height: 100%;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.header-left { display: flex; align-items: center; gap: 14px; }
.header-icon { color: var(--accent-amber); filter: drop-shadow(0 0 8px var(--accent-amber)); }
h1 {
font-family: var(--font-display);
font-size: 20px;
font-weight: 700;
letter-spacing: 0.15em;
color: var(--text-primary);
margin: 0;
}
.header-sub { font-family: var(--font-mono); font-size: 10px; color: var(--text-dim); letter-spacing: 0.1em; }
.add-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: var(--accent-amber-dim);
border: 1px solid rgba(249, 168, 37, 0.25);
border-radius: var(--radius-md);
color: var(--accent-amber);
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.12em;
transition: all var(--transition-fast);
}
.add-btn:hover {
background: rgba(249, 168, 37, 0.2);
box-shadow: var(--glow-amber);
}
/* Create panel */
.create-panel {
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-lg);
padding: 16px;
animation: fade-in-up 0.2s ease;
}
.create-inner {
display: flex;
align-items: center;
gap: 10px;
}
.create-inner input {
flex: 1;
padding: 10px 14px;
background: var(--bg-panel);
border: 1px solid var(--border-mid);
border-radius: var(--radius-md);
font-size: 13px;
}
.create-inner input:focus {
border-color: var(--accent-amber);
box-shadow: var(--glow-amber);
}
.priority-select {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.1em;
padding: 8px 12px;
background: var(--bg-panel);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
color: var(--text-secondary);
cursor: pointer;
}
.confirm-btn {
padding: 10px 20px;
background: var(--accent-amber-dim);
border: 1px solid rgba(249, 168, 37, 0.3);
border-radius: var(--radius-md);
color: var(--accent-amber);
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.12em;
transition: all var(--transition-fast);
}
.confirm-btn:hover { background: rgba(249, 168, 37, 0.2); box-shadow: var(--glow-amber); }
.cancel-btn {
padding: 10px 16px;
background: transparent;
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
color: var(--text-dim);
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.12em;
transition: all var(--transition-fast);
}
.cancel-btn:hover { border-color: var(--accent-red); color: var(--accent-red); }
/* Board */
.kanban-board {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
flex: 1;
min-height: 0;
}
.kanban-col {
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-lg);
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 400px;
}
.kanban-col.active-col {
border-color: rgba(251, 191, 36, 0.2);
background: rgba(251, 191, 36, 0.02);
}
.col-header { }
.col-title {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-display);
font-size: 10px;
letter-spacing: 0.15em;
color: var(--text-dim);
margin-bottom: 8px;
}
.col-count {
margin-left: auto;
background: var(--bg-panel);
border: 1px solid var(--border-dim);
border-radius: 10px;
padding: 1px 8px;
font-size: 10px;
color: var(--text-secondary);
}
.col-count.active {
background: var(--accent-amber-dim);
border-color: rgba(249, 168, 37, 0.3);
color: var(--accent-amber);
}
.col-line {
height: 1px;
background: linear-gradient(90deg, var(--col-color, var(--accent-cyan)), transparent);
margin-bottom: 4px;
opacity: 0.5;
}
.col-cards {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
overflow-y: auto;
}
.col-empty {
text-align: center;
padding: 32px 16px;
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
letter-spacing: 0.08em;
}
.task-card {
background: var(--bg-panel);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
padding: 10px 12px;
cursor: pointer;
display: flex;
align-items: stretch;
gap: 10px;
transition: all var(--transition-fast);
position: relative;
overflow: hidden;
}
.task-card:hover {
border-color: var(--border-mid);
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
.task-card.done { opacity: 0.55; }
.task-priority-bar {
width: 3px;
border-radius: 2px;
flex-shrink: 0;
}
.task-body { flex: 1; min-width: 0; }
.task-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.task-priority-tag {
font-family: var(--font-display);
font-size: 8px;
letter-spacing: 0.1em;
}
.active-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent-amber);
box-shadow: 0 0 6px var(--accent-amber);
animation: pulse-glow 1.5s ease-in-out infinite;
}
.task-title {
font-size: 12px;
color: var(--text-secondary);
line-height: 1.4;
}
.done-title {
text-decoration: line-through;
color: var(--text-dim);
}
.task-delete {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
padding: 2px;
border-radius: 3px;
opacity: 0;
transition: all var(--transition-fast);
flex-shrink: 0;
align-self: flex-start;
}
.task-card:hover .task-delete { opacity: 1; }
.task-delete:hover { color: var(--accent-red); background: rgba(255,71,87,0.1); }
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import SidebarNav from '@/components/SidebarNav.vue'
</script>
<template>
<div class="layout scanlines">
<SidebarNav />
<main class="main-content grid-bg">
<RouterView />
</main>
</div>
</template>
<style scoped>
.layout {
display: flex;
height: 100vh;
width: 100vw;
background: var(--bg-void);
overflow: hidden;
}
.main-content {
flex: 1;
overflow: hidden;
position: relative;
}
</style>

View File

@@ -0,0 +1,522 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { LogIn, Cpu, Shield, UserPlus } from 'lucide-vue-next'
import api from '@/api'
const auth = useAuthStore()
const router = useRouter()
// Tab state
const isLogin = ref(true)
// Login fields
const email = ref('')
const password = ref('')
const error = ref('')
const isLoading = ref(false)
// Register fields
const registerEmail = ref('')
const registerPassword = ref('')
const registerConfirmPassword = ref('')
const registerName = ref('')
const isRegistering = ref(false)
const registerError = ref('')
// Password strength calculation
function getPasswordStrength(pwd: string): { level: 'weak' | 'medium' | 'strong', text: string } {
if (pwd.length < 8) return { level: 'weak', text: '太短' }
let score = 0
if (pwd.length >= 8) score++
if (pwd.length >= 12) score++
if (/[a-z]/.test(pwd) && /[A-Z]/.test(pwd)) score++
if (/\d/.test(pwd)) score++
if (/[^a-zA-Z0-9]/.test(pwd)) score++
if (score <= 2) return { level: 'weak', text: '弱' }
if (score <= 3) return { level: 'medium', text: '中' }
return { level: 'strong', text: '强' }
}
const passwordStrength = computed(() => getPasswordStrength(registerPassword.value))
async function handleLogin() {
try {
error.value = ''
isLoading.value = true
await auth.login(email.value, password.value)
router.push('/chat')
} catch (e: unknown) {
error.value = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || 'Authentication failed'
} finally {
isLoading.value = false
}
}
async function handleRegister() {
if (registerPassword.value !== registerConfirmPassword.value) {
registerError.value = '两次密码输入不一致'
return
}
if (registerPassword.value.length < 8) {
registerError.value = '密码至少需要8个字符'
return
}
try {
registerError.value = ''
isRegistering.value = true
await api.post('/api/auth/register', {
email: registerEmail.value,
password: registerPassword.value,
full_name: registerName.value
})
// Register成功后自动登录
await auth.login(registerEmail.value, registerPassword.value)
router.push('/chat')
} catch (e: unknown) {
registerError.value = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '注册失败'
} finally {
isRegistering.value = false
}
}
</script>
<template>
<div class="login-container scanlines">
<!-- Background decorations -->
<div class="bg-grid"></div>
<div class="bg-glow"></div>
<div class="corner-tl"></div>
<div class="corner-tr"></div>
<div class="corner-bl"></div>
<div class="corner-br"></div>
<div class="login-wrapper">
<!-- Logo -->
<div class="login-logo">
<div class="logo-ring r1"></div>
<div class="logo-ring r2"></div>
<div class="logo-ring r3"></div>
<div class="logo-core">
<Cpu :size="36" />
</div>
</div>
<div class="login-title">JARVIS</div>
<div class="login-subtitle">PERSONAL AI ASSISTANT</div>
<!-- Tab Switch -->
<div class="tab-switch">
<button
class="tab-btn"
:class="{ active: isLogin }"
@click="isLogin = true"
>
登录
</button>
<button
class="tab-btn"
:class="{ active: !isLogin }"
@click="isLogin = false"
>
注册
</button>
</div>
<!-- Login Form -->
<form v-if="isLogin" class="login-form" @submit.prevent="handleLogin">
<div class="field-group">
<label class="field-label">// OPERATOR ID</label>
<div class="field-input">
<input v-model="email" type="email" placeholder="operator@jarvis.ai" required />
</div>
</div>
<div class="field-group">
<label class="field-label">// ACCESS CODE</label>
<div class="field-input">
<input v-model="password" type="password" placeholder="••••••••" required />
</div>
</div>
<div v-if="error" class="error-msg">
<Shield :size="12" />
{{ error }}
</div>
<button type="submit" class="login-btn" :disabled="isLoading">
<div v-if="isLoading" class="btn-loader"></div>
<LogIn v-else :size="15" />
<span>{{ isLoading ? 'AUTHENTICATING...' : 'AUTHENTICATE' }}</span>
</button>
</form>
<!-- Register Form -->
<form v-else class="login-form" @submit.prevent="handleRegister">
<div class="field-group">
<label class="field-label">// EMAIL</label>
<div class="field-input">
<input v-model="registerEmail" type="email" placeholder="your@email.com" required />
</div>
</div>
<div class="field-group">
<label class="field-label">// NAME</label>
<div class="field-input">
<input v-model="registerName" type="text" placeholder="Your Name" required />
</div>
</div>
<div class="field-group">
<label class="field-label">// PASSWORD</label>
<div class="field-input">
<input v-model="registerPassword" type="password" placeholder="••••••••" required />
</div>
<div v-if="registerPassword" class="password-strength">
<div class="strength-bar">
<div
class="strength-fill"
:class="passwordStrength.level"
:style="{
width: passwordStrength.level === 'weak' ? '33%' : passwordStrength.level === 'medium' ? '66%' : '100%'
}"
></div>
</div>
<span class="strength-text" :class="passwordStrength.level">{{ passwordStrength.text }}</span>
</div>
</div>
<div class="field-group">
<label class="field-label">// CONFIRM PASSWORD</label>
<div class="field-input">
<input v-model="registerConfirmPassword" type="password" placeholder="••••••••" required />
</div>
</div>
<div v-if="registerError" class="error-msg">
<Shield :size="12" />
{{ registerError }}
</div>
<button type="submit" class="login-btn" :disabled="isRegistering">
<div v-if="isRegistering" class="btn-loader"></div>
<UserPlus v-else :size="15" />
<span>{{ isRegistering ? 'REGISTERING...' : 'REGISTER' }}</span>
</button>
</form>
<!-- Footer info -->
<div class="login-footer">
<div class="footer-line"></div>
<div class="footer-text">JARVIS AI SYSTEM v2.0</div>
<div class="footer-line"></div>
</div>
</div>
</div>
</template>
<style scoped>
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-void);
position: relative;
overflow: hidden;
}
/* Background */
.bg-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(0, 245, 212, 0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(0, 245, 212, 0.04) 1px, transparent 1px);
background-size: 50px 50px;
}
.bg-glow {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 600px;
height: 600px;
background: radial-gradient(ellipse, rgba(0, 245, 212, 0.06) 0%, transparent 70%);
pointer-events: none;
}
/* Corners */
.corner-tl, .corner-tr, .corner-bl, .corner-br {
position: absolute;
width: 40px;
height: 40px;
}
.corner-tl::before, .corner-tl::after,
.corner-tr::before, .corner-tr::after,
.corner-bl::before, .corner-bl::after,
.corner-br::before, .corner-br::after {
content: '';
position: absolute;
background: var(--accent-cyan);
}
.corner-tl::before, .corner-tr::before,
.corner-bl::before, .corner-bl::after,
.corner-br::before, .corner-br::after { width: 100%; height: 1px; }
.corner-tl::after, .corner-tr::after,
.corner-tr::before, .corner-tr::after,
.corner-bl::after, .corner-br::after { width: 1px; height: 100%; }
.corner-tl { top: 24px; left: 24px; }
.corner-tr { top: 24px; right: 24px; transform: scaleX(-1); }
.corner-bl { bottom: 24px; left: 24px; transform: scaleY(-1); }
.corner-br { bottom: 24px; right: 24px; transform: scale(-1); }
/* Wrapper */
.login-wrapper {
width: 380px;
padding: 48px 40px;
background: rgba(10, 15, 26, 0.9);
border: 1px solid var(--border-mid);
border-radius: var(--radius-lg);
backdrop-filter: blur(20px);
position: relative;
animation: fade-in-up 0.5s ease;
box-shadow:
0 0 40px rgba(0, 245, 212, 0.08),
0 25px 50px rgba(0, 0, 0, 0.5);
}
/* Logo */
.login-logo {
position: relative;
width: 80px;
height: 80px;
margin: 0 auto 24px;
display: flex;
align-items: center;
justify-content: center;
color: var(--accent-cyan);
}
.logo-ring {
position: absolute;
border-radius: 50%;
border: 1px solid var(--accent-cyan);
animation: spin linear infinite;
}
.r1 { width: 80px; height: 80px; opacity: 0.2; animation-duration: 12s; border-style: dashed; }
.r2 { width: 60px; height: 60px; opacity: 0.35; animation-duration: 7s; animation-direction: reverse; }
.r3 { width: 44px; height: 44px; opacity: 0.5; animation-duration: 4s; }
.login-core {
position: relative;
z-index: 1;
filter: drop-shadow(0 0 12px var(--accent-cyan));
animation: pulse-glow 2s ease-in-out infinite;
}
.login-title {
font-family: var(--font-display);
font-size: 36px;
font-weight: 800;
letter-spacing: 0.25em;
color: var(--accent-cyan);
text-align: center;
text-shadow: var(--glow-cyan);
margin-bottom: 4px;
}
.login-subtitle {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.35em;
color: var(--text-dim);
text-align: center;
margin-bottom: 24px;
}
/* Tab Switch */
.tab-switch {
display: flex;
gap: 4px;
margin-bottom: 24px;
padding: 4px;
background: var(--bg-card);
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
}
.tab-btn {
flex: 1;
padding: 8px 16px;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.1em;
cursor: pointer;
transition: all var(--transition-fast);
}
.tab-btn:hover {
color: var(--text-secondary);
}
.tab-btn.active {
background: var(--accent-cyan-dim);
color: var(--accent-cyan);
}
/* Form */
.login-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.field-group { display: flex; flex-direction: column; gap: 6px; }
.field-label {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.15em;
color: var(--text-dim);
}
.field-input {
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-md);
padding: 12px 14px;
transition: all var(--transition-fast);
}
.field-input:focus-within {
border-color: var(--accent-cyan);
box-shadow: 0 0 0 1px rgba(0,245,212,0.1), var(--glow-cyan);
}
.field-input input {
width: 100%;
font-size: 13px;
color: var(--text-primary);
background: transparent;
border: none;
outline: none;
}
.field-input input::placeholder {
color: var(--text-dim);
}
/* Password Strength */
.password-strength {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
}
.strength-bar {
flex: 1;
height: 3px;
background: var(--border-dim);
border-radius: 2px;
overflow: hidden;
}
.strength-fill {
height: 100%;
transition: all var(--transition-fast);
}
.strength-fill.weak { background: var(--accent-red); }
.strength-fill.medium { background: var(--accent-amber); }
.strength-fill.strong { background: var(--accent-green); }
.strength-text {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.1em;
}
.strength-text.weak { color: var(--accent-red); }
.strength-text.medium { color: var(--accent-amber); }
.strength-text.strong { color: var(--accent-green); }
.error-msg {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: rgba(255, 71, 87, 0.08);
border: 1px solid rgba(255, 71, 87, 0.2);
border-radius: var(--radius-md);
color: var(--accent-red);
font-size: 11px;
letter-spacing: 0.05em;
}
.login-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 14px;
background: var(--accent-cyan-dim);
border: 1px solid var(--border-mid);
border-radius: var(--radius-md);
color: var(--accent-cyan);
font-family: var(--font-display);
font-size: 12px;
letter-spacing: 0.15em;
font-weight: 600;
transition: all var(--transition-mid);
margin-top: 4px;
}
.login-btn:hover:not(:disabled) {
background: rgba(0, 245, 212, 0.2);
box-shadow: var(--glow-cyan);
transform: translateY(-1px);
}
.btn-loader {
width: 16px;
height: 16px;
border: 2px solid transparent;
border-top-color: var(--accent-cyan);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
/* Footer */
.login-footer {
display: flex;
align-items: center;
gap: 12px;
margin-top: 32px;
}
.footer-line {
flex: 1;
height: 1px;
background: var(--border-dim);
}
.footer-text {
font-family: var(--font-mono);
font-size: 8px;
letter-spacing: 0.15em;
color: var(--text-muted);
white-space: nowrap;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>

View File

@@ -0,0 +1,937 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { settingsApi, type LLMConfig, type SchedulerConfig, type LLMModelConfig, type LLMProvider } from '@/api/settings'
import { Save, Eye, EyeOff, Play, RotateCcw, Plus, Trash2, Copy, X } from 'lucide-vue-next'
// 状态
const loading = ref(false)
const saving = ref(false)
const showApiKey = ref<Record<string, boolean>>({})
const toast = ref<{ show: boolean; message: string; type: 'success' | 'error' }>({
show: false,
message: '',
type: 'success'
})
// 用户资料
const profile = ref({
email: '',
full_name: '',
created_at: ''
})
const originalProfile = ref({ email: '', full_name: '' })
const newPassword = ref('')
// LLM 配置 - 每种类型支持多个模型
const llmConfig = ref<LLMConfig>({
chat: [],
vlm: [],
embedding: [],
rerank: []
})
const originalLlmConfig = ref<LLMConfig>({
chat: [],
vlm: [],
embedding: [],
rerank: []
})
// 定时任务配置
const schedulerConfig = ref<SchedulerConfig>({
daily_plan_time: '08:00',
forum_scan_interval_minutes: 30,
todo_ai_generate_time: '08:00',
enabled: true
})
const originalSchedulerConfig = ref<SchedulerConfig>({})
// 是否有修改
const isProfileDirty = computed(() => {
return profile.value.full_name !== originalProfile.value.full_name || newPassword.value !== ''
})
const isLlmDirty = computed(() => {
return JSON.stringify(llmConfig.value) !== JSON.stringify(originalLlmConfig.value)
})
const isSchedulerDirty = computed(() => {
return JSON.stringify(schedulerConfig.value) !== JSON.stringify(originalSchedulerConfig.value)
})
// 创建空的模型配置
function createEmptyModel(type: string): LLMModelConfig {
return {
name: `${type.toUpperCase()}-${Date.now()}`,
provider: 'openai',
model: type === 'chat' ? 'gpt-4o' : type === 'vlm' ? 'gpt-4o' : type === 'embedding' ? 'text-embedding-3-small' : 'bge-reranker-v2',
base_url: '',
api_key: '',
enabled: true
}
}
// 添加模型
function addModel(type: string) {
if (!llmConfig.value[type as keyof LLMConfig]) {
llmConfig.value[type as keyof LLMConfig] = []
}
llmConfig.value[type as keyof LLMConfig]!.push(createEmptyModel(type))
}
// 删除模型
function removeModel(type: string, index: number) {
llmConfig.value[type as keyof LLMConfig]!.splice(index, 1)
}
// 复制模型
function duplicateModel(type: string, index: number) {
const model = llmConfig.value[type as keyof LLMConfig]![index]
const copy = { ...model, name: `${model.name}-copy-${Date.now()}` }
llmConfig.value[type as keyof LLMConfig]!.push(copy)
}
// 加载设置
async function loadSettings() {
loading.value = true
try {
const res = await settingsApi.get()
profile.value = {
email: res.data.profile.email,
full_name: res.data.profile.full_name || '',
created_at: res.data.profile.created_at
}
originalProfile.value = { ...profile.value }
// 加载 LLM 配置
if (res.data.llm_config && Object.keys(res.data.llm_config).length > 0) {
llmConfig.value = res.data.llm_config as LLMConfig
} else {
// 默认添加一个空配置
llmConfig.value = {
chat: [createEmptyModel('chat')],
vlm: [createEmptyModel('vlm')],
embedding: [createEmptyModel('embedding')],
rerank: [createEmptyModel('rerank')]
}
}
originalLlmConfig.value = JSON.parse(JSON.stringify(llmConfig.value))
if (res.data.scheduler_config && Object.keys(res.data.scheduler_config).length > 0) {
schedulerConfig.value = res.data.scheduler_config as SchedulerConfig
}
originalSchedulerConfig.value = JSON.parse(JSON.stringify(schedulerConfig.value))
} catch (e) {
console.error('加载设置失败', e)
showToast('加载设置失败', 'error')
} finally {
loading.value = false
}
}
// 保存资料
async function saveProfile() {
saving.value = true
try {
await settingsApi.updateProfile({
full_name: profile.value.full_name,
password: newPassword.value || undefined
})
originalProfile.value = { email: profile.value.email, full_name: profile.value.full_name }
newPassword.value = ''
showToast('资料保存成功')
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败'
showToast(msg, 'error')
} finally {
saving.value = false
}
}
// 保存 LLM 配置
async function saveLLM() {
saving.value = true
try {
await settingsApi.updateLLM(llmConfig.value)
originalLlmConfig.value = JSON.parse(JSON.stringify(llmConfig.value))
showToast('LLM 配置保存成功')
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败'
showToast(msg, 'error')
} finally {
saving.value = false
}
}
// 测试 LLM 连接
async function testLLM(type: string, config: LLMModelConfig) {
try {
const res = await settingsApi.testLLM({ type: type as any, ...config })
if (res.data.success) {
showToast(`连接成功: ${res.data.message}`)
} else {
showToast(`连接失败: ${res.data.error}`, 'error')
}
} catch (e) {
showToast('测试连接失败', 'error')
}
}
// 保存定时任务配置
async function saveScheduler() {
saving.value = true
try {
await settingsApi.updateScheduler(schedulerConfig.value)
originalSchedulerConfig.value = JSON.parse(JSON.stringify(schedulerConfig.value))
showToast('定时任务配置保存成功')
} catch (e: unknown) {
const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败'
showToast(msg, 'error')
} finally {
saving.value = false
}
}
// 重置表单
function resetProfile() {
profile.value.full_name = originalProfile.value.full_name
newPassword.value = ''
}
function resetLLM() {
llmConfig.value = JSON.parse(JSON.stringify(originalLlmConfig.value))
}
function resetScheduler() {
schedulerConfig.value = JSON.parse(JSON.stringify(originalSchedulerConfig.value))
}
// Provider 默认 URL
function getDefaultBaseUrl(provider: string): string {
switch (provider) {
case 'ollama': return 'http://localhost:11434'
case 'openai': return 'https://api.openai.com/v1'
case 'claude': return 'https://api.anthropic.com'
case 'deepseek': return 'https://api.deepseek.com/v1'
default: return ''
}
}
// Provider 变化时自动填充 base_url
function onProviderChange(config: LLMModelConfig) {
if (!config.base_url || config.base_url === getDefaultBaseUrl(config.provider)) {
config.base_url = getDefaultBaseUrl(config.provider)
}
}
// Toast 提示
function showToast(message: string, type: 'success' | 'error' = 'success') {
toast.value = { show: true, message, type }
setTimeout(() => {
toast.value.show = false
}, 3000)
}
// LLM 类型列表
const llmTypes = ['chat', 'vlm', 'embedding', 'rerank'] as const
// 获取模型显示名称
function getModelName(config: LLMModelConfig): string {
if (config.name) return config.name
if (config.model) return config.model
return '未命名'
}
onMounted(loadSettings)
</script>
<template>
<div class="settings-view scanlines">
<!-- Background -->
<div class="bg-grid"></div>
<div class="bg-glow"></div>
<!-- Header -->
<div class="view-header">
<span class="header-title">// SETTINGS</span>
</div>
<!-- Toast -->
<Transition name="fade">
<div v-if="toast.show" class="toast" :class="toast.type">
{{ toast.message }}
</div>
</Transition>
<!-- Loading -->
<div v-if="loading" class="loading-overlay">
<div class="loading-text">LOADING...</div>
</div>
<!-- Content -->
<div class="settings-content">
<!-- Profile Section -->
<div class="settings-card">
<div class="card-header">
<span class="card-title">PROFILE</span>
<button v-if="isProfileDirty" class="reset-btn" @click="resetProfile">
<RotateCcw :size="12" /> 重置
</button>
</div>
<div class="form-group">
<label class="form-label">// EMAIL</label>
<input v-model="profile.email" type="email" disabled class="form-input disabled" />
</div>
<div class="form-group">
<label class="form-label">// NAME</label>
<input v-model="profile.full_name" type="text" class="form-input" placeholder="Your name" />
</div>
<div class="form-group">
<label class="form-label">// NEW PASSWORD (留空保持不变)</label>
<input v-model="newPassword" type="password" class="form-input" placeholder="••••••••" />
</div>
<button class="save-btn" @click="saveProfile" :disabled="saving || !isProfileDirty">
<div v-if="saving" class="btn-loader"></div>
<Save v-else :size="14" />
<span>{{ saving ? 'SAVING...' : 'SAVE PROFILE' }}</span>
</button>
</div>
<!-- LLM Config Section -->
<div v-for="type in llmTypes" :key="type" class="settings-card">
<div class="card-header">
<span class="card-title">{{ type.toUpperCase() }}</span>
<button class="add-btn" @click="addModel(type)">
<Plus :size="12" /> 添加模型
</button>
</div>
<!-- 模型列表 -->
<div v-if="llmConfig[type] && llmConfig[type]!.length > 0" class="model-list">
<div
v-for="(model, index) in llmConfig[type]"
:key="index"
class="model-item"
:class="{ disabled: !model.enabled }"
>
<div class="model-header">
<div class="model-name-group">
<input
v-model="model.name"
type="text"
class="model-name-input"
placeholder="模型名称"
/>
<label class="model-enabled">
<input type="checkbox" v-model="model.enabled" />
<span class="checkbox-custom"></span>
启用
</label>
</div>
<div class="model-actions">
<button class="icon-btn" @click="duplicateModel(type, index)" title="复制">
<Copy :size="12" />
</button>
<button class="icon-btn danger" @click="removeModel(type, index)" title="删除">
<Trash2 :size="12" />
</button>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">// PROVIDER</label>
<select
v-model="model.provider"
class="form-select"
@change="onProviderChange(model)"
>
<option value="openai">OpenAI</option>
<option value="claude">Claude</option>
<option value="ollama">Ollama</option>
<option value="deepseek">DeepSeek</option>
<option value="custom">Custom</option>
</select>
</div>
<div class="form-group">
<label class="form-label">// MODEL</label>
<input v-model="model.model" type="text" class="form-input" placeholder="gpt-4o" />
</div>
</div>
<div class="form-group">
<label class="form-label">// BASE URL</label>
<input
v-model="model.base_url"
type="text"
class="form-input"
:placeholder="getDefaultBaseUrl(model.provider)"
/>
</div>
<div class="form-group">
<label class="form-label">// API KEY</label>
<div class="input-with-toggle">
<input
v-model="model.api_key"
:type="showApiKey[`${type}-${index}`] ? 'text' : 'password'"
class="form-input"
placeholder="sk-..."
/>
<button class="toggle-visibility" @click="showApiKey[`${type}-${index}`] = !showApiKey[`${type}-${index}`]">
<Eye v-if="!showApiKey[`${type}-${index}`]" :size="14" />
<EyeOff v-else :size="14" />
</button>
</div>
</div>
<div class="model-footer">
<button class="test-btn" @click="testLLM(type, model)">
<Play :size="12" /> 测试连接
</button>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-else class="empty-state">
<span>暂无模型配置</span>
<button class="add-btn" @click="addModel(type)">
<Plus :size="12" /> 添加第一个模型
</button>
</div>
</div>
<button
class="save-btn full-width"
@click="saveLLM"
:disabled="saving || !isLlmDirty"
>
<div v-if="saving" class="btn-loader"></div>
<Save v-else :size="14" />
<span>{{ saving ? 'SAVING...' : 'SAVE LLM CONFIG' }}</span>
</button>
<!-- Scheduler Section -->
<div class="settings-card">
<div class="card-header">
<span class="card-title">SCHEDULER</span>
<button v-if="isSchedulerDirty" class="reset-btn" @click="resetScheduler">
<RotateCcw :size="12" /> 重置
</button>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">// DAILY PLAN TIME</label>
<input v-model="schedulerConfig.daily_plan_time" type="time" class="form-input" />
</div>
<div class="form-group">
<label class="form-label">// TODO AI GENERATE TIME</label>
<input v-model="schedulerConfig.todo_ai_generate_time" type="time" class="form-input" />
</div>
</div>
<div class="form-group">
<label class="form-label">// FORUM SCAN INTERVAL (minutes)</label>
<input
v-model.number="schedulerConfig.forum_scan_interval_minutes"
type="number"
min="5"
max="1440"
class="form-input"
/>
</div>
<div class="form-group toggle-group">
<label class="form-label">// SCHEDULER ENABLED</label>
<button
class="toggle-btn"
:class="{ active: schedulerConfig.enabled }"
@click="schedulerConfig.enabled = !schedulerConfig.enabled"
>
<span class="toggle-knob"></span>
</button>
</div>
<button
class="save-btn"
@click="saveScheduler"
:disabled="saving || !isSchedulerDirty"
>
<div v-if="saving" class="btn-loader"></div>
<Save v-else :size="14" />
<span>{{ saving ? 'SAVING...' : 'SAVE SCHEDULER' }}</span>
</button>
</div>
</div>
</div>
</template>
<style scoped>
.settings-view {
height: 100%;
display: flex;
flex-direction: column;
background: var(--bg-void);
position: relative;
overflow: hidden;
}
.bg-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(0,245,212,0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,245,212,0.04) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
z-index: 0;
}
.bg-glow {
position: absolute;
inset: 0;
background: radial-gradient(ellipse 80% 60% at 50% 40%, rgba(0,245,212,0.05) 0%, transparent 70%);
pointer-events: none;
z-index: 0;
}
.view-header {
display: flex;
align-items: center;
padding: 14px 24px;
border-bottom: 1px solid var(--border-dim);
background: rgba(5,8,16,0.6);
backdrop-filter: blur(8px);
position: relative;
z-index: 10;
}
.header-title {
font-family: var(--font-display);
font-size: 13px;
letter-spacing: 0.2em;
color: var(--text-primary);
}
/* Toast */
.toast {
position: fixed;
top: 80px;
left: 50%;
transform: translateX(-50%);
padding: 10px 20px;
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: 12px;
z-index: 1000;
animation: slide-in 0.3s ease;
}
.toast.success {
background: rgba(0, 245, 212, 0.15);
border: 1px solid var(--accent-cyan);
color: var(--accent-cyan);
}
.toast.error {
background: rgba(255, 71, 87, 0.15);
border: 1px solid var(--accent-red);
color: var(--accent-red);
}
.fade-enter-active, .fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
/* Loading */
.loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(5,8,16,0.8);
z-index: 50;
}
.loading-text {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.2em;
color: var(--accent-cyan);
animation: pulse 1s ease-in-out infinite;
}
/* Content */
.settings-content {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
position: relative;
z-index: 1;
}
/* Card */
.settings-card {
background: rgba(13,21,37,0.9);
border: 1px solid var(--border-dim);
border-radius: var(--radius-lg);
padding: 20px;
backdrop-filter: blur(12px);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.card-title {
font-family: var(--font-display);
font-size: 11px;
letter-spacing: 0.2em;
color: var(--accent-cyan);
}
.reset-btn, .add-btn, .test-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: transparent;
border: 1px solid var(--border-mid);
border-radius: var(--radius-sm);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 10px;
cursor: pointer;
transition: all var(--transition-fast);
}
.reset-btn:hover, .add-btn:hover, .test-btn:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.add-btn {
border-color: rgba(0,245,212,0.3);
color: var(--accent-cyan);
}
.test-btn {
border-color: rgba(0,245,212,0.3);
color: var(--accent-cyan);
}
/* Model List */
.model-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.model-item {
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-md);
padding: 14px;
transition: all var(--transition-fast);
}
.model-item.disabled {
opacity: 0.5;
}
.model-item:hover {
border-color: var(--border-mid);
}
.model-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.model-name-group {
display: flex;
align-items: center;
gap: 12px;
}
.model-name-input {
background: var(--bg-void);
border: 1px solid var(--border-dim);
border-radius: var(--radius-sm);
padding: 4px 8px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 11px;
width: 150px;
}
.model-name-input:focus {
outline: none;
border-color: var(--accent-cyan);
}
.model-enabled {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-dim);
}
.model-enabled input {
display: none;
}
.checkbox-custom {
width: 14px;
height: 14px;
border: 1px solid var(--border-mid);
border-radius: 3px;
background: var(--bg-void);
position: relative;
}
.model-enabled input:checked + .checkbox-custom {
background: var(--accent-cyan);
border-color: var(--accent-cyan);
}
.model-enabled input:checked + .checkbox-custom::after {
content: '✓';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--bg-void);
font-size: 10px;
}
.model-actions {
display: flex;
gap: 4px;
}
.icon-btn {
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--border-mid);
border-radius: var(--radius-sm);
color: var(--text-dim);
cursor: pointer;
transition: all var(--transition-fast);
}
.icon-btn:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.icon-btn.danger:hover {
border-color: var(--accent-red);
color: var(--accent-red);
}
.model-footer {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--border-dim);
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 11px;
}
/* Form */
.form-group {
margin-bottom: 14px;
}
.form-row {
display: flex;
gap: 14px;
}
.form-row .form-group {
flex: 1;
}
.form-label {
display: block;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.15em;
color: var(--text-dim);
margin-bottom: 6px;
}
.form-input, .form-select {
width: 100%;
padding: 10px 12px;
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 12px;
transition: all var(--transition-fast);
}
.form-input:focus, .form-select:focus {
outline: none;
border-color: var(--accent-cyan);
box-shadow: 0 0 0 1px rgba(0,245,212,0.1);
}
.form-input.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.form-select {
cursor: pointer;
}
.input-with-toggle {
display: flex;
gap: 8px;
}
.input-with-toggle .form-input {
flex: 1;
}
.toggle-visibility {
width: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-sm);
color: var(--text-dim);
cursor: pointer;
transition: all var(--transition-fast);
}
.toggle-visibility:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
/* Toggle */
.toggle-group {
display: flex;
align-items: center;
justify-content: space-between;
}
.toggle-btn {
width: 44px;
height: 22px;
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: 11px;
padding: 2px;
cursor: pointer;
transition: all 0.25s;
}
.toggle-btn.active {
background: rgba(0,245,212,.15);
border-color: var(--accent-cyan);
}
.toggle-knob {
display: block;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--text-dim);
transition: all 0.25s;
}
.toggle-btn.active .toggle-knob {
background: var(--accent-cyan);
box-shadow: 0 0 8px var(--accent-cyan);
transform: translateX(22px);
}
/* Save Button */
.save-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 10px 16px;
background: rgba(0,245,212,0.08);
border: 1px solid rgba(0,245,212,0.3);
border-radius: var(--radius-sm);
color: var(--accent-cyan);
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.1em;
cursor: pointer;
transition: all var(--transition-fast);
margin-top: 8px;
}
.save-btn:hover:not(:disabled) {
background: rgba(0,245,212,0.15);
box-shadow: 0 0 12px rgba(0,245,212,0.2);
}
.save-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.save-btn.full-width {
margin-top: 0;
}
.btn-loader {
width: 14px;
height: 14px;
border: 2px solid transparent;
border-top-color: var(--accent-cyan);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
</style>

View File

@@ -0,0 +1,482 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import * as statsApi from '@/api/stats'
import { Cpu, HardDrive, MemoryStick, Clock, TrendingUp, Tag } from 'lucide-vue-next'
import SectionHeader from '@/components/stats/SectionHeader.vue'
import MetricCard from '@/components/stats/MetricCard.vue'
import SummaryRow from '@/components/stats/SummaryRow.vue'
import MiniLineChart from '@/components/stats/MiniLineChart.vue'
import MiniBarChart from '@/components/stats/MiniBarChart.vue'
const isLoading = ref(true)
const hasError = ref(false)
// 数据状态
const systemHealth = ref<any>(null)
const conversationStats = ref<any>(null)
const knowledgeStats = ref<any>(null)
const kanbanStats = ref<any>(null)
const communityStats = ref<any>(null)
const personalInsights = ref<any>(null)
function formatUptime(seconds: number) {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
const mins = Math.floor((seconds % 3600) / 60)
if (days > 0) return `${days}d ${hours}h`
if (hours > 0) return `${hours}h ${mins}m`
return `${mins}m`
}
function formatNumber(num: number): string {
if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'
if (num >= 1000) return (num / 1000).toFixed(1) + 'K'
return num.toString()
}
onMounted(async () => {
try {
// 系统健康不需要认证
const sys = await statsApi.getSystemHealth().catch(() => null)
systemHealth.value = sys?.data || null
// 用户相关数据需要认证
const [conv, know, kanban, community, insights] = await Promise.all([
statsApi.getConversationStats().catch(() => null),
statsApi.getKnowledgeStats().catch(() => null),
statsApi.getKanbanStats().catch(() => null),
statsApi.getCommunityStats().catch(() => null),
statsApi.getPersonalInsights().catch(() => null),
])
conversationStats.value = conv?.data || null
knowledgeStats.value = know?.data || null
kanbanStats.value = kanban?.data || null
communityStats.value = community?.data || null
personalInsights.value = insights?.data || null
} catch (e) {
hasError.value = true
console.error('Failed to load stats:', e)
} finally {
isLoading.value = false
}
})
// 图表数据转换
const convChartData = computed(() =>
conversationStats.value?.daily_conversations?.map((d: any) => ({ date: d.date, value: d.count })) || []
)
const knowChartData = computed(() =>
knowledgeStats.value?.daily_new_tags?.map((d: any) => ({ date: d.date, value: d.count })) || []
)
const kanbanNewData = computed(() =>
kanbanStats.value?.daily_new_tasks?.map((d: any) => d.count) || []
)
const kanbanDoneData = computed(() =>
kanbanStats.value?.daily_completed_tasks?.map((d: any) => d.count) || []
)
const communityChartData = computed(() =>
communityStats.value?.daily_posts?.map((d: any) => ({ date: d.date, value: d.count })) || []
)
const hourlyActivityData = computed(() =>
personalInsights.value?.hourly_activity?.map((h: any) => h.count) || []
)
</script>
<template>
<div class="stats-view">
<div class="stats-header">
<h1>// DATA METRICS</h1>
</div>
<div v-if="isLoading" class="loading-state">
<div class="loading-spinner"></div>
<span>Loading metrics...</span>
</div>
<div v-else-if="hasError" class="error-state">
<span>Failed to load stats</span>
<button @click="() => window.location.reload()">Refresh</button>
</div>
<div v-else class="stats-content">
<!-- SYSTEM HEALTH -->
<section class="stats-section">
<SectionHeader title="SYSTEM HEALTH" tag="cyan" />
<div class="metrics-grid">
<MetricCard
:icon="Cpu"
label="CPU Usage"
:value="systemHealth ? systemHealth.cpu_percent + '%' : '--'"
accentColor="var(--accent-cyan)"
/>
<MetricCard
:icon="MemoryStick"
label="Memory"
:value="systemHealth ? systemHealth.memory_percent + '%' : '--'"
accentColor="var(--accent-purple)"
/>
<MetricCard
:icon="HardDrive"
label="Disk"
:value="systemHealth ? systemHealth.disk_percent + '%' : '--'"
accentColor="var(--accent-amber)"
/>
<MetricCard
:icon="Clock"
label="Uptime"
:value="systemHealth ? formatUptime(systemHealth.uptime_seconds) : '--'"
accentColor="var(--accent-green)"
/>
</div>
</section>
<!-- CONVERSATIONS -->
<section class="stats-section">
<SectionHeader title="CONVERSATIONS" tag="cyan" />
<SummaryRow
v-if="conversationStats"
:items="[
{ label: 'Total Conversations', value: formatNumber(conversationStats.totals?.conversations || 0) },
{ label: 'Total Messages', value: formatNumber(conversationStats.totals?.messages || 0) },
{ label: 'Input Tokens', value: formatNumber(conversationStats.totals?.input_tokens || 0) },
{ label: 'Output Tokens', value: formatNumber(conversationStats.totals?.output_tokens || 0) },
]"
/>
<div class="chart-box" v-if="convChartData.length > 0">
<div class="chart-label">30-Day Trend</div>
<MiniLineChart :data="convChartData" color="var(--accent-cyan)" :height="80" />
</div>
<div v-else class="empty-state">
<span>No conversation data yet</span>
</div>
</section>
<!-- KNOWLEDGE -->
<section class="stats-section">
<SectionHeader title="KNOWLEDGE BASE" tag="purple" />
<SummaryRow
v-if="knowledgeStats"
:items="[
{ label: 'New Tags', value: formatNumber(knowledgeStats.totals?.new_tags || 0) },
{ label: 'Documents', value: formatNumber(knowledgeStats.totals?.documents || 0) },
{ label: 'Tag Relations', value: formatNumber(knowledgeStats.totals?.tag_relations || 0) },
]"
:columns="3"
/>
<div class="chart-box" v-if="knowChartData.length > 0">
<div class="chart-label">Tag Growth</div>
<MiniLineChart :data="knowChartData" color="var(--accent-purple)" :height="80" />
</div>
<div v-else class="empty-state">
<span>No knowledge data yet</span>
</div>
</section>
<!-- KANBAN -->
<section class="stats-section">
<SectionHeader title="KANBAN" tag="cyan" />
<SummaryRow
v-if="kanbanStats"
:items="[
{ label: 'Pending Tasks', value: kanbanStats.current_pending_tasks || 0 },
{ label: 'New (30d)', value: formatNumber(kanbanStats.totals?.new_tasks || 0) },
{ label: 'Done (30d)', value: formatNumber(kanbanStats.totals?.completed_tasks || 0) },
]"
:columns="3"
/>
<div class="chart-box" v-if="kanbanNewData.length > 0">
<div class="chart-label">Tasks: New vs Completed</div>
<div class="bar-chart-group">
<MiniBarChart :data="kanbanNewData" color="var(--accent-cyan)" :height="60" />
<MiniBarChart :data="kanbanDoneData" color="var(--accent-green)" :height="60" />
</div>
</div>
<div v-else class="empty-state">
<span>No kanban data yet</span>
</div>
</section>
<!-- COMMUNITY -->
<section class="stats-section">
<SectionHeader title="COMMUNITY" tag="amber" />
<SummaryRow
v-if="communityStats"
:items="[
{ label: 'Posts', value: formatNumber(communityStats.totals?.posts || 0) },
{ label: 'Replies', value: formatNumber(communityStats.totals?.replies || 0) },
{ label: 'AI Executions', value: formatNumber(communityStats.totals?.ai_executions || 0) },
]"
:columns="3"
/>
<div class="chart-box" v-if="communityChartData.length > 0">
<div class="chart-label">Activity Trend</div>
<MiniLineChart :data="communityChartData" color="var(--accent-amber)" :height="80" />
</div>
<div v-else class="empty-state">
<span>No community data yet</span>
</div>
</section>
<!-- INSIGHTS -->
<section class="stats-section">
<SectionHeader title="PERSONAL INSIGHTS" tag="cyan" />
<div class="insights-grid" v-if="personalInsights">
<div class="insight-card">
<h4>Hourly Activity</h4>
<MiniBarChart
v-if="hourlyActivityData.length > 0"
:data="hourlyActivityData"
color="var(--accent-cyan)"
:height="80"
:maxBars="24"
/>
<div v-else class="empty-state small">No activity data</div>
</div>
<div class="insight-card">
<h4>Top Tags</h4>
<ul class="tag-list" v-if="personalInsights.top_tags?.length">
<li v-for="tag in personalInsights.top_tags" :key="tag.tag_path">
<Tag :size="12" />
<span class="tag-name">{{ tag.tag_path }}</span>
<span class="tag-count">{{ tag.usage_count }}</span>
</li>
</ul>
<div v-else class="empty-state small">No tags yet</div>
</div>
<div class="insight-card">
<h4>Token Trend</h4>
<div class="token-trend">
<span class="trend-value" :class="personalInsights.token_trend_percent > 0 ? 'up' : 'down'">
<TrendingUp :size="16" />
{{ personalInsights.token_trend_percent }}%
</span>
<span class="trend-label">vs last month</span>
</div>
</div>
</div>
<div v-else class="empty-state">
<span>Login to see personal insights</span>
</div>
</section>
</div>
</div>
</template>
<style scoped>
.stats-view {
height: 100%;
overflow-y: auto;
padding: 24px;
background: var(--bg-void);
}
.stats-header {
margin-bottom: 24px;
}
.stats-header h1 {
font-family: var(--font-mono);
font-size: 14px;
font-weight: 600;
letter-spacing: 0.15em;
color: var(--accent-cyan);
}
.stats-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.stats-section {
background: rgba(10, 15, 26, 0.6);
border: 1px solid var(--border-dim);
border-radius: var(--radius-lg);
padding: 20px;
margin-bottom: 16px;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
@media (max-width: 1199px) {
.metrics-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 767px) {
.metrics-grid { grid-template-columns: 1fr; }
}
.chart-box {
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
padding: 16px;
}
.chart-label {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.1em;
color: var(--text-dim);
text-transform: uppercase;
margin-bottom: 12px;
}
.bar-chart-group {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.insights-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
}
@media (max-width: 1199px) {
.insights-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 767px) {
.insights-grid { grid-template-columns: 1fr; }
}
.insight-card {
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
padding: 16px;
}
.insight-card h4 {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.1em;
color: var(--text-dim);
text-transform: uppercase;
margin-bottom: 12px;
}
.tag-list {
list-style: none;
padding: 0;
margin: 0;
}
.tag-list li {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 0;
border-bottom: 1px solid var(--border-dim);
font-size: 12px;
}
.tag-list li:last-child {
border-bottom: none;
}
.tag-name {
flex: 1;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tag-count {
font-family: var(--font-mono);
color: var(--accent-cyan);
font-size: 11px;
}
.token-trend {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 0;
}
.trend-value {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 24px;
font-weight: 600;
}
.trend-value.up {
color: var(--accent-red);
}
.trend-value.down {
color: var(--accent-green);
}
.trend-label {
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-dim);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.loading-state,
.error-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 12px;
gap: 12px;
}
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid var(--border-dim);
border-top-color: var(--accent-cyan);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.empty-state.small {
padding: 20px;
}
button {
padding: 8px 16px;
background: var(--accent-cyan-dim);
border: 1px solid var(--border-mid);
border-radius: var(--radius-md);
color: var(--accent-cyan);
font-family: var(--font-mono);
font-size: 11px;
cursor: pointer;
transition: all var(--transition-fast);
}
button:hover {
background: rgba(0, 245, 212, 0.2);
box-shadow: var(--glow-cyan);
}
</style>

View File

@@ -0,0 +1,419 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { todoApi, type Todo } from '@/api/todo'
import { CheckSquare, Plus, Sparkles, Calendar } from 'lucide-vue-next'
import { animate } from 'motion'
// 状态
const selectedDate = ref(new Date().toISOString().slice(0, 10)) // YYYY-MM-DD
const todos = ref<Todo[]>([])
const loading = ref(false)
const generating = ref(false)
const newTitle = ref('')
const isToday = computed(() => selectedDate.value === new Date().toISOString().slice(0, 10))
// 日期快捷切换
function formatDate(date: Date) {
return date.toISOString().slice(0, 10)
}
function goToday() {
selectedDate.value = formatDate(new Date())
}
function goYesterday() {
const d = new Date()
d.setDate(d.getDate() - 1)
selectedDate.value = formatDate(d)
}
function goBeforeYesterday() {
const d = new Date()
d.setDate(d.getDate() - 2)
selectedDate.value = formatDate(d)
}
// 加载数据
async function loadTodos() {
loading.value = true
try {
const res = await todoApi.list(selectedDate.value)
todos.value = res.data.items
} catch (e) {
console.error('加载待办失败', e)
} finally {
loading.value = false
}
}
// 新增
async function addTodo() {
if (!newTitle.value.trim()) return
try {
const res = await todoApi.create(newTitle.value.trim())
todos.value.unshift(res.data)
newTitle.value = ''
} catch (e) {
console.error('创建待办失败', e)
}
}
// 切换完成状态
async function toggleComplete(todo: Todo) {
if (!isToday.value) return
try {
const res = await todoApi.update(todo.id, { is_completed: !todo.is_completed })
const idx = todos.value.findIndex(t => t.id === todo.id)
if (idx !== -1) {
todos.value[idx] = res.data
// 播放动画
const el = document.querySelector(`[data-todo-id="${todo.id}"]`)
if (el) {
animate(el, { opacity: [0.5, 1] }, { duration: 0.3 }).play()
}
}
} catch (e) {
console.error('更新待办失败', e)
}
}
// 删除
async function deleteTodo(id: string) {
if (!isToday.value) return
try {
await todoApi.delete(id)
todos.value = todos.value.filter(t => t.id !== id)
} catch (e) {
console.error('删除待办失败', e)
}
}
// AI 生成
async function aiGenerate() {
generating.value = true
try {
const res = await todoApi.aiGenerate()
todos.value = res.data.items
} catch (e) {
console.error('AI 生成失败', e)
} finally {
generating.value = false
}
}
// 监听日期变化
watch(selectedDate, () => {
loadTodos()
})
onMounted(loadTodos)
</script>
<template>
<div class="todo-view scanlines">
<!-- 背景 -->
<div class="bg-grid"></div>
<div class="bg-glow"></div>
<!-- Header -->
<div class="view-header">
<div class="header-title">
<CheckSquare :size="16" />
<span class="title-bracket">[</span>
<span class="title-text">DAILY TODO</span>
<span class="title-bracket">]</span>
</div>
<div class="header-actions">
<div v-if="isToday" class="ai-btn" @click="aiGenerate" :class="{ loading: generating }">
<Sparkles :size="14" :class="{ 'ai-spin': generating }" />
<span>{{ generating ? '生成中...' : 'AI 规划今日' }}</span>
</div>
</div>
</div>
<!-- 日期导航 -->
<div class="date-nav">
<button class="date-btn" :class="{ active: !isToday }" @click="goBeforeYesterday">前天</button>
<button class="date-btn" @click="goYesterday">昨天</button>
<button class="date-btn primary" :class="{ active: isToday }" @click="goToday">
今天
<Calendar :size="12" />
</button>
</div>
<!-- 主内容 -->
<div class="todo-content">
<!-- 今日新增输入框 -->
<div v-if="isToday" class="add-form">
<input
v-model="newTitle"
class="add-input"
placeholder="输入待办事项,按回车添加..."
@keyup.enter="addTodo"
/>
<button class="add-btn" @click="addTodo">
<Plus :size="16" />
</button>
</div>
<!-- 待办列表 -->
<div class="todo-list">
<div
v-for="todo in todos"
:key="todo.id"
:data-todo-id="todo.id"
class="todo-item"
:class="{ completed: todo.is_completed, 'ai-source': todo.source !== 'manual' }"
>
<button class="check-btn" @click="toggleComplete(todo)" :disabled="!isToday">
<span class="check-box" :class="{ checked: todo.is_completed }">
<span v-if="todo.is_completed" class="check-mark">&#10003;</span>
</span>
</button>
<div class="todo-content">
<span class="todo-title">{{ todo.title }}</span>
<span v-if="todo.source_detail" class="todo-source">{{ todo.source_detail }}</span>
</div>
<button v-if="isToday" class="del-btn" @click="deleteTodo(todo.id)">
<span>&#215;</span>
</button>
</div>
<!-- 空状态 -->
<div v-if="!loading && todos.length === 0" class="empty-state">
<span class="empty-icon">[ ]</span>
<span class="empty-text">{{ isToday ? '今日待办为空,点击上方新增' : '该日无待办记录' }}</span>
</div>
<!-- 加载中 -->
<div v-if="loading" class="loading-state">
<span class="loading-text">LOADING...</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.todo-view {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg-void);
position: relative;
overflow: hidden;
}
.bg-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(0,245,212,0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,245,212,0.04) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
z-index: 0;
}
.bg-glow {
position: absolute;
inset: 0;
background: radial-gradient(ellipse 80% 60% at 50% 40%, rgba(0,245,212,0.05) 0%, transparent 70%);
pointer-events: none;
z-index: 0;
}
.view-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 24px;
border-bottom: 1px solid var(--border-dim);
background: rgba(5,8,16,0.6);
backdrop-filter: blur(8px);
position: relative;
z-index: 10;
}
.header-title {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-display);
font-size: 13px;
letter-spacing: 0.2em;
color: var(--text-primary);
}
.title-bracket { color: var(--accent-cyan); opacity: 0.6; }
.ai-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: rgba(249,168,37,0.08);
border: 1px solid rgba(249,168,37,0.3);
border-radius: var(--radius-sm);
color: var(--accent-amber);
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.1em;
cursor: pointer;
transition: all var(--transition-fast);
}
.ai-btn:hover { background: rgba(249,168,37,0.15); border-color: var(--accent-amber); box-shadow: 0 0 12px rgba(249,168,37,0.2); }
.ai-btn.loading { opacity: 0.7; cursor: not-allowed; }
.ai-spin { animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* 日期导航 */
.date-nav {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
border-bottom: 1px solid var(--border-dim);
}
.date-btn {
padding: 5px 14px;
background: transparent;
border: 1px solid var(--border-mid);
border-radius: var(--radius-sm);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.1em;
cursor: pointer;
transition: all var(--transition-fast);
display: flex;
align-items: center;
gap: 6px;
}
.date-btn:hover { border-color: var(--accent-cyan); color: var(--accent-cyan); }
.date-btn.active { border-color: var(--accent-cyan); color: var(--accent-cyan); background: rgba(0,245,212,0.08); }
.date-btn.primary { font-weight: 600; }
/* 内容区 */
.todo-content {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
}
.add-form {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
.add-input {
flex: 1;
padding: 10px 16px;
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-md);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 13px;
transition: all var(--transition-fast);
}
.add-input:focus {
outline: none;
border-color: var(--accent-cyan);
box-shadow: 0 0 0 2px rgba(0,245,212,0.1);
}
.add-input::placeholder { color: var(--text-dim); }
.add-btn {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0,245,212,0.08);
border: 1px solid rgba(0,245,212,0.3);
border-radius: var(--radius-md);
color: var(--accent-cyan);
cursor: pointer;
transition: all var(--transition-fast);
}
.add-btn:hover { background: rgba(0,245,212,0.15); box-shadow: var(--glow-cyan); }
/* 待办列表 */
.todo-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.todo-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--bg-card);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
transition: all var(--transition-fast);
}
.todo-item:hover { border-color: var(--border-mid); }
.todo-item.ai-source { border-left: 2px solid var(--accent-amber); }
.todo-item.completed { opacity: 0.5; }
.todo-item.completed .todo-title { text-decoration: line-through; color: var(--text-dim); }
.check-btn {
background: none;
border: none;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
}
.check-btn:disabled { cursor: default; }
.check-box {
width: 18px;
height: 18px;
border: 1px solid var(--border-mid);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
}
.check-box.checked { background: var(--accent-cyan); border-color: var(--accent-cyan); }
.check-mark { color: var(--bg-void); font-size: 12px; font-weight: bold; }
.todo-content { flex: 1; min-width: 0; }
.todo-title { display: block; font-size: 13px; color: var(--text-primary); font-family: var(--font-mono); }
.todo-source { display: block; font-size: 10px; color: var(--text-dim); margin-top: 3px; font-family: var(--font-mono); }
.del-btn {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
font-size: 18px;
border-radius: 4px;
transition: all var(--transition-fast);
}
.del-btn:hover { color: var(--accent-red); background: rgba(255,71,87,0.1); }
/* 空/加载状态 */
.empty-state, .loading-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60px 20px;
gap: 12px;
}
.empty-icon { font-family: var(--font-mono); font-size: 32px; color: var(--text-dim); opacity: 0.3; }
.empty-text { font-family: var(--font-mono); font-size: 12px; color: var(--text-dim); letter-spacing: 0.1em; }
.loading-text { font-family: var(--font-mono); font-size: 11px; color: var(--accent-cyan); letter-spacing: 0.2em; animation: pulse 1s ease-in-out infinite; }
@keyframes pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
</style>

View File

@@ -0,0 +1,18 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

20
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
},
})