diff --git a/.cursorignore b/.cursorignore
index 6f9f00f..56662bb 100644
--- a/.cursorignore
+++ b/.cursorignore
@@ -1 +1,2 @@
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
+# *.env
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index edd0df0..8a7451d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,4 +37,5 @@ npm-debug.log*
packages/db/generated
-volumes
\ No newline at end of file
+volumes
+uploads
\ No newline at end of file
diff --git a/README.md b/README.md
index 7fd254f..047a08a 100644
--- a/README.md
+++ b/README.md
@@ -4,22 +4,164 @@
This template is for creating a monorepo with Turborepo, shadcn/ui, tailwindcss v4, and react v19.
+## 项目结构
+
+```
+├── apps/
+│ ├── backend/ # Hono 后端应用
+│ └── web/ # Next.js 前端应用
+├── packages/
+│ ├── db/ # Prisma 数据库包
+│ ├── storage/ # 存储解决方案包
+│ ├── tus/ # TUS 上传协议包
+│ └── ui/ # UI 组件包
+└── docs/ # 文档
+```
+
+## 特性
+
+- 🚀 **现代技术栈**: Next.js 15, React 19, Hono, Prisma
+- 📦 **Monorepo**: 使用 Turborepo 管理多包项目
+- 🎨 **UI 组件**: shadcn/ui + TailwindCSS v4
+- 📤 **文件上传**: 支持 TUS 协议的可恢复上传
+- 💾 **多存储支持**: 本地存储 + S3 兼容存储
+- 🗄️ **数据库**: PostgreSQL + Prisma ORM
+- 🔄 **实时通信**: WebSocket 支持
+
+## 快速开始
+
+### 1. 安装依赖
+
+```bash
+pnpm install
+```
+
+### 2. 环境变量配置
+
+复制环境变量模板并配置:
+
+```bash
+cp .env.example .env
+```
+
+#### 存储配置
+
+**本地存储(开发环境推荐):**
+
+```bash
+STORAGE_TYPE=local
+UPLOAD_DIR=./uploads
+```
+
+**S3 存储(生产环境推荐):**
+
+```bash
+STORAGE_TYPE=s3
+S3_BUCKET=your-bucket-name
+S3_REGION=us-east-1
+S3_ACCESS_KEY_ID=your-access-key
+S3_SECRET_ACCESS_KEY=your-secret-key
+```
+
+**MinIO 本地开发:**
+
+```bash
+STORAGE_TYPE=s3
+S3_BUCKET=uploads
+S3_ENDPOINT=http://localhost:9000
+S3_ACCESS_KEY_ID=minioadmin
+S3_SECRET_ACCESS_KEY=minioadmin
+S3_FORCE_PATH_STYLE=true
+```
+
+详细的环境变量配置请参考:[环境变量配置指南](./docs/ENVIRONMENT.md)
+
+### 3. 数据库设置
+
+```bash
+# 生成 Prisma 客户端
+pnpm db:generate
+
+# 运行数据库迁移
+pnpm db:migrate
+
+# 填充种子数据(可选)
+pnpm db:seed
+```
+
+### 4. 启动开发服务器
+
+```bash
+pnpm dev
+```
+
+这将启动:
+
+- 前端应用: http://localhost:3001
+- 后端 API: http://localhost:3000
+- 文件上传: http://localhost:3000/upload
+- 存储管理 API: http://localhost:3000/api/storage
+
+## 存储包 (@repo/storage)
+
+项目包含一个功能完整的存储解决方案包,支持:
+
+### 核心功能
+
+- 🗂️ **多存储后端**: 本地文件系统、AWS S3、MinIO、阿里云 OSS、腾讯云 COS
+- 📤 **TUS 上传**: 支持可恢复的大文件上传
+- 🔧 **Hono 集成**: 提供即插即用的中间件
+- 📊 **文件管理**: 完整的文件生命周期管理
+- ⏰ **自动清理**: 过期文件自动清理机制
+- 🔄 **存储迁移**: 支持不同存储类型间的数据迁移
+
+### API 端点
+
+```bash
+# 文件资源管理
+GET /api/storage/resources # 获取所有资源
+GET /api/storage/resource/:fileId # 获取文件信息
+DELETE /api/storage/resource/:id # 删除资源
+
+# 文件访问和下载
+GET /download/:fileId # 文件下载和访问(支持所有存储类型)
+
+# 统计和管理
+GET /api/storage/stats # 获取统计信息
+POST /api/storage/cleanup # 清理过期文件
+POST /api/storage/migrate-storage # 迁移存储类型
+
+# 文件上传 (TUS 协议)
+POST /upload # 开始上传
+PATCH /upload/:id # 续传文件
+HEAD /upload/:id # 获取上传状态
+```
+
+### 使用示例
+
+```typescript
+import { createStorageApp, startCleanupScheduler } from '@repo/storage';
+
+// 创建存储应用
+const storageApp = createStorageApp({
+ apiBasePath: '/api/storage',
+ uploadPath: '/upload',
+});
+
+// 挂载到主应用
+app.route('/', storageApp);
+
+// 启动清理调度器
+startCleanupScheduler();
+```
+
## One-click Deploy
You can deploy this template to Vercel with the button below:
-[](https://vercel.com/new/clone?build-command=cd+..%2F..%2F+%26%26+pnpm+turbo+build+--filter%3Dweb...&demo-description=This+is+a+template+Turborepo+with+ShadcnUI+tailwindv4&demo-image=%2F%2Fimages.ctfassets.net%2Fe5382hct74si%2F2JxNyYATuuV7WPuJ31kF9Q%2F433990aa4c8e7524a9095682fb08f0b1%2FBasic.png&demo-title=Turborepo+%26+Next.js+Starter&demo-url=https%3A%2F%2Fexamples-basic-web.vercel.sh%2F&from=templates&project-name=Turborepo+%26+Next.js+Starter&repository-name=turborepo-shadcn-tailwind&repository-url=https%3A%2F%2Fgithub.com%2Flinkb15%2Fturborepo-shadcn-ui-tailwind-4&root-directory=apps%2Fweb&skippable-integrations=1)
+[](https://vercel.com/new/clone?build-command=cd+..%2F..%2F+%26%26+pnpm+turbo+build+--filter%3Dweb...&demo-description=This+is+a+template+Turborepo+with+ShadcnUI+tailwindv4&demo-image=%2F%2Fimages.ctfassets.net%2Fe5382hct74si%2F2JxNyYATuuV7WPuJ31kF9Q%2F433990aa4c8e7524a9095682fb08f0b1%2FBasic.png&demo-title=Turborepo+%26+Next.js+Starter&demo-url=https%3A%2F%2Fexamples-basic-web.vercel.sh%2F&from=templates&project-name=Turborepo+%26+Next.js+Starter&repository-name=turborepo-shadcn-tailwind&repository-url=https%3A%2F%2Flinkb15%2Fturborepo-shadcn-ui-tailwind-4&root-directory=apps%2Fweb&skippable-integrations=1)
-## Usage
-
-in the root directory run:
-
-```bash
-pnpm install
-pnpm dev
-```
-
-## Adding components
+## 添加 UI 组件
To add components to your app, run the following command at the root of your `web` app:
@@ -33,7 +175,7 @@ This will place the ui components in the `packages/ui/src/components` directory.
Your `globals.css` are already set up to use the components from the `ui` package which is imported in the `web` app.
-## Using components
+## 使用组件
To use the components in your app, import them from the `ui` package.
@@ -41,11 +183,44 @@ To use the components in your app, import them from the `ui` package.
import { Button } from '@repo/ui/components/ui/button';
```
+## 脚本命令
+
+```bash
+# 开发
+pnpm dev # 启动所有应用
+pnpm dev:web # 只启动前端
+pnpm dev:backend # 只启动后端
+
+# 构建
+pnpm build # 构建所有包
+pnpm build:web # 构建前端
+pnpm build:backend # 构建后端
+
+# 数据库
+pnpm db:generate # 生成 Prisma 客户端
+pnpm db:migrate # 运行数据库迁移
+pnpm db:seed # 填充种子数据
+pnpm db:studio # 打开 Prisma Studio
+
+# 代码质量
+pnpm lint # 代码检查
+pnpm type-check # 类型检查
+pnpm format # 代码格式化
+```
+
+## 文档
+
+- [环境变量配置指南](./docs/ENVIRONMENT.md)
+- [存储包文档](./packages/storage/README.md)
+- [文件访问使用指南](./docs/STATIC_FILES.md)
+
## More Resources
- [shadcn/ui - Monorepo](https://ui.shadcn.com/docs/monorepo)
- [Turborepo - shadcn/ui](https://turbo.build/repo/docs/guides/tools/shadcn-ui)
- [TailwindCSS v4 - Explicitly Registering Sources](https://tailwindcss.com/docs/detecting-classes-in-source-files#explicitly-registering-sources)
+- [Hono Documentation](https://hono.dev/)
+- [TUS Protocol](https://tus.io/)
[opengraph-image]: https://turborepo-shadcn-tailwind.vercel.app/opengraph-image.png
[opengraph-image-url]: https://turborepo-shadcn-tailwind.vercel.app/
diff --git a/apps/backend/package.json b/apps/backend/package.json
index 4c7c7d1..05b2536 100644
--- a/apps/backend/package.json
+++ b/apps/backend/package.json
@@ -1,35 +1,36 @@
{
- "name": "backend",
- "scripts": {
- "dev": "bun run --hot src/index.ts"
- },
- "dependencies": {
- "@elastic/elasticsearch": "^9.0.2",
- "@hono/node-server": "^1.14.3",
- "@hono/trpc-server": "^0.3.4",
- "@hono/zod-validator": "^0.5.0",
- "@repo/db": "workspace:*",
- "@repo/oidc-provider": "workspace:*",
- "@repo/tus": "workspace:*",
- "@trpc/server": "11.1.2",
- "dayjs": "^1.11.12",
- "hono": "^4.7.10",
- "ioredis": "5.4.1",
- "jose": "^6.0.11",
- "minio": "7.1.3",
- "nanoid": "^5.1.5",
- "node-cron": "^4.0.7",
- "oidc-provider": "^9.1.1",
- "superjson": "^2.2.2",
- "transliteration": "^2.3.5",
- "valibot": "^1.1.0",
- "zod": "^3.25.23"
- },
- "devDependencies": {
- "@types/bun": "latest",
- "@types/node": "^22.15.21",
- "@types/oidc-provider": "^9.1.0",
- "supertest": "^7.1.1",
- "vitest": "^3.1.4"
- }
+ "name": "backend",
+ "scripts": {
+ "dev": "bun run --hot src/index.ts"
+ },
+ "dependencies": {
+ "@elastic/elasticsearch": "^9.0.2",
+ "@hono/node-server": "^1.14.3",
+ "@hono/trpc-server": "^0.3.4",
+ "@hono/zod-validator": "^0.5.0",
+ "@repo/db": "workspace:*",
+ "@repo/oidc-provider": "workspace:*",
+ "@repo/tus": "workspace:*",
+ "@repo/storage": "workspace:*",
+ "@trpc/server": "11.1.2",
+ "dayjs": "^1.11.12",
+ "hono": "^4.7.10",
+ "ioredis": "5.4.1",
+ "jose": "^6.0.11",
+ "minio": "7.1.3",
+ "nanoid": "^5.1.5",
+ "node-cron": "^4.0.7",
+ "oidc-provider": "^9.1.1",
+ "superjson": "^2.2.2",
+ "transliteration": "^2.3.5",
+ "valibot": "^1.1.0",
+ "zod": "^3.25.23"
+ },
+ "devDependencies": {
+ "@types/bun": "latest",
+ "@types/node": "^22.15.21",
+ "@types/oidc-provider": "^9.1.0",
+ "supertest": "^7.1.1",
+ "vitest": "^3.1.4"
+ }
}
diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts
index f5e82bf..4162eeb 100644
--- a/apps/backend/src/index.ts
+++ b/apps/backend/src/index.ts
@@ -15,11 +15,8 @@ import { wsHandler, wsConfig } from './socket';
// 导入新的路由
import userRest from './user/user.rest';
-import uploadRest from './upload/upload.rest';
-import { startCleanupScheduler } from './upload/scheduler';
-
-// 导入OIDC Provider
-import { oidcApp } from './oidc';
+// 使用新的 @repo/storage 包
+import { createStorageApp, startCleanupScheduler } from '@repo/storage';
type Env = {
Variables: {
@@ -59,10 +56,13 @@ app.use(
// 添加 REST API 路由
app.route('/api/users', userRest);
-app.route('/api/upload', uploadRest);
-// 挂载 OIDC Provider
-app.route('/oidc', oidcApp);
+// 使用新的存储应用,包含API和上传功能
+const storageApp = createStorageApp({
+ apiBasePath: '/api/storage',
+ uploadPath: '/upload',
+});
+app.route('/', storageApp);
// 添加 WebSocket 路由
app.get('/ws', wsHandler);
diff --git a/apps/backend/src/upload/types.ts b/apps/backend/src/upload/types.ts
deleted file mode 100644
index 2140ebc..0000000
--- a/apps/backend/src/upload/types.ts
+++ /dev/null
@@ -1,29 +0,0 @@
-export interface UploadCompleteEvent {
- identifier: string;
- filename: string;
- size: number;
- hash: string;
- integrityVerified: boolean;
-}
-
-export type UploadEvent = {
- uploadStart: {
- identifier: string;
- filename: string;
- totalSize: number;
- resuming?: boolean;
- };
- uploadComplete: UploadCompleteEvent;
- uploadError: { identifier: string; error: string; filename: string };
-};
-export interface UploadLock {
- clientId: string;
- timestamp: number;
-}
-// 添加重试机制,处理临时网络问题
-// 实现定期清理过期的临时文件
-// 添加文件完整性校验
-// 实现上传进度持久化,支持服务重启后恢复
-// 添加并发限制,防止系统资源耗尽
-// 实现文件去重功能,避免重复上传
-// 添加日志记录和监控机制
diff --git a/apps/backend/src/upload/upload.rest.ts b/apps/backend/src/upload/upload.rest.ts
deleted file mode 100644
index 440e457..0000000
--- a/apps/backend/src/upload/upload.rest.ts
+++ /dev/null
@@ -1,198 +0,0 @@
-import { Hono } from 'hono';
-import { handleTusRequest, cleanupExpiredUploads, getStorageInfo } from './tus';
-import {
- getResourceByFileId,
- getAllResources,
- deleteResource,
- updateResource,
- getResourcesByStorageType,
- getResourcesByStatus,
- getUploadingResources,
- getResourceStats,
- migrateResourcesStorageType,
-} from './upload.index';
-import { StorageManager, StorageType, type StorageConfig } from './storage.adapter';
-import { prisma } from '@repo/db';
-
-const uploadRest = new Hono();
-
-// 获取文件资源信息
-uploadRest.get('/resource/:fileId', async (c) => {
- const fileId = c.req.param('fileId');
- const result = await getResourceByFileId(fileId);
- return c.json(result);
-});
-
-// 获取所有资源
-uploadRest.get('/resources', async (c) => {
- const resources = await getAllResources();
- return c.json(resources);
-});
-
-// 根据存储类型获取资源
-uploadRest.get('/resources/storage/:storageType', async (c) => {
- const storageType = c.req.param('storageType') as StorageType;
- const resources = await getResourcesByStorageType(storageType);
- return c.json(resources);
-});
-
-// 根据状态获取资源
-uploadRest.get('/resources/status/:status', async (c) => {
- const status = c.req.param('status');
- const resources = await getResourcesByStatus(status);
- return c.json(resources);
-});
-
-// 获取正在上传的资源
-uploadRest.get('/resources/uploading', async (c) => {
- const resources = await getUploadingResources();
- return c.json(resources);
-});
-
-// 获取资源统计信息
-uploadRest.get('/stats', async (c) => {
- const stats = await getResourceStats();
- return c.json(stats);
-});
-
-// 删除资源
-uploadRest.delete('/resource/:id', async (c) => {
- const id = c.req.param('id');
- const result = await deleteResource(id);
- return c.json(result);
-});
-
-// 更新资源
-uploadRest.patch('/resource/:id', async (c) => {
- const id = c.req.param('id');
- const data = await c.req.json();
- const result = await updateResource(id, data);
- return c.json(result);
-});
-
-// 迁移资源存储类型(批量更新数据库中的存储类型标记)
-uploadRest.post('/migrate-storage', async (c) => {
- try {
- const { from, to } = await c.req.json();
- const result = await migrateResourcesStorageType(from as StorageType, to as StorageType);
- return c.json({
- success: true,
- message: `Migrated ${result.count} resources from ${from} to ${to}`,
- count: result.count,
- });
- } catch (error) {
- console.error('Failed to migrate storage type:', error);
- return c.json(
- {
- success: false,
- error: error instanceof Error ? error.message : 'Unknown error',
- },
- 400,
- );
- }
-});
-
-// 清理过期上传
-uploadRest.post('/cleanup', async (c) => {
- const result = await cleanupExpiredUploads();
- return c.json(result);
-});
-
-// 手动清理指定状态的资源
-uploadRest.post('/cleanup/by-status', async (c) => {
- try {
- const { status, olderThanDays } = await c.req.json();
- const cutoffDate = new Date();
- cutoffDate.setDate(cutoffDate.getDate() - (olderThanDays || 30));
-
- const deletedResources = await prisma.resource.deleteMany({
- where: {
- status,
- createdAt: {
- lt: cutoffDate,
- },
- },
- });
-
- return c.json({
- success: true,
- message: `Deleted ${deletedResources.count} resources with status ${status}`,
- count: deletedResources.count,
- });
- } catch (error) {
- console.error('Failed to cleanup by status:', error);
- return c.json(
- {
- success: false,
- error: error instanceof Error ? error.message : 'Unknown error',
- },
- 400,
- );
- }
-});
-
-// 获取存储信息
-uploadRest.get('/storage/info', async (c) => {
- const storageInfo = getStorageInfo();
- return c.json(storageInfo);
-});
-
-// 切换存储类型(需要重启应用)
-uploadRest.post('/storage/switch', async (c) => {
- try {
- const newConfig = (await c.req.json()) as StorageConfig;
- const storageManager = StorageManager.getInstance();
- await storageManager.switchStorage(newConfig);
-
- return c.json({
- success: true,
- message: 'Storage configuration updated. Please restart the application for changes to take effect.',
- newType: newConfig.type,
- });
- } catch (error) {
- console.error('Failed to switch storage:', error);
- return c.json(
- {
- success: false,
- error: error instanceof Error ? error.message : 'Unknown error',
- },
- 400,
- );
- }
-});
-
-// 验证存储配置
-uploadRest.post('/storage/validate', async (c) => {
- try {
- const config = (await c.req.json()) as StorageConfig;
- const { validateStorageConfig } = await import('./storage.adapter');
- const errors = validateStorageConfig(config);
-
- if (errors.length > 0) {
- return c.json({ valid: false, errors }, 400);
- }
-
- return c.json({ valid: true, message: 'Storage configuration is valid' });
- } catch (error) {
- return c.json(
- {
- valid: false,
- errors: [error instanceof Error ? error.message : 'Invalid JSON'],
- },
- 400,
- );
- }
-});
-
-// TUS 协议处理 - 使用通用处理器
-uploadRest.all('/*', async (c) => {
- try {
- await handleTusRequest(c.req.raw, c.res);
- return new Response(null);
- } catch (error) {
- console.error('TUS request error:', error);
- return c.json({ error: 'Upload request failed' }, 500);
- }
-});
-
-export default uploadRest;
diff --git a/apps/backend/src/upload/utils.ts b/apps/backend/src/upload/utils.ts
deleted file mode 100644
index a7c189f..0000000
--- a/apps/backend/src/upload/utils.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-export function extractFileIdFromNginxUrl(url: string) {
- const match = url.match(/uploads\/(\d{4}\/\d{2}\/\d{2}\/[^/]+)/);
- return match ? match[1] : '';
-}
diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json
index 53412b3..ed8e081 100644
--- a/apps/backend/tsconfig.json
+++ b/apps/backend/tsconfig.json
@@ -4,7 +4,8 @@
"moduleResolution": "bundler",
"paths": {
"@/*": ["./*"],
- "@repo/db/*": ["../../packages/db/src/*"]
+ "@repo/db/*": ["../../packages/db/src/*"],
+ "@repo/storage/*": ["../../packages/storage/src/*"]
}
}
}
diff --git a/apps/web/app/upload/page.tsx b/apps/web/app/upload/page.tsx
new file mode 100644
index 0000000..cef182f
--- /dev/null
+++ b/apps/web/app/upload/page.tsx
@@ -0,0 +1,44 @@
+'use client';
+import { FileUpload } from '../../components/FileUpload';
+import { FileDownload } from '../../components/FileDownload';
+import { AdvancedFileDownload } from '../../components/AdvancedFileDownload';
+import { DownloadTester } from '../../components/DownloadTester';
+
+export default function UploadPage() {
+ return (
+
+
+
+
文件上传和下载中心
+
完整的文件管理解决方案:上传、下载、预览
+
+
+ {/* 上传组件 */}
+
+
📤 文件上传
+
+
+
+ {/* 下载测试组件 */}
+
+
🔧 下载测试
+
+
+
+
+ {/* 基础下载组件 */}
+
+
📥 基础下载
+
+
+
+ {/* 高级下载组件 */}
+
+
+
+
+ );
+}
diff --git a/apps/web/components/AdvancedFileDownload.tsx b/apps/web/components/AdvancedFileDownload.tsx
new file mode 100644
index 0000000..34ceeac
--- /dev/null
+++ b/apps/web/components/AdvancedFileDownload.tsx
@@ -0,0 +1,234 @@
+import React, { useState } from 'react';
+import { useFileDownload } from '../hooks/useFileDownload';
+import { useTusUpload } from '../hooks/useTusUpload';
+
+export function AdvancedFileDownload() {
+ const { getFileInfo } = useTusUpload();
+ const {
+ downloadProgress,
+ isDownloading,
+ downloadError,
+ downloadFile,
+ downloadFileWithProgress,
+ previewFile,
+ copyFileLink,
+ canPreview,
+ getFileIcon,
+ } = useFileDownload();
+
+ const [fileId, setFileId] = useState('');
+ const [fileInfo, setFileInfo] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ // 获取文件信息
+ const handleGetFileInfo = async () => {
+ if (!fileId.trim()) {
+ setError('请输入文件ID');
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ const info = await getFileInfo(fileId);
+ if (info) {
+ setFileInfo(info);
+ } else {
+ setError('文件不存在或未准备好');
+ }
+ } catch (err) {
+ setError('获取文件信息失败');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // 简单下载
+ const handleSimpleDownload = () => {
+ downloadFile(fileId, fileInfo?.title);
+ };
+
+ // 带进度的下载
+ const handleProgressDownload = async () => {
+ try {
+ await downloadFileWithProgress(fileId, fileInfo?.title);
+ } catch (error) {
+ console.error('Download with progress failed:', error);
+ }
+ };
+
+ // 预览文件
+ const handlePreview = () => {
+ previewFile(fileId);
+ };
+
+ // 复制链接
+ const handleCopyLink = async () => {
+ try {
+ await copyFileLink(fileId);
+ alert('链接已复制到剪贴板!');
+ } catch (error) {
+ alert('复制失败');
+ }
+ };
+
+ return (
+
+
高级文件下载
+
+ {/* 文件ID输入 */}
+
+
+
+ setFileId(e.target.value)}
+ placeholder="输入文件ID"
+ className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
+
+
+
+
+ {/* 错误信息 */}
+ {(error || downloadError) && (
+
+ {error || downloadError}
+
+ )}
+
+ {/* 下载进度 */}
+ {isDownloading && downloadProgress && (
+
+
+ 下载进度
+ {downloadProgress.percentage}%
+
+
+
+ {formatFileSize(downloadProgress.loaded)} / {formatFileSize(downloadProgress.total)}
+
+
+ )}
+
+ {/* 文件信息 */}
+ {fileInfo && (
+
+
+
{getFileIcon(fileInfo.type || '')}
+
+
{fileInfo.title || '未知文件'}
+
{fileInfo.type || '未知类型'}
+
+
+
+
+
+ 状态:
+
+ {fileInfo.status || '未知'}
+
+
+ {fileInfo.meta?.size && (
+
+ 大小: {formatFileSize(fileInfo.meta.size)}
+
+ )}
+
+ 创建时间: {new Date(fileInfo.createdAt).toLocaleString()}
+
+
+ 存储类型: {fileInfo.storageType || '未知'}
+
+
+
+ )}
+
+ {/* 操作按钮 */}
+ {fileInfo && (
+
+
+
+
+ {canPreview(fileInfo.type || '') && (
+
+ )}
+
+
+
+ {/* 文件预览提示 */}
+ {canPreview(fileInfo.type || '') && (
+
+
💡 此文件支持在线预览,点击"预览文件"可以在浏览器中直接查看
+
+ )}
+
+ )}
+
+ {/* 使用说明 */}
+
+
功能说明:
+
+ -
+ • 快速下载:直接通过浏览器下载文件
+
+ -
+ • 进度下载:显示下载进度,适合大文件
+
+ -
+ • 预览文件:图片、PDF、视频等可在线预览
+
+ -
+ • 复制链接:复制文件访问链接到剪贴板
+
+
+
+
+ );
+}
+
+// 格式化文件大小
+function 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];
+}
diff --git a/apps/web/components/DownloadTester.tsx b/apps/web/components/DownloadTester.tsx
new file mode 100644
index 0000000..aa472b2
--- /dev/null
+++ b/apps/web/components/DownloadTester.tsx
@@ -0,0 +1,90 @@
+import React, { useState } from 'react';
+import { useTusUpload } from '../hooks/useTusUpload';
+
+export function DownloadTester() {
+ const { serverUrl, getFileInfo } = useTusUpload();
+ const [fileId, setFileId] = useState('2025/05/28/1mVGC8r6jy');
+ const [testResults, setTestResults] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ const runTests = async () => {
+ setLoading(true);
+ const results: any = {
+ fileId,
+ serverUrl,
+ timestamp: new Date().toISOString(),
+ };
+
+ try {
+ // 测试1: 检查资源信息
+ console.log('Testing resource info...');
+ const resourceInfo = await getFileInfo(fileId);
+ results.resourceInfo = resourceInfo;
+
+ // 测试2: 测试下载端点
+ console.log('Testing download endpoint...');
+ const downloadUrl = `${serverUrl}/download/${fileId}`;
+ results.downloadUrl = downloadUrl;
+
+ const response = await fetch(downloadUrl, { method: 'HEAD' });
+ results.downloadResponse = {
+ status: response.status,
+ statusText: response.statusText,
+ headers: Object.fromEntries(response.headers.entries()),
+ };
+
+ // 测试3: 测试API端点
+ console.log('Testing API endpoint...');
+ const apiUrl = `${serverUrl}/api/storage/resource/${fileId}`;
+ results.apiUrl = apiUrl;
+
+ const apiResponse = await fetch(apiUrl);
+ const apiData = await apiResponse.json();
+ results.apiResponse = {
+ status: apiResponse.status,
+ data: apiData,
+ };
+ } catch (error) {
+ results.error = error instanceof Error ? error.message : String(error);
+ }
+
+ setTestResults(results);
+ setLoading(false);
+ };
+
+ return (
+
+
🔧 下载功能测试
+
+
+
+
+ setFileId(e.target.value)}
+ className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
+
+
+
+
+ {testResults && (
+
+
+
测试结果
+
+ {JSON.stringify(testResults, null, 2)}
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/web/components/FileDownload.tsx b/apps/web/components/FileDownload.tsx
new file mode 100644
index 0000000..3e9b544
--- /dev/null
+++ b/apps/web/components/FileDownload.tsx
@@ -0,0 +1,157 @@
+import React, { useState } from 'react';
+import { useTusUpload } from '../hooks/useTusUpload';
+
+interface FileDownloadProps {
+ fileId?: string;
+ fileName?: string;
+ className?: string;
+}
+
+export function FileDownload({ fileId, fileName, className }: FileDownloadProps) {
+ const { getFileUrlByFileId, getFileInfo } = useTusUpload();
+ const [inputFileId, setInputFileId] = useState(fileId || '');
+ const [fileInfo, setFileInfo] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ // 获取文件信息
+ const handleGetFileInfo = async () => {
+ if (!inputFileId.trim()) {
+ setError('请输入文件ID');
+ return;
+ }
+
+ setLoading(true);
+ setError(null);
+
+ try {
+ const info = await getFileInfo(inputFileId);
+ if (info) {
+ setFileInfo(info);
+ } else {
+ setError('文件不存在或未准备好');
+ }
+ } catch (err) {
+ setError('获取文件信息失败');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // 直接下载文件
+ const handleDirectDownload = () => {
+ const downloadUrl = getFileUrlByFileId(inputFileId);
+ window.open(downloadUrl, '_blank');
+ };
+
+ // 复制下载链接
+ const handleCopyLink = async () => {
+ const downloadUrl = getFileUrlByFileId(inputFileId);
+ try {
+ await navigator.clipboard.writeText(downloadUrl);
+ alert('下载链接已复制到剪贴板!');
+ } catch (error) {
+ console.error('复制失败:', error);
+ }
+ };
+
+ // 在新窗口预览文件
+ const handlePreview = () => {
+ const downloadUrl = getFileUrlByFileId(inputFileId);
+ window.open(downloadUrl, '_blank');
+ };
+
+ return (
+
+
文件下载
+
+ {/* 文件ID输入 */}
+
+
+
+ setInputFileId(e.target.value)}
+ placeholder="输入文件ID"
+ className="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
+
+
+
+
+ {/* 错误信息 */}
+ {error &&
{error}
}
+
+ {/* 文件信息 */}
+ {fileInfo && (
+
+
文件信息
+
+
+ 文件名: {fileInfo.title || '未知'}
+
+
+ 类型: {fileInfo.type || '未知'}
+
+
+ 状态: {fileInfo.status || '未知'}
+
+ {fileInfo.meta?.size && (
+
+ 大小: {formatFileSize(fileInfo.meta.size)}
+
+ )}
+
+ 创建时间: {new Date(fileInfo.createdAt).toLocaleString()}
+
+
+
+ )}
+
+ {/* 操作按钮 */}
+ {inputFileId && (
+
+
+
+
+
+ )}
+
+ {/* 使用说明 */}
+
+
使用说明:
+
+ - • 输入文件ID后点击"查询"获取文件信息
+ - • "直接下载"会打开新窗口下载文件
+ - • "预览/查看"适用于图片、PDF等可预览的文件
+ - • "复制链接"将下载地址复制到剪贴板
+
+
+
+ );
+}
+
+// 格式化文件大小
+function 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];
+}
diff --git a/apps/web/components/FileUpload.tsx b/apps/web/components/FileUpload.tsx
new file mode 100644
index 0000000..ce8670d
--- /dev/null
+++ b/apps/web/components/FileUpload.tsx
@@ -0,0 +1,218 @@
+'use client';
+import React, { useCallback, useState } from 'react';
+import { useTusUpload } from '../hooks/useTusUpload';
+
+interface UploadedFile {
+ fileId: string;
+ fileName: string;
+ url: string;
+}
+
+export function FileUpload() {
+ const { uploadProgress, isUploading, uploadError, handleFileUpload, getFileUrlByFileId, serverUrl } = useTusUpload();
+
+ const [uploadedFiles, setUploadedFiles] = useState([]);
+ const [dragOver, setDragOver] = useState(false);
+
+ // 处理文件选择
+ const handleFileSelect = useCallback(
+ async (files: FileList | null) => {
+ if (!files || files.length === 0) return;
+
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i];
+
+ try {
+ const result = await handleFileUpload(
+ file,
+ (result) => {
+ console.log('Upload success:', result);
+ setUploadedFiles((prev) => [
+ ...prev,
+ {
+ fileId: result.fileId,
+ fileName: result.fileName,
+ url: result.url,
+ },
+ ]);
+ },
+ (error) => {
+ console.error('Upload error:', error);
+ },
+ );
+ } catch (error) {
+ console.error('Upload failed:', error);
+ }
+ }
+ },
+ [handleFileUpload],
+ );
+
+ // 处理拖拽上传
+ const handleDrop = useCallback(
+ (e: React.DragEvent) => {
+ e.preventDefault();
+ setDragOver(false);
+ handleFileSelect(e.dataTransfer.files);
+ },
+ [handleFileSelect],
+ );
+
+ const handleDragOver = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ setDragOver(true);
+ }, []);
+
+ const handleDragLeave = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ setDragOver(false);
+ }, []);
+
+ // 处理文件输入
+ const handleInputChange = useCallback(
+ (e: React.ChangeEvent) => {
+ handleFileSelect(e.target.files);
+ },
+ [handleFileSelect],
+ );
+
+ // 复制链接到剪贴板
+ const copyToClipboard = useCallback(async (url: string) => {
+ try {
+ await navigator.clipboard.writeText(url);
+ alert('链接已复制到剪贴板!');
+ } catch (error) {
+ console.error('Failed to copy:', error);
+ }
+ }, []);
+
+ return (
+
+
文件上传
+
+ {/* 服务器信息 */}
+
+
+ 服务器地址: {serverUrl}
+
+
+
+ {/* 拖拽上传区域 */}
+
+
+
+
+
拖拽文件到这里,或者
+
+
+
支持多文件上传,TUS 协议支持断点续传
+
+
+
+ {/* 上传进度 */}
+ {isUploading && (
+
+
+ 上传中...
+ {uploadProgress}%
+
+
+
+ )}
+
+ {/* 错误信息 */}
+ {uploadError && (
+
+
+ 上传失败:
+ {uploadError}
+
+
+ )}
+
+ {/* 已上传文件列表 */}
+ {uploadedFiles.length > 0 && (
+
+
已上传文件
+
+ {uploadedFiles.map((file, index) => (
+
+
+
+
+
{file.fileName}
+
文件ID: {file.fileId}
+
+
+
+
+ 查看
+
+
+
+
+ ))}
+
+
+ )}
+
+ {/* 使用说明 */}
+
+
使用说明:
+
+ - • 支持拖拽和点击上传
+ - • 使用 TUS 协议,支持大文件和断点续传
+ - • 上传完成后可以通过链接直接访问文件
+ - • 图片和 PDF 会在浏览器中直接显示
+ - • 其他文件类型会触发下载
+
+
+
+ );
+}
diff --git a/apps/web/components/SimpleUploadExample.tsx b/apps/web/components/SimpleUploadExample.tsx
new file mode 100644
index 0000000..f5eb1d9
--- /dev/null
+++ b/apps/web/components/SimpleUploadExample.tsx
@@ -0,0 +1,75 @@
+import React, { useState } from 'react';
+import { useTusUpload } from '../hooks/useTusUpload';
+
+export function SimpleUploadExample() {
+ const { uploadProgress, isUploading, uploadError, handleFileUpload, getFileUrlByFileId } = useTusUpload();
+ const [uploadedFileUrl, setUploadedFileUrl] = useState('');
+
+ const handleFileChange = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ try {
+ const result = await handleFileUpload(
+ file,
+ (result) => {
+ console.log('上传成功!', result);
+ setUploadedFileUrl(result.url);
+ },
+ (error) => {
+ console.error('上传失败:', error);
+ },
+ );
+ } catch (error) {
+ console.error('上传出错:', error);
+ }
+ };
+
+ return (
+
+
简单上传示例
+
+
+
+
+
+ {isUploading && (
+
+
+ 上传进度
+ {uploadProgress}%
+
+
+
+ )}
+
+ {uploadError && (
+
{uploadError}
+ )}
+
+ {uploadedFileUrl && (
+
+ )}
+
+ );
+}
diff --git a/apps/web/docs/UPLOAD_HOOK_USAGE.md b/apps/web/docs/UPLOAD_HOOK_USAGE.md
new file mode 100644
index 0000000..c3f27c0
--- /dev/null
+++ b/apps/web/docs/UPLOAD_HOOK_USAGE.md
@@ -0,0 +1,287 @@
+# TUS 上传 Hook 使用指南
+
+## 概述
+
+`useTusUpload` 是一个自定义 React Hook,提供了基于 TUS 协议的文件上传功能,支持大文件上传、断点续传、进度跟踪等特性。
+
+## 环境变量配置
+
+确保在 `.env` 文件中配置了以下环境变量:
+
+```env
+NEXT_PUBLIC_SERVER_PORT=3000
+NEXT_PUBLIC_SERVER_IP=http://localhost
+```
+
+**注意**:在 Next.js 中,客户端组件只能访问以 `NEXT_PUBLIC_` 开头的环境变量。
+
+## Hook API
+
+### 返回值
+
+```typescript
+const {
+ uploadProgress, // 上传进度 (0-100)
+ isUploading, // 是否正在上传
+ uploadError, // 上传错误信息
+ handleFileUpload, // 文件上传函数
+ getFileUrlByFileId, // 根据文件ID获取访问链接
+ getFileInfo, // 获取文件详细信息
+ getUploadStatus, // 获取上传状态
+ serverUrl, // 服务器地址
+} = useTusUpload();
+```
+
+### 主要方法
+
+#### `handleFileUpload(file, onSuccess?, onError?)`
+
+上传文件的主要方法。
+
+**参数:**
+
+- `file: File` - 要上传的文件对象
+- `onSuccess?: (result: UploadResult) => void` - 成功回调
+- `onError?: (error: string) => void` - 失败回调
+
+**返回:** `Promise`
+
+**UploadResult 接口:**
+
+```typescript
+interface UploadResult {
+ compressedUrl: string; // 压缩版本URL(当前与原始URL相同)
+ url: string; // 文件访问URL
+ fileId: string; // 文件唯一标识
+ fileName: string; // 文件名
+}
+```
+
+#### `getFileUrlByFileId(fileId: string)`
+
+根据文件ID生成访问链接。
+
+**参数:**
+
+- `fileId: string` - 文件唯一标识
+
+**返回:** `string` - 文件访问URL
+
+## 使用示例
+
+### 基础使用
+
+```tsx
+import React, { useState } from 'react';
+import { useTusUpload } from '../hooks/useTusUpload';
+
+function UploadComponent() {
+ const { uploadProgress, isUploading, uploadError, handleFileUpload } = useTusUpload();
+ const [uploadedUrl, setUploadedUrl] = useState('');
+
+ const handleFileChange = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ try {
+ const result = await handleFileUpload(
+ file,
+ (result) => {
+ console.log('上传成功!', result);
+ setUploadedUrl(result.url);
+ },
+ (error) => {
+ console.error('上传失败:', error);
+ },
+ );
+ } catch (error) {
+ console.error('上传出错:', error);
+ }
+ };
+
+ return (
+
+
+
+ {isUploading && (
+
+
上传进度: {uploadProgress}%
+
+
+ )}
+
+ {uploadError &&
{uploadError}
}
+
+ {uploadedUrl && (
+
+ 查看上传的文件
+
+ )}
+
+ );
+}
+```
+
+### 拖拽上传
+
+```tsx
+import React, { useCallback, useState } from 'react';
+import { useTusUpload } from '../hooks/useTusUpload';
+
+function DragDropUpload() {
+ const { handleFileUpload, isUploading, uploadProgress } = useTusUpload();
+ const [dragOver, setDragOver] = useState(false);
+
+ const handleDrop = useCallback(
+ async (e: React.DragEvent) => {
+ e.preventDefault();
+ setDragOver(false);
+
+ const files = e.dataTransfer.files;
+ if (files.length > 0) {
+ await handleFileUpload(files[0]);
+ }
+ },
+ [handleFileUpload],
+ );
+
+ const handleDragOver = useCallback((e: React.DragEvent) => {
+ e.preventDefault();
+ setDragOver(true);
+ }, []);
+
+ return (
+ setDragOver(false)}
+ style={{
+ border: dragOver ? '2px dashed #0070f3' : '2px dashed #ccc',
+ padding: '20px',
+ textAlign: 'center',
+ backgroundColor: dragOver ? '#f0f8ff' : '#fafafa',
+ }}
+ >
+ {isUploading ?
上传中... {uploadProgress}%
:
拖拽文件到这里上传
}
+
+ );
+}
+```
+
+### 多文件上传
+
+```tsx
+function MultiFileUpload() {
+ const { handleFileUpload } = useTusUpload();
+ const [uploadingFiles, setUploadingFiles] = useState