feat: Initial project

This commit is contained in:
zhujunbei 2023-11-02 20:12:37 +08:00
commit 051791c538
345 changed files with 27644 additions and 0 deletions

26
.gitignore vendored Normal file
View File

@ -0,0 +1,26 @@
.DS_Store
node_modules
dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.history

0
CONTRIBUTING.md Normal file
View File

45
Dockerfile Normal file
View File

@ -0,0 +1,45 @@
# 镜像集成
FROM ubuntu:latest
# 安装依赖
RUN apt-get -y update
RUN apt-get -y install wget gcc
# 安装node环境
ENV NODE_VERSION v18.17.1
RUN mkdir -p /node/$NODE_VERSION
RUN wget https://nodejs.org/dist/$NODE_VERSION/node-$NODE_VERSION-linux-x64.tar.gz
RUN tar xzf node-$NODE_VERSION-linux-x64.tar.gz -C /node/
ENV PATH /node/node-$NODE_VERSION-linux-x64/bin:$PATH
# 设置工作区间
WORKDIR /xiaoju-survey
# 复制文件到工作区间
COPY . /xiaoju-survey
RUN npm config set registry https://registry.npmjs.org/
# 安装项目依赖
RUN cd /xiaoju-survey/web && npm install
RUN cd /xiaoju-survey/server && sh init.sh
# 构建项目,并把产物推送到服务公共目录
RUN cd /xiaoju-survey/web && npm run build
RUN cd /xiaoju-survey && cp -af ./web/dist/ ./server/src/apps/ui/public/
# 暴露端口
EXPOSE 8080
# docker入口文件,运行pm2启动,并保证监听不断
CMD ["sh","docker-run.sh"]
# 构建镜像
# docker build -t xiaoju-survey-app .
# 运行容器
# docker run --rm --name running-xiaoju-survey-app -p 8080:8080 xiaoju-survey-app
# 进入容器
# docker exec -it running-xiaoju-survey-app bash
# 停止容器
# docker stop running-xiaoju-survey-app
# 查看日志
# docker logs running-xiaoju-survey-app

201
LICENSE Normal file
View File

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

115
README.md Normal file
View File

@ -0,0 +1,115 @@
[<img src="https://img-hxy021.didistatic.com/static/starimg/img/j8lBA6yy201698840712358.jpg" width="300" />](https://xiaojusurvey.didi.cn)
**XiaoJuSurvey**是一套轻量、安全的问卷系统,提供面向个人和企业的一站式产品级解决方案,快速满足各类线上调研场景。
## 简介
平台在集团内部已沉淀了**40+**种题型,累积精选模板**100+**,适用于市场调研、客户满意度调研、在线考试、投票、报道、测评等众多场景。数据能力上,沉淀了数据表格、分题统计、交叉分析、多渠道分析等在线报表能力,快速满足回收数据专业化线上分析,协助业务挖掘数据价值。
本次开源主要围绕问卷生命周期提供了完整的产品化能力:
- 问卷管理:创、编、投、收、数据分析
- 多样化题型:单行输入框、多行输入框、单项选择、多项选择、判断题、评分、投票
_(更多题型将陆续开放,也欢迎您参与共建提交自定义题型)_
- 用户管理:登录、注册、权限管理
- 数据安全:传输加密、脱敏等
<img src="https://img-hxy021.didistatic.com/static/starimg/img/nJ5fyGhocH1698903177499.png" width="900" />
_**(个人和企业用户均可快速构建特定领域的调研类解决方案。)**_
## 项目优势
**一、具备全面的综合性和专业性**
- [制定了问卷标准化协议规范](https://xiaojusurvey.didi.cn/docs/agreement/%E3%80%8A%E9%97%AE%E5%8D%B7Meta%E5%8D%8F%E8%AE%AE%E3%80%8B)
领域标准保障概念互通,是全系统的基础和核心。基于实际业务经验,沉淀了两大类:
- 业务描述:问卷协议、题型协议
- 物料描述:题型物料协议,包含题型和设置器
- [制定了问卷UI/UX规范](https://xiaojusurvey.didi.cn/docs/design/%E3%80%8A%E8%AE%BE%E8%AE%A1%E8%A7%84%E8%8C%83%E3%80%8B)
设计语言是系统灵活性、一致性的基石,保障系统支撑的实际业务运转拥有极高的用户体验。包含两部分:
- 设计规范:灵活、降噪、统一
- 交互规范:遵循用户行为特征,遵循产品定位,遵循成熟的用户习惯
- [所见即所得,搭建渲染一致性高](https://xiaojusurvey.didi.cn/docs/document/%E8%AE%BE%E8%AE%A1%E5%8E%9F%E7%90%86/%E9%A2%98%E5%9E%8B%E5%9C%BA%E6%99%AF%E5%8C%96%E8%AE%BE%E8%AE%A1)
实际业务使用上包含问卷生成和投放使用,即对于系统的搭建端和渲染端。我们将题型场景化设计,以满足一份问卷从加工生产到投放应用的高度一致。
- [题型物料化设计,自由定制扩展](https://xiaojusurvey.didi.cn/docs/document/%E8%AE%BE%E8%AE%A1%E5%8E%9F%E7%90%86/%E9%A2%98%E5%9E%8B%E7%89%A9%E6%96%99%E5%8C%96%E8%AE%BE%E8%AE%A1/%E5%9F%BA%E7%A1%80%E8%AE%BE%E8%AE%A1)
题型是问卷最核心的组成部分,而题型可配置化能力决定了上层业务可扩展的场景以及系统自身可复用的场景。
题型架构设计上,主打每一类题型拥有通用基础能力,每一种题型拥有原子化特性能力,并保障高度定制化。
- [合规建设沉淀积累,安全能力拓展性高](https://xiaojusurvey.didi.cn/docs/document/%E6%95%B0%E6%8D%AE%E5%AE%89%E5%85%A8)
数据加密传输、敏感信息精细化检测、投票防刷等能力,保障问卷发布、数据回收链路安全性。
**二、轻量化设计,快速接入、灵活扩展**
- [产品级开源方案,快速产出一套调研流程](https://xiaojusurvey.didi.cn/docs/document/%E4%BA%A7%E5%93%81%E6%89%8B%E5%86%8C/%E6%A6%82%E8%BF%B0)
围绕问卷生命周期提供了完整的产品化能力,包含用户管理: 登录、注册、问卷权限,问卷管理: 创、编、投、收、数据分析,可快速构建特定领域的调研类解决方案。
- [问卷设计开箱即用,降低领域复杂度](https://xiaojusurvey.didi.cn/docs/document/%E8%AE%BE%E8%AE%A1%E5%8E%9F%E7%90%86/%E9%97%AE%E5%8D%B7%E6%90%AD%E5%BB%BA%E9%A2%86%E5%9F%9F%E5%8C%96%E8%AE%BE%E8%AE%A1)
问卷组成具有高灵活性,此业务特征带来问卷编辑能力的高复杂性设计。我们将问卷编辑划分为五大子领域,进行产品能力聚类,同时指导系统模块化设计和开发。基于模块编排和管理,能够开箱即用。
- [二次开发成本低,轻松定制专属调研系统](https://xiaojusurvey.didi.cn/docs/document/%E5%BC%80%E5%8F%91%E6%89%8B%E5%86%8C/%E5%B7%A5%E7%A8%8B%E9%85%8D%E7%BD%AE%E5%8C%96)
全系统设计原则基于协议标准化、功能模块化、管理配置化,并提供了一些列完整的文档和开发及扩展手册。
- [部署成本低,快速上线](https://xiaojusurvey.didi.cn/docs/document/%E5%B7%A5%E7%A8%8B%E9%83%A8%E7%BD%B2)
前后端分离提供Docker化方案提供了完善的部署指导手册。
## 快速启动
### 复制工程
```shell
git clone http://github.com/didi/xiaoju-survey
```
## 启动
### 后端启动
#### 安装数据库
详情查看 [环境准备](https://xiaojusurvey.didi.cn/docs/document/%E6%A6%82%E8%BF%B0/%E5%BF%AB%E9%80%9F%E5%BC%80%E5%A7%8B#%E5%AE%89%E8%A3%85%E6%95%B0%E6%8D%AE%E5%BA%93)
#### 安装依赖
```shell
cd server
sh init.sh
```
#### 启动
```shell
npm run dev
```
### 前端启动
#### 安装依赖
```shell
cd web
npm install
```
#### 启动
```shell
npm run serve
```
## 访问
### 问卷管理端
[http://localhost:8080/management](http://localhost:8080)
### 问卷投放端
创建并发布问卷:
[http://localhost:8080/render/:surveyPath](http://localhost:8080/render/:surveyPath)
## 交流群
[<img src="https://img-hxy021.didistatic.com/static/starimg/img/NSVCeskQL81698905740736.png" width="200" />](http://qm.qq.com/cgi-bin/qm/qr?_wv=1027&k=P61UJI_q8AzizyBLGOm-bUvzNrUnSQq-&authKey=yZFtL9biGB5yiIME3%2Bi%2Bf6XMOdTNiuf0pCIaviEEAIryySNzVy6LJ4xl7uHdEcrM&noverify=0&group_code=920623419)

3
docker-run.sh Normal file
View File

@ -0,0 +1,3 @@
#! /bin/bash
cd /xiaoju-survey/server
npm run start

106
server/.gitignore vendored Normal file
View File

@ -0,0 +1,106 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
package-lock.json
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and *not* Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# project
build/
src/apps/question/config/env/local.ts

18
server/README.md Normal file
View File

@ -0,0 +1,18 @@
# 问卷引擎服务端
问卷引擎服务端主要用于提供问卷的的管理端和发布端接口,其中管理端包括但不限于创建,编辑,修改,发布问卷接口。发布端主要为问卷提交接口和问卷信息获取接口,再通过问卷的渲染服务来渲染前端内容。
# 项目依赖安装
进入服务端目录运行init.sh来进行依赖安装
```sh
sh init.sh
```
# 项目的启动
开发模式的启动
```
npm run dev
```
正式环境的启动
```
npm run start
```
服务默认启动端口为8080

10
server/init.sh Normal file
View File

@ -0,0 +1,10 @@
#!/bin/bash
npm install
for appDir in `ls src/apps`
do
cd src/apps/$appDir
# 进行安装依赖
npm install
cd ../../../
done
cd ..

34
server/package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "survey-template",
"version": "1.0.0",
"description": "survey server template",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"copy": "cp -rf ./src/ ./build/",
"build": "tsc",
"launch": "npm run build && SERVER_ENV=local node ./build/index.js",
"start:stable": "SERVER_ENV=stable node ./build/index.js",
"start:preonline": "SERVER_ENV=preonline node ./build/index.js",
"start:online": "SERVER_ENV=online node ./build/index.js",
"start": "npm run copy && npm run launch",
"dev": "npm run copy && nodemon -e js,mjs,json,ts --exec 'npm run launch' --watch ./src"
},
"devDependencies": {
"nodemon": "^2.0.20",
"typescript": "^4.8.4"
},
"dependencies": {
"@types/koa": "^2.13.8",
"@types/koa-bodyparser": "^4.3.10",
"@types/koa-router": "^7.4.4",
"koa": "^2.14.2",
"koa-bodyparser": "^4.4.1",
"koa-pino-logger": "^4.0.0",
"koa-router": "^12.0.0"
},
"engines": {
"node": ">=14.15.4",
"npm": ">=6.14.10"
}
}

View File

@ -0,0 +1,10 @@
const config = {
mongo: {
url: process.env.xiaojuSurveyMongoUrl || 'mongodb://localhost:27017',
dbName: 'xiaojuSurvey'
}
}
export function getConfig() {
return config
}

View File

@ -0,0 +1,33 @@
import { Collection, MongoClient, ObjectId } from 'mongodb'
import { getConfig } from '../config/index'
const config = getConfig()
class mongoService {
isInit: boolean
client: MongoClient
constructor() {
this.client = new MongoClient(config.mongo.url);
}
async getCollection({ collectionName }): Promise<Collection> {
await this.client.connect()
return this.client.db(config.mongo.dbName).collection(collectionName)
}
convertId2StringByDoc(doc: any): any {
doc._id = doc._id.toString()
return doc;
}
convertId2StringByList(list: Array<any>): Array<any> {
return list.map(e => {
return this.convertId2StringByDoc(e);
})
}
getObjectIdByStr(str: string): ObjectId {
return new ObjectId(str)
}
}
export const mongo = new mongoService()

View File

@ -0,0 +1,16 @@
import { SurveyServer } from '../../decorator'
import { securityService } from './service/securityService'
export default class Security {
@SurveyServer({ type: 'rpc' })
async isHitKeys({ params, context }: { params: any, context: any }) {
const data = securityService.isHitKeys({
content: params.content,
dictType: params.dictType,
})
return {
result: data,
context, // 上下文主要是传递调用方信息使用比如traceid
}
}
}

View File

@ -0,0 +1,10 @@
{
"name": "sceurity",
"version": "1.0.0",
"description": "",
"main": "index.js",
"dependencies": {
"mongodb": "^5.7.0"
},
"devDependencies": {}
}

View File

@ -0,0 +1,14 @@
import { mongo } from '../db/mongo'
import { DICT_TYPE } from '../types/index'
import { participle } from '../utils/index'
class SecurityService {
async isHitKeys({ content, dictType }: { content: string, dictType: DICT_TYPE }) {
const securityDictModel = await mongo.getCollection({ collectionName: 'securityDict' });
const keywordList = participle({ content })
const hitCount = await securityDictModel.countDocuments({ keyword: { $in: keywordList }, type: dictType })
return hitCount > 0
}
}
export const securityService = new SecurityService()

View File

@ -0,0 +1,14 @@
export enum DICT_TYPE {
danger = "danger",
secret = "secret",
}
export class CommonError extends Error {
code: number
errmsg: number
constructor(msg, code = 500) {
super(msg)
this.errmsg = msg;
this.code = code;
}
}

View File

@ -0,0 +1,17 @@
import { CommonError } from '../types/index'
export function participle({ content, minLen, maxLen }: { content: string, minLen?: number, maxLen?: number }) {
const keys: Array<string> = []
minLen = minLen || 2
maxLen = maxLen || 13
for (let i = 0; i < content.length; i++) {
let tempStr = content[i]
for (let j = 1; j < maxLen && i + j < content.length; j++) {
tempStr += content[i + j]
if (j >= minLen - 1) {
keys.push(tempStr)
}
}
}
return keys
}

View File

@ -0,0 +1,10 @@
const config = {
mongo: {
url: process.env.xiaojuSurveyMongoUrl || 'mongodb://localhost:27017',
dbName: 'surveyEengine'
}
}
export function getConfig() {
return config
}

View File

@ -0,0 +1,33 @@
import { Collection, MongoClient, ObjectId } from 'mongodb'
import { getConfig } from '../config/index'
const config = getConfig()
class mongoService {
isInit: boolean
client: MongoClient
constructor() {
this.client = new MongoClient(config.mongo.url);
}
async getCollection({ collectionName }): Promise<Collection> {
await this.client.connect()
return this.client.db(config.mongo.dbName).collection(collectionName)
}
convertId2StringByDoc(doc: any): any {
doc._id = doc._id.toString()
return doc;
}
convertId2StringByList(list: Array<any>): Array<any> {
return list.map(e => {
return this.convertId2StringByDoc(e);
})
}
getObjectIdByStr(str: string): ObjectId {
return new ObjectId(str)
}
}
export const mongo = new mongoService()

View File

@ -0,0 +1,184 @@
import { SurveyServer } from '../../decorator'
import { Request, Response } from 'koa'
import { surveyService } from './service/surveyService'
import { userService } from './service/userService'
import { surveyHistoryService } from './service/surveyHistoryService'
import { HISTORY_TYPE } from './types/index'
import { getValidateValue } from './utils/index'
import * as Joi from 'joi'
export default class SurveyManage {
@SurveyServer({type:'http',method:'get',routerName:'/getBannerData'})
async getBannerData({req,res}:{req:Request, res:Response}) {
const data = await surveyService.getBannerData()
return {
code:200,
data,
}
}
@SurveyServer({type:'http',method:'post',routerName:'/add'})
async add({req,res}:{req:Request, res:Response}) {
const params = getValidateValue(Joi.object({
remark: Joi.string().required(),
questionType: Joi.string().required(),
title: Joi.string().required(),
}).validate(req.body,{allowUnknown: true}));
params.userData = await userService.checkLogin({req})
const addRes = await surveyService.add(params)
return {
code:200,
data: {
id: addRes.pageId,
},
}
}
@SurveyServer({type:'http',method:'post',routerName:'/update'})
async update({req,res}:{req:Request, res:Response}) {
const surveyParams = getValidateValue(Joi.object({
surveyId: Joi.string().required(),
remark: Joi.string().required(),
title: Joi.string().required(),
}).validate(req.body,{allowUnknown: true}));
const userData = await userService.checkLogin({req})
surveyParams.userData = userData
const data = await surveyService.update(surveyParams)
return {
code:200,
data,
}
}
@SurveyServer({type:'http',method:'post',routerName:'/delete'})
async delete({req,res}:{req:Request, res:Response}) {
const surveyParams = getValidateValue(Joi.object({
surveyId: Joi.string().required(),
}).validate(req.body,{allowUnknown: true}));
const userData = await userService.checkLogin({req})
surveyParams.userData = userData
const data = await surveyService.delete(surveyParams)
return {
code:200,
data,
}
}
@SurveyServer({type:'http',method:'get',routerName:'/list'})
async list({req,res}:{req:Request, res:Response}) {
const condition = getValidateValue(Joi.object({
curPage: Joi.number().default(1),
pageSize: Joi.number().default(10),
}).validate(req.query,{allowUnknown: true}));
const userData = await userService.checkLogin({req})
const listRes = await surveyService.list({
pageNum:condition.curPage,
pageSize:condition.pageSize,
userData
})
return {
code:200,
data: listRes,
}
}
@SurveyServer({type:'http',method:'post',routerName:'/saveConf'})
async saveConf({req,res}:{req:Request, res:Response}) {
const surveyData = getValidateValue(Joi.object({
surveyId: Joi.string().required(),
configData: Joi.object().required(),
}).validate(req.body,{allowUnknown: true}));
const userData = await userService.checkLogin({req})
// 保存数据
const saveRes = await surveyService.saveConf(surveyData);
// 保存历史
const historyRes = await surveyHistoryService.addHistory({
surveyId:surveyData.surveyId,
configData:surveyData.configData,
type:HISTORY_TYPE.dailyHis,
userData
});
return {
code:200,
data:{
saveRes,
historyRes
}
}
}
@SurveyServer({type:'http',method:'get',routerName:'/get'})
async get({req,res}:{req:Request, res:Response}) {
const params = getValidateValue(Joi.object({
surveyId: Joi.string().required(),
}).validate(req.query,{allowUnknown: true}));
const userData = await userService.checkLogin({req})
const data = await surveyService.get({
surveyId:params.surveyId,
userData
})
return {
code:200,
data,
}
}
@SurveyServer({type:'http',method:'get',routerName:'/getHistoryList'})
async getHistoryList({req,res}:{req:Request, res:Response}) {
const historyParams = getValidateValue(Joi.object({
surveyId: Joi.string().required(),
historyType: Joi.string().required(),
}).validate(req.query,{allowUnknown: true}));
const data = await surveyHistoryService.getHistoryList(historyParams)
return {
code:200,
data
}
}
@SurveyServer({type:'http',method:'post',routerName:'/publish'})
async publish({req,res}:{req:Request, res:Response}) {
const surveyParams = getValidateValue(Joi.object({
surveyId: Joi.string().required(),
}).validate(req.body,{allowUnknown: true}));
// 鉴权
const userData = await userService.checkLogin({req})
// 发布
surveyParams.userData = userData
const surveyData = await surveyService.publish(surveyParams)
// 保存历史
const historyRes = await surveyHistoryService.addHistory({
surveyId:surveyData.surveyConfRes.pageId,
configData:surveyData.surveyConfRes.code,
type:HISTORY_TYPE.publishHis,
userData
});
return {
code:200,
data:{
...surveyData,
historyRes
}
}
}
@SurveyServer({type:'http',method:'get',routerName:'/data'})
async data({req,res}:{req:Request, res:Response}) {
const surveyParams = getValidateValue(Joi.object({
surveyId: Joi.string().required(),
isShowSecret:Joi.boolean().default(true), // 默认true就是需要脱敏
page: Joi.number().default(1),
pageSize: Joi.number().default(10),
}).validate(req.query,{allowUnknown: true}));
const userData = await userService.checkLogin({req})
const data = await surveyService.data({
userData,
surveyId:surveyParams.surveyId,
isShowSecret:surveyParams.isShowSecret,
pageNum:surveyParams.page,
pageSize:surveyParams.pageSize,
})
return {
code:200,
data
}
}
}

View File

@ -0,0 +1,13 @@
{
"name": "survey_manage",
"version": "1.0.0",
"description": "",
"main": "index.js",
"dependencies": {
"joi": "^17.9.2",
"lodash": "^4.17.21",
"moment": "^2.29.4",
"mongodb": "^5.7.0"
},
"devDependencies": {}
}

View File

@ -0,0 +1,37 @@
import { mongo } from '../db/mongo'
import { getStatusObject } from '../utils/index'
import { SURVEY_STATUS, HISTORY_TYPE, UserType } from '../types/index'
class SurveyHistoryService {
async addHistory(surveyData: { surveyId: string, configData: any, type: HISTORY_TYPE, userData: UserType }) {
const surveyHistory = await mongo.getCollection({ collectionName: 'surveyHistory' });
const surveyHistoryRes = await surveyHistory.insertOne({
curStatus: getStatusObject({ status: SURVEY_STATUS.new }),
pageId: surveyData.surveyId,
type: surveyData.type,
code: {
data: surveyData.configData
},
createDate: Date.now(),
operator: {
_id: surveyData.userData._id,
username: surveyData.userData.username,
}
})
return surveyHistoryRes
}
async getHistoryList(historyParams: { surveyId: string, historyType: HISTORY_TYPE }) {
const surveyHistory = await mongo.getCollection({ collectionName: 'surveyHistory' });
const surveyHistoryListRes = await surveyHistory.find({
pageId: historyParams.surveyId,
type: historyParams.historyType,
})
.sort({ createDate: -1 })
.limit(100)
.toArray()
return mongo.convertId2StringByList(surveyHistoryListRes)
}
}
export const surveyHistoryService = new SurveyHistoryService()

View File

@ -0,0 +1,307 @@
import { mongo } from '../db/mongo'
import { rpcInvote } from '../../../rpc'
import { SURVEY_STATUS, QUESTION_TYPE, CommonError, UserType, DICT_TYPE } from '../types/index'
import { getStatusObject, genSurveyPath, getMapByKey, hanleSensitiveDate } from '../utils/index'
import * as path from "path";
import * as _ from "lodash";
import * as moment from "moment";
class SurveyService {
async checkSecurity({ content, dictType }: { content: string, dictType: DICT_TYPE }) {
const rpcResult = await rpcInvote<any, { result: boolean }>('security.isHitKeys', {
params: { content, dictType },
context: {}
})
return rpcResult.result
}
async getBannerData() {
const bannerConfPath = path.resolve(__dirname, "../template/banner/index.json");
return require(bannerConfPath)
}
async getCodeData({
questionType,
}: { questionType: QUESTION_TYPE }): Promise<any> {
const baseConfPath = path.resolve(__dirname, "../template/surveyTemplate/templateBase.json");
const templateConfPath = path.resolve(
__dirname,
`../template/surveyTemplate/survey/${questionType}.json`,
);
const baseConf = _.cloneDeep(require(baseConfPath))
const templateConf = _.cloneDeep(require(templateConfPath))
const codeData = _.merge(baseConf, templateConf);
const nowMoment = moment()
codeData.baseConf.begTime = nowMoment.format("YYYY-MM-DD HH:mm:ss")
codeData.baseConf.endTime = nowMoment.add(10, 'years').format("YYYY-MM-DD HH:mm:ss")
return codeData;
}
async getNewSurveyPath() {
const surveyMeta = await mongo.getCollection({ collectionName: 'surveyMeta' });
const surveyPath = genSurveyPath()
const surveyPathCount = await surveyMeta.countDocuments({ surveyPath })
if (surveyPathCount > 0) { return await this.getNewSurveyPath() }
return surveyPath
}
async add(surveyMetaInfo: { remark: string, questionType: QUESTION_TYPE, title: string, userData: UserType }) {
const surveyMeta = await mongo.getCollection({ collectionName: 'surveyMeta' });
const now = Date.now()
const surveyPath = await this.getNewSurveyPath()
const surveyMetaRes = await surveyMeta.insertOne({
surveyPath,
remark: surveyMetaInfo.remark,
questionType: surveyMetaInfo.questionType,
title: surveyMetaInfo.title,
creator: surveyMetaInfo.userData.username,
owner: surveyMetaInfo.userData.username,
curStatus: getStatusObject({ status: SURVEY_STATUS.new }),
createDate: now,
updateDate: now,
})
const pageId = surveyMetaRes.insertedId.toString()
const surveyConf = await mongo.getCollection({ collectionName: 'surveyConf' });
const surveyConfRes = await surveyConf.insertOne({
pageId,
pageType: surveyMetaInfo.questionType,
curStatus: getStatusObject({ status: SURVEY_STATUS.new }),
code: await this.getCodeData({
questionType: surveyMetaInfo.questionType,
})
})
return {
pageId,
surveyMetaRes,
surveyConfRes
}
}
async update(surveyParams: { surveyId: string, remark: string, title: string, userData: UserType }) {
const surveyMeta = await mongo.getCollection({ collectionName: 'surveyMeta' });
const _id = mongo.getObjectIdByStr(surveyParams.surveyId)
const surveyMetaUpdateRes = await surveyMeta.updateOne({
_id,
owner: surveyParams.userData.username,
}, {
$set: {
remark: surveyParams.remark,
title: surveyParams.title,
updateDate: Date.now(),
}
})
if (surveyMetaUpdateRes.matchedCount < 1) {
throw new CommonError("更新问卷信息失败,问卷不存在或您不是该问卷所有者")
}
return {
surveyMetaUpdateRes
}
}
async delete(surveyParams: { surveyId: string, userData: UserType }) {
const surveyMeta = await mongo.getCollection({ collectionName: 'surveyMeta' });
const _id = mongo.getObjectIdByStr(surveyParams.surveyId)
const surveyMetaDeleteRes = await surveyMeta.deleteOne({
_id,
owner: surveyParams.userData.username,
})
if (surveyMetaDeleteRes.deletedCount < 1) {
throw new CommonError("删除问卷失败,问卷已被删除或您不是该问卷所有者")
}
return {
surveyMetaDeleteRes
}
}
async list(condition: { pageNum: number, pageSize: number, userData: UserType }) {
const surveyMeta = await mongo.getCollection({ collectionName: 'surveyMeta' });
const cond = {
owner: condition.userData.username
}
const data = await surveyMeta.find(cond)
.sort({ updateDate: -1 })
.limit(condition.pageSize)
.skip((condition.pageNum - 1) * condition.pageSize)
.toArray()
const count = await surveyMeta.countDocuments(cond);
return { data: mongo.convertId2StringByList(data), count }
}
getListHeadByDataList(dataList) {
return dataList.map(surveyItem => {
let othersCode;
if(surveyItem.type === 'radio-star') {
const rangeConfigKeys = Object.keys(surveyItem.rangeConfig)
if(rangeConfigKeys.length>0) {
othersCode = [{code: `${surveyItem.field}_custom`, option: "填写理由"}]
}
} else {
othersCode = (surveyItem.options || [])
.filter(optionItem => optionItem.othersKey)
.map((optionItem) => {
return {
code: optionItem.othersKey,
option: optionItem.text
}
})
}
return {
field: surveyItem.field,
title: surveyItem.title,
type: surveyItem.type,
othersCode
}
})
}
async data(condition: { userData:UserType,surveyId: string, pageNum: number, pageSize: number, isShowSecret: boolean }) {
const surveyObjectId = mongo.getObjectIdByStr(condition.surveyId)
const surveyMeta = await mongo.getCollection({ collectionName: 'surveyMeta' });
const surveyMetaData = await surveyMeta.findOne({ _id: surveyObjectId })
if (surveyMetaData.owner !== condition.userData.username) {
throw new CommonError("问卷回收数据列表仅所有人才能打开")
}
const surveyPublish = await mongo.getCollection({ collectionName: 'surveyPublish' });
const publishConf = await surveyPublish.findOne({ pageId: condition.surveyId })
const dataList = publishConf?.code?.dataConf?.dataList || []
const listHead = this.getListHeadByDataList(dataList)
const dataListMap = getMapByKey({ data: dataList, key: 'field' })
const surveySubmit = await mongo.getCollection({ collectionName: 'surveySubmit' });
const surveySubmitData = await surveySubmit.find({ pageId: condition.surveyId })
.sort({ createDate: -1 })
.limit(condition.pageSize)
.skip((condition.pageNum - 1) * condition.pageSize)
.toArray()
const listBody = surveySubmitData.map((surveySubmitResList) => {
const data = surveySubmitResList.data
const dataKeys = Object.keys(data)
for(const itemKey of dataKeys) {
if(typeof itemKey !== 'string') {continue}
if(itemKey.indexOf("data")!==0) {continue}
const itemConfigKey = itemKey.split("_")[0];
const itemConfig = dataListMap[itemConfigKey];
// 题目删除会出现,数据列表报错
if(!itemConfig) {continue}
const doSecretData = (data)=>{
if(itemConfig.isSecret && condition.isShowSecret) {
return hanleSensitiveDate(data)
} else {
return data;
}
}
data[itemKey] = doSecretData(data[itemKey])
// 处理选项
if(itemConfig.type === 'radio-star' && !data[`${itemConfigKey}_custom`]) {
data[`${itemConfigKey}_custom`] = data[`${itemConfigKey}_${data[itemConfigKey]}`]
}
if (!itemConfig?.options?.length) { continue }
const options = itemConfig.options
const optionsMap = getMapByKey({ data: options, key: 'hash' })
const getText = e => doSecretData(optionsMap?.[e]?.text || e);
if (Array.isArray(data[itemKey])) {
data[itemKey] = data[itemKey].map(getText)
} else {
data[itemKey] = getText(data[itemKey])
}
}
return data
})
const total = await surveySubmit.countDocuments({ pageId: condition.surveyId });
return {
total,
listHead,
listBody
}
}
async get({ surveyId, userData }: { surveyId: string, userData: UserType }) {
const surveyObjectId = mongo.getObjectIdByStr(surveyId)
const surveyMeta = await mongo.getCollection({ collectionName: 'surveyMeta' });
const surveyMetaData = await surveyMeta.findOne({ _id: surveyObjectId })
if (!surveyMetaData) {
throw new CommonError("问卷不存在或已被删除")
}
if (surveyMetaData.owner !== userData.username) {
throw new CommonError("问卷仅所有人才能打开")
}
const surveyMetaRes = mongo.convertId2StringByDoc(surveyMetaData)
const surveyConf = await mongo.getCollection({ collectionName: 'surveyConf' });
const surveyConfData = await surveyConf.findOne({ pageId: surveyId })
if (!surveyConfData) {
throw new CommonError("问卷配置不存在或已被删除")
}
const surveyConfRes = mongo.convertId2StringByDoc(surveyConfData)
return {
surveyMetaRes,
surveyConfRes
}
}
async saveConf(surveyData: { surveyId: string, configData: any }) {
const surveyConf = await mongo.getCollection({ collectionName: 'surveyConf' });
const saveRes = await surveyConf.updateOne({
pageId: surveyData.surveyId
}, {
$set: {
code: surveyData.configData
}
})
return saveRes
}
async publish({ surveyId, userData }: { surveyId: string, userData: UserType }) {
const surveyObjectId = mongo.getObjectIdByStr(surveyId)
const surveyMeta = await mongo.getCollection({ collectionName: 'surveyMeta' });
const surveyConf = await mongo.getCollection({ collectionName: 'surveyConf' });
const surveyMetaRes = await surveyMeta.findOne({ _id: surveyObjectId })
if (!surveyMetaRes) {
throw new CommonError("问卷不存在或已被删除,无法发布")
}
if (surveyMetaRes.owner !== userData.username) {
throw new CommonError("只有问卷的所有者才能发布该问卷")
}
const surveyConfRes = await surveyConf.findOne({ pageId: surveyId })
if (!surveyConfRes) {
throw new CommonError("问卷配置不存在或已被删除,无法发布")
}
const surveyPublish = await mongo.getCollection({ collectionName: 'surveyPublish' });
// 清除id存储发布
delete surveyConfRes._id;
surveyConfRes.title = surveyMetaRes.title
surveyConfRes.curStatus = surveyMetaRes.curStatus
surveyConfRes.surveyPath = surveyMetaRes.surveyPath
const dataList = surveyConfRes?.code?.dataConf?.dataList || []
for (const data of dataList) {
const isDangerKey = await this.checkSecurity({ content: data.title, dictType: DICT_TYPE.danger })
if (isDangerKey) {
throw new CommonError("问卷存在非法关键字,不允许发布")
}
const isSecretKey = await this.checkSecurity({ content: data.title, dictType: DICT_TYPE.secret })
if (isSecretKey) {
data.isSecret = true
}
}
const publishRes = await surveyPublish.updateOne({
pageId: surveyId
}, {
$set: surveyConfRes
}, {
upsert: true //如果不存在则插入
});
const updateMetaRes = await surveyMeta.updateOne({
_id: surveyObjectId
}, {
$set: {
curStatus: getStatusObject({ status: SURVEY_STATUS.published }),
}
})
return {
updateMetaRes,
surveyConfRes,
publishRes
}
}
}
export const surveyService = new SurveyService()

View File

@ -0,0 +1,19 @@
import { rpcInvote } from '../../../rpc'
import { Request } from 'koa'
import { UserType, CommonError } from '../types/index'
class UserService {
async checkLogin({ req }: { req: Request }) {
if (!req.headers['authorization']) {
throw new CommonError('请先登录', 403)
}
const token = (String(req.headers['authorization']) || '').replace("Bearer ", "")
const rpcResult = await rpcInvote<any, { result: UserType }>('user.getUserByToken', {
params: { token },
context: req
})
return rpcResult.result
}
}
export const userService = new UserService()

View File

@ -0,0 +1,174 @@
{
"temp": {
"key": "default",
"name": "默认分类",
"list": [{
"src": "/imgs/skin/17e06b7604a007e1d3e1453b9ddadc3c.jpg",
"title": "1"
}]
},
"activity": {
"key": "activity",
"name": "节日",
"list": [{
"src": "/imgs/skin/Ixx8hqiwwk1660979120801.jpg",
"title": "1"
}]
},
"creative": {
"key": "creative",
"name": "创意",
"list": [{
"src": "/imgs/skin/PLwNH1rAie1558430219772.jpg",
"title": "1"
}, {
"src": "/imgs/skin/NnXsAOhBNm1558430219312.jpg",
"title": "2"
}, {
"src": "/imgs/skin/ujpUoWqhw31558430220124.jpg",
"title": "3"
}, {
"src": "/imgs/skin/5OCvbjqJQm1558430220362.jpg",
"title": "4"
}, {
"src": "/imgs/skin/0k7Jg7In8I1558430221154.jpg",
"title": "5"
}, {
"src": "/imgs/skin/UH0A8DbTai1558430221033.jpg",
"title": "6"
}, {
"src": "/imgs/skin/FRIzPC6ZtN1558430221344.jpg",
"title": "7"
}]
},
"scenery": {
"key": "scenery",
"name": "风景",
"list": [{
"src": "/imgs/skin/SyiLRcukyE1558430525760.jpg",
"title": "1"
}, {
"src": "/imgs/skin/sqYig4AcWr1558430525663.jpg",
"title": "2"
}, {
"src": "/imgs/skin/ElNeqJT2I21558430526165.jpg",
"title": "3"
}, {
"src": "/imgs/skin/CxQkSU6AY21558430526163.jpg",
"title": "4"
}, {
"src": "/imgs/skin/VTUwbp6vY61558430527320.jpg",
"title": "5"
}, {
"src": "/imgs/skin/SHs0K703Yn1558430527218.jpg",
"title": "6"
}, {
"src": "/imgs/skin/oVTedX9V4s1558430527671.jpg",
"title": "7"
}]
},
"transportation": {
"key": "transportation",
"name": "交通",
"list": [{
"src": "/imgs/skin/XYKqJZuMig1558430904735.jpg",
"title": "1"
}, {
"src": "/imgs/skin/GnPatsr48Z1558430904680.jpg",
"title": "2"
}, {
"src": "/imgs/skin/UqIvVvEXAK1558430905204.jpg",
"title": "3"
}, {
"src": "/imgs/skin/PUssufh5uI1558430905104.jpg",
"title": "4"
}, {
"src": "/imgs/skin/O409pRTDlW1558430905738.jpg",
"title": "5"
}, {
"src": "/imgs/skin/A9FzlbYXqI1558430905739.jpg",
"title": "6"
}, {
"src": "/imgs/skin/HN9YGctDeF1558430906686.jpg",
"title": "7"
}]
},
"delicacy": {
"key": "delicacy",
"name": "美食",
"list": [{
"src": "/imgs/skin/lE6PSclCcU1558434536703.jpg",
"title": "1"
}, {
"src": "/imgs/skin/OnSdbm7u6n1558434536641.jpg",
"title": "2"
}, {
"src": "/imgs/skin/N9Z2ZuyO731558434537314.jpg",
"title": "3"
}, {
"src": "/imgs/skin/YP9PoW8pX51558434537301.jpg",
"title": "4"
}, {
"src": "/imgs/skin/zUtDv378bg1558434538351.jpg",
"title": "5"
}, {
"src": "/imgs/skin/gY1JljCow21558434538303.jpg",
"title": "6"
}, {
"src": "/imgs/skin/oOjHPbABdd1558434538864.jpg",
"title": "7"
}]
},
"business": {
"key": "business",
"name": "商务",
"list": [{
"src": "/imgs/skin/3ABKqvDaVn1558514860472.jpg",
"title": "1"
}, {
"src": "/imgs/skin/OewuaQmWoq1558514860285.jpg",
"title": "2"
}, {
"src": "/imgs/skin/HuVqqtbFjs1558514860570.jpg",
"title": "3"
}, {
"src": "/imgs/skin/icSlqsr0uZ1558514860875.jpg",
"title": "4"
}, {
"src": "/imgs/skin/Qu9rg33wmq1558514861015.jpg",
"title": "5"
}, {
"src": "/imgs/skin/145gBCRtNP1558514861211.jpg",
"title": "6"
}, {
"src": "/imgs/skin/ykWLFV0QWj1558514861444.jpg",
"title": "7"
}]
},
"campus": {
"key": "campus",
"name": "校园",
"list": [{
"src": "/imgs/skin/4aWi5JxG471558514268698.jpg",
"title": "1"
}, {
"src": "/imgs/skin/j8C2OBP7WK1558514268563.jpg",
"title": "2"
}, {
"src": "/imgs/skin/q3uJoQhYsR1558514268877.jpg",
"title": "3"
}, {
"src": "/imgs/skin/W5PPlNsmsr1558514269088.jpg",
"title": "4"
}, {
"src": "/imgs/skin/6xQk1IAmKt1558514269874.jpg",
"title": "5"
}, {
"src": "/imgs/skin/XQE2iyF0rj1558514269935.jpg",
"title": "6"
}, {
"src": "/imgs/skin/POHlQiSwPR1558514270379.jpg",
"title": "7"
}]
}
}

View File

@ -0,0 +1,148 @@
{
"bannerConf": {
"titleConfig": {
"mainTitle": "<h3 style=\"text-align: center\">欢迎填写问卷</h3><p>为了给您提供更好的服务,希望您能抽出几分钟时间,将您的感受和建议告诉我们,<span style=\"color: rgb(204, 0, 0)\">期待您的参与!</span></p>",
"subTitle": ""
},
"bannerConfig": {
"bgImage": "//img-hxy021.didistatic.com/static/starimg/img/BP0HXOXDv01698311607854.jpg",
"videoLink": "",
"postImg": ""
}
},
"dataConf": {
"dataList": [
{
"isRequired": true,
"showIndex": true,
"showType": true,
"showSpliter": true,
"type": "text",
"valid": "",
"field": "data458",
"title": "标题1",
"placeholder": "",
"randomSort": false,
"checked": false,
"minNum": "",
"maxNum": "",
"maxPhotos": 5,
"star": 5,
"timeStep": {
"hour": 1,
"min": 10
},
"nps": {
"leftText": "极不满意",
"rightText": "极满意"
},
"placeholderDesc": "",
"addressType": 3,
"isAuto": false,
"urlKey": "",
"othersKeyMap": {},
"textRange": {
"min": {
"placeholder": "0",
"value": 0
},
"max": {
"placeholder": "500",
"value": 500
}
}
},
{
"isRequired": true,
"showIndex": true,
"showType": true,
"showSpliter": true,
"type": "radio",
"placeholderDesc": "",
"field": "data515",
"title": "标题2",
"placeholder": "",
"randomSort": false,
"checked": false,
"minNum": "",
"maxNum": "",
"options": [
{
"text": "选项1",
"imageUrl": "",
"others": false,
"mustOthers": false,
"othersKey": "",
"placeholderDesc": "",
"hash": "115019"
},
{
"text": "选项2",
"imageUrl": "",
"others": false,
"mustOthers": false,
"othersKey": "",
"placeholderDesc": "",
"hash": "115020"
}
],
"importKey": "single",
"importData": "",
"cOption": "",
"cOptions": [],
"timeStep": {
"hour": 1,
"min": 10
},
"maxPhotos": 5,
"nps": {
"leftText": "极不满意",
"rightText": "极满意"
},
"star": 5,
"exclude": false,
"addressType": 3,
"isAuto": false,
"othersKeyMap": {},
"textRange": {
"min": {
"placeholder": "0",
"value": 0
},
"max": {
"placeholder": "500",
"value": 500
}
}
}
]
},
"submitConf": {
"submitTitle": "提交",
"confirmAgain": {
"is_again": true,
"again_text": "确认要提交吗?"
},
"msgContent": {
"msg_200": "提交成功",
"msg_9001": "您来晚了,感谢支持问卷~",
"msg_9002": "请勿多次提交!",
"msg_9003": "您来晚了,已经满额!",
"msg_9004": "提交失败!"
}
},
"bottomConf": {
"logoImage": "",
"logoImageWidth": "33%"
},
"baseConf": {
"begTime": "2018-05-22 17:17:48",
"endTime": "2028-05-22 17:17:48",
"tLimit": "0",
"language": "chinese"
},
"skinConf": {
"skinColor": "#4a4c5b",
"inputBgColor": "#ffffff"
}
}

View File

@ -0,0 +1,140 @@
{
"submitConf": {
"submitTitle": "提交",
"confirmAgain": {
"is_again": true,
"again_text": "确认要提交吗?"
},
"msgContent": {
"msg_200": "提交成功",
"msg_9001": "您来晚了,感谢支持问卷~",
"msg_9002": "请勿多次提交!",
"msg_9003": "您来晚了,已经满额!",
"msg_9004": "提交失败!"
}
},
"skinConf": {
"skinColor": "#4a4c5b",
"inputBgColor": "#ffffff"
},
"bottomConf": {
"logoImage": "",
"logoImageWidth": "40%"
},
"bannerConf": {
"titleConfig": {
"mainTitle": "<h3 style=\"text-align: center\">满意度调研</h3> <p>&nbsp;</p> <p>为了给您提供更好的服务,希望您能抽出几分钟时间,将您的感受和建议告诉我们,<span style=\"color: rgb(204, 0, 0)\">期待您的参与</span></p>",
"subTitle": ""
},
"bannerConfig": {
"bgImage": "//img-hxy021.didistatic.com/static/starimg/img/BP0HXOXDv01698311607854.jpg",
"videoLink": "",
"postImg": ""
}
},
"baseConf": {
"showVoteProcess": "allow",
"begTime": "2018-05-22 17:17:48",
"endTime": "2028-05-22 17:17:48",
"tLimit": "0",
"language": "chinese"
},
"dataConf": {
"dataList": [
{
"field": "data8",
"showIndex": true,
"showType": true,
"showSpliter": true,
"placeholderDesc": "",
"placeholder": "",
"isRequired": true,
"randomSort": false,
"innerRandom": false,
"hideSubTitleIndex": false,
"checked": false,
"minNum": "",
"maxNum": "",
"relyType": "and",
"extraOptions": [],
"importKey": "single",
"importData": "",
"npsMin": 0,
"addressType": 3,
"isAuto": false,
"urlKey": "",
"hasRely": true,
"relyList": [],
"jumpTo": "",
"optionOrigin": "",
"answerTip": "",
"type": "text",
"valid": "",
"title": "标题1",
"answer": "",
"othersKeyMap": {},
"textRange": {
"min": {
"placeholder": "0",
"value": 0
},
"max": {
"placeholder": "500",
"value": 500
}
}
},
{
"field": "data4",
"showIndex": true,
"showType": true,
"showSpliter": true,
"placeholderDesc": "",
"placeholder": "",
"isRequired": true,
"randomSort": false,
"innerRandom": false,
"hideSubTitleIndex": false,
"checked": false,
"minNum": "",
"maxNum": "",
"relyType": "and",
"extraOptions": [],
"importKey": "single",
"importData": "",
"cOption": "",
"cOptions": [],
"npsMin": 0,
"star": 5,
"addressType": 3,
"isAuto": false,
"urlKey": "",
"defaultProps": {
"children": "children",
"label": "name",
"id": "id"
},
"hasRely": true,
"relyList": [],
"jumpTo": "",
"optionOrigin": "",
"answerTip": "",
"type": "radio-star",
"title": "标题3",
"answer": "",
"othersKeyMap": {},
"options": [],
"textRange": {
"min": {
"placeholder": "0",
"value": 0
},
"max": {
"placeholder": "500",
"value": 500
}
}
}
]
}
}

View File

@ -0,0 +1,180 @@
{
"bannerConf": {
"titleConfig": {
"mainTitle": "<h3 style=\"text-align: center\">欢迎填写问卷</h3><p>为了给您提供更好的服务,希望您能抽出几分钟时间,将您的感受和建议告诉我们,<span style=\"color: rgb(204, 0, 0)\">期待您的参与!</span></p>",
"subTitle": ""
},
"bannerConfig": {
"bgImage": "//img-hxy021.didistatic.com/static/starimg/img/BP0HXOXDv01698311607854.jpg",
"videoLink": "",
"postImg": ""
}
},
"dataConf": {
"dataList": [
{
"isRequired": true,
"showIndex": true,
"showType": true,
"showSpliter": true,
"type": "text",
"valid": "",
"field": "data458",
"title": "姓名",
"placeholder": "",
"randomSort": false,
"checked": false,
"minNum": "",
"maxNum": "",
"maxPhotos": 5,
"star": 5,
"timeStep": {
"hour": 1,
"min": 10
},
"nps": {
"leftText": "极不满意",
"rightText": "极满意"
},
"exclude": false,
"relTypes": {
"textarea": "多行文本框",
"text": "单行输入框"
},
"placeholderDesc": "",
"mhLimit": 0,
"addressType": 3,
"isAuto": false,
"jumpTo": "",
"startDate": "",
"endDate": "",
"othersKeyMap": {},
"textRange": {
"min": {
"placeholder": "0",
"value": 0
},
"max": {
"placeholder": "500",
"value": 500
}
}
},
{
"isRequired": true,
"showIndex": true,
"showType": true,
"showSpliter": true,
"type": "radio",
"placeholderDesc": "",
"field": "data515",
"title": "选择您感兴趣的课程进行报名",
"placeholder": "",
"valid": "",
"randomSort": false,
"checked": false,
"minNum": "",
"maxNum": "",
"options": [
{
"text": "课程1",
"hash": "115019",
"imageUrl": "",
"others": false,
"mustOthers": false,
"othersKey": "",
"placeholderDesc": ""
},
{
"text": "课程2",
"hash": "115020",
"imageUrl": "",
"others": false,
"mustOthers": false,
"othersKey": "",
"placeholderDesc": ""
},
{
"text": "课程3",
"hash": "115021",
"imageUrl": "",
"others": false,
"mustOthers": false,
"othersKey": "",
"placeholderDesc": ""
},
{
"text": "课程4",
"hash": "115022",
"imageUrl": "",
"others": false,
"mustOthers": false,
"othersKey": "",
"placeholderDesc": ""
}
],
"timeStep": {
"hour": 1,
"min": 10
},
"maxPhotos": 5,
"nps": {
"leftText": "极不满意",
"rightText": "极满意"
},
"star": 5,
"exclude": false,
"addressType": 3,
"isAuto": false,
"urlKey": "",
"defaultProps": {
"children": "children",
"label": "name",
"id": "id"
},
"startDate": "",
"endDate": "",
"othersKeyMap": {},
"textRange": {
"min": {
"placeholder": "0",
"value": 0
},
"max": {
"placeholder": "500",
"value": 500
}
}
}
]
},
"submitConf": {
"submitTitle": "提交",
"confirmAgain": {
"is_again": true,
"again_text": "确认要提交吗?"
},
"msgContent": {
"msg_200": "提交成功",
"msg_9001": "您来晚了,感谢支持问卷~",
"msg_9002": "请勿多次提交!",
"msg_9003": "您来晚了,已经满额!",
"msg_9004": "提交失败!"
},
"link": ""
},
"bottomConf": {
"logoImage": "",
"logoImageWidth": "33%"
},
"baseConf": {
"begTime": "2018-05-22 17:17:48",
"endTime": "2028-05-22 17:17:48",
"tLimit": "0",
"language": "chinese"
},
"skinConf": {
"skinColor": "#4a4c5b",
"inputBgColor": "#ffffff"
}
}

View File

@ -0,0 +1,167 @@
{
"bannerConf": {
"titleConfig": {
"mainTitle": "<h3 style=\"text-align: center\">欢迎填写问卷</h3><p>为了给您提供更好的服务,希望您能抽出几分钟时间,将您的感受和建议告诉我们,<span style=\"color: rgb(204, 0, 0)\">期待您的参与!</span></p>",
"subTitle": ""
},
"bannerConfig": {
"bgImage": "//img-hxy021.didistatic.com/static/starimg/img/BP0HXOXDv01698311607854.jpg",
"videoLink": "",
"postImg": ""
}
},
"dataConf": {
"dataList": [
{
"isRequired": true,
"showIndex": true,
"showType": true,
"showSpliter": true,
"type": "text",
"valid": "",
"field": "data631",
"title": "标题3",
"placeholder": "",
"sLimit": 1,
"randomSort": false,
"checked": false,
"minNum": "",
"maxNum": "",
"options": [
{
"text": "选项1",
"imageUrl": "",
"others": false,
"mustOthers": false,
"othersKey": "",
"placeholderDesc": ""
},
{
"text": "选项2",
"imageUrl": "",
"others": false,
"mustOthers": false,
"othersKey": "",
"placeholderDesc": ""
}
],
"maxPhotos": 5,
"star": 5,
"timeStep": {
"hour": 1,
"min": 10
},
"nps": {
"leftText": "极不满意",
"rightText": "极满意"
},
"exclude": false,
"relTypes": {
"textarea": "多行文本框",
"text": "单行输入框"
},
"placeholderDesc": "",
"addressType": 3,
"isAuto": false,
"urlKey": "",
"othersKeyMap": {},
"textRange": {
"min": {
"placeholder": "0",
"value": 0
},
"max": {
"placeholder": "500",
"value": 500
}
}
},
{
"isRequired": true,
"showIndex": true,
"showType": true,
"showSpliter": true,
"type": "vote",
"innerType": "radio",
"placeholderDesc": "",
"field": "data606",
"title": "标题1",
"placeholder": "",
"randomSort": false,
"checked": false,
"minNum": "",
"maxNum": "",
"options": [
{
"text": "甜的",
"imageUrl": "",
"others": false,
"mustOthers": false,
"othersKey": "",
"placeholderDesc": "",
"hash": "115019"
},
{
"text": "咸的",
"imageUrl": "",
"others": false,
"mustOthers": false,
"othersKey": "",
"placeholderDesc": "",
"hash": "115020"
}
],
"timeStep": {
"hour": 1,
"min": 10
},
"maxPhotos": 5,
"nps": {
"leftText": "极不满意",
"rightText": "极满意"
},
"star": 5,
"addressType": 3,
"othersKeyMap": {},
"textRange": {
"min": {
"placeholder": "0",
"value": 0
},
"max": {
"placeholder": "500",
"value": 500
}
}
}
]
},
"submitConf": {
"submitTitle": "提交",
"confirmAgain": {
"is_again": true,
"again_text": "确认要提交吗?"
},
"msgContent": {
"msg_200": "提交成功",
"msg_9001": "您来晚了,感谢支持问卷~",
"msg_9002": "请勿多次提交!",
"msg_9003": "您来晚了,已经满额!",
"msg_9004": "提交失败!"
}
},
"bottomConf": {
"logoImage": "",
"logoImageWidth": "33%"
},
"baseConf": {
"begTime": "2018-05-25 10:22:23",
"endTime": "2028-05-25 10:22:23",
"tLimit": "0",
"language": "chinese"
},
"skinConf": {
"skinColor": "#4a4c5b",
"inputBgColor": "#ffffff"
}
}

View File

@ -0,0 +1,42 @@
{
"bannerConf": {
"titleConfig": {
"mainTitle": "<h3 style=\"text-align: center\">欢迎填写问卷</h3><p>为了给您提供更好的服务,希望您能抽出几分钟时间,将您的感受和建议告诉我们,<span style=\"color: rgb(204, 0, 0)\">期待您的参与!</span></p>",
"subTitle": ""
},
"bannerConfig": {
"bgImage": "//img-hxy021.didistatic.com/static/starimg/img/BP0HXOXDv01698311607854.jpg",
"videoLink": "",
"postImg": ""
}
},
"submitConf": {
"submitTitle": "提交",
"confirmAgain": {
"is_again": true,
"again_text": "确认要提交吗?"
},
"msgContent": {
"msg_200": "提交成功",
"msg_9001": "您来晚了,感谢支持问卷~",
"msg_9002": "请勿多次提交!",
"msg_9003": "您来晚了,已经满额!",
"msg_9004": "提交失败!"
}
},
"bottomConf": {
"logoImage": "",
"logoImageWidth": "28%"
},
"baseConf": {
"begTime": "2018-05-30 10:38:31",
"endTime": "2028-05-30 10:38:31",
"tLimit": "0",
"language": "chinese",
"showVoteProcess": "allow"
},
"skinConf": {
"skinColor": "#4a4c5b",
"inputBgColor": "#ffffff"
}
}

View File

@ -0,0 +1,43 @@
export enum DICT_TYPE {
danger = "danger",
secret = "secret",
}
export enum SURVEY_STATUS {
new = "new",
editing = "editing",
pausing = "pausing",
published = "published",
removed = "removed",
}
export enum QUESTION_TYPE {
enps = "enps",
nps = "nps",
question = "question", //通用问卷
register = "register", //报名
vote = "vote", //投票
}
export enum HISTORY_TYPE {
dailyHis = "dailyHis", //保存历史
publishHis = "publishHis", //发布历史
}
export interface UserType {
_id:string,
username:string,
password:string,
curStatus:any,
createDate:number
}
export class CommonError extends Error {
code:number
errmsg:number
constructor(msg,code=500) {
super(msg)
this.errmsg = msg;
this.code = code;
}
}

View File

@ -0,0 +1,37 @@
const base58KeysObject = {
"1":0,"2":1,"3":2,"4":3,"5":4,"6":5,"7":6,"8":7,"9":8,
"A":9,"B":10,"C":11,"D":12,"E":13,"F":14,"G":15,"H":16,"J":17,
"K":18,"L":19,"M":20,"N":21,"P":22,"Q":23,"R":24,"S":25,"T":26,
"U":27,"V":28,"W":29,"X":30,"Y":31,"Z":32,
"a":33,"b":34,"c":35,"d":36,"e":37,"f":38,"g":39,"h":40,"i":41,"j":42,
"k":43,"m":44,"n":45,"o":46,"p":47,"q":48,"r":49,"s":50,"t":51,
"u":52,"v":53,"w":54,"x":55,"y":56,"z":57
};
const base58Keys = Object.keys(base58KeysObject);
const base58Len = 58n;
export function hex2Base58(hexNum:string):string
{
let base58NumArray =[];
let bigHexNumber = BigInt(`0x${hexNum}`);
while (bigHexNumber>=58n)
{
base58NumArray.unshift(base58Keys[(bigHexNumber % base58Len).toString()]);
bigHexNumber = bigHexNumber / base58Len;
}
base58NumArray.unshift(base58Keys[bigHexNumber.toString()]);
return base58NumArray.join('');
}
export function base582Hex(base58Num:string):string
{
let base58NumArray =base58Num.split('');
let big58Number = 0n;
let len = base58NumArray.length;
for(let i = 1;i<=len;i++)
{
let big58NumberTemp = BigInt(base58KeysObject[base58NumArray[len-i]])*(base58Len** BigInt(i-1));
big58Number += big58NumberTemp;
}
return big58Number.toString(16);
}

View File

@ -0,0 +1,49 @@
import { SURVEY_STATUS } from '../types/index'
import { CommonError } from '../types/index'
import { hex2Base58 } from './base58'
import * as Joi from 'joi'
export function getStatusObject({status}:{status:SURVEY_STATUS}) {
return {
status,
id: status,
date: Date.now(),
};
}
export function getValidateValue<T=any>(validationResult:Joi.ValidationResult<T>):T {
if(validationResult.error) {
throw new CommonError(validationResult.error.details.map(e=>e.message).join())
}
return validationResult.value;
}
export function genSurveyPath() {
return hex2Base58(process.hrtime.bigint().toString(16))
}
export function getMapByKey({data,key}:{data:Array<any>,key:string}) {
const datamap = {}
for(const item of data) {
datamap[item[key]] = item
}
return datamap
}
export function hanleSensitiveDate(value:string = ''): string {
if (!value) {
return '*'
}
let str = '' + value
if (str.length === 1) {
str = '*'
}
if (str.length === 2) {
str = str[0] + '*'
}
if (str.length >= 3) {
str = str[0] + '***' + str.slice(str.length - 1)
}
return str
}

View File

@ -0,0 +1,10 @@
const config = {
mongo:{
url: process.env.xiaojuSurveyMongoUrl ||'mongodb://localhost:27017',
dbName:'surveyEengine'
}
}
export function getConfig() {
return config
}

View File

@ -0,0 +1,33 @@
import { Collection, MongoClient, ObjectId } from 'mongodb'
import { getConfig } from '../config/index'
const config = getConfig()
class mongoService {
isInit:boolean
client:MongoClient
constructor() {
this.client = new MongoClient(config.mongo.url);
}
async getCollection({collectionName}):Promise<Collection> {
await this.client.connect()
return this.client.db(config.mongo.dbName).collection(collectionName)
}
convertId2StringByDoc(doc:any):any {
doc._id = doc._id.toString()
return doc;
}
convertId2StringByList(list:Array<any>):Array<any> {
return list.map(e=>{
return this.convertId2StringByDoc(e);
})
}
getObjectIdByStr(str:string) {
return new ObjectId(str)
}
}
export const mongo = new mongoService()

View File

@ -0,0 +1,53 @@
import { SurveyServer } from '../../decorator'
import { Request, Response } from 'koa'
import { surveySubmitService } from './service/surveySubmitService'
import { surveyPublishService } from './service/surveyPublishService'
import { getValidateValue } from './utils/index'
import { checkSign } from './utils/checkSign'
import * as Joi from 'joi'
export default class SurveyPublish {
// 获取发布配置
@SurveyServer({ type: 'http', method: 'get', routerName: '/getSurveyPublish' })
async getSurveyPublish({ req, res }: { req: Request, res: Response }) {
const surveySubmitData = getValidateValue(Joi.object({
surveyPath: Joi.string().required(),
}).validate(req.query, { allowUnknown: true }));
const data = await surveyPublishService.get(surveySubmitData)
return {
code: 200,
data: data.surveyPublishRes,
}
}
// 获取投票
@SurveyServer({ type: 'http', method: 'get', routerName: '/queryVote' })
async queryVote({ req, res }: { req: Request, res: Response }) {
const params = getValidateValue(Joi.object({
surveyPath: Joi.string().required(),
voteKeyList: Joi.string().required(),
}).validate(req.query, { allowUnknown: true }));
params.voteKeyList = params.voteKeyList.split(',')
const data = await surveyPublishService.queryVote(params)
return {
code: 200,
data: data,
}
}
// 提交问卷
@SurveyServer({ type: 'http', method: 'post', routerName: '/submit' })
async submit({ req, res }: { req: Request, res: Response }) {
// 检查签名
checkSign(req.body)
// 校验参数
const surveySubmitData = getValidateValue(Joi.object({
surveyPath: Joi.string().required(),
data: Joi.string().required(),
encryptType: Joi.string(),
}).validate(req.body, { allowUnknown: true }));
await surveySubmitService.submit({ surveySubmitData })
return {
code: 200,
msg: "报名成功",
}
}
}

View File

@ -0,0 +1,13 @@
{
"name": "survey_publish",
"version": "1.0.0",
"description": "",
"main": "index.js",
"dependencies": {
"joi": "^17.9.2",
"lodash": "^4.17.21",
"moment": "^2.29.4",
"mongodb": "^5.7.0"
},
"devDependencies": {}
}

View File

@ -0,0 +1,56 @@
import { mongo } from '../db/mongo'
// 该服务用于模拟redis
class SurveyKeyStoreService {
getKeyStoreResult(surveyKeyStoreData:Array<any>) {
const surveyKeyStoreReult = {}
for (const surveyKeyStoreItem of surveyKeyStoreData) {
surveyKeyStoreReult[surveyKeyStoreItem.key] = surveyKeyStoreItem.data
}
return surveyKeyStoreReult
}
async set({surveyPath, key, data, type}) {
const surveyKeyStore = await mongo.getCollection({collectionName:'surveyKeyStore'});
const setResult = await surveyKeyStore.updateOne({
key,
surveyPath,
type
},{
$set:{
key,
surveyPath,
type,
data,
createDate:Date.now(),
updateDate:Date.now(),
}
},{
upsert:true //如果不存在则插入
})
return setResult
}
async get({surveyPath,key,type}) {
const surveyKeyStore = await mongo.getCollection({collectionName:'surveyKeyStore'});
const surveyKeyStoreData = await surveyKeyStore.findOne({
key,
surveyPath,
type
})
return surveyKeyStoreData?.data
}
async getAll({surveyPath,keyList,type}) {
const surveyKeyStore = await mongo.getCollection({collectionName:'surveyKeyStore'});
const surveyKeyStoreData = await surveyKeyStore.find({
key:{$in:keyList},
surveyPath,
type
}).toArray()
return this.getKeyStoreResult(surveyKeyStoreData)
}
}
export const surveyKeyStoreService = new SurveyKeyStoreService()

View File

@ -0,0 +1,31 @@
import { mongo } from '../db/mongo'
import { surveyKeyStoreService } from './surveyKeyStoreService'
import {CommonError} from '../types/index'
class SurveyPublishService {
async get({surveyPath}:{surveyPath:string}) {
const surveyMeta = await mongo.getCollection({collectionName:'surveyMeta'});
const surveyMetaData = await surveyMeta.findOne({surveyPath})
if(!surveyMetaData) {
throw new CommonError('该问卷已不存在')
}
const surveyMetaRes = mongo.convertId2StringByDoc(surveyMetaData)
const surveyPublish = await mongo.getCollection({collectionName:'surveyPublish'});
const surveyPublishData = await surveyPublish.findOne({pageId:surveyMetaRes._id.toString()},{sort:{createDate:-1}})
if(!surveyPublishData) {
throw new CommonError('该问卷未发布')
}
const surveyPublishRes = mongo.convertId2StringByDoc(surveyPublishData)
return {
surveyMetaRes,
surveyPublishRes
}
}
async queryVote({surveyPath,voteKeyList}:{surveyPath:string, voteKeyList:Array<string>}) {
return await surveyKeyStoreService.getAll({surveyPath,keyList:voteKeyList,type:'vote'})
}
}
export const surveyPublishService = new SurveyPublishService()

View File

@ -0,0 +1,85 @@
import { mongo } from '../db/mongo'
import { getStatusObject, getMapByKey } from '../utils/index'
import { SURVEY_STATUS, CommonError } from '../types/index'
import { surveyKeyStoreService } from './surveyKeyStoreService'
import * as moment from 'moment'
class SurveySubmitService {
async submit({ surveySubmitData }: { surveySubmitData: any }) {
const surveyMeta = await mongo.getCollection({ collectionName: 'surveyMeta' });
const surveyMetaRes = mongo.convertId2StringByDoc(
await surveyMeta.findOne({ surveyPath: surveySubmitData.surveyPath })
)
if (!surveyMetaRes) {
throw new CommonError('该问卷已不存在,无法提交')
}
const pageId = surveyMetaRes._id.toString()
const surveyPublish = await mongo.getCollection({ collectionName: 'surveyPublish' });
const publishConf = await surveyPublish.findOne({ pageId })
const surveySubmit = await mongo.getCollection({ collectionName: 'surveySubmit' });
if (surveySubmitData.encryptType === 'base64') {
surveySubmitData.data = JSON.parse(decodeURIComponent(Buffer.from(surveySubmitData.data, "base64").toString()))
} else {
surveySubmitData.data = JSON.parse(surveySubmitData.data)
}
// 提交时间限制
const begTime = publishConf?.code?.baseConf?.begTime || 0
const endTime = publishConf?.code?.baseConf?.endTime || 0
if (begTime && endTime) {
const nowStamp = Date.now()
const begTimeStamp = new Date(begTime).getTime()
const endTimeStamp = new Date(endTime).getTime()
if (nowStamp < begTimeStamp || nowStamp > endTimeStamp) {
throw new CommonError('不在答题有效期内')
}
}
// 提交时间段限制
const answerBegTime = publishConf?.code?.baseConf?.answerBegTime || "00:00:00"
const answerEndTime = publishConf?.code?.baseConf?.answerEndTime || "23:59:59"
if (answerBegTime && answerEndTime) {
const nowStamp = Date.now()
const ymdString = moment().format('YYYY-MM-DD');
const answerBegTimeStamp = new Date(`${ymdString} ${answerBegTime}`).getTime()
const answerEndTimeStamp = new Date(`${ymdString} ${answerEndTime}`).getTime()
if (nowStamp < answerBegTimeStamp || nowStamp > answerEndTimeStamp) {
throw new CommonError('不在答题时段内')
}
}
// 提交总数限制
const tLimit = publishConf?.code?.baseConf?.tLimit || 0
if (tLimit > 0) {
// 提升性能可以使用redis
let nowSubmitCount = await surveySubmit.countDocuments({ surveyPath: surveySubmitData.surveyPath }) || 0
if (nowSubmitCount >= tLimit) {
throw new CommonError('超出提交总数限制')
}
}
// 投票信息保存
const dataList = publishConf?.code?.dataConf?.dataList || []
const dataListMap = getMapByKey({ data: dataList, key: 'field' })
const surveySubmitDataKeys = Object.keys(surveySubmitData.data)
for (const field of surveySubmitDataKeys) {
const configData = dataListMap[field]
if (configData && /vote/.exec(configData.type)) {
const voteData = (await surveyKeyStoreService.get({ surveyPath: surveySubmitData.surveyPath, key: field, type: 'vote' })) || { total: 0 }
voteData.total++;
if (!voteData[surveySubmitData.data[field]]) {
voteData[surveySubmitData.data[field]] = 1
} else {
voteData[surveySubmitData.data[field]]++;
}
await surveyKeyStoreService.set({ surveyPath: surveySubmitData.surveyPath, key: field, data: voteData, type: 'vote' })
}
}
// 提交问卷
const surveySubmitRes = await surveySubmit.insertOne({
...surveySubmitData,
pageId,
curStatus: getStatusObject({ status: SURVEY_STATUS.new }),
createDate: Date.now()
})
return surveySubmitRes
}
}
export const surveySubmitService = new SurveySubmitService()

View File

@ -0,0 +1,17 @@
export enum SURVEY_STATUS {
new = "new",
editing = "editing",
pausing = "pausing",
published = "published",
removed = "removed",
}
export class CommonError extends Error {
code:number
errmsg:string
constructor(msg,code=500) {
super(msg)
this.code = code;
this.errmsg = msg;
}
}

View File

@ -0,0 +1,48 @@
import {
createHash
} from 'crypto';
import { CommonError } from '../types/index'
const hash256 = (text) => {
return createHash('sha256').update(text).digest('hex')
}
const undefinedToString = (data) => {
const res = {}
for (const key in data) {
if (data[key] === undefined) {
res[key] = ''
} else {
res[key] = data[key]
}
}
return res;
}
const getSignByData = (sourceData, ts) => {
const data = undefinedToString(sourceData)
const keysArr = Object.keys(data)
keysArr.sort()
let signArr = keysArr.map(key => {
if (typeof data[key] === 'string') {
return `${key}=${encodeURIComponent(data[key])}`
}
return `${key}=${JSON.stringify(data[key])}`
})
const sign = hash256(signArr.join('') + ts)
return `${sign}`
}
export const checkSign = (sourceData) =>{
const sign = sourceData.sign
if(!sign) {
throw new CommonError('请求签名不存在')
}
delete sourceData.sign
const [inSign, ts] = sign.split('.');
const realSign = getSignByData(sourceData, ts)
if(inSign!==realSign) {
throw new CommonError('请求签名异常')
}
return true;
}

View File

@ -0,0 +1,25 @@
import { SURVEY_STATUS } from '../types/index'
import { CommonError } from '../types/index'
import * as Joi from 'joi'
export function getMapByKey({data,key}:{data:Array<any>,key:string}) {
const datamap = {}
for(const item of data) {
datamap[item[key]] = item
}
return datamap
}
export function getStatusObject({status}:{status:SURVEY_STATUS}) {
return {
status,
id: status,
date: Date.now(),
};
}
export function getValidateValue<T=any>(validationResult:Joi.ValidationResult<T>):T {
if(validationResult.error) {
throw new CommonError(validationResult.error.details.map(e=>e.message).join())
}
return validationResult.value;
}

View File

@ -0,0 +1,29 @@
import { SurveyServer } from '../../decorator'
import { Request, Response } from 'koa'
import { createReadStream } from 'fs'
import * as path from 'path'
export default class UI {
@SurveyServer({ type: 'http', method: 'get', routerName: '/render/(.*)' })
async render({ req, res }: {req:Request, res:Response}) {
const filePath = path.join(__dirname, 'public', 'render.html');
res.type = path.extname(filePath)
return createReadStream(filePath)
}
@SurveyServer({ type: 'http', method: 'get', routerName: '/management/(.*)' })
async management({ req, res }: {req:Request, res:Response}) {
const filePath = path.join(__dirname, 'public', 'management.html');
res.type = path.extname(filePath)
return createReadStream(filePath)
}
@SurveyServer({ type: 'http', method: 'get', routerName: '/(.*)' })
async index({ req, res }: {req:Request, res:Response}) {
let reqPath = req.path;
if (req.path === '/') {
reqPath = '/management.html'
}
const filePath = path.join(__dirname, 'public', reqPath);
res.type = path.extname(filePath)
return createReadStream(filePath)
}
}

View File

@ -0,0 +1,7 @@
{
"name": "ui",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"dependencies": {}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>问卷管理端</title>
</head>
<body>
<h2 id="hello">问卷管理端</h2>
</body>
</html>

View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>问卷投放端</title>
</head>
<body>
<h2 id="hello">问卷投放端</h2>
</body>
</html>

View File

@ -0,0 +1,14 @@
const config = {
mongo:{
url: process.env.xiaojuSurveyMongoUrl ||'mongodb://localhost:27017',
dbName:'surveyEengine'
},
jwt:{
secret: process.env.xiaojuSurveyJwtSecret || 'xiaojuSurveyJwtSecret',
expiresIn: process.env.xiaojuSurveyJwtExpiresIn || '8h',
}
}
export function getConfig() {
return config
}

View File

@ -0,0 +1,33 @@
import { Collection, MongoClient, ObjectId } from 'mongodb'
import { getConfig } from '../config/index'
const config = getConfig()
class mongoService {
isInit:boolean
client:MongoClient
constructor() {
this.client = new MongoClient(config.mongo.url);
}
async getCollection({collectionName}):Promise<Collection> {
await this.client.connect()
return this.client.db(config.mongo.dbName).collection(collectionName)
}
convertId2StringByDoc(doc:any):any {
doc._id = doc._id.toString()
return doc;
}
convertId2StringByList(list:Array<any>):Array<any> {
return list.map(e=>{
return this.convertId2StringByDoc(e);
})
}
getObjectIdByStr(str:string) {
return new ObjectId(str)
}
}
export const mongo = new mongoService()

View File

@ -0,0 +1,43 @@
import { SurveyServer } from '../../decorator'
import { Request, Response } from 'koa'
import * as Joi from 'joi'
import { userService } from './service/userService'
import { getValidateValue } from './utils/index'
export default class User {
@SurveyServer({ type: 'http', method: 'post', routerName: '/register' })
async register({ req, res }: { req: Request, res: Response }) {
const userInfo = getValidateValue(Joi.object({
username: Joi.string().required(),
password: Joi.string().required(),
}).validate(req.body, { allowUnknown: true }));
const userRegisterRes = await userService.register(userInfo)
return {
code: 200,
data: userRegisterRes,
}
}
@SurveyServer({ type: 'http', method: 'post', routerName: '/login' })
async login({ req, res }: { req: Request, res: Response }) {
const userInfo = getValidateValue(Joi.object({
username: Joi.string().required(),
password: Joi.string().required(),
}).validate(req.body, { allowUnknown: true }));
const data = await userService.login(userInfo)
return {
code: 200,
data,
}
}
@SurveyServer({ type: 'rpc' })
async getUserByToken({ params, context }: { params: any, context: any }) {
const data = await userService.getUserByToken({ token: params.token })
return {
result: data,
context, // 上下文主要是传递调用方信息使用比如traceid
}
}
}

View File

@ -0,0 +1,13 @@
{
"name": "user",
"version": "1.0.0",
"description": "",
"main": "index.js",
"dependencies": {
"joi": "^17.9.2",
"jsonwebtoken": "^9.0.1",
"lodash": "^4.17.21",
"mongodb": "^5.7.0"
},
"devDependencies": {}
}

View File

@ -0,0 +1,81 @@
import {
verify as jwtVerify,
sign as jwtSign
} from 'jsonwebtoken';
import {
createHash
} from 'crypto';
import { mongo } from '../db/mongo'
import { getStatusObject } from '../utils/index'
import { SURVEY_STATUS, CommonError } from '../types/index'
import { getConfig } from '../config/index'
const config = getConfig()
class UserService {
hash256(text) {
return createHash('sha256').update(text).digest('hex')
}
getToken(userInfo) {
return jwtSign(userInfo, config.jwt.secret, { expiresIn: config.jwt.expiresIn });
}
async register(userInfo:{username:string, password:string}) {
const user = await mongo.getCollection({collectionName:'user'});
const userRes = await user.findOne({
username:userInfo.username,
})
if(userRes) {
throw new CommonError('该用户已存在')
}
const userInsertRes = await user.insertOne({
username:userInfo.username,
password:this.hash256(userInfo.password),
curStatus:getStatusObject({status:SURVEY_STATUS.new}),
createDate:Date.now()
})
const token = this.getToken({
_id: userInsertRes.insertedId.toString(),
username:userInfo.username
});
return {userInsertRes, token, username:userInfo.username}
}
async login(userInfo:{username:string, password:string}) {
const user = await mongo.getCollection({collectionName:'user'});
const userRes = await user.findOne({
username:userInfo.username,
password:this.hash256(userInfo.password),
})
if(!userRes) {
throw new CommonError('用户名或密码错误')
}
const token = this.getToken({
_id: userRes._id.toString(),
username:userInfo.username
});
return {token, username:userInfo.username}
}
async getUserByToken(tokenInfo:{token:string}) {
let userInfo;
try {
userInfo = jwtVerify(tokenInfo.token, config.jwt.secret)
} catch(err) {
throw new CommonError('用户凭证无效或已过期',403)
}
const user = await mongo.getCollection({collectionName:'user'});
const userRes = await user.findOne({
_id:mongo.getObjectIdByStr(userInfo._id),
})
if(!userRes) {
throw new CommonError('用户已不存在')
}
return mongo.convertId2StringByDoc(userRes);
}
}
export const userService = new UserService()

View File

@ -0,0 +1,30 @@
export enum SURVEY_STATUS {
new = "new",
editing = "editing",
pausing = "pausing",
published = "published",
removed = "removed",
}
export enum QUESTION_TYPE {
enps = "enps",
nps = "nps",
question = "question", //通用问卷
register = "register", //报名
vote = "vote", //投票
}
export enum HISTORY_TYPE {
dailyHis = "dailyHis", //保存历史
publishHis = "publishHis", //发布历史
}
export class CommonError extends Error {
code:number
errmsg:string
constructor(msg,code=500) {
super(msg)
this.code = code;
this.errmsg = msg;
}
}

View File

@ -0,0 +1,16 @@
import { SURVEY_STATUS } from '../types/index'
import { CommonError } from '../types/index'
import * as Joi from 'joi'
export function getStatusObject({status}:{status:SURVEY_STATUS}) {
return {
status,
id: status,
date: Date.now(),
};
}
export function getValidateValue<T=any>(validationResult:Joi.ValidationResult<T>):T {
if(validationResult.error) {
throw new CommonError(validationResult.error.details.map(e=>e.message).join())
}
return validationResult.value;
}

19
server/src/decorator.ts Normal file
View File

@ -0,0 +1,19 @@
type ServerType = 'http' | 'websocket' | 'rpc'
export interface ServerValue {
type: ServerType,
method: 'get' | 'post' | 'put' | 'delete' | 'patch' | 'options';
routerName: string;
}
export const SurveyServerConfigKey = Symbol('appConfig') // vm环境和worker环境上下文不一致导致不能使用Symbol
export function SurveyServer(surveyServer) {
return function (target: any, propertyKey: string, _descriptor: PropertyDescriptor) {
if (!target[SurveyServerConfigKey]) {
target[SurveyServerConfigKey] = new Map<string, ServerValue>()
}
target[SurveyServerConfigKey].set(
propertyKey,
surveyServer
)
}
}

21
server/src/index.ts Normal file
View File

@ -0,0 +1,21 @@
import * as os from 'os'
import * as Koa from 'koa';
import * as KoaBodyparser from 'koa-bodyparser'
import * as logger from 'koa-pino-logger'
import { getRouter } from './router'
import { outputCatch } from './middleware/outputCatch'
const app = new Koa();
app.use(outputCatch({ showErrorStack: true }))
app.use(logger())
app.use(KoaBodyparser({
formLimit: '30mb',
jsonLimit: '30mb',
textLimit: '30mb',
xmlLimit: '30mb',
}))
app.use(getRouter().routes())
const port = 8080
app.listen(port)
process.stdout.write(`${os.EOL}server run: http://127.0.0.1:${port} ${os.EOL}`)

View File

@ -0,0 +1,13 @@
export function outputCatch({showErrorStack}:{showErrorStack:boolean}) {
return async function (ctx, next) {
try{
await next()
} catch(err) {
const outputData = {...err}
if(showErrorStack) {
outputData.stack = err.stack;
}
return ctx.body = outputData
}
}
}

39
server/src/router.ts Normal file
View File

@ -0,0 +1,39 @@
import * as Router from 'koa-router';
import { Context } from 'koa'
import { SurveyServerConfigKey } from './decorator';
import Ui from './apps/ui/index'
import SurveyManage from './apps/surveyManage/index'
import SurveyPublish from './apps/surveyPublish/index'
import User from './apps/user/index'
function loadAppRouter(app, appRouter) {
const appServerConfigMap = app[SurveyServerConfigKey]
for (const [serveName, serveValue] of appServerConfigMap) {
const middleware = async (ctx: Context) => {
const data = await app[serveName]({ req: ctx.request, res: ctx.response })
return ctx.body = data
}
const method = serveValue.method || 'all'
const routerName = serveValue.routerName || `/${serveName}`
appRouter[method](routerName, middleware)
}
return appRouter
}
export function getRouter() {
const rootRouter = new Router()
const apiAppMap = {
surveyManage: new SurveyManage(),
surveyPublish: new SurveyPublish(),
user: new User(),
}
for (const [apiAppName, apiApp] of Object.entries(apiAppMap)) {
const appRouter = new Router()
loadAppRouter(apiApp, appRouter)
rootRouter.use(`/api/${apiAppName}`, appRouter.routes(), rootRouter.allowedMethods())
}
loadAppRouter(new Ui(), rootRouter)
return rootRouter
}

11
server/src/rpc.ts Normal file
View File

@ -0,0 +1,11 @@
export async function rpcInvote<P, R>(appServerName: string, params: P): Promise<R> {
const appServerNameData = /^(\w+)\.(\w+)$/.exec(appServerName);
if (!appServerNameData) {
throw new Error('rpc调用必须按照app.function名方式填写app和function名称只支持数字字母下划线')
}
const appName = appServerNameData[1];
const serverName = appServerNameData[2];
const App = require(`./apps/${appName}/index`).default
const instance = new App()
return await instance[serverName](params)
}

16
server/tsconfig.json Normal file
View File

@ -0,0 +1,16 @@
{
"compilerOptions": {
"module": "commonjs",
"target":"ES2020",
"rootDir": "./src/",
"outDir": "./build/",
"declaration": true,
"experimentalDecorators": true,
"sourceMap": true,
"incremental":true,
"tsBuildInfoFile": "./build/tsconfig.tsbuildinfo"
},
"include": [
"src/**/*"
]
}

3
web/.browserslistrc Normal file
View File

@ -0,0 +1,3 @@
> 1%
last 2 versions
not dead

20
web/.eslintrc.js Normal file
View File

@ -0,0 +1,20 @@
module.exports = {
root: false,
env: {
node: true,
},
extends: [
'plugin:vue/essential',
'eslint:recommended',
'plugin:prettier/recommended',
],
parserOptions: {
parser: '@babel/eslint-parser',
},
rules: {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'vue/multi-word-component-names': 'off',
semi: ['error', 'always'],
},
};

27
web/.gitignore vendored Normal file
View File

@ -0,0 +1,27 @@
.DS_Store
node_modules
/dist
/output
# local env files
# .env.*.local 提交 .env.development.local使得用户能够在本地修改
.env.production.local
.env.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
package-lock.json
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.history

5
web/.prettierrc.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
singleQuote: true, // 使用单引号
semi: true, // 不使用分号
// trailingComma: 'all', // 在对象和数组末尾加上逗号
};

44
web/README.md Normal file
View File

@ -0,0 +1,44 @@
## 环境
node版本推荐>=v16.15.0
## 项目启动
1、安装依赖
```
npm install
```
2、启动
```
npm run serve
```
3、访问问卷管理端
```bash
http://localhost:8080/management
```
会自动重定向到问卷列表页,也就是问卷管理端系统的首页
```bash
http://localhost:8080/management/survey
```
问卷管理端所有的页面都需要登陆,需要自行注册账号
4、访问问卷投放端
创建一份问卷并且编辑好之后,需要点击发布,发布后会跳转到问卷投放页面
投放页面能看到问卷的问卷投放端的链接,点击打开即可访问
或者如果你知道问卷配置的surveyPath字段也可以通过下面的路径直接访问某张问卷
```bash
http://localhost:8080/render/:surveyPath
```
5、编译
执行下面的命令即可编译项目
```
npm run build
```
编译结果会产出两个htmlmanagement.html和render.html
6、部署和访问
前端的部署和访问依赖服务端
需要先将整个dist文件夹里面的内容移动到后端的静态文件夹下面
需要后端做一层代理:
当访问路径由/management开头的时候读取management.html的内容返回
当访问路径由/render开头的时候读取render.html的内容返回
此功能已经实现,做一个简单了解即可

11
web/babel.config.js Normal file
View File

@ -0,0 +1,11 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset',
[
'@vue/babel-preset-jsx',
{
injectH: false,
},
],
],
};

19
web/jsconfig.json Normal file
View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "es5",
"module": "esnext",
"baseUrl": "./",
"moduleResolution": "node",
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
]
}
}

56
web/package.json Normal file
View File

@ -0,0 +1,56 @@
{
"name": "web",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint",
"lintfix": "eslint --fix ."
},
"dependencies": {
"@vue/babel-helper-vue-jsx-merge-props": "^1.4.0",
"@vue/babel-preset-jsx": "^1.4.0",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-vue": "1.0.2",
"axios": "^1.4.0",
"clipboard": "^2.0.11",
"core-js": "^3.8.3",
"crypto-js": "^4.1.1",
"element-ui": "^2.15.13",
"moment": "^2.29.4",
"qrcode": "^1.5.3",
"vue": "^2.7.14",
"vue-demi": "^0.14.5",
"vue-router": "^3.5.1",
"vuedraggable": "^2.24.3",
"vuex": "^3.6.2",
"xss": "^1.0.14"
},
"devDependencies": {
"@babel/core": "^7.12.16",
"@babel/eslint-parser": "^7.12.16",
"@vue/cli-plugin-babel": "~5.0.0",
"@vue/cli-plugin-eslint": "~5.0.0",
"@vue/cli-plugin-router": "~5.0.0",
"@vue/cli-plugin-vuex": "~5.0.0",
"@vue/cli-service": "~5.0.0",
"eslint": "^7.32.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-vue": "^8.7.1",
"less-loader": "^11.1.3",
"postcss-import": "^15.1.0",
"postcss-url": "^10.1.3",
"prettier": "^2.4.1",
"sass": "^1.32.7",
"sass-loader": "^12.0.0",
"style-resources-loader": "^1.5.0",
"vue-style-loader": "^4.1.3",
"vue-template-compiler": "^2.7.14"
},
"engines": {
"node": ">=16.11.1",
"npm": ">=8.0.0"
}
}

BIN
web/public/imgs/Logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

BIN
web/public/imgs/avatar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

BIN
web/public/imgs/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

BIN
web/public/imgs/s-logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Some files were not shown because too many files have changed in this diff Show More