rht
This commit is contained in:
parent
c372c28f47
commit
0008f405fe
|
@ -1,5 +1,6 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable ,Logger} from '@nestjs/common';
|
||||
import { BaseService } from '../base/base.service';
|
||||
|
||||
import {
|
||||
UserProfile,
|
||||
db,
|
||||
|
@ -11,6 +12,7 @@ import {
|
|||
|
||||
@Injectable()
|
||||
export class ResourceService extends BaseService<Prisma.ResourceDelegate> {
|
||||
private readonly logger = new Logger(ResourceService.name);
|
||||
constructor() {
|
||||
super(db, ObjectType.RESOURCE);
|
||||
}
|
||||
|
@ -33,4 +35,53 @@ export class ResourceService extends BaseService<Prisma.ResourceDelegate> {
|
|||
},
|
||||
});
|
||||
}
|
||||
// 添加保存文件名的方法
|
||||
async saveFileName(fileId: string, fileName: string): Promise<void> {
|
||||
try {
|
||||
this.logger.log(`尝试保存文件名 "${fileName}" 到文件 ${fileId}`);
|
||||
|
||||
// 首先检查是否已存在 ShareCode 记录
|
||||
const existingShareCode = await db.shareCode.findUnique({
|
||||
where: { fileId },
|
||||
});
|
||||
|
||||
if (existingShareCode) {
|
||||
// 如果记录存在,更新文件名
|
||||
await db.shareCode.update({
|
||||
where: { fileId },
|
||||
data: { fileName },
|
||||
});
|
||||
this.logger.log(`更新了现有记录的文件名 "${fileName}" 到文件 ${fileId}`);
|
||||
} else {
|
||||
// 如果记录不存在,创建新记录
|
||||
await db.shareCode.create({
|
||||
data: {
|
||||
fileId,
|
||||
fileName,
|
||||
code: null, // 这里可以设置为 null 或生成一个临时码
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24小时后过期
|
||||
isUsed: false,
|
||||
},
|
||||
});
|
||||
this.logger.log(`创建了新记录并保存文件名 "${fileName}" 到文件 ${fileId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`保存文件名失败,文件ID: ${fileId}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 添加获取文件名的方法
|
||||
async getFileName(fileId: string): Promise<string | null> {
|
||||
try {
|
||||
const shareCode = await db.shareCode.findUnique({
|
||||
where: { fileId },
|
||||
select: { fileName: true },
|
||||
});
|
||||
return shareCode?.fileName || null;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get filename for ${fileId}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,173 @@
|
|||
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,
|
||||
fileName?: string,
|
||||
): Promise<GenerateShareCodeResponse> {
|
||||
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小时后过期
|
||||
|
||||
// 查找是否已有分享码记录
|
||||
const existingShareCode = await db.shareCode.findUnique({
|
||||
where: { fileId },
|
||||
});
|
||||
|
||||
if (existingShareCode) {
|
||||
// 更新现有记录,但保留原有文件名
|
||||
await db.shareCode.update({
|
||||
where: { fileId },
|
||||
data: {
|
||||
code,
|
||||
expiresAt,
|
||||
isUsed: false,
|
||||
// 只在没有现有文件名且提供了新文件名时才更新文件名
|
||||
...(fileName && !existingShareCode.fileName ? { fileName } : {}),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// 创建新记录
|
||||
await db.shareCode.create({
|
||||
data: {
|
||||
code,
|
||||
fileId,
|
||||
expiresAt,
|
||||
isUsed: false,
|
||||
fileName: fileName || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
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<ShareCode | null> {
|
||||
try {
|
||||
console.log(`尝试验证分享码: ${code}`);
|
||||
|
||||
// 查找有效的分享码
|
||||
const shareCode = await db.shareCode.findFirst({
|
||||
where: {
|
||||
code,
|
||||
isUsed: false,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
});
|
||||
|
||||
console.log('查询结果:', shareCode);
|
||||
|
||||
if (!shareCode) {
|
||||
console.log('分享码无效或已过期');
|
||||
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;
|
||||
} 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<ShareCode | null> {
|
||||
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<boolean> {
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
}
|
|
@ -20,6 +20,22 @@ export interface UploadLock {
|
|||
clientId: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface ShareCode {
|
||||
id: string;
|
||||
code: string;
|
||||
fileId: string;
|
||||
createdAt: Date;
|
||||
expiresAt: Date;
|
||||
isUsed: boolean;
|
||||
fileName?: string | null;
|
||||
}
|
||||
|
||||
export interface GenerateShareCodeResponse {
|
||||
code: string;
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
// 添加重试机制,处理临时网络问题
|
||||
// 实现定期清理过期的临时文件
|
||||
// 添加文件完整性校验
|
||||
|
@ -27,3 +43,10 @@ export interface UploadLock {
|
|||
// 添加并发限制,防止系统资源耗尽
|
||||
// 实现文件去重功能,避免重复上传
|
||||
// 添加日志记录和监控机制
|
||||
// 添加文件类型限制
|
||||
// 添加文件大小限制
|
||||
// 添加文件上传时间限制
|
||||
// 添加文件上传速度限制
|
||||
// 添加文件上传队列管理
|
||||
// 添加文件上传断点续传
|
||||
// 添加文件上传进度条
|
||||
|
|
|
@ -10,13 +10,22 @@ import {
|
|||
Delete,
|
||||
Head,
|
||||
Options,
|
||||
NotFoundException,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Body,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { TusService } from './tus.service';
|
||||
|
||||
import { ShareCodeService } from './share-code.service';
|
||||
import { ResourceService } from '@server/models/resource/resource.service';
|
||||
@Controller('upload')
|
||||
export class UploadController {
|
||||
constructor(private readonly tusService: TusService) {}
|
||||
constructor(
|
||||
private readonly tusService: TusService,
|
||||
private readonly shareCodeService: ShareCodeService,
|
||||
private readonly resourceService: ResourceService,
|
||||
) {}
|
||||
// @Post()
|
||||
// async handlePost(@Req() req: Request, @Res() res: Response) {
|
||||
// return this.tusService.handleTus(req, res);
|
||||
|
@ -36,6 +45,52 @@ export class UploadController {
|
|||
async handlePost(@Req() req: Request, @Res() res: Response) {
|
||||
return this.tusService.handleTus(req, res);
|
||||
}
|
||||
@Get('share/:code')
|
||||
async validateShareCode(@Param('code') code: string) {
|
||||
console.log('收到验证分享码请求,code:', code);
|
||||
|
||||
const shareCode = await this.shareCodeService.validateAndUseCode(code);
|
||||
console.log('验证分享码结果:', shareCode);
|
||||
|
||||
if (!shareCode) {
|
||||
throw new NotFoundException('分享码无效或已过期');
|
||||
}
|
||||
|
||||
// 获取文件信息
|
||||
const resource = await this.resourceService.findUnique({
|
||||
where: { fileId: shareCode.fileId },
|
||||
});
|
||||
console.log('获取到的资源信息:', resource);
|
||||
|
||||
if (!resource) {
|
||||
throw new NotFoundException('文件不存在');
|
||||
}
|
||||
|
||||
// 直接返回正确的数据结构
|
||||
const response = {
|
||||
fileId: shareCode.fileId,
|
||||
fileName:shareCode.fileName || 'downloaded_file',
|
||||
code: shareCode.code,
|
||||
expiresAt: shareCode.expiresAt
|
||||
};
|
||||
|
||||
console.log('返回给前端的数据:', response); // 添加日志
|
||||
return response;
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
@Get('/*')
|
||||
async handleGet(@Req() req: Request, @Res() res: Response) {
|
||||
return this.tusService.handleTus(req, res);
|
||||
|
@ -51,4 +106,80 @@ 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('filename')
|
||||
async saveFileName(@Body() data: { fileId: string; fileName: string }) {
|
||||
try {
|
||||
console.log('收到保存文件名请求:', data);
|
||||
|
||||
// 检查参数
|
||||
if (!data.fileId || !data.fileName) {
|
||||
throw new HttpException(
|
||||
{ message: '缺少必要参数' },
|
||||
HttpStatus.BAD_REQUEST
|
||||
);
|
||||
}
|
||||
// 保存文件名
|
||||
await this.resourceService.saveFileName(data.fileId, data.fileName);
|
||||
console.log('文件名保存成功:', data.fileName, '对应文件ID:', data.fileId);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('保存文件名失败:', error);
|
||||
throw new HttpException(
|
||||
{
|
||||
message: '保存文件名失败',
|
||||
error: (error instanceof Error) ? error.message : String(error)
|
||||
},
|
||||
HttpStatus.INTERNAL_SERVER_ERROR
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('download/:fileId')
|
||||
async downloadFile(@Param('fileId') fileId: string, @Res() res: Response) {
|
||||
try {
|
||||
// 获取文件信息
|
||||
const resource = await this.resourceService.findUnique({
|
||||
where: { fileId },
|
||||
});
|
||||
|
||||
if (!resource) {
|
||||
throw new NotFoundException('文件不存在');
|
||||
}
|
||||
|
||||
// 获取原始文件名
|
||||
const fileName = await this.resourceService.getFileName(fileId) || 'downloaded-file';
|
||||
|
||||
// 设置响应头,包含原始文件名
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${encodeURIComponent(fileName)}"`
|
||||
);
|
||||
|
||||
// 其他下载逻辑...
|
||||
|
||||
} catch (error) {
|
||||
// 错误处理...
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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 {}
|
||||
|
|
|
@ -0,0 +1,357 @@
|
|||
import { useTusUpload } from "@web/src/hooks/useTusUpload";
|
||||
import { ShareCodeGenerator } from "../sharecode/sharecodegenerator";
|
||||
import { ShareCodeValidator } from "../sharecode/sharecodevalidator";
|
||||
import { useState, useRef, useCallback } from "react";
|
||||
import { message, Progress, Button, Tabs, DatePicker } from "antd";
|
||||
import { UploadOutlined, DeleteOutlined, InboxOutlined } from "@ant-design/icons";
|
||||
import { env } from '../../../env'
|
||||
const { TabPane } = Tabs;
|
||||
|
||||
export default function DeptSettingPage() {
|
||||
const [uploadedFileId, setUploadedFileId] = useState<string>('');
|
||||
const [uploadedFileName, setUploadedFileName] = useState<string>('');
|
||||
const [fileNameMap, setFileNameMap] = useState<Record<string, string>>({});
|
||||
const [uploadedFiles, setUploadedFiles] = useState<{ id: string, name: string }[]>([]);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [expireTime, setExpireTime] = useState<Date | null>(null);
|
||||
const dropRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 使用您的 useTusUpload hook
|
||||
const { uploadProgress, isUploading, uploadError, handleFileUpload } = useTusUpload({
|
||||
onSuccess: (result) => {
|
||||
setUploadedFileId(result.fileId);
|
||||
setUploadedFileName(result.fileName);
|
||||
message.success('文件上传成功');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
message.error('上传失败:' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// 清除已上传文件
|
||||
const handleClearFile = () => {
|
||||
setUploadedFileId('');
|
||||
setUploadedFileName('');
|
||||
setUploadedFiles([]);
|
||||
setFileNameMap({});
|
||||
};
|
||||
|
||||
// 处理文件上传
|
||||
const handleFileSelect = async (file: File) => {
|
||||
// 限制:如果已有上传文件,则提示用户
|
||||
if (uploadedFiles.length > 0) {
|
||||
message.warning('只能上传一个文件,请先删除已上传的文件');
|
||||
return;
|
||||
}
|
||||
|
||||
const fileKey = `file-${Date.now()}`; // 生成唯一的文件标识
|
||||
|
||||
handleFileUpload(
|
||||
file,
|
||||
async (result) => {
|
||||
setUploadedFileId(result.fileId);
|
||||
setUploadedFileName(result.fileName);
|
||||
|
||||
// 添加到已上传文件列表
|
||||
setUploadedFiles([{ id: result.fileId, name: file.name }]);
|
||||
|
||||
// 在前端保存文件名映射(用于当前会话)
|
||||
setFileNameMap({
|
||||
[result.fileId]: file.name
|
||||
});
|
||||
|
||||
// 上传成功后保存原始文件名到数据库
|
||||
try {
|
||||
console.log('正在保存文件名到数据库:', result.fileName, '对应文件ID:', result.fileId);
|
||||
|
||||
const response = await fetch(`http://${env.SERVER_IP}:${env.SERVER_PORT}/upload/filename`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
fileId: result.fileId,
|
||||
fileName: file.name
|
||||
}),
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
console.log('保存文件名响应:', response.status, responseText);
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('保存文件名失败:', responseText);
|
||||
message.warning('文件名保存失败,下载时可能无法显示原始文件名');
|
||||
} else {
|
||||
console.log('文件名保存成功:', file.name);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存文件名请求失败:', error);
|
||||
message.warning('文件名保存失败,下载时可能无法显示原始文件名');
|
||||
}
|
||||
|
||||
message.success('文件上传成功');
|
||||
},
|
||||
(error) => {
|
||||
message.error('上传失败:' + error.message);
|
||||
},
|
||||
fileKey
|
||||
);
|
||||
};
|
||||
|
||||
// 处理多个文件上传 - 已移除
|
||||
// const handleFilesUpload = (file: File) => {
|
||||
// handleFileSelect(file);
|
||||
// };
|
||||
|
||||
// 处理文件删除
|
||||
const handleDeleteFile = async (fileId: string) => {
|
||||
try {
|
||||
// 可以添加删除文件的API调用
|
||||
// const response = await fetch(`http://${env.SERVER_IP}:${env.SERVER_PORT}/upload/delete/${fileId}`, {
|
||||
// method: 'DELETE'
|
||||
// });
|
||||
|
||||
// if (!response.ok) {
|
||||
// throw new Error('删除文件失败');
|
||||
// }
|
||||
|
||||
// 无论服务器删除是否成功,前端都需要更新状态
|
||||
setUploadedFiles([]);
|
||||
setUploadedFileId('');
|
||||
setUploadedFileName('');
|
||||
setFileNameMap({});
|
||||
|
||||
message.success('文件已删除');
|
||||
} catch (error) {
|
||||
console.error('删除文件错误:', error);
|
||||
message.error('删除文件失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 拖拽相关处理函数
|
||||
const handleDragEnter = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
handleFileSelect(e.dataTransfer.files[0]);
|
||||
}, []);
|
||||
|
||||
|
||||
// 处理分享码生成成功
|
||||
const handleShareSuccess = (code: string) => {
|
||||
message.success('分享码生成成功:' + code);
|
||||
// 可以在这里添加其他逻辑,比如保存到历史记录
|
||||
};
|
||||
|
||||
// 处理分享码验证成功
|
||||
const handleValidSuccess = async (fileId: string, fileName: string) => {
|
||||
try {
|
||||
// 构建下载URL(包含文件名参数)
|
||||
const downloadUrl = `/upload/download/${fileId}?fileName=${encodeURIComponent(fileName)}`;
|
||||
const response = await fetch(downloadUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error('文件下载失败');
|
||||
}
|
||||
|
||||
// 创建下载链接
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
|
||||
// 直接使用传入的 fileName
|
||||
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 (
|
||||
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '20px' }}>
|
||||
<span className="text-2xl py-4">文件分享中心</span>
|
||||
<Tabs defaultActiveKey="upload">
|
||||
<TabPane tab="上传分享" key="upload">
|
||||
{/* 文件上传区域 */}
|
||||
<div style={{ marginBottom: '40px' }}>
|
||||
<h3>第一步:上传文件</h3>
|
||||
|
||||
{/* 如果没有已上传文件,显示上传区域 */}
|
||||
{uploadedFiles.length === 0 ? (
|
||||
<div
|
||||
ref={dropRef}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
style={{
|
||||
padding: '20px',
|
||||
border: `2px dashed ${isDragging ? '#1890ff' : '#d9d9d9'}`,
|
||||
borderRadius: '8px',
|
||||
textAlign: 'center',
|
||||
backgroundColor: isDragging ? 'rgba(24, 144, 255, 0.05)' : 'transparent',
|
||||
transition: 'all 0.3s',
|
||||
marginBottom: '20px'
|
||||
}}
|
||||
>
|
||||
<InboxOutlined style={{ fontSize: '48px', color: isDragging ? '#1890ff' : '#d9d9d9' }} />
|
||||
<p>点击或拖拽文件到此区域进行上传</p>
|
||||
<p style={{ fontSize: '12px', color: '#888' }}>只能上传单个文件</p>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
id="file-input"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
handleFileSelect(file);
|
||||
}
|
||||
}}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
<label
|
||||
htmlFor="file-input"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#1890ff',
|
||||
color: 'white',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
marginTop: '10px'
|
||||
}}
|
||||
>
|
||||
<UploadOutlined /> 选择文件
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<div style={{
|
||||
padding: '10px',
|
||||
backgroundColor: '#f6ffed',
|
||||
border: '1px solid #b7eb8f',
|
||||
borderRadius: '4px',
|
||||
marginBottom: '10px'
|
||||
}}>
|
||||
<p style={{ color: '#52c41a', margin: 0 }}>
|
||||
您已上传文件,请继续下一步生成分享码
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 已上传文件列表 */}
|
||||
{uploadedFiles.length > 0 && (
|
||||
<div style={{
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden'
|
||||
}}>
|
||||
{uploadedFiles.map((file) => (
|
||||
<div key={file.id} style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '10px 15px',
|
||||
backgroundColor: '#fafafa'
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flex: 1
|
||||
}}>
|
||||
<div style={{
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#52c41a',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: '10px'
|
||||
}}>
|
||||
<span style={{ color: 'white', fontSize: '12px' }}>✓</span>
|
||||
</div>
|
||||
<span>{file.name}</span>
|
||||
</div>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteOutlined style={{ color: '#ff4d4f' }} />}
|
||||
onClick={() => handleDeleteFile(file.id)}
|
||||
title="删除此文件"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUploading && (
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<Progress
|
||||
percent={Math.round(Object.values(uploadProgress)[0] || 0)}
|
||||
status="active"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadError && (
|
||||
<div style={{ color: '#ff4d4f', marginTop: '10px' }}>
|
||||
{uploadError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 生成分享码区域 */}
|
||||
{uploadedFileId && (
|
||||
<div style={{ marginBottom: '40px' }}>
|
||||
<h3>第二步:生成分享码</h3>
|
||||
|
||||
<ShareCodeGenerator
|
||||
fileId={uploadedFileId}
|
||||
onSuccess={handleShareSuccess}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</TabPane>
|
||||
|
||||
{/* 使用分享码区域 */}
|
||||
<TabPane tab="下载文件" key="download">
|
||||
<div>
|
||||
<h3>使用分享码下载文件</h3>
|
||||
<ShareCodeValidator
|
||||
onValidSuccess={handleValidSuccess}
|
||||
/>
|
||||
</div>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
.container {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.input {
|
||||
font-size: 16px;
|
||||
text-transform: uppercase;
|
||||
}
|
|
@ -0,0 +1,135 @@
|
|||
|
||||
import React, { useState } from 'react';
|
||||
import { Button, message } from 'antd';
|
||||
import { CopyOutlined } from '@ant-design/icons';
|
||||
import {env} from '../../../env'
|
||||
interface ShareCodeGeneratorProps {
|
||||
fileId: string;
|
||||
onSuccess?: (code: string) => void;
|
||||
fileName?: string;
|
||||
}
|
||||
|
||||
export const ShareCodeGenerator: React.FC<ShareCodeGeneratorProps> = ({
|
||||
fileId,
|
||||
onSuccess,
|
||||
fileName,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [shareCode, setShareCode] = useState<string>('');
|
||||
const [expiresAt, setExpiresAt] = useState<Date | null>(null);
|
||||
|
||||
const generateCode = async () => {
|
||||
setLoading(true);
|
||||
console.log('开始生成分享码,fileId:', fileId, 'fileName:', fileName);
|
||||
|
||||
try {
|
||||
const response = await fetch(`http://${env.SERVER_IP}:${env.SERVER_PORT}/upload/share/${fileId}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
fileId
|
||||
})
|
||||
});
|
||||
console.log('Current fileId:', fileId); // 确保 fileId 有效
|
||||
console.log('请求URL:', `http://${env.SERVER_IP}:${env.SERVER_PORT}/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 (
|
||||
<div style={{ padding: '20px', backgroundColor: '#f8f9fa', borderRadius: '8px' }}>
|
||||
<div style={{ marginBottom: '10px' }}>
|
||||
{/* 添加调试信息 */}
|
||||
<small style={{ color: '#666' }}>文件ID: {fileId}</small>
|
||||
</div>
|
||||
|
||||
{!shareCode ? (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={generateCode}
|
||||
loading={loading}
|
||||
block
|
||||
>
|
||||
生成分享码
|
||||
</Button>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '12px',
|
||||
margin: '16px 0'
|
||||
}}>
|
||||
<span style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
letterSpacing: '2px',
|
||||
color: '#1890ff',
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#e6f7ff',
|
||||
borderRadius: '4px'
|
||||
}}>
|
||||
{shareCode}
|
||||
</span>
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(shareCode);
|
||||
message.success('分享码已复制');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{expiresAt ? (
|
||||
<div style={{ color: '#666' }}>
|
||||
有效期至: {expiresAt.toLocaleString()}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ color: 'red' }}>
|
||||
未获取到有效期信息
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,69 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Input, Button, message } from 'antd';
|
||||
import styles from './ShareCodeValidator.module.css';
|
||||
import {env} from '../../../env'
|
||||
interface ShareCodeValidatorProps {
|
||||
onValidSuccess: (fileId: string, fileName: string) => void;
|
||||
}
|
||||
|
||||
export const ShareCodeValidator: React.FC<ShareCodeValidatorProps> = ({
|
||||
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(`http://${env.SERVER_IP}:${env.SERVER_PORT}/upload/share/${code.trim()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || '分享码无效或已过期');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('验证分享码返回数据:', data);
|
||||
|
||||
if (!data.fileId) {
|
||||
throw new Error('未找到文件ID');
|
||||
}
|
||||
|
||||
const fileName = data.fileName || 'downloaded_file';
|
||||
|
||||
onValidSuccess(data.fileId, fileName);
|
||||
message.success(`验证成功,文件名:${fileName}`);
|
||||
} catch (error) {
|
||||
console.error('验证分享码失败:', error);
|
||||
message.error('分享码无效或已过期');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Input
|
||||
className={styles.input}
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase())}
|
||||
placeholder="请输入分享码"
|
||||
maxLength={8}
|
||||
onPressEnter={validateCode}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={validateCode}
|
||||
loading={loading}
|
||||
disabled={!code.trim()}
|
||||
>
|
||||
验证并下载
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -7,9 +7,10 @@ interface UploadResult {
|
|||
compressedUrl: string;
|
||||
url: string;
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export function useTusUpload() {
|
||||
export function useTusUpload(p0?: { onSuccess: (result: UploadResult) => void; onError: (error: Error) => void; }) {
|
||||
const [uploadProgress, setUploadProgress] = useState<
|
||||
Record<string, number>
|
||||
>({});
|
||||
|
@ -74,36 +75,31 @@ export function useTusUpload() {
|
|||
}));
|
||||
},
|
||||
onSuccess: async (payload) => {
|
||||
try {
|
||||
if (upload.url) {
|
||||
const fileId = getFileId(upload.url);
|
||||
const url = getResourceUrl(upload.url);
|
||||
setIsUploading(false);
|
||||
setUploadProgress((prev) => ({
|
||||
...prev,
|
||||
[fileKey]: 100,
|
||||
}));
|
||||
onSuccess({
|
||||
compressedUrl: getCompressedImageUrl(url),
|
||||
url,
|
||||
fileId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const err =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error("Unknown error");
|
||||
if (upload.url) {
|
||||
const fileId = getFileId(upload.url);
|
||||
const url = getResourceUrl(upload.url);
|
||||
setIsUploading(false);
|
||||
setUploadError(err.message);
|
||||
onError(err);
|
||||
setUploadProgress((prev) => ({
|
||||
...prev,
|
||||
[fileKey]: 100,
|
||||
}));
|
||||
onSuccess({
|
||||
compressedUrl: getCompressedImageUrl(url),
|
||||
url,
|
||||
fileId,
|
||||
fileName: uploadFile.name,
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
const err =
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error("Unknown error");
|
||||
setIsUploading(false);
|
||||
setUploadError(error.message);
|
||||
console.log(error);
|
||||
onError(error);
|
||||
onError(err);
|
||||
},
|
||||
});
|
||||
upload.start();
|
||||
|
|
|
@ -6,25 +6,7 @@ import {
|
|||
useParams,
|
||||
} from "react-router-dom";
|
||||
import ErrorPage from "../app/error";
|
||||
import WithAuth from "../components/utils/with-auth";
|
||||
import LoginPage from "../app/login";
|
||||
import HomePage from "../app/main/home/page";
|
||||
import { CourseDetailPage } from "../app/main/course/detail/page";
|
||||
import { CourseBasicForm } from "../components/models/course/editor/form/CourseBasicForm";
|
||||
import CourseContentForm from "../components/models/course/editor/form/CourseContentForm/CourseContentForm";
|
||||
import CourseEditorLayout from "../components/models/course/editor/layout/CourseEditorLayout";
|
||||
import { MainLayout } from "../app/main/layout/MainLayout";
|
||||
import CoursesPage from "../app/main/courses/page";
|
||||
import PathPage from "../app/main/path/page";
|
||||
import { adminRoute } from "./admin-route";
|
||||
import PathEditorPage from "../app/main/path/editor/page";
|
||||
|
||||
import { CoursePreview } from "../app/main/course/preview/page";
|
||||
import MyLearningPage from "../app/main/my-learning/page";
|
||||
import MyDutyPage from "../app/main/my-duty/page";
|
||||
import MyPathPage from "../app/main/my-path/page";
|
||||
import SearchPage from "../app/main/search/page";
|
||||
import MyDutyPathPage from "../app/main/my-duty-path/page";
|
||||
import DeptSettingPage from "../app/admin/deptsettingpage/page";
|
||||
interface CustomIndexRouteObject extends IndexRouteObject {
|
||||
name?: string;
|
||||
breadcrumb?: string;
|
||||
|
@ -47,123 +29,10 @@ export type CustomRouteObject =
|
|||
| CustomNonIndexRouteObject;
|
||||
export const routes: CustomRouteObject[] = [
|
||||
{
|
||||
path: "/",
|
||||
path:'/',
|
||||
element:<DeptSettingPage></DeptSettingPage>,
|
||||
errorElement: <ErrorPage />,
|
||||
handle: {
|
||||
crumb() {
|
||||
return <Link to={"/"}>主页</Link>;
|
||||
},
|
||||
},
|
||||
children: [
|
||||
{
|
||||
element: <MainLayout></MainLayout>,
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <HomePage />,
|
||||
},
|
||||
{
|
||||
path: "path",
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <PathPage></PathPage>,
|
||||
},
|
||||
{
|
||||
path: "editor/:id?",
|
||||
element: (
|
||||
<PathEditorPage></PathEditorPage>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "courses",
|
||||
element: <CoursesPage></CoursesPage>,
|
||||
},
|
||||
{
|
||||
path: "my-path",
|
||||
element: (
|
||||
<WithAuth>
|
||||
<MyPathPage></MyPathPage>
|
||||
</WithAuth>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "my-duty-path",
|
||||
element: (
|
||||
<WithAuth>
|
||||
<MyDutyPathPage></MyDutyPathPage>
|
||||
</WithAuth>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "my-duty",
|
||||
element: (
|
||||
<WithAuth>
|
||||
<MyDutyPage></MyDutyPage>
|
||||
</WithAuth>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "my-learning",
|
||||
element: (
|
||||
<WithAuth>
|
||||
<MyLearningPage></MyLearningPage>
|
||||
</WithAuth>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "search",
|
||||
element: <SearchPage></SearchPage>,
|
||||
},
|
||||
{
|
||||
path: "course/:id?/detail/:lectureId?", // 使用 ? 表示 id 参数是可选的
|
||||
element: <CourseDetailPage />,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
path: "course",
|
||||
children: [
|
||||
{
|
||||
path: ":id?/editor",
|
||||
element: (
|
||||
<WithAuth>
|
||||
<CourseEditorLayout></CourseEditorLayout>
|
||||
</WithAuth>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: (
|
||||
<WithAuth>
|
||||
<CourseBasicForm></CourseBasicForm>
|
||||
</WithAuth>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
path: "content",
|
||||
element: (
|
||||
<WithAuth>
|
||||
<CourseContentForm></CourseContentForm>
|
||||
</WithAuth>
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
adminRoute,
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
breadcrumb: "登录",
|
||||
element: <LoginPage></LoginPage>,
|
||||
},
|
||||
}
|
||||
];
|
||||
|
||||
export const router = createBrowserRouter(routes);
|
||||
|
|
|
@ -2,7 +2,7 @@ server {
|
|||
# 监听80端口
|
||||
listen 80;
|
||||
# 服务器域名/IP地址,使用环境变量
|
||||
server_name host.docker.internal;
|
||||
server_name 192.168.43.206;
|
||||
|
||||
# 基础性能优化配置
|
||||
# 启用tcp_nopush以优化数据发送
|
||||
|
@ -100,7 +100,7 @@ server {
|
|||
# 仅供内部使用
|
||||
internal;
|
||||
# 代理到认证服务
|
||||
proxy_pass http://host.docker.internal:3006/auth/file;
|
||||
proxy_pass http://192.168.43.206:3006/auth/file;
|
||||
|
||||
# 请求优化:不传递请求体
|
||||
proxy_pass_request_body off;
|
||||
|
|
|
@ -418,3 +418,16 @@ model Person {
|
|||
gender Boolean
|
||||
animals Animal[]
|
||||
}
|
||||
model ShareCode {
|
||||
id String @id @default(cuid())
|
||||
code String? @unique
|
||||
fileId String? @unique
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime? @map("expires_at")
|
||||
isUsed Boolean? @default(false)
|
||||
fileName String? @map("file_name")
|
||||
|
||||
@@index([code])
|
||||
@@index([fileId])
|
||||
@@index([expiresAt])
|
||||
}
|
Loading…
Reference in New Issue