From dd589790c2abafa7811ed320fd96002549720076 Mon Sep 17 00:00:00 2001 From: Primakov Alexandr Alexandrovich Date: Wed, 12 Mar 2025 00:09:36 +0300 Subject: [PATCH] =?UTF-8?q?fix=20=D0=BF=D1=83=D1=82=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/questioneer/openapi.yaml | 583 ++++++++++++++ server/routers/questioneer/public/admin.html | 47 +- server/routers/questioneer/public/create.html | 47 +- server/routers/questioneer/public/edit.html | 47 +- server/routers/questioneer/public/index.html | 51 +- server/routers/questioneer/public/poll.html | 45 +- .../questioneer/public/static/css/style.css | 431 ++++++++++- .../questioneer/public/static/js/admin.js | 438 +++++++---- .../questioneer/public/static/js/create.js | 39 +- .../questioneer/public/static/js/edit.js | 44 +- .../questioneer/public/static/js/index.js | 41 +- .../questioneer/public/static/js/poll.js | 713 +++++++++++------- 12 files changed, 1997 insertions(+), 529 deletions(-) create mode 100644 server/routers/questioneer/openapi.yaml 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 index 5928fc2..e692827 100644 --- a/server/routers/questioneer/public/admin.html +++ b/server/routers/questioneer/public/admin.html @@ -4,7 +4,45 @@ Управление опросом - + + + + + + + @@ -59,12 +97,5 @@ - - - - - \ No newline at end of file diff --git a/server/routers/questioneer/public/create.html b/server/routers/questioneer/public/create.html index 61cddc9..ea975c9 100644 --- a/server/routers/questioneer/public/create.html +++ b/server/routers/questioneer/public/create.html @@ -3,8 +3,45 @@ - Создание нового опроса - + Создать опрос + + + + + +
@@ -119,11 +156,5 @@
- - - - \ No newline at end of file diff --git a/server/routers/questioneer/public/edit.html b/server/routers/questioneer/public/edit.html index a67890f..0f9c007 100644 --- a/server/routers/questioneer/public/edit.html +++ b/server/routers/questioneer/public/edit.html @@ -4,7 +4,45 @@ Редактирование опроса - + + + + + + +
@@ -135,12 +173,5 @@
- - - - - \ No newline at end of file diff --git a/server/routers/questioneer/public/index.html b/server/routers/questioneer/public/index.html index ea8b6ef..94feea7 100644 --- a/server/routers/questioneer/public/index.html +++ b/server/routers/questioneer/public/index.html @@ -4,7 +4,50 @@ Анонимные опросы - + + + + + + @@ -32,11 +75,5 @@ - - - - \ No newline at end of file diff --git a/server/routers/questioneer/public/poll.html b/server/routers/questioneer/public/poll.html index 0aa8463..44e70cc 100644 --- a/server/routers/questioneer/public/poll.html +++ b/server/routers/questioneer/public/poll.html @@ -4,7 +4,44 @@ Участие в опросе - + + + + + +
@@ -35,11 +72,5 @@
- - - - \ 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 index 5dc4bcf..c943b91 100644 --- a/server/routers/questioneer/public/static/css/style.css +++ b/server/routers/questioneer/public/static/css/style.css @@ -66,11 +66,12 @@ p { } .btn-primary { - background-color: #4caf50; + background-color: #1976d2; + color: white; } .btn-primary:hover { - background-color: #388e3c; + background-color: #1565c0; } .btn-secondary { @@ -593,13 +594,14 @@ select:focus { justify-content: space-between; align-items: center; margin-top: 20px; - padding-top: 15px; - border-top: 1px solid #444; + margin-bottom: 10px; } -#question-counter { +.question-counter { + text-align: center; font-size: 1rem; - color: #bbdefb; + color: #64b5f6; + flex: 1; } /* Улучшения для облака тегов */ @@ -755,7 +757,7 @@ select:focus { .scale-average .highlight { color: var(--color-primary); font-size: 1.5em; - animation: pulse 2s infinite; + font-weight: bold; } .result-bar-container { @@ -837,15 +839,9 @@ select:focus { } @keyframes pulse { - 0% { - transform: scale(1); - } - 50% { - transform: scale(1.05); - } - 100% { - transform: scale(1); - } + 0% { opacity: 0.8; } + 50% { opacity: 1; } + 100% { opacity: 0.8; } } .question-result { @@ -863,7 +859,7 @@ select:focus { } .question-counter.update { - animation: pulse 0.5s; + animation: fadeIn 0.5s; } @keyframes fadeIn { @@ -908,7 +904,7 @@ select:focus { .loading-text { margin-top: 15px; - animation: pulse 1.5s infinite; + color: #64b5f6; } /* Анимации */ @@ -980,7 +976,8 @@ select:focus { } .btn-primary { - animation: pulse 2s infinite; + background-color: #1976d2; + color: white; } .question-item.error { @@ -1002,7 +999,9 @@ select:focus { } .tag-item.selected { - animation: pulse 1s; + background-color: var(--color-primary); + color: white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); } .scale-item label:hover { @@ -1011,7 +1010,9 @@ select:focus { } .scale-item input:checked + label { - animation: pulse 0.5s; + background-color: var(--color-primary); + color: white; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); } /* Анимированный лоадер */ @@ -1155,10 +1156,15 @@ input[type="checkbox"], input[type="radio"] { .welcome-icon svg, .thank-you-icon svg, .completed-icon svg { - width: 80px; - height: 80px; - color: var(--color-primary, #2196f3); - opacity: 0.8; + 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, @@ -1180,11 +1186,19 @@ input[type="checkbox"], input[type="radio"] { .welcome-start-btn, .view-results-btn { - margin-top: 20px; - font-size: 1.1rem; - padding: 12px 30px; + font-size: 1.2rem; + padding: 12px 24px; border-radius: 30px; - animation: pulse 2s infinite; + 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 { @@ -1207,12 +1221,6 @@ input[type="checkbox"], input[type="radio"] { } } -.welcome-icon svg, -.thank-you-icon svg, -.completed-icon svg { - animation: pulse 3s infinite; -} - /* CSS переменные для цветов */ :root { --color-primary: #2196f3; @@ -1236,9 +1244,9 @@ input[type="checkbox"], input[type="radio"] { display: flex; align-items: center; justify-content: center; - padding: 10px 20px; - border-radius: 30px; - transition: all 0.3s ease; + min-width: 110px; + background-color: #1976d2; + color: white; } .nav-btn svg { @@ -1246,8 +1254,13 @@ input[type="checkbox"], input[type="radio"] { } .nav-btn:hover { - transform: translateY(-3px); - box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2); + background-color: #0d47a1; +} + +.nav-btn:disabled { + background-color: #455a64; + cursor: not-allowed; + opacity: 0.7; } /* Стили для статистики в админке */ @@ -1827,4 +1840,342 @@ input[type="checkbox"], input[type="radio"] { 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 index 2adf316..14f5f0a 100644 --- a/server/routers/questioneer/public/static/js/admin.js +++ b/server/routers/questioneer/public/static/js/admin.js @@ -3,13 +3,27 @@ $(document).ready(function() { const adminLink = window.location.pathname.split('/').pop(); let questionnaireData = null; - // Получаем базовый путь API (для работы и с /questioneer, и с /ms/questioneer) + // Функция для получения базового пути API const getApiPath = () => { - const pathParts = window.location.pathname.split('/'); - // Убираем последние две части пути (admin/:adminLink) - pathParts.pop(); - pathParts.pop(); - return pathParts.join('/') + '/api'; + // Проверяем, содержит ли путь /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 { + // Для локальной разработки: используем обычный путь + // Извлекаем базовый путь из URL страницы + const pathParts = pathname.split('/'); + // Если последний сегмент пустой (из-за /) - удаляем его + if (pathParts[pathParts.length - 1] === '') { + pathParts.pop(); + } + + // Путь до корня приложения + return pathParts.join('/') + '/api'; + } }; // Загрузка данных опроса @@ -40,7 +54,17 @@ $(document).ready(function() { // Формируем ссылки const baseUrl = window.location.origin; - const baseQuestionnairePath = window.location.pathname.split('/admin')[0]; + 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}`; @@ -65,23 +89,32 @@ $(document).ready(function() { // Проверяем наличие ответов для каждого типа вопросов for (const question of questions) { - if (question.type === 'single' || question.type === 'multiple') { - if (question.options && question.options.some(option => option.votes && option.votes > 0)) { + // Согласовываем типы вопросов между бэкендом и фронтендом + 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 (question.type === 'tagcloud') { - if (question.tags && question.tags.some(tag => tag.count && tag.count > 0)) { + } else if (questionType === 'tagcloud') { + if (question.tags && question.tags.some(tag => tag.count > 0)) { hasAnyResponses = true; break; } - } else if (question.type === 'scale' || question.type === 'rating') { - if (question.responses && question.responses.length > 0) { + } 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 (question.type === 'text') { - if (question.textAnswers && question.textAnswers.length > 0) { + } 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; } @@ -99,138 +132,256 @@ $(document).ready(function() { const $questionTitle = $('

', { text: `${index + 1}. ${question.text}` }); $questionStats.append($questionTitle); + // Согласовываем типы вопросов между бэкендом и фронтендом + const questionType = normalizeQuestionType(question.type); + // В зависимости от типа вопроса отображаем разную статистику - if (question.type === 'single' || question.type === 'multiple') { + if (questionType === 'single' || questionType === 'multiple') { // Для вопросов с выбором вариантов - const totalVotes = question.options.reduce((sum, option) => sum + (option.votes || 0), 0); - - if (totalVotes === 0) { - $questionStats.append($('
', { class: 'no-votes', text: 'Нет голосов' })); - } else { - const $table = $('', { class: 'stats-table' }); - const $thead = $('').append( - $('').append( - $(''); - - question.options.forEach(option => { - const votes = option.votes || 0; - 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); - $questionStats.append($table); - $questionStats.append($('
', { class: 'total-votes', text: `Всего голосов: ${totalVotes}` })); - } - } else if (question.type === 'tagcloud') { + renderChoiceStats(question, $questionStats); + } else if (questionType === 'tagcloud') { // Для облака тегов - if (!question.tags || question.tags.length === 0 || !question.tags.some(tag => tag.count > 0)) { - $questionStats.append($('
', { class: 'no-votes', text: 'Нет выбранных тегов' })); - } else { - 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` } - }) - ); - } - }); - - $questionStats.append($tagCloud); - } - } else if (question.type === 'scale' || question.type === 'rating') { + renderTagCloudStats(question, $questionStats); + } else if (questionType === 'scale' || questionType === 'rating') { // Для шкалы и рейтинга - if (!question.responses || question.responses.length === 0) { - $questionStats.append($('
', { class: 'no-votes', text: 'Нет оценок' })); - } else { - const values = question.responses; - 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-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 }) - ) - ); - - $questionStats.append($scaleStats); - } - } else if (question.type === 'text') { + renderScaleStats(question, $questionStats); + } else if (questionType === 'text') { // Для текстовых ответов - if (!question.textAnswers || question.textAnswers.length === 0) { - $questionStats.append($('
', { class: 'no-votes', text: 'Нет текстовых ответов' })); - } else { - const $textAnswers = $('
', { class: 'text-answers-list' }); - - question.textAnswers.forEach((answer, i) => { - $textAnswers.append( - $('
', { class: 'text-answer-item' }).append( - $('
', { class: 'answer-number', text: `#${i + 1}` }), - $('
', { class: 'answer-text', text: answer }) - ) - ); - }); - - $questionStats.append($textAnswers); - } + 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(); @@ -252,7 +403,18 @@ $(document).ready(function() { // Редактирование опроса $('#edit-questionnaire').on('click', function() { - const basePath = window.location.pathname.split('/admin')[0]; + // Перенаправляем на страницу редактирования + 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}`; }); @@ -268,7 +430,7 @@ $(document).ready(function() { // Функция удаления опроса const deleteQuestionnaire = () => { $.ajax({ - url: `${getApiPath()}/questionnaires/admin/${adminLink}`, + url: `${getApiPath()}/questionnaires/${adminLink}`, method: 'DELETE', success: function(result) { if (result.success) { diff --git a/server/routers/questioneer/public/static/js/create.js b/server/routers/questioneer/public/static/js/create.js index 2597e7c..3b19c3b 100644 --- a/server/routers/questioneer/public/static/js/create.js +++ b/server/routers/questioneer/public/static/js/create.js @@ -6,12 +6,27 @@ $(document).ready(function() { let questionCount = 0; - // Получаем базовый путь API (для работы и с /questioneer, и с /ms/questioneer) + // Функция для получения базового пути API const getApiPath = () => { - const pathParts = window.location.pathname.split('/'); - // Убираем последнюю часть пути (create) - pathParts.pop(); - return pathParts.join('/') + '/api'; + // Проверяем, содержит ли путь /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 { + // Для локальной разработки: используем обычный путь + // Извлекаем базовый путь из URL страницы + const pathParts = pathname.split('/'); + // Если последний сегмент пустой (из-за /) - удаляем его + if (pathParts[pathParts.length - 1] === '') { + pathParts.pop(); + } + + // Путь до корня приложения + return pathParts.join('/') + '/api'; + } }; // Добавление нового вопроса @@ -217,8 +232,18 @@ $(document).ready(function() { data: JSON.stringify(questionnaire), success: function(result) { if (result.success) { - // Перенаправление на страницу администрирования опроса - const basePath = window.location.pathname.split('/create')[0]; + // Перенаправляем на страницу администратора + 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}`, 'Ошибка'); diff --git a/server/routers/questioneer/public/static/js/edit.js b/server/routers/questioneer/public/static/js/edit.js index 90120f6..7f41098 100644 --- a/server/routers/questioneer/public/static/js/edit.js +++ b/server/routers/questioneer/public/static/js/edit.js @@ -8,13 +8,27 @@ $(document).ready(function() { let questionCount = 0; let questionnaireData = null; - // Получаем базовый путь API + // Функция для получения базового пути API const getApiPath = () => { - const pathParts = window.location.pathname.split('/'); - // Убираем последние две части пути (edit/:adminLink) - pathParts.pop(); - pathParts.pop(); - return pathParts.join('/') + '/api'; + // Проверяем, содержит ли путь /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 { + // Для локальной разработки: используем обычный путь + // Извлекаем базовый путь из URL страницы + const pathParts = pathname.split('/'); + // Если последний сегмент пустой (из-за /) - удаляем его + if (pathParts[pathParts.length - 1] === '') { + pathParts.pop(); + } + + // Путь до корня приложения + return pathParts.join('/') + '/api'; + } }; // Загрузка данных опроса @@ -312,10 +326,20 @@ $(document).ready(function() { data: JSON.stringify(questionnaire), success: function(result) { if (result.success) { - showAlert('Опрос успешно обновлен', 'Успешно', function() { - const basePath = window.location.pathname.split('/edit')[0]; - window.location.href = `${basePath}/admin/${adminLink}`; - }); + 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}`, 'Ошибка'); } diff --git a/server/routers/questioneer/public/static/js/index.js b/server/routers/questioneer/public/static/js/index.js index 68f970d..243fe0a 100644 --- a/server/routers/questioneer/public/static/js/index.js +++ b/server/routers/questioneer/public/static/js/index.js @@ -2,15 +2,25 @@ $(document).ready(function() { // Функция для получения базового пути API const getApiPath = () => { - // Извлекаем базовый путь из URL страницы - const pathParts = window.location.pathname.split('/'); - // Если последний сегмент пустой (из-за /) - удаляем его - if (pathParts[pathParts.length - 1] === '') { - pathParts.pop(); - } + // Проверяем, содержит ли путь /ms/ (продакшн на dev.bro-js.ru) + const pathname = window.location.pathname; + const isMsPath = pathname.includes('/ms/questioneer'); - // Путь до корня приложения - return pathParts.join('/') + '/api'; + if (isMsPath) { + // Для продакшна: если в пути есть /ms/, то API доступно по /ms/questioneer/api + return '/ms/questioneer/api'; + } else { + // Для локальной разработки: используем обычный путь + // Извлекаем базовый путь из URL страницы + const pathParts = pathname.split('/'); + // Если последний сегмент пустой (из-за /) - удаляем его + if (pathParts[pathParts.length - 1] === '') { + pathParts.pop(); + } + + // Путь до корня приложения + return pathParts.join('/') + '/api'; + } }; // Функция для загрузки списка опросов @@ -40,9 +50,18 @@ $(document).ready(function() { } // Получаем базовый путь (для работы и с /questioneer, и с /ms/questioneer) - const basePath = window.location.pathname.endsWith('/') - ? window.location.pathname - : window.location.pathname + '/'; + 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 => `
diff --git a/server/routers/questioneer/public/static/js/poll.js b/server/routers/questioneer/public/static/js/poll.js index d7e78f1..8f64a65 100644 --- a/server/routers/questioneer/public/static/js/poll.js +++ b/server/routers/questioneer/public/static/js/poll.js @@ -23,6 +23,9 @@ $(document).ready(function() { // Для пошаговых опросов let currentQuestionIndex = 0; + // Объявляем переменную для хранения накопленных ответов + let accumulatedAnswers = []; + // Проверка доступности localStorage const isLocalStorageAvailable = () => { try { @@ -50,13 +53,33 @@ $(document).ready(function() { window.localStorage.setItem(getLocalStorageKey(), 'true'); }; - // Получаем базовый путь API + // Функция для получения базового пути API const getApiPath = () => { - const pathParts = window.location.pathname.split('/'); - // Убираем последние две части пути (poll/:publicLink) - pathParts.pop(); - pathParts.pop(); - return pathParts.join('/') + '/api'; + // Проверяем, содержит ли путь /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 { + // Для локальной разработки: используем обычный путь + // Извлекаем базовый путь из URL страницы + const pathParts = pathname.split('/'); + // Убираем slug и последний сегмент (poll/[id]) + if (pathParts.length > 2) { + // Удаляем 2 последних сегмента пути (poll/[id]) + pathParts.pop(); + pathParts.pop(); + } + // Если последний сегмент пустой (из-за /) - удаляем его + if (pathParts[pathParts.length - 1] === '') { + pathParts.pop(); + } + + // Путь до корня приложения + return pathParts.join('/') + '/api'; + } }; // Загрузка данных опроса @@ -231,7 +254,7 @@ $(document).ready(function() { Назад - Вопрос 1 из ${questionnaireData.questions.length} + Вопрос 1 из ${questionnaireData.questions.length}