refactor(frontend): move views into app and pages structure

Reorganize the frontend around app-level routing and page modules so the runtime and feature screens share a clearer navigation and composition layout for future work.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-21 22:13:12 +08:00
parent a27736a832
commit b024a2bcb5
25 changed files with 2628 additions and 1656 deletions

View File

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

View File

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

View File

@@ -0,0 +1,210 @@
import { nextTick, onMounted, ref } from 'vue'
import { useConversationStore } from '@/stores/conversation'
import { conversationApi, type Message } from '@/api/conversation'
import { documentApi } from '@/api/document'
export interface SelectedFile {
id: string
name: string
type: string
size: number
}
interface MessageWithAttachments extends Message {
attachments?: SelectedFile[]
}
export function useChatView() {
const store = useConversationStore()
const inputMessage = ref('')
const isSending = ref(false)
const chatContainer = ref<HTMLElement>()
const inputRef = ref<HTMLTextAreaElement>()
const isTyping = ref(false)
const fileInputRef = ref<HTMLInputElement>()
const showEmojiPicker = ref(false)
const selectedFiles = ref<SelectedFile[]>([])
async function sendMessage() {
if (!inputMessage.value.trim() || isSending.value) return
isSending.value = true
isTyping.value = true
const text = inputMessage.value.trim()
const attachments = [...selectedFiles.value]
inputMessage.value = ''
store.addMessage({
id: `temp-${Date.now()}`,
role: 'user',
content: text,
created_at: new Date().toISOString(),
attachments,
} as MessageWithAttachments)
await nextTick()
scrollToBottom()
try {
const response = await conversationApi.chat(text, store.currentConversationId || undefined, attachments.map((file) => file.id))
selectedFiles.value = []
isTyping.value = false
store.addMessage({
id: response.data.message_id,
role: 'assistant',
content: response.data.content,
model: response.data.agent_name,
created_at: new Date().toISOString(),
})
if (!store.currentConversationId) {
store.setCurrentConversation(response.data.conversation_id)
await loadConversations()
}
} catch (error) {
isTyping.value = false
console.error('发送失败:', error)
store.addMessage({
id: `err-${Date.now()}`,
role: 'assistant',
content: '抱歉,连接失败。请检查服务状态。',
created_at: new Date().toISOString(),
})
}
isSending.value = false
await nextTick()
scrollToBottom()
}
async function loadConversations() {
try {
const response = await conversationApi.list()
store.setConversations(response.data)
} catch (error) {
console.error('加载对话列表失败:', error)
}
}
async function selectConversation(id: string) {
store.setCurrentConversation(id)
store.setMessages([])
try {
const response = await conversationApi.getMessages(id)
store.setMessages(response.data)
await nextTick()
scrollToBottom()
} catch (error) {
console.error('加载消息失败:', error)
}
}
function newConversation() {
store.setCurrentConversation('')
store.setMessages([])
inputRef.value?.focus()
}
async function deleteConversation(id: string, event: Event) {
event.stopPropagation()
try {
await conversationApi.delete(id)
store.removeConversation(id)
if (store.currentConversationId === id) {
store.setCurrentConversation('')
store.setMessages([])
}
} catch (error) {
console.error('删除失败:', error)
}
}
function scrollToBottom() {
if (chatContainer.value) {
chatContainer.value.scrollTop = chatContainer.value.scrollHeight
}
}
function formatTime(dateStr: string) {
const date = new Date(dateStr)
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
function formatConvDate(dateStr: string) {
const date = new Date(dateStr)
const now = new Date()
const diff = now.getTime() - date.getTime()
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
if (diff < 86400000) return '今天'
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
}
function autoResize(event: Event) {
const element = event.target as HTMLTextAreaElement
element.style.height = 'auto'
element.style.height = `${Math.min(element.scrollHeight, 120)}px`
}
async function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement
if (!input.files?.length) return
for (const file of input.files) {
if (file.size > 10 * 1024 * 1024) {
alert(`文件 ${file.name} 超过10MB限制`)
continue
}
try {
const response = await documentApi.upload(file)
selectedFiles.value.push({
id: response.data.id,
name: file.name,
type: file.type,
size: file.size,
})
} catch (error) {
console.error('上传失败:', error)
alert(`文件 ${file.name} 上传失败`)
}
}
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
function insertEmoji(emoji: string) {
inputMessage.value += emoji
showEmojiPicker.value = false
}
function openFilePicker() {
fileInputRef.value?.click()
}
onMounted(() => {
loadConversations()
inputRef.value?.focus()
})
return {
store,
inputMessage,
isSending,
chatContainer,
inputRef,
isTyping,
fileInputRef,
showEmojiPicker,
selectedFiles,
sendMessage,
selectConversation,
newConversation,
deleteConversation,
formatTime,
formatConvDate,
autoResize,
handleFileSelect,
insertEmoji,
openFilePicker,
}
}

View File

@@ -0,0 +1,777 @@
<script setup lang="ts">
import { MessageCircle, Trash2, Send, Sparkles, CornerDownLeft, Paperclip, Smile } from 'lucide-vue-next'
import EmojiPicker from '@/components/chat/EmojiPicker.vue'
import FileMessage from '@/components/chat/FileMessage.vue'
import { useChatView } from '@/pages/chat/composables/useChatView'
const {
store,
inputMessage,
isSending,
chatContainer,
inputRef,
isTyping,
fileInputRef,
showEmojiPicker,
sendMessage,
selectConversation,
newConversation,
deleteConversation,
formatTime,
formatConvDate,
autoResize,
handleFileSelect,
insertEmoji,
openFilePicker,
} = useChatView()
</script>
<template>
<div class="chat-view">
<!-- Conversation list sidebar -->
<aside class="conv-sidebar">
<div class="conv-sidebar-header">
<div class="section-label">// SESSIONS</div>
<button class="new-chat-btn" @click="newConversation">
<span class="btn-line"></span>
<MessageCircle :size="14" />
<span>NEW SESSION</span>
</button>
</div>
<div class="conv-list">
<div
v-for="conv in store.conversations"
:key="conv.id"
class="conv-item"
:class="{ active: conv.id === store.currentConversationId }"
@click="selectConversation(conv.id)"
>
<div class="conv-item-icon">
<MessageCircle :size="12" />
</div>
<div class="conv-item-body">
<div class="conv-title">{{ conv.title || 'New Conversation' }}</div>
<div class="conv-date">{{ formatConvDate(conv.created_at) }}</div>
</div>
<button class="conv-delete" @click="deleteConversation(conv.id, $event)">
<Trash2 :size="12" />
</button>
<div class="conv-active-line"></div>
</div>
<div v-if="store.conversations.length === 0" class="conv-empty">
<div class="empty-icon"><MessageCircle :size="24" /></div>
<div class="empty-text">No sessions yet</div>
<div class="empty-hint">Start a new conversation</div>
</div>
</div>
</aside>
<!-- Chat area -->
<section class="chat-area">
<!-- Top bar -->
<div class="chat-topbar">
<div class="chat-status">
<div class="status-indicator" :class="{ active: !isSending }"></div>
<span class="status-text">{{ isTyping ? 'PROCESSING...' : 'READY' }}</span>
</div>
<div class="chat-model" v-if="store.messages.length > 0">
<Sparkles :size="12" />
<span>JARVIS v2.0</span>
</div>
</div>
<!-- Messages -->
<div ref="chatContainer" class="messages-area">
<!-- Welcome screen -->
<div v-if="store.messages.length === 0" class="welcome-screen">
<div class="welcome-icon">
<div class="welcome-ring r1"></div>
<div class="welcome-ring r2"></div>
<div class="welcome-ring r3"></div>
<div class="welcome-core">
<Sparkles :size="28" />
</div>
</div>
<div class="welcome-title">JARVIS</div>
<div class="welcome-sub">Personal AI Assistant</div>
<div class="welcome-hint">有什么我可以帮你的</div>
</div>
<!-- Message bubbles -->
<div
v-for="(msg, i) in store.messages"
:key="msg.id"
class="message-row"
:class="msg.role"
:style="{ animationDelay: `${i * 30}ms` }"
>
<div class="msg-avatar">
<span v-if="msg.role === 'user'">{{ '>' }}</span>
<span v-else class="ai-icon">J</span>
</div>
<div class="msg-content">
<div class="msg-meta">
<span class="msg-role">{{ msg.role === 'user' ? 'YOU' : 'JARVIS' }}</span>
<span class="msg-time">{{ formatTime(msg.created_at) }}</span>
</div>
<div class="msg-bubble">{{ msg.content }}</div>
<div v-if="msg.role === 'user' && msg.attachments?.length" class="msg-attachments">
<FileMessage
v-for="att in msg.attachments"
:key="att.id"
:filename="att.name"
:file-type="att.type"
:file-size="att.size"
/>
</div>
</div>
</div>
<!-- Typing indicator -->
<div v-if="isTyping" class="message-row assistant typing-row">
<div class="msg-avatar"><span class="ai-icon">J</span></div>
<div class="msg-content">
<div class="msg-meta">
<span class="msg-role">JARVIS</span>
</div>
<div class="msg-bubble typing">
<span class="dot"></span>
<span class="dot"></span>
<span class="dot"></span>
</div>
</div>
</div>
</div>
<!-- Input area -->
<div class="input-area">
<div class="input-frame">
<div class="input-corners tl"></div>
<div class="input-corners tr"></div>
<div class="input-corners bl"></div>
<div class="input-corners br"></div>
<textarea
ref="inputRef"
v-model="inputMessage"
placeholder="输入指令,按 Enter 发送..."
:disabled="isSending"
rows="1"
@keydown.enter.exact.prevent="sendMessage"
@input="autoResize"
></textarea>
<input
ref="fileInputRef"
type="file"
multiple
accept=".jpg,.jpeg,.png,.gif,.webp,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt"
style="display: none"
@change="handleFileSelect"
/>
<button class="attach-btn" @click="openFilePicker" title="上传文件">
<Paperclip :size="15" />
</button>
<div class="emoji-wrapper">
<button
class="emoji-btn"
:class="{ active: showEmojiPicker }"
@click="showEmojiPicker = !showEmojiPicker"
title="表情包"
>
<Smile :size="15" />
</button>
<EmojiPicker
:visible="showEmojiPicker"
@select="insertEmoji"
@close="showEmojiPicker = false"
/>
</div>
<button
class="send-btn"
:class="{ active: inputMessage.trim() }"
:disabled="!inputMessage.trim() || isSending"
@click="sendMessage"
>
<Send :size="15" />
<CornerDownLeft :size="12" class="enter-hint" />
</button>
</div>
<div class="input-hints">
<span class="hint-item">ENTER 发送</span>
<span class="hint-sep">|</span>
<span class="hint-item">SHIFT+ENTER 换行</span>
</div>
</div>
</section>
</div>
</template>
<style scoped>
.chat-view {
display: flex;
height: 100%;
overflow: hidden;
}
/* ── Conversation Sidebar ── */
.conv-sidebar {
width: 200px;
min-width: 200px;
background: var(--bg-panel);
border-right: 1px solid var(--border-dim);
display: flex;
flex-direction: column;
overflow: hidden;
}
.conv-sidebar-header {
padding: 16px 14px 12px;
border-bottom: 1px solid var(--border-dim);
}
.section-label {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.15em;
color: var(--text-dim);
margin-bottom: 10px;
}
.new-chat-btn {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
background: var(--accent-cyan-dim);
border: 1px solid var(--border-mid);
border-radius: var(--radius-md);
color: var(--accent-cyan);
font-size: 10px;
letter-spacing: 0.1em;
font-weight: 600;
transition: all var(--transition-fast);
position: relative;
overflow: hidden;
}
.new-chat-btn::before {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, rgba(0,245,212,0.1), transparent);
transform: translateX(-100%);
transition: transform 0.4s;
}
.new-chat-btn:hover::before { transform: translateX(100%); }
.new-chat-btn:hover {
background: rgba(0, 245, 212, 0.18);
box-shadow: var(--glow-cyan);
}
.btn-line {
width: 1px;
height: 12px;
background: var(--accent-cyan);
opacity: 0.6;
}
.conv-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.conv-item {
position: relative;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: var(--radius-md);
cursor: pointer;
margin-bottom: 2px;
border: 1px solid transparent;
transition: all var(--transition-fast);
overflow: hidden;
}
.conv-item:hover {
background: rgba(0, 245, 212, 0.04);
border-color: var(--border-dim);
}
.conv-item.active {
background: var(--accent-cyan-dim);
border-color: var(--border-mid);
}
.conv-active-line {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
background: var(--accent-cyan);
opacity: 0;
transition: opacity var(--transition-fast);
box-shadow: 0 0 6px var(--accent-cyan);
}
.conv-item.active .conv-active-line { opacity: 1; }
.conv-item-icon {
color: var(--text-dim);
flex-shrink: 0;
}
.conv-item.active .conv-item-icon { color: var(--accent-cyan); }
.conv-item-body { flex: 1; min-width: 0; }
.conv-title {
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 2px;
}
.conv-item.active .conv-title { color: var(--accent-cyan); }
.conv-date {
font-size: 9px;
color: var(--text-dim);
font-family: var(--font-mono);
}
.conv-delete {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
padding: 3px;
opacity: 0;
transition: all var(--transition-fast);
border-radius: 3px;
}
.conv-item:hover .conv-delete { opacity: 1; }
.conv-delete:hover { color: var(--accent-red); background: rgba(255,71,87,0.1); }
.conv-empty {
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 16px;
gap: 8px;
}
.empty-icon { color: var(--text-dim); }
.empty-text { font-size: 12px; color: var(--text-dim); }
.empty-hint { font-size: 10px; color: var(--text-muted); }
/* ── Chat Area ── */
.chat-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
position: relative;
}
.chat-topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 24px;
border-bottom: 1px solid var(--border-dim);
background: rgba(5, 8, 16, 0.6);
backdrop-filter: blur(8px);
}
.chat-status {
display: flex;
align-items: center;
gap: 8px;
}
.status-indicator {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-dim);
}
.status-indicator.active {
background: var(--accent-green);
box-shadow: 0 0 8px var(--accent-green);
animation: pulse-glow 1.5s ease-in-out infinite;
}
.status-text {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.15em;
color: var(--text-dim);
}
.chat-model {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-display);
font-size: 9px;
letter-spacing: 0.1em;
color: var(--accent-amber);
padding: 3px 10px;
border: 1px solid rgba(249, 168, 37, 0.2);
border-radius: 20px;
background: var(--accent-amber-dim);
}
/* ── Messages ── */
.messages-area {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 4px;
}
/* Welcome screen */
.welcome-screen {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding-bottom: 80px;
}
.welcome-icon {
position: relative;
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
color: var(--accent-cyan);
margin-bottom: 8px;
}
.welcome-ring {
position: absolute;
border-radius: 50%;
border: 1px solid var(--accent-cyan);
animation: spin linear infinite;
}
.r1 { width: 80px; height: 80px; opacity: 0.3; animation-duration: 8s; border-style: dashed; }
.r2 { width: 60px; height: 60px; opacity: 0.5; animation-duration: 5s; animation-direction: reverse; }
.r3 { width: 40px; height: 40px; opacity: 0.7; animation-duration: 3s; }
.welcome-core {
position: relative;
z-index: 1;
filter: drop-shadow(0 0 12px var(--accent-cyan));
animation: pulse-glow 2s ease-in-out infinite;
}
.welcome-title {
font-family: var(--font-display);
font-size: 32px;
font-weight: 800;
letter-spacing: 0.2em;
color: var(--accent-cyan);
text-shadow: var(--glow-cyan);
}
.welcome-sub {
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.3em;
color: var(--text-dim);
text-transform: uppercase;
}
.welcome-hint {
font-size: 13px;
color: var(--text-dim);
margin-top: 20px;
}
/* Message rows */
.message-row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 8px 0;
animation: fade-in-up 0.3s ease both;
}
.msg-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-size: 12px;
font-weight: 700;
flex-shrink: 0;
margin-top: 4px;
}
.user .msg-avatar {
background: rgba(0, 245, 212, 0.1);
border: 1px solid var(--border-mid);
color: var(--accent-cyan);
}
.assistant .msg-avatar {
background: linear-gradient(135deg, var(--accent-cyan-dim), var(--accent-purple-dim));
border: 1px solid var(--border-bright);
color: var(--accent-cyan);
box-shadow: 0 0 10px var(--accent-cyan-glow);
}
.msg-content {
flex: 1;
min-width: 0;
max-width: 75%;
}
.msg-meta {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 4px;
}
.msg-role {
font-family: var(--font-display);
font-size: 9px;
letter-spacing: 0.15em;
color: var(--text-dim);
}
.msg-time {
font-family: var(--font-mono);
font-size: 9px;
color: var(--text-muted);
}
.msg-bubble {
display: inline-block;
padding: 10px 16px;
border-radius: var(--radius-md);
font-size: 13px;
line-height: 1.65;
word-break: break-word;
white-space: pre-wrap;
}
.user .msg-bubble {
background: var(--accent-cyan-dim);
border: 1px solid rgba(0, 245, 212, 0.2);
border-radius: var(--radius-md) var(--radius-md) 4px var(--radius-md);
color: var(--text-primary);
}
.assistant .msg-bubble {
background: rgba(13, 21, 37, 0.8);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md) var(--radius-md) var(--radius-md) 4px;
color: var(--text-secondary);
backdrop-filter: blur(4px);
}
/* Typing indicator */
.typing .dot {
display: inline-block;
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--accent-cyan);
margin: 0 2px;
animation: typing-bounce 1.2s ease-in-out infinite;
}
.typing .dot:nth-child(2) { animation-delay: 0.2s; }
.typing .dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing-bounce {
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
30% { transform: translateY(-4px); opacity: 1; }
}
/* ── Input Area ── */
.input-area {
padding: 16px 24px 20px;
border-top: 1px solid var(--border-dim);
background: rgba(5, 8, 16, 0.8);
backdrop-filter: blur(12px);
}
.input-frame {
position: relative;
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-lg);
padding: 12px 16px;
display: flex;
align-items: flex-end;
gap: 12px;
transition: all var(--transition-mid);
}
.input-frame:focus-within {
border-color: var(--accent-cyan);
box-shadow: 0 0 0 1px rgba(0,245,212,0.1), var(--glow-cyan);
}
/* Corner accents */
.input-corners {
position: absolute;
width: 8px;
height: 8px;
pointer-events: none;
}
.input-corners::before,
.input-corners::after {
content: '';
position: absolute;
background: var(--accent-cyan);
}
.input-corners::before { width: 100%; height: 1px; }
.input-corners::after { width: 1px; height: 100%; }
.tl { top: -1px; left: -1px; }
.tr { top: -1px; right: -1px; transform: scaleX(-1); }
.bl { bottom: -1px; left: -1px; transform: scaleY(-1); }
.br { bottom: -1px; right: -1px; transform: scale(-1); }
.input-frame textarea {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.6;
resize: none;
max-height: 120px;
padding: 8px 0;
vertical-align: middle;
overflow: hidden;
}
.input-frame textarea::placeholder { color: var(--text-dim); }
.send-btn {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: var(--text-muted);
border: 1px solid transparent;
color: var(--text-dim);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
position: relative;
transition: all var(--transition-fast);
}
.send-btn.active {
background: var(--accent-cyan-dim);
border-color: var(--border-mid);
color: var(--accent-cyan);
}
.send-btn.active:hover {
background: rgba(0, 245, 212, 0.2);
box-shadow: var(--glow-cyan);
transform: scale(1.05);
}
.enter-hint { display: none; }
.input-hints {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
padding-left: 4px;
}
.hint-item {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.1em;
color: var(--text-muted);
}
.hint-sep {
color: var(--text-muted);
font-size: 9px;
}
/* File attachment button */
.attach-btn {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: transparent;
border: 1px solid transparent;
color: var(--text-dim);
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
}
.attach-btn:hover {
background: var(--accent-cyan-dim);
border-color: var(--border-mid);
color: var(--accent-cyan);
}
/* Emoji button */
.emoji-wrapper {
position: relative;
}
.emoji-btn {
width: 36px;
height: 36px;
border-radius: var(--radius-md);
background: transparent;
border: 1px solid transparent;
color: var(--text-dim);
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-fast);
}
.emoji-btn:hover,
.emoji-btn.active {
background: var(--accent-cyan-dim);
border-color: var(--border-mid);
color: var(--accent-cyan);
}
/* Message attachments */
.msg-attachments {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
</style>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,344 @@
import { computed, onMounted, ref } from 'vue'
import { documentApi, type Document } from '@/api/document'
import { folderApi, type FolderTree } from '@/api/folder'
export function useKnowledgeView() {
const folders = ref<FolderTree[]>([])
const documents = ref<Document[]>([])
const currentFolderId = ref<string | null>(null)
const isUploading = ref(false)
const isLoadingDocuments = ref(false)
const uploadError = ref('')
const uploadInput = ref<HTMLInputElement | null>(null)
const showNewFolderDialog = ref(false)
const newFolderName = ref('')
const newFolderParentId = ref<string | null>(null)
const showRenameDialog = ref(false)
const renameFolderName = ref('')
const renamingFolder = ref<FolderTree | null>(null)
const showDeleteDialog = ref(false)
const deletingFolder = ref<FolderTree | null>(null)
const showDocumentDialog = ref(false)
const activeDocument = ref<Document | null>(null)
const activeDocumentContent = ref('')
const isLoadingDocumentContent = ref(false)
const folderMap = computed(() => {
const map = new Map<string, FolderTree>()
function walk(nodes: FolderTree[]) {
for (const node of nodes) {
map.set(node.id, node)
if (node.children?.length) {
walk(node.children)
}
}
}
walk(folders.value)
return map
})
const currentFolder = computed(() => {
if (!currentFolderId.value) return null
return folderMap.value.get(currentFolderId.value) ?? null
})
const isRoot = computed(() => currentFolderId.value === null)
const visibleFolders = computed(() => {
if (isRoot.value) return folders.value
return currentFolder.value?.children ?? []
})
const breadcrumbs = computed(() => {
const items: Array<{ id: string | null; name: string }> = [{ id: null, name: '根目录' }]
if (!currentFolder.value) {
return items
}
const chain: FolderTree[] = []
let cursor: FolderTree | null = currentFolder.value
while (cursor) {
chain.unshift(cursor)
cursor = cursor.parent_id ? folderMap.value.get(cursor.parent_id) ?? null : null
}
for (const folder of chain) {
items.push({ id: folder.id, name: folder.name })
}
return items
})
const explorerTitle = computed(() => {
if (isRoot.value) {
return `${visibleFolders.value.length} 个文件夹`
}
return `${visibleFolders.value.length} 个文件夹 · ${documents.value.length} 个文件`
})
async function loadFolders() {
try {
const response = await folderApi.getTree()
folders.value = response.data
} catch (error) {
console.error('加载文件夹失败:', error)
}
}
async function loadDocumentsByFolder(folderId: string | null) {
if (!folderId) {
documents.value = []
return
}
isLoadingDocuments.value = true
try {
const response = await documentApi.list(folderId)
documents.value = response.data
} catch (error) {
console.error('加载文档失败:', error)
} finally {
isLoadingDocuments.value = false
}
}
async function enterFolder(folder: FolderTree) {
currentFolderId.value = folder.id
await loadDocumentsByFolder(folder.id)
}
async function goToFolder(folderId: string | null) {
currentFolderId.value = folderId
await loadDocumentsByFolder(folderId)
}
async function goBack() {
if (!currentFolder.value) return
await goToFolder(currentFolder.value.parent_id)
}
function triggerUpload() {
if (isRoot.value) return
uploadInput.value?.click()
}
async function handleUpload(event: Event) {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
if (!currentFolderId.value) {
uploadError.value = '请先进入目标文件夹后再上传文件'
window.setTimeout(() => {
uploadError.value = ''
}, 3000)
target.value = ''
return
}
isUploading.value = true
try {
await documentApi.upload(file, currentFolderId.value)
await loadDocumentsByFolder(currentFolderId.value)
} catch (error) {
console.error('上传失败:', error)
} finally {
isUploading.value = false
target.value = ''
}
}
async function handleDeleteDocument(id: string) {
try {
await documentApi.delete(id)
documents.value = documents.value.filter((doc) => doc.id !== id)
if (activeDocument.value?.id === id) {
closeDocumentDialog()
}
} catch (error) {
console.error('删除失败:', error)
}
}
function openNewFolderDialog(parentId: string | null = null) {
newFolderParentId.value = parentId
newFolderName.value = ''
showNewFolderDialog.value = true
}
async function createFolder() {
if (!newFolderName.value.trim()) return
try {
await folderApi.create({
name: newFolderName.value.trim(),
parent_id: newFolderParentId.value,
})
await loadFolders()
showNewFolderDialog.value = false
} catch (error) {
console.error('创建文件夹失败:', error)
}
}
function openRenameDialog(folder: FolderTree) {
renamingFolder.value = folder
renameFolderName.value = folder.name
showRenameDialog.value = true
}
async function renameFolder() {
if (!renamingFolder.value || !renameFolderName.value.trim()) return
try {
await folderApi.rename(renamingFolder.value.id, { name: renameFolderName.value.trim() })
await loadFolders()
showRenameDialog.value = false
renamingFolder.value = null
} catch (error) {
console.error('重命名文件夹失败:', error)
}
}
function openDeleteDialog(folder: FolderTree) {
deletingFolder.value = folder
showDeleteDialog.value = true
}
function isFolderInTree(nodes: FolderTree[], targetId: string) {
for (const node of nodes) {
if (node.id === targetId) return true
if (node.children?.length && isFolderInTree(node.children, targetId)) return true
}
return false
}
async function deleteFolder() {
if (!deletingFolder.value) return
const deletingId = deletingFolder.value.id
const fallbackParentId = deletingFolder.value.parent_id
try {
await folderApi.delete(deletingId)
await loadFolders()
if (currentFolderId.value && !isFolderInTree(folders.value, currentFolderId.value)) {
currentFolderId.value = fallbackParentId
}
await loadDocumentsByFolder(currentFolderId.value)
showDeleteDialog.value = false
deletingFolder.value = null
} catch (error) {
console.error('删除文件夹失败:', error)
}
}
async function openDocument(doc: Document) {
activeDocument.value = doc
activeDocumentContent.value = ''
showDocumentDialog.value = true
isLoadingDocumentContent.value = true
try {
const response = await documentApi.getContent(doc.id)
const content = response.data as string | { content?: string }
activeDocumentContent.value = typeof content === 'string' ? content : content.content ?? ''
} catch (error) {
console.error('加载文档内容失败:', error)
activeDocumentContent.value = '暂时无法加载文档内容。'
} finally {
isLoadingDocumentContent.value = false
}
}
function closeDocumentDialog() {
showDocumentDialog.value = false
activeDocument.value = null
activeDocumentContent.value = ''
}
function getFileTypeColor(type: string) {
const colors: Record<string, string> = {
pdf: '#f87171',
md: '#60a5fa',
txt: '#34d399',
docx: '#a78bfa',
}
return colors[type] || '#9ca3af'
}
function formatFileSize(fileSize: number) {
if (fileSize < 1024) return `${fileSize} B`
if (fileSize < 1024 * 1024) return `${(fileSize / 1024).toFixed(1)} KB`
return `${(fileSize / (1024 * 1024)).toFixed(1)} MB`
}
function formatDate(date: string) {
return new Date(date).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}
onMounted(async () => {
await loadFolders()
await loadDocumentsByFolder(null)
})
return {
folders,
documents,
currentFolderId,
isUploading,
isLoadingDocuments,
uploadError,
uploadInput,
showNewFolderDialog,
newFolderName,
newFolderParentId,
showRenameDialog,
renameFolderName,
renamingFolder,
showDeleteDialog,
deletingFolder,
showDocumentDialog,
activeDocument,
activeDocumentContent,
isLoadingDocumentContent,
currentFolder,
isRoot,
visibleFolders,
breadcrumbs,
explorerTitle,
enterFolder,
goToFolder,
goBack,
triggerUpload,
handleUpload,
handleDeleteDocument,
openNewFolderDialog,
createFolder,
openRenameDialog,
renameFolder,
openDeleteDialog,
deleteFolder,
openDocument,
closeDocumentDialog,
getFileTypeColor,
formatFileSize,
formatDate,
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,314 @@
import { computed, onMounted, ref } from 'vue'
import { settingsApi, type LLMConfig, type LLMModelConfig, type LLMType, type SchedulerConfig } from '@/api/settings'
type ToastState = {
show: boolean
message: string
type: 'success' | 'error'
}
type EditingSnapshot = {
type: string
index: number
data: LLMModelConfig
}
type ProfileState = {
email: string
full_name: string
created_at: string
}
function cloneLLMConfig(config: LLMConfig): LLMConfig {
return JSON.parse(JSON.stringify(config)) as LLMConfig
}
function cloneSchedulerConfig(config: SchedulerConfig): SchedulerConfig {
return JSON.parse(JSON.stringify(config)) as SchedulerConfig
}
function getErrorMessage(error: unknown, fallback: string) {
return (error as { response?: { data?: { detail?: string } } })?.response?.data?.detail || fallback
}
export function useSettingsView() {
const loading = ref(false)
const saving = ref(false)
const savingModel = ref<string | null>(null)
const toast = ref<ToastState>({
show: false,
message: '',
type: 'success',
})
const expandedRow = ref<string | null>(null)
const editingSnapshot = ref<EditingSnapshot | null>(null)
const profile = ref<ProfileState>({
email: '',
full_name: '',
created_at: '',
})
const originalProfile = ref({ email: '', full_name: '' })
const newPassword = ref('')
const llmConfig = ref<LLMConfig>({
chat: [],
vlm: [],
embedding: [],
rerank: [],
})
const originalLlmConfig = ref<LLMConfig>({
chat: [],
vlm: [],
embedding: [],
rerank: [],
})
const schedulerConfig = ref<SchedulerConfig>({
daily_plan_time: '08:00',
forum_scan_interval_minutes: 30,
todo_ai_generate_time: '08:00',
enabled: true,
})
const originalSchedulerConfig = ref<SchedulerConfig>({})
const showRequiredWarning = computed(() => {
return (llmConfig.value.chat?.length || 0) === 0 ||
(llmConfig.value.embedding?.length || 0) === 0 ||
(llmConfig.value.rerank?.length || 0) === 0
})
const isProfileDirty = computed(() => {
return profile.value.full_name !== originalProfile.value.full_name || newPassword.value !== ''
})
const isSchedulerDirty = computed(() => {
return JSON.stringify(schedulerConfig.value) !== JSON.stringify(originalSchedulerConfig.value)
})
function showToast(message: string, type: 'success' | 'error' = 'success') {
toast.value = { show: true, message, type }
window.setTimeout(() => {
toast.value.show = false
}, 3000)
}
function createEmptyModel(type: string): LLMModelConfig {
return {
name: `${type.toUpperCase()}-${Date.now()}`,
provider: 'openai',
model: type === 'chat'
? 'gpt-4o'
: type === 'vlm'
? 'gpt-4o'
: type === 'embedding'
? 'text-embedding-3-small'
: 'bge-reranker-v2',
base_url: '',
api_key: '',
enabled: true,
}
}
function getRowKey(type: string, index: number): string {
return `${type}-${index}`
}
function addModel(type: string) {
if (!llmConfig.value[type as keyof LLMConfig]) {
llmConfig.value[type as keyof LLMConfig] = []
}
if ((type === 'embedding' || type === 'rerank') &&
llmConfig.value[type as keyof LLMConfig]!.length >= 1) {
showToast(`${type === 'embedding' ? 'Embedding' : 'Rerank'} 最多配置 1 个`, 'error')
return
}
const newModel = createEmptyModel(type)
llmConfig.value[type as keyof LLMConfig]!.push(newModel)
const newIndex = llmConfig.value[type as keyof LLMConfig]!.length - 1
expandedRow.value = getRowKey(type, newIndex)
editingSnapshot.value = { type, index: newIndex, data: JSON.parse(JSON.stringify(newModel)) as LLMModelConfig }
}
async function removeModel(type: string, index: number) {
if ((type === 'embedding' || type === 'rerank') &&
llmConfig.value[type as keyof LLMConfig]!.length <= 1) {
showToast(`${type === 'embedding' ? 'Embedding' : 'Rerank'} 为知识库必填,至少保留 1 个`, 'error')
return
}
llmConfig.value[type as keyof LLMConfig]!.splice(index, 1)
expandedRow.value = null
editingSnapshot.value = null
try {
await settingsApi.updateLLM(llmConfig.value)
originalLlmConfig.value = cloneLLMConfig(llmConfig.value)
showToast('删除成功')
} catch (error: unknown) {
showToast(getErrorMessage(error, '删除失败'), 'error')
}
}
function toggleRow(type: string, index: number, model: LLMModelConfig) {
const key = getRowKey(type, index)
if (expandedRow.value === key) {
expandedRow.value = null
editingSnapshot.value = null
} else {
editingSnapshot.value = { type, index, data: JSON.parse(JSON.stringify(model)) as LLMModelConfig }
expandedRow.value = key
}
}
function updateModel(type: string, index: number, model: LLMModelConfig) {
llmConfig.value[type as keyof LLMConfig]![index] = model
}
async function loadSettings() {
loading.value = true
try {
const response = await settingsApi.get()
profile.value = {
email: response.data.profile.email,
full_name: response.data.profile.full_name || '',
created_at: response.data.profile.created_at,
}
originalProfile.value = { email: profile.value.email, full_name: profile.value.full_name }
if (response.data.llm_config) {
llmConfig.value = {
chat: response.data.llm_config.chat || [],
vlm: response.data.llm_config.vlm || [],
embedding: response.data.llm_config.embedding || [],
rerank: response.data.llm_config.rerank || [],
}
} else {
llmConfig.value = { chat: [], vlm: [], embedding: [], rerank: [] }
}
originalLlmConfig.value = cloneLLMConfig(llmConfig.value)
if (response.data.scheduler_config && Object.keys(response.data.scheduler_config).length > 0) {
schedulerConfig.value = response.data.scheduler_config as SchedulerConfig
}
originalSchedulerConfig.value = cloneSchedulerConfig(schedulerConfig.value)
} catch (error) {
console.error('加载设置失败', error)
showToast('加载设置失败', 'error')
} finally {
loading.value = false
}
}
async function saveProfile() {
saving.value = true
try {
await settingsApi.updateProfile({
full_name: profile.value.full_name,
password: newPassword.value || undefined,
})
originalProfile.value = { email: profile.value.email, full_name: profile.value.full_name }
newPassword.value = ''
showToast('资料保存成功')
} catch (error: unknown) {
showToast(getErrorMessage(error, '保存失败'), 'error')
} finally {
saving.value = false
}
}
async function saveModel(type: string, index: number, model: LLMModelConfig) {
const key = getRowKey(type, index)
llmConfig.value[type as keyof LLMConfig]![index] = JSON.parse(JSON.stringify(model)) as LLMModelConfig
savingModel.value = key
try {
await settingsApi.updateLLM(llmConfig.value)
originalLlmConfig.value = cloneLLMConfig(llmConfig.value)
expandedRow.value = null
editingSnapshot.value = null
showToast('保存成功')
} catch (error: unknown) {
showToast(getErrorMessage(error, '保存失败'), 'error')
} finally {
savingModel.value = null
}
}
async function testModel(type: string, index: number, model: LLMModelConfig) {
try {
const response = await settingsApi.testLLM({
type: type as LLMType,
provider: model.provider,
model: model.model,
base_url: model.base_url,
api_key: model.api_key,
})
if (response.data.success) {
llmConfig.value[type as keyof LLMConfig]![index].enabled = true
showToast('连接成功')
} else {
llmConfig.value[type as keyof LLMConfig]![index].enabled = false
showToast(`连接失败: ${response.data.error}`, 'error')
}
} catch (error) {
llmConfig.value[type as keyof LLMConfig]![index].enabled = false
showToast('测试连接失败', 'error')
}
}
async function saveScheduler() {
saving.value = true
try {
await settingsApi.updateScheduler(schedulerConfig.value)
originalSchedulerConfig.value = cloneSchedulerConfig(schedulerConfig.value)
showToast('定时任务配置保存成功')
} catch (error: unknown) {
showToast(getErrorMessage(error, '保存失败'), 'error')
} finally {
saving.value = false
}
}
function resetProfile() {
profile.value.full_name = originalProfile.value.full_name
newPassword.value = ''
}
function resetScheduler() {
schedulerConfig.value = cloneSchedulerConfig(originalSchedulerConfig.value)
}
onMounted(loadSettings)
return {
loading,
saving,
savingModel,
toast,
expandedRow,
editingSnapshot,
showRequiredWarning,
profile,
newPassword,
llmConfig,
schedulerConfig,
isProfileDirty,
isSchedulerDirty,
addModel,
removeModel,
getRowKey,
toggleRow,
updateModel,
saveProfile,
saveModel,
testModel,
saveScheduler,
resetProfile,
resetScheduler,
}
}

View File

@@ -0,0 +1,633 @@
<script setup lang="ts">
import LLMTableRow from '@/components/settings/LLMTableRow.vue'
import { Save, RotateCcw, Plus } from 'lucide-vue-next'
import { useSettingsView } from '@/pages/settings/composables/useSettingsView'
const {
loading,
saving,
toast,
expandedRow,
showRequiredWarning,
profile,
newPassword,
llmConfig,
schedulerConfig,
isProfileDirty,
isSchedulerDirty,
addModel,
removeModel,
getRowKey,
toggleRow,
updateModel,
saveProfile,
saveModel,
testModel,
saveScheduler,
resetProfile,
resetScheduler,
} = useSettingsView()
</script>
<template>
<div class="settings-view scanlines">
<!-- Background -->
<div class="bg-grid"></div>
<div class="bg-glow"></div>
<!-- Header -->
<div class="view-header">
<span class="header-title">// SETTINGS</span>
</div>
<!-- Toast -->
<Transition name="fade">
<div v-if="toast.show" class="toast" :class="toast.type">
{{ toast.message }}
</div>
</Transition>
<!-- Loading -->
<div v-if="loading" class="loading-overlay">
<div class="loading-text">LOADING...</div>
</div>
<!-- Content -->
<div class="settings-content">
<!-- Profile Section -->
<div class="settings-card">
<div class="card-header">
<span class="card-title">PROFILE</span>
<button v-if="isProfileDirty" class="reset-btn" @click="resetProfile">
<RotateCcw :size="12" /> 重置
</button>
</div>
<div class="form-group">
<label class="form-label">// EMAIL</label>
<input v-model="profile.email" type="email" disabled class="form-input disabled" />
</div>
<div class="form-group">
<label class="form-label">// NAME</label>
<input v-model="profile.full_name" type="text" class="form-input" placeholder="Your name" />
</div>
<div class="form-group">
<label class="form-label">// NEW PASSWORD (留空保持不变)</label>
<input v-model="newPassword" type="password" class="form-input" placeholder="••••••••" />
</div>
<button class="save-btn" @click="saveProfile" :disabled="saving || !isProfileDirty">
<div v-if="saving" class="btn-loader"></div>
<Save v-else :size="14" />
<span>{{ saving ? 'SAVING...' : 'SAVE PROFILE' }}</span>
</button>
</div>
<!-- LLM Config Section -->
<div class="settings-card">
<div class="card-header">
<span class="card-title">// LLM CONFIGURATION</span>
</div>
<!-- 必填警告 -->
<div v-if="showRequiredWarning" class="warning-bar">
chat / embedding / rerank 为知识库必填请确保已配置
</div>
<!-- Chat Section -->
<div class="llm-type-section">
<div class="llm-type-header">
<span class="llm-type-title">CHAT</span>
<button class="add-btn" @click="addModel('chat')">
<Plus :size="12" /> 添加
</button>
</div>
<div v-if="llmConfig.chat && llmConfig.chat.length > 0" class="model-table">
<div v-for="(model, index) in llmConfig.chat" :key="index">
<LLMTableRow
:model="model"
:is-expanded="expandedRow === getRowKey('chat', index)"
@toggle="toggleRow('chat', index, model)"
@update="(m) => updateModel('chat', index, m)"
@delete="removeModel('chat', index)"
@test="(m) => testModel('chat', index, m)"
@save="(m) => saveModel('chat', index, m)"
/>
</div>
</div>
<div v-else class="empty-state">暂无 chat 模型配置</div>
</div>
<!-- VLM Section -->
<div class="llm-type-section">
<div class="llm-type-header">
<span class="llm-type-title">VLM <span class="optional-tag">(可选)</span></span>
<button class="add-btn" @click="addModel('vlm')">
<Plus :size="12" /> 添加
</button>
</div>
<div v-if="llmConfig.vlm && llmConfig.vlm.length > 0" class="model-table">
<div v-for="(model, index) in llmConfig.vlm" :key="index">
<LLMTableRow
:model="model"
:is-expanded="expandedRow === getRowKey('vlm', index)"
@toggle="toggleRow('vlm', index, model)"
@update="(m) => updateModel('vlm', index, m)"
@delete="removeModel('vlm', index)"
@test="(m) => testModel('vlm', index, m)"
@save="(m) => saveModel('vlm', index, m)"
/>
</div>
</div>
<div v-else class="empty-state">暂无 vlm 模型配置</div>
</div>
<!-- Embedding Section -->
<div class="llm-type-section">
<div class="llm-type-header">
<span class="llm-type-title">EMBEDDING <span class="required-tag">(知识库)</span></span>
<button class="add-btn" @click="addModel('embedding')">
<Plus :size="12" /> 添加
</button>
</div>
<div v-if="llmConfig.embedding && llmConfig.embedding.length > 0" class="model-table">
<div v-for="(model, index) in llmConfig.embedding" :key="index">
<LLMTableRow
:model="model"
:is-expanded="expandedRow === getRowKey('embedding', index)"
@toggle="toggleRow('embedding', index, model)"
@update="(m) => updateModel('embedding', index, m)"
@delete="removeModel('embedding', index)"
@test="(m) => testModel('embedding', index, m)"
@save="(m) => saveModel('embedding', index, m)"
/>
</div>
</div>
<div v-else class="empty-state">暂无 embedding 模型配置</div>
</div>
<!-- Rerank Section -->
<div class="llm-type-section">
<div class="llm-type-header">
<span class="llm-type-title">RERANK <span class="required-tag">(知识库)</span></span>
<button class="add-btn" @click="addModel('rerank')">
<Plus :size="12" /> 添加
</button>
</div>
<div v-if="llmConfig.rerank && llmConfig.rerank.length > 0" class="model-table">
<div v-for="(model, index) in llmConfig.rerank" :key="index">
<LLMTableRow
:model="model"
:is-expanded="expandedRow === getRowKey('rerank', index)"
@toggle="toggleRow('rerank', index, model)"
@update="(m) => updateModel('rerank', index, m)"
@delete="removeModel('rerank', index)"
@test="(m) => testModel('rerank', index, m)"
@save="(m) => saveModel('rerank', index, m)"
/>
</div>
</div>
<div v-else class="empty-state">暂无 rerank 模型配置</div>
</div>
</div>
<!-- Scheduler Section -->
<div class="settings-card">
<div class="card-header">
<span class="card-title">SCHEDULER</span>
<button v-if="isSchedulerDirty" class="reset-btn" @click="resetScheduler">
<RotateCcw :size="12" /> 重置
</button>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">// DAILY PLAN TIME</label>
<input v-model="schedulerConfig.daily_plan_time" type="time" class="form-input" />
</div>
<div class="form-group">
<label class="form-label">// TODO AI GENERATE TIME</label>
<input v-model="schedulerConfig.todo_ai_generate_time" type="time" class="form-input" />
</div>
</div>
<div class="form-group">
<label class="form-label">// FORUM SCAN INTERVAL (minutes)</label>
<input
v-model.number="schedulerConfig.forum_scan_interval_minutes"
type="number"
min="5"
max="1440"
class="form-input"
/>
</div>
<div class="form-group toggle-group">
<label class="form-label">// SCHEDULER ENABLED</label>
<button
class="toggle-btn"
:class="{ active: schedulerConfig.enabled }"
@click="schedulerConfig.enabled = !schedulerConfig.enabled"
>
<span class="toggle-knob"></span>
</button>
</div>
<button
class="save-btn"
@click="saveScheduler"
:disabled="saving || !isSchedulerDirty"
>
<div v-if="saving" class="btn-loader"></div>
<Save v-else :size="14" />
<span>{{ saving ? 'SAVING...' : 'SAVE SCHEDULER' }}</span>
</button>
</div>
</div>
</div>
</template>
<style scoped>
.settings-view {
height: 100%;
display: flex;
flex-direction: column;
background: var(--bg-void);
position: relative;
overflow: hidden;
}
.bg-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(0,245,212,0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,245,212,0.04) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
z-index: 0;
}
.bg-glow {
position: absolute;
inset: 0;
background: radial-gradient(ellipse 80% 60% at 50% 40%, rgba(0,245,212,0.05) 0%, transparent 70%);
pointer-events: none;
z-index: 0;
}
.view-header {
display: flex;
align-items: center;
padding: 14px 24px;
border-bottom: 1px solid var(--border-dim);
background: rgba(5,8,16,0.6);
backdrop-filter: blur(8px);
position: relative;
z-index: 10;
}
.header-title {
font-family: var(--font-display);
font-size: 13px;
letter-spacing: 0.2em;
color: var(--text-primary);
}
/* Toast */
.toast {
position: fixed;
top: 80px;
left: 50%;
transform: translateX(-50%);
padding: 10px 20px;
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: 12px;
z-index: 1000;
animation: slide-in 0.3s ease;
}
.toast.success {
background: rgba(0, 245, 212, 0.15);
border: 1px solid var(--accent-cyan);
color: var(--accent-cyan);
}
.toast.error {
background: rgba(255, 71, 87, 0.15);
border: 1px solid var(--accent-red);
color: var(--accent-red);
}
.fade-enter-active, .fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
/* Loading */
.loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(5,8,16,0.8);
z-index: 50;
}
.loading-text {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.2em;
color: var(--accent-cyan);
animation: pulse 1s ease-in-out infinite;
}
/* Content */
.settings-content {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
position: relative;
z-index: 1;
}
/* Card */
.settings-card {
background: rgba(13,21,37,0.9);
border: 1px solid var(--border-dim);
border-radius: var(--radius-lg);
padding: 20px;
backdrop-filter: blur(12px);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.card-title {
font-family: var(--font-display);
font-size: 11px;
letter-spacing: 0.2em;
color: var(--accent-cyan);
}
.reset-btn, .add-btn, .test-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: transparent;
border: 1px solid var(--border-mid);
border-radius: var(--radius-sm);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 10px;
cursor: pointer;
transition: all var(--transition-fast);
}
.reset-btn:hover, .add-btn:hover, .test-btn:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.add-btn {
border-color: rgba(0,245,212,0.3);
color: var(--accent-cyan);
}
.test-btn {
border-color: rgba(0,245,212,0.3);
color: var(--accent-cyan);
}
/* LLM Type Section */
.llm-type-section {
margin-bottom: 20px;
}
.llm-type-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.llm-type-title {
font-family: var(--font-display);
font-size: 11px;
letter-spacing: 0.15em;
color: var(--accent-cyan);
}
.optional-tag {
font-size: 9px;
color: var(--text-dim);
letter-spacing: 0.1em;
}
.required-tag {
font-size: 9px;
color: var(--accent-red);
letter-spacing: 0.1em;
}
/* Warning Bar */
.warning-bar {
padding: 10px 14px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: var(--radius-sm);
color: var(--accent-red);
font-family: var(--font-mono);
font-size: 11px;
margin-bottom: 16px;
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 11px;
}
/* Form */
.form-group {
margin-bottom: 14px;
}
.form-row {
display: flex;
gap: 14px;
}
.form-row .form-group {
flex: 1;
}
.form-label {
display: block;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.15em;
color: var(--text-dim);
margin-bottom: 6px;
}
.form-input, .form-select {
width: 100%;
padding: 10px 12px;
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 12px;
transition: all var(--transition-fast);
}
.form-input:focus, .form-select:focus {
outline: none;
border-color: var(--accent-cyan);
box-shadow: 0 0 0 1px rgba(0,245,212,0.1);
}
.form-input.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.form-select {
cursor: pointer;
}
.input-with-toggle {
display: flex;
gap: 8px;
}
.input-with-toggle .form-input {
flex: 1;
}
.toggle-visibility {
width: 40px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-sm);
color: var(--text-dim);
cursor: pointer;
transition: all var(--transition-fast);
}
.toggle-visibility:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
/* Toggle */
.toggle-group {
display: flex;
align-items: center;
justify-content: space-between;
}
.toggle-btn {
width: 44px;
height: 22px;
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: 11px;
padding: 2px;
cursor: pointer;
transition: all 0.25s;
}
.toggle-btn.active {
background: rgba(0,245,212,.15);
border-color: var(--accent-cyan);
}
.toggle-knob {
display: block;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--text-dim);
transition: all 0.25s;
}
.toggle-btn.active .toggle-knob {
background: var(--accent-cyan);
box-shadow: 0 0 8px var(--accent-cyan);
transform: translateX(22px);
}
/* Save Button */
.save-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
padding: 10px 16px;
background: rgba(0,245,212,0.08);
border: 1px solid rgba(0,245,212,0.3);
border-radius: var(--radius-sm);
color: var(--accent-cyan);
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.1em;
cursor: pointer;
transition: all var(--transition-fast);
margin-top: 8px;
}
.save-btn:hover:not(:disabled) {
background: rgba(0,245,212,0.15);
box-shadow: 0 0 12px rgba(0,245,212,0.2);
}
.save-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.save-btn.full-width {
margin-top: 0;
}
.btn-loader {
width: 14px;
height: 14px;
border: 2px solid transparent;
border-top-color: var(--accent-cyan);
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
</style>

View File

@@ -0,0 +1,933 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
function animateIn(el: Element, done: () => void) {
const target = el as HTMLElement
target.animate(
[
{ opacity: '0', transform: 'translateY(12px)' },
{ opacity: '1', transform: 'translateY(0)' },
],
{ duration: 220, easing: 'ease-out', fill: 'forwards' },
).finished.finally(done)
}
function animateOut(el: Element, done: () => void) {
const target = el as HTMLElement
target.animate(
[
{ opacity: '1', transform: 'translateY(0)' },
{ opacity: '0', transform: 'translateY(12px)' },
],
{ duration: 180, easing: 'ease-in', fill: 'forwards' },
).finished.finally(done)
}
import { skillApi, type Skill, type SkillCreate } from '@/api/skill'
import { Bot, Plus, Edit2, Trash2, Eye, EyeOff, Copy, X } from 'lucide-vue-next'
// Available agent types and tools
const AGENT_TYPES = ['general', 'coder', 'analyst', 'planner', 'executor', 'librarian']
const AVAILABLE_TOOLS = ['file_operations', 'web_search', 'code_execution', 'database', 'api_calls', 'shell', 'git']
const VISIBILITY_OPTIONS = ['private', 'team', 'market'] as const
// State
const skills = ref<Skill[]>([])
const loading = ref(false)
const saving = ref(false)
const modalOpen = ref(false)
const editingSkill = ref<Skill | null>(null)
// Form state
const form = ref<SkillCreate>({
name: '',
description: '',
instructions: '',
agent_type: 'general',
tools: [],
visibility: 'private',
is_active: true,
})
// Reset form
function resetForm() {
form.value = {
name: '',
description: '',
instructions: '',
agent_type: 'general',
tools: [],
visibility: 'private',
is_active: true,
}
}
// Open create modal
function openCreateModal() {
editingSkill.value = null
resetForm()
modalOpen.value = true
}
// Open edit modal
function openEditModal(skill: Skill) {
editingSkill.value = skill
form.value = {
name: skill.name,
description: skill.description || '',
instructions: skill.instructions,
agent_type: skill.agent_type,
tools: [...skill.tools],
visibility: skill.visibility,
is_active: skill.is_active,
}
modalOpen.value = true
}
// Close modal
function closeModal() {
modalOpen.value = false
editingSkill.value = null
resetForm()
}
// Fetch skills
async function fetchSkills() {
loading.value = true
try {
const res = await skillApi.list()
skills.value = res.data
} catch (e) {
console.error('Failed to fetch skills', e)
} finally {
loading.value = false
}
}
// Create skill
async function createSkill() {
saving.value = true
try {
const res = await skillApi.create(form.value)
skills.value.push(res.data)
closeModal()
} catch (e) {
console.error('Failed to create skill', e)
} finally {
saving.value = false
}
}
// Update skill
async function updateSkill() {
if (!editingSkill.value) return
saving.value = true
try {
const res = await skillApi.update(editingSkill.value.id, form.value)
const idx = skills.value.findIndex(s => s.id === editingSkill.value!.id)
if (idx !== -1) {
skills.value[idx] = res.data
}
closeModal()
} catch (e) {
console.error('Failed to update skill', e)
} finally {
saving.value = false
}
}
// Delete skill
async function deleteSkill(skill: Skill) {
if (!confirm(`Delete skill "${skill.name}"?`)) return
try {
await skillApi.delete(skill.id)
skills.value = skills.value.filter(s => s.id !== skill.id)
} catch (e) {
console.error('Failed to delete skill', e)
}
}
// Toggle active
async function toggleActive(skill: Skill) {
try {
const res = await skillApi.update(skill.id, { is_active: !skill.is_active })
const idx = skills.value.findIndex(s => s.id === skill.id)
if (idx !== -1) {
skills.value[idx] = res.data
}
} catch (e) {
console.error('Failed to toggle skill active state', e)
}
}
// Copy skill to clipboard
function copySkill(skill: Skill) {
const skillText = JSON.stringify({
name: skill.name,
description: skill.description,
instructions: skill.instructions,
agent_type: skill.agent_type,
tools: skill.tools,
visibility: skill.visibility,
}, null, 2)
navigator.clipboard.writeText(skillText).then(() => {
// Could add toast notification here
}).catch(e => {
console.error('Failed to copy skill', e)
})
}
// Toggle tool selection
function toggleTool(tool: string) {
const idx = form.value.tools?.indexOf(tool) ?? -1
if (idx === -1) {
form.value.tools = [...(form.value.tools || []), tool]
} else {
form.value.tools = form.value.tools?.filter(t => t !== tool) ?? []
}
}
onMounted(fetchSkills)
</script>
<template>
<div class="skill-view scanlines">
<!-- Background -->
<div class="bg-grid"></div>
<div class="bg-glow"></div>
<!-- Header -->
<div class="view-header">
<div class="header-title">
<span class="title-bracket">[</span>
<span class="title-text">技能中心</span>
<span class="title-bracket">]</span>
</div>
<div class="header-actions">
<button class="btn-icon" @click="fetchSkills" :class="{ spinning: loading }" title="Refresh">
<Copy :size="14" />
</button>
<button class="btn-add" @click="openCreateModal">
<Plus :size="14" />
<span>新建技能</span>
</button>
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="loading-overlay">
<div class="loading-text">LOADING...</div>
</div>
<!-- Skills List -->
<div v-else-if="skills.length > 0" class="skills-grid">
<div
v-for="skill in skills"
:key="skill.id"
class="skill-card holo-card"
:class="{ inactive: !skill.is_active }"
>
<div class="skill-header">
<div class="skill-title">
<Bot :size="14" class="skill-icon" />
<span class="skill-name">{{ skill.name }}</span>
</div>
<span class="badge agent-badge">{{ skill.agent_type }}</span>
</div>
<p class="skill-description">{{ skill.description || 'No description' }}</p>
<div v-if="skill.tools && skill.tools.length > 0" class="skill-tools">
<span v-for="tool in skill.tools" :key="tool" class="tool-tag">{{ tool }}</span>
</div>
<div class="skill-footer">
<div class="skill-meta">
<span class="visibility-badge" :class="skill.visibility">{{ skill.visibility }}</span>
</div>
<div class="skill-actions">
<button
class="action-btn"
:class="{ active: skill.is_active }"
@click="toggleActive(skill)"
:title="skill.is_active ? 'Disable' : 'Enable'"
>
<Eye v-if="skill.is_active" :size="14" />
<EyeOff v-else :size="14" />
</button>
<button class="action-btn" @click="copySkill(skill)" title="Copy">
<Copy :size="14" />
</button>
<button class="action-btn edit" @click="openEditModal(skill)" title="Edit">
<Edit2 :size="14" />
</button>
<button class="action-btn delete" @click="deleteSkill(skill)" title="Delete">
<Trash2 :size="14" />
</button>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else class="empty-state">
<Bot :size="48" class="empty-icon" />
<p class="empty-text">No skills found</p>
<button class="btn-add" @click="openCreateModal">
<Plus :size="14" />
<span>Create your first skill</span>
</button>
</div>
<!-- Modal -->
<Transition :css="false" @enter="animateIn" @leave="animateOut">
<div v-if="modalOpen" class="modal-overlay" @click.self="closeModal">
<div class="modal-card">
<div class="modal-header">
<span class="modal-title">{{ editingSkill ? '// 编辑技能' : '// 新建技能' }}</span>
<button class="btn-close" @click="closeModal"><X :size="16" /></button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">// NAME</label>
<input v-model="form.name" type="text" class="form-input" placeholder="Skill name" />
</div>
<div class="form-group">
<label class="form-label">// DESCRIPTION</label>
<textarea v-model="form.description" class="form-textarea" rows="2" placeholder="Describe what this skill does..."></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">// AGENT TYPE</label>
<select v-model="form.agent_type" class="form-select">
<option v-for="type in AGENT_TYPES" :key="type" :value="type">{{ type }}</option>
</select>
</div>
<div class="form-group">
<label class="form-label">// VISIBILITY</label>
<select v-model="form.visibility" class="form-select">
<option v-for="vis in VISIBILITY_OPTIONS" :key="vis" :value="vis">{{ vis }}</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label">// TOOLS</label>
<div class="tools-grid">
<label v-for="tool in AVAILABLE_TOOLS" :key="tool" class="tool-checkbox">
<input type="checkbox" :checked="form.tools?.includes(tool)" @change="toggleTool(tool)" />
<span class="checkbox-custom"></span>
<span>{{ tool }}</span>
</label>
</div>
</div>
<div class="form-group flex-1">
<label class="form-label">// INSTRUCTIONS</label>
<textarea v-model="form.instructions" class="form-textarea code-textarea" rows="8" placeholder="Enter skill instructions..."></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn-secondary" @click="closeModal">Cancel</button>
<button
class="btn-primary"
@click="editingSkill ? updateSkill() : createSkill()"
:disabled="saving || !form.name || !form.instructions"
>
<span v-if="saving" class="btn-loader"></span>
{{ saving ? 'Saving...' : (editingSkill ? 'Update' : 'Create') }}
</button>
</div>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.skill-view {
height: 100%;
display: flex;
flex-direction: column;
background: var(--bg-void);
position: relative;
overflow: hidden;
}
.bg-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(0,245,212,0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,245,212,0.04) 1px, transparent 1px);
background-size: 40px 40px;
pointer-events: none;
z-index: 0;
}
.bg-glow {
position: absolute;
inset: 0;
background: radial-gradient(ellipse 80% 60% at 50% 40%, rgba(0,245,212,0.05) 0%, transparent 70%);
pointer-events: none;
z-index: 0;
}
/* Header */
.view-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 24px;
border-bottom: 1px solid var(--border-dim);
background: rgba(5,8,16,0.6);
backdrop-filter: blur(8px);
position: relative;
z-index: 10;
}
.header-title {
font-family: var(--font-display);
font-size: 13px;
letter-spacing: 0.2em;
color: var(--text-primary);
}
.title-bracket {
color: var(--accent-cyan);
opacity: 0.6;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.btn-icon {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-sm);
color: var(--text-secondary);
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-icon:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
box-shadow: var(--glow-cyan);
}
.btn-icon.spinning svg {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.btn-add {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
background: rgba(0,245,212,0.08);
border: 1px solid rgba(0,245,212,0.3);
border-radius: var(--radius-sm);
color: var(--accent-cyan);
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.1em;
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-add:hover {
background: rgba(0,245,212,0.15);
border-color: var(--accent-cyan);
box-shadow: var(--glow-cyan);
}
/* Loading */
.loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(5,8,16,0.8);
z-index: 50;
}
.loading-text {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.2em;
color: var(--accent-cyan);
animation: pulse 1s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 0.4; }
50% { opacity: 1; }
}
/* Skills Grid */
.skills-grid {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: 16px;
position: relative;
z-index: 1;
}
.skills-grid::-webkit-scrollbar {
width: 4px;
}
.skills-grid::-webkit-scrollbar-thumb {
background: var(--border-mid);
border-radius: 2px;
}
/* Skill Card */
.skill-card {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
}
.skill-card.inactive {
opacity: 0.5;
}
.skill-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.skill-title {
display: flex;
align-items: center;
gap: 8px;
}
.skill-icon {
color: var(--accent-cyan);
}
.skill-name {
font-family: var(--font-display);
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: 0.05em;
}
.agent-badge {
background: var(--accent-cyan-dim);
color: var(--accent-cyan);
border: 1px solid rgba(0,245,212,0.2);
}
.skill-description {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
line-height: 1.5;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-overflow: ellipsis;
}
.skill-tools {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tool-tag {
padding: 2px 8px;
background: var(--accent-purple-dim);
color: var(--accent-purple);
border: 1px solid rgba(123,44,191,0.2);
border-radius: 3px;
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.05em;
}
.skill-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 8px;
border-top: 1px solid var(--border-dim);
}
.skill-meta {
display: flex;
align-items: center;
gap: 8px;
}
.visibility-badge {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 2px 6px;
border-radius: 3px;
}
.visibility-badge.private {
background: rgba(255,71,87,0.1);
color: var(--accent-red);
border: 1px solid rgba(255,71,87,0.2);
}
.visibility-badge.team {
background: rgba(249,168,37,0.1);
color: var(--accent-amber);
border: 1px solid rgba(249,168,37,0.2);
}
.visibility-badge.market {
background: rgba(0,230,118,0.1);
color: var(--accent-green);
border: 1px solid rgba(0,230,118,0.2);
}
.skill-actions {
display: flex;
gap: 4px;
}
.action-btn {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--border-dim);
border-radius: var(--radius-sm);
color: var(--text-dim);
cursor: pointer;
transition: all var(--transition-fast);
}
.action-btn:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.action-btn.active {
color: var(--accent-cyan);
}
.action-btn.edit:hover {
border-color: var(--accent-amber);
color: var(--accent-amber);
}
.action-btn.delete:hover {
border-color: var(--accent-red);
color: var(--accent-red);
}
/* Empty State */
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
position: relative;
z-index: 1;
}
.empty-icon {
color: var(--text-dim);
opacity: 0.5;
}
.empty-text {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text-dim);
letter-spacing: 0.1em;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.7);
backdrop-filter: blur(4px);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
}
.modal-card {
width: 520px;
max-height: 85vh;
background: rgba(10,15,26,.98);
border: 1px solid var(--border-mid);
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0,0,0,.6), 0 0 0 1px rgba(0,245,212,.05);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--border-dim);
}
.modal-title {
font-family: var(--font-display);
font-size: 11px;
letter-spacing: 0.15em;
color: var(--accent-cyan);
}
.btn-close {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--border-dim);
border-radius: var(--radius-sm);
color: var(--text-dim);
cursor: pointer;
transition: all var(--transition-fast);
}
.btn-close:hover {
border-color: var(--accent-red);
color: var(--accent-red);
}
.modal-body {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.modal-body::-webkit-scrollbar {
width: 4px;
}
.modal-body::-webkit-scrollbar-thumb {
background: var(--border-mid);
border-radius: 2px;
}
.modal-footer {
display: flex;
gap: 12px;
padding: 16px 20px;
border-top: 1px solid var(--border-dim);
}
/* Form */
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group.flex-1 {
flex: 1;
display: flex;
flex-direction: column;
}
.form-row {
display: flex;
gap: 14px;
}
.form-row .form-group {
flex: 1;
}
.form-label {
font-family: var(--font-mono);
font-size: 9px;
letter-spacing: 0.15em;
color: var(--text-dim);
}
.form-input,
.form-textarea,
.form-select {
background: var(--bg-card);
border: 1px solid var(--border-mid);
border-radius: var(--radius-sm);
padding: 10px 12px;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 12px;
outline: none;
transition: border-color var(--transition-fast);
}
.form-input:focus,
.form-textarea:focus,
.form-select:focus {
border-color: var(--accent-cyan);
box-shadow: 0 0 0 1px rgba(0,245,212,.1);
}
.form-textarea {
resize: none;
line-height: 1.5;
}
.code-textarea {
font-size: 11px;
flex: 1;
}
.form-select {
cursor: pointer;
}
/* Tools Grid */
.tools-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.tool-checkbox {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-secondary);
}
.tool-checkbox input {
display: none;
}
.tool-checkbox .checkbox-custom {
width: 14px;
height: 14px;
border: 1px solid var(--border-mid);
border-radius: 3px;
background: var(--bg-card);
position: relative;
flex-shrink: 0;
}
.tool-checkbox input:checked + .checkbox-custom {
background: var(--accent-cyan);
border-color: var(--accent-cyan);
}
.tool-checkbox input:checked + .checkbox-custom::after {
content: '\2713';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: var(--bg-void);
font-size: 10px;
}
/* Buttons */
.btn-secondary,
.btn-primary {
flex: 1;
padding: 10px 16px;
border-radius: var(--radius-sm);
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.1em;
cursor: pointer;
transition: all var(--transition-fast);
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.btn-secondary {
background: transparent;
border: 1px solid var(--border-mid);
color: var(--text-secondary);
}
.btn-secondary:hover {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.btn-primary {
background: rgba(0,245,212,.1);
border: 1px solid var(--accent-cyan);
color: var(--accent-cyan);
}
.btn-primary:hover:not(:disabled) {
background: rgba(0,245,212,.2);
box-shadow: var(--glow-cyan);
}
.btn-primary:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-loader {
width: 12px;
height: 12px;
border: 1.5px solid transparent;
border-top-color: var(--accent-cyan);
border-radius: 50%;
animation: spin .6s linear infinite;
}
/* Animation helpers */
@keyframes animateIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
@keyframes animateOut {
from { opacity: 1; transform: scale(1); }
to { opacity: 0; transform: scale(0.95); }
}
</style>

View File

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

View File

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