Compare commits

...

46 Commits

Author SHA1 Message Date
RustamRu
78b72b0edc feat: add GigaChat model retrieval for enhanced image analysis 2025-03-14 08:34:16 +03:00
RustamRu
333fe79c8b fix: change carColor type to Mixed for improved flexibility 2025-03-12 17:56:46 +03:00
RustamRu
9d10c8501a Merge branch 'master' of ssh://85.143.175.152:222/bro-students/multy-stub 2025-03-12 17:41:54 +03:00
RustamRu
d64ece382a fix: update car color validation to handle both string and number types 2025-03-12 17:41:48 +03:00
Primakov Alexandr Alexandrovich
f91f821f86 fix navigation elements 2025-03-12 09:12:09 +03:00
Primakov Alexandr Alexandrovich
b5301f948a Еще правки с урлами и верстка 2025-03-12 00:37:32 +03:00
Primakov Alexandr Alexandrovich
dd589790c2 fix путей 2025-03-12 00:29:55 +03:00
Primakov Alexandr Alexandrovich
1fcc5ed70d init Questionnaire 2025-03-11 23:50:50 +03:00
RustamRu
41dbe81001 Merge branch 'master' of ssh://85.143.175.152:222/bro-students/multy-stub 2025-03-09 11:04:07 +03:00
RustamRu
7b685ad99e feat: add dynamic system prompt for car image analysis 2025-03-09 11:04:01 +03:00
2f1e1dc040 Merge pull request 'feature/master-date' (#108) from feature/master-date into master
Reviewed-on: #108
2025-03-09 09:43:26 +03:00
RustamRu
70e8a6877c change car img size limit 2025-03-04 19:04:38 +03:00
RustamRu
87fd3121f9 fix: get img value data 2025-03-03 20:41:55 +03:00
RustamRu
4f9434163e Merge branch 'master' of ssh://85.143.175.152:222/bro-students/multy-stub 2025-03-03 20:14:06 +03:00
RustamRu
350d452a7b fix file convert to base64 2025-03-03 20:13:51 +03:00
9a0669df13 feat: add find by id 2025-03-03 20:08:28 +03:00
RustamRu
c0883fc2bc add get token, fix prompt 2025-03-03 19:49:11 +03:00
566bce4663 feat: delete image 2025-03-03 19:46:10 +03:00
c828718498 feat: add today filter 2025-03-03 19:36:56 +03:00
RustamRu
69c280b266 evaluate with ai car dirtiness by image 2025-03-03 18:21:32 +03:00
6794b01ac8 feat: add fetch image
Some checks failed
ms-devops/pipeline/head There was a failure building this commit
2025-02-23 12:32:53 +03:00
1cb586f55a Merge pull request 'feat: upload car image' (#106) from feature/dry/wash-car-image-upload into master
Some checks failed
ms-devops/pipeline/head There was a failure building this commit
Reviewed-on: #106
2025-02-23 12:22:27 +03:00
RustamRu
df21879c0d feat: upload car image
Some checks failed
ms-devops/pipeline/head There was a failure building this commit
ms-devops/pipeline/pr-master There was a failure building this commit
2025-02-22 16:58:58 +03:00
Primakov Alexandr Alexandrovich
30c9c86c93 Merge branch 'master' of ssh://85.143.175.152:222/bro-students/multy-stub 2025-02-10 22:19:46 +03:00
Primakov Alexandr Alexandrovich
2925d0f17b fix eslint 2025-02-10 22:19:01 +03:00
Primakov Alexandr Alexandrovich
752dabd015 fix eslint 2025-02-10 22:13:55 +03:00
Primakov Alexandr Alexandrovich
815f11d5bc add nav router 2025-02-10 22:07:54 +03:00
02eb0e60b7 Merge pull request 'Fix paths' (#105) from dsf-fix-1 into master
Reviewed-on: #105
2025-02-08 14:55:33 +03:00
a64ac93935 Fix paths 2025-02-08 14:54:57 +03:00
66a48d1c7e Merge pull request 'Add queries to dogsitter-viewing' (#104) from dsf-fix-1 into master
Reviewed-on: #104
2025-02-08 14:34:34 +03:00
26c66f16b4 Add queries to dogsitter-viewing 2025-02-08 14:33:29 +03:00
02e50bb2f9 Merge pull request 'Update users.json' (#103) from dsf-fix-1 into master
Reviewed-on: #103
2025-02-08 14:26:13 +03:00
fadc62c8f0 Update users.json 2025-02-08 14:25:29 +03:00
4759f6f7ee Merge pull request 'Uncomment dogsitters app' (#102) from dsf-fix-1 into master
Reviewed-on: #102
2025-02-08 13:45:14 +03:00
14f2164a82 Uncomment dogsitters app 2025-02-08 13:44:34 +03:00
14ef1f9bad Merge pull request 'Fix export' (#101) from dsf-fix-1 into master
Reviewed-on: #101
2025-02-08 13:28:29 +03:00
dc99318ff0 Fix export 2025-02-08 13:27:38 +03:00
d2fc5f4d5c Merge pull request 'Fix dogsitters backend' (#100) from dsf-fix-1 into master
Reviewed-on: #100
2025-02-08 13:17:00 +03:00
938bd48fff Fix dogsitters backend 2025-02-08 13:15:02 +03:00
96f819dc91 Merge pull request 'fix' (#99) from sberhubproject into master
Reviewed-on: #99
2025-02-08 13:12:46 +03:00
25eee8adf5
fix 2025-02-08 13:10:58 +03:00
d2b2a29d3d Merge pull request 'Remove backend files' (#98) from dsf-fix-1 into master
Reviewed-on: #98
2025-02-08 13:10:27 +03:00
1cf71261d1 Remove backend files 2025-02-08 13:08:55 +03:00
88552eb04f Merge pull request 'Uncomment dogsitters app' (#97) from dsf-fix into master
Reviewed-on: #97
2025-02-08 12:49:30 +03:00
ab92c99321 Uncomment dogsitters app 2025-02-08 12:47:43 +03:00
02963de893 Merge pull request 'Изменение путей запросов' (#96) from dogsitters-finder-3 into master
Reviewed-on: #96
2025-02-08 12:41:38 +03:00
33 changed files with 9018 additions and 1865 deletions

135
package-lock.json generated
View File

@ -27,6 +27,7 @@
"mongoose": "^8.9.2", "mongoose": "^8.9.2",
"mongoose-sequence": "^6.0.1", "mongoose-sequence": "^6.0.1",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"pbkdf2-password": "^1.2.1", "pbkdf2-password": "^1.2.1",
"rotating-file-stream": "^3.2.5", "rotating-file-stream": "^3.2.5",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",
@ -2084,6 +2085,12 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/aproba": { "node_modules/aproba": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz",
@ -2443,9 +2450,19 @@
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@ -2708,6 +2725,21 @@
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
}, },
"node_modules/concat-stream": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
"engines": [
"node >= 0.8"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^2.2.2",
"typedarray": "^0.0.6"
}
},
"node_modules/console-control-strings": { "node_modules/console-control-strings": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
@ -2774,6 +2806,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/cors": { "node_modules/cors": {
"version": "2.8.5", "version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
@ -4578,6 +4616,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/isexe": { "node_modules/isexe": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -5841,6 +5885,15 @@
"node": "*" "node": "*"
} }
}, },
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/minipass": { "node_modules/minipass": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
@ -6150,6 +6203,36 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
}, },
"node_modules/multer": {
"version": "1.4.5-lts.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz",
"integrity": "sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.0.0",
"concat-stream": "^1.5.2",
"mkdirp": "^0.5.4",
"object-assign": "^4.1.1",
"type-is": "^1.6.4",
"xtend": "^4.0.0"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/multer/node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.8", "version": "3.3.8",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
@ -6707,6 +6790,12 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/prompts": { "node_modules/prompts": {
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
@ -6834,6 +6923,27 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/readable-stream/node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@ -7414,6 +7524,14 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": { "node_modules/string_decoder": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
@ -7806,6 +7924,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/uid-safe": { "node_modules/uid-safe": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz",
@ -8109,6 +8233,15 @@
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="
}, },
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/y18n": { "node_modules/y18n": {
"version": "5.0.8", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@ -41,6 +41,7 @@
"mongoose": "^8.9.2", "mongoose": "^8.9.2",
"mongoose-sequence": "^6.0.1", "mongoose-sequence": "^6.0.1",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"pbkdf2-password": "^1.2.1", "pbkdf2-password": "^1.2.1",
"rotating-file-stream": "^3.2.5", "rotating-file-stream": "^3.2.5",
"socket.io": "^4.8.1", "socket.io": "^4.8.1",

View File

@ -81,7 +81,7 @@ app.use(require("./root"))
app.use("/kfu-m-24-1", require("./routers/kfu-m-24-1")) app.use("/kfu-m-24-1", require("./routers/kfu-m-24-1"))
app.use("/epja-2024-1", require("./routers/epja-2024-1")) app.use("/epja-2024-1", require("./routers/epja-2024-1"))
app.use("/v1/todo", require("./routers/todo")) app.use("/v1/todo", require("./routers/todo"))
// app.use("/dogsitters-finder", require("./routers/dogsitters-finder")) app.use("/dogsitters-finder", require("./routers/dogsitters-finder"))
app.use("/kazan-explore", require("./routers/kazan-explore")) app.use("/kazan-explore", require("./routers/kazan-explore"))
app.use("/edateam", require("./routers/edateam-legacy")) app.use("/edateam", require("./routers/edateam-legacy"))
app.use("/dry-wash", require("./routers/dry-wash")) app.use("/dry-wash", require("./routers/dry-wash"))
@ -90,6 +90,7 @@ app.use("/dhs-testing", require("./routers/dhs-testing"))
app.use("/gamehub", require("./routers/gamehub")) app.use("/gamehub", require("./routers/gamehub"))
app.use("/esc", require("./routers/esc")) app.use("/esc", require("./routers/esc"))
app.use('/connectme', require('./routers/connectme')) app.use('/connectme', require('./routers/connectme'))
app.use('/questioneer', require('./routers/questioneer'))
app.use(require("./error")) app.use(require("./error"))

View 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
};

View File

@ -1,74 +0,0 @@
const { Router } = require("express");
const hash = require("pbkdf2-password")();
const { promisify } = require("node:util");
const jwt = require('jsonwebtoken')
const { getAnswer } = require("../../utils/common");
const { AuthModel } = require("./model/todo/auth");
const { TOKEN_KEY } = require('./const')
const { UserModel } = require("./model/todo/user");
const { requiredValidate } = require('./utils')
const router = Router();
router.post(
"/signup",
requiredValidate("login", "password", "email"),
async (req, res, next) => {
const { login, password, email } = req.body
const user = await AuthModel.findOne({ login });
if (user) {
throw new Error("Пользователь с таким логином уже существует");
}
hash({ password }, async function (err, pass, salt, hash) {
if (err) return next(err);
const user = await UserModel.create({ login, email });
await AuthModel.create({ login, hash, salt, userId: user.id });
res.json(getAnswer(null, { ok: true }))
})
}
)
function authenticate(login, pass, cb) {
AuthModel.findOne({ login }).populate('userId').exec().then((user) => {
if (!user) return cb(null, null)
hash({ password: pass, salt: user.salt }, function (err, pass, salt, hash) {
if (err) return cb(err)
if (hash === user.hash) return cb(null, user)
cb(null, null)
})
})
}
const auth = promisify(authenticate)
router.post('/signin', requiredValidate('login', 'password'), async (req, res) => {
const { login, password } = req.body
const user = await auth(login, password)
if (!user) {
throw new Error("Неверный логин или пароль")
}
const accessToken = jwt.sign({
...JSON.parse(JSON.stringify(user.userId)),
}, TOKEN_KEY, {
expiresIn: '12h'
})
res.json(getAnswer(null, {
user: user.userId,
token: accessToken,
}))
})
module.exports = router

View File

@ -1,3 +1,2 @@
exports.DSF_AUTH_PASSWD_MODEL_NAME = 'DSF_AUTH_PASSWD'
exports.DSF_AUTH_USER_MODEL_NAME = 'DSF_AUTH_USER' exports.DSF_AUTH_USER_MODEL_NAME = 'DSF_AUTH_USER'
exports.DSF_INTERACTION_MODEL_NAME = 'DSF_INTERACTION' exports.DSF_INTERACTION_MODEL_NAME = 'DSF_INTERACTION'

View File

@ -67,3 +67,162 @@ router.get("/auth/session", (request, response) => {
return response.status(403).json({ error: "Invalid token" }); return response.status(403).json({ error: "Invalid token" });
} }
}); });
// Проверка взаимодействия между пользователем и догситтером
router.get("/interactions/check", (req, res) => {
const { owner_id, dogsitter_id } = req.query;
const usersFilePath = path.resolve(__dirname, "./json/users/users.json");
delete require.cache[require.resolve(usersFilePath)];
const usersFile = require(usersFilePath);
const interactions = usersFile.interactions || [];
const exists = interactions.some(
(interaction) =>
interaction.owner_id === Number(owner_id) &&
interaction.dogsitter_id === Number(dogsitter_id)
);
res.json({ exists });
});
// Добавление нового взаимодействия
router.post("/interactions", (req, res) => {
const { owner_id, dogsitter_id, interaction_type } = req.body;
if (!owner_id || !dogsitter_id || !interaction_type) {
return res.status(400).json({ error: "Missing required fields" });
}
const usersFilePath = path.resolve(__dirname, "./json/users/users.json");
delete require.cache[require.resolve(usersFilePath)];
const usersFile = require(usersFilePath);
if (!usersFile.interactions) {
usersFile.interactions = [];
}
// Проверяем, существует ли уже такое взаимодействие
const exists = usersFile.interactions.some(
(interaction) =>
interaction.owner_id === Number(owner_id) &&
interaction.dogsitter_id === Number(dogsitter_id)
);
if (!exists) {
usersFile.interactions.push({
owner_id: Number(owner_id),
dogsitter_id: Number(dogsitter_id),
interaction_type,
});
fs.writeFileSync(
usersFilePath,
JSON.stringify(usersFile, null, 2),
"utf8"
);
console.log(
`Добавлено взаимодействие: owner_id=${owner_id}, dogsitter_id=${dogsitter_id}`
);
}
res.json({ success: true });
});
router.get("/dogsitter-viewing", (req, res) => {
const { id } = req.query;
console.log(`Получен запрос для dogsitter с ID: ${id}`);
const usersFile = require("./json/users/users.json");
const users = usersFile.data; // Извлекаем массив из свойства "data"
const user = users.find((user) => user.id === Number(id));
if (user) {
res.json(user); // Возвращаем найденного пользователя
} else {
res.status(404).json({ error: "User not found" }); // Если пользователь не найден
}
});
const fs = require('fs');
const path = require('path');
router.post('/dogsitter-viewing/rating/:id', (req, res) => {
const { id } = req.params;
const { rating } = req.body;
if (!rating || rating < 1 || rating > 5) {
return res.status(400).json({ error: 'Некорректная оценка' });
}
const usersFilePath = path.resolve(__dirname, "./json/users/users.json");
delete require.cache[require.resolve(usersFilePath)];
const usersFile = require(usersFilePath);
const users = usersFile.data;
const userIndex = users.findIndex(user => user.id === Number(id));
if (userIndex === -1) {
return res.status(404).json({ error: 'Догситтер не найден' });
}
if (!users[userIndex].ratings) {
users[userIndex].ratings = [];
}
users[userIndex].ratings.push(rating);
if (users[userIndex].ratings.length > 100) {
users[userIndex].ratings.shift();
}
const total = users[userIndex].ratings.reduce((sum, r) => sum + r, 0);
users[userIndex].rating = parseFloat((total / users[userIndex].ratings.length).toFixed(2));
fs.writeFileSync(usersFilePath, JSON.stringify({ data: users }, null, 2), 'utf8');
console.log(`Обновлен рейтинг догситтера ${id}: ${users[userIndex].rating}`);
res.json({ rating: users[userIndex].rating, ratings: users[userIndex].ratings });
});
router.patch('/users/:id', (req, res) => {
const { id } = req.params;
const updateData = req.body;
console.log('Полученные данные для обновления:', updateData);
const usersFilePath = path.resolve(__dirname, "./json/users/users.json");
delete require.cache[require.resolve(usersFilePath)];
const usersFile = require(usersFilePath);
const users = usersFile.data;
const userIndex = users.findIndex((user) => user.id === Number(id));
if (userIndex === -1) {
return res.status(404).json({ error: 'User not found' });
}
users[userIndex] = { ...users[userIndex], ...updateData };
fs.writeFileSync(
usersFilePath,
JSON.stringify({ data: users }, null, 2),
'utf8'
);
console.log('Обновлённые данные пользователя:', users[userIndex]);
res.json(users[userIndex]);
});
module.exports = router

View File

@ -1,13 +1,20 @@
[ {
"data": [
{ {
"id": 1, "id": 1,
"phone_number": 89283244141, "phone_number": "89999999999",
"first_name": "Вася", "first_name": "Вася",
"second_name": "Пупкин", "second_name": "Пупкин",
"role": "dogsitter", "role": "dogsitter",
"location": "Россия, республика Татарстан, Казань, улица Пушкина, 12", "location": "Россия, республика Татарстан, Казань, Пушкина, 12",
"price": 1500, "price": "1500",
"about_me": "Я люблю собак" "about_me": "Я люблю собак!",
"rating": 5,
"ratings": [
5,
5
],
"tg": "jullllllie"
}, },
{ {
"id": 2, "id": 2,
@ -16,12 +23,18 @@
"second_name": "Пуськин", "second_name": "Пуськин",
"role": "dogsitter", "role": "dogsitter",
"location": "Россия, республика Татарстан, Казань, улица Абсалямова, 19", "location": "Россия, республика Татарстан, Казань, улица Абсалямова, 19",
"price": 1000000, "price": 2000,
"about_me": "Я не люблю собак. И вообще я котоман." "about_me": "Я не люблю собак. И вообще я котоман.",
"rating": 4,
"ratings": [
4,
4
],
"tg": "vanya006"
}, },
{ {
"id": 3, "id": 3,
"phone_number": 89872855893, "phone_number": 89559999999,
"first_name": "Гадий", "first_name": "Гадий",
"second_name": "Петрович", "second_name": "Петрович",
"role": "owner" "role": "owner"
@ -33,7 +46,24 @@
"second_name": "Максим", "second_name": "Максим",
"role": "dogsitter", "role": "dogsitter",
"location": "Россия, республика Татарстан, Казань, проспект Ямашева, 83", "location": "Россия, республика Татарстан, Казань, проспект Ямашева, 83",
"price": 1000000, "price": 1750,
"about_me": "Миллион алых роз" "about_me": "Миллион алых роз",
"rating": 4.5,
"ratings": [
4,
5
],
"tg": "maks100500"
}
],
"interactions": [
{
"owner_id": 3,
"dogsitter_id": 4
},
{
"owner_id": 1,
"dogsitter_id": 2
} }
] ]
}

View File

@ -1,44 +0,0 @@
const { Schema, model } = require("mongoose");
const {
DSF_AUTH_PASSWD_MODEL_NAME,
DSF_AUTH_USER_MODEL_NAME,
} = require("../../const");
const schema = new Schema({
login: {
type: String,
required: true,
unique: true
},
hash: {
type: String,
required: true
},
salt: {
type: String,
required: true
},
userId: {
type: Schema.Types.ObjectId,
ref: DSF_AUTH_USER_MODEL_NAME
},
created: {
type: Date,
default: () => new Date().toISOString(),
},
});
schema.set("toJSON", {
virtuals: true,
versionKey: false,
transform: function (doc, ret) {
delete ret._id;
},
});
schema.virtual("id").get(function () {
return this._id.toHexString();
});
exports.AuthModel = model(DSF_AUTH_PASSWD_MODEL_NAME, schema);

View File

@ -1,13 +1,24 @@
const router = require('express').Router() const router = require("express").Router();
const {MasterModel} = require('./model/master') const { MasterModel } = require("./model/master");
const mongoose = require("mongoose") const mongoose = require("mongoose");
const {OrderModel} = require("./model/order") const { OrderModel } = require("./model/order");
router.get("/masters", async (req, res, next) => { router.get("/masters", async (req, res, next) => {
try { try {
const masters = await MasterModel.find({}); const masters = await MasterModel.find({});
const orders = await OrderModel.find({});
// Создаем объекты для начала и конца текущего дня
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const orders = await OrderModel.find({
startWashTime: {
$gte: today,
$lt: tomorrow,
},
});
const mastersWithOrders = masters.map((master) => { const mastersWithOrders = masters.map((master) => {
const masterOrders = orders.filter((order) => { const masterOrders = orders.filter((order) => {
@ -36,11 +47,11 @@ router.get("/masters", async (req, res, next) => {
} }
}); });
router.delete('/masters/:id', async (req, res,next) => { router.delete("/masters/:id", async (req, res, next) => {
const { id } = req.params; const { id } = req.params;
if (!mongoose.Types.ObjectId.isValid(id)) { if (!mongoose.Types.ObjectId.isValid(id)) {
throw new Error('ID is required') throw new Error("ID is required");
} }
try { try {
@ -48,58 +59,52 @@ router.delete('/masters/:id', async (req, res,next) => {
new: true, new: true,
}); });
if (!master) { if (!master) {
throw new Error('master not found') throw new Error("master not found");
} }
res.status(200).send({success: true, body: master}) res.status(200).send({ success: true, body: master });
} catch (error) { } catch (error) {
next(error) next(error);
} }
}) });
router.post("/masters", async (req, res, next) => {
router.post('/masters', async (req, res,next) => { const { name, phone } = req.body;
const {name, phone} = req.body
if (!name || !phone) { if (!name || !phone) {
throw new Error('Enter name and phone') throw new Error("Enter name and phone");
} }
try { try {
const master = await MasterModel.create({name, phone}) const master = await MasterModel.create({ name, phone });
res.status(200).send({success: true, body: master}) res.status(200).send({ success: true, body: master });
} catch (error) { } catch (error) {
next(error) next(error);
} }
}) });
router.patch("/masters/:id", async (req, res, next) => {
router.patch('/masters/:id', async (req, res, next) => {
const { id } = req.params; const { id } = req.params;
if (!mongoose.Types.ObjectId.isValid(id)) { if (!mongoose.Types.ObjectId.isValid(id)) {
throw new Error('ID is required') throw new Error("ID is required");
} }
const { name, phone } = req.body; const { name, phone } = req.body;
if (!name && !phone) { if (!name && !phone) {
throw new Error('Enter name and phone') throw new Error("Enter name and phone");
} }
try { try {
const updateData = {}; const updateData = {};
if (name) updateData.name = name; if (name) updateData.name = name;
if (phone) updateData.phone = phone; if (phone) updateData.phone = phone;
const master = await MasterModel.findByIdAndUpdate( const master = await MasterModel.findByIdAndUpdate(id, updateData, {
id, new: true,
updateData, });
{ new: true }
);
if (!master) { if (!master) {
throw new Error('master not found') throw new Error("master not found");
} }
res.status(200).send({ success: true, body: master }); res.status(200).send({ success: true, body: master });
@ -108,4 +113,4 @@ router.patch('/masters/:id', async (req, res, next) => {
} }
}); });
module.exports = router module.exports = router;

View File

@ -0,0 +1,23 @@
const getGigaToken = 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_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,
getSystemPrompt,
getGigaChatModel
}

View File

@ -0,0 +1,29 @@
const { Schema, model } = require('mongoose')
const schema = new Schema({
image: String,
imageRating: String,
imageDescription: String,
orderId: {
type: Schema.Types.ObjectId,
ref: 'dry-wash-order'
},
created: {
type: Date,
default: () => new Date().toISOString(),
},
})
schema.set('toJSON', {
virtuals: true,
versionKey: false,
transform(_doc, ret) {
delete ret._id
}
})
schema.virtual('id').get(function () {
return this._id.toHexString()
})
exports.OrderCarImgModel = model('dry-wash-order-car-image', schema)

View File

@ -15,7 +15,7 @@ const schema = new Schema({
type: Number, type: Number,
required: true required: true
}, },
carColor: String, carColor: Schema.Types.Mixed,
startWashTime: { startWashTime: {
type: Date, type: Date,
required: true required: true

View File

@ -1,13 +1,23 @@
const mongoose = require("mongoose") const mongoose = require("mongoose")
const router = require('express').Router() const router = require('express').Router()
const multer = require('multer')
const { MasterModel } = require('./model/master') const { MasterModel } = require('./model/master')
const { OrderModel } = require('./model/order') const { OrderModel } = require('./model/order')
const { OrderCarImgModel } = require('./model/order.car-img')
const { orderStatus } = require('./model/const') const { orderStatus } = require('./model/const')
const { getGigaToken, getSystemPrompt, getGigaChatModel } = require('./get-token')
const isValidPhoneNumber = (value) => /^(\+)?\d{9,15}/.test(value) const isValidPhoneNumber = (value) => /^(\+)?\d{9,15}/.test(value)
const isValidCarNumber = (value) => /^[авекмнорстух][0-9]{3}[авекмнорстух]{2}[0-9]{2,3}$/i.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 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 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})?)$/ const latitudeRe = /^(-?[1-8]?\d(?:\.\d{1,18})?|90(?:\.0{1,18})?)$/
@ -26,6 +36,9 @@ const isValidLocation = (value) => {
const isValidOrderStatus = (value) => Object.values(orderStatus).includes(value) const isValidOrderStatus = (value) => Object.values(orderStatus).includes(value)
const isValidOrderNotes = (value) => value.length < 500 const isValidOrderNotes = (value) => value.length < 500
const allowedMimeTypes = ['image/jpeg', 'image/png']
const sizeLimitInMegaBytes = 15
const VALIDATION_MESSAGES = { const VALIDATION_MESSAGES = {
order: { order: {
notFound: 'Order not found' notFound: 'Order not found'
@ -60,6 +73,13 @@ const VALIDATION_MESSAGES = {
carColor: { carColor: {
invalid: 'Invalid car color' invalid: 'Invalid car color'
}, },
carImg: {
required: 'Car image file is required',
invalid: {
type: `Invalid car image file type. Allowed types: ${allowedMimeTypes}`,
size: `Invalid car image file size. Limit is ${sizeLimitInMegaBytes}MB`
}
},
washingBegin: { washingBegin: {
required: 'Begin time of washing is required', required: 'Begin time of washing is required',
invalid: 'Invalid begin time of washing' invalid: 'Invalid begin time of washing'
@ -143,17 +163,21 @@ router.post('/create', async (req, res, next) => {
router.get('/:id', async (req, res, next) => { router.get('/:id', async (req, res, next) => {
const { id } = req.params const { id } = req.params
if (!mongoose.Types.ObjectId.isValid(id)) { if (!mongoose.Types.ObjectId.isValid(id)) {
throw new Error(VALIDATION_MESSAGES.orderId.invalid) throw new Error(VALIDATION_MESSAGES.orderId.invalid)
} }
try { try {
const order = await OrderModel.findById(id) const order = await OrderModel.findById(id)
if (!order) { if (!order) {
throw new Error(VALIDATION_MESSAGES.order.notFound) 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) { } catch (error) {
next(error) next(error)
} }
@ -248,4 +272,167 @@ router.delete('/:id', async (req, res, next) => {
} }
}) })
const storage = multer.memoryStorage()
const upload = multer({
storage: storage,
limits: { fileSize: sizeLimitInMegaBytes * 1024 * 1024 },
fileFilter: (req, file, cb) => {
if (allowedMimeTypes.includes(file.mimetype)) {
cb(null, true)
} else {
cb(new Error(VALIDATION_MESSAGES.carImg.invalid.type), false)
}
}
})
const { v4: uuidv4 } = require("uuid")
const axios = require('axios')
const GIGA_CHAT_OAUTH = 'https://ngw.devices.sberbank.ru:9443/api/v2/oauth'
const GIGA_CHAT_API = 'https://gigachat.devices.sberbank.ru/api/v1'
const getToken = async (req, res) => {
const gigaToken = await getGigaToken()
const rqUID = uuidv4()
const body = new URLSearchParams({
scope: "GIGACHAT_API_PERS",
})
const response = await fetch(GIGA_CHAT_OAUTH, {
method: "POST",
headers: {
Authorization: `Basic ${gigaToken}`,
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
RqUID: rqUID,
},
body,
})
if (!response.ok) {
const errorData = await response.json()
console.error("Ошибка запроса: ", errorData)
return res.status(response.status).json(errorData)
}
return await response.json()
}
const uploadImage = async (file, accessToken) => {
const formData = new FormData()
const blob = new Blob([file.buffer], { type: file.mimetype })
formData.append('file', blob, file.originalname)
formData.append('purpose', 'general')
const config = {
maxBodyLength: Infinity,
headers: {
'Content-Type': 'multipart/form-data',
'Accept': 'application/json',
'Authorization': `Bearer ${accessToken}`
}
}
try {
const response = await axios.post(`${GIGA_CHAT_API}/files`, formData, config)
return response.data.id
} catch (error) {
console.error(error)
}
}
const analyzeImage = async (fileId, token) => {
const response = await fetch(`${GIGA_CHAT_API}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
model: (await getGigaChatModel()) ?? "GigaChat-Max",
stream: false,
update_interval: 0,
messages: [
{
role: "system",
content: (await getSystemPrompt()) ?? `Ты эксперт по оценке степени загрязнения автомобилей. Твоя задача — анализировать фотографии машин и определять степень их загрязнения. ВАЖНО: Твой ответ ДОЛЖЕН быть СТРОГО в формате JSON и содержать ТОЛЬКО следующие поля: { "value": число от 0 до 10 (целое или с одним знаком после запятой), "description": "текстовое описание на русском языке" } Правила: 1. Поле "value": - Должно быть числом от 0 до 10 - 0 = машина абсолютно чистая - 10 = машина максимально грязная 2. Поле "description": - Должно содержать 2-3 предложения на русском языке - Обязательно указать конкретные признаки загрязнения - Объяснить почему выставлен именно такой балл НЕ ДОБАВЛЯЙ никаких дополнительных полей или комментариев вне JSON структуры. НЕ ИСПОЛЬЗУЙ markdown форматирование. ОТВЕТ ДОЛЖЕН БЫТЬ ВАЛИДНЫМ JSON. Если на фотографии нет одной машины, то оценка должна быть 0 и в описании должно быть указано, почему не удалось оценить.`,
},
{
role: "user",
content: 'Дай оценку для приложенного файла изображения согласно структуре, ответ должен быть на русском языке',
attachments: [fileId],
},
],
}),
})
const data = await response.json()
console.log(data)
try {
return JSON.parse(data.choices[0].message.content)
} catch (error) {
console.error(error)
return { description: data.choices[0].message.content }
}
}
const convertFileToBase64 = (file) => {
const base64Image = file.buffer.toString('base64')
return `data:${file.mimetype};base64,${base64Image}`
}
process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0"
router.post('/:id/upload-car-img', upload.single('file'), async (req, res) => {
const { id: orderId } = req.params
if (!mongoose.Types.ObjectId.isValid(orderId)) {
throw new Error(VALIDATION_MESSAGES.orderId.invalid)
}
const order = await OrderModel.findById(orderId)
if (!order) {
throw new Error(VALIDATION_MESSAGES.order.notFound)
}
if (!req.file) {
throw new Error(VALIDATION_MESSAGES.carImg.required)
}
try {
await OrderCarImgModel.deleteMany({ orderId })
const { access_token } = await getToken(req, res)
const fileId = await uploadImage(req.file, access_token)
const { value, description } = await analyzeImage(fileId, access_token) ?? {}
const orderCarImg = await OrderCarImgModel.create({
image: convertFileToBase64(req.file),
imageRating: value,
imageDescription: description,
orderId: order.id,
created: new Date().toISOString(),
})
res.status(200).send({ success: true, body: orderCarImg })
} catch (error) {
console.error(error)
}
})
router.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
switch (err.message) {
case 'File too large':
return res.status(400).json({ success: false, error: VALIDATION_MESSAGES.carImg.invalid.size })
default:
return res.status(400).json({ success: false, error: err.message })
}
}
throw new Error(err.message)
})
module.exports = router module.exports = router

File diff suppressed because it is too large Load Diff

View File

@ -607,8 +607,7 @@ function createGigachat(options = {}) {
} }
var gigachat = createGigachat(); var gigachat = createGigachat();
// Annotate the CommonJS export names for ESM import in node: // Annotate the CommonJS export names for ESM import in node:
0 && (module.exports = { module.exports = {
createGigachat, createGigachat,
gigachat gigachat
}); }
//# sourceMappingURL=index.js.map

View File

@ -84,7 +84,7 @@ router.use(async (req, res, next) => {
process.env.GIGACHAT_ACCESS_TOKEN = json.access_token; process.env.GIGACHAT_ACCESS_TOKEN = json.access_token;
process.env.GIGACHAT_EXPIRES_AT = json.expires_at; process.env.GIGACHAT_EXPIRES_AT = json.expires_at;
console.log(JSON.stringify(response.data)); console.log(JSON.stringify(response.data));
} catch { } catch (error) {
console.log(error); console.log(error);
} }
} }

View 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

View 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: Сообщение об ошибке

View 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>

View 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>

View 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>

View 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>

View 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>

File diff suppressed because it is too large Load Diff

View 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);
});

View 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: '&times;',
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;
}

View 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();
});

View 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();
});

View 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);
});

File diff suppressed because it is too large Load Diff

View File

@ -5,9 +5,11 @@ const router = Router()
const todoRouter = require('./routes') const todoRouter = require('./routes')
const authRouter = require('./auth') const authRouter = require('./auth')
const commentRouter = require('./comment') const commentRouter = require('./comment')
const navRouter = require('./nav')
router.use('/auth', authRouter) router.use('/auth', authRouter)
router.use('/comment', commentRouter) router.use('/comment', commentRouter)
router.use('/nav', navRouter)
router.use(todoRouter) router.use(todoRouter)

View File

@ -0,0 +1,51 @@
const router = require("express").Router();
router.get("/users", (req, res) => {
res.send({
success: false,
body: [
{
id: "some-user-id",
name: "alexandr",
age: 38,
surname: null,
email: null,
rated: 4,
avatar:
"https://www.gravatar.com/avatar/6529e885535ef67a3fad810ad71167c2c03f79480936e9b3a714731753cbb47e?d=robohash",
friends: [
{
id: "2",
name: "not alexandr",
surname: null,
email: null,
rated: 2,
avatar: "https://www.gravatar.com/avatar/6e?d=robohash",
},
],
},
{
id: "2",
name: "not alexandr",
surname: null,
email: null,
age: 24,
rated: 5,
avatar: "https://www.gravatar.com/avatar/6e?d=robohash",
friends: [
{
id: "some-user-id",
name: "alexandr",
surname: null,
email: null,
rated: 3,
avatar:
"https://www.gravatar.com/avatar/6529e885535ef67a3fad810ad71167c2c03f79480936e9b3a714731753cbb47e?d=robohash",
},
],
},
],
})
})
module.exports = router