Compare commits
2 Commits
725409a24b
...
93410c55d7
| Author | SHA1 | Date |
|---|---|---|
|
|
93410c55d7 | |
|
|
271c11b453 |
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 |
|
|
@ -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.
|
|
@ -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.
|
|
@ -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>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||
|
|
|
|||
11
src/App.tsx
11
src/App.tsx
|
|
@ -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,12 +25,17 @@ const PlaceholderPage = ({ title }: { title: string }) => (
|
|||
function App() {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<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 />}>
|
||||
|
|
@ -38,7 +46,7 @@ function App() {
|
|||
<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="files" element={<FileLibrary />} />
|
||||
<Route path="cases" element={<PlaceholderPage title="案例库" />} />
|
||||
<Route path="pending" element={<PlaceholderPage title="待审核" />} />
|
||||
</Route>
|
||||
|
|
@ -48,6 +56,7 @@ function App() {
|
|||
</Routes>
|
||||
</HashRouter>
|
||||
</DownloadManagerProvider>
|
||||
</UserProvider>
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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,6 +31,7 @@ export function Header() {
|
|||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
|
|
@ -52,50 +65,83 @@ export function Header() {
|
|||
上传资源
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-slate-200 mx-2"></div>
|
||||
<Separator orientation="vertical" className="h-6 mx-2" />
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<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 className="w-4 h-4 text-slate-400" />
|
||||
{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-600">欢迎!XXX</span>
|
||||
<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>
|
||||
|
||||
{/* Window Controls */}
|
||||
<div className="h-6 w-px bg-slate-200 mx-2"></div>
|
||||
<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}
|
||||
title="最小化"
|
||||
>
|
||||
<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}
|
||||
title="最大化/还原"
|
||||
>
|
||||
<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}
|
||||
title="关闭"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>关闭</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -13,7 +13,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|||
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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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
|
||||
},
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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 (
|
||||
|
|
@ -25,7 +55,7 @@ export default function Login() {
|
|||
className="absolute inset-0 z-0"
|
||||
style={{
|
||||
//background: 'linear-gradient(135deg, #e0f2fe 0%, #f0f9ff 100%)', // 默认淡蓝色渐变
|
||||
backgroundImage: 'url("/src/assets/images/登录背景.png")', // 替换为实际背景图路径
|
||||
backgroundImage: `url("${bgImage}")`, // 使用导入的图片以确保打包后路径正确
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -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 (
|
||||
<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">
|
||||
<div className="flex items-center gap-14 overflow-x-auto no-scrollbar pl-14 pr-14">
|
||||
<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) => (
|
||||
<button
|
||||
<TabsTrigger
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
value={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"
|
||||
"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}
|
||||
{activeTab === tab && (
|
||||
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-[300%] h-0.5 bg-[#0e7490]" />
|
||||
)}
|
||||
</button>
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
|
||||
{/* 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">
|
||||
{mockAssets.map((asset) => (
|
||||
{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" />
|
||||
{/* 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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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 className="font-bold text-slate-800 line-clamp-2 text-sm leading-relaxed min-h-[40px]" title={asset.name}>
|
||||
{asset.name}
|
||||
</h3>
|
||||
|
||||
<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>
|
||||
{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.author}</span>
|
||||
<span>{asset.remark || asset.createdBy || 'Unknown'}</span>
|
||||
<span className="text-slate-300">|</span>
|
||||
<span>{asset.date}</span>
|
||||
<span>{asset.createdTime ? asset.createdTime.split(' ')[0] : '-'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Download className="w-3 h-3" />
|
||||
<span>{asset.downloads}</span>
|
||||
<span>0</span>
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -155,12 +227,44 @@ export default function UploadResource() {
|
|||
/>
|
||||
</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">
|
||||
|
|
@ -171,20 +275,42 @@ export default function UploadResource() {
|
|||
/>
|
||||
</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>
|
||||
|
||||
<div className="grid grid-cols-[100px_1fr] items-start gap-4">
|
||||
<label className="text-right text-sm text-slate-500 pt-2"><span className="text-red-500 mr-1">*</span>资源标签</label>
|
||||
<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">
|
||||
{tag}
|
||||
<button className="hover:text-cyan-200"><X className="w-3 h-3" /></button>
|
||||
</span>
|
||||
))}
|
||||
{['交互插件', '渲染效果', '数据采集', '虚拟实验室', '教学模板'].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">
|
||||
<Badge key={tag} className="gap-1 bg-[#0e7490] hover:bg-[#0e7490]">
|
||||
{tag}
|
||||
<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: 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}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<Input placeholder="自定义标签" className="max-w-[200px]" />
|
||||
|
|
@ -245,7 +371,23 @@ export default function UploadResource() {
|
|||
|
||||
<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="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>
|
||||
) : (
|
||||
<>
|
||||
<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" />
|
||||
|
|
@ -253,6 +395,8 @@ export default function UploadResource() {
|
|||
</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>
|
||||
|
||||
|
|
@ -293,17 +437,29 @@ export default function UploadResource() {
|
|||
|
||||
<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">
|
||||
|
|
@ -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,7 +513,11 @@ 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>
|
||||
|
||||
|
|
@ -370,8 +534,11 @@ export default function UploadResource() {
|
|||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input type="checkbox" id="agreement" className="rounded border-slate-300 text-[#0e7490] focus:ring-[#0e7490]" />
|
||||
<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>
|
||||
|
|
@ -380,8 +547,15 @@ export default function UploadResource() {
|
|||
<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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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/, ''),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue