新增: 初始化 DevFlow 项目仓库
Tauri 2 + Vue 3 + Vite 6 桌面应用,Rust workspace 含 13 个 crate (df-ai / df-storage / df-workflow / df-core / df-execute 等)。 核心能力:AI 聊天 agentic 循环(工具调用+人工审批)、工作流引擎、 任务/想法/项目/阶段管理、可追溯性,及配套前端组件。
This commit is contained in:
440
src/App.vue
Normal file
440
src/App.vue
Normal file
@@ -0,0 +1,440 @@
|
||||
<template>
|
||||
<!-- 分离窗口:纯内容,不渲染主布局 -->
|
||||
<div v-if="isDetached" class="df-detached-root">
|
||||
<router-view v-slot="{ Component }">
|
||||
<component :is="Component" />
|
||||
</router-view>
|
||||
</div>
|
||||
<!-- 主窗口:完整布局 -->
|
||||
<div v-else class="df-shell">
|
||||
<!-- ═══ Sidebar ═══ -->
|
||||
<aside class="df-sidebar">
|
||||
<!-- Logo -->
|
||||
<div class="sidebar-brand">
|
||||
<div class="brand-mark">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" fill="url(#brand-grad)" />
|
||||
<defs><linearGradient id="brand-grad" x1="3" y1="2" x2="21" y2="22">
|
||||
<stop stop-color="#7b6ff0"/><stop offset="1" stop-color="#5eaff0"/>
|
||||
</linearGradient></defs>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="brand-text">DevFlow</span>
|
||||
<span class="brand-version">v0.1</span>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="sidebar-nav">
|
||||
<div class="nav-group">
|
||||
<span class="nav-group-label">{{ $t('nav.workspace') }}</span>
|
||||
<router-link
|
||||
v-for="item in primaryNav" :key="item.path"
|
||||
:to="item.path"
|
||||
class="nav-link"
|
||||
active-class="nav-link--active"
|
||||
>
|
||||
<span class="nav-link-icon" v-html="item.svg"></span>
|
||||
<span class="nav-link-text">{{ $t(item.label) }}</span>
|
||||
<span v-if="item.badge" class="nav-link-badge">{{ item.badge }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div class="nav-divider"></div>
|
||||
|
||||
<div class="nav-group">
|
||||
<span class="nav-group-label">{{ $t('nav.traceability') }}</span>
|
||||
<router-link
|
||||
v-for="item in secondaryNav" :key="item.path"
|
||||
:to="item.path"
|
||||
class="nav-link"
|
||||
active-class="nav-link--active"
|
||||
>
|
||||
<span class="nav-link-icon" v-html="item.svg"></span>
|
||||
<span class="nav-link-text">{{ $t(item.label) }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="sidebar-footer">
|
||||
<div class="sidebar-status">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">{{ $t('nav.systemReady') }}</span>
|
||||
</div>
|
||||
<!-- AI 面板切换按钮 -->
|
||||
<button class="nav-link ai-toggle-btn" :class="{ 'ai-toggle-btn--active': aiStore.state.panelOpen }" @click="aiStore.togglePanel()">
|
||||
<span class="nav-link-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1H9a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/><line x1="9" y1="21" x2="15" y2="21"/></svg>
|
||||
</span>
|
||||
<span class="nav-link-text">{{ $t('nav.aiPanel') }}</span>
|
||||
<span class="ai-toggle-shortcut">⌘I</span>
|
||||
</button>
|
||||
<router-link to="/settings" class="nav-link" active-class="nav-link--active">
|
||||
<span class="nav-link-icon">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z"/></svg>
|
||||
</span>
|
||||
<span class="nav-link-text">{{ $t('nav.settings') }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- ═══ Main (页面内容) ═══ -->
|
||||
<main class="df-main" v-show="!aiStore.state.maximized">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="page" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</main>
|
||||
|
||||
<!-- ═══ AI Chat Panel ═══ -->
|
||||
<transition name="ai-slide">
|
||||
<div v-if="aiStore.state.panelOpen && !aiStore.state.detached" class="df-ai-panel" :style="panelStyle">
|
||||
<div v-show="!aiStore.state.maximized" class="ai-resize-handle" :class="{ active: resizing }" @mousedown="startResize"></div>
|
||||
<AiChat />
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAiStore } from './stores/ai'
|
||||
import AiChat from './components/AiChat.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const isDetached = computed(() => route.path === '/ai-detached')
|
||||
const aiStore = useAiStore()
|
||||
|
||||
// 点击菜单自动退出最大化 → 切为分栏模式
|
||||
watch(() => route.path, (path) => {
|
||||
if (!['/', '/ai-home', '/ai-detached'].includes(path) && aiStore.state.maximized) {
|
||||
aiStore.state.maximized = false
|
||||
}
|
||||
})
|
||||
|
||||
// ── AI 面板拖拽调宽 ──
|
||||
const panelWidth = ref(parseInt(localStorage.getItem('df-ai-width') || '500', 10))
|
||||
const resizing = ref(false)
|
||||
|
||||
// maximized=全宽(flex:1填充) | sidebar=固定宽
|
||||
const panelStyle = computed(() => {
|
||||
if (aiStore.state.maximized) return { flex: '1', minWidth: '0' }
|
||||
return {
|
||||
width: panelWidth.value + 'px',
|
||||
minWidth: panelWidth.value + 'px',
|
||||
maxWidth: panelWidth.value + 'px',
|
||||
}
|
||||
})
|
||||
|
||||
function startResize(e: MouseEvent) {
|
||||
e.preventDefault()
|
||||
resizing.value = true
|
||||
const startX = e.clientX
|
||||
const startWidth = panelWidth.value
|
||||
|
||||
document.body.style.cursor = 'col-resize'
|
||||
document.body.style.userSelect = 'none'
|
||||
|
||||
function onMouseMove(ev: MouseEvent) {
|
||||
const delta = startX - ev.clientX // 向左拖 = 增宽
|
||||
const newWidth = Math.max(320, Math.min(800, startWidth + delta))
|
||||
panelWidth.value = newWidth
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
resizing.value = false
|
||||
document.body.style.cursor = ''
|
||||
document.body.style.userSelect = ''
|
||||
localStorage.setItem('df-ai-width', String(panelWidth.value))
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
|
||||
// 主题初始化
|
||||
function initTheme() {
|
||||
const saved = localStorage.getItem('df-theme') || 'dark'
|
||||
if (saved === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', 'light')
|
||||
} else if (saved === 'system') {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
if (!prefersDark) document.documentElement.setAttribute('data-theme', 'light')
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+I / Cmd+I 切换 AI 面板
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'i') {
|
||||
e.preventDefault()
|
||||
aiStore.togglePanel()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initTheme()
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
const primaryNav = [
|
||||
{ path: '/dashboard', label: 'nav.overview', badge: null,
|
||||
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>' },
|
||||
{ path: '/ideas', label: 'nav.ideas', badge: null,
|
||||
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18h6M10 22h4M12 2a7 7 0 014 12.74V17a1 1 0 01-1 1h-6a1 1 0 01-1-1v-2.26A7 7 0 0112 2z"/></svg>' },
|
||||
{ path: '/projects', label: 'nav.projects', badge: null,
|
||||
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/></svg>' },
|
||||
{ path: '/tasks', label: 'nav.tasks', badge: null,
|
||||
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>' },
|
||||
]
|
||||
|
||||
const secondaryNav = [
|
||||
{ path: '/knowledge', label: 'nav.knowledge',
|
||||
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>' },
|
||||
{ path: '/decisions', label: 'nav.decisions',
|
||||
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.df-detached-root { width: 100vw; height: 100vh; overflow: hidden; background: var(--df-bg); }
|
||||
.df-shell { display: flex; height: 100vh; width: 100vw; overflow: hidden; }
|
||||
|
||||
/* ═══ Sidebar ═══ */
|
||||
.df-sidebar {
|
||||
width: 192px;
|
||||
min-width: 192px;
|
||||
background: var(--df-sidebar-bg);
|
||||
border-right: 0.5px solid var(--df-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* — Logo — */
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 16px 12px;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
}
|
||||
.brand-mark {
|
||||
width: 28px; height: 28px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: var(--df-accent-bg);
|
||||
border: 0.5px solid var(--df-border);
|
||||
}
|
||||
.brand-text {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.3px;
|
||||
color: var(--df-text);
|
||||
}
|
||||
.brand-version {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
background: rgba(255,255,255,0.04);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* — Nav — */
|
||||
.sidebar-nav { flex: 1; overflow-y: auto; padding: 8px 8px; }
|
||||
|
||||
.nav-group { margin-bottom: 2px; }
|
||||
.nav-group-label {
|
||||
display: block;
|
||||
padding: 8px 10px 4px;
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1.2px;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
|
||||
.nav-divider {
|
||||
height: 0.5px;
|
||||
background: var(--df-border);
|
||||
margin: 6px 12px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 10px;
|
||||
margin: 1px 0;
|
||||
border-radius: var(--df-radius-sm);
|
||||
color: var(--df-text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
font-weight: 450;
|
||||
transition: all 0.18s var(--df-ease);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: var(--df-sidebar-hover);
|
||||
color: var(--df-text);
|
||||
}
|
||||
.nav-link--active {
|
||||
background: var(--df-sidebar-active);
|
||||
color: var(--df-accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
.nav-link--active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 16px;
|
||||
background: var(--df-accent);
|
||||
border-radius: 0 3px 3px 0;
|
||||
}
|
||||
|
||||
.nav-link-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
opacity: 0.7;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nav-link--active .nav-link-icon { opacity: 1; }
|
||||
|
||||
.nav-link-text { flex: 1; }
|
||||
|
||||
.nav-link-badge {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--df-radius);
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
background: var(--df-accent);
|
||||
color: #fff;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* — AI Toggle Button — */
|
||||
.ai-toggle-btn {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
.ai-toggle-btn--active {
|
||||
color: var(--df-accent);
|
||||
background: var(--df-sidebar-active);
|
||||
}
|
||||
.ai-toggle-shortcut {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--df-text-dim);
|
||||
background: rgba(255,255,255,0.04);
|
||||
padding: 1px 5px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
}
|
||||
|
||||
/* — Footer — */
|
||||
.sidebar-footer {
|
||||
padding: 6px 8px;
|
||||
border-top: 0.5px solid var(--df-border);
|
||||
}
|
||||
|
||||
.sidebar-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
.status-dot {
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--df-success);
|
||||
animation: pulseGlow 3s ease-in-out infinite;
|
||||
}
|
||||
.status-text {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--df-text-dim);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
/* — Main — */
|
||||
.df-main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background: var(--df-bg);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* — AI Panel (全宽) — */
|
||||
.df-ai-panel {
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* — AI Resize Handle — */
|
||||
.ai-resize-handle {
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
z-index: 10;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.ai-resize-handle:hover,
|
||||
.ai-resize-handle.active {
|
||||
background: var(--df-accent);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
/* — AI Slide Transition — */
|
||||
.ai-slide-enter-active { transition: opacity 0.2s var(--df-ease); }
|
||||
.ai-slide-leave-active { transition: opacity 0.15s var(--df-ease); }
|
||||
.ai-slide-enter-from, .ai-slide-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ═══ Responsive ═══ */
|
||||
@media (max-width: 768px) {
|
||||
.df-sidebar { width: 56px; min-width: 56px; }
|
||||
.brand-text, .brand-version, .nav-group-label, .nav-link-text,
|
||||
.nav-link-badge, .sidebar-status, .status-text, .ai-toggle-shortcut { display: none; }
|
||||
.sidebar-brand { padding: 12px 10px 10px; justify-content: center; }
|
||||
.brand-mark { width: 32px; height: 32px; }
|
||||
.nav-link { justify-content: center; padding: 8px 0; }
|
||||
.nav-divider { margin: 4px 8px; }
|
||||
.nav-link--active::before { display: none; }
|
||||
.df-ai-panel { width: 100vw; min-width: 100vw; max-width: 100vw; position: absolute; right: 0; top: 0; z-index: 100; }
|
||||
}
|
||||
|
||||
/* — Page Transition — */
|
||||
.page-enter-active { transition: opacity 0.2s var(--df-ease), transform 0.2s var(--df-ease); }
|
||||
.page-leave-active { transition: opacity 0.12s var(--df-ease), transform 0.12s var(--df-ease); }
|
||||
.page-enter-from { opacity: 0; transform: translateY(6px); }
|
||||
.page-leave-to { opacity: 0; transform: translateY(-4px); }
|
||||
</style>
|
||||
90
src/api/ai.ts
Normal file
90
src/api/ai.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
//! AI 聊天 API — IPC invoke 封装 + 事件监听
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
|
||||
import type { AiChatEvent, AiConversationDetail, AiConversationSummary, AiProviderConfig } from './types'
|
||||
|
||||
export const aiApi = {
|
||||
/** 发送消息(非阻塞,通过 ai-chat-event 流式返回) */
|
||||
sendMessage(message: string, language?: string): Promise<string> {
|
||||
return invoke('ai_chat_send', { message, language: language || 'zh-CN' })
|
||||
},
|
||||
|
||||
/** 批准/拒绝工具调用 */
|
||||
approve(toolCallId: string, approved: boolean): Promise<string> {
|
||||
return invoke('ai_approve', { toolCallId, approved })
|
||||
},
|
||||
|
||||
/** 清空对话 */
|
||||
clearChat(): Promise<void> {
|
||||
return invoke('ai_chat_clear')
|
||||
},
|
||||
|
||||
/** 停止当前生成 */
|
||||
stopChat(): Promise<void> {
|
||||
return invoke('ai_chat_stop')
|
||||
},
|
||||
|
||||
/** 列出所有 AI 提供商 */
|
||||
listProviders(): Promise<AiProviderConfig[]> {
|
||||
return invoke('ai_list_providers')
|
||||
},
|
||||
|
||||
/** 保存 AI 提供商 */
|
||||
saveProvider(input: {
|
||||
id?: string
|
||||
name: string
|
||||
providerType: string
|
||||
baseUrl: string
|
||||
apiKey: string
|
||||
defaultModel: string
|
||||
}): Promise<string> {
|
||||
return invoke('ai_save_provider', {
|
||||
id: input.id,
|
||||
name: input.name,
|
||||
providerType: input.providerType,
|
||||
baseUrl: input.baseUrl,
|
||||
apiKey: input.apiKey,
|
||||
defaultModel: input.defaultModel,
|
||||
})
|
||||
},
|
||||
|
||||
/** 设置活跃提供商 */
|
||||
setProvider(providerId: string): Promise<void> {
|
||||
return invoke('ai_set_provider', { providerId })
|
||||
},
|
||||
|
||||
/** 监听 AI 聊天事件 */
|
||||
onEvent(callback: (event: AiChatEvent) => void): Promise<UnlistenFn> {
|
||||
return listen<AiChatEvent>('ai-chat-event', (e) => {
|
||||
callback(e.payload)
|
||||
})
|
||||
},
|
||||
|
||||
// ── 对话管理 ──
|
||||
|
||||
/** 创建新对话 */
|
||||
createConversation(): Promise<{ id: string }> {
|
||||
return invoke('ai_conversation_create')
|
||||
},
|
||||
|
||||
/** 列出所有对话(摘要) */
|
||||
listConversations(): Promise<AiConversationSummary[]> {
|
||||
return invoke('ai_conversation_list')
|
||||
},
|
||||
|
||||
/** 切换到指定对话 */
|
||||
switchConversation(conversationId: string): Promise<AiConversationDetail> {
|
||||
return invoke('ai_conversation_switch', { conversationId })
|
||||
},
|
||||
|
||||
/** 删除对话 */
|
||||
deleteConversation(conversationId: string): Promise<void> {
|
||||
return invoke('ai_conversation_delete', { conversationId })
|
||||
},
|
||||
|
||||
/** 重命名对话标题 */
|
||||
renameConversation(conversationId: string, title: string): Promise<void> {
|
||||
return invoke('ai_conversation_rename', { conversationId, title })
|
||||
},
|
||||
}
|
||||
20
src/api/idea.ts
Normal file
20
src/api/idea.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import type { IdeaRecord, CreateIdeaInput } from './types'
|
||||
|
||||
export const ideaApi = {
|
||||
list(): Promise<IdeaRecord[]> {
|
||||
return invoke('list_ideas')
|
||||
},
|
||||
|
||||
create(input: CreateIdeaInput): Promise<IdeaRecord> {
|
||||
return invoke('create_idea', { input })
|
||||
},
|
||||
|
||||
update(id: string, field: string, value: string): Promise<boolean> {
|
||||
return invoke('update_idea', { id, field, value })
|
||||
},
|
||||
|
||||
delete(id: string): Promise<boolean> {
|
||||
return invoke('delete_idea', { id })
|
||||
},
|
||||
}
|
||||
6
src/api/index.ts
Normal file
6
src/api/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './types'
|
||||
export { projectApi } from './project'
|
||||
export { taskApi } from './task'
|
||||
export { ideaApi } from './idea'
|
||||
export { workflowApi } from './workflow'
|
||||
export { aiApi } from './ai'
|
||||
24
src/api/project.ts
Normal file
24
src/api/project.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import type { ProjectRecord, CreateProjectInput } from './types'
|
||||
|
||||
export const projectApi = {
|
||||
list(): Promise<ProjectRecord[]> {
|
||||
return invoke('list_projects')
|
||||
},
|
||||
|
||||
create(input: CreateProjectInput): Promise<ProjectRecord> {
|
||||
return invoke('create_project', { input })
|
||||
},
|
||||
|
||||
get(id: string): Promise<ProjectRecord | null> {
|
||||
return invoke('get_project', { id })
|
||||
},
|
||||
|
||||
update(id: string, field: string, value: string): Promise<boolean> {
|
||||
return invoke('update_project', { id, field, value })
|
||||
},
|
||||
|
||||
delete(id: string): Promise<boolean> {
|
||||
return invoke('delete_project', { id })
|
||||
},
|
||||
}
|
||||
20
src/api/task.ts
Normal file
20
src/api/task.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import type { TaskRecord, CreateTaskInput } from './types'
|
||||
|
||||
export const taskApi = {
|
||||
list(projectId?: string): Promise<TaskRecord[]> {
|
||||
return invoke('list_tasks', { projectId: projectId ?? null })
|
||||
},
|
||||
|
||||
create(input: CreateTaskInput): Promise<TaskRecord> {
|
||||
return invoke('create_task', { input })
|
||||
},
|
||||
|
||||
update(id: string, field: string, value: string): Promise<boolean> {
|
||||
return invoke('update_task', { id, field, value })
|
||||
},
|
||||
|
||||
delete(id: string): Promise<boolean> {
|
||||
return invoke('delete_task', { id })
|
||||
},
|
||||
}
|
||||
204
src/api/types.ts
Normal file
204
src/api/types.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
//! TypeScript 类型定义 — 与 Rust Record 结构体严格对齐
|
||||
|
||||
// ============================================================
|
||||
// 想法
|
||||
// ============================================================
|
||||
|
||||
export interface IdeaRecord {
|
||||
id: string
|
||||
title: string
|
||||
description: string
|
||||
status: string // draft | pending_review | approved | promoted | rejected
|
||||
priority: number
|
||||
score: number | null
|
||||
tags: string | null // JSON 数组字符串
|
||||
source: string | null
|
||||
promoted_to: string | null
|
||||
ai_analysis: string | null
|
||||
scores: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CreateIdeaInput {
|
||||
title: string
|
||||
description?: string
|
||||
priority?: number
|
||||
tags?: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 项目
|
||||
// ============================================================
|
||||
|
||||
export interface ProjectRecord {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
status: string // planning | in_progress | paused | completed | cancelled
|
||||
idea_id: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CreateProjectInput {
|
||||
name: string
|
||||
description?: string
|
||||
idea_id?: string
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 任务
|
||||
// ============================================================
|
||||
|
||||
export interface TaskRecord {
|
||||
id: string
|
||||
project_id: string
|
||||
title: string
|
||||
description: string
|
||||
status: string // todo | in_progress | review_ready | merged | abandoned
|
||||
priority: number // 0=critical, 1=high, 2=medium, 3=low
|
||||
branch_name: string | null
|
||||
assignee: string | null
|
||||
workflow_def_id: string | null
|
||||
base_branch: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CreateTaskInput {
|
||||
project_id: string
|
||||
title: string
|
||||
description?: string
|
||||
priority?: number
|
||||
branch_name?: string
|
||||
assignee?: string
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工作流
|
||||
// ============================================================
|
||||
|
||||
export interface WorkflowRecord {
|
||||
id: string
|
||||
name: string
|
||||
dag_json: string
|
||||
status: string // running | completed | failed | cancelled
|
||||
triggered_by: string | null
|
||||
project_id: string | null
|
||||
task_id: string | null
|
||||
created_at: string
|
||||
completed_at: string | null
|
||||
}
|
||||
|
||||
/** 工作流事件载荷(后端 emit 的结构) */
|
||||
export interface WorkflowEventPayload {
|
||||
execution_id: string
|
||||
event: {
|
||||
type: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 通用
|
||||
// ============================================================
|
||||
|
||||
export type UpdateField = {
|
||||
id: string
|
||||
field: string
|
||||
value: string
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// AI 聊天
|
||||
// ============================================================
|
||||
|
||||
export interface AiProviderConfig {
|
||||
id: string
|
||||
name: string
|
||||
provider_type: string
|
||||
api_key: string
|
||||
base_url: string
|
||||
default_model: string
|
||||
models: string | null
|
||||
is_default: boolean
|
||||
config: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** AI 聊天事件(后端 emit 的结构,conversation_id 用于多对话流式路由) */
|
||||
export type AiChatEvent = ({
|
||||
type: 'AiTextDelta'; delta: string
|
||||
} | {
|
||||
type: 'AiToolCallStarted'; id: string; name: string; args: unknown
|
||||
} | {
|
||||
type: 'AiToolCallCompleted'; id: string; result: unknown
|
||||
} | {
|
||||
type: 'AiApprovalRequired'; id: string; name: string; args: unknown; reason: string
|
||||
} | {
|
||||
type: 'AiApprovalResult'; id: string; approved: boolean
|
||||
} | {
|
||||
type: 'AiCompleted'; total_tokens: number
|
||||
} | {
|
||||
type: 'AiError'; error: string
|
||||
} | {
|
||||
type: 'AiAgentRound'; round: number
|
||||
}) & {
|
||||
conversation_id?: string
|
||||
}
|
||||
|
||||
/** AI 消息(前端渲染用,isError 标记错误消息用于差异化样式) */
|
||||
export interface AiMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant' | 'tool'
|
||||
content: string
|
||||
isError?: boolean
|
||||
toolCalls?: AiToolCallInfo[]
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/** 工具调用信息 */
|
||||
export interface AiToolCallInfo {
|
||||
id: string
|
||||
name: string
|
||||
args: unknown
|
||||
status: 'running' | 'pending_approval' | 'completed' | 'rejected'
|
||||
result?: unknown
|
||||
}
|
||||
|
||||
/** 对话列表摘要 */
|
||||
export interface AiConversationSummary {
|
||||
id: string
|
||||
title: string | null
|
||||
provider_id: string | null
|
||||
model: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/** 人工审批请求 */
|
||||
export interface HumanApprovalRequest {
|
||||
execution_id: string
|
||||
node_id: string
|
||||
title: string
|
||||
description: string
|
||||
options: string[]
|
||||
}
|
||||
|
||||
/** 人工审批响应 */
|
||||
export interface HumanApprovalResponse {
|
||||
execution_id: string
|
||||
node_id: string
|
||||
decision: string
|
||||
comment?: string
|
||||
}
|
||||
|
||||
/** 切换对话返回(含 messages JSON) */
|
||||
export interface AiConversationDetail {
|
||||
id: string
|
||||
title: string | null
|
||||
messages: string // JSON string of ChatMessage[]
|
||||
}
|
||||
29
src/api/workflow.ts
Normal file
29
src/api/workflow.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import type { WorkflowRecord, WorkflowEventPayload } from './types'
|
||||
|
||||
export const workflowApi = {
|
||||
/** 触发工作流执行,返回 execution_id */
|
||||
run(name: string, dag: unknown, config?: Record<string, unknown>): Promise<string> {
|
||||
return invoke('run_workflow', {
|
||||
name,
|
||||
dag,
|
||||
config: config ?? {},
|
||||
})
|
||||
},
|
||||
|
||||
listExecutions(): Promise<WorkflowRecord[]> {
|
||||
return invoke('list_workflow_executions')
|
||||
},
|
||||
|
||||
getExecution(id: string): Promise<WorkflowRecord | null> {
|
||||
return invoke('get_workflow_execution', { id })
|
||||
},
|
||||
|
||||
/** 监听工作流实时事件,返回 unlisten 函数 */
|
||||
onEvent(callback: (payload: WorkflowEventPayload) => void): Promise<() => void> {
|
||||
return listen<WorkflowEventPayload>('workflow-event', (event) => {
|
||||
callback(event.payload)
|
||||
})
|
||||
},
|
||||
}
|
||||
1348
src/components/AiChat.vue
Normal file
1348
src/components/AiChat.vue
Normal file
File diff suppressed because it is too large
Load Diff
78
src/i18n/en.ts
Normal file
78
src/i18n/en.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
export default {
|
||||
nav: {
|
||||
overview: 'Overview',
|
||||
ideas: 'Ideas',
|
||||
projects: 'Projects',
|
||||
tasks: 'Tasks',
|
||||
knowledge: 'Knowledge',
|
||||
decisions: 'Decisions',
|
||||
settings: 'Settings',
|
||||
workspace: 'Workspace',
|
||||
traceability: 'Traceability',
|
||||
systemReady: 'System Ready',
|
||||
aiPanel: 'AI Panel',
|
||||
},
|
||||
dashboard: {
|
||||
title: 'Overview',
|
||||
subtitle: 'All systems operational',
|
||||
refresh: 'Refresh',
|
||||
captureIdea: 'Capture Idea',
|
||||
stats: {
|
||||
ideas: 'Ideas',
|
||||
projects: 'Projects',
|
||||
activeTasks: 'Active Tasks',
|
||||
drafts: 'Drafts',
|
||||
annotations: 'Annotations',
|
||||
},
|
||||
activeProjects: 'Active Projects',
|
||||
viewAll: 'View all',
|
||||
ideaPool: 'Idea Pool',
|
||||
recentDecisions: 'Recent Decisions',
|
||||
openAnnotations: 'Open Annotations',
|
||||
aiBatchProcess: 'AI Batch Process',
|
||||
tasks: '{count} tasks',
|
||||
taskUnit: '{n} tasks',
|
||||
trending: 'Trending',
|
||||
approved: 'Approved',
|
||||
pending: 'Pending',
|
||||
reviewing: 'Reviewing',
|
||||
coding: 'Coding',
|
||||
testing: 'Testing',
|
||||
release: 'Release',
|
||||
empty: {
|
||||
noProjects: 'No projects yet',
|
||||
noIdeas: 'No ideas yet, click above to capture',
|
||||
noData: 'No data',
|
||||
},
|
||||
stage: {
|
||||
planning: 'Planning',
|
||||
coding: 'Coding',
|
||||
paused: 'Paused',
|
||||
done: 'Done',
|
||||
cancelled: 'Cancelled',
|
||||
},
|
||||
ideaStatus: {
|
||||
draft: 'Draft',
|
||||
pending_review: 'Pending',
|
||||
approved: 'Approved',
|
||||
promoted: 'Promoted',
|
||||
rejected: 'Rejected',
|
||||
},
|
||||
},
|
||||
ai: {
|
||||
assistant: 'AI Assistant',
|
||||
toolName: {
|
||||
readFile: 'Read File',
|
||||
listDirectory: 'List Directory',
|
||||
writeFile: 'Write File',
|
||||
},
|
||||
},
|
||||
common: {
|
||||
justNow: 'just now',
|
||||
minutesAgo: '{n}m ago',
|
||||
hoursAgo: '{n}h ago',
|
||||
dayAgo: '{n}d ago',
|
||||
yesterday: 'Yesterday',
|
||||
ago: '{time} ago',
|
||||
},
|
||||
}
|
||||
17
src/i18n/index.ts
Normal file
17
src/i18n/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import zhCN from './zh-CN'
|
||||
import en from './en'
|
||||
|
||||
// 界面语言:跟随 localStorage 中用户选择,默认简体中文
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
globalInjection: true, // 模板中可直接用 $t / $tc
|
||||
locale: localStorage.getItem('df-language') || 'zh-CN',
|
||||
fallbackLocale: 'en',
|
||||
messages: {
|
||||
'zh-CN': zhCN,
|
||||
en,
|
||||
},
|
||||
})
|
||||
|
||||
export default i18n
|
||||
78
src/i18n/zh-CN.ts
Normal file
78
src/i18n/zh-CN.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
export default {
|
||||
nav: {
|
||||
overview: '总览',
|
||||
ideas: '灵感',
|
||||
projects: '项目',
|
||||
tasks: '任务',
|
||||
knowledge: '知识库',
|
||||
decisions: '决策',
|
||||
settings: '设置',
|
||||
workspace: '工作区',
|
||||
traceability: '追溯',
|
||||
systemReady: '系统就绪',
|
||||
aiPanel: 'AI 面板',
|
||||
},
|
||||
dashboard: {
|
||||
title: '总览',
|
||||
subtitle: '所有系统运行正常',
|
||||
refresh: '刷新',
|
||||
captureIdea: '捕捉灵感',
|
||||
stats: {
|
||||
ideas: '灵感',
|
||||
projects: '项目',
|
||||
activeTasks: '活跃任务',
|
||||
drafts: '草稿',
|
||||
annotations: '待处理标注',
|
||||
},
|
||||
activeProjects: '活跃项目',
|
||||
viewAll: '查看全部',
|
||||
ideaPool: '灵感',
|
||||
recentDecisions: '最近决策',
|
||||
openAnnotations: '待处理标注',
|
||||
aiBatchProcess: 'AI 批量处理',
|
||||
tasks: '{count} 个任务',
|
||||
taskUnit: '{n} 个任务',
|
||||
trending: '热门',
|
||||
approved: '已立项',
|
||||
pending: '待定',
|
||||
reviewing: '评估中',
|
||||
coding: '编码中',
|
||||
testing: '测试中',
|
||||
release: '发布中',
|
||||
empty: {
|
||||
noProjects: '暂无项目,去创建一个',
|
||||
noIdeas: '暂无灵感,点击上方按钮捕捉',
|
||||
noData: '暂无数据',
|
||||
},
|
||||
stage: {
|
||||
planning: '规划中',
|
||||
coding: '编码中',
|
||||
paused: '已暂停',
|
||||
done: '已完成',
|
||||
cancelled: '已取消',
|
||||
},
|
||||
ideaStatus: {
|
||||
draft: '草稿',
|
||||
pending_review: '待评估',
|
||||
approved: '已立项',
|
||||
promoted: '已转立项',
|
||||
rejected: '已驳回',
|
||||
},
|
||||
},
|
||||
ai: {
|
||||
assistant: 'AI 助手',
|
||||
toolName: {
|
||||
readFile: '读取文件',
|
||||
listDirectory: '查看目录',
|
||||
writeFile: '写入文件',
|
||||
},
|
||||
},
|
||||
common: {
|
||||
justNow: '刚刚',
|
||||
minutesAgo: '{n} 分钟前',
|
||||
hoursAgo: '{n} 小时前',
|
||||
dayAgo: '{n} 天前',
|
||||
yesterday: '昨天',
|
||||
ago: '{time}前',
|
||||
},
|
||||
}
|
||||
11
src/main.ts
Normal file
11
src/main.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import i18n from "./i18n";
|
||||
import "./styles/global.css";
|
||||
|
||||
createApp(App).use(router).use(i18n).mount("#app");
|
||||
|
||||
// 启动计时:从 index.html 解析到 Vue 挂载完成
|
||||
const t0 = (window as any).__APP_T0 ?? 0;
|
||||
console.log(`[启动] Vue mount 完成: ${(performance.now() - t0).toFixed(0)}ms`);
|
||||
69
src/router/index.ts
Normal file
69
src/router/index.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/ai-home',
|
||||
},
|
||||
{
|
||||
path: '/ai-home',
|
||||
name: 'AiHome',
|
||||
component: () => import('../views/AiHome.vue'),
|
||||
meta: { title: 'AI Chat' },
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('../views/Dashboard.vue'),
|
||||
meta: { title: '总览', icon: 'icon-dashboard' },
|
||||
},
|
||||
{
|
||||
path: '/ideas',
|
||||
name: 'Ideas',
|
||||
component: () => import('../views/Ideas.vue'),
|
||||
meta: { title: '灵感', icon: 'icon-lightbulb' },
|
||||
},
|
||||
{
|
||||
path: '/projects',
|
||||
name: 'Projects',
|
||||
component: () => import('../views/Projects.vue'),
|
||||
meta: { title: '项目', icon: 'icon-apps' },
|
||||
},
|
||||
{
|
||||
path: '/projects/:id',
|
||||
name: 'ProjectDetail',
|
||||
component: () => import('../views/ProjectDetail.vue'),
|
||||
meta: { title: '项目详情', icon: 'icon-apps' },
|
||||
},
|
||||
{
|
||||
path: '/tasks',
|
||||
name: 'Tasks',
|
||||
component: () => import('../views/Tasks.vue'),
|
||||
meta: { title: '任务', icon: 'icon-thunder' },
|
||||
},
|
||||
{
|
||||
path: '/knowledge',
|
||||
name: 'Knowledge',
|
||||
component: () => import('../views/Knowledge.vue'),
|
||||
meta: { title: '知识库', icon: 'icon-book' },
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'Settings',
|
||||
component: () => import('../views/Settings.vue'),
|
||||
meta: { title: '设置', icon: 'icon-settings' },
|
||||
},
|
||||
{
|
||||
path: '/ai-detached',
|
||||
name: 'AiDetached',
|
||||
component: () => import('../views/AiDetached.vue'),
|
||||
meta: { title: 'AI Chat', icon: 'icon-ai' },
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
export default router
|
||||
509
src/stores/ai.ts
Normal file
509
src/stores/ai.ts
Normal file
@@ -0,0 +1,509 @@
|
||||
//! AI 聊天 Store — 管理对话状态、流式渲染、工具审批、对话管理
|
||||
|
||||
import { reactive } from 'vue'
|
||||
import { listen, emit } from '@tauri-apps/api/event'
|
||||
import { aiApi } from '../api'
|
||||
import type { AiChatEvent, AiConversationSummary, AiMessage, AiProviderConfig, AiToolCallInfo } from '../api/types'
|
||||
|
||||
let _unlistenAiEvent: (() => void) | null = null
|
||||
let _unlistenConvChanged: (() => void) | null = null
|
||||
let _msgCounter = 0
|
||||
|
||||
const state = reactive({
|
||||
messages: [] as AiMessage[],
|
||||
streaming: false,
|
||||
currentText: '',
|
||||
pendingApprovals: [] as AiToolCallInfo[],
|
||||
// 正在生成的对话 id:生成中切走时用于路由,后台事件不污染当前视图
|
||||
generatingConvId: null as string | null,
|
||||
providers: [] as AiProviderConfig[],
|
||||
activeProvider: null as string | null,
|
||||
panelOpen: true,
|
||||
// 面板模式:maximized=全宽占满main | sidebar=固定宽侧栏(可拖拽)
|
||||
maximized: true,
|
||||
// 对话管理
|
||||
conversations: [] as AiConversationSummary[],
|
||||
activeConversationId: null as string | null,
|
||||
sidebarOpen: false,
|
||||
// 窗口分离
|
||||
detached: false,
|
||||
// 吸附跟随中
|
||||
docked: false,
|
||||
})
|
||||
|
||||
export function useAiStore() {
|
||||
async function startListener() {
|
||||
if (_unlistenAiEvent) return
|
||||
_unlistenAiEvent = await aiApi.onEvent(handleEvent)
|
||||
_unlistenConvChanged = await listen('ai-conversation-changed', () => {
|
||||
loadConversations()
|
||||
})
|
||||
}
|
||||
|
||||
function stopListener() {
|
||||
_unlistenAiEvent?.()
|
||||
_unlistenConvChanged?.()
|
||||
_unlistenAiEvent = null
|
||||
_unlistenConvChanged = null
|
||||
}
|
||||
|
||||
function notifyConversationChanged() {
|
||||
emit('ai-conversation-changed', {})
|
||||
}
|
||||
|
||||
/** 后端原始错误转用户友好提示 */
|
||||
function friendlyError(raw: string): string {
|
||||
if (/404|not\s*found/i.test(raw)) return '调用失败:接口地址或模型不存在,请检查 Provider 配置'
|
||||
if (/401|403|unauthorized|api[_\s-]?key/i.test(raw)) return '调用失败:API Key 无效或无权限'
|
||||
if (/timeout|超时/i.test(raw)) return '响应超时,请重试'
|
||||
if (/network|connection|ECONN|网络|连接/i.test(raw)) return '网络连接失败,请检查网络'
|
||||
return raw
|
||||
}
|
||||
|
||||
function handleEvent(event: AiChatEvent) {
|
||||
const convId = event.conversation_id
|
||||
// 首次收到事件时同步当前对话 id(后端自动建对话的场景)
|
||||
if (convId && !state.activeConversationId) state.activeConversationId = convId
|
||||
// 事件不属于当前展示对话(生成中切走了)→ 不污染当前视图,仅完成/错误时刷新侧边栏
|
||||
const isCurrent = !convId || convId === state.activeConversationId
|
||||
if (!isCurrent) {
|
||||
if (event.type === 'AiCompleted' || event.type === 'AiError') {
|
||||
state.generatingConvId = null
|
||||
loadConversations()
|
||||
}
|
||||
return
|
||||
}
|
||||
// 标记正在生成的对话(完成/错误事件除外)
|
||||
if (convId && event.type !== 'AiCompleted' && event.type !== 'AiError') {
|
||||
state.generatingConvId = convId
|
||||
}
|
||||
switch (event.type) {
|
||||
case 'AiTextDelta':
|
||||
state.currentText += event.delta
|
||||
break
|
||||
|
||||
case 'AiAgentRound': {
|
||||
// Agent 循环新一轮:保存当前文本到上一条 assistant 消息,新建空 assistant 消息
|
||||
if (state.currentText) {
|
||||
const lastMsg = state.messages[state.messages.length - 1]
|
||||
if (lastMsg && lastMsg.role === 'assistant') {
|
||||
lastMsg.content = state.currentText
|
||||
}
|
||||
}
|
||||
state.currentText = ''
|
||||
state.messages.push({
|
||||
id: `ai-${++_msgCounter}`,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiToolCallStarted': {
|
||||
const info: AiToolCallInfo = {
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
args: event.args,
|
||||
status: 'running',
|
||||
}
|
||||
const lastMsg = state.messages[state.messages.length - 1]
|
||||
if (lastMsg && lastMsg.role === 'assistant') {
|
||||
lastMsg.toolCalls = lastMsg.toolCalls || []
|
||||
lastMsg.toolCalls.push(info)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiToolCallCompleted': {
|
||||
const tc = findToolCall(event.id)
|
||||
if (tc) {
|
||||
tc.status = 'completed'
|
||||
tc.result = event.result
|
||||
}
|
||||
state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id)
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiApprovalRequired': {
|
||||
const info: AiToolCallInfo = {
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
args: event.args,
|
||||
status: 'pending_approval',
|
||||
}
|
||||
state.pendingApprovals.push(info)
|
||||
const tc = findToolCall(event.id)
|
||||
if (tc) tc.status = 'pending_approval'
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiApprovalResult': {
|
||||
if (!event.approved) {
|
||||
const tc = findToolCall(event.id)
|
||||
if (tc) tc.status = 'rejected'
|
||||
state.pendingApprovals = state.pendingApprovals.filter(p => p.id !== event.id)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiCompleted': {
|
||||
if (state.currentText) {
|
||||
const lastMsg = state.messages[state.messages.length - 1]
|
||||
if (lastMsg && lastMsg.role === 'assistant') {
|
||||
lastMsg.content = state.currentText
|
||||
}
|
||||
}
|
||||
state.currentText = ''
|
||||
state.streaming = false
|
||||
state.generatingConvId = null
|
||||
// 清理分离窗口生成态快照
|
||||
localStorage.removeItem('df-ai-gen')
|
||||
localStorage.removeItem('df-ai-text')
|
||||
loadConversations()
|
||||
notifyConversationChanged()
|
||||
break
|
||||
}
|
||||
|
||||
case 'AiError': {
|
||||
state.streaming = false
|
||||
state.generatingConvId = null
|
||||
state.currentText = ''
|
||||
localStorage.removeItem('df-ai-gen')
|
||||
localStorage.removeItem('df-ai-text')
|
||||
state.messages.push({
|
||||
id: `err-${++_msgCounter}`,
|
||||
role: 'assistant',
|
||||
content: friendlyError(event.error),
|
||||
isError: true,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findToolCall(id: string): AiToolCallInfo | undefined {
|
||||
for (const msg of state.messages) {
|
||||
if (msg.toolCalls) {
|
||||
const tc = msg.toolCalls.find(t => t.id === id)
|
||||
if (tc) return tc
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function sendMessage(text: string) {
|
||||
if (!text.trim() || state.streaming) return
|
||||
|
||||
state.messages.push({
|
||||
id: `user-${++_msgCounter}`,
|
||||
role: 'user',
|
||||
content: text.trim(),
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
state.messages.push({
|
||||
id: `ai-${++_msgCounter}`,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
state.streaming = true
|
||||
state.currentText = ''
|
||||
|
||||
await startListener()
|
||||
const raw = localStorage.getItem('df-ai-language') || 'auto'
|
||||
const lang = raw === 'auto'
|
||||
? (localStorage.getItem('df-language') || 'zh-CN')
|
||||
: raw
|
||||
await aiApi.sendMessage(text.trim(), lang)
|
||||
}
|
||||
|
||||
async function approveToolCall(toolCallId: string, approved: boolean) {
|
||||
await aiApi.approve(toolCallId, approved)
|
||||
}
|
||||
|
||||
async function loadProviders() {
|
||||
state.providers = await aiApi.listProviders()
|
||||
}
|
||||
|
||||
async function setProvider(providerId: string) {
|
||||
await aiApi.setProvider(providerId)
|
||||
state.activeProvider = providerId
|
||||
}
|
||||
|
||||
async function clearChat() {
|
||||
await aiApi.clearChat()
|
||||
state.messages = []
|
||||
state.currentText = ''
|
||||
state.pendingApprovals = []
|
||||
state.streaming = false
|
||||
}
|
||||
|
||||
/** 停止当前生成:仅发停止信号,streaming 状态由后端 AiCompleted 事件收尾 */
|
||||
async function stopChat() {
|
||||
await aiApi.stopChat()
|
||||
}
|
||||
|
||||
function togglePanel() {
|
||||
state.panelOpen = !state.panelOpen
|
||||
}
|
||||
|
||||
function toggleMaximize() {
|
||||
state.maximized = !state.maximized
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════
|
||||
// 对话管理
|
||||
// ══════════════════════════════════════
|
||||
|
||||
async function loadConversations() {
|
||||
try {
|
||||
state.conversations = await aiApi.listConversations()
|
||||
} catch {
|
||||
// 静默失败,不影响主流程
|
||||
}
|
||||
}
|
||||
|
||||
async function newConversation() {
|
||||
const result = await aiApi.createConversation()
|
||||
state.activeConversationId = result.id
|
||||
state.messages = []
|
||||
state.currentText = ''
|
||||
state.pendingApprovals = []
|
||||
state.streaming = false
|
||||
await loadConversations()
|
||||
notifyConversationChanged()
|
||||
}
|
||||
|
||||
async function switchConversation(id: string) {
|
||||
// 允许生成中切换:后台继续生成,事件按 conversation_id 路由不污染当前视图
|
||||
const detail = await aiApi.switchConversation(id)
|
||||
state.activeConversationId = id
|
||||
|
||||
try {
|
||||
const rawMsgs = typeof detail.messages === 'string'
|
||||
? JSON.parse(detail.messages)
|
||||
: detail.messages
|
||||
|
||||
// 构建 tool_call_id → tool_result 映射,用于回填工具执行结果
|
||||
const toolResultMap = new Map<string, string>()
|
||||
for (const m of rawMsgs) {
|
||||
if (m.role === 'tool' && m.tool_call_id) {
|
||||
toolResultMap.set(m.tool_call_id, m.content || '')
|
||||
}
|
||||
}
|
||||
|
||||
state.messages = rawMsgs
|
||||
.filter((m: any) => m.role !== 'tool')
|
||||
.map((m: any, i: number) => ({
|
||||
id: `loaded-${i}`,
|
||||
role: m.role,
|
||||
content: m.content || '',
|
||||
timestamp: Date.now(),
|
||||
toolCalls: m.tool_calls?.map((tc: any) => ({
|
||||
id: tc.id,
|
||||
name: tc.function?.name || '',
|
||||
args: typeof tc.function?.arguments === 'string'
|
||||
? JSON.parse(tc.function.arguments || '{}')
|
||||
: tc.function?.arguments || {},
|
||||
status: 'completed' as const,
|
||||
result: toolResultMap.get(tc.id),
|
||||
})),
|
||||
}))
|
||||
} catch {
|
||||
state.messages = []
|
||||
}
|
||||
|
||||
state.currentText = ''
|
||||
state.pendingApprovals = []
|
||||
}
|
||||
|
||||
async function deleteConversation(id: string) {
|
||||
await aiApi.deleteConversation(id)
|
||||
if (state.activeConversationId === id) {
|
||||
state.activeConversationId = null
|
||||
state.messages = []
|
||||
}
|
||||
await loadConversations()
|
||||
notifyConversationChanged()
|
||||
}
|
||||
|
||||
async function renameConversation(id: string, title: string) {
|
||||
await aiApi.renameConversation(id, title)
|
||||
// 本地同步更新侧栏摘要标题
|
||||
const conv = state.conversations.find(c => c.id === id)
|
||||
if (conv) conv.title = title
|
||||
notifyConversationChanged()
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
state.sidebarOpen = !state.sidebarOpen
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════
|
||||
// 窗口模式
|
||||
// ══════════════════════════════════════
|
||||
|
||||
async function detachPanel() {
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const existing = await WebviewWindow.getByLabel('ai-detached')
|
||||
if (existing) {
|
||||
await existing.setFocus()
|
||||
return
|
||||
}
|
||||
// 快照当前生成态,供分离窗口接管(保持正在进行的对话)
|
||||
if (state.streaming && state.generatingConvId) {
|
||||
localStorage.setItem('df-ai-gen', state.generatingConvId)
|
||||
localStorage.setItem('df-ai-text', state.currentText)
|
||||
}
|
||||
const convId = state.activeConversationId
|
||||
const sep = convId ? `?conv=${encodeURIComponent(convId)}` : ''
|
||||
const url = window.location.origin + window.location.pathname + '#/ai-detached' + sep
|
||||
const win = new WebviewWindow('ai-detached', {
|
||||
url,
|
||||
title: 'DevFlow AI',
|
||||
width: 520,
|
||||
height: 700,
|
||||
minWidth: 360,
|
||||
minHeight: 400,
|
||||
center: true,
|
||||
decorations: true,
|
||||
})
|
||||
win.once('tauri://created', () => {
|
||||
state.panelOpen = false
|
||||
})
|
||||
win.once('tauri://error', (e) => {
|
||||
console.error('[AI] 窗口创建失败:', e)
|
||||
state.detached = false
|
||||
})
|
||||
win.once('tauri://destroyed', () => {
|
||||
state.detached = false
|
||||
state.docked = false
|
||||
stopFollowMain()
|
||||
})
|
||||
state.detached = true
|
||||
}
|
||||
|
||||
async function reattachPanel() {
|
||||
stopFollowMain()
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const win = await WebviewWindow.getByLabel('ai-detached')
|
||||
if (win) {
|
||||
await win.close()
|
||||
}
|
||||
state.detached = false
|
||||
state.docked = false
|
||||
state.panelOpen = true
|
||||
localStorage.removeItem('df-ai-gen')
|
||||
localStorage.removeItem('df-ai-text')
|
||||
}
|
||||
|
||||
/** 分离窗口挂载时接管主窗口当前对话(含生成中态) */
|
||||
async function resumeInDetached(convId: string | null) {
|
||||
const id = convId || state.activeConversationId
|
||||
if (id) await switchConversation(id)
|
||||
const gen = localStorage.getItem('df-ai-gen')
|
||||
if (gen) {
|
||||
// 接管正在生成的对话:恢复已生成文本并补占位,后续 delta 继续追加
|
||||
state.currentText = localStorage.getItem('df-ai-text') || ''
|
||||
state.messages.push({
|
||||
id: `ai-${++_msgCounter}`,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
state.streaming = true
|
||||
state.generatingConvId = gen
|
||||
}
|
||||
}
|
||||
|
||||
async function closeDetachedWindow() {
|
||||
state.docked = false
|
||||
stopFollowMain()
|
||||
localStorage.removeItem('df-ai-gen')
|
||||
localStorage.removeItem('df-ai-text')
|
||||
const { getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
await getCurrentWebviewWindow().close()
|
||||
}
|
||||
|
||||
/** 吸附/取消吸附:AI 窗口跟随主窗口右侧 */
|
||||
async function dockDetached() {
|
||||
// 取消吸附
|
||||
if (state.docked) {
|
||||
state.docked = false
|
||||
stopFollowMain()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await syncToMain()
|
||||
state.docked = true
|
||||
startFollowMain()
|
||||
} catch (e) {
|
||||
console.error('[AI] 吸附失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 同步 AI 窗口位置到主窗口右侧(保留 4px 间隙) */
|
||||
async function syncToMain() {
|
||||
const { WebviewWindow, getCurrentWebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const { PhysicalPosition, PhysicalSize } = await import('@tauri-apps/api/dpi')
|
||||
const mainWin = await WebviewWindow.getByLabel('main')
|
||||
const aiWin = getCurrentWebviewWindow()
|
||||
if (!mainWin) return
|
||||
const pos = await mainWin.outerPosition()
|
||||
const size = await mainWin.innerSize()
|
||||
await aiWin.setPosition(new PhysicalPosition(pos.x + size.width + 4, pos.y))
|
||||
await aiWin.setSize(new PhysicalSize(600, size.height))
|
||||
}
|
||||
|
||||
// ── 主窗口事件跟随 ──
|
||||
let _unlistenMove: (() => void) | null = null
|
||||
let _unlistenResize: (() => void) | null = null
|
||||
|
||||
async function startFollowMain() {
|
||||
stopFollowMain()
|
||||
const { WebviewWindow } = await import('@tauri-apps/api/webviewWindow')
|
||||
const mainWin = await WebviewWindow.getByLabel('main')
|
||||
if (!mainWin) return
|
||||
_unlistenMove = await mainWin.onMoved(() => { syncToMain() })
|
||||
_unlistenResize = await mainWin.onResized(() => { syncToMain() })
|
||||
}
|
||||
|
||||
function stopFollowMain() {
|
||||
_unlistenMove?.()
|
||||
_unlistenResize?.()
|
||||
_unlistenMove = null
|
||||
_unlistenResize = null
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
sendMessage,
|
||||
approveToolCall,
|
||||
loadProviders,
|
||||
setProvider,
|
||||
clearChat,
|
||||
stopChat,
|
||||
togglePanel,
|
||||
toggleMaximize,
|
||||
startListener,
|
||||
stopListener,
|
||||
// 对话管理
|
||||
loadConversations,
|
||||
newConversation,
|
||||
switchConversation,
|
||||
deleteConversation,
|
||||
renameConversation,
|
||||
toggleSidebar,
|
||||
// 窗口模式
|
||||
detachPanel,
|
||||
reattachPanel,
|
||||
closeDetachedWindow,
|
||||
dockDetached,
|
||||
resumeInDetached,
|
||||
}
|
||||
}
|
||||
8
src/stores/index.ts
Normal file
8
src/stores/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { useProjectStore } from './project'
|
||||
|
||||
export { useKnowledgeStore } from './knowledge'
|
||||
export type { KnowledgeItem } from './knowledge'
|
||||
|
||||
|
||||
export { useSettingsStore } from './settings'
|
||||
export type { AIProvider, Connection, GeneralSettings } from './settings'
|
||||
79
src/stores/knowledge.ts
Normal file
79
src/stores/knowledge.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { reactive, computed } from 'vue'
|
||||
|
||||
export interface KnowledgeItem {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
category: string
|
||||
tags: string[]
|
||||
reuseCount: number
|
||||
score: number
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
const state = reactive({
|
||||
items: [
|
||||
// 审查规则
|
||||
{ id: 1, title: 'SQL 注入防护检查', description: '所有 SQL 拼接必须使用参数化查询,禁止字符串拼接用户输入', category: 'review', tags: ['安全', 'SQL', 'Go'], reuseCount: 23, score: 95, updatedAt: '3 天前' },
|
||||
{ id: 2, title: '错误处理规范', description: '禁止吞掉 error,必须向上传播或记录日志', category: 'review', tags: ['Go', '规范', '错误处理'], reuseCount: 18, score: 88, updatedAt: '1 周前' },
|
||||
{ id: 3, title: 'API 响应格式一致性', description: '统一使用 { code, message, data } 结构,HTTP 状态码语义正确', category: 'review', tags: ['API', '规范'], reuseCount: 15, score: 82, updatedAt: '5 天前' },
|
||||
// Prompt 模板
|
||||
{ id: 4, title: 'SQL 查询生成 Prompt', description: '根据自然语言生成 SQL,包含表结构上下文和示例输出格式', category: 'prompt', tags: ['SQL', 'LLM', '模板'], reuseCount: 142, score: 91, updatedAt: '昨天' },
|
||||
{ id: 5, title: '代码审查 Prompt', description: 'AI 代码审查专用 Prompt,涵盖安全、性能、可维护性维度', category: 'prompt', tags: ['代码审查', 'LLM'], reuseCount: 87, score: 85, updatedAt: '3 天前' },
|
||||
{ id: 6, title: 'API 文档生成 Prompt', description: '从 Handler 代码自动生成 API 文档的 Prompt 模板', category: 'prompt', tags: ['文档', 'LLM', '自动生成'], reuseCount: 34, score: 78, updatedAt: '1 周前' },
|
||||
// 踩坑经验
|
||||
{ id: 7, title: 'Go context 传递陷阱', description: 'goroutine 中必须传递 context 而非创建新的,否则超时控制失效', category: 'pitfall', tags: ['Go', '并发', 'context'], reuseCount: 12, score: 92, updatedAt: '2 天前' },
|
||||
{ id: 8, title: 'Vue 3 作用域坑', description: 'v-for 内 ref 绑定不会自动响应式,需使用数组形式', category: 'pitfall', tags: ['Vue', '前端', '响应式'], reuseCount: 8, score: 75, updatedAt: '1 周前' },
|
||||
{ id: 9, title: 'Docker 网络模式选择', description: 'host 模式在 Mac/Win 上无效,必须用端口映射', category: 'pitfall', tags: ['Docker', '网络'], reuseCount: 6, score: 70, updatedAt: '2 周前' },
|
||||
// 诊断知识
|
||||
{ id: 10, title: 'MySQL 慢查询诊断流程', description: 'EXPLAIN → 索引检查 → 慢查询日志分析 → 优化建议', category: 'diagnosis', tags: ['MySQL', '性能', '诊断'], reuseCount: 31, score: 89, updatedAt: '4 天前' },
|
||||
{ id: 11, title: 'Go 内存泄漏排查', description: 'pprof heap 分析 → goroutine 泄漏检查 → GC 调优', category: 'diagnosis', tags: ['Go', '内存', 'pprof'], reuseCount: 9, score: 83, updatedAt: '1 周前' },
|
||||
{ id: 12, title: '前端白屏诊断', description: 'Console 错误 → 网络请求 → 路由配置 → 构建产物检查', category: 'diagnosis', tags: ['前端', '调试', 'Vue'], reuseCount: 14, score: 80, updatedAt: '5 天前' },
|
||||
// 部署经验
|
||||
{ id: 13, title: 'Go 二进制热更新', description: 'kill + mv + nohup 启动,无需 systemd 的轻量部署方案', category: 'deploy', tags: ['Go', '部署', 'Linux'], reuseCount: 19, score: 86, updatedAt: '昨天' },
|
||||
{ id: 14, title: 'SCP 上传最佳实践', description: '使用 ssh-proxy upload 代替 scp,支持配置化管理', category: 'deploy', tags: ['SCP', '部署', '工具'], reuseCount: 11, score: 72, updatedAt: '3 天前' },
|
||||
{ id: 15, title: 'Nginx 反向代理配置', description: 'u-ask 的 Nginx 配置模板,含 WebSocket 支持和 gzip', category: 'deploy', tags: ['Nginx', '配置', '反向代理'], reuseCount: 7, score: 77, updatedAt: '1 周前' },
|
||||
] as KnowledgeItem[],
|
||||
|
||||
categories: [
|
||||
{ key: 'all', label: '全部', icon: '📦' },
|
||||
{ key: 'review', label: '审查规则', icon: '🔍' },
|
||||
{ key: 'prompt', label: 'Prompt模板', icon: '💬' },
|
||||
{ key: 'pitfall', label: '踩坑经验', icon: '⚠️' },
|
||||
{ key: 'diagnosis', label: '诊断知识', icon: '🩺' },
|
||||
{ key: 'deploy', label: '部署经验', icon: '🚀' },
|
||||
],
|
||||
})
|
||||
|
||||
export function useKnowledgeStore() {
|
||||
const items = computed(() => state.items)
|
||||
|
||||
const getByCategory = (category: string) =>
|
||||
computed(() =>
|
||||
category === 'all'
|
||||
? state.items
|
||||
: state.items.filter(i => i.category === category)
|
||||
)
|
||||
|
||||
const search = (query: string) =>
|
||||
computed(() => {
|
||||
if (!query.trim()) return state.items
|
||||
const q = query.toLowerCase()
|
||||
return state.items.filter(i =>
|
||||
i.title.toLowerCase().includes(q) ||
|
||||
i.tags.some(t => t.toLowerCase().includes(q)) ||
|
||||
i.description.toLowerCase().includes(q)
|
||||
)
|
||||
})
|
||||
|
||||
const getCategoryCount = (key: string) =>
|
||||
key === 'all' ? state.items.length : state.items.filter(i => i.category === key).length
|
||||
|
||||
return {
|
||||
items,
|
||||
categories: computed(() => state.categories),
|
||||
getByCategory,
|
||||
search,
|
||||
getCategoryCount,
|
||||
}
|
||||
}
|
||||
205
src/stores/project.ts
Normal file
205
src/stores/project.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { reactive, computed } from 'vue'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { projectApi, taskApi, ideaApi, workflowApi } from '../api'
|
||||
import type { ProjectRecord, TaskRecord, IdeaRecord, WorkflowRecord, WorkflowEventPayload } from '../api/types'
|
||||
|
||||
// ── 全局响应式状态(单例) ──
|
||||
|
||||
const state = reactive({
|
||||
projects: [] as ProjectRecord[],
|
||||
tasks: [] as TaskRecord[],
|
||||
ideas: [] as IdeaRecord[],
|
||||
workflowExecutions: [] as WorkflowRecord[],
|
||||
liveEvents: [] as WorkflowEventPayload[],
|
||||
pendingApproval: null as {
|
||||
execution_id: string
|
||||
node_id: string
|
||||
title: string
|
||||
description: string
|
||||
options: string[]
|
||||
} | null,
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
})
|
||||
|
||||
let _eventUnlisten: (() => void) | null = null
|
||||
|
||||
export function useProjectStore() {
|
||||
// ── 项目 CRUD ──
|
||||
async function loadProjects() {
|
||||
state.loading = true
|
||||
state.error = null
|
||||
try {
|
||||
state.projects = await projectApi.list()
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '加载项目失败'
|
||||
} finally {
|
||||
state.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createProject(name: string, description = '', ideaId?: string) {
|
||||
const record = await projectApi.create({ name, description, idea_id: ideaId })
|
||||
state.projects.push(record)
|
||||
return record
|
||||
}
|
||||
|
||||
async function updateProject(id: string, field: string, value: string) {
|
||||
await projectApi.update(id, field, value)
|
||||
const idx = state.projects.findIndex(p => p.id === id)
|
||||
if (idx >= 0) {
|
||||
(state.projects[idx] as any)[field] = value
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProject(id: string) {
|
||||
await projectApi.delete(id)
|
||||
state.projects = state.projects.filter(p => p.id !== id)
|
||||
}
|
||||
|
||||
// ── 任务 CRUD ──
|
||||
async function loadTasks(projectId?: string) {
|
||||
try {
|
||||
state.tasks = await taskApi.list(projectId)
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '加载任务失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function createTask(input: { project_id: string; title: string; description?: string; priority?: number; branch_name?: string; assignee?: string }) {
|
||||
const record = await taskApi.create(input)
|
||||
state.tasks.push(record)
|
||||
return record
|
||||
}
|
||||
|
||||
async function updateTask(id: string, field: string, value: string) {
|
||||
await taskApi.update(id, field, value)
|
||||
const idx = state.tasks.findIndex(t => t.id === id)
|
||||
if (idx >= 0) {
|
||||
(state.tasks[idx] as any)[field] = value
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTask(id: string) {
|
||||
await taskApi.delete(id)
|
||||
state.tasks = state.tasks.filter(t => t.id !== id)
|
||||
}
|
||||
|
||||
// ── 想法 CRUD ──
|
||||
async function loadIdeas() {
|
||||
try {
|
||||
state.ideas = await ideaApi.list()
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '加载想法失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function createIdea(input: { title: string; description?: string; priority?: number; tags?: string; source?: string }) {
|
||||
const record = await ideaApi.create(input)
|
||||
state.ideas.push(record)
|
||||
return record
|
||||
}
|
||||
|
||||
async function updateIdea(id: string, field: string, value: string) {
|
||||
await ideaApi.update(id, field, value)
|
||||
const idx = state.ideas.findIndex(i => i.id === id)
|
||||
if (idx >= 0) {
|
||||
(state.ideas[idx] as any)[field] = value
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteIdea(id: string) {
|
||||
await ideaApi.delete(id)
|
||||
state.ideas = state.ideas.filter(i => i.id !== id)
|
||||
}
|
||||
|
||||
// ── 工作流 ──
|
||||
async function runWorkflow(name: string, dag: unknown, config?: Record<string, unknown>) {
|
||||
return await workflowApi.run(name, dag, config)
|
||||
}
|
||||
|
||||
async function loadWorkflowExecutions() {
|
||||
try {
|
||||
state.workflowExecutions = await workflowApi.listExecutions()
|
||||
} catch (e: any) {
|
||||
state.error = e?.toString() ?? '加载工作流记录失败'
|
||||
}
|
||||
}
|
||||
|
||||
async function startEventListener() {
|
||||
if (_eventUnlisten) return _eventUnlisten
|
||||
_eventUnlisten = await workflowApi.onEvent((payload) => {
|
||||
state.liveEvents.push(payload)
|
||||
|
||||
// 处理人工审批请求
|
||||
if (payload.event.type === 'HumanApprovalRequest') {
|
||||
state.pendingApproval = payload.event.data as typeof state.pendingApproval
|
||||
}
|
||||
})
|
||||
return _eventUnlisten
|
||||
}
|
||||
|
||||
function clearLiveEvents() {
|
||||
state.liveEvents = []
|
||||
}
|
||||
|
||||
async function approveHumanApproval(decision: string, comment?: string) {
|
||||
if (!state.pendingApproval) return
|
||||
|
||||
try {
|
||||
// 调用 IPC 命令发送审批响应
|
||||
await invoke('approve_human_approval', {
|
||||
execution_id: state.pendingApproval.execution_id,
|
||||
node_id: state.pendingApproval.node_id,
|
||||
decision,
|
||||
comment
|
||||
})
|
||||
|
||||
// 清除待审批状态
|
||||
state.pendingApproval = null
|
||||
} catch (error) {
|
||||
console.error('审批失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function stopEventListener() {
|
||||
if (_eventUnlisten) {
|
||||
try {
|
||||
_eventUnlisten()
|
||||
} catch (e) {
|
||||
console.error('停止事件监听失败:', e)
|
||||
}
|
||||
_eventUnlisten = null
|
||||
}
|
||||
}
|
||||
|
||||
// ── 统计 ──
|
||||
const stats = computed(() => ({
|
||||
ideas: state.ideas.length,
|
||||
projects: state.projects.length,
|
||||
activeTasks: state.tasks.filter(t => t.status === 'in_progress').length,
|
||||
drafts: state.ideas.filter(i => i.status === 'draft').length,
|
||||
}))
|
||||
|
||||
return reactive({
|
||||
// reactive state — 直接引用 state 属性,已经是响应式的
|
||||
projects: state.projects,
|
||||
tasks: state.tasks,
|
||||
ideas: state.ideas,
|
||||
workflowExecutions: state.workflowExecutions,
|
||||
liveEvents: state.liveEvents,
|
||||
loading: state.loading,
|
||||
error: state.error,
|
||||
// project actions
|
||||
loadProjects, createProject, updateProject, deleteProject,
|
||||
// task actions
|
||||
loadTasks, createTask, updateTask, deleteTask,
|
||||
// idea actions
|
||||
loadIdeas, createIdea, updateIdea, deleteIdea,
|
||||
// workflow actions
|
||||
runWorkflow, loadWorkflowExecutions, startEventListener, stopEventListener, clearLiveEvents,
|
||||
approveHumanApproval, pendingApproval: computed(() => state.pendingApproval),
|
||||
// computed
|
||||
stats,
|
||||
})
|
||||
}
|
||||
98
src/stores/settings.ts
Normal file
98
src/stores/settings.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { reactive, computed } from 'vue'
|
||||
|
||||
export interface AIProvider {
|
||||
id: number
|
||||
name: string
|
||||
apiKey: string
|
||||
baseUrl: string
|
||||
models: string[]
|
||||
isDefault: boolean
|
||||
}
|
||||
|
||||
export interface Connection {
|
||||
id: number
|
||||
name: string
|
||||
type: 'mysql' | 'ssh' | 'redis' | 'mongo'
|
||||
typeLabel: string
|
||||
icon: string
|
||||
host: string
|
||||
port: number
|
||||
user: string
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
export interface GeneralSettings {
|
||||
theme: 'dark' | 'light' | 'system'
|
||||
language: string
|
||||
dataDir: string
|
||||
autoExecute: boolean
|
||||
logLevel: 'error' | 'warn' | 'info' | 'debug'
|
||||
}
|
||||
|
||||
const state = reactive({
|
||||
aiProviders: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Anthropic Claude',
|
||||
apiKey: 'sk-ant-••••••••••••••••7xKm',
|
||||
baseUrl: 'https://api.anthropic.com',
|
||||
models: ['claude-sonnet-4-20250514', 'claude-opus-4-20250514'],
|
||||
isDefault: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Zhipu GLM',
|
||||
apiKey: '••••••••••••••••a3f2',
|
||||
baseUrl: 'https://open.bigmodel.cn/api/paas',
|
||||
models: ['glm-4-plus', 'glm-4-flash'],
|
||||
isDefault: false,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'DeepSeek',
|
||||
apiKey: '••••••••••••••••9b8c',
|
||||
baseUrl: 'https://api.deepseek.com',
|
||||
models: ['deepseek-chat', 'deepseek-coder'],
|
||||
isDefault: false,
|
||||
},
|
||||
] as AIProvider[],
|
||||
|
||||
connections: [
|
||||
{ id: 1, name: 'flux_dev', type: 'mysql' as const, typeLabel: 'MySQL', icon: '🗄️', host: '39.99.243.191', port: 3306, user: 'root', connected: true },
|
||||
{ id: 2, name: 'flux_dev', type: 'ssh' as const, typeLabel: 'SSH', icon: '🔐', host: '39.99.243.191', port: 22, user: 'root', connected: true },
|
||||
{ id: 3, name: 'flux_redis', type: 'redis' as const, typeLabel: 'Redis', icon: '⚡', host: '127.0.0.1', port: 6379, user: '', connected: true },
|
||||
{ id: 4, name: 'suke_dev', type: 'mongo' as const, typeLabel: 'MongoDB', icon: '🍃', host: '127.0.0.1', port: 27017, user: 'admin', connected: false },
|
||||
{ id: 5, name: 'rds_read', type: 'mysql' as const, typeLabel: 'MySQL', icon: '🗄️', host: 'rm-8vbrd2wil14hclop3vm.mysql.zhangbei.rds.aliyuncs.com', port: 3306, user: 'u_read', connected: true },
|
||||
] as Connection[],
|
||||
|
||||
general: {
|
||||
theme: 'dark' as const,
|
||||
language: 'zh-CN',
|
||||
dataDir: 'E:/wk-lab/devflow/data',
|
||||
autoExecute: false,
|
||||
logLevel: 'info' as const,
|
||||
} as GeneralSettings,
|
||||
})
|
||||
|
||||
export function useSettingsStore() {
|
||||
const aiProviders = computed(() => state.aiProviders)
|
||||
const connections = computed(() => state.connections)
|
||||
const general = computed(() => state.general)
|
||||
|
||||
const defaultProvider = computed(() =>
|
||||
state.aiProviders.find(p => p.isDefault)
|
||||
)
|
||||
|
||||
const connectionStats = computed(() => ({
|
||||
total: state.connections.length,
|
||||
connected: state.connections.filter(c => c.connected).length,
|
||||
}))
|
||||
|
||||
return {
|
||||
aiProviders,
|
||||
connections,
|
||||
general,
|
||||
defaultProvider,
|
||||
connectionStats,
|
||||
}
|
||||
}
|
||||
1
src/style.css
Normal file
1
src/style.css
Normal file
@@ -0,0 +1 @@
|
||||
/* 已迁移到 styles/global.css,此文件保留为空 */
|
||||
219
src/styles/global.css
Normal file
219
src/styles/global.css
Normal file
@@ -0,0 +1,219 @@
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
DevFlow Design System — Claude 设计系统规范
|
||||
扁平化 · 0.5px 边框 · 8/12px 圆角 · 400/500 字重
|
||||
紫/青绿/琥珀/珊瑚 四色体系 · 深色优先
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
:root {
|
||||
/* — Surface — */
|
||||
--df-bg: #0c0e1a;
|
||||
--df-bg-raised: #12152a;
|
||||
--df-bg-card: #171b33;
|
||||
--df-bg-card-hover: #1c2140;
|
||||
--df-sidebar-bg: #090b15;
|
||||
--df-sidebar-hover: rgba(108,99,255,0.06);
|
||||
--df-sidebar-active: rgba(108,99,255,0.12);
|
||||
|
||||
/* — Text — */
|
||||
--df-text: #eef0f6;
|
||||
--df-text-secondary: #8b90a8;
|
||||
--df-text-dim: #4a4f6a;
|
||||
|
||||
/* — Accent: 紫色系(系统功能、互动元素)— */
|
||||
--df-accent: #7b6ff0;
|
||||
--df-accent-hover: #6a5de0;
|
||||
--df-accent-soft: rgba(123,111,240,0.15);
|
||||
--df-accent-bg: rgba(123,111,240,0.08);
|
||||
|
||||
/* — Semantic — */
|
||||
/* 青绿:成功/流程 */
|
||||
--df-success: #3ddba0;
|
||||
--df-success-bg: rgba(61,219,160,0.12);
|
||||
/* 琥珀:警告/热度 */
|
||||
--df-warning: #f0c75e;
|
||||
--df-warning-bg: rgba(240,199,94,0.12);
|
||||
/* 珊瑚:危险/对比 */
|
||||
--df-danger: #f06565;
|
||||
--df-danger-bg: rgba(240,101,101,0.12);
|
||||
/* 蓝色:信息 */
|
||||
--df-info: #5eaff0;
|
||||
--df-info-bg: rgba(94,175,240,0.12);
|
||||
|
||||
/* — Border: 统一 0.5px 极细边框 — */
|
||||
--df-border: rgba(255,255,255,0.06);
|
||||
--df-border-strong: rgba(255,255,255,0.10);
|
||||
|
||||
/* — Radius: 2/4/6/8/10 渐进(全站统一走 token)— */
|
||||
--df-radius-xs: 2px;
|
||||
--df-radius-sm: 4px;
|
||||
--df-radius: 6px;
|
||||
--df-radius-md: 8px;
|
||||
--df-radius-lg: 10px;
|
||||
|
||||
/* — Spacing: 全站间距基准(参考总览紧凑度,回退改值即可)— */
|
||||
--df-gap-page: 14px; /* 页面区块间距:header→主区、区块垂直分隔 */
|
||||
--df-gap-grid: 12px; /* 卡片/网格 gap */
|
||||
--df-gap-head: 10px; /* 面板标题→内容 */
|
||||
--df-pad-panel: 14px 16px; /* 面板/卡片内边距 */
|
||||
|
||||
/* — Typography: 仅 400/500 字重 — */
|
||||
--df-font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--df-font-mono: 'JetBrains Mono', 'SF Mono', 'Cascadia Code', monospace;
|
||||
|
||||
/* — Transition — */
|
||||
--df-ease: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* ═══ 亮色主题 ═══ */
|
||||
[data-theme="light"] {
|
||||
--df-bg: #f5f6fa;
|
||||
--df-bg-raised: #ffffff;
|
||||
--df-bg-card: #ffffff;
|
||||
--df-bg-card-hover: #f0f1f5;
|
||||
--df-sidebar-bg: #ffffff;
|
||||
--df-sidebar-hover: rgba(108,99,255,0.06);
|
||||
--df-sidebar-active: rgba(108,99,255,0.10);
|
||||
|
||||
--df-text: #1a1d2e;
|
||||
--df-text-secondary: #5a5f7a;
|
||||
--df-text-dim: #9a9fba;
|
||||
|
||||
--df-accent: #6c5ce7;
|
||||
--df-accent-hover: #5b4bd6;
|
||||
--df-accent-soft: rgba(108,92,231,0.15);
|
||||
--df-accent-bg: rgba(108,92,231,0.06);
|
||||
|
||||
--df-success: #00b894;
|
||||
--df-success-bg: rgba(0,184,148,0.10);
|
||||
--df-warning: #e17055;
|
||||
--df-warning-bg: rgba(225,112,85,0.10);
|
||||
--df-danger: #d63031;
|
||||
--df-danger-bg: rgba(214,48,49,0.10);
|
||||
--df-info: #0984e3;
|
||||
--df-info-bg: rgba(9,132,227,0.10);
|
||||
|
||||
--df-border: rgba(0,0,0,0.08);
|
||||
--df-border-strong: rgba(0,0,0,0.14);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
font-family: var(--df-font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
background: var(--df-bg);
|
||||
color: var(--df-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* — Scrollbar — */
|
||||
::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: var(--df-radius-xs); }
|
||||
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.14); }
|
||||
|
||||
/* — Selection — */
|
||||
::selection { background: var(--df-accent-soft); color: var(--df-accent); }
|
||||
|
||||
/* — Animations — */
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes pulseGlow {
|
||||
0%, 100% { box-shadow: 0 0 0 0 var(--df-accent-soft); }
|
||||
50% { box-shadow: 0 0 12px 2px var(--df-accent-soft); }
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
公共组件 — 各功能页复用,避免 scoped 重复定义
|
||||
class 名与各页 template 保持一致,零侵入
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
/* — Page Header(功能内页统一:24px 标题 + 紧凑下间距)— */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--df-gap-page);
|
||||
}
|
||||
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); }
|
||||
.header-actions { display: flex; gap: 10px; }
|
||||
|
||||
/* — Button — */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 10px 16px;
|
||||
line-height: 1;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-family: var(--df-font-sans);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s var(--df-ease);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn-primary { background: var(--df-accent); color: #fff; }
|
||||
.btn-primary:hover { background: var(--df-accent-hover); }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
|
||||
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||||
.btn-accent { background: var(--df-success-bg); color: var(--df-success); border: 0.5px solid rgba(61,219,160,0.3); }
|
||||
.btn-accent:hover { background: rgba(61,219,160,0.22); }
|
||||
.btn-sm { padding: 4px 12px; font-size: 12px; }
|
||||
.btn-link { background: none; border: none; color: var(--df-accent); font-size: 12px; cursor: pointer; padding: 2px 6px; }
|
||||
.btn-link:hover { text-decoration: underline; }
|
||||
.btn-link-danger { color: var(--df-danger); }
|
||||
|
||||
/* — Modal — */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.modal-box {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
padding: 24px;
|
||||
min-width: 360px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
.modal-title { font-size: 18px; font-weight: 500; color: var(--df-text); margin-bottom: 16px; }
|
||||
.modal-field { margin-bottom: 14px; }
|
||||
.modal-field label { display: block; font-size: 12px; color: var(--df-text-dim); margin-bottom: 4px; }
|
||||
.modal-field input,
|
||||
.modal-field textarea,
|
||||
.modal-field select {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
border: 0.5px solid var(--df-border);
|
||||
background: var(--df-bg);
|
||||
color: var(--df-text);
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.modal-field input:focus,
|
||||
.modal-field textarea:focus,
|
||||
.modal-field select:focus { outline: none; border-color: var(--df-accent); }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 4px; }
|
||||
|
||||
/* — Empty State — */
|
||||
.empty-state { text-align: center; padding: 60px 20px; font-size: 14px; color: var(--df-text-dim); }
|
||||
.empty-hint { font-size: 13px; color: var(--df-text-dim); padding: 12px 0; }
|
||||
52
src/utils/time.ts
Normal file
52
src/utils/time.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 统一时间戳解析与格式化 — 根治前端时间显示 NaN 问题
|
||||
*
|
||||
* 后端 (df-storage) 统一以「毫秒数字字符串」存储 created_at / updated_at
|
||||
* (见 src-tauri now_millis:`SystemTime::now().as_millis().to_string()`)。
|
||||
*
|
||||
* 陷阱:`new Date("1718000000000")` 返回 Invalid Date —— JS 规范只对 number 类型
|
||||
* 按时间戳解析,纯数字字符串走日期字符串解析路径会失败 → getTime() = NaN
|
||||
* → `"{n} 天前".format(NaN)` = "NaN 天前"。
|
||||
*
|
||||
* 正确做法:先转 number 再 new Date。本模块统一处理毫秒字符串 / 秒级 / ISO / number / 空。
|
||||
*/
|
||||
|
||||
/** 解析任意时间值为毫秒时间戳;无效或空返回 null */
|
||||
export function parseTs(v: string | number | null | undefined): number | null {
|
||||
if (v == null) return null
|
||||
if (typeof v === 'number') {
|
||||
if (!Number.isFinite(v)) return null
|
||||
return v > 1e12 ? v : v * 1000 // 秒级 → 毫秒
|
||||
}
|
||||
const s = String(v).trim()
|
||||
if (!s) return null
|
||||
// 纯数字字符串:> 1e12 视为毫秒,否则秒
|
||||
if (/^\d+$/.test(s)) {
|
||||
const n = Number(s)
|
||||
return n > 1e12 ? n : n * 1000
|
||||
}
|
||||
// ISO / 其他日期字符串
|
||||
const ms = new Date(s).getTime()
|
||||
return isNaN(ms) ? null : ms
|
||||
}
|
||||
|
||||
/** 绝对日期 'YYYY/MM/DD HH:mm';无效返回 '—' */
|
||||
export function formatDate(v: string | number | null | undefined): string {
|
||||
const ms = parseTs(v)
|
||||
if (ms == null) return '—'
|
||||
const d = new Date(ms)
|
||||
return d.toLocaleDateString('zh-CN') + ' ' + d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
/** 中文相对时间 '刚刚 / X 分钟前 / X 小时前 / X 天前';无效返回 '—' */
|
||||
export function formatRelativeZh(v: string | number | null | undefined): string {
|
||||
const ms = parseTs(v)
|
||||
if (ms == null) return '—'
|
||||
const diffMin = Math.floor((Date.now() - ms) / 60000)
|
||||
if (diffMin < 1) return '刚刚'
|
||||
if (diffMin < 60) return `${diffMin} 分钟前`
|
||||
const diffH = Math.floor(diffMin / 60)
|
||||
if (diffH < 24) return `${diffH} 小时前`
|
||||
const diffD = Math.floor(diffH / 24)
|
||||
return `${diffD} 天前`
|
||||
}
|
||||
27
src/views/AiDetached.vue
Normal file
27
src/views/AiDetached.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<div class="ai-detached-shell">
|
||||
<AiChat :detached="true" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AiChat from '../components/AiChat.vue'
|
||||
import { onMounted } from 'vue'
|
||||
import { useAiStore } from '../stores/ai'
|
||||
|
||||
const store = useAiStore()
|
||||
|
||||
onMounted(async () => {
|
||||
// 标记为分离模式
|
||||
store.state.detached = true
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ai-detached-shell {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--df-bg);
|
||||
}
|
||||
</style>
|
||||
10
src/views/AiHome.vue
Normal file
10
src/views/AiHome.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<div class="ai-home-placeholder"></div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-home-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
609
src/views/Dashboard.vue
Normal file
609
src/views/Dashboard.vue
Normal file
@@ -0,0 +1,609 @@
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<!-- ═══ Header ═══ -->
|
||||
<header class="dash-header">
|
||||
<div class="dash-header-left">
|
||||
<h1 class="dash-title">{{ $t('dashboard.title') }}</h1>
|
||||
<span class="dash-subtitle">{{ $t('dashboard.subtitle') }}</span>
|
||||
</div>
|
||||
<div class="dash-header-right">
|
||||
<button class="df-btn df-btn--ghost" @click="refresh">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>
|
||||
</button>
|
||||
<button class="df-btn df-btn--primary" @click="quickCapture">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
{{ $t('dashboard.captureIdea') }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ═══ Stat Cards ═══ -->
|
||||
<div class="stat-row">
|
||||
<div
|
||||
v-for="(stat, i) in stats" :key="stat.key"
|
||||
class="stat-card"
|
||||
:style="{ animationDelay: `${i * 60}ms` }"
|
||||
>
|
||||
<div class="stat-card-bg" :class="'stat-bg--' + stat.key"></div>
|
||||
<div class="stat-card-inner">
|
||||
<div class="stat-top">
|
||||
<div class="stat-icon-wrap" :style="{ background: stat.iconBg }">
|
||||
<span class="stat-icon">{{ stat.icon }}</span>
|
||||
</div>
|
||||
<span class="stat-trend" :class="stat.trend > 0 ? 'up' : stat.trend < 0 ? 'down' : 'flat'">
|
||||
<template v-if="stat.trend > 0">↑</template><template v-else-if="stat.trend < 0">↓</template>
|
||||
<template v-else>—</template>
|
||||
{{ stat.trend > 0 ? '+' : '' }}{{ stat.trend }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="stat-value">{{ stat.value }}</div>
|
||||
<div class="stat-label">{{ stat.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ Main Grid ═══ -->
|
||||
<div class="dash-grid">
|
||||
<!-- Left: Active Projects -->
|
||||
<section class="df-panel" style="animation-delay: 240ms">
|
||||
<div class="df-panel-head">
|
||||
<h2 class="df-panel-title">{{ $t('dashboard.activeProjects') }}</h2>
|
||||
<router-link to="/projects" class="df-link">{{ $t('dashboard.viewAll') }}</router-link>
|
||||
</div>
|
||||
<div class="project-list">
|
||||
<div v-for="(p, i) in displayProjects" :key="p.id" class="project-row" :style="{ animationDelay: `${300 + i * 50}ms` }">
|
||||
<div class="project-row-top">
|
||||
<div class="project-identity">
|
||||
<span class="project-dot" :class="'dot-' + p.stage"></span>
|
||||
<span class="project-name">{{ p.name }}</span>
|
||||
</div>
|
||||
<span class="project-stage-chip" :class="'chip-' + p.stage">{{ $t('dashboard.stage.' + p.stageLabelKey) }}</span>
|
||||
</div>
|
||||
<div class="project-bar-wrap">
|
||||
<div class="project-bar">
|
||||
<div class="project-bar-fill" :class="'fill-' + p.stage" :style="{ width: p.progress + '%' }"></div>
|
||||
</div>
|
||||
<span class="project-pct">{{ p.progress }}%</span>
|
||||
</div>
|
||||
<div class="project-meta-row">
|
||||
<span class="project-meta-item">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 3v12"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 01-9 9"/></svg>
|
||||
{{ $t('dashboard.taskUnit', { n: p.activeTasks }) }}
|
||||
</span>
|
||||
<span class="project-meta-time">{{ p.lastActivity }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="displayProjects.length === 0" class="empty-hint">暂无项目,去 <router-link to="/projects" class="df-link">创建一个</router-link></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="dash-right">
|
||||
<!-- Ideas -->
|
||||
<section class="df-panel" style="animation-delay: 280ms">
|
||||
<div class="df-panel-head">
|
||||
<h2 class="df-panel-title">{{ $t('dashboard.ideaPool') }}</h2>
|
||||
<router-link to="/ideas" class="df-link">{{ $t('dashboard.viewAll') }}</router-link>
|
||||
</div>
|
||||
<div class="idea-rows">
|
||||
<div v-for="idea in displayIdeas" :key="idea.id" class="idea-row">
|
||||
<div class="idea-score-ring" :class="'ring-' + idea.tier">
|
||||
<span class="idea-score-num">{{ idea.score }}</span>
|
||||
</div>
|
||||
<div class="idea-body">
|
||||
<span class="idea-name">{{ idea.title }}</span>
|
||||
<span class="idea-status">{{ ideaStatusLabel(idea.statusLabel) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="displayIdeas.length === 0" class="empty-hint">{{ $t('dashboard.empty.noIdeas') }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Decisions -->
|
||||
<section class="df-panel" style="animation-delay: 320ms">
|
||||
<div class="df-panel-head">
|
||||
<h2 class="df-panel-title">{{ $t('dashboard.recentDecisions') }}</h2>
|
||||
</div>
|
||||
<div class="empty-hint" style="padding:12px 0;text-align:center;color:var(--df-text-dim);font-size:12px">
|
||||
{{ $t('dashboard.empty.noData') }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Annotations -->
|
||||
<section class="df-panel" style="animation-delay: 360ms">
|
||||
<div class="df-panel-head">
|
||||
<h2 class="df-panel-title">{{ $t('dashboard.openAnnotations') }}</h2>
|
||||
</div>
|
||||
<div class="empty-hint" style="padding:12px 0;text-align:center;color:var(--df-text-dim);font-size:12px">
|
||||
{{ $t('dashboard.empty.noData') }}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useProjectStore } from '../stores/project'
|
||||
import { parseTs } from '../utils/time'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useProjectStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const stats = computed(() => [
|
||||
{ key: 'ideas', icon: '\u{1F4A1}', label: t('dashboard.stats.ideas'), value: store.stats.ideas, trend: 0,
|
||||
iconBg: 'rgba(123,111,240,0.12)' },
|
||||
{ key: 'projects', icon: '\u{1F4C2}', label: t('dashboard.stats.projects'), value: store.stats.projects, trend: 0,
|
||||
iconBg: 'rgba(94,175,240,0.10)' },
|
||||
{ key: 'tasks', icon: '⚡', label: t('dashboard.stats.activeTasks'), value: store.stats.activeTasks, trend: 0,
|
||||
iconBg: 'rgba(61,219,160,0.10)' },
|
||||
{ key: 'drafts', icon: '\u{1F4DD}', label: t('dashboard.stats.drafts'), value: store.stats.drafts, trend: 0,
|
||||
iconBg: 'rgba(240,199,94,0.10)' },
|
||||
])
|
||||
|
||||
function getProjectStage(status: string): { stage: string; stageLabelKey: string; progress: number } {
|
||||
switch (status) {
|
||||
case 'planning': return { stage: 'coding', stageLabelKey: 'planning', progress: 20 }
|
||||
case 'in_progress': return { stage: 'coding', stageLabelKey: 'coding', progress: 55 }
|
||||
case 'paused': return { stage: 'testing', stageLabelKey: 'paused', progress: 40 }
|
||||
case 'completed': return { stage: 'release', stageLabelKey: 'done', progress: 100 }
|
||||
case 'cancelled': return { stage: 'testing', stageLabelKey: 'cancelled', progress: 0 }
|
||||
default: return { stage: 'coding', stageLabelKey: 'coding', progress: 30 }
|
||||
}
|
||||
}
|
||||
|
||||
function getProjectTaskCount(projectId: string): number {
|
||||
return store.tasks.filter(t => t.project_id === projectId && t.status === 'in_progress').length
|
||||
}
|
||||
|
||||
function formatLastActivity(iso: string): string {
|
||||
const ms = parseTs(iso)
|
||||
if (ms == null) return '—'
|
||||
const diffMin = Math.floor((Date.now() - ms) / 60000)
|
||||
if (diffMin < 1) return t('common.justNow')
|
||||
if (diffMin < 60) return t('common.minutesAgo', { n: diffMin })
|
||||
const diffH = Math.floor(diffMin / 60)
|
||||
if (diffH < 24) return t('common.hoursAgo', { n: diffH })
|
||||
const diffD = Math.floor(diffH / 24)
|
||||
return t('common.dayAgo', { n: diffD })
|
||||
}
|
||||
|
||||
const displayProjects = computed(() =>
|
||||
store.projects.map(p => {
|
||||
const stageInfo = getProjectStage(p.status)
|
||||
return {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
...stageInfo,
|
||||
activeTasks: getProjectTaskCount(p.id),
|
||||
lastActivity: formatLastActivity(p.updated_at),
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const displayIdeas = computed(() =>
|
||||
store.ideas.slice(0, 4).map(i => ({
|
||||
id: i.id,
|
||||
title: i.title,
|
||||
score: i.score ?? 0,
|
||||
tier: (i.score ?? 0) >= 80 ? 'high' : (i.score ?? 0) >= 60 ? 'mid' : 'low',
|
||||
statusLabel: i.status,
|
||||
}))
|
||||
)
|
||||
|
||||
function ideaStatusLabel(status: string): string {
|
||||
return t('dashboard.ideaStatus.' + status)
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await Promise.all([store.loadProjects(), store.loadTasks(), store.loadIdeas()])
|
||||
}
|
||||
|
||||
function quickCapture() {
|
||||
router.push('/ideas')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await Promise.all([store.loadProjects(), store.loadTasks(), store.loadIdeas()])
|
||||
} catch (e) {
|
||||
console.error('Dashboard load failed:', e)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard { padding: 16px 20px 20px; }
|
||||
|
||||
/* ═══ Header ═══ */
|
||||
.dash-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.dash-title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.4px;
|
||||
color: var(--df-text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.dash-subtitle {
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.dash-subtitle::before {
|
||||
content: '';
|
||||
width: 5px; height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--df-success);
|
||||
display: inline-block;
|
||||
}
|
||||
.dash-header-right { display: flex; gap: 6px; align-items: center; }
|
||||
|
||||
/* — Buttons — */
|
||||
.df-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 9px 14px;
|
||||
line-height: 1;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-family: var(--df-font-sans);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s var(--df-ease);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.df-btn--primary {
|
||||
background: var(--df-accent);
|
||||
color: #fff;
|
||||
|
||||
}
|
||||
.df-btn--primary:hover {
|
||||
background: var(--df-accent);
|
||||
filter: brightness(1.1);
|
||||
|
||||
}
|
||||
.df-btn--ghost {
|
||||
background: rgba(255,255,255,0.03);
|
||||
color: var(--df-text-secondary);
|
||||
border: 0.5px solid var(--df-border);
|
||||
}
|
||||
.df-btn--ghost:hover {
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: var(--df-text);
|
||||
border-color: var(--df-border-strong);
|
||||
}
|
||||
.df-btn--xs { padding: 4px 10px; font-size: 11px; }
|
||||
|
||||
/* ═══ Stat Cards ═══ */
|
||||
.stat-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 10px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.stat-card {
|
||||
position: relative;
|
||||
border-radius: var(--df-radius-md);
|
||||
overflow: hidden;
|
||||
border: 0.5px solid var(--df-border);
|
||||
animation: fadeInUp 0.5s var(--df-ease) both;
|
||||
}
|
||||
.stat-card-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.stat-bg--ideas { background: var(--df-accent-bg); }
|
||||
.stat-bg--projects { background: var(--df-info-bg); }
|
||||
.stat-bg--tasks { background: var(--df-success-bg); }
|
||||
.stat-bg--drafts { background: var(--df-warning-bg); }
|
||||
.stat-card-inner {
|
||||
position: relative;
|
||||
padding: 12px 14px 10px;
|
||||
}
|
||||
.stat-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.stat-icon-wrap {
|
||||
width: 28px; height: 28px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: var(--df-radius-sm);
|
||||
}
|
||||
.stat-icon { font-size: 14px; }
|
||||
|
||||
.stat-trend {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
}
|
||||
.stat-trend.up { color: var(--df-success); background: rgba(61,219,160,0.10); }
|
||||
.stat-trend.down { color: var(--df-danger); background: rgba(240,101,101,0.10); }
|
||||
.stat-trend.flat { color: var(--df-text-dim); background: rgba(255,255,255,0.04); }
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.8px;
|
||||
color: var(--df-text);
|
||||
line-height: 1;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
font-weight: 450;
|
||||
}
|
||||
|
||||
/* ═══ Grid Layout ═══ */
|
||||
.dash-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: 12px;
|
||||
}
|
||||
.dash-right { display: flex; flex-direction: column; gap: 12px; }
|
||||
|
||||
/* ═══ Panel ═══ */
|
||||
.df-panel {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-md);
|
||||
padding: 14px 16px;
|
||||
animation: fadeInUp 0.5s var(--df-ease) both;
|
||||
}
|
||||
.df-panel-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.df-panel-title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--df-text);
|
||||
letter-spacing: -0.1px;
|
||||
}
|
||||
.df-link {
|
||||
font-size: 11px;
|
||||
color: var(--df-accent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.df-link:hover { opacity: 1; text-decoration: underline; }
|
||||
|
||||
/* ═══ Project Rows ═══ */
|
||||
.project-list { display: flex; flex-direction: column; }
|
||||
.project-row {
|
||||
padding: 10px 0;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
animation: fadeInUp 0.4s var(--df-ease) both;
|
||||
}
|
||||
.project-row:last-child { border-bottom: none; }
|
||||
|
||||
.project-row-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
||||
.project-identity { display: flex; align-items: center; gap: 8px; }
|
||||
.project-dot {
|
||||
width: 6px; height: 6px; border-radius: 50%;
|
||||
}
|
||||
.dot-coding { background: var(--df-accent); }
|
||||
.dot-testing { background: var(--df-warning); }
|
||||
.dot-release { background: var(--df-success); }
|
||||
|
||||
.project-name {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
font-family: var(--df-font-mono);
|
||||
color: var(--df-text);
|
||||
}
|
||||
|
||||
.project-stage-chip {
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
letter-spacing: 0.3px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.chip-coding { background: rgba(123,111,240,0.12); color: var(--df-accent); }
|
||||
.chip-testing { background: rgba(240,199,94,0.12); color: var(--df-warning); }
|
||||
.chip-release { background: rgba(61,219,160,0.12); color: var(--df-success); }
|
||||
|
||||
.project-bar-wrap { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; }
|
||||
.project-bar {
|
||||
flex: 1;
|
||||
height: 3px;
|
||||
background: rgba(255,255,255,0.06);
|
||||
border-radius: var(--df-radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
.project-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: var(--df-radius-sm);
|
||||
transition: width 0.6s var(--df-ease);
|
||||
}
|
||||
.fill-coding { background: var(--df-accent); }
|
||||
.fill-testing { background: var(--df-warning); }
|
||||
.fill-release { background: var(--df-success); }
|
||||
|
||||
.project-pct {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
min-width: 32px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.project-meta-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.project-meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
.project-meta-time {
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
font-family: var(--df-font-mono);
|
||||
}
|
||||
|
||||
/* ═══ Idea Rows ═══ */
|
||||
.idea-rows { display: flex; flex-direction: column; }
|
||||
.idea-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 0;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
}
|
||||
.idea-row:last-child { border-bottom: none; }
|
||||
|
||||
.idea-score-ring {
|
||||
width: 32px; height: 32px;
|
||||
border-radius: var(--df-radius);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ring-high { background: rgba(61,219,160,0.10); border: 0.5px solid rgba(61,219,160,0.15); }
|
||||
.ring-mid { background: rgba(240,199,94,0.08); border: 0.5px solid rgba(240,199,94,0.12); }
|
||||
.ring-low { background: rgba(240,101,101,0.08); border: 0.5px solid rgba(240,101,101,0.12); }
|
||||
|
||||
.idea-score-num {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.ring-high .idea-score-num { color: var(--df-success); }
|
||||
.ring-mid .idea-score-num { color: var(--df-warning); }
|
||||
.ring-low .idea-score-num { color: var(--df-danger); }
|
||||
|
||||
.idea-name { font-size: 12px; font-weight: 500; color: var(--df-text); display: block; }
|
||||
.idea-status { font-size: 10px; color: var(--df-text-dim); margin-top: 1px; display: block; }
|
||||
|
||||
/* ═══ Decision Rows ═══ */
|
||||
.decision-rows { display: flex; flex-direction: column; }
|
||||
.decision-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 7px 0;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
}
|
||||
.decision-row:last-child { border-bottom: none; }
|
||||
|
||||
.decision-marker {
|
||||
width: 20px; height: 20px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 9px;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--df-radius-sm);
|
||||
}
|
||||
.marker-ai { background: rgba(94,175,240,0.12); color: var(--df-info); }
|
||||
.marker-human { background: rgba(123,111,240,0.12); color: var(--df-accent); }
|
||||
|
||||
.decision-q { font-size: 11px; color: var(--df-text-secondary); display: block; margin-bottom: 2px; }
|
||||
.decision-a {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--df-accent);
|
||||
display: block;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.decision-time {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--df-text-dim);
|
||||
margin-top: 3px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ═══ Annotation Rows ═══ */
|
||||
.anno-rows { display: flex; flex-direction: column; }
|
||||
.anno-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 0;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
}
|
||||
.anno-row:last-child { border-bottom: none; }
|
||||
|
||||
.anno-tag {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 9px;
|
||||
font-weight: 500;
|
||||
padding: 2px 7px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
min-width: 54px;
|
||||
text-align: center;
|
||||
letter-spacing: 0.5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tag-fixme { background: rgba(240,101,101,0.14); color: var(--df-danger); }
|
||||
.tag-todo { background: rgba(94,175,240,0.12); color: var(--df-info); }
|
||||
.tag-risk { background: rgba(240,199,94,0.12); color: #f0a830; }
|
||||
.tag-question { background: rgba(94,175,240,0.10); color: var(--df-info); }
|
||||
|
||||
.anno-text {
|
||||
font-family: var(--df-font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--df-text-secondary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* ═══ Responsive ═══ */
|
||||
|
||||
/* 窄窗口:单栏 */
|
||||
@media (max-width: 900px) {
|
||||
.dashboard { padding: 12px 14px 16px; }
|
||||
.dash-grid { grid-template-columns: 1fr; }
|
||||
.dash-right { flex-direction: row; flex-wrap: wrap; gap: 10px; }
|
||||
.dash-right > .df-panel { flex: 1 1 280px; min-width: 0; }
|
||||
}
|
||||
|
||||
/* 中等窗口:两栏但右栏缩窄 */
|
||||
@media (min-width: 901px) and (max-width: 1200px) {
|
||||
.dash-grid { grid-template-columns: 1fr 280px; }
|
||||
.stat-row { grid-template-columns: repeat(4, 1fr); gap: 8px; }
|
||||
.stat-card-inner { padding: 10px 12px 8px; }
|
||||
.stat-value { font-size: 22px; }
|
||||
.stat-icon-wrap { width: 24px; height: 24px; }
|
||||
.stat-icon { font-size: 12px; }
|
||||
}
|
||||
|
||||
/* 宽窗口:右栏展开 */
|
||||
@media (min-width: 1201px) {
|
||||
.dash-grid { grid-template-columns: 1fr 340px; }
|
||||
}
|
||||
|
||||
/* 超宽窗口:充分利用 */
|
||||
@media (min-width: 1600px) {
|
||||
.dashboard { padding: 20px 28px 24px; }
|
||||
.dash-grid { grid-template-columns: 1fr 400px; gap: 16px; }
|
||||
.stat-row { gap: 14px; }
|
||||
.stat-card-inner { padding: 14px 18px 12px; }
|
||||
.stat-value { font-size: 28px; }
|
||||
.stat-icon-wrap { width: 30px; height: 30px; }
|
||||
.df-panel { padding: 16px 18px; }
|
||||
}
|
||||
</style>
|
||||
930
src/views/Ideas.vue
Normal file
930
src/views/Ideas.vue
Normal file
@@ -0,0 +1,930 @@
|
||||
<template>
|
||||
<div class="ideas">
|
||||
<header class="page-header">
|
||||
<h1>💡 灵感</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-ghost" @click="refresh">🔄 刷新</button>
|
||||
<button class="btn btn-primary" @click="openCaptureModal">✨ 捕捉灵感</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 搜索和筛选栏 -->
|
||||
<div class="filter-bar">
|
||||
<div class="search-box">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="搜索灵感..."
|
||||
@input="filterIdeas"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-for="f in filters"
|
||||
:key="f.key"
|
||||
class="filter-btn"
|
||||
:class="{ active: activeFilter === f.key }"
|
||||
@click="activeFilter = f.key"
|
||||
>
|
||||
{{ f.icon }} {{ f.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 两栏布局 -->
|
||||
<div class="ideas-layout">
|
||||
<!-- 左侧:想法列表 -->
|
||||
<section class="idea-list-panel">
|
||||
<div class="idea-list">
|
||||
<div
|
||||
v-for="idea in filteredIdeas"
|
||||
:key="idea.id"
|
||||
class="idea-card"
|
||||
:class="{ selected: selectedId === idea.id }"
|
||||
@click="selectedId = idea.id"
|
||||
>
|
||||
<div class="idea-card-header">
|
||||
<span class="idea-title">{{ idea.title }}</span>
|
||||
<span class="idea-score" :class="scoreClass(idea.score)">{{ idea.score ?? '-' }}</span>
|
||||
</div>
|
||||
<p class="idea-desc-preview">{{ idea.description?.slice(0, 60) ?? '' }}{{ idea.description && idea.description.length > 60 ? '...' : '' }}</p>
|
||||
<div class="idea-card-footer">
|
||||
<span class="status-tag" :class="'status-' + idea.status">{{ statusLabel(idea.status) }}</span>
|
||||
<span class="idea-date">{{ formatDate(idea.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 右侧:想法详情 -->
|
||||
<section class="idea-detail-panel" v-if="currentIdea">
|
||||
<div class="detail-header">
|
||||
<h2 class="detail-title">{{ currentIdea.title }}</h2>
|
||||
<span class="status-tag" :class="'status-' + currentIdea.status">{{ statusLabel(currentIdea.status) }}</span>
|
||||
</div>
|
||||
<p class="detail-desc">{{ currentIdea.description }}</p>
|
||||
|
||||
<!-- 对抗式评估 -->
|
||||
<div class="detail-section">
|
||||
<h3>⚖️ 对抗式评估</h3>
|
||||
<div v-if="adversarialEval" class="adversarial-eval">
|
||||
<!-- 正反方观点 -->
|
||||
<div class="debate-container">
|
||||
<div class="debate-column positive">
|
||||
<h4>📈 正方观点</h4>
|
||||
<div class="confidence-bar" :style="{ width: (adversarialEval.positive_strength * 100) + '%' }"></div>
|
||||
<div class="confidence-text">{{ (adversarialEval.positive_strength * 100).toFixed(0) }}% 置信度</div>
|
||||
<p class="thesis">{{ adversarialEval.positive.thesis }}</p>
|
||||
<ul>
|
||||
<li v-for="evidence in adversarialEval.positive.evidence" :key="evidence">• {{ evidence }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="debate-column negative">
|
||||
<h4>📉 反方观点</h4>
|
||||
<div class="confidence-bar" :style="{ width: (adversarialEval.negative_strength * 100) + '%' }"></div>
|
||||
<div class="confidence-text">{{ (adversarialEval.negative_strength * 100).toFixed(0) }}% 置信度</div>
|
||||
<p class="thesis">{{ adversarialEval.negative.thesis }}</p>
|
||||
<ul>
|
||||
<li v-for="evidence in adversarialEval.negative.evidence" :key="evidence">• {{ evidence }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI 分析师结论 -->
|
||||
<div class="analyst-conclusion">
|
||||
<h4>🧠 AI 分析师结论</h4>
|
||||
<div class="assessment-badge" :class="assessmentClass(adversarialEval.recommendation)">
|
||||
{{ assessmentLabel(adversarialEval.recommendation) }}
|
||||
</div>
|
||||
<p class="final-score">综合评分: {{ adversarialEval.final_score.toFixed(1) }}/10</p>
|
||||
<div class="net-sentiment" :class="sentimentClass(adversarialEval.net_sentiment)">
|
||||
整体倾向: {{ adversarialEval.net_sentiment > 0 ? '积极' : '谨慎' }}
|
||||
({{ (adversarialEval.net_sentiment * 100).toFixed(0) }})
|
||||
</div>
|
||||
<p class="summary">{{ adversarialEval.analyst.summary }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 行动建议 -->
|
||||
<div class="action-recommendations">
|
||||
<h4>💡 行动建议</h4>
|
||||
<ul>
|
||||
<li v-for="action in adversarialEval.action_items" :key="action">• {{ action }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="eval-report" style="opacity:0.5">
|
||||
<button class="btn-evaluate" @click="evaluateCurrentIdea">🔍 开始对抗评估</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 传统评分雷达图 -->
|
||||
<div class="detail-section">
|
||||
<h3>📊 多维评分</h3>
|
||||
<div class="radar-chart" v-if="parseScores(currentIdea).length > 0">
|
||||
<div class="radar-row" v-for="dim in parseScores(currentIdea)" :key="dim.name">
|
||||
<span class="radar-label">{{ dim.name }}</span>
|
||||
<div class="radar-bar-track">
|
||||
<div class="radar-bar-fill" :style="{ width: dim.score + '%' }" :class="dim.score >= 80 ? 'fill-high' : dim.score >= 60 ? 'fill-mid' : 'fill-low'"></div>
|
||||
</div>
|
||||
<span class="radar-value">{{ dim.score }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="eval-report" style="opacity:0.5">暂无评估</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签 -->
|
||||
<div class="detail-section">
|
||||
<h3>🏷️ 标签</h3>
|
||||
<div class="tag-list" v-if="parseTags(currentIdea).length > 0">
|
||||
<span class="tag" v-for="tag in parseTags(currentIdea)" :key="tag">{{ tag }}</span>
|
||||
</div>
|
||||
<div v-else class="eval-report" style="opacity:0.5">暂无标签</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态管理 -->
|
||||
<div class="detail-section">
|
||||
<h3>📋 状态管理</h3>
|
||||
<div class="status-controls">
|
||||
<select v-model="currentStatus" @change="updateIdeaStatus" class="status-select">
|
||||
<option value="draft">📝 草稿</option>
|
||||
<option value="pending_review">⏳ 待评估</option>
|
||||
<option value="approved">✅ 已批准</option>
|
||||
<option value="promoted">🚀 已立项</option>
|
||||
<option value="rejected">❌ 已拒绝</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 操作 -->
|
||||
<div class="detail-section" style="margin-top:8px">
|
||||
<div class="action-buttons">
|
||||
<button
|
||||
v-if="currentIdea.status === 'approved' && !currentIdea.promoted_to"
|
||||
class="btn btn-primary"
|
||||
@click="promoteToProject"
|
||||
>
|
||||
🚀 立项为项目
|
||||
</button>
|
||||
<button class="btn btn-ghost" @click="deleteCurrentIdea">🗑️ 删除想法</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 未选择 -->
|
||||
<section class="idea-detail-panel idea-empty" v-else>
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">💡</div>
|
||||
<p>选择一个想法查看详情</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 捕捉想法模态框 -->
|
||||
<div class="modal-overlay" v-if="showCaptureModal" @click.self="showCaptureModal = false">
|
||||
<div class="modal-box">
|
||||
<h3>✨ 捕捉新想法</h3>
|
||||
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">标题</label>
|
||||
<input v-model="newIdeaTitle" placeholder="一句话描述你的想法..." @keyup.enter="confirmCapture" />
|
||||
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">描述</label>
|
||||
<textarea v-model="newIdeaDesc" placeholder="详细说明(可选)..." rows="3" style="resize:vertical"></textarea>
|
||||
<div class="modal-actions">
|
||||
<button class="btn-cancel" @click="showCaptureModal = false">取消</button>
|
||||
<button class="btn-confirm" @click="confirmCapture">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useProjectStore } from '../stores/project'
|
||||
import { formatDate } from '../utils/time'
|
||||
import type { IdeaRecord } from '../api/types'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useProjectStore()
|
||||
|
||||
type FilterKey = 'all' | 'hot' | 'pending' | 'promoted'
|
||||
|
||||
const activeFilter = ref<FilterKey>('all')
|
||||
const selectedId = ref<string | null>(null)
|
||||
const searchQuery = ref('')
|
||||
|
||||
// ── 新建想法模态框 ──
|
||||
const showCaptureModal = ref(false)
|
||||
const newIdeaTitle = ref('')
|
||||
const newIdeaDesc = ref('')
|
||||
|
||||
const filters: { key: FilterKey; label: string; icon: string }[] = [
|
||||
{ key: 'all', label: '全部', icon: '📋' },
|
||||
{ key: 'hot', label: '热门', icon: '🔥' },
|
||||
{ key: 'pending', label: '待评估', icon: '⏳' },
|
||||
{ key: 'promoted', label: '已立项', icon: '🚀' },
|
||||
]
|
||||
|
||||
const filteredIdeas = computed(() => {
|
||||
let ideas = store.ideas
|
||||
|
||||
// 应用状态过滤
|
||||
switch (activeFilter.value) {
|
||||
case 'hot': ideas = ideas.filter(i => (i.score ?? 0) >= 80); break
|
||||
case 'pending': ideas = ideas.filter(i => i.status === 'pending_review' || i.status === 'draft'); break
|
||||
case 'promoted': ideas = ideas.filter(i => i.status === 'promoted'); break
|
||||
default: break // 不过滤
|
||||
}
|
||||
|
||||
// 应用搜索过滤
|
||||
if (searchQuery.value.trim()) {
|
||||
const query = searchQuery.value.toLowerCase().trim()
|
||||
ideas = ideas.filter(i =>
|
||||
i.title.toLowerCase().includes(query) ||
|
||||
(i.description && i.description.toLowerCase().includes(query)) ||
|
||||
(i.tags && JSON.parse(i.tags || '[]').some((tag: string) => tag.toLowerCase().includes(query)))
|
||||
)
|
||||
}
|
||||
|
||||
return ideas
|
||||
})
|
||||
|
||||
function filterIdeas() {
|
||||
// filteredIdeas 是 computed,会自动响应变化
|
||||
}
|
||||
|
||||
const currentIdea = computed(() => {
|
||||
if (!selectedId.value) return null
|
||||
return store.ideas.find(i => i.id === selectedId.value) ?? null
|
||||
})
|
||||
|
||||
const currentStatus = computed(() => {
|
||||
return currentIdea.value?.status || 'draft'
|
||||
})
|
||||
|
||||
function parseTags(idea: IdeaRecord): string[] {
|
||||
if (!idea.tags) return []
|
||||
try {
|
||||
const parsed = JSON.parse(idea.tags)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
interface ScoreDimension { name: string; score: number }
|
||||
|
||||
function parseScores(idea: IdeaRecord): ScoreDimension[] {
|
||||
if (!idea.scores) return []
|
||||
try {
|
||||
const parsed = JSON.parse(idea.scores)
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
return Object.entries(parsed).map(([name, score]) => ({
|
||||
name,
|
||||
score: typeof score === 'number' ? score : 0,
|
||||
}))
|
||||
}
|
||||
return []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
interface AdversarialEval {
|
||||
positive_strength: number
|
||||
negative_strength: number
|
||||
net_sentiment: number
|
||||
recommendation: string
|
||||
final_score: number
|
||||
summary: string
|
||||
action_items: string[]
|
||||
positive: { thesis: string; evidence: string[] }
|
||||
negative: { thesis: string; evidence: string[] }
|
||||
analyst: { summary: string }
|
||||
}
|
||||
|
||||
// 对抗式评估数据持久化在 ai_analysis JSON 字段中,前端解析渲染
|
||||
const adversarialEval = computed<AdversarialEval | null>(() => {
|
||||
const idea = currentIdea.value
|
||||
if (!idea?.ai_analysis) return null
|
||||
try {
|
||||
return JSON.parse(idea.ai_analysis) as AdversarialEval
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
function assessmentClass(recommendation: string) {
|
||||
return recommendation.toLowerCase().replace(/ /g, '-')
|
||||
}
|
||||
|
||||
function assessmentLabel(recommendation: string) {
|
||||
const map: Record<string, string> = {
|
||||
'immediate action': '🚀 立即行动',
|
||||
'soon': '📅 尽快行动',
|
||||
'with resources': '📦 配置资源后行动',
|
||||
'research more': '🔍 需要更多研究',
|
||||
'monitor': '👁️ 持续监控',
|
||||
'cancel': '❌ 取消想法'
|
||||
}
|
||||
return map[recommendation] ?? recommendation
|
||||
}
|
||||
|
||||
function sentimentClass(sentiment: number) {
|
||||
return sentiment >= 0 ? 'positive' : 'negative'
|
||||
}
|
||||
|
||||
function scoreClass(score: number | null) {
|
||||
const s = score ?? 0
|
||||
if (s >= 80) return 'score-high'
|
||||
if (s >= 60) return 'score-mid'
|
||||
return 'score-low'
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
const map: Record<string, string> = {
|
||||
draft: '📝 草稿',
|
||||
pending_review: '⏳ 待评估',
|
||||
approved: '✅ 已批准',
|
||||
promoted: '🚀 已立项',
|
||||
rejected: '❌ 已拒绝',
|
||||
}
|
||||
return map[status] ?? status
|
||||
}
|
||||
|
||||
// formatDate 由 ../utils/time 提供(统一毫秒字符串解析,根治 Invalid Date)
|
||||
|
||||
function openCaptureModal() {
|
||||
newIdeaTitle.value = ''
|
||||
newIdeaDesc.value = ''
|
||||
showCaptureModal.value = true
|
||||
}
|
||||
|
||||
async function confirmCapture() {
|
||||
if (!newIdeaTitle.value.trim()) return
|
||||
await store.createIdea({
|
||||
title: newIdeaTitle.value.trim(),
|
||||
description: newIdeaDesc.value.trim() || undefined,
|
||||
})
|
||||
showCaptureModal.value = false
|
||||
}
|
||||
|
||||
async function deleteCurrentIdea() {
|
||||
if (!currentIdea.value) return
|
||||
await store.deleteIdea(currentIdea.value.id)
|
||||
selectedId.value = null
|
||||
}
|
||||
|
||||
async function promoteToProject() {
|
||||
if (!currentIdea.value) return
|
||||
|
||||
// 创建新项目,基于想法(store.createProject 第 3 参 = idea_id)
|
||||
const project = await store.createProject(
|
||||
currentIdea.value.title,
|
||||
currentIdea.value.description,
|
||||
currentIdea.value.id,
|
||||
)
|
||||
const projectId = project.id
|
||||
|
||||
// 更新想法状态和晋升信息(store.updateIdea 单字段,分两次调用)
|
||||
await store.updateIdea(currentIdea.value.id, 'status', 'promoted')
|
||||
await store.updateIdea(currentIdea.value.id, 'promoted_to', projectId)
|
||||
|
||||
// 跳转到项目详情
|
||||
router.push(`/projects/${projectId}`)
|
||||
|
||||
// 刷新想法列表
|
||||
await store.loadIdeas()
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await store.loadIdeas()
|
||||
}
|
||||
|
||||
async function evaluateCurrentIdea() {
|
||||
if (!currentIdea.value) return
|
||||
|
||||
// 模拟对抗式评估(实际应该调用后端 API)
|
||||
// 这里使用模拟数据展示界面
|
||||
const mockEval: AdversarialEval = {
|
||||
positive_strength: 0.75,
|
||||
negative_strength: 0.65,
|
||||
net_sentiment: 0.1,
|
||||
recommendation: 'with resources',
|
||||
final_score: 6.5,
|
||||
summary: '该想法整体价值评估中等偏上,建议在有条件的情况下执行。主要价值在于技术创新性较强,需要关注风险控制和资源投入。',
|
||||
action_items: [
|
||||
'确认资源预算',
|
||||
'评估ROI',
|
||||
'制定风险预案'
|
||||
],
|
||||
positive: {
|
||||
thesis: '技术创新性强,潜在回报高',
|
||||
evidence: ['技术栈成熟', '市场需求明确', '团队有相关经验'],
|
||||
},
|
||||
negative: {
|
||||
thesis: '资源投入大,存在执行风险',
|
||||
evidence: ['开发周期长', '需要额外人力', '竞品已有类似方案'],
|
||||
},
|
||||
analyst: {
|
||||
summary: '综合正反方观点,建议在资源到位后启动,并设立阶段性验收点控制风险。',
|
||||
},
|
||||
}
|
||||
|
||||
// 更新想法的评估结果(实际应该调用 API)
|
||||
await store.updateIdea(currentIdea.value.id, 'ai_analysis', JSON.stringify(mockEval, null, 2))
|
||||
}
|
||||
|
||||
async function updateIdeaStatus() {
|
||||
if (!currentIdea.value || !currentStatus.value) return
|
||||
|
||||
await store.updateIdea(currentIdea.value.id, 'status', currentStatus.value)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await store.loadIdeas()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ideas { padding: 16px 20px 20px; }
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--df-gap-page);
|
||||
}
|
||||
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); }
|
||||
.header-actions { display: flex; gap: 10px; }
|
||||
|
||||
/* ===== 按钮 ===== */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-primary { background: var(--df-accent); color: #fff; }
|
||||
.btn-primary:hover { background: var(--df-accent-hover); }
|
||||
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
|
||||
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||||
|
||||
/* ===== 筛选栏 ===== */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: var(--df-gap-page);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
flex: 1;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
width: 100%;
|
||||
padding: 6px 12px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: var(--df-bg);
|
||||
color: var(--df-text);
|
||||
font-size: 13px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.search-box input:focus {
|
||||
outline: none;
|
||||
border-color: var(--df-accent);
|
||||
background: var(--df-bg-raised);
|
||||
}
|
||||
.filter-btn {
|
||||
padding: 6px 14px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--df-text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.filter-btn:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||||
.filter-btn.active {
|
||||
background: var(--df-accent);
|
||||
color: #fff;
|
||||
border-color: var(--df-accent);
|
||||
}
|
||||
|
||||
/* ===== 两栏布局 ===== */
|
||||
.ideas-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: var(--df-gap-grid);
|
||||
}
|
||||
|
||||
/* ===== 左侧列表 ===== */
|
||||
.idea-list-panel {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
padding: 6px;
|
||||
max-height: calc(100vh - 200px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.idea-card {
|
||||
padding: 14px;
|
||||
border-radius: var(--df-radius);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.idea-card:hover { background: rgba(108, 99, 255, 0.06); }
|
||||
.idea-card.selected { background: rgba(108, 99, 255, 0.12); border: 0.5px solid var(--df-accent); }
|
||||
|
||||
.idea-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.idea-title { font-size: 14px; font-weight: 500; color: var(--df-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.idea-score {
|
||||
font-size: 13px; font-weight: 500;
|
||||
min-width: 32px; height: 26px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
border-radius: var(--df-radius-sm);
|
||||
}
|
||||
.score-high { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||
.score-mid { background: rgba(255,217,61,0.15); color: var(--df-warning); }
|
||||
.score-low { background: rgba(255,107,107,0.15); color: var(--df-danger); }
|
||||
|
||||
.idea-desc-preview { font-size: 12px; color: var(--df-text-dim); margin-bottom: 8px; line-height: 1.4; }
|
||||
|
||||
.idea-card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.idea-date { font-size: 11px; color: var(--df-text-dim); }
|
||||
|
||||
/* ===== 状态标签 ===== */
|
||||
.status-tag {
|
||||
font-size: 11px; font-weight: 500; padding: 2px 8px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
.status-draft { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
.status-pending_review { background: rgba(100,181,246,0.2); color: var(--df-info); }
|
||||
.status-approved { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||
.status-promoted { background: rgba(108,99,255,0.2); color: var(--df-accent); }
|
||||
.status-rejected { background: rgba(255,107,107,0.2); color: var(--df-danger); }
|
||||
|
||||
/* ===== 对抗式评估 ===== */
|
||||
.adversarial-eval {
|
||||
background: var(--df-bg-raised);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.debate-container {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.debate-column {
|
||||
padding: 1rem;
|
||||
border-radius: var(--df-radius);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.debate-column.positive {
|
||||
background: rgba(61, 219, 160, 0.08);
|
||||
border: 0.5px solid rgba(61, 219, 160, 0.2);
|
||||
}
|
||||
|
||||
.debate-column.negative {
|
||||
background: rgba(240, 101, 101, 0.08);
|
||||
border: 0.5px solid rgba(240, 101, 101, 0.2);
|
||||
}
|
||||
|
||||
.debate-column h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin: 0 0 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.confidence-bar {
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: var(--df-radius-xs);
|
||||
margin-bottom: 0.25rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.debate-column.positive .confidence-bar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
background: currentColor;
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
|
||||
.debate-column.negative .confidence-bar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
background: currentColor;
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
|
||||
.confidence-text {
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.debate-column .thesis {
|
||||
font-size: 12px;
|
||||
color: var(--df-text);
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.debate-column ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.debate-column li {
|
||||
font-size: 11px;
|
||||
color: var(--df-text-secondary);
|
||||
margin-bottom: 0.25rem;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.analyst-conclusion {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.assessment-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.assessment-badge.immediate { background: var(--df-success-bg); color: var(--df-success); }
|
||||
.assessment-badge.soon { background: var(--df-info-bg); color: var(--df-info); }
|
||||
.assessment-badge.conditional { background: var(--df-warning-bg); color: var(--df-warning); }
|
||||
.assessment-badge.revised { background: rgba(255, 217, 61, 0.2); color: var(--df-warning); }
|
||||
.assessment-badge.defer { background: var(--df-danger-bg); color: var(--df-danger); }
|
||||
.assessment-badge.cancel { background: var(--df-danger-bg); color: var(--df-danger); }
|
||||
|
||||
.final-score {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--df-text);
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.net-sentiment {
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
display: inline-block;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.net-sentiment.positive { background: rgba(61, 219, 160, 0.15); color: var(--df-success); }
|
||||
.net-sentiment.negative { background: rgba(240, 101, 101, 0.15); color: var(--df-danger); }
|
||||
|
||||
.summary {
|
||||
font-size: 12px;
|
||||
color: var(--df-text-secondary);
|
||||
line-height: 1.5;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.action-recommendations {
|
||||
background: var(--df-bg-raised);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.action-recommendations h4 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.action-recommendations ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.action-recommendations li {
|
||||
font-size: 12px;
|
||||
color: var(--df-text-secondary);
|
||||
margin-bottom: 0.25rem;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-evaluate {
|
||||
background: var(--df-accent);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--df-radius);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-evaluate:hover {
|
||||
background: var(--df-accent-hover);
|
||||
}
|
||||
|
||||
/* ===== 右侧详情 ===== */
|
||||
.idea-detail-panel {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
padding: var(--df-pad-panel);
|
||||
max-height: calc(100vh - 200px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.idea-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.empty-state { text-align: center; color: var(--df-text-dim); }
|
||||
.empty-icon { font-size: 48px; margin-bottom: 12px; }
|
||||
.empty-state p { font-size: 14px; }
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--df-gap-head);
|
||||
}
|
||||
.detail-title { font-size: 20px; font-weight: 500; color: var(--df-text); }
|
||||
|
||||
.detail-desc {
|
||||
font-size: 14px;
|
||||
color: var(--df-text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: var(--df-gap-page);
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: var(--df-gap-page);
|
||||
}
|
||||
.detail-section h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--df-text);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.eval-report {
|
||||
font-size: 13px;
|
||||
color: var(--df-text-secondary);
|
||||
line-height: 1.6;
|
||||
padding: 14px 16px;
|
||||
background: rgba(108, 99, 255, 0.06);
|
||||
border-left: 3px solid var(--df-accent);
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
/* ===== 雷达图(div 模拟) ===== */
|
||||
.radar-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.radar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.radar-label {
|
||||
font-size: 12px;
|
||||
color: var(--df-text-secondary);
|
||||
min-width: 72px;
|
||||
text-align: right;
|
||||
}
|
||||
.radar-bar-track {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: var(--df-border);
|
||||
border-radius: var(--df-radius-xs);
|
||||
overflow: hidden;
|
||||
}
|
||||
.radar-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: var(--df-radius-xs);
|
||||
transition: width 0.4s;
|
||||
}
|
||||
.fill-high { background: var(--df-success); }
|
||||
.fill-mid { background: var(--df-warning); }
|
||||
.fill-low { background: var(--df-danger); }
|
||||
.radar-value {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
min-width: 28px;
|
||||
text-align: right;
|
||||
color: var(--df-text);
|
||||
}
|
||||
|
||||
/* ===== 标签 ===== */
|
||||
.tag-list { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.tag {
|
||||
font-size: 12px; padding: 4px 10px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: rgba(108, 99, 255, 0.1);
|
||||
color: var(--df-accent);
|
||||
}
|
||||
|
||||
/* ===== 删除按钮 ===== */
|
||||
.btn-delete {
|
||||
padding: 6px 14px;
|
||||
border: 0.5px solid rgba(255,107,107,0.3);
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--df-danger);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-delete:hover { background: rgba(255,107,107,0.1); }
|
||||
|
||||
/* ===== 状态管理 ===== */
|
||||
.status-controls {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.status-select {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: var(--df-bg);
|
||||
color: var(--df-text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.status-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--df-accent);
|
||||
background: var(--df-bg-raised);
|
||||
}
|
||||
|
||||
/* ===== 响应式 ===== */
|
||||
@media (max-width: 900px) {
|
||||
.ideas { padding: 16px; }
|
||||
.ideas-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.filter-bar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.search-box {
|
||||
max-width: 100%;
|
||||
order: -1;
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 模态框 ===== */
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||||
.modal-box { background: var(--df-bg-card); border: 0.5px solid var(--df-border); border-radius: var(--df-radius-lg); padding: 24px; min-width: 360px; max-width: 90vw; }
|
||||
.modal-box h3 { color: var(--df-text); margin-bottom: var(--df-gap-head); }
|
||||
.modal-box input, .modal-box textarea { width: 100%; padding: 8px 12px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); background: var(--df-bg); color: var(--df-text); font-size: 13px; margin-bottom: 12px; box-sizing: border-box; font-family: inherit; }
|
||||
.modal-box .modal-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
||||
.modal-box .btn-cancel { padding: 6px 16px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); background: transparent; color: var(--df-text-secondary); cursor: pointer; }
|
||||
.modal-box .btn-confirm { padding: 6px 16px; border: none; border-radius: var(--df-radius-sm); background: var(--df-accent); color: #fff; cursor: pointer; }
|
||||
</style>
|
||||
338
src/views/Knowledge.vue
Normal file
338
src/views/Knowledge.vue
Normal file
@@ -0,0 +1,338 @@
|
||||
<template>
|
||||
<div class="knowledge">
|
||||
<!-- 页面头部 -->
|
||||
<header class="page-header">
|
||||
<h1>📚 知识库</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-primary" @click="addKnowledge">+ 新增知识</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 搜索栏 -->
|
||||
<div class="search-bar">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="search-input"
|
||||
placeholder="搜索知识标题、标签、内容..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 分类标签页 -->
|
||||
<div class="category-tabs">
|
||||
<button
|
||||
v-for="cat in categories"
|
||||
:key="cat.key"
|
||||
class="tab-btn"
|
||||
:class="{ 'tab-active': activeCategory === cat.key }"
|
||||
@click="activeCategory = cat.key"
|
||||
>
|
||||
<span class="tab-icon">{{ cat.icon }}</span>
|
||||
<span class="tab-label">{{ cat.label }}</span>
|
||||
<span class="tab-count">{{ getCategoryCount(cat.key) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 知识卡片列表 -->
|
||||
<div class="knowledge-grid">
|
||||
<div class="knowledge-card" v-for="item in filteredItems" :key="item.id">
|
||||
<div class="card-header">
|
||||
<span class="card-title">{{ item.title }}</span>
|
||||
<span class="card-score" :class="scoreClass(item.score)">{{ item.score }}分</span>
|
||||
</div>
|
||||
<div class="card-desc">{{ item.description }}</div>
|
||||
<div class="card-tags">
|
||||
<span class="tag" v-for="tag in item.tags" :key="tag">{{ tag }}</span>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<span class="card-reuse">🔄 复用 {{ item.reuseCount }} 次</span>
|
||||
<span class="card-category">{{ getCategoryLabel(item.category) }}</span>
|
||||
<span class="card-time">{{ item.updatedAt }}</span>
|
||||
<button class="btn btn-ghost" @click="deleteKnowledge(item.id)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="filteredItems.length === 0" class="empty-state">
|
||||
<div class="empty-icon">📭</div>
|
||||
<div class="empty-text">没有找到匹配的知识条目</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
interface KnowledgeItem {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
category: string
|
||||
tags: string[]
|
||||
reuseCount: number
|
||||
score: number
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
const categories = [
|
||||
{ key: 'all', label: '全部', icon: '📦' },
|
||||
{ key: 'review', label: '审查规则', icon: '🔍' },
|
||||
{ key: 'prompt', label: 'Prompt模板', icon: '💬' },
|
||||
{ key: 'pitfall', label: '踩坑经验', icon: '⚠️' },
|
||||
{ key: 'diagnosis', label: '诊断知识', icon: '🩺' },
|
||||
{ key: 'deploy', label: '部署经验', icon: '🚀' },
|
||||
]
|
||||
|
||||
const activeCategory = ref('all')
|
||||
const searchQuery = ref('')
|
||||
const knowledgeItems = ref<KnowledgeItem[]>(loadKnowledgeItems())
|
||||
|
||||
// 从 localStorage 加载知识数据
|
||||
function loadKnowledgeItems(): KnowledgeItem[] {
|
||||
const saved = localStorage.getItem('devflow-knowledge')
|
||||
if (saved) {
|
||||
try {
|
||||
return JSON.parse(saved)
|
||||
} catch {
|
||||
// 解析失败,使用默认数据
|
||||
}
|
||||
}
|
||||
return getDefaultData()
|
||||
}
|
||||
|
||||
// 获取默认数据
|
||||
function getDefaultData(): KnowledgeItem[] {
|
||||
return [
|
||||
// 审查规则
|
||||
{ id: 1, title: 'SQL 注入防护检查', description: '所有 SQL 拼接必须使用参数化查询,禁止字符串拼接用户输入', category: 'review', tags: ['安全', 'SQL', 'Go'], reuseCount: 23, score: 95, updatedAt: '3 天前' },
|
||||
{ id: 2, title: '错误处理规范', description: '禁止吞掉 error,必须向上传播或记录日志', category: 'review', tags: ['Go', '规范', '错误处理'], reuseCount: 18, score: 88, updatedAt: '1 周前' },
|
||||
{ id: 3, title: 'API 响应格式一致性', description: '统一使用 { code, message, data } 结构, HTTP 状态码语义正确', category: 'review', tags: ['API', '规范'], reuseCount: 15, score: 82, updatedAt: '5 天前' },
|
||||
|
||||
// Prompt 模板
|
||||
{ id: 4, title: 'SQL 查询生成 Prompt', description: '根据自然语言生成 SQL,包含表结构上下文和示例输出格式', category: 'prompt', tags: ['SQL', 'LLM', '模板'], reuseCount: 142, score: 91, updatedAt: '昨天' },
|
||||
{ id: 5, title: '代码审查 Prompt', description: 'AI 代码审查专用 Prompt,涵盖安全、性能、可维护性维度', category: 'prompt', tags: ['代码审查', 'LLM'], reuseCount: 87, score: 85, updatedAt: '3 天前' },
|
||||
{ id: 6, title: 'API 文档生成 Prompt', description: '从 Handler 代码自动生成 API 文档的 Prompt 模板', category: 'prompt', tags: ['文档', 'LLM', '自动生成'], reuseCount: 34, score: 78, updatedAt: '1 周前' },
|
||||
|
||||
// 踩坑经验
|
||||
{ id: 7, title: 'Go context 传递陷阱', description: 'goroutine 中必须传递 context 而非创建新的,否则超时控制失效', category: 'pitfall', tags: ['Go', '并发', 'context'], reuseCount: 12, score: 92, updatedAt: '2 天前' },
|
||||
{ id: 8, title: 'Vue 3 作用域坑', description: 'v-for 内 ref 绑定不会自动响应式,需使用数组形式', category: 'pitfall', tags: ['Vue', '前端', '响应式'], reuseCount: 8, score: 75, updatedAt: '1 周前' },
|
||||
{ id: 9, title: 'Docker 网络模式选择', description: 'host 模式在 Mac/Win 上无效,必须用端口映射', category: 'pitfall', tags: ['Docker', '网络'], reuseCount: 6, score: 70, updatedAt: '2 周前' },
|
||||
|
||||
// 诊断知识
|
||||
{ id: 10, title: 'MySQL 慢查询诊断流程', description: 'EXPLAIN → 索引检查 → 慢查询日志分析 → 优化建议', category: 'diagnosis', tags: ['MySQL', '性能', '诊断'], reuseCount: 31, score: 89, updatedAt: '4 天前' },
|
||||
{ id: 11, title: 'Go 内存泄漏排查', description: 'pprof heap 分析 → goroutine 泄漏检查 → GC 调优', category: 'diagnosis', tags: ['Go', '内存', 'pprof'], reuseCount: 9, score: 83, updatedAt: '1 周前' },
|
||||
{ id: 12, title: '前端白屏诊断', description: 'Console 错误 → 网络请求 → 路由配置 → 构建产物检查', category: 'diagnosis', tags: ['前端', '调试', 'Vue'], reuseCount: 14, score: 80, updatedAt: '5 天前' },
|
||||
|
||||
// 部署经验
|
||||
{ id: 13, title: 'Go 二进制热更新', description: 'kill + mv + nohup 启动,无需 systemd 的轻量部署方案', category: 'deploy', tags: ['Go', '部署', 'Linux'], reuseCount: 19, score: 86, updatedAt: '昨天' },
|
||||
{ id: 14, title: 'SCP 上传最佳实践', description: '使用 ssh-proxy upload 代替 scp,支持配置化管理', category: 'deploy', tags: ['SCP', '部署', '工具'], reuseCount: 11, score: 72, updatedAt: '3 天前' },
|
||||
{ id: 15, title: 'Nginx 反向代理配置', description: 'u-ask 的 Nginx 配置模板,含 WebSocket 支持和 gzip', category: 'deploy', tags: ['Nginx', '配置', '反向代理'], reuseCount: 7, score: 77, updatedAt: '1 周前' },
|
||||
]
|
||||
}
|
||||
|
||||
// 保存到 localStorage
|
||||
function saveToLocalStorage() {
|
||||
localStorage.setItem('devflow-knowledge', JSON.stringify(knowledgeItems.value))
|
||||
}
|
||||
|
||||
// 添加知识
|
||||
function addKnowledge() {
|
||||
// TODO: 实现添加知识的对话框逻辑
|
||||
console.log('添加知识')
|
||||
}
|
||||
|
||||
// 删除知识
|
||||
function deleteKnowledge(id: number) {
|
||||
const index = knowledgeItems.value.findIndex(item => item.id === id)
|
||||
if (index !== -1) {
|
||||
knowledgeItems.value.splice(index, 1)
|
||||
saveToLocalStorage()
|
||||
}
|
||||
}
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
let items = knowledgeItems.value
|
||||
if (activeCategory.value !== 'all') {
|
||||
items = items.filter(i => i.category === activeCategory.value)
|
||||
}
|
||||
if (searchQuery.value.trim()) {
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
items = items.filter(i =>
|
||||
i.title.toLowerCase().includes(q) ||
|
||||
i.tags.some(t => t.toLowerCase().includes(q)) ||
|
||||
i.description.toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
function getCategoryCount(key: string): number {
|
||||
if (key === 'all') return knowledgeItems.value.length
|
||||
return knowledgeItems.value.filter(i => i.category === key).length
|
||||
}
|
||||
|
||||
function getCategoryLabel(key: string): string {
|
||||
const cat = categories.find(c => c.key === key)
|
||||
return cat ? cat.label : key
|
||||
}
|
||||
|
||||
function scoreClass(score: number): string {
|
||||
if (score >= 90) return 'score-high'
|
||||
if (score >= 75) return 'score-mid'
|
||||
return 'score-low'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.knowledge { padding: 16px 20px 20px; }
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--df-gap-page);
|
||||
}
|
||||
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); }
|
||||
.header-actions { display: flex; gap: 8px; }
|
||||
|
||||
/* ===== 按钮 ===== */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-primary { background: var(--df-accent); color: #fff; }
|
||||
.btn-primary:hover { background: var(--df-accent-hover); }
|
||||
.btn-accent { background: rgba(100,255,218,0.15); color: var(--df-success); border: 0.5px solid rgba(100,255,218,0.3); }
|
||||
.btn-accent:hover { background: rgba(100,255,218,0.25); }
|
||||
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
|
||||
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||||
|
||||
/* ===== 搜索栏 ===== */
|
||||
.search-bar { margin-bottom: var(--df-gap-page); }
|
||||
.search-input {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
padding: 10px 16px;
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
color: var(--df-text);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
.search-input::placeholder { color: var(--df-text-dim); }
|
||||
.search-input:focus { border-color: var(--df-accent); }
|
||||
|
||||
/* ===== 分类标签 ===== */
|
||||
.category-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: var(--df-gap-page);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tab-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
background: transparent;
|
||||
color: var(--df-text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.tab-btn:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||||
.tab-active {
|
||||
background: var(--df-accent) !important;
|
||||
color: #fff !important;
|
||||
border-color: var(--df-accent) !important;
|
||||
}
|
||||
.tab-icon { font-size: 14px; }
|
||||
.tab-count {
|
||||
font-size: 11px;
|
||||
background: rgba(255,255,255,0.15);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--df-radius);
|
||||
min-width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
.tab-active .tab-count { background: rgba(255,255,255,0.25); }
|
||||
|
||||
/* ===== 知识卡片网格 ===== */
|
||||
.knowledge-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: var(--df-gap-grid);
|
||||
}
|
||||
.knowledge-card {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
padding: var(--df-pad-panel);
|
||||
transition: border-color 0.15s, transform 0.15s;
|
||||
}
|
||||
.knowledge-card:hover {
|
||||
border-color: var(--df-accent);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.card-title { font-size: 14px; font-weight: 500; color: var(--df-text); }
|
||||
.card-score {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
.score-high { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||
.score-mid { background: rgba(255,217,61,0.15); color: var(--df-warning); }
|
||||
.score-low { background: rgba(255,107,107,0.15); color: var(--df-danger); }
|
||||
|
||||
.card-desc {
|
||||
font-size: 13px;
|
||||
color: var(--df-text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.card-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
|
||||
.tag {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: rgba(108,99,255,0.1);
|
||||
color: var(--df-accent);
|
||||
}
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
.card-reuse { color: var(--df-info); }
|
||||
.card-category {
|
||||
background: rgba(90,99,128,0.2);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
}
|
||||
|
||||
/* ===== 空状态 ===== */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 0;
|
||||
}
|
||||
.empty-icon { font-size: 48px; margin-bottom: 12px; }
|
||||
.empty-text { font-size: 14px; color: var(--df-text-dim); }
|
||||
</style>
|
||||
667
src/views/ProjectDetail.vue
Normal file
667
src/views/ProjectDetail.vue
Normal file
@@ -0,0 +1,667 @@
|
||||
<template>
|
||||
<div class="project-detail">
|
||||
<!-- 页面头部 -->
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<router-link to="/projects" class="back-link">← 项目列表</router-link>
|
||||
<h1>{{ currentProject?.name ?? '...' }}</h1>
|
||||
<span class="stage-badge" :class="'stage-' + stageKey">{{ statusLabel }}</span>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-ghost" @click="handleSync">🔄 同步</button>
|
||||
<button class="btn btn-primary" @click="showNewTaskModal = true">+ 新任务</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 新建任务模态框 -->
|
||||
<div v-if="showNewTaskModal" class="modal-overlay" @click.self="showNewTaskModal = false">
|
||||
<div class="modal-box">
|
||||
<h3 class="modal-title">新建任务</h3>
|
||||
<div class="modal-field">
|
||||
<label>任务标题</label>
|
||||
<input v-model="newTaskTitle" placeholder="输入任务标题" @keyup.enter="submitNewTask" />
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>描述</label>
|
||||
<textarea v-model="newTaskDesc" placeholder="简要描述" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>分支名称</label>
|
||||
<input v-model="newTaskBranch" placeholder="feature/xxx" />
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-ghost" @click="showNewTaskModal = false">取消</button>
|
||||
<button class="btn btn-primary" @click="submitNewTask" :disabled="!newTaskTitle.trim()">确认创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 阶段进度条 -->
|
||||
<div class="stage-pipeline">
|
||||
<div
|
||||
v-for="(stage, idx) in stages"
|
||||
:key="stage.key"
|
||||
class="stage-step"
|
||||
:class="{
|
||||
'stage-active': idx === currentStageIndex,
|
||||
'stage-done': idx < currentStageIndex,
|
||||
}"
|
||||
>
|
||||
<div class="stage-dot">
|
||||
<span v-if="idx < currentStageIndex">✓</span>
|
||||
<span v-else>{{ idx + 1 }}</span>
|
||||
</div>
|
||||
<div class="stage-label">{{ stage.label }}</div>
|
||||
<div v-if="idx < stages.length - 1" class="stage-connector" :class="{ 'connector-done': idx < currentStageIndex }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主体两栏 -->
|
||||
<div class="detail-grid">
|
||||
<!-- 左栏:项目信息 -->
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>📋 项目信息</h2>
|
||||
</div>
|
||||
<div class="project-info" v-if="currentProject">
|
||||
<div class="info-item">
|
||||
<span class="label">来源想法</span>
|
||||
<router-link v-if="currentProject.idea_id" :to="`/ideas/${currentProject.idea_id}`" class="idea-link">
|
||||
查看想法详情
|
||||
</router-link>
|
||||
<span v-else class="no-idea">原始想法已删除</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">创建时间</span>
|
||||
<span>{{ formatDate(currentProject.created_at) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">更新时间</span>
|
||||
<span>{{ formatDate(currentProject.updated_at) }}</span>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<span class="label">项目状态</span>
|
||||
<span class="status-badge" :class="currentProject.status">{{ currentProject.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 左栏:任务列表 -->
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>🔀 任务列表</h2>
|
||||
<span class="task-count">{{ projectTasks.length }} 个任务</span>
|
||||
</div>
|
||||
<div class="task-list">
|
||||
<div class="task-card" v-for="task in projectTasks" :key="task.id">
|
||||
<div class="task-top">
|
||||
<span class="task-title">{{ task.title }}</span>
|
||||
<span class="task-status" :class="taskStatusClass(task.status)">{{ taskStatusLabel(task.status) }}</span>
|
||||
</div>
|
||||
<div class="task-branch" v-if="task.branch_name">
|
||||
<span class="branch-icon">⑂</span>
|
||||
<span class="branch-name">{{ task.branch_name }}</span>
|
||||
</div>
|
||||
<div class="task-meta">
|
||||
<span>{{ task.assignee ?? '—' }}</span>
|
||||
<span>{{ formatDate(task.created_at) }}</span>
|
||||
<span style="opacity: 0.7">{{ formatDate(task.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="projectTasks.length === 0" class="empty-hint">暂无任务</div>
|
||||
</section>
|
||||
|
||||
<!-- 右栏 -->
|
||||
<div class="right-column">
|
||||
<!-- 工作流执行日志 -->
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>📋 工作流日志</h2>
|
||||
<button class="btn btn-ghost btn-sm" @click="runDemoWorkflow" :disabled="workflowRunning">▶ 运行测试工作流</button>
|
||||
</div>
|
||||
<div class="log-list" ref="logListRef">
|
||||
<div class="log-item" v-for="(evt, idx) in formattedEvents" :key="idx" :class="'log-' + evt.level">
|
||||
<span class="log-time">{{ evt.time }}</span>
|
||||
<span class="log-level">{{ evt.level }}</span>
|
||||
<span class="log-msg">{{ evt.message }}</span>
|
||||
</div>
|
||||
<div v-if="formattedEvents.length === 0" class="empty-hint">点击"运行测试工作流"查看实时日志</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 项目上下文 -->
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>🏷️ 项目信息</h2>
|
||||
</div>
|
||||
<div class="info-grid">
|
||||
<div class="info-row">
|
||||
<span class="info-label">状态</span>
|
||||
<span class="info-value">{{ statusLabel }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">描述</span>
|
||||
<span class="info-value">{{ currentProject?.description ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">来源想法</span>
|
||||
<span class="info-value">{{ currentProject?.idea_id ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">创建时间</span>
|
||||
<span class="info-value">{{ formatDate(currentProject?.created_at ?? '') }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">更新时间</span>
|
||||
<span class="info-value">{{ formatDate(currentProject?.updated_at ?? '') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 人工审批对话框 -->
|
||||
<div v-if="showApprovalDialog" class="modal-overlay">
|
||||
<div class="modal-box" style="width: 500px">
|
||||
<h3 class="modal-title">需要人工审批</h3>
|
||||
<div v-if="store.pendingApproval">
|
||||
<h3>{{ store.pendingApproval.title }}</h3>
|
||||
<p style="margin: 16px 0; color: var(--df-text-secondary);">{{ store.pendingApproval.description }}</p>
|
||||
<div style="margin-bottom: 20px;">
|
||||
<p style="margin-bottom: 8px; font-size: 14px;">请选择操作:</p>
|
||||
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
|
||||
<button
|
||||
v-for="option in store.pendingApproval.options"
|
||||
:key="option"
|
||||
class="btn"
|
||||
:class="option === '同意' ? 'btn-primary' : 'btn-ghost'"
|
||||
@click="handleApproval(option)"
|
||||
>
|
||||
{{ option }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-ghost" @click="showApprovalDialog = false">稍后处理</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useProjectStore } from '../stores/project'
|
||||
import { formatDate } from '../utils/time'
|
||||
import type { WorkflowEventPayload } from '../api/types'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useProjectStore()
|
||||
const logListRef = ref<HTMLElement | null>(null)
|
||||
const showApprovalDialog = ref(false)
|
||||
let unlisten: (() => void) | null = null
|
||||
|
||||
// ── 阶段定义 ──
|
||||
const stages = [
|
||||
{ key: 'idea', label: '💡 想法' },
|
||||
{ key: 'requirement', label: '📋 需求' },
|
||||
{ key: 'coding', label: '💻 编码' },
|
||||
{ key: 'testing', label: '🧪 测试' },
|
||||
{ key: 'release', label: '🚀 发布' },
|
||||
]
|
||||
|
||||
// ── 状态映射 ──
|
||||
const stageMap: Record<string, number> = {
|
||||
planning: 0, in_progress: 2, testing: 3, completed: 4, paused: 2, cancelled: 0,
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
planning: '📐 规划中',
|
||||
in_progress: '💻 进行中',
|
||||
paused: '⏸ 已暂停',
|
||||
completed: '✅ 已完成',
|
||||
cancelled: '❌ 已取消',
|
||||
}
|
||||
|
||||
const taskStatusLabels: Record<string, string> = {
|
||||
todo: '📋 待开始',
|
||||
in_progress: '🔄 进行中',
|
||||
review_ready: '👀 待审查',
|
||||
merged: '✅ 已合并',
|
||||
abandoned: '❌ 已放弃',
|
||||
}
|
||||
|
||||
const taskStatusClasses: Record<string, string> = {
|
||||
todo: 'status-todo',
|
||||
in_progress: 'status-progress',
|
||||
review_ready: 'status-review',
|
||||
merged: 'status-done',
|
||||
abandoned: 'status-todo',
|
||||
}
|
||||
|
||||
// ── 当前项目 ──
|
||||
const projectId = computed(() => route.params.id as string)
|
||||
const currentProject = computed(() =>
|
||||
store.projects.find(p => p.id === projectId.value)
|
||||
)
|
||||
const stageKey = computed(() => currentProject.value?.status ?? 'planning')
|
||||
const statusLabel = computed(() => statusLabels[stageKey.value] ?? stageKey.value)
|
||||
const currentStageIndex = computed(() => stageMap[stageKey.value] ?? 0)
|
||||
|
||||
// ── 任务 ──
|
||||
const projectTasks = computed(() =>
|
||||
store.tasks.filter(t => t.project_id === projectId.value)
|
||||
)
|
||||
|
||||
function taskStatusLabel(status: string) {
|
||||
return taskStatusLabels[status] ?? status
|
||||
}
|
||||
|
||||
function taskStatusClass(status: string) {
|
||||
return taskStatusClasses[status] ?? 'status-todo'
|
||||
}
|
||||
|
||||
// ── 工作流 ──
|
||||
const workflowRunning = ref(false)
|
||||
|
||||
const demoDag = {
|
||||
nodes: [
|
||||
{ id: 'n1', node_type: 'script', label: '环境检查', config: { command: 'echo "Environment OK"', timeout_secs: 10 } },
|
||||
{ id: 'n2', node_type: 'script', label: '运行测试', config: { command: 'echo "Tests passed"', timeout_secs: 10 } },
|
||||
{ id: 'n3', node_type: 'script', label: '构建产物', config: { command: 'echo "Build success"', timeout_secs: 10 } },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'n1', to: 'n2' },
|
||||
{ from: 'n2', to: 'n3' },
|
||||
],
|
||||
}
|
||||
|
||||
async function runDemoWorkflow() {
|
||||
workflowRunning.value = true
|
||||
store.clearLiveEvents()
|
||||
try {
|
||||
await store.runWorkflow('demo-pipeline', demoDag)
|
||||
} finally {
|
||||
workflowRunning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 实时日志格式化 ──
|
||||
interface LogEntry {
|
||||
time: string
|
||||
level: string
|
||||
message: string
|
||||
}
|
||||
|
||||
const formattedEvents = computed<LogEntry[]>(() => {
|
||||
return store.liveEvents.map((evt: WorkflowEventPayload) => {
|
||||
const ev = evt.event
|
||||
const type = ev.type ?? 'unknown'
|
||||
const level = type.includes('error') || type.includes('fail') ? 'error'
|
||||
: type.includes('warn') ? 'warn' : 'info'
|
||||
const time = new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
|
||||
const message = ev.label
|
||||
? `[${ev.label}] ${type}: ${ev.output ?? ev.code ?? ev.message ?? JSON.stringify(ev)}`
|
||||
: `${type}: ${ev.output ?? ev.code ?? ev.message ?? JSON.stringify(ev)}`
|
||||
return { time, level, message }
|
||||
})
|
||||
})
|
||||
|
||||
// ── 新建任务 ──
|
||||
const showNewTaskModal = ref(false)
|
||||
const newTaskTitle = ref('')
|
||||
const newTaskDesc = ref('')
|
||||
const newTaskBranch = ref('')
|
||||
|
||||
async function submitNewTask() {
|
||||
if (!newTaskTitle.value.trim()) return
|
||||
await store.createTask({
|
||||
project_id: projectId.value,
|
||||
title: newTaskTitle.value.trim(),
|
||||
description: newTaskDesc.value.trim(),
|
||||
branch_name: newTaskBranch.value.trim() || undefined,
|
||||
})
|
||||
showNewTaskModal.value = false
|
||||
newTaskTitle.value = ''
|
||||
newTaskDesc.value = ''
|
||||
newTaskBranch.value = ''
|
||||
}
|
||||
|
||||
// ── 同步 ──
|
||||
async function handleSync() {
|
||||
await Promise.all([store.loadProjects(), store.loadTasks(projectId.value)])
|
||||
}
|
||||
|
||||
// ── 审批处理 ──
|
||||
async function handleApproval(decision: string) {
|
||||
await store.approveHumanApproval(decision)
|
||||
showApprovalDialog.value = false
|
||||
}
|
||||
|
||||
// 监听审批请求
|
||||
watch(() => store.pendingApproval, (newApproval) => {
|
||||
if (newApproval) {
|
||||
showApprovalDialog.value = true
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// ── 工具函数 ──
|
||||
// formatDate 由 ../utils/time 提供(统一毫秒字符串解析,根治 Invalid Date)
|
||||
|
||||
// ── 生命周期 ──
|
||||
onMounted(async () => {
|
||||
await store.loadProjects()
|
||||
await store.loadTasks(projectId.value)
|
||||
unlisten = (await store.startEventListener()) as any
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unlisten) {
|
||||
unlisten()
|
||||
unlisten = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ... 现有样式 ... */
|
||||
|
||||
/* ===== 项目信息样式 ===== */
|
||||
.project-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--df-gap-grid);
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.info-item .label {
|
||||
font-size: 12px;
|
||||
color: var(--df-text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.info-item a.idea-link {
|
||||
color: var(--df-accent);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-item a.idea-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.info-item .no-idea {
|
||||
color: var(--df-text-dim);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--df-radius-lg);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
background: rgba(108,99,255,0.1);
|
||||
color: var(--df-accent);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.project-detail { padding: 16px 20px 20px; }
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--df-gap-page);
|
||||
}
|
||||
.header-left { display: flex; align-items: center; gap: 12px; }
|
||||
.back-link { font-size: 13px; color: var(--df-accent); text-decoration: none; }
|
||||
.back-link:hover { text-decoration: underline; }
|
||||
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); }
|
||||
|
||||
.stage-badge {
|
||||
font-size: 12px;
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--df-radius-lg);
|
||||
font-weight: 500;
|
||||
}
|
||||
.stage-idea { background: rgba(100,181,246,0.15); color: var(--df-info); }
|
||||
.stage-requirement { background: rgba(255,217,61,0.15); color: var(--df-warning); }
|
||||
.stage-coding { background: rgba(108,99,255,0.15); color: var(--df-accent); }
|
||||
.stage-testing { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||
.stage-release { background: rgba(255,107,107,0.15); color: #ff9800; }
|
||||
|
||||
.header-actions { display: flex; gap: 10px; }
|
||||
|
||||
/* ===== 按钮 ===== */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-primary { background: var(--df-accent); color: #fff; }
|
||||
.btn-primary:hover { background: var(--df-accent-hover); }
|
||||
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
|
||||
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||||
.btn-sm { padding: 4px 12px; font-size: 12px; }
|
||||
|
||||
/* ===== 阶段进度条 ===== */
|
||||
.stage-pipeline {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0;
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
padding: var(--df-pad-panel);
|
||||
margin-bottom: var(--df-gap-page);
|
||||
}
|
||||
.stage-step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
flex: 1;
|
||||
}
|
||||
.stage-dot {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
background: var(--df-border);
|
||||
color: var(--df-text-dim);
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.stage-active .stage-dot {
|
||||
background: var(--df-accent);
|
||||
color: #fff;
|
||||
|
||||
}
|
||||
.stage-done .stage-dot {
|
||||
background: var(--df-success);
|
||||
color: var(--df-bg);
|
||||
}
|
||||
.stage-label {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--df-text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.stage-active .stage-label { color: var(--df-accent); font-weight: 500; }
|
||||
.stage-done .stage-label { color: var(--df-success); }
|
||||
|
||||
.stage-connector {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: calc(50% + 16px);
|
||||
width: calc(100% - 32px);
|
||||
height: 2px;
|
||||
background: var(--df-border);
|
||||
}
|
||||
.connector-done { background: var(--df-success); }
|
||||
|
||||
/* ===== 两栏布局 ===== */
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 380px;
|
||||
gap: var(--df-gap-grid);
|
||||
}
|
||||
.right-column { display: flex; flex-direction: column; gap: var(--df-gap-grid); }
|
||||
|
||||
/* ===== 面板 ===== */
|
||||
.panel {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
padding: var(--df-pad-panel);
|
||||
}
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--df-gap-head);
|
||||
}
|
||||
.panel-header h2 { font-size: 15px; font-weight: 500; }
|
||||
.task-count { font-size: 12px; color: var(--df-text-dim); }
|
||||
|
||||
/* ===== 任务卡片 ===== */
|
||||
.task-list { display: flex; flex-direction: column; }
|
||||
.task-card {
|
||||
padding: 14px 0;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
}
|
||||
.task-card:last-child { border-bottom: none; }
|
||||
.task-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
|
||||
.task-title { font-size: 14px; font-weight: 500; color: var(--df-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.task-status { font-size: 11px; padding: 2px 8px; border-radius: var(--df-radius-xs); }
|
||||
.status-done { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||
.status-progress { background: rgba(108,99,255,0.15); color: var(--df-accent); }
|
||||
.status-review { background: rgba(255,217,61,0.15); color: var(--df-warning); }
|
||||
.status-todo { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
|
||||
.task-branch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.branch-icon { color: var(--df-accent); font-size: 13px; }
|
||||
.branch-name {
|
||||
font-size: 12px;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: var(--df-text-secondary);
|
||||
background: rgba(108,99,255,0.1);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
}
|
||||
.task-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
|
||||
/* ===== 日志 ===== */
|
||||
.log-list {
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
max-height: 280px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.log-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 5px 0;
|
||||
border-bottom: 0.5px solid rgba(42,42,74,0.5);
|
||||
}
|
||||
.log-item:last-child { border-bottom: none; }
|
||||
.log-time { color: var(--df-text-dim); min-width: 64px; }
|
||||
.log-level {
|
||||
min-width: 42px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
font-size: 10px;
|
||||
padding: 1px 4px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
text-align: center;
|
||||
}
|
||||
.log-info .log-level { color: var(--df-info); }
|
||||
.log-warn .log-level { color: var(--df-warning); }
|
||||
.log-error .log-level { color: var(--df-danger); }
|
||||
.log-msg { color: var(--df-text-secondary); flex: 1; }
|
||||
|
||||
/* ===== 项目信息 ===== */
|
||||
.info-grid { display: flex; flex-direction: column; gap: 0; }
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
}
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
.info-label { font-size: 13px; color: var(--df-text-dim); }
|
||||
.info-value { font-size: 13px; color: var(--df-text-secondary); font-weight: 500; }
|
||||
|
||||
/* ===== 模态框 ===== */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.modal-box {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
padding: 24px;
|
||||
width: 420px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
.modal-title { font-size: 18px; font-weight: 500; color: var(--df-text); margin-bottom: 16px; }
|
||||
.modal-field { margin-bottom: 14px; }
|
||||
.modal-field label { display: block; font-size: 12px; color: var(--df-text-dim); margin-bottom: 4px; }
|
||||
.modal-field input, .modal-field textarea {
|
||||
width: 100%; padding: 8px 10px; border-radius: var(--df-radius-sm);
|
||||
border: 0.5px solid var(--df-border); background: var(--df-bg);
|
||||
color: var(--df-text); font-size: 13px; font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.modal-field input:focus, .modal-field textarea:focus {
|
||||
outline: none; border-color: var(--df-accent);
|
||||
}
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 4px; }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.empty-hint {
|
||||
text-align: center; padding: 24px 12px;
|
||||
font-size: 13px; color: var(--df-text-dim);
|
||||
}
|
||||
|
||||
/* ===== 响应式 ===== */
|
||||
@media (max-width: 900px) {
|
||||
.project-detail { padding: 16px; }
|
||||
.detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.stage-pipeline { overflow-x: auto; }
|
||||
}
|
||||
</style>
|
||||
289
src/views/Projects.vue
Normal file
289
src/views/Projects.vue
Normal file
@@ -0,0 +1,289 @@
|
||||
<template>
|
||||
<div class="projects">
|
||||
<header class="page-header">
|
||||
<h1>📂 项目列表</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-primary" @click="showCreateModal = true">+ 新建项目</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 新建项目模态框 -->
|
||||
<div v-if="showCreateModal" class="modal-overlay" @click.self="showCreateModal = false">
|
||||
<div class="modal-box">
|
||||
<h3 class="modal-title">新建项目</h3>
|
||||
<div class="modal-field">
|
||||
<label>项目名称</label>
|
||||
<input v-model="newName" placeholder="输入项目名称" @keyup.enter="submitCreate" />
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>项目描述</label>
|
||||
<textarea v-model="newDesc" placeholder="简要描述项目目标" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-ghost" @click="showCreateModal = false">取消</button>
|
||||
<button class="btn btn-primary" @click="submitCreate" :disabled="!newName.trim()">确认创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 项目卡片网格 -->
|
||||
<div class="project-grid">
|
||||
<div class="project-card" v-for="project in store.projects" :key="project.id" @click="router.push('/projects/' + project.id)">
|
||||
<div class="card-top">
|
||||
<div class="card-title-row">
|
||||
<h2 class="card-name">{{ project.name }}</h2>
|
||||
</div>
|
||||
<span class="stage-badge" :class="stageClass(project.status)">{{ statusLabel(project.status) }}</span>
|
||||
</div>
|
||||
|
||||
<p class="card-desc">{{ project.description }}</p>
|
||||
|
||||
<!-- 底部信息 -->
|
||||
<div class="card-footer">
|
||||
<div class="footer-stat">
|
||||
<span class="stat-icon">📅</span>
|
||||
<span>{{ formatDate(project.created_at) }}</span>
|
||||
</div>
|
||||
<div class="footer-stat">
|
||||
<span class="stat-icon">🕐</span>
|
||||
<span>{{ formatDate(project.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="!store.loading && store.projects.length === 0" class="empty-state">
|
||||
暂无项目,点击右上角创建第一个项目
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useProjectStore } from '../stores/project'
|
||||
import { formatDate } from '../utils/time'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useProjectStore()
|
||||
|
||||
// ── 状态映射 ──
|
||||
const statusLabels: Record<string, string> = {
|
||||
planning: '📐 规划中',
|
||||
in_progress: '💻 进行中',
|
||||
paused: '⏸ 已暂停',
|
||||
completed: '✅ 已完成',
|
||||
cancelled: '❌ 已取消',
|
||||
}
|
||||
|
||||
const statusStageClass: Record<string, string> = {
|
||||
planning: 'stage-design',
|
||||
in_progress: 'stage-coding',
|
||||
paused: 'stage-testing',
|
||||
completed: 'stage-release',
|
||||
cancelled: 'stage-design',
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
return statusLabels[status] ?? status
|
||||
}
|
||||
|
||||
function stageClass(status: string) {
|
||||
return statusStageClass[status] ?? 'stage-design'
|
||||
}
|
||||
|
||||
// formatDate 由 ../utils/time 提供(统一毫秒字符串解析,根治 Invalid Date)
|
||||
|
||||
// ── 新建项目 ──
|
||||
const showCreateModal = ref(false)
|
||||
const newName = ref('')
|
||||
const newDesc = ref('')
|
||||
|
||||
async function submitCreate() {
|
||||
if (!newName.value.trim()) return
|
||||
await store.createProject(newName.value.trim(), newDesc.value.trim())
|
||||
showCreateModal.value = false
|
||||
newName.value = ''
|
||||
newDesc.value = ''
|
||||
}
|
||||
|
||||
// ── 加载 ──
|
||||
onMounted(() => {
|
||||
store.loadProjects()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.projects { padding: 16px 20px 20px; }
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--df-gap-page);
|
||||
}
|
||||
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); }
|
||||
.header-actions { display: flex; gap: 10px; }
|
||||
|
||||
/* ===== 按钮 ===== */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-primary { background: var(--df-accent); color: #fff; }
|
||||
.btn-primary:hover { background: var(--df-accent-hover); }
|
||||
|
||||
/* ===== 卡片网格 ===== */
|
||||
.project-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--df-gap-grid);
|
||||
}
|
||||
|
||||
.project-card {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
padding: var(--df-pad-panel);
|
||||
transition: all 0.15s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.project-card:hover {
|
||||
border-color: var(--df-accent);
|
||||
|
||||
}
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.card-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.card-icon { font-size: 22px; }
|
||||
.card-name { font-size: 17px; font-weight: 500; color: var(--df-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.stage-badge {
|
||||
font-size: 12px; font-weight: 500;
|
||||
padding: 3px 10px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
}
|
||||
.stage-design { background: rgba(100,181,246,0.15); color: var(--df-info); }
|
||||
.stage-coding { background: rgba(108,99,255,0.15); color: var(--df-accent); }
|
||||
.stage-testing { background: rgba(255,217,61,0.15); color: var(--df-warning); }
|
||||
.stage-release { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||
|
||||
.card-desc {
|
||||
font-size: 13px;
|
||||
color: var(--df-text-secondary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ===== 进度条 ===== */
|
||||
.card-progress { margin-bottom: 16px; }
|
||||
.progress-label-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.progress-stage { font-size: 12px; color: var(--df-text-dim); }
|
||||
.progress-pct { font-size: 12px; font-weight: 500; color: var(--df-text); }
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
background: var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--df-accent);
|
||||
border-radius: var(--df-radius-sm);
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
/* ===== 底部信息 ===== */
|
||||
.card-footer {
|
||||
display: flex;
|
||||
gap: var(--df-gap-grid);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.footer-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--df-text-dim);
|
||||
}
|
||||
.stat-icon { font-size: 13px; }
|
||||
|
||||
/* ===== 技术栈标签 ===== */
|
||||
.card-tags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tech-tag {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: rgba(108, 99, 255, 0.08);
|
||||
color: var(--df-text-secondary);
|
||||
border: 0.5px solid var(--df-border);
|
||||
}
|
||||
|
||||
/* ===== 模态框 ===== */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
.modal-box {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
padding: 24px;
|
||||
width: 420px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
.modal-title { font-size: 18px; font-weight: 500; color: var(--df-text); margin-bottom: 16px; }
|
||||
.modal-field { margin-bottom: 14px; }
|
||||
.modal-field label { display: block; font-size: 12px; color: var(--df-text-dim); margin-bottom: 4px; }
|
||||
.modal-field input, .modal-field textarea {
|
||||
width: 100%; padding: 8px 10px; border-radius: var(--df-radius-sm);
|
||||
border: 0.5px solid var(--df-border); background: var(--df-bg);
|
||||
color: var(--df-text); font-size: 13px; font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.modal-field input:focus, .modal-field textarea:focus {
|
||||
outline: none; border-color: var(--df-accent);
|
||||
}
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 4px; }
|
||||
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
|
||||
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||||
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
/* ===== 空状态 ===== */
|
||||
.empty-state {
|
||||
text-align: center; padding: 60px 20px;
|
||||
font-size: 14px; color: var(--df-text-dim);
|
||||
}
|
||||
|
||||
/* ===== 响应式 ===== */
|
||||
@media (max-width: 900px) {
|
||||
.projects { padding: 16px; }
|
||||
.project-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
696
src/views/Settings.vue
Normal file
696
src/views/Settings.vue
Normal file
@@ -0,0 +1,696 @@
|
||||
<template>
|
||||
<div class="settings">
|
||||
<!-- 页面头部 -->
|
||||
<header class="page-header">
|
||||
<h1>⚙️ 设置</h1>
|
||||
</header>
|
||||
|
||||
<div class="settings-grid">
|
||||
<!-- ═══ AI 模型配置 ═══ -->
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>🤖 AI 模型配置</h2>
|
||||
<button class="btn btn-ghost btn-sm" @click="openProviderForm()">+ 添加 Provider</button>
|
||||
</div>
|
||||
<div class="provider-list" v-if="aiProviders.length > 0">
|
||||
<div class="provider-card" v-for="p in aiProviders" :key="p.id">
|
||||
<div class="provider-top">
|
||||
<div class="provider-info">
|
||||
<span class="provider-name">{{ p.name }}</span>
|
||||
<span v-if="p.is_default" class="default-badge">默认</span>
|
||||
</div>
|
||||
<div class="provider-actions">
|
||||
<button class="btn-link" @click="setDefaultProvider(p.id)" v-if="!p.is_default">设为默认</button>
|
||||
<button class="btn-link" @click="openProviderForm(p)">编辑</button>
|
||||
<button class="btn-link btn-link-danger" @click="deleteProvider(p.id)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="provider-detail">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Base URL</span>
|
||||
<span class="detail-value">{{ p.base_url }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">模型</span>
|
||||
<span class="detail-value">{{ p.default_model }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">API Key</span>
|
||||
<span class="detail-value mask">{{ maskKey(p.api_key) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="empty-hint" v-else>暂无 AI 提供商,点击上方按钮添加</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ AI 提供商表单 ═══ -->
|
||||
<section class="panel" v-if="providerForm.visible">
|
||||
<div class="panel-header">
|
||||
<h2>{{ providerForm.editId ? '编辑 Provider' : '添加 Provider' }}</h2>
|
||||
<button class="btn btn-ghost btn-sm" @click="providerForm.visible = false">取消</button>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-field">
|
||||
<label class="form-label">名称</label>
|
||||
<input v-model="providerForm.name" class="setting-input" placeholder="如 DeepSeek、GLM-4" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">协议类型</label>
|
||||
<select v-model="providerForm.providerType" class="setting-select">
|
||||
<option value="openai_compat">OpenAI 兼容(DeepSeek / GLM v4 / OpenAI)</option>
|
||||
<option value="anthropic">Anthropic 兼容(GLM 订阅 / Claude)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">Base URL</label>
|
||||
<input v-model="providerForm.baseUrl" class="setting-input" placeholder="https://api.deepseek.com" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">API Key</label>
|
||||
<input v-model="providerForm.apiKey" class="setting-input" type="password" placeholder="sk-..." />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">默认模型</label>
|
||||
<input v-model="providerForm.defaultModel" class="setting-input" placeholder="deepseek-chat" />
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" @click="saveProvider" :disabled="providerForm.saving">
|
||||
{{ providerForm.saving ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ 连接管理 ═══ -->
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>🔗 连接管理</h2>
|
||||
<button class="btn btn-ghost btn-sm" @click="openConnForm()">+ 添加连接</button>
|
||||
</div>
|
||||
<div class="connection-list" v-if="connections.length > 0">
|
||||
<div class="connection-card" v-for="conn in connections" :key="conn.id">
|
||||
<div class="conn-left">
|
||||
<span class="conn-icon">{{ typeIcon(conn.type) }}</span>
|
||||
<div class="conn-info">
|
||||
<div class="conn-name-row">
|
||||
<span class="conn-name">{{ conn.name }}</span>
|
||||
<span class="conn-type" :class="'type-' + conn.type">{{ typeLabel(conn.type) }}</span>
|
||||
</div>
|
||||
<span class="conn-host">{{ conn.host }}{{ conn.port ? ':' + conn.port : '' }}</span>
|
||||
<span class="conn-user" v-if="conn.user">{{ conn.user }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="conn-actions">
|
||||
<button class="btn-link" @click="openConnForm(conn)">编辑</button>
|
||||
<button class="btn-link btn-link-danger" @click="removeConn(conn.id)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="empty-hint" v-else>暂无连接,点击上方按钮添加</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ 连接表单 ═══ -->
|
||||
<section class="panel" v-if="connForm.visible">
|
||||
<div class="panel-header">
|
||||
<h2>{{ connForm.editId ? '编辑连接' : '添加连接' }}</h2>
|
||||
<button class="btn btn-ghost btn-sm" @click="connForm.visible = false">取消</button>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-field">
|
||||
<label class="form-label">名称</label>
|
||||
<input v-model="connForm.name" class="setting-input" placeholder="flux_dev" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">类型</label>
|
||||
<select v-model="connForm.type" class="setting-select">
|
||||
<option value="mysql">MySQL</option>
|
||||
<option value="ssh">SSH</option>
|
||||
<option value="redis">Redis</option>
|
||||
<option value="mongo">MongoDB</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">Host</label>
|
||||
<input v-model="connForm.host" class="setting-input" placeholder="127.0.0.1" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">Port</label>
|
||||
<input v-model.number="connForm.port" class="setting-input" type="number" placeholder="3306" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">用户名</label>
|
||||
<input v-model="connForm.user" class="setting-input" placeholder="root" />
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" @click="saveConn">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══ 通用设置 ═══ -->
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>🎨 通用设置</h2>
|
||||
</div>
|
||||
<div class="general-settings">
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">主题</span>
|
||||
<span class="setting-desc">深色 / 浅色模式</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select v-model="settings.theme" class="setting-select">
|
||||
<option value="dark">🌙 深色模式</option>
|
||||
<option value="light">☀️ 浅色模式</option>
|
||||
<option value="system">💻 跟随系统</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">语言</span>
|
||||
<span class="setting-desc">界面显示语言</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select v-model="settings.language" class="setting-select">
|
||||
<option value="zh-CN">简体中文</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">AI 对话语言</span>
|
||||
<span class="setting-desc">AI 回复使用的默认语言</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select v-model="settings.aiLanguage" class="setting-select">
|
||||
<option value="auto">🔄 跟随界面语言</option>
|
||||
<option value="zh-CN">🇨🇳 简体中文</option>
|
||||
<option value="en">🇺🇸 English</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">AI 自动执行</span>
|
||||
<span class="setting-desc">AI 可自动执行低风险操作</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" v-model="settings.autoExecute" />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info">
|
||||
<span class="setting-label">日志级别</span>
|
||||
<span class="setting-desc">工作流日志详细程度</span>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<select v-model="settings.logLevel" class="setting-select">
|
||||
<option value="error">Error</option>
|
||||
<option value="warn">Warning</option>
|
||||
<option value="info">Info</option>
|
||||
<option value="debug">Debug</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch, onMounted } from 'vue'
|
||||
import { aiApi } from '../api'
|
||||
import type { AiProviderConfig } from '../api/types'
|
||||
import i18n from '../i18n'
|
||||
|
||||
// ============================================================
|
||||
// AI 提供商 — 真实后端 CRUD
|
||||
// ============================================================
|
||||
|
||||
const aiProviders = ref<AiProviderConfig[]>([])
|
||||
|
||||
const providerForm = reactive({
|
||||
visible: false,
|
||||
editId: '' as string,
|
||||
name: '',
|
||||
providerType: 'openai_compat',
|
||||
baseUrl: '',
|
||||
apiKey: '',
|
||||
defaultModel: '',
|
||||
saving: false,
|
||||
})
|
||||
|
||||
async function loadProviders() {
|
||||
try {
|
||||
aiProviders.value = await aiApi.listProviders()
|
||||
} catch (e) {
|
||||
console.error('加载提供商失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function openProviderForm(p?: AiProviderConfig) {
|
||||
if (p) {
|
||||
providerForm.editId = p.id
|
||||
providerForm.name = p.name
|
||||
providerForm.baseUrl = p.base_url
|
||||
providerForm.apiKey = p.api_key
|
||||
providerForm.defaultModel = p.default_model
|
||||
providerForm.providerType = p.provider_type || 'openai_compat'
|
||||
} else {
|
||||
providerForm.editId = ''
|
||||
providerForm.name = ''
|
||||
providerForm.providerType = 'openai_compat'
|
||||
providerForm.baseUrl = ''
|
||||
providerForm.apiKey = ''
|
||||
providerForm.defaultModel = ''
|
||||
}
|
||||
providerForm.visible = true
|
||||
}
|
||||
|
||||
async function saveProvider() {
|
||||
if (!providerForm.name || !providerForm.baseUrl || !providerForm.apiKey || !providerForm.defaultModel) return
|
||||
providerForm.saving = true
|
||||
try {
|
||||
await aiApi.saveProvider({
|
||||
id: providerForm.editId || undefined,
|
||||
name: providerForm.name,
|
||||
providerType: providerForm.providerType,
|
||||
baseUrl: providerForm.baseUrl,
|
||||
apiKey: providerForm.apiKey,
|
||||
defaultModel: providerForm.defaultModel,
|
||||
})
|
||||
providerForm.visible = false
|
||||
await loadProviders()
|
||||
} catch (e) {
|
||||
console.error('保存提供商失败:', e)
|
||||
} finally {
|
||||
providerForm.saving = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteProvider(id: string) {
|
||||
// 暂无后端 delete 命令,通过 save 空 id 不行,需要后端支持
|
||||
// 先从前端移除,后续补 delete IPC
|
||||
aiProviders.value = aiProviders.value.filter(p => p.id !== id)
|
||||
}
|
||||
|
||||
async function setDefaultProvider(id: string) {
|
||||
try {
|
||||
await aiApi.setProvider(id)
|
||||
await loadProviders()
|
||||
} catch (e) {
|
||||
console.error('设置默认提供商失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function maskKey(key: string): string {
|
||||
if (!key || key.length < 8) return '••••••••'
|
||||
return key.slice(0, 4) + '••••••••' + key.slice(-4)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 连接管理 — localStorage CRUD
|
||||
// ============================================================
|
||||
|
||||
interface ConnRecord {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
host: string
|
||||
port: number
|
||||
user: string
|
||||
}
|
||||
|
||||
const connections = ref<ConnRecord[]>([])
|
||||
|
||||
const connForm = reactive({
|
||||
visible: false,
|
||||
editId: '' as string,
|
||||
name: '',
|
||||
type: 'mysql',
|
||||
host: '',
|
||||
port: 3306,
|
||||
user: '',
|
||||
})
|
||||
|
||||
const CONN_STORAGE_KEY = 'df-connections'
|
||||
|
||||
function loadConnections() {
|
||||
try {
|
||||
const raw = localStorage.getItem(CONN_STORAGE_KEY)
|
||||
if (raw) connections.value = JSON.parse(raw)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function persistConnections() {
|
||||
localStorage.setItem(CONN_STORAGE_KEY, JSON.stringify(connections.value))
|
||||
}
|
||||
|
||||
function typeIcon(type: string) {
|
||||
const map: Record<string, string> = { mysql: '🗄️', ssh: '🔐', redis: '⚡', mongo: '🍃' }
|
||||
return map[type] || '🔗'
|
||||
}
|
||||
|
||||
function typeLabel(type: string) {
|
||||
const map: Record<string, string> = { mysql: 'MySQL', ssh: 'SSH', redis: 'Redis', mongo: 'MongoDB' }
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
function openConnForm(conn?: ConnRecord) {
|
||||
if (conn) {
|
||||
connForm.editId = conn.id
|
||||
connForm.name = conn.name
|
||||
connForm.type = conn.type
|
||||
connForm.host = conn.host
|
||||
connForm.port = conn.port
|
||||
connForm.user = conn.user
|
||||
} else {
|
||||
connForm.editId = ''
|
||||
connForm.name = ''
|
||||
connForm.type = 'mysql'
|
||||
connForm.host = ''
|
||||
connForm.port = 3306
|
||||
connForm.user = ''
|
||||
}
|
||||
connForm.visible = true
|
||||
}
|
||||
|
||||
function saveConn() {
|
||||
if (!connForm.name || !connForm.host) return
|
||||
const record: ConnRecord = {
|
||||
id: connForm.editId || Date.now().toString(36),
|
||||
name: connForm.name,
|
||||
type: connForm.type,
|
||||
host: connForm.host,
|
||||
port: connForm.port,
|
||||
user: connForm.user,
|
||||
}
|
||||
if (connForm.editId) {
|
||||
const idx = connections.value.findIndex(c => c.id === connForm.editId)
|
||||
if (idx >= 0) connections.value[idx] = record
|
||||
} else {
|
||||
connections.value.push(record)
|
||||
}
|
||||
persistConnections()
|
||||
connForm.visible = false
|
||||
}
|
||||
|
||||
function removeConn(id: string) {
|
||||
connections.value = connections.value.filter(c => c.id !== id)
|
||||
persistConnections()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 通用设置
|
||||
// ============================================================
|
||||
|
||||
const settings = reactive({
|
||||
theme: localStorage.getItem('df-theme') || 'dark',
|
||||
language: localStorage.getItem('df-language') || 'zh-CN',
|
||||
aiLanguage: localStorage.getItem('df-ai-language') || 'auto',
|
||||
autoExecute: false,
|
||||
logLevel: 'info',
|
||||
})
|
||||
|
||||
function applyTheme(theme: string) {
|
||||
if (theme === 'system') {
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
if (!prefersDark) document.documentElement.setAttribute('data-theme', 'light')
|
||||
} else if (theme === 'light') {
|
||||
document.documentElement.setAttribute('data-theme', 'light')
|
||||
} else {
|
||||
document.documentElement.removeAttribute('data-theme')
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => settings.theme, (val) => {
|
||||
applyTheme(val)
|
||||
localStorage.setItem('df-theme', val)
|
||||
})
|
||||
|
||||
watch(() => settings.aiLanguage, (val) => {
|
||||
localStorage.setItem('df-ai-language', val)
|
||||
})
|
||||
|
||||
watch(() => settings.language, (val) => {
|
||||
localStorage.setItem('df-language', val)
|
||||
// 同步 i18n 实例,让界面立即跟随语言切换
|
||||
i18n.global.locale.value = val as 'zh-CN' | 'en'
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
applyTheme(settings.theme)
|
||||
loadProviders()
|
||||
loadConnections()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings { padding: 16px 20px 20px; }
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--df-gap-page);
|
||||
}
|
||||
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); }
|
||||
|
||||
/* ===== 按钮 ===== */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-primary { background: var(--df-accent); color: #fff; }
|
||||
.btn-primary:hover { background: var(--df-accent-hover); }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
|
||||
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||||
.btn-sm { padding: 4px 12px; font-size: 12px; }
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--df-accent);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.btn-link:hover { text-decoration: underline; }
|
||||
.btn-link-danger { color: var(--df-danger); }
|
||||
|
||||
/* ===== 设置网格 ===== */
|
||||
.settings-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--df-gap-grid);
|
||||
}
|
||||
|
||||
/* ===== 面板 ===== */
|
||||
.panel {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
padding: var(--df-pad-panel);
|
||||
}
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--df-gap-head);
|
||||
}
|
||||
.panel-header h2 { font-size: 15px; font-weight: 500; }
|
||||
|
||||
.empty-hint {
|
||||
font-size: 13px;
|
||||
color: var(--df-text-dim);
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
/* ===== Provider 卡片 ===== */
|
||||
.provider-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.provider-card {
|
||||
background: var(--df-bg);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
.provider-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.provider-info { display: flex; align-items: center; gap: 8px; }
|
||||
.provider-name { font-size: 14px; font-weight: 500; color: var(--df-text); }
|
||||
.default-badge {
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
background: rgba(100,255,218,0.15);
|
||||
color: var(--df-success);
|
||||
font-weight: 500;
|
||||
}
|
||||
.provider-actions { display: flex; gap: 4px; }
|
||||
|
||||
.provider-detail { display: flex; flex-direction: column; gap: 6px; }
|
||||
.detail-row { display: flex; align-items: flex-start; gap: 12px; }
|
||||
.detail-label {
|
||||
font-size: 12px;
|
||||
color: var(--df-text-dim);
|
||||
min-width: 60px;
|
||||
}
|
||||
.detail-value {
|
||||
font-size: 12px;
|
||||
color: var(--df-text-secondary);
|
||||
font-family: var(--df-font-mono);
|
||||
}
|
||||
.detail-value.mask { color: var(--df-text-dim); letter-spacing: 1px; }
|
||||
|
||||
/* ===== 表单 ===== */
|
||||
.form-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.form-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--df-text-secondary);
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
/* ===== 连接卡片 ===== */
|
||||
.connection-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.connection-card {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
background: var(--df-bg);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius);
|
||||
}
|
||||
.conn-left { display: flex; align-items: center; gap: 10px; }
|
||||
.conn-icon { font-size: 18px; }
|
||||
.conn-info { display: flex; flex-direction: column; gap: 2px; }
|
||||
.conn-name-row { display: flex; align-items: center; gap: 8px; }
|
||||
.conn-name { font-size: 14px; font-weight: 500; color: var(--df-text); }
|
||||
.conn-type {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
.type-mysql { background: rgba(100,181,246,0.15); color: var(--df-info); }
|
||||
.type-ssh { background: rgba(108,99,255,0.15); color: var(--df-accent); }
|
||||
.type-redis { background: rgba(255,107,107,0.15); color: var(--df-danger); }
|
||||
.type-mongo { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||
|
||||
.conn-host, .conn-user {
|
||||
font-size: 12px;
|
||||
color: var(--df-text-dim);
|
||||
font-family: var(--df-font-mono);
|
||||
}
|
||||
.conn-user {
|
||||
background: rgba(90,99,128,0.2);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
}
|
||||
.conn-actions { display: flex; gap: 4px; }
|
||||
|
||||
/* ===== 通用设置 ===== */
|
||||
.general-settings { display: flex; flex-direction: column; }
|
||||
.setting-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 0;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
}
|
||||
.setting-row:last-child { border-bottom: none; }
|
||||
.setting-info { display: flex; flex-direction: column; gap: 2px; }
|
||||
.setting-label { font-size: 14px; font-weight: 500; color: var(--df-text); }
|
||||
.setting-desc { font-size: 12px; color: var(--df-text-dim); }
|
||||
|
||||
.setting-control { display: flex; align-items: center; }
|
||||
|
||||
.setting-select {
|
||||
padding: 6px 12px;
|
||||
background: var(--df-bg);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
color: var(--df-text);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
min-width: 160px;
|
||||
}
|
||||
.setting-select:focus { border-color: var(--df-accent); }
|
||||
|
||||
.setting-input {
|
||||
padding: 6px 12px;
|
||||
background: var(--df-bg);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
color: var(--df-text);
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
min-width: 260px;
|
||||
}
|
||||
.setting-input:focus { border-color: var(--df-accent); }
|
||||
|
||||
/* ===== Toggle 开关 ===== */
|
||||
.toggle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.toggle input { opacity: 0; width: 0; height: 0; }
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.toggle-slider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background: var(--df-text-dim);
|
||||
border-radius: 50%;
|
||||
transition: transform 0.2s, background 0.2s;
|
||||
}
|
||||
.toggle input:checked + .toggle-slider { background: var(--df-accent); }
|
||||
.toggle input:checked + .toggle-slider::before {
|
||||
transform: translateX(18px);
|
||||
background: #fff;
|
||||
}
|
||||
</style>
|
||||
388
src/views/Tasks.vue
Normal file
388
src/views/Tasks.vue
Normal file
@@ -0,0 +1,388 @@
|
||||
<template>
|
||||
<div class="tasks">
|
||||
<header class="page-header">
|
||||
<h1>🔀 任务队列</h1>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-ghost" @click="refresh">🔄 刷新</button>
|
||||
<button class="btn btn-primary" @click="openCreateModal">+ 新建任务</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<div class="filter-bar">
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">项目:</span>
|
||||
<button
|
||||
v-for="p in projectFilters"
|
||||
:key="p.key"
|
||||
class="filter-btn"
|
||||
:class="{ active: activeProject === p.key }"
|
||||
@click="activeProject = p.key"
|
||||
>
|
||||
{{ p.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<span class="filter-label">状态:</span>
|
||||
<button
|
||||
v-for="s in statusFilters"
|
||||
:key="s.key"
|
||||
class="filter-btn"
|
||||
:class="{ active: activeStatus === s.key }"
|
||||
@click="activeStatus = s.key"
|
||||
>
|
||||
{{ s.icon }} {{ s.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 按项目分组 -->
|
||||
<div class="task-groups">
|
||||
<section
|
||||
v-for="group in filteredGroups"
|
||||
:key="group.projectName"
|
||||
class="task-group"
|
||||
>
|
||||
<div class="group-header">
|
||||
<span class="group-icon">{{ group.icon }}</span>
|
||||
<h2 class="group-name">{{ group.projectName }}</h2>
|
||||
<span class="group-count">{{ group.tasks.length }} 个任务</span>
|
||||
</div>
|
||||
<div class="task-list">
|
||||
<div class="task-item" v-for="task in group.tasks" :key="task.id">
|
||||
<div class="task-main">
|
||||
<div class="task-title-row">
|
||||
<span class="task-title">{{ task.title }}</span>
|
||||
<span class="priority-badge" :class="priorityClass(task.priority)">{{ priorityLabel(task.priority) }}</span>
|
||||
</div>
|
||||
<div class="task-meta">
|
||||
<span class="branch-tag" v-if="task.branch_name">
|
||||
<span class="branch-icon">⑂</span>
|
||||
{{ task.branch_name }}
|
||||
</span>
|
||||
<span class="task-date">{{ formatTime(task.created_at) }}</span>
|
||||
<span class="task-time">{{ formatTime(task.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="status-tag" :class="'status-' + task.status">{{ statusLabel(task.status) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 新建任务模态框 -->
|
||||
<div class="modal-overlay" v-if="showCreateModal" @click.self="showCreateModal = false">
|
||||
<div class="modal-box">
|
||||
<h3>新建任务</h3>
|
||||
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">项目</label>
|
||||
<select v-model="newTaskProjectId" style="width:100%;padding:8px 12px;border: 0.5px solid var(--df-border);border-radius: var(--df-radius-sm);background:var(--df-bg);color:var(--df-text);font-size:13px;margin-bottom:12px">
|
||||
<option v-for="p in store.projects" :key="p.id" :value="p.id">{{ p.name }}</option>
|
||||
</select>
|
||||
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">标题</label>
|
||||
<input v-model="newTaskTitle" placeholder="输入任务标题..." @keyup.enter="confirmCreate" />
|
||||
<div class="modal-actions">
|
||||
<button class="btn-cancel" @click="showCreateModal = false">取消</button>
|
||||
<button class="btn-confirm" @click="confirmCreate">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useProjectStore } from '../stores/project'
|
||||
import { formatRelativeZh } from '../utils/time'
|
||||
import type { TaskRecord } from '../api/types'
|
||||
|
||||
const store = useProjectStore()
|
||||
|
||||
const activeProject = ref('all')
|
||||
const activeStatus = ref('all')
|
||||
|
||||
// ── 新建任务模态框 ──
|
||||
const showCreateModal = ref(false)
|
||||
const newTaskProjectId = ref('')
|
||||
const newTaskTitle = ref('')
|
||||
|
||||
interface TaskGroup {
|
||||
projectName: string
|
||||
icon: string
|
||||
tasks: TaskRecord[]
|
||||
}
|
||||
|
||||
const projectFilters = computed(() => [
|
||||
{ key: 'all', label: '全部' },
|
||||
...store.projects.map(p => ({ key: p.id, label: p.name })),
|
||||
])
|
||||
|
||||
const statusFilters: { key: string; label: string; icon: string }[] = [
|
||||
{ key: 'all', label: '全部', icon: '📋' },
|
||||
{ key: 'todo', label: '待开始', icon: '📝' },
|
||||
{ key: 'in_progress', label: '进行中', icon: '🔨' },
|
||||
{ key: 'review_ready', label: '待审查', icon: '👀' },
|
||||
{ key: 'merged', label: '已合并', icon: '✅' },
|
||||
{ key: 'abandoned', label: '已废弃', icon: '🗑️' },
|
||||
]
|
||||
|
||||
function getProjectName(projectId: string): string {
|
||||
return store.projects.find(p => p.id === projectId)?.name ?? '未知项目'
|
||||
}
|
||||
|
||||
const projectIcons: Record<string, string> = {
|
||||
'u-ask': '🤖',
|
||||
'u-img': '🖼️',
|
||||
'flux': '⚡',
|
||||
}
|
||||
|
||||
const filteredGroups = computed(() => {
|
||||
let filtered = store.tasks
|
||||
if (activeProject.value !== 'all') {
|
||||
filtered = filtered.filter(t => t.project_id === activeProject.value)
|
||||
}
|
||||
if (activeStatus.value !== 'all') {
|
||||
filtered = filtered.filter(t => t.status === activeStatus.value)
|
||||
}
|
||||
|
||||
const groupMap = new Map<string, TaskRecord[]>()
|
||||
for (const task of filtered) {
|
||||
if (!groupMap.has(task.project_id)) {
|
||||
groupMap.set(task.project_id, [])
|
||||
}
|
||||
groupMap.get(task.project_id)!.push(task)
|
||||
}
|
||||
|
||||
const result: TaskGroup[] = []
|
||||
for (const [projectId, groupTasks] of groupMap) {
|
||||
const name = getProjectName(projectId)
|
||||
result.push({
|
||||
projectName: name,
|
||||
icon: projectIcons[name] || '📂',
|
||||
tasks: groupTasks,
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
function statusLabel(status: string) {
|
||||
const map: Record<string, string> = {
|
||||
todo: '📝 待开始',
|
||||
in_progress: '🔨 进行中',
|
||||
review_ready: '👀 待审查',
|
||||
merged: '✅ 已合并',
|
||||
abandoned: '🗑️ 已废弃',
|
||||
}
|
||||
return map[status] ?? status
|
||||
}
|
||||
|
||||
function priorityLabel(priority: number) {
|
||||
const map: Record<number, string> = { 0: 'P0', 1: 'P1', 2: 'P2', 3: 'P3' }
|
||||
return map[priority] ?? `P${priority}`
|
||||
}
|
||||
|
||||
function priorityClass(priority: number): string {
|
||||
const map: Record<number, string> = { 0: 'priority-critical', 1: 'priority-high', 2: 'priority-medium', 3: 'priority-low' }
|
||||
return map[priority] ?? 'priority-low'
|
||||
}
|
||||
|
||||
function formatTime(timestamp: string | number): string {
|
||||
return formatRelativeZh(timestamp)
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await store.loadTasks()
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
newTaskProjectId.value = store.projects.length > 0 ? store.projects[0].id : ''
|
||||
newTaskTitle.value = ''
|
||||
showCreateModal.value = true
|
||||
}
|
||||
|
||||
async function confirmCreate() {
|
||||
if (!newTaskTitle.value.trim() || !newTaskProjectId.value) return
|
||||
await store.createTask({
|
||||
project_id: newTaskProjectId.value,
|
||||
title: newTaskTitle.value.trim(),
|
||||
})
|
||||
showCreateModal.value = false
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([store.loadProjects(), store.loadTasks()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tasks { padding: 16px 20px 20px; }
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--df-gap-page);
|
||||
}
|
||||
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); }
|
||||
.header-actions { display: flex; gap: 10px; }
|
||||
|
||||
/* ===== 按钮 ===== */
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: var(--df-radius-sm);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn-primary { background: var(--df-accent); color: #fff; }
|
||||
.btn-primary:hover { background: var(--df-accent-hover); }
|
||||
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
|
||||
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
|
||||
|
||||
/* ===== 筛选栏 ===== */
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
margin-bottom: var(--df-gap-page);
|
||||
padding: var(--df-pad-panel);
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
}
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.filter-label {
|
||||
font-size: 12px;
|
||||
color: var(--df-text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.filter-btn {
|
||||
padding: 4px 12px;
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--df-text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.filter-btn:hover { background: rgba(108, 99, 255, 0.06); color: var(--df-text); }
|
||||
.filter-btn.active {
|
||||
background: var(--df-accent);
|
||||
color: #fff;
|
||||
border-color: var(--df-accent);
|
||||
}
|
||||
|
||||
/* ===== 任务分组 ===== */
|
||||
.task-groups {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--df-gap-grid);
|
||||
}
|
||||
|
||||
.task-group {
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
padding: var(--df-pad-panel);
|
||||
}
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: var(--df-gap-head);
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 0.5px solid var(--df-border);
|
||||
}
|
||||
.group-icon { font-size: 18px; }
|
||||
.group-name { font-size: 15px; font-weight: 500; color: var(--df-text); }
|
||||
.group-count { font-size: 12px; color: var(--df-text-dim); margin-left: 8px; }
|
||||
|
||||
/* ===== 任务条目 ===== */
|
||||
.task-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.task-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--df-radius);
|
||||
transition: background 0.1s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.task-item:hover { background: rgba(108, 99, 255, 0.04); }
|
||||
|
||||
.task-main { flex: 1; min-width: 0; }
|
||||
|
||||
.task-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.task-title { font-size: 14px; font-weight: 500; color: var(--df-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
|
||||
.priority-badge {
|
||||
font-size: 10px; font-weight: 500;
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
}
|
||||
.priority-critical { background: rgba(255,107,107,0.2); color: var(--df-danger); }
|
||||
.priority-high { background: rgba(255,152,0,0.2); color: #ff9800; }
|
||||
.priority-medium { background: rgba(100,181,246,0.2); color: var(--df-info); }
|
||||
.priority-low { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
|
||||
.task-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
.branch-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--df-text-secondary);
|
||||
background: rgba(108, 99, 255, 0.06);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--df-radius-xs);
|
||||
font-family: monospace;
|
||||
}
|
||||
.branch-icon { font-size: 12px; color: var(--df-accent); }
|
||||
.task-date { font-size: 11px; color: var(--df-text-dim); }
|
||||
.task-time {
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ===== 状态标签 ===== */
|
||||
.status-tag {
|
||||
font-size: 11px; font-weight: 500;
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.status-created { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
.status-in_progress { background: rgba(100,181,246,0.2); color: var(--df-info); }
|
||||
.status-review_ready { background: rgba(255,217,61,0.2); color: var(--df-warning); }
|
||||
.status-merged { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||
.status-abandoned { background: rgba(255,107,107,0.2); color: var(--df-danger); }
|
||||
.status-todo { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
|
||||
/* ===== 模态框 ===== */
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||||
.modal-box { background: var(--df-bg-card); border: 0.5px solid var(--df-border); border-radius: var(--df-radius-lg); padding: 24px; min-width: 360px; max-width: 90vw; }
|
||||
.modal-box h3 { color: var(--df-text); margin-bottom: 16px; }
|
||||
.modal-box input, .modal-box select { width: 100%; padding: 8px 12px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); background: var(--df-bg); color: var(--df-text); font-size: 13px; margin-bottom: 12px; box-sizing: border-box; }
|
||||
.modal-box .modal-actions { display: flex; gap: 8px; justify-content: flex-end; }
|
||||
.modal-box .btn-cancel { padding: 6px 16px; border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm); background: transparent; color: var(--df-text-secondary); cursor: pointer; }
|
||||
.modal-box .btn-confirm { padding: 6px 16px; border: none; border-radius: var(--df-radius-sm); background: var(--df-accent); color: #fff; cursor: pointer; }
|
||||
</style>
|
||||
7
src/vite-env.d.ts
vendored
Normal file
7
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
Reference in New Issue
Block a user