Commit 99d9abe3 by 赖慧粮

feat(questionnaire): 问卷ssr项目 & ui

parents
# editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
module.exports = {
root: true,
env: {
browser: true,
node: true
},
parserOptions: {
parser: '@babel/eslint-parser',
requireConfigFile: false
},
extends: [
'@nuxtjs',
'plugin:nuxt/recommended',
'prettier'
],
plugins: [
],
// add your custom rules here
rules: {}
}
# Created by .ignore support plugin (hsz.mobi)
### Node template
# Logs
/logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# 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
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://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/
# TypeScript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# parcel-bundler cache (https://parceljs.org/)
.cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# Nuxt generate
dist
nuxt-dist
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless
# IDE / Editor
.idea
# Service worker
sw.*
# macOS
.DS_Store
# Vim swap files
*.swp
{
"semi": false,
"singleQuote": true,
"eslintIntegration": true,
"jsxBracketSameLine": true,
"tabWidth": 2,
"printWidth": 120,
"endOfLine": "auto"
}
{
"cSpell.words": [
"gdyfe",
"nuxtjs"
]
}
FROM alpine AS builder
WORKDIR /home/app
RUN apk add --no-cache --update nodejs yarn
COPY package.json yarn.lock ./
RUN yarn install --registry=https://registry.yarnpkg.com
FROM registry.cn-hangzhou.aliyuncs.com/open_images/node12.13.1-pm2-zabbix
ADD ./ /var/www/web_questionnaire
ENV HOST 0.0.0.0
ENV TZ Asia/Shanghai
WORKDIR /var/www/web_questionnaire
COPY --from=builder /home/app/package.json ./package.json
COPY --from=builder /home/app/node_modules ./node_modules
RUN yarn build:prod
RUN rm -rf assets components layouts middleware pages plugins store .eslintrc.js .gitignore build.yml pDockerfile README.md pStart.sh
EXPOSE 4001
RUN chmod +x start.sh
ENTRYPOINT ["./start.sh"]
# web-questionnaire
## Build Setup
```bash
# install dependencies
$ yarn install
# serve with hot reload at localhost:3000
$ yarn dev
# build for production and launch server
$ yarn build
$ yarn start
# generate static project
$ yarn generate
```
For detailed explanation on how things work, check out the [documentation](https://nuxtjs.org).
## Special Directories
You can create the following extra directories, some of which have special behaviors. Only `pages` is required; you can delete them if you don't want to use their functionality.
### `assets`
The assets directory contains your uncompiled assets such as Stylus or Sass files, images, or fonts.
More information about the usage of this directory in [the documentation](https://nuxtjs.org/docs/2.x/directory-structure/assets).
### `components`
The components directory contains your Vue.js components. Components make up the different parts of your page and can be reused and imported into your pages, layouts and even other components.
More information about the usage of this directory in [the documentation](https://nuxtjs.org/docs/2.x/directory-structure/components).
### `layouts`
Layouts are a great help when you want to change the look and feel of your Nuxt app, whether you want to include a sidebar or have distinct layouts for mobile and desktop.
More information about the usage of this directory in [the documentation](https://nuxtjs.org/docs/2.x/directory-structure/layouts).
### `pages`
This directory contains your application views and routes. Nuxt will read all the `*.vue` files inside this directory and setup Vue Router automatically.
More information about the usage of this directory in [the documentation](https://nuxtjs.org/docs/2.x/get-started/routing).
### `plugins`
The plugins directory contains JavaScript plugins that you want to run before instantiating the root Vue.js Application. This is the place to add Vue plugins and to inject functions or constants. Every time you need to use `Vue.use()`, you should create a file in `plugins/` and add its path to plugins in `nuxt.config.js`.
More information about the usage of this directory in [the documentation](https://nuxtjs.org/docs/2.x/directory-structure/plugins).
### `static`
This directory contains your static files. Each file inside this directory is mapped to `/`.
Example: `/static/robots.txt` is mapped as `/robots.txt`.
More information about the usage of this directory in [the documentation](https://nuxtjs.org/docs/2.x/directory-structure/static).
### `store`
This directory contains your Vuex store files. Creating a file in this directory automatically activates Vuex.
More information about the usage of this directory in [the documentation](https://nuxtjs.org/docs/2.x/directory-structure/store).
/*
1# 模块分割命名请使用与模块内容较为贴近的命名,模块每个请求必须添加注释以说明用途
2# 键命名规则(建议拼接对应关键词)
查询/获取信息getxxx 新增addxxx 删除removexxx 修改/编辑/保存setxxx
3# 注意post和get传递的参数不同
4# 业务模块需要哪个接口则引入哪个
*/
import { get, post } from "@/api/request";
import CONFIG from "@/config";
const flag = CONFIG.env || CONFIG.private
// 获取dms sub_key
export const getDmsInfo = data =>
flag
? get("/Program/Login/getDmsInfo", { data })
: get("/Program/Login/getDmsInfo", { data, baseURL: CONFIG.privateDomain });
// 获取用户信息
export const getUserInfo = data =>
flag
? get("/picText/live/Content/getUserInfo", { data })
: get("/live/live/pictext/getUserInfo", { data, baseURL: CONFIG.noGateDomain });
// pv 统计
export const setPvInfo = data =>
flag
? post("/Program/Live/PostInfo", data)
: post("/live/postinfo", data, { baseURL: CONFIG.goDomain });
// 微信分享信息
export const getWxShareSecret = data =>
flag
? post("/Program/Share/getWxJsapiPackage", data)
: post("/Program/Share/getWxJsapiPackage", data, { baseURL: CONFIG.privateDomain });
import { post, get } from "@/api/request";
import CONFIG from "@/config";
const flag = CONFIG.env || CONFIG.private
// 获取问卷信息
export const getQuestionnaire = data =>
flag
? get("/activity/Questionnaire/getReportById", { data })
: get("/activity/Questionnaire/getReportById", { data, baseURL: CONFIG.activityDomain });
// 提交问卷
export const setQuestionnaire = data =>
flag
? post("/activity/Questionnaire/submitReport", data)
: post("/activity/Questionnaire/submitReport", data, { baseURL: CONFIG.activityDomain });
import axios from "axios";
import qs from "qs";
// import store from '@/store'
import VueCookie from "vue-cookie";
// message消息弹窗,可自行引入其他插件
import { Toast } from "vant";
// 全局配置文件
import CONFIG from "@/config";
const { env, armsPid } = CONFIG;
const arms = process.client && !CONFIG.private ? require('@/utils/arms') : null
const logger = arms && arms(armsPid, true);
// 环境变量
// 创建axios实例,不污染axios全局
const axiosService = axios.create();
// 默认content-type
axiosService.defaults.headers["Content-Type"] = "application/x-www-form-urlencoded";
// 默认baseURL
axiosService.defaults.baseURL = CONFIG.defDomain;
// 默认请求超时时间
axiosService.defaults.timeout = 3 * 1000;
// 请求拦截器
axiosService.interceptors.request.use(
config => {
const token = VueCookie.get(CONFIG.tokenKey);
const { method, headers } = config;
const data = filterNull(config.data);
// 格式化序列,目前只对post进行处理,可新增其他请求逻辑
if (
method === "post" &&
headers["Content-Type"].includes("application/x-www-form-urlencoded")
) {
config.data = qs.stringify(data);
} else if (method === "get") {
// 可以在调用的时候直接使用data而不用区分get(parms) post(data)
config.params = data;
config.data = null;
// config.data = { unused: 0 } // axios内部处理了get方法,使data无效
}
// 自定义header键:token
if (token) {
config.headers.token = token;
}
// 自定义header键:X-Ca-Stage(环境变量)
if (env) {
config.headers["X-Ca-Stage"] = env;
}
config.metadata = { startTime: new Date() };
return config;
},
error => {
return Promise.reject(error);
}
);
// 响应拦截器
axiosService.interceptors.response.use(
response => {
// 如果返回的状态码为200,说明接口请求成功,可以正常拿到数据
// 否则的话抛出错误
response.config.metadata.endTime = new Date();
response.duration = response.config.metadata.endTime - response.config.metadata.startTime;
const { status, statusText, duration, data, config } = response;
if (status === 200) {
// console.log(data && data.errorCode === 0 && data.code === 200);
if (data && data.errorCode === 0 && data.code === 200) {
// success
} else {
// failed
logger && logger.api(config.url, true, duration, data.errorCode, data.errorMessage);
}
return Promise.resolve(response.data);
} else {
// failed
logger && logger.api(config.url, false, duration, status, statusText);
errorHandle(502);
return Promise.reject(response);
}
},
error => {
error.config.metadata.endTime = new Date();
error.duration = error.config.metadata.endTime - error.config.metadata.startTime;
const { message, duration, config } = error;
if (message.includes("timeout")) {
// url, false, time, res.data.Flag, res.data.FlagString
logger && logger.api(config.url, false, duration, "NetworkTimeout", message);
errorHandle(408);
} else {
logger && logger.api(config.url, false, duration, "NetworkError", message);
errorHandle(504);
}
return Promise.reject(error);
}
);
/**
* 用来判断值类型
* @param {Object} obj
*/
function toType(obj) {
return {}.toString
.call(obj)
.match(/\s([a-zA-Z]+)/)[1]
.toLowerCase();
}
/**
* 对象null值过滤
* @param {Object} o 请求data对象
*/
function filterNull(o) {
for (const key in o) {
if (o[key] === null) {
delete o[key];
} else {
// eslint-disable-next-line no-lonely-if
if (toType(o[key]) === "string") {
o[key] = o[key].trim();
} else if (toType(o[key]) === "object") {
o[key] = filterNull(o[key]);
} else if (toType(o[key]) === "array") {
o[key] = filterNull(o[key]);
}
}
}
return o;
}
/**
* 根据状态码判断请求失败后的错误统一处理
* @param {Number} status 请求失败的状态码
*/
function errorHandle(status) {
let message = "";
switch (status) {
case 401:
// 401: 未登录
// 未登录则跳转登录页面,并携带当前页面的路径
// 在登录成功后返回当前页面,这一步需要在登录页操作。
message = "未授权(401),请重新登录";
// store.dispatch('info/jumpToLogin')
break;
case 403:
// 403 token过期
// 登录过期对用户进行提示
// 清除本地token和清空vuex中token对象
// 跳转登录页面
message = "拒绝访问(403),请刷新后重试";
// 清除token
// removeCookie()
// setTimeout(() => {
// store.dispatch('info/jumpToLogin')
// }, 1000)
break;
case 400:
message = "请求错误(400),请刷新后重试";
break;
case 404:
message = "请求错误(404),网络请求不存在";
break;
case 408:
message = "请求超时(408),请稍后重试";
break;
case 450:
message = "请求异常(450),请求参数错误";
break;
case 500:
message = "服务错误(500),请稍后重试!";
break;
case 502:
message = "网络错误(502),请稍后重试!";
break;
case 503:
message = "服务异常(503),正在维护,请稍等!";
break;
case 504:
message = "网络超时(504),请检查网络!";
break;
case 505:
message = "HTTP版本不受支持(505)";
break;
default:
message = `请求失败(${status}),请检查网络或联系管理员!`;
}
// console.log(message);
Toast.fail(message);
}
/**
* request
* @param {Object} config
* get、delete、head、options
* @param {String} url
* @param {Object} config
* post、put、patch
* @param {String} url
* @param {Object} data
* @param {Object} config
* 如果请求中需要使用与默认值不同的配置,直接传入config覆盖即可
*/
export const { get, post, delete: del, put, request, head, options, patch } = axiosService;
export default axiosService;
This source diff could not be displayed because it is too large. You can view the blob instead.
// rem 单位换算:定为 75px 只是方便运算,750px-75px、640-64px、1080px-108px,如此类推
@vw_fontsize: 75; // iPhone 6尺寸的根元素大小基准值
// @function rem($px) {
// @return ($px / $vw_fontsize) * 1rem;
// }
// 根元素大小使用 vw 单位
@vw_design: 750;
html {
font-size: (@vw_fontsize / (@vw_design / 2)) * 100vw;
// 同时,通过Media Queries 限制根元素最大最小值
@media screen and (max-width: 320px) {
font-size: 64px;
}
@media screen and (min-width: 540px) {
font-size: 108px;
}
}
// body 也增加最大最小宽度限制,避免默认100%宽度的 block 元素跟随 body 而过大过小
body {
max-width: @--body-max-width;
min-width: @--body-min-width;
margin: 0 auto !important;
}
@import './reset.less'; // 去除默认样式
@import 'normalize.css/normalize.css'; // 统一各个浏览器样式的插件
@import './adapt.less'; // 适配
html,
body {
background: #f7f7f7;
-webkit-overflow-scrolling: touch;
}
// 页面进入与离开
.page-enter-active,
.page-leave-active {
transition: all 0.25s cubic-bezier(0.55, 0, 0.1, 1);
opacity: 0;
transform: translate(30px, 0);
}
.page-enter,
.page-leave-active {
opacity: 0;
transform: translate(-30px, 0);
}
/**
* Eric Meyer's Reset CSS v2.0 (http://meyerweb.com/eric/tools/css/reset/)
* http://cssreset.com
*/
/* 去除默认样式 */
* {
box-sizing: border-box;
-webkit-overflow-scrolling: touch;
}
html,
body,
div,
span,
applet,
object,
iframe,
h1,
h2,
h3,
h4,
h5,
h6,
p,
blockquote,
pre,
a,
abbr,
acronym,
address,
big,
cite,
code,
del,
dfn,
em,
img,
ins,
kbd,
q,
s,
samp,
small,
strike,
strong,
sub,
sup,
tt,
var,
b,
u,
i,
center,
dl,
dt,
dd,
ol,
ul,
li,
fieldset,
form,
label,
legend,
table,
caption,
tbody,
tfoot,
thead,
tr,
th,
td,
article,
aside,
canvas,
details,
embed,
figure,
figcaption,
footer,
header,
menu,
nav,
output,
ruby,
section,
summary,
time,
mark,
audio,
video,
input {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font-weight: normal;
vertical-align: baseline;
}
/* HTML5 display-role reset for older browsers */
article,
aside,
details,
figcaption,
figure,
footer,
header,
menu,
nav,
section {
display: block;
}
body,
html {
line-height: 1;
font-family: 'Avenir', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f7f7f7;
}
blockquote,
q {
quotes: none;
}
blockquote:before,
blockquote:after,
q:before,
q:after {
content: none;
}
table {
border-collapse: collapse;
border-spacing: 0;
}
/* custom */
a {
color: #7e8c8d;
text-decoration: none;
-webkit-backface-visibility: hidden;
}
a:focus,
a:active {
outline: none;
}
a,
a:focus,
a:hover {
cursor: pointer;
color: inherit;
text-decoration: none;
}
li {
list-style: none;
}
html,
body {
width: 100%;
}
body {
-webkit-text-size-adjust: none;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
// 页面最大宽度限制
@--body-max-width: 750PX;
// 页面最小宽度限制
@--body-min-width: 320PX;
// 主题色
@--main-color: #3F7DF7;
# build.yml
version: 1.0
vvku:
config:
spa: false
ssr: true
folder: nuxt-dist
proxy: http://172.17.0.1:4001
server: /web\.vvku\.com/
target:
project: Web
location: questionnaire
command:
build: build:vvku
cbn:
config:
spa: false
ssr: true
folder: nuxt-dist
proxy: http://172.17.0.1:4001
server: /web\.cbnbn\.cn/
target:
project: Web
location: questionnaire
command:
build: build:cbn
huawei:
config:
spa: false
ssr: true
folder: nuxt-dist
proxy: http://172.17.0.1:34001
server: /web\.huaguangyun\.cn/
target:
project: Web
location: questionnaire
command:
build: build:huawei
FROM alpine AS builder
WORKDIR /home/app
RUN apk add --no-cache --update nodejs yarn
COPY package.json yarn.lock ./
RUN yarn install --registry=https://registry.yarnpkg.com
FROM registry.cn-hangzhou.aliyuncs.com/open_images/node12.13.1-pm2
ADD ./ /var/www/web_gather
ENV HOST 0.0.0.0
ENV TZ Asia/Shanghai
WORKDIR /var/www/web_gather
COPY --from=builder /home/app/package.json ./package.json
COPY --from=builder /home/app/node_modules ./node_modules
RUN yarn build:cbn
RUN rm -rf assets components layouts middleware pages plugins store .eslintrc.js .gitignore build.yml Dockerfile README.md start.sh
EXPOSE 4001
RUN chmod +x cbnStart.sh
ENTRYPOINT ["./cbnStart.sh"]
#!/bin/sh
yarn run pm2:cbn
while true
do
sleep 5;
done
FROM alpine AS builder
WORKDIR /home/app
RUN apk add --no-cache --update nodejs yarn
COPY package.json yarn.lock ./
RUN yarn install --registry=https://registry.yarnpkg.com
FROM registry.cn-hangzhou.aliyuncs.com/open_images/node12.13.1-pm2
ADD ./ /var/www/web_gather
ENV HOST 0.0.0.0
ENV TZ Asia/Shanghai
WORKDIR /var/www/web_gather
COPY --from=builder /home/app/package.json ./package.json
COPY --from=builder /home/app/node_modules ./node_modules
RUN yarn build:cm
RUN rm -rf assets components layouts middleware pages plugins store .eslintrc.js .gitignore build.yml Dockerfile README.md start.sh
EXPOSE 4001
RUN chmod +x cmStart.sh
ENTRYPOINT ["./cmStart.sh"]
#!/bin/sh
yarn run pm2:cm
while true
do
sleep 5;
done
<template>
<vue-scroll ref="vueScroll" :ops="defaultOpt" @handle-scroll="reachBottom">
<slot></slot>
</vue-scroll>
</template>
<script>
import VueScroll from 'vuescroll'
export default {
components: {
VueScroll,
},
props: {
opt: {
type: Object,
default: () => ({}),
},
},
data() {
return {
defaultOpt: {
bar: {
background: 'rgba(0,0,0,0.2)',
keepShow: false,
size: '6px',
minSize: 0,
},
rail: {
// size: '10px',
// border: '1px solid #E6E6E6',
// gutterOfSide: "5px"
},
scrollPanel: {
scrollingX: false,
},
},
}
},
computed: {
option() {
return Object.assign(this.defaultOpt, this.opt)
},
},
methods: {
// vertical, horizontal, nativeEvent
reachBottom(vertical, horizontal, nativeEvent) {
if (vertical.process === 1) {
this.$emit('reachBottom')
}
this.$emit('onScroll', { vertical, horizontal, nativeEvent })
},
getScrollProcess() {
return this.$refs.vueScroll.getScrollProcess()
},
scrollTo(obj) {
this.$refs.vueScroll.scrollTo(obj)
},
},
}
</script>
<template>
<van-popup
v-model="visible"
:close-on-click-overlay="false"
:overlay-style="{ background: '#fff' }"
class="custom-popup"
>
<div class="loading">
<AudioWave class="loading__audio"></AudioWave>
</div>
</van-popup>
</template>
<script>
import AudioWave from './modules/AudioWave'
export default {
name: 'Load',
components: {
AudioWave,
},
props: {
value: {
type: Boolean,
default: true,
},
},
computed: {
visible: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
},
},
},
}
</script>
<style lang="less" scoped>
.custom-popup {
background-color: transparent;
}
.loading {
// background: rgba(255, 255, 255, 1);
background-color: transparent;
height: 100px;
justify-content: center;
align-items: center;
display: flex;
}
</style>
<template>
<div class="audio-wave">
<span v-for="item in 5" :key="item"></span>
</div>
</template>
<script>
export default {
name: 'AudioWave'
}
</script>
<style lang="less" scoped>
// 块个数
@blockNum: 5;
// 块颜色
@colorOne: #9b59b6;
@colorTwo: #409eff;
// 块宽度
/* prettier-ignore */
@blockWidth: 9PX;
// 块高度
/* prettier-ignore */
@blockHeight: 5PX;
/* prettier-ignore */
@blockActiveHeight: 30PX;
// 块间距
/* prettier-ignore */
@blockSpacing: 2PX;
@parentWidth: (@blockWidth + @blockSpacing) * @blockNum - @blockSpacing;
// @start 开始元素
// @end 结束元素
// @index 索引标记
.audio-wave-child(@start:0, @end: 0, @index: 0 ) when (@start =< @end) {
&:nth-child(@{start}) {
animation: audio_wave 1.5s infinite ease-in-out;
animation-delay: 0.2s * @index;
}
.audio-wave-child((@start + 1), @end, @index: @index + 1);
}
@keyframes audio_wave {
0% {
height: @blockHeight;
background: @colorOne;
}
25% {
height: @blockActiveHeight;
background: @colorTwo;
}
50% {
height: @blockHeight;
background: @colorOne;
}
100% {
height: @blockHeight;
background: @colorOne;
}
}
.audio-wave {
width: @parentWidth;
height: @blockActiveHeight;
display: flex;
justify-content: space-between;
align-items: center;
> span {
display: block;
width: @blockWidth;
height: @blockHeight;
background: @colorOne;
.audio-wave-child(1, @blockNum);
}
}
</style>
<template>
<div class="rainbow">
<figure class="rainbow__wrap">
<div
v-for="item in 5"
:key="item"
class="rainbow__wrap__dot"
:class="{ 'rainbow__wrap__dot--white': item === 1 }"
></div>
</figure>
</div>
</template>
<script>
export default {
name: 'Rainbow'
}
</script>
<style lang="less" scoped>
// 父容器尺寸
@parentWidth: 100px;
@parentHeight: 100px;
// 元素尺寸
@dotWidth: 100px;
@dotHeight: 100px;
@dotColorOne: #33b5e5;
@dotColorTwo: #99cc00;
@dotColorThree: #ffbb33;
@dotColorFour: #ff4444;
// 无限旋转
@keyframes rotate {
0% {
transform: rotate(0);
}
10% {
width: @dotWidth;
height: @dotHeight;
}
66% {
width: @dotWidth / 2.6;
height: @dotHeight / 2.6;
}
100% {
transform: rotate(360deg);
width: @dotWidth;
height: @dotHeight;
}
}
// 上下 y轴上元素动画
@keyframes dotsY {
66% {
opacity: 0.1;
width: @dotWidth / 2.6;
}
77% {
opacity: 1;
width: 0;
}
}
// 左右 x轴上元素动画
@keyframes dotsX {
66% {
opacity: 0.1;
height: @dotHeight / 2.6;
}
77% {
opacity: 1;
height: 0;
}
}
// 透明变化
@keyframes flash {
33% {
opacity: 0;
border-radius: 0%;
}
55% {
opacity: 0.6;
border-radius: 100%;
}
66% {
opacity: 0;
}
}
.rainbow {
width: @parentWidth;
height: @parentHeight;
position: relative;
&__wrap {
position: absolute;
margin: auto;
top: 0;
bottom: 0;
left: 0;
right: 0;
animation: rotate 2.4s linear infinite;
width: @dotWidth;
height: @dotHeight;
&__dot {
position: absolute;
margin: auto;
width: @dotWidth / 2.6;
height: @dotHeight / 2.6;
border-radius: 100%;
transition: all 1s ease;
opacity: 0.8;
&--white {
top: 0;
bottom: 0;
left: 0;
right: 0;
background: white;
animation: flash 2.4s linear infinite;
opacity: 0;
}
&:nth-child(2) {
top: 0;
bottom: 0;
left: 0;
background: @dotColorOne;
animation: dotsY 2.4s linear infinite;
}
&:nth-child(3) {
left: 0;
right: 0;
top: 0;
background: @dotColorTwo;
animation: dotsX 2.4s linear infinite;
}
&:nth-child(4) {
top: 0;
bottom: 0;
right: 0;
background: @dotColorThree;
animation: dotsY 2.4s linear infinite;
}
&:nth-child(5) {
left: 0;
right: 0;
bottom: 0;
background: @dotColorFour;
animation: dotsX 2.4s linear infinite;
}
}
}
}
</style>
<template>
<div class="spin">
<span class="spin__dot"></span>
</div>
</template>
<script>
export default {
name: 'Spin',
props: {
msg: {
type: String,
default: ''
}
}
}
</script>
<style lang="less" scoped>
@dotWidth: 32px;
@dotHeight: 32px;
@keyframes ani-spin-bounce {
0% {
-webkit-transform: scale(0);
transform: scale(0);
}
to {
-webkit-transform: scale(1);
transform: scale(1);
opacity: 0;
}
}
.spin {
width: @dotWidth;
height: @dotHeight;
&__dot {
display: block;
border-radius: 50%;
background-color: #2d8cf0;
width: @dotWidth;
height: @dotHeight;
animation: ani-spin-bounce 1s 0s ease-in-out infinite;
}
}
</style>
<template>
<div class="progress">
<div class="progress__text">{{ total }}道题,当前进度:{{ answeredNum }}/{{ total }}</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'AnswerProgress',
computed: {
...mapGetters({
questionnaireForm: 'questionnaire/questionnaireForm',
answeredNum: 'questionnaire/answeredNum',
}),
total() {
return this.questionnaireForm.length || 1
},
},
}
</script>
<style lang='less' scoped>
.progress {
width: 100%;
height: 14px;
padding: 24px 0;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
box-sizing: content-box;
background-color: #fff;
border-radius: 11px;
&__text {
font-size: 12px;
color: #666;
transform: scale(9/12);
position: relative;
&::before {
content: '';
width: 47px;
height: 1px;
position: absolute;
transform: translateY(-50%);
top: 50%;
left: -55px;
background: linear-gradient(to left, #d5d5d5, #ececec);
}
&::after {
content: '';
width: 47px;
height: 1px;
position: absolute;
transform: translateY(-50%);
top: 50%;
right: -55px;
background: linear-gradient(to right, #d5d5d5, #ececec);
}
}
}
</style>
<template>
<section class="banner">
<a
v-lazy:background-image="bannerImg"
class="banner__wrap"
target="_blank"
:href="bannerLink"
:style="`padding-top: ${paddingTop}%`"
>
<div
v-lazy:background-image="bannerImg"
class="banner__glazing-firefox"
:style="`filter: blur(${filterBlur}px)`"
></div>
<slot></slot>
<div class="banner__glazing" :style="`backdrop-filter: saturate(${saturate}%) blur(${blur}px)`"></div>
</a>
</section>
</template>
<script>
// 默认背景图
import CONFIG from '@/config'
import { mapGetters } from 'vuex'
export default {
name: 'Banner',
props: {
scrollTop: {
type: Number,
default: 0,
},
},
data() {
return {
paddingTop: (200 / 375) * 100,
saturate: 100,
blur: 0,
filterBlur: 0,
}
},
computed: {
...mapGetters({
info: 'questionnaire/questionnaireInfo',
}),
bannerImg() {
const img = this.info.logo || CONFIG.defBanner
return `${img}${CONFIG.ossImageServe}`
},
bannerLink() {
return this.info.url || false
},
},
watch: {
scrollTop: {
handler(nVal) {
const headDistance = 98
const saturateMax = 80
const blurMax = 20
const filterBlurMax = 6
const scroll = nVal > headDistance ? headDistance : nVal <= 0 ? 0 : nVal
const scale = scroll / headDistance
this.saturate = scale * saturateMax + 100
this.blur = scale * blurMax
this.filterBlur = scale * filterBlurMax
this.paddingTop = (200 / 375) * 100 - (scroll / 375) * 100
},
immediate: true,
},
},
}
</script>
<style lang="less" scoped>
.banner {
width: 100%;
display: flex;
overflow: hidden;
&__wrap {
width: 100%;
position: relative;
padding-top: 200 / 375 * 100%;
height: 0;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
&__glazing {
/* chrome safiri 等浏览器下的毛玻璃实现 */
@supports ((-webkit-backdrop-filter: saturate(180%) blur(20px)) or (backdrop-filter: saturate(180%) blur(20px))) {
display: block;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
}
&__glazing-firefox {
display: none;
/* 兼容 firefox 浏览器下的毛玻璃实现 */
@supports not (backdrop-filter: saturate(180%) blur(20px)) {
display: block;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
z-index: 0;
}
}
}
</style>
<template>
<section v-if="'status' in info" class="countdown-bar">
<div class="countdown-bar__icon" :class="{ 'countdown-bar__icon--end': +status === ACTIVITY_STATE.end }"></div>
<div class="countdown-bar__state">
<div v-if="+status !== ACTIVITY_STATE.end" class="countdown-bar__state__progress">
<div class="countdown-bar__state__progress__text">
<span>问卷</span>
<span>倒计时</span>
</div>
<div class="countdown-bar__state__progress__time">
<van-count-down millisecond :time="time">
<template #default="timeData">
<span v-if="timeData.days" class="time-item">
{{ timeData.days | limitNum }}
<span class="time-item__day"></span>
</span>
<span class="time-item">
{{ timeData.hours | formatNum }}
<span>:</span>
</span>
<span class="time-item">
{{ timeData.minutes | formatNum }}
<span>:</span>
</span>
<span class="time-item">{{ timeData.seconds | formatNum }}</span>
</template>
</van-count-down>
</div>
<div class="countdown-bar__state__progress__sign">{{ countdownText }}</div>
</div>
<div v-if="+status === ACTIVITY_STATE.end" class="countdown-bar__state__end">问卷已结束</div>
</div>
</section>
</template>
<script>
// 倒计时逻辑处理
import { mapGetters } from 'vuex'
import { ACTIVITY_STATE, ACTIVITY_STATE_TXT } from '~/utils/constant'
export default {
name: 'CountDownBar',
filters: {
formatNum(val) {
let value = val
if (val < 10) {
value = `0${val}`
}
return value
},
limitNum(val) {
let value = val
if (val > 99) {
value = `99+`
}
return value
},
},
data() {
return {
timer: null,
ACTIVITY_STATE,
}
},
computed: {
...mapGetters({
info: 'questionnaire/questionnaireInfo',
}),
status() {
const { info } = this
return info.status
},
countdownText() {
return ACTIVITY_STATE_TXT?.[this.status]?.countDownText || ''
},
time() {
const { info, status } = this
const now = new Date().getTime()
let timeDiff
if (status === 0) {
timeDiff = info.startTime * 1000 - now
} else if (status === 1) {
timeDiff = info.endTime * 1000 - now
}
return timeDiff
},
},
}
</script>
<style lang='less' scoped>
@countdownHeight: 40px;
.countdown-bar {
height: @countdownHeight;
line-height: @countdownHeight;
display: flex;
&__icon {
width: @countdownHeight;
height: @countdownHeight;
border-radius: 50%;
background-color: #409dff;
background-image: url('../../assets/icon/countdown.png');
background-repeat: no-repeat;
background-position: center;
&--end {
background-color: #c1c1c1;
}
}
&__state {
flex: 1;
font-size: 12px;
color: #333;
&__progress {
display: flex;
height: 100%;
align-items: center;
&__text {
margin: 0 15px 0 10px;
display: flex;
flex-direction: column;
flex-wrap: wrap;
text-align: center;
> span {
line-height: 20px;
color: #333;
font-size: 15px;
font-weight: 400;
text-align: justify;
line-height: 16px;
&:nth-child(2) {
text-align: left;
font-size: 10px;
line-height: 15px;
}
}
}
&__time {
color: #409dff;
font-size: 20px;
margin-right: 10px;
/deep/ .van-count-down {
color: #409dff;
font-size: 20px;
.time-item {
> span {
margin: 0 5px;
}
}
}
}
&__sign {
color: #409dff;
font-weight: 600;
font-size: 12px;
align-self: flex-end;
line-height: 36px;
}
}
&__end {
padding-left: 15px;
font-size: 14px;
color: #c1c1c1;
}
}
}
</style>
<template>
<div class="introduction">
<van-collapse v-model="activeName" class="introduction__collapse" :border="false" accordion>
<van-collapse-item class="introduction__collapse-item" title="问卷介绍" name="1">{{
info.intro
}}</van-collapse-item>
</van-collapse>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'Introduction',
data() {
return {
activeName: '1',
}
},
computed: {
...mapGetters({ info: 'questionnaire/questionnaireInfo' }),
},
components: {},
methods: {},
}
</script>
<style lang='less' scoped>
.introduction {
width: 100%;
font-size: 14px;
line-height: 18px;
background: #fff;
color: #333;
padding: 6px 4px;
&__collapse-item {
/deep/ .van-cell {
&:after {
display: none !important;
}
}
/deep/ .van-cell__title {
span {
font-size: 14px;
font-weight: 500;
color: #333;
}
}
/deep/ .van-collapse-item__content{
padding-top: 0;
padding-bottom: 0;
font-size: 12px;
color: #666;
}
}
}
</style>
<template>
<div class="ques-address-picker">
<van-field
readonly
clickable
name="area"
:value="formItem.answer"
:disabled="disabled"
:rules="rulesConstruct('address', formItem.rule, formItem.type)"
placeholder="点击选择地点"
@click="isShowArea = true"
/>
<van-popup v-model="isShowArea" position="bottom">
<van-area :area-list="mapData" @confirm="onConfirm" @cancel="isShowArea = false" />
</van-popup>
</div>
</template>
<script>
import mapData from '@/assets/js/map.js';
import rulesConstruct from '@/utils/rulesConstruct';
export default {
name: 'QuesAddressPicker',
props: {
info: {
type: Object,
default: () => ({})
},
disabled:{
type: Boolean,
default: false
}
},
data() {
return {
mapData,
rulesConstruct,
isShowArea: false
};
},
computed: {
formItem() {
return this.info;
},
areaList() {
const provinceList = mapData['100000'];
const cityList = Object.assign(...Object.keys(mapData['100000']).map(item => mapData[item]));
const countyList = Object.assign(...Object.keys(cityList).map(item => mapData[item]));
return {
province_list: provinceList,
city_list: cityList,
county_list:countyList
};
}
},
methods: {
onConfirm(value) {
if (value.every(item => !item || item.name === '')) {
this.$toast('请选择完整地区');
return;
}
this.formItem.answer = value
.reduce((arr, currentValue) => {
currentValue && arr.push(currentValue.name);
return arr;
}, [])
.join('-');
this.isShowArea = false;
}
}
};
</script>
<style lang='less' scoped>
.ques-address-picker {
.van-field {
background-color: #fff;
border: 1px solid #D6D6D6;
border-radius: 12px;
&::after {
content: none;
}
}
}
</style>
<template>
<van-field class="ques-checkbox" :rules="rulesConstruct('checkbox', formItem.rule, formItem.type)">
<template #input>
<van-checkbox-group v-if="formItem.option.length" v-model="formItem.answer">
<van-checkbox
v-for="(item, itemIndex) in formItem.option"
:key="itemIndex"
:disabled="disabled"
:name="item.id"
shape="square"
>
<span class="ques-checkbox__text">{{ item.title }}</span>
<img v-if="item.img" v-lazy="item.img" class="ques-checkbox__img" alt @click.stop="previewImg(item.img)" />
</van-checkbox>
</van-checkbox-group>
</template>
</van-field>
</template>
<script>
import rulesConstruct from '@/utils/rulesConstruct'
import { ImagePreview } from 'vant'
export default {
name: 'QuesCheckbox',
props: {
info: {
type: Object,
default: () => ({}),
},
disabled: {
type: Boolean,
default: false,
},
index: {
type: Number,
default: 0,
},
},
data() {
return {
rulesConstruct,
}
},
computed: {
formItem() {
return this.info
},
},
methods: {
previewImg(url) {
ImagePreview([url])
},
},
}
</script>
<style lang='less' scoped>
.ques-checkbox {
&.van-cell {
padding: 0;
}
.van-checkbox {
min-height: 40px;
line-height: 40px;
}
/deep/ .van-checkbox__label {
font-size: 0;
display: flex;
align-items: center;
justify-content: flex-start;
}
&__text {
font-size: 14px;
flex: 1;
}
&__img {
height: 20px;
width: 20px;
display: inline-block;
font-size: 0;
margin-left: 6px;
}
}
</style>
<template>
<div class="ques-date-picker">
<van-field
readonly
clickable
name="datetimePicker"
:value="formItem.answer"
:disabled="disabled"
:rules="rulesConstruct('date', formItem.rule, formItem.type)"
placeholder="点击选择时间"
@click="isShowPicker = true"
/>
<van-popup v-model="isShowPicker" position="bottom">
<van-datetime-picker
v-model="currentValue"
type="date"
:formatter="formatter"
:min-date="minDate"
@confirm="onConfirm"
@cancel="isShowPicker = false"
/>
</van-popup>
</div>
</template>
<script>
import rulesConstruct from '@/utils/rulesConstruct'
export default {
name: 'QuesDatePicker',
props: {
info: {
type: Object,
default: () => ({}),
},
disabled: {
type: Boolean,
default: false,
},
},
data() {
return {
rulesConstruct,
currentValue: new Date(),
isShowPicker: false,
}
},
computed: {
formItem() {
return this.info
},
minDate() {
return new Date('1920-01-01')
},
},
methods: {
formatter(type, val) {
if (type === 'year') {
return `${val}年`
} else if (type === 'month') {
return `${val}月`
} else if (type === 'day') {
return `${val}日`
}
return val
},
onConfirm(time) {
this.formItem.answer = this.$options.filters.formatDate(new Date(time).getTime(), 'YYYY年MM月DD日')
this.isShowPicker = false
},
},
}
</script>
<style lang='less' scoped>
.ques-date-picker {
.van-field {
background-color: #fff;
border: 1px solid #d6d6d6;
border-radius: 12px;
&::after {
content: none;
}
}
}
</style>
<template>
<van-field
v-model="formItem.answer"
class="ques-input"
:placeholder="`请输入${formItem.title}`"
:rules="rulesConstruct('input', formItem.rule, formItem.type)"
:disabled="disabled"
clearable
/>
</template>
<script>
import rulesConstruct from '@/utils/rulesConstruct';
export default {
name: 'QuesInput',
props: {
info: {
type: Object,
default: () => ({})
},
disabled:{
type: Boolean,
default: false
}
},
data() {
return {
rulesConstruct
};
},
computed: {
formItem() {
return this.info;
}
}
};
</script>
<style lang="less" scoped>
.ques-input {
&.van-field {
background-color: #fff;
border: 1px solid #D6D6D6;
border-radius: 12px;
&::after {
content: none;
}
}
}
</style>
<template>
<van-field class="ques-radio" :rules="rulesConstruct('radio', formItem.rule, formItem.type)">
<template #input>
<van-radio-group v-if="formItem.option.length" v-model="formItem.answer">
<van-radio v-for="(item, index) in formItem.option" :key="index" :disabled="disabled" :name="item.id">
<span class="ques-radio__text">{{ item.title }}</span>
<img v-if="item.img" v-lazy="item.img" class="ques-radio__img" alt @click.stop="previewImg(item.img)" />
</van-radio>
</van-radio-group>
</template>
</van-field>
</template>
<script>
import rulesConstruct from '@/utils/rulesConstruct'
import { ImagePreview } from 'vant'
export default {
name: 'QuesRadio',
props: {
info: {
type: Object,
default: () => ({}),
},
disabled: {
type: Boolean,
default: false,
},
},
data() {
return {
rulesConstruct,
}
},
computed: {
formItem() {
return this.info
},
},
methods: {
previewImg(url) {
ImagePreview([url])
},
},
}
</script>
<style lang='less' scoped>
.ques-radio {
&.van-cell {
padding: 0;
}
.van-radio {
min-height: 40px;
line-height: 40px;
}
/deep/ .van-radio__label {
font-size: 0;
display: flex;
align-items: center;
justify-content: flex-start;
}
&__text {
font-size: 14px;
flex: 1;
}
&__img {
height: 20px;
width: 20px;
display: inline-block;
font-size: 0;
margin-left: 6px;
}
}
</style>
<template>
<van-field
v-model="formItem.answer"
class="ques-input"
type="textarea"
:placeholder="`请输入...`"
:rules="rulesConstruct('input', formItem.rule, formItem.type)"
:disabled="disabled"
/>
</template>
<script>
import rulesConstruct from '@/utils/rulesConstruct'
export default {
name: 'QuesTextarea',
props: {
info: {
type: Object,
default: () => ({}),
},
disabled: {
type: Boolean,
default: false,
},
},
data() {
return {
rulesConstruct,
}
},
computed: {
formItem() {
return this.info
},
},
}
</script>
<style lang="less" scoped>
.ques-input {
&.van-field {
background-color: #fff;
border: 1px solid #d6d6d6;
border-radius: 12px;
&::after {
content: none;
}
}
}
</style>
<template>
<div class="question-form">
<van-form scroll-to-error :show-error-message="true" @submit="submit" @failed="notValidPass">
<QuestionFormItem v-for="(item, index) in formData" :key="index" :index="index" :info="item">
<component
:is="questionComponents[item.component]"
:disabled="Boolean(questionnaireInfo.isJoined)"
:info="item"
></component>
</QuestionFormItem>
<div class="question-form__submit">
<van-button
round
block
:disabled="isButtonDisabled"
type="info"
native-type="submit"
color="linear-gradient(to bottom, #3388F7, #256BFD)"
>{{ buttonText }}</van-button
>
</div>
</van-form>
</div>
</template>
<script>
import { mapGetters, mapActions } from 'vuex'
import { cloneDeep } from 'lodash'
import QuestionFormItem from '@/components/Questionnaire/QuestionFormItem'
import {
QuesRadio,
QuesCheckbox,
QuesInput,
QuesTextarea,
QuesDatePicker,
QuesAddressPicker,
} from '@/components/Questionnaire'
import { ACTIVITY_STATE, ACTIVITY_STATE_TXT } from '~/utils/constant'
export default {
name: 'QuestionForm',
components: {
QuestionFormItem,
},
data() {
return {
questionComponents: {
radio: QuesRadio,
checkbox: QuesCheckbox,
singleInput: QuesInput,
multipleInput: QuesTextarea,
datePicker: QuesDatePicker,
addressPicker: QuesAddressPicker,
},
formData: [],
}
},
computed: {
...mapGetters({
questionnaireInfo: 'questionnaire/questionnaireInfo',
questionnaireForm: 'questionnaire/questionnaireForm',
isLogin: 'users/isLogin',
}),
isButtonDisabled() {
const { questionnaireInfo } = this
return +questionnaireInfo.status !== ACTIVITY_STATE.start
},
buttonText() {
const { questionnaireInfo } = this
return ACTIVITY_STATE_TXT?.[questionnaireInfo.status]?.buttonText || '提交问卷'
},
},
watch: {
questionnaireForm: {
handler(nVal) {
this.formData = cloneDeep(nVal)
},
deep: true,
immediate: true,
},
formData: {
handler(nVal) {
const count = nVal.reduce((total, currentValue) => {
if (currentValue && currentValue.answer && currentValue.answer !== '' && currentValue.answer.length !== 0) {
total += 1
}
return total
}, 0)
// 更新已填选项总量
this.$store.commit('questionnaire/SET_ANSWEREDNUM', count, { root: true })
},
deep: true,
},
},
methods: {
...mapActions({
submitAnswer: 'questionnaire/submitAnswer',
}),
submit() {
this.$dialog
.confirm({
title: '提示',
message: '提交后无法修改,是否提交?',
})
.then(() => {
// on confirm
this.submitAnswer({
id: this.$route.params.id,
config: JSON.stringify(this.formData),
})
})
.catch(() => {
// on cancel
})
},
notValidPass() {
this.$toast('请检查表单是否填写完整!')
},
},
}
</script>
<style lang='less' scoped>
.question-form {
/deep/ .van-form {
height: 100%;
}
&__submit {
position: fixed;
left: 0;
bottom: 0;
z-index: 1;
height: 40px;
margin-bottom: 48px;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
.van-button {
width: 175px;
}
}
}
</style>
<template>
<van-cell-group class="question-form-item">
<div class="question-form-item__wrap">
<div class="question-form-item__top">
<div class="question-form-item__index">
<span :class="{ 'question-form-item__hide-required': !info.rule.required }">*</span>
{{ (index + 1).toString().padStart(2, '0') }}
</div>
<div class="question-form-item__title">{{ info.title }}</div>
<div class="question-form-item__type">{{ info.component | showType }}</div>
</div>
<div class="question-form-item__content">
<slot></slot>
</div>
</div>
</van-cell-group>
</template>
<script>
import { QUESTION_TYPE } from '~/utils/constant'
export default {
name: 'QuestionFormItem',
filters: {
showType(type) {
if (type === 'radio') {
return '单选'
}
if (type === 'checkbox') {
return '多选'
}
return ''
},
},
props: {
index: {
type: Number,
default: 1,
},
info: {
type: Object,
default: () => ({}),
},
},
data() {
return {
QUESTION_TYPE,
}
},
}
</script>
<style lang='less' scoped>
.question-form-item {
background: #fff;
margin-top: 15px;
&:after {
display: none;
}
&:first-child {
margin-top: 0;
}
&__top {
display: flex;
justify-content: flex-start;
align-items: center;
min-height: 40px;
font-size: 14px;
}
&__index {
align-self: flex-start;
padding: 0 8px;
height: 40px;
line-height: 40px;
color: @--main-color;
font-size: 18px;
font-weight: 500;
> span {
color: #ee0a24;
line-height: 21px;
vertical-align: middle;
display: inline-block;
transform: translateY(1px);
}
}
&__hide-required {
visibility: hidden;
}
&__title {
padding: 5px 8px 5px 0;
text-align: left;
flex: 1;
line-height: 20px;
font-size: 18px;
color: #333;
font-weight: 500;
}
&__type {
align-self: start;
color: #217fff;
height: 40px;
line-height: 40px;
margin-right: 14px;
}
&__content {
padding: 10px 20px;
}
}
</style>
<template>
<div class="user-info">
<div class="user-info__logged" v-if="isLogin">您好,{{userInfo.userNick}}</div>
<div class="user-info__login" v-else>
<a @click="jump">登录</a>
,即可参与填写问卷!
</div>
</div>
</template>
<script>
import { mapGetters, mapActions } from 'vuex';
export default {
name: 'UserInfo',
data() {
return {
};
},
computed: {
...mapGetters({ isLogin: 'users/isLogin', userInfo: 'users/userInfo' }),
},
methods: {
...mapActions({ jumpToLogin: 'users/jumpToLogin' }),
jump() {
this.jumpToLogin()
}
}
};
</script>
<style lang='less' scoped>
@UserInfoHeight: 18px;
.user-info {
font-size: 14px;
height: @UserInfoHeight;
line-height: @UserInfoHeight;
text-indent: 4px;
color: #333;
&__login {
> a {
color: #409dff;
cursor: pointer;
}
}
}
</style>
<template>
<div class="validity">截止日期:{{ endTime | formatDate }}</div>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'Validity',
computed: {
...mapGetters({
info: 'questionnaire/questionnaireInfo',
}),
endTime() {
return this.info?.endTime ? this.info.endTime * 1000 : ''
},
},
}
</script>
<style lang='less' scoped>
.validity {
background-color: rgba(0, 0, 0, 0.3);
color: #fff;
font-size: 12px;
padding: 5px 10px;
border-radius: 50px;
}
</style>
// 回答进度
export { default as AnswerProgress } from '@/components/Questionnaire/AnswerProgress'
// 宣传图
export { default as Banner } from '@/components/Questionnaire/Banner'
// 介绍
export { default as Introduction } from '@/components/Questionnaire/Introduction'
// 倒计时
export { default as CountDownBar } from '@/components/Questionnaire/CountDownBar'
// 截止日期
export { default as Validity } from '@/components/Questionnaire/Validity'
// 用户信息
export { default as UserInfo } from '@/components/Questionnaire/UserInfo'
// 问题表单
export { default as QuestionForm } from '@/components/Questionnaire/QuestionForm'
/* 组件 */
// 单选
export { default as QuesRadio } from '@/components/Questionnaire/QuestionComponents/QuesRadio'
// 多选
export { default as QuesCheckbox } from '@/components/Questionnaire/QuestionComponents/QuesCheckbox'
// 填空
export { default as QuesInput } from '@/components/Questionnaire/QuestionComponents/QuesInput'
// 文本域
export { default as QuesTextarea } from '@/components/Questionnaire/QuestionComponents/QuesTextarea'
// 时间选择
export { default as QuesDatePicker } from '@/components/Questionnaire/QuestionComponents/QuesDatePicker'
// 地址选择
export { default as QuesAddressPicker } from '@/components/Questionnaire/QuestionComponents/QuesAddressPicker'
const packageEnv = require('./package.json')
export default {
defDomain: `${process.env.DEF_DOMAIM}/v1`,
noGateDomain: process.env.NO_GATE_DOMAIM,
goDomain: `${process.env.GO_DOMAIN}/v1`,
activityDomain: `${process.env.ACTIVITY_DOMAIN}/v1`,
privateDomain: `${process.env.PRIVATE_DOMAIN}/v1`,
defBanner: `${process.env.DEF_OSS}/common/img/questionnaire_banner_default_v2.png`,
defShareImg: `${process.env.DEF_OSS}/common/img/questionnaire_share.png`,
env: process.env.X_CA_STAGE,
// env: 'PRE',
ossImageServe: process.env.private
? ''
: '?x-oss-process=style/mobilebackground',
tokenKey: 'token',
armsPid: 'huh7k89btk@ddb5a66303d699e',
private: process.env.private,
}
/* sentry */
export const sentryOptions = {
dsn: `https://da0df316aab442148b3f7a848662ddd2@sentry.guangdianyun.tv/23`,
version: packageEnv.version,
env: process.env.X_CA_STAGE,
name: packageEnv.name,
}
/* 谷歌统计 */
export const GTAG_ID = 'G-9LKWZ4ZFJ7'
/* 正式:私有化部署(private)环境变量 */
module.exports = {
X_CA_STAGE: '',
private: true,
DEF_OSS: '//static.cbnbn.cn',
DEF_DOMAIM: '//cgateway.cbnbn.cn',
ROP_DOMAIN: 'cbnbn.cn',
VUE_APP_HTTP_PROT: '',
VUE_APP_HTTPS_PROT: ''
}
/* 正式:私有化部署(private)环境变量 */
module.exports = {
X_CA_STAGE: '',
private: true,
DEF_OSS: '//eos-beijing-4.cmecloud.cn/resource',
DEF_DOMAIM: '//cgateway.aodiansoft.com',
ROP_DOMAIN: 'aodiansoft.com',
VUE_APP_HTTP_PROT: ':8680',
VUE_APP_HTTPS_PROT: ':843'
}
/* 测试(dev)环境变量 */
module.exports = {
X_CA_STAGE: 'TEST',
run_server: 'development',
}
/* 写在该文件下的所有配置在所有环境下都会设置 且值可被覆盖 */
module.exports = {
baseUrl: '/questionnaire/',
DEF_DOMAIM: '//apiliveroom.dev.guangdianyun.tv',
NO_GATE_DOMAIM:
'//1812501212048408.cn-hangzhou.fc.aliyuncs.com/2016-08-15/proxy/gdyactivity.online',
GO_DOMAIN: '//golivec.guangdianyun.tv',
PRIVATE_DOMAIN: '//privateapi.guangdianyun.tv',
ACTIVITY_DOMAIN: '//activity.guangdianyun.tv',
ROP_DOMAIN: 'aodianyun.com',
DEF_OSS: '//static-pro.guangdianyun.tv',
VUE_APP_X_CA_STAGE: 'TEST',
private: false,
VUE_APP_TOKEN: 'token',
VUE_APP_HTTP_PROT: '',
VUE_APP_HTTPS_PROT: '',
run_port: 4001,
}
/* 正式:私有化部署(private)环境变量 */
module.exports = {
X_CA_STAGE: '',
private: true,
DEF_OSS: '//g-resource.obs.cn-north-4.myhuaweicloud.com',
DEF_DOMAIM: '//cgateway.huaguangyun.cn',
ROP_DOMAIN: 'huaguangyun.cn',
VUE_APP_HTTP_PROT: '',
VUE_APP_HTTPS_PROT: '',
run_port: 34001
}
/* 灰度(pre)环境变量 */
module.exports = {
X_CA_STAGE: 'PRE',
run_server: 'preview',
}
/* 正式(prod)环境变量 */
module.exports = {
X_CA_STAGE: '',
run_server: 'production',
}
module.exports = {
X_CA_STAGE: '',
private: true,
DEF_OSS: '//static.vvku.com',
DEF_DOMAIM: '//cgateway.vvku.com',
ROP_DOMAIN: 'vvku.com',
VUE_APP_HTTP_PROT: '',
VUE_APP_HTTPS_PROT: ''
}
import Vue from 'vue'
// https://webpack.js.org/guides/dependency-management/#requirecontext
const modulesFiles = require.context('./modules', true, /\.js$/)
// you do not need `import app from './modules/app'`
// it will auto require all vuex module from modules file
modulesFiles.keys().forEach(modulePath => {
const moduleName = modulePath.replace(/^\.\/(.*)\.\w+$/, '$1')
const value = modulesFiles(modulePath)
Vue.filter(moduleName, value.default)
})
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
export default (time, format = 'YYYY-MM-DD HH:mm') => {
if (!time) return ''
return dayjs(time).locale('zh-cn').format(format)
}
/**
* 阿拉伯数字转中文数字,
* 如果传入数字时则最多处理到21位,超过21位js会自动将数字表示成科学计数法,导致精度丢失和处理出错
* 传入数字字符串则没有限制
* @param {number|string} digit
*/
export default (digit) => {
digit = typeof digit === 'number' ? String(digit) : digit;
const zh = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九'];
const unit = ['千', '百', '十', ''];
const quot = ['万', '亿', '兆', '京', '垓', '秭', '穰', '沟', '涧', '正', '载', '极', '恒河沙', '阿僧祗', '那由他', '不可思议', '无量', '大数'];
let breakLen = Math.ceil(digit.length / 4);
let notBreakSegment = digit.length % 4 || 4;
let segment;
let zeroFlag = [], allZeroFlag = [];
let result = '';
while (breakLen > 0) {
if (!result) { // 第一次执行
segment = digit.slice(0, notBreakSegment);
let segmentLen = segment.length;
for (let i = 0; i < segmentLen; i++) {
if (segment[i] != 0) {
if (zeroFlag.length > 0) {
result += '零' + zh[segment[i]] + unit[4 - segmentLen + i];
// 判断是否需要加上 quot 单位
if (i === segmentLen - 1 && breakLen > 1) {
result += quot[breakLen - 2];
}
zeroFlag.length = 0;
} else {
result += zh[segment[i]] + unit[4 - segmentLen + i];
if (i === segmentLen - 1 && breakLen > 1) {
result += quot[breakLen - 2];
}
if(result === '二'){
result = '两'
}
}
} else {
// 处理为 0 的情形
if (segmentLen == 1) {
result += zh[segment[i]];
break;
}
zeroFlag.push(segment[i]);
continue;
}
}
} else {
segment = digit.slice(notBreakSegment, notBreakSegment + 4);
notBreakSegment += 4;
for (let j = 0; j < segment.length; j++) {
if (segment[j] != 0) {
if (zeroFlag.length > 0) {
// 第一次执行zeroFlag长度不为0,说明上一个分区最后有0待处理
if (j === 0) {
result += quot[breakLen - 1] + zh[segment[j]] + unit[j];
} else {
result += '零' + zh[segment[j]] + unit[j];
}
zeroFlag.length = 0;
} else {
result += zh[segment[j]] + unit[j];
}
// 判断是否需要加上 quot 单位
if (j === segment.length - 1 && breakLen > 1) {
result += quot[breakLen - 2];
}
} else {
// 第一次执行如果zeroFlag长度不为0, 且上一划分不全为0
if (j === 0 && zeroFlag.length > 0 && allZeroFlag.length === 0) {
result += quot[breakLen - 1];
zeroFlag.length = 0;
zeroFlag.push(segment[j]);
} else if (allZeroFlag.length > 0) {
// 执行到最后
if (breakLen == 1) {
result += '';
} else {
zeroFlag.length = 0;
}
} else {
zeroFlag.push(segment[j]);
}
if (j === segment.length - 1 && zeroFlag.length === 4 && breakLen !== 1) {
// 如果执行到末尾
if (breakLen === 1) {
allZeroFlag.length = 0;
zeroFlag.length = 0;
result += quot[breakLen - 1];
} else {
allZeroFlag.push(segment[j]);
}
}
continue;
}
}
--breakLen;
}
return result;
}
}
FROM alpine AS builder
WORKDIR /home/app
RUN apk add --no-cache --update nodejs yarn
COPY package.json yarn.lock ./
RUN yarn install --registry=https://registry.yarnpkg.com
FROM registry.cn-hangzhou.aliyuncs.com/open_images/node12.13.1-pm2
ADD ./ /var/www/web_gather
ENV HOST 0.0.0.0
ENV TZ Asia/Shanghai
WORKDIR /var/www/web_gather
COPY --from=builder /home/app/package.json ./package.json
COPY --from=builder /home/app/node_modules ./node_modules
RUN yarn build:huawei
RUN rm -rf assets components layouts middleware pages plugins store .eslintrc.js .gitignore build.yml Dockerfile README.md start.sh
EXPOSE 34001
RUN chmod +x huaweiStart.sh
ENTRYPOINT ["./huaweiStart.sh"]
#!/bin/sh
yarn run pm2:huawei
while true
do
sleep 5;
done
# LAYOUTS
**This directory is not required, you can delete it if you don't want to use it.**
This directory contains your Application Layouts.
More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/views#layouts).
<template>
<div class="container">
<nuxt />
</div>
</template>
<script>
export default {}
</script>
<style lang="less" scoped>
.container {
margin: 0 auto;
width: 100%;
height: 100vh;
}
</style>
# MIDDLEWARE
**This directory is not required, you can delete it if you don't want to use it.**
This directory contains your application middleware.
Middleware let you define custom functions that can be run before rendering either a page or a group of pages.
More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/routing#middleware).
export default function (context) {
context.userAgent = process.server
? context.req.headers['user-agent']
: navigator.userAgent
}
/* eslint-disable nuxt/no-cjs-in-config */
const path = require('path')
/* version from package.json */
// import packageEnv from './package.json'
/* env configs */
const globalEnvConfig = require(`./config/global.js`)
const envConfig = require(`./config/${process.env.mode || 'production'}.js`)
const env = Object.assign(globalEnvConfig, envConfig)
/* ali-oss */
const WebpackAliOSSPlugin = require('@gdyfe/webpack-alioss-plugin')
const time = WebpackAliOSSPlugin.getFormat('YYMMDD')
const flag = process.env.NODE_ENV === 'production' && !env.private
const plugins = flag
? [
// 优化打包速度
new WebpackAliOSSPlugin({
accessKeyId: 'LTAIIgVna8MHelpI',
accessKeySecret: 'fqQNjEGxww3ZZJ9GbUfNp2yzsWUcnl',
region: 'oss-cn-hangzhou',
bucket: `guangdianyun-static-${env.run_server}`,
prefix: 'questionnaire',
limit: 10, // 备份最近 3 个版本的 oss 文件
format: time,
exclude: [/.*\.html$/], // 或者 /.*\.html$/,排除.html文件的上传
deleteAll: false, // 优先匹配 format 配置项
output: path.resolve(__dirname, './nuxt-dist/dist/client'),
local: true, // 上传打包输出目录里的文件
}),
]
: []
export default {
buildDir: 'nuxt-dist',
env,
router: {
base: globalEnvConfig.baseUrl,
middleware: ['device'],
extendRoutes(routes, resolve) {
routes.push({
path: '/index/:id',
components: {
default: resolve(__dirname, 'pages/index'), // or routes[index].component
},
})
},
},
// Global page headers: https://go.nuxtjs.dev/config-head
head: {
title: '',
meta: [
{ charset: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ hid: 'description', name: 'description', content: '' },
{ name: 'format-detection', content: 'telephone=no' },
{
hid: 'description',
name: 'description',
content: '问卷报表',
},
],
link: [
{
rel: 'stylesheet',
href: `${env.DEF_OSS}/common/icon/iconfont.css`,
ssr: false,
},
{ rel: 'icon', href: `${env.DEF_OSS}/common/img/gdy_favicon.png` },
{
href: '//apiliveroom.dev.guangdianyun.tv/v1',
rel: 'dns-prefetch',
},
{
href: '//activity.guangdianyun.tv',
rel: 'dns-prefetch',
},
{
href: '//static-pro.guangdianyun.tv',
rel: 'dns-prefetch',
},
{
href: '//res.wx.qq.com',
rel: 'dns-prefetch',
},
{
href: '//www.googletagmanager.com',
rel: 'dns-prefetch',
},
],
script: [
// {
// type: 'text/javascript',
// src: '//res.wx.qq.com/open/js/jweixin-1.4.0.js',
// defer: true,
// body: true,
// },
],
},
// Global CSS: https://go.nuxtjs.dev/config-css
css: ['@/assets/styles/main.less'],
// Plugins to run before rendering page: https://go.nuxtjs.dev/config-plugins
plugins: [
{ src: '@/plugins/vant' },
{ src: '@/plugins/gtag' },
{ src: '@/plugins/DeviceId', ssr: false },
{ src: '@/filters' },
],
// Auto import components: https://go.nuxtjs.dev/config-components
components: true,
// Modules for dev and build (recommended): https://go.nuxtjs.dev/config-modules
buildModules: [
// https://go.nuxtjs.dev/eslint
'@nuxtjs/eslint-module',
'@nuxtjs/style-resources',
],
/* less全局变量 */
styleResources: {
less: './assets/styles/variable.less',
},
// Modules: https://go.nuxtjs.dev/config-modules
modules: ['@nuxtjs/axios'],
/*
** Axios module configuration
** See https://axios.nuxtjs.org/options
*/
axios: {},
server: {
port: env.run_port, // default: 3000
host: '0.0.0.0', // default: localhost
},
// Build Configuration: https://go.nuxtjs.dev/config-build
build: {
publicPath: !env.private
? `//static-${env.run_server}.guangdianyun.tv/questionnaire/${time}`
: `/${env.X_CA_STAGE.toLowerCase() || 'prod'}/`,
transpile: [/vant.*?less/],
babel: {
plugins: [
[
'import',
{
libraryName: 'vant',
style: (name) => {
return `${name}/style/less.js`
},
},
'vant',
],
],
},
postcss: {
plugins: {
'postcss-pxtorem': {
rootValue: 75,
selectorBlackList: [/^html$/, /^body$/], // 忽略转换正则匹配项
propList: ['*'],
},
},
},
plugins,
},
}
{
"name": "web-questionnaire",
"version": "1.0.0",
"private": true,
"license": "UNLICENSED",
"scripts": {
"serve": "cross-env mode=dev nuxt",
"serve:dev": "cross-env mode=dev nuxt",
"serve:pre": "cross-env mode=pre nuxt",
"serve:prod": "cross-env mode=production nuxt",
"serve:cm": "cross-env mode=cm.private nuxt",
"serve:vvku": "cross-env mode=vvku.private nuxt",
"serve:cbn": "cross-env mode=cbn.private nuxt",
"serve:huawei": "cross-env mode=huawei.private nuxt",
"build": "cross-env mode=dev nuxt build",
"build:dev": "cross-env mode=dev nuxt build",
"build:pre": "cross-env mode=pre nuxt build",
"build:prod": "cross-env mode=production nuxt build",
"build:cm": "cross-env mode=cm.private nuxt build",
"build:vvku": "cross-env mode=vvku.private nuxt build",
"build:cbn": "cross-env mode=cbn.private nuxt build",
"build:huawei": "cross-env mode=huawei.private nuxt build",
"start": "cross-env mode=dev nuxt start",
"start:dev": "cross-env mode=dev nuxt start",
"start:pre": "cross-env mode=pre nuxt start",
"start:prod": "cross-env mode=production nuxt start",
"start:cm": "cross-env mode=cm.private nuxt start",
"start:vvku": "cross-env mode=vvku.private nuxt start",
"start:cbn": "cross-env mode=cbn.private nuxt start",
"start:huawei": "cross-env mode=huawei.private nuxt start",
"pm2:dev": "pm2 start pm2.json --only questionnaire_test",
"pm2:pre": "pm2 start pm2.json --only questionnaire_pre",
"pm2": "pm2 start pm2.json --only questionnaire",
"pm2:cm": "pm2 start pm2.json --only questionnaire_cm",
"pm2:vvku": "pm2 start pm2.json --only questionnaire_vvku",
"pm2:cbn": "pm2 start pm2.json --only questionnaire_cbn",
"pm2:huawei": "pm2 start pm2.json --only questionnaire_huawei",
"generate": "nuxt generate",
"lint:js": "eslint --ext \".js,.vue\" --ignore-path .gitignore .",
"lint:style": "stylelint \"**/*.{vue,css}\" --ignore-path .gitignore",
"lint": "yarn lint:js && yarn lint:style"
},
"dependencies": {
"alife-logger": "^1.8.6",
"core-js": "^3.15.1",
"dayjs": "^1.10.6",
"normalize.css": "^8.0.1",
"nuxt": "^2.15.7",
"vant": "^2.12.22",
"vue-cookie": "^1.1.4",
"vue-gtag": "^1.16.1",
"vuescroll": "^4.17.3",
"weixin-js-sdk": "^1.6.0"
},
"devDependencies": {
"@babel/eslint-parser": "^7.14.7",
"@gdyfe/webpack-alioss-plugin": "^0.1.6",
"@nuxtjs/axios": "^5.13.6",
"@nuxtjs/eslint-config": "^6.0.1",
"@nuxtjs/eslint-module": "^3.0.2",
"@nuxtjs/style-resources": "^1.2.0",
"@vant/touch-emulator": "^1.3.2",
"babel-plugin-import": "^1.13.3",
"cross-env": "^7.0.3",
"eslint": "^7.29.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-nuxt": "^2.0.0",
"eslint-plugin-vue": "^7.12.1",
"less": "^3.11.1",
"less-loader": "^7.3.0",
"postcss-pxtorem": "^5.1.1",
"prettier": "^2.3.2"
}
}
<template>
<div class="back_img"></div>
</template>
<script>
export default {
mounted() {
document.title = "抱歉,您的页面迷路了!";
}
};
</script>
<style lang="less">
.back_img {
width: 100%;
height: 100vh;
background: url("../../assets/images/404.png") no-repeat;
background-size: cover;
background-position: center;
}
</style>
<template>
<section class="questionnaire">
<Banner class="questionnaire__banner" :scroll-top="scrollTop">
<Validity class="questionnaire__validity"></Validity>
</Banner>
<div class="questionnaire__main">
<Scroll class="questionnaire__scroll" @onScroll="onScroll">
<div ref="scroll" class="questionnaire__scroll-wrap">
<Introduction class="questionnaire__introduction"></Introduction>
<div ref="progressWrap" class="questionnaire__sticky-wrap" :style="stickyPlaceholder">
<div :class="{ 'questionnaire__progress--sticky': isProgressSticky }">
<AnswerProgress></AnswerProgress>
</div>
</div>
<UserInfo v-if="false" class="questionnaire__user-info"></UserInfo>
<QuestionForm class="questionnaire__form"></QuestionForm>
</div>
</Scroll>
</div>
<Loading v-if="false" v-model="pageLoading"></Loading>
</section>
</template>
<script>
/* 组件 */
import { Banner, Validity, Introduction, AnswerProgress, UserInfo, QuestionForm } from '@/components/Questionnaire'
import Loading from '@/components/Loading'
import Scroll from '@/components/Common/Scroll'
import { mapGetters, mapActions } from 'vuex'
import debounce from 'lodash/debounce'
export default {
components: {
Banner,
Validity,
Introduction,
AnswerProgress,
UserInfo,
QuestionForm,
Loading,
Scroll,
},
data() {
return {
scrollTop: 0,
stickyPlaceholder: {},
isProgressSticky: false,
}
},
computed: {
...mapGetters({
userInfo: 'users/userInfo',
isPageLoading: 'common/isPageLoading',
isLoading: 'questionnaire/isLoading',
isError: 'questionnaire/isError',
}),
pageLoading: {
get() {
return this.isPageLoading
},
set(nVal) {
this.updateIsPageLoading(nVal)
},
},
},
mounted() {
this.dataInit()
},
methods: {
...mapActions({
postInfo: 'users/postInfo',
getUerInfo: 'users/getUerInfo',
updateIsPageLoading: 'common/updateIsPageLoading',
getQuestionnaireInfo: 'questionnaire/getQuestionnaireInfo',
}),
dataInit() {
const { uin, id } = this.$route.query
if (uin && uin !== 0) {
// 用户数据加载
this.getUerInfo({ uin })
// pv统计
this.postInfo(id)
// 加载列表
this.loadData()
} else {
this.$router.push({ path: '/404' })
}
},
changeIsError(val) {
this.$store.commit('quesitonnaire/SET_ISERROR', val, { root: true })
},
// 加载数据
loadData() {
const { id } = this.$route.params
const { referKey } = this.$route.query
this.getQuestionnaireInfo({ id, referKey })
},
onScroll({ vertical }) {
this.scrollTop = vertical?.scrollTop || 0
this.stickyListener()
},
stickyListener: debounce(
function () {
const targetDom = this.$refs?.progressWrap
if (targetDom) {
const domRect = targetDom.getBoundingClientRect()
if (domRect.top <= 80) {
this.isProgressSticky = true
this.stickyPlaceholder = { height: `${domRect.height}px` }
} else {
this.isProgressSticky = false
this.stickyPlaceholder = {}
}
}
},
100,
{ maxWait: 100 }
),
},
}
</script>
<style lang="less">
@bannerHeight: 150px; // 上半部分高度
.questionnaire {
margin: 0 auto;
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
&__validity {
position: absolute;
top: 140 / 200 * 100%;
right: 74 / 375 * 100%;
}
&__main {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
&__scroll {
padding: 80px 16px 24px !important;
.__panel {
border-radius: 11px;
}
.__view {
min-height: unset !important;
}
}
&__scroll-wrap {
box-shadow: 0px 2px 4px 0px rgba(189, 189, 189, 0.5);
// FIXME Comput margin-top: 98px;
margin-top: 30%;
padding-bottom: 88px;
background-color: #fff;
border-radius: 11px;
overflow: hidden;
}
&__progress {
&--sticky {
position: fixed;
top: 80px;
z-index: 99;
left: 0;
right: 0;
padding: 0 16px;
max-width: @--body-max-width;
margin: 0 auto;
}
}
&__user-info {
margin: 20px 0;
}
}
</style>
// 生成的唯一标识
import QuickStorage from '@/utils/QuickStorage'
import UserAgents from '@/utils/UserAgents'
function makeUniqueKey() {
const isMobile = UserAgents.isMobile ? 'Mobile' : 'pc'
const isAndroid = UserAgents.isAndroid ? 'Android' : 'undefined'
const isIos = UserAgents.isIos ? 'IOS' : 'undefined'
const isWx = UserAgents.isWx ? 'MicroMessenger' : 'undefined'
const getTime = new Date().getTime()
const random = Math.floor(Math.random(100000) * 100000 + 100000)
return `${isMobile}_${isAndroid}_${isIos}_${isWx}_${getTime}_${random}`
}
if (!QuickStorage.localGet('DEVICE_ID')) {
const uniqueKey = makeUniqueKey()
QuickStorage.localSet('DEVICE_ID', uniqueKey)
}
import Report from "gdy-report";
import { sentryOptions } from "@/config";
import Vue from "vue";
/* 初始化 */
if (process.env.NODE_ENV === "production" && !process.env.private) {
Report(sentryOptions);
}
Vue.prototype.$report = Report;
\ No newline at end of file
import Vue from 'vue'
import vueScroll from 'vuescroll'
Vue.use(vueScroll)
import Vue from "vue";
import VueGtag from "vue-gtag"; // 谷歌统计
import { GTAG_ID } from "@/config";
if (process.env.NODE_ENV === "production" && !process.env.private) {
Vue.use(VueGtag, {
config: {
id: GTAG_ID,
params: {
anonymize_ip: true,
send_page_view: false,
},
},
});
}
import Vue from 'vue'
import {
Toast,
Loading,
Overlay,
Popup,
Lazyload,
Button,
Cell,
CellGroup,
Dialog,
Search,
Sticky,
CountDown,
Form,
Field,
RadioGroup,
Radio,
CheckboxGroup,
Checkbox,
DatetimePicker,
Area,
Progress,
Collapse,
CollapseItem,
} from 'vant'
import '@vant/touch-emulator' // 在pc端模拟移动端 touch 事件
import errorImg from '@/assets/images/lazyLoad/error.png'
import loadingImg from '@/assets/images/lazyLoad/loading.png'
/* 提醒 */
Vue.use(Toast)
Vue.prototype.$toast = Toast
/* loading */
Vue.use(Loading)
/* 弹出层 */
Vue.use(Overlay)
Vue.use(Popup)
/* 懒加载 */
Vue.use(Lazyload, {
lazyComponent: true,
loading: loadingImg,
error: errorImg,
})
/* 按钮 */
Vue.use(Button)
/* 滑动单元格 */
Vue.use(Cell)
Vue.use(CellGroup)
/* 弹窗 */
Vue.use(Dialog)
Vue.prototype.$dialog = Dialog
/* 搜索 */
Vue.use(Search)
/* 吸顶 */
Vue.use(Sticky)
/* 倒计时 */
Vue.use(CountDown)
/* 表单 */
Vue.use(Form)
Vue.use(Field)
/* 单选 */
Vue.use(RadioGroup)
Vue.use(Radio)
/* 复选 */
Vue.use(CheckboxGroup)
Vue.use(Checkbox)
/* 时间选择 */
Vue.use(DatetimePicker)
/* 地图选择 */
Vue.use(Area)
/* 进度条 */
Vue.use(Progress)
/* 折叠面板 */
Vue.use(Collapse);
Vue.use(CollapseItem);
{
"apps": [
{
"name": "questionnaire_test",
"script": "npm",
"args": "run start:dev",
"instances": "max",
"exec_mode": "cluster",
"autorestart": true,
"max_memory_restart": "4G",
"env": {
"port": 4001,
"NODE_ENV": "production"
},
"error_file": "./logs/app-err.log", // 错误日志文件
"out_file": "./logs/app-out.log" // 正常日志文件
},
{
"name": "questionnaire_pre",
"script": "npm",
"args": "run start:pre",
"instances": "max",
"exec_mode": "cluster",
"autorestart": true,
"max_memory_restart": "4G",
"env": {
"port": 4001,
"NODE_ENV": "production"
},
"error_file": "./logs/app-err.log", // 错误日志文件
"out_file": "./logs/app-out.log" // 正常日志文件
},
{
"name": "questionnaire",
"script": "npm",
"args": "run start:prod",
"instances": "max",
"exec_mode": "cluster",
"autorestart": true,
"max_memory_restart": "8G",
"env": {
"port": 4001,
"NODE_ENV": "production"
},
"error_file": "./logs/app-err.log", // 错误日志文件
"out_file": "./logs/app-out.log" // 正常日志文件
},
{
"name": "questionnaire_cm",
"script": "npm",
"args": "run start:cm",
"instances": "max",
"exec_mode": "cluster",
"autorestart": true,
"max_memory_restart": "8G",
"env": {
"port": 4001,
"NODE_ENV": "production"
},
"error_file": "./logs/app-err.log",
"out_file": "./logs/app-out.log"
},
{
"name": "questionnaire_vvku",
"script": "npm",
"args": "run start:vvku",
"instances": "max",
"exec_mode": "cluster",
"autorestart": true,
"max_memory_restart": "8G",
"env": {
"port": 4001,
"NODE_ENV": "production"
},
"error_file": "./logs/app-err.log",
"out_file": "./logs/app-out.log"
},
{
"name": "questionnaire_cbn",
"script": "npm",
"args": "run start:cbn",
"instances": "max",
"exec_mode": "cluster",
"autorestart": true,
"max_memory_restart": "8G",
"env": {
"port": 4001,
"NODE_ENV": "production"
},
"error_file": "./logs/app-err.log",
"out_file": "./logs/app-out.log"
},
{
"name": "questionnaire_huawei",
"script": "npm",
"args": "run start:huawei",
"instances": "max",
"exec_mode": "cluster",
"autorestart": true,
"max_memory_restart": "8G",
"env": {
"port": 34001,
"NODE_ENV": "production"
},
"error_file": "./logs/app-err.log",
"out_file": "./logs/app-out.log"
}
]
}
#!/bin/sh
/etc/init.d/zabbix_agentd start
yarn run pm2
while true
do
sleep 5;
done
# STORE
**This directory is not required, you can delete it if you don't want to use it.**
This directory contains your Vuex Store files.
Vuex Store option is implemented in the Nuxt.js framework.
Creating a file in this directory automatically activates the option in the framework.
More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/vuex-store).
/* eslint-disable no-param-reassign */
import CONFIG from '@/config'
import { getWxShareSecret } from '@/api/modules/common'
import { Toast } from 'vant'
export const state = () => ({
isPageLoading: true, // 加载动画
wxShareInfo: {
link: process.client ? window.location.href : '', // 访问链接
title: '', // 标题
desc: '', // 副标题
imgUrl: CONFIG.defShareImg, // 分享图
// eslint-disable-next-line no-empty-function
success() {},
// eslint-disable-next-line no-empty-function
fail() {},
},
})
export const mutations = {
SET_ISPAGELOADING: (state, isLoading) => {
state.isPageLoading = isLoading
},
SET_WXSHAREINFO: (state, info) => {
const newObj = Object.assign(state.wxShareInfo, info)
state.wxShareInfo = newObj
},
}
export const actions = {
/* 填入分享信息,需主动调用 */
initWXShare({ dispatch }, data) {
const { info, params } = data
dispatch('getWxJsApiToken', params)
window.wx.ready(() => {
window.wx.updateTimelineShareData(info) // 分享至好友
window.wx.updateAppMessageShareData(info) // 分享至朋友圈
})
window.wx.error((err) => {
console.log(err)
})
},
/* 请求授权 */
getWxJsApiToken({ dispatch }, params = {}) {
const obj = Object.assign({ href: window.location.href }, params)
getWxShareSecret(obj).then((res) => {
const { code, errorCode, data, errorMessage } = res
if (code === 200 && errorCode === 0) {
if (typeof data !== 'string') {
dispatch('initWXSDK', data)
}
} else {
Toast.fail(errorMessage)
}
})
},
/* 初始化微信分享 */
// eslint-disable-next-line no-unused-vars
initWXSDK({ state }, info) {
window.wx.config({
debug: false,
appId: info.appId,
timestamp: info.timestamp,
nonceStr: info.nonceStr,
signature: info.signature,
jsApiList: ['updateTimelineShareData', 'updateAppMessageShareData'],
})
},
updateIsPageLoading({ commit }, value) {
commit('common/SET_ISPAGELOADING', value, { root: true })
},
}
export const getters = {
isPageLoading: (state) => state.isPageLoading, // 是否加载
wxShareInfo: (state) => state.wxShareInfo, // 是否加载
}
/* eslint-disable no-param-reassign */
import { Toast, Dialog } from 'vant'
import { getQuestionnaire, setQuestionnaire } from '@/api/modules/questionnaire'
import { ACTIVITY_STATE, ACTIVITY_STATE_TXT } from '@/utils/constant'
import UserAgents from '@/utils/UserAgents'
import CONFIG from '@/config'
export const state = () => ({
isLoading: false, // 是否正在加载投
isError: false, // 是否加载失败
answeredNum: 0, // 已回答个数
questionnaireForm: [], // 问题表单
questionnaireInfo: {
startTime: '', // 开始时间
endTime: '', // 结束时间
title: '', // 主题
logo: '', // 轮播占位图
},
})
export const mutations = {
SET_QUESTIONNAIREINFO: (state, info) => {
state.questionnaireInfo = info
},
SET_QUESTIONNAIREFORM: (state, list) => {
state.questionnaireForm = list
},
SET_ANSWEREDNUM: (state, num) => {
state.answeredNum = num
},
SET_ISLOADING: (state, status) => {
state.isLoading = status
},
SET_ISERROR: (state, status) => {
state.isError = status
},
}
export const actions = {
// 获取问卷数据
getQuestionnaireInfo({ state, commit, rootState }, params = {}) {
const { isLoading } = state
if (isLoading) return
commit('SET_ISLOADING', true)
getQuestionnaire(params)
.then((res) => {
const { code, errorCode, errorMessage, data } = res
if (code === 200 && errorCode === 0) {
const { title, intro, question, isJoined, status, referUrl } = data
// 设置网页标题
document.title = title || '调查问卷'
// 鉴权成功则转跳接口返回的对应网址
if (referUrl && +status === ACTIVITY_STATE.end) {
Dialog.alert({
message: '活动已结束,点击“确认”可以直接观看直播',
}).then(() => {
window.location.href = referUrl
})
}
// 鉴权成功则转跳接口返回的对应网址
if (referUrl && !!isJoined && +status === ACTIVITY_STATE.start) {
Dialog.alert({
message: '您已提交过问卷,点击“确认”可以直接观看直播',
}).then(() => {
window.location.href = referUrl
})
}
commit('SET_QUESTIONNAIREINFO', data)
question.forEach((item) => {
if (item.component === 'checkbox') {
item.answer = []
}
})
commit('SET_QUESTIONNAIREFORM', question)
// 微信分享初始化
if (UserAgents.isWx && process.client) {
const wxShare = require('@/utils/wxShare').default
wxShare.init(
{
link: window.location.href,
title: title || '问卷调查',
desc: intro || window.location.href,
imgUrl: `${window.location.protocol}${CONFIG.defShareImg}`,
},
{ uin: rootState.users.uin }
)
}
// 停止加载效果
commit('common/SET_ISPAGELOADING', false, { root: true })
} else {
Toast.fail(errorMessage)
if (errorCode === 9) {
setTimeout(() => {
this.app.router.push({ path: '/404' })
}, 500)
}
}
})
.catch(() => {
commit('SET_ISERROR', true)
commit('SET_ISLOADING', false)
})
},
// 提交问卷答案
submitAnswer({ state, dispatch, rootState }, params) {
const { questionnaireInfo } = state
if (+questionnaireInfo.status !== ACTIVITY_STATE.start) {
Toast(`活动${ACTIVITY_STATE_TXT[questionnaireInfo.status].label}!`)
return
}
if (!rootState.users.isLogin) {
Toast({
message: '请先登录',
duration: 1500,
onClose: () => {
dispatch('jumpToLogin')
},
})
return
}
if (questionnaireInfo.isJoined) {
Toast.fail('您已参与过问卷活动')
return
}
const { referUrl } = state.questionnaireInfo
setQuestionnaire(params).then((res) => {
const { code, errorCode, errorMessage } = res
if (code === 200 && errorCode === 0) {
Toast.success({
message: referUrl ? '提交成功,正在跳转' : '提交成功',
onClose: () => {
if (referUrl) {
window.location.href = state.questionnaireInfo.referUrl
}
},
})
} else {
Toast.fail(errorMessage)
}
})
},
}
export const getters = {
questionnaireInfo: (state) => state.questionnaireInfo,
questionnaireForm: (state) => state.questionnaireForm,
answeredNum: (state) => state.answeredNum,
isLoading: (state) => state.isLoading,
isError: (state) => state.isError,
}
/* eslint-disable no-param-reassign */
import { getUserInfo, setPvInfo } from '@/api/modules/common'
import QuickStorage from '@/utils/QuickStorage'
export const state = () => ({
uin: 0,
isLogin: false,
userInfo: {},
})
export const mutations = {
SET_USERINFO: (state, info) => {
state.userInfo = info
},
SET_ISLOGIN: (state, isLogin) => {
state.isLogin = isLogin
},
SET_UIN: (state, uin) => {
state.uin = uin
},
}
export const actions = {
async getUerInfo({ commit }, params = {}) {
commit('SET_UIN', params.uin || 0)
const res = await getUserInfo(params)
const { code, errorCode, errorMessage, data } = res
const isReqSuccess = code === 200 && errorCode === 0
if (isReqSuccess) {
if (data.id) {
commit('SET_USERINFO', res.data)
commit('SET_ISLOGIN', true)
} else {
commit('SET_ISLOGIN', false)
}
}
return new Promise((resolve, reject) => {
if (isReqSuccess) {
return resolve(data)
}
reject(new Error(JSON.stringify({ errorCode, errorMessage })))
})
},
// eslint-disable-next-line no-unused-vars
postInfo({ state }, id = 0) {
const params = {
channelId: id, // 资源id
uin: state.uin,
userId: 0,
type: 10, // todo 问卷
deviceid: QuickStorage.localGet('DEVICE_ID')
}
return setPvInfo(params)
},
jumpToLogin({ state }) {
// 登录跳转地址
if (!state.uin) {
return false
}
const origin = window.location.origin
const backUrl = encodeURIComponent(window.location.href)
window.location.href = `${origin}/my/login?uin=${state.uin}&backUrl=${backUrl}`
},
}
export const getters = {
isLogin: (state) => state.isLogin, // 是否登录
userInfo: (state) => state.userInfo, // 用户信息
uin: (state) => state.uin,
}
/* 封装localstorage */
const namespace = 'GDY_';
const sessionSet = (key, val) => {
const obj = JSON.stringify({"key": val});
window.sessionStorage.setItem(namespace + key, obj);
}
const sessionGet = (key) => {
let value = window.sessionStorage.getItem(namespace + key);
value = JSON.parse(value) || {};
return value.key;
}
const sessionRemove = (key) => {
window.sessionStorage.removeItem(namespace + key);
}
const localSet = (key, val) => {
const obj = JSON.stringify({"key": val});
window.localStorage.setItem(namespace + key, obj);
}
const localGet = (key) => {
let value = window.localStorage.getItem(namespace + key);
value = JSON.parse(value) || {};
return value.key;
}
const localRemove = (key) => {
window.localStorage.removeItem(namespace + key);
}
export default {
sessionSet,
sessionGet,
sessionRemove,
localSet,
localGet,
localRemove,
}
const userAgent = () => {
if (!process.client) return {}
const u = navigator.userAgent.toLowerCase()
const json = {
isAndroid: /Android|Linux|Adr/i.test(u),
// Android
isIos: /\(i[^;]+;( U;)? CPU.+Mac OS X/i.test(u),
// ios
isMobile: /Android|webOS|iPhone|iPod|BlackBerry/i.test(u),
// 手机设备
isUc: /ucweb/i.test(u),
// UC浏览器
isChrome: /chrome/i.test(u.substr(-33, 6)),
// Chrome浏览器
isFirefox: /firefox/i.test(u),
// 火狐浏览器
isOpera: /opera/i.test(u),
// Opera浏览器
isSafire: /safari/i.test(u) && !/chrome/.test(u),
// safire浏览器
is360: /360se/i.test(u),
// 360浏览器
isBaidu: /bidubrowser/i.test(u),
// 百度浏览器
isSougou: /metasr/i.test(u),
// 搜狗浏览器
isIE6: /msie 6.0/i.test(u),
// IE6
isIE7: /msie 7.0/i.test(u),
// IE7
isIE8: /msie 8.0/i.test(u),
// IE8
isIE9: /msie 9.0/i.test(u),
// IE9
isIE10: /msie 10.0/i.test(u),
// IE10
isIE11: /msie 11.0/i.test(u),
// IE11
isLB: /lbbrowser/i.test(u),
// 猎豹浏览器
isWx: /micromessenger/i.test(u),
// 微信内置浏览器
isQQ: /qqbrowser/i.test(u)
// QQ浏览器
}
return json
}
export default userAgent()
/**
* @file aliyun arms report method of es6
* @since 1.0.8
* @author whzcorcd <whzcorcd@gmail.com>
*
* 阿里前端监控
* 配置文档 https://www.npmjs.com/package/alife-logger
*
* 使用示例:import arms from 'arms'
* const logger = arms('huh7k89btk@39bb266d318880a', true, true)
* logger && logger.api(url, true, time, res.data.Flag, res.data.FlagString)
* 示例解析:arms(应用 pid, 是否关闭 API 自动上报, 是否启用 SPA 分析)
* api(请求地址,请求成功与否,请求耗时,请求状态码,请求返回 Msg)
*/
const BrowerLogger = require('alife-logger')
const __bl = (pid, disableHook = false, enableSPA = true) => {
if (process.env.NODE_ENV === 'production' && !process.env.private) {
try {
return BrowerLogger.singleton({
pid,
appType: 'web',
imgUrl: 'https://arms-retcode.aliyuncs.com/r.png?',
sendResource: true,
enableLinkTrace: true,
behavior: true,
disableHook,
useFmp: true,
enableSPA
})
} catch (e) {
// eslint-disable-next-line no-console
console.error('init logger fail', e)
return false
}
} else {
return false
}
}
module.exports = process.env.NODE_ENV === 'production' ? __bl : false
/* 状态 */
export const ACTIVITY_STATE = {
teaser: 0,
start: 1,
end: 2,
}
export const ACTIVITY_STATE_TXT = {
[ACTIVITY_STATE.teaser]: { label: '未开始', buttonText: '活动未开始', countDownText: '后开始' },
[ACTIVITY_STATE.start]: { label: '进行中', buttonText: '提交问卷', countDownText: '后结束' },
[ACTIVITY_STATE.end]: { label: '已结束', buttonText: '活动已结束', countDownText: '' },
}
/* 题目类型 */
export const QUESTION_TYPE = {
singleInput: '填空',
multipleInput: '填空',
radio: '单项选择',
checkbox: '多项选择',
datePicker: '时间选择',
addressPicker: '地址选择',
}
import ROP from '@whzcorcd/rop-client'
export default new ROP({
ICS_ADDR: `mqttdms.${process.env.ROP_DOMAIN}`,
ROP_FLASH_SITE: `https://cdn.${process.env.ROP_DOMAIN}/dms/`
})
export default function(type, rules, specialSign) {
const defRules = [{ required: Boolean(rules.required), message: "内容不能为空" }]
if(type === 'checkbox'){
return [{ required: Boolean(rules.required), message: "至少选择一项" }];
}
if(type === 'radio'){
return [{ required: Boolean(rules.required), message: "请选择一项" }];
}
if(specialSign){
if(specialSign === 'phone') {
// /(^\s*$)|(^(?:(?:\+|00)86)?1\d{10}$)/
defRules.push({ pattern: /^s*$|(?:(?:\+|00)86)?1\d{10}$/, message: '请输入正确的手机号'})
}
}
// console.log(defRules)
return defRules;
}
import wx from 'weixin-js-sdk'
import { getWxShareSecret } from '@/api/modules/common'
class WxShare {
constructor() {
this.wxShareInfo = WxShare.translatorInitInfo()
this.config = {
jsApiList: ['updateTimelineShareData', 'updateAppMessageShareData'],
}
}
init(initInfo, initParams) {
const info = WxShare.translatorInitInfo(initInfo)
const params = WxShare.translatorInitParams(initParams)
this.getWxJsApiSecret(params)
wx.ready(() => {
wx.updateTimelineShareData(info) // 分享至好友
wx.updateAppMessageShareData(info) // 分享至朋友圈
})
wx.error((err) => {
console.log(err)
})
}
// 接口获取验证token
getWxJsApiSecret(params) {
getWxShareSecret(params).then((res) => {
const { code, errorCode, data, errorMessage } = res
if (code === 200 && errorCode === 0) {
if (typeof data !== 'string') {
this.setWxSDKConfig(data)
}
} else {
console.error(errorMessage)
}
})
}
// 通过config接口注入权限验证配置
setWxSDKConfig(info) {
const defConfig = {
debug: false,
appId: info?.appId || '',
timestamp: info?.timestamp || '',
nonceStr: info?.nonceStr || '',
signature: info?.signature || '',
jsApiList: ['updateTimelineShareData', 'updateAppMessageShareData'],
}
wx.config({
...defConfig,
...this.config,
})
}
static translatorInitInfo(info) {
return {
link: info?.link || window.location.href,
title: info?.title || '分享',
desc: info?.desc || window.location.href, // 副标题
imgUrl: info?.imgUrl || `${window.location.protocol}${process.env.OSS_DOMAIN}/common/img/shop_share.png`,
success: info?.success || function () {},
fail: info?.fail || function () {},
}
}
static translatorInitParams(params) {
return {
uin: params?.uin || 0,
href: params?.href || window.location.href,
}
}
}
export default new WxShare()
FROM alpine AS builder
WORKDIR /home/app
RUN apk add --no-cache --update nodejs yarn
COPY package.json yarn.lock ./
RUN yarn install --registry=https://registry.yarnpkg.com
FROM registry.cn-hangzhou.aliyuncs.com/open_images/node12.13.1-pm2
ADD ./ /var/www/web_gather
ENV HOST 0.0.0.0
ENV TZ Asia/Shanghai
WORKDIR /var/www/web_gather
COPY --from=builder /home/app/package.json ./package.json
COPY --from=builder /home/app/node_modules ./node_modules
RUN yarn build:vvku
RUN rm -rf assets components layouts middleware pages plugins store .eslintrc.js .gitignore build.yml Dockerfile README.md start.sh
EXPOSE 4001
RUN chmod +x vvkuStart.sh
ENTRYPOINT ["./vvkuStart.sh"]
#!/bin/sh
yarn run pm2:vvku
while true
do
sleep 5;
done
This source diff could not be displayed because it is too large. You can view the blob instead.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment