Browse Source

feat(test detail): regroup sections, copy, and editor affordances

- AccSection: optional subtitle; disclosure body top padding
- Renames: О тесте, Вопросы, История (Версии + Прохождения), Показ в каталоге
- Move import into Вопросы as «Документ в вопросы»; remove duplicate import section
- Merge publication + assignment with subheads Видимость / Кому выдать; non-full-width actions
- Assign: «Выбрать всех (N)»; search row height tweaks
- Editor: крестик удаления варианта; единый футер + вариант/удалить вопрос; + вопрос в отдельной полосе
- AI block: test-detail-ai-panel (lighter, aligned with rest)
- History: empty state for runs; attempts error only in section (remove hero duplicate)

Made-with: Cursor
dev
Константин Лебединский 2 weeks ago
parent
commit
f1f5223076
  1. 678
      frontend/src/pages/TestDetail.jsx
  2. 195
      frontend/src/styles/cabinet-theme.css

678
frontend/src/pages/TestDetail.jsx

@ -34,13 +34,18 @@ function createEmptyQuestion() {
} }
/** /**
* @param {{ title: string, defaultOpen?: boolean, id?: string, children: import('react').ReactNode }} p * @param {{ title: string, subtitle?: string, defaultOpen?: boolean, id?: string, children: import('react').ReactNode }} p
*/ */
function AccSection({ title, defaultOpen, id, children }) { function AccSection({ title, subtitle, defaultOpen, id, children }) {
return ( return (
<div className="cabinet-brick"> <div className="cabinet-brick">
<details className="cabinet-disclosure" defaultOpen={defaultOpen} id={id || undefined}> <details className="cabinet-disclosure" defaultOpen={defaultOpen} id={id || undefined}>
<summary className="cabinet-disclosure__summary font-headline">{title}</summary> <summary className="cabinet-disclosure__summary">
<span className="cabinet-disclosure__summary-text">
<span className="cabinet-disclosure__summary-title font-headline">{title}</span>
{subtitle ? <span className="cabinet-disclosure__summary-sub">{subtitle}</span> : null}
</span>
</summary>
<div className="cabinet-disclosure__body">{children}</div> <div className="cabinet-disclosure__body">{children}</div>
</details> </details>
</div> </div>
@ -235,6 +240,16 @@ export default function TestDetail() {
}); });
} }
function selectAllVisible() {
setAssignSelected((prev) => {
const next = new Set(prev);
for (const p of assignPeople) {
next.add(assignPersonKey(p));
}
return next;
});
}
async function postAssign() { async function postAssign() {
const selectedRows = assignPeople.filter((p) => const selectedRows = assignPeople.filter((p) =>
assignSelected.has(assignPersonKey(p)) assignSelected.has(assignPersonKey(p))
@ -657,14 +672,13 @@ export default function TestDetail() {
При сохранении будет создана новая версия теста. При сохранении будет создана новая версия теста.
</div> </div>
)} )}
{data && attemptsErr && (
<p className="error-text" style={{ marginTop: '0.75rem', marginBottom: 0 }} role="alert">
{attemptsErr}
</p>
)}
</div> </div>
<AccSection title="Метаинформация" defaultOpen> <AccSection
title="О тесте"
subtitle="Название, описание и порог зачёта"
defaultOpen
>
<div className="draft-block"> <div className="draft-block">
<label className="form-label" htmlFor="draft-title"> <label className="form-label" htmlFor="draft-title">
Название Название
@ -702,17 +716,13 @@ export default function TestDetail() {
</div> </div>
</AccSection> </AccSection>
<AccSection title="Содержание" defaultOpen> <AccSection
title="Вопросы"
subtitle="Тексты, варианты и при необходимости загрузка из файла"
defaultOpen
>
<div className="draft-block"> <div className="draft-block">
<div <div className="test-detail-ai-panel" aria-label="Параметры генерации теста с ИИ">
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 <p
className="font-headline" className="font-headline"
style={{ fontSize: '0.95rem', marginTop: 0, marginBottom: '0.65rem' }} style={{ fontSize: '0.95rem', marginTop: 0, marginBottom: '0.65rem' }}
@ -792,12 +802,7 @@ export default function TestDetail() {
{draftQuestions.map((q, qi) => ( {draftQuestions.map((q, qi) => (
<div <div
key={q.key} key={q.key}
className="question-editor-block" className={`question-editor-block${qi === 0 ? ' question-editor-block--first' : ''}`}
style={{
borderTop: '1px solid var(--border-subtle, #2a2a2a)',
marginTop: '1rem',
paddingTop: '1rem',
}}
> >
<div className="question-editor-block__header"> <div className="question-editor-block__header">
<label <label
@ -848,11 +853,7 @@ export default function TestDetail() {
</p> </p>
<ul className="question-options-list" style={{ listStyle: 'none', padding: 0, margin: 0 }}> <ul className="question-options-list" style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{q.options.map((o, oi) => ( {q.options.map((o, oi) => (
<li <li key={o.key} className="question-option-row">
key={o.key}
className="question-option-row"
style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8, marginBottom: 8 }}
>
<input <input
key={`${o.key}-${q.hasMultipleAnswers ? 'm' : 's'}`} key={`${o.key}-${q.hasMultipleAnswers ? 'm' : 's'}`}
className="question-option-row__mark" className="question-option-row__mark"
@ -886,8 +887,7 @@ export default function TestDetail() {
aria-label="Пометить как верный" aria-label="Пометить как верный"
/> />
<input <input
className="form-input" className="form-input question-option-row__text"
style={{ flex: '1 1 200px' }}
value={o.text} value={o.text}
onChange={(e) => { onChange={(e) => {
const v = e.target.value; const v = e.target.value;
@ -908,17 +908,25 @@ export default function TestDetail() {
{q.options.length > 1 && ( {q.options.length > 1 && (
<button <button
type="button" type="button"
className="btn btn-ghost btn--sm" className="question-option-remove"
onClick={() => removeOption(qi, oi)} onClick={() => removeOption(qi, oi)}
title="Удалить вариант"
aria-label="Удалить вариант"
> >
убрать <span className="material-symbols-outlined" aria-hidden>
close
</span>
</button> </button>
)} )}
</li> </li>
))} ))}
</ul> </ul>
<div className="inline-actions" style={{ marginTop: 4 }}> <div className="question-editor__footer">
<button type="button" className="btn btn-ghost btn--sm" onClick={() => addOption(qi)}> <button
type="button"
className="btn btn-ghost btn--sm"
onClick={() => addOption(qi)}
>
+ вариант + вариант
</button> </button>
{draftQuestions.length > 1 && ( {draftQuestions.length > 1 && (
@ -933,11 +941,92 @@ export default function TestDetail() {
</div> </div>
</div> </div>
))} ))}
<div className="inline-actions" style={{ marginTop: '0.75rem' }}> <div className="test-detail-add-question">
<button type="button" className="btn btn-ghost" onClick={addQuestion}> <button
type="button"
className="btn btn-ghost question-editor__add-question"
onClick={addQuestion}
>
+ вопрос + вопрос
</button> </button>
</div> </div>
<div className="test-detail-subsection test-detail-subsection--import">
<h3 className="test-detail-subsection__title">Документ в вопросы</h3>
<p className="muted test-detail-hint" style={{ marginTop: 0 }}>
PDF, Word или текст вставьте в черновик вопросов.
</p>
<div
className="import-file-row import-file-row--block"
style={{ marginBottom: importPreview || importErr ? '0.5rem' : 0, marginTop: 0 }}
>
<input
id="import-test-file"
className="import-file-input"
type="file"
accept=".pdf,.docx,.txt,.md,application/pdf"
onChange={onImportFile}
disabled={importBusy}
aria-label="Выбрать файл для импорта"
/>
<label
htmlFor="import-test-file"
className={`btn btn-ghost import-file-label${importBusy ? ' is-busy' : ''}`}
style={importBusy ? { pointerEvents: 'none' } : undefined}
>
{importBusy ? 'Обработка…' : 'Выбрать файл'}
</label>
</div>
{importErr && (
<p className="error-text" role="alert">
{importErr}
</p>
)}
{importPreview && (
<div className="draft-block" style={{ marginTop: '0.65rem', marginBottom: 0 }}>
<p className="muted" style={{ marginTop: 0, marginBottom: 0 }}>
{importPreview.generation?.message}
</p>
{importPreview.generation?.textPreview && !importPreview.generation?.available && (
<pre
className="form-input"
style={{
maxHeight: 180,
overflow: 'auto',
fontSize: 13,
marginTop: 8,
whiteSpace: 'pre-wrap',
}}
>
{importPreview.generation.textPreview}
{importPreview.textLength > 4000 ? '…' : ''}
</pre>
)}
{importPreview.generation?.draft && (
<div className="inline-actions" style={{ marginTop: 8, marginBottom: 0 }}>
<button
type="button"
className="btn btn-ghost"
onClick={applyGeneratedDraft}
>
Применить сгенерированный черновик
</button>
</div>
)}
{importPreview.extractedText && (
<div className="inline-actions" style={{ marginTop: 8, marginBottom: 0 }}>
<button
type="button"
className="btn btn-ghost"
onClick={applyExtractedToQuestion}
>
Вставить в первый вопрос (до 2000 симв.)
</button>
</div>
)}
</div>
)}
</div>
</div> </div>
</AccSection> </AccSection>
@ -959,295 +1048,276 @@ export default function TestDetail() {
</p> </p>
)} )}
</div> </div>
<AccSection title="История версий" defaultOpen={false}> <AccSection
<ul className="version-card-list" aria-label="Список версий теста"> title="История"
{versions.map((r) => ( subtitle="Версии теста и кто проходил"
<li key={r.id} className="surface-card version-card-list__item"> defaultOpen={false}
<div className="version-card-list__row"> >
<div className="version-card-list__main"> <div className="test-detail-subsection test-detail-subsection--tight">
<div className="version-card-list__title-line"> <h3 className="test-detail-subsection__title">Версии</h3>
<span className="font-headline" style={{ fontSize: '1rem' }}> <ul className="version-card-list" aria-label="Список версий теста">
v{r.version} {versions.map((r) => (
</span> <li key={r.id} className="surface-card version-card-list__item">
{r.is_active && ( <div className="version-card-list__row">
<span className="code-inline" style={{ fontSize: '0.7rem' }}> <div className="version-card-list__main">
текущая <div className="version-card-list__title-line">
<span className="font-headline" style={{ fontSize: '1rem' }}>
v{r.version}
</span> </span>
)} {r.is_active && (
</div> <span className="code-inline" style={{ fontSize: '0.7rem' }}>
<p className="muted mono" style={{ margin: '0.4rem 0 0', fontSize: '0.8rem' }}> текущая
{fmtDt(r.created_at)} </span>
</p> )}
<p className="muted" style={{ margin: '0.2rem 0 0', fontSize: '0.8rem' }}>
Активна: {r.is_active ? 'да' : 'нет'}
</p>
</div>
{!r.is_active && (
<button
type="button"
className="btn btn-ghost btn--sm version-card-list__action"
onClick={() => activateVersion(r.id)}
>
Сделать активной
</button>
)}
</div>
</li>
))}
</ul>
</AccSection>
{attemptsList != null && attemptsList.length > 0 && (
<AccSection title="Прогоны и разбор" defaultOpen={false}>
{attemptsErr && (
<p className="error-text" role="alert">
{attemptsErr}
</p>
)}
<ul className="attempts-card-list" aria-label="Список прогонов">
{attemptsList.map((a) => {
const when = a.completedAt
? fmtDt(a.completedAt)
: a.startedAt
? fmtDt(a.startedAt)
: '—';
const result =
a.status === 'completed' && a.totalQuestions != null ? (
<>
{a.correctCount}/{a.totalQuestions}
{a.passed ? ' · зачёт' : ' · незачёт'}
</>
) : (
a.status
);
return (
<li key={a.id} className="surface-card attempts-card-list__item">
<div className="attempts-card-list__row">
<div className="attempts-card-list__main">
<p className="muted attempts-card-list__when mono" style={{ margin: 0, fontSize: '0.8rem' }}>
{when}
</p>
<p className="attempts-card-list__who" style={{ margin: '0.35rem 0 0', fontWeight: 600 }}>
{a.attempterName || '—'}
{a.attempterLogin && (
<span className="code-inline" style={{ fontSize: 11, marginLeft: 6 }}>
{a.attempterLogin}
</span>
)}
</p>
<p className="muted" style={{ margin: '0.25rem 0 0', fontSize: '0.85rem' }}>
v{a.testVersion} ·{' '}
<span className="attempts-card-list__result">{result}</span>
</p>
</div> </div>
{a.status === 'completed' && ( <p className="muted mono" style={{ margin: '0.4rem 0 0', fontSize: '0.8rem' }}>
<Link {fmtDt(r.created_at)}
to={`/tests/${id}/attempts/${a.id}/review`} </p>
className="btn btn-ghost btn--sm attempts-card-list__action" <p className="muted" style={{ margin: '0.2rem 0 0', fontSize: '0.8rem' }}>
> Активна: {r.is_active ? 'да' : 'нет'}
Разбор </p>
</Link>
)}
</div> </div>
</li> {!r.is_active && (
); <button
})} type="button"
className="btn btn-ghost btn--sm version-card-list__action"
onClick={() => activateVersion(r.id)}
>
Сделать активной
</button>
)}
</div>
</li>
))}
</ul> </ul>
</AccSection> </div>
)} {attemptsList !== undefined && (
<div className="test-detail-subsection">
<AccSection title="Публикация (видимость в списке)" defaultOpen={false}> <h3 className="test-detail-subsection__title">Прохождения</h3>
<div {attemptsList == null && (
className="inline-actions inline-actions--block-mobile" <p
style={{ marginTop: '0.5rem' }} className={attemptsErr ? 'error-text' : 'text-muted'}
> style={{ margin: 0 }}
{test?.chainActive !== false ? ( role={attemptsErr ? 'alert' : undefined}
<button >
type="button" {attemptsErr || 'Список прогонов сейчас недоступен.'}
className="btn btn-ghost" </p>
disabled={deactivateBusy} )}
onClick={() => setChainVisible(false)} {attemptsList && attemptsList.length > 0 && (
> <ul className="attempts-card-list" aria-label="Список прогонов">
Скрыть из списка {attemptsList.map((a) => {
</button> const when = a.completedAt
) : ( ? fmtDt(a.completedAt)
<button : a.startedAt
type="button" ? fmtDt(a.startedAt)
className="btn btn-ghost" : '—';
disabled={deactivateBusy} const result =
onClick={() => setChainVisible(true)} a.status === 'completed' && a.totalQuestions != null ? (
> <>
Снова показать в списке {a.correctCount}/{a.totalQuestions}
</button> {a.passed ? ' · зачёт' : ' · незачёт'}
</>
) : (
a.status
);
return (
<li key={a.id} className="surface-card attempts-card-list__item">
<div className="attempts-card-list__row">
<div className="attempts-card-list__main">
<p
className="muted attempts-card-list__when mono"
style={{ margin: 0, fontSize: '0.8rem' }}
>
{when}
</p>
<p
className="attempts-card-list__who"
style={{ margin: '0.35rem 0 0', fontWeight: 600 }}
>
{a.attempterName || '—'}
{a.attempterLogin && (
<span className="code-inline" style={{ fontSize: 11, marginLeft: 6 }}>
{a.attempterLogin}
</span>
)}
</p>
<p className="muted" style={{ margin: '0.25rem 0 0', fontSize: '0.85rem' }}>
v{a.testVersion} ·{' '}
<span className="attempts-card-list__result">{result}</span>
</p>
</div>
{a.status === 'completed' && (
<Link
to={`/tests/${id}/attempts/${a.id}/review`}
className="btn btn-ghost btn--sm attempts-card-list__action"
>
Разбор
</Link>
)}
</div>
</li>
);
})}
</ul>
)}
{attemptsList && attemptsList.length === 0 && (
<p className="text-muted" style={{ margin: 0 }}>
Пока нет зарегистрированных прогонов.
</p>
)}
</div>
)} )}
</div>
</AccSection> </AccSection>
<AccSection title="Импорт из файла" defaultOpen={false}> <AccSection
<div className="import-file-row" style={{ marginBottom: '0.5rem' }}> title="Показ в каталоге"
<input subtitle={
id="import-test-file" assignmentUi && data
className="import-file-input" ? 'Видимость в списке и выдача сотрудникам'
type="file" : 'Показать или скрыть в общем списке'
accept=".pdf,.docx,.txt,.md,application/pdf" }
onChange={onImportFile} defaultOpen={false}
disabled={importBusy} >
aria-label="Выбрать файл для импорта" <div className="test-detail-subsection test-detail-subsection--tight">
/> <h3 className="test-detail-subsection__title">Видимость</h3>
<label <p className="test-detail-hint" style={{ marginTop: 0 }}>
htmlFor="import-test-file" Скрытые тесты в общем списке не показываются; ссылку на тест по-прежнему можно открыть.
className={`btn btn-ghost import-file-label${importBusy ? ' is-busy' : ''}`}
style={importBusy ? { pointerEvents: 'none' } : undefined}
>
{importBusy ? 'Обработка…' : 'Выбрать файл'}
</label>
</div>
{importErr && (
<p className="error-text" role="alert">
{importErr}
</p>
)}
{importPreview && (
<div className="draft-block" style={{ marginBottom: '1rem' }}>
<p className="muted" style={{ marginTop: 0 }}>
{importPreview.generation?.message}
</p> </p>
{importPreview.generation?.textPreview && !importPreview.generation?.available && ( <div className="publication-visibility__actions" style={{ marginTop: 4 }}>
<pre {test?.chainActive !== false ? (
className="form-input"
style={{
maxHeight: 180,
overflow: 'auto',
fontSize: 13,
marginTop: 8,
whiteSpace: 'pre-wrap',
}}
>
{importPreview.generation.textPreview}
{importPreview.textLength > 4000 ? '…' : ''}
</pre>
)}
{importPreview.generation?.draft && (
<div className="inline-actions" style={{ marginTop: 8 }}>
<button <button
type="button" type="button"
className="btn btn-ghost" className="btn btn-ghost"
onClick={applyGeneratedDraft} disabled={deactivateBusy}
onClick={() => setChainVisible(false)}
> >
Применить сгенерированный черновик Скрыть из списка
</button> </button>
</div> ) : (
)}
{importPreview.extractedText && (
<div className="inline-actions" style={{ marginTop: 8 }}>
<button <button
type="button" type="button"
className="btn btn-ghost" className="btn btn-ghost"
onClick={applyExtractedToQuestion} disabled={deactivateBusy}
onClick={() => setChainVisible(true)}
> >
Вставить в первый вопрос (до 2000 симв.) Снова показать в списке
</button> </button>
</div> )}
)} </div>
</div> </div>
)} {assignmentUi && data && (
</AccSection> <div className="test-detail-subsection">
<h3 className="test-detail-subsection__title">Кому выдать</h3>
{assignmentUi && data && ( <p className="test-detail-hint" style={{ marginTop: 0 }}>
<AccSection title="Назначение сотрудникам" defaultOpen={false}> Список с учётом поиска и фильтров; можно отметить всех на экране.
{assignErr && (
<p className="error-text" role="alert">
{assignErr}
</p> </p>
)} {assignErr && (
<div className="assign-toolbar"> <p className="error-text" role="alert" style={{ marginTop: 0, marginBottom: 8 }}>
<input {assignErr}
type="search" </p>
className="form-input assign-toolbar__search" )}
placeholder="Поиск: ФИО, логин" <div className="assign-toolbar">
value={assignSearch} <input
onChange={(e) => setAssignSearch(e.target.value)} type="search"
aria-label="Поиск сотрудника" className="form-input assign-toolbar__search"
/> placeholder="Поиск: ФИО, логин"
<select value={assignSearch}
className="form-input" onChange={(e) => setAssignSearch(e.target.value)}
value={assignDept} aria-label="Поиск сотрудника"
onChange={(e) => setAssignDept(e.target.value)} />
aria-label="Фильтр по отделу" <select
> className="form-input"
<option value="__all__">Все отделы</option> value={assignDept}
{assignDepts.map((d) => ( onChange={(e) => setAssignDept(e.target.value)}
<option key={d} value={d}> aria-label="Фильтр по отделу"
{d} >
</option> <option value="__all__">Все отделы</option>
))} {assignDepts.map((d) => (
</select> <option key={d} value={d}>
<select {d}
className="form-input" </option>
value={assignClinic} ))}
onChange={(e) => setAssignClinic(e.target.value)} </select>
aria-label="Кто в модуле" <select
> className="form-input"
<option value="all">Все</option> value={assignClinic}
<option value="with">С учёткой в модуле</option> onChange={(e) => setAssignClinic(e.target.value)}
<option value="without">Без учётки (создать)</option> aria-label="Кто в модуле"
</select> >
{assignLoadBusy && <span className="text-muted">Загрузка</span>} <option value="all">Все</option>
</div> <option value="with">С учёткой в модуле</option>
{assignPeople.length > 0 ? ( <option value="without">Без учётки (создать)</option>
<div className="assign-list" role="group" aria-label="Список сотрудников"> </select>
{assignPeople.map((p) => { {assignLoadBusy && <span className="text-muted">Загрузка</span>}
const k = assignPersonKey(p); </div>
const picked = assignSelected.has(k); {assignPeople.length > 0 && (
return ( <div
<label className="publication-visibility__actions"
key={k} style={{ marginTop: '0.45rem', marginBottom: '0.25rem' }}
className={`assign-row${picked ? ' assign-row--selected' : ''}`} >
> <button
<input type="button"
type="checkbox" className="btn btn-ghost btn--sm"
checked={picked} onClick={selectAllVisible}
onChange={() => toggleAssignPerson(p)} disabled={assignLoadBusy}
/> >
<span className="assign-row__text"> Выбрать всех ({assignPeople.length})
<span className="assign-row__fio">{p.fio}</span> </button>
{p.webLogin && ( </div>
<span className="assign-row__login">{p.webLogin}</span> )}
)} {assignPeople.length > 0 ? (
<span className="assign-row__meta"> <div className="assign-list" role="group" aria-label="Список сотрудников">
{p.departments || '—'} {assignPeople.map((p) => {
{p.clinicUserId const k = assignPersonKey(p);
? ' · есть учётка в модуле' const picked = assignSelected.has(k);
: ' · нет учётки (создадим при назначении)'} return (
<label
key={k}
className={`assign-row${picked ? ' assign-row--selected' : ''}`}
>
<input
type="checkbox"
checked={picked}
onChange={() => toggleAssignPerson(p)}
/>
<span className="assign-row__text">
<span className="assign-row__fio">{p.fio}</span>
{p.webLogin && <span className="assign-row__login">{p.webLogin}</span>}
<span className="assign-row__meta">
{p.departments || '—'}
{p.clinicUserId
? ' · есть учётка в модуле'
: ' · нет учётки (создадим при назначении)'}
</span>
</span> </span>
</span> </label>
</label> );
); })}
})} </div>
) : (
!assignLoadBusy && (
<p className="text-muted" style={{ margin: '0.5rem 0' }}>
Нет подходящих записей. Смените фильтры или поиск.
</p>
)
)}
<div className="publication-visibility__actions" style={{ marginTop: '0.75rem' }}>
<button
type="button"
className="btn btn-ghost"
disabled={assignSelectedInList.length === 0}
onClick={postAssign}
>
Назначить выбранных
{assignSelectedInList.length > 0
? ` (${assignSelectedInList.length})`
: ''}
</button>
</div> </div>
) : ( {assignMsg && <p className="text-muted" style={{ marginTop: 8, marginBottom: 0 }}>{assignMsg}</p>}
!assignLoadBusy && (
<p className="text-muted" style={{ margin: '0.5rem 0' }}>
Нет подходящих записей. Смените фильтры или поиск.
</p>
)
)}
<div className="inline-actions" style={{ marginTop: '0.75rem' }}>
<button
type="button"
className="btn btn-ghost"
disabled={assignSelectedInList.length === 0}
onClick={postAssign}
>
Назначить выбранных
{assignSelectedInList.length > 0
? ` (${assignSelectedInList.length})`
: ''}
</button>
</div> </div>
{assignMsg && <p className="text-muted">{assignMsg}</p>} )}
</AccSection> </AccSection>
)}
<div <div
className="test-detail-fixed-actions" className="test-detail-fixed-actions"

195
frontend/src/styles/cabinet-theme.css

@ -605,6 +605,29 @@ code,
min-height: 2.75rem; min-height: 2.75rem;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem;
}
.cabinet-disclosure__summary-text {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.15rem;
min-width: 0;
text-align: left;
}
.cabinet-disclosure__summary-title {
font-size: 1.05rem;
line-height: 1.25;
}
.cabinet-disclosure__summary-sub {
display: block;
font-size: 0.8rem;
font-weight: 400;
line-height: 1.3;
color: var(--secondary);
} }
.cabinet-disclosure__summary::-webkit-details-marker { .cabinet-disclosure__summary::-webkit-details-marker {
@ -626,7 +649,7 @@ code,
} }
.cabinet-disclosure__body { .cabinet-disclosure__body {
padding: 0 1rem 1.05rem; padding: 0.7rem 1rem 1.05rem;
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent); border-top: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent);
} }
@ -657,19 +680,160 @@ code,
.assign-toolbar__search { .assign-toolbar__search {
flex: 1 1 200px; flex: 1 1 200px;
min-height: 2.5rem;
max-height: 2.75rem;
padding-top: 6px;
padding-bottom: 6px;
line-height: 1.25;
box-sizing: border-box;
} }
/* Назначение: компактнее поля на мобилке; 16px — без авто-зума iOS в поле */ /* Назначение: селекты — как раньше; поиск — одна «строка», не «плитка» */
@media (max-width: 640px) { @media (max-width: 640px) {
.assign-toolbar { .assign-toolbar {
gap: 0.4rem; gap: 0.4rem;
} }
.assign-toolbar .form-input { .assign-toolbar .form-input {
padding: 8px 12px;
font-size: 16px; font-size: 16px;
line-height: 1.3; line-height: 1.3;
} }
.assign-toolbar__search {
font-size: 16px;
line-height: 1.3;
}
.assign-toolbar .form-input:not(.assign-toolbar__search) {
padding: 8px 12px;
}
}
/* Подсекции и подсказки (карточка теста) */
.test-detail-subsection {
margin-top: 1.25rem;
padding-top: 1.15rem;
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 32%, transparent);
}
.test-detail-subsection:first-of-type,
.test-detail-subsection--tight {
margin-top: 0;
padding-top: 0;
border-top: none;
}
.test-detail-subsection--import {
margin-top: 1.35rem;
padding-top: 1.2rem;
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 32%, transparent);
}
.test-detail-subsection__title {
margin: 0 0 0.35rem;
font-size: 0.95rem;
font-weight: 600;
font-family: inherit;
color: var(--on-surface);
letter-spacing: -0.01em;
}
.test-detail-hint {
margin: 0 0 0.6rem;
font-size: 0.8rem;
line-height: 1.4;
color: var(--secondary);
}
/* Панель «ИИ» — в тон остальным disclosure, без лишнего «модальности» */
.test-detail-ai-panel {
padding: 0.9rem 1rem;
margin-bottom: 1.15rem;
background: var(--surface-container-low);
border: 1px solid color-mix(in srgb, var(--outline-variant) 32%, transparent);
border-radius: 0.85rem;
box-shadow: none;
}
/* Кнопка публикации — не тянем на 100% ширины, если одна */
.publication-visibility__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
margin: 0;
}
.publication-visibility__actions .btn {
width: auto;
min-width: min(100%, 11rem);
max-width: 100%;
box-sizing: border-box;
}
/* Убрать вариант: иконка */
.question-option-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
min-width: 2.25rem;
height: 2.25rem;
margin: 0;
padding: 0;
border: none;
border-radius: 0.65rem;
background: transparent;
color: var(--secondary);
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.question-option-remove:hover {
background: color-mix(in srgb, var(--error) 8%, var(--surface));
color: var(--error, #b91c1c);
}
.question-option-remove .material-symbols-outlined {
font-size: 1.2rem;
line-height: 1;
}
/* Футер вопроса: + вариант, удалить вопрос */
.question-editor__footer {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem 0.65rem;
margin-top: 0.4rem;
}
.question-editor__footer .btn--sm,
.question-editor__add-question {
font-size: 0.8rem;
}
/* Добавить вопрос — в конец блока «Вопросы» */
.test-detail-add-question {
display: flex;
justify-content: flex-start;
margin-top: 1.1rem;
padding-top: 0.85rem;
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 28%, transparent);
}
/* Импорт: кнопка на всю ширину в колонку; ритм отступов */
@media (max-width: 640px) {
.import-file-row--block {
flex-direction: column;
align-items: stretch;
}
.import-file-label {
display: block;
width: 100%;
text-align: center;
}
} }
.assign-list { .assign-list {
@ -986,6 +1150,31 @@ code,
/* --- Спринт 2: редактор вопроса, прогоны, импорт, фикс. футер --- */ /* --- Спринт 2: редактор вопроса, прогоны, импорт, фикс. футер --- */
.question-editor-block {
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 32%, transparent);
margin-top: 1.05rem;
padding-top: 1.05rem;
}
.question-editor-block--first {
border-top: none;
margin-top: 0;
padding-top: 0;
}
.question-option-row {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.question-option-row__text {
flex: 1 1 10rem;
min-width: 0;
}
.question-editor-block__header { .question-editor-block__header {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

Loading…
Cancel
Save