From 9f1bc38acde0af4bbb13ceef8f883f9b1b0f8d32 Mon Sep 17 00:00:00 2001 From: Rao <1227431568@qq.com> Date: Thu, 10 Apr 2025 11:10:33 +0800 Subject: [PATCH] rht --- .../models/share-code/share-code.router.ts | 48 +-- .../models/share-code/share-code.service.ts | 362 ++++++++++++++---- .../admin/code-manage/CodeManageContext.tsx | 6 +- .../admin/code-manage/CodeManageLayout.tsx | 18 +- .../components/CodeManageSearchBase.tsx | 22 +- .../components/CodeMangeDisplay.tsx | 2 +- .../components/ShareCodeListCard.tsx | 22 +- .../app/admin/dashboard/DashboardContext.tsx | 72 ++++ .../app/admin/dashboard/DashboardLayout.tsx | 13 + .../admin/dashboard/components/Activate.tsx | 37 ++ .../dashboard/components/ActivityItem.tsx | 26 ++ .../app/admin/dashboard/components/Board.tsx | 138 +++++++ .../admin/quick-file/components/header.tsx | 41 ++ apps/web/src/app/admin/quick-file/manage.tsx | 66 ++++ apps/web/src/app/admin/quick-file/page.tsx | 50 +-- .../app/admin/quick-file/quickFileContext.tsx | 7 +- .../admin/sharecode/components/CodeRecord.tsx | 2 +- .../admin/sharecode/sharecodegenerator.tsx | 2 +- apps/web/src/routes/index.tsx | 34 ++ packages/common/prisma/schema.prisma | 48 ++- 20 files changed, 841 insertions(+), 175 deletions(-) create mode 100644 apps/web/src/app/admin/dashboard/DashboardContext.tsx create mode 100644 apps/web/src/app/admin/dashboard/DashboardLayout.tsx create mode 100644 apps/web/src/app/admin/dashboard/components/Activate.tsx create mode 100644 apps/web/src/app/admin/dashboard/components/ActivityItem.tsx create mode 100644 apps/web/src/app/admin/dashboard/components/Board.tsx create mode 100644 apps/web/src/app/admin/quick-file/components/header.tsx create mode 100644 apps/web/src/app/admin/quick-file/manage.tsx diff --git a/apps/server/src/models/share-code/share-code.router.ts b/apps/server/src/models/share-code/share-code.router.ts index 6feb18b..ac82db8 100644 --- a/apps/server/src/models/share-code/share-code.router.ts +++ b/apps/server/src/models/share-code/share-code.router.ts @@ -4,6 +4,7 @@ import { TrpcService } from "@server/trpc/trpc.service"; import { Injectable } from "@nestjs/common"; import { Prisma } from "@nice/common"; const ShareCodeWhereInputSchema: ZodType = z.any(); +const ShareCodeFindManyArgsSchema: ZodType = z.any(); @Injectable() export class ShareCodeRouter { constructor( @@ -12,31 +13,6 @@ export class ShareCodeRouter { ) { } router = this.trpc.router({ - generateShareCode: this.trpc.procedure - .input(z.object({ fileId: z.string(), expiresAt: z.date(), canUseTimes: z.number() })) - .mutation(async ({ input }) => { - return this.shareCodeService.generateShareCode(input.fileId, input.expiresAt, input.canUseTimes); - }), - validateAndUseCode: this.trpc.procedure - .input(z.object({ code: z.string() })) - .mutation(async ({ input }) => { - return this.shareCodeService.validateAndUseCode(input.code); - }), - getShareCodeInfo: this.trpc.procedure - .input(z.object({ code: z.string() })) - .query(async ({ input }) => { - return this.shareCodeService.getShareCodeInfo(input.code); - }), - hasActiveShareCode: this.trpc.procedure - .input(z.object({ fileId: z.string() })) - .query(async ({ input }) => { - return this.shareCodeService.hasActiveShareCode(input.fileId); - }), - getFileShareHistory: this.trpc.procedure - .input(z.object({ fileId: z.string() })) - .query(async ({ input }) => { - return this.shareCodeService.getFileShareHistory(input.fileId); - }), getFileByShareCode: this.trpc.procedure .input(z.object({ code: z.string() })) .query(async ({ input }) => { @@ -44,8 +20,8 @@ export class ShareCodeRouter { }), generateShareCodeByFileId: this.trpc.procedure .input(z.object({ fileId: z.string(), expiresAt: z.date(), canUseTimes: z.number() })) - .mutation(async ({ input }) => { - return this.shareCodeService.generateShareCodeByFileId(input.fileId, input.expiresAt, input.canUseTimes); + .mutation(async ({ input, ctx }) => { + return this.shareCodeService.generateShareCodeByFileId(input.fileId, input.expiresAt, input.canUseTimes, ctx.ip); }), getShareCodesWithResources: this.trpc.procedure .input(z.object({ page: z.number(), pageSize: z.number(), where: ShareCodeWhereInputSchema.optional() })) @@ -68,5 +44,23 @@ export class ShareCodeRouter { .mutation(async ({ input }) => { return this.shareCodeService.updateShareCode(input.id, input.data); }), + getShareCodeResourcesTotalSize: this.trpc.procedure + .query(async () => { + return this.shareCodeService.getShareCodeResourcesTotalSize(); + }), + getShareCodeResourcesSizeByDateRange: this.trpc.procedure + .input(z.object({ dateType: z.enum(['today', 'yesterday']) })) + .query(async ({ input }) => { + return this.shareCodeService.getShareCodeResourcesSizeByDateRange(input.dateType); + }), + countDistinctUploadIPs: this.trpc.procedure + .query(async () => { + return this.shareCodeService.countDistinctUploadIPs(); + }), + findShareCodes: this.trpc.procedure + .input(ShareCodeFindManyArgsSchema) + .query(async ({ input }) => { + return this.shareCodeService.findShareCodes(input); + }), }); } \ No newline at end of file diff --git a/apps/server/src/models/share-code/share-code.service.ts b/apps/server/src/models/share-code/share-code.service.ts index 810c82a..b4658f2 100755 --- a/apps/server/src/models/share-code/share-code.service.ts +++ b/apps/server/src/models/share-code/share-code.service.ts @@ -9,7 +9,6 @@ import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc'; import timezone from 'dayjs/plugin/timezone'; import { BaseService } from '../base/base.service'; -import { url } from 'inspector'; dayjs.extend(utc); dayjs.extend(timezone); export interface ShareCode { @@ -23,6 +22,7 @@ export interface ShareCode { canUseTimes: number | null; } export interface GenerateShareCodeResponse { + id?: string; code: string; expiresAt: Date; canUseTimes: number; @@ -33,6 +33,8 @@ export interface GenerateShareCodeResponse { url: string; meta: ResourceMeta } + uploadIp?: string; + createdAt?: Date; } interface ResourceMeta { @@ -41,6 +43,25 @@ interface ResourceMeta { size: string; } +const ShareCodeSelect = { + id: true, + code: true, + fileId: true, + expiresAt: true, + fileName: true, + canUseTimes: true, + resource: { + select: { + id: true, + type: true, + url: true, + meta: true, + } + }, + uploadIp: true, + createdAt: true, +} + @Injectable() export class ShareCodeService extends BaseService { private readonly logger = new Logger(ShareCodeService.name); @@ -53,12 +74,26 @@ export class ShareCodeService extends BaseService { constructor(private readonly resourceService: ResourceService) { super(db, ObjectType.SHARE_CODE, false); } + // 根据文件ID生成分享码 + async generateShareCodeByFileId(fileId: string, expiresAt: Date, canUseTimes: number, ip: string) { + try { + this.logger.log('收到生成分享码请求,fileId:', fileId); + this.logger.log('客户端IP:', ip); + const result = await this.generateShareCode(fileId, expiresAt, canUseTimes, undefined, ip); + this.logger.log('生成分享码结果:', result); + return result; + } catch (error) { + this.logger.error('生成分享码错误:', error); + return error + } + } async generateShareCode( fileId: string, expiresAt: Date, canUseTimes: number, fileName?: string, + uploadIp?: string, ): Promise { try { // 检查文件是否存在 @@ -87,6 +122,7 @@ export class ShareCodeService extends BaseService { canUseTimes, isUsed: false, fileName: filename || "downloaded_file", + uploadIp, }, }); } else { @@ -99,6 +135,7 @@ export class ShareCodeService extends BaseService { canUseTimes, isUsed: false, fileName: filename || "downloaded_file", + uploadIp, }, }); } @@ -130,7 +167,7 @@ export class ShareCodeService extends BaseService { this.logger.log(`尝试验证分享码: ${code}`); // 查找有效的分享码 - const shareCode = await db.shareCode.findFirst({ + const shareCode = await super.findFirst({ where: { code, isUsed: false, @@ -142,7 +179,7 @@ export class ShareCodeService extends BaseService { return null; } //更新已使用次数 - await db.shareCode.update({ + await super.update({ where: { id: shareCode.id }, data: { canUseTimes: shareCode.canUseTimes - 1 }, }); @@ -167,7 +204,7 @@ export class ShareCodeService extends BaseService { @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) async cleanupExpiredShareCodes() { try { - const shareCodes = await db.shareCode.findMany({ + const shareCodes = await super.findMany({ where: { OR: [ { expiresAt: { lt: dayjs().tz('Asia/Shanghai').toDate() } }, @@ -180,7 +217,7 @@ export class ShareCodeService extends BaseService { shareCodes.forEach(code => { this.cleanupUploadFolder(code.fileId); }) - const result = await db.shareCode.deleteMany({ + const result = await super.deleteMany({ where: { fileId: { in: shareCodes.map(code => code.fileId) @@ -235,48 +272,6 @@ export class ShareCodeService extends BaseService { this.logger.log(`Deleted folder: ${dirPath}`); } } - // 获取分享码信息 - 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: dayjs().tz('Asia/Shanghai').toDate() }, - }, - }); - - 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 []; - } - } // 根据分享码获取文件 async getFileByShareCode(code: string) { @@ -293,7 +288,7 @@ export class ShareCodeService extends BaseService { where: { fileId: shareCode.fileId }, }); this.logger.log('获取到的资源信息:', resource); - const { filename,filetype,size } = resource.meta as any as ResourceMeta + const { filename, filetype, size } = resource.meta as any as ResourceMeta const fileUrl = resource?.url if (!resource) { throw new NotFoundException('文件不存在'); @@ -301,17 +296,17 @@ export class ShareCodeService extends BaseService { // 直接返回正确的数据结构 const response = { - id:shareCode.id, + id: shareCode.id, code: shareCode.code, fileName: filename || 'downloaded_file', expiresAt: shareCode.expiresAt, canUseTimes: shareCode.canUseTimes - 1, - resource:{ - id:resource.id, - type:resource.type, - url:resource.url, - meta:{ - filename,filetype,size + resource: { + id: resource.id, + type: resource.type, + url: resource.url, + meta: { + filename, filetype, size } } }; @@ -320,18 +315,6 @@ export class ShareCodeService extends BaseService { return response; } - // 根据文件ID生成分享码 - async generateShareCodeByFileId(fileId: string, expiresAt: Date, canUseTimes: number) { - try { - this.logger.log('收到生成分享码请求,fileId:', fileId); - const result = await this.generateShareCode(fileId, expiresAt, canUseTimes); - this.logger.log('生成分享码结果:', result); - return result; - } catch (error) { - this.logger.error('生成分享码错误:', error); - return error - } - } async getShareCodesWithResources(args: { page?: number; @@ -345,24 +328,8 @@ export class ShareCodeService extends BaseService { // 使用include直接关联查询Resource const { items, totalPages } = await super.findManyWithPagination({ ...args, - select: { - id: true, - code: true, - fileId: true, - expiresAt: true, - fileName: true, - canUseTimes: true, - resource: { - select: { - id: true, - type: true, - url: true, - meta: true, - } - } - } + select: ShareCodeSelect }); - this.logger.log('search result:', items); return { items, totalPages @@ -394,4 +361,231 @@ export class ShareCodeService extends BaseService { throw error; } } + + /** + * 获取所有分享码关联资源的总大小 + * @returns 返回所有资源的总大小和资源数量 + */ + async getShareCodeResourcesTotalSize(): Promise<{ totalSize: number; resourceCount: number }> { + try { + this.logger.log('获取所有分享码关联资源的总大小'); + + // 查询所有有效的分享码及其关联的资源 + const shareCodes = await super.findMany({ + where: { + deletedAt: null + }, + select: { ...ShareCodeSelect } + }); + + // 计算总大小和资源数量 + let totalSize = 0; + let resourceCount = 0; + + shareCodes.forEach(shareCode => { + + if ((shareCode as any as GenerateShareCodeResponse).resource && (shareCode as any as GenerateShareCodeResponse).resource.meta) { + const meta = (shareCode as any as GenerateShareCodeResponse).resource.meta as any; + if (meta.size) { + // 如果size是字符串格式(如 "1024"或"1 MB"),需要转换 + let sizeValue: number; + if (typeof meta.size === 'string') { + // 尝试直接解析数字 + sizeValue = parseInt(meta.size, 10); + // 如果解析失败,可能需要更复杂的处理 + if (isNaN(sizeValue)) { + // 简单处理,实际应用中可能需要更复杂的单位转换 + this.logger.warn(`无法解析资源大小: ${meta.size}`); + sizeValue = 0; + } + } else if (typeof meta.size === 'number') { + sizeValue = meta.size; + } else { + sizeValue = 0; + } + + totalSize += sizeValue; + resourceCount++; + } + } + }); + + this.logger.log(`资源总大小: ${totalSize}, 资源数量: ${resourceCount}`); + return { totalSize, resourceCount }; + } catch (error) { + this.logger.error('获取分享码资源总大小失败', error); + throw error; + } + } + /** + * 根据日期范围获取分享码关联资源的大小 + * @param dateType 日期类型: 'today' 或 'yesterday' + * @returns 返回特定日期范围内的资源总大小和资源数量 + */ + async getShareCodeResourcesSizeByDateRange(dateType: 'today' | 'yesterday'): Promise<{ totalSize: number; resourceCount: number }> { + try { + let startDate: Date; + let endDate: Date; + const now = dayjs().tz('Asia/Shanghai'); + + if (dateType === 'today') { + startDate = now.startOf('day').toDate(); + endDate = now.endOf('day').toDate(); + this.logger.log(`获取今天创建的分享码资源大小, 日期范围: ${startDate} 到 ${endDate}`); + } else { + startDate = now.subtract(1, 'day').startOf('day').toDate(); + endDate = now.subtract(1, 'day').endOf('day').toDate(); + this.logger.log(`获取昨天创建的分享码资源大小, 日期范围: ${startDate} 到 ${endDate}`); + } + + // 查询特定日期范围内创建的分享码及其关联的资源 + const shareCodes = await super.findMany({ + where: { + createdAt: { + gte: startDate, + lte: endDate + }, + deletedAt: null + }, + select: { + ...ShareCodeSelect + } + }); + + // 计算总大小和资源数量 + let totalSize = 0; + let resourceCount = 0; + + shareCodes.forEach(shareCode => { + if ((shareCode as any as GenerateShareCodeResponse).resource && (shareCode as any as GenerateShareCodeResponse).resource.meta) { + const meta = (shareCode as any as GenerateShareCodeResponse).resource.meta as any; + if (meta.size) { + // 如果size是字符串格式(如 "1024"或"1 MB"),需要转换 + let sizeValue: number; + if (typeof meta.size === 'string') { + // 尝试直接解析数字 + sizeValue = parseInt(meta.size, 10); + // 如果解析失败,可能需要更复杂的处理 + if (isNaN(sizeValue)) { + // 简单处理,实际应用中可能需要更复杂的单位转换 + this.logger.warn(`无法解析资源大小: ${meta.size}`); + sizeValue = 0; + } + } else if (typeof meta.size === 'number') { + sizeValue = meta.size; + } else { + sizeValue = 0; + } + + totalSize += sizeValue; + resourceCount++; + } + } + }); + + this.logger.log(`${dateType}资源总大小: ${totalSize}, 资源数量: ${resourceCount}`); + return { totalSize, resourceCount }; + } catch (error) { + this.logger.error(`获取${dateType === 'today' ? '今天' : '昨天'}的分享码资源大小失败`, error); + throw error; + } + } + /** + * 统计不同时间段内独立uploadIp的数量 + * @returns 返回本周和上周的不同uploadIp数量 + */ + async countDistinctUploadIPs(): Promise<{ thisWeek: number; lastWeek: number; all: number }> { + try { + const now = dayjs().tz('Asia/Shanghai'); + + // 本周的开始和结束 + const thisWeekStart = now.startOf('week').toDate(); + const thisWeekEnd = now.endOf('week').toDate(); + + // 上周的开始和结束 + const lastWeekStart = now.subtract(1, 'week').startOf('week').toDate(); + const lastWeekEnd = now.subtract(1, 'week').endOf('week').toDate(); + + this.logger.log(`统计本周IP数量, 日期范围: ${thisWeekStart} 到 ${thisWeekEnd}`); + this.logger.log(`统计上周IP数量, 日期范围: ${lastWeekStart} 到 ${lastWeekEnd}`); + // 查询所有不同IP + const allIPs = await super.findMany({ + where: { + deletedAt: null, + uploadIp: { + not: null + } + }, + select: { + uploadIp: true + }, + distinct: ['uploadIp'] + }); + // 查询本周的不同IP + const thisWeekIPs = await super.findMany({ + where: { + createdAt: { + gte: thisWeekStart, + lte: thisWeekEnd + }, + uploadIp: { + not: null + }, + deletedAt: null + }, + select: { + uploadIp: true + }, + distinct: ['uploadIp'] + }); + + // 查询上周的不同IP + const lastWeekIPs = await super.findMany({ + where: { + createdAt: { + gte: lastWeekStart, + lte: lastWeekEnd + }, + uploadIp: { + not: null + }, + deletedAt: null + }, + select: { + uploadIp: true + }, + distinct: ['uploadIp'] + }); + + const thisWeekCount = thisWeekIPs.length; + const lastWeekCount = lastWeekIPs.length; + const allCount = allIPs.length; + + this.logger.log(`本周不同IP数量: ${thisWeekCount}, 上周不同IP数量: ${lastWeekCount}, 所有不同IP数量: ${allCount}`); + + return { thisWeek: thisWeekCount, lastWeek: lastWeekCount, all: allCount }; + } catch (error) { + this.logger.error('统计不同uploadIp数量失败', error); + throw error; + } + } + + /** + * 获取分享码列表,使用ShareCodeSelect并按创建时间倒序排序 + * @param args 查询参数 + * @returns 返回分享码列表 + */ + async findShareCodes(args?: Omit): Promise { + try { + const result = await super.findMany({ + ...args, + select: ShareCodeSelect, + }); + this.logger.log(`获取分享码列表成功, 数量: ${result.length}`); + return result as unknown as GenerateShareCodeResponse[]; + } catch (error) { + this.logger.error('获取分享码列表失败', error); + throw error; + } + } } diff --git a/apps/web/src/app/admin/code-manage/CodeManageContext.tsx b/apps/web/src/app/admin/code-manage/CodeManageContext.tsx index 2ebadcb..9549b4c 100644 --- a/apps/web/src/app/admin/code-manage/CodeManageContext.tsx +++ b/apps/web/src/app/admin/code-manage/CodeManageContext.tsx @@ -19,7 +19,8 @@ interface CodeManageContextType { searchRefetch: () => void, setSearchKeyword: (keyword: string) => void, currentCode: string | null, - setCurrentCode: (code: string) => void + setCurrentCode: (code: string) => void, + searchKeyword: string } interface ShareCodeWithResource { @@ -97,7 +98,8 @@ export const CodeManageProvider = ({ children }: { children: React.ReactNode }) searchRefetch, setSearchKeyword, currentCode, - setCurrentCode + setCurrentCode, + searchKeyword }}> {children} diff --git a/apps/web/src/app/admin/code-manage/CodeManageLayout.tsx b/apps/web/src/app/admin/code-manage/CodeManageLayout.tsx index 634c504..ebca1b5 100644 --- a/apps/web/src/app/admin/code-manage/CodeManageLayout.tsx +++ b/apps/web/src/app/admin/code-manage/CodeManageLayout.tsx @@ -1,9 +1,25 @@ +import { useSearchParams } from "react-router-dom"; import CodeManageSearchBase from "./components/CodeManageSearchBase"; import CodeManageDisplay from "./components/CodeMangeDisplay"; +import { useCodeManageContext } from "./CodeManageContext"; +import { useEffect, useState } from "react"; export default function CodeManageLayout() { + const [searchParams] = useSearchParams(); + const { setSearchKeyword, searchRefetch, setCurrentPage } = useCodeManageContext(); + const [localKeyword, setLocalKeyword] = useState(""); + + useEffect(() => { + const keyword = searchParams.get('keyword'); + if (keyword) { + setSearchKeyword(keyword); + setLocalKeyword(keyword); + setCurrentPage(1); + searchRefetch(); + } + }, [searchParams, setSearchKeyword, setCurrentPage, searchRefetch]); return (
- +
) diff --git a/apps/web/src/app/admin/code-manage/components/CodeManageSearchBase.tsx b/apps/web/src/app/admin/code-manage/components/CodeManageSearchBase.tsx index c605624..ec87eb7 100644 --- a/apps/web/src/app/admin/code-manage/components/CodeManageSearchBase.tsx +++ b/apps/web/src/app/admin/code-manage/components/CodeManageSearchBase.tsx @@ -1,16 +1,26 @@ import { Button, Form, Input } from 'antd'; import { useCodeManageContext } from '../CodeManageContext'; -import { ChangeEvent, useRef } from 'react'; +import { ChangeEvent, useEffect, useRef } from 'react'; -export default function CodeManageSearchBase() { - const { setCurrentPage, searchRefetch, setSearchKeyword } = useCodeManageContext(); +export default function CodeManageSearchBase({ keyword }: { keyword?: string }) { + const { setCurrentPage, searchRefetch, setSearchKeyword, searchKeyword } = useCodeManageContext(); const debounceTimer = useRef(null); + const formRef = Form.useForm()[0]; // 获取表单实例 + + // 当 keyword 属性变化时更新表单值 + useEffect(() => { + if (keyword) { + formRef.setFieldsValue({ search: keyword }); + } + }, [keyword, formRef]); + const onSearch = (value: string) => { console.log(value); setSearchKeyword(value); setCurrentPage(1) searchRefetch() }; + const onChange = (e: React.ChangeEvent) => { // 设置表单值 setSearchKeyword(e.target.value); @@ -25,13 +35,15 @@ export default function CodeManageSearchBase() { searchRefetch(); }, 300); // 300毫秒的防抖延迟 }; + return <> -
-
+
+ diff --git a/apps/web/src/app/admin/code-manage/components/CodeMangeDisplay.tsx b/apps/web/src/app/admin/code-manage/components/CodeMangeDisplay.tsx index 61bfe56..a1f4482 100644 --- a/apps/web/src/app/admin/code-manage/components/CodeMangeDisplay.tsx +++ b/apps/web/src/app/admin/code-manage/components/CodeMangeDisplay.tsx @@ -51,7 +51,7 @@ export default function CodeMangeDisplay() { console.log('currentShareCodes:', currentShareCodes); }, [currentShareCodes]); return <> -
+
-

+

文件名: {item.fileName}

-

+

文件大小: {Math.max(0.01, (Number(item?.resource?.meta?.size) / 1024 / 1024)).toFixed(2)} MB

-

- 过期时间: {dayjs(item.expiresAt).format('YYYY-MM-DD HH:mm:ss')} -

-

+ +

剩余使用次数: {item.canUseTimes}

+

+ 上传IP: {item.uploadIp} +

+

+ 过期时间: {dayjs(item.expiresAt).format('YYYY-MM-DD HH:mm:ss')} +

+

+ 上传时间: {dayjs(item.createdAt).format('YYYY-MM-DD HH:mm:ss')} +

+
diff --git a/apps/web/src/app/admin/dashboard/DashboardContext.tsx b/apps/web/src/app/admin/dashboard/DashboardContext.tsx new file mode 100644 index 0000000..81899be --- /dev/null +++ b/apps/web/src/app/admin/dashboard/DashboardContext.tsx @@ -0,0 +1,72 @@ +import { api } from "@nice/client"; +import { createContext, useContext, useState } from "react"; +import { ShareCodeResponse } from "../quick-file/quickFileContext"; +interface DashboardContextType { + shareCodeAll: ShareCodeResourcesSizeByDateRange; + shareCodeToday: ShareCodeResourcesSizeByDateRange; + shareCodeYesterday: ShareCodeResourcesSizeByDateRange; + isShareCodeAllLoading: boolean; + isShareCodeTodayLoading: boolean; + isShareCodeYesterdayLoading: boolean; + distinctUploadIPs: { thisWeek: number; lastWeek: number; all: number }; + isDistinctUploadIPsLoading: boolean; + shareCodeList: ShareCodeResponse[]; + isShareCodeListLoading: boolean; +} +interface ShareCodeResourcesSizeByDateRange { + totalSize: number; + resourceCount: number; +} +export const DashboardContext = createContext(null); + +export const DashboardProvider = ({ children }: { children: React.ReactNode }) => { + const { data: shareCodeAll, isLoading: isShareCodeAllLoading }: + { data: ShareCodeResourcesSizeByDateRange, isLoading: boolean } + = api.shareCode.getShareCodeResourcesTotalSize.useQuery() + const { data: shareCodeToday, isLoading: isShareCodeTodayLoading }: + { data: ShareCodeResourcesSizeByDateRange, isLoading: boolean } + = api.shareCode.getShareCodeResourcesSizeByDateRange.useQuery( + { dateType: "today" }) + const { data: shareCodeYesterday, isLoading: isShareCodeYesterdayLoading }: + { data: ShareCodeResourcesSizeByDateRange, isLoading: boolean } + = api.shareCode.getShareCodeResourcesSizeByDateRange.useQuery( + { dateType: "yesterday" }) + const { data: distinctUploadIPs, isLoading: isDistinctUploadIPsLoading }: + { data: { thisWeek: number; lastWeek: number; all: number }, isLoading: boolean } + = api.shareCode.countDistinctUploadIPs.useQuery() + const { data: shareCodeList, isLoading: isShareCodeListLoading }: + { data: ShareCodeResponse[], isLoading: boolean } + = api.shareCode.findShareCodes.useQuery({ + where: { + deletedAt: null + }, + take: 8, + orderBy: { + createdAt: 'desc', + }, + }) + return <> + + {children} + + +}; + +export const useDashboardContext = () => { + const context = useContext(DashboardContext); + if (!context) { + throw new Error("useDashboardContext must be used within a DashboardProvider"); + } + return context; +}; \ No newline at end of file diff --git a/apps/web/src/app/admin/dashboard/DashboardLayout.tsx b/apps/web/src/app/admin/dashboard/DashboardLayout.tsx new file mode 100644 index 0000000..1a13b91 --- /dev/null +++ b/apps/web/src/app/admin/dashboard/DashboardLayout.tsx @@ -0,0 +1,13 @@ +import Board from './components/Board'; +import Activate from './components/Activate'; + +export default function DashboardLayout() { + return ( +
+ + {/* 最近活动 */} + +
+ ); +} + diff --git a/apps/web/src/app/admin/dashboard/components/Activate.tsx b/apps/web/src/app/admin/dashboard/components/Activate.tsx new file mode 100644 index 0000000..94ba01a --- /dev/null +++ b/apps/web/src/app/admin/dashboard/components/Activate.tsx @@ -0,0 +1,37 @@ +import { Typography, Space, Skeleton } from 'antd'; +import { FileOutlined } from '@ant-design/icons'; +import DashboardCard from '@web/src/components/presentation/dashboard-card'; +import ActivityItem from './ActivityItem'; +import { useDashboardContext } from '../DashboardContext'; +import { ShareCodeResponse } from '../../quick-file/quickFileContext'; +import { useNavigate } from 'react-router-dom'; +const { Title, Text } = Typography; +export default function Activate() { + const { shareCodeList, isShareCodeListLoading } = useDashboardContext(); + const navigate = useNavigate() + const handleClick = (item: ShareCodeResponse) => { + navigate(`/manage/share-code?keyword=${encodeURIComponent(item.code)}`) + } + return <> +
+ 最新上传活动 + + + {isShareCodeListLoading ? + + : + shareCodeList.map((item) => ( +
handleClick(item)}> + } + title={`来自${item.uploadIp}的用户上传了文件:"${item.fileName}",文件大小:${Math.max(Number(item.resource.meta.size) / 1024 / 1024, 0.01).toFixed(2)}MB,分享码:${item.code}`} + time={item.createdAt?.toLocaleString()} + /> +
+ ))} +
+
+
+ +} \ No newline at end of file diff --git a/apps/web/src/app/admin/dashboard/components/ActivityItem.tsx b/apps/web/src/app/admin/dashboard/components/ActivityItem.tsx new file mode 100644 index 0000000..f51246e --- /dev/null +++ b/apps/web/src/app/admin/dashboard/components/ActivityItem.tsx @@ -0,0 +1,26 @@ +import { motion } from 'framer-motion'; +// 活动项组件 +interface ActivityItemProps { + icon: React.ReactNode; + title: string; + time: string; +} + +export default function ActivityItem({ icon, title, time }: ActivityItemProps) { + return ( + +
+ {icon} +
+
+
{title}
+
{time}
+
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/app/admin/dashboard/components/Board.tsx b/apps/web/src/app/admin/dashboard/components/Board.tsx new file mode 100644 index 0000000..598e540 --- /dev/null +++ b/apps/web/src/app/admin/dashboard/components/Board.tsx @@ -0,0 +1,138 @@ +import { Row, Col, Typography, Space, Tooltip, Statistic, Spin } from 'antd'; +import { FileOutlined, HddOutlined, UserOutlined, CheckCircleOutlined, HistoryOutlined } from '@ant-design/icons'; +import DashboardCard from '@web/src/components/presentation/dashboard-card'; +import { useDashboardContext } from '../DashboardContext'; +import { useEffect, useState } from 'react'; +const { Title, Text } = Typography; +export default function Board() { + const { shareCodeAll, shareCodeToday, shareCodeYesterday, + isShareCodeAllLoading, isShareCodeTodayLoading, isShareCodeYesterdayLoading, + distinctUploadIPs, isDistinctUploadIPsLoading } = useDashboardContext(); + const [serverUptime, setServerUptime] = useState(''); + useEffect(() => { + const calculateTimeDifference = () => { + const now = new Date(); + const targetDate = new Date('2025-04-09T15:00:00'); + const diffMs = now.getTime()- targetDate.getTime(); + + // 如果是负数,表示目标日期已过 + if (diffMs < 0) { + setServerUptime('0天0小时0分钟'); + return; + } + + // 计算天数、小时数和分钟数 + const days = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)); + + setServerUptime(`${days}天${hours}小时${minutes}分钟`); + }; + + calculateTimeDifference(); + // 每分钟更新一次 + const timer = setInterval(calculateTimeDifference, 60000); + + return () => clearInterval(timer); + }, []); + return <> + 仪表盘 + + {/* 总文件数卡片 */} + + + + 总文件数 +
+ } + className="h-full" + > +
+ + { + isShareCodeTodayLoading || isShareCodeYesterdayLoading + ? + : (
+ 昨天: {shareCodeYesterday?.resourceCount} 今天: {shareCodeToday?.resourceCount} +
) + } +
+ + + + {/* 存储空间卡片 */} + + + + 已使用的存储空间 +
+ } + className="h-full" + > +
+ + { + isShareCodeTodayLoading || isShareCodeYesterdayLoading + ? + : (
+ 昨天: {(shareCodeYesterday?.totalSize / 1024 / 1024).toFixed(2)}MB 今天: {(shareCodeToday?.totalSize / 1024 / 1024).toFixed(2)}MB +
) + } +
+ + + + {/* 活跃用户卡片 */} + + + + 全部用户 +
+ } + className="h-full" + > +
+ + +
+ 上周使用用户: {distinctUploadIPs?.lastWeek} 本周使用用户: {distinctUploadIPs?.thisWeek} + { + distinctUploadIPs?.lastWeek ? ( + distinctUploadIPs?.lastWeek > distinctUploadIPs?.thisWeek ? + ↓{((distinctUploadIPs?.lastWeek - distinctUploadIPs?.thisWeek) / distinctUploadIPs?.lastWeek * 100).toFixed(2)}% + : ↑ {((distinctUploadIPs?.thisWeek - distinctUploadIPs?.lastWeek) / distinctUploadIPs?.lastWeek * 100).toFixed(2)}% + ) : null + } +
+
+ + + + {/* 系统状态卡片 */} + + + + 系统状态 + + } + className="h-full" + > +
+ +
+ 服务器运行时间: {serverUptime} +
+
+
+ + + +} \ No newline at end of file diff --git a/apps/web/src/app/admin/quick-file/components/header.tsx b/apps/web/src/app/admin/quick-file/components/header.tsx new file mode 100644 index 0000000..4a6d501 --- /dev/null +++ b/apps/web/src/app/admin/quick-file/components/header.tsx @@ -0,0 +1,41 @@ +import { useAuth } from "@web/src/providers/auth-provider"; +import { Button } from "antd"; +import { RolePerms } from "@nice/common"; +import { useNavigate } from "react-router-dom"; + +interface HeaderProps { + showLoginButton?: boolean; +} + +export const Header: React.FC = ({ showLoginButton = true }) => { + const navigate = useNavigate(); + const {isAuthenticated , hasEveryPermissions} = useAuth(); + const isAdmin = hasEveryPermissions(RolePerms.MANAGE_ANY_POST) && isAuthenticated; + + return ( +
+
+ + 烽火快传 +
+ + {showLoginButton && ( + + )} +
+ ); +}; + +export default Header; \ No newline at end of file diff --git a/apps/web/src/app/admin/quick-file/manage.tsx b/apps/web/src/app/admin/quick-file/manage.tsx new file mode 100644 index 0000000..69dd70c --- /dev/null +++ b/apps/web/src/app/admin/quick-file/manage.tsx @@ -0,0 +1,66 @@ +import { useState, useEffect } from "react"; +import { Layout, Menu } from "antd"; +import { FileOutlined, DashboardOutlined } from "@ant-design/icons"; +import { NavLink, Outlet, useNavigate, useLocation } from "react-router-dom"; + +const { Sider, Content } = Layout; + +export default function QuickFileManage() { + const [collapsed, setCollapsed] = useState(false); + const navigate = useNavigate(); + const location = useLocation(); + const [currentKey, setCurrentKey] = useState("1"); + + useEffect(() => { + // 根据当前URL路径设置currentKey + if (location.pathname.includes("/manage/dashboard")) { + setCurrentKey("1"); + } else if (location.pathname.includes("/manage/share-code")) { + setCurrentKey("2"); + } + }, [location.pathname]); + + return ( + + setCollapsed(value)} + theme="light" + style={{ + borderRight: "1px solid #f0f0f0" + }} + > +
{ + navigate('/'); + }} + > + + {!collapsed && 烽火快传} +
+ , + label: 数据看板, + }, + { + key: "2", + icon: , + label: 所有文件, + }, + ]} + /> + + + + + + ); +} \ No newline at end of file diff --git a/apps/web/src/app/admin/quick-file/page.tsx b/apps/web/src/app/admin/quick-file/page.tsx index acdea12..fb4957f 100755 --- a/apps/web/src/app/admin/quick-file/page.tsx +++ b/apps/web/src/app/admin/quick-file/page.tsx @@ -1,26 +1,28 @@ import { ShareCodeGenerator } from "../sharecode/sharecodegenerator"; import { ShareCodeValidator } from "../sharecode/sharecodevalidator"; -import { message, Tabs, Form, Spin, Alert } from "antd"; -import { env } from '../../../env' +import { message, Tabs, Form } from "antd"; import { TusUploader } from "@web/src/components/common/uploader/TusUploader"; -import { useState } from "react"; import CodeRecord from "../sharecode/components/CodeRecord"; +import Header from "./components/header"; const { TabPane } = Tabs; export default function QuickUploadPage() { const [form] = Form.useForm(); const uploadFileId = Form.useWatch(["file"], form)?.[0] - const handleShareSuccess = (code: string) => { - message.success('分享码生成成功:' + code); - } return ( -
-
- - 烽火快传 -
- - +
+
+
+
+
+ 使用分享码下载文件 + +
+
+ +
+
+
上传文件并生成分享码 @@ -40,17 +42,17 @@ export default function QuickUploadPage() { fileId={uploadFileId} />
- - -
- 使用分享码下载文件 - -
-
- -
-
- +
+ + {/* + + + + + + + */} +
) diff --git a/apps/web/src/app/admin/quick-file/quickFileContext.tsx b/apps/web/src/app/admin/quick-file/quickFileContext.tsx index e7205ee..713af04 100644 --- a/apps/web/src/app/admin/quick-file/quickFileContext.tsx +++ b/apps/web/src/app/admin/quick-file/quickFileContext.tsx @@ -25,6 +25,8 @@ export interface ShareCodeResponse { url: string; meta: ResourceMeta } + uploadIp?: string; + createdAt?: Date; } interface ResourceMeta { filename: string; @@ -57,7 +59,10 @@ export const QuickFileProvider = ({ children }: { children: React.ReactNode }) = }; // 获取已有记录并添加新记录 const existingGeneratorRecords = localStorage.getItem(recordName); - const generatorRecords = existingGeneratorRecords ? JSON.parse(existingGeneratorRecords) : []; + let generatorRecords = existingGeneratorRecords ? JSON.parse(existingGeneratorRecords) : []; + if (data.code) { + generatorRecords = generatorRecords.filter((item: ShareCodeResponse) => item.code !== data.code) + } generatorRecords.unshift(newRecord); // 添加到最前面 localStorage.setItem(recordName, JSON.stringify(generatorRecords)); } diff --git a/apps/web/src/app/admin/sharecode/components/CodeRecord.tsx b/apps/web/src/app/admin/sharecode/components/CodeRecord.tsx index 75de0aa..844879c 100644 --- a/apps/web/src/app/admin/sharecode/components/CodeRecord.tsx +++ b/apps/web/src/app/admin/sharecode/components/CodeRecord.tsx @@ -72,7 +72,7 @@ export default function CodeRecord({ title, btnContent, recordName ,styles,isDow
{records.map(item => ( = ({ const [expiresAt, setExpiresAt] = useState(null); const [canUseTimes, setCanUseTimes] = useState(null); const queryClient = useQueryClient(); - const queryKey = getQueryKey(api.term); + const queryKey = getQueryKey(api.shareCode); const [isGenerate, setIsGenerate] = useState(false); const [currentFileId, setCurrentFileId] = useState(''); const [form] = Form.useForm(); diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 83176a4..52f865f 100755 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -2,6 +2,7 @@ import { createBrowserRouter, IndexRouteObject, Link, + Navigate, NonIndexRouteObject, useParams, } from "react-router-dom"; @@ -12,6 +13,9 @@ import { CodeManageProvider } from "../app/admin/code-manage/CodeManageContext"; import CodeManageLayout from "../app/admin/code-manage/CodeManageLayout"; import QuickUploadPage from "../app/admin/quick-file/page"; import { QuickFileProvider } from "../app/admin/quick-file/quickFileContext"; +import QuickFileManage from "../app/admin/quick-file/manage"; +import { DashboardProvider } from "../app/admin/dashboard/DashboardContext"; +import DashboardLayout from "../app/admin/dashboard/DashboardLayout"; interface CustomIndexRouteObject extends IndexRouteObject { name?: string; breadcrumb?: string; @@ -57,6 +61,36 @@ export const routes: CustomRouteObject[] = [ }, + { + path: "/manage", + element: <> + + + + , + children: [ + { + index: true, + element: + }, + { + path: "/manage/share-code", + element: <> + + + + + }, + { + path: "/manage/dashboard", + element: <> + + + + + } + ] + } ]; export const router = createBrowserRouter(routes); diff --git a/packages/common/prisma/schema.prisma b/packages/common/prisma/schema.prisma index fdbfb97..a695160 100755 --- a/packages/common/prisma/schema.prisma +++ b/packages/common/prisma/schema.prisma @@ -340,26 +340,26 @@ model PostInstructor { } model Resource { - id String @id @default(cuid()) @map("id") - title String? @map("title") - description String? @map("description") - type String? @map("type") - fileId String? @unique + id String @id @default(cuid()) @map("id") + title String? @map("title") + description String? @map("description") + type String? @map("type") + fileId String? @unique url String? // 元数据 - meta Json? @map("meta") + meta Json? @map("meta") // 处理状态控制 status String? - createdAt DateTime? @default(now()) @map("created_at") - updatedAt DateTime? @updatedAt @map("updated_at") - createdBy String? @map("created_by") - updatedBy String? @map("updated_by") - deletedAt DateTime? @map("deleted_at") - isPublic Boolean? @default(true) @map("is_public") - owner Staff? @relation(fields: [ownerId], references: [id]) - ownerId String? @map("owner_id") - post Post? @relation(fields: [postId], references: [id]) - postId String? @map("post_id") + createdAt DateTime? @default(now()) @map("created_at") + updatedAt DateTime? @updatedAt @map("updated_at") + createdBy String? @map("created_by") + updatedBy String? @map("updated_by") + deletedAt DateTime? @map("deleted_at") + isPublic Boolean? @default(true) @map("is_public") + owner Staff? @relation(fields: [ownerId], references: [id]) + ownerId String? @map("owner_id") + post Post? @relation(fields: [postId], references: [id]) + postId String? @map("post_id") shareCode ShareCode? // 索引 @@ -426,16 +426,22 @@ model Person { model ShareCode { id String @id @default(cuid()) code String? @unique + fileId String? @unique + fileName String? @map("file_name") + resource Resource? @relation(fields: [fileId], references: [fileId]) + createdAt DateTime @default(now()) expiresAt DateTime? @map("expires_at") - isUsed Boolean? @default(false) - fileName String? @map("file_name") - canUseTimes Int? - resource Resource? @relation(fields: [fileId], references: [fileId]) deletedAt DateTime? @map("deleted_at") + + isUsed Boolean? @default(false) + canUseTimes Int? + + uploadIp String? @map("upload_ip") + @@index([code]) @@index([fileId]) @@index([expiresAt]) @@map("share_code") -} \ No newline at end of file +}