Initial commit

This commit is contained in:
lipan
2026-05-11 23:17:14 +08:00
commit 5e2257ab78
198 changed files with 37871 additions and 0 deletions

23
.env.example Executable file
View File

@@ -0,0 +1,23 @@
# 宏量点云处理系统 - 环境变量配置
# 使用方法: cp .env.example .env 并修改以下值
# ── 安全 ───────────────────────────────────────────────
# 【必须修改】JWT 签名密钥,越长越好,生产环境请用随机字符串
JWT_SECRET=please-change-this-to-a-long-random-string
# ── 数据库 ─────────────────────────────────────────────
# MySQL root 密码(首次启动时设置,之后不可随意改动)
MYSQL_ROOT_PASSWORD=root_password_change_me
# 应用连接密码(与 docker-compose DATABASE_URL 中一致)
MYSQL_PASSWORD=pointcloud_password
# ── MinIO 对象存储 ─────────────────────────────────────
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=minioadmin123
# ── 数据目录 ───────────────────────────────────────────
# 宿主机存放 CAD 与点云文件的目录,会被挂载为容器内 /data
DATA_DIR=./data
# ── 前端标题 ───────────────────────────────────────────
VITE_APP_TITLE=宏量点云处理系统

151
.gitignore vendored Normal file
View File

@@ -0,0 +1,151 @@
# Dependencies
node_modules/
**/node_modules/
.pnpm-store/
**/.pnpm-store/
.npm/
# Build outputs
dist/
build/
.vite/
.cache/
.parcel-cache/
*.tsbuildinfo
# Logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
.manus-logs/
**/.manus-logs/
# Environment files
.env
.env.*
!.env.example
!.env.*.example
# Runtime / temporary files
tmp/
temp/
.tmp/
pids/
*.pid
*.seed
*.pid.lock
*.bak
# Test / coverage
coverage/
*.lcov
.nyc_output/
test-results/
playwright-report/
# Local databases
*.db
*.sqlite
*.sqlite3
*.sql.gz
*.sql.zip
dump*.sql
backup*.sql
*_dump.sql
*_backup.sql
# Docker image exports and offline packages
images/
*.tar
*.tar.gz
*.tgz
*.zip
*.7z
*.rar
*.image.tar
massive-cloud-deploy-*.tar.gz
release/
releases/
offline-package/
deploy-package/
# Runtime data, uploads, processing outputs
data/
src/data/
uploads/
upload/
downloads/
processed/
processing/
outputs/
output/
results/
result/
octree/
lod/
tiles/
potree/
pointclouds/
point-clouds/
cad-files/
models/
samples/
# Point cloud raw/intermediate data
*.pcd
*.ply
*.las
*.laz
*.e57
*.xyz
*.pts
*.asc
*.bin
# Large sampled point-cloud text files
points*.txt
# CAD / mesh / model data
*.stl
*.obj
*.mtl
*.step
*.stp
*.iges
*.igs
*.fbx
*.glb
*.gltf
*.dae
*.ifc
# Python / algorithm cache and binary outputs
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/
.venv/
venv/
env/
*.npy
*.npz
*.pkl
*.joblib
# Editor / OS
.DS_Store
._*
.Spotlight-V100
.Trashes
.vscode/
.idea/
*.swp
*.swo
*~
Thumbs.db
ehthumbs.db

712
client/src/pages/Home.tsx Executable file
View File

@@ -0,0 +1,712 @@
import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { useAuth } from '@/_core/hooks/useAuth';
import { getLoginUrl } from '@/const';
import PointCloudViewer from '@/components/PointCloudViewer';
import VehicleTree, { TreeSelection } from '@/components/VehicleTree';
import ModelManager from '@/components/ModelManager';
import TaskManager from '@/components/TaskManager';
import AnalysisPanel from '@/components/AnalysisPanel';
import WorkflowProgress, { type WorkflowState, type WorkflowStepId } from '@/components/WorkflowProgress';
import { trpc } from '@/lib/trpc';
import {
LogIn,
LogOut,
Box,
Eye,
EyeOff,
Settings,
Maximize2,
Crosshair,
Train,
Loader2,
} from 'lucide-react';
import { toast } from 'sonner';
export default function Home() {
const { user, loading, isAuthenticated, logout } = useAuth();
const utils = trpc.useUtils();
// ── 树导航选中状态 ─────────────────────────────────────────────────────────
const [selection, setSelection] = useState<TreeSelection>({
seriesId: null,
modelId: null,
taskId: null,
});
// ── 3D 视图数据 ────────────────────────────────────────────────────────────
const [cadModelUrl, setCadModelUrl] = useState<string | undefined>();
const [cadModelFormat, setCadModelFormat] = useState<string | undefined>();
const [activeCadName, setActiveCadName] = useState<string | undefined>();
const [cadLoading, setCadLoading] = useState(false);
const [cadLoadProgress, setCadLoadProgress] = useState(0);
const [pointCloudTaskId, setPointCloudTaskId] = useState<number | null>(null);
const [pointCloudUrls, setPointCloudUrls] = useState<string[]>([]);
const [pcLoading, setPcLoading] = useState(false);
const [pcProgress, setPcProgress] = useState(0);
// ── 视图控制 ───────────────────────────────────────────────────────────────
const [showHeatmap, setShowHeatmap] = useState(true);
const [showCADModel, setShowCADModel] = useState(true);
const [showWireframe, setShowWireframe] = useState(false);
const [pointSize, setPointSize] = useState(0.03);
/** 点云预览采样预算(非全量);调低可明显减轻卡顿 */
const [renderPointBudget, setRenderPointBudget] = useState(400_000);
/** 点云:逐点;平滑热力:仅在分析结果就绪后由右侧「在 3D 中查看」切换 */
const [pointCloudRenderMode, setPointCloudRenderMode] = useState<'points' | 'smoothHeatmap'>('points');
/** 后台「平滑热力」分析结果 ID,用于拉取顶点色 */
const [heatmapViewResultId, setHeatmapViewResultId] = useState<number | null>(null);
const [errorPickEnabled, setErrorPickEnabled] = useState(false);
const [isCreatingTask, setIsCreatingTask] = useState(false);
// 触发分析时的 completed 结果基线数(key=taskId, value=触发时已有的 completed 数)
const [analysisBaselines, setAnalysisBaselines] = useState<Map<number, number>>(new Map());
// 用 ref 在 onSuccess 闭包中读取最新 analysisResults,避免过时闭包
const analysisResultsRef = useRef<any[]>([]);
const [showLeftPanel, setShowLeftPanel] = useState(true);
const [showRightPanel, setShowRightPanel] = useState(true);
const [selectedPoint, setSelectedPoint] = useState<{
position: { x: number; y: number; z: number };
error: number;
index: number;
} | null>(null);
const cadModelUrlRef = useRef<string | undefined>(undefined);
cadModelUrlRef.current = cadModelUrl;
// ── 当前车型信息(用于面板标题) ───────────────────────────────────────────
const { data: activeModel } = trpc.vehicleModel.get.useQuery(
{ id: selection.modelId! },
{ enabled: selection.modelId != null }
);
/** 平滑热力与装配检测共用同一 mutation,避免重复订阅;勿在 batchTaskId 为空时 disabled,否则点击无任何反馈 */
const runAnalysisJob = trpc.analysisResult.runAnalysis.useMutation({
onSuccess: (_data, variables) => {
if (variables.type === 'smooth_heatmap') {
toast.success('已提交平滑热力后台分析,请稍后在右侧「分析结果」中查看;完成后点击「在 3D 中查看」');
} else if (variables.type === 'assembly_inspection') {
toast.success('已提交装配检测(STEP + 最近零件),请稍后在右侧「分析结果」查看各零件统计');
} else if (variables.type === 'obj_deviation') {
toast.success('已提交偏差热力分析(STL/OBJ + 点云距离),完成后点击「在 3D 中查看」');
}
void utils.analysisResult.list.invalidate({ taskId: variables.taskId });
// 记录此时已有的 completed 数作为基线,只有新增结果才算本次分析完成
const baselineCount = analysisResultsRef.current.filter((r: any) => r.status === 'completed').length;
setAnalysisBaselines(prev => {
const next = new Map(prev);
if (!next.has(variables.taskId)) next.set(variables.taskId, baselineCount);
return next;
});
},
onError: (e) => toast.error(e.message || '提交失败'),
});
// 切换车型时清空 CAD,避免沿用上一车型的 URL
useEffect(() => {
setCadModelUrl(undefined);
setCadModelFormat(undefined);
setActiveCadName(undefined);
setCadLoading(false);
setCadLoadProgress(0);
}, [selection.modelId]);
useEffect(() => {
setHeatmapViewResultId(null);
setPointCloudRenderMode('points');
setIsCreatingTask(false);
}, [selection.taskId]);
// ── 回调 ───────────────────────────────────────────────────────────────────
/** STEP 无浏览器解析:若服务端已有 assembly manifest,则用首件 STL 作为 3D 预览 */
const resolveCadPreview = useCallback(
async (modelId: number, file: { id: number; name: string; format: string }) => {
const lower = file.name.toLowerCase();
if (!lower.endsWith('.stp') && !lower.endsWith('.step')) {
return {
url: `/api/files/read/cad/${modelId}/${encodeURIComponent(file.name)}`,
format: file.format,
label: file.name,
};
}
try {
const res = await utils.cadFile.assemblyManifest.fetch({ modelId, cadFileId: file.id });
const manifest = res.manifest as { parts?: { stlRelative: string; name: string }[] } | null;
if (res.ready && manifest?.parts && manifest.parts.length > 0) {
const p0 = manifest.parts[0]!;
return {
url: `/api/files/read/cad/${modelId}/${encodeURIComponent(
`.step_parse/${file.id}/${p0.stlRelative}`,
)}`,
format: 'stl',
label: `${file.name} → ${p0.name}`,
};
}
} catch (e) {
console.warn('[Home] assemblyManifest', e);
}
return null;
},
[utils.cadFile.assemblyManifest],
);
const handleCadProgress = useCallback((p: number) => setCadLoadProgress(p), []);
const handleCadLoaded = useCallback(() => {
setCadLoading(false);
setCadLoadProgress(100);
}, []);
const handleCadLoadError = useCallback((message: string) => {
setCadLoading(false);
setCadLoadProgress(0);
toast.error(message);
}, []);
const handleSelectCad = useCallback(
async (url: string, format: string, name: string, cadFileId?: number) => {
setCadLoading(true);
setCadLoadProgress(3);
if (selection.modelId != null && cadFileId != null) {
const resolved = await resolveCadPreview(selection.modelId, { id: cadFileId, name, format });
if (resolved) {
setCadModelUrl(resolved.url);
setCadModelFormat(resolved.format);
setActiveCadName(resolved.label);
return;
}
const isStep = name.toLowerCase().match(/\.(stp|step)$/);
if (isStep) {
toast.error(
'STEP 尚未生成三角网格预览。请先点击底部「装配检测」跑完一次(需 Python cadquery),或改用 STL/OBJ 做 3D 预览。',
);
setCadLoading(false);
setCadLoadProgress(0);
setCadModelUrl(undefined);
setCadModelFormat(undefined);
setActiveCadName(undefined);
return;
}
}
setCadModelUrl(url);
setCadModelFormat(format);
setActiveCadName(name);
},
[resolveCadPreview, selection.modelId],
);
const handleLoadPointClouds = useCallback(async (taskId: number, _filePaths: string[]) => {
const modelId = selection.modelId;
// 若未在左侧点过 CAD「预览」,自动加载该车型下第一个 CAD,便于与点云同屏
if (modelId != null && !cadModelUrlRef.current) {
try {
const files = await utils.cadFile.list.fetch({ modelId });
const first = files[0];
if (first) {
setCadLoading(true);
setCadLoadProgress(0);
const resolved = await resolveCadPreview(modelId, {
id: first.id,
name: first.name,
format: first.format,
});
if (resolved) {
setCadModelUrl(resolved.url);
setCadModelFormat(resolved.format);
setActiveCadName(resolved.label);
} else if (!first.name.toLowerCase().match(/\.(stp|step)$/)) {
setCadModelUrl(`/api/files/read/cad/${modelId}/${encodeURIComponent(first.name)}`);
setCadModelFormat(first.format);
setActiveCadName(first.name);
} else {
setCadLoading(false);
}
}
} catch (e) {
console.warn('[Home] CAD list fetch failed:', e);
}
}
setPointCloudTaskId(taskId);
setPointCloudUrls([`__task:${taskId}`]);
setPcLoading(true);
setPcProgress(0);
}, [selection.modelId, utils.cadFile.list, resolveCadPreview]);
const handlePcProgress = useCallback((p: number) => setPcProgress(p), []);
const handlePcLoaded = useCallback(() => {
setPcLoading(false);
setPcProgress(100);
}, []);
const handlePointSelect = useCallback((pt: any) => {
if (pt) setSelectedPoint({ position: pt.position, error: pt.error, index: pt.index });
}, []);
// ── 把 __task:N 格式的 URL 转成 batch 请求参数 ──────────────────────────────
const batchTaskId = pointCloudTaskId;
// ── 工作流进度条状态推导 ───────────────────────────────────────────────────
const { data: analysisResults = [] } = trpc.analysisResult.list.useQuery(
{ taskId: selection.taskId! },
{ enabled: selection.taskId != null, refetchInterval: 5000 },
);
useEffect(() => { analysisResultsRef.current = analysisResults; }, [analysisResults]);
const workflowState = useMemo<WorkflowState>(() => {
const hasCad = !!cadModelUrl;
const hasPc = pointCloudTaskId != null;
const rendered = hasPc && !pcLoading && pcProgress >= 100;
const baseline = (!isCreatingTask && selection.taskId != null)
? (analysisBaselines.get(selection.taskId) ?? -1)
: -1;
const triggered = baseline >= 0;
const currentCompleted = (analysisResults as any[]).filter((r: any) => r.status === 'completed').length;
const analyzed = triggered && currentCompleted > baseline;
function step(cond: boolean, prev: boolean): 'done' | 'active' | 'pending' {
if (cond) return 'done';
if (prev) return 'active';
return 'pending';
}
// 分析步骤:只在 触发后未完成 时转圈;保存步骤:分析完成即完成,无中间态
const analysisStatus: StepStatus = analyzed ? 'done' : triggered ? 'active' : 'pending';
const saveStatus: StepStatus = analyzed ? 'done' : 'pending';
return {
cad: step(hasCad, true),
pointcloud: step(hasPc, hasCad),
render: step(rendered, hasPc),
analysis: analysisStatus,
save: saveStatus,
};
}, [cadModelUrl, pointCloudTaskId, pcLoading, pcProgress, analysisResults, isCreatingTask, analysisBaselines, selection.taskId]);
const rightPanelRef = useRef<HTMLDivElement | null>(null);
const handleWorkflowStepClick = useCallback((id: WorkflowStepId) => {
if (id === 'cad' || id === 'pointcloud') {
setShowLeftPanel(true);
} else if (id === 'render') {
if (selection.taskId != null && pointCloudTaskId !== selection.taskId) {
handleLoadPointClouds(selection.taskId, []);
}
} else if (id === 'analysis' || id === 'save') {
setShowRightPanel(true);
}
}, [selection.taskId, pointCloudTaskId, handleLoadPointClouds]);
// 必须稳定数组引用:若每次 render 都 new [],PointCloudViewer 会以为点云源变了而反复全量请求
const pointCloudUrlsStable = useMemo(
() => (pointCloudTaskId != null ? [`__task:${pointCloudTaskId}`] : undefined),
[pointCloudTaskId]
);
// ── 左侧面板渲染 ───────────────────────────────────────────────────────────
const renderLeftPanel = () => {
if (selection.modelId == null) {
return (
<VehicleTree selection={selection} onSelect={setSelection} />
);
}
return (
<>
<VehicleTree selection={selection} onSelect={setSelection} />
<div className="h-px bg-border my-1" />
<ModelManager
modelId={selection.modelId}
onSelectCad={handleSelectCad}
activeCadName={activeCadName}
cadLoading={cadLoading}
cadLoadProgress={cadLoadProgress}
/>
<div className="h-px bg-border my-1" />
<TaskManager
modelId={selection.modelId}
activeTaskId={selection.taskId}
onSelectTask={taskId => setSelection(s => ({ ...s, taskId }))}
onLoadPointClouds={handleLoadPointClouds}
onStartCreate={() => setIsCreatingTask(true)}
onEndCreate={() => setIsCreatingTask(false)}
/>
</>
);
};
const renderRightPanel = () => {
if (selection.taskId == null) {
return (
<div className="flex flex-col items-center justify-center h-full text-center py-16 text-xs text-muted-foreground space-y-2">
<Train className="w-8 h-8 opacity-20" />
<p>请从左侧选择检测任务</p>
<p className="text-[10px]">选中任务后可查看分析结果</p>
</div>
);
}
return (
<AnalysisPanel
taskId={selection.taskId}
onReplay={resultId => console.log('[Replay] resultId', resultId)}
onViewSmoothHeatmap={(resultId) => {
setHeatmapViewResultId(resultId);
setPointCloudRenderMode('smoothHeatmap');
}}
/>
);
};
return (
<div className="h-screen flex flex-col bg-background">
{/* 顶部工具栏 */}
<header className="h-12 border-b border-border bg-card flex items-center justify-between px-4 flex-shrink-0">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<div className="w-7 h-7 rounded bg-primary/20 flex items-center justify-center">
<Box className="w-4 h-4 text-primary" />
</div>
<h1 className="text-sm font-semibold">宏量点云处理系统</h1>
</div>
{activeModel && (
<>
<div className="h-6 w-px bg-border" />
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Train className="w-3.5 h-3.5" />
<span>{activeModel.name}</span>
{selection.taskId && (
<span className="text-[10px] bg-primary/20 text-primary px-1.5 py-0.5 rounded">
任务 #{selection.taskId}
</span>
)}
</div>
</>
)}
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1">
<button
className={`industrial-btn py-1.5 ${showLeftPanel ? 'industrial-btn-primary' : ''}`}
onClick={() => setShowLeftPanel(v => !v)}
title="左侧面板"
>
<Settings className="w-3.5 h-3.5" />
</button>
<button
className={`industrial-btn py-1.5 ${showRightPanel ? 'industrial-btn-primary' : ''}`}
onClick={() => setShowRightPanel(v => !v)}
title="右侧面板"
>
<Eye className="w-3.5 h-3.5" />
</button>
</div>
<div className="flex items-center gap-2 px-3 py-1 industrial-card">
<span className={`status-led ${
pcLoading ? 'status-led-processing' :
cadLoading ? 'status-led-processing' :
'status-led-idle'
}`} />
<span className="text-xs text-muted-foreground">
{pcLoading ? `加载点云 ${pcProgress}%` :
cadLoading ? `加载 CAD ${cadLoadProgress}%` :
'待机'}
</span>
</div>
{loading ? (
<div className="w-7 h-7 rounded bg-secondary animate-pulse" />
) : isAuthenticated ? (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{user?.name || '用户'}</span>
<button className="industrial-btn py-1.5" onClick={() => logout()}>
<LogOut className="w-3.5 h-3.5" />
</button>
</div>
) : (
<a href={getLoginUrl()} className="industrial-btn industrial-btn-primary py-1.5">
<LogIn className="w-3.5 h-3.5" />
</a>
)}
</div>
</header>
{/* 主内容区 */}
<div className="flex-1 flex overflow-hidden">
{/* 左侧面板 */}
{showLeftPanel && (
<aside className="w-72 border-r border-border bg-card/30 flex-shrink-0 overflow-y-auto p-3 space-y-2">
{renderLeftPanel()}
</aside>
)}
{/* 中央 3D 视图 */}
<main className="flex-1 flex flex-col min-w-0">
{/* 工作流进度条(仅在选中任务时显示) */}
{selection.taskId != null && (
<WorkflowProgress
state={workflowState}
onStepClick={handleWorkflowStepClick}
/>
)}
<div className="flex-1 relative">
<div className="absolute inset-2">
<div className="viewer-container w-full h-full relative">
{/* 视图标题栏 */}
<div className="absolute top-0 left-0 right-0 h-6 bg-[oklch(0.16_0.012_240)] border-b border-border z-10 flex items-center px-3">
<span className="text-[10px] text-muted-foreground uppercase tracking-wider">3D Viewport</span>
<div className="flex-1" />
<div className="flex items-center gap-3 text-[10px]">
<span className="text-muted-foreground">FPS: <span className="text-[oklch(0.62_0.16_145)] font-mono">60</span></span>
</div>
</div>
{/* 3D 渲染区域 */}
<div className="absolute inset-0 pt-6">
<PointCloudViewer
showHeatmap={showHeatmap}
showCADModel={showCADModel}
showWireframe={showWireframe}
pointSize={pointSize}
cadModelUrl={cadModelUrl}
cadModelFormat={cadModelFormat}
pointCloudUrls={pointCloudUrlsStable}
batchTaskId={batchTaskId ?? undefined}
onCadProgress={handleCadProgress}
onCadLoaded={handleCadLoaded}
onCadLoadError={handleCadLoadError}
onPointCloudProgress={handlePcProgress}
onPointCloudLoaded={handlePcLoaded}
onPointSelect={handlePointSelect}
enableErrorPicking={errorPickEnabled}
renderPointBudget={renderPointBudget}
pointCloudRenderMode={pointCloudRenderMode}
heatmapAnalysisResultId={heatmapViewResultId}
className="w-full h-full"
/>
{pcLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-background/70 backdrop-blur-sm pointer-events-none">
<div className="industrial-panel p-6 text-center max-w-xs">
<div className="text-sm font-medium mb-3">正在加载点云数据</div>
<div className="industrial-progress mb-2">
<div className="industrial-progress-bar" style={{ width: `${pcProgress}%` }} />
</div>
<div className="text-xs text-muted-foreground">{pcProgress}%</div>
</div>
</div>
)}
</div>
{/* 操作提示 */}
<div className="absolute bottom-3 left-3 info-overlay flex items-center gap-4 text-[10px] flex-wrap">
{errorPickEnabled && pointCloudRenderMode === 'smoothHeatmap' && (
<span className="text-amber-400/90">平滑模式不支持选点,请切回「点云」</span>
)}
{errorPickEnabled && pointCloudRenderMode === 'points'
? <><span>左键点击选点误差</span><span>右键平移</span><span>滚轮缩放</span></>
: <><span>左键旋转</span><span>右键平移</span><span>滚轮缩放</span></>
}
</div>
{/* 选中点信息 */}
{selectedPoint && (
<div className="absolute top-10 right-2 industrial-panel p-2 text-xs space-y-1 min-w-[140px]">
<div className="industrial-label text-[10px] mb-1">选中点</div>
<div className="grid grid-cols-3 gap-1 font-mono text-[10px]">
<div className="text-center"><div className="text-muted-foreground">X</div>{selectedPoint.position.x.toFixed(2)}</div>
<div className="text-center"><div className="text-muted-foreground">Y</div>{selectedPoint.position.y.toFixed(2)}</div>
<div className="text-center"><div className="text-muted-foreground">Z</div>{selectedPoint.position.z.toFixed(2)}</div>
</div>
<div className="flex justify-between items-center border-t border-border/40 pt-1">
<span className="text-muted-foreground">误差</span>
<span className={`font-mono ${
selectedPoint.error > 1.5 ? 'text-red-400' :
selectedPoint.error > 0.8 ? 'text-amber-400' : 'text-green-400'
}`}>{selectedPoint.error.toFixed(4)} mm</span>
</div>
</div>
)}
{/* 坐标轴 */}
<div className="axis-indicator">
<svg viewBox="0 0 50 50" className="w-full h-full">
<line x1="25" y1="25" x2="45" y2="25" stroke="#ef4444" strokeWidth="2" />
<line x1="25" y1="25" x2="25" y2="5" stroke="#22c55e" strokeWidth="2" />
<line x1="25" y1="25" x2="10" y2="40" stroke="#3b82f6" strokeWidth="2" />
<text x="46" y="28" fill="#ef4444" fontSize="8">X</text>
<text x="22" y="4" fill="#22c55e" fontSize="8">Y</text>
<text x="4" y="44" fill="#3b82f6" fontSize="8">Z</text>
</svg>
</div>
</div>
</div>
</div>
{/* 底部视图控制 */}
<div className="h-10 border-t border-border bg-card/50 flex items-center px-4 gap-4">
<div className="flex items-center gap-2">
<button
type="button"
className={`industrial-btn py-1 ${pointCloudRenderMode === 'points' ? 'industrial-btn-primary' : ''}`}
onClick={() => {
setPointCloudRenderMode('points');
setHeatmapViewResultId(null);
}}
title="逐点显示(支持误差拾取)"
>
<span className="text-[10px]">点云</span>
</button>
<button
type="button"
className={`industrial-btn py-1 ${
runAnalysisJob.isPending ? 'opacity-70 cursor-wait' : ''
} ${batchTaskId == null ? 'opacity-60' : ''}`}
disabled={runAnalysisJob.isPending}
onClick={() => {
if (batchTaskId == null) {
toast.error('请先在左侧选择检测任务并加载点云');
return;
}
runAnalysisJob.mutate({
taskId: batchTaskId,
type: 'smooth_heatmap',
totalPoints: renderPointBudget,
});
}}
title="在后台计算 CAD 壳平滑热力,不阻塞页面;完成后在右侧分析结果中打开"
>
{runAnalysisJob.isPending ? (
<Loader2 className="w-3.5 h-3.5 animate-spin inline mr-0.5" />
) : null}
<span className="text-[10px]">提交平滑热力</span>
</button>
<button
type="button"
className={`industrial-btn py-1 ${
runAnalysisJob.isPending ? 'opacity-70 cursor-wait' : ''
} ${batchTaskId == null ? 'opacity-60' : ''}`}
disabled={runAnalysisJob.isPending}
onClick={() => {
if (batchTaskId == null) {
toast.error('请先在左侧选择检测任务并加载点云');
return;
}
runAnalysisJob.mutate({
taskId: batchTaskId,
type: 'assembly_inspection',
samplePoints: Math.min(200_000, Math.max(20_000, renderPointBudget)),
toleranceMm: 0.3,
includeVertexHeatmap: false,
});
}}
title="需车型下已上传 STEP;后台解析零件并做最近零件归属与误差统计(依赖 Python cadquery)"
>
{runAnalysisJob.isPending ? (
<Loader2 className="w-3.5 h-3.5 animate-spin inline mr-0.5" />
) : null}
<span className="text-[10px]">装配检测</span>
</button>
<button
type="button"
className={`industrial-btn py-1 ${
runAnalysisJob.isPending ? 'opacity-70 cursor-wait' : ''
} ${batchTaskId == null ? 'opacity-60' : ''}`}
disabled={runAnalysisJob.isPending}
onClick={() => {
if (batchTaskId == null) {
toast.error('请先在左侧选择检测任务并加载点云');
return;
}
runAnalysisJob.mutate({
taskId: batchTaskId,
type: 'obj_deviation',
totalPoints: renderPointBudget,
});
}}
title="在 STL/OBJ 网格上按「顶点到最近点云点的距离」着色,生成偏差色图"
>
{runAnalysisJob.isPending ? (
<Loader2 className="w-3.5 h-3.5 animate-spin inline mr-0.5" />
) : null}
<span className="text-[10px]">偏差热力</span>
</button>
{heatmapViewResultId != null && pointCloudRenderMode === 'smoothHeatmap' && (
<span className="text-[9px] text-primary/90 px-1.5 py-0.5 rounded bg-primary/10 border border-primary/20">
正在显示分析结果 #{heatmapViewResultId}
</span>
)}
<button
className={`industrial-btn py-1 ${showHeatmap ? 'industrial-btn-primary' : ''}`}
onClick={() => setShowHeatmap(v => !v)}
>
{showHeatmap ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
<span className="ml-1">热力图</span>
</button>
<button
className={`industrial-btn py-1 ${showCADModel ? 'industrial-btn-primary' : ''}`}
onClick={() => setShowCADModel(v => !v)}
>
<Box className="w-3.5 h-3.5" />
<span className="ml-1">CAD</span>
</button>
<button
className={`industrial-btn py-1 ${showWireframe ? 'industrial-btn-primary' : ''}`}
onClick={() => setShowWireframe(v => !v)}
>
<Settings className="w-3.5 h-3.5" />
<span className="ml-1">线框</span>
</button>
<button
className={`industrial-btn py-1 ${errorPickEnabled ? 'industrial-btn-primary' : ''}`}
onClick={() => setErrorPickEnabled(v => !v)}
title="开启后左键点击显示该点误差"
>
<Crosshair className="w-3.5 h-3.5" />
<span className="ml-1">误差拾取</span>
</button>
</div>
<div className="h-5 w-px bg-border" />
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">点大小</span>
<input
type="range"
min="0.01"
max="0.1"
step="0.005"
value={pointSize}
onChange={e => setPointSize(parseFloat(e.target.value))}
className="w-24 h-1 bg-secondary rounded appearance-none cursor-pointer"
/>
<span className="text-xs font-mono w-10">{pointSize.toFixed(3)}</span>
</div>
<div className="h-5 w-px bg-border" />
<div className="flex items-center gap-2" title="服务端随机采样点数,非磁盘全量。调低更流畅;偏差热力 GPU 可设更高。">
<span className="text-xs text-muted-foreground whitespace-nowrap">预览点数</span>
<input
type="range"
min={100_000}
max={5_000_000}
step={100_000}
value={renderPointBudget}
onChange={e => setRenderPointBudget(Number(e.target.value))}
className="w-28 h-1 bg-secondary rounded appearance-none cursor-pointer"
/>
<span className="text-xs font-mono w-14">{renderPointBudget >= 1_000_000 ? (renderPointBudget / 1_000_000).toFixed(1) + 'M' : (renderPointBudget / 1000).toFixed(0) + 'k'}</span>
</div>
</div>
</main>
{/* 右侧面板 - 分析结果 */}
{showRightPanel && (
<aside ref={rightPanelRef} className="w-80 border-l border-border bg-card/30 flex-shrink-0 overflow-y-auto p-3">
{renderRightPanel()}
</aside>
)}
</div>
</div>
);
}

27
cp_file.sh Executable file
View File

@@ -0,0 +1,27 @@
TASK_ID=1
SRC_DIR=/home/lipan/massive-cloud/data/pointcloud
# 在容器内创建目录
docker exec massive-point-cloud-app-dev mkdir -p /data/tasks/${TASK_ID}/pointcloud
# 循环复制并写库
for f in ${SRC_DIR}/points*.txt; do
name=$(basename "$f")
size=$(stat -c%s "$f")
echo "复制 $name ($((size/1024/1024)) MB)..."
docker cp "$f" massive-point-cloud-app-dev:/data/tasks/${TASK_ID}/pointcloud/${name}
docker exec massive-point-cloud-mysql \
mysql -u pointcloud -ppointcloud_password pointcloud_db -e \
"INSERT IGNORE INTO task_pointcloud_files (taskId, name, fileSize, filePath, createdAt)
VALUES (${TASK_ID}, '${name}', ${size}, '/data/tasks/${TASK_ID}/pointcloud/${name}', NOW());"
echo " ✓ 完成"
done
echo ""
echo "=== 验证 ==="
docker exec massive-point-cloud-mysql \
mysql -u pointcloud -ppointcloud_password pointcloud_db -e \
"SELECT id, name, ROUND(fileSize/1024/1024,1) AS size_MB FROM task_pointcloud_files WHERE taskId=${TASK_ID};"

15
docker-compose.gpu-v1.yml Executable file
View File

@@ -0,0 +1,15 @@
# GPU 叠加配置(旧版 docker-compose V1,使用 runtime: nvidia)
# 用法:docker-compose -f docker-compose.yml -f docker-compose.gpu-v1.yml up -d
#
# 前提:已注册 nvidia runtime
# sudo nvidia-ctk runtime configure --runtime=docker
# sudo systemctl restart docker
# 验证:docker info | grep -i nvidia
version: '3.8'
services:
app:
runtime: nvidia
environment:
- ASSEMBLY_USE_GPU=1

25
docker-compose.gpu-v2.yml Executable file
View File

@@ -0,0 +1,25 @@
# GPU 叠加配置(Compose V2:docker compose)
# 用法:docker compose -f docker-compose.yml -f docker-compose.gpu-v2.yml up -d
#
# 前提:主机已安装 nvidia-container-toolkit
# distribution=$(. /etc/os-release; echo $ID$VERSION_ID)
# curl -s -L https://nvidia.github.io/libnvidia-container/gpgkey | sudo apt-key add -
# curl -s -L https://nvidia.github.io/libnvidia-container/$distribution/libnvidia-container.list \
# | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
# sudo apt-get update && sudo apt-get install -y nvidia-container-toolkit
# sudo nvidia-ctk runtime configure --runtime=docker
# sudo systemctl restart docker
version: '3.8'
services:
app:
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
environment:
- ASSEMBLY_USE_GPU=1

View File

@@ -0,0 +1,16 @@
# GPU overlay (Docker Compose V2 插件, docker compose 命令)
# 不需要额外注册 nvidia runtime
#
# 启动: docker compose -f docker-compose.yml -f docker-compose.gpu.compose-v2.yml up -d
version: '3.8'
services:
app:
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]

12
docker-compose.gpu.yml Executable file
View File

@@ -0,0 +1,12 @@
# GPU overlay (旧版 docker-compose / Docker Engine < 19.03)
# 需先在宿主机注册 nvidia 运行时:
# sudo nvidia-ctk runtime configure --runtime=docker && sudo systemctl restart docker
# 验证: docker info | grep -i nvidia
#
# 启动: docker-compose -f docker-compose.yml -f docker-compose.gpu.yml up -d
version: '3.8'
services:
app:
runtime: nvidia

134
docker-compose.yml Executable file
View File

@@ -0,0 +1,134 @@
version: '3.8'
# 宏量点云处理系统 — 目标服务器部署配置
# 使用方法:
# 1. 先执行 load-images.sh 载入镜像
# 2. cp .env.example .env 然后按需修改
# 3. docker-compose up -d
#
# GPU (可选):叠加 GPU overlay:
# 旧版 docker-compose: docker-compose -f docker-compose.yml -f docker-compose.gpu.yml up -d
# Compose V2: docker compose -f docker-compose.yml -f docker-compose.gpu.compose-v2.yml up -d
#
# 端口说明 (host 网络模式,直接占用宿主机端口):
# 应用: 3002
# MySQL: 3307
# MinIO API: 9010
# MinIO 控制台: 9011
services:
app:
image: massive-point-cloud-app-dev:frozen-20260409
container_name: massive-point-cloud-app-dev
working_dir: /app
restart: unless-stopped
network_mode: host
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
environment:
- NODE_ENV=development
- PORT=3002
- DATABASE_URL=mysql://pointcloud:${MYSQL_PASSWORD:-pointcloud_password}@localhost:3307/pointcloud_db
- JWT_SECRET=${JWT_SECRET:-please-change-this-secret-in-production}
- AUTH_MODE=local
- S3_ENDPOINT=http://localhost:9010
- S3_ACCESS_KEY=${MINIO_ROOT_USER:-minioadmin}
- S3_SECRET_KEY=${MINIO_ROOT_PASSWORD:-minioadmin123}
- S3_BUCKET=pointcloud-data
- S3_REGION=us-east-1
- DATA_DIR=/data
- PYTHON3_PATH=/usr/bin/python3
- ASSEMBLY_USE_GPU=1
- NVIDIA_VISIBLE_DEVICES=all
- NVIDIA_DRIVER_CAPABILITIES=compute,utility
- VITE_APP_TITLE=${VITE_APP_TITLE:-宏量点云处理系统}
- VITE_OAUTH_PORTAL_URL=disabled
- VITE_APP_ID=
- CHOKIDAR_USEPOLLING=true
volumes:
- ./src:/app
- app-node-modules:/app/node_modules
- ${DATA_DIR:-./data}:/data
command: sh -c "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm dev --host 0.0.0.0"
depends_on:
mysql:
condition: service_healthy
minio:
condition: service_started
mysql:
image: mysql:8.0
container_name: massive-point-cloud-mysql
restart: unless-stopped
network_mode: host
environment:
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-root_password_change_me}
- MYSQL_DATABASE=pointcloud_db
- MYSQL_USER=pointcloud
- MYSQL_PASSWORD=${MYSQL_PASSWORD:-pointcloud_password}
- MYSQL_TCP_PORT=3307
volumes:
- mysql-data:/var/lib/mysql
- ./docker/mysql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-P", "3307", "-u", "root", "-p${MYSQL_ROOT_PASSWORD:-root_password_change_me}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
command: >
--default-authentication-plugin=mysql_native_password
--character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci
--port=3307
--sort_buffer_size=33554432
--read_rnd_buffer_size=8388608
--tmp_table_size=67108864
--max_heap_table_size=67108864
minio:
image: minio/minio:latest
container_name: massive-point-cloud-minio
restart: unless-stopped
network_mode: host
environment:
- MINIO_ROOT_USER=${MINIO_ROOT_USER:-minioadmin}
- MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-minioadmin123}
volumes:
- minio-data:/data
command: server /data --address ":9010" --console-address ":9011"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9010/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
minio-init:
image: minio/mc:latest
container_name: massive-point-cloud-minio-init
network_mode: host
restart: on-failure
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
mc alias set myminio http://localhost:9010 ${MINIO_ROOT_USER:-minioadmin} ${MINIO_ROOT_PASSWORD:-minioadmin123};
mc mb myminio/pointcloud-data --ignore-existing;
mc anonymous set public myminio/pointcloud-data;
echo 'MinIO bucket initialized';
exit 0;
"
volumes:
mysql-data:
name: pointcloud-mysql-data
minio-data:
name: pointcloud-minio-data
app-node-modules:
name: pointcloud-app-node-modules-dev

43
docker/Dockerfile.dev Executable file
View File

@@ -0,0 +1,43 @@
# 开发镜像(仅 CPU,体积小):Node 22 + Python 3 + cadquery
# 需要 Docker 内装配检测 GPU 时改用 docker/Dockerfile.dev.gpu(见 docker-compose.dev.yml)
# 使用 glibc(bookworm),便于 cadquery / OCP 的 manylinux 轮子
FROM node:22-bookworm-slim
# cadquery/OCP 会链接 OpenGL 与 X11 客户端库(无显示器也会加载),slim 需一次性装齐
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
python3-pip \
ca-certificates \
libgl1 \
libglib2.0-0 \
libxrender1 \
libxext6 \
libsm6 \
libice6 \
libx11-6 \
&& rm -rf /var/lib/apt/lists/*
# 先安装几何依赖(镜像构建时固化,避免每次 compose up 再 pip)
# cadquery-ocp 轮子约 70MB,默认 pip 超时过短会 Read timed out;可调大 PIP_DEFAULT_TIMEOUT
# 默认国内 PyPI 源;覆盖示例:--build-arg PIP_INDEX_URL=https://mirrors.aliyun.com/pypi/simple/
ARG PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
ARG PIP_DEFAULT_TIMEOUT=300
COPY requirements-geometry.txt /tmp/requirements-geometry.txt
# 必须用 python3 -m pip,避免 pip3 与 /usr/bin/python3 不是同一套 site-packages(PEP 668 下常见踩坑)
RUN python3 -m pip install --no-cache-dir --break-system-packages \
--default-timeout="${PIP_DEFAULT_TIMEOUT}" \
--retries 10 \
-i "${PIP_INDEX_URL}" \
-r /tmp/requirements-geometry.txt \
&& rm /tmp/requirements-geometry.txt \
&& python3 -c "import cadquery; print('cadquery OK:', cadquery.__file__)"
# Node 子进程解析 STEP 时使用;与 compose 中 PYTHON3_PATH 一致,避免仅 build 镜像未带 compose 时未设置
ENV PYTHON3_PATH=/usr/bin/python3
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest --activate
# 默认占位;docker-compose.dev.yml 会覆盖为 pnpm dev
CMD ["node", "-e", "console.log('use docker-compose command')"]

89
docker/Dockerfile.dev.gpu Executable file
View File

@@ -0,0 +1,89 @@
# 开发镜像(GPU):Ubuntu 22.04 + CUDA 12.1 runtime + Node 22 + cadquery + PyTorch(cu121) + PyTorch3D
# 用于装配检测「最近零件」服务端 GPU(scripts/assembly_nearest_gpu.py)
# 宿主机需:NVIDIA 驱动 + nvidia-container-toolkit;构建不要求 GPU,运行需 --gpus
#
# 构建:docker build -t massive-point-cloud-app-dev:local -f docker/Dockerfile.dev.gpu .
#
# 无 NVIDIA 的机器请改用 docker/Dockerfile.dev(仅 CPU),compose 中去掉 gpus 与 ASSEMBLY_USE_GPU
FROM nvidia/cuda:12.1.1-cudnn8-runtime-ubuntu22.04
ENV DEBIAN_FRONTEND=noninteractive \
PYTHON3_PATH=/usr/bin/python3 \
ASSEMBLY_USE_GPU=1 \
NVIDIA_VISIBLE_DEVICES=all \
NVIDIA_DRIVER_CAPABILITIES=compute,utility
# apt 换阿里云(国内构建加速);需官方源时可在下方注释掉本段
RUN set -eux; \
if [ -f /etc/apt/sources.list ]; then \
sed -i 's|http://archive.ubuntu.com/ubuntu|http://mirrors.aliyun.com/ubuntu|g' /etc/apt/sources.list; \
sed -i 's|http://security.ubuntu.com/ubuntu|http://mirrors.aliyun.com/ubuntu|g' /etc/apt/sources.list; \
fi
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 \
python3-pip \
python3-dev \
ca-certificates \
curl \
gnupg \
libgl1 \
libglib2.0-0 \
libxrender1 \
libxext6 \
libsm6 \
libice6 \
libx11-6 \
&& rm -rf /var/lib/apt/lists/*
# CUDA runtime 镜像默认不带 nvidia-smi;装 Ubuntu 的 nvidia-utils(主版本与宿主机驱动大版本一致,如 535/550)
# 构建失败时改:--build-arg NVIDIA_UTILS_MAJOR=550
ARG NVIDIA_UTILS_MAJOR=535
RUN apt-get update && apt-get install -y --no-install-recommends \
"nvidia-utils-${NVIDIA_UTILS_MAJOR}" \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \
&& apt-get update && apt-get install -y --no-install-recommends nodejs \
&& rm -rf /var/lib/apt/lists/*
RUN python3 -m pip install --no-cache-dir --upgrade pip setuptools wheel
# cadquery(与 Dockerfile.dev 一致)
ARG PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
ARG PIP_DEFAULT_TIMEOUT=300
COPY requirements-geometry.txt /tmp/requirements-geometry.txt
RUN python3 -m pip install --no-cache-dir --break-system-packages \
--default-timeout="${PIP_DEFAULT_TIMEOUT}" \
--retries 10 \
-i "${PIP_INDEX_URL}" \
-r /tmp/requirements-geometry.txt \
&& rm /tmp/requirements-geometry.txt \
&& python3 -c "import cadquery; print('cadquery OK')"
# PyTorch CUDA 12.1:默认清华镜像;覆盖示例:--build-arg TORCH_INDEX_URL=https://download.pytorch.org/whl/cu121
ARG TORCH_INDEX_URL=https://mirrors.tuna.tsinghua.edu.cn/pytorch/whl/cu121
RUN python3 -m pip install --no-cache-dir --break-system-packages \
--default-timeout=300 --retries 10 \
torch==2.3.1 torchvision==0.18.1 \
--index-url "${TORCH_INDEX_URL}"
# PyTorch3D 预编译轮仅 Meta 官方 CDN,无稳定国内镜像;加大超时。可本地下载 wheel 后 COPY + pip install。
RUN python3 -m pip install --no-cache-dir --break-system-packages \
--default-timeout=300 --retries 10 \
-i https://pypi.tuna.tsinghua.edu.cn/simple \
trimesh \
&& python3 -m pip install --no-cache-dir --break-system-packages \
--default-timeout=600 --retries 15 \
pytorch3d -f https://dl.fbaipublicfiles.com/pytorch3d/packaging/wheels/py310_cu121_pyt231/download.html
RUN python3 -c "import torch; import pytorch3d; import trimesh; print('pytorch', torch.__version__, 'pytorch3d', pytorch3d.__version__)"
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest --activate
CMD ["node", "-e", "console.log('use docker-compose command')"]

41
docker/env-config.md Executable file
View File

@@ -0,0 +1,41 @@
# 环境变量配置说明
部署前,请在项目根目录创建 `.env` 文件,包含以下配置:
## 安全配置 (必须修改!)
```bash
# JWT密钥 - 用于会话签名,请使用随机字符串
# 生成方法: openssl rand -base64 32
JWT_SECRET=your-super-secret-jwt-key-change-in-production
# MySQL root密码
MYSQL_ROOT_PASSWORD=root_password_change_me
```
## MinIO对象存储配置
```bash
# MinIO管理员用户名
MINIO_ROOT_USER=minioadmin
# MinIO管理员密码 (至少8个字符)
MINIO_ROOT_PASSWORD=minioadmin123
```
## 应用配置
```bash
# 应用标题
VITE_APP_TITLE=宏量点云处理系统
```
## 可选配置
```bash
# 启用Redis缓存 (需要在docker-compose中启用with-cache profile)
# REDIS_URL=redis://redis:6379
# 日志级别: debug, info, warn, error
# LOG_LEVEL=info
```

166
docker/mysql/init.sql Executable file
View File

@@ -0,0 +1,166 @@
-- 宏量点云处理软件 - 数据库初始化脚本
-- 该脚本在MySQL容器首次启动时自动执行
-- 后续增量迁移由 server/_core/index.ts runMigrations() 从 drizzle/*.sql 自动应用
-- 设置字符集
SET NAMES utf8mb4;
SET CHARACTER SET utf8mb4;
-- 确保数据库存在
CREATE DATABASE IF NOT EXISTS pointcloud_db
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE pointcloud_db;
-- 用户表
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
openId VARCHAR(64) NOT NULL UNIQUE,
name TEXT,
email VARCHAR(320),
loginMethod VARCHAR(64),
role ENUM('user', 'admin') NOT NULL DEFAULT 'user',
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updatedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
lastSignedIn TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_openId (openId),
INDEX idx_email (email(255))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 点云项目表(旧版,兼容保留)
CREATE TABLE IF NOT EXISTS point_cloud_projects (
id INT AUTO_INCREMENT PRIMARY KEY,
userId INT NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
cadModelUrl TEXT,
cadModelName VARCHAR(255),
totalChunks INT NOT NULL DEFAULT 0,
processedChunks INT NOT NULL DEFAULT 0,
totalPoints BIGINT NOT NULL DEFAULT 0,
status ENUM('pending', 'uploading', 'processing', 'completed', 'error') NOT NULL DEFAULT 'pending',
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updatedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_userId (userId),
INDEX idx_status (status),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 点云数据块表(旧版,兼容保留)
CREATE TABLE IF NOT EXISTS point_cloud_chunks (
id INT AUTO_INCREMENT PRIMARY KEY,
projectId INT NOT NULL,
chunkIndex INT NOT NULL,
fileName VARCHAR(255) NOT NULL,
fileUrl TEXT NOT NULL,
fileSize BIGINT NOT NULL DEFAULT 0,
pointCount INT NOT NULL DEFAULT 0,
status ENUM('pending', 'uploading', 'processing', 'completed', 'error') NOT NULL DEFAULT 'pending',
lodLevel INT NOT NULL DEFAULT 0,
boundingBox JSON,
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updatedAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_projectId (projectId),
INDEX idx_status (status),
UNIQUE KEY uk_project_chunk (projectId, chunkIndex),
FOREIGN KEY (projectId) REFERENCES point_cloud_projects(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- CAD模型表(旧版,兼容保留)
CREATE TABLE IF NOT EXISTS cad_models (
id INT AUTO_INCREMENT PRIMARY KEY,
userId INT NOT NULL,
name VARCHAR(255) NOT NULL,
format VARCHAR(32) NOT NULL,
fileUrl TEXT NOT NULL,
fileSize BIGINT NOT NULL DEFAULT 0,
vertices INT NOT NULL DEFAULT 0,
faces INT NOT NULL DEFAULT 0,
boundingBox JSON,
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_userId (userId),
FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 分析结果表(旧版,兼容保留)
CREATE TABLE IF NOT EXISTS analysis_results (
id INT AUTO_INCREMENT PRIMARY KEY,
projectId INT NOT NULL,
analysisType ENUM('error', 'measurement', 'compression') NOT NULL,
resultData JSON NOT NULL,
createdAt TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_projectId (projectId),
INDEX idx_type (analysisType),
FOREIGN KEY (projectId) REFERENCES point_cloud_projects(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ─── 车系/车型/检测任务/分析结果 层级结构 ──────────────────────────────────────
CREATE TABLE IF NOT EXISTS `vehicle_series` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`name` VARCHAR(100) NOT NULL,
`description` TEXT,
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updatedAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `vehicle_models` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`seriesId` INT NOT NULL,
`name` VARCHAR(100) NOT NULL,
`description` TEXT,
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updatedAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `model_cad_files` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`modelId` INT NOT NULL,
`name` VARCHAR(255) NOT NULL,
`format` VARCHAR(32) NOT NULL,
`fileSize` BIGINT NOT NULL,
`filePath` VARCHAR(512) NOT NULL,
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `inspection_tasks` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`modelId` INT NOT NULL,
`name` VARCHAR(255) NOT NULL,
`inspector` VARCHAR(100),
`notes` TEXT,
`status` ENUM('pending','processing','completed','error') NOT NULL DEFAULT 'pending',
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updatedAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`completedAt` TIMESTAMP NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `task_pointcloud_files` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`taskId` INT NOT NULL,
`name` VARCHAR(255) NOT NULL,
`fileSize` BIGINT NOT NULL,
`filePath` VARCHAR(512) NOT NULL,
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `task_analysis_results` (
`id` INT AUTO_INCREMENT PRIMARY KEY,
`taskId` INT NOT NULL,
`type` ENUM('error_analysis','measurement','compression','smooth_heatmap','assembly_inspection','obj_deviation') NOT NULL,
`status` ENUM('pending','processing','completed','error') NOT NULL DEFAULT 'pending',
`errorStats` JSON,
`measurements` JSON,
`resultData` JSON,
`createdAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updatedAt` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 创建默认管理员用户 (内网部署使用)
INSERT INTO users (openId, name, email, role) VALUES
('admin-local', '系统管理员', 'admin@localhost', 'admin')
ON DUPLICATE KEY UPDATE name = VALUES(name);
-- 输出初始化完成信息
SELECT 'Database initialization completed successfully!' AS message;

152
docker/nginx/nginx.conf Executable file
View File

@@ -0,0 +1,152 @@
# 宏量点云处理软件 - Nginx反向代理配置
# 适用于内网生产环境部署
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# 日志格式
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" '
'rt=$request_time uct="$upstream_connect_time" '
'uht="$upstream_header_time" urt="$upstream_response_time"';
access_log /var/log/nginx/access.log main;
# 性能优化
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Gzip压缩
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript
application/rss+xml application/atom+xml image/svg+xml;
# 文件上传大小限制 (支持大型点云文件)
client_max_body_size 2G;
client_body_buffer_size 128M;
client_body_timeout 600s;
# 代理缓冲设置
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
proxy_temp_file_write_size 256k;
# 上游服务器
upstream app_server {
server app:3000;
keepalive 32;
}
upstream minio_server {
server minio:9000;
keepalive 32;
}
# HTTP服务器 (重定向到HTTPS,生产环境启用)
server {
listen 80;
server_name _;
# 如果启用HTTPS,取消下面的注释
# return 301 https://$host$request_uri;
# 如果不使用HTTPS,使用下面的配置
location / {
proxy_pass http://app_server;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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_cache_bypass $http_upgrade;
proxy_read_timeout 600s;
proxy_connect_timeout 600s;
proxy_send_timeout 600s;
}
# API路由
location /api/ {
proxy_pass http://app_server;
proxy_http_version 1.1;
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_read_timeout 600s;
proxy_connect_timeout 600s;
proxy_send_timeout 600s;
}
# MinIO存储代理 (可选)
location /storage/ {
rewrite ^/storage/(.*) /$1 break;
proxy_pass http://minio_server;
proxy_http_version 1.1;
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;
}
# 健康检查端点
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}
# HTTPS服务器 (生产环境启用,需要配置SSL证书)
# server {
# listen 443 ssl http2;
# server_name your-domain.com;
#
# ssl_certificate /etc/nginx/ssl/cert.pem;
# ssl_certificate_key /etc/nginx/ssl/key.pem;
# ssl_session_timeout 1d;
# ssl_session_cache shared:SSL:50m;
# ssl_session_tickets off;
#
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
# ssl_prefer_server_ciphers off;
#
# # HSTS
# add_header Strict-Transport-Security "max-age=63072000" always;
#
# location / {
# proxy_pass http://app_server;
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection 'upgrade';
# 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_cache_bypass $http_upgrade;
# proxy_read_timeout 600s;
# }
# }
}

47
load-images.sh Executable file
View File

@@ -0,0 +1,47 @@
#!/bin/bash
# 宏量点云处理系统 - 镜像载入脚本
# 用法: bash load-images.sh
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
IMAGES_DIR="$SCRIPT_DIR/images"
echo "================================================"
echo " 宏量点云处理系统 - 离线镜像载入"
echo "================================================"
echo ""
load_image() {
local name="$1"
local file="$IMAGES_DIR/$2"
if [ ! -f "$file" ]; then
echo "[跳过] $name → 文件不存在: $file"
return
fi
local size=$(du -BM "$file" | cut -f1)
echo "[载入] $name (${size}) ..."
docker load -i "$file"
echo "[完成] $name"
echo ""
}
load_image "App (massive-point-cloud-app-dev:frozen-20260409)" "app-dev.tar"
load_image "MySQL 8.0" "mysql.tar"
load_image "MinIO" "minio.tar"
load_image "MinIO MC" "minio-mc.tar"
echo "================================================"
echo " 所有镜像载入完毕,确认:"
echo "================================================"
docker images | grep -E 'massive-point-cloud-app-dev|mysql|minio' || true
echo ""
echo "下一步: cp .env.example .env 并修改密钥,然后运行:"
echo ""
echo " # 无 GPU:"
echo " docker-compose up -d"
echo ""
echo " # 有 NVIDIA GPU (旧版 docker-compose):"
echo " docker-compose -f docker-compose.yml -f docker-compose.gpu.yml up -d"
echo ""
echo " # 有 NVIDIA GPU (Compose V2):"
echo " docker compose -f docker-compose.yml -f docker-compose.gpu.compose-v2.yml up -d"

56
src/.dockerignore Normal file
View File

@@ -0,0 +1,56 @@
# 宏量点云处理软件 - Docker构建忽略文件
# 依赖目录
node_modules
.pnpm-store
# 构建输出
dist
build
.next
.nuxt
# 开发文件
.git
.gitignore
.github
.vscode
.idea
# 环境文件
.env
.env.*
!.env.example
# 日志
logs
*.log
npm-debug.log*
pnpm-debug.log*
# 测试
coverage
.nyc_output
*.test.ts
*.spec.ts
__tests__
# 文档
*.md
!README.md
docs
# Docker相关
Dockerfile*
docker-compose*
.docker
# 本地大数据目录(开发 compose 挂载到 /data,勿打入构建上下文)
data
# 其他
.DS_Store
Thumbs.db
*.swp
*.swo
.manus-logs

107
src/.gitignore vendored Normal file
View File

@@ -0,0 +1,107 @@
# Dependencies
**/node_modules
.pnpm-store/
# Build outputs
dist/
build/
*.dist
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE and editor files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
*.bak
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# nyc test coverage
.nyc_output
# Dependency directories
jspm_packages/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
# Gatsby files
.cache/
# Storybook build outputs
.out
.storybook-out
# Temporary folders
tmp/
temp/
# Database
*.db
*.sqlite
*.sqlite3

0
src/.gitkeep Normal file
View File

35
src/.prettierignore Normal file
View File

@@ -0,0 +1,35 @@
# Dependencies
node_modules/
.pnpm-store/
# Build outputs
dist/
build/
*.dist
# Generated files
*.tsbuildinfo
coverage/
# Package files
package-lock.json
pnpm-lock.yaml
# Database
*.db
*.sqlite
*.sqlite3
# Logs
*.log
# Environment files
.env*
# IDE files
.vscode/
.idea/
# OS files
.DS_Store
Thumbs.db

15
src/.prettierrc Normal file
View File

@@ -0,0 +1,15 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": false,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "avoid",
"endOfLine": "lf",
"quoteProps": "as-needed",
"jsxSingleQuote": false,
"proseWrap": "preserve"
}

264
src/DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,264 @@
# 宏量点云处理软件 - 内网Docker部署指南
本文档详细说明如何在内网服务器上使用Docker部署宏量点云处理软件。
## 系统要求
### 硬件要求
| 配置项 | 最低要求 | 推荐配置 |
|--------|----------|----------|
| CPU | 4核 | 8核+ |
| 内存 | 8GB | 16GB+ |
| 存储 | 100GB SSD | 500GB+ SSD |
| 网络 | 100Mbps | 1Gbps |
### 软件要求
- 操作系统: Linux (Ubuntu 20.04+, CentOS 7+, Debian 10+)
- Docker: 20.10+
- Docker Compose: 2.0+
## 快速开始
### 1. 安装Docker和Docker Compose
```bash
# Ubuntu/Debian
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
# 安装Docker Compose
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
# 重新登录以应用用户组更改
```
### 2. 获取项目代码
```bash
# 从GitHub克隆 (如果已导出到GitHub)
git clone https://github.com/your-org/massive-point-cloud.git
cd massive-point-cloud
# 或者直接上传项目文件到服务器
```
### 3. 配置环境变量
```bash
# 创建环境变量文件
cat > .env << 'EOF'
# 安全配置 - 请修改为随机值
JWT_SECRET=$(openssl rand -base64 32)
MYSQL_ROOT_PASSWORD=$(openssl rand -base64 16)
# MinIO配置
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=$(openssl rand -base64 16)
# 应用配置
VITE_APP_TITLE=宏量点云处理系统
EOF
# 或者手动编辑
nano .env
```
### 4. 启动服务
```bash
# 基础部署 (应用 + MySQL + MinIO)
docker-compose up -d
# 查看服务状态
docker-compose ps
# 查看日志
docker-compose logs -f
```
### 5. 访问应用
- **Web应用**: http://your-server-ip:3000
- **MinIO控制台**: http://your-server-ip:9001
## 部署架构
```
┌─────────────────────────────────────────────────────────────┐
│ 内网服务器 │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Docker Network │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ │
│ │ │ App │ │ MySQL │ │ MinIO │ │ │
│ │ │ :3000 │ │ :3306 │ │ :9000 / :9001 │ │ │
│ │ └────┬─────┘ └────┬─────┘ └────────┬─────────┘ │ │
│ │ │ │ │ │ │
│ │ └─────────────┴──────────────────┘ │ │
│ │ │ │ │
│ └──────────────────────┼──────────────────────────────┘ │
│ │ │
│ ┌──────────────────────┴──────────────────────────────┐ │
│ │ Nginx (可选, 生产环境) │ │
│ │ :80 / :443 │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
## 服务说明
| 服务 | 端口 | 说明 |
|------|------|------|
| app | 3000 | 主应用服务 |
| mysql | 3306 | MySQL数据库 |
| minio | 9000 | MinIO API |
| minio | 9001 | MinIO控制台 |
| nginx | 80/443 | 反向代理 (可选) |
| redis | 6379 | 缓存服务 (可选) |
## 高级配置
### 启用Nginx反向代理
```bash
# 使用production profile启动
docker-compose --profile production up -d
```
### 启用Redis缓存
```bash
# 使用with-cache profile启动
docker-compose --profile with-cache up -d
```
### 配置HTTPS
1. 将SSL证书放置到 `docker/nginx/ssl/` 目录:
- `cert.pem` - 证书文件
- `key.pem` - 私钥文件
2. 编辑 `docker/nginx/nginx.conf`,取消HTTPS服务器配置的注释
3. 重启Nginx:
```bash
docker-compose restart nginx
```
### 数据持久化
所有数据存储在Docker卷中:
| 卷名 | 用途 |
|------|------|
| pointcloud-mysql-data | MySQL数据 |
| pointcloud-minio-data | 点云文件存储 |
| pointcloud-redis-data | Redis缓存 |
| pointcloud-app-logs | 应用日志 |
备份数据:
```bash
# 备份MySQL
docker exec massive-point-cloud-mysql mysqldump -u root -p pointcloud_db > backup.sql
# 备份MinIO数据
docker run --rm -v pointcloud-minio-data:/data -v $(pwd):/backup alpine tar cvf /backup/minio-backup.tar /data
```
## 运维命令
```bash
# 查看所有服务状态
docker-compose ps
# 查看实时日志
docker-compose logs -f app
# 重启单个服务
docker-compose restart app
# 停止所有服务
docker-compose down
# 停止并删除数据卷 (危险!)
docker-compose down -v
# 更新应用
git pull
docker-compose build app
docker-compose up -d app
# 进入容器调试
docker exec -it massive-point-cloud-app sh
docker exec -it massive-point-cloud-mysql mysql -u root -p
```
## 故障排查
### 应用无法启动
```bash
# 检查日志
docker-compose logs app
# 检查数据库连接
docker exec massive-point-cloud-app curl -f http://mysql:3306 || echo "MySQL not ready"
```
### 数据库连接失败
```bash
# 检查MySQL状态
docker-compose logs mysql
# 手动测试连接
docker exec -it massive-point-cloud-mysql mysql -u pointcloud -p -e "SELECT 1"
```
### MinIO无法访问
```bash
# 检查MinIO状态
docker-compose logs minio
# 检查存储桶
docker exec massive-point-cloud-minio-init mc ls myminio/
```
## 性能优化
### MySQL优化
编辑 `docker/mysql/my.cnf`:
```ini
[mysqld]
innodb_buffer_pool_size = 2G
innodb_log_file_size = 512M
max_connections = 200
```
### 增加文件上传限制
修改 `docker/nginx/nginx.conf`:
```nginx
client_max_body_size 5G;
```
## 安全建议
1. **修改默认密码**: 务必修改 `.env` 中的所有默认密码
2. **限制端口暴露**: 生产环境只暴露80/443端口
3. **启用防火墙**: 配置iptables或firewalld
4. **定期备份**: 设置自动备份任务
5. **更新镜像**: 定期更新Docker镜像修复安全漏洞
## 联系支持
如遇到部署问题,请检查:
1. Docker和Docker Compose版本是否满足要求
2. 服务器资源是否充足
3. 端口是否被占用
4. 防火墙规则是否正确

114
src/Dockerfile Normal file
View File

@@ -0,0 +1,114 @@
# 宏量点云处理软件 - 生产环境Dockerfile
# 多阶段构建,优化镜像大小
# ============================================
# 阶段1: 依赖安装
# ============================================
FROM node:22-alpine AS deps
WORKDIR /app
# 安装pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# 复制依赖文件
COPY package.json pnpm-lock.yaml* ./
COPY patches ./patches
# 安装依赖
RUN pnpm install --frozen-lockfile --prod=false
# ============================================
# 阶段2: 构建应用
# ============================================
FROM node:22-alpine AS builder
WORKDIR /app
# 安装pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# 复制依赖
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# 设置构建时环境变量
ARG VITE_APP_TITLE="宏量点云处理系统"
ARG VITE_OAUTH_PORTAL_URL="disabled"
ARG VITE_APP_ID=""
ENV VITE_APP_TITLE=$VITE_APP_TITLE
ENV VITE_OAUTH_PORTAL_URL=$VITE_OAUTH_PORTAL_URL
ENV VITE_APP_ID=$VITE_APP_ID
# 构建应用
RUN pnpm build
# ============================================
# 阶段3: 生产运行环境(bookworm:与 dev 一致,便于 cadquery manylinux 轮子)
# ============================================
FROM node:22-bookworm-slim AS runner
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
dumb-init \
python3 \
python3-pip \
ca-certificates \
libgl1 \
libglib2.0-0 \
libxrender1 \
libxext6 \
libsm6 \
libice6 \
libx11-6 \
&& rm -rf /var/lib/apt/lists/*
# cadquery-ocp 轮子较大,须加大 pip 超时;覆盖:--build-arg PIP_DEFAULT_TIMEOUT=600
ARG PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
ARG PIP_DEFAULT_TIMEOUT=300
COPY requirements-geometry.txt /tmp/requirements-geometry.txt
RUN python3 -m pip install --no-cache-dir --break-system-packages \
--default-timeout="${PIP_DEFAULT_TIMEOUT}" \
--retries 10 \
-i "${PIP_INDEX_URL}" \
-r /tmp/requirements-geometry.txt \
&& rm /tmp/requirements-geometry.txt \
&& python3 -c "import cadquery; print('cadquery OK')"
ENV PYTHON3_PATH=/usr/bin/python3
# 创建非 root 用户(bookworm 用 groupadd/useradd)
RUN groupadd --gid 1001 nodejs && \
useradd --uid 1001 --gid nodejs --system --shell /usr/sbin/nologin appuser
# 安装pnpm (用于运行时)
RUN corepack enable && corepack prepare pnpm@latest --activate
# 复制构建产物
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/drizzle ./drizzle
# STEP 装配检测:子进程脚本(runStepParseMvp 从 dist 相对定位 ../scripts)
COPY --from=builder /app/scripts ./scripts
# 创建日志目录
RUN mkdir -p /app/logs && chown -R appuser:nodejs /app
# 切换到非root用户
USER appuser
# 暴露端口
EXPOSE 3000
# 健康检查 (使用环境变量中的端口,默认3000)
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:${PORT:-3000}/api/health || exit 1
# 使用dumb-init作为PID 1进程
ENTRYPOINT ["dumb-init", "--"]
# 启动应用
CMD ["node", "dist/index.js"]

22
src/client/index.html Normal file
View File

@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1" />
<title>宏量点云处理软件</title>
<!-- THIS IS THE START OF A COMMENT BLOCK, BLOCK TO BE DELETED: Google Fonts here, example:
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
THIS IS THE END OF A COMMENT BLOCK, BLOCK TO BE DELETED -->
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

View File

@@ -0,0 +1,821 @@
/**
* Manus Debug Collector (agent-friendly)
*
* Captures:
* 1) Console logs
* 2) Network requests (fetch + XHR)
* 3) User interactions (semantic uiEvents: click/type/submit/nav/scroll/etc.)
*
* Data is periodically sent to /__manus__/logs
* Note: uiEvents are mirrored to sessionEvents for sessionReplay.log
*/
(function () {
"use strict";
// Prevent double initialization
if (window.__MANUS_DEBUG_COLLECTOR__) return;
// ==========================================================================
// Configuration
// ==========================================================================
const CONFIG = {
reportEndpoint: "/__manus__/logs",
bufferSize: {
console: 500,
network: 200,
// semantic, agent-friendly UI events
ui: 500,
},
reportInterval: 2000,
sensitiveFields: [
"password",
"token",
"secret",
"key",
"authorization",
"cookie",
"session",
],
maxBodyLength: 10240,
// UI event logging privacy policy:
// - inputs matching sensitiveFields or type=password are masked by default
// - non-sensitive inputs log up to 200 chars
uiInputMaxLen: 200,
uiTextMaxLen: 80,
// Scroll throttling: minimum ms between scroll events
scrollThrottleMs: 500,
};
// ==========================================================================
// Storage
// ==========================================================================
const store = {
consoleLogs: [],
networkRequests: [],
uiEvents: [],
lastReportTime: Date.now(),
lastScrollTime: 0,
};
// ==========================================================================
// Utility Functions
// ==========================================================================
function sanitizeValue(value, depth) {
if (depth === void 0) depth = 0;
if (depth > 5) return "[Max Depth]";
if (value === null) return null;
if (value === undefined) return undefined;
if (typeof value === "string") {
return value.length > 1000 ? value.slice(0, 1000) + "...[truncated]" : value;
}
if (typeof value !== "object") return value;
if (Array.isArray(value)) {
return value.slice(0, 100).map(function (v) {
return sanitizeValue(v, depth + 1);
});
}
var sanitized = {};
for (var k in value) {
if (Object.prototype.hasOwnProperty.call(value, k)) {
var isSensitive = CONFIG.sensitiveFields.some(function (f) {
return k.toLowerCase().indexOf(f) !== -1;
});
if (isSensitive) {
sanitized[k] = "[REDACTED]";
} else {
sanitized[k] = sanitizeValue(value[k], depth + 1);
}
}
}
return sanitized;
}
function formatArg(arg) {
try {
if (arg instanceof Error) {
return { type: "Error", message: arg.message, stack: arg.stack };
}
if (typeof arg === "object") return sanitizeValue(arg);
return String(arg);
} catch (e) {
return "[Unserializable]";
}
}
function formatArgs(args) {
var result = [];
for (var i = 0; i < args.length; i++) result.push(formatArg(args[i]));
return result;
}
function pruneBuffer(buffer, maxSize) {
if (buffer.length > maxSize) buffer.splice(0, buffer.length - maxSize);
}
function tryParseJson(str) {
if (typeof str !== "string") return str;
try {
return JSON.parse(str);
} catch (e) {
return str;
}
}
// ==========================================================================
// Semantic UI Event Logging (agent-friendly)
// ==========================================================================
function shouldIgnoreTarget(target) {
try {
if (!target || !(target instanceof Element)) return false;
return !!target.closest(".manus-no-record");
} catch (e) {
return false;
}
}
function compactText(s, maxLen) {
try {
var t = (s || "").trim().replace(/\s+/g, " ");
if (!t) return "";
return t.length > maxLen ? t.slice(0, maxLen) + "…" : t;
} catch (e) {
return "";
}
}
function elText(el) {
try {
var t = el.innerText || el.textContent || "";
return compactText(t, CONFIG.uiTextMaxLen);
} catch (e) {
return "";
}
}
function describeElement(el) {
if (!el || !(el instanceof Element)) return null;
var getAttr = function (name) {
return el.getAttribute(name);
};
var tag = el.tagName ? el.tagName.toLowerCase() : null;
var id = el.id || null;
var name = getAttr("name") || null;
var role = getAttr("role") || null;
var ariaLabel = getAttr("aria-label") || null;
var dataLoc = getAttr("data-loc") || null;
var testId =
getAttr("data-testid") ||
getAttr("data-test-id") ||
getAttr("data-test") ||
null;
var type = tag === "input" ? (getAttr("type") || "text") : null;
var href = tag === "a" ? getAttr("href") || null : null;
// a small, stable hint for agents (avoid building full CSS paths)
var selectorHint = null;
if (testId) selectorHint = '[data-testid="' + testId + '"]';
else if (dataLoc) selectorHint = '[data-loc="' + dataLoc + '"]';
else if (id) selectorHint = "#" + id;
else selectorHint = tag || "unknown";
return {
tag: tag,
id: id,
name: name,
type: type,
role: role,
ariaLabel: ariaLabel,
testId: testId,
dataLoc: dataLoc,
href: href,
text: elText(el),
selectorHint: selectorHint,
};
}
function isSensitiveField(el) {
if (!el || !(el instanceof Element)) return false;
var tag = el.tagName ? el.tagName.toLowerCase() : "";
if (tag !== "input" && tag !== "textarea") return false;
var type = (el.getAttribute("type") || "").toLowerCase();
if (type === "password") return true;
var name = (el.getAttribute("name") || "").toLowerCase();
var id = (el.id || "").toLowerCase();
return CONFIG.sensitiveFields.some(function (f) {
return name.indexOf(f) !== -1 || id.indexOf(f) !== -1;
});
}
function getInputValueSafe(el) {
if (!el || !(el instanceof Element)) return null;
var tag = el.tagName ? el.tagName.toLowerCase() : "";
if (tag !== "input" && tag !== "textarea" && tag !== "select") return null;
var v = "";
try {
v = el.value != null ? String(el.value) : "";
} catch (e) {
v = "";
}
if (isSensitiveField(el)) return { masked: true, length: v.length };
if (v.length > CONFIG.uiInputMaxLen) v = v.slice(0, CONFIG.uiInputMaxLen) + "…";
return v;
}
function logUiEvent(kind, payload) {
var entry = {
timestamp: Date.now(),
kind: kind,
url: location.href,
viewport: { width: window.innerWidth, height: window.innerHeight },
payload: sanitizeValue(payload),
};
store.uiEvents.push(entry);
pruneBuffer(store.uiEvents, CONFIG.bufferSize.ui);
}
function installUiEventListeners() {
// Clicks
document.addEventListener(
"click",
function (e) {
var t = e.target;
if (shouldIgnoreTarget(t)) return;
logUiEvent("click", {
target: describeElement(t),
x: e.clientX,
y: e.clientY,
});
},
true
);
// Typing "commit" events
document.addEventListener(
"change",
function (e) {
var t = e.target;
if (shouldIgnoreTarget(t)) return;
logUiEvent("change", {
target: describeElement(t),
value: getInputValueSafe(t),
});
},
true
);
document.addEventListener(
"focusin",
function (e) {
var t = e.target;
if (shouldIgnoreTarget(t)) return;
logUiEvent("focusin", { target: describeElement(t) });
},
true
);
document.addEventListener(
"focusout",
function (e) {
var t = e.target;
if (shouldIgnoreTarget(t)) return;
logUiEvent("focusout", {
target: describeElement(t),
value: getInputValueSafe(t),
});
},
true
);
// Enter/Escape are useful for form flows & modals
document.addEventListener(
"keydown",
function (e) {
if (e.key !== "Enter" && e.key !== "Escape") return;
var t = e.target;
if (shouldIgnoreTarget(t)) return;
logUiEvent("keydown", { key: e.key, target: describeElement(t) });
},
true
);
// Form submissions
document.addEventListener(
"submit",
function (e) {
var t = e.target;
if (shouldIgnoreTarget(t)) return;
logUiEvent("submit", { target: describeElement(t) });
},
true
);
// Throttled scroll events
window.addEventListener(
"scroll",
function () {
var now = Date.now();
if (now - store.lastScrollTime < CONFIG.scrollThrottleMs) return;
store.lastScrollTime = now;
logUiEvent("scroll", {
scrollX: window.scrollX,
scrollY: window.scrollY,
documentHeight: document.documentElement.scrollHeight,
viewportHeight: window.innerHeight,
});
},
{ passive: true }
);
// Navigation tracking for SPAs
function nav(reason) {
logUiEvent("navigate", { reason: reason });
}
var origPush = history.pushState;
history.pushState = function () {
origPush.apply(this, arguments);
nav("pushState");
};
var origReplace = history.replaceState;
history.replaceState = function () {
origReplace.apply(this, arguments);
nav("replaceState");
};
window.addEventListener("popstate", function () {
nav("popstate");
});
window.addEventListener("hashchange", function () {
nav("hashchange");
});
}
// ==========================================================================
// Console Interception
// ==========================================================================
var originalConsole = {
log: console.log.bind(console),
debug: console.debug.bind(console),
info: console.info.bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console),
};
["log", "debug", "info", "warn", "error"].forEach(function (method) {
console[method] = function () {
var args = Array.prototype.slice.call(arguments);
var entry = {
timestamp: Date.now(),
level: method.toUpperCase(),
args: formatArgs(args),
stack: method === "error" ? new Error().stack : null,
};
store.consoleLogs.push(entry);
pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console);
originalConsole[method].apply(console, args);
};
});
window.addEventListener("error", function (event) {
store.consoleLogs.push({
timestamp: Date.now(),
level: "ERROR",
args: [
{
type: "UncaughtError",
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
stack: event.error ? event.error.stack : null,
},
],
stack: event.error ? event.error.stack : null,
});
pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console);
// Mark an error moment in UI event stream for agents
logUiEvent("error", {
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
});
});
window.addEventListener("unhandledrejection", function (event) {
var reason = event.reason;
store.consoleLogs.push({
timestamp: Date.now(),
level: "ERROR",
args: [
{
type: "UnhandledRejection",
reason: reason && reason.message ? reason.message : String(reason),
stack: reason && reason.stack ? reason.stack : null,
},
],
stack: reason && reason.stack ? reason.stack : null,
});
pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console);
logUiEvent("unhandledrejection", {
reason: reason && reason.message ? reason.message : String(reason),
});
});
// ==========================================================================
// Fetch Interception
// ==========================================================================
var originalFetch = window.fetch.bind(window);
window.fetch = function (input, init) {
init = init || {};
var startTime = Date.now();
// Handle string, Request object, or URL object
var url = typeof input === "string"
? input
: (input && (input.url || input.href || String(input))) || "";
var method = init.method || (input && input.method) || "GET";
// Don't intercept internal requests
if (url.indexOf("/__manus__/") === 0) {
return originalFetch(input, init);
}
// Safely parse headers (avoid breaking if headers format is invalid)
var requestHeaders = {};
try {
if (init.headers) {
requestHeaders = Object.fromEntries(new Headers(init.headers).entries());
}
} catch (e) {
requestHeaders = { _parseError: true };
}
var entry = {
timestamp: startTime,
type: "fetch",
method: method.toUpperCase(),
url: url,
request: {
headers: requestHeaders,
body: init.body ? sanitizeValue(tryParseJson(init.body)) : null,
},
response: null,
duration: null,
error: null,
};
return originalFetch(input, init)
.then(function (response) {
entry.duration = Date.now() - startTime;
var contentType = (response.headers.get("content-type") || "").toLowerCase();
var contentLength = response.headers.get("content-length");
entry.response = {
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
body: null,
};
// Semantic network hint for agents on failures (sync, no need to wait for body)
if (response.status >= 400) {
logUiEvent("network_error", {
kind: "fetch",
method: entry.method,
url: entry.url,
status: response.status,
statusText: response.statusText,
});
}
// Skip body capture for streaming responses (SSE, etc.) to avoid memory leaks
var isStreaming = contentType.indexOf("text/event-stream") !== -1 ||
contentType.indexOf("application/stream") !== -1 ||
contentType.indexOf("application/x-ndjson") !== -1;
if (isStreaming) {
entry.response.body = "[Streaming response - not captured]";
store.networkRequests.push(entry);
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
return response;
}
// Skip body capture for large responses to avoid memory issues
if (contentLength && parseInt(contentLength, 10) > CONFIG.maxBodyLength) {
entry.response.body = "[Response too large: " + contentLength + " bytes]";
store.networkRequests.push(entry);
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
return response;
}
// Skip body capture for binary content types
var isBinary = contentType.indexOf("image/") !== -1 ||
contentType.indexOf("video/") !== -1 ||
contentType.indexOf("audio/") !== -1 ||
contentType.indexOf("application/octet-stream") !== -1 ||
contentType.indexOf("application/pdf") !== -1 ||
contentType.indexOf("application/zip") !== -1;
if (isBinary) {
entry.response.body = "[Binary content: " + contentType + "]";
store.networkRequests.push(entry);
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
return response;
}
// For text responses, clone and read body in background
var clonedResponse = response.clone();
// Async: read body in background, don't block the response
clonedResponse
.text()
.then(function (text) {
if (text.length <= CONFIG.maxBodyLength) {
entry.response.body = sanitizeValue(tryParseJson(text));
} else {
entry.response.body = text.slice(0, CONFIG.maxBodyLength) + "...[truncated]";
}
})
.catch(function () {
entry.response.body = "[Unable to read body]";
})
.finally(function () {
store.networkRequests.push(entry);
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
});
// Return response immediately, don't wait for body reading
return response;
})
.catch(function (error) {
entry.duration = Date.now() - startTime;
entry.error = { message: error.message, stack: error.stack };
store.networkRequests.push(entry);
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
logUiEvent("network_error", {
kind: "fetch",
method: entry.method,
url: entry.url,
message: error.message,
});
throw error;
});
};
// ==========================================================================
// XHR Interception
// ==========================================================================
var originalXHROpen = XMLHttpRequest.prototype.open;
var originalXHRSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url) {
this._manusData = {
method: (method || "GET").toUpperCase(),
url: url,
startTime: null,
};
return originalXHROpen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function (body) {
var xhr = this;
if (
xhr._manusData &&
xhr._manusData.url &&
xhr._manusData.url.indexOf("/__manus__/") !== 0
) {
xhr._manusData.startTime = Date.now();
xhr._manusData.requestBody = body ? sanitizeValue(tryParseJson(body)) : null;
xhr.addEventListener("load", function () {
var contentType = (xhr.getResponseHeader("content-type") || "").toLowerCase();
var responseBody = null;
// Skip body capture for streaming responses
var isStreaming = contentType.indexOf("text/event-stream") !== -1 ||
contentType.indexOf("application/stream") !== -1 ||
contentType.indexOf("application/x-ndjson") !== -1;
// Skip body capture for binary content types
var isBinary = contentType.indexOf("image/") !== -1 ||
contentType.indexOf("video/") !== -1 ||
contentType.indexOf("audio/") !== -1 ||
contentType.indexOf("application/octet-stream") !== -1 ||
contentType.indexOf("application/pdf") !== -1 ||
contentType.indexOf("application/zip") !== -1;
if (isStreaming) {
responseBody = "[Streaming response - not captured]";
} else if (isBinary) {
responseBody = "[Binary content: " + contentType + "]";
} else {
// Safe to read responseText for text responses
try {
var text = xhr.responseText || "";
if (text.length > CONFIG.maxBodyLength) {
responseBody = text.slice(0, CONFIG.maxBodyLength) + "...[truncated]";
} else {
responseBody = sanitizeValue(tryParseJson(text));
}
} catch (e) {
// responseText may throw for non-text responses
responseBody = "[Unable to read response: " + e.message + "]";
}
}
var entry = {
timestamp: xhr._manusData.startTime,
type: "xhr",
method: xhr._manusData.method,
url: xhr._manusData.url,
request: { body: xhr._manusData.requestBody },
response: {
status: xhr.status,
statusText: xhr.statusText,
body: responseBody,
},
duration: Date.now() - xhr._manusData.startTime,
error: null,
};
store.networkRequests.push(entry);
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
if (entry.response && entry.response.status >= 400) {
logUiEvent("network_error", {
kind: "xhr",
method: entry.method,
url: entry.url,
status: entry.response.status,
statusText: entry.response.statusText,
});
}
});
xhr.addEventListener("error", function () {
var entry = {
timestamp: xhr._manusData.startTime,
type: "xhr",
method: xhr._manusData.method,
url: xhr._manusData.url,
request: { body: xhr._manusData.requestBody },
response: null,
duration: Date.now() - xhr._manusData.startTime,
error: { message: "Network error" },
};
store.networkRequests.push(entry);
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
logUiEvent("network_error", {
kind: "xhr",
method: entry.method,
url: entry.url,
message: "Network error",
});
});
}
return originalXHRSend.apply(this, arguments);
};
// ==========================================================================
// Data Reporting
// ==========================================================================
function reportLogs() {
var consoleLogs = store.consoleLogs.splice(0);
var networkRequests = store.networkRequests.splice(0);
var uiEvents = store.uiEvents.splice(0);
// Skip if no new data
if (
consoleLogs.length === 0 &&
networkRequests.length === 0 &&
uiEvents.length === 0
) {
return Promise.resolve();
}
var payload = {
timestamp: Date.now(),
consoleLogs: consoleLogs,
networkRequests: networkRequests,
// Mirror uiEvents to sessionEvents for sessionReplay.log
sessionEvents: uiEvents,
// agent-friendly semantic events
uiEvents: uiEvents,
};
return originalFetch(CONFIG.reportEndpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
}).catch(function () {
// Put data back on failure (but respect limits)
store.consoleLogs = consoleLogs.concat(store.consoleLogs);
store.networkRequests = networkRequests.concat(store.networkRequests);
store.uiEvents = uiEvents.concat(store.uiEvents);
pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console);
pruneBuffer(store.networkRequests, CONFIG.bufferSize.network);
pruneBuffer(store.uiEvents, CONFIG.bufferSize.ui);
});
}
// Periodic reporting
setInterval(reportLogs, CONFIG.reportInterval);
// Report on page unload
window.addEventListener("beforeunload", function () {
var consoleLogs = store.consoleLogs;
var networkRequests = store.networkRequests;
var uiEvents = store.uiEvents;
if (
consoleLogs.length === 0 &&
networkRequests.length === 0 &&
uiEvents.length === 0
) {
return;
}
var payload = {
timestamp: Date.now(),
consoleLogs: consoleLogs,
networkRequests: networkRequests,
// Mirror uiEvents to sessionEvents for sessionReplay.log
sessionEvents: uiEvents,
uiEvents: uiEvents,
};
if (navigator.sendBeacon) {
var payloadStr = JSON.stringify(payload);
// sendBeacon has ~64KB limit, truncate if too large
var MAX_BEACON_SIZE = 60000; // Leave some margin
if (payloadStr.length > MAX_BEACON_SIZE) {
// Prioritize: keep recent events, drop older logs
var truncatedPayload = {
timestamp: Date.now(),
consoleLogs: consoleLogs.slice(-50),
networkRequests: networkRequests.slice(-20),
sessionEvents: uiEvents.slice(-100),
uiEvents: uiEvents.slice(-100),
_truncated: true,
};
payloadStr = JSON.stringify(truncatedPayload);
}
navigator.sendBeacon(CONFIG.reportEndpoint, payloadStr);
}
});
// ==========================================================================
// Initialization
// ==========================================================================
// Install semantic UI listeners ASAP
try {
installUiEventListeners();
} catch (e) {
console.warn("[Manus] Failed to install UI listeners:", e);
}
// Mark as initialized
window.__MANUS_DEBUG_COLLECTOR__ = {
version: "2.0-no-rrweb",
store: store,
forceReport: reportLogs,
};
console.debug("[Manus] Debug collector initialized (no rrweb, UI events only)");
})();

42
src/client/src/App.tsx Normal file
View File

@@ -0,0 +1,42 @@
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import NotFound from "@/pages/NotFound";
import { Route, Switch } from "wouter";
import ErrorBoundary from "./components/ErrorBoundary";
import { ThemeProvider } from "./contexts/ThemeContext";
import Home from "./pages/Home";
function Router() {
// make sure to consider if you need authentication for certain routes
return (
<Switch>
<Route path={"/"} component={Home} />
<Route path={"/404"} component={NotFound} />
{/* Final fallback route */}
<Route component={NotFound} />
</Switch>
);
}
// NOTE: About Theme
// - First choose a default theme according to your design style (dark or light bg), than change color palette in index.css
// to keep consistent foreground/background color across components
// - If you want to make theme switchable, pass `switchable` ThemeProvider and use `useTheme` hook
function App() {
return (
<ErrorBoundary>
<ThemeProvider
defaultTheme="dark"
// switchable
>
<TooltipProvider>
<Toaster />
<Router />
</TooltipProvider>
</ThemeProvider>
</ErrorBoundary>
);
}
export default App;

View File

@@ -0,0 +1,84 @@
import { getLoginUrl } from "@/const";
import { trpc } from "@/lib/trpc";
import { TRPCClientError } from "@trpc/client";
import { useCallback, useEffect, useMemo } from "react";
type UseAuthOptions = {
redirectOnUnauthenticated?: boolean;
redirectPath?: string;
};
export function useAuth(options?: UseAuthOptions) {
const { redirectOnUnauthenticated = false, redirectPath = getLoginUrl() } =
options ?? {};
const utils = trpc.useUtils();
const meQuery = trpc.auth.me.useQuery(undefined, {
retry: false,
refetchOnWindowFocus: false,
});
const logoutMutation = trpc.auth.logout.useMutation({
onSuccess: () => {
utils.auth.me.setData(undefined, null);
},
});
const logout = useCallback(async () => {
try {
await logoutMutation.mutateAsync();
} catch (error: unknown) {
if (
error instanceof TRPCClientError &&
error.data?.code === "UNAUTHORIZED"
) {
return;
}
throw error;
} finally {
utils.auth.me.setData(undefined, null);
await utils.auth.me.invalidate();
}
}, [logoutMutation, utils]);
const state = useMemo(() => {
localStorage.setItem(
"manus-runtime-user-info",
JSON.stringify(meQuery.data)
);
return {
user: meQuery.data ?? null,
loading: meQuery.isLoading || logoutMutation.isPending,
error: meQuery.error ?? logoutMutation.error ?? null,
isAuthenticated: Boolean(meQuery.data),
};
}, [
meQuery.data,
meQuery.error,
meQuery.isLoading,
logoutMutation.error,
logoutMutation.isPending,
]);
useEffect(() => {
if (!redirectOnUnauthenticated) return;
if (meQuery.isLoading || logoutMutation.isPending) return;
if (state.user) return;
if (typeof window === "undefined") return;
if (window.location.pathname === redirectPath) return;
window.location.href = redirectPath
}, [
redirectOnUnauthenticated,
redirectPath,
logoutMutation.isPending,
meQuery.isLoading,
state.user,
]);
return {
...state,
refresh: () => meQuery.refetch(),
logout,
};
}

View File

@@ -0,0 +1,335 @@
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { Loader2, Send, User, Sparkles } from "lucide-react";
import { useState, useEffect, useRef } from "react";
import { Streamdown } from "streamdown";
/**
* Message type matching server-side LLM Message interface
*/
export type Message = {
role: "system" | "user" | "assistant";
content: string;
};
export type AIChatBoxProps = {
/**
* Messages array to display in the chat.
* Should match the format used by invokeLLM on the server.
*/
messages: Message[];
/**
* Callback when user sends a message.
* Typically you'll call a tRPC mutation here to invoke the LLM.
*/
onSendMessage: (content: string) => void;
/**
* Whether the AI is currently generating a response
*/
isLoading?: boolean;
/**
* Placeholder text for the input field
*/
placeholder?: string;
/**
* Custom className for the container
*/
className?: string;
/**
* Height of the chat box (default: 600px)
*/
height?: string | number;
/**
* Empty state message to display when no messages
*/
emptyStateMessage?: string;
/**
* Suggested prompts to display in empty state
* Click to send directly
*/
suggestedPrompts?: string[];
};
/**
* A ready-to-use AI chat box component that integrates with the LLM system.
*
* Features:
* - Matches server-side Message interface for seamless integration
* - Markdown rendering with Streamdown
* - Auto-scrolls to latest message
* - Loading states
* - Uses global theme colors from index.css
*
* @example
* ```tsx
* const ChatPage = () => {
* const [messages, setMessages] = useState<Message[]>([
* { role: "system", content: "You are a helpful assistant." }
* ]);
*
* const chatMutation = trpc.ai.chat.useMutation({
* onSuccess: (response) => {
* // Assuming your tRPC endpoint returns the AI response as a string
* setMessages(prev => [...prev, {
* role: "assistant",
* content: response
* }]);
* },
* onError: (error) => {
* console.error("Chat error:", error);
* // Optionally show error message to user
* }
* });
*
* const handleSend = (content: string) => {
* const newMessages = [...messages, { role: "user", content }];
* setMessages(newMessages);
* chatMutation.mutate({ messages: newMessages });
* };
*
* return (
* <AIChatBox
* messages={messages}
* onSendMessage={handleSend}
* isLoading={chatMutation.isPending}
* suggestedPrompts={[
* "Explain quantum computing",
* "Write a hello world in Python"
* ]}
* />
* );
* };
* ```
*/
export function AIChatBox({
messages,
onSendMessage,
isLoading = false,
placeholder = "Type your message...",
className,
height = "600px",
emptyStateMessage = "Start a conversation with AI",
suggestedPrompts,
}: AIChatBoxProps) {
const [input, setInput] = useState("");
const scrollAreaRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const inputAreaRef = useRef<HTMLFormElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Filter out system messages
const displayMessages = messages.filter((msg) => msg.role !== "system");
// Calculate min-height for last assistant message to push user message to top
const [minHeightForLastMessage, setMinHeightForLastMessage] = useState(0);
useEffect(() => {
if (containerRef.current && inputAreaRef.current) {
const containerHeight = containerRef.current.offsetHeight;
const inputHeight = inputAreaRef.current.offsetHeight;
const scrollAreaHeight = containerHeight - inputHeight;
// Reserve space for:
// - padding (p-4 = 32px top+bottom)
// - user message: 40px (item height) + 16px (margin-top from space-y-4) = 56px
// Note: margin-bottom is not counted because it naturally pushes the assistant message down
const userMessageReservedHeight = 56;
const calculatedHeight = scrollAreaHeight - 32 - userMessageReservedHeight;
setMinHeightForLastMessage(Math.max(0, calculatedHeight));
}
}, []);
// Scroll to bottom helper function with smooth animation
const scrollToBottom = () => {
const viewport = scrollAreaRef.current?.querySelector(
'[data-radix-scroll-area-viewport]'
) as HTMLDivElement;
if (viewport) {
requestAnimationFrame(() => {
viewport.scrollTo({
top: viewport.scrollHeight,
behavior: 'smooth'
});
});
}
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmedInput = input.trim();
if (!trimmedInput || isLoading) return;
onSendMessage(trimmedInput);
setInput("");
// Scroll immediately after sending
scrollToBottom();
// Keep focus on input
textareaRef.current?.focus();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
};
return (
<div
ref={containerRef}
className={cn(
"flex flex-col bg-card text-card-foreground rounded-lg border shadow-sm",
className
)}
style={{ height }}
>
{/* Messages Area */}
<div ref={scrollAreaRef} className="flex-1 overflow-hidden">
{displayMessages.length === 0 ? (
<div className="flex h-full flex-col p-4">
<div className="flex flex-1 flex-col items-center justify-center gap-6 text-muted-foreground">
<div className="flex flex-col items-center gap-3">
<Sparkles className="size-12 opacity-20" />
<p className="text-sm">{emptyStateMessage}</p>
</div>
{suggestedPrompts && suggestedPrompts.length > 0 && (
<div className="flex max-w-2xl flex-wrap justify-center gap-2">
{suggestedPrompts.map((prompt, index) => (
<button
key={index}
onClick={() => onSendMessage(prompt)}
disabled={isLoading}
className="rounded-lg border border-border bg-card px-4 py-2 text-sm transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
>
{prompt}
</button>
))}
</div>
)}
</div>
</div>
) : (
<ScrollArea className="h-full">
<div className="flex flex-col space-y-4 p-4">
{displayMessages.map((message, index) => {
// Apply min-height to last message only if NOT loading (when loading, the loading indicator gets it)
const isLastMessage = index === displayMessages.length - 1;
const shouldApplyMinHeight =
isLastMessage && !isLoading && minHeightForLastMessage > 0;
return (
<div
key={index}
className={cn(
"flex gap-3",
message.role === "user"
? "justify-end items-start"
: "justify-start items-start"
)}
style={
shouldApplyMinHeight
? { minHeight: `${minHeightForLastMessage}px` }
: undefined
}
>
{message.role === "assistant" && (
<div className="size-8 shrink-0 mt-1 rounded-full bg-primary/10 flex items-center justify-center">
<Sparkles className="size-4 text-primary" />
</div>
)}
<div
className={cn(
"max-w-[80%] rounded-lg px-4 py-2.5",
message.role === "user"
? "bg-primary text-primary-foreground"
: "bg-muted text-foreground"
)}
>
{message.role === "assistant" ? (
<div className="prose prose-sm dark:prose-invert max-w-none">
<Streamdown>{message.content}</Streamdown>
</div>
) : (
<p className="whitespace-pre-wrap text-sm">
{message.content}
</p>
)}
</div>
{message.role === "user" && (
<div className="size-8 shrink-0 mt-1 rounded-full bg-secondary flex items-center justify-center">
<User className="size-4 text-secondary-foreground" />
</div>
)}
</div>
);
})}
{isLoading && (
<div
className="flex items-start gap-3"
style={
minHeightForLastMessage > 0
? { minHeight: `${minHeightForLastMessage}px` }
: undefined
}
>
<div className="size-8 shrink-0 mt-1 rounded-full bg-primary/10 flex items-center justify-center">
<Sparkles className="size-4 text-primary" />
</div>
<div className="rounded-lg bg-muted px-4 py-2.5">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
</div>
)}
</div>
</ScrollArea>
)}
</div>
{/* Input Area */}
<form
ref={inputAreaRef}
onSubmit={handleSubmit}
className="flex gap-2 p-4 border-t bg-background/50 items-end"
>
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="flex-1 max-h-32 resize-none min-h-9"
rows={1}
/>
<Button
type="submit"
size="icon"
disabled={!input.trim() || isLoading}
className="shrink-0 h-[38px] w-[38px]"
>
{isLoading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Send className="size-4" />
)}
</Button>
</form>
</div>
);
}

View File

@@ -0,0 +1,464 @@
import { useState } from 'react';
import { trpc } from '@/lib/trpc';
import { BarChart2, Ruler, Zap, Play, Trash2, ChevronDown, ChevronRight, RefreshCw, Flame, Box, Layers, Crosshair } from 'lucide-react';
interface Props {
taskId: number;
onReplay?: (resultId: number) => void;
/** 在 3D 视口显示该条平滑热力分析结果 */
onViewSmoothHeatmap?: (resultId: number) => void;
/** 触发装配检测(来自 Home.tsx 的 runAnalysisJob) */
onRunAssembly?: () => void;
/** 触发偏差热力(来自 Home.tsx 的 runAnalysisJob) */
onRunDeviation?: () => void;
/** 外部分析任务是否正在提交中 */
externalPending?: boolean;
}
type Measurement = { name: string; value: number; unit: string; tolerance: number; status: string };
type ErrorStats = { min: number; max: number; mean: number; stdDev: number; totalPoints: number; withinTolerance: number; outOfTolerance: number };
function ErrorStatCard({ stats }: { stats: ErrorStats }) {
const pct = stats.totalPoints > 0
? ((stats.withinTolerance / stats.totalPoints) * 100).toFixed(1)
: '0';
return (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-1.5">
{[
{ label: '最小误差', value: stats.min.toFixed(4) + ' mm' },
{ label: '最大误差', value: stats.max.toFixed(4) + ' mm' },
{ label: '平均误差', value: stats.mean.toFixed(4) + ' mm' },
{ label: '标准差', value: stats.stdDev.toFixed(4) + ' mm' },
].map(({ label, value }) => (
<div key={label} className="industrial-card p-2">
<div className="text-[10px] text-muted-foreground">{label}</div>
<div className="text-xs font-mono mt-0.5">{value}</div>
</div>
))}
</div>
<div className="industrial-card p-2">
<div className="flex items-center justify-between text-[10px] mb-1">
<span className="text-muted-foreground">合格率</span>
<span className={parseFloat(pct) >= 95 ? 'text-green-400' : parseFloat(pct) >= 85 ? 'text-amber-400' : 'text-red-400'}>
{pct}%
</span>
</div>
<div className="industrial-progress">
<div
className="industrial-progress-bar"
style={{ width: `${pct}%`, background: parseFloat(pct) >= 95 ? 'oklch(0.62 0.16 145)' : parseFloat(pct) >= 85 ? 'oklch(0.72 0.16 85)' : 'oklch(0.55 0.20 28)' }}
/>
</div>
<div className="flex justify-between text-[10px] text-muted-foreground mt-1">
<span>合格 {stats.withinTolerance.toLocaleString()}</span>
<span>超差 {stats.outOfTolerance.toLocaleString()}</span>
</div>
</div>
</div>
);
}
function MeasurementTable({ items }: { items: Measurement[] }) {
return (
<table className="w-full text-[10px] border-collapse">
<thead>
<tr className="text-muted-foreground">
<th className="text-left py-1 font-normal">项目</th>
<th className="text-right py-1 font-normal">值</th>
<th className="text-right py-1 font-normal">公差</th>
<th className="text-right py-1 font-normal">结果</th>
</tr>
</thead>
<tbody>
{items.map((m, i) => (
<tr key={i} className="border-t border-border/40">
<td className="py-1">{m.name}</td>
<td className="text-right font-mono">{m.value.toFixed(3)} {m.unit}</td>
<td className="text-right text-muted-foreground">±{m.tolerance}</td>
<td className="text-right">
<span className={`px-1 py-0.5 rounded text-[9px] ${
m.status === 'pass' ? 'bg-green-900/40 text-green-400' :
m.status === 'warning' ? 'bg-amber-900/40 text-amber-400' :
'bg-red-900/40 text-red-400'
}`}>
{m.status === 'pass' ? '合格' : m.status === 'warning' ? '警告' : '超差'}
</span>
</td>
</tr>
))}
</tbody>
</table>
);
}
type SmoothHeatmapMeta = {
kind?: string;
/** 0–1,processing 时子进程写入 */
progress?: number;
stage?: string;
vertexCount?: number;
dispMin?: number;
dispMax?: number;
error?: string;
};
type AssemblyPartRow = {
partId: string;
name: string;
min: number;
max: number;
mean: number;
stdDev: number;
passRate: number;
pointCount: number;
vertexHeatmapBin?: string;
};
type AssemblyMeta = SmoothHeatmapMeta & {
toleranceMm?: number;
samplePoints?: number;
parts?: AssemblyPartRow[];
};
export default function AnalysisPanel({ taskId, onReplay, onViewSmoothHeatmap, onRunAssembly, onRunDeviation, externalPending }: Props) {
const utils = trpc.useUtils();
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
const { data: results = [], isLoading } = trpc.analysisResult.list.useQuery(
{ taskId },
{ refetchInterval: 3000 },
);
const runAnalysis = trpc.analysisResult.runAnalysis.useMutation({
onSuccess: () => utils.analysisResult.list.invalidate({ taskId }),
});
const deleteResult = trpc.analysisResult.delete.useMutation({
onSuccess: () => utils.analysisResult.list.invalidate({ taskId }),
});
function toggleExpand(id: number) {
setExpandedIds(prev => {
const n = new Set(prev);
n.has(id) ? n.delete(id) : n.add(id);
return n;
});
}
const running = runAnalysis.isPending;
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-1 flex-wrap">
<span className="industrial-label flex items-center gap-1.5">
<BarChart2 className="w-3.5 h-3.5" /> 分析结果
</span>
<div className="flex items-center gap-1">
<button
className={`industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5 ${running ? 'opacity-60 cursor-not-allowed' : ''}`}
onClick={() => !running && runAnalysis.mutate({ taskId, type: 'error_analysis' })}
disabled={running}
title="误差统计分析"
>
{running ? <RefreshCw className="w-3 h-3 animate-spin" /> : <Play className="w-3 h-3" />}
{running ? '分析中…' : '误差分析'}
</button>
{onRunAssembly && (
<button
className={`industrial-btn py-0.5 text-[10px] flex items-center gap-0.5 ${externalPending ? 'opacity-60 cursor-wait' : ''}`}
onClick={onRunAssembly}
disabled={externalPending}
title="需车型下已上传 STEP;后台解析零件并做最近零件归属与误差统计"
>
<Layers className="w-3 h-3" />
装配检测
</button>
)}
{onRunDeviation && (
<button
className={`industrial-btn py-0.5 text-[10px] flex items-center gap-0.5 ${externalPending ? 'opacity-60 cursor-wait' : ''}`}
onClick={onRunDeviation}
disabled={externalPending}
title="在 STL/OBJ 网格上按顶点到最近点云点的距离着色"
>
<Crosshair className="w-3 h-3" />
偏差热力
</button>
)}
</div>
</div>
{isLoading && (
<div className="text-xs text-muted-foreground text-center py-4">加载中…</div>
)}
{!isLoading && results.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-4">
暂无分析结果,点击运行分析
</div>
)}
<div className="space-y-1.5">
{results.map(r => {
const expanded = expandedIds.has(r.id);
const errorStats = r.errorStats as ErrorStats | null;
const measurements = r.measurements as Measurement[] | null;
return (
<div key={r.id} className="industrial-card rounded overflow-hidden">
{/* 标题行 */}
<div
className="flex items-center gap-2 px-2 py-1.5 cursor-pointer hover:bg-secondary/40 group"
onClick={() => toggleExpand(r.id)}
>
{expanded ? <ChevronDown className="w-3 h-3 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 text-muted-foreground" />}
{r.type === 'error_analysis' && <BarChart2 className="w-3.5 h-3.5 text-blue-400 flex-shrink-0" />}
{r.type === 'measurement' && <Ruler className="w-3.5 h-3.5 text-green-400 flex-shrink-0" />}
{r.type === 'compression' && <Zap className="w-3.5 h-3.5 text-amber-400 flex-shrink-0" />}
{r.type === 'smooth_heatmap' && <Flame className="w-3.5 h-3.5 text-orange-400 flex-shrink-0" />}
{r.type === 'assembly_inspection' && <Layers className="w-3.5 h-3.5 text-cyan-400 flex-shrink-0" />}
{r.type === 'obj_deviation' && <Crosshair className="w-3.5 h-3.5 text-rose-400 flex-shrink-0" />}
<span className="text-xs flex-1">
{r.type === 'error_analysis'
? '误差分析'
: r.type === 'measurement'
? '尺寸测量'
: r.type === 'smooth_heatmap'
? '平滑热力'
: r.type === 'assembly_inspection'
? '装配检测'
: r.type === 'obj_deviation'
? '偏差热力'
: '压缩'}
</span>
<span className="text-[10px] text-muted-foreground">
{new Date(r.createdAt).toLocaleDateString('zh-CN')}
</span>
<span className={`text-[9px] px-1.5 py-0.5 rounded flex-shrink-0 ${
r.status === 'completed' ? 'bg-green-900/40 text-green-400' :
r.status === 'processing' ? 'bg-blue-900/40 text-blue-400 animate-pulse' :
r.status === 'error' ? 'bg-red-900/40 text-red-400' :
'bg-secondary text-muted-foreground'
}`}>{r.status}</span>
{r.status === 'processing' &&
(r.type === 'smooth_heatmap' || r.type === 'assembly_inspection' || r.type === 'obj_deviation') &&
(() => {
const hm = r.resultData as SmoothHeatmapMeta | null;
const pct = hm?.progress != null ? Math.round(hm.progress * 100) : null;
return pct != null ? (
<span className="text-[9px] font-mono text-primary tabular-nums">{pct}%</span>
) : null;
})()}
<span className="hidden group-hover:flex items-center gap-0.5">
{onReplay && r.status === 'completed' && (
<button
className="p-0.5 rounded hover:bg-secondary"
title="回放此结果"
onClick={e => { e.stopPropagation(); onReplay(r.id); }}
>
<Play className="w-3 h-3 text-primary" />
</button>
)}
<button
className="p-0.5 rounded hover:bg-secondary text-destructive"
title="删除"
onClick={e => { e.stopPropagation(); deleteResult.mutate({ id: r.id }); }}
>
<Trash2 className="w-3 h-3" />
</button>
</span>
</div>
{/* 展开内容 */}
{expanded && r.status === 'completed' && r.type !== 'smooth_heatmap' && r.type !== 'assembly_inspection' && r.type !== 'obj_deviation' && (
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-3">
{errorStats && <ErrorStatCard stats={errorStats} />}
{measurements && measurements.length > 0 && (
<div>
<div className="text-[10px] text-muted-foreground mb-1">尺寸测量</div>
<MeasurementTable items={measurements} />
</div>
)}
</div>
)}
{expanded &&
(r.type === 'smooth_heatmap' || r.type === 'assembly_inspection' || r.type === 'obj_deviation') &&
r.status === 'processing' &&
(() => {
const hm = r.resultData as SmoothHeatmapMeta | null;
const p = hm?.progress ?? 0;
const stage = hm?.stage ?? '排队或启动中…';
return (
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-2">
<div className="text-[10px] text-muted-foreground">{stage}</div>
<div className="industrial-progress h-1.5">
<div
className="industrial-progress-bar transition-[width] duration-300"
style={{
width: `${Math.min(100, Math.max(0, p * 100))}%`,
background: 'oklch(0.55 0.14 250)',
}}
/>
</div>
<p className="text-[9px] text-muted-foreground/80">
后台子进程执行,页面可继续操作;进度约每 1.2s 刷新。
</p>
</div>
);
})()}
{expanded && r.type === 'smooth_heatmap' && r.status === 'completed' && (
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-2">
{(() => {
const hm = r.resultData as SmoothHeatmapMeta | null;
return (
<>
<div className="text-[10px] text-muted-foreground space-y-0.5">
{hm?.vertexCount != null && (
<div>网格顶点约 {hm.vertexCount.toLocaleString()}</div>
)}
{hm?.dispMin != null && hm?.dispMax != null && (
<div>
色标误差约 {hm.dispMin.toFixed(4)} ~ {hm.dispMax.toFixed(4)} mm
</div>
)}
</div>
<button
type="button"
className="industrial-btn industrial-btn-primary py-1 text-[10px] flex items-center gap-1 w-full justify-center"
onClick={(e) => {
e.stopPropagation();
onViewSmoothHeatmap?.(r.id);
}}
>
<Box className="w-3 h-3" />
在 3D 中查看
</button>
</>
);
})()}
</div>
)}
{expanded && r.type === 'smooth_heatmap' && r.status === 'error' && (
<div className="px-3 pb-3 pt-1 border-t border-border/40">
<p className="text-[10px] text-red-400/90">
{(r.resultData as SmoothHeatmapMeta | null)?.error ?? '分析失败'}
</p>
</div>
)}
{expanded && r.type === 'assembly_inspection' && r.status === 'completed' && (
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-2">
{(() => {
const am = r.resultData as AssemblyMeta | null;
const tol = am?.toleranceMm ?? 0.3;
const parts = am?.parts ?? [];
return (
<>
<div className="text-[10px] text-muted-foreground">
阈值 &lt; {tol} mm 为合格;测点归属为「到各零件网格距离最小」的零件。
</div>
<div className="overflow-x-auto max-h-48 overflow-y-auto">
<table className="w-full text-[9px] border-collapse">
<thead>
<tr className="text-muted-foreground text-left">
<th className="py-0.5 pr-1">零件</th>
<th className="py-0.5 pr-1 text-right">点数</th>
<th className="py-0.5 pr-1 text-right">min</th>
<th className="py-0.5 pr-1 text-right">max</th>
<th className="py-0.5 pr-1 text-right">mean</th>
<th className="py-0.5 text-right">合格率</th>
</tr>
</thead>
<tbody>
{parts.map((p) => (
<tr key={p.partId} className="border-t border-border/30">
<td className="py-0.5 pr-1 font-mono truncate max-w-[100px]" title={p.name}>
{p.name}
</td>
<td className="py-0.5 pr-1 text-right">{p.pointCount}</td>
<td className="py-0.5 pr-1 text-right">{p.min.toFixed(4)}</td>
<td className="py-0.5 pr-1 text-right">{p.max.toFixed(4)}</td>
<td className="py-0.5 pr-1 text-right">{p.mean.toFixed(4)}</td>
<td className="py-0.5 text-right">
<span
className={
p.passRate >= 0.95
? 'text-green-400'
: p.passRate >= 0.85
? 'text-amber-400'
: 'text-red-400'
}
>
{(p.passRate * 100).toFixed(1)}%
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
})()}
</div>
)}
{expanded && r.type === 'assembly_inspection' && r.status === 'error' && (
<div className="px-3 pb-3 pt-1 border-t border-border/40">
<p className="text-[10px] text-red-400/90">
{(r.resultData as AssemblyMeta | null)?.error ?? '装配检测失败'}
</p>
</div>
)}
{expanded && r.type === 'obj_deviation' && r.status === 'completed' && (
<div className="px-3 pb-3 pt-1 border-t border-border/40 space-y-2">
{(() => {
const hm = r.resultData as SmoothHeatmapMeta | null;
return (
<>
<div className="text-[10px] text-muted-foreground space-y-0.5">
<div>按顶点到最近点云点的距离着色(蓝=近 → 红=远)</div>
{hm?.vertexCount != null && (
<div>网格顶点约 {hm.vertexCount.toLocaleString()}</div>
)}
{hm?.dispMin != null && hm?.dispMax != null && (
<div>
偏差范围约 {hm.dispMin.toFixed(4)} ~ {hm.dispMax.toFixed(4)}
</div>
)}
</div>
<button
type="button"
className="industrial-btn industrial-btn-primary py-1 text-[10px] flex items-center gap-1 w-full justify-center"
onClick={(e) => {
e.stopPropagation();
onViewSmoothHeatmap?.(r.id);
}}
>
<Box className="w-3 h-3" />
在 3D 中查看
</button>
</>
);
})()}
</div>
)}
{expanded && r.type === 'obj_deviation' && r.status === 'error' && (
<div className="px-3 pb-3 pt-1 border-t border-border/40">
<p className="text-[10px] text-red-400/90">
{(r.resultData as SmoothHeatmapMeta | null)?.error ?? '偏差分析失败'}
</p>
</div>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,345 @@
import { useState, useRef, useEffect } from 'react';
import { Box, Upload, FileText, Check, Loader2, FolderOpen, RefreshCw, Trash2 } from 'lucide-react';
interface CADModel {
id: string;
name: string;
format: string;
vertices: number;
faces: number;
size: string;
path?: string; // 文件路径
}
interface CADModelSelectorProps {
onModelSelect: (model: CADModel) => void;
isLoading: boolean;
loadProgress: number;
className?: string;
}
// 格式化文件大小
function formatFileSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
// 获取文件格式
function getFileFormat(filename: string): string {
const ext = filename.split('.').pop()?.toUpperCase() || 'UNKNOWN';
return ext;
}
export default function CADModelSelector({
onModelSelect,
isLoading,
loadProgress,
className = ''
}: CADModelSelectorProps) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [models, setModels] = useState<CADModel[]>([]);
const [isLoadingFiles, setIsLoadingFiles] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// 加载本地文件夹中的 CAD 文件
const loadLocalFiles = async () => {
setIsLoadingFiles(true);
setError(null);
try {
const response = await fetch('/api/files/cad');
if (!response.ok) {
throw new Error('无法加载文件列表');
}
const data = await response.json();
setModels(data.files || []);
} catch (err) {
console.error('Failed to load CAD files:', err);
setError('无法加载文件列表,请检查数据目录配置');
// 显示演示数据
setModels([
{ id: 'demo-1', name: 'reference_model.stl', format: 'STL', vertices: 125840, faces: 41946, size: '12.4 MB' },
{ id: 'demo-2', name: 'assembly_part_a.obj', format: 'OBJ', vertices: 89234, faces: 29744, size: '8.7 MB' },
]);
} finally {
setIsLoadingFiles(false);
}
};
// 初始加载
useEffect(() => {
loadLocalFiles();
}, []);
const handleSelect = (model: CADModel) => {
setSelectedId(model.id);
onModelSelect(model);
};
// 删除文件
const handleDelete = async (model: CADModel, e: React.MouseEvent) => {
e.stopPropagation(); // 防止触发选择
if (!confirm(`确定要删除 ${model.name} 吗?此操作不可恢复。`)) {
return;
}
try {
const response = await fetch(`/api/files/delete/cad/${encodeURIComponent(model.name)}`, {
method: 'DELETE'
});
if (response.ok) {
setModels(prev => prev.filter(m => m.id !== model.id));
// 如果删除的是当前选中的,清除选中状态
if (selectedId === model.id) {
setSelectedId(null);
}
} else {
setError('删除文件失败');
}
} catch (err) {
console.error('Failed to delete file:', err);
setError('删除文件失败');
}
};
// 上传文件到服务器
const uploadFile = async (file: File): Promise<CADModel | null> => {
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/files/upload/cad', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('Upload failed');
}
const result = await response.json();
console.log('[CADModelSelector] File uploaded:', result);
if (result.success && result.file) {
return {
id: result.file.id,
name: result.file.name,
format: result.file.format,
vertices: 0,
faces: 0,
size: result.file.size,
path: result.file.path,
};
}
} catch (err) {
console.error('[CADModelSelector] Upload error:', err);
setError('文件上传失败');
}
return null;
};
// 处理文件选择
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
const file = files[0];
setIsLoadingFiles(true);
setError(null);
const model = await uploadFile(file);
if (model) {
setModels(prev => [model, ...prev]);
handleSelect(model);
}
setIsLoadingFiles(false);
// 清空 input,允许重复选择同一文件
e.target.value = '';
};
// 处理文件拖放
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const files = e.dataTransfer.files;
if (!files || files.length === 0) return;
const file = files[0];
setIsLoadingFiles(true);
setError(null);
const model = await uploadFile(file);
if (model) {
setModels(prev => [model, ...prev]);
handleSelect(model);
}
setIsLoadingFiles(false);
};
return (
<div className={`space-y-4 ${className}`}>
{/* 隐藏的文件输入 */}
<input
ref={fileInputRef}
type="file"
accept=".stl,.obj,.step,.stp,.iges,.igs"
onChange={handleFileSelect}
className="hidden"
/>
{/* 上传区域 */}
<div
className={`industrial-panel p-6 border-2 border-dashed transition-colors ${
isDragging ? 'border-primary bg-primary/5' : 'border-border'
}`}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
>
<div className="flex flex-col items-center gap-3 text-center">
<div className="w-12 h-12 rounded-lg bg-secondary flex items-center justify-center">
<Upload className="w-6 h-6 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium">拖拽CAD模型文件到此处</p>
<p className="text-xs text-muted-foreground mt-1">支持 STL, OBJ, STEP 格式</p>
</div>
<button
className="industrial-btn mt-2"
onClick={() => fileInputRef.current?.click()}
>
<FolderOpen className="w-4 h-4 mr-1" />
浏览文件
</button>
</div>
</div>
{/* 已有模型列表 */}
<div className="industrial-panel p-4">
<div className="flex items-center justify-between mb-3">
<span className="industrial-label">本地模型</span>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">{models.length} 个文件</span>
<button
className="p-1 hover:bg-secondary rounded"
onClick={loadLocalFiles}
disabled={isLoadingFiles}
title="刷新文件列表"
>
<RefreshCw className={`w-3.5 h-3.5 ${isLoadingFiles ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
{error && (
<div className="text-xs text-amber-500 mb-2 p-2 bg-amber-500/10 rounded">
{error}
</div>
)}
<div className="space-y-2 max-h-64 overflow-y-auto">
{models.map((model) => (
<div
key={model.id}
className={`p-3 rounded cursor-pointer transition-all ${
selectedId === model.id
? 'bg-primary/10 border border-primary/30'
: 'bg-secondary/50 border border-transparent hover:bg-secondary'
}`}
onClick={() => handleSelect(model)}
>
<div className="flex items-start gap-3">
<div className={`w-8 h-8 rounded flex items-center justify-center flex-shrink-0 ${
selectedId === model.id ? 'bg-primary' : 'bg-muted'
}`}>
{selectedId === model.id && isLoading ? (
<Loader2 className="w-4 h-4 text-white animate-spin" />
) : selectedId === model.id ? (
<Check className="w-4 h-4 text-white" />
) : (
<Box className="w-4 h-4 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{model.name}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-secondary text-muted-foreground">
{model.format}
</span>
</div>
<div className="flex items-center gap-4 mt-1 text-xs text-muted-foreground">
<span>{model.vertices.toLocaleString()} 顶点</span>
<span>{model.faces.toLocaleString()} 面片</span>
<span>{model.size}</span>
</div>
</div>
{/* 删除按钮 */}
<button
className="p-1.5 rounded hover:bg-red-500/20 text-muted-foreground hover:text-red-500 transition-colors flex-shrink-0"
onClick={(e) => handleDelete(model, e)}
title="删除文件"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{/* 加载进度 */}
{selectedId === model.id && isLoading && (
<div className="mt-3">
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-muted-foreground">加载模型中...</span>
<span className="font-mono">{loadProgress}%</span>
</div>
<div className="industrial-progress h-4">
<div
className="industrial-progress-bar"
style={{ width: `${loadProgress}%` }}
/>
</div>
</div>
)}
</div>
))}
</div>
</div>
{/* 模型预览信息 */}
{selectedId && !isLoading && (
<div className="industrial-panel p-4">
<div className="industrial-label mb-3">模型信息</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">边界盒</span>
<span className="font-mono">120 x 80 x 60 mm</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">体积</span>
<span className="font-mono">345,600 mm³</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">表面积</span>
<span className="font-mono">28,400 mm²</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">质心</span>
<span className="font-mono">(0, 0, 0)</span>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,69 @@
import { useMemo } from 'react';
interface ChunkVisualizerProps {
totalChunks: number;
processedChunks: number;
currentChunk: number;
className?: string;
}
export default function ChunkVisualizer({
totalChunks,
processedChunks,
currentChunk,
className = ''
}: ChunkVisualizerProps) {
const chunks = useMemo(() => {
return Array.from({ length: totalChunks }, (_, i) => {
if (i < processedChunks) return 'completed';
if (i === currentChunk) return 'processing';
return 'pending';
});
}, [totalChunks, processedChunks, currentChunk]);
const completedSize = useMemo(() => {
const sizePerChunk = 32 / totalChunks; // 32GB total
return (processedChunks * sizePerChunk).toFixed(1);
}, [processedChunks, totalChunks]);
return (
<div className={`industrial-panel p-4 ${className}`}>
<div className="flex items-center justify-between mb-3">
<span className="industrial-label">数据块状态</span>
<div className="flex items-center gap-3 text-xs">
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded-sm bg-[oklch(0.52_0.14_220)]" />
<span className="text-muted-foreground">已完成</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded-sm bg-[oklch(0.68_0.16_55)]" />
<span className="text-muted-foreground">处理中</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 rounded-sm bg-[oklch(0.22_0.015_240)]" />
<span className="text-muted-foreground">等待</span>
</div>
</div>
</div>
<div className="chunk-indicator flex-wrap">
{chunks.map((status, index) => (
<div
key={index}
className={`chunk-block chunk-block-${status}`}
title={`数据块 ${index + 1}`}
/>
))}
</div>
<div className="mt-3 flex items-center justify-between text-xs">
<span className="text-muted-foreground">
已处理: <span className="text-foreground font-medium">{processedChunks}</span> / {totalChunks} 块
</span>
<span className="data-display py-1 px-2">
{completedSize} GB
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Minimize2, HardDrive, Clock, TrendingDown } from 'lucide-react';
interface CompressionResult {
originalSize: number;
compressedSize: number;
compressionRatio: number;
originalPoints: number;
compressedPoints: number;
processingTime: number;
}
interface CompressionResultsProps {
result: CompressionResult;
className?: string;
}
function formatBytes(bytes: number): string {
if (bytes >= 1e12) return `${(bytes / 1e12).toFixed(2)} TB`;
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(2)} GB`;
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)} MB`;
if (bytes >= 1e3) return `${(bytes / 1e3).toFixed(1)} KB`;
return `${bytes} B`;
}
function formatNumber(num: number): string {
if (num >= 1e9) return `${(num / 1e9).toFixed(2)}B`;
if (num >= 1e6) return `${(num / 1e6).toFixed(2)}M`;
if (num >= 1e3) return `${(num / 1e3).toFixed(1)}K`;
return num.toString();
}
export default function CompressionResults({ result, className = '' }: CompressionResultsProps) {
const savedSize = result.originalSize - result.compressedSize;
const savedPercentage = ((1 - result.compressionRatio) * 100).toFixed(1);
return (
<Card className={`glass-panel border-border/50 ${className}`}>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Minimize2 className="w-4 h-4 text-primary" />
压缩结果
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Compression visualization */}
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">压缩进度</span>
<span className="font-medium text-green-400">节省 {savedPercentage}%</span>
</div>
<div className="relative h-8 bg-secondary/30 rounded-lg overflow-hidden">
<div
className="absolute inset-y-0 left-0 bg-gradient-to-r from-primary/80 to-primary/40 rounded-lg"
style={{ width: `${result.compressionRatio * 100}%` }}
/>
<div className="absolute inset-0 flex items-center justify-center text-xs font-medium">
{formatBytes(result.compressedSize)} / {formatBytes(result.originalSize)}
</div>
</div>
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 gap-3">
<div className="p-3 rounded-lg bg-secondary/30">
<div className="flex items-center gap-2 text-muted-foreground text-xs mb-1">
<HardDrive className="w-3 h-3" />
原始大小
</div>
<div className="font-mono font-medium">{formatBytes(result.originalSize)}</div>
</div>
<div className="p-3 rounded-lg bg-secondary/30">
<div className="flex items-center gap-2 text-muted-foreground text-xs mb-1">
<TrendingDown className="w-3 h-3" />
压缩后大小
</div>
<div className="font-mono font-medium text-green-400">{formatBytes(result.compressedSize)}</div>
</div>
<div className="p-3 rounded-lg bg-secondary/30">
<div className="flex items-center gap-2 text-muted-foreground text-xs mb-1">
<Minimize2 className="w-3 h-3" />
压缩点数
</div>
<div className="font-mono font-medium">
{formatNumber(result.compressedPoints)} / {formatNumber(result.originalPoints)}
</div>
</div>
<div className="p-3 rounded-lg bg-secondary/30">
<div className="flex items-center gap-2 text-muted-foreground text-xs mb-1">
<Clock className="w-3 h-3" />
处理时间
</div>
<div className="font-mono font-medium">{result.processingTime.toFixed(1)}s</div>
</div>
</div>
{/* Summary */}
<div className="p-3 rounded-lg bg-green-500/10 border border-green-500/20">
<div className="text-sm text-green-400 font-medium">
成功节省 {formatBytes(savedSize)} 存储空间
</div>
<div className="text-xs text-muted-foreground mt-1">
压缩比: {(result.compressionRatio * 100).toFixed(1)}% ·
点云保留率: {(result.compressionRatio * 100).toFixed(1)}%
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,127 @@
import { useState, useEffect } from 'react';
import { ArrowRight, Database, Minimize2 } from 'lucide-react';
interface CompressionVisualProps {
isCompressing: boolean;
progress: number;
originalSize: number;
compressedSize: number;
compressionRatio: number;
className?: string;
}
export default function CompressionVisual({
isCompressing,
progress,
originalSize,
compressedSize,
compressionRatio,
className = ''
}: CompressionVisualProps) {
const [animatedProgress, setAnimatedProgress] = useState(0);
useEffect(() => {
if (isCompressing) {
setAnimatedProgress(progress);
}
}, [progress, isCompressing]);
const formatSize = (bytes: number) => {
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)} MB`;
return `${bytes} B`;
};
return (
<div className={`industrial-panel p-4 ${className}`}>
<div className="flex items-center justify-between mb-4">
<span className="industrial-label">数据压缩</span>
{isCompressing && (
<div className="flex items-center gap-2">
<span className="status-led status-led-processing" />
<span className="text-xs text-[oklch(0.68_0.16_55)]">压缩中</span>
</div>
)}
</div>
{/* 压缩示意图 */}
<div className="compression-visual mb-4">
<div className="flex flex-col items-center gap-2">
<div
className="compression-cube compression-cube-original"
style={{
opacity: isCompressing ? 0.5 : 1,
transition: 'opacity 0.3s'
}}
>
<Database className="w-6 h-6" />
</div>
<span className="text-xs text-muted-foreground">原始数据</span>
<span className="data-display py-1 px-2 text-xs">
{formatSize(originalSize)}
</span>
</div>
<div className="flex flex-col items-center gap-2">
<ArrowRight className="w-8 h-8 text-muted-foreground" />
{isCompressing && (
<span className="text-xs text-[oklch(0.68_0.16_55)] font-mono">
{progress.toFixed(0)}%
</span>
)}
</div>
<div className="flex flex-col items-center gap-2">
<div
className="compression-cube compression-cube-compressed"
style={{
opacity: progress > 0 ? 1 : 0.3,
transform: `scale(${0.5 + (progress / 100) * 0.5})`,
transition: 'all 0.5s'
}}
>
<Minimize2 className="w-4 h-4" />
</div>
<span className="text-xs text-muted-foreground">压缩数据</span>
<span className="data-display py-1 px-2 text-xs">
{formatSize(compressedSize)}
</span>
</div>
</div>
{/* 进度条 */}
{isCompressing && (
<div className="space-y-2 mb-4">
<div className="industrial-progress">
<div
className="industrial-progress-bar"
style={{ width: `${animatedProgress}%` }}
/>
</div>
</div>
)}
{/* 压缩统计 */}
<div className="grid grid-cols-3 gap-3">
<div className="industrial-card p-3 text-center">
<div className="industrial-label mb-1">压缩率</div>
<div className="text-lg font-mono text-[oklch(0.72_0.12_145)]">
{((1 - compressionRatio) * 100).toFixed(0)}%
</div>
</div>
<div className="industrial-card p-3 text-center">
<div className="industrial-label mb-1">节省空间</div>
<div className="text-lg font-mono text-[oklch(0.58_0.14_220)]">
{formatSize(originalSize - compressedSize)}
</div>
</div>
<div className="industrial-card p-3 text-center">
<div className="industrial-label mb-1">保留率</div>
<div className="text-lg font-mono text-foreground">
{(compressionRatio * 100).toFixed(0)}%
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,252 @@
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { toast } from 'sonner';
import {
Settings,
Eye,
Palette,
Ruler,
Minimize2,
Play,
RotateCcw,
Download,
Loader2
} from 'lucide-react';
interface ViewSettings {
showHeatmap: boolean;
showCADModel: boolean;
showWireframe: boolean;
pointSize: number;
}
interface ControlPanelProps {
viewSettings: ViewSettings;
onViewSettingsChange: (settings: ViewSettings) => void;
onRunErrorAnalysis?: () => void;
onRunMeasurement?: () => void;
onRunCompression?: () => void;
onResetView?: () => void;
onExportResults?: () => void;
className?: string;
}
export default function ControlPanel({
viewSettings,
onViewSettingsChange,
onRunErrorAnalysis,
onRunMeasurement,
onRunCompression,
onResetView,
onExportResults,
className = ''
}: ControlPanelProps) {
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [isMeasuring, setIsMeasuring] = useState(false);
const [isCompressing, setIsCompressing] = useState(false);
const handleErrorAnalysis = async () => {
setIsAnalyzing(true);
toast.info('正在执行误差分析...', { duration: 2000 });
// Simulate analysis
await new Promise(resolve => setTimeout(resolve, 2000));
onRunErrorAnalysis?.();
setIsAnalyzing(false);
toast.success('误差分析完成!', {
description: '已生成误差热力图和统计数据'
});
};
const handleMeasurement = async () => {
setIsMeasuring(true);
toast.info('正在执行尺寸测量...', { duration: 1500 });
// Simulate measurement
await new Promise(resolve => setTimeout(resolve, 1500));
onRunMeasurement?.();
setIsMeasuring(false);
toast.success('尺寸测量完成!', {
description: '已生成7项测量结果'
});
};
const handleCompression = async () => {
setIsCompressing(true);
toast.info('正在执行点云压缩...', { duration: 3000 });
// Simulate compression
await new Promise(resolve => setTimeout(resolve, 3000));
onRunCompression?.();
setIsCompressing(false);
toast.success('点云压缩完成!', {
description: '压缩率: 70%,原始32GB → 压缩后9.6GB'
});
};
const handleExport = () => {
toast.info('功能开发中...', {
description: '导出功能将在后续版本中实现'
});
onExportResults?.();
};
return (
<div className={`space-y-4 ${className}`}>
{/* View Settings */}
<Card className="glass-panel border-border/50">
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Eye className="w-4 h-4 text-primary" />
视图设置
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<Label htmlFor="heatmap" className="text-sm flex items-center gap-2">
<Palette className="w-4 h-4 text-muted-foreground" />
误差热力图
</Label>
<Switch
id="heatmap"
checked={viewSettings.showHeatmap}
onCheckedChange={(checked) =>
onViewSettingsChange({ ...viewSettings, showHeatmap: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="cadmodel" className="text-sm flex items-center gap-2">
<Settings className="w-4 h-4 text-muted-foreground" />
CAD模型
</Label>
<Switch
id="cadmodel"
checked={viewSettings.showCADModel}
onCheckedChange={(checked) =>
onViewSettingsChange({ ...viewSettings, showCADModel: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<Label htmlFor="wireframe" className="text-sm flex items-center gap-2">
<Settings className="w-4 h-4 text-muted-foreground" />
线框模式
</Label>
<Switch
id="wireframe"
checked={viewSettings.showWireframe}
onCheckedChange={(checked) =>
onViewSettingsChange({ ...viewSettings, showWireframe: checked })
}
/>
</div>
<Separator />
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-sm">点大小</Label>
<span className="text-xs text-muted-foreground">
{viewSettings.pointSize.toFixed(2)}
</span>
</div>
<Slider
value={[viewSettings.pointSize]}
min={0.01}
max={0.1}
step={0.005}
onValueChange={([value]) =>
onViewSettingsChange({ ...viewSettings, pointSize: value })
}
/>
</div>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={onResetView}
>
<RotateCcw className="w-4 h-4 mr-2" />
重置视图
</Button>
</CardContent>
</Card>
{/* Analysis Functions */}
<Card className="glass-panel border-border/50">
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Play className="w-4 h-4 text-primary" />
分析功能
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<Button
className="w-full justify-start"
variant="secondary"
disabled={isAnalyzing}
onClick={handleErrorAnalysis}
>
{isAnalyzing ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Palette className="w-4 h-4 mr-2" />
)}
误差分析
</Button>
<Button
className="w-full justify-start"
variant="secondary"
disabled={isMeasuring}
onClick={handleMeasurement}
>
{isMeasuring ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Ruler className="w-4 h-4 mr-2" />
)}
尺寸测量
</Button>
<Button
className="w-full justify-start"
variant="secondary"
disabled={isCompressing}
onClick={handleCompression}
>
{isCompressing ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Minimize2 className="w-4 h-4 mr-2" />
)}
点云压缩
</Button>
<Separator />
<Button
className="w-full justify-start"
variant="outline"
onClick={handleExport}
>
<Download className="w-4 h-4 mr-2" />
导出结果
</Button>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,264 @@
import { useAuth } from "@/_core/hooks/useAuth";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarInset,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar";
import { getLoginUrl } from "@/const";
import { useIsMobile } from "@/hooks/useMobile";
import { LayoutDashboard, LogOut, PanelLeft, Users } from "lucide-react";
import { CSSProperties, useEffect, useRef, useState } from "react";
import { useLocation } from "wouter";
import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton';
import { Button } from "./ui/button";
const menuItems = [
{ icon: LayoutDashboard, label: "Page 1", path: "/" },
{ icon: Users, label: "Page 2", path: "/some-path" },
];
const SIDEBAR_WIDTH_KEY = "sidebar-width";
const DEFAULT_WIDTH = 280;
const MIN_WIDTH = 200;
const MAX_WIDTH = 480;
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const [sidebarWidth, setSidebarWidth] = useState(() => {
const saved = localStorage.getItem(SIDEBAR_WIDTH_KEY);
return saved ? parseInt(saved, 10) : DEFAULT_WIDTH;
});
const { loading, user } = useAuth();
useEffect(() => {
localStorage.setItem(SIDEBAR_WIDTH_KEY, sidebarWidth.toString());
}, [sidebarWidth]);
if (loading) {
return <DashboardLayoutSkeleton />
}
if (!user) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="flex flex-col items-center gap-8 p-8 max-w-md w-full">
<div className="flex flex-col items-center gap-6">
<h1 className="text-2xl font-semibold tracking-tight text-center">
Sign in to continue
</h1>
<p className="text-sm text-muted-foreground text-center max-w-sm">
Access to this dashboard requires authentication. Continue to launch the login flow.
</p>
</div>
<Button
onClick={() => {
window.location.href = getLoginUrl();
}}
size="lg"
className="w-full shadow-lg hover:shadow-xl transition-all"
>
Sign in
</Button>
</div>
</div>
);
}
return (
<SidebarProvider
style={
{
"--sidebar-width": `${sidebarWidth}px`,
} as CSSProperties
}
>
<DashboardLayoutContent setSidebarWidth={setSidebarWidth}>
{children}
</DashboardLayoutContent>
</SidebarProvider>
);
}
type DashboardLayoutContentProps = {
children: React.ReactNode;
setSidebarWidth: (width: number) => void;
};
function DashboardLayoutContent({
children,
setSidebarWidth,
}: DashboardLayoutContentProps) {
const { user, logout } = useAuth();
const [location, setLocation] = useLocation();
const { state, toggleSidebar } = useSidebar();
const isCollapsed = state === "collapsed";
const [isResizing, setIsResizing] = useState(false);
const sidebarRef = useRef<HTMLDivElement>(null);
const activeMenuItem = menuItems.find(item => item.path === location);
const isMobile = useIsMobile();
useEffect(() => {
if (isCollapsed) {
setIsResizing(false);
}
}, [isCollapsed]);
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
if (!isResizing) return;
const sidebarLeft = sidebarRef.current?.getBoundingClientRect().left ?? 0;
const newWidth = e.clientX - sidebarLeft;
if (newWidth >= MIN_WIDTH && newWidth <= MAX_WIDTH) {
setSidebarWidth(newWidth);
}
};
const handleMouseUp = () => {
setIsResizing(false);
};
if (isResizing) {
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
}
return () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
};
}, [isResizing, setSidebarWidth]);
return (
<>
<div className="relative" ref={sidebarRef}>
<Sidebar
collapsible="icon"
className="border-r-0"
disableTransition={isResizing}
>
<SidebarHeader className="h-16 justify-center">
<div className="flex items-center gap-3 px-2 transition-all w-full">
<button
onClick={toggleSidebar}
className="h-8 w-8 flex items-center justify-center hover:bg-accent rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-ring shrink-0"
aria-label="Toggle navigation"
>
<PanelLeft className="h-4 w-4 text-muted-foreground" />
</button>
{!isCollapsed ? (
<div className="flex items-center gap-2 min-w-0">
<span className="font-semibold tracking-tight truncate">
Navigation
</span>
</div>
) : null}
</div>
</SidebarHeader>
<SidebarContent className="gap-0">
<SidebarMenu className="px-2 py-1">
{menuItems.map(item => {
const isActive = location === item.path;
return (
<SidebarMenuItem key={item.path}>
<SidebarMenuButton
isActive={isActive}
onClick={() => setLocation(item.path)}
tooltip={item.label}
className={`h-10 transition-all font-normal`}
>
<item.icon
className={`h-4 w-4 ${isActive ? "text-primary" : ""}`}
/>
<span>{item.label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
})}
</SidebarMenu>
</SidebarContent>
<SidebarFooter className="p-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex items-center gap-3 rounded-lg px-1 py-1 hover:bg-accent/50 transition-colors w-full text-left group-data-[collapsible=icon]:justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<Avatar className="h-9 w-9 border shrink-0">
<AvatarFallback className="text-xs font-medium">
{user?.name?.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 group-data-[collapsible=icon]:hidden">
<p className="text-sm font-medium truncate leading-none">
{user?.name || "-"}
</p>
<p className="text-xs text-muted-foreground truncate mt-1.5">
{user?.email || "-"}
</p>
</div>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem
onClick={logout}
className="cursor-pointer text-destructive focus:text-destructive"
>
<LogOut className="mr-2 h-4 w-4" />
<span>Sign out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarFooter>
</Sidebar>
<div
className={`absolute top-0 right-0 w-1 h-full cursor-col-resize hover:bg-primary/20 transition-colors ${isCollapsed ? "hidden" : ""}`}
onMouseDown={() => {
if (isCollapsed) return;
setIsResizing(true);
}}
style={{ zIndex: 50 }}
/>
</div>
<SidebarInset>
{isMobile && (
<div className="flex border-b h-14 items-center justify-between bg-background/95 px-2 backdrop-blur supports-[backdrop-filter]:backdrop-blur sticky top-0 z-40">
<div className="flex items-center gap-2">
<SidebarTrigger className="h-9 w-9 rounded-lg bg-background" />
<div className="flex items-center gap-3">
<div className="flex flex-col gap-1">
<span className="tracking-tight text-foreground">
{activeMenuItem?.label ?? "Menu"}
</span>
</div>
</div>
</div>
</div>
)}
<main className="flex-1 p-4">{children}</main>
</SidebarInset>
</>
);
}

View File

@@ -0,0 +1,46 @@
import { Skeleton } from './ui/skeleton';
export function DashboardLayoutSkeleton() {
return (
<div className="flex min-h-screen bg-background">
{/* Sidebar skeleton */}
<div className="w-[280px] border-r border-border bg-background p-4 space-y-6">
{/* Logo area */}
<div className="flex items-center gap-3 px-2">
<Skeleton className="h-8 w-8 rounded-md" />
<Skeleton className="h-4 w-24" />
</div>
{/* Menu items */}
<div className="space-y-2 px-2">
<Skeleton className="h-10 w-full rounded-lg" />
<Skeleton className="h-10 w-full rounded-lg" />
<Skeleton className="h-10 w-full rounded-lg" />
</div>
{/* User profile area at bottom */}
<div className="absolute bottom-4 left-4 right-4">
<div className="flex items-center gap-3 px-1">
<Skeleton className="h-9 w-9 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-2 w-32" />
</div>
</div>
</div>
</div>
{/* Main content skeleton */}
<div className="flex-1 p-4 space-y-4">
{/* Content blocks */}
<Skeleton className="h-12 w-48 rounded-lg" />
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Skeleton className="h-32 rounded-xl" />
<Skeleton className="h-32 rounded-xl" />
<Skeleton className="h-32 rounded-xl" />
</div>
<Skeleton className="h-64 rounded-xl" />
</div>
</div>
);
}

View File

@@ -0,0 +1,62 @@
import { cn } from "@/lib/utils";
import { AlertTriangle, RotateCcw } from "lucide-react";
import { Component, ReactNode } from "react";
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
return (
<div className="flex items-center justify-center min-h-screen p-8 bg-background">
<div className="flex flex-col items-center w-full max-w-2xl p-8">
<AlertTriangle
size={48}
className="text-destructive mb-6 flex-shrink-0"
/>
<h2 className="text-xl mb-4">An unexpected error occurred.</h2>
<div className="p-4 w-full rounded bg-muted overflow-auto mb-6">
<pre className="text-sm text-muted-foreground whitespace-break-spaces">
{this.state.error?.stack}
</pre>
</div>
<button
onClick={() => window.location.reload()}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-lg",
"bg-primary text-primary-foreground",
"hover:opacity-90 cursor-pointer"
)}
>
<RotateCcw size={16} />
Reload Page
</button>
</div>
</div>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -0,0 +1,323 @@
import { useState, useEffect, useCallback } from 'react';
import {
Box,
Layers,
Trash2,
RefreshCw,
FileText,
FolderOpen,
ChevronDown,
ChevronRight,
Eye,
Download
} from 'lucide-react';
interface FileInfo {
id: string;
name: string;
format: string;
size: string;
path?: string;
modifiedAt?: string;
}
interface FileManagerProps {
onSelectCAD?: (file: FileInfo) => void;
onSelectPointCloud?: (files: FileInfo[]) => void;
currentCAD?: string; // 当前加载的 CAD 文件名
currentPointClouds?: string[]; // 当前加载的点云文件名列表
className?: string;
}
export default function FileManager({
onSelectCAD,
onSelectPointCloud,
currentCAD,
currentPointClouds = [],
className = ''
}: FileManagerProps) {
const [cadFiles, setCadFiles] = useState<FileInfo[]>([]);
const [pointCloudFiles, setPointCloudFiles] = useState<FileInfo[]>([]);
const [isLoadingCAD, setIsLoadingCAD] = useState(false);
const [isLoadingPC, setIsLoadingPC] = useState(false);
const [expandedCAD, setExpandedCAD] = useState(true);
const [expandedPC, setExpandedPC] = useState(true);
const [selectedPCFiles, setSelectedPCFiles] = useState<string[]>([]);
// 加载 CAD 文件列表
const loadCADFiles = useCallback(async () => {
setIsLoadingCAD(true);
try {
const response = await fetch('/api/files/cad');
if (response.ok) {
const data = await response.json();
setCadFiles(data.files || []);
}
} catch (err) {
console.error('Failed to load CAD files:', err);
} finally {
setIsLoadingCAD(false);
}
}, []);
// 加载点云文件列表
const loadPointCloudFiles = useCallback(async () => {
setIsLoadingPC(true);
try {
const response = await fetch('/api/files/pointcloud');
if (response.ok) {
const data = await response.json();
setPointCloudFiles(data.files || []);
}
} catch (err) {
console.error('Failed to load point cloud files:', err);
} finally {
setIsLoadingPC(false);
}
}, []);
// 初始加载
useEffect(() => {
loadCADFiles();
loadPointCloudFiles();
}, [loadCADFiles, loadPointCloudFiles]);
// 删除 CAD 文件
const deleteCADFile = async (file: FileInfo) => {
if (!confirm(`确定要删除 ${file.name} 吗?`)) return;
try {
const response = await fetch(`/api/files/delete/cad/${encodeURIComponent(file.name)}`, {
method: 'DELETE'
});
if (response.ok) {
setCadFiles(prev => prev.filter(f => f.id !== file.id));
}
} catch (err) {
console.error('Failed to delete CAD file:', err);
}
};
// 删除点云文件
const deletePointCloudFile = async (file: FileInfo) => {
if (!confirm(`确定要删除 ${file.name} 吗?`)) return;
try {
const response = await fetch(`/api/files/delete/pointcloud/${encodeURIComponent(file.name)}`, {
method: 'DELETE'
});
if (response.ok) {
setPointCloudFiles(prev => prev.filter(f => f.id !== file.id));
}
} catch (err) {
console.error('Failed to delete point cloud file:', err);
}
};
// 切换点云文件选择
const togglePCSelection = (fileId: string) => {
setSelectedPCFiles(prev =>
prev.includes(fileId)
? prev.filter(id => id !== fileId)
: [...prev, fileId]
);
};
// 加载选中的点云文件
const loadSelectedPointClouds = () => {
const selected = pointCloudFiles.filter(f => selectedPCFiles.includes(f.id));
if (selected.length > 0 && onSelectPointCloud) {
onSelectPointCloud(selected);
}
};
return (
<div className={`space-y-3 ${className}`}>
{/* CAD 文件管理 */}
<div className="industrial-panel">
<div
className="flex items-center justify-between p-3 cursor-pointer hover:bg-secondary/30"
onClick={() => setExpandedCAD(!expandedCAD)}
>
<div className="flex items-center gap-2">
{expandedCAD ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
<Box className="w-4 h-4 text-primary" />
<span className="text-sm font-medium">CAD 模型</span>
<span className="text-xs text-muted-foreground">({cadFiles.length})</span>
</div>
<button
className="p-1 hover:bg-secondary rounded"
onClick={(e) => { e.stopPropagation(); loadCADFiles(); }}
disabled={isLoadingCAD}
>
<RefreshCw className={`w-3.5 h-3.5 ${isLoadingCAD ? 'animate-spin' : ''}`} />
</button>
</div>
{expandedCAD && (
<div className="px-3 pb-3 space-y-1.5 max-h-48 overflow-y-auto">
{cadFiles.length === 0 ? (
<div className="text-xs text-muted-foreground text-center py-4">
暂无 CAD 文件
</div>
) : (
cadFiles.map(file => (
<div
key={file.id}
className={`flex items-center justify-between p-2 rounded text-xs ${
currentCAD === file.name
? 'bg-primary/20 border border-primary/30'
: 'bg-secondary/50 hover:bg-secondary'
}`}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<FileText className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
<span className="truncate" title={file.name}>{file.name}</span>
<span className="text-[10px] px-1 py-0.5 rounded bg-muted text-muted-foreground flex-shrink-0">
{file.format}
</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
<span className="text-muted-foreground mr-1">{file.size}</span>
{currentCAD !== file.name && onSelectCAD && (
<button
className="p-1 hover:bg-primary/20 rounded"
onClick={() => onSelectCAD(file)}
title="加载此模型"
>
<Eye className="w-3 h-3" />
</button>
)}
<button
className="p-1 hover:bg-destructive/20 rounded text-destructive/70 hover:text-destructive"
onClick={() => deleteCADFile(file)}
title="删除文件"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
))
)}
</div>
)}
</div>
{/* 点云文件管理 */}
<div className="industrial-panel">
<div
className="flex items-center justify-between p-3 cursor-pointer hover:bg-secondary/30"
onClick={() => setExpandedPC(!expandedPC)}
>
<div className="flex items-center gap-2">
{expandedPC ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
<Layers className="w-4 h-4 text-primary" />
<span className="text-sm font-medium">点云数据</span>
<span className="text-xs text-muted-foreground">({pointCloudFiles.length})</span>
</div>
<button
className="p-1 hover:bg-secondary rounded"
onClick={(e) => { e.stopPropagation(); loadPointCloudFiles(); }}
disabled={isLoadingPC}
>
<RefreshCw className={`w-3.5 h-3.5 ${isLoadingPC ? 'animate-spin' : ''}`} />
</button>
</div>
{expandedPC && (
<div className="px-3 pb-3 space-y-1.5">
{pointCloudFiles.length === 0 ? (
<div className="text-xs text-muted-foreground text-center py-4">
暂无点云文件
</div>
) : (
<>
<div className="max-h-48 overflow-y-auto space-y-1.5">
{pointCloudFiles.map(file => {
const isLoaded = currentPointClouds.includes(file.name);
const isSelected = selectedPCFiles.includes(file.id);
return (
<div
key={file.id}
className={`flex items-center justify-between p-2 rounded text-xs ${
isLoaded
? 'bg-primary/20 border border-primary/30'
: isSelected
? 'bg-secondary border border-primary/50'
: 'bg-secondary/50 hover:bg-secondary'
}`}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<input
type="checkbox"
checked={isSelected || isLoaded}
disabled={isLoaded}
onChange={() => togglePCSelection(file.id)}
className="w-3.5 h-3.5 rounded border-muted-foreground"
onClick={e => e.stopPropagation()}
/>
<FileText className="w-3.5 h-3.5 text-muted-foreground flex-shrink-0" />
<span className="truncate" title={file.name}>{file.name}</span>
<span className="text-[10px] px-1 py-0.5 rounded bg-muted text-muted-foreground flex-shrink-0">
{file.format}
</span>
</div>
<div className="flex items-center gap-1 flex-shrink-0 ml-2">
<span className="text-muted-foreground mr-1">{file.size}</span>
<button
className="p-1 hover:bg-destructive/20 rounded text-destructive/70 hover:text-destructive"
onClick={() => deletePointCloudFile(file)}
title="删除文件"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
);
})}
</div>
{/* 加载选中的点云按钮 */}
{selectedPCFiles.length > 0 && onSelectPointCloud && (
<button
className="industrial-btn industrial-btn-primary w-full mt-2 flex items-center justify-center gap-2"
onClick={loadSelectedPointClouds}
>
<Layers className="w-3.5 h-3.5" />
加载选中的 {selectedPCFiles.length} 个文件
</button>
)}
</>
)}
</div>
)}
</div>
{/* 当前加载状态摘要 */}
{(currentCAD || currentPointClouds.length > 0) && (
<div className="industrial-panel p-3">
<div className="industrial-label mb-2">当前加载</div>
<div className="space-y-1.5 text-xs">
{currentCAD && (
<div className="flex items-center gap-2">
<Box className="w-3.5 h-3.5 text-primary" />
<span className="truncate text-muted-foreground" title={currentCAD}>
{currentCAD}
</span>
</div>
)}
{currentPointClouds.length > 0 && (
<div className="flex items-center gap-2">
<Layers className="w-3.5 h-3.5 text-primary" />
<span className="text-muted-foreground">
{currentPointClouds.length} 个点云文件
</span>
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,252 @@
import { useState, useCallback, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Upload, File, CheckCircle2, XCircle, Loader2, FolderOpen } from 'lucide-react';
interface UploadFile {
id: string;
file: File;
progress: number;
status: 'pending' | 'uploading' | 'completed' | 'error';
error?: string;
}
interface FileUploaderProps {
accept?: string;
multiple?: boolean;
maxFileSize?: number; // in bytes
onFilesSelected?: (files: File[]) => void;
onUploadComplete?: (files: UploadFile[]) => void;
title?: string;
description?: string;
className?: string;
}
export default function FileUploader({
accept = '.ply,.pcd,.las,.laz,.xyz,.pts',
multiple = true,
maxFileSize = 5 * 1024 * 1024 * 1024, // 5GB default
onFilesSelected,
onUploadComplete,
title = '上传点云数据',
description = '支持 PLY, PCD, LAS, LAZ, XYZ, PTS 格式',
className = ''
}: FileUploaderProps) {
const [files, setFiles] = useState<UploadFile[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
};
const handleFileSelect = useCallback((selectedFiles: FileList | null) => {
if (!selectedFiles) return;
const newFiles: UploadFile[] = Array.from(selectedFiles).map((file, index) => ({
id: `${Date.now()}-${index}`,
file,
progress: 0,
status: 'pending' as const
}));
setFiles(prev => [...prev, ...newFiles]);
onFilesSelected?.(Array.from(selectedFiles));
}, [onFilesSelected]);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
handleFileSelect(e.dataTransfer.files);
}, [handleFileSelect]);
const simulateUpload = useCallback(async () => {
setIsUploading(true);
const updatedFiles = [...files];
for (let i = 0; i < updatedFiles.length; i++) {
if (updatedFiles[i].status !== 'pending') continue;
updatedFiles[i].status = 'uploading';
setFiles([...updatedFiles]);
// Simulate chunked upload progress
for (let progress = 0; progress <= 100; progress += Math.random() * 15 + 5) {
await new Promise(resolve => setTimeout(resolve, 100 + Math.random() * 200));
updatedFiles[i].progress = Math.min(progress, 100);
setFiles([...updatedFiles]);
}
updatedFiles[i].progress = 100;
updatedFiles[i].status = 'completed';
setFiles([...updatedFiles]);
}
setIsUploading(false);
onUploadComplete?.(updatedFiles);
}, [files, onUploadComplete]);
const removeFile = useCallback((id: string) => {
setFiles(prev => prev.filter(f => f.id !== id));
}, []);
const clearCompleted = useCallback(() => {
setFiles(prev => prev.filter(f => f.status !== 'completed'));
}, []);
const totalSize = files.reduce((sum, f) => sum + f.file.size, 0);
const completedCount = files.filter(f => f.status === 'completed').length;
const overallProgress = files.length > 0
? files.reduce((sum, f) => sum + f.progress, 0) / files.length
: 0;
return (
<Card className={`glass-panel border-border/50 ${className}`}>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<Upload className="w-5 h-5 text-primary" />
{title}
</CardTitle>
<p className="text-sm text-muted-foreground">{description}</p>
</CardHeader>
<CardContent className="space-y-4">
{/* Drop zone */}
<div
className={`
relative border-2 border-dashed rounded-lg p-8 text-center transition-all cursor-pointer
${isDragging
? 'border-primary bg-primary/10'
: 'border-border hover:border-primary/50 hover:bg-accent/30'
}
`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept={accept}
multiple={multiple}
className="hidden"
onChange={(e) => handleFileSelect(e.target.files)}
/>
<FolderOpen className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
拖拽文件到此处,或 <span className="text-primary">点击选择文件</span>
</p>
<p className="text-xs text-muted-foreground mt-2">
单个文件最大 {formatFileSize(maxFileSize)}
</p>
</div>
{/* File list */}
{files.length > 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{files.length} 个文件 · 总计 {formatFileSize(totalSize)}
</span>
{completedCount > 0 && (
<Button variant="ghost" size="sm" onClick={clearCompleted}>
清除已完成
</Button>
)}
</div>
{/* Overall progress */}
{isUploading && (
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>总体进度</span>
<span>{overallProgress.toFixed(0)}%</span>
</div>
<Progress value={overallProgress} className="h-2" />
</div>
)}
{/* Individual files */}
<div className="max-h-48 overflow-y-auto space-y-2">
{files.map((file) => (
<div
key={file.id}
className="flex items-center gap-3 p-3 rounded-lg bg-secondary/30"
>
<File className="w-5 h-5 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="text-sm truncate">{file.file.name}</span>
<span className="text-xs text-muted-foreground ml-2">
{formatFileSize(file.file.size)}
</span>
</div>
{file.status === 'uploading' && (
<Progress value={file.progress} className="h-1 mt-1" />
)}
</div>
<div className="flex-shrink-0">
{file.status === 'pending' && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => { e.stopPropagation(); removeFile(file.id); }}
>
<XCircle className="w-4 h-4 text-muted-foreground" />
</Button>
)}
{file.status === 'uploading' && (
<Loader2 className="w-4 h-4 animate-spin text-primary" />
)}
{file.status === 'completed' && (
<CheckCircle2 className="w-4 h-4 text-green-500" />
)}
{file.status === 'error' && (
<XCircle className="w-4 h-4 text-destructive" />
)}
</div>
</div>
))}
</div>
{/* Upload button */}
<Button
className="w-full"
disabled={isUploading || files.every(f => f.status === 'completed')}
onClick={simulateUpload}
>
{isUploading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
上传中...
</>
) : (
<>
<Upload className="w-4 h-4 mr-2" />
开始上传 ({files.filter(f => f.status === 'pending').length} 个文件)
</>
)}
</Button>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,219 @@
import { useState } from 'react';
import { History, Calendar, FileText, Download, Eye, Trash2, Clock, HardDrive } from 'lucide-react';
interface HistoryRecord {
id: string;
name: string;
date: string;
time: string;
cadModel: string;
pointCount: number;
chunkCount: number;
compressedSize: string;
status: 'completed' | 'partial';
}
interface HistoryBrowserProps {
onLoadHistory: (record: HistoryRecord) => void;
isLoading: boolean;
loadProgress: { decompress: number; report: number };
className?: string;
}
const demoHistory: HistoryRecord[] = [
{
id: '1',
name: '零件A检测报告',
date: '2026-01-27',
time: '14:32:15',
cadModel: 'reference_model.stl',
pointCount: 1000000000,
chunkCount: 48,
compressedSize: '9.6 GB',
status: 'completed'
},
{
id: '2',
name: '零件B检测报告',
date: '2026-01-26',
time: '09:15:42',
cadModel: 'assembly_part_a.obj',
pointCount: 850000000,
chunkCount: 42,
compressedSize: '8.2 GB',
status: 'completed'
},
{
id: '3',
name: '外壳检测报告',
date: '2026-01-25',
time: '16:48:33',
cadModel: 'housing_v2.stl',
pointCount: 1200000000,
chunkCount: 56,
compressedSize: '11.4 GB',
status: 'partial'
},
];
export default function HistoryBrowser({
onLoadHistory,
isLoading,
loadProgress,
className = ''
}: HistoryBrowserProps) {
const [selectedId, setSelectedId] = useState<string | null>(null);
const handleLoad = (record: HistoryRecord) => {
setSelectedId(record.id);
onLoadHistory(record);
};
const formatPointCount = (count: number) => {
if (count >= 1e9) return `${(count / 1e9).toFixed(1)}B`;
if (count >= 1e6) return `${(count / 1e6).toFixed(0)}M`;
return count.toLocaleString();
};
return (
<div className={`space-y-4 ${className}`}>
{/* 搜索和筛选 */}
<div className="industrial-panel p-4">
<div className="flex items-center gap-3">
<div className="flex-1 relative">
<input
type="text"
placeholder="搜索历史记录..."
className="w-full bg-secondary border border-border rounded px-3 py-2 text-sm focus:outline-none focus:border-primary"
/>
</div>
<button className="industrial-btn flex items-center gap-1.5">
<Calendar className="w-3.5 h-3.5" />
日期
</button>
</div>
</div>
{/* 历史记录列表 */}
<div className="industrial-panel p-4">
<div className="flex items-center justify-between mb-3">
<span className="industrial-label">历史记录</span>
<span className="text-xs text-muted-foreground">{demoHistory.length} 条记录</span>
</div>
<div className="space-y-2">
{demoHistory.map((record) => (
<div
key={record.id}
className={`p-3 rounded border transition-all ${
selectedId === record.id
? 'bg-primary/10 border-primary/30'
: 'bg-secondary/30 border-transparent hover:bg-secondary/50'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm font-medium truncate">{record.name}</span>
{record.status === 'partial' && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[oklch(0.72_0.16_85/0.2)] text-[oklch(0.72_0.16_85)]">
部分
</span>
)}
</div>
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{record.date} {record.time}
</span>
<span className="flex items-center gap-1">
<HardDrive className="w-3 h-3" />
{record.compressedSize}
</span>
</div>
<div className="flex items-center gap-4 mt-1 text-xs text-muted-foreground">
<span>CAD: {record.cadModel}</span>
<span>点云: {formatPointCount(record.pointCount)}</span>
<span>{record.chunkCount} 块</span>
</div>
</div>
<div className="flex items-center gap-1">
<button
className="industrial-btn py-1.5 px-2"
onClick={() => handleLoad(record)}
disabled={isLoading}
>
<Eye className="w-3.5 h-3.5" />
</button>
<button className="industrial-btn py-1.5 px-2">
<Download className="w-3.5 h-3.5" />
</button>
<button className="industrial-btn py-1.5 px-2 hover:text-destructive">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* 加载进度 */}
{selectedId === record.id && isLoading && (
<div className="mt-3 space-y-3">
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-muted-foreground">解压数据</span>
<span className="font-mono">{loadProgress.decompress}%</span>
</div>
<div className="industrial-progress h-4">
<div
className="industrial-progress-bar"
style={{ width: `${loadProgress.decompress}%` }}
/>
</div>
</div>
<div>
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-muted-foreground">加载报表</span>
<span className="font-mono">{loadProgress.report}%</span>
</div>
<div className="industrial-progress h-4">
<div
className="industrial-progress-bar"
style={{ width: `${loadProgress.report}%` }}
/>
</div>
</div>
</div>
)}
</div>
))}
</div>
</div>
{/* 存储统计 */}
<div className="industrial-panel p-4">
<div className="industrial-label mb-3">存储统计</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground">总记录数</span>
<span className="font-mono">{demoHistory.length}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">总存储</span>
<span className="font-mono">29.2 GB</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">已用空间</span>
<span className="font-mono">58.4%</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">可用空间</span>
<span className="font-mono">20.8 GB</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,258 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import {
Database,
Box,
Activity,
Target,
Layers,
Clock,
HardDrive,
Cpu
} from 'lucide-react';
interface SelectedPointInfo {
position: { x: number; y: number; z: number };
error: number;
index: number;
}
interface ProjectStats {
totalPoints: number;
totalChunks: number;
uploadedChunks: number;
totalSize: number;
lodLevels: number;
status: 'uploading' | 'processing' | 'ready' | 'error';
}
interface InfoPanelProps {
projectName?: string;
projectStats?: ProjectStats;
selectedPoint?: SelectedPointInfo | null;
cadModelInfo?: {
name: string;
format: string;
vertices: number;
faces: number;
} | null;
errorStats?: {
min: number;
max: number;
mean: number;
stdDev: number;
} | null;
className?: string;
}
function formatNumber(num: number): string {
if (num >= 1e9) return `${(num / 1e9).toFixed(2)}B`;
if (num >= 1e6) return `${(num / 1e6).toFixed(2)}M`;
if (num >= 1e3) return `${(num / 1e3).toFixed(1)}K`;
return num.toString();
}
function formatBytes(bytes: number): string {
if (bytes >= 1e12) return `${(bytes / 1e12).toFixed(2)} TB`;
if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(2)} GB`;
if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)} MB`;
if (bytes >= 1e3) return `${(bytes / 1e3).toFixed(1)} KB`;
return `${bytes} B`;
}
function StatusBadge({ status }: { status: string }) {
const variants: Record<string, { variant: 'default' | 'secondary' | 'destructive' | 'outline'; label: string }> = {
uploading: { variant: 'secondary', label: '上传中' },
processing: { variant: 'default', label: '处理中' },
ready: { variant: 'outline', label: '就绪' },
error: { variant: 'destructive', label: '错误' }
};
const config = variants[status] || { variant: 'secondary' as const, label: status };
return (
<Badge variant={config.variant} className="text-xs">
{config.label}
</Badge>
);
}
export default function InfoPanel({
projectName = '演示项目',
projectStats,
selectedPoint,
cadModelInfo,
errorStats,
className = ''
}: InfoPanelProps) {
const defaultStats: ProjectStats = {
totalPoints: 1000000000,
totalChunks: 48,
uploadedChunks: 48,
totalSize: 32000000000,
lodLevels: 8,
status: 'ready'
};
const stats = projectStats || defaultStats;
return (
<div className={`space-y-4 ${className}`}>
{/* Project Info */}
<Card className="glass-panel border-border/50">
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center justify-between">
<span className="flex items-center gap-2">
<Database className="w-4 h-4 text-primary" />
项目信息
</span>
<StatusBadge status={stats.status} />
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-lg font-semibold gradient-text">{projectName}</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2">
<Target className="w-4 h-4 text-muted-foreground" />
<div>
<div className="text-muted-foreground text-xs">点云数量</div>
<div className="font-medium">{formatNumber(stats.totalPoints)}</div>
</div>
</div>
<div className="flex items-center gap-2">
<HardDrive className="w-4 h-4 text-muted-foreground" />
<div>
<div className="text-muted-foreground text-xs">数据大小</div>
<div className="font-medium">{formatBytes(stats.totalSize)}</div>
</div>
</div>
<div className="flex items-center gap-2">
<Layers className="w-4 h-4 text-muted-foreground" />
<div>
<div className="text-muted-foreground text-xs">数据块</div>
<div className="font-medium">{stats.uploadedChunks}/{stats.totalChunks}</div>
</div>
</div>
<div className="flex items-center gap-2">
<Cpu className="w-4 h-4 text-muted-foreground" />
<div>
<div className="text-muted-foreground text-xs">LOD层级</div>
<div className="font-medium">{stats.lodLevels} 级</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* CAD Model Info */}
{cadModelInfo && (
<Card className="glass-panel border-border/50">
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Box className="w-4 h-4 text-primary" />
CAD模型
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">名称</span>
<span className="font-medium">{cadModelInfo.name}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">格式</span>
<span className="font-medium uppercase">{cadModelInfo.format}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">顶点数</span>
<span className="font-medium">{formatNumber(cadModelInfo.vertices)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">面片数</span>
<span className="font-medium">{formatNumber(cadModelInfo.faces)}</span>
</div>
</CardContent>
</Card>
)}
{/* Selected Point Info */}
{selectedPoint && (
<Card className="glass-panel border-border/50 border-primary/30">
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Target className="w-4 h-4 text-primary" />
选中点信息
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid grid-cols-3 gap-2 text-sm">
<div className="text-center p-2 rounded bg-secondary/30">
<div className="text-muted-foreground text-xs">X</div>
<div className="font-mono font-medium">{selectedPoint.position.x.toFixed(3)}</div>
</div>
<div className="text-center p-2 rounded bg-secondary/30">
<div className="text-muted-foreground text-xs">Y</div>
<div className="font-mono font-medium">{selectedPoint.position.y.toFixed(3)}</div>
</div>
<div className="text-center p-2 rounded bg-secondary/30">
<div className="text-muted-foreground text-xs">Z</div>
<div className="font-mono font-medium">{selectedPoint.position.z.toFixed(3)}</div>
</div>
</div>
<Separator />
<div className="flex justify-between items-center">
<span className="text-muted-foreground text-sm">误差值</span>
<span className={`font-mono font-bold ${
selectedPoint.error > 1.5 ? 'text-red-400' :
selectedPoint.error > 0.8 ? 'text-yellow-400' : 'text-green-400'
}`}>
{selectedPoint.error.toFixed(4)} mm
</span>
</div>
<div className="flex justify-between items-center text-sm">
<span className="text-muted-foreground">点索引</span>
<span className="font-mono">{selectedPoint.index}</span>
</div>
</CardContent>
</Card>
)}
{/* Error Statistics */}
{errorStats && (
<Card className="glass-panel border-border/50">
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Activity className="w-4 h-4 text-primary" />
误差统计
</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">最小误差</span>
<span className="font-mono text-green-400">{errorStats.min.toFixed(4)} mm</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">最大误差</span>
<span className="font-mono text-red-400">{errorStats.max.toFixed(4)} mm</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">平均误差</span>
<span className="font-mono text-yellow-400">{errorStats.mean.toFixed(4)} mm</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">标准差</span>
<span className="font-mono">{errorStats.stdDev.toFixed(4)} mm</span>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,89 @@
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
} from "@/components/ui/dialog";
interface ManusDialogProps {
title?: string;
logo?: string;
open?: boolean;
onLogin: () => void;
onOpenChange?: (open: boolean) => void;
onClose?: () => void;
}
export function ManusDialog({
title,
logo,
open = false,
onLogin,
onOpenChange,
onClose,
}: ManusDialogProps) {
const [internalOpen, setInternalOpen] = useState(open);
useEffect(() => {
if (!onOpenChange) {
setInternalOpen(open);
}
}, [open, onOpenChange]);
const handleOpenChange = (nextOpen: boolean) => {
if (onOpenChange) {
onOpenChange(nextOpen);
} else {
setInternalOpen(nextOpen);
}
if (!nextOpen) {
onClose?.();
}
};
return (
<Dialog
open={onOpenChange ? open : internalOpen}
onOpenChange={handleOpenChange}
>
<DialogContent className="py-5 bg-[#f8f8f7] rounded-[20px] w-[400px] shadow-[0px_4px_11px_0px_rgba(0,0,0,0.08)] border border-[rgba(0,0,0,0.08)] backdrop-blur-2xl p-0 gap-0 text-center">
<div className="flex flex-col items-center gap-2 p-5 pt-12">
{logo ? (
<div className="w-16 h-16 bg-white rounded-xl border border-[rgba(0,0,0,0.08)] flex items-center justify-center">
<img
src={logo}
alt="Dialog graphic"
className="w-10 h-10 rounded-md"
/>
</div>
) : null}
{/* Title and subtitle */}
{title ? (
<DialogTitle className="text-xl font-semibold text-[#34322d] leading-[26px] tracking-[-0.44px]">
{title}
</DialogTitle>
) : null}
<DialogDescription className="text-sm text-[#858481] leading-5 tracking-[-0.154px]">
Please login with Manus to continue
</DialogDescription>
</div>
<DialogFooter className="px-5 py-5">
{/* Login button */}
<Button
onClick={onLogin}
className="w-full h-10 bg-[#1a1a19] hover:bg-[#1a1a19]/90 text-white rounded-[10px] text-sm font-medium leading-5 tracking-[-0.154px]"
>
Login with Manus
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,155 @@
/**
* GOOGLE MAPS FRONTEND INTEGRATION - ESSENTIAL GUIDE
*
* USAGE FROM PARENT COMPONENT:
* ======
*
* const mapRef = useRef<google.maps.Map | null>(null);
*
* <MapView
* initialCenter={{ lat: 40.7128, lng: -74.0060 }}
* initialZoom={15}
* onMapReady={(map) => {
* mapRef.current = map; // Store to control map from parent anytime, google map itself is in charge of the re-rendering, not react state.
* </MapView>
*
* ======
* Available Libraries and Core Features:
* -------------------------------
* 📍 MARKER (from `marker` library)
* - Attaches to map using { map, position }
* new google.maps.marker.AdvancedMarkerElement({
* map,
* position: { lat: 37.7749, lng: -122.4194 },
* title: "San Francisco",
* });
*
* -------------------------------
* 🏢 PLACES (from `places` library)
* - Does not attach directly to map; use data with your map manually.
* const place = new google.maps.places.Place({ id: PLACE_ID });
* await place.fetchFields({ fields: ["displayName", "location"] });
* map.setCenter(place.location);
* new google.maps.marker.AdvancedMarkerElement({ map, position: place.location });
*
* -------------------------------
* 🧭 GEOCODER (from `geocoding` library)
* - Standalone service; manually apply results to map.
* const geocoder = new google.maps.Geocoder();
* geocoder.geocode({ address: "New York" }, (results, status) => {
* if (status === "OK" && results[0]) {
* map.setCenter(results[0].geometry.location);
* new google.maps.marker.AdvancedMarkerElement({
* map,
* position: results[0].geometry.location,
* });
* }
* });
*
* -------------------------------
* 📐 GEOMETRY (from `geometry` library)
* - Pure utility functions; not attached to map.
* const dist = google.maps.geometry.spherical.computeDistanceBetween(p1, p2);
*
* -------------------------------
* 🛣️ ROUTES (from `routes` library)
* - Combines DirectionsService (standalone) + DirectionsRenderer (map-attached)
* const directionsService = new google.maps.DirectionsService();
* const directionsRenderer = new google.maps.DirectionsRenderer({ map });
* directionsService.route(
* { origin, destination, travelMode: "DRIVING" },
* (res, status) => status === "OK" && directionsRenderer.setDirections(res)
* );
*
* -------------------------------
* 🌦️ MAP LAYERS (attach directly to map)
* - new google.maps.TrafficLayer().setMap(map);
* - new google.maps.TransitLayer().setMap(map);
* - new google.maps.BicyclingLayer().setMap(map);
*
* -------------------------------
* ✅ SUMMARY
* - “map-attached” → AdvancedMarkerElement, DirectionsRenderer, Layers.
* - “standalone” → Geocoder, DirectionsService, DistanceMatrixService, ElevationService.
* - “data-only” → Place, Geometry utilities.
*/
/// <reference types="@types/google.maps" />
import { useEffect, useRef } from "react";
import { usePersistFn } from "@/hooks/usePersistFn";
import { cn } from "@/lib/utils";
declare global {
interface Window {
google?: typeof google;
}
}
const API_KEY = import.meta.env.VITE_FRONTEND_FORGE_API_KEY;
const FORGE_BASE_URL =
import.meta.env.VITE_FRONTEND_FORGE_API_URL ||
"https://forge.butterfly-effect.dev";
const MAPS_PROXY_URL = `${FORGE_BASE_URL}/v1/maps/proxy`;
function loadMapScript() {
return new Promise(resolve => {
const script = document.createElement("script");
script.src = `${MAPS_PROXY_URL}/maps/api/js?key=${API_KEY}&v=weekly&libraries=marker,places,geocoding,geometry`;
script.async = true;
script.crossOrigin = "anonymous";
script.onload = () => {
resolve(null);
script.remove(); // Clean up immediately
};
script.onerror = () => {
console.error("Failed to load Google Maps script");
};
document.head.appendChild(script);
});
}
interface MapViewProps {
className?: string;
initialCenter?: google.maps.LatLngLiteral;
initialZoom?: number;
onMapReady?: (map: google.maps.Map) => void;
}
export function MapView({
className,
initialCenter = { lat: 37.7749, lng: -122.4194 },
initialZoom = 12,
onMapReady,
}: MapViewProps) {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<google.maps.Map | null>(null);
const init = usePersistFn(async () => {
await loadMapScript();
if (!mapContainer.current) {
console.error("Map container not found");
return;
}
map.current = new window.google.maps.Map(mapContainer.current, {
zoom: initialZoom,
center: initialCenter,
mapTypeControl: true,
fullscreenControl: true,
zoomControl: true,
streetViewControl: true,
mapId: "DEMO_MAP_ID",
});
if (onMapReady) {
onMapReady(map.current);
}
});
useEffect(() => {
init();
}, [init]);
return (
<div ref={mapContainer} className={cn("w-full h-[500px]", className)} />
);
}

View File

@@ -0,0 +1,110 @@
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Ruler, CheckCircle2, AlertTriangle, XCircle } from 'lucide-react';
interface Measurement {
name: string;
value: number;
unit: string;
tolerance: number;
status: 'pass' | 'warning' | 'fail';
}
interface MeasurementResultsProps {
measurements: Measurement[];
className?: string;
}
function StatusIcon({ status }: { status: string }) {
switch (status) {
case 'pass':
return <CheckCircle2 className="w-4 h-4 text-green-400" />;
case 'warning':
return <AlertTriangle className="w-4 h-4 text-yellow-400" />;
case 'fail':
return <XCircle className="w-4 h-4 text-red-400" />;
default:
return null;
}
}
function StatusBadge({ status }: { status: string }) {
const config = {
pass: { variant: 'outline' as const, label: '合格', className: 'border-green-500/50 text-green-400' },
warning: { variant: 'outline' as const, label: '警告', className: 'border-yellow-500/50 text-yellow-400' },
fail: { variant: 'outline' as const, label: '不合格', className: 'border-red-500/50 text-red-400' }
};
const c = config[status as keyof typeof config] || config.pass;
return (
<Badge variant={c.variant} className={`text-xs ${c.className}`}>
{c.label}
</Badge>
);
}
export default function MeasurementResults({ measurements, className = '' }: MeasurementResultsProps) {
const passCount = measurements.filter(m => m.status === 'pass').length;
const warningCount = measurements.filter(m => m.status === 'warning').length;
const failCount = measurements.filter(m => m.status === 'fail').length;
return (
<Card className={`glass-panel border-border/50 ${className}`}>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center justify-between">
<span className="flex items-center gap-2">
<Ruler className="w-4 h-4 text-primary" />
测量结果
</span>
<div className="flex items-center gap-2 text-xs">
<span className="text-green-400">{passCount} 合格</span>
{warningCount > 0 && <span className="text-yellow-400">{warningCount} 警告</span>}
{failCount > 0 && <span className="text-red-400">{failCount} 不合格</span>}
</div>
</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-[280px] pr-4">
<div className="space-y-2">
{measurements.map((measurement, index) => (
<div
key={index}
className={`
p-3 rounded-lg bg-secondary/30 border
${measurement.status === 'fail' ? 'border-red-500/30' :
measurement.status === 'warning' ? 'border-yellow-500/30' :
'border-transparent'}
`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<StatusIcon status={measurement.status} />
<span className="text-sm font-medium">{measurement.name}</span>
</div>
<StatusBadge status={measurement.status} />
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground text-xs">测量值</span>
<div className="font-mono font-medium">
{measurement.value.toFixed(3)} {measurement.unit}
</div>
</div>
<div>
<span className="text-muted-foreground text-xs">公差</span>
<div className="font-mono">
±{measurement.tolerance} {measurement.unit}
</div>
</div>
</div>
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,177 @@
import { useRef, useState } from 'react';
import { trpc } from '@/lib/trpc';
import { Upload, Trash2, Box, FileBox, Eye, Loader2 } from 'lucide-react';
interface Props {
modelId: number;
/** cadFileId:用于 STEP 时拉取已解析的首件 STL 预览 */
onSelectCad: (url: string, format: string, name: string, cadFileId?: number) => void;
activeCadName?: string;
/** 当前 CAD 是否在 3D 中加载(网络/解析) */
cadLoading?: boolean;
cadLoadProgress?: number;
}
function formatSize(bytes: number) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1e6) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1e9) return (bytes / 1e6).toFixed(1) + ' MB';
return (bytes / 1e9).toFixed(2) + ' GB';
}
export default function ModelManager({
modelId,
onSelectCad,
activeCadName,
cadLoading = false,
cadLoadProgress = 0,
}: Props) {
const utils = trpc.useUtils();
const fileRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const { data: cadFiles = [] } = trpc.cadFile.list.useQuery({ modelId });
const deleteCad = trpc.cadFile.delete.useMutation({
onSuccess: () => utils.cadFile.list.invalidate({ modelId }),
});
async function handleUpload(file: File) {
setUploading(true);
setUploadProgress(0);
try {
const formData = new FormData();
formData.append('file', file);
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = e => {
if (e.lengthComputable) setUploadProgress(Math.round((e.loaded / e.total) * 100));
};
await new Promise<void>((resolve, reject) => {
xhr.onload = () => xhr.status < 300 ? resolve() : reject(new Error(xhr.responseText));
xhr.onerror = () => reject(new Error('Upload failed'));
xhr.open('POST', `/api/files/upload/cad/${modelId}`);
xhr.send(formData);
});
utils.cadFile.list.invalidate({ modelId });
} catch (e) {
console.error('CAD upload error', e);
alert('上传失败: ' + (e as Error).message);
} finally {
setUploading(false);
setUploadProgress(0);
}
}
return (
<div className="space-y-2">
{cadLoading && activeCadName && (
<div className="rounded border border-primary/40 bg-primary/5 px-2 py-1.5 space-y-1">
<div className="flex items-center gap-2 text-[10px] text-primary">
<Loader2 className="w-3 h-3 animate-spin flex-shrink-0" />
<span className="truncate">载入 3D:{activeCadName}</span>
<span className="font-mono flex-shrink-0">{Math.round(cadLoadProgress)}%</span>
</div>
<div className="industrial-progress h-1">
<div
className="industrial-progress-bar transition-[width] duration-150"
style={{ width: `${Math.min(100, Math.max(0, cadLoadProgress))}%` }}
/>
</div>
</div>
)}
<div className="flex items-center justify-between">
<span className="industrial-label flex items-center gap-1.5">
<FileBox className="w-3.5 h-3.5" /> CAD 文件
</span>
<button
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
onClick={() => fileRef.current?.click()}
disabled={uploading}
>
<Upload className="w-3 h-3" /> 上传 CAD
</button>
<input
ref={fileRef}
type="file"
accept=".obj,.stl,.step,.stp,.iges,.igs"
className="hidden"
onChange={e => e.target.files?.[0] && handleUpload(e.target.files[0])}
/>
</div>
{uploading && (
<div>
<div className="industrial-progress">
<div className="industrial-progress-bar" style={{ width: `${uploadProgress}%` }} />
</div>
<div className="text-[10px] text-muted-foreground mt-1 text-right">{uploadProgress}%</div>
</div>
)}
{cadFiles.length === 0 && !uploading && (
<div className="text-xs text-muted-foreground text-center py-4">
暂无 CAD 文件,点击上传
</div>
)}
<div className="space-y-1">
{cadFiles.map(f => {
const isActive = activeCadName === f.name;
const loadingThis = cadLoading && isActive;
return (
<div
key={f.id}
className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs group
${loadingThis ? 'ring-1 ring-primary/50 animate-pulse bg-primary/10' : ''}
${isActive && !loadingThis ? 'bg-primary/20 text-primary' : ''}
${!isActive ? 'industrial-card hover:bg-secondary/60' : ''}`}
>
{loadingThis ? (
<Loader2 className="w-3.5 h-3.5 flex-shrink-0 text-primary animate-spin" />
) : (
<Box className={`w-3.5 h-3.5 flex-shrink-0 ${isActive ? 'text-primary' : 'text-blue-400'}`} />
)}
<span className="flex-1 truncate">{f.name}</span>
{loadingThis ? (
<span className="text-[10px] text-primary font-mono flex-shrink-0">{Math.round(cadLoadProgress)}%</span>
) : (
<span className="text-muted-foreground text-[10px] flex-shrink-0">{formatSize(f.fileSize)}</span>
)}
<button
type="button"
className={`p-0.5 rounded hover:bg-secondary ${loadingThis ? 'block' : 'hidden group-hover:block'}`}
title={loadingThis ? '载入中…' : '在3D视图中显示'}
disabled={loadingThis}
onClick={() => onSelectCad(
`/api/files/read/cad/${modelId}/${encodeURIComponent(f.name)}`,
f.format,
f.name,
f.id,
)}
>
{loadingThis ? (
<Loader2 className="w-3 h-3 text-primary animate-spin" />
) : (
<Eye className="w-3 h-3 text-primary" />
)}
</button>
<button
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary text-destructive"
title="删除"
onClick={() => {
if (confirm(`删除 CAD 文件「${f.name}」?`)) {
deleteCad.mutate({ id: f.id });
}
}}
>
<Trash2 className="w-3 h-3" />
</button>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,397 @@
import { useState, useRef, useEffect } from 'react';
import { Layers, Upload, FileText, Check, Loader2, FolderOpen, RefreshCw, Folder, Trash2 } from 'lucide-react';
interface PointCloudFile {
id: string;
name: string;
format: string;
size: string;
path?: string;
pointCount?: number;
modifiedAt?: string;
}
interface PointCloudSelectorProps {
onFilesSelect: (files: PointCloudFile[]) => void;
isLoading: boolean;
loadProgress: number;
className?: string;
}
// 格式化文件大小
function formatFileSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
export default function PointCloudSelector({
onFilesSelect,
isLoading,
loadProgress,
className = ''
}: PointCloudSelectorProps) {
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [files, setFiles] = useState<PointCloudFile[]>([]);
const [isLoadingFiles, setIsLoadingFiles] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const folderInputRef = useRef<HTMLInputElement>(null);
// 加载本地文件夹中的点云文件
const loadLocalFiles = async () => {
setIsLoadingFiles(true);
setError(null);
try {
const response = await fetch('/api/files/pointcloud');
if (!response.ok) {
throw new Error('无法加载文件列表');
}
const data = await response.json();
setFiles(data.files || []);
} catch (err) {
console.error('Failed to load point cloud files:', err);
setError('无法加载文件列表,请检查数据目录配置');
// 显示空列表
setFiles([]);
} finally {
setIsLoadingFiles(false);
}
};
// 初始加载
useEffect(() => {
loadLocalFiles();
}, []);
const handleSelect = (file: PointCloudFile) => {
setSelectedFiles(prev => {
const newSelection = prev.includes(file.id)
? prev.filter(id => id !== file.id)
: [...prev, file.id];
return newSelection;
});
};
const handleSelectAll = () => {
if (selectedFiles.length === files.length) {
setSelectedFiles([]);
} else {
setSelectedFiles(files.map(f => f.id));
}
};
// 确认选择
const handleConfirm = () => {
const selected = files.filter(f => selectedFiles.includes(f.id));
if (selected.length > 0) {
onFilesSelect(selected);
}
};
// 删除文件
const handleDelete = async (file: PointCloudFile, e: React.MouseEvent) => {
e.stopPropagation(); // 防止触发选择
if (!confirm(`确定要删除 ${file.name} 吗?此操作不可恢复。`)) {
return;
}
try {
const response = await fetch(`/api/files/delete/pointcloud/${encodeURIComponent(file.name)}`, {
method: 'DELETE'
});
if (response.ok) {
setFiles(prev => prev.filter(f => f.id !== file.id));
// 如果删除的文件在选中列表中,也移除
setSelectedFiles(prev => prev.filter(id => id !== file.id));
} else {
setError('删除文件失败');
}
} catch (err) {
console.error('Failed to delete file:', err);
setError('删除文件失败');
}
};
// 上传文件到服务器
const uploadFile = async (file: File): Promise<PointCloudFile | null> => {
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/files/upload/pointcloud', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error('Upload failed');
}
const result = await response.json();
console.log('[PointCloudSelector] File uploaded:', result);
if (result.success && result.file) {
return {
id: result.file.id,
name: result.file.name,
format: result.file.format,
size: result.file.size,
path: result.file.path,
};
}
} catch (err) {
console.error('[PointCloudSelector] Upload error:', err);
}
return null;
};
// 处理文件选择
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const fileList = e.target.files;
if (!fileList || fileList.length === 0) return;
setIsLoadingFiles(true);
setError(null);
const uploadedFiles: PointCloudFile[] = [];
for (const file of Array.from(fileList)) {
const uploaded = await uploadFile(file);
if (uploaded) {
uploadedFiles.push(uploaded);
}
}
if (uploadedFiles.length > 0) {
setFiles(prev => [...uploadedFiles, ...prev]);
// 自动选中新上传的文件
setSelectedFiles(prev => [...prev, ...uploadedFiles.map(f => f.id)]);
}
setIsLoadingFiles(false);
// 清空 input
e.target.value = '';
};
// 处理文件拖放
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const fileList = e.dataTransfer.files;
if (!fileList || fileList.length === 0) return;
setIsLoadingFiles(true);
setError(null);
const uploadedFiles: PointCloudFile[] = [];
for (const file of Array.from(fileList)) {
const uploaded = await uploadFile(file);
if (uploaded) {
uploadedFiles.push(uploaded);
}
}
if (uploadedFiles.length > 0) {
setFiles(prev => [...uploadedFiles, ...prev]);
setSelectedFiles(prev => [...prev, ...uploadedFiles.map(f => f.id)]);
}
setIsLoadingFiles(false);
};
return (
<div className={`space-y-4 ${className}`}>
{/* 隐藏的文件输入 */}
<input
ref={fileInputRef}
type="file"
accept=".ply,.pcd,.las,.laz,.xyz,.pts,.txt"
multiple
onChange={handleFileSelect}
className="hidden"
/>
<input
ref={folderInputRef}
type="file"
accept=".ply,.pcd,.las,.laz,.xyz,.pts,.txt"
multiple
// @ts-ignore
webkitdirectory=""
onChange={handleFileSelect}
className="hidden"
/>
{/* 上传区域 */}
<div
className={`industrial-panel p-6 border-2 border-dashed transition-colors ${
isDragging ? 'border-primary bg-primary/5' : 'border-border'
}`}
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
>
<div className="flex flex-col items-center gap-3 text-center">
<div className="w-12 h-12 rounded-lg bg-secondary flex items-center justify-center">
<Upload className="w-6 h-6 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium">拖拽点云文件到此处</p>
<p className="text-xs text-muted-foreground mt-1">支持 PLY, PCD, LAS, LAZ, XYZ, PTS, TXT 格式</p>
</div>
<div className="flex items-center gap-2">
<button
className="industrial-btn"
onClick={() => fileInputRef.current?.click()}
>
<FolderOpen className="w-4 h-4 mr-1" />
选择文件
</button>
<button
className="industrial-btn"
onClick={() => folderInputRef.current?.click()}
>
<Folder className="w-4 h-4 mr-1" />
选择文件夹
</button>
</div>
</div>
</div>
{/* 已有文件列表 */}
<div className="industrial-panel p-4">
<div className="flex items-center justify-between mb-3">
<span className="industrial-label">本地点云文件</span>
<div className="flex items-center gap-2">
<button
className="text-xs text-primary hover:underline"
onClick={handleSelectAll}
>
{selectedFiles.length === files.length ? '取消全选' : '全选'}
</button>
<span className="text-xs text-muted-foreground">{files.length} 个文件</span>
<button
className="p-1 hover:bg-secondary rounded"
onClick={loadLocalFiles}
disabled={isLoadingFiles}
title="刷新文件列表"
>
<RefreshCw className={`w-3.5 h-3.5 ${isLoadingFiles ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
{error && (
<div className="text-xs text-amber-500 mb-2 p-2 bg-amber-500/10 rounded">
{error}
</div>
)}
<div className="space-y-2 max-h-64 overflow-y-auto">
{files.length === 0 && !isLoadingFiles && (
<div className="text-center py-8 text-muted-foreground text-sm">
暂无点云文件,请上传文件
</div>
)}
{files.map((file) => {
const isSelected = selectedFiles.includes(file.id);
return (
<div
key={file.id}
className={`p-3 rounded cursor-pointer transition-all ${
isSelected
? 'bg-primary/10 border border-primary/30'
: 'bg-secondary/50 border border-transparent hover:bg-secondary'
}`}
onClick={() => handleSelect(file)}
>
<div className="flex items-start gap-3">
<div className={`w-8 h-8 rounded flex items-center justify-center flex-shrink-0 ${
isSelected ? 'bg-primary' : 'bg-muted'
}`}>
{isSelected ? (
<Check className="w-4 h-4 text-white" />
) : (
<Layers className="w-4 h-4 text-muted-foreground" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium truncate">{file.name}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-secondary text-muted-foreground">
{file.format}
</span>
</div>
<div className="flex items-center gap-4 mt-1 text-xs text-muted-foreground">
<span>{file.size}</span>
{file.pointCount && <span>{file.pointCount.toLocaleString()} 点</span>}
</div>
</div>
{/* 删除按钮 */}
<button
className="p-1.5 rounded hover:bg-red-500/20 text-muted-foreground hover:text-red-500 transition-colors flex-shrink-0"
onClick={(e) => handleDelete(file, e)}
title="删除文件"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
);
})}
</div>
{/* 加载进度 */}
{isLoading && (
<div className="mt-3">
<div className="flex items-center justify-between text-xs mb-1">
<span className="text-muted-foreground">加载点云中...</span>
<span className="font-mono">{loadProgress}%</span>
</div>
<div className="industrial-progress h-4">
<div
className="industrial-progress-bar"
style={{ width: `${loadProgress}%` }}
/>
</div>
</div>
)}
</div>
{/* 确认选择按钮 */}
{selectedFiles.length > 0 && !isLoading && (
<button
className="industrial-btn industrial-btn-primary w-full flex items-center justify-center gap-2"
onClick={handleConfirm}
>
<Layers className="w-4 h-4" />
加载 {selectedFiles.length} 个点云文件
</button>
)}
{/* 选择摘要 */}
{selectedFiles.length > 0 && (
<div className="industrial-panel p-4">
<div className="industrial-label mb-3">已选择</div>
<div className="text-xs space-y-1">
<div className="flex justify-between">
<span className="text-muted-foreground">文件数量</span>
<span className="font-mono">{selectedFiles.length} 个</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">预计点数</span>
<span className="font-mono">~1,000,000,000</span>
</div>
</div>
</div>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,174 @@
import { useMemo } from 'react';
import { CheckCircle2, AlertTriangle, XCircle, TrendingUp, TrendingDown } from 'lucide-react';
interface MeasurementItem {
name: string;
value: number;
unit: string;
tolerance: number;
status: 'pass' | 'warning' | 'fail';
}
interface ErrorStats {
min: number;
max: number;
mean: number;
stdDev: number;
}
interface RealtimeReportProps {
processedChunks: number;
totalChunks: number;
measurements: MeasurementItem[];
errorStats: ErrorStats | null;
isProcessing: boolean;
className?: string;
}
export default function RealtimeReport({
processedChunks,
totalChunks,
measurements,
errorStats,
isProcessing,
className = ''
}: RealtimeReportProps) {
const visibleMeasurements = useMemo(() => {
const ratio = processedChunks / totalChunks;
const count = Math.floor(measurements.length * ratio);
return measurements.slice(0, count);
}, [processedChunks, totalChunks, measurements]);
const stats = useMemo(() => {
const pass = visibleMeasurements.filter(m => m.status === 'pass').length;
const warning = visibleMeasurements.filter(m => m.status === 'warning').length;
const fail = visibleMeasurements.filter(m => m.status === 'fail').length;
return { pass, warning, fail, total: visibleMeasurements.length };
}, [visibleMeasurements]);
return (
<div className={`space-y-3 ${className}`}>
{/* 误差统计卡片 */}
<div className="report-card">
<div className="report-card-header flex items-center justify-between">
<span>误差统计</span>
{isProcessing && (
<span className="text-[oklch(0.68_0.16_55)]">实时更新中...</span>
)}
</div>
<div className="report-card-body">
{errorStats ? (
<div className="grid grid-cols-2 gap-3">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">最小误差</span>
<span className="data-display py-0.5 px-2 text-xs text-[oklch(0.55_0.18_250)]">
{errorStats.min.toFixed(4)} mm
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">最大误差</span>
<span className="data-display py-0.5 px-2 text-xs text-[oklch(0.58_0.20_30)]">
{errorStats.max.toFixed(4)} mm
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">平均误差</span>
<span className="data-display py-0.5 px-2 text-xs text-[oklch(0.72_0.16_85)]">
{errorStats.mean.toFixed(4)} mm
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">标准差</span>
<span className="data-display py-0.5 px-2 text-xs">
{errorStats.stdDev.toFixed(4)} mm
</span>
</div>
</div>
) : (
<div className="text-center text-xs text-muted-foreground py-4">
等待数据处理...
</div>
)}
</div>
</div>
{/* 测量结果卡片 */}
<div className="report-card">
<div className="report-card-header flex items-center justify-between">
<span>测量结果</span>
<div className="flex items-center gap-3">
{stats.pass > 0 && (
<span className="flex items-center gap-1 text-[oklch(0.62_0.16_145)]">
<CheckCircle2 className="w-3 h-3" />
{stats.pass}
</span>
)}
{stats.warning > 0 && (
<span className="flex items-center gap-1 text-[oklch(0.72_0.16_85)]">
<AlertTriangle className="w-3 h-3" />
{stats.warning}
</span>
)}
{stats.fail > 0 && (
<span className="flex items-center gap-1 text-[oklch(0.55_0.20_28)]">
<XCircle className="w-3 h-3" />
{stats.fail}
</span>
)}
</div>
</div>
<div className="report-card-body max-h-48 overflow-y-auto">
{visibleMeasurements.length > 0 ? (
<div className="space-y-2">
{visibleMeasurements.map((item, index) => (
<div
key={index}
className={`flex items-center justify-between p-2 rounded border ${
item.status === 'pass' ? 'border-[oklch(0.62_0.16_145/0.3)] bg-[oklch(0.62_0.16_145/0.05)]' :
item.status === 'warning' ? 'border-[oklch(0.72_0.16_85/0.3)] bg-[oklch(0.72_0.16_85/0.05)]' :
'border-[oklch(0.55_0.20_28/0.3)] bg-[oklch(0.55_0.20_28/0.05)]'
}`}
>
<div className="flex items-center gap-2">
{item.status === 'pass' && <CheckCircle2 className="w-3.5 h-3.5 text-[oklch(0.62_0.16_145)]" />}
{item.status === 'warning' && <AlertTriangle className="w-3.5 h-3.5 text-[oklch(0.72_0.16_85)]" />}
{item.status === 'fail' && <XCircle className="w-3.5 h-3.5 text-[oklch(0.55_0.20_28)]" />}
<span className="text-xs">{item.name}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-mono">
{item.value.toFixed(3)} {item.unit}
</span>
<span className="text-xs text-muted-foreground">
±{item.tolerance}
</span>
</div>
</div>
))}
</div>
) : (
<div className="text-center text-xs text-muted-foreground py-4">
等待测量数据...
</div>
)}
</div>
</div>
{/* 热力图图例 */}
<div className="report-card">
<div className="report-card-header">误差热力图</div>
<div className="report-card-body">
<div className="heatmap-legend">
<span className="text-xs text-muted-foreground">负</span>
<div className="heatmap-gradient" />
<span className="text-xs text-muted-foreground">正</span>
</div>
<div className="flex items-center justify-between mt-2 text-xs text-muted-foreground">
<span>0.00 mm</span>
<span>2.50 mm</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,259 @@
import { useRef, useState } from 'react';
import { trpc } from '@/lib/trpc';
import {
Plus,
Trash2,
Upload,
ClipboardList,
Cloud,
PlayCircle,
Check,
X,
} from 'lucide-react';
interface Props {
modelId: number;
activeTaskId: number | null;
onSelectTask: (taskId: number) => void;
onLoadPointClouds: (taskId: number, filePaths: string[]) => void;
onStartCreate?: () => void;
onEndCreate?: () => void;
}
function formatSize(bytes: number) {
if (bytes < 1e6) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1e9) return (bytes / 1e6).toFixed(1) + ' MB';
return (bytes / 1e9).toFixed(2) + ' GB';
}
export default function TaskManager({ modelId, activeTaskId, onSelectTask, onLoadPointClouds, onStartCreate, onEndCreate }: Props) {
const utils = trpc.useUtils();
const [adding, setAdding] = useState(false);
const [taskName, setTaskName] = useState('');
const [uploadingFor, setUploadingFor] = useState<number | null>(null);
const [uploadProgress, setUploadProgress] = useState(0);
const fileRef = useRef<HTMLInputElement>(null);
const pendingTaskId = useRef<number | null>(null);
const { data: tasks = [] } = trpc.inspectionTask.listByModel.useQuery({ modelId });
const createTask = trpc.inspectionTask.create.useMutation({
onSuccess: () => utils.inspectionTask.listByModel.invalidate({ modelId }),
});
const deleteTask = trpc.inspectionTask.delete.useMutation({
onSuccess: () => utils.inspectionTask.listByModel.invalidate({ modelId }),
});
// point cloud files for the active task
const { data: pcFiles = [], refetch: refetchPc } = trpc.pointcloudFile.list.useQuery(
{ taskId: activeTaskId! },
{ enabled: activeTaskId != null }
);
const deletePcFile = trpc.pointcloudFile.delete.useMutation({
onSuccess: () => utils.pointcloudFile.list.invalidate({ taskId: activeTaskId! }),
});
function openUpload(taskId: number) {
pendingTaskId.current = taskId;
fileRef.current?.click();
}
async function handleUpload(files: FileList) {
const taskId = pendingTaskId.current;
if (!taskId) return;
setUploadingFor(taskId);
setUploadProgress(0);
try {
for (let i = 0; i < files.length; i++) {
const file = files[i];
const fd = new FormData();
fd.append('file', file);
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = e => {
if (e.lengthComputable) {
const base = (i / files.length) * 100;
setUploadProgress(Math.round(base + (e.loaded / e.total) * (100 / files.length)));
}
};
await new Promise<void>((resolve, reject) => {
xhr.onload = () => xhr.status < 300 ? resolve() : reject(new Error(xhr.responseText));
xhr.onerror = () => reject(new Error('Upload failed'));
xhr.open('POST', `/api/files/upload/pointcloud/${taskId}`);
xhr.send(fd);
});
}
utils.pointcloudFile.list.invalidate({ taskId });
} catch (e) {
console.error('PC upload error', e);
alert('上传失败: ' + (e as Error).message);
} finally {
setUploadingFor(null);
setUploadProgress(0);
}
}
return (
<div className="space-y-3">
{/* 任务列表 */}
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="industrial-label flex items-center gap-1.5">
<ClipboardList className="w-3.5 h-3.5" /> 检测任务
</span>
<button
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
onClick={() => { setAdding(true); onStartCreate?.(); }}
>
<Plus className="w-3 h-3" /> 新建任务
</button>
</div>
{adding && (
<div className="flex items-center gap-1 mb-1.5">
<input
autoFocus
className="flex-1 bg-secondary text-xs px-2 py-1 rounded outline-none border border-primary"
placeholder="任务名称,如:2024年第3次出厂检测"
value={taskName}
onChange={e => setTaskName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && taskName.trim()) {
createTask.mutate({ modelId, name: taskName.trim() });
setTaskName('');
setAdding(false);
onEndCreate?.();
}
if (e.key === 'Escape') { setAdding(false); onEndCreate?.(); }
}}
/>
<button
className="text-green-400"
onClick={() => {
if (taskName.trim()) createTask.mutate({ modelId, name: taskName.trim() });
setAdding(false);
onEndCreate?.();
}}
>
<Check className="w-3.5 h-3.5" />
</button>
<button className="text-muted-foreground" onClick={() => { setAdding(false); onEndCreate?.(); }}>
<X className="w-3.5 h-3.5" />
</button>
</div>
)}
<div className="space-y-1">
{tasks.map(task => (
<div
key={task.id}
className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs group cursor-pointer
${activeTaskId === task.id ? 'bg-primary/20 text-primary' : 'industrial-card hover:bg-secondary/60'}`}
onClick={() => onSelectTask(task.id)}
>
<ClipboardList className={`w-3.5 h-3.5 flex-shrink-0 ${activeTaskId === task.id ? 'text-primary' : 'text-amber-400'}`} />
<span className="flex-1 truncate">{task.name}</span>
<span className={`text-[9px] px-1 py-0.5 rounded flex-shrink-0 ${
task.status === 'completed' ? 'bg-green-900/40 text-green-400' :
task.status === 'processing' ? 'bg-blue-900/40 text-blue-400' :
task.status === 'error' ? 'bg-red-900/40 text-red-400' :
'bg-secondary text-muted-foreground'
}`}>{task.status}</span>
<button
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary"
title="上传点云文件"
onClick={e => { e.stopPropagation(); openUpload(task.id); }}
>
<Upload className="w-3 h-3" />
</button>
<button
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary text-destructive"
title="删除任务"
onClick={e => {
e.stopPropagation();
if (confirm(`删除任务「${task.name}」?`)) deleteTask.mutate({ id: task.id });
}}
>
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
</div>
{tasks.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-4">
暂无任务,点击新建任务
</div>
)}
</div>
{/* 选中任务的点云文件 */}
{activeTaskId != null && (
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="industrial-label flex items-center gap-1.5">
<Cloud className="w-3.5 h-3.5" /> 点云文件
<span className="text-[10px] text-muted-foreground font-normal">({pcFiles.length})</span>
</span>
<div className="flex items-center gap-1">
<button
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
onClick={() => openUpload(activeTaskId)}
>
<Upload className="w-3 h-3" /> 上传
</button>
{pcFiles.length > 0 && (
<button
className="industrial-btn industrial-btn-primary py-0.5 text-[10px] flex items-center gap-0.5"
onClick={() => onLoadPointClouds(activeTaskId, pcFiles.map(f => f.filePath))}
>
<PlayCircle className="w-3 h-3" /> 加载渲染
</button>
)}
</div>
</div>
{uploadingFor === activeTaskId && (
<div className="mb-1">
<div className="industrial-progress">
<div className="industrial-progress-bar" style={{ width: `${uploadProgress}%` }} />
</div>
<div className="text-[10px] text-muted-foreground mt-0.5 text-right">{uploadProgress}%</div>
</div>
)}
<div className="space-y-1">
{pcFiles.map(f => (
<div key={f.id} className="flex items-center gap-2 px-2 py-1.5 industrial-card rounded text-xs group">
<Cloud className="w-3.5 h-3.5 flex-shrink-0 text-cyan-400" />
<span className="flex-1 truncate">{f.name}</span>
<span className="text-muted-foreground text-[10px] flex-shrink-0">{formatSize(f.fileSize)}</span>
<button
className="hidden group-hover:block p-0.5 rounded hover:bg-secondary text-destructive"
onClick={() => {
if (confirm(`删除点云文件「${f.name}」?`)) deletePcFile.mutate({ id: f.id });
}}
>
<Trash2 className="w-3 h-3" />
</button>
</div>
))}
{pcFiles.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-3">
暂无点云文件
</div>
)}
</div>
</div>
)}
{/* 隐藏的文件 input,支持多选 */}
<input
ref={fileRef}
type="file"
multiple
accept=".txt,.xyz,.pts,.ply,.pcd,.las,.laz"
className="hidden"
onChange={e => e.target.files && e.target.files.length > 0 && handleUpload(e.target.files)}
/>
</div>
);
}

View File

@@ -0,0 +1,291 @@
import { useMemo, useState } from 'react';
import { trpc } from '@/lib/trpc';
import {
ChevronRight,
ChevronDown,
Plus,
Trash2,
Train,
Box,
Check,
X,
} from 'lucide-react';
export type TreeSelection = {
seriesId: number | null;
modelId: number | null;
taskId: number | null;
};
interface Props {
selection: TreeSelection;
onSelect: (s: TreeSelection) => void;
}
function EditableText({
value,
onSave,
}: {
value: string;
onSave: (v: string) => void;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
if (!editing) {
return (
<span
className="flex-1 cursor-pointer hover:text-foreground truncate"
onDoubleClick={() => { setDraft(value); setEditing(true); }}
>
{value}
</span>
);
}
return (
<span className="flex items-center gap-1 flex-1">
<input
autoFocus
className="flex-1 bg-secondary text-xs px-1 py-0.5 rounded outline-none border border-primary"
value={draft}
onChange={e => setDraft(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter') { onSave(draft); setEditing(false); }
if (e.key === 'Escape') setEditing(false);
}}
/>
<button onClick={() => { onSave(draft); setEditing(false); }} className="text-green-400"><Check className="w-3 h-3" /></button>
<button onClick={() => setEditing(false)} className="text-muted-foreground"><X className="w-3 h-3" /></button>
</span>
);
}
export default function VehicleTree({ selection, onSelect }: Props) {
const utils = trpc.useUtils();
const [expandedSeries, setExpandedSeries] = useState<Set<number>>(new Set());
const [newSeriesName, setNewSeriesName] = useState('');
const [addingSeries, setAddingSeries] = useState(false);
const [addingModelFor, setAddingModelFor] = useState<number | null>(null);
const [newModelName, setNewModelName] = useState('');
const { data: seriesList = [] } = trpc.vehicleSeries.list.useQuery();
// 固定次数的 Hooks:用 trpc.useQueries(内部是单个 useQueries),禁止在 map 里调 useQuery
const modelQueryResults = trpc.useQueries((t) =>
seriesList.map((s) =>
t.vehicleModel.listBySeries(
{ seriesId: s.id },
{ enabled: expandedSeries.has(s.id) }
)
)
);
const createSeries = trpc.vehicleSeries.create.useMutation({
onSuccess: () => utils.vehicleSeries.list.invalidate(),
});
const deleteSeries = trpc.vehicleSeries.delete.useMutation({
onSuccess: () => utils.vehicleSeries.list.invalidate(),
});
const updateSeries = trpc.vehicleSeries.update.useMutation({
onSuccess: () => utils.vehicleSeries.list.invalidate(),
});
const createModel = trpc.vehicleModel.create.useMutation({
onSuccess: (_, vars) => utils.vehicleModel.listBySeries.invalidate({ seriesId: vars.seriesId }),
});
const deleteModel = trpc.vehicleModel.delete.useMutation({
onSuccess: () => {
seriesList.forEach(s => utils.vehicleModel.listBySeries.invalidate({ seriesId: s.id }));
},
});
const updateModel = trpc.vehicleModel.update.useMutation({
onSuccess: () => {
seriesList.forEach(s => utils.vehicleModel.listBySeries.invalidate({ seriesId: s.id }));
},
});
function toggleSeries(id: number) {
setExpandedSeries(prev => {
const n = new Set(prev);
n.has(id) ? n.delete(id) : n.add(id);
return n;
});
}
return (
<div className="industrial-panel p-2 space-y-1">
<div className="flex items-center justify-between mb-2">
<span className="industrial-label">车系 / 车型 / 任务</span>
<button
className="industrial-btn py-0.5 text-[10px] flex items-center gap-0.5"
onClick={() => setAddingSeries(true)}
>
<Plus className="w-3 h-3" /> 车系
</button>
</div>
{addingSeries && (
<div className="flex items-center gap-1 pl-1">
<input
autoFocus
className="flex-1 bg-secondary text-xs px-2 py-1 rounded outline-none border border-primary"
placeholder="车系名称"
value={newSeriesName}
onChange={e => setNewSeriesName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && newSeriesName.trim()) {
createSeries.mutate({ name: newSeriesName.trim() });
setNewSeriesName('');
setAddingSeries(false);
}
if (e.key === 'Escape') setAddingSeries(false);
}}
/>
<button
className="text-green-400"
onClick={() => {
if (newSeriesName.trim()) {
createSeries.mutate({ name: newSeriesName.trim() });
setNewSeriesName('');
}
setAddingSeries(false);
}}
>
<Check className="w-3.5 h-3.5" />
</button>
<button className="text-muted-foreground" onClick={() => setAddingSeries(false)}>
<X className="w-3.5 h-3.5" />
</button>
</div>
)}
{seriesList.map((series, si) => {
const expanded = expandedSeries.has(series.id);
const models = modelQueryResults[si]?.data ?? [];
const isSeriesSelected = selection.seriesId === series.id && !selection.modelId;
return (
<div key={series.id}>
{/* 车系行 */}
<div
className={`flex items-center gap-1 px-1 py-1 rounded cursor-pointer group text-xs
${isSeriesSelected ? 'bg-primary/20 text-primary' : 'hover:bg-secondary/60'}`}
onClick={() => {
toggleSeries(series.id);
onSelect({ seriesId: series.id, modelId: null, taskId: null });
}}
>
{expanded ? <ChevronDown className="w-3 h-3 flex-shrink-0 text-muted-foreground" /> : <ChevronRight className="w-3 h-3 flex-shrink-0 text-muted-foreground" />}
<Train className="w-3 h-3 flex-shrink-0 text-muted-foreground" />
<EditableText
value={series.name}
onSave={name => updateSeries.mutate({ id: series.id, name })}
/>
<span className="hidden group-hover:flex items-center gap-0.5 flex-shrink-0">
<button
className="p-0.5 rounded hover:bg-secondary"
title="新增车型"
onClick={e => { e.stopPropagation(); setAddingModelFor(series.id); setNewModelName(''); }}
>
<Plus className="w-3 h-3" />
</button>
<button
className="p-0.5 rounded hover:bg-secondary text-destructive"
title="删除车系"
onClick={e => {
e.stopPropagation();
if (confirm(`删除车系「${series.name}」?`)) deleteSeries.mutate({ id: series.id });
}}
>
<Trash2 className="w-3 h-3" />
</button>
</span>
</div>
{/* 新增车型输入 */}
{addingModelFor === series.id && (
<div className="flex items-center gap-1 pl-7 pr-1 py-0.5">
<input
autoFocus
className="flex-1 bg-secondary text-xs px-2 py-1 rounded outline-none border border-primary"
placeholder="车型名称"
value={newModelName}
onChange={e => setNewModelName(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && newModelName.trim()) {
createModel.mutate({ seriesId: series.id, name: newModelName.trim() });
setNewModelName('');
setAddingModelFor(null);
setExpandedSeries(prev => {
const n = new Set(prev);
n.add(series.id);
return n;
});
}
if (e.key === 'Escape') setAddingModelFor(null);
}}
/>
<button
className="text-green-400"
onClick={() => {
if (newModelName.trim()) {
createModel.mutate({ seriesId: series.id, name: newModelName.trim() });
setExpandedSeries(prev => {
const n = new Set(prev);
n.add(series.id);
return n;
});
}
setAddingModelFor(null);
}}
>
<Check className="w-3.5 h-3.5" />
</button>
<button className="text-muted-foreground" onClick={() => setAddingModelFor(null)}>
<X className="w-3.5 h-3.5" />
</button>
</div>
)}
{/* 车型列表 */}
{expanded && models.map(model => {
const isModelSelected = selection.modelId === model.id;
return (
<div key={model.id}>
<div
className={`flex items-center gap-1 px-1 py-1 pl-5 rounded cursor-pointer group text-xs
${isModelSelected ? 'bg-primary/20 text-primary' : 'hover:bg-secondary/60'}`}
onClick={() => onSelect({ seriesId: series.id, modelId: model.id, taskId: null })}
>
<Box className="w-3 h-3 flex-shrink-0 text-blue-400" />
<EditableText
value={model.name}
onSave={name => updateModel.mutate({ id: model.id, name })}
/>
<span className="hidden group-hover:flex items-center gap-0.5 flex-shrink-0">
<button
className="p-0.5 rounded hover:bg-secondary text-destructive"
title="删除车型"
onClick={e => {
e.stopPropagation();
if (confirm(`删除车型「${model.name}」?`)) deleteModel.mutate({ id: model.id });
}}
>
<Trash2 className="w-3 h-3" />
</button>
</span>
</div>
</div>
);
})}
</div>
);
})}
{seriesList.length === 0 && (
<div className="text-center py-8 text-xs text-muted-foreground">
暂无车系,点击右上角新增
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,111 @@
import { useState, useCallback, useEffect } from 'react';
import {
Upload,
Box,
Layers,
Activity,
Save,
History,
Check,
Loader2
} from 'lucide-react';
export type WorkflowStep =
| 'cad_loading'
| 'pointcloud_loading'
| 'rendering'
| 'processing_complete'
| 'saving_data'
| 'viewing_history';
interface WorkflowManagerProps {
currentStep: WorkflowStep;
onStepChange: (step: WorkflowStep) => void;
totalChunks: number;
processedChunks: number;
className?: string;
}
const steps = [
{ id: 'cad_loading', label: 'CAD模型', icon: Box },
{ id: 'pointcloud_loading', label: '点云数据', icon: Upload },
{ id: 'rendering', label: '渲染中', icon: Layers },
{ id: 'processing_complete', label: '分析完成', icon: Activity },
{ id: 'saving_data', label: '保存数据', icon: Save },
{ id: 'viewing_history', label: '历史数据', icon: History },
];
export default function WorkflowManager({
currentStep,
onStepChange,
totalChunks,
processedChunks,
className = ''
}: WorkflowManagerProps) {
const currentIndex = steps.findIndex(s => s.id === currentStep);
return (
<div className={`industrial-panel p-4 ${className}`}>
<div className="flex items-center justify-between mb-3">
<span className="industrial-label">工作流程</span>
<div className="flex items-center gap-2">
<span className={`status-led ${
currentStep === 'rendering' ? 'status-led-processing' :
currentStep === 'processing_complete' ? 'status-led-active' :
'status-led-idle'
}`} />
<span className="text-xs text-muted-foreground">
{currentStep === 'rendering' ? '渲染中' :
currentStep === 'processing_complete' ? '就绪' : '待机'}
</span>
</div>
</div>
<div className="workflow-timeline">
<div className="workflow-connector" />
{steps.map((step, index) => {
const Icon = step.icon;
const isActive = step.id === currentStep;
const isCompleted = index < currentIndex;
return (
<div
key={step.id}
className={`workflow-step ${isActive ? 'workflow-step-active' : ''} ${isCompleted ? 'workflow-step-completed' : ''}`}
onClick={() => onStepChange(step.id as WorkflowStep)}
style={{ cursor: 'pointer' }}
>
<div className="workflow-step-icon">
{isCompleted ? (
<Check className="w-4 h-4 text-white" />
) : isActive && currentStep === 'rendering' ? (
<Loader2 className="w-4 h-4 text-white animate-spin" />
) : (
<Icon className={`w-4 h-4 ${isActive ? 'text-white' : 'text-muted-foreground'}`} />
)}
</div>
<span className="workflow-step-label">{step.label}</span>
</div>
);
})}
</div>
{currentStep === 'rendering' && (
<div className="mt-4 space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">渲染进度</span>
<span className="data-display py-1 px-2">
{processedChunks} / {totalChunks}
</span>
</div>
<div className="industrial-progress">
<div
className="industrial-progress-bar"
style={{ width: `${(processedChunks / totalChunks) * 100}%` }}
/>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,111 @@
import { type ElementType } from 'react';
import { CheckCircle2, Loader2, FolderOpen, Cloud, Box, BarChart3, Save } from 'lucide-react';
export type WorkflowStepId = 'cad' | 'pointcloud' | 'render' | 'analysis' | 'save';
export type StepStatus = 'done' | 'active' | 'pending';
export interface WorkflowState {
cad: StepStatus;
pointcloud: StepStatus;
render: StepStatus;
analysis: StepStatus;
save: StepStatus;
}
interface StepDef {
id: WorkflowStepId;
label: string;
icon: ElementType;
}
const STEPS: StepDef[] = [
{ id: 'cad', label: 'CAD 模型', icon: Box },
{ id: 'pointcloud', label: '点云采集', icon: Cloud },
{ id: 'render', label: '渲染完成', icon: FolderOpen },
{ id: 'analysis', label: '分析完成', icon: BarChart3 },
{ id: 'save', label: '保存完成', icon: Save },
];
function StepNode({
step,
status,
onClick,
}: {
step: StepDef;
status: StepStatus;
onClick?: () => void;
}) {
const Icon = step.icon;
const circleClass =
status === 'done'
? 'bg-[oklch(0.55_0.18_145)] border-[oklch(0.55_0.18_145)] text-white'
: status === 'active'
? 'bg-primary/20 border-primary text-primary'
: 'bg-transparent border-border text-muted-foreground/40';
const labelClass =
status === 'done'
? 'text-[oklch(0.75_0.14_145)]'
: status === 'active'
? 'text-primary'
: 'text-muted-foreground/40';
return (
<button
onClick={onClick}
disabled={!onClick || status === 'pending'}
className={`flex flex-col items-center gap-1 transition-opacity
${status === 'pending' ? 'cursor-default' : 'cursor-pointer hover:opacity-80'}`}
>
<div className={`w-8 h-8 rounded-full border-2 flex items-center justify-center transition-all ${circleClass}`}>
{status === 'done' ? (
<CheckCircle2 className="w-4 h-4" />
) : status === 'active' ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Icon className="w-4 h-4" />
)}
</div>
<span className={`text-[10px] leading-tight whitespace-nowrap transition-colors ${labelClass}`}>
{step.label}
</span>
</button>
);
}
function Connector({ done }: { done: boolean }) {
return (
<div className="flex-1 flex items-center pb-4 px-1 min-w-[24px]">
<div className={`h-px w-full transition-colors duration-500 ${done ? 'bg-[oklch(0.55_0.18_145)]' : 'bg-border'}`} />
</div>
);
}
interface Props {
state: WorkflowState;
onStepClick?: (id: WorkflowStepId) => void;
}
export default function WorkflowProgress({ state, onStepClick }: Props) {
return (
<div className="h-14 border-b border-border bg-card/60 flex items-center px-6 flex-shrink-0">
<span className="text-[10px] text-muted-foreground uppercase tracking-wider mr-4 flex-shrink-0">
工作流程
</span>
<div className="flex items-center flex-1 min-w-0">
{STEPS.map((step, i) => (
<div key={step.id} className="flex items-center flex-1 min-w-0">
<StepNode
step={step}
status={state[step.id]}
onClick={onStepClick ? () => onStepClick(step.id) : undefined}
/>
{i < STEPS.length - 1 && <Connector done={state[step.id] === 'done'} />}
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,64 @@
import * as React from "react";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };

View File

@@ -0,0 +1,155 @@
import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button";
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
);
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
);
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
);
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
);
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
);
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
);
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
};

View File

@@ -0,0 +1,66 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
);
}
export { Alert, AlertTitle, AlertDescription };

View File

@@ -0,0 +1,9 @@
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio";
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />;
}
export { AspectRatio };

View File

@@ -0,0 +1,51 @@
import * as React from "react";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { cn } from "@/lib/utils";
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
);
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
);
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
);
}
export { Avatar, AvatarImage, AvatarFallback };

View File

@@ -0,0 +1,46 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
);
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span";
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,109 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { ChevronRight, MoreHorizontal } from "lucide-react";
import { cn } from "@/lib/utils";
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
);
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
);
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
);
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
);
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
);
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
);
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
};

View File

@@ -0,0 +1,83 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
const buttonGroupVariants = cva(
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
);
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
);
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "div";
return (
<Comp
className={cn(
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
className
)}
{...props}
/>
);
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
};

View File

@@ -0,0 +1,60 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-transparent shadow-xs hover:bg-accent dark:bg-transparent dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Button, buttonVariants };

View File

@@ -0,0 +1,211 @@
import * as React from "react";
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react";
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button";
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) {
const defaultClassNames = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: date =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
);
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
);
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
);
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
);
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
);
},
...components,
}}
{...props}
/>
);
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames();
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
);
}
export { Calendar, CalendarDayButton };

View File

@@ -0,0 +1,92 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
};

View File

@@ -0,0 +1,239 @@
import * as React from "react";
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react";
import { ArrowLeft, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
type CarouselApi = UseEmblaCarouselType[1];
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
type CarouselOptions = UseCarouselParameters[0];
type CarouselPlugin = UseCarouselParameters[1];
type CarouselProps = {
opts?: CarouselOptions;
plugins?: CarouselPlugin;
orientation?: "horizontal" | "vertical";
setApi?: (api: CarouselApi) => void;
};
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
api: ReturnType<typeof useEmblaCarousel>[1];
scrollPrev: () => void;
scrollNext: () => void;
canScrollPrev: boolean;
canScrollNext: boolean;
} & CarouselProps;
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
function useCarousel() {
const context = React.useContext(CarouselContext);
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />");
}
return context;
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
);
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
const [canScrollNext, setCanScrollNext] = React.useState(false);
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return;
setCanScrollPrev(api.canScrollPrev());
setCanScrollNext(api.canScrollNext());
}, []);
const scrollPrev = React.useCallback(() => {
api?.scrollPrev();
}, [api]);
const scrollNext = React.useCallback(() => {
api?.scrollNext();
}, [api]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
scrollPrev();
} else if (event.key === "ArrowRight") {
event.preventDefault();
scrollNext();
}
},
[scrollPrev, scrollNext]
);
React.useEffect(() => {
if (!api || !setApi) return;
setApi(api);
}, [api, setApi]);
React.useEffect(() => {
if (!api) return;
onSelect(api);
api.on("reInit", onSelect);
api.on("select", onSelect);
return () => {
api?.off("select", onSelect);
};
}, [api, onSelect]);
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
);
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel();
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
);
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel();
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
);
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
);
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel();
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
);
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
};

View File

@@ -0,0 +1,355 @@
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}) {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
);
if (!colorConfig.length) {
return null;
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
}) {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter(item => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload.fill || item.color;
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
}
const ChartLegend = RechartsPrimitive.Legend;
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}) {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter(item => item.type !== "none")
.map(item => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};

View File

@@ -0,0 +1,30 @@
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
);
}
export { Checkbox };

View File

@@ -0,0 +1,31 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
);
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
);
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent };

View File

@@ -0,0 +1,184 @@
"use client";
import * as React from "react";
import { Command as CommandPrimitive } from "cmdk";
import { SearchIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
);
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
);
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
);
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
);
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
);
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
);
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
);
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -0,0 +1,250 @@
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
);
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
);
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
);
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
);
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
);
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
);
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
);
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
};

View File

@@ -0,0 +1,209 @@
import { cn } from "@/lib/utils";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import * as React from "react";
// Context to track composition state across dialog children
const DialogCompositionContext = React.createContext<{
isComposing: () => boolean;
setComposing: (composing: boolean) => void;
justEndedComposing: () => boolean;
markCompositionEnd: () => void;
}>({
isComposing: () => false,
setComposing: () => {},
justEndedComposing: () => false,
markCompositionEnd: () => {},
});
export const useDialogComposition = () =>
React.useContext(DialogCompositionContext);
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
const composingRef = React.useRef(false);
const justEndedRef = React.useRef(false);
const endTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const contextValue = React.useMemo(
() => ({
isComposing: () => composingRef.current,
setComposing: (composing: boolean) => {
composingRef.current = composing;
},
justEndedComposing: () => justEndedRef.current,
markCompositionEnd: () => {
justEndedRef.current = true;
if (endTimerRef.current) {
clearTimeout(endTimerRef.current);
}
endTimerRef.current = setTimeout(() => {
justEndedRef.current = false;
}, 150);
},
}),
[]
);
return (
<DialogCompositionContext.Provider value={contextValue}>
<DialogPrimitive.Root data-slot="dialog" {...props} />
</DialogCompositionContext.Provider>
);
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
);
}
DialogOverlay.displayName = "DialogOverlay";
function DialogContent({
className,
children,
showCloseButton = true,
onEscapeKeyDown,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
const { isComposing } = useDialogComposition();
const handleEscapeKeyDown = React.useCallback(
(e: KeyboardEvent) => {
// Check both the native isComposing property and our context state
// This handles Safari's timing issues with composition events
const isCurrentlyComposing = (e as any).isComposing || isComposing();
// If IME is composing, prevent dialog from closing
if (isCurrentlyComposing) {
e.preventDefault();
return;
}
// Call user's onEscapeKeyDown if provided
onEscapeKeyDown?.(e);
},
[isComposing, onEscapeKeyDown]
);
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
onEscapeKeyDown={handleEscapeKeyDown}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger
};

View File

@@ -0,0 +1,133 @@
import * as React from "react";
import { Drawer as DrawerPrimitive } from "vaul";
import { cn } from "@/lib/utils";
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
);
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
);
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
);
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
};

View File

@@ -0,0 +1,255 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
);
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
);
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
);
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
);
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
);
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
};

View File

@@ -0,0 +1,104 @@
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
className
)}
{...props}
/>
);
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn(
"flex max-w-sm flex-col items-center gap-2 text-center",
className
)}
{...props}
/>
);
}
const emptyMediaVariants = cva(
"flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: "default",
},
}
);
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
);
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("text-lg font-medium tracking-tight", className)}
{...props}
/>
);
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
);
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
className
)}
{...props}
/>
);
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
};

View File

@@ -0,0 +1,242 @@
import { useMemo } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
);
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className
)}
{...props}
/>
);
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className
)}
{...props}
/>
);
}
const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
}
);
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
);
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className
)}
{...props}
/>
);
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
className
)}
{...props}
/>
);
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
);
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
);
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode;
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
);
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>;
}) {
const content = useMemo(() => {
if (children) {
return children;
}
if (!errors) {
return null;
}
if (errors?.length === 1 && errors[0]?.message) {
return errors[0].message;
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{errors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
);
}, [children, errors]);
if (!content) {
return null;
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-destructive text-sm font-normal", className)}
{...props}
>
{content}
</div>
);
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
};

View File

@@ -0,0 +1,168 @@
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState } = useFormContext();
const formState = useFormState({ name: fieldContext.name });
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
);
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
);
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField();
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
);
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField();
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message ?? "") : props.children;
if (!body) {
return null;
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
);
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
};

View File

@@ -0,0 +1,42 @@
import * as React from "react";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import { cn } from "@/lib/utils";
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
);
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
);
}
export { HoverCard, HoverCardTrigger, HoverCardContent };

View File

@@ -0,0 +1,168 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
"h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
// Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className
)}
{...props}
/>
);
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
"block-end":
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
},
},
defaultVariants: {
align: "inline-start",
},
}
);
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={e => {
if ((e.target as HTMLElement).closest("button")) {
return;
}
e.currentTarget.parentElement?.querySelector("input")?.focus();
}}
{...props}
/>
);
}
const inputGroupButtonVariants = cva(
"text-sm shadow-none flex gap-2 items-center",
{
variants: {
size: {
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
);
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
);
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
);
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
);
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
};

View File

@@ -0,0 +1,75 @@
import * as React from "react";
import { OTPInput, OTPInputContext } from "input-otp";
import { MinusIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
);
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center", className)}
{...props}
/>
);
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number;
}) {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
);
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
);
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

@@ -0,0 +1,70 @@
import { useDialogComposition } from "@/components/ui/dialog";
import { useComposition } from "@/hooks/useComposition";
import { cn } from "@/lib/utils";
import * as React from "react";
function Input({
className,
type,
onKeyDown,
onCompositionStart,
onCompositionEnd,
...props
}: React.ComponentProps<"input">) {
// Get dialog composition context if available (will be no-op if not inside Dialog)
const dialogComposition = useDialogComposition();
// Add composition event handlers to support input method editor (IME) for CJK languages.
const {
onCompositionStart: handleCompositionStart,
onCompositionEnd: handleCompositionEnd,
onKeyDown: handleKeyDown,
} = useComposition<HTMLInputElement>({
onKeyDown: (e) => {
// Check if this is an Enter key that should be blocked
const isComposing = (e.nativeEvent as any).isComposing || dialogComposition.justEndedComposing();
// If Enter key is pressed while composing or just after composition ended,
// don't call the user's onKeyDown (this blocks the business logic)
if (e.key === "Enter" && isComposing) {
return;
}
// Otherwise, call the user's onKeyDown
onKeyDown?.(e);
},
onCompositionStart: e => {
dialogComposition.setComposing(true);
onCompositionStart?.(e);
},
onCompositionEnd: e => {
// Mark that composition just ended - this helps handle the Enter key that confirms input
dialogComposition.markCompositionEnd();
// Delay setting composing to false to handle Safari's event order
// In Safari, compositionEnd fires before the ESC keydown event
setTimeout(() => {
dialogComposition.setComposing(false);
}, 100);
onCompositionEnd?.(e);
},
});
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
onKeyDown={handleKeyDown}
{...props}
/>
);
}
export { Input };

View File

@@ -0,0 +1,193 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn("group/item-group flex flex-col", className)}
{...props}
/>
);
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn("my-0", className)}
{...props}
/>
);
}
const itemVariants = cva(
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border-border",
muted: "bg-muted/50",
},
size: {
default: "p-4 gap-4 ",
sm: "py-3 px-4 gap-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function Item({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
return (
<Comp
data-slot="item"
data-variant={variant}
data-size={size}
className={cn(itemVariants({ variant, size, className }))}
{...props}
/>
);
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
{
variants: {
variant: {
default: "bg-transparent",
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
image:
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
}
);
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
);
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
className
)}
{...props}
/>
);
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium",
className
)}
{...props}
/>
);
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
);
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
);
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
);
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
);
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
};

View File

@@ -0,0 +1,28 @@
import { cn } from "@/lib/utils";
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none",
"[&_svg:not([class*='size-'])]:size-3",
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
className
)}
{...props}
/>
);
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
);
}
export { Kbd, KbdGroup };

View File

@@ -0,0 +1,22 @@
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
);
}
export { Label };

View File

@@ -0,0 +1,274 @@
import * as React from "react";
import * as MenubarPrimitive from "@radix-ui/react-menubar";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot="menubar"
className={cn(
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
className
)}
{...props}
/>
);
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />;
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />;
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />;
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
);
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
className
)}
{...props}
/>
);
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</MenubarPortal>
);
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<MenubarPrimitive.Item
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function MenubarCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
);
}
function MenubarRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
data-slot="menubar-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
);
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean;
}) {
return (
<MenubarPrimitive.Label
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
);
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot="menubar-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
);
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />;
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
);
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
);
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
};

View File

@@ -0,0 +1,168 @@
import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority";
import { ChevronDownIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean;
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
);
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className
)}
{...props}
/>
);
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
);
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
);
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
);
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className
)}
{...props}
/>
);
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
{...props}
/>
</div>
);
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
);
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
);
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
};

View File

@@ -0,0 +1,127 @@
import * as React from "react";
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button";
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
);
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
);
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />;
}
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">;
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
);
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
);
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
);
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
);
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
};

View File

@@ -0,0 +1,46 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils";
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
);
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@@ -0,0 +1,29 @@
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
);
}
export { Progress };

View File

@@ -0,0 +1,43 @@
import * as React from "react";
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
import { CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
);
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
}
export { RadioGroup, RadioGroupItem };

View File

@@ -0,0 +1,54 @@
import * as React from "react";
import { GripVerticalIcon } from "lucide-react";
import * as ResizablePrimitive from "react-resizable-panels";
import { cn } from "@/lib/utils";
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
return (
<ResizablePrimitive.PanelGroup
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
);
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />;
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
withHandle?: boolean;
}) {
return (
<ResizablePrimitive.PanelResizeHandle
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
);
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle };

View File

@@ -0,0 +1,56 @@
import * as React from "react";
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
import { cn } from "@/lib/utils";
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
);
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
);
}
export { ScrollArea, ScrollBar };

View File

@@ -0,0 +1,185 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};

View File

@@ -0,0 +1,26 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
);
}
export { Separator };

View File

@@ -0,0 +1,139 @@
"use client";
import * as React from "react";
import * as SheetPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
);
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
);
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
);
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
);
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
);
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View File

@@ -0,0 +1,734 @@
"use client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useIsMobile } from "@/hooks/useMobile";
import { cn } from "@/lib/utils";
import { Slot } from "@radix-ui/react-slot";
import { cva, VariantProps } from "class-variance-authority";
import { PanelLeftIcon } from "lucide-react";
import * as React from "react";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
const SIDEBAR_WIDTH = "16rem";
const SIDEBAR_WIDTH_MOBILE = "18rem";
const SIDEBAR_WIDTH_ICON = "3rem";
const SIDEBAR_KEYBOARD_SHORTCUT = "b";
type SidebarContextProps = {
state: "expanded" | "collapsed";
open: boolean;
setOpen: (open: boolean) => void;
openMobile: boolean;
setOpenMobile: (open: boolean) => void;
isMobile: boolean;
toggleSidebar: () => void;
};
const SidebarContext = React.createContext<SidebarContextProps | null>(null);
function useSidebar() {
const context = React.useContext(SidebarContext);
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.");
}
return context;
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}) {
const isMobile = useIsMobile();
const [openMobile, setOpenMobile] = React.useState(false);
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen);
const open = openProp ?? _open;
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value;
if (setOpenProp) {
setOpenProp(openState);
} else {
_setOpen(openState);
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
},
[setOpenProp, open]
);
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile(open => !open) : setOpen(open => !open);
}, [isMobile, setOpen, setOpenMobile]);
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault();
toggleSidebar();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [toggleSidebar]);
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed";
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
);
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
);
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
disableTransition = false,
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right";
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
disableTransition?: boolean;
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
);
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent",
disableTransition
? "transition-none"
: "transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) md:flex",
disableTransition
? "transition-none"
: "transition-[left,right,width] duration-200 ease-linear",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
);
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar();
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={event => {
onClick?.(event);
toggleSidebar();
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
);
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar();
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
);
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
);
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
);
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
);
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
);
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
);
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
);
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div";
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
);
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
);
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
);
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
);
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
isActive?: boolean;
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button";
const { isMobile, state } = useSidebar();
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
);
if (!tooltip) {
return button;
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
};
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
);
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean;
showOnHover?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
);
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean;
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`;
}, []);
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
);
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
);
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean;
size?: "sm" | "md";
isActive?: boolean;
}) {
const Comp = asChild ? Slot : "a";
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
);
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar
};

View File

@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils";
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
);
}
export { Skeleton };

View File

@@ -0,0 +1,61 @@
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
);
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
);
}
export { Slider };

View File

@@ -0,0 +1,23 @@
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
);
};
export { Toaster };

View File

@@ -0,0 +1,16 @@
import { Loader2Icon } from "lucide-react";
import { cn } from "@/lib/utils";
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
);
}
export { Spinner };

View File

@@ -0,0 +1,29 @@
import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
);
}
export { Switch };

View File

@@ -0,0 +1,114 @@
import * as React from "react";
import { cn } from "@/lib/utils";
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
);
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
);
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
);
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};

Some files were not shown because too many files have changed in this diff Show More