first-update
This commit is contained in:
22
frontend/Dockerfile
Normal file
22
frontend/Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package.json .
|
||||
RUN npm install
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Build
|
||||
RUN npm run build
|
||||
|
||||
# Serve with nginx
|
||||
FROM nginx:alpine
|
||||
COPY --from=0 /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>YG-Dataset 数据生成平台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
20
frontend/nginx.conf
Normal file
20
frontend/nginx.conf
Normal file
@@ -0,0 +1,20 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
}
|
||||
2001
frontend/package-lock.json
generated
Normal file
2001
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
frontend/package.json
Normal file
24
frontend/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "yg-dataset-frontend",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.4.0",
|
||||
"pinia": "^2.2.0",
|
||||
"element-plus": "^2.8.0",
|
||||
"@element-plus/icons-vue": "^2.3.0",
|
||||
"axios": "^1.7.0",
|
||||
"@vueuse/core": "^11.0.0",
|
||||
"xlsx": "^0.18.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
338
frontend/src/App.vue
Normal file
338
frontend/src/App.vue
Normal file
@@ -0,0 +1,338 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<!-- Animated Background -->
|
||||
<div class="bg-mesh">
|
||||
<div class="mesh-gradient mesh-1"></div>
|
||||
<div class="mesh-gradient mesh-2"></div>
|
||||
<div class="mesh-gradient mesh-3"></div>
|
||||
<div class="noise-overlay"></div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="app-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="page" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
|
||||
const locale = ref(zhCn)
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* ========================
|
||||
Design System: Neural Flow
|
||||
======================== */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
|
||||
|
||||
:root {
|
||||
/* Core Colors - Deep Space */
|
||||
--bg-primary: #030407;
|
||||
--bg-secondary: #0a0a0f;
|
||||
--bg-tertiary: #12121a;
|
||||
--bg-elevated: #1a1a24;
|
||||
--bg-hover: #22222e;
|
||||
|
||||
/* Accent - Cyan Violet */
|
||||
--accent-primary: #00d4ff;
|
||||
--accent-primary-hover: #00b8dc;
|
||||
--accent-primary-muted: rgba(0, 212, 255, 0.12);
|
||||
--accent-secondary: #7c3aed;
|
||||
--accent-tertiary: #06b6d4;
|
||||
|
||||
/* Text */
|
||||
--text-primary: #f4f4f5;
|
||||
--text-secondary: #a1a1aa;
|
||||
--text-tertiary: #71717a;
|
||||
--text-muted: #52525b;
|
||||
|
||||
/* Status Colors */
|
||||
--success: #34d399;
|
||||
--success-muted: rgba(52, 211, 153, 0.12);
|
||||
--warning: #fbbf24;
|
||||
--warning-muted: rgba(251, 191, 36, 0.12);
|
||||
--danger: #f87171;
|
||||
--danger-muted: rgba(248, 113, 113, 0.12);
|
||||
|
||||
/* Borders */
|
||||
--border-subtle: rgba(255, 255, 255, 0.05);
|
||||
--border-default: rgba(255, 255, 255, 0.1);
|
||||
--border-strong: rgba(255, 255, 255, 0.15);
|
||||
|
||||
/* Effects */
|
||||
--glow-primary: 0 0 30px rgba(0, 212, 255, 0.25);
|
||||
--glow-secondary: 0 0 30px rgba(124, 58, 237, 0.25);
|
||||
--glass-bg: rgba(18, 18, 26, 0.6);
|
||||
--glass-border: rgba(255, 255, 255, 0.06);
|
||||
|
||||
/* Spacing */
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 10px;
|
||||
--radius-lg: 14px;
|
||||
--radius-xl: 20px;
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* Typography */
|
||||
--font-display: 'Outfit', sans-serif;
|
||||
--font-mono: 'JetBrains Mono', monospace;
|
||||
}
|
||||
|
||||
*, *::before, *::after {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html, body, #app {
|
||||
height: 100%;
|
||||
font-family: var(--font-display);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* ========================
|
||||
Background Mesh Animation
|
||||
======================== */
|
||||
.app-container {
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bg-mesh {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mesh-gradient {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(120px);
|
||||
opacity: 0.5;
|
||||
animation: float 25s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.mesh-1 {
|
||||
width: 700px;
|
||||
height: 700px;
|
||||
background: radial-gradient(circle, rgba(0, 212, 255, 0.35) 0%, transparent 70%);
|
||||
top: -250px;
|
||||
right: -150px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.mesh-2 {
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
background: radial-gradient(circle, rgba(124, 58, 237, 0.3) 0%, transparent 70%);
|
||||
bottom: -200px;
|
||||
left: -150px;
|
||||
animation-delay: -8s;
|
||||
}
|
||||
|
||||
.mesh-3 {
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: radial-gradient(circle, rgba(6, 182, 212, 0.2) 0%, transparent 70%);
|
||||
top: 40%;
|
||||
left: 30%;
|
||||
animation-delay: -16s;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
25% { transform: translate(40px, -40px) scale(1.08); }
|
||||
50% { transform: translate(-30px, 30px) scale(0.95); }
|
||||
75% { transform: translate(-40px, -25px) scale(1.03); }
|
||||
}
|
||||
|
||||
.noise-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
|
||||
opacity: 0.025;
|
||||
}
|
||||
|
||||
.app-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ========================
|
||||
Page Transitions
|
||||
======================== */
|
||||
.page-enter-active,
|
||||
.page-leave-active {
|
||||
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.page-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(15px) scale(0.98);
|
||||
}
|
||||
|
||||
.page-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-15px) scale(0.98);
|
||||
}
|
||||
|
||||
/* ========================
|
||||
Scrollbar
|
||||
======================== */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-default);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||
|
||||
/* ========================
|
||||
Element Plus Overrides
|
||||
======================== */
|
||||
.el-button--primary {
|
||||
--el-button-bg-color: var(--accent-primary);
|
||||
--el-button-border-color: var(--accent-primary);
|
||||
--el-button-text-color: #030407;
|
||||
--el-button-hover-bg-color: var(--accent-primary-hover);
|
||||
--el-button-hover-border-color: var(--accent-primary-hover);
|
||||
--el-button-hover-text-color: #030407;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.el-card {
|
||||
--el-card-bg-color: var(--bg-secondary);
|
||||
--el-card-border-color: var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.el-dialog {
|
||||
--el-dialog-bg-color: var(--bg-elevated);
|
||||
--el-dialog-border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.el-input__wrapper {
|
||||
background: var(--bg-tertiary) !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
box-shadow: none !important;
|
||||
border: 1px solid var(--border-subtle) !important;
|
||||
}
|
||||
|
||||
.el-input__wrapper:hover {
|
||||
border-color: var(--border-default) !important;
|
||||
}
|
||||
|
||||
.el-input__wrapper.is-focus {
|
||||
border-color: var(--accent-primary) !important;
|
||||
box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.1) !important;
|
||||
}
|
||||
|
||||
.el-select .el-input__wrapper {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.el-textarea__inner {
|
||||
background: var(--bg-tertiary) !important;
|
||||
border: 1px solid var(--border-subtle) !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
}
|
||||
|
||||
.el-textarea__inner:focus {
|
||||
border-color: var(--accent-primary) !important;
|
||||
box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.1) !important;
|
||||
}
|
||||
|
||||
.el-table {
|
||||
--el-table-bg-color: transparent;
|
||||
--el-table-tr-bg-color: transparent;
|
||||
--el-table-header-bg-color: var(--bg-tertiary);
|
||||
--el-table-row-hover-bg-color: var(--bg-hover);
|
||||
--el-table-border-color: var(--border-subtle);
|
||||
}
|
||||
|
||||
.el-tabs__item {
|
||||
color: var(--text-secondary) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.el-tabs__item.is-active {
|
||||
color: var(--accent-primary) !important;
|
||||
}
|
||||
|
||||
.el-tabs__active-bar {
|
||||
background-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.el-tag {
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.el-tag--dark {
|
||||
background: var(--accent-primary-muted) !important;
|
||||
border-color: transparent !important;
|
||||
color: var(--accent-primary) !important;
|
||||
}
|
||||
|
||||
.el-message {
|
||||
--el-message-bg-color: var(--bg-elevated);
|
||||
--el-message-border-color: var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.el-popconfirm__main {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.el-form-item__label {
|
||||
color: var(--text-secondary) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ========================
|
||||
Utility Classes
|
||||
======================== */
|
||||
.glass {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--glass-border);
|
||||
}
|
||||
|
||||
.glow-text {
|
||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.hover-lift {
|
||||
transition: transform var(--transition-base), box-shadow var(--transition-base);
|
||||
}
|
||||
|
||||
.hover-lift:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--glow-primary);
|
||||
}
|
||||
|
||||
/* Selection */
|
||||
::selection {
|
||||
background: var(--accent-primary-muted);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
81
frontend/src/api/index.js
Normal file
81
frontend/src/api/index.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const request = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 60000
|
||||
})
|
||||
|
||||
// Request interceptor
|
||||
request.interceptors.request.use(
|
||||
config => {
|
||||
return config
|
||||
},
|
||||
error => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// Response interceptor
|
||||
request.interceptors.response.use(
|
||||
response => {
|
||||
return response.data
|
||||
},
|
||||
error => {
|
||||
const message = error.response?.data?.message || error.message || '请求失败'
|
||||
console.error('API Error:', message)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export const projectApi = {
|
||||
list: () => request.get('/projects/'),
|
||||
get: (id) => request.get(`/projects/${id}`),
|
||||
create: (data) => request.post('/projects/', data),
|
||||
update: (id, data) => request.put(`/projects/${id}`, data),
|
||||
delete: (id) => request.delete(`/projects/${id}`)
|
||||
}
|
||||
|
||||
export const fileApi = {
|
||||
upload: (projectId, formData) =>
|
||||
request.post(`/projects/${projectId}/files/upload`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
}),
|
||||
list: (projectId) => request.get(`/projects/${projectId}/files/`),
|
||||
get: (projectId, fileId) => request.get(`/projects/${projectId}/files/${fileId}`),
|
||||
delete: (projectId, fileId) => request.delete(`/projects/${projectId}/files/${fileId}`)
|
||||
}
|
||||
|
||||
export const chunkApi = {
|
||||
split: (projectId, data) => request.post(`/projects/${projectId}/chunks/split`, data),
|
||||
list: (projectId, params) => request.get(`/projects/${projectId}/chunks/`, { params }),
|
||||
get: (projectId, chunkId) => request.get(`/projects/${projectId}/chunks/${chunkId}`),
|
||||
update: (projectId, chunkId, data) => request.put(`/projects/${projectId}/chunks/${chunkId}`, data),
|
||||
delete: (projectId, chunkId) => request.delete(`/projects/${projectId}/chunks/${chunkId}`)
|
||||
}
|
||||
|
||||
export const questionApi = {
|
||||
generate: (projectId, data) => request.post(`/projects/${projectId}/generate-questions`, data),
|
||||
list: (projectId, params) => request.get(`/projects/${projectId}/chunks/${params.chunkId}/questions`),
|
||||
update: (projectId, questionId, data) => request.put(`/projects/${projectId}/questions/${questionId}`, data),
|
||||
delete: (projectId, questionId) => request.delete(`/projects/${projectId}/questions/${questionId}`)
|
||||
}
|
||||
|
||||
export const datasetApi = {
|
||||
list: (projectId) => request.get(`/projects/${projectId}/datasets/`),
|
||||
create: (projectId, data) => request.post(`/projects/${projectId}/datasets/`, data),
|
||||
get: (projectId, datasetId) => request.get(`/projects/${projectId}/datasets/${datasetId}`),
|
||||
delete: (projectId, datasetId) => request.delete(`/projects/${projectId}/datasets/${datasetId}`),
|
||||
export: (projectId, datasetId, data) =>
|
||||
request.post(`/projects/${projectId}/datasets/${datasetId}/export`, data, {
|
||||
responseType: 'blob'
|
||||
})
|
||||
}
|
||||
|
||||
export const evalApi = {
|
||||
list: (projectId) => request.get(`/projects/${projectId}/eval-datasets/`),
|
||||
create: (projectId, data) => request.post(`/projects/${projectId}/eval-datasets/`, data),
|
||||
run: (projectId, evalId) => request.post(`/projects/${projectId}/eval-datasets/${evalId}/evaluate`),
|
||||
getResults: (projectId, taskId) => request.get(`/projects/${projectId}/eval-tasks/${taskId}`)
|
||||
}
|
||||
|
||||
export default request
|
||||
21
frontend/src/main.js
Normal file
21
frontend/src/main.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// Register all Element Plus icons
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
|
||||
app.mount('#app')
|
||||
67
frontend/src/router/index.js
Normal file
67
frontend/src/router/index.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: () => import('@/views/HomeView.vue')
|
||||
},
|
||||
{
|
||||
path: '/project/:id',
|
||||
name: 'Project',
|
||||
component: () => import('@/views/ProjectView.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirect: to => `/project/${to.params.id}/files`
|
||||
},
|
||||
{
|
||||
path: 'files',
|
||||
name: 'ProjectFiles',
|
||||
component: () => import('@/views/project/FileManage.vue')
|
||||
},
|
||||
{
|
||||
path: 'split',
|
||||
name: 'ProjectSplit',
|
||||
component: () => import('@/views/project/TextSplit.vue')
|
||||
},
|
||||
{
|
||||
path: 'questions',
|
||||
name: 'ProjectQuestions',
|
||||
component: () => import('@/views/project/QuestionManage.vue')
|
||||
},
|
||||
{
|
||||
path: 'datasets',
|
||||
name: 'ProjectDatasets',
|
||||
component: () => import('@/views/project/DatasetManage.vue')
|
||||
},
|
||||
{
|
||||
path: 'eval',
|
||||
name: 'ProjectEval',
|
||||
component: () => import('@/views/project/EvalManage.vue')
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'ProjectSettings',
|
||||
component: () => import('@/views/project/Settings.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/playground',
|
||||
name: 'Playground',
|
||||
component: () => import('@/views/PlaygroundView.vue')
|
||||
},
|
||||
{
|
||||
path: '/data-square',
|
||||
name: 'DataSquare',
|
||||
component: () => import('@/views/DataSquareView.vue')
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
export default router
|
||||
397
frontend/src/views/DataSquareView.vue
Normal file
397
frontend/src/views/DataSquareView.vue
Normal file
@@ -0,0 +1,397 @@
|
||||
<template>
|
||||
<div class="data-square">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<div class="header-left" @click="goHome">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
<span>返回</span>
|
||||
</div>
|
||||
<div class="header-title">
|
||||
<h1>数据集广场</h1>
|
||||
<p>发现和分享高质量数据集</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main -->
|
||||
<main class="main">
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<div class="search-box">
|
||||
<el-icon class="search-icon"><Search /></el-icon>
|
||||
<el-input v-model="searchQuery" placeholder="搜索数据集..." clearable class="search-input" />
|
||||
</div>
|
||||
<el-select v-model="filterType" placeholder="类型" clearable class="type-select">
|
||||
<el-option label="全部类型" value="" />
|
||||
<el-option label="问答对" value="qa" />
|
||||
<el-option label="多轮对话" value="conversation" />
|
||||
<el-option label="指令跟随" value="instruction" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<!-- Stats Bar -->
|
||||
<div class="stats-bar">
|
||||
<span class="stat">
|
||||
<strong>{{ datasets.length }}</strong> 个数据集可用
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<div class="dataset-grid" v-loading="loading">
|
||||
<div v-if="!loading && datasets.length === 0" class="empty-state">
|
||||
<el-icon size="56"><Collection /></el-icon>
|
||||
<h3>未找到数据集</h3>
|
||||
<p>请尝试调整搜索条件或筛选器</p>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
v-for="(dataset, index) in datasets"
|
||||
:key="dataset.id"
|
||||
class="dataset-card"
|
||||
:style="{ '--delay': index * 0.08 + 's' }"
|
||||
>
|
||||
<div class="card-cover">
|
||||
<div class="cover-gradient"></div>
|
||||
<div class="cover-pattern"></div>
|
||||
<el-icon size="40" class="cover-icon"><Collection /></el-icon>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">{{ dataset.name }}</h3>
|
||||
<el-tag size="small" effect="dark">{{ dataset.type || '问答' }}</el-tag>
|
||||
</div>
|
||||
<p class="card-desc">{{ dataset.description }}</p>
|
||||
<div class="card-stats">
|
||||
<div class="stat-item">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>{{ dataset.question_count }} 条</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<el-icon><User /></el-icon>
|
||||
<span>{{ dataset.author }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-tags">
|
||||
<el-tag v-for="tag in dataset.tags" :key="tag" size="small" effect="plain">{{ tag }}</el-tag>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<span class="card-date">{{ formatDate(dataset.created_at) }}</span>
|
||||
<div class="card-actions">
|
||||
<el-button type="primary" size="small" @click="viewDataset(dataset)">
|
||||
<el-icon><View /></el-icon>
|
||||
查看
|
||||
</el-button>
|
||||
<el-button size="small" @click="downloadDataset(dataset)">
|
||||
<el-icon><Download /></el-icon>
|
||||
下载
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const filterType = ref('')
|
||||
const datasets = ref([])
|
||||
|
||||
const goHome = () => router.push('/')
|
||||
|
||||
const fetchDatasets = async () => {
|
||||
loading.value = true
|
||||
setTimeout(() => {
|
||||
datasets.value = [
|
||||
{ id: '1', name: '通用知识问答', description: '涵盖科学、历史和地理的综合问答数据集', question_count: 10000, author: '数据集团队', created_at: '2024-01-15', tags: ['知识', '通用'], type: '问答' },
|
||||
{ id: '2', name: '代码理解', description: '多语言代码理解和解释数据集', question_count: 5000, author: 'Code AI', created_at: '2024-02-20', tags: ['代码', '编程'], type: '指令' },
|
||||
{ id: '3', name: '数学推理', description: '包含详细步骤解答的数学问题集', question_count: 8000, author: '数学实验室', created_at: '2024-03-10', tags: ['数学', '推理'], type: '问答' },
|
||||
{ id: '4', name: '医学知识问答', description: '专业医学领域问答数据集', question_count: 3500, author: '医学 AI', created_at: '2024-04-05', tags: ['医学', '专业'], type: '问答' }
|
||||
]
|
||||
loading.value = false
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const viewDataset = (d) => ElMessage.info('查看中: ' + d.name)
|
||||
const downloadDataset = (d) => ElMessage.success('下载中: ' + d.name)
|
||||
const formatDate = (d) => d ? d.split('-').slice(1).join('/') : ''
|
||||
|
||||
onMounted(() => fetchDatasets())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.data-square { min-height: 100vh; }
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.header-left:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-title h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.header-title p {
|
||||
font-size: 14px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Main */
|
||||
.main {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 24px;
|
||||
}
|
||||
|
||||
/* Filters */
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-muted);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.search-input :deep(.el-input__wrapper) {
|
||||
padding-left: 40px;
|
||||
}
|
||||
|
||||
.type-select {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
/* Stats Bar */
|
||||
.stats-bar {
|
||||
margin-bottom: 24px;
|
||||
font-size: 14px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.stats-bar strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Dataset Grid */
|
||||
.dataset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.dataset-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
transition: all var(--transition-base);
|
||||
animation: cardIn 0.5s ease backwards;
|
||||
animation-delay: var(--delay);
|
||||
}
|
||||
|
||||
@keyframes cardIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.dataset-card:hover {
|
||||
border-color: var(--accent-primary);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.card-cover {
|
||||
height: 120px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cover-gradient {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(135deg, #22d3ee 0%, #7c3aed 50%, #06b6d4 100%);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.cover-pattern {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: radial-gradient(circle at 25% 25%, rgba(255,255,255,0.1) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.cover-icon {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: white;
|
||||
filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3));
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 14px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-stats {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card-tags {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.card-date {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 80px 0;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.empty-state .el-icon {
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.filters {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.type-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dataset-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1239
frontend/src/views/HomeView.vue
Normal file
1239
frontend/src/views/HomeView.vue
Normal file
File diff suppressed because it is too large
Load Diff
415
frontend/src/views/PlaygroundView.vue
Normal file
415
frontend/src/views/PlaygroundView.vue
Normal file
@@ -0,0 +1,415 @@
|
||||
<template>
|
||||
<div class="playground">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="header-content">
|
||||
<div class="header-left" @click="goHome">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
<span>返回</span>
|
||||
</div>
|
||||
<div class="header-title">
|
||||
<h1>测试场</h1>
|
||||
<p>交互式测试和评估 AI 模型</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main -->
|
||||
<div class="main">
|
||||
<!-- Config Panel -->
|
||||
<div class="config-panel">
|
||||
<div class="config-content">
|
||||
<div class="config-item">
|
||||
<label>模型</label>
|
||||
<el-select v-model="config.model" class="model-select">
|
||||
<el-option label="GPT-4o" value="gpt-4o">
|
||||
<span class="model-option">
|
||||
<span class="model-name">GPT-4o</span>
|
||||
<span class="model-provider">OpenAI</span>
|
||||
</span>
|
||||
</el-option>
|
||||
<el-option label="GPT-4o-mini" value="gpt-4o-mini">
|
||||
<span class="model-option">
|
||||
<span class="model-name">GPT-4o Mini</span>
|
||||
<span class="model-provider">OpenAI</span>
|
||||
</span>
|
||||
</el-option>
|
||||
<el-option label="Claude-3.5" value="claude-3.5">
|
||||
<span class="model-option">
|
||||
<span class="model-name">Claude 3.5</span>
|
||||
<span class="model-provider">Anthropic</span>
|
||||
</span>
|
||||
</el-option>
|
||||
<el-option label="Gemini Pro" value="gemini-pro">
|
||||
<span class="model-option">
|
||||
<span class="model-name">Gemini Pro</span>
|
||||
<span class="model-provider">Google</span>
|
||||
</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="config-item">
|
||||
<label>Temperature</label>
|
||||
<div class="slider-wrap">
|
||||
<el-slider v-model="config.temperature" :min="0" :max="2" :step="0.1" />
|
||||
<span class="slider-value">{{ config.temperature }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Area -->
|
||||
<div class="chat-container">
|
||||
<div class="messages" ref="messagesRef">
|
||||
<div
|
||||
v-for="(msg, index) in messages"
|
||||
:key="index"
|
||||
class="message"
|
||||
:class="msg.role"
|
||||
>
|
||||
<div class="message-avatar">
|
||||
<el-icon v-if="msg.role === 'user'"><User /></el-icon>
|
||||
<el-icon v-else><Robot /></el-icon>
|
||||
</div>
|
||||
<div class="message-bubble">
|
||||
<div class="message-text" v-html="formatMessage(msg.content)"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loading" class="message assistant">
|
||||
<div class="message-avatar">
|
||||
<el-icon><Robot /></el-icon>
|
||||
</div>
|
||||
<div class="message-bubble">
|
||||
<div class="loading-dots">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<div class="input-area">
|
||||
<el-input
|
||||
v-model="userInput"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="输入消息... (Ctrl+Enter 发送)"
|
||||
resize="none"
|
||||
@keydown.enter.ctrl="sendMessage"
|
||||
/>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="sendMessage"
|
||||
:loading="loading"
|
||||
:disabled="!userInput.trim()"
|
||||
class="send-btn"
|
||||
>
|
||||
<el-icon><Promotion /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const messages = ref([
|
||||
{ role: 'assistant', content: '你好!我是你的 AI 测试助手。\n\n从顶部选择一个模型,然后向我提问。' }
|
||||
])
|
||||
|
||||
const userInput = ref('')
|
||||
const loading = ref(false)
|
||||
const messagesRef = ref(null)
|
||||
|
||||
const config = reactive({ model: 'gpt-4o-mini', temperature: 0.7 })
|
||||
|
||||
const goHome = () => router.push('/')
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!userInput.value.trim() || loading.value) return
|
||||
const userMessage = userInput.value.trim()
|
||||
userInput.value = ''
|
||||
loading.value = true
|
||||
messages.value.push({ role: 'user', content: userMessage })
|
||||
await nextTick()
|
||||
scrollToBottom()
|
||||
setTimeout(() => {
|
||||
messages.value.push({ role: 'assistant', content: '这是一个演示回复。\n\n在实际生产环境中,这会调用你选择的模型 API。' })
|
||||
loading.value = false
|
||||
nextTick(scrollToBottom)
|
||||
}, 1200)
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (messagesRef.value) messagesRef.value.scrollTop = messagesRef.value.scrollHeight
|
||||
}
|
||||
|
||||
const formatMessage = (content) => content.replace(/\n/g, '<br>')
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.playground {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
padding: 16px 24px;
|
||||
}
|
||||
|
||||
.header-content {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
padding: 8px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.header-left:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.header-title h1 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.header-title p {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Main */
|
||||
.main {
|
||||
flex: 1;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* Config Panel */
|
||||
.config-panel {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.config-content {
|
||||
display: flex;
|
||||
gap: 32px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.config-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.config-item label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.model-select {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.model-option {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.model-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.model-provider {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.slider-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.slider-wrap .el-slider {
|
||||
width: 140px;
|
||||
}
|
||||
|
||||
.slider-value {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
/* Chat Container */
|
||||
.chat-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-subtle);
|
||||
overflow: hidden;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
margin-bottom: 24px;
|
||||
animation: messageIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes messageIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
}
|
||||
|
||||
.message.user {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.message.assistant .message-avatar {
|
||||
background: var(--accent-primary-muted);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.message.user .message-avatar {
|
||||
background: var(--success-muted);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
max-width: 70%;
|
||||
padding: 16px 20px;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.message.assistant .message-bubble {
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
|
||||
.message.user .message-bubble {
|
||||
background: var(--accent-primary-muted);
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Loading */
|
||||
.loading-dots {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.loading-dots span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-primary);
|
||||
animation: bounce 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.loading-dots span:nth-child(1) { animation-delay: -0.32s; }
|
||||
.loading-dots span:nth-child(2) { animation-delay: -0.16s; }
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% { transform: scale(0); }
|
||||
40% { transform: scale(1); }
|
||||
}
|
||||
|
||||
/* Input Area */
|
||||
.input-area {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.input-area .el-textarea {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.config-content {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
max-width: 85%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
363
frontend/src/views/ProjectView.vue
Normal file
363
frontend/src/views/ProjectView.vue
Normal file
@@ -0,0 +1,363 @@
|
||||
<template>
|
||||
<div class="project-layout">
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar" :class="{ collapsed: sidebarCollapsed }">
|
||||
<div class="sidebar-header">
|
||||
<div class="project-info" @click="goHome">
|
||||
<div class="back-btn">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
</div>
|
||||
<div class="project-details">
|
||||
<h2 class="project-name">{{ project.name }}</h2>
|
||||
<p class="project-desc">{{ project.description || '数据生成项目' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="collapse-btn" @click="sidebarCollapsed = !sidebarCollapsed">
|
||||
<el-icon><DArrowLeft v-if="!sidebarCollapsed" /><DArrowRight v-else /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<router-link
|
||||
v-for="item in navItems"
|
||||
:key="item.path"
|
||||
:to="`/project/${id}/${item.path}`"
|
||||
class="nav-item"
|
||||
:class="{ active: isActive(item.path) }"
|
||||
>
|
||||
<div class="nav-icon">
|
||||
<el-icon size="20"><component :is="item.icon" /></el-icon>
|
||||
</div>
|
||||
<span class="nav-label">{{ item.label }}</span>
|
||||
<span class="nav-dot"></span>
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<router-link to="/" class="home-link">
|
||||
<el-icon><HomeFilled /></el-icon>
|
||||
<span>返回首页</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { projectApi } from '@/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const id = computed(() => route.params.id)
|
||||
const sidebarCollapsed = ref(false)
|
||||
|
||||
const project = ref({ name: '加载中...', description: '' })
|
||||
|
||||
const navItems = [
|
||||
{ path: 'files', label: '文件管理', icon: 'Folder' },
|
||||
{ path: 'split', label: '文本分割', icon: 'Operation' },
|
||||
{ path: 'questions', label: '问答管理', icon: 'ChatDotSquare' },
|
||||
{ path: 'datasets', label: '数据集', icon: 'Collection' },
|
||||
{ path: 'eval', label: '评估系统', icon: 'DataAnalysis' },
|
||||
{ path: 'settings', label: '项目设置', icon: 'Setting' }
|
||||
]
|
||||
|
||||
const isActive = (path) => route.path.includes(path)
|
||||
|
||||
const fetchProject = async () => {
|
||||
try {
|
||||
const res = await projectApi.get(id.value)
|
||||
project.value = res.data
|
||||
} catch (error) {
|
||||
ElMessage.error('加载项目失败')
|
||||
}
|
||||
}
|
||||
|
||||
const goHome = () => router.push('/')
|
||||
|
||||
onMounted(() => fetchProject())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.project-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ========================
|
||||
Sidebar
|
||||
======================== */
|
||||
.sidebar {
|
||||
width: 280px;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 72px;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
position: absolute;
|
||||
right: -12px;
|
||||
top: 28px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 50%;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.collapse-btn:hover {
|
||||
background: var(--accent-primary-muted);
|
||||
color: var(--accent-primary);
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.project-info {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.project-info:hover .back-btn {
|
||||
background: var(--accent-primary-muted);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.project-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .project-details {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.project-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 16px 12px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all var(--transition-fast);
|
||||
margin-bottom: 4px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--accent-primary-muted);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-item:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.nav-item.active::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-label {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
white-space: nowrap;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-label {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.nav-dot {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-primary);
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.sidebar.collapsed .nav-dot {
|
||||
right: 50%;
|
||||
transform: translateX(50%);
|
||||
}
|
||||
|
||||
.nav-item.active .nav-dot {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Sidebar Footer */
|
||||
.sidebar-footer {
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.home-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 14px;
|
||||
text-decoration: none;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.home-link:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.home-link span {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .home-link span {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-left: 280px;
|
||||
min-height: 100vh;
|
||||
transition: margin-left 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.sidebar.collapsed + .main-content,
|
||||
.sidebar.collapsed ~ .main-content {
|
||||
margin-left: 72px;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
width: 72px;
|
||||
}
|
||||
|
||||
.sidebar .project-details,
|
||||
.sidebar .nav-label,
|
||||
.sidebar .home-link span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 72px;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
445
frontend/src/views/project/DatasetManage.vue
Normal file
445
frontend/src/views/project/DatasetManage.vue
Normal file
@@ -0,0 +1,445 @@
|
||||
<template>
|
||||
<div class="dataset-manage">
|
||||
<!-- Page Header -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>数据集管理</h2>
|
||||
<p class="header-desc">创建和管理训练数据集</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="showCreateDialog = true" class="create-btn">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新建数据集
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<el-tabs v-model="activeTab" class="dataset-tabs">
|
||||
<el-tab-pane label="训练数据集" name="train">
|
||||
<div class="dataset-list" v-loading="loading">
|
||||
<div v-if="!loading && datasets.length === 0" class="empty-state">
|
||||
<el-icon size="48"><Collection /></el-icon>
|
||||
<h3>暂无数据集</h3>
|
||||
<p>创建您的第一个数据集开始使用</p>
|
||||
<el-button type="primary" @click="showCreateDialog = true">新建数据集</el-button>
|
||||
</div>
|
||||
|
||||
<div v-else class="datasets-grid">
|
||||
<div
|
||||
v-for="(dataset, index) in datasets"
|
||||
:key="dataset.id"
|
||||
class="dataset-card"
|
||||
:style="{ '--delay': index * 0.08 + 's' }"
|
||||
>
|
||||
<div class="dataset-header">
|
||||
<div class="dataset-icon">
|
||||
<el-icon size="28"><Collection /></el-icon>
|
||||
</div>
|
||||
<el-dropdown trigger="click">
|
||||
<el-button text size="small" class="menu-btn">
|
||||
<el-icon><MoreFilled /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item @click="handleExport(dataset)">导出</el-dropdown-item>
|
||||
<el-dropdown-item @click="handleDuplicate(dataset)">复制</el-dropdown-item>
|
||||
<el-dropdown-item divided @click="handleDelete(dataset)">删除</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
<h3 class="dataset-name">{{ dataset.name }}</h3>
|
||||
<p class="dataset-desc">{{ dataset.description || '暂无描述' }}</p>
|
||||
<div class="dataset-meta">
|
||||
<el-tag size="small" effect="dark">{{ getTypeName(dataset.dataset_type) }}</el-tag>
|
||||
<span class="meta-count">{{ dataset.question_count || 0 }} 条</span>
|
||||
</div>
|
||||
<div class="dataset-footer">
|
||||
<span class="meta-date">{{ formatDate(dataset.created_at) }}</span>
|
||||
<el-button type="primary" size="small" @click="handleExport(dataset)">
|
||||
导出
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="图片数据集" name="image">
|
||||
<div class="empty-state">
|
||||
<el-icon size="48"><Picture /></el-icon>
|
||||
<h3>即将上线</h3>
|
||||
<p>图片数据集功能正在开发中</p>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- Create Dialog -->
|
||||
<el-dialog v-model="showCreateDialog" title="新建数据集" width="520px" class="create-dialog">
|
||||
<el-form :model="form" :rules="rules" ref="formRef" label-position="top">
|
||||
<el-form-item label="数据集名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="输入数据集名称" size="large" maxlength="50" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" placeholder="可选描述" maxlength="200" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="数据集类型">
|
||||
<el-radio-group v-model="form.dataset_type">
|
||||
<el-radio-button label="qa">问答对</el-radio-button>
|
||||
<el-radio-button label="conversation">多轮对话</el-radio-button>
|
||||
<el-radio-button label="instruction">指令跟随</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showCreateDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleCreate" :loading="creating">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Export Dialog -->
|
||||
<el-dialog v-model="showExportDialog" title="导出数据集" width="480px" class="export-dialog">
|
||||
<div class="export-options">
|
||||
<div
|
||||
v-for="option in exportOptions"
|
||||
:key="option.value"
|
||||
class="export-option"
|
||||
:class="{ active: exportFormat === option.value }"
|
||||
@click="exportFormat = option.value"
|
||||
>
|
||||
<div class="option-icon" :style="{ background: option.color }">
|
||||
<el-icon size="20"><component :is="option.icon" /></el-icon>
|
||||
</div>
|
||||
<div class="option-info">
|
||||
<span class="option-name">{{ option.label }}</span>
|
||||
<span class="option-desc">{{ option.desc }}</span>
|
||||
</div>
|
||||
<el-icon v-if="exportFormat === option.value" class="check-icon"><CircleCheck /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="showExportDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="confirmExport">导出</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { datasetApi } from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => route.params.id)
|
||||
|
||||
const loading = ref(false)
|
||||
const creating = ref(false)
|
||||
const datasets = ref([])
|
||||
const activeTab = ref('train')
|
||||
const showCreateDialog = ref(false)
|
||||
const showExportDialog = ref(false)
|
||||
const selectedDataset = ref(null)
|
||||
const exportFormat = ref('alpaca')
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
dataset_type: 'qa'
|
||||
})
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入数据集名称', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const exportOptions = [
|
||||
{ value: 'alpaca', label: 'Alpaca', desc: 'Alpaca 格式', icon: 'Document', color: '#6366f1' },
|
||||
{ value: 'sharegpt', label: 'ShareGPT', desc: 'ShareGPT 格式', icon: 'ChatDotSquare', color: '#10b981' },
|
||||
{ value: 'llama_factory', label: 'LLaMA Factory', desc: 'LLaMA Factory 格式', icon: 'Setting', color: '#f59e0b' },
|
||||
{ value: 'json', label: 'JSON', desc: '原生 JSON 格式', icon: 'List', color: '#3b82f6' }
|
||||
]
|
||||
|
||||
const fetchDatasets = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await datasetApi.list(projectId.value)
|
||||
datasets.value = res.data.datasets || []
|
||||
} catch (error) {
|
||||
datasets.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
creating.value = true
|
||||
try {
|
||||
await datasetApi.create(projectId.value, form)
|
||||
ElMessage.success('创建成功')
|
||||
showCreateDialog.value = false
|
||||
fetchDatasets()
|
||||
} catch (error) {
|
||||
ElMessage.error('创建失败')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleExport = (dataset) => {
|
||||
selectedDataset.value = dataset
|
||||
exportFormat.value = 'alpaca'
|
||||
showExportDialog.value = true
|
||||
}
|
||||
|
||||
const confirmExport = async () => {
|
||||
try {
|
||||
const res = await datasetApi.export(projectId.value, selectedDataset.value.id, { format: exportFormat.value })
|
||||
const blob = new Blob([JSON.stringify(res.data || res, null, 2)], { type: 'application/json' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `${selectedDataset.value.name}.${exportFormat.value === 'alpaca' ? 'json' : exportFormat.value}`
|
||||
link.click()
|
||||
window.URL.revokeObjectURL(url)
|
||||
ElMessage.success('导出成功')
|
||||
showExportDialog.value = false
|
||||
} catch (error) {
|
||||
ElMessage.error('导出失败')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDuplicate = (dataset) => {
|
||||
ElMessage.info('复制功能开发中')
|
||||
}
|
||||
|
||||
const handleDelete = async (dataset) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除此数据集?', '确认', { type: 'warning' })
|
||||
await datasetApi.delete(projectId.value, dataset.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchDatasets()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const getTypeName = (type) => {
|
||||
const map = { qa: '问答对', conversation: '多轮对话', instruction: '指令' }
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return ''
|
||||
const d = new Date(date)
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`
|
||||
}
|
||||
|
||||
onMounted(() => fetchDatasets())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dataset-manage {
|
||||
padding: 32px;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.header-left h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.header-desc {
|
||||
font-size: 14px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
padding: 10px 20px;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.dataset-tabs {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Dataset Grid */
|
||||
.datasets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dataset-card {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
transition: all var(--transition-base);
|
||||
animation: cardIn 0.4s ease backwards;
|
||||
animation-delay: var(--delay);
|
||||
}
|
||||
|
||||
@keyframes cardIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(15px);
|
||||
}
|
||||
}
|
||||
|
||||
.dataset-card:hover {
|
||||
border-color: var(--accent-primary);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.dataset-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dataset-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--accent-primary-muted);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.menu-btn {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dataset-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dataset-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dataset-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.meta-count {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.dataset-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.meta-date {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.empty-state .el-icon {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Export Options */
|
||||
.export-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.export-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.export-option:hover {
|
||||
border-color: var(--border-default);
|
||||
}
|
||||
|
||||
.export-option.active {
|
||||
border-color: var(--accent-primary);
|
||||
background: var(--accent-primary-muted);
|
||||
}
|
||||
|
||||
.option-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-md);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.option-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.option-name {
|
||||
font-weight: 600;
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.option-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
</style>
|
||||
405
frontend/src/views/project/EvalManage.vue
Normal file
405
frontend/src/views/project/EvalManage.vue
Normal file
@@ -0,0 +1,405 @@
|
||||
<template>
|
||||
<div class="eval-manage">
|
||||
<!-- Page Header -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>评估管理</h2>
|
||||
<p class="header-desc">评估数据集质量和模型性能</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="showEvalDialog = true" class="create-btn">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新建评估
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<el-tabs v-model="activeTab" class="eval-tabs">
|
||||
<el-tab-pane label="评估数据集" name="eval">
|
||||
<div class="eval-list" v-loading="loading">
|
||||
<div v-if="!loading && evalDatasets.length === 0" class="empty-state">
|
||||
<el-icon size="48"><DataAnalysis /></el-icon>
|
||||
<h3>暂无评估数据集</h3>
|
||||
<p>创建您的第一个评估数据集</p>
|
||||
<el-button type="primary" @click="showEvalDialog = true">新建评估</el-button>
|
||||
</div>
|
||||
|
||||
<div v-else class="evals-grid">
|
||||
<div v-for="(evalSet, index) in evalDatasets" :key="evalSet.id" class="eval-card" :style="{ '--delay': index * 0.08 + 's' }">
|
||||
<div class="eval-header">
|
||||
<div class="eval-icon">
|
||||
<el-icon size="24"><DataLine /></el-icon>
|
||||
</div>
|
||||
<div class="eval-actions">
|
||||
<el-button type="primary" size="small" @click="runEval(evalSet)">运行</el-button>
|
||||
<el-button size="small" @click="viewResults(evalSet)">结果</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="eval-name">{{ evalSet.name }}</h3>
|
||||
<div class="eval-meta">
|
||||
<el-tag size="small" effect="dark">{{ getTypeName(evalSet.question_type) }}</el-tag>
|
||||
<span class="meta-count">{{ evalSet.question_count || 0 }} 题</span>
|
||||
</div>
|
||||
<span class="eval-date">{{ formatDate(evalSet.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="盲测系统" name="blind-test">
|
||||
<div class="blind-test" v-loading="loading">
|
||||
<div v-if="!loading && blindTasks.length === 0" class="empty-state">
|
||||
<el-icon size="48"><Aim /></el-icon>
|
||||
<h3>暂无盲测任务</h3>
|
||||
<p>创建盲测来对比模型表现</p>
|
||||
<el-button type="primary" @click="showBlindTestDialog = true">创建测试</el-button>
|
||||
</div>
|
||||
|
||||
<div v-else class="tasks-grid">
|
||||
<div v-for="(task, index) in blindTasks" :key="task.id" class="task-card" :style="{ '--delay': index * 0.08 + 's' }">
|
||||
<div class="task-header">
|
||||
<div class="task-icon" :class="task.status">
|
||||
<el-icon size="20"><component :is="getTaskIcon(task.status)" /></el-icon>
|
||||
</div>
|
||||
<el-tag :type="getStatusType(task.status)" size="small" effect="dark">{{ getStatusText(task.status) }}</el-tag>
|
||||
</div>
|
||||
<h3 class="task-name">{{ task.name }}</h3>
|
||||
<el-progress :percentage="task.progress || 0" :stroke-width="6" />
|
||||
<div class="task-meta">已完成: {{ task.completed || 0 }}/{{ task.total || 0 }}</div>
|
||||
<div class="task-actions">
|
||||
<el-button v-if="task.status === 'running'" type="danger" size="small" @click="stopTask(task)">停止</el-button>
|
||||
<el-button type="primary" size="small" @click="continueTask(task)">{{ task.status === 'running' ? '查看' : '继续' }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- Eval Dialog -->
|
||||
<el-dialog v-model="showEvalDialog" title="新建评估数据集" width="520px" class="eval-dialog">
|
||||
<el-form :model="evalForm" label-position="top">
|
||||
<el-form-item label="数据集名称">
|
||||
<el-input v-model="evalForm.name" placeholder="输入名称" size="large" />
|
||||
</el-form-item>
|
||||
<div class="form-row">
|
||||
<el-form-item label="问题类型">
|
||||
<el-select v-model="evalForm.question_type" placeholder="选择类型" size="large" style="width: 100%">
|
||||
<el-option label="混合" value="mixed" />
|
||||
<el-option label="事实性" value="fact" />
|
||||
<el-option label="推理" value="reasoning" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="问题数量">
|
||||
<el-input-number v-model="evalForm.count" :min="10" :max="500" size="large" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showEvalDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="createEval">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Blind Test Dialog -->
|
||||
<el-dialog v-model="showBlindTestDialog" title="创建盲测任务" width="520px" class="blind-dialog">
|
||||
<el-form :model="blindForm" label-position="top">
|
||||
<el-form-item label="任务名称">
|
||||
<el-input v-model="blindForm.name" placeholder="输入任务名称" size="large" />
|
||||
</el-form-item>
|
||||
<el-form-item label="选择模型">
|
||||
<el-checkbox-group v-model="blindForm.model_ids" class="model-check-group">
|
||||
<el-checkbox-button label="gpt-4">GPT-4</el-checkbox-button>
|
||||
<el-checkbox-button label="claude-3">Claude-3</el-checkbox-button>
|
||||
<el-checkbox-button label="gemini-pro">Gemini Pro</el-checkbox-button>
|
||||
<el-checkbox-button label="llama-3">Llama 3</el-checkbox-button>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showBlindTestDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="createBlindTest">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { evalApi } from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => route.params.id)
|
||||
|
||||
const loading = ref(false)
|
||||
const activeTab = ref('eval')
|
||||
const evalDatasets = ref([])
|
||||
const blindTasks = ref([])
|
||||
const showEvalDialog = ref(false)
|
||||
const showBlindTestDialog = ref(false)
|
||||
|
||||
const evalForm = reactive({
|
||||
name: '',
|
||||
question_type: 'mixed',
|
||||
count: 50
|
||||
})
|
||||
|
||||
const blindForm = reactive({
|
||||
name: '',
|
||||
model_ids: []
|
||||
})
|
||||
|
||||
const fetchEvalDatasets = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await evalApi.list(projectId.value)
|
||||
evalDatasets.value = res.data.datasets || []
|
||||
} catch (error) {
|
||||
evalDatasets.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createEval = async () => {
|
||||
try {
|
||||
await evalApi.create(projectId.value, evalForm)
|
||||
ElMessage.success('创建成功')
|
||||
showEvalDialog.value = false
|
||||
fetchEvalDatasets()
|
||||
} catch (error) {
|
||||
ElMessage.error('创建失败')
|
||||
}
|
||||
}
|
||||
|
||||
const runEval = async (evalSet) => {
|
||||
try {
|
||||
await evalApi.run(projectId.value, evalSet.id)
|
||||
ElMessage.success('评估已启动')
|
||||
} catch (error) {
|
||||
ElMessage.error('启动失败')
|
||||
}
|
||||
}
|
||||
|
||||
const viewResults = (evalSet) => {
|
||||
ElMessage.info('结果查看功能开发中')
|
||||
}
|
||||
|
||||
const createBlindTest = () => {
|
||||
ElMessage.info('盲测功能开发中')
|
||||
showBlindTestDialog.value = false
|
||||
}
|
||||
|
||||
const stopTask = (task) => ElMessage.info('停止功能开发中')
|
||||
const continueTask = (task) => ElMessage.info('继续功能开发中')
|
||||
|
||||
const getTypeName = (type) => {
|
||||
const map = { mixed: '混合', fact: '事实性', reasoning: '推理' }
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
const getStatusType = (status) => {
|
||||
const map = { pending: 'info', running: 'warning', completed: 'success', failed: 'danger' }
|
||||
return map[status] || 'info'
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const map = { pending: '等待中', running: '运行中', completed: '已完成', failed: '失败' }
|
||||
return map[status] || '未知'
|
||||
}
|
||||
|
||||
const getTaskIcon = (status) => {
|
||||
const map = { pending: 'Clock', running: 'Loading', completed: 'CircleCheck', failed: 'CircleClose' }
|
||||
return map[status] || 'Clock'
|
||||
}
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return ''
|
||||
const d = new Date(date)
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`
|
||||
}
|
||||
|
||||
onMounted(() => fetchEvalDatasets())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.eval-manage {
|
||||
padding: 32px;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.header-left h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.header-desc {
|
||||
font-size: 14px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
padding: 10px 20px;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.eval-tabs {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.evals-grid, .tasks-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.eval-card, .task-card {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
transition: all var(--transition-base);
|
||||
animation: cardIn 0.4s ease backwards;
|
||||
animation-delay: var(--delay);
|
||||
}
|
||||
|
||||
@keyframes cardIn {
|
||||
from { opacity: 0; transform: translateY(15px); }
|
||||
}
|
||||
|
||||
.eval-card:hover, .task-card:hover {
|
||||
border-color: var(--accent-primary);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.eval-header, .task-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.eval-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--accent-primary-muted);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.task-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.task-icon.running {
|
||||
background: rgba(251, 191, 36, 0.15);
|
||||
color: var(--warning);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.task-icon.completed {
|
||||
background: var(--success-muted);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.eval-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.eval-name, .task-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.eval-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.meta-count {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.eval-date {
|
||||
display: block;
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.empty-state .el-icon {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.model-check-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
611
frontend/src/views/project/FileManage.vue
Normal file
611
frontend/src/views/project/FileManage.vue
Normal file
@@ -0,0 +1,611 @@
|
||||
<template>
|
||||
<div class="file-manage">
|
||||
<!-- Page Header -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>文件管理</h2>
|
||||
<p class="header-desc">上传和管理您的文档</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="handleUpload" class="upload-btn">
|
||||
<el-icon><Upload /></el-icon>
|
||||
上传文件
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon cyan">
|
||||
<el-icon><Document /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ files.length }}</span>
|
||||
<span class="stat-label">总文件数</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon violet">
|
||||
<el-icon><Check /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ completedFiles }}</span>
|
||||
<span class="stat-label">已处理</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon orange">
|
||||
<el-icon><Loading /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ processingFiles.length }}</span>
|
||||
<span class="stat-label">处理中</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<el-tabs v-model="activeTab" class="file-tabs">
|
||||
<el-tab-pane label="全部文件" name="all">
|
||||
<div class="file-list" v-loading="loading">
|
||||
<div v-if="!loading && files.length === 0" class="empty-state">
|
||||
<div class="empty-illustration">
|
||||
<div class="circle-ring"></div>
|
||||
<el-icon size="56"><UploadFilled /></el-icon>
|
||||
</div>
|
||||
<h3>暂无文件</h3>
|
||||
<p>上传您的第一个文档开始处理</p>
|
||||
<el-button type="primary" @click="handleUpload">上传文件</el-button>
|
||||
</div>
|
||||
|
||||
<div v-else class="files-grid">
|
||||
<div
|
||||
v-for="(file, index) in files"
|
||||
:key="file.id"
|
||||
class="file-card"
|
||||
:style="{ '--delay': index * 0.05 + 's' }"
|
||||
>
|
||||
<div class="file-icon" :style="{ background: getFileBg(file.file_type) }">
|
||||
<el-icon size="24" color="white">
|
||||
<component :is="getFileIcon(file.file_type)" />
|
||||
</el-icon>
|
||||
</div>
|
||||
<div class="file-info">
|
||||
<h4 class="file-name">{{ file.filename }}</h4>
|
||||
<div class="file-meta">
|
||||
<span class="file-size">{{ formatSize(file.size) }}</span>
|
||||
<span class="file-divider">•</span>
|
||||
<span class="file-date">{{ formatDate(file.created_at) }}</span>
|
||||
</div>
|
||||
<div class="file-progress" v-if="file.status === 'processing'">
|
||||
<el-progress :percentage="50" :indeterminate="true" :show-text="false" />
|
||||
<span class="progress-text">处理中...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-actions">
|
||||
<el-tag v-if="file.status === 'completed'" type="success" size="small" effect="dark">
|
||||
就绪
|
||||
</el-tag>
|
||||
<el-tag v-else-if="file.status === 'processing'" size="small" effect="dark">
|
||||
处理中
|
||||
</el-tag>
|
||||
<el-button
|
||||
v-if="file.status === 'completed'"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="goToSplit(file)"
|
||||
class="action-btn"
|
||||
>
|
||||
分割
|
||||
</el-button>
|
||||
<el-popconfirm title="确定删除此文件?" @confirm="handleDelete(file)">
|
||||
<template #reference>
|
||||
<el-button type="danger" size="small" plain class="action-btn">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="处理中" name="processing">
|
||||
<div class="file-list" v-loading="loading">
|
||||
<div v-if="processingFiles.length === 0" class="empty-state small">
|
||||
<el-icon size="40"><Clock /></el-icon>
|
||||
<p>暂无正在处理的文件</p>
|
||||
</div>
|
||||
<div v-else class="files-grid">
|
||||
<div v-for="file in processingFiles" :key="file.id" class="file-card processing">
|
||||
<div class="file-icon processing">
|
||||
<el-icon size="24" color="white"><Loading /></el-icon>
|
||||
</div>
|
||||
<div class="file-info">
|
||||
<h4 class="file-name">{{ file.filename }}</h4>
|
||||
<div class="file-progress">
|
||||
<el-progress :percentage="50" :indeterminate="true" :show-text="false" />
|
||||
<span class="progress-text">处理中...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- Upload Dialog -->
|
||||
<el-dialog v-model="uploadDialogVisible" title="上传文件" width="520px" class="upload-dialog">
|
||||
<div class="upload-area">
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
class="upload-component"
|
||||
:auto-upload="false"
|
||||
:limit="10"
|
||||
:on-change="handleChange"
|
||||
:on-remove="handleRemove"
|
||||
:file-list="fileList"
|
||||
drag
|
||||
multiple
|
||||
accept=".pdf,.docx,.doc,.xlsx,.xls,.csv,.epub,.md,.txt"
|
||||
>
|
||||
<div class="upload-content">
|
||||
<div class="upload-icon">
|
||||
<el-icon size="48"><UploadFilled /></el-icon>
|
||||
</div>
|
||||
<div class="upload-text">
|
||||
拖拽文件到此处或 <em>点击上传</em>
|
||||
</div>
|
||||
<div class="upload-formats">
|
||||
<span>PDF</span>
|
||||
<span>DOCX</span>
|
||||
<span>EPUB</span>
|
||||
<span>Excel</span>
|
||||
<span>CSV</span>
|
||||
<span>Markdown</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="uploadDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitUpload" :loading="uploading">
|
||||
上传 {{ fileList.length > 0 ? `(${fileList.length})` : '' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { fileApi } from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const projectId = computed(() => route.params.id)
|
||||
|
||||
const loading = ref(false)
|
||||
const files = ref([])
|
||||
const activeTab = ref('all')
|
||||
const uploadDialogVisible = ref(false)
|
||||
const uploading = ref(false)
|
||||
const uploadRef = ref(null)
|
||||
const fileList = ref([])
|
||||
|
||||
const completedFiles = computed(() => files.value.filter(f => f.status === 'completed').length)
|
||||
|
||||
const processingFiles = computed(() =>
|
||||
files.value.filter(f => f.status === 'processing' || f.status === 'pending')
|
||||
)
|
||||
|
||||
const fetchFiles = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fileApi.list(projectId.value)
|
||||
files.value = res.data.files || []
|
||||
} catch (error) {
|
||||
files.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpload = () => {
|
||||
fileList.value = []
|
||||
uploadDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleChange = (file, files) => { fileList.value = files }
|
||||
const handleRemove = (file, files) => { fileList.value = files }
|
||||
|
||||
const submitUpload = async () => {
|
||||
if (fileList.value.length === 0) {
|
||||
ElMessage.warning('请先选择文件')
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
try {
|
||||
for (const item of fileList.value) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', item.raw)
|
||||
await fileApi.upload(projectId.value, formData)
|
||||
}
|
||||
ElMessage.success('上传完成')
|
||||
uploadDialogVisible.value = false
|
||||
fetchFiles()
|
||||
} catch (error) {
|
||||
ElMessage.error('上传失败')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (file) => {
|
||||
try {
|
||||
await fileApi.delete(projectId.value, file.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchFiles()
|
||||
} catch (error) {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const goToSplit = (file) => router.push(`/project/${projectId.value}/split?fileId=${file.id}`)
|
||||
|
||||
const getFileIcon = (type) => {
|
||||
const map = { pdf: 'Document', docx: 'Document', xlsx: 'Grid', csv: 'Document', epub: 'Notebook', md: 'Document', txt: 'Document' }
|
||||
return map[type] || 'Document'
|
||||
}
|
||||
|
||||
const getFileBg = (type) => {
|
||||
const map = { pdf: '#ef4444', docx: '#3b82f6', xlsx: '#22c55e', csv: '#22c55e', epub: '#f59e0b', md: '#8b5cf6', txt: '#6b7280' }
|
||||
return map[type] || '#6b7280'
|
||||
}
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (!bytes) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return ''
|
||||
return new Date(date).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
onMounted(() => fetchFiles())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-manage {
|
||||
padding: 32px;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.header-left h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.header-desc {
|
||||
font-size: 14px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.upload-btn {
|
||||
padding: 10px 20px;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.stat-icon.cyan { background: var(--accent-primary-muted); color: var(--accent-primary); }
|
||||
.stat-icon.violet { background: rgba(124, 58, 237, 0.15); color: var(--accent-secondary); }
|
||||
.stat-icon.orange { background: rgba(251, 191, 36, 0.15); color: var(--warning); }
|
||||
|
||||
.stat-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.file-tabs {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
:deep(.el-tabs__header) {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__nav-wrap::after) {
|
||||
background: var(--border-subtle);
|
||||
}
|
||||
|
||||
/* Files Grid */
|
||||
.files-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.file-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 20px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
transition: all var(--transition-fast);
|
||||
animation: cardSlide 0.4s ease backwards;
|
||||
animation-delay: var(--delay);
|
||||
}
|
||||
|
||||
@keyframes cardSlide {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.file-card:hover {
|
||||
border-color: var(--border-default);
|
||||
}
|
||||
|
||||
.file-card.processing {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-md);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.file-icon.processing {
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.file-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.file-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.file-divider {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.file-progress {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.file-progress .el-progress {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.empty-state.small {
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.empty-illustration {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin: 0 auto 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.circle-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 2px dashed var(--border-default);
|
||||
border-radius: 50%;
|
||||
animation: spin 20s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.empty-illustration .el-icon {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Upload Dialog */
|
||||
:deep(.upload-dialog .el-dialog__header) {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
:deep(.upload-dialog .el-dialog__body) {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
border: 2px dashed var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 40px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.upload-area:hover {
|
||||
border-color: var(--accent-primary);
|
||||
background: var(--accent-primary-muted);
|
||||
}
|
||||
|
||||
.upload-component :deep(.el-upload-dragger) {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.upload-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
color: var(--accent-primary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.upload-text em {
|
||||
color: var(--accent-primary);
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.upload-formats {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.upload-formats span {
|
||||
padding: 4px 10px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.file-card {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
320
frontend/src/views/project/QuestionManage.vue
Normal file
320
frontend/src/views/project/QuestionManage.vue
Normal file
@@ -0,0 +1,320 @@
|
||||
<template>
|
||||
<div class="question-manage">
|
||||
<!-- Page Header -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>问答管理</h2>
|
||||
<p class="header-desc">管理和生成问答数据</p>
|
||||
</div>
|
||||
<el-button type="primary" @click="showGenerateDialog = true" class="generate-btn">
|
||||
<el-icon><Plus /></el-icon>
|
||||
生成问题
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon violet">
|
||||
<el-icon><ChatDotSquare /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ questions.length }}</span>
|
||||
<span class="stat-label">总问题数</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon cyan">
|
||||
<el-icon><MagicStick /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ generatedCount }}</span>
|
||||
<span class="stat-label">AI 生成</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon orange">
|
||||
<el-icon><EditPen /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ manualCount }}</span>
|
||||
<span class="stat-label">手动添加</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Question Table -->
|
||||
<div class="table-card">
|
||||
<el-table :data="questions" v-loading="loading" style="width: 100%">
|
||||
<el-table-column prop="content" label="问题内容" min-width="300">
|
||||
<template #default="{ row }">
|
||||
<div class="question-content">{{ row.content }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="answer" label="答案" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<div class="answer-content">{{ row.answer || '-' }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="question_type" label="类型" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="getTypeColor(row.question_type)" effect="dark">
|
||||
{{ getTypeName(row.question_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="source" label="来源" width="120" />
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-popconfirm title="确定删除此问题?" @confirm="handleDelete(row)">
|
||||
<template #reference>
|
||||
<el-button type="danger" size="small" text>删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- Generate Dialog -->
|
||||
<el-dialog v-model="showGenerateDialog" title="生成问题" width="560px" class="generate-dialog">
|
||||
<el-form :model="generateConfig" label-position="top">
|
||||
<el-form-item label="选择文本块">
|
||||
<el-select
|
||||
v-model="generateConfig.chunk_ids"
|
||||
multiple
|
||||
placeholder="选择文本块"
|
||||
style="width: 100%"
|
||||
size="large"
|
||||
>
|
||||
<el-option
|
||||
v-for="chunk in chunks"
|
||||
:key="chunk.id"
|
||||
:label="chunk.name || chunk.content.slice(0, 50) + '...'"
|
||||
:value="chunk.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<div class="form-row">
|
||||
<el-form-item label="每个块生成数量">
|
||||
<el-input-number v-model="generateConfig.count" :min="1" :max="50" size="large" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="问题类型">
|
||||
<el-checkbox-group v-model="generateConfig.question_types">
|
||||
<el-checkbox label="fact">事实性</el-checkbox>
|
||||
<el-checkbox label="summary">总结性</el-checkbox>
|
||||
<el-checkbox label="reasoning">推理性</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showGenerateDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleGenerate" :loading="generating">开始生成</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { chunkApi, questionApi } from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => route.params.id)
|
||||
|
||||
const loading = ref(false)
|
||||
const generating = ref(false)
|
||||
const questions = ref([])
|
||||
const chunks = ref([])
|
||||
const showGenerateDialog = ref(false)
|
||||
|
||||
const generateConfig = reactive({
|
||||
chunk_ids: [],
|
||||
count: 5,
|
||||
question_types: ['fact', 'summary']
|
||||
})
|
||||
|
||||
const generatedCount = computed(() => questions.value.filter(q => q.source === 'generated').length)
|
||||
const manualCount = computed(() => questions.value.filter(q => q.source === 'manual').length)
|
||||
|
||||
const fetchQuestions = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await chunkApi.list(projectId.value, {})
|
||||
chunks.value = res.data.chunks || []
|
||||
questions.value = chunks.value.flatMap(c => (c.questions || []).map(q => ({ ...q, source: c.name })))
|
||||
} catch (error) {
|
||||
questions.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (generateConfig.chunk_ids.length === 0) {
|
||||
ElMessage.warning('请选择文本块')
|
||||
return
|
||||
}
|
||||
generating.value = true
|
||||
try {
|
||||
await questionApi.generate(projectId.value, generateConfig)
|
||||
ElMessage.success('问题生成任务已启动')
|
||||
showGenerateDialog.value = false
|
||||
setTimeout(fetchQuestions, 2000)
|
||||
} catch (error) {
|
||||
ElMessage.error('生成失败')
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (question) => {
|
||||
try {
|
||||
await questionApi.delete(projectId.value, question.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchQuestions()
|
||||
} catch (error) {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const getTypeColor = (type) => {
|
||||
const map = { 'fact': 'success', 'summary': 'primary', 'reasoning': 'warning' }
|
||||
return map[type] || 'info'
|
||||
}
|
||||
|
||||
const getTypeName = (type) => {
|
||||
const map = { 'fact': '事实性', 'summary': '总结性', 'reasoning': '推理性' }
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
onMounted(() => fetchQuestions())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.question-manage {
|
||||
padding: 32px;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.header-left h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.header-desc {
|
||||
font-size: 14px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.generate-btn {
|
||||
padding: 10px 20px;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* Stats Grid */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.stat-icon.violet { background: rgba(124, 58, 237, 0.15); color: var(--accent-secondary); }
|
||||
.stat-icon.cyan { background: var(--accent-primary-muted); color: var(--accent-primary); }
|
||||
.stat-icon.orange { background: rgba(251, 191, 36, 0.15); color: var(--warning); }
|
||||
|
||||
.stat-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Table Card */
|
||||
.table-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.question-content {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.answer-content {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.5;
|
||||
max-height: 60px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Form */
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.generate-dialog :deep(.el-form-item__label) {
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
202
frontend/src/views/project/Settings.vue
Normal file
202
frontend/src/views/project/Settings.vue
Normal file
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<div class="settings">
|
||||
<div class="page-header">
|
||||
<h2>项目设置</h2>
|
||||
<p class="header-desc">管理项目配置</p>
|
||||
</div>
|
||||
|
||||
<el-tabs v-model="activeTab" class="settings-tabs">
|
||||
<el-tab-pane label="基本信息" name="basic">
|
||||
<div class="settings-card">
|
||||
<el-form :model="projectInfo" label-position="top">
|
||||
<el-form-item label="项目名称">
|
||||
<el-input v-model="projectInfo.name" size="large" />
|
||||
</el-form-item>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="projectInfo.description" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="saveProjectInfo">保存修改</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="模型配置" name="model">
|
||||
<div class="settings-card">
|
||||
<div class="card-header">
|
||||
<span>模型配置</span>
|
||||
<el-button type="primary" size="small" @click="showModelDialog = true">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加模型
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="modelConfigs" v-loading="loading">
|
||||
<el-table-column prop="provider" label="提供商" width="140" />
|
||||
<el-table-column prop="model_name" label="模型名称" />
|
||||
<el-table-column prop="api_base" label="API 地址" />
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" size="small" text @click="deleteModel(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="提示词配置" name="prompt">
|
||||
<div class="settings-card">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="问题生成提示词">
|
||||
<el-input v-model="prompts.question_generation" type="textarea" :rows="6" />
|
||||
</el-form-item>
|
||||
<el-form-item label="摘要生成提示词">
|
||||
<el-input v-model="prompts.summary_generation" type="textarea" :rows="4" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="savePrompts">保存提示词</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- Model Dialog -->
|
||||
<el-dialog v-model="showModelDialog" title="添加模型" width="500px">
|
||||
<el-form :model="modelForm" label-position="top">
|
||||
<el-form-item label="提供商">
|
||||
<el-select v-model="modelForm.provider" placeholder="选择提供商" style="width: 100%">
|
||||
<el-option label="OpenAI" value="openai" />
|
||||
<el-option label="Anthropic" value="anthropic" />
|
||||
<el-option label="Ollama" value="ollama" />
|
||||
<el-option label="自定义" value="custom" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="模型名称">
|
||||
<el-input v-model="modelForm.model_name" placeholder="例如: gpt-4o-mini" />
|
||||
</el-form-item>
|
||||
<el-form-item label="API Key">
|
||||
<el-input v-model="modelForm.api_key" type="password" placeholder="输入 API Key" />
|
||||
</el-form-item>
|
||||
<el-form-item label="API Base URL">
|
||||
<el-input v-model="modelForm.api_base" placeholder="自定义 API 地址(可选)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showModelDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="addModel">添加</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { projectApi } from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => route.params.id)
|
||||
|
||||
const activeTab = ref('basic')
|
||||
const loading = ref(false)
|
||||
const showModelDialog = ref(false)
|
||||
|
||||
const projectInfo = reactive({
|
||||
name: '',
|
||||
description: ''
|
||||
})
|
||||
|
||||
const modelConfigs = ref([])
|
||||
const modelForm = reactive({
|
||||
provider: 'openai',
|
||||
model_name: '',
|
||||
api_key: '',
|
||||
api_base: ''
|
||||
})
|
||||
|
||||
const prompts = reactive({
|
||||
question_generation: '你是一个问答对生成专家。请根据以下文本生成 {count} 个高质量的问答对。',
|
||||
summary_generation: '请为以下文本生成一个简洁的摘要。'
|
||||
})
|
||||
|
||||
const fetchProject = async () => {
|
||||
try {
|
||||
const res = await projectApi.get(projectId.value)
|
||||
projectInfo.name = res.data.name
|
||||
projectInfo.description = res.data.description || ''
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
const saveProjectInfo = async () => {
|
||||
try {
|
||||
await projectApi.update(projectId.value, projectInfo)
|
||||
ElMessage.success('保存成功')
|
||||
} catch (error) {
|
||||
ElMessage.error('保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
const addModel = () => {
|
||||
ElMessage.info('模型添加功能开发中')
|
||||
showModelDialog.value = false
|
||||
}
|
||||
|
||||
const deleteModel = (model) => {
|
||||
ElMessage.info('模型删除功能开发中')
|
||||
}
|
||||
|
||||
const savePrompts = () => {
|
||||
ElMessage.success('提示词保存成功')
|
||||
}
|
||||
|
||||
onMounted(() => fetchProject())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings {
|
||||
padding: 32px;
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.header-desc {
|
||||
font-size: 14px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.settings-tabs {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
470
frontend/src/views/project/TextSplit.vue
Normal file
470
frontend/src/views/project/TextSplit.vue
Normal file
@@ -0,0 +1,470 @@
|
||||
<template>
|
||||
<div class="text-split">
|
||||
<!-- Page Header -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>文本分割</h2>
|
||||
<p class="header-desc">将文档内容智能分割为文本块</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button @click="refreshChunks" class="action-btn">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
<el-button type="primary" @click="showSplitDialog = true" class="action-btn">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新建分割
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Split Config Card -->
|
||||
<div class="config-card" v-if="selectedFile">
|
||||
<div class="config-header">
|
||||
<div class="config-title">
|
||||
<div class="file-badge">
|
||||
<el-icon><Document /></el-icon>
|
||||
</div>
|
||||
<span>{{ selectedFile.filename }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="config-form">
|
||||
<div class="form-row">
|
||||
<div class="form-item">
|
||||
<label>分割算法</label>
|
||||
<el-select v-model="splitConfig.method" placeholder="选择算法">
|
||||
<el-option label="Markdown 结构分割" value="markdown_structure" />
|
||||
<el-option label="递归字符分割" value="recursive" />
|
||||
<el-option label="Token 数量分割" value="token" />
|
||||
<el-option label="代码感知分割" value="code" />
|
||||
<el-option label="自定义分隔符" value="custom" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row sliders">
|
||||
<div class="form-item">
|
||||
<label>块大小: {{ splitConfig.chunk_size }}</label>
|
||||
<el-slider v-model="splitConfig.chunk_size" :min="100" :max="2000" :step="100" :marks="{100: '100', 500: '500', 1000: '1k', 2000: '2k'}" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>重叠字符: {{ splitConfig.overlap }}</label>
|
||||
<el-slider v-model="splitConfig.overlap" :min="0" :max="500" :step="50" :marks="{0: '0', 250: '250', 500: '500'}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-item" v-if="splitConfig.method === 'custom'">
|
||||
<label>自定义分隔符</label>
|
||||
<el-input v-model="splitConfig.separator" placeholder="例如: \n\n 或 || 或 ---" />
|
||||
</div>
|
||||
|
||||
<div class="config-action">
|
||||
<el-button type="primary" size="large" @click="handleSplit" :loading="splitting" class="start-btn">
|
||||
<el-icon><CaretRight /></el-icon>
|
||||
开始分割
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<div class="empty-illustration">
|
||||
<div class="ring"></div>
|
||||
<el-icon size="56"><Document /></el-icon>
|
||||
</div>
|
||||
<h3>未选择文件</h3>
|
||||
<p>请从文件管理中选择一个文件开始分割</p>
|
||||
</div>
|
||||
|
||||
<!-- Chunk List -->
|
||||
<div class="chunks-card" v-if="selectedFile && chunks.length > 0">
|
||||
<div class="chunks-header">
|
||||
<div class="chunks-title">
|
||||
<el-icon><List /></el-icon>
|
||||
<span>文本块 ({{ chunks.length }})</span>
|
||||
</div>
|
||||
<el-tag type="primary" effect="dark">
|
||||
总计 {{ totalWords }} 字
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="chunk-list" v-loading="loading">
|
||||
<div
|
||||
v-for="(chunk, index) in chunks"
|
||||
:key="chunk.id"
|
||||
class="chunk-item"
|
||||
:style="{ '--delay': index * 0.03 + 's' }"
|
||||
>
|
||||
<div class="chunk-header">
|
||||
<div class="chunk-badge">{{ index + 1 }}</div>
|
||||
<span class="chunk-title">{{ chunk.name || '未命名' }}</span>
|
||||
<span class="chunk-meta">{{ chunk.word_count || 0 }} 字</span>
|
||||
</div>
|
||||
<div class="chunk-content">{{ chunk.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Select File Dialog -->
|
||||
<el-dialog v-model="showSplitDialog" title="选择文件" width="500px" class="select-dialog">
|
||||
<el-select v-model="selectedFileId" placeholder="选择要分割的文件" style="width: 100%" size="large">
|
||||
<el-option
|
||||
v-for="file in files"
|
||||
:key="file.id"
|
||||
:label="file.filename"
|
||||
:value="file.id"
|
||||
>
|
||||
<div class="file-option">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>{{ file.filename }}</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<template #footer>
|
||||
<el-button @click="showSplitDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="confirmSelectFile">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { fileApi, chunkApi } from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => route.params.id)
|
||||
|
||||
const loading = ref(false)
|
||||
const splitting = ref(false)
|
||||
const files = ref([])
|
||||
const chunks = ref([])
|
||||
const selectedFileId = ref('')
|
||||
const selectedFile = ref(null)
|
||||
const showSplitDialog = ref(false)
|
||||
|
||||
const splitConfig = reactive({
|
||||
method: 'recursive',
|
||||
chunk_size: 500,
|
||||
overlap: 50,
|
||||
separator: '\n\n'
|
||||
})
|
||||
|
||||
const totalWords = computed(() => {
|
||||
return chunks.value.reduce((sum, c) => sum + (c.word_count || 0), 0)
|
||||
})
|
||||
|
||||
const fetchFiles = async () => {
|
||||
try {
|
||||
const res = await fileApi.list(projectId.value)
|
||||
files.value = res.data.files || []
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchChunks = async () => {
|
||||
if (!selectedFile.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await chunkApi.list(projectId.value, { file_id: selectedFile.value.id })
|
||||
chunks.value = res.data.chunks || []
|
||||
} catch (error) {
|
||||
ElMessage.error('获取分割结果失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const confirmSelectFile = () => {
|
||||
selectedFile.value = files.value.find(f => f.id === selectedFileId.value)
|
||||
showSplitDialog.value = false
|
||||
fetchChunks()
|
||||
}
|
||||
|
||||
const handleSplit = async () => {
|
||||
if (!selectedFile.value) {
|
||||
ElMessage.warning('请先选择文件')
|
||||
return
|
||||
}
|
||||
splitting.value = true
|
||||
try {
|
||||
await chunkApi.split(projectId.value, { file_id: selectedFile.value.id, ...splitConfig })
|
||||
ElMessage.success('分割任务已启动')
|
||||
fetchChunks()
|
||||
} catch (error) {
|
||||
ElMessage.error('分割失败')
|
||||
} finally {
|
||||
splitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshChunks = () => fetchChunks()
|
||||
|
||||
onMounted(() => {
|
||||
fetchFiles()
|
||||
const fileId = route.query.fileId
|
||||
if (fileId) {
|
||||
selectedFileId.value = fileId
|
||||
setTimeout(() => {
|
||||
selectedFile.value = files.value.find(f => f.id === fileId)
|
||||
if (selectedFile.value) fetchChunks()
|
||||
}, 500)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-split {
|
||||
padding: 32px;
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.header-left h2 {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.header-desc {
|
||||
font-size: 14px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 10px 18px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* Config Card */
|
||||
.config-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.config-header {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.config-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.file-badge {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--accent-primary-muted);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.config-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.form-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-item label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.config-action {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.start-btn {
|
||||
padding: 12px 28px;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 20px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px dashed var(--border-default);
|
||||
border-radius: var(--radius-lg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-illustration {
|
||||
position: relative;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 2px dashed var(--border-default);
|
||||
border-radius: 50%;
|
||||
animation: spin 15s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.empty-illustration .el-icon {
|
||||
color: var(--text-muted);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 18px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Chunks Card */
|
||||
.chunks-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chunks-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.chunks-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chunk-list {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.chunk-item {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
transition: background var(--transition-fast);
|
||||
animation: slideIn 0.3s ease backwards;
|
||||
animation-delay: var(--delay);
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.chunk-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.chunk-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.chunk-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.chunk-badge {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--accent-gradient);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chunk-title {
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.chunk-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.chunk-content {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* File Option */
|
||||
.file-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
</style>
|
||||
21
frontend/vite.config.js
Normal file
21
frontend/vite.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src')
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user