forked from hangshuo652/aurak
P0-1/P0-2/P1-1: dimensions form + E2E tests + PDF export
P0-1 Backend: dimensions column on template entity + validation P0-1 Frontend: dimensions edit UI in TemplateManager P0-2: routeAfterGrading unit tests (10 cases), service spec fix + certificate tests, jest-e2e.json P1-1: proper PDF generation with embedded CJK font via pdf-lib low-level API
This commit is contained in:
@@ -5,6 +5,7 @@ import { AssessmentService } from './assessment.service';
|
|||||||
import { TenantService } from '../tenant/tenant.service';
|
import { TenantService } from '../tenant/tenant.service';
|
||||||
import { UserService } from '../user/user.service';
|
import { UserService } from '../user/user.service';
|
||||||
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
|
import { CombinedAuthGuard } from '../auth/combined-auth.guard';
|
||||||
|
import { ExportService } from './services/export.service';
|
||||||
|
|
||||||
describe('AssessmentController', () => {
|
describe('AssessmentController', () => {
|
||||||
let controller: AssessmentController;
|
let controller: AssessmentController;
|
||||||
@@ -23,8 +24,9 @@ describe('AssessmentController', () => {
|
|||||||
controllers: [AssessmentController],
|
controllers: [AssessmentController],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: AssessmentService, useFactory: mockService },
|
{ provide: AssessmentService, useFactory: mockService },
|
||||||
{ provide: 'UserService', useFactory: mockService },
|
{ provide: UserService, useFactory: mockService },
|
||||||
{ provide: TenantService, useFactory: mockService },
|
{ provide: TenantService, useFactory: mockService },
|
||||||
|
{ provide: ExportService, useFactory: mockService },
|
||||||
{ provide: Reflector, useFactory: mockReflector },
|
{ provide: Reflector, useFactory: mockReflector },
|
||||||
{ provide: CombinedAuthGuard, useFactory: mockGuard },
|
{ provide: CombinedAuthGuard, useFactory: mockGuard },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -271,12 +271,12 @@ export class AssessmentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/export/pdf')
|
@Get(':id/export/pdf')
|
||||||
@ApiOperation({ summary: 'Export assessment to PDF (text format)' })
|
@ApiOperation({ summary: 'Export assessment to PDF' })
|
||||||
async exportPdf(@Param('id') sessionId: string) {
|
async exportPdf(@Param('id') sessionId: string) {
|
||||||
const buffer = await this.exportService.exportToPdf(sessionId);
|
const buffer = await this.exportService.exportToPdf(sessionId);
|
||||||
return {
|
return {
|
||||||
filename: `assessment-${sessionId}.txt`,
|
filename: `assessment-${sessionId}.pdf`,
|
||||||
content: buffer.toString('utf-8'),
|
buffer: buffer.toString('base64'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
import { AssessmentService } from './assessment.service';
|
import { AssessmentService } from './assessment.service';
|
||||||
import { AssessmentSession } from './entities/assessment-session.entity';
|
import { AssessmentSession, AssessmentStatus } from './entities/assessment-session.entity';
|
||||||
import { AssessmentQuestion } from './entities/assessment-question.entity';
|
import { AssessmentQuestion } from './entities/assessment-question.entity';
|
||||||
import { AssessmentAnswer } from './entities/assessment-answer.entity';
|
import { AssessmentAnswer } from './entities/assessment-answer.entity';
|
||||||
import { AssessmentCertificate } from './entities/assessment-certificate.entity';
|
import { AssessmentCertificate } from './entities/assessment-certificate.entity';
|
||||||
|
import { QuestionBank } from './entities/question-bank.entity';
|
||||||
|
import { QuestionBankItem } from './entities/question-bank-item.entity';
|
||||||
import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service';
|
import { KnowledgeBaseService } from '../knowledge-base/knowledge-base.service';
|
||||||
import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service';
|
import { KnowledgeGroupService } from '../knowledge-group/knowledge-group.service';
|
||||||
import { ModelConfigService } from '../model-config/model-config.service';
|
import { ModelConfigService } from '../model-config/model-config.service';
|
||||||
@@ -22,16 +24,21 @@ import { NotFoundException } from '@nestjs/common';
|
|||||||
describe('AssessmentService', () => {
|
describe('AssessmentService', () => {
|
||||||
let service: AssessmentService;
|
let service: AssessmentService;
|
||||||
let sessionRepository: any;
|
let sessionRepository: any;
|
||||||
|
let certificateRepository: any;
|
||||||
|
|
||||||
const mockRepository = () => ({
|
const mockRepository = () => ({
|
||||||
delete: jest.fn(),
|
delete: jest.fn(),
|
||||||
find: jest.fn(),
|
find: jest.fn(),
|
||||||
findOne: jest.fn(),
|
findOne: jest.fn(),
|
||||||
save: jest.fn(),
|
save: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const mockService = () => ({});
|
const mockService = () => ({});
|
||||||
|
|
||||||
|
const regularUser = { id: 'user-1', role: 'user' };
|
||||||
|
const adminUser = { id: 'admin-1', role: 'admin' };
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
@@ -40,6 +47,8 @@ describe('AssessmentService', () => {
|
|||||||
{ provide: getRepositoryToken(AssessmentQuestion), useFactory: mockRepository },
|
{ provide: getRepositoryToken(AssessmentQuestion), useFactory: mockRepository },
|
||||||
{ provide: getRepositoryToken(AssessmentAnswer), useFactory: mockRepository },
|
{ provide: getRepositoryToken(AssessmentAnswer), useFactory: mockRepository },
|
||||||
{ provide: getRepositoryToken(AssessmentCertificate), useFactory: mockRepository },
|
{ provide: getRepositoryToken(AssessmentCertificate), useFactory: mockRepository },
|
||||||
|
{ provide: getRepositoryToken(QuestionBank), useFactory: mockRepository },
|
||||||
|
{ provide: getRepositoryToken(QuestionBankItem), useFactory: mockRepository },
|
||||||
{ provide: KnowledgeBaseService, useFactory: mockService },
|
{ provide: KnowledgeBaseService, useFactory: mockService },
|
||||||
{ provide: KnowledgeGroupService, useFactory: mockService },
|
{ provide: KnowledgeGroupService, useFactory: mockService },
|
||||||
{ provide: ModelConfigService, useFactory: mockService },
|
{ provide: ModelConfigService, useFactory: mockService },
|
||||||
@@ -57,6 +66,7 @@ describe('AssessmentService', () => {
|
|||||||
|
|
||||||
service = module.get<AssessmentService>(AssessmentService);
|
service = module.get<AssessmentService>(AssessmentService);
|
||||||
sessionRepository = module.get(getRepositoryToken(AssessmentSession));
|
sessionRepository = module.get(getRepositoryToken(AssessmentSession));
|
||||||
|
certificateRepository = module.get(getRepositoryToken(AssessmentCertificate));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be defined', () => {
|
it('should be defined', () => {
|
||||||
@@ -64,15 +74,96 @@ describe('AssessmentService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('deleteSession', () => {
|
describe('deleteSession', () => {
|
||||||
it('should delete a session if it exists and belongs to the user', async () => {
|
it('should delete a session when non-admin user owns it', async () => {
|
||||||
sessionRepository.delete.mockResolvedValue({ affected: 1 });
|
sessionRepository.delete.mockResolvedValue({ affected: 1 });
|
||||||
await expect(service.deleteSession('session-id', 'user-id')).resolves.not.toThrow();
|
await expect(service.deleteSession('session-id', regularUser)).resolves.not.toThrow();
|
||||||
expect(sessionRepository.delete).toHaveBeenCalledWith({ id: 'session-id', userId: 'user-id' });
|
expect(sessionRepository.delete).toHaveBeenCalledWith({ id: 'session-id', userId: 'user-1' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete any session when admin user', async () => {
|
||||||
|
sessionRepository.delete.mockResolvedValue({ affected: 1 });
|
||||||
|
await expect(service.deleteSession('other-session', adminUser)).resolves.not.toThrow();
|
||||||
|
expect(sessionRepository.delete).toHaveBeenCalledWith({ id: 'other-session' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw NotFoundException if no session was affected', async () => {
|
it('should throw NotFoundException if no session was affected', async () => {
|
||||||
sessionRepository.delete.mockResolvedValue({ affected: 0 });
|
sessionRepository.delete.mockResolvedValue({ affected: 0 });
|
||||||
await expect(service.deleteSession('non-existent', 'user-id')).rejects.toThrow(NotFoundException);
|
await expect(service.deleteSession('non-existent', regularUser)).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generateCertificate', () => {
|
||||||
|
const completedSession = {
|
||||||
|
id: 'session-1',
|
||||||
|
userId: 'user-1',
|
||||||
|
status: AssessmentStatus.COMPLETED,
|
||||||
|
finalScore: 85,
|
||||||
|
templateId: 'template-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should throw NotFoundException when session does not exist', async () => {
|
||||||
|
sessionRepository.findOne.mockResolvedValue(null);
|
||||||
|
await expect(
|
||||||
|
service.generateCertificate('no-session', 'user-1', 'tenant-1'),
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw Error when session is not completed', async () => {
|
||||||
|
sessionRepository.findOne.mockResolvedValue({
|
||||||
|
...completedSession,
|
||||||
|
status: AssessmentStatus.IN_PROGRESS,
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
service.generateCertificate('session-1', 'user-1', 'tenant-1'),
|
||||||
|
).rejects.toThrow('Session not completed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return existing certificate if already generated (idempotent)', async () => {
|
||||||
|
const existingCert = { id: 'cert-1', sessionId: 'session-1' };
|
||||||
|
sessionRepository.findOne.mockResolvedValue(completedSession);
|
||||||
|
certificateRepository.findOne.mockResolvedValue(existingCert);
|
||||||
|
const result = await service.generateCertificate('session-1', 'user-1', 'tenant-1');
|
||||||
|
expect(result).toEqual(existingCert);
|
||||||
|
expect(certificateRepository.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a new certificate with correct level for score >= 90 (Expert)', async () => {
|
||||||
|
sessionRepository.findOne.mockResolvedValue({ ...completedSession, finalScore: 95 });
|
||||||
|
certificateRepository.findOne.mockResolvedValue(null);
|
||||||
|
certificateRepository.create.mockReturnValue({ id: 'cert-new' });
|
||||||
|
certificateRepository.save.mockResolvedValue({ id: 'cert-new', level: 'Expert' });
|
||||||
|
|
||||||
|
const result = await service.generateCertificate('session-1', 'user-1', 'tenant-1');
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(certificateRepository.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ level: 'Expert', totalScore: 95 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a new certificate with Advanced level for score 75-89', async () => {
|
||||||
|
sessionRepository.findOne.mockResolvedValue(completedSession);
|
||||||
|
certificateRepository.findOne.mockResolvedValue(null);
|
||||||
|
certificateRepository.create.mockReturnValue({ id: 'cert-new' });
|
||||||
|
certificateRepository.save.mockResolvedValue({ id: 'cert-new', level: 'Advanced' });
|
||||||
|
|
||||||
|
const result = await service.generateCertificate('session-1', 'user-1', 'tenant-1');
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(certificateRepository.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ level: 'Advanced', totalScore: 85 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a new certificate with Novice level for score < 60', async () => {
|
||||||
|
sessionRepository.findOne.mockResolvedValue({ ...completedSession, finalScore: 45 });
|
||||||
|
certificateRepository.findOne.mockResolvedValue(null);
|
||||||
|
certificateRepository.create.mockReturnValue({ id: 'cert-new' });
|
||||||
|
certificateRepository.save.mockResolvedValue({ id: 'cert-new', level: 'Novice' });
|
||||||
|
|
||||||
|
const result = await service.generateCertificate('session-1', 'user-1', 'tenant-1');
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(certificateRepository.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ level: 'Novice', totalScore: 45 }),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -59,6 +59,10 @@ export class CreateTemplateDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
linkedGroupIds?: string[];
|
linkedGroupIds?: string[];
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
dimensions?: Array<{ name: string; label: string; weight: number }>;
|
||||||
|
|
||||||
@IsObject()
|
@IsObject()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
weightConfig?: {
|
weightConfig?: {
|
||||||
|
|||||||
@@ -63,6 +63,9 @@ export class AssessmentTemplate {
|
|||||||
@JoinColumn({ name: 'knowledge_group_id' })
|
@JoinColumn({ name: 'knowledge_group_id' })
|
||||||
knowledgeGroup: KnowledgeGroup;
|
knowledgeGroup: KnowledgeGroup;
|
||||||
|
|
||||||
|
@Column({ type: 'simple-json', name: 'dimensions', nullable: true })
|
||||||
|
dimensions: Array<{ name: string; label: string; weight: number }>;
|
||||||
|
|
||||||
@Column({ type: 'boolean', name: 'is_active', default: true })
|
@Column({ type: 'boolean', name: 'is_active', default: true })
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { routeAfterGrading } from './builder';
|
||||||
|
|
||||||
|
describe('routeAfterGrading', () => {
|
||||||
|
it('should route to interviewer when shouldFollowUp is true (overrides all other logic)', () => {
|
||||||
|
const result = routeAfterGrading({
|
||||||
|
shouldFollowUp: true,
|
||||||
|
currentQuestionIndex: 0,
|
||||||
|
questionCount: 5,
|
||||||
|
questions: [],
|
||||||
|
} as any);
|
||||||
|
expect(result).toBe('interviewer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should route to generator when currentIndex >= questionsLen and currentIndex < targetCount', () => {
|
||||||
|
const result = routeAfterGrading({
|
||||||
|
shouldFollowUp: false,
|
||||||
|
currentQuestionIndex: 3,
|
||||||
|
questionCount: 5,
|
||||||
|
questions: [{ text: 'q1' }, { text: 'q2' }, { text: 'q3' }],
|
||||||
|
} as any);
|
||||||
|
expect(result).toBe('generator');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should route to interviewer when currentIndex < questionsLen and currentIndex < targetCount', () => {
|
||||||
|
const result = routeAfterGrading({
|
||||||
|
shouldFollowUp: false,
|
||||||
|
currentQuestionIndex: 2,
|
||||||
|
questionCount: 5,
|
||||||
|
questions: [{ text: 'q1' }, { text: 'q2' }, { text: 'q3' }, { text: 'q4' }, { text: 'q5' }],
|
||||||
|
} as any);
|
||||||
|
expect(result).toBe('interviewer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should route to analyzer when currentIndex >= targetCount', () => {
|
||||||
|
const result = routeAfterGrading({
|
||||||
|
shouldFollowUp: false,
|
||||||
|
currentQuestionIndex: 5,
|
||||||
|
questionCount: 5,
|
||||||
|
questions: [{ text: 'q1' }, { text: 'q2' }, { text: 'q3' }, { text: 'q4' }, { text: 'q5' }],
|
||||||
|
} as any);
|
||||||
|
expect(result).toBe('analyzer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default targetCount of 5 when questionCount is undefined', () => {
|
||||||
|
const result = routeAfterGrading({
|
||||||
|
shouldFollowUp: false,
|
||||||
|
currentQuestionIndex: 4,
|
||||||
|
questions: [{ text: 'q1' }, { text: 'q2' }, { text: 'q3' }, { text: 'q4' }],
|
||||||
|
} as any);
|
||||||
|
expect(result).toBe('generator');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default targetCount of 5 when questionCount is undefined and index 5 routes to analyzer', () => {
|
||||||
|
const result = routeAfterGrading({
|
||||||
|
shouldFollowUp: false,
|
||||||
|
currentQuestionIndex: 5,
|
||||||
|
questions: [{ text: 'q1' }, { text: 'q2' }, { text: 'q3' }, { text: 'q4' }, { text: 'q5' }],
|
||||||
|
} as any);
|
||||||
|
expect(result).toBe('analyzer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined questions gracefully (defaults to empty array)', () => {
|
||||||
|
const result = routeAfterGrading({
|
||||||
|
shouldFollowUp: false,
|
||||||
|
currentQuestionIndex: 0,
|
||||||
|
questionCount: 5,
|
||||||
|
} as any);
|
||||||
|
expect(result).toBe('generator');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prevent negative currentQuestionIndex via Math.max(0)', () => {
|
||||||
|
const result = routeAfterGrading({
|
||||||
|
shouldFollowUp: false,
|
||||||
|
currentQuestionIndex: -1,
|
||||||
|
questionCount: 5,
|
||||||
|
questions: [{ text: 'q1' }, { text: 'q2' }, { text: 'q3' }, { text: 'q4' }, { text: 'q5' }],
|
||||||
|
} as any);
|
||||||
|
expect(result).toBe('interviewer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle completely empty state (no fields provided)', () => {
|
||||||
|
const result = routeAfterGrading({} as any);
|
||||||
|
expect(result).toBe('generator');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should route to interviewer at last index before targetCount boundary', () => {
|
||||||
|
const result = routeAfterGrading({
|
||||||
|
shouldFollowUp: false,
|
||||||
|
currentQuestionIndex: 4,
|
||||||
|
questionCount: 5,
|
||||||
|
questions: [{ text: 'q1' }, { text: 'q2' }, { text: 'q3' }, { text: 'q4' }, { text: 'q5' }],
|
||||||
|
} as any);
|
||||||
|
expect(result).toBe('interviewer');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,7 +8,7 @@ import { reportAnalyzerNode } from './nodes/analyzer.node';
|
|||||||
/**
|
/**
|
||||||
* Conditional routing logic for the Grader node.
|
* Conditional routing logic for the Grader node.
|
||||||
*/
|
*/
|
||||||
const routeAfterGrading = (state: typeof EvaluationAnnotation.State) => {
|
export const routeAfterGrading = (state: typeof EvaluationAnnotation.State) => {
|
||||||
const targetCount = state.questionCount || 5;
|
const targetCount = state.questionCount || 5;
|
||||||
const questionsLen = state.questions?.length || 0;
|
const questionsLen = state.questions?.length || 0;
|
||||||
const currentIndex = Math.max(0, state.currentQuestionIndex || 0);
|
const currentIndex = Math.max(0, state.currentQuestionIndex || 0);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { AssessmentSession } from '../entities/assessment-session.entity';
|
|||||||
import { AssessmentQuestion } from '../entities/assessment-question.entity';
|
import { AssessmentQuestion } from '../entities/assessment-question.entity';
|
||||||
import { AssessmentAnswer } from '../entities/assessment-answer.entity';
|
import { AssessmentAnswer } from '../entities/assessment-answer.entity';
|
||||||
import { AssessmentCertificate } from '../entities/assessment-certificate.entity';
|
import { AssessmentCertificate } from '../entities/assessment-certificate.entity';
|
||||||
|
import { generateAssessmentPdf } from './pdf-generator';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExportService {
|
export class ExportService {
|
||||||
@@ -155,73 +156,55 @@ export class ExportService {
|
|||||||
where: { questionId: In(questions.map((q) => q.id)) },
|
where: { questionId: In(questions.map((q) => q.id)) },
|
||||||
});
|
});
|
||||||
|
|
||||||
const content = this.generatePdfContent(session, questions, answers, certificate);
|
|
||||||
return Buffer.from(content, 'utf-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
private generatePdfContent(
|
|
||||||
session: AssessmentSession,
|
|
||||||
questions: AssessmentQuestion[],
|
|
||||||
answers: AssessmentAnswer[],
|
|
||||||
certificate: AssessmentCertificate | null,
|
|
||||||
): string {
|
|
||||||
const lines: string[] = [];
|
|
||||||
|
|
||||||
lines.push('='.repeat(60));
|
|
||||||
lines.push(' 人才评估报告');
|
|
||||||
lines.push('='.repeat(60));
|
|
||||||
lines.push('');
|
|
||||||
|
|
||||||
lines.push(`评估ID: ${session.id}`);
|
|
||||||
lines.push(`用户: ${session.user?.displayName || session.user?.username || session.userId}`);
|
|
||||||
lines.push(`状态: ${session.status === 'COMPLETED' ? '已完成' : '进行中'}`);
|
|
||||||
lines.push(`最终分数: ${session.finalScore || '-'}`);
|
|
||||||
lines.push(`评估模板: ${session.template?.name || session.templateJson?.name || '-'}`);
|
|
||||||
lines.push(`评估时间: ${session.startedAt ? new Date(session.startedAt).toLocaleString() : '-'}`);
|
|
||||||
lines.push('');
|
|
||||||
|
|
||||||
if (certificate) {
|
|
||||||
lines.push('-'.repeat(60));
|
|
||||||
lines.push('证书信息');
|
|
||||||
lines.push('-'.repeat(60));
|
|
||||||
lines.push(`等级: ${certificate.level}`);
|
|
||||||
lines.push(`总分: ${certificate.totalScore}`);
|
|
||||||
lines.push(`是否通过: ${certificate.passed ? '是' : '否'}`);
|
|
||||||
lines.push(`颁发时间: ${certificate.issuedAt ? new Date(certificate.issuedAt).toLocaleString() : '-'}`);
|
|
||||||
lines.push('');
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push('-'.repeat(60));
|
|
||||||
lines.push('题目详情');
|
|
||||||
lines.push('-'.repeat(60));
|
|
||||||
|
|
||||||
const answerMap = new Map(answers.map((a) => [a.questionId, a]));
|
const answerMap = new Map(answers.map((a) => [a.questionId, a]));
|
||||||
|
|
||||||
|
const detailLines: string[] = [];
|
||||||
for (let i = 0; i < questions.length; i++) {
|
for (let i = 0; i < questions.length; i++) {
|
||||||
const q = questions[i];
|
const q = questions[i];
|
||||||
const a = answerMap.get(q.id);
|
const a = answerMap.get(q.id);
|
||||||
lines.push('');
|
detailLines.push(`Q${i + 1}: ${q.questionText || '-'}`);
|
||||||
lines.push(`第${i + 1}题:`);
|
detailLines.push(` A: ${a?.userAnswer || '-'}`);
|
||||||
lines.push(` 题目: ${q.questionText || '-'}`);
|
detailLines.push(` Score: ${a?.score ?? '-'}`);
|
||||||
lines.push(` 用户回答: ${a?.userAnswer || '-'}`);
|
detailLines.push(` Feedback: ${a?.feedback || '-'}`);
|
||||||
lines.push(` 得分: ${a?.score ?? '-'}`);
|
|
||||||
lines.push(` 反馈: ${a?.feedback || '-'}`);
|
|
||||||
lines.push(` 追问: ${a?.isFollowUp ? '是' : '否'}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.finalReport) {
|
const certificateLines: string[] = [];
|
||||||
lines.push('');
|
if (certificate) {
|
||||||
lines.push('-'.repeat(60));
|
certificateLines.push(`Level: ${certificate.level}`);
|
||||||
lines.push('综合评估报告');
|
certificateLines.push(`Total Score: ${certificate.totalScore}`);
|
||||||
lines.push('-'.repeat(60));
|
certificateLines.push(`Passed: ${certificate.passed ? 'Yes' : 'No'}`);
|
||||||
lines.push(session.finalReport);
|
certificateLines.push(`Issued: ${certificate.issuedAt ? new Date(certificate.issuedAt).toLocaleString() : '-'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push('');
|
const userName = session.user?.displayName || session.user?.username || session.userId;
|
||||||
lines.push('='.repeat(60));
|
|
||||||
lines.push(' 报告结束');
|
|
||||||
lines.push('='.repeat(60));
|
|
||||||
|
|
||||||
return lines.join('\n');
|
return generateAssessmentPdf({
|
||||||
|
title: 'Assessment Report',
|
||||||
|
subtitle: `${userName} — ${new Date(session.createdAt).toLocaleDateString()}`,
|
||||||
|
sections: [
|
||||||
|
{
|
||||||
|
title: 'Summary',
|
||||||
|
lines: [
|
||||||
|
`Session: ${session.id}`,
|
||||||
|
`User: ${userName}`,
|
||||||
|
`Status: ${session.status}`,
|
||||||
|
`Score: ${session.finalScore ?? '-'}`,
|
||||||
|
`Template: ${session.template?.name || session.templateJson?.name || '-'}`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
...(certificate ? [{
|
||||||
|
title: 'Certificate',
|
||||||
|
lines: certificateLines,
|
||||||
|
}] : []),
|
||||||
|
{
|
||||||
|
title: 'Question Details',
|
||||||
|
lines: detailLines,
|
||||||
|
},
|
||||||
|
...(session.finalReport ? [{
|
||||||
|
title: 'Final Report',
|
||||||
|
lines: [session.finalReport],
|
||||||
|
}] : []),
|
||||||
|
],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { PDFDocument, rgb } from 'pdf-lib';
|
||||||
|
|
||||||
|
const FONT_SEARCH_PATHS = [
|
||||||
|
path.join(__dirname, '..', '..', '..', 'assets', 'fonts', 'NotoSansSC-VF.ttf'),
|
||||||
|
'C:/Windows/Fonts/NotoSansSC-VF.ttf',
|
||||||
|
'C:/Windows/Fonts/msyh.ttc',
|
||||||
|
'C:/Windows/Fonts/simsun.ttc',
|
||||||
|
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
|
||||||
|
'/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc',
|
||||||
|
];
|
||||||
|
|
||||||
|
let cachedFontBytes: Buffer | null = null;
|
||||||
|
|
||||||
|
function findFont(): Buffer {
|
||||||
|
if (cachedFontBytes) return cachedFontBytes;
|
||||||
|
for (const p of FONT_SEARCH_PATHS) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(p)) {
|
||||||
|
cachedFontBytes = fs.readFileSync(p);
|
||||||
|
return cachedFontBytes;
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
return Buffer.alloc(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PdfReportOptions {
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
sections: PdfSection[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PdfSection {
|
||||||
|
title: string;
|
||||||
|
lines: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function textToHex(str: string): string {
|
||||||
|
let hex = '';
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
hex += str.charCodeAt(i).toString(16).toUpperCase().padStart(4, '0');
|
||||||
|
}
|
||||||
|
return hex;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateAssessmentPdf(options: PdfReportOptions): Promise<Buffer> {
|
||||||
|
const doc = await PDFDocument.create();
|
||||||
|
const page = doc.addPage([595.28, 841.89]);
|
||||||
|
const ctx = doc.context;
|
||||||
|
|
||||||
|
const fontBytes = findFont();
|
||||||
|
if (fontBytes.length === 0) {
|
||||||
|
throw new Error('No CJK font found. Install Noto Sans SC or similar.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fontProgRef = ctx.nextRef();
|
||||||
|
ctx.assign(fontProgRef, ctx.flateStream(fontBytes));
|
||||||
|
|
||||||
|
const fontDescRef = ctx.nextRef();
|
||||||
|
ctx.assign(fontDescRef, ctx.obj({
|
||||||
|
Type: 'FontDescriptor',
|
||||||
|
FontName: 'CJKFont',
|
||||||
|
Flags: 4,
|
||||||
|
FontBBox: [0, -300, 1000, 1000],
|
||||||
|
ItalicAngle: 0,
|
||||||
|
Ascent: 900,
|
||||||
|
Descent: -200,
|
||||||
|
CapHeight: 800,
|
||||||
|
StemV: 80,
|
||||||
|
FontFile2: fontProgRef,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const cidFontRef = ctx.nextRef();
|
||||||
|
ctx.assign(cidFontRef, ctx.obj({
|
||||||
|
Type: 'Font',
|
||||||
|
Subtype: 'CIDFontType2',
|
||||||
|
BaseFont: 'CJKFont',
|
||||||
|
CIDSystemInfo: ctx.obj({
|
||||||
|
Registry: 'Adobe',
|
||||||
|
Ordering: 'Identity',
|
||||||
|
Supplement: 0,
|
||||||
|
}),
|
||||||
|
FontDescriptor: fontDescRef,
|
||||||
|
W: [0, [500]],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fontRef = ctx.nextRef();
|
||||||
|
ctx.assign(fontRef, ctx.obj({
|
||||||
|
Type: 'Font',
|
||||||
|
Subtype: 'Type0',
|
||||||
|
BaseFont: 'CJKFont',
|
||||||
|
Encoding: 'Identity-H',
|
||||||
|
DescendantFonts: [cidFontRef],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fontKey = page.node.newFontDictionaryKey('F1');
|
||||||
|
page.node.setFontDictionary(fontKey, fontRef);
|
||||||
|
|
||||||
|
let contentOps = '';
|
||||||
|
let y = 800;
|
||||||
|
const margin = 50;
|
||||||
|
const pageWidth = 595.28;
|
||||||
|
|
||||||
|
function addLine(text: string, size: number, bold: boolean = false) {
|
||||||
|
const hex = textToHex(text);
|
||||||
|
contentOps += `BT\n/F1 ${size} Tf\n1 0 0 1 ${margin} ${y} Tm\n<${hex}> Tj\nET\n`;
|
||||||
|
y -= size * 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSeparator() {
|
||||||
|
let hex = '';
|
||||||
|
for (let i = 0; i < 55; i++) hex += '002D';
|
||||||
|
contentOps += `BT\n/F1 8 Tf\n1 0 0 1 ${margin} ${y} Tm\n<${hex}> Tj\nET\n`;
|
||||||
|
y -= 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
addLine(options.title, 22);
|
||||||
|
y -= 4;
|
||||||
|
if (options.subtitle) {
|
||||||
|
addLine(options.subtitle, 10);
|
||||||
|
y -= 4;
|
||||||
|
}
|
||||||
|
addSeparator();
|
||||||
|
|
||||||
|
for (const section of options.sections) {
|
||||||
|
if (y < 60) break;
|
||||||
|
addLine(section.title, 12);
|
||||||
|
for (const line of section.lines) {
|
||||||
|
if (y < 40) break;
|
||||||
|
addLine(line, 10);
|
||||||
|
}
|
||||||
|
y -= 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
addSeparator();
|
||||||
|
addLine('--- End of Report ---', 9);
|
||||||
|
|
||||||
|
const contentObj = ctx.flateStream(contentOps);
|
||||||
|
const contentRef = ctx.nextRef();
|
||||||
|
ctx.assign(contentRef, contentObj);
|
||||||
|
page.node.addContentStream(contentRef);
|
||||||
|
|
||||||
|
return Buffer.from(await doc.save());
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { TemplateService } from './template.service';
|
||||||
|
import { AssessmentTemplate } from '../entities/assessment-template.entity';
|
||||||
|
import { TenantService } from '../../tenant/tenant.service';
|
||||||
|
|
||||||
|
describe('TemplateService', () => {
|
||||||
|
let service: TemplateService;
|
||||||
|
let repo: any;
|
||||||
|
|
||||||
|
const mockTenantService = () => ({
|
||||||
|
canAccessTenant: jest.fn().mockResolvedValue(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
TemplateService,
|
||||||
|
{
|
||||||
|
provide: getRepositoryToken(AssessmentTemplate),
|
||||||
|
useFactory: () => ({
|
||||||
|
create: jest.fn(),
|
||||||
|
save: jest.fn(),
|
||||||
|
find: jest.fn(),
|
||||||
|
findOne: jest.fn(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ provide: TenantService, useFactory: mockTenantService },
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<TemplateService>(TemplateService);
|
||||||
|
repo = module.get(getRepositoryToken(AssessmentTemplate));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('create', () => {
|
||||||
|
it('should throw BadRequestException when linkedGroupIds is empty', async () => {
|
||||||
|
const dto = { name: 'Test', linkedGroupIds: [] };
|
||||||
|
await expect(
|
||||||
|
service.create(dto as any, 'user-id', 'tenant-id'),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw BadRequestException when dimensions is empty array', async () => {
|
||||||
|
const dto = { name: 'Test', linkedGroupIds: ['g-1'], dimensions: [] };
|
||||||
|
await expect(
|
||||||
|
service.create(dto as any, 'user-id', 'tenant-id'),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create template with valid data', async () => {
|
||||||
|
const dto = {
|
||||||
|
name: 'Valid Template',
|
||||||
|
linkedGroupIds: ['g-1'],
|
||||||
|
dimensions: [{ name: 'PROMPT', label: 'Prompt', weight: 0.5 }],
|
||||||
|
};
|
||||||
|
repo.create.mockReturnValue({ id: 'new-id', ...dto });
|
||||||
|
repo.save.mockResolvedValue({ id: 'new-id', ...dto });
|
||||||
|
|
||||||
|
const result = await service.create(dto as any, 'user-id', 'tenant-id');
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result.id).toBe('new-id');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('update', () => {
|
||||||
|
it('should throw BadRequestException when clearing linkedGroupIds', async () => {
|
||||||
|
repo.findOne.mockResolvedValue({
|
||||||
|
id: 'tpl-1', name: 'Existing',
|
||||||
|
linkedGroupIds: ['g-1'],
|
||||||
|
dimensions: [{ name: 'PROMPT', label: 'Prompt', weight: 1 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.update('tpl-1', { linkedGroupIds: [] } as any, 'user-id', 'tenant-id'),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw BadRequestException when clearing dimensions', async () => {
|
||||||
|
repo.findOne.mockResolvedValue({
|
||||||
|
id: 'tpl-1', name: 'Existing',
|
||||||
|
linkedGroupIds: ['g-1'],
|
||||||
|
dimensions: [{ name: 'PROMPT', label: 'Prompt', weight: 1 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.update('tpl-1', { dimensions: [] } as any, 'user-id', 'tenant-id'),
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
@@ -18,11 +19,33 @@ export class TemplateService {
|
|||||||
private readonly tenantService: TenantService,
|
private readonly tenantService: TenantService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private validateRequiredFields(data: {
|
||||||
|
linkedGroupIds?: string[];
|
||||||
|
dimensions?: Array<{ name: string; label?: string; weight?: number }>;
|
||||||
|
}) {
|
||||||
|
if (data.linkedGroupIds !== undefined) {
|
||||||
|
if (!Array.isArray(data.linkedGroupIds) || data.linkedGroupIds.length === 0) {
|
||||||
|
throw new BadRequestException('At least one knowledge group must be linked');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.dimensions !== undefined) {
|
||||||
|
if (!Array.isArray(data.dimensions) || data.dimensions.length === 0) {
|
||||||
|
throw new BadRequestException('At least one dimension must be defined');
|
||||||
|
}
|
||||||
|
for (const d of data.dimensions) {
|
||||||
|
if (!d.name) {
|
||||||
|
throw new BadRequestException('Each dimension must have a name');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async create(
|
async create(
|
||||||
createDto: CreateTemplateDto,
|
createDto: CreateTemplateDto,
|
||||||
userId: string,
|
userId: string,
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
): Promise<AssessmentTemplate> {
|
): Promise<AssessmentTemplate> {
|
||||||
|
this.validateRequiredFields(createDto);
|
||||||
const { ...data } = createDto;
|
const { ...data } = createDto;
|
||||||
const template = this.templateRepository.create({
|
const template = this.templateRepository.create({
|
||||||
...data,
|
...data,
|
||||||
@@ -76,6 +99,8 @@ export class TemplateService {
|
|||||||
tenantId: string,
|
tenantId: string,
|
||||||
): Promise<AssessmentTemplate> {
|
): Promise<AssessmentTemplate> {
|
||||||
const template = await this.findOne(id, userId, tenantId);
|
const template = await this.findOne(id, userId, tenantId);
|
||||||
|
const merged = { ...template, ...updateDto };
|
||||||
|
this.validateRequiredFields(merged);
|
||||||
Object.assign(template, updateDto);
|
Object.assign(template, updateDto);
|
||||||
return this.templateRepository.save(template);
|
return this.templateRepository.save(template);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { INestApplication } from '@nestjs/common';
|
||||||
|
import { AppModule } from '../src/app.module';
|
||||||
|
|
||||||
|
describe('App (e2e)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [AppModule],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
app = moduleFixture.createNestApplication();
|
||||||
|
await app.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be defined', () => {
|
||||||
|
expect(app).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"moduleFileExtensions": ["js", "json", "ts"],
|
||||||
|
"rootDir": ".",
|
||||||
|
"testEnvironment": "node",
|
||||||
|
"testRegex": ".e2e-spec.ts$",
|
||||||
|
"transform": {
|
||||||
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
|
},
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"^@/(.*)$": "<rootDir>/../src/$1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import { useToast } from '../../contexts/ToastContext';
|
|||||||
import { useConfirm } from '../../contexts/ConfirmContext';
|
import { useConfirm } from '../../contexts/ConfirmContext';
|
||||||
import { templateService } from '../../services/templateService';
|
import { templateService } from '../../services/templateService';
|
||||||
import { knowledgeGroupService } from '../../services/knowledgeGroupService';
|
import { knowledgeGroupService } from '../../services/knowledgeGroupService';
|
||||||
import { AssessmentTemplate, CreateTemplateData, UpdateTemplateData, KnowledgeGroup } from '../../types';
|
import { AssessmentTemplate, CreateTemplateData, UpdateTemplateData, KnowledgeGroup, AssessmentDimension } from '../../types';
|
||||||
|
|
||||||
export const AssessmentTemplateManager: React.FC = () => {
|
export const AssessmentTemplateManager: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
@@ -31,6 +31,7 @@ export const AssessmentTemplateManager: React.FC = () => {
|
|||||||
knowledgeGroupId: '',
|
knowledgeGroupId: '',
|
||||||
});
|
});
|
||||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||||
|
const [dimensions, setDimensions] = useState<AssessmentDimension[]>([]);
|
||||||
|
|
||||||
const fetchTemplates = async () => {
|
const fetchTemplates = async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -73,6 +74,7 @@ export const AssessmentTemplateManager: React.FC = () => {
|
|||||||
style: template.style || 'Professional',
|
style: template.style || 'Professional',
|
||||||
knowledgeGroupId: template.knowledgeGroupId || '',
|
knowledgeGroupId: template.knowledgeGroupId || '',
|
||||||
});
|
});
|
||||||
|
setDimensions(template.dimensions || []);
|
||||||
} else {
|
} else {
|
||||||
setEditingTemplate(null);
|
setEditingTemplate(null);
|
||||||
setFormData({
|
setFormData({
|
||||||
@@ -84,6 +86,7 @@ export const AssessmentTemplateManager: React.FC = () => {
|
|||||||
style: 'Professional',
|
style: 'Professional',
|
||||||
knowledgeGroupId: '',
|
knowledgeGroupId: '',
|
||||||
});
|
});
|
||||||
|
setDimensions([]);
|
||||||
}
|
}
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
};
|
};
|
||||||
@@ -111,6 +114,7 @@ export const AssessmentTemplateManager: React.FC = () => {
|
|||||||
difficultyDistribution: diffDist,
|
difficultyDistribution: diffDist,
|
||||||
style: formData.style,
|
style: formData.style,
|
||||||
knowledgeGroupId: formData.knowledgeGroupId || undefined,
|
knowledgeGroupId: formData.knowledgeGroupId || undefined,
|
||||||
|
dimensions: dimensions.length > 0 ? dimensions : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editingTemplate) {
|
if (editingTemplate) {
|
||||||
@@ -141,6 +145,20 @@ export const AssessmentTemplateManager: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDimensionChange = (index: number, field: 'name' | 'label' | 'weight', value: string | number) => {
|
||||||
|
const updated = [...dimensions];
|
||||||
|
updated[index] = { ...updated[index], [field]: value };
|
||||||
|
setDimensions(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddDimension = () => {
|
||||||
|
setDimensions([...dimensions, { name: '', label: '', weight: 1 }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveDimension = (index: number) => {
|
||||||
|
setDimensions(dimensions.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!(await confirm(t('confirmTitle')))) return;
|
if (!(await confirm(t('confirmTitle')))) return;
|
||||||
try {
|
try {
|
||||||
@@ -255,6 +273,16 @@ export const AssessmentTemplateManager: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{Array.isArray(template.dimensions) && template.dimensions.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||||
|
{template.dimensions.map((dim, i) => (
|
||||||
|
<span key={i} className="px-2 py-0.5 bg-amber-50 text-amber-700 text-[10px] font-bold rounded-full border border-amber-100/50">
|
||||||
|
{dim.label} ({dim.weight}%)
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-1.5 pt-4 border-t border-slate-50">
|
<div className="flex flex-wrap gap-1.5 pt-4 border-t border-slate-50">
|
||||||
{Array.isArray(template.keywords) && template.keywords.map((kw, i) => (
|
{Array.isArray(template.keywords) && template.keywords.map((kw, i) => (
|
||||||
<span key={i} className="px-2 py-0.5 bg-indigo-50 text-indigo-600 text-[10px] font-bold rounded-full border border-indigo-100/50">
|
<span key={i} className="px-2 py-0.5 bg-indigo-50 text-indigo-600 text-[10px] font-bold rounded-full border border-indigo-100/50">
|
||||||
@@ -385,6 +413,58 @@ export const AssessmentTemplateManager: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5 md:col-span-2">
|
||||||
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-1 ml-1 flex items-center gap-2">
|
||||||
|
<Sliders size={12} className="text-indigo-500" />
|
||||||
|
{t('templateDimensions')} *
|
||||||
|
</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{dimensions.length === 0 && (
|
||||||
|
<p className="text-xs text-slate-400 italic px-3">{t('mmEmpty')}</p>
|
||||||
|
)}
|
||||||
|
{dimensions.map((dim, index) => (
|
||||||
|
<div key={index} className="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
className="w-1/3 px-4 py-3 bg-slate-50 border border-slate-200 rounded-[1rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all placeholder:text-slate-300"
|
||||||
|
value={dim.name}
|
||||||
|
onChange={e => handleDimensionChange(index, 'name', e.target.value)}
|
||||||
|
placeholder={t('dimensionName')}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="w-1/3 px-4 py-3 bg-slate-50 border border-slate-200 rounded-[1rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all placeholder:text-slate-300"
|
||||||
|
value={dim.label}
|
||||||
|
onChange={e => handleDimensionChange(index, 'label', e.target.value)}
|
||||||
|
placeholder={t('dimensionLabel')}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="w-20 px-4 py-3 bg-slate-50 border border-slate-200 rounded-[1rem] text-sm font-bold focus:ring-4 focus:ring-indigo-500/10 focus:border-indigo-500/50 outline-none transition-all"
|
||||||
|
value={dim.weight}
|
||||||
|
onChange={e => handleDimensionChange(index, 'weight', parseInt(e.target.value) || 0)}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
placeholder={t('dimensionWeight')}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveDimension(index)}
|
||||||
|
className="p-2 text-red-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-all flex-shrink-0"
|
||||||
|
title={t('removeDimension')}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddDimension}
|
||||||
|
className="text-xs font-bold text-indigo-600 hover:text-indigo-800 transition-colors px-1"
|
||||||
|
>
|
||||||
|
+ {t('addDimension')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-4">
|
<div className="flex justify-end gap-3 pt-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -777,7 +777,10 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
|||||||
if (!session) return;
|
if (!session) return;
|
||||||
try {
|
try {
|
||||||
const result = await assessmentService.exportPdf(session.id);
|
const result = await assessmentService.exportPdf(session.id);
|
||||||
const blob = new Blob([result.content], { type: 'text/plain;charset=utf-8' });
|
const binary = atob(result.buffer);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||||
|
const blob = new Blob([bytes], { type: 'application/pdf' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = url;
|
a.href = url;
|
||||||
|
|||||||
@@ -139,8 +139,8 @@ export class AssessmentService {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async exportPdf(sessionId: string): Promise<{ filename: string; content: string }> {
|
async exportPdf(sessionId: string): Promise<{ filename: string; buffer: string }> {
|
||||||
const { data } = await apiClient.get<{ filename: string; content: string }>(`/assessment/${sessionId}/export/pdf`);
|
const { data } = await apiClient.get<{ filename: string; buffer: string }>(`/assessment/${sessionId}/export/pdf`);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -332,6 +332,12 @@ export interface TenantMember {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Assessment Template Types
|
// Assessment Template Types
|
||||||
|
export interface AssessmentDimension {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
weight: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AssessmentTemplate {
|
export interface AssessmentTemplate {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -343,6 +349,7 @@ export interface AssessmentTemplate {
|
|||||||
knowledgeBaseId?: string;
|
knowledgeBaseId?: string;
|
||||||
knowledgeGroupId?: string;
|
knowledgeGroupId?: string;
|
||||||
knowledgeGroup?: KnowledgeGroup;
|
knowledgeGroup?: KnowledgeGroup;
|
||||||
|
dimensions?: AssessmentDimension[];
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
version: number;
|
version: number;
|
||||||
creatorId: string;
|
creatorId: string;
|
||||||
@@ -359,6 +366,7 @@ export interface CreateTemplateData {
|
|||||||
style?: string;
|
style?: string;
|
||||||
knowledgeBaseId?: string;
|
knowledgeBaseId?: string;
|
||||||
knowledgeGroupId?: string;
|
knowledgeGroupId?: string;
|
||||||
|
dimensions?: AssessmentDimension[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateTemplateData extends Partial<CreateTemplateData> {
|
export interface UpdateTemplateData extends Partial<CreateTemplateData> {
|
||||||
|
|||||||
@@ -636,6 +636,12 @@ export const translations = {
|
|||||||
style: "风格要求",
|
style: "风格要求",
|
||||||
createTemplate: "创建模板",
|
createTemplate: "创建模板",
|
||||||
editTemplate: "编辑模板",
|
editTemplate: "编辑模板",
|
||||||
|
templateDimensions: "评估维度",
|
||||||
|
dimensionName: "维度名称",
|
||||||
|
dimensionLabel: "维度标签",
|
||||||
|
dimensionWeight: "权重",
|
||||||
|
addDimension: "添加维度",
|
||||||
|
removeDimension: "删除",
|
||||||
|
|
||||||
allNotes: "所有笔记",
|
allNotes: "所有笔记",
|
||||||
filterNotesPlaceholder: "筛选笔记...",
|
filterNotesPlaceholder: "筛选笔记...",
|
||||||
@@ -1573,6 +1579,12 @@ export const translations = {
|
|||||||
style: "Style Requirements",
|
style: "Style Requirements",
|
||||||
createTemplate: "Create Template",
|
createTemplate: "Create Template",
|
||||||
editTemplate: "Edit Template",
|
editTemplate: "Edit Template",
|
||||||
|
templateDimensions: "Evaluation Dimensions",
|
||||||
|
dimensionName: "Dimension Name",
|
||||||
|
dimensionLabel: "Label",
|
||||||
|
dimensionWeight: "Weight",
|
||||||
|
addDimension: "Add Dimension",
|
||||||
|
removeDimension: "Remove",
|
||||||
|
|
||||||
allNotes: "All Notes",
|
allNotes: "All Notes",
|
||||||
filterNotesPlaceholder: "Filter notes...",
|
filterNotesPlaceholder: "Filter notes...",
|
||||||
@@ -2610,6 +2622,13 @@ export const translations = {
|
|||||||
style: "スタイル要件",
|
style: "スタイル要件",
|
||||||
createTemplate: "テンプレートを作成",
|
createTemplate: "テンプレートを作成",
|
||||||
editTemplate: "テンプレートを編集",
|
editTemplate: "テンプレートを編集",
|
||||||
|
templateDimensions: "評価ディメンション",
|
||||||
|
dimensionName: "ディメンション名",
|
||||||
|
dimensionLabel: "ラベル",
|
||||||
|
dimensionWeight: "重み",
|
||||||
|
addDimension: "ディメンションを追加",
|
||||||
|
removeDimension: "削除",
|
||||||
|
|
||||||
allNotes: "すべてのノート",
|
allNotes: "すべてのノート",
|
||||||
filterNotesPlaceholder: "ノートをフィルタリング...",
|
filterNotesPlaceholder: "ノートをフィルタリング...",
|
||||||
startWritingPlaceholder: "書き始める...",
|
startWritingPlaceholder: "書き始める...",
|
||||||
|
|||||||
Reference in New Issue
Block a user