Merge branch 'master' of ssh://85.143.175.152:222/bro-students/multy-stub
This commit is contained in:
commit
9d10c8501a
@ -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"))
|
||||
|
||||
|
60
server/models/questionnaire.js
Normal file
60
server/models/questionnaire.js
Normal file
@ -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
|
||||
};
|
421
server/routers/questioneer/index.js
Normal file
421
server/routers/questioneer/index.js
Normal file
@ -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
|
583
server/routers/questioneer/openapi.yaml
Normal file
583
server/routers/questioneer/openapi.yaml
Normal file
@ -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: Сообщение об ошибке
|
117
server/routers/questioneer/public/admin.html
Normal file
117
server/routers/questioneer/public/admin.html
Normal file
@ -0,0 +1,117 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Управление опросом</title>
|
||||
<!-- Добавляем проверку на различные пути -->
|
||||
<script>
|
||||
// Определяем путь к статическим файлам с учетом prod и dev окружений
|
||||
function getStaticPath() {
|
||||
if (window.location.pathname.includes('/ms/questioneer')) {
|
||||
// Для продакшна
|
||||
return '/ms/questioneer/static';
|
||||
} else {
|
||||
// Для локальной разработки
|
||||
const basePath = window.location.pathname.split('/admin')[0];
|
||||
// Проверяем, заканчивается ли путь на слеш
|
||||
return basePath + (basePath.endsWith('/') ? 'static' : '/static');
|
||||
}
|
||||
}
|
||||
|
||||
// Динамически добавляем CSS
|
||||
const cssLink = document.createElement('link');
|
||||
cssLink.rel = 'stylesheet';
|
||||
cssLink.href = getStaticPath() + '/css/style.css';
|
||||
document.head.appendChild(cssLink);
|
||||
</script>
|
||||
|
||||
<!-- Добавляем jQuery -->
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcode-generator/1.4.4/qrcode.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Динамически добавляем скрипты
|
||||
const scriptPaths = [
|
||||
'/js/common.js',
|
||||
'/js/admin.js'
|
||||
];
|
||||
|
||||
const staticPath = getStaticPath();
|
||||
scriptPaths.forEach(path => {
|
||||
const script = document.createElement('script');
|
||||
script.src = staticPath + path;
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Навигационная шапка -->
|
||||
<header class="nav-header">
|
||||
<div class="nav-container">
|
||||
<a href="javascript:;" id="nav-home-link" class="nav-logo">Анонимные опросы</a>
|
||||
<nav class="nav-menu">
|
||||
<a href="javascript:;" id="nav-main-link" class="nav-link">Главная</a>
|
||||
<a href="javascript:;" id="nav-create-link" class="nav-link">Создать опрос</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<h1>Управление опросом</h1>
|
||||
|
||||
<div id="loading">Загрузка опроса...</div>
|
||||
|
||||
<div id="questionnaire-container" style="display: none;">
|
||||
<div class="questionnaire-header">
|
||||
<h2 id="questionnaire-title"></h2>
|
||||
<p id="questionnaire-description"></p>
|
||||
</div>
|
||||
|
||||
<div class="questionnaire-links">
|
||||
<div class="link-group">
|
||||
<h3>Ссылка для голосования:</h3>
|
||||
<div class="link-input-group">
|
||||
<input type="text" id="public-link" readonly>
|
||||
<button class="btn btn-small" id="copy-public-link">Копировать</button>
|
||||
<button class="btn btn-small" id="show-qr-code">QR-код</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="link-group">
|
||||
<h3>Административная ссылка:</h3>
|
||||
<div class="link-input-group">
|
||||
<input type="text" id="admin-link" readonly>
|
||||
<button class="btn btn-small" id="copy-admin-link">Копировать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="questionnaire-stats">
|
||||
<h3>Статистика ответов</h3>
|
||||
<div id="stats-container"></div>
|
||||
</div>
|
||||
|
||||
<div class="questionnaire-actions">
|
||||
<button id="edit-questionnaire" class="btn">Редактировать опрос</button>
|
||||
<button id="delete-questionnaire" class="btn btn-danger">Удалить опрос</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Добавляем корректные пути к ссылкам после загрузки страницы
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Определяем базовый путь с учетом /ms в продакшен-версии
|
||||
const isMsPath = window.location.pathname.includes('/ms/questioneer');
|
||||
const basePath = isMsPath ? '/ms/questioneer' : '/questioneer';
|
||||
|
||||
// Устанавливаем правильные ссылки
|
||||
document.getElementById('nav-home-link').href = basePath;
|
||||
document.getElementById('nav-main-link').href = basePath;
|
||||
document.getElementById('nav-create-link').href = basePath + '/create';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
187
server/routers/questioneer/public/create.html
Normal file
187
server/routers/questioneer/public/create.html
Normal file
@ -0,0 +1,187 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Создать опрос</title>
|
||||
<!-- Добавляем проверку на различные пути -->
|
||||
<script>
|
||||
// Определяем путь к статическим файлам с учетом prod и dev окружений
|
||||
function getStaticPath() {
|
||||
if (window.location.pathname.includes('/ms/questioneer')) {
|
||||
// Для продакшна
|
||||
return '/ms/questioneer/static';
|
||||
} else {
|
||||
// Для локальной разработки
|
||||
const basePath = window.location.pathname.split('/create')[0];
|
||||
// Проверяем, заканчивается ли путь на слеш
|
||||
return basePath + (basePath.endsWith('/') ? 'static' : '/static');
|
||||
}
|
||||
}
|
||||
|
||||
// Динамически добавляем CSS
|
||||
const cssLink = document.createElement('link');
|
||||
cssLink.rel = 'stylesheet';
|
||||
cssLink.href = getStaticPath() + '/css/style.css';
|
||||
document.head.appendChild(cssLink);
|
||||
</script>
|
||||
|
||||
<!-- Добавляем jQuery и остальные скрипты с учетом переменного пути -->
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Динамически добавляем скрипты
|
||||
const scriptPaths = [
|
||||
'/js/common.js',
|
||||
'/js/create.js'
|
||||
];
|
||||
|
||||
const staticPath = getStaticPath();
|
||||
scriptPaths.forEach(path => {
|
||||
const script = document.createElement('script');
|
||||
script.src = staticPath + path;
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Навигационная шапка -->
|
||||
<header class="nav-header">
|
||||
<div class="nav-container">
|
||||
<a href="javascript:;" id="nav-home-link" class="nav-logo">Анонимные опросы</a>
|
||||
<nav class="nav-menu">
|
||||
<a href="javascript:;" id="nav-main-link" class="nav-link">Главная</a>
|
||||
<a href="javascript:;" id="nav-create-link" class="nav-link active">Создать опрос</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<h1>Создание нового опроса</h1>
|
||||
|
||||
<div class="form-container">
|
||||
<form id="create-questionnaire-form">
|
||||
<div class="form-group">
|
||||
<label for="title">Название опроса *</label>
|
||||
<input type="text" id="title" name="title" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Описание опроса</label>
|
||||
<textarea id="description" name="description"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="display: none;">
|
||||
<label for="display-type">Тип отображения</label>
|
||||
<select id="display-type" name="display-type">
|
||||
<option value="step_by_step">Пошаговый</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="questions-container">
|
||||
<h2>Вопросы</h2>
|
||||
<div id="questions-list"></div>
|
||||
|
||||
<button type="button" id="add-question" class="btn btn-small">Добавить вопрос</button>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<a href="/questioneer" class="btn btn-secondary">Отмена</a>
|
||||
<button type="submit" class="btn btn-primary">Создать опрос</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Шаблон для вопроса -->
|
||||
<template id="question-template">
|
||||
<div class="question-item" data-index="{{index}}">
|
||||
<div class="question-header">
|
||||
<h3>Вопрос {{number}}</h3>
|
||||
<button type="button" class="btn-icon delete-question">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5Zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5Zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6Z"/>
|
||||
<path d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1ZM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118ZM2.5 3h11V2h-11v1Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="question-text-{{index}}">Текст вопроса *</label>
|
||||
<input type="text" id="question-text-{{index}}" class="question-text" name="questions[{{index}}][text]" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="question-type-{{index}}">Тип вопроса *</label>
|
||||
<select id="question-type-{{index}}" class="question-type-select" name="questions[{{index}}][type]" required>
|
||||
<option value="single_choice">Одиночный выбор</option>
|
||||
<option value="multiple_choice">Множественный выбор</option>
|
||||
<option value="text">Текстовый ответ</option>
|
||||
<option value="scale">Шкала оценки</option>
|
||||
<option value="tag_cloud">Облако тегов</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" name="questions[{{index}}][required]" value="true">
|
||||
Обязательный вопрос
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="options-container" id="options-container-{{index}}">
|
||||
<h4>Варианты ответа</h4>
|
||||
<div class="options-list" id="options-list-{{index}}"></div>
|
||||
<button type="button" class="btn btn-small add-option" data-question-index="{{index}}">Добавить вариант</button>
|
||||
</div>
|
||||
|
||||
<div class="scale-container" id="scale-container-{{index}}" style="display: none;">
|
||||
<h4>Настройки шкалы</h4>
|
||||
<div class="form-group">
|
||||
<label for="scale-min-{{index}}">Минимальное значение</label>
|
||||
<input type="number" id="scale-min-{{index}}" class="scale-min" name="questions[{{index}}][scaleMin]" value="0" min="0">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="scale-max-{{index}}">Максимальное значение</label>
|
||||
<input type="number" id="scale-max-{{index}}" class="scale-max" name="questions[{{index}}][scaleMax]" value="10" min="1" max="20">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="scale-min-label-{{index}}">Подпись минимального значения</label>
|
||||
<input type="text" id="scale-min-label-{{index}}" class="scale-min-label" name="questions[{{index}}][scaleMinLabel]" value="Минимум">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="scale-max-label-{{index}}">Подпись максимального значения</label>
|
||||
<input type="text" id="scale-max-label-{{index}}" class="scale-max-label" name="questions[{{index}}][scaleMaxLabel]" value="Максимум">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Шаблон для варианта ответа -->
|
||||
<template id="option-template">
|
||||
<div class="option-item" data-index="{{optionIndex}}">
|
||||
<input type="text" name="questions[{{questionIndex}}][options][{{optionIndex}}][text]" placeholder="Вариант ответа">
|
||||
<button type="button" class="btn-icon delete-option">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// Добавляем корректные пути к ссылкам после загрузки страницы
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Определяем базовый путь с учетом /ms в продакшен-версии
|
||||
const isMsPath = window.location.pathname.includes('/ms/questioneer');
|
||||
const basePath = isMsPath ? '/ms/questioneer' : '/questioneer';
|
||||
|
||||
// Устанавливаем правильные ссылки
|
||||
document.getElementById('nav-home-link').href = basePath;
|
||||
document.getElementById('nav-main-link').href = basePath;
|
||||
document.getElementById('nav-create-link').href = basePath + '/create';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
204
server/routers/questioneer/public/edit.html
Normal file
204
server/routers/questioneer/public/edit.html
Normal file
@ -0,0 +1,204 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Редактирование опроса</title>
|
||||
<!-- Добавляем проверку на различные пути -->
|
||||
<script>
|
||||
// Определяем путь к статическим файлам с учетом prod и dev окружений
|
||||
function getStaticPath() {
|
||||
if (window.location.pathname.includes('/ms/questioneer')) {
|
||||
// Для продакшна
|
||||
return '/ms/questioneer/static';
|
||||
} else {
|
||||
// Для локальной разработки
|
||||
const basePath = window.location.pathname.split('/edit')[0];
|
||||
// Проверяем, заканчивается ли путь на слеш
|
||||
return basePath + (basePath.endsWith('/') ? 'static' : '/static');
|
||||
}
|
||||
}
|
||||
|
||||
// Динамически добавляем CSS
|
||||
const cssLink = document.createElement('link');
|
||||
cssLink.rel = 'stylesheet';
|
||||
cssLink.href = getStaticPath() + '/css/style.css';
|
||||
document.head.appendChild(cssLink);
|
||||
</script>
|
||||
|
||||
<!-- Добавляем jQuery -->
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcode-generator/1.4.4/qrcode.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Динамически добавляем скрипты
|
||||
const scriptPaths = [
|
||||
'/js/common.js',
|
||||
'/js/edit.js'
|
||||
];
|
||||
|
||||
const staticPath = getStaticPath();
|
||||
scriptPaths.forEach(path => {
|
||||
const script = document.createElement('script');
|
||||
script.src = staticPath + path;
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Навигационная шапка -->
|
||||
<header class="nav-header">
|
||||
<div class="nav-container">
|
||||
<a href="javascript:;" id="nav-home-link" class="nav-logo">Анонимные опросы</a>
|
||||
<nav class="nav-menu">
|
||||
<a href="javascript:;" id="nav-main-link" class="nav-link">Главная</a>
|
||||
<a href="javascript:;" id="nav-create-link" class="nav-link">Создать опрос</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<h1>Редактирование опроса</h1>
|
||||
|
||||
<div id="loading">Загрузка опроса...</div>
|
||||
|
||||
<div class="form-container" id="edit-form-container" style="display: none;">
|
||||
<form id="edit-questionnaire-form">
|
||||
<div class="form-group">
|
||||
<label for="title">Название опроса:</label>
|
||||
<input type="text" id="title" name="title" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Описание:</label>
|
||||
<textarea id="description" name="description" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="display-type">Тип отображения:</label>
|
||||
<select id="display-type" name="displayType">
|
||||
<option value="default">Обычный</option>
|
||||
<option value="tag_cloud">Облако тегов</option>
|
||||
<option value="voting">Голосование</option>
|
||||
<option value="poll">Опрос</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="questions-container">
|
||||
<h2>Вопросы</h2>
|
||||
<div id="questions-list"></div>
|
||||
|
||||
<button type="button" id="add-question" class="btn btn-small">Добавить вопрос</button>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<div class="link-group">
|
||||
<h3>Ссылки:</h3>
|
||||
<div class="link-input-group">
|
||||
<div>
|
||||
<label for="public-link">Ссылка для голосования:</label>
|
||||
<input type="text" id="public-link" readonly>
|
||||
<button type="button" class="btn btn-small" id="copy-public-link">Копировать</button>
|
||||
<button type="button" class="btn btn-small" id="show-qr-code">QR-код</button>
|
||||
</div>
|
||||
<div>
|
||||
<label for="admin-link">Административная ссылка:</label>
|
||||
<input type="text" id="admin-link" readonly>
|
||||
<button type="button" class="btn btn-small" id="copy-admin-link">Копировать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-buttons">
|
||||
<a href="#" id="back-to-admin" class="btn btn-secondary">Вернуться</a>
|
||||
<button type="submit" class="btn btn-primary">Сохранить изменения</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Шаблон для вопроса -->
|
||||
<template id="question-template">
|
||||
<div class="question-item" data-index="{{index}}">
|
||||
<div class="question-header">
|
||||
<h3>Вопрос {{number}}</h3>
|
||||
<button type="button" class="btn-icon delete-question">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="question-text-{{index}}">Текст вопроса:</label>
|
||||
<input type="text" id="question-text-{{index}}" name="questions[{{index}}][text]" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="question-type-{{index}}">Тип вопроса:</label>
|
||||
<select id="question-type-{{index}}" name="questions[{{index}}][type]" class="question-type-select">
|
||||
<option value="single_choice">Один вариант</option>
|
||||
<option value="multiple_choice">Несколько вариантов</option>
|
||||
<option value="text">Текстовый ответ</option>
|
||||
<option value="rating">Оценка</option>
|
||||
<option value="scale">Шкала оценки</option>
|
||||
<option value="tag_cloud">Облако тегов</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" name="questions[{{index}}][required]" value="true">
|
||||
Обязательный вопрос
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="options-container" id="options-container-{{index}}">
|
||||
<h4>Варианты ответа</h4>
|
||||
<div class="options-list" id="options-list-{{index}}"></div>
|
||||
<button type="button" class="btn btn-small add-option" data-question-index="{{index}}">Добавить вариант</button>
|
||||
</div>
|
||||
|
||||
<div class="scale-container" id="scale-container-{{index}}" style="display: none;">
|
||||
<h4>Настройки шкалы</h4>
|
||||
<div class="form-group">
|
||||
<label for="scale-max-{{index}}">Максимальное значение:</label>
|
||||
<select id="scale-max-{{index}}" name="questions[{{index}}][scaleMax]">
|
||||
<option value="5">5</option>
|
||||
<option value="10" selected>10</option>
|
||||
<option value="20">20</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Шаблон для варианта ответа -->
|
||||
<template id="option-template">
|
||||
<div class="option-item" data-index="{{optionIndex}}">
|
||||
<input type="text" name="questions[{{questionIndex}}][options][{{optionIndex}}][text]" placeholder="Вариант ответа">
|
||||
<button type="button" class="btn-icon delete-option">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M4.646 4.646a.5.5 0 0 1 .708 0L8 7.293l2.646-2.647a.5.5 0 0 1 .708.708L8.707 8l2.647 2.646a.5.5 0 0 1-.708.708L8 8.707l-2.646 2.647a.5.5 0 0 1-.708-.708L7.293 8 4.646 5.354a.5.5 0 0 1 0-.708z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// Добавляем корректные пути к ссылкам после загрузки страницы
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Определяем базовый путь с учетом /ms в продакшен-версии
|
||||
const isMsPath = window.location.pathname.includes('/ms/questioneer');
|
||||
const basePath = isMsPath ? '/ms/questioneer' : '/questioneer';
|
||||
|
||||
// Устанавливаем правильные ссылки
|
||||
document.getElementById('nav-home-link').href = basePath;
|
||||
document.getElementById('nav-main-link').href = basePath;
|
||||
document.getElementById('nav-create-link').href = basePath + '/create';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
94
server/routers/questioneer/public/index.html
Normal file
94
server/routers/questioneer/public/index.html
Normal file
@ -0,0 +1,94 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Анонимные опросы</title>
|
||||
<!-- Добавляем проверку на различные пути -->
|
||||
<script>
|
||||
// Определяем путь к статическим файлам с учетом prod и dev окружений
|
||||
function getStaticPath() {
|
||||
const pathname = window.location.pathname;
|
||||
if (pathname.includes('/ms/questioneer')) {
|
||||
// Для продакшна
|
||||
return '/ms/questioneer/static';
|
||||
} else {
|
||||
// Для локальной разработки
|
||||
// Если путь заканчивается на слеш или на /questioneer, добавляем /static
|
||||
if (pathname.endsWith('/') || pathname.endsWith('/questioneer')) {
|
||||
return pathname + '/static';
|
||||
} else {
|
||||
return pathname + '/static';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Динамически добавляем CSS
|
||||
const cssLink = document.createElement('link');
|
||||
cssLink.rel = 'stylesheet';
|
||||
cssLink.href = getStaticPath() + '/css/style.css';
|
||||
document.head.appendChild(cssLink);
|
||||
</script>
|
||||
|
||||
<!-- Добавляем jQuery -->
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Динамически добавляем скрипты
|
||||
const scriptPaths = [
|
||||
'/js/common.js',
|
||||
'/js/index.js'
|
||||
];
|
||||
|
||||
const staticPath = getStaticPath();
|
||||
scriptPaths.forEach(path => {
|
||||
const script = document.createElement('script');
|
||||
script.src = staticPath + path;
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Навигационная шапка -->
|
||||
<header class="nav-header">
|
||||
<div class="nav-container">
|
||||
<a href="javascript:;" id="nav-home-link" class="nav-logo">Анонимные опросы</a>
|
||||
<nav class="nav-menu">
|
||||
<a href="javascript:;" id="nav-main-link" class="nav-link active">Главная</a>
|
||||
<a href="javascript:;" id="nav-create-link" class="nav-link">Создать опрос</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<h1>Сервис анонимных опросов</h1>
|
||||
|
||||
<div class="main-buttons">
|
||||
<a href="javascript:;" id="create-button" class="btn">Создать новый опрос</a>
|
||||
</div>
|
||||
|
||||
<div class="questionnaires-list">
|
||||
<h2>Ваши опросы</h2>
|
||||
<div id="questionnaires-container">
|
||||
<p>Загрузка опросов...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Добавляем корректные пути к ссылкам после загрузки страницы
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Определяем базовый путь с учетом /ms в продакшен-версии
|
||||
const isMsPath = window.location.pathname.includes('/ms/questioneer');
|
||||
const basePath = isMsPath ? '/ms/questioneer' : '/questioneer';
|
||||
|
||||
// Устанавливаем правильные ссылки
|
||||
document.getElementById('nav-home-link').href = basePath;
|
||||
document.getElementById('nav-main-link').href = basePath;
|
||||
document.getElementById('nav-create-link').href = basePath + '/create';
|
||||
document.getElementById('create-button').href = basePath + '/create';
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
97
server/routers/questioneer/public/poll.html
Normal file
97
server/routers/questioneer/public/poll.html
Normal file
@ -0,0 +1,97 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Участие в опросе</title>
|
||||
<!-- Добавляем проверку на различные пути -->
|
||||
<script>
|
||||
// Определяем путь к статическим файлам с учетом prod и dev окружений
|
||||
function getStaticPath() {
|
||||
if (window.location.pathname.includes('/ms/questioneer')) {
|
||||
// Для продакшна
|
||||
return '/ms/questioneer/static';
|
||||
} else {
|
||||
// Для локальной разработки
|
||||
const basePath = window.location.pathname.split('/poll')[0];
|
||||
// Проверяем, заканчивается ли путь на слеш
|
||||
return basePath + (basePath.endsWith('/') ? 'static' : '/static');
|
||||
}
|
||||
}
|
||||
|
||||
// Динамически добавляем CSS
|
||||
const cssLink = document.createElement('link');
|
||||
cssLink.rel = 'stylesheet';
|
||||
cssLink.href = getStaticPath() + '/css/style.css';
|
||||
document.head.appendChild(cssLink);
|
||||
</script>
|
||||
|
||||
<!-- Добавляем jQuery -->
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Динамически добавляем скрипты
|
||||
const scriptPaths = [
|
||||
'/js/common.js',
|
||||
'/js/poll.js'
|
||||
];
|
||||
|
||||
const staticPath = getStaticPath();
|
||||
scriptPaths.forEach(path => {
|
||||
const script = document.createElement('script');
|
||||
script.src = staticPath + path;
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Навигационная шапка -->
|
||||
<header class="nav-header">
|
||||
<div class="nav-container">
|
||||
<a href="javascript:;" id="nav-home-link" class="nav-logo">Анонимные опросы</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<div id="loading">Загрузка опроса...</div>
|
||||
|
||||
<div id="questionnaire-container" style="display: none;">
|
||||
<div class="questionnaire-header">
|
||||
<h1 id="questionnaire-title"></h1>
|
||||
<p id="questionnaire-description"></p>
|
||||
</div>
|
||||
|
||||
<form id="poll-form">
|
||||
<div id="questions-container"></div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Отправить ответы</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="results-container" style="display: none;">
|
||||
<h2>Спасибо за участие!</h2>
|
||||
<p>Ваши ответы были успешно отправлены.</p>
|
||||
|
||||
<div class="poll-results">
|
||||
<h3>Текущие результаты:</h3>
|
||||
<div id="poll-results-container"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Добавляем корректные пути к ссылкам после загрузки страницы
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Определяем базовый путь с учетом /ms в продакшен-версии
|
||||
const isMsPath = window.location.pathname.includes('/ms/questioneer');
|
||||
const basePath = isMsPath ? '/ms/questioneer' : '/questioneer';
|
||||
|
||||
// Устанавливаем правильные ссылки
|
||||
document.getElementById('nav-home-link').href = basePath;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
2181
server/routers/questioneer/public/static/css/style.css
Normal file
2181
server/routers/questioneer/public/static/css/style.css
Normal file
File diff suppressed because it is too large
Load Diff
465
server/routers/questioneer/public/static/js/admin.js
Normal file
465
server/routers/questioneer/public/static/js/admin.js
Normal file
@ -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('<div class="no-stats">Пока нет ответов на опрос</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
// Для каждого вопроса создаем блок статистики
|
||||
questions.forEach((question, index) => {
|
||||
const $questionStats = $('<div>', { class: 'question-stats' });
|
||||
const $questionTitle = $('<h3>', { 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($('<div>', { class: 'no-votes', text: 'Нет голосов' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const $table = $('<table>', { class: 'stats-table' });
|
||||
const $thead = $('<thead>').append(
|
||||
$('<tr>').append(
|
||||
$('<th>', { text: 'Вариант' }),
|
||||
$('<th>', { text: 'Голоса' }),
|
||||
$('<th>', { text: '%' }),
|
||||
$('<th>', { text: 'Визуализация' })
|
||||
)
|
||||
);
|
||||
|
||||
const $tbody = $('<tbody>');
|
||||
|
||||
options.forEach(option => {
|
||||
const votes = option.votes;
|
||||
const percent = totalVotes > 0 ? Math.round((votes / totalVotes) * 100) : 0;
|
||||
|
||||
const $tr = $('<tr>').append(
|
||||
$('<td>', { text: option.text }),
|
||||
$('<td>', { text: votes }),
|
||||
$('<td>', { text: `${percent}%` }),
|
||||
$('<td>').append(
|
||||
$('<div>', { class: 'bar-container' }).append(
|
||||
$('<div>', {
|
||||
class: 'bar',
|
||||
css: { width: `${percent}%` }
|
||||
})
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
$tbody.append($tr);
|
||||
});
|
||||
|
||||
$table.append($thead, $tbody);
|
||||
$container.append($table);
|
||||
$container.append($('<div>', { 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($('<div>', { class: 'no-votes', text: 'Нет выбранных тегов' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const $tagCloud = $('<div>', { 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(
|
||||
$('<span>', {
|
||||
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($('<div>', { 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 = $('<div>', { class: 'scale-stats' });
|
||||
|
||||
// Добавляем сводную статистику
|
||||
$scaleStats.append(
|
||||
$('<div>', { class: 'stat-summary' }).append(
|
||||
$('<div>', { class: 'stat-item' }).append(
|
||||
$('<span>', { class: 'stat-label', text: 'Среднее значение:' }),
|
||||
$('<span>', { class: 'stat-value', text: avg.toFixed(1) })
|
||||
),
|
||||
$('<div>', { class: 'stat-item' }).append(
|
||||
$('<span>', { class: 'stat-label', text: 'Минимум:' }),
|
||||
$('<span>', { class: 'stat-value', text: min })
|
||||
),
|
||||
$('<div>', { class: 'stat-item' }).append(
|
||||
$('<span>', { class: 'stat-label', text: 'Максимум:' }),
|
||||
$('<span>', { class: 'stat-value', text: max })
|
||||
),
|
||||
$('<div>', { class: 'stat-item' }).append(
|
||||
$('<span>', { class: 'stat-label', text: 'Количество оценок:' }),
|
||||
$('<span>', { class: 'stat-value', text: values.length })
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// Создаем таблицу для визуализации распределения голосов
|
||||
const $table = $('<table>', { class: 'stats-table' });
|
||||
const $thead = $('<thead>').append(
|
||||
$('<tr>').append(
|
||||
$('<th>', { text: 'Значение' }),
|
||||
$('<th>', { text: 'Голоса' }),
|
||||
$('<th>', { text: '%' }),
|
||||
$('<th>', { text: 'Визуализация' })
|
||||
)
|
||||
);
|
||||
|
||||
const $tbody = $('<tbody>');
|
||||
|
||||
// Определяем минимальное и максимальное значение шкалы из самих данных
|
||||
// либо используем значения из настроек вопроса, если они есть
|
||||
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 = $('<tr>').append(
|
||||
$('<td>', { text: value }),
|
||||
$('<td>', { text: count }),
|
||||
$('<td>', { text: `${percent}%` }),
|
||||
$('<td>').append(
|
||||
$('<div>', { class: 'bar-container' }).append(
|
||||
$('<div>', {
|
||||
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($('<div>', { class: 'no-votes', text: 'Нет текстовых ответов' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const $textAnswers = $('<div>', { class: 'text-answers-list' });
|
||||
|
||||
answers.forEach((answer, i) => {
|
||||
$textAnswers.append(
|
||||
$('<div>', { class: 'text-answer-item' }).append(
|
||||
$('<div>', { class: 'answer-number', text: `#${i + 1}` }),
|
||||
$('<div>', { 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);
|
||||
});
|
236
server/routers/questioneer/public/static/js/common.js
Normal file
236
server/routers/questioneer/public/static/js/common.js
Normal file
@ -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 = $('<div>', { class: 'modal-overlay' });
|
||||
const $modal = $('<div>', { 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 = $('<div>', { class: 'modal-header' });
|
||||
const $modalTitle = $('<h3>', { text: settings.title });
|
||||
const $modalClose = $('<button>', {
|
||||
class: 'modal-close',
|
||||
html: '×',
|
||||
click: function() {
|
||||
closeModal();
|
||||
if (typeof settings.onClose === 'function') {
|
||||
settings.onClose();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$modalHeader.append($modalTitle, $modalClose);
|
||||
|
||||
// Создаем тело
|
||||
const $modalBody = $('<div>', { class: 'modal-body' });
|
||||
if (typeof settings.content === 'string') {
|
||||
$modalBody.html(settings.content);
|
||||
} else {
|
||||
$modalBody.append(settings.content);
|
||||
}
|
||||
|
||||
// Создаем футер
|
||||
const $modalFooter = $('<div>', { class: 'modal-footer' });
|
||||
|
||||
// Если нужно показать кнопку отмены
|
||||
if (settings.showCancel) {
|
||||
const $cancelButton = $('<button>', {
|
||||
class: 'btn btn-secondary',
|
||||
text: settings.cancelText,
|
||||
click: function() {
|
||||
closeModal();
|
||||
if (typeof settings.onCancel === 'function') {
|
||||
settings.onCancel();
|
||||
}
|
||||
}
|
||||
});
|
||||
$modalFooter.append($cancelButton);
|
||||
}
|
||||
|
||||
// Кнопка подтверждения/закрытия
|
||||
const $confirmButton = $('<button>', {
|
||||
class: settings.showCancel ? 'btn btn-primary' : 'btn',
|
||||
text: settings.showCancel ? settings.confirmText : settings.closeText,
|
||||
click: function() {
|
||||
closeModal();
|
||||
if (settings.showCancel && typeof settings.onConfirm === 'function') {
|
||||
settings.onConfirm();
|
||||
} else if (!settings.showCancel && typeof settings.onClose === 'function') {
|
||||
settings.onClose();
|
||||
}
|
||||
}
|
||||
});
|
||||
$modalFooter.append($confirmButton);
|
||||
|
||||
// Добавляем прогресс-бар, если включено автоматическое закрытие
|
||||
if (settings.autoClose) {
|
||||
const $progressBar = $('<div>', { class: 'modal-progress' });
|
||||
$modal.append($progressBar);
|
||||
}
|
||||
|
||||
// Собираем модальное окно
|
||||
$modal.append($modalHeader, $modalBody, $modalFooter);
|
||||
$modalOverlay.append($modal);
|
||||
|
||||
// Добавляем модальное окно в DOM
|
||||
$('body').append($modalOverlay);
|
||||
|
||||
// Закрытие по клику на фоне
|
||||
$modalOverlay.on('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeModal();
|
||||
if (typeof settings.onClose === 'function') {
|
||||
settings.onClose();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Функция закрытия модального окна
|
||||
function closeModal() {
|
||||
$modalOverlay.removeClass('active');
|
||||
setTimeout(function() {
|
||||
$modalOverlay.remove();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// Активируем модальное окно
|
||||
setTimeout(function() {
|
||||
$modalOverlay.addClass('active');
|
||||
|
||||
// Активируем прогресс-бар и запускаем таймер закрытия, если включено автоматическое закрытие
|
||||
if (settings.autoClose) {
|
||||
const $progressBar = $modal.find('.modal-progress');
|
||||
|
||||
setTimeout(() => {
|
||||
$progressBar.addClass('active');
|
||||
}, 50);
|
||||
|
||||
setTimeout(() => {
|
||||
closeModal();
|
||||
if (typeof settings.onClose === 'function') {
|
||||
settings.onClose();
|
||||
}
|
||||
}, settings.autoCloseTime);
|
||||
}
|
||||
}, 10);
|
||||
|
||||
// Возвращаем объект модального окна
|
||||
return {
|
||||
$modal: $modal,
|
||||
$overlay: $modalOverlay,
|
||||
close: closeModal
|
||||
};
|
||||
}
|
||||
|
||||
// Функция для отображения модального окна с сообщением (замена alert)
|
||||
function showAlert(message, title, callback, autoClose = false) {
|
||||
return createModal({
|
||||
title: title || 'Сообщение',
|
||||
content: message,
|
||||
onClose: callback,
|
||||
autoClose: autoClose,
|
||||
autoCloseTime: 2000
|
||||
});
|
||||
}
|
||||
|
||||
// Функция для отображения модального окна с подтверждением (замена confirm)
|
||||
function showConfirm(message, callback, title) {
|
||||
return createModal({
|
||||
title: title || 'Подтверждение',
|
||||
content: message,
|
||||
showCancel: true,
|
||||
onConfirm: function() {
|
||||
if (typeof callback === 'function') {
|
||||
callback(true);
|
||||
}
|
||||
},
|
||||
onCancel: function() {
|
||||
if (typeof callback === 'function') {
|
||||
callback(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Функция для генерации QR-кода
|
||||
function generateQRCode(data, size) {
|
||||
const typeNumber = 0; // Автоматическое определение
|
||||
const errorCorrectionLevel = 'L'; // Низкий уровень коррекции ошибок
|
||||
const qr = qrcode(typeNumber, errorCorrectionLevel);
|
||||
qr.addData(data);
|
||||
qr.make();
|
||||
return qr.createImgTag(size || 8, 0);
|
||||
}
|
||||
|
||||
// Функция для отображения QR-кода в модальном окне
|
||||
function showQRCodeModal(url, title) {
|
||||
const qrCode = generateQRCode(url);
|
||||
const content = `
|
||||
<div class="qr-container">
|
||||
<div class="qr-code">
|
||||
${qrCode}
|
||||
</div>
|
||||
<div class="qr-link-container">
|
||||
<input type="text" class="qr-link-input" value="${url}" readonly>
|
||||
<button class="btn btn-copy-link">Копировать</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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;
|
||||
}
|
364
server/routers/questioneer/public/static/js/create.js
Normal file
364
server/routers/questioneer/public/static/js/create.js
Normal file
@ -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();
|
||||
});
|
355
server/routers/questioneer/public/static/js/edit.js
Normal file
355
server/routers/questioneer/public/static/js/edit.js
Normal file
@ -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();
|
||||
});
|
82
server/routers/questioneer/public/static/js/index.js
Normal file
82
server/routers/questioneer/public/static/js/index.js
Normal file
@ -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(`<p class="error">Ошибка: ${result.error}</p>`);
|
||||
}
|
||||
},
|
||||
error: function(error) {
|
||||
console.error('Error loading questionnaires:', error);
|
||||
$('#questionnaires-container').html('<p class="error">Не удалось загрузить опросы. Пожалуйста, попробуйте позже.</p>');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Функция для отображения списка опросов
|
||||
const renderQuestionnaires = (questionnaires) => {
|
||||
if (!questionnaires || questionnaires.length === 0) {
|
||||
$('#questionnaires-container').html('<p>У вас еще нет созданных опросов.</p>');
|
||||
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 => `
|
||||
<div class="questionnaire-item">
|
||||
<h3>${q.title}</h3>
|
||||
<p>${q.description || 'Нет описания'}</p>
|
||||
<p>Создан: ${new Date(q.createdAt).toLocaleString()}</p>
|
||||
<div class="questionnaire-links">
|
||||
<a href="${basePath}admin/${q.adminLink}" class="btn btn-small">Редактировать</a>
|
||||
<a href="${basePath}poll/${q.publicLink}" class="btn btn-small btn-primary" target="_blank">Смотреть как участник</a>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
$('#questionnaires-container').html(questionnairesHTML);
|
||||
};
|
||||
|
||||
// Инициализация страницы
|
||||
loadQuestionnaires();
|
||||
|
||||
// Обновление данных каждые 30 секунд
|
||||
setInterval(loadQuestionnaires, 30000);
|
||||
});
|
1202
server/routers/questioneer/public/static/js/poll.js
Normal file
1202
server/routers/questioneer/public/static/js/poll.js
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user