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:
Developer
2026-05-19 08:42:03 +08:00
parent 0b0a060967
commit 68371922ca
18 changed files with 663 additions and 72 deletions
@@ -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"
+4 -1
View File
@@ -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;