Add Vue frontend application
This commit is contained in:
176
frontend/src/components/FolderTree.vue
Normal file
176
frontend/src/components/FolderTree.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { type FolderTree } from '@/api/folder'
|
||||
import { Folder, FolderOpen, ChevronRight, Plus, Edit2, Trash2 } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
folders: FolderTree[]
|
||||
selectedId?: string | null
|
||||
onSelect: (folder: FolderTree) => void
|
||||
onCreate: (parentId: string | null) => void
|
||||
onRename: (folder: FolderTree) => void
|
||||
onDelete: (folder: FolderTree) => void
|
||||
}>()
|
||||
|
||||
const expandedIds = ref<Set<string>>(new Set())
|
||||
|
||||
function toggleExpand(id: string) {
|
||||
if (expandedIds.value.has(id)) {
|
||||
expandedIds.value.delete(id)
|
||||
} else {
|
||||
expandedIds.value.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
function handleContextMenu(e: MouseEvent, folder: FolderTree) {
|
||||
e.preventDefault()
|
||||
// 显示右键菜单
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="folder-tree">
|
||||
<div
|
||||
v-for="folder in folders"
|
||||
:key="folder.id"
|
||||
class="folder-item"
|
||||
>
|
||||
<div
|
||||
class="folder-row"
|
||||
:class="{ selected: folder.id === selectedId }"
|
||||
@click="props.onSelect(folder)"
|
||||
@contextmenu="handleContextMenu($event, folder)"
|
||||
>
|
||||
<!-- 展开/折叠箭头 -->
|
||||
<button
|
||||
v-if="folder.children?.length"
|
||||
class="expand-btn"
|
||||
@click.stop="toggleExpand(folder.id)"
|
||||
>
|
||||
<ChevronRight
|
||||
:size="12"
|
||||
:class="{ rotated: expandedIds.has(folder.id) }"
|
||||
/>
|
||||
</button>
|
||||
<span v-else class="expand-placeholder"></span>
|
||||
|
||||
<!-- 文件夹图标 -->
|
||||
<FolderOpen v-if="expandedIds.has(folder.id)" :size="14" class="folder-icon" />
|
||||
<Folder v-else :size="14" class="folder-icon" />
|
||||
|
||||
<!-- 文件夹名称 -->
|
||||
<span class="folder-name">{{ folder.name }}</span>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="folder-actions">
|
||||
<button @click.stop="props.onCreate(folder.id)" title="添加子文件夹">
|
||||
<Plus :size="12" />
|
||||
</button>
|
||||
<button @click.stop="props.onRename(folder)" title="重命名">
|
||||
<Edit2 :size="12" />
|
||||
</button>
|
||||
<button @click.stop="props.onDelete(folder)" title="删除">
|
||||
<Trash2 :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 子文件夹(递归) -->
|
||||
<div
|
||||
v-if="folder.children?.length && expandedIds.has(folder.id)"
|
||||
class="folder-children"
|
||||
>
|
||||
<FolderTree
|
||||
:folders="folder.children"
|
||||
:selected-id="selectedId"
|
||||
:on-select="onSelect"
|
||||
:on-create="onCreate"
|
||||
:on-rename="onRename"
|
||||
:on-delete="onDelete"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* sci-fi 风格 */
|
||||
.folder-tree {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.folder-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.folder-row:hover {
|
||||
background: rgba(0, 245, 212, 0.04);
|
||||
}
|
||||
|
||||
.folder-row.selected {
|
||||
background: var(--accent-cyan-dim);
|
||||
border: 1px solid rgba(0, 245, 212, 0.2);
|
||||
}
|
||||
|
||||
.expand-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.expand-placeholder {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.folder-icon {
|
||||
color: var(--accent-amber);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.folder-name {
|
||||
flex: 1;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.folder-actions {
|
||||
display: none;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.folder-row:hover .folder-actions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.folder-actions button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 2px;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
border-radius: 3px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.folder-actions button:hover {
|
||||
color: var(--accent-cyan);
|
||||
background: rgba(0, 245, 212, 0.1);
|
||||
}
|
||||
|
||||
.folder-children {
|
||||
padding-left: 16px;
|
||||
}
|
||||
</style>
|
||||
93
frontend/src/components/HelloWorld.vue
Normal file
93
frontend/src/components/HelloWorld.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import viteLogo from '../assets/vite.svg'
|
||||
import heroImg from '../assets/hero.png'
|
||||
import vueLogo from '../assets/vue.svg'
|
||||
|
||||
const count = ref(0)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="center">
|
||||
<div class="hero">
|
||||
<img :src="heroImg" class="base" width="170" height="179" alt="" />
|
||||
<img :src="vueLogo" class="framework" alt="Vue logo" />
|
||||
<img :src="viteLogo" class="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>Edit <code>src/App.vue</code> and save to test <code>HMR</code></p>
|
||||
</div>
|
||||
<button class="counter" @click="count++">Count is {{ count }}</button>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img class="logo" :src="viteLogo" alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://vuejs.org/" target="_blank">
|
||||
<img class="button-icon" :src="vueLogo" alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg class="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg class="button-icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</template>
|
||||
378
frontend/src/components/SidebarNav.vue
Normal file
378
frontend/src/components/SidebarNav.vue
Normal file
@@ -0,0 +1,378 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { MessageCircle, BookOpen, Network, LayoutGrid, MessageSquare, LogOut, Cpu, Bot, Activity, CheckSquare, Settings } from 'lucide-vue-next'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const navItems = [
|
||||
{ name: '沟通系统', path: '/chat', icon: MessageCircle },
|
||||
{ name: '智能链路', path: '/agents', icon: Bot },
|
||||
{ name: '资料中枢', path: '/knowledge', icon: BookOpen },
|
||||
{ name: '关系图谱', path: '/graph', icon: Network },
|
||||
{ name: '任务矩阵', path: '/kanban', icon: LayoutGrid },
|
||||
{ name: '事务栈', path: '/todo', icon: CheckSquare },
|
||||
{ name: '交互广场', path: '/forum', icon: MessageSquare },
|
||||
{ name: '数据舱', path: '/stats', icon: Activity },
|
||||
{ name: '系统设置', path: '/settings', icon: Settings },
|
||||
]
|
||||
|
||||
function isActive(path: string) {
|
||||
return route.path === path
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="sidebar-nav">
|
||||
<!-- Grid lines decoration -->
|
||||
<div class="corner-deco top-left"></div>
|
||||
<div class="corner-deco top-right"></div>
|
||||
<div class="corner-deco bottom-left"></div>
|
||||
<div class="corner-deco bottom-right"></div>
|
||||
|
||||
<!-- Logo -->
|
||||
<div class="logo-section">
|
||||
<div class="logo-icon">
|
||||
<Cpu :size="22" />
|
||||
<div class="logo-pulse"></div>
|
||||
</div>
|
||||
<div class="logo-text-group">
|
||||
<span class="logo-name">JARVIS</span>
|
||||
<span class="logo-sub">AI ASSISTANT v2.0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status bar -->
|
||||
<div class="status-bar">
|
||||
<div class="status-dot"></div>
|
||||
<span>SYSTEM ONLINE</span>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<ul class="nav-items">
|
||||
<li v-for="item in navItems" :key="item.path">
|
||||
<router-link
|
||||
:to="item.path"
|
||||
class="nav-link"
|
||||
:class="{ active: isActive(item.path) }"
|
||||
>
|
||||
<span class="nav-indicator"></span>
|
||||
<component :is="item.icon" :size="18" class="nav-icon" />
|
||||
<span class="nav-label">{{ item.name }}</span>
|
||||
<span class="nav-line"></span>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="nav-footer">
|
||||
<div class="user-info">
|
||||
<div class="user-avatar">
|
||||
<span>{{ auth.user?.email?.[0]?.toUpperCase() || 'U' }}</span>
|
||||
</div>
|
||||
<div class="user-details">
|
||||
<span class="user-name">{{ auth.user?.email?.split('@')[0] || 'Operator' }}</span>
|
||||
<span class="user-level">LEVEL 1</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="logout-btn" @click="handleLogout">
|
||||
<LogOut :size="16" />
|
||||
<span>LOGOUT</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sidebar-nav {
|
||||
width: 220px;
|
||||
min-width: 220px;
|
||||
height: 100%;
|
||||
background: var(--bg-panel);
|
||||
border-right: 1px solid var(--border-dim);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Corner decorations */
|
||||
.corner-deco {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
z-index: 2;
|
||||
}
|
||||
.corner-deco::before,
|
||||
.corner-deco::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background: var(--accent-cyan);
|
||||
}
|
||||
.corner-deco::before { width: 100%; height: 1px; }
|
||||
.corner-deco::after { width: 1px; height: 100%; }
|
||||
.top-left { top: 8px; left: 8px; }
|
||||
.top-right { top: 8px; right: 8px; transform: scaleX(-1); }
|
||||
.bottom-left { bottom: 8px; left: 8px; transform: scaleY(-1); }
|
||||
.bottom-right { bottom: 8px; right: 8px; transform: scale(-1); }
|
||||
|
||||
/* Logo */
|
||||
.logo-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 24px 20px 16px;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
position: relative;
|
||||
color: var(--accent-cyan);
|
||||
filter: drop-shadow(0 0 8px var(--accent-cyan));
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.logo-pulse {
|
||||
position: absolute;
|
||||
inset: -4px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(0,245,212,0.15) 0%, transparent 70%);
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.logo-text-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logo-name {
|
||||
font-family: var(--font-display);
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--accent-cyan);
|
||||
text-shadow: var(--glow-cyan);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.logo-sub {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.2em;
|
||||
color: var(--text-dim);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
/* Status bar */
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 20px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--accent-green);
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-green);
|
||||
box-shadow: 0 0 6px var(--accent-green);
|
||||
animation: pulse-glow 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Nav items */
|
||||
.nav-items {
|
||||
list-style: none;
|
||||
padding: 16px 12px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.nav-items li { margin-bottom: 2px; }
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-dim);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.06em;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all var(--transition-mid);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.nav-link::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--accent-cyan);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
box-shadow: 0 0 8px var(--accent-cyan);
|
||||
}
|
||||
|
||||
.nav-indicator {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
flex-shrink: 0;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 500;
|
||||
transition: color var(--transition-fast);
|
||||
}
|
||||
|
||||
.nav-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, var(--border-dim), transparent);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--text-secondary);
|
||||
background: rgba(0, 245, 212, 0.04);
|
||||
border-color: var(--border-dim);
|
||||
}
|
||||
|
||||
.nav-link:hover .nav-indicator {
|
||||
border-color: var(--accent-cyan);
|
||||
background: rgba(0,245,212,0.2);
|
||||
}
|
||||
|
||||
.nav-link:hover .nav-line { opacity: 1; }
|
||||
|
||||
.nav-link.active {
|
||||
color: var(--accent-cyan);
|
||||
background: var(--accent-cyan-dim);
|
||||
border-color: rgba(0, 245, 212, 0.2);
|
||||
}
|
||||
|
||||
.nav-link.active::before { opacity: 1; }
|
||||
|
||||
.nav-link.active .nav-indicator {
|
||||
border-color: var(--accent-cyan);
|
||||
background: var(--accent-cyan);
|
||||
box-shadow: 0 0 8px var(--accent-cyan);
|
||||
}
|
||||
|
||||
.nav-link.active .nav-icon {
|
||||
filter: drop-shadow(0 0 4px var(--accent-cyan));
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.nav-link.active .nav-label {
|
||||
font-weight: 600;
|
||||
text-shadow: 0 0 10px var(--accent-cyan-glow);
|
||||
}
|
||||
|
||||
.nav-link.active .nav-line { opacity: 1; }
|
||||
|
||||
/* Footer */
|
||||
.nav-footer {
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--border-dim);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--accent-cyan-dim), var(--accent-purple-dim));
|
||||
border: 1px solid var(--border-mid);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--font-display);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-cyan);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.user-level {
|
||||
font-family: var(--font-display);
|
||||
font-size: 8px;
|
||||
letter-spacing: 0.15em;
|
||||
color: var(--accent-amber);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-dim);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
background: rgba(255, 71, 87, 0.08);
|
||||
border-color: rgba(255, 71, 87, 0.3);
|
||||
color: var(--accent-red);
|
||||
}
|
||||
</style>
|
||||
123
frontend/src/components/chat/EmojiPicker.vue
Normal file
123
frontend/src/components/chat/EmojiPicker.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
visible: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [emoji: string]
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const categories = [
|
||||
{ key: 'smile', name: '😀', label: '笑脸' },
|
||||
{ key: 'gesture', name: '👍', label: '手势' },
|
||||
{ key: 'object', name: '📦', label: '物品' },
|
||||
{ key: 'symbol', name: '💬', label: '符号' },
|
||||
]
|
||||
|
||||
const emojiData: Record<string, string[]> = {
|
||||
smile: ['😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '😉', '😌'],
|
||||
gesture: ['👍', '👎', '👌', '🤌', '🤏', '✌️', '🤞', '🖖', '🤙', '💪', '🙏', '👏'],
|
||||
object: ['📄', '📁', '🖼️', '📊', '📝', '💾', '📧', '🔗', '📌', '🔍', '💡', '⚡'],
|
||||
symbol: ['✅', '❌', '⚠️', '🔥', '💯', '🎯', '⭐', '✨', '💬', '🗨️', '❤️', '🧡'],
|
||||
}
|
||||
|
||||
const activeCategory = ref('smile')
|
||||
|
||||
function selectEmoji(emoji: string) {
|
||||
emit('select', emoji)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="emoji-picker">
|
||||
<div class="emoji-tabs">
|
||||
<button
|
||||
v-for="cat in categories"
|
||||
:key="cat.key"
|
||||
:class="{ active: activeCategory === cat.key }"
|
||||
@click="activeCategory = cat.key"
|
||||
:title="cat.label"
|
||||
>
|
||||
{{ cat.name }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="emoji-grid">
|
||||
<button
|
||||
v-for="emoji in emojiData[activeCategory]"
|
||||
:key="emoji"
|
||||
class="emoji-btn"
|
||||
@click="selectEmoji(emoji)"
|
||||
>
|
||||
{{ emoji }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.emoji-picker {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 0;
|
||||
margin-bottom: 8px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-mid);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 8px;
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.3);
|
||||
z-index: 100;
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
.emoji-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
}
|
||||
|
||||
.emoji-tabs button {
|
||||
flex: 1;
|
||||
padding: 6px;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.emoji-tabs button:hover {
|
||||
background: var(--accent-cyan-dim);
|
||||
}
|
||||
|
||||
.emoji-tabs button.active {
|
||||
background: var(--accent-cyan-dim);
|
||||
border-color: var(--border-mid);
|
||||
}
|
||||
|
||||
.emoji-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.emoji-btn {
|
||||
padding: 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.emoji-btn:hover {
|
||||
background: var(--accent-cyan-dim);
|
||||
transform: scale(1.2);
|
||||
}
|
||||
</style>
|
||||
95
frontend/src/components/chat/FileMessage.vue
Normal file
95
frontend/src/components/chat/FileMessage.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { FileText, Image, File } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
filename: string
|
||||
fileType: string
|
||||
fileSize: number
|
||||
}>()
|
||||
|
||||
const icon = computed(() => {
|
||||
if (props.fileType.startsWith('image/')) return Image
|
||||
if (props.fileType.includes('pdf') || props.fileType.includes('document')) return FileText
|
||||
return File
|
||||
})
|
||||
|
||||
const fileSizeDisplay = computed(() => {
|
||||
const size = props.fileSize
|
||||
if (size < 1024) return size + ' B'
|
||||
if (size < 1024 * 1024) return (size / 1024).toFixed(1) + ' KB'
|
||||
return (size / (1024 * 1024)).toFixed(1) + ' MB'
|
||||
})
|
||||
|
||||
const ext = computed(() => {
|
||||
const parts = props.filename.split('.')
|
||||
return parts.length > 1 ? parts.pop()?.toUpperCase() : ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="file-message">
|
||||
<div class="file-icon">
|
||||
<component :is="icon" :size="24" />
|
||||
<span v-if="ext" class="file-ext">{{ ext }}</span>
|
||||
</div>
|
||||
<div class="file-info">
|
||||
<span class="file-name">{{ filename }}</span>
|
||||
<span class="file-size">{{ fileSizeDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.file-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--accent-cyan-dim);
|
||||
border: 1px solid rgba(0, 245, 212, 0.2);
|
||||
border-radius: var(--radius-md);
|
||||
min-width: 200px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
position: relative;
|
||||
color: var(--accent-cyan);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-ext {
|
||||
position: absolute;
|
||||
bottom: -2px;
|
||||
right: -4px;
|
||||
font-size: 7px;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 700;
|
||||
color: var(--bg-void);
|
||||
background: var(--accent-cyan);
|
||||
padding: 1px 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-dim);
|
||||
}
|
||||
</style>
|
||||
74
frontend/src/components/stats/MetricCard.vue
Normal file
74
frontend/src/components/stats/MetricCard.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
icon: Component
|
||||
label: string
|
||||
value: string | number
|
||||
accentColor?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="metric-card" :style="{ '--card-accent': accentColor || 'var(--accent-cyan)' }">
|
||||
<div class="metric-icon">
|
||||
<component :is="icon" :size="16" />
|
||||
</div>
|
||||
<div class="metric-info">
|
||||
<span class="metric-value">{{ value }}</span>
|
||||
<span class="metric-label">{{ label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.metric-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition-mid);
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
border-color: var(--card-accent);
|
||||
box-shadow: 0 0 12px color-mix(in srgb, var(--card-accent) 30%, transparent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.metric-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--card-accent) 12%, transparent);
|
||||
color: var(--card-accent);
|
||||
}
|
||||
|
||||
.metric-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--card-accent);
|
||||
text-shadow: 0 0 10px color-mix(in srgb, var(--card-accent) 40%, transparent);
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
</style>
|
||||
62
frontend/src/components/stats/MiniBarChart.vue
Normal file
62
frontend/src/components/stats/MiniBarChart.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
data: number[]
|
||||
color?: string
|
||||
height?: number
|
||||
maxBars?: number
|
||||
}>(), {
|
||||
color: 'var(--accent-cyan)',
|
||||
height: 32,
|
||||
maxBars: 7
|
||||
})
|
||||
|
||||
const displayData = computed(() => {
|
||||
if (props.data.length <= props.maxBars) return props.data
|
||||
// 采样:取最后 N 个
|
||||
return props.data.slice(-props.maxBars)
|
||||
})
|
||||
|
||||
const maxValue = computed(() => Math.max(...displayData.value, 1))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mini-bar-chart" :style="{ '--chart-height': height + 'px', '--chart-color': color }">
|
||||
<div class="bars">
|
||||
<div
|
||||
v-for="(value, i) in displayData"
|
||||
:key="i"
|
||||
class="bar"
|
||||
:style="{ height: (value / maxValue * 100) + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mini-bar-chart {
|
||||
width: 100%;
|
||||
height: var(--chart-height);
|
||||
}
|
||||
|
||||
.bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bar {
|
||||
flex: 1;
|
||||
background: var(--chart-color);
|
||||
border-radius: 2px 2px 0 0;
|
||||
opacity: 0.7;
|
||||
transition: opacity var(--transition-fast);
|
||||
min-height: 2px;
|
||||
}
|
||||
|
||||
.bar:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
87
frontend/src/components/stats/MiniLineChart.vue
Normal file
87
frontend/src/components/stats/MiniLineChart.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
data: { date: string; value: number }[]
|
||||
color?: string
|
||||
height?: number
|
||||
maxPoints?: number
|
||||
}>(), {
|
||||
color: 'var(--accent-cyan)',
|
||||
height: 60,
|
||||
maxPoints: 30
|
||||
})
|
||||
|
||||
const displayData = computed(() => {
|
||||
if (props.data.length <= props.maxPoints) return props.data
|
||||
// 采样:均匀抽取
|
||||
const step = Math.ceil(props.data.length / props.maxPoints)
|
||||
return props.data.filter((_, i) => i % step === 0)
|
||||
})
|
||||
|
||||
const maxValue = computed(() => Math.max(...displayData.value.map(d => d.value), 1))
|
||||
|
||||
const points = computed(() => {
|
||||
return displayData.value.map((d, i) => {
|
||||
const x = (i / (displayData.value.length - 1)) * 100
|
||||
const y = 100 - (d.value / maxValue.value * 100)
|
||||
return `${x},${y}`
|
||||
}).join(' ')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mini-line-chart" :style="{ '--chart-height': height + 'px', '--chart-color': color }">
|
||||
<svg :viewBox="`0 0 100 100`" preserveAspectRatio="none" class="chart-svg">
|
||||
<!-- 背景网格 -->
|
||||
<line x1="0" y1="50" x2="100" y2="50" class="grid-line" />
|
||||
<!-- 折线 -->
|
||||
<polyline :points="points" class="line" />
|
||||
<!-- 数据点 -->
|
||||
<circle
|
||||
v-for="(d, i) in displayData"
|
||||
:key="i"
|
||||
:cx="(i / (displayData.length - 1)) * 100"
|
||||
:cy="100 - (d.value / maxValue * 100)"
|
||||
r="2"
|
||||
class="dot"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mini-line-chart {
|
||||
width: 100%;
|
||||
height: var(--chart-height);
|
||||
}
|
||||
|
||||
.chart-svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.grid-line {
|
||||
stroke: var(--border-dim);
|
||||
stroke-width: 0.5;
|
||||
}
|
||||
|
||||
.line {
|
||||
fill: none;
|
||||
stroke: var(--chart-color);
|
||||
stroke-width: 1.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.dot {
|
||||
fill: var(--chart-color);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.mini-line-chart:hover .dot {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
73
frontend/src/components/stats/SectionHeader.vue
Normal file
73
frontend/src/components/stats/SectionHeader.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string
|
||||
tag?: 'cyan' | 'purple' | 'amber'
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="section-header">
|
||||
<div class="section-title">
|
||||
<span class="section-slash">//</span>
|
||||
<span class="section-name">{{ title }}</span>
|
||||
</div>
|
||||
<span v-if="tag" class="section-tag" :class="tag">
|
||||
{{ title.split(' ')[0] }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 0 12px;
|
||||
border-bottom: 1px solid var(--border-dim);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.section-slash {
|
||||
color: var(--text-dim);
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.section-name {
|
||||
color: var(--text-primary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.section-tag {
|
||||
padding: 3px 10px;
|
||||
border-radius: 3px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.section-tag.cyan {
|
||||
background: var(--accent-cyan-dim);
|
||||
color: var(--accent-cyan);
|
||||
border: 1px solid rgba(0, 245, 212, 0.2);
|
||||
}
|
||||
|
||||
.section-tag.purple {
|
||||
background: var(--accent-purple-dim);
|
||||
color: var(--accent-purple);
|
||||
border: 1px solid rgba(123, 44, 191, 0.2);
|
||||
}
|
||||
|
||||
.section-tag.amber {
|
||||
background: var(--accent-amber-dim);
|
||||
color: var(--accent-amber);
|
||||
border: 1px solid rgba(249, 168, 37, 0.2);
|
||||
}
|
||||
</style>
|
||||
55
frontend/src/components/stats/SummaryRow.vue
Normal file
55
frontend/src/components/stats/SummaryRow.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
items: { label: string; value: string | number }[]
|
||||
columns?: number
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="summary-row" :style="{ '--cols': columns || 4 }">
|
||||
<div v-for="item in items" :key="item.label" class="summary-item">
|
||||
<span class="summary-value">{{ item.value }}</span>
|
||||
<span class="summary-label">{{ item.label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.summary-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--cols), 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.summary-item:hover {
|
||||
border-color: var(--border-mid);
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-cyan);
|
||||
text-shadow: 0 0 8px var(--accent-cyan-glow);
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user