由于上个仓库主线和分支差距过大且主线不再使用所以新建此仓库
This commit is contained in:
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files (can opt-in for committing if needed)
|
||||||
|
.env*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"idf.pythonInstallPath": "/opt/homebrew/bin/python3"
|
||||||
|
}
|
||||||
204
README.md
Normal file
204
README.md
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
# Linux 镜像源文件浏览器
|
||||||
|
|
||||||
|
一个使用 Next.js 构建的现代化 Linux 镜像源文件浏览网站,支持暗黑模式、镜像源自动配置等功能。
|
||||||
|
|
||||||
|
## 🚀 特性
|
||||||
|
|
||||||
|
- **现代化界面**: 基于 Next.js 16 和 Tailwind CSS 4 构建的响应式界面
|
||||||
|
- **文件浏览**: 支持列表和网格两种视图模式
|
||||||
|
- **暗黑模式**: 自动检测系统主题,支持手动切换
|
||||||
|
- **镜像源配置**: 一键生成常用 Linux 发行版的镜像源配置命令
|
||||||
|
- **搜索功能**: 快速搜索文件和目录
|
||||||
|
- **实时统计**: 显示目录数量、文件数量和总大小
|
||||||
|
- **配置文件**: 通过 JSON 配置文件轻松自定义
|
||||||
|
|
||||||
|
## 📦 支持的 Linux 发行版
|
||||||
|
|
||||||
|
- Ubuntu (Debian 包管理器)
|
||||||
|
- CentOS/RHEL (YUM/DNF 包管理器)
|
||||||
|
- Debian (APT 包管理器)
|
||||||
|
- Arch Linux (Pacman 包管理器)
|
||||||
|
- Fedora (DNF 包管理器)
|
||||||
|
|
||||||
|
## 🛠️ 技术栈
|
||||||
|
|
||||||
|
- **前端**: Next.js 16 (App Router), React 19, TypeScript
|
||||||
|
- **样式**: Tailwind CSS 4, Font Awesome 6
|
||||||
|
- **构建工具**: Turbopack, ESLint
|
||||||
|
- **部署**: 支持 Vercel, Docker 等多种部署方式
|
||||||
|
|
||||||
|
## 🚀 快速开始
|
||||||
|
|
||||||
|
### 1. 安装依赖
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
# 或
|
||||||
|
yarn install
|
||||||
|
# 或
|
||||||
|
pnpm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 启动开发服务器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# 或
|
||||||
|
yarn dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 打开浏览器
|
||||||
|
|
||||||
|
访问 [http://localhost:3000](http://localhost:3000) 查看应用。
|
||||||
|
|
||||||
|
## ⚙️ 配置
|
||||||
|
|
||||||
|
项目通过 `config.json` 文件进行配置:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mirror": {
|
||||||
|
"name": "Linux 镜像源",
|
||||||
|
"description": "快速、稳定的 Linux 发行版镜像服务",
|
||||||
|
"baseUrl": "https://mirror.example.com"
|
||||||
|
},
|
||||||
|
"directories": [
|
||||||
|
{
|
||||||
|
"name": "ubuntu",
|
||||||
|
"path": "/var/mirror/ubuntu",
|
||||||
|
"description": "Ubuntu Linux 发行版",
|
||||||
|
"icon": "fab fa-ubuntu",
|
||||||
|
"color": "#E95420"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"features": {
|
||||||
|
"enableSearch": true,
|
||||||
|
"enableDarkMode": true,
|
||||||
|
"enableStats": true,
|
||||||
|
"enableDownload": true,
|
||||||
|
"enableCopyLink": true
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"itemsPerPage": 50,
|
||||||
|
"defaultView": "list",
|
||||||
|
"showHiddenFiles": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📁 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/ # Next.js App Router
|
||||||
|
│ ├── api/ # API 路由
|
||||||
|
│ │ ├── browse/ # 文件浏览 API
|
||||||
|
│ │ ├── command/ # 镜像源配置 API
|
||||||
|
│ │ └── config/ # 配置信息 API
|
||||||
|
│ ├── globals.css # 全局样式
|
||||||
|
│ ├── layout.tsx # 根布局
|
||||||
|
│ └── page.tsx # 主页面
|
||||||
|
├── components/ # React 组件
|
||||||
|
│ ├── FileBrowser.tsx # 文件浏览器组件
|
||||||
|
│ ├── Header.tsx # 头部组件
|
||||||
|
│ ├── Footer.tsx # 底部组件
|
||||||
|
│ └── CommandModal.tsx # 镜像源配置弹窗
|
||||||
|
├── lib/ # 工具库
|
||||||
|
│ ├── config.ts # 配置读取
|
||||||
|
│ ├── filesystem.ts # 文件系统操作
|
||||||
|
│ └── demo-data.ts # 演示数据
|
||||||
|
└── types/ # TypeScript 类型定义
|
||||||
|
└── index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🏗️ 部署
|
||||||
|
|
||||||
|
### Vercel 部署 (推荐)
|
||||||
|
|
||||||
|
1. 将代码推送到 GitHub
|
||||||
|
2. 在 Vercel 中导入项目
|
||||||
|
3. 配置环境变量
|
||||||
|
4. 自动部署完成
|
||||||
|
|
||||||
|
### Docker 部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建镜像
|
||||||
|
docker build -t linux-mirror-browser .
|
||||||
|
|
||||||
|
# 运行容器
|
||||||
|
docker run -p 3000:3000 -v /path/to/mirror:/var/mirror linux-mirror-browser
|
||||||
|
```
|
||||||
|
|
||||||
|
### 传统部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建项目
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# 启动生产服务器
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔧 自定义
|
||||||
|
|
||||||
|
### 添加新的 Linux 发行版
|
||||||
|
|
||||||
|
1. 在 `config.json` 的 `directories` 中添加新目录
|
||||||
|
2. 在 `linuxCommands` 中添加对应的配置命令
|
||||||
|
3. 在 `src/lib/demo-data.ts` 中添加演示数据(可选)
|
||||||
|
|
||||||
|
### 修改样式
|
||||||
|
|
||||||
|
- 编辑 `src/app/globals.css` 添加自定义样式
|
||||||
|
- 修改 `tailwind.config.js` 配置 Tailwind CSS
|
||||||
|
|
||||||
|
### 扩展功能
|
||||||
|
|
||||||
|
- 在 `src/app/api/` 下添加新的 API 路由
|
||||||
|
- 在 `src/components/` 下添加新的组件
|
||||||
|
|
||||||
|
## 🔒 安全注意事项
|
||||||
|
|
||||||
|
1. **路径安全**: 项目包含路径验证,防止目录遍历攻击
|
||||||
|
2. **文件权限**: 确保运行用户有适当权限访问镜像目录
|
||||||
|
3. **网络安全**: 生产环境建议配置 HTTPS 和防火墙
|
||||||
|
|
||||||
|
## 🤝 贡献
|
||||||
|
|
||||||
|
欢迎提交 Issue 和 Pull Request!
|
||||||
|
|
||||||
|
## 📄 许可证
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
## 🙏 致谢
|
||||||
|
|
||||||
|
- [Next.js](https://nextjs.org/) - React 框架
|
||||||
|
- [Tailwind CSS](https://tailwindcss.com/) - CSS 框架
|
||||||
|
- [Font Awesome](https://fontawesome.com/) - 图标库
|
||||||
|
- [Context7](https://context7.dev/) - 技术文档支持
|
||||||
|
|
||||||
|
## 📱 功能演示
|
||||||
|
|
||||||
|
### 主要功能
|
||||||
|
|
||||||
|
1. **文件浏览**: 以列表或网格形式浏览镜像文件
|
||||||
|
2. **搜索**: 实时搜索文件和目录
|
||||||
|
3. **镜像源配置**: 点击"添加镜像源"按钮生成配置命令
|
||||||
|
4. **暗黑模式**: 点击月亮/太阳图标切换主题
|
||||||
|
5. **响应式设计**: 支持手机、平板和桌面设备
|
||||||
|
|
||||||
|
### 使用镜像源配置
|
||||||
|
|
||||||
|
1. 点击页面顶部的"添加镜像源"按钮
|
||||||
|
2. 选择您的 Linux 发行版
|
||||||
|
3. 复制生成的命令
|
||||||
|
4. 在终端中执行命令
|
||||||
|
5. 更新软件包列表
|
||||||
|
|
||||||
|
### 配置文件说明
|
||||||
|
|
||||||
|
- `config.json`: 主配置文件,包含镜像源信息、目录配置和功能开关
|
||||||
|
- 支持通过修改配置文件来自定义界面颜色、图标、功能等
|
||||||
|
- 配置更改后需要重启开发服务器
|
||||||
67
config.json
Normal file
67
config.json
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
{
|
||||||
|
"mirror": {
|
||||||
|
"name": "Linux 镜像源",
|
||||||
|
"description": "快速、稳定的 Linux 发行版镜像服务",
|
||||||
|
"baseUrl": "http://127.0.0.1:3000",
|
||||||
|
"logo": "/logo.svg"
|
||||||
|
},
|
||||||
|
"directories": [
|
||||||
|
{
|
||||||
|
"name": "ubuntu",
|
||||||
|
"path": "/Users/hokori/code/html/mirrors/f/ubuntu",
|
||||||
|
"description": "Ubuntu Linux 发行版",
|
||||||
|
"icon": "fab fa-ubuntu",
|
||||||
|
"color": "#E95420"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "centos",
|
||||||
|
"path": "/Users/hokori/code/html/mirrors/f/centos",
|
||||||
|
"description": "CentOS Linux 发行版",
|
||||||
|
"icon": "fab fa-centos",
|
||||||
|
"color": "#932279"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "debian",
|
||||||
|
"path": "/Users/hokori/code/html/mirrors/f/debian",
|
||||||
|
"description": "Debian Linux 发行版",
|
||||||
|
"icon": "fab fa-debian",
|
||||||
|
"color": "#A80030"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "archlinux",
|
||||||
|
"path": "/Users/hokori/code/html/mirrors/f/archlinux",
|
||||||
|
"description": "Arch Linux 发行版",
|
||||||
|
"icon": "fab fa-linux",
|
||||||
|
"color": "#1793D1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "fedora",
|
||||||
|
"path": "/Users/hokori/code/html/mirrors/f/fedora",
|
||||||
|
"description": "Fedora Linux 发行版",
|
||||||
|
"icon": "fab fa-fedora",
|
||||||
|
"color": "#294172"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"features": {
|
||||||
|
"enableSearch": true,
|
||||||
|
"enableDarkMode": true,
|
||||||
|
"enableStats": true,
|
||||||
|
"enableDownload": true,
|
||||||
|
"enableCopyLink": true,
|
||||||
|
"maxFileSize": "50GB",
|
||||||
|
"supportedFormats": [".iso", ".tar.gz", ".tar.bz2", ".deb", ".rpm", ".zip"]
|
||||||
|
},
|
||||||
|
"ui": {
|
||||||
|
"itemsPerPage": 50,
|
||||||
|
"defaultView": "list",
|
||||||
|
"showHiddenFiles": false,
|
||||||
|
"refreshInterval": 300
|
||||||
|
},
|
||||||
|
"linuxCommands": {
|
||||||
|
"ubuntu": "echo 'deb https://mirror.example.com/ubuntu/ $(lsb_release -cs) main restricted universe multiverse' | sudo tee /etc/apt/sources.list.d/mirror.list",
|
||||||
|
"centos": "sudo yum-config-manager --add-repo=https://mirror.example.com/centos/$(rpm -E %rhel)/BaseOS/x86_64/os/",
|
||||||
|
"debian": "echo 'deb https://mirror.example.com/debian $(lsb_release -cs) main' | sudo tee /etc/apt/sources.list.d/mirror.list",
|
||||||
|
"archlinux": "sudo pacman-mirrors -i -c China -m rank",
|
||||||
|
"fedora": "sudo dnf config-manager --add-repo=https://mirror.example.com/fedora/releases/$(rpm -E %fedora)/Everything/x86_64/os/"
|
||||||
|
}
|
||||||
|
}
|
||||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
30
next.config.js
Normal file
30
next.config.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
// 配置静态文件目录
|
||||||
|
async rewrites() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/ubuntu/:path*',
|
||||||
|
destination: '/api/files/ubuntu/:path*',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/centos/:path*',
|
||||||
|
destination: '/api/files/centos/:path*',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/debian/:path*',
|
||||||
|
destination: '/api/files/debian/:path*',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/archlinux/:path*',
|
||||||
|
destination: '/api/files/archlinux/:path*',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: '/fedora/:path*',
|
||||||
|
destination: '/api/files/fedora/:path*',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
/* config options here */
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
6556
package-lock.json
generated
Normal file
6556
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "linux-mirror-browser",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "16.0.7",
|
||||||
|
"react": "19.2.0",
|
||||||
|
"react-dom": "19.2.0",
|
||||||
|
"@fortawesome/fontawesome-free": "^6.5.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.0.7",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
1
public/file.svg
Normal file
1
public/file.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
1
public/window.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
46
src/app/api/auth/login/route.ts
Normal file
46
src/app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { username, password } = await request.json();
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: '用户名和密码不能为空' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取用户数据
|
||||||
|
const dbPath = join(process.cwd(), 'src', 'db', 'user.json');
|
||||||
|
const userData = JSON.parse(readFileSync(dbPath, 'utf-8'));
|
||||||
|
|
||||||
|
// 查找用户
|
||||||
|
const user = userData.users.find(
|
||||||
|
(u: any) => u.username === username && u.password === password
|
||||||
|
);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
// 登录成功,返回用户信息(不包含密码)
|
||||||
|
const { password, ...userInfo } = user;
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '登录成功',
|
||||||
|
data: userInfo
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: '用户名或密码错误' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('登录错误:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: '服务器内部错误' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/app/api/auth/logout/route.ts
Normal file
17
src/app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// 这里可以实现登出逻辑,比如清除session等
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '退出成功'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('登出错误:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: '服务器内部错误' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
237
src/app/api/browse/route.ts
Normal file
237
src/app/api/browse/route.ts
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { getDemoFiles } from '@/lib/demo-data';
|
||||||
|
|
||||||
|
// 本地配置加载函数
|
||||||
|
function loadConfig() {
|
||||||
|
try {
|
||||||
|
const configPath = join(process.cwd(), 'config.json');
|
||||||
|
const configData = readFileSync(configPath, 'utf-8');
|
||||||
|
return JSON.parse(configData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load config:', error);
|
||||||
|
return {
|
||||||
|
mirror: {
|
||||||
|
name: 'Linux 镜像源',
|
||||||
|
description: '快速、稳定的 Linux 发行版镜像服务',
|
||||||
|
baseUrl: 'https://mirror.example.com',
|
||||||
|
logo: '/logo.svg'
|
||||||
|
},
|
||||||
|
directories: [],
|
||||||
|
features: {
|
||||||
|
enableSearch: true,
|
||||||
|
enableDarkMode: true,
|
||||||
|
enableStats: true,
|
||||||
|
enableDownload: true,
|
||||||
|
enableCopyLink: true,
|
||||||
|
maxFileSize: '50GB',
|
||||||
|
supportedFormats: ['.iso', '.tar.gz', '.tar.bz2', '.deb', '.rpm', '.zip']
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
itemsPerPage: 50,
|
||||||
|
defaultView: 'list',
|
||||||
|
showHiddenFiles: false,
|
||||||
|
refreshInterval: 300
|
||||||
|
},
|
||||||
|
linuxCommands: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 服务器端文件系统操作
|
||||||
|
function getDirectoryItems(dirPath: string, showHidden: boolean = false) {
|
||||||
|
try {
|
||||||
|
const { readdirSync, statSync } = require('fs');
|
||||||
|
const { extname } = require('path');
|
||||||
|
|
||||||
|
const items = readdirSync(dirPath, { withFileTypes: true });
|
||||||
|
const result = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (!showHidden && item.name.startsWith('.')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullPath = join(dirPath, item.name);
|
||||||
|
const stats = statSync(fullPath);
|
||||||
|
|
||||||
|
const fileInfo: any = {
|
||||||
|
name: item.name,
|
||||||
|
path: fullPath,
|
||||||
|
type: item.isDirectory() ? 'dir' : 'file',
|
||||||
|
size: stats.size,
|
||||||
|
modified: stats.mtime,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (item.isFile()) {
|
||||||
|
fileInfo.ext = extname(item.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(fileInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序:目录在前,文件在后,按名称排序
|
||||||
|
return result.sort((a, b) => {
|
||||||
|
if (a.type !== b.type) {
|
||||||
|
return a.type === 'dir' ? -1 : 1;
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name, undefined, { numeric: true });
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error reading directory:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidPath(path: string, allowedPaths: string[]): boolean {
|
||||||
|
try {
|
||||||
|
const { resolve } = require('path');
|
||||||
|
const resolvedPath = resolve(path);
|
||||||
|
return allowedPaths.some(allowedPath => {
|
||||||
|
const resolvedAllowedPath = resolve(allowedPath);
|
||||||
|
return resolvedPath.startsWith(resolvedAllowedPath);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const path = searchParams.get('path') || '';
|
||||||
|
const showHidden = searchParams.get('showHidden') === 'true';
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
const allowedPaths = config.directories.map((dir: any) => dir.path);
|
||||||
|
|
||||||
|
// 如果没有指定路径,返回根目录列表
|
||||||
|
if (!path) {
|
||||||
|
const directories = config.directories.map((dir: any) => ({
|
||||||
|
name: dir.name,
|
||||||
|
path: dir.path,
|
||||||
|
type: 'dir' as const,
|
||||||
|
size: 0,
|
||||||
|
modified: new Date(),
|
||||||
|
description: dir.description,
|
||||||
|
icon: dir.icon,
|
||||||
|
color: dir.color
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: directories,
|
||||||
|
path: '',
|
||||||
|
isRoot: true,
|
||||||
|
stats: {
|
||||||
|
folders: directories.length,
|
||||||
|
files: 0,
|
||||||
|
totalSize: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否是已配置的真实文件系统路径
|
||||||
|
let useRealFileSystem = false;
|
||||||
|
let targetPath = path;
|
||||||
|
|
||||||
|
// 处理相对路径(如 "ubuntu" -> "/Users/hokori/code/html/mirrors/f")
|
||||||
|
for (const dir of config.directories) {
|
||||||
|
// 精确匹配目录名
|
||||||
|
if (dir.name === path) {
|
||||||
|
targetPath = dir.path;
|
||||||
|
useRealFileSystem = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// 处理子目录路径(如 "ubuntu/tt")
|
||||||
|
if (path.startsWith(dir.name + '/')) {
|
||||||
|
// 将相对路径转换为绝对路径
|
||||||
|
const relativePath = path.substring(dir.name.length + 1);
|
||||||
|
targetPath = dir.path + '/' + relativePath;
|
||||||
|
useRealFileSystem = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// 也支持完整路径匹配
|
||||||
|
if (path.startsWith(dir.path)) {
|
||||||
|
targetPath = path;
|
||||||
|
useRealFileSystem = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是配置的文件系统路径,直接访问真实文件系统
|
||||||
|
if (useRealFileSystem) {
|
||||||
|
// 验证路径安全性
|
||||||
|
if (!isValidPath(targetPath, allowedPaths)) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '无效的路径或访问被拒绝'
|
||||||
|
}, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取真实文件系统目录内容
|
||||||
|
let items = getDirectoryItems(targetPath, showHidden);
|
||||||
|
|
||||||
|
// 计算统计信息
|
||||||
|
const folderCount = items.filter(item => item.type === 'dir').length;
|
||||||
|
const fileCount = items.filter(item => item.type === 'file').length;
|
||||||
|
const totalSize = items
|
||||||
|
.filter(item => item.type === 'file')
|
||||||
|
.reduce((sum, item) => sum + item.size, 0);
|
||||||
|
const stats = { folders: folderCount, files: fileCount, totalSize };
|
||||||
|
|
||||||
|
// 添加额外信息
|
||||||
|
const enrichedItems = items.map((item: any) => {
|
||||||
|
const configDir = config.directories.find((dir: any) => dir.path === targetPath || dir.name === targetPath);
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
description: item.type === 'dir' ? configDir?.description : undefined,
|
||||||
|
icon: item.type === 'dir' ? configDir?.icon : undefined,
|
||||||
|
color: item.type === 'dir' ? configDir?.color : undefined
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: enrichedItems,
|
||||||
|
path, // 返回原始路径用于面包屑
|
||||||
|
isRoot: false,
|
||||||
|
stats
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果不是配置的路径,使用演示数据
|
||||||
|
const { items: demoItems, stats: demoStats } = getDemoFiles(path);
|
||||||
|
const enrichedItems = demoItems.map((item: any) => {
|
||||||
|
const configDir = config.directories.find((dir: any) => dir.name === item.name);
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
description: item.type === 'dir' ? configDir?.description : undefined,
|
||||||
|
icon: item.type === 'dir' ? configDir?.icon : undefined,
|
||||||
|
color: item.type === 'dir' ? configDir?.color : undefined
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
items: enrichedItems,
|
||||||
|
path,
|
||||||
|
isRoot: false,
|
||||||
|
stats: demoStats
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '服务器内部错误'
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/app/api/command/route.ts
Normal file
70
src/app/api/command/route.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
// 本地配置加载函数,避免客户端导入
|
||||||
|
function loadConfig() {
|
||||||
|
try {
|
||||||
|
const configPath = join(process.cwd(), 'config.json');
|
||||||
|
const configData = readFileSync(configPath, 'utf-8');
|
||||||
|
return JSON.parse(configData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load config:', error);
|
||||||
|
return {
|
||||||
|
mirror: { baseUrl: 'https://mirror.example.com' },
|
||||||
|
directories: [],
|
||||||
|
linuxCommands: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { distro } = await request.json();
|
||||||
|
|
||||||
|
if (!distro || typeof distro !== 'string') {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '请提供有效的发行版名称'
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
// 检查是否支持该发行版
|
||||||
|
if (!config.linuxCommands[distro]) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: `不支持的发行版: ${distro}`
|
||||||
|
}, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = config.linuxCommands[distro];
|
||||||
|
const baseUrl = config.mirror.baseUrl;
|
||||||
|
|
||||||
|
// 生成完整的命令
|
||||||
|
const fullCommand = command.replace('${mirrorUrl}', baseUrl);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
distro,
|
||||||
|
command: fullCommand,
|
||||||
|
description: `添加 ${config.directories.find((d: any) => d.name === distro)?.description || distro} 镜像源`,
|
||||||
|
instructions: [
|
||||||
|
'1. 打开终端',
|
||||||
|
'2. 复制并执行以下命令',
|
||||||
|
'3. 更新软件包列表',
|
||||||
|
'4. 验证镜像源是否添加成功'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '服务器内部错误'
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/app/api/config/route.ts
Normal file
71
src/app/api/config/route.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
// 本地配置加载函数
|
||||||
|
function loadConfig() {
|
||||||
|
try {
|
||||||
|
const configPath = join(process.cwd(), 'config.json');
|
||||||
|
const configData = readFileSync(configPath, 'utf-8');
|
||||||
|
return JSON.parse(configData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load config:', error);
|
||||||
|
return {
|
||||||
|
mirror: {
|
||||||
|
name: 'Linux 镜像源',
|
||||||
|
description: '快速、稳定的 Linux 发行版镜像服务',
|
||||||
|
baseUrl: 'https://mirror.example.com',
|
||||||
|
logo: '/logo.svg'
|
||||||
|
},
|
||||||
|
directories: [],
|
||||||
|
features: {
|
||||||
|
enableSearch: true,
|
||||||
|
enableDarkMode: true,
|
||||||
|
enableStats: true,
|
||||||
|
enableDownload: true,
|
||||||
|
enableCopyLink: true,
|
||||||
|
maxFileSize: '50GB',
|
||||||
|
supportedFormats: ['.iso', '.tar.gz', '.tar.bz2', '.deb', '.rpm', '.zip']
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
itemsPerPage: 50,
|
||||||
|
defaultView: 'list',
|
||||||
|
showHiddenFiles: false,
|
||||||
|
refreshInterval: 300
|
||||||
|
},
|
||||||
|
linuxCommands: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const config = loadConfig();
|
||||||
|
|
||||||
|
// 返回公开的配置信息(不包含敏感路径)
|
||||||
|
const publicConfig = {
|
||||||
|
mirror: config.mirror,
|
||||||
|
directories: config.directories.map((dir: any) => ({
|
||||||
|
name: dir.name,
|
||||||
|
description: dir.description,
|
||||||
|
icon: dir.icon,
|
||||||
|
color: dir.color
|
||||||
|
})),
|
||||||
|
features: config.features,
|
||||||
|
ui: config.ui,
|
||||||
|
linuxCommands: Object.keys(config.linuxCommands)
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: publicConfig
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API Error:', error);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '获取配置失败'
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/app/api/debug/db-connection/route.ts
Normal file
51
src/app/api/debug/db-connection/route.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { getConnectionStatus, testConnection } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
console.log('测试数据库连接...');
|
||||||
|
const connectionStatus = await getConnectionStatus();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
connectionStatus,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('数据库连接测试失败:', error);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '数据库连接测试失败',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
console.log('重新测试数据库连接...');
|
||||||
|
const isConnected = await testConnection();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: isConnected,
|
||||||
|
data: {
|
||||||
|
connected: isConnected,
|
||||||
|
message: isConnected ? '数据库连接成功' : '数据库连接失败',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('数据库连接重新测试失败:', error);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '数据库连接重新测试失败',
|
||||||
|
details: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
187
src/app/api/delete/route.ts
Normal file
187
src/app/api/delete/route.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { join, resolve } from 'path';
|
||||||
|
import { existsSync, unlinkSync, statSync, rmdirSync, readFileSync, writeFileSync } from 'fs';
|
||||||
|
|
||||||
|
// 验证用户是否已登录
|
||||||
|
function validateUser(request: NextRequest) {
|
||||||
|
const authHeader = request.headers.get('authorization');
|
||||||
|
if (!authHeader) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = JSON.parse(atob(authHeader));
|
||||||
|
return user;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载配置
|
||||||
|
function loadConfig() {
|
||||||
|
try {
|
||||||
|
const configPath = join(process.cwd(), 'config.json');
|
||||||
|
const configData = readFileSync(configPath, 'utf-8');
|
||||||
|
return JSON.parse(configData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load config:', error);
|
||||||
|
return {
|
||||||
|
directories: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新状态文件
|
||||||
|
function updateStatus(status: number) {
|
||||||
|
try {
|
||||||
|
const statusPath = join(process.cwd(), 'src', 'db', 'status.json');
|
||||||
|
// 读取现有状态,保留lastUpdated
|
||||||
|
const existingStatus = JSON.parse(readFileSync(statusPath, 'utf-8'));
|
||||||
|
const statusData = {
|
||||||
|
status: status,
|
||||||
|
lastUpdated: existingStatus.lastUpdated
|
||||||
|
};
|
||||||
|
writeFileSync(statusPath, JSON.stringify(statusData, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update status:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证路径安全性
|
||||||
|
function isValidPath(path: string, allowedPaths: string[]): boolean {
|
||||||
|
try {
|
||||||
|
const resolvedPath = resolve(path);
|
||||||
|
return allowedPaths.some(allowedPath => {
|
||||||
|
const resolvedAllowedPath = resolve(allowedPath);
|
||||||
|
return resolvedPath.startsWith(resolvedAllowedPath);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// 验证用户身份
|
||||||
|
const user = validateUser(request);
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: '请先登录' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户权限(只有管理员可以删除)
|
||||||
|
if (user.role !== 'admin') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: '只有管理员可以删除文件' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { paths } = await request.json();
|
||||||
|
|
||||||
|
if (!paths || !Array.isArray(paths) || paths.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: '请指定要删除的文件路径' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载配置
|
||||||
|
const config = loadConfig();
|
||||||
|
const allowedPaths = config.directories.map((dir: { path: string }) => dir.path);
|
||||||
|
|
||||||
|
const deletedFiles = [];
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
for (const path of paths) {
|
||||||
|
// 处理目标路径
|
||||||
|
let targetPath = path;
|
||||||
|
let useRealFileSystem = false;
|
||||||
|
|
||||||
|
// 查找配置的目录映射
|
||||||
|
for (const dir of config.directories) {
|
||||||
|
if (dir.name === path) {
|
||||||
|
targetPath = dir.path;
|
||||||
|
useRealFileSystem = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (path.startsWith(dir.name + '/')) {
|
||||||
|
const relativePath = path.substring(dir.name.length + 1);
|
||||||
|
targetPath = dir.path + '/' + relativePath;
|
||||||
|
useRealFileSystem = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (path.startsWith(dir.path)) {
|
||||||
|
targetPath = path;
|
||||||
|
useRealFileSystem = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证路径安全性
|
||||||
|
if (useRealFileSystem && !isValidPath(targetPath, allowedPaths)) {
|
||||||
|
errors.push({ path, error: '无效的路径或访问被拒绝' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果不是真实文件系统路径,模拟删除
|
||||||
|
if (!useRealFileSystem) {
|
||||||
|
deletedFiles.push({
|
||||||
|
path: path,
|
||||||
|
deleteTime: new Date().toISOString()
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件是否存在
|
||||||
|
if (!existsSync(targetPath)) {
|
||||||
|
errors.push({ path, error: '文件不存在' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否为目录(不允许删除目录)
|
||||||
|
const stats = statSync(targetPath);
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
errors.push({ path, error: '不允许删除目录' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行删除操作
|
||||||
|
try {
|
||||||
|
unlinkSync(targetPath);
|
||||||
|
deletedFiles.push({
|
||||||
|
path: targetPath,
|
||||||
|
originalPath: path,
|
||||||
|
deleteTime: new Date().toISOString()
|
||||||
|
});
|
||||||
|
} catch (deleteError) {
|
||||||
|
console.error('Delete operation failed:', deleteError);
|
||||||
|
errors.push({ path, error: '删除操作失败,请检查文件是否被占用' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有文件被成功删除,更新状态文件(删除文件时状态设为1)
|
||||||
|
if (deletedFiles.length > 0) {
|
||||||
|
updateStatus(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: deletedFiles.length > 0,
|
||||||
|
message: `成功删除 ${deletedFiles.length} 个文件${errors.length > 0 ? `,${errors.length} 个文件删除失败` : ''}`,
|
||||||
|
data: {
|
||||||
|
deletedFiles,
|
||||||
|
errors
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete API Error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: '服务器内部错误' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
96
src/app/api/download/route.ts
Normal file
96
src/app/api/download/route.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { readFileSync, existsSync } from 'fs';
|
||||||
|
import { join, basename } from 'path';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const path = searchParams.get('path');
|
||||||
|
|
||||||
|
if (!path) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '缺少文件路径参数'
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getConfig();
|
||||||
|
const allowedPaths = config.directories.map(dir => dir.path);
|
||||||
|
|
||||||
|
// 解码URL编码的路径
|
||||||
|
const decodedPath = decodeURIComponent(path);
|
||||||
|
|
||||||
|
// 安全检查:确保路径在允许的目录内
|
||||||
|
let isAllowed = false;
|
||||||
|
for (const allowedPath of allowedPaths) {
|
||||||
|
if (decodedPath.startsWith(allowedPath)) {
|
||||||
|
isAllowed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAllowed) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '文件路径不被允许访问'
|
||||||
|
}, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件是否存在
|
||||||
|
if (!existsSync(decodedPath)) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '文件不存在'
|
||||||
|
}, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取文件
|
||||||
|
const fileBuffer = readFileSync(decodedPath);
|
||||||
|
const fileName = basename(decodedPath);
|
||||||
|
|
||||||
|
// 获取文件类型
|
||||||
|
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
||||||
|
const contentType = getContentType(ext);
|
||||||
|
|
||||||
|
// 设置响应头
|
||||||
|
const response = new NextResponse(fileBuffer);
|
||||||
|
response.headers.set('Content-Type', contentType);
|
||||||
|
response.headers.set('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`);
|
||||||
|
|
||||||
|
return response;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Download error:', error);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '下载文件时发生错误'
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContentType(ext: string): string {
|
||||||
|
const contentTypes: { [key: string]: string } = {
|
||||||
|
'iso': 'application/x-iso9660-image',
|
||||||
|
'img': 'application/octet-stream',
|
||||||
|
'bin': 'application/octet-stream',
|
||||||
|
'exe': 'application/octet-stream',
|
||||||
|
'dmg': 'application/x-apple-diskimage',
|
||||||
|
'pkg': 'application/x-newton-compatible-pkg',
|
||||||
|
'deb': 'application/x-debian-package',
|
||||||
|
'rpm': 'application/x-rpm',
|
||||||
|
'tar': 'application/x-tar',
|
||||||
|
'gz': 'application/gzip',
|
||||||
|
'zip': 'application/zip',
|
||||||
|
'txt': 'text/plain',
|
||||||
|
'md': 'text/markdown',
|
||||||
|
'pdf': 'application/pdf',
|
||||||
|
'jpg': 'image/jpeg',
|
||||||
|
'jpeg': 'image/jpeg',
|
||||||
|
'png': 'image/png',
|
||||||
|
'gif': 'image/gif',
|
||||||
|
'svg': 'image/svg+xml'
|
||||||
|
};
|
||||||
|
|
||||||
|
return contentTypes[ext] || 'application/octet-stream';
|
||||||
|
}
|
||||||
105
src/app/api/files/[...path]/route.ts
Normal file
105
src/app/api/files/[...path]/route.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { readFileSync, existsSync } from 'fs';
|
||||||
|
import { join, basename, extname } from 'path';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ path: string[] }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const resolvedParams = await params;
|
||||||
|
const path = resolvedParams.path.join('/');
|
||||||
|
|
||||||
|
// 解析目录名和文件名
|
||||||
|
const pathParts = path.split('/');
|
||||||
|
const dirName = pathParts[0];
|
||||||
|
const fileName = pathParts.slice(1).join('/');
|
||||||
|
|
||||||
|
if (!fileName) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '文件名不能为空'
|
||||||
|
}, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getConfig();
|
||||||
|
const directory = config.directories.find(dir => dir.name === dirName);
|
||||||
|
|
||||||
|
if (!directory) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '目录不存在'
|
||||||
|
}, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = join(directory.path, fileName);
|
||||||
|
|
||||||
|
// 安全检查:确保文件在配置的目录内
|
||||||
|
const normalizedDirPath = directory.path.replace(/\\/g, '/');
|
||||||
|
const normalizedFilePath = filePath.replace(/\\/g, '/');
|
||||||
|
|
||||||
|
if (!normalizedFilePath.startsWith(normalizedDirPath)) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '访问被拒绝'
|
||||||
|
}, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查文件是否存在
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '文件不存在'
|
||||||
|
}, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取文件
|
||||||
|
const fileBuffer = readFileSync(filePath);
|
||||||
|
const fileExt = extname(fileName);
|
||||||
|
const contentType = getContentType(fileExt);
|
||||||
|
|
||||||
|
// 设置响应头
|
||||||
|
const response = new NextResponse(fileBuffer);
|
||||||
|
response.headers.set('Content-Type', contentType);
|
||||||
|
response.headers.set('Content-Disposition', `inline; filename="${encodeURIComponent(basename(filePath))}"`);
|
||||||
|
|
||||||
|
// 设置缓存头
|
||||||
|
response.headers.set('Cache-Control', 'public, max-age=3600');
|
||||||
|
|
||||||
|
return response;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('File serving error:', error);
|
||||||
|
return NextResponse.json({
|
||||||
|
success: false,
|
||||||
|
error: '服务器内部错误'
|
||||||
|
}, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContentType(ext: string): string {
|
||||||
|
const contentTypes: { [key: string]: string } = {
|
||||||
|
'iso': 'application/x-iso9660-image',
|
||||||
|
'img': 'application/octet-stream',
|
||||||
|
'bin': 'application/octet-stream',
|
||||||
|
'exe': 'application/octet-stream',
|
||||||
|
'dmg': 'application/x-apple-diskimage',
|
||||||
|
'pkg': 'application/x-newton-compatible-pkg',
|
||||||
|
'deb': 'application/x-debian-package',
|
||||||
|
'rpm': 'application/x-rpm',
|
||||||
|
'tar': 'application/x-tar',
|
||||||
|
'gz': 'application/gzip',
|
||||||
|
'zip': 'application/zip',
|
||||||
|
'txt': 'text/plain; charset=utf-8',
|
||||||
|
'md': 'text/markdown; charset=utf-8',
|
||||||
|
'pdf': 'application/pdf',
|
||||||
|
'jpg': 'image/jpeg',
|
||||||
|
'jpeg': 'image/jpeg',
|
||||||
|
'png': 'image/png',
|
||||||
|
'gif': 'image/gif',
|
||||||
|
'svg': 'image/svg+xml',
|
||||||
|
};
|
||||||
|
|
||||||
|
return contentTypes[ext.toLowerCase()] || 'application/octet-stream';
|
||||||
|
}
|
||||||
57
src/app/api/setstatus/route.ts
Normal file
57
src/app/api/setstatus/route.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { readFileSync, writeFileSync } from 'fs';
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { s, time } = await request.json();
|
||||||
|
|
||||||
|
// 验证参数
|
||||||
|
if (!s || !time) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: '缺少必要参数: s 和 time' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusPath = join(process.cwd(), 'src', 'db', 'status.json');
|
||||||
|
|
||||||
|
// 读取现有状态文件
|
||||||
|
let statusData;
|
||||||
|
try {
|
||||||
|
const existingStatus = JSON.parse(readFileSync(statusPath, 'utf-8'));
|
||||||
|
statusData = existingStatus;
|
||||||
|
} catch (error) {
|
||||||
|
// 如果文件不存在,创建默认状态
|
||||||
|
statusData = {
|
||||||
|
status: 1,
|
||||||
|
lastUpdated: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 s 是 "ok",将 status 设为 0,lastUpdated 设为传入的 time
|
||||||
|
if (s === 'ok') {
|
||||||
|
statusData.status = 0;
|
||||||
|
statusData.lastUpdated = time;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入更新后的状态
|
||||||
|
writeFileSync(statusPath, JSON.stringify(statusData, null, 2));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '状态更新成功',
|
||||||
|
data: {
|
||||||
|
status: statusData.status,
|
||||||
|
lastUpdated: statusData.lastUpdated
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Set status API Error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: '服务器内部错误' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
127
src/app/api/status/route.ts
Normal file
127
src/app/api/status/route.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { readFileSync, writeFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
// 读取状态文件
|
||||||
|
function readStatus() {
|
||||||
|
try {
|
||||||
|
const statusPath = join(process.cwd(), 'src', 'db', 'status.json');
|
||||||
|
const statusData = readFileSync(statusPath, 'utf-8');
|
||||||
|
return JSON.parse(statusData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to read status file:', error);
|
||||||
|
// 如果文件不存在,返回默认状态
|
||||||
|
return {
|
||||||
|
status: 0,
|
||||||
|
lastUpdated: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入状态文件
|
||||||
|
function writeStatus(status: number) {
|
||||||
|
try {
|
||||||
|
const statusPath = join(process.cwd(), 'src', 'db', 'status.json');
|
||||||
|
// 读取现有状态,保留lastUpdated
|
||||||
|
const existingStatus = JSON.parse(readFileSync(statusPath, 'utf-8'));
|
||||||
|
const statusData = {
|
||||||
|
status: status,
|
||||||
|
lastUpdated: existingStatus.lastUpdated
|
||||||
|
};
|
||||||
|
writeFileSync(statusPath, JSON.stringify(statusData, null, 2));
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to write status file:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证用户是否已登录
|
||||||
|
function validateUser(request: NextRequest) {
|
||||||
|
const authHeader = request.headers.get('authorization');
|
||||||
|
if (!authHeader) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = JSON.parse(atob(authHeader));
|
||||||
|
return user;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const statusData = readStatus();
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
status: statusData.status,
|
||||||
|
lastUpdated: statusData.lastUpdated
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Status GET Error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: '服务器内部错误' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// 验证用户身份
|
||||||
|
const user = validateUser(request);
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: '请先登录' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户权限(只有管理员可以更新状态)
|
||||||
|
if (user.role !== 'admin') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: '只有管理员可以更新状态' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { status } = await request.json();
|
||||||
|
|
||||||
|
if (typeof status !== 'number' || (status !== 0 && status !== 1)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: '状态值必须是0或1' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = writeStatus(status);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
const statusData = readStatus();
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '状态更新成功',
|
||||||
|
data: {
|
||||||
|
status: statusData.status,
|
||||||
|
lastUpdated: statusData.lastUpdated
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: '状态更新失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Status PUT Error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: '服务器内部错误' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
228
src/app/api/upload/route.ts
Normal file
228
src/app/api/upload/route.ts
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { join, basename } from 'path';
|
||||||
|
import { writeFileSync, mkdirSync, existsSync } from 'fs';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
|
||||||
|
// 验证用户是否已登录
|
||||||
|
function validateUser(request: NextRequest) {
|
||||||
|
const authHeader = request.headers.get('authorization');
|
||||||
|
if (!authHeader) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = JSON.parse(atob(authHeader));
|
||||||
|
return user;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载配置
|
||||||
|
function loadConfig() {
|
||||||
|
try {
|
||||||
|
const configPath = join(process.cwd(), 'config.json');
|
||||||
|
const configData = readFileSync(configPath, 'utf-8');
|
||||||
|
return JSON.parse(configData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load config:', error);
|
||||||
|
return {
|
||||||
|
directories: [],
|
||||||
|
features: {
|
||||||
|
supportedFormats: ['.iso', '.tar.gz', '.tar.bz2', '.deb', '.rpm', '.zip', '.txt', '.pdf'],
|
||||||
|
maxFileSize: '50GB'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新状态文件
|
||||||
|
function updateStatus(status: number) {
|
||||||
|
try {
|
||||||
|
const statusPath = join(process.cwd(), 'src', 'db', 'status.json');
|
||||||
|
// 读取现有状态,保留lastUpdated
|
||||||
|
const existingStatus = JSON.parse(readFileSync(statusPath, 'utf-8'));
|
||||||
|
const statusData = {
|
||||||
|
status: status,
|
||||||
|
lastUpdated: existingStatus.lastUpdated
|
||||||
|
};
|
||||||
|
writeFileSync(statusPath, JSON.stringify(statusData, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update status:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证路径安全性
|
||||||
|
function isValidPath(path: string, allowedPaths: string[]): boolean {
|
||||||
|
try {
|
||||||
|
const { resolve } = require('path');
|
||||||
|
const resolvedPath = resolve(path);
|
||||||
|
return allowedPaths.some(allowedPath => {
|
||||||
|
const resolvedAllowedPath = resolve(allowedPath);
|
||||||
|
return resolvedPath.startsWith(resolvedAllowedPath);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析文件大小
|
||||||
|
function parseFileSize(size: string): number {
|
||||||
|
const units: { [key: string]: number } = {
|
||||||
|
'B': 1,
|
||||||
|
'KB': 1024,
|
||||||
|
'MB': 1024 * 1024,
|
||||||
|
'GB': 1024 * 1024 * 1024,
|
||||||
|
'TB': 1024 * 1024 * 1024 * 1024
|
||||||
|
};
|
||||||
|
|
||||||
|
const match = size.match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB)$/i);
|
||||||
|
if (!match) return 0;
|
||||||
|
|
||||||
|
const value = parseFloat(match[1]);
|
||||||
|
const unit = match[2].toUpperCase();
|
||||||
|
return value * (units[unit] || 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
// 验证用户身份
|
||||||
|
const user = validateUser(request);
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: '请先登录' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户权限(只有管理员可以上传)
|
||||||
|
if (user.role !== 'admin') {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: '只有管理员可以上传文件' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const file = formData.get('file') as File;
|
||||||
|
const path = formData.get('path') as string || '';
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: '请选择要上传的文件' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载配置
|
||||||
|
const config = loadConfig();
|
||||||
|
const allowedPaths = config.directories.map((dir: any) => dir.path);
|
||||||
|
|
||||||
|
// 处理目标路径
|
||||||
|
let targetPath = path;
|
||||||
|
let useRealFileSystem = false;
|
||||||
|
|
||||||
|
// 查找配置的目录映射
|
||||||
|
for (const dir of config.directories) {
|
||||||
|
if (dir.name === path) {
|
||||||
|
targetPath = dir.path;
|
||||||
|
useRealFileSystem = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (path.startsWith(dir.name + '/')) {
|
||||||
|
const relativePath = path.substring(dir.name.length + 1);
|
||||||
|
targetPath = dir.path + '/' + relativePath;
|
||||||
|
useRealFileSystem = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (path.startsWith(dir.path)) {
|
||||||
|
targetPath = path;
|
||||||
|
useRealFileSystem = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证路径安全性
|
||||||
|
if (useRealFileSystem && !isValidPath(targetPath, allowedPaths)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: '无效的目标路径' },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果不是真实文件系统路径,模拟上传
|
||||||
|
if (!useRealFileSystem) {
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '文件上传成功(演示模式)',
|
||||||
|
data: {
|
||||||
|
filename: file.name,
|
||||||
|
size: file.size,
|
||||||
|
path: path,
|
||||||
|
uploadTime: new Date().toISOString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除文件格式限制,允许上传所有类型文件
|
||||||
|
|
||||||
|
// 验证文件大小
|
||||||
|
const maxSize = parseFileSize(config.features.maxFileSize || '50GB');
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: `文件大小超过限制:${config.features.maxFileSize}` },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保目标目录存在
|
||||||
|
if (!existsSync(targetPath)) {
|
||||||
|
try {
|
||||||
|
mkdirSync(targetPath, { recursive: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create directory:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: '创建目标目录失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存文件
|
||||||
|
const bytes = await file.arrayBuffer();
|
||||||
|
const buffer = Buffer.from(bytes);
|
||||||
|
const filePath = join(targetPath, file.name);
|
||||||
|
|
||||||
|
try {
|
||||||
|
writeFileSync(filePath, buffer);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save file:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: '文件保存失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新状态文件(上传文件时状态设为1)
|
||||||
|
updateStatus(1);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: '文件上传成功',
|
||||||
|
data: {
|
||||||
|
filename: file.name,
|
||||||
|
size: file.size,
|
||||||
|
path: targetPath,
|
||||||
|
fullPath: filePath,
|
||||||
|
uploadTime: new Date().toISOString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: '服务器内部错误' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
228
src/app/globals.css
Normal file
228
src/app/globals.css
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #ffffff;
|
||||||
|
--foreground: #171717;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-geist-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--background: #0a0a0a;
|
||||||
|
--foreground: #ededed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode styles */
|
||||||
|
.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Simple dark mode overrides */
|
||||||
|
.dark body {
|
||||||
|
background-color: #111827;
|
||||||
|
color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bg-white {
|
||||||
|
background-color: #1f2937 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bg-gray-50 {
|
||||||
|
background-color: #1f2937 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bg-gray-100 {
|
||||||
|
background-color: #374151 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bg-gray-800 {
|
||||||
|
background-color: #1f2937 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .text-gray-900 {
|
||||||
|
color: #f9fafb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .text-gray-800 {
|
||||||
|
color: #f3f4f6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .text-gray-600 {
|
||||||
|
color: #d1d5db !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .text-gray-500 {
|
||||||
|
color: #9ca3af !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .text-gray-400 {
|
||||||
|
color: #6b7280 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .border-gray-200 {
|
||||||
|
border-color: #4b5563 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .border-gray-300 {
|
||||||
|
border-color: #4b5563 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .border-gray-700 {
|
||||||
|
border-color: #374151 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .hover\:bg-gray-100:hover {
|
||||||
|
background-color: #374151 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .hover\:bg-gray-700:hover {
|
||||||
|
background-color: #374151 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .hover\:text-gray-200:hover {
|
||||||
|
color: #e5e7eb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .hover\:text-gray-800:hover {
|
||||||
|
color: #e5e7eb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暗黑模式下的样式覆盖 */
|
||||||
|
.dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 动画效果 */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文件行悬停效果 */
|
||||||
|
.file-row {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-row:hover {
|
||||||
|
background-color: rgba(59, 130, 246, 0.05);
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .file-row:hover {
|
||||||
|
background-color: rgba(59, 130, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文件图标颜色 */
|
||||||
|
.icon-folder { color: #3b82f6; }
|
||||||
|
.icon-file { color: #6b7280; }
|
||||||
|
.icon-archive { color: #10b981; }
|
||||||
|
.icon-package { color: #f59e0b; }
|
||||||
|
.icon-text { color: #8b5cf6; }
|
||||||
|
.icon-image { color: #ec4899; }
|
||||||
|
|
||||||
|
/* 模态框样式 */
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-box {
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条样式 */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark ::-webkit-scrollbar-track {
|
||||||
|
background: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #888;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载动画 */
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 工具提示样式 */
|
||||||
|
.tooltip {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip::after {
|
||||||
|
content: attr(data-tooltip);
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background-color: #1f2937;
|
||||||
|
color: white;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
z-index: 10;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip:hover::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
26
src/app/layout.tsx
Normal file
26
src/app/layout.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
// 引入 FontAwesome CSS
|
||||||
|
import '@fortawesome/fontawesome-free/css/all.min.css';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Linux 镜像源 - 文件浏览器",
|
||||||
|
description: "快速、稳定的 Linux 发行版镜像文件浏览器",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="zh-CN" className="h-full">
|
||||||
|
<body
|
||||||
|
className="antialiased h-full font-sans"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
src/app/page.tsx
Normal file
124
src/app/page.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { FileBrowser } from '@/components/FileBrowser';
|
||||||
|
import { Header } from '@/components/Header';
|
||||||
|
import { Footer } from '@/components/Footer';
|
||||||
|
import { CommandModal } from '@/components/CommandModal';
|
||||||
|
import { LoginModal } from '@/components/LoginModal';
|
||||||
|
import { Config } from '@/types';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const [config, setConfig] = useState<Config | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [darkMode, setDarkMode] = useState(false);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// 加载配置
|
||||||
|
fetch('/api/config')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
setConfig(data.data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
|
||||||
|
// 检查本地存储的主题设置
|
||||||
|
const savedTheme = localStorage.getItem('theme');
|
||||||
|
if (savedTheme === 'dark' || (!savedTheme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||||
|
setDarkMode(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查本地存储的登录状态
|
||||||
|
const savedUser = localStorage.getItem('user');
|
||||||
|
if (savedUser) {
|
||||||
|
try {
|
||||||
|
setUser(JSON.parse(savedUser));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('解析用户数据失败:', error);
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [mounted]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (darkMode) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
localStorage.setItem('theme', 'dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
localStorage.setItem('theme', 'light');
|
||||||
|
}
|
||||||
|
}, [darkMode, mounted]);
|
||||||
|
|
||||||
|
// 防止hydration不匹配的loading状态
|
||||||
|
if (!mounted || loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
<p className="mt-2 text-gray-600">加载中...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-red-600">加载配置失败</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`min-h-screen bg-gray-50 dark:bg-gray-900 transition-colors duration-300`}>
|
||||||
|
<Header
|
||||||
|
config={config}
|
||||||
|
darkMode={darkMode}
|
||||||
|
onToggleTheme={() => setDarkMode(!darkMode)}
|
||||||
|
onOpenModal={() => setIsModalOpen(true)}
|
||||||
|
onLogin={() => setIsLoginModalOpen(true)}
|
||||||
|
user={user}
|
||||||
|
onUserChange={setUser}
|
||||||
|
/>
|
||||||
|
<main className="flex-1">
|
||||||
|
<FileBrowser config={config} user={user} />
|
||||||
|
</main>
|
||||||
|
<Footer />
|
||||||
|
<CommandModal
|
||||||
|
config={config}
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
/>
|
||||||
|
<LoginModal
|
||||||
|
isOpen={isLoginModalOpen}
|
||||||
|
onClose={() => setIsLoginModalOpen(false)}
|
||||||
|
onLogin={setUser}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
261
src/components/CommandModal.tsx
Normal file
261
src/components/CommandModal.tsx
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Config } from '@/types';
|
||||||
|
|
||||||
|
interface CommandModalProps {
|
||||||
|
config: Config;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandModal({ config, isOpen, onClose }: CommandModalProps) {
|
||||||
|
const [selectedDistro, setSelectedDistro] = useState('');
|
||||||
|
const [command, setCommand] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
// 重置状态当模态框关闭时
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setSelectedDistro('');
|
||||||
|
setCommand('');
|
||||||
|
setCopied(false);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const distros = config.linuxCommands.map(distro => {
|
||||||
|
const dirConfig = config.directories.find(d => d.name === distro);
|
||||||
|
return {
|
||||||
|
name: distro,
|
||||||
|
description: dirConfig?.description || distro,
|
||||||
|
icon: dirConfig?.icon || 'fab fa-linux',
|
||||||
|
color: dirConfig?.color || '#000000'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const generateCommand = async () => {
|
||||||
|
if (!selectedDistro) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/command', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ distro: selectedDistro }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
setCommand(data.data.command);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to generate command:', data.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating command:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(command);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy to clipboard:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedDistro) {
|
||||||
|
generateCommand();
|
||||||
|
} else {
|
||||||
|
setCommand('');
|
||||||
|
}
|
||||||
|
}, [selectedDistro]);
|
||||||
|
|
||||||
|
// 按ESC键关闭模态框
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && isOpen) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
document.body.style.overflow = 'hidden'; // 防止背景滚动
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleEscape);
|
||||||
|
document.body.style.overflow = 'unset'; // 恢复滚动
|
||||||
|
};
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
{/* 背景遮罩 */}
|
||||||
|
<div className="absolute inset-0 bg-black bg-opacity-50 transition-opacity"></div>
|
||||||
|
|
||||||
|
{/* 模态框内容 */}
|
||||||
|
<div
|
||||||
|
className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()} // 防止点击内容区域时关闭
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
|
{/* 标题和关闭按钮 */}
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-xl font-bold text-gray-800 dark:text-gray-200">
|
||||||
|
添加 Linux 镜像源
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||||
|
title="关闭"
|
||||||
|
>
|
||||||
|
<i className="fas fa-times text-xl"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 发行版选择 */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
选择您的 Linux 发行版:
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||||
|
{distros.map((distro) => (
|
||||||
|
<button
|
||||||
|
key={distro.name}
|
||||||
|
onClick={() => setSelectedDistro(distro.name)}
|
||||||
|
className={`p-3 rounded-lg border-2 transition-all ${
|
||||||
|
selectedDistro === distro.name
|
||||||
|
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||||
|
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<i
|
||||||
|
className={`${distro.icon} text-2xl mb-1`}
|
||||||
|
style={{ color: distro.color }}
|
||||||
|
></i>
|
||||||
|
<div className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||||
|
{distro.name.charAt(0).toUpperCase() + distro.name.slice(1)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{distro.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 命令生成结果 */}
|
||||||
|
{selectedDistro && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
生成的命令:
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
className="px-3 py-1 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors"
|
||||||
|
disabled={loading || !command}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<>
|
||||||
|
<i className="fas fa-check mr-1"></i>
|
||||||
|
已复制
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<i className="fas fa-copy mr-1"></i>
|
||||||
|
复制
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<div className="bg-gray-900 text-green-400 p-4 rounded-lg font-mono text-sm overflow-x-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-green-400"></div>
|
||||||
|
<span>生成中...</span>
|
||||||
|
</div>
|
||||||
|
) : command ? (
|
||||||
|
<pre className="whitespace-pre-wrap">{command}</pre>
|
||||||
|
) : (
|
||||||
|
<span>请选择发行版</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 使用说明 */}
|
||||||
|
{selectedDistro && command && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
使用说明:
|
||||||
|
</h4>
|
||||||
|
<ol className="list-decimal list-inside space-y-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<li>打开终端</li>
|
||||||
|
<li>复制并执行上面的命令</li>
|
||||||
|
<li>更新软件包列表:
|
||||||
|
<code className="bg-gray-100 dark:bg-gray-700 px-1 rounded">
|
||||||
|
{selectedDistro === 'ubuntu' || selectedDistro === 'debian'
|
||||||
|
? 'sudo apt update'
|
||||||
|
: selectedDistro === 'centos' || selectedDistro === 'fedora'
|
||||||
|
? 'sudo dnf update'
|
||||||
|
: 'sudo pacman -Syu'
|
||||||
|
}
|
||||||
|
</code>
|
||||||
|
</li>
|
||||||
|
<li>验证镜像源是否添加成功</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 注意事项 */}
|
||||||
|
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||||
|
<div className="flex items-start space-x-2">
|
||||||
|
<i className="fas fa-exclamation-triangle text-yellow-600 dark:text-yellow-400 mt-1"></i>
|
||||||
|
<div className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||||
|
<p className="font-medium mb-1">注意事项:</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1">
|
||||||
|
<li>执行命令前请备份原有的软件源配置</li>
|
||||||
|
<li>不同的 Linux 发行版使用不同的包管理器</li>
|
||||||
|
<li>如果遇到网络问题,请检查 DNS 设置</li>
|
||||||
|
<li>某些发行版可能需要额外的依赖包</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 底部按钮 */}
|
||||||
|
<div className="flex justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
关闭
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
src/components/DeleteConfirmModal.tsx
Normal file
128
src/components/DeleteConfirmModal.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface FileInfo {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
type: 'file' | 'dir';
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeleteConfirmModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
files: FileInfo[];
|
||||||
|
deleting: boolean;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteConfirmModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onDelete,
|
||||||
|
files,
|
||||||
|
deleting,
|
||||||
|
error
|
||||||
|
}: DeleteConfirmModalProps) {
|
||||||
|
if (!isOpen || files.length === 0) return null;
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalSize = files.reduce((sum, file) => sum + (file.size || 0), 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-8 w-full max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
确认删除 {files.length} 个文件
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
<i className="fas fa-times text-xl"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-4">
|
||||||
|
<p className="text-sm text-red-800 dark:text-red-200 mb-2">
|
||||||
|
<i className="fas fa-exclamation-triangle mr-2"></i>
|
||||||
|
<strong>警告:</strong>即将删除以下文件,此操作不可撤销。
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-red-700 dark:text-red-300">
|
||||||
|
共 {files.length} 个文件,总大小:{formatFileSize(totalSize)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg max-h-64 overflow-y-auto">
|
||||||
|
<div className="divide-y divide-gray-200 dark:divide-gray-600">
|
||||||
|
{files.map((file, index) => (
|
||||||
|
<div key={index} className="flex items-center space-x-3 p-3">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<i className="fas fa-file text-gray-500 text-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h4 className="font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
{file.name}
|
||||||
|
</h4>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{formatFileSize(file.size || 0)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3 mt-4">
|
||||||
|
<p className="text-sm text-red-800 dark:text-red-200">
|
||||||
|
<i className="fas fa-info-circle mr-2"></i>
|
||||||
|
确定要删除这些文件吗?此操作不可撤销。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-red-600 dark:text-red-400 text-sm mb-4">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex space-x-4 pt-4">
|
||||||
|
<button
|
||||||
|
onClick={onDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
className="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 disabled:bg-red-400 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{deleting ? (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
|
删除中...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
'确认删除'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={deleting}
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
616
src/components/FileBrowser.tsx
Normal file
616
src/components/FileBrowser.tsx
Normal file
@@ -0,0 +1,616 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Config, FileInfo, DirectoryStats } from '@/types';
|
||||||
|
import { UploadModal } from './UploadModal';
|
||||||
|
import { DeleteConfirmModal } from './DeleteConfirmModal';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 客户端可用的工具函数
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return '-';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileIcon(type: string, ext?: string): string {
|
||||||
|
if (type === 'dir') return 'fas fa-folder icon-folder';
|
||||||
|
|
||||||
|
const extMap: { [key: string]: string } = {
|
||||||
|
'iso': 'fas fa-compact-disc icon-package',
|
||||||
|
'tar': 'fas fa-archive icon-archive',
|
||||||
|
'gz': 'fas fa-file-archive icon-archive',
|
||||||
|
'bz2': 'fas fa-file-archive icon-archive',
|
||||||
|
'zip': 'fas fa-file-archive icon-archive',
|
||||||
|
'deb': 'fas fa-box icon-package',
|
||||||
|
'rpm': 'fas fa-box icon-package',
|
||||||
|
'md': 'fas fa-file-alt icon-text',
|
||||||
|
'txt': 'fas fa-file-alt icon-text',
|
||||||
|
'pdf': 'fas fa-file-pdf icon-text',
|
||||||
|
'jpg': 'fas fa-image icon-image',
|
||||||
|
'jpeg': 'fas fa-image icon-image',
|
||||||
|
'png': 'fas fa-image icon-image',
|
||||||
|
'svg': 'fas fa-image icon-image',
|
||||||
|
'gif': 'fas fa-image icon-image'
|
||||||
|
};
|
||||||
|
|
||||||
|
return extMap[ext?.toLowerCase() || ''] || 'fas fa-file icon-file';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileType(ext?: string): string {
|
||||||
|
const typeMap: { [key: string]: string } = {
|
||||||
|
'iso': '光盘镜像',
|
||||||
|
'tar': '压缩包',
|
||||||
|
'gz': '压缩包',
|
||||||
|
'bz2': '压缩包',
|
||||||
|
'zip': '压缩包',
|
||||||
|
'deb': 'Debian 包',
|
||||||
|
'rpm': 'RPM 包',
|
||||||
|
'md': 'Markdown 文档',
|
||||||
|
'txt': '文本文件',
|
||||||
|
'pdf': 'PDF 文档',
|
||||||
|
'jpg': '图片',
|
||||||
|
'jpeg': '图片',
|
||||||
|
'png': '图片',
|
||||||
|
'svg': 'SVG 图片',
|
||||||
|
'gif': 'GIF 图片'
|
||||||
|
};
|
||||||
|
|
||||||
|
return typeMap[ext?.toLowerCase() || ''] || '文件';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileBrowserProps {
|
||||||
|
config: Config;
|
||||||
|
user: User | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileBrowser({ config, user }: FileBrowserProps) {
|
||||||
|
const [currentPath, setCurrentPath] = useState('');
|
||||||
|
const [items, setItems] = useState<FileInfo[]>([]);
|
||||||
|
const [stats, setStats] = useState<DirectoryStats>({ folders: 0, files: 0, totalSize: 0 });
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [view, setView] = useState<'list' | 'grid'>(config.ui.defaultView);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [selectedItems, setSelectedItems] = useState<FileInfo[]>([]);
|
||||||
|
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [deleteError, setDeleteError] = useState('');
|
||||||
|
|
||||||
|
// 加载目录内容
|
||||||
|
const loadDirectory = async (path: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/browse?path=${encodeURIComponent(path)}&showHidden=${config.ui.showHiddenFiles}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
setItems(data.data.items);
|
||||||
|
setStats(data.data.stats);
|
||||||
|
setCurrentPath(path);
|
||||||
|
// 切换目录时清空选择状态
|
||||||
|
setSelectedItems([]);
|
||||||
|
} else {
|
||||||
|
console.error('Failed to load directory:', data.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading directory:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDirectory('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 过滤文件
|
||||||
|
const filteredItems = items.filter(item =>
|
||||||
|
item.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
// 导航到目录
|
||||||
|
const navigateTo = (path: string, itemName: string) => {
|
||||||
|
// 使用目录名称进行导航,这样演示数据可以正确匹配
|
||||||
|
const targetPath = itemName;
|
||||||
|
const fullPath = currentPath ? `${currentPath}/${targetPath}` : targetPath;
|
||||||
|
loadDirectory(fullPath);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 返回上级目录
|
||||||
|
const goBack = () => {
|
||||||
|
if (!currentPath) return;
|
||||||
|
const parentPath = currentPath.split('/').slice(0, -1).join('/');
|
||||||
|
loadDirectory(parentPath);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 复制链接
|
||||||
|
const copyLink = async (item: FileInfo) => {
|
||||||
|
// 生成静态下载链接
|
||||||
|
const url = generateDownloadUrl(item);
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
showNotification('下载链接已复制到剪贴板');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy link:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生成下载URL
|
||||||
|
const generateDownloadUrl = (item: FileInfo) => {
|
||||||
|
if (item.type === 'file' && currentPath) {
|
||||||
|
// 使用完整的当前路径生成静态URL格式: http://localhost:3000/ubuntu/tt/文件名
|
||||||
|
return `${config.mirror.baseUrl}/${currentPath}/${encodeURIComponent(item.name)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 备用:使用API下载链接
|
||||||
|
return `${window.location.origin}/api/download?path=${encodeURIComponent(item.path)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 显示通知
|
||||||
|
const showNotification = (message: string) => {
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.className = 'fixed top-20 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50 fade-in';
|
||||||
|
notification.innerHTML = `
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<i class="fas fa-check-circle"></i>
|
||||||
|
<span>${message}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(notification);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.classList.add('opacity-0');
|
||||||
|
setTimeout(() => notification.remove(), 300);
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除文件
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (selectedItems.length === 0) return;
|
||||||
|
|
||||||
|
setDeleting(true);
|
||||||
|
setDeleteError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (user) {
|
||||||
|
headers['Authorization'] = btoa(JSON.stringify(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
paths: selectedItems.map(item => item.path)
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setSelectedItems([]);
|
||||||
|
loadDirectory(currentPath);
|
||||||
|
} else {
|
||||||
|
setDeleteError(data.message || '删除失败');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setDeleteError('网络错误,请重试');
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 处理文件选择
|
||||||
|
const handleSelectItem = (item: FileInfo) => {
|
||||||
|
// 只允许选择文件,不允许选择目录
|
||||||
|
if (item.type === 'dir') return;
|
||||||
|
|
||||||
|
setSelectedItems(prev => {
|
||||||
|
const isSelected = prev.some(selected => selected.path === item.path);
|
||||||
|
if (isSelected) {
|
||||||
|
return prev.filter(selected => selected.path !== item.path);
|
||||||
|
} else {
|
||||||
|
return [...prev, item];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查文件是否被选中
|
||||||
|
const isItemSelected = (item: FileInfo) => {
|
||||||
|
return selectedItems.some(selected => selected.path === item.path);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 切换选择状态
|
||||||
|
const toggleItemSelection = (item: FileInfo) => {
|
||||||
|
handleSelectItem(item);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生成面包屑
|
||||||
|
const generateBreadcrumb = () => {
|
||||||
|
const parts = currentPath.split('/').filter(p => p);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="mb-6">
|
||||||
|
<ol className="flex items-center space-x-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<li className="flex items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => loadDirectory('')}
|
||||||
|
className="hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||||
|
>
|
||||||
|
<i className="fas fa-home mr-1"></i>根目录
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{parts.map((part, index) => {
|
||||||
|
const path = parts.slice(0, index + 1).join('/');
|
||||||
|
const isLast = index === parts.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={index} className="flex items-center">
|
||||||
|
<i className="fas fa-chevron-right text-gray-400 dark:text-gray-500 mx-2 text-xs"></i>
|
||||||
|
{isLast ? (
|
||||||
|
<span className="text-gray-800 dark:text-gray-200">{part}</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => loadDirectory(path)}
|
||||||
|
className="hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
{/* 面包屑导航 */}
|
||||||
|
{generateBreadcrumb()}
|
||||||
|
|
||||||
|
{/* 状态栏 */}
|
||||||
|
{config.features.enableStats && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm p-4 mb-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-6">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<i className="fas fa-folder text-blue-500"></i>
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
目录: <span className="font-semibold text-gray-800 dark:text-gray-200">{stats.folders}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<i className="fas fa-file text-gray-500"></i>
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
文件: <span className="font-semibold text-gray-800 dark:text-gray-200">{stats.files}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<i className="fas fa-hdd text-green-500"></i>
|
||||||
|
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
总大小: <span className="font-semibold text-gray-800 dark:text-gray-200">{formatFileSize(stats.totalSize)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
{/* 视图切换 */}
|
||||||
|
<div className="flex items-center bg-gray-100 dark:bg-gray-700 rounded-lg p-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setView('list')}
|
||||||
|
className={`px-3 py-1 rounded text-sm font-medium ${
|
||||||
|
view === 'list'
|
||||||
|
? 'bg-white dark:bg-gray-600 text-gray-800 dark:text-gray-200 shadow-sm'
|
||||||
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<i className="fas fa-list"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setView('grid')}
|
||||||
|
className={`px-3 py-1 rounded text-sm font-medium ${
|
||||||
|
view === 'grid'
|
||||||
|
? 'bg-white dark:bg-gray-600 text-gray-800 dark:text-gray-200 shadow-sm'
|
||||||
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<i className="fas fa-th"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 上传按钮 - 只有管理员可见 */}
|
||||||
|
{user && user.role === 'admin' && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsUploadModalOpen(true)}
|
||||||
|
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<i className="fas fa-upload mr-2"></i>
|
||||||
|
上传文件
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 删除按钮 - 只有管理员可见且有选中项 */}
|
||||||
|
{user && user.role === 'admin' && selectedItems.length > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsDeleteModalOpen(true)}
|
||||||
|
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<i className="fas fa-trash mr-2"></i>
|
||||||
|
删除 ({selectedItems.length})
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 刷新按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={() => loadDirectory(currentPath)}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<i className="fas fa-sync-alt mr-2"></i>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 返回按钮 */}
|
||||||
|
{currentPath && (
|
||||||
|
<button
|
||||||
|
onClick={goBack}
|
||||||
|
className="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors text-sm"
|
||||||
|
>
|
||||||
|
<i className="fas fa-arrow-left mr-2"></i>
|
||||||
|
返回
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 搜索框 */}
|
||||||
|
{config.features.enableSearch && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索当前目录中的文件..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 加载状态 */}
|
||||||
|
{loading && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||||
|
<p className="mt-4 text-gray-600 dark:text-gray-400">加载中...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 空状态 */}
|
||||||
|
{!loading && filteredItems.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
|
<i className="fas fa-folder-open text-gray-300 dark:text-gray-600 text-6xl mb-4"></i>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 text-lg">
|
||||||
|
{searchQuery ? '没有找到匹配的文件' : '此目录为空'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 文件列表 */}
|
||||||
|
{!loading && filteredItems.length > 0 && (
|
||||||
|
<>
|
||||||
|
{view === 'list' ? (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
{/* 表头 */}
|
||||||
|
<div className="grid grid-cols-12 bg-gray-50 dark:bg-gray-700 px-6 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 border-b border-gray-200 dark:border-gray-600">
|
||||||
|
<div className="col-span-1">
|
||||||
|
{user && user.role === 'admin' && (
|
||||||
|
<div className="text-center">选择</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-5">名称</div>
|
||||||
|
<div className="col-span-2 text-center">大小</div>
|
||||||
|
<div className="col-span-2 text-center">修改时间</div>
|
||||||
|
<div className="col-span-2 text-center">类型</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 文件项 */}
|
||||||
|
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{filteredItems.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`grid grid-cols-12 px-6 py-3 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer items-center transition-colors ${
|
||||||
|
isItemSelected(item) ? 'bg-blue-50 dark:bg-blue-900/20' : ''
|
||||||
|
}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
// 检查是否点击了复选框或图标
|
||||||
|
if (e.target as HTMLElement) {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('input[type="checkbox"]') || target.closest('i')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user && user.role === 'admin' && item.type === 'file') {
|
||||||
|
// 管理员点击选择文件
|
||||||
|
toggleItemSelection(item);
|
||||||
|
} else {
|
||||||
|
// 普通用户点击导航
|
||||||
|
if (item.type === 'dir') {
|
||||||
|
navigateTo(item.path, item.name);
|
||||||
|
} else {
|
||||||
|
// 处理文件点击
|
||||||
|
if (config.features.enableDownload) {
|
||||||
|
const downloadUrl = generateDownloadUrl(item);
|
||||||
|
window.open(downloadUrl, '_blank');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 选择列 */}
|
||||||
|
<div className="col-span-1 flex items-center justify-center">
|
||||||
|
{user && user.role === 'admin' && item.type === 'file' && (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isItemSelected(item)}
|
||||||
|
onChange={() => toggleItemSelection(item)}
|
||||||
|
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 文件名列 */}
|
||||||
|
<div className="col-span-5 flex items-center space-x-3">
|
||||||
|
<i
|
||||||
|
className={`${getFileIcon(item.type, item.ext)} text-lg`}
|
||||||
|
style={item.color ? { color: item.color } : {}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!user || user.role !== 'admin') {
|
||||||
|
if (item.type === 'dir') {
|
||||||
|
navigateTo(item.path, item.name);
|
||||||
|
} else if (config.features.enableDownload) {
|
||||||
|
const downloadUrl = generateDownloadUrl(item);
|
||||||
|
window.open(downloadUrl, '_blank');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
></i>
|
||||||
|
<span className="font-medium text-gray-800 dark:text-gray-200">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 大小列 */}
|
||||||
|
<div className="col-span-2 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{formatFileSize(item.size)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 修改时间列 */}
|
||||||
|
<div className="col-span-2 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{new Date(item.modified).toLocaleDateString('zh-CN')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 类型列 */}
|
||||||
|
<div className="col-span-2 text-center">
|
||||||
|
<span className="px-2 py-1 bg-gray-100 dark:bg-gray-600 text-gray-700 dark:text-gray-300 rounded text-xs">
|
||||||
|
{item.type === 'dir' ? '目录' : getFileType(item.ext)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||||
|
{filteredItems.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition-shadow cursor-pointer text-center relative ${
|
||||||
|
isItemSelected(item) ? 'ring-2 ring-blue-500 border-blue-500' : ''
|
||||||
|
}`}
|
||||||
|
onClick={(e) => {
|
||||||
|
// 检查是否点击了复选框或图标
|
||||||
|
if (e.target as HTMLElement) {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest('input[type="checkbox"]') || target.closest('i')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user && user.role === 'admin' && item.type === 'file') {
|
||||||
|
// 管理员点击选择文件
|
||||||
|
toggleItemSelection(item);
|
||||||
|
} else {
|
||||||
|
// 普通用户点击导航
|
||||||
|
if (item.type === 'dir') {
|
||||||
|
navigateTo(item.path, item.name);
|
||||||
|
} else {
|
||||||
|
if (config.features.enableDownload) {
|
||||||
|
const downloadUrl = generateDownloadUrl(item);
|
||||||
|
window.open(downloadUrl, '_blank');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 网格视图的复选框 */}
|
||||||
|
{user && user.role === 'admin' && item.type === 'file' && (
|
||||||
|
<div className="absolute top-2 right-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isItemSelected(item)}
|
||||||
|
onChange={() => toggleItemSelection(item)}
|
||||||
|
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<i
|
||||||
|
className={`${getFileIcon(item.type, item.ext)} text-4xl mb-3`}
|
||||||
|
style={item.color ? { color: item.color } : {}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!user || user.role !== 'admin') {
|
||||||
|
if (item.type === 'dir') {
|
||||||
|
navigateTo(item.path, item.name);
|
||||||
|
} else if (config.features.enableDownload) {
|
||||||
|
const downloadUrl = generateDownloadUrl(item);
|
||||||
|
window.open(downloadUrl, '_blank');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
></i>
|
||||||
|
<p className="text-sm font-medium text-gray-800 dark:text-gray-200 truncate mb-1">
|
||||||
|
{item.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{formatFileSize(item.size)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 上传模态框 */}
|
||||||
|
<UploadModal
|
||||||
|
isOpen={isUploadModalOpen}
|
||||||
|
onClose={() => setIsUploadModalOpen(false)}
|
||||||
|
currentPath={currentPath}
|
||||||
|
onUploadSuccess={() => {
|
||||||
|
loadDirectory(currentPath);
|
||||||
|
}}
|
||||||
|
user={user}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 删除确认模态框 */}
|
||||||
|
<DeleteConfirmModal
|
||||||
|
isOpen={isDeleteModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
setDeleteError('');
|
||||||
|
}}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
files={selectedItems}
|
||||||
|
deleting={deleting}
|
||||||
|
error={deleteError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/components/Footer.tsx
Normal file
40
src/components/Footer.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function Footer() {
|
||||||
|
const [lastUpdate, setLastUpdate] = useState(new Date().toLocaleString('zh-CN'));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setLastUpdate(new Date().toLocaleString('zh-CN'));
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="mt-auto py-6 border-t border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center justify-between text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
{/* <span>© 2024 Linux 镜像源</span> */}
|
||||||
|
<span>•</span>
|
||||||
|
<span>最后更新: {lastUpdate}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<a href="#" className="hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
|
||||||
|
关于
|
||||||
|
</a>
|
||||||
|
<a href="#" className="hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
|
||||||
|
帮助
|
||||||
|
</a>
|
||||||
|
<a href="#" className="hover:text-blue-600 dark:hover:text-blue-400 transition-colors">
|
||||||
|
API
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
src/components/Header.tsx
Normal file
94
src/components/Header.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Config } from '@/types';
|
||||||
|
import { UserMenu } from './UserMenu';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HeaderProps {
|
||||||
|
config: Config;
|
||||||
|
darkMode: boolean;
|
||||||
|
onToggleTheme: () => void;
|
||||||
|
onOpenModal: () => void;
|
||||||
|
onLogin: () => void;
|
||||||
|
user: User | null;
|
||||||
|
onUserChange: (user: User | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header({ config, darkMode, onToggleTheme, onOpenModal, onLogin, user, onUserChange }: HeaderProps) {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700 sticky top-0 z-40">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center justify-between h-16">
|
||||||
|
{/* Logo 和标题 */}
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="relative">
|
||||||
|
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center">
|
||||||
|
<i className="fas fa-compact-disc text-white text-lg"></i>
|
||||||
|
</div>
|
||||||
|
<span className="absolute -bottom-1 -right-1 w-3 h-3 bg-green-500 border-2 border-white dark:border-gray-800 rounded-full"></span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-800 dark:text-gray-200">
|
||||||
|
{config.mirror.name}
|
||||||
|
</h1>
|
||||||
|
<span className="text-xs px-2 py-1 bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 rounded-full">
|
||||||
|
在线
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 搜索和控制区域 */}
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
{/* 搜索框 */}
|
||||||
|
{config.features.enableSearch && (
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索文件..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-64 px-4 py-2 pl-10 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm"
|
||||||
|
/>
|
||||||
|
<i className="fas fa-search absolute left-3 top-2.5 text-gray-400 dark:text-gray-500"></i>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 用户菜单 */}
|
||||||
|
<UserMenu
|
||||||
|
user={user}
|
||||||
|
onLogin={onLogin}
|
||||||
|
onLogout={() => onUserChange(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 镜像源添加按钮 */}
|
||||||
|
<button
|
||||||
|
onClick={onOpenModal}
|
||||||
|
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
<i className="fas fa-plus mr-2"></i>
|
||||||
|
添加镜像源
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 主题切换 */}
|
||||||
|
{config.features.enableDarkMode && (
|
||||||
|
<button
|
||||||
|
onClick={onToggleTheme}
|
||||||
|
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
title="切换主题"
|
||||||
|
>
|
||||||
|
<i className={`fas ${darkMode ? 'fa-sun text-yellow-500' : 'fa-moon text-gray-600 dark:text-gray-400'}`}></i>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
src/components/LoginModal.tsx
Normal file
144
src/components/LoginModal.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onLogin: (user: User) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginModal({ isOpen, onClose, onLogin }: LoginModalProps) {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// 保存登录状态到localStorage
|
||||||
|
localStorage.setItem('user', JSON.stringify(data.data));
|
||||||
|
onLogin(data.data);
|
||||||
|
onClose();
|
||||||
|
// 重置表单
|
||||||
|
setUsername('');
|
||||||
|
setPassword('');
|
||||||
|
} else {
|
||||||
|
setError(data.message || '登录失败');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('网络错误,请重试');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-8 w-full max-w-md">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
登录账号
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<i className="fas fa-times text-xl"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
用户名
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||||
|
placeholder="请输入用户名"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
密码
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
className="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-red-600 dark:text-red-400 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex space-x-4 pt-4">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
|
登录中...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
'登录'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||||
|
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
|
<i className="fas fa-info-circle mr-2"></i>
|
||||||
|
登录以上传文件
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
230
src/components/UploadModal.tsx
Normal file
230
src/components/UploadModal.tsx
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef } from 'react';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UploadModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
currentPath: string;
|
||||||
|
onUploadSuccess: () => void;
|
||||||
|
user: User | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UploadModal({ isOpen, onClose, currentPath, onUploadSuccess, user }: UploadModalProps) {
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [dragActive, setDragActive] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleDrag = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.type === 'dragenter' || e.type === 'dragover') {
|
||||||
|
setDragActive(true);
|
||||||
|
} else if (e.type === 'dragleave') {
|
||||||
|
setDragActive(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setDragActive(false);
|
||||||
|
|
||||||
|
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
||||||
|
setFile(e.dataTransfer.files[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files && e.target.files[0]) {
|
||||||
|
setFile(e.target.files[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpload = async () => {
|
||||||
|
if (!file) {
|
||||||
|
setError('请选择要上传的文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('path', currentPath);
|
||||||
|
|
||||||
|
// 添加用户认证信息
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (user) {
|
||||||
|
headers['Authorization'] = btoa(JSON.stringify(user));
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
onUploadSuccess();
|
||||||
|
onClose();
|
||||||
|
// 重置表单
|
||||||
|
setFile(null);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError(data.message || '上传失败');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('网络错误,请重试');
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-8 w-full max-w-md">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
上传文件
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||||
|
>
|
||||||
|
<i className="fas fa-times text-xl"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
目标目录
|
||||||
|
</label>
|
||||||
|
<div className="px-3 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{currentPath || '根目录'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
选择文件
|
||||||
|
</label>
|
||||||
|
<div
|
||||||
|
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
|
||||||
|
dragActive
|
||||||
|
? 'border-blue-400 bg-blue-50 dark:bg-blue-900/20'
|
||||||
|
: 'border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500'
|
||||||
|
}`}
|
||||||
|
onDragEnter={handleDrag}
|
||||||
|
onDragLeave={handleDrag}
|
||||||
|
onDragOver={handleDrag}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
disabled={uploading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{file ? (
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
{file.name}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setFile(null);
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-red-500 hover:text-red-700"
|
||||||
|
>
|
||||||
|
<i className="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
大小: {formatFileSize(file.size)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<i className="fas fa-cloud-upload-alt text-3xl text-gray-400 dark:text-gray-500 mb-2"></i>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
点击或拖拽文件到这里
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-red-600 dark:text-red-400 text-sm mb-4">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex space-x-4 pt-4">
|
||||||
|
<button
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={!file || uploading}
|
||||||
|
className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||||
|
上传中...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
'上传'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={uploading}
|
||||||
|
className="flex-1 px-4 py-2 bg-gray-200 hover:bg-gray-300 dark:bg-gray-600 dark:hover:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||||
|
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
|
<i className="fas fa-info-circle mr-2"></i>
|
||||||
|
只有管理员可以上传文件
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/components/UserMenu.tsx
Normal file
91
src/components/UserMenu.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserMenuProps {
|
||||||
|
user: User | null;
|
||||||
|
onLogin: () => void;
|
||||||
|
onLogout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserMenu({ user, onLogin, onLogout }: UserMenuProps) {
|
||||||
|
const [showMenu, setShowMenu] = useState(false);
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
try {
|
||||||
|
await fetch('/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
localStorage.removeItem('user');
|
||||||
|
onLogout();
|
||||||
|
setShowMenu(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('登出失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={onLogin}
|
||||||
|
className="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
<i className="fas fa-sign-in-alt mr-2"></i>
|
||||||
|
登录
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowMenu(!showMenu)}
|
||||||
|
className="flex items-center space-x-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
<i className="fas fa-user"></i>
|
||||||
|
<span>{user.username}</span>
|
||||||
|
<i className="fas fa-chevron-down text-xs"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showMenu && (
|
||||||
|
<>
|
||||||
|
{/* 遮罩层 */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-10"
|
||||||
|
onClick={() => setShowMenu(false)}
|
||||||
|
/>
|
||||||
|
{/* 菜单 */}
|
||||||
|
<div className="absolute right-0 top-full mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 z-20">
|
||||||
|
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
{user.username}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{user.email}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
角色: {user.role === 'admin' ? '管理员' : '用户'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="py-2">
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
<i className="fas fa-sign-out-alt mr-2"></i>
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
src/db/status.json
Normal file
4
src/db/status.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"status": 0,
|
||||||
|
"lastUpdated": "2025-12-13T17:22:14.3NZ"
|
||||||
|
}
|
||||||
20
src/db/user.json
Normal file
20
src/db/user.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"username": "bbt",
|
||||||
|
"password": "123",
|
||||||
|
"email": "",
|
||||||
|
"role": "admin",
|
||||||
|
"createdAt": "2024-01-01T00:00:00Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"username": "user",
|
||||||
|
"password": "user123",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"role": "user",
|
||||||
|
"createdAt": "2024-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
98
src/lib/config.ts
Normal file
98
src/lib/config.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
// 移除 Node.js 模块导入,将在服务器端 API 中使用
|
||||||
|
|
||||||
|
export interface DirectoryConfig {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MirrorConfig {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
baseUrl: string;
|
||||||
|
logo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeaturesConfig {
|
||||||
|
enableSearch: boolean;
|
||||||
|
enableDarkMode: boolean;
|
||||||
|
enableStats: boolean;
|
||||||
|
enableDownload: boolean;
|
||||||
|
enableCopyLink: boolean;
|
||||||
|
maxFileSize: string;
|
||||||
|
supportedFormats: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UIConfig {
|
||||||
|
itemsPerPage: number;
|
||||||
|
defaultView: 'list' | 'grid';
|
||||||
|
showHiddenFiles: boolean;
|
||||||
|
refreshInterval: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LinuxCommandsConfig {
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Config {
|
||||||
|
mirror: MirrorConfig;
|
||||||
|
directories: DirectoryConfig[];
|
||||||
|
features: FeaturesConfig;
|
||||||
|
ui: UIConfig;
|
||||||
|
linuxCommands: LinuxCommandsConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
let config: Config | null = null;
|
||||||
|
|
||||||
|
// 注意:这个函数只在服务器端使用
|
||||||
|
export function getConfig(): Config {
|
||||||
|
if (config) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 只在服务器端使用 Node.js 模块
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
const { readFileSync } = require('fs');
|
||||||
|
const { join } = require('path');
|
||||||
|
|
||||||
|
const configPath = join(process.cwd(), 'config.json');
|
||||||
|
const configData = readFileSync(configPath, 'utf-8');
|
||||||
|
config = JSON.parse(configData) as Config;
|
||||||
|
return config;
|
||||||
|
} else {
|
||||||
|
// 客户端返回默认配置
|
||||||
|
throw new Error('Cannot load config on client side');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load config:', error);
|
||||||
|
// 返回默认配置
|
||||||
|
return {
|
||||||
|
mirror: {
|
||||||
|
name: 'Linux 镜像源',
|
||||||
|
description: '快速、稳定的 Linux 发行版镜像服务',
|
||||||
|
baseUrl: 'https://mirror.example.com',
|
||||||
|
logo: '/logo.svg'
|
||||||
|
},
|
||||||
|
directories: [],
|
||||||
|
features: {
|
||||||
|
enableSearch: true,
|
||||||
|
enableDarkMode: true,
|
||||||
|
enableStats: true,
|
||||||
|
enableDownload: true,
|
||||||
|
enableCopyLink: true,
|
||||||
|
maxFileSize: '50GB',
|
||||||
|
supportedFormats: ['.iso', '.tar.gz', '.tar.bz2', '.deb', '.rpm', '.zip']
|
||||||
|
},
|
||||||
|
ui: {
|
||||||
|
itemsPerPage: 50,
|
||||||
|
defaultView: 'list',
|
||||||
|
showHiddenFiles: false,
|
||||||
|
refreshInterval: 300
|
||||||
|
},
|
||||||
|
linuxCommands: {}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/lib/db.ts
Normal file
40
src/lib/db.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
// 数据库连接模块 - 简单的文件系统状态管理
|
||||||
|
import { readFileSync, writeFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
interface ConnectionStatus {
|
||||||
|
connected: boolean;
|
||||||
|
lastCheck: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取连接状态
|
||||||
|
export async function getConnectionStatus(): Promise<ConnectionStatus> {
|
||||||
|
try {
|
||||||
|
const statusPath = join(process.cwd(), 'src', 'db', 'status.json');
|
||||||
|
const statusData = JSON.parse(readFileSync(statusPath, 'utf-8'));
|
||||||
|
|
||||||
|
return {
|
||||||
|
connected: true,
|
||||||
|
lastCheck: new Date().toISOString(),
|
||||||
|
message: '文件系统连接正常'
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
connected: false,
|
||||||
|
lastCheck: new Date().toISOString(),
|
||||||
|
message: `连接错误: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试连接
|
||||||
|
export async function testConnection(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const statusPath = join(process.cwd(), 'src', 'db', 'status.json');
|
||||||
|
readFileSync(statusPath, 'utf-8');
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
161
src/lib/demo-data.ts
Normal file
161
src/lib/demo-data.ts
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { FileInfo } from '@/types';
|
||||||
|
|
||||||
|
export function getDemoFiles(path: string): { items: FileInfo[], stats: { folders: number, files: number, totalSize: number } } {
|
||||||
|
const demoFileSystem: { [key: string]: FileInfo[] } = {
|
||||||
|
'': [
|
||||||
|
{
|
||||||
|
name: 'ubuntu',
|
||||||
|
path: '/var/mirror/ubuntu',
|
||||||
|
type: 'dir',
|
||||||
|
size: 0,
|
||||||
|
modified: new Date('2024-01-15T10:30:00'),
|
||||||
|
description: 'Ubuntu Linux 发行版',
|
||||||
|
icon: 'fab fa-ubuntu',
|
||||||
|
color: '#E95420'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'centos',
|
||||||
|
path: '/var/mirror/centos',
|
||||||
|
type: 'dir',
|
||||||
|
size: 0,
|
||||||
|
modified: new Date('2024-01-15T09:45:00'),
|
||||||
|
description: 'CentOS Linux 发行版',
|
||||||
|
icon: 'fab fa-centos',
|
||||||
|
color: '#932279'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'debian',
|
||||||
|
path: '/var/mirror/debian',
|
||||||
|
type: 'dir',
|
||||||
|
size: 0,
|
||||||
|
modified: new Date('2024-01-15T11:20:00'),
|
||||||
|
description: 'Debian Linux 发行版',
|
||||||
|
icon: 'fab fa-debian',
|
||||||
|
color: '#A80030'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'archlinux',
|
||||||
|
path: '/var/mirror/archlinux',
|
||||||
|
type: 'dir',
|
||||||
|
size: 0,
|
||||||
|
modified: new Date('2024-01-15T12:00:00'),
|
||||||
|
description: 'Arch Linux 发行版',
|
||||||
|
icon: 'fab fa-linux',
|
||||||
|
color: '#1793D1'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'fedora',
|
||||||
|
path: '/var/mirror/fedora',
|
||||||
|
type: 'dir',
|
||||||
|
size: 0,
|
||||||
|
modified: new Date('2024-01-15T13:15:00'),
|
||||||
|
description: 'Fedora Linux 发行版',
|
||||||
|
icon: 'fab fa-fedora',
|
||||||
|
color: '#294172'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'README.md',
|
||||||
|
path: '/var/mirror/README.md',
|
||||||
|
type: 'file',
|
||||||
|
size: 2048,
|
||||||
|
modified: new Date('2024-01-10T08:00:00'),
|
||||||
|
ext: 'md'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'ubuntu': [
|
||||||
|
{
|
||||||
|
name: 'releases',
|
||||||
|
path: '/var/mirror/ubuntu/releases',
|
||||||
|
type: 'dir',
|
||||||
|
size: 0,
|
||||||
|
modified: new Date('2024-01-15T10:30:00')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'pool',
|
||||||
|
path: '/var/mirror/ubuntu/pool',
|
||||||
|
type: 'dir',
|
||||||
|
size: 0,
|
||||||
|
modified: new Date('2024-01-15T10:30:00')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'dists',
|
||||||
|
path: '/var/mirror/ubuntu/dists',
|
||||||
|
type: 'dir',
|
||||||
|
size: 0,
|
||||||
|
modified: new Date('2024-01-15T10:30:00')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ubuntu-22.04.3-desktop-amd64.iso',
|
||||||
|
path: '/var/mirror/ubuntu/ubuntu-22.04.3-desktop-amd64.iso',
|
||||||
|
type: 'file',
|
||||||
|
size: 4325376000,
|
||||||
|
modified: new Date('2024-01-14T15:20:00'),
|
||||||
|
ext: 'iso'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ubuntu-22.04.3-live-server-amd64.iso',
|
||||||
|
path: '/var/mirror/ubuntu/ubuntu-22.04.3-live-server-amd64.iso',
|
||||||
|
type: 'file',
|
||||||
|
size: 1625292800,
|
||||||
|
modified: new Date('2024-01-14T15:25:00'),
|
||||||
|
ext: 'iso'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'centos': [
|
||||||
|
{
|
||||||
|
name: '7',
|
||||||
|
path: '/var/mirror/centos/7',
|
||||||
|
type: 'dir',
|
||||||
|
size: 0,
|
||||||
|
modified: new Date('2024-01-15T09:45:00')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '8',
|
||||||
|
path: '/var/mirror/centos/8',
|
||||||
|
type: 'dir',
|
||||||
|
size: 0,
|
||||||
|
modified: new Date('2024-01-15T09:45:00')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'stream',
|
||||||
|
path: '/var/mirror/centos/stream',
|
||||||
|
type: 'dir',
|
||||||
|
size: 0,
|
||||||
|
modified: new Date('2024-01-15T09:45:00')
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'debian': [
|
||||||
|
{
|
||||||
|
name: 'dists',
|
||||||
|
path: '/var/mirror/debian/dists',
|
||||||
|
type: 'dir',
|
||||||
|
size: 0,
|
||||||
|
modified: new Date('2024-01-15T11:20:00')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'pool',
|
||||||
|
path: '/var/mirror/debian/pool',
|
||||||
|
type: 'dir',
|
||||||
|
size: 0,
|
||||||
|
modified: new Date('2024-01-15T11:20:00')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'debian-12.5.0-amd64-netinst.iso',
|
||||||
|
path: '/var/mirror/debian/debian-12.5.0-amd64-netinst.iso',
|
||||||
|
type: 'file',
|
||||||
|
size: 419840000,
|
||||||
|
modified: new Date('2024-01-12T10:30:00'),
|
||||||
|
ext: 'iso'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = demoFileSystem[path] || [];
|
||||||
|
const folders = items.filter(item => item.type === 'dir').length;
|
||||||
|
const files = items.filter(item => item.type === 'file').length;
|
||||||
|
const totalSize = items
|
||||||
|
.filter(item => item.type === 'file')
|
||||||
|
.reduce((sum, item) => sum + item.size, 0);
|
||||||
|
|
||||||
|
return { items, stats: { folders, files, totalSize } };
|
||||||
|
}
|
||||||
68
src/types/index.ts
Normal file
68
src/types/index.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
export interface DirectoryConfig {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MirrorConfig {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
baseUrl: string;
|
||||||
|
logo: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FeaturesConfig {
|
||||||
|
enableSearch: boolean;
|
||||||
|
enableDarkMode: boolean;
|
||||||
|
enableStats: boolean;
|
||||||
|
enableDownload: boolean;
|
||||||
|
enableCopyLink: boolean;
|
||||||
|
maxFileSize: string;
|
||||||
|
supportedFormats: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UIConfig {
|
||||||
|
itemsPerPage: number;
|
||||||
|
defaultView: 'list' | 'grid';
|
||||||
|
showHiddenFiles: boolean;
|
||||||
|
refreshInterval: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Config {
|
||||||
|
mirror: MirrorConfig;
|
||||||
|
directories: DirectoryConfig[];
|
||||||
|
features: FeaturesConfig;
|
||||||
|
ui: UIConfig;
|
||||||
|
linuxCommands: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileInfo {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
type: 'file' | 'dir';
|
||||||
|
size: number;
|
||||||
|
modified: Date;
|
||||||
|
ext?: string;
|
||||||
|
description?: string;
|
||||||
|
icon?: string;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirectoryStats {
|
||||||
|
folders: number;
|
||||||
|
files: number;
|
||||||
|
totalSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowseResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: {
|
||||||
|
items: FileInfo[];
|
||||||
|
path: string;
|
||||||
|
isRoot: boolean;
|
||||||
|
stats: DirectoryStats;
|
||||||
|
};
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
78
status_monitor.sh
Executable file
78
status_monitor.sh
Executable file
@@ -0,0 +1,78 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 状态监控和执行脚本
|
||||||
|
# 监控 /api/status,根据状态执行相应操作并循环
|
||||||
|
|
||||||
|
BASE_URL="http://localhost:3000"
|
||||||
|
STATUS_ENDPOINT="/api/status"
|
||||||
|
SETSTATUS_ENDPOINT="/api/setstatus"
|
||||||
|
REPO_DIR="/Users/hokori/code/html/mirrors/linux-mirror-browser"
|
||||||
|
|
||||||
|
echo "=== 状态监控脚本启动 ==="
|
||||||
|
echo "监控地址: $BASE_URL$STATUS_ENDPOINT"
|
||||||
|
echo "仓库目录: $REPO_DIR"
|
||||||
|
echo "注意: 脚本运行时不写入日志文件"
|
||||||
|
echo
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
|
||||||
|
echo "[$TIMESTAMP] 检查状态..."
|
||||||
|
|
||||||
|
# 获取状态
|
||||||
|
STATUS_RESPONSE=$(curl -s "$BASE_URL$STATUS_ENDPOINT")
|
||||||
|
|
||||||
|
# 解析状态值
|
||||||
|
if echo "$STATUS_RESPONSE" | grep -q '"status":1'; then
|
||||||
|
echo "[$TIMESTAMP] 状态: 1 (需要更新)"
|
||||||
|
|
||||||
|
# 切换到仓库目录并执行更新脚本
|
||||||
|
if [ -d "$REPO_DIR" ]; then
|
||||||
|
echo "[$TIMESTAMP] 切换到目录: $REPO_DIR"
|
||||||
|
cd "$REPO_DIR" || {
|
||||||
|
echo "[$TIMESTAMP] 错误: 无法切换到目录 $REPO_DIR"
|
||||||
|
sleep 30
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "[$TIMESTAMP] 执行更新脚本: ./update_repo.sh"
|
||||||
|
if [ -f "./update_repo.sh" ]; then
|
||||||
|
chmod +x ./update_repo.sh
|
||||||
|
|
||||||
|
# 执行更新脚本,输出直接显示在控制台
|
||||||
|
echo "[$TIMESTAMP] ===== 更新脚本开始执行 ====="
|
||||||
|
./update_repo.sh
|
||||||
|
UPDATE_RESULT=$?
|
||||||
|
echo "[$TIMESTAMP] ===== 更新脚本执行结束 ====="
|
||||||
|
|
||||||
|
if [ $UPDATE_RESULT -eq 0 ]; then
|
||||||
|
echo "[$TIMESTAMP] 更新脚本执行成功"
|
||||||
|
CURRENT_TIME=$(date -u +"%Y-%m-%dT%H:%M:%S.%3NZ")
|
||||||
|
echo "[$TIMESTAMP] 发送状态更新: s=ok, time=$CURRENT_TIME"
|
||||||
|
|
||||||
|
# 发送状态更新
|
||||||
|
SETSTATUS_RESPONSE=$(curl -s -X POST "$BASE_URL$SETSTATUS_ENDPOINT" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"s\": \"ok\", \"time\": \"$CURRENT_TIME\"}")
|
||||||
|
|
||||||
|
echo "[$TIMESTAMP] 状态更新响应: $SETSTATUS_RESPONSE"
|
||||||
|
else
|
||||||
|
echo "[$TIMESTAMP] 更新脚本执行失败,退出码: $UPDATE_RESULT"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "[$TIMESTAMP] 错误: 更新脚本 ./update_repo.sh 不存在"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "[$TIMESTAMP] 错误: 仓库目录 $REPO_DIR 不存在"
|
||||||
|
fi
|
||||||
|
|
||||||
|
elif echo "$STATUS_RESPONSE" | grep -q '"status":0'; then
|
||||||
|
echo "[$TIMESTAMP] 状态: 0 (无需更新),无事可做"
|
||||||
|
else
|
||||||
|
echo "[$TIMESTAMP] 错误: 无法解析状态响应"
|
||||||
|
echo "[$TIMESTAMP] 响应内容: $STATUS_RESPONSE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[$TIMESTAMP] 等待30秒..."
|
||||||
|
sleep 30
|
||||||
|
echo "[$TIMESTAMP] ------------------------"
|
||||||
|
done
|
||||||
63
test_setstatus.sh
Executable file
63
test_setstatus.sh
Executable file
@@ -0,0 +1,63 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# 测试 /api/setstatus POST 接口的shell脚本
|
||||||
|
|
||||||
|
BASE_URL="http://localhost:3000"
|
||||||
|
API_ENDPOINT="/api/setstatus"
|
||||||
|
|
||||||
|
echo "=== 测试 /api/setstatus POST 接口 ==="
|
||||||
|
echo
|
||||||
|
|
||||||
|
# 测试1: 正常情况 - s="ok", time=当前时间
|
||||||
|
echo "测试1: s='ok', time=当前时间"
|
||||||
|
CURRENT_TIME=$(date -u +"%Y-%m-%dT%H:%M:%S.%3NZ")
|
||||||
|
echo "请求参数: s='ok', time='$CURRENT_TIME'"
|
||||||
|
RESPONSE1=$(curl -s -X POST "$BASE_URL$API_ENDPOINT" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"s\": \"ok\", \"time\": \"$CURRENT_TIME\"}")
|
||||||
|
echo "响应: $RESPONSE1"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# 测试2: 检查status.json是否更新成功
|
||||||
|
echo "检查status.json内容:"
|
||||||
|
if [ -f "src/db/status.json" ]; then
|
||||||
|
cat src/db/status.json
|
||||||
|
else
|
||||||
|
echo "status.json文件不存在"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
|
||||||
|
# 测试3: 缺少s参数
|
||||||
|
echo "测试3: 缺少s参数"
|
||||||
|
RESPONSE3=$(curl -s -X POST "$BASE_URL$API_ENDPOINT" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"time\": \"$CURRENT_TIME\"}")
|
||||||
|
echo "响应: $RESPONSE3"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# 测试4: 缺少time参数
|
||||||
|
echo "测试4: 缺少time参数"
|
||||||
|
RESPONSE4=$(curl -s -X POST "$BASE_URL$API_ENDPOINT" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"s\": \"ok\"}")
|
||||||
|
echo "响应: $RESPONSE4"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# 测试5: s不等于ok
|
||||||
|
echo "测试5: s不等于ok"
|
||||||
|
TEST_TIME=$(date -u +"%Y-%m-%dT%H:%M:%S.%3NZ")
|
||||||
|
RESPONSE5=$(curl -s -X POST "$BASE_URL$API_ENDPOINT" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"s\": \"error\", \"time\": \"$TEST_TIME\"}")
|
||||||
|
echo "响应: $RESPONSE5"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# 测试6: 空参数
|
||||||
|
echo "测试6: 空参数"
|
||||||
|
RESPONSE6=$(curl -s -X POST "$BASE_URL$API_ENDPOINT" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{}")
|
||||||
|
echo "响应: $RESPONSE6"
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "=== 测试完成 ==="
|
||||||
34
tsconfig.json
Normal file
34
tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
1
update_repo.sh
Executable file
1
update_repo.sh
Executable file
@@ -0,0 +1 @@
|
|||||||
|
echo aaa
|
||||||
Reference in New Issue
Block a user