refactor: 前端架构重构 - 提取 CSS 和逻辑到独立模块
前端重构: - 删除旧的大体积 Vue 组件(HomeView, FileManage, TextSplit 等) - 删除旧的 composables(useFormatters, useModels, useProjects) - 新增 core/, page-logic/, pages/, shared/ 模块化目录结构 - 提取 CSS 到 styles/pages/ 目录 - 添加全局样式 variables.css 和 common.css 后端 API 更新: - chunks: 语义分割 API 增强 - files: 文件处理 API 更新 - models: 模型管理 API 更新 - questions: 问答管理 API 更新 - database: 数据库连接优化 - semantic_embedding: 语义嵌入服务优化 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,6 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import axios from 'axios'
|
||||
import type { AxiosInstance } from 'axios'
|
||||
import type { Project, ProjectCreate, ProjectUpdate, Model, ModelCreate } from '@/types'
|
||||
import type { Project, ProjectCreate, ProjectUpdate, Model, ModelCreate } from '@/shared/types'
|
||||
|
||||
const apiBaseURL = import.meta.env.VITE_API_BASE_URL
|
||||
|| (import.meta.env.PROD
|
||||
? '/api/v1'
|
||||
: `${window.location.protocol}//${window.location.hostname}:8000/api/v1`)
|
||||
|
||||
const request: AxiosInstance = axios.create({
|
||||
baseURL: import.meta.env.PROD
|
||||
? '/api/v1'
|
||||
: 'http://10.10.10.77:8000/api/v1',
|
||||
baseURL: apiBaseURL,
|
||||
timeout: 60000
|
||||
})
|
||||
|
||||
@@ -71,7 +74,10 @@ export const fileApi = {
|
||||
}
|
||||
|
||||
export const chunkApi = {
|
||||
split: (projectId: string, data: any) => request.post(`/projects/${projectId}/chunks/split`, data),
|
||||
split: (projectId: string, data: any) =>
|
||||
request.post(`/projects/${projectId}/chunks/split`, data, {
|
||||
timeout: 300000
|
||||
}),
|
||||
list: (projectId: string, params?: any) => request.get(`/projects/${projectId}/chunks`, { params }),
|
||||
get: (projectId: string, chunkId: string) => request.get(`/projects/${projectId}/chunks/${chunkId}`),
|
||||
update: (projectId: string, chunkId: string, data: any) => request.put(`/projects/${projectId}/chunks/${chunkId}`, data),
|
||||
@@ -79,8 +85,8 @@ export const chunkApi = {
|
||||
}
|
||||
|
||||
export const questionApi = {
|
||||
generate: (projectId: string, data: any) => request.post(`/projects/${projectId}/generate-questions`, data),
|
||||
list: (projectId: string, params: { chunkId: string }) => request.get(`/projects/${projectId}/chunks/${params.chunkId}/questions`),
|
||||
generate: (projectId: string, data: any) => request.post(`/projects/${projectId}/questions/generate`, data),
|
||||
list: (projectId: string, params?: any) => request.get(`/projects/${projectId}/questions`, { params }),
|
||||
update: (projectId: string, questionId: string, data: any) => request.put(`/projects/${projectId}/questions/${questionId}`, data),
|
||||
delete: (projectId: string, questionId: string) => request.delete(`/projects/${projectId}/questions/${questionId}`)
|
||||
}
|
||||
@@ -1,61 +1,61 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: () => import('@/views/HomeView.vue')
|
||||
component: () => import('@/pages/HomePage.vue')
|
||||
},
|
||||
{
|
||||
path: '/project/:id',
|
||||
name: 'Project',
|
||||
component: () => import('@/views/ProjectView.vue'),
|
||||
component: () => import('@/pages/ProjectPage.vue'),
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
redirect: to => `/project/${to.params.id}/files`
|
||||
redirect: to => `/project/${String(to.params.id)}/files`
|
||||
},
|
||||
{
|
||||
path: 'files',
|
||||
name: 'ProjectFiles',
|
||||
component: () => import('@/views/project/FileManage.vue')
|
||||
component: () => import('@/pages/ProjectFilePage.vue')
|
||||
},
|
||||
{
|
||||
path: 'split',
|
||||
name: 'ProjectSplit',
|
||||
component: () => import('@/views/project/TextSplit.vue')
|
||||
component: () => import('@/pages/ProjectTextSplitPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'questions',
|
||||
name: 'ProjectQuestions',
|
||||
component: () => import('@/views/project/QuestionManage.vue')
|
||||
component: () => import('@/pages/ProjectQuestionPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'datasets',
|
||||
name: 'ProjectDatasets',
|
||||
component: () => import('@/views/project/DatasetManage.vue')
|
||||
component: () => import('@/pages/ProjectDatasetPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'eval',
|
||||
name: 'ProjectEval',
|
||||
component: () => import('@/views/project/EvalManage.vue')
|
||||
component: () => import('@/pages/ProjectEvalPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'ProjectSettings',
|
||||
component: () => import('@/views/project/Settings.vue')
|
||||
component: () => import('@/pages/ProjectSettingsPage.vue')
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/models',
|
||||
name: 'ModelSettings',
|
||||
component: () => import('@/views/ModelSettingsView.vue')
|
||||
component: () => import('@/pages/ModelSettingsPage.vue')
|
||||
},
|
||||
{
|
||||
path: '/crawler',
|
||||
name: 'Crawler',
|
||||
component: () => import('@/views/CrawlerView.vue')
|
||||
component: () => import('@/pages/CrawlerPage.vue')
|
||||
}
|
||||
]
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'ant-design-vue/dist/reset.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import router from './core/router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
260
frontend/src/page-logic/ModelSettingsPage.ts
Normal file
260
frontend/src/page-logic/ModelSettingsPage.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { defineComponent } from 'vue'
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { ModelConfig, ProviderOption, ModelCreate, ModelType } from '@/shared/types'
|
||||
import { modelApi } from '@/core/api'
|
||||
import { watch } from 'vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'ModelSettingsView',
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const deleting = ref(false)
|
||||
const showAddDialog = ref(false)
|
||||
const deleteDialogVisible = ref(false)
|
||||
const modelToDelete = ref<ModelConfig | null>(null)
|
||||
const models = ref<ModelConfig[]>([])
|
||||
|
||||
// 表单
|
||||
const modelForm = reactive<ModelCreate>({
|
||||
provider: 'minimax',
|
||||
model_type: 'chat',
|
||||
model_name: '',
|
||||
api_key: '',
|
||||
api_base: 'https://api.minimax.chat/v1',
|
||||
is_default: false
|
||||
})
|
||||
|
||||
// 供应商默认 API 地址
|
||||
const providerDefaultUrls: Record<string, string> = {
|
||||
minimax: 'https://api.minimax.chat/v1',
|
||||
glm: 'https://open.bigmodel.cn/api/paas/v4',
|
||||
openai: 'https://api.openai.com/v1',
|
||||
ali: 'https://dashscope.aliyuncs.com/compatible-mode/v1'
|
||||
}
|
||||
|
||||
type ProviderOptionItem = ProviderOption & {
|
||||
desc: string
|
||||
}
|
||||
|
||||
type ModelTypeOption = {
|
||||
value: ModelType
|
||||
label: string
|
||||
abbr: string
|
||||
desc: string
|
||||
}
|
||||
|
||||
// 提供商
|
||||
const providers: ProviderOptionItem[] = [
|
||||
{ value: 'minimax', label: 'MiniMax', abbr: 'MM', desc: '适合国内接入,默认官方端点' },
|
||||
{ value: 'glm', label: 'GLM', abbr: 'GL', desc: '智谱接口,兼容常见模型配置' },
|
||||
{ value: 'openai', label: 'OpenAI Compatible', abbr: 'OP', desc: '适配 OpenAI 及兼容协议服务' },
|
||||
{ value: 'ali', label: '阿里云百炼', abbr: 'AL', desc: '默认走 DashScope 兼容模式端点' }
|
||||
]
|
||||
|
||||
const modelTypes: ModelTypeOption[] = [
|
||||
{ value: 'chat', label: 'Chat', abbr: 'CH', desc: '标准对话生成模型' },
|
||||
{ value: 'vlm', label: 'VLM', abbr: 'VL', desc: '视觉语言模型,适合图文输入' },
|
||||
{ value: 'embedding', label: 'Embedding', abbr: 'EM', desc: '文本向量化与语义检索' },
|
||||
{ value: 'rerank', label: 'Rerank', abbr: 'RR', desc: '重排模型,用于检索结果排序' }
|
||||
]
|
||||
|
||||
const normalizeModelType = (modelType?: string, modelName?: string): ModelType => {
|
||||
if (modelType && modelTypes.some(type => type.value === modelType) && modelType !== 'chat') {
|
||||
return modelType as ModelType
|
||||
}
|
||||
|
||||
const normalizedName = (modelName || '').trim().toLowerCase()
|
||||
|
||||
if (['rerank', 'bce-reranker', 'gte-rerank'].some(keyword => normalizedName.includes(keyword))) {
|
||||
return 'rerank'
|
||||
}
|
||||
|
||||
if (
|
||||
[
|
||||
'embedding',
|
||||
'embed',
|
||||
'text-embedding',
|
||||
'bge-',
|
||||
'bge_m3',
|
||||
'gte-',
|
||||
'm3e',
|
||||
'e5-',
|
||||
'jina-embeddings'
|
||||
].some(keyword => normalizedName.includes(keyword))
|
||||
) {
|
||||
return 'embedding'
|
||||
}
|
||||
|
||||
if (['vl', 'vision', 'visual', 'multimodal', 'qwen-vl', 'gpt-4o'].some(keyword => normalizedName.includes(keyword))) {
|
||||
return 'vlm'
|
||||
}
|
||||
|
||||
return 'chat'
|
||||
}
|
||||
|
||||
// 监听 provider 变化,自动设置默认 API 地址
|
||||
|
||||
watch(() => modelForm.provider, (newProvider) => {
|
||||
if (providerDefaultUrls[newProvider]) {
|
||||
modelForm.api_base = providerDefaultUrls[newProvider]
|
||||
}
|
||||
})
|
||||
|
||||
// 方法
|
||||
const goHome = () => router.push('/')
|
||||
|
||||
const getProviderAbbr = (provider: string) => {
|
||||
const p = providers.find(p => p.value === provider)
|
||||
return p?.abbr || '?'
|
||||
}
|
||||
|
||||
const getModelTypeLabel = (modelType?: string, modelName?: string) => {
|
||||
const item = modelTypes.find(type => type.value === normalizeModelType(modelType, modelName))
|
||||
return item?.label || 'Chat'
|
||||
}
|
||||
|
||||
const fetchModels = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await modelApi.list()
|
||||
// Handle different response formats
|
||||
if (Array.isArray(res)) {
|
||||
models.value = res.map(model => ({
|
||||
...model,
|
||||
model_type: normalizeModelType(model.model_type, model.model_name)
|
||||
}))
|
||||
} else if (res?.data && Array.isArray(res.data)) {
|
||||
models.value = res.data.map((model: ModelConfig) => ({
|
||||
...model,
|
||||
model_type: normalizeModelType(model.model_type, model.model_name)
|
||||
}))
|
||||
} else {
|
||||
models.value = []
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('获取模型列表失败:', error)
|
||||
ElMessage.error(error?.message || '加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openAddDialog = () => {
|
||||
modelForm.provider = 'minimax'
|
||||
modelForm.model_type = 'chat'
|
||||
modelForm.model_name = ''
|
||||
modelForm.api_key = ''
|
||||
modelForm.api_base = providerDefaultUrls['minimax']
|
||||
modelForm.is_default = false
|
||||
showAddDialog.value = true
|
||||
}
|
||||
|
||||
const addModel = async () => {
|
||||
if (!modelForm.model_name || !modelForm.api_key) {
|
||||
ElMessage.warning('请填写模型名称和 API Key')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
// Convert is_default from boolean to string
|
||||
const data = {
|
||||
provider: modelForm.provider,
|
||||
model_type: modelForm.model_type,
|
||||
model_name: modelForm.model_name,
|
||||
api_key: modelForm.api_key,
|
||||
api_base: modelForm.api_base,
|
||||
is_default: modelForm.is_default ? 'true' : 'false'
|
||||
}
|
||||
await modelApi.create(data)
|
||||
ElMessage.success('添加成功')
|
||||
showAddDialog.value = false
|
||||
fetchModels()
|
||||
} catch (error: any) {
|
||||
console.error('添加模型失败:', error)
|
||||
ElMessage.error(error?.message || '添加失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = (model: ModelConfig) => {
|
||||
modelToDelete.value = model
|
||||
deleteDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!modelToDelete.value?.id) return
|
||||
deleting.value = true
|
||||
try {
|
||||
await modelApi.delete(modelToDelete.value.id)
|
||||
ElMessage.success('删除成功')
|
||||
deleteDialogVisible.value = false
|
||||
modelToDelete.value = null
|
||||
fetchModels()
|
||||
} catch (error: any) {
|
||||
console.error('删除模型失败:', error)
|
||||
ElMessage.error(error?.message || '删除失败')
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const testConnection = async (model: ModelConfig) => {
|
||||
ElMessage.info(`正在测试 ${model.model_name}...`)
|
||||
try {
|
||||
const res = await modelApi.test(model.id)
|
||||
// Update model connection status from response
|
||||
const modelItem = models.value.find(m => m.id === model.id)
|
||||
if (modelItem && res?.model) {
|
||||
modelItem.connection_status = res.model.connection_status
|
||||
if (res.test_result?.success) {
|
||||
ElMessage.success('连接成功!')
|
||||
} else {
|
||||
ElMessage.error(res.test_result?.message || '连接失败')
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('测试连接失败:', error)
|
||||
const modelItem = models.value.find(m => m.id === model.id)
|
||||
if (modelItem) {
|
||||
modelItem.connection_status = 'disconnected'
|
||||
}
|
||||
ElMessage.error(error?.message || '连接失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => fetchModels())
|
||||
|
||||
return {
|
||||
router,
|
||||
loading,
|
||||
submitting,
|
||||
deleting,
|
||||
showAddDialog,
|
||||
deleteDialogVisible,
|
||||
modelToDelete,
|
||||
models,
|
||||
modelForm,
|
||||
providerDefaultUrls,
|
||||
providers,
|
||||
modelTypes,
|
||||
normalizeModelType,
|
||||
goHome,
|
||||
getProviderAbbr,
|
||||
getModelTypeLabel,
|
||||
fetchModels,
|
||||
openAddDialog,
|
||||
addModel,
|
||||
confirmDelete,
|
||||
handleDelete,
|
||||
testConnection
|
||||
}
|
||||
}
|
||||
})
|
||||
377
frontend/src/page-logic/ProjectFilePage.ts
Normal file
377
frontend/src/page-logic/ProjectFilePage.ts
Normal file
@@ -0,0 +1,377 @@
|
||||
import { defineComponent } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { fileApi } from '@/core/api'
|
||||
import DeleteDialog from '@/shared/components/common/DeleteDialog.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'FileManage',
|
||||
components: { DeleteDialog },
|
||||
setup() {
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => route.params.id)
|
||||
|
||||
const loading = ref(false)
|
||||
const files = ref([])
|
||||
const filterStatus = ref('')
|
||||
const isInitialLoad = ref(true)
|
||||
|
||||
const filteredFiles = computed(() => {
|
||||
if (!filterStatus.value) return files.value
|
||||
return files.value.filter(f => f.status === filterStatus.value)
|
||||
})
|
||||
const uploadDialogVisible = ref(false)
|
||||
const uploading = ref(false)
|
||||
const uploadRef = ref(null)
|
||||
const fileList = ref([])
|
||||
const deleteDialogVisible = ref(false)
|
||||
const pendingDeleteFile = ref(null)
|
||||
const deletingFile = ref(false)
|
||||
|
||||
// Multi-select
|
||||
const selectedFiles = ref([])
|
||||
|
||||
const isAllSelected = computed(() => filteredFiles.value.length > 0 && selectedFiles.value.length === filteredFiles.value.length)
|
||||
const selectedCount = computed(() => selectedFiles.value.length)
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (isAllSelected.value) {
|
||||
selectedFiles.value = []
|
||||
} else {
|
||||
selectedFiles.value = filteredFiles.value.map(f => f.id)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelect = (fileId) => {
|
||||
const index = selectedFiles.value.indexOf(fileId)
|
||||
if (index === -1) {
|
||||
selectedFiles.value.push(fileId)
|
||||
} else {
|
||||
selectedFiles.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const isSelected = (fileId) => selectedFiles.value.includes(fileId)
|
||||
|
||||
const clearSelection = () => {
|
||||
selectedFiles.value = []
|
||||
}
|
||||
|
||||
const batchDeleteDialogVisible = ref(false)
|
||||
const batchDeleting = ref(false)
|
||||
const batchDeleteFiles = ref([])
|
||||
|
||||
const batchDelete = async () => {
|
||||
if (selectedFiles.value.length === 0) return
|
||||
batchDeleteFiles.value = files.value.filter(f => selectedFiles.value.includes(f.id))
|
||||
batchDeleteDialogVisible.value = true
|
||||
}
|
||||
|
||||
const executeBatchDelete = async () => {
|
||||
if (selectedFiles.value.length === 0) return
|
||||
batchDeleting.value = true
|
||||
try {
|
||||
for (const fileId of selectedFiles.value) {
|
||||
await fileApi.delete(projectId.value, fileId)
|
||||
}
|
||||
ElMessage.success(`已删除 ${selectedFiles.value.length} 个文件`)
|
||||
selectedFiles.value = []
|
||||
batchDeleteDialogVisible.value = false
|
||||
fetchFiles()
|
||||
} catch (error) {
|
||||
ElMessage.error('删除失败')
|
||||
} finally {
|
||||
batchDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Preview
|
||||
const previewVisible = ref(false)
|
||||
const previewFile = ref(null)
|
||||
const previewContent = ref('')
|
||||
const previewLoading = ref(false)
|
||||
const previewMode = ref('source') // 'source' | 'markdown'
|
||||
const isPdfPreview = ref(false)
|
||||
const pdfDataUrl = ref('')
|
||||
const previewError = 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 failedFiles = computed(() => files.value.filter(f => f.status === 'failed').length)
|
||||
|
||||
const fetchFiles = async () => {
|
||||
const wasInitial = isInitialLoad.value
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fileApi.list(projectId.value)
|
||||
files.value = res || []
|
||||
} catch (error) {
|
||||
files.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (wasInitial) {
|
||||
isInitialLoad.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpload = () => {
|
||||
fileList.value = []
|
||||
uploadDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleChange = (file, files) => { fileList.value = files }
|
||||
const handleRemove = (file, files) => { fileList.value = files }
|
||||
|
||||
const triggerUpload = () => {
|
||||
const input = uploadRef.value?.$el?.querySelector('input')
|
||||
if (input) {
|
||||
input.click()
|
||||
}
|
||||
}
|
||||
|
||||
const submitUpload = async () => {
|
||||
if (fileList.value.length === 0) {
|
||||
ElMessage.warning('请先选择文件')
|
||||
return
|
||||
}
|
||||
|
||||
// 保存上传前的文件数量
|
||||
const prevFileCount = files.value.length
|
||||
|
||||
// 先关闭对话框
|
||||
uploadDialogVisible.value = false
|
||||
ElMessage.success('已开始上传文件')
|
||||
|
||||
// 设置上传状态,防止显示空状态
|
||||
uploading.value = true
|
||||
|
||||
// 在后台逐个上传(不等待上传完成)
|
||||
const uploadPromises = fileList.value.map(async (item) => {
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', item.raw)
|
||||
await fileApi.upload(projectId.value, formData)
|
||||
} catch (error) {
|
||||
console.error('上传失败:', error)
|
||||
}
|
||||
})
|
||||
|
||||
// 立即刷新文件列表,显示新增的文件(状态为 processing)
|
||||
await fetchFiles()
|
||||
|
||||
// 如果之前没有文件,需要等待上传的Promise完成后再刷新一次
|
||||
if (prevFileCount === 0) {
|
||||
await Promise.all(uploadPromises)
|
||||
await fetchFiles()
|
||||
}
|
||||
|
||||
// 持续轮询文件列表,直到没有 processing 状态的文件
|
||||
const pollInterval = setInterval(async () => {
|
||||
await fetchFiles()
|
||||
// 检查是否还有处理中的文件
|
||||
const hasProcessing = files.value.some(f => f.status === 'processing')
|
||||
if (!hasProcessing) {
|
||||
clearInterval(pollInterval)
|
||||
uploading.value = false
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
// 最多轮询60秒
|
||||
setTimeout(() => {
|
||||
clearInterval(pollInterval)
|
||||
uploading.value = false
|
||||
}, 60000)
|
||||
}
|
||||
|
||||
const handleDelete = async (file) => {
|
||||
try {
|
||||
deletingFile.value = true
|
||||
await fileApi.delete(projectId.value, file.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchFiles()
|
||||
} catch (error) {
|
||||
ElMessage.error('删除失败')
|
||||
} finally {
|
||||
deletingFile.value = false
|
||||
deleteDialogVisible.value = false
|
||||
pendingDeleteFile.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const openDeleteDialog = (file) => {
|
||||
pendingDeleteFile.value = file
|
||||
deleteDialogVisible.value = true
|
||||
}
|
||||
|
||||
const confirmDeleteFile = async () => {
|
||||
if (!pendingDeleteFile.value) return
|
||||
await handleDelete(pendingDeleteFile.value)
|
||||
}
|
||||
|
||||
const handlePreview = async (file) => {
|
||||
previewFile.value = file
|
||||
previewVisible.value = true
|
||||
previewContent.value = ''
|
||||
previewError.value = ''
|
||||
previewLoading.value = true
|
||||
previewMode.value = 'source'
|
||||
|
||||
try {
|
||||
await loadPreviewContent()
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadPreviewContent = async () => {
|
||||
if (!previewFile.value) return
|
||||
previewLoading.value = true
|
||||
previewContent.value = ''
|
||||
previewError.value = ''
|
||||
isPdfPreview.value = false
|
||||
pdfDataUrl.value = ''
|
||||
|
||||
try {
|
||||
const endpoint = previewMode.value === 'source' ? 'raw' : 'content'
|
||||
const response = await fetch(`/api/v1/projects/${projectId.value}/files/${previewFile.value.id}/${endpoint}`)
|
||||
|
||||
if (response.ok) {
|
||||
const text = await response.text()
|
||||
if (text.startsWith('data:application/pdf;base64,')) {
|
||||
isPdfPreview.value = true
|
||||
pdfDataUrl.value = text
|
||||
previewContent.value = ''
|
||||
} else {
|
||||
previewContent.value = text
|
||||
}
|
||||
} else if (response.status === 404) {
|
||||
previewError.value = previewMode.value === 'source'
|
||||
? '源文件不存在或已被删除'
|
||||
: 'Markdown 内容不存在,请等待处理完成'
|
||||
} else if (response.status === 500) {
|
||||
previewError.value = '服务器内部错误,请稍后重试'
|
||||
} else {
|
||||
previewError.value = `加载失败 (${response.status})`
|
||||
}
|
||||
} catch (error) {
|
||||
previewError.value = '网络错误,请检查网络连接'
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const switchPreviewMode = async (mode) => {
|
||||
previewMode.value = mode
|
||||
await loadPreviewContent()
|
||||
}
|
||||
|
||||
const getFileIcon = (type) => {
|
||||
const map = { pdf: 'Document', docx: 'Document', xlsx: 'Grid', csv: 'Document', epub: 'Notebook', md: 'Document', txt: 'Document' }
|
||||
return map[type] || 'Document'
|
||||
}
|
||||
|
||||
const getTypeColor = (type) => {
|
||||
const map = {
|
||||
pdf: '#ef4444',
|
||||
docx: '#3b82f6',
|
||||
xlsx: '#22c55e',
|
||||
csv: '#22c55e',
|
||||
epub: '#f59e0b',
|
||||
md: '#8b5cf6',
|
||||
txt: '#6b7280'
|
||||
}
|
||||
return map[type] || '#6b7280'
|
||||
}
|
||||
|
||||
const getFileExt = (filename) => {
|
||||
if (!filename) return ''
|
||||
const ext = filename.split('.').pop()?.toLowerCase()
|
||||
return ext ? '.' + ext : ''
|
||||
}
|
||||
|
||||
const getStatusText = (status) => {
|
||||
const map = {
|
||||
processing: '处理中',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
pending: '待处理'
|
||||
}
|
||||
return map[status] || '未知'
|
||||
}
|
||||
|
||||
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', hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
onMounted(() => fetchFiles())
|
||||
|
||||
return {
|
||||
route,
|
||||
projectId,
|
||||
loading,
|
||||
files,
|
||||
filterStatus,
|
||||
isInitialLoad,
|
||||
filteredFiles,
|
||||
uploadDialogVisible,
|
||||
uploading,
|
||||
uploadRef,
|
||||
fileList,
|
||||
deleteDialogVisible,
|
||||
pendingDeleteFile,
|
||||
deletingFile,
|
||||
selectedFiles,
|
||||
isAllSelected,
|
||||
selectedCount,
|
||||
toggleSelectAll,
|
||||
toggleSelect,
|
||||
isSelected,
|
||||
clearSelection,
|
||||
batchDeleteDialogVisible,
|
||||
batchDeleting,
|
||||
batchDeleteFiles,
|
||||
batchDelete,
|
||||
executeBatchDelete,
|
||||
previewVisible,
|
||||
previewFile,
|
||||
previewContent,
|
||||
previewLoading,
|
||||
previewMode,
|
||||
isPdfPreview,
|
||||
pdfDataUrl,
|
||||
previewError,
|
||||
completedFiles,
|
||||
processingFiles,
|
||||
failedFiles,
|
||||
fetchFiles,
|
||||
handleUpload,
|
||||
handleChange,
|
||||
handleRemove,
|
||||
triggerUpload,
|
||||
submitUpload,
|
||||
handleDelete,
|
||||
openDeleteDialog,
|
||||
confirmDeleteFile,
|
||||
handlePreview,
|
||||
loadPreviewContent,
|
||||
switchPreviewMode,
|
||||
getFileIcon,
|
||||
getTypeColor,
|
||||
getFileExt,
|
||||
getStatusText,
|
||||
formatSize,
|
||||
formatDate
|
||||
}
|
||||
}
|
||||
})
|
||||
252
frontend/src/page-logic/ProjectQuestionPage.ts
Normal file
252
frontend/src/page-logic/ProjectQuestionPage.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import { defineComponent } from 'vue'
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { chunkApi, questionApi, modelApi } from '@/core/api'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'QuestionManage',
|
||||
setup() {
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => route.params.id)
|
||||
|
||||
const loading = ref(false)
|
||||
const isInitialLoad = ref(true)
|
||||
const generating = ref(false)
|
||||
const questions = ref([])
|
||||
const chunks = ref([])
|
||||
const availableModels = ref([])
|
||||
const showGenerateDialog = ref(false)
|
||||
const filterStatus = ref('')
|
||||
const chunkMap = ref({})
|
||||
|
||||
const DEFAULT_GENERATE_PROMPT = '你是一名高质量中文问答数据构建助手。请基于给定 chunk 内容生成准确、自然、可用于训练的数据集问答对。问题必须清晰具体,答案必须直接来自内容或基于内容做合理概括,不要编造原文没有的信息,不要输出与目录、导航、页眉页脚、噪声文字相关的问题。'
|
||||
|
||||
const generateConfig = reactive({
|
||||
model_id: '',
|
||||
chunk_ids: [],
|
||||
count: 3,
|
||||
dirty_data_filter: true,
|
||||
thinking_mode: true,
|
||||
preset_prompt: DEFAULT_GENERATE_PROMPT
|
||||
})
|
||||
|
||||
// Multi-select
|
||||
const selectedQuestions = ref([])
|
||||
|
||||
const filteredQuestions = computed(() => {
|
||||
if (!filterStatus.value) return questions.value
|
||||
return questions.value.filter(q => q.source === filterStatus.value)
|
||||
})
|
||||
|
||||
const generatedCount = computed(() => questions.value.filter(q => q.source === 'generated').length)
|
||||
const manualCount = computed(() => questions.value.filter(q => q.source === 'manual').length)
|
||||
const failedCount = computed(() => questions.value.filter(q => q.status === 'failed').length)
|
||||
const generateModels = computed(() => {
|
||||
return availableModels.value.filter(model => {
|
||||
const type = normalizeModelType(model.model_type, model.model_name)
|
||||
return type === 'chat' || type === 'vlm'
|
||||
})
|
||||
})
|
||||
|
||||
const isAllSelected = computed(() => filteredQuestions.value.length > 0 && selectedQuestions.value.length === filteredQuestions.value.length)
|
||||
const selectedCount = computed(() => selectedQuestions.value.length)
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (isAllSelected.value) {
|
||||
selectedQuestions.value = []
|
||||
} else {
|
||||
selectedQuestions.value = filteredQuestions.value.map(q => q.id)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelect = (id) => {
|
||||
const index = selectedQuestions.value.indexOf(id)
|
||||
if (index === -1) {
|
||||
selectedQuestions.value.push(id)
|
||||
} else {
|
||||
selectedQuestions.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const isSelected = (id) => selectedQuestions.value.includes(id)
|
||||
|
||||
const clearSelection = () => {
|
||||
selectedQuestions.value = []
|
||||
}
|
||||
|
||||
const batchDelete = async () => {
|
||||
if (selectedQuestions.value.length === 0) return
|
||||
try {
|
||||
for (const id of selectedQuestions.value) {
|
||||
await questionApi.delete(projectId.value, id)
|
||||
}
|
||||
ElMessage.success(`已删除 ${selectedQuestions.value.length} 个问题`)
|
||||
selectedQuestions.value = []
|
||||
fetchQuestions()
|
||||
} catch (error) {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeModelType = (modelType, modelName = '') => {
|
||||
if (modelType && modelType !== 'chat') {
|
||||
return modelType
|
||||
}
|
||||
const normalizedName = String(modelName).trim().toLowerCase()
|
||||
if (['rerank', 'bce-reranker', 'gte-rerank'].some(keyword => normalizedName.includes(keyword))) return 'rerank'
|
||||
if (['embedding', 'embed', 'text-embedding', 'bge-', 'gte-', 'm3e', 'e5-', 'jina-embeddings'].some(keyword => normalizedName.includes(keyword))) return 'embedding'
|
||||
if (['vl', 'vision', 'visual', 'multimodal', 'qwen-vl', 'gpt-4o'].some(keyword => normalizedName.includes(keyword))) return 'vlm'
|
||||
return 'chat'
|
||||
}
|
||||
|
||||
const getProviderLabel = (provider) => {
|
||||
const map = {
|
||||
openai: 'OpenAI Compatible',
|
||||
minimax: 'MiniMax',
|
||||
glm: 'GLM',
|
||||
ali: '阿里云百炼'
|
||||
}
|
||||
return map[provider] || provider
|
||||
}
|
||||
|
||||
const fetchAvailableModels = async () => {
|
||||
try {
|
||||
const res = await modelApi.list()
|
||||
availableModels.value = Array.isArray(res) ? res : (res?.data || [])
|
||||
if (!generateConfig.model_id && generateModels.value.length) {
|
||||
const defaultModel = generateModels.value.find(model => model.is_default === 'true') || generateModels.value[0]
|
||||
generateConfig.model_id = defaultModel?.id || ''
|
||||
}
|
||||
} catch (error) {
|
||||
availableModels.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const fetchAllChunks = async () => {
|
||||
const allChunks = []
|
||||
let page = 1
|
||||
let total = 0
|
||||
do {
|
||||
const res = await chunkApi.list(projectId.value, { page, page_size: 100 })
|
||||
const items = res.items || res.data || []
|
||||
total = res.total || res.pagination?.total || items.length
|
||||
allChunks.push(...items)
|
||||
page += 1
|
||||
} while (allChunks.length < total)
|
||||
return allChunks
|
||||
}
|
||||
|
||||
const fetchQuestions = async () => {
|
||||
const wasInitial = isInitialLoad.value
|
||||
loading.value = true
|
||||
try {
|
||||
const [chunkList, questionRes] = await Promise.all([
|
||||
fetchAllChunks(),
|
||||
questionApi.list(projectId.value, { page: 1, page_size: 500 })
|
||||
])
|
||||
chunks.value = chunkList
|
||||
chunkMap.value = Object.fromEntries(chunkList.map(chunk => [chunk.id, chunk]))
|
||||
questions.value = questionRes.items || questionRes.data || []
|
||||
} catch (error) {
|
||||
questions.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (wasInitial) {
|
||||
isInitialLoad.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (generateConfig.chunk_ids.length === 0) {
|
||||
ElMessage.warning('请选择文本块')
|
||||
return
|
||||
}
|
||||
if (!generateConfig.model_id) {
|
||||
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': '#22c55e', 'summary': '#818cf8', 'reasoning': '#f59e0b' }
|
||||
return map[type] || '#818cf8'
|
||||
}
|
||||
|
||||
const getTypeName = (type) => {
|
||||
const map = { 'fact': '事实性', 'summary': '总结性', 'reasoning': '推理性' }
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
const getSourceName = (source) => {
|
||||
const map = { 'generated': 'AI生成', 'manual': '手动', 'failed': '失败' }
|
||||
return map[source] || source
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAvailableModels()
|
||||
fetchQuestions()
|
||||
})
|
||||
|
||||
return {
|
||||
route,
|
||||
projectId,
|
||||
loading,
|
||||
isInitialLoad,
|
||||
generating,
|
||||
questions,
|
||||
chunks,
|
||||
availableModels,
|
||||
showGenerateDialog,
|
||||
filterStatus,
|
||||
chunkMap,
|
||||
DEFAULT_GENERATE_PROMPT,
|
||||
generateConfig,
|
||||
selectedQuestions,
|
||||
filteredQuestions,
|
||||
generatedCount,
|
||||
manualCount,
|
||||
failedCount,
|
||||
generateModels,
|
||||
isAllSelected,
|
||||
selectedCount,
|
||||
toggleSelectAll,
|
||||
toggleSelect,
|
||||
isSelected,
|
||||
clearSelection,
|
||||
batchDelete,
|
||||
normalizeModelType,
|
||||
getProviderLabel,
|
||||
fetchAvailableModels,
|
||||
fetchAllChunks,
|
||||
fetchQuestions,
|
||||
handleGenerate,
|
||||
handleDelete,
|
||||
getTypeColor,
|
||||
getTypeName,
|
||||
getSourceName
|
||||
}
|
||||
}
|
||||
})
|
||||
839
frontend/src/page-logic/ProjectTextSplitPage.ts
Normal file
839
frontend/src/page-logic/ProjectTextSplitPage.ts
Normal file
@@ -0,0 +1,839 @@
|
||||
import { defineComponent } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { fileApi, chunkApi, modelApi, questionApi } from '@/core/api'
|
||||
import DeleteDialog from '@/shared/components/common/DeleteDialog.vue'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'TextSplit',
|
||||
components: { DeleteDialog },
|
||||
setup() {
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => route.params.id)
|
||||
|
||||
const loading = ref(false)
|
||||
const splitting = ref(false)
|
||||
const files = ref([])
|
||||
const filterStatus = ref('')
|
||||
const fileChunks = ref({})
|
||||
const isInitialLoad = ref(true)
|
||||
const availableModels = ref([])
|
||||
const filePollingTimer = ref(null)
|
||||
const generateDialogVisible = ref(false)
|
||||
const generatingQuestions = ref(false)
|
||||
|
||||
const DEFAULT_GENERATE_PROMPT = '你是一名高质量中文问答数据构建助手。请基于给定 chunk 内容生成准确、自然、可用于训练的数据集问答对。问题必须清晰具体,答案必须直接来自内容或基于内容做合理概括,不要编造原文没有的信息,不要输出与目录、导航、页眉页脚、噪声文字相关的问题。'
|
||||
|
||||
// Multi-select
|
||||
const selectedFiles = ref([])
|
||||
const splitDialogVisible = ref(false)
|
||||
|
||||
// Chunk Preview Dialog
|
||||
const chunkPreviewVisible = ref(false)
|
||||
const previewFile = ref(null)
|
||||
const previewChunks = ref([])
|
||||
const previewLoading = ref(false)
|
||||
const savingChunks = ref(false)
|
||||
const deletingChunkId = ref('')
|
||||
const deletingFileChunksId = ref('')
|
||||
const deleteDialogVisible = ref(false)
|
||||
const deleteDialogMode = ref('')
|
||||
const deleteDialogTarget = ref(null)
|
||||
const previewSearch = ref('')
|
||||
const previewFilter = ref('all')
|
||||
const previewJumpInput = ref('')
|
||||
const selectedPreviewChunkId = ref('')
|
||||
|
||||
const isAllSelected = computed(() => filteredFiles.value.length > 0 && selectedFiles.value.length === filteredFiles.value.length)
|
||||
const selectedCount = computed(() => selectedFiles.value.length)
|
||||
|
||||
const filteredFiles = computed(() => {
|
||||
if (!filterStatus.value) return files.value
|
||||
return files.value.filter(f => {
|
||||
if (filterStatus.value === 'completed') {
|
||||
return fileChunks.value[f.id]
|
||||
}
|
||||
if (filterStatus.value === 'processing') {
|
||||
return f.status === 'processing'
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (isAllSelected.value) {
|
||||
selectedFiles.value = []
|
||||
} else {
|
||||
selectedFiles.value = filteredFiles.value.map(f => f.id)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelect = (fileId) => {
|
||||
const index = selectedFiles.value.indexOf(fileId)
|
||||
if (index === -1) {
|
||||
selectedFiles.value.push(fileId)
|
||||
} else {
|
||||
selectedFiles.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const isSelected = (fileId) => selectedFiles.value.includes(fileId)
|
||||
|
||||
const clearSelection = () => {
|
||||
selectedFiles.value = []
|
||||
}
|
||||
|
||||
const splitConfig = reactive({
|
||||
method: 'recursive',
|
||||
chunk_size: 500,
|
||||
overlap: 50,
|
||||
separator: '\n\n',
|
||||
embedding_model_id: '',
|
||||
similarity_threshold: 0.3,
|
||||
min_chunk_size: 100,
|
||||
})
|
||||
|
||||
const generateConfig = reactive({
|
||||
model_id: '',
|
||||
dirty_data_filter: true,
|
||||
thinking_mode: true,
|
||||
preset_prompt: DEFAULT_GENERATE_PROMPT,
|
||||
count: 3,
|
||||
})
|
||||
|
||||
const methods = [
|
||||
{ value: 'recursive', label: '递归字符', tag: '基础' },
|
||||
{ value: 'semantic', label: '句段优先', tag: '规则' },
|
||||
{ value: 'semantic_embedding', label: '语义嵌入', tag: 'API' },
|
||||
{ value: 'markdown_structure', label: 'Markdown', tag: '结构' },
|
||||
]
|
||||
|
||||
const completedFiles = computed(() => {
|
||||
return Object.keys(fileChunks.value).length
|
||||
})
|
||||
|
||||
const processingCount = computed(() => {
|
||||
return files.value.filter(f => f.status === 'processing').length
|
||||
})
|
||||
|
||||
const totalChunks = computed(() => {
|
||||
return Object.values(fileChunks.value).reduce((sum, count) => sum + count, 0)
|
||||
})
|
||||
|
||||
const normalizeModelType = (modelType, modelName = '') => {
|
||||
if (modelType && modelType !== 'chat') {
|
||||
return modelType
|
||||
}
|
||||
|
||||
const normalizedName = String(modelName).trim().toLowerCase()
|
||||
|
||||
if (['rerank', 'bce-reranker', 'gte-rerank'].some(keyword => normalizedName.includes(keyword))) {
|
||||
return 'rerank'
|
||||
}
|
||||
|
||||
if ([
|
||||
'embedding',
|
||||
'embed',
|
||||
'text-embedding',
|
||||
'bge-',
|
||||
'bge_m3',
|
||||
'gte-',
|
||||
'm3e',
|
||||
'e5-',
|
||||
'jina-embeddings'
|
||||
].some(keyword => normalizedName.includes(keyword))) {
|
||||
return 'embedding'
|
||||
}
|
||||
|
||||
if (['vl', 'vision', 'visual', 'multimodal', 'qwen-vl', 'gpt-4o'].some(keyword => normalizedName.includes(keyword))) {
|
||||
return 'vlm'
|
||||
}
|
||||
|
||||
return 'chat'
|
||||
}
|
||||
|
||||
const embeddingModels = computed(() => {
|
||||
return availableModels.value.filter(model => normalizeModelType(model.model_type, model.model_name) === 'embedding')
|
||||
})
|
||||
|
||||
const selectedEmbeddingModel = computed(() => {
|
||||
return embeddingModels.value.find(model => model.id === splitConfig.embedding_model_id) || null
|
||||
})
|
||||
|
||||
const generateModels = computed(() => {
|
||||
return availableModels.value.filter(model => {
|
||||
const type = normalizeModelType(model.model_type, model.model_name)
|
||||
return type === 'chat' || type === 'vlm'
|
||||
})
|
||||
})
|
||||
|
||||
const getProviderLabel = (provider) => {
|
||||
const providerMap = {
|
||||
openai: 'OpenAI Compatible',
|
||||
minimax: 'MiniMax',
|
||||
glm: 'GLM',
|
||||
ali: '阿里云百炼'
|
||||
}
|
||||
return providerMap[provider] || provider
|
||||
}
|
||||
|
||||
const fetchAllChunks = async () => {
|
||||
const allChunks = []
|
||||
let page = 1
|
||||
let total = 0
|
||||
|
||||
do {
|
||||
const res = await chunkApi.list(projectId.value, { page, page_size: 100 })
|
||||
const items = res.items || res.data || []
|
||||
total = res.total || res.pagination?.total || items.length
|
||||
allChunks.push(...items)
|
||||
page += 1
|
||||
} while (allChunks.length < total)
|
||||
|
||||
return allChunks
|
||||
}
|
||||
|
||||
const goToModelSettings = () => {
|
||||
splitDialogVisible.value = false
|
||||
generateDialogVisible.value = false
|
||||
router.push('/models')
|
||||
}
|
||||
|
||||
const fetchAvailableModels = async () => {
|
||||
try {
|
||||
const res = await modelApi.list()
|
||||
if (Array.isArray(res)) {
|
||||
availableModels.value = res
|
||||
} else if (res?.data && Array.isArray(res.data)) {
|
||||
availableModels.value = res.data
|
||||
} else {
|
||||
availableModels.value = []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
availableModels.value = []
|
||||
}
|
||||
}
|
||||
|
||||
watch(embeddingModels, (models) => {
|
||||
if (!models.length) {
|
||||
splitConfig.embedding_model_id = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (!models.some(model => model.id === splitConfig.embedding_model_id)) {
|
||||
const defaultModel = models.find(model => model.is_default === 'true') || models[0]
|
||||
splitConfig.embedding_model_id = defaultModel?.id || ''
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
watch(generateModels, (models) => {
|
||||
if (!models.length) {
|
||||
generateConfig.model_id = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (!models.some(model => model.id === generateConfig.model_id)) {
|
||||
const defaultModel = models.find(model => model.is_default === 'true') || models[0]
|
||||
generateConfig.model_id = defaultModel?.id || ''
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const fetchFiles = async () => {
|
||||
const wasInitial = isInitialLoad.value
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fileApi.list(projectId.value)
|
||||
files.value = res || []
|
||||
// 获取每个文件的 chunk 数量
|
||||
await fetchChunksCount()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (wasInitial) {
|
||||
isInitialLoad.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fetchChunksCount = async () => {
|
||||
const counts = {}
|
||||
for (const file of files.value) {
|
||||
try {
|
||||
const res = await chunkApi.list(projectId.value, { file_id: file.id })
|
||||
const chunkList = res.items || res || []
|
||||
if (chunkList.length > 0) {
|
||||
counts[file.id] = chunkList.length
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
fileChunks.value = counts
|
||||
}
|
||||
|
||||
const openSplitDialog = () => {
|
||||
if (selectedFiles.value.length === 0) {
|
||||
ElMessage.warning('请先选择要分割的文件')
|
||||
return
|
||||
}
|
||||
if (!availableModels.value.length) {
|
||||
fetchAvailableModels()
|
||||
}
|
||||
splitDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleBatchSplit = async () => {
|
||||
if (selectedFiles.value.length === 0) {
|
||||
ElMessage.warning('请先选择文件')
|
||||
return
|
||||
}
|
||||
if (splitConfig.method === 'semantic_embedding' && !selectedEmbeddingModel.value) {
|
||||
ElMessage.warning('请先选择已配置的 embedding 模型')
|
||||
return
|
||||
}
|
||||
if (splitConfig.method === 'semantic_embedding' && !selectedEmbeddingModel.value?.api_key) {
|
||||
ElMessage.warning('当前 embedding 模型缺少 API Key,请先到模型配置补全')
|
||||
return
|
||||
}
|
||||
splitting.value = true
|
||||
splitDialogVisible.value = false
|
||||
|
||||
const successFiles = []
|
||||
const failedFiles = []
|
||||
|
||||
try {
|
||||
for (const fileId of selectedFiles.value) {
|
||||
const file = files.value.find(item => item.id === fileId)
|
||||
const payload = {
|
||||
file_id: fileId,
|
||||
method: splitConfig.method,
|
||||
chunk_size: splitConfig.chunk_size,
|
||||
overlap: splitConfig.overlap,
|
||||
separator: splitConfig.separator,
|
||||
similarity_threshold: splitConfig.similarity_threshold,
|
||||
min_chunk_size: splitConfig.min_chunk_size,
|
||||
}
|
||||
|
||||
if (splitConfig.method === 'semantic_embedding' && selectedEmbeddingModel.value) {
|
||||
payload.embedding_provider = selectedEmbeddingModel.value.provider
|
||||
payload.embedding_api_key = selectedEmbeddingModel.value.api_key
|
||||
payload.embedding_base_url = selectedEmbeddingModel.value.api_base
|
||||
payload.embedding_model = selectedEmbeddingModel.value.model_name
|
||||
}
|
||||
|
||||
try {
|
||||
await chunkApi.split(projectId.value, payload)
|
||||
successFiles.push(file?.filename || fileId)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
failedFiles.push({
|
||||
name: file?.filename || fileId,
|
||||
message: error?.message || '分割失败'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (successFiles.length && !failedFiles.length) {
|
||||
ElMessage.success(`已为 ${successFiles.length} 个文件启动后台分割任务`)
|
||||
} else if (successFiles.length && failedFiles.length) {
|
||||
ElMessage.warning(`已启动 ${successFiles.length} 个,失败 ${failedFiles.length} 个:${failedFiles[0].name} - ${failedFiles[0].message}`)
|
||||
} else if (failedFiles.length) {
|
||||
ElMessage.error(`分割失败:${failedFiles[0].name} - ${failedFiles[0].message}`)
|
||||
return
|
||||
}
|
||||
|
||||
// 清除选择
|
||||
selectedFiles.value = []
|
||||
|
||||
fetchFiles()
|
||||
} finally {
|
||||
splitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openGenerateDialog = () => {
|
||||
if (completedFiles.value === 0) {
|
||||
ElMessage.warning('没有已分割的文件可生成')
|
||||
return
|
||||
}
|
||||
if (!availableModels.value.length) {
|
||||
fetchAvailableModels()
|
||||
}
|
||||
generateDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleBatchGenerate = async () => {
|
||||
if (!generateConfig.model_id) {
|
||||
ElMessage.warning('请选择用于生成问答的大语言模型')
|
||||
return
|
||||
}
|
||||
|
||||
generatingQuestions.value = true
|
||||
try {
|
||||
const allChunks = await fetchAllChunks()
|
||||
if (!allChunks.length) {
|
||||
ElMessage.warning('当前项目还没有可用文本块')
|
||||
return
|
||||
}
|
||||
|
||||
await questionApi.generate(projectId.value, {
|
||||
chunk_ids: allChunks.map(chunk => chunk.id),
|
||||
model_id: generateConfig.model_id,
|
||||
dirty_data_filter: generateConfig.dirty_data_filter,
|
||||
thinking_mode: generateConfig.thinking_mode,
|
||||
preset_prompt: generateConfig.preset_prompt,
|
||||
count: generateConfig.count
|
||||
})
|
||||
|
||||
generateDialogVisible.value = false
|
||||
ElMessage.success(`已为 ${allChunks.length} 个文本块启动后台问答生成任务`)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
ElMessage.error(error?.message || '问答生成启动失败')
|
||||
} finally {
|
||||
generatingQuestions.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Chunk Preview Methods
|
||||
const openChunkPreview = async (file) => {
|
||||
previewFile.value = file
|
||||
previewSearch.value = ''
|
||||
previewFilter.value = 'all'
|
||||
previewJumpInput.value = ''
|
||||
selectedPreviewChunkId.value = ''
|
||||
chunkPreviewVisible.value = true
|
||||
await fetchPreviewChunks(file.id)
|
||||
}
|
||||
|
||||
const previewChunksWithIndex = computed(() => {
|
||||
return previewChunks.value.map((chunk, index) => ({
|
||||
...chunk,
|
||||
displayIndex: index + 1
|
||||
}))
|
||||
})
|
||||
|
||||
const isChunkModified = (chunk) => chunk.editingContent !== chunk.content
|
||||
|
||||
const filteredPreviewChunks = computed(() => {
|
||||
const keyword = previewSearch.value.trim().toLowerCase()
|
||||
|
||||
return previewChunksWithIndex.value.filter(chunk => {
|
||||
const matchesFilter = previewFilter.value !== 'modified' || isChunkModified(chunk)
|
||||
if (!matchesFilter) return false
|
||||
if (!keyword) return true
|
||||
|
||||
return String(chunk.displayIndex).includes(keyword) || chunk.content.toLowerCase().includes(keyword) || chunk.editingContent.toLowerCase().includes(keyword)
|
||||
})
|
||||
})
|
||||
|
||||
const activePreviewChunk = computed(() => {
|
||||
return previewChunks.value.find(chunk => chunk.id === selectedPreviewChunkId.value) || null
|
||||
})
|
||||
|
||||
const activePreviewChunkIndex = computed(() => {
|
||||
const index = previewChunks.value.findIndex(chunk => chunk.id === selectedPreviewChunkId.value)
|
||||
return index === -1 ? 0 : index + 1
|
||||
})
|
||||
|
||||
const modifiedPreviewCount = computed(() => {
|
||||
return previewChunks.value.filter(chunk => isChunkModified(chunk)).length
|
||||
})
|
||||
|
||||
const deleteDialogTitle = computed(() => {
|
||||
if (deleteDialogMode.value === 'chunk') return '删除分片'
|
||||
if (deleteDialogMode.value === 'file-chunks') return '删除全部分块'
|
||||
return '删除'
|
||||
})
|
||||
|
||||
const deleteDialogItemName = computed(() => {
|
||||
if (deleteDialogMode.value === 'chunk') {
|
||||
return `块 ${activePreviewChunkIndex.value || ''}`.trim()
|
||||
}
|
||||
if (deleteDialogMode.value === 'file-chunks') {
|
||||
return deleteDialogTarget.value?.filename || ''
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const deleteDialogDetail = computed(() => {
|
||||
if (deleteDialogMode.value === 'chunk') {
|
||||
return '这会移除当前选中的单个文本分片,适用于清理错误切分或无效内容。'
|
||||
}
|
||||
if (deleteDialogMode.value === 'file-chunks') {
|
||||
return '这会清空当前文件已生成的全部分块,但不会删除文件本身。'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const deleteDialogWarning = computed(() => {
|
||||
if (deleteDialogMode.value === 'chunk') {
|
||||
return '删除后不可恢复,和该分片关联的后续内容可能需要重新生成。'
|
||||
}
|
||||
if (deleteDialogMode.value === 'file-chunks') {
|
||||
return '删除后不可恢复,该文件的全部分块将被清空,你需要重新执行分割才能恢复。'
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const deleteDialogConfirmText = computed(() => {
|
||||
if (deleteDialogMode.value === 'chunk') return '确认删除分片'
|
||||
if (deleteDialogMode.value === 'file-chunks') return '确认删除全部分块'
|
||||
return '确认删除'
|
||||
})
|
||||
|
||||
const deleteDialogLoading = computed(() => {
|
||||
if (deleteDialogMode.value === 'chunk') {
|
||||
return !!deleteDialogTarget.value && deletingChunkId.value === deleteDialogTarget.value.id
|
||||
}
|
||||
if (deleteDialogMode.value === 'file-chunks') {
|
||||
return !!deleteDialogTarget.value && deletingFileChunksId.value === deleteDialogTarget.value.id
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const selectPreviewChunk = (chunkId) => {
|
||||
selectedPreviewChunkId.value = chunkId
|
||||
}
|
||||
|
||||
const getChunkSnippet = (chunk) => {
|
||||
return (chunk.editingContent || chunk.content || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 110) || '空白分片'
|
||||
}
|
||||
|
||||
const jumpToChunk = () => {
|
||||
const index = Number(previewJumpInput.value)
|
||||
if (!Number.isInteger(index) || index < 1) {
|
||||
ElMessage.warning('请输入有效的块号')
|
||||
return
|
||||
}
|
||||
|
||||
const target = previewChunksWithIndex.value.find(chunk => chunk.displayIndex === index)
|
||||
if (!target) {
|
||||
ElMessage.warning('未找到对应块号')
|
||||
return
|
||||
}
|
||||
|
||||
if (previewFilter.value === 'modified' && !isChunkModified(target)) {
|
||||
previewFilter.value = 'all'
|
||||
}
|
||||
|
||||
selectedPreviewChunkId.value = target.id
|
||||
}
|
||||
|
||||
const resetChunk = (chunk) => {
|
||||
chunk.editingContent = chunk.content
|
||||
}
|
||||
|
||||
const openDeleteChunkDialog = (chunk) => {
|
||||
deleteDialogMode.value = 'chunk'
|
||||
deleteDialogTarget.value = chunk
|
||||
deleteDialogVisible.value = true
|
||||
}
|
||||
|
||||
const openDeleteFileChunksDialog = (file) => {
|
||||
deleteDialogMode.value = 'file-chunks'
|
||||
deleteDialogTarget.value = file
|
||||
deleteDialogVisible.value = true
|
||||
}
|
||||
|
||||
const fetchPreviewChunks = async (fileId) => {
|
||||
previewLoading.value = true
|
||||
try {
|
||||
const res = await chunkApi.list(projectId.value, { file_id: fileId })
|
||||
previewChunks.value = (res.items || res || []).map(c => ({
|
||||
...c,
|
||||
editingContent: c.content
|
||||
}))
|
||||
selectedPreviewChunkId.value = previewChunks.value[0]?.id || ''
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
ElMessage.error('获取 chunks 失败')
|
||||
} finally {
|
||||
previewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveChunk = async (chunk) => {
|
||||
savingChunks.value = true
|
||||
try {
|
||||
await chunkApi.update(projectId.value, chunk.id, {
|
||||
content: chunk.editingContent
|
||||
})
|
||||
ElMessage.success('保存成功')
|
||||
// Update local state
|
||||
chunk.content = chunk.editingContent
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
ElMessage.error('保存失败')
|
||||
} finally {
|
||||
savingChunks.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteChunk = async (chunk) => {
|
||||
deletingChunkId.value = chunk.id
|
||||
try {
|
||||
await chunkApi.delete(projectId.value, chunk.id)
|
||||
previewChunks.value = previewChunks.value.filter(item => item.id !== chunk.id)
|
||||
|
||||
if (previewFile.value?.id) {
|
||||
const nextCount = Math.max((fileChunks.value[previewFile.value.id] || 1) - 1, 0)
|
||||
if (nextCount > 0) {
|
||||
fileChunks.value = {
|
||||
...fileChunks.value,
|
||||
[previewFile.value.id]: nextCount
|
||||
}
|
||||
} else {
|
||||
const { [previewFile.value.id]: _, ...rest } = fileChunks.value
|
||||
fileChunks.value = rest
|
||||
}
|
||||
}
|
||||
|
||||
ElMessage.success('删除成功')
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
ElMessage.error('删除失败')
|
||||
} finally {
|
||||
deletingChunkId.value = ''
|
||||
deleteDialogVisible.value = false
|
||||
deleteDialogMode.value = ''
|
||||
deleteDialogTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const deleteFileChunks = async (file) => {
|
||||
deletingFileChunksId.value = file.id
|
||||
try {
|
||||
const allChunks = []
|
||||
let page = 1
|
||||
let total = 0
|
||||
|
||||
do {
|
||||
const res = await chunkApi.list(projectId.value, {
|
||||
file_id: file.id,
|
||||
page,
|
||||
page_size: 100
|
||||
})
|
||||
const items = res.items || []
|
||||
total = res.total || items.length
|
||||
allChunks.push(...items)
|
||||
page += 1
|
||||
} while (allChunks.length < total)
|
||||
|
||||
for (const chunk of allChunks) {
|
||||
await chunkApi.delete(projectId.value, chunk.id)
|
||||
}
|
||||
|
||||
const { [file.id]: _, ...rest } = fileChunks.value
|
||||
fileChunks.value = rest
|
||||
|
||||
if (previewFile.value?.id === file.id) {
|
||||
previewChunks.value = []
|
||||
selectedPreviewChunkId.value = ''
|
||||
}
|
||||
|
||||
ElMessage.success(`已删除 ${allChunks.length} 个分块`)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
ElMessage.error('删除全部分块失败')
|
||||
} finally {
|
||||
deletingFileChunksId.value = ''
|
||||
deleteDialogVisible.value = false
|
||||
deleteDialogMode.value = ''
|
||||
deleteDialogTarget.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDeleteAction = async () => {
|
||||
if (!deleteDialogTarget.value) return
|
||||
|
||||
if (deleteDialogMode.value === 'chunk') {
|
||||
await deleteChunk(deleteDialogTarget.value)
|
||||
return
|
||||
}
|
||||
|
||||
if (deleteDialogMode.value === 'file-chunks') {
|
||||
await deleteFileChunks(deleteDialogTarget.value)
|
||||
}
|
||||
}
|
||||
|
||||
watch(filteredPreviewChunks, (chunks) => {
|
||||
if (!chunks.length) {
|
||||
selectedPreviewChunkId.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (!chunks.some(chunk => chunk.id === selectedPreviewChunkId.value)) {
|
||||
selectedPreviewChunkId.value = chunks[0].id
|
||||
}
|
||||
})
|
||||
|
||||
const refreshFiles = () => {
|
||||
fetchFiles()
|
||||
}
|
||||
|
||||
const startFilePolling = () => {
|
||||
if (filePollingTimer.value) return
|
||||
filePollingTimer.value = window.setInterval(() => {
|
||||
fetchFiles()
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
const stopFilePolling = () => {
|
||||
if (!filePollingTimer.value) return
|
||||
window.clearInterval(filePollingTimer.value)
|
||||
filePollingTimer.value = null
|
||||
}
|
||||
|
||||
const formatSize = (bytes) => {
|
||||
if (!bytes) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let i = 0
|
||||
while (bytes >= 1024 && i < units.length - 1) {
|
||||
bytes /= 1024
|
||||
i++
|
||||
}
|
||||
return `${bytes.toFixed(1)} ${units[i]}`
|
||||
}
|
||||
|
||||
const getFileBg = (type) => {
|
||||
const colors = {
|
||||
pdf: '#ef4444',
|
||||
docx: '#3b82f6',
|
||||
xlsx: '#22c55e',
|
||||
csv: '#f59e0b',
|
||||
md: '#8b5cf6',
|
||||
txt: '#6b7280',
|
||||
epub: '#ec4899'
|
||||
}
|
||||
return colors[type] || '#6b7280'
|
||||
}
|
||||
|
||||
const getFileIcon = (type) => {
|
||||
const icons = {
|
||||
pdf: 'Document',
|
||||
docx: 'Document',
|
||||
xlsx: 'Grid',
|
||||
csv: 'Grid',
|
||||
md: 'Document',
|
||||
txt: 'Document',
|
||||
epub: 'Book'
|
||||
}
|
||||
return icons[type] || 'Document'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchAvailableModels()
|
||||
fetchFiles()
|
||||
})
|
||||
|
||||
watch(processingCount, (count) => {
|
||||
if (count > 0) {
|
||||
startFilePolling()
|
||||
} else {
|
||||
stopFilePolling()
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
onUnmounted(() => {
|
||||
stopFilePolling()
|
||||
})
|
||||
|
||||
return {
|
||||
router,
|
||||
route,
|
||||
projectId,
|
||||
loading,
|
||||
splitting,
|
||||
files,
|
||||
filterStatus,
|
||||
fileChunks,
|
||||
isInitialLoad,
|
||||
availableModels,
|
||||
filePollingTimer,
|
||||
generateDialogVisible,
|
||||
generatingQuestions,
|
||||
DEFAULT_GENERATE_PROMPT,
|
||||
selectedFiles,
|
||||
splitDialogVisible,
|
||||
chunkPreviewVisible,
|
||||
previewFile,
|
||||
previewChunks,
|
||||
previewLoading,
|
||||
savingChunks,
|
||||
deletingChunkId,
|
||||
deletingFileChunksId,
|
||||
deleteDialogVisible,
|
||||
deleteDialogMode,
|
||||
deleteDialogTarget,
|
||||
previewSearch,
|
||||
previewFilter,
|
||||
previewJumpInput,
|
||||
selectedPreviewChunkId,
|
||||
isAllSelected,
|
||||
selectedCount,
|
||||
filteredFiles,
|
||||
toggleSelectAll,
|
||||
toggleSelect,
|
||||
isSelected,
|
||||
clearSelection,
|
||||
splitConfig,
|
||||
generateConfig,
|
||||
methods,
|
||||
completedFiles,
|
||||
processingCount,
|
||||
totalChunks,
|
||||
normalizeModelType,
|
||||
embeddingModels,
|
||||
selectedEmbeddingModel,
|
||||
generateModels,
|
||||
getProviderLabel,
|
||||
fetchAllChunks,
|
||||
goToModelSettings,
|
||||
fetchAvailableModels,
|
||||
fetchFiles,
|
||||
fetchChunksCount,
|
||||
openSplitDialog,
|
||||
handleBatchSplit,
|
||||
openGenerateDialog,
|
||||
handleBatchGenerate,
|
||||
openChunkPreview,
|
||||
previewChunksWithIndex,
|
||||
isChunkModified,
|
||||
filteredPreviewChunks,
|
||||
activePreviewChunk,
|
||||
activePreviewChunkIndex,
|
||||
modifiedPreviewCount,
|
||||
deleteDialogTitle,
|
||||
deleteDialogItemName,
|
||||
deleteDialogDetail,
|
||||
deleteDialogWarning,
|
||||
deleteDialogConfirmText,
|
||||
deleteDialogLoading,
|
||||
selectPreviewChunk,
|
||||
getChunkSnippet,
|
||||
jumpToChunk,
|
||||
resetChunk,
|
||||
openDeleteChunkDialog,
|
||||
openDeleteFileChunksDialog,
|
||||
fetchPreviewChunks,
|
||||
saveChunk,
|
||||
deleteChunk,
|
||||
deleteFileChunks,
|
||||
confirmDeleteAction,
|
||||
refreshFiles,
|
||||
startFilePolling,
|
||||
stopFilePolling,
|
||||
formatSize,
|
||||
getFileBg,
|
||||
getFileIcon
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -137,7 +137,7 @@ import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Link, Download, FolderAdd } from '@element-plus/icons-vue'
|
||||
import { projectApi } from '@/api'
|
||||
import { projectApi } from '@/core/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -257,14 +257,14 @@ import { ref, onMounted, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { FolderAdd, Check, Connection, Clock, Lock, TrendCharts, ArrowLeft, ArrowRight } from '@element-plus/icons-vue'
|
||||
import { projectApi } from '@/api'
|
||||
import type { Project, ProjectCreate } from '@/types'
|
||||
import { projectApi } from '@/core/api'
|
||||
import type { Project, ProjectCreate } from '@/shared/types'
|
||||
|
||||
// Components
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import ProjectCard from '@/components/common/ProjectCard.vue'
|
||||
import CreateProjectDialog from '@/components/common/CreateProjectDialog.vue'
|
||||
import DeleteDialog from '@/components/common/DeleteDialog.vue'
|
||||
import EmptyState from '@/shared/components/common/EmptyState.vue'
|
||||
import ProjectCard from '@/shared/components/common/ProjectCard.vue'
|
||||
import CreateProjectDialog from '@/shared/components/common/CreateProjectDialog.vue'
|
||||
import DeleteDialog from '@/shared/components/common/DeleteDialog.vue'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
262
frontend/src/pages/ModelSettingsPage.vue
Normal file
262
frontend/src/pages/ModelSettingsPage.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<div class="model-settings">
|
||||
<!-- 背景效果 -->
|
||||
<div class="bg-effects">
|
||||
<div class="glow-orb glow-1"></div>
|
||||
<div class="glow-orb glow-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- 页面头部 -->
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<el-button text class="back-btn" @click="goHome">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
<span>返回</span>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="header-content">
|
||||
<h1 class="page-title">
|
||||
<el-icon class="title-icon"><Cpu /></el-icon>
|
||||
模型管理
|
||||
</h1>
|
||||
<p class="page-subtitle">管理您的 AI 模型 API</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button type="primary" class="add-btn" @click="openAddDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>添加模型</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 主内容 -->
|
||||
<main class="page-main">
|
||||
<!-- 模型列表 -->
|
||||
<section class="models-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">
|
||||
<span class="title-line"></span>
|
||||
已配置的模型
|
||||
</h2>
|
||||
<span class="count-badge">{{ models.length }} 个</span>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-if="models.length === 0 && !loading" class="empty-state">
|
||||
<div class="empty-illustration">
|
||||
<div class="pulse-ring"></div>
|
||||
<el-icon size="48"><Setting /></el-icon>
|
||||
</div>
|
||||
<h3>暂无模型配置</h3>
|
||||
<p>添加您的第一个 AI 模型开始使用</p>
|
||||
<el-button type="primary" @click="openAddDialog">添加模型</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 模型卡片 -->
|
||||
<div v-else class="models-grid">
|
||||
<article
|
||||
v-for="(model, index) in models"
|
||||
:key="model.id"
|
||||
class="model-card"
|
||||
:class="{ 'is-default': model.is_default === 'true' }"
|
||||
:style="{ '--delay': index * 0.08 + 's' }"
|
||||
>
|
||||
<div class="card-glow"></div>
|
||||
|
||||
<!-- 默认标识 -->
|
||||
<div v-if="model.is_default === 'true'" class="default-badge">
|
||||
<el-icon><Star /></el-icon>
|
||||
默认
|
||||
</div>
|
||||
|
||||
<!-- 提供商图标 -->
|
||||
<div class="provider-logo" :class="model.provider">
|
||||
{{ getProviderAbbr(model.provider) }}
|
||||
</div>
|
||||
|
||||
<!-- 模型信息 -->
|
||||
<div class="model-info">
|
||||
<div class="model-name-row">
|
||||
<h3 class="model-name">{{ model.model_name }}</h3>
|
||||
<span class="model-type-badge" :class="`type-${normalizeModelType(model.model_type, model.model_name)}`">
|
||||
{{ getModelTypeLabel(model.model_type, model.model_name) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="model-endpoint">
|
||||
<el-icon><Link /></el-icon>
|
||||
{{ model.api_base || '默认端点' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作 -->
|
||||
<div class="card-footer">
|
||||
<div class="status-badge" :class="model.connection_status">
|
||||
<span class="status-dot" :class="model.connection_status"></span>
|
||||
<template v-if="model.connection_status === 'connected'">已联通</template>
|
||||
<template v-else-if="model.connection_status === 'failed'">连接失败</template>
|
||||
<template v-else>未测试</template>
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<el-button text class="action-btn test" @click="testConnection(model)">
|
||||
测试连接
|
||||
</el-button>
|
||||
<el-button text class="action-btn delete" @click="confirmDelete(model)">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<el-dialog
|
||||
v-model="showAddDialog"
|
||||
:show-close="false"
|
||||
width="560px"
|
||||
class="add-dialog"
|
||||
:append-to-body="true"
|
||||
>
|
||||
<template #header>
|
||||
<div class="dialog-header">
|
||||
<div class="dialog-icon">
|
||||
<el-icon size="20"><Plus /></el-icon>
|
||||
</div>
|
||||
<div class="dialog-title">
|
||||
<h3>添加模型</h3>
|
||||
<p>配置新的 AI 模型</p>
|
||||
</div>
|
||||
<button class="dialog-close" @click="showAddDialog = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form :model="modelForm" label-position="top" class="model-form">
|
||||
<el-form-item label="选择提供商">
|
||||
<el-select
|
||||
v-model="modelForm.provider"
|
||||
placeholder="选择 AI 服务提供商"
|
||||
size="large"
|
||||
class="provider-select"
|
||||
popper-class="provider-select-dropdown"
|
||||
>
|
||||
<el-option
|
||||
v-for="provider in providers"
|
||||
:key="provider.value"
|
||||
:label="provider.label"
|
||||
:value="provider.value"
|
||||
>
|
||||
<div class="provider-option-item">
|
||||
<span class="provider-icon">{{ provider.abbr }}</span>
|
||||
<div class="provider-copy">
|
||||
<span>{{ provider.label }}</span>
|
||||
<small>{{ provider.desc }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="模型类型">
|
||||
<el-select
|
||||
v-model="modelForm.model_type"
|
||||
placeholder="选择模型类型"
|
||||
size="large"
|
||||
class="provider-select"
|
||||
>
|
||||
<el-option
|
||||
v-for="type in modelTypes"
|
||||
:key="type.value"
|
||||
:label="type.label"
|
||||
:value="type.value"
|
||||
>
|
||||
<div class="provider-option-item">
|
||||
<span class="provider-icon">{{ type.abbr }}</span>
|
||||
<div class="provider-copy">
|
||||
<span>{{ type.label }}</span>
|
||||
<small>{{ type.desc }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="模型名称" required>
|
||||
<el-input
|
||||
v-model="modelForm.model_name"
|
||||
placeholder="例如: gpt-4o-mini / text-embedding-v3-small"
|
||||
size="large"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="API Key" required>
|
||||
<el-input
|
||||
v-model="modelForm.api_key"
|
||||
type="password"
|
||||
placeholder="输入 API Key"
|
||||
size="large"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="API 地址">
|
||||
<el-input
|
||||
v-model="modelForm.api_base"
|
||||
placeholder="自定义 API 地址"
|
||||
size="large"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="modelForm.is_default">
|
||||
设为默认模型
|
||||
</el-checkbox>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="dialog-footer">
|
||||
<el-button @click="showAddDialog = false" size="large">取消</el-button>
|
||||
<el-button type="primary" @click="addModel" :loading="submitting" size="large">
|
||||
添加模型
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="deleteDialogVisible"
|
||||
:show-close="false"
|
||||
width="400"
|
||||
class="delete-dialog"
|
||||
:append-to-body="true"
|
||||
>
|
||||
<template #header>
|
||||
<div class="delete-header">
|
||||
<div class="delete-icon">
|
||||
<el-icon size="24"><WarningFilled /></el-icon>
|
||||
</div>
|
||||
<h3>确认删除</h3>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="delete-content">
|
||||
<p>确定要删除模型 <strong>{{ modelToDelete?.model_name }}</strong> 吗?</p>
|
||||
<p class="warning-text">此操作不可恢复</p>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="delete-footer">
|
||||
<el-button @click="deleteDialogVisible = false" size="large">取消</el-button>
|
||||
<el-button type="danger" @click="handleDelete" :loading="deleting" size="large">
|
||||
确认删除
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="../page-logic/ModelSettingsPage.ts"></script>
|
||||
<style scoped src="../styles/pages/model-settings.css"></style>
|
||||
@@ -128,7 +128,7 @@
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { datasetApi } from '@/api'
|
||||
import { datasetApi } from '@/core/api'
|
||||
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => route.params.id)
|
||||
@@ -294,7 +294,7 @@
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { evalApi } from '@/api'
|
||||
import { evalApi } from '@/core/api'
|
||||
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => route.params.id)
|
||||
319
frontend/src/pages/ProjectFilePage.vue
Normal file
319
frontend/src/pages/ProjectFilePage.vue
Normal file
@@ -0,0 +1,319 @@
|
||||
<template>
|
||||
<div class="file-manage">
|
||||
<!-- Header -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2 class="page-title">文件管理</h2>
|
||||
<p class="page-subtitle">管理您的文档集合</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="handleUpload" class="upload-btn">
|
||||
<el-icon><Upload /></el-icon>
|
||||
<span>上传文件</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="stats-grid">
|
||||
<div
|
||||
class="stat-card stat-total"
|
||||
:class="{ active: filterStatus === '' }"
|
||||
@click="filterStatus = ''"
|
||||
>
|
||||
<div class="stat-glow"></div>
|
||||
<div class="stat-inner">
|
||||
<div class="stat-icon-wrap">
|
||||
<el-icon size="24"><Document /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ files.length }}</span>
|
||||
<span class="stat-label">总文件数</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat-card stat-completed"
|
||||
:class="{ active: filterStatus === 'completed' }"
|
||||
@click="filterStatus = filterStatus === 'completed' ? '' : 'completed'"
|
||||
>
|
||||
<div class="stat-glow"></div>
|
||||
<div class="stat-inner">
|
||||
<div class="stat-icon-wrap">
|
||||
<el-icon size="24"><CircleCheckFilled /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ completedFiles }}</span>
|
||||
<span class="stat-label">已完成</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat-card stat-processing"
|
||||
:class="{ active: filterStatus === 'processing' }"
|
||||
@click="filterStatus = filterStatus === 'processing' ? '' : 'processing'"
|
||||
>
|
||||
<div class="stat-glow"></div>
|
||||
<div class="stat-inner">
|
||||
<div class="stat-icon-wrap">
|
||||
<el-icon size="24"><Loading /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ processingFiles.length }}</span>
|
||||
<span class="stat-label">处理中</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat-card stat-failed"
|
||||
:class="{ active: filterStatus === 'failed' }"
|
||||
@click="filterStatus = filterStatus === 'failed' ? '' : 'failed'"
|
||||
>
|
||||
<div class="stat-glow"></div>
|
||||
<div class="stat-inner">
|
||||
<div class="stat-icon-wrap">
|
||||
<el-icon size="24"><CircleCloseFilled /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ failedFiles }}</span>
|
||||
<span class="stat-label">失败</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File List Container -->
|
||||
<div class="file-container" v-loading="loading && isInitialLoad">
|
||||
<!-- Empty State -->
|
||||
<div v-if="!loading && !isInitialLoad && filteredFiles.length === 0 && !uploading" class="empty-state">
|
||||
<div class="empty-illustration">
|
||||
<div class="orbit orbit-1"></div>
|
||||
<div class="orbit orbit-2"></div>
|
||||
<div class="orbit orbit-3"></div>
|
||||
<div class="empty-core">
|
||||
<el-icon size="40"><FolderOpened /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="empty-title">暂无文件</h3>
|
||||
<p class="empty-desc">上传您的第一个文档,开启智能处理之旅</p>
|
||||
</div>
|
||||
|
||||
<!-- Files Table -->
|
||||
<div v-else class="files-table-wrapper">
|
||||
<!-- Table Header -->
|
||||
<div class="table-header">
|
||||
<div class="table-select">
|
||||
<el-checkbox
|
||||
:model-value="isAllSelected"
|
||||
@change="toggleSelectAll"
|
||||
class="select-all"
|
||||
>
|
||||
<span v-if="selectedCount > 0" class="selected-text">已选择 {{ selectedCount }} 项</span>
|
||||
<span v-else>全选</span>
|
||||
</el-checkbox>
|
||||
</div>
|
||||
<div class="table-actions" v-if="selectedCount > 0">
|
||||
<el-button type="danger" size="small" plain @click="clearSelection" class="batch-clear-btn">
|
||||
<el-icon><Close /></el-icon>
|
||||
<span>清除选择</span>
|
||||
</el-button>
|
||||
<el-button type="danger" size="small" plain @click="batchDelete" class="batch-delete-btn">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>批量删除</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table Body -->
|
||||
<div class="files-table">
|
||||
<div
|
||||
v-for="(file, index) in filteredFiles"
|
||||
:key="file.id"
|
||||
class="file-row"
|
||||
:class="{
|
||||
'is-selected': isSelected(file.id),
|
||||
'is-processing': file.status === 'processing',
|
||||
'row-animated': isInitialLoad
|
||||
}"
|
||||
:style="{ '--delay': index * 0.04 + 's' }"
|
||||
@click="toggleSelect(file.id)"
|
||||
>
|
||||
<!-- Select Checkbox -->
|
||||
<div class="col-select" @click.stop>
|
||||
<el-checkbox
|
||||
:model-value="isSelected(file.id)"
|
||||
@change="toggleSelect(file.id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- File Icon -->
|
||||
<div class="col-icon">
|
||||
<div class="file-type-icon" :style="{ '--type-color': getTypeColor(file.file_type) }">
|
||||
<el-icon size="18">
|
||||
<component :is="getFileIcon(file.file_type)" />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Name -->
|
||||
<div class="col-name">
|
||||
<span class="filename-text">{{ file.filename }}</span>
|
||||
<span class="file-ext">{{ getFileExt(file.filename) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Size -->
|
||||
<div class="col-size">
|
||||
{{ formatSize(file.size) }}
|
||||
</div>
|
||||
|
||||
<!-- Date -->
|
||||
<div class="col-date">
|
||||
{{ formatDate(file.created_at) }}
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="col-status">
|
||||
<div class="status-pill" :class="'status-' + file.status">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">{{ getStatusText(file.status) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="col-actions" @click.stop>
|
||||
<el-tooltip content="预览" placement="top" v-if="file.status === 'completed'">
|
||||
<el-button text size="small" class="action-btn preview" @click="handlePreview(file)">
|
||||
<el-icon><View /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-button text size="small" class="action-btn delete" @click="openDeleteDialog(file)">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Dialog -->
|
||||
<el-dialog v-model="uploadDialogVisible" title="上传文件" width="520px" class="upload-dialog" :close-on-click-modal="false">
|
||||
<div class="upload-area" @click="triggerUpload">
|
||||
<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"
|
||||
style="display: none;"
|
||||
/>
|
||||
<div class="upload-content">
|
||||
<div class="upload-illustration">
|
||||
<div class="upload-ring"></div>
|
||||
<div class="upload-core">
|
||||
<el-icon size="32"><UploadFilled /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="upload-text">
|
||||
拖拽文件到此处,或 <em>点击选择</em>
|
||||
</div>
|
||||
<div class="upload-hint">
|
||||
支持 PDF、DOCX、Excel、EPUB、Markdown 等格式
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected Files -->
|
||||
<div v-if="fileList.length > 0" class="selected-area">
|
||||
<div class="selected-header">
|
||||
<span>已选择 <strong>{{ fileList.length }}</strong> 个文件</span>
|
||||
<el-button text size="small" @click="fileList = []">清空</el-button>
|
||||
</div>
|
||||
<div class="selected-list">
|
||||
<div v-for="item in fileList" :key="item.uid" class="selected-item">
|
||||
<el-icon size="14"><Document /></el-icon>
|
||||
<span>{{ item.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="uploadDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitUpload" :loading="uploading" :disabled="fileList.length === 0">
|
||||
开始上传
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="previewVisible"
|
||||
class="preview-backdrop"
|
||||
@click="previewVisible = false"
|
||||
></div>
|
||||
</Transition>
|
||||
|
||||
<Transition name="slide-right">
|
||||
<div v-if="previewVisible" class="preview-modal">
|
||||
<div class="preview-header">
|
||||
<div class="header-title">
|
||||
<el-icon class="title-icon"><Document /></el-icon>
|
||||
<span class="filename">{{ previewFile?.filename }}</span>
|
||||
</div>
|
||||
<el-button class="close-btn" text @click="previewVisible = false">
|
||||
<el-icon><Close /></el-icon>
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="preview-tabs-wrapper">
|
||||
<div class="preview-tabs">
|
||||
<button
|
||||
class="tab-item"
|
||||
:class="{ active: previewMode === 'source' }"
|
||||
@click="switchPreviewMode('source')"
|
||||
>
|
||||
源文件
|
||||
</button>
|
||||
<button
|
||||
class="tab-item"
|
||||
:class="{ active: previewMode === 'markdown' }"
|
||||
@click="switchPreviewMode('markdown')"
|
||||
>
|
||||
Markdown
|
||||
</button>
|
||||
<div class="tab-indicator" :class="{ 'at-right': previewMode === 'markdown' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-content" v-loading="previewLoading">
|
||||
<iframe v-if="isPdfPreview && pdfDataUrl" :src="pdfDataUrl" class="pdf-viewer"></iframe>
|
||||
<pre v-else-if="previewContent" class="code-content">{{ previewContent }}</pre>
|
||||
<div v-else-if="!previewLoading && !isPdfPreview" class="preview-empty">
|
||||
<el-icon size="32"><Document /></el-icon>
|
||||
<span>暂无内容</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<DeleteDialog
|
||||
v-model:visible="deleteDialogVisible"
|
||||
title="删除文件"
|
||||
:item-name="pendingDeleteFile?.filename || ''"
|
||||
detail-text="该操作会移除原始文件以及关联的处理结果,请确认当前项目内不再需要它。"
|
||||
warning-text="删除后不可恢复,文件相关的分割结果和后续数据将一并失效。"
|
||||
confirm-text="确认删除文件"
|
||||
:loading="deletingFile"
|
||||
@confirm="confirmDeleteFile"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="../page-logic/ProjectFilePage.ts"></script>
|
||||
<style scoped src="../styles/pages/project-file.css"></style>
|
||||
@@ -44,7 +44,7 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { projectApi } from '@/api'
|
||||
import { projectApi } from '@/core/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const route = useRoute()
|
||||
244
frontend/src/pages/ProjectQuestionPage.vue
Normal file
244
frontend/src/pages/ProjectQuestionPage.vue
Normal file
@@ -0,0 +1,244 @@
|
||||
<template>
|
||||
<div class="question-manage">
|
||||
<!-- Header -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2 class="page-title">问答管理</h2>
|
||||
<p class="page-subtitle">管理和生成问答数据</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="showGenerateDialog = true" class="generate-btn">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>生成问题</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="stats-grid">
|
||||
<div
|
||||
class="stat-card stat-total"
|
||||
:class="{ active: filterStatus === '' }"
|
||||
@click="filterStatus = ''"
|
||||
>
|
||||
<div class="stat-glow"></div>
|
||||
<div class="stat-inner">
|
||||
<div class="stat-icon-wrap">
|
||||
<el-icon size="24"><ChatDotSquare /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ questions.length }}</span>
|
||||
<span class="stat-label">总问题数</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat-card stat-completed"
|
||||
:class="{ active: filterStatus === 'generated' }"
|
||||
@click="filterStatus = filterStatus === 'generated' ? '' : 'generated'"
|
||||
>
|
||||
<div class="stat-glow"></div>
|
||||
<div class="stat-inner">
|
||||
<div class="stat-icon-wrap">
|
||||
<el-icon size="24"><MagicStick /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ generatedCount }}</span>
|
||||
<span class="stat-label">AI 生成</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat-card stat-processing"
|
||||
:class="{ active: filterStatus === 'manual' }"
|
||||
@click="filterStatus = filterStatus === 'manual' ? '' : 'manual'"
|
||||
>
|
||||
<div class="stat-glow"></div>
|
||||
<div class="stat-inner">
|
||||
<div class="stat-icon-wrap">
|
||||
<el-icon size="24"><EditPen /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ manualCount }}</span>
|
||||
<span class="stat-label">手动添加</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat-card stat-failed"
|
||||
:class="{ active: filterStatus === 'failed' }"
|
||||
@click="filterStatus = filterStatus === 'failed' ? '' : 'failed'"
|
||||
>
|
||||
<div class="stat-glow"></div>
|
||||
<div class="stat-inner">
|
||||
<div class="stat-icon-wrap">
|
||||
<el-icon size="24"><CircleCloseFilled /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ failedCount }}</span>
|
||||
<span class="stat-label">失败</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Question Container -->
|
||||
<div class="question-container" v-loading="loading && isInitialLoad">
|
||||
<!-- Empty State -->
|
||||
<div v-if="!loading && !isInitialLoad && filteredQuestions.length === 0" class="empty-state">
|
||||
<div class="empty-illustration">
|
||||
<div class="orbit orbit-1"></div>
|
||||
<div class="orbit orbit-2"></div>
|
||||
<div class="orbit orbit-3"></div>
|
||||
<div class="empty-core">
|
||||
<el-icon size="40"><ChatDotSquare /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="empty-title">暂无问答数据</h3>
|
||||
<p class="empty-desc">生成您的第一个问答数据集</p>
|
||||
<el-button type="primary" @click="showGenerateDialog = true" class="empty-btn">生成问题</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Question Table -->
|
||||
<div v-else class="question-table-wrapper">
|
||||
<!-- Table Header -->
|
||||
<div class="table-header">
|
||||
<div class="table-select">
|
||||
<el-checkbox
|
||||
:model-value="isAllSelected"
|
||||
@change="toggleSelectAll"
|
||||
class="select-all"
|
||||
>
|
||||
<span v-if="selectedCount > 0" class="selected-text">已选择 {{ selectedCount }} 项</span>
|
||||
<span v-else>全选</span>
|
||||
</el-checkbox>
|
||||
</div>
|
||||
<div class="table-actions" v-if="selectedCount > 0">
|
||||
<el-button type="danger" size="small" plain @click="clearSelection" class="batch-clear-btn">
|
||||
<el-icon><Close /></el-icon>
|
||||
<span>清除选择</span>
|
||||
</el-button>
|
||||
<el-button type="danger" size="small" plain @click="batchDelete" class="batch-delete-btn">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>批量删除</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table Body -->
|
||||
<div class="question-table">
|
||||
<div
|
||||
v-for="(question, index) in filteredQuestions"
|
||||
:key="question.id"
|
||||
class="question-row"
|
||||
:class="{
|
||||
'is-selected': isSelected(question.id),
|
||||
'row-animated': isInitialLoad
|
||||
}"
|
||||
:style="{ '--delay': index * 0.04 + 's' }"
|
||||
@click="toggleSelect(question.id)"
|
||||
>
|
||||
<!-- Select Checkbox -->
|
||||
<div class="col-select" @click.stop>
|
||||
<el-checkbox
|
||||
:model-value="isSelected(question.id)"
|
||||
@change="toggleSelect(question.id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Question Content -->
|
||||
<div class="col-content">
|
||||
<div class="question-text">{{ question.content }}</div>
|
||||
<div class="answer-text" v-if="question.answer">答: {{ question.answer }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Type -->
|
||||
<div class="col-type">
|
||||
<el-tag size="small" :style="{ '--tag-color': getTypeColor(question.question_type) }" effect="dark">
|
||||
{{ getTypeName(question.question_type) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<!-- Source -->
|
||||
<div class="col-source">
|
||||
<span class="source-badge" :class="'source-' + question.source">{{ getSourceName(question.source) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="col-actions" @click.stop>
|
||||
<el-popconfirm title="确定删除此问题?" @confirm="handleDelete(question)">
|
||||
<template #reference>
|
||||
<el-button text size="small" class="action-btn delete">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="showGenerateDialog" title="生成问题" width="640px" class="generate-dialog">
|
||||
<el-form :model="generateConfig" label-position="top">
|
||||
<el-form-item label="生成模型">
|
||||
<el-select
|
||||
v-model="generateConfig.model_id"
|
||||
placeholder="选择 chat / vlm 模型"
|
||||
style="width: 100%"
|
||||
size="large"
|
||||
>
|
||||
<el-option
|
||||
v-for="model in generateModels"
|
||||
:key="model.id"
|
||||
:label="`${model.model_name} · ${getProviderLabel(model.provider)}`"
|
||||
:value="model.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<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="8" size="large" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="生成策略">
|
||||
<div style="display: flex; gap: 16px; flex-wrap: wrap;">
|
||||
<el-checkbox v-model="generateConfig.dirty_data_filter">脏数据过滤</el-checkbox>
|
||||
<el-checkbox v-model="generateConfig.thinking_mode">思考模式</el-checkbox>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="预设提示语">
|
||||
<el-input
|
||||
v-model="generateConfig.preset_prompt"
|
||||
type="textarea"
|
||||
:rows="8"
|
||||
resize="none"
|
||||
/>
|
||||
</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 lang="ts" src="../page-logic/ProjectQuestionPage.ts"></script>
|
||||
<style scoped src="../styles/pages/project-question.css"></style>
|
||||
@@ -95,7 +95,7 @@
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { projectApi } from '@/api'
|
||||
import { projectApi } from '@/core/api'
|
||||
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => route.params.id)
|
||||
830
frontend/src/pages/ProjectTextSplitPage.vue
Normal file
830
frontend/src/pages/ProjectTextSplitPage.vue
Normal file
@@ -0,0 +1,830 @@
|
||||
<template>
|
||||
<div class="text-split">
|
||||
<!-- Header -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2 class="page-title">分割生成</h2>
|
||||
<p class="page-subtitle">选择文件进行智能分割</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button
|
||||
@click="openSplitDialog"
|
||||
:disabled="selectedFiles.length === 0"
|
||||
class="split-btn"
|
||||
>
|
||||
<el-icon><ChatDotSquare /></el-icon>
|
||||
<span>批量分割</span>
|
||||
</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
@click="openGenerateDialog"
|
||||
:disabled="completedFiles === 0"
|
||||
class="generate-btn"
|
||||
>
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
<span>批量生成</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="stats-grid">
|
||||
<div
|
||||
class="stat-card stat-total"
|
||||
:class="{ active: filterStatus === '' }"
|
||||
@click="filterStatus = ''"
|
||||
>
|
||||
<div class="stat-glow"></div>
|
||||
<div class="stat-inner">
|
||||
<div class="stat-icon-wrap">
|
||||
<el-icon size="24"><Document /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ files.length }}</span>
|
||||
<span class="stat-label">总文件</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat-card stat-completed"
|
||||
:class="{ active: filterStatus === 'completed' }"
|
||||
@click="filterStatus = filterStatus === 'completed' ? '' : 'completed'"
|
||||
>
|
||||
<div class="stat-glow"></div>
|
||||
<div class="stat-inner">
|
||||
<div class="stat-icon-wrap">
|
||||
<el-icon size="24"><CircleCheckFilled /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ completedFiles }}</span>
|
||||
<span class="stat-label">已分割</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat-card stat-processing"
|
||||
:class="{ active: filterStatus === 'processing' }"
|
||||
@click="filterStatus = filterStatus === 'processing' ? '' : 'processing'"
|
||||
>
|
||||
<div class="stat-glow"></div>
|
||||
<div class="stat-inner">
|
||||
<div class="stat-icon-wrap">
|
||||
<el-icon size="24"><Loading /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ processingCount }}</span>
|
||||
<span class="stat-label">分割中</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat-card stat-chunks"
|
||||
>
|
||||
<div class="stat-glow"></div>
|
||||
<div class="stat-inner">
|
||||
<div class="stat-icon-wrap">
|
||||
<el-icon size="24"><List /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ totalChunks }}</span>
|
||||
<span class="stat-label">总文本块</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File List / Split View -->
|
||||
<div class="content-area">
|
||||
<!-- Empty State -->
|
||||
<div v-if="!loading && !isInitialLoad && filteredFiles.length === 0" class="empty-state">
|
||||
<div class="empty-illustration">
|
||||
<div class="orbit orbit-1"></div>
|
||||
<div class="orbit orbit-2"></div>
|
||||
<div class="orbit orbit-3"></div>
|
||||
<div class="empty-core">
|
||||
<el-icon size="40"><FolderOpened /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="empty-title">暂无可分割文件</h3>
|
||||
<p class="empty-desc">请先在文件管理中上传文档</p>
|
||||
</div>
|
||||
|
||||
<!-- File Table -->
|
||||
<div v-else class="files-table-wrapper">
|
||||
<div class="table-header">
|
||||
<div class="table-select">
|
||||
<el-checkbox
|
||||
:model-value="isAllSelected"
|
||||
@change="toggleSelectAll"
|
||||
class="select-all"
|
||||
>
|
||||
<span v-if="selectedCount > 0" class="selected-text">已选择 {{ selectedCount }} 项</span>
|
||||
<span v-else>全选</span>
|
||||
</el-checkbox>
|
||||
</div>
|
||||
<div class="table-actions" v-if="selectedCount > 0">
|
||||
<el-button type="danger" size="small" plain @click="clearSelection" class="batch-clear-btn">
|
||||
<el-icon><Close /></el-icon>
|
||||
<span>清除选择</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="files-list">
|
||||
<div
|
||||
v-for="(file, index) in filteredFiles"
|
||||
:key="file.id"
|
||||
class="file-row"
|
||||
:class="{
|
||||
'is-selected': isSelected(file.id),
|
||||
'is-processing': file.status === 'processing'
|
||||
}"
|
||||
:style="{ '--delay': index * 0.04 + 's' }"
|
||||
@click="toggleSelect(file.id)"
|
||||
>
|
||||
<div class="col-select">
|
||||
<el-checkbox
|
||||
:model-value="isSelected(file.id)"
|
||||
@click.stop
|
||||
@change="toggleSelect(file.id)"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-icon">
|
||||
<div class="file-type-icon" style="background: #8b5cf6;">
|
||||
<el-icon size="18" color="white">
|
||||
<Document />
|
||||
</el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-name">
|
||||
<span class="file-name">{{ file.filename }}.md</span>
|
||||
<span class="file-meta">{{ formatSize(file.size) }}</span>
|
||||
</div>
|
||||
<div class="col-chunks">
|
||||
<span v-if="fileChunks[file.id]" class="chunk-count">
|
||||
{{ fileChunks[file.id] }} 块
|
||||
</span>
|
||||
<span v-else class="chunk-count empty">-</span>
|
||||
</div>
|
||||
<div class="col-status">
|
||||
<div v-if="file.status === 'processing'" class="status-badge processing">
|
||||
<el-icon class="spin" size="12"><Loading /></el-icon>
|
||||
<span>分割中</span>
|
||||
</div>
|
||||
<div v-else-if="fileChunks[file.id]" class="status-badge success">
|
||||
<el-icon size="12"><CircleCheckFilled /></el-icon>
|
||||
<span>已完成</span>
|
||||
</div>
|
||||
<div v-else class="status-badge pending">
|
||||
<el-icon size="12"><Clock /></el-icon>
|
||||
<span>待分割</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-operations" v-if="fileChunks[file.id]">
|
||||
<el-tooltip content="预览修改" placement="top">
|
||||
<el-button text size="small" class="op-btn" @click.stop="openChunkPreview(file)">
|
||||
<el-icon><Edit /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
<el-tooltip content="删除该文件的所有块" placement="top">
|
||||
<el-button
|
||||
text
|
||||
size="small"
|
||||
class="op-btn delete"
|
||||
:loading="deletingFileChunksId === file.id"
|
||||
@click.stop="openDeleteFileChunksDialog(file)"
|
||||
>
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Batch Split Dialog -->
|
||||
<Teleport to="body">
|
||||
<Transition name="dialog-fade">
|
||||
<div v-if="splitDialogVisible" class="split-dialog-overlay" @click.self="splitDialogVisible = false">
|
||||
<div class="split-dialog">
|
||||
<!-- Header -->
|
||||
<div class="dialog-header">
|
||||
<div class="header-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h3>批量分割配置</h3>
|
||||
<span class="header-sub">{{ selectedFiles.length }} 个文件待处理</span>
|
||||
</div>
|
||||
<button class="close-btn" @click="splitDialogVisible = false">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Dialog Body -->
|
||||
<div class="dialog-body">
|
||||
<!-- Split Method Selector -->
|
||||
<div class="method-selector">
|
||||
<div class="method-label">
|
||||
<span class="label-icon">◆</span>
|
||||
<span>分割算法</span>
|
||||
</div>
|
||||
<div class="method-grid">
|
||||
<button
|
||||
v-for="m in methods"
|
||||
:key="m.value"
|
||||
class="method-btn"
|
||||
:class="{ active: splitConfig.method === m.value }"
|
||||
@click="splitConfig.method = m.value"
|
||||
>
|
||||
<span class="method-name">{{ m.label }}</span>
|
||||
<span class="method-tag">{{ m.tag }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Parameters Panel -->
|
||||
<div class="params-panel">
|
||||
<!-- Panel Grid Background -->
|
||||
<div class="panel-grid"></div>
|
||||
|
||||
<!-- Common Parameters -->
|
||||
<div class="param-section" v-if="splitConfig.method !== 'paragraph'">
|
||||
<div class="section-header">
|
||||
<span class="section-num">01</span>
|
||||
<span class="section-title">块大小控制</span>
|
||||
</div>
|
||||
<div class="param-row">
|
||||
<div class="param-info">
|
||||
<span class="param-name">chunk_size</span>
|
||||
<span class="param-desc">每个文本块的字符数</span>
|
||||
</div>
|
||||
<div class="param-control">
|
||||
<input
|
||||
type="range"
|
||||
v-model.number="splitConfig.chunk_size"
|
||||
:min="100"
|
||||
:max="2000"
|
||||
:step="100"
|
||||
class="cyber-slider"
|
||||
/>
|
||||
<div class="param-value">
|
||||
<span class="value-num">{{ splitConfig.chunk_size }}</span>
|
||||
<span class="value-unit">chars</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Visual Preview -->
|
||||
<div class="chunk-preview">
|
||||
<div class="preview-label">预览</div>
|
||||
<div class="preview-bars">
|
||||
<div
|
||||
v-for="i in 5"
|
||||
:key="i"
|
||||
class="preview-bar"
|
||||
:style="{ width: (splitConfig.chunk_size / 20) + 'px' }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="param-section" v-if="splitConfig.method !== 'sentence'">
|
||||
<div class="section-header">
|
||||
<span class="section-num">02</span>
|
||||
<span class="section-title">重叠控制</span>
|
||||
</div>
|
||||
<div class="param-row">
|
||||
<div class="param-info">
|
||||
<span class="param-name">overlap</span>
|
||||
<span class="param-desc">相邻块之间的重叠字符数</span>
|
||||
</div>
|
||||
<div class="param-control">
|
||||
<input
|
||||
type="range"
|
||||
v-model.number="splitConfig.overlap"
|
||||
:min="0"
|
||||
:max="500"
|
||||
:step="50"
|
||||
class="cyber-slider"
|
||||
/>
|
||||
<div class="param-value">
|
||||
<span class="value-num">{{ splitConfig.overlap }}</span>
|
||||
<span class="value-unit">chars</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Overlap Visual -->
|
||||
<div class="overlap-preview">
|
||||
<div class="overlap-block" :style="{ width: (splitConfig.chunk_size / 4) + 'px' }"></div>
|
||||
<div class="overlap-zone" :style="{ width: (splitConfig.overlap / 4) + 'px' }"></div>
|
||||
<div class="overlap-block" :style="{ width: (splitConfig.chunk_size / 4) + 'px' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Separator -->
|
||||
<div class="param-section" v-if="splitConfig.method === 'custom'">
|
||||
<div class="section-header">
|
||||
<span class="section-num">03</span>
|
||||
<span class="section-title">自定义分隔符</span>
|
||||
</div>
|
||||
<div class="param-row">
|
||||
<div class="param-info">
|
||||
<span class="param-name">separator</span>
|
||||
<span class="param-desc">用于分割文本的字符序列</span>
|
||||
</div>
|
||||
<div class="param-control full">
|
||||
<input
|
||||
type="text"
|
||||
v-model="splitConfig.separator"
|
||||
placeholder="\n\n"
|
||||
class="cyber-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="splitConfig.method === 'semantic'">
|
||||
<div class="param-section">
|
||||
<div class="section-header highlight">
|
||||
<span class="section-num">04</span>
|
||||
<span class="section-title">句段优先说明</span>
|
||||
<span class="section-badge">RULE</span>
|
||||
</div>
|
||||
<div class="param-row full">
|
||||
<div class="param-info">
|
||||
<span class="param-name">规则型切分</span>
|
||||
<span class="param-desc">按段落和句子边界优先切分,不调用 embedding API,更接近“句段优先的递归切分”。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="splitConfig.method === 'semantic_embedding'">
|
||||
<div class="param-section">
|
||||
<div class="section-header highlight">
|
||||
<span class="section-num">04</span>
|
||||
<span class="section-title">Embedding 模型</span>
|
||||
<span class="section-badge">API</span>
|
||||
</div>
|
||||
<div class="api-grid">
|
||||
<div class="param-row full">
|
||||
<div class="param-info">
|
||||
<span class="param-name">embedding_model</span>
|
||||
<span class="param-desc">直接使用模型管理中已配置的 embedding 模型</span>
|
||||
</div>
|
||||
<div class="param-control full">
|
||||
<select v-model="splitConfig.embedding_model_id" class="cyber-select" :disabled="embeddingModels.length === 0">
|
||||
<option value="" disabled>
|
||||
{{ embeddingModels.length ? '选择 embedding 模型' : '暂无可用 embedding 模型' }}
|
||||
</option>
|
||||
<option
|
||||
v-for="model in embeddingModels"
|
||||
:key="model.id"
|
||||
:value="model.id"
|
||||
>
|
||||
{{ model.model_name }} · {{ getProviderLabel(model.provider) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-row full" v-if="embeddingModels.length === 0">
|
||||
<div class="embedding-empty-hint">
|
||||
<span>还没有可用的 embedding 模型。</span>
|
||||
<button type="button" class="text-link-btn" @click="goToModelSettings">
|
||||
去模型配置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="param-section">
|
||||
<div class="section-header highlight">
|
||||
<span class="section-num">05</span>
|
||||
<span class="section-title">语义边界参数</span>
|
||||
<span class="section-badge">AI</span>
|
||||
</div>
|
||||
<div class="param-row">
|
||||
<div class="param-info">
|
||||
<span class="param-name">similarity_threshold</span>
|
||||
<span class="param-desc">越高越保守,越低越容易切出新块</span>
|
||||
</div>
|
||||
<div class="param-control">
|
||||
<input
|
||||
type="range"
|
||||
v-model.number="splitConfig.similarity_threshold"
|
||||
:min="0.1"
|
||||
:max="0.9"
|
||||
:step="0.05"
|
||||
class="cyber-slider accent"
|
||||
/>
|
||||
<div class="param-value accent">
|
||||
<span class="value-num">{{ splitConfig.similarity_threshold.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-row">
|
||||
<div class="param-info">
|
||||
<span class="param-name">min_chunk_size</span>
|
||||
<span class="param-desc">最小块大小,避免语义过碎</span>
|
||||
</div>
|
||||
<div class="param-control">
|
||||
<input
|
||||
type="range"
|
||||
v-model.number="splitConfig.min_chunk_size"
|
||||
:min="50"
|
||||
:max="500"
|
||||
:step="10"
|
||||
class="cyber-slider accent"
|
||||
/>
|
||||
<div class="param-value accent">
|
||||
<span class="value-num">{{ splitConfig.min_chunk_size }}</span>
|
||||
<span class="value-unit">chars</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dialog-footer">
|
||||
<button class="btn-cancel" @click="splitDialogVisible = false">
|
||||
<span>取消</span>
|
||||
</button>
|
||||
<button class="btn-confirm" @click="handleBatchSplit" :class="{ loading: splitting }">
|
||||
<span v-if="!splitting" class="btn-text">
|
||||
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||
</svg>
|
||||
开始生成
|
||||
</span>
|
||||
<span v-else class="btn-loading">
|
||||
<span class="loading-dot"></span>
|
||||
<span class="loading-dot"></span>
|
||||
<span class="loading-dot"></span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="dialog-fade">
|
||||
<div v-if="generateDialogVisible" class="split-dialog-overlay" @click.self="generateDialogVisible = false">
|
||||
<div class="split-dialog generate-dialog-shell">
|
||||
<div class="dialog-header">
|
||||
<div class="header-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2l2.4 5.8L20 10l-4 4 1 6-5-3-5 3 1-6-4-4 5.6-2.2L12 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h3>批量生成问答</h3>
|
||||
<span class="header-sub">面向当前项目全部已分割文本块</span>
|
||||
</div>
|
||||
<button class="close-btn" @click="generateDialogVisible = false">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dialog-body">
|
||||
<div class="params-panel">
|
||||
<div class="panel-grid"></div>
|
||||
|
||||
<div class="param-section">
|
||||
<div class="section-header">
|
||||
<span class="section-num">01</span>
|
||||
<span class="section-title">大语言模型</span>
|
||||
</div>
|
||||
<div class="param-row full">
|
||||
<div class="param-info">
|
||||
<span class="param-name">model</span>
|
||||
<span class="param-desc">选择用于生成问答对的 chat / vlm 模型</span>
|
||||
</div>
|
||||
<div class="param-control full">
|
||||
<el-select
|
||||
v-model="generateConfig.model_id"
|
||||
class="cyber-select generate-model-select"
|
||||
:disabled="generateModels.length === 0"
|
||||
:teleported="false"
|
||||
placement="bottom-start"
|
||||
popper-class="generate-model-dropdown"
|
||||
placeholder="选择生成模型"
|
||||
>
|
||||
<el-option
|
||||
v-for="model in generateModels"
|
||||
:key="model.id"
|
||||
:label="`${model.model_name} · ${getProviderLabel(model.provider)}`"
|
||||
:value="model.id"
|
||||
>
|
||||
<div class="generate-option">
|
||||
<div class="generate-option__title">{{ model.model_name }}</div>
|
||||
<div class="generate-option__meta">{{ getProviderLabel(model.provider) }}</div>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="param-row full" v-if="generateModels.length === 0">
|
||||
<div class="embedding-empty-hint">
|
||||
<span>还没有可用的 chat / vlm 模型。</span>
|
||||
<button type="button" class="text-link-btn" @click="goToModelSettings">
|
||||
去模型配置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="param-section generate-strategy-section">
|
||||
<div class="section-header">
|
||||
<span class="section-num">02</span>
|
||||
<span class="section-title">生成策略</span>
|
||||
</div>
|
||||
<div class="generate-strategy-grid">
|
||||
<button
|
||||
type="button"
|
||||
class="strategy-card"
|
||||
:class="{ active: generateConfig.dirty_data_filter }"
|
||||
@click="generateConfig.dirty_data_filter = !generateConfig.dirty_data_filter"
|
||||
>
|
||||
<div class="strategy-card__head">
|
||||
<span class="strategy-card__title">脏数据过滤</span>
|
||||
<span class="strategy-card__switch" :class="{ active: generateConfig.dirty_data_filter }">
|
||||
<span class="strategy-card__switch-handle"></span>
|
||||
</span>
|
||||
</div>
|
||||
<p class="strategy-card__desc">过滤目录、极短内容和明显噪声,减少无效调用。</p>
|
||||
<span class="strategy-card__state">{{ generateConfig.dirty_data_filter ? '已开启' : '已关闭' }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="strategy-card"
|
||||
:class="{ active: generateConfig.thinking_mode }"
|
||||
@click="generateConfig.thinking_mode = !generateConfig.thinking_mode"
|
||||
>
|
||||
<div class="strategy-card__head">
|
||||
<span class="strategy-card__title">思考模式</span>
|
||||
<span class="strategy-card__switch" :class="{ active: generateConfig.thinking_mode }">
|
||||
<span class="strategy-card__switch-handle"></span>
|
||||
</span>
|
||||
</div>
|
||||
<p class="strategy-card__desc">生成前强化内容分析,提升问题质量与覆盖度。</p>
|
||||
<span class="strategy-card__state">{{ generateConfig.thinking_mode ? '已开启' : '已关闭' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="param-row">
|
||||
<div class="param-info">
|
||||
<span class="param-name">single_chunk_count</span>
|
||||
<span class="param-desc">每个 chunk 生成多少组问答</span>
|
||||
</div>
|
||||
<div class="param-control">
|
||||
<input
|
||||
type="range"
|
||||
v-model.number="generateConfig.count"
|
||||
:min="1"
|
||||
:max="8"
|
||||
:step="1"
|
||||
class="cyber-slider accent"
|
||||
/>
|
||||
<div class="param-value accent">
|
||||
<span class="value-num">{{ generateConfig.count }}</span>
|
||||
<span class="value-unit">pairs</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="param-section generate-prompt-section">
|
||||
<div class="section-header">
|
||||
<span class="section-num">03</span>
|
||||
<span class="section-title">预设提示语</span>
|
||||
</div>
|
||||
<div class="generate-prompt-box">
|
||||
<textarea
|
||||
v-model="generateConfig.preset_prompt"
|
||||
class="cyber-textarea generate-prompt-textarea"
|
||||
rows="9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dialog-footer">
|
||||
<button class="btn-cancel" @click="generateDialogVisible = false">
|
||||
<span>取消</span>
|
||||
</button>
|
||||
<button class="btn-confirm" @click="handleBatchGenerate" :class="{ loading: generatingQuestions }">
|
||||
<span v-if="!generatingQuestions" class="btn-text">
|
||||
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polygon points="5 3 19 12 5 21 5 3"/>
|
||||
</svg>
|
||||
开始生成
|
||||
</span>
|
||||
<span v-else class="btn-loading">
|
||||
<span class="loading-dot"></span>
|
||||
<span class="loading-dot"></span>
|
||||
<span class="loading-dot"></span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body">
|
||||
<Transition name="dialog-fade">
|
||||
<div v-if="chunkPreviewVisible" class="chunk-preview-overlay" @click.self="chunkPreviewVisible = false">
|
||||
<div class="chunk-preview-dialog">
|
||||
<div class="dialog-header">
|
||||
<div class="header-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||
<path d="M2 17l10 5 10-5"/>
|
||||
<path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="header-text">
|
||||
<h3>预览修改</h3>
|
||||
<span class="header-sub">{{ previewFile?.filename }} - {{ previewChunks.length }} 个块</span>
|
||||
</div>
|
||||
<button class="close-btn" @click="chunkPreviewVisible = false">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="chunk-preview-toolbar">
|
||||
<label class="preview-search">
|
||||
<span class="preview-search-label">检索</span>
|
||||
<input
|
||||
v-model.trim="previewSearch"
|
||||
type="text"
|
||||
class="cyber-input"
|
||||
placeholder="搜索块内容、块号"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="preview-filter-group">
|
||||
<button
|
||||
class="preview-filter-btn"
|
||||
:class="{ active: previewFilter === 'all' }"
|
||||
@click="previewFilter = 'all'"
|
||||
>
|
||||
全部
|
||||
</button>
|
||||
<button
|
||||
class="preview-filter-btn"
|
||||
:class="{ active: previewFilter === 'modified' }"
|
||||
@click="previewFilter = 'modified'"
|
||||
>
|
||||
仅已修改
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label class="preview-jump">
|
||||
<span class="preview-search-label">跳转</span>
|
||||
<input
|
||||
v-model.trim="previewJumpInput"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
class="cyber-input"
|
||||
placeholder="块号"
|
||||
@keydown.enter.prevent="jumpToChunk"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="preview-stats">
|
||||
<span>{{ filteredPreviewChunks.length }} / {{ previewChunks.length }} 块</span>
|
||||
<span>{{ modifiedPreviewCount }} 已修改</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dialog-body chunk-workspace">
|
||||
<div v-if="previewLoading" class="loading-state">
|
||||
<span class="loading-dot"></span>
|
||||
<span class="loading-dot"></span>
|
||||
<span class="loading-dot"></span>
|
||||
</div>
|
||||
<div v-else-if="previewChunks.length" class="chunk-workspace-grid">
|
||||
<aside class="chunk-nav-panel">
|
||||
<div class="chunk-nav-list">
|
||||
<button
|
||||
v-for="chunk in filteredPreviewChunks"
|
||||
:key="chunk.id"
|
||||
class="chunk-nav-item"
|
||||
:class="{
|
||||
active: chunk.id === selectedPreviewChunkId,
|
||||
modified: isChunkModified(chunk)
|
||||
}"
|
||||
@click="selectPreviewChunk(chunk.id)"
|
||||
>
|
||||
<div class="chunk-nav-meta">
|
||||
<span class="chunk-nav-index">块 {{ chunk.displayIndex }}</span>
|
||||
<span class="chunk-nav-words">{{ chunk.word_count || 0 }} 字</span>
|
||||
</div>
|
||||
<p class="chunk-nav-snippet">{{ getChunkSnippet(chunk) }}</p>
|
||||
<div class="chunk-nav-footer">
|
||||
<span class="chunk-nav-status">{{ isChunkModified(chunk) ? '已修改' : '原始' }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section v-if="activePreviewChunk" class="chunk-editor-panel">
|
||||
<div class="chunk-item">
|
||||
<div class="chunk-header">
|
||||
<div class="chunk-header-main">
|
||||
<span class="chunk-index">块 {{ activePreviewChunkIndex }} / {{ previewChunks.length }}</span>
|
||||
<span class="chunk-state-pill" :class="{ modified: isChunkModified(activePreviewChunk) }">
|
||||
{{ isChunkModified(activePreviewChunk) ? '已修改未保存' : '内容未变更' }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="chunk-words">{{ activePreviewChunk.word_count || 0 }} 字</span>
|
||||
</div>
|
||||
<div class="chunk-form">
|
||||
<div class="form-group">
|
||||
<label>内容</label>
|
||||
<textarea
|
||||
v-model="activePreviewChunk.editingContent"
|
||||
class="cyber-textarea preview-editor"
|
||||
rows="20"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="chunk-actions">
|
||||
<button
|
||||
class="btn-secondary"
|
||||
:disabled="!isChunkModified(activePreviewChunk)"
|
||||
@click="resetChunk(activePreviewChunk)"
|
||||
>
|
||||
还原
|
||||
</button>
|
||||
<button
|
||||
class="btn-save"
|
||||
:class="{ loading: savingChunks }"
|
||||
:disabled="!isChunkModified(activePreviewChunk)"
|
||||
@click="saveChunk(activePreviewChunk)"
|
||||
>
|
||||
<span v-if="!savingChunks">保存</span>
|
||||
<span v-else class="btn-loading">
|
||||
<span class="loading-dot"></span>
|
||||
<span class="loading-dot"></span>
|
||||
<span class="loading-dot"></span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn-delete"
|
||||
:class="{ loading: deletingChunkId === activePreviewChunk.id }"
|
||||
:disabled="deletingChunkId === activePreviewChunk.id"
|
||||
@click="openDeleteChunkDialog(activePreviewChunk)"
|
||||
>
|
||||
<span v-if="deletingChunkId !== activePreviewChunk.id">删除</span>
|
||||
<span v-else class="btn-loading">
|
||||
<span class="loading-dot"></span>
|
||||
<span class="loading-dot"></span>
|
||||
<span class="loading-dot"></span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div v-else class="chunk-empty-state">
|
||||
<h4>没有匹配的分片</h4>
|
||||
<p>试试清空搜索词,或切回“全部”查看所有分片。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<DeleteDialog
|
||||
v-model:visible="deleteDialogVisible"
|
||||
:title="deleteDialogTitle"
|
||||
:item-name="deleteDialogItemName"
|
||||
:detail-text="deleteDialogDetail"
|
||||
:warning-text="deleteDialogWarning"
|
||||
:confirm-text="deleteDialogConfirmText"
|
||||
:loading="deleteDialogLoading"
|
||||
@confirm="confirmDeleteAction"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script lang="ts" src="../page-logic/ProjectTextSplitPage.ts"></script>
|
||||
<style scoped src="../styles/pages/project-text-split.css"></style>
|
||||
@@ -2,8 +2,8 @@
|
||||
* 模型相关业务逻辑
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { modelApi } from '@/api'
|
||||
import type { Model } from '@/types'
|
||||
import { modelApi } from '@/core/api'
|
||||
import type { Model } from '@/shared/types'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
export function useModels() {
|
||||
@@ -2,8 +2,8 @@
|
||||
* 项目相关业务逻辑
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { projectApi } from '@/api'
|
||||
import type { Project, ProjectCreate } from '@/types'
|
||||
import { projectApi } from '@/core/api'
|
||||
import type { Project, ProjectCreate } from '@/shared/types'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
export function useProjects() {
|
||||
@@ -5,6 +5,7 @@
|
||||
export interface Model {
|
||||
id: string
|
||||
provider: ModelProvider
|
||||
model_type: ModelType
|
||||
model_name: string
|
||||
api_key?: string
|
||||
api_base?: string
|
||||
@@ -17,6 +18,7 @@ export interface Model {
|
||||
export interface ModelConfig {
|
||||
id: string
|
||||
provider: ModelProvider
|
||||
model_type: ModelType
|
||||
model_name: string
|
||||
api_key?: string
|
||||
api_base?: string
|
||||
@@ -26,10 +28,12 @@ export interface ModelConfig {
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export type ModelProvider = 'minimax' | 'glm' | 'openai'
|
||||
export type ModelProvider = 'minimax' | 'glm' | 'openai' | 'ali'
|
||||
export type ModelType = 'chat' | 'vlm' | 'embedding' | 'rerank'
|
||||
|
||||
export interface ModelCreate {
|
||||
provider: ModelProvider
|
||||
model_type: ModelType
|
||||
model_name: string
|
||||
api_key: string
|
||||
api_base?: string
|
||||
750
frontend/src/styles/pages/model-settings.css
Normal file
750
frontend/src/styles/pages/model-settings.css
Normal file
@@ -0,0 +1,750 @@
|
||||
/* 使用全局 CSS 变量 */
|
||||
.model-settings {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 背景效果 */
|
||||
.bg-effects {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.glow-orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(120px);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.glow-1 {
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: var(--accent-primary);
|
||||
top: -200px;
|
||||
right: -100px;
|
||||
}
|
||||
|
||||
.glow-2 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: var(--accent-secondary);
|
||||
bottom: -100px;
|
||||
left: -100px;
|
||||
}
|
||||
|
||||
/* 页面头部 */
|
||||
.page-header {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 20px 32px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.title-icon {
|
||||
color: var(--accent-primary);
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.header-left, .header-right {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-secondary);
|
||||
padding: 8px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: var(--bg-hover) !important;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--accent-primary);
|
||||
border: none;
|
||||
color: #030407;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
background: var(--accent-primary-hover);
|
||||
}
|
||||
|
||||
/* 主内容 */
|
||||
.page-main {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
/* 模型列表 */
|
||||
.models-section {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.title-line {
|
||||
width: 4px;
|
||||
height: 18px;
|
||||
background: linear-gradient(180deg, var(--accent-primary), var(--accent-secondary));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
background: var(--bg-hover);
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
}
|
||||
|
||||
.empty-illustration {
|
||||
position: relative;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
margin: 0 auto 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.pulse-ring {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border: 2px solid var(--border-subtle);
|
||||
border-radius: 50%;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.1); opacity: 0.5; }
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
font-size: 18px;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-tertiary);
|
||||
margin: 0 0 24px;
|
||||
}
|
||||
|
||||
/* 模型网格 */
|
||||
.models-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.model-card {
|
||||
position: relative;
|
||||
padding: 24px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 16px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
animation: fadeInUp 0.4s ease backwards;
|
||||
animation-delay: var(--delay);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.model-card:hover {
|
||||
border-color: rgba(0, 212, 255, 0.4);
|
||||
transform: translateY(-6px);
|
||||
box-shadow: 0 12px 40px rgba(0, 212, 255, 0.15);
|
||||
}
|
||||
|
||||
.model-card.is-default {
|
||||
border-color: rgba(52, 211, 153, 0.4);
|
||||
}
|
||||
|
||||
.card-glow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 16px;
|
||||
background: radial-gradient(circle at top right, rgba(0, 212, 255, 0.15), transparent 60%);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.model-card:hover .card-glow {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.default-badge {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--success);
|
||||
background: var(--success-muted);
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.provider-logo {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||
}
|
||||
|
||||
.model-info {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.model-name-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.model-name {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.model-type-badge {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 72px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.model-type-badge.type-chat {
|
||||
color: #38bdf8;
|
||||
background: rgba(56, 189, 248, 0.12);
|
||||
border-color: rgba(56, 189, 248, 0.25);
|
||||
}
|
||||
|
||||
.model-type-badge.type-vlm {
|
||||
color: #fbbf24;
|
||||
background: rgba(251, 191, 36, 0.12);
|
||||
border-color: rgba(251, 191, 36, 0.25);
|
||||
}
|
||||
|
||||
.model-type-badge.type-embedding {
|
||||
color: #34d399;
|
||||
background: rgba(52, 211, 153, 0.12);
|
||||
border-color: rgba(52, 211, 153, 0.25);
|
||||
}
|
||||
|
||||
.model-type-badge.type-rerank {
|
||||
color: #c084fc;
|
||||
background: rgba(192, 132, 252, 0.12);
|
||||
border-color: rgba(192, 132, 252, 0.25);
|
||||
}
|
||||
|
||||
.model-endpoint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-badge.connected {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-badge.disconnected {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.status-badge.untested {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-dot.online,
|
||||
.status-dot.connected {
|
||||
background: var(--success);
|
||||
box-shadow: 0 0 8px var(--success);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
.status-dot.disconnected {
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 8px var(--danger);
|
||||
}
|
||||
|
||||
.status-dot.untested {
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
color: var(--text-tertiary);
|
||||
background: var(--bg-hover);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.action-btn.delete:hover {
|
||||
color: var(--danger);
|
||||
background: var(--danger-muted);
|
||||
}
|
||||
|
||||
/* 弹窗样式 */
|
||||
:deep(.model-dialog .el-dialog) {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.model-dialog .el-dialog__header) {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(.model-dialog .el-dialog__body) {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 24px;
|
||||
background: linear-gradient(135deg, var(--accent-primary-muted), rgba(124, 58, 237, 0.1));
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.dialog-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||
border-radius: 12px;
|
||||
color: #030407;
|
||||
box-shadow: 0 4px 16px rgba(0, 212, 255, 0.3);
|
||||
}
|
||||
|
||||
.dialog-title h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.dialog-title p {
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
|
||||
.dialog-close {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 10px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.dialog-close:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-default);
|
||||
}
|
||||
|
||||
.provider-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.provider-select :deep(.el-select__wrapper),
|
||||
.provider-select :deep(.el-input__wrapper),
|
||||
.dialog-input :deep(.el-input__wrapper) {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 12px;
|
||||
padding: 4px 16px;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.provider-select :deep(.el-select__wrapper:hover),
|
||||
.provider-select :deep(.el-input__wrapper:hover),
|
||||
.dialog-input :deep(.el-input__wrapper:hover) {
|
||||
border-color: var(--border-default);
|
||||
}
|
||||
|
||||
.provider-select :deep(.el-select__wrapper.is-focused),
|
||||
.provider-select :deep(.el-input__wrapper.is-focus),
|
||||
.dialog-input :deep(.el-input__wrapper.is-focus) {
|
||||
border-color: var(--accent-primary);
|
||||
box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.15) !important;
|
||||
}
|
||||
|
||||
.provider-select :deep(.el-select__placeholder),
|
||||
.provider-select :deep(.el-input__inner),
|
||||
.dialog-input :deep(.el-input__inner) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.provider-select :deep(.el-select__placeholder.is-transparent),
|
||||
.provider-select :deep(.el-input__inner::placeholder),
|
||||
.dialog-input :deep(.el-input__inner::placeholder) {
|
||||
color: rgba(226, 232, 240, 0.38) !important;
|
||||
}
|
||||
|
||||
.provider-select :deep(.el-select__caret) {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.provider-select :deep(.el-select__placeholder) {
|
||||
color: rgba(226, 232, 240, 0.32);
|
||||
}
|
||||
|
||||
.provider-select :deep(.el-select__selected-item) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.provider-select :deep(.el-select__selection) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.provider-select :deep(.el-select__selection),
|
||||
.provider-select :deep(.el-select__selected-item),
|
||||
.provider-select :deep(.el-select__selected-item span) {
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
:global(.provider-select-dropdown.el-popper) {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
:global(.provider-select-dropdown .el-select-dropdown) {
|
||||
background: var(--bg-elevated) !important;
|
||||
border: 1px solid var(--border-subtle) !important;
|
||||
border-radius: 14px;
|
||||
padding: 8px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
:global(.provider-select-dropdown .el-select-dropdown__item) {
|
||||
background: transparent !important;
|
||||
color: var(--text-primary) !important;
|
||||
min-height: 64px;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:global(.provider-select-dropdown .el-select-dropdown__item.hover),
|
||||
:global(.provider-select-dropdown .el-select-dropdown__item:hover) {
|
||||
background: rgba(148, 163, 184, 0.08) !important;
|
||||
}
|
||||
|
||||
:global(.provider-select-dropdown .el-select-dropdown__item.is-selected) {
|
||||
background: var(--accent-primary-muted) !important;
|
||||
color: var(--accent-primary) !important;
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 212, 255, 0.18);
|
||||
}
|
||||
|
||||
:global(.provider-select-dropdown .el-popper__arrow),
|
||||
:global(.provider-select-dropdown .el-select-dropdown__loading),
|
||||
:global(.provider-select-dropdown .el-select-dropdown__empty) {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.provider-option-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.provider-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex: 0 0 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: #030407;
|
||||
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
|
||||
}
|
||||
|
||||
.model-type-icon {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.provider-option-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.provider-option-name {
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.provider-option-desc {
|
||||
color: rgba(226, 232, 240, 0.48);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 20px 24px;
|
||||
background: var(--bg-tertiary);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
/* 删除弹窗 */
|
||||
:deep(.delete-dialog .el-dialog) {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--danger-muted);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.delete-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 32px 24px 24px;
|
||||
}
|
||||
|
||||
.delete-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, rgba(248, 113, 113, 0.2), rgba(248, 113, 113, 0.1));
|
||||
border: 1px solid var(--danger-muted);
|
||||
border-radius: 16px;
|
||||
color: var(--danger);
|
||||
box-shadow: 0 4px 20px rgba(248, 113, 113, 0.2);
|
||||
}
|
||||
|
||||
.delete-header h3 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.delete-content {
|
||||
text-align: center;
|
||||
padding: 0 32px 24px;
|
||||
}
|
||||
|
||||
.delete-content p {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.delete-content p strong {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
color: var(--danger) !important;
|
||||
font-size: 13px;
|
||||
margin-top: 12px !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.delete-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 20px 24px;
|
||||
background: var(--bg-tertiary);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
border-radius: 0 0 16px 16px;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.page-main {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.provider-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
697
frontend/src/styles/pages/project-eval.css
Normal file
697
frontend/src/styles/pages/project-eval.css
Normal file
@@ -0,0 +1,697 @@
|
||||
/* ========================
|
||||
CSS Variables
|
||||
======================== */
|
||||
.eval-manage {
|
||||
--accent-cyan: #00d4ff;
|
||||
--accent-cyan-dim: rgba(0, 212, 255, 0.15);
|
||||
--accent-cyan-glow: rgba(0, 212, 255, 0.4);
|
||||
--bg-elevated: #0f1117;
|
||||
--bg-card: #161920;
|
||||
--bg-hover: #1c2029;
|
||||
--border-subtle: rgba(255, 255, 255, 0.08);
|
||||
--border-active: rgba(0, 212, 255, 0.3);
|
||||
--text-secondary: #9ca3af;
|
||||
--text-muted: #6b7280;
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--radius-lg: 12px;
|
||||
--radius-md: 8px;
|
||||
--radius-sm: 6px;
|
||||
padding: 28px 32px;
|
||||
}
|
||||
|
||||
/* ========================
|
||||
Header
|
||||
======================== */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #ffffff 0%, var(--accent-cyan) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
background: var(--accent-cyan) !important;
|
||||
border: none !important;
|
||||
color: #030407 !important;
|
||||
font-weight: 600;
|
||||
padding: 10px 22px;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 0 20px var(--accent-cyan-dim);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.create-btn:hover {
|
||||
box-shadow: 0 0 35px var(--accent-cyan-glow);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ========================
|
||||
Stats Grid
|
||||
======================== */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
position: relative;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--border-active);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stat-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.stat-card.active {
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 20px var(--accent-cyan-dim);
|
||||
}
|
||||
|
||||
.stat-card.active::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.stat-card.active .stat-glow {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.stat-glow {
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.stat-card:hover .stat-glow {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Stat variations */
|
||||
.stat-total {
|
||||
--stat-color: #818cf8;
|
||||
}
|
||||
|
||||
.stat-total::before {
|
||||
background: linear-gradient(90deg, #6366f1, #818cf8);
|
||||
}
|
||||
|
||||
.stat-total .stat-glow {
|
||||
background: radial-gradient(circle at 30% 30%, rgba(99, 102, 241, 0.3), transparent 50%);
|
||||
}
|
||||
|
||||
.stat-completed {
|
||||
--stat-color: var(--success);
|
||||
}
|
||||
|
||||
.stat-completed::before {
|
||||
background: linear-gradient(90deg, #16a34a, var(--success));
|
||||
}
|
||||
|
||||
.stat-completed .stat-glow {
|
||||
background: radial-gradient(circle at 30% 30%, rgba(34, 197, 94, 0.3), transparent 50%);
|
||||
}
|
||||
|
||||
.stat-processing {
|
||||
--stat-color: var(--warning);
|
||||
}
|
||||
|
||||
.stat-processing::before {
|
||||
background: linear-gradient(90deg, #d97706, var(--warning));
|
||||
}
|
||||
|
||||
.stat-processing .stat-glow {
|
||||
background: radial-gradient(circle at 30% 30%, rgba(245, 158, 11, 0.3), transparent 50%);
|
||||
}
|
||||
|
||||
.stat-failed {
|
||||
--stat-color: var(--danger);
|
||||
}
|
||||
|
||||
.stat-failed::before {
|
||||
background: linear-gradient(90deg, #dc2626, var(--danger));
|
||||
}
|
||||
|
||||
.stat-failed .stat-glow {
|
||||
background: radial-gradient(circle at 30% 30%, rgba(239, 68, 68, 0.3), transparent 50%);
|
||||
}
|
||||
|
||||
.stat-inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 22px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.stat-icon-wrap {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--stat-color);
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
font-family: 'SF Mono', 'JetBrains Mono', monospace;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ========================
|
||||
Tabs
|
||||
======================== */
|
||||
.eval-tabs {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.eval-container,
|
||||
.blind-test-container {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* ========================
|
||||
Empty State
|
||||
======================== */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 40px;
|
||||
min-height: 450px;
|
||||
}
|
||||
|
||||
.empty-illustration {
|
||||
position: relative;
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.orbit {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
border: 1px dashed rgba(0, 212, 255, 0.2);
|
||||
}
|
||||
|
||||
.orbit-1 {
|
||||
inset: 10px;
|
||||
animation: orbit-rotate 20s linear infinite;
|
||||
}
|
||||
|
||||
.orbit-2 {
|
||||
inset: 30px;
|
||||
border-color: rgba(0, 212, 255, 0.15);
|
||||
animation: orbit-rotate 15s linear infinite reverse;
|
||||
}
|
||||
|
||||
.orbit-3 {
|
||||
inset: 50px;
|
||||
border-color: rgba(0, 212, 255, 0.1);
|
||||
animation: orbit-rotate 10s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes orbit-rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.empty-core {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--bg-hover) 0%, var(--bg-card) 100%);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 50%;
|
||||
color: var(--accent-cyan);
|
||||
box-shadow: 0 0 40px var(--accent-cyan-dim);
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-btn {
|
||||
background: var(--accent-cyan) !important;
|
||||
border: none !important;
|
||||
color: #030407 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ========================
|
||||
Eval Table
|
||||
======================== */
|
||||
.eval-table-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 800px;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 20px;
|
||||
background: var(--bg-elevated);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.table-select .select-all {
|
||||
--el-checkbox-text-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.selected-text {
|
||||
color: var(--accent-cyan);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.batch-delete-btn {
|
||||
background: transparent !important;
|
||||
border-color: var(--danger) !important;
|
||||
color: var(--danger) !important;
|
||||
}
|
||||
|
||||
.batch-delete-btn:hover {
|
||||
background: var(--danger) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.batch-clear-btn {
|
||||
background: transparent !important;
|
||||
border-color: var(--text-muted) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.batch-clear-btn:hover {
|
||||
background: var(--bg-hover) !important;
|
||||
border-color: var(--text-secondary) !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.eval-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.eval-row {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 50px 1fr 80px 80px 100px 90px;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
transition: background 0.15s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.eval-row.row-animated {
|
||||
animation: row-in 0.3s ease backwards;
|
||||
animation-delay: var(--delay);
|
||||
}
|
||||
|
||||
@keyframes row-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.eval-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.eval-row.is-selected {
|
||||
background: rgba(0, 212, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Column styles */
|
||||
.col-select {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.col-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.eval-type-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: color-mix(in srgb, var(--type-color) 15%, transparent);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--type-color);
|
||||
}
|
||||
|
||||
.col-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.eval-name-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #ffffff;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.col-count {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'SF Mono', monospace;
|
||||
}
|
||||
|
||||
.col-date {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'SF Mono', monospace;
|
||||
}
|
||||
|
||||
/* Status Pill */
|
||||
.col-status {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-running {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.status-running .status-dot {
|
||||
background: var(--warning);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.status-completed {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.status-completed .status-dot {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.status-failed {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.status-failed .status-dot {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.status-pending {
|
||||
background: rgba(107, 114, 128, 0.1);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.status-pending .status-dot {
|
||||
background: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.col-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
min-width: 90px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
color: var(--text-muted) !important;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
color: var(--accent-cyan) !important;
|
||||
background: var(--accent-cyan-dim) !important;
|
||||
}
|
||||
|
||||
.action-btn.run:hover {
|
||||
color: var(--success) !important;
|
||||
background: rgba(34, 197, 94, 0.1) !important;
|
||||
}
|
||||
|
||||
.action-btn.delete:hover {
|
||||
color: var(--danger) !important;
|
||||
background: rgba(239, 68, 68, 0.1) !important;
|
||||
}
|
||||
|
||||
/* ========================
|
||||
Blind Test Tasks Grid
|
||||
======================== */
|
||||
.tasks-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.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); }
|
||||
}
|
||||
|
||||
.task-card:hover {
|
||||
border-color: var(--accent-cyan);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.task-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.task-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ========================
|
||||
Dialog
|
||||
======================== */
|
||||
.eval-dialog,
|
||||
.blind-dialog {
|
||||
--el-dialog-bg-color: var(--bg-elevated);
|
||||
--el-dialog-border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.model-check-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* ========================
|
||||
Responsive
|
||||
======================== */
|
||||
@media (max-width: 900px) {
|
||||
.eval-manage {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-actions .el-button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.eval-row {
|
||||
grid-template-columns: 40px 40px 1fr 60px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.col-date,
|
||||
.col-status {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.col-actions {
|
||||
min-width: 50px;
|
||||
}
|
||||
}
|
||||
1190
frontend/src/styles/pages/project-file.css
Normal file
1190
frontend/src/styles/pages/project-file.css
Normal file
File diff suppressed because it is too large
Load Diff
557
frontend/src/styles/pages/project-question.css
Normal file
557
frontend/src/styles/pages/project-question.css
Normal file
@@ -0,0 +1,557 @@
|
||||
/* ========================
|
||||
CSS Variables
|
||||
======================== */
|
||||
.question-manage {
|
||||
--accent-cyan: #00d4ff;
|
||||
--accent-cyan-dim: rgba(0, 212, 255, 0.15);
|
||||
--accent-cyan-glow: rgba(0, 212, 255, 0.4);
|
||||
--bg-elevated: #0f1117;
|
||||
--bg-card: #161920;
|
||||
--bg-hover: #1c2029;
|
||||
--border-subtle: rgba(255, 255, 255, 0.08);
|
||||
--border-active: rgba(0, 212, 255, 0.3);
|
||||
--text-secondary: #9ca3af;
|
||||
--text-muted: #6b7280;
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--radius-lg: 12px;
|
||||
--radius-md: 8px;
|
||||
--radius-sm: 6px;
|
||||
padding: 28px 32px;
|
||||
}
|
||||
|
||||
/* ========================
|
||||
Header
|
||||
======================== */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #ffffff 0%, var(--accent-cyan) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.generate-btn {
|
||||
background: var(--accent-cyan) !important;
|
||||
border: none !important;
|
||||
color: #030407 !important;
|
||||
font-weight: 600;
|
||||
padding: 10px 22px;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 0 20px var(--accent-cyan-dim);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.generate-btn:hover {
|
||||
box-shadow: 0 0 35px var(--accent-cyan-glow);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ========================
|
||||
Stats Grid
|
||||
======================== */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
position: relative;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--border-active);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stat-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.stat-card.active {
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 20px var(--accent-cyan-dim);
|
||||
}
|
||||
|
||||
.stat-card.active::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.stat-card.active .stat-glow {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.stat-glow {
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.stat-card:hover .stat-glow {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Stat variations */
|
||||
.stat-total {
|
||||
--stat-color: #818cf8;
|
||||
}
|
||||
|
||||
.stat-total::before {
|
||||
background: linear-gradient(90deg, #6366f1, #818cf8);
|
||||
}
|
||||
|
||||
.stat-total .stat-glow {
|
||||
background: radial-gradient(circle at 30% 30%, rgba(99, 102, 241, 0.3), transparent 50%);
|
||||
}
|
||||
|
||||
.stat-completed {
|
||||
--stat-color: var(--success);
|
||||
}
|
||||
|
||||
.stat-completed::before {
|
||||
background: linear-gradient(90deg, #16a34a, var(--success));
|
||||
}
|
||||
|
||||
.stat-completed .stat-glow {
|
||||
background: radial-gradient(circle at 30% 30%, rgba(34, 197, 94, 0.3), transparent 50%);
|
||||
}
|
||||
|
||||
.stat-processing {
|
||||
--stat-color: var(--warning);
|
||||
}
|
||||
|
||||
.stat-processing::before {
|
||||
background: linear-gradient(90deg, #d97706, var(--warning));
|
||||
}
|
||||
|
||||
.stat-processing .stat-glow {
|
||||
background: radial-gradient(circle at 30% 30%, rgba(245, 158, 11, 0.3), transparent 50%);
|
||||
}
|
||||
|
||||
.stat-failed {
|
||||
--stat-color: var(--danger);
|
||||
}
|
||||
|
||||
.stat-failed::before {
|
||||
background: linear-gradient(90deg, #dc2626, var(--danger));
|
||||
}
|
||||
|
||||
.stat-failed .stat-glow {
|
||||
background: radial-gradient(circle at 30% 30%, rgba(239, 68, 68, 0.3), transparent 50%);
|
||||
}
|
||||
|
||||
.stat-inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 22px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.stat-icon-wrap {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--stat-color);
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
font-family: 'SF Mono', 'JetBrains Mono', monospace;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ========================
|
||||
Question Container
|
||||
======================== */
|
||||
.question-container {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* ========================
|
||||
Empty State
|
||||
======================== */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 40px;
|
||||
min-height: 450px;
|
||||
}
|
||||
|
||||
.empty-illustration {
|
||||
position: relative;
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.orbit {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
border: 1px dashed rgba(0, 212, 255, 0.2);
|
||||
}
|
||||
|
||||
.orbit-1 {
|
||||
inset: 10px;
|
||||
animation: orbit-rotate 20s linear infinite;
|
||||
}
|
||||
|
||||
.orbit-2 {
|
||||
inset: 30px;
|
||||
border-color: rgba(0, 212, 255, 0.15);
|
||||
animation: orbit-rotate 15s linear infinite reverse;
|
||||
}
|
||||
|
||||
.orbit-3 {
|
||||
inset: 50px;
|
||||
border-color: rgba(0, 212, 255, 0.1);
|
||||
animation: orbit-rotate 10s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes orbit-rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.empty-core {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--bg-hover) 0%, var(--bg-card) 100%);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 50%;
|
||||
color: var(--accent-cyan);
|
||||
box-shadow: 0 0 40px var(--accent-cyan-dim);
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-btn {
|
||||
background: var(--accent-cyan) !important;
|
||||
border: none !important;
|
||||
color: #030407 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ========================
|
||||
Question Table
|
||||
======================== */
|
||||
.question-table-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 800px;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 20px;
|
||||
background: var(--bg-elevated);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.table-select .select-all {
|
||||
--el-checkbox-text-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.selected-text {
|
||||
color: var(--accent-cyan);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.batch-delete-btn {
|
||||
background: transparent !important;
|
||||
border-color: var(--danger) !important;
|
||||
color: var(--danger) !important;
|
||||
}
|
||||
|
||||
.batch-delete-btn:hover {
|
||||
background: var(--danger) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.batch-clear-btn {
|
||||
background: transparent !important;
|
||||
border-color: var(--text-muted) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.batch-clear-btn:hover {
|
||||
background: var(--bg-hover) !important;
|
||||
border-color: var(--text-secondary) !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.question-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.question-row {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr 90px 80px 60px;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
transition: background 0.15s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.question-row.row-animated {
|
||||
animation: row-in 0.3s ease backwards;
|
||||
animation-delay: var(--delay);
|
||||
}
|
||||
|
||||
@keyframes row-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.question-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.question-row.is-selected {
|
||||
background: rgba(0, 212, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Column styles */
|
||||
.col-select {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.col-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.question-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #ffffff;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.answer-text {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.col-type {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.col-type .el-tag {
|
||||
background: color-mix(in srgb, var(--tag-color) 15%, transparent) !important;
|
||||
border-color: transparent !important;
|
||||
color: var(--tag-color) !important;
|
||||
}
|
||||
|
||||
.col-source {
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.source-badge {
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.source-badge.source-generated {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.source-badge.source-manual {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.source-badge.source-failed {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.col-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
color: var(--text-muted) !important;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.action-btn.delete:hover {
|
||||
color: var(--danger) !important;
|
||||
background: rgba(239, 68, 68, 0.1) !important;
|
||||
}
|
||||
|
||||
/* ========================
|
||||
Dialog
|
||||
======================== */
|
||||
.generate-dialog {
|
||||
--el-dialog-bg-color: var(--bg-elevated);
|
||||
--el-dialog-border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.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: 900px) {
|
||||
.question-manage {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-actions .el-button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.question-row {
|
||||
grid-template-columns: 40px 1fr 60px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.col-type,
|
||||
.col-source {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.col-actions {
|
||||
min-width: 50px;
|
||||
}
|
||||
}
|
||||
1999
frontend/src/styles/pages/project-text-split.css
Normal file
1999
frontend/src/styles/pages/project-text-split.css
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,918 +0,0 @@
|
||||
<template>
|
||||
<div class="question-manage">
|
||||
<!-- Header -->
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2 class="page-title">问答管理</h2>
|
||||
<p class="page-subtitle">管理和生成问答数据</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-button type="primary" @click="showGenerateDialog = true" class="generate-btn">
|
||||
<el-icon><Plus /></el-icon>
|
||||
<span>生成问题</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="stats-grid">
|
||||
<div
|
||||
class="stat-card stat-total"
|
||||
:class="{ active: filterStatus === '' }"
|
||||
@click="filterStatus = ''"
|
||||
>
|
||||
<div class="stat-glow"></div>
|
||||
<div class="stat-inner">
|
||||
<div class="stat-icon-wrap">
|
||||
<el-icon size="24"><ChatDotSquare /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ questions.length }}</span>
|
||||
<span class="stat-label">总问题数</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat-card stat-completed"
|
||||
:class="{ active: filterStatus === 'generated' }"
|
||||
@click="filterStatus = filterStatus === 'generated' ? '' : 'generated'"
|
||||
>
|
||||
<div class="stat-glow"></div>
|
||||
<div class="stat-inner">
|
||||
<div class="stat-icon-wrap">
|
||||
<el-icon size="24"><MagicStick /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ generatedCount }}</span>
|
||||
<span class="stat-label">AI 生成</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat-card stat-processing"
|
||||
:class="{ active: filterStatus === 'manual' }"
|
||||
@click="filterStatus = filterStatus === 'manual' ? '' : 'manual'"
|
||||
>
|
||||
<div class="stat-glow"></div>
|
||||
<div class="stat-inner">
|
||||
<div class="stat-icon-wrap">
|
||||
<el-icon size="24"><EditPen /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ manualCount }}</span>
|
||||
<span class="stat-label">手动添加</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat-card stat-failed"
|
||||
:class="{ active: filterStatus === 'failed' }"
|
||||
@click="filterStatus = filterStatus === 'failed' ? '' : 'failed'"
|
||||
>
|
||||
<div class="stat-glow"></div>
|
||||
<div class="stat-inner">
|
||||
<div class="stat-icon-wrap">
|
||||
<el-icon size="24"><CircleCloseFilled /></el-icon>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<span class="stat-value">{{ failedCount }}</span>
|
||||
<span class="stat-label">失败</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Question Container -->
|
||||
<div class="question-container" v-loading="loading && isInitialLoad">
|
||||
<!-- Empty State -->
|
||||
<div v-if="!loading && !isInitialLoad && filteredQuestions.length === 0" class="empty-state">
|
||||
<div class="empty-illustration">
|
||||
<div class="orbit orbit-1"></div>
|
||||
<div class="orbit orbit-2"></div>
|
||||
<div class="orbit orbit-3"></div>
|
||||
<div class="empty-core">
|
||||
<el-icon size="40"><ChatDotSquare /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="empty-title">暂无问答数据</h3>
|
||||
<p class="empty-desc">生成您的第一个问答数据集</p>
|
||||
<el-button type="primary" @click="showGenerateDialog = true" class="empty-btn">生成问题</el-button>
|
||||
</div>
|
||||
|
||||
<!-- Question Table -->
|
||||
<div v-else class="question-table-wrapper">
|
||||
<!-- Table Header -->
|
||||
<div class="table-header">
|
||||
<div class="table-select">
|
||||
<el-checkbox
|
||||
:model-value="isAllSelected"
|
||||
@change="toggleSelectAll"
|
||||
class="select-all"
|
||||
>
|
||||
<span v-if="selectedCount > 0" class="selected-text">已选择 {{ selectedCount }} 项</span>
|
||||
<span v-else>全选</span>
|
||||
</el-checkbox>
|
||||
</div>
|
||||
<div class="table-actions" v-if="selectedCount > 0">
|
||||
<el-button type="danger" size="small" plain @click="clearSelection" class="batch-clear-btn">
|
||||
<el-icon><Close /></el-icon>
|
||||
<span>清除选择</span>
|
||||
</el-button>
|
||||
<el-button type="danger" size="small" plain @click="batchDelete" class="batch-delete-btn">
|
||||
<el-icon><Delete /></el-icon>
|
||||
<span>批量删除</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table Body -->
|
||||
<div class="question-table">
|
||||
<div
|
||||
v-for="(question, index) in filteredQuestions"
|
||||
:key="question.id"
|
||||
class="question-row"
|
||||
:class="{
|
||||
'is-selected': isSelected(question.id),
|
||||
'row-animated': isInitialLoad
|
||||
}"
|
||||
:style="{ '--delay': index * 0.04 + 's' }"
|
||||
@click="toggleSelect(question.id)"
|
||||
>
|
||||
<!-- Select Checkbox -->
|
||||
<div class="col-select" @click.stop>
|
||||
<el-checkbox
|
||||
:model-value="isSelected(question.id)"
|
||||
@change="toggleSelect(question.id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Question Content -->
|
||||
<div class="col-content">
|
||||
<div class="question-text">{{ question.content }}</div>
|
||||
<div class="answer-text" v-if="question.answer">答: {{ question.answer }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Type -->
|
||||
<div class="col-type">
|
||||
<el-tag size="small" :style="{ '--tag-color': getTypeColor(question.question_type) }" effect="dark">
|
||||
{{ getTypeName(question.question_type) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<!-- Source -->
|
||||
<div class="col-source">
|
||||
<span class="source-badge" :class="'source-' + question.source">{{ getSourceName(question.source) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="col-actions" @click.stop>
|
||||
<el-popconfirm title="确定删除此问题?" @confirm="handleDelete(question)">
|
||||
<template #reference>
|
||||
<el-button text size="small" class="action-btn delete">
|
||||
<el-icon><Delete /></el-icon>
|
||||
</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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 isInitialLoad = ref(true)
|
||||
const generating = ref(false)
|
||||
const questions = ref([])
|
||||
const chunks = ref([])
|
||||
const showGenerateDialog = ref(false)
|
||||
const filterStatus = ref('')
|
||||
|
||||
const generateConfig = reactive({
|
||||
chunk_ids: [],
|
||||
count: 5,
|
||||
question_types: ['fact', 'summary']
|
||||
})
|
||||
|
||||
// Multi-select
|
||||
const selectedQuestions = ref([])
|
||||
|
||||
const filteredQuestions = computed(() => {
|
||||
if (!filterStatus.value) return questions.value
|
||||
return questions.value.filter(q => q.source === filterStatus.value)
|
||||
})
|
||||
|
||||
const generatedCount = computed(() => questions.value.filter(q => q.source === 'generated').length)
|
||||
const manualCount = computed(() => questions.value.filter(q => q.source === 'manual').length)
|
||||
const failedCount = computed(() => questions.value.filter(q => q.status === 'failed').length)
|
||||
|
||||
const isAllSelected = computed(() => filteredQuestions.value.length > 0 && selectedQuestions.value.length === filteredQuestions.value.length)
|
||||
const selectedCount = computed(() => selectedQuestions.value.length)
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (isAllSelected.value) {
|
||||
selectedQuestions.value = []
|
||||
} else {
|
||||
selectedQuestions.value = filteredQuestions.value.map(q => q.id)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelect = (id) => {
|
||||
const index = selectedQuestions.value.indexOf(id)
|
||||
if (index === -1) {
|
||||
selectedQuestions.value.push(id)
|
||||
} else {
|
||||
selectedQuestions.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
const isSelected = (id) => selectedQuestions.value.includes(id)
|
||||
|
||||
const clearSelection = () => {
|
||||
selectedQuestions.value = []
|
||||
}
|
||||
|
||||
const batchDelete = async () => {
|
||||
if (selectedQuestions.value.length === 0) return
|
||||
try {
|
||||
for (const id of selectedQuestions.value) {
|
||||
await questionApi.delete(projectId.value, id)
|
||||
}
|
||||
ElMessage.success(`已删除 ${selectedQuestions.value.length} 个问题`)
|
||||
selectedQuestions.value = []
|
||||
fetchQuestions()
|
||||
} catch (error) {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
const fetchQuestions = async () => {
|
||||
const wasInitial = isInitialLoad.value
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await chunkApi.list(projectId.value, {})
|
||||
chunks.value = res.data?.chunks || res.chunks || []
|
||||
questions.value = chunks.value.flatMap(c => (c.questions || []).map(q => ({ ...q, source: c.name })))
|
||||
} catch (error) {
|
||||
questions.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (wasInitial) {
|
||||
isInitialLoad.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': '#22c55e', 'summary': '#818cf8', 'reasoning': '#f59e0b' }
|
||||
return map[type] || '#818cf8'
|
||||
}
|
||||
|
||||
const getTypeName = (type) => {
|
||||
const map = { 'fact': '事实性', 'summary': '总结性', 'reasoning': '推理性' }
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
const getSourceName = (source) => {
|
||||
const map = { 'generated': 'AI生成', 'manual': '手动', 'failed': '失败' }
|
||||
return map[source] || source
|
||||
}
|
||||
|
||||
onMounted(() => fetchQuestions())
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ========================
|
||||
CSS Variables
|
||||
======================== */
|
||||
.question-manage {
|
||||
--accent-cyan: #00d4ff;
|
||||
--accent-cyan-dim: rgba(0, 212, 255, 0.15);
|
||||
--accent-cyan-glow: rgba(0, 212, 255, 0.4);
|
||||
--bg-elevated: #0f1117;
|
||||
--bg-card: #161920;
|
||||
--bg-hover: #1c2029;
|
||||
--border-subtle: rgba(255, 255, 255, 0.08);
|
||||
--border-active: rgba(0, 212, 255, 0.3);
|
||||
--text-secondary: #9ca3af;
|
||||
--text-muted: #6b7280;
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--radius-lg: 12px;
|
||||
--radius-md: 8px;
|
||||
--radius-sm: 6px;
|
||||
padding: 28px 32px;
|
||||
}
|
||||
|
||||
/* ========================
|
||||
Header
|
||||
======================== */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #ffffff 0%, var(--accent-cyan) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.generate-btn {
|
||||
background: var(--accent-cyan) !important;
|
||||
border: none !important;
|
||||
color: #030407 !important;
|
||||
font-weight: 600;
|
||||
padding: 10px 22px;
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 0 20px var(--accent-cyan-dim);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.generate-btn:hover {
|
||||
box-shadow: 0 0 35px var(--accent-cyan-glow);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ========================
|
||||
Stats Grid
|
||||
======================== */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
position: relative;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--border-active);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stat-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.stat-card.active {
|
||||
border-color: var(--accent-cyan);
|
||||
box-shadow: 0 0 20px var(--accent-cyan-dim);
|
||||
}
|
||||
|
||||
.stat-card.active::before {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.stat-card.active .stat-glow {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.stat-glow {
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.stat-card:hover .stat-glow {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Stat variations */
|
||||
.stat-total {
|
||||
--stat-color: #818cf8;
|
||||
}
|
||||
|
||||
.stat-total::before {
|
||||
background: linear-gradient(90deg, #6366f1, #818cf8);
|
||||
}
|
||||
|
||||
.stat-total .stat-glow {
|
||||
background: radial-gradient(circle at 30% 30%, rgba(99, 102, 241, 0.3), transparent 50%);
|
||||
}
|
||||
|
||||
.stat-completed {
|
||||
--stat-color: var(--success);
|
||||
}
|
||||
|
||||
.stat-completed::before {
|
||||
background: linear-gradient(90deg, #16a34a, var(--success));
|
||||
}
|
||||
|
||||
.stat-completed .stat-glow {
|
||||
background: radial-gradient(circle at 30% 30%, rgba(34, 197, 94, 0.3), transparent 50%);
|
||||
}
|
||||
|
||||
.stat-processing {
|
||||
--stat-color: var(--warning);
|
||||
}
|
||||
|
||||
.stat-processing::before {
|
||||
background: linear-gradient(90deg, #d97706, var(--warning));
|
||||
}
|
||||
|
||||
.stat-processing .stat-glow {
|
||||
background: radial-gradient(circle at 30% 30%, rgba(245, 158, 11, 0.3), transparent 50%);
|
||||
}
|
||||
|
||||
.stat-failed {
|
||||
--stat-color: var(--danger);
|
||||
}
|
||||
|
||||
.stat-failed::before {
|
||||
background: linear-gradient(90deg, #dc2626, var(--danger));
|
||||
}
|
||||
|
||||
.stat-failed .stat-glow {
|
||||
background: radial-gradient(circle at 30% 30%, rgba(239, 68, 68, 0.3), transparent 50%);
|
||||
}
|
||||
|
||||
.stat-inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 20px 22px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.stat-icon-wrap {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--stat-color);
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
font-family: 'SF Mono', 'JetBrains Mono', monospace;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ========================
|
||||
Question Container
|
||||
======================== */
|
||||
.question-container {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* ========================
|
||||
Empty State
|
||||
======================== */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 80px 40px;
|
||||
min-height: 450px;
|
||||
}
|
||||
|
||||
.empty-illustration {
|
||||
position: relative;
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.orbit {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
border: 1px dashed rgba(0, 212, 255, 0.2);
|
||||
}
|
||||
|
||||
.orbit-1 {
|
||||
inset: 10px;
|
||||
animation: orbit-rotate 20s linear infinite;
|
||||
}
|
||||
|
||||
.orbit-2 {
|
||||
inset: 30px;
|
||||
border-color: rgba(0, 212, 255, 0.15);
|
||||
animation: orbit-rotate 15s linear infinite reverse;
|
||||
}
|
||||
|
||||
.orbit-3 {
|
||||
inset: 50px;
|
||||
border-color: rgba(0, 212, 255, 0.1);
|
||||
animation: orbit-rotate 10s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes orbit-rotate {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.empty-core {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--bg-hover) 0%, var(--bg-card) 100%);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 50%;
|
||||
color: var(--accent-cyan);
|
||||
box-shadow: 0 0 40px var(--accent-cyan-dim);
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.empty-desc {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
margin: 0 0 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-btn {
|
||||
background: var(--accent-cyan) !important;
|
||||
border: none !important;
|
||||
color: #030407 !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ========================
|
||||
Question Table
|
||||
======================== */
|
||||
.question-table-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 800px;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 20px;
|
||||
background: var(--bg-elevated);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.table-select .select-all {
|
||||
--el-checkbox-text-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.selected-text {
|
||||
color: var(--accent-cyan);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.batch-delete-btn {
|
||||
background: transparent !important;
|
||||
border-color: var(--danger) !important;
|
||||
color: var(--danger) !important;
|
||||
}
|
||||
|
||||
.batch-delete-btn:hover {
|
||||
background: var(--danger) !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.batch-clear-btn {
|
||||
background: transparent !important;
|
||||
border-color: var(--text-muted) !important;
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
.batch-clear-btn:hover {
|
||||
background: var(--bg-hover) !important;
|
||||
border-color: var(--text-secondary) !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.question-table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.question-row {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr 90px 80px 60px;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
transition: background 0.15s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.question-row.row-animated {
|
||||
animation: row-in 0.3s ease backwards;
|
||||
animation-delay: var(--delay);
|
||||
}
|
||||
|
||||
@keyframes row-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.question-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.question-row.is-selected {
|
||||
background: rgba(0, 212, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Column styles */
|
||||
.col-select {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.col-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.question-text {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #ffffff;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.answer-text {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.col-type {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.col-type .el-tag {
|
||||
background: color-mix(in srgb, var(--tag-color) 15%, transparent) !important;
|
||||
border-color: transparent !important;
|
||||
color: var(--tag-color) !important;
|
||||
}
|
||||
|
||||
.col-source {
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.source-badge {
|
||||
font-size: 12px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.source-badge.source-generated {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.source-badge.source-manual {
|
||||
background: rgba(0, 212, 255, 0.1);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.source-badge.source-failed {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.col-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
color: var(--text-muted) !important;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.action-btn.delete:hover {
|
||||
color: var(--danger) !important;
|
||||
background: rgba(239, 68, 68, 0.1) !important;
|
||||
}
|
||||
|
||||
/* ========================
|
||||
Dialog
|
||||
======================== */
|
||||
.generate-dialog {
|
||||
--el-dialog-bg-color: var(--bg-elevated);
|
||||
--el-dialog-border-radius: var(--radius-xl);
|
||||
border: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.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: 900px) {
|
||||
.question-manage {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-actions .el-button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.question-row {
|
||||
grid-template-columns: 40px 1fr 60px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.col-type,
|
||||
.col-source {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.col-actions {
|
||||
min-width: 50px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user