Compare commits
25 Commits
feature/im
...
freetracke
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b9183fd6b | ||
|
|
1a63f53fef | ||
| f3566361fb | |||
| a63a229b64 | |||
| 8944508308 | |||
| 775f24cffa | |||
|
|
78b72b0edc | ||
|
|
333fe79c8b | ||
|
|
9d10c8501a | ||
|
|
d64ece382a | ||
|
|
f91f821f86 | ||
|
|
b5301f948a | ||
|
|
dd589790c2 | ||
|
|
1fcc5ed70d | ||
|
|
41dbe81001 | ||
|
|
7b685ad99e | ||
| 2f1e1dc040 | |||
|
|
70e8a6877c | ||
|
|
87fd3121f9 | ||
|
|
4f9434163e | ||
|
|
350d452a7b | ||
| 9a0669df13 | |||
| 566bce4663 | |||
| c828718498 | |||
|
|
909662e92e |
@@ -90,6 +90,7 @@ app.use("/dhs-testing", require("./routers/dhs-testing"))
|
||||
app.use("/gamehub", require("./routers/gamehub"))
|
||||
app.use("/esc", require("./routers/esc"))
|
||||
app.use('/connectme', require('./routers/connectme'))
|
||||
app.use('/questioneer', require('./routers/questioneer'))
|
||||
|
||||
app.use(require("./error"))
|
||||
|
||||
|
||||
60
server/models/questionnaire.js
Normal file
60
server/models/questionnaire.js
Normal file
@@ -0,0 +1,60 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
// Типы вопросов
|
||||
const QUESTION_TYPES = {
|
||||
SINGLE_CHOICE: 'single_choice', // Один вариант
|
||||
MULTIPLE_CHOICE: 'multiple_choice', // Несколько вариантов
|
||||
TEXT: 'text', // Текстовый ответ
|
||||
RATING: 'rating', // Оценка по шкале
|
||||
TAG_CLOUD: 'tag_cloud' // Облако тегов
|
||||
};
|
||||
|
||||
// Типы отображения
|
||||
const DISPLAY_TYPES = {
|
||||
DEFAULT: 'default',
|
||||
TAG_CLOUD: 'tag_cloud',
|
||||
VOTING: 'voting',
|
||||
POLL: 'poll'
|
||||
};
|
||||
|
||||
// Схема варианта ответа
|
||||
const optionSchema = new mongoose.Schema({
|
||||
text: { type: String, required: true },
|
||||
count: { type: Number, default: 0 } // счетчик голосов
|
||||
});
|
||||
|
||||
// Схема вопроса
|
||||
const questionSchema = new mongoose.Schema({
|
||||
text: { type: String, required: true },
|
||||
type: {
|
||||
type: String,
|
||||
enum: Object.values(QUESTION_TYPES),
|
||||
required: true
|
||||
},
|
||||
options: [optionSchema],
|
||||
required: { type: Boolean, default: false }
|
||||
});
|
||||
|
||||
// Схема опроса
|
||||
const questionnaireSchema = new mongoose.Schema({
|
||||
title: { type: String, required: true },
|
||||
description: { type: String },
|
||||
questions: [questionSchema],
|
||||
displayType: {
|
||||
type: String,
|
||||
enum: Object.values(DISPLAY_TYPES),
|
||||
default: DISPLAY_TYPES.DEFAULT
|
||||
},
|
||||
createdAt: { type: Date, default: Date.now },
|
||||
updatedAt: { type: Date, default: Date.now },
|
||||
adminLink: { type: String, required: true }, // ссылка для редактирования
|
||||
publicLink: { type: String, required: true } // ссылка для голосования
|
||||
});
|
||||
|
||||
const Questionnaire = mongoose.model('Questionnaire', questionnaireSchema);
|
||||
|
||||
module.exports = {
|
||||
Questionnaire,
|
||||
QUESTION_TYPES,
|
||||
DISPLAY_TYPES
|
||||
};
|
||||
@@ -1,111 +1,117 @@
|
||||
const router = require('express').Router()
|
||||
const {MasterModel} = require('./model/master')
|
||||
const mongoose = require("mongoose")
|
||||
const {OrderModel} = require("./model/order")
|
||||
const router = require("express").Router();
|
||||
const { MasterModel } = require("./model/master");
|
||||
const mongoose = require("mongoose");
|
||||
const { OrderModel } = require("./model/order");
|
||||
|
||||
router.post("/masters/list", async (req, res, next) => {
|
||||
try {
|
||||
const { startDate, endDate } = req.body;
|
||||
|
||||
router.get("/masters", async (req, res, next) => {
|
||||
try {
|
||||
const masters = await MasterModel.find({});
|
||||
const orders = await OrderModel.find({});
|
||||
|
||||
const mastersWithOrders = masters.map((master) => {
|
||||
const masterOrders = orders.filter((order) => {
|
||||
return (
|
||||
order?.master && order.master.toString() === master._id.toString()
|
||||
);
|
||||
});
|
||||
|
||||
const schedule = masterOrders.map((order) => ({
|
||||
id: order._id,
|
||||
startWashTime: order.startWashTime,
|
||||
endWashTime: order.endWashTime,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: master._id,
|
||||
name: master.name,
|
||||
schedule: schedule,
|
||||
phone: master.phone,
|
||||
};
|
||||
});
|
||||
|
||||
res.status(200).send({ success: true, body: mastersWithOrders });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/masters/:id', async (req, res,next) => {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!mongoose.Types.ObjectId.isValid(id)){
|
||||
throw new Error('ID is required')
|
||||
if (!startDate || !endDate) {
|
||||
throw new Error("Missing startDate or endDate");
|
||||
}
|
||||
|
||||
try {
|
||||
const master = await MasterModel.findByIdAndDelete(id, {
|
||||
new: true,
|
||||
});
|
||||
if (!master) {
|
||||
throw new Error('master not found')
|
||||
}
|
||||
res.status(200).send({success: true, body: master})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
const masters = await MasterModel.find({});
|
||||
|
||||
const orders = await OrderModel.find({
|
||||
$or: [
|
||||
{ startWashTime: { $lt: end }, endWashTime: { $gt: start } }
|
||||
]
|
||||
});
|
||||
|
||||
router.post('/masters', async (req, res,next) => {
|
||||
|
||||
const {name, phone} = req.body
|
||||
|
||||
if (!name || !phone ){
|
||||
throw new Error('Enter name and phone')
|
||||
}
|
||||
try {
|
||||
const master = await MasterModel.create({name, phone})
|
||||
res.status(200).send({success: true, body: master})
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
router.patch('/masters/:id', async (req, res, next) => {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||||
throw new Error('ID is required')
|
||||
}
|
||||
|
||||
const { name, phone } = req.body;
|
||||
|
||||
if (!name && !phone) {
|
||||
throw new Error('Enter name and phone')
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const updateData = {};
|
||||
if (name) updateData.name = name;
|
||||
if (phone) updateData.phone = phone;
|
||||
|
||||
const master = await MasterModel.findByIdAndUpdate(
|
||||
id,
|
||||
updateData,
|
||||
{ new: true }
|
||||
const mastersWithOrders = masters.map((master) => {
|
||||
const masterOrders = orders.filter((order) => {
|
||||
return (
|
||||
order?.master && order.master.toString() === master._id.toString()
|
||||
);
|
||||
});
|
||||
|
||||
if (!master) {
|
||||
throw new Error('master not found')
|
||||
}
|
||||
const schedule = masterOrders.map((order) => ({
|
||||
id: order._id,
|
||||
startWashTime: order.startWashTime,
|
||||
endWashTime: order.endWashTime,
|
||||
}));
|
||||
|
||||
res.status(200).send({ success: true, body: master });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
return {
|
||||
id: master._id,
|
||||
name: master.name,
|
||||
schedule: schedule,
|
||||
phone: master.phone,
|
||||
};
|
||||
});
|
||||
|
||||
res.status(200).send({ success: true, body: mastersWithOrders });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router
|
||||
router.delete("/masters/:id", async (req, res, next) => {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||||
throw new Error("ID is required");
|
||||
}
|
||||
|
||||
try {
|
||||
const master = await MasterModel.findByIdAndDelete(id, {
|
||||
new: true,
|
||||
});
|
||||
if (!master) {
|
||||
throw new Error("master not found");
|
||||
}
|
||||
res.status(200).send({ success: true, body: master });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/masters", async (req, res, next) => {
|
||||
const { name, phone } = req.body;
|
||||
|
||||
if (!name || !phone) {
|
||||
throw new Error("Enter name and phone");
|
||||
}
|
||||
try {
|
||||
const master = await MasterModel.create({ name, phone });
|
||||
res.status(200).send({ success: true, body: master });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch("/masters/:id", async (req, res, next) => {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||||
throw new Error("ID is required");
|
||||
}
|
||||
|
||||
const { name, phone } = req.body;
|
||||
|
||||
if (!name && !phone) {
|
||||
throw new Error("Enter name and phone");
|
||||
}
|
||||
|
||||
try {
|
||||
const updateData = {};
|
||||
if (name) updateData.name = name;
|
||||
if (phone) updateData.phone = phone;
|
||||
|
||||
const master = await MasterModel.findByIdAndUpdate(id, updateData, {
|
||||
new: true,
|
||||
});
|
||||
|
||||
if (!master) {
|
||||
throw new Error("master not found");
|
||||
}
|
||||
|
||||
res.status(200).send({ success: true, body: master });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -4,6 +4,20 @@ const getGigaToken = async () => {
|
||||
return data.features['dry-wash-bh'].GIGA_TOKEN.value
|
||||
}
|
||||
|
||||
const getSystemPrompt = async () => {
|
||||
const response = await fetch('https://admin.bro-js.ru/api/config/v1/dev')
|
||||
const data = await response.json()
|
||||
return data.features['dry-wash-bh'].SYSTEM_PROMPT.value
|
||||
}
|
||||
|
||||
const getGigaChatModel = async () => {
|
||||
const response = await fetch('https://admin.bro-js.ru/api/config/v1/dev')
|
||||
const data = await response.json()
|
||||
return data.features['dry-wash-bh'].GIGA_CHAT_MODEL.value
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getGigaToken
|
||||
getGigaToken,
|
||||
getSystemPrompt,
|
||||
getGigaChatModel
|
||||
}
|
||||
@@ -15,7 +15,7 @@ const schema = new Schema({
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
carColor: String,
|
||||
carColor: Schema.Types.Mixed,
|
||||
startWashTime: {
|
||||
type: Date,
|
||||
required: true
|
||||
|
||||
@@ -5,12 +5,19 @@ const { MasterModel } = require('./model/master')
|
||||
const { OrderModel } = require('./model/order')
|
||||
const { OrderCarImgModel } = require('./model/order.car-img')
|
||||
const { orderStatus } = require('./model/const')
|
||||
const { getGigaToken } = require('./get-token')
|
||||
const { getGigaToken, getSystemPrompt, getGigaChatModel } = require('./get-token')
|
||||
|
||||
const isValidPhoneNumber = (value) => /^(\+)?\d{9,15}/.test(value)
|
||||
const isValidCarNumber = (value) => /^[авекмнорстух][0-9]{3}[авекмнорстух]{2}[0-9]{2,3}$/i.test(value)
|
||||
const isValidCarBodyType = (value) => typeof value === 'number' && value > 0 && value < 100
|
||||
const isValidCarColor = (value) => value.length < 50 && /^[#a-z0-9а-я-\s,.()]+$/i.test(value)
|
||||
const isValidCarColor = (value) => {
|
||||
if (typeof value === 'number') {
|
||||
return value >= 0 && value <= 7
|
||||
} else if (typeof value === 'string') {
|
||||
return /^[#a-z0-9а-я-\s,.()]+$/i.test(value)
|
||||
}
|
||||
return false
|
||||
}
|
||||
const isValidISODate = (value) => /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:.\d{1,3})?Z$/.test(value)
|
||||
|
||||
const latitudeRe = /^(-?[1-8]?\d(?:\.\d{1,18})?|90(?:\.0{1,18})?)$/
|
||||
@@ -30,7 +37,7 @@ const isValidOrderStatus = (value) => Object.values(orderStatus).includes(value)
|
||||
const isValidOrderNotes = (value) => value.length < 500
|
||||
|
||||
const allowedMimeTypes = ['image/jpeg', 'image/png']
|
||||
const sizeLimitInMegaBytes = 5
|
||||
const sizeLimitInMegaBytes = 15
|
||||
|
||||
const VALIDATION_MESSAGES = {
|
||||
order: {
|
||||
@@ -156,17 +163,21 @@ router.post('/create', async (req, res, next) => {
|
||||
|
||||
router.get('/:id', async (req, res, next) => {
|
||||
const { id } = req.params
|
||||
|
||||
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||||
throw new Error(VALIDATION_MESSAGES.orderId.invalid)
|
||||
}
|
||||
|
||||
try {
|
||||
const order = await OrderModel.findById(id)
|
||||
|
||||
if (!order) {
|
||||
throw new Error(VALIDATION_MESSAGES.order.notFound)
|
||||
}
|
||||
|
||||
res.status(200).send({ success: true, body: order })
|
||||
const imgProps = await OrderCarImgModel.findOne({ orderId: order.id })
|
||||
|
||||
res.status(200).send({ success: true, body: { ...order.toObject(), ...imgProps?.toObject() } })
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
@@ -277,7 +288,6 @@ const upload = multer({
|
||||
const { v4: uuidv4 } = require("uuid")
|
||||
const axios = require('axios')
|
||||
|
||||
const GIGA_CHAT_ACCESS_TOKEN = 'MTQwMmNmZjgtZjA5OC00OGMxLWI0OTUtNWU3ZTU4YzMzZjdjOmU5OGFiYmNiLThmMDItNGVmOC1hNjhhLTA4Y2QxYjVmOGRmMA=='
|
||||
const GIGA_CHAT_OAUTH = 'https://ngw.devices.sberbank.ru:9443/api/v2/oauth'
|
||||
const GIGA_CHAT_API = 'https://gigachat.devices.sberbank.ru/api/v1'
|
||||
|
||||
@@ -341,31 +351,13 @@ const analyzeImage = async (fileId, token) => {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "GigaChat-Max",
|
||||
model: (await getGigaChatModel()) ?? "GigaChat-Max",
|
||||
stream: false,
|
||||
update_interval: 0,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
`Ты эксперт по оценке степени загрязнения автомобилей. Твоя задача — анализировать фотографии машин и определять степень их загрязнения.
|
||||
ВАЖНО: Твой ответ ДОЛЖЕН быть СТРОГО в формате JSON и содержать ТОЛЬКО следующие поля:
|
||||
{
|
||||
"value": число от 0 до 10 (целое или с одним знаком после запятой),
|
||||
"description": "текстовое описание на русском языке"
|
||||
}
|
||||
Правила:
|
||||
1. Поле "value":
|
||||
- Должно быть числом от 0 до 10
|
||||
- 0 = машина абсолютно чистая
|
||||
- 10 = машина максимально грязная
|
||||
2. Поле "description":
|
||||
- Должно содержать 2-3 предложения на русском языке
|
||||
- Обязательно указать конкретные признаки загрязнения
|
||||
- Объяснить почему выставлен именно такой балл
|
||||
НЕ ДОБАВЛЯЙ никаких дополнительных полей или комментариев вне JSON структуры.
|
||||
НЕ ИСПОЛЬЗУЙ markdown форматирование.
|
||||
ОТВЕТ ДОЛЖЕН БЫТЬ ВАЛИДНЫМ JSON.`,
|
||||
content: (await getSystemPrompt()) ?? `Ты эксперт по оценке степени загрязнения автомобилей. Твоя задача — анализировать фотографии машин и определять степень их загрязнения. ВАЖНО: Твой ответ ДОЛЖЕН быть СТРОГО в формате JSON и содержать ТОЛЬКО следующие поля: { "value": число от 0 до 10 (целое или с одним знаком после запятой), "description": "текстовое описание на русском языке" } Правила: 1. Поле "value": - Должно быть числом от 0 до 10 - 0 = машина абсолютно чистая - 10 = машина максимально грязная 2. Поле "description": - Должно содержать 2-3 предложения на русском языке - Обязательно указать конкретные признаки загрязнения - Объяснить почему выставлен именно такой балл НЕ ДОБАВЛЯЙ никаких дополнительных полей или комментариев вне JSON структуры. НЕ ИСПОЛЬЗУЙ markdown форматирование. ОТВЕТ ДОЛЖЕН БЫТЬ ВАЛИДНЫМ JSON. Если на фотографии нет одной машины, то оценка должна быть 0 и в описании должно быть указано, почему не удалось оценить.`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
@@ -377,6 +369,8 @@ const analyzeImage = async (fileId, token) => {
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
console.log(data)
|
||||
|
||||
try {
|
||||
return JSON.parse(data.choices[0].message.content)
|
||||
} catch (error) {
|
||||
@@ -387,7 +381,7 @@ const analyzeImage = async (fileId, token) => {
|
||||
|
||||
const convertFileToBase64 = (file) => {
|
||||
const base64Image = file.buffer.toString('base64')
|
||||
return base64Image
|
||||
return `data:${file.mimetype};base64,${base64Image}`
|
||||
}
|
||||
|
||||
process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0"
|
||||
@@ -407,6 +401,8 @@ router.post('/:id/upload-car-img', upload.single('file'), async (req, res) => {
|
||||
}
|
||||
|
||||
try {
|
||||
await OrderCarImgModel.deleteMany({ orderId })
|
||||
|
||||
const { access_token } = await getToken(req, res)
|
||||
|
||||
const fileId = await uploadImage(req.file, access_token)
|
||||
@@ -416,7 +412,7 @@ router.post('/:id/upload-car-img', upload.single('file'), async (req, res) => {
|
||||
image: convertFileToBase64(req.file),
|
||||
imageRating: value,
|
||||
imageDescription: description,
|
||||
orderId,
|
||||
orderId: order.id,
|
||||
created: new Date().toISOString(),
|
||||
})
|
||||
|
||||
@@ -439,4 +435,4 @@ router.use((err, req, res, next) => {
|
||||
throw new Error(err.message)
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
module.exports = router
|
||||
|
||||
22
server/routers/freetracker/dashboard-customer/index.js
Normal file
22
server/routers/freetracker/dashboard-customer/index.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const Router = require('express').Router;
|
||||
|
||||
const router = Router()
|
||||
|
||||
const timer = (_req, _res, next) => {
|
||||
setTimeout(() => next(), 500)
|
||||
}
|
||||
|
||||
router.use(timer)
|
||||
|
||||
router.get(
|
||||
'/trips',
|
||||
(req, res) =>
|
||||
res.send(require(`./json/trips-success.json`))
|
||||
)
|
||||
|
||||
router.get('/cars/:id', (req, res) => {
|
||||
res.send(require(`./json/cars-success.json`))
|
||||
|
||||
})
|
||||
|
||||
module.exports = router
|
||||
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"success": true,
|
||||
"errors": [],
|
||||
"warnings":[],
|
||||
"body": [
|
||||
{
|
||||
"id": 1,
|
||||
"car": "Mersedes",
|
||||
"driver": "Иванов Иван Иванович",
|
||||
"telephone": "+7 9600376666",
|
||||
"upgradeNum": "Челябинск"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"car": "Mersedes",
|
||||
"driver": "Иванов Иван Иванович",
|
||||
"telephone": "+7 9600376666",
|
||||
"upgradeNum": "Челябинск"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"car": "Mersedes",
|
||||
"driver": "Иванов Иван Иванович",
|
||||
"telephone": "+7 9600376666",
|
||||
"upgradeNum": "Челябинск"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,807 @@
|
||||
{
|
||||
"success": true,
|
||||
"errors": [],
|
||||
"warnings":[],
|
||||
"body": [
|
||||
{
|
||||
"key": 0,
|
||||
"trip": "Владисвосток-Москва",
|
||||
"count": 99,
|
||||
"date_start": "Mon, 29 Jun 2020 14:13:16 GMT",
|
||||
"date_end": "Wed, 15 Jul 2020 09:45:23 GMT",
|
||||
"created_at": "Thu, 01 Dec 2022 15:55:14 GMT"
|
||||
},
|
||||
{
|
||||
"key": 1,
|
||||
"trip": "Казань-Москва",
|
||||
"count": 85,
|
||||
"date_start": "Mon, 17 Oct 2022 09:46:21 GMT",
|
||||
"date_end": "Fri, 28 Oct 2022 16:30:12 GMT",
|
||||
"created_at": "Wed, 14 Jun 2023 03:06:52 GMT"
|
||||
},
|
||||
{
|
||||
"key": 2,
|
||||
"trip": "Казань-Сызрань",
|
||||
"count": 48,
|
||||
"date_start": "Thu, 01 Sep 2022 04:28:45 GMT",
|
||||
"date_end": "Mon, 12 Sep 2022 11:15:33 GMT",
|
||||
"created_at": "Mon, 07 Oct 2024 01:37:16 GMT"
|
||||
},
|
||||
{
|
||||
"key": 3,
|
||||
"trip": "Владивосток-Владимир",
|
||||
"count": 72,
|
||||
"date_start": "Wed, 26 Jun 2024 16:22:43 GMT",
|
||||
"date_end": "Tue, 09 Jul 2024 13:45:28 GMT",
|
||||
"created_at": "Sun, 11 Dec 2022 16:12:07 GMT"
|
||||
},
|
||||
{
|
||||
"key": 4,
|
||||
"trip": "Владивосток-Вологда",
|
||||
"count": 29,
|
||||
"date_start": "Mon, 29 Jun 2020 17:37:33 GMT",
|
||||
"date_end": "Thu, 16 Jul 2020 08:22:15 GMT",
|
||||
"created_at": "Thu, 26 Mar 2020 01:18:31 GMT"
|
||||
},
|
||||
{
|
||||
"key": 5,
|
||||
"trip": "Москва-Вологда",
|
||||
"count": 78,
|
||||
"date_start": "Fri, 05 May 2023 03:42:20 GMT",
|
||||
"date_end": "Wed, 17 May 2023 19:30:45 GMT",
|
||||
"created_at": "Tue, 21 Sep 2021 13:54:18 GMT"
|
||||
},
|
||||
{
|
||||
"key": 6,
|
||||
"trip": "Казань-Вологда",
|
||||
"count": 36,
|
||||
"date_start": "Sun, 02 Oct 2022 14:35:34 GMT",
|
||||
"date_end": "Sat, 15 Oct 2022 10:20:18 GMT",
|
||||
"created_at": "Sun, 11 Sep 2022 12:27:29 GMT"
|
||||
},
|
||||
{
|
||||
"key": 7,
|
||||
"trip": "Казань-Тольятти",
|
||||
"count": 7,
|
||||
"date_start": "Tue, 16 Feb 2021 16:17:32 GMT",
|
||||
"date_end": "Mon, 01 Mar 2021 14:45:22 GMT",
|
||||
"created_at": "Tue, 02 Mar 2021 02:15:25 GMT"
|
||||
},
|
||||
{
|
||||
"key": 8,
|
||||
"trip": "Чита-Тольятти",
|
||||
"count": 29,
|
||||
"date_start": "Mon, 16 Sep 2024 17:48:59 GMT",
|
||||
"date_end": "Sun, 29 Sep 2024 09:30:15 GMT",
|
||||
"created_at": "Thu, 02 Jul 2020 17:12:02 GMT"
|
||||
},
|
||||
{
|
||||
"key": 9,
|
||||
"trip": "Чита-Тюмень",
|
||||
"count": 94,
|
||||
"date_start": "Wed, 04 May 2022 01:36:51 GMT",
|
||||
"date_end": "Tue, 17 May 2022 18:20:33 GMT",
|
||||
"created_at": "Mon, 28 Oct 2024 04:00:12 GMT"
|
||||
},
|
||||
{
|
||||
"key": 10,
|
||||
"trip": "Москва-Тюмень",
|
||||
"count": 12,
|
||||
"date_start": "Thu, 24 Oct 2024 05:48:10 GMT",
|
||||
"date_end": "Wed, 06 Nov 2024 16:25:40 GMT",
|
||||
"created_at": "Sun, 30 Aug 2020 05:14:05 GMT"
|
||||
},
|
||||
{
|
||||
"key": 11,
|
||||
"trip": "Казань-Тольятти",
|
||||
"count": 49,
|
||||
"date_start": "Mon, 22 Apr 2024 15:59:19 GMT",
|
||||
"date_end": "Sun, 05 May 2024 12:30:45 GMT",
|
||||
"created_at": "Tue, 22 Dec 2020 09:51:24 GMT"
|
||||
},
|
||||
{
|
||||
"key": 12,
|
||||
"trip": "Владивосток-Владимир",
|
||||
"count": 40,
|
||||
"date_start": "Thu, 30 May 2024 01:48:52 GMT",
|
||||
"date_end": "Wed, 12 Jun 2024 20:15:33 GMT",
|
||||
"created_at": "Sat, 28 Jan 2023 14:11:25 GMT"
|
||||
},
|
||||
{
|
||||
"key": 13,
|
||||
"trip": "Самара-Тюмень",
|
||||
"count": 47,
|
||||
"date_start": "Sun, 22 Nov 2020 13:11:01 GMT",
|
||||
"date_end": "Sat, 05 Dec 2020 09:45:28 GMT",
|
||||
"created_at": "Mon, 17 Jan 2022 16:24:57 GMT"
|
||||
},
|
||||
{
|
||||
"key": 14,
|
||||
"trip": "Казань-Вологда",
|
||||
"count": 32,
|
||||
"date_start": "Thu, 08 Dec 2022 03:45:34 GMT",
|
||||
"date_end": "Wed, 21 Dec 2022 15:20:18 GMT",
|
||||
"created_at": "Wed, 05 Jun 2024 08:59:22 GMT"
|
||||
},
|
||||
{
|
||||
"key": 15,
|
||||
"trip": "Самара-Казань",
|
||||
"count": 61,
|
||||
"date_start": "Sat, 02 May 2020 19:18:10 GMT",
|
||||
"date_end": "Fri, 15 May 2020 16:30:45 GMT",
|
||||
"created_at": "Sat, 12 Aug 2023 01:13:15 GMT"
|
||||
},
|
||||
{
|
||||
"key": 16,
|
||||
"trip": "Тюмень-Казань",
|
||||
"count": 99,
|
||||
"date_start": "Tue, 22 Dec 2020 08:05:09 GMT",
|
||||
"date_end": "Mon, 04 Jan 2021 14:25:33 GMT",
|
||||
"created_at": "Sun, 15 Jan 2023 09:27:02 GMT"
|
||||
},
|
||||
{
|
||||
"key": 17,
|
||||
"trip": "Казань-Тольятти",
|
||||
"count": 29,
|
||||
"date_start": "Thu, 15 Dec 2022 07:04:50 GMT",
|
||||
"date_end": "Wed, 28 Dec 2022 11:40:15 GMT",
|
||||
"created_at": "Wed, 22 Mar 2023 23:11:56 GMT"
|
||||
},
|
||||
{
|
||||
"key": 18,
|
||||
"trip": "Тюмень-Вологда",
|
||||
"count": 72,
|
||||
"date_start": "Wed, 28 Dec 2022 11:36:57 GMT",
|
||||
"date_end": "Tue, 10 Jan 2023 18:20:45 GMT",
|
||||
"created_at": "Sun, 06 Mar 2022 12:22:23 GMT"
|
||||
},
|
||||
{
|
||||
"key": 19,
|
||||
"trip": "Тюмень-Ижевск",
|
||||
"count": 44,
|
||||
"date_start": "Tue, 30 Jun 2020 05:32:53 GMT",
|
||||
"date_end": "Mon, 13 Jul 2020 12:15:30 GMT",
|
||||
"created_at": "Mon, 22 Apr 2024 11:49:06 GMT"
|
||||
},
|
||||
{
|
||||
"key": 20,
|
||||
"trip": "Самара-Тюмень",
|
||||
"count": 86,
|
||||
"date_start": "Sat, 29 Aug 2020 02:28:28 GMT",
|
||||
"date_end": "Fri, 11 Sep 2020 09:40:18 GMT",
|
||||
"created_at": "Sat, 23 Mar 2024 02:57:58 GMT"
|
||||
},
|
||||
{
|
||||
"key": 21,
|
||||
"trip": "Москва-Вологда",
|
||||
"count": 67,
|
||||
"date_start": "Wed, 28 Aug 2024 14:19:06 GMT",
|
||||
"date_end": "Tue, 10 Sep 2024 21:30:45 GMT",
|
||||
"created_at": "Fri, 29 Apr 2022 20:59:06 GMT"
|
||||
},
|
||||
{
|
||||
"key": 22,
|
||||
"trip": "Чита-Тольятти",
|
||||
"count": 5,
|
||||
"date_start": "Thu, 07 Oct 2021 03:54:24 GMT",
|
||||
"date_end": "Wed, 20 Oct 2021 10:25:33 GMT",
|
||||
"created_at": "Mon, 01 Mar 2021 20:24:58 GMT"
|
||||
},
|
||||
{
|
||||
"key": 23,
|
||||
"trip": "Москва-Ижевск",
|
||||
"count": 52,
|
||||
"date_start": "Sun, 09 May 2021 05:00:06 GMT",
|
||||
"date_end": "Sat, 22 May 2021 11:45:20 GMT",
|
||||
"created_at": "Sun, 25 Aug 2024 07:43:58 GMT"
|
||||
},
|
||||
{
|
||||
"key": 24,
|
||||
"trip": "Москва-Пермь",
|
||||
"count": 56,
|
||||
"date_start": "Thu, 22 Apr 2021 01:45:23 GMT",
|
||||
"date_end": "Wed, 05 May 2021 08:30:15 GMT",
|
||||
"created_at": "Tue, 14 Apr 2020 14:34:44 GMT"
|
||||
},
|
||||
{
|
||||
"key": 25,
|
||||
"trip": "Владивосток-Владимир",
|
||||
"count": 84,
|
||||
"date_start": "Sun, 30 May 2021 06:20:13 GMT",
|
||||
"date_end": "Sat, 12 Jun 2021 13:15:40 GMT",
|
||||
"created_at": "Sat, 02 Oct 2021 16:16:48 GMT"
|
||||
},
|
||||
{
|
||||
"key": 26,
|
||||
"trip": "Тюмень-Ижевск",
|
||||
"count": 64,
|
||||
"date_start": "Mon, 18 Mar 2024 03:50:05 GMT",
|
||||
"date_end": "Sun, 31 Mar 2024 10:35:25 GMT",
|
||||
"created_at": "Thu, 06 Aug 2020 10:22:30 GMT"
|
||||
},
|
||||
{
|
||||
"key": 27,
|
||||
"trip": "Самара-Тюмень",
|
||||
"count": 52,
|
||||
"date_start": "Sun, 20 Mar 2022 06:26:26 GMT",
|
||||
"date_end": "Sat, 02 Apr 2022 13:20:15 GMT",
|
||||
"created_at": "Wed, 15 May 2024 16:25:33 GMT"
|
||||
},
|
||||
{
|
||||
"key": 28,
|
||||
"trip": "Самара-Казань",
|
||||
"count": 69,
|
||||
"date_start": "Wed, 16 Sep 2020 23:18:39 GMT",
|
||||
"date_end": "Tue, 29 Sep 2020 06:05:30 GMT",
|
||||
"created_at": "Sun, 27 Aug 2023 10:13:51 GMT"
|
||||
},
|
||||
{
|
||||
"key": 29,
|
||||
"trip": "Владивосток-Вологда",
|
||||
"count": 39,
|
||||
"date_start": "Sat, 13 Jul 2024 16:41:31 GMT",
|
||||
"date_end": "Fri, 26 Jul 2024 23:30:18 GMT",
|
||||
"created_at": "Sun, 04 Apr 2021 10:16:17 GMT"
|
||||
},
|
||||
{
|
||||
"key": 30,
|
||||
"trip": "Москва-Тюмень",
|
||||
"count": 15,
|
||||
"date_start": "Mon, 12 Feb 2024 21:46:30 GMT",
|
||||
"date_end": "Sun, 25 Feb 2024 04:35:22 GMT",
|
||||
"created_at": "Mon, 25 Oct 2021 21:06:29 GMT"
|
||||
},
|
||||
{
|
||||
"key": 31,
|
||||
"trip": "Москва-Тюмень",
|
||||
"count": 98,
|
||||
"date_start": "Tue, 01 Oct 2024 12:54:27 GMT",
|
||||
"date_end": "Mon, 14 Oct 2024 19:40:15 GMT",
|
||||
"created_at": "Wed, 09 Mar 2022 03:46:32 GMT"
|
||||
},
|
||||
{
|
||||
"key": 32,
|
||||
"trip": "N",
|
||||
"count": 44,
|
||||
"date_start": "Wed, 08 Apr 2020 13:09:07 GMT",
|
||||
"date_end": "Tue, 21 Apr 2020 20:00:33 GMT",
|
||||
"created_at": "Wed, 01 Jan 2025 19:48:47 GMT"
|
||||
},
|
||||
{
|
||||
"key": 33,
|
||||
"trip": "Q",
|
||||
"count": 23,
|
||||
"date_start": "Wed, 09 Feb 2022 00:13:32 GMT",
|
||||
"date_end": "Tue, 22 Feb 2022 07:05:20 GMT",
|
||||
"created_at": "Sun, 19 Mar 2023 13:20:24 GMT"
|
||||
},
|
||||
{
|
||||
"key": 34,
|
||||
"trip": "Чита-Тюмень",
|
||||
"count": 67,
|
||||
"date_start": "Mon, 21 Oct 2024 14:57:27 GMT",
|
||||
"date_end": "Sun, 03 Nov 2024 21:45:15 GMT",
|
||||
"created_at": "Tue, 15 Nov 2022 17:33:15 GMT"
|
||||
},
|
||||
{
|
||||
"key": 35,
|
||||
"trip": "S",
|
||||
"count": 28,
|
||||
"date_start": "Wed, 18 Mar 2020 10:03:48 GMT",
|
||||
"date_end": "Tue, 31 Mar 2020 16:50:33 GMT",
|
||||
"created_at": "Thu, 23 May 2024 20:59:48 GMT"
|
||||
},
|
||||
{
|
||||
"key": 36,
|
||||
"trip": "Казань-Вологда",
|
||||
"count": 82,
|
||||
"date_start": "Sat, 24 Apr 2021 08:35:13 GMT",
|
||||
"date_end": "Fri, 07 May 2021 15:25:40 GMT",
|
||||
"created_at": "Tue, 24 Oct 2023 03:16:38 GMT"
|
||||
},
|
||||
{
|
||||
"key": 37,
|
||||
"trip": "Казань-Тольятти",
|
||||
"count": 43,
|
||||
"date_start": "Wed, 05 Oct 2022 20:10:30 GMT",
|
||||
"date_end": "Tue, 18 Oct 2022 03:00:18 GMT",
|
||||
"created_at": "Tue, 11 Jun 2024 22:41:02 GMT"
|
||||
},
|
||||
{
|
||||
"key": 38,
|
||||
"trip": "Москва-Тюмень",
|
||||
"count": 56,
|
||||
"date_start": "Thu, 06 Jan 2022 02:47:53 GMT",
|
||||
"date_end": "Wed, 19 Jan 2022 09:35:25 GMT",
|
||||
"created_at": "Mon, 06 Sep 2021 05:40:10 GMT"
|
||||
},
|
||||
{
|
||||
"key": 39,
|
||||
"trip": "Чита-Тольятти",
|
||||
"count": 84,
|
||||
"date_start": "Wed, 05 Jan 2022 06:42:08 GMT",
|
||||
"date_end": "Tue, 18 Jan 2022 13:30:40 GMT",
|
||||
"created_at": "Mon, 13 Apr 2020 02:45:40 GMT"
|
||||
},
|
||||
{
|
||||
"key": 40,
|
||||
"trip": "Самара-Казань",
|
||||
"count": 51,
|
||||
"date_start": "Mon, 12 Feb 2024 09:03:02 GMT",
|
||||
"date_end": "Sun, 25 Feb 2024 15:50:33 GMT",
|
||||
"created_at": "Tue, 14 May 2024 13:03:26 GMT"
|
||||
},
|
||||
{
|
||||
"key": 41,
|
||||
"trip": "Москва-Пермь",
|
||||
"count": 58,
|
||||
"date_start": "Fri, 19 May 2023 23:10:06 GMT",
|
||||
"date_end": "Thu, 01 Jun 2023 06:00:20 GMT",
|
||||
"created_at": "Sun, 05 Dec 2021 07:35:56 GMT"
|
||||
},
|
||||
{
|
||||
"key": 42,
|
||||
"trip": "Владивосток-Вологда",
|
||||
"count": 46,
|
||||
"date_start": "Thu, 18 Mar 2021 14:42:08 GMT",
|
||||
"date_end": "Wed, 31 Mar 2021 21:30:45 GMT",
|
||||
"created_at": "Fri, 02 Jul 2021 01:45:38 GMT"
|
||||
},
|
||||
{
|
||||
"key": 43,
|
||||
"trip": "Тюмень-Вологда",
|
||||
"count": 92,
|
||||
"date_start": "Sat, 01 Feb 2025 10:11:00 GMT",
|
||||
"date_end": "Fri, 14 Feb 2025 17:00:33 GMT",
|
||||
"created_at": "Mon, 05 Jul 2021 06:14:03 GMT"
|
||||
},
|
||||
{
|
||||
"key": 44,
|
||||
"trip": "E",
|
||||
"count": 96,
|
||||
"date_start": "Mon, 24 Feb 2020 19:09:06 GMT",
|
||||
"date_end": "Sun, 08 Mar 2020 02:00:20 GMT",
|
||||
"created_at": "Sun, 12 Apr 2020 04:59:22 GMT"
|
||||
},
|
||||
{
|
||||
"key": 45,
|
||||
"trip": "E",
|
||||
"count": 3,
|
||||
"date_start": "Sun, 18 Jun 2023 07:26:40 GMT",
|
||||
"date_end": "Sat, 01 Jul 2023 14:15:25 GMT",
|
||||
"created_at": "Sun, 18 Aug 2024 06:19:01 GMT"
|
||||
},
|
||||
{
|
||||
"key": 46,
|
||||
"trip": "E",
|
||||
"count": 63,
|
||||
"date_start": "Sat, 10 Apr 2021 13:23:55 GMT",
|
||||
"date_end": "Fri, 23 Apr 2021 20:10:40 GMT",
|
||||
"created_at": "Thu, 02 Apr 2020 16:26:15 GMT"
|
||||
},
|
||||
{
|
||||
"key": 47,
|
||||
"trip": "Москва-Тюмень",
|
||||
"count": 16,
|
||||
"date_start": "Wed, 23 Aug 2023 00:01:58 GMT",
|
||||
"date_end": "Tue, 05 Sep 2023 06:50:33 GMT",
|
||||
"created_at": "Fri, 16 Feb 2024 01:39:30 GMT"
|
||||
},
|
||||
{
|
||||
"key": 48,
|
||||
"trip": "Владивосток-Владимир",
|
||||
"count": 36,
|
||||
"date_start": "Tue, 25 Apr 2023 23:09:56 GMT",
|
||||
"date_end": "Mon, 08 May 2023 06:00:20 GMT",
|
||||
"created_at": "Sat, 17 Dec 2022 13:52:04 GMT"
|
||||
},
|
||||
{
|
||||
"key": 49,
|
||||
"trip": "Самара-Казань",
|
||||
"count": 62,
|
||||
"date_start": "Fri, 04 Oct 2024 11:01:56 GMT",
|
||||
"date_end": "Thu, 17 Oct 2024 17:50:33 GMT",
|
||||
"created_at": "Thu, 15 Feb 2024 02:39:03 GMT"
|
||||
},
|
||||
{
|
||||
"key": 50,
|
||||
"trip": "Москва-Тюмень",
|
||||
"count": 65,
|
||||
"date_start": "Thu, 18 Jun 2020 22:40:19 GMT",
|
||||
"date_end": "Wed, 01 Jul 2020 05:30:15 GMT",
|
||||
"created_at": "Mon, 28 Jun 2021 12:55:18 GMT"
|
||||
},
|
||||
{
|
||||
"key": 51,
|
||||
"trip": "Тюмень-Вологда",
|
||||
"count": 17,
|
||||
"date_start": "Tue, 08 Sep 2020 08:53:55 GMT",
|
||||
"date_end": "Mon, 21 Sep 2020 15:40:30 GMT",
|
||||
"created_at": "Thu, 19 Mar 2020 00:27:17 GMT"
|
||||
},
|
||||
{
|
||||
"key": 52,
|
||||
"trip": "Казань-Москва",
|
||||
"count": 28,
|
||||
"date_start": "Wed, 07 Sep 2022 20:41:37 GMT",
|
||||
"date_end": "Tue, 20 Sep 2022 03:30:25 GMT",
|
||||
"created_at": "Wed, 20 Apr 2022 04:36:43 GMT"
|
||||
},
|
||||
{
|
||||
"key": 53,
|
||||
"trip": "U",
|
||||
"count": 36,
|
||||
"date_start": "Mon, 22 May 2023 20:35:52 GMT",
|
||||
"date_end": "Sun, 04 Jun 2023 03:25:40 GMT",
|
||||
"created_at": "Tue, 20 Oct 2020 13:11:01 GMT"
|
||||
},
|
||||
{
|
||||
"key": 54,
|
||||
"trip": "S",
|
||||
"count": 57,
|
||||
"date_start": "Mon, 01 Jan 2024 12:53:55 GMT",
|
||||
"date_end": "Sun, 14 Jan 2024 19:40:30 GMT",
|
||||
"created_at": "Fri, 31 Jan 2025 10:15:04 GMT"
|
||||
},
|
||||
{
|
||||
"key": 55,
|
||||
"trip": "Казань-Сызрань",
|
||||
"count": 27,
|
||||
"date_start": "Thu, 03 Nov 2022 13:51:00 GMT",
|
||||
"date_end": "Wed, 16 Nov 2022 20:40:15 GMT",
|
||||
"created_at": "Wed, 03 Jun 2020 07:10:46 GMT"
|
||||
},
|
||||
{
|
||||
"key": 56,
|
||||
"trip": "N",
|
||||
"count": 58,
|
||||
"date_start": "Tue, 03 Nov 2020 10:27:49 GMT",
|
||||
"date_end": "Mon, 16 Nov 2020 17:15:33 GMT",
|
||||
"created_at": "Sat, 01 May 2021 08:40:29 GMT"
|
||||
},
|
||||
{
|
||||
"key": 57,
|
||||
"trip": "Владисвосток-Москва",
|
||||
"count": 29,
|
||||
"date_start": "Sun, 05 Jan 2020 00:10:45 GMT",
|
||||
"date_end": "Sat, 18 Jan 2020 07:00:20 GMT",
|
||||
"created_at": "Thu, 29 Jul 2021 08:04:38 GMT"
|
||||
},
|
||||
{
|
||||
"key": 58,
|
||||
"trip": "Казань-Тольятти",
|
||||
"count": 22,
|
||||
"date_start": "Wed, 05 Apr 2023 14:15:27 GMT",
|
||||
"date_end": "Tue, 18 Apr 2023 21:05:15 GMT",
|
||||
"created_at": "Fri, 06 Dec 2024 03:20:18 GMT"
|
||||
},
|
||||
{
|
||||
"key": 59,
|
||||
"trip": "Москва-Пермь",
|
||||
"count": 8,
|
||||
"date_start": "Fri, 18 Nov 2022 21:56:47 GMT",
|
||||
"date_end": "Thu, 01 Dec 2022 04:45:30 GMT",
|
||||
"created_at": "Tue, 07 Jul 2020 16:18:05 GMT"
|
||||
},
|
||||
{
|
||||
"key": 60,
|
||||
"trip": "Владисвосток-Москва",
|
||||
"count": 29,
|
||||
"date_start": "Sat, 06 Apr 2024 10:22:54 GMT",
|
||||
"date_end": "Fri, 19 Apr 2024 17:10:40 GMT",
|
||||
"created_at": "Wed, 21 Oct 2020 18:04:55 GMT"
|
||||
},
|
||||
{
|
||||
"key": 61,
|
||||
"trip": "Q",
|
||||
"count": 55,
|
||||
"date_start": "Mon, 28 Aug 2023 18:16:49 GMT",
|
||||
"date_end": "Sun, 10 Sep 2023 01:05:33 GMT",
|
||||
"created_at": "Mon, 29 Jul 2024 02:42:50 GMT"
|
||||
},
|
||||
{
|
||||
"key": 62,
|
||||
"trip": "Казань-Москва",
|
||||
"count": 64,
|
||||
"date_start": "Mon, 11 Dec 2023 13:50:59 GMT",
|
||||
"date_end": "Sun, 24 Dec 2023 20:40:25 GMT",
|
||||
"created_at": "Wed, 13 Jan 2021 18:49:04 GMT"
|
||||
},
|
||||
{
|
||||
"key": 63,
|
||||
"trip": "N",
|
||||
"count": 36,
|
||||
"date_start": "Tue, 29 Sep 2020 17:47:49 GMT",
|
||||
"date_end": "Mon, 12 Oct 2020 00:35:30 GMT",
|
||||
"created_at": "Sat, 05 Dec 2020 01:30:25 GMT"
|
||||
},
|
||||
{
|
||||
"key": 64,
|
||||
"trip": "Z",
|
||||
"count": 36,
|
||||
"date_start": "Tue, 26 May 2020 16:40:52 GMT",
|
||||
"date_end": "Mon, 08 Jun 2020 23:30:25 GMT",
|
||||
"created_at": "Thu, 25 Feb 2021 23:11:39 GMT"
|
||||
},
|
||||
{
|
||||
"key": 65,
|
||||
"trip": "Чита-Тольятти",
|
||||
"count": 61,
|
||||
"date_start": "Thu, 21 Sep 2023 19:54:17 GMT",
|
||||
"date_end": "Wed, 04 Oct 2023 02:45:15 GMT",
|
||||
"created_at": "Thu, 30 Mar 2023 15:33:19 GMT"
|
||||
},
|
||||
{
|
||||
"key": 66,
|
||||
"trip": "Владивосток-Вологда",
|
||||
"count": 42,
|
||||
"date_start": "Tue, 14 Apr 2020 15:50:34 GMT",
|
||||
"date_end": "Mon, 27 Apr 2020 22:40:20 GMT",
|
||||
"created_at": "Thu, 15 Apr 2021 02:35:05 GMT"
|
||||
},
|
||||
{
|
||||
"key": 67,
|
||||
"trip": "U",
|
||||
"count": 75,
|
||||
"date_start": "Thu, 10 Aug 2023 00:45:04 GMT",
|
||||
"date_end": "Wed, 23 Aug 2023 07:35:30 GMT",
|
||||
"created_at": "Wed, 09 Jun 2021 13:29:50 GMT"
|
||||
},
|
||||
{
|
||||
"key": 68,
|
||||
"trip": "Тюмень-Ижевск",
|
||||
"count": 73,
|
||||
"date_start": "Sat, 19 Mar 2022 00:00:59 GMT",
|
||||
"date_end": "Fri, 01 Apr 2022 06:50:40 GMT",
|
||||
"created_at": "Tue, 18 Apr 2023 11:39:45 GMT"
|
||||
},
|
||||
{
|
||||
"key": 69,
|
||||
"trip": "Казань-Тольятти",
|
||||
"count": 27,
|
||||
"date_start": "Thu, 15 Aug 2024 04:16:12 GMT",
|
||||
"date_end": "Wed, 28 Aug 2024 11:05:25 GMT",
|
||||
"created_at": "Wed, 02 Jun 2021 10:41:59 GMT"
|
||||
},
|
||||
{
|
||||
"key": 70,
|
||||
"trip": "Казань-Тольятти",
|
||||
"count": 85,
|
||||
"date_start": "Sun, 27 Sep 2020 23:18:20 GMT",
|
||||
"date_end": "Sat, 10 Oct 2020 06:05:15 GMT",
|
||||
"created_at": "Tue, 13 Apr 2021 06:32:10 GMT"
|
||||
},
|
||||
{
|
||||
"key": 71,
|
||||
"trip": "Москва-Тюмень",
|
||||
"count": 85,
|
||||
"date_start": "Mon, 23 Sep 2024 22:49:15 GMT",
|
||||
"date_end": "Sun, 06 Oct 2024 05:40:30 GMT",
|
||||
"created_at": "Sat, 09 Dec 2023 04:25:00 GMT"
|
||||
},
|
||||
{
|
||||
"key": 72,
|
||||
"trip": "Владисвосток-Москва",
|
||||
"count": 54,
|
||||
"date_start": "Fri, 05 Mar 2021 19:03:42 GMT",
|
||||
"date_end": "Thu, 18 Mar 2021 01:50:25 GMT",
|
||||
"created_at": "Sat, 07 Dec 2024 02:38:40 GMT"
|
||||
},
|
||||
{
|
||||
"key": 73,
|
||||
"trip": "Москва-Пермь",
|
||||
"count": 93,
|
||||
"date_start": "Wed, 16 Feb 2022 08:53:29 GMT",
|
||||
"date_end": "Tue, 01 Mar 2022 15:40:15 GMT",
|
||||
"created_at": "Mon, 18 Jan 2021 12:32:30 GMT"
|
||||
},
|
||||
{
|
||||
"key": 74,
|
||||
"trip": "Казань-Тольятти",
|
||||
"count": 11,
|
||||
"date_start": "Wed, 27 Jan 2021 14:00:04 GMT",
|
||||
"date_end": "Tue, 09 Feb 2021 20:50:30 GMT",
|
||||
"created_at": "Wed, 22 Apr 2020 14:49:30 GMT"
|
||||
},
|
||||
{
|
||||
"key": 75,
|
||||
"trip": "Казань-Москва",
|
||||
"count": 92,
|
||||
"date_start": "Wed, 23 Aug 2023 11:38:30 GMT",
|
||||
"date_end": "Tue, 05 Sep 2023 18:25:15 GMT",
|
||||
"created_at": "Sat, 21 May 2022 15:04:36 GMT"
|
||||
},
|
||||
{
|
||||
"key": 76,
|
||||
"trip": "Москва-Пермь",
|
||||
"count": 7,
|
||||
"date_start": "Tue, 31 May 2022 05:01:43 GMT",
|
||||
"date_end": "Mon, 13 Jun 2022 11:50:30 GMT",
|
||||
"created_at": "Tue, 22 Oct 2024 05:05:05 GMT"
|
||||
},
|
||||
{
|
||||
"key": 77,
|
||||
"trip": "Самара-Тюмень",
|
||||
"count": 71,
|
||||
"date_start": "Thu, 25 Jan 2024 10:34:25 GMT",
|
||||
"date_end": "Wed, 07 Feb 2024 17:20:15 GMT",
|
||||
"created_at": "Tue, 10 Mar 2020 22:40:26 GMT"
|
||||
},
|
||||
{
|
||||
"key": 78,
|
||||
"trip": "Москва-Пермь",
|
||||
"count": 70,
|
||||
"date_start": "Thu, 05 Jan 2023 11:57:02 GMT",
|
||||
"date_end": "Wed, 18 Jan 2023 18:45:30 GMT",
|
||||
"created_at": "Wed, 27 Sep 2023 08:44:01 GMT"
|
||||
},
|
||||
{
|
||||
"key": 79,
|
||||
"trip": "Тюмень-Казань",
|
||||
"count": 68,
|
||||
"date_start": "Fri, 18 Mar 2022 04:00:41 GMT",
|
||||
"date_end": "Thu, 31 Mar 2022 10:50:25 GMT",
|
||||
"created_at": "Tue, 25 Apr 2023 02:53:24 GMT"
|
||||
},
|
||||
{
|
||||
"key": 80,
|
||||
"trip": "Москва-Владивосток",
|
||||
"count": 32,
|
||||
"date_start": "Thu, 09 Mar 2023 20:34:19 GMT",
|
||||
"date_end": "Wed, 22 Mar 2023 03:25:15 GMT",
|
||||
"created_at": "Sun, 05 May 2024 13:34:05 GMT"
|
||||
},
|
||||
{
|
||||
"key": 81,
|
||||
"trip": "Москва-Владивосток",
|
||||
"count": 32,
|
||||
"date_start": "Fri, 09 Dec 2022 00:39:37 GMT",
|
||||
"date_end": "Thu, 22 Dec 2022 07:30:25 GMT",
|
||||
"created_at": "Thu, 03 Oct 2024 18:33:04 GMT"
|
||||
},
|
||||
{
|
||||
"key": 82,
|
||||
"trip": "E",
|
||||
"count": 57,
|
||||
"date_start": "Mon, 21 Mar 2022 09:02:45 GMT",
|
||||
"date_end": "Sun, 03 Apr 2022 15:50:30 GMT",
|
||||
"created_at": "Sun, 16 Oct 2022 07:03:06 GMT"
|
||||
},
|
||||
{
|
||||
"key": 83,
|
||||
"trip": "P",
|
||||
"count": 5,
|
||||
"date_start": "Tue, 05 Mar 2024 13:06:40 GMT",
|
||||
"date_end": "Mon, 18 Mar 2024 19:55:25 GMT",
|
||||
"created_at": "Sun, 18 Apr 2021 02:55:13 GMT"
|
||||
},
|
||||
{
|
||||
"key": 84,
|
||||
"trip": "Казань-Сызрань",
|
||||
"count": 42,
|
||||
"date_start": "Sun, 11 Aug 2024 02:05:46 GMT",
|
||||
"date_end": "Sat, 24 Aug 2024 08:55:30 GMT",
|
||||
"created_at": "Sun, 18 Oct 2020 18:39:33 GMT"
|
||||
},
|
||||
{
|
||||
"key": 85,
|
||||
"trip": "Казань-Тольятти",
|
||||
"count": 48,
|
||||
"date_start": "Fri, 07 Aug 2020 09:21:46 GMT",
|
||||
"date_end": "Thu, 20 Aug 2020 16:10:30 GMT",
|
||||
"created_at": "Wed, 22 Jul 2020 21:59:15 GMT"
|
||||
},
|
||||
{
|
||||
"key": 86,
|
||||
"trip": "Самара-Казань",
|
||||
"count": 43,
|
||||
"date_start": "Thu, 03 Dec 2020 21:51:58 GMT",
|
||||
"date_end": "Wed, 16 Dec 2020 04:40:25 GMT",
|
||||
"created_at": "Mon, 07 Nov 2022 18:42:21 GMT"
|
||||
},
|
||||
{
|
||||
"key": 87,
|
||||
"trip": "Москва-Тюмень",
|
||||
"count": 50,
|
||||
"date_start": "Mon, 20 Apr 2020 17:12:02 GMT",
|
||||
"date_end": "Sun, 03 May 2020 00:00:30 GMT",
|
||||
"created_at": "Fri, 10 Feb 2023 02:41:47 GMT"
|
||||
},
|
||||
{
|
||||
"key": 88,
|
||||
"trip": "Москва-Вологда",
|
||||
"count": 83,
|
||||
"date_start": "Mon, 22 Jan 2024 07:15:58 GMT",
|
||||
"date_end": "Sun, 04 Feb 2024 14:05:25 GMT",
|
||||
"created_at": "Mon, 27 Jan 2020 21:25:21 GMT"
|
||||
},
|
||||
{
|
||||
"key": 89,
|
||||
"trip": "Владивосток-Владимир",
|
||||
"count": 27,
|
||||
"date_start": "Wed, 08 Nov 2023 00:45:46 GMT",
|
||||
"date_end": "Tue, 21 Nov 2023 07:35:30 GMT",
|
||||
"created_at": "Sun, 03 Apr 2022 08:12:20 GMT"
|
||||
},
|
||||
{
|
||||
"key": 90,
|
||||
"trip": "S",
|
||||
"count": 75,
|
||||
"date_start": "Fri, 06 Aug 2021 21:40:09 GMT",
|
||||
"date_end": "Thu, 19 Aug 2021 04:30:25 GMT",
|
||||
"created_at": "Mon, 27 Dec 2021 10:19:20 GMT"
|
||||
},
|
||||
{
|
||||
"key": 91,
|
||||
"trip": "U",
|
||||
"count": 30,
|
||||
"date_start": "Sun, 08 Mar 2020 17:12:41 GMT",
|
||||
"date_end": "Sat, 21 Mar 2020 00:00:30 GMT",
|
||||
"created_at": "Mon, 22 Aug 2022 15:00:22 GMT"
|
||||
},
|
||||
{
|
||||
"key": 92,
|
||||
"trip": "Москва-Ижевск",
|
||||
"count": 46,
|
||||
"date_start": "Sat, 23 May 2020 10:41:01 GMT",
|
||||
"date_end": "Fri, 05 Jun 2020 17:30:25 GMT",
|
||||
"created_at": "Wed, 06 Jul 2022 06:39:05 GMT"
|
||||
},
|
||||
{
|
||||
"key": 93,
|
||||
"trip": "U",
|
||||
"count": 16,
|
||||
"date_start": "Fri, 26 May 2023 09:51:29 GMT",
|
||||
"date_end": "Thu, 08 Jun 2023 16:40:15 GMT",
|
||||
"created_at": "Sat, 26 Feb 2022 08:38:00 GMT"
|
||||
},
|
||||
{
|
||||
"key": 94,
|
||||
"trip": "Q",
|
||||
"count": 4,
|
||||
"date_start": "Wed, 08 Jul 2020 11:02:39 GMT",
|
||||
"date_end": "Tue, 21 Jul 2020 17:50:25 GMT",
|
||||
"created_at": "Fri, 24 Dec 2021 23:43:01 GMT"
|
||||
},
|
||||
{
|
||||
"key": 95,
|
||||
"trip": "Москва-Вологда",
|
||||
"count": 70,
|
||||
"date_start": "Mon, 10 Oct 2022 00:06:40 GMT",
|
||||
"date_end": "Sun, 23 Oct 2022 06:55:30 GMT",
|
||||
"created_at": "Sun, 28 Jul 2024 09:47:35 GMT"
|
||||
},
|
||||
{
|
||||
"key": 96,
|
||||
"trip": "N",
|
||||
"count": 80,
|
||||
"date_start": "Thu, 03 Oct 2024 02:27:30 GMT",
|
||||
"date_end": "Wed, 16 Oct 2024 09:15:25 GMT",
|
||||
"created_at": "Sun, 12 Feb 2023 12:07:51 GMT"
|
||||
},
|
||||
{
|
||||
"key": 97,
|
||||
"trip": "Тюмень-Вологда",
|
||||
"count": 40,
|
||||
"date_start": "Sat, 23 Mar 2024 21:42:40 GMT",
|
||||
"date_end": "Fri, 05 Apr 2024 04:30:30 GMT",
|
||||
"created_at": "Fri, 17 Mar 2023 15:47:32 GMT"
|
||||
},
|
||||
{
|
||||
"key": 98,
|
||||
"trip": "Москва-Пермь",
|
||||
"count": 38,
|
||||
"date_start": "Thu, 14 Jul 2022 14:03:39 GMT",
|
||||
"date_end": "Wed, 27 Jul 2022 20:50:25 GMT",
|
||||
"created_at": "Sat, 30 Nov 2024 07:09:52 GMT"
|
||||
},
|
||||
{
|
||||
"key": 99,
|
||||
"trip": "Тюмень-Казань",
|
||||
"count": 74,
|
||||
"date_start": "Thu, 19 Mar 2020 04:40:50 GMT",
|
||||
"date_end": "Wed, 01 Apr 2020 11:30:30 GMT",
|
||||
"created_at": "Thu, 20 Aug 2020 13:58:54 GMT"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
const router = require('express').Router();
|
||||
|
||||
router.use('/customer', require('./dashboard-customer'))
|
||||
router.use('/performer', require('./dashboard-performer'))
|
||||
router.use('/auth', require('./auth'))
|
||||
router.use('/landing', require('./landing'))
|
||||
|
||||
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
Reference in New Issue
Block a user