Redesign test editor: meta, content, AI shape, command bar
- Split draft editor into AccSection Метаинформация and Содержание - AI generation: topic, question count (1–30), answers per question (2–8) - Move save and back-to-list to bottom command panel; remove AI from hero - Normalize generated options to requested count; sync ai topic on import draft - Add DOC/ШАГИ/ШАГ_2026-04-27_001.md and track design proposal doc Made-with: Cursor
This commit is contained in:
+321
-214
@@ -85,6 +85,10 @@ export default function TestDetail() {
|
||||
const [importBusy, setImportBusy] = useState(false);
|
||||
const [aiTestBusy, setAiTestBusy] = useState(false);
|
||||
const [aiQBusy, setAiQBusy] = useState(null);
|
||||
/** Параметры блока «Сгенерировать тест (ИИ)» (редизайн формы редактора) */
|
||||
const [aiGenTopic, setAiGenTopic] = useState('');
|
||||
const [aiQuestionsCount, setAiQuestionsCount] = useState(7);
|
||||
const [aiAnswersCount, setAiAnswersCount] = useState(3);
|
||||
const [assignSearch, setAssignSearch] = useState('');
|
||||
const [assignSearchApplied, setAssignSearchApplied] = useState('');
|
||||
const [assignDept, setAssignDept] = useState('__all__');
|
||||
@@ -119,6 +123,7 @@ export default function TestDetail() {
|
||||
setChain(c);
|
||||
if (ed?.test) {
|
||||
setDraftTitle(ed.test.title || '');
|
||||
setAiGenTopic((ed.test.title || '').trim());
|
||||
setDraftDescription(ed.test.description || '');
|
||||
const th = ed.test.passingThreshold;
|
||||
setDraftPassing(
|
||||
@@ -300,21 +305,41 @@ export default function TestDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGeneratedQuestionOptions(q, targetCount) {
|
||||
const n = Math.min(12, Math.max(2, targetCount));
|
||||
const raw = (q?.options || []).map((o) => ({
|
||||
key: newKey(),
|
||||
text: (o.text || '').trim() || 'Вариант',
|
||||
isCorrect: !!o.isCorrect,
|
||||
}));
|
||||
const out = raw.slice(0, n);
|
||||
while (out.length < n) {
|
||||
out.push({ key: newKey(), text: '', isCorrect: false });
|
||||
}
|
||||
if (!out.some((o) => o.isCorrect)) {
|
||||
out[0] = { ...out[0], isCorrect: true };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function runAiGenerateTest() {
|
||||
if (aiTestBusy || !id) {
|
||||
return;
|
||||
}
|
||||
const nQ = Math.min(30, Math.max(1, Math.floor(Number(aiQuestionsCount)) || 7));
|
||||
const nA = Math.min(8, Math.max(2, Math.floor(Number(aiAnswersCount)) || 3));
|
||||
const topic = (aiGenTopic || draftTitle || '').trim() || 'Тест';
|
||||
setDraftStatus('');
|
||||
setAiTestBusy(true);
|
||||
try {
|
||||
const shape = draftQuestions.map((q) => ({
|
||||
optionsCount: Math.max(2, Math.min(12, q.options?.length || 2)),
|
||||
hasMultipleAnswers: q.hasMultipleAnswers,
|
||||
const shape = Array.from({ length: nQ }, () => ({
|
||||
optionsCount: nA,
|
||||
hasMultipleAnswers: false,
|
||||
}));
|
||||
const out = await api(`/api/tests/${id}/ai/generate-test`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
testTitle: draftTitle,
|
||||
testTitle: topic,
|
||||
testDescription: draftDescription,
|
||||
shape,
|
||||
}),
|
||||
@@ -330,11 +355,7 @@ export default function TestDetail() {
|
||||
key: newKey(),
|
||||
text: (q.text || '').trim() || 'Вопрос',
|
||||
hasMultipleAnswers: !!q.hasMultipleAnswers,
|
||||
options: (q.options || []).map((o) => ({
|
||||
key: newKey(),
|
||||
text: (o.text || '').trim() || 'Вариант',
|
||||
isCorrect: !!o.isCorrect,
|
||||
})),
|
||||
options: normalizeGeneratedQuestionOptions(q, nA),
|
||||
}));
|
||||
if (qs.length) {
|
||||
setDraftQuestions(qs);
|
||||
@@ -449,6 +470,7 @@ export default function TestDetail() {
|
||||
return;
|
||||
}
|
||||
setDraftTitle((d.title || '').trim() || 'Без названия');
|
||||
setAiGenTopic((d.title || '').trim());
|
||||
setDraftDescription(
|
||||
d.description != null && String(d.description).trim() ? String(d.description).trim() : ''
|
||||
);
|
||||
@@ -615,21 +637,6 @@ export default function TestDetail() {
|
||||
<p className="text-muted" style={{ marginTop: 0, marginBottom: '0.5rem', fontSize: 14 }}>
|
||||
{formatTestAuthorLabel(user, test?.createdBy, test?.authorFullName)}
|
||||
</p>
|
||||
<div className="inline-actions" style={{ marginBottom: '0.65rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
disabled={aiTestBusy}
|
||||
onClick={runAiGenerateTest}
|
||||
>
|
||||
{aiTestBusy ? 'Генерация…' : 'Сгенерировать тест (ИИ)'}
|
||||
</button>
|
||||
</div>
|
||||
{test?.description && (
|
||||
<p className="text-muted" style={{ marginTop: 0, marginBottom: '1rem' }}>
|
||||
{test.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="muted" style={{ marginBottom: 0 }}>
|
||||
Обновлён: {fmtDt(test?.updatedAt || test?.createdAt)}
|
||||
{test?.chainActive === false && (
|
||||
@@ -657,6 +664,296 @@ export default function TestDetail() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AccSection title="Метаинформация" defaultOpen>
|
||||
<div className="draft-block">
|
||||
<label className="form-label" htmlFor="draft-title">
|
||||
Название
|
||||
</label>
|
||||
<input
|
||||
id="draft-title"
|
||||
className="form-input"
|
||||
value={draftTitle}
|
||||
onChange={(e) => setDraftTitle(e.target.value)}
|
||||
/>
|
||||
<label className="form-label" htmlFor="draft-desc" style={{ marginTop: '0.75rem' }}>
|
||||
Описание
|
||||
</label>
|
||||
<textarea
|
||||
id="draft-desc"
|
||||
className="form-input"
|
||||
rows={2}
|
||||
value={draftDescription}
|
||||
onChange={(e) => setDraftDescription(e.target.value)}
|
||||
/>
|
||||
<label className="form-label" htmlFor="draft-pass" style={{ marginTop: '0.75rem' }}>
|
||||
Порог зачёта, %
|
||||
</label>
|
||||
<input
|
||||
id="draft-pass"
|
||||
type="number"
|
||||
className="form-input"
|
||||
min={0}
|
||||
max={100}
|
||||
inputMode="numeric"
|
||||
value={draftPassing}
|
||||
onChange={(e) => setDraftPassing(e.target.value)}
|
||||
style={{ maxWidth: 120 }}
|
||||
/>
|
||||
</div>
|
||||
</AccSection>
|
||||
|
||||
<AccSection title="Содержание" defaultOpen>
|
||||
<div className="draft-block">
|
||||
<div
|
||||
className="surface-card"
|
||||
style={{
|
||||
padding: '1rem 1.1rem',
|
||||
marginBottom: '1.25rem',
|
||||
borderLeft: '3px solid color-mix(in srgb, var(--primary) 45%, transparent)',
|
||||
}}
|
||||
aria-label="Параметры генерации теста с ИИ"
|
||||
>
|
||||
<p
|
||||
className="font-headline"
|
||||
style={{ fontSize: '0.95rem', marginTop: 0, marginBottom: '0.65rem' }}
|
||||
>
|
||||
Генерация сетки вопросов (ИИ)
|
||||
</p>
|
||||
<label className="form-label" htmlFor="ai-gen-topic">
|
||||
Тема
|
||||
</label>
|
||||
<input
|
||||
id="ai-gen-topic"
|
||||
className="form-input"
|
||||
value={aiGenTopic}
|
||||
onChange={(e) => setAiGenTopic(e.target.value)}
|
||||
placeholder="По умолчанию — как в «Название»; при необходимости уточните"
|
||||
/>
|
||||
<div
|
||||
className="inline-actions"
|
||||
style={{ marginTop: '0.75rem', flexWrap: 'wrap', alignItems: 'flex-end', gap: '0.9rem' }}
|
||||
>
|
||||
<div>
|
||||
<label className="form-label" htmlFor="ai-n-questions" style={{ display: 'block' }}>
|
||||
Вопросов
|
||||
</label>
|
||||
<input
|
||||
id="ai-n-questions"
|
||||
type="number"
|
||||
className="form-input"
|
||||
min={1}
|
||||
max={30}
|
||||
inputMode="numeric"
|
||||
value={aiQuestionsCount}
|
||||
onChange={(e) => {
|
||||
const n = parseInt(e.target.value, 10);
|
||||
if (!Number.isFinite(n) || n < 1) {
|
||||
return;
|
||||
}
|
||||
setAiQuestionsCount(Math.min(30, Math.max(1, n)));
|
||||
}}
|
||||
style={{ maxWidth: 90 }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label" htmlFor="ai-n-answers" style={{ display: 'block' }}>
|
||||
Вариантов
|
||||
</label>
|
||||
<input
|
||||
id="ai-n-answers"
|
||||
type="number"
|
||||
className="form-input"
|
||||
min={2}
|
||||
max={8}
|
||||
inputMode="numeric"
|
||||
value={aiAnswersCount}
|
||||
onChange={(e) => {
|
||||
const n = parseInt(e.target.value, 10);
|
||||
if (!Number.isFinite(n) || n < 2) {
|
||||
return;
|
||||
}
|
||||
setAiAnswersCount(Math.min(8, Math.max(2, n)));
|
||||
}}
|
||||
style={{ maxWidth: 90 }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ marginTop: '0.1rem' }}
|
||||
disabled={aiTestBusy}
|
||||
onClick={runAiGenerateTest}
|
||||
>
|
||||
{aiTestBusy ? 'Генерация…' : 'Сгенерировать тест (ИИ)'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{draftQuestions.map((q, qi) => (
|
||||
<div
|
||||
key={q.key}
|
||||
style={{
|
||||
borderTop: '1px solid var(--border-subtle, #2a2a2a)',
|
||||
marginTop: '1rem',
|
||||
paddingTop: '1rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="inline-actions"
|
||||
style={{ marginBottom: 6, flexWrap: 'wrap', alignItems: 'center' }}
|
||||
>
|
||||
<label className="form-label" htmlFor={`qtext-${q.key}`} style={{ marginBottom: 0 }}>
|
||||
Вопрос {qi + 1}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn--sm"
|
||||
disabled={aiQBusy != null}
|
||||
onClick={() => runAiGenerateQuestion(qi)}
|
||||
>
|
||||
{aiQBusy === qi ? '…' : 'Сгенерировать вопрос (ИИ)'}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
id={`qtext-${q.key}`}
|
||||
className="form-input"
|
||||
rows={2}
|
||||
value={q.text}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setDraftQuestions((prev) =>
|
||||
prev.map((row, i) => (i === qi ? { ...row, text: v } : row))
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
className="form-label"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={q.hasMultipleAnswers}
|
||||
onChange={(e) => {
|
||||
const v = e.target.checked;
|
||||
setDraftQuestions((prev) =>
|
||||
prev.map((row, i) => (i === qi ? { ...row, hasMultipleAnswers: v } : row))
|
||||
);
|
||||
}}
|
||||
/>
|
||||
Несколько верных ответов
|
||||
</label>
|
||||
<p className="muted" style={{ marginBottom: 6, fontSize: 13 }}>
|
||||
Варианты
|
||||
</p>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||
{q.options.map((o, oi) => (
|
||||
<li
|
||||
key={o.key}
|
||||
style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8, marginBottom: 8 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={o.isCorrect}
|
||||
onChange={(e) => {
|
||||
const v = e.target.checked;
|
||||
setDraftQuestions((prev) =>
|
||||
prev.map((row, i) => {
|
||||
if (i !== qi) {
|
||||
return row;
|
||||
}
|
||||
if (!row.hasMultipleAnswers && v) {
|
||||
return {
|
||||
...row,
|
||||
options: row.options.map((op, j) => ({
|
||||
...op,
|
||||
isCorrect: j === oi,
|
||||
})),
|
||||
};
|
||||
}
|
||||
const options = row.options.map((op, j) =>
|
||||
j === oi ? { ...op, isCorrect: v } : op
|
||||
);
|
||||
return { ...row, options };
|
||||
})
|
||||
);
|
||||
}}
|
||||
title="Верный ответ"
|
||||
aria-label="Пометить как верный"
|
||||
/>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ flex: '1 1 200px' }}
|
||||
value={o.text}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setDraftQuestions((prev) =>
|
||||
prev.map((row, i) => {
|
||||
if (i !== qi) {
|
||||
return row;
|
||||
}
|
||||
return {
|
||||
...row,
|
||||
options: row.options.map((op, j) => (j === oi ? { ...op, text: v } : op)),
|
||||
};
|
||||
})
|
||||
);
|
||||
}}
|
||||
placeholder="Текст варианта"
|
||||
/>
|
||||
{q.options.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn--sm"
|
||||
onClick={() => removeOption(qi, oi)}
|
||||
>
|
||||
убрать
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="inline-actions" style={{ marginTop: 4 }}>
|
||||
<button type="button" className="btn btn-ghost btn--sm" onClick={() => addOption(qi)}>
|
||||
+ вариант
|
||||
</button>
|
||||
{draftQuestions.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn--sm"
|
||||
onClick={() => removeQuestion(qi)}
|
||||
>
|
||||
Удалить вопрос
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="inline-actions" style={{ marginTop: '0.75rem' }}>
|
||||
<button type="button" className="btn btn-ghost" onClick={addQuestion}>
|
||||
+ вопрос
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</AccSection>
|
||||
|
||||
<div className="cabinet-brick" style={{ marginTop: 0 }}>
|
||||
<div
|
||||
className="inline-actions"
|
||||
style={{ flexWrap: 'wrap', gap: '0.5rem', marginBottom: draftStatus ? '0.35rem' : 0 }}
|
||||
>
|
||||
<button type="button" className="btn btn-primary" onClick={saveDraft}>
|
||||
Сохранить черновик
|
||||
</button>
|
||||
<Link to="/tests" className="btn btn-ghost">
|
||||
К списку
|
||||
</Link>
|
||||
</div>
|
||||
{draftStatus && (
|
||||
<p className="muted" style={{ marginTop: 0, marginBottom: 0 }}>
|
||||
{draftStatus}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<AccSection title="История версий" defaultOpen={false}>
|
||||
<div className="surface-card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
<table className="table-cabinet">
|
||||
@@ -853,196 +1150,6 @@ export default function TestDetail() {
|
||||
)}
|
||||
</AccSection>
|
||||
|
||||
<AccSection title="Содержание: название, порог, вопросы" defaultOpen>
|
||||
<div className="draft-block">
|
||||
<label className="form-label" htmlFor="draft-title">
|
||||
Название
|
||||
</label>
|
||||
<input
|
||||
id="draft-title"
|
||||
className="form-input"
|
||||
value={draftTitle}
|
||||
onChange={(e) => setDraftTitle(e.target.value)}
|
||||
/>
|
||||
<label className="form-label" htmlFor="draft-desc" style={{ marginTop: '0.75rem' }}>
|
||||
Описание
|
||||
</label>
|
||||
<textarea
|
||||
id="draft-desc"
|
||||
className="form-input"
|
||||
rows={2}
|
||||
value={draftDescription}
|
||||
onChange={(e) => setDraftDescription(e.target.value)}
|
||||
/>
|
||||
<label className="form-label" htmlFor="draft-pass" style={{ marginTop: '0.75rem' }}>
|
||||
Порог зачёта, %
|
||||
</label>
|
||||
<input
|
||||
id="draft-pass"
|
||||
type="number"
|
||||
className="form-input"
|
||||
min={0}
|
||||
max={100}
|
||||
inputMode="numeric"
|
||||
value={draftPassing}
|
||||
onChange={(e) => setDraftPassing(e.target.value)}
|
||||
style={{ maxWidth: 120 }}
|
||||
/>
|
||||
{draftQuestions.map((q, qi) => (
|
||||
<div
|
||||
key={q.key}
|
||||
style={{
|
||||
borderTop: '1px solid var(--border-subtle, #2a2a2a)',
|
||||
marginTop: '1rem',
|
||||
paddingTop: '1rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="inline-actions"
|
||||
style={{ marginBottom: 6, flexWrap: 'wrap', alignItems: 'center' }}
|
||||
>
|
||||
<label className="form-label" htmlFor={`qtext-${q.key}`} style={{ marginBottom: 0 }}>
|
||||
Вопрос {qi + 1}
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn--sm"
|
||||
disabled={aiQBusy != null}
|
||||
onClick={() => runAiGenerateQuestion(qi)}
|
||||
>
|
||||
{aiQBusy === qi ? '…' : 'Сгенерировать вопрос (ИИ)'}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
id={`qtext-${q.key}`}
|
||||
className="form-input"
|
||||
rows={2}
|
||||
value={q.text}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setDraftQuestions((prev) =>
|
||||
prev.map((row, i) => (i === qi ? { ...row, text: v } : row))
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
className="form-label"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={q.hasMultipleAnswers}
|
||||
onChange={(e) => {
|
||||
const v = e.target.checked;
|
||||
setDraftQuestions((prev) =>
|
||||
prev.map((row, i) => (i === qi ? { ...row, hasMultipleAnswers: v } : row))
|
||||
);
|
||||
}}
|
||||
/>
|
||||
Несколько верных ответов
|
||||
</label>
|
||||
<p className="muted" style={{ marginBottom: 6, fontSize: 13 }}>
|
||||
Варианты
|
||||
</p>
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||
{q.options.map((o, oi) => (
|
||||
<li
|
||||
key={o.key}
|
||||
style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8, marginBottom: 8 }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={o.isCorrect}
|
||||
onChange={(e) => {
|
||||
const v = e.target.checked;
|
||||
setDraftQuestions((prev) =>
|
||||
prev.map((row, i) => {
|
||||
if (i !== qi) {
|
||||
return row;
|
||||
}
|
||||
if (!row.hasMultipleAnswers && v) {
|
||||
return {
|
||||
...row,
|
||||
options: row.options.map((op, j) => ({
|
||||
...op,
|
||||
isCorrect: j === oi,
|
||||
})),
|
||||
};
|
||||
}
|
||||
const options = row.options.map((op, j) =>
|
||||
j === oi ? { ...op, isCorrect: v } : op
|
||||
);
|
||||
return { ...row, options };
|
||||
})
|
||||
);
|
||||
}}
|
||||
title="Верный ответ"
|
||||
aria-label="Пометить как верный"
|
||||
/>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ flex: '1 1 200px' }}
|
||||
value={o.text}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setDraftQuestions((prev) =>
|
||||
prev.map((row, i) => {
|
||||
if (i !== qi) {
|
||||
return row;
|
||||
}
|
||||
return {
|
||||
...row,
|
||||
options: row.options.map((op, j) => (j === oi ? { ...op, text: v } : op)),
|
||||
};
|
||||
})
|
||||
);
|
||||
}}
|
||||
placeholder="Текст варианта"
|
||||
/>
|
||||
{q.options.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn--sm"
|
||||
onClick={() => removeOption(qi, oi)}
|
||||
>
|
||||
убрать
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="inline-actions" style={{ marginTop: 4 }}>
|
||||
<button type="button" className="btn btn-ghost btn--sm" onClick={() => addOption(qi)}>
|
||||
+ вариант
|
||||
</button>
|
||||
{draftQuestions.length > 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn--sm"
|
||||
onClick={() => removeQuestion(qi)}
|
||||
>
|
||||
Удалить вопрос
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="inline-actions" style={{ marginTop: '0.75rem' }}>
|
||||
<button type="button" className="btn btn-ghost" onClick={addQuestion}>
|
||||
+ вопрос
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={saveDraft}>
|
||||
Сохранить черновик
|
||||
</button>
|
||||
</div>
|
||||
{draftStatus && (
|
||||
<p className="muted" style={{ marginTop: '0.5rem' }}>
|
||||
{draftStatus}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</AccSection>
|
||||
|
||||
{assignmentUi && data && (
|
||||
<AccSection title="Назначение сотрудникам" defaultOpen={false}>
|
||||
{assignErr && (
|
||||
|
||||
Reference in New Issue
Block a user