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;
|
||||
|
||||
Reference in New Issue
Block a user