This commit is contained in:
longdayi 2025-01-26 10:49:24 +08:00
commit cabec45c64
24 changed files with 1239 additions and 968 deletions

View File

@ -39,7 +39,7 @@ export class BaseService<
protected prisma: PrismaClient, protected prisma: PrismaClient,
protected objectType: string, protected objectType: string,
protected enableOrder: boolean = false, protected enableOrder: boolean = false,
) { } ) {}
/** /**
* Retrieves the name of the model dynamically. * Retrieves the name of the model dynamically.
@ -452,8 +452,12 @@ export class BaseService<
pageSize?: number; pageSize?: number;
where?: WhereArgs<A['findMany']>; where?: WhereArgs<A['findMany']>;
select?: SelectArgs<A['findMany']>; select?: SelectArgs<A['findMany']>;
orderBy?: OrderByArgs<A['findMany']> orderBy?: OrderByArgs<A['findMany']>;
}): Promise<{ items: R['findMany']; totalPages: number, totalCount: number }> { }): Promise<{
items: R['findMany'];
totalPages: number;
totalCount: number;
}> {
const { page = 1, pageSize = 10, where, select, orderBy } = args; const { page = 1, pageSize = 10, where, select, orderBy } = args;
try { try {
@ -473,7 +477,7 @@ export class BaseService<
return { return {
items, items,
totalPages, totalPages,
totalCount: total totalCount: total,
}; };
} catch (error) { } catch (error) {
this.handleError(error, 'read'); this.handleError(error, 'read');

View File

@ -1,25 +1,27 @@
import { db, Prisma, PrismaClient } from "@nice/common"; import { db, Prisma, PrismaClient } from '@nice/common';
export type Operations = export type Operations =
| 'aggregate' | 'aggregate'
| 'count' | 'count'
| 'create' | 'create'
| 'createMany' | 'createMany'
| 'delete' | 'delete'
| 'deleteMany' | 'deleteMany'
| 'findFirst' | 'findFirst'
| 'findMany' | 'findMany'
| 'findUnique' | 'findUnique'
| 'update' | 'update'
| 'updateMany' | 'updateMany'
| 'upsert'; | 'upsert';
export type DelegateFuncs = { [K in Operations]: (args: any) => Promise<unknown> } export type DelegateFuncs = {
[K in Operations]: (args: any) => Promise<unknown>;
};
export type DelegateArgs<T> = { export type DelegateArgs<T> = {
[K in keyof T]: T[K] extends (args: infer A) => Promise<any> ? A : never; [K in keyof T]: T[K] extends (args: infer A) => Promise<any> ? A : never;
}; };
export type DelegateReturnTypes<T> = { export type DelegateReturnTypes<T> = {
[K in keyof T]: T[K] extends (args: any) => Promise<infer R> ? R : never; [K in keyof T]: T[K] extends (args: any) => Promise<infer R> ? R : never;
}; };
export type WhereArgs<T> = T extends { where?: infer W } ? W : never; export type WhereArgs<T> = T extends { where?: infer W } ? W : never;
@ -28,17 +30,17 @@ export type DataArgs<T> = T extends { data: infer D } ? D : never;
export type IncludeArgs<T> = T extends { include: infer I } ? I : never; export type IncludeArgs<T> = T extends { include: infer I } ? I : never;
export type OrderByArgs<T> = T extends { orderBy: infer O } ? O : never; export type OrderByArgs<T> = T extends { orderBy: infer O } ? O : never;
export type UpdateOrderArgs = { export type UpdateOrderArgs = {
id: string id: string;
overId: string overId: string;
} };
export interface FindManyWithCursorType<T extends DelegateFuncs> { export interface FindManyWithCursorType<T extends DelegateFuncs> {
cursor?: string; cursor?: string;
limit?: number; limit?: number;
where?: WhereArgs<DelegateArgs<T>['findUnique']>; where?: WhereArgs<DelegateArgs<T>['findUnique']>;
select?: SelectArgs<DelegateArgs<T>['findUnique']>; select?: SelectArgs<DelegateArgs<T>['findUnique']>;
orderBy?: OrderByArgs<DelegateArgs<T>['findMany']> orderBy?: OrderByArgs<DelegateArgs<T>['findMany']>;
} }
export type TransactionType = Omit< export type TransactionType = Omit<
PrismaClient, PrismaClient,
'$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends' '$connect' | '$disconnect' | '$on' | '$transaction' | '$use' | '$extends'
>; >;

View File

@ -12,13 +12,13 @@ const PostDeleteManyArgsSchema: ZodType<Prisma.PostDeleteManyArgs> = z.any();
const PostWhereInputSchema: ZodType<Prisma.PostWhereInput> = z.any(); const PostWhereInputSchema: ZodType<Prisma.PostWhereInput> = z.any();
const PostSelectSchema: ZodType<Prisma.PostSelect> = z.any(); const PostSelectSchema: ZodType<Prisma.PostSelect> = z.any();
const PostUpdateInputSchema: ZodType<Prisma.PostUpdateInput> = z.any(); const PostUpdateInputSchema: ZodType<Prisma.PostUpdateInput> = z.any();
const PostOrderBySchema: ZodType<Prisma.PostOrderByWithRelationInput> = z.any() const PostOrderBySchema: ZodType<Prisma.PostOrderByWithRelationInput> = z.any();
@Injectable() @Injectable()
export class PostRouter { export class PostRouter {
constructor( constructor(
private readonly trpc: TrpcService, private readonly trpc: TrpcService,
private readonly postService: PostService, private readonly postService: PostService,
) { } ) {}
router = this.trpc.router({ router = this.trpc.router({
create: this.trpc.protectProcedure create: this.trpc.protectProcedure
.input(PostCreateArgsSchema) .input(PostCreateArgsSchema)
@ -104,7 +104,7 @@ export class PostRouter {
pageSize: z.number().optional(), pageSize: z.number().optional(),
where: PostWhereInputSchema.optional(), where: PostWhereInputSchema.optional(),
select: PostSelectSchema.optional(), select: PostSelectSchema.optional(),
orderBy: PostOrderBySchema.optional() orderBy: PostOrderBySchema.optional(),
}), }),
) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword ) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {

View File

@ -101,22 +101,31 @@ export class PostService extends BaseService<Prisma.PostDelegate> {
}); });
} }
async findManyWithPagination( async findManyWithPagination(
args: { page?: number; pageSize?: number; where?: Prisma.PostWhereInput; select?: Prisma.PostSelect<DefaultArgs>; orderBy?: Prisma.PostOrderByWithRelationInput }, args: {
page?: number;
pageSize?: number;
where?: Prisma.PostWhereInput;
select?: Prisma.PostSelect<DefaultArgs>;
orderBy?: Prisma.PostOrderByWithRelationInput;
},
staff?: UserProfile, staff?: UserProfile,
clientIp?: string, clientIp?: string,
) { ) {
if (!args.where) args.where = {}; if (!args.where) args.where = {};
args.where.OR = await this.preFilter(args.where.OR, staff); args.where.OR = await this.preFilter(args.where.OR, staff);
return this.wrapResult(super.findManyWithPagination(args as any), async (result) => { return this.wrapResult(
const { items } = result; super.findManyWithPagination(args as any),
await Promise.all( async (result) => {
items.map(async (item) => { const { items } = result;
await setPostRelation({ data: item, staff, clientIp }); await Promise.all(
await this.setPerms(item, staff); items.map(async (item) => {
}), await setPostRelation({ data: item, staff, clientIp });
); await this.setPerms(item, staff);
return { ...result, items }; }),
}); );
return { ...result, items };
},
);
} }
protected async setPerms(data: Post, staff?: UserProfile) { protected async setPerms(data: Post, staff?: UserProfile) {
if (!staff) return; if (!staff) return;

View File

@ -12,13 +12,15 @@ const StaffWhereInputSchema: ZodType<Prisma.StaffWhereInput> = z.any();
const StaffSelectSchema: ZodType<Prisma.StaffSelect> = z.any(); const StaffSelectSchema: ZodType<Prisma.StaffSelect> = z.any();
const StaffUpdateInputSchema: ZodType<Prisma.PostUpdateInput> = z.any(); const StaffUpdateInputSchema: ZodType<Prisma.PostUpdateInput> = z.any();
const StaffFindManyArgsSchema: ZodType<Prisma.StaffFindManyArgs> = z.any(); const StaffFindManyArgsSchema: ZodType<Prisma.StaffFindManyArgs> = z.any();
const StaffOrderBySchema: ZodType<Prisma.StaffOrderByWithRelationInput> =
z.any();
@Injectable() @Injectable()
export class StaffRouter { export class StaffRouter {
constructor( constructor(
private readonly trpc: TrpcService, private readonly trpc: TrpcService,
private readonly staffService: StaffService, private readonly staffService: StaffService,
private readonly staffRowService: StaffRowService, private readonly staffRowService: StaffRowService,
) { } ) {}
router = this.trpc.router({ router = this.trpc.router({
create: this.trpc.procedure create: this.trpc.procedure
@ -78,12 +80,15 @@ export class StaffRouter {
return this.staffService.updateOrder(input); return this.staffService.updateOrder(input);
}), }),
findManyWithPagination: this.trpc.procedure findManyWithPagination: this.trpc.procedure
.input(z.object({ .input(
page: z.number(), z.object({
pageSize: z.number().optional(), page: z.number(),
where: StaffWhereInputSchema.optional(), pageSize: z.number().optional(),
select: StaffSelectSchema.optional() where: StaffWhereInputSchema.optional(),
})) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword select: StaffSelectSchema.optional(),
orderBy: StaffOrderBySchema.optional(),
}),
) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => { .query(async ({ input }) => {
return await this.staffService.findManyWithPagination(input); return await this.staffService.findManyWithPagination(input);
}), }),

View File

@ -105,7 +105,7 @@ export class StaffService extends BaseService<Prisma.StaffDelegate> {
* @returns * @returns
*/ */
async updateUserDomain(data: { domainId?: string }, staff?: UserProfile) { async updateUserDomain(data: { domainId?: string }, staff?: UserProfile) {
let { domainId } = data; const { domainId } = data;
if (staff.domainId !== domainId) { if (staff.domainId !== domainId) {
const result = await this.update({ const result = await this.update({
where: { id: staff.id }, where: { id: staff.id },
@ -120,14 +120,23 @@ export class StaffService extends BaseService<Prisma.StaffDelegate> {
} }
} }
async findManyWithPagination(args: { page?: number; pageSize?: number; where?: Prisma.StaffWhereInput; select?: Prisma.StaffSelect<DefaultArgs>; }) { async findManyWithPagination(args: {
page?: number;
pageSize?: number;
where?: Prisma.StaffWhereInput;
select?: Prisma.StaffSelect<DefaultArgs>;
orderBy?: Prisma.StaffOrderByWithRelationInput;
}) {
if (args.where.deptId && typeof args.where.deptId === 'string') { 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 = { args.where.deptId = {
in: childDepts in: childDepts,
} };
} }
return super.findManyWithPagination(args) return super.findManyWithPagination(args as any);
} }
} }

View File

@ -1,4 +1,4 @@
import { VisitType } from 'packages/common/dist'; import { VisitType } from '@nice/common';
export enum QueueJobType { export enum QueueJobType {
UPDATE_STATS = 'update_stats', UPDATE_STATS = 'update_stats',
FILE_PROCESS = 'file_process', FILE_PROCESS = 'file_process',

View File

@ -0,0 +1,74 @@
export function Header() {
return (
<header className="bg-gradient-to-r from-primary to-primary-400 p-6 rounded-t-xl">
<div className="flex flex-col space-y-6">
{/* 主标题区域 */}
<div>
<h1 className="text-3xl font-bold tracking-wider text-white">
</h1>
<p className="mt-2 text-blue-100 text-lg">
</p>
</div>
{/* 服务特点说明 */}
<div className="flex flex-wrap gap-6 text-sm text-white">
<div className="flex items-center gap-2">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
/>
</svg>
<span></span>
</div>
<div className="flex items-center gap-2">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
/>
</svg>
<span></span>
</div>
<div className="flex items-center gap-2">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
/>
</svg>
<span></span>
</div>
</div>
{/* 服务宗旨说明 */}
<div className="text-sm text-blue-100 border-t border-blue-400/30 pt-4">
<p className="leading-relaxed">
</p>
</div>
</div>
</header>
);
}

View File

@ -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 使其成为弹性布局容器
<div className="min-h-screen shadow-elegant border-2 border-white rounded-xl overflow-hidden bg-gradient-to-b from-slate-100 to-slate-50 flex flex-col">
<Header />
{/* 添加 flex-grow 使内容区域自动填充剩余空间 */}
<LetterList
params={{
where: {
receivers: {
some: {
id: user?.id,
},
},
},
}}></LetterList>
</div>
);
}

View File

@ -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 <InboxPage></InboxPage>;
}
return <LetterListPage></LetterListPage>;
}

View File

@ -0,0 +1,74 @@
export function Header() {
return (
<header className="bg-gradient-to-r from-primary to-primary-400 p-6 rounded-t-xl">
<div className="flex flex-col space-y-6">
{/* 主标题区域 */}
<div>
<h1 className="text-3xl font-bold tracking-wider text-white">
</h1>
<p className="mt-2 text-blue-100 text-lg">
</p>
</div>
{/* 服务特点说明 */}
<div className="flex flex-wrap gap-6 text-sm text-white">
<div className="flex items-center gap-2">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
/>
</svg>
<span></span>
</div>
<div className="flex items-center gap-2">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span></span>
</div>
<div className="flex items-center gap-2">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
/>
</svg>
<span></span>
</div>
</div>
{/* 服务宗旨说明 */}
<div className="text-sm text-blue-100 border-t border-blue-400/30 pt-4">
<p className="leading-relaxed">
</p>
</div>
</div>
</header>
);
}

View File

@ -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 使其成为弹性布局容器
<div className="min-h-screen shadow-elegant border-2 border-white rounded-xl overflow-hidden bg-gradient-to-b from-slate-100 to-slate-50 flex flex-col">
<Header />
{/* 添加 flex-grow 使内容区域自动填充剩余空间 */}
<LetterList
params={{
where: {
authorId: user?.id,
},
}}></LetterList>
</div>
);
}

View File

@ -1,138 +1,149 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from "react";
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from "framer-motion";
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from "react-router-dom";
import { SendCard } from './SendCard'; import { SendCard } from "./SendCard";
import { Spin, Empty, Input, Alert, Pagination } from 'antd'; import { Spin, Empty, Input, Alert, Pagination } from "antd";
import { api, useTerm } from '@nice/client'; import { api, useTerm } from "@nice/client";
import DepartmentSelect from '@web/src/components/models/department/department-select'; import DepartmentSelect from "@web/src/components/models/department/department-select";
import debounce from 'lodash/debounce'; import debounce from "lodash/debounce";
import { SearchOutlined } from '@ant-design/icons'; import { SearchOutlined } from "@ant-design/icons";
import WriteHeader from './WriteHeader'; import WriteHeader from "./WriteHeader";
export default function WriteLetterPage() { export default function WriteLetterPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const termId = searchParams.get('termId'); const termId = searchParams.get("termId");
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState("");
const [selectedDept, setSelectedDept] = useState<string>(); const [selectedDept, setSelectedDept] = useState<string>();
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const pageSize = 10; const pageSize = 10;
const { getTerm } = useTerm() const { getTerm } = useTerm();
const { data, isLoading, error } = api.staff.findManyWithPagination.useQuery({ const { data, isLoading, error } =
page: currentPage, api.staff.findManyWithPagination.useQuery({
pageSize, page: currentPage,
where: { pageSize,
deptId: selectedDept, where: {
OR: [{ deptId: selectedDept,
showname: { OR: [
contains: searchQuery {
} showname: {
}, { contains: searchQuery,
username: { },
contains: searchQuery },
} {
}] username: {
} contains: searchQuery,
}); },
},
],
},
orderBy: {
order: "desc",
},
// orderBy:{
const resetPage = useCallback(() => { // }
setCurrentPage(1); });
}, []);
// Reset page when search or department changes const resetPage = useCallback(() => {
useEffect(() => { setCurrentPage(1);
resetPage(); }, []);
}, [searchQuery, selectedDept, resetPage]);
return ( // Reset page when search or department changes
<div className="min-h-screen shadow-elegant border-2 border-white rounded-xl bg-slate-200"> useEffect(() => {
<WriteHeader term={getTerm(termId)} /> resetPage();
<div className="container mx-auto px-4 py-8"> }, [searchQuery, selectedDept, resetPage]);
<div className="mb-8 space-y-4">
{/* Search and Filter Section */}
<div className="flex flex-col md:flex-row gap-4 items-center">
<DepartmentSelect
variant='filled'
size="large"
value={selectedDept}
onChange={setSelectedDept as any}
className="w-1/2"
/>
<Input
variant='filled'
className={'w-1/2'}
prefix={<SearchOutlined className="text-gray-400" />}
placeholder="搜索领导姓名或职级..."
onChange={debounce((e) => setSearchQuery(e.target.value), 300)}
size="large" return (
/> <div className="min-h-screen shadow-elegant border-2 border-white rounded-xl bg-slate-200">
<WriteHeader term={getTerm(termId)} />
<div className="container mx-auto px-4 py-8">
<div className="mb-8 space-y-4">
{/* Search and Filter Section */}
<div className="flex flex-col md:flex-row gap-4 items-center">
<DepartmentSelect
variant="filled"
size="large"
value={selectedDept}
onChange={setSelectedDept as any}
className="w-1/2"
/>
<Input
variant="filled"
className={"w-1/2"}
prefix={
<SearchOutlined className="text-gray-400" />
}
placeholder="搜索领导姓名或职级..."
onChange={debounce(
(e) => setSearchQuery(e.target.value),
300
)}
size="large"
/>
</div>
</div> {error && (
<Alert
message="加载失败"
description="获取数据时出现错误,请刷新页面重试。"
type="error"
showIcon
/>
)}
</div>
{error && ( <AnimatePresence>
<Alert {isLoading ? (
message="加载失败" <div className="flex justify-center items-center py-12">
description="获取数据时出现错误,请刷新页面重试。" <Spin size="large" tip="加载中..." />
type="error" </div>
showIcon ) : data?.items.length > 0 ? (
/> <motion.div
)} className="grid grid-cols-1 gap-6"
</div> initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}>
{data?.items.map((item: any) => (
<SendCard
key={item.id}
staff={item}
termId={termId || undefined}
/>
))}
</motion.div>
) : (
<motion.div
className="text-center py-12"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}>
<Empty
description="没有找到匹配的收信人"
className="py-12"
/>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence> {/* Pagination */}
{isLoading ? ( {data?.items.length > 0 && (
<div className="flex justify-center items-center py-12"> <div className="flex justify-center mt-8">
<Spin size="large" tip="加载中..." /> <Pagination
</div> current={currentPage}
) : data?.items.length > 0 ? ( total={data?.totalPages || 0}
<motion.div pageSize={pageSize}
className="grid grid-cols-1 gap-6" onChange={(page) => {
initial={{ opacity: 0 }} setCurrentPage(page);
animate={{ opacity: 1 }} window.scrollTo(0, 0);
exit={{ opacity: 0 }} }}
> showSizeChanger={false}
{data?.items.map((item: any) => ( showTotal={(total) => `${total} 条记录`}
<SendCard />
key={item.id} </div>
staff={item} )}
termId={termId || undefined} </div>
/> </div>
))} );
</motion.div>
) : (
<motion.div
className="text-center py-12"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<Empty
description="没有找到匹配的收信人"
className="py-12"
/>
</motion.div>
)}
</AnimatePresence>
{/* Pagination */}
{data?.items.length > 0 && (
<div className="flex justify-center mt-8">
<Pagination
current={currentPage}
total={data?.totalPages || 0}
pageSize={pageSize}
onChange={(page) => {
setCurrentPage(page);
window.scrollTo(0, 0);
}}
showSizeChanger={false}
showTotal={(total) => `${total} 条记录`}
/>
</div>
)}
</div>
</div>
);
} }

View File

@ -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<AvatarUploaderProps> = ({
value,
onChange,
className,
placeholder = "点击上传",
style, // 解构style属性
}) => {
const { handleFileUpload, uploadProgress } = useTusUpload();
const [file, setFile] = useState<UploadingFile | null>(null);
const [previewUrl, setPreviewUrl] = useState<string>(value || "");
const [uploading, setUploading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const { token } = theme.useToken();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
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<string>((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 (
<div
className={`relative w-24 h-24 overflow-hidden cursor-pointer ${className}`}
onClick={triggerUpload}
style={{
border: `1px solid ${token.colorBorder}`,
background: token.colorBgContainer,
...style, // 应用外部传入的样式
}}>
<input
type="file"
ref={inputRef}
onChange={handleChange}
accept="image/*"
style={{ display: "none" }}
/>
{previewUrl ? (
<img
src={previewUrl}
alt="Avatar"
className="w-full h-full object-cover"
/>
) : (
<div className="flex items-center justify-center w-full h-full text-sm text-gray-500">
{placeholder}
</div>
)}
{uploading && (
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-50">
<Spin />
</div>
)}
{file && file.status === "uploading" && (
<div className="absolute bottom-0 left-0 right-0 bg-white bg-opacity-75">
<Progress
percent={Math.round(
uploadProgress?.[file.fileKey!] || 0
)}
showInfo={false}
strokeColor={token.colorPrimary}
/>
</div>
)}
</div>
);
};
export default AvatarUploader;

View File

@ -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<FileItemProps> = memo(
({ file, progress, onRemove, isUploaded }) => (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, x: -20 }}
className="group flex items-center p-4 bg-white rounded-xl border border-gray-100 shadow-sm hover:shadow-md transition-all duration-200">
<DocumentIcon className="w-8 h-8 text-blue-500/80" />
<div className="ml-3 flex-1">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-900 truncate max-w-[200px]">
{file.name}
</p>
<button
onClick={() => onRemove(file.name)}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-gray-100 rounded-full"
aria-label={`Remove ${file.name}`}>
<XMarkIcon className="w-5 h-5 text-gray-500" />
</button>
</div>
{!isUploaded && progress !== undefined && (
<div className="mt-2">
<div className="bg-gray-100 rounded-full h-1.5 overflow-hidden">
<motion.div
className="bg-blue-500 h-1.5 rounded-full"
initial={{ width: 0 }}
animate={{ width: `${progress}%` }}
transition={{ duration: 0.3 }}
/>
</div>
<span className="text-xs text-gray-500 mt-1">
{progress}%
</span>
</div>
)}
{isUploaded && (
<div className="mt-2 flex items-center text-green-500">
<CheckCircleIcon className="w-4 h-4 mr-1" />
<span className="text-xs"></span>
</div>
)}
</div>
</motion.div>
)
);
const FileUploader: React.FC<FileUploaderProps> = ({
onSuccess,
onError,
maxSize = 100,
placeholder = "点击或拖拽文件到这里上传",
allowedTypes = ["*/*"],
}) => {
const [isDragging, setIsDragging] = useState(false);
const [files, setFiles] = useState<
Array<{ file: File; isUploaded: boolean }>
>([]);
const fileInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className="w-full space-y-4">
<div
onClick={handleClick}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
className={`
relative flex flex-col items-center justify-center w-full h-32
border-2 border-dashed rounded-lg cursor-pointer
transition-colors duration-200 ease-in-out
${
isDragging
? "border-blue-500 bg-blue-50"
: "border-gray-300 hover:border-blue-500"
}
`}>
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileSelect}
accept={allowedTypes.join(",")}
className="hidden"
/>
<CloudArrowUpIcon className="w-10 h-10 text-gray-400" />
<p className="mt-2 text-sm text-gray-500">{placeholder}</p>
{isDragging && (
<div className="absolute inset-0 flex items-center justify-center bg-blue-100/50 rounded-lg">
<p className="text-blue-500 font-medium">
</p>
</div>
)}
</div>
<AnimatePresence>
<div className="space-y-3">
{files.map(({ file, isUploaded }) => (
<FileItem
key={file.name}
file={file}
progress={isUploaded ? 100 : progress}
onRemove={removeFile}
isUploaded={isUploaded}
/>
))}
</div>
</AnimatePresence>
{uploadError && (
<div className="flex items-center text-red-500 text-sm">
<ExclamationCircleIcon className="w-4 h-4 mr-1" />
<span>{uploadError}</span>
</div>
)}
</div>
);
};
export default FileUploader;

View File

@ -4,194 +4,210 @@ import { motion, AnimatePresence } from "framer-motion";
import { useState, useRef, useCallback, useMemo } from "react"; import { useState, useRef, useCallback, useMemo } from "react";
import { Avatar } from "../../common/element/Avatar"; import { Avatar } from "../../common/element/Avatar";
import { import {
UserOutlined, UserOutlined,
SettingOutlined, SettingOutlined,
QuestionCircleOutlined, QuestionCircleOutlined,
LogoutOutlined LogoutOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { Spin } from "antd"; import { Spin } from "antd";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { MenuItemType } from "./types"; import { MenuItemType } from "./types";
const menuVariants = { const menuVariants = {
hidden: { opacity: 0, scale: 0.95, y: -10 }, hidden: { opacity: 0, scale: 0.95, y: -10 },
visible: { visible: {
opacity: 1, opacity: 1,
scale: 1, scale: 1,
y: 0, y: 0,
transition: { transition: {
type: "spring", type: "spring",
stiffness: 300, stiffness: 300,
damping: 30 damping: 30,
} },
}, },
exit: { exit: {
opacity: 0, opacity: 0,
scale: 0.95, scale: 0.95,
y: -10, y: -10,
transition: { transition: {
duration: 0.2 duration: 0.2,
} },
} },
}; };
export function UserMenu() { export function UserMenu() {
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const { user, logout, isLoading } = useAuth(); const { user, logout, isLoading } = useAuth();
const navigate = useNavigate() const navigate = useNavigate();
useClickOutside(menuRef, () => setShowMenu(false)); useClickOutside(menuRef, () => setShowMenu(false));
const toggleMenu = useCallback(() => { const toggleMenu = useCallback(() => {
setShowMenu(prev => !prev); setShowMenu((prev) => !prev);
}, []); }, []);
const menuItems: MenuItemType[] = useMemo(() => [ const menuItems: MenuItemType[] = useMemo(
{ () => [
icon: <UserOutlined className="text-lg" />, {
label: '个人信息', icon: <UserOutlined className="text-lg" />,
action: () => { }, label: "个人信息",
}, action: () => {},
{ },
icon: <SettingOutlined className="text-lg" />, {
label: '设置', icon: <SettingOutlined className="text-lg" />,
action: () => { label: "设置",
navigate('/admin/staff') action: () => {
}, navigate("/admin/staff");
}, },
{ },
icon: <QuestionCircleOutlined className="text-lg" />, // {
label: '帮助', // icon: <QuestionCircleOutlined className="text-lg" />,
action: () => { }, // label: '帮助',
}, // action: () => { },
{ // },
icon: <LogoutOutlined className="text-lg" />, {
label: '注销', icon: <LogoutOutlined className="text-lg" />,
action: () => logout(), label: "注销",
}, action: () => logout(),
], [logout]); },
],
[logout]
);
const handleMenuItemClick = useCallback((action: () => void) => { const handleMenuItemClick = useCallback((action: () => void) => {
action(); action();
setShowMenu(false); setShowMenu(false);
}, []); }, []);
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center w-10 h-10"> <div className="flex items-center justify-center w-10 h-10">
<Spin size="small" /> <Spin size="small" />
</div> </div>
); );
} }
return ( return (
<div ref={menuRef} className="relative"> <div ref={menuRef} className="relative">
<motion.button <motion.button
aria-label="用户菜单" aria-label="用户菜单"
aria-haspopup="true" aria-haspopup="true"
aria-expanded={showMenu} aria-expanded={showMenu}
aria-controls="user-menu" aria-controls="user-menu"
whileHover={{ scale: 1.02 }} whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }} whileTap={{ scale: 0.98 }}
onClick={toggleMenu} onClick={toggleMenu}
className="relative rounded-full focus:outline-none className="flex items-center rounded-full transition-all duration-200 ease-in-out">
focus:ring-2 focus:ring-[#00538E]/80 focus:ring-offset-2 {/* Avatar 容器,相对定位 */}
focus:ring-offset-white transition-all duration-200 ease-in-out" <div className="relative">
> <Avatar
<Avatar src={user?.avatar}
src={user?.avatar} name={user?.showname || user?.username}
name={user?.showname || user?.username} size={40}
size={40} className="ring-2 ring-white hover:ring-[#00538E]/90
className="ring-2 ring-white hover:ring-[#00538E]/90 transition-all duration-200 ease-in-out shadow-md
transition-all duration-200 ease-in-out shadow-md hover:shadow-lg focus:outline-none
hover:shadow-lg" focus:ring-2 focus:ring-[#00538E]/80 focus:ring-offset-2
/> focus:ring-offset-white "
<span />
className="absolute bottom-0 right-0 h-3 w-3 {/* 小绿点 */}
rounded-full bg-emerald-500 ring-2 ring-white <span
shadow-sm transition-transform duration-200 className="absolute bottom-0 right-0 h-3 w-3
ease-in-out hover:scale-110" rounded-full bg-emerald-500 ring-2 ring-white
aria-hidden="true" shadow-sm transition-transform duration-200
/> ease-in-out hover:scale-110"
</motion.button> aria-hidden="true"
/>
</div>
<AnimatePresence> {/* 用户信息,显示在 Avatar 右侧 */}
{showMenu && ( <div className="flex flex-col space-y-0.5 ml-3 items-start">
<motion.div <span className="text-sm font-semibold text-white">
initial="hidden" {user?.showname || user?.username}
animate="visible" </span>
exit="exit" <span className="text-xs text-white flex items-center gap-1.5">
variants={menuVariants} {user?.department?.name}
role="menu" </span>
id="user-menu" </div>
aria-orientation="vertical" </motion.button>
aria-labelledby="user-menu-button"
style={{ zIndex: 100 }} <AnimatePresence>
className="absolute right-0 mt-3 w-64 origin-top-right {showMenu && (
<motion.div
initial="hidden"
animate="visible"
exit="exit"
variants={menuVariants}
role="menu"
id="user-menu"
aria-orientation="vertical"
aria-labelledby="user-menu-button"
style={{ zIndex: 100 }}
className="absolute right-0 mt-3 w-64 origin-top-right
bg-white rounded-xl overflow-hidden shadow-lg bg-white rounded-xl overflow-hidden shadow-lg
border border-[#E5EDF5]" border border-[#E5EDF5]">
> {/* User Profile Section */}
{/* User Profile Section */} <div
<div className="px-4 py-4 bg-gradient-to-b from-[#F6F9FC] to-white
className="px-4 py-4 bg-gradient-to-b from-[#F6F9FC] to-white border-b border-[#E5EDF5] ">
border-b border-[#E5EDF5] " <div className="flex items-center space-x-4">
<Avatar
src={user?.avatar}
name={user?.showname || user?.username}
size={40}
className="ring-2 ring-white shadow-sm"
/>
<div className="flex flex-col space-y-0.5">
<span className="text-sm font-semibold text-[#00538E]">
{user?.showname || user?.username}
</span>
<span className="text-xs text-[#718096] flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
线
</span>
</div>
</div>
</div>
> {/* Menu Items */}
<div className="flex items-center space-x-4"> <div className="p-2">
<Avatar {menuItems.map((item, index) => (
src={user?.avatar} <button
name={user?.showname || user?.username} key={index}
size={40} role="menuitem"
className="ring-2 ring-white shadow-sm" tabIndex={showMenu ? 0 : -1}
/> onClick={(e) => {
<div className="flex flex-col space-y-0.5"> e.stopPropagation();
<span className="text-sm font-semibold text-[#00538E]"> handleMenuItemClick(item.action);
{user?.showname || user?.username} }}
</span> className={`flex items-center gap-3 w-full px-4 py-3
<span className="text-xs text-[#718096] flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
线
</span>
</div>
</div>
</div>
{/* Menu Items */}
<div className="p-2">
{menuItems.map((item, index) => (
<button
key={index}
role="menuitem"
tabIndex={showMenu ? 0 : -1}
onClick={(e) => {
e.stopPropagation();
handleMenuItemClick(item.action);
}}
className={`flex items-center gap-3 w-full px-4 py-3
text-sm font-medium rounded-lg transition-all text-sm font-medium rounded-lg transition-all
focus:outline-none focus:outline-none
focus:ring-2 focus:ring-[#00538E]/20 focus:ring-2 focus:ring-[#00538E]/20
group relative overflow-hidden group relative overflow-hidden
active:scale-[0.99] active:scale-[0.99]
${item.label === '注销' ${
? 'text-[#B22234] hover:bg-red-50/80 hover:text-red-700' item.label === "注销"
: 'text-[#00538E] hover:bg-[#E6EEF5] hover:text-[#003F6A]' ? "text-[#B22234] hover:bg-red-50/80 hover:text-red-700"
}`} : "text-[#00538E] hover:bg-[#E6EEF5] hover:text-[#003F6A]"
> }`}>
<span className={`w-5 h-5 flex items-center justify-center <span
className={`w-5 h-5 flex items-center justify-center
transition-all duration-200 ease-in-out transition-all duration-200 ease-in-out
group-hover:scale-110 group-hover:rotate-6 group-hover:scale-110 group-hover:rotate-6
group-hover:translate-x-0.5 ${item.label === '注销' group-hover:translate-x-0.5 ${
? 'group-hover:text-red-600' item.label === "注销"
: 'group-hover:text-[#003F6A]'}`}> ? "group-hover:text-red-600"
{item.icon} : "group-hover:text-[#003F6A]"
</span> }`}>
<span>{item.label}</span> {item.icon}
</button> </span>
))} <span>{item.label}</span>
</div> </button>
</motion.div> ))}
)} </div>
</AnimatePresence> </motion.div>
</div> )}
); </AnimatePresence>
</div>
);
} }

View File

@ -7,28 +7,24 @@ import { UserOutlined } from "@ant-design/icons";
import { UserMenu } from "../element/usermenu"; import { UserMenu } from "../element/usermenu";
import SineWavesCanvas from "../../animation/sine-wave"; import SineWavesCanvas from "../../animation/sine-wave";
interface HeaderProps { interface HeaderProps {
onSearch?: (query: string) => void; onSearch?: (query: string) => void;
} }
export const Header = memo(function Header({ onSearch }: HeaderProps) { export const Header = memo(function Header({ onSearch }: HeaderProps) {
const { isAuthenticated } = useAuth() const { isAuthenticated } = useAuth();
return ( return (
<header className="sticky top-0 z-50 bg-gradient-to-br from-primary-500 to-primary-800 text-white shadow-lg"> <header className="sticky top-0 z-50 bg-gradient-to-br from-primary-500 to-primary-800 text-white shadow-lg">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
<div className="py-3"> <div className="py-3">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className=" text-xl font-bold"> <div className=" text-xl font-bold"></div>
<div className="flex-grow max-w-2xl">
<SearchBar onSearch={onSearch} />
</div> </div>
<div className="flex-grow max-w-2xl"> <div className="flex items-center gap-6">
<SearchBar onSearch={onSearch} /> {!isAuthenticated ? (
</div> <Link
<div className="flex items-center gap-6"> to="/auth"
className="group flex items-center gap-2 rounded-lg
{
!isAuthenticated ? <Link
to="/auth"
className="group flex items-center gap-2 rounded-lg
bg-[#00539B]/90 px-5 py-2.5 font-medium bg-[#00539B]/90 px-5 py-2.5 font-medium
shadow-lg transition-all duration-300 shadow-lg transition-all duration-300
hover:-translate-y-0.5 hover:bg-[#0063B8] hover:-translate-y-0.5 hover:bg-[#0063B8]
@ -36,21 +32,21 @@ hover:shadow-xl hover:shadow-[#00539B]/30
focus:outline-none focus:ring-2 focus:outline-none focus:ring-2
focus:ring-[#8EADD4] focus:ring-offset-2 focus:ring-[#8EADD4] focus:ring-offset-2
focus:ring-offset-[#13294B]" focus:ring-offset-[#13294B]"
aria-label="Login" aria-label="Login">
> <UserOutlined
<UserOutlined className="h-5 w-5 transition-transform className="h-5 w-5 transition-transform
group-hover:scale-110 group-hover:rotate-12"></UserOutlined> group-hover:scale-110 group-hover:rotate-12"></UserOutlined>
<span></span> <span></span>
</Link> : <UserMenu /> </Link>
) : (
<UserMenu />
} )}
</div> </div>
</div> </div>
</div> </div>
<Navigation /> <Navigation />
</div> </div>
</header> </header>
); );
}); });

View File

@ -1,96 +1,108 @@
import { NavLink, useLocation } from "react-router-dom"; import { NavLink, useLocation } from "react-router-dom";
import { useNavItem } from "./useNavItem"; import { useNavItem } from "./useNavItem";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import React from "react";
interface NavItem { interface NavItem {
to: string; to: string;
label: string; label: string;
icon?: React.ReactNode; icon?: React.ReactNode;
} }
interface NavigationProps { interface NavigationProps {
className?: string; className?: string;
} }
export default function Navigation({ className }: NavigationProps) { export default function Navigation({ className }: NavigationProps) {
const { navItems } = useNavItem(); const { navItems } = useNavItem();
const location = useLocation(); const location = useLocation();
const isActive = (to: string) => { const isActive = (to: string) => {
const [pathname, search] = to.split('?'); const [pathname, search] = to.split("?");
return location.pathname === pathname && return (
(!search ? !location.search : location.search === `?${search}`); location.pathname === pathname &&
}; (!search ? !location.search : location.search === `?${search}`)
);
};
return ( return (
<nav className={twMerge( <nav
"mt-4 rounded-xl bg-gradient-to-r from-primary-500 to-primary-600 shadow-lg", className={twMerge(
className "mt-4 rounded-xl bg-gradient-to-r from-primary-500 to-primary-600 shadow-lg",
)}> className
<div className="flex flex-col md:flex-row items-stretch"> )}>
{/* Desktop Navigation */} <div className="flex flex-col md:flex-row items-stretch">
<div className="hidden md:flex items-center px-6 py-2 w-full overflow-x-auto"> {/* Desktop Navigation */}
<div className="flex space-x-6 min-w-max"> <div className="hidden md:flex items-center px-6 py-2 w-full overflow-x-auto">
{navItems.map((item) => ( <div className="flex space-x-6 min-w-max">
<NavLink {navItems.map((item) => (
key={item.to} <NavLink
to={item.to} key={item.to}
className={({ isActive: active }) => twMerge( to={item.to}
"relative px-4 py-2.5 text-sm font-medium", className={({ isActive: active }) =>
"text-gray-300 hover:text-white", twMerge(
"transition-all duration-200 ease-out group", "relative px-4 py-2.5 text-sm font-medium",
active && "text-white" "text-gray-300 hover:text-white",
)} "transition-all duration-200 ease-out group",
> active && "text-white"
<span className="relative z-10 flex items-center gap-2 transition-transform group-hover:translate-y-[-1px]"> )
{item.icon} }>
<span className="tracking-wide">{item.label}</span> <span className="relative z-10 flex items-center gap-2 transition-transform group-hover:translate-y-[-1px]">
</span> {item.icon}
<span className="tracking-wide">
{item.label}
</span>
</span>
{/* Active Indicator */} {/* Active Indicator */}
<span className={twMerge( <span
"absolute bottom-0 left-1/2 h-[2px] bg-blue-400", className={twMerge(
"transition-all duration-300 ease-out", "absolute bottom-0 left-1/2 h-[2px] bg-blue-400",
"transform -translate-x-1/2", "transition-all duration-300 ease-out",
isActive(item.to) "transform -translate-x-1/2",
? "w-full opacity-100" isActive(item.to)
: "w-0 opacity-0 group-hover:w-1/2 group-hover:opacity-40" ? "w-full opacity-100"
)} /> : "w-0 opacity-0 group-hover:w-1/2 group-hover:opacity-40"
)}
/>
{/* Hover Glow Effect */} {/* Hover Glow Effect */}
<span className={twMerge( <span
"absolute inset-0 rounded-lg bg-blue-400/0", className={twMerge(
"transition-all duration-300", "absolute inset-0 rounded-lg bg-blue-400/0",
"group-hover:bg-blue-400/5" "transition-all duration-300",
)} /> "group-hover:bg-blue-400/5"
</NavLink> )}
))} />
</div> </NavLink>
</div> ))}
</div>
</div>
{/* Mobile Navigation */} {/* Mobile Navigation */}
<div className="md:hidden flex overflow-x-auto scrollbar-none px-4 py-2"> <div className="md:hidden flex overflow-x-auto scrollbar-none px-4 py-2">
<div className="flex space-x-4 min-w-max"> <div className="flex space-x-4 min-w-max">
{navItems.map((item) => ( {navItems.map((item) => (
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
className={({ isActive: active }) => twMerge( className={({ isActive: active }) =>
"px-3 py-1.5 text-sm font-medium rounded-full", twMerge(
"transition-colors duration-200", "px-3 py-1.5 text-sm font-medium rounded-full",
"text-gray-300 hover:text-white", "transition-colors duration-200",
active && "bg-blue-500/20 text-white" "text-gray-300 hover:text-white",
)} active && "bg-blue-500/20 text-white"
> )
<span className="flex items-center gap-1.5"> }>
{item.icon} <span className="flex items-center gap-1.5">
<span>{item.label}</span> {item.icon}
</span> <span>{item.label}</span>
</NavLink> </span>
))} </NavLink>
</div> ))}
</div> </div>
</div> </div>
</nav> </div>
); </nav>
);
} }

View File

@ -1,66 +1,87 @@
import { api } from "@nice/client"; import { api } from "@nice/client";
import { TaxonomySlug } from "@nice/common"; import { TaxonomySlug } from "@nice/common";
import { useMemo } from "react"; import React, { useMemo } from "react";
import { MailOutlined, SendOutlined } from "@ant-design/icons";
import { import {
FileTextOutlined, FileTextOutlined,
ScheduleOutlined, ScheduleOutlined,
QuestionCircleOutlined, QuestionCircleOutlined,
FolderOutlined, FolderOutlined,
TagsOutlined TagsOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { useAuth } from "@web/src/providers/auth-provider";
export interface NavItem { export interface NavItem {
to: string; to: string;
label: string; label: string;
icon?: React.ReactNode; icon?: React.ReactNode;
} }
export function useNavItem() { export function useNavItem() {
const { data } = api.term.findMany.useQuery({ const { user } = useAuth();
where: { const { data } = api.term.findMany.useQuery({
taxonomy: { slug: TaxonomySlug.CATEGORY } where: {
} taxonomy: { slug: TaxonomySlug.CATEGORY },
}); },
});
const navItems = useMemo(() => { const navItems = useMemo(() => {
// 定义固定的导航项 // 定义固定的导航项
const staticItems = { const staticItems = {
letterList: { inbox: {
to: "/", to: user ? "/" : "/inbox",
label: "公开信件", label: "我收到的",
icon: <FileTextOutlined className="text-base" /> icon: <MailOutlined className="text-base" />,
}, },
letterProgress: { outbox: {
to: "/letter-progress", to: "/outbox",
label: "进度查询", label: "我发出的",
icon: <ScheduleOutlined className="text-base" /> icon: <SendOutlined className="text-base" />,
}, },
help: { letterList: {
to: "/help", to: !user ? "/" : "/letter-list",
label: "使用帮助", label: "公开信件",
icon: <QuestionCircleOutlined className="text-base" /> icon: <FileTextOutlined className="text-base" />,
} },
}; letterProgress: {
to: "/letter-progress",
label: "进度查询",
icon: <ScheduleOutlined className="text-base" />,
},
// help: {
// to: "/help",
// label: "使用帮助",
// icon: <QuestionCircleOutlined className="text-base" />
// }
};
if (!data) { if (!data) {
return [staticItems.letterList, staticItems.letterProgress, staticItems.help]; return [
} user && staticItems.inbox,
user && staticItems.outbox,
staticItems.letterList,
staticItems.letterProgress,
// staticItems.help,
].filter(Boolean);
}
// 构建分类导航项 // 构建分类导航项
const categoryItems = data.map(term => ({ const categoryItems = data.map((term) => ({
to: `/write-letter?termId=${term.id}`, to: `/write-letter?termId=${term.id}`,
label: term.name, label: term.name,
icon: <TagsOutlined className="text-base"></TagsOutlined> icon: <TagsOutlined className="text-base"></TagsOutlined>,
})); }));
// 按照指定顺序返回导航项 // 按照指定顺序返回导航项
return [ return [
staticItems.letterList, user && staticItems.inbox,
staticItems.letterProgress, user && staticItems.outbox,
...categoryItems, staticItems.letterList,
staticItems.help staticItems.letterProgress,
]; ...categoryItems,
}, [data]); // staticItems.help,
].filter(Boolean);
}, [data]);
return { navItems }; return { navItems };
} }

View File

@ -1,113 +1,114 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from "react";
import { Input, Pagination, Empty, Spin } from 'antd'; import { Input, Pagination, Empty, Spin } from "antd";
import { api, RouterInputs } from "@nice/client"; import { api, RouterInputs } from "@nice/client";
import { LetterCard } from "../LetterCard"; import { LetterCard } from "../LetterCard";
import { NonVoid } from "@nice/utils"; import { NonVoid } from "@nice/utils";
import { SearchOutlined } from '@ant-design/icons'; import { SearchOutlined } from "@ant-design/icons";
import debounce from 'lodash/debounce'; import debounce from "lodash/debounce";
import { postDetailSelect } from '@nice/common'; import { postDetailSelect } from "@nice/common";
export default function LetterList({ params }: { params: NonVoid<RouterInputs["post"]["findManyWithPagination"]> }) { export default function LetterList({
const [searchText, setSearchText] = useState(''); params,
const [currentPage, setCurrentPage] = useState(1); }: {
params: NonVoid<RouterInputs["post"]["findManyWithPagination"]>;
}) {
const [searchText, setSearchText] = useState("");
const [currentPage, setCurrentPage] = useState(1);
const { data, isLoading } = api.post.findManyWithPagination.useQuery({ const { data, isLoading } = api.post.findManyWithPagination.useQuery({
page: currentPage, page: currentPage,
pageSize: params.pageSize, pageSize: params.pageSize,
where: { where: {
OR: [{ OR: [
title: { {
contains: searchText title: {
} contains: searchText,
}], },
...params?.where },
}, ],
orderBy: { ...params?.where,
updatedAt: "desc" },
}, orderBy: {
select: { updatedAt: "desc",
...postDetailSelect, },
...params.select select: {
} ...postDetailSelect,
}); ...params.select,
},
});
const debouncedSearch = useMemo( const debouncedSearch = useMemo(
() => () =>
debounce((value: string) => { debounce((value: string) => {
setSearchText(value); setSearchText(value);
setCurrentPage(1); setCurrentPage(1);
}, 300), }, 300),
[] []
); );
// Cleanup debounce on unmount // Cleanup debounce on unmount
useEffect(() => { useEffect(() => {
return () => { return () => {
debouncedSearch.cancel(); debouncedSearch.cancel();
}; };
}, [debouncedSearch]); }, [debouncedSearch]);
const handleSearch = (value: string) => { const handleSearch = (value: string) => {
debouncedSearch(value); debouncedSearch(value);
}; };
const handlePageChange = (page: number) => { const handlePageChange = (page: number) => {
setCurrentPage(page); setCurrentPage(page);
// Scroll to top when page changes // Scroll to top when page changes
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: "smooth" });
}; };
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Search Bar */} {/* Search Bar */}
<div className="p-6 transition-all "> <div className="p-6 transition-all ">
<Input <Input
variant="filled" variant="filled"
className='w-full' className="w-full"
placeholder="搜索信件标题..." placeholder="搜索信件标题..."
allowClear allowClear
size="large" size="large"
onChange={(e) => handleSearch(e.target.value)} onChange={(e) => handleSearch(e.target.value)}
prefix={<SearchOutlined className="text-gray-400" />} prefix={<SearchOutlined className="text-gray-400" />}
/> />
</div> </div>
{/* Content Area */} {/* Content Area */}
<div className="flex-grow px-6"> <div className="flex-grow px-6">
{isLoading ? ( {isLoading ? (
<div className='flex justify-center items-center pt-6'> <div className="flex justify-center items-center pt-6">
<Spin size='large'></Spin> <Spin size="large"></Spin>
</div> </div>
) : data?.items.length ? ( ) : data?.items.length ? (
<> <>
<div className="grid grid-cols-1 md:grid-cols-1 lg:grid-cols-1 gap-4 mb-6"> <div className="grid grid-cols-1 md:grid-cols-1 lg:grid-cols-1 gap-4 mb-6">
{data.items.map((letter: any) => ( {data.items.map((letter: any) => (
<LetterCard key={letter.id} letter={letter} /> <LetterCard key={letter.id} letter={letter} />
))} ))}
</div> </div>
<div className="flex justify-center pb-6"> <div className="flex justify-center pb-6">
<Pagination <Pagination
current={currentPage} current={currentPage}
total={data.totalCount} total={data.totalCount}
pageSize={params.pageSize} pageSize={params.pageSize}
onChange={handlePageChange} onChange={handlePageChange}
showSizeChanger={false} showSizeChanger={false}
showQuickJumper showQuickJumper
/> />
</div> </div>
</> </>
) : ( ) : (
<div className="flex flex-col justify-center items-center pt-6"> <div className="flex flex-col justify-center items-center pt-6">
<Empty <Empty
description={
description={ searchText ? "未找到相关信件" : "暂无信件"
searchText ? "未找到相关信件" : "暂无信件" }
} />
</div>
)}
/> </div>
</div> </div>
)} );
</div>
</div>
);
} }

View File

@ -1,8 +1,8 @@
import StaffList from "./staff-list"; import StaffList from "./staff-list";
import { ObjectType, RolePerms } from "@nice/common" import { ObjectType, RolePerms } from "@nice/common";
import { Icon } from "@nice/iconer" import { Icon } from "@nice/iconer";
import StaffModal from "./staff-modal"; 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 { useAuth } from "@web/src/providers/auth-provider";
import { Button } from "antd"; import { Button } from "antd";
import DepartmentSelect from "../department/department-select"; import DepartmentSelect from "../department/department-select";
@ -10,63 +10,87 @@ import { FormInstance, useForm } from "antd/es/form/Form";
import AdminHeader from "../../layout/admin/AdminHeader"; import AdminHeader from "../../layout/admin/AdminHeader";
export const StaffEditorContext = createContext<{ export const StaffEditorContext = createContext<{
domainId: string, domainId: string;
modalOpen: boolean, modalOpen: boolean;
setDomainId: React.Dispatch<React.SetStateAction<string>>, setDomainId: React.Dispatch<React.SetStateAction<string>>;
setModalOpen: React.Dispatch<React.SetStateAction<boolean>>, setModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
editId: string, editId: string;
setEditId: React.Dispatch<React.SetStateAction<string>>, setEditId: React.Dispatch<React.SetStateAction<string>>;
form: FormInstance<any>, form: FormInstance<any>;
formLoading: boolean, formLoading: boolean;
setFormLoading: React.Dispatch<React.SetStateAction<boolean>>, setFormLoading: React.Dispatch<React.SetStateAction<boolean>>;
canManageAnyStaff: boolean canManageAnyStaff: boolean;
}>({ }>({
domainId: undefined, domainId: undefined,
modalOpen: false, modalOpen: false,
setDomainId: undefined, setDomainId: undefined,
setModalOpen: undefined, setModalOpen: undefined,
editId: undefined, editId: undefined,
setEditId: undefined, setEditId: undefined,
form: undefined, form: undefined,
formLoading: undefined, formLoading: undefined,
setFormLoading: undefined, setFormLoading: undefined,
canManageAnyStaff: false canManageAnyStaff: false,
}); });
export default function StaffEditor() { export default function StaffEditor() {
const [form] = useForm() const [form] = useForm();
const [domainId, setDomainId] = useState<string>(); const [domainId, setDomainId] = useState<string>();
const [modalOpen, setModalOpen] = useState<boolean>(false); const [modalOpen, setModalOpen] = useState<boolean>(false);
const [editId, setEditId] = useState<string>() const [editId, setEditId] = useState<string>();
const { user, hasSomePermissions } = useAuth() const { user, hasSomePermissions } = useAuth();
const [formLoading, setFormLoading] = useState<boolean>() const [formLoading, setFormLoading] = useState<boolean>();
useEffect(() => { useEffect(() => {
if (user) { if (user) {
setDomainId(user.domainId) setDomainId(user.domainId);
} }
}, [user]) }, [user]);
const canManageStaff = useMemo(() => { const canManageStaff = useMemo(() => {
return hasSomePermissions(RolePerms.MANAGE_ANY_STAFF, RolePerms.MANAGE_DOM_STAFF) return hasSomePermissions(
}, [user]) RolePerms.MANAGE_ANY_STAFF,
const canManageAnyStaff = useMemo(() => { RolePerms.MANAGE_DOM_STAFF
return hasSomePermissions(RolePerms.MANAGE_ANY_STAFF) );
}, [user]) }, [user]);
return <StaffEditorContext.Provider value={{ canManageAnyStaff, formLoading, setFormLoading, form, editId, setEditId, domainId, modalOpen, setDomainId, setModalOpen }}> const canManageAnyStaff = useMemo(() => {
<AdminHeader roomId="staff-editor"> return hasSomePermissions(RolePerms.MANAGE_ANY_STAFF);
<div className="flex items-center gap-4"> }, [user]);
<DepartmentSelect rootId={user?.domainId} onChange={(value) => setDomainId(value as string)} disabled={!canManageAnyStaff} value={domainId} className="w-48" domain={true}></DepartmentSelect> return (
{canManageStaff && <Button <StaffEditorContext.Provider
value={{
type="primary" canManageAnyStaff,
icon={<Icon name="add"></Icon>} formLoading,
onClick={() => { setFormLoading,
setModalOpen(true) form,
}}> editId,
setEditId,
</Button>} domainId,
</div> modalOpen,
</AdminHeader> setDomainId,
<StaffList domainId={domainId}></StaffList> setModalOpen,
<StaffModal></StaffModal> }}>
</StaffEditorContext.Provider> <AdminHeader roomId="staff-editor">
} <div className="flex items-center gap-4">
<DepartmentSelect
rootId={user?.domainId}
onChange={(value) => setDomainId(value as string)}
disabled={!canManageAnyStaff}
value={domainId}
className="w-48"
domain={true}></DepartmentSelect>
{canManageStaff && (
<Button
type="primary"
icon={<Icon name="add"></Icon>}
onClick={() => {
setModalOpen(true);
}}>
</Button>
)}
</div>
</AdminHeader>
<StaffList domainId={domainId}></StaffList>
<StaffModal></StaffModal>
</StaffEditorContext.Provider>
);
}

View File

@ -1,10 +1,12 @@
import { Button, Form, Input, Spin, Switch, message } from "antd"; 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 { useStaff } from "@nice/client";
import DepartmentSelect from "../department/department-select"; import DepartmentSelect from "../department/department-select";
import { api } from "@nice/client" import { api } from "@nice/client";
import { StaffEditorContext } from "./staff-editor"; import { StaffEditorContext } from "./staff-editor";
import { useAuth } from "@web/src/providers/auth-provider"; import { useAuth } from "@web/src/providers/auth-provider";
import AvatarUploader from "../../common/uploader/AvatarUploader";
import { StaffDto } from "@nice/common";
export default function StaffForm() { export default function StaffForm() {
const { create, update } = useStaff(); // Ensure you have these methods in your hooks const { create, update } = useStaff(); // Ensure you have these methods in your hooks
const { const {
@ -17,7 +19,13 @@ export default function StaffForm() {
canManageAnyStaff, canManageAnyStaff,
setEditId, setEditId,
} = useContext(StaffEditorContext); } = useContext(StaffEditorContext);
const { data, isLoading } = api.staff.findFirst.useQuery( const {
data,
isLoading,
}: {
data: StaffDto;
isLoading: boolean;
} = api.staff.findFirst.useQuery(
{ where: { id: editId } }, { where: { id: editId } },
{ enabled: !!editId } { enabled: !!editId }
); );
@ -31,8 +39,9 @@ export default function StaffForm() {
password, password,
phoneNumber, phoneNumber,
officerId, officerId,
enabled enabled,
} = values photoUrl,
} = values;
setFormLoading(true); setFormLoading(true);
try { try {
if (data && editId) { if (data && editId) {
@ -46,8 +55,11 @@ export default function StaffForm() {
password, password,
phoneNumber, phoneNumber,
officerId, officerId,
enabled enabled,
} meta: {
photoUrl,
},
},
}); });
} else { } else {
await create.mutateAsync({ await create.mutateAsync({
@ -58,8 +70,11 @@ export default function StaffForm() {
domainId: fieldDomainId ? fieldDomainId : domainId, domainId: fieldDomainId ? fieldDomainId : domainId,
password, password,
officerId, officerId,
phoneNumber phoneNumber,
} meta: {
photoUrl,
},
},
}); });
form.resetFields(); form.resetFields();
if (deptId) form.setFieldValue("deptId", deptId); if (deptId) form.setFieldValue("deptId", deptId);
@ -83,7 +98,8 @@ export default function StaffForm() {
form.setFieldValue("deptId", data.deptId); form.setFieldValue("deptId", data.deptId);
form.setFieldValue("officerId", data.officerId); form.setFieldValue("officerId", data.officerId);
form.setFieldValue("phoneNumber", data.phoneNumber); form.setFieldValue("phoneNumber", data.phoneNumber);
form.setFieldValue("enabled", data.enabled) form.setFieldValue("enabled", data.enabled);
form.setFieldValue("photoUrl", data?.meta?.photoUrl);
} }
}, [data]); }, [data]);
useEffect(() => { useEffect(() => {
@ -91,7 +107,7 @@ export default function StaffForm() {
form.setFieldValue("domainId", domainId); form.setFieldValue("domainId", domainId);
form.setFieldValue("deptId", domainId); form.setFieldValue("deptId", domainId);
} }
}, [domainId, data]); }, [domainId, data as any]);
return ( return (
<div className="relative"> <div className="relative">
{isLoading && ( {isLoading && (
@ -106,48 +122,75 @@ export default function StaffForm() {
requiredMark="optional" requiredMark="optional"
autoComplete="off" autoComplete="off"
onFinish={handleFinish}> onFinish={handleFinish}>
<div className=" flex items-center gap-4 mb-2">
<div>
<Form.Item
name={"photoUrl"}
label="头像"
noStyle
rules={[{ required: true }]}>
<AvatarUploader
style={{
width: "100px",
height: "120px",
}}></AvatarUploader>
</Form.Item>
</div>
<div className="grid grid-cols-1 gap-2 flex-1">
<Form.Item
noStyle
rules={[{ required: true }]}
name={"username"}
label="帐号">
<Input
placeholder="请输入用户名"
allowClear
autoComplete="new-username" // 使用非标准的自动完成值
spellCheck={false}
/>
</Form.Item>
<Form.Item
noStyle
rules={[{ required: true }]}
name={"showname"}
label="姓名">
<Input
placeholder="请输入姓名"
allowClear
autoComplete="new-name" // 使用非标准的自动完成值
spellCheck={false}
/>
</Form.Item>
<Form.Item
noStyle
name={"deptId"}
label="所属单位"
rules={[{ required: true }]}>
<DepartmentSelect
rootId={isRoot ? undefined : domainId}
/>
</Form.Item>
</div>
</div>
{canManageAnyStaff && ( {canManageAnyStaff && (
<Form.Item <Form.Item
name={"domainId"} name={"domainId"}
label="所属域" label="所属域"
rules={[{ required: true }]}> rules={[{ required: true }]}>
<DepartmentSelect <DepartmentSelect
placeholder="选择域"
rootId={isRoot ? undefined : domainId} rootId={isRoot ? undefined : domainId}
domain={true} domain={true}
/> />
</Form.Item> </Form.Item>
)} )}
<Form.Item
name={"deptId"}
label="所属单位"
rules={[{ required: true }]}>
<DepartmentSelect rootId={isRoot ? undefined : domainId} />
</Form.Item>
<Form.Item
rules={[{ required: true }]}
name={"username"}
label="帐号">
<Input allowClear
autoComplete="new-username" // 使用非标准的自动完成值
spellCheck={false}
/>
</Form.Item>
<Form.Item
rules={[{ required: true }]}
name={"showname"}
label="姓名">
<Input allowClear
autoComplete="new-name" // 使用非标准的自动完成值
spellCheck={false}
/>
</Form.Item>
<Form.Item <Form.Item
rules={[ rules={[
{ {
required: false, required: false,
pattern: /^\d{5,18}$/, pattern: /^\d{5,18}$/,
message: "请输入正确的证件号(数字)" message: "请输入正确的证件号(数字)",
} },
]} ]}
name={"officerId"} name={"officerId"}
label="证件号"> label="证件号">
@ -158,20 +201,29 @@ export default function StaffForm() {
{ {
required: false, required: false,
pattern: /^\d{6,11}$/, pattern: /^\d{6,11}$/,
message: "请输入正确的手机号(数字)" message: "请输入正确的手机号(数字)",
} },
]} ]}
name={"phoneNumber"} name={"phoneNumber"}
label="手机号"> label="手机号">
<Input autoComplete="new-phone" // 使用非标准的自动完成值 <Input
spellCheck={false} allowClear /> autoComplete="new-phone" // 使用非标准的自动完成值
spellCheck={false}
allowClear
/>
</Form.Item> </Form.Item>
<Form.Item label="密码" name={"password"}> <Form.Item label="密码" name={"password"}>
<Input.Password spellCheck={false} visibilityToggle autoComplete="new-password" /> <Input.Password
spellCheck={false}
visibilityToggle
autoComplete="new-password"
/>
</Form.Item> </Form.Item>
{editId && <Form.Item label="是否启用" name={"enabled"}> {editId && (
<Switch></Switch> <Form.Item label="是否启用" name={"enabled"}>
</Form.Item>} <Switch></Switch>
</Form.Item>
)}
</Form> </Form>
</div> </div>
); );

View File

@ -23,6 +23,9 @@ import LetterDetailPage from "../app/main/letter/detail/page";
import AdminLayout from "../components/layout/admin/AdminLayout"; import AdminLayout from "../components/layout/admin/AdminLayout";
import { CustomRouteObject } from "./types"; import { CustomRouteObject } from "./types";
import { adminRoute } from "./admin-route"; 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[] = [ export const routes: CustomRouteObject[] = [
{ {
path: "/", path: "/",
@ -36,8 +39,20 @@ export const routes: CustomRouteObject[] = [
{ {
element: <MainLayout></MainLayout>, element: <MainLayout></MainLayout>,
children: [ children: [
{
path: "inbox",
element: <InboxPage></InboxPage>,
},
{
path: "outbox",
element: <OutboxPage></OutboxPage>,
},
{ {
index: true, index: true,
element: <IndexPage></IndexPage>,
},
{
path: "letter-list",
element: <LetterListPage></LetterListPage>, element: <LetterListPage></LetterListPage>,
}, },
{ {

View File

@ -8,6 +8,7 @@
"scripts": { "scripts": {
"build": "tsup", "build": "tsup",
"dev": "tsup --watch", "dev": "tsup --watch",
"dev-static": "tsup --no-watch",
"clean": "rimraf dist", "clean": "rimraf dist",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },