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:
@@ -7,7 +7,7 @@ import { useToast } from '../../contexts/ToastContext';
|
||||
import { useConfirm } from '../../contexts/ConfirmContext';
|
||||
import { templateService } from '../../services/templateService';
|
||||
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 = () => {
|
||||
const { t } = useLanguage();
|
||||
@@ -31,6 +31,7 @@ export const AssessmentTemplateManager: React.FC = () => {
|
||||
knowledgeGroupId: '',
|
||||
});
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [dimensions, setDimensions] = useState<AssessmentDimension[]>([]);
|
||||
|
||||
const fetchTemplates = async () => {
|
||||
setIsLoading(true);
|
||||
@@ -73,6 +74,7 @@ export const AssessmentTemplateManager: React.FC = () => {
|
||||
style: template.style || 'Professional',
|
||||
knowledgeGroupId: template.knowledgeGroupId || '',
|
||||
});
|
||||
setDimensions(template.dimensions || []);
|
||||
} else {
|
||||
setEditingTemplate(null);
|
||||
setFormData({
|
||||
@@ -84,6 +86,7 @@ export const AssessmentTemplateManager: React.FC = () => {
|
||||
style: 'Professional',
|
||||
knowledgeGroupId: '',
|
||||
});
|
||||
setDimensions([]);
|
||||
}
|
||||
setShowModal(true);
|
||||
};
|
||||
@@ -111,6 +114,7 @@ export const AssessmentTemplateManager: React.FC = () => {
|
||||
difficultyDistribution: diffDist,
|
||||
style: formData.style,
|
||||
knowledgeGroupId: formData.knowledgeGroupId || undefined,
|
||||
dimensions: dimensions.length > 0 ? dimensions : undefined,
|
||||
};
|
||||
|
||||
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) => {
|
||||
if (!(await confirm(t('confirmTitle')))) return;
|
||||
try {
|
||||
@@ -255,6 +273,16 @@ export const AssessmentTemplateManager: React.FC = () => {
|
||||
</span>
|
||||
</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">
|
||||
{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">
|
||||
@@ -385,6 +413,58 @@ export const AssessmentTemplateManager: React.FC = () => {
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -777,7 +777,10 @@ export const AssessmentView: React.FC<AssessmentViewProps> = ({
|
||||
if (!session) return;
|
||||
try {
|
||||
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 a = document.createElement('a');
|
||||
a.href = url;
|
||||
|
||||
@@ -139,8 +139,8 @@ export class AssessmentService {
|
||||
return data;
|
||||
}
|
||||
|
||||
async exportPdf(sessionId: string): Promise<{ filename: string; content: string }> {
|
||||
const { data } = await apiClient.get<{ filename: string; content: string }>(`/assessment/${sessionId}/export/pdf`);
|
||||
async exportPdf(sessionId: string): Promise<{ filename: string; buffer: string }> {
|
||||
const { data } = await apiClient.get<{ filename: string; buffer: string }>(`/assessment/${sessionId}/export/pdf`);
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -332,6 +332,12 @@ export interface TenantMember {
|
||||
}
|
||||
|
||||
// Assessment Template Types
|
||||
export interface AssessmentDimension {
|
||||
name: string;
|
||||
label: string;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
export interface AssessmentTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -343,6 +349,7 @@ export interface AssessmentTemplate {
|
||||
knowledgeBaseId?: string;
|
||||
knowledgeGroupId?: string;
|
||||
knowledgeGroup?: KnowledgeGroup;
|
||||
dimensions?: AssessmentDimension[];
|
||||
isActive: boolean;
|
||||
version: number;
|
||||
creatorId: string;
|
||||
@@ -359,6 +366,7 @@ export interface CreateTemplateData {
|
||||
style?: string;
|
||||
knowledgeBaseId?: string;
|
||||
knowledgeGroupId?: string;
|
||||
dimensions?: AssessmentDimension[];
|
||||
}
|
||||
|
||||
export interface UpdateTemplateData extends Partial<CreateTemplateData> {
|
||||
|
||||
@@ -636,6 +636,12 @@ export const translations = {
|
||||
style: "风格要求",
|
||||
createTemplate: "创建模板",
|
||||
editTemplate: "编辑模板",
|
||||
templateDimensions: "评估维度",
|
||||
dimensionName: "维度名称",
|
||||
dimensionLabel: "维度标签",
|
||||
dimensionWeight: "权重",
|
||||
addDimension: "添加维度",
|
||||
removeDimension: "删除",
|
||||
|
||||
allNotes: "所有笔记",
|
||||
filterNotesPlaceholder: "筛选笔记...",
|
||||
@@ -1573,6 +1579,12 @@ export const translations = {
|
||||
style: "Style Requirements",
|
||||
createTemplate: "Create Template",
|
||||
editTemplate: "Edit Template",
|
||||
templateDimensions: "Evaluation Dimensions",
|
||||
dimensionName: "Dimension Name",
|
||||
dimensionLabel: "Label",
|
||||
dimensionWeight: "Weight",
|
||||
addDimension: "Add Dimension",
|
||||
removeDimension: "Remove",
|
||||
|
||||
allNotes: "All Notes",
|
||||
filterNotesPlaceholder: "Filter notes...",
|
||||
@@ -2610,6 +2622,13 @@ export const translations = {
|
||||
style: "スタイル要件",
|
||||
createTemplate: "テンプレートを作成",
|
||||
editTemplate: "テンプレートを編集",
|
||||
templateDimensions: "評価ディメンション",
|
||||
dimensionName: "ディメンション名",
|
||||
dimensionLabel: "ラベル",
|
||||
dimensionWeight: "重み",
|
||||
addDimension: "ディメンションを追加",
|
||||
removeDimension: "削除",
|
||||
|
||||
allNotes: "すべてのノート",
|
||||
filterNotesPlaceholder: "ノートをフィルタリング...",
|
||||
startWritingPlaceholder: "書き始める...",
|
||||
|
||||
Reference in New Issue
Block a user