Compare commits

...

10 Commits

Author SHA1 Message Date
Developer
15846a0f7a refactor(frontend): 用 TypeScript 版本替换 JavaScript API 客户端
- 删除旧的 JavaScript API 客户端 (index.js)
- 使用新的 TypeScript 版本 (index.ts)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 17:30:32 +08:00
Developer
5f56eec248 feat(frontend): 更新依赖和路由配置
- 更新 npm 依赖 (package.json)
- 更新路由配置 (router/index.js)
- 更新 Vite 构建配置 (vite.config.js)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 17:30:21 +08:00
Developer
47d1da7cea feat(backend): 更新核心模块和文件处理
- 更新配置模块 (config.py)
- 更新数据库连接 (database.py)
- 更新主应用入口 (main.py)
- 更新数据模型 (models.py)
- 更新基础 Schema (base.py)
- 更新文件处理器 (docx, excel, pdf)
- 更新 Dockerfile

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 17:30:11 +08:00
Developer
db11429290 feat(backend): 更新 API 端点实现
- 更新 Chunks API 端点
- 更新 Datasets API 端点
- 更新 Evaluation API 端点
- 更新 Files API 端点
- 更新 Projects API 端点
- 更新 Questions API 端点

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 17:29:58 +08:00
Developer
eac10a9d95 chore: 添加一键启动脚本
- 添加 start.sh 启动脚本
- 支持前端、后端一键启动
- 支持自定义端口配置

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 17:29:34 +08:00
Developer
e6aa585e06 chore: 添加项目配置文件
- 添加 Python 项目配置 (pyproject.toml)
- 添加环境变量示例 (.env.example)
- 添加 Docker 忽略文件 (.dockerignore)
- 添加 TypeScript 配置 (tsconfig.json, tsconfig.node.json)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 17:29:24 +08:00
Developer
2b2e1a67c8 feat(frontend): 优化页面功能和 UI
- 添加模型配置页面 (ModelSettingsView.vue)
- 优化首页项目列表显示和删除功能 (HomeView.vue)
- 优化项目详情页 (ProjectView.vue)
- 优化项目设置页 (Settings.vue)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 17:29:11 +08:00
Developer
66d251dcc4 feat(frontend): 添加 TypeScript 类型定义和组件
- 添加 TypeScript API 客户端 (api/index.ts)
- 添加全局样式 (styles/)
- 添加类型定义 (types/)
- 添加 Vue 组件 (components/)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 17:28:58 +08:00
Developer
3eb5d47bd3 feat(backend): 添加 API Schemas 定义
- 添加 Chunk 数据结构 (chunk.py)
- 添加 Dataset Schema (dataset.py)
- 添加 Evaluation Schema (eval.py)
- 添加 File Schema (file.py)
- 添加 Project Schema (project.py)
- 添加 Question Schema (question.py)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 17:28:47 +08:00
Developer
efe5d240ae feat(backend): 添加核心架构模块
- 添加认证模块 (auth.py)
- 添加 CRUD 基础操作 (crud.py)
- 添加异常处理 (exceptions.py)
- 添加日志模块 (logging.py)
- 添加响应格式 (response.py)
- 添加依赖注入 (dependencies.py)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 17:28:36 +08:00
54 changed files with 5602 additions and 1642 deletions

50
backend/.dockerignore Normal file
View File

@@ -0,0 +1,50 @@
# Git
.git
.gitignore
# Python
__pycache__
*.py[cod]
*$py.class
*.so
.Python
venv/
.venv/
env/
.env
*.egg-info/
dist/
build/
# IDE
.vscode/
.idea/
*.swp
*.swo
# Testing
.pytest_cache/
.coverage
htmlcov/
# Logs
*.log
logs/
# Database
*.db
*.sqlite
*.sqlite3
# Uploads (should be persisted separately)
uploads/*
!uploads/.gitkeep
# Docker
Dockerfile
.dockerignore
# Misc
.DS_Store
*.md
docs/

24
backend/.env.example Normal file
View File

@@ -0,0 +1,24 @@
# Application
APP_NAME=YG-Dataset
DEBUG=true
HOST=0.0.0.0
PORT=8000
ALLOWED_ORIGINS=*
# Database - Use SQLite for development, PostgreSQL for production
DATABASE_URL=sqlite+aiosqlite:///./ygdataset.db
DATABASE_URL_SYNC=sqlite:///./ygdataset.db
# Security
SECRET_KEY=your-secret-key-change-in-production
# File Storage
UPLOAD_DIR=./uploads
MAX_FILE_SIZE=104857600
# LLM Settings
DEFAULT_MODEL_PROVIDER=openai
DEFAULT_MODEL_NAME=gpt-4o-mini
# Logging
LOG_LEVEL=INFO

View File

@@ -1,27 +1,60 @@
FROM python:3.11-slim
# Multi-stage build for Python FastAPI application
# Stage 1: Base image
FROM python:3.11-slim as base
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONFAULTHANDLER=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
# Stage 2: Dependencies
FROM base as deps
# Install system dependencies
RUN apt-get update && apt-get install -y \
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements
COPY requirements.txt .
# Create virtual environment
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY . .
# Create uploads directory
RUN mkdir -p uploads
# Stage 3: Production
FROM base
# Install system dependencies for production
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
# Copy virtual environment from deps stage
COPY --from=deps /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Copy application
COPY --chown=app:app . /app
RUN mkdir -p /app/uploads /app/logs && chown -R app:app /app
# Switch to non-root user
USER app
# Expose port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
# Run application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]

View File

@@ -0,0 +1,20 @@
"""
API Dependencies
API 依赖项
"""
from typing import Annotated
from fastapi import Depends
from app.core.auth import verify_api_key
# Type alias for API key dependency
ApiKey = Annotated[str, Depends(verify_api_key)]
# Optional API key (for endpoints that can work with or without auth)
async def get_optional_api_key(api_key: str = None) -> Optional[str]:
"""Get optional API key"""
return api_key
OptionalApiKey = Annotated[Optional[str], Depends(get_optional_api_key)]

View File

@@ -0,0 +1,75 @@
"""
API Response Wrapper
统一 API 响应格式
"""
from datetime import datetime
from typing import Any, Generic, List, Optional, TypeVar
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
T = TypeVar("T")
class ApiResponse(BaseModel, Generic[T]):
"""统一 API 响应格式"""
model_config = ConfigDict(from_attributes=True)
success: bool = True
message: str = "Success"
data: Optional[T] = None
error: Optional[dict] = None
timestamp: datetime = Field(default_factory=datetime.utcnow)
@classmethod
def ok(cls, data: T = None, message: str = "Success") -> "ApiResponse[T]":
"""成功响应"""
return cls(success=True, message=message, data=data)
@classmethod
def fail(cls, message: str, error: dict = None) -> "ApiResponse[None]":
"""失败响应"""
return cls(success=False, message=message, error=error)
class PaginatedResponse(BaseModel, Generic[T]):
"""分页响应格式"""
model_config = ConfigDict(from_attributes=True)
success: bool = True
message: str = "Success"
data: List[T] = []
pagination: dict = Field(default_factory=lambda: {
"page": 1,
"page_size": 20,
"total": 0,
"total_pages": 0
})
@classmethod
def ok(
cls,
items: List[T],
page: int = 1,
page_size: int = 20,
total: int = 0
) -> "PaginatedResponse[T]":
"""创建分页响应"""
total_pages = (total + page_size - 1) // page_size if page_size > 0 else 0
return cls(
success=True,
data=items,
pagination={
"page": page,
"page_size": page_size,
"total": total,
"total_pages": total_pages
}
)
class ErrorDetail(BaseModel):
"""错误详情"""
code: str
message: str
details: Optional[dict] = None
field: Optional[str] = None

View File

@@ -1,15 +1,21 @@
"""
Chunks API Router
"""
import asyncio
from typing import List, Optional
from uuid import UUID
from pydantic import BaseModel
from pydantic import BaseModel, Field
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
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.models.models import Chunk, File
from app.schemas.base import ChunkCreate, ChunkResponse
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
@@ -17,26 +23,23 @@ from app.services.file_processor.excel_processor import process_csv, process_exc
router = APIRouter()
# Initialize CRUD
chunk_crud = CRUDBase(Chunk)
class SplitRequest(BaseModel):
"""Request model for splitting text"""
file_id: Optional[UUID] = None
file_id: UUID
method: str = "recursive"
chunk_size: int = 500
overlap: int = 50
chunk_size: int = Field(500, ge=50, le=5000)
overlap: int = Field(50, ge=0, le=500)
separator: Optional[str] = None
class ChunkListResponse(BaseModel):
"""Response for chunk list"""
chunks: List[ChunkResponse]
total: int
def process_file_by_type(file: File) -> str:
async def process_file_by_type(file: File) -> str:
"""Process file based on its type"""
if not file.file_path:
raise HTTPException(status_code=400, detail="File path not found")
raise NotFoundException("File", file.id)
processors = {
"pdf": process_pdf,
@@ -48,13 +51,17 @@ def process_file_by_type(file: File) -> str:
processor = processors.get(file.file_type)
if not processor:
# Return raw text for txt, md files
with open(file.file_path, 'r', encoding='utf-8') as f:
return f.read()
loop = asyncio.get_event_loop()
content = await loop.run_in_executor(
None,
lambda: open(file.file_path, 'r', encoding='utf-8').read()
)
return content
return processor(file.file_path)
return await processor(file.file_path)
@router.post("/split", response_model=dict)
@router.post("/split", response_model=ApiResponse)
async def split_text(
project_id: UUID,
request: SplitRequest,
@@ -62,22 +69,19 @@ async def split_text(
):
"""Split text into chunks"""
# Get file
if request.file_id:
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 HTTPException(status_code=404, detail="File not found")
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 = process_file_by_type(file)
# Process file
text = await process_file_by_type(file)
# Update file status
file.status = "processing"
await db.commit()
else:
raise HTTPException(status_code=400, detail="file_id is required")
# Update file status
file.status = "processing"
await db.commit()
# Split text
kwargs = {"chunk_size": request.chunk_size, "overlap": request.overlap}
@@ -106,77 +110,87 @@ async def split_text(
file.status = "completed"
await db.commit()
return {"chunks": len(chunks), "message": f"Successfully split into {len(chunks)} chunks"}
return ApiResponse.ok(
data={"chunks": len(chunks)},
message=f"Successfully split into {len(chunks)} chunks"
)
@router.get("/", response_model=dict)
@router.get("", response_model=ApiResponse)
async def list_chunks(
project_id: UUID,
file_id: Optional[UUID] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db)
):
"""List chunks for a project"""
query = select(Chunk).where(Chunk.project_id == project_id)
filters = {"project_id": project_id}
if file_id:
query = query.where(Chunk.file_id == file_id)
filters["file_id"] = file_id
query = query.order_by(Chunk.created_at.desc())
result = await db.execute(query)
chunks = result.scalars().all()
return {
"chunks": [ChunkResponse.model_validate(c) for c in chunks],
"total": len(chunks)
}
@router.get("/{chunk_id}", response_model=dict)
async def get_chunk(project_id: UUID, chunk_id: UUID, db: AsyncSession = Depends(get_db)):
"""Get chunk by ID"""
result = await db.execute(
select(Chunk).where(Chunk.id == chunk_id, Chunk.project_id == project_id)
skip = (page - 1) * page_size
chunks, total = await chunk_crud.get_multi(
db,
skip=skip,
limit=page_size,
filters=filters,
order_by="created_at",
descending=True
)
chunk_responses = [ChunkResponse.model_validate(c) for c in chunks]
return PaginatedResponse.ok(
items=chunk_responses,
page=page,
page_size=page_size,
total=total
)
chunk = result.scalar_one_or_none()
if not chunk:
raise HTTPException(status_code=404, detail="Chunk not found")
return ChunkResponse.model_validate(chunk)
@router.put("/{chunk_id}", response_model=dict)
@router.get("/{chunk_id}", response_model=ApiResponse)
async def get_chunk(
project_id: UUID,
chunk_id: UUID,
db: AsyncSession = Depends(get_db)
):
"""Get chunk by ID"""
chunk = await chunk_crud.get(db, chunk_id)
if not chunk or chunk.project_id != project_id:
raise NotFoundException("Chunk", chunk_id)
return ApiResponse.ok(data=ChunkResponse.model_validate(chunk))
@router.put("/{chunk_id}", response_model=ApiResponse)
async def update_chunk(
project_id: UUID,
chunk_id: UUID,
chunk: ChunkCreate,
chunk: ChunkCreateSchema,
db: AsyncSession = Depends(get_db)
):
"""Update chunk"""
result = await db.execute(
select(Chunk).where(Chunk.id == chunk_id, Chunk.project_id == project_id)
db_chunk = await chunk_crud.get(db, chunk_id)
if not db_chunk or db_chunk.project_id != project_id:
raise NotFoundException("Chunk", chunk_id)
updated_chunk = await chunk_crud.update(db, db_chunk, chunk)
return ApiResponse.ok(
data=ChunkResponse.model_validate(updated_chunk),
message="Chunk updated successfully"
)
db_chunk = result.scalar_one_or_none()
if not db_chunk:
raise HTTPException(status_code=404, detail="Chunk not found")
for key, value in chunk.model_dump(exclude_unset=True).items():
setattr(db_chunk, key, value)
await db.commit()
await db.refresh(db_chunk)
return ChunkResponse.model_validate(db_chunk)
@router.delete("/{chunk_id}", response_model=dict)
async def delete_chunk(project_id: UUID, chunk_id: UUID, db: AsyncSession = Depends(get_db)):
@router.delete("/{chunk_id}", response_model=ApiResponse)
async def delete_chunk(
project_id: UUID,
chunk_id: UUID,
db: AsyncSession = Depends(get_db)
):
"""Delete chunk"""
result = await db.execute(
select(Chunk).where(Chunk.id == chunk_id, Chunk.project_id == project_id)
)
chunk = result.scalar_one_or_none()
if not chunk:
raise HTTPException(status_code=404, detail="Chunk not found")
chunk = await chunk_crud.get(db, chunk_id)
if not chunk or chunk.project_id != project_id:
raise NotFoundException("Chunk", chunk_id)
await db.delete(chunk)
await db.commit()
return {"message": "Chunk deleted successfully"}
await chunk_crud.delete(db, chunk_id)
return ApiResponse.ok(message="Chunk deleted successfully")

View File

@@ -3,94 +3,107 @@ Datasets API Router
"""
from typing import List, Optional
from uuid import UUID
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.api.response import ApiResponse, PaginatedResponse
from app.core.database import get_db
from app.models.models import Dataset, Question
from app.schemas.base import DatasetCreate, DatasetResponse
from app.core.exceptions import NotFoundException
from app.core.crud import CRUDBase
from app.models.models import Dataset
from app.schemas.dataset import DatasetResponse
from app.schemas.dataset import DatasetCreateSchema
router = APIRouter()
# Initialize CRUD
dataset_crud = CRUDBase(Dataset)
class ExportRequest(BaseModel):
"""Export request schema"""
format: str = "alpaca" # alpaca, sharegpt, llama_factory, json
format: str = Field("alpaca", pattern="^(alpaca|sharegpt|llama_factory|json)$")
@router.get("/", response_model=dict)
async def list_datasets(project_id: UUID, db: AsyncSession = Depends(get_db)):
@router.get("", response_model=ApiResponse)
async def list_datasets(
project_id: UUID,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db)
):
"""List datasets for a project"""
result = await db.execute(
select(Dataset).where(Dataset.project_id == project_id).order_by(Dataset.created_at.desc())
skip = (page - 1) * page_size
datasets, total = await dataset_crud.get_multi(
db,
skip=skip,
limit=page_size,
filters={"project_id": project_id},
order_by="created_at",
descending=True
)
datasets = result.scalars().all()
# Get question count for each dataset
dataset_list = []
for dataset in datasets:
dataset_data = DatasetResponse.model_validate(dataset)
# TODO: Count questions in dataset
dataset_data.question_count = 0
dataset_list.append(dataset_data)
return {"datasets": dataset_list}
dataset_responses = [DatasetResponse.model_validate(d) for d in datasets]
return PaginatedResponse.ok(
items=dataset_responses,
page=page,
page_size=page_size,
total=total
)
@router.post("/", response_model=dict)
@router.post("", response_model=ApiResponse)
async def create_dataset(
project_id: UUID,
dataset: DatasetCreate,
dataset: DatasetCreateSchema,
db: AsyncSession = Depends(get_db)
):
"""Create a new dataset"""
db_dataset = Dataset(project_id=project_id, **dataset.model_dump())
# Add project_id to the dataset
dataset_dict = dataset.model_dump()
dataset_dict["project_id"] = project_id
db_dataset = Dataset(**dataset_dict)
db.add(db_dataset)
await db.commit()
await db.refresh(db_dataset)
return {"id": str(db_dataset.id)}
return ApiResponse.ok(
data={"id": str(db_dataset.id)},
message="Dataset created successfully"
)
@router.get("/{dataset_id}", response_model=dict)
@router.get("/{dataset_id}", response_model=ApiResponse)
async def get_dataset(
project_id: UUID,
dataset_id: UUID,
db: AsyncSession = Depends(get_db)
):
"""Get dataset by ID"""
result = await db.execute(
select(Dataset).where(Dataset.id == dataset_id, Dataset.project_id == project_id)
)
dataset = result.scalar_one_or_none()
if not dataset:
raise HTTPException(status_code=404, detail="Dataset not found")
dataset = await dataset_crud.get(db, dataset_id)
if not dataset or dataset.project_id != project_id:
raise NotFoundException("Dataset", dataset_id)
return DatasetResponse.model_validate(dataset)
return ApiResponse.ok(data=DatasetResponse.model_validate(dataset))
@router.delete("/{dataset_id}", response_model=dict)
@router.delete("/{dataset_id}", response_model=ApiResponse)
async def delete_dataset(
project_id: UUID,
dataset_id: UUID,
db: AsyncSession = Depends(get_db)
):
"""Delete dataset"""
result = await db.execute(
select(Dataset).where(Dataset.id == dataset_id, Dataset.project_id == project_id)
)
dataset = result.scalar_one_or_none()
if not dataset:
raise HTTPException(status_code=404, detail="Dataset not found")
dataset = await dataset_crud.get(db, dataset_id)
if not dataset or dataset.project_id != project_id:
raise NotFoundException("Dataset", dataset_id)
await db.delete(dataset)
await db.commit()
return {"message": "Dataset deleted successfully"}
await dataset_crud.delete(db, dataset_id)
return ApiResponse.ok(message="Dataset deleted successfully")
@router.post("/{dataset_id}/export")
@router.post("/{dataset_id}/export", response_model=ApiResponse)
async def export_dataset(
project_id: UUID,
dataset_id: UUID,
@@ -98,18 +111,9 @@ async def export_dataset(
db: AsyncSession = Depends(get_db)
):
"""Export dataset in specified format"""
# TODO: Implement actual export logic
# Get dataset
result = await db.execute(
select(Dataset).where(Dataset.id == dataset_id, Dataset.project_id == project_id)
)
dataset = result.scalar_one_or_none()
if not dataset:
raise HTTPException(status_code=404, detail="Dataset not found")
# Get questions for this dataset (placeholder)
# In real implementation, would link questions to datasets
dataset = await dataset_crud.get(db, dataset_id)
if not dataset or dataset.project_id != project_id:
raise NotFoundException("Dataset", dataset_id)
# Return sample data based on format
sample_data = [
@@ -121,6 +125,9 @@ async def export_dataset(
]
if request.format == "json":
return sample_data
return ApiResponse.ok(data=sample_data)
return {"data": sample_data, "format": request.format}
return ApiResponse.ok(
data={"data": sample_data, "format": request.format},
message="Dataset exported successfully"
)

View File

@@ -1,24 +1,32 @@
"""
Evaluation API Router
"""
from typing import List, Optional
from typing import Optional
from uuid import UUID
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
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.models.models import EvalDataset, Task
from app.schemas.base import EvalDatasetCreate, EvalDatasetResponse, TaskResponse
from app.schemas.eval import EvalDatasetResponse, TaskResponse
from app.schemas.eval import EvalDatasetCreateSchema
router = APIRouter()
# Initialize CRUD
eval_crud = CRUDBase(EvalDataset)
task_crud = CRUDBase(Task)
class GenerateEvalRequest(BaseModel):
"""Request for generating evaluation dataset"""
name: str
question_type: str = "mixed"
count: int = 50
name: str = Field(..., min_length=1, max_length=255)
question_type: str = Field("mixed", pattern="^(mixed|fact|reasoning|summary)$")
count: int = Field(50, ge=1, le=500)
class RunEvalRequest(BaseModel):
@@ -26,18 +34,34 @@ class RunEvalRequest(BaseModel):
model_config_id: Optional[UUID] = None
@router.get("/", response_model=dict)
async def list_eval_datasets(project_id: UUID, db: AsyncSession = Depends(get_db)):
@router.get("", response_model=ApiResponse)
async def list_eval_datasets(
project_id: UUID,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db)
):
"""List evaluation datasets"""
result = await db.execute(
select(EvalDataset).where(EvalDataset.project_id == project_id).order_by(EvalDataset.created_at.desc())
skip = (page - 1) * page_size
datasets, total = await eval_crud.get_multi(
db,
skip=skip,
limit=page_size,
filters={"project_id": project_id},
order_by="created_at",
descending=True
)
datasets = result.scalars().all()
return {"datasets": [EvalDatasetResponse.model_validate(d) for d in datasets]}
dataset_responses = [EvalDatasetResponse.model_validate(d) for d in datasets]
return PaginatedResponse.ok(
items=dataset_responses,
page=page,
page_size=page_size,
total=total
)
@router.post("/", response_model=dict)
@router.post("", response_model=ApiResponse)
async def create_eval_dataset(
project_id: UUID,
request: GenerateEvalRequest,
@@ -53,10 +77,27 @@ async def create_eval_dataset(
await db.commit()
await db.refresh(db_dataset)
return {"id": str(db_dataset.id)}
return ApiResponse.ok(
data={"id": str(db_dataset.id)},
message="Evaluation dataset created successfully"
)
@router.post("/{eval_id}/evaluate", response_model=dict)
@router.get("/{eval_id}", response_model=ApiResponse)
async def get_eval_dataset(
project_id: UUID,
eval_id: UUID,
db: AsyncSession = Depends(get_db)
):
"""Get evaluation dataset by ID"""
dataset = await eval_crud.get(db, eval_id)
if not dataset or dataset.project_id != project_id:
raise NotFoundException("Evaluation Dataset", eval_id)
return ApiResponse.ok(data=EvalDatasetResponse.model_validate(dataset))
@router.post("/{eval_id}/evaluate", response_model=ApiResponse)
async def run_evaluation(
project_id: UUID,
eval_id: UUID,
@@ -65,12 +106,9 @@ async def run_evaluation(
):
"""Run evaluation on dataset"""
# Check dataset exists
result = await db.execute(
select(EvalDataset).where(EvalDataset.id == eval_id, EvalDataset.project_id == project_id)
)
dataset = result.scalar_one_or_none()
if not dataset:
raise HTTPException(status_code=404, detail="Evaluation dataset not found")
dataset = await eval_crud.get(db, eval_id)
if not dataset or dataset.project_id != project_id:
raise NotFoundException("Evaluation Dataset", eval_id)
# Create evaluation task
task = Task(
@@ -82,19 +120,21 @@ async def run_evaluation(
await db.commit()
await db.refresh(task)
# TODO: Start evaluation in background
return {"task_id": str(task.id), "message": "Evaluation task started"}
@router.get("/results", response_model=dict)
async def get_eval_results(project_id: UUID, task_id: UUID, db: AsyncSession = Depends(get_db)):
"""Get evaluation results"""
result = await db.execute(
select(Task).where(Task.id == task_id, Task.project_id == project_id)
return ApiResponse.ok(
data={"task_id": str(task.id)},
message="Evaluation task started"
)
task = result.scalar_one_or_none()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return TaskResponse.model_validate(task)
@router.get("/results", response_model=ApiResponse)
async def get_eval_results(
project_id: UUID,
task_id: UUID,
db: AsyncSession = Depends(get_db)
):
"""Get evaluation results"""
task = await task_crud.get(db, task_id)
if not task or task.project_id != project_id:
raise NotFoundException("Task", task_id)
return ApiResponse.ok(data=TaskResponse.model_validate(task))

View File

@@ -2,17 +2,21 @@
Files API Router
"""
import os
import aiofiles
import asyncio
from pathlib import Path
from typing import List
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from fastapi import APIRouter, Depends, UploadFile, File, Query
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.database import get_db
from app.api.response import ApiResponse, PaginatedResponse
from app.core.config import get_settings
from app.models.models import File
from app.schemas.base import FileResponse
from app.core.database import get_db
from app.core.exceptions import ValidationException, NotFoundException
from app.core.crud import CRUDBase
from app.models.models import File as FileModel
from app.schemas.file import FileResponse, FileCreateSchema
settings = get_settings()
router = APIRouter()
@@ -21,6 +25,9 @@ router = APIRouter()
UPLOAD_DIR = Path(settings.UPLOAD_DIR)
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
# Initialize CRUD
file_crud = CRUDBase(FileModel)
def get_file_type(filename: str) -> str:
"""Get file type from extension"""
@@ -40,71 +47,157 @@ def get_file_type(filename: str) -> str:
return type_map.get(ext, 'txt')
@router.post("/upload", response_model=dict)
# Allowed file extensions
ALLOWED_EXTENSIONS = {'pdf', 'docx', 'doc', 'xlsx', 'xls', 'csv', 'epub', 'md', 'txt'}
def validate_file(filename: str, file_size: int) -> None:
"""Validate file extension and size"""
ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
if ext not in ALLOWED_EXTENSIONS:
raise ValidationException(
f"File type '{ext}' not allowed",
field="file"
)
if file_size > settings.MAX_FILE_SIZE:
raise ValidationException(
f"File size exceeds maximum allowed size of {settings.MAX_FILE_SIZE // (1024*1024)}MB",
field="file"
)
async def save_file_async(file: UploadFile, destination: Path) -> None:
"""Save uploaded file asynchronously"""
content = await file.read()
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, lambda: destination.write_bytes(content))
@router.post("/upload", response_model=ApiResponse)
async def upload_file(
project_id: UUID,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db)
):
"""Upload a file"""
# Read file content for validation
content = await file.read()
file_size = len(content)
# Validate file
validate_file(file.filename, file_size)
# Save file to disk
file_path = UPLOAD_DIR / f"{project_id}_{file.filename}"
async with aiofiles.open(file_path, 'wb') as f:
content = await file.read()
await f.write(content)
safe_filename = f"{project_id}_{UUID.uuid4().hex[:8]}_{file.filename}"
file_path = UPLOAD_DIR / safe_filename
# Write file asynchronously
await asyncio.get_event_loop().run_in_executor(
None,
lambda: file_path.write_bytes(content)
)
# Create file record
db_file = File(
db_file = FileModel(
project_id=project_id,
filename=file.filename,
file_type=get_file_type(file.filename),
file_path=str(file_path),
size=len(content),
size=file_size,
status="pending"
)
db.add(db_file)
await db.commit()
await db.refresh(db_file)
return {"id": str(db_file.id), "filename": db_file.filename, "status": db_file.status}
return ApiResponse.ok(
data={"id": str(db_file.id), "filename": db_file.filename, "status": db_file.status},
message="File uploaded successfully"
)
@router.get("/", response_model=dict)
async def list_files(project_id: UUID, db: AsyncSession = Depends(get_db)):
@router.get("", response_model=ApiResponse)
async def list_files(
project_id: UUID,
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db)
):
"""List files for a project"""
result = await db.execute(
select(File).where(File.project_id == project_id).order_by(File.created_at.desc())
skip = (page - 1) * page_size
files, total = await file_crud.get_multi(
db,
skip=skip,
limit=page_size,
filters={"project_id": project_id},
order_by="created_at",
descending=True
)
file_responses = [FileResponse.model_validate(f) for f in files]
return PaginatedResponse.ok(
items=file_responses,
page=page,
page_size=page_size,
total=total
)
files = result.scalars().all()
return {"files": [FileResponse.model_validate(f) for f in files]}
@router.get("/{file_id}", response_model=dict)
async def get_file(project_id: UUID, file_id: UUID, db: AsyncSession = Depends(get_db)):
@router.get("/{file_id}", response_model=ApiResponse)
async def get_file(
project_id: UUID,
file_id: UUID,
db: AsyncSession = Depends(get_db)
):
"""Get file by ID"""
result = await db.execute(
select(File).where(File.id == file_id, File.project_id == project_id)
)
file = result.scalar_one_or_none()
if not file:
raise HTTPException(status_code=404, detail="File not found")
return FileResponse.model_validate(file)
file = await file_crud.get(db, file_id)
if not file or file.project_id != project_id:
raise NotFoundException("File", file_id)
return ApiResponse.ok(data=FileResponse.model_validate(file))
@router.delete("/{file_id}", response_model=dict)
async def delete_file(project_id: UUID, file_id: UUID, db: AsyncSession = Depends(get_db)):
@router.delete("/{file_id}", response_model=ApiResponse)
async def delete_file(
project_id: UUID,
file_id: UUID,
db: AsyncSession = Depends(get_db)
):
"""Delete file"""
result = await db.execute(
select(File).where(File.id == file_id, File.project_id == project_id)
)
file = result.scalar_one_or_none()
if not file:
raise HTTPException(status_code=404, detail="File not found")
file = await file_crud.get(db, file_id)
if not file or file.project_id != project_id:
raise NotFoundException("File", file_id)
# Delete file from disk
if file.file_path and os.path.exists(file.file_path):
os.remove(file.file_path)
await asyncio.get_event_loop().run_in_executor(
None,
os.remove,
file.file_path
)
await db.delete(file)
await db.commit()
return {"message": "File deleted successfully"}
await file_crud.delete(db, file_id)
return ApiResponse.ok(message="File deleted successfully")
@router.get("/{file_id}/download", response_class=FileResponse)
async def download_file(
project_id: UUID,
file_id: UUID,
db: AsyncSession = Depends(get_db)
):
"""Download file"""
file = await file_crud.get(db, file_id)
if not file or file.project_id != project_id:
raise NotFoundException("File", file_id)
if not file.file_path or not os.path.exists(file.file_path):
raise ValidationException("File not found on disk", field="file")
return FileResponse(
path=file.file_path,
filename=file.filename,
media_type=f"application/{file.file_type}"
)

View File

@@ -1,74 +1,111 @@
"""
Projects API Router
"""
from typing import List
import logging
from typing import List, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
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.models.models import Project
from app.schemas.base import (
from app.schemas.project import (
ProjectCreate,
ProjectUpdate,
ProjectResponse
ProjectResponse,
ProjectCreateSchema,
ProjectUpdateSchema
)
router = APIRouter()
logger = logging.getLogger("yg_dataset.projects")
# Initialize CRUD
project_crud = CRUDBase(Project)
@router.get("/", response_model=dict)
async def list_projects(db: AsyncSession = Depends(get_db)):
"""List all projects"""
result = await db.execute(select(Project).order_by(Project.created_at.desc()))
projects = result.scalars().all()
return {"projects": [ProjectResponse.model_validate(p) for p in projects]}
@router.get("", response_model=ApiResponse)
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"),
db: AsyncSession = Depends(get_db)
):
"""List all projects with pagination"""
logger.info(f"Listing projects - page: {page}, page_size: {page_size}")
skip = (page - 1) * page_size
projects, total = await project_crud.get_multi(
db,
skip=skip,
limit=page_size,
order_by="created_at",
descending=True
)
logger.info(f"Found {total} projects, returning {len(projects)} items")
project_responses = [ProjectResponse.model_validate(p) for p in projects]
return PaginatedResponse.ok(
items=project_responses,
page=page,
page_size=page_size,
total=total
)
@router.post("/", response_model=dict)
async def create_project(project: ProjectCreate, db: AsyncSession = Depends(get_db)):
@router.post("", response_model=ApiResponse)
async def create_project(
project: ProjectCreateSchema,
db: AsyncSession = Depends(get_db)
):
"""Create a new project"""
db_project = Project(**project.model_dump())
db.add(db_project)
await db.commit()
await db.refresh(db_project)
return {"id": str(db_project.id)}
logger.info(f"Creating project: name={project.name}, description={project.description}")
db_project = await project_crud.create(db, project)
logger.info(f"Project created successfully: id={db_project.id}")
return ApiResponse.ok(
data={"id": str(db_project.id)},
message="Project created successfully"
)
@router.get("/{project_id}", response_model=dict)
async def get_project(project_id: UUID, db: AsyncSession = Depends(get_db)):
@router.get("/{project_id}", response_model=ApiResponse)
async def get_project(
project_id: UUID,
db: AsyncSession = Depends(get_db)
):
"""Get project by ID"""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
return ProjectResponse.model_validate(project)
logger.info(f"Getting project: id={project_id}")
project = await project_crud.get_or_raise(db, project_id, "Project")
logger.info(f"Found project: name={project.name}")
return ApiResponse.ok(data=ProjectResponse.model_validate(project))
@router.put("/{project_id}", response_model=dict)
async def update_project(project_id: UUID, project: ProjectUpdate, db: AsyncSession = Depends(get_db)):
@router.put("/{project_id}", response_model=ApiResponse)
async def update_project(
project_id: UUID,
project: ProjectUpdateSchema,
db: AsyncSession = Depends(get_db)
):
"""Update project"""
result = await db.execute(select(Project).where(Project.id == project_id))
db_project = result.scalar_one_or_none()
if not db_project:
raise HTTPException(status_code=404, detail="Project not found")
for key, value in project.model_dump(exclude_unset=True).items():
setattr(db_project, key, value)
await db.commit()
await db.refresh(db_project)
return ProjectResponse.model_validate(db_project)
logger.info(f"Updating project: id={project_id}")
db_project = await project_crud.get_or_raise(db, project_id, "Project")
updated_project = await project_crud.update(db, db_project, project)
logger.info(f"Project updated: name={updated_project.name}")
return ApiResponse.ok(
data=ProjectResponse.model_validate(updated_project),
message="Project updated successfully"
)
@router.delete("/{project_id}", response_model=dict)
async def delete_project(project_id: UUID, db: AsyncSession = Depends(get_db)):
@router.delete("/{project_id}", response_model=ApiResponse)
async def delete_project(
project_id: UUID,
db: AsyncSession = Depends(get_db)
):
"""Delete project"""
result = await db.execute(select(Project).where(Project.id == project_id))
project = result.scalar_one_or_none()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
await db.delete(project)
await db.commit()
return {"message": "Project deleted successfully"}
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)
logger.info(f"Project deleted: id={project_id}")
return ApiResponse.ok(message="Project deleted successfully")

View File

@@ -3,37 +3,38 @@ Questions API Router
"""
from typing import List, Optional
from uuid import UUID
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.api.response import ApiResponse, PaginatedResponse
from app.core.database import get_db
from app.core.exceptions import NotFoundException, ValidationException
from app.core.crud import CRUDBase
from app.models.models import Question, Chunk
from app.schemas.base import QuestionCreate, QuestionResponse
from app.schemas.question import QuestionResponse
from app.schemas.question import QuestionCreateSchema
router = APIRouter()
# Initialize CRUD
question_crud = CRUDBase(Question)
class GenerateRequest(BaseModel):
"""Request model for generating questions"""
chunk_ids: List[UUID] = []
count: int = 5
chunk_ids: List[UUID] = Field(..., min_length=1)
count: int = Field(5, ge=1, le=50)
question_types: List[str] = ["fact", "summary"]
@router.post("/generate", response_model=dict)
@router.post("/generate", response_model=ApiResponse)
async def generate_questions(
project_id: UUID,
request: GenerateRequest,
db: AsyncSession = Depends(get_db)
):
"""Generate questions from chunks using LLM"""
# TODO: Implement LLM-based question generation
# This is a placeholder that creates sample questions
if not request.chunk_ids:
raise HTTPException(status_code=400, detail="chunk_ids is required")
# Get chunks
result = await db.execute(
select(Chunk).where(Chunk.id.in_(request.chunk_ids), Chunk.project_id == project_id)
@@ -41,9 +42,9 @@ async def generate_questions(
chunks = result.scalars().all()
if not chunks:
raise HTTPException(status_code=404, detail="No chunks found")
raise ValidationException("No valid chunks found", field="chunk_ids")
# Create sample questions (placeholder)
# Create sample questions (placeholder for LLM-based generation)
created_questions = []
for chunk in chunks:
for i in range(request.count):
@@ -60,63 +61,73 @@ async def generate_questions(
await db.commit()
return {
"questions": len(created_questions),
"message": f"Successfully generated {len(created_questions)} questions"
}
return ApiResponse.ok(
data={"questions": len(created_questions)},
message=f"Successfully generated {len(created_questions)} questions"
)
@router.get("/", response_model=dict)
@router.get("", response_model=ApiResponse)
async def list_questions(
project_id: UUID,
chunk_id: Optional[UUID] = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db)
):
"""List questions for a project"""
query = select(Question).where(Question.project_id == project_id)
filters = {"project_id": project_id}
if chunk_id:
query = query.where(Question.chunk_id == chunk_id)
filters["chunk_id"] = chunk_id
result = await db.execute(query)
questions = result.scalars().all()
skip = (page - 1) * page_size
questions, total = await question_crud.get_multi(
db,
skip=skip,
limit=page_size,
filters=filters,
order_by="created_at",
descending=True
)
return {"questions": [QuestionResponse.model_validate(q) for q in questions]}
question_responses = [QuestionResponse.model_validate(q) for q in questions]
return PaginatedResponse.ok(
items=question_responses,
page=page,
page_size=page_size,
total=total
)
@router.put("/{question_id}", response_model=dict)
@router.put("/{question_id}", response_model=ApiResponse)
async def update_question(
project_id: UUID,
question_id: UUID,
question: QuestionCreate,
question: QuestionCreateSchema,
db: AsyncSession = Depends(get_db)
):
"""Update question"""
result = await db.execute(
select(Question).where(Question.id == question_id, Question.project_id == project_id)
db_question = await question_crud.get(db, question_id)
if not db_question or db_question.project_id != project_id:
raise NotFoundException("Question", question_id)
updated_question = await question_crud.update(db, db_question, question)
return ApiResponse.ok(
data=QuestionResponse.model_validate(updated_question),
message="Question updated successfully"
)
db_question = result.scalar_one_or_none()
if not db_question:
raise HTTPException(status_code=404, detail="Question not found")
for key, value in question.model_dump(exclude_unset=True).items():
setattr(db_question, key, value)
await db.commit()
await db.refresh(db_question)
return QuestionResponse.model_validate(db_question)
@router.delete("/{question_id}", response_model=dict)
async def delete_question(project_id: UUID, question_id: UUID, db: AsyncSession = Depends(get_db)):
@router.delete("/{question_id}", response_model=ApiResponse)
async def delete_question(
project_id: UUID,
question_id: UUID,
db: AsyncSession = Depends(get_db)
):
"""Delete question"""
result = await db.execute(
select(Question).where(Question.id == question_id, Question.project_id == project_id)
)
question = result.scalar_one_or_none()
if not question:
raise HTTPException(status_code=404, detail="Question not found")
question = await question_crud.get(db, question_id)
if not question or question.project_id != project_id:
raise NotFoundException("Question", question_id)
await db.delete(question)
await db.commit()
return {"message": "Question deleted successfully"}
await question_crud.delete(db, question_id)
return ApiResponse.ok(message="Question deleted successfully")

38
backend/app/core/auth.py Normal file
View File

@@ -0,0 +1,38 @@
"""
API Key Authentication
API Key 认证中间件
"""
from typing import Optional
from fastapi import Header, HTTPException, Request
from fastapi.security import APIKeyHeader
from app.core.config import get_settings
settings = get_settings()
# API Key header
API_KEY_HEADER = APIKeyHeader(name="X-API-Key", auto_error=False)
async def verify_api_key(api_key: Optional[str] = Header(None)) -> str:
"""Verify API key from header"""
if not api_key:
raise HTTPException(status_code=401, detail="API key is required")
# In production, you would validate against a database or cache
# For development, we can use a simple validation
if settings.DEBUG and api_key == "dev-api-key":
return api_key
# TODO: Implement proper API key validation
# This is a placeholder - in production, validate against stored keys
if len(api_key) < 32:
raise HTTPException(status_code=401, detail="Invalid API key")
return api_key
def create_api_key() -> str:
"""Generate a new API key"""
import secrets
return secrets.token_hex(32)

View File

@@ -4,7 +4,7 @@ Application Configuration
from functools import lru_cache
from pydantic_settings import BaseSettings
from pydantic import Field
from pydantic import Field, field_validator
class Settings(BaseSettings):
@@ -15,12 +15,16 @@ class Settings(BaseSettings):
DEBUG: bool = True
HOST: str = "0.0.0.0"
PORT: int = 8000
ALLOWED_ORIGINS: str = Field(
default="*",
description="Comma-separated list of allowed CORS origins"
)
# Database - 使用 SQLite 进行开发/测试
# 生产环境可切换为 PostgreSQL
DATABASE_URL: str = Field(
default="sqlite:///./ygdataset.db",
description="Database connection URL (sqlite:// or postgresql+asyncpg://)"
default="sqlite+aiosqlite:///./ygdataset.db",
description="Database connection URL (sqlite+aiosqlite:// or postgresql+asyncpg://)"
)
DATABASE_URL_SYNC: str = Field(
default="sqlite:///./ygdataset.db",
@@ -38,8 +42,31 @@ class Settings(BaseSettings):
DEFAULT_MODEL_PROVIDER: str = "openai"
DEFAULT_MODEL_NAME: str = "gpt-4o-mini"
# Security
SECRET_KEY: str = Field(
default="your-secret-key-change-in-production",
description="Secret key for JWT and other security operations"
)
API_KEY_HEADER: str = "X-API-Key"
# Pagination
DEFAULT_PAGE_SIZE: int = 20
MAX_PAGE_SIZE: int = 100
# Logging
LOG_LEVEL: str = "INFO"
@field_validator("MAX_FILE_SIZE")
@classmethod
def validate_max_file_size(cls, v: int) -> int:
"""Validate max file size (max 500MB)"""
if v > 500 * 1024 * 1024:
return 500 * 1024 * 1024
return v
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
extra = "allow"
@@ -47,3 +74,7 @@ class Settings(BaseSettings):
def get_settings() -> Settings:
"""Get cached settings"""
return Settings()
# Create global settings instance
settings = get_settings()

178
backend/app/core/crud.py Normal file
View File

@@ -0,0 +1,178 @@
"""
Database CRUD Operations
数据库通用 CRUD 操作
"""
from typing import Any, Generic, List, Optional, Type, TypeVar
from uuid import UUID
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload, joinedload
from app.core.exceptions import NotFoundException, DuplicateException
from app.core.logging import LoggerMixin
ModelType = TypeVar("ModelType")
class CRUDBase(Generic[ModelType], LoggerMixin):
"""Base CRUD class with common operations"""
def __init__(self, model: Type[ModelType]):
"""Initialize CRUD with model"""
self.model = model
async def get(
self,
db: AsyncSession,
id: UUID,
load_relations: Optional[List[str]] = None
) -> Optional[ModelType]:
"""Get single record by ID"""
query = select(self.model).where(self.model.id == id)
# Load relationships if specified
if load_relations:
for relation in load_relations:
if hasattr(self.model, relation):
query = query.options(selectinload(getattr(self.model, relation)))
result = await db.execute(query)
return result.scalar_one_or_none()
async def get_or_raise(
self,
db: AsyncSession,
id: UUID,
resource_name: str = "Resource",
load_relations: Optional[List[str]] = None
) -> ModelType:
"""Get single record by ID or raise NotFoundException"""
obj = await self.get(db, id, load_relations)
if not obj:
raise NotFoundException(resource_name, id)
return obj
async def get_multi(
self,
db: AsyncSession,
skip: int = 0,
limit: int = 20,
load_relations: Optional[List[str]] = None,
filters: Optional[dict] = None,
order_by: Optional[str] = "created_at",
descending: bool = True
) -> tuple[List[ModelType], int]:
"""Get multiple records with pagination"""
query = select(self.model)
count_query = select(func.count()).select_from(self.model)
# Apply filters
if filters:
conditions = []
for key, value in filters.items():
if hasattr(self.model, key):
conditions.append(getattr(self.model, key) == value)
if conditions:
query = query.where(and_(*conditions))
count_query = count_query.where(and_(*conditions))
# Load relationships if specified
if load_relations:
for relation in load_relations:
if hasattr(self.model, relation):
query = query.options(selectinload(getattr(self.model, relation)))
# Count total
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# Apply ordering
if order_by and hasattr(self.model, order_by):
order_column = getattr(self.model, order_by)
if descending:
query = query.order_by(order_column.desc())
else:
query = query.order_by(order_column.asc())
# Apply pagination
query = query.offset(skip).limit(limit)
result = await db.execute(query)
items = result.scalars().all()
return list(items), total
async def create(
self,
db: AsyncSession,
obj_in: Any,
commit: bool = True
) -> ModelType:
"""Create new record"""
obj_data = obj_in.model_dump() if hasattr(obj_in, 'model_dump') else obj_in.dict()
db_obj = self.model(**obj_data)
db.add(db_obj)
if commit:
await db.commit()
await db.refresh(db_obj)
self.log.debug(f"Created {self.model.__name__}: {db_obj.id}")
return db_obj
async def update(
self,
db: AsyncSession,
db_obj: ModelType,
obj_in: Any,
commit: bool = True
) -> ModelType:
"""Update existing record"""
if hasattr(obj_in, 'model_dump'):
obj_data = obj_in.model_dump(exclude_unset=True)
elif hasattr(obj_in, 'dict'):
obj_data = obj_in.dict(exclude_unset=True)
else:
obj_data = obj_in
for field, value in obj_data.items():
if hasattr(db_obj, field):
setattr(db_obj, field, value)
if commit:
await db.commit()
await db.refresh(db_obj)
self.log.debug(f"Updated {self.model.__name__}: {db_obj.id}")
return db_obj
async def delete(
self,
db: AsyncSession,
id: UUID,
commit: bool = True
) -> bool:
"""Delete record by ID"""
obj = await self.get(db, id)
if obj:
await db.delete(obj)
if commit:
await db.commit()
self.log.debug(f"Deleted {self.model.__name__}: {id}")
return True
return False
async def exists(
self,
db: AsyncSession,
filters: dict
) -> bool:
"""Check if record exists"""
query = select(func.count()).select_from(self.model)
for key, value in filters.items():
if hasattr(self.model, key):
query = query.where(getattr(self.model, key) == value)
result = await db.execute(query)
count = result.scalar() or 0
return count > 0

View File

@@ -2,25 +2,32 @@
Database Configuration and Session Management
支持 SQLite 和 PostgreSQL
"""
import logging
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy import create_engine
from sqlalchemy import create_engine, event
from sqlalchemy.pool import NullPool
from app.core.config import get_settings
logger = logging.getLogger(__name__)
settings = get_settings()
def get_engine_config():
"""根据数据库类型返回引擎配置"""
if settings.DATABASE_URL.startswith("sqlite"):
return {"echo": settings.DEBUG}
return {"echo": settings.DEBUG, "poolclass": NullPool}
else:
return {
"echo": settings.DEBUG,
"pool_pre_ping": True,
"pool_size": 10,
"max_overflow": 20,
"pool_recycle": 3600,
"pool_timeout": 30,
}
@@ -30,14 +37,14 @@ async_engine = create_async_engine(
**get_engine_config()
)
# Sync engine for migrations
# Sync engine for migrations (use NullPool for SQLite)
sync_engine = create_engine(
settings.DATABASE_URL_SYNC,
echo=settings.DEBUG,
pool_pre_ping=True,
poolclass=NullPool if settings.DATABASE_URL_SYNC.startswith("sqlite") else None,
)
# Async session factory
AsyncSessionLocal = async_sessionmaker(
async_engine,
@@ -55,8 +62,31 @@ class Base(DeclarativeBase):
async def init_db():
"""Initialize database tables"""
logger.info("Initializing database...")
async with async_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("Database initialized successfully")
async def close_db():
"""Close database connections"""
logger.info("Closing database connections...")
await async_engine.dispose()
logger.info("Database connections closed")
@asynccontextmanager
async def get_db_session() -> AsyncGenerator[AsyncSession, None]:
"""Context manager for database sessions with automatic cleanup"""
session = AsyncSessionLocal()
try:
yield session
except Exception as e:
logger.error(f"Database session error: {str(e)}")
await session.rollback()
raise
finally:
await session.close()
async def get_db() -> AsyncSession:
@@ -64,5 +94,9 @@ async def get_db() -> AsyncSession:
async with AsyncSessionLocal() as session:
try:
yield session
except Exception as e:
logger.error(f"Database error in dependency: {str(e)}")
await session.rollback()
raise
finally:
await session.close()

View File

@@ -0,0 +1,119 @@
"""
Custom Exceptions
自定义异常类
"""
from typing import Any, Optional
class AppException(Exception):
"""基础应用异常"""
def __init__(
self,
message: str,
code: str = "INTERNAL_ERROR",
status_code: int = 500,
details: Optional[dict] = None
):
self.message = message
self.code = code
self.status_code = status_code
self.details = details
super().__init__(self.message)
class NotFoundException(AppException):
"""资源未找到异常"""
def __init__(self, resource: str, resource_id: Any = None):
message = f"{resource} not found"
if resource_id:
message = f"{resource} with id '{resource_id}' not found"
super().__init__(
message=message,
code="NOT_FOUND",
status_code=404
)
class ValidationException(AppException):
"""验证异常"""
def __init__(self, message: str, field: str = None, details: dict = None):
super().__init__(
message=message,
code="VALIDATION_ERROR",
status_code=422,
details={"field": field, **(details or {})}
)
class DuplicateException(AppException):
"""重复资源异常"""
def __init__(self, resource: str, field: str = None):
message = f"{resource} already exists"
if field:
message = f"{resource} with {field} already exists"
super().__init__(
message=message,
code="DUPLICATE",
status_code=409
)
class UnauthorizedException(AppException):
"""未授权异常"""
def __init__(self, message: str = "Unauthorized"):
super().__init__(
message=message,
code="UNAUTHORIZED",
status_code=401
)
class ForbiddenException(AppException):
"""禁止访问异常"""
def __init__(self, message: str = "Forbidden"):
super().__init__(
message=message,
code="FORBIDDEN",
status_code=403
)
class RateLimitException(AppException):
"""速率限制异常"""
def __init__(self, message: str = "Rate limit exceeded"):
super().__init__(
message=message,
code="RATE_LIMIT",
status_code=429
)
class FileProcessingException(AppException):
"""文件处理异常"""
def __init__(self, message: str, file_name: str = None):
details = {"file_name": file_name} if file_name else None
super().__init__(
message=message,
code="FILE_PROCESSING_ERROR",
status_code=422,
details=details
)
class DatabaseException(AppException):
"""数据库异常"""
def __init__(self, message: str = "Database operation failed"):
super().__init__(
message=message,
code="DATABASE_ERROR",
status_code=500
)

View File

@@ -0,0 +1,66 @@
"""
Logging Configuration
日志配置
"""
import logging
import sys
from typing import Any
from logging.handlers import RotatingFileHandler
from pathlib import Path
from app.core.config import get_settings
settings = get_settings()
# Log directory
LOG_DIR = Path("./logs")
LOG_DIR.mkdir(exist_ok=True)
def setup_logging(name: str = "yg_dataset") -> logging.Logger:
"""Setup application logging"""
logger = logging.getLogger(name)
logger.setLevel(logging.DEBUG if settings.DEBUG else logging.INFO)
# Avoid duplicate handlers
if logger.handlers:
return logger
# Console handler
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(logging.DEBUG if settings.DEBUG else logging.INFO)
console_formatter = logging.Formatter(
fmt="%(asctime)s | %(levelname)-8s | %(name)s:%(funcName)s:%(lineno)d | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S"
)
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,
encoding="utf-8"
)
file_handler.setLevel(logging.INFO)
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)
return logger
# Create default logger
logger = setup_logging()
class LoggerMixin:
"""Mixin to add logging capability to classes"""
@property
def log(self) -> logging.Logger:
"""Get logger for this class"""
return logging.getLogger(self.__class__.__module__ + "." + self.__class__.__name__)

View File

@@ -3,23 +3,71 @@ YG-Dataset Backend Application
FastAPI-based API server for dataset generation platform
"""
import logging
import time
import uuid
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError
from starlette.middleware.base import BaseHTTPMiddleware
from sqlalchemy.exc import SQLAlchemyError
from app.api.v1 import api_router
from app.api.response import ApiResponse
from app.core.config import settings
from app.core.database import init_db
from app.core.database import init_db, close_db
from app.core.exceptions import AppException
from app.core.logging import logger
class RequestIDMiddleware(BaseHTTPMiddleware):
"""Middleware to add request ID to each request"""
async def dispatch(self, request: Request, call_next):
request_id = str(uuid.uuid4())
request.state.request_id = request_id
# Add request ID to response headers
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return response
class TimingMiddleware(BaseHTTPMiddleware):
"""Middleware to measure request processing time"""
async def dispatch(self, request: Request, call_next):
start_time = time.time()
# Log request
logger.info(f"{request.method} {request.url.path}")
response = await call_next(request)
process_time = time.time() - start_time
response.headers["X-Process-Time"] = str(process_time)
# Log response
logger.info(f"{request.method} {request.url.path} | Status: {response.status_code} | Time: {process_time:.3f}s")
return response
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan events"""
# Startup
logger.info("Starting YG-Dataset application...")
await init_db()
logger.info("Database initialized successfully")
yield
# Shutdown
pass
logger.info("Shutting down YG-Dataset application...")
await close_db()
logger.info("Database connections closed")
app = FastAPI(
@@ -29,15 +77,83 @@ app = FastAPI(
lifespan=lifespan,
)
# CORS
# Add custom middleware (order matters: last added = first executed)
app.add_middleware(TimingMiddleware)
app.add_middleware(RequestIDMiddleware)
# CORS - Configure properly for production
# For development, you can use ["*"] but for production, specify exact origins
ALLOWED_ORIGINS = settings.ALLOWED_ORIGINS.split(",") if settings.ALLOWED_ORIGINS else ["*"]
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_origins=ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["*"],
)
# Exception handlers
@app.exception_handler(AppException)
async def app_exception_handler(request: Request, exc: AppException):
"""Handle custom application exceptions"""
logger.warning(f"App exception: {exc.message} | Code: {exc.code}")
return JSONResponse(
status_code=exc.status_code,
content=ApiResponse.fail(
message=exc.message,
error={"code": exc.code, "details": exc.details}
).model_dump()
)
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(request: Request, exc: RequestValidationError):
"""Handle validation exceptions"""
errors = []
for error in exc.errors():
errors.append({
"field": ".".join(str(loc) for loc in error["loc"]),
"message": error["msg"],
"type": error["type"]
})
logger.warning(f"Validation error: {errors}")
return JSONResponse(
status_code=422,
content=ApiResponse.fail(
message="Validation error",
error={"code": "VALIDATION_ERROR", "details": {"errors": errors}}
).model_dump()
)
@app.exception_handler(SQLAlchemyError)
async def database_exception_handler(request: Request, exc: SQLAlchemyError):
"""Handle database exceptions"""
logger.error(f"Database error: {str(exc)}", exc_info=True)
return JSONResponse(
status_code=500,
content=ApiResponse.fail(
message="Database operation failed",
error={"code": "DATABASE_ERROR"}
).model_dump()
)
@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
"""Handle unhandled exceptions"""
logger.error(f"Unhandled exception: {str(exc)}", exc_info=True)
return JSONResponse(
status_code=500,
content=ApiResponse.fail(
message="Internal server error",
error={"code": "INTERNAL_ERROR"}
).model_dump()
)
# Include API routes
app.include_router(api_router, prefix="/api/v1")
@@ -45,7 +161,10 @@ app.include_router(api_router, prefix="/api/v1")
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {"status": "healthy", "version": "1.0.0"}
return ApiResponse.ok(
data={"status": "healthy", "version": "1.0.0"},
message="Service is running"
)
if __name__ == "__main__":

View File

@@ -51,7 +51,7 @@ class Chunk(Base, UUIDMixin, TimestampMixin):
content = Column(Text, nullable=False)
summary = Column(Text)
word_count = Column(Integer)
metadata = Column(JSON) # store additional info like headings, page numbers
extra_data = Column(JSON) # store additional info like headings, page numbers
# Relationships
project = relationship("Project", back_populates="chunks")
@@ -112,7 +112,7 @@ class Dataset(Base, UUIDMixin, TimestampMixin):
name = Column(String(255), nullable=False)
description = Column(Text)
dataset_type = Column(String(50)) # qa, conversation, instruction
metadata = Column(JSON)
extra_data = Column(JSON)
# Relationships
project = relationship("Project", back_populates="datasets")
@@ -125,7 +125,7 @@ class EvalDataset(Base, UUIDMixin, TimestampMixin):
project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
name = Column(String(255), nullable=False)
question_type = Column(String(50)) # mixed, fact, reasoning
metadata = Column(JSON)
extra_data = Column(JSON)
# Relationships
project = relationship("Project", back_populates="eval_datasets")

View File

@@ -1,3 +1,89 @@
"""
Pydantic Schemas
"""
from app.schemas.base import (
TimestampMixin,
UUIDMixin,
)
from app.schemas.project import (
ProjectBase,
ProjectCreate,
ProjectUpdate,
ProjectResponse,
)
from app.schemas.file import (
FileBase,
FileCreate,
FileUpdate,
FileResponse,
)
from app.schemas.chunk import (
ChunkBase,
ChunkCreate,
ChunkUpdate,
ChunkResponse,
)
from app.schemas.question import (
QuestionBase,
QuestionCreate,
QuestionUpdate,
QuestionResponse,
)
from app.schemas.dataset import (
DatasetBase,
DatasetCreate,
DatasetUpdate,
DatasetResponse,
)
from app.schemas.eval import (
EvalDatasetBase,
EvalDatasetCreate,
EvalDatasetUpdate,
EvalDatasetResponse,
TaskBase,
TaskResponse,
)
__all__ = [
# Base
"TimestampMixin",
"UUIDMixin",
# Project
"ProjectBase",
"ProjectCreate",
"ProjectUpdate",
"ProjectResponse",
# File
"FileBase",
"FileCreate",
"FileUpdate",
"FileResponse",
# Chunk
"ChunkBase",
"ChunkCreate",
"ChunkUpdate",
"ChunkResponse",
# Question
"QuestionBase",
"QuestionCreate",
"QuestionUpdate",
"QuestionResponse",
# Dataset
"DatasetBase",
"DatasetCreate",
"DatasetUpdate",
"DatasetResponse",
# Eval
"EvalDatasetBase",
"EvalDatasetCreate",
"EvalDatasetUpdate",
"EvalDatasetResponse",
"TaskBase",
"TaskResponse",
]

View File

@@ -4,7 +4,7 @@ Base Pydantic schemas
from datetime import datetime
from typing import Optional, Any
from uuid import UUID
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
class TimestampMixin(BaseModel):
@@ -18,153 +18,3 @@ class UUIDMixin(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: UUID
class ProjectBase(BaseModel):
"""Base project schema"""
name: str
description: Optional[str] = None
class ProjectCreate(ProjectBase):
"""Project create schema"""
pass
class ProjectUpdate(ProjectBase):
"""Project update schema"""
pass
class ProjectResponse(ProjectBase, UUIDMixin, TimestampMixin):
"""Project response schema"""
pass
class FileBase(BaseModel):
"""Base file schema"""
filename: str
file_type: str
size: Optional[int] = None
class FileResponse(FileBase, UUIDMixin, TimestampMixin):
"""File response schema"""
status: str
class ChunkBase(BaseModel):
"""Base chunk schema"""
name: Optional[str] = None
content: str
summary: Optional[str] = None
word_count: Optional[int] = None
class ChunkCreate(ChunkBase):
"""Chunk create schema"""
file_id: Optional[UUID] = None
class ChunkResponse(ChunkBase, UUIDMixin, TimestampMixin):
"""Chunk response schema"""
pass
class QuestionBase(BaseModel):
"""Base question schema"""
content: str
answer: Optional[str] = None
question_type: Optional[str] = None
class QuestionCreate(QuestionBase):
"""Question create schema"""
chunk_id: Optional[UUID] = None
class QuestionResponse(QuestionBase, UUIDMixin, TimestampMixin):
"""Question response schema"""
source: str
class DatasetBase(BaseModel):
"""Base dataset schema"""
name: str
description: Optional[str] = None
dataset_type: Optional[str] = None
class DatasetCreate(DatasetBase):
"""Dataset create schema"""
pass
class DatasetResponse(DatasetBase, UUIDMixin, TimestampMixin):
"""Dataset response schema"""
question_count: Optional[int] = None
class EvalDatasetBase(BaseModel):
"""Base eval dataset schema"""
name: str
question_type: Optional[str] = None
class EvalDatasetCreate(EvalDatasetBase):
"""Eval dataset create schema"""
pass
class EvalDatasetResponse(EvalDatasetBase, UUIDMixin, TimestampMixin):
"""Eval dataset response schema"""
pass
class TagBase(BaseModel):
"""Base tag schema"""
label: str
parent_id: Optional[UUID] = None
color: Optional[str] = None
class TagCreate(TagBase):
"""Tag create schema"""
pass
class TagResponse(TagBase, UUIDMixin, TimestampMixin):
"""Tag response schema"""
pass
class ModelConfigBase(BaseModel):
"""Base model config schema"""
provider: str
model_name: Optional[str] = None
api_key: Optional[str] = None
api_base: Optional[str] = None
is_default: Optional[str] = "false"
class ModelConfigCreate(ModelConfigBase):
"""Model config create schema"""
pass
class ModelConfigResponse(ModelConfigBase, UUIDMixin, TimestampMixin):
"""Model config response schema"""
pass
class TaskBase(BaseModel):
"""Base task schema"""
task_type: str
status: Optional[str] = "pending"
progress: Optional[int] = 0
class TaskResponse(TaskBase, UUIDMixin, TimestampMixin):
"""Task response schema"""
result: Optional[Any] = None
error: Optional[str] = None

View File

@@ -0,0 +1,46 @@
"""
Chunk Schemas
"""
from datetime import datetime
from typing import Optional, Any
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
class ChunkBase(BaseModel):
"""Base chunk schema"""
name: Optional[str] = Field(None, max_length=255)
content: str = Field(..., min_length=1)
summary: Optional[str] = None
word_count: Optional[int] = None
extra_data: Optional[dict] = None
class ChunkCreate(ChunkBase):
"""Chunk create schema"""
project_id: Optional[UUID] = None
file_id: Optional[UUID] = None
class ChunkUpdate(BaseModel):
"""Chunk update schema"""
name: Optional[str] = Field(None, max_length=255)
content: Optional[str] = Field(None, min_length=1)
summary: Optional[str] = None
extra_data: Optional[dict] = None
class ChunkResponse(ChunkBase):
"""Chunk response schema"""
model_config = ConfigDict(from_attributes=True)
id: UUID
project_id: UUID
file_id: Optional[UUID]
created_at: datetime
updated_at: datetime
# Alias for CRUD
ChunkCreateSchema = ChunkCreate
ChunkUpdateSchema = ChunkUpdate

View File

@@ -0,0 +1,43 @@
"""
Dataset Schemas
"""
from datetime import datetime
from typing import Optional, Any
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
class DatasetBase(BaseModel):
"""Base dataset schema"""
name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=2000)
dataset_type: Optional[str] = Field(None, max_length=50)
extra_data: Optional[dict] = None
class DatasetCreate(DatasetBase):
"""Dataset create schema"""
pass
class DatasetUpdate(BaseModel):
"""Dataset update schema"""
name: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=2000)
dataset_type: Optional[str] = Field(None, max_length=50)
extra_data: Optional[dict] = None
class DatasetResponse(DatasetBase):
"""Dataset response schema"""
model_config = ConfigDict(from_attributes=True)
id: UUID
project_id: UUID
created_at: datetime
updated_at: datetime
# Alias for CRUD
DatasetCreateSchema = DatasetCreate
DatasetUpdateSchema = DatasetUpdate

View File

@@ -0,0 +1,60 @@
"""
Evaluation Dataset Schemas
"""
from datetime import datetime
from typing import Optional, Any
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
class EvalDatasetBase(BaseModel):
"""Base eval dataset schema"""
name: str = Field(..., min_length=1, max_length=255)
question_type: Optional[str] = Field("mixed", max_length=50)
extra_data: Optional[dict] = None
class EvalDatasetCreate(EvalDatasetBase):
"""Eval dataset create schema"""
pass
class EvalDatasetUpdate(BaseModel):
"""Eval dataset update schema"""
name: Optional[str] = Field(None, min_length=1, max_length=255)
question_type: Optional[str] = Field(None, max_length=50)
extra_data: Optional[dict] = None
class EvalDatasetResponse(EvalDatasetBase):
"""Eval dataset response schema"""
model_config = ConfigDict(from_attributes=True)
id: UUID
project_id: UUID
created_at: datetime
updated_at: datetime
class TaskBase(BaseModel):
"""Base task schema"""
task_type: str = Field(..., max_length=50)
status: Optional[str] = "pending"
progress: Optional[int] = Field(0, ge=0, le=100)
result: Optional[Any] = None
error: Optional[str] = None
class TaskResponse(TaskBase):
"""Task response schema"""
model_config = ConfigDict(from_attributes=True)
id: UUID
project_id: Optional[UUID]
created_at: datetime
updated_at: datetime
# Alias for CRUD
EvalDatasetCreateSchema = EvalDatasetCreate
EvalDatasetUpdateSchema = EvalDatasetUpdate

View File

@@ -0,0 +1,43 @@
"""
File Schemas
"""
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
class FileBase(BaseModel):
"""Base file schema"""
filename: str = Field(..., min_length=1, max_length=255)
file_type: str = Field(..., max_length=50)
size: Optional[int] = None
class FileCreate(FileBase):
"""File create schema"""
project_id: UUID
file_path: Optional[str] = None
status: str = "pending"
class FileUpdate(BaseModel):
"""File update schema"""
status: Optional[str] = None
class FileResponse(FileBase):
"""File response schema"""
model_config = ConfigDict(from_attributes=True)
id: UUID
project_id: UUID
file_path: Optional[str]
status: str
created_at: datetime
updated_at: datetime
# Alias for CRUD
FileCreateSchema = FileCreate
FileUpdateSchema = FileUpdate

View File

@@ -0,0 +1,38 @@
"""
Project Schemas
"""
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
class ProjectBase(BaseModel):
"""Base project schema"""
name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = Field(None, max_length=2000)
class ProjectCreate(ProjectBase):
"""Project create schema"""
pass
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)
class ProjectResponse(ProjectBase):
"""Project response schema"""
model_config = ConfigDict(from_attributes=True)
id: UUID
created_at: datetime
updated_at: datetime
# Alias for CRUD
ProjectCreateSchema = ProjectCreate
ProjectUpdateSchema = ProjectUpdate

View File

@@ -0,0 +1,43 @@
"""
Question Schemas
"""
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
class QuestionBase(BaseModel):
"""Base question schema"""
content: str = Field(..., min_length=1)
answer: Optional[str] = None
question_type: Optional[str] = Field(None, max_length=50)
source: Optional[str] = "manual"
class QuestionCreate(QuestionBase):
"""Question create schema"""
chunk_id: Optional[UUID] = None
class QuestionUpdate(BaseModel):
"""Question update schema"""
content: Optional[str] = Field(None, min_length=1)
answer: Optional[str] = None
question_type: Optional[str] = Field(None, max_length=50)
class QuestionResponse(QuestionBase):
"""Question response schema"""
model_config = ConfigDict(from_attributes=True)
id: UUID
project_id: UUID
chunk_id: Optional[UUID]
created_at: datetime
updated_at: datetime
# Alias for CRUD
QuestionCreateSchema = QuestionCreate
QuestionUpdateSchema = QuestionUpdate

View File

@@ -1,8 +1,9 @@
"""
DOCX Text Extractor
"""
import asyncio
from typing import Dict
from docx import Document
from typing import Dict, List
class DOCXProcessor:
@@ -26,6 +27,12 @@ class DOCXProcessor:
return "\n\n".join(text_parts)
async def extract_text_async(self, file_path: str) -> str:
"""Extract all text from DOCX asynchronously"""
return await asyncio.get_event_loop().run_in_executor(
None, self.extract_text, file_path
)
def extract_with_metadata(self, file_path: str) -> Dict:
"""Extract text with DOCX metadata"""
doc = Document(file_path)
@@ -46,8 +53,14 @@ class DOCXProcessor:
return result
async def extract_with_metadata_async(self, file_path: str) -> Dict:
"""Extract with metadata asynchronously"""
return await asyncio.get_event_loop().run_in_executor(
None, self.extract_with_metadata, file_path
)
def process_docx(file_path: str) -> str:
async def process_docx(file_path: str) -> str:
"""Process DOCX file and return text"""
processor = DOCXProcessor()
return processor.extract_text(file_path)
return await processor.extract_text_async(file_path)

View File

@@ -1,8 +1,9 @@
"""
Excel/CSV Text Extractor
"""
import pandas as pd
import asyncio
from typing import Dict, List
import pandas as pd
class ExcelProcessor:
@@ -13,6 +14,12 @@ class ExcelProcessor:
df = pd.read_csv(file_path)
return self._dataframe_to_text(df)
async def extract_csv_async(self, file_path: str) -> str:
"""Extract CSV asynchronously"""
return await asyncio.get_event_loop().run_in_executor(
None, self.extract_csv, file_path
)
def extract_excel(self, file_path: str, sheet_name: str = None) -> str:
"""Extract text from Excel file"""
if sheet_name:
@@ -27,6 +34,12 @@ class ExcelProcessor:
text_parts.append(self._dataframe_to_text(df))
return "\n\n".join(text_parts)
async def extract_excel_async(self, file_path: str, sheet_name: str = None) -> str:
"""Extract Excel asynchronously"""
return await asyncio.get_event_loop().run_in_executor(
None, self.extract_excel, file_path, sheet_name
)
def _dataframe_to_text(self, df: pd.DataFrame) -> str:
"""Convert DataFrame to readable text"""
text_parts = []
@@ -48,19 +61,25 @@ class ExcelProcessor:
sheets = pd.read_excel(file_path, sheet_name=None)
return {name: self._dataframe_to_text(df) for name, df in sheets.items()}
async def extract_all_sheets_async(self, file_path: str) -> Dict[str, str]:
"""Extract all sheets asynchronously"""
return await asyncio.get_event_loop().run_in_executor(
None, self.extract_all_sheets, file_path
)
def get_sheet_names(self, file_path: str) -> List[str]:
"""Get all sheet names from Excel file"""
xl = pd.ExcelFile(file_path)
return xl.sheet_names
def process_csv(file_path: str) -> str:
async def process_csv(file_path: str) -> str:
"""Process CSV file and return text"""
processor = ExcelProcessor()
return processor.extract_csv(file_path)
return await processor.extract_csv_async(file_path)
def process_excel(file_path: str) -> str:
async def process_excel(file_path: str) -> str:
"""Process Excel file and return text"""
processor = ExcelProcessor()
return processor.extract_excel(file_path)
return await processor.extract_excel_async(file_path)

View File

@@ -1,8 +1,9 @@
"""
PDF Text Extractor
"""
import asyncio
from typing import Dict, List
import pdfplumber
from typing import Dict, List, Optional
class PDFProcessor:
@@ -20,6 +21,12 @@ class PDFProcessor:
return "\n\n".join(text_parts)
async def extract_text_async(self, file_path: str) -> str:
"""Extract all text from PDF asynchronously"""
return await asyncio.get_event_loop().run_in_executor(
None, self.extract_text, file_path
)
def extract_pages(self, file_path: str) -> List[Dict]:
"""Extract text page by page with metadata"""
pages = []
@@ -36,6 +43,12 @@ class PDFProcessor:
return pages
async def extract_pages_async(self, file_path: str) -> List[Dict]:
"""Extract pages asynchronously"""
return await asyncio.get_event_loop().run_in_executor(
None, self.extract_pages, file_path
)
def extract_with_metadata(self, file_path: str) -> Dict:
"""Extract text with PDF metadata"""
result = {
@@ -58,8 +71,14 @@ class PDFProcessor:
return result
async def extract_with_metadata_async(self, file_path: str) -> Dict:
"""Extract with metadata asynchronously"""
return await asyncio.get_event_loop().run_in_executor(
None, self.extract_with_metadata, file_path
)
def process_pdf(file_path: str) -> str:
async def process_pdf(file_path: str) -> str:
"""Process PDF file and return text"""
processor = PDFProcessor()
return processor.extract_with_metadata(file_path)["text"]
return await processor.extract_with_metadata_async(file_path)

125
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,125 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "yg-dataset"
version = "1.0.0"
description = "Dataset Generation Platform API"
readme = "README.md"
requires-python = ">=3.11"
license = {text = "MIT"}
authors = [
{name = "YG-Dataset Team", email = "team@yg-dataset.com"}
]
keywords = ["dataset", "machine-learning", "llm", "data-generation"]
classifiers = [
"Development Status :: 4 - Beta",
"Framework :: FastAPI",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.30.0",
"python-multipart>=0.0.9",
"sqlalchemy>=2.0.0",
"alembic>=1.13.0",
"pydantic>=2.0.0",
"pydantic-settings>=2.0.0",
"pdfplumber>=0.10.4",
"python-docx>=1.1.0",
"openpyxl>=3.1.2",
"pandas>=2.2.0",
"ebooklib>=0.5",
"PyMuPDF>=1.24.0",
"langchain>=0.3.0",
"langchain-community>=0.2.0",
"langchain-openai>=0.1.0",
"tiktoken>=0.7.0",
"python-dotenv>=1.0.0",
"python-dateutil>=2.8.2",
"httpx>=0.27.0",
"aiofiles>=23.2.1",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"pytest-cov>=4.1.0",
"ruff>=0.3.0",
"mypy>=1.8.0",
"pre-commit>=3.6.0",
"black>=24.2.0",
]
[tool.uv]
dev-dependencies = [
"pytest>=8.0.0",
"pytest-asyncio>=0.23.0",
"pytest-cov>=4.1.0",
"ruff>=0.3.0",
"mypy>=1.8.0",
]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"UP", # pyupgrade
]
ignore = [
"E501", # line too long (handled by formatter)
"B008", # do not perform function calls in argument defaults
]
[tool.mypy]
python_version = "3.11"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = false
disallow_incomplete_defs = false
check_untyped_defs = true
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = "-v --tb=short"
[tool.coverage.run]
source = ["app"]
omit = [
"*/tests/*",
"*/venv/*",
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"raise AssertionError",
"raise NotImplementedError",
"if __name__ == .__main__.:",
]
[project.scripts]
yg-dataset = "app.main:main"

File diff suppressed because it is too large Load Diff

View File

@@ -8,17 +8,21 @@
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.0",
"@vueuse/core": "^11.0.0",
"axios": "^1.7.0",
"element-plus": "^2.8.0",
"pinia": "^2.2.0",
"vue": "^3.5.0",
"vue-router": "^4.4.0",
"pinia": "^2.2.0",
"element-plus": "^2.8.0",
"@element-plus/icons-vue": "^2.3.0",
"axios": "^1.7.0",
"@vueuse/core": "^11.0.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^6.0.0"
"@vitejs/plugin-vue": "^5.2.4",
"sass": "^1.77.0",
"sass-embedded": "^1.98.0",
"typescript": "^5.9.3",
"vite": "^6.0.0",
"vue-tsc": "^3.2.5"
}
}

View File

@@ -1,81 +0,0 @@
import axios from 'axios'
const request = axios.create({
baseURL: '/api/v1',
timeout: 60000
})
// Request interceptor
request.interceptors.request.use(
config => {
return config
},
error => {
return Promise.reject(error)
}
)
// Response interceptor
request.interceptors.response.use(
response => {
return response.data
},
error => {
const message = error.response?.data?.message || error.message || '请求失败'
console.error('API Error:', message)
return Promise.reject(error)
}
)
export const projectApi = {
list: () => request.get('/projects/'),
get: (id) => request.get(`/projects/${id}`),
create: (data) => request.post('/projects/', data),
update: (id, data) => request.put(`/projects/${id}`, data),
delete: (id) => request.delete(`/projects/${id}`)
}
export const fileApi = {
upload: (projectId, formData) =>
request.post(`/projects/${projectId}/files/upload`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
}),
list: (projectId) => request.get(`/projects/${projectId}/files/`),
get: (projectId, fileId) => request.get(`/projects/${projectId}/files/${fileId}`),
delete: (projectId, fileId) => request.delete(`/projects/${projectId}/files/${fileId}`)
}
export const chunkApi = {
split: (projectId, data) => request.post(`/projects/${projectId}/chunks/split`, data),
list: (projectId, params) => request.get(`/projects/${projectId}/chunks/`, { params }),
get: (projectId, chunkId) => request.get(`/projects/${projectId}/chunks/${chunkId}`),
update: (projectId, chunkId, data) => request.put(`/projects/${projectId}/chunks/${chunkId}`, data),
delete: (projectId, chunkId) => request.delete(`/projects/${projectId}/chunks/${chunkId}`)
}
export const questionApi = {
generate: (projectId, data) => request.post(`/projects/${projectId}/generate-questions`, data),
list: (projectId, params) => request.get(`/projects/${projectId}/chunks/${params.chunkId}/questions`),
update: (projectId, questionId, data) => request.put(`/projects/${projectId}/questions/${questionId}`, data),
delete: (projectId, questionId) => request.delete(`/projects/${projectId}/questions/${questionId}`)
}
export const datasetApi = {
list: (projectId) => request.get(`/projects/${projectId}/datasets/`),
create: (projectId, data) => request.post(`/projects/${projectId}/datasets/`, data),
get: (projectId, datasetId) => request.get(`/projects/${projectId}/datasets/${datasetId}`),
delete: (projectId, datasetId) => request.delete(`/projects/${projectId}/datasets/${datasetId}`),
export: (projectId, datasetId, data) =>
request.post(`/projects/${projectId}/datasets/${datasetId}/export`, data, {
responseType: 'blob'
})
}
export const evalApi = {
list: (projectId) => request.get(`/projects/${projectId}/eval-datasets/`),
create: (projectId, data) => request.post(`/projects/${projectId}/eval-datasets/`, data),
run: (projectId, evalId) => request.post(`/projects/${projectId}/eval-datasets/${evalId}/evaluate`),
getResults: (projectId, taskId) => request.get(`/projects/${projectId}/eval-tasks/${taskId}`)
}
export default request

94
frontend/src/api/index.ts Normal file
View File

@@ -0,0 +1,94 @@
import axios from 'axios'
import type { AxiosInstance } from 'axios'
import type { Project, ProjectCreate, ProjectUpdate } from '@/types'
const request: AxiosInstance = axios.create({
baseURL: import.meta.env.PROD
? '/api/v1'
: 'http://10.10.10.77:8000/api/v1',
timeout: 60000
})
// Request interceptor
request.interceptors.request.use(
config => {
return config
},
error => {
return Promise.reject(error)
}
)
// Response interceptor
request.interceptors.response.use(
response => {
const data = response.data
// Handle new ApiResponse format
if (data.success !== undefined) {
if (data.success) {
return data.data // Return the actual data
} else {
return Promise.reject(new Error(data.message || data.error || '请求失败'))
}
}
return data
},
error => {
const message = error.response?.data?.message || error.message || '请求失败'
console.error('API Error:', message)
return Promise.reject(error)
}
)
export const projectApi = {
list: () => request.get<Project[]>('/projects/'),
get: (id: string) => request.get<Project>(`/projects/${id}`),
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}`)
}
export const fileApi = {
upload: (projectId: string, formData: FormData) =>
request.post(`/projects/${projectId}/files/upload`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
}),
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 }),
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}`)
}
export const questionApi = {
generate: (projectId: string, data: any) => request.post(`/projects/${projectId}/generate-questions`, data),
list: (projectId: string, params: { chunkId: string }) => request.get(`/projects/${projectId}/chunks/${params.chunkId}/questions`),
update: (projectId: string, questionId: string, data: any) => request.put(`/projects/${projectId}/questions/${questionId}`, data),
delete: (projectId: string, questionId: string) => request.delete(`/projects/${projectId}/questions/${questionId}`)
}
export const datasetApi = {
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) =>
request.post(`/projects/${projectId}/datasets/${datasetId}/export`, data, {
responseType: 'blob'
})
}
export const evalApi = {
list: (projectId: string) => request.get(`/projects/${projectId}/eval-datasets/`),
create: (projectId: string, data: any) => request.post(`/projects/${projectId}/eval-datasets/`, data),
run: (projectId: string, evalId: string) => request.post(`/projects/${projectId}/eval-datasets/${evalId}/evaluate`),
getResults: (projectId: string, taskId: string) => request.get(`/projects/${projectId}/eval-tasks/${taskId}`)
}
export default request

View File

@@ -0,0 +1,423 @@
<template>
<el-dialog
:model-value="visible"
title=""
width="480px"
class="create-dialog"
:show-close="false"
align-center
@update:model-value="$emit('update:visible', $event)"
>
<template #header>
<div class="dialog-header">
<div class="header-glow"></div>
<div class="header-content">
<div class="dialog-icon-new">
<el-icon size="24"><FolderAdd /></el-icon>
</div>
<div class="header-text">
<h3>创建新项目</h3>
<p>开始构建您的AI训练数据集</p>
</div>
</div>
<button class="close-btn" @click="handleClose">
<el-icon><Close /></el-icon>
</button>
</div>
</template>
<div class="dialog-body">
<div class="form-card">
<div class="input-group">
<label class="input-label">
<span class="label-icon"></span>
项目名称
</label>
<el-input
v-model="formData.name"
placeholder="例如:客服问答数据集"
size="large"
class="custom-input"
/>
</div>
<div class="input-group">
<label class="input-label">
<span class="label-icon"></span>
项目描述
</label>
<el-input
v-model="formData.description"
type="textarea"
:rows="3"
placeholder="描述这个项目的用途和数据类型..."
class="custom-input"
/>
</div>
</div>
<!-- Quick Templates -->
<div class="templates-section">
<span class="templates-label">快速开始模板</span>
<div class="templates-grid">
<div class="template-card" @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>
<div class="template-card" @click="useTemplate('instruction')">
<el-icon><Promotion /></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"
@click="handleSubmit"
class="btn-create"
>
<el-icon v-if="!loading"><Plus /></el-icon>
创建项目
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { reactive, watch } from 'vue'
const props = defineProps({
visible: {
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:visible', 'submit'])
const formData = reactive({
name: '',
description: ''
})
const templates = {
qa: { name: '问答数据集', description: '基于文档生成问答对训练数据' },
conversation: { name: '对话数据集', description: '创建多轮对话训练数据' },
instruction: { name: '指令数据集', description: '构建指令跟随训练数据' }
}
const useTemplate = (type) => {
const t = templates[type]
formData.name = t.name
formData.description = t.description
}
const handleClose = () => {
emit('update:visible', false)
}
const handleSubmit = () => {
emit('submit', { ...formData })
}
// Reset form when dialog opens
watch(() => props.visible, (newVal) => {
if (newVal) {
formData.name = ''
formData.description = ''
}
})
</script>
<style scoped>
.create-dialog :deep(.el-dialog) {
border-radius: 20px;
background: linear-gradient(145deg, rgba(20, 20, 30, 0.98) 0%, rgba(15, 15, 25, 0.98) 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow:
0 25px 50px -12px rgba(0, 0, 0, 0.6),
0 0 40px rgba(0, 212, 255, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
overflow: hidden;
}
.create-dialog :deep(.el-dialog__header) {
padding: 0;
margin: 0;
}
.dialog-header {
position: relative;
padding: 24px 28px;
background: linear-gradient(135deg, rgba(0, 212, 255, 0.08) 0%, rgba(124, 58, 237, 0.08) 100%);
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
overflow: hidden;
}
.header-glow {
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle at 30% 50%, rgba(0, 212, 255, 0.15) 0%, transparent 50%);
animation: headerGlow 4s ease-in-out infinite;
}
@keyframes headerGlow {
0%, 100% { opacity: 0.5; transform: scale(1); }
50% { opacity: 0.8; transform: scale(1.1); }
}
.header-content {
position: relative;
display: flex;
align-items: center;
gap: 16px;
}
.dialog-icon-new {
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(0, 212, 255, 0.2) 0%, rgba(124, 58, 237, 0.2) 100%);
border: 1px solid rgba(0, 212, 255, 0.3);
border-radius: 14px;
color: var(--accent-primary);
animation: iconPulse 2s ease-in-out infinite;
}
@keyframes iconPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(0, 212, 255, 0.4); }
50% { box-shadow: 0 0 20px 5px rgba(0, 212, 255, 0.2); }
}
.header-text h3 {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 4px 0;
}
.header-text p {
font-size: 13px;
color: var(--text-tertiary);
margin: 0;
}
.close-btn {
position: absolute;
top: 16px;
right: 16px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
color: var(--text-muted);
cursor: pointer;
transition: all 0.2s ease;
}
.close-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
border-color: rgba(255, 255, 255, 0.15);
}
/* Dialog Body */
.create-dialog :deep(.el-dialog__body) {
padding: 28px;
}
.dialog-body {
display: flex;
flex-direction: column;
gap: 24px;
}
.form-card {
display: flex;
flex-direction: column;
gap: 20px;
padding: 24px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 16px;
}
.input-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.input-label {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
.label-icon {
color: var(--accent-primary);
font-size: 10px;
}
.custom-input :deep(.el-input__wrapper) {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
box-shadow: none;
padding: 4px 14px;
transition: all 0.25s ease;
}
.custom-input :deep(.el-input__wrapper:hover) {
border-color: rgba(0, 212, 255, 0.3);
}
.custom-input :deep(.el-input__wrapper.is-focus) {
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.15);
}
.custom-input :deep(.el-input__inner) {
color: var(--text-primary);
}
.custom-input :deep(.el-input__inner::placeholder) {
color: var(--text-muted);
}
.custom-input :deep(.el-textarea__inner) {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
box-shadow: none;
padding: 12px 14px;
color: var(--text-primary);
resize: none;
transition: all 0.25s ease;
}
.custom-input :deep(.el-textarea__inner:hover) {
border-color: rgba(0, 212, 255, 0.3);
}
.custom-input :deep(.el-textarea__inner:focus) {
border-color: var(--accent-primary);
box-shadow: 0 0 0 3px rgba(0, 212, 255, 0.15);
}
/* Templates Section */
.templates-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.templates-label {
font-size: 12px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.templates-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.template-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 12px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
cursor: pointer;
transition: all 0.25s ease;
}
.template-card:hover {
background: rgba(0, 212, 255, 0.08);
border-color: rgba(0, 212, 255, 0.25);
transform: translateY(-2px);
}
.template-card .el-icon {
font-size: 22px;
color: var(--accent-primary);
}
.template-card span {
font-size: 12px;
color: var(--text-secondary);
}
/* Dialog Footer */
.create-dialog :deep(.el-dialog__footer) {
padding: 0;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 20px 28px;
background: rgba(0, 0, 0, 0.2);
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.btn-cancel {
padding: 10px 20px;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: var(--text-secondary);
transition: all 0.2s ease;
}
.btn-cancel:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.15);
color: var(--text-primary);
}
.btn-create {
padding: 10px 24px;
background: linear-gradient(135deg, var(--accent-primary) 0%, #0891b2 100%);
border: none;
border-radius: 10px;
font-weight: 500;
transition: all 0.25s ease;
}
.btn-create:hover {
transform: translateY(-1px);
box-shadow: 0 4px 15px rgba(0, 212, 255, 0.35);
}
</style>

View File

@@ -0,0 +1,188 @@
<template>
<el-dialog
:model-value="visible"
title=""
width="420px"
class="delete-dialog"
:show-close="false"
align-center
:close-on-click-modal="false"
@update:model-value="$emit('update:visible', $event)"
>
<template #header>
<div class="delete-dialog-header">
<div class="delete-icon-wrapper">
<el-icon size="28"><WarningFilled /></el-icon>
</div>
<h3>{{ title }}</h3>
</div>
</template>
<div class="delete-dialog-body">
<p>
确定要删除 <strong>{{ itemName }}</strong>
</p>
<p class="warning-text">{{ warningText }}</p>
</div>
<template #footer>
<div class="delete-dialog-footer">
<el-button
@click="handleCancel"
class="btn-cancel-delete"
>
取消
</el-button>
<el-button
type="danger"
:loading="loading"
@click="handleConfirm"
class="btn-delete"
>
<el-icon v-if="!loading"><Delete /></el-icon>
确认删除
</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
const props = defineProps({
visible: {
type: Boolean,
default: false
},
title: {
type: String,
default: '删除项目'
},
itemName: {
type: String,
default: ''
},
warningText: {
type: String,
default: '此操作不可恢复,所有相关数据将被永久删除'
},
loading: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:visible', 'confirm', 'cancel'])
const handleConfirm = () => {
emit('confirm')
}
const handleCancel = () => {
emit('update:visible', false)
emit('cancel')
}
</script>
<style scoped>
.delete-dialog :deep(.el-dialog) {
background: linear-gradient(145deg, #1a1a2e 0%, #16162a 100%);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 16px;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5), 0 0 30px rgba(239, 68, 68, 0.1);
}
.delete-dialog :deep(.el-dialog__header) {
padding: 24px 24px 12px;
}
.delete-dialog :deep(.el-dialog__body) {
padding: 0 24px 24px;
}
.delete-dialog :deep(.el-dialog__footer) {
padding: 0;
}
.delete-dialog-header {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.delete-icon-wrapper {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(239, 68, 68, 0.2) 0%, rgba(239, 68, 68, 0.1) 100%);
border: 1px solid rgba(239, 68, 68, 0.3);
border-radius: 50%;
color: #ef4444;
}
.delete-dialog-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #fff;
}
.delete-dialog-body {
text-align: center;
padding: 8px 0;
}
.delete-dialog-body p {
margin: 0;
color: #ccc;
font-size: 14px;
}
.delete-dialog-body p strong {
color: #fff;
}
.delete-dialog-body .warning-text {
margin-top: 8px;
color: #ef4444;
font-size: 13px;
}
.delete-dialog-footer {
display: flex;
justify-content: center;
gap: 12px;
padding: 20px 24px;
background: rgba(0, 0, 0, 0.2);
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.btn-cancel-delete {
padding: 10px 24px;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: #ccc;
transition: all 0.2s ease;
}
.btn-cancel-delete:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.2);
color: #fff;
}
.btn-delete {
padding: 10px 24px;
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
border: none;
border-radius: 10px;
font-weight: 500;
transition: all 0.25s ease;
}
.btn-delete:hover {
transform: translateY(-1px);
box-shadow: 0 4px 15px rgba(239, 68, 68, 0.4);
}
</style>

View File

@@ -0,0 +1,96 @@
<template>
<div class="empty-state">
<div class="empty-illustration">
<div class="circle-1"></div>
<div class="circle-2"></div>
<div class="circle-3"></div>
<el-icon size="48"><component :is="icon" /></el-icon>
</div>
<h3>{{ title }}</h3>
<p>{{ description }}</p>
<el-button v-if="actionText" type="primary" @click="$emit('action')">
{{ actionText }}
</el-button>
</div>
</template>
<script setup>
defineProps({
icon: {
type: Object,
default: () => null
},
title: {
type: String,
default: '暂无数据'
},
description: {
type: String,
default: '暂无相关内容'
},
actionText: {
type: String,
default: ''
}
})
defineEmits(['action'])
</script>
<style scoped>
.empty-state {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 40px;
background: var(--glass-bg);
backdrop-filter: blur(20px);
border: 1px dashed var(--border-default);
border-radius: var(--radius-xl);
text-align: center;
}
.empty-illustration {
position: relative;
width: 120px;
height: 120px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24px;
}
.empty-illustration .el-icon {
position: relative;
z-index: 1;
color: var(--text-muted);
}
.circle-1, .circle-2, .circle-3 {
position: absolute;
border-radius: 50%;
border: 1px solid var(--border-default);
}
.circle-1 { width: 100%; height: 100%; animation: rotate 20s linear infinite; }
.circle-2 { width: 70%; height: 70%; animation: rotate 15s linear infinite reverse; }
.circle-3 { width: 40%; height: 40%; background: var(--bg-tertiary); }
@keyframes rotate {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.empty-state h3 {
font-size: 20px;
margin-bottom: 8px;
color: var(--text-primary);
}
.empty-state p {
color: var(--text-tertiary);
margin-bottom: 24px;
}
</style>

View File

@@ -0,0 +1,202 @@
<template>
<div
class="project-card"
:style="{ '--delay': delay }"
@click="$emit('click', project)"
>
<div class="card-glow"></div>
<button
v-if="showDelete"
class="card-delete-btn"
@click.stop="$emit('delete', project)"
>
<el-icon><Delete /></el-icon>
</button>
<div class="card-header">
<div class="card-avatar">
<el-icon><Folder /></el-icon>
</div>
</div>
<h3 class="card-title">{{ project.name }}</h3>
<p class="card-desc">{{ project.description || '暂无描述' }}</p>
<div class="card-footer">
<span class="card-date">
<el-icon><Calendar /></el-icon>
{{ formattedDate }}
</span>
<div class="card-status">
<span class="status-dot"></span>
{{ statusText }}
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
project: {
type: Object,
required: true
},
index: {
type: Number,
default: 0
},
showDelete: {
type: Boolean,
default: true
},
statusText: {
type: String,
default: '活跃'
}
})
defineEmits(['click', 'delete'])
const delay = computed(() => `${props.index * 0.1}s`)
const formattedDate = computed(() => {
if (!props.project.created_at) return ''
const d = new Date(props.project.created_at)
return d.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', year: 'numeric' })
})
</script>
<style scoped>
.project-card {
position: relative;
padding: 24px;
background: var(--bg-secondary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
cursor: pointer;
transition: all var(--transition-base);
overflow: hidden;
animation: cardFadeIn 0.5s ease backwards;
animation-delay: var(--delay);
}
@keyframes cardFadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.project-card:hover {
border-color: var(--accent-primary);
transform: translateY(-4px);
}
.card-glow {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, var(--accent-primary), transparent);
opacity: 0;
transition: opacity var(--transition-base);
}
.project-card:hover .card-glow { opacity: 1; }
.card-delete-btn {
position: absolute;
top: 12px;
right: 12px;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--danger);
border: none;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transform: scale(0.8);
transition: all 0.2s ease;
z-index: 10;
}
.card-delete-btn:hover {
transform: scale(1.1);
background: #dc2626;
}
.project-card:hover .card-delete-btn {
opacity: 1;
transform: scale(1);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.card-avatar {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent-primary-muted);
border-radius: var(--radius-md);
font-size: 20px;
color: var(--accent-primary);
}
.card-title {
font-size: 17px;
font-weight: 600;
margin-bottom: 8px;
color: var(--text-primary);
}
.card-desc {
font-size: 14px;
color: var(--text-tertiary);
line-height: 1.5;
margin-bottom: 20px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 16px;
border-top: 1px solid var(--border-subtle);
}
.card-date {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-muted);
}
.card-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--success);
}
.status-dot {
width: 6px;
height: 6px;
background: var(--success);
border-radius: 50%;
}
</style>

View File

@@ -48,9 +48,9 @@ const routes = [
]
},
{
path: '/playground',
name: 'Playground',
component: () => import('@/views/PlaygroundView.vue')
path: '/models',
name: 'ModelSettings',
component: () => import('@/views/ModelSettingsView.vue')
},
{
path: '/data-square',

View File

@@ -0,0 +1,426 @@
/* ========================
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; }
}

32
frontend/src/types/api.d.ts vendored Normal file
View File

@@ -0,0 +1,32 @@
/**
* API Response Types
*/
// Base API response wrapper
export interface ApiResponse<T = any> {
success: boolean
message: string
data: T
error: string | null
timestamp: string
}
// Paginated response
export interface PaginatedResponse<T = any> extends ApiResponse<T> {
page?: number
page_size?: number
total?: number
}
// List items wrapper
export interface ListResponse<T> {
items: T[]
total: number
page: number
page_size: number
}
// Simple ID response
export interface IdResponse {
id: string
}

60
frontend/src/types/common.d.ts vendored Normal file
View File

@@ -0,0 +1,60 @@
/**
* Common Types
*/
// File types
export interface FileItem {
id: string
filename: string
file_type: string
size?: number
status: string
created_at: string
updated_at: string
}
// Chunk types
export interface Chunk {
id: string
name?: string
content: string
summary?: string
word_count?: number
file_id?: string
created_at: string
updated_at: string
}
// Question types
export interface Question {
id: string
content: string
answer?: string
question_type?: string
chunk_id?: string
source: string
created_at: string
updated_at: string
}
// Dataset types
export interface Dataset {
id: string
name: string
description?: string
dataset_type?: string
question_count?: number
created_at: string
updated_at: string
}
// Dialog props
export interface DialogProps {
visible: boolean
loading?: boolean
}
export interface DeleteDialogProps extends DialogProps {
itemName?: string
itemType?: string
}

View File

@@ -0,0 +1,7 @@
/**
* Type exports
*/
export * from './api'
export * from './project'
export * from './model'
export * from './common'

30
frontend/src/types/model.d.ts vendored Normal file
View File

@@ -0,0 +1,30 @@
/**
* Model Configuration Types
*/
export interface ModelConfig {
id: string
provider: ModelProvider
model_name: string
api_key?: string
api_base?: string
is_default: 'true' | 'false'
created_at?: string
updated_at?: string
}
export type ModelProvider = 'openai' | 'anthropic' | 'google' | 'other'
export interface ModelCreate {
provider: ModelProvider
model_name: string
api_key: string
api_base?: string
is_default: boolean
}
export interface ProviderOption {
value: ModelProvider
label: string
abbr: string
}

21
frontend/src/types/project.d.ts vendored Normal file
View File

@@ -0,0 +1,21 @@
/**
* Project Types
*/
export interface Project {
id: string
name: string
description?: string
created_at: string
updated_at: string
}
export interface ProjectCreate {
name: string
description?: string
}
export interface ProjectUpdate {
name?: string
description?: string
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,975 @@
<template>
<div class="model-settings">
<!-- 背景效果 -->
<div class="bg-effects">
<div class="glow-orb glow-1"></div>
<div class="glow-orb glow-2"></div>
</div>
<!-- 页面头部 -->
<header class="page-header">
<div class="header-left">
<el-button text class="back-btn" @click="goHome">
<el-icon><ArrowLeft /></el-icon>
<span>返回</span>
</el-button>
</div>
<div class="header-content">
<h1 class="page-title">
<el-icon class="title-icon"><Cpu /></el-icon>
模型配置
</h1>
<p class="page-subtitle">管理您的 AI 模型 API 配置</p>
</div>
<div class="header-right">
<el-button type="primary" class="add-btn" @click="openAddDialog">
<el-icon><Plus /></el-icon>
<span>添加模型</span>
</el-button>
</div>
</header>
<!-- 主内容 -->
<main class="page-main">
<!-- 统计卡片 -->
<section class="stats-grid">
<div class="stat-card" v-for="stat in stats" :key="stat.label">
<div class="stat-icon" :class="stat.class">
{{ stat.icon }}
</div>
<div class="stat-info">
<span class="stat-value">{{ stat.value }}</span>
<span class="stat-label">{{ stat.label }}</span>
</div>
</div>
</section>
<!-- 模型列表 -->
<section class="models-section">
<div class="section-header">
<h2 class="section-title">
<span class="title-line"></span>
已配置的模型
</h2>
<span class="count-badge">{{ models.length }} </span>
</div>
<!-- 空状态 -->
<div v-if="models.length === 0 && !loading" class="empty-state">
<div class="empty-illustration">
<div class="pulse-ring"></div>
<el-icon size="48"><Setting /></el-icon>
</div>
<h3>暂无模型配置</h3>
<p>添加您的第一个 AI 模型开始使用</p>
<el-button type="primary" @click="openAddDialog">添加模型</el-button>
</div>
<!-- 模型卡片 -->
<div v-else class="models-grid">
<article
v-for="(model, index) in models"
:key="model.id"
class="model-card"
:class="{ 'is-default': model.is_default === 'true' }"
:style="{ '--delay': index * 0.08 + 's' }"
>
<div class="card-glow"></div>
<!-- 默认标识 -->
<div v-if="model.is_default === 'true'" class="default-badge">
<el-icon><Star /></el-icon>
默认
</div>
<!-- 提供商图标 -->
<div class="provider-logo" :class="model.provider">
{{ getProviderAbbr(model.provider) }}
</div>
<!-- 模型信息 -->
<div class="model-info">
<h3 class="model-name">{{ model.model_name }}</h3>
<p class="model-endpoint">
<el-icon><Link /></el-icon>
{{ model.api_base || '默认端点' }}
</p>
</div>
<!-- 底部操作 -->
<div class="card-footer">
<div class="status-badge">
<span class="status-dot online"></span>
已配置
</div>
<div class="card-actions">
<el-tooltip content="测试连接" placement="top">
<el-button text circle class="action-btn" @click="testConnection(model)">
<el-icon><Connection /></el-icon>
</el-button>
</el-tooltip>
<el-tooltip content="删除" placement="top">
<el-button text circle class="action-btn delete" @click="confirmDelete(model)">
<el-icon><Delete /></el-icon>
</el-button>
</el-tooltip>
</div>
</div>
</article>
</div>
</section>
</main>
<!-- 添加模型弹窗 -->
<el-dialog
v-model="showAddDialog"
:show-close="false"
width="500"
class="model-dialog"
:append-to-body="true"
>
<template #header>
<div class="dialog-header">
<div class="dialog-icon">
<el-icon size="20"><Plus /></el-icon>
</div>
<div class="dialog-title">
<h3>添加模型</h3>
<p>配置新的 AI 模型</p>
</div>
<button class="dialog-close" @click="showAddDialog = false">
<el-icon><Close /></el-icon>
</button>
</div>
</template>
<el-form :model="modelForm" label-position="top" class="model-form">
<!-- 提供商选择 -->
<el-form-item label="选择提供商">
<div class="provider-grid">
<div
v-for="provider in providers"
:key="provider.value"
class="provider-option"
:class="{ active: modelForm.provider === provider.value }"
@click="modelForm.provider = provider.value"
>
<span class="provider-abbr">{{ provider.abbr }}</span>
<span class="provider-name">{{ provider.label }}</span>
</div>
</div>
</el-form-item>
<!-- 模型名称 -->
<el-form-item label="模型名称" required>
<el-input
v-model="modelForm.model_name"
placeholder="例如: gpt-4o-mini"
size="large"
/>
</el-form-item>
<!-- API Key -->
<el-form-item label="API Key" required>
<el-input
v-model="modelForm.api_key"
type="password"
placeholder="输入 API Key"
size="large"
show-password
/>
</el-form-item>
<!-- API 地址 -->
<el-form-item label="API 地址 (可选)">
<el-input
v-model="modelForm.api_base"
placeholder="自定义 API 地址"
size="large"
/>
</el-form-item>
<!-- 默认开关 -->
<el-form-item>
<el-checkbox v-model="modelForm.is_default">
设为默认模型
</el-checkbox>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="showAddDialog = false" size="large">取消</el-button>
<el-button type="primary" @click="addModel" :loading="submitting" size="large">
添加模型
</el-button>
</div>
</template>
</el-dialog>
<!-- 删除确认弹窗 -->
<el-dialog
v-model="deleteDialogVisible"
:show-close="false"
width="400"
class="delete-dialog"
:append-to-body="true"
>
<template #header>
<div class="delete-header">
<div class="delete-icon">
<el-icon size="24"><WarningFilled /></el-icon>
</div>
<h3>确认删除</h3>
</div>
</template>
<div class="delete-content">
<p>确定要删除模型 <strong>{{ modelToDelete?.model_name }}</strong> </p>
<p class="warning-text">此操作不可恢复</p>
</div>
<template #footer>
<div class="delete-footer">
<el-button @click="deleteDialogVisible = false" size="large">取消</el-button>
<el-button type="danger" @click="handleDelete" :loading="deleting" size="large">
确认删除
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { ModelConfig, ProviderOption, ModelCreate } from '@/types'
const router = useRouter()
// 状态
const loading = ref(false)
const submitting = ref(false)
const deleting = ref(false)
const showAddDialog = ref(false)
const deleteDialogVisible = ref(false)
const modelToDelete = ref<ModelConfig | null>(null)
const models = ref<ModelConfig[]>([])
// 表单
const modelForm = reactive<ModelCreate>({
provider: 'openai',
model_name: '',
api_key: '',
api_base: '',
is_default: false
})
// 提供商
const providers: ProviderOption[] = [
{ value: 'openai', label: 'OpenAI', abbr: 'OP' },
{ value: 'anthropic', label: 'Anthropic', abbr: 'AN' },
{ value: 'google', label: 'Google', abbr: 'GO' },
{ value: 'other', label: '其他', abbr: 'OT' }
]
// Mock
const mockModels: ModelConfig[] = [
{ id: '1', provider: 'openai', model_name: 'gpt-4o', api_base: 'https://api.openai.com/v1', is_default: 'true' },
{ id: '2', provider: 'openai', model_name: 'gpt-4o-mini', api_base: 'https://api.openai.com/v1', is_default: 'false' },
{ id: '3', provider: 'anthropic', model_name: 'claude-3-5-sonnet', api_base: 'https://api.anthropic.com', is_default: 'false' }
]
// 统计
const stats = computed(() => [
{ label: 'OpenAI', value: models.value.filter(m => m.provider === 'openai').length, icon: 'OP', class: 'openai' },
{ label: 'Anthropic', value: models.value.filter(m => m.provider === 'anthropic').length, icon: 'AN', class: 'anthropic' },
{ label: 'Google', value: models.value.filter(m => m.provider === 'google').length, icon: 'GO', class: 'google' },
{ label: '默认模型', value: models.value.find(m => m.is_default === 'true')?.model_name || '未设置', icon: '★', class: 'default' }
])
// 方法
const goHome = () => router.push('/')
const getProviderAbbr = (provider: string) => {
const p = providers.find(p => p.value === provider)
return p?.abbr || '?'
}
const fetchModels = async () => {
loading.value = true
try {
await new Promise(r => setTimeout(r, 500))
models.value = mockModels
} catch {
ElMessage.error('加载失败')
} finally {
loading.value = false
}
}
const openAddDialog = () => {
modelForm.provider = 'openai'
modelForm.model_name = ''
modelForm.api_key = ''
modelForm.api_base = ''
modelForm.is_default = false
showAddDialog.value = true
}
const addModel = async () => {
if (!modelForm.model_name || !modelForm.api_key) {
ElMessage.warning('请填写模型名称和 API Key')
return
}
submitting.value = true
try {
await new Promise(r => setTimeout(r, 500))
ElMessage.success('添加成功')
showAddDialog.value = false
fetchModels()
} catch {
ElMessage.error('添加失败')
} finally {
submitting.value = false
}
}
const confirmDelete = (model: ModelConfig) => {
modelToDelete.value = model
deleteDialogVisible.value = true
}
const handleDelete = async () => {
deleting.value = true
try {
await new Promise(r => setTimeout(r, 500))
ElMessage.success('删除成功')
deleteDialogVisible.value = false
modelToDelete.value = null
fetchModels()
} catch {
ElMessage.error('删除失败')
} finally {
deleting.value = false
}
}
const testConnection = (model: ModelConfig) => {
ElMessage.info(`测试 ${model.model_name}...`)
}
onMounted(() => fetchModels())
</script>
<style scoped>
/* 使用全局 CSS 变量 */
.model-settings {
min-height: 100vh;
background: var(--bg-primary);
position: relative;
overflow: hidden;
}
/* 背景效果 */
.bg-effects {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
}
.glow-orb {
position: absolute;
border-radius: 50%;
filter: blur(120px);
opacity: 0.4;
}
.glow-1 {
width: 500px;
height: 500px;
background: var(--accent-primary);
top: -200px;
right: -100px;
}
.glow-2 {
width: 400px;
height: 400px;
background: var(--accent-secondary);
bottom: -100px;
left: -100px;
}
/* 页面头部 */
.page-header {
position: relative;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px 32px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-subtle);
}
.header-content {
text-align: center;
}
.page-title {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
font-size: 22px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.title-icon {
color: var(--accent-primary);
}
.page-subtitle {
font-size: 13px;
color: var(--text-tertiary);
margin: 4px 0 0;
}
.header-left, .header-right {
flex: 1;
}
.header-right {
display: flex;
justify-content: flex-end;
}
.back-btn {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-secondary);
padding: 8px 16px;
border-radius: var(--radius-md);
background: transparent !important;
}
.back-btn:hover {
background: var(--bg-hover) !important;
color: var(--text-primary);
}
.add-btn {
display: flex;
align-items: center;
gap: 6px;
background: var(--accent-primary);
border: none;
color: #030407;
font-weight: 600;
}
.add-btn:hover {
background: var(--accent-primary-hover);
}
/* 主内容 */
.page-main {
position: relative;
z-index: 1;
max-width: 1200px;
margin: 0 auto;
padding: 32px;
}
/* 统计卡片 */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-bottom: 32px;
}
.stat-card {
display: flex;
align-items: center;
gap: 16px;
padding: 20px;
background: var(--bg-secondary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
transition: all var(--transition-fast);
}
.stat-card:hover {
border-color: var(--border-default);
transform: translateY(-2px);
}
.stat-icon {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 700;
color: white;
}
.stat-icon.openai { background: linear-gradient(135deg, #10a37f, #0d8c6d); }
.stat-icon.anthropic { background: linear-gradient(135deg, #d97757, #c45f3f); }
.stat-icon.google { background: linear-gradient(135deg, #4285f4, #3367d6); }
.stat-icon.default { background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); }
.stat-info {
display: flex;
flex-direction: column;
}
.stat-value {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
}
.stat-label {
font-size: 12px;
color: var(--text-tertiary);
}
/* 模型列表 */
.models-section {
background: var(--bg-secondary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-xl);
padding: 24px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.title-line {
width: 4px;
height: 18px;
background: linear-gradient(180deg, var(--accent-primary), var(--accent-secondary));
border-radius: 2px;
}
.count-badge {
font-size: 12px;
color: var(--text-tertiary);
background: var(--bg-hover);
padding: 6px 14px;
border-radius: 20px;
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 80px 20px;
}
.empty-illustration {
position: relative;
width: 100px;
height: 100px;
margin: 0 auto 24px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
}
.pulse-ring {
position: absolute;
inset: 0;
border: 2px solid var(--border-subtle);
border-radius: 50%;
animation: pulse 2s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.1); opacity: 0.5; }
}
.empty-state h3 {
font-size: 18px;
color: var(--text-primary);
margin: 0 0 8px;
}
.empty-state p {
color: var(--text-tertiary);
margin: 0 0 24px;
}
/* 模型网格 */
.models-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.model-card {
position: relative;
padding: 24px;
background: var(--bg-tertiary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
transition: all var(--transition-base);
animation: fadeInUp 0.4s ease backwards;
animation-delay: var(--delay);
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
.model-card:hover {
border-color: var(--accent-primary-muted);
transform: translateY(-4px);
box-shadow: var(--glow-primary);
}
.model-card.is-default {
border-color: rgba(52, 211, 153, 0.3);
}
.card-glow {
position: absolute;
inset: 0;
border-radius: var(--radius-lg);
background: radial-gradient(circle at top right, var(--accent-primary-muted), transparent 60%);
opacity: 0;
transition: opacity var(--transition-base);
}
.model-card:hover .card-glow {
opacity: 1;
}
.default-badge {
position: absolute;
top: 16px;
right: 16px;
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
color: var(--success);
background: var(--success-muted);
padding: 4px 10px;
border-radius: 6px;
}
.provider-logo {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
font-size: 13px;
font-weight: 700;
color: white;
margin-bottom: 16px;
}
.provider-logo.openai { background: linear-gradient(135deg, #10a37f, #0d8c6d); }
.provider-logo.anthropic { background: linear-gradient(135deg, #d97757, #c45f3f); }
.provider-logo.google { background: linear-gradient(135deg, #4285f4, #3367d6); }
.provider-logo.other { background: linear-gradient(135deg, #6b7280, #4b5563); }
.model-info {
margin-bottom: 20px;
}
.model-name {
font-size: 17px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 8px;
}
.model-endpoint {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-muted);
margin: 0;
}
.card-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 16px;
border-top: 1px solid var(--border-subtle);
}
.status-badge {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-muted);
}
.status-dot.online {
background: var(--success);
box-shadow: 0 0 8px var(--success);
}
.card-actions {
display: flex;
gap: 8px;
}
.action-btn {
width: 32px;
height: 32px;
color: var(--text-tertiary);
background: var(--bg-hover);
border-radius: var(--radius-sm);
}
.action-btn:hover {
color: var(--text-primary);
background: var(--bg-elevated);
}
.action-btn.delete:hover {
color: var(--danger);
background: var(--danger-muted);
}
/* 弹窗样式 */
:deep(.model-dialog .el-dialog) {
background: var(--bg-elevated);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-xl);
}
.dialog-header {
display: flex;
align-items: center;
gap: 16px;
padding: 20px 24px;
position: relative;
}
.dialog-icon {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent-primary);
border-radius: var(--radius-md);
color: #030407;
}
.dialog-title h3 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.dialog-title p {
font-size: 13px;
color: var(--text-tertiary);
margin: 4px 0 0;
}
.dialog-close {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
border-radius: var(--radius-sm);
color: var(--text-tertiary);
cursor: pointer;
}
.dialog-close:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.provider-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.provider-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 8px;
background: var(--bg-tertiary);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
}
.provider-option:hover {
border-color: var(--border-default);
}
.provider-option.active {
border-color: var(--accent-primary);
background: var(--accent-primary-muted);
}
.provider-option .provider-abbr {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
}
.provider-option .provider-name {
font-size: 11px;
color: var(--text-tertiary);
}
.provider-option.active .provider-name {
color: var(--accent-primary);
}
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px 24px;
background: var(--bg-tertiary);
border-top: 1px solid var(--border-subtle);
}
/* 删除弹窗 */
:deep(.delete-dialog .el-dialog) {
background: var(--bg-elevated);
border: 1px solid var(--danger-muted);
border-radius: var(--radius-xl);
}
.delete-header {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 24px;
}
.delete-icon {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
background: var(--danger-muted);
border: 1px solid var(--danger-muted);
border-radius: 50%;
color: var(--danger);
}
.delete-header h3 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.delete-content {
text-align: center;
padding: 0 24px 24px;
}
.delete-content p {
color: var(--text-secondary);
margin: 0;
}
.delete-content p strong {
color: var(--text-primary);
}
.warning-text {
color: var(--danger) !important;
font-size: 13px;
margin-top: 8px !important;
}
.delete-footer {
display: flex;
justify-content: center;
gap: 12px;
padding: 16px 24px;
background: var(--bg-tertiary);
border-top: 1px solid var(--border-subtle);
}
/* 响应式 */
@media (max-width: 768px) {
.page-header {
flex-direction: column;
gap: 16px;
padding: 16px;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.page-main {
padding: 16px;
}
.provider-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View File

@@ -76,7 +76,8 @@ const isActive = (path) => route.path.includes(path)
const fetchProject = async () => {
try {
const res = await projectApi.get(id.value)
project.value = res.data
// New format: returns project directly
project.value = res
} catch (error) {
ElMessage.error('加载项目失败')
}

View File

@@ -125,8 +125,9 @@ const prompts = reactive({
const fetchProject = async () => {
try {
const res = await projectApi.get(projectId.value)
projectInfo.name = res.data.name
projectInfo.description = res.data.description || ''
// New format: project directly in response
projectInfo.name = res.name
projectInfo.description = res.description || ''
} catch (error) {
console.error(error)
}

26
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"paths": {
"@/*": ["./src/*"]
},
"baseUrl": ".",
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

View File

@@ -10,6 +10,7 @@ export default defineConfig({
}
},
server: {
host: '0.0.0.0',
port: 3000,
proxy: {
'/api': {
@@ -17,5 +18,8 @@ export default defineConfig({
changeOrigin: true
}
}
},
build: {
assetsDir: 'assets'
}
})

71
start.sh Executable file
View File

@@ -0,0 +1,71 @@
#!/bin/bash
# YG-Datasets 一键启动脚本
echo "========================================="
echo " YG-Datasets 一键启动"
echo "========================================="
# 颜色定义
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# 检查并创建必要目录
echo -e "${YELLOW}[1/5] 检查环境...${NC}"
mkdir -p backend/uploads
mkdir -p frontend
# 创建并激活 Python 虚拟环境
echo -e "${YELLOW}[2/5] 创建 Python 虚拟环境...${NC}"
cd backend
if [ ! -d "venv" ]; then
python3 -m venv venv
fi
source venv/bin/activate
# 安装后端依赖
echo -e "${YELLOW}[3/5] 安装后端依赖...${NC}"
pip install -r requirements.txt
# 安装前端依赖
echo -e "${YELLOW}[4/5] 安装前端依赖...${NC}"
cd ../frontend
if [ ! -d "node_modules" ]; then
npm install
fi
# 启动后端
echo -e "${YELLOW}[5/5] 启动服务...${NC}"
cd ../backend
echo -e "${GREEN}启动后端 (端口 8000)...${NC}"
export HOST="0.0.0.0"
export DATABASE_URL="sqlite+aiosqlite:///./ygdataset.db"
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 &
BACKEND_PID=$!
# 等待后端启动
sleep 3
# 启动前端
cd ../frontend
echo -e "${GREEN}启动前端 (端口 3000)...${NC}"
npm run dev -- --host 0.0.0.0 &
FRONTEND_PID=$!
echo ""
echo "========================================="
echo -e " ${GREEN}启动完成!${NC}"
echo "========================================="
echo " 后端: http://localhost:8000"
echo " API文档: http://localhost:8000/docs"
echo " 前端: http://localhost:3000"
echo ""
echo " 按 Ctrl+C 停止所有服务"
echo "========================================="
# 捕获 Ctrl+C 并停止所有服务
trap "kill $BACKEND_PID $FRONTEND_PID 2>/dev/null; exit" INT TERM
# 等待
wait