diff --git a/server/index.js b/server/index.js index 1e81309..2a179cd 100644 --- a/server/index.js +++ b/server/index.js @@ -90,6 +90,7 @@ app.use("/dhs-testing", require("./routers/dhs-testing")) app.use("/gamehub", require("./routers/gamehub")) app.use("/esc", require("./routers/esc")) app.use('/connectme', require('./routers/connectme')) +app.use('/questioneer', require('./routers/questioneer')) app.use(require("./error")) diff --git a/server/models/questionnaire.js b/server/models/questionnaire.js new file mode 100644 index 0000000..e08928e --- /dev/null +++ b/server/models/questionnaire.js @@ -0,0 +1,60 @@ +const mongoose = require('mongoose'); + +// Типы вопросов +const QUESTION_TYPES = { + SINGLE_CHOICE: 'single_choice', // Один вариант + MULTIPLE_CHOICE: 'multiple_choice', // Несколько вариантов + TEXT: 'text', // Текстовый ответ + RATING: 'rating', // Оценка по шкале + TAG_CLOUD: 'tag_cloud' // Облако тегов +}; + +// Типы отображения +const DISPLAY_TYPES = { + DEFAULT: 'default', + TAG_CLOUD: 'tag_cloud', + VOTING: 'voting', + POLL: 'poll' +}; + +// Схема варианта ответа +const optionSchema = new mongoose.Schema({ + text: { type: String, required: true }, + count: { type: Number, default: 0 } // счетчик голосов +}); + +// Схема вопроса +const questionSchema = new mongoose.Schema({ + text: { type: String, required: true }, + type: { + type: String, + enum: Object.values(QUESTION_TYPES), + required: true + }, + options: [optionSchema], + required: { type: Boolean, default: false } +}); + +// Схема опроса +const questionnaireSchema = new mongoose.Schema({ + title: { type: String, required: true }, + description: { type: String }, + questions: [questionSchema], + displayType: { + type: String, + enum: Object.values(DISPLAY_TYPES), + default: DISPLAY_TYPES.DEFAULT + }, + createdAt: { type: Date, default: Date.now }, + updatedAt: { type: Date, default: Date.now }, + adminLink: { type: String, required: true }, // ссылка для редактирования + publicLink: { type: String, required: true } // ссылка для голосования +}); + +const Questionnaire = mongoose.model('Questionnaire', questionnaireSchema); + +module.exports = { + Questionnaire, + QUESTION_TYPES, + DISPLAY_TYPES +}; \ No newline at end of file diff --git a/server/routers/questioneer/index.js b/server/routers/questioneer/index.js new file mode 100644 index 0000000..c031883 --- /dev/null +++ b/server/routers/questioneer/index.js @@ -0,0 +1,421 @@ +const express = require('express') +const { Router } = require("express") +const router = Router() +const crypto = require('crypto') +const path = require('path') +const { getDB } = require('../../utils/mongo') +const mongoose = require('mongoose') + +// Используем одно определение модели +const Questionnaire = (() => { + // Если модель уже существует, используем её + if (mongoose.models.Questionnaire) { + return mongoose.models.Questionnaire; + } + + // Иначе создаем новую модель + const questionnaireSchema = new mongoose.Schema({ + title: { type: String, required: true }, + description: { type: String }, + questions: [{ + text: { type: String, required: true }, + type: { + type: String, + enum: ['single_choice', 'multiple_choice', 'text', 'rating', 'tag_cloud', 'scale'], + required: true + }, + required: { type: Boolean, default: false }, + options: [{ + text: { type: String, required: true }, + count: { type: Number, default: 0 } + }], + scaleMin: { type: Number }, + scaleMax: { type: Number }, + scaleMinLabel: { type: String }, + scaleMaxLabel: { type: String }, + answers: [{ type: String }], + scaleValues: [{ type: Number }], + tags: [{ + text: { type: String }, + count: { type: Number, default: 1 } + }] + }], + displayType: { + type: String, + enum: ['default', 'tag_cloud', 'voting', 'poll', 'step_by_step'], + default: 'step_by_step' + }, + createdAt: { type: Date, default: Date.now }, + updatedAt: { type: Date, default: Date.now }, + adminLink: { type: String, required: true }, + publicLink: { type: String, required: true } + }); + + return mongoose.model('Questionnaire', questionnaireSchema); +})(); + +// Middleware для парсинга JSON +router.use(express.json()); + +// Обслуживание статичных файлов - проверяем правильность пути +router.use('/static', express.static(path.join(__dirname, 'public', 'static'))); + +// Получить главную страницу +router.get("/", (req, res) => { + res.sendFile(path.join(__dirname, 'public/index.html')) +}) + +// Страница создания нового опроса +router.get("/create", (req, res) => { + res.sendFile(path.join(__dirname, 'public/create.html')) +}) + +// Страница редактирования опроса +router.get("/edit/:adminLink", (req, res) => { + res.sendFile(path.join(__dirname, 'public/edit.html')) +}) + +// Страница администрирования опроса +router.get("/admin/:adminLink", (req, res) => { + res.sendFile(path.join(__dirname, 'public/admin.html')) +}) + +// Страница голосования +router.get("/poll/:publicLink", (req, res) => { + res.sendFile(path.join(__dirname, 'public/poll.html')) +}) + +// API для работы с опросами + +// Создать новый опрос +router.post("/api/questionnaires", async (req, res) => { + try { + // Проверка наличия нужных полей + const { title, questions } = req.body; + + if (!title || !Array.isArray(questions) || questions.length === 0) { + return res.json({ success: false, error: 'Необходимо указать название и хотя бы один вопрос' }); + } + + // Создаем уникальные ссылки + const adminLink = crypto.randomBytes(6).toString('hex'); + const publicLink = crypto.randomBytes(6).toString('hex'); + + // Устанавливаем тип отображения step_by_step, если не указан + if (!req.body.displayType) { + req.body.displayType = 'step_by_step'; + } + + // Создаем новый опросник + const questionnaire = new Questionnaire({ + ...req.body, + adminLink, + publicLink + }); + + await questionnaire.save(); + + res.json({ + success: true, + data: { + adminLink, + publicLink + } + }); + } catch (error) { + console.error('Error creating questionnaire:', error); + res.json({ success: false, error: error.message }); + } +}); + +// Получить все опросы +router.get("/api/questionnaires", async (req, res) => { + try { + const questionnaires = await Questionnaire.find({}, { + title: 1, + description: 1, + createdAt: 1, + updatedAt: 1, + _id: 1, + adminLink: 1, + publicLink: 1 + }).sort({ createdAt: -1 }) + + res.status(200).json({ + success: true, + data: questionnaires + }) + } catch (error) { + console.error('Error fetching questionnaires:', error) + res.status(500).json({ + success: false, + error: 'Failed to fetch questionnaires' + }) + } +}) + +// Получить опрос по ID для админа +router.get("/api/questionnaires/admin/:adminLink", async (req, res) => { + try { + const { adminLink } = req.params + const questionnaire = await Questionnaire.findOne({ adminLink }) + + if (!questionnaire) { + return res.status(404).json({ + success: false, + error: 'Questionnaire not found' + }) + } + + res.status(200).json({ + success: true, + data: questionnaire + }) + } catch (error) { + console.error('Error fetching questionnaire:', error) + res.status(500).json({ + success: false, + error: 'Failed to fetch questionnaire' + }) + } +}) + +// Получить опрос по публичной ссылке (для голосования) +router.get("/api/questionnaires/public/:publicLink", async (req, res) => { + try { + const { publicLink } = req.params + const questionnaire = await Questionnaire.findOne({ publicLink }) + + if (!questionnaire) { + return res.status(404).json({ + success: false, + error: 'Questionnaire not found' + }) + } + + res.status(200).json({ + success: true, + data: questionnaire + }) + } catch (error) { + console.error('Error fetching questionnaire:', error) + res.status(500).json({ + success: false, + error: 'Failed to fetch questionnaire' + }) + } +}) + +// Обновить опрос +router.put("/api/questionnaires/:adminLink", async (req, res) => { + try { + const { adminLink } = req.params + const { title, description, questions, displayType } = req.body + + const updatedQuestionnaire = await Questionnaire.findOneAndUpdate( + { adminLink }, + { + title, + description, + questions, + displayType, + updatedAt: Date.now() + }, + { new: true } + ) + + if (!updatedQuestionnaire) { + return res.status(404).json({ + success: false, + error: 'Questionnaire not found' + }) + } + + res.status(200).json({ + success: true, + data: updatedQuestionnaire + }) + } catch (error) { + console.error('Error updating questionnaire:', error) + res.status(500).json({ + success: false, + error: 'Failed to update questionnaire' + }) + } +}) + +// Удалить опрос +router.delete("/api/questionnaires/:adminLink", async (req, res) => { + try { + const { adminLink } = req.params + + const deletedQuestionnaire = await Questionnaire.findOneAndDelete({ adminLink }) + + if (!deletedQuestionnaire) { + return res.status(404).json({ + success: false, + error: 'Questionnaire not found' + }) + } + + res.status(200).json({ + success: true, + message: 'Questionnaire deleted successfully' + }) + } catch (error) { + console.error('Error deleting questionnaire:', error) + res.status(500).json({ + success: false, + error: 'Failed to delete questionnaire' + }) + } +}) + +// Голосование в опросе +router.post("/api/vote/:publicLink", async (req, res) => { + try { + const { publicLink } = req.params + const { answers } = req.body + + const questionnaire = await Questionnaire.findOne({ publicLink }) + + if (!questionnaire) { + return res.status(404).json({ + success: false, + error: 'Questionnaire not found' + }) + } + + // Обновить счетчики голосов + answers.forEach(answer => { + const { questionIndex, optionIndices, textAnswer, scaleValue, tagTexts } = answer + + // Обработка одиночного и множественного выбора + if (Array.isArray(optionIndices)) { + // Для множественного выбора + optionIndices.forEach(optionIndex => { + if (questionnaire.questions[questionIndex] && + questionnaire.questions[questionIndex].options[optionIndex]) { + questionnaire.questions[questionIndex].options[optionIndex].count += 1 + } + }) + } else if (typeof optionIndices === 'number') { + // Для единичного выбора + if (questionnaire.questions[questionIndex] && + questionnaire.questions[questionIndex].options[optionIndices]) { + questionnaire.questions[questionIndex].options[optionIndices].count += 1 + } + } + + // Сохраняем текстовые ответы + if (textAnswer && questionnaire.questions[questionIndex]) { + if (!questionnaire.questions[questionIndex].answers) { + questionnaire.questions[questionIndex].answers = []; + } + questionnaire.questions[questionIndex].answers.push(textAnswer); + } + + // Сохраняем ответы шкалы оценки + if (scaleValue !== undefined && questionnaire.questions[questionIndex]) { + if (!questionnaire.questions[questionIndex].scaleValues) { + questionnaire.questions[questionIndex].scaleValues = []; + } + questionnaire.questions[questionIndex].scaleValues.push(scaleValue); + } + + // Сохраняем теги + if (Array.isArray(tagTexts) && tagTexts.length > 0 && questionnaire.questions[questionIndex]) { + if (!questionnaire.questions[questionIndex].tags) { + questionnaire.questions[questionIndex].tags = []; + } + + tagTexts.forEach(tagText => { + const existingTag = questionnaire.questions[questionIndex].tags.find(t => t.text === tagText); + if (existingTag) { + existingTag.count += 1; + } else { + questionnaire.questions[questionIndex].tags.push({ text: tagText, count: 1 }); + } + }); + } + }) + + await questionnaire.save() + + res.status(200).json({ + success: true, + message: 'Vote registered successfully' + }) + } catch (error) { + console.error('Error registering vote:', error) + res.status(500).json({ + success: false, + error: 'Failed to register vote' + }) + } +}) + +// Получить результаты опроса по публичной ссылке +router.get("/api/results/:publicLink", async (req, res) => { + try { + const { publicLink } = req.params; + const questionnaire = await Questionnaire.findOne({ publicLink }); + + if (!questionnaire) { + return res.status(404).json({ + success: false, + error: 'Questionnaire not found' + }); + } + + // Формируем результаты для отправки + const results = { + title: questionnaire.title, + description: questionnaire.description, + questions: questionnaire.questions.map(question => { + const result = { + text: question.text, + type: question.type + }; + + // Добавляем варианты ответов, если они есть + if (question.options && question.options.length > 0) { + result.options = question.options; + } + + // Добавляем текстовые ответы, если они есть + if (question.answers && question.answers.length > 0) { + result.answers = question.answers; + } + + // Добавляем результаты шкалы, если они есть + if (question.scaleValues && question.scaleValues.length > 0) { + result.scaleValues = question.scaleValues; + + // Считаем среднее значение + result.scaleAverage = question.scaleValues.reduce((a, b) => a + b, 0) / question.scaleValues.length; + } + + // Добавляем теги, если они есть + if (question.tags && question.tags.length > 0) { + result.tags = question.tags; + } + + return result; + }) + }; + + res.status(200).json({ + success: true, + data: results + }); + } catch (error) { + console.error('Error fetching poll results:', error); + res.status(500).json({ + success: false, + error: 'Failed to fetch poll results' + }); + } +}); + +module.exports = router diff --git a/server/routers/questioneer/openapi.yaml b/server/routers/questioneer/openapi.yaml new file mode 100644 index 0000000..2293c84 --- /dev/null +++ b/server/routers/questioneer/openapi.yaml @@ -0,0 +1,583 @@ +openapi: 3.0.0 +info: + title: Анонимные опросы API + description: API для работы с системой анонимных опросов + version: 1.0.0 +servers: + - url: /questioneer/api + description: Базовый URL API +paths: + /questionnaires: + get: + summary: Получить список опросов пользователя + description: Возвращает список всех опросов, сохраненных в локальном хранилище браузера + operationId: getQuestionnaires + responses: + '200': + description: Успешный запрос + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionnairesResponse' + post: + summary: Создать новый опрос + description: Создает новый опрос с указанными параметрами + operationId: createQuestionnaire + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionnaireCreate' + responses: + '200': + description: Опрос успешно создан + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionnaireResponse' + /questionnaires/public/{publicLink}: + get: + summary: Получить опрос для участия + description: Возвращает данные опроса по публичной ссылке + operationId: getPublicQuestionnaire + parameters: + - name: publicLink + in: path + required: true + schema: + type: string + responses: + '200': + description: Успешный запрос + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionnaireResponse' + '404': + description: Опрос не найден + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /questionnaires/admin/{adminLink}: + get: + summary: Получить опрос для редактирования и просмотра результатов + description: Возвращает данные опроса по административной ссылке + operationId: getAdminQuestionnaire + parameters: + - name: adminLink + in: path + required: true + schema: + type: string + responses: + '200': + description: Успешный запрос + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionnaireResponse' + '404': + description: Опрос не найден + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + put: + summary: Обновить опрос + description: Обновляет существующий опрос + operationId: updateQuestionnaire + parameters: + - name: adminLink + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionnaireUpdate' + responses: + '200': + description: Опрос успешно обновлен + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionnaireResponse' + '404': + description: Опрос не найден + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + summary: Удалить опрос + description: Удаляет опрос вместе со всеми ответами + operationId: deleteQuestionnaire + parameters: + - name: adminLink + in: path + required: true + schema: + type: string + responses: + '200': + description: Опрос успешно удален + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + '404': + description: Опрос не найден + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /vote/{publicLink}: + post: + summary: Отправить ответы на опрос + description: Отправляет ответы пользователя на опрос + operationId: submitVote + parameters: + - name: publicLink + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VoteRequest' + responses: + '200': + description: Ответы успешно отправлены + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + '404': + description: Опрос не найден + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /results/{publicLink}: + get: + summary: Получить результаты опроса + description: Возвращает текущие результаты опроса + operationId: getResults + parameters: + - name: publicLink + in: path + required: true + schema: + type: string + responses: + '200': + description: Успешный запрос + content: + application/json: + schema: + $ref: '#/components/schemas/ResultsResponse' + '404': + description: Опрос не найден + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' +components: + schemas: + QuestionnaireCreate: + type: object + required: + - title + - questions + properties: + title: + type: string + description: Название опроса + description: + type: string + description: Описание опроса + questions: + type: array + description: Список вопросов + items: + $ref: '#/components/schemas/Question' + displayType: + type: string + description: Тип отображения опроса + enum: [standard, step_by_step] + default: standard + QuestionnaireUpdate: + type: object + properties: + title: + type: string + description: Название опроса + description: + type: string + description: Описание опроса + questions: + type: array + description: Список вопросов + items: + $ref: '#/components/schemas/Question' + displayType: + type: string + description: Тип отображения опроса + enum: [standard, step_by_step] + Question: + type: object + required: + - text + - type + properties: + text: + type: string + description: Текст вопроса + type: + type: string + description: Тип вопроса + enum: [single, multiple, text, scale, rating, tagcloud] + required: + type: boolean + description: Является ли вопрос обязательным + default: false + options: + type: array + description: Варианты ответа (для single, multiple) + items: + $ref: '#/components/schemas/Option' + tags: + type: array + description: Список тегов (для tagcloud) + items: + $ref: '#/components/schemas/Tag' + scaleMin: + type: integer + description: Минимальное значение шкалы (для scale) + default: 0 + scaleMax: + type: integer + description: Максимальное значение шкалы (для scale) + default: 10 + scaleMinLabel: + type: string + description: Метка для минимального значения шкалы + default: "Минимум" + scaleMaxLabel: + type: string + description: Метка для максимального значения шкалы + default: "Максимум" + Option: + type: object + required: + - text + properties: + text: + type: string + description: Текст варианта ответа + votes: + type: integer + description: Количество голосов за этот вариант + default: 0 + Tag: + type: object + required: + - text + properties: + text: + type: string + description: Текст тега + count: + type: integer + description: Количество выборов данного тега + default: 0 + VoteRequest: + type: object + required: + - answers + properties: + answers: + type: array + description: Список ответов пользователя + items: + $ref: '#/components/schemas/Answer' + Answer: + type: object + required: + - questionIndex + properties: + questionIndex: + type: integer + description: Индекс вопроса + optionIndices: + type: array + description: Индексы выбранных вариантов (для single, multiple) + items: + type: integer + textAnswer: + type: string + description: Текстовый ответ пользователя (для text) + scaleValue: + type: integer + description: Значение шкалы (для scale, rating) + tagTexts: + type: array + description: Тексты выбранных или введенных тегов (для tagcloud) + items: + type: string + QuestionnairesResponse: + type: object + properties: + success: + type: boolean + description: Успешность запроса + data: + type: array + description: Список опросов + items: + $ref: '#/components/schemas/QuestionnaireInfo' + QuestionnaireResponse: + type: object + properties: + success: + type: boolean + description: Успешность запроса + data: + $ref: '#/components/schemas/QuestionnaireData' + QuestionnaireInfo: + type: object + properties: + title: + type: string + description: Название опроса + description: + type: string + description: Описание опроса + adminLink: + type: string + description: Административная ссылка + publicLink: + type: string + description: Публичная ссылка + createdAt: + type: string + format: date-time + description: Дата создания опроса + updatedAt: + type: string + format: date-time + description: Дата последнего обновления опроса + QuestionnaireData: + type: object + properties: + _id: + type: string + description: Идентификатор опроса + title: + type: string + description: Название опроса + description: + type: string + description: Описание опроса + questions: + type: array + description: Список вопросов + items: + $ref: '#/components/schemas/QuestionData' + displayType: + type: string + description: Тип отображения опроса + enum: [standard, step_by_step] + adminLink: + type: string + description: Административная ссылка + publicLink: + type: string + description: Публичная ссылка + createdAt: + type: string + format: date-time + description: Дата создания опроса + updatedAt: + type: string + format: date-time + description: Дата последнего обновления опроса + QuestionData: + type: object + properties: + _id: + type: string + description: Идентификатор вопроса + text: + type: string + description: Текст вопроса + type: + type: string + description: Тип вопроса + required: + type: boolean + description: Является ли вопрос обязательным + options: + type: array + description: Варианты ответа (для single, multiple) + items: + $ref: '#/components/schemas/OptionData' + tags: + type: array + description: Список тегов (для tagcloud) + items: + $ref: '#/components/schemas/TagData' + scaleMin: + type: integer + description: Минимальное значение шкалы (для scale) + scaleMax: + type: integer + description: Максимальное значение шкалы (для scale) + scaleMinLabel: + type: string + description: Метка для минимального значения шкалы + scaleMaxLabel: + type: string + description: Метка для максимального значения шкалы + answers: + type: array + description: Текстовые ответы (для text) + items: + type: string + scaleValues: + type: array + description: Значения шкалы от пользователей (для scale, rating) + items: + type: integer + textAnswers: + type: array + description: Текстовые ответы (для text) + items: + type: string + responses: + type: array + description: Значения шкалы от пользователей (для scale, rating) + items: + type: integer + OptionData: + type: object + properties: + _id: + type: string + description: Идентификатор варианта ответа + text: + type: string + description: Текст варианта ответа + votes: + type: integer + description: Количество голосов за этот вариант + count: + type: integer + description: Альтернативное поле для количества голосов + TagData: + type: object + properties: + _id: + type: string + description: Идентификатор тега + text: + type: string + description: Текст тега + count: + type: integer + description: Количество выборов данного тега + ResultsResponse: + type: object + properties: + success: + type: boolean + description: Успешность запроса + data: + $ref: '#/components/schemas/ResultsData' + ResultsData: + type: object + properties: + questions: + type: array + description: Список вопросов с результатами + items: + $ref: '#/components/schemas/QuestionResults' + QuestionResults: + type: object + properties: + text: + type: string + description: Текст вопроса + type: + type: string + description: Тип вопроса + options: + type: array + description: Варианты ответа с количеством голосов (для single, multiple) + items: + type: object + properties: + text: + type: string + description: Текст варианта ответа + count: + type: integer + description: Количество голосов + tags: + type: array + description: Список тегов с количеством выборов (для tagcloud) + items: + type: object + properties: + text: + type: string + description: Текст тега + count: + type: integer + description: Количество выборов + scaleValues: + type: array + description: Значения шкалы от пользователей (для scale, rating) + items: + type: integer + scaleAverage: + type: number + description: Среднее значение шкалы (для scale, rating) + answers: + type: array + description: Текстовые ответы (для text) + items: + type: string + responses: + type: array + description: Значения шкалы от пользователей (для scale, rating) + items: + type: integer + SuccessResponse: + type: object + properties: + success: + type: boolean + description: Успешность запроса + example: true + message: + type: string + description: Сообщение об успешном выполнении + ErrorResponse: + type: object + properties: + success: + type: boolean + description: Успешность запроса + example: false + error: + type: string + description: Сообщение об ошибке \ No newline at end of file diff --git a/server/routers/questioneer/public/admin.html b/server/routers/questioneer/public/admin.html new file mode 100644 index 0000000..8905b49 --- /dev/null +++ b/server/routers/questioneer/public/admin.html @@ -0,0 +1,117 @@ + + +
+ + +Загрузка опросов...
+', { text: 'Вариант' }), + $(' | ', { text: 'Голоса' }), + $(' | ', { text: '%' }), + $(' | ', { text: 'Визуализация' }) + ) + ); + + const $tbody = $(' | ||||||||
---|---|---|---|---|---|---|---|---|---|---|---|
', { text: option.text }), + $(' | ', { text: votes }), + $(' | ', { text: `${percent}%` }), + $(' | ').append(
+ $(' ', { class: 'bar-container' }).append(
+ $(' ', {
+ class: 'bar',
+ css: { width: `${percent}%` }
+ })
+ )
+ )
+ );
+
+ $tbody.append($tr);
+ });
+
+ $table.append($thead, $tbody);
+ $container.append($table);
+ $container.append($(' ', { class: 'total-votes', text: `Всего голосов: ${totalVotes}` }));
+ };
+
+ // Отображение статистики для облака тегов
+ const renderTagCloudStats = (question, $container) => {
+ if (!question.tags || question.tags.length === 0 || !question.tags.some(tag => tag.count > 0)) {
+ $container.append($(' ', { class: 'no-votes', text: 'Нет выбранных тегов' }));
+ return;
+ }
+
+ const $tagCloud = $(' ', { class: 'tag-cloud-stats' });
+
+ // Находим максимальное количество для масштабирования
+ const maxCount = Math.max(...question.tags.map(tag => tag.count || 0));
+
+ // Сортируем теги по популярности
+ const sortedTags = [...question.tags].sort((a, b) => (b.count || 0) - (a.count || 0));
+
+ sortedTags.forEach(tag => {
+ if (tag.count && tag.count > 0) {
+ const fontSize = maxCount > 0 ? 1 + (tag.count / maxCount) * 1.5 : 1; // от 1em до 2.5em
+
+ $tagCloud.append(
+ $('', {
+ class: 'tag-item',
+ text: `${tag.text} (${tag.count})`,
+ css: { fontSize: `${fontSize}em` }
+ })
+ );
+ }
+ });
+
+ $container.append($tagCloud);
+ };
+
+ // Отображение статистики для шкалы и рейтинга
+ const renderScaleStats = (question, $container) => {
+ // Используем scaleValues или responses, в зависимости от того, что доступно
+ const values = question.responses && question.responses.length > 0
+ ? question.responses
+ : (question.scaleValues || []);
+
+ if (values.length === 0) {
+ $container.append($(' ', { class: 'no-votes', text: 'Нет оценок' }));
+ return;
+ }
+
+ const sum = values.reduce((a, b) => a + b, 0);
+ const avg = sum / values.length;
+ const min = Math.min(...values);
+ const max = Math.max(...values);
+
+ // Создаем контейнер для статистики
+ const $scaleStats = $(' ', { class: 'scale-stats' });
+
+ // Добавляем сводную статистику
+ $scaleStats.append(
+ $(' ', { class: 'stat-summary' }).append(
+ $(' ', { class: 'stat-item' }).append(
+ $('', { class: 'stat-label', text: 'Среднее значение:' }),
+ $('', { class: 'stat-value', text: avg.toFixed(1) })
+ ),
+ $(' ', { class: 'stat-item' }).append(
+ $('', { class: 'stat-label', text: 'Минимум:' }),
+ $('', { class: 'stat-value', text: min })
+ ),
+ $(' ', { class: 'stat-item' }).append(
+ $('', { class: 'stat-label', text: 'Максимум:' }),
+ $('', { class: 'stat-value', text: max })
+ ),
+ $(' ', { class: 'stat-item' }).append(
+ $('', { class: 'stat-label', text: 'Количество оценок:' }),
+ $('', { class: 'stat-value', text: values.length })
+ )
+ )
+ );
+
+ // Создаем таблицу для визуализации распределения голосов
+ const $table = $('
|