feat(wishes): трекер пожеланий по улучшению системы
Любой авторизованный пользователь подаёт пожелание (заголовок, категория, описание); видит только свои. Админ видит все, фильтрует по статусу, ведёт по статусам (новое → запланировано → в работе → готово / отклонено) и пишет ответ автору. Автор получает уведомление при смене статуса (pushNotif). Бэкенд: миграция 080 (таблица wishes), wishController (list/create/update/remove с валидацией и whitelist категорий/статусов), routes/wishes (PATCH — только админ, DELETE — автор«новое»/админ, проверка в хендлере), смонтировано в server.js. Тесты 15/15. Фронт: страница /wishes (форма + список со статус-бейджами; у админа — фильтры, смена статуса, ответ, удаление), пункт «Пожелания» в сайдбаре (все роли), фиче-флаг feature_wishes_enabled (тумблер в админ-модулях + whitelist + FEATURE_HREFS; админ видит всегда). Клиентские врапперы LS.wish*. ⚠️ Живой БД нужен npm run migrate (080). lint:routes 0; node --check всех файлов + инлайна. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -528,7 +528,7 @@ function getFeatures(_req, res) {
|
||||
function updateFeatures(req, res) {
|
||||
const allowed = ['crossword', 'hangman', 'pet', 'red_book', 'collection',
|
||||
'flashcards', 'knowledge_map', 'board', 'biochem', 'live_quiz', 'classroom',
|
||||
'gamification', 'assistant', 'sim_builder', 'quantik', 'theory', 'lab', 'sitemap'];
|
||||
'gamification', 'assistant', 'sim_builder', 'quantik', 'theory', 'lab', 'sitemap', 'wishes'];
|
||||
const updates = req.body;
|
||||
const stmt = db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)");
|
||||
const getOld = db.prepare("SELECT value FROM app_settings WHERE key = ?");
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
'use strict';
|
||||
const db = require('../db/db');
|
||||
const { stripTags } = require('../utils/sanitize');
|
||||
const { pushNotif } = require('../utils/notifications');
|
||||
|
||||
const CATEGORIES = ['ui', 'content', 'feature', 'bug', 'other'];
|
||||
const STATUSES = ['new', 'planned', 'in_progress', 'done', 'declined'];
|
||||
const STATUS_LABEL = {
|
||||
new: 'Новое', planned: 'Запланировано', in_progress: 'В работе',
|
||||
done: 'Готово', declined: 'Отклонено',
|
||||
};
|
||||
|
||||
function clampStr(v, max) {
|
||||
return stripTags(String(v == null ? '' : v)).slice(0, max).trim();
|
||||
}
|
||||
|
||||
/* ── GET /api/wishes ── список: админ видит все (с фильтрами), остальные — свои ── */
|
||||
function list(req, res) {
|
||||
const isAdmin = req.user.role === 'admin';
|
||||
const { status, category } = req.query;
|
||||
const where = [];
|
||||
const args = [];
|
||||
if (!isAdmin) { where.push('w.user_id = ?'); args.push(req.user.id); }
|
||||
if (status && STATUSES.includes(status)) { where.push('w.status = ?'); args.push(status); }
|
||||
if (category && CATEGORIES.includes(category)) { where.push('w.category = ?'); args.push(category); }
|
||||
const sql = `
|
||||
SELECT w.id, w.user_id, w.title, w.body, w.category, w.status, w.admin_note,
|
||||
w.created_at, w.updated_at,
|
||||
${isAdmin ? 'u.name AS author_name, u.email AS author_email,' : ''}
|
||||
0 AS _pad
|
||||
FROM wishes w
|
||||
${isAdmin ? 'JOIN users u ON u.id = w.user_id' : ''}
|
||||
${where.length ? 'WHERE ' + where.join(' AND ') : ''}
|
||||
ORDER BY CASE w.status WHEN 'new' THEN 0 WHEN 'planned' THEN 1 WHEN 'in_progress' THEN 2 ELSE 3 END,
|
||||
w.updated_at DESC`;
|
||||
const rows = db.prepare(sql).all(...args);
|
||||
|
||||
let counts = null;
|
||||
if (isAdmin) {
|
||||
counts = {};
|
||||
for (const r of db.prepare('SELECT status, COUNT(*) c FROM wishes GROUP BY status').all()) counts[r.status] = r.c;
|
||||
}
|
||||
res.json({ wishes: rows, counts, isAdmin });
|
||||
}
|
||||
|
||||
/* ── POST /api/wishes ── создать (любой авторизованный) ── */
|
||||
function create(req, res) {
|
||||
const title = clampStr(req.body?.title, 200);
|
||||
if (!title) return res.status(400).json({ error: 'Заголовок обязателен' });
|
||||
const body = clampStr(req.body?.body, 4000);
|
||||
let category = String(req.body?.category || 'other');
|
||||
if (!CATEGORIES.includes(category)) category = 'other';
|
||||
|
||||
const info = db.prepare(
|
||||
`INSERT INTO wishes (user_id, title, body, category) VALUES (?,?,?,?)`
|
||||
).run(req.user.id, title, body || null, category);
|
||||
const row = db.prepare('SELECT * FROM wishes WHERE id = ?').get(Number(info.lastInsertRowid));
|
||||
res.status(201).json(row);
|
||||
}
|
||||
|
||||
/* ── PATCH /api/wishes/:id ── триаж: статус + ответ (только админ) ── */
|
||||
function update(req, res) {
|
||||
const wish = db.prepare('SELECT * FROM wishes WHERE id = ?').get(req.params.id);
|
||||
if (!wish) return res.status(404).json({ error: 'Не найдено' });
|
||||
|
||||
const fields = [];
|
||||
const args = [];
|
||||
let newStatus = null;
|
||||
if (req.body?.status !== undefined) {
|
||||
if (!STATUSES.includes(req.body.status)) return res.status(400).json({ error: 'Неверный статус' });
|
||||
if (req.body.status !== wish.status) newStatus = req.body.status;
|
||||
fields.push('status = ?'); args.push(req.body.status);
|
||||
}
|
||||
if (req.body?.admin_note !== undefined) {
|
||||
fields.push('admin_note = ?'); args.push(clampStr(req.body.admin_note, 2000) || null);
|
||||
}
|
||||
if (!fields.length) return res.status(400).json({ error: 'Нет изменений' });
|
||||
|
||||
fields.push("updated_at = datetime('now')");
|
||||
db.prepare(`UPDATE wishes SET ${fields.join(', ')} WHERE id = ?`).run(...args, wish.id);
|
||||
|
||||
// Уведомить автора при смене статуса (durable + SSE).
|
||||
if (newStatus && wish.user_id !== req.user.id) {
|
||||
try {
|
||||
pushNotif(wish.user_id, 'wish_update',
|
||||
`Ваше пожелание «${wish.title}»: ${STATUS_LABEL[newStatus] || newStatus}`, '/wishes');
|
||||
} catch { /* notif не критичен */ }
|
||||
}
|
||||
res.json(db.prepare('SELECT * FROM wishes WHERE id = ?').get(wish.id));
|
||||
}
|
||||
|
||||
/* ── DELETE /api/wishes/:id ── автор (пока «новое») или админ ── */
|
||||
function remove(req, res) {
|
||||
const wish = db.prepare('SELECT id, user_id, status FROM wishes WHERE id = ?').get(req.params.id);
|
||||
if (!wish) return res.status(404).json({ error: 'Не найдено' });
|
||||
const isAdmin = req.user.role === 'admin';
|
||||
const isOwner = wish.user_id === req.user.id;
|
||||
if (!isAdmin && !(isOwner && wish.status === 'new'))
|
||||
return res.status(403).json({ error: 'Удалять можно только своё необработанное пожелание' });
|
||||
db.prepare('DELETE FROM wishes WHERE id = ?').run(wish.id);
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
module.exports = { list, create, update, remove, CATEGORIES, STATUSES };
|
||||
@@ -0,0 +1,15 @@
|
||||
-- 080_wishes.sql — трекер пожеланий по улучшению системы.
|
||||
-- Любой пользователь подаёт пожелание; видит только свои. Админ видит все и ведёт по статусам.
|
||||
CREATE TABLE IF NOT EXISTS wishes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT,
|
||||
category TEXT NOT NULL DEFAULT 'other', -- ui | content | feature | bug | other
|
||||
status TEXT NOT NULL DEFAULT 'new', -- new | planned | in_progress | done | declined
|
||||
admin_note TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_wishes_user ON wishes(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_wishes_status ON wishes(status);
|
||||
@@ -0,0 +1,15 @@
|
||||
'use strict';
|
||||
const router = require('express').Router();
|
||||
const { authMiddleware, requireRole } = require('../middleware/auth');
|
||||
const ctrl = require('../controllers/wishController');
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
router.get('/', ctrl.list); // admin → все, остальные → свои (фильтрация в контроллере)
|
||||
router.post('/', ctrl.create); // любой авторизованный
|
||||
|
||||
// @public-by-design: PATCH — только админ; DELETE — автор(своё «новое») или админ (проверка в хендлере)
|
||||
router.patch('/:id', requireRole('admin'), ctrl.update);
|
||||
router.delete('/:id', ctrl.remove);
|
||||
|
||||
module.exports = router;
|
||||
@@ -198,6 +198,7 @@ app.use('/api/lab', labRoutes);
|
||||
app.use('/api/materials', require('./routes/materials'));
|
||||
app.use('/api/custom-sims', require('./routes/customSims'));
|
||||
app.use('/api/game', require('./routes/game'));
|
||||
app.use('/api/wishes', require('./routes/wishes'));
|
||||
app.use('/api/prep', require('./routes/prep'));
|
||||
app.use('/api/dashboard', require('./routes/dashboard'));
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
'use strict';
|
||||
/**
|
||||
* Integration tests: /api/wishes — трекер пожеланий по улучшению.
|
||||
* Covers: auth-only; создание (валидация); приватность (автор видит только свои,
|
||||
* админ — все + counts); триаж только админом (403 ученику); смена статуса; удаление
|
||||
* (автор «новое» / админ; чужое нельзя).
|
||||
*/
|
||||
const { describe, it, before, after } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { app, inject, getToken, cleanup } = require('./setup');
|
||||
|
||||
app.use('/api/wishes', require('../src/routes/wishes'));
|
||||
after(() => cleanup());
|
||||
|
||||
describe('/api/wishes', () => {
|
||||
let s1, s2, admin;
|
||||
|
||||
before(async () => {
|
||||
s1 = await getToken('student');
|
||||
s2 = await getToken('student');
|
||||
admin = await getToken('admin');
|
||||
});
|
||||
|
||||
it('POST /wishes requires auth (401)', async () => {
|
||||
const res = await inject('POST', '/api/wishes', { title: 'x' }, null);
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
it('создание: пустой заголовок → 400', async () => {
|
||||
const res = await inject('POST', '/api/wishes', { title: ' ' }, s1.token);
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
let wishId;
|
||||
it('создание пожелания учеником → 201, статус new', async () => {
|
||||
const res = await inject('POST', '/api/wishes',
|
||||
{ title: 'Тёмная тема', body: 'Хочу ночной режим', category: 'ui' }, s1.token);
|
||||
assert.equal(res.status, 201, JSON.stringify(res.body));
|
||||
assert.equal(res.body.status, 'new');
|
||||
assert.equal(res.body.category, 'ui');
|
||||
assert.equal(res.body.user_id, s1.userId);
|
||||
wishId = res.body.id;
|
||||
});
|
||||
|
||||
it('неизвестная категория → other', async () => {
|
||||
const res = await inject('POST', '/api/wishes', { title: 'Что-то', category: 'hack' }, s1.token);
|
||||
assert.equal(res.body.category, 'other');
|
||||
});
|
||||
|
||||
it('приватность: автор видит свои', async () => {
|
||||
const res = await inject('GET', '/api/wishes', null, s1.token);
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(res.body.wishes.some(w => w.id === wishId));
|
||||
assert.equal(res.body.isAdmin, false);
|
||||
});
|
||||
|
||||
it('приватность: другой ученик НЕ видит чужое', async () => {
|
||||
const res = await inject('GET', '/api/wishes', null, s2.token);
|
||||
assert.ok(!res.body.wishes.some(w => w.id === wishId));
|
||||
});
|
||||
|
||||
it('админ видит все + counts', async () => {
|
||||
const res = await inject('GET', '/api/wishes', null, admin.token);
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.isAdmin, true);
|
||||
assert.ok(res.body.wishes.some(w => w.id === wishId));
|
||||
assert.ok(res.body.counts && typeof res.body.counts.new === 'number');
|
||||
// у админа в списке есть имя автора
|
||||
const w = res.body.wishes.find(x => x.id === wishId);
|
||||
assert.ok(w.author_name);
|
||||
});
|
||||
|
||||
it('триаж учеником запрещён (403)', async () => {
|
||||
const res = await inject('PATCH', `/api/wishes/${wishId}`, { status: 'done' }, s1.token);
|
||||
assert.equal(res.status, 403);
|
||||
});
|
||||
|
||||
it('админ меняет статус + ответ → 200', async () => {
|
||||
const res = await inject('PATCH', `/api/wishes/${wishId}`,
|
||||
{ status: 'planned', admin_note: 'Запланировано на лето' }, admin.token);
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body));
|
||||
assert.equal(res.body.status, 'planned');
|
||||
assert.equal(res.body.admin_note, 'Запланировано на лето');
|
||||
});
|
||||
|
||||
it('неверный статус → 400', async () => {
|
||||
const res = await inject('PATCH', `/api/wishes/${wishId}`, { status: 'bogus' }, admin.token);
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
it('фильтр по статусу у админа', async () => {
|
||||
const res = await inject('GET', '/api/wishes?status=planned', null, admin.token);
|
||||
assert.ok(res.body.wishes.every(w => w.status === 'planned'));
|
||||
assert.ok(res.body.wishes.some(w => w.id === wishId));
|
||||
});
|
||||
|
||||
it('автор НЕ может удалить уже обработанное (не new) → 403', async () => {
|
||||
const res = await inject('DELETE', `/api/wishes/${wishId}`, null, s1.token);
|
||||
assert.equal(res.status, 403);
|
||||
});
|
||||
|
||||
it('чужой ученик не может удалить → 403', async () => {
|
||||
const res = await inject('DELETE', `/api/wishes/${wishId}`, null, s2.token);
|
||||
assert.equal(res.status, 403);
|
||||
});
|
||||
|
||||
it('автор удаляет своё «новое» → 200', async () => {
|
||||
const c = await inject('POST', '/api/wishes', { title: 'Черновик' }, s1.token);
|
||||
const res = await inject('DELETE', `/api/wishes/${c.body.id}`, null, s1.token);
|
||||
assert.equal(res.status, 200);
|
||||
});
|
||||
|
||||
it('админ удаляет любое → 200', async () => {
|
||||
const res = await inject('DELETE', `/api/wishes/${wishId}`, null, admin.token);
|
||||
assert.equal(res.status, 200);
|
||||
const gone = await inject('GET', '/api/wishes', null, admin.token);
|
||||
assert.ok(!gone.body.wishes.some(w => w.id === wishId));
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@
|
||||
{ key: 'classroom', label: 'Онлайн-уроки (classroom)', desc: 'Синхронные онлайн-уроки с доской и видео', icon: 'video' },
|
||||
{ key: 'sim_builder', label: 'Конструктор симуляций', desc: 'Создание учителем своих интерактивных симуляций (рабочее поле, формулы, физика, графики)', icon: 'pencil-ruler' },
|
||||
{ key: 'quantik', label: 'Квантик: Законы Мира', desc: '2D физика-головоломка: уровни на движке симуляций, прогресс, скины', icon: 'rocket' },
|
||||
{ key: 'wishes', label: 'Пожелания', desc: 'Трекер пожеланий по улучшению: пользователи подают идеи, админ ведёт по статусам', icon: 'lightbulb' },
|
||||
];
|
||||
|
||||
const FS_FEATURES = [
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Пожелания — LearnSpace</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/css/ls.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
|
||||
<style>
|
||||
.sb-content { background: #f4f5f8; }
|
||||
.container { max-width: 820px; margin: 0 auto; padding: 28px 32px 100px; }
|
||||
.page-title { font-family: 'Unbounded', sans-serif; font-size: 1.1rem; font-weight: 800; color: #0F172A; margin-bottom: 6px; }
|
||||
.page-sub { font-size: 0.82rem; color: var(--text-3); margin-bottom: 22px; }
|
||||
|
||||
/* submit card */
|
||||
.wf-card { background: #fff; border: 1px solid rgba(15,23,42,0.07); border-radius: 16px; padding: 18px 20px; margin-bottom: 24px; }
|
||||
.wf-row { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
|
||||
.wf-inp, .wf-sel, .wf-area {
|
||||
width: 100%; padding: 9px 12px; border: 1.5px solid rgba(15,23,42,0.12); border-radius: 10px;
|
||||
font-family: 'Manrope', sans-serif; font-size: 0.86rem; color: #0F172A; outline: none; transition: border-color .15s;
|
||||
}
|
||||
.wf-inp:focus, .wf-sel:focus, .wf-area:focus { border-color: var(--violet); }
|
||||
.wf-area { min-height: 70px; resize: vertical; }
|
||||
.wf-sel { cursor: pointer; }
|
||||
.wf-actions { display: flex; justify-content: flex-end; gap: 10px; }
|
||||
|
||||
/* filters */
|
||||
.w-filters { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; }
|
||||
.w-fchip { padding: 6px 13px; border-radius: 999px; border: 1.5px solid rgba(15,23,42,0.1); background: transparent;
|
||||
font-family: 'Manrope', sans-serif; font-size: 0.75rem; font-weight: 600; color: var(--text-3); cursor: pointer; transition: all .15s; }
|
||||
.w-fchip:hover { border-color: var(--violet); color: var(--violet); }
|
||||
.w-fchip.active { background: rgba(155,93,229,0.08); border-color: var(--violet); color: var(--violet); }
|
||||
|
||||
/* wish list */
|
||||
.w-list { display: flex; flex-direction: column; gap: 12px; }
|
||||
.w-card { background: #fff; border: 1px solid rgba(15,23,42,0.07); border-radius: 16px; padding: 16px 18px; border-left: 3px solid var(--wc, #9B5DE5); }
|
||||
.w-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 6px; }
|
||||
.w-title { font-size: 0.92rem; font-weight: 700; color: #0F172A; flex: 1; min-width: 0; }
|
||||
.w-badge { font-size: 0.68rem; font-weight: 700; padding: 3px 10px; border-radius: 999px; white-space: nowrap; }
|
||||
.wb-new { background: rgba(6,214,224,0.12); color: #06aab3; }
|
||||
.wb-planned { background: rgba(155,93,229,0.12); color: #9B5DE5; }
|
||||
.wb-in_progress{ background: rgba(255,179,71,0.14); color: #d97706; }
|
||||
.wb-done { background: rgba(5,150,82,0.12); color: #059652; }
|
||||
.wb-declined { background: rgba(15,23,42,0.07); color: var(--text-3); }
|
||||
.w-cat { font-size: 0.7rem; font-weight: 700; color: var(--text-3); }
|
||||
.w-meta { font-size: 0.72rem; color: var(--text-3); }
|
||||
.w-body { font-size: 0.84rem; color: #3D4F6B; line-height: 1.5; margin-top: 4px; white-space: pre-wrap; word-break: break-word; }
|
||||
.w-note { font-size: 0.8rem; color: #0F172A; background: rgba(155,93,229,0.06); border: 1px solid rgba(155,93,229,0.18);
|
||||
border-radius: 10px; padding: 8px 12px; margin-top: 10px; line-height: 1.5; }
|
||||
.w-note b { color: var(--violet); }
|
||||
.w-author { font-size: 0.72rem; font-weight: 700; color: var(--violet); }
|
||||
|
||||
/* admin manage */
|
||||
.w-manage { display: flex; gap: 8px; align-items: flex-start; flex-wrap: wrap; margin-top: 12px; padding-top: 12px; border-top: 1px dashed rgba(15,23,42,0.1); }
|
||||
.w-manage .wf-sel { width: auto; min-width: 150px; }
|
||||
.w-manage .wf-area { flex: 1; min-width: 200px; min-height: 40px; }
|
||||
.w-btn { padding: 7px 14px; border-radius: 10px; border: 1.5px solid rgba(15,23,42,0.12); background: #fff;
|
||||
font-family: 'Manrope', sans-serif; font-size: 0.76rem; font-weight: 700; color: #3D4F6B; cursor: pointer; transition: all .15s; }
|
||||
.w-btn:hover { border-color: var(--violet); color: var(--violet); }
|
||||
.w-btn-primary { background: var(--grad-1); color: #fff; border-color: transparent; }
|
||||
.w-btn-primary:hover { opacity: .9; color: #fff; }
|
||||
.w-btn-danger { border-color: rgba(239,71,111,0.25); color: #EF476F; }
|
||||
.w-btn-danger:hover { background: rgba(239,71,111,0.06); }
|
||||
|
||||
.w-empty { text-align: center; padding: 50px 20px; color: var(--text-3); }
|
||||
@media (max-width: 600px) { .container { padding: 16px 14px 80px; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar" id="app-sidebar"></aside>
|
||||
<div class="notif-drop" id="notif-drop"></div>
|
||||
<div class="sb-content">
|
||||
<div class="container">
|
||||
<div class="page-title">Пожелания по улучшению</div>
|
||||
<div class="page-sub" id="w-sub">Предложите, что улучшить в системе — мы это увидим и ответим.</div>
|
||||
|
||||
<!-- Submit form -->
|
||||
<div class="wf-card">
|
||||
<div class="wf-row">
|
||||
<input class="wf-inp" id="wf-title" maxlength="200" placeholder="Кратко: что улучшить?" style="flex:2;min-width:200px" />
|
||||
<select class="wf-sel" id="wf-cat" style="flex:1;min-width:150px">
|
||||
<option value="feature">Новая функция</option>
|
||||
<option value="ui">Интерфейс</option>
|
||||
<option value="content">Контент</option>
|
||||
<option value="bug">Баг / ошибка</option>
|
||||
<option value="other">Другое</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="wf-row">
|
||||
<textarea class="wf-area" id="wf-body" maxlength="4000" placeholder="Подробнее (необязательно): как должно работать, зачем это нужно…"></textarea>
|
||||
</div>
|
||||
<div class="wf-actions">
|
||||
<button class="w-btn w-btn-primary" id="wf-submit" onclick="submitWish()">
|
||||
<i data-lucide="send" style="width:13px;height:13px;vertical-align:-2px"></i> Отправить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Admin filters -->
|
||||
<div class="w-filters" id="w-filters" style="display:none"></div>
|
||||
|
||||
<div class="w-list" id="w-list"><div class="w-empty">Загрузка…</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api.js"></script>
|
||||
<script src="/js/sidebar.js"></script>
|
||||
<script src="/js/notifications.js"></script>
|
||||
<script>
|
||||
const { user, isAdmin } = LS.initPage();
|
||||
if (!user) throw new Error('Not logged in');
|
||||
LS.showBoardIfAllowed();
|
||||
LS.notif.init();
|
||||
|
||||
const CAT_LABEL = { ui: 'Интерфейс', content: 'Контент', feature: 'Новая функция', bug: 'Баг', other: 'Другое' };
|
||||
const ST_LABEL = { new: 'Новое', planned: 'Запланировано', in_progress: 'В работе', done: 'Готово', declined: 'Отклонено' };
|
||||
const ST_ORDER = ['new', 'planned', 'in_progress', 'done', 'declined'];
|
||||
let _statusFilter = null;
|
||||
let _wishes = [];
|
||||
|
||||
function fmtDate(s) {
|
||||
if (!s) return '';
|
||||
const d = new Date(s.includes('T') ? s : s.replace(' ', 'T') + 'Z');
|
||||
return d.toLocaleDateString('ru', { day: 'numeric', month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const params = _statusFilter ? { status: _statusFilter } : {};
|
||||
const data = await LS.wishesList(params);
|
||||
_wishes = data.wishes || [];
|
||||
if (data.isAdmin) renderFilters(data.counts || {});
|
||||
render();
|
||||
} catch (e) {
|
||||
document.getElementById('w-list').innerHTML = `<div class="w-empty">Не удалось загрузить: ${esc(e.message || '')}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderFilters(counts) {
|
||||
const el = document.getElementById('w-filters');
|
||||
el.style.display = '';
|
||||
const total = Object.values(counts).reduce((a, b) => a + b, 0);
|
||||
let html = `<button class="w-fchip${!_statusFilter ? ' active' : ''}" onclick="setFilter(null)">Все ${total ? '· ' + total : ''}</button>`;
|
||||
html += ST_ORDER.map(s => counts[s]
|
||||
? `<button class="w-fchip${_statusFilter === s ? ' active' : ''}" onclick="setFilter('${s}')">${ST_LABEL[s]} · ${counts[s]}</button>`
|
||||
: '').join('');
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
function setFilter(s) { _statusFilter = s; load(); }
|
||||
|
||||
function render() {
|
||||
const el = document.getElementById('w-list');
|
||||
if (!_wishes.length) {
|
||||
el.innerHTML = `<div class="w-empty"><div style="opacity:.4;margin-bottom:8px"><i data-lucide="lightbulb" style="width:40px;height:40px"></i></div>${isAdmin ? 'Пожеланий пока нет.' : 'Вы ещё не оставляли пожеланий. Поделитесь идеей выше!'}</div>`;
|
||||
if (window.lucide) lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
el.innerHTML = _wishes.map(cardHtml).join('');
|
||||
if (window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
const ST_COLOR = { new: '#06aab3', planned: '#9B5DE5', in_progress: '#d97706', done: '#059652', declined: '#94A3B8' };
|
||||
|
||||
function cardHtml(w) {
|
||||
const author = (isAdmin && w.author_name) ? `<span class="w-author">${esc(w.author_name)}</span> · ` : '';
|
||||
const noteHtml = w.admin_note ? `<div class="w-note"><b>Ответ:</b> ${esc(w.admin_note)}</div>` : '';
|
||||
let manage = '';
|
||||
if (isAdmin) {
|
||||
const opts = ST_ORDER.map(s => `<option value="${s}"${w.status === s ? ' selected' : ''}>${ST_LABEL[s]}</option>`).join('');
|
||||
manage = `<div class="w-manage">
|
||||
<select class="wf-sel" id="st-${w.id}">${opts}</select>
|
||||
<textarea class="wf-area" id="note-${w.id}" placeholder="Ответ автору (необязательно)…">${esc(w.admin_note || '')}</textarea>
|
||||
<button class="w-btn w-btn-primary" onclick="saveWish(${w.id})">Сохранить</button>
|
||||
<button class="w-btn w-btn-danger" onclick="delWish(${w.id})" title="Удалить"><i data-lucide="trash-2" style="width:13px;height:13px"></i></button>
|
||||
</div>`;
|
||||
} else if (w.status === 'new') {
|
||||
manage = `<div class="w-manage"><button class="w-btn w-btn-danger" onclick="delWish(${w.id})"><i data-lucide="trash-2" style="width:13px;height:13px;vertical-align:-2px"></i> Удалить</button></div>`;
|
||||
}
|
||||
return `<div class="w-card" style="--wc:${ST_COLOR[w.status] || '#9B5DE5'}">
|
||||
<div class="w-head">
|
||||
<span class="w-title">${esc(w.title)}</span>
|
||||
<span class="w-badge wb-${w.status}">${ST_LABEL[w.status] || w.status}</span>
|
||||
</div>
|
||||
<div class="w-meta">${author}<span class="w-cat">${CAT_LABEL[w.category] || w.category}</span> · ${fmtDate(w.created_at)}</div>
|
||||
${w.body ? `<div class="w-body">${esc(w.body)}</div>` : ''}
|
||||
${noteHtml}
|
||||
${manage}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function submitWish() {
|
||||
const title = document.getElementById('wf-title').value.trim();
|
||||
if (!title) { LS.toast('Введите заголовок', 'warn'); return; }
|
||||
const btn = document.getElementById('wf-submit');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await LS.wishCreate({
|
||||
title,
|
||||
category: document.getElementById('wf-cat').value,
|
||||
body: document.getElementById('wf-body').value.trim(),
|
||||
});
|
||||
document.getElementById('wf-title').value = '';
|
||||
document.getElementById('wf-body').value = '';
|
||||
LS.toast('Пожелание отправлено — спасибо!', 'success');
|
||||
_statusFilter = null;
|
||||
await load();
|
||||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||
finally { btn.disabled = false; }
|
||||
}
|
||||
|
||||
async function saveWish(id) {
|
||||
try {
|
||||
await LS.wishUpdate(id, {
|
||||
status: document.getElementById('st-' + id).value,
|
||||
admin_note: document.getElementById('note-' + id).value.trim(),
|
||||
});
|
||||
LS.toast('Сохранено', 'success');
|
||||
await load();
|
||||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||
}
|
||||
|
||||
async function delWish(id) {
|
||||
if (!await LS.confirm('Удалить это пожелание?', { title: 'Удаление', confirmText: 'Удалить', danger: true })) return;
|
||||
try {
|
||||
await LS.wishDelete(id);
|
||||
_wishes = _wishes.filter(w => w.id !== id);
|
||||
render();
|
||||
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
|
||||
}
|
||||
|
||||
load();
|
||||
if (window.lucide) lucide.createIcons();
|
||||
</script>
|
||||
<script src="/js/search.js"></script>
|
||||
<script src="/js/mobile.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -191,6 +191,11 @@ async function kickMember(classId, userId) { return req('DELETE', `/classes/
|
||||
async function regenerateInviteCode(classId) { return req('POST', `/classes/${classId}/new-code`); }
|
||||
async function classJournal(classId) { return req('GET', `/classes/${classId}/journal`); }
|
||||
async function classOutstanding(classId) { return req('GET', `/classes/${classId}/outstanding`); }
|
||||
/* ── Пожелания по улучшению ── */
|
||||
async function wishesList(params = {}) { const q = new URLSearchParams(params).toString(); return req('GET', '/wishes' + (q ? '?' + q : '')); }
|
||||
async function wishCreate(data) { return req('POST', '/wishes', data); }
|
||||
async function wishUpdate(id, data) { return req('PATCH', `/wishes/${id}`, data); }
|
||||
async function wishDelete(id) { return req('DELETE', `/wishes/${id}`); }
|
||||
async function createAssignment(classId, data) { return req('POST', `/classes/${classId}/assignments`, data); }
|
||||
async function createDirectAssignment(data) { return req('POST', '/assignments', data); }
|
||||
async function updateAssignment(id, data) { return req('PUT', `/assignments/${id}`, data); }
|
||||
@@ -853,6 +858,7 @@ const FEATURE_HREFS = {
|
||||
quantik: ['/quantik', '/quantik.html'],
|
||||
theory: ['/theory', '/theory.html'],
|
||||
sitemap: ['/sitemap', '/sitemap.html'],
|
||||
wishes: ['/wishes', '/wishes.html'],
|
||||
};
|
||||
/* Контейнеры виджетов-модулей (дашборд и т.п.) — прячем блок целиком, а не только
|
||||
ссылку, иначе остаётся пустой блок (напр. виджет флеш-карт #w-flashcard).
|
||||
@@ -1127,6 +1133,7 @@ window.LS = {
|
||||
getQuestions, createQuestion, duplicateQuestion, updateQuestion, deleteQuestion, importQuestions,
|
||||
getClasses, createClass, getClassDetail, updateClass, deleteClass, kickMember, addClassMember, createAssignment, createDirectAssignment, updateAssignment, deleteAssignment,
|
||||
regenerateInviteCode, classJournal, classOutstanding,
|
||||
wishesList, wishCreate, wishUpdate, wishDelete,
|
||||
joinClass, myClasses, getStudents, classFeed,
|
||||
getAnnouncements, createAnnouncement, deleteAnnouncement,
|
||||
getNotifications, markNotifRead, markAllNotifsRead, connectSSE,
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
<button class="sb-link" onclick="typeof lsSearchOpen!=='undefined'&&lsSearchOpen()" title="Ctrl+K"><i data-lucide="search" class="sb-icon"></i><span class="sb-lbl">Поиск</span></button>
|
||||
${L('/dashboard', 'home', 'Дашборд')}
|
||||
${L('/sitemap', 'map', 'Путеводитель')}
|
||||
${L('/wishes', 'lightbulb', 'Пожелания')}
|
||||
${L('/teacher-guide', 'book-marked', 'Руководство', { cls: 'sb-teacher-only', hidden: !isTch })}
|
||||
|
||||
${G('learning', 'Учебный процесс', `
|
||||
|
||||
Reference in New Issue
Block a user