This commit is contained in:
longdayi 2025-01-26 19:38:53 +08:00
commit c4abf0fa68
42 changed files with 814 additions and 594 deletions

View File

@ -4,44 +4,48 @@ import { AppConfigService } from './app-config.service';
import { z, ZodType } from 'zod'; import { z, ZodType } from 'zod';
import { Prisma } from '@nice/common'; import { Prisma } from '@nice/common';
import { RealtimeServer } from '@server/socket/realtime/realtime.server'; import { RealtimeServer } from '@server/socket/realtime/realtime.server';
const AppConfigUncheckedCreateInputSchema: ZodType<Prisma.AppConfigUncheckedCreateInput> = z.any() const AppConfigUncheckedCreateInputSchema: ZodType<Prisma.AppConfigUncheckedCreateInput> =
const AppConfigUpdateArgsSchema: ZodType<Prisma.AppConfigUpdateArgs> = z.any() z.any();
const AppConfigDeleteManyArgsSchema: ZodType<Prisma.AppConfigDeleteManyArgs> = z.any() const AppConfigUpdateArgsSchema: ZodType<Prisma.AppConfigUpdateArgs> = z.any();
const AppConfigFindFirstArgsSchema: ZodType<Prisma.AppConfigFindFirstArgs> = z.any() const AppConfigDeleteManyArgsSchema: ZodType<Prisma.AppConfigDeleteManyArgs> =
z.any();
const AppConfigFindFirstArgsSchema: ZodType<Prisma.AppConfigFindFirstArgs> =
z.any();
@Injectable() @Injectable()
export class AppConfigRouter { export class AppConfigRouter {
constructor( constructor(
private readonly trpc: TrpcService, private readonly trpc: TrpcService,
private readonly appConfigService: AppConfigService, private readonly appConfigService: AppConfigService,
private readonly realtimeServer: RealtimeServer private readonly realtimeServer: RealtimeServer,
) { } ) {}
router = this.trpc.router({ router = this.trpc.router({
create: this.trpc.protectProcedure create: this.trpc.protectProcedure
.input(AppConfigUncheckedCreateInputSchema) .input(AppConfigUncheckedCreateInputSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { staff } = ctx; const { staff } = ctx;
return await this.appConfigService.create({ data: input }); return await this.appConfigService.create({ data: input });
}), }),
update: this.trpc.protectProcedure update: this.trpc.protectProcedure
.input(AppConfigUpdateArgsSchema) .input(AppConfigUpdateArgsSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { staff } = ctx;
const { staff } = ctx; return await this.appConfigService.update(input);
return await this.appConfigService.update(input); }),
}), deleteMany: this.trpc.protectProcedure
deleteMany: this.trpc.protectProcedure.input(AppConfigDeleteManyArgsSchema).mutation(async ({ input }) => { .input(AppConfigDeleteManyArgsSchema)
return await this.appConfigService.deleteMany(input) .mutation(async ({ input }) => {
}), return await this.appConfigService.deleteMany(input);
findFirst: this.trpc.protectProcedure.input(AppConfigFindFirstArgsSchema). }),
query(async ({ input }) => { findFirst: this.trpc.protectProcedure
.input(AppConfigFindFirstArgsSchema)
return await this.appConfigService.findFirst(input) .query(async ({ input }) => {
}), return await this.appConfigService.findFirst(input);
clearRowCache: this.trpc.protectProcedure.mutation(async () => { }),
return await this.appConfigService.clearRowCache() clearRowCache: this.trpc.protectProcedure.mutation(async () => {
}), return await this.appConfigService.clearRowCache();
getClientCount: this.trpc.protectProcedure.query(() => { }),
return this.realtimeServer.getClientCount() getClientCount: this.trpc.protectProcedure.query(() => {
}) return this.realtimeServer.getClientCount();
}); }),
});
} }

View File

@ -1,10 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { import { db, ObjectType, Prisma } from '@nice/common';
db,
ObjectType,
Prisma,
} from '@nice/common';
import { BaseService } from '../base/base.service'; import { BaseService } from '../base/base.service';
import { deleteByPattern } from '@server/utils/redis/utils'; import { deleteByPattern } from '@server/utils/redis/utils';
@ -12,10 +7,10 @@ import { deleteByPattern } from '@server/utils/redis/utils';
@Injectable() @Injectable()
export class AppConfigService extends BaseService<Prisma.AppConfigDelegate> { export class AppConfigService extends BaseService<Prisma.AppConfigDelegate> {
constructor() { constructor() {
super(db, "appConfig"); super(db, 'appConfig');
} }
async clearRowCache() { async clearRowCache() {
await deleteByPattern("row-*") await deleteByPattern('row-*');
return true return true;
} }
} }

View File

@ -1,9 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { TrpcService } from '@server/trpc/trpc.service'; import { TrpcService } from '@server/trpc/trpc.service';
import { import { ObjectType, RoleMapMethodSchema } from '@nice/common';
ObjectType,
RoleMapMethodSchema,
} from '@nice/common';
import { RoleMapService } from './rolemap.service'; import { RoleMapService } from './rolemap.service';
@Injectable() @Injectable()
@ -11,7 +8,7 @@ export class RoleMapRouter {
constructor( constructor(
private readonly trpc: TrpcService, private readonly trpc: TrpcService,
private readonly roleMapService: RoleMapService, private readonly roleMapService: RoleMapService,
) { } ) {}
router = this.trpc.router({ router = this.trpc.router({
deleteAllRolesForObject: this.trpc.protectProcedure deleteAllRolesForObject: this.trpc.protectProcedure
.input(RoleMapMethodSchema.deleteWithObject) .input(RoleMapMethodSchema.deleteWithObject)
@ -67,5 +64,10 @@ export class RoleMapRouter {
.query(async ({ input }) => { .query(async ({ input }) => {
return this.roleMapService.getStaffsNotMap(input); return this.roleMapService.getStaffsNotMap(input);
}), }),
getStaffIdsByRoleNames: this.trpc.procedure
.input(RoleMapMethodSchema.getStaffIdsByRoleNames)
.query(async ({ input }) => {
return this.roleMapService.getStaffIdsByRoleNames(input);
}),
}); });
} }

View File

@ -44,7 +44,7 @@ export class RoleMapService extends RowModelService {
) { ) {
const { roleId, domainId } = request; const { roleId, domainId } = request;
// Base conditions // Base conditions
let condition = super.createGetRowsFilters(request, staff); const condition = super.createGetRowsFilters(request, staff);
if (isFieldCondition(condition)) return; if (isFieldCondition(condition)) return;
// Adding conditions based on parameters existence // Adding conditions based on parameters existence
if (roleId) { if (roleId) {
@ -64,10 +64,7 @@ export class RoleMapService extends RowModelService {
return condition; return condition;
} }
protected async getRowDto( protected async getRowDto(row: any, staff?: UserProfile): Promise<any> {
row: any,
staff?: UserProfile,
): Promise<any> {
if (!row.id) return row; if (!row.id) return row;
return row; return row;
} }
@ -126,15 +123,17 @@ export class RoleMapService extends RowModelService {
data: roleMaps, data: roleMaps,
}); });
}); });
const wrapResult = Promise.all(result.map(async item => { const wrapResult = Promise.all(
const staff = await db.staff.findMany({ result.map(async (item) => {
include: { department: true }, const staff = await db.staff.findMany({
where: { include: { department: true },
id: item.objectId where: {
} id: item.objectId,
}) },
return { ...item, staff } });
})) return { ...item, staff };
}),
);
return wrapResult; return wrapResult;
} }
async addRoleForObjects( async addRoleForObjects(
@ -187,11 +186,11 @@ export class RoleMapService extends RowModelService {
{ objectId: staffId, objectType: ObjectType.STAFF }, { objectId: staffId, objectType: ObjectType.STAFF },
...(deptId || ancestorDeptIds.length > 0 ...(deptId || ancestorDeptIds.length > 0
? [ ? [
{ {
objectId: { in: [deptId, ...ancestorDeptIds].filter(Boolean) }, objectId: { in: [deptId, ...ancestorDeptIds].filter(Boolean) },
objectType: ObjectType.DEPARTMENT, objectType: ObjectType.DEPARTMENT,
}, },
] ]
: []), : []),
]; ];
// Helper function to fetch roles based on domain ID. // 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))); // const processedItems = await Promise.all(items.map(item => this.genRoleMapDto(item)));
return { items, totalCount }; return { items, totalCount };
} }
async getStaffsNotMap(data: z.infer<typeof RoleMapMethodSchema.getStaffsNotMap>) { async getStaffsNotMap(
data: z.infer<typeof RoleMapMethodSchema.getStaffsNotMap>,
) {
const { domainId, roleId } = data; const { domainId, roleId } = data;
let staffs = await db.staff.findMany({ let staffs = await db.staff.findMany({
where: { where: {
@ -280,6 +281,35 @@ export class RoleMapService extends RowModelService {
); );
return staffs; 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 * @param data
@ -300,7 +330,9 @@ export class RoleMapService extends RowModelService {
* @param data ID和域ID的数据 * @param data ID和域ID的数据
* @returns 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 { roleId, domainId } = data;
const res = await db.roleMap.findMany({ where: { roleId, domainId } }); const res = await db.roleMap.findMany({ where: { roleId, domainId } });

View File

@ -4,7 +4,7 @@ import { ResourceService } from './resource.service';
import { TrpcService } from '@server/trpc/trpc.service'; import { TrpcService } from '@server/trpc/trpc.service';
@Module({ @Module({
exports: [ResourceRouter, ResourceService], exports: [ResourceRouter, ResourceService],
providers: [ResourceRouter, ResourceService, TrpcService], providers: [ResourceRouter, ResourceService, TrpcService],
}) })
export class ResourceModule { } export class ResourceModule {}

View File

@ -3,68 +3,75 @@ import { TrpcService } from '@server/trpc/trpc.service';
import { Prisma, UpdateOrderSchema } from '@nice/common'; import { Prisma, UpdateOrderSchema } from '@nice/common';
import { ResourceService } from './resource.service'; import { ResourceService } from './resource.service';
import { z, ZodType } from 'zod'; import { z, ZodType } from 'zod';
const ResourceCreateArgsSchema: ZodType<Prisma.ResourceCreateArgs> = z.any() const ResourceCreateArgsSchema: ZodType<Prisma.ResourceCreateArgs> = z.any();
const ResourceCreateManyInputSchema: ZodType<Prisma.ResourceCreateManyInput> = z.any() const ResourceCreateManyInputSchema: ZodType<Prisma.ResourceCreateManyInput> =
const ResourceDeleteManyArgsSchema: ZodType<Prisma.ResourceDeleteManyArgs> = z.any() z.any();
const ResourceFindManyArgsSchema: ZodType<Prisma.ResourceFindManyArgs> = z.any() const ResourceDeleteManyArgsSchema: ZodType<Prisma.ResourceDeleteManyArgs> =
const ResourceFindFirstArgsSchema: ZodType<Prisma.ResourceFindFirstArgs> = z.any() z.any();
const ResourceWhereInputSchema: ZodType<Prisma.ResourceWhereInput> = z.any() const ResourceFindManyArgsSchema: ZodType<Prisma.ResourceFindManyArgs> =
const ResourceSelectSchema: ZodType<Prisma.ResourceSelect> = z.any() z.any();
const ResourceFindFirstArgsSchema: ZodType<Prisma.ResourceFindFirstArgs> =
z.any();
const ResourceWhereInputSchema: ZodType<Prisma.ResourceWhereInput> = z.any();
const ResourceSelectSchema: ZodType<Prisma.ResourceSelect> = z.any();
@Injectable() @Injectable()
export class ResourceRouter { export class ResourceRouter {
constructor( constructor(
private readonly trpc: TrpcService, private readonly trpc: TrpcService,
private readonly resourceService: ResourceService, private readonly resourceService: ResourceService,
) { } ) {}
router = this.trpc.router({ router = this.trpc.router({
create: this.trpc.protectProcedure create: this.trpc.protectProcedure
.input(ResourceCreateArgsSchema) .input(ResourceCreateArgsSchema)
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { staff } = ctx; const { staff } = ctx;
return await this.resourceService.create(input, { staff }); return await this.resourceService.create(input, { staff });
}), }),
createMany: this.trpc.protectProcedure.input(z.array(ResourceCreateManyInputSchema)) createMany: this.trpc.protectProcedure
.mutation(async ({ ctx, input }) => { .input(z.array(ResourceCreateManyInputSchema))
const { staff } = ctx; .mutation(async ({ ctx, input }) => {
const { staff } = ctx;
return await this.resourceService.createMany({ data: input }, staff); return await this.resourceService.createMany({ data: input }, staff);
}), }),
deleteMany: this.trpc.procedure deleteMany: this.trpc.procedure
.input(ResourceDeleteManyArgsSchema) .input(ResourceDeleteManyArgsSchema)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
return await this.resourceService.deleteMany(input); return await this.resourceService.deleteMany(input);
}), }),
findFirst: this.trpc.procedure findFirst: this.trpc.procedure
.input(ResourceFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword .input(ResourceFindFirstArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => { .query(async ({ input }) => {
return await this.resourceService.findFirst(input); return await this.resourceService.findFirst(input);
}), }),
softDeleteByIds: this.trpc.protectProcedure softDeleteByIds: this.trpc.protectProcedure
.input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema .input(z.object({ ids: z.array(z.string()) })) // expect input according to the schema
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
return this.resourceService.softDeleteByIds(input.ids); return this.resourceService.softDeleteByIds(input.ids);
}), }),
updateOrder: this.trpc.protectProcedure updateOrder: this.trpc.protectProcedure
.input(UpdateOrderSchema) .input(UpdateOrderSchema)
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
return this.resourceService.updateOrder(input); return this.resourceService.updateOrder(input);
}), }),
findMany: this.trpc.procedure findMany: this.trpc.procedure
.input(ResourceFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword .input(ResourceFindManyArgsSchema) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => { .query(async ({ input }) => {
return await this.resourceService.findMany(input); return await this.resourceService.findMany(input);
}), }),
findManyWithCursor: this.trpc.protectProcedure findManyWithCursor: this.trpc.protectProcedure
.input(z.object({ .input(
cursor: z.any().nullish(), z.object({
take: z.number().nullish(), cursor: z.any().nullish(),
where: ResourceWhereInputSchema.nullish(), take: z.number().nullish(),
select: ResourceSelectSchema.nullish() where: ResourceWhereInputSchema.nullish(),
})) select: ResourceSelectSchema.nullish(),
.query(async ({ ctx, input }) => { }),
const { staff } = ctx; )
return await this.resourceService.findManyWithCursor(input); .query(async ({ ctx, input }) => {
}), const { staff } = ctx;
}); return await this.resourceService.findManyWithCursor(input);
}),
});
} }

View File

@ -12,4 +12,4 @@ import { StaffRowService } from './staff.row.service';
exports: [StaffService, StaffRouter, StaffRowService], exports: [StaffService, StaffRouter, StaffRowService],
controllers: [StaffController], controllers: [StaffController],
}) })
export class StaffModule { } export class StaffModule {}

View File

@ -44,7 +44,7 @@ export class StaffService extends BaseService<Prisma.StaffDelegate> {
...data, ...data,
password: await argon2.hash((data.password || '123456') as string), password: await argon2.hash((data.password || '123456') as string),
}; };
const result = await super.create({ ...args, data: createData }); const result = await super.create({ ...args, data: createData });
this.emitDataChangedEvent(result, CrudOperation.CREATED); this.emitDataChangedEvent(result, CrudOperation.CREATED);
return result; return result;

View File

@ -187,6 +187,13 @@ export class VisitService extends BaseService<Prisma.VisitDelegate> {
visitType: VisitType.LIKE, visitType: VisitType.LIKE,
}); });
} }
if (args.where.type === VisitType.HATE) {
EventBus.emit('updateVisitCount', {
objectType: ObjectType.POST,
id: args?.where?.postId as string,
visitType: VisitType.HATE,
});
}
} }
return superDetele; return superDetele;
} }

View File

@ -1,5 +1,6 @@
import { db, PostState, PostType, VisitType } from '@nice/common'; import { db, PostState, PostType, VisitType } from '@nice/common';
export async function updatePostViewCount(id: string, type: VisitType) { export async function updatePostViewCount(id: string, type: VisitType) {
console.log('updatePostViewCount', type);
const totalViews = await db.visit.aggregate({ const totalViews = await db.visit.aggregate({
_sum: { _sum: {
views: true, views: true,
@ -19,7 +20,6 @@ export async function updatePostViewCount(id: string, type: VisitType) {
}, },
}); });
} else if (type === VisitType.LIKE) { } else if (type === VisitType.LIKE) {
console.log('totalViews._sum.view', totalViews._sum.views);
await db.post.update({ await db.post.update({
where: { where: {
id: id, id: id,
@ -28,5 +28,14 @@ export async function updatePostViewCount(id: string, type: VisitType) {
likes: totalViews._sum.views || 0, // Use 0 if no visits exist likes: totalViews._sum.views || 0, // Use 0 if no visits exist
}, },
}); });
} else if (type === VisitType.HATE) {
await db.post.update({
where: {
id: id,
},
data: {
hates: totalViews._sum.views || 0, // Use 0 if no visits exist
},
});
} }
} }

View File

@ -8,7 +8,6 @@ import { updatePostViewCount } from '../models/post/utils';
const logger = new Logger('QueueWorker'); const logger = new Logger('QueueWorker');
export default async function processJob(job: Job<any, any, QueueJobType>) { export default async function processJob(job: Job<any, any, QueueJobType>) {
try { try {
if (job.name === QueueJobType.UPDATE_POST_VISIT_COUNT) { if (job.name === QueueJobType.UPDATE_POST_VISIT_COUNT) {
await updatePostViewCount(job.data.id, job.data.type); await updatePostViewCount(job.data.id, job.data.type);
} }

View File

@ -12,12 +12,7 @@ import {
Term, Term,
} from '@nice/common'; } from '@nice/common';
import EventBus from '@server/utils/event-bus'; import EventBus from '@server/utils/event-bus';
import { import { capitalizeFirstLetter, DevDataCounts, getCounts } from './utils';
capitalizeFirstLetter,
DevDataCounts,
getCounts,
} from './utils';
import { StaffService } from '@server/models/staff/staff.service'; import { StaffService } from '@server/models/staff/staff.service';
@Injectable() @Injectable()
export class GenDevService { export class GenDevService {
@ -26,7 +21,7 @@ export class GenDevService {
deptStaffRecord: Record<string, Staff[]> = {}; deptStaffRecord: Record<string, Staff[]> = {};
terms: Record<TaxonomySlug, Term[]> = { terms: Record<TaxonomySlug, Term[]> = {
[TaxonomySlug.CATEGORY]: [], [TaxonomySlug.CATEGORY]: [],
[TaxonomySlug.TAG]: [] [TaxonomySlug.TAG]: [],
}; };
depts: Department[] = []; depts: Department[] = [];
domains: Department[] = []; domains: Department[] = [];
@ -39,7 +34,7 @@ export class GenDevService {
private readonly departmentService: DepartmentService, private readonly departmentService: DepartmentService,
private readonly staffService: StaffService, private readonly staffService: StaffService,
private readonly termService: TermService, private readonly termService: TermService,
) { } ) {}
async genDataEvent() { async genDataEvent() {
EventBus.emit('genDataEvent', { type: 'start' }); EventBus.emit('genDataEvent', { type: 'start' });
try { try {
@ -47,7 +42,6 @@ export class GenDevService {
await this.generateDepartments(3, 6); await this.generateDepartments(3, 6);
await this.generateTerms(1, 3); await this.generateTerms(1, 3);
await this.generateStaffs(4); await this.generateStaffs(4);
} catch (err) { } catch (err) {
this.logger.error(err); this.logger.error(err);
} }
@ -164,8 +158,8 @@ export class GenDevService {
showname: username, showname: username,
username: username, username: username,
deptId: dept.id, deptId: dept.id,
domainId: domain.id domainId: domain.id,
} },
}); });
// Update both deptStaffRecord and staffs array // Update both deptStaffRecord and staffs array
this.deptStaffRecord[dept.id].push(staff); this.deptStaffRecord[dept.id].push(staff);
@ -190,7 +184,7 @@ export class GenDevService {
name, name,
isDomain: currentDepth === 1 ? true : false, isDomain: currentDepth === 1 ? true : false,
parentId, parentId,
} },
}); });
return department; return department;
} }
@ -208,7 +202,9 @@ export class GenDevService {
throw new Error(`Taxonomy with slug ${taxonomySlug} not found`); throw new Error(`Taxonomy with slug ${taxonomySlug} not found`);
} }
this.logger.log(`Creating terms for taxonomy: ${taxonomy.name} (${taxonomy.slug})`); this.logger.log(
`Creating terms for taxonomy: ${taxonomy.name} (${taxonomy.slug})`,
);
let counter = 1; let counter = 1;
const createTermTree = async ( const createTermTree = async (
parentId: string | null, parentId: string | null,
@ -223,7 +219,7 @@ export class GenDevService {
taxonomyId: taxonomy!.id, taxonomyId: taxonomy!.id,
domainId: domain?.id, domainId: domain?.id,
parentId, parentId,
} },
}); });
this.terms[taxonomySlug].push(newTerm); this.terms[taxonomySlug].push(newTerm);
await createTermTree(newTerm.id, currentDepth + 1); await createTermTree(newTerm.id, currentDepth + 1);

View File

@ -15,6 +15,8 @@ import { WebSocketModule } from '@server/socket/websocket.module';
import { RoleMapModule } from '@server/models/rbac/rbac.module'; import { RoleMapModule } from '@server/models/rbac/rbac.module';
import { TransformModule } from '@server/models/transform/transform.module'; import { TransformModule } from '@server/models/transform/transform.module';
import { ResourceModule } from '@server/models/resource/resource.module';
@Module({ @Module({
imports: [ imports: [
AuthModule, AuthModule,
@ -30,6 +32,7 @@ import { TransformModule } from '@server/models/transform/transform.module';
PostModule, PostModule,
VisitModule, VisitModule,
WebSocketModule, WebSocketModule,
ResourceModule,
], ],
controllers: [], controllers: [],
providers: [TrpcService, TrpcRouter, Logger], providers: [TrpcService, TrpcRouter, Logger],

View File

@ -13,10 +13,10 @@ import { VisitRouter } from '@server/models/visit/visit.router';
import { RoleMapRouter } from '@server/models/rbac/rolemap.router'; import { RoleMapRouter } from '@server/models/rbac/rolemap.router';
import { TransformRouter } from '@server/models/transform/transform.router'; import { TransformRouter } from '@server/models/transform/transform.router';
import { RoleRouter } from '@server/models/rbac/role.router'; import { RoleRouter } from '@server/models/rbac/role.router';
import { ResourceRouter } from '../models/resource/resource.router';
@Injectable() @Injectable()
export class TrpcRouter { export class TrpcRouter {
logger = new Logger(TrpcRouter.name); logger = new Logger(TrpcRouter.name);
constructor( constructor(
private readonly trpc: TrpcService, private readonly trpc: TrpcService,
@ -31,10 +31,10 @@ export class TrpcRouter {
private readonly app_config: AppConfigRouter, private readonly app_config: AppConfigRouter,
private readonly message: MessageRouter, private readonly message: MessageRouter,
private readonly visitor: VisitRouter, private readonly visitor: VisitRouter,
private readonly resource: ResourceRouter,
) { } ) {}
getRouter() { getRouter() {
return return;
} }
appRouter = this.trpc.router({ appRouter = this.trpc.router({
transform: this.transform.router, transform: this.transform.router,
@ -48,6 +48,7 @@ export class TrpcRouter {
message: this.message.router, message: this.message.router,
app_config: this.app_config.router, app_config: this.app_config.router,
visitor: this.visitor.router, visitor: this.visitor.router,
resource: this.resource.router,
}); });
wss: WebSocketServer = undefined; wss: WebSocketServer = undefined;

View File

@ -1,3 +1,3 @@
import { TrpcRouter } from "./trpc.router"; import { TrpcRouter } from './trpc.router';
export type AppRouter = TrpcRouter[`appRouter`]; export type AppRouter = TrpcRouter[`appRouter`];

View File

@ -1,32 +1,25 @@
import { import { AppConfigSlug, BaseSetting, RolePerms } from "@nice/common";
AppConfigSlug,
BaseSetting,
RolePerms,
} from "@nice/common";
import { useContext, useEffect, useState } from "react"; import { useContext, useEffect, useState } from "react";
import { import { Button, Form, Input, message, theme } from "antd";
Button,
Form,
Input,
message,
theme,
} from "antd";
import { useAppConfig } from "@nice/client"; import { useAppConfig } from "@nice/client";
import { useAuth } from "@web/src/providers/auth-provider"; import { useAuth } from "@web/src/providers/auth-provider";
import { useForm } from "antd/es/form/Form"; import { useForm } from "antd/es/form/Form";
import { api } from "@nice/client" import { api } from "@nice/client";
import AdminHeader from "@web/src/components/layout/admin/AdminHeader"; import AdminHeader from "@web/src/components/layout/admin/AdminHeader";
import AvatarUploader from "@web/src/components/common/uploader/AvatarUploader";
export default function BaseSettingPage() { export default function BaseSettingPage() {
const { update, baseSetting } = useAppConfig(); const { update, baseSetting } = useAppConfig();
const utils = api.useUtils() const utils = api.useUtils();
const [form] = useForm() const [form] = useForm();
const { token } = theme.useToken(); const { token } = theme.useToken();
const { data: clientCount } = api.app_config.getClientCount.useQuery(undefined, { const { data: clientCount } = api.app_config.getClientCount.useQuery(
refetchInterval: 3000, undefined,
refetchIntervalInBackground: true {
}) refetchInterval: 3000,
refetchIntervalInBackground: true,
}
);
const [isFormChanged, setIsFormChanged] = useState(false); const [isFormChanged, setIsFormChanged] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { user, hasSomePermissions } = useAuth(); const { user, hasSomePermissions } = useAuth();
@ -34,31 +27,27 @@ export default function BaseSettingPage() {
setIsFormChanged(true); setIsFormChanged(true);
} }
function onResetClick() { function onResetClick() {
if (!form) if (!form) return;
return
if (!baseSetting) { if (!baseSetting) {
form.resetFields(); form.resetFields();
} else { } else {
form.resetFields(); form.resetFields();
form.setFieldsValue(baseSetting); form.setFieldsValue(baseSetting);
} }
setIsFormChanged(false); setIsFormChanged(false);
} }
function onSaveClick() { function onSaveClick() {
if (form) if (form) form.submit();
form.submit();
} }
async function onSubmit(values: BaseSetting) { async function onSubmit(values: BaseSetting) {
setLoading(true); setLoading(true);
try { try {
await update.mutateAsync({ await update.mutateAsync({
where: { where: {
slug: AppConfigSlug.BASE_SETTING, slug: AppConfigSlug.BASE_SETTING,
}, },
data: { meta: JSON.stringify(values) } data: { meta: JSON.stringify(values) },
}); });
setIsFormChanged(false); setIsFormChanged(false);
message.success("已保存"); message.success("已保存");
@ -70,12 +59,11 @@ export default function BaseSettingPage() {
} }
useEffect(() => { useEffect(() => {
if (baseSetting && form) { if (baseSetting && form) {
form.setFieldsValue(baseSetting); form.setFieldsValue(baseSetting);
} }
}, [baseSetting, form]); }, [baseSetting, form]);
return ( return (
<div > <div>
<AdminHeader> <AdminHeader>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isFormChanged && {isFormChanged &&
@ -101,7 +89,6 @@ export default function BaseSettingPage() {
!hasSomePermissions(RolePerms.MANAGE_BASE_SETTING) !hasSomePermissions(RolePerms.MANAGE_BASE_SETTING)
} }
onFinish={onSubmit} onFinish={onSubmit}
onFieldsChange={handleFieldsChange} onFieldsChange={handleFieldsChange}
layout="vertical"> layout="vertical">
{/* <div {/* <div
@ -127,6 +114,17 @@ export default function BaseSettingPage() {
<Input></Input> <Input></Input>
</Form.Item> </Form.Item>
</div> </div>
<div className="p-2 grid grid-cols-8 gap-2 border-b">
<Form.Item
label="网站logo"
name={["appConfig", "logo"]}>
<AvatarUploader
style={{
width: 192,
height: 108,
}}></AvatarUploader>
</Form.Item>
</div>
{/* <div {/* <div
className="p-2 border-b flex items-center justify-between" className="p-2 border-b flex items-center justify-between"
style={{ style={{
@ -171,17 +169,21 @@ export default function BaseSettingPage() {
</Button> </Button>
</div> </div>
{<div {
className="p-2 border-b text-primary flex justify-between items-center" <div
style={{ className="p-2 border-b text-primary flex justify-between items-center"
fontSize: token.fontSize, style={{
fontWeight: "bold", fontSize: token.fontSize,
}}> fontWeight: "bold",
<span>app在线人数</span> }}>
<div> <span>app在线人数</span>
{clientCount && clientCount > 0 ? `${clientCount}人在线` : '无人在线'} <div>
{clientCount && clientCount > 0
? `${clientCount}人在线`
: "无人在线"}
</div>
</div> </div>
</div>} }
</div> </div>
</div> </div>
); );

View File

@ -134,12 +134,12 @@ export const RegisterForm = ({ onSubmit, isLoading }: RegisterFormProps) => {
rules={[ rules={[
{ required: true, message: "请输入密码" }, { required: true, message: "请输入密码" },
{ min: 8, message: "密码至少需要8个字符" }, { min: 8, message: "密码至少需要8个字符" },
{ // {
pattern: // pattern:
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/, // /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/,
message: // message:
"密码必须包含大小写字母、数字和特殊字符", // "密码必须包含大小写字母、数字和特殊字符",
}, // },
]}> ]}>
<Input.Password placeholder="密码" /> <Input.Password placeholder="密码" />
</Form.Item> </Form.Item>
@ -150,13 +150,13 @@ export const RegisterForm = ({ onSubmit, isLoading }: RegisterFormProps) => {
label="证件号" label="证件号"
noStyle noStyle
rules={[ rules={[
{ required: true, message: "请输入证件号" }, { message: "请输入证件号" },
{ {
pattern: /^\d{5,12}$/, pattern: /^\d{5,12}$/,
message: "请输入有效的证件号5-12位数字", message: "请输入有效的证件号5-12位数字",
}, },
]}> ]}>
<Input placeholder="证件号(可选)" /> <Input placeholder="证件号" />
</Form.Item> </Form.Item>
<Form.Item noStyle name={"email"}> <Form.Item noStyle name={"email"}>
<Input <Input

View File

@ -9,7 +9,7 @@ export default function LetterEditorPage() {
const termId = searchParams.get("termId"); const termId = searchParams.get("termId");
return ( 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> <WriteHeader></WriteHeader>
<LetterFormProvider receiverId={receiverId} termId={termId}> <LetterFormProvider receiverId={receiverId} termId={termId}>
<LetterBasicForm /> <LetterBasicForm />

View File

@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from "react"; import { useState, useCallback, useEffect, useMemo } 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";
@ -9,136 +9,157 @@ import DepartmentSelect from "@web/src/components/models/department/department-s
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";
import { ObjectType, RoleName } from "@nice/common";
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: enabledStaffIds, isLoading: roleMapIsLoading } =
api.rolemap.getStaffIdsByRoleNames.useQuery({
roleNames: [RoleName.Leader, RoleName.Organization],
});
// eslint-disable-next-line react-hooks/exhaustive-deps
const { data, isLoading, error } = const { data, isLoading, error } =
api.staff.findManyWithPagination.useQuery({ api.staff.findManyWithPagination.useQuery(
page: currentPage, {
pageSize, page: currentPage,
where: { pageSize,
deptId: selectedDept, where: {
OR: [ id: enabledStaffIds
{ ? {
showname: { in: enabledStaffIds,
contains: searchQuery, }
}, : undefined,
}, deptId: selectedDept,
{ OR: [
username: { {
contains: searchQuery, showname: {
}, contains: searchQuery,
}, },
},
{
username: {
contains: searchQuery,
},
},
{
meta: {
path: ["rank"], // 指定 JSON 字段的路径
string_contains: searchQuery, // 对 rank 字段进行模糊搜索
},
},
],
},
orderBy: {
order: "asc",
},
},
{
enabled: !roleMapIsLoading,
}
);
], const resetPage = useCallback(() => {
}, setCurrentPage(1);
orderBy: { }, []);
order: "desc",
}
});
const resetPage = useCallback(() => { // Reset page when search or department changes
setCurrentPage(1); useEffect(() => {
}, []); resetPage();
}, [searchQuery, selectedDept, resetPage]);
// Reset page when search or department changes return (
useEffect(() => { <div className="min-h-screen shadow-elegant border-2 border-white rounded-xl bg-slate-200">
resetPage(); <WriteHeader term={getTerm(termId)} />
}, [searchQuery, selectedDept, resetPage]); <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 ( {/* Pagination */}
<div className="min-h-screen shadow-elegant border-2 border-white rounded-xl bg-slate-200"> {data?.items.length > 0 && (
<WriteHeader term={getTerm(termId)} /> <div className="flex justify-center mt-8">
<div className=" mx-auto p-6"> <Pagination
<div className="mb-4 space-y-4"> current={currentPage}
<div className="flex flex-col md:flex-row gap-4 items-center"> total={data?.totalPages || 0}
<DepartmentSelect pageSize={pageSize}
variant="filled" onChange={(page) => {
size="large" setCurrentPage(page);
value={selectedDept} window.scrollTo(0, 0);
onChange={setSelectedDept as any} }}
className="w-1/2" showSizeChanger={false}
/> showTotal={(total) => `${total} 条记录`}
<Input />
variant="filled" </div>
className={"w-1/2"} )}
prefix={ </div>
<SearchOutlined className="text-gray-400" /> </div>
} );
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>
);
} }

View File

@ -2,6 +2,7 @@ import { env } from "@web/src/env";
import { message, Progress, Spin, theme } from "antd"; import { message, Progress, Spin, theme } from "antd";
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect, useRef } from "react";
import { useTusUpload } from "@web/src/hooks/useTusUpload"; import { useTusUpload } from "@web/src/hooks/useTusUpload";
import { Avatar } from "antd/lib";
export interface AvatarUploaderProps { export interface AvatarUploaderProps {
value?: string; value?: string;
@ -16,6 +17,7 @@ interface UploadingFile {
progress: number; progress: number;
status: "uploading" | "done" | "error"; status: "uploading" | "done" | "error";
fileId?: string; fileId?: string;
url?: string;
fileKey?: string; fileKey?: string;
} }
@ -28,6 +30,7 @@ const AvatarUploader: React.FC<AvatarUploaderProps> = ({
}) => { }) => {
const { handleFileUpload, uploadProgress } = useTusUpload(); const { handleFileUpload, uploadProgress } = useTusUpload();
const [file, setFile] = useState<UploadingFile | null>(null); const [file, setFile] = useState<UploadingFile | null>(null);
const [previewUrl, setPreviewUrl] = useState<string>(value || ""); const [previewUrl, setPreviewUrl] = useState<string>(value || "");
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@ -56,7 +59,9 @@ const AvatarUploader: React.FC<AvatarUploaderProps> = ({
progress: 100, progress: 100,
status: "done", status: "done",
fileId: result.fileId, fileId: result.fileId,
url: result?.url,
})); }));
setPreviewUrl(result?.url);
resolve(result.fileId); resolve(result.fileId);
}, },
(error) => { (error) => {
@ -65,7 +70,6 @@ const AvatarUploader: React.FC<AvatarUploaderProps> = ({
file?.fileKey file?.fileKey
); );
}); });
setPreviewUrl(`${env.SERVER_IP}/uploads/${fileId}`);
onChange?.(fileId); onChange?.(fileId);
message.success("头像上传成功"); message.success("头像上传成功");
} catch (error) { } catch (error) {
@ -98,9 +102,9 @@ const AvatarUploader: React.FC<AvatarUploaderProps> = ({
style={{ display: "none" }} style={{ display: "none" }}
/> />
{previewUrl ? ( {previewUrl ? (
<img <Avatar
src={previewUrl} src={previewUrl}
alt="Avatar" shape="square"
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
) : ( ) : (

View File

@ -4,14 +4,16 @@ import {
CheckCircleOutlined, CheckCircleOutlined,
DeleteOutlined, DeleteOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { Upload, message, Progress, Button } from "antd"; import { Upload, Progress, Button } from "antd";
import type { UploadFile } from "antd"; import type { UploadFile } from "antd";
import { useTusUpload } from "@web/src/hooks/useTusUpload"; import { useTusUpload } from "@web/src/hooks/useTusUpload";
import toast from "react-hot-toast";
export interface TusUploaderProps { export interface TusUploaderProps {
value?: string[]; value?: string[];
onChange?: (value: string[]) => void; onChange?: (value: string[]) => void;
} }
interface UploadingFile { interface UploadingFile {
name: string; name: string;
progress: number; progress: number;
@ -32,98 +34,82 @@ export const TusUploader = ({ value = [], onChange }: TusUploaderProps) => {
fileId, fileId,
})) || [] })) || []
); );
// 恢复使用 uploadResults 状态跟踪最新结果
const [uploadResults, setUploadResults] = useState<string[]>(value || []); const [uploadResults, setUploadResults] = useState<string[]>(value || []);
const handleRemoveFile = useCallback( const handleRemoveFile = useCallback(
(fileId: string) => { (fileId: string) => {
setCompletedFiles((prev) => setCompletedFiles((prev) =>
prev.filter((f) => f.fileId !== fileId) prev.filter((f) => f.fileId !== fileId)
); );
const newResults = uploadResults.filter((id) => id !== fileId); // 使用函数式更新保证获取最新状态
setUploadResults(newResults); setUploadResults((prev) => {
onChange?.(newResults); const newValue = prev.filter((id) => id !== fileId);
onChange?.(newValue); // 同步更新父组件
return newValue;
});
}, },
[uploadResults, onChange] [onChange]
); );
const handleChange = useCallback( const handleBeforeUpload = useCallback(
async (fileList: UploadFile | UploadFile[]) => { (file: File) => {
const files = Array.isArray(fileList) ? fileList : [fileList]; const fileKey = `${file.name}-${Date.now()}`;
console.log("文件", files);
if (!files.every((f) => f instanceof File)) { setUploadingFiles((prev) => [
message.error("无效的文件格式"); ...prev,
return false; {
} name: file.name,
progress: 0,
status: "uploading",
fileKey,
},
]);
const newFiles: UploadingFile[] = files.map((f) => ({ handleFileUpload(
name: f.name, file,
progress: 0, (result) => {
status: "uploading" as const, setCompletedFiles((prev) => [
fileKey: `${f.name}-${Date.now()}`, // 为每个文件创建唯一标识 ...prev,
})); {
name: file.name,
progress: 100,
status: "done",
fileId: result.fileId,
},
]);
setUploadingFiles((prev) => [...prev, ...newFiles]); setUploadingFiles((prev) =>
prev.filter((f) => f.fileKey !== fileKey)
const newUploadResults: string[] = [];
try {
for (const [index, f] of files.entries()) {
if (!f) {
throw new Error(`文件 ${f.name} 无效`);
}
const fileKey = newFiles[index].fileKey!;
const fileId = await new Promise<string>(
(resolve, reject) => {
handleFileUpload(
f as File,
(result) => {
console.log("上传成功:", result);
const completedFile = {
name: f.name,
progress: 100,
status: "done" as const,
fileId: result.fileId,
};
setCompletedFiles((prev) => [
...prev,
completedFile,
]);
setUploadingFiles((prev) =>
prev.filter(
(file) => file.fileKey !== fileKey
)
);
resolve(result.fileId);
},
(error) => {
console.error("上传错误:", error);
reject(error);
},
fileKey
);
}
); );
newUploadResults.push(fileId);
}
const newValue = Array.from( // 正确的状态更新方式
new Set([...uploadResults, ...newUploadResults]) setUploadResults((prev) => {
); const newValue = [...prev, result.fileId];
setUploadResults(newValue); onChange?.(newValue); // 传递值而非函数
onChange?.(newValue); return newValue;
message.success(`${files.length} 个文件上传成功`); });
} catch (error) { },
console.error("上传错误详情:", error); (error) => {
message.error( console.error("上传错误:", error);
`上传失败: ${error instanceof Error ? error.message : "未知错误"}` toast.error(
); `上传失败: ${
setUploadingFiles((prev) => error instanceof Error ? error.message : "未知错误"
prev.map((f) => ({ ...f, status: "error" })) }`
); );
} setUploadingFiles((prev) =>
prev.map((f) =>
f.fileKey === fileKey
? { ...f, status: "error" }
: f
)
);
},
fileKey
);
return false; return false;
}, },
[uploadResults, onChange, handleFileUpload] [handleFileUpload, onChange]
); );
return ( return (
@ -132,8 +118,8 @@ export const TusUploader = ({ value = [], onChange }: TusUploaderProps) => {
name="files" name="files"
multiple multiple
showUploadList={false} showUploadList={false}
style={{ background: "white", borderStyle: "solid" }} style={{ background: "transparent", borderStyle: "none" }}
beforeUpload={handleChange}> beforeUpload={handleBeforeUpload}>
<p className="ant-upload-drag-icon"> <p className="ant-upload-drag-icon">
<UploadOutlined /> <UploadOutlined />
</p> </p>
@ -141,64 +127,58 @@ export const TusUploader = ({ value = [], onChange }: TusUploaderProps) => {
</p> </p>
<p className="ant-upload-hint"></p> <p className="ant-upload-hint"></p>
{/* 正在上传的文件 */}
{(uploadingFiles.length > 0 || completedFiles.length > 0) && (
<div className=" px-2 py-0 border rounded bg-white mt-1 ">
{uploadingFiles.map((file) => (
<div
key={file.fileKey}
className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<div className="text-sm">{file.name}</div>
</div>
<Progress {/* 上传状态展示 */}
className="flex-1 w-full" <div className="px-2 py-0 rounded mt-1">
percent={ {/* 上传中的文件 */}
file.status === "done" {uploadingFiles.map((file) => (
? 100 <div
: Math.round( key={file.fileKey}
uploadProgress?.[ className="flex flex-col gap-1 mb-2">
file?.fileKey <div className="flex items-center gap-2">
] || 0 <span className="text-sm">{file.name}</span>
)
}
status={
file.status === "error"
? "exception"
: file.status === "done"
? "success"
: "active"
}
/>
</div> </div>
))} <Progress
{completedFiles.length > 0 && percent={
completedFiles.map((file, index) => ( file.status === "done"
<div ? 100
key={index} : Math.round(
className="flex items-center justify-between gap-2"> uploadProgress?.[
<div className="flex items-center gap-2"> file.fileKey!
<CheckCircleOutlined className="text-green-500" /> ] || 0
<div className="text-sm"> )
{file.name} }
</div> status={
</div> file.status === "error"
<Button ? "exception"
type="text" : "active"
danger }
icon={<DeleteOutlined />} />
onClick={(e) => { </div>
e.stopPropagation(); // 阻止事件冒泡 ))}
if (file.fileId) {
handleRemoveFile(file.fileId); // 只删除文件 {/* 已完成的文件 */}
} {completedFiles.map((file) => (
}} <div
/> key={file.fileId}
</div> className="flex items-center justify-between gap-2 mb-2">
))} <div className="flex items-center gap-2">
</div> <CheckCircleOutlined className="text-green-500" />
)} <span className="text-sm">{file.name}</span>
</div>
<Button
type="text"
danger
icon={<DeleteOutlined />}
onClick={(e) => {
e.stopPropagation();
if (file.fileId)
handleRemoveFile(file.fileId);
}}
/>
</div>
))}
</div>
</Upload.Dragger> </Upload.Dragger>
</div> </div>
); );

View File

@ -7,25 +7,29 @@ import { UserOutlined } from "@ant-design/icons";
import { UserMenu } from "../element/usermenu/usermenu"; import { UserMenu } from "../element/usermenu/usermenu";
export const Header = memo(function Header() { export const Header = memo(function Header() {
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=" mx-auto px-4"> <div className=" mx-auto px-4">
<div className="py-4"> <div className="py-4">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className=""> <div className="">
<span className="text-xl font-bold"></span> <span className="text-xl font-bold">
<p className=" text-sm text-secondary-50">怀</p>
</div> </span>
<div className="flex-grow max-w-2xl"> <p className=" text-sm text-secondary-50">
<SearchBar /> 怀
</div> </p>
<div className="flex items-center "> </div>
{!isAuthenticated ? ( <div className="flex-grow max-w-2xl">
<Link <SearchBar />
to="/auth" </div>
className="group flex items-center gap-2 rounded-lg <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 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]
@ -33,22 +37,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> </Link>
) : ( ) : (
<UserMenu /> <UserMenu />
)} )}
</div> </div>
</div> </div>
</div> </div>
<Navigation /> <Navigation />
</div> </div>
</header> </header>
); );
}); });

View File

@ -28,7 +28,7 @@ export default function Navigation({ className }: NavigationProps) {
return ( return (
<nav <nav
className={twMerge( 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 className
)}> )}>
<div className="flex flex-col md:flex-row items-stretch"> <div className="flex flex-col md:flex-row items-stretch">

View File

@ -14,6 +14,7 @@ import { PostDto, PostStateLabels } from "@nice/common";
import dayjs from "dayjs"; import dayjs from "dayjs";
import PostLikeButton from "./detail/PostHeader/PostLikeButton"; import PostLikeButton from "./detail/PostHeader/PostLikeButton";
import { LetterBadge } from "./LetterBadge"; import { LetterBadge } from "./LetterBadge";
import PostHateButton from "./detail/PostHeader/PostHateButton";
const { Title, Paragraph, Text } = Typography; const { Title, Paragraph, Text } = Typography;
interface LetterCardProps { interface LetterCardProps {
@ -48,7 +49,9 @@ export function LetterCard({ letter }: LetterCardProps) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<UserOutlined className="text-primary text-base" /> <UserOutlined className="text-primary text-base" />
<Text className="text-primary font-medium"> <Text className="text-primary font-medium">
{letter.author?.showname || "匿名用户"} {letter?.meta?.signature ||
letter.author?.showname ||
"匿名用户"}
</Text> </Text>
</div> </div>
@ -116,6 +119,7 @@ export function LetterCard({ letter }: LetterCardProps) {
<span className="mr-1"></span> <span className="mr-1"></span>
{letter.views} {letter.views}
</Button> </Button>
<PostHateButton post={letter as any}></PostHateButton>
<PostLikeButton post={letter as any}></PostLikeButton> <PostLikeButton post={letter as any}></PostLikeButton>
</div> </div>
</div> </div>

View File

@ -6,10 +6,17 @@ import { Avatar } from "antd";
import { useVisitor } from "@nice/client"; import { useVisitor } from "@nice/client";
import { useContext, useEffect, useRef, useState } from "react"; import { useContext, useEffect, useRef, useState } from "react";
import { PostDetailContext } from "./context/PostDetailContext"; import { PostDetailContext } from "./context/PostDetailContext";
import { CheckCircleOutlined, CheckOutlined, LikeFilled, LikeOutlined } from "@ant-design/icons"; import {
CheckCircleOutlined,
CheckOutlined,
LikeFilled,
LikeOutlined,
} from "@ant-design/icons";
import PostLikeButton from "./PostHeader/PostLikeButton"; import PostLikeButton from "./PostHeader/PostLikeButton";
import { CustomAvatar } from "@web/src/components/presentation/CustomAvatar"; import { CustomAvatar } from "@web/src/components/presentation/CustomAvatar";
import PostResources from "./PostResources"; import PostResources from "./PostResources";
import PostHateButton from "./PostHeader/PostHateButton";
import PostSendButton from "./PostHeader/PostSendButton";
export default function PostCommentCard({ export default function PostCommentCard({
post, post,
@ -30,14 +37,18 @@ export default function PostCommentCard({
src={post.author?.avatar} src={post.author?.avatar}
size={50} size={50}
name={!post.author?.avatar && post.author?.showname} name={!post.author?.avatar && post.author?.showname}
ip={post?.meta?.ip}></CustomAvatar> randomString={
post?.meta?.signature || post?.meta?.ip
}></CustomAvatar>
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className={`flex-1 min-w-0 `}> <div className={`flex-1 min-w-0 `}>
<div className="flex flex-1 justify-between "> <div className="flex flex-1 justify-between ">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span className="flex font-medium text-slate-900"> <span className="flex font-medium text-slate-900">
{post.author?.showname || "匿名用户"} {post?.meta?.signature ||
post.author?.showname ||
"匿名用户"}
</span> </span>
<span className="flex text-sm text-slate-500 "> <span className="flex text-sm text-slate-500 ">
{dayjs(post?.createdAt).format( {dayjs(post?.createdAt).format(
@ -54,9 +65,15 @@ export default function PostCommentCard({
{/* 添加有帮助按钮 */} {/* 添加有帮助按钮 */}
<div> <div>
<div className="flex justify-center items-center gap-2"> <div className="flex justify-center items-center gap-2">
<span className=" text-sm text-slate-500">{`#${index + 1}`}</span> {isReceiverComment && (
<PostSendButton
post={post}></PostSendButton>
)}
<PostHateButton
post={post}></PostHateButton>
<PostLikeButton <PostLikeButton
post={post}></PostLikeButton> post={post}></PostLikeButton>
<span className=" text-sm text-slate-500">{`#${index + 1}`}</span>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,25 +1,24 @@
import React, { useContext, useState } from "react"; import React, { useContext, useState } from "react";
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import { Button, Tabs } from "antd"; import { Button, Input, Tabs } from "antd";
import QuillEditor from "@web/src/components/common/editor/quill/QuillEditor"; import QuillEditor from "@web/src/components/common/editor/quill/QuillEditor";
import { PostDetailContext } from "./context/PostDetailContext"; import { PostDetailContext } from "./context/PostDetailContext";
import { usePost } from "@nice/client"; import { useEntity } from "@nice/client";
import { PostType } from "@nice/common"; import { PostType } from "@nice/common";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { isContentEmpty } from "./utils"; import { isContentEmpty } from "./utils";
import { SendOutlined } from "@ant-design/icons"; import { SendOutlined } from "@ant-design/icons";
import { TusUploader } from "@web/src/components/common/uploader/TusUploader"; import { TusUploader } from "@web/src/components/common/uploader/TusUploader";
import { useEntity } from '../../../../../../../packages/client/src/api/hooks/useEntity'; import { CustomAvatar } from "@web/src/components/presentation/CustomAvatar";
const { TabPane } = Tabs; const { TabPane } = Tabs;
export default function PostCommentEditor() { export default function PostCommentEditor() {
const { post } = useContext(PostDetailContext); const { post } = useContext(PostDetailContext);
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const [isPreview, setIsPreview] = useState(false); const [signature, setSignature] = useState<string | undefined>(undefined);
const [fileIds, setFileIds] = useState<string[]>([]); const [fileIds, setFileIds] = useState<string[]>([]);
// const { create } = usePost(); const { create } = useEntity("post")
const {create}=useEntity("post")
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (isContentEmpty(content)) { if (isContentEmpty(content)) {
@ -31,7 +30,7 @@ export default function PostCommentEditor() {
await create.mutateAsync({ await create.mutateAsync({
data: { data: {
type: PostType.POST_COMMENT, type: PostType.POST_COMMENT,
parentId: post?.id, parentId: post?.id,
content: content, content: content,
resources: { resources: {
@ -39,11 +38,14 @@ export default function PostCommentEditor() {
fileId: id, fileId: id,
})), })),
}, },
meta: {
signature,
},
}, },
}); });
toast.success("发布成功!"); toast.success("发布成功!");
setContent(""); setContent("");
setFileIds([]);
} catch (error) { } catch (error) {
toast.error("发布失败,请稍后重试"); toast.error("发布失败,请稍后重试");
console.error("Error posting comment:", error); console.error("Error posting comment:", error);
@ -62,11 +64,9 @@ export default function PostCommentEditor() {
<QuillEditor <QuillEditor
value={content} value={content}
onChange={setContent} onChange={setContent}
className="bg-transparent" className="bg-transparent"
theme="snow" theme="snow"
minRows={6} minRows={6}
modules={{ modules={{
toolbar: [ toolbar: [
["bold", "italic", "strike"], ["bold", "italic", "strike"],
@ -79,26 +79,39 @@ export default function PostCommentEditor() {
["clean"], ["clean"],
], ],
}} }}
/> />
</div> </div>
</TabPane> </TabPane>
<TabPane tab="附件" key="2"> <TabPane tab="附件" key="2">
<TusUploader <div className="relative rounded-xl border border-white hover:ring-1 ring-white transition-all duration-300 ease-in-out bg-slate-100">
onChange={(value) => { <TusUploader
setFileIds(value); onChange={(value) => {
}} console.log("ids", value);
/> setFileIds(value);
}}
/>
</div>
</TabPane> </TabPane>
</Tabs> </Tabs>
{!isContentEmpty(content) && ( {!isContentEmpty(content) && (
<div className="flex items-center justify-end"> <div className="flex items-center justify-end gap-2">
<CustomAvatar randomString={signature}></CustomAvatar>
<Input
maxLength={10}
style={{
width: 150,
}}
onChange={(e) => {
setSignature(e.target.value);
}}
showCount
placeholder="签名"
/>
<div> <div>
<Button <Button
type="primary" type="primary"
htmlType="submit" htmlType="submit"
size="large"
disabled={isContentEmpty(content)} disabled={isContentEmpty(content)}
className="flex items-center space-x-2 bg-primary" className="flex items-center space-x-2 bg-primary"
icon={<SendOutlined />}> icon={<SendOutlined />}>

View File

@ -26,16 +26,15 @@ export default function Header() {
</h1> </h1>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Space className="mr-4"> <Space className="mr-4">
<span className="text-white"></span> <span className="text-white"></span>
<Text className="text-white" strong> <Text className="text-white" strong>
{post?.author?.showname || "匿名用户"} {post?.meta?.signature ||
post?.author?.showname ||
"匿名用户"}
</Text> </Text>
</Space> </Space>
<Space className="mr-4"> <Space className="mr-4">
<span className="text-white"> </span> <span className="text-white"> </span>
{post?.receivers?.map((receiver, index) => ( {post?.receivers?.map((receiver, index) => (
@ -43,27 +42,23 @@ export default function Header() {
strong strong
className="text-white" className="text-white"
key={`${index}`}> key={`${index}`}>
{receiver?.meta?.rank} {receiver?.showname || '匿名用户'} {receiver?.meta?.rank}{" "}
{receiver?.showname || "匿名用户"}
</Text> </Text>
))} ))}
</Space> </Space>
{/* Date Info Badge */} {/* Date Info Badge */}
<Space className="mr-4"> <Space className="mr-4">
<span className="text-white"></span> <span className="text-white"></span>
<Text className="text-white"> <Text className="text-white">
{dayjs(post?.createdAt).format("YYYY-MM-DD")} {dayjs(post?.createdAt).format("YYYY-MM-DD")}
</Text> </Text>
</Space> </Space>
{/* Last Updated Badge */} {/* Last Updated Badge */}
<Space className="mr-4"> <Space className="mr-4">
<span className="text-white"></span> <span className="text-white"></span>
<Text className="text-white"> <Text className="text-white">
{dayjs(post?.updatedAt).format("YYYY-MM-DD")} {dayjs(post?.updatedAt).format("YYYY-MM-DD")}
</Text> </Text>
</Space> </Space>

View File

@ -0,0 +1,53 @@
import { PostDto, VisitType } from "@nice/common";
import { useVisitor } from "@nice/client";
import { Button, Tooltip } from "antd";
import { DislikeFilled, DislikeOutlined } from "@ant-design/icons";
import { useAuth } from "@web/src/providers/auth-provider";
export default function PostHateButton({ post }: { post: PostDto }) {
const { user } = useAuth();
const { hate, unHate } = useVisitor();
function hateThisPost() {
if (!post?.hated) {
post.hates += 1;
post.hated = true;
hate.mutateAsync({
data: {
visitorId: user?.id || null,
postId: post.id,
type: VisitType.HATE,
},
});
} else {
post.hates -= 1;
post.hated = false;
unHate.mutateAsync({
where: {
visitorId: user?.id || null,
postId: post.id,
type: VisitType.HATE,
},
});
}
}
return (
<Button
title={post?.hated ? "取消点踩" : "点踩"}
type={post?.hated ? "primary" : "default"}
style={{
backgroundColor: post?.hated ? "#ff4d4f" : "#fff",
borderColor: post?.hated ? "transparent" : "",
color: post?.hated ? "#fff" : "#000",
boxShadow: "none", // 去除阴影
}}
shape="round"
icon={post?.hated ? <DislikeFilled /> : <DislikeOutlined />}
onClick={(e) => {
e.stopPropagation();
hateThisPost();
}}>
<span className="mr-1"></span>
{post?.hates || 0}
</Button>
);
}

View File

@ -35,16 +35,16 @@ export default function PostLikeButton({ post }: { post: PostDto }) {
title={post?.liked ? "取消点赞" : "点赞"} title={post?.liked ? "取消点赞" : "点赞"}
type={post?.liked ? "primary" : "default"} type={post?.liked ? "primary" : "default"}
shape="round" shape="round"
style={{
boxShadow: "none", // 去除阴影
}}
icon={post?.liked ? <LikeFilled /> : <LikeOutlined />} icon={post?.liked ? <LikeFilled /> : <LikeOutlined />}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
likeThisPost() likeThisPost();
}} }}>
>
<span className="mr-1"></span> <span className="mr-1"></span>
{post?.likes} {post?.likes}
</Button> </Button>
); );
} }

View File

@ -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>
);
}

View File

@ -10,6 +10,7 @@ import { Button, Tooltip } from "antd/lib";
import { PostDetailContext } from "../context/PostDetailContext"; import { PostDetailContext } from "../context/PostDetailContext";
import PostLikeButton from "./PostLikeButton"; import PostLikeButton from "./PostLikeButton";
import PostResources from "../PostResources"; import PostResources from "../PostResources";
import PostHateButton from "./PostHateButton";
export function StatsSection() { export function StatsSection() {
const { post } = useContext(PostDetailContext); const { post } = useContext(PostDetailContext);
@ -25,6 +26,7 @@ export function StatsSection() {
<Button type="default" title="回复数" shape="round" icon={<CommentOutlined />}> <Button type="default" title="回复数" shape="round" icon={<CommentOutlined />}>
<span className="mr-1"></span>{post?.commentsCount} <span className="mr-1"></span>{post?.commentsCount}
</Button> </Button>
<PostHateButton post={post}></PostHateButton>
<PostLikeButton post={post}></PostLikeButton> <PostLikeButton post={post}></PostLikeButton>
</div> </div>

View File

@ -105,7 +105,9 @@ export default function PostResources({ post }: { post: PostDto }) {
</span> </span>
<span className="text-xs text-gray-400"> <span className="text-xs text-gray-400">
{resource.meta.size && {resource.meta.size &&
formatFileSize(resource.meta.size)} formatFileSize(
resource.meta.size
)}
</span> </span>
</div> </div>
</div> </div>

View File

@ -65,7 +65,7 @@ export function LetterBasicForm() {
name="content" name="content"
rules={[{ required: true, message: "请输入内容" }]} rules={[{ required: true, message: "请输入内容" }]}
required={false}> 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 <QuillEditor
maxLength={10000} maxLength={10000}
placeholder="请输入内容" placeholder="请输入内容"
@ -79,7 +79,7 @@ export function LetterBasicForm() {
</TabPane> </TabPane>
<TabPane tab="附件" key="2"> <TabPane tab="附件" key="2">
<Form.Item name="resources" required={false}> <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 <TusUploader
onChange={(resources) => onChange={(resources) =>
form.setFieldValue( form.setFieldValue(
@ -99,14 +99,26 @@ export function LetterBasicForm() {
</Checkbox> </Checkbox>
</Form.Item> </Form.Item>
<Button <div className="flex gap-2 ">
type="primary" <Form.Item name={["meta", "signature"]}>
onClick={() => form.submit()} <Input
size="large" maxLength={10}
icon={<SendOutlined />} style={{
className="w-full sm:w-40"> width: 150,
}}
</Button> showCount
placeholder="签名"
/>
</Form.Item>
<Button
type="primary"
onClick={() => form.submit()}
size="large"
icon={<SendOutlined />}
className="w-full sm:w-40">
</Button>
</div>
</div> </div>
</Form> </Form>
</div> </div>

View File

@ -4,28 +4,24 @@ import { RoleEditorContext } from "./role-editor";
import RoleForm from "./role-form"; import RoleForm from "./role-form";
export default function RoleModal() { export default function RoleModal() {
const { const { roleForm, editRoleId, roleModalOpen, setRoleModalOpen } =
roleForm, useContext(RoleEditorContext);
editRoleId,
roleModalOpen, setRoleModalOpen
} = useContext(RoleEditorContext);
const handleOk = async () => { const handleOk = async () => {
roleForm.submit() roleForm.submit();
}; };
const handleCancel = () => setRoleModalOpen(false); const handleCancel = () => setRoleModalOpen(false);
return ( return (
<Modal <Modal
width={500} width={500}
title={editRoleId ? "编辑角色" : "创建角色"} title={editRoleId ? "编辑角色" : "创建角色"}
open={roleModalOpen} open={roleModalOpen}
onOk={handleOk} onOk={handleOk}
// confirmLoading={loading} // confirmLoading={loading}
onCancel={handleCancel} onCancel={handleCancel}>
> <RoleForm></RoleForm>
<RoleForm></RoleForm> </Modal>
</Modal> );
);
} }

View File

@ -6,14 +6,14 @@ import multiavatar from "@multiavatar/multiavatar";
interface CustomAvatarProps extends Omit<AvatarProps, "children"> { interface CustomAvatarProps extends Omit<AvatarProps, "children"> {
src?: string; src?: string;
name?: string; name?: string;
ip?: string; randomString?: string;
} }
export function CustomAvatar({ export function CustomAvatar({
src, src,
name, name,
className = "", className = "",
ip, randomString,
...props ...props
}: CustomAvatarProps) { }: CustomAvatarProps) {
// 获取名字的第一个字符,如果没有名字则显示"匿" // 获取名字的第一个字符,如果没有名字则显示"匿"
@ -39,7 +39,7 @@ export function CustomAvatar({
const avatarSrc = const avatarSrc =
src || (name && name !== "匿名用户") src || (name && name !== "匿名用户")
? src ? src
: generateAvatarFromIp(ip || "default"); : generateAvatarFromIp(randomString || "default");
return ( return (
<Avatar <Avatar

View File

@ -1,5 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import * as tus from "tus-js-client"; import * as tus from "tus-js-client";
import { env } from "../env";
// useTusUpload.ts // useTusUpload.ts
interface UploadProgress { interface UploadProgress {
@ -27,7 +28,16 @@ export function useTusUpload() {
} }
return parts.slice(uploadIndex + 1, uploadIndex + 5).join("/"); 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 ( const handleFileUpload = async (
file: File, file: File,
onSuccess: (result: UploadResult) => void, onSuccess: (result: UploadResult) => void,
@ -52,7 +62,7 @@ export function useTusUpload() {
metadata: { metadata: {
filename: file.name, filename: file.name,
filetype: file.type, filetype: file.type,
size: file.size as any size: file.size as any,
}, },
onProgress: (bytesUploaded, bytesTotal) => { onProgress: (bytesUploaded, bytesTotal) => {
const progress = Number( const progress = Number(
@ -67,13 +77,14 @@ export function useTusUpload() {
try { try {
if (upload.url) { if (upload.url) {
const fileId = getFileId(upload.url); const fileId = getFileId(upload.url);
const url = getResourceUrl(upload.url);
setIsUploading(false); setIsUploading(false);
setUploadProgress((prev) => ({ setUploadProgress((prev) => ({
...prev, ...prev,
[fileKey]: 100, [fileKey]: 100,
})); }));
onSuccess({ onSuccess({
url: upload.url, url,
fileId, fileId,
}); });
} }

View File

@ -3,15 +3,15 @@ import { AppConfigSlug, BaseSetting } from "@nice/common";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
export function useAppConfig() { export function useAppConfig() {
const utils = api.useUtils() const utils = api.useUtils();
const [baseSetting, setBaseSetting] = useState<BaseSetting | undefined>(); const [baseSetting, setBaseSetting] = useState<BaseSetting | undefined>();
const { data, isLoading }: { data: any; isLoading: boolean } = const { data, isLoading }: { data: any; isLoading: boolean } =
api.app_config.findFirst.useQuery({ api.app_config.findFirst.useQuery({
where: { slug: AppConfigSlug.BASE_SETTING } where: { slug: AppConfigSlug.BASE_SETTING },
}); });
const handleMutationSuccess = useCallback(() => { const handleMutationSuccess = useCallback(() => {
utils.app_config.invalidate() utils.app_config.invalidate();
}, [utils]); }, [utils]);
// Use the generic success handler in mutations // Use the generic success handler in mutations
@ -28,7 +28,6 @@ export function useAppConfig() {
if (data?.meta) { if (data?.meta) {
setBaseSetting(JSON.parse(data?.meta)); setBaseSetting(JSON.parse(data?.meta));
} }
}, [data, isLoading]); }, [data, isLoading]);
const splashScreen = useMemo(() => { const splashScreen = useMemo(() => {
return baseSetting?.appConfig?.splashScreen; return baseSetting?.appConfig?.splashScreen;
@ -36,8 +35,10 @@ export function useAppConfig() {
const devDept = useMemo(() => { const devDept = useMemo(() => {
return baseSetting?.appConfig?.devDept; return baseSetting?.appConfig?.devDept;
}, [baseSetting]); }, [baseSetting]);
const logo = useMemo(() => {
return baseSetting?.appConfig?.logo;
}, [baseSetting]);
return { return {
create, create,
deleteMany, deleteMany,
update, update,
@ -45,5 +46,6 @@ export function useAppConfig() {
splashScreen, splashScreen,
devDept, devDept,
isLoading, isLoading,
logo,
}; };
} }

View File

@ -8,6 +8,7 @@ export function useVisitor() {
const create = api.visitor.create.useMutation({ const create = api.visitor.create.useMutation({
onSuccess() { onSuccess() {
utils.visitor.invalidate(); utils.visitor.invalidate();
// utils.post.invalidate(); // utils.post.invalidate();
}, },
}); });
@ -171,5 +172,7 @@ export function useVisitor() {
deleteStar, deleteStar,
like, like,
unLike, unLike,
hate,
unHate,
}; };
} }

View File

@ -201,3 +201,10 @@ export const PostStateLabels = {
[PostState.PROCESSING]: "处理中", [PostState.PROCESSING]: "处理中",
[PostState.RESOLVED]: "已完成", [PostState.RESOLVED]: "已完成",
}; };
export enum RoleName {
Basic = "基层", // 基层
Organization = "机关", // 机关
Leader = "领导", // 领导
DomainAdmin = "域管理员", // 域管理员
RootAdmin = "根管理员", // 根管理员
}

View File

@ -320,6 +320,9 @@ export const RoleMapMethodSchema = {
domainId: z.string().nullish(), domainId: z.string().nullish(),
roleId: z.string().nullish(), roleId: z.string().nullish(),
}), }),
getStaffIdsByRoleNames: z.object({
roleNames: z.array(z.string()),
}),
}; };
export const RoleMethodSchema = { export const RoleMethodSchema = {
create: z.object({ create: z.object({

View File

@ -8,6 +8,7 @@ export const postDetailSelect: Prisma.PostSelect = {
content: true, content: true,
views: true, views: true,
likes: true, likes: true,
hates: true,
isPublic: true, isPublic: true,
resources: true, resources: true,
createdAt: true, createdAt: true,

View File

@ -39,11 +39,11 @@ export type StaffDto = Staff & {
domain?: Department; domain?: Department;
department?: Department; department?: Department;
meta?: { meta?: {
photoUrl?: string photoUrl?: string;
office?: string office?: string;
email?: string email?: string;
rank?: string rank?: string;
} };
}; };
export interface AuthDto { export interface AuthDto {
token: string; token: string;
@ -182,6 +182,7 @@ export type ResourceDto = Resource & {
export type PostDto = Post & { export type PostDto = Post & {
readed: boolean; readed: boolean;
liked: boolean; liked: boolean;
hated: boolean;
readedCount: number; readedCount: number;
commentsCount: number; commentsCount: number;
terms: TermDto[]; terms: TermDto[];
@ -216,6 +217,7 @@ export interface BaseSetting {
appConfig?: { appConfig?: {
splashScreen?: string; splashScreen?: string;
devDept?: string; devDept?: string;
logo?: string;
}; };
} }
export interface PostMeta { export interface PostMeta {