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 946e01f..6feb18b 100644 --- a/apps/server/src/models/share-code/share-code.router.ts +++ b/apps/server/src/models/share-code/share-code.router.ts @@ -1,14 +1,16 @@ -import { z } from "zod"; +import { z, ZodType } from "zod"; import { ShareCodeService } from "./share-code.service"; import { TrpcService } from "@server/trpc/trpc.service"; import { Injectable } from "@nestjs/common"; +import { Prisma } from "@nice/common"; +const ShareCodeWhereInputSchema: ZodType = z.any(); @Injectable() export class ShareCodeRouter { constructor( private readonly shareCodeService: ShareCodeService, private readonly trpc: TrpcService - ) {} - + ) { } + router = this.trpc.router({ generateShareCode: this.trpc.procedure .input(z.object({ fileId: z.string(), expiresAt: z.date(), canUseTimes: z.number() })) @@ -45,5 +47,26 @@ export class ShareCodeRouter { .mutation(async ({ input }) => { return this.shareCodeService.generateShareCodeByFileId(input.fileId, input.expiresAt, input.canUseTimes); }), + getShareCodesWithResources: this.trpc.procedure + .input(z.object({ page: z.number(), pageSize: z.number(), where: ShareCodeWhereInputSchema.optional() })) + .query(async ({ input }) => { + return this.shareCodeService.getShareCodesWithResources(input); + }), + softDeleteShareCodes: this.trpc.procedure + .input(z.object({ ids: z.array(z.string()) })) + .mutation(async ({ input }) => { + return this.shareCodeService.softDeleteShareCodes(input.ids); + }), + updateShareCode: this.trpc.procedure + .input(z.object({ + id: z.string(), + data: z.object({ + expiresAt: z.date().optional(), + canUseTimes: z.number().optional(), + }) + })) + .mutation(async ({ input }) => { + return this.shareCodeService.updateShareCode(input.id, input.data); + }), }); } \ 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 346f1ab..33535a5 100755 --- a/apps/server/src/models/share-code/share-code.service.ts +++ b/apps/server/src/models/share-code/share-code.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { customAlphabet } from 'nanoid-cjs'; -import { db } from '@nice/common'; +import { db, ObjectType, Prisma, Resource } from '@nice/common'; import { Cron, CronExpression } from '@nestjs/schedule'; import { ResourceService } from '@server/models/resource/resource.service'; import * as fs from 'fs' @@ -8,6 +8,7 @@ import * as path from 'path' import dayjs from 'dayjs'; import utc from 'dayjs/plugin/utc'; import timezone from 'dayjs/plugin/timezone'; +import { BaseService } from '../base/base.service'; dayjs.extend(utc); dayjs.extend(timezone); export interface ShareCode { @@ -33,7 +34,7 @@ interface ResourceMeta { } @Injectable() -export class ShareCodeService { +export class ShareCodeService extends BaseService { private readonly logger = new Logger(ShareCodeService.name); // 生成8位分享码,使用易读的字符 private readonly generateCode = customAlphabet( @@ -41,7 +42,9 @@ export class ShareCodeService { 8, ); - constructor(private readonly resourceService: ResourceService) { } + constructor(private readonly resourceService: ResourceService) { + super(db, ObjectType.SHARE_CODE, false); + } async generateShareCode( fileId: string, @@ -54,42 +57,40 @@ export class ShareCodeService { const resource = await this.resourceService.findUnique({ where: { fileId }, }); - this.logger.log('完整 fileId:', fileId); // 确保与前端一致 - + this.logger.log('完整 resource:', resource); if (!resource) { throw new NotFoundException('文件不存在'); } - + const { filename } = resource.meta as any as ResourceMeta // 生成分享码 const code = this.generateCode(); // 查找是否已有分享码记录 - const existingShareCode = await db.shareCode.findUnique({ + const existingShareCode = await super.findUnique({ where: { fileId }, }); if (existingShareCode) { // 更新现有记录,但保留原有文件名 - await db.shareCode.update({ + await super.update({ where: { fileId }, data: { code, expiresAt, canUseTimes, isUsed: false, - // 只在没有现有文件名且提供了新文件名时才更新文件名 - ...(fileName && !existingShareCode.fileName ? { fileName } : {}), + fileName: filename|| "downloaded_file", }, }); } else { // 创建新记录 - await db.shareCode.create({ + await super.create({ data: { code, fileId, expiresAt, canUseTimes, isUsed: false, - fileName: fileName || null, + fileName: filename || "downloaded_file", }, }); } @@ -304,6 +305,68 @@ export class ShareCodeService { this.logger.error('生成分享码错误:', error); return error } + } + async getShareCodesWithResources(args: { + page?: number; + pageSize?: number; + where?: Prisma.ShareCodeWhereInput; + }): Promise<{ + items: Array; + totalPages: number; + }> { + try { + console.log('args:', args.where.OR); + // 使用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, + } + } + } + }); + this.logger.log('search result:', items); + return { + items, + totalPages + }; + } catch (error) { + this.logger.error('Failed to get share codes with resources', error); + throw error; + } + } + async softDeleteShareCodes(ids: string[]): Promise { + try { + this.logger.log(`尝试软删除分享码,IDs: ${ids.join(', ')}`); + const result = await super.softDeleteByIds(ids); + this.logger.log(`软删除分享码成功,数量: ${result.length}`); + return result; + } catch (error) { + this.logger.error('软删除分享码失败', error); + throw error; + } + } + async updateShareCode(id: string, data: Partial): Promise { + try { + this.logger.log(`尝试更新分享码,ID: ${id},数据:`, data); + const result = await super.updateById(id, data); + this.logger.log(`更新分享码成功:`, result); + return result; + } 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 new file mode 100644 index 0000000..260a924 --- /dev/null +++ b/apps/web/src/app/admin/code-manage/CodeManageContext.tsx @@ -0,0 +1,126 @@ +import { Form, FormInstance, message } from "antd"; +import { api } from "@nice/client"; +import { createContext, useContext, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { getQueryKey } from "@trpc/react-query"; + +interface CodeManageContextType { + editForm: FormInstance; + isLoading: boolean; + currentShareCodes: ShareCodeWithResource; + currentPage: number; + setCurrentPage: (page: number) => void; + pageSize: number; + deletShareCode: (id: string) => void; + updateCode: (expiresAt: Date, canUseTimes: number) => void + setCurrentCodeId: (id: string) => void, + currentCodeId: string | null, + searchRefetch: () => void, + setSearchKeyword: (keyword: string) => void, + currentCode: string | null, + setCurrentCode: (code: string) => void +} + +interface ShareCodeWithResource { + items: { + id: string; + code: string; + fileName: string; + fileSize: number; + expiresAt: string; + createdAt: string; + canUseTimes: number; + resource: { + id: string; + type: string; + url: string; + meta: any; + } + }[], + totalPages: number +} + +export const CodeManageContext = createContext(null); + +export const CodeManageProvider = ({ children }: { children: React.ReactNode }) => { + const [editForm] = Form.useForm(); + const [currentPage, setCurrentPage] = useState(1); + const [currentCodeId, setCurrentCodeId] = useState() + const [currentCode, setCurrentCode] = useState() + const queryClient = useQueryClient(); + const pageSize = 8; + // 在组件顶部添加 + const [searchKeyword, setSearchKeyword] = useState(''); + // 构建查询条件 + const whereCondition = { + deletedAt: null, + ...(searchKeyword ? { + OR: [ + { fileName: { contains: searchKeyword } }, + { code: { contains: searchKeyword } } + ] + } : {}) + }; + const { data: currentShareCodes, refetch: searchRefetch }: { data: ShareCodeWithResource, refetch: () => void } = api.shareCode.getShareCodesWithResources.useQuery( + { + page: currentPage, + pageSize: pageSize, + where: whereCondition, + }, + { + enabled: true, + refetchOnWindowFocus: false, + } + ) + const { mutate: softDeleteShareCode } = api.shareCode.softDeleteShareCodes.useMutation({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: getQueryKey(api.shareCode) }); + }, + onError: () => { + message.error('删除失败') + } + }) + const { mutate: updateShareCode } = api.shareCode.updateShareCode.useMutation({ + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: getQueryKey(api.shareCode) }); + }, + onError: () => { + message.error('更新失败') + } + }) + const deletShareCode = (id: string) => { + softDeleteShareCode({ ids: [id] }) + } + const updateCode = (expiresAt: Date, canUseTimes: number) => { + if (currentCodeId) updateShareCode({ id: currentCodeId, data: { expiresAt, canUseTimes } }) + } + const [isLoading, setIsLoading] = useState(false); + return <> + + {children} + + +}; + +export const useCodeManageContext = () => { + const context = useContext(CodeManageContext); + if (!context) { + throw new Error("useCodeManageContext must be used within a CodeManageProvider"); + } + return context; +}; \ No newline at end of file diff --git a/apps/web/src/app/admin/code-manage/CodeManageEdit.tsx b/apps/web/src/app/admin/code-manage/CodeManageEdit.tsx new file mode 100644 index 0000000..accc8ca --- /dev/null +++ b/apps/web/src/app/admin/code-manage/CodeManageEdit.tsx @@ -0,0 +1,72 @@ +import { useCodeManageContext } from "./CodeManageContext"; +import { Form, DatePicker, Input, Button } from "antd"; +import dayjs from "dayjs"; +import { useState } from "react"; + +export default function CodeManageEdit() { + const { editForm } = useCodeManageContext(); + // 验证数字输入只能是大于等于0的整数 + const validatePositiveInteger = (_: any, value: string) => { + const num = parseInt(value, 10); + if (isNaN(num) || num < 0 || num !== parseFloat(value)) { + return Promise.reject("请输入大于等于0的整数"); + } + return Promise.resolve(); + }; + + return ( +
+
+ 分享码有效期} + name="expiresAt" + rules={[{ required: true, message: "请选择有效期" }]} + className="mb-5" + > + current && current < dayjs().startOf('day')} + disabledTime={(current) => { + if (current && current.isSame(dayjs(), 'day')) { + return { + disabledHours: () => [...Array(dayjs().hour()).keys()], + disabledMinutes: (selectedHour) => { + if (selectedHour === dayjs().hour()) { + return [...Array(dayjs().minute()).keys()]; + } + return []; + } + }; + } + return {}; + }} + /> + + + 使用次数} + name="canUseTimes" + rules={[ + { required: true, message: "请输入使用次数" }, + { validator: validatePositiveInteger } + ]} + className="mb-5" + > + + +
+
+ ); +} \ No newline at end of file diff --git a/apps/web/src/app/admin/code-manage/CodeManageLayout.tsx b/apps/web/src/app/admin/code-manage/CodeManageLayout.tsx new file mode 100644 index 0000000..afe0b1f --- /dev/null +++ b/apps/web/src/app/admin/code-manage/CodeManageLayout.tsx @@ -0,0 +1,10 @@ +import CodeManageSearchBase from "./CodeManageSearchBase"; +import CodeManageDisplay from "./CodeMangeDisplay"; +export default function CodeManageLayout() { + return ( +
+ + +
+ ) +} \ No newline at end of file diff --git a/apps/web/src/app/admin/code-manage/CodeManageSearchBase.tsx b/apps/web/src/app/admin/code-manage/CodeManageSearchBase.tsx new file mode 100644 index 0000000..5370ae3 --- /dev/null +++ b/apps/web/src/app/admin/code-manage/CodeManageSearchBase.tsx @@ -0,0 +1,42 @@ +import { Button, Form, Input } from 'antd'; +import { useCodeManageContext } from './CodeManageContext'; +import { ChangeEvent, useRef } from 'react'; + +export default function CodeManageSearchBase() { + const { setCurrentPage, searchRefetch, setSearchKeyword } = useCodeManageContext(); + const debounceTimer = useRef(null); + const onSearch = (value: string) => { + console.log(value); + setSearchKeyword(value); + setCurrentPage(1) + searchRefetch() + }; + const onChange = (e: React.ChangeEvent) => { + // 设置表单值 + setSearchKeyword(e.target.value); + // 设置页码为1,确保从第一页开始显示搜索结果 + setCurrentPage(1); + // 使用防抖处理,避免频繁发送请求 + if (debounceTimer.current) { + clearTimeout(debounceTimer.current); + } + debounceTimer.current = setTimeout(() => { + // 触发查询 + searchRefetch(); + }, 300); // 300毫秒的防抖延迟 + }; + return <> +
+
+ + + +
+
+ +} \ No newline at end of file diff --git a/apps/web/src/app/admin/code-manage/CodeMangeDisplay.tsx b/apps/web/src/app/admin/code-manage/CodeMangeDisplay.tsx new file mode 100644 index 0000000..22c0837 --- /dev/null +++ b/apps/web/src/app/admin/code-manage/CodeMangeDisplay.tsx @@ -0,0 +1,87 @@ +import { message, Modal, Pagination } from "antd"; +import ShareCodeList from "./ShareCodeList"; +import { useCodeManageContext } from "./CodeManageContext"; +import { useEffect, useState } from "react"; +import { ExclamationCircleFilled } from "@ant-design/icons"; +import CodeManageEdit from "./CodeManageEdit"; +import dayjs from "dayjs"; +export default function CodeMangeDisplay() { + const { isLoading, currentShareCodes, pageSize, currentPage, + setCurrentPage, deletShareCode, editForm, + updateCode, setCurrentCodeId, setCurrentCode, currentCode + } = useCodeManageContext(); + const [modalOpen, setModalOpen] = useState(false); + const [formLoading, setFormLoading] = useState(false); + const { confirm } = Modal; + const handleEdit = (id: string, expiresAt: Date, canUseTimes: number, code: string) => { + console.log('编辑分享码:', id); + setCurrentCodeId(id) + setModalOpen(true); + setCurrentCode(code) + editForm.setFieldsValue({ + expiresAt: dayjs(expiresAt), + canUseTimes: canUseTimes + }); + }; + const handleEditOk = () => { + const expiresAt = editForm.getFieldsValue().expiresAt.tz('Asia/Shanghai').toDate() + const canUseTimes = Number(editForm.getFieldsValue().canUseTimes) + updateCode(expiresAt, canUseTimes) + message.success('分享码已更新') + setModalOpen(false) + } + const handleDelete = (id: string) => { + console.log('删除分享码:', id); + confirm({ + title: '确定删除该分享码吗', + icon: , + content: '', + okText: '删除', + okType: 'danger', + cancelText: '取消', + async onOk() { + deletShareCode(id) + message.success('分享码已删除') + }, + onCancel() { + }, + }); + }; + useEffect(() => { + console.log('currentShareCodes:', currentShareCodes); + }, [currentShareCodes]); + return <> +
+ +
+
+ { + setCurrentPage(page); + }} + /> +
+ { + handleEditOk() + }} + centered + open={modalOpen} + confirmLoading={formLoading} + onCancel={() => { + setModalOpen(false); + }} + title={`编辑分享码:${currentCode}`}> + + + +} \ No newline at end of file diff --git a/apps/web/src/app/admin/code-manage/ShareCodeList.tsx b/apps/web/src/app/admin/code-manage/ShareCodeList.tsx new file mode 100644 index 0000000..50d315b --- /dev/null +++ b/apps/web/src/app/admin/code-manage/ShareCodeList.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { List,} from 'antd'; +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; +import ShareCodeListCard from './ShareCodeListCard'; + +dayjs.extend(utc); +dayjs.extend(timezone); + +export interface ShareCodeItem { + id: string; + code: string; + expiresAt: string; + fileName: string; + canUseTimes: number; + resource: { + id: string; + type: string; + url: string; + meta: { + size: number; + filename: string; + }; + } +} + +interface ShareCodeListProps { + data: ShareCodeItem[]; + loading?: boolean; + onEdit?: (id: string,expiresAt:Date,canUseTimes:number,code:string) => void; + onDelete?: (id: string) => void; +} + +const ShareCodeList: React.FC = ({ + data, + loading, + onEdit, + onDelete +}) => { + + return ( + ( + + + + )} + /> + ); +}; + +export default ShareCodeList; \ No newline at end of file diff --git a/apps/web/src/app/admin/code-manage/ShareCodeListCard.tsx b/apps/web/src/app/admin/code-manage/ShareCodeListCard.tsx new file mode 100644 index 0000000..bddad1c --- /dev/null +++ b/apps/web/src/app/admin/code-manage/ShareCodeListCard.tsx @@ -0,0 +1,54 @@ +import { DeleteOutlined, EditOutlined } from "@ant-design/icons"; +import { Button, Card, Typography } from "antd"; +import dayjs from "dayjs"; +import { ShareCodeItem } from "./ShareCodeList"; +import { useEffect } from "react"; + +export default function ShareCodeListCard({ item, onEdit, onDelete }: { item: ShareCodeItem, onEdit: (id: string,expiresAt:Date,canUseTimes:number,code:string) => void, onDelete: (id: string) => void }) { + useEffect(() => { + console.log('item:', item); + }, [item]); + return
+ + {item.code} + + } + hoverable + actions={[ +
+} \ No newline at end of file diff --git a/apps/web/src/app/login.tsx b/apps/web/src/app/login.tsx index 5737980..0e158de 100755 --- a/apps/web/src/app/login.tsx +++ b/apps/web/src/app/login.tsx @@ -56,13 +56,13 @@ const LoginPage: React.FC = () => { }>
+ style={{ width: 900, height: 600 }}>
{showLogin ? (
- -
没有账号?
+ + {/*
没有账号?
点击注册一个属于你自己的账号吧!
@@ -70,7 +70,7 @@ const LoginPage: React.FC = () => { onClick={() => setShowLogin(false)} className="hover:translate-y-1 my-8 p-2 text-center rounded-xl border-white border hover:bg-white hover:text-primary hover:shadow-xl hover:cursor-pointer transition-all"> 注册 -
+
*/}
) : (
@@ -122,8 +122,8 @@ const LoginPage: React.FC = () => { ]}> -
-
diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 4c56b52..7a59941 100755 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -7,6 +7,10 @@ import { } from "react-router-dom"; import ErrorPage from "../app/error"; import DeptSettingPage from "../app/admin/deptsettingpage/page"; +import LoginPage from "../app/login"; +import WithAuth from "../components/utils/with-auth"; +import { CodeManageProvider } from "../app/admin/code-manage/CodeManageContext"; +import CodeManageLayout from "../app/admin/code-manage/CodeManageLayout"; interface CustomIndexRouteObject extends IndexRouteObject { name?: string; breadcrumb?: string; @@ -33,6 +37,21 @@ export const routes: CustomRouteObject[] = [ element: , errorElement: , }, + { + path: "/login", + breadcrumb: "登录", + element: , + }, + { + path: "/code-manage", + element: <> + + + + + + + }, ]; export const router = createBrowserRouter(routes); diff --git a/packages/common/prisma/schema.prisma b/packages/common/prisma/schema.prisma index 4e0b275..fdbfb97 100755 --- a/packages/common/prisma/schema.prisma +++ b/packages/common/prisma/schema.prisma @@ -360,6 +360,7 @@ model Resource { ownerId String? @map("owner_id") post Post? @relation(fields: [postId], references: [id]) postId String? @map("post_id") + shareCode ShareCode? // 索引 @@index([type]) @@ -431,7 +432,10 @@ model ShareCode { isUsed Boolean? @default(false) fileName String? @map("file_name") canUseTimes Int? + resource Resource? @relation(fields: [fileId], references: [fileId]) + deletedAt DateTime? @map("deleted_at") @@index([code]) @@index([fileId]) @@index([expiresAt]) -} + @@map("share_code") +} \ No newline at end of file diff --git a/packages/common/src/enum.ts b/packages/common/src/enum.ts index 309ef6d..b4adaee 100755 --- a/packages/common/src/enum.ts +++ b/packages/common/src/enum.ts @@ -59,6 +59,7 @@ export enum ObjectType { LECTURE = "lecture", ENROLLMENT = "enrollment", RESOURCE = "resource", + SHARE_CODE = "shareCode", } export enum RolePerms { // Create Permissions 创建权限