Compare commits

...

9 Commits

Author SHA1 Message Date
Developer
fa7829657f chore: 删除废弃文件
- 删除 bug修复.md
- 删除废弃的 home.scss 样式文件

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 16:08:31 +08:00
Developer
3e2d07a502 refactor(frontend): 更新项目视图和文本分割页面
- App.vue: 更新样式和路由配置
- ProjectView.vue: 布局调整
- TextSplit.vue: 分割功能完善

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 16:08:16 +08:00
Developer
df70c09fe2 feat(frontend): 优化文件管理上传流程和 UI 体验
- 上传后立即显示文件列表,无需等待
- 添加轮询机制自动更新处理状态
- 移除固定高度限制,表格高度自适应
- 优化动画只在首次加载时播放,避免刷新闪烁
- 上传中状态隐藏空状态显示

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 16:08:12 +08:00
Developer
cc2e73c595 feat(backend): 更新 API 支持语义分割和 embedding 配置
- chunks API 添加 embedding 配置字段
- projects API 更新路由和方法

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 16:08:08 +08:00
Developer
da2887d913 feat(backend): 添加语义嵌入文本分割功能
- 新增 semantic_embedding.py 模块,基于 embedding 相似度进行语义分割
- 集成到 splitter.py 的 get_splitter 工厂函数
- 支持配置 embedding 模型和相似度阈值

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 16:08:04 +08:00
Developer
1cf44ac6f7 fix(backend): 修复文件上传后异步处理失败问题
- 修复 async_session_maker 未定义错误,改用 AsyncSessionLocal
- 确保文件上传后能正确异步转换为 Markdown

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 16:08:00 +08:00
Developer
9a12907f25 feat(frontend): 新增 composables 工具函数和爬虫页面
- 添加 useFormatters、useModels、useProjects 组合式函数
- 新增样式文件 index.scss 和 pages/home.scss
- 添加 CrawlerView 爬虫页面视图

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 10:45:36 +08:00
Developer
a1342b7634 feat: 完善前端功能,添加爬虫页面和项目分页
- 新增 CrawlerView 爬虫页面
- 完善 HomeView 分页展示(9个/页)
- 更新 ProjectCard 组件图标
- 优化 API 客户端和类型定义
- 重构样式文件结构到独立目录

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 10:45:32 +08:00
Developer
68453cead8 feat(backend): 完善日志系统,支持按日期分目录存储
- 实现 logs/YYYY-MM-DD/ 日期文件夹结构
- 添加 success.log 和 failure.log 专用日志
- 使用 TimedRotatingFileHandler 实现按天切割
- 添加 log_success 和 log_failure 便捷函数
- 集成 markitdown 进行文件转换
- 优化文件存储路径,按项目ID分类存储

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 10:44:09 +08:00
32 changed files with 5120 additions and 1245 deletions

View File

@@ -10,9 +10,11 @@ api_router = APIRouter()
# Include sub-routers
api_router.include_router(projects.router, prefix="/projects", tags=["projects"])
api_router.include_router(files.router, prefix="/files", tags=["files"])
api_router.include_router(chunks.router, prefix="/chunks", tags=["chunks"])
api_router.include_router(questions.router, prefix="/questions", tags=["questions"])
api_router.include_router(datasets.router, prefix="/datasets", tags=["datasets"])
api_router.include_router(eval.router, prefix="/eval", tags=["eval"])
# files, chunks, questions, datasets, eval 需要嵌套在 projects 下
# 通过 projects 路由中的子路由处理
api_router.include_router(files.router, prefix="/projects/{project_id}/files", tags=["files"])
api_router.include_router(chunks.router, prefix="/projects/{project_id}/chunks", tags=["chunks"])
api_router.include_router(questions.router, prefix="/projects/{project_id}/questions", tags=["questions"])
api_router.include_router(datasets.router, prefix="/projects/{project_id}/datasets", tags=["datasets"])
api_router.include_router(eval.router, prefix="/projects/{project_id}/eval", tags=["eval"])
api_router.include_router(models.router, prefix="/models", tags=["models"])

View File

@@ -2,6 +2,7 @@
Chunks API Router
"""
import asyncio
from pathlib import Path
from typing import List, Optional
from uuid import UUID
from pydantic import BaseModel, Field
@@ -13,19 +14,28 @@ from app.api.response import ApiResponse, PaginatedResponse
from app.core.database import get_db
from app.core.exceptions import NotFoundException
from app.core.crud import CRUDBase
from app.core.logging import log_success, log_failure
from app.models.models import Chunk, File
from app.schemas.chunk import ChunkResponse
from app.schemas.chunk import ChunkCreateSchema
from app.services.text_splitter.splitter import get_splitter
from app.services.file_processor.pdf_processor import process_pdf
from app.services.file_processor.docx_processor import process_docx
from app.services.file_processor.excel_processor import process_csv, process_excel
from markitdown import MarkItDown
router = APIRouter()
# Initialize CRUD
chunk_crud = CRUDBase(Chunk)
# Initialize markitdown
markitdown = MarkItDown()
def get_project_ready_dir(project_id: str) -> Path:
"""获取项目的 ready 文件目录"""
base_dir = Path("/data/code/YG-Datasets/data") / project_id / "ready"
base_dir.mkdir(parents=True, exist_ok=True)
return base_dir
class SplitRequest(BaseModel):
"""Request model for splitting text"""
@@ -34,31 +44,40 @@ class SplitRequest(BaseModel):
chunk_size: int = Field(500, ge=50, le=5000)
overlap: int = Field(50, ge=0, le=500)
separator: Optional[str] = None
# Embedding 相关参数(用于 semantic_embedding 方法)
embedding_provider: Optional[str] = Field(None, description="embedding provider: openai, minimax")
embedding_api_key: Optional[str] = Field(None, description="API key for embedding")
embedding_base_url: Optional[str] = Field(None, description="API base URL")
embedding_model: Optional[str] = Field(None, description="Embedding model name")
# 语义分割参数
similarity_threshold: float = Field(0.3, ge=0.0, le=1.0, description="Similarity threshold for semantic split")
min_chunk_size: int = Field(100, ge=10, le=1000, description="Minimum chunk size")
async def process_file_by_type(file: File) -> str:
"""Process file based on its type"""
"""Process file based on its type, convert to markdown"""
if not file.file_path:
raise NotFoundException("File", file.id)
processors = {
"pdf": process_pdf,
"docx": process_docx,
"xlsx": process_excel,
"csv": process_csv,
}
# Supported types for markitdown
markitdown_types = ["pdf", "docx", "doc", "pptx", "ppt", "xlsx", "xls", "htm", "html"]
processor = processors.get(file.file_type)
if not processor:
# Return raw text for txt, md files
if file.file_type in markitdown_types:
# Use markitdown to convert to markdown
loop = asyncio.get_event_loop()
content = await loop.run_in_executor(
result = await loop.run_in_executor(
None,
lambda: open(file.file_path, 'r', encoding='utf-8').read()
lambda: markitdown.convert(file.file_path)
)
return content
return result.text_content
return await processor(file.file_path)
# Return raw text for txt, md files
loop = asyncio.get_event_loop()
content = await loop.run_in_executor(
None,
lambda: open(file.file_path, 'r', encoding='utf-8').read()
)
return content
@router.post("/split", response_model=ApiResponse)
@@ -68,52 +87,106 @@ async def split_text(
db: AsyncSession = Depends(get_db)
):
"""Split text into chunks"""
# Get file
result = await db.execute(
select(File).where(File.id == request.file_id, File.project_id == project_id)
)
file = result.scalar_one_or_none()
if not file:
raise NotFoundException("File", request.file_id)
# Process file
text = await process_file_by_type(file)
# Update file status
file.status = "processing"
await db.commit()
# Split text
kwargs = {"chunk_size": request.chunk_size, "overlap": request.overlap}
if request.method == "custom" and request.separator:
kwargs["separator"] = request.separator
splitter = get_splitter(request.method, **kwargs)
split_results = splitter.split(text)
# Save chunks
chunks = []
for chunk_data in split_results:
db_chunk = Chunk(
project_id=project_id,
file_id=file.id,
name=chunk_data.get("name", f"Chunk {chunk_data['index'] + 1}"),
content=chunk_data["content"],
word_count=chunk_data.get("word_count", len(chunk_data["content"].split()))
try:
# Get file
result = await db.execute(
select(File).where(File.id == request.file_id, File.project_id == project_id)
)
db.add(db_chunk)
chunks.append(db_chunk)
file = result.scalar_one_or_none()
if not file:
raise NotFoundException("File", request.file_id)
await db.commit()
# 记录开始处理
log_success(
"开始处理文件",
project_id=str(project_id),
file_id=str(file.id),
filename=file.filename,
method=request.method,
chunk_size=request.chunk_size,
overlap=request.overlap
)
# Update file status
file.status = "completed"
await db.commit()
# Process file
text = await process_file_by_type(file)
return ApiResponse.ok(
data={"chunks": len(chunks)},
message=f"Successfully split into {len(chunks)} chunks"
)
# Update file status
file.status = "processing"
await db.commit()
# Split text
kwargs = {"chunk_size": request.chunk_size, "overlap": request.overlap}
if request.method == "custom" and request.separator:
kwargs["separator"] = request.separator
# 如果使用 semantic_embedding 方法,传递 embedding 参数
if request.method == "semantic_embedding":
kwargs["embedding_provider_type"] = request.embedding_provider or "openai"
kwargs["embedding_api_key"] = request.embedding_api_key
kwargs["embedding_base_url"] = request.embedding_base_url or "https://api.minimax.chat/v1"
kwargs["embedding_model"] = request.embedding_model or "text-embedding-3-small"
kwargs["similarity_threshold"] = request.similarity_threshold
kwargs["min_chunk_size"] = request.min_chunk_size
splitter = get_splitter(request.method, **kwargs)
split_results = splitter.split(text)
# Save chunks
chunks = []
for chunk_data in split_results:
db_chunk = Chunk(
project_id=project_id,
file_id=file.id,
name=chunk_data.get("name", f"Chunk {chunk_data['index'] + 1}"),
content=chunk_data["content"],
word_count=chunk_data.get("word_count", len(chunk_data["content"].split()))
)
db.add(db_chunk)
chunks.append(db_chunk)
await db.commit()
# Save processed markdown to ready directory
ready_dir = get_project_ready_dir(str(project_id))
md_filename = f"{file.id}_{file.filename}.md"
md_path = ready_dir / md_filename
# Write markdown content to file
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
lambda: md_path.write_text(text, encoding='utf-8')
)
# Update file path to ready location
file.file_path = str(md_path)
file.status = "completed"
await db.commit()
# 记录成功日志
log_success(
"文件处理完成",
project_id=str(project_id),
file_id=str(file.id),
filename=file.filename,
chunk_count=len(chunks),
text_length=len(text),
ready_path=str(md_path)
)
return ApiResponse.ok(
data={"chunks": len(chunks)},
message=f"Successfully split into {len(chunks)} chunks"
)
except Exception as e:
# 记录失败日志
log_failure(
"文件处理失败",
project_id=str(project_id),
file_id=str(request.file_id),
error=str(e)
)
raise
@router.get("", response_model=ApiResponse)

View File

@@ -5,9 +5,9 @@ import os
import asyncio
from pathlib import Path
from typing import Optional
from uuid import UUID
from uuid import UUID, uuid4
from fastapi import APIRouter, Depends, UploadFile, File, Query
from fastapi.responses import FileResponse
from fastapi.responses import FileResponse, PlainTextResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.response import ApiResponse, PaginatedResponse
@@ -15,19 +15,34 @@ from app.core.config import get_settings
from app.core.database import get_db
from app.core.exceptions import ValidationException, NotFoundException
from app.core.crud import CRUDBase
from app.core.logging import log_success, log_failure
from app.models.models import File as FileModel
from app.schemas.file import FileResponse, FileCreateSchema
from markitdown import MarkItDown
settings = get_settings()
router = APIRouter()
# Ensure upload directory exists
UPLOAD_DIR = Path(settings.UPLOAD_DIR)
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
# Initialize CRUD
file_crud = CRUDBase(FileModel)
# Initialize markitdown
markitdown = MarkItDown()
def get_project_raw_dir(project_id: str) -> Path:
"""获取项目的 raw 文件目录"""
base_dir = Path("/data/code/YG-Datasets/data") / project_id / "raw"
base_dir.mkdir(parents=True, exist_ok=True)
return base_dir
def get_project_ready_dir(project_id: str) -> Path:
"""获取项目的 ready 文件目录(处理后的文件)"""
base_dir = Path("/data/code/YG-Datasets/data") / project_id / "ready"
base_dir.mkdir(parents=True, exist_ok=True)
return base_dir
def get_file_type(filename: str) -> str:
"""Get file type from extension"""
@@ -82,40 +97,124 @@ async def upload_file(
db: AsyncSession = Depends(get_db)
):
"""Upload a file"""
# Read file content for validation
content = await file.read()
file_size = len(content)
try:
# Read file content for validation
content = await file.read()
file_size = len(content)
# Validate file
validate_file(file.filename, file_size)
# Validate file
validate_file(file.filename, file_size)
# Save file to disk
safe_filename = f"{project_id}_{UUID.uuid4().hex[:8]}_{file.filename}"
file_path = UPLOAD_DIR / safe_filename
# Save file to disk - 使用项目 raw 目录
safe_filename = f"{uuid4().hex[:8]}_{file.filename}"
project_dir = get_project_raw_dir(str(project_id))
file_path = project_dir / safe_filename
# Write file asynchronously
await asyncio.get_event_loop().run_in_executor(
None,
lambda: file_path.write_bytes(content)
)
# Write file asynchronously
await asyncio.get_event_loop().run_in_executor(
None,
lambda: file_path.write_bytes(content)
)
# Create file record
db_file = FileModel(
project_id=project_id,
filename=file.filename,
file_type=get_file_type(file.filename),
file_path=str(file_path),
size=file_size,
status="pending"
)
db.add(db_file)
await db.commit()
await db.refresh(db_file)
# Create file record
db_file = FileModel(
project_id=project_id,
filename=file.filename,
file_type=get_file_type(file.filename),
file_path=str(file_path),
size=file_size,
status="processing"
)
db.add(db_file)
await db.commit()
await db.refresh(db_file)
return ApiResponse.ok(
data={"id": str(db_file.id), "filename": db_file.filename, "status": db_file.status},
message="File uploaded successfully"
)
# 异步处理文件:立即返回,不等待处理完成
async def process_file_async(file_id: UUID, file_path_obj: Path, file_type: str, filename: str, project_id_val: UUID):
"""后台异步处理文件"""
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import AsyncSessionLocal
async with AsyncSessionLocal() as processing_db:
try:
# 重新获取文件记录
file_record = await file_crud.get(processing_db, file_id)
if not file_record:
return
# 支持 markitdown 转换的文件类型
markitdown_types = ["pdf", "docx", "doc", "pptx", "ppt", "xlsx", "xls", "htm", "html"]
text_content = ""
if file_type in markitdown_types:
# 使用 markitdown 转换为 markdown
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
lambda: markitdown.convert(str(file_path_obj))
)
text_content = result.text_content
else:
# txt, md 等直接读取
text_content = file_path_obj.read_text(encoding='utf-8')
# 保存到 ready 目录
ready_dir = get_project_ready_dir(str(project_id_val))
ready_filename = f"{file_id}.md"
ready_path = ready_dir / ready_filename
ready_path.write_text(text_content, encoding='utf-8')
# 更新文件状态为处理完成
file_record.status = "completed"
await processing_db.commit()
log_success(
"文件处理完成",
project_id=str(project_id_val),
file_id=str(file_id),
filename=filename,
ready_path=str(ready_path)
)
except Exception as e:
# 更新文件状态为处理失败
file_record = await file_crud.get(processing_db, file_id)
if file_record:
file_record.status = "failed"
await processing_db.commit()
log_failure(
"文件处理失败",
project_id=str(project_id_val),
file_id=str(file_id),
filename=filename,
error=str(e)
)
# 启动异步任务处理文件
asyncio.create_task(
process_file_async(
db_file.id,
file_path,
db_file.file_type,
file.filename,
project_id
)
)
return ApiResponse.ok(
data={"id": str(db_file.id), "filename": db_file.filename, "status": db_file.status},
message="File uploaded successfully, processing in background"
)
except Exception as e:
# 记录失败日志
log_failure(
"文件上传失败",
project_id=str(project_id),
filename=file.filename if 'file' in locals() else "unknown",
error=str(e)
)
raise
@router.get("", response_model=ApiResponse)
@@ -159,6 +258,71 @@ async def get_file(
return ApiResponse.ok(data=FileResponse.model_validate(file))
@router.get("/{file_id}/raw")
async def get_file_raw(
project_id: UUID,
file_id: UUID,
db: AsyncSession = Depends(get_db)
):
"""Get raw file content for preview"""
file = await file_crud.get(db, file_id)
if not file or file.project_id != project_id:
raise NotFoundException("File", file_id)
# 读取 raw 目录中的原始文件
raw_path = Path(file.file_path)
if not raw_path.exists():
raise NotFoundException("File not found on disk", file_id)
# 根据文件类型返回不同的内容
if file.file_type in ['txt', 'md', 'markdown', 'csv']:
content = raw_path.read_text(encoding='utf-8')
return PlainTextResponse(content=content, media_type="text/plain; charset=utf-8")
elif file.file_type == 'pdf':
# 返回PDF文件浏览器可以内嵌显示
import base64
content = raw_path.read_bytes()
b64 = base64.b64encode(content).decode('utf-8')
return PlainTextResponse(
content=f"data:application/pdf;base64,{b64}",
media_type="text/plain"
)
else:
# 其他二进制文件,返回文件信息
size_mb = file.size / (1024 * 1024)
content = f"""[二进制文件]
文件名: {file.filename}
文件类型: {file.file_type.upper()}
文件大小: {size_mb:.2f} MB
此文件为二进制格式,请下载后查看。
"""
return PlainTextResponse(content=content, media_type="text/plain; charset=utf-8")
@router.get("/{file_id}/content")
async def get_file_content(
project_id: UUID,
file_id: UUID,
db: AsyncSession = Depends(get_db)
) -> PlainTextResponse:
"""Get file content (markdown)"""
file = await file_crud.get(db, file_id)
if not file or file.project_id != project_id:
raise NotFoundException("File", file_id)
# 读取 ready 目录中的 markdown 文件
ready_path = Path("/data/code/YG-Datasets/data") / str(project_id) / "ready" / f"{file_id}.md"
if ready_path.exists():
content = ready_path.read_text(encoding='utf-8')
return PlainTextResponse(content=content, media_type="text/plain; charset=utf-8")
else:
raise NotFoundException("File content", file_id)
@router.delete("/{file_id}", response_model=ApiResponse)
async def delete_file(
project_id: UUID,
@@ -170,7 +334,7 @@ async def delete_file(
if not file or file.project_id != project_id:
raise NotFoundException("File", file_id)
# Delete file from disk
# Delete file from raw directory
if file.file_path and os.path.exists(file.file_path):
await asyncio.get_event_loop().run_in_executor(
None,
@@ -178,16 +342,25 @@ async def delete_file(
file.file_path
)
# Delete file from ready directory (processed markdown)
ready_path = Path("/data/code/YG-Datasets/data") / str(project_id) / "ready" / f"{file_id}.md"
if ready_path.exists():
await asyncio.get_event_loop().run_in_executor(
None,
os.remove,
str(ready_path)
)
await file_crud.delete(db, file_id)
return ApiResponse.ok(message="File deleted successfully")
@router.get("/{file_id}/download", response_class=FileResponse)
@router.get("/{file_id}/download")
async def download_file(
project_id: UUID,
file_id: UUID,
db: AsyncSession = Depends(get_db)
):
) -> FileResponse:
"""Download file"""
file = await file_crud.get(db, file_id)
if not file or file.project_id != project_id:

View File

@@ -2,6 +2,8 @@
Projects API Router
"""
import logging
import shutil
from pathlib import Path
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, Query
@@ -27,7 +29,7 @@ logger = logging.getLogger("yg_dataset.projects")
project_crud = CRUDBase(Project)
@router.get("", response_model=ApiResponse)
@router.get("", response_model=PaginatedResponse)
async def list_projects(
page: int = Query(1, ge=1, description="Page number"),
page_size: int = Query(20, ge=1, le=100, description="Page size"),
@@ -107,5 +109,12 @@ async def delete_project(
logger.info(f"Deleting project: id={project_id}")
await project_crud.get_or_raise(db, project_id, "Project")
await project_crud.delete(db, project_id)
# 删除项目对应的本地数据目录
project_data_dir = Path("/data/code/YG-Datasets/data") / str(project_id)
if project_data_dir.exists():
shutil.rmtree(project_data_dir)
logger.info(f"Project data directory deleted: {project_data_dir}")
logger.info(f"Project deleted: id={project_id}")
return ApiResponse.ok(message="Project deleted successfully")

View File

@@ -4,8 +4,9 @@ Logging Configuration
"""
import logging
import sys
from datetime import datetime
from typing import Any
from logging.handlers import RotatingFileHandler
from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler
from pathlib import Path
from app.core.config import get_settings
@@ -15,6 +16,18 @@ settings = get_settings()
LOG_DIR = Path("./logs")
LOG_DIR.mkdir(exist_ok=True)
# 日期格式
LOG_DATE = datetime.now().strftime("%Y-%m-%d")
# 当天的日志目录
CURRENT_LOG_DIR = LOG_DIR / LOG_DATE
CURRENT_LOG_DIR.mkdir(exist_ok=True)
def get_log_path(filename: str) -> Path:
"""获取当天的日志文件路径"""
return CURRENT_LOG_DIR / filename
def setup_logging(name: str = "yg_dataset") -> logging.Logger:
"""Setup application logging"""
@@ -35,20 +48,21 @@ def setup_logging(name: str = "yg_dataset") -> logging.Logger:
console_handler.setFormatter(console_formatter)
logger.addHandler(console_handler)
# File handler
file_handler = RotatingFileHandler(
LOG_DIR / f"{name}.log",
maxBytes=10 * 1024 * 1024, # 10MB
backupCount=5,
# Main log file handler - app.log
main_file_handler = TimedRotatingFileHandler(
get_log_path("app.log"),
when="midnight",
interval=1,
backupCount=30,
encoding="utf-8"
)
file_handler.setLevel(logging.INFO)
file_formatter = logging.Formatter(
main_file_handler.setLevel(logging.INFO)
main_file_formatter = logging.Formatter(
fmt="%(asctime)s | %(levelname)-8s | %(name)s:%(funcName)s:%(lineno)d | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
file_handler.setFormatter(file_formatter)
logger.addHandler(file_handler)
main_file_handler.setFormatter(main_file_formatter)
logger.addHandler(main_file_handler)
return logger
@@ -57,6 +71,65 @@ def setup_logging(name: str = "yg_dataset") -> logging.Logger:
logger = setup_logging()
# ============== Success Logger ==============
def get_success_logger() -> logging.Logger:
"""获取成功日志记录器"""
success_logger = logging.getLogger("yg_dataset.success")
if not success_logger.handlers:
handler = RotatingFileHandler(
get_log_path("success.log"),
maxBytes=10 * 1024 * 1024,
backupCount=30,
encoding="utf-8"
)
handler.setLevel(logging.INFO)
formatter = logging.Formatter(
fmt="%(asctime)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
handler.setFormatter(formatter)
success_logger.addHandler(handler)
success_logger.setLevel(logging.INFO)
return success_logger
# ============== Failure Logger ==============
def get_failure_logger() -> logging.Logger:
"""获取失败日志记录器"""
failure_logger = logging.getLogger("yg_dataset.failure")
if not failure_logger.handlers:
handler = RotatingFileHandler(
get_log_path("failure.log"),
maxBytes=10 * 1024 * 1024,
backupCount=30,
encoding="utf-8"
)
handler.setLevel(logging.WARNING)
formatter = logging.Formatter(
fmt="%(asctime)s | %(levelname)-8s | %(name)s:%(funcName)s:%(lineno)d | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
handler.setFormatter(formatter)
failure_logger.addHandler(handler)
failure_logger.setLevel(logging.WARNING)
return failure_logger
# ============== Convenience functions ==============
def log_success(message: str, **kwargs):
"""记录成功日志"""
extra_info = " | ".join([f"{k}={v}" for k, v in kwargs.items()]) if kwargs else ""
full_message = f"{message} | {extra_info}" if extra_info else message
get_success_logger().info(full_message)
def log_failure(message: str, **kwargs):
"""记录失败日志"""
extra_info = " | ".join([f"{k}={v}" for k, v in kwargs.items()]) if kwargs else ""
full_message = f"{message} | {extra_info}" if extra_info else message
get_failure_logger().warning(full_message)
class LoggerMixin:
"""Mixin to add logging capability to classes"""

View File

@@ -107,7 +107,7 @@ async def app_exception_handler(request: Request, exc: AppException):
content=ApiResponse.fail(
message=exc.message,
error={"code": exc.code, "details": exc.details}
).model_dump()
).model_dump(mode='json')
)
@@ -127,7 +127,7 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE
content=ApiResponse.fail(
message="Validation error",
error={"code": "VALIDATION_ERROR", "details": {"errors": errors}}
).model_dump()
).model_dump(mode='json')
)
@@ -140,7 +140,7 @@ async def database_exception_handler(request: Request, exc: SQLAlchemyError):
content=ApiResponse.fail(
message="Database operation failed",
error={"code": "DATABASE_ERROR"}
).model_dump()
).model_dump(mode='json')
)
@@ -153,7 +153,7 @@ async def general_exception_handler(request: Request, exc: Exception):
content=ApiResponse.fail(
message="Internal server error",
error={"code": "INTERNAL_ERROR"}
).model_dump()
).model_dump(mode='json')
)

View File

@@ -14,6 +14,7 @@ class Project(Base, UUIDMixin, TimestampMixin):
name = Column(String(255), nullable=False)
description = Column(Text)
type = Column(String(50), default="qa") # qa, table, database
# Relationships
files = relationship("File", back_populates="project", cascade="all, delete-orphan")

View File

@@ -11,6 +11,7 @@ class ProjectBase(BaseModel):
"""Base project schema"""
name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=2000)
type: str = Field(default="qa") # qa, table, database
class ProjectCreate(ProjectBase):
@@ -22,6 +23,7 @@ class ProjectUpdate(BaseModel):
"""Project update schema"""
name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=2000)
type: Optional[str] = Field(None)
class ProjectResponse(ProjectBase):

View File

@@ -0,0 +1,395 @@
"""
Semantic Text Splitter using Online Embedding APIs
基于在线 Embedding API 的语义分割器
"""
import re
import asyncio
import httpx
import numpy as np
from typing import List, Dict, Optional
from abc import ABC, abstractmethod
class EmbeddingProvider(ABC):
"""Embedding API 提供商基类"""
@abstractmethod
async def get_embeddings(self, texts: List[str]) -> List[List[float]]:
"""获取文本的嵌入向量"""
pass
class OpenAIEmbedding(EmbeddingProvider):
"""OpenAI 兼容的 Embedding API"""
def __init__(self, api_key: str, base_url: str, model: str = "text-embedding-3-small"):
self.api_key = api_key
self.base_url = base_url.rstrip('/')
self.model = model
async def get_embeddings(self, texts: List[str]) -> List[List[float]]:
"""调用 OpenAI 兼容的 Embedding API"""
async with httpx.AsyncClient(timeout=60.0) as client:
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
}
# OpenAI 格式
payload = {
"input": texts,
"model": self.model
}
response = await client.post(
f"{self.base_url}/embeddings",
headers=headers,
json=payload
)
response.raise_for_status()
data = response.json()
# 提取 embeddings
return [item["embedding"] for item in data["data"]]
class MiniMaxEmbedding(EmbeddingProvider):
"""MiniMax Embedding API"""
def __init__(self, api_key: str, base_url: str = "https://api.minimax.chat/v1"):
self.api_key = api_key
self.base_url = base_url.rstrip('/')
async def get_embeddings(self, texts: List[str]) -> List[List[float]]:
"""调用 MiniMax Embedding API"""
async with httpx.AsyncClient(timeout=60.0) as client:
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
}
# MiniMax 格式
payload = {
"texts": texts,
"model": "embo-01"
}
response = await client.post(
f"{self.base_url}/text_embeddings",
headers=headers,
json=payload
)
response.raise_for_status()
data = response.json()
# MiniMax 返回格式可能不同,需要适配
if "data" in data:
return [item["embedding"] for item in data["data"]]
return []
class EmbeddingSplitter:
"""基于 Embedding 的语义分割器基类"""
def __init__(
self,
chunk_size: int = 500,
overlap: int = 50,
embedding_provider: Optional[EmbeddingProvider] = None,
similarity_threshold: float = 0.3,
min_chunk_size: int = 100,
window_size: int = 3
):
self.chunk_size = chunk_size
self.overlap = overlap
self.embedding_provider = embedding_provider
self.similarity_threshold = similarity_threshold
self.min_chunk_size = min_chunk_size
self.window_size = window_size
def _tokenize_sentences(self, text: str) -> List[str]:
"""将文本切分为句子"""
# 中英文句末符号
# 先按换行分割,保持段落结构
paragraphs = re.split(r'\n+', text)
sentences = []
for para in paragraphs:
if not para.strip():
continue
# 按句子符号分割
# 中文:。!?;
# 英文:. ! ? ;
parts = re.split(r'([。!?;\n]|(?<=[.!?])\s+)', para)
# 重新组合句子
current_sentence = ""
for part in parts:
if part in '。!?;.\n':
if current_sentence.strip():
sentences.append(current_sentence.strip())
current_sentence = ""
elif part and part.strip():
current_sentence += part
# 处理最后一个句子
if current_sentence.strip():
sentences.append(current_sentence.strip())
return sentences
def _compute_similarities(self, embeddings: List[List[float]]) -> List[float]:
"""计算相邻句子的余弦相似度"""
similarities = []
for i in range(len(embeddings) - 1):
# 余弦相似度
vec1 = np.array(embeddings[i])
vec2 = np.array(embeddings[i + 1])
# 归一化
vec1 = vec1 / (np.linalg.norm(vec1) + 1e-8)
vec2 = vec2 / (np.linalg.norm(vec2) + 1e-8)
# 点积 = 余弦相似度(归一化后)
sim = np.dot(vec1, vec2)
similarities.append(float(sim))
return similarities
def _smooth_similarities(self, similarities: List[float]) -> List[float]:
"""滑动窗口平滑相似度"""
if not similarities:
return []
window = self.window_size
smoothed = []
for i in range(len(similarities)):
start = max(0, i - window + 1)
end = i + 1
window_vals = similarities[start:end]
smoothed.append(sum(window_vals) / len(window_vals))
return smoothed
def _detect_boundaries(self, similarities: List[float]) -> List[int]:
"""检测分割点(相似度显著下降的位置)"""
if not similarities:
return [0]
# 平滑
smoothed = self._smooth_similarities(similarities)
# 计算深度分数(类似 TextTiling
depth_scores = []
for i in range(1, len(smoothed) - 1):
# 当前位置的深度 = 当前位置的值 - 平均值
# 但更准确的是:左侧平均 - 右侧平均
left_avg = sum(smoothed[max(0, i - self.window_size):i]) / self.window_size
right_avg = sum(smoothed[i:min(len(smoothed), i + self.window_size)]) / self.window_size
depth = left_avg - right_avg
depth_scores.append(depth)
# 如果没有足够的点,直接返回
if not depth_scores:
return [0]
# 阈值判断
mean_depth = np.mean(depth_scores)
std_depth = np.std(depth_scores)
# 找分割点depth 显著高于均值的位置
threshold = mean_depth + 0.5 * std_depth
boundaries = [0] # 起始点
for i, depth in enumerate(depth_scores):
if depth > threshold and depth > self.similarity_threshold:
boundaries.append(i + 1) # 对应相似度的下一个位置
boundaries.append(len(self._tokenize_sentences.__name__)) # 结束点
return sorted(list(set(boundaries)))
def _assemble_chunks(self, sentences: List[str], boundaries: List[int]) -> List[Dict]:
"""按分割点组装 chunks"""
if not sentences:
return []
# 重新计算 boundaries确保不超过句子数
if not boundaries or boundaries[0] != 0:
boundaries = [0] + boundaries
if boundaries[-1] != len(sentences):
boundaries.append(len(sentences))
chunks = []
for i in range(len(boundaries) - 1):
start = boundaries[i]
end = boundaries[i + 1]
chunk_text = ' '.join(sentences[start:end])
# 如果 chunk 过大,递归分割
if len(chunk_text) > self.chunk_size * 1.5:
# 使用更小的窗口再次分割
sub_chunks = self._split_large_chunk(sentences[start:end])
for j, sub in enumerate(sub_chunks):
chunks.append({
"index": len(chunks),
"content": sub.strip(),
"word_count": len(sub.split()),
"char_count": len(sub)
})
else:
chunks.append({
"index": len(chunks),
"content": chunk_text.strip(),
"word_count": len(chunk_text.split()),
"char_count": len(chunk_text)
})
# 合并过小的相邻 chunks
chunks = self._merge_small_chunks(chunks)
return chunks
def _split_large_chunk(self, sentences: List[str]) -> List[str]:
"""分割过大的 chunk"""
# 使用固定长度分割
result = []
current = ""
for sent in sentences:
if len(current) + len(sent) > self.chunk_size:
if current:
result.append(current)
current = sent
else:
current += " " + sent if current else sent
if current:
result.append(current)
return result
def _merge_small_chunks(self, chunks: List[Dict]) -> List[Dict]:
"""合并过小的相邻 chunks"""
if len(chunks) <= 1:
return chunks
merged = [chunks[0]]
for chunk in chunks[1:]:
# 如果前一个 chunk 太小,合并
if merged[-1]["char_count"] < self.min_chunk_size:
merged[-1]["content"] += " " + chunk["content"]
merged[-1]["word_count"] += chunk["word_count"]
merged[-1]["char_count"] += chunk["char_count"]
else:
merged.append(chunk)
return merged
async def split_with_embedding(self, text: str) -> List[Dict]:
"""使用 Embedding 进行语义分割"""
# 1. 句子切分
sentences = self._tokenize_sentences(text)
if not sentences:
return []
# 过滤过短的句子
sentences = [s for s in sentences if len(s) >= 10]
if not sentences:
return []
# 2. 如果只有一个句子,直接返回
if len(sentences) == 1:
return [{
"index": 0,
"content": sentences[0],
"word_count": len(sentences[0].split()),
"char_count": len(sentences[0])
}]
# 3. 调用 Embedding API
try:
embeddings = await self.embedding_provider.get_embeddings(sentences)
except Exception as e:
# 如果 embedding 失败,降级到规则分割
print(f"Embedding failed, falling back to rule-based: {e}")
return self._fallback_split(text)
# 4. 计算相似度
similarities = self._compute_similarities(embeddings)
# 5. 检测分割点
boundaries = self._detect_boundaries(similarities)
# 6. 组装 chunks
chunks = self._assemble_chunks(sentences, boundaries)
return chunks
def _fallback_split(self, text: str) -> List[Dict]:
"""降级到规则分割"""
# 使用 langchain 的 RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=self.chunk_size,
chunk_overlap=self.overlap,
separators=["\n\n", "\n", "", "", "", ". ", "! ", "? "]
)
chunks = splitter.split_text(text)
return [{
"index": i,
"content": c.strip(),
"word_count": len(c.split()),
"char_count": len(c)
} for i, c in enumerate(chunks)]
class SemanticEmbeddingSplitter(EmbeddingSplitter):
"""基于在线 Embedding 的语义分割器"""
def __init__(
self,
chunk_size: int = 500,
overlap: int = 50,
embedding_provider: Optional[EmbeddingProvider] = None,
similarity_threshold: float = 0.3,
min_chunk_size: int = 100,
window_size: int = 3
):
super().__init__(
chunk_size=chunk_size,
overlap=overlap,
embedding_provider=embedding_provider,
similarity_threshold=similarity_threshold,
min_chunk_size=min_chunk_size,
window_size=window_size
)
def split(self, text: str) -> List[Dict]:
"""同步接口,内部调用异步"""
# 由于 split 是同步方法,需要创建新的事件循环
try:
loop = asyncio.get_event_loop()
if loop.is_running():
# 如果在异步环境中,创建新任务
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as pool:
future = pool.submit(asyncio.run, self.split_with_embedding(text))
return future.result()
else:
return loop.run_until_complete(self.split_with_embedding(text))
except RuntimeError:
# 没有事件循环,直接创建
return asyncio.run(self.split_with_embedding(text))
def create_embedding_provider(provider: str, api_key: str, base_url: str, model: str = None) -> EmbeddingProvider:
"""创建 Embedding 提供商"""
if provider in ["openai", "compatible"]:
return OpenAIEmbedding(api_key, base_url, model or "text-embedding-3-small")
elif provider == "minimax":
return MiniMaxEmbedding(api_key, base_url)
else:
raise ValueError(f"Unsupported embedding provider: {provider}")

View File

@@ -3,6 +3,7 @@ Text Splitter
"""
import re
from typing import List, Dict, Optional
from langchain_text_splitters import RecursiveCharacterTextSplitter
class TextSplitter:
@@ -18,51 +19,29 @@ class TextSplitter:
class RecursiveTextSplitter(TextSplitter):
"""Recursive character text splitter"""
"""Recursive character text splitter using langchain"""
def __init__(self, chunk_size: int = 500, overlap: int = 50, separators: List[str] = None):
super().__init__(chunk_size, overlap)
self.separators = separators or ["\n\n", "\n", ". ", " ", ""]
self.splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=overlap,
separators=separators or [
"\n\n", "\n", ". ", " ", ",", ""
]
)
def split(self, text: str) -> List[Dict]:
"""Split text recursively"""
chunks = []
current_chunk = ""
chunk_index = 0
for separator in self.separators:
if separator in text:
parts = text.split(separator)
for part in parts:
if len(current_chunk) + len(part) > self.chunk_size:
if current_chunk:
chunks.append({
"index": chunk_index,
"content": current_chunk.strip(),
"word_count": len(current_chunk.split())
})
chunk_index += 1
# Handle overlap
if self.overlap > 0 and chunks:
overlap_text = " ".join(chunks[-1]["content"].split()[-self.overlap:])
current_chunk = overlap_text + separator + part
else:
current_chunk = part
else:
current_chunk += separator + part if current_chunk else part
if current_chunk:
chunks.append({
"index": chunk_index,
"content": current_chunk.strip(),
"word_count": len(current_chunk.split())
})
break
else:
continue
return chunks
chunks = self.splitter.split_text(text)
result = []
for i, chunk in enumerate(chunks):
result.append({
"index": i,
"content": chunk.strip(),
"word_count": len(chunk.split())
})
return result
class MarkdownStructureSplitter(TextSplitter):
@@ -236,13 +215,199 @@ class CustomSplitter(TextSplitter):
def get_splitter(method: str, **kwargs) -> TextSplitter:
"""Get text splitter by method name"""
# 导入 embedding 分割器
from .semantic_embedding import (
SemanticEmbeddingSplitter,
create_embedding_provider
)
splitters = {
"recursive": RecursiveTextSplitter,
"markdown_structure": MarkdownStructureSplitter,
"token": TokenSplitter,
"code": CodeSplitter,
"custom": CustomSplitter
"custom": CustomSplitter,
"semantic": SemanticSentenceSplitter, # 语义分割(按段落+句子)
"semantic_embedding": None, # 需要特殊处理
"sentence": SentenceSplitter, # 严格按单句分割
"paragraph": ParagraphSplitter, # 按段落分割
}
# 特殊处理 embedding 分割器
if method == "semantic_embedding":
# 提取 embedding 相关参数
embedding_provider = kwargs.pop('embedding_provider', None)
if embedding_provider is None:
# 如果没有提供 provider使用默认配置
# 从 kwargs 中获取模型配置
provider = kwargs.pop('embedding_provider_type', 'openai')
api_key = kwargs.pop('embedding_api_key', '')
base_url = kwargs.pop('embedding_base_url', 'https://api.minimax.chat/v1')
model = kwargs.pop('embedding_model', 'text-embedding-3-small')
if api_key:
embedding_provider = create_embedding_provider(
provider, api_key, base_url, model
)
# 创建分割器
if embedding_provider:
return SemanticEmbeddingSplitter(
embedding_provider=embedding_provider,
**kwargs
)
else:
# 没有 embedding provider降级到 semantic
method = "semantic"
splitter_class = splitters.get(method, RecursiveTextSplitter)
return splitter_class(**kwargs)
class SemanticSentenceSplitter(TextSplitter):
"""语义分割器 - 按段落优先,其次按句子"""
def __init__(self, chunk_size: int = 500, overlap: int = 50):
super().__init__(chunk_size, overlap)
self.splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=overlap,
separators=[
"\n\n", # 段落分隔优先
"", # 中文句号
"", # 中文感叹号
"", # 中文问号
". ", # 英文句号
"! ", # 英文感叹号
"? ", # 英文问号
"\n", # 换行
" ", # 空格
],
length_function=self._count_chars
)
def _count_chars(self, text: str) -> int:
chinese_chars = len(re.findall(r'[\u4e00-\u9fff]', text))
other_chars = len(re.sub(r'[\u4e00-\u9fff]', '', text))
return chinese_chars + int(other_chars * 1.5)
def split(self, text: str) -> List[Dict]:
chunks = self.splitter.split_text(text)
result = []
for i, chunk in enumerate(chunks):
result.append({
"index": i,
"content": chunk.strip(),
"word_count": len(chunk.split()),
"char_count": len(chunk)
})
return result
class SentenceSplitter(TextSplitter):
"""严格按单句分割 - 每个chunk就是一句话"""
def __init__(self, chunk_size: int = 200, overlap: int = 0):
super().__init__(chunk_size, overlap)
# 只按句子结束符分割,不合并
self.splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=overlap,
separators=[
"", # 中文句号
"", # 中文感叹号
"", # 中文问号
". ", # 英文句号
"! ", # 英文感叹号
"? ", # 英文问号
"\n", # 换行
" ", # 空格
],
length_function=lambda x: len(x)
)
def split(self, text: str) -> List[Dict]:
chunks = self.splitter.split_text(text)
result = []
for i, chunk in enumerate(chunks):
chunk = chunk.strip()
if chunk: # 跳过空chunk
result.append({
"index": i,
"content": chunk,
"word_count": len(chunk.split()),
"char_count": len(chunk)
})
return result
class ParagraphSplitter(TextSplitter):
"""按段落分割 - 以空行分隔"""
def __init__(self, chunk_size: int = 2000, overlap: int = 100):
overlap = min(overlap, chunk_size // 2) # overlap 不能超过 chunk_size
super().__init__(chunk_size, overlap)
def split(self, text: str) -> List[Dict]:
# 按空行分割段落
paragraphs = re.split(r'\n\s*\n', text)
result = []
current_chunk = ""
chunk_index = 0
for para in paragraphs:
para = para.strip()
if not para:
continue
# 如果单个段落超过chunk_size递归分割
if len(para) > self.chunk_size:
if current_chunk:
result.append({
"index": chunk_index,
"content": current_chunk.strip(),
"word_count": len(current_chunk.split()),
"char_count": len(current_chunk)
})
chunk_index += 1
current_chunk = ""
# 递归处理大段落
sub_splitter = RecursiveCharacterTextSplitter(
chunk_size=self.chunk_size,
chunk_overlap=self.overlap,
separators=["\n", "", "", "", ". ", "! ", "? "]
)
sub_chunks = sub_splitter.split_text(para)
for sub in sub_chunks:
result.append({
"index": chunk_index,
"content": sub.strip(),
"word_count": len(sub.split()),
"char_count": len(sub)
})
chunk_index += 1
else:
if len(current_chunk) + len(para) > self.chunk_size:
if current_chunk:
result.append({
"index": chunk_index,
"content": current_chunk.strip(),
"word_count": len(current_chunk.split()),
"char_count": len(current_chunk)
})
chunk_index += 1
current_chunk = ""
current_chunk += para + "\n\n"
# 添加最后一个chunk
if current_chunk.strip():
result.append({
"index": chunk_index,
"content": current_chunk.strip(),
"word_count": len(current_chunk.split()),
"char_count": len(current_chunk)
})
return result

View File

@@ -1,20 +0,0 @@
# Bug 修改记录
## 2026-03-17
### 初始项目创建
- 创建 YG-Dataset 重构项目
- 搭建 FastAPI + Vue 3 基础架构
---
## 修复记录格式
### 日期
**问题描述:**
**原因:**
**修复方案:**
---
*持续更新中...*

View File

@@ -10,6 +10,7 @@
"dependencies": {
"@element-plus/icons-vue": "^2.3.0",
"@vueuse/core": "^11.0.0",
"ant-design-vue": "^4.2.6",
"axios": "^1.7.0",
"element-plus": "^2.8.0",
"pinia": "^2.2.0",
@@ -26,6 +27,43 @@
"vue-tsc": "^3.2.5"
}
},
"node_modules/@ant-design/colors": {
"version": "6.0.0",
"resolved": "https://registry.npmmirror.com/@ant-design/colors/-/colors-6.0.0.tgz",
"integrity": "sha512-qAZRvPzfdWHtfameEGP2Qvuf838NhergR35o+EuVyB5XvSA98xod5r4utvi4TJ3ywmevm290g9nsCG5MryrdWQ==",
"license": "MIT",
"dependencies": {
"@ctrl/tinycolor": "^3.4.0"
}
},
"node_modules/@ant-design/colors/node_modules/@ctrl/tinycolor": {
"version": "3.6.1",
"resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz",
"integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/@ant-design/icons-svg": {
"version": "4.4.2",
"resolved": "https://registry.npmmirror.com/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz",
"integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==",
"license": "MIT"
},
"node_modules/@ant-design/icons-vue": {
"version": "7.0.1",
"resolved": "https://registry.npmmirror.com/@ant-design/icons-vue/-/icons-vue-7.0.1.tgz",
"integrity": "sha512-eCqY2unfZK6Fe02AwFlDHLfoyEFreP6rBwAZMIJ1LugmfMiVgwWDYlp1YsRugaPtICYOabV1iWxXdP12u9U43Q==",
"license": "MIT",
"dependencies": {
"@ant-design/colors": "^6.0.0",
"@ant-design/icons-svg": "^4.2.1"
},
"peerDependencies": {
"vue": ">=3.0.3"
}
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
@@ -59,6 +97,15 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz",
@@ -97,6 +144,18 @@
"vue": "^3.2.0"
}
},
"node_modules/@emotion/hash": {
"version": "0.9.2",
"resolved": "https://registry.npmmirror.com/@emotion/hash/-/hash-0.9.2.tgz",
"integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==",
"license": "MIT"
},
"node_modules/@emotion/unitless": {
"version": "0.8.1",
"resolved": "https://registry.npmmirror.com/@emotion/unitless/-/unitless-0.8.1.tgz",
"integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==",
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -1241,6 +1300,16 @@
"win32"
]
},
"node_modules/@simonwep/pickr": {
"version": "1.8.2",
"resolved": "https://registry.npmmirror.com/@simonwep/pickr/-/pickr-1.8.2.tgz",
"integrity": "sha512-/l5w8BIkrpP6n1xsetx9MWPWlU6OblN5YgZZphxan0Tq4BByTCETL6lyIeY8lagalS2Nbt4F2W034KHLIiunKA==",
"license": "MIT",
"dependencies": {
"core-js": "^3.15.1",
"nanopop": "^2.1.0"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
@@ -1486,6 +1555,61 @@
"dev": true,
"license": "MIT"
},
"node_modules/ant-design-vue": {
"version": "4.2.6",
"resolved": "https://registry.npmmirror.com/ant-design-vue/-/ant-design-vue-4.2.6.tgz",
"integrity": "sha512-t7eX13Yj3i9+i5g9lqFyYneoIb3OzTvQjq9Tts1i+eiOd3Eva/6GagxBSXM1fOCjqemIu0FYVE1ByZ/38epR3Q==",
"license": "MIT",
"dependencies": {
"@ant-design/colors": "^6.0.0",
"@ant-design/icons-vue": "^7.0.0",
"@babel/runtime": "^7.10.5",
"@ctrl/tinycolor": "^3.5.0",
"@emotion/hash": "^0.9.0",
"@emotion/unitless": "^0.8.0",
"@simonwep/pickr": "~1.8.0",
"array-tree-filter": "^2.1.0",
"async-validator": "^4.0.0",
"csstype": "^3.1.1",
"dayjs": "^1.10.5",
"dom-align": "^1.12.1",
"dom-scroll-into-view": "^2.0.0",
"lodash": "^4.17.21",
"lodash-es": "^4.17.15",
"resize-observer-polyfill": "^1.5.1",
"scroll-into-view-if-needed": "^2.2.25",
"shallow-equal": "^1.0.0",
"stylis": "^4.1.3",
"throttle-debounce": "^5.0.0",
"vue-types": "^3.0.0",
"warning": "^4.0.0"
},
"engines": {
"node": ">=12.22.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ant-design-vue"
},
"peerDependencies": {
"vue": ">=3.2.0"
}
},
"node_modules/ant-design-vue/node_modules/@ctrl/tinycolor": {
"version": "3.6.1",
"resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz",
"integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/array-tree-filter": {
"version": "2.1.0",
"resolved": "https://registry.npmmirror.com/array-tree-filter/-/array-tree-filter-2.1.0.tgz",
"integrity": "sha512-4ROwICNlNw/Hqa9v+rk5h22KjmzB1JGTMVKP2AKJBOCgb0yL0ASf0+YvCcLNNwquOHNX48jkeZIJ3a+oOQqKcw==",
"license": "MIT"
},
"node_modules/async-validator": {
"version": "4.2.5",
"resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz",
@@ -1579,6 +1703,23 @@
"node": ">= 0.8"
}
},
"node_modules/compute-scroll-into-view": {
"version": "1.0.20",
"resolved": "https://registry.npmmirror.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz",
"integrity": "sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==",
"license": "MIT"
},
"node_modules/core-js": {
"version": "3.49.0",
"resolved": "https://registry.npmmirror.com/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/crc-32": {
"version": "1.2.2",
"resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz",
@@ -1623,6 +1764,18 @@
"node": ">=8"
}
},
"node_modules/dom-align": {
"version": "1.12.4",
"resolved": "https://registry.npmmirror.com/dom-align/-/dom-align-1.12.4.tgz",
"integrity": "sha512-R8LUSEay/68zE5c8/3BDxiTEvgb4xZTF0RKmAHfiEVN3klfIpXfi2/QCoiWPccVQ0J/ZGdz9OjzL4uJEP/MRAw==",
"license": "MIT"
},
"node_modules/dom-scroll-into-view": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/dom-scroll-into-view/-/dom-scroll-into-view-2.0.1.tgz",
"integrity": "sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w==",
"license": "MIT"
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2020,6 +2173,21 @@
"node": ">=0.10.0"
}
},
"node_modules/is-plain-object": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/is-plain-object/-/is-plain-object-3.0.1.tgz",
"integrity": "sha512-Xnpx182SBMrr/aBik8y+GuR4U1L9FqMSojwDQwPMmxyC6bvEqly9UBCxhauBF5vNh2gwWJNX6oDV7O+OM4z34g==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.23.tgz",
@@ -2043,6 +2211,18 @@
"lodash-es": "*"
}
},
"node_modules/loose-envify": {
"version": "1.4.0",
"resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz",
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
"license": "MIT",
"dependencies": {
"js-tokens": "^3.0.0 || ^4.0.0"
},
"bin": {
"loose-envify": "cli.js"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
@@ -2113,6 +2293,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/nanopop": {
"version": "2.4.2",
"resolved": "https://registry.npmmirror.com/nanopop/-/nanopop-2.4.2.tgz",
"integrity": "sha512-NzOgmMQ+elxxHeIha+OG/Pv3Oc3p4RU2aBhwWwAqDpXrdTbtRylbRLQztLy8dMMwfl6pclznBdfUhccEn9ZIzw==",
"license": "MIT"
},
"node_modules/node-addon-api": {
"version": "7.1.1",
"resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz",
@@ -2223,6 +2409,12 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/resize-observer-polyfill": {
"version": "1.5.1",
"resolved": "https://registry.npmmirror.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==",
"license": "MIT"
},
"node_modules/rollup": {
"version": "4.59.0",
"resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.59.0.tgz",
@@ -2647,6 +2839,21 @@
"node": ">=14.0.0"
}
},
"node_modules/scroll-into-view-if-needed": {
"version": "2.2.31",
"resolved": "https://registry.npmmirror.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.31.tgz",
"integrity": "sha512-dGCXy99wZQivjmjIqihaBQNjryrz5rueJY7eHfTdyWEiR4ttYpsajb14rn9s5d4DY4EcY6+4+U/maARBXJedkA==",
"license": "MIT",
"dependencies": {
"compute-scroll-into-view": "^1.0.20"
}
},
"node_modules/shallow-equal": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/shallow-equal/-/shallow-equal-1.2.1.tgz",
"integrity": "sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==",
"license": "MIT"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2668,6 +2875,12 @@
"node": ">=0.8"
}
},
"node_modules/stylis": {
"version": "4.3.6",
"resolved": "https://registry.npmmirror.com/stylis/-/stylis-4.3.6.tgz",
"integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==",
"license": "MIT"
},
"node_modules/supports-color": {
"version": "8.1.1",
"resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-8.1.1.tgz",
@@ -2707,6 +2920,15 @@
"node": ">=16.0.0"
}
},
"node_modules/throttle-debounce": {
"version": "5.0.2",
"resolved": "https://registry.npmmirror.com/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
"integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==",
"license": "MIT",
"engines": {
"node": ">=12.22"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -2913,6 +3135,30 @@
"typescript": ">=5.0.0"
}
},
"node_modules/vue-types": {
"version": "3.0.2",
"resolved": "https://registry.npmmirror.com/vue-types/-/vue-types-3.0.2.tgz",
"integrity": "sha512-IwUC0Aq2zwaXqy74h4WCvFCUtoV0iSWr0snWnE9TnU18S66GAQyqQbRf2qfJtUuiFsBf6qp0MEwdonlwznlcrw==",
"license": "MIT",
"dependencies": {
"is-plain-object": "3.0.1"
},
"engines": {
"node": ">=10.15.0"
},
"peerDependencies": {
"vue": "^3.0.0"
}
},
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmmirror.com/warning/-/warning-4.0.3.tgz",
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz",

View File

@@ -10,6 +10,7 @@
"dependencies": {
"@element-plus/icons-vue": "^2.3.0",
"@vueuse/core": "^11.0.0",
"ant-design-vue": "^4.2.6",
"axios": "^1.7.0",
"element-plus": "^2.8.0",
"pinia": "^2.2.0",

View File

@@ -33,12 +33,12 @@ const locale = ref(zhCn)
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
:root {
/* Core Colors - Deep Space */
--bg-primary: #030407;
--bg-secondary: #0a0a0f;
--bg-tertiary: #12121a;
--bg-elevated: #1a1a24;
--bg-hover: #22222e;
/* Core Colors - Deep Space with Gradient */
--bg-primary: #0a0a14;
--bg-secondary: #12121f;
--bg-tertiary: #1a1a2a;
--bg-elevated: #222233;
--bg-hover: #2a2a3d;
/* Accent - Cyan Violet */
--accent-primary: #00d4ff;
@@ -69,8 +69,8 @@ const locale = ref(zhCn)
/* Effects */
--glow-primary: 0 0 30px rgba(0, 212, 255, 0.25);
--glow-secondary: 0 0 30px rgba(124, 58, 237, 0.25);
--glass-bg: rgba(18, 18, 26, 0.6);
--glass-border: rgba(255, 255, 255, 0.06);
--glass-bg: rgba(26, 26, 42, 0.65);
--glass-border: rgba(255, 255, 255, 0.08);
/* Spacing */
--radius-sm: 6px;
@@ -110,6 +110,12 @@ html, body, #app {
min-height: 100vh;
position: relative;
overflow: hidden;
background:
radial-gradient(ellipse 100% 80% at 50% 120%, rgba(99, 102, 241, 0.15), transparent 50%),
radial-gradient(ellipse 80% 60% at 80% 10%, rgba(0, 212, 255, 0.12), transparent 40%),
radial-gradient(ellipse 70% 50% at 10% 80%, rgba(124, 58, 237, 0.1), transparent 40%),
radial-gradient(ellipse 60% 40% at 90% 90%, rgba(236, 72, 153, 0.08), transparent 40%),
linear-gradient(180deg, var(--bg-primary) 0%, #0f0f1a 100%);
}
.bg-mesh {
@@ -123,50 +129,50 @@ html, body, #app {
.mesh-gradient {
position: absolute;
border-radius: 50%;
filter: blur(120px);
opacity: 0.5;
animation: float 25s ease-in-out infinite;
filter: blur(80px);
opacity: 0.6;
animation: float 20s ease-in-out infinite;
}
.mesh-1 {
width: 700px;
height: 700px;
background: radial-gradient(circle, rgba(0, 212, 255, 0.35) 0%, transparent 70%);
top: -250px;
right: -150px;
width: 800px;
height: 800px;
background: radial-gradient(circle, rgba(0, 212, 255, 0.4) 0%, rgba(6, 182, 212, 0.2) 40%, transparent 70%);
top: -300px;
right: -200px;
animation-delay: 0s;
}
.mesh-2 {
width: 600px;
height: 600px;
background: radial-gradient(circle, rgba(124, 58, 237, 0.3) 0%, transparent 70%);
bottom: -200px;
left: -150px;
animation-delay: -8s;
width: 700px;
height: 700px;
background: radial-gradient(circle, rgba(124, 58, 237, 0.4) 0%, rgba(139, 92, 246, 0.2) 40%, transparent 70%);
bottom: -250px;
left: -200px;
animation-delay: -7s;
}
.mesh-3 {
width: 500px;
height: 500px;
background: radial-gradient(circle, rgba(6, 182, 212, 0.2) 0%, transparent 70%);
top: 40%;
left: 30%;
animation-delay: -16s;
width: 600px;
height: 600px;
background: radial-gradient(circle, rgba(236, 72, 153, 0.3) 0%, rgba(168, 85, 247, 0.15) 40%, transparent 70%);
top: 30%;
left: 25%;
animation-delay: -14s;
}
@keyframes float {
0%, 100% { transform: translate(0, 0) scale(1); }
25% { transform: translate(40px, -40px) scale(1.08); }
50% { transform: translate(-30px, 30px) scale(0.95); }
75% { transform: translate(-40px, -25px) scale(1.03); }
25% { transform: translate(60px, -50px) scale(1.1); }
50% { transform: translate(-40px, 40px) scale(0.95); }
75% { transform: translate(-50px, -30px) scale(1.05); }
}
.noise-overlay {
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
opacity: 0.025;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
opacity: 0.035;
}
.app-content {
@@ -248,6 +254,36 @@ html, body, #app {
background: var(--bg-tertiary);
}
/* Ant Design Vue Select Dropdown */
.ant-select-dropdown {
background: var(--bg-elevated) !important;
border: 1px solid var(--border-subtle) !important;
border-radius: 8px !important;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3) !important;
}
.ant-select-item {
color: var(--text-primary) !important;
border-radius: 6px !important;
}
.ant-select-item-option-active:not(.ant-select-item-option-disabled) {
background: var(--bg-hover) !important;
}
.ant-select-item-option-selected:not(.ant-select-item-option-disabled) {
background: var(--accent-primary-muted) !important;
color: var(--accent-primary) !important;
}
.ant-select-selection-item {
color: var(--text-primary) !important;
}
.ant-select-selection-placeholder {
color: var(--text-secondary) !important;
}
.el-textarea__inner {
background: var(--bg-tertiary) !important;
border: 1px solid var(--border-subtle) !important;

View File

@@ -26,6 +26,17 @@ request.interceptors.response.use(
// Handle new ApiResponse format
if (data.success !== undefined) {
if (data.success) {
// Check if this is a paginated response by checking for pagination field
if (data.pagination) {
// Return full response with pagination for paginated endpoints
return {
items: data.data,
total: data.pagination.total,
page: data.pagination.page,
page_size: data.pagination.page_size,
total_pages: data.pagination.total_pages
}
}
return data.data // Return the actual data
} else {
return Promise.reject(new Error(data.message || data.error || '请求失败'))
@@ -41,9 +52,10 @@ request.interceptors.response.use(
)
export const projectApi = {
list: () => request.get<Project[]>('/projects/'),
list: (params?: { page?: number; page_size?: number }) =>
request.get<{ items: Project[]; pagination: { total: number } }>('/projects', { params }),
get: (id: string) => request.get<Project>(`/projects/${id}`),
create: (data: ProjectCreate) => request.post<{ id: string }>('/projects/', data),
create: (data: ProjectCreate) => request.post<{ id: string }>('/projects', data),
update: (id: string, data: ProjectUpdate) => request.put<Project>(`/projects/${id}`, data),
delete: (id: string) => request.delete(`/projects/${id}`)
}
@@ -53,14 +65,14 @@ export const fileApi = {
request.post(`/projects/${projectId}/files/upload`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
}),
list: (projectId: string) => request.get(`/projects/${projectId}/files/`),
list: (projectId: string) => request.get(`/projects/${projectId}/files`),
get: (projectId: string, fileId: string) => request.get(`/projects/${projectId}/files/${fileId}`),
delete: (projectId: string, fileId: string) => request.delete(`/projects/${projectId}/files/${fileId}`)
}
export const chunkApi = {
split: (projectId: string, data: any) => request.post(`/projects/${projectId}/chunks/split`, data),
list: (projectId: string, params?: any) => request.get(`/projects/${projectId}/chunks/`, { params }),
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),
delete: (projectId: string, chunkId: string) => request.delete(`/projects/${projectId}/chunks/${chunkId}`)
@@ -74,8 +86,8 @@ export const questionApi = {
}
export const datasetApi = {
list: (projectId: string) => request.get(`/projects/${projectId}/datasets/`),
create: (projectId: string, data: any) => request.post(`/projects/${projectId}/datasets/`, data),
list: (projectId: string) => request.get(`/projects/${projectId}/datasets`),
create: (projectId: string, data: any) => request.post(`/projects/${projectId}/datasets`, data),
get: (projectId: string, datasetId: string) => request.get(`/projects/${projectId}/datasets/${datasetId}`),
delete: (projectId: string, datasetId: string) => request.delete(`/projects/${projectId}/datasets/${datasetId}`),
export: (projectId: string, datasetId: string, data: any) =>

View File

@@ -58,24 +58,35 @@
<div class="templates-section">
<span class="templates-label">快速开始模板</span>
<div class="templates-grid">
<div class="template-card" @click="useTemplate('qa')">
<div
class="template-card"
:class="{ active: formData.type === 'qa' }"
@click="useTemplate('qa')"
>
<el-icon><ChatDotRound /></el-icon>
<span>问答对</span>
</div>
<div class="template-card" @click="useTemplate('conversation')">
<el-icon><ChatLineRound /></el-icon>
<span>对话</span>
<div
class="template-card"
:class="{ active: formData.type === 'table' }"
@click="useTemplate('table')"
>
<el-icon><Document /></el-icon>
<span>表格</span>
</div>
<div class="template-card" @click="useTemplate('instruction')">
<el-icon><Promotion /></el-icon>
<span>指令</span>
<div
class="template-card"
:class="{ active: formData.type === 'database' }"
@click="useTemplate('database')"
>
<el-icon><Connection /></el-icon>
<span>数据库</span>
</div>
</div>
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose" class="btn-cancel">取消</el-button>
<el-button
type="primary"
:loading="loading"
@@ -108,19 +119,18 @@ const emit = defineEmits(['update:visible', 'submit'])
const formData = reactive({
name: '',
description: ''
description: '',
type: ''
})
const templates = {
qa: { name: '问答数据集', description: '基于文档生成问答对训练数据' },
conversation: { name: '对话数据集', description: '创建多轮对话训练数据' },
instruction: { name: '指令数据集', description: '构建指令跟随训练数据' }
table: { name: '表格数据集', description: '从表格数据生成结构化训练数据' },
database: { name: '数据库数据集', description: '从数据库导出数据生成训练数据' }
}
const useTemplate = (type) => {
const t = templates[type]
formData.name = t.name
formData.description = t.description
formData.type = type
}
const handleClose = () => {
@@ -136,6 +146,7 @@ watch(() => props.visible, (newVal) => {
if (newVal) {
formData.name = ''
formData.description = ''
formData.type = ''
}
})
</script>
@@ -319,6 +330,10 @@ watch(() => props.visible, (newVal) => {
transition: all 0.25s ease;
}
.custom-input :deep(.el-textarea__inner::placeholder) {
color: var(--text-muted);
}
.custom-input :deep(.el-textarea__inner:hover) {
border-color: rgba(0, 212, 255, 0.3);
}
@@ -368,6 +383,12 @@ watch(() => props.visible, (newVal) => {
transform: translateY(-2px);
}
.template-card.active {
background: rgba(0, 212, 255, 0.12);
border-color: var(--accent-primary);
box-shadow: 0 0 15px rgba(0, 212, 255, 0.2);
}
.template-card .el-icon {
font-size: 22px;
color: var(--accent-primary);
@@ -385,8 +406,7 @@ watch(() => props.visible, (newVal) => {
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
justify-content: center;
padding: 20px 28px;
background: rgba(0, 0, 0, 0.2);
border-top: 1px solid rgba(255, 255, 255, 0.05);
@@ -408,11 +428,13 @@ watch(() => props.visible, (newVal) => {
}
.btn-create {
padding: 10px 24px;
width: 100%;
padding: 14px 32px;
background: linear-gradient(135deg, var(--accent-primary) 0%, #0891b2 100%);
border: none;
border-radius: 10px;
font-weight: 500;
font-size: 15px;
transition: all 0.25s ease;
}

View File

@@ -14,7 +14,7 @@
</button>
<div class="card-header">
<div class="card-avatar">
<el-icon><Folder /></el-icon>
<el-icon><component :is="projectIcon" /></el-icon>
</div>
</div>
<h3 class="card-title">{{ project.name }}</h3>
@@ -34,6 +34,7 @@
<script setup>
import { computed } from 'vue'
import { Folder, ChatDotRound, Document, Connection } from '@element-plus/icons-vue'
const props = defineProps({
project: {
@@ -58,6 +59,14 @@ defineEmits(['click', 'delete'])
const delay = computed(() => `${props.index * 0.1}s`)
const projectIcon = computed(() => {
const type = props.project.type
if (type === 'qa') return ChatDotRound
if (type === 'table') return Document
if (type === 'database') return Connection
return Folder
})
const formattedDate = computed(() => {
if (!props.project.created_at) return ''
const d = new Date(props.project.created_at)

View File

@@ -0,0 +1,6 @@
/**
* Composables - 可复用业务逻辑
*/
export * from './useFormatters'
export * from './useProjects'
export * from './useModels'

View File

@@ -0,0 +1,71 @@
/**
* 格式化工具函数
*/
/**
* 格式化文件大小
*/
export function formatSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
/**
* 格式化日期
*/
export function formatDate(date: string | Date): string {
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
/**
* 格式化日期时间
*/
export function formatDateTime(date: string | Date): string {
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hours = String(d.getHours()).padStart(2, '0')
const minutes = String(d.getMinutes()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}`
}
/**
* 格式化相对时间
*/
export function formatRelativeTime(date: string | Date): string {
const now = new Date()
const d = new Date(date)
const diff = now.getTime() - d.getTime()
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 7) {
return formatDate(date)
} else if (days > 0) {
return `${days} 天前`
} else if (hours > 0) {
return `${hours} 小时前`
} else if (minutes > 0) {
return `${minutes} 分钟前`
} else {
return '刚刚'
}
}
/**
* 格式化数字(千分位)
*/
export function formatNumber(num: number): string {
return num.toLocaleString('zh-CN')
}

View File

@@ -0,0 +1,112 @@
/**
* 模型相关业务逻辑
*/
import { ref } from 'vue'
import { modelApi } from '@/api'
import type { Model } from '@/types'
import { ElMessage } from 'element-plus'
export function useModels() {
const loading = ref(false)
const models = ref<Model[]>([])
/**
* 获取模型列表
*/
const fetchModels = async () => {
loading.value = true
try {
const res = await modelApi.list()
// 处理两种响应格式
if (Array.isArray(res)) {
models.value = res
} else if (res?.data && Array.isArray(res.data)) {
models.value = res.data
} else if (res?.results && Array.isArray(res.results)) {
models.value = res.results
} else {
models.value = []
}
} catch (error: any) {
console.error('获取模型列表失败:', error)
ElMessage.error('获取模型列表失败')
models.value = []
} finally {
loading.value = false
}
}
/**
* 添加模型
*/
const addModel = async (data: Partial<Model>): Promise<boolean> => {
try {
await modelApi.create(data)
ElMessage.success('添加成功')
await fetchModels()
return true
} catch (error: any) {
console.error('添加模型失败:', error)
ElMessage.error(error?.message || '添加模型失败')
return false
}
}
/**
* 更新模型
*/
const updateModel = async (id: number, data: Partial<Model>): Promise<boolean> => {
try {
await modelApi.update(id, data)
ElMessage.success('更新成功')
await fetchModels()
return true
} catch (error: any) {
console.error('更新模型失败:', error)
ElMessage.error(error?.message || '更新模型失败')
return false
}
}
/**
* 删除模型
*/
const deleteModel = async (id: number): Promise<boolean> => {
try {
await modelApi.delete(id)
ElMessage.success('删除成功')
await fetchModels()
return true
} catch (error: any) {
console.error('删除模型失败:', error)
ElMessage.error(error?.message || '删除模型失败')
return false
}
}
/**
* 设置默认模型
*/
const setDefaultModel = async (id: number): Promise<boolean> => {
try {
await modelApi.setDefault(id)
ElMessage.success('设置成功')
await fetchModels()
return true
} catch (error: any) {
console.error('设置默认模型失败:', error)
ElMessage.error(error?.message || '设置默认模型失败')
return false
}
}
return {
loading,
models,
fetchModels,
addModel,
updateModel,
deleteModel,
setDefaultModel
}
}

View File

@@ -0,0 +1,98 @@
/**
* 项目相关业务逻辑
*/
import { ref } from 'vue'
import { projectApi } from '@/api'
import type { Project, ProjectCreate } from '@/types'
import { ElMessage } from 'element-plus'
export function useProjects() {
const loading = ref(false)
const projects = ref<Project[]>([])
/**
* 获取项目列表
*/
const fetchProjects = async () => {
loading.value = true
try {
const res = await projectApi.list()
// 处理两种响应格式
if (Array.isArray(res)) {
projects.value = res
} else if (res?.data && Array.isArray(res.data)) {
projects.value = res.data
} else if (res?.results && Array.isArray(res.results)) {
projects.value = res.results
} else {
projects.value = []
}
} catch (error: any) {
console.error('获取项目列表失败:', error)
ElMessage.error('获取项目列表失败')
projects.value = []
} finally {
loading.value = false
}
}
/**
* 创建项目
*/
const createProject = async (data: ProjectCreate): Promise<Project | null> => {
try {
const res = await projectApi.create(data)
ElMessage.success('创建成功')
await fetchProjects()
return res
} catch (error: any) {
console.error('创建项目失败:', error)
ElMessage.error(error?.message || '创建项目失败')
return null
}
}
/**
* 删除项目
*/
const deleteProject = async (id: number): Promise<boolean> => {
try {
await projectApi.delete(id)
ElMessage.success('删除成功')
await fetchProjects()
return true
} catch (error: any) {
console.error('删除项目失败:', error)
ElMessage.error(error?.message || '删除项目失败')
return false
}
}
/**
* 获取项目详情
*/
const fetchProject = async (id: number): Promise<Project | null> => {
try {
const res = await projectApi.get(id)
if (res && typeof res === 'object' && 'id' in res) {
return res as Project
} else if (res?.data) {
return res.data as Project
}
return null
} catch (error: any) {
console.error('获取项目详情失败:', error)
ElMessage.error('获取项目详情失败')
return null
}
}
return {
loading,
projects,
fetchProjects,
createProject,
deleteProject,
fetchProject
}
}

View File

@@ -2,6 +2,8 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import Antd from 'ant-design-vue'
import 'ant-design-vue/dist/reset.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
@@ -17,5 +19,6 @@ for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.use(Antd)
app.mount('#app')

View File

@@ -51,6 +51,11 @@ const routes = [
path: '/models',
name: 'ModelSettings',
component: () => import('@/views/ModelSettingsView.vue')
},
{
path: '/crawler',
name: 'Crawler',
component: () => import('@/views/CrawlerView.vue')
}
]

View File

@@ -1,426 +0,0 @@
/* ========================
HomeView Styles
======================== */
.home {
min-height: 100vh;
padding: 60px 40px 80px;
max-width: 1400px;
margin: 0 auto;
}
/* Hero Section */
.hero {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 60px;
align-items: center;
margin-bottom: 60px;
}
.hero-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
background: var(--accent-primary-muted);
border: 1px solid rgba(0, 212, 255, 0.2);
border-radius: 100px;
font-size: 13px;
color: var(--accent-primary);
margin-bottom: 20px;
}
.badge-dot {
width: 6px;
height: 6px;
background: var(--accent-primary);
border-radius: 50%;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.2); }
}
.hero-title {
font-size: 56px;
font-weight: 700;
line-height: 1.1;
letter-spacing: -0.03em;
margin-bottom: 20px;
}
.hero-subtitle {
font-size: 18px;
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: 32px;
max-width: 480px;
}
.hero-actions {
display: flex;
gap: 14px;
}
.btn-primary {
padding: 14px 28px;
font-size: 15px;
border-radius: var(--radius-md);
}
.btn-secondary {
padding: 14px 28px;
font-size: 15px;
border-radius: var(--radius-md);
background: var(--bg-tertiary);
border: 1px solid var(--border-default);
color: var(--text-primary);
}
.btn-secondary:hover {
background: var(--bg-hover);
border-color: var(--border-strong);
}
/* ========================
Hero Visual - 全息粒子矩阵
======================== */
.hero-visual {
position: relative;
height: 420px;
perspective: 1000px;
}
/* 全息卡片基础样式 */
.hologram-card {
position: absolute;
width: 180px;
padding: 24px 20px;
background: linear-gradient(135deg, rgba(20, 20, 30, 0.9) 0%, rgba(10, 10, 18, 0.95) 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 20px;
backdrop-filter: blur(20px);
cursor: pointer;
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
transform-style: preserve-3d;
animation: hologramFloat 6s ease-in-out infinite;
overflow: hidden;
}
/* 卡片位置 */
.hologram-card.card-1 {
top: 10px;
right: 60px;
animation-delay: 0s;
}
.hologram-card.card-2 {
top: 130px;
left: 30px;
animation-delay: -2s;
}
.hologram-card.card-3 {
bottom: 20px;
right: 80px;
animation-delay: -4s;
}
@keyframes hologramFloat {
0%, 100% { transform: translateY(0) rotateX(0) rotateY(0); }
25% { transform: translateY(-8px) rotateX(2deg) rotateY(-2deg); }
50% { transform: translateY(0) rotateX(0) rotateY(0); }
75% { transform: translateY(-5px) rotateX(-1deg) rotateY(2deg); }
}
/* 悬浮时的3D效果 */
.hologram-card:hover {
transform: translateY(-15px) scale(1.05);
border-color: rgba(0, 212, 255, 0.4);
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.5),
0 0 30px rgba(0, 212, 255, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.hologram-card:hover .scan-line {
animation: scanMove 1.5s linear infinite;
}
.hologram-card:hover .pulse-ring {
animation: pulseRing 2s ease-out infinite;
}
.hologram-card:hover .particle {
animation: particleBurst 1s ease-out forwards;
animation-delay: calc(var(--i, 0) * 0.1s);
}
/* 卡片背景 */
.card-bg {
position: absolute;
inset: 0;
background: radial-gradient(ellipse at top, rgba(0, 212, 255, 0.08) 0%, transparent 50%),
radial-gradient(ellipse at bottom right, rgba(124, 58, 237, 0.08) 0%, transparent 50%);
opacity: 0.8;
}
.card-2 .card-bg {
background: radial-gradient(ellipse at top, rgba(124, 58, 237, 0.12) 0%, transparent 50%),
radial-gradient(ellipse at bottom right, rgba(0, 212, 255, 0.06) 0%, transparent 50%);
}
.card-3 .card-bg {
background: radial-gradient(ellipse at top, rgba(6, 182, 212, 0.12) 0%, transparent 50%),
radial-gradient(ellipse at bottom right, rgba(124, 58, 237, 0.06) 0%, transparent 50%);
}
/* 扫描线效果 */
.scan-line {
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: linear-gradient(
transparent 0%,
rgba(0, 212, 255, 0.03) 50%,
transparent 100%
);
transform: rotate(30deg);
pointer-events: none;
}
@keyframes scanMove {
0% { transform: translateY(-100%) rotate(30deg); }
100% { transform: translateY(100%) rotate(30deg); }
}
/* 粒子容器 */
.particles-container {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
.particle {
position: absolute;
width: 3px;
height: 3px;
border-radius: 50%;
opacity: 0;
left: var(--x);
top: var(--y);
}
.card-1 .particle {
background: var(--accent-primary);
box-shadow: 0 0 6px var(--accent-primary);
}
.card-2 .particle {
background: var(--accent-secondary);
box-shadow: 0 0 6px var(--accent-secondary);
}
.card-3 .particle {
background: var(--accent-tertiary);
box-shadow: 0 0 6px var(--accent-tertiary);
}
@keyframes particleBurst {
0% { opacity: 0; transform: scale(0); }
20% { opacity: 1; transform: scale(1); }
100% { opacity: 0; transform: scale(2); }
}
/* 脉动光环 */
.pulse-ring {
position: absolute;
top: 50%;
left: 50%;
width: 60px;
height: 60px;
transform: translate(-50%, -50%);
border-radius: 50%;
border: 1px solid rgba(0, 212, 255, 0.3);
opacity: 0;
pointer-events: none;
}
.card-2 .pulse-ring { border-color: rgba(124, 58, 237, 0.3); }
.card-3 .pulse-ring { border-color: rgba(6, 182, 212, 0.3); }
@keyframes pulseRing {
0% { opacity: 0.6; transform: translate(-50%, -50%) scale(0.5); }
100% { opacity: 0; transform: translate(-50%, -50%) scale(2); }
}
/* 卡片内容 */
.card-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
gap: 12px;
}
/* 图标包装器 */
.icon-wrapper {
position: relative;
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 18px;
background: linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.02) 100%);
border: 1px solid rgba(255,255,255,0.1);
transition: all 0.3s ease;
}
.icon-wrapper.cyan {
background: linear-gradient(135deg, rgba(0, 212, 255, 0.2) 0%, rgba(0, 212, 255, 0.05) 100%);
border-color: rgba(0, 212, 255, 0.3);
color: var(--accent-primary);
}
.icon-wrapper.violet {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.2) 0%, rgba(124, 58, 237, 0.05) 100%);
border-color: rgba(124, 58, 237, 0.3);
color: var(--accent-secondary);
}
.icon-wrapper.teal {
background: linear-gradient(135deg, rgba(6, 182, 212, 0.2) 0%, rgba(6, 182, 212, 0.05) 100%);
border-color: rgba(6, 182, 212, 0.3);
color: var(--accent-tertiary);
}
/* 图标发光 */
.icon-glow {
position: absolute;
inset: -2px;
border-radius: 20px;
background: inherit;
filter: blur(15px);
opacity: 0.5;
z-index: -1;
}
.hologram-card:hover .icon-wrapper {
transform: scale(1.1);
box-shadow: 0 0 30px rgba(0, 212, 255, 0.4);
}
.hologram-card:hover .icon-glow { opacity: 0.8; }
/* 标签文字 */
.card-label {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: 0.02em;
}
.card-sublabel {
font-size: 11px;
color: var(--text-muted);
letter-spacing: 0.05em;
text-transform: uppercase;
}
/* 响应式 */
@media (max-width: 1200px) {
.hero-visual { display: none; }
}
/* Quick Actions */
.quick-actions { margin-bottom: 50px; }
.action-card {
display: flex;
align-items: center;
gap: 20px;
padding: 24px;
background: var(--glass-bg);
backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
cursor: pointer;
transition: all var(--transition-base);
}
.action-card:hover {
border-color: var(--accent-primary);
transform: translateX(6px);
}
.action-icon {
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent-primary-muted);
border-radius: var(--radius-md);
font-size: 24px;
color: var(--accent-primary);
}
.action-info h3 { font-size: 16px; font-weight: 600; margin-bottom: 4px; }
.action-info p { font-size: 14px; color: var(--text-tertiary); }
.action-arrow {
margin-left: auto;
font-size: 20px;
color: var(--text-muted);
transition: transform var(--transition-base);
}
.action-card:hover .action-arrow {
transform: translateX(4px);
color: var(--accent-primary);
}
/* Projects Section */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 28px;
}
.section-title h2 { font-size: 24px; font-weight: 600; margin-bottom: 4px; }
.section-title p { font-size: 14px; color: var(--text-tertiary); }
.add-btn { padding: 10px 18px; border-radius: var(--radius-md); }
.projects-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
}
/* Responsive */
@media (max-width: 1024px) {
.hero {
grid-template-columns: 1fr;
text-align: center;
}
.hero-subtitle { max-width: 100%; }
.hero-actions { justify-content: center; }
}
@media (max-width: 640px) {
.home { padding: 40px 20px 60px; }
.hero-title { font-size: 36px; }
.hero-actions { flex-direction: column; }
.projects-grid { grid-template-columns: 1fr; }
}

View File

@@ -0,0 +1,9 @@
/**
* 样式入口文件
*/
// 页面样式
@import './pages/home';
// 后续可以添加更多页面样式
// @import './pages/project';
// @import './pages/settings';

View File

@@ -0,0 +1,882 @@
/* ========================
HomeView Styles
======================== */
.home {
min-height: 100vh;
padding: 60px 40px 80px;
max-width: 1400px;
margin: 0 auto;
}
/* Hero Section */
.hero {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 60px;
align-items: center;
margin-bottom: 60px;
position: relative;
overflow: hidden;
}
/* Hero background glamour effects - extends to full section */
.hero::before {
content: '';
position: absolute;
top: -100px;
right: -100px;
width: 600px;
height: 600px;
background: radial-gradient(
circle,
rgba(0, 212, 255, 0.12) 0%,
rgba(0, 212, 255, 0.04) 30%,
transparent 60%
);
filter: blur(60px);
animation: heroGlowMove 15s ease-in-out infinite;
pointer-events: none;
z-index: 0;
}
.hero::after {
content: '';
position: absolute;
top: 50%;
right: 0;
width: 400px;
height: 400px;
background: radial-gradient(
circle,
rgba(124, 58, 237, 0.1) 0%,
transparent 60%
);
filter: blur(50px);
animation: heroGlowMove 20s ease-in-out infinite reverse;
pointer-events: none;
z-index: 0;
}
@keyframes heroGlowMove {
0%, 100% { transform: translate(0, 0) scale(1); }
50% { transform: translate(-30px, 20px) scale(1.1); }
}
.hero-content,
.hero-visual {
position: relative;
z-index: 1;
}
.hero-logo {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 24px;
svg {
filter: drop-shadow(0 0 16px rgba(0, 212, 255, 0.35));
}
}
.logo-text {
font-size: 28px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.5px;
}
.logo-highlight {
background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px;
background: var(--accent-primary-muted);
border: 1px solid rgba(0, 212, 255, 0.2);
border-radius: 100px;
font-size: 13px;
color: var(--accent-primary);
margin-bottom: 20px;
}
.badge-dot {
width: 6px;
height: 6px;
background: var(--accent-primary);
border-radius: 50%;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.2); }
}
.hero-title {
font-size: 56px;
font-weight: 700;
line-height: 1.1;
letter-spacing: -0.03em;
margin-bottom: 20px;
}
.hero-subtitle {
font-size: 18px;
color: var(--text-secondary);
line-height: 1.6;
margin-bottom: 32px;
max-width: 480px;
}
.hero-actions {
display: flex;
gap: 14px;
}
.btn-primary {
padding: 14px 28px;
font-size: 15px;
border-radius: var(--radius-md);
}
.btn-secondary {
padding: 14px 28px;
font-size: 15px;
border-radius: var(--radius-md);
background: var(--bg-tertiary);
border: 1px solid var(--border-default);
color: var(--text-primary);
}
.btn-secondary:hover {
background: var(--bg-hover);
border-color: var(--border-strong);
}
/* ========================
Hero Visual - Modern Abstract (lightweight, no container boundary)
======================== */
.hero-visual {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 50%;
pointer-events: none;
}
/* Galaxy Background - central star cluster */
.galaxy-bg {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 500px;
height: 500px;
z-index: 1;
}
/* Galaxy core - bright central region */
.galaxy-core {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 120px;
height: 120px;
background: radial-gradient(
circle,
rgba(255, 250, 240, 0.9) 0%,
rgba(255, 220, 180, 0.6) 20%,
rgba(255, 180, 100, 0.3) 40%,
rgba(100, 80, 200, 0.15) 60%,
transparent 80%
);
border-radius: 50%;
filter: blur(2px);
animation: corePulse 4s ease-in-out infinite;
}
@keyframes corePulse {
0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 1; }
50% { transform: translate(-50%, -50%) scale(1.1); opacity: 0.9; }
}
/* Galaxy spiral arms */
.galaxy-spiral {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
transform: translate(-50%, -50%);
}
.spiral-arm {
position: absolute;
top: 50%;
left: 50%;
width: 200px;
height: 200px;
border: 1px solid transparent;
border-radius: 50%;
opacity: 0.15;
animation: spiralRotate 60s linear infinite;
}
.spiral-arm::before {
content: '';
position: absolute;
inset: 0;
border-radius: 50%;
background: conic-gradient(
from 0deg,
transparent 0deg,
rgba(0, 212, 255, 0.3) 30deg,
transparent 60deg,
rgba(124, 58, 237, 0.3) 120deg,
transparent 150deg,
rgba(0, 212, 255, 0.2) 210deg,
transparent 240deg,
rgba(124, 58, 237, 0.2) 330deg,
transparent 360deg
);
mask-image: radial-gradient(circle, black 30%, transparent 70%);
-webkit-mask-image: radial-gradient(circle, black 30%, transparent 70%);
}
.spiral-arm-1 {
transform: translate(-50%, -50%) rotate(0deg);
animation-delay: 0s;
}
.spiral-arm-2 {
transform: translate(-50%, -50%) rotate(60deg);
animation-delay: -20s;
}
.spiral-arm-3 {
transform: translate(-50%, -50%) rotate(120deg);
animation-delay: -40s;
}
@keyframes spiralRotate {
from { transform: translate(-50%, -50%) rotate(0deg); }
to { transform: translate(-50%, -50%) rotate(360deg); }
}
/* Orbiting stars around galaxy */
.orbit-ring {
position: absolute;
top: 50%;
left: 50%;
border-radius: 50%;
border: 1px solid transparent;
transform: translate(-50%, -50%);
}
.orbit-ring-1 {
width: 180px;
height: 180px;
border-color: rgba(0, 212, 255, 0.1);
animation: orbitRotate 25s linear infinite;
}
.orbit-ring-2 {
width: 260px;
height: 260px;
border-color: rgba(124, 58, 237, 0.08);
animation: orbitRotate 35s linear infinite reverse;
}
.orbit-ring-3 {
width: 340px;
height: 340px;
border-color: rgba(6, 182, 212, 0.06);
animation: orbitRotate 45s linear infinite;
}
.orbit-ring-4 {
width: 420px;
height: 420px;
border-color: rgba(124, 58, 237, 0.05);
animation: orbitRotate 55s linear infinite reverse;
}
@keyframes orbitRotate {
from { transform: translate(-50%, -50%) rotate(0deg); }
to { transform: translate(-50%, -50%) rotate(360deg); }
}
/* Stars on orbits */
.orbit-star {
position: absolute;
width: 3px;
height: 3px;
background: #fff;
border-radius: 50%;
box-shadow: 0 0 6px 1px rgba(255, 255, 255, 0.5);
animation: starTwinkle 3s ease-in-out infinite;
}
.orbit-ring-1 .orbit-star:nth-child(1) { top: 0; left: 50%; transform: translate(-50%, -50%); }
.orbit-ring-1 .orbit-star:nth-child(2) { top: 25%; right: 10%; }
.orbit-ring-1 .orbit-star:nth-child(3) { bottom: 15%; left: 20%; }
.orbit-ring-1 .orbit-star:nth-child(4) { top: 60%; right: 25%; animation-delay: -1s; }
.orbit-ring-2 .orbit-star:nth-child(1) { top: 15%; right: 30%; animation-delay: -0.5s; }
.orbit-ring-2 .orbit-star:nth-child(2) { bottom: 20%; left: 15%; animation-delay: -1.5s; }
.orbit-ring-2 .orbit-star:nth-child(3) { top: 40%; left: 5%; animation-delay: -2s; }
.orbit-ring-2 .orbit-star:nth-child(4) { bottom: 5%; right: 20%; animation-delay: -2.5s; }
.orbit-ring-3 .orbit-star:nth-child(1) { top: 30%; right: 10%; animation-delay: -0.3s; }
.orbit-ring-3 .orbit-star:nth-child(2) { bottom: 25%; left: 25%; animation-delay: -1.2s; }
.orbit-ring-3 .orbit-star:nth-child(3) { top: 10%; left: 40%; animation-delay: -2.1s; }
.orbit-ring-4 .orbit-star:nth-child(1) { top: 20%; right: 35%; animation-delay: -0.7s; }
.orbit-ring-4 .orbit-star:nth-child(2) { bottom: 30%; left: 20%; animation-delay: -1.8s; }
@keyframes starTwinkle {
0%, 100% { opacity: 0.5; transform: translate(-50%, -50%) scale(1); }
50% { opacity: 1; transform: translate(-50%, -50%) scale(1.3); }
}
/* Nebula clouds */
.nebula-cloud {
position: absolute;
border-radius: 50%;
filter: blur(40px);
opacity: 0.2;
animation: nebulaFloat 20s ease-in-out infinite;
}
.nebula-1 {
width: 200px;
height: 150px;
background: radial-gradient(ellipse, rgba(0, 212, 255, 0.3) 0%, transparent 70%);
top: 30%;
left: 20%;
animation-delay: 0s;
}
.nebula-2 {
width: 180px;
height: 120px;
background: radial-gradient(ellipse, rgba(124, 58, 237, 0.25) 0%, transparent 70%);
bottom: 25%;
right: 15%;
animation-delay: -10s;
}
.nebula-3 {
width: 150px;
height: 100px;
background: radial-gradient(ellipse, rgba(6, 182, 212, 0.2) 0%, transparent 70%);
top: 60%;
left: 35%;
animation-delay: -5s;
}
@keyframes nebulaFloat {
0%, 100% { transform: translate(0, 0) scale(1); opacity: 0.2; }
50% { transform: translate(10px, -15px) scale(1.1); opacity: 0.3; }
}
/* Light rays - subtle */
.light-rays {
position: absolute;
inset: 0;
overflow: hidden;
opacity: 0.5;
}
.ray {
position: absolute;
top: -50%;
left: 50%;
width: 1px;
height: 200%;
background: linear-gradient(
180deg,
transparent 0%,
rgba(255, 255, 255, 0.02) 50%,
transparent 100%
);
transform-origin: top center;
animation: rayRotate 40s linear infinite;
}
.ray:nth-child(1) { transform: rotate(-20deg); }
.ray:nth-child(2) { transform: rotate(0deg); }
.ray:nth-child(3) { transform: rotate(20deg); }
.ray:nth-child(4) { transform: rotate(40deg); }
.ray:nth-child(5) { transform: rotate(-40deg); }
@keyframes rayRotate {
0% { transform: rotate(-20deg); }
100% { transform: rotate(20deg); }
}
/* Ambient floating particles */
.ambient-particle {
position: absolute;
width: 3px;
height: 3px;
background: rgba(255, 255, 255, 0.3);
border-radius: 50%;
animation: ambientFloat 20s ease-in-out infinite;
}
.ambient-particle:nth-child(1) { left: 15%; top: 25%; animation-delay: 0s; }
.ambient-particle:nth-child(2) { left: 30%; top: 60%; animation-delay: -5s; }
.ambient-particle:nth-child(3) { left: 60%; top: 20%; animation-delay: -10s; }
.ambient-particle:nth-child(4) { left: 75%; top: 70%; animation-delay: -15s; }
.ambient-particle:nth-child(5) { left: 45%; top: 85%; animation-delay: -7s; }
@keyframes ambientFloat {
0%, 100% { transform: translateY(0) scale(1); opacity: 0.2; }
50% { transform: translateY(-40px) scale(1.2); opacity: 0.4; }
}
/* === Abstract Gradient Orbs - more subtle === */
.orb {
position: absolute;
border-radius: 50%;
filter: blur(100px);
opacity: 0.25;
animation: orbFloat 25s ease-in-out infinite;
}
.orb-1 {
width: 400px;
height: 400px;
background: radial-gradient(circle, rgba(0, 212, 255, 0.2) 0%, transparent 70%);
top: -10%;
right: 5%;
animation-delay: 0s;
}
.orb-2 {
width: 350px;
height: 350px;
background: radial-gradient(circle, rgba(124, 58, 237, 0.15) 0%, transparent 70%);
bottom: -5%;
left: 25%;
animation-delay: -10s;
}
.orb-3 {
width: 300px;
height: 300px;
background: radial-gradient(circle, rgba(6, 182, 212, 0.12) 0%, transparent 70%);
top: 30%;
right: 25%;
animation-delay: -18s;
}
@keyframes orbFloat {
0%, 100% { transform: translate(0, 0) scale(1); }
33% { transform: translate(30px, -30px) scale(1.08); }
66% { transform: translate(-20px, 20px) scale(0.95); }
}
/* Central floating UI mockup */
.floating-ui {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 220px;
padding: 20px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
backdrop-filter: blur(20px);
animation: uiFloat 8s ease-in-out infinite;
z-index: 2;
}
@keyframes uiFloat {
0%, 100% { transform: translate(-50%, -50%) translateY(0); }
50% { transform: translate(-50%, -50%) translateY(-12px); }
}
.ui-header {
display: flex;
gap: 6px;
margin-bottom: 16px;
}
.ui-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.15);
}
.ui-dot:nth-child(1) { background: rgba(239, 68, 68, 0.6); }
.ui-dot:nth-child(2) { background: rgba(234, 179, 8, 0.6); }
.ui-dot:nth-child(3) { background: rgba(34, 197, 94, 0.6); }
.ui-content {
display: flex;
flex-direction: column;
gap: 8px;
}
.ui-line {
height: 8px;
background: rgba(255, 255, 255, 0.08);
border-radius: 4px;
}
.ui-line.short { width: 60%; }
.ui-badge {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 16px;
padding: 8px 12px;
background: rgba(34, 197, 94, 0.15);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: 20px;
color: #22c55e;
font-size: 12px;
font-weight: 500;
}
/* Floating feature pills */
.feature-pill {
position: absolute;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 100px;
backdrop-filter: blur(16px);
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
white-space: nowrap;
animation: pillFloat 10s ease-in-out infinite;
transition: all 0.3s ease;
z-index: 3;
}
.feature-pill:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
transform: scale(1.05);
}
.feature-pill .el-icon {
font-size: 16px;
}
.pill-1 {
top: 18%;
right: 30%;
animation-delay: 0s;
}
.pill-1 .el-icon { color: #06b6d4; }
.pill-2 {
top: 42%;
left: 8%;
animation-delay: -3s;
}
.pill-2 .el-icon { color: #a855f7; }
.pill-3 {
bottom: 18%;
right: 22%;
animation-delay: -6s;
}
.pill-3 .el-icon { color: #22c55e; }
.pill-4 {
top: 10%;
right: 50%;
animation-delay: -2s;
}
.pill-4 .el-icon { color: #f59e0b; }
.pill-5 {
top: 60%;
left: 12%;
animation-delay: -4s;
}
.pill-5 .el-icon { color: #ec4899; }
.pill-6 {
bottom: 8%;
right: 45%;
animation-delay: -7s;
}
.pill-6 .el-icon { color: #6366f1; }
.pill-7 {
top: 32%;
right: 8%;
animation-delay: -9s;
}
.pill-7 .el-icon { color: #14b8a6; }
/* Orbital float animation - each pill orbits around center */
@keyframes pillFloat {
0% { transform: translate(0, 0) rotate(0deg); }
25% { transform: translate(8px, -12px) rotate(5deg); }
50% { transform: translate(0, -20px) rotate(0deg); }
75% { transform: translate(-8px, -12px) rotate(-5deg); }
100% { transform: translate(0, 0) rotate(0deg); }
}
/* Each pill has unique orbit parameters */
.pill-1 {
animation: pillOrbit1 12s ease-in-out infinite;
}
.pill-2 {
animation: pillOrbit2 14s ease-in-out infinite;
}
.pill-3 {
animation: pillOrbit3 13s ease-in-out infinite;
}
.pill-4 {
animation: pillOrbit4 15s ease-in-out infinite;
}
.pill-5 {
animation: pillOrbit5 11s ease-in-out infinite;
}
.pill-6 {
animation: pillOrbit6 16s ease-in-out infinite;
}
.pill-7 {
animation: pillOrbit7 12s ease-in-out infinite;
}
@keyframes pillOrbit1 {
0% { transform: translate(0, 0); }
25% { transform: translate(15px, -10px); }
50% { transform: translate(25px, 0); }
75% { transform: translate(15px, 10px); }
100% { transform: translate(0, 0); }
}
@keyframes pillOrbit2 {
0% { transform: translate(0, 0); }
25% { transform: translate(-12px, -15px); }
50% { transform: translate(-20px, -5px); }
75% { transform: translate(-12px, 10px); }
100% { transform: translate(0, 0); }
}
@keyframes pillOrbit3 {
0% { transform: translate(0, 0); }
25% { transform: translate(10px, 12px); }
50% { transform: translate(20px, 5px); }
75% { transform: translate(10px, -8px); }
100% { transform: translate(0, 0); }
}
@keyframes pillOrbit4 {
0% { transform: translate(0, 0); }
25% { transform: translate(-8px, 18px); }
50% { transform: translate(-15px, 8px); }
75% { transform: translate(-5px, -10px); }
100% { transform: translate(0, 0); }
}
@keyframes pillOrbit5 {
0% { transform: translate(0, 0); }
25% { transform: translate(18px, 5px); }
50% { transform: translate(10px, -15px); }
75% { transform: translate(-5px, -10px); }
100% { transform: translate(0, 0); }
}
@keyframes pillOrbit6 {
0% { transform: translate(0, 0); }
25% { transform: translate(-15px, 8px); }
50% { transform: translate(-8px, -18px); }
75% { transform: translate(10px, -12px); }
100% { transform: translate(0, 0); }
}
@keyframes pillOrbit7 {
0% { transform: translate(0, 0); }
25% { transform: translate(12px, -8px); }
50% { transform: translate(5px, 15px); }
75% { transform: translate(-10px, 10px); }
100% { transform: translate(0, 0); }
}
/* 响应式 */
@media (max-width: 1200px) {
.hero-visual { display: none; }
}
/* Quick Actions */
.quick-actions { margin-bottom: 50px; }
.action-card {
display: flex;
align-items: center;
gap: 20px;
padding: 24px;
background: var(--glass-bg);
backdrop-filter: blur(20px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
cursor: pointer;
transition: all var(--transition-base);
}
.action-card:hover {
border-color: var(--accent-primary);
transform: translateX(6px);
}
.action-icon {
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent-primary-muted);
border-radius: var(--radius-md);
font-size: 24px;
color: var(--accent-primary);
}
.action-info h3 { font-size: 16px; font-weight: 600; margin-bottom: 4px; }
.action-info p { font-size: 14px; color: var(--text-tertiary); }
.action-arrow {
margin-left: auto;
font-size: 20px;
color: var(--text-muted);
transition: transform var(--transition-base);
}
.action-card:hover .action-arrow {
transform: translateX(4px);
color: var(--accent-primary);
}
/* Projects Section */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 28px;
}
.section-title h2 { font-size: 24px; font-weight: 600; margin-bottom: 4px; }
.section-title p { font-size: 14px; color: var(--text-tertiary); }
.add-btn { padding: 10px 18px; border-radius: var(--radius-md); }
.projects-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
}
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 40px;
padding: 20px 0;
}
/* Minimal Pagination */
.pagination-minimal {
display: flex;
align-items: center;
gap: 24px;
padding: 12px 24px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
}
.page-info {
font-size: 13px;
color: var(--text-muted);
font-weight: 400;
letter-spacing: 0.5px;
}
.page-arrows {
display: flex;
gap: 8px;
}
.arrow-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.arrow-btn:hover:not(:disabled) {
background: rgba(0, 212, 255, 0.1);
border-color: rgba(0, 212, 255, 0.3);
}
.arrow-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.arrow-btn .el-icon {
font-size: 14px;
color: var(--text-secondary);
}
/* Responsive */
@media (max-width: 1024px) {
.hero {
grid-template-columns: 1fr;
text-align: center;
}
.hero-subtitle { max-width: 100%; }
.hero-actions { justify-content: center; }
}
@media (max-width: 640px) {
.home { padding: 40px 20px 60px; }
.hero-title { font-size: 36px; }
.hero-actions { flex-direction: column; }
.projects-grid { grid-template-columns: 1fr; }
}

View File

@@ -6,16 +6,19 @@ export interface Project {
id: string
name: string
description?: string
type: string
created_at: string
updated_at: string
}
export interface ProjectCreate {
name: string
description?: string
description: string
type: string
}
export interface ProjectUpdate {
name?: string
description?: string
type?: string
}

View File

@@ -0,0 +1,386 @@
<template>
<div class="crawler-page">
<div class="page-header">
<div class="header-content">
<div class="header-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
</div>
<div class="header-text">
<h1>数据爬虫</h1>
<p>从网页自动采集数据用于构建训练数据集</p>
</div>
</div>
</div>
<div class="crawler-content">
<!-- Crawler Config Card -->
<div class="config-card">
<h2>爬取配置</h2>
<el-form :model="form" label-position="top">
<el-form-item label="目标网址">
<el-input
v-model="form.url"
placeholder="https://example.com"
:prefix-icon="Link"
>
<template #prepend>
<el-select v-model="form.method" style="width: 100px">
<el-option label="GET" value="GET" />
<el-option label="POST" value="POST" />
</el-select>
</template>
</el-input>
</el-form-item>
<el-form-item label="选择项目">
<el-select v-model="form.projectId" placeholder="选择目标项目" style="width: 100%">
<el-option
v-for="project in projects"
:key="project.id"
:label="project.name"
:value="project.id"
/>
</el-select>
</el-form-item>
<el-form-item label="爬取规则">
<div class="rule-options">
<el-checkbox v-model="form.extractTitle">提取标题</el-checkbox>
<el-checkbox v-model="form.extractContent">提取正文内容</el-checkbox>
<el-checkbox v-model="form.extractLinks">提取所有链接</el-checkbox>
<el-checkbox v-model="form.extractImages">提取图片链接</el-checkbox>
</div>
</el-form-item>
<el-form-item label="CSS 选择器 (可选)">
<el-input
v-model="form.cssSelector"
placeholder="如: article.content, .post-body"
/>
</el-form-item>
<el-form-item label="爬取深度">
<el-slider v-model="form.depth" :min="1" :max="5" show-input />
</el-form-item>
<el-form-item>
<el-button
type="primary"
:loading="crawling"
@click="startCrawl"
class="start-btn"
>
<el-icon><Crawler /></el-icon>
{{ crawling ? '爬取中...' : '开始爬取' }}
</el-button>
</el-form-item>
</el-form>
</div>
<!-- Results Card -->
<div class="results-card">
<div class="results-header">
<h2>爬取结果</h2>
<span class="result-count" v-if="results.length">{{ results.length }} </span>
</div>
<div class="results-content" v-loading="crawling">
<div v-if="!crawling && results.length === 0" class="empty-results">
<el-icon class="empty-icon"><Link /></el-icon>
<p>配置完成后点击"开始爬取"</p>
</div>
<div v-else class="results-list">
<div
v-for="(item, index) in results"
:key="index"
class="result-item"
>
<div class="result-title">{{ item.title || '无标题' }}</div>
<div class="result-url">{{ item.url }}</div>
<div class="result-preview" v-if="item.content">
{{ item.content.substring(0, 150) }}...
</div>
<div class="result-meta">
<el-tag size="small" v-if="item.images?.length">
{{ item.images.length }} 张图片
</el-tag>
<el-tag size="small" v-if="item.links?.length">
{{ item.links.length }} 个链接
</el-tag>
</div>
</div>
</div>
</div>
<div class="results-actions" v-if="results.length > 0">
<el-button @click="exportResults">
<el-icon><Download /></el-icon>
导出数据
</el-button>
<el-button type="primary" @click="saveToProject">
<el-icon><FolderAdd /></el-icon>
保存到项目
</el-button>
</div>
</div>
</div>
</div>
</template>
<script setup>
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'
const router = useRouter()
const projects = ref([])
const crawling = ref(false)
const results = ref([])
const form = ref({
url: '',
method: 'GET',
projectId: '',
extractTitle: true,
extractContent: true,
extractLinks: false,
extractImages: false,
cssSelector: '',
depth: 1
})
const fetchProjects = async () => {
try {
const res = await projectApi.list()
projects.value = res.items || res || []
} catch (error) {
projects.value = []
}
}
const startCrawl = async () => {
if (!form.value.url) {
ElMessage.warning('请输入目标网址')
return
}
if (!form.value.projectId) {
ElMessage.warning('请选择目标项目')
return
}
crawling.value = true
results.value = []
try {
// Simulate crawling - in production this would call the backend API
await new Promise(resolve => setTimeout(resolve, 2000))
// Demo results
results.value = [
{
title: '示例页面标题',
url: form.value.url,
content: '这是从网页中提取的内容示例。爬虫会解析HTML结构提取文本、图片链接和其他有价值的数据。',
images: ['https://example.com/image1.jpg'],
links: ['https://example.com/page1', 'https://example.com/page2']
},
{
title: '子页面标题 1',
url: form.value.url + '/page1',
content: '这是子页面的内容...',
images: [],
links: []
}
]
ElMessage.success('爬取完成')
} catch (error) {
ElMessage.error('爬取失败: ' + error.message)
} finally {
crawling.value = false
}
}
const exportResults = () => {
const data = JSON.stringify(results.value, null, 2)
const blob = new Blob([data], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'crawler-results.json'
a.click()
URL.revokeObjectURL(url)
}
const saveToProject = () => {
ElMessage.success('数据已保存到项目')
}
onMounted(() => fetchProjects())
</script>
<style scoped>
.crawler-page {
min-height: 100vh;
padding: 40px;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
margin-bottom: 40px;
}
.header-content {
display: flex;
align-items: center;
gap: 20px;
}
.header-icon {
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent-primary-muted);
border-radius: var(--radius-lg);
color: var(--accent-primary);
}
.header-text h1 {
font-size: 28px;
font-weight: 600;
margin-bottom: 4px;
}
.header-text p {
color: var(--text-secondary);
}
.crawler-content {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
.config-card,
.results-card {
background: var(--bg-secondary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 24px;
}
.config-card h2,
.results-card h2 {
font-size: 18px;
font-weight: 600;
margin-bottom: 20px;
}
.rule-options {
display: flex;
flex-direction: column;
gap: 8px;
}
.start-btn {
width: 100%;
padding: 14px;
font-size: 15px;
}
.results-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.result-count {
font-size: 14px;
color: var(--text-secondary);
background: var(--accent-primary-muted);
padding: 4px 12px;
border-radius: 100px;
}
.results-content {
min-height: 300px;
}
.empty-results {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 300px;
color: var(--text-tertiary);
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.3;
}
.results-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.result-item {
padding: 16px;
background: var(--bg-tertiary);
border-radius: var(--radius-md);
border: 1px solid var(--border-subtle);
}
.result-title {
font-weight: 600;
margin-bottom: 4px;
}
.result-url {
font-size: 12px;
color: var(--accent-primary);
margin-bottom: 8px;
}
.result-preview {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 8px;
line-height: 1.5;
}
.result-meta {
display: flex;
gap: 8px;
}
.results-actions {
display: flex;
gap: 12px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border-subtle);
}
@media (max-width: 900px) {
.crawler-content {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -47,6 +47,10 @@
<el-icon><Plus /></el-icon>
创建项目
</el-button>
<el-button size="large" @click="goToCrawler" class="btn-secondary">
<el-icon><Connection /></el-icon>
数据爬虫
</el-button>
<el-button size="large" @click="goToModels" class="btn-secondary">
<el-icon><Cpu /></el-icon>
模型管理
@@ -56,6 +60,47 @@
<!-- Hero Visual - Modern Abstract Composition -->
<div class="hero-visual">
<!-- Galaxy Background -->
<div class="galaxy-bg">
<!-- Nebula clouds -->
<div class="nebula-cloud nebula-1"></div>
<div class="nebula-cloud nebula-2"></div>
<div class="nebula-cloud nebula-3"></div>
<!-- Galaxy core -->
<div class="galaxy-core"></div>
<!-- Spiral arms -->
<div class="galaxy-spiral">
<div class="spiral-arm spiral-arm-1"></div>
<div class="spiral-arm spiral-arm-2"></div>
<div class="spiral-arm spiral-arm-3"></div>
</div>
<!-- Orbit rings with stars -->
<div class="orbit-ring orbit-ring-1">
<span class="orbit-star"></span>
<span class="orbit-star"></span>
<span class="orbit-star"></span>
<span class="orbit-star"></span>
</div>
<div class="orbit-ring orbit-ring-2">
<span class="orbit-star"></span>
<span class="orbit-star"></span>
<span class="orbit-star"></span>
<span class="orbit-star"></span>
</div>
<div class="orbit-ring orbit-ring-3">
<span class="orbit-star"></span>
<span class="orbit-star"></span>
<span class="orbit-star"></span>
</div>
<div class="orbit-ring orbit-ring-4">
<span class="orbit-star"></span>
<span class="orbit-star"></span>
</div>
</div>
<!-- Light rays -->
<div class="light-rays">
<div class="ray"></div>
@@ -134,7 +179,7 @@
<div class="section-header">
<div class="section-title">
<h2>我的项目</h2>
<p>{{ projects.length }} 个项目</p>
<p>{{ total }} 个项目</p>
</div>
<el-button type="primary" @click="createProject" class="add-btn">
<el-icon><Plus /></el-icon>
@@ -165,6 +210,29 @@
@delete="confirmDelete"
/>
</div>
<!-- Pagination -->
<div class="pagination-wrapper" v-if="needPagination">
<div class="pagination-minimal">
<span class="page-info"> {{ currentPage }} / {{ totalPages }} </span>
<div class="page-arrows">
<button
class="arrow-btn"
:disabled="currentPage === 1"
@click="handlePageChange(currentPage - 1)"
>
<el-icon><ArrowLeft /></el-icon>
</button>
<button
class="arrow-btn"
:disabled="currentPage === totalPages"
@click="handlePageChange(currentPage + 1)"
>
<el-icon><ArrowRight /></el-icon>
</button>
</div>
</div>
</div>
</section>
<!-- Create Dialog -->
@@ -185,10 +253,10 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { FolderAdd, Check, Connection, Clock, Lock, TrendCharts } from '@element-plus/icons-vue'
import { FolderAdd, Check, Connection, Clock, Lock, TrendCharts, ArrowLeft, ArrowRight } from '@element-plus/icons-vue'
import { projectApi } from '@/api'
import type { Project, ProjectCreate } from '@/types'
@@ -208,29 +276,62 @@ const projectToDelete = ref(null)
const submitting = ref(false)
const deleting = ref(false)
// Pagination
const currentPage = ref(1)
const pageSize = ref(9)
const total = ref(0)
const fetchProjects = async () => {
loading.value = true
try {
const res = await projectApi.list()
// New paginated format: {items: [...], total, page, page_size}
projects.value = res.items || res || []
const res = await projectApi.list({ page: currentPage.value, page_size: pageSize.value })
// API returns: { items: [], total, page, page_size, total_pages }
if (res && typeof res === 'object' && 'items' in res) {
projects.value = res.items || []
total.value = res.total || 0
} else if (Array.isArray(res)) {
projects.value = res
total.value = res.length
} else {
projects.value = []
total.value = 0
}
} catch (error) {
projects.value = []
total.value = 0
} finally {
loading.value = false
}
}
const handlePageChange = (page: number) => {
currentPage.value = page
fetchProjects()
}
const needPagination = computed(() => total.value > pageSize.value || projects.value.length === pageSize.value)
const totalPages = computed(() => Math.ceil(total.value / pageSize.value))
const createProject = () => {
dialogVisible.value = true
}
const handleCreateSubmit = async (formData) => {
// Simple validation
// Validation - name, description and type are required
if (!formData.name || formData.name.trim() === '') {
ElMessage.warning('请输入项目名称')
return
}
if (!formData.description || formData.description.trim() === '') {
ElMessage.warning('请输入项目描述')
return
}
if (!formData.type) {
ElMessage.warning('请选择项目类型')
return
}
console.log('Creating project with form:', formData)
submitting.value = true
@@ -278,6 +379,7 @@ const handleDelete = async () => {
}
const goToDataSquare = () => router.push('/data-square')
const goToCrawler = () => router.push('/crawler')
const goToModels = () => router.push('/models')
onMounted(() => fetchProjects())

View File

@@ -64,7 +64,7 @@ const project = ref({ name: '加载中...', description: '' })
const navItems = [
{ path: 'files', label: '文件管理', icon: 'Folder' },
{ path: 'split', label: '文本分割', icon: 'Operation' },
{ path: 'split', label: '分割生成', icon: 'Operation' },
{ path: 'questions', label: '问答管理', icon: 'ChatDotSquare' },
{ path: 'datasets', label: '数据集', icon: 'Collection' },
{ path: 'eval', label: '评估系统', icon: 'DataAnalysis' },

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff