Compare commits
9 Commits
7514e7e763
...
fa7829657f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa7829657f | ||
|
|
3e2d07a502 | ||
|
|
df70c09fe2 | ||
|
|
cc2e73c595 | ||
|
|
da2887d913 | ||
|
|
1cf44ac6f7 | ||
|
|
9a12907f25 | ||
|
|
a1342b7634 | ||
|
|
68453cead8 |
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"""
|
||||
|
||||
|
||||
@@ -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')
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
395
backend/app/services/text_splitter/semantic_embedding.py
Normal file
395
backend/app/services/text_splitter/semantic_embedding.py
Normal 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}")
|
||||
@@ -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
|
||||
|
||||
20
bug修改.md
20
bug修改.md
@@ -1,20 +0,0 @@
|
||||
# Bug 修改记录
|
||||
|
||||
## 2026-03-17
|
||||
|
||||
### 初始项目创建
|
||||
- 创建 YG-Dataset 重构项目
|
||||
- 搭建 FastAPI + Vue 3 基础架构
|
||||
|
||||
---
|
||||
|
||||
## 修复记录格式
|
||||
|
||||
### 日期
|
||||
**问题描述:**
|
||||
**原因:**
|
||||
**修复方案:**
|
||||
|
||||
---
|
||||
|
||||
*持续更新中...*
|
||||
246
frontend/package-lock.json
generated
246
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
6
frontend/src/composables/index.ts
Normal file
6
frontend/src/composables/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Composables - 可复用业务逻辑
|
||||
*/
|
||||
export * from './useFormatters'
|
||||
export * from './useProjects'
|
||||
export * from './useModels'
|
||||
71
frontend/src/composables/useFormatters.ts
Normal file
71
frontend/src/composables/useFormatters.ts
Normal 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')
|
||||
}
|
||||
112
frontend/src/composables/useModels.ts
Normal file
112
frontend/src/composables/useModels.ts
Normal 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
|
||||
}
|
||||
}
|
||||
98
frontend/src/composables/useProjects.ts
Normal file
98
frontend/src/composables/useProjects.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -51,6 +51,11 @@ const routes = [
|
||||
path: '/models',
|
||||
name: 'ModelSettings',
|
||||
component: () => import('@/views/ModelSettingsView.vue')
|
||||
},
|
||||
{
|
||||
path: '/crawler',
|
||||
name: 'Crawler',
|
||||
component: () => import('@/views/CrawlerView.vue')
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
9
frontend/src/styles/index.scss
Normal file
9
frontend/src/styles/index.scss
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 样式入口文件
|
||||
*/
|
||||
|
||||
// 页面样式
|
||||
@import './pages/home';
|
||||
// 后续可以添加更多页面样式
|
||||
// @import './pages/project';
|
||||
// @import './pages/settings';
|
||||
882
frontend/src/styles/pages/home.scss
Normal file
882
frontend/src/styles/pages/home.scss
Normal 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; }
|
||||
}
|
||||
5
frontend/src/types/project.d.ts
vendored
5
frontend/src/types/project.d.ts
vendored
@@ -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
|
||||
}
|
||||
|
||||
386
frontend/src/views/CrawlerView.vue
Normal file
386
frontend/src/views/CrawlerView.vue
Normal 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>
|
||||
@@ -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())
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user