feat: полный бэк и фронт (попытки, разбор, импорт, ИИ, назначения)
- Сервисы: 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: Cursor
This commit is contained in:
@@ -129,7 +129,7 @@
|
|||||||
4. Миграции: из каталога `backend/`: `npm run migrate`, затем `npm start` (и фронт из `frontend/` — `npm run dev`).
|
4. Миграции: из каталога `backend/`: `npm run migrate`, затем `npm start` (и фронт из `frontend/` — `npm run dev`).
|
||||||
|
|
||||||
**Docker (UI + API + общий Postgres):** поднять `Postgres_TG_Bots` (сеть `hr_postgres_dev_net`), создать БД `clinic_tests`, затем из корня `TestingWebApp`:
|
**Docker (UI + API + общий Postgres):** поднять `Postgres_TG_Bots` (сеть `hr_postgres_dev_net`), создать БД `clinic_tests`, затем из корня `TestingWebApp`:
|
||||||
`docker compose -f docker-compose.dev.yml up --build` — интерфейс **http://localhost:8080** (Nginx проксирует `/api` в backend), API с хоста **http://localhost:3002** (внутри сети контейнера `3001`; см. [docker-compose.dev.yml](docker-compose.dev.yml), миграции в entrypoint). В БД `clinic_tests` для локального логина нужен активный `users` с bcrypt-паролем, либо включите `HR_AUTH=1` + `HR_DATABASE_URL` в compose/`.env` (см. `backend/.env.example`).
|
`docker compose -f docker-compose.dev.yml up --build` — интерфейс **http://localhost:8080** (Nginx проксирует `/api` в backend), API с хоста **http://localhost:3002** (внутри сети контейнера `3107`; см. [docker-compose.dev.yml](docker-compose.dev.yml), миграции в entrypoint). В БД `clinic_tests` для локального логина нужен активный `users` с bcrypt-паролем, либо включите `HR_AUTH=1` + `HR_DATABASE_URL` в compose/`.env` (см. `backend/.env.example`).
|
||||||
|
|
||||||
`docker compose -f docker-compose.dev.yml down` — остановка.
|
`docker compose -f docker-compose.dev.yml down` — остановка.
|
||||||
|
|
||||||
|
|||||||
+15
-1
@@ -24,6 +24,20 @@ DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/clinic_tests
|
|||||||
|
|
||||||
JWT_SECRET=change_me_in_production
|
JWT_SECRET=change_me_in_production
|
||||||
|
|
||||||
# A.1: HR login (Werkzeug password, staff by web_login)
|
# A.1: HR login (Werkzeug password, staff by web_login = username в public.users)
|
||||||
|
# В Docker (docker-compose.dev.yml) по умолчанию HR_AUTH=1 и HR_DATABASE_URL на hr_bot_test.
|
||||||
# HR_AUTH=1
|
# HR_AUTH=1
|
||||||
# HR_DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/hr_bot_test
|
# HR_DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/hr_bot_test
|
||||||
|
|
||||||
|
# V.8: API/UI назначения (POST /api/tests/:id/assign, каталог в карточке). В NODE_ENV=development
|
||||||
|
# включено без этого флага. В production: CLINIC_ASSIGNMENT_ENABLED=1
|
||||||
|
# CLINIC_ASSIGNMENT_ENABLED=1
|
||||||
|
|
||||||
|
# D.3 — генерация черновика из импорта (POST /api/tests/import/document), OpenAI-совместимый API
|
||||||
|
# DEEPSEEK_API_KEY= → по умолчанию https://api.deepseek.com/v1, модель deepseek-chat
|
||||||
|
# OPENAI_API_KEY= → https://api.openai.com/v1, модель gpt-4o-mini (если нет ключа DeepSeek)
|
||||||
|
# LLM_BASE_URL= → переопределить (без /chat/completions)
|
||||||
|
# LLM_MODEL=
|
||||||
|
# LLM_NO_JSON=1 → убрать response_format, если API не принимает json_object
|
||||||
|
# DEEPSEEK_API_KEY=
|
||||||
|
# OPENAI_API_KEY=
|
||||||
|
|||||||
+1
-1
@@ -3,6 +3,6 @@ WORKDIR /app
|
|||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY . .
|
COPY . .
|
||||||
EXPOSE 3001
|
EXPOSE 3107
|
||||||
RUN chmod +x docker-entrypoint.sh
|
RUN chmod +x docker-entrypoint.sh
|
||||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||||
|
|||||||
Generated
+634
-400
File diff suppressed because it is too large
Load Diff
@@ -6,8 +6,9 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/server.js",
|
"start": "node src/server.js",
|
||||||
"dev": "node --watch src/server.js",
|
"dev": "NODE_ENV=development node --watch src/server.js",
|
||||||
"test": "node --test 'src/**/*.test.js'",
|
"test": "node --test 'src/**/*.test.js'",
|
||||||
|
"test:integration": "CLINIC_TESTS_INTEGRATION=1 node --test 'src/**/*.test.js'",
|
||||||
"migrate": "node src/db/migrate.js",
|
"migrate": "node src/db/migrate.js",
|
||||||
"lint": "eslint src/",
|
"lint": "eslint src/",
|
||||||
"lint:fix": "eslint src/ --fix",
|
"lint:fix": "eslint src/ --fix",
|
||||||
@@ -17,17 +18,20 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcrypt": "^5.1.1",
|
"bcryptjs": "^3.0.3",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.21.0",
|
"express": "^4.21.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"mammoth": "^1.12.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"pdf-parse": "^2.4.5",
|
||||||
"pg": "^8.12.0"
|
"pg": "^8.12.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"prettier": "^3.3.3"
|
"prettier": "^3.3.3",
|
||||||
|
"supertest": "^7.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
+8
-3
@@ -4,6 +4,7 @@ import cookieParser from 'cookie-parser';
|
|||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import authRoutes from './routes/auth.js';
|
import authRoutes from './routes/auth.js';
|
||||||
import testsRoutes from './routes/tests.js';
|
import testsRoutes from './routes/tests.js';
|
||||||
|
import { RU } from './messages/ru.js';
|
||||||
|
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
@@ -14,7 +15,11 @@ export function createApp() {
|
|||||||
? process.env.FRONTEND_URL
|
? process.env.FRONTEND_URL
|
||||||
? [process.env.FRONTEND_URL]
|
? [process.env.FRONTEND_URL]
|
||||||
: []
|
: []
|
||||||
: ['http://localhost:5173', 'http://localhost:3000'];
|
: [
|
||||||
|
'http://localhost:5173',
|
||||||
|
'http://localhost:3000',
|
||||||
|
'http://localhost:8080',
|
||||||
|
];
|
||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
origin: corsOrigins.length ? corsOrigins : true,
|
origin: corsOrigins.length ? corsOrigins : true,
|
||||||
@@ -35,11 +40,11 @@ export function createApp() {
|
|||||||
app.use((err, req, res, _next) => {
|
app.use((err, req, res, _next) => {
|
||||||
console.error('Error:', err);
|
console.error('Error:', err);
|
||||||
res.status(err.status || 500).json({
|
res.status(err.status || 500).json({
|
||||||
error: err.message || 'Internal Server Error',
|
error: err.message || RU.internal,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
app.use((req, res) => {
|
app.use((req, res) => {
|
||||||
res.status(404).json({ error: 'Not found' });
|
res.status(404).json({ error: RU.notFound });
|
||||||
});
|
});
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: 'Выбран вариант ответа, не относящийся к вопросу.',
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { verifyToken } from '../utils/auth.js';
|
import { verifyToken } from '../utils/auth.js';
|
||||||
import { query } from '../db/db.js';
|
import { query } from '../db/db.js';
|
||||||
|
import { RU } from '../messages/ru.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract token from cookie
|
* Extract token from cookie
|
||||||
@@ -24,13 +25,13 @@ export async function authenticate(req, res, next) {
|
|||||||
const token = getTokenFromCookie(req);
|
const token = getTokenFromCookie(req);
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return res.status(401).json({ error: 'Authentication required' });
|
return res.status(401).json({ error: RU.authRequired });
|
||||||
}
|
}
|
||||||
|
|
||||||
const decoded = verifyToken(token);
|
const decoded = verifyToken(token);
|
||||||
|
|
||||||
if (!decoded) {
|
if (!decoded) {
|
||||||
return res.status(401).json({ error: 'Invalid or expired token' });
|
return res.status(401).json({ error: RU.tokenInvalid });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await query(
|
const result = await query(
|
||||||
@@ -39,7 +40,7 @@ export async function authenticate(req, res, next) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result.rows.length === 0) {
|
if (result.rows.length === 0) {
|
||||||
return res.status(401).json({ error: 'User not found' });
|
return res.status(401).json({ error: RU.userNotFound });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = result.rows[0];
|
const user = result.rows[0];
|
||||||
@@ -59,7 +60,7 @@ export async function authenticate(req, res, next) {
|
|||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Auth middleware error:', error);
|
console.error('Auth middleware error:', error);
|
||||||
return res.status(500).json({ error: 'Authentication error' });
|
return res.status(500).json({ error: RU.authError });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,11 +74,11 @@ export function requireRole(roles) {
|
|||||||
|
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
return res.status(401).json({ error: 'Authentication required' });
|
return res.status(401).json({ error: RU.authRequired });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!allowedRoles.includes(req.user.role)) {
|
if (!allowedRoles.includes(req.user.role)) {
|
||||||
return res.status(403).json({ error: 'Insufficient permissions' });
|
return res.status(403).json({ error: RU.insufficientPermissions });
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
@@ -93,7 +94,7 @@ export function requireRole(roles) {
|
|||||||
export function requireDepartment(departmentId) {
|
export function requireDepartment(departmentId) {
|
||||||
return (req, res, next) => {
|
return (req, res, next) => {
|
||||||
if (!req.user) {
|
if (!req.user) {
|
||||||
return res.status(401).json({ error: 'Authentication required' });
|
return res.status(401).json({ error: RU.authRequired });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Admins can access all departments
|
// Admins can access all departments
|
||||||
@@ -103,7 +104,7 @@ export function requireDepartment(departmentId) {
|
|||||||
|
|
||||||
// Managers can only access their department
|
// Managers can only access their department
|
||||||
if (req.user.role === 'manager' && req.user.departmentId !== departmentId) {
|
if (req.user.role === 'manager' && req.user.departmentId !== departmentId) {
|
||||||
return res.status(403).json({ error: 'Access denied to this department' });
|
return res.status(403).json({ error: RU.departmentAccessDenied });
|
||||||
}
|
}
|
||||||
|
|
||||||
next();
|
next();
|
||||||
|
|||||||
+49
-16
@@ -11,6 +11,12 @@ import {
|
|||||||
isHrAuthEnabled,
|
isHrAuthEnabled,
|
||||||
HR_MANAGED_PASSWORD_PLACEHOLDER,
|
HR_MANAGED_PASSWORD_PLACEHOLDER,
|
||||||
} from '../config/authConstants.js';
|
} from '../config/authConstants.js';
|
||||||
|
import { RU } from '../messages/ru.js';
|
||||||
|
import {
|
||||||
|
getAssignmentDirectory,
|
||||||
|
getHrDepartmentNames,
|
||||||
|
} from '../services/assignmentDirectoryService.js';
|
||||||
|
import { isAssignmentFeatureEnabled } from '../config/featureFlags.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -18,12 +24,12 @@ router.post('/login', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { login, password } = req.body;
|
const { login, password } = req.body;
|
||||||
if (!login || !password) {
|
if (!login || !password) {
|
||||||
return res.status(400).json({ error: 'Login and password are required' });
|
return res.status(400).json({ error: RU.loginAndPasswordRequired });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHrAuthEnabled()) {
|
if (isHrAuthEnabled()) {
|
||||||
if (!getHrPool()) {
|
if (!getHrPool()) {
|
||||||
return res.status(500).json({ error: 'HR_DATABASE_URL is not set' });
|
return res.status(500).json({ error: RU.hrDatabaseUrlMissing });
|
||||||
}
|
}
|
||||||
const u = await queryHr(
|
const u = await queryHr(
|
||||||
`SELECT id, username, password_hash, role
|
`SELECT id, username, password_hash, role
|
||||||
@@ -32,12 +38,12 @@ router.post('/login', async (req, res) => {
|
|||||||
[login]
|
[login]
|
||||||
);
|
);
|
||||||
if (u.rows.length === 0 || !u.rows[0].password_hash) {
|
if (u.rows.length === 0 || !u.rows[0].password_hash) {
|
||||||
return res.status(401).json({ error: 'Invalid credentials' });
|
return res.status(401).json({ error: RU.invalidCredentials });
|
||||||
}
|
}
|
||||||
const row = u.rows[0];
|
const row = u.rows[0];
|
||||||
const ok = await comparePassword(password, row.password_hash);
|
const ok = await comparePassword(password, row.password_hash);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
return res.status(401).json({ error: 'Invalid credentials' });
|
return res.status(401).json({ error: RU.invalidCredentials });
|
||||||
}
|
}
|
||||||
const s = await queryHr(
|
const s = await queryHr(
|
||||||
`SELECT id, fio FROM staff_members
|
`SELECT id, fio FROM staff_members
|
||||||
@@ -45,9 +51,7 @@ router.post('/login', async (req, res) => {
|
|||||||
[login]
|
[login]
|
||||||
);
|
);
|
||||||
if (s.rows.length === 0) {
|
if (s.rows.length === 0) {
|
||||||
return res
|
return res.status(403).json({ error: RU.noStaffForLogin });
|
||||||
.status(403)
|
|
||||||
.json({ error: 'No staff link for this login (web_login)' });
|
|
||||||
}
|
}
|
||||||
const staffId = s.rows[0].id;
|
const staffId = s.rows[0].id;
|
||||||
const fio = s.rows[0].fio || login;
|
const fio = s.rows[0].fio || login;
|
||||||
@@ -93,15 +97,15 @@ router.post('/login', async (req, res) => {
|
|||||||
[login]
|
[login]
|
||||||
);
|
);
|
||||||
if (result.rows.length === 0) {
|
if (result.rows.length === 0) {
|
||||||
return res.status(401).json({ error: 'Invalid credentials' });
|
return res.status(401).json({ error: RU.invalidCredentials });
|
||||||
}
|
}
|
||||||
const user = result.rows[0];
|
const user = result.rows[0];
|
||||||
if (user.password_hash === HR_MANAGED_PASSWORD_PLACEHOLDER) {
|
if (user.password_hash === HR_MANAGED_PASSWORD_PLACEHOLDER) {
|
||||||
return res.status(401).json({ error: 'Use HR login' });
|
return res.status(401).json({ error: RU.useHrLogin });
|
||||||
}
|
}
|
||||||
const isValidPassword = await comparePassword(password, user.password_hash);
|
const isValidPassword = await comparePassword(password, user.password_hash);
|
||||||
if (!isValidPassword) {
|
if (!isValidPassword) {
|
||||||
return res.status(401).json({ error: 'Invalid credentials' });
|
return res.status(401).json({ error: RU.invalidCredentials });
|
||||||
}
|
}
|
||||||
const token = generateToken(user.id, user.role, user.department_id);
|
const token = generateToken(user.id, user.role, user.department_id);
|
||||||
res.cookie('token', token, {
|
res.cookie('token', token, {
|
||||||
@@ -122,10 +126,10 @@ router.post('/login', async (req, res) => {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.message?.includes('HR database not configured')) {
|
if (error.message?.includes('HR database not configured')) {
|
||||||
return res.status(500).json({ error: 'HR database not configured' });
|
return res.status(500).json({ error: RU.hrDatabaseNotConfigured });
|
||||||
}
|
}
|
||||||
console.error('Login error:', error);
|
console.error('Login error:', error);
|
||||||
return res.status(500).json({ error: 'Login failed' });
|
return res.status(500).json({ error: RU.loginFailed });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,19 +140,48 @@ router.post('/logout', (req, res) => {
|
|||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production',
|
||||||
sameSite: 'strict',
|
sameSite: 'strict',
|
||||||
});
|
});
|
||||||
res.json({ message: 'Logged out successfully' });
|
res.json({ message: RU.loggedOut });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Logout error:', error);
|
console.error('Logout error:', error);
|
||||||
res.status(500).json({ error: 'Logout failed' });
|
res.status(500).json({ error: RU.logoutFailed });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/me', authenticate, async (req, res) => {
|
router.get('/me', authenticate, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
res.json({ user: req.user });
|
const devUi = process.env.NODE_ENV === 'development';
|
||||||
|
const assignmentUi = isAssignmentFeatureEnabled();
|
||||||
|
res.json({ user: req.user, devUi, assignmentUi });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get current user error:', error);
|
console.error('Get current user error:', error);
|
||||||
res.status(500).json({ error: 'Failed to get user data' });
|
res.status(500).json({ error: RU.userDataFailed });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Каталог сотрудников для назначения: HR (все) + отделы + поиск. Как `POST .../assign`: см. `isAssignmentFeatureEnabled()`.
|
||||||
|
* Query: q, department (имя отдела или __all__), clinic=all|with|without
|
||||||
|
*/
|
||||||
|
router.get('/dev/assignment-directory', authenticate, async (req, res) => {
|
||||||
|
if (!isAssignmentFeatureEnabled()) {
|
||||||
|
return res.status(404).json({ error: RU.notFound });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const q = typeof req.query.q === 'string' ? req.query.q : '';
|
||||||
|
const department = typeof req.query.department === 'string' ? req.query.department : '';
|
||||||
|
const c = req.query.clinic;
|
||||||
|
const clinicFilter =
|
||||||
|
c === 'with' || c === 'without' ? c : 'all';
|
||||||
|
const { people, source } = await getAssignmentDirectory({
|
||||||
|
q,
|
||||||
|
department,
|
||||||
|
clinicFilter,
|
||||||
|
});
|
||||||
|
const departments = await getHrDepartmentNames();
|
||||||
|
res.json({ people, source, departments });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('dev assignment directory:', error);
|
||||||
|
res.status(500).json({ error: RU.userDataFailed });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createApp } from './app.js';
|
import { createApp } from './app.js';
|
||||||
|
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const PORT = process.env.PORT || 3001;
|
const PORT = process.env.PORT || 3107;
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Server is running on port ${PORT}`);
|
console.log(`Server is running on port ${PORT}`);
|
||||||
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
|
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
* V.3 saveTestDraft, fork версии, контент вопросов.
|
* V.3 saveTestDraft, fork версии, контент вопросов.
|
||||||
*/
|
*/
|
||||||
import { hasAnyAttemptForTest } from './testChainService.js';
|
import { hasAnyAttemptForTest } from './testChainService.js';
|
||||||
|
import { RU } from '../messages/ru.js';
|
||||||
|
import { isTestAuthor } from '../config/devAuthor.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import('pg').PoolClient} client
|
* @param {import('pg').PoolClient} client
|
||||||
@@ -93,23 +95,25 @@ export async function replaceVersionContent(client, testVersionId, payload) {
|
|||||||
export async function forkNewVersion(client, testId) {
|
export async function forkNewVersion(client, testId) {
|
||||||
const av = await getActiveVersionRow(client, testId);
|
const av = await getActiveVersionRow(client, testId);
|
||||||
if (!av) {
|
if (!av) {
|
||||||
throw new Error('no active version');
|
throw new Error(RU.noActiveVersion);
|
||||||
}
|
}
|
||||||
const { rows: mx } = await client.query(
|
const { rows: mx } = await client.query(
|
||||||
`SELECT COALESCE(MAX(version), 0) AS v FROM test_versions WHERE test_id = $1`,
|
`SELECT COALESCE(MAX(version), 0) AS v FROM test_versions WHERE test_id = $1`,
|
||||||
[testId]
|
[testId]
|
||||||
);
|
);
|
||||||
const nextV = (mx[0].v || 0) + 1;
|
const nextV = (mx[0].v || 0) + 1;
|
||||||
|
// Сначала снять is_active с цепочки: частичный уникальный индекс
|
||||||
|
// uq_test_versions_one_active_per_test — не более одной true на test_id.
|
||||||
|
await client.query(
|
||||||
|
`UPDATE test_versions SET is_active = false WHERE test_id = $1`,
|
||||||
|
[testId]
|
||||||
|
);
|
||||||
const { rows: nv } = await client.query(
|
const { rows: nv } = await client.query(
|
||||||
`INSERT INTO test_versions (test_id, version, is_active, parent_id)
|
`INSERT INTO test_versions (test_id, version, is_active, parent_id)
|
||||||
VALUES ($1, $2, true, $3) RETURNING *`,
|
VALUES ($1, $2, true, $3) RETURNING *`,
|
||||||
[testId, nextV, av.id]
|
[testId, nextV, av.id]
|
||||||
);
|
);
|
||||||
const newRow = nv[0];
|
const newRow = nv[0];
|
||||||
await client.query(
|
|
||||||
`UPDATE test_versions SET is_active = false WHERE test_id = $1 AND id <> $2`,
|
|
||||||
[testId, newRow.id]
|
|
||||||
);
|
|
||||||
await copyQuestionTree(client, av.id, newRow.id);
|
await copyQuestionTree(client, av.id, newRow.id);
|
||||||
return newRow;
|
return newRow;
|
||||||
}
|
}
|
||||||
@@ -126,13 +130,13 @@ export async function saveTestDraft(pool, authorId, testId, payload) {
|
|||||||
[testId]
|
[testId]
|
||||||
);
|
);
|
||||||
if (!tr.length) {
|
if (!tr.length) {
|
||||||
const e = new Error('Test not found');
|
const e = new Error(RU.testNotFound);
|
||||||
e.status = 404;
|
e.status = 404;
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
const t = tr[0];
|
const t = tr[0];
|
||||||
if (t.created_by !== authorId) {
|
if (!isTestAuthor(t.created_by, authorId)) {
|
||||||
const e = new Error('Forbidden');
|
const e = new Error(RU.forbidden);
|
||||||
e.status = 403;
|
e.status = 403;
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
@@ -148,10 +152,20 @@ export async function saveTestDraft(pool, authorId, testId, payload) {
|
|||||||
[testId, payload.title ?? null, payload.description ?? null]
|
[testId, payload.title ?? null, payload.description ?? null]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (payload.passingThreshold !== undefined && payload.passingThreshold !== null) {
|
||||||
|
const raw = Number(payload.passingThreshold);
|
||||||
|
if (Number.isFinite(raw)) {
|
||||||
|
const pt = Math.max(0, Math.min(100, Math.round(raw)));
|
||||||
|
await client.query(
|
||||||
|
`UPDATE tests SET passing_threshold = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1`,
|
||||||
|
[testId, pt]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
const hasAttempts = await hasAnyAttemptForTest(client, testId);
|
const hasAttempts = await hasAnyAttemptForTest(client, testId);
|
||||||
let versionRow = await getActiveVersionRow(client, testId);
|
let versionRow = await getActiveVersionRow(client, testId);
|
||||||
if (!versionRow) {
|
if (!versionRow) {
|
||||||
const e = new Error('No active version');
|
const e = new Error(RU.noActiveVersion);
|
||||||
e.status = 500;
|
e.status = 500;
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* Password hashing and JWT token management
|
* Password hashing and JWT token management
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import bcrypt from 'bcrypt';
|
import { hash, compare } from 'bcryptjs';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import dotenv from 'dotenv';
|
import dotenv from 'dotenv';
|
||||||
import { checkWerkzeugPassword } from './werkzeugPassword.js';
|
import { checkWerkzeugPassword } from './werkzeugPassword.js';
|
||||||
@@ -14,7 +14,7 @@ dotenv.config();
|
|||||||
const JWT_SECRET = process.env.JWT_SECRET;
|
const JWT_SECRET = process.env.JWT_SECRET;
|
||||||
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
|
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
|
||||||
|
|
||||||
// Salt rounds for bcrypt
|
// Salt rounds (bcryptjs — тот же формат $2*, без нативной сборки — проще Docker/ARM/musl)
|
||||||
const SALT_ROUNDS = 10;
|
const SALT_ROUNDS = 10;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,7 +23,7 @@ const SALT_ROUNDS = 10;
|
|||||||
* @returns {Promise<string>} Hashed password
|
* @returns {Promise<string>} Hashed password
|
||||||
*/
|
*/
|
||||||
export async function hashPassword(password) {
|
export async function hashPassword(password) {
|
||||||
return bcrypt.hash(password, SALT_ROUNDS);
|
return hash(password, SALT_ROUNDS);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,7 +43,7 @@ export async function comparePassword(password, hash) {
|
|||||||
return checkWerkzeugPassword(hash, password);
|
return checkWerkzeugPassword(hash, password);
|
||||||
}
|
}
|
||||||
if (hash.startsWith('$2')) {
|
if (hash.startsWith('$2')) {
|
||||||
return bcrypt.compare(password, hash);
|
return compare(password, hash);
|
||||||
}
|
}
|
||||||
return checkWerkzeugPassword(hash, password);
|
return checkWerkzeugPassword(hash, password);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,15 +14,22 @@ services:
|
|||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: testing_webapp_backend
|
container_name: testing_webapp_backend
|
||||||
|
# LLM и прочие секреты из хоста (не копируются в образ — см. .dockerignore)
|
||||||
|
env_file:
|
||||||
|
- ./backend/.env
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/clinic_tests
|
DATABASE_URL: postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/clinic_tests
|
||||||
JWT_SECRET: ${JWT_SECRET:-testing_webapp_jwt_dev}
|
JWT_SECRET: ${JWT_SECRET:-testing_webapp_jwt_dev}
|
||||||
# development: httpOnly-cookie без Secure (иначе на http://localhost:8080 логин не сработает)
|
# development: httpOnly-cookie без Secure (иначе на http://localhost:8080 логин не сработает)
|
||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
FRONTEND_URL: http://localhost:8080
|
FRONTEND_URL: http://localhost:8080
|
||||||
# На хосте 3002, если 3001 занят локальным dev-сервером
|
# Вход теми же учётками, что в HR: проверка пароля в hr_bot_test + привязка сотрудника по web_login.
|
||||||
|
# Без HR_AUTH / HR_DATABASE_URL логин ищется только в clinic_tests.users (локальные dev-учётки).
|
||||||
|
HR_AUTH: ${HR_AUTH:-1}
|
||||||
|
HR_DATABASE_URL: postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/hr_bot_test
|
||||||
|
# На хосте 3002, если 3107 занят локальным dev-сервером
|
||||||
ports:
|
ports:
|
||||||
- "3002:3001"
|
- "3002:3107"
|
||||||
networks:
|
networks:
|
||||||
- app
|
- app
|
||||||
- postgres
|
- postgres
|
||||||
|
|||||||
+12
-1
@@ -4,7 +4,18 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Клинические Тесты</title>
|
<meta name="color-scheme" content="light" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Manrope:wght@600;700;800&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<title>Система тестрования</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ server {
|
|||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
client_max_body_size 10m;
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://testing-backend:3001;
|
proxy_pass http://testing-backend:3107;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
+12
-2
@@ -1,15 +1,25 @@
|
|||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import CabinetLayout from './components/CabinetLayout';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import TestsList from './pages/TestsList';
|
import TestsList from './pages/TestsList';
|
||||||
import TestDetail from './pages/TestDetail';
|
import TestDetail from './pages/TestDetail';
|
||||||
|
import TestAttempt from './pages/TestAttempt';
|
||||||
|
import TestAttemptReview from './pages/TestAttemptReview';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/tests" element={<TestsList />} />
|
<Route element={<CabinetLayout />}>
|
||||||
<Route path="/tests/:id" element={<TestDetail />} />
|
<Route path="/tests" element={<TestsList />} />
|
||||||
|
<Route path="/tests/:id/attempt/:attemptId" element={<TestAttempt />} />
|
||||||
|
<Route
|
||||||
|
path="/tests/:id/attempts/:attemptId/review"
|
||||||
|
element={<TestAttemptReview />}
|
||||||
|
/>
|
||||||
|
<Route path="/tests/:id" element={<TestDetail />} />
|
||||||
|
</Route>
|
||||||
<Route path="/" element={<Navigate to="/tests" replace />} />
|
<Route path="/" element={<Navigate to="/tests" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
+8
-4
@@ -1,12 +1,16 @@
|
|||||||
const base = '';
|
const base = '';
|
||||||
|
|
||||||
export async function api(path, opts = {}) {
|
export async function api(path, opts = {}) {
|
||||||
|
const isFormData =
|
||||||
|
typeof FormData !== 'undefined' && opts.body instanceof FormData;
|
||||||
const r = await fetch(`${base}${path}`, {
|
const r = await fetch(`${base}${path}`, {
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
headers: {
|
headers: isFormData
|
||||||
'Content-Type': 'application/json',
|
? { ...(opts.headers || {}) }
|
||||||
...(opts.headers || {}),
|
: {
|
||||||
},
|
'Content-Type': 'application/json',
|
||||||
|
...(opts.headers || {}),
|
||||||
|
},
|
||||||
...opts,
|
...opts,
|
||||||
});
|
});
|
||||||
const text = await r.text();
|
const text = await r.text();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import App from './App.jsx';
|
import App from './App.jsx';
|
||||||
|
import './styles/cabinet-theme.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
|
|||||||
@@ -23,41 +23,59 @@ export default function Login() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 360, margin: '48px auto', fontFamily: 'system-ui' }}>
|
<div className="login-page">
|
||||||
<h1>Вход</h1>
|
<div className="login-shell">
|
||||||
<p style={{ color: '#666', fontSize: 14 }}>
|
<div className="login-logo">
|
||||||
Локальный пользователь из <code>clinic_tests</code> (если HR_AUTH не
|
<div className="login-logo__frame" aria-hidden>
|
||||||
включён).
|
<span className="material-symbols-outlined">school</span>
|
||||||
</p>
|
</div>
|
||||||
<form onSubmit={onSubmit}>
|
<h1 className="font-headline">Система тестрования</h1>
|
||||||
<div style={{ marginBottom: 12 }}>
|
<p className="login-subtitle">Войдите в систему</p>
|
||||||
<label>
|
|
||||||
Логин
|
|
||||||
<br />
|
|
||||||
<input
|
|
||||||
value={login}
|
|
||||||
onChange={(e) => setLogin(e.target.value)}
|
|
||||||
style={{ width: '100%', padding: 8 }}
|
|
||||||
autoComplete="username"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginBottom: 12 }}>
|
|
||||||
<label>
|
{err && (
|
||||||
Пароль
|
<div className="callout callout--error" style={{ marginBottom: '1rem' }}>
|
||||||
<br />
|
{err}
|
||||||
<input
|
</div>
|
||||||
type="password"
|
)}
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
<div className="login-card">
|
||||||
style={{ width: '100%', padding: 8 }}
|
<form onSubmit={onSubmit} noValidate>
|
||||||
autoComplete="current-password"
|
<div className="form-field">
|
||||||
/>
|
<label className="form-label" htmlFor="login-username">
|
||||||
</label>
|
Логин
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="login-username"
|
||||||
|
className="form-input"
|
||||||
|
value={login}
|
||||||
|
onChange={(e) => setLogin(e.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="form-field">
|
||||||
|
<label className="form-label" htmlFor="login-password">
|
||||||
|
Пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="login-password"
|
||||||
|
className="form-input"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" className="btn btn-primary">
|
||||||
|
Войти
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="text-muted" style={{ marginTop: '1.25rem', marginBottom: 0 }}>
|
||||||
|
Локальный пользователь в <code className="code-inline">clinic_tests</code> (если
|
||||||
|
отключён вход через персонал HR).
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{err && <p style={{ color: 'coral' }}>{err}</p>}
|
</div>
|
||||||
<button type="submit">Войти</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</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;
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ export default defineConfig({
|
|||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3001',
|
target: 'http://localhost:3107',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user