diff --git a/apps/server/src/trpc/trpc.module.ts b/apps/server/src/trpc/trpc.module.ts index 5ae0760..2a209eb 100755 --- a/apps/server/src/trpc/trpc.module.ts +++ b/apps/server/src/trpc/trpc.module.ts @@ -36,7 +36,7 @@ import { DailyTrainModule } from '@server/models/daily-train/dailyTrain.module'; ResourceModule, TrainContentModule, TrainSituationModule, - DailyTrainModule + DailyTrainModule, ], controllers: [], providers: [TrpcService, TrpcRouter, Logger], diff --git a/apps/server/src/upload/share-code.service.ts b/apps/server/src/upload/share-code.service.ts new file mode 100644 index 0000000..55e9247 --- /dev/null +++ b/apps/server/src/upload/share-code.service.ts @@ -0,0 +1,149 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { customAlphabet } from 'nanoid-cjs'; +import { db } from '@nice/common'; +import { ShareCode, GenerateShareCodeResponse } from './types'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { ResourceService } from '@server/models/resource/resource.service'; + +@Injectable() +export class ShareCodeService { + private readonly logger = new Logger(ShareCodeService.name); + // 生成8位分享码,使用易读的字符 + private readonly generateCode = customAlphabet( + '23456789ABCDEFGHJKLMNPQRSTUVWXYZ', + 8 + ); + + constructor(private readonly resourceService: ResourceService) {} + + async generateShareCode(fileId: string): Promise { + try { + // 检查文件是否存在 + const resource = await this.resourceService.findUnique({ + where: { fileId }, + }); + console.log('完整 fileId:', fileId); // 确保与前端一致 + + if (!resource) { + throw new NotFoundException('文件不存在'); + } + + // 生成分享码 + const code = this.generateCode(); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24小时后过期 + + // 创建分享码记录 + await db.shareCode.create({ + data: { + code, + fileId, + expiresAt, + isUsed: false, + }, + }); + + this.logger.log(`Generated share code ${code} for file ${fileId}`); + + return { + code, + expiresAt, + }; + } catch (error) { + this.logger.error('Failed to generate share code', error); + throw error; + } + } + + async validateAndUseCode(code: string): Promise { + try { + // 查找有效的分享码 + const shareCode = await db.shareCode.findFirst({ + where: { + code, + isUsed: false, + expiresAt: { gt: new Date() }, + }, + }); + + if (!shareCode) { + return null; + } + + // 标记分享码为已使用 + await db.shareCode.update({ + where: { id: shareCode.id }, + data: { isUsed: true }, + }); + + // 记录使用日志 + this.logger.log(`Share code ${code} used for file ${shareCode.fileId}`); + + return shareCode.fileId; + } catch (error) { + this.logger.error('Failed to validate share code', error); + return null; + } + } + + // 每天清理过期的分享码 + @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) + async cleanupExpiredShareCodes() { + try { + const result = await db.shareCode.deleteMany({ + where: { + OR: [ + { expiresAt: { lt: new Date() } }, + { isUsed: true }, + ], + }, + }); + + this.logger.log(`Cleaned up ${result.count} expired share codes`); + } catch (error) { + this.logger.error('Failed to cleanup expired share codes', error); + } + } + + // 获取分享码信息 + async getShareCodeInfo(code: string): Promise { + try { + return await db.shareCode.findFirst({ + where: { code }, + }); + } catch (error) { + this.logger.error('Failed to get share code info', error); + return null; + } + } + + // 检查文件是否已经生成过分享码 + async hasActiveShareCode(fileId: string): Promise { + try { + const activeCode = await db.shareCode.findFirst({ + where: { + fileId, + isUsed: false, + expiresAt: { gt: new Date() }, + }, + }); + + return !!activeCode; + } catch (error) { + this.logger.error('Failed to check active share code', error); + return false; + } + } + + // 获取文件的所有分享记录 + async getFileShareHistory(fileId: string) { + try { + return await db.shareCode.findMany({ + where: { fileId }, + orderBy: { createdAt: 'desc' }, + }); + } catch (error) { + this.logger.error('Failed to get file share history', error); + return []; + } + } +} \ No newline at end of file diff --git a/apps/server/src/upload/tus.service.ts b/apps/server/src/upload/tus.service.ts index 01c9072..f41cc6e 100755 --- a/apps/server/src/upload/tus.service.ts +++ b/apps/server/src/upload/tus.service.ts @@ -131,4 +131,5 @@ export class TusService implements OnModuleInit { // console.log(req) return this.tusServer.handle(req, res); } + } diff --git a/apps/server/src/upload/types.ts b/apps/server/src/upload/types.ts index 2140ebc..9329aff 100755 --- a/apps/server/src/upload/types.ts +++ b/apps/server/src/upload/types.ts @@ -20,6 +20,20 @@ export interface UploadLock { clientId: string; timestamp: number; } + +export interface ShareCode { + code: string; + fileId: string; + createdAt: Date; + expiresAt: Date; + isUsed: boolean; +} + +export interface GenerateShareCodeResponse { + code: string; + expiresAt: Date; +} + // 添加重试机制,处理临时网络问题 // 实现定期清理过期的临时文件 // 添加文件完整性校验 @@ -27,3 +41,10 @@ export interface UploadLock { // 添加并发限制,防止系统资源耗尽 // 实现文件去重功能,避免重复上传 // 添加日志记录和监控机制 +// 添加文件类型限制 +// 添加文件大小限制 +// 添加文件上传时间限制 +// 添加文件上传速度限制 +// 添加文件上传队列管理 +// 添加文件上传断点续传 +// 添加文件上传进度条 diff --git a/apps/server/src/upload/upload.controller.ts b/apps/server/src/upload/upload.controller.ts index f014c42..760692d 100755 --- a/apps/server/src/upload/upload.controller.ts +++ b/apps/server/src/upload/upload.controller.ts @@ -10,13 +10,19 @@ import { Delete, Head, Options, + NotFoundException, + HttpException, + HttpStatus, } from '@nestjs/common'; import { Request, Response } from 'express'; import { TusService } from './tus.service'; - +import { ShareCodeService } from './share-code.service'; @Controller('upload') export class UploadController { - constructor(private readonly tusService: TusService) {} + constructor( + private readonly tusService: TusService, + private readonly shareCodeService: ShareCodeService, + ) {} // @Post() // async handlePost(@Req() req: Request, @Res() res: Response) { // return this.tusService.handleTus(req, res); @@ -51,4 +57,45 @@ export class UploadController { async handleUpload(@Req() req: Request, @Res() res: Response) { return this.tusService.handleTus(req, res); } + @Post('share/:fileId(*)') + async generateShareCode(@Param('fileId') fileId: string) { + try { + console.log('收到生成分享码请求,fileId:', fileId); + const result = await this.shareCodeService.generateShareCode(fileId); + console.log('生成分享码结果:', result); + return result; + } catch (error) { + console.error('生成分享码错误:', error); + throw new HttpException( + { + message: (error as Error).message || '生成分享码失败', + error: 'SHARE_CODE_GENERATION_FAILED' + }, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } + } + + @Get('share/:code') + async validateShareCode(@Param('code') code: string) { + const fileId = await this.shareCodeService.validateAndUseCode(code); + if (!fileId) { + throw new NotFoundException('分享码无效或已过期'); + } + return { fileId }; + } + + @Get('share/info/:code') + async getShareCodeInfo(@Param('code') code: string) { + const info = await this.shareCodeService.getShareCodeInfo(code); + if (!info) { + throw new NotFoundException('分享码不存在'); + } + return info; + } + + @Get('share/history/:fileId') + async getFileShareHistory(@Param('fileId') fileId: string) { + return this.shareCodeService.getFileShareHistory(fileId); + } } diff --git a/apps/server/src/upload/upload.module.ts b/apps/server/src/upload/upload.module.ts index 6c8e1b0..e1a6a56 100755 --- a/apps/server/src/upload/upload.module.ts +++ b/apps/server/src/upload/upload.module.ts @@ -3,7 +3,7 @@ import { UploadController } from './upload.controller'; import { BullModule } from '@nestjs/bullmq'; import { TusService } from './tus.service'; import { ResourceModule } from '@server/models/resource/resource.module'; - +import { ShareCodeService } from './share-code.service'; @Module({ imports: [ BullModule.registerQueue({ @@ -12,6 +12,6 @@ import { ResourceModule } from '@server/models/resource/resource.module'; ResourceModule, ], controllers: [UploadController], - providers: [TusService], + providers: [TusService, ShareCodeService], }) export class UploadModule {} diff --git a/apps/web/src/app/main/admin/deptsettingpage/page.tsx b/apps/web/src/app/main/admin/deptsettingpage/page.tsx index 8ef3ae4..26c1969 100644 --- a/apps/web/src/app/main/admin/deptsettingpage/page.tsx +++ b/apps/web/src/app/main/admin/deptsettingpage/page.tsx @@ -1,7 +1,165 @@ +import { useTusUpload } from "@web/src/hooks/useTusUpload"; +import { ShareCodeGenerator } from "../sharecode/sharecodegenerator"; +import { ShareCodeValidator } from "../sharecode/sharecodevalidator"; +import { useState } from "react"; +import { message, Progress, Button, Tabs } from "antd"; +import { UploadOutlined } from "@ant-design/icons"; + +const { TabPane } = Tabs; + export default function DeptSettingPage() { + const [uploadedFileId, setUploadedFileId] = useState(''); + + // 使用您的 useTusUpload hook + const { uploadProgress, isUploading, uploadError, handleFileUpload } = useTusUpload({ + onSuccess: (fileId: string) => { + setUploadedFileId(fileId); + message.success('文件上传成功'); + }, + onError: (error: Error) => { + message.error('上传失败:' + error.message); + } + }); + + // 处理文件上传 + const handleFileSelect = async (file: File) => { + const fileKey = `file-${Date.now()}`; // 生成唯一的文件标识 + handleFileUpload( + file, + (result) => { + setUploadedFileId(result.fileId); + message.success('文件上传成功'); + }, + (error) => { + message.error('上传失败:' + error.message); + }, + fileKey + ); + }; + + // 处理分享码生成成功 + const handleShareSuccess = (code: string) => { + message.success('分享码生成成功:' + code); + // 可以在这里添加其他逻辑,比如保存到历史记录 + }; + + // 处理分享码验证成功 + const handleValidSuccess = async (fileId: string) => { + try { + // 构建下载URL + const response = await fetch(`/api/upload/download/${fileId}`); + if (!response.ok) { + throw new Error('文件下载失败'); + } + + // 获取文件名 + const contentDisposition = response.headers.get('content-disposition'); + let filename = 'downloaded-file'; + if (contentDisposition) { + const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(contentDisposition); + if (matches != null && matches[1]) { + filename = matches[1].replace(/['"]/g, ''); + } + } + + // 创建下载链接 + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + message.success('文件下载开始'); + } catch (error) { + console.error('下载失败:', error); + message.error('文件下载失败'); + } + }; + return ( -
-

部门设置

+
+

文件分享中心

+ + + + {/* 文件上传区域 */} +
+

第一步:上传文件

+
+ { + const file = e.target.files?.[0]; + if (file) { + handleFileSelect(file); + } + }} + disabled={isUploading} + /> + + + {isUploading && ( +
+ +
+ )} + + {uploadError && ( +
+ {uploadError} +
+ )} +
+
+ + {/* 生成分享码区域 */} + {uploadedFileId && ( +
+

第二步:生成分享码

+ +
+ )} +
+ + {/* 使用分享码区域 */} + +
+

使用分享码下载文件

+ +
+
+
); } \ No newline at end of file diff --git a/apps/web/src/app/main/admin/sharecode/ShareCodeCenerator.module.css b/apps/web/src/app/main/admin/sharecode/ShareCodeCenerator.module.css new file mode 100644 index 0000000..9972735 --- /dev/null +++ b/apps/web/src/app/main/admin/sharecode/ShareCodeCenerator.module.css @@ -0,0 +1,68 @@ +.container { + padding: 20px; + border-radius: 8px; + background-color: #f8f9fa; + } + + .generateButton { + width: 100%; + padding: 12px; + border: none; + border-radius: 6px; + background-color: #1890ff; + color: white; + font-size: 16px; + cursor: pointer; + transition: background-color 0.3s; + } + + .generateButton:hover { + background-color: #40a9ff; + } + + .generateButton:disabled { + background-color: #d9d9d9; + cursor: not-allowed; + } + + .codeDisplay { + text-align: center; + } + + .codeWrapper { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + margin: 16px 0; + } + + .code { + font-size: 24px; + font-weight: bold; + letter-spacing: 2px; + color: #1890ff; + padding: 8px 16px; + background-color: #e6f7ff; + border-radius: 4px; + } + + .copyButton { + border: none; + background: none; + cursor: pointer; + color: #1890ff; + font-size: 18px; + padding: 4px; + } + + .expireInfo { + color: #666; + margin: 8px 0; + } + + .hint { + color: #ff4d4f; + margin: 8px 0; + font-size: 14px; + } \ No newline at end of file diff --git a/apps/web/src/app/main/admin/sharecode/ShareCodeValidator.module.css b/apps/web/src/app/main/admin/sharecode/ShareCodeValidator.module.css new file mode 100644 index 0000000..440a145 --- /dev/null +++ b/apps/web/src/app/main/admin/sharecode/ShareCodeValidator.module.css @@ -0,0 +1,12 @@ +.container { + display: flex; + gap: 12px; + padding: 20px; + border-radius: 8px; + background-color: #f8f9fa; + } + + .input { + font-size: 16px; + text-transform: uppercase; + } \ No newline at end of file diff --git a/apps/web/src/app/main/admin/sharecode/sharecodegenerator.tsx b/apps/web/src/app/main/admin/sharecode/sharecodegenerator.tsx new file mode 100644 index 0000000..fbd819f --- /dev/null +++ b/apps/web/src/app/main/admin/sharecode/sharecodegenerator.tsx @@ -0,0 +1,128 @@ + +import React, { useState } from 'react'; +import { Button, message } from 'antd'; +import { CopyOutlined } from '@ant-design/icons'; + +interface ShareCodeGeneratorProps { + fileId: string; + onSuccess?: (code: string) => void; +} + +export const ShareCodeGenerator: React.FC = ({ + fileId, + onSuccess, +}) => { + const [loading, setLoading] = useState(false); + const [shareCode, setShareCode] = useState(''); + const [expiresAt, setExpiresAt] = useState(null); + + const generateCode = async () => { + setLoading(true); + console.log('开始生成分享码,fileId:', fileId); + + try { + const response = await fetch(`/upload/share/${fileId}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + }) + }); + console.log('Current fileId:', fileId); // 确保 fileId 有效 + console.log('请求URL:', `/upload/share/${fileId}`); + console.log('API响应状态:', response.status); + + const responseText = await response.text(); + console.log('API原始响应:', responseText); + + if (!response.ok) { + throw new Error(`请求失败: ${response.status} ${responseText || '无错误信息'}`); + } + + // 确保响应不为空 + if (!responseText) { + throw new Error('服务器返回空响应'); + } + + // 尝试解析JSON + let data; + try { + data = JSON.parse(responseText); + } catch (e) { + console.error('解析响应JSON失败:', e); + throw new Error('服务器响应格式错误'); + } + + console.log('解析后的响应数据:', data); // 调试日志 + + if (!data.code) { + throw new Error('响应中没有分享码'); + } + + setShareCode(data.code); + setExpiresAt(data.expiresAt ? new Date(data.expiresAt) : null); + onSuccess?.(data.code); + message.success('分享码生成成功'); + } catch (error) { + console.error('生成分享码错误:', error); + message.error('生成分享码失败: ' + (error instanceof Error ? error.message : '未知错误')); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ {/* 添加调试信息 */} + 文件ID: {fileId} +
+ + {!shareCode ? ( + + ) : ( +
+
+ + {shareCode} + +
+ {expiresAt && ( +
+ 有效期至: {expiresAt.toLocaleString()} +
+ )} +
+ )} +
+ ); +}; \ No newline at end of file diff --git a/apps/web/src/app/main/admin/sharecode/sharecodevalidator.tsx b/apps/web/src/app/main/admin/sharecode/sharecodevalidator.tsx new file mode 100644 index 0000000..e197b2e --- /dev/null +++ b/apps/web/src/app/main/admin/sharecode/sharecodevalidator.tsx @@ -0,0 +1,60 @@ +import React, { useState } from 'react'; +import { Input, Button, message } from 'antd'; +import styles from './ShareCodeValidator.module.css'; + +interface ShareCodeValidatorProps { + onValidSuccess: (fileId: string) => void; +} + +export const ShareCodeValidator: React.FC = ({ + onValidSuccess, +}) => { + const [code, setCode] = useState(''); + const [loading, setLoading] = useState(false); + + const validateCode = async () => { + if (!code.trim()) { + message.warning('请输入分享码'); + return; + } + + setLoading(true); + try { + const response = await fetch(`/api/upload/share/${code.trim()}`); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.message || '分享码无效或已过期'); + } + + const data = await response.json(); + onValidSuccess(data.fileId); + message.success('验证成功'); + } catch (error) { + message.error('分享码无效或已过期'); + } finally { + setLoading(false); + } + }; + + return ( +
+ setCode(e.target.value.toUpperCase())} + placeholder="请输入分享码" + maxLength={8} + onPressEnter={validateCode} + /> + +
+ ); +}; \ No newline at end of file diff --git a/apps/web/src/hooks/useTusUpload.ts b/apps/web/src/hooks/useTusUpload.ts index 0337110..5e2e761 100755 --- a/apps/web/src/hooks/useTusUpload.ts +++ b/apps/web/src/hooks/useTusUpload.ts @@ -9,7 +9,7 @@ interface UploadResult { fileId: string; } -export function useTusUpload() { +export function useTusUpload(p0: { onSuccess: (fileId: string) => void; onError: (error: Error) => void; }) { const [uploadProgress, setUploadProgress] = useState< Record >({}); diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 234727e..81a15aa 100755 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -14,8 +14,7 @@ import DailyPage from "../app/main/daily/page"; import Dashboard from "../app/main/home/page"; import WeekPlanPage from "../app/main/plan/weekplan/page"; import StaffInformation from "../app/main/staffinformation/page"; -import SettingPage from "../app/main/admin/deptsettingpage/settingpage"; -import DeptSettingPage from "../app/main/admin/deptsettingpage/settingpage"; +import DeptSettingPage from "../app/main/admin/deptsettingpage/page"; interface CustomIndexRouteObject extends IndexRouteObject { name?: string; breadcrumb?: string; diff --git a/packages/common/prisma/schema.prisma b/packages/common/prisma/schema.prisma index ead43b0..ca9d166 100755 --- a/packages/common/prisma/schema.prisma +++ b/packages/common/prisma/schema.prisma @@ -536,3 +536,16 @@ model TrainPlan { @@map("train_plan") } + +model ShareCode { + id String @id @default(cuid()) + code String @unique + fileId String + createdAt DateTime @default(now()) + expiresAt DateTime + isUsed Boolean @default(false) + + @@index([code]) + @@index([fileId]) + @@index([expiresAt]) +}