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("/gamehub", require("./routers/gamehub"))
|
||||||
app.use("/esc", require("./routers/esc"))
|
app.use("/esc", require("./routers/esc"))
|
||||||
app.use('/connectme', require('./routers/connectme'))
|
app.use('/connectme', require('./routers/connectme'))
|
||||||
|
app.use('/questioneer', require('./routers/questioneer'))
|
||||||
|
|
||||||
app.use(require("./error"))
|
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