diff --git a/apps/server/src/models/base/base.service.ts b/apps/server/src/models/base/base.service.ts index 41f7b0e..c6e8ad7 100644 --- a/apps/server/src/models/base/base.service.ts +++ b/apps/server/src/models/base/base.service.ts @@ -39,7 +39,7 @@ export class BaseService< protected prisma: PrismaClient, protected objectType: string, protected enableOrder: boolean = false, - ) { } + ) {} /** * Retrieves the name of the model dynamically. @@ -452,8 +452,12 @@ export class BaseService< pageSize?: number; where?: WhereArgs; select?: SelectArgs; - orderBy?: OrderByArgs - }): Promise<{ items: R['findMany']; totalPages: number, totalCount: number }> { + orderBy?: OrderByArgs; + }): Promise<{ + items: R['findMany']; + totalPages: number; + totalCount: number; + }> { const { page = 1, pageSize = 10, where, select, orderBy } = args; try { @@ -473,7 +477,7 @@ export class BaseService< return { items, totalPages, - totalCount: total + totalCount: total, }; } catch (error) { this.handleError(error, 'read'); diff --git a/apps/server/src/models/base/base.type.ts b/apps/server/src/models/base/base.type.ts index 878dffe..01254df 100644 --- a/apps/server/src/models/base/base.type.ts +++ b/apps/server/src/models/base/base.type.ts @@ -1,25 +1,27 @@ -import { db, Prisma, PrismaClient } from "@nice/common"; +import { db, Prisma, PrismaClient } from '@nice/common'; export type Operations = - | 'aggregate' - | 'count' - | 'create' - | 'createMany' - | 'delete' - | 'deleteMany' - | 'findFirst' - | 'findMany' - | 'findUnique' - | 'update' - | 'updateMany' - | 'upsert'; -export type DelegateFuncs = { [K in Operations]: (args: any) => Promise } + | 'aggregate' + | 'count' + | 'create' + | 'createMany' + | 'delete' + | 'deleteMany' + | 'findFirst' + | 'findMany' + | 'findUnique' + | 'update' + | 'updateMany' + | 'upsert'; +export type DelegateFuncs = { + [K in Operations]: (args: any) => Promise; +}; export type DelegateArgs = { - [K in keyof T]: T[K] extends (args: infer A) => Promise ? A : never; + [K in keyof T]: T[K] extends (args: infer A) => Promise ? A : never; }; export type DelegateReturnTypes = { - [K in keyof T]: T[K] extends (args: any) => Promise ? R : never; + [K in keyof T]: T[K] extends (args: any) => Promise ? R : never; }; export type WhereArgs = T extends { where?: infer W } ? W : never; @@ -28,17 +30,17 @@ export type DataArgs = T extends { data: infer D } ? D : never; export type IncludeArgs = T extends { include: infer I } ? I : never; export type OrderByArgs = T extends { orderBy: infer O } ? O : never; export type UpdateOrderArgs = { - id: string - overId: string -} + id: string; + overId: string; +}; export interface FindManyWithCursorType { - cursor?: string; - limit?: number; - where?: WhereArgs['findUnique']>; - select?: SelectArgs['findUnique']>; - orderBy?: OrderByArgs['findMany']> + cursor?: string; + limit?: number; + where?: WhereArgs['findUnique']>; + select?: SelectArgs['findUnique']>; + orderBy?: OrderByArgs['findMany']>; } export type TransactionType = Omit< - PrismaClient, - '$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends' ->; \ No newline at end of file + PrismaClient, + '$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends' +>; diff --git a/apps/server/src/models/post/post.router.ts b/apps/server/src/models/post/post.router.ts index c536977..e94dd57 100755 --- a/apps/server/src/models/post/post.router.ts +++ b/apps/server/src/models/post/post.router.ts @@ -12,13 +12,13 @@ const PostDeleteManyArgsSchema: ZodType = z.any(); const PostWhereInputSchema: ZodType = z.any(); const PostSelectSchema: ZodType = z.any(); const PostUpdateInputSchema: ZodType = z.any(); -const PostOrderBySchema: ZodType = z.any() +const PostOrderBySchema: ZodType = z.any(); @Injectable() export class PostRouter { constructor( private readonly trpc: TrpcService, private readonly postService: PostService, - ) { } + ) {} router = this.trpc.router({ create: this.trpc.protectProcedure .input(PostCreateArgsSchema) @@ -104,7 +104,7 @@ export class PostRouter { pageSize: z.number().optional(), where: PostWhereInputSchema.optional(), select: PostSelectSchema.optional(), - orderBy: PostOrderBySchema.optional() + orderBy: PostOrderBySchema.optional(), }), ) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword .query(async ({ input, ctx }) => { diff --git a/apps/server/src/models/post/post.service.ts b/apps/server/src/models/post/post.service.ts index c599add..089f686 100755 --- a/apps/server/src/models/post/post.service.ts +++ b/apps/server/src/models/post/post.service.ts @@ -101,22 +101,31 @@ export class PostService extends BaseService { }); } async findManyWithPagination( - args: { page?: number; pageSize?: number; where?: Prisma.PostWhereInput; select?: Prisma.PostSelect; orderBy?: Prisma.PostOrderByWithRelationInput }, + args: { + page?: number; + pageSize?: number; + where?: Prisma.PostWhereInput; + select?: Prisma.PostSelect; + orderBy?: Prisma.PostOrderByWithRelationInput; + }, staff?: UserProfile, clientIp?: string, ) { if (!args.where) args.where = {}; args.where.OR = await this.preFilter(args.where.OR, staff); - return this.wrapResult(super.findManyWithPagination(args as any), async (result) => { - const { items } = result; - await Promise.all( - items.map(async (item) => { - await setPostRelation({ data: item, staff, clientIp }); - await this.setPerms(item, staff); - }), - ); - return { ...result, items }; - }); + return this.wrapResult( + super.findManyWithPagination(args as any), + async (result) => { + const { items } = result; + await Promise.all( + items.map(async (item) => { + await setPostRelation({ data: item, staff, clientIp }); + await this.setPerms(item, staff); + }), + ); + return { ...result, items }; + }, + ); } protected async setPerms(data: Post, staff?: UserProfile) { if (!staff) return; diff --git a/apps/server/src/models/staff/staff.router.ts b/apps/server/src/models/staff/staff.router.ts index c8d73dd..80e5bdc 100755 --- a/apps/server/src/models/staff/staff.router.ts +++ b/apps/server/src/models/staff/staff.router.ts @@ -12,13 +12,15 @@ const StaffWhereInputSchema: ZodType = z.any(); const StaffSelectSchema: ZodType = z.any(); const StaffUpdateInputSchema: ZodType = z.any(); const StaffFindManyArgsSchema: ZodType = z.any(); +const StaffOrderBySchema: ZodType = + z.any(); @Injectable() export class StaffRouter { constructor( private readonly trpc: TrpcService, private readonly staffService: StaffService, private readonly staffRowService: StaffRowService, - ) { } + ) {} router = this.trpc.router({ create: this.trpc.procedure @@ -78,12 +80,15 @@ export class StaffRouter { return this.staffService.updateOrder(input); }), findManyWithPagination: this.trpc.procedure - .input(z.object({ - page: z.number(), - pageSize: z.number().optional(), - where: StaffWhereInputSchema.optional(), - select: StaffSelectSchema.optional() - })) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword + .input( + z.object({ + page: z.number(), + pageSize: z.number().optional(), + where: StaffWhereInputSchema.optional(), + select: StaffSelectSchema.optional(), + orderBy: StaffOrderBySchema.optional(), + }), + ) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword .query(async ({ input }) => { return await this.staffService.findManyWithPagination(input); }), diff --git a/apps/server/src/models/staff/staff.service.ts b/apps/server/src/models/staff/staff.service.ts index a2d69fe..1d5850a 100755 --- a/apps/server/src/models/staff/staff.service.ts +++ b/apps/server/src/models/staff/staff.service.ts @@ -105,7 +105,7 @@ export class StaffService extends BaseService { * @returns 更新后的员工记录 */ async updateUserDomain(data: { domainId?: string }, staff?: UserProfile) { - let { domainId } = data; + const { domainId } = data; if (staff.domainId !== domainId) { const result = await this.update({ where: { id: staff.id }, @@ -120,14 +120,23 @@ export class StaffService extends BaseService { } } - async findManyWithPagination(args: { page?: number; pageSize?: number; where?: Prisma.StaffWhereInput; select?: Prisma.StaffSelect; }) { + async findManyWithPagination(args: { + page?: number; + pageSize?: number; + where?: Prisma.StaffWhereInput; + select?: Prisma.StaffSelect; + orderBy?: Prisma.StaffOrderByWithRelationInput; + }) { if (args.where.deptId && typeof args.where.deptId === 'string') { - const childDepts = await this.departmentService.getDescendantIds(args.where.deptId, true); + const childDepts = await this.departmentService.getDescendantIds( + args.where.deptId, + true, + ); args.where.deptId = { - in: childDepts - } + in: childDepts, + }; } - - return super.findManyWithPagination(args) + + return super.findManyWithPagination(args as any); } } diff --git a/apps/server/src/queue/types.ts b/apps/server/src/queue/types.ts index d986daa..88acd7f 100755 --- a/apps/server/src/queue/types.ts +++ b/apps/server/src/queue/types.ts @@ -1,4 +1,4 @@ -import { VisitType } from 'packages/common/dist'; +import { VisitType } from '@nice/common'; export enum QueueJobType { UPDATE_STATS = 'update_stats', FILE_PROCESS = 'file_process', diff --git a/apps/web/src/app/main/letter/inbox/Header.tsx b/apps/web/src/app/main/letter/inbox/Header.tsx new file mode 100644 index 0000000..3189c55 --- /dev/null +++ b/apps/web/src/app/main/letter/inbox/Header.tsx @@ -0,0 +1,74 @@ +export function Header() { + return ( +
+
+ {/* 主标题区域 */} +
+

+ 我收到的信件 +

+

+ 及时查看 • 快速处理 • 高效反馈 +

+
+ + {/* 服务特点说明 */} +
+
+ + + + 随时查看收到的信件 +
+
+ + + + 快速处理信件内容 +
+
+ + + + 高效反馈处理结果 +
+
+ + {/* 服务宗旨说明 */} +
+

+ 我们确保您能够及时查看、快速处理收到的信件, + 并通过高效反馈机制,提升沟通效率,助力工作顺利开展。 +

+
+
+
+ ); +} diff --git a/apps/web/src/app/main/letter/inbox/page.tsx b/apps/web/src/app/main/letter/inbox/page.tsx new file mode 100644 index 0000000..78c227e --- /dev/null +++ b/apps/web/src/app/main/letter/inbox/page.tsx @@ -0,0 +1,24 @@ +import LetterList from "@web/src/components/models/post/list/LetterList"; +import { Header } from "./Header"; +import { useAuth } from "@web/src/providers/auth-provider"; +export default function InboxPage() { + const { user } = useAuth(); + return ( + // 添加 flex flex-col 使其成为弹性布局容器 +
+
+ {/* 添加 flex-grow 使内容区域自动填充剩余空间 */} + + +
+ ); +} diff --git a/apps/web/src/app/main/letter/index/page.tsx b/apps/web/src/app/main/letter/index/page.tsx new file mode 100644 index 0000000..3dcea41 --- /dev/null +++ b/apps/web/src/app/main/letter/index/page.tsx @@ -0,0 +1,11 @@ +import { useAuth } from "@web/src/providers/auth-provider"; +import InboxPage from "../inbox/page"; +import LetterListPage from "../list/page"; + +export default function IndexPage() { + const { user } = useAuth(); + if (user) { + return ; + } + return ; +} diff --git a/apps/web/src/app/main/letter/outbox/Header.tsx b/apps/web/src/app/main/letter/outbox/Header.tsx new file mode 100644 index 0000000..31630bb --- /dev/null +++ b/apps/web/src/app/main/letter/outbox/Header.tsx @@ -0,0 +1,74 @@ +export function Header() { + return ( +
+
+ {/* 主标题区域 */} +
+

+ 我发出的信件 +

+

+ 清晰记录 • 实时跟踪 • 高效沟通 +

+
+ + {/* 服务特点说明 */} +
+
+ + + + 清晰记录发出的信件 +
+
+ + + + 实时跟踪信件状态 +
+
+ + + + 高效沟通信件进展 +
+
+ + {/* 服务宗旨说明 */} +
+

+ 我们确保您能够清晰记录发出的信件, + 实时跟踪信件状态,并通过高效沟通机制,确保信件处理顺利进行。 +

+
+
+
+ ); +} diff --git a/apps/web/src/app/main/letter/outbox/page.tsx b/apps/web/src/app/main/letter/outbox/page.tsx new file mode 100644 index 0000000..c39748a --- /dev/null +++ b/apps/web/src/app/main/letter/outbox/page.tsx @@ -0,0 +1,20 @@ +import LetterList from "@web/src/components/models/post/list/LetterList"; +import { Header } from "./Header"; +import { useAuth } from "@web/src/providers/auth-provider"; +export default function OutboxPage() { + const { user } = useAuth(); + return ( + // 添加 flex flex-col 使其成为弹性布局容器 +
+
+ {/* 添加 flex-grow 使内容区域自动填充剩余空间 */} + + +
+ ); +} diff --git a/apps/web/src/app/main/letter/write/page.tsx b/apps/web/src/app/main/letter/write/page.tsx index b9af0dd..a9c6d87 100644 --- a/apps/web/src/app/main/letter/write/page.tsx +++ b/apps/web/src/app/main/letter/write/page.tsx @@ -1,138 +1,149 @@ -import { useState, useCallback, useEffect } from 'react'; -import { motion, AnimatePresence } from 'framer-motion'; -import { useSearchParams } from 'react-router-dom'; +import { useState, useCallback, useEffect } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { useSearchParams } from "react-router-dom"; -import { SendCard } from './SendCard'; -import { Spin, Empty, Input, Alert, Pagination } from 'antd'; -import { api, useTerm } from '@nice/client'; -import DepartmentSelect from '@web/src/components/models/department/department-select'; -import debounce from 'lodash/debounce'; -import { SearchOutlined } from '@ant-design/icons'; -import WriteHeader from './WriteHeader'; +import { SendCard } from "./SendCard"; +import { Spin, Empty, Input, Alert, Pagination } from "antd"; +import { api, useTerm } from "@nice/client"; +import DepartmentSelect from "@web/src/components/models/department/department-select"; +import debounce from "lodash/debounce"; +import { SearchOutlined } from "@ant-design/icons"; +import WriteHeader from "./WriteHeader"; export default function WriteLetterPage() { - const [searchParams] = useSearchParams(); - const termId = searchParams.get('termId'); - const [searchQuery, setSearchQuery] = useState(''); - const [selectedDept, setSelectedDept] = useState(); - const [currentPage, setCurrentPage] = useState(1); - const pageSize = 10; - const { getTerm } = useTerm() + const [searchParams] = useSearchParams(); + const termId = searchParams.get("termId"); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedDept, setSelectedDept] = useState(); + const [currentPage, setCurrentPage] = useState(1); + const pageSize = 10; + const { getTerm } = useTerm(); - const { data, isLoading, error } = api.staff.findManyWithPagination.useQuery({ - page: currentPage, - pageSize, - where: { - deptId: selectedDept, - OR: [{ - showname: { - contains: searchQuery - } - }, { - username: { - contains: searchQuery - } - }] - } - }); + const { data, isLoading, error } = + api.staff.findManyWithPagination.useQuery({ + page: currentPage, + pageSize, + where: { + deptId: selectedDept, + OR: [ + { + showname: { + contains: searchQuery, + }, + }, + { + username: { + contains: searchQuery, + }, + }, + ], + }, + orderBy: { + order: "desc", + }, + // orderBy:{ - const resetPage = useCallback(() => { - setCurrentPage(1); - }, []); + // } + }); - // Reset page when search or department changes - useEffect(() => { - resetPage(); - }, [searchQuery, selectedDept, resetPage]); + const resetPage = useCallback(() => { + setCurrentPage(1); + }, []); - return ( -
- -
-
- {/* Search and Filter Section */} -
- - } - placeholder="搜索领导姓名或职级..." - onChange={debounce((e) => setSearchQuery(e.target.value), 300)} + // Reset page when search or department changes + useEffect(() => { + resetPage(); + }, [searchQuery, selectedDept, resetPage]); - size="large" - /> + return ( +
+ +
+
+ {/* Search and Filter Section */} +
+ + + } + placeholder="搜索领导姓名或职级..." + onChange={debounce( + (e) => setSearchQuery(e.target.value), + 300 + )} + size="large" + /> +
-
+ {error && ( + + )} +
- {error && ( - - )} -
+ + {isLoading ? ( +
+ +
+ ) : data?.items.length > 0 ? ( + + {data?.items.map((item: any) => ( + + ))} + + ) : ( + + + + )} +
- - {isLoading ? ( -
- -
- ) : data?.items.length > 0 ? ( - - {data?.items.map((item: any) => ( - - ))} - - ) : ( - - - - )} -
- - {/* Pagination */} - {data?.items.length > 0 && ( -
- { - setCurrentPage(page); - window.scrollTo(0, 0); - }} - showSizeChanger={false} - showTotal={(total) => `共 ${total} 条记录`} - /> -
- )} -
-
- ); + {/* Pagination */} + {data?.items.length > 0 && ( +
+ { + setCurrentPage(page); + window.scrollTo(0, 0); + }} + showSizeChanger={false} + showTotal={(total) => `共 ${total} 条记录`} + /> +
+ )} +
+
+ ); } diff --git a/apps/web/src/components/common/uploader/AvatarUploader.tsx b/apps/web/src/components/common/uploader/AvatarUploader.tsx new file mode 100644 index 0000000..6efc6c9 --- /dev/null +++ b/apps/web/src/components/common/uploader/AvatarUploader.tsx @@ -0,0 +1,131 @@ +import { env } from "@web/src/env"; +import { message, Progress, Spin, theme } from "antd"; +import React, { useState, useEffect, useRef } from "react"; +import { useTusUpload } from "@web/src/hooks/useTusUpload"; + +export interface AvatarUploaderProps { + value?: string; + placeholder?: string; + className?: string; + onChange?: (value: string) => void; + style?: React.CSSProperties; // 添加style属性 +} + +interface UploadingFile { + name: string; + progress: number; + status: "uploading" | "done" | "error"; + fileId?: string; + fileKey?: string; +} + +const AvatarUploader: React.FC = ({ + value, + onChange, + className, + placeholder = "点击上传", + style, // 解构style属性 +}) => { + const { handleFileUpload, uploadProgress } = useTusUpload(); + const [file, setFile] = useState(null); + const [previewUrl, setPreviewUrl] = useState(value || ""); + const [uploading, setUploading] = useState(false); + const inputRef = useRef(null); + + const { token } = theme.useToken(); + + const handleChange = async (event: React.ChangeEvent) => { + const selectedFile = event.target.files?.[0]; + if (!selectedFile) return; + + setFile({ + name: selectedFile.name, + progress: 0, + status: "uploading", + fileKey: `${selectedFile.name}-${Date.now()}`, + }); + setUploading(true); + + try { + const fileId = await new Promise((resolve, reject) => { + handleFileUpload( + selectedFile, + (result) => { + setFile((prev) => ({ + ...prev!, + progress: 100, + status: "done", + fileId: result.fileId, + })); + resolve(result.fileId); + }, + (error) => { + reject(error); + }, + file?.fileKey + ); + }); + setPreviewUrl(`${env.SERVER_IP}/uploads/${fileId}`); + onChange?.(fileId); + message.success("头像上传成功"); + } catch (error) { + console.error("上传错误:", error); + message.error("头像上传失败"); + setFile((prev) => ({ ...prev!, status: "error" })); + } finally { + setUploading(false); + } + }; + + const triggerUpload = () => { + inputRef.current?.click(); + }; + + return ( +
+ + {previewUrl ? ( + Avatar + ) : ( +
+ {placeholder} +
+ )} + {uploading && ( +
+ +
+ )} + {file && file.status === "uploading" && ( +
+ +
+ )} +
+ ); +}; + +export default AvatarUploader; diff --git a/apps/web/src/components/common/uploader/FileUploader.tsx b/apps/web/src/components/common/uploader/FileUploader.tsx deleted file mode 100644 index 99bb2af..0000000 --- a/apps/web/src/components/common/uploader/FileUploader.tsx +++ /dev/null @@ -1,241 +0,0 @@ -// 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?: (result: { url: string; fileId: string }) => void; - onError?: (error: Error) => void; - maxSize?: number; - allowedTypes?: string[]; - placeholder?: string; -} - -interface FileItemProps { - file: File; - progress?: number; - onRemove: (name: string) => void; - isUploaded: boolean; -} - -const FileItem: React.FC = memo( - ({ file, progress, onRemove, isUploaded }) => ( - - -
-
-

- {file.name} -

- -
- {!isUploaded && progress !== undefined && ( -
-
- -
- - {progress}% - -
- )} - {isUploaded && ( -
- - 上传完成 -
- )} -
-
- ) -); - -const FileUploader: React.FC = ({ - 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 { progress, isUploading, uploadError, handleFileUpload } = - useTusUpload(); - - const handleError = (error: Error) => { - toast.error(error.message); - onError?.(error); - }; - - 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 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(", ")}` - ); - } - }; - - const uploadFile = (file: File) => { - try { - validateFile(file); - handleFileUpload( - file, - (upload) => { - console.log("Upload complete:", { - url: upload.url, - fileId: upload.fileId, - // resource: upload.resource - }); - onSuccess?.(upload); - setFiles((prev) => - prev.map((f) => - f.file.name === file.name - ? { ...f, isUploaded: true } - : f - ) - ); - }, - handleError - ); - } catch (error) { - handleError(error as Error); - } - }; - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragging(false); - - 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.map((file) => ({ file, isUploaded: false })), - ]); - selectedFiles.forEach(uploadFile); - } - }; - - const removeFile = (fileName: string) => { - setFiles((prev) => prev.filter(({ file }) => file.name !== fileName)); - }; - - const handleClick = () => { - fileInputRef.current?.click(); - }; - - return ( -
-
- - -

{placeholder}

- {isDragging && ( -
-

- 释放文件以上传 -

-
- )} -
- - -
- {files.map(({ file, isUploaded }) => ( - - ))} -
-
- - {uploadError && ( -
- - {uploadError} -
- )} -
- ); -}; - -export default FileUploader; diff --git a/apps/web/src/components/layout/element/usermenu.tsx b/apps/web/src/components/layout/element/usermenu.tsx index e261666..2fab536 100644 --- a/apps/web/src/components/layout/element/usermenu.tsx +++ b/apps/web/src/components/layout/element/usermenu.tsx @@ -4,194 +4,210 @@ import { motion, AnimatePresence } from "framer-motion"; import { useState, useRef, useCallback, useMemo } from "react"; import { Avatar } from "../../common/element/Avatar"; import { - UserOutlined, - SettingOutlined, - QuestionCircleOutlined, - LogoutOutlined + UserOutlined, + SettingOutlined, + QuestionCircleOutlined, + LogoutOutlined, } from "@ant-design/icons"; import { Spin } from "antd"; import { useNavigate } from "react-router-dom"; import { MenuItemType } from "./types"; const menuVariants = { - hidden: { opacity: 0, scale: 0.95, y: -10 }, - visible: { - opacity: 1, - scale: 1, - y: 0, - transition: { - type: "spring", - stiffness: 300, - damping: 30 - } - }, - exit: { - opacity: 0, - scale: 0.95, - y: -10, - transition: { - duration: 0.2 - } - } + hidden: { opacity: 0, scale: 0.95, y: -10 }, + visible: { + opacity: 1, + scale: 1, + y: 0, + transition: { + type: "spring", + stiffness: 300, + damping: 30, + }, + }, + exit: { + opacity: 0, + scale: 0.95, + y: -10, + transition: { + duration: 0.2, + }, + }, }; export function UserMenu() { - const [showMenu, setShowMenu] = useState(false); - const menuRef = useRef(null); - const { user, logout, isLoading } = useAuth(); - const navigate = useNavigate() - useClickOutside(menuRef, () => setShowMenu(false)); + const [showMenu, setShowMenu] = useState(false); + const menuRef = useRef(null); + const { user, logout, isLoading } = useAuth(); + const navigate = useNavigate(); + useClickOutside(menuRef, () => setShowMenu(false)); - const toggleMenu = useCallback(() => { - setShowMenu(prev => !prev); - }, []); + const toggleMenu = useCallback(() => { + setShowMenu((prev) => !prev); + }, []); - const menuItems: MenuItemType[] = useMemo(() => [ - { - icon: , - label: '个人信息', - action: () => { }, - }, - { - icon: , - label: '设置', - action: () => { - navigate('/admin/staff') - }, - }, - { - icon: , - label: '帮助', - action: () => { }, - }, - { - icon: , - label: '注销', - action: () => logout(), - }, - ], [logout]); + const menuItems: MenuItemType[] = useMemo( + () => [ + { + icon: , + label: "个人信息", + action: () => {}, + }, + { + icon: , + label: "设置", + action: () => { + navigate("/admin/staff"); + }, + }, + // { + // icon: , + // label: '帮助', + // action: () => { }, + // }, + { + icon: , + label: "注销", + action: () => logout(), + }, + ], + [logout] + ); - const handleMenuItemClick = useCallback((action: () => void) => { - action(); - setShowMenu(false); - }, []); + const handleMenuItemClick = useCallback((action: () => void) => { + action(); + setShowMenu(false); + }, []); - if (isLoading) { - return ( -
- -
- ); - } + if (isLoading) { + return ( +
+ +
+ ); + } - return ( -
- - - + return ( +
+ + {/* Avatar 容器,相对定位 */} +
+ + {/* 小绿点 */} +
- - {showMenu && ( - + + {user?.showname || user?.username} + + + {user?.department?.name} + +
+ + + + {showMenu && ( + - {/* User Profile Section */} -
+ {/* User Profile Section */} +
+
+ +
+ + {user?.showname || user?.username} + + + + 在线 + +
+
+
- > -
- -
- - {user?.showname || user?.username} - - - - 在线 - -
-
-
- - {/* Menu Items */} -
- {menuItems.map((item, index) => ( - - ))} -
-
- )} -
-
- ); + group-hover:translate-x-0.5 ${ + item.label === "注销" + ? "group-hover:text-red-600" + : "group-hover:text-[#003F6A]" + }`}> + {item.icon} + + {item.label} + + ))} + + + )} + + + ); } diff --git a/apps/web/src/components/layout/main/Header.tsx b/apps/web/src/components/layout/main/Header.tsx index 5289417..ade7a88 100644 --- a/apps/web/src/components/layout/main/Header.tsx +++ b/apps/web/src/components/layout/main/Header.tsx @@ -7,28 +7,24 @@ import { UserOutlined } from "@ant-design/icons"; import { UserMenu } from "../element/usermenu"; import SineWavesCanvas from "../../animation/sine-wave"; interface HeaderProps { - onSearch?: (query: string) => void; + onSearch?: (query: string) => void; } export const Header = memo(function Header({ onSearch }: HeaderProps) { - const { isAuthenticated } = useAuth() - return ( -
-
-
-
-
- - 领导机关信箱 -
-
- -
-
- - { - !isAuthenticated ? +
+
+
+
领导机关信箱
+
+ +
+
+ {!isAuthenticated ? ( + - + - 登录 - : - - - } -
-
-
- -
-
- ); -}); \ No newline at end of file + 登录 + + ) : ( + + )} + + + + + + + ); +}); diff --git a/apps/web/src/components/layout/main/navigation.tsx b/apps/web/src/components/layout/main/navigation.tsx index 1a1cccd..27a1302 100644 --- a/apps/web/src/components/layout/main/navigation.tsx +++ b/apps/web/src/components/layout/main/navigation.tsx @@ -1,96 +1,108 @@ import { NavLink, useLocation } from "react-router-dom"; import { useNavItem } from "./useNavItem"; import { twMerge } from "tailwind-merge"; +import React from "react"; interface NavItem { - to: string; - label: string; - icon?: React.ReactNode; + to: string; + label: string; + icon?: React.ReactNode; } interface NavigationProps { - className?: string; + className?: string; } export default function Navigation({ className }: NavigationProps) { - const { navItems } = useNavItem(); - const location = useLocation(); + const { navItems } = useNavItem(); + const location = useLocation(); - const isActive = (to: string) => { - const [pathname, search] = to.split('?'); - return location.pathname === pathname && - (!search ? !location.search : location.search === `?${search}`); - }; + const isActive = (to: string) => { + const [pathname, search] = to.split("?"); + return ( + location.pathname === pathname && + (!search ? !location.search : location.search === `?${search}`) + ); + }; - return ( - - ); + {/* Mobile Navigation */} +
+
+ {navItems.map((item) => ( + + twMerge( + "px-3 py-1.5 text-sm font-medium rounded-full", + "transition-colors duration-200", + "text-gray-300 hover:text-white", + active && "bg-blue-500/20 text-white" + ) + }> + + {item.icon} + {item.label} + + + ))} +
+
+ + + ); } diff --git a/apps/web/src/components/layout/main/useNavItem.tsx b/apps/web/src/components/layout/main/useNavItem.tsx index c04a818..48f12f7 100644 --- a/apps/web/src/components/layout/main/useNavItem.tsx +++ b/apps/web/src/components/layout/main/useNavItem.tsx @@ -1,66 +1,87 @@ import { api } from "@nice/client"; import { TaxonomySlug } from "@nice/common"; -import { useMemo } from "react"; +import React, { useMemo } from "react"; +import { MailOutlined, SendOutlined } from "@ant-design/icons"; import { - FileTextOutlined, - ScheduleOutlined, - QuestionCircleOutlined, - FolderOutlined, - TagsOutlined + FileTextOutlined, + ScheduleOutlined, + QuestionCircleOutlined, + FolderOutlined, + TagsOutlined, } from "@ant-design/icons"; +import { useAuth } from "@web/src/providers/auth-provider"; export interface NavItem { - to: string; - label: string; - icon?: React.ReactNode; + to: string; + label: string; + icon?: React.ReactNode; } export function useNavItem() { - const { data } = api.term.findMany.useQuery({ - where: { - taxonomy: { slug: TaxonomySlug.CATEGORY } - } - }); + const { user } = useAuth(); + const { data } = api.term.findMany.useQuery({ + where: { + taxonomy: { slug: TaxonomySlug.CATEGORY }, + }, + }); - const navItems = useMemo(() => { - // 定义固定的导航项 - const staticItems = { - letterList: { - to: "/", - label: "公开信件", - icon: - }, - letterProgress: { - to: "/letter-progress", - label: "进度查询", - icon: - }, - help: { - to: "/help", - label: "使用帮助", - icon: - } - }; + const navItems = useMemo(() => { + // 定义固定的导航项 + const staticItems = { + inbox: { + to: user ? "/" : "/inbox", + label: "我收到的", + icon: , + }, + outbox: { + to: "/outbox", + label: "我发出的", + icon: , + }, + letterList: { + to: !user ? "/" : "/letter-list", + label: "公开信件", + icon: , + }, + letterProgress: { + to: "/letter-progress", + label: "进度查询", + icon: , + }, + // help: { + // to: "/help", + // label: "使用帮助", + // icon: + // } + }; - if (!data) { - return [staticItems.letterList, staticItems.letterProgress, staticItems.help]; - } + if (!data) { + return [ + user && staticItems.inbox, + user && staticItems.outbox, + staticItems.letterList, + staticItems.letterProgress, + // staticItems.help, + ].filter(Boolean); + } - // 构建分类导航项 - const categoryItems = data.map(term => ({ - to: `/write-letter?termId=${term.id}`, - label: term.name, - icon: - })); + // 构建分类导航项 + const categoryItems = data.map((term) => ({ + to: `/write-letter?termId=${term.id}`, + label: term.name, + icon: , + })); - // 按照指定顺序返回导航项 - return [ - staticItems.letterList, - staticItems.letterProgress, - ...categoryItems, - staticItems.help - ]; - }, [data]); + // 按照指定顺序返回导航项 + return [ + user && staticItems.inbox, + user && staticItems.outbox, + staticItems.letterList, + staticItems.letterProgress, + ...categoryItems, + // staticItems.help, + ].filter(Boolean); + }, [data]); - return { navItems }; + return { navItems }; } diff --git a/apps/web/src/components/models/post/list/LetterList.tsx b/apps/web/src/components/models/post/list/LetterList.tsx index 0e60ef6..b11d055 100644 --- a/apps/web/src/components/models/post/list/LetterList.tsx +++ b/apps/web/src/components/models/post/list/LetterList.tsx @@ -1,113 +1,114 @@ -import { useState, useEffect, useMemo } from 'react'; -import { Input, Pagination, Empty, Spin } from 'antd'; +import { useState, useEffect, useMemo } from "react"; +import { Input, Pagination, Empty, Spin } from "antd"; import { api, RouterInputs } from "@nice/client"; import { LetterCard } from "../LetterCard"; import { NonVoid } from "@nice/utils"; -import { SearchOutlined } from '@ant-design/icons'; -import debounce from 'lodash/debounce'; -import { postDetailSelect } from '@nice/common'; -export default function LetterList({ params }: { params: NonVoid }) { - const [searchText, setSearchText] = useState(''); - const [currentPage, setCurrentPage] = useState(1); +import { SearchOutlined } from "@ant-design/icons"; +import debounce from "lodash/debounce"; +import { postDetailSelect } from "@nice/common"; +export default function LetterList({ + params, +}: { + params: NonVoid; +}) { + const [searchText, setSearchText] = useState(""); + const [currentPage, setCurrentPage] = useState(1); - const { data, isLoading } = api.post.findManyWithPagination.useQuery({ - page: currentPage, - pageSize: params.pageSize, - where: { - OR: [{ - title: { - contains: searchText - } - }], - ...params?.where - }, - orderBy: { - updatedAt: "desc" - }, - select: { - ...postDetailSelect, - ...params.select - } - }); + const { data, isLoading } = api.post.findManyWithPagination.useQuery({ + page: currentPage, + pageSize: params.pageSize, + where: { + OR: [ + { + title: { + contains: searchText, + }, + }, + ], + ...params?.where, + }, + orderBy: { + updatedAt: "desc", + }, + select: { + ...postDetailSelect, + ...params.select, + }, + }); - const debouncedSearch = useMemo( - () => - debounce((value: string) => { - setSearchText(value); - setCurrentPage(1); - }, 300), - [] - ); - // Cleanup debounce on unmount - useEffect(() => { - return () => { - debouncedSearch.cancel(); - }; - }, [debouncedSearch]); - const handleSearch = (value: string) => { - debouncedSearch(value); - }; + const debouncedSearch = useMemo( + () => + debounce((value: string) => { + setSearchText(value); + setCurrentPage(1); + }, 300), + [] + ); + // Cleanup debounce on unmount + useEffect(() => { + return () => { + debouncedSearch.cancel(); + }; + }, [debouncedSearch]); + const handleSearch = (value: string) => { + debouncedSearch(value); + }; - const handlePageChange = (page: number) => { - setCurrentPage(page); - // Scroll to top when page changes - window.scrollTo({ top: 0, behavior: 'smooth' }); - }; + const handlePageChange = (page: number) => { + setCurrentPage(page); + // Scroll to top when page changes + window.scrollTo({ top: 0, behavior: "smooth" }); + }; - return ( -
- {/* Search Bar */} -
- handleSearch(e.target.value)} - prefix={} - /> -
+ return ( +
+ {/* Search Bar */} +
+ handleSearch(e.target.value)} + prefix={} + /> +
- {/* Content Area */} -
- {isLoading ? ( -
- -
- ) : data?.items.length ? ( - <> -
- {data.items.map((letter: any) => ( - - ))} -
-
- -
- - ) : ( -
- -
- )} -
- - -
- ); + {/* Content Area */} +
+ {isLoading ? ( +
+ +
+ ) : data?.items.length ? ( + <> +
+ {data.items.map((letter: any) => ( + + ))} +
+
+ +
+ + ) : ( +
+ +
+ )} +
+
+ ); } diff --git a/apps/web/src/components/models/staff/staff-editor.tsx b/apps/web/src/components/models/staff/staff-editor.tsx index 4bc4b76..c93b1c8 100644 --- a/apps/web/src/components/models/staff/staff-editor.tsx +++ b/apps/web/src/components/models/staff/staff-editor.tsx @@ -1,8 +1,8 @@ import StaffList from "./staff-list"; -import { ObjectType, RolePerms } from "@nice/common" -import { Icon } from "@nice/iconer" +import { ObjectType, RolePerms } from "@nice/common"; +import { Icon } from "@nice/iconer"; import StaffModal from "./staff-modal"; -import { createContext, useEffect, useMemo, useState } from "react"; +import React,{ createContext, useEffect, useMemo, useState } from "react"; import { useAuth } from "@web/src/providers/auth-provider"; import { Button } from "antd"; import DepartmentSelect from "../department/department-select"; @@ -10,63 +10,87 @@ import { FormInstance, useForm } from "antd/es/form/Form"; import AdminHeader from "../../layout/admin/AdminHeader"; export const StaffEditorContext = createContext<{ - domainId: string, - modalOpen: boolean, - setDomainId: React.Dispatch>, - setModalOpen: React.Dispatch>, - editId: string, - setEditId: React.Dispatch>, - form: FormInstance, - formLoading: boolean, - setFormLoading: React.Dispatch>, - canManageAnyStaff: boolean + domainId: string; + modalOpen: boolean; + setDomainId: React.Dispatch>; + setModalOpen: React.Dispatch>; + editId: string; + setEditId: React.Dispatch>; + form: FormInstance; + formLoading: boolean; + setFormLoading: React.Dispatch>; + canManageAnyStaff: boolean; }>({ - domainId: undefined, - modalOpen: false, - setDomainId: undefined, - setModalOpen: undefined, - editId: undefined, - setEditId: undefined, - form: undefined, - formLoading: undefined, - setFormLoading: undefined, - canManageAnyStaff: false + domainId: undefined, + modalOpen: false, + setDomainId: undefined, + setModalOpen: undefined, + editId: undefined, + setEditId: undefined, + form: undefined, + formLoading: undefined, + setFormLoading: undefined, + canManageAnyStaff: false, }); export default function StaffEditor() { - const [form] = useForm() - const [domainId, setDomainId] = useState(); - const [modalOpen, setModalOpen] = useState(false); - const [editId, setEditId] = useState() - const { user, hasSomePermissions } = useAuth() - const [formLoading, setFormLoading] = useState() - useEffect(() => { - if (user) { - setDomainId(user.domainId) - } - }, [user]) + const [form] = useForm(); + const [domainId, setDomainId] = useState(); + const [modalOpen, setModalOpen] = useState(false); + const [editId, setEditId] = useState(); + const { user, hasSomePermissions } = useAuth(); + const [formLoading, setFormLoading] = useState(); + useEffect(() => { + if (user) { + setDomainId(user.domainId); + } + }, [user]); - const canManageStaff = useMemo(() => { - return hasSomePermissions(RolePerms.MANAGE_ANY_STAFF, RolePerms.MANAGE_DOM_STAFF) - }, [user]) - const canManageAnyStaff = useMemo(() => { - return hasSomePermissions(RolePerms.MANAGE_ANY_STAFF) - }, [user]) - return - -
- setDomainId(value as string)} disabled={!canManageAnyStaff} value={domainId} className="w-48" domain={true}> - {canManageStaff && } -
-
- - -
-} \ No newline at end of file + const canManageStaff = useMemo(() => { + return hasSomePermissions( + RolePerms.MANAGE_ANY_STAFF, + RolePerms.MANAGE_DOM_STAFF + ); + }, [user]); + const canManageAnyStaff = useMemo(() => { + return hasSomePermissions(RolePerms.MANAGE_ANY_STAFF); + }, [user]); + return ( + + +
+ setDomainId(value as string)} + disabled={!canManageAnyStaff} + value={domainId} + className="w-48" + domain={true}> + {canManageStaff && ( + + )} +
+
+ + +
+ ); +} diff --git a/apps/web/src/components/models/staff/staff-form.tsx b/apps/web/src/components/models/staff/staff-form.tsx index 1dc4920..070a9f5 100644 --- a/apps/web/src/components/models/staff/staff-form.tsx +++ b/apps/web/src/components/models/staff/staff-form.tsx @@ -1,10 +1,12 @@ import { Button, Form, Input, Spin, Switch, message } from "antd"; -import { useContext, useEffect} from "react"; +import { useContext, useEffect } from "react"; import { useStaff } from "@nice/client"; import DepartmentSelect from "../department/department-select"; -import { api } from "@nice/client" +import { api } from "@nice/client"; import { StaffEditorContext } from "./staff-editor"; import { useAuth } from "@web/src/providers/auth-provider"; +import AvatarUploader from "../../common/uploader/AvatarUploader"; +import { StaffDto } from "@nice/common"; export default function StaffForm() { const { create, update } = useStaff(); // Ensure you have these methods in your hooks const { @@ -17,7 +19,13 @@ export default function StaffForm() { canManageAnyStaff, setEditId, } = useContext(StaffEditorContext); - const { data, isLoading } = api.staff.findFirst.useQuery( + const { + data, + isLoading, + }: { + data: StaffDto; + isLoading: boolean; + } = api.staff.findFirst.useQuery( { where: { id: editId } }, { enabled: !!editId } ); @@ -31,8 +39,9 @@ export default function StaffForm() { password, phoneNumber, officerId, - enabled - } = values + enabled, + photoUrl, + } = values; setFormLoading(true); try { if (data && editId) { @@ -46,8 +55,11 @@ export default function StaffForm() { password, phoneNumber, officerId, - enabled - } + enabled, + meta: { + photoUrl, + }, + }, }); } else { await create.mutateAsync({ @@ -58,8 +70,11 @@ export default function StaffForm() { domainId: fieldDomainId ? fieldDomainId : domainId, password, officerId, - phoneNumber - } + phoneNumber, + meta: { + photoUrl, + }, + }, }); form.resetFields(); if (deptId) form.setFieldValue("deptId", deptId); @@ -83,7 +98,8 @@ export default function StaffForm() { form.setFieldValue("deptId", data.deptId); form.setFieldValue("officerId", data.officerId); form.setFieldValue("phoneNumber", data.phoneNumber); - form.setFieldValue("enabled", data.enabled) + form.setFieldValue("enabled", data.enabled); + form.setFieldValue("photoUrl", data?.meta?.photoUrl); } }, [data]); useEffect(() => { @@ -91,7 +107,7 @@ export default function StaffForm() { form.setFieldValue("domainId", domainId); form.setFieldValue("deptId", domainId); } - }, [domainId, data]); + }, [domainId, data as any]); return (
{isLoading && ( @@ -106,48 +122,75 @@ export default function StaffForm() { requiredMark="optional" autoComplete="off" onFinish={handleFinish}> +
+
+ + + +
+
+ + + + + + + + + +
+
{canManageAnyStaff && ( )} - - - - - - - - - @@ -158,20 +201,29 @@ export default function StaffForm() { { required: false, pattern: /^\d{6,11}$/, - message: "请输入正确的手机号(数字)" - } + message: "请输入正确的手机号(数字)", + }, ]} name={"phoneNumber"} label="手机号"> - + - + - {editId && - - } + {editId && ( + + + + )}
); diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index a876714..dc0dca8 100755 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -23,6 +23,9 @@ import LetterDetailPage from "../app/main/letter/detail/page"; import AdminLayout from "../components/layout/admin/AdminLayout"; import { CustomRouteObject } from "./types"; import { adminRoute } from "./admin-route"; +import InboxPage from "../app/main/letter/inbox/page"; +import OutboxPage from "../app/main/letter/outbox/page"; +import IndexPage from "../app/main/letter/index/page"; export const routes: CustomRouteObject[] = [ { path: "/", @@ -36,8 +39,20 @@ export const routes: CustomRouteObject[] = [ { element: , children: [ + { + path: "inbox", + element: , + }, + { + path: "outbox", + element: , + }, { index: true, + element: , + }, + { + path: "letter-list", element: , }, { diff --git a/packages/utils/package.json b/packages/utils/package.json index 8f3fd0e..c02c2c3 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -8,6 +8,7 @@ "scripts": { "build": "tsup", "dev": "tsup --watch", + "dev-static": "tsup --no-watch", "clean": "rimraf dist", "typecheck": "tsc --noEmit" },