add
This commit is contained in:
parent
d411935430
commit
483058090d
|
@ -1,9 +1,6 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
import {
|
||||
ObjectType,
|
||||
RoleMapMethodSchema,
|
||||
} from '@nice/common';
|
||||
import { ObjectType, RoleMapMethodSchema } from '@nice/common';
|
||||
import { RoleMapService } from './rolemap.service';
|
||||
|
||||
@Injectable()
|
||||
|
@ -11,7 +8,7 @@ export class RoleMapRouter {
|
|||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly roleMapService: RoleMapService,
|
||||
) { }
|
||||
) {}
|
||||
router = this.trpc.router({
|
||||
deleteAllRolesForObject: this.trpc.protectProcedure
|
||||
.input(RoleMapMethodSchema.deleteWithObject)
|
||||
|
@ -67,5 +64,10 @@ export class RoleMapRouter {
|
|||
.query(async ({ input }) => {
|
||||
return this.roleMapService.getStaffsNotMap(input);
|
||||
}),
|
||||
getStaffIdsByRoleNames: this.trpc.procedure
|
||||
.input(RoleMapMethodSchema.getStaffIdsByRoleNames)
|
||||
.query(async ({ input }) => {
|
||||
return this.roleMapService.getStaffIdsByRoleNames(input);
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -44,7 +44,7 @@ export class RoleMapService extends RowModelService {
|
|||
) {
|
||||
const { roleId, domainId } = request;
|
||||
// Base conditions
|
||||
let condition = super.createGetRowsFilters(request, staff);
|
||||
const condition = super.createGetRowsFilters(request, staff);
|
||||
if (isFieldCondition(condition)) return;
|
||||
// Adding conditions based on parameters existence
|
||||
if (roleId) {
|
||||
|
@ -64,10 +64,7 @@ export class RoleMapService extends RowModelService {
|
|||
return condition;
|
||||
}
|
||||
|
||||
protected async getRowDto(
|
||||
row: any,
|
||||
staff?: UserProfile,
|
||||
): Promise<any> {
|
||||
protected async getRowDto(row: any, staff?: UserProfile): Promise<any> {
|
||||
if (!row.id) return row;
|
||||
return row;
|
||||
}
|
||||
|
@ -126,15 +123,17 @@ export class RoleMapService extends RowModelService {
|
|||
data: roleMaps,
|
||||
});
|
||||
});
|
||||
const wrapResult = Promise.all(result.map(async item => {
|
||||
const staff = await db.staff.findMany({
|
||||
include: { department: true },
|
||||
where: {
|
||||
id: item.objectId
|
||||
}
|
||||
})
|
||||
return { ...item, staff }
|
||||
}))
|
||||
const wrapResult = Promise.all(
|
||||
result.map(async (item) => {
|
||||
const staff = await db.staff.findMany({
|
||||
include: { department: true },
|
||||
where: {
|
||||
id: item.objectId,
|
||||
},
|
||||
});
|
||||
return { ...item, staff };
|
||||
}),
|
||||
);
|
||||
return wrapResult;
|
||||
}
|
||||
async addRoleForObjects(
|
||||
|
@ -187,11 +186,11 @@ export class RoleMapService extends RowModelService {
|
|||
{ objectId: staffId, objectType: ObjectType.STAFF },
|
||||
...(deptId || ancestorDeptIds.length > 0
|
||||
? [
|
||||
{
|
||||
objectId: { in: [deptId, ...ancestorDeptIds].filter(Boolean) },
|
||||
objectType: ObjectType.DEPARTMENT,
|
||||
},
|
||||
]
|
||||
{
|
||||
objectId: { in: [deptId, ...ancestorDeptIds].filter(Boolean) },
|
||||
objectType: ObjectType.DEPARTMENT,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
// Helper function to fetch roles based on domain ID.
|
||||
|
@ -260,7 +259,9 @@ export class RoleMapService extends RowModelService {
|
|||
// const processedItems = await Promise.all(items.map(item => this.genRoleMapDto(item)));
|
||||
return { items, totalCount };
|
||||
}
|
||||
async getStaffsNotMap(data: z.infer<typeof RoleMapMethodSchema.getStaffsNotMap>) {
|
||||
async getStaffsNotMap(
|
||||
data: z.infer<typeof RoleMapMethodSchema.getStaffsNotMap>,
|
||||
) {
|
||||
const { domainId, roleId } = data;
|
||||
let staffs = await db.staff.findMany({
|
||||
where: {
|
||||
|
@ -280,6 +281,35 @@ export class RoleMapService extends RowModelService {
|
|||
);
|
||||
return staffs;
|
||||
}
|
||||
async getStaffIdsByRoleNames(
|
||||
data: z.infer<typeof RoleMapMethodSchema.getStaffIdsByRoleNames>,
|
||||
) {
|
||||
const { roleNames } = data;
|
||||
const roles = await db.role.findMany({
|
||||
where: {
|
||||
name: {
|
||||
in: roleNames,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
const roleMaps = await db.roleMap.findMany({
|
||||
where: {
|
||||
roleId: {
|
||||
in: roles.map((role) => role.id),
|
||||
},
|
||||
objectType: ObjectType.STAFF,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
objectId: true,
|
||||
},
|
||||
});
|
||||
const staffIds = roleMaps.map((roleMap) => roleMap.objectId);
|
||||
return staffIds;
|
||||
}
|
||||
/**
|
||||
* 更新角色映射
|
||||
* @param data 包含更新信息的数据
|
||||
|
@ -300,7 +330,9 @@ export class RoleMapService extends RowModelService {
|
|||
* @param data 包含角色ID和域ID的数据
|
||||
* @returns 角色映射详情,包含部门ID和员工ID列表
|
||||
*/
|
||||
async getRoleMapDetail(data: z.infer<typeof RoleMapMethodSchema.getRoleMapDetail>) {
|
||||
async getRoleMapDetail(
|
||||
data: z.infer<typeof RoleMapMethodSchema.getRoleMapDetail>,
|
||||
) {
|
||||
const { roleId, domainId } = data;
|
||||
const res = await db.roleMap.findMany({ where: { roleId, domainId } });
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ import { ResourceService } from './resource.service';
|
|||
import { TrpcService } from '@server/trpc/trpc.service';
|
||||
|
||||
@Module({
|
||||
exports: [ResourceRouter, ResourceService],
|
||||
providers: [ResourceRouter, ResourceService, TrpcService],
|
||||
exports: [ResourceRouter, ResourceService],
|
||||
providers: [ResourceRouter, ResourceService, TrpcService],
|
||||
})
|
||||
export class ResourceModule { }
|
||||
export class ResourceModule {}
|
||||
|
|
|
@ -3,68 +3,75 @@ import { TrpcService } from '@server/trpc/trpc.service';
|
|||
import { Prisma, UpdateOrderSchema } from '@nice/common';
|
||||
import { ResourceService } from './resource.service';
|
||||
import { z, ZodType } from 'zod';
|
||||
const ResourceCreateArgsSchema: ZodType<Prisma.ResourceCreateArgs> = z.any()
|
||||
const ResourceCreateManyInputSchema: ZodType<Prisma.ResourceCreateManyInput> = z.any()
|
||||
const ResourceDeleteManyArgsSchema: ZodType<Prisma.ResourceDeleteManyArgs> = z.any()
|
||||
const ResourceFindManyArgsSchema: ZodType<Prisma.ResourceFindManyArgs> = z.any()
|
||||
const ResourceFindFirstArgsSchema: ZodType<Prisma.ResourceFindFirstArgs> = z.any()
|
||||
const ResourceWhereInputSchema: ZodType<Prisma.ResourceWhereInput> = z.any()
|
||||
const ResourceSelectSchema: ZodType<Prisma.ResourceSelect> = z.any()
|
||||
const ResourceCreateArgsSchema: ZodType<Prisma.ResourceCreateArgs> = z.any();
|
||||
const ResourceCreateManyInputSchema: ZodType<Prisma.ResourceCreateManyInput> =
|
||||
z.any();
|
||||
const ResourceDeleteManyArgsSchema: ZodType<Prisma.ResourceDeleteManyArgs> =
|
||||
z.any();
|
||||
const ResourceFindManyArgsSchema: ZodType<Prisma.ResourceFindManyArgs> =
|
||||
z.any();
|
||||
const ResourceFindFirstArgsSchema: ZodType<Prisma.ResourceFindFirstArgs> =
|
||||
z.any();
|
||||
const ResourceWhereInputSchema: ZodType<Prisma.ResourceWhereInput> = z.any();
|
||||
const ResourceSelectSchema: ZodType<Prisma.ResourceSelect> = z.any();
|
||||
|
||||
@Injectable()
|
||||
export class ResourceRouter {
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly resourceService: ResourceService,
|
||||
) { }
|
||||
router = this.trpc.router({
|
||||
create: this.trpc.protectProcedure
|
||||
.input(ResourceCreateArgsSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.resourceService.create(input, { staff });
|
||||
}),
|
||||
createMany: this.trpc.protectProcedure.input(z.array(ResourceCreateManyInputSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
private readonly resourceService: ResourceService,
|
||||
) {}
|
||||
router = this.trpc.router({
|
||||
create: this.trpc.protectProcedure
|
||||
.input(ResourceCreateArgsSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.resourceService.create(input, { staff });
|
||||
}),
|
||||
createMany: this.trpc.protectProcedure
|
||||
.input(z.array(ResourceCreateManyInputSchema))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
|
||||
return await this.resourceService.createMany({ data: input }, staff);
|
||||
}),
|
||||
deleteMany: this.trpc.procedure
|
||||
.input(ResourceDeleteManyArgsSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
return await this.resourceService.deleteMany(input);
|
||||
}),
|
||||
findFirst: this.trpc.procedure
|
||||
.input(ResourceFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.query(async ({ input }) => {
|
||||
return await this.resourceService.findFirst(input);
|
||||
}),
|
||||
softDeleteByIds: this.trpc.protectProcedure
|
||||
.input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema
|
||||
.mutation(async ({ input }) => {
|
||||
return this.resourceService.softDeleteByIds(input.ids);
|
||||
}),
|
||||
updateOrder: this.trpc.protectProcedure
|
||||
.input(UpdateOrderSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
return this.resourceService.updateOrder(input);
|
||||
}),
|
||||
findMany: this.trpc.procedure
|
||||
.input(ResourceFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.query(async ({ input }) => {
|
||||
return await this.resourceService.findMany(input);
|
||||
}),
|
||||
findManyWithCursor: this.trpc.protectProcedure
|
||||
.input(z.object({
|
||||
cursor: z.any().nullish(),
|
||||
take: z.number().nullish(),
|
||||
where: ResourceWhereInputSchema.nullish(),
|
||||
select: ResourceSelectSchema.nullish()
|
||||
}))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.resourceService.findManyWithCursor(input);
|
||||
}),
|
||||
});
|
||||
return await this.resourceService.createMany({ data: input }, staff);
|
||||
}),
|
||||
deleteMany: this.trpc.procedure
|
||||
.input(ResourceDeleteManyArgsSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
return await this.resourceService.deleteMany(input);
|
||||
}),
|
||||
findFirst: this.trpc.procedure
|
||||
.input(ResourceFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.query(async ({ input }) => {
|
||||
return await this.resourceService.findFirst(input);
|
||||
}),
|
||||
softDeleteByIds: this.trpc.protectProcedure
|
||||
.input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema
|
||||
.mutation(async ({ input }) => {
|
||||
return this.resourceService.softDeleteByIds(input.ids);
|
||||
}),
|
||||
updateOrder: this.trpc.protectProcedure
|
||||
.input(UpdateOrderSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
return this.resourceService.updateOrder(input);
|
||||
}),
|
||||
findMany: this.trpc.procedure
|
||||
.input(ResourceFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
|
||||
.query(async ({ input }) => {
|
||||
return await this.resourceService.findMany(input);
|
||||
}),
|
||||
findManyWithCursor: this.trpc.protectProcedure
|
||||
.input(
|
||||
z.object({
|
||||
cursor: z.any().nullish(),
|
||||
take: z.number().nullish(),
|
||||
where: ResourceWhereInputSchema.nullish(),
|
||||
select: ResourceSelectSchema.nullish(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const { staff } = ctx;
|
||||
return await this.resourceService.findManyWithCursor(input);
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
|
@ -12,4 +12,4 @@ import { StaffRowService } from './staff.row.service';
|
|||
exports: [StaffService, StaffRouter, StaffRowService],
|
||||
controllers: [StaffController],
|
||||
})
|
||||
export class StaffModule { }
|
||||
export class StaffModule {}
|
||||
|
|
|
@ -44,7 +44,7 @@ export class StaffService extends BaseService<Prisma.StaffDelegate> {
|
|||
...data,
|
||||
password: await argon2.hash((data.password || '123456') as string),
|
||||
};
|
||||
|
||||
|
||||
const result = await super.create({ ...args, data: createData });
|
||||
this.emitDataChangedEvent(result, CrudOperation.CREATED);
|
||||
return result;
|
||||
|
|
|
@ -15,6 +15,8 @@ import { WebSocketModule } from '@server/socket/websocket.module';
|
|||
import { RoleMapModule } from '@server/models/rbac/rbac.module';
|
||||
import { TransformModule } from '@server/models/transform/transform.module';
|
||||
|
||||
import { ResourceModule } from '@server/models/resource/resource.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AuthModule,
|
||||
|
@ -30,6 +32,7 @@ import { TransformModule } from '@server/models/transform/transform.module';
|
|||
PostModule,
|
||||
VisitModule,
|
||||
WebSocketModule,
|
||||
ResourceModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [TrpcService, TrpcRouter, Logger],
|
||||
|
|
|
@ -13,10 +13,10 @@ import { VisitRouter } from '@server/models/visit/visit.router';
|
|||
import { RoleMapRouter } from '@server/models/rbac/rolemap.router';
|
||||
import { TransformRouter } from '@server/models/transform/transform.router';
|
||||
import { RoleRouter } from '@server/models/rbac/role.router';
|
||||
import { ResourceRouter } from '../models/resource/resource.router';
|
||||
|
||||
@Injectable()
|
||||
export class TrpcRouter {
|
||||
|
||||
logger = new Logger(TrpcRouter.name);
|
||||
constructor(
|
||||
private readonly trpc: TrpcService,
|
||||
|
@ -31,10 +31,10 @@ export class TrpcRouter {
|
|||
private readonly app_config: AppConfigRouter,
|
||||
private readonly message: MessageRouter,
|
||||
private readonly visitor: VisitRouter,
|
||||
|
||||
) { }
|
||||
private readonly resource: ResourceRouter,
|
||||
) {}
|
||||
getRouter() {
|
||||
return
|
||||
return;
|
||||
}
|
||||
appRouter = this.trpc.router({
|
||||
transform: this.transform.router,
|
||||
|
@ -48,6 +48,7 @@ export class TrpcRouter {
|
|||
message: this.message.router,
|
||||
app_config: this.app_config.router,
|
||||
visitor: this.visitor.router,
|
||||
resource: this.resource.router,
|
||||
});
|
||||
wss: WebSocketServer = undefined;
|
||||
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
import { TrpcRouter } from "./trpc.router";
|
||||
import { TrpcRouter } from './trpc.router';
|
||||
|
||||
export type AppRouter = TrpcRouter[`appRouter`];
|
||||
|
|
|
@ -9,7 +9,7 @@ export default function LetterEditorPage() {
|
|||
const termId = searchParams.get("termId");
|
||||
|
||||
return (
|
||||
<div className="min-h-screen rounded-xl shadow-elegant border-2 border-white bg-gradient-to-b from-slate-100 to-slate-50 ">
|
||||
<div className="shadow-elegant border-2 border-white rounded-xl bg-slate-200">
|
||||
<WriteHeader></WriteHeader>
|
||||
<LetterFormProvider receiverId={receiverId} termId={termId}>
|
||||
<LetterBasicForm />
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
|
@ -9,136 +9,157 @@ import DepartmentSelect from "@web/src/components/models/department/department-s
|
|||
import debounce from "lodash/debounce";
|
||||
import { SearchOutlined } from "@ant-design/icons";
|
||||
import WriteHeader from "./WriteHeader";
|
||||
import { ObjectType, RoleName } from "@nice/common";
|
||||
|
||||
export default function WriteLetterPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const termId = searchParams.get("termId");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedDept, setSelectedDept] = useState<string>();
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 10;
|
||||
const { getTerm } = useTerm();
|
||||
const [searchParams] = useSearchParams();
|
||||
const termId = searchParams.get("termId");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedDept, setSelectedDept] = useState<string>();
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 10;
|
||||
const { getTerm } = useTerm();
|
||||
const { data: enabledStaffIds, isLoading: roleMapIsLoading } =
|
||||
api.rolemap.getStaffIdsByRoleNames.useQuery({
|
||||
roleNames: [RoleName.Leader, RoleName.Organization],
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
const { data, isLoading, error } =
|
||||
api.staff.findManyWithPagination.useQuery({
|
||||
page: currentPage,
|
||||
pageSize,
|
||||
where: {
|
||||
deptId: selectedDept,
|
||||
OR: [
|
||||
{
|
||||
showname: {
|
||||
contains: searchQuery,
|
||||
},
|
||||
},
|
||||
{
|
||||
username: {
|
||||
contains: searchQuery,
|
||||
},
|
||||
},
|
||||
const { data, isLoading, error } =
|
||||
api.staff.findManyWithPagination.useQuery(
|
||||
{
|
||||
page: currentPage,
|
||||
pageSize,
|
||||
where: {
|
||||
id: enabledStaffIds
|
||||
? {
|
||||
in: enabledStaffIds,
|
||||
}
|
||||
: undefined,
|
||||
deptId: selectedDept,
|
||||
OR: [
|
||||
{
|
||||
showname: {
|
||||
contains: searchQuery,
|
||||
},
|
||||
},
|
||||
{
|
||||
username: {
|
||||
contains: searchQuery,
|
||||
},
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
path: ["rank"], // 指定 JSON 字段的路径
|
||||
string_contains: searchQuery, // 对 rank 字段进行模糊搜索
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
orderBy: {
|
||||
order: "asc",
|
||||
},
|
||||
},
|
||||
{
|
||||
enabled: !roleMapIsLoading,
|
||||
}
|
||||
);
|
||||
|
||||
],
|
||||
},
|
||||
orderBy: {
|
||||
order: "desc",
|
||||
}
|
||||
});
|
||||
const resetPage = useCallback(() => {
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const resetPage = useCallback(() => {
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
// Reset page when search or department changes
|
||||
useEffect(() => {
|
||||
resetPage();
|
||||
}, [searchQuery, selectedDept, resetPage]);
|
||||
|
||||
// Reset page when search or department changes
|
||||
useEffect(() => {
|
||||
resetPage();
|
||||
}, [searchQuery, selectedDept, resetPage]);
|
||||
return (
|
||||
<div className="min-h-screen shadow-elegant border-2 border-white rounded-xl bg-slate-200">
|
||||
<WriteHeader term={getTerm(termId)} />
|
||||
<div className=" mx-auto p-6">
|
||||
<div className="mb-4 space-y-4">
|
||||
<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>
|
||||
{error && (
|
||||
<Alert
|
||||
message="加载失败"
|
||||
description="获取数据时出现错误,请刷新页面重试。"
|
||||
type="error"
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<Spin size="large" tip="加载中..." />
|
||||
</div>
|
||||
) : data?.items.length > 0 ? (
|
||||
<motion.div
|
||||
className="grid grid-cols-1 gap-6"
|
||||
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>
|
||||
|
||||
return (
|
||||
<div className="min-h-screen shadow-elegant border-2 border-white rounded-xl bg-slate-200">
|
||||
<WriteHeader term={getTerm(termId)} />
|
||||
<div className=" mx-auto p-6">
|
||||
<div className="mb-4 space-y-4">
|
||||
<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>
|
||||
{error && (
|
||||
<Alert
|
||||
message="加载失败"
|
||||
description="获取数据时出现错误,请刷新页面重试。"
|
||||
type="error"
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<AnimatePresence>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<Spin size="large" tip="加载中..." />
|
||||
</div>
|
||||
) : data?.items.length > 0 ? (
|
||||
<motion.div
|
||||
className="grid grid-cols-1 gap-6"
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,6 +2,7 @@ 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";
|
||||
import { Avatar } from "antd/lib";
|
||||
|
||||
export interface AvatarUploaderProps {
|
||||
value?: string;
|
||||
|
@ -69,7 +70,6 @@ const AvatarUploader: React.FC<AvatarUploaderProps> = ({
|
|||
file?.fileKey
|
||||
);
|
||||
});
|
||||
setPreviewUrl(`http://${env.SERVER_IP}/uploads/${fileId}`);
|
||||
onChange?.(fileId);
|
||||
message.success("头像上传成功");
|
||||
} catch (error) {
|
||||
|
@ -94,7 +94,6 @@ const AvatarUploader: React.FC<AvatarUploaderProps> = ({
|
|||
background: token.colorBgContainer,
|
||||
...style, // 应用外部传入的样式
|
||||
}}>
|
||||
<div>{previewUrl}</div>
|
||||
<input
|
||||
type="file"
|
||||
ref={inputRef}
|
||||
|
@ -103,9 +102,9 @@ const AvatarUploader: React.FC<AvatarUploaderProps> = ({
|
|||
style={{ display: "none" }}
|
||||
/>
|
||||
{previewUrl ? (
|
||||
<img
|
||||
<Avatar
|
||||
src={previewUrl}
|
||||
alt="Avatar"
|
||||
shape="square"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
|
|
|
@ -130,9 +130,8 @@ export const TusUploader = ({ value = [], onChange }: TusUploaderProps) => {
|
|||
<div className="space-y-1">
|
||||
<Upload.Dragger
|
||||
name="files"
|
||||
multiple
|
||||
showUploadList={false}
|
||||
style={{ background: "white", borderStyle: "solid" }}
|
||||
style={{ background: "transparent", borderStyle: "none" }}
|
||||
beforeUpload={handleChange}>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<UploadOutlined />
|
||||
|
@ -143,7 +142,7 @@ export const TusUploader = ({ value = [], onChange }: TusUploaderProps) => {
|
|||
<p className="ant-upload-hint">支持单个或批量上传文件</p>
|
||||
{/* 正在上传的文件 */}
|
||||
{(uploadingFiles.length > 0 || completedFiles.length > 0) && (
|
||||
<div className=" px-2 py-0 border rounded bg-white mt-1 ">
|
||||
<div className=" px-2 py-0 rounded mt-1 ">
|
||||
{uploadingFiles.map((file) => (
|
||||
<div
|
||||
key={file.fileKey}
|
||||
|
|
|
@ -1,31 +1,61 @@
|
|||
import { Link, NavLink, useNavigate } from "react-router-dom";
|
||||
import { memo } from "react";
|
||||
import { memo, useMemo } from "react";
|
||||
import { SearchBar } from "./SearchBar";
|
||||
import Navigation from "./navigation";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
import { UserOutlined } from "@ant-design/icons";
|
||||
import { UserMenu } from "../element/usermenu/usermenu";
|
||||
import { api, useAppConfig } from "@nice/client";
|
||||
import { env } from "@web/src/env";
|
||||
|
||||
export const Header = memo(function Header() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 bg-gradient-to-br from-primary-500 to-primary-800 text-white shadow-lg">
|
||||
<div className=" mx-auto px-4">
|
||||
<div className="py-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="">
|
||||
<span className="text-xl font-bold">首长机关信箱</span>
|
||||
<p className=" text-sm text-secondary-50">聆怀若水,应语如风;纾难化困,践诺成春</p>
|
||||
</div>
|
||||
<div className="flex-grow max-w-2xl">
|
||||
<SearchBar />
|
||||
</div>
|
||||
<div className="flex items-center ">
|
||||
{!isAuthenticated ? (
|
||||
<Link
|
||||
to="/auth"
|
||||
className="group flex items-center gap-2 rounded-lg
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { logo } = useAppConfig();
|
||||
const { data: logoRes, isLoading } = api.resource.findFirst.useQuery(
|
||||
{
|
||||
where: {
|
||||
fileId: logo,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
url: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
enabled: !!logo,
|
||||
}
|
||||
);
|
||||
const logoUrl: string = useMemo(() => {
|
||||
return `http://${env.SERVER_IP}/uploads/${logoRes?.url}`;
|
||||
}, [logoRes]);
|
||||
return (
|
||||
<header className="sticky top-0 z-50 bg-gradient-to-br from-primary-500 to-primary-800 text-white shadow-lg">
|
||||
<div className=" mx-auto px-4">
|
||||
<div className="py-2">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="">
|
||||
{/** 在这里放置logo */}
|
||||
{isLoading ? (
|
||||
<div className="w-48 h-24 bg-gray-300 animate-pulse"></div>
|
||||
) : (
|
||||
logoUrl && (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt="Logo"
|
||||
style={{ width: 192, height: 72 }}
|
||||
className=" w-full h-full"
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-grow max-w-2xl">
|
||||
<SearchBar />
|
||||
</div>
|
||||
<div className="flex items-center ">
|
||||
{!isAuthenticated ? (
|
||||
<Link
|
||||
to="/auth"
|
||||
className="group flex items-center gap-2 rounded-lg
|
||||
bg-[#00539B]/90 px-5 py-2.5 font-medium
|
||||
shadow-lg transition-all duration-300
|
||||
hover:-translate-y-0.5 hover:bg-[#0063B8]
|
||||
|
@ -33,22 +63,21 @@ hover:shadow-xl hover:shadow-[#00539B]/30
|
|||
focus:outline-none focus:ring-2
|
||||
focus:ring-[#8EADD4] focus:ring-offset-2
|
||||
focus:ring-offset-[#13294B]"
|
||||
aria-label="Login">
|
||||
|
||||
<UserOutlined
|
||||
className="h-5 w-5 transition-transform
|
||||
aria-label="Login">
|
||||
<UserOutlined
|
||||
className="h-5 w-5 transition-transform
|
||||
group-hover:scale-110 group-hover:rotate-12"></UserOutlined>
|
||||
|
||||
<span>登录</span>
|
||||
</Link>
|
||||
) : (
|
||||
<UserMenu />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Navigation />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
<span>登录</span>
|
||||
</Link>
|
||||
) : (
|
||||
<UserMenu />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Navigation />
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -28,7 +28,7 @@ export default function Navigation({ className }: NavigationProps) {
|
|||
return (
|
||||
<nav
|
||||
className={twMerge(
|
||||
"mt-4 rounded-xl bg-gradient-to-r from-primary-500 to-primary-600 shadow-lg",
|
||||
"mt-4 rounded-t-xl bg-gradient-to-r from-primary-500 to-primary-600 shadow-lg",
|
||||
className
|
||||
)}>
|
||||
<div className="flex flex-col md:flex-row items-stretch">
|
||||
|
|
|
@ -16,6 +16,7 @@ import PostLikeButton from "./PostHeader/PostLikeButton";
|
|||
import { CustomAvatar } from "@web/src/components/presentation/CustomAvatar";
|
||||
import PostResources from "./PostResources";
|
||||
import PostHateButton from "./PostHeader/PostHateButton";
|
||||
import PostSendButton from "./PostHeader/PostSendButton";
|
||||
|
||||
export default function PostCommentCard({
|
||||
post,
|
||||
|
@ -60,11 +61,15 @@ export default function PostCommentCard({
|
|||
{/* 添加有帮助按钮 */}
|
||||
<div>
|
||||
<div className="flex justify-center items-center gap-2">
|
||||
<span className=" text-sm text-slate-500">{`#${index + 1}`}</span>
|
||||
{isReceiverComment && (
|
||||
<PostSendButton
|
||||
post={post}></PostSendButton>
|
||||
)}
|
||||
<PostLikeButton
|
||||
post={post}></PostLikeButton>
|
||||
<PostHateButton
|
||||
post={post}></PostHateButton>
|
||||
<span className=" text-sm text-slate-500">{`#${index + 1}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -60,11 +60,9 @@ export default function PostCommentEditor() {
|
|||
<QuillEditor
|
||||
value={content}
|
||||
onChange={setContent}
|
||||
|
||||
className="bg-transparent"
|
||||
theme="snow"
|
||||
minRows={6}
|
||||
|
||||
modules={{
|
||||
toolbar: [
|
||||
["bold", "italic", "strike"],
|
||||
|
@ -77,16 +75,17 @@ export default function PostCommentEditor() {
|
|||
["clean"],
|
||||
],
|
||||
}}
|
||||
|
||||
/>
|
||||
</div>
|
||||
</TabPane>
|
||||
<TabPane tab="附件" key="2">
|
||||
<TusUploader
|
||||
onChange={(value) => {
|
||||
setFileIds(value);
|
||||
}}
|
||||
/>
|
||||
<div className="relative rounded-xl border border-white hover:ring-1 ring-white transition-all duration-300 ease-in-out bg-slate-100">
|
||||
<TusUploader
|
||||
onChange={(value) => {
|
||||
setFileIds(value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
|
||||
|
|
|
@ -36,8 +36,9 @@ export default function PostHateButton({ post }: { post: PostDto }) {
|
|||
type={post?.hated ? "primary" : "default"}
|
||||
style={{
|
||||
backgroundColor: post?.hated ? "#ff4d4f" : "#fff",
|
||||
borderColor: "#ff4d4f",
|
||||
borderColor: post?.hated ? "transparent" : "#ff4d4f",
|
||||
color: post?.hated ? "#fff" : "#ff4d4f",
|
||||
boxShadow: "none", // 去除阴影
|
||||
}}
|
||||
shape="round"
|
||||
icon={post?.hated ? <DislikeFilled /> : <DislikeOutlined />}
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
import { PostDto, VisitType } from "@nice/common";
|
||||
import { useVisitor } from "@nice/client";
|
||||
import { Button, Tooltip } from "antd";
|
||||
import {
|
||||
DislikeFilled,
|
||||
DislikeOutlined,
|
||||
SendOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useAuth } from "@web/src/providers/auth-provider";
|
||||
|
||||
export default function PostSendButton({ post }: { post: PostDto }) {
|
||||
const { user } = useAuth();
|
||||
const { hate, unHate } = useVisitor();
|
||||
function sendPost() {
|
||||
if (post?.authorId) {
|
||||
window.open(`/editor?receiverId=${post?.authorId}`, "_blank");
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
title="给他写信"
|
||||
type="default"
|
||||
shape="round"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
sendPost();
|
||||
}}
|
||||
icon={<SendOutlined />}>
|
||||
<span className="mr-1">给他写信</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
|
@ -4,7 +4,7 @@ import { DownloadOutlined } from "@ant-design/icons";
|
|||
import { PostDto } from "@nice/common";
|
||||
import { env } from "@web/src/env";
|
||||
import { getFileIcon } from "./utils";
|
||||
import { formatFileSize } from '@nice/utils';
|
||||
import { formatFileSize } from "@nice/utils";
|
||||
export default function PostResources({ post }: { post: PostDto }) {
|
||||
const { resources } = useMemo(() => {
|
||||
if (!post?.resources) return { resources: [] };
|
||||
|
@ -99,7 +99,9 @@ export default function PostResources({ post }: { post: PostDto }) {
|
|||
</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
{resource.meta.size &&
|
||||
formatFileSize(resource.meta.size)}
|
||||
formatFileSize(
|
||||
resource.meta.size
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -65,7 +65,7 @@ export function LetterBasicForm() {
|
|||
name="content"
|
||||
rules={[{ required: true, message: "请输入内容" }]}
|
||||
required={false}>
|
||||
<div className="rounded-lg border border-gray-200 bg-white shadow-sm">
|
||||
<div className="rounded-xl border border-white hover:ring-1 ring-white transition-all duration-300 ease-in-out bg-slate-100">
|
||||
<QuillEditor
|
||||
maxLength={10000}
|
||||
placeholder="请输入内容"
|
||||
|
@ -79,7 +79,8 @@ export function LetterBasicForm() {
|
|||
</TabPane>
|
||||
<TabPane tab="附件" key="2">
|
||||
<Form.Item name="resources" required={false}>
|
||||
<div className="rounded-lg border border-gray-200 bg-white shadow-sm">
|
||||
<div className="rounded-xl border border-white hover:ring-1 ring-white transition-all duration-300 ease-in-out bg-slate-100">
|
||||
|
||||
<TusUploader
|
||||
onChange={(resources) =>
|
||||
form.setFieldValue(
|
||||
|
@ -88,12 +89,13 @@ export function LetterBasicForm() {
|
|||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Form.Item>
|
||||
</TabPane>
|
||||
</Tabs>
|
||||
{/* Footer Actions */}
|
||||
<div className="flex flex-col-reverse sm:flex-row items-center justify-between gap-4 mt-6">
|
||||
<div className="flex flex-col-reverse sm:flex-row items-center justify-between gap-4 ">
|
||||
<Form.Item name="isPublic" valuePropName="checked">
|
||||
<Checkbox className="text-gray-600 hover:text-gray-900 transition-colors text-sm">
|
||||
是否公开
|
||||
|
|
|
@ -4,28 +4,24 @@ import { RoleEditorContext } from "./role-editor";
|
|||
import RoleForm from "./role-form";
|
||||
|
||||
export default function RoleModal() {
|
||||
const {
|
||||
roleForm,
|
||||
editRoleId,
|
||||
roleModalOpen, setRoleModalOpen
|
||||
} = useContext(RoleEditorContext);
|
||||
const { roleForm, editRoleId, roleModalOpen, setRoleModalOpen } =
|
||||
useContext(RoleEditorContext);
|
||||
|
||||
const handleOk = async () => {
|
||||
roleForm.submit()
|
||||
};
|
||||
const handleOk = async () => {
|
||||
roleForm.submit();
|
||||
};
|
||||
|
||||
const handleCancel = () => setRoleModalOpen(false);
|
||||
const handleCancel = () => setRoleModalOpen(false);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
width={500}
|
||||
title={editRoleId ? "编辑角色" : "创建角色"}
|
||||
open={roleModalOpen}
|
||||
onOk={handleOk}
|
||||
// confirmLoading={loading}
|
||||
onCancel={handleCancel}
|
||||
>
|
||||
<RoleForm></RoleForm>
|
||||
</Modal>
|
||||
);
|
||||
return (
|
||||
<Modal
|
||||
width={500}
|
||||
title={editRoleId ? "编辑角色" : "创建角色"}
|
||||
open={roleModalOpen}
|
||||
onOk={handleOk}
|
||||
// confirmLoading={loading}
|
||||
onCancel={handleCancel}>
|
||||
<RoleForm></RoleForm>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { useState } from "react";
|
||||
import * as tus from "tus-js-client";
|
||||
import { env } from "../env";
|
||||
|
||||
// useTusUpload.ts
|
||||
interface UploadProgress {
|
||||
|
@ -27,7 +28,16 @@ export function useTusUpload() {
|
|||
}
|
||||
return parts.slice(uploadIndex + 1, uploadIndex + 5).join("/");
|
||||
};
|
||||
|
||||
const getResourceUrl = (url: string) => {
|
||||
const parts = url.split("/");
|
||||
const uploadIndex = parts.findIndex((part) => part === "upload");
|
||||
if (uploadIndex === -1 || uploadIndex + 4 >= parts.length) {
|
||||
throw new Error("Invalid upload URL format");
|
||||
}
|
||||
const resUrl = `http://${env.SERVER_IP}/uploads/${parts.slice(uploadIndex + 1, uploadIndex + 6).join("/")}`;
|
||||
console.log(resUrl);
|
||||
return resUrl;
|
||||
};
|
||||
const handleFileUpload = async (
|
||||
file: File,
|
||||
onSuccess: (result: UploadResult) => void,
|
||||
|
@ -52,7 +62,7 @@ export function useTusUpload() {
|
|||
metadata: {
|
||||
filename: file.name,
|
||||
filetype: file.type,
|
||||
size: file.size as any
|
||||
size: file.size as any,
|
||||
},
|
||||
onProgress: (bytesUploaded, bytesTotal) => {
|
||||
const progress = Number(
|
||||
|
@ -67,13 +77,14 @@ export function useTusUpload() {
|
|||
try {
|
||||
if (upload.url) {
|
||||
const fileId = getFileId(upload.url);
|
||||
const url = getResourceUrl(upload.url);
|
||||
setIsUploading(false);
|
||||
setUploadProgress((prev) => ({
|
||||
...prev,
|
||||
[fileKey]: 100,
|
||||
}));
|
||||
onSuccess({
|
||||
url: upload.url,
|
||||
url,
|
||||
fileId,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ export function useVisitor() {
|
|||
const create = api.visitor.create.useMutation({
|
||||
onSuccess() {
|
||||
utils.visitor.invalidate();
|
||||
|
||||
// utils.post.invalidate();
|
||||
},
|
||||
});
|
||||
|
|
|
@ -201,3 +201,10 @@ export const PostStateLabels = {
|
|||
[PostState.PROCESSING]: "处理中",
|
||||
[PostState.RESOLVED]: "已完成",
|
||||
};
|
||||
export enum RoleName {
|
||||
Basic = "基层", // 基层
|
||||
Organization = "机关", // 机关
|
||||
Leader = "领导", // 领导
|
||||
DomainAdmin = "域管理员", // 域管理员
|
||||
RootAdmin = "根管理员", // 根管理员
|
||||
}
|
||||
|
|
|
@ -320,6 +320,9 @@ export const RoleMapMethodSchema = {
|
|||
domainId: z.string().nullish(),
|
||||
roleId: z.string().nullish(),
|
||||
}),
|
||||
getStaffIdsByRoleNames: z.object({
|
||||
roleNames: z.array(z.string()),
|
||||
}),
|
||||
};
|
||||
export const RoleMethodSchema = {
|
||||
create: z.object({
|
||||
|
|
Loading…
Reference in New Issue