Compare commits

...

2 Commits

Author SHA1 Message Date
DESKTOP-PB0N82B\admin 93410c55d7 no message 2026-01-30 18:02:55 +08:00
DESKTOP-PB0N82B\admin 271c11b453 no message 2026-01-30 11:17:02 +08:00
45 changed files with 3963 additions and 471 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

View File

@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://172.16.1.12" />
<title>AssetPro</title>
<script type="module" crossorigin src="./assets/index-C1_JoRdR.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-Cnn4LfhN.css">
<script type="module" crossorigin src="./assets/index-Cst68Kk3.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BAlDckV1.css">
</head>
<body>
<div id="root"></div>

Binary file not shown.

Binary file not shown.

View File

@ -14,8 +14,8 @@ x64:
nodeModuleFilePatterns: []
nsis:
script: |-
!include "D:\Project\AssetPro\node_modules\app-builder-lib\templates\nsis\include\StdUtils.nsh"
!addincludedir "D:\Project\AssetPro\node_modules\app-builder-lib\templates\nsis\include"
!include "D:\Project\AssetPro2\node_modules\app-builder-lib\templates\nsis\include\StdUtils.nsh"
!addincludedir "D:\Project\AssetPro2\node_modules\app-builder-lib\templates\nsis\include"
!macro _isUpdated _a _b _t _f
${StdUtils.TestParameter} $R9 "updated"
StrCmp "$R9" "true" `${_t}` `${_f}`
@ -87,7 +87,7 @@ nsis:
!insertmacro MUI_LANGUAGE "Vietnamese"
!macroend
!include "C:\Users\admin\AppData\Local\Temp\t-PKnz4i\0-messages.nsh"
!include "C:\Users\admin\AppData\Local\Temp\t-p3ldDi\0-messages.nsh"
!addplugindir /x86-unicode "C:\Users\admin\AppData\Local\electron-builder\Cache\nsis\nsis-resources-3.4.1\plugins\x86-unicode"
Var newStartMenuLink

Binary file not shown.

View File

@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' http://172.16.1.12" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; connect-src 'self' http://172.16.1.12" />
<title>AssetPro</title>
</head>
<body>

1169
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -20,6 +20,14 @@
"author": "",
"license": "MIT",
"dependencies": {
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@types/react-syntax-highlighter": "^15.5.13",
"axios": "^1.13.2",
"electron-store": "^8.1.0",

View File

@ -5,9 +5,12 @@ import AssetLibrary from '@/pages/assets/AssetLibrary'
import UploadResource from '@/pages/assets/UploadResource'
import ProjectLibrary from '@/pages/projects/ProjectLibrary'
import NewProject from '@/pages/projects/NewProject'
import ProfilePage from '@/pages/ProfilePage'
import FileLibrary from '@/pages/files/FileLibrary'
import { ToastProvider } from '@/components/ui/toast-provider'
import { DownloadManagerProvider } from '@/components/DownloadManager'
import { UserProvider } from '@/contexts/UserContext'
// 其他页面的占位符,以避免演示期间出现 404
const PlaceholderPage = ({ title }: { title: string }) => (
@ -22,32 +25,38 @@ const PlaceholderPage = ({ title }: { title: string }) => (
function App() {
return (
<ToastProvider>
<DownloadManagerProvider>
<HashRouter>
<Routes>
<Route path="/" element={<Login />} />
<Route path="/upload" element={<UploadResource />} />
<Route path="/app/projects/new" element={<NewProject />} />
<UserProvider>
<DownloadManagerProvider>
<HashRouter>
<Routes>
<Route path="/" element={<Login />} />
{/* Full width pages with Header but no Sidebar */}
<Route element={<MainLayout showSidebar={false} />}>
<Route path="/upload" element={<UploadResource />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/app/projects/new" element={<NewProject />} />
</Route>
{/* Main Application Routes */}
<Route path="/app" element={<MainLayout />}>
<Route index element={<Navigate to="/app/plugins" replace />} />
<Route path="plugins" element={<AssetLibrary />} />
<Route path="components" element={<PlaceholderPage title="组件库" />} />
<Route path="tools" element={<PlaceholderPage title="工具库" />} />
<Route path="prototypes" element={<PlaceholderPage title="原型库" />} />
<Route path="design" element={<PlaceholderPage title="设计库" />} />
<Route path="projects" element={<ProjectLibrary />} />
<Route path="files" element={<PlaceholderPage title="文件库" />} />
<Route path="cases" element={<PlaceholderPage title="案例库" />} />
<Route path="pending" element={<PlaceholderPage title="待审核" />} />
</Route>
{/* Main Application Routes */}
<Route path="/app" element={<MainLayout />}>
<Route index element={<Navigate to="/app/plugins" replace />} />
<Route path="plugins" element={<AssetLibrary />} />
<Route path="components" element={<PlaceholderPage title="组件库" />} />
<Route path="tools" element={<PlaceholderPage title="工具库" />} />
<Route path="prototypes" element={<PlaceholderPage title="原型库" />} />
<Route path="design" element={<PlaceholderPage title="设计库" />} />
<Route path="projects" element={<ProjectLibrary />} />
<Route path="files" element={<FileLibrary />} />
<Route path="cases" element={<PlaceholderPage title="案例库" />} />
<Route path="pending" element={<PlaceholderPage title="待审核" />} />
</Route>
{/* Legacy/Redirects */}
<Route path="/dashboard" element={<Navigate to="/app" replace />} />
</Routes>
</HashRouter>
</DownloadManagerProvider>
{/* Legacy/Redirects */}
<Route path="/dashboard" element={<Navigate to="/app" replace />} />
</Routes>
</HashRouter>
</DownloadManagerProvider>
</UserProvider>
</ToastProvider>
)
}

41
src/api/auth.ts Normal file
View File

@ -0,0 +1,41 @@
import simClient from './simulation';
export interface LoginParams {
username: string;
password?: string;
login_user_key?: string; // 示例中出现的字段,可能需要
}
export interface LoginResponse {
msg: string;
code: number;
data: any;
token: string;
}
export interface UserInfo {
userId: number;
userName: string;
nickName: string;
email: string;
phonenumber: string;
sex: string;
avatar: string;
status: string;
}
export interface GetInfoResponse {
code: number;
msg: string;
user: UserInfo;
roles: string[];
permissions: string[];
}
export const login = (data: LoginParams) => {
return simClient.post<any, LoginResponse>('/login', data);
};
export const getUserInfo = () => {
return simClient.get<any, GetInfoResponse>('/getInfo');
};

View File

@ -14,13 +14,34 @@ const apiClient = axios.create({
timeout: 60000,
headers: {
'Content-Type': 'application/json',
...(API_TOKEN ? { Authorization: `token ${API_TOKEN}` } : {}),
},
});
// 请求拦截器,自动添加 token
apiClient.interceptors.request.use(
(config) => {
console.log(`>>> [Gitea] ${config.method?.toUpperCase()} ${config.url}`);
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`; // 这里的格式调整为 Bearer Token
console.log('--- Gitea Auth Token (Bearer):', token);
}
if (config.data) {
console.log('>>> Gitea Request JSON:', JSON.stringify(config.data, null, 2));
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 响应拦截器,用于错误处理
apiClient.interceptors.response.use(
(response) => response,
(response) => {
console.log('<<< Gitea Response JSON:', response.data);
return response;
},
(error) => {
console.error('API Error:', error);
return Promise.reject(error);

View File

@ -117,6 +117,32 @@ export const giteaApi = {
}
},
/**
* Get Repository Branch Details (includes latest commit)
*/
getRepoBranch: async (owner: string, repo: string, branchName: string): Promise<any> => {
try {
const response = await apiClient.get(`/repos/${owner}/${repo}/branches/${branchName}`);
return response.data;
} catch (error) {
console.error(`Failed to fetch branch ${branchName} for ${owner}/${repo}`, error);
throw error;
}
},
/**
* Get all branches for a repository
*/
getRepoBranches: async (owner: string, repo: string): Promise<any[]> => {
try {
const response = await apiClient.get<any[]>(`/repos/${owner}/${repo}/branches`);
return response.data;
} catch (error) {
console.error(`Failed to fetch branches for ${owner}/${repo}`, error);
throw error;
}
},
/**
* Get Repository Commits
*/

63
src/api/simulation.ts Normal file
View File

@ -0,0 +1,63 @@
import axios from 'axios';
import { AddSimulationResourceRequest, AddSimulationResourceResponse, GetSimulationListRequest, GetSimulationListResponse } from './simulationTypes';
// 仿真资源 API 基础路径
// 开发环境使用 Vite 代理,生产环境使用实际地址
export const SIM_API_BASE_URL = import.meta.env.DEV
? '/zichan-api'
: 'http://172.16.1.144:8082';
const simClient = axios.create({
baseURL: SIM_API_BASE_URL,
timeout: 60000,
headers: {
'Content-Type': 'application/json',
},
});
// 请求拦截器,自动添加 token
simClient.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
console.log(`>>> [Simulation] ${config.method?.toUpperCase()} ${config.url}`);
if (token) {
config.headers.Authorization = `Bearer ${token}`;
console.log('--- Auth Token (Bearer):', token);
}
if (config.data) {
console.log('>>> Upload Request JSON:', JSON.stringify(config.data, null, 2));
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// 响应拦截器
simClient.interceptors.response.use(
(response) => {
console.log('<<< Received Response JSON:', response.data);
return response.data;
},
(error) => {
console.error('Simulation API Error:', error);
return Promise.reject(error);
}
);
/**
* 仿
*/
export const addSimulationResource = async (data: AddSimulationResourceRequest): Promise<AddSimulationResourceResponse> => {
return simClient.post('/zichan/sim/upload/add', data);
};
/**
*
*/
export const getSimulationUploadList = async (params: GetSimulationListRequest): Promise<GetSimulationListResponse> => {
return simClient.get('/zichan/sim/upload/list', { params });
};
export default simClient;

View File

@ -0,0 +1,63 @@
export interface ProUploadItem {
sourceName: string;
sourceForm: string;
fileType: string;
fileName: string;
fileUrl: string;
fileVersion: string;
}
export interface AddSimulationResourceRequest {
name: string;
stores?: string;
types: string;
category: string;
tag: string;
introduction: string;
details: string;
urls: string;
versions: string;
env: string;
createdBy: string;
proUploadList: ProUploadItem[];
}
export interface AddSimulationResourceResponse {
code: number;
msg: string;
data?: any;
}
export interface SimulationResource {
id: string;
name: string;
stores?: string;
types: string;
category: string;
tag: string;
introduction: string;
details: string;
urls: string;
versions: string;
env: string;
createdTime: string;
createdBy: string;
remark?: string;
}
export interface GetSimulationListRequest {
pageNum?: number;
pageSize?: number;
name?: string;
types?: string;
category?: string;
tag?: string;
env?: string;
}
export interface GetSimulationListResponse {
code: number;
msg: string;
rows: SimulationResource[];
total: number;
}

View File

@ -14,7 +14,7 @@ interface DownloadItem {
}
interface DownloadContextType {
startDownload: (repoUrl: string, targetDir: string) => Promise<void>
startDownload: (repoUrl: string, targetDir: string, branch?: string) => Promise<void>
activeDownloads: DownloadItem[]
}
@ -40,6 +40,9 @@ export function DownloadManagerProvider({ children }: { children: React.ReactNod
}, [activeDownloads])
useEffect(() => {
// 监听 Git 进度
window.electronAPI.git.onProgress((data) => {
const { repoUrl, raw } = data
@ -86,7 +89,7 @@ export function DownloadManagerProvider({ children }: { children: React.ReactNod
}
}, [])
const startDownload = async (repoUrl: string, targetDir: string) => {
const startDownload = async (repoUrl: string, targetDir: string, branch?: string) => {
const id = Math.random().toString(36).substring(7)
const newItem: DownloadItem = {
id,
@ -101,7 +104,8 @@ export function DownloadManagerProvider({ children }: { children: React.ReactNod
setActiveDownloads(prev => [...prev, newItem])
try {
const result = await window.electronAPI.git.clone(repoUrl, targetDir)
// @ts-ignore - Assuming electronAPI is updated to accept branch
const result = await window.electronAPI.git.clone(repoUrl, targetDir, branch)
if (result.success) {
setActiveDownloads(prev => prev.map(item =>

View File

@ -1,10 +1,22 @@
import { Search, Plus, User, Minus, Square, X } from 'lucide-react'
import { Search, Plus, User, Minus, Square, X, LogOut } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useNavigate } from 'react-router-dom'
import { useToast } from '@/components/ui/toast-provider'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Separator } from '@/components/ui/separator'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useUser } from '@/contexts/UserContext'
export function Header() {
const navigate = useNavigate()
const { showToast } = useToast()
const { user, logout } = useUser()
const handleMinimize = () => {
window.electronAPI?.window.minimize()
@ -19,83 +31,117 @@ export function Header() {
}
return (
<div className="h-16 border-b bg-white flex items-center justify-between px-6 shadow-sm z-10 relative drag-region">
{/* Left: Logo */}
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-[#0e7490] rounded-md flex items-center justify-center">
{/* Simple Logo Icon Placeholder */}
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<span className="text-xl font-bold text-slate-800"></span>
</div>
{/* Center: Search (aligned slightly left or center) */}
<div className="flex-1 max-w-xl mx-12 flex justify-center">
<div className="relative w-full no-drag">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="搜索资源名称/标签/ID..."
className="pl-10 bg-slate-50 border-slate-200 focus-visible:ring-[#0e7490]"
/>
</div>
</div>
{/* Right: Actions */}
<div className="flex items-center gap-4 no-drag">
<Button
onClick={() => navigate('/upload')}
className="bg-[#e0f2fe] text-[#0e7490] hover:bg-[#bae6fd] border-none shadow-none gap-2 font-medium"
>
<Plus className="w-4 h-4" />
</Button>
<div className="h-6 w-px bg-slate-200 mx-2"></div>
<div className="flex items-center gap-3">
<div className="w-8 h-8 bg-slate-100 rounded-full flex items-center justify-center overflow-hidden border border-slate-200">
<User className="w-4 h-4 text-slate-400" />
<TooltipProvider>
<div className="h-16 border-b bg-white flex items-center justify-between px-6 shadow-sm z-10 relative drag-region">
{/* Left: Logo */}
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-[#0e7490] rounded-md flex items-center justify-center">
{/* Simple Logo Icon Placeholder */}
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<span className="text-sm font-medium text-slate-600">XXX</span>
<span className="text-xl font-bold text-slate-800"></span>
</div>
{/* Window Controls */}
<div className="h-6 w-px bg-slate-200 mx-2"></div>
{/* Center: Search (aligned slightly left or center) */}
<div className="flex-1 max-w-xl mx-12 flex justify-center">
<div className="relative w-full no-drag">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<Input
placeholder="搜索资源名称/标签/ID..."
className="pl-10 bg-slate-50 border-slate-200 focus-visible:ring-[#0e7490]"
/>
</div>
</div>
<div className="flex items-center gap-1">
{/* Right: Actions */}
<div className="flex items-center gap-4 no-drag">
<Button
variant="ghost"
size="icon"
className="text-slate-400 hover:text-slate-900 hover:bg-slate-100 w-8 h-8"
onClick={handleMinimize}
title="最小化"
onClick={() => navigate('/upload')}
className="bg-[#e0f2fe] text-[#0e7490] hover:bg-[#bae6fd] border-none shadow-none gap-2 font-medium"
>
<Minus className="w-4 h-4" />
<Plus className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-slate-400 hover:text-slate-900 hover:bg-slate-100 w-8 h-8"
onClick={handleMaximize}
title="最大化/还原"
>
<Square className="w-3.5 h-3.5" />
</Button>
<Separator orientation="vertical" className="h-6 mx-2" />
<Button
variant="ghost"
size="icon"
className="text-slate-400 hover:text-red-600 hover:bg-red-50 w-8 h-8"
onClick={handleClose}
title="关闭"
>
<X className="w-4 h-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<div className="flex items-center gap-3 cursor-pointer hover:bg-slate-50 p-1 rounded-md transition-colors no-drag">
<div className="w-8 h-8 bg-slate-100 rounded-full flex items-center justify-center overflow-hidden border border-slate-200">
{user?.avatar ? <img src={user.avatar} className="w-full h-full object-cover" /> : <User className="w-4 h-4 text-slate-400" />}
</div>
<span className="text-sm font-medium text-slate-700">{user?.userName || '加载中...'}</span>
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem onClick={() => navigate('/profile')}>
<User className="mr-2 h-4 w-4" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem
className="text-red-600 focus:text-red-600"
onClick={() => {
logout();
showToast('已安全退出', 'success');
navigate('/');
}}
>
<LogOut className="mr-2 h-4 w-4" />
<span>退</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Separator orientation="vertical" className="h-6 mx-2" />
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-slate-400 hover:text-slate-900 hover:bg-slate-100 w-8 h-8"
onClick={handleMinimize}
>
<Minus className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-slate-400 hover:text-slate-900 hover:bg-slate-100 w-8 h-8"
onClick={handleMaximize}
>
<Square className="w-3.5 h-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="text-slate-400 hover:text-red-600 hover:bg-red-50 w-8 h-8"
onClick={handleClose}
>
<X className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent></TooltipContent>
</Tooltip>
</div>
</div>
</div>
</div>
</TooltipProvider>
)
}

View File

@ -0,0 +1,38 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
cyan: "border-transparent bg-cyan-100 text-cyan-700 hover:bg-cyan-200/80",
slate: "border-transparent bg-slate-100 text-slate-600 hover:bg-slate-200/80",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> { }
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@ -0,0 +1,28 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@ -0,0 +1,198 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg 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",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md 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",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@ -2,7 +2,7 @@ import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
extends React.InputHTMLAttributes<HTMLInputElement> { }
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
@ -10,7 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
"flex h-10 w-full rounded-md border border-slate-200 bg-slate-50/50 px-3 py-2 text-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0e7490]/20 focus-visible:border-[#0e7490] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}

View File

@ -0,0 +1,158 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-slate-200 bg-slate-50/50 px-3 py-2 text-sm transition-colors placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-[#0e7490]/20 focus:border-[#0e7490] disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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",
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}
{...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)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@ -0,0 +1,29 @@
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

117
src/components/ui/table.tsx Normal file
View File

@ -0,0 +1,117 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@ -0,0 +1,53 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -6,14 +6,14 @@ import * as React from 'react'
import { cn } from '@/lib/utils'
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> { }
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
'flex min-h-[80px] w-full rounded-md border border-slate-200 bg-slate-50/50 px-3 py-2 text-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0e7490]/20 focus-visible:border-[#0e7490] disabled:cursor-not-allowed disabled:opacity-50',
className
)}
ref={ref}

View File

@ -42,3 +42,11 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
</ToastContext.Provider>
)
}
export function useToast() {
const context = React.useContext(ToastContext)
if (context === undefined) {
throw new Error('useToast must be used within a ToastProvider')
}
return context
}

View File

@ -0,0 +1,28 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@ -0,0 +1,65 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { getUserInfo, UserInfo } from '@/api/auth';
interface UserContextType {
user: UserInfo | null;
loading: boolean;
refreshUser: () => Promise<void>;
logout: () => void;
}
const UserContext = createContext<UserContextType | undefined>(undefined);
export const UserProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<UserInfo | null>(null);
const [loading, setLoading] = useState(true);
const refreshUser = useCallback(async () => {
const token = localStorage.getItem('token');
if (!token) {
setUser(null);
setLoading(false);
return;
}
try {
setLoading(true);
const response = await getUserInfo();
if (response.code === 200) {
setUser(response.user);
} else {
// Token invalid or other error
localStorage.removeItem('token');
setUser(null);
}
} catch (error) {
console.error('Failed to fetch user info:', error);
setUser(null);
} finally {
setLoading(false);
}
}, []);
const logout = useCallback(() => {
localStorage.removeItem('token');
setUser(null);
}, []);
useEffect(() => {
refreshUser();
}, [refreshUser]);
return (
<UserContext.Provider value={{ user, loading, refreshUser, logout }}>
{children}
</UserContext.Provider>
);
};
export const useUser = () => {
const context = useContext(UserContext);
if (context === undefined) {
throw new Error('useUser must be used within a UserProvider');
}
return context;
};

View File

@ -173,7 +173,7 @@ export const cacheUtils = {
*/
async has(key: string, config?: { namespace?: string }): Promise<boolean> {
const result = await window.electronAPI.cache.has(key, config)
return result.success ? result.exists : false
return result.success ? (result.exists ?? false) : false
},
/**

View File

@ -2,7 +2,11 @@ import { Outlet } from 'react-router-dom'
import { Sidebar } from '@/components/layout/Sidebar'
import { Header } from '@/components/layout/Header'
export default function MainLayout() {
interface MainLayoutProps {
showSidebar?: boolean
}
export default function MainLayout({ showSidebar = true }: MainLayoutProps) {
return (
<div className="h-screen w-full bg-slate-50 flex flex-col overflow-hidden">
{/* Header at the top */}
@ -11,7 +15,7 @@ export default function MainLayout() {
{/* Main content area below header */}
<div className="flex-1 flex overflow-hidden">
{/* Sidebar on the left */}
<Sidebar />
{showSidebar && <Sidebar />}
{/* Page Content on the right */}
<main className="flex-1 overflow-auto p-6">

View File

@ -4,16 +4,46 @@ import { User, Lock, Power } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent } from '@/components/ui/card'
import bgImage from '@/assets/images/登录背景.png'
import { login } from '@/api/auth'
import { useToast } from '@/components/ui/toast-provider'
import { useUser } from '@/contexts/UserContext'
export default function Login() {
const navigate = useNavigate()
const [username, setUsername] = useState('test1')
const [password, setPassword] = useState('')
const { showToast } = useToast()
const { refreshUser } = useUser()
const [username, setUsername] = useState('admin') // Default to admin as per request
const [password, setPassword] = useState('123456') // Default password
const [loading, setLoading] = useState(false)
const handleLogin = () => {
// 简单的登录模拟
console.log('Login with:', username, password)
navigate('/app')
const handleLogin = async () => {
if (!username || !password) {
showToast('请输入用户名和密码', 'error')
return
}
setLoading(true)
try {
const res = await login({ username, password })
// simClient returns response.data directly via interceptor
// res structure: { msg: "...", code: 200, data: ..., token: "..." }
if (res.code === 200) {
localStorage.setItem('token', res.token)
await refreshUser()
showToast('登录成功', 'success')
navigate('/app')
} else {
showToast(res.msg || '登录失败', 'error')
}
} catch (error: any) {
console.error('Login error:', error)
const errorMsg = error.response?.data?.msg || error.message || '登录请求失败';
showToast(errorMsg, 'error')
} finally {
setLoading(false)
}
}
return (
@ -24,8 +54,8 @@ export default function Login() {
<div
className="absolute inset-0 z-0"
style={{
//background: 'linear-gradient(135deg, #e0f2fe 0%, #f0f9ff 100%)', // 默认淡蓝色渐变
backgroundImage: 'url("/src/assets/images/登录背景.png")', // 替换为实际背景图路径
//background: 'linear-gradient(135deg, #e0f2fe 0%, #f0f9ff 100%)', // 默认淡蓝色渐变
backgroundImage: `url("${bgImage}")`, // 使用导入的图片以确保打包后路径正确
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
@ -43,11 +73,11 @@ export default function Login() {
{/* 左侧装饰区域 (对应图中的文件夹 3D 图标) */}
<div className="hidden lg:flex flex-1 items-center justify-center">
{/*
{/*
TODO: 这里是放置左侧 3D
使
*/}
<div className="w-full h-full min-h-[400px]"></div>
<div className="w-full h-full min-h-[400px]"></div>
</div>
{/* 右侧登录卡片 */}
@ -92,13 +122,21 @@ export default function Login() {
<Button
className="w-full h-12 text-base bg-[#0e7490] hover:bg-[#155e75] transition-all duration-300 shadow-lg shadow-cyan-900/20"
onClick={handleLogin}
disabled={loading}
>
{loading ? '登 录 中...' : '登 录'}
</Button>
{/* 底部链接 */}
<div className="text-center mt-6">
<a href="#" className="text-sm text-[#0e7490] hover:underline font-medium">
<a
href="#"
onClick={(e) => {
e.preventDefault()
navigate('/app')
}}
className="text-sm text-[#0e7490] hover:underline font-medium"
>
线访
</a>
</div>

267
src/pages/ProfilePage.tsx Normal file
View File

@ -0,0 +1,267 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
ArrowLeft,
User,
Box,
Heart,
Edit
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useUser } from '@/contexts/UserContext'
// Mock Data
const MOCK_RESOURCES = [
{
id: 1,
name: '大学物理实验室场景组件',
thumbnail: 'https://images.unsplash.com/photo-1614726365723-49cfaeb5d203?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3',
tags: ['Unity', '实验室场景'],
uploadTime: '2024-05-10',
downloads: 128,
status: 'published', // published, draft, auditing
},
{
id: 2,
name: '光学实验交互插件 V2.0',
thumbnail: 'https://images.unsplash.com/photo-1532094349884-543bc11b234d?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3',
tags: ['Unity', '交互插件'],
uploadTime: '2024-06-15',
downloads: 86,
status: 'published',
},
{
id: 3,
name: '力学虚拟实验项目模板',
thumbnail: 'https://images.unsplash.com/photo-1581093458791-9f3c3900df4b?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3',
tags: ['Unreal', '项目模板'],
uploadTime: '2024-07-02',
downloads: 0,
status: 'draft',
},
{
id: 4,
name: '电磁学数据采集工具',
thumbnail: 'https://images.unsplash.com/photo-1581091226825-a6a2a5aee158?w=800&auto=format&fit=crop&q=60&ixlib=rb-4.0.3',
tags: ['Python', '数据工具'],
uploadTime: '2024-07-10',
downloads: 0,
status: 'auditing',
}
]
export default function ProfilePage() {
const navigate = useNavigate()
const [activeTab, setActiveTab] = useState('resources') // info, resources, favorites
const [resourceFilter, setResourceFilter] = useState('all') // all, published, draft, auditing
const { user } = useUser()
const getStatusBadge = (status: string) => {
switch (status) {
case 'published':
return <span className="text-blue-500 bg-blue-50 px-2 py-0.5 rounded text-xs"></span>
case 'draft':
return <span className="text-slate-500 bg-slate-100 px-2 py-0.5 rounded text-xs">稿</span>
case 'auditing':
return <span className="text-amber-500 bg-amber-50 px-2 py-0.5 rounded text-xs"></span>
default:
return null
}
}
return (
<div className="flex flex-col h-full bg-slate-50">
<main className="flex-1 w-full p-2.5 flex flex-col gap-2.5 overflow-hidden">
{/* Back Button */}
<div className="shrink-0">
<button
onClick={() => navigate("/app")}
className="flex items-center gap-1 text-slate-500 hover:text-slate-800 text-sm font-medium transition-colors"
>
<ArrowLeft className="w-4 h-4" />
</button>
</div>
<div className="flex-1 flex w-full p-2.5 gap-6 h-full">
{/* Left Sidebar */}
<div className="w-64 flex-shrink-0 space-y-6">
{/* User Profile Card */}
<div className="bg-white rounded-xl p-6 border border-slate-200 flex flex-col items-center">
<div className="w-20 h-20 bg-slate-100 rounded-full flex items-center justify-center mb-4 relative group cursor-pointer overflow-hidden">
{/* Avatar */}
{user?.avatar ? <img src={user.avatar} className="w-full h-full object-cover" /> : <User className="w-8 h-8 text-slate-400" />}
<div className="absolute inset-0 bg-black/40 hidden group-hover:flex items-center justify-center transition-all">
<Edit className="w-4 h-4 text-white" />
</div>
</div>
<div className="text-center mb-1">
<h2 className="font-bold text-lg text-slate-800 flex items-center gap-2 justify-center">
{user?.nickName || user?.userName || '加载中...'}
<span className="text-[10px] bg-amber-100 text-amber-700 px-1.5 py-0.5 rounded border border-amber-200">
</span>
</h2>
</div>
<div className="flex items-center gap-4 text-xs text-slate-500 mt-2 w-full justify-center">
<div className="flex flex-col items-center">
<span className="font-bold text-slate-800 text-base">28</span>
<span></span>
</div>
<div className="w-px h-8 bg-slate-100"></div>
<div className="flex flex-col items-center">
<span className="font-bold text-slate-800 text-base">12</span>
<span></span>
</div>
</div>
</div>
{/* Navigation Menu */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
<div className="p-2 space-y-1">
<button
onClick={() => setActiveTab('info')}
className={cn(
"w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-lg transition-colors",
activeTab === 'info'
? "bg-cyan-50 text-[#0e7490]"
: "text-slate-600 hover:bg-slate-50"
)}
>
<User className="w-4 h-4" />
</button>
<button
onClick={() => setActiveTab('resources')}
className={cn(
"w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-lg transition-colors",
activeTab === 'resources'
? "bg-cyan-50 text-[#0e7490]"
: "text-slate-600 hover:bg-slate-50"
)}
>
<Box className="w-4 h-4" />
</button>
<button
onClick={() => setActiveTab('favorites')}
className={cn(
"w-full flex items-center gap-3 px-4 py-3 text-sm font-medium rounded-lg transition-colors",
activeTab === 'favorites'
? "bg-cyan-50 text-[#0e7490]"
: "text-slate-600 hover:bg-slate-50"
)}
>
<Heart className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* Right Content Area */}
<div className="flex-1 bg-white rounded-xl border border-slate-200 min-h-[600px] flex flex-col">
{activeTab === 'resources' && (
<>
<div className="p-6 border-b border-slate-100">
<h1 className="text-xl font-bold text-slate-800 mb-6"></h1>
{/* Tabs */}
<div className="flex items-center gap-8 border-b border-slate-100">
{['all', 'published', 'draft', 'auditing'].map((tab) => (
<button
key={tab}
onClick={() => setResourceFilter(tab)}
className={cn(
"pb-3 text-sm font-medium transition-all relative",
resourceFilter === tab
? "text-[#0e7490]"
: "text-slate-500 hover:text-slate-800"
)}
>
{tab === 'all' && '全部资源'}
{tab === 'published' && '已发布'}
{tab === 'draft' && '草稿箱'}
{tab === 'auditing' && '审核中'}
{resourceFilter === tab && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-[#0e7490]"></div>
)}
</button>
))}
</div>
</div>
{/* Table List */}
<div className="p-6">
<table className="w-full text-left text-sm">
<thead>
<tr className="text-slate-400 border-b border-slate-100">
<th className="pb-4 font-normal w-24"></th>
<th className="pb-4 font-normal"></th>
<th className="pb-4 font-normal"></th>
<th className="pb-4 font-normal"></th>
<th className="pb-4 font-normal"></th>
<th className="pb-4 font-normal"></th>
<th className="pb-4 font-normal text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-50">
{MOCK_RESOURCES.filter(item => resourceFilter === 'all' || item.status === resourceFilter).map((item) => (
<tr key={item.id} className="group hover:bg-slate-50">
<td className="py-4">
<div className="w-16 h-10 bg-slate-100 rounded overflow-hidden border border-slate-200">
<img src={item.thumbnail} alt="" className="w-full h-full object-cover" />
</div>
</td>
<td className="py-4 font-medium text-slate-700">{item.name}</td>
<td className="py-4">
<div className="flex gap-1.5">
{item.tags.map(tag => (
<span key={tag} className="px-1.5 py-0.5 bg-slate-100 text-slate-500 rounded text-xs">{tag}</span>
))}
</div>
</td>
<td className="py-4 text-slate-500">{item.uploadTime}</td>
<td className="py-4 text-slate-500">{item.downloads} </td>
<td className="py-4">{getStatusBadge(item.status)}</td>
<td className="py-4 text-right">
<div className="flex justify-end gap-3 text-xs">
<button className="text-[#0e7490] hover:underline"></button>
{item.status === 'published' && <button className="text-slate-500 hover:text-[#0e7490] hover:underline"></button>}
{item.status === 'auditing' && <button className="text-slate-500 hover:text-[#0e7490] hover:underline"></button>}
{item.status === 'draft' && <button className="text-[#0e7490] hover:underline"></button>}
<button className="text-red-500 hover:underline"></button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{MOCK_RESOURCES.filter(item => resourceFilter === 'all' || item.status === resourceFilter).length === 0 && (
<div className="py-20 text-center text-slate-400">
</div>
)}
</div>
</>
)}
{/* Placeholders for other tabs */}
{activeTab !== 'resources' && (
<div className="flex items-center justify-center h-full text-slate-400">
...
</div>
)}
</div>
</div>
</main>
</div>
)
}

View File

@ -1,122 +1,162 @@
import { useState } from 'react'
import { Filter, Download, Eye, Heart } from 'lucide-react'
import { useState, useEffect } from 'react'
import { Filter, Download, Eye, Heart, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardFooter } from '@/components/ui/card'
import { cn } from '@/lib/utils'
import { getSimulationUploadList } from '@/api/simulation'
import { SimulationResource } from '@/api/simulationTypes'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
// 模拟数据
const tabs = [
"全部", "地形", "天气", "特效", "高亮", "视频", "VR", "动画", "前端", "后端", "特殊"
]
interface Asset {
id: string
title: string
tags: string[]
author: string
date: string
downloads: number
imageUrl: string // In a real app, this would be a real URL
}
const mockAssets: Asset[] = Array(10).fill(null).map((_, i) => ({
id: `asset-${i}`,
title: 'SKY MASTER ULTIMATE 天空大师终极版版版版版版版',
tags: ['Unity', '实验室场景'],
author: '张老师',
date: '2021-05-10',
downloads: 128,
// Using a placeholder image color or pattern
imageUrl: ''
}))
export default function AssetLibrary() {
const [activeTab, setActiveTab] = useState("全部")
const [assets, setAssets] = useState<SimulationResource[]>([])
const [loading, setLoading] = useState(false)
const [total, setTotal] = useState(0)
// Fetch data
useEffect(() => {
const fetchAssets = async () => {
try {
setLoading(true)
const response = await getSimulationUploadList({
pageNum: 1,
pageSize: 20, // Fetch more initially
types: activeTab === "全部" ? undefined : activeTab
})
if (response.code === 200) {
setAssets(response.rows || [])
setTotal(response.total || 0)
}
} catch (error) {
console.error("Failed to fetch assets:", error)
} finally {
setLoading(false)
}
}
fetchAssets()
}, [activeTab])
return (
<div className="bg-white rounded-xl shadow-sm min-h-full flex flex-col">
{/* Top Tabs & Filter Bar */}
<div className="flex items-center justify-between px-6 border-b border-slate-100">
<div className="flex items-center gap-14 overflow-x-auto no-scrollbar pl-14 pr-14">
{tabs.map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={cn(
"py-4 text-sm font-medium transition-all relative",
activeTab === tab
? "text-[#0e7490] font-bold"
: "text-slate-500 hover:text-slate-800"
)}
>
{tab}
{activeTab === tab && (
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-[300%] h-0.5 bg-[#0e7490]" />
)}
</button>
))}
<TooltipProvider>
<div className="bg-white rounded-xl shadow-sm min-h-full flex flex-col">
{/* Top Tabs & Filter Bar */}
<div className="flex items-center justify-between px-6 border-b border-slate-100">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 overflow-x-auto no-scrollbar py-2">
<TabsList className="bg-transparent h-auto gap-0 p-0">
{tabs.map((tab) => (
<TabsTrigger
key={tab}
value={tab}
className={cn(
"py-4 px-8 text-sm font-semibold transition-all relative rounded-none border-b-0 data-[state=active]:text-[#0e7490] data-[state=active]:bg-transparent data-[state=active]:shadow-none text-slate-500 hover:text-[#0e7490]",
"after:absolute after:bottom-0 after:left-8 after:right-8 after:h-[3.5px] after:bg-[#0e7490] after:scale-x-0 data-[state=active]:after:scale-x-100 after:transition-transform after:duration-200 after:rounded-t-full"
)}
>
{tab}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="flex items-center gap-4">
<span className="text-xs text-slate-400 whitespace-nowrap"> {total} </span>
<Button variant="ghost" size="sm" className="gap-2 text-slate-600 hover:text-[#0e7490]">
<Filter className="w-4 h-4" />
</Button>
</div>
</div>
<Button variant="ghost" size="sm" className="gap-2 text-slate-600 hover:text-[#0e7490]">
<Filter className="w-4 h-4" />
</Button>
</div>
{/* Asset Grid */}
<div className="p-6">
{loading ? (
<div className="flex h-64 items-center justify-center text-slate-400">
<Loader2 className="w-8 h-8 animate-spin" />
</div>
) : assets.length === 0 ? (
<div className="flex h-64 items-center justify-center text-slate-400 text-sm">
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{assets.map((asset) => (
<Card key={asset.id} className="overflow-hidden hover:shadow-xl transition-all duration-300 border-slate-100 group bg-white">
{/* Image Area */}
<div className="aspect-video bg-slate-100 relative overflow-hidden">
{/* Placeholder Gradient */}
<div className="absolute inset-0 bg-gradient-to-br from-slate-200 to-slate-300 group-hover:scale-105 transition-transform duration-500" />
{/* Asset Grid */}
<div className="p-6">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{mockAssets.map((asset) => (
<Card key={asset.id} className="overflow-hidden hover:shadow-xl transition-all duration-300 border-slate-100 group bg-white">
{/* Image Area */}
<div className="aspect-video bg-slate-100 relative overflow-hidden">
{/* Placeholder Gradient */}
<div className="absolute inset-0 bg-gradient-to-br from-slate-200 to-slate-300 group-hover:scale-105 transition-transform duration-500" />
{/* You would put <img src={asset.imageUrl} ... /> here */}
{/* Hover Actions Overlay */}
<div className="absolute top-3 right-3 flex gap-2 opacity-0 group-hover:opacity-100 transition-all duration-300 translate-y-[-10px] group-hover:translate-y-0 z-10">
<Tooltip>
<TooltipTrigger asChild>
<button className="w-8 h-8 rounded-full bg-black/20 hover:bg-black/40 text-white flex items-center justify-center backdrop-blur-sm transition-colors">
<Eye className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top"></TooltipContent>
</Tooltip>
{/* Hover Actions Overlay */}
<div className="absolute top-3 right-3 flex gap-2 opacity-0 group-hover:opacity-100 transition-all duration-300 translate-y-[-10px] group-hover:translate-y-0 z-10">
<button className="w-8 h-8 rounded-full bg-black/20 hover:bg-black/40 text-white flex items-center justify-center backdrop-blur-sm transition-colors">
<Eye className="w-4 h-4" />
</button>
<button className="w-8 h-8 rounded-full bg-black/20 hover:bg-black/40 text-white flex items-center justify-center backdrop-blur-sm transition-colors">
<Heart className="w-4 h-4" />
</button>
<button className="w-8 h-8 rounded-full bg-black/20 hover:bg-black/40 text-white flex items-center justify-center backdrop-blur-sm transition-colors">
<Download className="w-4 h-4" />
</button>
</div>
</div>
<Tooltip>
<TooltipTrigger asChild>
<button className="w-8 h-8 rounded-full bg-black/20 hover:bg-black/40 text-white flex items-center justify-center backdrop-blur-sm transition-colors">
<Heart className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top"></TooltipContent>
</Tooltip>
<CardContent className="p-4 space-y-3">
<h3 className="font-bold text-slate-800 line-clamp-2 text-sm leading-relaxed min-h-[40px]" title={asset.title}>
{asset.title}
</h3>
<Tooltip>
<TooltipTrigger asChild>
<button className="w-8 h-8 rounded-full bg-black/20 hover:bg-black/40 text-white flex items-center justify-center backdrop-blur-sm transition-colors">
<Download className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top"></TooltipContent>
</Tooltip>
</div>
</div>
<div className="flex flex-wrap gap-2">
{asset.tags.map(tag => (
<span key={tag} className="px-2 py-0.5 bg-slate-50 text-slate-400 text-xs rounded-sm border border-slate-100">
{tag}
</span>
))}
</div>
</CardContent>
<CardContent className="p-4 space-y-3">
<h3 className="font-bold text-slate-800 line-clamp-2 text-sm leading-relaxed min-h-[40px]" title={asset.name}>
{asset.name}
</h3>
<CardFooter className="p-4 pt-0 flex items-center justify-between text-xs text-slate-400 border-t border-slate-50 mt-2 pt-3">
<div className="flex items-center gap-2">
<span>{asset.author}</span>
<span className="text-slate-300">|</span>
<span>{asset.date}</span>
</div>
<div className="flex items-center gap-1">
<Download className="w-3 h-3" />
<span>{asset.downloads}</span>
</div>
</CardFooter>
</Card>
))}
<div className="flex flex-wrap gap-2">
{asset.tag && asset.tag.split(/[,]/).map(tag => (
<Badge key={tag} variant="slate" className="font-normal border-slate-100">
{tag.trim()}
</Badge>
))}
</div>
</CardContent>
<CardFooter className="p-4 pt-0 flex items-center justify-between text-xs text-slate-400 border-t border-slate-50 mt-2 pt-3">
<div className="flex items-center gap-2">
<span>{asset.remark || asset.createdBy || 'Unknown'}</span>
<span className="text-slate-300">|</span>
<span>{asset.createdTime ? asset.createdTime.split(' ')[0] : '-'}</span>
</div>
<div className="flex items-center gap-1">
<Download className="w-3 h-3" />
<span>0</span>
</div>
</CardFooter>
</Card>
))}
</div>
)}
</div>
</div>
</div>
</TooltipProvider>
)
}

View File

@ -1,26 +1,67 @@
import { useState, type ChangeEvent } from 'react'
import { useState, useEffect, type ChangeEvent, useContext } from 'react'
import { useNavigate } from 'react-router-dom'
import { ArrowLeft, Power, Upload, X, FileText, CheckCircle2 } from 'lucide-react'
import { ArrowLeft, Upload, X, CheckCircle2, Loader2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { cn } from '@/lib/utils'
import { addSimulationResource } from '@/api/simulation'
import { AddSimulationResourceRequest, ProUploadItem } from '@/api/simulationTypes'
import { ToastContext } from '@/components/ui/toast-provider'
import { useUser } from '@/contexts/UserContext'
const RESOURCE_LIBRARIES: Record<string, string[]> = {
'插件库': ["地形", "天气", "特效", "高亮", "视频", "VR", "动画", "前端", "后端", "特殊"],
'组件库': ["UI组件", "功能组件", "逻辑组件", "数据组件"],
'工具库': ["调试工具", "构建工具", "测试工具", "效率工具"],
'原型库': ["低保真", "高保真", "交互原型", "静态原型"],
'设计库': ["图标", "图片", "配色方案", "字体"],
'项目库': ["物理仿真", "化学仿真", "生物仿真", "工程仿真"],
'文件库': ["文档", "表格", "幻灯片", "PDF", "图片", "视频"],
'案例库': ["教学案例", "实训案例", "演示案例"]
}
export default function UploadResource() {
const navigate = useNavigate()
const toast = useContext(ToastContext)
const [currentStep, setCurrentStep] = useState(1)
// 表单状态
const [formData, setFormData] = useState({
name: '12',
type: 'Unity插件/场景',
category: '天气',
tags: ['Unity', 'Unreal', '3D模型'],
description: '',
detailDescription: '',
name: '虚拟仿真实验平台',
stores: '插件库',
type: '地形',
category: '教学平台',
tags: ['仿真', '教学', '3D'],
description: '用于物理化学实验的虚拟仿真平台',
detailDescription: '该平台支持多种实验场景,包括光学、力学、化学等实验模块',
urls: 'http://example.com',
versions: 'v1.0.0',
env: 'Windows 10/Linux',
createdBy: '',
files: [] as File[],
thumbnail: null as File | null,
thumbnailUrl: '' as string,
})
const { user } = useUser()
// 当用户信息加载后,自动设置上传人
useEffect(() => {
if (user?.nickName) {
setFormData(prev => ({ ...prev, createdBy: user.nickName }))
}
}, [user])
// Mock file upload progress
const [uploadProgress, setUploadProgress] = useState(0)
const [isUploading, setIsUploading] = useState(false)
@ -46,50 +87,81 @@ export default function UploadResource() {
setIsUploading(true)
let progress = 0
const interval = setInterval(() => {
progress += 10
progress += 20
setUploadProgress(progress)
if (progress >= 100) {
clearInterval(interval)
setIsUploading(false)
}
}, 200)
}, 100)
setFormData({ ...formData, files: Array.from(e.target.files) })
}
}
return (
<div className="h-screen bg-slate-50 flex flex-col overflow-hidden">
{/* Custom Header for Upload Page */}
<header className="h-16 bg-white border-b px-6 flex items-center justify-between shadow-sm shrink-0">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-[#0e7490] rounded-md flex items-center justify-center">
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<span className="text-xl font-bold text-slate-800"></span>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-slate-600">
<div className="w-8 h-8 bg-slate-200 rounded-full flex items-center justify-center">
<UserIcon />
</div>
<span className="text-sm font-medium">XXX</span>
</div>
<div className="h-4 w-[1px] bg-slate-300"></div>
<button className="p-2 hover:bg-slate-100 rounded-full text-slate-500 transition-colors">
<Power className="w-5 h-5" />
</button>
</div>
</header>
const handleThumbnailChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
const file = e.target.files[0];
const url = URL.createObjectURL(file);
setFormData((prev) => ({ ...prev, thumbnail: file, thumbnailUrl: url }))
}
}
const handleSubmit = async () => {
try {
setIsUploading(true)
const proUploadList: ProUploadItem[] = formData.files.map((file: File) => ({
sourceName: formData.name,
sourceForm: "RES-" + Date.now(),
fileType: file.name.split('.').pop()?.toUpperCase() || 'OTHER',
fileName: file.name,
fileUrl: "/upload/temp/" + file.name, // Mock URL
fileVersion: formData.versions
}))
const requestData: AddSimulationResourceRequest = {
name: formData.name,
stores: formData.stores,
types: formData.type,
category: formData.category,
tag: formData.tags.join(','),
introduction: formData.description,
details: formData.detailDescription,
urls: formData.urls,
versions: formData.versions,
env: formData.env,
createdBy: formData.createdBy,
proUploadList
}
const response = await addSimulationResource(requestData)
if (response.code === 200 || response.code === 0) {
toast?.showToast('上传成功!', 'success')
navigate('/app')
} else {
toast?.showToast(response.msg || '上传失败,请稍后重试', 'error')
}
} catch (error) {
console.error('Submit error:', error)
toast?.showToast('请求出错,请检查网络连接', 'error')
} finally {
setIsUploading(false)
}
}
return (
<div className="flex flex-col h-full bg-slate-50">
{/* Main Content */}
{/* Main Content */}
<main className="flex-1 w-full p-2.5 flex flex-col gap-2.5 overflow-hidden">
{/* Back Button */}
<div className="shrink-0">
<button
onClick={() => navigate(-1)}
onClick={() => navigate('/app')}
className="flex items-center gap-1 text-slate-500 hover:text-slate-800 text-sm font-medium transition-colors"
>
<ArrowLeft className="w-4 h-4" />
@ -120,7 +192,7 @@ export default function UploadResource() {
{/* Step Circles and Labels - 使用 justify-between 让第一个和最后一个分别在两端 */}
<div className="flex justify-between items-start relative z-10">
{steps.map((step, index) => (
{steps.map((step) => (
<div key={step.number} className="flex flex-col items-center flex-shrink-0">
<div className={cn(
"w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold transition-colors duration-300 mb-2 relative z-10",
@ -151,23 +223,64 @@ export default function UploadResource() {
<label className="text-right text-sm text-slate-500"><span className="text-red-500 mr-1">*</span></label>
<Input
value={formData.name}
onChange={e => setFormData({...formData, name: e.target.value})}
onChange={e => setFormData({ ...formData, name: e.target.value })}
/>
</div>
<div className="grid grid-cols-[100px_1fr] items-center gap-4">
<label className="text-right text-sm text-slate-500"><span className="text-red-500 mr-1">*</span></label>
<Select
value={formData.stores}
onValueChange={value => {
setFormData({
...formData,
stores: value,
type: RESOURCE_LIBRARIES[value]?.[0] || ''
})
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="选择资源库" />
</SelectTrigger>
<SelectContent>
{Object.keys(RESOURCE_LIBRARIES).map(store => (
<SelectItem key={store} value={store}>{store}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-[100px_1fr] items-center gap-4">
<label className="text-right text-sm text-slate-500"><span className="text-red-500 mr-1">*</span></label>
<Input
<Select
value={formData.type}
onChange={e => setFormData({...formData, type: e.target.value})}
/>
onValueChange={value => setFormData({ ...formData, type: value })}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="选择资源类型" />
</SelectTrigger>
<SelectContent>
{RESOURCE_LIBRARIES[formData.stores]?.map(type => (
<SelectItem key={type} value={type}>{type}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-[100px_1fr] items-center gap-4">
<label className="text-right text-sm text-slate-500"><span className="text-red-500 mr-1">*</span></label>
<Input
value={formData.category}
onChange={e => setFormData({...formData, category: e.target.value})}
onChange={e => setFormData({ ...formData, category: e.target.value })}
/>
</div>
<div className="grid grid-cols-[100px_1fr] items-center gap-4">
<label className="text-right text-sm text-slate-500"><span className="text-red-500 mr-1">*</span></label>
<Input
value={formData.createdBy}
readOnly
className="bg-slate-50 cursor-not-allowed"
/>
</div>
@ -176,15 +289,28 @@ export default function UploadResource() {
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
{formData.tags.map(tag => (
<span key={tag} className="bg-[#0e7490] text-white px-3 py-1 rounded-full text-xs flex items-center gap-1">
<Badge key={tag} className="gap-1 bg-[#0e7490] hover:bg-[#0e7490]">
{tag}
<button className="hover:text-cyan-200"><X className="w-3 h-3" /></button>
</span>
<button className="hover:text-cyan-200" onClick={() => {
setFormData({ ...formData, tags: formData.tags.filter(t => t !== tag) })
}}>
<X className="w-3 h-3" />
</button>
</Badge>
))}
{['交互插件', '渲染效果', '数据采集', '虚拟实验室', '教学模板'].map(tag => (
<button key={tag} className="bg-slate-100 text-slate-600 hover:bg-slate-200 px-3 py-1 rounded-full text-xs transition-colors">
{['交互插件', '渲染效果', '数据采集', '虚拟实验室', '教学模板'].map((tag: string) => (
<Badge
key={tag}
variant="secondary"
className="cursor-pointer font-normal border-slate-200"
onClick={() => {
if (!formData.tags.includes(tag)) {
setFormData({ ...formData, tags: [...formData.tags, tag] })
}
}}
>
{tag}
</button>
</Badge>
))}
</div>
<Input placeholder="自定义标签" className="max-w-[200px]" />
@ -193,8 +319,8 @@ export default function UploadResource() {
</div>
<div className="flex justify-between pt-10">
<Button variant="secondary" className="bg-slate-100 text-slate-600 hover:bg-slate-200">稿</Button>
<Button onClick={handleNext} className="bg-[#0e7490] hover:bg-[#0891b2] min-w-[100px]"></Button>
<Button variant="secondary" className="bg-slate-100 text-slate-600 hover:bg-slate-200">稿</Button>
<Button onClick={handleNext} className="bg-[#0e7490] hover:bg-[#0891b2] min-w-[100px]"></Button>
</div>
</div>
)}
@ -221,47 +347,65 @@ export default function UploadResource() {
{/* Progress */}
{(isUploading || formData.files.length > 0) && (
<div className="bg-slate-50 border border-slate-100 rounded-lg p-4 flex items-center gap-4">
<div className="w-10 h-10 bg-red-100 rounded flex items-center justify-center text-red-500 font-bold text-xs">
PDF
</div>
<div className="flex-1">
<div className="flex justify-between text-xs mb-1">
<span className="font-medium text-slate-700">.pdf</span>
<span className="text-slate-400">34.01KB</span>
</div>
<div className="h-1.5 bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-[#0e7490] transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
<button className="text-slate-400 hover:text-slate-600">
<X className="w-4 h-4" />
</button>
</div>
<div className="bg-slate-50 border border-slate-100 rounded-lg p-4 flex items-center gap-4">
<div className="w-10 h-10 bg-red-100 rounded flex items-center justify-center text-red-500 font-bold text-xs">
PDF
</div>
<div className="flex-1">
<div className="flex justify-between text-xs mb-1">
<span className="font-medium text-slate-700">.pdf</span>
<span className="text-slate-400">34.01KB</span>
</div>
<div className="h-1.5 bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-[#0e7490] transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
<button className="text-slate-400 hover:text-slate-600">
<X className="w-4 h-4" />
</button>
</div>
)}
<div className="space-y-2">
<h3 className="text-base font-bold text-slate-800 text-xs text-slate-500"></h3>
<div className="border-2 border-dashed border-slate-200 rounded-lg p-8 flex flex-col items-center justify-center text-center hover:bg-slate-50 transition-colors cursor-pointer h-40">
<div className="w-10 h-10 bg-slate-100 text-slate-400 rounded-lg flex items-center justify-center mb-2">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<h3 className="text-base font-bold text-slate-800 text-xs text-slate-500"></h3>
<div className="border-2 border-dashed border-slate-200 rounded-lg p-8 flex flex-col items-center justify-center text-center hover:bg-slate-50 transition-colors cursor-pointer h-40 relative overflow-hidden group">
<input
type="file"
accept="image/png, image/jpeg"
className="absolute inset-0 opacity-0 cursor-pointer z-10"
onChange={handleThumbnailChange}
/>
{formData.thumbnailUrl ? (
<div className="absolute inset-0 w-full h-full">
<img src={formData.thumbnailUrl} alt="Thumbnail preview" className="w-full h-full object-contain" />
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<p className="text-white text-sm font-medium"></p>
</div>
</div>
<p className="text-xs font-medium text-slate-600 mb-1"></p>
<p className="text-[10px] text-slate-400">JPG/PNG | 800x600px | 5MB</p>
</div>
) : (
<>
<div className="w-10 h-10 bg-slate-100 text-slate-400 rounded-lg flex items-center justify-center mb-2">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<p className="text-xs font-medium text-slate-600 mb-1"></p>
<p className="text-[10px] text-slate-400">JPG/PNG | 800x600px | 5MB</p>
</>
)}
</div>
</div>
<div className="flex justify-between pt-4">
<Button variant="secondary" className="bg-slate-100 text-slate-600 hover:bg-slate-200">稿</Button>
<div className="flex gap-3">
<Button variant="outline" onClick={handlePrev}></Button>
<Button onClick={handleNext} className="bg-[#0e7490] hover:bg-[#0891b2] min-w-[100px]"></Button>
</div>
<Button variant="secondary" className="bg-slate-100 text-slate-600 hover:bg-slate-200">稿</Button>
<div className="flex gap-3">
<Button variant="outline" onClick={handlePrev}></Button>
<Button onClick={handleNext} className="bg-[#0e7490] hover:bg-[#0891b2] min-w-[100px]"></Button>
</div>
</div>
</div>
)}
@ -277,7 +421,7 @@ export default function UploadResource() {
placeholder="请简要描述资源的功能、特点和使用场景200字以内"
className="min-h-[100px]"
value={formData.description}
onChange={e => setFormData({...formData, description: e.target.value})}
onChange={e => setFormData({ ...formData, description: e.target.value })}
/>
</div>
@ -287,39 +431,51 @@ export default function UploadResource() {
placeholder="请详细描述资源的安装、使用、适配环境等信息(可选)"
className="min-h-[150px]"
value={formData.detailDescription}
onChange={e => setFormData({...formData, detailDescription: e.target.value})}
onChange={e => setFormData({ ...formData, detailDescription: e.target.value })}
/>
</div>
<div className="grid grid-cols-[100px_1fr] items-center gap-4">
<label className="text-right text-sm text-slate-500">使/</label>
<Input placeholder="如GitHub、百度网盘、在线文档等链接可选" />
<Input
placeholder="如GitHub、百度网盘、在线文档等链接可选"
value={formData.urls}
onChange={e => setFormData({ ...formData, urls: e.target.value })}
/>
</div>
<div className="grid grid-cols-[100px_1fr] items-center gap-4">
<label className="text-right text-sm text-slate-500"></label>
<Input placeholder="如V1.0、2021版可选" defaultValue="如V1.0、2021版可选" />
<label className="text-right text-sm text-slate-500"></label>
<Input
placeholder="如V1.0.0"
value={formData.versions}
onChange={e => setFormData({ ...formData, versions: e.target.value })}
/>
</div>
<div className="grid grid-cols-[100px_1fr] items-center gap-4">
<label className="text-right text-sm text-slate-500"></label>
<Input placeholder="如Unity2021.3、Windows 10可选" defaultValue="如Unity2021.3、Windows 10可选" />
<label className="text-right text-sm text-slate-500"></label>
<Input
placeholder="如Windows 10/Linux"
value={formData.env}
onChange={e => setFormData({ ...formData, env: e.target.value })}
/>
</div>
<div className="grid grid-cols-[100px_1fr] items-center gap-4">
<label className="text-right text-sm text-slate-800 font-bold"></label>
<div className="bg-slate-50 p-3 rounded text-sm text-[#0e7490] font-medium">
.pdf
.pdf
</div>
</div>
</div>
<div className="flex justify-between pt-10">
<Button variant="secondary" className="bg-slate-100 text-slate-600 hover:bg-slate-200">稿</Button>
<div className="flex gap-3">
<Button variant="outline" onClick={handlePrev}></Button>
<Button onClick={handleNext} className="bg-[#0e7490] hover:bg-[#0891b2] min-w-[100px]"></Button>
</div>
<Button variant="secondary" className="bg-slate-100 text-slate-600 hover:bg-slate-200">稿</Button>
<div className="flex gap-3">
<Button variant="outline" onClick={handlePrev}></Button>
<Button onClick={handleNext} className="bg-[#0e7490] hover:bg-[#0891b2] min-w-[100px]"></Button>
</div>
</div>
</div>
)}
@ -335,6 +491,10 @@ export default function UploadResource() {
<span className="text-slate-500"></span>
<span className="text-slate-900 font-medium">{formData.name}</span>
</div>
<div className="grid grid-cols-[80px_1fr] gap-4">
<span className="text-slate-500"></span>
<span className="text-slate-900">{formData.stores}</span>
</div>
<div className="grid grid-cols-[80px_1fr] gap-4">
<span className="text-slate-500"></span>
<span className="text-slate-900">{formData.type}</span>
@ -353,36 +513,50 @@ export default function UploadResource() {
</div>
<div className="grid grid-cols-[80px_1fr] gap-4">
<span className="text-slate-500"></span>
<span className="text-slate-900">1</span>
<span className="text-slate-900 line-clamp-1">{formData.description}</span>
</div>
<div className="grid grid-cols-[80px_1fr] gap-4">
<span className="text-slate-500"></span>
<span className="text-slate-900">{formData.createdBy}</span>
</div>
</div>
<div className="bg-orange-50 border border-orange-100 rounded-lg p-4 text-sm text-orange-800">
<div className="flex items-center gap-2 font-bold mb-2">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" /></svg>
</div>
<ul className="list-disc list-inside space-y-1 text-orange-700/80 text-xs ml-1">
<li></li>
<li></li>
<li>1-3/</li>
<li></li>
</ul>
<div className="flex items-center gap-2 font-bold mb-2">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" /></svg>
</div>
<ul className="list-disc list-inside space-y-1 text-orange-700/80 text-xs ml-1">
<li></li>
<li></li>
<li>1-3/</li>
<li></li>
</ul>
</div>
<div className="flex items-center gap-2">
<input type="checkbox" id="agreement" className="rounded border-slate-300 text-[#0e7490] focus:ring-[#0e7490]" />
<label htmlFor="agreement" className="text-sm text-slate-600">
<a href="#" className="text-[#0e7490] underline">仿</a> <a href="#" className="text-[#0e7490] underline"></a>
</label>
<div className="flex items-start gap-2">
<Checkbox
id="agreement"
className="mt-1 border-slate-300 data-[state=checked]:bg-[#0e7490] data-[state=checked]:border-[#0e7490]"
/>
<label htmlFor="agreement" className="text-sm text-slate-600">
<a href="#" className="text-[#0e7490] underline">仿</a> <a href="#" className="text-[#0e7490] underline"></a>
</label>
</div>
<div className="flex justify-between pt-4">
<Button variant="secondary" className="bg-slate-100 text-slate-600 hover:bg-slate-200">稿</Button>
<div className="flex gap-3">
<Button variant="outline" onClick={handlePrev}></Button>
<Button className="bg-[#0e7490] hover:bg-[#0891b2] min-w-[100px]"></Button>
</div>
<Button variant="secondary" className="bg-slate-100 text-slate-600 hover:bg-slate-200">稿</Button>
<div className="flex gap-3">
<Button variant="outline" onClick={handlePrev} disabled={isUploading}></Button>
<Button
onClick={handleSubmit}
className="bg-[#0e7490] hover:bg-[#0891b2] min-w-[100px]"
disabled={isUploading}
>
{isUploading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
</Button>
</div>
</div>
</div>
)}
@ -393,10 +567,4 @@ export default function UploadResource() {
)
}
function UserIcon() {
return (
<svg className="w-5 h-5 text-slate-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clipRule="evenodd" />
</svg>
)
}

View File

@ -0,0 +1,326 @@
import { useState } from 'react'
import {
Folder,
FileText,
ChevronRight,
ChevronDown,
Filter,
Download,
FileEdit,
RefreshCw,
Plus,
MoreHorizontal,
LayoutTemplate,
History,
FileDown,
ExternalLink
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table'
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from '@/components/ui/tooltip'
// Mock Data for Directory Tree
const directoryData = [
{
id: '1',
name: '虚拟仿真课程文档库',
type: 'root',
tag: '文档库',
children: [
{
id: '1-1',
name: '物理仿真项目文档',
type: 'folder',
tag: '公共',
children: [
{
id: '1-1-1',
name: '光学实验仿真文档',
type: 'doc',
tag: '文档',
selected: true
},
{
id: '1-1-2',
name: '力学虚拟实验文档',
type: 'doc',
tag: '文档'
},
{
id: '1-1-3',
name: '电磁学仿真系统文档',
type: 'doc',
tag: '私有'
}
]
},
{
id: '1-2',
name: '化学仿真项目文档',
type: 'folder',
tag: '公共'
},
{
id: '1-3',
name: '文档模版库',
type: 'folder',
tag: '模版'
}
]
}
]
// Mock Data for Files Table
const associatedFiles = [
{ name: '光学实验原理说明.pdf', type: 'PDF', size: '1.2MB', date: '2024-07-20 14:30' },
{ name: '系统安装指南.docx', type: 'Word', size: '850KB', date: '2024-07-19 10:15' },
{ name: '常见问题汇编.xlsx', type: 'Excel', size: '420KB', date: '2024-07-18 16:45' },
]
export default function FileLibrary() {
const [expandedNodes, setExpandedNodes] = useState<string[]>(['1', '1-1'])
const toggleNode = (id: string) => {
setExpandedNodes(prev =>
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
)
}
const renderTree = (nodes: any[]) => {
return nodes.map(node => (
<div key={node.id} className="ml-4">
<div
className={`flex items-center justify-between py-1.5 px-2 rounded-md cursor-pointer transition-colors ${node.selected ? 'bg-cyan-50 text-cyan-700' : 'hover:bg-slate-100 text-slate-600'}`}
onClick={() => node.children && toggleNode(node.id)}
>
<div className="flex items-center gap-2 overflow-hidden">
{node.children ? (
expandedNodes.includes(node.id) ? <ChevronDown className="w-4 h-4 shrink-0" /> : <ChevronRight className="w-4 h-4 shrink-0" />
) : (
<div className="w-4" />
)}
<div className="shrink-0">
{node.type === 'root' || node.type === 'folder' ? <Folder className={`w-4 h-4 ${node.type === 'root' ? 'text-blue-500' : 'text-amber-500'}`} /> : <FileText className="w-4 h-4 text-slate-400" />}
</div>
<span className="text-sm truncate font-medium">{node.name}</span>
</div>
{node.tag && (
<Badge
variant={
node.tag === '文档库' ? 'secondary' :
node.tag === '公共' ? 'cyan' :
node.tag === '文档' ? 'slate' :
node.tag === '私有' ? 'destructive' :
'default'
}
className="text-[10px] px-1.5 py-0 shrink-0 ml-2 h-4 leading-none font-normal"
>
{node.tag}
</Badge>
)}
</div>
{node.children && expandedNodes.includes(node.id) && (
<div className="mt-1">
{renderTree(node.children)}
</div>
)}
</div>
))
}
return (
<TooltipProvider>
<div className="flex h-full gap-6 overflow-hidden">
{/* Left Sidebar: Directory Tree */}
<div className="w-80 flex flex-col bg-white rounded-xl border border-slate-200 overflow-hidden shadow-sm">
<div className="p-4 border-b border-slate-100 flex items-center justify-between">
<h2 className="text-base font-bold text-slate-800 flex items-center gap-2">
<Folder className="w-4 h-4 text-[#0e7490]" />
</h2>
<Button variant="ghost" size="icon" className="w-8 h-8 text-slate-400 hover:text-slate-600">
<RefreshCw className="w-4 h-4" />
</Button>
</div>
<div className="flex-1 overflow-y-auto p-2">
{renderTree(directoryData)}
</div>
<div className="p-3 bg-slate-50 border-t border-slate-100">
<Button className="w-full justify-start gap-2 bg-white text-slate-700 hover:bg-white hover:text-cyan-600 border border-slate-200 shadow-sm" variant="outline">
<Plus className="w-4 h-4" />
</Button>
</div>
</div>
{/* Right Content Area */}
<div className="flex-1 flex flex-col gap-6 overflow-y-auto pr-2 no-scrollbar">
{/* Upper Header Card */}
<Card className="border-slate-200 shadow-sm overflow-hidden">
<div className="bg-gradient-to-r from-cyan-600 to-[#0e7490] px-6 py-4 flex items-center justify-between text-white">
<div className="flex items-center gap-4">
<div className="p-2 bg-white/20 rounded-lg backdrop-blur-sm">
<FileEdit className="w-6 h-6" />
</div>
<div>
<h1 className="text-xl font-bold">仿</h1>
<p className="text-cyan-50 text-xs opacity-80 mt-1">
最后更新: 2024-07-20 14:30 | 编辑人: 张建国
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button className="bg-white/10 hover:bg-white/20 text-white border-white/20 border gap-2">
<History className="w-4 h-4" />
</Button>
<Button className="bg-white text-[#0e7490] hover:bg-white/90 gap-2 font-bold shadow-md">
<ExternalLink className="w-4 h-4" />
</Button>
</div>
</div>
<CardContent className="p-8">
<section className="prose prose-slate max-w-none text-slate-600 space-y-8">
<div>
<h2 className="text-xl font-bold text-slate-800 flex items-center gap-3 mb-4">
<div className="w-1.5 h-6 bg-[#0e7490] rounded-full"></div>
1.
</h2>
<p className="leading-relaxed">仿3D光路模拟</p>
</div>
<div>
<h2 className="text-xl font-bold text-slate-800 flex items-center gap-3 mb-4">
<div className="w-1.5 h-6 bg-[#0e7490] rounded-full"></div>
2.仿
</h2>
<div className="grid grid-cols-2 gap-4">
<div className="p-4 bg-slate-50 rounded-lg border border-slate-100">
<h4 className="font-bold text-slate-700 mb-2 flex items-center gap-2">
<LayoutTemplate className="w-4 h-4 text-cyan-600" />
线
</h4>
<p className="text-sm">Ray Tracing算法</p>
</div>
<div className="p-4 bg-slate-50 rounded-lg border border-slate-100">
<h4 className="font-bold text-slate-700 mb-2 flex items-center gap-2">
<RefreshCw className="w-4 h-4 text-cyan-600" />
</h4>
<p className="text-sm"></p>
</div>
</div>
</div>
<div>
<h2 className="text-xl font-bold text-slate-800 flex items-center gap-3 mb-4">
<div className="w-1.5 h-6 bg-[#0e7490] rounded-full"></div>
3.
</h2>
<div className="space-y-3">
<p>3.1 启动系统:双击桌面快捷方式仿.exe</p>
<p>3.2 选择实验:在主界面左侧实验列表中选择需要进行的光学实验类型</p>
<p>3.3 :
<span className="block pl-4 mt-2 mb-2"> 使</span>
<span className="block pl-4 mb-2"> </span>
<span className="block pl-4 mb-2"> 仿</span>
<span className="block pl-4 mb-2"> </span>
</p>
</div>
</div>
<div>
<h2 className="text-xl font-bold text-slate-800 flex items-center gap-3 mb-4">
<div className="w-1.5 h-6 bg-[#0e7490] rounded-full"></div>
4.
</h2>
<p></p>
</div>
</section>
</CardContent>
</Card>
{/* Bottom Associated Files Table */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden shadow-sm mb-6">
<div className="px-5 py-3 bg-slate-100/50 border-b border-slate-200 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-bold text-slate-800">
<Folder className="w-4 h-4 text-amber-500 fill-amber-500" />
</div>
<Button variant="ghost" size="sm" className="h-8 text-[#0e7490] font-medium hover:bg-cyan-50">
<Plus className="w-4 h-4 mr-1" />
</Button>
</div>
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent bg-slate-50/30">
<TableHead className="w-[45%] font-bold text-slate-600"></TableHead>
<TableHead className="font-bold text-slate-600"></TableHead>
<TableHead className="font-bold text-slate-600"></TableHead>
<TableHead className="font-bold text-slate-600"></TableHead>
<TableHead className="text-center font-bold text-slate-600"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{associatedFiles.map((file, idx) => (
<TableRow key={idx} className="group cursor-default">
<TableCell>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded bg-slate-50 flex items-center justify-center text-slate-400 group-hover:bg-[#e0f2fe] group-hover:text-[#0e7490] transition-colors">
<FileText className="w-4 h-4" />
</div>
<span className="font-medium text-slate-700">{file.name}</span>
</div>
</TableCell>
<TableCell>
<Badge variant="slate" className="font-normal">{file.type}</Badge>
</TableCell>
<TableCell className="text-slate-500">{file.size}</TableCell>
<TableCell className="text-slate-500">{file.date}</TableCell>
<TableCell>
<div className="flex items-center justify-center gap-1 opacity-0 group-hover:opacity-100 transition-all">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="w-8 h-8 text-slate-400 hover:text-[#0e7490] hover:bg-cyan-50">
<Download className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top"></TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="w-8 h-8 text-slate-400 hover:text-slate-600">
<MoreHorizontal className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top"></TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</div>
</TooltipProvider>
)
}

View File

@ -1,6 +1,6 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { ArrowLeft, Power } from 'lucide-react'
import { ArrowLeft } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
@ -93,30 +93,7 @@ export default function NewProject() {
}
return (
<div className="h-screen bg-slate-50 flex flex-col overflow-hidden">
{/* Custom Header matching UploadResource */}
<header className="h-16 bg-white border-b px-6 flex items-center justify-between shadow-sm shrink-0">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-[#0e7490] rounded-md flex items-center justify-center">
<svg className="w-5 h-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<span className="text-xl font-bold text-slate-800"></span>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-slate-600">
<div className="w-8 h-8 bg-slate-200 rounded-full flex items-center justify-center">
<UserIcon />
</div>
<span className="text-sm font-medium">XXX</span>
</div>
<div className="h-4 w-[1px] bg-slate-300"></div>
<button className="p-2 hover:bg-slate-100 rounded-full text-slate-500 transition-colors">
<Power className="w-5 h-5" />
</button>
</div>
</header>
<div className="flex flex-col h-full bg-slate-50">
{/* Main Content */}
<main className="flex-1 w-full p-2.5 flex flex-col gap-2.5 overflow-hidden">
@ -338,10 +315,4 @@ export default function NewProject() {
)
}
function UserIcon() {
return (
<svg className="w-5 h-5 text-slate-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clipRule="evenodd" />
</svg>
)
}

View File

@ -14,7 +14,10 @@ import {
ArrowDownToLine,
GitCommitHorizontal,
User,
Calendar
Calendar,
GitBranch,
ChevronsUpDown,
Check
} from 'lucide-react'
@ -107,6 +110,11 @@ export default function ProjectLibrary() {
const [commits, setCommits] = useState<GiteaCommit[]>([]) // Git Commits
const [commitsLoading, setCommitsLoading] = useState(false) // Commits loading state
// Branch Management
const [branches, setBranches] = useState<any[]>([])
const [selectedBranch, setSelectedBranch] = useState<string>('')
const [isBranchDropdownOpen, setIsBranchDropdownOpen] = useState(false)
// 当repos数据变化时构建树形结构
useEffect(() => {
if (!repos || repos.length === 0) return
@ -209,7 +217,30 @@ export default function ProjectLibrary() {
return
}
console.log(`调用API获取提交记录: owner=${selectedRepo.owner.username}, repo=${selectedRepo.name}`)
console.log('调用API获取提交记录')
// 1. 首先尝试获取分支信息以快速填充 Banner通常比获取完整提交列表快得多
try {
const branchData = await giteaApi.getRepoBranch(selectedRepo.owner.username, selectedRepo.name, selectedRepo.default_branch)
if (branchData && branchData.commit) {
// 构造一个简单的 GiteaCommit 对象用于 Banner 显示
const latestCommit: any = {
sha: branchData.commit.id,
commit: {
message: branchData.commit.message,
author: {
name: branchData.commit.author.name,
date: branchData.commit.timestamp
}
}
}
setCommits([latestCommit])
// 这里不设置 Loading 结束,因为还要拉取列表
}
} catch (branchError) {
console.error("Failed to fetch branch info:", branchError)
}
// 2. 拉取完整的提交列表用于下方的记录显示
const data = await giteaApi.getRepoCommits(selectedRepo.owner.username, selectedRepo.name, 1, 20)
setCommits(data)
@ -229,6 +260,30 @@ export default function ProjectLibrary() {
}
fetchCommits()
fetchCommits()
}, [selectedRepo])
// Fetch Branches when Repo changes
useEffect(() => {
if (!selectedRepo) {
setBranches([])
setSelectedBranch('')
return
}
const fetchBranches = async () => {
try {
const data = await giteaApi.getRepoBranches(selectedRepo.owner.username, selectedRepo.name)
setBranches(data)
// Set default branch initially
if (!selectedBranch || selectedBranch !== selectedRepo.default_branch) {
setSelectedBranch(selectedRepo.default_branch)
}
} catch (error) {
console.error("Failed to fetch branches:", error)
}
}
fetchBranches()
}, [selectedRepo])
/**
@ -517,7 +572,7 @@ export default function ProjectLibrary() {
const targetPath = result.path
// 2. 委托给 DownloadManager 开始下载(支持排队和进度显示)
startDownload(selectedRepo.clone_url, targetPath)
startDownload(selectedRepo.clone_url, targetPath, selectedBranch)
} catch (error) {
console.error('打开下载目录失败:', error)
@ -759,9 +814,49 @@ export default function ProjectLibrary() {
<div className="bg-slate-50/80 rounded-sm p-6 grid grid-cols-3 gap-8 text-sm shrink-0 border-0">
<div className="space-y-2">
<div className="text-slate-400 text-xs"></div>
<div className="text-slate-700 font-medium">
{selectedRepo.default_branch}(: {new Date(selectedRepo.updated_at).toLocaleDateString()})
<div className="text-slate-400 text-xs flex items-center gap-1">
<GitBranch className="w-3 h-3" />
</div>
<div className="relative">
<div
className="flex items-center gap-2 cursor-pointer hover:bg-slate-200/50 rounded p-1 -ml-1 transition-colors"
onClick={() => setIsBranchDropdownOpen(!isBranchDropdownOpen)}
>
<span className="text-slate-700 font-medium">{selectedBranch || 'Loading...'}</span>
<ChevronsUpDown className="w-3 h-3 text-slate-400" />
</div>
{/* Branch Dropdown */}
{isBranchDropdownOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setIsBranchDropdownOpen(false)}
/>
<div className="absolute top-full left-0 mt-1 w-56 max-h-60 overflow-y-auto bg-white border border-slate-200 rounded-md shadow-lg z-20 py-1">
{branches.map((branch) => (
<div
key={branch.name}
className={cn(
"px-3 py-2 text-sm flex items-center justify-between cursor-pointer hover:bg-slate-50",
selectedBranch === branch.name && "bg-cyan-50 text-[#0e7490]"
)}
onClick={() => {
setSelectedBranch(branch.name)
setIsBranchDropdownOpen(false)
}}
>
<div className="flex items-center gap-2">
<GitBranch className="w-3.5 h-3.5 opacity-70" />
<span>{branch.name}</span>
</div>
{selectedBranch === branch.name && <Check className="w-3.5 h-3.5" />}
</div>
))}
</div>
</>
)}
</div>
</div>

35
src/vite-env.d.ts vendored
View File

@ -18,6 +18,25 @@ interface GitOperationResult {
stdout?: string
}
interface SettingsOperationResult {
success: boolean
message?: string
data?: unknown
}
interface CacheOperationResult {
success: boolean
message?: string
data?: any
exists?: boolean
}
interface WindowOperationResult {
success: boolean
message?: string
isMaximized?: boolean
}
interface ElectronAPI {
file: {
save: (content: string, filename?: string) => Promise<FileOperationResult>
@ -33,6 +52,22 @@ interface ElectronAPI {
get: (key?: string) => Promise<SettingsOperationResult>
save: (key: string, value: unknown) => Promise<SettingsOperationResult>
}
cache: {
setJSON: (key: string, data: any, config?: { maxAge?: number; namespace?: string }) => Promise<CacheOperationResult>
getJSON: (key: string, config?: { maxAge?: number; namespace?: string }) => Promise<CacheOperationResult>
setBinary: (key: string, buffer: any, type?: 'binary' | 'image', config?: { maxAge?: number; namespace?: string }) => Promise<CacheOperationResult>
getBinary: (key: string, config?: { maxAge?: number; namespace?: string }) => Promise<CacheOperationResult>
has: (key: string, config?: { namespace?: string }) => Promise<CacheOperationResult>
delete: (key: string, namespace?: string) => Promise<CacheOperationResult>
clearNamespace: (namespace: string) => Promise<CacheOperationResult>
clearAll: () => Promise<CacheOperationResult>
getStats: () => Promise<CacheOperationResult>
}
window: {
minimize: () => Promise<WindowOperationResult>
maximize: () => Promise<WindowOperationResult>
close: () => Promise<WindowOperationResult>
}
}
declare global {

View File

@ -59,6 +59,13 @@ export default defineConfig({
changeOrigin: true,
secure: false,
// 不重写路径,直接转发
},
// 代理仿真资源 API
'/zichan-api': {
target: 'http://172.16.1.144:8082',
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/zichan-api/, ''),
}
}
}