Add Vue frontend application
This commit is contained in:
1
frontend/.env.example
Normal file
1
frontend/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_URL=http://localhost:8000
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal 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
14
frontend/Dockerfile
Normal 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
5
frontend/README.md
Normal 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
13
frontend/index.html
Normal 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
18
frontend/nginx.conf
Normal 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
2095
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
frontend/package.json
Normal file
30
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
frontend/public/favicon.svg
Normal file
1
frontend/public/favicon.svg
Normal file
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
24
frontend/public/icons.svg
Normal 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
7
frontend/src/App.vue
Normal 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
34
frontend/src/api/agent.ts
Normal 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
|
||||
},
|
||||
}
|
||||
44
frontend/src/api/conversation.ts
Normal file
44
frontend/src/api/conversation.ts
Normal 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,
|
||||
})
|
||||
},
|
||||
}
|
||||
73
frontend/src/api/document.ts
Normal file
73
frontend/src/api/document.ts
Normal 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`)
|
||||
},
|
||||
}
|
||||
47
frontend/src/api/folder.ts
Normal file
47
frontend/src/api/folder.ts
Normal 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
49
frontend/src/api/forum.ts
Normal 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
56
frontend/src/api/graph.ts
Normal 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
29
frontend/src/api/index.ts
Normal 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
|
||||
71
frontend/src/api/settings.ts
Normal file
71
frontend/src/api/settings.ts
Normal 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
17
frontend/src/api/stats.ts
Normal 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
35
frontend/src/api/task.ts
Normal 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
59
frontend/src/api/todo.ts
Normal 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 } : {},
|
||||
})
|
||||
},
|
||||
}
|
||||
BIN
frontend/src/assets/hero.png
Normal file
BIN
frontend/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
1
frontend/src/assets/vite.svg
Normal file
1
frontend/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.svg
Normal 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 |
176
frontend/src/components/FolderTree.vue
Normal file
176
frontend/src/components/FolderTree.vue
Normal 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>
|
||||
93
frontend/src/components/HelloWorld.vue
Normal file
93
frontend/src/components/HelloWorld.vue
Normal 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>
|
||||
378
frontend/src/components/SidebarNav.vue
Normal file
378
frontend/src/components/SidebarNav.vue
Normal 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>
|
||||
123
frontend/src/components/chat/EmojiPicker.vue
Normal file
123
frontend/src/components/chat/EmojiPicker.vue
Normal 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>
|
||||
95
frontend/src/components/chat/FileMessage.vue
Normal file
95
frontend/src/components/chat/FileMessage.vue
Normal 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>
|
||||
74
frontend/src/components/stats/MetricCard.vue
Normal file
74
frontend/src/components/stats/MetricCard.vue
Normal 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>
|
||||
62
frontend/src/components/stats/MiniBarChart.vue
Normal file
62
frontend/src/components/stats/MiniBarChart.vue
Normal 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>
|
||||
87
frontend/src/components/stats/MiniLineChart.vue
Normal file
87
frontend/src/components/stats/MiniLineChart.vue
Normal 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>
|
||||
73
frontend/src/components/stats/SectionHeader.vue
Normal file
73
frontend/src/components/stats/SectionHeader.vue
Normal 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>
|
||||
55
frontend/src/components/stats/SummaryRow.vue
Normal file
55
frontend/src/components/stats/SummaryRow.vue
Normal 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>
|
||||
74
frontend/src/data/agents.ts
Normal file
74
frontend/src/data/agents.ts
Normal 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
13
frontend/src/main.ts
Normal 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')
|
||||
83
frontend/src/router/index.ts
Normal file
83
frontend/src/router/index.ts
Normal 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
|
||||
44
frontend/src/stores/auth.ts
Normal file
44
frontend/src/stores/auth.ts
Normal 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 }
|
||||
})
|
||||
47
frontend/src/stores/conversation.ts
Normal file
47
frontend/src/stores/conversation.ts
Normal 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
207
frontend/src/style.css
Normal 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;
|
||||
}
|
||||
738
frontend/src/views/AgentView.vue
Normal file
738
frontend/src/views/AgentView.vue
Normal 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>
|
||||
924
frontend/src/views/ChatView.vue
Normal file
924
frontend/src/views/ChatView.vue
Normal 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>
|
||||
362
frontend/src/views/ForumView.vue
Normal file
362
frontend/src/views/ForumView.vue
Normal 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>
|
||||
468
frontend/src/views/GraphView.vue
Normal file
468
frontend/src/views/GraphView.vue
Normal 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>
|
||||
478
frontend/src/views/KanbanView.vue
Normal file
478
frontend/src/views/KanbanView.vue
Normal 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>
|
||||
1022
frontend/src/views/KnowledgeView.vue
Normal file
1022
frontend/src/views/KnowledgeView.vue
Normal file
File diff suppressed because it is too large
Load Diff
29
frontend/src/views/LayoutView.vue
Normal file
29
frontend/src/views/LayoutView.vue
Normal 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>
|
||||
522
frontend/src/views/LoginView.vue
Normal file
522
frontend/src/views/LoginView.vue
Normal 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>
|
||||
937
frontend/src/views/SettingsView.vue
Normal file
937
frontend/src/views/SettingsView.vue
Normal 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>
|
||||
482
frontend/src/views/StatsView.vue
Normal file
482
frontend/src/views/StatsView.vue
Normal 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>
|
||||
419
frontend/src/views/TodoView.vue
Normal file
419
frontend/src/views/TodoView.vue
Normal 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">✓</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>×</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>
|
||||
18
frontend/tsconfig.app.json
Normal file
18
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal 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
20
frontend/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user