From 8e9b23e7e8f5205b43c140439503c5f834926634 Mon Sep 17 00:00:00 2001 From: ditiqi Date: Wed, 22 Jan 2025 19:25:55 +0800 Subject: [PATCH 1/2] add --- apps/server/src/models/post/post.router.ts | 1 + apps/server/src/models/post/post.service.ts | 1 + .../PostEditor/context/PostEditorContext.tsx | 113 ++++++++++++++++++ .../PostEditor/layout/PostEditorLayout.tsx | 22 ++++ apps/web/src/routes/index.tsx | 60 ++++++---- 5 files changed, 177 insertions(+), 20 deletions(-) create mode 100644 apps/web/src/components/models/post/PostEditor/context/PostEditorContext.tsx create mode 100644 apps/web/src/components/models/post/PostEditor/layout/PostEditorLayout.tsx diff --git a/apps/server/src/models/post/post.router.ts b/apps/server/src/models/post/post.router.ts index 40398bb..3181ae7 100755 --- a/apps/server/src/models/post/post.router.ts +++ b/apps/server/src/models/post/post.router.ts @@ -35,6 +35,7 @@ export class PostRouter { } as Prisma.InputJsonObject; // 明确指定类型 return await this.postService.create(input, { staff }); }), + softDeleteByIds: this.trpc.protectProcedure .input( z.object({ diff --git a/apps/server/src/models/post/post.service.ts b/apps/server/src/models/post/post.service.ts index 0f3f6e0..e6207bf 100755 --- a/apps/server/src/models/post/post.service.ts +++ b/apps/server/src/models/post/post.service.ts @@ -27,6 +27,7 @@ export class PostService extends BaseService { params: { staff?: UserProfile; tx?: Prisma.PostDelegate }, ) { args.data.authorId = params?.staff?.id; + // args.data.resources const result = await super.create(args); EventBus.emit('dataChanged', { type: ObjectType.POST, diff --git a/apps/web/src/components/models/post/PostEditor/context/PostEditorContext.tsx b/apps/web/src/components/models/post/PostEditor/context/PostEditorContext.tsx new file mode 100644 index 0000000..2f6bd5c --- /dev/null +++ b/apps/web/src/components/models/post/PostEditor/context/PostEditorContext.tsx @@ -0,0 +1,113 @@ +import { createContext, useContext, ReactNode, useEffect } from "react"; +import { useForm, FormProvider, SubmitHandler } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { api, usePost } from "@nice/client"; +// import { PostDto, PostLevel, PostStatus } from "@nice/common"; +// import { api, usePost } from "@nice/client"; +import toast from "react-hot-toast"; +import { useNavigate } from "react-router-dom"; +import { Post, PostType } from "@nice/common"; +// 定义帖子表单验证 Schema + +const postSchema = z.object({ + title: z.string().min(1, "标题不能为空"), + content: z.string().min(1, "内容不能为空"), + resources: z.array(z.string()).nullish(), + isPublic: z.boolean(), + signature: z.string().nullish(), +}); +// 定义课程表单验证 Schema + +export type PostFormData = z.infer; +interface PostEditorContextType { + onSubmit: SubmitHandler; + editId?: string; // 添加 editId + part?: string; + // course?: PostDto; +} +interface PostFormProviderProps { + children: ReactNode; + editId?: string; // 添加 editId 参数 + part?: string; +} +const PostEditorContext = createContext(null); +export function PostFormProvider({ children, editId }: PostFormProviderProps) { + const { create, update } = usePost(); + const { data: post }: { data: Post } = api.post.findById.useQuery( + { + id: editId, + }, + { enabled: !!editId } + ); + const navigate = useNavigate(); + const methods = useForm({ + resolver: zodResolver(postSchema), + defaultValues: { + resources: [], + }, + }); + useEffect(() => { + if (post) { + const formData = { + title: post.title, + content: post.content, + signature: (post.meta as any)?.signature, + }; + methods.reset(formData as any); + } + }, [post, methods]); + const onSubmit: SubmitHandler = async ( + data: PostFormData + ) => { + try { + if (editId) { + // await update.mutateAsync({ + // where: { id: editId }, + // data: { + // ...data + // } + // }) + toast.success("课程更新成功!"); + } else { + const result = await create.mutateAsync({ + data: { + type: PostType.POST, + ...data, + resources: data.resources?.length + ? { + connect: data.resources.map((id) => ({ + id, + })), + } + : undefined, + }, + }); + // navigate(`/course/${result.id}/editor`, { replace: true }); + toast.success("发送成功!"); + } + methods.reset(data); + } catch (error) { + console.error("Error submitting form:", error); + toast.error("操作失败,请重试!"); + } + }; + return ( + + {children} + + ); +} + +export const usePostEditor = () => { + const context = useContext(PostEditorContext); + if (!context) { + throw new Error("usePostEditor must be used within PostFormProvider"); + } + return context; +}; diff --git a/apps/web/src/components/models/post/PostEditor/layout/PostEditorLayout.tsx b/apps/web/src/components/models/post/PostEditor/layout/PostEditorLayout.tsx new file mode 100644 index 0000000..06e6fa6 --- /dev/null +++ b/apps/web/src/components/models/post/PostEditor/layout/PostEditorLayout.tsx @@ -0,0 +1,22 @@ +import { ReactNode, useEffect, useState } from "react"; +import { Outlet, useNavigate, useParams } from "react-router-dom"; + +import { motion } from "framer-motion"; +import { NavItem } from "@nice/client"; +import { PostFormProvider } from "../context/PostEditorContext"; + +export default function PostEditorLayout() { + const { id } = useParams(); + + return ( + <> + +
+
+ +
+
+
+ + ); +} diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 5d29743..5364c1d 100755 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -20,6 +20,7 @@ import CourseContentForm from "../components/models/course/editor/form/CourseCon import { CourseGoalForm } from "../components/models/course/editor/form/CourseGoalForm"; import CourseSettingForm from "../components/models/course/editor/form/CourseSettingForm"; import CourseEditorLayout from "../components/models/course/editor/layout/CourseEditorLayout"; +import PostEditorLayout from "../components/models/post/PostEditor/layout/PostEditorLayout"; interface CustomIndexRouteObject extends IndexRouteObject { name?: string; @@ -57,8 +58,22 @@ export const routes: CustomRouteObject[] = [ { index: true, element: , - } - + }, + ], + }, + { + path: "post", + children: [ + { + path: ":id?/editor", + element: , + children: [ + { + index: true, + element: , + }, + ], + }, ], }, { @@ -67,24 +82,29 @@ export const routes: CustomRouteObject[] = [ { path: ":id?/editor", element: , - children: [{ - index: true, - element: - }, - { - path: 'goal', - element: - }, - { - path: 'content', - element: - }, - { - path: 'setting', - element: - } - ] - } + children: [ + { + index: true, + element: , + }, + { + path: "goal", + element: , + }, + { + path: "content", + element: ( + + ), + }, + { + path: "setting", + element: ( + + ), + }, + ], + }, ], }, { From ce65bea0ed045fea6f168d8d05787d893daa5a60 Mon Sep 17 00:00:00 2001 From: ditiqi Date: Wed, 22 Jan 2025 23:19:51 +0800 Subject: [PATCH 2/2] add --- apps/server/src/auth/auth.guard.ts | 47 +- apps/server/src/models/staff/staff.router.ts | 14 +- apps/server/src/models/staff/staff.service.ts | 42 +- apps/server/src/upload/tus.service.ts | 202 +++++---- apps/server/src/upload/upload.controller.ts | 87 ++-- apps/web/src/app/main/letter/detail/page.tsx | 11 + apps/web/src/app/main/letter/editor/page.tsx | 16 + apps/web/src/app/main/letter/write/mock.ts | 53 +-- .../common/editor/quill/QuillCharCounter.tsx | 60 +-- .../components/common/form/FormCheckbox.tsx | 127 ++++++ .../src/components/common/form/FormInput.tsx | 255 ++++++----- .../components/common/form/FormQuillInput.tsx | 140 +++--- .../components/common/form/FormSignature.tsx | 158 +++++++ .../src/components/common/form/FormTags.tsx | 187 ++++++++ .../common/uploader/FileUploader.tsx | 414 ++++++++++-------- .../context/LetterEditorContext.tsx | 115 +++++ .../LetterEditor/form/LetterBasicForm.tsx | 178 ++++++++ .../layout/LetterEditorLayout.tsx | 100 +++++ .../PostEditor/context/PostEditorContext.tsx | 113 ----- .../PostEditor/layout/PostEditorLayout.tsx | 22 - .../post/detail/context/PostDetailContext.tsx | 54 +++ .../components/models/staff/staff-select.tsx | 10 +- apps/web/src/hooks/useTusUpload.ts | 58 +++ apps/web/src/routes/index.tsx | 25 +- packages/common/prisma/schema.prisma | 6 +- 25 files changed, 1717 insertions(+), 777 deletions(-) create mode 100644 apps/web/src/app/main/letter/detail/page.tsx create mode 100644 apps/web/src/app/main/letter/editor/page.tsx create mode 100644 apps/web/src/components/common/form/FormCheckbox.tsx create mode 100644 apps/web/src/components/common/form/FormSignature.tsx create mode 100644 apps/web/src/components/common/form/FormTags.tsx create mode 100644 apps/web/src/components/models/post/LetterEditor/context/LetterEditorContext.tsx create mode 100644 apps/web/src/components/models/post/LetterEditor/form/LetterBasicForm.tsx create mode 100644 apps/web/src/components/models/post/LetterEditor/layout/LetterEditorLayout.tsx delete mode 100644 apps/web/src/components/models/post/PostEditor/context/PostEditorContext.tsx delete mode 100644 apps/web/src/components/models/post/PostEditor/layout/PostEditorLayout.tsx create mode 100644 apps/web/src/components/models/post/detail/context/PostDetailContext.tsx create mode 100644 apps/web/src/hooks/useTusUpload.ts diff --git a/apps/server/src/auth/auth.guard.ts b/apps/server/src/auth/auth.guard.ts index e9fda52..5c8a455 100644 --- a/apps/server/src/auth/auth.guard.ts +++ b/apps/server/src/auth/auth.guard.ts @@ -1,8 +1,8 @@ import { - CanActivate, - ExecutionContext, - Injectable, - UnauthorizedException, + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { env } from '@server/env'; @@ -12,26 +12,21 @@ import { extractTokenFromHeader } from './utils'; @Injectable() export class AuthGuard implements CanActivate { - constructor(private jwtService: JwtService) { } - async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const token = extractTokenFromHeader(request); - if (!token) { - throw new UnauthorizedException(); - } - try { - const payload: JwtPayload = await this.jwtService.verifyAsync( - token, - { - secret: env.JWT_SECRET - } - ); - request['user'] = payload; - } catch { - throw new UnauthorizedException(); - } - return true; + constructor(private jwtService: JwtService) {} + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const token = extractTokenFromHeader(request); + if (!token) { + throw new UnauthorizedException(); } - - -} \ No newline at end of file + try { + const payload: JwtPayload = await this.jwtService.verifyAsync(token, { + secret: env.JWT_SECRET, + }); + request['user'] = payload; + } catch { + throw new UnauthorizedException(); + } + return true; + } +} diff --git a/apps/server/src/models/staff/staff.router.ts b/apps/server/src/models/staff/staff.router.ts index 37fe6fa..31ec09c 100755 --- a/apps/server/src/models/staff/staff.router.ts +++ b/apps/server/src/models/staff/staff.router.ts @@ -17,8 +17,8 @@ export class StaffRouter { constructor( private readonly trpc: TrpcService, private readonly staffService: StaffService, - private readonly staffRowService: StaffRowService - ) { } + private readonly staffRowService: StaffRowService, + ) {} router = this.trpc.router({ create: this.trpc.procedure @@ -35,7 +35,7 @@ export class StaffRouter { updateUserDomain: this.trpc.protectProcedure .input( z.object({ - domainId: z.string() + domainId: z.string(), }), ) .mutation(async ({ input, ctx }) => { @@ -72,8 +72,10 @@ export class StaffRouter { const { staff } = ctx; return await this.staffService.findFirst(input); }), - updateOrder: this.trpc.protectProcedure.input(UpdateOrderSchema).mutation(async ({ input }) => { - return this.staffService.updateOrder(input) - }), + updateOrder: this.trpc.protectProcedure + .input(UpdateOrderSchema) + .mutation(async ({ input }) => { + return this.staffService.updateOrder(input); + }), }); } diff --git a/apps/server/src/models/staff/staff.service.ts b/apps/server/src/models/staff/staff.service.ts index cf37549..1dbe0fc 100755 --- a/apps/server/src/models/staff/staff.service.ts +++ b/apps/server/src/models/staff/staff.service.ts @@ -14,7 +14,6 @@ import EventBus, { CrudOperation } from '@server/utils/event-bus'; @Injectable() export class StaffService extends BaseService { - constructor(private readonly departmentService: DepartmentService) { super(db, ObjectType.STAFF, true); } @@ -25,7 +24,10 @@ export class StaffService extends BaseService { */ async findByDept(data: z.infer) { const { deptId, domainId } = data; - const childDepts = await this.departmentService.getDescendantIds(deptId, true); + const childDepts = await this.departmentService.getDescendantIds( + deptId, + true, + ); const result = await db.staff.findMany({ where: { deptId: { in: childDepts }, @@ -50,7 +52,9 @@ export class StaffService extends BaseService { await this.validateUniqueFields(data, where.id); const updateData = { ...data, - ...(data.password && { password: await argon2.hash(data.password as string) }) + ...(data.password && { + password: await argon2.hash(data.password as string), + }), }; const result = await super.update({ ...args, data: updateData }); this.emitDataChangedEvent(result, CrudOperation.UPDATED); @@ -58,17 +62,26 @@ export class StaffService extends BaseService { } private async validateUniqueFields(data: any, excludeId?: string) { const uniqueFields = [ - { field: 'officerId', errorMsg: (val: string) => `证件号为${val}的用户已存在` }, - { field: 'phoneNumber', errorMsg: (val: string) => `手机号为${val}的用户已存在` }, - { field: 'username', errorMsg: (val: string) => `帐号为${val}的用户已存在` } + { + field: 'officerId', + errorMsg: (val: string) => `证件号为${val}的用户已存在`, + }, + { + field: 'phoneNumber', + errorMsg: (val: string) => `手机号为${val}的用户已存在`, + }, + { + field: 'username', + errorMsg: (val: string) => `帐号为${val}的用户已存在`, + }, ]; for (const { field, errorMsg } of uniqueFields) { if (data[field]) { const count = await db.staff.count({ where: { [field]: data[field], - ...(excludeId && { id: { not: excludeId } }) - } + ...(excludeId && { id: { not: excludeId } }), + }, }); if (count > 0) { throw new Error(errorMsg(data[field])); @@ -77,9 +90,8 @@ export class StaffService extends BaseService { } } - private emitDataChangedEvent(data: any, operation: CrudOperation) { - EventBus.emit("dataChanged", { + EventBus.emit('dataChanged', { type: this.objectType, operation, data, @@ -87,10 +99,10 @@ export class StaffService extends BaseService { } /** - * 更新员工DomainId - * @param data 包含domainId对象 - * @returns 更新后的员工记录 - */ + * 更新员工DomainId + * @param data 包含domainId对象 + * @returns 更新后的员工记录 + */ async updateUserDomain(data: { domainId?: string }, staff?: UserProfile) { let { domainId } = data; if (staff.domainId !== domainId) { @@ -107,7 +119,6 @@ export class StaffService extends BaseService { } } - // /** // * 根据关键词或ID集合查找员工 // * @param data 包含关键词、域ID和ID集合的对象 @@ -176,5 +187,4 @@ export class StaffService extends BaseService { // return combinedResults; // } - } diff --git a/apps/server/src/upload/tus.service.ts b/apps/server/src/upload/tus.service.ts index 0eea246..5b6961f 100644 --- a/apps/server/src/upload/tus.service.ts +++ b/apps/server/src/upload/tus.service.ts @@ -1,7 +1,7 @@ import { Injectable, OnModuleInit, Logger } from '@nestjs/common'; -import { Server, Uid, Upload } from "@nice/tus" +import { Server, Uid, Upload } from '@nice/tus'; import { FileStore } from '@nice/tus'; -import { Request, Response } from "express" +import { Request, Response } from 'express'; import { db, ResourceStatus } from '@nice/common'; import { getFilenameWithoutExt } from '@server/utils/file'; import { ResourceService } from '@server/models/resource/resource.service'; @@ -12,104 +12,120 @@ import { QueueJobType } from '@server/queue/types'; import { nanoid } from 'nanoid-cjs'; import { slugify } from 'transliteration'; const FILE_UPLOAD_CONFIG = { - directory: process.env.UPLOAD_DIR, - maxSizeBytes: 20_000_000_000, // 20GB - expirationPeriod: 24 * 60 * 60 * 1000 // 24 hours + directory: process.env.UPLOAD_DIR, + maxSizeBytes: 20_000_000_000, // 20GB + expirationPeriod: 24 * 60 * 60 * 1000, // 24 hours }; @Injectable() export class TusService implements OnModuleInit { - private readonly logger = new Logger(TusService.name); - private tusServer: Server; - constructor(private readonly resourceService: ResourceService, - @InjectQueue("file-queue") private fileQueue: Queue - ) { } - onModuleInit() { - this.initializeTusServer(); - this.setupTusEventHandlers(); - } - private initializeTusServer() { - this.tusServer = new Server({ - namingFunction(req, metadata) { - const safeFilename = slugify(metadata.filename); - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, '0'); - const day = String(now.getDate()).padStart(2, '0'); - const uniqueId = nanoid(10); - return `${year}/${month}/${day}/${uniqueId}/${safeFilename}`; - }, - path: '/upload', - datastore: new FileStore({ - directory: FILE_UPLOAD_CONFIG.directory, - expirationPeriodInMilliseconds: FILE_UPLOAD_CONFIG.expirationPeriod - }), - maxSize: FILE_UPLOAD_CONFIG.maxSizeBytes, - postReceiveInterval: 1000, - getFileIdFromRequest: (req, lastPath) => { - const match = req.url.match(/\/upload\/(.+)/); - return match ? match[1] : lastPath; - } - }); - } + private readonly logger = new Logger(TusService.name); + private tusServer: Server; + constructor( + private readonly resourceService: ResourceService, + @InjectQueue('file-queue') private fileQueue: Queue, + ) {} + onModuleInit() { + this.initializeTusServer(); + this.setupTusEventHandlers(); + } + private initializeTusServer() { + this.tusServer = new Server({ + namingFunction(req, metadata) { + const safeFilename = slugify(metadata.filename); + const now = new Date(); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, '0'); + const day = String(now.getDate()).padStart(2, '0'); + const uniqueId = nanoid(10); + return `${year}/${month}/${day}/${uniqueId}/${safeFilename}`; + }, + path: '/upload', + datastore: new FileStore({ + directory: FILE_UPLOAD_CONFIG.directory, + expirationPeriodInMilliseconds: FILE_UPLOAD_CONFIG.expirationPeriod, + }), + maxSize: FILE_UPLOAD_CONFIG.maxSizeBytes, + postReceiveInterval: 1000, + getFileIdFromRequest: (req, lastPath) => { + const match = req.url.match(/\/upload\/(.+)/); + return match ? match[1] : lastPath; + }, + }); + } - private setupTusEventHandlers() { - this.tusServer.on("POST_CREATE", this.handleUploadCreate.bind(this)); - this.tusServer.on("POST_FINISH", this.handleUploadFinish.bind(this)); + private setupTusEventHandlers() { + this.tusServer.on('POST_CREATE', this.handleUploadCreate.bind(this)); + this.tusServer.on('POST_FINISH', this.handleUploadFinish.bind(this)); + } + private getFileId(uploadId: string) { + return uploadId.replace(/\/[^/]+$/, ''); + } + private async handleUploadCreate( + req: Request, + res: Response, + upload: Upload, + url: string, + ) { + try { + const fileId = this.getFileId(upload.id); + const filename = upload.metadata.filename; + await this.resourceService.create({ + data: { + title: getFilenameWithoutExt(upload.metadata.filename), + fileId, // 移除最后的文件名 + url: upload.id, + metadata: upload.metadata, + status: ResourceStatus.UPLOADING, + }, + }); + } catch (error) { + this.logger.error('Failed to create resource during upload', error); } - private getFileId(uploadId: string) { - return uploadId.replace(/\/[^/]+$/, '') - } - private async handleUploadCreate(req: Request, res: Response, upload: Upload, url: string) { - try { + } - const fileId = this.getFileId(upload.id) - const filename = upload.metadata.filename - await this.resourceService.create({ - data: { - title: getFilenameWithoutExt(upload.metadata.filename), - fileId, // 移除最后的文件名 - url: upload.id, - metadata: upload.metadata, - status: ResourceStatus.UPLOADING - } - }); - } catch (error) { - this.logger.error('Failed to create resource during upload', error); - } + private async handleUploadFinish( + req: Request, + res: Response, + upload: Upload, + ) { + try { + const resource = await this.resourceService.update({ + where: { fileId: this.getFileId(upload.id) }, + data: { status: ResourceStatus.UPLOADED }, + }); + this.fileQueue.add( + QueueJobType.FILE_PROCESS, + { resource }, + { jobId: resource.id }, + ); + this.logger.log(`Upload finished ${resource.url}`); + } catch (error) { + this.logger.error('Failed to update resource after upload', error); } + } - private async handleUploadFinish(req: Request, res: Response, upload: Upload) { - try { - const resource = await this.resourceService.update({ - where: { fileId: this.getFileId(upload.id) }, - data: { status: ResourceStatus.UPLOADED } - }); - this.fileQueue.add(QueueJobType.FILE_PROCESS, { resource }, { jobId: resource.id }) - this.logger.log(`Upload finished ${resource.url}`); - } catch (error) { - this.logger.error('Failed to update resource after upload', error); - } + @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) + async cleanupExpiredUploads() { + try { + // Delete incomplete uploads older than 24 hours + const deletedResources = await db.resource.deleteMany({ + where: { + createdAt: { + lt: new Date(Date.now() - FILE_UPLOAD_CONFIG.expirationPeriod), + }, + status: ResourceStatus.UPLOADING, + }, + }); + const expiredUploadCount = await this.tusServer.cleanUpExpiredUploads(); + this.logger.log( + `Cleanup complete: ${deletedResources.count} resources and ${expiredUploadCount} uploads removed`, + ); + } catch (error) { + this.logger.error('Expired uploads cleanup failed', error); } + } - @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) - async cleanupExpiredUploads() { - try { - // Delete incomplete uploads older than 24 hours - const deletedResources = await db.resource.deleteMany({ - where: { - createdAt: { lt: new Date(Date.now() - FILE_UPLOAD_CONFIG.expirationPeriod) }, - status: ResourceStatus.UPLOADING - } - }); - const expiredUploadCount = await this.tusServer.cleanUpExpiredUploads(); - this.logger.log(`Cleanup complete: ${deletedResources.count} resources and ${expiredUploadCount} uploads removed`); - } catch (error) { - this.logger.error('Expired uploads cleanup failed', error); - } - } - - async handleTus(req: Request, res: Response) { - - return this.tusServer.handle(req, res); - } -} \ No newline at end of file + async handleTus(req: Request, res: Response) { + return this.tusServer.handle(req, res); + } +} diff --git a/apps/server/src/upload/upload.controller.ts b/apps/server/src/upload/upload.controller.ts index ff3e38b..f014c42 100644 --- a/apps/server/src/upload/upload.controller.ts +++ b/apps/server/src/upload/upload.controller.ts @@ -1,55 +1,54 @@ import { - Controller, - All, - Req, - Res, - Get, - Post, - Patch, - Param, - Delete, - Head, - Options, + Controller, + All, + Req, + Res, + Get, + Post, + Patch, + Param, + Delete, + Head, + Options, } from '@nestjs/common'; -import { Request, Response } from "express" +import { Request, Response } from 'express'; import { TusService } from './tus.service'; @Controller('upload') export class UploadController { - constructor(private readonly tusService: TusService) { } - // @Post() - // async handlePost(@Req() req: Request, @Res() res: Response) { - // return this.tusService.handleTus(req, res); - // } + constructor(private readonly tusService: TusService) {} + // @Post() + // async handlePost(@Req() req: Request, @Res() res: Response) { + // return this.tusService.handleTus(req, res); + // } + @Options() + async handleOptions(@Req() req: Request, @Res() res: Response) { + return this.tusService.handleTus(req, res); + } - @Options() - async handleOptions(@Req() req: Request, @Res() res: Response) { - return this.tusService.handleTus(req, res); - } + @Head() + async handleHead(@Req() req: Request, @Res() res: Response) { + return this.tusService.handleTus(req, res); + } - @Head() - async handleHead(@Req() req: Request, @Res() res: Response) { - return this.tusService.handleTus(req, res); - } + @Post() + async handlePost(@Req() req: Request, @Res() res: Response) { + return this.tusService.handleTus(req, res); + } + @Get('/*') + async handleGet(@Req() req: Request, @Res() res: Response) { + return this.tusService.handleTus(req, res); + } - @Post() - async handlePost(@Req() req: Request, @Res() res: Response) { - return this.tusService.handleTus(req, res); - } - @Get("/*") - async handleGet(@Req() req: Request, @Res() res: Response) { - return this.tusService.handleTus(req, res); - } + @Patch('/*') + async handlePatch(@Req() req: Request, @Res() res: Response) { + return this.tusService.handleTus(req, res); + } - @Patch("/*") - async handlePatch(@Req() req: Request, @Res() res: Response) { - return this.tusService.handleTus(req, res); - } - - // Keeping the catch-all method as a fallback - @All() - async handleUpload(@Req() req: Request, @Res() res: Response) { - return this.tusService.handleTus(req, res); - } -} \ No newline at end of file + // Keeping the catch-all method as a fallback + @All() + async handleUpload(@Req() req: Request, @Res() res: Response) { + return this.tusService.handleTus(req, res); + } +} diff --git a/apps/web/src/app/main/letter/detail/page.tsx b/apps/web/src/app/main/letter/detail/page.tsx new file mode 100644 index 0000000..c43c603 --- /dev/null +++ b/apps/web/src/app/main/letter/detail/page.tsx @@ -0,0 +1,11 @@ +import { useParams } from "react-router-dom"; + +export default function LetterDetailPage() { + const { id } = useParams(); + + return ( + <> +
{id}
+ + ); +} diff --git a/apps/web/src/app/main/letter/editor/page.tsx b/apps/web/src/app/main/letter/editor/page.tsx new file mode 100644 index 0000000..bece392 --- /dev/null +++ b/apps/web/src/app/main/letter/editor/page.tsx @@ -0,0 +1,16 @@ +import { motion } from "framer-motion"; +import LetterEditorLayout from "@web/src/components/models/post/LetterEditor/layout/LetterEditorLayout"; + +export default function EditorLetterPage() { + return ( +
+ + + +
+ ); +} diff --git a/apps/web/src/app/main/letter/write/mock.ts b/apps/web/src/app/main/letter/write/mock.ts index 9faf55a..1a7ff1d 100644 --- a/apps/web/src/app/main/letter/write/mock.ts +++ b/apps/web/src/app/main/letter/write/mock.ts @@ -1,28 +1,31 @@ import { Leader } from "./types"; export const leaders: Leader[] = [ - { - id: '1', - name: 'John Mitchell', - rank: 'General', - division: 'Air Combat Command', - imageUrl: 'https://th.bing.com/th/id/OIP.ea0spF2OAgI4I1KzgZFtTgHaHX?rs=1&pid=ImgDetMain', - email: 'j.mitchell@af.mil', - phone: '(555) 123-4567', - office: 'Pentagon, Wing A-123', - }, - { - id: '2', - name: 'Sarah Williams', - rank: 'Colonel', - division: 'Air Force Space Command', - imageUrl: 'https://th.bing.com/th/id/OIP.ea0spF2OAgI4I1KzgZFtTgHaHX?rs=1&pid=ImgDetMain', - }, - { - id: '3', - name: 'Michael Roberts', - rank: 'Major General', - division: 'Air Mobility Command', - imageUrl: 'https://th.bing.com/th/id/OIP.ea0spF2OAgI4I1KzgZFtTgHaHX?rs=1&pid=ImgDetMain', - }, -]; \ No newline at end of file + { + id: "1", + name: "John Mitchell", + rank: "General", + division: "Air Combat Command", + imageUrl: + "https://th.bing.com/th/id/OIP.ea0spF2OAgI4I1KzgZFtTgHaHX?rs=1&pid=ImgDetMain", + email: "j.mitchell@af.mil", + phone: "(555) 123-4567", + office: "Pentagon, Wing A-123", + }, + { + id: "2", + name: "Sarah Williams", + rank: "Colonel", + division: "Air Force Space Command", + imageUrl: + "https://th.bing.com/th/id/OIP.ea0spF2OAgI4I1KzgZFtTgHaHX?rs=1&pid=ImgDetMain", + }, + { + id: "3", + name: "Michael Roberts", + rank: "Major General", + division: "Air Mobility Command", + imageUrl: + "https://th.bing.com/th/id/OIP.ea0spF2OAgI4I1KzgZFtTgHaHX?rs=1&pid=ImgDetMain", + }, +]; diff --git a/apps/web/src/components/common/editor/quill/QuillCharCounter.tsx b/apps/web/src/components/common/editor/quill/QuillCharCounter.tsx index ed409b7..1ff0199 100644 --- a/apps/web/src/components/common/editor/quill/QuillCharCounter.tsx +++ b/apps/web/src/components/common/editor/quill/QuillCharCounter.tsx @@ -1,42 +1,44 @@ +import React from "react"; interface QuillCharCounterProps { - currentCount: number; - maxLength?: number; - minLength?: number; + currentCount: number; + maxLength?: number; + minLength?: number; } const QuillCharCounter: React.FC = ({ - currentCount, - maxLength, - minLength = 0 + currentCount, + maxLength, + minLength = 0, }) => { - const getStatusColor = () => { - if (currentCount > (maxLength || Infinity)) return 'text-red-500'; - if (currentCount < minLength) return 'text-amber-500'; - return 'text-gray-500'; - }; + const getStatusColor = () => { + if (currentCount > (maxLength || Infinity)) return "text-red-500"; + if (currentCount < minLength) return "text-amber-500"; + return "text-gray-500"; + }; - return ( -
- {currentCount} - {maxLength && ( - <> - / - {maxLength} - - )} - 字符 - {minLength > 0 && currentCount < minLength && ( - - 至少输入 {minLength} 字符 - - )} -
- ); + {currentCount} + {maxLength && ( + <> + / + {maxLength} + + )} + 字符 + {minLength > 0 && currentCount < minLength && ( + + 至少输入 {minLength} 字符 + + )} + + ); }; -export default QuillCharCounter \ No newline at end of file +export default QuillCharCounter; diff --git a/apps/web/src/components/common/form/FormCheckbox.tsx b/apps/web/src/components/common/form/FormCheckbox.tsx new file mode 100644 index 0000000..643df5c --- /dev/null +++ b/apps/web/src/components/common/form/FormCheckbox.tsx @@ -0,0 +1,127 @@ +import { useFormContext } from "react-hook-form"; +import React, { useState } from "react"; +import { motion } from "framer-motion"; +import { CheckIcon } from "@heroicons/react/24/outline"; + +export interface FormCheckboxProps + extends Omit, "type"> { + name: string; + label?: string; + viewMode?: boolean; +} + +export function FormCheckbox({ + name, + label, + className, + viewMode = false, + ...restProps +}: FormCheckboxProps) { + const [isHovered, setIsHovered] = useState(false); + const { + register, + formState: { errors }, + watch, + setValue, + } = useFormContext(); + + const value = watch(name); + const error = errors[name]?.message as string; + + const handleToggle = () => { + setValue(name, !value); + }; + + const checkboxClasses = ` + w-6 h-6 rounded border + transition-all duration-200 ease-out + flex items-center justify-center + cursor-pointer + ${ + value + ? "bg-[#00308F] border-[#00308F]" + : "bg-white border-gray-200 hover:border-gray-300" + } + ${error ? "border-red-500" : ""} + ${className || ""} + `; + + const labelClasses = ` + text-sm font-medium + ${error ? "text-red-500" : "text-gray-700"} + ${value ? "text-[#00308F]" : ""} + transition-colors duration-200 + `; + + const viewModeClasses = ` + w-full text-gray-700 hover:text-[#00308F] min-h-[48px] + flex items-center gap-2 relative cursor-pointer group + transition-all duration-200 ease-out select-none + `; + + const renderViewMode = () => ( +
+
+ {value && } +
+ {label} +
+ ); + + const renderEditMode = () => ( + setIsHovered(true)} + onHoverEnd={() => setIsHovered(false)} + className="relative inline-flex items-center gap-3"> + + + + {value && ( + + + + )} + + + {label && ( + + {label} + + )} + + {error && ( + + {error} + + )} + + ); + + return ( +
+ {viewMode ? renderViewMode() : renderEditMode()} +
+ ); +} diff --git a/apps/web/src/components/common/form/FormInput.tsx b/apps/web/src/components/common/form/FormInput.tsx index 325eeba..774e42c 100644 --- a/apps/web/src/components/common/form/FormInput.tsx +++ b/apps/web/src/components/common/form/FormInput.tsx @@ -1,148 +1,161 @@ -import { useFormContext } from 'react-hook-form'; -import { useRef, useState } from 'react'; -import { CheckIcon, PencilIcon, XMarkIcon } from '@heroicons/react/24/outline'; -import FormError from './FormError'; -import { Button } from '../element/Button'; -export interface FormInputProps extends Omit, 'type'> { - name: string; - label?: string; - type?: 'text' | 'textarea' | 'password' | 'email' | 'number' | 'tel' | 'url' | 'search' | 'date' | 'time' | 'datetime-local'; - rows?: number; - viewMode?: boolean; - +import { useFormContext } from "react-hook-form"; +import React, { useRef, useState } from "react"; +import { CheckIcon, PencilIcon, XMarkIcon } from "@heroicons/react/24/outline"; +import FormError from "./FormError"; +import { Button } from "../element/Button"; +export interface FormInputProps + extends Omit< + React.InputHTMLAttributes, + "type" + > { + name: string; + label?: string; + type?: + | "text" + | "textarea" + | "password" + | "email" + | "number" + | "tel" + | "url" + | "search" + | "date" + | "time" + | "datetime-local"; + rows?: number; + viewMode?: boolean; } export function FormInput({ - name, - label, - type = 'text', - rows = 4, - className, - viewMode = false, // 默认为编辑模式 - ...restProps + name, + label, + type = "text", + rows = 4, + className, + viewMode = false, // 默认为编辑模式 + ...restProps }: FormInputProps) { - const [isFocused, setIsFocused] = useState(false); - const [isEditing, setIsEditing] = useState(!viewMode); - const inputWrapper = useRef(null); - const { - register, - formState: { errors }, - watch, - setValue, - trigger, // Add trigger from useFormContext - } = useFormContext(); - const handleBlur = async () => { - setIsFocused(false); - await trigger(name); // Trigger validation for this field - if (viewMode) { - setIsEditing(false) - } - - }; - const value = watch(name); - const error = errors[name]?.message as string; - const isValid = value && !error; - const inputClasses = ` + const [isFocused, setIsFocused] = useState(false); + const [isEditing, setIsEditing] = useState(!viewMode); + const inputWrapper = useRef(null); + const { + register, + formState: { errors }, + watch, + setValue, + trigger, // Add trigger from useFormContext + } = useFormContext(); + const handleBlur = async () => { + setIsFocused(false); + await trigger(name); // Trigger validation for this field + if (viewMode) { + setIsEditing(false); + } + }; + const value = watch(name); + const error = errors[name]?.message as string; + const isValid = value && !error; + const inputClasses = ` w-full rounded-lg border bg-white px-4 py-2 outline-none transition-all duration-200 ease-out placeholder:text-gray-400 - ${error - ? 'border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-200' - : 'border-gray-200 hover:border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100' - } - ${isFocused ? 'ring-2 ring-opacity-50' : ''} - ${className || ''} + ${ + error + ? "border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-200" + : "border-gray-200 hover:border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100" + } + ${isFocused ? "ring-2 ring-opacity-50" : ""} + ${className || ""} `; - const viewModeClasses = ` + const viewModeClasses = ` w-full text-gray-700 hover:text-blue-600 min-h-[48px] flex items-center gap-2 relative cursor-pointer group transition-all duration-200 ease-out select-none `; - const InputElement = type === 'textarea' ? 'textarea' : 'input'; + const InputElement = type === "textarea" ? "textarea" : "input"; - const renderViewMode = () => ( -
setIsEditing(true)} - > - - {value || 点击编辑} - - +
+ ); - - - ); - - const renderEditMode = () => ( -
- setIsFocused(true)} - onBlur={handleBlur} - className={inputClasses} - aria-label={label} - autoFocus - /> -
+ setIsFocused(true)} + onBlur={handleBlur} + className={inputClasses} + aria-label={label} + autoFocus + /> +
- {value && isFocused && ( - - )} - {isValid && ( - + + + )} + {isValid && ( + - )} - {error && } -
-
- ); - return ( -
- {label &&
-
+
+ ); + return ( +
+ {label && ( +
+ - {restProps.maxLength && ( - - {value?.length || 0}/{restProps.maxLength} - - )} -
} - {viewMode && !isEditing ? renderViewMode() : renderEditMode()} -
- ); -} \ No newline at end of file + {label} + + {restProps.maxLength && ( + + {value?.length || 0}/{restProps.maxLength} + + )} +
+ )} + {viewMode && !isEditing ? renderViewMode() : renderEditMode()} + + ); +} diff --git a/apps/web/src/components/common/form/FormQuillInput.tsx b/apps/web/src/components/common/form/FormQuillInput.tsx index 7820f55..01bab4c 100644 --- a/apps/web/src/components/common/form/FormQuillInput.tsx +++ b/apps/web/src/components/common/form/FormQuillInput.tsx @@ -1,79 +1,83 @@ -import { useFormContext, Controller } from 'react-hook-form'; -import FormError from './FormError'; -import { useState } from 'react'; -import QuillEditor from '../editor/quill/QuillEditor'; +import { useFormContext, Controller } from "react-hook-form"; +import FormError from "./FormError"; +import { useState } from "react"; +import QuillEditor from "../editor/quill/QuillEditor"; export interface FormQuillInputProps { - name: string; - label: string; - placeholder?: string; - maxLength?: number; - minLength?: number; - className?: string; - readOnly?: boolean; - maxRows?: number; - minRows?: number; + name: string; + label?: string; + placeholder?: string; + maxLength?: number; + minLength?: number; + className?: string; + readOnly?: boolean; + maxRows?: number; + minRows?: number; } export function FormQuillInput({ - name, - label, - placeholder, - maxLength, - minLength, - className, - readOnly = false, - maxRows = 10, - minRows = 4 + name, + label, + placeholder, + maxLength, + minLength, + className, + readOnly = false, + maxRows = 10, + minRows = 4, }: FormQuillInputProps) { - const [isFocused, setIsFocused] = useState(false); - const { - control, - formState: { errors }, - trigger, - } = useFormContext(); + const [isFocused, setIsFocused] = useState(false); + const { + control, + formState: { errors }, + trigger, + } = useFormContext(); - const error = errors[name]?.message as string; + const error = errors[name]?.message as string; - const handleBlur = async () => { - - setIsFocused(false); - await trigger(name); - }; - console.log(isFocused) - const containerClasses = ` + const handleBlur = async () => { + setIsFocused(false); + await trigger(name); + }; + console.log(isFocused); + const containerClasses = ` w-full rounded-md border bg-white shadow-sm transition-all duration-300 ease-out - ${isFocused - ? `ring-2 ring-opacity-50 ${error ? 'ring-red-200 border-red-500' : 'ring-blue-200 border-blue-500'}` - : 'border-gray-300' - } + ${ + isFocused + ? `ring-2 ring-opacity-50 ${error ? "ring-red-200 border-red-500" : "ring-blue-200 border-blue-500"}` + : "border-gray-300" + } ${className} - `.trim() - return ( -
- -
- ( - setIsFocused(true)} - onBlur={handleBlur} - /> - )} - /> -
- -
- ); -} \ No newline at end of file + `.trim(); + return ( +
+ {label && ( + + )} +
+ ( + setIsFocused(true)} + onBlur={handleBlur} + /> + )} + /> +
+ +
+ ); +} diff --git a/apps/web/src/components/common/form/FormSignature.tsx b/apps/web/src/components/common/form/FormSignature.tsx new file mode 100644 index 0000000..af57284 --- /dev/null +++ b/apps/web/src/components/common/form/FormSignature.tsx @@ -0,0 +1,158 @@ +import { useFormContext } from "react-hook-form"; +import { motion } from "framer-motion"; +import { + PencilSquareIcon, + CheckIcon, + XMarkIcon, +} from "@heroicons/react/24/outline"; +import React, { useState } from "react"; + +export interface FormSignatureProps + extends Omit< + React.InputHTMLAttributes, + "type" + > { + name: string; + label?: string; + type?: + | "text" + | "textarea" + | "password" + | "email" + | "number" + | "tel" + | "url" + | "search" + | "date" + | "time" + | "datetime-local"; + viewMode?: boolean; + width?: string; // 新添加的属性 +} + +export function FormSignature({ + name, + label, + type = "text", + className, + viewMode = true, + placeholder = "添加您的个性签名...", + maxLength = 50, + width, + ...restProps +}: FormSignatureProps) { + const [isEditing, setIsEditing] = useState(!viewMode); + const { + register, + formState: { errors }, + watch, + setValue, + trigger, + } = useFormContext(); + + const value = watch(name); + const error = errors[name]?.message as string; + + const handleBlur = async () => { + await trigger(name); + if (viewMode) { + setIsEditing(false); + } + }; + + const inputClasses = ` + w-full h-8 px-3 text-sm bg-gray-50 + rounded-md border border-gray-200 + focus:outline-none focus:ring-2 focus:ring-[#00308F]/20 + focus:border-[#00308F] transition-all duration-200 + ${className || ""} + `; + + return ( + + { +
+ + {label && ( + {label} + )} +
+ } + +
+ {viewMode && !isEditing ? ( + setIsEditing(true)} + className="group flex items-center h-8 px-3 cursor-pointer + rounded-md border border-transparent hover:border-gray-200 + transition-all duration-200"> + {value ? ( + + {value} + + ) : ( + + {placeholder} + + )} + + + + + ) : ( +
+ +
+ {value && ( + setValue(name, "")} + className="p-1 rounded-full hover:bg-gray-200 text-gray-400 + hover:text-gray-600 transition-colors"> + + + )} + {value && !error && ( + + + + )} +
+
+ )} +
+ + {error && ( + + {error} + + )} +
+ ); +} diff --git a/apps/web/src/components/common/form/FormTags.tsx b/apps/web/src/components/common/form/FormTags.tsx new file mode 100644 index 0000000..4176bfa --- /dev/null +++ b/apps/web/src/components/common/form/FormTags.tsx @@ -0,0 +1,187 @@ +import { useFormContext } from "react-hook-form"; +import React, { useRef, useState, KeyboardEvent } from "react"; +import { + CheckIcon, + PencilIcon, + XMarkIcon, + PlusCircleIcon, +} from "@heroicons/react/24/outline"; +import FormError from "./FormError"; +import { Button } from "../element/Button"; + +export interface FormTagsProps + extends Omit, "type"> { + name: string; + label?: string; + viewMode?: boolean; + maxTags?: number; +} + +export function FormTags({ + name, + label, + className, + viewMode = false, + maxTags = 10, + ...restProps +}: FormTagsProps) { + const [isFocused, setIsFocused] = useState(false); + const [isEditing, setIsEditing] = useState(!viewMode); + const [inputValue, setInputValue] = useState(""); + const inputWrapper = useRef(null); + + const { + register, + formState: { errors }, + watch, + setValue, + trigger, + } = useFormContext(); + + const tags = watch(name) || []; + const error = errors[name]?.message as string; + const isValid = tags.length > 0 && !error; + + const handleAddTag = () => { + if (inputValue.trim() && tags.length < maxTags) { + const newTags = [...tags, inputValue.trim()]; + setValue(name, newTags); + setInputValue(""); + trigger(name); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddTag(); + } + }; + + const handleRemoveTag = (indexToRemove: number) => { + const newTags = tags.filter((_, index) => index !== indexToRemove); + setValue(name, newTags); + trigger(name); + }; + + const inputClasses = ` + w-full rounded-lg border bg-white px-4 py-2 outline-none + transition-all duration-200 ease-out placeholder:text-gray-400 + ${ + error + ? "border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-200" + : "border-gray-200 hover:border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-100" + } + ${isFocused ? "ring-2 ring-opacity-50" : ""} + ${className || ""} + `; + + const viewModeClasses = ` + w-full text-gray-700 hover:text-blue-600 min-h-[48px] + flex items-center gap-2 relative cursor-pointer group + transition-all duration-200 ease-out select-none + `; + + const renderTags = () => ( +
+ {tags.map((tag: string, index: number) => ( + + {tag} + {!viewMode && ( + handleRemoveTag(index)} + /> + )} + + ))} +
+ ); + + const renderViewMode = () => ( +
setIsEditing(true)}> + {tags.length > 0 ? ( + renderTags() + ) : ( + 点击编辑标签 + )} +
+ ); + + const renderEditMode = () => ( +
+ {renderTags()} +
+ setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + onFocus={() => setIsFocused(true)} + onBlur={() => { + setIsFocused(false); + if (viewMode) setIsEditing(false); + }} + className={inputClasses} + placeholder="输入标签后按回车添加" + aria-label={label} + {...restProps} + /> +
+ {inputValue && ( + + )} + {isValid && ( + + )} + {error && } +
+
+
+ {tags.length}/{maxTags} 个标签 +
+
+ ); + + return ( +
+ {label && ( + + )} + {viewMode && !isEditing ? renderViewMode() : renderEditMode()} +
+ ); +} diff --git a/apps/web/src/components/common/uploader/FileUploader.tsx b/apps/web/src/components/common/uploader/FileUploader.tsx index d7e19eb..3635063 100644 --- a/apps/web/src/components/common/uploader/FileUploader.tsx +++ b/apps/web/src/components/common/uploader/FileUploader.tsx @@ -1,211 +1,237 @@ -import { useState, useCallback, useRef, memo } from 'react' -import { CloudArrowUpIcon, XMarkIcon, DocumentIcon, ExclamationCircleIcon } from '@heroicons/react/24/outline' -import * as tus from 'tus-js-client' -import { motion, AnimatePresence } from 'framer-motion' -import { toast } from 'react-hot-toast' +// FileUploader.tsx +import React, { useRef, memo, useState } from "react"; +import { + CloudArrowUpIcon, + XMarkIcon, + DocumentIcon, + ExclamationCircleIcon, + CheckCircleIcon, +} from "@heroicons/react/24/outline"; +import { motion, AnimatePresence } from "framer-motion"; +import { toast } from "react-hot-toast"; +import { useTusUpload } from "@web/src/hooks/useTusUpload"; + interface FileUploaderProps { - endpoint?: string - onSuccess?: (url: string) => void - onError?: (error: Error) => void - maxSize?: number - allowedTypes?: string[] - placeholder?: string + endpoint?: string; + onSuccess?: (url: string) => void; + onError?: (error: Error) => void; + maxSize?: number; + allowedTypes?: string[]; + placeholder?: string; } -const FileItem = memo(({ file, progress, onRemove }: { - file: File - progress?: number - onRemove: (name: string) => void -}) => ( - - -
-
-

{file.name}

- -
- {progress !== undefined && ( -
-
- -
- {progress}% -
- )} -
-
-)) +interface FileItemProps { + file: File; + progress?: number; + onRemove: (name: string) => void; + isUploaded: boolean; +} -export default function FileUploader({ - endpoint = '', - onSuccess, - onError, - maxSize = 100, - placeholder = '点击或拖拽文件到这里上传', - allowedTypes = ['*/*'] -}: FileUploaderProps) { - const [isDragging, setIsDragging] = useState(false) - const [files, setFiles] = useState([]) - const [progress, setProgress] = useState<{ [key: string]: number }>({}) - const fileInputRef = useRef(null) +const FileItem: React.FC = memo( + ({ file, progress, onRemove, isUploaded }) => ( + + +
+
+

+ {file.name} +

+ +
+ {!isUploaded && progress !== undefined && ( +
+
+ +
+ + {progress}% + +
+ )} + {isUploaded && ( +
+ + 上传完成 +
+ )} +
+
+ ) +); - const handleError = useCallback((error: Error) => { - toast.error(error.message) - onError?.(error) - }, [onError]) +const FileUploader: React.FC = ({ + endpoint = "", + onSuccess, + onError, + maxSize = 100, + placeholder = "点击或拖拽文件到这里上传", + allowedTypes = ["*/*"], +}) => { + const [isDragging, setIsDragging] = useState(false); + const [files, setFiles] = useState< + Array<{ file: File; isUploaded: boolean }> + >([]); + const fileInputRef = useRef(null); - const handleDrag = useCallback((e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - if (e.type === 'dragenter' || e.type === 'dragover') { - setIsDragging(true) - } else if (e.type === 'dragleave') { - setIsDragging(false) - } - }, []) + const { progress, isUploading, uploadError, handleFileUpload } = + useTusUpload(); - const validateFile = useCallback((file: File) => { - if (file.size > maxSize * 1024 * 1024) { - throw new Error(`文件大小不能超过 ${maxSize}MB`) - } - if (!allowedTypes.includes('*/*') && !allowedTypes.includes(file.type)) { - throw new Error(`不支持的文件类型。支持的类型: ${allowedTypes.join(', ')}`) - } - }, [maxSize, allowedTypes]) + const handleError = (error: Error) => { + toast.error(error.message); + onError?.(error); + }; - const uploadFile = async (file: File) => { - try { - validateFile(file) + const handleDrag = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (e.type === "dragenter" || e.type === "dragover") { + setIsDragging(true); + } else if (e.type === "dragleave") { + setIsDragging(false); + } + }; - const upload = new tus.Upload(file, { - endpoint, - retryDelays: [0, 3000, 5000, 10000, 20000], - metadata: { - filename: file.name, - filetype: file.type - }, - onError: handleError, - onProgress: (bytesUploaded, bytesTotal) => { - const percentage = (bytesUploaded / bytesTotal * 100).toFixed(2) - setProgress(prev => ({ - ...prev, - [file.name]: parseFloat(percentage) - })) - }, - onSuccess: () => { - onSuccess?.(upload.url || '') - setProgress(prev => { - const newProgress = { ...prev } - delete newProgress[file.name] - return newProgress - }) - } - }) + const validateFile = (file: File) => { + if (file.size > maxSize * 1024 * 1024) { + throw new Error(`文件大小不能超过 ${maxSize}MB`); + } + if ( + !allowedTypes.includes("*/*") && + !allowedTypes.includes(file.type) + ) { + throw new Error( + `不支持的文件类型。支持的类型: ${allowedTypes.join(", ")}` + ); + } + }; - upload.start() - } catch (error) { - handleError(error as Error) - } - } + const uploadFile = (file: File) => { + try { + validateFile(file); + handleFileUpload( + file, + (upload) => { + onSuccess?.(upload.url || ""); + setFiles((prev) => + prev.map((f) => + f.file.name === file.name + ? { ...f, isUploaded: true } + : f + ) + ); + }, + handleError + ); + } catch (error) { + handleError(error as Error); + } + }; - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - setIsDragging(false) + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(false); - const droppedFiles = Array.from(e.dataTransfer.files) - setFiles(prev => [...prev, ...droppedFiles]) - droppedFiles.forEach(uploadFile) - }, []) + const droppedFiles = Array.from(e.dataTransfer.files); + setFiles((prev) => [ + ...prev, + ...droppedFiles.map((file) => ({ file, isUploaded: false })), + ]); + droppedFiles.forEach(uploadFile); + }; - const handleFileSelect = (e: React.ChangeEvent) => { - if (e.target.files) { - const selectedFiles = Array.from(e.target.files) - setFiles(prev => [...prev, ...selectedFiles]) - selectedFiles.forEach(uploadFile) - } - } + const handleFileSelect = (e: React.ChangeEvent) => { + if (e.target.files) { + const selectedFiles = Array.from(e.target.files); + setFiles((prev) => [ + ...prev, + ...selectedFiles.map((file) => ({ file, isUploaded: false })), + ]); + selectedFiles.forEach(uploadFile); + } + }; - const removeFile = (fileName: string) => { - setFiles(prev => prev.filter(file => file.name !== fileName)) - setProgress(prev => { - const newProgress = { ...prev } - delete newProgress[fileName] - return newProgress - }) - } + const removeFile = (fileName: string) => { + setFiles((prev) => prev.filter(({ file }) => file.name !== fileName)); + }; - return ( -
- fileInputRef.current?.click()} - aria-label="文件上传区域" - > - + const handleClick = () => { + fileInputRef.current?.click(); + }; -
- - - -
-

{placeholder}

-
-

- - 支持的文件类型: {allowedTypes.join(', ')} · 最大文件大小: {maxSize}MB -

-
-
+ return ( +
+
+ + +

{placeholder}

+ {isDragging && ( +
+

+ 释放文件以上传 +

+
+ )} +
- -
- {files.map(file => ( - - ))} -
-
-
- ) -} \ No newline at end of file + +
+ {files.map(({ file, isUploaded }) => ( + + ))} +
+
+ + {uploadError && ( +
+ + {uploadError} +
+ )} +
+ ); +}; + +export default FileUploader; diff --git a/apps/web/src/components/models/post/LetterEditor/context/LetterEditorContext.tsx b/apps/web/src/components/models/post/LetterEditor/context/LetterEditorContext.tsx new file mode 100644 index 0000000..32f2969 --- /dev/null +++ b/apps/web/src/components/models/post/LetterEditor/context/LetterEditorContext.tsx @@ -0,0 +1,115 @@ +import { createContext, useContext, ReactNode, useEffect } from "react"; +import { useForm, FormProvider, SubmitHandler } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { api, usePost } from "@nice/client"; +// import { PostDto, PostLevel, PostStatus } from "@nice/common"; +// import { api, usePost } from "@nice/client"; +import toast from "react-hot-toast"; +import { useNavigate } from "react-router-dom"; +import { Post, PostType } from "@nice/common"; +// 定义帖子表单验证 Schema + +const letterSchema = z.object({ + title: z.string().min(1, "标题不能为空"), + content: z.string().min(1, "内容不能为空"), + resources: z.array(z.string()).nullish(), + isPublic: z.boolean().nullish(), + signature: z.string().nullish(), + meta: z + .object({ + tags: z.array(z.string()).default([]), + signature: z.string().nullish(), + }) + .default({ + tags: [], + signature: null, + }), +}); +// 定义课程表单验证 Schema + +export type LetterFormData = z.infer; +interface LetterEditorContextType { + onSubmit: SubmitHandler; + receiverId?: string; + termId?: string; + part?: string; + // course?: PostDto; +} +interface LetterFormProviderProps { + children: ReactNode; + receiverId?: string; + termId?: string; + part?: string; +} +const LetterEditorContext = createContext(null); +export function LetterFormProvider({ + children, + receiverId, + termId, + // editId, +}: LetterFormProviderProps) { + const { create } = usePost(); + const navigate = useNavigate(); + const methods = useForm({ + resolver: zodResolver(letterSchema), + defaultValues: { + resources: [], + meta: { + tags: [], + }, + }, + }); + const onSubmit: SubmitHandler = async ( + data: LetterFormData + ) => { + try { + const result = await create.mutateAsync({ + data: { + type: PostType.POST, + termId: termId, + receivers: { + connect: [receiverId].filter(Boolean).map((id) => ({ + id, + })), + }, + ...data, + resources: data.resources?.length + ? { + connect: data.resources.map((id) => ({ + id, + })), + } + : undefined, + }, + }); + // navigate(`/course/${result.id}/editor`, { replace: true }); + toast.success("发送成功!"); + + methods.reset(data); + } catch (error) { + console.error("Error submitting form:", error); + toast.error("操作失败,请重试!"); + } + }; + return ( + + {children} + + ); +} + +export const useLetterEditor = () => { + const context = useContext(LetterEditorContext); + if (!context) { + throw new Error( + "useLetterEditor must be used within LetterFormProvider" + ); + } + return context; +}; diff --git a/apps/web/src/components/models/post/LetterEditor/form/LetterBasicForm.tsx b/apps/web/src/components/models/post/LetterEditor/form/LetterBasicForm.tsx new file mode 100644 index 0000000..64a6b73 --- /dev/null +++ b/apps/web/src/components/models/post/LetterEditor/form/LetterBasicForm.tsx @@ -0,0 +1,178 @@ +import { useFormContext } from "react-hook-form"; +import { motion } from "framer-motion"; +import { + LetterFormData, + useLetterEditor, +} from "../context/LetterEditorContext"; +import { FormInput } from "@web/src/components/common/form/FormInput"; +import { FormQuillInput } from "@web/src/components/common/form/FormQuillInput"; +import { api } from "@nice/client"; +import { + UserIcon, + FolderIcon, + HashtagIcon, + DocumentTextIcon, +} from "@heroicons/react/24/outline"; +import FileUploader from "@web/src/components/common/uploader/FileUploader"; +import { FormTags } from "@web/src/components/common/form/FormTags"; +import { FormSignature } from "@web/src/components/common/form/FormSignature"; +import { FormCheckbox } from "@web/src/components/common/form/FormCheckbox"; + +export function LetterBasicForm() { + const { + handleSubmit, + getValues, + formState: { errors }, + } = useFormContext(); + const { onSubmit, receiverId, termId } = useLetterEditor(); + const { data: receiver } = api.staff.findFirst.useQuery( + { + where: { + id: receiverId, + }, + }, + { + enabled: !!receiverId, + } + ); + const { data: term } = api.term.findFirst.useQuery( + { + where: { id: termId }, + }, + { enabled: !!termId } + ); + + const formControls = { + hidden: { opacity: 0, y: 20 }, + visible: (i: number) => ({ + opacity: 1, + y: 0, + transition: { + delay: i * 0.2, + duration: 0.5, + }, + }), + }; + + return ( + + {/* 收件人 */} + { + + +
收件人:{receiver?.showname}
+
+ } + {/* 选择板块 */} + { + + +
板块:{term?.name}
+
+ } + {/* 主题输入框 */} + + + + + {/* 标签输入 */} + + + + + {/* 内容输入框 */} + + + + + + + + + + + + + + + + 发送信件 + + +
+ ); +} diff --git a/apps/web/src/components/models/post/LetterEditor/layout/LetterEditorLayout.tsx b/apps/web/src/components/models/post/LetterEditor/layout/LetterEditorLayout.tsx new file mode 100644 index 0000000..86309fd --- /dev/null +++ b/apps/web/src/components/models/post/LetterEditor/layout/LetterEditorLayout.tsx @@ -0,0 +1,100 @@ +import { useLocation, useParams } from "react-router-dom"; +import { motion } from "framer-motion"; +import { PaperAirplaneIcon } from "@heroicons/react/24/outline"; +import { LetterFormProvider } from "../context/LetterEditorContext"; +import { LetterBasicForm } from "../form/LetterBasicForm"; + +export default function LetterEditorLayout() { + const location = useLocation(); + const params = new URLSearchParams(location.search); + + const receiverId = params.get("receiverId"); + const termId = params.get("termId"); + + return ( + +
+
+ + +

撰写信件

+
+ + {/* 隐私保护说明 */} +
+
+ + + + 个人信息严格保密 +
+
+ + + + 支持匿名反映问题 +
+
+ + + + 网络信息加密存储 +
+
+ + {/* 隐私承诺 */} +
+

+ 我们承诺:您的个人信息将被严格保密,不向任何第三方透露。您可以选择匿名反映问题,平台会自动过滤可能暴露身份的信息。 +

+
+
+
+ +
+ + + +
+
+ ); +} diff --git a/apps/web/src/components/models/post/PostEditor/context/PostEditorContext.tsx b/apps/web/src/components/models/post/PostEditor/context/PostEditorContext.tsx deleted file mode 100644 index 2f6bd5c..0000000 --- a/apps/web/src/components/models/post/PostEditor/context/PostEditorContext.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { createContext, useContext, ReactNode, useEffect } from "react"; -import { useForm, FormProvider, SubmitHandler } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import { api, usePost } from "@nice/client"; -// import { PostDto, PostLevel, PostStatus } from "@nice/common"; -// import { api, usePost } from "@nice/client"; -import toast from "react-hot-toast"; -import { useNavigate } from "react-router-dom"; -import { Post, PostType } from "@nice/common"; -// 定义帖子表单验证 Schema - -const postSchema = z.object({ - title: z.string().min(1, "标题不能为空"), - content: z.string().min(1, "内容不能为空"), - resources: z.array(z.string()).nullish(), - isPublic: z.boolean(), - signature: z.string().nullish(), -}); -// 定义课程表单验证 Schema - -export type PostFormData = z.infer; -interface PostEditorContextType { - onSubmit: SubmitHandler; - editId?: string; // 添加 editId - part?: string; - // course?: PostDto; -} -interface PostFormProviderProps { - children: ReactNode; - editId?: string; // 添加 editId 参数 - part?: string; -} -const PostEditorContext = createContext(null); -export function PostFormProvider({ children, editId }: PostFormProviderProps) { - const { create, update } = usePost(); - const { data: post }: { data: Post } = api.post.findById.useQuery( - { - id: editId, - }, - { enabled: !!editId } - ); - const navigate = useNavigate(); - const methods = useForm({ - resolver: zodResolver(postSchema), - defaultValues: { - resources: [], - }, - }); - useEffect(() => { - if (post) { - const formData = { - title: post.title, - content: post.content, - signature: (post.meta as any)?.signature, - }; - methods.reset(formData as any); - } - }, [post, methods]); - const onSubmit: SubmitHandler = async ( - data: PostFormData - ) => { - try { - if (editId) { - // await update.mutateAsync({ - // where: { id: editId }, - // data: { - // ...data - // } - // }) - toast.success("课程更新成功!"); - } else { - const result = await create.mutateAsync({ - data: { - type: PostType.POST, - ...data, - resources: data.resources?.length - ? { - connect: data.resources.map((id) => ({ - id, - })), - } - : undefined, - }, - }); - // navigate(`/course/${result.id}/editor`, { replace: true }); - toast.success("发送成功!"); - } - methods.reset(data); - } catch (error) { - console.error("Error submitting form:", error); - toast.error("操作失败,请重试!"); - } - }; - return ( - - {children} - - ); -} - -export const usePostEditor = () => { - const context = useContext(PostEditorContext); - if (!context) { - throw new Error("usePostEditor must be used within PostFormProvider"); - } - return context; -}; diff --git a/apps/web/src/components/models/post/PostEditor/layout/PostEditorLayout.tsx b/apps/web/src/components/models/post/PostEditor/layout/PostEditorLayout.tsx deleted file mode 100644 index 06e6fa6..0000000 --- a/apps/web/src/components/models/post/PostEditor/layout/PostEditorLayout.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { ReactNode, useEffect, useState } from "react"; -import { Outlet, useNavigate, useParams } from "react-router-dom"; - -import { motion } from "framer-motion"; -import { NavItem } from "@nice/client"; -import { PostFormProvider } from "../context/PostEditorContext"; - -export default function PostEditorLayout() { - const { id } = useParams(); - - return ( - <> - -
-
- -
-
-
- - ); -} diff --git a/apps/web/src/components/models/post/detail/context/PostDetailContext.tsx b/apps/web/src/components/models/post/detail/context/PostDetailContext.tsx new file mode 100644 index 0000000..974d390 --- /dev/null +++ b/apps/web/src/components/models/post/detail/context/PostDetailContext.tsx @@ -0,0 +1,54 @@ +import { api, usePost } from "@nice/client"; +import {} from "@nice/common"; +import React, { createContext, ReactNode, useState } from "react"; +import { string } from "zod"; + +interface PostDetailContextType { + editId?: string; // 添加 editId + course?: CourseDto; + selectedLectureId?: string | undefined; + setSelectedLectureId?: React.Dispatch>; + isLoading?: boolean; + isHeaderVisible: boolean; // 新增 + setIsHeaderVisible: (visible: boolean) => void; // 新增 +} +interface CourseFormProviderProps { + children: ReactNode; + editId?: string; // 添加 editId 参数 +} +export const CourseDetailContext = + createContext(null); +export function CourseDetailProvider({ + children, + editId, +}: CourseFormProviderProps) { + const { data: course, isLoading }: { data: CourseDto; isLoading: boolean } = + api.course.findFirst.useQuery( + { + where: { id: editId }, + include: { + sections: { include: { lectures: true } }, + enrollments: true, + }, + }, + { enabled: Boolean(editId) } + ); + const [selectedLectureId, setSelectedLectureId] = useState< + string | undefined + >(undefined); + const [isHeaderVisible, setIsHeaderVisible] = useState(true); // 新增 + return ( + + {children} + + ); +} diff --git a/apps/web/src/components/models/staff/staff-select.tsx b/apps/web/src/components/models/staff/staff-select.tsx index 5881f2a..d6721b1 100644 --- a/apps/web/src/components/models/staff/staff-select.tsx +++ b/apps/web/src/components/models/staff/staff-select.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from "react"; import { Button, Select, Spin } from "antd"; import type { SelectProps } from "antd"; import { api } from "@nice/client"; +import React from "react"; interface StaffSelectProps { value?: string | string[]; onChange?: (value: string | string[]) => void; @@ -42,16 +43,15 @@ export default function StaffSelect({ }, { id: { - in: ids - } - } + in: ids, + }, + }, ], domainId, - }, select: { id: true, showname: true, username: true }, take: 30, - orderBy: { order: "asc" } + orderBy: { order: "asc" }, }); const handleSearch = (value: string) => { diff --git a/apps/web/src/hooks/useTusUpload.ts b/apps/web/src/hooks/useTusUpload.ts new file mode 100644 index 0000000..c9afb1f --- /dev/null +++ b/apps/web/src/hooks/useTusUpload.ts @@ -0,0 +1,58 @@ +// useTusUpload.ts +import { useState } from "react"; +import * as tus from "tus-js-client"; + +interface UploadResult { + url?: string; +} + +export function useTusUpload() { + const [progress, setProgress] = useState(0); + const [isUploading, setIsUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); + + const handleFileUpload = ( + file: File, + onSuccess: (result: UploadResult) => void, + onError: (error: Error) => void + ) => { + setIsUploading(true); + setProgress(0); + setUploadError(null); + + const upload = new tus.Upload(file, { + endpoint: "http://localhost:3000/upload", // 替换为实际的上传端点 + retryDelays: [0, 1000, 3000, 5000], + metadata: { + filename: file.name, + filetype: file.type, + }, + onProgress: (bytesUploaded, bytesTotal) => { + const uploadProgress = ( + (bytesUploaded / bytesTotal) * + 100 + ).toFixed(2); + setProgress(Number(uploadProgress)); + }, + onSuccess: () => { + setIsUploading(false); + setProgress(100); + onSuccess({ url: upload.url }); + }, + onError: (error) => { + setIsUploading(false); + setUploadError(error.message); + onError(error); + }, + }); + + upload.start(); + }; + + return { + progress, + isUploading, + uploadError, + handleFileUpload, + }; +} diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index e257d35..1c75339 100755 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -20,10 +20,13 @@ import CourseContentForm from "../components/models/course/editor/form/CourseCon import { CourseGoalForm } from "../components/models/course/editor/form/CourseGoalForm"; import CourseSettingForm from "../components/models/course/editor/form/CourseSettingForm"; import CourseEditorLayout from "../components/models/course/editor/layout/CourseEditorLayout"; -import PostEditorLayout from "../components/models/post/PostEditor/layout/PostEditorLayout"; import WriteLetterPage from "../app/main/letter/write/page"; import LetterListPage from "../app/main/letter/list/page"; import React from "react"; +import LetterEditorLayout from "../components/models/post/LetterEditor/layout/LetterEditorLayout"; +import { LetterBasicForm } from "../components/models/post/LetterEditor/form/LetterBasicForm"; +import EditorLetterPage from "../app/main/letter/editor/page"; +import LetterDetailPage from "../app/main/letter/detail/page"; interface CustomIndexRouteObject extends IndexRouteObject { name?: string; @@ -66,20 +69,13 @@ export const routes: CustomRouteObject[] = [ index: true, element: , }, - ], - }, - { - path: "post", - children: [ { - path: ":id?/editor", - element: , - children: [ - { - index: true, - element: , - }, - ], + path: ":id?/detail", + element: , + }, + { + path: "editor", + element: , }, { path: "write-letter", @@ -91,6 +87,7 @@ export const routes: CustomRouteObject[] = [ }, ], }, + { path: "course", children: [ diff --git a/packages/common/prisma/schema.prisma b/packages/common/prisma/schema.prisma index 041f28a..077f689 100644 --- a/packages/common/prisma/schema.prisma +++ b/packages/common/prisma/schema.prisma @@ -27,11 +27,13 @@ model Taxonomy { model Term { id String @id @default(cuid()) name String + posts Post[] taxonomy Taxonomy? @relation(fields: [taxonomyId], references: [id]) taxonomyId String? @map("taxonomy_id") order Float? @map("order") description String? parentId String? @map("parent_id") + parent Term? @relation("ChildParent", fields: [parentId], references: [id], onDelete: Cascade) children Term[] @relation("ChildParent") ancestors TermAncestry[] @relation("DescendantToAncestor") @@ -188,6 +190,8 @@ model Post { title String? // 帖子标题,可为空 content String? // 帖子内容,可为空 domainId String? @map("domain_id") + term Term? @relation(fields: [termId], references: [id]) + termId String? @map("term_id") // 日期时间类型字段 createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -202,7 +206,7 @@ model Post { children Post[] @relation("PostChildren") // 子级帖子列表,关联 Post 模型 resources Resource[] // 附件列表 isPublic Boolean? @default(false) @map("is_public") - meta Json? // 签名 和 IP + meta Json? // 签名 和 IP 和 tags // 复合索引 @@index([type, domainId]) // 类型和域组合查询