Просмотр исходного кода

优化前端界面,添加sso

lxylxy123321 6 дней назад
Родитель
Сommit
1cf913557a

+ 12 - 0
backend/.env

@@ -41,3 +41,15 @@ QLORA_DOUBLE_QUANT=true
 # 上传限制
 MAX_UPLOAD_SIZE_MB=500
 ALLOWED_DATASET_FORMATS=jsonl,csv,parquet,json
+
+# --- SSO 统一认证 ---
+SSO_BASE_URL=http://192.168.92.61:8200
+SSO_CLIENT_ID=hmDeOtXZVbeo2AZ-x58yPssZLg4Tcb1W
+SSO_CLIENT_SECRET=pj9UirhGUFPsFnCizCz-Qo1dOGi3kxRIrDKKmJZu2aRCPgtTogTubDRW1weM4KNL
+SSO_REDIRECT_URI=http://192.168.92.151:3000/auth/callback
+SSO_FRONTEND_URL=http://192.168.92.151:3000
+SSO_SCOPE=email
+SSO_LOGOUT_REDIRECT_URL=http://192.168.92.61:9200/login
+JWT_SECRET_KEY=change-me-in-production-use-a-long-random-string
+JWT_ACCESS_EXPIRE_MINUTES=20
+JWT_REFRESH_EXPIRE_HOURS=24

+ 12 - 0
backend/.env.example

@@ -41,3 +41,15 @@ QLORA_DOUBLE_QUANT=true
 # 上传限制
 MAX_UPLOAD_SIZE_MB=500
 ALLOWED_DATASET_FORMATS=jsonl,csv,parquet,json
+
+# --- SSO 统一认证 ---
+SSO_BASE_URL=http://192.168.92.61:8200
+SSO_CLIENT_ID=WviiGL8KQE20tQhmhQPQhhJ5QpFK51F6
+SSO_CLIENT_SECRET=9WXP88hEHJiHRSiUdmx7ip5oQPzY0bnJNsEswQoO4sk6juCplyJTcnAiZsv7e3lJ
+SSO_REDIRECT_URI=http://192.168.92.151:3000/auth/callback
+SSO_FRONTEND_URL=http://192.168.92.151:3000
+SSO_SCOPE=email
+SSO_LOGOUT_REDIRECT_URL=http://192.168.92.61:9200/login
+JWT_SECRET_KEY=change-me-in-production-use-a-long-random-string
+JWT_ACCESS_EXPIRE_MINUTES=20
+JWT_REFRESH_EXPIRE_HOURS=24

+ 227 - 0
backend/app/api/auth.py

@@ -0,0 +1,227 @@
+import logging
+import uuid
+from datetime import datetime, timedelta, timezone
+from urllib.parse import urlencode
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from fastapi.responses import RedirectResponse
+from pydantic import BaseModel
+from sqlalchemy import select
+
+from app.config import get_settings
+from app.core.auth import get_current_user
+from app.core.db import RefreshTokenModel, UserModel, async_session
+from app.core.security import create_access_token, create_refresh_token
+from app.core.sso_client import exchange_code_for_token, fetch_sso_userinfo
+
+router = APIRouter()
+settings = get_settings()
+logger = logging.getLogger(__name__)
+
+
+class CodeExchangeRequest(BaseModel):
+    code: str
+
+
+class RefreshRequest(BaseModel):
+    refresh_token: str
+
+
+class LogoutRequest(BaseModel):
+    token: str
+    refresh_token: str
+
+
+async def _sync_user(sso_info: dict) -> UserModel:
+    username = sso_info.get("username", sso_info.get("sub", "unknown"))
+    role_codes = [r.get("code", "") for r in sso_info.get("roles", [])]
+
+    async with async_session() as session:
+        result = await session.execute(select(UserModel).where(UserModel.username == username))
+        user = result.scalar_one_or_none()
+
+        if not user:
+            user = UserModel(
+                id=str(uuid.uuid4()),
+                username=username,
+                email=sso_info.get("email"),
+                real_name=sso_info.get("real_name"),
+                avatar_url=sso_info.get("avatar_url"),
+                company=sso_info.get("company"),
+                department=sso_info.get("department"),
+                position=sso_info.get("position"),
+                roles=role_codes,
+                is_active=1,
+            )
+            session.add(user)
+        else:
+            user.roles = role_codes
+            user.email = sso_info.get("email", user.email)
+            user.updated_at = datetime.now(timezone.utc)
+
+        await session.commit()
+        await session.refresh(user)
+        return user
+
+
+@router.post("/api/oauth/exchange-code")
+async def exchange_code(req: CodeExchangeRequest):
+    if not req.code:
+        return {"code": "100001", "message": "缺少授权码", "data": None}
+
+    logger.info("[SSO] exchange_code start, code=%s", req.code[:10])
+    logger.info("[SSO] sso_base_url=%s", settings.sso_base_url)
+    logger.info("[SSO] client_id=%s", settings.sso_client_id)
+    logger.info("[SSO] redirect_uri=%s", settings.sso_redirect_uri)
+
+    try:
+        token_resp = await exchange_code_for_token(req.code)
+        logger.info("[SSO] token response: %s", token_resp)
+        sso_access_token = token_resp.get("access_token")
+        if not sso_access_token:
+            logger.error("[SSO] no access_token in response: %s", token_resp)
+            raise HTTPException(status_code=500, detail="登录失败: 获取令牌失败")
+
+        sso_userinfo = await fetch_sso_userinfo(sso_access_token)
+        logger.info("[SSO] userinfo: %s", sso_userinfo)
+        if not sso_userinfo.get("username") and not sso_userinfo.get("sub"):
+            raise HTTPException(status_code=500, detail="登录失败: 用户信息格式异常")
+
+        user = await _sync_user(sso_userinfo)
+
+        local_token = create_access_token(
+            user_id=user.id, username=user.username, roles=user.roles or [],
+        )
+        refresh_token_str = create_refresh_token()
+        expires_at = datetime.now(timezone.utc) + timedelta(hours=settings.jwt_refresh_expire_hours)
+
+        async with async_session() as session:
+            rt = RefreshTokenModel(
+                id=str(uuid.uuid4()),
+                user_id=user.id,
+                token=refresh_token_str,
+                expires_at=expires_at,
+            )
+            session.add(rt)
+            await session.commit()
+
+        return {
+            "code": "000000",
+            "message": "登录成功",
+            "data": {
+                "token": local_token,
+                "refresh_token": refresh_token_str,
+                "user": {
+                    "id": user.id,
+                    "username": user.username,
+                    "email": user.email,
+                    "phone": None,
+                    "is_superuser": bool(user.is_superuser),
+                    "is_active": bool(user.is_active),
+                    "roles": user.roles,
+                },
+            },
+        }
+    except HTTPException:
+        raise
+    except Exception as e:
+        import traceback
+        logger.error("[SSO] exchange_code failed: %s", traceback.format_exc())
+        raise HTTPException(status_code=500, detail=f"登录失败: {str(e)}")
+
+
+@router.get("/auth/sso/authorize")
+async def sso_authorize(redirect: bool = Query(False)):
+    params = urlencode({
+        "response_type": "code",
+        "client_id": settings.sso_client_id,
+        "redirect_uri": settings.sso_redirect_uri,
+        "scope": settings.sso_scope,
+    })
+    authorize_url = f"{settings.sso_base_url}/oauth/authorize?{params}"
+    if redirect:
+        return RedirectResponse(url=authorize_url)
+    return {"code": "000000", "message": "获取授权URL成功", "data": {"authorize_url": authorize_url}}
+
+
+@router.post("/api/v1/auth/refresh")
+async def refresh_token_endpoint(req: RefreshRequest):
+    async with async_session() as session:
+        result = await session.execute(
+            select(RefreshTokenModel).where(
+                RefreshTokenModel.token == req.refresh_token,
+                RefreshTokenModel.revoked == 0,
+                RefreshTokenModel.expires_at > datetime.now(timezone.utc),
+            )
+        )
+        rt = result.scalar_one_or_none()
+        if not rt:
+            raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
+
+        result = await session.execute(select(UserModel).where(UserModel.id == rt.user_id))
+        user = result.scalar_one_or_none()
+        if not user or not user.is_active:
+            raise HTTPException(status_code=401, detail="User not found")
+
+        rt.revoked = 1
+        new_token_str = create_refresh_token()
+        new_expires = datetime.now(timezone.utc) + timedelta(hours=settings.jwt_refresh_expire_hours)
+        new_rt = RefreshTokenModel(
+            id=str(uuid.uuid4()), user_id=user.id, token=new_token_str, expires_at=new_expires,
+        )
+        session.add(new_rt)
+        await session.commit()
+
+        new_access = create_access_token(
+            user_id=user.id, username=user.username, roles=user.roles or [],
+        )
+        return {
+            "code": "000000",
+            "message": "刷新成功",
+            "data": {"token": new_access, "refresh_token": new_token_str},
+        }
+
+
+@router.post("/api/v1/auth/logout")
+async def logout(req: LogoutRequest, current_user: dict = Depends(get_current_user)):
+    async with async_session() as session:
+        result = await session.execute(
+            select(RefreshTokenModel).where(RefreshTokenModel.token == req.refresh_token)
+        )
+        rt = result.scalar_one_or_none()
+        if rt:
+            rt.revoked = 1
+            await session.commit()
+
+    return {
+        "code": "000000",
+        "message": "登出成功",
+        "data": {"sso_logout_url": settings.sso_logout_redirect_url},
+    }
+
+
+@router.get("/api/v1/auth/userinfo")
+async def get_userinfo(current_user: dict = Depends(get_current_user)):
+    user_id = current_user.get("sub")
+    async with async_session() as session:
+        result = await session.execute(select(UserModel).where(UserModel.id == user_id))
+        user = result.scalar_one_or_none()
+        if not user:
+            raise HTTPException(status_code=404, detail="User not found")
+        return {
+            "code": "000000",
+            "data": {
+                "id": user.id,
+                "username": user.username,
+                "email": user.email,
+                "real_name": user.real_name,
+                "roles": user.roles,
+                "avatar_url": user.avatar_url,
+                "permissions": [],
+            },
+        }
+
+
+@router.get("/api/v1/auth/me")
+async def get_me(current_user: dict = Depends(get_current_user)):
+    return await get_userinfo(current_user)

+ 13 - 0
backend/app/config.py

@@ -111,6 +111,19 @@ class Settings(BaseSettings):
     compute_node_remote_env: str = "production"
     compute_node_ssh_timeout: int = 300  # SSH 命令超时(秒)
 
+    # --- SSO 统一认证 ---
+    sso_base_url: str = "http://192.168.92.61:8200"
+    sso_client_id: str = "hmDeOtXZVbeo2AZ-x58yPssZLg4Tcb1W"
+    sso_client_secret: str = "pj9UirhGUFPsFnCizCz-Qo1dOGi3kxRIrDKKmJZu2aRCPgtTogTubDRW1weM4KNL"
+    sso_redirect_uri: str = "http://192.168.92.151:3000/auth/callback"
+    sso_frontend_url: str = "http://192.168.92.151:3000"
+    sso_scope: str = "email"
+    sso_logout_redirect_url: str = "http://192.168.92.61:9200/login"
+    jwt_secret_key: str = "change-me-in-production-use-a-long-random-string"
+    jwt_algorithm: str = "HS256"
+    jwt_access_expire_minutes: int = 20
+    jwt_refresh_expire_hours: int = 24
+
     @field_validator("backend_cors_origins", mode="before")
     @classmethod
     def parse_cors_origins(cls, v):

+ 37 - 0
backend/app/core/auth.py

@@ -0,0 +1,37 @@
+import jwt
+from fastapi import Depends, HTTPException, status
+from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
+from sqlalchemy import select
+
+from app.core.db import UserModel, async_session
+from app.core.security import decode_access_token
+
+security = HTTPBearer(auto_error=False)
+
+
+async def get_current_user(
+    credentials: HTTPAuthorizationCredentials = Depends(security),
+) -> dict:
+    if not credentials:
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
+    try:
+        payload = decode_access_token(credentials.credentials)
+        if payload.get("type") != "access":
+            raise HTTPException(status_code=401, detail="Invalid token type")
+    except jwt.ExpiredSignatureError:
+        raise HTTPException(status_code=401, detail="Token expired")
+    except jwt.InvalidTokenError:
+        raise HTTPException(status_code=401, detail="Invalid token")
+    return payload
+
+
+async def get_current_active_user(
+    current_user: dict = Depends(get_current_user),
+) -> dict:
+    user_id = current_user.get("sub")
+    async with async_session() as session:
+        result = await session.execute(select(UserModel).where(UserModel.id == user_id))
+        user = result.scalar_one_or_none()
+        if not user or not user.is_active:
+            raise HTTPException(status_code=401, detail="User not found or inactive")
+    return current_user

+ 31 - 1
backend/app/core/db.py

@@ -1,6 +1,7 @@
 from datetime import datetime
+import uuid
 
-from sqlalchemy import Column, DateTime, Float, Integer, String, Text
+from sqlalchemy import JSON, Column, DateTime, Float, Integer, String, Text
 from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
 from sqlalchemy.orm import declarative_base
 
@@ -128,6 +129,35 @@ class DeployTaskModel(Base):
     created_at = Column(DateTime, default=datetime.utcnow)
 
 
+class UserModel(Base):
+    __tablename__ = "users"
+
+    id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
+    username = Column(String(128), unique=True, nullable=False, index=True)
+    email = Column(String(256), nullable=True)
+    real_name = Column(String(128), nullable=True)
+    avatar_url = Column(String(512), nullable=True)
+    company = Column(String(128), nullable=True)
+    department = Column(String(128), nullable=True)
+    position = Column(String(128), nullable=True)
+    roles = Column(JSON, default=list)
+    is_active = Column(Integer, default=1, nullable=False)
+    is_superuser = Column(Integer, default=0, nullable=False)
+    created_at = Column(DateTime, default=datetime.utcnow)
+    updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+
+class RefreshTokenModel(Base):
+    __tablename__ = "refresh_tokens"
+
+    id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
+    user_id = Column(String(36), nullable=False, index=True)
+    token = Column(String(512), unique=True, nullable=False, index=True)
+    expires_at = Column(DateTime, nullable=False)
+    revoked = Column(Integer, default=0, nullable=False)
+    created_at = Column(DateTime, default=datetime.utcnow)
+
+
 async def get_db() -> AsyncSession:
     async with async_session() as session:
         yield session

+ 29 - 0
backend/app/core/security.py

@@ -0,0 +1,29 @@
+import uuid
+from datetime import datetime, timedelta, timezone
+
+import jwt
+
+from app.config import get_settings
+
+settings = get_settings()
+
+
+def create_access_token(user_id: str, username: str, roles: list[str]) -> str:
+    expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_access_expire_minutes)
+    payload = {
+        "sub": user_id,
+        "username": username,
+        "roles": roles,
+        "exp": expire,
+        "iat": datetime.now(timezone.utc),
+        "type": "access",
+    }
+    return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
+
+
+def create_refresh_token() -> str:
+    return f"rt_{uuid.uuid4().hex}{uuid.uuid4().hex[:16]}"
+
+
+def decode_access_token(token: str) -> dict:
+    return jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])

+ 32 - 0
backend/app/core/sso_client.py

@@ -0,0 +1,32 @@
+import httpx
+
+from app.config import get_settings
+
+settings = get_settings()
+
+
+async def exchange_code_for_token(code: str) -> dict:
+    async with httpx.AsyncClient() as client:
+        resp = await client.post(
+            f"{settings.sso_base_url}/oauth/token",
+            data={
+                "grant_type": "authorization_code",
+                "code": code,
+                "redirect_uri": settings.sso_redirect_uri,
+                "client_id": settings.sso_client_id,
+                "client_secret": settings.sso_client_secret,
+            },
+            headers={"Content-Type": "application/x-www-form-urlencoded"},
+        )
+        resp.raise_for_status()
+        return resp.json()
+
+
+async def fetch_sso_userinfo(access_token: str) -> dict:
+    async with httpx.AsyncClient() as client:
+        resp = await client.get(
+            f"{settings.sso_base_url}/oauth/userinfo",
+            headers={"Authorization": f"Bearer {access_token}"},
+        )
+        resp.raise_for_status()
+        return resp.json()

+ 31 - 7
backend/main.py

@@ -9,7 +9,7 @@ os.environ["TORCH_FLASH_ATTN"] = "0"
 
 from contextlib import asynccontextmanager
 
-from fastapi import FastAPI
+from fastapi import Depends, FastAPI
 from fastapi.middleware.cors import CORSMiddleware
 
 from app.config import get_settings
@@ -60,13 +60,37 @@ def create_app() -> FastAPI:
     from app.api import evaluation as evaluation_api
     from app.api import deployment as deployment_api
     from app.api import inference as inference_api
+    from app.api import auth as auth_api
+    from app.core.auth import get_current_active_user
 
-    app.include_router(models_api.router, prefix="/api/v1/models", tags=["models"])
-    app.include_router(datasets_api.router, prefix="/api/v1/datasets", tags=["datasets"])
-    app.include_router(training_api.router, prefix="/api/v1/training", tags=["training"])
-    app.include_router(evaluation_api.router, prefix="/api/v1/evaluation", tags=["evaluation"])
-    app.include_router(deployment_api.router, prefix="/api/v1/deployment", tags=["deployment"])
-    app.include_router(inference_api.router, prefix="/api/v1/inference", tags=["inference"])
+    # 认证路由(无 prefix,端点自带完整路径)
+    app.include_router(auth_api.router)
+
+    # 已有路由:添加认证依赖保护
+    app.include_router(
+        models_api.router, prefix="/api/v1/models", tags=["models"],
+        dependencies=[Depends(get_current_active_user)],
+    )
+    app.include_router(
+        datasets_api.router, prefix="/api/v1/datasets", tags=["datasets"],
+        dependencies=[Depends(get_current_active_user)],
+    )
+    app.include_router(
+        training_api.router, prefix="/api/v1/training", tags=["training"],
+        dependencies=[Depends(get_current_active_user)],
+    )
+    app.include_router(
+        evaluation_api.router, prefix="/api/v1/evaluation", tags=["evaluation"],
+        dependencies=[Depends(get_current_active_user)],
+    )
+    app.include_router(
+        deployment_api.router, prefix="/api/v1/deployment", tags=["deployment"],
+        dependencies=[Depends(get_current_active_user)],
+    )
+    app.include_router(
+        inference_api.router, prefix="/api/v1/inference", tags=["inference"],
+        dependencies=[Depends(get_current_active_user)],
+    )
 
     # WebSocket
     from app.core.websocket import router as ws_router

+ 4 - 1
backend/requirements.txt

@@ -22,4 +22,7 @@ modelscope>=1.15.0,<1.18.0
 oss2>=2.18.0
 datasets
 huggingface_hub
-aiohttp>=3.9.0,<3.11.0
+aiohttp>=3.9.0,<3.11.0
+# SSO 认证
+PyJWT>=2.8.0
+httpx>=0.27.0

+ 26 - 0
frontend/nginx.conf

@@ -16,9 +16,35 @@ server {
         proxy_set_header X-Real-IP $remote_addr;
         proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
         proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_set_header Content-Type $content_type;
+        proxy_set_header Content-Length $content_length;
+        proxy_request_buffering on;
         proxy_read_timeout 600s;
     }
 
+    # 反向代理 SSO 认证 API 到后端(仅代理 /auth/sso/ 和 /auth/api/ 路径)
+    location /auth/sso/ {
+        proxy_pass http://backend:8010;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_set_header Content-Type $content_type;
+        proxy_set_header Content-Length $content_length;
+        proxy_request_buffering on;
+    }
+
+    location /auth/api/ {
+        proxy_pass http://backend:8010;
+        proxy_set_header Host $host;
+        proxy_set_header X-Real-IP $remote_addr;
+        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+        proxy_set_header X-Forwarded-Proto $scheme;
+        proxy_set_header Content-Type $content_type;
+        proxy_set_header Content-Length $content_length;
+        proxy_request_buffering on;
+    }
+
     # WebSocket 代理
     location /ws/ {
         proxy_pass http://backend:8010;

+ 30 - 12
frontend/src/App.tsx

@@ -1,6 +1,7 @@
 import { lazy, Suspense } from 'react'
-import { Routes, Route } from 'react-router-dom'
+import { Routes, Route, Navigate } from 'react-router-dom'
 import { Layout } from './components/layout/Layout'
+import { useAuth } from './contexts/AuthContext'
 
 const Dashboard = lazy(() => import('./pages/Dashboard').then(m => ({ default: m.Dashboard })))
 const Models = lazy(() => import('./pages/Models').then(m => ({ default: m.Models })))
@@ -9,23 +10,40 @@ const Training = lazy(() => import('./pages/Training').then(m => ({ default: m.T
 const Evaluation = lazy(() => import('./pages/Evaluation').then(m => ({ default: m.Evaluation })))
 const Deployment = lazy(() => import('./pages/Deployment').then(m => ({ default: m.Deployment })))
 const Inference = lazy(() => import('./pages/Inference').then(m => ({ default: m.Inference })))
+const Login = lazy(() => import('./pages/AuthLogin').then(m => ({ default: m.Login })))
+const AuthCallback = lazy(() => import('./pages/AuthCallback').then(m => ({ default: m.AuthCallback })))
 
 function PageFallback() {
   return <div style={{ padding: 24, color: '#999' }}>加载中...</div>
 }
 
+function AuthRoute({ children }: { children: React.ReactNode }) {
+  const { isAuthenticated } = useAuth()
+  return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />
+}
+
 export default function App() {
   return (
-    <Layout>
-      <Routes>
-        <Route path="/" element={<Suspense fallback={<PageFallback />}><Dashboard /></Suspense>} />
-        <Route path="/models" element={<Suspense fallback={<PageFallback />}><Models /></Suspense>} />
-        <Route path="/datasets" element={<Suspense fallback={<PageFallback />}><Datasets /></Suspense>} />
-        <Route path="/training" element={<Suspense fallback={<PageFallback />}><Training /></Suspense>} />
-        <Route path="/evaluation" element={<Suspense fallback={<PageFallback />}><Evaluation /></Suspense>} />
-        <Route path="/deployment" element={<Suspense fallback={<PageFallback />}><Deployment /></Suspense>} />
-        <Route path="/inference" element={<Suspense fallback={<PageFallback />}><Inference /></Suspense>} />
-      </Routes>
-    </Layout>
+    <Routes>
+      <Route path="/login" element={<Suspense fallback={<PageFallback />}><Login /></Suspense>} />
+      <Route path="/auth/callback" element={<Suspense fallback={<PageFallback />}><AuthCallback /></Suspense>} />
+      <Route path="/*" element={
+        <AuthRoute>
+          <Layout>
+            <Suspense fallback={<PageFallback />}>
+              <Routes>
+                <Route path="/" element={<Dashboard />} />
+                <Route path="/models" element={<Models />} />
+                <Route path="/datasets" element={<Datasets />} />
+                <Route path="/training" element={<Training />} />
+                <Route path="/evaluation" element={<Evaluation />} />
+                <Route path="/deployment" element={<Deployment />} />
+                <Route path="/inference" element={<Inference />} />
+              </Routes>
+            </Suspense>
+          </Layout>
+        </AuthRoute>
+      } />
+    </Routes>
   )
 }

+ 61 - 3
frontend/src/api/client.ts

@@ -1,10 +1,68 @@
-// 统一的 fetch 包装器:非 2xx 状态码自动抛出错误
+// 统一的 fetch 包装器:自动携带 Token、处理刷新和滑动过期
+let tokenRefreshPromise: Promise<string> | null = null
+
+async function doRefreshToken(rt: string): Promise<string> {
+  tokenRefreshPromise = (async () => {
+    try {
+      const res = await fetch('/api/v1/auth/refresh', {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ refresh_token: rt }),
+      })
+      if (!res.ok) throw new Error('Refresh failed')
+      const data = await res.json()
+      const newToken = data.data.token
+      const newRt = data.data.refresh_token
+      localStorage.setItem('token', newToken)
+      localStorage.setItem('refresh_token', newRt)
+      return newToken
+    } finally {
+      tokenRefreshPromise = null
+    }
+  })()
+  return tokenRefreshPromise
+}
+
 async function apiFetch(url: string, init?: RequestInit): Promise<Response> {
-  const res = await fetch(url, init)
+  const headers: Record<string, string> = { ...(init?.headers as Record<string, string> || {}) }
+  const storedToken = localStorage.getItem('token')
+  if (storedToken) {
+    headers['Authorization'] = `Bearer ${storedToken}`
+  }
+  // FormData 时不要设 Content-Type
+  if (!(init?.body instanceof FormData)) {
+    headers['Content-Type'] = headers['Content-Type'] || 'application/json'
+  }
+
+  let res = await fetch(url, { ...init, headers })
+
+  // 滑动过期:检测 X-New-Token 响应头
+  const newToken = res.headers.get('X-New-Token')
+  if (newToken) {
+    localStorage.setItem('token', newToken)
+  }
+
+  // 401 时尝试用 refresh_token 刷新一次
+  if (res.status === 401 && storedToken) {
+    const storedRt = localStorage.getItem('refresh_token')
+    if (storedRt) {
+      try {
+        const freshToken = await doRefreshToken(storedRt)
+        headers['Authorization'] = `Bearer ${freshToken}`
+        res = await fetch(url, { ...init, headers })
+      } catch {
+        localStorage.removeItem('token')
+        localStorage.removeItem('refresh_token')
+        localStorage.removeItem('user')
+        window.location.href = '/login'
+      }
+    }
+  }
+
   if (!res.ok) {
     try {
       const err = await res.json()
-      throw new Error(err.detail || err.error || `Request failed: ${res.status}`)
+      throw new Error(err.detail || err.error || err.message || `Request failed: ${res.status}`)
     } catch (e) {
       if (e instanceof Error) throw e
       throw new Error(`Request failed: ${res.status}`)

+ 7 - 1
frontend/src/api/websocket.ts

@@ -8,7 +8,13 @@ class WSManager {
     if (this.ws) return
     const url = baseUrl || (import.meta.env.VITE_WS_BASE_URL as string) || 'ws://127.0.0.1:8000/ws'
     // If relative path, resolve to current origin
-    const wsUrl = url.startsWith('ws') ? url : `${window.location.protocol === 'https:' ? 'wss://' : 'ws://'}${window.location.host}${url}`
+    let wsUrl = url.startsWith('ws') ? url : `${window.location.protocol === 'https:' ? 'wss://' : 'ws://'}${window.location.host}${url}`
+    // Append token for authentication
+    const token = localStorage.getItem('token')
+    if (token) {
+      wsUrl += wsUrl.includes('?') ? '&' : '?'
+      wsUrl += `token=${encodeURIComponent(token)}`
+    }
     this.url = wsUrl
 
     try {

+ 17 - 0
frontend/src/components/layout/Layout.tsx

@@ -1,4 +1,5 @@
 import { Link, useLocation } from 'react-router-dom'
+import { useAuth } from '../../contexts/AuthContext'
 
 const NAV_ITEMS = [
   { path: '/', label: '仪表盘' },
@@ -12,6 +13,7 @@ const NAV_ITEMS = [
 
 export function Layout({ children }: { children: React.ReactNode }) {
   const location = useLocation()
+  const { user, logout } = useAuth()
 
   return (
     <div style={{ display: 'flex', minHeight: '100vh', fontFamily: 'system-ui, sans-serif' }}>
@@ -38,6 +40,21 @@ export function Layout({ children }: { children: React.ReactNode }) {
             {item.label}
           </Link>
         ))}
+        <div style={{ marginTop: 'auto', padding: '16px', borderTop: '1px solid #333' }}>
+          <div style={{ fontSize: 13, color: '#ccc', marginBottom: 8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
+            {user?.real_name || user?.username || '用户'}
+          </div>
+          <button
+            onClick={logout}
+            style={{
+              width: '100%', padding: '6px 12px', background: 'transparent',
+              border: '1px solid #555', borderRadius: 4, color: '#999',
+              cursor: 'pointer', fontSize: 12,
+            }}
+          >
+            退出登录
+          </button>
+        </div>
       </nav>
       <main style={{ flex: 1, padding: 24, background: '#f5f5f5' }}>{children}</main>
     </div>

+ 97 - 0
frontend/src/contexts/AuthContext.tsx

@@ -0,0 +1,97 @@
+import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'
+
+export interface User {
+  id: string
+  username: string
+  email?: string
+  real_name?: string
+  roles: string[]
+  is_active: boolean
+}
+
+interface AuthState {
+  user: User | null
+  token: string | null
+  refreshToken: string | null
+  isAuthenticated: boolean
+  login: (token: string, refreshToken: string, user: User) => void
+  logout: () => Promise<void>
+}
+
+const AuthContext = createContext<AuthState | null>(null)
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+  const [user, setUser] = useState<User | null>(null)
+  const [token, setToken] = useState<string | null>(null)
+  const [refreshToken, setRefreshToken] = useState<string | null>(null)
+
+  useEffect(() => {
+    const stored = localStorage.getItem('token')
+    const storedRt = localStorage.getItem('refresh_token')
+    const storedUser = localStorage.getItem('user')
+    if (stored && storedUser) {
+      setToken(stored)
+      setRefreshToken(storedRt)
+      setUser(JSON.parse(storedUser))
+    }
+  }, [])
+
+  const login = useCallback((t: string, rt: string, u: User) => {
+    setToken(t)
+    setRefreshToken(rt)
+    setUser(u)
+    localStorage.setItem('token', t)
+    localStorage.setItem('refresh_token', rt)
+    localStorage.setItem('user', JSON.stringify(u))
+  }, [])
+
+  const logout = useCallback(async () => {
+    const currentToken = localStorage.getItem('token')
+    const currentRt = localStorage.getItem('refresh_token')
+    if (currentToken && currentRt) {
+      try {
+        const res = await fetch('/api/v1/auth/logout', {
+          method: 'POST',
+          headers: {
+            'Content-Type': 'application/json',
+            Authorization: `Bearer ${currentToken}`,
+          },
+          body: JSON.stringify({ token: currentToken, refresh_token: currentRt }),
+        })
+        const data = await res.json()
+        if (data.data?.sso_logout_url) {
+          clearAuth()
+          window.location.href = data.data.sso_logout_url
+          return
+        }
+      } catch {
+        // ignore network errors during logout
+      }
+    }
+    clearAuth()
+    window.location.href = '/login'
+  }, [])
+
+  const clearAuth = useCallback(() => {
+    setToken(null)
+    setRefreshToken(null)
+    setUser(null)
+    localStorage.removeItem('token')
+    localStorage.removeItem('refresh_token')
+    localStorage.removeItem('user')
+  }, [])
+
+  return (
+    <AuthContext.Provider
+      value={{ user, token, refreshToken, isAuthenticated: !!token, login, logout }}
+    >
+      {children}
+    </AuthContext.Provider>
+  )
+}
+
+export function useAuth() {
+  const ctx = useContext(AuthContext)
+  if (!ctx) throw new Error('useAuth must be used inside AuthProvider')
+  return ctx
+}

+ 5 - 2
frontend/src/main.tsx

@@ -2,13 +2,16 @@ import { StrictMode } from 'react'
 import { createRoot } from 'react-dom/client'
 import { BrowserRouter } from 'react-router-dom'
 import { Toaster } from 'sonner'
+import { AuthProvider } from './contexts/AuthContext'
 import App from './App'
 
 createRoot(document.getElementById('root')!).render(
   <StrictMode>
     <BrowserRouter>
-      <App />
-      <Toaster position="top-right" />
+      <AuthProvider>
+        <App />
+        <Toaster position="top-right" />
+      </AuthProvider>
     </BrowserRouter>
   </StrictMode>,
 )

+ 55 - 0
frontend/src/pages/AuthCallback.tsx

@@ -0,0 +1,55 @@
+import { useEffect, useState } from 'react'
+import { useNavigate, useSearchParams } from 'react-router-dom'
+import { useAuth } from '../contexts/AuthContext'
+
+export function AuthCallback() {
+  const [searchParams] = useSearchParams()
+  const navigate = useNavigate()
+  const { login } = useAuth()
+  const [error, setError] = useState('')
+
+  useEffect(() => {
+    const code = searchParams.get('code')
+    if (!code) {
+      setError('缺少授权码')
+      setTimeout(() => navigate('/login'), 3000)
+      return
+    }
+
+    fetch('/api/oauth/exchange-code', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ code }),
+    })
+      .then(r => r.json())
+      .then(result => {
+        if (result.code === '000000') {
+          login(result.data.token, result.data.refresh_token, result.data.user)
+          navigate('/')
+        } else {
+          setError(result.message || '登录失败')
+          setTimeout(() => navigate('/login'), 3000)
+        }
+      })
+      .catch(() => {
+        setError('网络错误,请重试')
+        setTimeout(() => navigate('/login'), 3000)
+      })
+  }, [searchParams, login, navigate])
+
+  if (error) {
+    return (
+      <div style={{ padding: 40, textAlign: 'center', background: '#0f0f23', minHeight: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', color: '#fff' }}>
+        <h2>登录失败</h2>
+        <p style={{ color: '#e94560' }}>{error}</p>
+        <p style={{ color: '#999', fontSize: 14 }}>3秒后跳转到登录页...</p>
+      </div>
+    )
+  }
+
+  return (
+    <div style={{ padding: 40, textAlign: 'center', background: '#0f0f23', minHeight: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', color: '#fff' }}>
+      <h2>正在登录,请稍候...</h2>
+    </div>
+  )
+}

+ 54 - 0
frontend/src/pages/AuthLogin.tsx

@@ -0,0 +1,54 @@
+import { useEffect } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { useAuth } from '../contexts/AuthContext'
+
+export function Login() {
+  const { isAuthenticated } = useAuth()
+  const navigate = useNavigate()
+
+  useEffect(() => {
+    if (isAuthenticated) navigate('/')
+  }, [isAuthenticated, navigate])
+
+  const handleSSOLogin = async () => {
+    try {
+      const res = await fetch('/auth/sso/authorize?redirect=true')
+      // 如果不是 redirect=true,则获取 URL 并跳转
+      if (!res.redirected) {
+        const data = await res.json()
+        window.location.href = data.data?.authorize_url
+      }
+    } catch {
+      // 降级:直接跳转 SSO 授权页
+      window.location.href = '/auth/sso/authorize?redirect=true'
+    }
+  }
+
+  return (
+    <div style={{
+      display: 'flex', justifyContent: 'center', alignItems: 'center',
+      minHeight: '100vh', background: '#0f0f23',
+    }}>
+      <div style={{
+        background: '#1a1a2e', borderRadius: 12, padding: 48, width: 420,
+        boxShadow: '0 4px 24px rgba(0,0,0,0.4)', textAlign: 'center',
+        border: '1px solid #333',
+      }}>
+        <h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 8, color: '#fff' }}>
+          PEFT Fine-Tuning Platform
+        </h1>
+        <p style={{ color: '#999', marginBottom: 32, fontSize: 14 }}>统一认证登录</p>
+        <button
+          onClick={handleSSOLogin}
+          style={{
+            width: '100%', padding: '12px 24px', borderRadius: 8,
+            border: 'none', background: '#e94560', color: '#fff',
+            fontSize: 16, fontWeight: 600, cursor: 'pointer',
+          }}
+        >
+          使用统一认证平台登录
+        </button>
+      </div>
+    </div>
+  )
+}

+ 61 - 19
frontend/src/pages/Deployment.tsx

@@ -1,42 +1,73 @@
-import { useState } from 'react'
-import api, { DeployResponse } from '../api/client'
+import { useState, useEffect } from 'react'
+import api, { DeployResponse, TrainingJob } from '../api/client'
+
+const EXPORT_FORMATS = [
+  { value: 'safetensors', label: 'SafeTensors (推荐)' },
+  { value: 'pytorch', label: 'PyTorch (.bin)' },
+  { value: 'gguf', label: 'GGUF (llama.cpp)' },
+]
 
 export function Deployment() {
+  const [jobs, setJobs] = useState<TrainingJob[]>([])
   const [jobId, setJobId] = useState('')
   const [mergeWithBase, setMergeWithBase] = useState(false)
   const [exportFormat, setExportFormat] = useState('safetensors')
   const [running, setRunning] = useState(false)
   const [result, setResult] = useState<DeployResponse | null>(null)
+  const [error, setError] = useState('')
+
+  useEffect(() => {
+    api.training.list()
+      .then(data => setJobs(data.filter(j => j.status === 'completed')))
+      .catch(() => setJobs([]))
+  }, [])
 
   const handleExport = () => {
     if (!jobId.trim()) return
     setRunning(true)
+    setError('')
+    setResult(null)
     api.deployment.export({
       job_id: jobId,
       merge_with_base: mergeWithBase,
       export_format: exportFormat,
     })
       .then(setResult)
-      .catch(console.error)
+      .catch(err => setError(err instanceof Error ? err.message : '导出失败'))
       .finally(() => setRunning(false))
   }
 
   return (
     <div>
-      <h1>部署</h1>
+      <h1 style={{ margin: 0, fontSize: 22, fontWeight: 700 }}>模型部署</h1>
+      <p style={{ color: '#666', fontSize: 13, margin: '4px 0 16px' }}>导出训练好的模型用于生产部署</p>
 
-      <div style={{ marginTop: 16, background: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
-        <h2 style={{ margin: '0 0 16px', fontSize: 16 }}>导出 Adapter</h2>
+      <div style={{ background: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 3px rgba(0,0,0,0.08)' }}>
+        <h2 style={{ margin: '0 0 16px', fontSize: 15, fontWeight: 600 }}>导出 Adapter</h2>
         <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 12, alignItems: 'end' }}>
           <div>
-            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>训练任务 ID</label>
-            <input value={jobId} onChange={e => setJobId(e.target.value)} placeholder="任务 ID" style={{ width: '100%', padding: '6px 8px', borderRadius: 4, border: '1px solid #ccc', boxSizing: 'border-box' }} />
+            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>训练任务</label>
+            <select
+              value={jobId}
+              onChange={e => setJobId(e.target.value)}
+              style={{ width: '100%', padding: '8px 12px', borderRadius: 4, border: '1px solid #d0d0d0', boxSizing: 'border-box', fontSize: 13 }}
+            >
+              <option value="" disabled>选择已完成的训练任务</option>
+              {jobs.map(j => (
+                <option key={j.id} value={j.id}>{j.id.slice(0, 8)}... — {j.model_id}</option>
+              ))}
+            </select>
           </div>
           <div>
             <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>导出格式</label>
-            <select value={exportFormat} onChange={e => setExportFormat(e.target.value)} style={{ width: '100%', padding: '6px 8px', borderRadius: 4, border: '1px solid #ccc', boxSizing: 'border-box' }}>
-              <option value="safetensors">SafeTensors</option>
-              <option value="gguf">GGUF</option>
+            <select
+              value={exportFormat}
+              onChange={e => setExportFormat(e.target.value)}
+              style={{ width: '100%', padding: '8px 12px', borderRadius: 4, border: '1px solid #d0d0d0', boxSizing: 'border-box', fontSize: 13 }}
+            >
+              {EXPORT_FORMATS.map(f => (
+                <option key={f.value} value={f.value}>{f.label}</option>
+              ))}
             </select>
           </div>
           <div>
@@ -46,22 +77,33 @@ export function Deployment() {
             </label>
           </div>
         </div>
+
+        {error && (
+          <div style={{ marginTop: 12, padding: 10, background: '#fff2f0', borderRadius: 4, fontSize: 13, color: '#cf1322', border: '1px solid #ffccc7' }}>
+            {error}
+          </div>
+        )}
+
         <button
           onClick={handleExport}
-          disabled={running}
-          style={{ marginTop: 16, padding: '8px 24px', borderRadius: 4, border: 'none', background: '#e94560', color: '#fff', cursor: 'pointer', opacity: running ? 0.6 : 1 }}
+          disabled={running || !jobId}
+          style={{
+            marginTop: 16, padding: '10px 32px', borderRadius: 6, border: 'none',
+            background: '#e94560', color: '#fff', cursor: 'pointer',
+            opacity: (running || !jobId) ? 0.5 : 1, fontSize: 14, fontWeight: 600,
+          }}
         >
           {running ? '导出中...' : '开始导出'}
         </button>
       </div>
 
       {result && (
-        <div style={{ marginTop: 24, background: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
-          <h3>导出状态</h3>
-          <p><strong>任务 ID:</strong> {result.job_id}</p>
-          <p><strong>状态:</strong> <span style={{ color: result.error ? '#e94560' : '#4caf50' }}>{result.status}</span></p>
-          {result.output_path && <p><strong>输出路径:</strong> {result.output_path}</p>}
-          {result.error && <p style={{ color: '#e94560' }}><strong>错误:</strong> {result.error}</p>}
+        <div style={{ marginTop: 24, background: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 3px rgba(0,0,0,0.08)' }}>
+          <h3 style={{ margin: '0 0 12px', fontSize: 15, fontWeight: 600 }}>导出状态</h3>
+          <p style={{ fontSize: 13 }}><strong>任务 ID:</strong> <code style={{ background: '#f5f5f5', padding: '2px 6px', borderRadius: 3 }}>{result.job_id}</code></p>
+          <p style={{ fontSize: 13 }}><strong>状态:</strong> <span style={{ color: result.error ? '#e94560' : '#4caf50', fontWeight: 600 }}>{result.status}</span></p>
+          {result.output_path && <p style={{ fontSize: 13 }}><strong>输出路径:</strong> <code style={{ background: '#f5f5f5', padding: '2px 6px', borderRadius: 3 }}>{result.output_path}</code></p>}
+          {result.error && <p style={{ color: '#e94560', fontSize: 13 }}><strong>错误:</strong> {result.error}</p>}
         </div>
       )}
     </div>

+ 67 - 22
frontend/src/pages/Evaluation.tsx

@@ -1,74 +1,119 @@
-import { useState } from 'react'
-import api, { EvalResult } from '../api/client'
+import { useState, useEffect } from 'react'
+import api, { EvalResult, TrainingJob } from '../api/client'
+
+const METRICS_PRESETS = [
+  { value: 'perplexity,loss', label: '困惑度 + Loss' },
+  { value: 'perplexity', label: '困惑度' },
+  { value: 'loss', label: 'Loss' },
+  { value: 'accuracy', label: '准确率' },
+]
 
 export function Evaluation() {
+  const [jobs, setJobs] = useState<TrainingJob[]>([])
   const [jobId, setJobId] = useState('')
   const [metrics, setMetrics] = useState('perplexity,loss')
   const [running, setRunning] = useState(false)
   const [result, setResult] = useState<EvalResult | null>(null)
+  const [error, setError] = useState('')
+
+  useEffect(() => {
+    api.training.list()
+      .then(data => setJobs(data.filter(j => j.status === 'completed')))
+      .catch(() => setJobs([]))
+  }, [])
 
   const handleRun = () => {
     if (!jobId.trim()) return
     setRunning(true)
+    setError('')
+    setResult(null)
     api.evaluation.run({
       job_id: jobId,
       metrics: metrics.split(',').map(s => s.trim()).filter(Boolean),
     })
-      .then(setResult)
-      .catch(console.error)
+      .then(res => setResult(res))
+      .catch(err => setError(err instanceof Error ? err.message : '评估失败'))
       .finally(() => setRunning(false))
   }
 
   return (
     <div>
-      <h1>评估</h1>
+      <h1 style={{ margin: 0, fontSize: 22, fontWeight: 700 }}>模型评估</h1>
+      <p style={{ color: '#666', fontSize: 13, margin: '4px 0 16px' }}>对已完成的训练任务进行性能评估</p>
 
-      <div style={{ marginTop: 16, background: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
-        <h2 style={{ margin: '0 0 16px', fontSize: 16 }}>运行评估</h2>
+      <div style={{ background: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 3px rgba(0,0,0,0.08)' }}>
+        <h2 style={{ margin: '0 0 16px', fontSize: 15, fontWeight: 600 }}>运行评估</h2>
         <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
           <div>
-            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>训练任务 ID</label>
-            <input value={jobId} onChange={e => setJobId(e.target.value)} placeholder="任务 ID" style={{ width: '100%', padding: '6px 8px', borderRadius: 4, border: '1px solid #ccc', boxSizing: 'border-box' }} />
+            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>训练任务</label>
+            <select
+              value={jobId}
+              onChange={e => setJobId(e.target.value)}
+              style={{ width: '100%', padding: '8px 12px', borderRadius: 4, border: '1px solid #d0d0d0', boxSizing: 'border-box', fontSize: 13 }}
+            >
+              <option value="" disabled>选择已完成的训练任务</option>
+              {jobs.map(j => (
+                <option key={j.id} value={j.id}>{j.id.slice(0, 8)}... — {j.model_id}</option>
+              ))}
+            </select>
           </div>
           <div>
-            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>评估指标 (逗号分隔)</label>
-            <input value={metrics} onChange={e => setMetrics(e.target.value)} placeholder="perplexity,loss" style={{ width: '100%', padding: '6px 8px', borderRadius: 4, border: '1px solid #ccc', boxSizing: 'border-box' }} />
+            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>评估指标</label>
+            <select
+              value={metrics}
+              onChange={e => setMetrics(e.target.value)}
+              style={{ width: '100%', padding: '8px 12px', borderRadius: 4, border: '1px solid #d0d0d0', boxSizing: 'border-box', fontSize: 13 }}
+            >
+              {METRICS_PRESETS.map(m => (
+                <option key={m.value} value={m.value}>{m.label}</option>
+              ))}
+            </select>
           </div>
         </div>
+
+        {error && (
+          <div style={{ marginTop: 12, padding: 10, background: '#fff2f0', borderRadius: 4, fontSize: 13, color: '#cf1322', border: '1px solid #ffccc7' }}>
+            {error}
+          </div>
+        )}
+
         <button
           onClick={handleRun}
-          disabled={running}
-          style={{ marginTop: 16, padding: '8px 24px', borderRadius: 4, border: 'none', background: '#e94560', color: '#fff', cursor: 'pointer', opacity: running ? 0.6 : 1 }}
+          disabled={running || !jobId}
+          style={{
+            marginTop: 16, padding: '10px 32px', borderRadius: 6, border: 'none',
+            background: '#e94560', color: '#fff', cursor: 'pointer',
+            opacity: (running || !jobId) ? 0.5 : 1, fontSize: 14, fontWeight: 600,
+          }}
         >
           {running ? '评估中...' : '启动评估'}
         </button>
       </div>
 
       {result && (
-        <div style={{ marginTop: 24, background: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
-          <h3>评估结果</h3>
-          <p><strong>评估 ID:</strong> {result.id}</p>
-          <p><strong>任务 ID:</strong> {result.job_id}</p>
+        <div style={{ marginTop: 24, background: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 3px rgba(0,0,0,0.08)' }}>
+          <h3 style={{ margin: '0 0 12px', fontSize: 15, fontWeight: 600 }}>评估结果</h3>
+          <p style={{ fontSize: 13, color: '#666' }}>评估 ID: <code style={{ background: '#f5f5f5', padding: '2px 6px', borderRadius: 3 }}>{result.id}</code></p>
           {Object.keys(result.metrics).length > 0
             ? (
               <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14, marginTop: 12 }}>
                 <thead>
                   <tr style={{ borderBottom: '2px solid #eee', textAlign: 'left' }}>
-                    <th style={{ padding: '8px 0' }}>指标</th>
-                    <th>值</th>
+                    <th style={{ padding: '8px 12px', fontSize: 12, color: '#666' }}>指标</th>
+                    <th style={{ padding: '8px 12px', fontSize: 12, color: '#666' }}>值</th>
                   </tr>
                 </thead>
                 <tbody>
                   {Object.entries(result.metrics).map(([k, v]) => (
                     <tr key={k} style={{ borderBottom: '1px solid #eee' }}>
-                      <td style={{ padding: '6px 0' }}>{k}</td>
-                      <td>{String(v)}</td>
+                      <td style={{ padding: '8px 12px' }}>{k}</td>
+                      <td style={{ padding: '8px 12px', fontFamily: 'monospace' }}>{String(v)}</td>
                     </tr>
                   ))}
                 </tbody>
               </table>
             )
-            : <p style={{ color: '#999', fontSize: 14 }}>评估结果为空(后端尚未返回数据)</p>
+            : <p style={{ color: '#999', fontSize: 13 }}>评估结果为空(后端尚未返回数据)</p>
           }
         </div>
       )}

+ 154 - 55
frontend/src/pages/Inference.tsx

@@ -1,6 +1,66 @@
 import { useState, useEffect } from 'react'
 import api, { AdapterInfo } from '../api/client'
 
+// --- 预设值 ---
+const MAX_TOKEN_PRESETS = [
+  { value: 64, label: '64 (快速)' },
+  { value: 128, label: '128' },
+  { value: 256, label: '256 (推荐)' },
+  { value: 512, label: '512' },
+  { value: 1024, label: '1024' },
+  { value: 2048, label: '2048 (长输出)' },
+]
+
+const TEMP_PRESETS = [
+  { value: 0.1, label: '0.1 (确定性)' },
+  { value: 0.3, label: '0.3' },
+  { value: 0.5, label: '0.5' },
+  { value: 0.7, label: '0.7' },
+  { value: 0.8, label: '0.8 (推荐)' },
+  { value: 1.0, label: '1.0' },
+  { value: 1.5, label: '1.5 (高创意)' },
+]
+
+const TOP_P_PRESETS = [
+  { value: 0.5, label: '0.5 (严格)' },
+  { value: 0.8, label: '0.8' },
+  { value: 0.9, label: '0.9' },
+  { value: 0.95, label: '0.95 (推荐)' },
+  { value: 1.0, label: '1.0 (无限制)' },
+]
+
+const REP_PENALTY_PRESETS = [
+  { value: 1.0, label: '1.0 (无惩罚)' },
+  { value: 1.05, label: '1.05' },
+  { value: 1.1, label: '1.1 (推荐)' },
+  { value: 1.2, label: '1.2' },
+  { value: 1.5, label: '1.5 (强惩罚)' },
+]
+
+interface SelectProps {
+  options: { value: number | string; label: string }[]
+  value: number | string
+  onChange: (value: number | string) => void
+}
+
+function Select({ options, value, onChange }: SelectProps) {
+  return (
+    <select
+      value={value}
+      onChange={e => onChange(e.target.value)}
+      style={{
+        width: '100%', padding: '6px 8px', borderRadius: 4,
+        border: '1px solid #d0d0d0', boxSizing: 'border-box',
+        fontSize: 13, background: '#fff', cursor: 'pointer',
+      }}
+    >
+      {options.map(o => (
+        <option key={o.value} value={o.value}>{o.label}</option>
+      ))}
+    </select>
+  )
+}
+
 export function Inference() {
   const [adapters, setAdapters] = useState<AdapterInfo[]>([])
   const [adapterId, setAdapterId] = useState('')
@@ -54,66 +114,77 @@ export function Inference() {
 
   return (
     <div>
-      <h1>模型推理</h1>
+      <h1 style={{ margin: 0, fontSize: 22, fontWeight: 700 }}>模型推理</h1>
+      <p style={{ color: '#666', fontSize: 13, margin: '4px 0 16px' }}>使用训练好的 Adapter 进行文本生成</p>
 
       {/* Adapter selector */}
-      <div style={{ marginTop: 16, background: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
-        <h2 style={{ margin: '0 0 16px', fontSize: 16 }}>选择 Adapter</h2>
+      <div style={{ background: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 3px rgba(0,0,0,0.08)' }}>
+        <h2 style={{ margin: '0 0 12px', fontSize: 15, fontWeight: 600 }}>选择 Adapter</h2>
         {adapters.length === 0 ? (
-          <p style={{ color: '#999', fontSize: 14 }}>暂无可用的 adapter,请先完成训练任务</p>
+          <div style={{ padding: 20, textAlign: 'center', color: '#999', fontSize: 13, background: '#fafafa', borderRadius: 6 }}>
+            暂无可用的 adapter,请先完成训练任务
+          </div>
         ) : (
           <select
             value={adapterId}
             onChange={e => setAdapterId(e.target.value)}
-            style={{ padding: '6px 12px', borderRadius: 4, border: '1px solid #ccc', width: '100%', maxWidth: 500 }}
+            style={{ padding: '8px 12px', borderRadius: 4, border: '1px solid #d0d0d0', width: '100%', maxWidth: 500, fontSize: 13 }}
           >
             {adapters.map(a => (
-              <option key={a.id} value={a.id}>{a.id} (base: {a.base_model})</option>
+              <option key={a.id} value={a.id}>{a.id} — {a.base_model} ({a.peft_type})</option>
             ))}
           </select>
         )}
       </div>
 
       {/* Prompt input */}
-      <div style={{ marginTop: 16, background: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
-        <h2 style={{ margin: '0 0 16px', fontSize: 16 }}>输入提示词</h2>
+      <div style={{ marginTop: 16, background: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 3px rgba(0,0,0,0.08)' }}>
+        <h2 style={{ margin: '0 0 12px', fontSize: 15, fontWeight: 600 }}>输入提示词</h2>
         <textarea
           value={prompt}
           onChange={e => setPrompt(e.target.value)}
           placeholder="输入你的问题或指令..."
           rows={4}
-          style={{ width: '100%', padding: 12, borderRadius: 4, border: '1px solid #ccc', fontSize: 14, boxSizing: 'border-box', resize: 'vertical' }}
+          style={{ width: '100%', padding: 12, borderRadius: 6, border: '1px solid #d0d0d0', fontSize: 14, boxSizing: 'border-box', resize: 'vertical', lineHeight: 1.6 }}
         />
 
         {/* Generation params */}
-        <div style={{ marginTop: 12, display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12 }}>
-          <div>
-            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>Max Tokens</label>
-            <input type="number" value={maxTokens} onChange={e => setMaxTokens(Number(e.target.value))} min={1} max={4096} style={{ width: '100%', padding: '6px 8px', borderRadius: 4, border: '1px solid #ccc', boxSizing: 'border-box' }} />
-          </div>
-          <div>
-            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>Temperature</label>
-            <input type="number" value={temperature} onChange={e => setTemperature(Number(e.target.value))} min={0} max={2} step={0.1} style={{ width: '100%', padding: '6px 8px', borderRadius: 4, border: '1px solid #ccc', boxSizing: 'border-box' }} />
+        <div style={{ marginTop: 16 }}>
+          <div style={{ fontSize: 13, fontWeight: 600, color: '#666', marginBottom: 8 }}>生成参数</div>
+          <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12 }}>
+            <div>
+              <label style={{ display: 'block', fontSize: 12, color: '#888', marginBottom: 4 }}>最大输出长度</label>
+              <Select options={MAX_TOKEN_PRESETS} value={String(maxTokens)} onChange={v => setMaxTokens(Number(v))} />
+            </div>
+            <div>
+              <label style={{ display: 'block', fontSize: 12, color: '#888', marginBottom: 4 }}>Temperature</label>
+              <Select options={TEMP_PRESETS} value={String(temperature)} onChange={v => setTemperature(Number(v))} />
+            </div>
+            <div>
+              <label style={{ display: 'block', fontSize: 12, color: '#888', marginBottom: 4 }}>Top P</label>
+              <Select options={TOP_P_PRESETS} value={String(topP)} onChange={v => setTopP(Number(v))} />
+            </div>
+            <div>
+              <label style={{ display: 'block', fontSize: 12, color: '#888', marginBottom: 4 }}>重复惩罚</label>
+              <Select options={REP_PENALTY_PRESETS} value={String(repetitionPenalty)} onChange={v => setRepetitionPenalty(Number(v))} />
+            </div>
           </div>
-          <div>
-            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>Top P</label>
-            <input type="number" value={topP} onChange={e => setTopP(Number(e.target.value))} min={0} max={1} step={0.05} style={{ width: '100%', padding: '6px 8px', borderRadius: 4, border: '1px solid #ccc', boxSizing: 'border-box' }} />
-          </div>
-          <div>
-            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>Repetition Penalty</label>
-            <input type="number" value={repetitionPenalty} onChange={e => setRepetitionPenalty(Number(e.target.value))} min={1} max={2} step={0.1} style={{ width: '100%', padding: '6px 8px', borderRadius: 4, border: '1px solid #ccc', boxSizing: 'border-box' }} />
-          </div>
-        </div>
 
-        <label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, cursor: 'pointer', marginTop: 12 }}>
-          <input type="checkbox" checked={doSample} onChange={e => setDoSample(e.target.checked)} />
-          启用采样 (关闭则为 greedy decoding)
-        </label>
+          <label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, cursor: 'pointer', marginTop: 12 }}>
+            <input type="checkbox" checked={doSample} onChange={e => setDoSample(e.target.checked)} />
+            启用随机采样 (关闭则为贪婪解码)
+          </label>
+        </div>
 
         <button
           onClick={handleGenerate}
-          disabled={generating || !adapterId}
-          style={{ marginTop: 16, padding: '8px 24px', borderRadius: 4, border: 'none', background: '#e94560', color: '#fff', cursor: 'pointer', opacity: generating || !adapterId ? 0.6 : 1 }}
+          disabled={generating || !adapterId || !prompt.trim()}
+          style={{
+            marginTop: 16, padding: '10px 32px', borderRadius: 6, border: 'none',
+            background: '#2196f3', color: '#fff', cursor: 'pointer',
+            opacity: (generating || !adapterId || !prompt.trim()) ? 0.5 : 1,
+            fontSize: 14, fontWeight: 600,
+          }}
         >
           {generating ? '生成中...' : '生成'}
         </button>
@@ -121,38 +192,66 @@ export function Inference() {
 
       {/* Error */}
       {error && (
-        <div style={{ marginTop: 16, padding: 16, background: '#ffebee', borderRadius: 8, color: '#c62828' }}>
-          <strong>错误:</strong> {error}
+        <div style={{ marginTop: 16, padding: 14, background: '#fff2f0', borderRadius: 6, color: '#cf1322', fontSize: 13, border: '1px solid #ffccc7' }}>
+          {error}
         </div>
       )}
 
       {/* Result */}
       {result && (
-        <div style={{ marginTop: 16, background: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
-          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
-            <h2 style={{ margin: 0, fontSize: 16 }}>生成结果</h2>
-            <span style={{ fontSize: 12, color: '#999' }}>{result.tokens_generated} tokens</span>
+        <div style={{ marginTop: 16, background: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 3px rgba(0,0,0,0.08)' }}>
+          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
+            <h2 style={{ margin: 0, fontSize: 15, fontWeight: 600 }}>生成结果</h2>
+            <span style={{ fontSize: 12, color: '#999', background: '#f5f5f5', padding: '2px 10px', borderRadius: 10 }}>
+              {result.tokens_generated} tokens
+            </span>
           </div>
 
-          {/* View mode toggle */}
-          <div style={{ marginBottom: 12 }}>
-            <button
-              onClick={() => setViewMode('full')}
-              style={{ padding: '4px 12px', borderRadius: 4, border: `1px solid ${viewMode === 'full' ? '#e94560' : '#ccc'}`, background: viewMode === 'full' ? '#e94560' : '#fff', color: viewMode === 'full' ? '#fff' : '#333', cursor: 'pointer', marginRight: 8, fontSize: 13 }}
-            >
-              完整输出
-            </button>
-            <button
-              onClick={() => setViewMode('new')}
-              style={{ padding: '4px 12px', borderRadius: 4, border: `1px solid ${viewMode === 'new' ? '#e94560' : '#ccc'}`, background: viewMode === 'new' ? '#e94560' : '#fff', color: viewMode === 'new' ? '#fff' : '#333', cursor: 'pointer', fontSize: 13 }}
-            >
-              仅新生成部分
-            </button>
+          {/* Prompt */}
+          <div style={{ marginBottom: 16 }}>
+            <div style={{ fontSize: 12, color: '#999', marginBottom: 6, fontWeight: 600 }}>Prompt</div>
+            <div style={{ padding: 12, background: '#f0f7ff', borderRadius: 6, fontSize: 14, lineHeight: 1.6 }}>
+              {prompt}
+            </div>
           </div>
 
-          <pre style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word', background: '#f5f5f5', padding: 16, borderRadius: 4, fontSize: 14, lineHeight: 1.6, maxHeight: 400, overflow: 'auto' }}>
-            {viewMode === 'full' ? result.generated_text : result.generated_text.replace(prompt, '').trim()}
-          </pre>
+          {/* Response */}
+          <div>
+            <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
+              <span style={{ fontSize: 12, color: '#999', fontWeight: 600 }}>Response</span>
+              <div style={{ display: 'flex', gap: 4 }}>
+                <button
+                  onClick={() => setViewMode('full')}
+                  style={{
+                    padding: '3px 10px', borderRadius: 4, fontSize: 12, cursor: 'pointer',
+                    border: `1px solid ${viewMode === 'full' ? '#e94560' : '#d0d0d0'}`,
+                    background: viewMode === 'full' ? '#e94560' : '#fff',
+                    color: viewMode === 'full' ? '#fff' : '#666',
+                  }}
+                >
+                  完整输出
+                </button>
+                <button
+                  onClick={() => setViewMode('new')}
+                  style={{
+                    padding: '3px 10px', borderRadius: 4, fontSize: 12, cursor: 'pointer',
+                    border: `1px solid ${viewMode === 'new' ? '#e94560' : '#d0d0d0'}`,
+                    background: viewMode === 'new' ? '#e94560' : '#fff',
+                    color: viewMode === 'new' ? '#fff' : '#666',
+                  }}
+                >
+                  仅新生成
+                </button>
+              </div>
+            </div>
+            <pre style={{
+              whiteSpace: 'pre-wrap', wordBreak: 'break-word', background: '#fafafa',
+              padding: 16, borderRadius: 6, fontSize: 14, lineHeight: 1.6,
+              maxHeight: 400, overflow: 'auto', margin: 0, border: '1px solid #f0f0f0',
+            }}>
+              {viewMode === 'full' ? result.generated_text : result.generated_text.slice(prompt.length).trim()}
+            </pre>
+          </div>
         </div>
       )}
     </div>

+ 275 - 191
frontend/src/pages/Training.tsx

@@ -10,7 +10,7 @@ const MODEL_TYPES = [
 
 const PEFT_METHODS = [
   { value: 'lora', label: 'LoRA' },
-  { value: 'qlora', label: 'QLoRA' },
+  { value: 'qlora', label: 'QLoRA (推荐)' },
   { value: 'ia3', label: 'IA3' },
   { value: 'adalora', label: 'AdaLoRA' },
   { value: 'prefix_tuning', label: 'Prefix Tuning' },
@@ -21,16 +21,91 @@ const TASK_TYPES = [
   { value: 'dpo', label: 'DPO (直接偏好优化)' },
   { value: 'orpo', label: 'ORPO (比值偏好优化)' },
   { value: 'kto', label: 'KTO (Kahneman-Tversky)' },
-  { value: 'rm', label: 'Reward Modeling' },
-  { value: 'ppo', label: 'PPO (强化学习)' },
 ]
 
 const DATASET_TEMPLATES = [
-  { value: 'alpaca', label: 'Alpaca' },
-  { value: 'sharegpt', label: 'ShareGPT' },
-  { value: 'raw', label: 'Raw (直接字段)' },
+  { value: 'alpaca', label: 'Alpaca (instruction/input/output)' },
+  { value: 'sharegpt', label: 'ShareGPT (conversations)' },
+  { value: 'raw', label: 'Raw (text 字段)' },
 ]
 
+// --- 预设值常量 ---
+const EPOCH_PRESETS = [
+  { value: 1, label: '1 (快速验证)' },
+  { value: 2, label: '2' },
+  { value: 3, label: '3 (推荐)' },
+  { value: 5, label: '5' },
+  { value: 10, label: '10 (充分训练)' },
+]
+
+const BATCH_SIZE_PRESETS = [
+  { value: 1, label: '1 (显存受限)' },
+  { value: 2, label: '2' },
+  { value: 4, label: '4 (推荐)' },
+  { value: 8, label: '8' },
+  { value: 16, label: '16' },
+  { value: 32, label: '32' },
+]
+
+const LR_PRESETS = [
+  { value: '1e-3', label: '1e-3 (较大)' },
+  { value: '5e-4', label: '5e-4' },
+  { value: '2e-4', label: '2e-4 (推荐)' },
+  { value: '1e-4', label: '1e-4' },
+  { value: '5e-5', label: '5e-5 (较小)' },
+  { value: '1e-5', label: '1e-5' },
+]
+
+const LORA_R_PRESETS = [
+  { value: 4, label: '4 (轻量)' },
+  { value: 8, label: '8' },
+  { value: 16, label: '16 (推荐)' },
+  { value: 32, label: '32' },
+  { value: 64, label: '64 (高精度)' },
+]
+
+const SEQ_LEN_PRESETS = [
+  { value: 512, label: '512 (短文本)' },
+  { value: 1024, label: '1024' },
+  { value: 2048, label: '2048 (推荐)' },
+  { value: 4096, label: '4096 (长文本)' },
+]
+
+const GRAD_ACC_PRESETS = [
+  { value: 1, label: '1 (无累积)' },
+  { value: 2, label: '2' },
+  { value: 4, label: '4 (推荐)' },
+  { value: 8, label: '8' },
+  { value: 16, label: '16' },
+]
+
+// --- 通用 Select 组件 ---
+interface SelectProps {
+  options: { value: string | number; label: string }[]
+  value: string | number
+  onChange: (value: string | number) => void
+  placeholder?: string
+}
+
+function Select({ options, value, onChange, placeholder }: SelectProps) {
+  return (
+    <select
+      value={value}
+      onChange={e => onChange(e.target.value)}
+      style={{
+        width: '100%', padding: '6px 8px', borderRadius: 4,
+        border: '1px solid #d0d0d0', boxSizing: 'border-box',
+        fontSize: 13, background: '#fff', cursor: 'pointer',
+      }}
+    >
+      {placeholder && <option value="" disabled>{placeholder}</option>}
+      {options.map(o => (
+        <option key={o.value} value={o.value}>{o.label}</option>
+      ))}
+    </select>
+  )
+}
+
 // --- 可搜索下拉框组件 ---
 interface SearchableSelectProps {
   options: { value: string; label: string; subtitle?: string }[]
@@ -46,7 +121,6 @@ function SearchableSelect({ options, value, onChange, placeholder, loading }: Se
   const wrapperRef = useRef<HTMLDivElement>(null)
   const inputRef = useRef<HTMLInputElement>(null)
 
-  // 点击外部关闭
   useEffect(() => {
     const handler = (e: MouseEvent) => {
       if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
@@ -57,7 +131,6 @@ function SearchableSelect({ options, value, onChange, placeholder, loading }: Se
     return () => document.removeEventListener('mousedown', handler)
   }, [open])
 
-  // 打开时自动聚焦输入框
   useEffect(() => {
     if (open) {
       setFilter('')
@@ -65,9 +138,7 @@ function SearchableSelect({ options, value, onChange, placeholder, loading }: Se
     }
   }, [open])
 
-  // 当前选中的 label
   const selectedLabel = options.find(o => o.value === value)?.label ?? ''
-
   const filtered = options.filter(o =>
     o.label.toLowerCase().includes(filter.toLowerCase()) || o.value.toLowerCase().includes(filter.toLowerCase())
   )
@@ -89,46 +160,27 @@ function SearchableSelect({ options, value, onChange, placeholder, loading }: Se
 
   return (
     <div ref={wrapperRef} style={{ position: 'relative' }}>
-      {/* 显示框 */}
       <div
         onClick={toggleOpen}
         style={{
-          padding: '6px 8px',
-          borderRadius: 4,
-          border: '1px solid #ccc',
-          cursor: 'pointer',
-          background: '#fff',
-          minHeight: 32,
-          display: 'flex',
-          alignItems: 'center',
-          justifyContent: 'space-between',
-          fontSize: 14,
+          padding: '6px 8px', borderRadius: 4,
+          border: '1px solid #d0d0d0', cursor: 'pointer', background: '#fff',
+          minHeight: 32, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
+          fontSize: 13, transition: 'border-color 0.2s',
         }}
       >
         <span style={{ color: value ? '#333' : '#999' }}>
           {value ? selectedLabel : placeholder}
         </span>
-        <span style={{ color: '#999', fontSize: 12 }}>{open ? '▲' : '▼'}</span>
+        <span style={{ color: '#999', fontSize: 11 }}>{open ? '▲' : '▼'}</span>
       </div>
 
-      {/* 下拉列表 */}
       {open && (
         <div style={{
-          position: 'absolute',
-          top: '100%',
-          left: 0,
-          right: 0,
-          background: '#fff',
-          border: '1px solid #ccc',
-          borderRadius: 4,
-          boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
-          zIndex: 1000,
-          marginTop: 2,
-          maxHeight: 240,
-          display: 'flex',
-          flexDirection: 'column',
+          position: 'absolute', top: '100%', left: 0, right: 0, background: '#fff',
+          border: '1px solid #d0d0d0', borderRadius: 4, boxShadow: '0 4px 12px rgba(0,0,0,0.12)',
+          zIndex: 1000, marginTop: 2, maxHeight: 240, display: 'flex', flexDirection: 'column',
         }}>
-          {/* 搜索输入 */}
           <input
             ref={inputRef}
             value={filter}
@@ -136,14 +188,10 @@ function SearchableSelect({ options, value, onChange, placeholder, loading }: Se
             onKeyDown={handleKeyDown}
             placeholder="搜索..."
             style={{
-              padding: '6px 8px',
-              border: 'none',
-              borderBottom: '1px solid #eee',
-              outline: 'none',
-              fontSize: 13,
+              padding: '6px 8px', border: 'none', borderBottom: '1px solid #eee',
+              outline: 'none', fontSize: 13,
             }}
           />
-          {/* 选项列表 */}
           <div style={{ overflowY: 'auto', flex: 1 }}>
             {loading && (
               <div style={{ padding: '8px 12px', color: '#999', fontSize: 13 }}>加载中...</div>
@@ -156,18 +204,13 @@ function SearchableSelect({ options, value, onChange, placeholder, loading }: Se
                 key={opt.value}
                 onClick={() => handleSelect(opt.value)}
                 style={{
-                  padding: '8px 12px',
-                  cursor: 'pointer',
+                  padding: '8px 12px', cursor: 'pointer',
                   background: opt.value === value ? '#e94560' : 'transparent',
                   color: opt.value === value ? '#fff' : '#333',
                   fontSize: 13,
                 }}
-                onMouseEnter={e => {
-                  if (opt.value !== value) (e.currentTarget.style.background = '#f5f5f5')
-                }}
-                onMouseLeave={e => {
-                  if (opt.value !== value) (e.currentTarget.style.background = 'transparent')
-                }}
+                onMouseEnter={e => { if (opt.value !== value) e.currentTarget.style.background = '#f5f5f5' }}
+                onMouseLeave={e => { if (opt.value !== value) e.currentTarget.style.background = 'transparent' }}
               >
                 <div>{opt.label}</div>
                 {opt.subtitle && (
@@ -184,24 +227,86 @@ function SearchableSelect({ options, value, onChange, placeholder, loading }: Se
   )
 }
 
+// --- 任务状态颜色 ---
+const statusColor = (status: string) => {
+  switch (status) {
+    case 'completed': return '#4caf50'
+    case 'failed': return '#e94560'
+    case 'training': return '#2196f3'
+    case 'pending': case 'queued': return '#ff9800'
+    case 'preprocessing': return '#9c27b0'
+    case 'cancelled': return '#999'
+    default: return '#666'
+  }
+}
+
+const statusLabel = (status: string) => {
+  switch (status) {
+    case 'completed': return '已完成'
+    case 'failed': return '失败'
+    case 'training': return '训练中'
+    case 'pending': return '等待中'
+    case 'queued': return '排队中'
+    case 'preprocessing': return '预处理'
+    case 'cancelled': return '已取消'
+    default: return status
+  }
+}
+
+// --- 任务行(memo) ---
+const JobRow = memo(function JobRow({ j, onCancel }: { j: TrainingJob; onCancel: (id: string) => void }) {
+  return (
+    <tr style={{ borderBottom: '1px solid #eee' }}>
+      <td style={{ padding: '8px 0', fontFamily: 'monospace', fontSize: 12 }}>{j.id.slice(0, 8)}...</td>
+      <td style={{ fontSize: 13 }}>{j.model_id}</td>
+      <td style={{ fontSize: 13, textTransform: 'uppercase' }}>{j.peft_method}</td>
+      <td>
+        <span style={{
+          display: 'inline-block', padding: '2px 8px', borderRadius: 4, fontSize: 12,
+          background: statusColor(j.status) + '18', color: statusColor(j.status), fontWeight: 600,
+        }}>
+          {statusLabel(j.status)}
+        </span>
+      </td>
+      <td>
+        <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
+          <div style={{ width: 100, height: 6, background: '#eee', borderRadius: 3, overflow: 'hidden' }}>
+            <div style={{ width: `${j.progress}%`, height: '100%', background: statusColor(j.status), transition: 'width 0.3s' }} />
+          </div>
+          <span style={{ fontSize: 12, color: '#666', minWidth: 40 }}>{j.progress.toFixed(1)}%</span>
+        </div>
+      </td>
+      <td style={{ fontSize: 13, fontFamily: 'monospace' }}>{j.loss?.toFixed(4) ?? '-'}</td>
+      <td style={{ fontSize: 12, color: '#666' }}>Epoch {j.current_epoch}</td>
+      <td>
+        {(j.status === 'training' || j.status === 'pending' || j.status === 'queued' || j.status === 'preprocessing') && (
+          <button onClick={() => onCancel(j.id)} style={{ padding: '2px 8px', color: '#e94560', border: '1px solid #e94560', borderRadius: 4, background: 'transparent', cursor: 'pointer', fontSize: 12 }}>取消</button>
+        )}
+      </td>
+    </tr>
+  )
+})
+
 export function Training() {
   const [modelId, setModelId] = useState('')
   const [modelType, setModelType] = useState('text')
   const [datasetId, setDatasetId] = useState('')
-  const [peftMethod, setPeftMethod] = useState('lora')
+  const [peftMethod, setPeftMethod] = useState('qlora')
   const [taskType, setTaskType] = useState('sft')
   const [template, setTemplate] = useState('alpaca')
   const [epochs, setEpochs] = useState(3)
   const [batchSize, setBatchSize] = useState(4)
   const [lr, setLr] = useState('2e-4')
   const [loraR, setLoraR] = useState(16)
+  const [seqLen, setSeqLen] = useState(2048)
+  const [gradAcc, setGradAcc] = useState(4)
   const [deepspeed, setDeepspeed] = useState(false)
 
   const [jobs, setJobs] = useState<TrainingJob[]>([])
   const [loading, setLoading] = useState(false)
   const [submitting, setSubmitting] = useState(false)
+  const [createError, setCreateError] = useState('')
 
-  // 模型和数据集列表
   const [models, setModels] = useState<ModelInfo[]>([])
   const [datasets, setDatasets] = useState<DatasetInfo[]>([])
   const [loadingOptions, setLoadingOptions] = useState(true)
@@ -217,25 +322,19 @@ export function Training() {
     }).finally(() => setLoadingOptions(false))
   }, [])
 
-  // 页面加载时获取选项
-  useEffect(() => {
-    fetchOptions()
-  }, [fetchOptions])
+  useEffect(() => { fetchOptions() }, [fetchOptions])
 
-  // Connect WebSocket on mount
   useEffect(() => {
     wsManager.connect()
     return () => wsManager.disconnect()
   }, [])
 
-  // 将 jobs 存入 ref 用于比较,避免相同数据触发重渲染
   const jobsRef = useRef<TrainingJob[]>([])
 
   const fetchJobs = () => {
     setLoading(true)
     api.training.list()
       .then(newJobs => {
-        // 仅在数据真正变化时更新 state
         const prev = jobsRef.current
         if (JSON.stringify(prev) !== JSON.stringify(newJobs)) {
           setJobs(newJobs)
@@ -260,166 +359,145 @@ export function Training() {
   const handleCreate = () => {
     if (!modelId.trim() || !datasetId.trim()) return
     setSubmitting(true)
+    setCreateError('')
+
+    const tempId = 'temp-' + Date.now()
+    const tempJob: TrainingJob = {
+      id: tempId, model_id: modelId, model_type: modelType,
+      peft_method: peftMethod, status: 'pending', progress: 0,
+      loss: undefined, created_at: new Date().toISOString(),
+      started_at: undefined, finished_at: undefined,
+      error_message: undefined, adapter_path: undefined,
+      current_epoch: 0, current_step: 0, total_steps: 0,
+    }
+    setJobs(prev => [tempJob, ...prev])
+    setLoading(false)
+
     api.training.create({
-      model_id: modelId,
-      model_type: modelType,
-      dataset_id: datasetId,
-      peft_method: peftMethod,
-      task_type: taskType,
-      dataset_template: template,
-      epochs,
-      batch_size: batchSize,
-      learning_rate: parseFloat(lr),
-      lora_r: loraR,
-      lora_alpha: loraR * 2,
-      deepspeed: deepspeed,
+      model_id: modelId, model_type: modelType, dataset_id: datasetId,
+      peft_method: peftMethod, task_type: taskType, dataset_template: template,
+      epochs, batch_size: batchSize, gradient_accumulation: gradAcc,
+      max_seq_length: seqLen, learning_rate: parseFloat(lr),
+      lora_r: loraR, lora_alpha: loraR * 2, deepspeed,
     })
       .then(() => {
         setModelId('')
         setDatasetId('')
+        setJobs(prev => prev.filter(j => j.id !== tempId))
         fetchJobs()
         fetchOptions()
       })
-      .catch(console.error)
+      .catch(err => {
+        setJobs(prev => prev.filter(j => j.id !== tempId))
+        setCreateError(err instanceof Error ? err.message : '创建失败')
+      })
       .finally(() => setSubmitting(false))
   }
 
   const handleCancel = (id: string) => {
-    api.training.cancel(id)
-      .then(() => fetchJobs())
-      .catch(console.error)
-  }
-
-// --- 任务状态颜色 ---
-const statusColor = (status: string) => {
-  switch (status) {
-    case 'completed': return '#4caf50'
-    case 'failed': return '#e94560'
-    case 'training': return '#2196f3'
-    case 'pending': case 'queued': return '#ff9800'
-    case 'preprocessing': return '#9c27b0'
-    case 'cancelled': return '#999'
-    default: return '#666'
+    api.training.cancel(id).then(() => fetchJobs()).catch(console.error)
   }
-}
 
-// --- 任务行(memo 避免父组件渲染时重渲染) ---
-const JobRow = memo(function JobRow({ j, onCancel }: { j: TrainingJob; onCancel: (id: string) => void }) {
-  return (
-    <tr style={{ borderBottom: '1px solid #eee' }}>
-      <td style={{ padding: '8px 0', fontFamily: 'monospace', fontSize: 12 }}>{j.id.slice(0, 8)}...</td>
-      <td>{j.model_id}</td>
-      <td>{j.peft_method}</td>
-      <td style={{ fontSize: 12 }}>{j.status === 'preprocessing' ? '预处理' : j.status === 'training' ? '训练中' : j.status}</td>
-      <td style={{ color: statusColor(j.status), fontWeight: 600 }}>{j.status}</td>
-      <td>
-        <div style={{ width: 120, height: 6, background: '#eee', borderRadius: 3, overflow: 'hidden' }}>
-          <div style={{ width: `${j.progress}%`, height: '100%', background: j.status === 'failed' ? '#e94560' : '#4caf50', transition: 'width 0.3s' }} />
-        </div>
-        <span style={{ fontSize: 11, color: '#999' }}>{j.progress.toFixed(1)}%</span>
-      </td>
-      <td>{j.loss?.toFixed(4) ?? '-'}</td>
-      <td>
-        {(j.status === 'training' || j.status === 'pending' || j.status === 'queued' || j.status === 'preprocessing') && (
-          <button onClick={() => onCancel(j.id)} style={{ padding: '2px 8px', color: '#e94560', border: '1px solid #e94560', borderRadius: 4, background: 'transparent', cursor: 'pointer' }}>取消</button>
-        )}
-      </td>
-    </tr>
-  )
-})
-
-  // 构建下拉选项
   const modelOptions = models.map(m => ({
-    value: m.id,
-    label: m.id,
-    subtitle: `${m.model_type}${m.is_downloaded ? ' ✓' : ''}`,
+    value: m.id, label: m.id, subtitle: `${m.model_type}${m.is_downloaded ? ' ✓ 已下载' : ''}`,
   }))
 
   const datasetOptions = datasets.map(d => ({
-    value: d.id,
-    label: d.name,
-    subtitle: `${d.format} · ${d.record_count} 条`,
+    value: d.id, label: d.name, subtitle: `${d.format} · ${d.record_count} 条`,
   }))
 
   return (
     <div>
-      <h1>训练任务</h1>
+      <h1 style={{ margin: 0, fontSize: 22, fontWeight: 700 }}>训练任务</h1>
+      <p style={{ color: '#666', fontSize: 13, margin: '4px 0 16px' }}>创建和管理模型微调任务</p>
 
       {/* Create form */}
-      <div style={{ marginTop: 16, background: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 3px rgba(0,0,0,0.1)' }}>
-        <h2 style={{ margin: '0 0 16px', fontSize: 16 }}>创建训练任务</h2>
-        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12 }}>
+      <div style={{ background: '#fff', borderRadius: 8, padding: 20, boxShadow: '0 1px 3px rgba(0,0,0,0.08)' }}>
+        <h2 style={{ margin: '0 0 16px', fontSize: 15, fontWeight: 600 }}>创建训练任务</h2>
+
+        {/* 核心配置 */}
+        <div style={{ fontSize: 13, fontWeight: 600, color: '#e94560', marginBottom: 8, paddingBottom: 4, borderBottom: '1px solid #f0f0f0' }}>核心配置</div>
+        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12, marginBottom: 16 }}>
           <div>
-            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>模型</label>
-            <SearchableSelect
-              options={modelOptions}
-              value={modelId}
-              onChange={setModelId}
-              placeholder="选择模型"
-              loading={loadingOptions}
-            />
+            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>基础模型</label>
+            <SearchableSelect options={modelOptions} value={modelId} onChange={setModelId} placeholder="选择已下载的模型" loading={loadingOptions} />
           </div>
           <div>
             <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>模型类型</label>
-            <select value={modelType} onChange={e => setModelType(e.target.value)} style={{ width: '100%', padding: '6px 8px', borderRadius: 4, border: '1px solid #ccc', boxSizing: 'border-box' }}>
-              {MODEL_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
-            </select>
+            <Select options={MODEL_TYPES} value={modelType} onChange={v => setModelType(String(v))} />
           </div>
           <div>
-            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>数据集</label>
-            <SearchableSelect
-              options={datasetOptions}
-              value={datasetId}
-              onChange={setDatasetId}
-              placeholder="选择数据集"
-              loading={loadingOptions}
-            />
+            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>训练数据集</label>
+            <SearchableSelect options={datasetOptions} value={datasetId} onChange={setDatasetId} placeholder="选择数据集" loading={loadingOptions} />
           </div>
           <div>
-            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>训练类型</label>
-            <select value={taskType} onChange={e => setTaskType(e.target.value)} style={{ width: '100%', padding: '6px 8px', borderRadius: 4, border: '1px solid #ccc', boxSizing: 'border-box' }}>
-              {TASK_TYPES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
-            </select>
+            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>训练方法</label>
+            <Select options={TASK_TYPES} value={taskType} onChange={v => setTaskType(String(v))} />
           </div>
           <div>
             <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>数据模板</label>
-            <select value={template} onChange={e => setTemplate(e.target.value)} style={{ width: '100%', padding: '6px 8px', borderRadius: 4, border: '1px solid #ccc', boxSizing: 'border-box' }}>
-              {DATASET_TEMPLATES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
-            </select>
+            <Select options={DATASET_TEMPLATES} value={template} onChange={v => setTemplate(String(v))} />
           </div>
           <div>
             <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>PEFT 方法</label>
-            <select value={peftMethod} onChange={e => setPeftMethod(e.target.value)} style={{ width: '100%', padding: '6px 8px', borderRadius: 4, border: '1px solid #ccc', boxSizing: 'border-box' }}>
-              {PEFT_METHODS.map(m => <option key={m.value} value={m.value}>{m.label}</option>)}
-            </select>
+            <Select options={PEFT_METHODS} value={peftMethod} onChange={v => setPeftMethod(String(v))} />
+          </div>
+        </div>
+
+        {/* 训练超参 */}
+        <div style={{ fontSize: 13, fontWeight: 600, color: '#e94560', marginBottom: 8, paddingBottom: 4, borderBottom: '1px solid #f0f0f0' }}>训练超参数</div>
+        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12, marginBottom: 16 }}>
+          <div>
+            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>训练轮数 (Epochs)</label>
+            <Select options={EPOCH_PRESETS} value={String(epochs)} onChange={v => setEpochs(Number(v))} />
           </div>
           <div>
-            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>Epochs</label>
-            <input type="number" value={epochs} onChange={e => setEpochs(Number(e.target.value))} min={1} style={{ width: '100%', padding: '6px 8px', borderRadius: 4, border: '1px solid #ccc', boxSizing: 'border-box' }} />
+            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>批次大小 (Batch Size)</label>
+            <Select options={BATCH_SIZE_PRESETS} value={String(batchSize)} onChange={v => setBatchSize(Number(v))} />
           </div>
           <div>
-            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>Batch Size</label>
-            <input type="number" value={batchSize} onChange={e => setBatchSize(Number(e.target.value))} min={1} style={{ width: '100%', padding: '6px 8px', borderRadius: 4, border: '1px solid #ccc', boxSizing: 'border-box' }} />
+            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>梯度累积</label>
+            <Select options={GRAD_ACC_PRESETS} value={String(gradAcc)} onChange={v => setGradAcc(Number(v))} />
           </div>
           <div>
-            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>Learning Rate</label>
-            <input value={lr} onChange={e => setLr(e.target.value)} style={{ width: '100%', padding: '6px 8px', borderRadius: 4, border: '1px solid #ccc', boxSizing: 'border-box' }} />
+            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>学习率</label>
+            <Select options={LR_PRESETS} value={lr} onChange={v => setLr(String(v))} />
           </div>
           <div>
-            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>LoRA R</label>
-            <input type="number" value={loraR} onChange={e => setLoraR(Number(e.target.value))} min={1} style={{ width: '100%', padding: '6px 8px', borderRadius: 4, border: '1px solid #ccc', boxSizing: 'border-box' }} />
+            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>最大序列长度</label>
+            <Select options={SEQ_LEN_PRESETS} value={String(seqLen)} onChange={v => setSeqLen(Number(v))} />
           </div>
           <div>
-            <label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, cursor: 'pointer' }}>
-              <input type="checkbox" checked={deepspeed} onChange={e => setDeepspeed(e.target.checked)} />
-              DeepSpeed 多 GPU
-            </label>
+            <label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>LoRA Rank (R)</label>
+            <Select options={LORA_R_PRESETS} value={String(loraR)} onChange={v => setLoraR(Number(v))} />
           </div>
         </div>
+
+        {/* 高级选项 */}
+        <div style={{ fontSize: 13, fontWeight: 600, color: '#e94560', marginBottom: 8, paddingBottom: 4, borderBottom: '1px solid #f0f0f0' }}>高级选项</div>
+        <div style={{ display: 'flex', gap: 16, alignItems: 'center', marginBottom: 16 }}>
+          <label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, cursor: 'pointer' }}>
+            <input type="checkbox" checked={deepspeed} onChange={e => setDeepspeed(e.target.checked)} />
+            DeepSpeed ZeRO-2 (多 GPU)
+          </label>
+        </div>
+
+        {/* 错误提示 */}
+        {createError && (
+          <div style={{ marginBottom: 12, padding: 10, background: '#fff2f0', borderRadius: 4, fontSize: 13, color: '#cf1322', border: '1px solid #ffccc7' }}>
+            {createError}
+          </div>
+        )}
+
         <button
           onClick={handleCreate}
-          disabled={submitting}
-          style={{ marginTop: 16, padding: '8px 24px', borderRadius: 4, border: 'none', background: '#e94560', color: '#fff', cursor: 'pointer', opacity: submitting ? 0.6 : 1 }}
+          disabled={submitting || !modelId || !datasetId}
+          style={{
+            marginTop: 8, padding: '10px 32px', borderRadius: 6, border: 'none',
+            background: '#e94560', color: '#fff', cursor: 'pointer',
+            opacity: (submitting || !modelId || !datasetId) ? 0.5 : 1,
+            fontSize: 14, fontWeight: 600,
+          }}
         >
           {submitting ? '创建中...' : '启动训练'}
         </button>
@@ -428,36 +506,42 @@ const JobRow = memo(function JobRow({ j, onCancel }: { j: TrainingJob; onCancel:
       {/* Job list */}
       <div style={{ marginTop: 24 }}>
         <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
-          <h2 style={{ margin: 0 }}>任务列表</h2>
-          <button onClick={fetchJobs} style={{ padding: '4px 12px', borderRadius: 4, border: '1px solid #ccc', background: '#fff', cursor: 'pointer' }}>刷新</button>
+          <h2 style={{ margin: 0, fontSize: 15, fontWeight: 600 }}>任务列表</h2>
+          <button onClick={fetchJobs} style={{ padding: '4px 12px', borderRadius: 4, border: '1px solid #d0d0d0', background: '#fff', cursor: 'pointer', fontSize: 12 }}>
+            刷新
+          </button>
         </div>
 
-        {loading && <p style={{ color: '#999' }}>加载中...</p>}
+        {loading && <p style={{ color: '#999', fontSize: 13 }}>加载中...</p>}
 
         {!loading && jobs.length === 0 && (
-          <p style={{ color: '#999', fontSize: 14 }}>暂无训练任务</p>
+          <div style={{ padding: 40, textAlign: 'center', color: '#999', fontSize: 14, background: '#fff', borderRadius: 8 }}>
+            暂无训练任务,请先创建训练任务
+          </div>
         )}
 
         {!loading && jobs.length > 0 && (
-          <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
-            <thead>
-              <tr style={{ borderBottom: '2px solid #eee', textAlign: 'left' }}>
-                <th style={{ padding: '8px 0' }}>任务 ID</th>
-                <th>模型</th>
-                <th>PEFT</th>
-                <th>类型</th>
-                <th>状态</th>
-                <th>进度</th>
-                <th>Loss</th>
-                <th>操作</th>
-              </tr>
-            </thead>
-            <tbody>
-              {jobs.map(j => (
-                <JobRow key={j.id} j={j} onCancel={handleCancel} />
-              ))}
-            </tbody>
-          </table>
+          <div style={{ background: '#fff', borderRadius: 8, overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.08)' }}>
+            <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
+              <thead>
+                <tr style={{ background: '#fafafa', borderBottom: '2px solid #eee', textAlign: 'left' }}>
+                  <th style={{ padding: '10px 12px', fontSize: 12, color: '#666', fontWeight: 600 }}>任务 ID</th>
+                  <th style={{ padding: '10px 12px', fontSize: 12, color: '#666', fontWeight: 600 }}>模型</th>
+                  <th style={{ padding: '10px 12px', fontSize: 12, color: '#666', fontWeight: 600 }}>PEFT</th>
+                  <th style={{ padding: '10px 12px', fontSize: 12, color: '#666', fontWeight: 600 }}>状态</th>
+                  <th style={{ padding: '10px 12px', fontSize: 12, color: '#666', fontWeight: 600 }}>进度</th>
+                  <th style={{ padding: '10px 12px', fontSize: 12, color: '#666', fontWeight: 600 }}>Loss</th>
+                  <th style={{ padding: '10px 12px', fontSize: 12, color: '#666', fontWeight: 600 }}>轮次</th>
+                  <th style={{ padding: '10px 12px', fontSize: 12, color: '#666', fontWeight: 600 }}>操作</th>
+                </tr>
+              </thead>
+              <tbody>
+                {jobs.map(j => (
+                  <JobRow key={j.id} j={j} onCancel={handleCancel} />
+                ))}
+              </tbody>
+            </table>
+          </div>
         )}
       </div>
     </div>

+ 4 - 0
frontend/vite.config.ts

@@ -10,6 +10,10 @@ export default defineConfig({
         target: 'http://192.168.91.253:8010',
         changeOrigin: true,
       },
+      '/auth': {
+        target: 'http://192.168.91.253:8010',
+        changeOrigin: true,
+      },
       '/ws': {
         target: 'ws://192.168.91.253:8010',
         ws: true,