This commit is contained in:
Your Name 2025-05-23 08:18:31 +08:00
parent f3cc347a8e
commit 58fc9d8895
58 changed files with 4846 additions and 4355 deletions

View File

@ -23,12 +23,12 @@ import { UploadModule } from './upload/upload.module';
imports: [ imports: [
ConfigModule.forRoot({ ConfigModule.forRoot({
isGlobal: true, // 全局可用 isGlobal: true, // 全局可用
envFilePath: '.env' envFilePath: '.env',
}), }),
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
JwtModule.register({ JwtModule.register({
global: true, global: true,
secret: env.JWT_SECRET secret: env.JWT_SECRET,
}), }),
WebSocketModule, WebSocketModule,
TrpcModule, TrpcModule,
@ -42,11 +42,13 @@ import { UploadModule } from './upload/upload.module';
MinioModule, MinioModule,
CollaborationModule, CollaborationModule,
RealTimeModule, RealTimeModule,
UploadModule UploadModule,
], ],
providers: [{ providers: [
{
provide: APP_FILTER, provide: APP_FILTER,
useClass: ExceptionsFilter, useClass: ExceptionsFilter,
}], },
],
}) })
export class AppModule { } export class AppModule {}

View File

@ -12,7 +12,7 @@ import { extractTokenFromHeader } from './utils';
@Injectable() @Injectable()
export class AuthGuard implements CanActivate { export class AuthGuard implements CanActivate {
constructor(private jwtService: JwtService) { } constructor(private jwtService: JwtService) {}
async canActivate(context: ExecutionContext): Promise<boolean> { async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest(); const request = context.switchToHttp().getRequest();
const token = extractTokenFromHeader(request); const token = extractTokenFromHeader(request);
@ -20,18 +20,13 @@ export class AuthGuard implements CanActivate {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
try { try {
const payload: JwtPayload = await this.jwtService.verifyAsync( const payload: JwtPayload = await this.jwtService.verifyAsync(token, {
token, secret: env.JWT_SECRET,
{ });
secret: env.JWT_SECRET
}
);
request['user'] = payload; request['user'] = payload;
} catch { } catch {
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
return true; return true;
} }
} }

View File

@ -8,12 +8,8 @@ import { SessionService } from './session.service';
import { RoleMapModule } from '@server/models/rbac/rbac.module'; import { RoleMapModule } from '@server/models/rbac/rbac.module';
@Module({ @Module({
imports: [StaffModule, RoleMapModule], imports: [StaffModule, RoleMapModule],
providers: [ providers: [AuthService, TrpcService, DepartmentService, SessionService],
AuthService,
TrpcService,
DepartmentService,
SessionService],
exports: [AuthService], exports: [AuthService],
controllers: [AuthController], controllers: [AuthController],
}) })
export class AuthModule { } export class AuthModule {}

View File

@ -29,16 +29,21 @@ export class SessionService {
const sessionInfo: SessionInfo = { const sessionInfo: SessionInfo = {
session_id: uuidv4(), session_id: uuidv4(),
access_token: accessToken, access_token: accessToken,
access_token_expires_at: Date.now() + expirationConfig.accessTokenExpirationMs, access_token_expires_at:
Date.now() + expirationConfig.accessTokenExpirationMs,
refresh_token: refreshToken, refresh_token: refreshToken,
refresh_token_expires_at: Date.now() + expirationConfig.refreshTokenExpirationMs, refresh_token_expires_at:
Date.now() + expirationConfig.refreshTokenExpirationMs,
}; };
await this.saveSession(userId, sessionInfo, expirationConfig.sessionTTL); await this.saveSession(userId, sessionInfo, expirationConfig.sessionTTL);
return sessionInfo; return sessionInfo;
} }
async getSession(userId: string, sessionId: string): Promise<SessionInfo | null> { async getSession(
userId: string,
sessionId: string,
): Promise<SessionInfo | null> {
const sessionData = await redis.get(this.getSessionKey(userId, sessionId)); const sessionData = await redis.get(this.getSessionKey(userId, sessionId));
return sessionData ? JSON.parse(sessionData) : null; return sessionData ? JSON.parse(sessionData) : null;
} }

View File

@ -9,10 +9,10 @@ export interface TokenConfig {
} }
export interface FileAuthResult { export interface FileAuthResult {
isValid: boolean isValid: boolean;
userId?: string userId?: string;
resourceType?: string resourceType?: string;
error?: string error?: string;
} }
export interface FileRequest { export interface FileRequest {
originalUri: string; originalUri: string;
@ -20,12 +20,12 @@ export interface FileRequest {
method: string; method: string;
queryParams: string; queryParams: string;
host: string; host: string;
authorization: string authorization: string;
} }
export enum FileValidationErrorType { export enum FileValidationErrorType {
INVALID_URI = 'INVALID_URI', INVALID_URI = 'INVALID_URI',
RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND', RESOURCE_NOT_FOUND = 'RESOURCE_NOT_FOUND',
AUTHORIZATION_REQUIRED = 'AUTHORIZATION_REQUIRED', AUTHORIZATION_REQUIRED = 'AUTHORIZATION_REQUIRED',
INVALID_TOKEN = 'INVALID_TOKEN', INVALID_TOKEN = 'INVALID_TOKEN',
UNKNOWN_ERROR = 'UNKNOWN_ERROR' UNKNOWN_ERROR = 'UNKNOWN_ERROR',
} }

View File

@ -11,7 +11,7 @@ import { env } from '@server/env';
import { redis } from '@server/utils/redis/redis.service'; import { redis } from '@server/utils/redis/redis.service';
import EventBus from '@server/utils/event-bus'; import EventBus from '@server/utils/event-bus';
import { RoleMapService } from '@server/models/rbac/rolemap.service'; import { RoleMapService } from '@server/models/rbac/rolemap.service';
import { Request } from "express" import { Request } from 'express';
interface ProfileResult { interface ProfileResult {
staff: UserProfile | undefined; staff: UserProfile | undefined;
error?: string; error?: string;
@ -22,9 +22,11 @@ interface TokenVerifyResult {
error?: string; error?: string;
} }
export function extractTokenFromHeader(request: Request): string | undefined { export function extractTokenFromHeader(request: Request): string | undefined {
return extractTokenFromAuthorization(request.headers.authorization) return extractTokenFromAuthorization(request.headers.authorization);
} }
export function extractTokenFromAuthorization(authorization: string): string | undefined { export function extractTokenFromAuthorization(
authorization: string,
): string | undefined {
const [type, token] = authorization?.split(' ') ?? []; const [type, token] = authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined; return type === 'Bearer' ? token : undefined;
} }
@ -40,7 +42,7 @@ export class UserProfileService {
this.jwtService = new JwtService(); this.jwtService = new JwtService();
this.departmentService = new DepartmentService(); this.departmentService = new DepartmentService();
this.roleMapService = new RoleMapService(this.departmentService); this.roleMapService = new RoleMapService(this.departmentService);
EventBus.on("dataChanged", ({ type, data }) => { EventBus.on('dataChanged', ({ type, data }) => {
if (type === ObjectType.STAFF) { if (type === ObjectType.STAFF) {
// 确保 data 是数组,如果不是则转换为数组 // 确保 data 是数组,如果不是则转换为数组
const dataArray = Array.isArray(data) ? data : [data]; const dataArray = Array.isArray(data) ? data : [data];
@ -51,7 +53,6 @@ export class UserProfileService {
} }
} }
}); });
} }
public getProfileCacheKey(id: string) { public getProfileCacheKey(id: string) {
return `user-profile-${id}`; return `user-profile-${id}`;
@ -175,9 +176,7 @@ export class UserProfileService {
staff.deptId staff.deptId
? this.departmentService.getDescendantIdsInDomain(staff.deptId) ? this.departmentService.getDescendantIdsInDomain(staff.deptId)
: [], : [],
staff.deptId staff.deptId ? this.departmentService.getAncestorIds([staff.deptId]) : [],
? this.departmentService.getAncestorIds([staff.deptId])
: [],
this.roleMapService.getPermsForObject({ this.roleMapService.getPermsForObject({
domainId: staff.domainId, domainId: staff.domainId,
staffId: staff.id, staffId: staff.id,

View File

@ -7,6 +7,6 @@ import { RealTimeModule } from '@server/socket/realtime/realtime.module';
@Module({ @Module({
imports: [RealTimeModule], imports: [RealTimeModule],
providers: [AppConfigService, AppConfigRouter, TrpcService], providers: [AppConfigService, AppConfigRouter, TrpcService],
exports: [AppConfigService, AppConfigRouter] exports: [AppConfigService, AppConfigRouter],
}) })
export class AppConfigModule { } export class AppConfigModule {}

View File

@ -3,9 +3,9 @@ import {
NotFoundException, NotFoundException,
ConflictException, ConflictException,
InternalServerErrorException, InternalServerErrorException,
} from '@nestjs/common'; } from '@nestjs/common';
export const PrismaErrorCode = Object.freeze({ export const PrismaErrorCode = Object.freeze({
P2000: 'P2000', P2000: 'P2000',
P2001: 'P2001', P2001: 'P2001',
P2002: 'P2002', P2002: 'P2002',
@ -35,32 +35,33 @@ import {
P1002: 'P1002', P1002: 'P1002',
P1015: 'P1015', P1015: 'P1015',
P1017: 'P1017', P1017: 'P1017',
}); });
export type PrismaErrorCode = keyof typeof PrismaErrorCode; export type PrismaErrorCode = keyof typeof PrismaErrorCode;
interface PrismaErrorMeta {
interface PrismaErrorMeta {
target?: string; target?: string;
model?: string; model?: string;
relationName?: string; relationName?: string;
details?: string; details?: string;
} }
export type operationT = 'create' | 'read' | 'update' | 'delete'; export type operationT = 'create' | 'read' | 'update' | 'delete';
export type PrismaErrorHandler = ( export type PrismaErrorHandler = (
operation: operationT, operation: operationT,
meta?: PrismaErrorMeta, meta?: PrismaErrorMeta,
) => Error; ) => Error;
export const ERROR_MAP: Record<PrismaErrorCode, PrismaErrorHandler> = { export const ERROR_MAP: Record<PrismaErrorCode, PrismaErrorHandler> = {
P2000: (_operation, meta) => new BadRequestException( P2000: (_operation, meta) =>
`The provided value for ${meta?.target || 'a field'} is too long. Please use a shorter value.` new BadRequestException(
`The provided value for ${meta?.target || 'a field'} is too long. Please use a shorter value.`,
), ),
P2001: (operation, meta) => new NotFoundException( P2001: (operation, meta) =>
`The ${meta?.model || 'record'} you are trying to ${operation} could not be found.` new NotFoundException(
`The ${meta?.model || 'record'} you are trying to ${operation} could not be found.`,
), ),
P2002: (operation, meta) => { P2002: (operation, meta) => {
@ -68,131 +69,154 @@ import {
switch (operation) { switch (operation) {
case 'create': case 'create':
return new ConflictException( return new ConflictException(
`A record with the same ${field} already exists. Please use a different value.` `A record with the same ${field} already exists. Please use a different value.`,
); );
case 'update': case 'update':
return new ConflictException( return new ConflictException(
`The new value for ${field} conflicts with an existing record.` `The new value for ${field} conflicts with an existing record.`,
); );
default: default:
return new ConflictException( return new ConflictException(
`Unique constraint violation on ${field}.` `Unique constraint violation on ${field}.`,
); );
} }
}, },
P2003: (operation) => new BadRequestException( P2003: (operation) =>
`Foreign key constraint failed. Unable to ${operation} the record because related data is invalid or missing.` new BadRequestException(
`Foreign key constraint failed. Unable to ${operation} the record because related data is invalid or missing.`,
), ),
P2006: (_operation, meta) => new BadRequestException( P2006: (_operation, meta) =>
`The provided value for ${meta?.target || 'a field'} is invalid. Please correct it.` new BadRequestException(
`The provided value for ${meta?.target || 'a field'} is invalid. Please correct it.`,
), ),
P2007: (operation) => new InternalServerErrorException( P2007: (operation) =>
`Data validation error during ${operation}. Please ensure all inputs are valid and try again.` new InternalServerErrorException(
`Data validation error during ${operation}. Please ensure all inputs are valid and try again.`,
), ),
P2008: (operation) => new InternalServerErrorException( P2008: (operation) =>
`Failed to query the database during ${operation}. Please try again later.` new InternalServerErrorException(
`Failed to query the database during ${operation}. Please try again later.`,
), ),
P2009: (operation) => new InternalServerErrorException( P2009: (operation) =>
`Invalid data fetched during ${operation}. Check query structure.` new InternalServerErrorException(
`Invalid data fetched during ${operation}. Check query structure.`,
), ),
P2010: () => new InternalServerErrorException( P2010: () =>
`Invalid raw query. Ensure your query is correct and try again.` new InternalServerErrorException(
`Invalid raw query. Ensure your query is correct and try again.`,
), ),
P2011: (_operation, meta) => new BadRequestException( P2011: (_operation, meta) =>
`The required field ${meta?.target || 'a field'} is missing. Please provide it to continue.` new BadRequestException(
`The required field ${meta?.target || 'a field'} is missing. Please provide it to continue.`,
), ),
P2012: (operation, meta) => new BadRequestException( P2012: (operation, meta) =>
`Missing required relation ${meta?.relationName || ''}. Ensure all related data exists before ${operation}.` new BadRequestException(
`Missing required relation ${meta?.relationName || ''}. Ensure all related data exists before ${operation}.`,
), ),
P2014: (operation) => { P2014: (operation) => {
switch (operation) { switch (operation) {
case 'create': case 'create':
return new BadRequestException( return new BadRequestException(
`Cannot create record because the referenced data does not exist. Ensure related data exists.` `Cannot create record because the referenced data does not exist. Ensure related data exists.`,
); );
case 'delete': case 'delete':
return new BadRequestException( return new BadRequestException(
`Unable to delete record because it is linked to other data. Update or delete dependent records first.` `Unable to delete record because it is linked to other data. Update or delete dependent records first.`,
); );
default: default:
return new BadRequestException(`Foreign key constraint error.`); return new BadRequestException(`Foreign key constraint error.`);
} }
}, },
P2015: () => new InternalServerErrorException( P2015: () =>
`A record with the required ID was expected but not found. Please retry.` new InternalServerErrorException(
`A record with the required ID was expected but not found. Please retry.`,
), ),
P2016: (operation) => new InternalServerErrorException( P2016: (operation) =>
`Query ${operation} failed because the record could not be fetched. Ensure the query is correct.` new InternalServerErrorException(
`Query ${operation} failed because the record could not be fetched. Ensure the query is correct.`,
), ),
P2017: (operation) => new InternalServerErrorException( P2017: (operation) =>
`Connected records were not found for ${operation}. Check related data.` new InternalServerErrorException(
`Connected records were not found for ${operation}. Check related data.`,
), ),
P2018: () => new InternalServerErrorException( P2018: () =>
`The required connection could not be established. Please check relationships.` new InternalServerErrorException(
`The required connection could not be established. Please check relationships.`,
), ),
P2019: (_operation, meta) => new InternalServerErrorException( P2019: (_operation, meta) =>
`Invalid input for ${meta?.details || 'a field'}. Please ensure data conforms to expectations.` new InternalServerErrorException(
`Invalid input for ${meta?.details || 'a field'}. Please ensure data conforms to expectations.`,
), ),
P2021: (_operation, meta) => new InternalServerErrorException( P2021: (_operation, meta) =>
`The ${meta?.model || 'model'} was not found in the database.` new InternalServerErrorException(
`The ${meta?.model || 'model'} was not found in the database.`,
), ),
P2025: (operation, meta) => new NotFoundException( P2025: (operation, meta) =>
`The ${meta?.model || 'record'} you are trying to ${operation} does not exist. It may have been deleted.` new NotFoundException(
`The ${meta?.model || 'record'} you are trying to ${operation} does not exist. It may have been deleted.`,
), ),
P2031: () => new InternalServerErrorException( P2031: () =>
`Invalid Prisma Client initialization error. Please check configuration.` new InternalServerErrorException(
`Invalid Prisma Client initialization error. Please check configuration.`,
), ),
P2033: (operation) => new InternalServerErrorException( P2033: (operation) =>
`Insufficient database write permissions for ${operation}.` new InternalServerErrorException(
`Insufficient database write permissions for ${operation}.`,
), ),
P2034: (operation) => new InternalServerErrorException( P2034: (operation) =>
`Database read-only transaction failed during ${operation}.` new InternalServerErrorException(
`Database read-only transaction failed during ${operation}.`,
), ),
P2037: (operation) => new InternalServerErrorException( P2037: (operation) =>
`Unsupported combinations of input types for ${operation}. Please correct the query or input.` new InternalServerErrorException(
`Unsupported combinations of input types for ${operation}. Please correct the query or input.`,
), ),
P1000: () => new InternalServerErrorException( P1000: () =>
`Database authentication failed. Verify your credentials and try again.` new InternalServerErrorException(
`Database authentication failed. Verify your credentials and try again.`,
), ),
P1001: () => new InternalServerErrorException( P1001: () =>
`The database server could not be reached. Please check its availability.` new InternalServerErrorException(
`The database server could not be reached. Please check its availability.`,
), ),
P1002: () => new InternalServerErrorException( P1002: () =>
`Connection to the database timed out. Verify network connectivity and server availability.` new InternalServerErrorException(
`Connection to the database timed out. Verify network connectivity and server availability.`,
), ),
P1015: (operation) => new InternalServerErrorException( P1015: (operation) =>
`Migration failed. Unable to complete ${operation}. Check migration history or database state.` new InternalServerErrorException(
`Migration failed. Unable to complete ${operation}. Check migration history or database state.`,
), ),
P1017: () => new InternalServerErrorException( P1017: () =>
`Database connection failed. Ensure the database is online and credentials are correct.` new InternalServerErrorException(
`Database connection failed. Ensure the database is online and credentials are correct.`,
), ),
P2023: function (operation: operationT, meta?: PrismaErrorMeta): Error { P2023: function (operation: operationT, meta?: PrismaErrorMeta): Error {
throw new Error('Function not implemented.'); throw new Error('Function not implemented.');
} },
}; };

View File

@ -1,25 +1,28 @@
import { UserProfile, RowModelRequest, RowRequestSchema } from "@nice/common"; import { UserProfile, RowModelRequest, RowRequestSchema } from '@nice/common';
import { RowModelService } from "./row-model.service"; import { RowModelService } from './row-model.service';
import { isFieldCondition, LogicalCondition, SQLBuilder } from "./sql-builder"; import { isFieldCondition, LogicalCondition, SQLBuilder } from './sql-builder';
import EventBus from "@server/utils/event-bus"; import EventBus from '@server/utils/event-bus';
import supejson from "superjson-cjs" import supejson from 'superjson-cjs';
import { deleteByPattern } from "@server/utils/redis/utils"; import { deleteByPattern } from '@server/utils/redis/utils';
import { redis } from "@server/utils/redis/redis.service"; import { redis } from '@server/utils/redis/redis.service';
import { z } from "zod"; import { z } from 'zod';
export class RowCacheService extends RowModelService { export class RowCacheService extends RowModelService {
constructor(tableName: string, private enableCache: boolean = true) { constructor(
super(tableName) tableName: string,
private enableCache: boolean = true,
) {
super(tableName);
if (this.enableCache) { if (this.enableCache) {
EventBus.on("dataChanged", async ({ type, data }) => { EventBus.on('dataChanged', async ({ type, data }) => {
if (type === tableName) { if (type === tableName) {
const dataArray = Array.isArray(data) ? data : [data]; const dataArray = Array.isArray(data) ? data : [data];
for (const item of dataArray) { for (const item of dataArray) {
try { try {
if (item.id) { if (item.id) {
this.invalidateRowCacheById(item.id) this.invalidateRowCacheById(item.id);
} }
if (item.parentId) { if (item.parentId) {
this.invalidateRowCacheById(item.parentId) this.invalidateRowCacheById(item.parentId);
} }
} catch (err) { } catch (err) {
console.error(`Error deleting cache for type ${tableName}:`, err); console.error(`Error deleting cache for type ${tableName}:`, err);
@ -38,21 +41,15 @@ export class RowCacheService extends RowModelService {
await deleteByPattern(pattern); await deleteByPattern(pattern);
} }
createJoinSql(request?: RowModelRequest): string[] { createJoinSql(request?: RowModelRequest): string[] {
return [] return [];
} }
protected async getRowRelation(args: { data: any, staff?: UserProfile }) { protected async getRowRelation(args: { data: any; staff?: UserProfile }) {
return args.data; return args.data;
} }
protected async setResPermissions( protected async setResPermissions(data: any, staff?: UserProfile) {
data: any, return data;
staff?: UserProfile,
) {
return data
} }
protected async getRowDto( protected async getRowDto(data: any, staff?: UserProfile): Promise<any> {
data: any,
staff?: UserProfile,
): Promise<any> {
// 如果没有id直接返回原数据 // 如果没有id直接返回原数据
if (!data?.id) return data; if (!data?.id) return data;
// 如果未启用缓存,直接处理并返回数据 // 如果未启用缓存,直接处理并返回数据
@ -77,25 +74,21 @@ export class RowCacheService extends RowModelService {
private async getCachedData( private async getCachedData(
key: string, key: string,
staff?: UserProfile staff?: UserProfile,
): Promise<any | null> { ): Promise<any | null> {
const cachedDataStr = await redis.get(key); const cachedDataStr = await redis.get(key);
if (!cachedDataStr) return null; if (!cachedDataStr) return null;
const cachedData = supejson.parse(cachedDataStr) as any; const cachedData = supejson.parse(cachedDataStr) as any;
if (!cachedData?.id) return null; if (!cachedData?.id) return null;
return staff return staff ? this.setResPermissions(cachedData, staff) : cachedData;
? this.setResPermissions(cachedData, staff)
: cachedData;
} }
private async processDataWithPermissions( private async processDataWithPermissions(
data: any, data: any,
staff?: UserProfile staff?: UserProfile,
): Promise<any> { ): Promise<any> {
// 处理权限 // 处理权限
const permData = staff const permData = staff ? await this.setResPermissions(data, staff) : data;
? await this.setResPermissions(data, staff)
: data;
// 获取关联数据 // 获取关联数据
return this.getRowRelation({ data: permData, staff }); return this.getRowRelation({ data: permData, staff });
} }

View File

@ -21,7 +21,7 @@ export abstract class RowModelService {
// 添加更多需要引号的关键词 // 添加更多需要引号的关键词
]); ]);
protected logger = new Logger(this.tableName); protected logger = new Logger(this.tableName);
protected constructor(protected tableName: string) { } protected constructor(protected tableName: string) {}
protected async getRowDto(row: any, staff?: UserProfile): Promise<any> { protected async getRowDto(row: any, staff?: UserProfile): Promise<any> {
return row; return row;
} }

View File

@ -1,21 +1,38 @@
export interface FieldCondition { export interface FieldCondition {
field: string; field: string;
op: OperatorType op: OperatorType;
type?: "text" | "number" | "date"; type?: 'text' | 'number' | 'date';
value?: any; value?: any;
valueTo?: any; valueTo?: any;
}; }
export type OperatorType = 'equals' | 'notEqual' | 'contains' | 'startsWith' | 'endsWith' | 'blank' | 'notBlank' | 'greaterThan' | 'lessThanOrEqual' | 'inRange' | 'lessThan' | 'greaterThan' | 'in'; export type OperatorType =
export type LogicalCondition = FieldCondition | { | 'equals'
| 'notEqual'
| 'contains'
| 'startsWith'
| 'endsWith'
| 'blank'
| 'notBlank'
| 'greaterThan'
| 'lessThanOrEqual'
| 'inRange'
| 'lessThan'
| 'greaterThan'
| 'in';
export type LogicalCondition =
| FieldCondition
| {
AND?: LogicalCondition[]; AND?: LogicalCondition[];
OR?: LogicalCondition[]; OR?: LogicalCondition[];
}; };
export function isFieldCondition(condition: LogicalCondition): condition is FieldCondition { export function isFieldCondition(
condition: LogicalCondition,
): condition is FieldCondition {
return (condition as FieldCondition).field !== undefined; return (condition as FieldCondition).field !== undefined;
} }
function buildCondition(condition: FieldCondition): string { function buildCondition(condition: FieldCondition): string {
const { field, op, value, type = "text", valueTo } = condition; const { field, op, value, type = 'text', valueTo } = condition;
switch (op) { switch (op) {
case 'equals': case 'equals':
return `${field} = '${value}'`; return `${field} = '${value}'`;
@ -28,15 +45,11 @@ function buildCondition(condition: FieldCondition): string {
case 'endsWith': case 'endsWith':
return `${field} LIKE '%${value}'`; return `${field} LIKE '%${value}'`;
case 'blank': case 'blank':
if (type !== "date") if (type !== 'date') return `(${field} IS NULL OR ${field} = '')`;
return `(${field} IS NULL OR ${field} = '')`; else return `${field} IS NULL`;
else
return `${field} IS NULL`;
case 'notBlank': case 'notBlank':
if (type !== 'date') if (type !== 'date') return `${field} IS NOT NULL AND ${field} != ''`;
return `${field} IS NOT NULL AND ${field} != ''`; else return `${field} IS NOT NULL`;
else
return `${field} IS NOT NULL`;
case 'greaterThan': case 'greaterThan':
return `${field} > '${value}'`; return `${field} > '${value}'`;
case 'lessThanOrEqual': case 'lessThanOrEqual':
@ -52,7 +65,7 @@ function buildCondition(condition: FieldCondition): string {
// Return a condition that is always false if value is empty or an empty array // Return a condition that is always false if value is empty or an empty array
return '1 = 0'; return '1 = 0';
} }
return `${field} IN (${(value as any[]).map(val => `'${val}'`).join(', ')})`; return `${field} IN (${(value as any[]).map((val) => `'${val}'`).join(', ')})`;
default: default:
return 'true'; // Default return for unmatched conditions return 'true'; // Default return for unmatched conditions
} }
@ -63,18 +76,18 @@ function buildLogicalCondition(logicalCondition: LogicalCondition): string {
} }
const parts: string[] = []; const parts: string[] = [];
if (logicalCondition.AND && logicalCondition.AND.length > 0) { if (logicalCondition.AND && logicalCondition.AND.length > 0) {
const andParts = logicalCondition.AND const andParts = logicalCondition.AND.map((c) =>
.map(c => buildLogicalCondition(c)) buildLogicalCondition(c),
.filter(part => part !== ''); // Filter out empty conditions ).filter((part) => part !== ''); // Filter out empty conditions
if (andParts.length > 0) { if (andParts.length > 0) {
parts.push(`(${andParts.join(' AND ')})`); parts.push(`(${andParts.join(' AND ')})`);
} }
} }
// Process OR conditions // Process OR conditions
if (logicalCondition.OR && logicalCondition.OR.length > 0) { if (logicalCondition.OR && logicalCondition.OR.length > 0) {
const orParts = logicalCondition.OR const orParts = logicalCondition.OR.map((c) =>
.map(c => buildLogicalCondition(c)) buildLogicalCondition(c),
.filter(part => part !== ''); // Filter out empty conditions ).filter((part) => part !== ''); // Filter out empty conditions
if (orParts.length > 0) { if (orParts.length > 0) {
parts.push(`(${orParts.join(' OR ')})`); parts.push(`(${orParts.join(' OR ')})`);
} }
@ -85,12 +98,18 @@ function buildLogicalCondition(logicalCondition: LogicalCondition): string {
export class SQLBuilder { export class SQLBuilder {
static select(fields: string[], distinctField?: string): string { static select(fields: string[], distinctField?: string): string {
const distinctClause = distinctField ? `DISTINCT ON (${distinctField}) ` : ""; const distinctClause = distinctField
return `SELECT ${distinctClause}${fields.join(", ")}`; ? `DISTINCT ON (${distinctField}) `
: '';
return `SELECT ${distinctClause}${fields.join(', ')}`;
} }
static rowNumber(orderBy: string, partitionBy: string | null = null, alias: string = 'row_num'): string { static rowNumber(
orderBy: string,
partitionBy: string | null = null,
alias: string = 'row_num',
): string {
if (!orderBy) { if (!orderBy) {
throw new Error("orderBy 参数不能为空"); throw new Error('orderBy 参数不能为空');
} }
let partitionClause = ''; let partitionClause = '';
@ -106,15 +125,15 @@ export class SQLBuilder {
static where(conditions: LogicalCondition): string { static where(conditions: LogicalCondition): string {
const whereClause = buildLogicalCondition(conditions); const whereClause = buildLogicalCondition(conditions);
return whereClause ? `WHERE ${whereClause}` : ""; return whereClause ? `WHERE ${whereClause}` : '';
} }
static groupBy(columns: string[]): string { static groupBy(columns: string[]): string {
return columns.length ? `GROUP BY ${columns.join(", ")}` : ""; return columns.length ? `GROUP BY ${columns.join(', ')}` : '';
} }
static orderBy(columns: string[]): string { static orderBy(columns: string[]): string {
return columns.length ? `ORDER BY ${columns.join(", ")}` : ""; return columns.length ? `ORDER BY ${columns.join(', ')}` : '';
} }
static limit(pageSize: number, offset: number = 0): string { static limit(pageSize: number, offset: number = 0): string {
@ -125,14 +144,27 @@ export class SQLBuilder {
return clauses.filter(Boolean).join(' '); return clauses.filter(Boolean).join(' ');
} }
static createFilterSql(key: string, item: any): LogicalCondition { static createFilterSql(key: string, item: any): LogicalCondition {
const conditionFuncs: Record<string, (item: { values?: any[], dateFrom?: string, dateTo?: string, filter: any, type: OperatorType, filterType: OperatorType }) => LogicalCondition> = { const conditionFuncs: Record<
string,
(item: {
values?: any[];
dateFrom?: string;
dateTo?: string;
filter: any;
type: OperatorType;
filterType: OperatorType;
}) => LogicalCondition
> = {
text: (item) => ({ value: item.filter, op: item.type, field: key }), text: (item) => ({ value: item.filter, op: item.type, field: key }),
number: (item) => ({ value: item.filter, op: item.type, field: key }), number: (item) => ({ value: item.filter, op: item.type, field: key }),
date: (item) => ({ value: item.dateFrom, valueTo: item.dateTo, op: item.type, field: key }), date: (item) => ({
set: (item) => ({ value: item.values, op: "in", field: key }) value: item.dateFrom,
} valueTo: item.dateTo,
return conditionFuncs[item.filterType](item) op: item.type,
field: key,
}),
set: (item) => ({ value: item.values, op: 'in', field: key }),
};
return conditionFuncs[item.filterType](item);
} }
} }

View File

@ -8,7 +8,13 @@ import { DepartmentModule } from '../department/department.module';
@Module({ @Module({
imports: [DepartmentModule], imports: [DepartmentModule],
providers: [RoleMapService, RoleRouter, TrpcService, RoleService, RoleMapRouter], providers: [
exports: [RoleRouter, RoleService, RoleMapService, RoleMapRouter] RoleMapService,
RoleRouter,
TrpcService,
RoleService,
RoleMapRouter,
],
exports: [RoleRouter, RoleService, RoleMapService, RoleMapRouter],
}) })
export class RoleMapModule { } export class RoleMapModule {}

View File

@ -3,21 +3,21 @@ import { TrpcService } from '@server/trpc/trpc.service';
import { Prisma, UpdateOrderSchema } from '@nice/common'; import { Prisma, UpdateOrderSchema } from '@nice/common';
import { RoleService } from './role.service'; import { RoleService } from './role.service';
import { z, ZodType } from 'zod'; import { z, ZodType } from 'zod';
const RoleCreateArgsSchema: ZodType<Prisma.RoleCreateArgs> = z.any() const RoleCreateArgsSchema: ZodType<Prisma.RoleCreateArgs> = z.any();
const RoleUpdateArgsSchema: ZodType<Prisma.RoleUpdateArgs> = z.any() const RoleUpdateArgsSchema: ZodType<Prisma.RoleUpdateArgs> = z.any();
const RoleCreateManyInputSchema: ZodType<Prisma.RoleCreateManyInput> = z.any() const RoleCreateManyInputSchema: ZodType<Prisma.RoleCreateManyInput> = z.any();
const RoleDeleteManyArgsSchema: ZodType<Prisma.RoleDeleteManyArgs> = z.any() const RoleDeleteManyArgsSchema: ZodType<Prisma.RoleDeleteManyArgs> = z.any();
const RoleFindManyArgsSchema: ZodType<Prisma.RoleFindManyArgs> = z.any() const RoleFindManyArgsSchema: ZodType<Prisma.RoleFindManyArgs> = z.any();
const RoleFindFirstArgsSchema: ZodType<Prisma.RoleFindFirstArgs> = z.any() const RoleFindFirstArgsSchema: ZodType<Prisma.RoleFindFirstArgs> = z.any();
const RoleWhereInputSchema: ZodType<Prisma.RoleWhereInput> = z.any() const RoleWhereInputSchema: ZodType<Prisma.RoleWhereInput> = z.any();
const RoleSelectSchema: ZodType<Prisma.RoleSelect> = z.any() const RoleSelectSchema: ZodType<Prisma.RoleSelect> = z.any();
const RoleUpdateInputSchema: ZodType<Prisma.RoleUpdateInput> = z.any(); const RoleUpdateInputSchema: ZodType<Prisma.RoleUpdateInput> = z.any();
@Injectable() @Injectable()
export class RoleRouter { export class RoleRouter {
constructor( constructor(
private readonly trpc: TrpcService, private readonly trpc: TrpcService,
private readonly roleService: RoleService, private readonly roleService: RoleService,
) { } ) {}
router = this.trpc.router({ router = this.trpc.router({
create: this.trpc.protectProcedure create: this.trpc.protectProcedure
.input(RoleCreateArgsSchema) .input(RoleCreateArgsSchema)
@ -31,7 +31,8 @@ export class RoleRouter {
const { staff } = ctx; const { staff } = ctx;
return await this.roleService.update(input, staff); return await this.roleService.update(input, staff);
}), }),
createMany: this.trpc.protectProcedure.input(z.array(RoleCreateManyInputSchema)) createMany: this.trpc.protectProcedure
.input(z.array(RoleCreateManyInputSchema))
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
const { staff } = ctx; const { staff } = ctx;
@ -41,7 +42,7 @@ export class RoleRouter {
.input( .input(
z.object({ z.object({
ids: z.array(z.string()), ids: z.array(z.string()),
data: RoleUpdateInputSchema.optional() data: RoleUpdateInputSchema.optional(),
}), }),
) )
.mutation(async ({ input }) => { .mutation(async ({ input }) => {
@ -64,23 +65,27 @@ export class RoleRouter {
return await this.roleService.findMany(input); return await this.roleService.findMany(input);
}), }),
findManyWithCursor: this.trpc.protectProcedure findManyWithCursor: this.trpc.protectProcedure
.input(z.object({ .input(
z.object({
cursor: z.any().nullish(), cursor: z.any().nullish(),
take: z.number().optional(), take: z.number().optional(),
where: RoleWhereInputSchema.optional(), where: RoleWhereInputSchema.optional(),
select: RoleSelectSchema.optional() select: RoleSelectSchema.optional(),
})) }),
)
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const { staff } = ctx; const { staff } = ctx;
return await this.roleService.findManyWithCursor(input); return await this.roleService.findManyWithCursor(input);
}), }),
findManyWithPagination: this.trpc.procedure findManyWithPagination: this.trpc.procedure
.input(z.object({ .input(
z.object({
page: z.number(), page: z.number(),
pageSize: z.number().optional(), pageSize: z.number().optional(),
where: RoleWhereInputSchema.optional(), where: RoleWhereInputSchema.optional(),
select: RoleSelectSchema.optional() select: RoleSelectSchema.optional(),
})) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword }),
) // Assuming StaffMethodSchema.findMany is the Zod schema for finding staffs by keyword
.query(async ({ input }) => { .query(async ({ input }) => {
return await this.roleService.findManyWithPagination(input); return await this.roleService.findManyWithPagination(input);
}), }),

View File

@ -1,45 +1,57 @@
import { db, ObjectType, RowModelRequest, RowRequestSchema, UserProfile } from "@nice/common"; import {
import { RowCacheService } from "../base/row-cache.service"; db,
import { isFieldCondition, LogicalCondition } from "../base/sql-builder"; ObjectType,
import { z } from "zod"; RowModelRequest,
RowRequestSchema,
UserProfile,
} from '@nice/common';
import { RowCacheService } from '../base/row-cache.service';
import { isFieldCondition, LogicalCondition } from '../base/sql-builder';
import { z } from 'zod';
export class RoleRowService extends RowCacheService { export class RoleRowService extends RowCacheService {
protected createGetRowsFilters( protected createGetRowsFilters(
request: z.infer<typeof RowRequestSchema>, request: z.infer<typeof RowRequestSchema>,
staff?: UserProfile staff?: UserProfile,
) { ) {
const condition = super.createGetRowsFilters(request) const condition = super.createGetRowsFilters(request);
if (isFieldCondition(condition)) if (isFieldCondition(condition)) return {};
return {} const baseModelCondition: LogicalCondition[] = [
const baseModelCondition: LogicalCondition[] = [{ {
field: `${this.tableName}.deleted_at`, field: `${this.tableName}.deleted_at`,
op: "blank", op: 'blank',
type: "date" type: 'date',
}] },
condition.AND = [...baseModelCondition, ...condition.AND!] ];
return condition condition.AND = [...baseModelCondition, ...condition.AND!];
return condition;
} }
createUnGroupingRowSelect(): string[] { createUnGroupingRowSelect(): string[] {
return [ return [
`${this.tableName}.id AS id`, `${this.tableName}.id AS id`,
`${this.tableName}.name AS name`, `${this.tableName}.name AS name`,
`${this.tableName}.system AS system`, `${this.tableName}.system AS system`,
`${this.tableName}.permissions AS permissions` `${this.tableName}.permissions AS permissions`,
]; ];
} }
protected async getRowDto(data: any, staff?: UserProfile): Promise<any> { protected async getRowDto(data: any, staff?: UserProfile): Promise<any> {
if (!data.id) if (!data.id) return data;
return data
const roleMaps = await db.roleMap.findMany({ const roleMaps = await db.roleMap.findMany({
where: { where: {
roleId: data.id roleId: data.id,
} },
}) });
const deptIds = roleMaps.filter(item => item.objectType === ObjectType.DEPARTMENT).map(roleMap => roleMap.objectId) const deptIds = roleMaps
const staffIds = roleMaps.filter(item => item.objectType === ObjectType.STAFF).map(roleMap => roleMap.objectId) .filter((item) => item.objectType === ObjectType.DEPARTMENT)
const depts = await db.department.findMany({ where: { id: { in: deptIds } } }) .map((roleMap) => roleMap.objectId);
const staffs = await db.staff.findMany({ where: { id: { in: staffIds } } }) const staffIds = roleMaps
const result = { ...data, depts, staffs } .filter((item) => item.objectType === ObjectType.STAFF)
return result .map((roleMap) => roleMap.objectId);
const depts = await db.department.findMany({
where: { id: { in: deptIds } },
});
const staffs = await db.staff.findMany({ where: { id: { in: staffIds } } });
const result = { ...data, depts, staffs };
return result;
} }
createJoinSql(request?: RowModelRequest): string[] { createJoinSql(request?: RowModelRequest): string[] {
return []; return [];

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)

View File

@ -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(
result.map(async (item) => {
const staff = await db.staff.findMany({ const staff = await db.staff.findMany({
include: { department: true }, include: { department: true },
where: { where: {
id: item.objectId id: item.objectId,
} },
}) });
return { ...item, staff } return { ...item, staff };
})) }),
);
return wrapResult; return wrapResult;
} }
async addRoleForObjects( async addRoleForObjects(
@ -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: {
@ -300,7 +301,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

@ -1,23 +1,24 @@
import path, { dirname } from "path"; import path, { dirname } from 'path';
import { FileMetadata, VideoMetadata, ResourceProcessor } from "../types"; import { FileMetadata, VideoMetadata, ResourceProcessor } from '../types';
import { Resource, ResourceStatus, db } from "@nice/common"; import { Resource, ResourceStatus, db } from '@nice/common';
import { Logger } from "@nestjs/common"; import { Logger } from '@nestjs/common';
import fs from 'fs/promises'; import fs from 'fs/promises';
export abstract class BaseProcessor implements ResourceProcessor { export abstract class BaseProcessor implements ResourceProcessor {
constructor() { } constructor() {}
protected logger = new Logger(BaseProcessor.name) protected logger = new Logger(BaseProcessor.name);
abstract process(resource: Resource): Promise<Resource> abstract process(resource: Resource): Promise<Resource>;
protected createOutputDir(filepath: string, subdirectory: string = 'assets'): string { protected createOutputDir(
const outputDir = path.join( filepath: string,
path.dirname(filepath), subdirectory: string = 'assets',
subdirectory, ): string {
const outputDir = path.join(path.dirname(filepath), subdirectory);
fs.mkdir(outputDir, { recursive: true }).catch((err) =>
this.logger.error(`Failed to create directory: ${err.message}`),
); );
fs.mkdir(outputDir, { recursive: true }).catch(err => this.logger.error(`Failed to create directory: ${err.message}`));
return outputDir; return outputDir;
} }
} }
// //

View File

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

View File

@ -1,4 +1,4 @@
import { Injectable ,Logger} from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { BaseService } from '../base/base.service'; import { BaseService } from '../base/base.service';
import { import {

View File

@ -1,20 +1,20 @@
import { Resource } from "@nice/common"; import { Resource } from '@nice/common';
export interface ResourceProcessor { export interface ResourceProcessor {
process(resource: Resource): Promise<any> process(resource: Resource): Promise<any>;
} }
export interface ProcessResult { export interface ProcessResult {
success: boolean success: boolean;
resource: Resource resource: Resource;
error?: Error error?: Error;
} }
export interface BaseMetadata { export interface BaseMetadata {
size: number size: number;
filetype: string filetype: string;
filename: string filename: string;
extension: string extension: string;
modifiedAt: Date modifiedAt: Date;
} }
/** /**
* *
@ -37,7 +37,7 @@ export interface VideoMetadata {
duration?: number; duration?: number;
videoCodec?: string; videoCodec?: string;
audioCodec?: string; audioCodec?: string;
coverUrl?: string coverUrl?: string;
} }
/** /**
@ -51,5 +51,7 @@ export interface AudioMetadata {
codec?: string; // 音频编码格式 codec?: string; // 音频编码格式
} }
export type FileMetadata = ImageMetadata &
export type FileMetadata = ImageMetadata & VideoMetadata & AudioMetadata & BaseMetadata VideoMetadata &
AudioMetadata &
BaseMetadata;

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

@ -8,7 +8,7 @@ export class TaxonomyRouter {
constructor( constructor(
private readonly trpc: TrpcService, private readonly trpc: TrpcService,
private readonly taxonomyService: TaxonomyService, private readonly taxonomyService: TaxonomyService,
) { } ) {}
router = this.trpc.router({ router = this.trpc.router({
create: this.trpc.procedure create: this.trpc.procedure

View File

@ -13,4 +13,4 @@ import { TermRowService } from './term.row.service';
exports: [TermService, TermRouter], exports: [TermService, TermRouter],
controllers: [TermController], controllers: [TermController],
}) })
export class TermModule { } export class TermModule {}

View File

@ -8,12 +8,7 @@ import { DepartmentModule } from '../department/department.module';
import { StaffModule } from '../staff/staff.module'; import { StaffModule } from '../staff/staff.module';
// import { TransformController } from './transform.controller'; // import { TransformController } from './transform.controller';
@Module({ @Module({
imports: [ imports: [DepartmentModule, StaffModule, TermModule, TaxonomyModule],
DepartmentModule,
StaffModule,
TermModule,
TaxonomyModule,
],
providers: [TransformService, TransformRouter, TrpcService], providers: [TransformService, TransformRouter, TrpcService],
exports: [TransformRouter, TransformService], exports: [TransformRouter, TransformService],
// controllers:[TransformController] // controllers:[TransformController]

View File

@ -1,7 +1,6 @@
import { WebSocketServer, WebSocket } from 'ws';
import { WebSocketServer, WebSocket } from "ws"; import { Logger } from '@nestjs/common';
import { Logger } from "@nestjs/common"; import { WebSocketServerConfig, WSClient, WebSocketType } from '../types';
import { WebSocketServerConfig, WSClient, WebSocketType } from "../types";
import { SocketMessage } from '@nice/common'; import { SocketMessage } from '@nice/common';
const DEFAULT_CONFIG: WebSocketServerConfig = { const DEFAULT_CONFIG: WebSocketServerConfig = {
@ -25,9 +24,7 @@ export abstract class BaseWebSocketServer implements IWebSocketServer {
protected readonly logger = new Logger(this.constructor.name); protected readonly logger = new Logger(this.constructor.name);
protected readonly finalConfig: WebSocketServerConfig; protected readonly finalConfig: WebSocketServerConfig;
private userClientMap: Map<string, WSClient> = new Map(); private userClientMap: Map<string, WSClient> = new Map();
constructor( constructor(protected readonly config: Partial<WebSocketServerConfig> = {}) {
protected readonly config: Partial<WebSocketServerConfig> = {}
) {
this.finalConfig = { this.finalConfig = {
...DEFAULT_CONFIG, ...DEFAULT_CONFIG,
...config, ...config,
@ -39,7 +36,7 @@ export abstract class BaseWebSocketServer implements IWebSocketServer {
} }
} }
public getClientCount() { public getClientCount() {
return this.clients.size return this.clients.size;
} }
// 暴露 WebSocketServer 实例的只读访问 // 暴露 WebSocketServer 实例的只读访问
public get wss(): WebSocketServer | null { public get wss(): WebSocketServer | null {
@ -62,7 +59,7 @@ export abstract class BaseWebSocketServer implements IWebSocketServer {
this._wss = new WebSocketServer({ this._wss = new WebSocketServer({
noServer: true, noServer: true,
path: this.serverPath path: this.serverPath,
}); });
this.debugLog(`WebSocket server starting on path: ${this.serverPath}`); this.debugLog(`WebSocket server starting on path: ${this.serverPath}`);
@ -76,12 +73,12 @@ export abstract class BaseWebSocketServer implements IWebSocketServer {
this.pingIntervalId = undefined; this.pingIntervalId = undefined;
} }
this.clients.forEach(client => client.close()); this.clients.forEach((client) => client.close());
this.clients.clear(); this.clients.clear();
this.timeouts.clear(); this.timeouts.clear();
if (this._wss) { if (this._wss) {
await new Promise(resolve => this._wss!.close(resolve)); await new Promise((resolve) => this._wss!.close(resolve));
this._wss = null; this._wss = null;
} }
@ -89,33 +86,36 @@ export abstract class BaseWebSocketServer implements IWebSocketServer {
} }
public broadcast(data: SocketMessage): void { public broadcast(data: SocketMessage): void {
this.clients.forEach(client => this.clients.forEach(
client.readyState === WebSocket.OPEN && client.send(JSON.stringify(data)) (client) =>
client.readyState === WebSocket.OPEN &&
client.send(JSON.stringify(data)),
); );
} }
public sendToUser(id: string, data: SocketMessage) { public sendToUser(id: string, data: SocketMessage) {
const message = JSON.stringify(data); const message = JSON.stringify(data);
const client = this.userClientMap.get(id); const client = this.userClientMap.get(id);
client?.send(message) client?.send(message);
} }
public sendToUsers(ids: string[], data: SocketMessage) { public sendToUsers(ids: string[], data: SocketMessage) {
const message = JSON.stringify(data); const message = JSON.stringify(data);
ids.forEach(id => { ids.forEach((id) => {
const client = this.userClientMap.get(id); const client = this.userClientMap.get(id);
client?.send(message); client?.send(message);
}); });
} }
public sendToRoom(roomId: string, data: SocketMessage) { public sendToRoom(roomId: string, data: SocketMessage) {
const message = JSON.stringify(data); const message = JSON.stringify(data);
this.clients.forEach(client => { this.clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN && client.roomId === roomId) { if (client.readyState === WebSocket.OPEN && client.roomId === roomId) {
client.send(message) client.send(message);
} }
}) });
} }
protected getRoomClientsCount(roomId?: string): number { protected getRoomClientsCount(roomId?: string): number {
if (!roomId) return 0; if (!roomId) return 0;
return Array.from(this.clients).filter(client => client.roomId === roomId).length; return Array.from(this.clients).filter((client) => client.roomId === roomId)
.length;
} }
public handleConnection(ws: WSClient): void { public handleConnection(ws: WSClient): void {
@ -161,7 +161,10 @@ export abstract class BaseWebSocketServer implements IWebSocketServer {
ws.on('pong', () => this.handlePong(ws)) ws.on('pong', () => this.handlePong(ws))
.on('close', () => this.handleDisconnection(ws)) .on('close', () => this.handleDisconnection(ws))
.on('error', (error) => { .on('error', (error) => {
this.logger.error(`[${this.serverType}] client error on path ${this.serverPath}:`, error); this.logger.error(
`[${this.serverType}] client error on path ${this.serverPath}:`,
error,
);
this.handleDisconnection(ws); this.handleDisconnection(ws);
}); });
} }
@ -178,19 +181,19 @@ export abstract class BaseWebSocketServer implements IWebSocketServer {
private startPingInterval(): void { private startPingInterval(): void {
this.pingIntervalId = setInterval( this.pingIntervalId = setInterval(
() => this.pingClients(), () => this.pingClients(),
this.finalConfig.pingInterval this.finalConfig.pingInterval,
); );
} }
private pingClients(): void { private pingClients(): void {
this.clients.forEach(ws => { this.clients.forEach((ws) => {
if (!ws.isAlive) return this.handleDisconnection(ws); if (!ws.isAlive) return this.handleDisconnection(ws);
ws.isAlive = false; ws.isAlive = false;
ws.ping(); ws.ping();
const timeout = setTimeout( const timeout = setTimeout(
() => !ws.isAlive && this.handleDisconnection(ws), () => !ws.isAlive && this.handleDisconnection(ws),
this.finalConfig.pingTimeout this.finalConfig.pingTimeout,
); );
this.timeouts.set(ws, timeout); this.timeouts.set(ws, timeout);
}); });
@ -200,6 +203,8 @@ export abstract class BaseWebSocketServer implements IWebSocketServer {
if (!this._wss) return; if (!this._wss) return;
this._wss this._wss
.on('connection', (ws: WSClient) => this.handleConnection(ws)) .on('connection', (ws: WSClient) => this.handleConnection(ws))
.on('error', (error) => this.logger.error(`Server error on path ${this.serverPath}:`, error)); .on('error', (error) =>
this.logger.error(`Server error on path ${this.serverPath}:`, error),
);
} }
} }

View File

@ -8,12 +8,13 @@ import http from 'http';
import { parseInt as libParseInt } from 'lib0/number'; import { parseInt as libParseInt } from 'lib0/number';
import { WSSharedDoc } from './ws-shared-doc'; import { WSSharedDoc } from './ws-shared-doc';
/** /**
* URL配置, * URL配置,
* null * null
*/ */
const CALLBACK_URL = process.env.CALLBACK_URL ? new URL(process.env.CALLBACK_URL) : null; const CALLBACK_URL = process.env.CALLBACK_URL
? new URL(process.env.CALLBACK_URL)
: null;
/** /**
* , * ,
@ -25,7 +26,9 @@ const CALLBACK_TIMEOUT = libParseInt(process.env.CALLBACK_TIMEOUT || '5000');
* *
* CALLBACK_OBJECTS中解析JSON格式的配置 * CALLBACK_OBJECTS中解析JSON格式的配置
*/ */
const CALLBACK_OBJECTS: Record<string, string> = process.env.CALLBACK_OBJECTS ? JSON.parse(process.env.CALLBACK_OBJECTS) : {}; const CALLBACK_OBJECTS: Record<string, string> = process.env.CALLBACK_OBJECTS
? JSON.parse(process.env.CALLBACK_OBJECTS)
: {};
/** /**
* URL是否已配置的标志 * URL是否已配置的标志
@ -37,10 +40,13 @@ export const isCallbackSet = !!CALLBACK_URL;
*/ */
interface DataToSend { interface DataToSend {
room: string; // 房间/文档标识 room: string; // 房间/文档标识
data: Record<string, { data: Record<
string,
{
type: string; // 数据类型 type: string; // 数据类型
content: any; // 数据内容 content: any; // 数据内容
}>; }
>;
} }
/** /**
@ -59,25 +65,29 @@ type OriginType = any;
* @param origin - * @param origin -
* @param doc - * @param doc -
*/ */
export const callbackHandler = (update: UpdateType, origin: OriginType, doc: WSSharedDoc): void => { export const callbackHandler = (
update: UpdateType,
origin: OriginType,
doc: WSSharedDoc,
): void => {
// 获取文档名称作为房间标识 // 获取文档名称作为房间标识
const room = doc.name; const room = doc.name;
// 初始化要发送的数据对象 // 初始化要发送的数据对象
const dataToSend: DataToSend = { const dataToSend: DataToSend = {
room, room,
data: {} data: {},
}; };
// 获取所有需要监听的共享对象名称 // 获取所有需要监听的共享对象名称
const sharedObjectList = Object.keys(CALLBACK_OBJECTS); const sharedObjectList = Object.keys(CALLBACK_OBJECTS);
// 遍历所有共享对象,获取它们的最新内容 // 遍历所有共享对象,获取它们的最新内容
sharedObjectList.forEach(sharedObjectName => { sharedObjectList.forEach((sharedObjectName) => {
const sharedObjectType = CALLBACK_OBJECTS[sharedObjectName]; const sharedObjectType = CALLBACK_OBJECTS[sharedObjectName];
dataToSend.data[sharedObjectName] = { dataToSend.data[sharedObjectName] = {
type: sharedObjectType, type: sharedObjectType,
content: getContent(sharedObjectName, sharedObjectType, doc).toJSON() content: getContent(sharedObjectName, sharedObjectType, doc).toJSON(),
}; };
}); });
@ -106,8 +116,8 @@ const callbackRequest = (url: URL, timeout: number, data: DataToSend): void => {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(dataString) 'Content-Length': Buffer.byteLength(dataString),
} },
}; };
// 创建HTTP请求 // 创建HTTP请求
@ -137,14 +147,24 @@ const callbackRequest = (url: URL, timeout: number, data: DataToSend): void => {
* @param doc - * @param doc -
* @returns * @returns
*/ */
const getContent = (objName: string, objType: string, doc: WSSharedDoc): any => { const getContent = (
objName: string,
objType: string,
doc: WSSharedDoc,
): any => {
// 根据对象类型返回相应的共享对象 // 根据对象类型返回相应的共享对象
switch (objType) { switch (objType) {
case 'Array': return doc.getArray(objName); case 'Array':
case 'Map': return doc.getMap(objName); return doc.getArray(objName);
case 'Text': return doc.getText(objName); case 'Map':
case 'XmlFragment': return doc.getXmlFragment(objName); return doc.getMap(objName);
case 'XmlElement': return doc.getXmlElement(objName); case 'Text':
default: return {}; return doc.getText(objName);
case 'XmlFragment':
return doc.getXmlFragment(objName);
case 'XmlElement':
return doc.getXmlElement(objName);
default:
return {};
} }
}; };

View File

@ -3,6 +3,6 @@ import { YjsServer } from './yjs.server';
@Module({ @Module({
providers: [YjsServer], providers: [YjsServer],
exports: [YjsServer] exports: [YjsServer],
}) })
export class CollaborationModule { } export class CollaborationModule {}

View File

@ -23,7 +23,7 @@ if (typeof persistenceDir === 'string') {
ldb.storeUpdate(docName, update); ldb.storeUpdate(docName, update);
}); });
}, },
writeState: async (_docName, _ydoc) => { }, writeState: async (_docName, _ydoc) => {},
}; };
} }

View File

@ -1,5 +1,4 @@
export interface ConnectionOptions { export interface ConnectionOptions {
docName: string; docName: string;
gc: boolean; gc: boolean;
} }

View File

@ -1,17 +1,28 @@
import { readSyncMessage } from '@nice/common'; import { readSyncMessage } from '@nice/common';
import { applyAwarenessUpdate, Awareness, encodeAwarenessUpdate, removeAwarenessStates, writeSyncStep1, writeUpdate } from '@nice/common'; import {
applyAwarenessUpdate,
Awareness,
encodeAwarenessUpdate,
removeAwarenessStates,
writeSyncStep1,
writeUpdate,
} from '@nice/common';
import * as encoding from 'lib0/encoding'; import * as encoding from 'lib0/encoding';
import * as decoding from 'lib0/decoding'; import * as decoding from 'lib0/decoding';
import * as Y from "yjs" import * as Y from 'yjs';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { getPersistence, setPersistence } from './persistence'; import { getPersistence, setPersistence } from './persistence';
import { callbackHandler, isCallbackSet } from './callback'; import { callbackHandler, isCallbackSet } from './callback';
import { WebSocket } from "ws"; import { WebSocket } from 'ws';
import { YMessageType } from '@nice/common'; import { YMessageType } from '@nice/common';
import { WSClient } from '../types'; import { WSClient } from '../types';
export const docs = new Map<string, WSSharedDoc>(); export const docs = new Map<string, WSSharedDoc>();
export const CALLBACK_DEBOUNCE_WAIT = parseInt(process.env.CALLBACK_DEBOUNCE_WAIT || '2000'); export const CALLBACK_DEBOUNCE_WAIT = parseInt(
export const CALLBACK_DEBOUNCE_MAXWAIT = parseInt(process.env.CALLBACK_DEBOUNCE_MAXWAIT || '10000'); process.env.CALLBACK_DEBOUNCE_WAIT || '2000',
);
export const CALLBACK_DEBOUNCE_MAXWAIT = parseInt(
process.env.CALLBACK_DEBOUNCE_MAXWAIT || '10000',
);
export const getYDoc = (docname: string, gc = true): WSSharedDoc => { export const getYDoc = (docname: string, gc = true): WSSharedDoc => {
return docs.get(docname) || createYDoc(docname, gc); return docs.get(docname) || createYDoc(docname, gc);
}; };
@ -27,7 +38,9 @@ export const send = (doc: WSSharedDoc, conn: WebSocket, m: Uint8Array) => {
return; return;
} }
try { try {
conn.send(m, {}, err => { err != null && closeConn(doc, conn) }); conn.send(m, {}, (err) => {
err != null && closeConn(doc, conn);
});
} catch (e) { } catch (e) {
closeConn(doc, conn); closeConn(doc, conn);
} }
@ -36,14 +49,12 @@ export const closeConn = (doc: WSSharedDoc, conn: WebSocket) => {
if (doc.conns.has(conn)) { if (doc.conns.has(conn)) {
const controlledIds = doc.conns.get(conn) as Set<number>; const controlledIds = doc.conns.get(conn) as Set<number>;
doc.conns.delete(conn); doc.conns.delete(conn);
removeAwarenessStates( removeAwarenessStates(doc.awareness, Array.from(controlledIds), null);
doc.awareness,
Array.from(controlledIds),
null
);
if (doc.conns.size === 0 && getPersistence() !== null) { if (doc.conns.size === 0 && getPersistence() !== null) {
getPersistence()?.writeState(doc.name, doc).then(() => { getPersistence()
?.writeState(doc.name, doc)
.then(() => {
doc.destroy(); doc.destroy();
}); });
docs.delete(doc.name); docs.delete(doc.name);
@ -52,7 +63,11 @@ export const closeConn = (doc: WSSharedDoc, conn: WebSocket) => {
conn.close(); conn.close();
}; };
export const messageListener = (conn: WSClient, doc: WSSharedDoc, message: Uint8Array) => { export const messageListener = (
conn: WSClient,
doc: WSSharedDoc,
message: Uint8Array,
) => {
try { try {
const encoder = encoding.createEncoder(); const encoder = encoding.createEncoder();
const decoder = decoding.createDecoder(message); const decoder = decoding.createDecoder(message);
@ -71,7 +86,7 @@ export const messageListener = (conn: WSClient, doc: WSSharedDoc, message: Uint8
applyAwarenessUpdate( applyAwarenessUpdate(
doc.awareness, doc.awareness,
decoding.readVarUint8Array(decoder), decoding.readVarUint8Array(decoder),
conn conn,
); );
// console.log(`received awareness message from ${conn.origin} total ${doc.awareness.states.size}`) // console.log(`received awareness message from ${conn.origin} total ${doc.awareness.states.size}`)
break; break;
@ -83,7 +98,12 @@ export const messageListener = (conn: WSClient, doc: WSSharedDoc, message: Uint8
} }
}; };
const updateHandler = (update: Uint8Array, _origin: any, doc: WSSharedDoc, _tr: any) => { const updateHandler = (
update: Uint8Array,
_origin: any,
doc: WSSharedDoc,
_tr: any,
) => {
const encoder = encoding.createEncoder(); const encoder = encoding.createEncoder();
encoding.writeVarUint(encoder, YMessageType.Sync); encoding.writeVarUint(encoder, YMessageType.Sync);
writeUpdate(encoder, update); writeUpdate(encoder, update);
@ -91,7 +111,8 @@ const updateHandler = (update: Uint8Array, _origin: any, doc: WSSharedDoc, _tr:
doc.conns.forEach((_, conn) => send(doc, conn, message)); doc.conns.forEach((_, conn) => send(doc, conn, message));
}; };
let contentInitializor: (ydoc: Y.Doc) => Promise<void> = (_ydoc) => Promise.resolve(); let contentInitializor: (ydoc: Y.Doc) => Promise<void> = (_ydoc) =>
Promise.resolve();
export const setContentInitializor = (f: (ydoc: Y.Doc) => Promise<void>) => { export const setContentInitializor = (f: (ydoc: Y.Doc) => Promise<void>) => {
contentInitializor = f; contentInitializor = f;
}; };
@ -110,22 +131,29 @@ export class WSSharedDoc extends Y.Doc {
this.awareness = new Awareness(this); this.awareness = new Awareness(this);
this.awareness.setLocalState(null); this.awareness.setLocalState(null);
const awarenessUpdateHandler = ({ const awarenessUpdateHandler = (
{
added, added,
updated, updated,
removed removed,
}: { }: {
added: number[], added: number[];
updated: number[], updated: number[];
removed: number[] removed: number[];
}, conn: WebSocket) => { },
conn: WebSocket,
) => {
const changedClients = added.concat(updated, removed); const changedClients = added.concat(updated, removed);
if (changedClients.length === 0) return if (changedClients.length === 0) return;
if (conn !== null) { if (conn !== null) {
const connControlledIDs = this.conns.get(conn) as Set<number>; const connControlledIDs = this.conns.get(conn) as Set<number>;
if (connControlledIDs !== undefined) { if (connControlledIDs !== undefined) {
added.forEach(clientID => { connControlledIDs.add(clientID); }); added.forEach((clientID) => {
removed.forEach(clientID => { connControlledIDs.delete(clientID); }); connControlledIDs.add(clientID);
});
removed.forEach((clientID) => {
connControlledIDs.delete(clientID);
});
} }
} }
@ -133,7 +161,7 @@ export class WSSharedDoc extends Y.Doc {
encoding.writeVarUint(encoder, YMessageType.Awareness); encoding.writeVarUint(encoder, YMessageType.Awareness);
encoding.writeVarUint8Array( encoding.writeVarUint8Array(
encoder, encoder,
encodeAwarenessUpdate(this.awareness, changedClients) encodeAwarenessUpdate(this.awareness, changedClients),
); );
const buff = encoding.toUint8Array(encoder); const buff = encoding.toUint8Array(encoder);
@ -146,11 +174,12 @@ export class WSSharedDoc extends Y.Doc {
this.on('update', updateHandler as any); this.on('update', updateHandler as any);
if (isCallbackSet) { if (isCallbackSet) {
this.on('update', debounce( this.on(
callbackHandler as any, 'update',
CALLBACK_DEBOUNCE_WAIT, debounce(callbackHandler as any, CALLBACK_DEBOUNCE_WAIT, {
{ maxWait: CALLBACK_DEBOUNCE_MAXWAIT } maxWait: CALLBACK_DEBOUNCE_MAXWAIT,
) as any); }) as any,
);
} }
this.whenInitialized = contentInitializor(this); this.whenInitialized = contentInitializor(this);

View File

@ -1,25 +1,36 @@
import { Injectable } from "@nestjs/common"; import { Injectable } from '@nestjs/common';
import { WebSocketType, WSClient } from "../types"; import { WebSocketType, WSClient } from '../types';
import { BaseWebSocketServer } from "../base/base-websocket-server"; import { BaseWebSocketServer } from '../base/base-websocket-server';
import { encoding } from "lib0"; import { encoding } from 'lib0';
import { YMessageType, writeSyncStep1, encodeAwarenessUpdate } from "@nice/common"; import {
import { getYDoc, closeConn, WSSharedDoc, messageListener, send } from "./ws-shared-doc"; YMessageType,
writeSyncStep1,
encodeAwarenessUpdate,
} from '@nice/common';
import {
getYDoc,
closeConn,
WSSharedDoc,
messageListener,
send,
} from './ws-shared-doc';
@Injectable() @Injectable()
export class YjsServer extends BaseWebSocketServer { export class YjsServer extends BaseWebSocketServer {
public get serverType(): WebSocketType { public get serverType(): WebSocketType {
return WebSocketType.YJS; return WebSocketType.YJS;
} }
public override handleConnection( public override handleConnection(connection: WSClient): void {
connection: WSClient super.handleConnection(connection);
): void {
super.handleConnection(connection)
try { try {
connection.binaryType = 'arraybuffer'; connection.binaryType = 'arraybuffer';
const doc = this.initializeDocument(connection, connection.roomId, true); const doc = this.initializeDocument(connection, connection.roomId, true);
this.setupConnectionHandlers(connection, doc); this.setupConnectionHandlers(connection, doc);
this.sendInitialSync(connection, doc); this.sendInitialSync(connection, doc);
} catch (error: any) { } catch (error: any) {
this.logger.error(`Error in handleNewConnection: ${error.message}`, error.stack); this.logger.error(
`Error in handleNewConnection: ${error.message}`,
error.stack,
);
connection.close(); connection.close();
} }
} }
@ -31,7 +42,10 @@ export class YjsServer extends BaseWebSocketServer {
return doc; return doc;
} }
private setupConnectionHandlers(connection: WSClient, doc: WSSharedDoc): void { private setupConnectionHandlers(
connection: WSClient,
doc: WSSharedDoc,
): void {
connection.on('message', (message: ArrayBuffer) => { connection.on('message', (message: ArrayBuffer) => {
this.handleMessage(connection, doc, message); this.handleMessage(connection, doc, message);
}); });
@ -39,9 +53,14 @@ export class YjsServer extends BaseWebSocketServer {
this.handleClose(doc, connection); this.handleClose(doc, connection);
}); });
connection.on('error', (error) => { connection.on('error', (error) => {
this.logger.error(`WebSocket error for doc ${doc.name}: ${error.message}`, error.stack); this.logger.error(
`WebSocket error for doc ${doc.name}: ${error.message}`,
error.stack,
);
closeConn(doc, connection); closeConn(doc, connection);
this.logger.warn(`Connection closed due to error for doc: ${doc.name}. Remaining connections: ${doc.conns.size}`); this.logger.warn(
`Connection closed due to error for doc: ${doc.name}. Remaining connections: ${doc.conns.size}`,
);
}); });
} }
@ -49,14 +68,24 @@ export class YjsServer extends BaseWebSocketServer {
try { try {
closeConn(doc, connection); closeConn(doc, connection);
} catch (error: any) { } catch (error: any) {
this.logger.error(`Error closing connection: ${error.message}`, error.stack); this.logger.error(
`Error closing connection: ${error.message}`,
error.stack,
);
} }
} }
private handleMessage(connection: WSClient, doc: WSSharedDoc, message: ArrayBuffer): void { private handleMessage(
connection: WSClient,
doc: WSSharedDoc,
message: ArrayBuffer,
): void {
try { try {
messageListener(connection, doc, new Uint8Array(message)); messageListener(connection, doc, new Uint8Array(message));
} catch (error: any) { } catch (error: any) {
this.logger.error(`Error handling message: ${error.message}`, error.stack); this.logger.error(
`Error handling message: ${error.message}`,
error.stack,
);
} }
} }
private sendInitialSync(connection: WSClient, doc: any): void { private sendInitialSync(connection: WSClient, doc: any): void {
@ -77,7 +106,10 @@ export class YjsServer extends BaseWebSocketServer {
encoding.writeVarUint(encoder, YMessageType.Awareness); encoding.writeVarUint(encoder, YMessageType.Awareness);
encoding.writeVarUint8Array( encoding.writeVarUint8Array(
encoder, encoder,
encodeAwarenessUpdate(doc.awareness, Array.from(awarenessStates.keys())) encodeAwarenessUpdate(
doc.awareness,
Array.from(awarenessStates.keys()),
),
); );
send(doc, connection, encoding.toUint8Array(encoder)); send(doc, connection, encoding.toUint8Array(encoder));
} }

View File

@ -1,9 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { RealtimeServer } from './realtime.server'; import { RealtimeServer } from './realtime.server';
@Module({ @Module({
providers: [ RealtimeServer], providers: [RealtimeServer],
exports: [ RealtimeServer] exports: [RealtimeServer],
}) })
export class RealTimeModule { } export class RealTimeModule {}

View File

@ -1,23 +1,23 @@
import { Injectable, OnModuleInit } from "@nestjs/common"; import { Injectable, OnModuleInit } from '@nestjs/common';
import { WebSocketType } from "../types"; import { WebSocketType } from '../types';
import { BaseWebSocketServer } from "../base/base-websocket-server"; import { BaseWebSocketServer } from '../base/base-websocket-server';
import EventBus, { CrudOperation } from "@server/utils/event-bus"; import EventBus, { CrudOperation } from '@server/utils/event-bus';
import { ObjectType, SocketMsgType } from "@nice/common"; import { ObjectType, SocketMsgType } from '@nice/common';
@Injectable() @Injectable()
export class RealtimeServer extends BaseWebSocketServer implements OnModuleInit { export class RealtimeServer
extends BaseWebSocketServer
implements OnModuleInit
{
onModuleInit() { onModuleInit() {
EventBus.on("dataChanged", ({ data, type, operation }) => { EventBus.on('dataChanged', ({ data, type, operation }) => {
// if (type === ObjectType.MESSAGE && operation === CrudOperation.CREATED) { // if (type === ObjectType.MESSAGE && operation === CrudOperation.CREATED) {
// const receiverIds = (data as Partial<MessageDto>).receivers.map(receiver => receiver.id) // const receiverIds = (data as Partial<MessageDto>).receivers.map(receiver => receiver.id)
// this.sendToUsers(receiverIds, { type: SocketMsgType.NOTIFY, payload: { objectType: ObjectType.MESSAGE } }) // this.sendToUsers(receiverIds, { type: SocketMsgType.NOTIFY, payload: { objectType: ObjectType.MESSAGE } })
// } // }
// if (type === ObjectType.POST) { // if (type === ObjectType.POST) {
// const post = data as Partial<PostDto> // const post = data as Partial<PostDto>
// } // }
}) });
} }
public get serverType(): WebSocketType { public get serverType(): WebSocketType {
return WebSocketType.REALTIME; return WebSocketType.REALTIME;

View File

@ -1,16 +1,16 @@
import { WebSocketServer, WebSocket } from "ws"; import { WebSocketServer, WebSocket } from 'ws';
// 类型定义 // 类型定义
export enum WebSocketType { export enum WebSocketType {
YJS = "yjs", YJS = 'yjs',
REALTIME = "realtime" REALTIME = 'realtime',
} }
export interface WebSocketServerConfig { export interface WebSocketServerConfig {
path?: string; path?: string;
pingInterval?: number; pingInterval?: number;
pingTimeout?: number; pingTimeout?: number;
debug?: boolean debug?: boolean;
} }
export interface ServerInstance { export interface ServerInstance {
@ -23,7 +23,7 @@ export interface ServerInstance {
export interface WSClient extends WebSocket { export interface WSClient extends WebSocket {
isAlive?: boolean; isAlive?: boolean;
type?: WebSocketType; type?: WebSocketType;
userId?: string userId?: string;
origin?: string origin?: string;
roomId?: string roomId?: string;
} }

View File

@ -8,4 +8,4 @@ import { CollaborationModule } from './collaboration/collaboration.module';
providers: [WebSocketService], providers: [WebSocketService],
exports: [WebSocketService], exports: [WebSocketService],
}) })
export class WebSocketModule { } export class WebSocketModule {}

View File

@ -1,9 +1,9 @@
import { Injectable, Logger } from "@nestjs/common"; import { Injectable, Logger } from '@nestjs/common';
import { Server } from "http"; import { Server } from 'http';
import { WSClient } from "./types"; import { WSClient } from './types';
import { RealtimeServer } from "./realtime/realtime.server"; import { RealtimeServer } from './realtime/realtime.server';
import { YjsServer } from "./collaboration/yjs.server"; import { YjsServer } from './collaboration/yjs.server';
import { BaseWebSocketServer } from "./base/base-websocket-server"; import { BaseWebSocketServer } from './base/base-websocket-server';
@Injectable() @Injectable()
export class WebSocketService { export class WebSocketService {
@ -11,14 +11,14 @@ export class WebSocketService {
private readonly servers: BaseWebSocketServer[] = []; private readonly servers: BaseWebSocketServer[] = [];
constructor( constructor(
private realTimeServer: RealtimeServer, private realTimeServer: RealtimeServer,
private yjsServer: YjsServer private yjsServer: YjsServer,
) { ) {
this.servers.push(this.realTimeServer) this.servers.push(this.realTimeServer);
this.servers.push(this.yjsServer) this.servers.push(this.yjsServer);
} }
public async initialize(httpServer: Server): Promise<void> { public async initialize(httpServer: Server): Promise<void> {
try { try {
await Promise.all(this.servers.map(server => server.start())); await Promise.all(this.servers.map((server) => server.start()));
this.setupUpgradeHandler(httpServer); this.setupUpgradeHandler(httpServer);
} catch (error) { } catch (error) {
this.logger.error('Failed to initialize:', error); this.logger.error('Failed to initialize:', error);
@ -36,7 +36,7 @@ export class WebSocketService {
const urlParams = new URLSearchParams(url.search); const urlParams = new URLSearchParams(url.search);
const roomId = urlParams.get('roomId'); const roomId = urlParams.get('roomId');
const userId = urlParams.get('userId'); const userId = urlParams.get('userId');
const server = this.servers.find(server => { const server = this.servers.find((server) => {
const serverPathClean = server.serverPath.replace(/\/$/, ''); const serverPathClean = server.serverPath.replace(/\/$/, '');
const pathnameClean = pathname.replace(/\/$/, ''); const pathnameClean = pathname.replace(/\/$/, '');
return serverPathClean === pathnameClean; return serverPathClean === pathnameClean;
@ -48,8 +48,8 @@ export class WebSocketService {
server.wss!.handleUpgrade(request, socket, head, (ws: WSClient) => { server.wss!.handleUpgrade(request, socket, head, (ws: WSClient) => {
ws.userId = userId; ws.userId = userId;
ws.origin = request.url ws.origin = request.url;
ws.roomId = roomId ws.roomId = roomId;
server.wss!.emit('connection', ws, request); server.wss!.emit('connection', ws, request);
}); });
} catch (error) { } catch (error) {

View File

@ -38,7 +38,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 {
@ -87,16 +87,57 @@ export class GenDevService {
// 定义网系类别 // 定义网系类别
const systemTypes = [ const systemTypes = [
{ name: '文印系统', children: ['电源故障', '主板故障', '内存故障', '硬盘故障', '显示器故障', '键盘故障', '鼠标故障'] }, {
{ name: '内网系统', children: ['系统崩溃', '应用程序错误', '病毒感染', '驱动问题', '系统更新失败'] }, name: '文印系统',
{ name: 'Windows系统', children: ['系统响应慢', '资源占用过高', '过热', '电池寿命短', '存储空间不足'] }, children: [
{ name: 'Linux系统', children: ['未知错误', '用户操作错误', '环境因素', '设备老化'] }, '电源故障',
{ name: '移动设备系统', children: ['参数设置错误', '配置文件损坏', '兼容性问题', '初始化失败'] }, '主板故障',
'内存故障',
'硬盘故障',
'显示器故障',
'键盘故障',
'鼠标故障',
],
},
{
name: '内网系统',
children: [
'系统崩溃',
'应用程序错误',
'病毒感染',
'驱动问题',
'系统更新失败',
],
},
{
name: 'Windows系统',
children: [
'系统响应慢',
'资源占用过高',
'过热',
'电池寿命短',
'存储空间不足',
],
},
{
name: 'Linux系统',
children: ['未知错误', '用户操作错误', '环境因素', '设备老化'],
},
{
name: '移动设备系统',
children: ['参数设置错误', '配置文件损坏', '兼容性问题', '初始化失败'],
},
]; ];
// 定义安防设备的子类型 // 定义安防设备的子类型
const securityDevices = { const securityDevices = {
: ['未授权访问', '数据泄露', '密码重置', '权限异常', '安全策略冲突'] , : [
'未授权访问',
'数据泄露',
'密码重置',
'权限异常',
'安全策略冲突',
],
}; };
// 创建网系类别及其关联的设备类型 // 创建网系类别及其关联的设备类型

View File

@ -4,6 +4,6 @@ import { ReminderService } from './reminder.service';
@Module({ @Module({
imports: [], imports: [],
providers: [ReminderService], providers: [ReminderService],
exports: [ReminderService] exports: [ReminderService],
}) })
export class ReminderModule { } export class ReminderModule {}

View File

@ -8,7 +8,6 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
/** /**
* *
*/ */
@ -24,7 +23,7 @@ export class ReminderService {
* *
* @param messageService * @param messageService
*/ */
constructor() { } constructor() {}
/** /**
* *
@ -74,7 +73,5 @@ export class ReminderService {
*/ */
async remindDeadline() { async remindDeadline() {
this.logger.log('开始检查截止日期以发送提醒。'); this.logger.log('开始检查截止日期以发送提醒。');
} }
} }

View File

@ -1,9 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TasksService } from './tasks.service'; import { TasksService } from './tasks.service';
import { InitModule } from '@server/tasks/init/init.module'; import { InitModule } from '@server/tasks/init/init.module';
import { ReminderModule } from "@server/tasks/reminder/reminder.module" import { ReminderModule } from '@server/tasks/reminder/reminder.module';
@Module({ @Module({
imports: [InitModule, ReminderModule], imports: [InitModule, ReminderModule],
providers: [TasksService] providers: [TasksService],
}) })
export class TasksModule { } export class TasksModule {}

View File

@ -11,8 +11,8 @@ export class TasksService implements OnModuleInit {
constructor( constructor(
private readonly schedulerRegistry: SchedulerRegistry, private readonly schedulerRegistry: SchedulerRegistry,
private readonly initService: InitService, private readonly initService: InitService,
private readonly reminderService: ReminderService private readonly reminderService: ReminderService,
) { } ) {}
async onModuleInit() { async onModuleInit() {
this.logger.log('Main node launch'); this.logger.log('Main node launch');
@ -30,11 +30,17 @@ export class TasksService implements OnModuleInit {
await this.reminderService.remindDeadline(); await this.reminderService.remindDeadline();
this.logger.log('Reminder successfully processed'); this.logger.log('Reminder successfully processed');
} catch (reminderErr) { } catch (reminderErr) {
this.logger.error('Error occurred while processing reminder', reminderErr); this.logger.error(
'Error occurred while processing reminder',
reminderErr,
);
} }
}); });
this.schedulerRegistry.addCronJob('remindDeadline', handleRemindJob as any); this.schedulerRegistry.addCronJob(
'remindDeadline',
handleRemindJob as any,
);
this.logger.log('Start remind cron job'); this.logger.log('Start remind cron job');
handleRemindJob.start(); handleRemindJob.start();
} catch (cronJobErr) { } catch (cronJobErr) {

View File

@ -66,5 +66,4 @@ export class UploadController {
async handleUpload(@Req() req: Request, @Res() res: Response) { async handleUpload(@Req() req: Request, @Res() res: Response) {
return this.tusService.handleTus(req, res); return this.tusService.handleTus(req, res);
} }
} }

View File

@ -3,6 +3,6 @@ import { MinioService } from './minio.service';
@Module({ @Module({
providers: [MinioService], providers: [MinioService],
exports: [MinioService] exports: [MinioService],
}) })
export class MinioModule {} export class MinioModule {}

View File

@ -1,4 +1,4 @@
import { redis } from "./redis.service"; import { redis } from './redis.service';
export async function deleteByPattern(pattern: string) { export async function deleteByPattern(pattern: string) {
try { try {

View File

@ -1,36 +1,40 @@
import { createReadStream } from "fs"; import { createReadStream } from 'fs';
import { createInterface } from "readline"; import { createInterface } from 'readline';
import { db } from '@nice/common'; import { db } from '@nice/common';
import * as tus from "tus-js-client"; import * as tus from 'tus-js-client';
import ExcelJS from 'exceljs'; import ExcelJS from 'exceljs';
export function truncateStringByByte(str, maxBytes) { export function truncateStringByByte(str, maxBytes) {
let byteCount = 0; let byteCount = 0;
let index = 0; let index = 0;
while (index < str.length && byteCount + new TextEncoder().encode(str[index]).length <= maxBytes) { while (
index < str.length &&
byteCount + new TextEncoder().encode(str[index]).length <= maxBytes
) {
byteCount += new TextEncoder().encode(str[index]).length; byteCount += new TextEncoder().encode(str[index]).length;
index++; index++;
} }
return str.substring(0, index) + (index < str.length ? "..." : ""); return str.substring(0, index) + (index < str.length ? '...' : '');
} }
export async function loadPoliciesFromCSV(filePath: string) { export async function loadPoliciesFromCSV(filePath: string) {
const policies = { const policies = {
p: [], p: [],
g: [] g: [],
}; };
const stream = createReadStream(filePath); const stream = createReadStream(filePath);
const rl = createInterface({ const rl = createInterface({
input: stream, input: stream,
crlfDelay: Infinity crlfDelay: Infinity,
}); });
// Updated regex to handle commas inside parentheses as part of a single field // Updated regex to handle commas inside parentheses as part of a single field
const regex = /(?:\((?:[^)(]+|\((?:[^)(]+|\([^)(]*\))*\))*\)|"(?:\\"|[^"])*"|[^,"()\s]+)(?=\s*,|\s*$)/g; const regex =
/(?:\((?:[^)(]+|\((?:[^)(]+|\([^)(]*\))*\))*\)|"(?:\\"|[^"])*"|[^,"()\s]+)(?=\s*,|\s*$)/g;
for await (const line of rl) { for await (const line of rl) {
// Ignore empty lines and comments // Ignore empty lines and comments
if (line.trim() && !line.startsWith("#")) { if (line.trim() && !line.startsWith('#')) {
const parts = []; const parts = [];
let match; let match;
while ((match = regex.exec(line)) !== null) { while ((match = regex.exec(line)) !== null) {
@ -61,10 +65,10 @@ export function uploadFile(blob: any, fileName: string) {
metadata: { metadata: {
filename: fileName, filename: fileName,
filetype: filetype:
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
}, },
onError: (error) => { onError: (error) => {
console.error("Failed because: " + error); console.error('Failed because: ' + error);
reject(error); // 错误时,我们要拒绝 promise reject(error); // 错误时,我们要拒绝 promise
}, },
onProgress: (bytesUploaded, bytesTotal) => { onProgress: (bytesUploaded, bytesTotal) => {
@ -80,7 +84,6 @@ export function uploadFile(blob: any, fileName: string) {
}); });
} }
class TreeNode { class TreeNode {
value: string; value: string;
children: TreeNode[]; children: TreeNode[];
@ -91,14 +94,12 @@ class TreeNode {
} }
addChild(childValue: string): TreeNode { addChild(childValue: string): TreeNode {
let newChild = undefined let newChild = undefined;
if (this.children.findIndex(child => child.value === childValue) === -1) { if (this.children.findIndex((child) => child.value === childValue) === -1) {
newChild = new TreeNode(childValue); newChild = new TreeNode(childValue);
this.children.push(newChild) this.children.push(newChild);
} }
return this.children.find(child => child.value === childValue) return this.children.find((child) => child.value === childValue);
} }
} }
function buildTree(data: string[][]): TreeNode { function buildTree(data: string[][]): TreeNode {
@ -111,12 +112,9 @@ function buildTree(data: string[][]): TreeNode {
} }
} }
return root; return root;
} catch (error) {
console.error(error);
} }
catch (error) {
console.error(error)
}
} }
export function printTree(node: TreeNode, level: number = 0): void { export function printTree(node: TreeNode, level: number = 0): void {
const indent = ' '.repeat(level); const indent = ' '.repeat(level);
@ -133,9 +131,12 @@ export async function generateTreeFromFile(file: Buffer): Promise<TreeNode> {
const data: string[][] = []; const data: string[][] = [];
worksheet.eachRow((row, rowNumber) => { worksheet.eachRow((row, rowNumber) => {
if (rowNumber > 1) { // Skip header row if any if (rowNumber > 1) {
const rowData: string[] = (row.values as string[]).slice(2).map(cell => (cell || '').toString()); // Skip header row if any
data.push(rowData.map(value => value.trim())); const rowData: string[] = (row.values as string[])
.slice(2)
.map((cell) => (cell || '').toString());
data.push(rowData.map((value) => value.trim()));
} }
}); });
// Fill forward values // Fill forward values

View File

@ -2,7 +2,7 @@ import { PipeTransform, BadRequestException } from '@nestjs/common';
import { ZodSchema } from 'zod'; import { ZodSchema } from 'zod';
export class ZodValidationPipe implements PipeTransform { export class ZodValidationPipe implements PipeTransform {
constructor(private schema: ZodSchema) { } constructor(private schema: ZodSchema) {}
transform(value: unknown) { transform(value: unknown) {
try { try {
@ -11,7 +11,7 @@ export class ZodValidationPipe implements PipeTransform {
} catch (error: any) { } catch (error: any) {
throw new BadRequestException('Validation failed', { throw new BadRequestException('Validation failed', {
cause: error, cause: error,
description: error.errors description: error.errors,
}); });
} }
} }

2
apps/web/index.html Executable file → Normal file
View File

@ -14,7 +14,7 @@
VITE_APP_FILE_PORT: "$FILE_PORT", VITE_APP_FILE_PORT: "$FILE_PORT",
}; };
</script> </script>
<title>$APP_NAME</title> <title>故障检索</title>
</head> </head>
<body> <body>

View File

@ -1,8 +1,388 @@
import { useState, useEffect, useRef } from 'react';
import { Card, Row, Col, DatePicker, Select, Spin } from 'antd';
import ReactECharts from 'echarts-for-react';
import dayjs from 'dayjs';
import { api } from "@nice/client";
const { RangePicker } = DatePicker;
const { Option } = Select;
const DashboardPage = () => { const DashboardPage = () => {
const [dateRange, setDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs]>([
dayjs().subtract(30, 'days'),
dayjs()
]);
const [loading, setLoading] = useState(true);
// 使用useRef跟踪是否已执行过刷新
const hasRefetched = useRef(false);
// 获取网系类别数据
const { data: systemTypeTerms, isLoading: loadingTypes, refetch: refetchSypes } = api.term.findMany.useQuery({
where: {
taxonomy: { slug: "system_type" },
deletedAt: null,
},
orderBy: { order: "asc" },
});
// 获取设备故障数据
const { data: devices, isLoading: loadingDevices, refetch: refetchDevices } = api.device.findMany.useQuery({
where: {
deletedAt: null,
createdAt: {
// 将开始日期设置为当天的开始时间 00:00:00
gte: dateRange[0].startOf('day').toISOString(),
// 将结束日期设置为当天的结束时间 23:59:59
lte: dateRange[1].endOf('day').toISOString(),
}
},
include: {
department: true,
},
});
useEffect(() => {
// 只在数据加载完成后执行一次刷新
if (!loadingTypes && !loadingDevices && !hasRefetched.current) {
// 标记为已执行
hasRefetched.current = true
// 刷新数据
refetchSypes();
refetchDevices();
setLoading(false);
}
}, [loadingTypes, loadingDevices]);
// 处理日期范围变化
const handleDateRangeChange = (dates, dateStrings) => {
if (dates) {
setDateRange([dates[0], dates[1]]);
} else {
setDateRange([dayjs().subtract(30, 'days'), dayjs()]);
}
};
// 准备各个网系故障情况数据(按时间段)
const prepareSystemFaultsByTimeData = () => {
if (!devices || !systemTypeTerms) return { xAxis: [], series: [] };
// 获取选择的日期范围
const startDate = dateRange[0];
const endDate = dateRange[1];
const diffDays = endDate.diff(startDate, 'day');
// 根据时间跨度选择合适的间隔
const intervals = [];
let format = '';
if (diffDays <= 14) {
// 小于等于两周,按天展示
format = 'MM-DD';
for (let i = 0; i <= diffDays; i++) {
intervals.push(startDate.add(i, 'day'));
}
} else if (diffDays <= 90) {
// 小于等于3个月按周展示
format = 'MM-DD';
const weeks = Math.ceil(diffDays / 7);
for (let i = 0; i < weeks; i++) {
const weekStart = startDate.add(i * 7, 'day');
intervals.push(weekStart);
}
} else {
// 大于3个月按月展示
format = 'YYYY-MM';
const months = Math.ceil(diffDays / 30);
for (let i = 0; i < months; i++) {
const monthStart = startDate.add(i, 'month').startOf('month');
intervals.push(monthStart);
}
}
// 格式化时间段显示
const timeLabels = intervals.map(date => {
if (diffDays <= 14) {
return date.format(format);
} else if (diffDays <= 90) {
return `${date.format(format)}${date.add(6, 'day').format(format)}`;
} else {
return date.format(format);
}
});
// 统计每个网系在每个时间段的故障数
const systemNames = systemTypeTerms.map(type => type.name);
const systemIdMap = {};
systemTypeTerms.forEach(type => {
systemIdMap[type.id] = type.name;
});
const seriesData = systemNames.map(name => ({
name,
type: 'bar',
data: new Array(intervals.length).fill(0)
}));
devices.forEach(device => {
const systemName = systemIdMap[device.systemType] || '未知';
const systemIndex = systemNames.indexOf(systemName);
if (systemIndex !== -1) {
const createDate = dayjs(device.createdAt);
for (let i = 0; i < intervals.length; i++) {
const nextInterval = null;
if (diffDays <= 14) {
// 按天,检查是否是同一天
if (createDate.format('YYYY-MM-DD') === intervals[i].format('YYYY-MM-DD')) {
seriesData[systemIndex].data[i]++;
}
} else if (diffDays <= 90) {
// 按周,检查是否在这周内
const weekEnd = intervals[i].add(6, 'day').endOf('day');
if (createDate.isAfter(intervals[i]) && createDate.isBefore(weekEnd)) {
seriesData[systemIndex].data[i]++;
}
} else {
// 按月,检查是否在这个月内
const monthEnd = intervals[i].endOf('month');
if (createDate.isAfter(intervals[i]) && createDate.isBefore(monthEnd)) {
seriesData[systemIndex].data[i]++;
}
}
}
}
});
return {
xAxis: timeLabels,
series: seriesData
};
};
// 准备各个网系故障率数据
const prepareSystemFaultRateData = () => {
if (!devices || !systemTypeTerms) return { names: [], values: [] };
const systemCounts = {};
const totalDevices = devices.length;
console.log("devices", devices.length);
// 初始化所有网系的计数
systemTypeTerms.forEach(type => {
systemCounts[type.name] = 0;
});
// 统计每个网系的故障数
devices.forEach(device => {
const systemType = systemTypeTerms?.find(t => t.id === device.systemType);
if (systemType) {
systemCounts[systemType.name] = (systemCounts[systemType.name] || 0) + 1;
}
});
const names = Object.keys(systemCounts);
const values = names.map(name => ({
value: totalDevices ? ((systemCounts[name] / totalDevices) * 100).toFixed(2) : 0,
name
}));
return { names, values };
};
// 准备故障处置完成率数据
const prepareFaultCompletionRateData = () => {
if (!devices) return { value: 0 };
const completedFaults = devices.filter(device =>
device.deviceStatus === 'normal'
).length;
const totalFaults = devices.length;
const completionRate = totalFaults ? (completedFaults / totalFaults) * 100 : 0;
return { value: parseFloat(completionRate.toFixed(2)) };
};
// 网系故障时间段分布选项
const getSystemFaultsByTimeOption = () => {
const data = prepareSystemFaultsByTimeData();
return {
title: {
text: '各网系故障时间分布',
left: 'center'
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
legend: {
data: data.series.map(item => item.name),
bottom: 10
},
grid: {
left: '3%',
right: '4%',
bottom: '15%',
top: '15%',
containLabel: true
},
xAxis: {
type: 'category',
data: data.xAxis
},
yAxis: {
type: 'value',
name: '故障数量'
},
series: data.series
};
};
// 网系故障率选项
const getSystemFaultRateOption = () => {
const data = prepareSystemFaultRateData();
return {
title: {
text: '各网系故障率',
left: 'center'
},
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c}%'
},
legend: {
orient: 'vertical',
left: 10,
top: 'center',
data: data.names
},
series: [
{
name: '故障率',
type: 'pie',
radius: ['40%', '70%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: true,
formatter: '{b}: {c}%'
},
emphasis: {
label: {
show: true,
fontSize: '15',
fontWeight: 'bold'
}
},
data: data.values
}
]
};
};
// 故障处置完成率选项
const getFaultCompletionRateOption = () => {
const data = prepareFaultCompletionRateData();
return {
title: {
text: '故障处置完成率',
left: 'center'
},
tooltip: {
formatter: '{a} <br/>{b} : {c}%'
},
series: [
{
name: '完成率',
type: 'gauge',
detail: { formatter: '{value}%' },
data: [{ value: data.value, name: '完成率' }],
axisLine: {
lineStyle: {
width: 30,
color: [
[0.3, '#ff6e76'],
[0.7, '#fddd60'],
[1, '#7cffb2']
]
}
},
pointer: {
itemStyle: {
color: 'auto'
}
}
}
]
};
};
return ( return (
<div className="min-h-screen"> <div className="p-4 min-h-screen bg-gray-50">
<h1>Dashboard</h1> <h1 className="text-2xl font-bold mb-6"></h1>
{/* <RangePicker
value={dateRange}
onChange={handleDateRangeChange}
format="YYYY-MM-DD"
className="mb-2"
/> */}
<Row className="mb-4">
<Col span={24}>
<Card className="shadow-sm">
<div className="flex justify-between items-center ">
<span className="text-lg font-medium"></span>
<RangePicker
value={dateRange}
onChange={handleDateRangeChange}
format="YYYY-MM-DD"
/>
</div>
</Card>
</Col>
</Row>
{loading ? (
<div className="flex justify-center items-center h-64">
<Spin size="large" tip="数据加载中..." />
</div>
) : (
<Row gutter={[16, 16]}>
<Col span={12}>
<Card className="shadow-sm hover:shadow-md transition-shadow duration-300">
<ReactECharts
option={getFaultCompletionRateOption()}
style={{ height: '400px' }}
/>
</Card>
</Col>
<Col span={12}>
<Card className="shadow-sm hover:shadow-md transition-shadow duration-300">
<ReactECharts
option={getSystemFaultRateOption()}
style={{ height: '400px' }}
/>
</Card>
</Col>
<Col span={24}>
<Card className="shadow-sm hover:shadow-md transition-shadow duration-300">
<ReactECharts
option={getSystemFaultsByTimeOption()}
style={{ height: '450px' }}
/>
</Card>
</Col>
</Row>
)}
</div> </div>
); );
}; };

View File

@ -85,7 +85,7 @@ export default function DeviceModal() {
label="故障名称 " label="故障名称 "
rules={[{ required: true, message: "请输入故障名称" }]} rules={[{ required: true, message: "请输入故障名称" }]}
> >
<Input className="rounded-lg" /> <Input className="rounded-lg" placeholder="请输入故障名称" />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
@ -98,11 +98,10 @@ export default function DeviceModal() {
<Col span={8}> <Col span={8}>
<Form.Item name="deviceStatus" label="故障状态"> <Form.Item name="deviceStatus" label="故障状态">
<Select className="rounded-lg"> <Select className="rounded-lg" placeholder="请选择故障状态">
<Select.Option value="normal"></Select.Option> <Select.Option value="normal"></Select.Option>
<Select.Option value="maintenance"></Select.Option> <Select.Option value="maintenance"></Select.Option>
<Select.Option value="broken"></Select.Option> <Select.Option value="broken"></Select.Option>
<Select.Option value="idle"></Select.Option>
</Select> </Select>
</Form.Item> </Form.Item>
</Col> </Col>

View File

@ -1,4 +1,4 @@
import { Button, Checkbox, Modal, Table, Upload } from "antd"; import { Button, Checkbox, Modal, Table, Upload, Tag } from "antd"; // 添加 Tag 导入
import { ColumnsType } from "antd/es/table"; import { ColumnsType } from "antd/es/table";
import { api, useDevice, useStaff } from "@nice/client"; import { api, useDevice, useStaff } from "@nice/client";
import { useEffect, useState, useImperativeHandle, forwardRef, useRef } from "react"; import { useEffect, useState, useImperativeHandle, forwardRef, useRef } from "react";
@ -51,6 +51,8 @@ const DeviceTable = forwardRef(({ onSelectedChange }: DeviceTableProps, ref) =>
enabled: true, enabled: true,
} }
); );
console.log("devices",devices);
const { data: systemTypeTerms, refetch: refetchSystemType } = const { data: systemTypeTerms, refetch: refetchSystemType } =
api.term.findMany.useQuery({ api.term.findMany.useQuery({
where: { where: {
@ -181,21 +183,39 @@ const DeviceTable = forwardRef(({ onSelectedChange }: DeviceTableProps, ref) =>
key: "deviceStatus", key: "deviceStatus",
align: "center", align: "center",
render: (status) => { render: (status) => {
const statusMap = { const statusConfig = {
normal: "正常", normal: {
maintenance: "维修中", text: "已修复",
broken: "损坏", color: "success"
idle: "闲置"
};
return statusMap[status] || "未知";
}, },
maintenance: {
text: "维修中",
color: "processing"
},
broken: {
text: "未修复",
color: "error"
}
};
const config = statusConfig[status] || {
text: "未知",
color: "default"
};
return (
<Tag color={config.color} style={{ minWidth: '60px' , textAlign: 'center' }}>
{config.text}
</Tag>
);
}
}, },
{ {
title: "时间", title: "时间",
dataIndex: "createdAt", dataIndex: "createdAt",
key: "createdAt", key: "createdAt",
align: "center", align: "center",
render: (text, record) => record.createdAt ? dayjs(record.createdAt).format('YYYY-MM-DD') : "未知", render: (text, record) => record.createdAt ? dayjs(record.createdAt).format('YYYY-MM-DD HH:mm:ss') : "未知",
}, },
{ {
title: "操作", title: "操作",
@ -262,7 +282,7 @@ const DeviceTable = forwardRef(({ onSelectedChange }: DeviceTableProps, ref) =>
'故障名称': item?.showname || "未命名故障", '故障名称': item?.showname || "未命名故障",
'故障状态': (() => { '故障状态': (() => {
const statusMap = { const statusMap = {
normal: "正常", normal: "已修复",
maintenance: "维修中", maintenance: "维修中",
broken: "损坏", broken: "损坏",
idle: "闲置" idle: "闲置"
@ -366,10 +386,9 @@ const DeviceTable = forwardRef(({ onSelectedChange }: DeviceTableProps, ref) =>
// 获取状态键 // 获取状态键
const getStatusKeyByValue = (value: string) => { const getStatusKeyByValue = (value: string) => {
const statusMap: Record<string, string> = { const statusMap: Record<string, string> = {
"正常": "normal", "已修复": "normal",
"维修中": "maintenance", "维修中": "maintenance",
"损坏": "broken", "未修复 ": "broken",
"闲置": "idle"
}; };
return statusMap[value] || "normal"; return statusMap[value] || "normal";
}; };
@ -388,15 +407,12 @@ const DeviceTable = forwardRef(({ onSelectedChange }: DeviceTableProps, ref) =>
toast.error("没有找到有效的记录数据"); toast.error("没有找到有效的记录数据");
return; return;
} }
// 设置批处理大小 // 设置批处理大小
const batchSize = 5; const batchSize = 5;
let successCount = 0; let successCount = 0;
let totalProcessed = 0; let totalProcessed = 0;
// 显示进度提示 // 显示进度提示
const loadingToast = toast.loading(`正在导入数据...`); const loadingToast = toast.loading(`正在导入数据...`);
// 分批处理数据 // 分批处理数据
for (let i = 0; i < validRecords.length; i += batchSize) { for (let i = 0; i < validRecords.length; i += batchSize) {
const batch = validRecords.slice(i, i + batchSize); const batch = validRecords.slice(i, i + batchSize);
@ -444,7 +460,7 @@ const DeviceTable = forwardRef(({ onSelectedChange }: DeviceTableProps, ref) =>
'故障类型': deviceTypeTerms?.[0]?.name || '故障类型1', '故障类型': deviceTypeTerms?.[0]?.name || '故障类型1',
'单位': '单位名称', '单位': '单位名称',
'故障名称': '示例故障名称', '故障名称': '示例故障名称',
'故障状态': '正常', // 可选值: 正常、维修中、损坏、闲置 '故障状态': '未修复', // 可选值:已修复, 维修中, 未修复
'描述': '这是一个示例描述' '描述': '这是一个示例描述'
} }
]; ];
@ -490,7 +506,10 @@ const DeviceTable = forwardRef(({ onSelectedChange }: DeviceTableProps, ref) =>
</div> </div>
), ),
preserveSelectedRowKeys: true // 这个属性保证翻页时选中状态不丢失 preserveSelectedRowKeys: true // 这个属性保证翻页时选中状态不丢失
} }
const TableHeader = () => ( const TableHeader = () => (
<div className="w-full flex justify-between mb-2"> <div className="w-full flex justify-between mb-2">
<span> <span>
@ -562,6 +581,10 @@ const DeviceTable = forwardRef(({ onSelectedChange }: DeviceTableProps, ref) =>
pageSize: 10, pageSize: 10,
showSizeChanger: true, showSizeChanger: true,
pageSizeOptions: ["10", "20", "30"], pageSizeOptions: ["10", "20", "30"],
hideOnSinglePage: true,
responsive: true,
showTotal: (total, range) => `${total} 条数据`,
showQuickJumper: true,
}} }}
components={{ components={{
header: { header: {

View File

@ -20,6 +20,7 @@ import DepartmentSelect from "@web/src/components/models/department/department-s
import SystemTypeSelect from "@web/src/app/main/devicepage/select/System-select"; import SystemTypeSelect from "@web/src/app/main/devicepage/select/System-select";
import DeviceTypeSelect from "@web/src/app/main/devicepage/select/Device-select"; import DeviceTypeSelect from "@web/src/app/main/devicepage/select/Device-select";
import dayjs from "dayjs"; import dayjs from "dayjs";
import FixTypeSelect from "./select/Fix-select";
// 添加筛选条件类型 // 添加筛选条件类型
type SearchCondition = { type SearchCondition = {
deletedAt: null; deletedAt: null;
@ -58,6 +59,7 @@ export default function DeviceMessage() {
const [location, setLocation] = useState<string>(""); const [location, setLocation] = useState<string>("");
const [status, setStatus] = useState<string | null>(null); const [status, setStatus] = useState<string | null>(null);
const [selectedSystemTypeId, setSelectedSystemTypeId] = useState<string>(""); const [selectedSystemTypeId, setSelectedSystemTypeId] = useState<string>("");
const [selectedFixType, setSelectedFixType] = useState<string | null>(null);
// 创建ref以访问DeviceTable内部方法 // 创建ref以访问DeviceTable内部方法
const tableRef = useRef(null); const tableRef = useRef(null);
@ -78,6 +80,7 @@ export default function DeviceMessage() {
deletedAt: null, deletedAt: null,
...(selectedSystem && { systemType: selectedSystem }), ...(selectedSystem && { systemType: selectedSystem }),
...(selectedDeviceType && { deviceType: selectedDeviceType }), ...(selectedDeviceType && { deviceType: selectedDeviceType }),
...(selectedFixType && { deviceStatus: selectedFixType }),
...(selectedDept && { deptId: selectedDept }), ...(selectedDept && { deptId: selectedDept }),
...(time && { ...(time && {
createdAt: { createdAt: {
@ -166,6 +169,14 @@ export default function DeviceMessage() {
systemTypeId={selectedSystemTypeId} systemTypeId={selectedSystemTypeId}
/> />
</div> </div>
<div className="flex-1 min-w-[200px]">
<FixTypeSelect
value={selectedFixType}
onChange={setSelectedFixType}
placeholder="选择故障状态"
className="w-full"
/>
</div>
<div className="flex-1 min-w-[200px]"> <div className="flex-1 min-w-[200px]">
<DepartmentSelect <DepartmentSelect
placeholder="单位" placeholder="单位"

View File

@ -0,0 +1,41 @@
// apps/web/src/components/models/term/system-type-select.tsx
import { Select } from "antd";
import { api } from "@nice/client";
import React from "react";
interface FixTypeSelectProps {
value?: string;
onChange?: (value: string) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
style?: React.CSSProperties;
}
export default function FixTypeSelect({ value,onChange,placeholder = "选择故障状态",disabled = false,className,style,}: FixTypeSelectProps) {
// 故障状态是固定的,直接定义选项
const options = [
{ label: "已修复", value: "normal" },
{ label: "维修中", value: "maintenance" },
{ label: "未修复", value: "broken" },
];
return (
<Select
value={value}
onChange={onChange}
options={options}
placeholder={placeholder}
disabled={disabled}
className={className}
style={style}
showSearch
filterOption={(input, option) =>
(option?.label?.toString() || "")
.toLowerCase()
.includes(input.toLowerCase())
}
allowClear
/>
);
}

View File

@ -29,12 +29,9 @@ function getItem(
}; };
} }
const items = [ const items = [
getItem("首页", "/dashboard", <HomeOutlined />, null, null), getItem("数据看板", "/dashboard", <HomeOutlined />, null, null),
getItem("故障收录检索", "/device", <BookOutlined />, null, null), getItem("故障收录检索", "/device", <BookOutlined />, null, null),
getItem( getItem("系统设置","/admin",<SettingOutlined />,
"系统设置",
"/admin",
<SettingOutlined />,
[ [
getItem("基本设置", "/admin/base-setting", <FormOutlined />, null, null), getItem("基本设置", "/admin/base-setting", <FormOutlined />, null, null),
getItem("用户管理", "/admin/user", <UserOutlined />, null, null), getItem("用户管理", "/admin/user", <UserOutlined />, null, null),

View File

@ -2,7 +2,7 @@ server {
# 监听80端口 # 监听80端口
listen 80; listen 80;
# 服务器域名/IP地址使用环境变量 # 服务器域名/IP地址使用环境变量
server_name 192.168.119.194; server_name 192.168.21.194;
# 基础性能优化配置 # 基础性能优化配置
# 启用tcp_nopush以优化数据发送 # 启用tcp_nopush以优化数据发送
@ -100,7 +100,7 @@ server {
# 仅供内部使用 # 仅供内部使用
internal; internal;
# 代理到认证服务 # 代理到认证服务
proxy_pass http://192.168.119.194:3000/auth/file; proxy_pass http://192.168.21.194:3000/auth/file;
# 请求优化:不传递请求体 # 请求优化:不传递请求体
proxy_pass_request_body off; proxy_pass_request_body off;

View File

@ -11,5 +11,9 @@
}, },
"keywords": [], "keywords": [],
"author": "insiinc", "author": "insiinc",
"license": "ISC" "license": "ISC",
"dependencies": {
"echarts": "^5.6.0",
"echarts-for-react": "^3.0.2"
}
} }

View File

@ -217,8 +217,6 @@ model Device {
deviceStatus String? @map("device_status") deviceStatus String? @map("device_status")
confidentialLabelId String? @map("confidential_label_id") confidentialLabelId String? @map("confidential_label_id")
confidentialityLevel String? @map("confidentiality_level") confidentialityLevel String? @map("confidentiality_level")
ipAddress String? @map("ip_address")
macAddress String? @map("mac_address")
diskSerialNumber String? @map("disk_serial_number") diskSerialNumber String? @map("disk_serial_number")
storageLocation String? @map("storage_location") storageLocation String? @map("storage_location")
responsiblePerson String? @map("responsible_person") responsiblePerson String? @map("responsible_person")

5371
pnpm-lock.yaml Executable file → Normal file

File diff suppressed because it is too large Load Diff