Merge branch 'main' of http://113.45.157.195:3003/insiinc/leader-mail
This commit is contained in:
commit
cabec45c64
|
@ -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');
|
||||||
|
|
|
@ -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'
|
||||||
>;
|
>;
|
|
@ -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 }) => {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
}),
|
}),
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>;
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
@ -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;
|
|
|
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
|
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 };
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in New Issue