feat: add project

This commit is contained in:
qixinbo
2026-03-16 16:12:35 +08:00
parent 1354a0cbc6
commit cec5fde098
23 changed files with 990 additions and 179 deletions
+25 -38
View File
@@ -1,57 +1,35 @@
from typing import List, Dict, Any
from typing import List, Dict, Any, Optional
from fastapi import APIRouter, HTTPException, Depends, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import jwt, JWTError
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.datasource import DataSource
from app.schemas.datasource import DataSourceCreate, DataSourceUpdate, DataSource as DataSourceSchema, DataSourceTestRequest
from app.core.security import SECRET_KEY, ALGORITHM
from app.core.security import get_current_user, get_admin_user, CurrentUser
from app.connectors.factory import get_connector_from_config
from pydantic import BaseModel
router = APIRouter()
security = HTTPBearer()
class CurrentUser(BaseModel):
id: int
username: str
is_admin: bool = False
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> CurrentUser:
unauthorized = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
)
try:
payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError:
raise unauthorized
user_id = payload.get("id")
username = payload.get("sub")
is_admin = bool(payload.get("is_admin", False))
if user_id is None or username is None:
raise unauthorized
return CurrentUser(id=user_id, username=username, is_admin=is_admin)
def get_admin_user(current_user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin permission required")
return current_user
@router.get("/datasources", response_model=List[DataSourceSchema])
def list_datasources(
project_id: Optional[int] = None,
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: CurrentUser = Depends(get_current_user)
):
# Admin can see all, regular user might only see allowed ones?
# For now, let's assume only admin can manage, but maybe regular users can see them to use?
# The requirement says "Add data source config in Admin User Center", implying management is admin-only.
# But usage in chat should be available to users.
# Let's allow read for all authenticated users for now.
datasources = db.query(DataSource).offset(skip).limit(limit).all()
query = db.query(DataSource)
if project_id:
query = query.filter(DataSource.project_id == project_id)
# If not admin, check if user has access to the project
if not current_user.is_admin and project_id:
from app.models.project import Project
project = db.query(Project).filter(Project.id == project_id).first()
if not project or project.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not enough permissions for this project")
datasources = query.offset(skip).limit(limit).all()
# Hide sensitive info for non-admins if necessary, but config usually contains secrets.
# Maybe we should return a sanitized version for regular users?
@@ -75,8 +53,17 @@ def list_datasources(
def create_datasource(
datasource: DataSourceCreate,
db: Session = Depends(get_db),
_: CurrentUser = Depends(get_admin_user)
current_user: CurrentUser = Depends(get_current_user)
):
# Check if project exists and user has access
from app.models.project import Project
project = db.query(Project).filter(Project.id == datasource.project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
if not current_user.is_admin and project.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not enough permissions for this project")
db_datasource = DataSource(**datasource.dict())
db.add(db_datasource)
db.commit()
+92
View File
@@ -0,0 +1,92 @@
from typing import List
from fastapi import APIRouter, HTTPException, Depends, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.project import Project
from app.schemas.project import ProjectCreate, ProjectUpdate, Project as ProjectSchema
from app.core.security import get_current_user, CurrentUser
router = APIRouter()
@router.get("/projects", response_model=List[ProjectSchema])
def list_projects(
skip: int = 0,
limit: int = 100,
db: Session = Depends(get_db),
current_user: CurrentUser = Depends(get_current_user)
):
# Users can only see their own projects, unless they are admin (who can see all?)
# For simplicity, let's allow users to see their own projects.
query = db.query(Project)
if not current_user.is_admin:
query = query.filter(Project.owner_id == current_user.id)
projects = query.offset(skip).limit(limit).all()
return projects
@router.post("/projects", response_model=ProjectSchema)
def create_project(
project: ProjectCreate,
db: Session = Depends(get_db),
current_user: CurrentUser = Depends(get_current_user)
):
db_project = Project(**project.dict(), owner_id=current_user.id)
db.add(db_project)
db.commit()
db.refresh(db_project)
return db_project
@router.get("/projects/{project_id}", response_model=ProjectSchema)
def read_project(
project_id: int,
db: Session = Depends(get_db),
current_user: CurrentUser = Depends(get_current_user)
):
db_project = db.query(Project).filter(Project.id == project_id).first()
if db_project is None:
raise HTTPException(status_code=404, detail="Project not found")
if not current_user.is_admin and db_project.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not enough permissions")
return db_project
@router.put("/projects/{project_id}", response_model=ProjectSchema)
def update_project(
project_id: int,
project: ProjectUpdate,
db: Session = Depends(get_db),
current_user: CurrentUser = Depends(get_current_user)
):
db_project = db.query(Project).filter(Project.id == project_id).first()
if db_project is None:
raise HTTPException(status_code=404, detail="Project not found")
if not current_user.is_admin and db_project.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not enough permissions")
project_data = project.dict(exclude_unset=True)
for key, value in project_data.items():
setattr(db_project, key, value)
db.add(db_project)
db.commit()
db.refresh(db_project)
return db_project
@router.delete("/projects/{project_id}")
def delete_project(
project_id: int,
db: Session = Depends(get_db),
current_user: CurrentUser = Depends(get_current_user)
):
db_project = db.query(Project).filter(Project.id == project_id).first()
if db_project is None:
raise HTTPException(status_code=404, detail="Project not found")
if not current_user.is_admin and db_project.owner_id != current_user.id:
raise HTTPException(status_code=403, detail="Not enough permissions")
db.delete(db_project)
db.commit()
return {"status": "success"}
+33 -10
View File
@@ -14,6 +14,7 @@ class Skill(BaseModel):
description: Optional[str] = Field(None, description="Description of what the skill does")
content: str = Field(..., description="The content/prompt/logic of the skill")
type: str = Field("python", description="Type of the skill (python, sql, api)")
project_id: Optional[int] = Field(None, description="The ID of the project this skill belongs to")
class SkillCreate(BaseModel):
id: str
@@ -21,12 +22,14 @@ class SkillCreate(BaseModel):
description: Optional[str] = None
content: str
type: str = "python"
project_id: Optional[int] = None
class SkillUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
content: Optional[str] = None
type: Optional[str] = None
project_id: Optional[int] = None
def _load_data() -> List[Dict[str, Any]]:
if not os.path.exists(DATA_FILE):
@@ -42,19 +45,24 @@ def _save_data(data: List[Dict[str, Any]]):
with open(DATA_FILE, "w") as f:
json.dump(data, f, indent=2)
def load_skills() -> List[Dict[str, Any]]:
return _load_data()
def load_skills(project_id: Optional[int] = None) -> List[Dict[str, Any]]:
data = _load_data()
if project_id is not None:
return [item for item in data if item.get("project_id") == project_id]
return data
@router.get("/skills", response_model=List[Skill])
def list_skills():
data = load_skills()
def list_skills(project_id: Optional[int] = None):
data = load_skills(project_id)
return [Skill(**item) for item in data]
@router.get("/skills/{skill_id}", response_model=Skill)
def get_skill(skill_id: str):
def get_skill(skill_id: str, project_id: Optional[int] = None):
data = _load_data()
for item in data:
if item["id"] == skill_id:
if project_id is not None and item.get("project_id") != project_id:
continue
return Skill(**item)
raise HTTPException(status_code=404, detail="Skill not found")
@@ -70,10 +78,12 @@ def create_skill(skill: SkillCreate):
return Skill(**new_skill)
@router.put("/skills/{skill_id}", response_model=Skill)
def update_skill(skill_id: str, skill: SkillUpdate):
def update_skill(skill_id: str, skill: SkillUpdate, project_id: Optional[int] = None):
data = _load_data()
for i, item in enumerate(data):
if item["id"] == skill_id:
if project_id is not None and item.get("project_id") != project_id:
continue
updated_item = item.copy()
update_data = skill.dict(exclude_unset=True)
updated_item.update(update_data)
@@ -83,11 +93,24 @@ def update_skill(skill_id: str, skill: SkillUpdate):
raise HTTPException(status_code=404, detail="Skill not found")
@router.delete("/skills/{skill_id}")
def delete_skill(skill_id: str):
def delete_skill(skill_id: str, project_id: Optional[int] = None):
data = _load_data()
initial_len = len(data)
data = [item for item in data if item["id"] != skill_id]
if len(data) == initial_len:
# If project_id is provided, we only delete if it matches
new_data = []
found = False
for item in data:
if item["id"] == skill_id:
if project_id is not None and item.get("project_id") != project_id:
new_data.append(item)
continue
found = True
else:
new_data.append(item)
if not found:
raise HTTPException(status_code=404, detail="Skill not found")
_save_data(data)
_save_data(new_data)
return {"message": "Skill deleted successfully"}
+31 -1
View File
@@ -1,13 +1,22 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import jwt
from jose import jwt, JWTError
from passlib.context import CryptContext
from fastapi import HTTPException, Depends, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel
SECRET_KEY = "your-super-secret-key-for-dataclaw" # In production, use env variable
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30 * 24 * 60 # 30 days
pwd_context = CryptContext(schemes=["pbkdf2_sha256"], deprecated="auto")
security = HTTPBearer()
class CurrentUser(BaseModel):
id: int
username: str
is_admin: bool = False
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
@@ -24,3 +33,24 @@ def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> CurrentUser:
unauthorized = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
)
try:
payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError:
raise unauthorized
user_id = payload.get("id")
username = payload.get("sub")
is_admin = bool(payload.get("is_admin", False))
if user_id is None or username is None:
raise unauthorized
return CurrentUser(id=user_id, username=username, is_admin=is_admin)
def get_admin_user(current_user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin permission required")
return current_user
+5 -1
View File
@@ -1,4 +1,5 @@
from sqlalchemy import Column, Integer, String, JSON, DateTime, func
from sqlalchemy import Column, Integer, String, JSON, DateTime, ForeignKey, func
from sqlalchemy.orm import relationship
from app.database import Base
class DataSource(Base):
@@ -8,5 +9,8 @@ class DataSource(Base):
name = Column(String, nullable=False)
type = Column(String, nullable=False)
config = Column(JSON, nullable=False)
project_id = Column(Integer, ForeignKey("projects.id"), nullable=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
project = relationship("Project", back_populates="data_sources")
+16
View File
@@ -0,0 +1,16 @@
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, func
from sqlalchemy.orm import relationship
from app.database import Base
class Project(Base):
__tablename__ = "projects"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False)
description = Column(String, nullable=True)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
owner = relationship("User", back_populates="projects")
data_sources = relationship("DataSource", back_populates="project", cascade="all, delete-orphan")
+3
View File
@@ -1,4 +1,5 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.database import Base
@@ -12,3 +13,5 @@ class User(Base):
is_active = Column(Boolean, default=True)
is_admin = Column(Boolean, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
projects = relationship("Project", back_populates="owner")
+1
View File
@@ -6,6 +6,7 @@ class DataSourceBase(BaseModel):
name: str
type: str # sqlite, postgres, clickhouse, supabase, parquet
config: Dict[str, Any]
project_id: int
class DataSourceCreate(DataSourceBase):
pass
+23
View File
@@ -0,0 +1,23 @@
from pydantic import BaseModel
from typing import Optional, List
from datetime import datetime
class ProjectBase(BaseModel):
name: str
description: Optional[str] = None
class ProjectCreate(ProjectBase):
pass
class ProjectUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
class Project(ProjectBase):
id: int
owner_id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
Binary file not shown.
+3 -1
View File
@@ -7,7 +7,7 @@ import asyncio
import json
from datetime import datetime
from app.api import upload, llm, skills, users, datasources
from app.api import upload, llm, skills, users, datasources, projects
from app.connectors.postgres import postgres_connector
from app.connectors.clickhouse import clickhouse_connector
from app.core.nanobot import nanobot_service
@@ -16,6 +16,7 @@ from app.agent.nl2sql import process_nl2sql, NL2SQLRequest, NL2SQLResponse
from app.database import engine, Base
# Import all models to ensure they are registered
from app.models.user import User
from app.models.project import Project
from app.models.datasource import DataSource
app = FastAPI()
@@ -35,6 +36,7 @@ app.include_router(upload.router, prefix="/api/v1")
app.include_router(llm.router, prefix="/api/v1")
app.include_router(skills.router, prefix="/api/v1")
app.include_router(users.router, prefix="/api/v1")
app.include_router(projects.router, prefix="/api/v1")
app.include_router(datasources.router, prefix="/api/v1")
@app.on_event("startup")
+17 -2
View File
@@ -1,10 +1,12 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { Sidebar } from "./components/Sidebar";
import { ProjectSwitcher } from "./components/ProjectSwitcher";
import { ChatInterface } from "./components/ChatInterface";
import { Dashboard } from "./pages/Dashboard";
import { Skills } from "./pages/Skills";
import { Settings } from "./pages/Settings";
import { Users } from "./pages/Users";
import { Projects } from "./pages/Projects";
import { Login } from "./pages/Login";
import { ModelConfigs } from "./pages/ModelConfigs";
import { DataSources } from "./pages/DataSources";
@@ -29,8 +31,13 @@ function MainLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen w-full bg-background text-foreground overflow-hidden">
<Sidebar />
<main className="flex-1 flex flex-col overflow-hidden h-screen relative">
{children}
<main className="flex-1 flex flex-col overflow-hidden h-screen">
<div className="flex justify-center border-b">
<ProjectSwitcher />
</div>
<div className="flex-1 overflow-hidden">
{children}
</div>
</main>
</div>
);
@@ -77,6 +84,14 @@ function App() {
</ProtectedRoute>
} />
<Route path="/projects" element={
<ProtectedRoute>
<MainLayout>
<Projects />
</MainLayout>
</ProtectedRoute>
} />
<Route path="/users" element={
<ProtectedRoute requireAdmin={true}>
<MainLayout>
+86 -37
View File
@@ -13,6 +13,7 @@ import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { useLocation } from "react-router-dom";
import { InlineVisualizationCard } from "./InlineVisualizationCard";
import { useProjectStore } from "@/store/projectStore";
interface Message {
id: string;
@@ -76,6 +77,7 @@ export function ChatInterface() {
const [isLoading, setIsLoading] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const location = useLocation();
const { currentProject } = useProjectStore();
// Model selection state
const [models, setModels] = useState<ModelConfig[]>([]);
@@ -83,10 +85,7 @@ export function ChatInterface() {
const [modelOpen, setModelOpen] = useState(false);
// Data Source selection state
const [availableDataSources, setAvailableDataSources] = useState<{id: string, name: string}[]>([
{ id: "postgres-main", name: "PostgreSQL" },
{ id: "clickhouse-main", name: "ClickHouse" }
]);
const [availableDataSources, setAvailableDataSources] = useState<{id: string, name: string}[]>([]);
// Try to parse active session from URL query
const queryParams = new URLSearchParams(location.search);
@@ -101,16 +100,29 @@ export function ChatInterface() {
useEffect(() => {
fetchModels();
fetchDataSources();
}, []);
useEffect(() => {
if (currentProject) {
fetchDataSources();
}
}, [currentProject]);
const fetchDataSources = async () => {
if (!currentProject) return;
try {
const data = await api.get<Array<{id: number, name: string}>>("/api/v1/datasources");
setAvailableDataSources(prev => [
...prev.filter(d => !d.id.startsWith("ds:")),
...data.map(d => ({ id: `ds:${d.id}`, name: d.name }))
]);
const data = await api.get<Array<{id: number, name: string}>>(`/api/v1/datasources?project_id=${currentProject.id}`);
const projectSources = data.map(d => ({ id: `ds:${d.id}`, name: d.name }));
setAvailableDataSources(projectSources);
// Default select the first one if current selection is not in the list
if (projectSources.length > 0) {
if (!selectedDataSource.startsWith("ds:") || !projectSources.find(ds => ds.id === selectedDataSource)) {
setSelectedDataSource(projectSources[0].id);
}
} else {
setSelectedDataSource("upload"); // Default to upload if no data sources
}
} catch (e) {
console.error("Failed to fetch data sources", e);
}
@@ -282,14 +294,18 @@ export function ChatInterface() {
useEffect(() => {
const fetchSkills = async () => {
try {
const skills = await api.get<Skill[]>("/api/v1/skills");
let url = "/api/v1/skills";
if (currentProject) {
url += `?project_id=${currentProject.id}`;
}
const skills = await api.get<Skill[]>(url);
setAvailableSkills(skills);
} catch (err) {
console.error("Failed to fetch skills:", err);
}
};
fetchSkills();
}, []);
}, [currentProject]);
useEffect(() => {
if (scrollRef.current) {
@@ -340,12 +356,21 @@ export function ChatInterface() {
const token = localStorage.getItem("token");
const effectiveModelId = selectedModelId || currentModel?.id || "";
const selectedSource = selectedDataSource.split('-')[0];
// Correctly parse source from selectedDataSource (could be 'ds:ID', 'upload', or legacy 'postgres-main')
let source = selectedDataSource;
if (selectedDataSource.includes("-")) {
source = selectedDataSource.split("-")[0];
}
const useUploadSource = Boolean(
currentAttachedFile?.url?.startsWith("local://") ||
(selectedSource === "upload" && activeDataFile?.url?.startsWith("local://"))
(source === "upload" && activeDataFile?.url?.startsWith("local://"))
);
const source = useUploadSource ? "upload" : selectedSource;
if (useUploadSource) {
source = "upload";
}
const fileUrl = useUploadSource ? (currentAttachedFile?.url || activeDataFile?.url) : undefined;
const preferSqlChart = chartIntentPattern.test(messagePayload);
const response = await fetch("/nanobot/chat/stream", {
@@ -570,19 +595,11 @@ export function ChatInterface() {
</div>
<div className="space-y-0.5">
{[
{ id: 'postgres-main', label: 'Postgres (Main)', icon: Database },
{ id: 'clickhouse-main', label: 'Clickhouse', icon: Database },
{ id: 'upload', label: '本地文件上传', icon: FileIcon },
].map((ds) => (
{availableDataSources.map((ds) => (
<button
key={ds.id}
onClick={() => {
setSelectedDataSource(ds.id);
if (ds.id === 'upload') {
fileInputRef.current?.click();
setIsMenuOpen(false);
}
}}
className={cn(
"w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm transition-all duration-200",
@@ -592,12 +609,32 @@ export function ChatInterface() {
)}
>
<div className="flex items-center gap-2.5">
<ds.icon className={cn("h-4 w-4", selectedDataSource === ds.id ? "text-blue-500" : "text-zinc-400")} />
<span className="font-medium">{ds.label}</span>
<Database className={cn("h-4 w-4", selectedDataSource === ds.id ? "text-blue-500" : "text-zinc-400")} />
<span className="font-medium">{ds.name}</span>
</div>
{selectedDataSource === ds.id && <CheckCircle2 className="h-4 w-4 text-blue-500" />}
</button>
))}
<button
onClick={() => {
setSelectedDataSource('upload');
fileInputRef.current?.click();
setIsMenuOpen(false);
}}
className={cn(
"w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm transition-all duration-200",
selectedDataSource === 'upload' || selectedDataSource === 'upload-main'
? "bg-white text-zinc-900 shadow-sm ring-1 ring-zinc-200"
: "text-zinc-600 hover:bg-white hover:shadow-sm"
)}
>
<div className="flex items-center gap-2.5">
<FileIcon className={cn("h-4 w-4", (selectedDataSource === 'upload' || selectedDataSource === 'upload-main') ? "text-blue-500" : "text-zinc-400")} />
<span className="font-medium"></span>
</div>
{(selectedDataSource === 'upload' || selectedDataSource === 'upload-main') && <CheckCircle2 className="h-4 w-4 text-blue-500" />}
</button>
</div>
</div>
@@ -776,19 +813,11 @@ export function ChatInterface() {
</div>
<div className="space-y-0.5">
{[
{ id: 'postgres-main', label: 'Postgres (Main)', icon: Database },
{ id: 'clickhouse-main', label: 'Clickhouse', icon: Database },
{ id: 'upload', label: '本地文件上传', icon: FileIcon },
].map((ds) => (
{availableDataSources.map((ds) => (
<button
key={ds.id}
onClick={() => {
setSelectedDataSource(ds.id);
if (ds.id === 'upload') {
fileInputRef.current?.click();
setIsMenuOpen(false);
}
}}
className={cn(
"w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm transition-all duration-200",
@@ -798,12 +827,32 @@ export function ChatInterface() {
)}
>
<div className="flex items-center gap-2.5">
<ds.icon className={cn("h-4 w-4", selectedDataSource === ds.id ? "text-blue-500" : "text-zinc-400")} />
<span className="font-medium">{ds.label}</span>
<Database className={cn("h-4 w-4", selectedDataSource === ds.id ? "text-blue-500" : "text-zinc-400")} />
<span className="font-medium">{ds.name}</span>
</div>
{selectedDataSource === ds.id && <CheckCircle2 className="h-4 w-4 text-blue-500" />}
</button>
))}
<button
onClick={() => {
setSelectedDataSource('upload');
fileInputRef.current?.click();
setIsMenuOpen(false);
}}
className={cn(
"w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm transition-all duration-200",
selectedDataSource === 'upload' || selectedDataSource === 'upload-main'
? "bg-white text-zinc-900 shadow-sm ring-1 ring-zinc-200"
: "text-zinc-600 hover:bg-white hover:shadow-sm"
)}
>
<div className="flex items-center gap-2.5">
<FileIcon className={cn("h-4 w-4", (selectedDataSource === 'upload' || selectedDataSource === 'upload-main') ? "text-blue-500" : "text-zinc-400")} />
<span className="font-medium"></span>
</div>
{(selectedDataSource === 'upload' || selectedDataSource === 'upload-main') && <CheckCircle2 className="h-4 w-4 text-blue-500" />}
</button>
</div>
</div>
@@ -6,6 +6,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { Code, Table as TableIcon, BarChart as ChartIcon, LayoutDashboard } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useDashboardStore, type ChartConfig } from "@/store/dashboardStore";
import { useProjectStore } from "@/store/projectStore";
import type { ChartSpec } from "@/store/visualizationStore";
import { VegaChart } from "./VegaChart";
@@ -25,6 +26,7 @@ export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) {
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingChart, setPendingChart] = useState<Omit<ChartConfig, 'layout'> | null>(null);
const { addChart } = useDashboardStore();
const { currentProject } = useProjectStore();
const objectRows = viz.rows.filter((row) => row && typeof row === "object" && !Array.isArray(row)) as Record<string, unknown>[];
const columns = objectRows.length > 0 ? Object.keys(objectRows[0]) : [];
@@ -43,14 +45,15 @@ export function InlineVisualizationCard({ viz }: InlineVisualizationCardProps) {
};
const handleAddToDashboard = () => {
if (!currentProject) return;
const chart = buildPendingChart();
setPendingChart(chart);
setConfirmOpen(true);
};
const handleConfirmAdd = () => {
if (!pendingChart) return;
addChart(pendingChart);
if (!pendingChart || !currentProject) return;
addChart(pendingChart, currentProject.id);
setConfirmOpen(false);
setPendingChart(null);
};
+123
View File
@@ -0,0 +1,123 @@
import React, { useEffect, useState } from 'react';
import { ChevronDown, Plus, Folder } from 'lucide-react';
import { useProjectStore, type Project } from '@/store/projectStore';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenuGroup,
} from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
export function ProjectSwitcher() {
const { projects, currentProject, fetchProjects, setCurrentProject, addProject } = useProjectStore();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [newProjectName, setNewProjectName] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
fetchProjects();
}, [fetchProjects]);
const handleCreateProject = async () => {
if (!newProjectName.trim()) return;
setIsSubmitting(true);
try {
await addProject(newProjectName);
setNewProjectName('');
setIsCreateDialogOpen(false);
} catch (error) {
console.error('Failed to create project:', error);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="flex items-center gap-2 px-4 py-2 bg-background h-12">
<div className="flex items-center text-sm font-medium text-muted-foreground">
<span>DataClaw</span>
<span className="mx-2 text-zinc-300">/</span>
</div>
<DropdownMenu>
<DropdownMenuTrigger className="flex h-8 items-center gap-1 rounded-md px-2 font-semibold hover:bg-accent hover:text-accent-foreground outline-none transition-colors">
<Folder className="h-4 w-4 mr-1 text-blue-500" />
{currentProject?.name || 'Select Project'}
<ChevronDown className="h-4 w-4 opacity-50" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuGroup>
<DropdownMenuLabel className="flex items-center justify-between">
PROJECTS
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setIsCreateDialogOpen(true);
}}
>
<Plus className="h-3 w-3" />
</Button>
</DropdownMenuLabel>
<DropdownMenuSeparator />
</DropdownMenuGroup>
<div className="max-h-64 overflow-y-auto">
{projects.map((project) => (
<DropdownMenuItem
key={project.id}
onClick={() => {
setCurrentProject(project);
}}
className={currentProject?.id === project.id ? 'bg-accent' : ''}
>
<Folder className="h-4 w-4 mr-2 text-zinc-400" />
{project.name}
</DropdownMenuItem>
))}
{projects.length === 0 && (
<div className="px-2 py-4 text-center text-xs text-muted-foreground">
No projects found
</div>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create New Project</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Project Name</Label>
<Input
id="name"
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
placeholder="Enter project name"
autoFocus
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>Cancel</Button>
<Button onClick={handleCreateProject} disabled={isSubmitting || !newProjectName.trim()}>
{isSubmitting ? 'Creating...' : 'Create Project'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+23 -12
View File
@@ -1,7 +1,7 @@
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Menu, LayoutDashboard, Plus, MoreVertical, User, Search, Wrench, Settings, Brain, Trash2, Pencil, Pin, Archive, Database, CheckSquare, Square, ListChecks, RotateCcw, Wand2 } from "lucide-react";
import { Menu, LayoutDashboard, Plus, MoreVertical, User, Search, Wrench, Settings, Brain, Trash2, Pencil, Pin, Archive, Database, CheckSquare, Square, ListChecks, RotateCcw, Wand2, Folder } from "lucide-react";
import { useState, useRef, useEffect } from "react";
import { Link, useNavigate, useLocation } from "react-router-dom";
import { useAuthStore } from "@/store/authStore";
@@ -561,6 +561,28 @@ function SidebarBody() {
<p className="text-xs text-zinc-500 truncate">{user?.email}</p>
</div>
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-zinc-700 hover:bg-zinc-100 transition-colors"
onClick={() => {
navigate("/projects");
setShowUserMenu(false);
}}
>
<Folder className="h-4 w-4 text-zinc-500" />
</button>
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-zinc-700 hover:bg-zinc-100 transition-colors"
onClick={() => {
navigate("/datasources");
setShowUserMenu(false);
}}
>
<Database className="h-4 w-4 text-zinc-500" />
</button>
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-zinc-700 hover:bg-zinc-100 transition-colors"
onClick={() => {
@@ -584,17 +606,6 @@ function SidebarBody() {
<Brain className="h-4 w-4 text-zinc-500" />
</button>
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-zinc-700 hover:bg-zinc-100 transition-colors"
onClick={() => {
navigate("/datasources");
setShowUserMenu(false);
}}
>
<Database className="h-4 w-4 text-zinc-500" />
</button>
<button
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-indigo-600 hover:bg-indigo-50 transition-colors"
@@ -7,6 +7,7 @@ import { Code, Table as TableIcon, BarChart as ChartIcon, Download, LayoutDashbo
import { ScrollArea } from "@/components/ui/scroll-area";
import { useDashboardStore, type ChartConfig } from "@/store/dashboardStore";
import { useVisualizationStore } from "@/store/visualizationStore";
import { useProjectStore } from "@/store/projectStore";
import { VegaChart } from "./VegaChart";
export function VisualizationPanel() {
@@ -14,6 +15,7 @@ export function VisualizationPanel() {
const [confirmOpen, setConfirmOpen] = useState(false);
const [pendingChart, setPendingChart] = useState<Omit<ChartConfig, 'layout'> | null>(null);
const { addChart } = useDashboardStore();
const { currentProject } = useProjectStore();
const { currentData, currentSQL, currentChartSpec, currentChartInfo, isLoading, error } = useVisualizationStore();
const buildPendingChart = (): Omit<ChartConfig, 'layout'> | null => {
@@ -32,6 +34,7 @@ export function VisualizationPanel() {
};
const handleAddToDashboard = () => {
if (!currentProject) return;
const chart = buildPendingChart();
if (!chart) return;
setPendingChart(chart);
@@ -39,8 +42,8 @@ export function VisualizationPanel() {
};
const handleConfirmAdd = () => {
if (!pendingChart) return;
addChart(pendingChart);
if (!pendingChart || !currentProject) return;
addChart(pendingChart, currentProject.id);
setConfirmOpen(false);
setPendingChart(null);
};
+34 -14
View File
@@ -1,6 +1,7 @@
import { useMemo } from 'react';
import { useMemo, useEffect } from 'react';
import { Responsive, WidthProvider } from 'react-grid-layout/legacy';
import { useDashboardStore } from '../store/dashboardStore';
import { useProjectStore } from '../store/projectStore';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { X } from "lucide-react";
@@ -39,7 +40,15 @@ function inferChartKeys(data: Record<string, unknown>[]) {
}
export function Dashboard() {
const { charts, removeChart, updateLayout } = useDashboardStore();
const { charts, removeChart, updateLayout, loadCharts } = useDashboardStore();
const { currentProject } = useProjectStore();
useEffect(() => {
if (currentProject) {
loadCharts(currentProject.id);
}
}, [currentProject, loadCharts]);
const ResponsiveGridLayout = useMemo(
() => WidthProvider(Responsive as any) as any,
[]
@@ -50,22 +59,33 @@ export function Dashboard() {
}), [charts]);
const onLayoutChange = (currentLayout: any[]) => {
updateLayout(
currentLayout.map((item) => ({
i: item.i,
x: item.x,
y: item.y,
w: item.w,
h: item.h,
}))
);
if (currentProject) {
updateLayout(
currentLayout.map((item) => ({
i: item.i,
x: item.x,
y: item.y,
w: item.w,
h: item.h,
})),
currentProject.id
);
}
};
if (!currentProject) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<p></p>
</div>
);
}
if (charts.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<p>No charts in dashboard.</p>
<p className="text-sm">Go to Chat and add some visualizations!</p>
<p></p>
<p className="text-sm"></p>
</div>
);
}
@@ -95,7 +115,7 @@ export function Dashboard() {
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => removeChart(chart.id)}
onClick={() => removeChart(chart.id, currentProject.id)}
>
<X className="h-4 w-4" />
</Button>
+11 -9
View File
@@ -2,9 +2,10 @@ import { useState, useEffect } from "react";
import { api } from "@/lib/api";
import { DataSourceForm, type DataSourceConfig } from "@/components/DataSourceForm";
import { Button } from "@/components/ui/button";
import { Plus, Database, Pencil, Trash2, Loader2 } from "lucide-react";
import { Plus, Database, Pencil, Trash2, Loader2, FolderOpen } from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useAuthStore } from "@/store/authStore";
import { useProjectStore } from "@/store/projectStore";
import { useNavigate } from "react-router-dom";
export function DataSources() {
@@ -13,20 +14,20 @@ export function DataSources() {
const [isOpen, setIsOpen] = useState(false);
const [editingDs, setEditingDs] = useState<DataSourceConfig | null>(null);
const { user } = useAuthStore();
const { currentProject } = useProjectStore();
const navigate = useNavigate();
useEffect(() => {
if (!user?.is_admin) {
navigate("/");
return;
if (currentProject) {
fetchDataSources();
}
fetchDataSources();
}, [user]);
}, [currentProject]);
const fetchDataSources = async () => {
if (!currentProject) return;
setIsLoading(true);
try {
const data = await api.get<DataSourceConfig[]>("/api/v1/datasources");
const data = await api.get<DataSourceConfig[]>(`/api/v1/datasources?project_id=${currentProject.id}`);
setDatasources(data);
} catch (e) {
console.error("Failed to fetch data sources", e);
@@ -56,11 +57,12 @@ export function DataSources() {
};
const handleSubmit = async (data: Omit<DataSourceConfig, "id">) => {
if (!currentProject) return;
try {
if (editingDs?.id) {
await api.put(`/api/v1/datasources/${editingDs.id}`, data);
await api.put(`/api/v1/datasources/${editingDs.id}`, { ...data, project_id: currentProject.id });
} else {
await api.post("/api/v1/datasources", data);
await api.post("/api/v1/datasources", { ...data, project_id: currentProject.id });
}
setIsOpen(false);
fetchDataSources();
+238
View File
@@ -0,0 +1,238 @@
import React, { useEffect, useState } from 'react';
import { Plus, Folder, Pencil, Trash2, Loader2, Database } from 'lucide-react';
import { useProjectStore, type Project } from '@/store/projectStore';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { useNavigate } from 'react-router-dom';
export function Projects() {
const { projects, loading, fetchProjects, addProject, updateProject, deleteProject, setCurrentProject } = useProjectStore();
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [editingProject, setEditingProject] = useState<Project | null>(null);
const [formData, setFormData] = useState({ name: '', description: '' });
const [isSubmitting, setIsSubmitting] = useState(false);
const navigate = useNavigate();
useEffect(() => {
fetchProjects();
}, [fetchProjects]);
const handleCreate = async () => {
if (!formData.name.trim()) return;
setIsSubmitting(true);
try {
await addProject(formData.name, formData.description);
setFormData({ name: '', description: '' });
setIsCreateDialogOpen(false);
} catch (error) {
console.error('Failed to create project:', error);
} finally {
setIsSubmitting(false);
}
};
const handleUpdate = async () => {
if (!editingProject || !formData.name.trim()) return;
setIsSubmitting(true);
try {
await updateProject(editingProject.id, formData.name, formData.description);
setEditingProject(null);
setFormData({ name: '', description: '' });
setIsEditDialogOpen(false);
} catch (error) {
console.error('Failed to update project:', error);
} finally {
setIsSubmitting(false);
}
};
const handleDelete = async (id: number) => {
if (!window.confirm('Are you sure you want to delete this project? All associated data sources will be deleted.')) return;
try {
await deleteProject(id);
} catch (error) {
console.error('Failed to delete project:', error);
}
};
const openEditDialog = (project: Project) => {
setEditingProject(project);
setFormData({ name: project.name, description: project.description || '' });
setIsEditDialogOpen(true);
};
const goToDataSources = (project: Project) => {
setCurrentProject(project);
navigate('/datasources');
};
return (
<div className="flex-1 flex flex-col h-full bg-zinc-50/30 overflow-hidden">
<div className="h-14 px-6 flex items-center justify-between border-b border-zinc-100 bg-white">
<div className="flex items-center gap-2 text-zinc-700 font-medium">
<Folder className="h-5 w-5 text-blue-500" />
</div>
<Button onClick={() => {
setFormData({ name: '', description: '' });
setIsCreateDialogOpen(true);
}} size="sm" className="gap-2">
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="flex-1 p-6 overflow-auto">
<div className="max-w-5xl mx-auto space-y-6">
<Card className="border-zinc-200 shadow-sm">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{loading && projects.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-zinc-400">
<Loader2 className="h-8 w-8 animate-spin mb-4" />
<p>...</p>
</div>
) : projects.length === 0 ? (
<div className="text-center py-12 border-2 border-dashed rounded-lg border-zinc-100">
<Folder className="h-12 w-12 text-zinc-200 mx-auto mb-4" />
<p className="text-zinc-500"></p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{projects.map((project) => (
<TableRow key={project.id}>
<TableCell className="font-medium">{project.name}</TableCell>
<TableCell className="text-zinc-500 max-w-xs truncate">
{project.description || '-'}
</TableCell>
<TableCell className="text-zinc-500">
{new Date(project.created_at).toLocaleDateString()}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => goToDataSources(project)}
title="管理数据源"
>
<Database className="h-4 w-4 text-emerald-500" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => openEditDialog(project)}
title="编辑项目"
>
<Pencil className="h-4 w-4 text-blue-500" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(project.id)}
title="删除项目"
>
<Trash2 className="h-4 w-4 text-red-500" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</div>
</div>
{/* Create Dialog */}
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name"></Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="输入项目名称"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="description"> ()</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="输入项目描述"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}></Button>
<Button onClick={handleCreate} disabled={isSubmitting || !formData.name.trim()}>
{isSubmitting ? '创建中...' : '创建'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="edit-name"></Label>
<Input
id="edit-name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="输入项目名称"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="edit-description"> ()</Label>
<Textarea
id="edit-description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="输入项目描述"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}></Button>
<Button onClick={handleUpdate} disabled={isSubmitting || !formData.name.trim()}>
{isSubmitting ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+83 -29
View File
@@ -2,13 +2,14 @@ import { useState, useEffect } from 'react';
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Trash2, Edit2, Plus, Terminal, Loader2 } from "lucide-react";
import { Trash2, Edit2, Plus, Terminal, Loader2, FolderOpen } from "lucide-react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { api } from "@/lib/api";
import { useProjectStore } from "@/store/projectStore";
interface Skill {
id: string;
@@ -16,22 +17,28 @@ interface Skill {
description: string;
content: string;
type: 'python' | 'sql' | 'api';
project_id?: number;
}
export function Skills() {
const [skills, setSkills] = useState<Skill[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingSkill, setEditingSkill] = useState<Skill | null>(null);
const [newSkill, setNewSkill] = useState<Partial<Skill>>({ type: 'python', content: '' });
const { currentProject } = useProjectStore();
useEffect(() => {
fetchSkills();
}, []);
if (currentProject) {
fetchSkills();
}
}, [currentProject]);
const fetchSkills = async () => {
if (!currentProject) return;
setIsLoading(true);
try {
const data = await api.get<Skill[]>('/api/v1/skills');
const data = await api.get<Skill[]>(`/api/v1/skills?project_id=${currentProject.id}`);
setSkills(data);
} catch (error) {
console.error("Failed to fetch skills", error);
@@ -41,52 +48,90 @@ export function Skills() {
};
const handleAddSkill = async () => {
if (!currentProject) return;
if (newSkill.name && newSkill.description && newSkill.content) {
try {
const skillToCreate = {
...newSkill,
id: Date.now().toString(),
};
const createdSkill = await api.post<Skill>('/api/v1/skills', skillToCreate);
setSkills([...skills, createdSkill]);
if (editingSkill) {
const updatedSkill = await api.put<Skill>(`/api/v1/skills/${editingSkill.id}?project_id=${currentProject.id}`, {
...newSkill,
project_id: currentProject.id
});
setSkills(skills.map(s => s.id === editingSkill.id ? updatedSkill : s));
} else {
const skillToCreate = {
...newSkill,
id: Date.now().toString(),
project_id: currentProject.id
};
const createdSkill = await api.post<Skill>('/api/v1/skills', skillToCreate);
setSkills([...skills, createdSkill]);
}
setNewSkill({ type: 'python', content: '' });
setEditingSkill(null);
setIsDialogOpen(false);
} catch (error) {
console.error("Failed to create skill", error);
console.error("Failed to save skill", error);
}
}
};
const handleEditSkill = (skill: Skill) => {
setEditingSkill(skill);
setNewSkill(skill);
setIsDialogOpen(true);
};
const handleDeleteSkill = async (id: string) => {
if (!currentProject) return;
if (!window.confirm("确定要删除这个技能吗?")) return;
try {
await api.delete(`/api/v1/skills/${id}`);
await api.delete(`/api/v1/skills/${id}?project_id=${currentProject.id}`);
setSkills(skills.filter(s => s.id !== id));
} catch (error) {
console.error("Failed to delete skill", error);
}
};
if (!currentProject) {
return (
<div className="h-full flex flex-col items-center justify-center text-zinc-500 gap-4">
<FolderOpen className="h-12 w-12 text-zinc-200" />
<p></p>
</div>
);
}
return (
<div className="p-6 h-full flex flex-col overflow-hidden">
<div className="flex justify-between items-center mb-6 shrink-0">
<div>
<h1 className="text-2xl font-bold">Skills</h1>
<p className="text-muted-foreground">Manage AI capabilities and tools</p>
<h1 className="text-2xl font-bold"> - {currentProject.name}</h1>
<p className="text-muted-foreground"> AI </p>
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<Dialog open={isDialogOpen} onOpenChange={(open) => {
setIsDialogOpen(open);
if (!open) {
setEditingSkill(null);
setNewSkill({ type: 'python', content: '' });
}
}}>
<DialogTrigger render={
<Button>
<Button onClick={() => {
setEditingSkill(null);
setNewSkill({ type: 'python', content: '' });
setIsDialogOpen(true);
}}>
<Plus className="h-4 w-4 mr-2" />
Add Skill
</Button>
} />
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Add New Skill</DialogTitle>
<DialogTitle>{editingSkill ? '编辑技能' : '添加新技能'}</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="name" className="text-right">Name</Label>
<Label htmlFor="name" className="text-right"></Label>
<Input
id="name"
value={newSkill.name || ''}
@@ -95,13 +140,13 @@ export function Skills() {
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="type" className="text-right">Type</Label>
<Label htmlFor="type" className="text-right"></Label>
<Select
value={newSkill.type}
onValueChange={(val: any) => setNewSkill({...newSkill, type: val})}
>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="Select type" />
<SelectValue placeholder="选择类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="python">Python</SelectItem>
@@ -111,7 +156,7 @@ export function Skills() {
</Select>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="description" className="text-right">Description</Label>
<Label htmlFor="description" className="text-right"></Label>
<Textarea
id="description"
value={newSkill.description || ''}
@@ -120,18 +165,18 @@ export function Skills() {
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="content" className="text-right">Content</Label>
<Label htmlFor="content" className="text-right"></Label>
<Textarea
id="content"
value={newSkill.content || ''}
onChange={(e) => setNewSkill({...newSkill, content: e.target.value})}
className="col-span-3 font-mono text-xs"
placeholder="Python code, SQL query template, or API spec..."
placeholder="Python 代码、SQL 查询模板或 API 规范..."
/>
</div>
</div>
<DialogFooter>
<Button onClick={handleAddSkill}>Save Skill</Button>
<Button onClick={handleAddSkill}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -155,7 +200,12 @@ export function Skills() {
<CardDescription>{skill.type.toUpperCase()}</CardDescription>
</div>
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-foreground">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-foreground"
onClick={() => handleEditSkill(skill)}
>
<Edit2 className="h-4 w-4" />
</Button>
<Button
@@ -169,12 +219,16 @@ export function Skills() {
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground line-clamp-2">
{skill.description}
</p>
<p className="text-sm text-muted-foreground line-clamp-2">{skill.description}</p>
</CardContent>
</Card>
))}
{skills.length === 0 && (
<div className="col-span-full py-12 text-center text-zinc-500 bg-zinc-50 rounded-xl border-2 border-dashed border-zinc-100">
<Terminal className="h-12 w-12 mx-auto mb-4 opacity-20" />
<p></p>
</div>
)}
</div>
)}
</ScrollArea>
+27 -21
View File
@@ -16,17 +16,22 @@ export interface ChartConfig {
interface DashboardState {
charts: ChartConfig[];
addChart: (chart: Omit<ChartConfig, 'layout'>) => void;
removeChart: (id: string) => void;
updateLayout: (layouts: GridLayout[]) => void;
addChart: (chart: Omit<ChartConfig, 'layout'>, projectId: number) => void;
removeChart: (id: string, projectId: number) => void;
updateLayout: (layouts: GridLayout[], projectId: number) => void;
loadCharts: (projectId: number) => void;
}
const DASHBOARD_STORAGE_KEY = 'dashboard_charts_v1';
const DASHBOARD_STORAGE_KEY_PREFIX = 'dashboard_charts_v1_project_';
function loadChartsFromStorage(): ChartConfig[] {
function getStorageKey(projectId: number) {
return `${DASHBOARD_STORAGE_KEY_PREFIX}${projectId}`;
}
function loadChartsFromStorage(projectId: number): ChartConfig[] {
if (typeof window === 'undefined') return [];
try {
const raw = window.localStorage.getItem(DASHBOARD_STORAGE_KEY);
const raw = window.localStorage.getItem(getStorageKey(projectId));
if (!raw) return [];
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
@@ -47,14 +52,17 @@ function loadChartsFromStorage(): ChartConfig[] {
}
}
function saveChartsToStorage(charts: ChartConfig[]) {
function saveChartsToStorage(charts: ChartConfig[], projectId: number) {
if (typeof window === 'undefined') return;
window.localStorage.setItem(DASHBOARD_STORAGE_KEY, JSON.stringify(charts));
window.localStorage.setItem(getStorageKey(projectId), JSON.stringify(charts));
}
export const useDashboardStore = create<DashboardState>((set) => ({
charts: loadChartsFromStorage(),
addChart: (chart) => set((state) => {
export const useDashboardStore = create<DashboardState>((set, get) => ({
charts: [],
loadCharts: (projectId) => {
set({ charts: loadChartsFromStorage(projectId) });
},
addChart: (chart, projectId) => set((state) => {
const colSize = 4;
const cols = 12 / colSize;
const index = state.charts.length;
@@ -66,22 +74,20 @@ export const useDashboardStore = create<DashboardState>((set) => ({
h: 4,
};
const nextCharts = [...state.charts, { ...chart, layout: newLayout }];
saveChartsToStorage(nextCharts);
saveChartsToStorage(nextCharts, projectId);
return { charts: nextCharts };
}),
removeChart: (id) => set((state) => ({
charts: (() => {
const nextCharts = state.charts.filter((c) => c.id !== id);
saveChartsToStorage(nextCharts);
return nextCharts;
})(),
})),
updateLayout: (layouts) => set((state) => {
removeChart: (id, projectId) => set((state) => {
const nextCharts = state.charts.filter((c) => c.id !== id);
saveChartsToStorage(nextCharts, projectId);
return { charts: nextCharts };
}),
updateLayout: (layouts, projectId) => set((state) => {
const nextCharts = state.charts.map((chart) => {
const layout = layouts.find((l) => l.i === chart.id);
return layout ? { ...chart, layout } : chart;
});
saveChartsToStorage(nextCharts);
saveChartsToStorage(nextCharts, projectId);
return { charts: nextCharts };
}),
}));
+106
View File
@@ -0,0 +1,106 @@
import { create } from 'zustand';
import { api } from '@/lib/api';
export interface Project {
id: number;
name: string;
description?: string;
owner_id: number;
created_at: string;
updated_at: string;
}
interface ProjectState {
projects: Project[];
currentProject: Project | null;
loading: boolean;
error: string | null;
fetchProjects: () => Promise<void>;
setCurrentProject: (project: Project) => void;
addProject: (name: string, description?: string) => Promise<Project>;
updateProject: (id: number, name: string, description?: string) => Promise<Project>;
deleteProject: (id: number) => Promise<void>;
}
export const useProjectStore = create<ProjectState>((set, get) => ({
projects: [],
currentProject: JSON.parse(localStorage.getItem('currentProject') || 'null'),
loading: false,
error: null,
fetchProjects: async () => {
set({ loading: true, error: null });
try {
const projects = await api.get<Project[]>('/api/v1/projects');
set({ projects, loading: false });
// Set current project if not set or not in list
const current = get().currentProject;
if (projects.length > 0) {
if (!current || !projects.find((p: Project) => p.id === current.id)) {
get().setCurrentProject(projects[0]);
}
} else {
set({ currentProject: null });
localStorage.removeItem('currentProject');
}
} catch (error: any) {
set({ error: error.message, loading: false });
}
},
setCurrentProject: (project: Project) => {
localStorage.setItem('currentProject', JSON.stringify(project));
set({ currentProject: project });
},
addProject: async (name: string, description?: string) => {
try {
const newProject = await api.post<Project>('/api/v1/projects', { name, description });
set((state) => ({ projects: [...state.projects, newProject] }));
if (!get().currentProject) {
get().setCurrentProject(newProject);
}
return newProject;
} catch (error: any) {
throw new Error(error.message || 'Failed to create project');
}
},
updateProject: async (id: number, name: string, description?: string) => {
try {
const updatedProject = await api.put<Project>(`/api/v1/projects/${id}`, { name, description });
set((state) => ({
projects: state.projects.map((p) => (p.id === id ? updatedProject : p)),
currentProject: state.currentProject?.id === id ? updatedProject : state.currentProject,
}));
if (get().currentProject?.id === id) {
localStorage.setItem('currentProject', JSON.stringify(updatedProject));
}
return updatedProject;
} catch (error: any) {
throw new Error(error.message || 'Failed to update project');
}
},
deleteProject: async (id: number) => {
try {
await api.delete(`/api/v1/projects/${id}`);
set((state) => {
const projects = state.projects.filter((p) => p.id !== id);
let currentProject = state.currentProject;
if (currentProject?.id === id) {
currentProject = projects.length > 0 ? projects[0] : null;
if (currentProject) {
localStorage.setItem('currentProject', JSON.stringify(currentProject));
} else {
localStorage.removeItem('currentProject');
}
}
return { projects, currentProject };
});
} catch (error: any) {
throw new Error(error.message || 'Failed to delete project');
}
},
}));