Browse Source
- Сервисы: testAttemptService, testAccess, document import/gen/extract, LLM, assignment, aiEditor - Конфиг: devAuthor, featureFlags; messages/ru; интеграция V.9 (skip без БД) - API/роуты: app, auth, server; Dockerfile и env example - Фронт: TestAttempt, TestAttemptReview, AttemptReviewBlock, стили, правки App/api/login/vite - compose и README; смоук-тесты расширены Закрывает отсутствие модулей в origin после клона. Made-with: Cursordev
38 changed files with 3682 additions and 490 deletions
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,26 @@ |
|||||||
|
/** |
||||||
|
* V.9 — минимальные проверки HTTP без БД: health и 401 на защищённых маршрутах. |
||||||
|
* Интеграции с Postgres — см. отдельные сценарии / ручной журнал. |
||||||
|
*/ |
||||||
|
import { test } from 'node:test'; |
||||||
|
import assert from 'node:assert/strict'; |
||||||
|
import request from 'supertest'; |
||||||
|
import { createApp } from './app.js'; |
||||||
|
import { RU } from './messages/ru.js'; |
||||||
|
|
||||||
|
const app = createApp(); |
||||||
|
|
||||||
|
test('GET /api/health — 200 и status ok', async () => { |
||||||
|
const res = await request(app).get('/api/health').expect(200); |
||||||
|
assert.equal(res.body.status, 'ok'); |
||||||
|
}); |
||||||
|
|
||||||
|
test('GET /api/tests без cookie — 401', async () => { |
||||||
|
const res = await request(app).get('/api/tests').expect(401); |
||||||
|
assert.equal(res.body.error, RU.authRequired); |
||||||
|
}); |
||||||
|
|
||||||
|
test('GET /api/__no_route__ — 404 на русском', async () => { |
||||||
|
const res = await request(app).get('/api/__no_route__').expect(404); |
||||||
|
assert.equal(res.body.error, RU.notFound); |
||||||
|
}); |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
/** |
||||||
|
* Правка цепочки теста (черновик, версии, публикация, редактор) — только создатель (`tests.created_by`). |
||||||
|
*/ |
||||||
|
export function isTestAuthor(createdBy, userId) { |
||||||
|
return createdBy === userId; |
||||||
|
} |
||||||
@ -0,0 +1,18 @@ |
|||||||
|
/** |
||||||
|
* Флаги продуктовых фич (env). В development ряд вещей включён по умолчанию. |
||||||
|
*/ |
||||||
|
|
||||||
|
/** API и UI: назначение тестов сотрудникам (каталог HR + POST /tests/:id/assign). */ |
||||||
|
export function isAssignmentFeatureEnabled() { |
||||||
|
if (process.env.NODE_ENV === 'development') { |
||||||
|
return true; |
||||||
|
} |
||||||
|
const v = (process.env.CLINIC_ASSIGNMENT_ENABLED || '').toLowerCase(); |
||||||
|
if (v === '1' || v === 'true' || v === 'yes') { |
||||||
|
return true; |
||||||
|
} |
||||||
|
if (v === '0' || v === 'false' || v === 'no') { |
||||||
|
return false; |
||||||
|
} |
||||||
|
return false; |
||||||
|
} |
||||||
@ -0,0 +1,234 @@ |
|||||||
|
/** |
||||||
|
* Card1 V.9: интеграция с реальной `clinic_tests` — старая попытка остаётся |
||||||
|
* на снимке версии и старых `question_id` после форка (новая версия). |
||||||
|
* |
||||||
|
* Запуск: `CLINIC_TESTS_INTEGRATION=1` и применённые миграции (`npm run migrate`), |
||||||
|
* `DATABASE_URL` (или DB_*) к той же базе. Без флага тесты помечаются skip. |
||||||
|
*/ |
||||||
|
import { test } from 'node:test'; |
||||||
|
import assert from 'node:assert/strict'; |
||||||
|
import pg from 'pg'; |
||||||
|
import bcrypt from 'bcryptjs'; |
||||||
|
import { getPoolConfig } from '../db/poolConfig.js'; |
||||||
|
import { saveTestDraft, createTestWithVersion } from '../services/testDraftService.js'; |
||||||
|
|
||||||
|
const { Pool } = pg; |
||||||
|
|
||||||
|
/** `CLINIC_TESTS_INTEGRATION=1` и успешный `SELECT 1` (без БД — skip, не fail). */ |
||||||
|
let runDb = false; |
||||||
|
if (process.env.CLINIC_TESTS_INTEGRATION === '1') { |
||||||
|
const probe = new Pool({ |
||||||
|
...getPoolConfig(), |
||||||
|
connectionTimeoutMillis: 2000, |
||||||
|
}); |
||||||
|
try { |
||||||
|
await probe.query('SELECT 1'); |
||||||
|
runDb = true; |
||||||
|
} catch { |
||||||
|
runDb = false; |
||||||
|
} finally { |
||||||
|
await probe.end(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const qPayload = (label) => ({ |
||||||
|
title: 'V9 ' + label, |
||||||
|
questions: [ |
||||||
|
{ |
||||||
|
text: `Q ${label}`, |
||||||
|
question_order: 1, |
||||||
|
hasMultipleAnswers: false, |
||||||
|
options: [ |
||||||
|
{ text: 'yes', isCorrect: true, option_order: 1 }, |
||||||
|
{ text: 'no', isCorrect: false, option_order: 2 }, |
||||||
|
], |
||||||
|
}, |
||||||
|
], |
||||||
|
}); |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {import('pg').Pool} pool |
||||||
|
* @param {string} testId |
||||||
|
* @param {string} [exceptUserId] |
||||||
|
*/ |
||||||
|
async function purgeTestChain(pool, testId, exceptUserId) { |
||||||
|
await pool.query( |
||||||
|
`DELETE FROM user_answers WHERE attempt_id IN (
|
||||||
|
SELECT id FROM test_attempts WHERE test_version_id IN ( |
||||||
|
SELECT id FROM test_versions WHERE test_id = $1 |
||||||
|
) |
||||||
|
)`,
|
||||||
|
[testId] |
||||||
|
); |
||||||
|
await pool.query( |
||||||
|
`DELETE FROM test_attempts WHERE test_version_id IN (
|
||||||
|
SELECT id FROM test_versions WHERE test_id = $1 |
||||||
|
)`,
|
||||||
|
[testId] |
||||||
|
); |
||||||
|
await pool.query( |
||||||
|
`DELETE FROM answer_options WHERE question_id IN (
|
||||||
|
SELECT id FROM questions WHERE test_version_id IN ( |
||||||
|
SELECT id FROM test_versions WHERE test_id = $1 |
||||||
|
) |
||||||
|
)`,
|
||||||
|
[testId] |
||||||
|
); |
||||||
|
await pool.query( |
||||||
|
`DELETE FROM questions WHERE test_version_id IN (
|
||||||
|
SELECT id FROM test_versions WHERE test_id = $1 |
||||||
|
)`,
|
||||||
|
[testId] |
||||||
|
); |
||||||
|
await pool.query(`DELETE FROM test_versions WHERE test_id = $1`, [testId]); |
||||||
|
await pool.query(`DELETE FROM tests WHERE id = $1`, [testId]); |
||||||
|
if (exceptUserId) { |
||||||
|
await pool.query(`DELETE FROM users WHERE id = $1`, [exceptUserId]); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
test( |
||||||
|
'V.9: без попыток два saveTestDraft — одна строка test_versions (редактирование на месте)', |
||||||
|
{ skip: !runDb }, |
||||||
|
async () => { |
||||||
|
const pool = new Pool(getPoolConfig()); |
||||||
|
const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; |
||||||
|
let userId; |
||||||
|
let testId; |
||||||
|
try { |
||||||
|
const { rows: u } = await pool.query( |
||||||
|
`INSERT INTO users (login, password_hash, full_name, role, is_active)
|
||||||
|
VALUES ($1, $2, 'V9 in-place', 'hr', true) RETURNING id`,
|
||||||
|
[`v9p-${suffix}`, bcrypt.hashSync('x', 4)] |
||||||
|
); |
||||||
|
userId = u[0].id; |
||||||
|
const c = await createTestWithVersion(pool, userId, { title: 'V9P' }); |
||||||
|
testId = c.testId; |
||||||
|
const { rows: v0 } = await pool.query( |
||||||
|
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true`, |
||||||
|
[testId] |
||||||
|
); |
||||||
|
const vid0 = v0[0].id; |
||||||
|
await saveTestDraft(pool, userId, testId, qPayload('A')); |
||||||
|
const { rows: c1 } = await pool.query( |
||||||
|
`SELECT count(*)::int AS n FROM test_versions WHERE test_id = $1`, |
||||||
|
[testId] |
||||||
|
); |
||||||
|
assert.equal(c1[0].n, 1, 'должна остаться одна версия'); |
||||||
|
const { rows: v1 } = await pool.query( |
||||||
|
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true`, |
||||||
|
[testId] |
||||||
|
); |
||||||
|
assert.equal( |
||||||
|
v1[0].id, |
||||||
|
vid0, |
||||||
|
'id активной версии не меняется при нуле попыток' |
||||||
|
); |
||||||
|
await saveTestDraft(pool, userId, testId, qPayload('B')); |
||||||
|
const { rows: c2 } = await pool.query( |
||||||
|
`SELECT count(*)::int AS n FROM test_versions WHERE test_id = $1`, |
||||||
|
[testId] |
||||||
|
); |
||||||
|
assert.equal(c2[0].n, 1); |
||||||
|
const { rows: v2 } = await pool.query( |
||||||
|
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true`, |
||||||
|
[testId] |
||||||
|
); |
||||||
|
assert.equal(v2[0].id, vid0); |
||||||
|
} finally { |
||||||
|
if (userId && testId) { |
||||||
|
await purgeTestChain(pool, testId, userId); |
||||||
|
} |
||||||
|
await pool.end(); |
||||||
|
} |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
test( |
||||||
|
'V.9: после попытки форк — попытка и user_answers остаются на старых version_id / question_id', |
||||||
|
{ skip: !runDb }, |
||||||
|
async () => { |
||||||
|
const pool = new Pool(getPoolConfig()); |
||||||
|
const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`; |
||||||
|
let userId; |
||||||
|
let testId; |
||||||
|
let v1Id; |
||||||
|
let q1Id; |
||||||
|
let opt1Id; |
||||||
|
let attemptId; |
||||||
|
try { |
||||||
|
const { rows: u } = await pool.query( |
||||||
|
`INSERT INTO users (login, password_hash, full_name, role, is_active)
|
||||||
|
VALUES ($1, $2, 'V9 fork', 'hr', true) RETURNING id`,
|
||||||
|
[`v9f-${suffix}`, bcrypt.hashSync('x', 4)] |
||||||
|
); |
||||||
|
userId = u[0].id; |
||||||
|
const c = await createTestWithVersion(pool, userId, { title: 'V9F' }); |
||||||
|
testId = c.testId; |
||||||
|
await saveTestDraft(pool, userId, testId, qPayload('pre')); |
||||||
|
|
||||||
|
const { rows: tv0 } = await pool.query( |
||||||
|
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true`, |
||||||
|
[testId] |
||||||
|
); |
||||||
|
v1Id = tv0[0].id; |
||||||
|
const { rows: qu } = await pool.query( |
||||||
|
`SELECT id FROM questions WHERE test_version_id = $1 LIMIT 1`, |
||||||
|
[v1Id] |
||||||
|
); |
||||||
|
q1Id = qu[0].id; |
||||||
|
const { rows: op } = await pool.query( |
||||||
|
`SELECT id FROM answer_options WHERE question_id = $1 AND is_correct = true LIMIT 1`, |
||||||
|
[q1Id] |
||||||
|
); |
||||||
|
opt1Id = op[0].id; |
||||||
|
|
||||||
|
const { rows: at } = await pool.query( |
||||||
|
`INSERT INTO test_attempts (test_version_id, user_id, attempt_number, status, correct_count, total_questions, passed)
|
||||||
|
VALUES ($1, $2, 1, 'completed', 1, 1, true) RETURNING id`,
|
||||||
|
[v1Id, userId] |
||||||
|
); |
||||||
|
attemptId = at[0].id; |
||||||
|
await pool.query( |
||||||
|
`INSERT INTO user_answers (attempt_id, question_id, selected_options) VALUES ($1, $2, $3::uuid[])`, |
||||||
|
[attemptId, q1Id, [opt1Id]] |
||||||
|
); |
||||||
|
|
||||||
|
const out = await saveTestDraft(pool, userId, testId, qPayload('post-fork')); |
||||||
|
assert.equal(out.forked, true, 'должна создаться новая версия после попытки'); |
||||||
|
|
||||||
|
const { rows: att } = await pool.query( |
||||||
|
`SELECT test_version_id FROM test_attempts WHERE id = $1`, |
||||||
|
[attemptId] |
||||||
|
); |
||||||
|
assert.equal( |
||||||
|
att[0].test_version_id, |
||||||
|
v1Id, |
||||||
|
'попытка остаётся на версии, с которой проходили' |
||||||
|
); |
||||||
|
const { rows: ua } = await pool.query( |
||||||
|
`SELECT question_id, selected_options FROM user_answers WHERE attempt_id = $1`, |
||||||
|
[attemptId] |
||||||
|
); |
||||||
|
assert.equal(ua[0].question_id, q1Id); |
||||||
|
assert.equal(ua[0].selected_options[0], opt1Id); |
||||||
|
|
||||||
|
const { rows: qExists } = await pool.query( |
||||||
|
`SELECT 1 FROM questions WHERE id = $1 AND test_version_id = $2`, |
||||||
|
[q1Id, v1Id] |
||||||
|
); |
||||||
|
assert.equal(qExists.length, 1, 'старый вопрос остаётся в старой версии'); |
||||||
|
|
||||||
|
const { rows: active } = await pool.query( |
||||||
|
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true`, |
||||||
|
[testId] |
||||||
|
); |
||||||
|
assert.notEqual(active[0].id, v1Id, 'новая версия — активна'); |
||||||
|
} finally { |
||||||
|
if (userId && testId) { |
||||||
|
await purgeTestChain(pool, testId, userId); |
||||||
|
} |
||||||
|
await pool.end(); |
||||||
|
} |
||||||
|
} |
||||||
|
); |
||||||
@ -0,0 +1,41 @@ |
|||||||
|
/** Тексты ответов API для пользователей (русский). */ |
||||||
|
export const RU = { |
||||||
|
loginAndPasswordRequired: 'Укажите логин и пароль.', |
||||||
|
invalidCredentials: 'Неверный логин или пароль.', |
||||||
|
useHrLogin: 'Войдите через учётную запись кадровой системы (тот же логин, что в HR).', |
||||||
|
hrDatabaseUrlMissing: |
||||||
|
'База кадровой системы не настроена: задайте HR_DATABASE_URL на backend.', |
||||||
|
hrDatabaseNotConfigured: 'База кадровой системы не настроена.', |
||||||
|
noStaffForLogin: |
||||||
|
'К учётной записи не привязан сотрудник: в HR в карточке сотрудника должно совпадать поле веб-логина (web_login) с логином входа, как в кабинете сотрудника.', |
||||||
|
loggedOut: 'Вы вышли из системы.', |
||||||
|
logoutFailed: 'Не удалось выйти. Повторите попытку.', |
||||||
|
userDataFailed: 'Не удалось загрузить данные пользователя.', |
||||||
|
loginFailed: 'Ошибка входа. Повторите попытку.', |
||||||
|
authRequired: 'Требуется вход в систему.', |
||||||
|
tokenInvalid: 'Сессия истекла или недействительна. Войдите снова.', |
||||||
|
userNotFound: 'Пользователь не найден.', |
||||||
|
authError: 'Ошибка проверки доступа.', |
||||||
|
insufficientPermissions: 'Недостаточно прав.', |
||||||
|
departmentAccessDenied: 'Нет доступа к этому подразделению.', |
||||||
|
notFound: 'Не найдено.', |
||||||
|
fileFieldRequired: 'Прикрепите файл к полю file.', |
||||||
|
uploadFailed: 'Не удалось принять файл.', |
||||||
|
titleRequired: 'Укажите название.', |
||||||
|
assignmentUserRequired: 'Передайте userId (UUID) или staffId (число, сотрудник из HR).', |
||||||
|
assignmentUserOrStaff: 'Укажите только userId, или только staffId — не оба сразу.', |
||||||
|
testNotFound: 'Тест не найден.', |
||||||
|
forbidden: 'Доступ запрещён.', |
||||||
|
versionNotFound: 'Версия не найдена.', |
||||||
|
chainActiveRequired: 'Передайте chainActive: true/false в теле запроса.', |
||||||
|
noActiveVersion: 'Нет активной версии теста.', |
||||||
|
internal: 'Внутренняя ошибка сервера.', |
||||||
|
fileTooLarge: 'Файл слишком большой (максимум 10 МБ).', |
||||||
|
unsupportedFileType: |
||||||
|
'Неподдерживаемый формат. Допустимы: PDF, DOCX, TXT, MD.', |
||||||
|
attemptNotFound: 'Попытка не найдена.', |
||||||
|
attemptNotInProgress: 'Попытка уже завершена или просрочена.', |
||||||
|
attemptNotCompleted: 'Попытка ещё не завершена — подробный разбор доступен после отправки ответов.', |
||||||
|
testHasNoQuestions: 'В активной версии нет вопросов. Добавьте вопросы и сохраните черновик.', |
||||||
|
invalidOptionForQuestion: 'Выбран вариант ответа, не относящийся к вопросу.', |
||||||
|
}; |
||||||
@ -0,0 +1,197 @@ |
|||||||
|
/** |
||||||
|
* Генерация теста/вопроса в редакторе: строгая сетка (число вопросов и вариантов) из UI. |
||||||
|
*/ |
||||||
|
import { getLlmConfig, chatCompletionTextContent } from './llmClient.js'; |
||||||
|
import { |
||||||
|
parseJsonFromLlmText, |
||||||
|
validateAndNormalizeDraft, |
||||||
|
} from './documentGenService.js'; |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {unknown} s |
||||||
|
* @returns {{ optionsCount: number, hasMultipleAnswers: boolean }[]} |
||||||
|
*/ |
||||||
|
export function parseAndValidateShape(s) { |
||||||
|
if (!Array.isArray(s) || s.length === 0) { |
||||||
|
const e = new Error('Передайте непустой массив shape: [{ optionsCount, hasMultipleAnswers }, ...].'); |
||||||
|
e.status = 400; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
if (s.length > 40) { |
||||||
|
const e = new Error('Не более 40 вопросов за раз.'); |
||||||
|
e.status = 400; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
return s.map((row, i) => { |
||||||
|
if (!row || typeof row !== 'object') { |
||||||
|
const e = new Error(`shape[${i}]: ожидается объект.`); |
||||||
|
e.status = 400; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
const n = Math.floor(Number((/** @type {any} */ (row)).optionsCount)); |
||||||
|
const hasMultipleAnswers = Boolean((/** @type {any} */ (row)).hasMultipleAnswers); |
||||||
|
if (!Number.isFinite(n) || n < 2 || n > 12) { |
||||||
|
const e = new Error(`shape[${i}]: optionsCount от 2 до 12.`); |
||||||
|
e.status = 400; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
return { optionsCount: n, hasMultipleAnswers }; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {any} o parsed draft |
||||||
|
* @param {Array<{ optionsCount: number, hasMultipleAnswers: boolean }>} shape |
||||||
|
*/ |
||||||
|
export function assertDraftMatchesShape(o, shape) { |
||||||
|
if (!o?.questions || !Array.isArray(o.questions)) { |
||||||
|
const e = new Error('В ответе нет questions.'); |
||||||
|
e.code = 'llm_shape'; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
if (o.questions.length !== shape.length) { |
||||||
|
const e = new Error( |
||||||
|
`Ожидалось вопросов: ${shape.length}, в ответе: ${o.questions.length}.` |
||||||
|
); |
||||||
|
e.code = 'llm_shape'; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
for (let i = 0; i < shape.length; i++) { |
||||||
|
const q = o.questions[i]; |
||||||
|
const sh = shape[i]; |
||||||
|
if (!q?.options || !Array.isArray(q.options)) { |
||||||
|
const e = new Error(`Вопрос ${i + 1}: нет options.`); |
||||||
|
e.code = 'llm_shape'; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
if (q.options.length !== sh.optionsCount) { |
||||||
|
const e = new Error( |
||||||
|
`Вопрос ${i + 1}: ожидалось вариантов ${sh.optionsCount}, в ответе: ${q.options.length}.` |
||||||
|
); |
||||||
|
e.code = 'llm_shape'; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
if (Boolean(q.hasMultipleAnswers) !== sh.hasMultipleAnswers) { |
||||||
|
const e = new Error( |
||||||
|
`Вопрос ${i + 1}: hasMultipleAnswers должен быть ${sh.hasMultipleAnswers}.` |
||||||
|
); |
||||||
|
e.code = 'llm_shape'; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {string} testTitle |
||||||
|
* @param {string} testDescription |
||||||
|
* @param {Array<{ optionsCount: number, hasMultipleAnswers: boolean }>} shape |
||||||
|
*/ |
||||||
|
export async function generateFullTestByShape(testTitle, testDescription, shape) { |
||||||
|
const cfg = getLlmConfig(); |
||||||
|
if (!cfg) { |
||||||
|
const e = new Error('Задайте DEEPSEEK_API_KEY или OPENAI_API_KEY на сервере.'); |
||||||
|
/** @type {any} */ (e).status = 503; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
const title = (testTitle || '').trim() || 'Тест'; |
||||||
|
const desc = (testDescription || '').trim(); |
||||||
|
const lines = shape.map( |
||||||
|
(sh, i) => |
||||||
|
`Вопрос ${i + 1}: ровно ${sh.optionsCount} вариантов ответа; ${ |
||||||
|
sh.hasMultipleAnswers |
||||||
|
? 'несколько вариантов помечены как верные (hasMultipleAnswers: true).' |
||||||
|
: 'ровно один верный вариант (hasMultipleAnswers: false).' |
||||||
|
}` |
||||||
|
); |
||||||
|
const system = |
||||||
|
'Ты составитель учебных тестов. Отвечай ТОЛЬКО одним JSON-объектом на русском. Схема: {"title": string, "description": string (может быть пустой строкой), "questions": array}. Каждый вопрос: {"text", "hasMultipleAnswers", "options": [{ "text", "isCorrect" }]}.'; |
||||||
|
const user = `Составь тест по теме.
|
||||||
|
|
||||||
|
Название (можно уточнить, но смысл сохранить): ${title} |
||||||
|
Краткое описание / контекст темы: ${desc || 'не указано; придумай согласованную тему с названием.'} |
||||||
|
|
||||||
|
Соблюди СТРОГО число вопросов и вариантов (не больше и не меньше): |
||||||
|
${lines.join('\n')} |
||||||
|
|
||||||
|
Правила: варианты — осмысленные, по теме; отметь isCorrect согласно hasMultipleAnswers; для одного правильного — ровна одна true.`;
|
||||||
|
|
||||||
|
const raw = await chatCompletionTextContent(cfg, system, user, 0.35); |
||||||
|
const parsed = parseJsonFromLlmText(raw); |
||||||
|
const draft = validateAndNormalizeDraft(parsed); |
||||||
|
assertDraftMatchesShape({ questions: draft.questions }, shape); |
||||||
|
return { |
||||||
|
title: draft.title, |
||||||
|
description: draft.description, |
||||||
|
questions: draft.questions, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Пустой вопрос → сгенерировать формулировки; непустой → переформулировать только текст вопроса. |
||||||
|
* @param {string} testTitle |
||||||
|
* @param {string} testDescription |
||||||
|
* @param {string} questionText |
||||||
|
* @param {number} optionsCount |
||||||
|
* @param {boolean} hasMultipleAnswers |
||||||
|
*/ |
||||||
|
export async function generateOrRephraseQuestion( |
||||||
|
testTitle, |
||||||
|
testDescription, |
||||||
|
questionText, |
||||||
|
optionsCount, |
||||||
|
hasMultipleAnswers |
||||||
|
) { |
||||||
|
const cfg = getLlmConfig(); |
||||||
|
if (!cfg) { |
||||||
|
const e = new Error('Задайте DEEPSEEK_API_KEY или OPENAI_API_KEY на сервере.'); |
||||||
|
/** @type {any} */ (e).status = 503; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
const n = Math.floor(Number(optionsCount)); |
||||||
|
if (!Number.isFinite(n) || n < 2 || n > 12) { |
||||||
|
const e = new Error('optionsCount: от 2 до 12.'); |
||||||
|
e.status = 400; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
const topic = `${(testTitle || '').trim() || 'Тест'}. ${(testDescription || '').trim()}`.trim(); |
||||||
|
const qt = (questionText || '').trim(); |
||||||
|
|
||||||
|
if (qt) { |
||||||
|
const system = |
||||||
|
'Ты редактор учебных материалов. Отвечай ТОЛЬКО JSON: {"text": string} — чёткая формулировка вопроса на русском, 1–3 полных предложения в зависимости от сложности исходного черновика, без вариантов ответа.'; |
||||||
|
const user = `Тема теста: ${topic}\n\nИсходный черновик вопроса (улучши формулировку, не меняй смысл без нужды):\n${qt}`; |
||||||
|
const raw = await chatCompletionTextContent(cfg, system, user, 0.3); |
||||||
|
const parsed = parseJsonFromLlmText(raw); |
||||||
|
const text = String((/** @type {any} */ (parsed)).text ?? '').trim(); |
||||||
|
if (!text) { |
||||||
|
const e = new Error('Пустой text в ответе модели.'); |
||||||
|
e.code = 'llm_shape'; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
return { mode: 'rephrase', text }; |
||||||
|
} |
||||||
|
|
||||||
|
const system = |
||||||
|
'Ты составитель тестов. Отвечай ТОЛЬКО JSON: {"text", "hasMultipleAnswers", "options": [{ "text", "isCorrect" }]}. Все на русском.'; |
||||||
|
const user = `Тема теста: ${topic} |
||||||
|
|
||||||
|
Сформулируй ОДИН вопрос по этой теме с ровно ${n} вариантами ответа. hasMultipleAnswers = ${ |
||||||
|
hasMultipleAnswers |
||||||
|
? 'true (несколько верных, минимум 2 isCorrect: true, остальные false).' |
||||||
|
: 'false (ровно один isCorrect: true).' |
||||||
|
}`;
|
||||||
|
const raw = await chatCompletionTextContent(cfg, system, user, 0.35); |
||||||
|
const parsed = parseJsonFromLlmText(raw); |
||||||
|
const shape = [{ optionsCount: n, hasMultipleAnswers: Boolean(hasMultipleAnswers) }]; |
||||||
|
assertDraftMatchesShape({ questions: [parsed] }, shape); |
||||||
|
const draft = validateAndNormalizeDraft({ |
||||||
|
title: 'временно', |
||||||
|
questions: [parsed], |
||||||
|
}); |
||||||
|
return { |
||||||
|
mode: 'full', |
||||||
|
text: draft.questions[0].text, |
||||||
|
hasMultipleAnswers: draft.questions[0].hasMultipleAnswers, |
||||||
|
options: draft.questions[0].options, |
||||||
|
}; |
||||||
|
} |
||||||
@ -0,0 +1,20 @@ |
|||||||
|
import { test } from 'node:test'; |
||||||
|
import assert from 'node:assert/strict'; |
||||||
|
import { parseAndValidateShape } from './aiEditorService.js'; |
||||||
|
|
||||||
|
test('parseAndValidateShape: валидный ввод', () => { |
||||||
|
const s = parseAndValidateShape([ |
||||||
|
{ optionsCount: 3, hasMultipleAnswers: false }, |
||||||
|
{ optionsCount: 2, hasMultipleAnswers: true }, |
||||||
|
]); |
||||||
|
assert.equal(s.length, 2); |
||||||
|
assert.equal(s[0].optionsCount, 3); |
||||||
|
assert.equal(s[1].hasMultipleAnswers, true); |
||||||
|
}); |
||||||
|
|
||||||
|
test('parseAndValidateShape: пусто — ошибка', () => { |
||||||
|
assert.throws( |
||||||
|
() => parseAndValidateShape([]), |
||||||
|
/Передайте/ |
||||||
|
); |
||||||
|
}); |
||||||
@ -0,0 +1,125 @@ |
|||||||
|
/** |
||||||
|
* Каталог для назначения: HR (staff_members + отделы) + учётки clinic_tests по staff_id. |
||||||
|
* Две БД — данные сливаем в Node. |
||||||
|
*/ |
||||||
|
import { getHrPool, queryHr } from '../db/hrPool.js'; |
||||||
|
import pool from '../db/db.js'; |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {{ q?: string, department?: string, clinicFilter?: 'all' | 'with' | 'without' }} p |
||||||
|
*/ |
||||||
|
export async function getAssignmentDirectory(p) { |
||||||
|
const { rows: clinicByStaff } = await pool.query( |
||||||
|
`SELECT id, staff_id, login, full_name
|
||||||
|
FROM users |
||||||
|
WHERE is_active = true AND staff_id IS NOT NULL` |
||||||
|
); |
||||||
|
const byStaff = new Map(); |
||||||
|
for (const r of clinicByStaff) { |
||||||
|
byStaff.set(r.staff_id, { clinicUserId: r.id, login: r.login, fullName: r.full_name }); |
||||||
|
} |
||||||
|
|
||||||
|
if (!getHrPool()) { |
||||||
|
const { rows } = await pool.query( |
||||||
|
`SELECT u.id, u.staff_id, u.full_name AS fio, u.login AS "webLogin"
|
||||||
|
FROM users u WHERE u.is_active = true ORDER BY u.full_name NULLS LAST, u.login` |
||||||
|
); |
||||||
|
let people = rows.map((r) => ({ |
||||||
|
staffId: r.staff_id, |
||||||
|
fio: r.fio || r.webLogin, |
||||||
|
webLogin: r.webLogin, |
||||||
|
departments: '', |
||||||
|
clinicUserId: r.id, |
||||||
|
})); |
||||||
|
const qx = (p.q || '').trim().toLowerCase(); |
||||||
|
if (qx) { |
||||||
|
people = people.filter( |
||||||
|
(x) => |
||||||
|
(x.fio && x.fio.toLowerCase().includes(qx)) || |
||||||
|
(x.webLogin && x.webLogin.toLowerCase().includes(qx)) || |
||||||
|
(x.clinicUserId && x.clinicUserId.toLowerCase().includes(qx)) |
||||||
|
); |
||||||
|
} |
||||||
|
return { people, source: 'clinic' }; |
||||||
|
} |
||||||
|
|
||||||
|
const q = (p.q || '').trim(); |
||||||
|
const dept = (p.department || '').trim(); |
||||||
|
const clinicFilter = p.clinicFilter || 'all'; |
||||||
|
|
||||||
|
const { rows: staffRows } = await queryHr( |
||||||
|
`SELECT sm.id AS staff_id, sm.fio, sm.web_login
|
||||||
|
FROM staff_members sm`,
|
||||||
|
[] |
||||||
|
); |
||||||
|
if (!staffRows.length) { |
||||||
|
return { people: [], source: 'hr' }; |
||||||
|
} |
||||||
|
|
||||||
|
const { rows: edRows } = await queryHr( |
||||||
|
`SELECT staff_id, department FROM employees_departments
|
||||||
|
WHERE department IS NOT NULL AND trim(department) <> ''`,
|
||||||
|
[] |
||||||
|
); |
||||||
|
const deptsByStaff = new Map(); |
||||||
|
for (const r of edRows) { |
||||||
|
if (!deptsByStaff.has(r.staff_id)) { |
||||||
|
deptsByStaff.set(r.staff_id, new Set()); |
||||||
|
} |
||||||
|
deptsByStaff.get(r.staff_id).add(r.department); |
||||||
|
} |
||||||
|
|
||||||
|
let people = staffRows.map((r) => { |
||||||
|
const dset = deptsByStaff.get(r.staff_id); |
||||||
|
const departments = dset |
||||||
|
? [...dset].sort((a, b) => a.localeCompare(b, 'ru')).join(', ') |
||||||
|
: ''; |
||||||
|
const cu = byStaff.get(r.staff_id) || null; |
||||||
|
return { |
||||||
|
staffId: r.staff_id, |
||||||
|
fio: r.fio || '—', |
||||||
|
webLogin: r.web_login, |
||||||
|
departments, |
||||||
|
clinicUserId: cu ? cu.clinicUserId : null, |
||||||
|
}; |
||||||
|
}); |
||||||
|
|
||||||
|
if (q) { |
||||||
|
const low = q.toLowerCase(); |
||||||
|
people = people.filter( |
||||||
|
(x) => |
||||||
|
(x.fio && x.fio.toLowerCase().includes(low)) || |
||||||
|
(x.webLogin && x.webLogin.toLowerCase().includes(low)) |
||||||
|
); |
||||||
|
} |
||||||
|
if (dept && dept !== '__all__') { |
||||||
|
people = people.filter((x) => { |
||||||
|
const s = deptsByStaff.get(x.staffId); |
||||||
|
return s && s.has(dept); |
||||||
|
}); |
||||||
|
} |
||||||
|
if (clinicFilter === 'with') { |
||||||
|
people = people.filter((x) => x.clinicUserId != null); |
||||||
|
} else if (clinicFilter === 'without') { |
||||||
|
people = people.filter((x) => x.clinicUserId == null); |
||||||
|
} |
||||||
|
|
||||||
|
people.sort((a, b) => (a.fio || '').localeCompare(b.fio || '', 'ru')); |
||||||
|
return { people, source: 'hr' }; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @returns {Promise<string[]>} |
||||||
|
*/ |
||||||
|
export async function getHrDepartmentNames() { |
||||||
|
if (!getHrPool()) { |
||||||
|
return []; |
||||||
|
} |
||||||
|
const { rows } = await queryHr( |
||||||
|
`SELECT DISTINCT TRIM(department) AS d
|
||||||
|
FROM employees_departments |
||||||
|
WHERE department IS NOT NULL AND TRIM(department) <> '' |
||||||
|
ORDER BY 1` |
||||||
|
); |
||||||
|
return rows.map((r) => r.d).filter(Boolean); |
||||||
|
} |
||||||
@ -0,0 +1,64 @@ |
|||||||
|
/** |
||||||
|
* Создать/найти запись `clinic_tests.users` по staff_id (HR), чтобы назначить target_id = uuid. |
||||||
|
*/ |
||||||
|
import { queryHr, getHrPool } from '../db/hrPool.js'; |
||||||
|
import { HR_MANAGED_PASSWORD_PLACEHOLDER } from '../config/authConstants.js'; |
||||||
|
import { RU } from '../messages/ru.js'; |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {import('pg').Pool} pool |
||||||
|
* @param {number} staffId |
||||||
|
* @returns {Promise<string>} uuid в clinic_tests.users |
||||||
|
*/ |
||||||
|
export async function ensureClinicUserIdForStaff(pool, staffId) { |
||||||
|
const n = Math.floor(Number(staffId)); |
||||||
|
if (!Number.isFinite(n) || n < 1) { |
||||||
|
const e = new Error(RU.assignmentUserRequired); |
||||||
|
e.status = 400; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
const { rows: ex } = await pool.query( |
||||||
|
`SELECT id FROM users WHERE staff_id = $1 AND is_active = true LIMIT 1`, |
||||||
|
[n] |
||||||
|
); |
||||||
|
if (ex.length) { |
||||||
|
return ex[0].id; |
||||||
|
} |
||||||
|
if (!getHrPool()) { |
||||||
|
const e = new Error('Нет HR БД: нельзя завести учётку по staff_id.'); |
||||||
|
e.status = 400; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
const { rows: st } = await queryHr( |
||||||
|
`SELECT id, fio, web_login FROM staff_members WHERE id = $1`, |
||||||
|
[n] |
||||||
|
); |
||||||
|
if (!st.length) { |
||||||
|
const e = new Error('Сотрудник не найден в HR.'); |
||||||
|
e.status = 400; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
const fio = st[0].fio || `staff #${n}`; |
||||||
|
const rawLogin = (st[0].web_login && String(st[0].web_login).trim()) || null; |
||||||
|
let login = rawLogin; |
||||||
|
if (!login) { |
||||||
|
login = `staff_${n}@clinic.local`; |
||||||
|
} |
||||||
|
const { rows: taken } = await pool.query( |
||||||
|
`SELECT 1 FROM users WHERE LOWER(TRIM(login)) = LOWER(TRIM($1)) AND (staff_id IS NULL OR staff_id <> $2) LIMIT 1`, |
||||||
|
[login, n] |
||||||
|
); |
||||||
|
if (taken.length) { |
||||||
|
login = `staff_${n}@clinic.local`; |
||||||
|
} |
||||||
|
const ins = await pool.query( |
||||||
|
`INSERT INTO users (login, password_hash, full_name, role, department_id, is_active, staff_id)
|
||||||
|
VALUES ($1, $2, $3, 'employee', null, true, $4) |
||||||
|
ON CONFLICT (staff_id) DO UPDATE SET |
||||||
|
full_name = EXCLUDED.full_name, |
||||||
|
is_active = true |
||||||
|
RETURNING id`,
|
||||||
|
[login, HR_MANAGED_PASSWORD_PLACEHOLDER, fio, n] |
||||||
|
); |
||||||
|
return ins.rows[0].id; |
||||||
|
} |
||||||
@ -0,0 +1,66 @@ |
|||||||
|
/** |
||||||
|
* D.2 — извлечение текста из PDF, DOCX, TXT (см. card1.md). |
||||||
|
*/ |
||||||
|
import { readFile } from 'fs/promises'; |
||||||
|
import { createRequire } from 'node:module'; |
||||||
|
import mammoth from 'mammoth'; |
||||||
|
import { RU } from '../messages/ru.js'; |
||||||
|
|
||||||
|
const require = createRequire(import.meta.url); |
||||||
|
const pdfParse = require('pdf-parse'); |
||||||
|
|
||||||
|
/** @param {string} mime @param {string} [originalName] */ |
||||||
|
export function resolveDocumentKind(mime, originalName = '') { |
||||||
|
const m = (mime || '').toLowerCase(); |
||||||
|
const n = originalName.toLowerCase(); |
||||||
|
if (m === 'application/pdf' || n.endsWith('.pdf')) { |
||||||
|
return 'pdf'; |
||||||
|
} |
||||||
|
if ( |
||||||
|
m === |
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || |
||||||
|
n.endsWith('.docx') |
||||||
|
) { |
||||||
|
return 'docx'; |
||||||
|
} |
||||||
|
if (m === 'text/plain' || m === 'text/markdown' || n.endsWith('.txt') || n.endsWith('.md')) { |
||||||
|
return 'text'; |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {string} mimetype |
||||||
|
* @param {string} filePath |
||||||
|
* @param {string} [originalName] |
||||||
|
* @returns {Promise<string>} извлечённый плоский текст |
||||||
|
*/ |
||||||
|
export async function extractTextFromFile(mimetype, filePath, originalName) { |
||||||
|
const kind = resolveDocumentKind(mimetype, originalName); |
||||||
|
if (!kind) { |
||||||
|
const e = new Error(RU.unsupportedFileType); |
||||||
|
e.status = 400; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
const buf = await readFile(filePath); |
||||||
|
return extractTextFromBuffer(kind, buf); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {'pdf'|'docx'|'text'} kind |
||||||
|
* @param {Buffer} buffer |
||||||
|
*/ |
||||||
|
export async function extractTextFromBuffer(kind, buffer) { |
||||||
|
if (kind === 'text') { |
||||||
|
return buffer.toString('utf8'); |
||||||
|
} |
||||||
|
if (kind === 'docx') { |
||||||
|
const { value } = await mammoth.extractRawText({ buffer }); |
||||||
|
return (value || '').replace(/\r\n/g, '\n').trim(); |
||||||
|
} |
||||||
|
if (kind === 'pdf') { |
||||||
|
const data = await pdfParse(buffer); |
||||||
|
return ((data && data.text) || '').replace(/\r\n/g, '\n').trim(); |
||||||
|
} |
||||||
|
return ''; |
||||||
|
} |
||||||
@ -0,0 +1,33 @@ |
|||||||
|
import { test } from 'node:test'; |
||||||
|
import assert from 'node:assert/strict'; |
||||||
|
import { |
||||||
|
extractTextFromBuffer, |
||||||
|
resolveDocumentKind, |
||||||
|
} from './documentExtractService.js'; |
||||||
|
|
||||||
|
test('resolveDocumentKind: PDF по MIME и по имени', () => { |
||||||
|
assert.equal(resolveDocumentKind('application/pdf'), 'pdf'); |
||||||
|
assert.equal(resolveDocumentKind('', 'X.PDF'), 'pdf'); |
||||||
|
assert.equal(resolveDocumentKind('application/octet-stream', 'a.pdf'), 'pdf'); |
||||||
|
}); |
||||||
|
|
||||||
|
test('resolveDocumentKind: docx, txt, неизвестно', () => { |
||||||
|
assert.equal( |
||||||
|
resolveDocumentKind( |
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' |
||||||
|
), |
||||||
|
'docx' |
||||||
|
); |
||||||
|
assert.equal(resolveDocumentKind('text/plain', 'x.txt'), 'text'); |
||||||
|
assert.equal(resolveDocumentKind('', 'readme.md'), 'text'); |
||||||
|
assert.equal(resolveDocumentKind('image/png'), null); |
||||||
|
assert.equal(resolveDocumentKind('application/octet-stream', 'a.exe'), null); |
||||||
|
}); |
||||||
|
|
||||||
|
test('extractTextFromBuffer: text UTF-8', async () => { |
||||||
|
const t = await extractTextFromBuffer( |
||||||
|
'text', |
||||||
|
Buffer.from('Проверка D.2', 'utf8') |
||||||
|
); |
||||||
|
assert.equal(t, 'Проверка D.2'); |
||||||
|
}); |
||||||
@ -0,0 +1,176 @@ |
|||||||
|
/** |
||||||
|
* D.3 — генерация структуры теста из извлечённого текста (OpenAI-совместимый Chat Completions). |
||||||
|
* Ключ: DEEPSEEK_API_KEY (по умолчанию api.deepseek.com) или OPENAI_API_KEY. Опц.: LLM_BASE_URL, LLM_MODEL. |
||||||
|
*/ |
||||||
|
import { getLlmConfig, chatCompletionTextContent } from './llmClient.js'; |
||||||
|
|
||||||
|
const MAX_EXTRACT_CHARS = 14000; |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {string} text |
||||||
|
* @returns {string} |
||||||
|
*/ |
||||||
|
export function parseJsonFromLlmText(text) { |
||||||
|
if (typeof text !== 'string' || !text.trim()) { |
||||||
|
const e = new Error('Пустой ответ модели.'); |
||||||
|
e.code = 'llm_empty'; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
let t = text.trim(); |
||||||
|
const fence = /^```(?:json)?\s*([\s\S]*?)```$/m.exec(t); |
||||||
|
if (fence) { |
||||||
|
t = fence[1].trim(); |
||||||
|
} |
||||||
|
let parsed; |
||||||
|
try { |
||||||
|
parsed = JSON.parse(t); |
||||||
|
} catch (err) { |
||||||
|
const e = new Error('Ответ модели не является корректным JSON.'); |
||||||
|
e.code = 'llm_json_parse'; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
return parsed; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {unknown} o |
||||||
|
* @returns {{ title: string, description: string | null, questions: Array<{ text: string, hasMultipleAnswers: boolean, options: Array<{ text: string, isCorrect: boolean }> }> }} |
||||||
|
*/ |
||||||
|
export function validateAndNormalizeDraft(o) { |
||||||
|
if (!o || typeof o !== 'object') { |
||||||
|
const e = new Error('JSON не содержит объекта с данными.'); |
||||||
|
e.code = 'llm_shape'; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
const title = String((/** @type {any} */ (o)).title ?? '').trim(); |
||||||
|
if (!title) { |
||||||
|
const e = new Error('В ответе нет поля title.'); |
||||||
|
e.code = 'llm_shape'; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
const desc = (/** @type {any} */ (o)).description; |
||||||
|
const description = |
||||||
|
desc != null && String(desc).trim() ? String(desc).trim() : null; |
||||||
|
const rawQs = (/** @type {any} */ (o)).questions; |
||||||
|
if (!Array.isArray(rawQs) || rawQs.length === 0) { |
||||||
|
const e = new Error('В ответе нет вопросов (questions).'); |
||||||
|
e.code = 'llm_shape'; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
if (rawQs.length > 40) { |
||||||
|
const e = new Error('Слишком много вопросов в ответе (макс. 40).'); |
||||||
|
e.code = 'llm_shape'; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
const questions = rawQs.map((q, i) => { |
||||||
|
if (!q || typeof q !== 'object') { |
||||||
|
const e = new Error(`Вопрос ${i + 1}: неверный формат.`); |
||||||
|
e.code = 'llm_shape'; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
const text = String((/** @type {any} */ (q)).text ?? '').trim(); |
||||||
|
if (!text) { |
||||||
|
const e = new Error(`Вопрос ${i + 1}: пустой текст.`); |
||||||
|
e.code = 'llm_shape'; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
const hasMultipleAnswers = Boolean( |
||||||
|
(/** @type {any} */ (q)).hasMultipleAnswers |
||||||
|
); |
||||||
|
const rawOpts = (/** @type {any} */ (q)).options; |
||||||
|
if (!Array.isArray(rawOpts) || rawOpts.length < 2) { |
||||||
|
const e = new Error(`Вопрос ${i + 1}: нужны минимум 2 варианта ответа.`); |
||||||
|
e.code = 'llm_shape'; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
if (rawOpts.length > 12) { |
||||||
|
const e = new Error(`Вопрос ${i + 1}: слишком много вариантов (макс. 12).`); |
||||||
|
e.code = 'llm_shape'; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
const options = rawOpts.map((op, j) => { |
||||||
|
if (!op || typeof op !== 'object') { |
||||||
|
const e = new Error( |
||||||
|
`Вопрос ${i + 1}, вариант ${j + 1}: неверный формат.` |
||||||
|
); |
||||||
|
e.code = 'llm_shape'; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
return { |
||||||
|
text: String((/** @type {any} */ (op)).text ?? '').trim() || `Вариант ${j + 1}`, |
||||||
|
isCorrect: Boolean((/** @type {any} */ (op)).isCorrect), |
||||||
|
}; |
||||||
|
}); |
||||||
|
const correctN = options.filter((x) => x.isCorrect).length; |
||||||
|
if (correctN === 0) { |
||||||
|
const e = new Error( |
||||||
|
`Вопрос ${i + 1}: отметьте минимум один правильный вариант.` |
||||||
|
); |
||||||
|
e.code = 'llm_shape'; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
if (!hasMultipleAnswers && correctN > 1) { |
||||||
|
const e = new Error( |
||||||
|
`Вопрос ${i + 1}: с одним правильным ответом должен быть один вариант isCorrect, либо укажите hasMultipleAnswers: true.` |
||||||
|
); |
||||||
|
e.code = 'llm_shape'; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
return { text, hasMultipleAnswers, options }; |
||||||
|
}); |
||||||
|
return { title, description, questions }; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* D.1/D.2/D.3 — ответ для POST /import/document (клиент не получает сырые ключи). |
||||||
|
* @param {string} extractedText |
||||||
|
*/ |
||||||
|
export async function generationForImportDocument(extractedText) { |
||||||
|
const text = (extractedText || '').trim(); |
||||||
|
if (!text) { |
||||||
|
return { |
||||||
|
available: false, |
||||||
|
message: 'Нет извлечённого текста — нечего передавать в модель.', |
||||||
|
}; |
||||||
|
} |
||||||
|
const cfg = getLlmConfig(); |
||||||
|
if (!cfg) { |
||||||
|
return { |
||||||
|
available: false, |
||||||
|
message: |
||||||
|
'Автогенерация выключена: задайте DEEPSEEK_API_KEY или OPENAI_API_KEY (см. backend/.env.example). Ниже — превью текста; можно вставить в черновик вручную.', |
||||||
|
textPreview: text.slice(0, 4000), |
||||||
|
}; |
||||||
|
} |
||||||
|
const slice = |
||||||
|
text.length > MAX_EXTRACT_CHARS |
||||||
|
? `${text.slice(0, MAX_EXTRACT_CHARS)}\n\n[…фрагмент обрезан для API]` |
||||||
|
: text; |
||||||
|
try { |
||||||
|
const system = |
||||||
|
'Ты помощник для составления тестов. Отвечай ТОЛЬКО одним JSON-объектом без пояснений. Схема: {"title": string, "description"?: string, "questions": array}. Каждый вопрос: {"text", "hasMultipleAnswers": boolean, "options": [{"text", "isCorrect": boolean}, ...]}. Минимум 2 варианта. Для одиночного выбора ровно один isCorrect: true. Текст и формулировки — на русском, по содержанию входного материала.'; |
||||||
|
const user = |
||||||
|
'Составь тест с вопросами с одним или несколькими правильными ответами на основе текста:\n\n' + slice; |
||||||
|
const raw = await chatCompletionTextContent(cfg, system, user, 0.25); |
||||||
|
const parsed = parseJsonFromLlmText(raw); |
||||||
|
const draft = validateAndNormalizeDraft(parsed); |
||||||
|
return { |
||||||
|
available: true, |
||||||
|
message: `Сгенерировано: «${draft.title}», вопросов: ${draft.questions.length}. Нажмите «Применить сгенерированный черновик» ниже.`, |
||||||
|
draft: { |
||||||
|
title: draft.title, |
||||||
|
description: draft.description, |
||||||
|
questions: draft.questions, |
||||||
|
}, |
||||||
|
}; |
||||||
|
} catch (e) { |
||||||
|
const msg = e instanceof Error ? e.message : String(e); |
||||||
|
const code = e instanceof Error && 'code' in e ? (/** @type {any} */ (e)).code : 'llm_error'; |
||||||
|
return { |
||||||
|
available: false, |
||||||
|
message: `Генерация не удалась: ${msg}`, |
||||||
|
errorCode: code, |
||||||
|
textPreview: text.slice(0, 4000), |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,63 @@ |
|||||||
|
import { test } from 'node:test'; |
||||||
|
import assert from 'node:assert/strict'; |
||||||
|
import { |
||||||
|
parseJsonFromLlmText, |
||||||
|
validateAndNormalizeDraft, |
||||||
|
} from './documentGenService.js'; |
||||||
|
|
||||||
|
test('parseJsonFromLlmText: чистый JSON', () => { |
||||||
|
const o = parseJsonFromLlmText('{"title":"T","questions":[{"text":"Q","options":[{"text":"a","isCorrect":true},{"text":"b","isCorrect":false}]}]}'); |
||||||
|
assert.equal(o.title, 'T'); |
||||||
|
assert.equal(o.questions.length, 1); |
||||||
|
}); |
||||||
|
|
||||||
|
test('parseJsonFromLlmText: JSON в markdown-заборе', () => { |
||||||
|
const raw = '```json\n{"title":"X","questions":[{"text":"1","options":[{"text":"+","isCorrect":true},{"text":"-","isCorrect":false}]}]}\n```'; |
||||||
|
const o = parseJsonFromLlmText(raw); |
||||||
|
assert.equal(o.title, 'X'); |
||||||
|
}); |
||||||
|
|
||||||
|
test('parseJsonFromLlmText: невалидный JSON — ошибка', () => { |
||||||
|
assert.throws( |
||||||
|
() => parseJsonFromLlmText('not json'), |
||||||
|
/JSON/i |
||||||
|
); |
||||||
|
}); |
||||||
|
|
||||||
|
test('validateAndNormalizeDraft: валидный черновик', () => { |
||||||
|
const d = validateAndNormalizeDraft({ |
||||||
|
title: ' Экзамен ', |
||||||
|
description: ' оп ', |
||||||
|
questions: [ |
||||||
|
{ |
||||||
|
text: '2+2?', |
||||||
|
hasMultipleAnswers: false, |
||||||
|
options: [ |
||||||
|
{ text: '4', isCorrect: true }, |
||||||
|
{ text: '5', isCorrect: false }, |
||||||
|
], |
||||||
|
}, |
||||||
|
], |
||||||
|
}); |
||||||
|
assert.equal(d.title, 'Экзамен'); |
||||||
|
assert.equal(d.description, 'оп'); |
||||||
|
assert.equal(d.questions[0].options.length, 2); |
||||||
|
}); |
||||||
|
|
||||||
|
test('validateAndNormalizeDraft: нет title', () => { |
||||||
|
assert.throws( |
||||||
|
() => |
||||||
|
validateAndNormalizeDraft({ |
||||||
|
questions: [ |
||||||
|
{ |
||||||
|
text: 'Q', |
||||||
|
options: [ |
||||||
|
{ text: 'a', isCorrect: true }, |
||||||
|
{ text: 'b', isCorrect: false }, |
||||||
|
], |
||||||
|
}, |
||||||
|
], |
||||||
|
}), |
||||||
|
/title/i |
||||||
|
); |
||||||
|
}); |
||||||
@ -0,0 +1,98 @@ |
|||||||
|
/** |
||||||
|
* OpenAI-совместимый Chat Completions. Общий для импорта и редактора. |
||||||
|
*/ |
||||||
|
|
||||||
|
/** |
||||||
|
* @returns {null | { provider: string, apiKey: string, baseUrl: string, model: string }} |
||||||
|
*/ |
||||||
|
export function getLlmConfig() { |
||||||
|
if (process.env.DEEPSEEK_API_KEY) { |
||||||
|
return { |
||||||
|
provider: 'deepseek', |
||||||
|
apiKey: process.env.DEEPSEEK_API_KEY, |
||||||
|
baseUrl: (process.env.LLM_BASE_URL || 'https://api.deepseek.com/v1').replace( |
||||||
|
/\/+$/, |
||||||
|
'' |
||||||
|
), |
||||||
|
model: process.env.LLM_MODEL || 'deepseek-chat', |
||||||
|
}; |
||||||
|
} |
||||||
|
if (process.env.OPENAI_API_KEY) { |
||||||
|
return { |
||||||
|
provider: 'openai', |
||||||
|
apiKey: process.env.OPENAI_API_KEY, |
||||||
|
baseUrl: (process.env.LLM_BASE_URL || 'https://api.openai.com/v1').replace( |
||||||
|
/\/+$/, |
||||||
|
'' |
||||||
|
), |
||||||
|
model: process.env.LLM_MODEL || 'gpt-4o-mini', |
||||||
|
}; |
||||||
|
} |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {{ baseUrl: string, apiKey: string, model: string }} cfg |
||||||
|
* @param {string} system |
||||||
|
* @param {string} user |
||||||
|
* @param {number} [temperature] |
||||||
|
* @returns {Promise<string>} raw assistant message |
||||||
|
*/ |
||||||
|
export async function chatCompletionTextContent(cfg, system, user, temperature = 0.25) { |
||||||
|
const url = `${cfg.baseUrl}/chat/completions`; |
||||||
|
const body = { |
||||||
|
model: cfg.model, |
||||||
|
messages: [ |
||||||
|
{ role: 'system', content: system }, |
||||||
|
{ role: 'user', content: user }, |
||||||
|
], |
||||||
|
temperature, |
||||||
|
}; |
||||||
|
if (process.env.LLM_NO_JSON !== '1') { |
||||||
|
body.response_format = { type: 'json_object' }; |
||||||
|
} |
||||||
|
const ac = new AbortController(); |
||||||
|
const t = setTimeout(() => ac.abort(), 120000); |
||||||
|
let res; |
||||||
|
try { |
||||||
|
res = await fetch(url, { |
||||||
|
method: 'POST', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json', |
||||||
|
Authorization: `Bearer ${cfg.apiKey}`, |
||||||
|
}, |
||||||
|
body: JSON.stringify(body), |
||||||
|
signal: ac.signal, |
||||||
|
}); |
||||||
|
} catch (e) { |
||||||
|
if (e.name === 'AbortError') { |
||||||
|
const err = new Error('Превышен таймаут ожидания ответа LLM (120 с).'); |
||||||
|
err.code = 'llm_timeout'; |
||||||
|
throw err; |
||||||
|
} |
||||||
|
const err = new Error( |
||||||
|
e instanceof Error ? e.message : 'Сбой сети при обращении к LLM' |
||||||
|
); |
||||||
|
err.code = 'llm_network'; |
||||||
|
throw err; |
||||||
|
} finally { |
||||||
|
clearTimeout(t); |
||||||
|
} |
||||||
|
if (!res.ok) { |
||||||
|
const errText = await res.text(); |
||||||
|
const err = new Error( |
||||||
|
`LLM ${res.status}: ${errText.replace(/\s+/g, ' ').slice(0, 280)}` |
||||||
|
); |
||||||
|
err.code = 'llm_http'; |
||||||
|
err.status = res.status; |
||||||
|
throw err; |
||||||
|
} |
||||||
|
const data = await res.json(); |
||||||
|
const content = data?.choices?.[0]?.message?.content; |
||||||
|
if (typeof content !== 'string' || !content.trim()) { |
||||||
|
const e = new Error('Пустой content в ответе API.'); |
||||||
|
e.code = 'llm_empty'; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
return content; |
||||||
|
} |
||||||
@ -0,0 +1,477 @@ |
|||||||
|
/** |
||||||
|
* Прохождение теста: контент для игры, проверка ответов, завершение попытки. |
||||||
|
*/ |
||||||
|
import { RU } from '../messages/ru.js'; |
||||||
|
import { isTestAuthor } from '../config/devAuthor.js'; |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {import('pg').Pool|import('pg').PoolClient} db |
||||||
|
* @param {string} testVersionId |
||||||
|
* @param {{ includeCorrect: boolean }} opts |
||||||
|
*/ |
||||||
|
export async function loadQuestionsForVersion(db, testVersionId, opts) { |
||||||
|
const { rows: qrows } = await db.query( |
||||||
|
`SELECT id, text, question_order, has_multiple_answers
|
||||||
|
FROM questions |
||||||
|
WHERE test_version_id = $1 |
||||||
|
ORDER BY question_order`,
|
||||||
|
[testVersionId] |
||||||
|
); |
||||||
|
const out = []; |
||||||
|
for (const row of qrows) { |
||||||
|
const { rows: orows } = await db.query( |
||||||
|
`SELECT id, text, is_correct, option_order
|
||||||
|
FROM answer_options |
||||||
|
WHERE question_id = $1 |
||||||
|
ORDER BY option_order`,
|
||||||
|
[row.id] |
||||||
|
); |
||||||
|
const options = orows.map((o) => { |
||||||
|
const base = { |
||||||
|
id: o.id, |
||||||
|
text: o.text, |
||||||
|
optionOrder: o.option_order, |
||||||
|
}; |
||||||
|
if (opts.includeCorrect) { |
||||||
|
return { ...base, isCorrect: o.is_correct }; |
||||||
|
} |
||||||
|
return base; |
||||||
|
}); |
||||||
|
out.push({ |
||||||
|
id: row.id, |
||||||
|
text: row.text, |
||||||
|
questionOrder: row.question_order, |
||||||
|
hasMultipleAnswers: row.has_multiple_answers, |
||||||
|
options, |
||||||
|
}); |
||||||
|
} |
||||||
|
return out; |
||||||
|
} |
||||||
|
|
||||||
|
function sortUuidStrings(arr) { |
||||||
|
return [...new Set(arr)].map(String).sort(); |
||||||
|
} |
||||||
|
|
||||||
|
function sameSelection(selected, correctIds) { |
||||||
|
const a = sortUuidStrings(selected); |
||||||
|
const b = sortUuidStrings(correctIds); |
||||||
|
if (a.length !== b.length) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
return a.every((x, i) => x === b[i]); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {import('pg').Pool} pool |
||||||
|
* @param {string} userId |
||||||
|
* @param {string} testId |
||||||
|
*/ |
||||||
|
export async function getEditorContent(pool, userId, testId) { |
||||||
|
const { rows: tr } = await pool.query( |
||||||
|
`SELECT t.id, t.title, t.description, t.passing_threshold, t.created_by
|
||||||
|
FROM tests t WHERE t.id = $1`,
|
||||||
|
[testId] |
||||||
|
); |
||||||
|
if (!tr.length) { |
||||||
|
const e = new Error(RU.testNotFound); |
||||||
|
e.status = 404; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
if (!isTestAuthor(tr[0].created_by, userId)) { |
||||||
|
const e = new Error(RU.forbidden); |
||||||
|
e.status = 403; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
const { rows: tv } = await pool.query( |
||||||
|
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true LIMIT 1`, |
||||||
|
[testId] |
||||||
|
); |
||||||
|
if (!tv.length) { |
||||||
|
const e = new Error(RU.noActiveVersion); |
||||||
|
e.status = 400; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
const versionId = tv[0].id; |
||||||
|
const questions = await loadQuestionsForVersion(pool, versionId, { |
||||||
|
includeCorrect: true, |
||||||
|
}); |
||||||
|
return { |
||||||
|
test: { |
||||||
|
id: tr[0].id, |
||||||
|
title: tr[0].title, |
||||||
|
description: tr[0].description, |
||||||
|
passingThreshold: tr[0].passing_threshold, |
||||||
|
}, |
||||||
|
activeVersionId: versionId, |
||||||
|
questions, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {import('pg').Pool} pool |
||||||
|
* @param {string} userId |
||||||
|
* @param {string} testId |
||||||
|
* @param {string} attemptId |
||||||
|
*/ |
||||||
|
export async function getPlayContent(pool, userId, testId, attemptId) { |
||||||
|
const { rows: arows } = await pool.query( |
||||||
|
`SELECT ta.id, ta.user_id, ta.status, ta.test_version_id, tv.test_id, t.title, t.passing_threshold
|
||||||
|
FROM test_attempts ta |
||||||
|
INNER JOIN test_versions tv ON tv.id = ta.test_version_id |
||||||
|
INNER JOIN tests t ON t.id = tv.test_id |
||||||
|
WHERE ta.id = $1`,
|
||||||
|
[attemptId] |
||||||
|
); |
||||||
|
if (!arows.length) { |
||||||
|
const e = new Error(RU.attemptNotFound); |
||||||
|
e.status = 404; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
const a = arows[0]; |
||||||
|
if (a.test_id !== testId) { |
||||||
|
const e = new Error(RU.attemptNotFound); |
||||||
|
e.status = 404; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
if (a.user_id !== userId) { |
||||||
|
const e = new Error(RU.forbidden); |
||||||
|
e.status = 403; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
if (a.status !== 'in_progress') { |
||||||
|
const e = new Error(RU.attemptNotInProgress); |
||||||
|
e.status = 400; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
const questions = await loadQuestionsForVersion(pool, a.test_version_id, { |
||||||
|
includeCorrect: false, |
||||||
|
}); |
||||||
|
return { |
||||||
|
testTitle: a.title, |
||||||
|
passingThreshold: a.passing_threshold, |
||||||
|
attemptId: a.id, |
||||||
|
questions, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {import('pg').Pool} pool |
||||||
|
* @param {string} userId |
||||||
|
* @param {string} testId |
||||||
|
* @param {string} attemptId |
||||||
|
* @param {Record<string, string | string[] | undefined> | null | undefined} rawAnswers |
||||||
|
*/ |
||||||
|
export async function submitAttempt(pool, userId, testId, attemptId, rawAnswers) { |
||||||
|
const answers = rawAnswers && typeof rawAnswers === 'object' ? rawAnswers : {}; |
||||||
|
const client = await pool.connect(); |
||||||
|
try { |
||||||
|
await client.query('BEGIN'); |
||||||
|
const { rows: arows } = await client.query( |
||||||
|
`SELECT id, user_id, status, test_version_id
|
||||||
|
FROM test_attempts |
||||||
|
WHERE id = $1 |
||||||
|
FOR UPDATE`,
|
||||||
|
[attemptId] |
||||||
|
); |
||||||
|
if (!arows.length) { |
||||||
|
const e = new Error(RU.attemptNotFound); |
||||||
|
e.status = 404; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
const a0 = arows[0]; |
||||||
|
const { rows: trows } = await client.query( |
||||||
|
`SELECT t.passing_threshold, tv.test_id
|
||||||
|
FROM test_versions tv |
||||||
|
INNER JOIN tests t ON t.id = tv.test_id |
||||||
|
WHERE tv.id = $1`,
|
||||||
|
[a0.test_version_id] |
||||||
|
); |
||||||
|
if (!trows.length) { |
||||||
|
const e = new Error(RU.testNotFound); |
||||||
|
e.status = 404; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
const link = trows[0]; |
||||||
|
const a = { |
||||||
|
test_id: link.test_id, |
||||||
|
user_id: a0.user_id, |
||||||
|
status: a0.status, |
||||||
|
test_version_id: a0.test_version_id, |
||||||
|
passing_threshold: link.passing_threshold, |
||||||
|
}; |
||||||
|
if (a.test_id !== testId) { |
||||||
|
const e = new Error(RU.attemptNotFound); |
||||||
|
e.status = 404; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
if (a.user_id !== userId) { |
||||||
|
const e = new Error(RU.forbidden); |
||||||
|
e.status = 403; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
if (a.status !== 'in_progress') { |
||||||
|
const e = new Error(RU.attemptNotInProgress); |
||||||
|
e.status = 400; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
const versionId = a.test_version_id; |
||||||
|
const threshold = Number(a.passing_threshold) || 0; |
||||||
|
|
||||||
|
const { rows: qrows } = await client.query( |
||||||
|
`SELECT id, has_multiple_answers
|
||||||
|
FROM questions |
||||||
|
WHERE test_version_id = $1`,
|
||||||
|
[versionId] |
||||||
|
); |
||||||
|
if (!qrows.length) { |
||||||
|
const e = new Error(RU.testHasNoQuestions); |
||||||
|
e.status = 400; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
|
||||||
|
const { rows: allOpts } = await client.query( |
||||||
|
`SELECT a.id, a.question_id, a.is_correct
|
||||||
|
FROM answer_options a |
||||||
|
INNER JOIN questions q ON q.id = a.question_id |
||||||
|
WHERE q.test_version_id = $1`,
|
||||||
|
[versionId] |
||||||
|
); |
||||||
|
const byQuestion = new Map(); |
||||||
|
for (const o of allOpts) { |
||||||
|
if (!byQuestion.has(o.question_id)) { |
||||||
|
byQuestion.set(o.question_id, { all: new Set(), correct: [] }); |
||||||
|
} |
||||||
|
const g = byQuestion.get(o.question_id); |
||||||
|
g.all.add(String(o.id)); |
||||||
|
if (o.is_correct) { |
||||||
|
g.correct.push(String(o.id)); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
let correctCount = 0; |
||||||
|
for (const q of qrows) { |
||||||
|
const qid = String(q.id); |
||||||
|
let selected = answers[qid] ?? answers[q.id]; |
||||||
|
if (selected == null) { |
||||||
|
selected = []; |
||||||
|
} else if (!Array.isArray(selected)) { |
||||||
|
selected = [String(selected)]; |
||||||
|
} else { |
||||||
|
selected = selected.map(String); |
||||||
|
} |
||||||
|
const g = byQuestion.get(q.id); |
||||||
|
if (!g) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
for (const sid of selected) { |
||||||
|
if (!g.all.has(sid)) { |
||||||
|
const e = new Error(RU.invalidOptionForQuestion); |
||||||
|
e.status = 400; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
} |
||||||
|
if (sameSelection(selected, g.correct)) { |
||||||
|
correctCount += 1; |
||||||
|
} |
||||||
|
} |
||||||
|
const total = qrows.length; |
||||||
|
const percent = (correctCount / total) * 100; |
||||||
|
const passed = percent + 1e-9 >= threshold; |
||||||
|
|
||||||
|
await client.query(`DELETE FROM user_answers WHERE attempt_id = $1`, [attemptId]); |
||||||
|
for (const q of qrows) { |
||||||
|
const qid = String(q.id); |
||||||
|
let selected = answers[qid] ?? answers[q.id] ?? []; |
||||||
|
if (!Array.isArray(selected)) { |
||||||
|
selected = [String(selected)]; |
||||||
|
} else { |
||||||
|
selected = selected.map(String); |
||||||
|
} |
||||||
|
await client.query( |
||||||
|
`INSERT INTO user_answers (attempt_id, question_id, selected_options)
|
||||||
|
VALUES ($1, $2, $3::uuid[])`,
|
||||||
|
[attemptId, q.id, selected] |
||||||
|
); |
||||||
|
} |
||||||
|
await client.query( |
||||||
|
`UPDATE test_attempts
|
||||||
|
SET status = 'completed', completed_at = CURRENT_TIMESTAMP, |
||||||
|
correct_count = $2, total_questions = $3, passed = $4 |
||||||
|
WHERE id = $1`,
|
||||||
|
[attemptId, correctCount, total, passed] |
||||||
|
); |
||||||
|
await client.query('COMMIT'); |
||||||
|
const base = { |
||||||
|
attemptId, |
||||||
|
correctCount, |
||||||
|
totalQuestions: total, |
||||||
|
percent: Math.round(percent * 10) / 10, |
||||||
|
passed, |
||||||
|
passingThreshold: threshold, |
||||||
|
}; |
||||||
|
const review = await buildReviewFromDb(pool, attemptId); |
||||||
|
return { ...base, review }; |
||||||
|
} catch (e) { |
||||||
|
await client.query('ROLLBACK'); |
||||||
|
throw e; |
||||||
|
} finally { |
||||||
|
client.release(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Подробный разбор завершённой попытки (для API и ответа submit). |
||||||
|
* @param {import('pg').Pool|import('pg').PoolClient} pool |
||||||
|
* @param {string} attemptId |
||||||
|
*/ |
||||||
|
export async function buildReviewFromDb(pool, attemptId) { |
||||||
|
const { rows: arows } = await pool.query( |
||||||
|
`SELECT ta.id, ta.status, ta.test_version_id, ta.user_id, ta.correct_count, ta.total_questions,
|
||||||
|
ta.passed, ta.started_at, ta.completed_at, |
||||||
|
t.id AS test_id, t.title, t.passing_threshold, |
||||||
|
u.full_name AS attempter_name, u.login AS attempter_login |
||||||
|
FROM test_attempts ta |
||||||
|
INNER JOIN test_versions tv ON tv.id = ta.test_version_id |
||||||
|
INNER JOIN tests t ON t.id = tv.test_id |
||||||
|
INNER JOIN users u ON u.id = ta.user_id |
||||||
|
WHERE ta.id = $1`,
|
||||||
|
[attemptId] |
||||||
|
); |
||||||
|
if (!arows.length) { |
||||||
|
const e = new Error(RU.attemptNotFound); |
||||||
|
e.status = 404; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
const a = arows[0]; |
||||||
|
if (a.status !== 'completed') { |
||||||
|
const e = new Error(RU.attemptNotCompleted); |
||||||
|
e.status = 400; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
const questions = await loadQuestionsForVersion(pool, a.test_version_id, { |
||||||
|
includeCorrect: true, |
||||||
|
}); |
||||||
|
const { rows: uans } = await pool.query( |
||||||
|
`SELECT question_id, selected_options FROM user_answers WHERE attempt_id = $1`, |
||||||
|
[attemptId] |
||||||
|
); |
||||||
|
const selByQ = new Map(); |
||||||
|
for (const r of uans) { |
||||||
|
selByQ.set(String(r.question_id), (r.selected_options || []).map(String)); |
||||||
|
} |
||||||
|
const threshold = Number(a.passing_threshold) || 0; |
||||||
|
const total = a.total_questions || questions.length; |
||||||
|
const percent = |
||||||
|
total > 0 |
||||||
|
? Math.round(((a.correct_count || 0) / total) * 1000) / 10 |
||||||
|
: 0; |
||||||
|
const qOut = questions.map((q) => { |
||||||
|
const selected = sortUuidStrings(selByQ.get(String(q.id)) || []); |
||||||
|
const correctIdList = sortUuidStrings( |
||||||
|
q.options.filter((o) => o.isCorrect).map((o) => String(o.id)) |
||||||
|
); |
||||||
|
const isUserCorrect = sameSelection(selected, correctIdList); |
||||||
|
const selectedSet = new Set(selected); |
||||||
|
return { |
||||||
|
id: q.id, |
||||||
|
text: q.text, |
||||||
|
hasMultipleAnswers: q.hasMultipleAnswers, |
||||||
|
isUserCorrect, |
||||||
|
options: q.options.map((o) => ({ |
||||||
|
id: o.id, |
||||||
|
text: o.text, |
||||||
|
isCorrect: o.isCorrect, |
||||||
|
selected: selectedSet.has(String(o.id)), |
||||||
|
})), |
||||||
|
}; |
||||||
|
}); |
||||||
|
return { |
||||||
|
attemptId: a.id, |
||||||
|
testId: a.test_id, |
||||||
|
testTitle: a.title, |
||||||
|
passingThreshold: threshold, |
||||||
|
correctCount: a.correct_count, |
||||||
|
totalQuestions: total, |
||||||
|
percent, |
||||||
|
passed: a.passed, |
||||||
|
startedAt: a.started_at, |
||||||
|
completedAt: a.completed_at, |
||||||
|
attempterUserId: a.user_id, |
||||||
|
attempterName: a.attempter_name, |
||||||
|
attempterLogin: a.attempter_login, |
||||||
|
questions: qOut, |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Разбор попытки: владелец попытки или автор теста. |
||||||
|
* @param {import('pg').Pool} pool |
||||||
|
* @param {string} currentUserId |
||||||
|
* @param {string} testId |
||||||
|
* @param {string} attemptId |
||||||
|
*/ |
||||||
|
export async function getAttemptReviewForUser(pool, currentUserId, testId, attemptId) { |
||||||
|
const { rows } = await pool.query( |
||||||
|
`SELECT ta.user_id, t.created_by, tv.test_id
|
||||||
|
FROM test_attempts ta |
||||||
|
INNER JOIN test_versions tv ON tv.id = ta.test_version_id |
||||||
|
INNER JOIN tests t ON t.id = tv.test_id |
||||||
|
WHERE ta.id = $1`,
|
||||||
|
[attemptId] |
||||||
|
); |
||||||
|
if (!rows.length) { |
||||||
|
const e = new Error(RU.attemptNotFound); |
||||||
|
e.status = 404; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
const r0 = rows[0]; |
||||||
|
if (r0.test_id !== testId) { |
||||||
|
const e = new Error(RU.attemptNotFound); |
||||||
|
e.status = 404; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
const isOwner = r0.user_id === currentUserId; |
||||||
|
const isAuthor = isTestAuthor(r0.created_by, currentUserId); |
||||||
|
if (!isOwner && !isAuthor) { |
||||||
|
const e = new Error(RU.forbidden); |
||||||
|
e.status = 403; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
return buildReviewFromDb(pool, attemptId); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Список всех попыток по цепочке (все версии) — только автор. |
||||||
|
* @param {import('pg').Pool} pool |
||||||
|
* @param {string} authorId |
||||||
|
* @param {string} testId |
||||||
|
*/ |
||||||
|
export async function listTestAttemptsForAuthor(pool, authorId, testId) { |
||||||
|
const { rows: t } = await pool.query( |
||||||
|
`SELECT id, created_by FROM tests WHERE id = $1`, |
||||||
|
[testId] |
||||||
|
); |
||||||
|
if (!t.length) { |
||||||
|
const e = new Error(RU.testNotFound); |
||||||
|
e.status = 404; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
if (!isTestAuthor(t[0].created_by, authorId)) { |
||||||
|
const e = new Error(RU.forbidden); |
||||||
|
e.status = 403; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
const { rows } = await pool.query( |
||||||
|
`SELECT ta.id, ta.user_id, ta.status, ta.attempt_number, ta.started_at, ta.completed_at,
|
||||||
|
ta.correct_count, ta.total_questions, ta.passed, tv.version AS test_version, |
||||||
|
u.full_name AS attempter_name, u.login AS attempter_login |
||||||
|
FROM test_attempts ta |
||||||
|
INNER JOIN test_versions tv ON tv.id = ta.test_version_id |
||||||
|
INNER JOIN users u ON u.id = ta.user_id |
||||||
|
WHERE tv.test_id = $1 |
||||||
|
ORDER BY ta.started_at DESC NULLS LAST |
||||||
|
LIMIT 200`,
|
||||||
|
[testId] |
||||||
|
); |
||||||
|
return rows; |
||||||
|
} |
||||||
@ -0,0 +1,112 @@ |
|||||||
|
import { Link } from 'react-router-dom'; |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {{ review: { |
||||||
|
* testTitle?: string, |
||||||
|
* attempterName?: string, |
||||||
|
* attempterLogin?: string, |
||||||
|
* startedAt?: string, |
||||||
|
* completedAt?: string, |
||||||
|
* correctCount: number, |
||||||
|
* totalQuestions: number, |
||||||
|
* percent: number, |
||||||
|
* passed: boolean, |
||||||
|
* passingThreshold: number, |
||||||
|
* questions: Array<{ |
||||||
|
* id: string, |
||||||
|
* text: string, |
||||||
|
* isUserCorrect: boolean, |
||||||
|
* options: Array<{ id: string, text: string, isCorrect: boolean, selected: boolean }> |
||||||
|
* }> |
||||||
|
* }, showAttempter?: boolean, backLink: { to: string, label: string } }} p |
||||||
|
*/ |
||||||
|
export default function AttemptReviewBlock({ review, showAttempter, backLink }) { |
||||||
|
if (!review?.questions?.length) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
return ( |
||||||
|
<div className="attempt-review" style={{ maxWidth: 640 }}> |
||||||
|
{showAttempter && (review.attempterName || review.attempterLogin) && ( |
||||||
|
<p className="text-muted" style={{ marginTop: 0, fontSize: 14 }}> |
||||||
|
Участник: {review.attempterName || '—'}{' '} |
||||||
|
{review.attempterLogin && ( |
||||||
|
<span className="code-inline" style={{ fontSize: 12 }}> |
||||||
|
{review.attempterLogin} |
||||||
|
</span> |
||||||
|
)} |
||||||
|
</p> |
||||||
|
)} |
||||||
|
{review.completedAt && ( |
||||||
|
<p className="text-muted" style={{ marginTop: 0, fontSize: 13 }}> |
||||||
|
Завершено: {new Date(review.completedAt).toLocaleString('ru-RU')} |
||||||
|
</p> |
||||||
|
)} |
||||||
|
<ol style={{ paddingLeft: '1.1rem', marginTop: '0.75rem' }}> |
||||||
|
{review.questions.map((q, i) => ( |
||||||
|
<li |
||||||
|
key={q.id} |
||||||
|
style={{ |
||||||
|
marginBottom: '1.1rem', |
||||||
|
borderLeft: '3px solid', |
||||||
|
borderColor: q.isUserCorrect |
||||||
|
? 'color-mix(in srgb, var(--primary) 50%, var(--outline-variant))' |
||||||
|
: 'color-mix(in srgb, var(--error, #c00) 45%, var(--outline-variant))', |
||||||
|
paddingLeft: 10, |
||||||
|
}} |
||||||
|
> |
||||||
|
<p style={{ marginTop: 0, marginBottom: 8, fontWeight: 600 }}>{i + 1}. {q.text}</p> |
||||||
|
<p |
||||||
|
className={q.isUserCorrect ? 'text-muted' : 'error-text'} |
||||||
|
style={{ margin: '0 0 6px', fontSize: 13 }} |
||||||
|
> |
||||||
|
{q.isUserCorrect ? 'Верно' : 'Ошибка'} |
||||||
|
</p> |
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0, fontSize: 14 }}> |
||||||
|
{q.options.map((o) => { |
||||||
|
const mark = |
||||||
|
o.selected && o.isCorrect |
||||||
|
? '✓ верно' |
||||||
|
: o.selected && !o.isCorrect |
||||||
|
? '✗ выбрано' |
||||||
|
: !o.selected && o.isCorrect |
||||||
|
? '— правильный вариант' |
||||||
|
: ''; |
||||||
|
return ( |
||||||
|
<li |
||||||
|
key={o.id} |
||||||
|
style={{ |
||||||
|
marginBottom: 4, |
||||||
|
opacity: o.selected || o.isCorrect ? 1 : 0.7, |
||||||
|
}} |
||||||
|
> |
||||||
|
<span |
||||||
|
style={ |
||||||
|
o.isCorrect |
||||||
|
? { fontWeight: 600, color: 'var(--primary, #007168)' } |
||||||
|
: o.selected |
||||||
|
? {} |
||||||
|
: { color: 'var(--secondary)' } |
||||||
|
} |
||||||
|
> |
||||||
|
{o.text} |
||||||
|
</span> |
||||||
|
{mark && ( |
||||||
|
<span className="text-muted" style={{ marginLeft: 8, fontSize: 12 }}> |
||||||
|
{mark} |
||||||
|
</span> |
||||||
|
)} |
||||||
|
</li> |
||||||
|
); |
||||||
|
})} |
||||||
|
</ul> |
||||||
|
</li> |
||||||
|
))} |
||||||
|
</ol> |
||||||
|
{backLink && ( |
||||||
|
<p className="link-back" style={{ marginTop: '1rem' }}> |
||||||
|
<Link to={backLink.to}>{backLink.label}</Link> |
||||||
|
</p> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,215 @@ |
|||||||
|
import { useEffect, useState } from 'react'; |
||||||
|
import { Link, useNavigate, useParams } from 'react-router-dom'; |
||||||
|
import AttemptReviewBlock from '../components/AttemptReviewBlock'; |
||||||
|
import { api } from '../api'; |
||||||
|
|
||||||
|
export default function TestAttempt() { |
||||||
|
const { id: testId, attemptId } = useParams(); |
||||||
|
const nav = useNavigate(); |
||||||
|
const [play, setPlay] = useState(null); |
||||||
|
const [err, setErr] = useState(null); |
||||||
|
const [submitErr, setSubmitErr] = useState(null); |
||||||
|
const [loading, setLoading] = useState(true); |
||||||
|
const [sending, setSending] = useState(false); |
||||||
|
/** @type {Record<string, string[]>} */ |
||||||
|
const [selections, setSelections] = useState({}); |
||||||
|
const [result, setResult] = useState(null); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
let cancelled = false; |
||||||
|
(async () => { |
||||||
|
setErr(null); |
||||||
|
setLoading(true); |
||||||
|
try { |
||||||
|
const data = await api(`/api/tests/${testId}/attempts/${attemptId}/play`); |
||||||
|
if (!cancelled) { |
||||||
|
setPlay(data); |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
if (e.status === 401) { |
||||||
|
nav('/login'); |
||||||
|
return; |
||||||
|
} |
||||||
|
if (!cancelled) { |
||||||
|
setErr(e.message); |
||||||
|
} |
||||||
|
} finally { |
||||||
|
if (!cancelled) { |
||||||
|
setLoading(false); |
||||||
|
} |
||||||
|
} |
||||||
|
})(); |
||||||
|
return () => { |
||||||
|
cancelled = true; |
||||||
|
}; |
||||||
|
}, [testId, attemptId, nav]); |
||||||
|
|
||||||
|
function toggleOption(questionId, optionId, hasMultiple) { |
||||||
|
setSelections((prev) => { |
||||||
|
const key = String(questionId); |
||||||
|
const cur = prev[key] || []; |
||||||
|
const id = String(optionId); |
||||||
|
if (hasMultiple) { |
||||||
|
if (cur.includes(id)) { |
||||||
|
return { ...prev, [key]: cur.filter((x) => x !== id) }; |
||||||
|
} |
||||||
|
return { ...prev, [key]: [...cur, id] }; |
||||||
|
} |
||||||
|
return { ...prev, [key]: [id] }; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
function isSelected(questionId, optionId) { |
||||||
|
const s = selections[String(questionId)] || []; |
||||||
|
return s.includes(String(optionId)); |
||||||
|
} |
||||||
|
|
||||||
|
async function onSubmit() { |
||||||
|
setSubmitErr(null); |
||||||
|
setSending(true); |
||||||
|
try { |
||||||
|
const out = await api(`/api/tests/${testId}/attempts/${attemptId}/submit`, { |
||||||
|
method: 'POST', |
||||||
|
body: JSON.stringify({ answers: selections }), |
||||||
|
}); |
||||||
|
setResult(out); |
||||||
|
} catch (e) { |
||||||
|
setSubmitErr(e.message); |
||||||
|
} finally { |
||||||
|
setSending(false); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (loading) { |
||||||
|
return <p className="text-muted">Загрузка вопросов…</p>; |
||||||
|
} |
||||||
|
if (err) { |
||||||
|
return ( |
||||||
|
<div> |
||||||
|
<p className="error-text">{err}</p> |
||||||
|
<p> |
||||||
|
<Link to={`/tests/${testId}`}>← к карточке теста</Link> |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
if (result) { |
||||||
|
return ( |
||||||
|
<div> |
||||||
|
<p className="link-back"> |
||||||
|
<Link to={`/tests/${testId}`}>← к карточке теста</Link> |
||||||
|
</p> |
||||||
|
<h1 className="font-headline" style={{ fontSize: '1.35rem' }}> |
||||||
|
Результат |
||||||
|
</h1> |
||||||
|
<p> |
||||||
|
Правильно: <strong>{result.correctCount}</strong> из {result.totalQuestions} ( |
||||||
|
{result.percent}%). Порог: {result.passingThreshold}%. |
||||||
|
</p> |
||||||
|
<p className={result.passed ? 'text-muted' : 'error-text'}> |
||||||
|
{result.passed |
||||||
|
? 'Тест пройден по порогу.' |
||||||
|
: 'Порог не достигнут — при необходимости начните новую попытку на карточке теста.'} |
||||||
|
</p> |
||||||
|
{result.review && ( |
||||||
|
<> |
||||||
|
<h2 |
||||||
|
className="font-headline" |
||||||
|
style={{ fontSize: '1.1rem', marginTop: '1.25rem', marginBottom: '0.5rem' }} |
||||||
|
> |
||||||
|
Разбор |
||||||
|
</h2> |
||||||
|
<AttemptReviewBlock review={result.review} showAttempter={false} /> |
||||||
|
{result.attemptId && ( |
||||||
|
<p className="text-muted" style={{ fontSize: 14, marginTop: '0.75rem' }}> |
||||||
|
<Link to={`/tests/${testId}/attempts/${result.attemptId}/review`}> |
||||||
|
Полная страница разбора |
||||||
|
</Link> |
||||||
|
</p> |
||||||
|
)} |
||||||
|
</> |
||||||
|
)} |
||||||
|
<div className="inline-actions" style={{ marginTop: '1rem' }}> |
||||||
|
<Link to={`/tests/${testId}`} className="btn btn-ghost"> |
||||||
|
К настройкам теста |
||||||
|
</Link> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
if (!play?.questions?.length) { |
||||||
|
return ( |
||||||
|
<div> |
||||||
|
<p className="error-text">В этой версии нет вопросов. Добавьте вопросы в черновике.</p> |
||||||
|
<Link to={`/tests/${testId}`}>← к карточке</Link> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div> |
||||||
|
<p className="link-back"> |
||||||
|
<Link to={`/tests/${testId}`}>← к карточке теста</Link> |
||||||
|
</p> |
||||||
|
<h1 className="font-headline" style={{ fontSize: '1.35rem', marginTop: 0 }}> |
||||||
|
{play.testTitle} |
||||||
|
</h1> |
||||||
|
<p className="text-muted" style={{ marginTop: 0 }}> |
||||||
|
Отметьте ответы и нажмите «Завершить». Порог для зачёта: {play.passingThreshold}%. |
||||||
|
</p> |
||||||
|
|
||||||
|
<ol style={{ paddingLeft: '1.25rem', maxWidth: 640 }}> |
||||||
|
{play.questions.map((q) => ( |
||||||
|
<li key={q.id} style={{ marginBottom: '1.5rem' }}> |
||||||
|
<p style={{ marginTop: 0, marginBottom: '0.5rem' }}>{q.text}</p> |
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}> |
||||||
|
{q.options.map((o) => { |
||||||
|
const inputType = q.hasMultipleAnswers ? 'checkbox' : 'radio'; |
||||||
|
const name = `q-${q.id}`; |
||||||
|
return ( |
||||||
|
<li key={o.id} style={{ marginBottom: 6 }}> |
||||||
|
<label |
||||||
|
style={{ |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'flex-start', |
||||||
|
gap: 8, |
||||||
|
cursor: 'pointer', |
||||||
|
}} |
||||||
|
> |
||||||
|
<input |
||||||
|
type={inputType} |
||||||
|
name={q.hasMultipleAnswers ? undefined : name} |
||||||
|
checked={isSelected(q.id, o.id)} |
||||||
|
onChange={() => toggleOption(q.id, o.id, q.hasMultipleAnswers)} |
||||||
|
style={{ marginTop: 3 }} |
||||||
|
aria-label={o.text} |
||||||
|
/> |
||||||
|
<span>{o.text}</span> |
||||||
|
</label> |
||||||
|
</li> |
||||||
|
); |
||||||
|
})} |
||||||
|
</ul> |
||||||
|
</li> |
||||||
|
))} |
||||||
|
</ol> |
||||||
|
|
||||||
|
{submitErr && ( |
||||||
|
<p className="error-text" role="alert"> |
||||||
|
{submitErr} |
||||||
|
</p> |
||||||
|
)} |
||||||
|
<div className="inline-actions" style={{ marginTop: '0.5rem' }}> |
||||||
|
<button |
||||||
|
type="button" |
||||||
|
className="btn btn-ghost" |
||||||
|
onClick={onSubmit} |
||||||
|
disabled={sending} |
||||||
|
> |
||||||
|
{sending ? 'Отправка…' : 'Завершить тест'} |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,79 @@ |
|||||||
|
import { useEffect, useState } from 'react'; |
||||||
|
import { Link, useNavigate, useParams } from 'react-router-dom'; |
||||||
|
import { api } from '../api'; |
||||||
|
import AttemptReviewBlock from '../components/AttemptReviewBlock'; |
||||||
|
|
||||||
|
export default function TestAttemptReview() { |
||||||
|
const { id: testId, attemptId } = useParams(); |
||||||
|
const nav = useNavigate(); |
||||||
|
const [review, setReview] = useState(null); |
||||||
|
const [err, setErr] = useState(null); |
||||||
|
const [loading, setLoading] = useState(true); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
let cancelled = false; |
||||||
|
(async () => { |
||||||
|
setErr(null); |
||||||
|
setLoading(true); |
||||||
|
try { |
||||||
|
const data = await api(`/api/tests/${testId}/attempts/${attemptId}/review`); |
||||||
|
if (!cancelled) { |
||||||
|
setReview(data); |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
if (e.status === 401) { |
||||||
|
nav('/login'); |
||||||
|
return; |
||||||
|
} |
||||||
|
if (!cancelled) { |
||||||
|
setErr(e.message); |
||||||
|
} |
||||||
|
} finally { |
||||||
|
if (!cancelled) { |
||||||
|
setLoading(false); |
||||||
|
} |
||||||
|
} |
||||||
|
})(); |
||||||
|
return () => { |
||||||
|
cancelled = true; |
||||||
|
}; |
||||||
|
}, [testId, attemptId, nav]); |
||||||
|
|
||||||
|
if (loading) { |
||||||
|
return <p className="text-muted">Загрузка разбора…</p>; |
||||||
|
} |
||||||
|
if (err) { |
||||||
|
return ( |
||||||
|
<div> |
||||||
|
<p className="error-text">{err}</p> |
||||||
|
<p> |
||||||
|
<Link to={`/tests/${testId}`}>← к карточке теста</Link> |
||||||
|
</p> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
if (!review) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div> |
||||||
|
<p className="link-back"> |
||||||
|
<Link to={`/tests/${testId}`}>← к карточке теста</Link> |
||||||
|
</p> |
||||||
|
<h1 className="font-headline" style={{ fontSize: '1.35rem' }}> |
||||||
|
Разбор попытки: {review.testTitle} |
||||||
|
</h1> |
||||||
|
<p> |
||||||
|
Правильно: <strong>{review.correctCount}</strong> из {review.totalQuestions} ({review.percent} |
||||||
|
%). Порог: {review.passingThreshold}%.{' '} |
||||||
|
{review.passed ? ( |
||||||
|
<span className="text-muted">Зачёт.</span> |
||||||
|
) : ( |
||||||
|
<span className="error-text">Незачёт.</span> |
||||||
|
)} |
||||||
|
</p> |
||||||
|
<AttemptReviewBlock review={review} showAttempter /> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,785 @@ |
|||||||
|
/* Match: HR_TG_Bot/tgFlaskForm .../cabinet/tailwind_config.js + cabinet/login.html */ |
||||||
|
:root { |
||||||
|
--surface: #ffffff; |
||||||
|
--surface-container-low: #f3f8f9; |
||||||
|
--surface-container: #eaf3f5; |
||||||
|
--surface-container-high: #dfeef1; |
||||||
|
--on-surface: #0d1b1d; |
||||||
|
--on-surface-variant: #3d5357; |
||||||
|
--primary: #007168; |
||||||
|
--on-primary: #ffffff; |
||||||
|
--primary-container: #56f1e0; |
||||||
|
--on-primary-container: #00574f; |
||||||
|
--secondary: #506965; |
||||||
|
--secondary-container: #cce8e3; |
||||||
|
--on-secondary-container: #3d5653; |
||||||
|
--error: #af3d3b; |
||||||
|
--outline-variant: #b9bc94; |
||||||
|
--outline: #80835f; |
||||||
|
--shadow-card: 0 8px 40px rgba(0, 0, 0, 0.08); |
||||||
|
--radius-card: 2rem; |
||||||
|
--max-content: 42rem; /* max-w-2xl */ |
||||||
|
color-scheme: light; |
||||||
|
} |
||||||
|
|
||||||
|
*, |
||||||
|
*::before, |
||||||
|
*::after { |
||||||
|
box-sizing: border-box; |
||||||
|
} |
||||||
|
|
||||||
|
html { |
||||||
|
color-scheme: light; |
||||||
|
} |
||||||
|
|
||||||
|
body { |
||||||
|
margin: 0; |
||||||
|
min-height: 100dvh; |
||||||
|
font-family: 'Inter', system-ui, -apple-system, sans-serif; |
||||||
|
background: var(--surface-container-low); |
||||||
|
color: var(--on-surface); |
||||||
|
-webkit-tap-highlight-color: transparent; |
||||||
|
line-height: 1.45; |
||||||
|
} |
||||||
|
|
||||||
|
#root { |
||||||
|
min-height: 100dvh; |
||||||
|
} |
||||||
|
|
||||||
|
.font-headline, |
||||||
|
h1, |
||||||
|
h2, |
||||||
|
h3 { |
||||||
|
font-family: 'Manrope', 'Inter', sans-serif; |
||||||
|
font-weight: 700; |
||||||
|
letter-spacing: -0.02em; |
||||||
|
line-height: 1.2; |
||||||
|
color: var(--on-surface); |
||||||
|
} |
||||||
|
|
||||||
|
h1 { |
||||||
|
font-size: 1.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
h2 { |
||||||
|
font-size: 1.25rem; |
||||||
|
margin: 0 0 0.75rem; |
||||||
|
} |
||||||
|
|
||||||
|
h3 { |
||||||
|
font-size: 1.1rem; |
||||||
|
margin: 1.25rem 0 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
a { |
||||||
|
color: var(--primary); |
||||||
|
text-decoration: none; |
||||||
|
font-weight: 500; |
||||||
|
transition: color 0.15s ease; |
||||||
|
} |
||||||
|
|
||||||
|
a:hover { |
||||||
|
color: var(--on-primary-container); |
||||||
|
text-decoration: underline; |
||||||
|
} |
||||||
|
|
||||||
|
code, |
||||||
|
.code-inline { |
||||||
|
display: inline-block; |
||||||
|
background: var(--secondary-container); |
||||||
|
color: var(--on-primary-container); |
||||||
|
padding: 1px 7px; |
||||||
|
border-radius: 5px; |
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; |
||||||
|
font-size: 0.8rem; |
||||||
|
font-weight: 500; |
||||||
|
} |
||||||
|
|
||||||
|
.text-muted, |
||||||
|
.text-secondary { |
||||||
|
color: var(--secondary); |
||||||
|
font-size: 0.875rem; |
||||||
|
} |
||||||
|
|
||||||
|
.material-symbols-outlined { |
||||||
|
font-family: 'Material Symbols Outlined', sans-serif; |
||||||
|
font-weight: normal; |
||||||
|
font-style: normal; |
||||||
|
line-height: 1; |
||||||
|
letter-spacing: normal; |
||||||
|
text-transform: none; |
||||||
|
display: inline-block; |
||||||
|
white-space: nowrap; |
||||||
|
word-wrap: normal; |
||||||
|
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; |
||||||
|
direction: ltr; |
||||||
|
-webkit-font-feature-settings: 'liga'; |
||||||
|
font-feature-settings: 'liga'; |
||||||
|
-webkit-font-smoothing: antialiased; |
||||||
|
} |
||||||
|
|
||||||
|
/* --- Login (cabinet/login.html) --- */ |
||||||
|
.login-page { |
||||||
|
min-height: 100dvh; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
padding: 1.5rem; |
||||||
|
background: #ffffff; |
||||||
|
} |
||||||
|
|
||||||
|
.login-shell { |
||||||
|
width: 100%; |
||||||
|
max-width: 24rem; /* max-w-sm */ |
||||||
|
transition: max-width 0.2s ease-out; |
||||||
|
} |
||||||
|
|
||||||
|
.login-logo { |
||||||
|
text-align: center; |
||||||
|
margin-bottom: 2rem; |
||||||
|
} |
||||||
|
|
||||||
|
.login-logo__frame { |
||||||
|
width: 4rem; |
||||||
|
height: 4rem; |
||||||
|
border-radius: 1.5rem; |
||||||
|
background: #fff; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
margin: 0 auto 1rem; |
||||||
|
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08); |
||||||
|
border: 1px solid color-mix(in srgb, var(--outline-variant) 25%, transparent); |
||||||
|
overflow: hidden; |
||||||
|
} |
||||||
|
|
||||||
|
.login-logo__frame .material-symbols-outlined { |
||||||
|
font-size: 2.25rem; |
||||||
|
color: var(--primary); |
||||||
|
} |
||||||
|
|
||||||
|
.login-page h1 { |
||||||
|
font-size: 1.5rem; |
||||||
|
font-weight: 800; |
||||||
|
color: var(--primary); |
||||||
|
margin: 0 0 0.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
.login-subtitle { |
||||||
|
color: var(--secondary); |
||||||
|
font-size: 0.875rem; |
||||||
|
margin: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.login-card { |
||||||
|
background: #fff; |
||||||
|
border-radius: var(--radius-card); |
||||||
|
box-shadow: var(--shadow-card); |
||||||
|
padding: 1.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
/* Form controls */ |
||||||
|
.form-label { |
||||||
|
display: block; |
||||||
|
font-size: 0.9rem; |
||||||
|
font-weight: 500; |
||||||
|
color: var(--on-surface); |
||||||
|
margin-bottom: 0.35rem; |
||||||
|
} |
||||||
|
|
||||||
|
.form-input { |
||||||
|
width: 100%; |
||||||
|
padding: 11px 13px; |
||||||
|
border: 1.5px solid var(--outline-variant); |
||||||
|
border-radius: 0.75rem; |
||||||
|
font-size: 15px; |
||||||
|
font-family: inherit; |
||||||
|
outline: none; |
||||||
|
background: var(--surface-container-low); |
||||||
|
color: var(--on-surface); |
||||||
|
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s; |
||||||
|
} |
||||||
|
|
||||||
|
.form-input::placeholder { |
||||||
|
color: var(--on-surface-variant); |
||||||
|
opacity: 0.7; |
||||||
|
} |
||||||
|
|
||||||
|
.form-input:focus { |
||||||
|
border-color: var(--primary); |
||||||
|
box-shadow: 0 0 0 3px rgba(0, 113, 104, 0.12); |
||||||
|
background: #fff; |
||||||
|
} |
||||||
|
|
||||||
|
.form-field { |
||||||
|
margin-bottom: 0.75rem; |
||||||
|
} |
||||||
|
|
||||||
|
/* Buttons */ |
||||||
|
.btn { |
||||||
|
font-family: inherit; |
||||||
|
font-size: 0.9375rem; |
||||||
|
font-weight: 600; |
||||||
|
padding: 0.55rem 1.1rem; |
||||||
|
border-radius: 0.75rem; |
||||||
|
border: 1.5px solid transparent; |
||||||
|
cursor: pointer; |
||||||
|
transition: background 0.15s, color 0.15s, border-color 0.15s, box-shadow 0.15s; |
||||||
|
} |
||||||
|
|
||||||
|
.btn-primary { |
||||||
|
background: var(--primary); |
||||||
|
color: var(--on-primary); |
||||||
|
width: 100%; |
||||||
|
padding-top: 0.65rem; |
||||||
|
padding-bottom: 0.65rem; |
||||||
|
margin-top: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.btn-primary:hover { |
||||||
|
background: #00645b; |
||||||
|
filter: none; |
||||||
|
} |
||||||
|
|
||||||
|
.btn-primary:active { |
||||||
|
transform: scale(0.99); |
||||||
|
} |
||||||
|
|
||||||
|
.btn-ghost { |
||||||
|
background: transparent; |
||||||
|
color: var(--primary); |
||||||
|
border-color: color-mix(in srgb, var(--outline-variant) 50%, transparent); |
||||||
|
} |
||||||
|
|
||||||
|
.btn-ghost:hover { |
||||||
|
background: var(--surface-container); |
||||||
|
border-color: var(--primary); |
||||||
|
} |
||||||
|
|
||||||
|
.btn-ghost:disabled, |
||||||
|
.btn-primary:disabled { |
||||||
|
opacity: 0.5; |
||||||
|
cursor: not-allowed; |
||||||
|
} |
||||||
|
|
||||||
|
.btn--sm { |
||||||
|
font-size: 0.8rem; |
||||||
|
padding: 0.35rem 0.6rem; |
||||||
|
border-radius: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
/* --- App shell (cabinet/base) --- */ |
||||||
|
.cabinet-app { |
||||||
|
min-height: 100dvh; |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
background: var(--surface); |
||||||
|
} |
||||||
|
|
||||||
|
.cabinet-page--center { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
min-height: 100dvh; |
||||||
|
padding: 1.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.cabinet-header { |
||||||
|
position: sticky; |
||||||
|
top: 0; |
||||||
|
z-index: 20; |
||||||
|
background: color-mix(in srgb, var(--surface) 88%, transparent); |
||||||
|
backdrop-filter: blur(10px); |
||||||
|
-webkit-backdrop-filter: blur(10px); |
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent); |
||||||
|
} |
||||||
|
|
||||||
|
.cabinet-header__inner { |
||||||
|
max-width: var(--max-content); |
||||||
|
margin: 0 auto; |
||||||
|
padding: 0.75rem 1.25rem; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: space-between; |
||||||
|
gap: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.cabinet-brand { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
gap: 0.65rem; |
||||||
|
color: var(--on-surface); |
||||||
|
text-decoration: none; |
||||||
|
min-width: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.cabinet-brand:hover { |
||||||
|
text-decoration: none; |
||||||
|
color: var(--on-surface); |
||||||
|
} |
||||||
|
|
||||||
|
.cabinet-brand__icon { |
||||||
|
font-size: 1.75rem; |
||||||
|
color: var(--primary); |
||||||
|
background: var(--surface-container-low); |
||||||
|
border-radius: 0.75rem; |
||||||
|
padding: 0.35rem; |
||||||
|
border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent); |
||||||
|
flex-shrink: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.cabinet-brand__title { |
||||||
|
font-family: 'Manrope', 'Inter', sans-serif; |
||||||
|
font-weight: 800; |
||||||
|
font-size: 1rem; |
||||||
|
line-height: 1.2; |
||||||
|
letter-spacing: -0.02em; |
||||||
|
} |
||||||
|
|
||||||
|
.cabinet-brand__subtitle { |
||||||
|
font-size: 0.7rem; |
||||||
|
color: var(--secondary); |
||||||
|
text-transform: uppercase; |
||||||
|
letter-spacing: 0.04em; |
||||||
|
font-weight: 600; |
||||||
|
} |
||||||
|
|
||||||
|
.cabinet-header__actions { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
gap: 0.75rem; |
||||||
|
flex-shrink: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.cabinet-user { |
||||||
|
font-size: 0.8rem; |
||||||
|
color: var(--on-surface-variant); |
||||||
|
text-align: right; |
||||||
|
max-width: 12rem; |
||||||
|
overflow: hidden; |
||||||
|
text-overflow: ellipsis; |
||||||
|
white-space: nowrap; |
||||||
|
display: none; |
||||||
|
} |
||||||
|
|
||||||
|
@media (min-width: 480px) { |
||||||
|
.cabinet-user { |
||||||
|
display: inline; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.cabinet-user__role { |
||||||
|
color: var(--secondary); |
||||||
|
font-weight: 500; |
||||||
|
} |
||||||
|
|
||||||
|
.cabinet-main { |
||||||
|
flex: 1; |
||||||
|
max-width: var(--max-content); |
||||||
|
width: 100%; |
||||||
|
margin: 0 auto; |
||||||
|
padding: 1.25rem 1.25rem 2.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
/* Cards & lists */ |
||||||
|
.surface-card { |
||||||
|
background: var(--surface); |
||||||
|
border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent); |
||||||
|
border-radius: 1rem; |
||||||
|
padding: 1rem 1.1rem; |
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); |
||||||
|
} |
||||||
|
|
||||||
|
.list-stack { |
||||||
|
list-style: none; |
||||||
|
margin: 0; |
||||||
|
padding: 0; |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.list-row { |
||||||
|
display: block; |
||||||
|
border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent); |
||||||
|
border-radius: 1rem; |
||||||
|
padding: 0.9rem 1rem; |
||||||
|
background: var(--surface); |
||||||
|
transition: border-color 0.15s, box-shadow 0.15s; |
||||||
|
} |
||||||
|
|
||||||
|
.list-row:hover { |
||||||
|
border-color: color-mix(in srgb, var(--primary) 35%, var(--outline-variant)); |
||||||
|
box-shadow: 0 2px 12px rgba(0, 113, 104, 0.08); |
||||||
|
} |
||||||
|
|
||||||
|
.list-row a { |
||||||
|
text-decoration: none; |
||||||
|
color: var(--on-surface); |
||||||
|
font-weight: 600; |
||||||
|
} |
||||||
|
|
||||||
|
.list-row a:hover { |
||||||
|
color: var(--primary); |
||||||
|
text-decoration: none; |
||||||
|
} |
||||||
|
|
||||||
|
.list-row__meta { |
||||||
|
color: var(--secondary); |
||||||
|
font-size: 0.8rem; |
||||||
|
display: block; |
||||||
|
margin-top: 0.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
/* Вся плитка — одна ссылка */ |
||||||
|
.list-row--action { |
||||||
|
padding: 0; |
||||||
|
overflow: hidden; |
||||||
|
} |
||||||
|
|
||||||
|
.list-row--action .list-row__link { |
||||||
|
display: block; |
||||||
|
padding: 0.9rem 1rem; |
||||||
|
text-decoration: none; |
||||||
|
color: inherit; |
||||||
|
outline-offset: 2px; |
||||||
|
border-radius: inherit; |
||||||
|
transition: background 0.12s ease; |
||||||
|
} |
||||||
|
|
||||||
|
.list-row--action .list-row__link:hover { |
||||||
|
background: color-mix(in srgb, var(--primary) 6%, transparent); |
||||||
|
text-decoration: none; |
||||||
|
} |
||||||
|
|
||||||
|
.list-row--action .list-row__title { |
||||||
|
display: block; |
||||||
|
color: var(--on-surface); |
||||||
|
font-weight: 600; |
||||||
|
} |
||||||
|
|
||||||
|
/* Список: слева ссылка на карточку, справа «Пройти» */ |
||||||
|
.list-row--split { |
||||||
|
display: flex; |
||||||
|
flex-direction: row; |
||||||
|
align-items: stretch; |
||||||
|
padding: 0; |
||||||
|
overflow: hidden; |
||||||
|
gap: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.list-row--split .list-row__main { |
||||||
|
flex: 1; |
||||||
|
min-width: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.list-row--split .list-row__link { |
||||||
|
display: block; |
||||||
|
padding: 0.9rem 1rem; |
||||||
|
text-decoration: none; |
||||||
|
color: inherit; |
||||||
|
outline-offset: 2px; |
||||||
|
border-radius: 0.85rem 0 0 0.85rem; |
||||||
|
transition: background 0.12s ease; |
||||||
|
} |
||||||
|
|
||||||
|
.list-row--split .list-row__link:hover { |
||||||
|
background: color-mix(in srgb, var(--primary) 6%, transparent); |
||||||
|
text-decoration: none; |
||||||
|
} |
||||||
|
|
||||||
|
.list-row--split .list-row__side { |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
padding: 0.5rem 0.9rem 0.5rem 0; |
||||||
|
flex-shrink: 0; |
||||||
|
border-left: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent); |
||||||
|
} |
||||||
|
|
||||||
|
.list-row--split .list-row__title { |
||||||
|
display: block; |
||||||
|
color: var(--on-surface); |
||||||
|
font-weight: 600; |
||||||
|
} |
||||||
|
|
||||||
|
@media (max-width: 520px) { |
||||||
|
.list-row--split { |
||||||
|
flex-wrap: wrap; |
||||||
|
} |
||||||
|
|
||||||
|
.list-row--split .list-row__link { |
||||||
|
border-radius: 0.85rem 0.85rem 0 0; |
||||||
|
} |
||||||
|
|
||||||
|
.list-row--split .list-row__side { |
||||||
|
width: 100%; |
||||||
|
justify-content: flex-end; |
||||||
|
border-left: none; |
||||||
|
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent); |
||||||
|
padding: 0.5rem 0.9rem 0.75rem; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/* Карточка теста: визуальные блоки + сворачивание (удобно на узком экране) */ |
||||||
|
.test-detail-page { |
||||||
|
max-width: var(--max-content, 42rem); |
||||||
|
margin: 0 auto; |
||||||
|
padding-bottom: 1.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.cabinet-brick { |
||||||
|
margin-bottom: 1.1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.cabinet-brick--hero { |
||||||
|
padding: 0.1rem 0 0.2rem; |
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--outline-variant) 45%, transparent); |
||||||
|
margin-bottom: 1.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
.cabinet-disclosure { |
||||||
|
border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent); |
||||||
|
border-radius: 1rem; |
||||||
|
background: var(--surface); |
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); |
||||||
|
} |
||||||
|
|
||||||
|
.cabinet-disclosure__summary { |
||||||
|
cursor: pointer; |
||||||
|
list-style: none; |
||||||
|
user-select: none; |
||||||
|
padding: 0.85rem 1rem 0.75rem; |
||||||
|
font-size: 1.05rem; |
||||||
|
border-radius: 1rem 1rem 0 0; |
||||||
|
min-height: 2.75rem; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
} |
||||||
|
|
||||||
|
.cabinet-disclosure__summary::-webkit-details-marker { |
||||||
|
display: none; |
||||||
|
} |
||||||
|
|
||||||
|
.cabinet-disclosure__summary::after { |
||||||
|
content: 'expand_more'; |
||||||
|
font-family: 'Material Symbols Outlined', sans-serif; |
||||||
|
margin-left: auto; |
||||||
|
font-size: 1.25rem; |
||||||
|
opacity: 0.55; |
||||||
|
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24; |
||||||
|
transition: transform 0.2s ease; |
||||||
|
} |
||||||
|
|
||||||
|
.cabinet-disclosure[open] .cabinet-disclosure__summary::after { |
||||||
|
transform: rotate(180deg); |
||||||
|
} |
||||||
|
|
||||||
|
.cabinet-disclosure__body { |
||||||
|
padding: 0 1rem 1.05rem; |
||||||
|
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent); |
||||||
|
} |
||||||
|
|
||||||
|
.cabinet-disclosure[open] .cabinet-disclosure__summary { |
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--outline-variant) 25%, transparent); |
||||||
|
} |
||||||
|
|
||||||
|
/* Назначение: поиск + список */ |
||||||
|
.assign-toolbar { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 0.5rem; |
||||||
|
margin-bottom: 0.65rem; |
||||||
|
} |
||||||
|
|
||||||
|
@media (min-width: 520px) { |
||||||
|
.assign-toolbar { |
||||||
|
flex-direction: row; |
||||||
|
flex-wrap: wrap; |
||||||
|
align-items: center; |
||||||
|
} |
||||||
|
|
||||||
|
.assign-toolbar .form-input { |
||||||
|
flex: 1 1 160px; |
||||||
|
min-width: 0; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.assign-toolbar__search { |
||||||
|
flex: 1 1 200px; |
||||||
|
} |
||||||
|
|
||||||
|
.assign-list { |
||||||
|
max-height: min(50vh, 22rem); |
||||||
|
overflow: auto; |
||||||
|
border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent); |
||||||
|
border-radius: 0.75rem; |
||||||
|
background: var(--surface-container-low); |
||||||
|
-webkit-overflow-scrolling: touch; |
||||||
|
} |
||||||
|
|
||||||
|
.assign-row { |
||||||
|
display: flex; |
||||||
|
gap: 0.5rem; |
||||||
|
padding: 0.65rem 0.75rem; |
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent); |
||||||
|
cursor: pointer; |
||||||
|
align-items: flex-start; |
||||||
|
} |
||||||
|
|
||||||
|
.assign-row:last-child { |
||||||
|
border-bottom: none; |
||||||
|
} |
||||||
|
|
||||||
|
.assign-row--selected, |
||||||
|
.assign-row:hover { |
||||||
|
background: color-mix(in srgb, var(--primary) 8%, transparent); |
||||||
|
} |
||||||
|
|
||||||
|
.assign-row__text { |
||||||
|
display: flex; |
||||||
|
flex-direction: column; |
||||||
|
gap: 0.2rem; |
||||||
|
min-width: 0; |
||||||
|
flex: 1; |
||||||
|
} |
||||||
|
|
||||||
|
.assign-row__fio { |
||||||
|
font-weight: 600; |
||||||
|
color: var(--on-surface); |
||||||
|
font-size: 0.95rem; |
||||||
|
word-break: break-word; |
||||||
|
} |
||||||
|
|
||||||
|
.assign-row__login { |
||||||
|
font-size: 0.8rem; |
||||||
|
color: var(--secondary); |
||||||
|
font-family: ui-monospace, Menlo, monospace; |
||||||
|
} |
||||||
|
|
||||||
|
.assign-row__meta { |
||||||
|
font-size: 0.8rem; |
||||||
|
color: var(--secondary); |
||||||
|
line-height: 1.35; |
||||||
|
} |
||||||
|
|
||||||
|
.create-row { |
||||||
|
display: flex; |
||||||
|
flex-wrap: wrap; |
||||||
|
gap: 0.5rem; |
||||||
|
margin: 1.25rem 0; |
||||||
|
align-items: center; |
||||||
|
} |
||||||
|
|
||||||
|
.create-row .form-input { |
||||||
|
flex: 1 1 12rem; |
||||||
|
min-width: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.create-row .btn { |
||||||
|
width: auto; |
||||||
|
flex: 0 0 auto; |
||||||
|
} |
||||||
|
|
||||||
|
.callout { |
||||||
|
border-radius: 1rem; |
||||||
|
padding: 0.75rem 1rem; |
||||||
|
font-size: 0.9rem; |
||||||
|
font-weight: 500; |
||||||
|
margin: 0 0 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.callout--warning { |
||||||
|
background: #fffbeb; |
||||||
|
border: 1px solid #fde68a; |
||||||
|
color: #92400e; |
||||||
|
} |
||||||
|
|
||||||
|
.callout--error { |
||||||
|
background: #fff5f5; |
||||||
|
border: 1px solid #fecaca; |
||||||
|
color: #991b1b; |
||||||
|
} |
||||||
|
|
||||||
|
.callout--success { |
||||||
|
background: #ecfdf5; |
||||||
|
border: 1px solid #a7f3d0; |
||||||
|
color: #047857; |
||||||
|
} |
||||||
|
|
||||||
|
.error-text { |
||||||
|
color: var(--error); |
||||||
|
font-size: 0.9rem; |
||||||
|
margin: 0.5rem 0 0; |
||||||
|
} |
||||||
|
|
||||||
|
.link-back { |
||||||
|
display: inline-flex; |
||||||
|
align-items: center; |
||||||
|
gap: 0.25rem; |
||||||
|
font-size: 0.9rem; |
||||||
|
font-weight: 500; |
||||||
|
margin: 0 0 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
/* Table (detail) */ |
||||||
|
.table-cabinet { |
||||||
|
width: 100%; |
||||||
|
border-collapse: separate; |
||||||
|
border-spacing: 0; |
||||||
|
font-size: 0.875rem; |
||||||
|
} |
||||||
|
|
||||||
|
.table-cabinet th, |
||||||
|
.table-cabinet td { |
||||||
|
padding: 0.5rem 0.6rem; |
||||||
|
text-align: left; |
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--outline-variant) 40%, transparent); |
||||||
|
vertical-align: top; |
||||||
|
} |
||||||
|
|
||||||
|
.table-cabinet th { |
||||||
|
color: var(--secondary); |
||||||
|
font-weight: 600; |
||||||
|
font-size: 0.75rem; |
||||||
|
text-transform: uppercase; |
||||||
|
letter-spacing: 0.03em; |
||||||
|
} |
||||||
|
|
||||||
|
.table-cabinet tr:last-child td { |
||||||
|
border-bottom: none; |
||||||
|
} |
||||||
|
|
||||||
|
.table-cabinet .mono { |
||||||
|
font-size: 0.75rem; |
||||||
|
word-break: break-all; |
||||||
|
color: var(--on-surface-variant); |
||||||
|
} |
||||||
|
|
||||||
|
.draft-block { |
||||||
|
margin-top: 1.25rem; |
||||||
|
padding: 1rem; |
||||||
|
background: var(--surface-container); |
||||||
|
border-radius: 1rem; |
||||||
|
border: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent); |
||||||
|
} |
||||||
|
|
||||||
|
.draft-block .form-input { |
||||||
|
margin-top: 0.25rem; |
||||||
|
} |
||||||
|
|
||||||
|
.muted { |
||||||
|
color: var(--secondary); |
||||||
|
font-size: 0.875rem; |
||||||
|
} |
||||||
|
|
||||||
|
.inline-actions { |
||||||
|
display: flex; |
||||||
|
flex-wrap: wrap; |
||||||
|
align-items: center; |
||||||
|
gap: 0.75rem; |
||||||
|
margin-top: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.inline-actions .btn { |
||||||
|
width: auto; |
||||||
|
} |
||||||
Loading…
Reference in new issue