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 @@ + + + + + + Управление опросом + + + + + + + + + + + + +
+

Управление опросом

+ +
Загрузка опроса...
+ + +
+ + + + \ No newline at end of file diff --git a/server/routers/questioneer/public/create.html b/server/routers/questioneer/public/create.html new file mode 100644 index 0000000..41e49d2 --- /dev/null +++ b/server/routers/questioneer/public/create.html @@ -0,0 +1,187 @@ + + + + + + Создать опрос + + + + + + + + + + + +
+

Создание нового опроса

+ +
+
+
+ + +
+ +
+ + +
+ + + +
+

Вопросы

+
+ + +
+ +
+ Отмена + +
+
+
+
+ + + + + + + + + + \ No newline at end of file diff --git a/server/routers/questioneer/public/edit.html b/server/routers/questioneer/public/edit.html new file mode 100644 index 0000000..02c9485 --- /dev/null +++ b/server/routers/questioneer/public/edit.html @@ -0,0 +1,204 @@ + + + + + + Редактирование опроса + + + + + + + + + + + + +
+

Редактирование опроса

+ +
Загрузка опроса...
+ + +
+ + + + + + + + + + \ No newline at end of file diff --git a/server/routers/questioneer/public/index.html b/server/routers/questioneer/public/index.html new file mode 100644 index 0000000..db72bec --- /dev/null +++ b/server/routers/questioneer/public/index.html @@ -0,0 +1,94 @@ + + + + + + Анонимные опросы + + + + + + + + + + + +
+

Сервис анонимных опросов

+ +
+ Создать новый опрос +
+ +
+

Ваши опросы

+
+

Загрузка опросов...

+
+
+
+ + + + \ No newline at end of file diff --git a/server/routers/questioneer/public/poll.html b/server/routers/questioneer/public/poll.html new file mode 100644 index 0000000..dc2f0ee --- /dev/null +++ b/server/routers/questioneer/public/poll.html @@ -0,0 +1,97 @@ + + + + + + Участие в опросе + + + + + + + + + + + +
+
Загрузка опроса...
+ + +
+ + + + \ No newline at end of file diff --git a/server/routers/questioneer/public/static/css/style.css b/server/routers/questioneer/public/static/css/style.css new file mode 100644 index 0000000..c943b91 --- /dev/null +++ b/server/routers/questioneer/public/static/css/style.css @@ -0,0 +1,2181 @@ +/* Темный стиль */ +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + margin: 0; + padding: 0; + color: #e0e0e0; + background-color: #1e1e1e; + line-height: 1.6; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +h1, h2, h3, h4, h5, h6 { + margin-top: 0; + margin-bottom: 0.5rem; + font-weight: 500; + line-height: 1.2; +} + +h1 { + font-size: 2.5rem; + margin-bottom: 1.5rem; + color: #61dafb; +} + +h2 { + font-size: 2rem; + color: #64b5f6; +} + +h3 { + font-size: 1.5rem; + color: #81c784; +} + +p { + margin-top: 0; + margin-bottom: 1rem; +} + +/* Кнопки */ +.btn { + display: inline-block; + font-weight: 400; + text-align: center; + vertical-align: middle; + user-select: none; + padding: 0.5rem 1rem; + font-size: 1rem; + line-height: 1.5; + border-radius: 0.25rem; + text-decoration: none; + cursor: pointer; + background-color: #2196f3; + color: white; + border: 1px solid transparent; + transition: all 0.15s ease-in-out; +} + +.btn:hover { + background-color: #1976d2; +} + +.btn-primary { + background-color: #1976d2; + color: white; +} + +.btn-primary:hover { + background-color: #1565c0; +} + +.btn-secondary { + background-color: #757575; +} + +.btn-secondary:hover { + background-color: #616161; +} + +.btn-danger { + background-color: #f44336; +} + +.btn-danger:hover { + background-color: #d32f2f; +} + +.btn-small { + padding: 0.25rem 0.5rem; + font-size: 0.875rem; +} + +.btn-icon { + background: none; + border: none; + cursor: pointer; + font-size: 1rem; + padding: 0; + margin: 0 5px; + color: #e0e0e0; +} + +/* Формы */ +.form-container { + background-color: #2d2d2d; + padding: 20px; + border-radius: 5px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); + margin-bottom: 20px; +} + +.form-group { + margin-bottom: 15px; +} + +.form-group label { + display: block; + margin-bottom: 5px; + font-weight: 500; + color: #bbdefb; +} + +input[type="text"], +input[type="email"], +input[type="password"], +input[type="number"], +textarea, +select { + display: block; + width: 100%; + padding: 0.5rem 0.75rem; + font-size: 1rem; + line-height: 1.5; + color: #e0e0e0; + background-color: #424242; + background-clip: padding-box; + border: 1px solid #616161; + border-radius: 0.25rem; + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + box-sizing: border-box; +} + +input:focus, +textarea:focus, +select:focus { + color: #e0e0e0; + background-color: #424242; + border-color: #64b5f6; + outline: 0; + box-shadow: 0 0 0 0.2rem rgba(33, 150, 243, 0.25); +} + +.form-actions { + margin-top: 20px; + display: flex; + justify-content: space-between; +} + +/* Список опросов */ +.questionnaires-list { + margin-top: 30px; +} + +.questionnaire-item { + background-color: #2d2d2d; + padding: 20px; + border-radius: 5px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); + margin-bottom: 20px; +} + +.questionnaire-links { + display: flex; + gap: 10px; + margin-top: 15px; +} + +/* Вопросы */ +.question-item { + background-color: #2d2d2d; + padding: 20px; + border-radius: 5px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + margin-bottom: 20px; +} + +.question-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; +} + +.options-container { + margin-top: 10px; +} + +.options-list { + margin-bottom: 10px; +} + +.option-item { + display: flex; + align-items: center; + margin-bottom: 8px; +} + +/* Стили для варианта ответа */ +.option-item input[type="text"] { + flex: 1; + margin-right: 10px; +} + +.radio-option label, +.checkbox-option label { + margin-left: 10px; + cursor: pointer; +} + +/* Звездный рейтинг */ +.rating-container { + display: flex; + gap: 10px; + margin-top: 10px; +} + +.rating-item { + display: flex; + flex-direction: column; + align-items: center; +} + +.rating-item label { + cursor: pointer; + padding: 8px 12px; + background-color: #424242; + border-radius: 4px; + transition: all 0.2s; +} + +.rating-item input { + display: none; +} + +.rating-item input:checked + label { + background-color: #2196f3; + color: white; +} + +/* Облако тегов */ +.tag-cloud-container { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 10px; +} + +.tag-item { + padding: 8px 15px; + background-color: #424242; + border-radius: 20px; + cursor: pointer; + transition: all 0.2s; +} + +.tag-item:hover { + background-color: #616161; +} + +.tag-item.selected { + background-color: #2196f3; + color: white; +} + +/* Результаты голосования */ +.results-visualization { + margin-top: 15px; +} + +.result-bar-container { + margin-bottom: 15px; +} + +.result-label { + margin-bottom: 5px; + font-weight: 500; + color: #bbdefb; +} + +.result-bar { + height: 20px; + background-color: #424242; + border-radius: 4px; + overflow: hidden; +} + +.result-bar-fill { + height: 100%; + background-color: #2196f3; + border-radius: 4px; + transition: width 0.5s; +} + +.result-percent { + margin-top: 5px; + font-size: 0.875rem; + color: #9e9e9e; +} + +.results-tag-cloud { + display: flex; + flex-wrap: wrap; + gap: 15px; + margin-top: 15px; + justify-content: center; +} + +.result-tag { + padding: 5px 10px; + background-color: #2196f3; + color: white; + border-radius: 20px; + display: inline-block; +} + +/* Обязательные поля */ +.required-mark { + color: #f44336; +} + +/* Состояния загрузки и ошибки */ +#loading { + text-align: center; + padding: 20px; + font-size: 1.2rem; + color: #9e9e9e; +} + +.error { + color: #f44336; + padding: 10px; + background-color: rgba(244, 67, 54, 0.2); + border-radius: 4px; +} + +/* Ссылки */ +.link-group { + margin-bottom: 15px; +} + +.link-input-group { + display: flex; + gap: 10px; +} + +.link-input-group input { + flex: 1; +} + +/* Текстовая область для ввода ответа */ +.textarea-container { + width: 100%; + margin-top: 10px; +} + +.text-answer { + width: 100%; + min-height: 100px; + resize: vertical; +} + +/* Таблица статистики */ +.stats-table { + width: 100%; + border-collapse: collapse; + margin-top: 10px; + color: #e0e0e0; +} + +.stats-table th, +.stats-table td { + padding: 8px; + text-align: left; + border-bottom: 1px solid #424242; +} + +.stats-table th { + background-color: #383838; + font-weight: 500; + color: #bbdefb; +} + +.stats-table .total-row { + font-weight: bold; + background-color: #424242; +} + +/* Стили для question-stats */ +.question-stats { + background-color: #2d2d2d; + padding: 15px; + border-radius: 5px; + margin-bottom: 20px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15); +} + +/* Модальные окна */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + opacity: 0; + visibility: hidden; + transition: opacity 0.3s, visibility 0.3s; +} + +.modal-overlay.active { + opacity: 1; + visibility: visible; +} + +.modal { + background-color: #2d2d2d; + border-radius: 5px; + box-shadow: 0 3px 15px rgba(0, 0, 0, 0.3); + width: 90%; + max-width: 500px; + padding: 20px; + position: relative; + transform: translateY(-20px); + transition: transform 0.3s; + max-height: 90vh; + overflow-y: auto; +} + +.modal-overlay.active .modal { + transform: translateY(0); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; + padding-bottom: 10px; + border-bottom: 1px solid #444; +} + +.modal-header h3 { + margin: 0; + color: #64b5f6; +} + +.modal-close { + background: none; + border: none; + color: #e0e0e0; + font-size: 1.5rem; + cursor: pointer; + padding: 0; + line-height: 1; +} + +.modal-body { + margin-bottom: 20px; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding-top: 10px; + border-top: 1px solid #444; +} + +/* QR код */ +.qr-container { + display: flex; + flex-direction: column; + align-items: center; + padding: 15px; +} + +.qr-code { + margin-bottom: 15px; + background-color: #fff; + padding: 15px; + border-radius: 8px; +} + +.qr-link-container { + display: flex; + width: 100%; + max-width: 500px; + margin-top: 10px; +} + +.qr-link-input { + flex-grow: 1; + padding: 10px; + border: 1px solid #444; + border-radius: 4px 0 0 4px; + background-color: #333; + color: #fff; +} + +.btn-copy-link { + padding: 10px 15px; + background-color: #64b5f6; + color: #fff; + border: none; + border-radius: 0 4px 4px 0; + cursor: pointer; + transition: background-color 0.3s; +} + +.btn-copy-link:hover { + background-color: #90caf9; +} + +.btn-copy-link.copied { + background-color: #4caf50; +} + +/* Шкала оценки */ +.scale-container { + margin-top: 20px; + width: 100%; +} + +.scale-labels { + display: flex; + justify-content: space-between; + margin-bottom: 10px; +} + +.scale-label-min, +.scale-label-max { + font-weight: 500; + color: #bbdefb; +} + +.scale-values { + display: flex; + justify-content: space-between; + flex-wrap: wrap; + gap: 5px; +} + +.scale-item { + text-align: center; +} + +.scale-item input { + display: none; +} + +.scale-item label { + display: block; + width: 40px; + height: 40px; + line-height: 40px; + text-align: center; + background-color: #424242; + border-radius: 50%; + cursor: pointer; + transition: all 0.2s; +} + +.scale-item input:checked + label { + background-color: #2196f3; + color: white; +} + +.scale-item label:hover { + background-color: #616161; +} + +/* Пошаговый опрос */ +.step-navigation { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 20px; + margin-bottom: 10px; +} + +.question-counter { + text-align: center; + font-size: 1rem; + color: #64b5f6; + flex: 1; +} + +/* Улучшения для облака тегов */ +.tag-input-container { + width: 100%; +} + +.tag-input { + width: 100%; + padding: 10px; + margin-bottom: 10px; + background-color: #424242; + border: 1px solid #616161; + border-radius: 4px; + color: #e0e0e0; +} + +.tag-items { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 10px; +} + +.tag-item { + position: relative; + padding: 8px 15px; + background-color: #424242; + border-radius: 20px; + cursor: pointer; + transition: all 0.2s; +} + +.tag-item.selected { + background-color: #2196f3; + color: white; +} + +.tag-remove { + margin-left: 5px; + font-size: 1.2rem; + cursor: pointer; +} + +/* Стили для результатов опроса */ +.question-result { + background-color: #2d2d2d; + padding: 15px; + border-radius: 5px; + margin-bottom: 20px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15); +} + +.text-answers { + margin-top: 10px; +} + +.text-answer { + background-color: #424242; + padding: 10px; + border-radius: 4px; + margin-bottom: 10px; +} + +/* Улучшения для радио и чекбоксов */ +.radio-options-container, +.checkbox-options-container { + margin-top: 10px; +} + +.radio-option, +.checkbox-option { + display: flex; + align-items: center; + margin-bottom: 10px; +} + +.radio-option input, +.checkbox-option input { + margin-right: 10px; +} + +.radio-option label, +.checkbox-option label { + cursor: pointer; +} + +/* Стили для вопросов с ошибками */ +.question-item.error { + border-left: 4px solid var(--color-error); + box-shadow: 0 0 10px rgba(220, 53, 69, 0.3); + padding-left: 16px; + transition: all 0.3s ease; +} + +.question-item.error .question-title { + color: var(--color-error); +} + +/* Анимация для ошибок */ +.shake { + animation: shake 0.6s cubic-bezier(.36,.07,.19,.97) both; +} + +@keyframes shake { + 10%, 90% { + transform: translate3d(-1px, 0, 0); + } + + 20%, 80% { + transform: translate3d(2px, 0, 0); + } + + 30%, 50%, 70% { + transform: translate3d(-4px, 0, 0); + } + + 40%, 60% { + transform: translate3d(4px, 0, 0); + } +} + +/* Стили для выхода вопроса */ +.question-item.exit { + opacity: 0; + transform: translateX(-30px); + transition: opacity 0.3s ease, transform 0.3s ease; + pointer-events: none; +} + +/* Стили для плавного входа вопроса */ +.question-item.enter { + opacity: 0; + transform: translateX(30px); +} + +.question-item.active { + opacity: 1; + transform: translateX(0); + transition: opacity 0.5s ease, transform 0.5s ease; +} + +/* Анимации для элементов результатов */ +.results-title { + animation: slideDown 0.8s ease forwards; +} + +.scale-average { + margin-bottom: 15px; + font-size: 1.2em; +} + +.scale-average .highlight { + color: var(--color-primary); + font-size: 1.5em; + font-weight: bold; +} + +.result-bar-container { + margin-bottom: 10px; +} + +.result-label { + margin-bottom: 5px; + font-weight: 500; +} + +.result-bar { + height: 20px; + background-color: rgba(0, 0, 0, 0.1); + border-radius: 4px; + overflow: hidden; + position: relative; +} + +.result-bar-fill { + height: 100%; + background-color: var(--color-primary); + transition: width 1s ease-out; + border-radius: 4px; +} + +.result-percent { + margin-top: 3px; + text-align: right; + font-size: 0.9em; + color: var(--color-muted); +} + +.text-answer { + background-color: rgba(0, 0, 0, 0.05); + padding: 10px 15px; + border-radius: 4px; + margin-bottom: 10px; + transition: opacity 0.5s ease, transform 0.5s ease; +} + +.results-tag-cloud { + display: flex; + flex-wrap: wrap; + justify-content: center; + margin: 20px 0; +} + +.result-tag { + background-color: rgba(var(--primary-rgb), 0.1); + color: var(--color-primary); + padding: 8px 15px; + border-radius: 20px; + margin: 5px; + display: inline-block; + transition: all 0.5s ease; +} + +@keyframes slideDown { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(30px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes pulse { + 0% { opacity: 0.8; } + 50% { opacity: 1; } + 100% { opacity: 0.8; } +} + +.question-result { + transition: all 0.5s ease; + padding: 15px; + margin-bottom: 20px; + border-radius: 8px; + background-color: rgba(0, 0, 0, 0.03); +} + +/* Улучшенная анимация для счетчика вопросов */ +.question-counter { + transition: all 0.3s ease; + animation: fadeIn 0.5s; +} + +.question-counter.update { + animation: fadeIn 0.5s; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +/* Анимации для кнопок навигации */ +.nav-btn:hover { + transform: translateY(-3px); + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); + transition: all 0.3s ease; +} + +.nav-btn:active { + transform: translateY(-1px); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); +} + +/* Улучшенные стили для загрузки */ +#loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin: 50px 0; +} + +.loading-spinner { + width: 50px; + height: 50px; + border: 5px solid rgba(0, 0, 0, 0.1); + border-radius: 50%; + border-top: 5px solid var(--color-primary); + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.loading-text { + margin-top: 15px; + color: #64b5f6; +} + +/* Анимации */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideInUp { + from { + transform: translateY(20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes slideInRight { + from { + transform: translateX(20px); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes pulse { + 0% { transform: scale(1); } + 50% { transform: scale(1.05); } + 100% { transform: scale(1); } +} + +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); } + 20%, 40%, 60%, 80% { transform: translateX(5px); } +} + +@keyframes rotate { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* Применение анимаций */ +#questionnaire-container { + animation: fadeIn 0.6s ease-out; +} + +.question-item { + animation: slideInUp 0.5s ease-out; + transition: all 0.3s ease; +} + +.btn { + transition: all 0.2s ease-in-out; +} + +.btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); +} + +.btn:active { + transform: translateY(1px); +} + +.btn-primary { + background-color: #1976d2; + color: white; +} + +.question-item.error { + animation: shake 0.5s ease-in-out; +} + +.radio-option label:hover, +.checkbox-option label:hover { + transform: translateX(3px); + transition: transform 0.2s ease; +} + +.tag-item { + transition: all 0.2s ease; +} + +.tag-item:hover { + transform: scale(1.05); +} + +.tag-item.selected { + background-color: var(--color-primary); + color: white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.scale-item label:hover { + transform: scale(1.1); + transition: transform 0.2s ease; +} + +.scale-item input:checked + label { + background-color: var(--color-primary); + color: white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +/* Анимированный лоадер */ +#loading { + position: relative; + padding-left: 30px; +} + +#loading:before { + content: ''; + position: absolute; + left: 0; + top: 50%; + margin-top: -10px; + width: 20px; + height: 20px; + border: 3px solid #2196f3; + border-top-color: transparent; + border-radius: 50%; + animation: rotate 1s linear infinite; +} + +/* Анимированные переходы между вопросами */ +.step-navigation button { + transition: all 0.3s ease; +} + +.question-item.active { + animation: slideInRight 0.4s ease-out; +} + +.question-item.exit { + animation: fadeIn 0.4s ease-out reverse; +} + +/* Анимация для результатов */ +.result-bar-fill { + transition: width 1.5s ease-out; + animation: slideInRight 1.5s ease-out; +} + +.results-container { + animation: fadeIn 1s ease-out; +} + +.question-result { + animation: slideInUp 0.5s ease-out; + animation-fill-mode: both; +} + +.question-result:nth-child(1) { animation-delay: 0.1s; } +.question-result:nth-child(2) { animation-delay: 0.2s; } +.question-result:nth-child(3) { animation-delay: 0.3s; } +.question-result:nth-child(4) { animation-delay: 0.4s; } +.question-result:nth-child(5) { animation-delay: 0.5s; } +.question-result:nth-child(6) { animation-delay: 0.6s; } +.question-result:nth-child(7) { animation-delay: 0.7s; } +.question-result:nth-child(8) { animation-delay: 0.8s; } +.question-result:nth-child(9) { animation-delay: 0.9s; } +.question-result:nth-child(10) { animation-delay: 1s; } + +/* Другие улучшения стилей */ +.textarea-container textarea { + transition: height 0.3s ease; +} + +.textarea-container textarea:focus { + height: 120px; +} + +/* Анимация для модальных окон */ +.modal-overlay.active .modal { + animation: slideInUp 0.3s ease-out; +} + +/* Анимация для кнопки добавления вопроса */ +#add-question { + transition: background-color 0.3s ease, transform 0.2s ease; +} + +#add-question:hover { + transform: translateY(-2px); +} + +#add-question:active { + transform: translateY(1px); +} + +/* Анимация иконок */ +.btn-icon svg { + transition: transform 0.3s ease; +} + +.btn-icon:hover svg { + transform: rotate(90deg); +} + +/* Анимация для обратной связи */ +@keyframes success-animation { + 0% { background-color: transparent; } + 30% { background-color: rgba(76, 175, 80, 0.2); } + 100% { background-color: transparent; } +} + +.success-feedback { + animation: success-animation 1.5s ease; +} + +/* Анимированные переключатели */ +input[type="checkbox"], input[type="radio"] { + transition: all 0.2s ease; +} + +/* Дополнительные плавные переходы для всех элементов */ +* { + transition-property: background-color, border-color, color, box-shadow; + transition-duration: 0.2s; + transition-timing-function: ease; +} + +/* Стили для приветствия и благодарности */ +.welcome-animation, +.thank-you-animation, +.already-completed { + max-width: 800px; + margin: 50px auto; + padding: 30px; + text-align: center; + background-color: #2d2d2d; + border-radius: 8px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + transition: all 0.5s ease; +} + +.welcome-icon, +.thank-you-icon, +.completed-icon { + margin-bottom: 20px; +} + +.welcome-icon svg, +.thank-you-icon svg, +.completed-icon svg { + width: 100px; + height: 100px; + color: var(--color-primary); +} + +.welcome-icon svg:hover, +.thank-you-icon svg:hover, +.completed-icon svg:hover { + transform: scale(1.05); +} + +.welcome-title, +.thank-you-title, +.completed-title { + font-size: 2rem; + margin-bottom: 15px; + color: var(--color-primary, #2196f3); +} + +.welcome-description, +.thank-you-description, +.completed-description { + font-size: 1.1rem; + line-height: 1.6; + margin-bottom: 30px; + color: #e0e0e0; +} + +.welcome-start-btn, +.view-results-btn { + font-size: 1.2rem; + padding: 12px 24px; + border-radius: 30px; + background-color: var(--color-primary); + color: white; + border: none; + cursor: pointer; + transition: background-color 0.3s; +} + +.welcome-start-btn:hover, +.view-results-btn:hover { + background-color: #0d47a1; +} + +.start-again-btn { + margin-top: 20px; + margin-left: 10px; + font-size: 1.1rem; + padding: 12px 30px; + border-radius: 30px; +} + +/* Анимации для приветствия и благодарности */ +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(30px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* CSS переменные для цветов */ +:root { + --color-primary: #2196f3; + --color-primary-dark: #1976d2; + --color-primary-light: #64b5f6; + --color-secondary: #757575; + --color-secondary-dark: #616161; + --color-success: #4caf50; + --color-success-dark: #388e3c; + --color-error: #f44336; + --color-error-dark: #d32f2f; + --color-muted: #9e9e9e; + --primary-rgb: 33, 150, 243; + --color-bg-dark: #1e1e1e; + --color-bg-card: #2d2d2d; + --color-bg-input: #424242; +} + +/* Стили для навигационных кнопок */ +.nav-btn { + display: flex; + align-items: center; + justify-content: center; + min-width: 110px; + background-color: #1976d2; + color: white; +} + +.nav-btn svg { + margin: 0 5px; +} + +.nav-btn:hover { + background-color: #0d47a1; +} + +.nav-btn:disabled { + background-color: #455a64; + cursor: not-allowed; + opacity: 0.7; +} + +/* Стили для статистики в админке */ +.question-stats { + margin-bottom: 30px; + padding: 20px; + background-color: #2d2d2d; + border-radius: 8px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); +} + +.question-stats h3 { + margin-top: 0; + margin-bottom: 15px; + color: #90caf9; + border-bottom: 1px solid #444; + padding-bottom: 10px; +} + +.no-stats, .no-votes { + padding: 15px; + background-color: #383838; + border-radius: 5px; + text-align: center; + color: #aaa; +} + +.stats-table { + width: 100%; + border-collapse: collapse; + margin-bottom: 15px; +} + +.stats-table th, .stats-table td { + padding: 10px; + text-align: left; + border-bottom: 1px solid #444; +} + +.stats-table th { + background-color: #383838; + color: #90caf9; +} + +.bar-container { + width: 100%; + height: 20px; + background-color: #383838; + border-radius: 3px; + overflow: hidden; +} + +.bar { + height: 100%; + background-color: #64b5f6; + border-radius: 3px; + transition: width 0.5s ease-in-out; +} + +.total-votes { + text-align: right; + font-style: italic; + color: #aaa; + margin-top: 10px; +} + +/* Стили для облака тегов в статистике */ +.tag-cloud-stats { + display: flex; + flex-wrap: wrap; + gap: 10px; + padding: 15px; + background-color: #383838; + border-radius: 5px; +} + +.tag-item { + display: inline-block; + padding: 5px 10px; + background-color: #444; + border-radius: 15px; + margin-right: 8px; + margin-bottom: 8px; + color: #90caf9; + transition: transform 0.2s ease; +} + +.tag-item:hover { + transform: scale(1.05); +} + +/* Стили для шкалы и рейтинга */ +.scale-stats { + padding: 15px; + background-color: #383838; + border-radius: 5px; +} + +.stat-item { + margin-bottom: 10px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.stat-label { + font-weight: bold; + color: #aaa; +} + +.stat-value { + font-size: 1.1em; + color: #90caf9; +} + +/* Навигация */ +.nav-header { + background-color: #212121; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + padding: 15px 0; + margin-bottom: 30px; +} + +.nav-container { + max-width: 1200px; + margin: 0 auto; + padding: 0 20px; + display: flex; + justify-content: space-between; + align-items: center; +} + +.nav-logo { + font-size: 1.3rem; + font-weight: 600; + color: #61dafb; + text-decoration: none; +} + +.nav-menu { + display: flex; + gap: 20px; +} + +.nav-link { + color: #e0e0e0; + text-decoration: none; + padding: 8px 12px; + border-radius: 4px; + transition: background-color 0.2s; +} + +.nav-link:hover { + background-color: #424242; + color: #61dafb; +} + +.nav-link.active { + background-color: #424242; + color: #61dafb; +} + +/* Формы - улучшенные чекбоксы и радиокнопки */ +.form-container { + background-color: #2d2d2d; + padding: 20px; + border-radius: 5px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); + margin-bottom: 20px; +} + +.form-group { + margin-bottom: 15px; +} + +.form-group label { + display: block; + margin-bottom: 5px; + font-weight: 500; + color: #bbdefb; +} + +/* Улучшенные стили для чекбоксов и радиокнопок */ +.radio-option, +.checkbox-option { + display: flex; + align-items: center; + margin-bottom: 12px; + position: relative; +} + +.radio-option input[type="radio"], +.checkbox-option input[type="checkbox"] { + position: absolute; + opacity: 0; + cursor: pointer; + height: 0; + width: 0; +} + +.radio-option label, +.checkbox-option label { + position: relative; + padding-left: 40px; + cursor: pointer; + display: block; + font-size: 1rem; + user-select: none; +} + +.radio-option label:before, +.checkbox-option label:before { + content: ''; + position: absolute; + left: 0; + top: 0; + width: 24px; + height: 24px; + border: 2px solid #616161; + background-color: #424242; + transition: all 0.3s; +} + +.radio-option label:before { + border-radius: 50%; +} + +.checkbox-option label:before { + border-radius: 4px; +} + +.radio-option input[type="radio"]:checked ~ label:before { + background-color: #2196f3; + border-color: #2196f3; +} + +.checkbox-option input[type="checkbox"]:checked ~ label:before { + background-color: #4caf50; + border-color: #4caf50; +} + +.radio-option label:after { + content: ''; + position: absolute; + width: 10px; + height: 10px; + background: white; + border-radius: 50%; + top: 7px; + left: 7px; + transition: all 0.2s; + opacity: 0; + transform: scale(0); +} + +.checkbox-option label:after { + content: ''; + position: absolute; + left: 9px; + top: 5px; + width: 6px; + height: 12px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg) scale(0); + opacity: 0; + transition: all 0.2s; +} + +.radio-option input[type="radio"]:checked ~ label:after { + opacity: 1; + transform: scale(1); +} + +.checkbox-option input[type="checkbox"]:checked ~ label:after { + opacity: 1; + transform: rotate(45deg) scale(1); +} + +.radio-option:hover label:before, +.checkbox-option:hover label:before { + border-color: #90caf9; +} + +/* Модальные окна с прогресс-баром */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + opacity: 0; + visibility: hidden; + transition: opacity 0.3s, visibility 0.3s; +} + +.modal-overlay.active { + opacity: 1; + visibility: visible; +} + +.modal { + background-color: #2d2d2d; + border-radius: 5px; + box-shadow: 0 3px 15px rgba(0, 0, 0, 0.3); + width: 90%; + max-width: 500px; + padding: 20px; + position: relative; + transform: translateY(-20px); + transition: transform 0.3s; + max-height: 90vh; + overflow-y: auto; +} + +.modal-overlay.active .modal { + transform: translateY(0); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; + padding-bottom: 10px; + border-bottom: 1px solid #444; +} + +.modal-header h3 { + margin: 0; + color: #64b5f6; +} + +.modal-close { + background: none; + border: none; + color: #e0e0e0; + font-size: 1.5rem; + cursor: pointer; + padding: 0; + line-height: 1; +} + +.modal-body { + margin-bottom: 20px; +} + +.modal-footer { + display: flex; + justify-content: flex-end; + gap: 10px; + padding-top: 10px; + border-top: 1px solid #444; +} + +/* Прогресс-бар для модальных окон */ +.modal-progress { + position: absolute; + bottom: 0; + left: 0; + height: 5px; + background-color: #2196f3; + width: 0; + transition: width 2s linear; +} + +.modal-progress.active { + width: 100%; +} + +/* Анимации для опроса */ +.question-item { + opacity: 1; + transform: translateY(0); + transition: opacity 0.5s, transform 0.5s; +} + +.question-item.enter { + opacity: 0; + transform: translateY(20px); +} + +.question-item.active { + opacity: 1; + transform: translateY(0); +} + +#question-counter { + transition: opacity 0.3s, transform 0.3s; +} + +#question-counter.update { + opacity: 0; + transform: translateY(-10px); +} + +/* Анимации для благодарности и приветствия */ +.welcome-animation, +.thank-you-animation, +.already-completed { + background-color: #2d2d2d; + padding: 30px; + border-radius: 8px; + text-align: center; + margin: 50px auto; + max-width: 600px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3); + transition: opacity 0.5s, transform 0.5s; +} + +.welcome-icon, +.thank-you-icon, +.completed-icon { + margin-bottom: 20px; + color: #4caf50; +} + +.welcome-title, +.thank-you-title, +.completed-title { + color: #64b5f6; + margin-bottom: 15px; +} + +.welcome-description, +.thank-you-description, +.completed-description { + color: #e0e0e0; + margin-bottom: 25px; +} + +.welcome-start-btn, +.view-results-btn { + margin: 10px; +} + +/* Анимации для ошибок */ +.shake-animation { + animation: shake 0.5s; +} + +@keyframes shake { + 0%, 100% { transform: translateX(0); } + 10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); } + 20%, 40%, 60%, 80% { transform: translateX(5px); } +} + +/* Улучшенные стили для чекбоксов и радиокнопок */ +.radio-option, +.checkbox-option { + display: flex; + align-items: center; + margin-bottom: 12px; + position: relative; +} + +.radio-option input[type="radio"], +.checkbox-option input[type="checkbox"] { + position: absolute; + opacity: 0; + cursor: pointer; + height: 0; + width: 0; +} + +.radio-option label, +.checkbox-option label { + position: relative; + padding-left: 40px; + cursor: pointer; + display: block; + font-size: 1rem; + user-select: none; +} + +.radio-option label:before, +.checkbox-option label:before { + content: ''; + position: absolute; + left: 0; + top: 0; + width: 24px; + height: 24px; + border: 2px solid #616161; + background-color: #424242; + transition: all 0.3s; +} + +.radio-option label:before { + border-radius: 50%; +} + +.checkbox-option label:before { + border-radius: 4px; +} + +.radio-option input[type="radio"]:checked ~ label:before { + background-color: #2196f3; + border-color: #2196f3; +} + +.checkbox-option input[type="checkbox"]:checked ~ label:before { + background-color: #4caf50; + border-color: #4caf50; +} + +.radio-option label:after { + content: ''; + position: absolute; + width: 10px; + height: 10px; + background: white; + border-radius: 50%; + top: 7px; + left: 7px; + transition: all 0.2s; + opacity: 0; + transform: scale(0); +} + +.checkbox-option label:after { + content: ''; + position: absolute; + left: 9px; + top: 5px; + width: 6px; + height: 12px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg) scale(0); + opacity: 0; + transition: all 0.2s; +} + +.radio-option input[type="radio"]:checked ~ label:after { + opacity: 1; + transform: scale(1); +} + +.checkbox-option input[type="checkbox"]:checked ~ label:after { + opacity: 1; + transform: rotate(45deg) scale(1); +} + +.radio-option:hover label:before, +.checkbox-option:hover label:before { + border-color: #90caf9; +} + +/* Стили для текстовых ответов в админке */ +.text-answers-list { + margin-top: 15px; +} + +.text-answer-item { + background-color: #383838; + padding: 12px 15px; + border-radius: 5px; + margin-bottom: 10px; + display: flex; + align-items: flex-start; +} + +.answer-number { + font-weight: bold; + color: #64b5f6; + margin-right: 10px; + flex-shrink: 0; +} + +.answer-text { + flex-grow: 1; + white-space: pre-wrap; + word-break: break-word; +} + +/* Добавляем стили для анимации конфети */ +.confetti-container { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 9999; + overflow: hidden; +} + +.confetti { + position: absolute; + width: 10px; + height: 10px; + background-color: #90caf9; + border-radius: 3px; + will-change: transform; +} + +.confetti.square { + border-radius: 0; +} + +.confetti.triangle { + width: 0; + height: 0; + background-color: transparent; + border-style: solid; + border-width: 0 5px 8.7px 5px; + border-color: transparent transparent #90caf9 transparent; +} + +.confetti.circle { + border-radius: 50%; +} + +.confetti.red { + background-color: #f44336; + border-color: transparent transparent #f44336 transparent; +} + +.confetti.blue { + background-color: #2196f3; + border-color: transparent transparent #2196f3 transparent; +} + +.confetti.green { + background-color: #4caf50; + border-color: transparent transparent #4caf50 transparent; +} + +.confetti.yellow { + background-color: #ffeb3b; + border-color: transparent transparent #ffeb3b transparent; +} + +.confetti.purple { + background-color: #9c27b0; + border-color: transparent transparent #9c27b0 transparent; +} + +@keyframes confetti-fall { + 0% { + transform: translateY(-100vh) rotate(0deg); + } + 100% { + transform: translateY(100vh) rotate(360deg); + } +} + +@keyframes confetti-sway { + 0% { + transform: translateX(0); + } + 25% { + transform: translateX(10px); + } + 50% { + transform: translateX(-10px); + } + 75% { + transform: translateX(5px); + } + 100% { + transform: translateX(0); + } +} + +/* Медиа-запросы для адаптивности */ +@media (max-width: 768px) { + /* Основные стили */ + .container { + padding: 15px; + } + + h1 { + font-size: 2rem; + } + + h2 { + font-size: 1.75rem; + } + + h3 { + font-size: 1.25rem; + } + + /* Навигация */ + .nav-container { + flex-direction: column; + padding: 10px; + } + + .nav-menu { + margin-top: 10px; + width: 100%; + justify-content: center; + } + + .nav-link { + margin: 0 10px; + } + + /* Формы */ + .form-group { + margin-bottom: 15px; + } + + input[type="text"], + input[type="email"], + input[type="number"], + textarea, + select { + padding: 8px; + font-size: 14px; + } + + /* Кнопки */ + .btn { + padding: 8px 16px; + font-size: 14px; + } + + .questionnaire-actions { + flex-direction: column; + } + + .questionnaire-actions .btn { + width: 100%; + margin-bottom: 10px; + } + + /* Опросы */ + .question-item { + padding: 15px; + margin-bottom: 15px; + } + + /* Навигация по опросу */ + .step-navigation { + flex-direction: column; + align-items: center; + } + + .step-navigation .btn { + width: 100%; + margin: 5px 0; + } + + .question-counter { + margin-bottom: 10px; + width: 100%; + text-align: center; + } + + /* Шкала оценки */ + .scale-container { + padding: 10px; + } + + .scale-values { + flex-wrap: wrap; + justify-content: center; + } + + .scale-item { + margin: 5px; + } + + /* Облако тегов */ + .tag-cloud-container { + padding: 10px; + } + + .tag-item { + margin: 5px; + font-size: 14px; + padding: 5px 10px; + } + + /* Результаты опроса */ + .questionnaire-links { + flex-direction: column; + } + + .link-group { + width: 100%; + } + + .link-input-group { + flex-direction: column; + } + + .link-input-group input { + width: 100%; + margin-bottom: 10px; + } + + .stats-table th, + .stats-table td { + padding: 5px; + font-size: 14px; + } + + /* Модальные окна */ + .modal-content { + width: 90%; + padding: 15px; + } + + .modal-footer { + flex-direction: column; + } + + .modal-footer .btn { + width: 100%; + margin: 5px 0; + } + + /* Улучшенные стили для навигации в опросе на мобильных устройствах */ + .step-navigation { + flex-direction: column-reverse; + align-items: stretch; + } + + .step-navigation .btn { + margin: 5px 0; + width: 100%; + height: 44px; /* Увеличиваем высоту для лучшего тача */ + } + + .question-counter { + margin: 10px 0; + order: -1; /* Показываем счетчик вопросов сверху на мобильных */ + } + + /* Улучшения для формы */ + .form-actions { + flex-direction: column; + gap: 10px; + } + + .form-actions .btn { + width: 100%; + height: 44px; /* Увеличиваем высоту для лучшего тача */ + } + + /* Улучшения для textarea */ + textarea { + min-height: 100px; + } + + /* Улучшения для облака тегов */ + .tag-input { + width: 100%; + height: 44px; /* Увеличиваем высоту для лучшего тача */ + font-size: 16px; /* Оптимальный размер шрифта для мобильных устройств */ + } + + /* Убираем зум при фокусе на поля ввода */ + input[type="text"], + input[type="email"], + input[type="number"], + textarea, + select { + font-size: 16px; /* Оптимальный размер шрифта для мобильных устройств */ + } +} + +/* Медиа-запросы для маленьких экранов */ +@media (max-width: 480px) { + h1 { + font-size: 1.75rem; + } + + h2 { + font-size: 1.5rem; + } + + h3 { + font-size: 1.25rem; + } + + .container { + padding: 10px; + } + + .btn { + padding: 8px 12px; + font-size: 13px; + } + + .question-item { + padding: 12px; + } + + .tag-item { + margin: 3px; + font-size: 13px; + padding: 4px 8px; + } + + .scale-item label { + min-width: 30px; + height: 30px; + font-size: 13px; + } + + /* Уменьшаем размер чекбоксов и радиокнопок для лучшего тача */ + input[type="checkbox"], + input[type="radio"] { + transform: scale(1.2); + margin-right: 8px; + } +} \ No newline at end of file diff --git a/server/routers/questioneer/public/static/js/admin.js b/server/routers/questioneer/public/static/js/admin.js new file mode 100644 index 0000000..ba55dc9 --- /dev/null +++ b/server/routers/questioneer/public/static/js/admin.js @@ -0,0 +1,465 @@ +/* global $, window, document, showAlert, showConfirm, showQRCodeModal */ +$(document).ready(function() { + const adminLink = window.location.pathname.split('/').pop(); + let questionnaireData = null; + + // Функция для получения базового пути API + const getApiPath = () => { + // Проверяем, содержит ли путь /ms/ (продакшн на dev.bro-js.ru) + const pathname = window.location.pathname; + const isMsPath = pathname.includes('/ms/questioneer'); + + if (isMsPath) { + // Для продакшна: если в пути есть /ms/, то API доступно по /ms/questioneer/api + return '/ms/questioneer/api'; + } else { + // Для локальной разработки: формируем путь к API без учета текущей страницы + // Извлекаем базовый путь из URL страницы до /admin/[adminLink] + const basePath = pathname.split('/admin')[0]; + + // Путь до API приложения + return basePath + '/api'; + } + }; + + // Загрузка данных опроса + const loadQuestionnaire = () => { + $.ajax({ + url: `${getApiPath()}/questionnaires/admin/${adminLink}`, + method: 'GET', + success: function(result) { + if (result.success) { + questionnaireData = result.data; + renderQuestionnaire(); + } else { + $('#loading').text(`Ошибка: ${result.error}`); + } + }, + error: function(error) { + console.error('Error loading questionnaire:', error); + $('#loading').text('Не удалось загрузить опрос. Пожалуйста, попробуйте позже.'); + } + }); + }; + + // Отображение данных опроса + const renderQuestionnaire = () => { + // Заполняем основные данные + $('#questionnaire-title').text(questionnaireData.title); + $('#questionnaire-description').text(questionnaireData.description || 'Нет описания'); + + // Формируем ссылки + const baseUrl = window.location.origin; + const isMsPath = window.location.pathname.includes('/ms/questioneer'); + + let baseQuestionnairePath; + if (isMsPath) { + // Для продакшна: используем /ms/questioneer + baseQuestionnairePath = '/ms/questioneer'; + } else { + // Для локальной разработки: используем текущий путь + baseQuestionnairePath = window.location.pathname.split('/admin')[0]; + } + + const publicUrl = `${baseUrl}${baseQuestionnairePath}/poll/${questionnaireData.publicLink}`; + const adminUrl = `${baseUrl}${baseQuestionnairePath}/admin/${questionnaireData.adminLink}`; + + $('#public-link').val(publicUrl); + $('#admin-link').val(adminUrl); + + // Отображаем статистику + renderStats(questionnaireData.questions); + + // Показываем контейнер с данными + $('#loading').hide(); + $('#questionnaire-container').show(); + }; + + // Отображение статистики опроса + const renderStats = (questions) => { + const $statsContainer = $('#stats-container'); + $statsContainer.empty(); + + // Проверяем, есть ли ответы + let hasAnyResponses = false; + + // Проверяем наличие ответов для каждого типа вопросов + for (const question of questions) { + // Согласовываем типы вопросов между бэкендом и фронтендом + const questionType = normalizeQuestionType(question.type); + + if (questionType === 'single' || questionType === 'multiple') { + if (question.options && question.options.some(option => (option.votes > 0 || option.count > 0))) { + hasAnyResponses = true; + break; + } + } else if (questionType === 'tagcloud') { + if (question.tags && question.tags.some(tag => tag.count > 0)) { + hasAnyResponses = true; + break; + } + } else if (questionType === 'scale' || questionType === 'rating') { + // Проверяем оба возможных поля для данных шкалы + const hasScaleValues = question.scaleValues && question.scaleValues.length > 0; + const hasResponses = question.responses && question.responses.length > 0; + if (hasScaleValues || hasResponses) { + hasAnyResponses = true; + break; + } + } else if (questionType === 'text') { + // Проверяем оба возможных поля для текстовых ответов + const hasTextAnswers = question.textAnswers && question.textAnswers.length > 0; + const hasAnswers = question.answers && question.answers.length > 0; + if (hasTextAnswers || hasAnswers) { + hasAnyResponses = true; + break; + } + } + } + + if (!hasAnyResponses) { + $statsContainer.html('
Пока нет ответов на опрос
'); + return; + } + + // Для каждого вопроса создаем блок статистики + questions.forEach((question, index) => { + const $questionStats = $('
', { class: 'question-stats' }); + const $questionTitle = $('

', { text: `${index + 1}. ${question.text}` }); + $questionStats.append($questionTitle); + + // Согласовываем типы вопросов между бэкендом и фронтендом + const questionType = normalizeQuestionType(question.type); + + // В зависимости от типа вопроса отображаем разную статистику + if (questionType === 'single' || questionType === 'multiple') { + // Для вопросов с выбором вариантов + renderChoiceStats(question, $questionStats); + } else if (questionType === 'tagcloud') { + // Для облака тегов + renderTagCloudStats(question, $questionStats); + } else if (questionType === 'scale' || questionType === 'rating') { + // Для шкалы и рейтинга + renderScaleStats(question, $questionStats); + } else if (questionType === 'text') { + // Для текстовых ответов + renderTextStats(question, $questionStats); + } + + $statsContainer.append($questionStats); + }); + }; + + // Приводит тип вопроса к стандартному формату + const normalizeQuestionType = (type) => { + const typeMap = { + 'single_choice': 'single', + 'multiple_choice': 'multiple', + 'tag_cloud': 'tagcloud', + 'single': 'single', + 'multiple': 'multiple', + 'tagcloud': 'tagcloud', + 'scale': 'scale', + 'rating': 'rating', + 'text': 'text' + }; + return typeMap[type] || type; + }; + + // Отображение статистики для вопросов с выбором + const renderChoiceStats = (question, $container) => { + // Преобразуем опции к единому формату + const options = question.options.map(option => ({ + text: option.text, + votes: option.votes || option.count || 0 + })); + + const totalVotes = options.reduce((sum, option) => sum + option.votes, 0); + + if (totalVotes === 0) { + $container.append($('
', { class: 'no-votes', text: 'Нет голосов' })); + return; + } + + const $table = $('', { class: 'stats-table' }); + const $thead = $('').append( + $('').append( + $(''); + + options.forEach(option => { + const votes = option.votes; + const percent = totalVotes > 0 ? Math.round((votes / totalVotes) * 100) : 0; + + const $tr = $('').append( + $('
', { 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 = $('', { class: 'stats-table' }); + const $thead = $('').append( + $('').append( + $(''); + + // Определяем минимальное и максимальное значение шкалы из самих данных + // либо используем значения из настроек вопроса, если они есть + const scaleMin = question.scaleMin !== undefined ? question.scaleMin : min; + const scaleMax = question.scaleMax !== undefined ? question.scaleMax : max; + + // Создаем счетчик для каждого возможного значения шкалы + const countByValue = {}; + for (let i = scaleMin; i <= scaleMax; i++) { + countByValue[i] = 0; + } + + // Подсчитываем количество голосов для каждого значения + values.forEach(value => { + if (countByValue[value] !== undefined) { + countByValue[value]++; + } + }); + + // Создаем строки таблицы для каждого значения шкалы + for (let value = scaleMin; value <= scaleMax; value++) { + const count = countByValue[value] || 0; + const percent = values.length > 0 ? Math.round((count / values.length) * 100) : 0; + + const $tr = $('').append( + $('
', { text: 'Значение' }), + $('', { text: 'Голоса' }), + $('', { text: '%' }), + $('', { text: 'Визуализация' }) + ) + ); + + const $tbody = $('
', { text: value }), + $('', { text: count }), + $('', { text: `${percent}%` }), + $('').append( + $('
', { class: 'bar-container' }).append( + $('
', { + class: 'bar', + css: { width: `${percent}%` } + }) + ) + ) + ); + + $tbody.append($tr); + } + + $table.append($thead, $tbody); + $scaleStats.append($table); + + $container.append($scaleStats); + }; + + // Отображение статистики для текстовых ответов + const renderTextStats = (question, $container) => { + // Используем textAnswers или answers, в зависимости от того, что доступно + const answers = question.textAnswers && question.textAnswers.length > 0 + ? question.textAnswers + : (question.answers || []); + + if (answers.length === 0) { + $container.append($('
', { class: 'no-votes', text: 'Нет текстовых ответов' })); + return; + } + + const $textAnswers = $('
', { class: 'text-answers-list' }); + + answers.forEach((answer, i) => { + $textAnswers.append( + $('
', { class: 'text-answer-item' }).append( + $('
', { class: 'answer-number', text: `#${i + 1}` }), + $('
', { class: 'answer-text', text: answer }) + ) + ); + }); + + $container.append($textAnswers); + }; + + // Копирование ссылок + $('#copy-public-link').on('click', function() { + $('#public-link').select(); + document.execCommand('copy'); + showAlert('Ссылка для голосования скопирована в буфер обмена', 'Копирование', null, true); + }); + + $('#copy-admin-link').on('click', function() { + $('#admin-link').select(); + document.execCommand('copy'); + showAlert('Административная ссылка скопирована в буфер обмена', 'Копирование', null, true); + }); + + // Отображение QR-кода + $('#show-qr-code').on('click', function() { + const publicUrl = $('#public-link').val(); + showQRCodeModal(publicUrl, 'QR-код для голосования'); + }); + + // Редактирование опроса + $('#edit-questionnaire').on('click', function() { + // Перенаправляем на страницу редактирования + const isMsPath = window.location.pathname.includes('/ms/questioneer'); + let basePath; + + if (isMsPath) { + // Для продакшна: используем /ms/questioneer + basePath = '/ms/questioneer'; + } else { + // Для локальной разработки: используем текущий путь + basePath = window.location.pathname.split('/admin')[0]; + } + + window.location.href = `${basePath}/edit/${adminLink}`; + }); + + // Удаление опроса + $('#delete-questionnaire').on('click', function() { + showConfirm('Вы уверены, что хотите удалить опрос? Все ответы будут удалены безвозвратно.', function(confirmed) { + if (confirmed) { + deleteQuestionnaire(); + } + }, 'Удаление опроса'); + }); + + // Функция удаления опроса + const deleteQuestionnaire = () => { + $.ajax({ + url: `${getApiPath()}/questionnaires/${adminLink}`, + method: 'DELETE', + success: function(result) { + if (result.success) { + showAlert('Опрос успешно удален', 'Удаление опроса', function() { + // Получаем базовый путь с учетом /ms в продакшен-версии + const isMsPath = window.location.pathname.includes('/ms/questioneer'); + let basePath; + + if (isMsPath) { + // Для продакшна: используем /ms/questioneer + basePath = '/ms/questioneer'; + } else { + // Для локальной разработки: используем текущий путь + basePath = window.location.pathname.split('/admin')[0]; + } + + // Перенаправляем на главную страницу + window.location.href = basePath; + }, true); + } else { + showAlert(`Ошибка при удалении опроса: ${result.error}`, 'Ошибка'); + } + }, + error: function(error) { + console.error('Error deleting questionnaire:', error); + showAlert('Не удалось удалить опрос. Пожалуйста, попробуйте позже.', 'Ошибка'); + } + }); + }; + + // Инициализация + loadQuestionnaire(); + + // Обновление данных каждые 10 секунд + setInterval(loadQuestionnaire, 10000); +}); \ No newline at end of file diff --git a/server/routers/questioneer/public/static/js/common.js b/server/routers/questioneer/public/static/js/common.js new file mode 100644 index 0000000..7007de8 --- /dev/null +++ b/server/routers/questioneer/public/static/js/common.js @@ -0,0 +1,236 @@ +/* global $, document */ + +// Функция для создания модального окна +function createModal(options) { + // Если модальное окно уже существует, удаляем его + $('.modal-overlay').remove(); + + // Опции по умолчанию + const defaultOptions = { + title: 'Сообщение', + content: '', + closeText: 'Закрыть', + onClose: null, + showCancel: false, + cancelText: 'Отмена', + confirmText: 'Подтвердить', + onConfirm: null, + onCancel: null, + size: 'normal', // 'normal', 'large', 'small' + customClass: '', + autoClose: false, // Автоматическое закрытие по таймеру + autoCloseTime: 2000 // Время до автоматического закрытия (2 секунды) + }; + + // Объединяем пользовательские опции с опциями по умолчанию + const settings = $.extend({}, defaultOptions, options); + + // Создаем структуру модального окна + const $modalOverlay = $('
', { class: 'modal-overlay' }); + const $modal = $('
', { class: `modal ${settings.customClass}` }); + + // Устанавливаем ширину в зависимости от размера + if (settings.size === 'large') { + $modal.css('max-width', '700px'); + } else if (settings.size === 'small') { + $modal.css('max-width', '400px'); + } + + // Создаем заголовок + const $modalHeader = $('
', { class: 'modal-header' }); + const $modalTitle = $('

', { text: settings.title }); + const $modalClose = $(' +

+
+ `; + + const modal = createModal({ + title: title || 'QR-код для доступа', + content: content, + size: 'large' + }); + + // Добавляем обработчик для кнопки копирования + modal.$modal.find('.btn-copy-link').on('click', function() { + const input = modal.$modal.find('.qr-link-input'); + input.select(); + document.execCommand('copy'); + + // Показываем уведомление о копировании + const $button = $(this); + const originalText = $button.text(); + $button.text('Скопировано!'); + $button.addClass('copied'); + + setTimeout(function() { + $button.text(originalText); + $button.removeClass('copied'); + }, 1500); + }); + + return modal; +} \ No newline at end of file diff --git a/server/routers/questioneer/public/static/js/create.js b/server/routers/questioneer/public/static/js/create.js new file mode 100644 index 0000000..e979449 --- /dev/null +++ b/server/routers/questioneer/public/static/js/create.js @@ -0,0 +1,364 @@ +/* global $, window, document, alert, showAlert, showConfirm */ +$(document).ready(function() { + const form = $('#create-questionnaire-form'); + const questionsList = $('#questions-list'); + const addQuestionBtn = $('#add-question'); + + let questionCount = 0; + + // Функция для получения базового пути API + const getApiPath = () => { + // Проверяем, содержит ли путь /ms/ (продакшн на dev.bro-js.ru) + const pathname = window.location.pathname; + const isMsPath = pathname.includes('/ms/questioneer'); + + if (isMsPath) { + // Для продакшна: если в пути есть /ms/, то API доступно по /ms/questioneer/api + return '/ms/questioneer/api'; + } else { + // Для локальной разработки: формируем путь к API без учета текущей страницы + // Извлекаем базовый путь из URL страницы до /create + const basePath = pathname.split('/create')[0]; + + // Путь до API приложения + return basePath + '/api'; + } + }; + + // Добавление нового вопроса + addQuestionBtn.on('click', function() { + addQuestion(); + }); + + // Обработка отправки формы + form.on('submit', function(e) { + e.preventDefault(); + saveQuestionnaire(); + }); + + // Делегирование событий для динамических элементов + questionsList.on('click', '.delete-question', function() { + // Удаление вопроса + const questionItem = $(this).closest('.question-item'); + showConfirm('Вы уверены, что хотите удалить этот вопрос?', function(confirmed) { + if (confirmed) { + questionItem.remove(); + renumberQuestions(); + // Вызываем функцию обновления атрибутов required + updateRequiredAttributes(); + } + }); + }); + + questionsList.on('click', '.add-option', function() { + // Добавление варианта ответа + const questionIndex = $(this).data('question-index'); + addOption(questionIndex); + }); + + questionsList.on('click', '.delete-option', function() { + // Удаление варианта ответа + $(this).closest('.option-item').remove(); + // Вызываем функцию обновления атрибутов required + updateRequiredAttributes(); + }); + + // Делегирование для изменения типа вопроса + questionsList.on('change', '.question-type-select', function() { + const questionItem = $(this).closest('.question-item'); + const questionIndex = questionItem.data('index'); + const optionsContainer = $(`#options-container-${questionIndex}`); + const scaleContainer = $(`#scale-container-${questionIndex}`); + + // Скрыть/показать варианты ответа в зависимости от типа вопроса + const questionType = $(this).val(); + if (['single_choice', 'multiple_choice', 'tag_cloud'].includes(questionType)) { + optionsContainer.show(); + scaleContainer.hide(); + + // Если нет вариантов, добавляем два + const optionsList = $(`#options-list-${questionIndex}`); + if (optionsList.children().length === 0) { + addOption(questionIndex); + addOption(questionIndex); + } + + // Включаем required для полей ввода вариантов + optionsList.find('input[type="text"]').prop('required', true); + } else if (questionType === 'scale') { + optionsContainer.hide(); + scaleContainer.show(); + // Отключаем required для скрытых полей + $(`#options-list-${questionIndex}`).find('input[type="text"]').prop('required', false); + } else { + optionsContainer.hide(); + scaleContainer.hide(); + // Отключаем required для скрытых полей + $(`#options-list-${questionIndex}`).find('input[type="text"]').prop('required', false); + } + + // Вызываем функцию обновления атрибутов required + updateRequiredAttributes(); + }); + + // Функция для добавления нового вопроса + function addQuestion() { + const template = $('#question-template').html(); + const index = questionCount++; + + // Заменяем плейсхолдеры в шаблоне + let questionHtml = template + .replace(/\{\{index\}\}/g, index) + .replace(/\{\{number\}\}/g, index + 1); + + questionsList.append(questionHtml); + + // Показываем/скрываем контейнер вариантов в зависимости от типа вопроса + const questionType = $(`#question-type-${index}`).val(); + if (!['single_choice', 'multiple_choice', 'tag_cloud'].includes(questionType)) { + $(`#options-container-${index}`).hide(); + // Отключаем required для скрытых полей + $(`#options-list-${index}`).find('input[type="text"]').prop('required', false); + } else { + // Добавляем пару начальных вариантов ответа + addOption(index); + addOption(index); + } + + if (questionType === 'scale') { + $(`#scale-container-${index}`).show(); + } else { + $(`#scale-container-${index}`).hide(); + } + + // Вызываем функцию обновления атрибутов required + updateRequiredAttributes(); + } + + // Функция для добавления варианта ответа + function addOption(questionIndex) { + const optionsList = $(`#options-list-${questionIndex}`); + const template = $('#option-template').html(); + + const optionIndex = optionsList.children().length; + + // Заменяем плейсхолдеры в шаблоне + let optionHtml = template + .replace(/\{\{questionIndex\}\}/g, questionIndex) + .replace(/\{\{optionIndex\}\}/g, optionIndex); + + optionsList.append(optionHtml); + + // Проверяем, видим ли контейнер опций + const optionsContainer = $(`#options-container-${questionIndex}`); + if (optionsContainer.is(':hidden')) { + // Если контейнер скрыт, отключаем required у полей ввода + optionsList.find('input[type="text"]').prop('required', false); + } + + // Вызываем функцию обновления атрибутов required + updateRequiredAttributes(); + } + + // Перенумерация вопросов + function renumberQuestions() { + $('.question-item').each(function(index) { + $(this).find('h3').text(`Вопрос ${index + 1}`); + }); + } + + // Функция для обновления нумерации вопросов + function updateQuestionNumbers() { + $('.question-item').each(function(index) { + $(this).find('h3').text(`Вопрос ${index + 1}`); + }); + } + + // Сохранение опроса + function saveQuestionnaire() { + const questionnaire = { + title: $('#title').val(), + description: $('#description').val(), + displayType: 'step_by_step', // Всегда устанавливаем пошаговый режим + questions: [] + }; + + // Собираем данные о вопросах + $('.question-item').each(function() { + const index = $(this).data('index'); + const questionType = $(`#question-type-${index}`).val(); + + const question = { + text: $(`#question-text-${index}`).val(), + type: questionType, + required: $(`input[name="questions[${index}][required]"]`).is(':checked'), + options: [] + }; + + // Добавляем настройки шкалы если нужно + if (questionType === 'scale') { + question.scaleMin = parseInt($(`#scale-min-${index}`).val()) || 0; + question.scaleMax = parseInt($(`#scale-max-${index}`).val()) || 10; + question.scaleMinLabel = $(`#scale-min-label-${index}`).val() || 'Минимум'; + question.scaleMaxLabel = $(`#scale-max-label-${index}`).val() || 'Максимум'; + } + + // Собираем варианты ответа если это не текстовый вопрос или шкала + if (['single_choice', 'multiple_choice', 'tag_cloud'].includes(questionType)) { + $(`#options-list-${index} .option-item`).each(function() { + const optionText = $(this).find('input[type="text"]').val(); + + if (optionText) { + question.options.push({ + text: optionText, + count: 0 + }); + } + }); + } + + questionnaire.questions.push(question); + }); + + // Отправка на сервер + $.ajax({ + url: `${getApiPath()}/questionnaires`, + method: 'POST', + contentType: 'application/json', + data: JSON.stringify(questionnaire), + success: function(result) { + if (result.success) { + // Перенаправляем на страницу администратора + const isMsPath = window.location.pathname.includes('/ms/questioneer'); + let basePath; + + if (isMsPath) { + // Для продакшна: используем /ms/questioneer + basePath = '/ms/questioneer'; + } else { + // Для локальной разработки: используем текущий путь + basePath = window.location.pathname.split('/create')[0]; + } + + window.location.href = `${basePath}/admin/${result.data.adminLink}`; + } else { + showAlert(`Ошибка при создании опроса: ${result.error}`, 'Ошибка'); + } + }, + error: function(error) { + console.error('Error creating questionnaire:', error); + showAlert('Не удалось создать опрос. Пожалуйста, попробуйте позже.', 'Ошибка'); + } + }); + } + + // Функция для обновления атрибута required в зависимости от видимости полей + function updateRequiredAttributes() { + // Для полей вопросов + $('.question-item').each(function() { + const questionType = $(this).find('.question-type-select').val(); + const textInput = $(this).find('.question-text'); + const optionsContainer = $(this).find('.options-container'); + + // Обновляем required для текстового поля вопроса + if (textInput.is(':visible')) { + textInput.prop('required', true); + } else { + textInput.prop('required', false); + } + + // Обновляем required для полей опций + if (questionType === 'single_choice' || questionType === 'multiple_choice') { + optionsContainer.find('input[type="text"]').each(function() { + if ($(this).is(':visible')) { + $(this).prop('required', true); + } else { + $(this).prop('required', false); + } + }); + } else { + optionsContainer.find('input[type="text"]').prop('required', false); + } + + // Для шкалы оценки + if (questionType === 'scale') { + const minInput = $(this).find('.scale-min'); + const maxInput = $(this).find('.scale-max'); + const minLabelInput = $(this).find('.scale-min-label'); + const maxLabelInput = $(this).find('.scale-max-label'); + + if (minInput.is(':visible')) minInput.prop('required', true); + else minInput.prop('required', false); + + if (maxInput.is(':visible')) maxInput.prop('required', true); + else maxInput.prop('required', false); + + if (minLabelInput.is(':visible')) minLabelInput.prop('required', true); + else minLabelInput.prop('required', false); + + if (maxLabelInput.is(':visible')) maxLabelInput.prop('required', true); + else maxLabelInput.prop('required', false); + } + }); + + // Для основных полей формы + const titleInput = $('#title'); + const descriptionInput = $('#description'); + + if (titleInput.is(':visible')) titleInput.prop('required', true); + else titleInput.prop('required', false); + + if (descriptionInput.is(':visible')) descriptionInput.prop('required', false); // Описание не обязательно + } + + // Инициализация с одним вопросом + addQuestion(); + + // Обработчик отправки формы + $('#create-questionnaire-form').on('submit', function(e) { + // Обновляем атрибуты required перед отправкой + updateRequiredAttributes(); + + // Проверяем валидность формы + if (!this.checkValidity()) { + e.preventDefault(); + e.stopPropagation(); + + // Находим первый невалидный элемент и прокручиваем к нему + const firstInvalid = $(this).find(':invalid').first(); + if (firstInvalid.length) { + $('html, body').animate({ + scrollTop: firstInvalid.offset().top - 100 + }, 500); + + // Добавляем класс ошибки к родительскому элементу вопроса + firstInvalid.closest('.question-item').addClass('error'); + setTimeout(() => { + firstInvalid.closest('.question-item').removeClass('error'); + }, 3000); + } + } + + $(this).addClass('was-validated'); + }); + + // Инициализируем атрибуты required + updateRequiredAttributes(); +}); + +// Обработчик удаления вопроса +$(document).on('click', '.remove-question', function() { + $(this).closest('.question-item').remove(); + updateQuestionNumbers(); + + // Вызываем функцию обновления атрибутов required + updateRequiredAttributes(); +}); + +// Обработчик удаления опции +$(document).on('click', '.remove-option', function() { + $(this).closest('.option-item').remove(); + + // Вызываем функцию обновления атрибутов required + updateRequiredAttributes(); +}); \ No newline at end of file diff --git a/server/routers/questioneer/public/static/js/edit.js b/server/routers/questioneer/public/static/js/edit.js new file mode 100644 index 0000000..15e35a8 --- /dev/null +++ b/server/routers/questioneer/public/static/js/edit.js @@ -0,0 +1,355 @@ +/* global $, window, document, showAlert, showConfirm, showQRCodeModal */ +$(document).ready(function() { + const form = $('#edit-questionnaire-form'); + const questionsList = $('#questions-list'); + const addQuestionBtn = $('#add-question'); + const adminLink = window.location.pathname.split('/').pop(); + + let questionCount = 0; + let questionnaireData = null; + + // Функция для получения базового пути API + const getApiPath = () => { + // Проверяем, содержит ли путь /ms/ (продакшн на dev.bro-js.ru) + const pathname = window.location.pathname; + const isMsPath = pathname.includes('/ms/questioneer'); + + if (isMsPath) { + // Для продакшна: если в пути есть /ms/, то API доступно по /ms/questioneer/api + return '/ms/questioneer/api'; + } else { + // Для локальной разработки: формируем путь к API без учета текущей страницы + // Извлекаем базовый путь из URL страницы до /edit/[adminLink] + const basePath = pathname.split('/edit')[0]; + + // Убеждаемся, что путь не заканчивается на /admin, если это часть URL + const cleanPath = basePath.endsWith('/admin') ? basePath.slice(0, -6) : basePath; + + // Путь до API приложения + return cleanPath + '/api'; + } + }; + + // Загрузка данных опроса + const loadQuestionnaire = () => { + $.ajax({ + url: `${getApiPath()}/questionnaires/admin/${adminLink}`, + method: 'GET', + success: function(result) { + if (result.success) { + questionnaireData = result.data; + fillFormData(); + $('#loading').hide(); + $('#edit-form-container').show(); + } else { + $('#loading').text(`Ошибка: ${result.error}`); + } + }, + error: function(error) { + console.error('Error loading questionnaire:', error); + $('#loading').text('Не удалось загрузить опрос. Пожалуйста, попробуйте позже.'); + } + }); + }; + + // Заполнение формы данными опроса + const fillFormData = () => { + // Заполняем основные данные + $('#title').val(questionnaireData.title); + $('#description').val(questionnaireData.description || ''); + $('#display-type').val(questionnaireData.displayType); + + // Формируем ссылки + const baseUrl = window.location.origin; + const baseQuestionnairePath = window.location.pathname.split('/edit')[0]; + const publicUrl = `${baseUrl}${baseQuestionnairePath}/poll/${questionnaireData.publicLink}`; + const adminUrl = `${baseUrl}${baseQuestionnairePath}/admin/${questionnaireData.adminLink}`; + + $('#public-link').val(publicUrl); + $('#admin-link').val(adminUrl); + + // Добавляем вопросы + questionsList.empty(); + + if (questionnaireData.questions && questionnaireData.questions.length > 0) { + questionnaireData.questions.forEach((question, index) => { + addQuestion(question); + }); + } else { + // Если нет вопросов, добавляем пустой + addQuestion(); + } + + renumberQuestions(); + }; + + // Добавление нового вопроса + addQuestionBtn.on('click', function() { + addQuestion(); + renumberQuestions(); + }); + + // Обработка отправки формы + form.on('submit', function(e) { + e.preventDefault(); + saveQuestionnaire(); + }); + + // Делегирование событий для динамических элементов + questionsList.on('click', '.delete-question', function() { + // Удаление вопроса + const questionItem = $(this).closest('.question-item'); + questionItem.remove(); + renumberQuestions(); + }); + + questionsList.on('click', '.add-option', function() { + // Добавление варианта ответа + const questionIndex = $(this).data('question-index'); + addOption(questionIndex); + }); + + questionsList.on('click', '.delete-option', function() { + // Удаление варианта ответа + $(this).closest('.option-item').remove(); + }); + + // Делегирование для изменения типа вопроса + questionsList.on('change', '.question-type-select', function() { + const questionItem = $(this).closest('.question-item'); + const questionIndex = questionItem.data('index'); + const optionsContainer = $(`#options-container-${questionIndex}`); + const scaleContainer = $(`#scale-container-${questionIndex}`); + + // Показываем/скрываем контейнеры в зависимости от типа вопроса + const questionType = $(this).val(); + if (['single_choice', 'multiple_choice', 'tag_cloud'].includes(questionType)) { + optionsContainer.show(); + scaleContainer.hide(); + + // Если нет вариантов, добавляем два + const optionsList = $(`#options-list-${questionIndex}`); + if (optionsList.children().length === 0) { + addOption(questionIndex); + addOption(questionIndex); + } + + // Включаем required для полей ввода вариантов + optionsList.find('input[type="text"]').prop('required', true); + } else if (questionType === 'scale') { + optionsContainer.hide(); + scaleContainer.show(); + // Отключаем required для скрытых полей + $(`#options-list-${questionIndex}`).find('input[type="text"]').prop('required', false); + } else { + optionsContainer.hide(); + scaleContainer.hide(); + // Отключаем required для скрытых полей + $(`#options-list-${questionIndex}`).find('input[type="text"]').prop('required', false); + } + }); + + // Копирование ссылок + $('#copy-public-link').on('click', function() { + $('#public-link').select(); + document.execCommand('copy'); + showAlert('Ссылка для голосования скопирована в буфер обмена', 'Копирование'); + }); + + $('#copy-admin-link').on('click', function() { + $('#admin-link').select(); + document.execCommand('copy'); + showAlert('Административная ссылка скопирована в буфер обмена', 'Копирование'); + }); + + // Отображение QR-кода + $('#show-qr-code').on('click', function() { + const publicUrl = $('#public-link').val(); + showQRCodeModal(publicUrl, 'QR-код для голосования'); + }); + + // Возврат к админке + $('#back-to-admin').on('click', function(e) { + e.preventDefault(); + const basePath = window.location.pathname.split('/edit')[0]; + window.location.href = `${basePath}/admin/${adminLink}`; + }); + + // Функция для добавления нового вопроса + function addQuestion(questionData) { + const template = $('#question-template').html(); + const index = questionCount++; + + // Заменяем плейсхолдеры в шаблоне + let questionHtml = template + .replace(/\{\{index\}\}/g, index) + .replace(/\{\{number\}\}/g, index + 1); + + questionsList.append(questionHtml); + + // Если есть данные вопроса - заполняем поля + if (questionData) { + $(`#question-text-${index}`).val(questionData.text); + $(`#question-type-${index}`).val(questionData.type); + + if (questionData.required) { + $(`input[name="questions[${index}][required]"]`).prop('checked', true); + } + + // Добавляем варианты ответа если они есть + if (questionData.options && questionData.options.length > 0) { + questionData.options.forEach(option => { + addOption(index, option.text); + }); + } + + // Заполняем настройки шкалы если нужно + if (questionData.scaleMax) { + $(`#scale-max-${index}`).val(questionData.scaleMax); + } + } + + // Показываем/скрываем контейнеры в зависимости от типа вопроса + const questionType = $(`#question-type-${index}`).val(); + if (['single_choice', 'multiple_choice', 'tag_cloud'].includes(questionType)) { + $(`#options-container-${index}`).show(); + $(`#scale-container-${index}`).hide(); + + // Если нет вариантов и не загружены данные, добавляем два + if (!questionData && $(`#options-list-${index}`).children().length === 0) { + addOption(index); + addOption(index); + } + } else if (questionType === 'scale') { + $(`#options-container-${index}`).hide(); + $(`#scale-container-${index}`).show(); + } else { + $(`#options-container-${index}`).hide(); + $(`#scale-container-${index}`).hide(); + } + } + + // Функция для добавления варианта ответа + function addOption(questionIndex, optionText) { + const optionsList = $(`#options-list-${questionIndex}`); + const template = $('#option-template').html(); + + const optionIndex = optionsList.children().length; + + // Заменяем плейсхолдеры в шаблоне + let optionHtml = template + .replace(/\{\{questionIndex\}\}/g, questionIndex) + .replace(/\{\{optionIndex\}\}/g, optionIndex); + + optionsList.append(optionHtml); + + // Если есть текст варианта - устанавливаем его + if (optionText) { + optionsList.children().last().find('input[type="text"]').val(optionText); + } + + // Проверяем, видим ли контейнер опций + const optionsContainer = $(`#options-container-${questionIndex}`); + if (optionsContainer.is(':hidden')) { + // Если контейнер скрыт, отключаем required у полей ввода + optionsList.find('input[type="text"]').prop('required', false); + } + } + + // Перенумерация вопросов + function renumberQuestions() { + $('.question-item').each(function(index) { + $(this).find('h3').text(`Вопрос ${index + 1}`); + }); + } + + // Сохранение опроса + function saveQuestionnaire() { + const questionnaire = { + title: $('#title').val(), + description: $('#description').val(), + displayType: $('#display-type').val(), + questions: [] + }; + + // Собираем данные о вопросах + $('.question-item').each(function() { + const index = $(this).data('index'); + const questionType = $(`#question-type-${index}`).val(); + + const question = { + text: $(`#question-text-${index}`).val(), + type: questionType, + required: $(`input[name="questions[${index}][required]"]`).is(':checked'), + options: [] + }; + + // Добавляем настройки шкалы если нужно + if (questionType === 'scale') { + question.scaleMax = parseInt($(`#scale-max-${index}`).val()); + } + + // Собираем варианты ответа если это не текстовый вопрос или оценка + if (['single_choice', 'multiple_choice', 'tag_cloud'].includes(questionType)) { + $(`#options-list-${index} .option-item`).each(function() { + const optionText = $(this).find('input[type="text"]').val(); + + if (optionText) { + // Сохраняем количество голосов из старых данных + let count = 0; + const optionIndex = $(this).data('index'); + + if (questionnaireData && + questionnaireData.questions[index] && + questionnaireData.questions[index].options && + questionnaireData.questions[index].options[optionIndex]) { + count = questionnaireData.questions[index].options[optionIndex].count || 0; + } + + question.options.push({ + text: optionText, + count: count + }); + } + }); + } + + questionnaire.questions.push(question); + }); + + // Отправка на сервер + $.ajax({ + url: `${getApiPath()}/questionnaires/${adminLink}`, + method: 'PUT', + contentType: 'application/json', + data: JSON.stringify(questionnaire), + success: function(result) { + if (result.success) { + showAlert('Опрос успешно сохранен!', 'Успех', { autoClose: true }); + // Перенаправляем на страницу администратора + const isMsPath = window.location.pathname.includes('/ms/questioneer'); + let basePath; + + if (isMsPath) { + // Для продакшна: используем /ms/questioneer + basePath = '/ms/questioneer'; + } else { + // Для локальной разработки: используем текущий путь + basePath = window.location.pathname.split('/edit')[0]; + } + + window.location.href = `${basePath}/admin/${adminLink}`; + } else { + showAlert(`Ошибка при обновлении опроса: ${result.error}`, 'Ошибка'); + } + }, + error: function(error) { + console.error('Error updating questionnaire:', error); + showAlert('Не удалось обновить опрос. Пожалуйста, попробуйте позже.', 'Ошибка'); + } + }); + } + + // Инициализация + loadQuestionnaire(); +}); \ No newline at end of file diff --git a/server/routers/questioneer/public/static/js/index.js b/server/routers/questioneer/public/static/js/index.js new file mode 100644 index 0000000..e616047 --- /dev/null +++ b/server/routers/questioneer/public/static/js/index.js @@ -0,0 +1,82 @@ +/* global $, window, document */ +$(document).ready(function() { + // Функция для получения базового пути API + const getApiPath = () => { + // Проверяем, содержит ли путь /ms/ (продакшн на dev.bro-js.ru) + const pathname = window.location.pathname; + const isMsPath = pathname.includes('/ms/questioneer'); + + if (isMsPath) { + // Для продакшна: если в пути есть /ms/, то API доступно по /ms/questioneer/api + return '/ms/questioneer/api'; + } else { + // Для локальной разработки: формируем путь к API для главной страницы + // Убираем завершающий слеш, если он есть + const basePath = pathname.endsWith('/') ? pathname.slice(0, -1) : pathname; + + // Путь до API приложения + return basePath + '/api'; + } + }; + + // Функция для загрузки списка опросов + const loadQuestionnaires = () => { + $.ajax({ + url: getApiPath() + '/questionnaires', + method: 'GET', + success: function(result) { + if (result.success) { + renderQuestionnaires(result.data); + } else { + $('#questionnaires-container').html(`

Ошибка: ${result.error}

`); + } + }, + error: function(error) { + console.error('Error loading questionnaires:', error); + $('#questionnaires-container').html('

Не удалось загрузить опросы. Пожалуйста, попробуйте позже.

'); + } + }); + }; + + // Функция для отображения списка опросов + const renderQuestionnaires = (questionnaires) => { + if (!questionnaires || questionnaires.length === 0) { + $('#questionnaires-container').html('

У вас еще нет созданных опросов.

'); + return; + } + + // Получаем базовый путь (для работы и с /questioneer, и с /ms/questioneer) + const basePath = (() => { + const pathname = window.location.pathname; + const isMsPath = pathname.includes('/ms/questioneer'); + + if (isMsPath) { + // Для продакшна: нужно использовать /ms/questioneer/ для ссылок + return '/ms/questioneer/'; + } else { + // Для локальной разработки: используем текущий путь + return pathname.endsWith('/') ? pathname : pathname + '/'; + } + })(); + + const questionnairesHTML = questionnaires.map(q => ` +
+

${q.title}

+

${q.description || 'Нет описания'}

+

Создан: ${new Date(q.createdAt).toLocaleString()}

+ +
+ `).join(''); + + $('#questionnaires-container').html(questionnairesHTML); + }; + + // Инициализация страницы + loadQuestionnaires(); + + // Обновление данных каждые 30 секунд + setInterval(loadQuestionnaires, 30000); +}); \ No newline at end of file diff --git a/server/routers/questioneer/public/static/js/poll.js b/server/routers/questioneer/public/static/js/poll.js new file mode 100644 index 0000000..adbdd4d --- /dev/null +++ b/server/routers/questioneer/public/static/js/poll.js @@ -0,0 +1,1202 @@ +/* global $, window, document, showAlert */ +$(document).ready(function() { + const publicLink = window.location.pathname.split('/').pop(); + let questionnaireData = null; + + // Элементы DOM + const loadingEl = $('#loading'); + const containerEl = $('#questionnaire-container'); + const titleEl = $('#questionnaire-title'); + const descriptionEl = $('#questionnaire-description'); + const questionsContainerEl = $('#questions-container'); + const formEl = $('#poll-form'); + const resultsContainerEl = $('#results-container'); + const pollResultsContainerEl = $('#poll-results-container'); + + // Элементы навигации для пошаговых опросов + const navigationControlsEl = $('#navigation-controls'); + const prevButtonEl = $('#prev-question'); + const nextButtonEl = $('#next-question'); + const questionCounterEl = $('#question-counter'); + const submitButtonEl = $('#submit-button'); + + // Для пошаговых опросов + let currentQuestionIndex = 0; + + // Объявляем переменную для хранения накопленных ответов + let accumulatedAnswers = []; + + // Проверка доступности localStorage + const isLocalStorageAvailable = () => { + try { + const testKey = 'test'; + window.localStorage.setItem(testKey, testKey); + window.localStorage.removeItem(testKey); + return true; + } catch (e) { + return false; + } + }; + + // Ключ для localStorage + const getLocalStorageKey = () => `questionnaire_${publicLink}_completed`; + + // Проверка на повторное прохождение опроса + const checkIfAlreadyCompleted = () => { + if (!isLocalStorageAvailable()) return false; + return window.localStorage.getItem(getLocalStorageKey()) === 'true'; + }; + + // Сохранение информации о прохождении опроса + const markAsCompleted = () => { + if (!isLocalStorageAvailable()) return; + window.localStorage.setItem(getLocalStorageKey(), 'true'); + }; + + // Функция для получения базового пути API + const getApiPath = () => { + // Проверяем, содержит ли путь /ms/ (продакшн на dev.bro-js.ru) + const pathname = window.location.pathname; + const isMsPath = pathname.includes('/ms/questioneer'); + + if (isMsPath) { + // Для продакшна: если в пути есть /ms/, то API доступно по /ms/questioneer/api + return '/ms/questioneer/api'; + } else { + // Для локальной разработки: формируем путь к API без учета текущей страницы + // Извлекаем базовый путь из URL страницы до /poll + const basePath = pathname.split('/poll')[0]; + + // Путь до API приложения + return basePath + '/api'; + } + }; + + // Загрузка данных опроса + const loadQuestionnaire = () => { + $.ajax({ + url: `${getApiPath()}/questionnaires/public/${publicLink}`, + method: 'GET', + success: function(result) { + if (result.success) { + questionnaireData = result.data; + + // Проверяем, проходил ли пользователь уже этот опрос + if (checkIfAlreadyCompleted()) { + showAlreadyCompletedMessage(); + } else { + showWelcomeAnimation(); + } + } else { + loadingEl.text(`Ошибка: ${result.error}`); + } + }, + error: function(error) { + console.error('Error loading questionnaire:', error); + loadingEl.text('Не удалось загрузить опрос. Пожалуйста, попробуйте позже.'); + } + }); + }; + + // Показываем анимацию приветствия + const showWelcomeAnimation = () => { + // Скрываем индикатор загрузки + loadingEl.hide(); + + // Создаем элемент приветствия + const $welcomeEl = $('
', { + id: 'welcome-animation', + class: 'welcome-animation', + css: { + opacity: 0, + transform: 'translateY(20px)' + } + }); + + const $welcomeIcon = $('
', { + class: 'welcome-icon', + html: '' + }); + + const $welcomeTitle = $('

', { + text: `Добро пожаловать в опрос "${questionnaireData.title}"`, + class: 'welcome-title' + }); + + const $welcomeDescription = $('

', { + text: questionnaireData.description || 'Пожалуйста, ответьте на следующие вопросы:', + class: 'welcome-description' + }); + + const $startButton = $(' + Вопрос 1 из ${questionnaireData.questions.length} + +

+ `); + + // Обработчики для навигации + $('#prev-question').on('click', prevQuestion); + $('#next-question').on('click', nextQuestion); + + // Показываем только первый вопрос + currentQuestionIndex = 0; + renderCurrentQuestion(); + + // Показываем контейнер с данными + containerEl.show(); + }; + + // Отображение текущего вопроса в пошаговом режиме + const renderCurrentQuestion = () => { + // Очищаем контейнер вопросов + questionsContainerEl.empty(); + + // Получаем текущий вопрос + const currentQuestion = questionnaireData.questions[currentQuestionIndex]; + + // Отображаем вопрос + renderQuestion(questionsContainerEl, currentQuestion, currentQuestionIndex); + + // Добавляем классы для анимации + const $currentQuestionEl = questionsContainerEl.find(`.question-item[data-index="${currentQuestionIndex}"]`); + $currentQuestionEl.addClass('enter'); + + // Запускаем анимацию появления после небольшой задержки + setTimeout(() => { + $currentQuestionEl.removeClass('enter').addClass('active'); + }, 50); + + // Обновляем счетчик вопросов + questionCounterEl.addClass('update'); + questionCounterEl.text(`Вопрос ${currentQuestionIndex + 1} из ${questionnaireData.questions.length}`); + + // Снимаем класс анимации счетчика + setTimeout(() => { + questionCounterEl.removeClass('update'); + }, 500); + + // Управляем состоянием кнопок навигации + prevButtonEl.prop('disabled', currentQuestionIndex === 0); + nextButtonEl.text( + currentQuestionIndex === questionnaireData.questions.length - 1 + ? 'Отправить' + : 'Далее' + ); + }; + + // Переход к предыдущему вопросу + const prevQuestion = function() { + if (currentQuestionIndex > 0) { + currentQuestionIndex--; + renderCurrentQuestion(); + } + }; + + // Переход к следующему вопросу или завершение опроса + const nextQuestion = function() { + const currentQuestion = questionnaireData.questions[currentQuestionIndex]; + + // Проверяем, что на текущий вопрос есть ответ, если он обязательный + if (currentQuestion.required && !checkQuestionAnswer(currentQuestion, currentQuestionIndex)) { + // Добавляем класс ошибки к текущему вопросу + const $currentQuestionEl = questionsContainerEl.find(`.question-item[data-index="${currentQuestionIndex}"]`); + $currentQuestionEl.addClass('error shake'); + + // Удаляем класс ошибки после окончания анимации + setTimeout(() => { + $currentQuestionEl.removeClass('shake'); + }, 500); + + return; + } + + // Сохраняем ответ на текущий вопрос + saveCurrentAnswer(currentQuestion, currentQuestionIndex); + + // Если есть еще вопросы, переходим к следующему + if (currentQuestionIndex < questionnaireData.questions.length - 1) { + // Анимируем текущий вопрос перед переходом к следующему + questionsContainerEl.find(`.question-item[data-index="${currentQuestionIndex}"]`).addClass('exit'); + + // Увеличиваем индекс текущего вопроса + currentQuestionIndex++; + + // Рендерим новый вопрос после небольшой задержки + setTimeout(() => { + renderCurrentQuestion(); + }, 200); + } else { + // Анимируем контейнер перед отправкой формы + questionsContainerEl.find('.question-item').addClass('exit'); + + // Последний вопрос, отправляем форму + setTimeout(() => { + // Отключаем атрибут required у невидимых полей перед отправкой + $('input[required]:not(:visible), textarea[required]:not(:visible)').prop('required', false); + + submitForm(); + }, 300); + } + }; + + // Функция для сохранения ответа на текущий вопрос + const saveCurrentAnswer = (question, questionIndex) => { + // Нормализуем тип вопроса для согласованной обработки + const questionType = normalizeQuestionType(question.type); + const $questionEl = $(`.question-item[data-index="${questionIndex}"]`); + + // Удаляем предыдущий ответ на этот вопрос, если он был + accumulatedAnswers = accumulatedAnswers.filter(answer => answer.questionIndex !== questionIndex); + + let optionIndices; + let selectedOption; + let selectedOptions; + let selectedTags; + let textAnswer; + let scaleValue; + + switch (questionType) { + case 'single': + selectedOption = $questionEl.find(`input[name="question_${questionIndex}"]:checked`); + if (selectedOption.length) { + accumulatedAnswers.push({ + questionIndex, + optionIndices: [parseInt(selectedOption.val())] + }); + } + break; + + case 'multiple': + selectedOptions = $questionEl.find('input[type="checkbox"]:checked:not(.multiple-choice-validation)'); + if (selectedOptions.length) { + optionIndices = $.map(selectedOptions, function(option) { + const nameParts = $(option).attr('name').split('_'); + return parseInt(nameParts[nameParts.length - 1]); + }); + accumulatedAnswers.push({ + questionIndex, + optionIndices + }); + } + break; + + case 'text': + textAnswer = $questionEl.find('textarea').val().trim(); + if (textAnswer) { + accumulatedAnswers.push({ + questionIndex, + textAnswer + }); + } + break; + + case 'scale': + case 'rating': // Обратная совместимость + selectedOption = $questionEl.find(`input[name="question_${questionIndex}"]:checked`); + if (selectedOption.length) { + const scaleValue = parseInt(selectedOption.val()); + accumulatedAnswers.push({ + questionIndex, + scaleValue, + optionIndices: [scaleValue] + }); + } + break; + + case 'tagcloud': + selectedTags = $questionEl.find('.tag-item.selected'); + if (selectedTags.length) { + const tagTexts = $.map(selectedTags, function(tag) { + return $(tag).text().replace('×', '').trim(); + }); + accumulatedAnswers.push({ + questionIndex, + tagTexts + }); + } + break; + } + }; + + // Отправка формы + const submitForm = function() { + // Отключаем атрибут required у невидимых полей перед отправкой + $('input[required]:not(:visible), textarea[required]:not(:visible)').prop('required', false); + + // Отправляем накопленные ответы на сервер + $.ajax({ + url: `${getApiPath()}/vote/${publicLink}`, + method: 'POST', + contentType: 'application/json', + data: JSON.stringify({ answers: accumulatedAnswers }), + success: function(result) { + if (result.success) { + // Сохраняем информацию о прохождении опроса + markAsCompleted(); + + // Показываем анимацию благодарности вместо сразу скрытия формы + showThankYouAnimation(); + } else { + showAlert(`Ошибка: ${result.error}`, 'Ошибка'); + } + }, + error: function(error) { + console.error('Error submitting poll:', error); + showAlert('Не удалось отправить ответы. Пожалуйста, попробуйте позже.', 'Ошибка'); + } + }); + }; + + // Проверка наличия ответа на вопрос + const checkQuestionAnswer = (question, questionIndex) => { + switch (question.type) { + case 'single_choice': + return $(`.question-item[data-index="${questionIndex}"] input[name="question_${questionIndex}"]:checked`).length > 0; + + case 'multiple_choice': + return $(`.question-item[data-index="${questionIndex}"] input[type="checkbox"]:checked:not(.multiple-choice-validation)`).length > 0; + + case 'text': + return $(`.question-item[data-index="${questionIndex}"] textarea`).val().trim() !== ''; + + case 'scale': + return $(`.question-item[data-index="${questionIndex}"] input[name="question_${questionIndex}"]:checked`).length > 0; + + case 'tag_cloud': + return $(`.question-item[data-index="${questionIndex}"] .tag-item.selected`).length > 0; + + default: + return true; + } + }; + + // Отображение всех вопросов (стандартный режим) + const renderQuestions = () => { + questionsContainerEl.empty(); + + $.each(questionnaireData.questions, function(questionIndex, question) { + renderQuestion(questionsContainerEl, question, questionIndex); + }); + }; + + // Отображение одного вопроса + const renderQuestion = (container, question, questionIndex) => { + const $questionEl = $('
', { + class: 'question-item', + 'data-index': questionIndex + }); + + const $questionTitle = $('

', { + text: question.text, + class: 'question-title' + }); + + if (question.required) { + const $requiredMark = $('', { + class: 'required-mark', + text: ' *' + }); + $questionTitle.append($requiredMark); + } + + $questionEl.append($questionTitle); + + // Отображение вариантов ответа в зависимости от типа вопроса + switch (question.type) { + case 'single_choice': + renderSingleChoice($questionEl, question, questionIndex); + break; + case 'multiple_choice': + renderMultipleChoice($questionEl, question, questionIndex); + break; + case 'text': + renderTextAnswer($questionEl, question, questionIndex); + break; + case 'scale': + renderScale($questionEl, question, questionIndex); + break; + case 'rating': // Обратная совместимость для рейтинга + renderScale($questionEl, { ...question, scaleMax: 5 }, questionIndex); + break; + case 'tag_cloud': + renderTagCloud($questionEl, question, questionIndex); + break; + } + + container.append($questionEl); + }; + + // Отображение вопроса с одиночным выбором + const renderSingleChoice = ($container, question, questionIndex) => { + const $optionsContainer = $('
', { + class: 'radio-options-container' + }); + + $.each(question.options, function(optionIndex, option) { + const $optionContainer = $('
', { + class: 'radio-option' + }); + + const $optionInput = $('', { + type: 'radio', + id: `question_${questionIndex}_option_${optionIndex}`, + name: `question_${questionIndex}`, + value: optionIndex, + required: question.required + }); + + const $optionLabel = $('