pruple_boy 2 vuotta sitten
commit
3aa0897511
100 muutettua tiedostoa jossa 39419 lisäystä ja 0 poistoa
  1. 11 0
      .editorconfig
  2. 38 0
      .env
  3. 14 0
      .env.development
  4. 21 0
      .env.production
  5. 14 0
      .env.test
  6. 6 0
      .eslintignore
  7. 23 0
      .gitignore
  8. 13 0
      .vscode/launch.json
  9. 4 0
      .vscode/settings.json
  10. 278 0
      README.md
  11. 772 0
      REMARK.md
  12. 26 0
      babel.config.js
  13. 0 0
      directoryList.md
  14. 275 0
      eslintrc.js
  15. 9 0
      jsconfig.json
  16. 33189 0
      package-lock.json
  17. 94 0
      package.json
  18. 33 0
      plop-templates/component/index.hbs
  19. 62 0
      plop-templates/component/prompt.js
  20. 16 0
      plop-templates/store/index.hbs
  21. 62 0
      plop-templates/store/prompt.js
  22. 2 0
      plop-templates/utils.js
  23. 33 0
      plop-templates/view/index.hbs
  24. 55 0
      plop-templates/view/prompt.js
  25. 9 0
      plopfile.js
  26. BIN
      public/favicon.ico
  27. 39 0
      public/index.html
  28. 5 0
      public/skeleton.css
  29. 53 0
      src/App.vue
  30. BIN
      src/assets/images/404.jpg
  31. BIN
      src/assets/logo/favicon.ico
  32. BIN
      src/assets/logo/logo.png
  33. 113 0
      src/components/common/TopBack/index.vue
  34. 59 0
      src/components/common/element-ui/DatePick/index.vue
  35. 125 0
      src/components/common/element-ui/department/index.vue
  36. 86 0
      src/components/dialog/element-ui/DetailTable/index.vue
  37. 106 0
      src/components/form/element-ui/universal/index.vue
  38. 20 0
      src/components/table/element-ui/Universal/index.mixin.js
  39. 114 0
      src/components/table/element-ui/Universal/index.vue
  40. 91 0
      src/components/vendor/Siriwave/index.vue
  41. 16 0
      src/config/event-bus.js
  42. 27 0
      src/config/expert/filters.js
  43. 34 0
      src/config/expert/mixins.js
  44. 15 0
      src/config/loader/ant-design.js
  45. 184 0
      src/config/loader/element-ui.js
  46. 76 0
      src/config/loader/flex-rem.js
  47. 95 0
      src/config/loader/quasar.js
  48. 5 0
      src/config/loader/vconsole.js
  49. 4 0
      src/config/mcConf.js
  50. 11 0
      src/config/skeleton/conf.js
  51. 28 0
      src/config/skeleton/native.vue
  52. 33 0
      src/config/skeleton/web.vue
  53. 19 0
      src/directive/loader.js
  54. 14 0
      src/directive/waves/index.js
  55. 27 0
      src/directive/waves/waves.css
  56. 72 0
      src/directive/waves/waves.js
  57. 70 0
      src/main.js
  58. BIN
      src/modules/dinkle/assets/icon/+pk-select.png
  59. BIN
      src/modules/dinkle/assets/icon/+pk.png
  60. BIN
      src/modules/dinkle/assets/icon/download.png
  61. BIN
      src/modules/dinkle/assets/icon/link.png
  62. BIN
      src/modules/dinkle/assets/icon/look.png
  63. BIN
      src/modules/dinkle/assets/icon/no-data.png
  64. BIN
      src/modules/dinkle/assets/icon/order.png
  65. BIN
      src/modules/dinkle/assets/icon/pk-relation.png
  66. BIN
      src/modules/dinkle/assets/icon/player.png
  67. BIN
      src/modules/dinkle/assets/icon/reset.png
  68. BIN
      src/modules/dinkle/assets/icon/rotate.png
  69. BIN
      src/modules/dinkle/assets/icon/search-to.png
  70. BIN
      src/modules/dinkle/assets/icon/search.png
  71. BIN
      src/modules/dinkle/assets/icon/select.png
  72. BIN
      src/modules/dinkle/assets/icon/selected.png
  73. BIN
      src/modules/dinkle/assets/image/no-data-pk.png
  74. BIN
      src/modules/dinkle/assets/image/no-data-search.png
  75. BIN
      src/modules/dinkle/assets/logo/logo-icon.png
  76. BIN
      src/modules/dinkle/assets/logo/logo.png
  77. 102 0
      src/modules/dinkle/components/productCell/index.vue
  78. 3 0
      src/modules/dinkle/remark.md
  79. 87 0
      src/modules/dinkle/router/index.js
  80. 73 0
      src/modules/dinkle/service/api.js
  81. 80 0
      src/modules/dinkle/store/dinkle.js
  82. 241 0
      src/modules/dinkle/views/content/index.vue
  83. 146 0
      src/modules/dinkle/views/contrast/index.vue
  84. 326 0
      src/modules/dinkle/views/detail/index.vue
  85. 326 0
      src/modules/dinkle/views/detail/relation.vue
  86. 286 0
      src/modules/dinkle/views/home/index.vue
  87. 123 0
      src/modules/dinkle/views/layout/index copy.vue
  88. 123 0
      src/modules/dinkle/views/layout/index.vue
  89. 188 0
      src/modules/dinkle/views/pkList/index.vue
  90. 122 0
      src/modules/dinkle/views/search/index.vue
  91. 31 0
      src/modules/dinkle/views/website/index.vue
  92. 3 0
      src/modules/fushuo/remark.md
  93. 18 0
      src/modules/fushuo/router/index.js
  94. 33 0
      src/modules/fushuo/service/api.js
  95. 40 0
      src/modules/fushuo/store/auth.js
  96. 50 0
      src/modules/fushuo/views/export-fei/index.mixin.js
  97. 184 0
      src/modules/fushuo/views/export-fei/index.vue
  98. 50 0
      src/modules/fushuo/views/export-te/index.mixin.js
  99. 184 0
      src/modules/fushuo/views/export-te/index.vue
  100. 0 0
      src/modules/opay/assets/logo/logo.png

+ 11 - 0
.editorconfig

@@ -0,0 +1,11 @@
+# 格式化配置
+
+root = true
+
+[*]
+charset = utf-8
+indent_style = space
+indent_size = 2
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true

+ 38 - 0
.env

@@ -0,0 +1,38 @@
+# 全局环境: 修改需要重新运行
+NODE_ENV = 'development'
+
+# px2rem 适配
+VUE_APP_REM_UNIT = 10
+
+# 版本号 chunk 缓存
+VUE_APP_VERSION = '0.1.0'
+
+# 网络超时
+VUE_APP_TIME_OUT = 300000
+
+# 基础 URL: dev需为空 代理为target后第一匹配字段
+VUE_APP_BASE_URL = ''
+
+# 阿里云 OSS URL
+VUE_APP_OSS_URL = 'http://alading-20210318.oss-cn-shanghai.aliyuncs.com'
+
+# ant-design 组件库
+VUE_APP_UI_ANT_DESIGN = 'ant-design'
+
+# quasar 组件库
+VUE_APP_UI_QUASAR = 'quasar'
+
+# element-ui 组件库
+VUE_APP_UI_ELEMENT = 'element-ui'
+
+# 编译组件选择
+# FIXME: 若不生效多刷新几次main.js即可
+VUE_APP_PLATFORM = ['ant-design', 'quasar', 'element-ui']
+
+# 若同时存在多个组件库, 公共位置优先引用库  - 如 progress.js
+# FIXME: ant-design 不支持原型链条调用方式, 实现方式需要 App.vue + vuex 来控制: 繁琐故取消了
+VUE_APP_PRIORITY = "quasar"
+
+# 骨架屏优先选择项: native/web/隐藏不传入  - 骨架屏 Loading 时还不能获取到对象 native
+VUE_APP_SKELETON = 'web'
+

+ 14 - 0
.env.development

@@ -0,0 +1,14 @@
+# 开发环境: 修改需要重新运行
+NODE_ENV = 'development'
+
+# 网络超时
+VUE_APP_TIME_OUT = 0
+
+# 本地代理
+VUE_APP_DEV_PROXY = '/dev-proxy'
+
+# 配置端口 & Chrome Debugger
+VUE_APP_DEV_PORT = 8001
+
+# 请求 target: 为空运行报错
+VUE_APP_BASE_API = 'http://localhost:9001'

+ 21 - 0
.env.production

@@ -0,0 +1,21 @@
+# 生产环境: 修改需要重新运行
+NODE_ENV = 'production'
+
+# 生产编译期字段: dev环境不处理 - just a flag
+
+# 是否开启压缩 gizp
+IS_GZIP = "gzip"
+
+# 是否分析生产包 analyz
+IS_ANALYZE = ""
+
+# 打包文件夹名称: 默认 dist
+DIR_OUT_PUT = ''
+
+# 生产编译期字段: dev环境不处理 - just a flag
+
+# 去掉代理: 不支持空类型
+VUE_APP_DEV_PROXY = ''
+
+# 基础 URL: 直连不配nginx反向代理
+VUE_APP_BASE_URL = 'https://mc.cloudpure.cn'

+ 14 - 0
.env.test

@@ -0,0 +1,14 @@
+# 开发环境: 修改需要重新运行
+NODE_ENV = 'test'
+
+# 网络超时
+VUE_APP_TIME_OUT = 30000
+
+# 本地代理
+VUE_APP_DEV_PROXY = '/dev-proxy'
+
+# 配置端口 & Chrome Debugger
+VUE_APP_DEV_PORT = 9001
+
+# 请求 target: 为空运行报错 - 本地环境
+VUE_APP_BASE_API = ''

+ 6 - 0
.eslintignore

@@ -0,0 +1,6 @@
+# 1.如果是针对某个文件: 在需要过滤的文件头部末尾分别添加:/* eslint-disable */,/* eslint-disable no-new */
+
+# 2.除了 .eslintignore 文件中的模式,ESLint总是忽略 /node_modules/* 和 /bower_components/* 中的文件
+
+**/vendor/*
+**/components/comp-vendor/*

+ 23 - 0
.gitignore

@@ -0,0 +1,23 @@
+# General
+.DS_Store
+node_modules
+/dist
+/test
+/dist.zip
+
+# local env files
+.env.local
+.env.*.local
+
+# Log files
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Editor directories and files
+.idea
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?

+ 13 - 0
.vscode/launch.json

@@ -0,0 +1,13 @@
+{
+  "version": "0.2.0",
+  "configurations": [
+    {
+      "type": "chrome",
+      "request": "launch",
+      "name": "vue-mcli",
+      "url": "http://localhost:9001",
+      "webRoot": "${workspaceFolder}",
+      "sourceMaps": true
+    }
+  ]
+}

+ 4 - 0
.vscode/settings.json

@@ -0,0 +1,4 @@
+{
+  "px-to-rem.px-per-rem": 100,
+  "liveServer.settings.port": 5500
+}

+ 278 - 0
README.md

@@ -0,0 +1,278 @@
+# README.md
+
+- 开发
+
+  基础: pug + stylus / sass / less + vuex + axios + quasar + element-ui + vant + bus + ...
+  高级: 自定义指令, 页面混入, 全局过滤器, 重置样式和主题, 条件编译`compression-webpack-plugin`, 动态作用域插槽和具名插槽, 基础库和依赖 (crypto-js, px2rem, fecha ...)
+  集成: 配置 mob 和 admin 两套代码登录页, mob 的 TabBar, admin sideBar, token 和路由钩子验证
+  平台: 条件编译下配置不同环境下加载不同组件库, 真正的按需引入. _兼容两端, `process.env.VUE_APP_PLATFORM` 值为集合, 多平台编译_
+  扩展: 配置了对应的主题和样式重置(条件编译), 分环境: 加载路由 `redirectIndex`, `progress`, 骨架屏, 404 页面(_设备判断优先于且兼容平台_), ...
+
+- 取消
+
+  图片压缩(图片压缩不如图片懒加载和 base64)
+  node_modules 抽离公共部分单独部署(多项目)
+
+- 扩展
+
+  ssr: 更高的服务器成本(https://ssr.vuejs.org/zh/) Nuxt.js. 更好的 SEO,避开 ajax 获取内容不能被抓取,若 SEO 至关重要; 更快的内容到达时间,若内容到达时间(time-to-content) 与转化率直接相关.
+  可用:`prerender-spa-plugin` 预渲染,相对于 ssr 改动小,且只有极少页面需要 seo. 但存在动态数据, 经常发生变化, 实时性, 路由过多情况下不建议使用, 维护成本过高
+  推荐: 骨架屏实现方案: 全局骨架屏使用的 base64 GIF 图片, 极大减少白屏时间. 页面骨架屏将 SEO 配置骨架屏标签上, 不但实现了 seo 抓取页面内容, 还提升了数据请求 loading 单一, 加载导致页面抖动问题
+  依赖: 使用 `vue-content-loader` 依赖, 构建页面级骨架屏. 若使用了 quasar / vant-ui. 可使用其内置的骨架屏组件
+
+--~---~---~---~---~---~---~-----~---~---~---~---~---~---~---~--~-----~---~-----~---~---~---~---~---~---~---~--~-----~---~-----~---~---~--
+
+# 前言
+
+- 验证
+
+  开发环境下开启了 `eslint`, 并配置 `pre-commit`
+
+- 原则
+
+  1. 尽可能按需加载
+  2. 尽量做到复用性
+  3. 尽可能减少体积
+  4. 尽可能使用异步
+  5. 尽可能不要添加无效 npm
+
+- 规范
+
+  1. 使用 px 而不是 rem, 已添加了高清和 rem 适配, 详见 REMARK.md
+  2. 尽可能符合语法规范: CSS BEM, 驼峰, lint, ...
+  3. 使用 bus/storage 将 key 声明为常量,便于追踪和管理
+  4. 数据源返回尽可能使用 map, 提取需要字段. 避免注入过多无效字典影响性能
+  5. vue 不允许直接在组件第一层使用 v-for,因此为每个组件都添加有容器,页面类名为 main,组件类名为 comp
+  6. 重复代码、公共样式、固定方法,统一进行封装;尽可能将公共变量、配置环境变量常量化到配置文件
+  7. 数组处理尽量少使用 for/forEach。多使用 find、filter、some、every、reduce 等
+  8. 尽量使用结构和扩展语法,取值,合并对象
+  9. 变量和文件命名尽可能不要使用拼音,使用驼峰命名方式(每个单词首字母大写,第一个单词首字母可小写)
+  10. 组件引用使用动态引入,懒加载:compName: () => import("@/comp path"),
+  11. 路由按需加载: component: () => import("@/comp path"),
+  12. 组件尽可能声明 name 值,组件内使用 comp + 组件名称,页面使用路由名称: `keep-alive`, `dom 递归`, `vue-tools` :: _通过 `plop` 生成自带_
+  13. 引用文件, 路径使用绝对路径(若需要可为目录配置别名)
+  14. 尽可能去掉无用生产依赖, 尽可能安装在 `devDependencies` 下 (_安装依赖 -D 等同于 --save-dev, -S 即 --save 进入 `dependencies` 内_)
+
+- 文件
+
+  1. 文件夹:中横线
+  2. js 文件名称:中横线
+  3. css 文件名称:点
+  4. vue 文件名称:大驼峰
+
+- 使用
+
+  1. 数据量大, 冻结, 降低开销: `this.item = Object.freeze(Object.assign({}, this.item))` _滤掉无用字段可用封装 `optimize` 库_
+  2. 一次性响应事件使用 v-once
+  3. 优先 v-show, 尽量不使用 v-if
+  4. 使用 keep-alive: 需要组件有 name, `plop` 方式下自动生成
+  5. v-if 不要和 v-for 同级: 若真是存在这个情况, 改用计算属性
+
+--~---~---~---~---~---~---~-----~---~---~---~---~---~---~---~--~-----~---~-----~---~---~---~---~---~---~---~--~-----~---~-----~---~---~--
+
+# 使用
+
+- 环境
+
+  1. npm install / npm i: 下载项目依赖
+  2. npm run serve / npm run dev: 运行测试环境
+  3. npm run build: 运行生产环境: `dist` -- 可在 `.env` 配置打包平台
+
+- 细节
+
+  1. 路由懒加载:component: () => import("path”)
+  2. 组件懒加载: compName: () => import("path”)
+  3. 所有文件按需加载
+     1. styles 继承和混合(主题自动加载); 详见 REMARK.md 样式篇
+     2. axios 不使用 `vue-router` 插件, _不在原型链添加 axios 对象, 按需引入_
+     3. 组件库 `element-ui / vant-ui` 按需加载, 详见 READEME.MD
+  4. 配置 `babel/syntax-dynamic-import` 异步加载插件 -- 详见 `.babel.config.js`
+  5. 在普通 js 文件中获取到 vue 实例
+     1. 粗暴直接: 在 main.js 将创建的 vue 对象挂在在 window 上: `window.$vue = new Vue({})`, _若只是获取 vue 属性,如 router 可直接导入其文件即可_
+     2. 相对优雅: 在 main.js 进行 vue 实例进行导出,在需要的文件被内导入, _尽可能使用传参的方式带入`this`以避免性能问题, 示例: `@/utils/message.js`全局提示_ (import 不会重复导入)
+     3. 解决方案: _按需引入, `this.$xxx` 访问 `Vue.prototype.$xxx` 原型链对象, 无需通过 `main.js`作为中转, 直接按照声明方式使用即可. 其中若使用 `router / store` 同理_
+     4. 案例展示: `@/service/progress` 分平台全局提示 UI: 设备判断优先于且兼容平台, 如 loading, message, notice, dialog, .... (_服务于通用而不是组件内部_)
+  6. `optimize` 防抖和节流, 序列化 table 组件表头数据, 组件 table 映射必须属性
+  7. 性能优化: 如 bus, vue 细节点, 详见 REMAEK.md
+
+--~---~---~---~---~---~---~-----~---~---~---~---~---~---~---~--~-----~---~-----~---~---~---~---~---~---~---~--~-----~---~-----~---~---~--
+
+# 项目
+
+- 项目配置
+
+  1. 浏览器样式重置
+  2. 开发环境下 vconsole
+  3. 主题自动加载
+  4. 条件编译
+  5. 自动异步加载
+  6. px 自动转为 rem
+  7. 文件别名管理
+  8. 打包体积分析
+  9. 本地代理
+  10. 自动识别文件后缀
+  11. 代码压缩混淆, 去除注释和打印
+  12. 配置 gzip 压缩 (需要服端也开启)
+  13. 取消编译 map
+  14. 分包处理和懒加载, chunk 配置, 将各个文件 css 和 js 单独拆分, 减少体积提升加载速度
+  15. 输出重构: 打包编译后的文件名称【模块名称.chunkhash.版本号】, 避免缓存
+  16. 骨架屏: 全局 loading GIF
+  17. vuex: mudules 管理 (命名空间)
+  18. axios aop 统一管控,导入导出通用接口
+
+- 项目工程结构
+
+  1. 依赖 `npm i -D mddir`, 目录结构图 (开发依赖)
+  2. 执行 `npm run topology` 运行 `node node_modules/mddir/src/mddir src` 为 `src` 目录生成目录解构图
+
+```
+|-- vue-mcli
+    |-- .DS_Store
+    |-- App.vue
+    |-- main.js
+    |-- package-lock.json
+    |-- package.json
+    |-- assets
+    |   |-- .DS_Store
+    |   |-- icon
+    |   |   |-- no-data-icon.png
+    |   |-- images
+    |   |   |-- .DS_Store
+    |   |   |-- 404.jpg
+    |   |   |-- bg-login-admin.jpg
+    |   |   |-- bg-login-mob.jpeg
+    |   |   |-- sample-media.jpg
+    |   |   |-- sample.jpg
+    |   |   |-- solution-media.jpg
+    |   |   |-- solution.jpg
+    |   |-- logo
+    |   |   |-- .DS_Store
+    |   |   |-- ico.png
+    |   |   |-- logo-calendar.png
+    |   |   |-- logo-t.png
+    |   |   |-- logo.png
+    |   |-- tmp
+    |       |-- .DS_Store
+    |       |-- banner
+    |       |   |-- banner1-mob.png
+    |       |   |-- banner1.png
+    |       |   |-- banner2-mob.jpg
+    |       |   |-- banner2.jpg
+    |       |-- skeleton
+    |           |-- .DS_Store
+    |           |-- seketon-admin.jpeg
+    |           |-- seketon-mob.jpeg
+    |-- components
+    |   |-- admin
+    |   |   |-- table.mixin.js
+    |   |   |-- table.vue
+    |   |   |-- topBack.vue
+    |   |-- dialog
+    |   |   |-- role.vue
+    |   |-- mob
+    |       |-- navBar.vue
+    |-- config
+    |   |-- bus.js
+    |   |-- setting.js
+    |   |-- expert
+    |   |   |-- filters.js
+    |   |   |-- mixins.js
+    |   |-- loader
+    |   |   |-- element.ui.js
+    |   |   |-- flexRem.js
+    |   |   |-- quasar.ui.js
+    |   |   |-- vant.ui.js
+    |   |   |-- vconsole.js
+    |   |-- skeleton
+    |       |-- admin.vue
+    |       |-- conf.js
+    |       |-- mob.vue
+    |-- directive
+    |   |-- loader.js
+    |   |-- waves
+    |       |-- index.js
+    |       |-- waves.css
+    |       |-- waves.js
+    |-- mock
+    |-- router
+    |   |-- index.js
+    |   |-- modules
+    |       |-- admin.js
+    |       |-- mob.js
+    |       |-- sideBar.js
+    |       |-- tabBar.js
+    |       |-- universal.js
+    |-- service
+    |   |-- api.js
+    |   |-- network.js
+    |   |-- progress.js
+    |   |-- request.js
+    |   |-- vendor.js
+    |-- store
+    |   |-- getters.js
+    |   |-- index.js
+    |   |-- modules
+    |       |-- config.js
+    |       |-- user.js
+    |-- styles
+    |   |-- .DS_Store
+    |   |-- animate.styl
+    |   |-- extend.styl
+    |   |-- mixin.styl
+    |   |-- quasar.variables.styl
+    |   |-- transition.styl
+    |   |-- loader
+    |       |-- browser.reset.styl
+    |       |-- common.styl
+    |       |-- element.variables.scss
+    |       |-- navBar.reset.styl
+    |       |-- vant.variables.less
+    |       |-- variables.styl
+    |-- utils
+    |   |-- array.js
+    |   |-- calc.js
+    |   |-- crypto.js
+    |   |-- device.js
+    |   |-- fecha.js
+    |   |-- optimize.js
+    |   |-- storage.js
+    |   |-- util.js
+    |   |-- validate.js
+    |-- views
+        |-- .DS_Store
+        |-- index.vue
+        |-- element
+        |   |-- pages
+        |   |   |-- hsJianGao.vue
+        |   |   |-- loginIn.vue
+        |   |-- sideBar
+        |       |-- sideBar.1.1.vue
+        |       |-- sideBar.1.2.vue
+        |       |-- sideBar.2.1.vue
+        |       |-- sideBar.2.2.vue
+        |       |-- sideBar.layout.vue
+        |-- quasar
+        |   |-- calendar
+        |   |   |-- approval.vue
+        |   |   |-- checkIn.vue
+        |   |   |-- layout.vue
+        |   |-- cms
+        |       |-- .DS_Store
+        |       |-- layout
+        |       |   |-- index.vue
+        |       |-- pages
+        |           |-- .DS_Store
+        |           |-- index.vue
+        |-- universal
+        |   |-- 404.vue
+        |-- vant
+            |-- pages
+            |   |-- login.vue
+            |   |-- setting.vue
+            |-- tabBar
+                |-- tabBar.four.vue
+                |-- tabBar.layout.vue
+                |-- tabBar.one.vue
+                |-- tabBar.three.vue
+                |-- tabBar.two.vue
+```

+ 772 - 0
REMARK.md

@@ -0,0 +1,772 @@
+# REMARK.md
+
+- 配置和参数详细说明文档
+- 使用详见 README.md
+
+--~---~---~---~---~---~---~-----~---~---~---~---~---~---~---~--~-----~---~-----~---~---~---~---~---~---~---~--~-----~---~-----~---~---~--
+
+_使用篇_
+
+# 传值
+
+- 方法作为传递属性: 子组件可以传入一个 `method` 作为属性, 若未在 `props` 声明, 可通过 `$attrs` 访问. 多层级组件可使用 `$listener` 来抛出事件
+- 子组件中 `v-model` 优雅地绑定父组件属性: `.sync` 和 `update:` 结合计算属性. `.sync` 其实是语法糖, 父组件会自动同步在子组件的 `update:` 抛出的值
+
+  1. 父组件使用 `.sync` 来自动接收子组件抛出的 `update:`. 子组件重新定义一个带有 `set / get` 的计算属性: `set` 抛出更新触发 `.sync`; `get` 取 `props` 值
+  2. 需要注意的是父组件必须要传入且用 `.sync` 修饰, 否则子组件 `update:` 没有接收, 链条就断了.
+
+  ```
+  // 通过计算属性优雅地在子组件使用 `v-model` 绑定父组件的值
+  currentPage: {
+    get() {
+      return this.page
+    },
+    set(val) {
+      this.$emit('update:page', val)
+    }
+  }
+  ```
+
+- 若父组件是在 `created` 内使用 `this.$nextTick(() => { })`,其内部执行会晚于子组件的 `mounted` 方法。如在 `router-view` 嵌套的二级路由页面 `mounted` 晚于父组件 `$nextTick`
+
+- `.sync` 处理对象更新
+
+  1. vue 不检测在子组件内更新对象的属性 (_封装 form 尤其有用_). 注意两个前提, 传入对象需要是 data 下第一层, 对象内部字段需要在父组件中进行声明
+  2. 可以在子组件抛出一个对象或者集合, 如对 `table` 的封装, 监听多选抛出对选集合数据: _父组件传入空集合, 子组件在多选选择事件内进行抛出_
+
+- 路由声明式传参:`params` 下页面刷新不会丢失,被存储在路径中,并且变量名提前声明了. _路由无参数会进入 404, 且 push 推入路由页面需要使用 name 属性_
+
+- 过滤器支持多参: 首参默认是数据源, 不能修改. 如 _全局过滤器_, 时间格式化和解析, 默认值: `YYYY-MM-DD HH:mm:ss`, 也可自定义传入 `fecha` 支持的格式
+- 计算属性和 watch: 计算属性监听的值没有变化不会重新计算, 且可以同时监听多个属性, 唯一的问题是, 她需要被使用才会触发: 如果有这个需求可以使用 watch,一个 watch 只能监听一个属性
+
+# 使用
+
+- table 组件化: _动态作用域插槽和具名插槽, 分页计算属性和 props 的优雅结合_. 配置表单字段映射, 传入数据源和表头即可实现表单 (使用混入)
+- navBar 组件化: 引入导航头, 自动计算偏移量, 将页面通过插槽载入到 navBar 组件内. 主容器设置 100% height 不设置 overflow, 若需要设置高度 100%以填充背景色, 设置其容器 height 100%即可
+- index 入口: 由 `process.env.VUE_APP_PLATFORM` 集合的值决定: 若仅有 mob 入口为移动端, 否则为 admin. _若兼容两端则 index 根据设备自动跳转对应路由_
+
+# 进阶
+
+- bus
+
+  1. 配置了事件载体和事件触发 id, _使用 bus 将 key 声明为常量,便于追踪和管理_, 若有频繁触发使用节流控制(并抛出), 如 `flex-rem` 中的 `window.onresize`
+  2. 使用 `bus.$vm.$on`, 需要在 `destroyed() { bus.$vm.$off(bus.$sel.window_resize); }` 关闭监听. 若仅仅监听一次使用 `bus.$vm.$off`, 抛出事件 `bus.$vm.$emit`
+
+```
+// vue 支持 vue-hooks
+created() {
+  bus.$vm.$on(bus.$sel.window_resize, () => { });
+  // vue 支持 vue-hooks
+  this.$on("hook:destroyed", () => {
+    bus.$vm.$off(bus.$sel.window_resize);
+  });
+}
+```
+
+- 优化
+
+  1. **当数据赋值后不需要响应式, 使用 Object.freeze 冻结**: `this.item = Object.freeze(Object.assign({}, this.item))` _滤掉无用字段可用封装 `optimize` 库_
+  2. 一次性响应事件使用 v-once
+  3. 优先 v-show, 尽量不使用 v-if
+  4. 使用 keep-alive: 需要组件有 name, `plop` 方式下自动生成
+  5. v-if 不要和 v-for 同级: 若真是存在这个情况, 改用计算属性
+
+- js 和 css 变量共享
+
+  1. css 到 js: 通过 `css-modules :export` 来实现, 如 `variables.styl` 导出 `:export { colorTheme: $-color-theme }`. 在 js 内 import 文件即可. _css 也可用_
+  2. js 到 css 最简单方式就是 vue 动态样式: 若需要实时响应, 可和 vuex 进行关联. 如 `App.vue` 内, _通过 vuex 实现了高分屏兼容 @media 布局: 控制显示设备_. **IE 不支持 `css var()`**
+
+# 兼容
+
+- `swiper` 最新版 `^5.3.8` 不支持 `IE10`, 选择兼容版本 `^3.4.2` 版本. 匹配 `vue-awesome-swiper` 版本 `^2.6.7"` (各个 swiper 版本间配置稍有不同)
+- `skeleton`: 若骨架屏注入, 提示资源加载异常, 将 `.env` 中 `VUE_APP_SKELETON` 设置为空字符串即可. (_index.html_ 引入 `skeleton.css` 可不注释)
+- `vue add quasar` 添加 `quasar` 组件库, 选择兼容 `IE 11`, 依赖和配置会自动注入
+
+- 底部导航栏体验优化 **计算布局容器避免使用 `margin`**
+
+  1. 前言: 钉钉布局底部会留出操作安全区域, 当页面超过一屏时, 底部 `TabBar` 上下滑动时会移动, 且 `web` 页面自身的滑动和 `webview` 自带的滑动体验感极差, _尤其添加了下拉刷新_
+  2. 方案: 基于 `quasar` 的 `QScrollArea` 组件, 其还支持自定义滚动条. 计算出固定位置高度后, 余下部分留给使用 `QScrollArea` 进行包裹, 是兼容 `QPullToRefresh` 下拉刷新的
+
+  ```
+  <!-- 滚动标签 -->
+  q-scroll-area.main-area(:thumb-style="thumbStyle" :bar-style="barStyle" :style="{height: areaH}")
+  <!-- 高度属性 -->
+  data() {
+    return {
+      areaH: 0 // 自定义滑动区域, 优化体验
+    };
+  },
+  computed: {
+    // 自定义滚动区域 Bar 样式
+    thumbStyle() {
+      return {
+        right: "4px",
+        borderRadius: "5px",
+        backgroundColor: "#027be3",
+        width: "5px",
+        opacity: 0.75
+      };
+    },
+    barStyle() {
+      return {
+        right: "2px",
+        borderRadius: "9px",
+        backgroundColor: "#027be3",
+        width: "9px",
+        opacity: 0.2
+      };
+    }
+  },
+  async created() {
+    // 因为父组件内使用 `this.$nextTick(() => { })`
+    this.$nextTick(() => {
+      this.areaH =
+        document.body.clientHeight -
+        this.tabH -
+        this.$refs.dateDom.clientHeight +
+        "px";
+    });
+  },
+  ```
+
+  3. 传值: 若父组件是在 `created` 内使用 `$nextTick`,其内部执行会晚于子组件的 `mounted` 方法。如在 `router-view` 嵌套的二级路由页面 `mounted` 晚于父组件 `$nextTick`
+  4. 效果: 页面部分区域滚动, 自定义滚动条, 兼容下拉刷新效果. 高度锁定了 `100%`, 因此页面滑动 `tabBar` 不会出现上下移动体验差情况. _若有可能, 通过各平台 jsApi 关闭 webview 的弹性效果_
+
+  ```
+  import { ui as ddUI } from "dingtalk-jsapi";
+  mounted() {
+    // 锁定滑动, 自定义滑动区域
+    ddUI.webViewBounce.disable();
+  },
+  ```
+
+--~---~---~---~---~---~---~-----~---~---~---~---~---~---~---~--~-----~---~-----~---~---~---~---~---~---~---~--~-----~---~-----~---~---~--
+
+_配置篇_
+
+# plop
+
+- 模板库在根目录下 `plop-templates`, 包含了组件, 页面和 `vuex`. `vue` 配置同于 `vue.json` 的 `snippets`. `vuex` 同于其 `modules` 下配置
+- 配置 `plopfile.js` 文件, 并在 `package.json` 的 `scripts` 配置 `new`, 运行 `npm run new` 即可选择对应魔板和文件路径名称, 即可生成. _自动添加组件 `name`_
+
+# 二级路由
+
+1. `mob` 配置了 `tabBar` (使用路由模式: 不需要绑定 `active`. `vant` 跳转仅使用二级路由 `path` 即可), `admin` 配置了 `sideBar`, 由 `admin.js` 路由文件 reduce 处理后加载
+2. 两个方式显示二级路由: `children` 内 `path` 前不添加 `/`, 则使用时需要 `一级path/二级路由path` 的格式; 若添加了 `/`, 则可直接使用其二级路由的 `path`,
+3. `tabBar.js` 配合 `vant` 使用路由模式: 不需要绑定 `active`. `vant` 跳转仅使用二级路由 `path` 即可. `tab` 的活跃取决于当前绑定的路由, 不使用 `v-model`
+
+# 关于 alias
+
+1. 不为 src 下一级文件夹配置别名: 无太大实际意义; 为按需引入文件和文件夹配置, 如 `@import "~mixin"`, `m-background-image('~image/background-admin.png')`, ...
+2. 关于路径前需要添加 `~` 标识说明: 配置了 webpack 输出路径为 `./`, _样式和图片文件_ 实质上是从编译包读取, 因此在 css 内引入需要添加, **注意: hmtl 内不能使用别名, 未进入编译**
+
+# 关于环境变量
+
+- 前言
+
+  1. 前言: `webpack` 运行会自动读取环境变量, 基于 `NodeJs`. `vue.config.js` 内使用 _ES5_ 语法不能通过 `import` 读取外部文件, 可用 `require`. (_process 不支持解构取值_)
+  2. 新建: 在 **根目录** 新建 `.env` 为全局环境变量, 运行 `package.json - scripts` 对应环境命令时, 先加载全局 .env, 再加载对应环境下变量, 同名变量后覆盖前
+  3. 命令: `serve / dev` 默认读取 `.env.development`, `build` 读取 `env.prod`. 若需要运行自定义环境, 在对应命令后追加 `--mode 对应环境配置文件名称`
+  4. 高级: `.local` 也可以加在指定模式的环境文件上,比如 .env.development.local 将会在 development 模式下被载入,且被 git 忽略 (配置秘钥会很有用)
+
+- 使用
+
+  1. 格式: 变量命名格式为 `VUE_APP_*`, 取值通过 `process.evn.VUE_APP_*` 取对应的 key 即可. 修改变量需要重新 run 项目, webpack 重新读取, 否则不生效
+  2. 说明: 若变量只是在编译期间使用, 不限制格式. 如 vue.config.js 中 `ISANALYZ`. 若需要打到 dist 包内, 则必须符合 `VUE_APP_*`格式
+  3. 高级: 不同环境文件命令互不能加载: 配置代理 key 时, 需要所有在 build 下文件中赋值 `"", 空字符串`.
+     1. _未定义变量值为 undefined, 不为空字符串, 支持对象 (BOOL 不会转换, 会以字符串出现)_
+     2. `.env`是 `string` 化的值, 非合法 `json: string` 可使用 `includes` 函数, 单独效验使用正则
+
+# 关于 proxy
+
+- 配置: 如需要多个代理, `proxy` 为对象类型, 继续添加对应环境变量, 同理配置即可实现
+
+```
+// 本地代理
+devServer: {
+  proxy: {
+    [env.VUE_APP_DEV_PROXY]: {
+      // target字段为空编译报错
+      target: env.VUE_APP_BASE_API,
+      changeOrigin: true,
+      pathRewrite: {
+        ["^" + env.VUE_APP_DEV_PROXY]: ""
+      }
+    }
+  },
+  disableHostCheck: true
+},
+```
+
+- 说明: 请求 target 和代理 key 配置在环境变量文件内, 若有关键不提交仓库信息, 可放入对应环境变量的 `.local` 文件内. _只需要修改开发环境文件中 `VUE_APP_DEV_PROXY` 即可_
+
+```
+// 开发代理
+function _devProxy(url) {
+  return process.env.VUE_APP_DEV_PROXY + url;
+}
+```
+
+# 关于 axios
+
+不直接使用对 `axios` 的封装 `request`, 进行网络请求: thunk 包装调用, 在实际请求文件中导入 `request`. _对 axios 的封装是 create 新的对象_
+
+- 使用
+
+  1. 请求格式 form/json 取决于 Content-Type:data 在 body, `详见 @/service/request:`. 发起请求不直接引用 `request`, 通过 `thunk` 包装调用
+  2. service 网络请求, 分文件模块化以管理不同业务请求. `request` 中新建了 axios 对象, 使用通过对应业务文件按需加载 (_不使用 `vue-router` 插件, 不在原型链添加 axios 对象_)
+
+- 拦截器
+
+  1.  返回 reject: 未被捕获下会在控制台抛出报错: `Uncaught (in promise) Error: 错误信息`, _阻断代码继续执行, 不需要添加额外条件处理_
+  2.  捕捉 reject: 使用 `try...catch` 语法; 或在调用方法后追加 `.catch(err => {})` 进行捕获 (_被捕获后则控制台不会报错, 注意后续代码执行, 不会阻断代码继续执行_)
+  3.  实现 catch 捕获: 要注意添加 if (!res) return; (catch 后 await 得到的就是其返回的值, 没有返回即为 undefined). _res 的判断条件取决于 catch 返回抛出的值_
+
+  ```
+  // 当前月事件分布 - 实现 catch 要注意添加 if (!res) return; (catch 后 await 得到的就是其返回的值, 没有返回即为 undefined)
+  async refreshMonthMark(params) {
+    const res = await monthRecordList(params).catch(() => false);
+    if (!res) return;
+    this.dateEvents = res.list.map(item => item.date.replace(/-/gi, "/"));
+  }
+  ```
+
+  4. 调用封装的 `async` 方法, 内部没有返回 `Promise`, 是不支持 `await` 的: 如 `reLogin` 的实现, 内部返回 `Promise`, 通过 `bus` 实现监听. _在 `progress` 重置登录中 `bus` 抛出_
+
+  ```
+
+  async created () {
+    await this.loginAuth(); // 登录验证: 返回Promise
+    this.inputCalendar(this.today) // 调用封装的async方法, 内部没有返回Promise, 是不支持await的: 若成功执行到reLogin后被挂起
+    await this.relogin() // 重试登录: 返回Promise => 每次登录失效的请求都会触发: 内部做一个throttle, 避免多个连续登录请求
+    this.inputCalendar(this.today) // 若收到登录失效回调: 刷新所有请求接口. 若正常请求成功, 被reLogin的await挂起
+  },
+  destroyed() {
+    bus.$vm.$off(bus.$sel.login_timeout);
+  }
+  // 登录过期重新登录: 每次登录失效的请求都会触发. throttle, 避免多个连续登录请求
+  relogin () {
+    return new Promise(resolve => {
+      const _reLogin = throttle(() => this._ddingLoginAuth(this, resolve), 300);
+      bus.$vm.$on(bus.$sel.login_timeout, () => _reLogin()); // 响应抛出登录失效
+    })
+  },
+  // 钉钉免登
+  loginAuth () {
+    if (this.token) return Promise.resolve();
+    return new Promise(resolve => {
+      const _this = this;
+      ready(function () {
+        _this._ddingLoginAuth(_this, resolve)
+      });
+    });
+  },
+  // 钉钉登录授权
+  _ddingLoginAuth (_this, resolve) {
+    runtime.permission.requestAuthCode({
+      corpId: process.env.VUE_APP_CorpId,
+      onSuccess: function (result) {
+        _this.loginIn({ authCode: result.code }).then(() => resolve());
+      }
+    });
+  },
+  ```
+
+- 提示效果 `@service/progres.js`
+
+  1. 「服务于通用而不是组件内部 」: 分平台全局提示 UI
+  2. 组件库优先于平台和设备: quasar 通用最优, 其次是优先移动端
+
+```
+import store from "@/store";
+
+const progress = {};
+const env = process.env;
+const _this = Vue.prototype;
+
+// 组件库优先于平台和设备: quasar 通用最优, 其次是优先移动端
+const quasar = env.VUE_APP_PLATFORM.includes(env.VUE_APP_UI_QUASAR);
+const vant = env.VUE_APP_PRIORITY !== "admin" && store?.state?.config?.native;
+```
+
+# 按需加载
+
+- 配置差异和说明
+
+  1. 注意: cli4 配置 .babelrc 不识别, 需要在 babel.config.js 配置. _按需引入不需要全局引入 css: 配置成功引入会报错_
+  2. 编译: `presets`配置使用 cli 自带插件, 不需要使用 `es2015`. 若使用 `npm i babel-preset-es2015 -D` 依赖 (_-D 为 --save-dev, -S 进入 `dependencies`_)
+  3. 依赖: `element-ui` 依赖于 `babel-plugin-component`, `vant-ui` 依赖于 `babel-plugin-import`. _都是开发依赖_
+  4. 自动: `quasar` 通过 `vue add quasar` 自动添加. 关于 `quasar.variables.styl` 不能修改文件名称和路径: 因为 `loader` 对路径做了绑定和优化, 需要引入才会生效
+
+**babel.config.js**
+
+```
+module.exports = {
+  presets: ["@vue/cli-plugin-babel/preset"],
+  plugins: [
+    "@babel/syntax-dynamic-import" /*异步加载*/,
+    [
+      "component",
+      {
+        libraryName: "element-ui",
+        styleLibraryName: "theme-chalk"
+      },
+      "element-ui"
+    ],
+    [
+      "import",
+      {
+        libraryName: "vant",
+        libraryDirectory: "es",
+        style: false // 主题和样式重置设置 false 减少包体积
+      },
+      "vant"
+    ],
+    [
+      "transform-imports",
+      {
+        quasar: {
+          transform: "quasar/dist/babel-transforms/imports.js",
+          preventFullImport: true
+        }
+      }
+    ]
+  ]
+};
+```
+
+- 重置主题和样式
+
+  1. 全局样式: 在 `App.vue` 中导入通用和样式重置: `@import '~@/styles/loader/common'`, `@import '~@/styles/loader/browser.reset'`
+  2. 移动端使用: `navBar` 组件导入移动端重置: 禁用长按复制粘贴: 在 `navBar` 载入 `@import '~@/styles/loader/navBar.reset'`
+  3. `element-ui` 使用 `sass`, 需要下载 `node-sass` 和 `sass-laoder` 开发依赖, 新建一个后缀为 _.scss 而不是 .sass 的文件_, 在 main.js 引入, 不是 App.vue
+  4. `vant-ui` 使用 `less`, 需要下载 `less` 和 `less-laoder` 开发依赖, _配置: `loaderOptions` 自动导入、`main.js` 导入主题、`.babel.config.js` 减少包体积_
+  5. `vant-ui`在`main.js`中`import "vant/lib/index.less";`引入主题. 且在`vue.config.js`配置`loaderOptions`进行样式重置; 在`babel.config.js`配置`style: false`以减少包体积
+  6. `quasar` 通过 `vue add quasar` 添加 `quasar` 组件库, 配置手动加载组件, `styles` 预编译, `icons` 使用 `recommend`, 选择兼容 `IE 11`, 依赖和配置会自动注入
+  7. `quasar` _因不能修改 `quasar.variables.styl` 文件名和路径_, 因此配置主题重置于此文件, 并在 `mian,js` 添加编译以引入 (`quasar.styl` 和 `quasar` 通过配置后删除)
+
+**main.js**
+
+```
+// 按需引入组件库 & 主题和样式重置
+
+/* IFTRUE_ELEMENT */
+import "@/config/loader/element.ui";
+import "@/styles/loader/element.variables"; // 主题和样式重置
+/* FITRUE_ELEMENT */
+
+/* IFTRUE_VANT */
+import "@/config/loader/vant.ui";
+import "vant/lib/index.less"; // 需引入主题: 且在 vue.config.js 配置 loaderOptions 进行样式重置; babel.config.js 配置 style: false 以减少包体积
+/* FITRUE_VANT */
+
+/* IFTRUE_QUASAR */
+import "@/config/loader/quasar.ui";
+import "@/styles/quasar.variables"; // 备注: 不能修改文件名称和路径: loader对路径做了绑定和优化, 需要引入才会生效 (vue add quasar 自动添加)
+/* FITRUE_QUASAR */
+
+```
+
+**vue.config.js**
+
+```
+// vant-ui 主题覆盖 less (条件编译) //
+let vantVariables = {};
+/* IFTRUE_MOB */
+vantVariables = {
+  modifyVars: {
+    hack: `true; @import "~@/styles/loader/vant.variables";`
+  }
+};
+/* FITRUE_MOB */
+
+// es5语法, 导出一份拷贝: 修改需要重新运行
+module.exports = {
+  // css配置: 自动加载 loaderOptions 对 stylus 和 less 都无效
+  css: {
+    less: vantVariables
+  },
+  configureWebpack: config => {
+    // quasar: vue add quasar 自动添加
+    pluginOptions: {
+      quasar: {
+        importStrategy: "manual",
+        rtlSupport: false
+      }
+    },
+    transpileDependencies: ["quasar"]
+  }
+}
+```
+
+# 骨架屏
+
+- 依赖 `vue-skeleton-webpack-plugin` 注入骨架屏. 环境变量 `VUE_APP_SKELETON` 控制是否加载骨架屏
+
+```
+// vue骨架屏插件配置
+if (env.VUE_APP_SKELETON) {
+  config.plugins.push(
+    new SkeletonWebpackPlugin({
+      webpackConfig: {
+        entry: {
+          app: resolve("src/config/skeleton/conf")
+        }
+      },
+      minimize: true,
+      quiet: true
+    })
+  );
+}
+```
+
+- 可通过 `https://github.com/famanoder/dps` 插件生成骨架屏, 将页面导入到 `@/config/skeleton` 即可; 或使用 `vue-content-loader` 组件库, _mob 可使用 vant 组件_
+
+- 更建议使用全局 loading gif 替换, 将图片打包为 base64 注入到代码: 骨架屏是打包注入, 此时不能访问 window. _根据条件编译, 可选择优先平台. 移动端端优先_
+
+```
+// 骨架屏是打包注入, 此时不能访问 window: 移动端优先
+const compile = process.env.VUE_APP_PRIORITY === "mob";
+
+// 骨架屏: 全局记录挂在方法
+window.mountApp = () => {
+  app.$mount("#app");
+  window.mountApp = null;
+};
+
+// 骨架屏:当js晚于css加载完成,那直接执行渲染
+if (window.STYLE_READY || process.env.VUE_APP_SKELETON) {
+  window.mountApp?.();
+}
+
+/* 骨架屏 (触发器): 服务于 index.hml, 触发自动挂载 vue 对象 -- skeleton.css */
+
+<!-- 骨架屏结束触发自动挂载 vue 对象 -->
+<link rel="preload" href="./skeleton.css" as="style" onload="rel='stylesheet';this.onload=null;loadSkeleton?.();">
+<script>
+  function loadSkeleton() {
+    window.STYLE_READY = true;
+    window.mountApp && window.mountApp();
+    loadSkeleton = null
+  }
+  // Firefox 不支持 preload
+  if (navigator.userAgent.indexOf("Firefox") > 0) {
+    loadSkeleton && loadSkeleton()
+  }
+</script>
+```
+
+# 条件编译
+
+- 前言: `require` 支持 if, `import` 需要使用 `const x = () => import('') / import('').then()`, 返回 `Promise`, 且返回值被 `default` 包裹 . _引入就被 build, 因此更优是条件编译_
+
+- 初衷: 不同平台按需加载对应模块, 定制化情况下减少包体积 (没有条件编译会打入包)
+
+```
+// 配置条件编译: js-conditional-compile-loader 下 npm 和 cnpm 不能混用
+config.module.rules.push({
+  test: /\.js\$/,
+  include: [resolve("src"), resolve("test")],
+  use: [{
+    loader: "js-conditional-compile-loader",
+    options: {
+    isDebug: process.env.NODE_ENV == "development", // optional, this is default
+      // 自定义 flag: process.env.npm_config_flag
+      VANT: env.VUE_APP_PLATFORM.includes(env.VUE_APP_UI_VANT),
+      ELEMENT: env.VUE_APP_PLATFORM.includes(env.VUE_APP_UI_ELEMENT),
+      QUASAR: env.VUE_APP_PLATFORM.includes(env.VUE_APP_UI_QUASAR),
+      }
+    }
+  ]
+});
+```
+
+- 使用: 默认配置了 DEBUG. 在 `vue.config.js` 添加了 `MOB` 和 `ADMIN` 和 `UNI` 编译条件, 通过 `VUE_APP_PLATFORM` 集合控制加载不同组件库 (_配置见 `按需加载`_)
+
+```
+/* IFDEBUG // 开发环境下远程调试vconsole
+import "@/config/loader/vconsole";
+FIDEBUG */
+```
+
+- 路由: 结合路由处理, 可为不同编译条件下包路由和体积不同, 做到真正的按需加载, 保密及性能
+
+```
+const indexAuto = "/index"; // 若有重定向, 使用目标页面, 避免由redirect触发router切换后报错
+const indexMob = "/loginIn";
+const indexAdmin = "/login";
+
+let index = indexAuto;
+
+import mob from "./modules/mob";
+import admin from "./modules/admin";
+import universal from "./modules/universal";
+
+const compile = process.env.VUE_APP_PRIORITY;
+const routes = [];
+
+if (compile === "mob") {
+  index = indexMob;
+  routes.push(...mob);
+} else if (compile === "admin") {
+  index = indexAdmin;
+  routes.push(...admin);
+} else {
+  if (compile === "universal") {
+    routes.push(...universal);
+  } else {
+    routes.push(...mob, ...admin, ...universal);
+  }
+}
+```
+
+# 代码处理
+
+- 前言: npm i uglifyjs-webpack-plugin@1 需要使用低版本, 新版本不识别 const 关键字 - 报 npm audit fix
+- 方案: 使用 TerserPlugin 插件, 配置代码压缩和去除注释和打印
+
+```
+// 代码压缩去除打印: npm i uglifyjs-webpack-plugin@1 需要使用低版本, 新版本不识别 const 关键字 - 报 npm audit fix
+minimizer: [
+  new TerserPlugin({
+  cache: false,
+  sourceMap: false,
+  parallel: true, // 使用多进程并行运行来提高构建速度。默认并发运行数:os.cpus().length - 1。
+  terserOptions: {
+    compress: {
+        drop_debugger: true, // 去除 debugger
+        drop_console: true, // 生产环境自动删除 console
+        dead_code: true // 去除不可达代码: 如 if (false) { ... }
+      },
+    warnings: false
+    }
+  })
+];
+```
+
+--~---~---~---~---~---~---~-----~---~---~---~---~---~---~---~--~-----~---~-----~---~---~---~---~---~---~---~--~-----~---~-----~---~---~--
+
+_样式篇_
+
+# 关于高分屏导致@media 和栅格布局异常说明和处理
+
+- css 颜色透明度:rgba(0, 0, 0, 0.6) / rgba("#000", 0.6)
+
+- 因为高分屏 `flexRem.js` 设置了 `viewport` 高分屏缩放比例方案. 导致 `@media` 无效, `-none、-xs、-sm、-md、-lg、-xl` 无效. `col` 和 `row` 可用, 但不支持分设备
+
+- **通过 `vuex` 配置了 `config` 文件, `vuex` 状态同步更新, 支持响应式动态绑定, 在 `App.vue` 全局进行响应**
+
+  1. `native` 是否移动端设备: 在 `flexRem` 的 `resize` 响应式更新. flexRem 窗口 resize 同步更新 `store.commit("config/SET_IS_NATIVE", isNative());`
+  2. `realWid` 还原 `document.body.clienWidth` 真实的宽度. 在 `setFlexRem` 中同步 `store.commit("config/SET_REAL_WID", document.body.clientWidth * scale);`
+  3. 通过 `getters` 缓存和响应需要监听变更窗口的临界点, _实现了类 @media 效果_, 响应式尺寸配置型
+
+  ```
+  // computed
+  const getters = {
+    // 缓存realWid是否需要变更布局
+    media: state => {
+      const docWid = state.config.realWid;
+      return {
+        mob: docWid <= 734,
+        pad: docWid > 734 && docWid <= 1080,
+        pc: docWid > 1080
+      };
+    }
+  };
+  ```
+
+- 通过绑定动态 `class` 对象, 实现类似 `@media` 的效果
+
+  1. 在页面顶层容器 `App.vue` 上绑定动态样式 `div(id="app" :class="classMedia"). 容器包含关系, 样式自然全局有效
+
+  2. 将动态样式绑定在计算属性 `computed` 内, 和 `App.vue` 容器绑定. 如下监听 `this.$store.getters` 对于 `@media`计算属性的变化
+
+  ```
+  <script>
+  export default {
+    computed: {
+      // 实现了@media功能, 兼容高分屏方案
+      classMedia() {
+        const { mob, pad, pc } = this.$store.getters.media;
+        const native = this.$store.state.config.native;
+        return {
+          "media-mob": mob,
+          "media-pad": pad,
+          "media-pc": pc,
+          // 移动设备, 监听运行环境是否移动设备
+          "device-native": native,
+          "device-web": !native
+        };
+      }
+    }
+  };
+  </script>
+  ```
+
+  3. 在设置通用样式控制现显示与隐藏, 若需要为指定自定义 `@media` 重置样式, 将样式写入对应类下即可, **注意权重, 建议直接复制修改对应值**
+
+  ```
+  // 通过vuex实现了高分屏兼容@media布局: 控制显示设备, only 当前设备显示, not 当前设备隐藏(_注意理解 `only` 的含义_)
+  .media-mob
+    .only-pad, .only-pc, .not-mob
+      display none
+  .media-pad
+    .only-mob, .only-pc, .not-pad
+      display none
+  .media-pc
+    .only-pad, .only-mob, .not-pc
+      display none
+  // 移动设备, 监听运行环境是否移动设备
+  .device-native
+    .only-web, .not-native
+      display none
+  .device-web
+    .only-native, .not-web
+      display none
+  ```
+
+  4.  若需要为指定自定义 `@media` 重置样式, 将样式写入对应类下即可
+
+      1.  若在指定设备重置样式, 样式重置没有 `only` 和 `not`, 不同设备类下进行重置, 多个设备取并集即可. **注意权重, 建议直接复制然后修改对应值**
+      2.  仅仅在 `mob` 显示: `q-menu.only-mob`, 仅不在 `mob` 显示: `q-tabs.not-mob`. 不要取并集如 `q-tabs.only-pad.only-pc` 可能两个设备下都不会显示. _注意理解 `only` 的含义_
+
+  ```
+  // 通过 vuex 实现了高分屏兼容@media 布局
+  .media-pc, .media-pad
+    .layout
+      &-content
+        &:first-child
+          left 25%
+        &:last-child
+          right 25%
+  .media-mob
+    .layout
+      &-content
+        width 100%
+  ```
+
+      4. `device-web` 和 `device-native` 检测. 也同样添加了 `only` 和 `not` 处理. 样式重置可以取并集. _参考上面第2 和第1 的说明_
+
+  ```
+  // 仅web环境下添加: q-btn 的 hover 效果
+  .device-web
+    .q-btn
+      transition all 0.5s
+      position relative
+      overflow hidden
+    .q-btn:before
+      content ''
+      position absolute
+      top 0
+      left 0
+      width 100%
+      height 100%
+      z-index 1
+      transition all 0.5s
+      opacity 1
+      transform translate(-105%, 0)
+      border-right-width 1px
+      border-right-style solid
+      border-right-color rgba(0, 0, 0, 1)
+      background-color rgba(0, 0, 0, 0.25)
+    .q-btn:hover:before
+      opacity 0
+      transform translate(0, 0)
+  ```
+
+# 高分屏方案和 rem 适配
+
+1. 针对不同移动机型做高分屏方案, 兼容 pc 端 dpr 为 1, 兼容@media 无效问题
+2. 通过 px2rem 自动将 px 转为 rem. `基准值 100, 基准字号 14`, 可根据设计图实际调整, 配置在 `.env` 文件
+3. 响应 `onsize` 方法动态更新尺寸, 更新方法 `setFlexRem` 做了防抖和节流
+
+```
+// setFlexRem 防抖和节流
+import { debounce, throttle } from "@/utils/optimize";
+import bus from "@/config/event-bus";
+
+// 节流: 初始化和 onresize 加载时
+const initLayout = throttle(isEmit => {
+  setFlexRem();
+  if (isEmit) bus.$vm.$emit(bus.$sel.window_resize); // 响应式更新
+}, 400);
+
+// 初始化
+initLayout();
+// 兼容 onresize 改变窗口全局调用: 防抖
+window.onresize = debounce(() => initLayout(true), 200);
+// 响应式需要调用resize事件以响应加载时更新
+window.onload = () => {
+  bus.$vm.$emit(bus.$sel.window_onload);
+  bus.$vm.$emit(bus.$sel.window_resize);
+};
+
+// bus.$vm.$on(bus.$sel.window_resize, () => { }); // 响应resize
+// bus.$vm.$on(bus.$sel.window_onload, () => { }); // 响应onload
+```
+
+# 关于 iconfont
+
+- iconfont 地址:: 注意在 css 后缀下**format**会被单独格式化为一行, stylus 导入后不识别这个语法 (less OK), 因此不独立配置 iconfont.css 文件
+
+# 关于 stylus @import
+
+1. 存在 css 预编译时,@impor 导入 css 不需要添加 css-loader; **使用 css loaderOptions 而不是 style-resources-loader 实现自动加载**
+2. _使用 stylus 引用绝对路径需要在路径前添加 ~ ,支持别名. 否则路径不识别_ (不建议使用相对路径); **按需加载, 主题已自动加载 (`初次不生效多刷新下`)**
+3. 按需引入 `@import '~styles/extend'`, 若使用 `@extend` 时, _导入必须在 `@extend` 之前_. mixin 不受影响. **@extend 导入后可直接使用已定义样式**
+4. _转义_: `Unicode` 模式下多色图标是无效的, 若有需求可使用 `Font class` 或 `Symbol`. **`Unicode` 下若图标需要在 `data` 传递到标签, 将 `&#xe606;` 替换为 `\ue606` 即可**
+
+# 关于 css loaderOptions
+
+- 配置文件
+
+```
+// css 配置: 自动加载 loaderOptions 对 stylus 和 less 都无效
+css: {
+  extract: true,
+  sourceMap: false,
+  loaderOptions: {
+    // 自动加载仅导入主题: less sass stylus 方式不相同 - 细节详见 README.md
+    stylus: {
+      import: "~@styles/loader/variables.styl"
+    }
+  }
+},
+```
+
+# style-resources-loader
+
+- 前言
+
+  1.  前言:css 配置 `loaderOptions` 对, less sass stylus 方式不相同. 以下是使用 `style-resource-loader`自动加载 css 整理
+  1.  方式:不要使用 npm i style-resources-loader —save-dev,使用 vue add style-resources-loader 命令, 按提示选择 css 预编译语言, 配置路径
+  1.  注意:style-resources-loader 是兼容加载 css 的,当作对应的文件传入方式即可(在 css 预编译文件内,支持直接 @import .css 文件,_不需要添加 css-loader_)
+
+- 说明
+
+  1.  css 做了 chunk, 每次打包都会更新, 每一个页面 css 文件都是独立的
+  2.  在 style 标签内导入非 .css 文件, 是不能直接使用的, 因为 @import 仅仅是导入, 没有预编译, 也不能触发 loader 进行转译.
+  3.  css 预编译 @import 是不能直接使用的, 需要通过继承/混入为当前文件下的类. 或通过 style-resource-loader 自动加载, 对应的 loader 后转译可以使用: _仅用于导入配置变量_
+  4.  若 vue 页面有 scoped 和非 scoped 的 style 标签, loader 私有化后打入到文件会因为 scoped 原因带出现有 id 和不带 id 的两套样式, 导致 chunk 文件过大, 且大多是无效重复样式
+
+- 使用
+
+  1.  全局样式写为 css 文件, 如 resetcss 通过 App.vue 内引入, 不加 scoped. 打包仅产生一个 app.css 文件, 且是全局有效的. _reset 其它组件库也使用这种方式_
+  2.  _css 预编译文件按需引入_, 通过混合和继承产生类后使用.
+  3.  主题即值类型通过 style-resources-loader 自动加载引入, _主题即变量不会触发打包 chunk_
+  4.  不要在页面内 scoped 和非 scoped 的 style 标签. _最根本的解决方案还是: 1. 自动加载仅导入主题; 2. 全局样式写为.css 在 App.vue 导入; 3. 预编译文件按需要引入_
+
+- 配置
+
+```
+// 自动加载主题: 使用 vue add style-resources-loader 添加插件方式
+pluginOptions: {
+"style-resources-loader": {
+  preProcessor: "stylus",
+  // 自动加载仅导入主题: 支持 .css 文件,不需要添加 css-loader - 细节详见 README.md
+    patterns: [path.resolve(__dirname, "src/theme/index.styl")]
+  }
+},
+```
+
+--~---~---~---~---~---~---~-----~---~---~---~---~---~---~---~--~-----~---~-----~---~---~---~---~---~---~---~--~-----~---~-----~---~---~--

+ 26 - 0
babel.config.js

@@ -0,0 +1,26 @@
+module.exports = {
+  presets: ["@vue/cli-plugin-babel/preset"],
+  plugins: [
+    "@babel/syntax-dynamic-import" /*异步加载*/,
+    [
+      "transform-imports",
+      {
+        quasar: {
+          transform: "quasar/dist/babel-transforms/imports.js",
+          preventFullImport: true
+        }
+      }
+    ],
+    [
+      "import",
+      { libraryName: "ant-design-vue", libraryDirectory: "es", style: true }
+    ],
+    [
+      "component",
+      {
+        libraryName: "element-ui",
+        styleLibraryName: "theme-chalk"
+      }
+    ]
+  ]
+};

+ 0 - 0
directoryList.md


+ 275 - 0
eslintrc.js

@@ -0,0 +1,275 @@
+module.exports = {
+  root: true,
+  parserOptions: {
+    parser: "babel-eslint",
+    sourceType: "module"
+  },
+  env: {
+    browser: true,
+    node: true,
+    es6: true
+  },
+  extends: ["plugin:vue/recommended", "eslint:recommended"],
+
+  // add your custom rules here
+  //it is base on https://github.com/vuejs/eslint-config-vue
+  rules: {
+    "vue/max-attributes-per-line": [
+      2,
+      {
+        singleline: 10,
+        multiline: {
+          max: 1,
+          allowFirstLine: false
+        }
+      }
+    ],
+    "vue/singleline-html-element-content-newline": "off",
+    "vue/multiline-html-element-content-newline": "off",
+    "vue/name-property-casing": ["error", "PascalCase"],
+    "vue/no-v-html": "off",
+    "accessor-pairs": 2,
+    "arrow-spacing": [
+      2,
+      {
+        before: true,
+        after: true
+      }
+    ],
+    "block-spacing": [2, "always"],
+    "brace-style": [
+      2,
+      "1tbs",
+      {
+        allowSingleLine: true
+      }
+    ],
+    camelcase: [
+      0,
+      {
+        properties: "always"
+      }
+    ],
+    "comma-dangle": [2, "never"],
+    "comma-spacing": [
+      2,
+      {
+        before: false,
+        after: true
+      }
+    ],
+    "comma-style": [2, "last"],
+    "constructor-super": 2,
+    curly: [2, "multi-line"],
+    "dot-location": [2, "property"],
+    "eol-last": 2,
+    eqeqeq: ["error", "always", { null: "ignore" }],
+    "generator-star-spacing": [
+      2,
+      {
+        before: true,
+        after: true
+      }
+    ],
+    "handle-callback-err": [2, "^(err|error)$"],
+    indent: [
+      2,
+      2,
+      {
+        SwitchCase: 1
+      }
+    ],
+    "jsx-quotes": [2, "prefer-single"],
+    "key-spacing": [
+      2,
+      {
+        beforeColon: false,
+        afterColon: true
+      }
+    ],
+    "keyword-spacing": [
+      2,
+      {
+        before: true,
+        after: true
+      }
+    ],
+    "new-cap": [
+      2,
+      {
+        newIsCap: true,
+        capIsNew: false
+      }
+    ],
+    "new-parens": 2,
+    "no-array-constructor": 2,
+    "no-caller": 2,
+    "no-console": "off",
+    "no-class-assign": 2,
+    "no-cond-assign": 2,
+    "no-const-assign": 2,
+    "no-control-regex": 0,
+    "no-delete-var": 2,
+    "no-dupe-args": 2,
+    "no-dupe-class-members": 2,
+    "no-dupe-keys": 2,
+    "no-duplicate-case": 2,
+    "no-empty-character-class": 2,
+    "no-empty-pattern": 2,
+    "no-eval": 2,
+    "no-ex-assign": 2,
+    "no-extend-native": 2,
+    "no-extra-bind": 2,
+    "no-extra-boolean-cast": 2,
+    "no-extra-parens": [2, "functions"],
+    "no-fallthrough": 2,
+    "no-floating-decimal": 2,
+    "no-func-assign": 2,
+    "no-implied-eval": 2,
+    "no-inner-declarations": [2, "functions"],
+    "no-invalid-regexp": 2,
+    "no-irregular-whitespace": 2,
+    "no-iterator": 2,
+    "no-label-var": 2,
+    "no-labels": [
+      2,
+      {
+        allowLoop: false,
+        allowSwitch: false
+      }
+    ],
+    "no-lone-blocks": 2,
+    "no-mixed-spaces-and-tabs": 2,
+    "no-multi-spaces": 2,
+    "no-multi-str": 2,
+    "no-multiple-empty-lines": [
+      2,
+      {
+        max: 1
+      }
+    ],
+    "no-native-reassign": 2,
+    "no-negated-in-lhs": 2,
+    "no-new-object": 2,
+    "no-new-require": 2,
+    "no-new-symbol": 2,
+    "no-new-wrappers": 2,
+    "no-obj-calls": 2,
+    "no-octal": 2,
+    "no-octal-escape": 2,
+    "no-path-concat": 2,
+    "no-proto": 2,
+    "no-redeclare": 2,
+    "no-regex-spaces": 2,
+    "no-return-assign": [2, "except-parens"],
+    "no-self-assign": 2,
+    "no-self-compare": 2,
+    "no-sequences": 2,
+    "no-shadow-restricted-names": 2,
+    "no-spaced-func": 2,
+    "no-sparse-arrays": 2,
+    "no-this-before-super": 2,
+    "no-throw-literal": 2,
+    "no-trailing-spaces": 2,
+    "no-undef": 2,
+    "no-undef-init": 2,
+    "no-unexpected-multiline": 2,
+    "no-unmodified-loop-condition": 2,
+    "no-unneeded-ternary": [
+      2,
+      {
+        defaultAssignment: false
+      }
+    ],
+    "no-unreachable": 2,
+    "no-unsafe-finally": 2,
+    "no-unused-vars": [
+      2,
+      {
+        vars: "all",
+        args: "none"
+      }
+    ],
+    "no-useless-call": 2,
+    "no-useless-computed-key": 2,
+    "no-useless-constructor": 2,
+    "no-useless-escape": 0,
+    "no-whitespace-before-property": 2,
+    "no-with": 2,
+    "one-var": [
+      2,
+      {
+        initialized: "never"
+      }
+    ],
+    "operator-linebreak": [
+      2,
+      "after",
+      {
+        overrides: {
+          "?": "before",
+          ":": "before"
+        }
+      }
+    ],
+    "padded-blocks": [2, "never"],
+    quotes: [
+      2,
+      "single",
+      {
+        avoidEscape: true,
+        allowTemplateLiterals: true
+      }
+    ],
+    semi: [2, "never"],
+    "semi-spacing": [
+      2,
+      {
+        before: false,
+        after: true
+      }
+    ],
+    "space-before-blocks": [2, "always"],
+    "space-before-function-paren": [2, "never"],
+    "space-in-parens": [2, "never"],
+    "space-infix-ops": 2,
+    "space-unary-ops": [
+      2,
+      {
+        words: true,
+        nonwords: false
+      }
+    ],
+    "spaced-comment": [
+      2,
+      "always",
+      {
+        markers: [
+          "global",
+          "globals",
+          "eslint",
+          "eslint-disable",
+          "*package",
+          "!",
+          ","
+        ]
+      }
+    ],
+    "template-curly-spacing": [2, "never"],
+    "use-isnan": 2,
+    "valid-typeof": 2,
+    "wrap-iife": [2, "any"],
+    "yield-star-spacing": [2, "both"],
+    yoda: [2, "never"],
+    "prefer-const": 2,
+    "no-debugger": process.env.NODE_ENV === "prod" ? 2 : 0,
+    "object-curly-spacing": [
+      2,
+      "always",
+      {
+        objectsInObjects: false
+      }
+    ],
+    "array-bracket-spacing": [2, "never"]
+  }
+};

+ 9 - 0
jsconfig.json

@@ -0,0 +1,9 @@
+{
+  "compilerOptions": {
+    "baseUrl": "./",
+    "paths": {
+      "@/*": ["src/*"]
+    }
+  },
+  "exclude": ["node_modules", "dist"]
+}

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 33189 - 0
package-lock.json


+ 94 - 0
package.json

@@ -0,0 +1,94 @@
+{
+  "name": "vue-mcli",
+  "version": "0.1.0",
+  "private": true,
+  "scripts": {
+    "inspect": "vue-cli-service inspect",
+    "serve": "vue-cli-service serve",
+    "dev": "vue-cli-service serve",
+    "build": "rm -rf node_module/.cache && vue-cli-service build",
+    "lint": "vue-cli-service lint",
+    "clean": "rm -rf node_module/.cache && rimraf dist && rimraf test",
+    "topology": "node node_modules/mddir/src/mddir src",
+    "new": "plop",
+    "test": "vue-cli-service serve --mode=test"
+  },
+  "dependencies": {
+    "@quasar/extras": "^1.0.0",
+    "ant-design-vue": "^1.7.4",
+    "axios": "^0.19.2",
+    "core-js": "^3.6.4",
+    "crypto-js": "^4.0.0",
+    "element-ui": "^2.15.1",
+    "fecha": "^4.2.0",
+    "file-saver": "^2.0.5",
+    "number-precision": "^1.3.2",
+    "qs": "^6.9.2",
+    "quasar": "^1.0.0",
+    "swiper": "^5.4.5",
+    "vconsole": "^3.3.4",
+    "vue": "^2.6.11",
+    "vue-router": "^3.1.6",
+    "vuex": "^3.1.3",
+    "xlsx": "^0.16.9"
+  },
+  "devDependencies": {
+    "@babel/plugin-syntax-dynamic-import": "^7.8.3",
+    "@vue/cli-plugin-babel": "^4.2.3",
+    "@vue/cli-plugin-eslint": "^4.2.3",
+    "@vue/cli-service": "^4.2.3",
+    "babel-eslint": "^10.1.0",
+    "babel-plugin-component": "^1.1.1",
+    "babel-plugin-import": "^1.13.0",
+    "babel-plugin-transform-imports": "1.5.0",
+    "compression-webpack-plugin": "^3.1.0",
+    "eslint": "^6.8.0",
+    "eslint-plugin-vue": "^6.2.2",
+    "js-conditional-compile-loader": "^1.0.13",
+    "less": "^3.11.1",
+    "less-loader": "^5.0.0",
+    "mddir": "^1.1.1",
+    "node-sass": "^4.13.1",
+    "plop": "^2.7.1",
+    "postcss-px2rem": "^0.3.0",
+    "pre-commit": "^1.2.2",
+    "pug": "^3.0.2",
+    "pug-plain-loader": "^1.1.0",
+    "sass-loader": "^8.0.2",
+    "stylus": "^0.54.8",
+    "stylus-loader": "^3.0.2",
+    "terser-webpack-plugin": "^2.3.5",
+    "video.js": "^7.11.8",
+    "vue-awesome-swiper": "^4.1.1",
+    "vue-cli-plugin-quasar": "~2.0.2",
+    "vue-skeleton-webpack-plugin": "^1.2.2",
+    "vue-template-compiler": "^2.6.11"
+  },
+  "eslintConfig": {
+    "root": true,
+    "env": {
+      "node": true
+    },
+    "extends": [
+      "plugin:vue/essential",
+      "eslint:recommended"
+    ],
+    "rules": {},
+    "parserOptions": {
+      "parser": "babel-eslint"
+    }
+  },
+  "postcss": {
+    "plugins": {
+      "autoprefixer": {}
+    }
+  },
+  "browserslist": [
+    "> 1%",
+    "last 2 versions",
+    "not ie <= 8"
+  ],
+  "pre-commit": [
+    "lint"
+  ]
+}

+ 33 - 0
plop-templates/component/index.hbs

@@ -0,0 +1,33 @@
+{{#if template}}
+<template lang="pug">
+  div.comp
+</template>
+{{/if}}
+
+{{#if script}}
+<script>
+  export default {
+    name: 'comp-{{ properCase name }}',
+    props: {},
+    data() {
+      return {
+
+      }
+    },
+    computed: {},
+    watch: {},
+    filters: {},
+    created() { },
+    mounted() { },
+    methods: {
+
+    },
+  }
+</script>
+{{/if}}
+
+{{#if style}}
+<style lang="stylus" scoped>
+
+</style>
+{{/if}}

+ 62 - 0
plop-templates/component/prompt.js

@@ -0,0 +1,62 @@
+const { notEmpty } = require("../utils.js");
+
+module.exports = {
+  description: "generate vue component",
+  prompts: [
+    {
+      type: "input",
+      name: "name",
+      message: "component name please",
+      validate: notEmpty("name")
+    },
+    {
+      type: "checkbox",
+      name: "blocks",
+      message: "Blocks:",
+      choices: [
+        {
+          name: "<template>",
+          value: "template",
+          checked: true
+        },
+        {
+          name: "<script>",
+          value: "script",
+          checked: true
+        },
+        {
+          name: "style",
+          value: "style",
+          checked: true
+        }
+      ],
+      validate(value) {
+        if (
+          value.indexOf("script") === -1 &&
+          value.indexOf("template") === -1
+        ) {
+          return "Components require at least a <script> or <template> tag.";
+        }
+        return true;
+      }
+    }
+  ],
+  actions: data => {
+    const name = "{{properCase name}}";
+    const actions = [
+      {
+        type: "add",
+        path: `src/components/${name}/index.vue`,
+        templateFile: "plop-templates/component/index.hbs",
+        data: {
+          name: name,
+          template: data.blocks.includes("template"),
+          script: data.blocks.includes("script"),
+          style: data.blocks.includes("style")
+        }
+      }
+    ];
+
+    return actions;
+  }
+};

+ 16 - 0
plop-templates/store/index.hbs

@@ -0,0 +1,16 @@
+{{#if state}}
+const state = {}
+{{/if}}
+
+{{#if mutations}}
+const mutations = {}
+{{/if}}
+
+{{#if actions}}
+const actions = {}
+{{/if}}
+
+export default {
+namespaced: true,
+{{options}}
+}

+ 62 - 0
plop-templates/store/prompt.js

@@ -0,0 +1,62 @@
+const { notEmpty } = require('../utils.js')
+
+module.exports = {
+  description: 'generate store',
+  prompts: [{
+    type: 'input',
+    name: 'name',
+    message: 'store name please',
+    validate: notEmpty('name')
+  },
+  {
+    type: 'checkbox',
+    name: 'blocks',
+    message: 'Blocks:',
+    choices: [{
+      name: 'state',
+      value: 'state',
+      checked: true
+    },
+    {
+      name: 'mutations',
+      value: 'mutations',
+      checked: true
+    },
+    {
+      name: 'actions',
+      value: 'actions',
+      checked: true
+    }
+    ],
+    validate(value) {
+      if (!value.includes('state') || !value.includes('mutations')) {
+        return 'store require at least state and mutations'
+      }
+      return true
+    }
+  }
+  ],
+  actions(data) {
+    const name = '{{name}}'
+    const { blocks } = data
+    const options = ['state', 'mutations']
+    const joinFlag = `,
+  `
+    if (blocks.length === 3) {
+      options.push('actions')
+    }
+
+    const actions = [{
+      type: 'add',
+      path: `src/store/modules/${name}.js`,
+      templateFile: 'plop-templates/store/index.hbs',
+      data: {
+        options: options.join(joinFlag),
+        state: blocks.includes('state'),
+        mutations: blocks.includes('mutations'),
+        actions: blocks.includes('actions')
+      }
+    }]
+    return actions
+  }
+}

+ 2 - 0
plop-templates/utils.js

@@ -0,0 +1,2 @@
+exports.notEmpty = name => v =>
+  !v || v.trim() === '' ? `${name} is required` : true

+ 33 - 0
plop-templates/view/index.hbs

@@ -0,0 +1,33 @@
+{{#if template}}
+<template lang="pug">
+  div.main
+</template>
+{{/if}}
+
+{{#if script}}
+<script>
+  export default {
+    name: '{{ properCase name }}',
+    components: {},
+    data() {
+      return {
+
+      }
+    },
+    computed: {},
+    watch: {},
+    filters: {},
+    created() { },
+    mounted() { },
+    methods: {
+
+    },
+  }
+</script>
+{{/if}}
+
+{{#if style}}
+<style lang="stylus" scoped>
+
+</style>
+{{/if}}

+ 55 - 0
plop-templates/view/prompt.js

@@ -0,0 +1,55 @@
+const { notEmpty } = require('../utils.js')
+
+module.exports = {
+  description: 'generate a view',
+  prompts: [{
+    type: 'input',
+    name: 'name',
+    message: 'view name please',
+    validate: notEmpty('name')
+  },
+  {
+    type: 'checkbox',
+    name: 'blocks',
+    message: 'Blocks:',
+    choices: [{
+      name: '<template>',
+      value: 'template',
+      checked: true
+    },
+    {
+      name: '<script>',
+      value: 'script',
+      checked: true
+    },
+    {
+      name: 'style',
+      value: 'style',
+      checked: true
+    }
+    ],
+    validate(value) {
+      if (value.indexOf('script') === -1 && value.indexOf('template') === -1) {
+        return 'View require at least a <script> or <template> tag.'
+      }
+      return true
+    }
+  }
+  ],
+  actions: data => {
+    const name = '{{name}}'
+    const actions = [{
+      type: 'add',
+      path: `src/views/${name}/index.vue`,
+      templateFile: 'plop-templates/view/index.hbs',
+      data: {
+        name: name,
+        template: data.blocks.includes('template'),
+        script: data.blocks.includes('script'),
+        style: data.blocks.includes('style')
+      }
+    }]
+
+    return actions
+  }
+}

+ 9 - 0
plopfile.js

@@ -0,0 +1,9 @@
+const viewGenerator = require('./plop-templates/view/prompt')
+const componentGenerator = require('./plop-templates/component/prompt')
+const storeGenerator = require('./plop-templates/store/prompt.js')
+
+module.exports = function(plop) {
+  plop.setGenerator('view', viewGenerator)
+  plop.setGenerator('component', componentGenerator)
+  plop.setGenerator('store', storeGenerator)
+}

BIN
public/favicon.ico


+ 39 - 0
public/index.html

@@ -0,0 +1,39 @@
+<!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,user-scalable=0" />
+  <link rel="icon" href="<%= BASE_URL %>favicon.ico" />
+  <title></title>
+
+  <!-- 骨架屏结束触发自动挂载 vue 对象 -->
+  <link rel="preload" href="./skeleton.css" as="style"
+    onload="rel='stylesheet'; this.onload=null ;loadSkeleton && loadSkeleton();">
+  <script>
+    function loadSkeleton() {
+      window.STYLE_READY = true;
+      window.mountApp && window.mountApp();
+      loadSkeleton = null
+    }
+    // 兼容不支持 preload 浏览器, 如 Firefox, 部分Android, 尤其华为
+    setTimeout(() => {
+      loadSkeleton && loadSkeleton()
+    }, 200);
+
+  </script>
+  <!-- 飞书API: 蠢不正常npm单独引用 -->
+  <script type="text/javascript" src="https://lf1-cdn-tos.bytegoofy.com/goofy/lark/op/h5-js-sdk-1.5.15.js"></script>
+</head>
+
+<body>
+  <noscript>
+    <strong>We're sorry but web doesn't work properly without JavaScript enabled.
+      Please enable it to continue.</strong>
+  </noscript>
+  <div id="app"></div>
+  <!-- built files will be auto injected -->
+</body>
+
+</html>

+ 5 - 0
public/skeleton.css

@@ -0,0 +1,5 @@
+/* 骨架屏 (触发器): 服务于 index.hml, 触发自动挂载 vue 对象 */
+
+:export {
+  share: "css-modules :export 共享变量到 JavaScript";
+}

+ 53 - 0
src/App.vue

@@ -0,0 +1,53 @@
+<template lang="pug">
+  div(id="app" :class="classMedia")
+    //- 淡入淡出: App.vue 使用页面返回会跳动: transition(name="fade")
+    router-view
+</template>
+
+<script>
+export default {
+  computed: {
+    // 实现了@media功能, 兼容高分屏方案
+    classMedia() {
+      const { mob, pad, pc } = this.$store.getters.media;
+      const native = this.$store.state.config.native;
+      return {
+        "media-mob": mob,
+        "media-pad": pad,
+        "media-pc": pc,
+        // 移动设备, 监听运行环境是否移动设备
+        "device-native": native,
+        "device-web": !native
+      };
+    }
+  }
+};
+</script>
+
+<style lang="stylus">
+// 全局样式 & 样式重置导入 - 细节详见 README.md
+@import '~@/styles/loader/common'
+@import '~@/styles/loader/browser.reset'
+
+// 通过vuex实现了高分屏兼容@media布局: 控制显示设备, only 当前设备显示, not 当前设备隐藏(_注意理解 `only` 的含义_)
+.media-mob
+  .only-pad, .only-pc, .not-mob
+    display: none
+
+.media-pad
+  .only-mob, .only-pc, .not-pad
+    display: none
+
+.media-pc
+  .only-pad, .only-mob, .not-pc
+    display: none
+
+// 移动设备, 监听运行环境是否移动设备
+.device-native
+  .only-web, .not-native
+    display: none
+
+.device-web
+  .only-native, .not-web
+    display: none
+</style>

BIN
src/assets/images/404.jpg


BIN
src/assets/logo/favicon.ico


BIN
src/assets/logo/logo.png


+ 113 - 0
src/components/common/TopBack/index.vue

@@ -0,0 +1,113 @@
+<template>
+  <transition :name="transitionName">
+    <div v-show="visible" :style="customStyle" class="back-to-ceiling" @click="backToTop">
+      <svg width="16" height="16" viewBox="0 0 17 17" xmlns="http://www.w3.org/2000/svg" class="Icon Icon--backToTopArrow" aria-hidden="true" style="height: 16px; width: 16px;">
+        <path d="M12.036 15.59a1 1 0 0 1-.997.995H5.032a.996.996 0 0 1-.997-.996V8.584H1.03c-1.1 0-1.36-.633-.578-1.416L7.33.29a1.003 1.003 0 0 1 1.412 0l6.878 6.88c.782.78.523 1.415-.58 1.415h-3.004v7.004z" />
+      </svg>
+    </div>
+  </transition>
+</template>
+
+<script>
+export default {
+  name: 'BackToTop',
+  props: {
+    visibilityHeight: {
+      type: Number,
+      default: 400,
+    },
+    backPosition: {
+      type: Number,
+      default: 0,
+    },
+    customStyle: {
+      type: Object,
+      default: function () {
+        return {
+          right: '50px',
+          bottom: '50px',
+          width: '40px',
+          height: '40px',
+          'border-radius': '4px',
+          'line-height': '45px',
+          background: '#e7eaf1',
+        };
+      },
+    },
+    transitionName: {
+      type: String,
+      default: 'fade',
+    },
+  },
+  data () {
+    return {
+      visible: false,
+      interval: null,
+      isMoving: false,
+    };
+  },
+  mounted () {
+    window.addEventListener('scroll', this.handleScroll);
+  },
+  beforeDestroy () {
+    window.removeEventListener('scroll', this.handleScroll);
+    if (this.interval) {
+      clearInterval(this.interval);
+    }
+  },
+  methods: {
+    handleScroll () {
+      this.visible = window.pageYOffset > this.visibilityHeight;
+    },
+    backToTop () {
+      if (this.isMoving) return;
+      const start = window.pageYOffset;
+      let i = 0;
+      this.isMoving = true;
+      this.interval = setInterval(() => {
+        const next = Math.floor(this.easeInOutQuad(10 * i, start, -start, 500));
+        if (next <= this.backPosition) {
+          window.scrollTo(0, this.backPosition);
+          clearInterval(this.interval);
+          this.isMoving = false;
+        } else {
+          window.scrollTo(0, next);
+        }
+        i++;
+      }, 16.7);
+    },
+    easeInOutQuad (t, b, c, d) {
+      if ((t /= d / 2) < 1) return (c / 2) * t * t + b;
+      return (-c / 2) * (--t * (t - 2) - 1) + b;
+    },
+  },
+};
+</script>
+
+<style scoped>
+.back-to-ceiling {
+  position: fixed;
+  display: inline-block;
+  text-align: center;
+  cursor: pointer;
+}
+
+.back-to-ceiling:hover {
+  background: #d5dbe7;
+}
+
+.fade-enter-active,
+.fade-leave-active {
+  transition: opacity 0.5s;
+}
+
+.fade-enter,
+.fade-leave-to {
+  opacity: 0;
+}
+
+.back-to-ceiling .Icon {
+  fill: #9aaabf;
+  background: none;
+}
+</style>

+ 59 - 0
src/components/common/element-ui/DatePick/index.vue

@@ -0,0 +1,59 @@
+<template lang="pug">
+  el-date-picker(v-model="dateTime"  size="small" type="daterange" align="right", unlink-panels range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期"
+    :picker-options="dateOptions" :clearable="false" clearable @change="handleChange" value-format="timestamp")
+</template>
+
+<script>
+import { rangeDays } from "@/utils/date";
+
+export default {
+  name: "comp-datePick",
+  props: {
+    dateRange: {
+      type: Array,
+      default: () => [] // 工厂函数
+    }
+  },
+  data() {
+    return {
+      // 日期时间
+      dateTime: [],
+      // 日期组件选项
+      dateOptions: {
+        shortcuts: [
+          {
+            text: "最近 30 天",
+            onClick(picker) {
+              picker.$emit("pick", [rangeDays(-31), rangeDays(-1)]);
+            }
+          },
+          {
+            text: "最近 60 天",
+            onClick(picker) {
+              picker.$emit("pick", [rangeDays(-61), rangeDays(-1)]);
+            }
+          },
+          {
+            text: "最近 90 天",
+            onClick(picker) {
+              picker.$emit("pick", [rangeDays(-91), rangeDays(-1)]);
+            }
+          }
+        ]
+      }
+    };
+  },
+  computed: {},
+  watch: {},
+  filters: {},
+  created() {},
+  mounted() {},
+  methods: {
+    handleChange(date) {
+      this.$emit("update:dateRange", date);
+    }
+  }
+};
+</script>
+
+<style scoped lang="stylus"></style>

+ 125 - 0
src/components/common/element-ui/department/index.vue

@@ -0,0 +1,125 @@
+<template lang="pug">
+.comp
+  span.comp-department(@click.stop="changeDepartmentRelation") {{ title }}
+    i.el-icon-link(v-if="isRelation")
+    i.el-icon-paperclip(v-else)
+  el-cascader(
+    v-model="deptId",
+    size="small",
+    :options="deptList",
+    :props="deptConf",
+    clearable,
+    filterable,
+    collapse-tags,
+    :show-all-levels="false",
+    @change="departmentChanged",
+    ref="refDepartment"
+  )
+</template>
+
+<script>
+import { LS } from "@/utils/storage";
+
+export default {
+  name: "comp-cascader",
+  props: {
+    title: {
+      type: String,
+      default: "部门名称"
+    },
+    deptIds: {
+      type: Array,
+      default: () => [] // 工厂函数
+    }
+  },
+  data () {
+    // 部门级联是否关联选项: 级联更新配置不能动态刷新, 配置两个级联组件会配置对象也会串
+    const isRelation = LS.GET("DEPARTMENT_RELATION") != "false";
+
+    return {
+      // 部门级联数据源
+      deptList: [],
+      // 部门级联组件选项
+      deptConf: {
+        expandTrigger: "hover",
+        value: "departmentId",
+        label: "name",
+        emitPath: isRelation,
+        checkStrictly: !isRelation,
+        multiple: true
+      },
+      // 部门级联是否关联选项:
+      isRelation,
+      // 过滤部门
+      deptId: []
+    };
+  },
+  computed: {
+    // 当前选择的部门集合: 不缓存
+    current: {
+      get () {
+        return [];
+      },
+      set (val) {
+        this.$emit("update:deptIds", val);
+      }
+    }
+  },
+  watch: {},
+  filters: {},
+  created () {
+    this.syncDepartInfo();
+  },
+  mounted () { },
+  methods: {
+    // 部门级别数据查询
+    async syncDepartInfo () {
+      // this.deptList = await this.$store.dispatch("common/syncDepartInfo");
+    },
+    // 部门级联关系变更
+    changeDepartmentRelation () {
+      this.$el_confirm(
+        "此操作将会刷新当前页面, 是否继续?",
+        "变更部门勾选关联"
+      ).then(() => {
+        LS.SET("DEPARTMENT_RELATION", !this.isRelation);
+        window.location.reload();
+      });
+    },
+    // 过滤当前部门前的父节点: 记录当前部门, 选中后关闭
+    departmentChanged (values) {
+      if (this.isRelation) {
+        const deptIds = values.reduce((acc, cur) => {
+          acc.push(
+            ...cur.filter(dept => {
+              return (
+                dept >= this.$refs["refDepartment"].getCheckedNodes()[0].value
+              );
+            })
+          );
+          return acc;
+        }, []);
+        this.current = [...new Set(deptIds)];
+      } else {
+        this.current = this.deptId;
+      }
+    }
+  }
+};
+</script>
+
+<style lang="stylus">
+.comp
+  &-department
+    color: $-color-theme-second
+    margin-right: 10px
+    margin-left: 20px
+  .el-input
+    width: 200px
+  &-department
+    cursor: pointer
+    &:hover
+      text-decoration: underline
+    i
+      margin-left: 3px
+</style>

+ 86 - 0
src/components/dialog/element-ui/DetailTable/index.vue

@@ -0,0 +1,86 @@
+<template lang="pug">
+  div.comp
+    el-dialog(:title="title" :visible.sync="dialogTableVisible" top="18vh")
+      el-table(:data="tableData")
+        el-table-column(prop="label" label="字段" align="center")
+        el-table-column(prop="value" label="内容" align="center")
+</template>
+
+<script>
+export default {
+  name: "detail-table",
+  props: {
+    isShow: Boolean,
+    title: {
+      type: String,
+      default: "表格明细"
+    },
+    // 对象类型展开其明细
+    detail: Object,
+    header: Array
+  },
+  data() {
+    return {
+      tableData: []
+    };
+  },
+  computed: {
+    dialogTableVisible: {
+      set(val) {
+        this.$emit("update:isShow", val);
+      },
+      get() {
+        return this.isShow;
+      }
+    }
+  },
+  watch: {},
+  filters: {},
+  created() {
+    const detail = {
+      created_at: "2020-06-15 16:40:49",
+      finance_code: "028",
+      id: "d_1101",
+      item_code: "",
+      item_name: "口服液",
+      pk_measdoc: "018",
+      specification: "2",
+      updated_at: "2020-06-15 16:40:49"
+    };
+
+    const header = [
+      {
+        header: "编号",
+        field: "id"
+      },
+      {
+        header: "名称",
+        field: "item_name"
+      },
+      {
+        header: "-",
+        field: "item_name"
+      },
+      {
+        header: "创建时间",
+        field: "updated_at"
+      },
+      {
+        header: "更新时间",
+        field: "created_at"
+      }
+    ];
+
+    this.tableData = header.map(item => {
+      return {
+        label: item.header,
+        value: detail[item.field]
+      };
+    });
+  },
+  mounted() {},
+  methods: {}
+};
+</script>
+
+<style scoped lang="stylus"></style>

+ 106 - 0
src/components/form/element-ui/universal/index.vue

@@ -0,0 +1,106 @@
+<template lang="pug">
+  div.comp
+    el-drawer(:title="title" :visible.sync="isShow"  direction="rtl" :size="sizeDrawer" @open="resetForm")
+      el-form(size="small" :inline="true" :model="formData" :rules="formRules" ref="domForm" :label-width="wLabel")
+        //- 表单录入组件规则, 动态 [字段, 占位符, ....]
+        el-form-item(v-for="origin of originForm" :key="origin.prop" :label="origin.label" :prop="origin.prop")
+          slot(v-if="origin.slot" :name="origin.slot")
+          el-date-picker(v-else-if="origin.date" v-model="formData[origin.prop]" :disabled="origin.disabled" :type="origin.date.type" :value-format="origin.date.format" :placeholder="origin | formatPlaceholder('请选择')")
+          el-select(v-else-if="origin.options" v-model="formData[origin.prop]" :disabled="origin.disabled" :placeholder="origin | formatPlaceholder('请选择')")
+            el-option(v-for="option in origin.options" :key="option.value" :label="option.label" :value="option.value")
+          el-input(v-else v-model="formData[origin.prop]" :type="origin.type"  :disabled="origin.disabled" :placeholder="origin | formatPlaceholder('请输入')")
+            template(slot="append" v-if="origin.unit")
+              span {{ origin.unit }}
+        div.comp-form-oper
+          el-button(type="primary" size="small" @click="submitForm") {{ editData ? "修改" : "新增" }}
+          el-button(type="plain" size="small" @click="resetForm") 重置
+</template>
+
+<script>
+export default {
+  name: "drawer-form",
+  props: {
+    showForm: Boolean,
+    title: String,
+    // drawer 宽度
+    sizeDrawer: {
+      type: String,
+      default: "50%"
+    },
+    // form 录入组件标题
+    wLabel: {
+      type: String,
+      default: "140px"
+    },
+    // form 原始数据
+    originForm: Array,
+    // form 编辑对象
+    editData: {
+      type: Object,
+      default: undefined
+    }
+  },
+  data() {
+    return {
+      formData: {}, // 表单数据
+      formRules: {}, // 验证规则
+      resetData: {} // 重置对象
+    };
+  },
+  computed: {
+    isShow: {
+      set(val) {
+        this.$emit("update:showForm", val); // 只抛出不记录
+      },
+      get() {
+        return this.showForm;
+      }
+    }
+  },
+  watch: {},
+  filters: {
+    formatPlaceholder(origin, preDef) {
+      return origin.place ? origin.place : preDef + origin.label;
+    }
+  },
+  created() {
+    // 格式化数据源
+    const formRules = {};
+    const formReset = this.originForm.reduce((acc, cur) => {
+      formRules[cur.prop] = cur.rule;
+      acc[cur.prop] = cur.value;
+      return acc;
+    }, {});
+    this.resetData = formReset;
+    this.formRules = formRules;
+  },
+  mounted() {},
+  methods: {
+    submitForm() {
+      this.$refs["domForm"].validate(valid => {
+        if (!valid) return;
+        this.isShow = false;
+        this.$emit("handleSubmit", this.formData);
+      });
+    },
+    resetForm() {
+      if (this.$refs["domForm"]) {
+        this.$refs["domForm"].resetFields();
+      }
+      this.formData = Object.assign({}, this.resetData, this.editData);
+    }
+  }
+};
+</script>
+
+<style scoped lang="stylus">
+.comp
+  .el-input
+    width 200px
+  &-form-oper
+    margin-top 70px
+    text-align center
+    .el-button
+      margin 0 15px
+      width: 40%
+</style>

+ 20 - 0
src/components/table/element-ui/Universal/index.mixin.js

@@ -0,0 +1,20 @@
+import bus from "@/config/event-bus";
+import { HIDDEN_PAGINATION_HEIGHT } from "@/config/mcConf";
+export default {
+  data () {
+    return {
+      paginationHidden: false
+    };
+  },
+  created () {
+    // 高度低于临界点收起分页
+    bus.$vm.$on(bus.$sel.window_resize, () => {
+      this.paginationHidden =
+        document.documentElement.clientHeight <= HIDDEN_PAGINATION_HEIGHT;
+    });
+    // vue 支持 vue-hooks
+    this.$on("hook:destroyed", () => {
+      bus.$vm.$off(bus.$sel.window_resize);
+    });
+  }
+};

+ 114 - 0
src/components/table/element-ui/Universal/index.vue

@@ -0,0 +1,114 @@
+<template lang="pug">
+  div.comp
+    div.comp-table
+      el-table(:data="data" size="medium" header-cell-class-name="tableStyle" highlight-current-row @selection-change="handleSelectionChange" border @row-click="handelRowClick")
+        el-table-column(v-if="selection" type="selection" align="center" width="80")
+        el-table-column(v-if="indexes" type="index" :label="indexes" align="center" width="80")
+        //- 动态插入作用域插槽: 具名插槽 + 作用域插槽
+        el-table-column(v-if="prop.slot" v-for="prop of props" :key="prop.field" :label="prop.header" :align="prop.align" :min-width='prop.minw' :fixed="prop.fixed")
+          template(slot-scope="scope")
+            slot(:scope="{row: scope.row, col: prop}" :name="prop.slot")
+        //- 多级表头: 动态插入作用域插槽
+        el-table-column(v-else-if="prop.merge" :label="prop.header" :align="prop.align" :fixed="prop.fixed")
+          el-table-column(v-if="split.slot" v-for="split of prop.merge" :key="split.field" :label="split.header" :align="split.align" :min-width='split.minw')
+            template(slot-scope="scope")
+              slot(:scope="{row: scope.row, col: split}" :name="split.slot")
+          el-table-column(v-else :key="split.field" :prop="split.field" :label="split.header" :align="split.align" :min-width='split.minw')
+        //- 正常表头: 无需自定义
+        el-table-column(v-else :key="prop.field" :prop="prop.field" :label="prop.header" :align="prop.align" :min-width='prop.minw' :fixed="prop.fixed")
+    div.comp-pagination(v-show="!hiddenPaging")
+      el-pagination(:total="paging.total" :layout="pagination.layout" :current-page="pagination.currentPage" :page-size="pagination.pageSize" popper-class="paginationStyle" @current-change="currentChanged" @size-change="sizeChanged")
+</template>
+
+<script>
+import mixin from "./index.mixin";
+
+export default {
+  name: "comp-table-report",
+  mixins: [mixin],
+  props: {
+    props: Array, // 表头
+    data: Array, // 数据
+    paging: {
+      type: Object,
+      default: () => {
+        return {
+          page: 1,
+          size: 10,
+          total: 100
+        };
+      }
+    }, // 分页
+    selection: Array, // 多选集合
+    indexes: String, // 索引名称
+    hiddenPaging: Boolean // 隐藏
+  },
+  data() {
+    return {
+      // 分页默认值
+      pagination: {
+        layout: "total, ->, prev, pager, next, sizes, jumper" // -> : 布局间距
+      }
+    };
+  },
+  methods: {
+    // 样式
+    headerStyle() {
+      return "tableStyle";
+    },
+    paginationStyle() {
+      return "paginationStyle";
+    },
+    // 分页
+    currentChanged(index) {
+      this.pagination.currentPage = index;
+      this._paginationChanged();
+    },
+    sizeChanged(size) {
+      this.pagination.pageSize = size;
+      this._paginationChanged();
+    },
+    // 向父组件提分页交改变
+    _paginationChanged() {
+      this.$emit("paginationChanged", {
+        page: this.pagination.currentPage,
+        size: this.pagination.pageSize
+      });
+    },
+    // 多选数据抛出同步 - `.sync` 处理对象更新
+    handleSelectionChange(val) {
+      this.$emit("update:selection", val);
+    },
+    // 行点击事件
+    handelRowClick(row, col) {
+      console.log(row, col);
+      this.$emit("rowClick", {
+        page: this.pagination.currentPage,
+        size: this.pagination.pageSize
+      });
+    }
+  },
+  filters: {},
+  computed: {},
+  watch: {},
+  created() {
+    // 分页初始化
+    this.pagination.currentPage = this.paging.page;
+    this.pagination.pageSize = this.paging.size;
+  },
+  mounted() {}
+};
+</script>
+
+<style scoped lang="stylus">
+.comp
+  background-color: #fff;
+  &-pagination
+    padding: 30px 0 10px 0
+>>> .tableStyle
+  background-color: #F5F7FA !important
+  color: #333;
+  font-weight: bold;
+.paginationStyle
+  text-align: center
+</style>

+ 91 - 0
src/components/vendor/Siriwave/index.vue

@@ -0,0 +1,91 @@
+<template>
+  <div class="comp-canvas" ref="domCanvas" />
+</template>
+<script>
+import SiriWave from "@/vendor/comp/siriwave.umd";
+
+export default {
+  name: "comp-siriwave",
+  props: {
+    voiceSize: {
+      default: 0
+    }
+  },
+  data() {
+    return {
+      wave: ""
+    };
+  },
+  methods: {
+    init() {
+      this.wave = new SiriWave({
+        container: this.$refs.domCanvas,
+        amplitude: 0.7,
+        speed: 0.1,
+        // width: 375,
+        // height: 80,
+        color: "22, 334, 1",
+        // speedInterpolationSpeed: 0,
+        frequency: 2, //波的个数
+        autostart: true,
+        cover: true,
+        ratio: 1,
+        style: "ios",
+        curveDefinition: [
+          {
+            attenuation: -0.6,
+            lineWidth: 3,
+            opacity: 1
+          },
+          {
+            attenuation: 0.4,
+            lineWidth: 3,
+            opacity: 1
+          },
+          {
+            attenuation: 0.8,
+            lineWidth: 3,
+            opacity: 1
+          }
+        ]
+      });
+    }
+  },
+  watch: {
+    voiceSize(newValue, oldValue) {
+      this.wave.setAmplitude(this.voiceSize);
+    },
+    getAverageVolume(data) {
+      var values = 0;
+      var length = data.length;
+      for (var i = 0; i < length; i++) {
+        values += data[i];
+      }
+      return values / length;
+    }
+  },
+  mounted() {
+    this.init();
+    this.ctx = this.wave.ctx;
+    var gradient = this.ctx.createLinearGradient(0, 0, 710, 0);
+    gradient.addColorStop(0, "rgba(255,249,50,1)");
+    gradient.addColorStop(1, "rgba(76,228,253,1)");
+    gradient.width = "900px";
+    this.ctx.fillRect(-200, 20, 1500, 1000);
+    this.ctx.strokeStyle = gradient;
+    this.ctx.stroke();
+    // var ctx = new (window.AudioContext || window.webkitAudioContext)();
+    // var analyser = ctx.createAnalyser();
+    // analyser.connect(ctx.destination);
+    // analyser.fftSize = 2048;
+    // var bufferLength = analyser.frequencyBinCount;
+  }
+};
+</script>
+<style lang="stylus" scoped>
+.comp-canvas
+  margin 50px 0
+  width 100%
+  height 80px
+  z-index 99
+</style>

+ 16 - 0
src/config/event-bus.js

@@ -0,0 +1,16 @@
+import Vue from "vue";
+
+const event = new Vue();
+
+// 使用 bus/storage 将 key 声明为常量,便于追踪和管理
+const selector = {
+  window_resize: "WINDOW_RESIZE",
+  window_onload: "WINDOW_ONLOAD", // onload 会抛出 resize 以响应式更新
+  login_timeout: "LOGIN_TIME_OUT" // 抛出登录超时
+};
+
+// import bus from "@/config/event-bus"
+export default {
+  $vm: event,
+  $sel: selector
+};

+ 27 - 0
src/config/expert/filters.js

@@ -0,0 +1,27 @@
+// 全局载入
+
+/** @interface 时间格式化和解析: 默认格式 YYYY-MM-DD HH:mm:ss (facha)*/
+export {
+  fmtDate,
+  fmtTime,
+  fmtDateTime,
+  parseDate,
+  parseTime,
+  parseDateTime
+} from "@/utils/date";
+
+/** @interface 格式化阿里云OSS地址 */
+export function fmtOSSUrl (path) {
+  return `${process.env.VUE_APP_OSS_URL}/${path}`;
+}
+
+/** @interface 格式化审批格式 */
+export function fmtApproveStatus (val) {
+  return {
+    "已通过": "success",
+    "已拒绝": "info",
+    "已撤回": "info",
+    "已撤销": "danger",
+    "审批中": "warning",
+  }[val];
+}

+ 34 - 0
src/config/expert/mixins.js

@@ -0,0 +1,34 @@
+import { throttle } from "@/utils/optimize";
+import { isNative } from "@/utils/device";
+
+// 全局导入
+export default {
+  data() {
+    return {
+      fnBlur: null,
+      listener: isNative() ? "touchmove" : "scroll"
+    };
+  },
+  methods: {
+    mixBlur() {
+      // 获取页面所有输入框元素, 使用 document.activeElement 更优方案
+      const elem = document.activeElement;
+      if (!elem.placeholder) return;
+      elem.blur();
+    },
+    // 移动端能聚焦, 但只有通过绑定在事件上的函数触发,才能唤起键盘
+    mixFocus() {
+      document.getElementsByTagName("input")[0].focus();
+    }
+  },
+  // 页面滚动隐藏键盘
+  created() {
+    this.fnBlur = throttle(() => this.mixBlur(), 750);
+  },
+  mounted() {
+    window.addEventListener(this.listener, this.fnBlur);
+  },
+  destroyed() {
+    window.removeEventListener(this.listener, this.fnBlur);
+  }
+};

+ 15 - 0
src/config/loader/ant-design.js

@@ -0,0 +1,15 @@
+import Vue from "vue";
+import { Button, Tabs, Icon, Slider } from "ant-design-vue";
+
+const antDesign = {
+  install: function (Vue) {
+    // 按需引入组件库
+    Vue.use(Button);
+    Vue.use(Tabs);
+    Vue.use(Icon);
+    Vue.use(Slider);
+  }
+};
+
+/* 使用 Vue.use() 方法默认会调用 install 方法 */
+Vue.use(antDesign);

+ 184 - 0
src/config/loader/element-ui.js

@@ -0,0 +1,184 @@
+import Vue from "vue";
+
+// 按需引入不需要全局引入 element-css: 配置成功引入会报错
+
+import {
+  Pagination,
+  Dialog,
+  Autocomplete,
+  Dropdown,
+  DropdownMenu,
+  DropdownItem,
+  Menu,
+  Submenu,
+  MenuItem,
+  MenuItemGroup,
+  Input,
+  InputNumber,
+  Radio,
+  RadioGroup,
+  RadioButton,
+  Checkbox,
+  CheckboxButton,
+  CheckboxGroup,
+  Switch,
+  Select,
+  Option,
+  OptionGroup,
+  Button,
+  ButtonGroup,
+  Table,
+  TableColumn,
+  DatePicker,
+  TimeSelect,
+  TimePicker,
+  Popover,
+  Tooltip,
+  Breadcrumb,
+  BreadcrumbItem,
+  Form,
+  FormItem,
+  Tabs,
+  TabPane,
+  Tag,
+  Tree,
+  Alert,
+  Slider,
+  Icon,
+  Row,
+  Col,
+  Upload,
+  Progress,
+  Spinner,
+  Badge,
+  Card,
+  Rate,
+  Steps,
+  Step,
+  Carousel,
+  CarouselItem,
+  Collapse,
+  CollapseItem,
+  Cascader,
+  ColorPicker,
+  Transfer,
+  Container,
+  Header,
+  Aside,
+  Main,
+  Footer,
+  Timeline,
+  TimelineItem,
+  Link,
+  Divider,
+  Image,
+  Calendar,
+  Backtop,
+  PageHeader,
+  CascaderPanel,
+  Drawer,
+  Avatar
+} from "element-ui";
+
+const elementui = {
+  install: function (Vue) {
+    // 按需引入组件库
+    Vue.use(Pagination);
+    Vue.use(Dialog);
+    Vue.use(Autocomplete);
+    Vue.use(Dropdown);
+    Vue.use(DropdownMenu);
+    Vue.use(DropdownItem);
+    Vue.use(Menu);
+    Vue.use(Submenu);
+    Vue.use(MenuItem);
+    Vue.use(MenuItemGroup);
+    Vue.use(Input);
+    Vue.use(InputNumber);
+    Vue.use(Radio);
+    Vue.use(RadioGroup);
+    Vue.use(RadioButton);
+    Vue.use(Checkbox);
+    Vue.use(CheckboxButton);
+    Vue.use(CheckboxGroup);
+    Vue.use(Switch);
+    Vue.use(Select);
+    Vue.use(Option);
+    Vue.use(OptionGroup);
+    Vue.use(Button);
+    Vue.use(ButtonGroup);
+    Vue.use(Table);
+    Vue.use(TableColumn);
+    Vue.use(DatePicker);
+    Vue.use(TimeSelect);
+    Vue.use(TimePicker);
+    Vue.use(Popover);
+    Vue.use(Tooltip);
+    Vue.use(Breadcrumb);
+    Vue.use(BreadcrumbItem);
+    Vue.use(Form);
+    Vue.use(FormItem);
+    Vue.use(Tabs);
+    Vue.use(TabPane);
+    Vue.use(Tag);
+    Vue.use(Tree);
+    Vue.use(Alert);
+    Vue.use(Slider);
+    Vue.use(Icon);
+    Vue.use(Row);
+    Vue.use(Col);
+    Vue.use(Upload);
+    Vue.use(Progress);
+    Vue.use(Spinner);
+    Vue.use(Badge);
+    Vue.use(Card);
+    Vue.use(Rate);
+    Vue.use(Steps);
+    Vue.use(Step);
+    Vue.use(Carousel);
+    Vue.use(CarouselItem);
+    Vue.use(Collapse);
+    Vue.use(CollapseItem);
+    Vue.use(Cascader);
+    Vue.use(ColorPicker);
+    Vue.use(Transfer);
+    Vue.use(Container);
+    Vue.use(Header);
+    Vue.use(Aside);
+    Vue.use(Main);
+    Vue.use(Footer);
+    Vue.use(Timeline);
+    Vue.use(TimelineItem);
+    Vue.use(Link);
+    Vue.use(Divider);
+    Vue.use(Image);
+    Vue.use(Calendar);
+    Vue.use(Backtop);
+    Vue.use(PageHeader);
+    Vue.use(CascaderPanel);
+    Vue.use(Drawer);
+    Vue.use(Avatar);
+  }
+};
+
+
+window.ResizeObserver = undefined;
+
+/* 使用 Vue.use() 方法默认会调用 install 方法 */
+Vue.use(elementui);
+
+// 全局插件和自定义指令
+
+import { Loading, MessageBox, Notification, Message } from "element-ui";
+
+// element-ui loading指令
+Vue.use(Loading.directive);
+
+// element-ui 服务模式
+Vue.prototype.$el_loading = Loading.service;
+Vue.prototype.$el_msgboxEl = MessageBox;
+Vue.prototype.$el_alert = MessageBox.alert;
+Vue.prototype.$el_confirm = MessageBox.confirm;
+Vue.prototype.$el_prompt = MessageBox.prompt;
+Vue.prototype.$el_notify = Notification;
+Vue.prototype.$el_message = Message;

+ 76 - 0
src/config/loader/flex-rem.js

@@ -0,0 +1,76 @@
+"use strict";
+
+/**
+ * @export 设置高分屏方案函数
+ * @param {Boolean} [normal = false] - 默认开启页面压缩以使页面高清;
+ * @param {Number} [baseFontSize = 100] - 基础fontSize, 默认100px;
+ * @param {Number} [fontscale = 1] - 有的业务希望能放大一定比例的字体;
+ */
+export function setFlexRem (normal, baseFontSize, fontscale) {
+  const win = window;
+  const _baseFontSize = baseFontSize || process.env.VUE_APP_REM_UNIT;
+  const _fontscale = fontscale || 1;
+
+  const doc = win.document;
+  const ua = navigator.userAgent;
+  const matches = ua.match(/Android[\S\s]+AppleWebkit\/(\d{3})/i);
+  const UCversion = ua.match(/U3\/((\d+|\.){5,})/i);
+  const isUCHd =
+    UCversion && parseInt(UCversion[1].split(".").join(""), 10) >= 80;
+  const isIos = navigator.appVersion.match(/(iphone|ipad|ipod)/gi);
+  let dpr = win.devicePixelRatio || 1;
+  if (!isIos && !(matches && matches[1] > 534) && !isUCHd) {
+    // 如果非iOS, 非Android4.3以上, 非UC内核, 就不执行高分屏, dpr设为1;
+    dpr = 1;
+  }
+  const scale = normal ? 1 : 1 / dpr;
+
+  let metaEl = doc.querySelector('meta[name="viewport"]');
+  if (!metaEl) {
+    metaEl = doc.createElement("meta");
+    metaEl.setAttribute("name", "viewport");
+    doc.head.appendChild(metaEl);
+  }
+  metaEl.setAttribute(
+    "content",
+    `width=device-width,user-scalable=no,initial-scale=${scale},maximum-scale=${scale},minimum-scale=${scale}`
+  );
+  doc.documentElement.style.fontSize = normal
+    ? "50px"
+    : `${_baseFontSize * dpr * _fontscale}px`;
+  // 通过resize还原document.body.clientWidth, 以兼容@media失效
+  store.commit("config/SET_REAL_WID", document.body.clientWidth * scale);
+  console.info(
+    "高分屏方案: dpr =",
+    scale,
+    ", 1rem =",
+    doc.documentElement.style.fontSize
+  );
+}
+
+// setFlexRem 防抖和节流
+import { debounce, throttle } from "@/utils/optimize";
+import { isNative } from "@/utils/device";
+import bus from "@/config/event-bus";
+import store from "@/store";
+
+// 节流: 初始化和 onresize 加载时
+const initLayout = throttle(isEmit => {
+  setFlexRem();
+  store.commit("config/SET_IS_NATIVE", isNative());
+  if (isEmit) bus.$vm.$emit(bus.$sel.window_resize); // 响应式更新
+}, 400);
+
+// 初始化
+initLayout();
+// 兼容 onresize 改变窗口全局调用: 防抖
+window.onresize = debounce(() => initLayout(true));
+// 响应式需要调用resize事件以响应加载时更新
+window.onload = () => {
+  bus.$vm.$emit(bus.$sel.window_onload);
+  bus.$vm.$emit(bus.$sel.window_resize);
+};
+
+// import bus from "@/config/event-bus";
+// bus.$vm.$on(bus.$sel.window_resize, () => { }); // 响应resize
+// bus.$vm.$on(bus.$sel.window_onload, () => { }); // 响应onload

+ 95 - 0
src/config/loader/quasar.js

@@ -0,0 +1,95 @@
+import Vue from "vue";
+
+import langZH from "quasar/lang/zh-hans";
+import "quasar/dist/quasar.ie.polyfills";
+import "@quasar/extras/material-icons/material-icons.css";
+import {
+  Quasar,
+  QBtn,
+  Ripple,
+  TouchPan,
+  TouchSwipe,
+  Loading,
+  Notify,
+  Dialog,
+  QTabs,
+  QTab,
+  QRouteTab,
+  QBadge,
+  QToolbar,
+  QBanner,
+  QPageScroller,
+  QPageContainer,
+  QLayout,
+  QToolbarTitle,
+  QHeader,
+  QFooter,
+  QAvatar,
+  QPage,
+  QImg,
+  QCard,
+  QCardSection,
+  QExpansionItem,
+  QSeparator,
+  QPageSticky,
+  QInput,
+  QScrollArea,
+  QList,
+  QItem,
+  QItemSection,
+  QIcon,
+  QItemLabel,
+  QCheckbox,
+  QBtnToggle,
+  QTable,
+  QToggle,
+  QChip,
+  QInfiniteScroll,
+  QSpinnerDots,
+  QSlider
+} from "quasar";
+
+Vue.use(Quasar, {
+  config: {},
+  components: {
+    QBtn,
+    QTabs,
+    QTab,
+    QRouteTab,
+    QBadge,
+    QToolbar,
+    QBanner,
+    QPageScroller,
+    QPageContainer,
+    QLayout,
+    QToolbarTitle,
+    QHeader,
+    QFooter,
+    QAvatar,
+    QPage,
+    QImg,
+    QCard,
+    QCardSection,
+    QExpansionItem,
+    QSeparator,
+    QPageSticky,
+    QInput,
+    QScrollArea,
+    QList,
+    QItem,
+    QItemSection,
+    QIcon,
+    QItemLabel,
+    QCheckbox,
+    QBtnToggle,
+    QTable,
+    QToggle,
+    QChip,
+    QInfiniteScroll,
+    QSpinnerDots,
+    QSlider
+  },
+  directives: { Ripple, TouchPan, TouchSwipe },
+  plugins: { Loading, Notify, Dialog },
+  lang: langZH
+});

+ 5 - 0
src/config/loader/vconsole.js

@@ -0,0 +1,5 @@
+import Vue from "vue";
+
+const Vconsole = require("vconsole");
+const vConsole = new Vconsole();
+Vue.use(vConsole);

+ 4 - 0
src/config/mcConf.js

@@ -0,0 +1,4 @@
+// setting
+
+/** 高度低于临界点收起分页 */
+export const HIDDEN_PAGINATION_HEIGHT = 500;

+ 11 - 0
src/config/skeleton/conf.js

@@ -0,0 +1,11 @@
+import Vue from "vue";
+
+import native from "./native";
+import web from "./web";
+
+// 骨架屏是打包注入, 此时不能访问 window: 移动端优先
+const compile = process.env.VUE_APP_SKELETON === "native";
+export default new Vue({
+  // 编译器不识别组件对象, 因此使用环境变量
+  render: h => h(compile ? native : web)
+});

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 28 - 0
src/config/skeleton/native.vue


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 33 - 0
src/config/skeleton/web.vue


+ 19 - 0
src/directive/loader.js

@@ -0,0 +1,19 @@
+import Vue from "vue";
+
+// v-debaunce: 提交以后禁用按钮一段时间,防止重复提交
+Vue.directive("debounce", {
+  inserted(el) {
+    el.v_click = () => {
+      el.classList.add("is-disabled");
+      el.disabled = true;
+      setTimeout(() => {
+        el.disabled = false;
+        el.classList.remove("is-disabled");
+      }, 750);
+    };
+    el.addEventListener("click", el.v_click);
+  },
+  unbind(el) {
+    el.removeEventListener("click", el.v_click);
+  }
+});

+ 14 - 0
src/directive/waves/index.js

@@ -0,0 +1,14 @@
+import waves from "./waves";
+
+const install = function(Vue) {
+  Vue.directive("waves", waves);
+};
+
+if (window.Vue) {
+  window.waves = waves;
+  Vue.use(install); // eslint-disable-line
+}
+
+// import waves from "@/directive/waves"; // 水波纹指令
+waves.install = install;
+export default waves;

+ 27 - 0
src/directive/waves/waves.css

@@ -0,0 +1,27 @@
+.waves-ripple {
+  position: absolute;
+  border-radius: 100%;
+  background-color: rgba(0, 0, 0, 0.15);
+  background-clip: padding-box;
+  pointer-events: none;
+  -webkit-user-select: none;
+  -moz-user-select: none;
+  -ms-user-select: none;
+  user-select: none;
+  -webkit-transform: scale(0);
+  -ms-transform: scale(0);
+  transform: scale(0);
+  opacity: 1;
+}
+
+.waves-ripple.z-active {
+  opacity: 0;
+  -webkit-transform: scale(2);
+  -ms-transform: scale(2);
+  transform: scale(2);
+  -webkit-transition: opacity 1.2s ease-out, -webkit-transform 0.6s ease-out;
+  transition: opacity 1.2s ease-out, -webkit-transform 0.6s ease-out;
+  transition: opacity 1.2s ease-out, transform 0.6s ease-out;
+  transition: opacity 1.2s ease-out, transform 0.6s ease-out,
+    -webkit-transform 0.6s ease-out;
+}

+ 72 - 0
src/directive/waves/waves.js

@@ -0,0 +1,72 @@
+import './waves.css'
+
+const context = '@@wavesContext'
+
+function handleClick(el, binding) {
+  function handle(e) {
+    const customOpts = Object.assign({}, binding.value)
+    const opts = Object.assign({
+      ele: el, // 波纹作用元素
+      type: 'hit', // hit 点击位置扩散 center中心点扩展
+      color: 'rgba(0, 0, 0, 0.15)' // 波纹颜色
+    },
+    customOpts
+    )
+    const target = opts.ele
+    if (target) {
+      target.style.position = 'relative'
+      target.style.overflow = 'hidden'
+      const rect = target.getBoundingClientRect()
+      let ripple = target.querySelector('.waves-ripple')
+      if (!ripple) {
+        ripple = document.createElement('span')
+        ripple.className = 'waves-ripple'
+        ripple.style.height = ripple.style.width = Math.max(rect.width, rect.height) + 'px'
+        target.appendChild(ripple)
+      } else {
+        ripple.className = 'waves-ripple'
+      }
+      switch (opts.type) {
+        case 'center':
+          ripple.style.top = rect.height / 2 - ripple.offsetHeight / 2 + 'px'
+          ripple.style.left = rect.width / 2 - ripple.offsetWidth / 2 + 'px'
+          break
+        default:
+          ripple.style.top =
+            (e.pageY - rect.top - ripple.offsetHeight / 2 - document.documentElement.scrollTop ||
+              document.body.scrollTop) + 'px'
+          ripple.style.left =
+            (e.pageX - rect.left - ripple.offsetWidth / 2 - document.documentElement.scrollLeft ||
+              document.body.scrollLeft) + 'px'
+      }
+      ripple.style.backgroundColor = opts.color
+      ripple.className = 'waves-ripple z-active'
+      return false
+    }
+  }
+
+  if (!el[context]) {
+    el[context] = {
+      removeHandle: handle
+    }
+  } else {
+    el[context].removeHandle = handle
+  }
+
+  return handle
+}
+
+export default {
+  bind(el, binding) {
+    el.addEventListener('click', handleClick(el, binding), false)
+  },
+  update(el, binding) {
+    el.removeEventListener('click', el[context].removeHandle, false)
+    el.addEventListener('click', handleClick(el, binding), false)
+  },
+  unbind(el) {
+    el.removeEventListener('click', el[context].removeHandle, false)
+    el[context] = null
+    delete el[context]
+  }
+}

+ 70 - 0
src/main.js

@@ -0,0 +1,70 @@
+import Vue from "vue";
+import App from "./App.vue";
+import router from "./router";
+import store from "./store";
+
+// 高分屏方案和 Rem 适配 (移动端配置且兼容了@media问题)
+import "@/config/loader/flex-rem";
+
+/* IFDEBUG // 开发环境下远程调试vconsole
+import "@/config/loader/vconsole";
+FIDEBUG */
+
+
+// 按需引入组件库 & 主题和样式重置
+
+/* IFTRUE_ELEMENT */
+import "@/styles/loader/element.variables"; // 主题和样式重置
+import "@/config/loader/element-ui";
+/* FITRUE_ELEMENT */
+
+/* IFTRUE_ANT_DESIGN */
+import "@/config/loader/ant-design"; // ant design 使用less loader在vue.config.js内配置条件编译, 无需再此处重新引入
+/* FITRUE_ANT_DESIGN */
+
+/* IFTRUE_QUASAR */
+import "@/styles/quasar.variables"; // 备注: 不能修改文件名称和路径: loader对路径做了绑定和优化, 需要引入才会生效 (vue add quasar 自动添加)
+import "@/config/loader/quasar";
+/* FITRUE_QUASAR */
+
+Vue.config.productionTip = false;
+
+import "@/directive/loader"; // 全局自定义指令
+import mixin from "@/config/expert/mixins"; // 全局混入
+Vue.mixin(mixin);
+import * as filters from "@/config/expert/filters"; // 注册全局自定义过滤器
+Object.keys(filters).forEach(key => {
+  Vue.filter(key, filters[key]);
+});
+
+// 路由钩子
+import progress from "@/service/progress";
+router.beforeEach((to, _, next) => {
+  // 需要鉴权页面
+  if (to.meta.authToken && store.state.user.token) {
+    progress.invalidToken("登录已失效");
+    return;
+  }
+  next();
+});
+router.afterEach(to => {
+  // 路由切换后更新标题
+  document.title = `${to.meta.title} | 丰凯利`;
+});
+
+const app = new Vue({
+  router,
+  store,
+  render: h => h(App)
+});
+
+// 骨架屏: 全局记录挂在方法
+window.mountApp = () => {
+  app.$mount("#app");
+  window.mountApp = null;
+};
+
+// 骨架屏:当js晚于css加载完成 || 取消骨架屏,那直接执行渲染
+if (window.STYLE_READY || !process.env.VUE_APP_SKELETON) {
+  window.mountApp?.();
+}

BIN
src/modules/dinkle/assets/icon/+pk-select.png


BIN
src/modules/dinkle/assets/icon/+pk.png


BIN
src/modules/dinkle/assets/icon/download.png


BIN
src/modules/dinkle/assets/icon/link.png


BIN
src/modules/dinkle/assets/icon/look.png


BIN
src/modules/dinkle/assets/icon/no-data.png


BIN
src/modules/dinkle/assets/icon/order.png


BIN
src/modules/dinkle/assets/icon/pk-relation.png


BIN
src/modules/dinkle/assets/icon/player.png


BIN
src/modules/dinkle/assets/icon/reset.png


BIN
src/modules/dinkle/assets/icon/rotate.png


BIN
src/modules/dinkle/assets/icon/search-to.png


BIN
src/modules/dinkle/assets/icon/search.png


BIN
src/modules/dinkle/assets/icon/select.png


BIN
src/modules/dinkle/assets/icon/selected.png


BIN
src/modules/dinkle/assets/image/no-data-pk.png


BIN
src/modules/dinkle/assets/image/no-data-search.png


BIN
src/modules/dinkle/assets/logo/logo-icon.png


BIN
src/modules/dinkle/assets/logo/logo.png


+ 102 - 0
src/modules/dinkle/components/productCell/index.vue

@@ -0,0 +1,102 @@
+<template lang="pug">
+  div.comp
+    q-list.result(v-if="hasList" :class="{'result-top': !isPK }")
+      q-item(clickable v-for="item of list" :key="item.id")
+        q-item-section(v-if="isPK" side @click="clickSelected(item)")
+          q-avatar(rounded size="60px")
+            img(v-if="item.isSelected" src="~dinkle/assets/icon/selected.png")
+            img(v-else src="~dinkle/assets/icon/select.png")
+        q-item-section(thumbnail side @click="clickItem(item)")
+          img(:src="item.image | fmtOSSUrl")
+        q-item-section(@click="clickItem(item)")
+          p.text-h5.text-weight-bold {{ item.label }}
+          div.cell-des
+            p {{ item.factory }}
+            p {{ item.code }}
+          p.cell-relation 关联竞品:
+            q-chip(clickable size="lg" v-for="sub of item.relation" :key="sub.id" @click="clickItem(sub)") {{ sub.factory }}
+        q-item-section(avatar v-if="!isPK" side @click="addPKList(item)")
+          q-avatar(square)
+            img.result-pk(v-if="item.existPK" src="~dinkle/assets/icon/+pk-select.png")
+            img.result-pk(v-else src="~dinkle/assets/icon/+pk.png")
+    div.no-data(v-else)
+      img(v-if="isPK" src="~dinkle/assets/image/no-data-pk.png")
+      img(v-else src="~dinkle/assets/image/no-data-search.png")
+</template>
+
+<script>
+export default {
+  name: "comp-product-cell",
+  props: {
+    list: Array,
+    isPK: Boolean,
+    isForbidden: Boolean
+  },
+  data() {
+    return {};
+  },
+  computed: {
+    hasList() {
+      return this.isForbidden || this.list.length;
+    }
+  },
+  watch: {},
+  filters: {},
+  created() {},
+  mounted() {},
+  methods: {
+    // 添加到pk列表
+    addPKList(item) {
+      if (item.existPK) return;
+      item.existPK = true;
+      this.$store.commit("dinkle/ADD_PK_LIST", item);
+    },
+    // 跳转到详情
+    clickItem(item) {
+      console.log('this.$route.query.search = "";', this.$route.query.search);
+      this.$route.query.search = "";
+      this.$router.push({
+        name: "detail",
+        query: {
+          id: item.id
+        }
+      });
+    },
+    // pk数据选择
+    clickSelected(item) {
+      item.isSelected = !item.isSelected;
+      this.$emit("selectChanged");
+    }
+  }
+};
+</script>
+
+<style scoped lang="stylus">
+.result
+  padding 0 15px
+  background-color #fff
+  &-top
+    margin-top 60px
+  &-pk
+    margin-top 5px
+.no-data
+  text-align center
+  padding 40px 10px
+  background-color #fff
+  margin-top 60px
+  img
+    width 100%
+.cell-des
+  margin 5px 0
+  p
+    font-size 14px
+    font-weight 400
+.cell-relation
+  font-weight 500
+  font-size 12px
+>>> .q-item__section--thumbnail img
+  width 70px
+  height auto
+>>> .q-avatar__content, .q-avatar img:not(.q-icon)
+  height auto
+</style>

+ 3 - 0
src/modules/dinkle/remark.md

@@ -0,0 +1,3 @@
+## dinkle
+
+町洋(dinkle)

+ 87 - 0
src/modules/dinkle/router/index.js

@@ -0,0 +1,87 @@
+export default [
+  {
+    path: "/tabbar",
+    name: "tabbar",
+    meta: {
+      title: ""
+    },
+    component: () => import("@/modules/dinkle/views/layout"),
+    redirect: "home",
+    children: [
+      {
+        path: "/home",
+        name: "home",
+        meta: {
+          title: "首页",
+          navHide: true
+        },
+        component: () => import("@/modules/dinkle/views/home")
+      },
+      {
+        path: "/contrast",
+        name: "contrast",
+        meta: {
+          title: "对比"
+        },
+        component: () => import("@/modules/dinkle/views/contrast")
+      },
+      {
+        path: "/website",
+        name: "website",
+        meta: {
+          title: "官网",
+          navHide: true
+        },
+        component: () => import("@/modules/dinkle/views/website")
+      },
+      {
+        path: "/content",
+        name: "content",
+        meta: {
+          title: "推广包"
+        },
+        component: () => import("@/modules/dinkle/views/content")
+      },
+      {
+        path: "/search",
+        name: "search",
+        meta: {
+          title: "搜索",
+          tabHide: true,
+          canBack: true
+        },
+        component: () => import("@/modules/dinkle/views/search")
+      },
+      {
+        path: "/detail",
+        name: "detail",
+        meta: {
+          title: "产品详情",
+          tabHide: true,
+          canBack: true
+        },
+        component: () => import("@/modules/dinkle/views/detail")
+      },
+      {
+        path: "/relation",
+        name: "relation",
+        meta: {
+          title: "产品详情",
+          tabHide: true,
+          canBack: true
+        },
+        component: () => import("@/modules/dinkle/views/detail/relation")
+      },
+      {
+        path: "/pkList",
+        name: "pkList",
+        meta: {
+          title: "PK列表",
+          tabHide: true,
+          canBack: true
+        },
+        component: () => import("@/modules/dinkle/views/pkList")
+      }
+    ]
+  }
+];

+ 73 - 0
src/modules/dinkle/service/api.js

@@ -0,0 +1,73 @@
+import request from "@/service/request";
+import { devProxy } from "@/service/network";
+import { KEY_NO_LOADING } from "@/service/request";
+
+//////////////////////////// 服务地址 ////////////////////////////
+
+// target: http://106.14.189.173:8080
+
+// 致拓宜搭服务地址
+function _requestUrl (url) {
+  return `http://106.14.189.173:8080/api/dd${url}`;
+}
+
+/////////////////////  业务逻辑:thunk包装调用  /////////////////////
+
+// 查询首页轮播图
+export function getBannerResource (params) {
+  return request.getParams(
+    _requestUrl("/sys/maintenance/getBannerResource"),
+    params
+  );
+}
+
+// 查询首页配置模块
+export function getHomepageResource (params) {
+  return request.getParams(
+    _requestUrl("/sys/maintenance/getHomepageResource"),
+    params
+  );
+}
+
+// 获取竞品速链页维护数据
+export function getOtherProductResource (params) {
+  return request.getParams(
+    _requestUrl("/sys/maintenance/getOtherProductResource"),
+    params
+  );
+}
+
+// PK查询多产品详情: pk详情页
+export function queryProductDetailList (params) {
+  return request.getParams(
+    _requestUrl("/product/queryProductDetailList"),
+    params
+  );
+}
+
+// 查询产品详情
+export function queryProductDetail (params) {
+  return request.getParams(_requestUrl("/product/queryProductDetail"), params);
+}
+
+// 搜索产品列表
+export function searchProduct (params) {
+  return request.getParams(_requestUrl("/product/searchProduct"), params, {
+    [KEY_NO_LOADING]: true
+  });
+}
+
+// 获取所有文宣模块
+export function getAllWx (params) {
+  return request.getParams(_requestUrl("/sys/wx/getAllWx"), params);
+}
+
+// 根据模块分页查询文章
+export function pageDetailByModuleId (params) {
+  return request.getParams(_requestUrl("/sys/wx/pageDetailByModuleId"), params);
+}
+
+// 获取所有参数数据
+export function listAll (params) {
+  return request.getParams(_requestUrl("/sys/product/param/listAll"), params);
+}

+ 80 - 0
src/modules/dinkle/store/dinkle.js

@@ -0,0 +1,80 @@
+import { getAllWx, listAll } from "dinkle/service/api";
+
+// states
+const state = {
+  // 文宣模块列表
+  tabList: undefined,
+  // pk列表数据
+  pkList: [],
+  // 查询关键字
+  searchKey: undefined,
+  // 产品参数列表
+  paramList: undefined
+};
+
+// mutations:
+// this.$store.commit("dinkle/xxx");
+const mutations = {
+  SET_TAB_LIST(state, payload) {
+    state.tabList = payload;
+  },
+  ADD_PK_LIST(state, payload) {
+    const list = state.pkList;
+    const isExist = list.some(pk => pk.id == payload.id);
+    if (!isExist) state.pkList.push(payload);
+  },
+  DEL_PK_LIST(state) {
+    state.pkList = state.pkList.filter(item => !item.isSelected);
+  },
+  SET_SEARCH_KEY(state, payload) {
+    state.searchKey = payload;
+  },
+  SET_PARAM_LIST(state, payload) {
+    state.paramList = payload;
+  }
+};
+
+// actions
+// this.$store.dispatch("dinkle/xxx");
+const actions = {
+  // 记录文宣模块列表
+  async SYNC_TAB_LIST({ commit, state }) {
+    if (state.tabList) return state.tabList;
+    const res = await getAllWx();
+    const list = res.data.map(({ id, moduleName }) => {
+      return { id, moduleName };
+    });
+    commit("SET_TAB_LIST", list);
+    return list;
+  },
+  // 记录产品参数列表
+  async SYNC_PARAM_LIST({ commit, state }) {
+    if (state.paramList) return state.paramList;
+    const resp = await listAll();
+    // 参数排序 paramPriority: 返回差值而不是 Boolean
+    const list = resp.data.sort(
+      (itemA, itemB) => itemA.paramPriority - itemB.paramPriority
+    );
+    console.log("xxx", list);
+    const paramList = list.reduce((acc, cur) => {
+      // 去除水中隐藏字段
+      if (Number(cur.paramStatus)) return acc;
+      // 初始化值, 也需要读取数据
+      if (!([cur.paramType] in acc)) {
+        acc[cur.paramType] = [];
+      }
+      acc[cur.paramType].push(cur.paramName);
+      return acc;
+    }, {});
+    commit("SET_PARAM_LIST", paramList);
+    console.log(paramList);
+    return paramList;
+  }
+};
+
+export default {
+  namespaced: true,
+  state,
+  mutations,
+  actions
+};

+ 241 - 0
src/modules/dinkle/views/content/index.vue

@@ -0,0 +1,241 @@
+<template>
+  <div class="main">
+    <!-- 视频 -->
+    <q-page-sticky expand position="top" ref="topContainer" class="player">
+      <video
+        class="player-video video-js vjs-big-play-centered"
+        ref="videoPlayer"
+        playsinline
+        :poster="options.poster"
+      ></video>
+    </q-page-sticky>
+    <!-- 模块 -->
+    <div class="q-pa-xs" :style="{ 'margin-top': playerH }">
+      <q-tabs
+        v-model="tab"
+        align="justify"
+        narrow-indicator
+        @input="queryModuleList"
+        active-color="#ff6600"
+      >
+        <q-tab
+          v-for="item of tabList"
+          :key="item.id"
+          :name="item.id"
+          :label="item.moduleName"
+        />
+      </q-tabs>
+    </div>
+    <!-- 排序 -->
+    <q-separator color="#E0E0E0"></q-separator>
+    <div class="q-pa-md list-order">
+      <div @click="orderPriorityChange(false)">
+        <img src="~dinkle/assets/icon/order.png" />
+        <p :class="{ 'list-order-selected': !orderByPriority }">按时间排序</p>
+      </div>
+      <div @click="orderPriorityChange(true)">
+        <img src="~dinkle/assets/icon/order.png" />
+        <p :class="{ 'list-order-selected': orderByPriority }">按优先级排序</p>
+      </div>
+    </div>
+    <!-- 列表 -->
+    <q-separator color="#E0E0E0"></q-separator>
+    <div class="q-pa-md" v-for="record of records" :key="record.id">
+      <div class="q-col-gutter-md row items-start">
+        <div class="col-4" @click="listCellClick(record)">
+          <q-img :src="record.picKey | fmtOSSUrl" style="width: 100%">
+            <div class="absolute-full text-subtitle1 flex flex-center">
+              <img src="~dinkle/assets/icon/player.png" style="width: 50px" />
+            </div>
+          </q-img>
+        </div>
+        <div class="col-8">
+          <div class="text-h5 q-pa-sm">
+            {{ record.moduleTitle }}
+          </div>
+          <q-expansion-item :label="record.fileType" expand-separator>
+            <p style="margin-bottom: 40px">{{ record.moduleContent }}</p>
+          </q-expansion-item>
+        </div>
+      </div>
+      <q-separator color="#E0E0E0"></q-separator>
+    </div>
+  </div>
+</template>
+
+<script>
+import videojs from "video.js";
+import "video.js/dist/video-js.css";
+import { pageDetailByModuleId } from "dinkle/service/api";
+
+export default {
+  name: "",
+  components: {},
+  data() {
+    return {
+      // 播放器
+      player: null,
+      options: {
+        controls: true,
+        preload: "metadata",
+        fluid: true,
+        poster: null,
+        sources: []
+      },
+      playerH: 0,
+      // 模块列表
+      tab: "",
+      tabIndex: 0,
+      tabList: [],
+      // 按优先级orderByPriority传true, false默认按时间排
+      orderByPriority: true,
+      // records
+      records: []
+    };
+  },
+  computed: {},
+  watch: {},
+  filters: {},
+  async created() {
+    await this.syncTabList();
+    await this.queryModuleList();
+  },
+  mounted() {
+    this.initVideoPlayer();
+  },
+  beforeDestroy() {
+    if (this.player) this.player.dispose();
+  },
+  methods: {
+    // 初始化视频方法
+    initVideoPlayer() {
+      this.player = videojs(
+        this.$refs.videoPlayer,
+        this.options,
+        function onPlayerReady() {
+          this.play();
+        }
+      );
+      this.playerH = this.$refs.topContainer.$el.clientHeight + "px";
+    },
+    // 同步文宣模块列表
+    async syncTabList() {
+      this.tabList = await this.$store.dispatch("dinkle/SYNC_TAB_LIST");
+      if (this.tabList.length) this.tab = this.tabList[this.tabIndex].id;
+    },
+    // 查询模块数据列表
+    async queryModuleList() {
+      const res = await pageDetailByModuleId({
+        moduleId: this.tab,
+        orderByPriority: this.orderByPriority
+      });
+      this.records = [...res.data.records];
+      // 视频默认加载第一个
+      const item = this.records[0];
+      if (item.fileType == "MP4") this.listCellClick(item);
+    },
+    // 点击触发操作
+    listCellClick(item) {
+      if (item.fileType == "MP4") {
+        this.player.src({
+          src: item.linkUrl || `${process.env.VUE_APP_OSS_URL}/${item.fileKey}`,
+          type: `video/${item.fileType}`
+        });
+        this.player.poster = `${process.env.VUE_APP_OSS_URL}/${item.picKey}`;
+        this.player.posterImage.show();
+        this.player.play();
+      } else {
+        window.open(
+          item.linkUrl || `${process.env.VUE_APP_OSS_URL}/${item.fileKey}`
+        );
+      }
+    },
+    // 排序规则变更
+    orderPriorityChange(orderByPriority) {
+      this.orderByPriority = orderByPriority;
+      this.queryModuleList();
+    }
+  }
+};
+</script>
+
+<style scoped lang="stylus">
+// 宽度100%
+.player
+  z-index 999
+  &-video
+    width 100%
+.list-order
+  display flex
+  justify-content center
+  align-items center
+  margin-top 10px;
+  > div
+    display flex
+    justify-content center
+    align-items center
+    width 50%
+    p
+      font-weight: 500;
+      color: #313131;
+      line-height: 11px;
+      margin-left 5px
+      font-size 15px
+    img
+      width 30px
+  &-selected
+    color #ff6600 !important
+// 折叠展开
+>>> .q-item
+  padding 0.8rem
+>>> .q-tab
+    min-height 4rem
+    margin-bottom 0.5rem
+</style>
+
+<style>
+/* 暂停时显示播放按钮 */
+.vjs-paused .vjs-big-play-button,
+.vjs-paused.vjs-has-started .vjs-big-play-button {
+  display: block;
+}
+/* 播放按钮变圆形 */
+.video-js .vjs-big-play-button {
+  font-size: 2.5em;
+  line-height: 2.3em;
+  height: 2.5em;
+  width: 2.5em;
+  -webkit-border-radius: 2.5em;
+  -moz-border-radius: 2.5em;
+  border-radius: 2.5em;
+  background-color: #73859f;
+  background-color: rgba(115, 133, 159, 0.5);
+  border-width: 0.15em;
+  margin-top: -1.25em;
+  margin-left: -1.75em;
+}
+/* 中间的播放箭头 */
+.vjs-big-play-button .vjs-icon-placeholder {
+  font-size: 1.63em;
+}
+/* 加载圆圈 */
+.vjs-loading-spinner {
+  font-size: 2.5em;
+  width: 2em;
+  height: 2em;
+  border-radius: 1em;
+  margin-top: -1em;
+  margin-left: -1.5em;
+}
+/* 点击屏幕播放/暂停 */
+.video-js.vjs-playing .vjs-tech {
+  pointer-events: auto;
+}
+/* 进度显示当前播放时间 */
+.video-js .vjs-time-control {
+  display: block;
+}
+.video-js .vjs-remaining-time {
+  display: none;
+}
+</style>

+ 146 - 0
src/modules/dinkle/views/contrast/index.vue

@@ -0,0 +1,146 @@
+<template lang="pug">
+  div.main
+    div.top-nodata(v-if="hasPK")
+      img(src="~dinkle/assets/icon/search-to.png" @click="$router.push('search')")
+    div.main-top(v-else)
+      q-checkbox(v-model="isAll" label="全选" color="red" @input="selectAll")
+      div.top-reset(@click="resetPKList")
+        q-avatar(rounded size="50px")
+          img(src="~dinkle/assets/icon/reset.png")
+        p 重新选择
+    ProductList(:list="pkList" :isPK="true" @selectChanged="selectChanged" :style="{ 'margin-bottom': bottom }" )
+    div.main-bottom(:style="{ 'bottom': bottom }" @click="toDelete")
+      q-btn-toggle(:value="currentBtn" spread no-caps size='44px' unelevated stack toggle-color="negative" color="#F2F2F2" text-color="#878787" :options="options" clearable @clear="toPK")
+</template>
+
+<script>
+import ProductList from "dinkle/components/productCell";
+import { mapState } from "vuex";
+
+export default {
+  name: "",
+  components: {
+    ProductList
+  },
+  data() {
+    return {
+      // 搜索内容
+      search: "",
+      // 搜索列表
+      searchList: [],
+      // 是否全选
+      isAll: false,
+      // 操作按钮
+      currentBtn: "pk",
+      options: [
+        { label: "删除 (0)", value: "" },
+        { label: "开始对比 (0)", value: "pk" }
+      ],
+      isPk: false,
+      // 距离底部
+      bottom: 0
+    };
+  },
+  computed: {
+    ...mapState({
+      pkList: state => state.dinkle.pkList
+    }),
+    hasPK() {
+      return !this.pkList.length;
+    }
+  },
+  watch: {},
+  filters: {},
+  created() {},
+  mounted() {
+    this.selectChanged();
+    setTimeout(() => {
+      this.bottom = window.webH + "px";
+    }, 200);
+  },
+  methods: {
+    // 重置pk列表
+    resetPKList() {
+      this.pkList.forEach(item => (item.isSelected = false));
+      this.isAll = false;
+      this._updateCount(0);
+    },
+    // 同步全选状态
+    selectChanged() {
+      const arrS = this.pkList.filter(item => item.isSelected);
+      this._updateCount(arrS.length);
+      if (arrS.length) {
+        this.isAll = this.pkList.length == arrS.length ? true : null;
+      } else {
+        this.isAll = false;
+      }
+    },
+    // 全选所有
+    selectAll() {
+      const arrS = this.pkList.filter(item => item.isSelected);
+      if (this.pkList.length == arrS.length) {
+        this.pkList.forEach(item => (item.isSelected = false));
+        this.isAll = false;
+        this._updateCount(0);
+      } else {
+        this.pkList.forEach(item => (item.isSelected = true));
+        this.isAll = true;
+        this._updateCount(this.pkList.length);
+      }
+    },
+    // 更新选中的数量
+    _updateCount(num) {
+      this.options[0].label = `删除 (${num})`;
+      this.options[1].label = `开始对比 (${num})`;
+    },
+    // pk对比页面
+    toPK() {
+      if (this.isAll === false) return;
+      this.isPk = true;
+      setTimeout(() => {
+        this.isPk = false;
+      }, 100);
+      const arrS = this.pkList.filter(item => item.isSelected);
+      const ids = arrS.map(item => item.id).join(",");
+      this.$router.push({
+        name: "pkList",
+        query: { ids }
+      });
+    },
+    // 删除操作
+    toDelete() {
+      if (this.isAll === false) return;
+      if (this.isPk) return;
+      this.$store.commit("dinkle/DEL_PK_LIST");
+      this.selectChanged();
+    }
+  }
+};
+</script>
+
+<style scoped lang="stylus">
+.main
+  background-color #fff
+.main-top, .top-reset
+  display flex
+  justify-content start
+  align-items center
+.top-reset
+  &:hover
+    color #ff6600
+  margin-left 25px
+  p
+    margin-left 8px
+.top-nodata
+  text-align center
+  padding-top 15px
+  img
+    width 80%
+.main-top
+  padding 15px 20px
+.main-bottom
+  position fixed
+  width 100%
+  left 0
+  background-color #fff
+</style>

+ 326 - 0
src/modules/dinkle/views/detail/index.vue

@@ -0,0 +1,326 @@
+<template>
+  <div class="main">
+    <!-- 轮播图 -->
+    <swiper class="swiper" :options="swiperOptions">
+      <swiper-slide v-for="slide of swiperList" :key="slide">
+        <img class="swiper-image" :src="slide | fmtOSSUrl" />
+      </swiper-slide>
+    </swiper>
+    <!-- 操作按钮 -->
+    <div class="row function-v justify-around">
+      <div
+        v-for="(item, idx) in functions"
+        :key="idx"
+        @click="productionOperate(idx)"
+      >
+        <img :src="detailInfo.existPK && !idx ? item.select : item.img" />
+        <p>{{ item.title }}</p>
+      </div>
+    </div>
+    <!-- 内部\外部消息切换 -->
+    <q-btn-toggle
+      v-model="currentBtn"
+      spread
+      no-caps
+      unelevated
+      toggle-color="negative"
+      color="#F2F2F2"
+      text-color="#878787"
+      :options="options"
+    />
+    <!-- 技术规格 -->
+    <div v-if="currentBtn === 'one'" class="content-v">
+      <div class="product-explain">
+        <p>
+          <span>厂商</span>
+          <span>{{ detailInfo.productFactory }}</span>
+        </p>
+        <p>
+          <span>产品类型</span>
+          <span>{{ detailInfo.productType }}</span>
+        </p>
+        <p>
+          <span>系列码</span>
+          <span>{{ detailInfo.productCode }}</span>
+        </p>
+        <p>
+          <span>产品品号</span>
+          <span>{{ detailInfo.productNumber }}</span>
+        </p>
+        <p>
+          <span>订货号</span>
+          <span>{{ detailInfo.bookNumber }}</span>
+        </p>
+        <p>产品概述</p>
+        <p class="bg-white product-desc">{{ detailInfo.productDesc }}</p>
+        <p></p>
+        <p>关联竞品</p>
+        <ul class="text-primary ellipsis-2-lines">
+          <li
+            v-for="relation of detailInfo.relationEntityList"
+            :key="relation.id"
+            @click="relationQuery(relation)"
+          >
+            {{ relation.refProductNumber }}
+          </li>
+        </ul>
+      </div>
+      <!-- 产品参数 -->
+      <div>
+        <div class="param-title">产品参数</div>
+        <div class="product-params">
+          <div v-for="(param, idx) in detailInfo.params" :key="param">
+            <ul class="col">
+              <p class="param-type" :class="{ 'param-type-1': idx === 0 }">
+                {{ param }}
+              </p>
+              <li
+                class="row justify-between"
+                v-for="(item, index) of detailInfo.paramEntityList[param]"
+                :key="index"
+              >
+                <span>{{ item.name }}</span>
+                <span>{{ item.value }}</span>
+              </li>
+            </ul>
+          </div>
+        </div>
+      </div>
+    </div>
+    <!-- 商务信息 -->
+    <div v-else class="content-v product-explain">
+      <p>
+        <span>指导价格</span>
+        <span>{{ detailInfo.normalPrice }}</span>
+      </p>
+      <p>
+        <span>A工厂库存</span>
+        <span>{{ detailInfo.stockCount1 }}</span>
+      </p>
+      <p>
+        <span>B工厂库存</span>
+        <span>{{ detailInfo.stockCount2 }}</span>
+      </p>
+      <p>
+        <span>最小包装数据</span>
+        <span>{{ detailInfo.minSpec }}</span>
+      </p>
+      <p>
+        <span>交期</span>
+        <span>{{ detailInfo.deliveryTime }}</span>
+      </p>
+    </div>
+  </div>
+</template>
+
+<script>
+import { Swiper, SwiperSlide } from "vue-awesome-swiper";
+import "swiper/css/swiper.css";
+import { queryProductDetail } from "dinkle/service/api";
+export default {
+  name: "",
+  components: { Swiper, SwiperSlide },
+  data() {
+    return {
+      // swiper 配置
+      swiperOptions: {
+        autoplay: true,
+        loop: true,
+        grabCursor: true,
+        setWrapperSize: true,
+        a11y: true,
+        roundLengths: true,
+        spaceBetween: 10,
+        pagination: {
+          el: ".swiper-pagination",
+          clickable: true,
+          hideOnClick: true
+        }
+      },
+      // 轮播图列表
+      swiperList: [],
+      // 外部/内部消息切换
+      currentBtn: "one",
+      options: [
+        { label: "技术规格", value: "one" },
+        { label: "商务信息", value: "two" }
+      ],
+      // 产品操作按钮
+      functions: [
+        {
+          title: "加入PK",
+          img: require("dinkle/assets/icon/+pk.png"),
+          select: require("dinkle/assets/icon/+pk-select.png")
+        },
+        {
+          title: "查看图纸",
+          img: require("dinkle/assets/icon/look.png")
+        },
+        {
+          title: "一键对比",
+          img: require("dinkle/assets/icon/pk-relation.png")
+        }
+      ],
+      // 产品详情
+      detailInfo: {
+        existPK: false
+      }
+    };
+  },
+  filters: {},
+  computed: {},
+  watch: {},
+  methods: {
+    productionOperate(idx) {
+      // 加入pk
+      if (!idx) {
+        if (this.detailInfo.existPK) return;
+        this.detailInfo.existPK = true;
+        const item = this.detailInfo;
+        const relation = item.relationEntityList.map(sub => ({
+          id: sub.refProductId,
+          factory: sub.refProductFactory
+        }));
+        const detial = {
+          id: item.id,
+          label: item.productNumber,
+          factory: item.productFactory,
+          code: item.productCode,
+          image: item.productImage,
+          isSelected: false,
+          existPK: false,
+          relation
+        };
+        this.$store.commit("dinkle/ADD_PK_LIST", detial);
+        return;
+      }
+      // 查看图纸
+      if (idx == 1) {
+        const src = this.detailInfo.productDrawingPdf;
+        if (src) window.open(`${process.env.VUE_APP_OSS_URL}/${src}`);
+        return;
+      }
+      // 一键对比
+      if (idx == 2) {
+        const arr = this.detailInfo.relationEntityList.map(
+          item => item.refProductId
+        );
+        arr.push(this.detailInfo.id);
+        this.$router.push({
+          name: "pkList",
+          query: { ids: arr.join(",") }
+        });
+        return;
+      }
+    },
+    // 查询产品详情
+    async queryDetail() {
+      const res = await queryProductDetail({ productId: this.$route.query.id });
+      const data = res.data;
+      // 轮播资源拼接处理
+      const arr = [];
+      if (data.productDrawingImage)
+        arr.push(...data.productDrawingImage.split(","));
+      if (data.productImage) arr.push(...data.productImage.split(","));
+      this.swiperList = arr;
+      // 参数分类处理
+      const params = {};
+      data.paramEntityList.forEach(item => {
+        // 初始化值, 也需要读取数据
+        if (!([item.paramType] in params)) {
+          params[item.paramType] = [];
+        }
+        params[item.paramType].push({
+          name: item.paramName,
+          value: item.productValue || "/"
+        });
+      });
+      data.paramEntityList = params;
+      data.params = Object.keys(params);
+      this.detailInfo = { ...this.detailInfo, ...res.data };
+    },
+    // 点击查询关联竞品
+    relationQuery({ refProductId } = {}) {
+      this.$router.push({
+        name: "relation",
+        query: { id: refProductId }
+      });
+    }
+  },
+  created() {
+    this.queryDetail();
+  },
+  async mounted() {}
+};
+</script>
+
+<style scoped lang="stylus">
+.main
+  background #fff
+  font-family: PingFangSC-Medium, PingFang SC;
+  font-weight: 500;
+  color: #313131;
+  font-size: 12px;
+.swiper
+  --swiper-theme-color: #ff6600;
+  &-image
+    width: 100%;
+.function-v
+  background-color #fff
+  height 70px
+  align-content center
+  align-items center
+  margin-bottom 10px
+  text-align center
+  p
+    color #878787
+  img
+    width 30px
+    margin-bottom 4px
+.content-v
+  padding 10px 9px 9px 9.5px
+.product-explain
+  p
+    display flex
+    padding 5px
+    align-content: center;
+    line-height 20px
+    &:nth-child(2n - 1)
+      background #F2F2F2
+    span:first-child
+      width 30%
+      margin-right 10px
+    span:last-child
+      color #595959
+  .product-desc
+    color #595959
+  ul
+    padding 5px 0
+    li
+      color #3C8EF4
+      line-height 20px
+      padding 5px
+      &:hover
+        text-decoration underline
+.product-params
+  background #F2F2F2
+  padding-bottom 15px
+.param-type
+li
+  padding 3px 8px 3px 10px
+  span:first-child
+    color #878787
+.param-type-1
+  background #fff
+  padding auto 0
+.param-title
+  padding 3px
+  background #F2F2F2
+.q-btn-toggle
+  border-top-left-radius: 0;
+  border-bottom-left-radius: 0;
+  border-top-right-radius: 0px;
+  border-bottom-right-radius: 0px;
+  background-color #F2F2F2
+  height 40px
+</style>

+ 326 - 0
src/modules/dinkle/views/detail/relation.vue

@@ -0,0 +1,326 @@
+<template>
+  <div class="main">
+    <!-- 轮播图 -->
+    <swiper class="swiper" :options="swiperOptions">
+      <swiper-slide v-for="slide of swiperList" :key="slide">
+        <img class="swiper-image" :src="slide | fmtOSSUrl" />
+      </swiper-slide>
+    </swiper>
+    <!-- 操作按钮 -->
+    <div class="row function-v justify-around">
+      <div
+        v-for="(item, idx) in functions"
+        :key="idx"
+        @click="productionOperate(idx)"
+      >
+        <img :src="detailInfo.existPK && !idx ? item.select : item.img" />
+        <p>{{ item.title }}</p>
+      </div>
+    </div>
+    <!-- 内部\外部消息切换 -->
+    <q-btn-toggle
+      v-model="currentBtn"
+      spread
+      no-caps
+      unelevated
+      toggle-color="negative"
+      color="#F2F2F2"
+      text-color="#878787"
+      :options="options"
+    />
+    <!-- 技术规格 -->
+    <div v-if="currentBtn === 'one'" class="content-v">
+      <div class="product-explain">
+        <p>
+          <span>厂商</span>
+          <span>{{ detailInfo.productFactory }}</span>
+        </p>
+        <p>
+          <span>产品类型</span>
+          <span>{{ detailInfo.productType }}</span>
+        </p>
+        <p>
+          <span>系列码</span>
+          <span>{{ detailInfo.productCode }}</span>
+        </p>
+        <p>
+          <span>产品品号</span>
+          <span>{{ detailInfo.productNumber }}</span>
+        </p>
+        <p>
+          <span>订货号</span>
+          <span>{{ detailInfo.bookNumber }}</span>
+        </p>
+        <p>产品概述</p>
+        <p class="bg-white product-desc">{{ detailInfo.productDesc }}</p>
+        <p></p>
+        <p>关联竞品</p>
+        <ul class="text-primary ellipsis-2-lines">
+          <li
+            v-for="relation of detailInfo.relationEntityList"
+            :key="relation.id"
+            @click="relationQuery(relation)"
+          >
+            {{ relation.refProductNumber }}
+          </li>
+        </ul>
+      </div>
+      <!-- 产品参数 -->
+      <div>
+        <div class="param-title">产品参数</div>
+        <div class="product-params">
+          <div v-for="(param, idx) in detailInfo.params" :key="param">
+            <ul class="col">
+              <p class="param-type" :class="{ 'param-type-1': idx === 0 }">
+                {{ param }}
+              </p>
+              <li
+                class="row justify-between"
+                v-for="(item, index) of detailInfo.paramEntityList[param]"
+                :key="index"
+              >
+                <span>{{ item.name }}</span>
+                <span>{{ item.value }}</span>
+              </li>
+            </ul>
+          </div>
+        </div>
+      </div>
+    </div>
+    <!-- 商务信息 -->
+    <div v-else class="content-v product-explain">
+      <p>
+        <span>指导价格</span>
+        <span>{{ detailInfo.normalPrice }}</span>
+      </p>
+      <p>
+        <span>A工厂库存</span>
+        <span>{{ detailInfo.stockCount1 }}</span>
+      </p>
+      <p>
+        <span>B工厂库存</span>
+        <span>{{ detailInfo.stockCount2 }}</span>
+      </p>
+      <p>
+        <span>最小包装数据</span>
+        <span>{{ detailInfo.minSpec }}</span>
+      </p>
+      <p>
+        <span>交期</span>
+        <span>{{ detailInfo.deliveryTime }}</span>
+      </p>
+    </div>
+  </div>
+</template>
+
+<script>
+import { Swiper, SwiperSlide } from "vue-awesome-swiper";
+import "swiper/css/swiper.css";
+import { queryProductDetail } from "dinkle/service/api";
+export default {
+  name: "",
+  components: { Swiper, SwiperSlide },
+  data() {
+    return {
+      // swiper 配置
+      swiperOptions: {
+        autoplay: true,
+        loop: true,
+        grabCursor: true,
+        setWrapperSize: true,
+        a11y: true,
+        roundLengths: true,
+        spaceBetween: 10,
+        pagination: {
+          el: ".swiper-pagination",
+          clickable: true,
+          hideOnClick: true
+        }
+      },
+      // 轮播图列表
+      swiperList: [],
+      // 外部/内部消息切换
+      currentBtn: "one",
+      options: [
+        { label: "技术规格", value: "one" },
+        { label: "商务信息", value: "two" }
+      ],
+      // 产品操作按钮
+      functions: [
+        {
+          title: "加入PK",
+          img: require("dinkle/assets/icon/+pk.png"),
+          select: require("dinkle/assets/icon/+pk-select.png")
+        },
+        {
+          title: "查看图纸",
+          img: require("dinkle/assets/icon/look.png")
+        },
+        {
+          title: "一键对比",
+          img: require("dinkle/assets/icon/pk-relation.png")
+        }
+      ],
+      // 产品详情
+      detailInfo: {
+        existPK: false
+      }
+    };
+  },
+  filters: {},
+  computed: {},
+  watch: {},
+  methods: {
+    productionOperate(idx) {
+      // 加入pk
+      if (!idx) {
+        if (this.detailInfo.existPK) return;
+        this.detailInfo.existPK = true;
+        const item = this.detailInfo;
+        const relation = item.relationEntityList.map(sub => ({
+          id: sub.refProductId,
+          factory: sub.refProductFactory
+        }));
+        const detial = {
+          id: item.id,
+          label: item.productNumber,
+          factory: item.productFactory,
+          code: item.productCode,
+          image: item.productImage,
+          isSelected: false,
+          existPK: false,
+          relation
+        };
+        this.$store.commit("dinkle/ADD_PK_LIST", detial);
+        return;
+      }
+      // 查看图纸
+      if (idx == 1) {
+        const src = this.detailInfo.productDrawingPdf;
+        if (src) window.open(`${process.env.VUE_APP_OSS_URL}/${src}`);
+        return;
+      }
+      // 一键对比
+      if (idx == 2) {
+        const arr = this.detailInfo.relationEntityList.map(
+          item => item.refProductId
+        );
+        arr.push(this.detailInfo.id);
+        this.$router.push({
+          name: "pkList",
+          query: { ids: arr.join(",") }
+        });
+        return;
+      }
+    },
+    // 查询产品详情
+    async queryDetail() {
+      const res = await queryProductDetail({ productId: this.$route.query.id });
+      const data = res.data;
+      // 轮播资源拼接处理
+      const arr = [];
+      if (data.productDrawingImage)
+        arr.push(...data.productDrawingImage.split(","));
+      if (data.productImage) arr.push(...data.productImage.split(","));
+      this.swiperList = arr;
+      // 参数分类处理
+      const params = {};
+      data.paramEntityList.forEach(item => {
+        // 初始化值, 也需要读取数据
+        if (!([item.paramType] in params)) {
+          params[item.paramType] = [];
+        }
+        params[item.paramType].push({
+          name: item.paramName,
+          value: item.productValue || "/"
+        });
+      });
+      data.paramEntityList = params;
+      data.params = Object.keys(params);
+      this.detailInfo = { ...this.detailInfo, ...res.data };
+    },
+    // 点击查询关联竞品
+    relationQuery({ refProductId } = {}) {
+      this.$router.push({
+        name: "detail",
+        query: { id: refProductId }
+      });
+    }
+  },
+  created() {
+    this.queryDetail();
+  },
+  async mounted() {}
+};
+</script>
+
+<style scoped lang="stylus">
+.main
+  background #fff
+  font-family: PingFangSC-Medium, PingFang SC;
+  font-weight: 500;
+  color: #313131;
+  font-size: 12px;
+.swiper
+  --swiper-theme-color: #ff6600;
+  &-image
+    width: 100%;
+.function-v
+  background-color #fff
+  height 70px
+  align-content center
+  align-items center
+  margin-bottom 10px
+  text-align center
+  p
+    color #878787
+  img
+    width 30px
+    margin-bottom 4px
+.content-v
+  padding 10px 9px 9px 9.5px
+.product-explain
+  p
+    display flex
+    padding 5px
+    align-content: center;
+    line-height 20px
+    &:nth-child(2n - 1)
+      background #F2F2F2
+    span:first-child
+      width 30%
+      margin-right 10px
+    span:last-child
+      color #595959
+  .product-desc
+    color #595959
+  ul
+    padding 5px 0
+    li
+      color #3C8EF4
+      line-height 20px
+      padding 5px
+      &:hover
+        text-decoration underline
+.product-params
+  background #F2F2F2
+  padding-bottom 15px
+.param-type
+li
+  padding 3px 8px 3px 10px
+  span:first-child
+    color #878787
+.param-type-1
+  background #fff
+  padding auto 0
+.param-title
+  padding 3px
+  background #F2F2F2
+.q-btn-toggle
+  border-top-left-radius: 0;
+  border-bottom-left-radius: 0;
+  border-top-right-radius: 0px;
+  border-bottom-right-radius: 0px;
+  background-color #F2F2F2
+  height 40px
+</style>

+ 286 - 0
src/modules/dinkle/views/home/index.vue

@@ -0,0 +1,286 @@
+<template lang="pug">
+  div.main
+    swiper.swiper(:options="swiperOptions")
+      swiper-slide(v-for="slide of swiperList" :key="slide")
+        img.swiper-image(:src="slide | fmtOSSUrl")
+      div.swiper-pagination(slot="pagination")
+    div.search
+      q-input.search-input(filled v-model="search" dense bg-color="white" placeholder="请输入町洋品号/竞品品号/订货号" clearable @focus="showSearch = true" @input="queryReset" @keyup.enter="toSearch")
+        template(v-slot:prepend)
+          q-avatar(@click="toSearch")
+            img(src="~dinkle/assets/logo/logo.png")
+      q-scroll-area.search-list(:thumb-style="thumbStyle" :bar-style="barStyle" v-show="showSearch && search" :style="{ 'height': searchH }")
+        q-infinite-scroll(@load="onLoad" :disable="isFinish")
+          q-list
+            q-item.text-center(clickable v-ripple v-for="item of searchList" :key="item.id" @click="clickItem(item)")
+              q-item-section {{ item.label }}
+          template(v-slot:loading)
+            div
+              q-spinner-dots(color="negative")
+          div.no-more(v-show="isFinish") — 没有更多数据了 —
+    div.modules
+      div(v-for="module of moduleList" :key="module.title" @click="jumpModule(module)") {{ module.title }}
+    q-separator(color="#E0E0E0")
+    div.relation
+      img(src="~dinkle/assets/icon/link.png")
+      p 竞品速链
+    div.modules.contract
+      div.modules-item(v-for="(contrast, idx) of linkList" :key="idx" @click="jumpModule(contrast)")
+        img(v-if="contrast.image" :src="contrast.image | fmtOSSUrl")
+        p(v-else) {{ contrast.title }}
+</template>
+
+<script>
+import { Swiper, SwiperSlide } from "vue-awesome-swiper";
+import "swiper/css/swiper.css";
+import {
+  getBannerResource,
+  getHomepageResource,
+  getOtherProductResource,
+  searchProduct
+} from "dinkle/service/api";
+
+export default {
+  name: "",
+  components: { Swiper, SwiperSlide },
+  data() {
+    return {
+      // swiper 配置
+      swiperOptions: {
+        autoplay: true,
+        loop: true,
+        grabCursor: true,
+        setWrapperSize: true,
+        a11y: true,
+        roundLengths: true,
+        spaceBetween: 10,
+        pagination: {
+          el: ".swiper-pagination",
+          clickable: true,
+          hideOnClick: true
+        }
+      },
+      // 轮播图列表
+      swiperList: [],
+      // 搜索内容
+      search: "",
+      // 搜索区域高度
+      searchH: 0,
+      // 是否显示搜索列表
+      showSearch: false,
+      // 搜索列表样式
+      thumbStyle: {
+        right: "4px",
+        borderRadius: "5px",
+        backgroundColor: "#027be3",
+        width: "5px",
+        opacity: 0.75
+      },
+      barStyle: {
+        right: "2px",
+        borderRadius: "9px",
+        backgroundColor: "#027be3",
+        width: "9px",
+        opacity: 0.2
+      },
+      // 搜索列表
+      searchList: [],
+      // 首页模块列表
+      moduleList: [],
+      // 竞品速链列表
+      linkList: [],
+      // 分页
+      paging: {
+        pageNo: 1,
+        pageSize: 20
+      },
+      // 是否查询结束
+      isFinish: false,
+      // 查询防抖处理
+      isForbidden: true
+    };
+  },
+  computed: {},
+  watch: {},
+  filters: {},
+  created() {
+    this._initSwiperList();
+    this._initModuleList();
+    this._initContrastList();
+    this.searchH = document.body.clientHeight * 0.6 + "px";
+  },
+  mounted() {},
+  methods: {
+    // bannar 初始化
+    async _initSwiperList() {
+      const res = await getBannerResource();
+      this.swiperList = res.data.map(item => item.resourceKey);
+    },
+    // modules 初始化
+    async _initModuleList() {
+      const res = await getHomepageResource();
+      this.moduleList = res.data.map(item => {
+        return {
+          title: item.resourceRemark,
+          link: item.jumpLink
+        };
+      });
+    },
+    // 模块跳转
+    jumpModule(module) {
+      if (module.link) window.open(module.link, "");
+    },
+    // 竞品速链初始化
+    async _initContrastList() {
+      const res = await getOtherProductResource();
+      this.linkList = res.data.map(item => {
+        return {
+          id: item.id,
+          title: item.resourceRemark,
+          link: item.jumpLink,
+          image: item.resourceKey
+        };
+      });
+    },
+    // 搜索查询
+    async querySearch(done) {
+      if (!this.search) return;
+      const { pageNo, pageSize } = this.paging;
+      const res = await searchProduct({
+        keyword: this.search,
+        pageNo,
+        pageSize
+      });
+      const arr = res.data.records.map(item => {
+        return {
+          id: item.id,
+          label:
+            item.productNumber == item.bookNumber
+              ? item.productNumber
+              : `${item.productNumber} - ${item.bookNumber}`
+        };
+      });
+      this.searchList.push(...arr);
+      if (!done) {
+        this.isForbidden = false;
+      }
+      this.isFinish = this.searchList.length >= res.data.total;
+      done && done();
+    },
+    // 跳转到详情
+    clickItem(item) {
+      this.showSearch = false;
+      this.$router.push({
+        name: "detail",
+        query: {
+          id: item.id
+        }
+      });
+    },
+    // 跳转到搜索页面
+    toSearch() {
+      this.showSearch = false;
+      this.$router.push({
+        name: "search",
+        params: {
+          search: this.search
+        }
+      });
+    },
+    // 分页查询
+    onLoad(_, done) {
+      if (this.isForbidden) {
+        done && done();
+        return;
+      }
+      this.paging.pageNo += 1;
+      this.querySearch(() => done());
+    },
+    // 重置分页
+    queryReset() {
+      this.isForbidden = true;
+      this.paging.pageNo = 1;
+      this.searchList = [];
+      this.querySearch();
+    }
+  }
+};
+</script>
+
+<style scoped lang="stylus">
+.main
+  background-color #F0F2F5
+.swiper
+  --swiper-theme-color: #ff6600;
+  &-image
+    width: 100%;
+.search
+  position absolute
+  left 8%
+  width 84%
+  top 100px
+  z-index 999
+  opacity: 0.89;
+  &-input
+     width 86%
+     margin-left 7%
+  &-list
+    margin-top 5px
+    background-color #FFF
+    width: 100%;
+    border-radius 2px
+    overflow hidden
+.modules
+  display flex
+  align-items center
+  flex-wrap wrap
+  margin 20px 0
+.modules > div
+  font-weight: 400;
+  color: #9C9C9C;
+  line-height: 9px;
+  font-size 13px
+  text-align center
+  width 25%
+  &:hover
+    color #ff6600
+.relation
+  display flex
+  align-items center
+  margin 15px 15px
+  color #333
+  p
+    font-size 18px
+    margin-left 5px
+  img
+    width 20px
+.contract
+   margin 10px 10px 10px 15px
+.modules-item
+  margin-bottom 5px
+  margin-right 5px
+  width calc(25% - 5px) !important
+  line-height 51px
+  background-color #EEEEEF
+  font-weight: 400;
+  color: #595959;
+  &:hover
+    background-color #ff6600
+    color #fff !important
+  p, img
+    width: 100%
+    height 51px
+    line-height 51px
+    font-size 13px
+.no-more
+  background-color #fff
+  padding 10px
+  color #9C9C9C
+  text-align center
+  font-size 11px
+>>> .q-infinite-scroll__loading
+  text-align center
+  padding-bottom 10px
+>>> .q-field--filled .q-field__control
+  border-radius 0.4rem
+</style>

+ 123 - 0
src/modules/dinkle/views/layout/index copy.vue

@@ -0,0 +1,123 @@
+<template>
+  <div class="full-height main">
+    <q-layout view="lHh lpr lFf" container>
+      <q-header bordered class="bg-white text-primary">
+        <q-toolbar v-show="!$route.meta.navHide">
+          <q-btn
+            v-show="$route.meta.canBack"
+            flat
+            round
+            dense
+            icon="img:http://alading-20210318.oss-cn-shanghai.aliyuncs.com/upload/1620883976604-o1m4-back-line.png"
+            @click="$router.go(-1)"
+          />
+          <q-toolbar-title class="text-center">
+            <span>{{ $route.meta.title }}</span>
+          </q-toolbar-title>
+        </q-toolbar>
+      </q-header>
+      <q-footer
+        bordered
+        class="bg-white text-primary"
+        v-show="!$route.meta.tabHide"
+        ref="tabBar"
+      >
+        <q-tabs
+          no-caps
+          active-color="negative"
+          indicator-color="transparent"
+          class="text-grey"
+          v-model="tab"
+          align="justify"
+        >
+          <q-route-tab
+            v-for="item of tabs"
+            :key="item.name"
+            :name="item.name"
+            :icon="item | fmtTabIcon(tab)"
+            :label="item.label"
+            :to="'/' + item.name"
+            exact
+            :ripple="false"
+          >
+            <div v-if="item.name == 'home'">
+              <q-badge v-show="pkList.length" color="negative" floating>{{
+                pkList.length
+              }}</q-badge>
+            </div>
+          </q-route-tab>
+        </q-tabs>
+      </q-footer>
+
+      <q-page-container>
+        <q-page>
+          <router-view></router-view>
+        </q-page>
+      </q-page-container>
+    </q-layout>
+  </div>
+</template>
+
+<script>
+import { mapState } from "vuex";
+
+export default {
+  name: "",
+  components: {},
+  data() {
+    return {
+      // tab路由
+      tab: "home",
+      // tab集合
+      tabs: [
+        {
+          name: "home",
+          icon: "search",
+          label: "首页"
+        },
+        {
+          name: "contrast",
+          icon: "pk",
+          label: "对比"
+        },
+        {
+          name: "website",
+          icon: "website",
+          label: "官网"
+        },
+        {
+          name: "content",
+          icon: "resource",
+          label: "推广包"
+        }
+      ]
+    };
+  },
+  computed: {
+    ...mapState({
+      pkList: state => state.dinkle.pkList
+    })
+  },
+  watch: {},
+  filters: {
+    fmtTabIcon(item, tab) {
+      const icon = item.name == tab ? `${item.icon}-select` : item.icon;
+      return `img:http://alading-20210318.oss-cn-shanghai.aliyuncs.com/assets/${icon}.png`;
+    }
+  },
+  created() {},
+  mounted() {
+    window.webH = this.$refs.tabBar.$el.clientHeight;
+  },
+  methods: {}
+};
+</script>
+
+<style scoped lang="stylus">
+.main
+  max-width 1080px
+>>> .q-tab__icon
+  width 38px
+  height 38px
+  margin -10px
+</style>

+ 123 - 0
src/modules/dinkle/views/layout/index.vue

@@ -0,0 +1,123 @@
+<template>
+  <div class="full-height main">
+    <q-layout view="lHh lpr lFf" container>
+      <q-header bordered class="bg-white text-primary">
+        <q-toolbar v-show="!$route.meta.navHide">
+          <q-btn
+            v-show="$route.meta.canBack"
+            flat
+            round
+            dense
+            icon="img:http://alading-20210318.oss-cn-shanghai.aliyuncs.com/upload/1620883976604-o1m4-back-line.png"
+            @click="$router.go(-1)"
+          />
+          <q-toolbar-title class="text-center">
+            <span>{{ $route.meta.title }}</span>
+          </q-toolbar-title>
+        </q-toolbar>
+      </q-header>
+      <q-footer
+        bordered
+        class="bg-white text-primary"
+        v-show="!$route.meta.tabHide"
+        ref="tabBar"
+      >
+        <q-tabs
+          no-caps
+          active-color="negative"
+          indicator-color="transparent"
+          class="text-grey"
+          v-model="tab"
+          align="justify"
+        >
+          <q-route-tab
+            v-for="item of tabs"
+            :key="item.name"
+            :name="item.name"
+            :icon="item | fmtTabIcon(tab)"
+            :label="item.label"
+            :to="'/' + item.name"
+            exact
+            :ripple="false"
+          >
+            <div v-if="item.name == 'contrast'">
+              <q-badge v-show="pkList.length" color="negative" floating>{{
+                pkList.length
+              }}</q-badge>
+            </div>
+          </q-route-tab>
+        </q-tabs>
+      </q-footer>
+
+      <q-page-container>
+        <q-page>
+          <router-view></router-view>
+        </q-page>
+      </q-page-container>
+    </q-layout>
+  </div>
+</template>
+
+<script>
+import { mapState } from "vuex";
+
+export default {
+  name: "",
+  components: {},
+  data() {
+    return {
+      // tab路由
+      tab: "home",
+      // tab集合
+      tabs: [
+        {
+          name: "home",
+          icon: "search",
+          label: "首页"
+        },
+        {
+          name: "contrast",
+          icon: "pk",
+          label: "对比"
+        },
+        {
+          name: "website",
+          icon: "website",
+          label: "官网"
+        },
+        {
+          name: "content",
+          icon: "resource",
+          label: "推广包"
+        }
+      ]
+    };
+  },
+  computed: {
+    ...mapState({
+      pkList: state => state.dinkle.pkList
+    })
+  },
+  watch: {},
+  filters: {
+    fmtTabIcon(item, tab) {
+      const icon = item.name == tab ? `${item.icon}-select` : item.icon;
+      return `img:http://alading-20210318.oss-cn-shanghai.aliyuncs.com/assets/${icon}.png`;
+    }
+  },
+  created() {},
+  mounted() {
+    window.webH = this.$refs.tabBar.$el.clientHeight;
+  },
+  methods: {}
+};
+</script>
+
+<style scoped lang="stylus">
+.main
+  max-width 1080px
+>>> .q-tab__icon
+  width 38px
+  height 38px
+  margin -10px
+</style>

+ 188 - 0
src/modules/dinkle/views/pkList/index.vue

@@ -0,0 +1,188 @@
+<template>
+  <div class="main">
+    <div class="main-top">
+      <q-toggle
+        v-model="onlyDiff"
+        color="negative"
+        left-label
+        label="仅看差异"
+        dense
+      ></q-toggle>
+      <div>
+        <p></p>
+        <span>表示参数优势 </span>
+      </div>
+    </div>
+    <div class="main-info">
+      <p v-for="prop of propInfo" :key="prop.prop">
+        <span>{{ prop.label }} </span>
+        <span
+          v-for="(info, idx) of proInfo"
+          :key="info.id"
+          v-show="
+            !proInfo[idx - 1] ||
+              !onlyDiff ||
+              (proInfo[idx - 1][prop.prop] != info[prop.prop]) | fmtOnlyDiff
+          "
+        >
+          {{ info[prop.prop] }}</span
+        >
+      </p>
+    </div>
+    <!-- 产品参数 -->
+    <div class="main-bottom">
+      <div class="main-info main-params" v-for="prop of propParams" :key="prop">
+        <div>{{ prop }}</div>
+        <p v-for="params of propParamsObj[prop]" :key="params">
+          <span>{{ params }}</span>
+          <span
+            v-for="(pro, idx) of proParmas"
+            :key="idx"
+            v-show="
+              !proParmas[idx - 1] ||
+                !onlyDiff ||
+                proParmas[idx - 1][prop][params] != pro[prop][params]
+            "
+          >
+            <!-- 可能没有该分类 -->
+            {{ pro[prop] ? pro[prop][params] : "—" }}
+          </span>
+        </p>
+      </div>
+    </div>
+  </div>
+</template>
+<script>
+import { queryProductDetailList } from "dinkle/service/api";
+
+export default {
+  name: "",
+  components: {},
+  data() {
+    return {
+      // 仅看差异
+      onlyDiff: false,
+      // 产品信息
+      proInfo: [],
+      // 产品参数
+      proParmas: [],
+      // info字段
+      propInfo: [
+        {
+          prop: "productFactory",
+          label: "厂商"
+        },
+        {
+          prop: "productType",
+          label: "产品类型"
+        },
+        {
+          prop: "productCode",
+          label: "系列码"
+        },
+        {
+          prop: "productNumber",
+          label: "产品品号"
+        },
+        {
+          prop: "bookNumber",
+          label: "订货号"
+        },
+        {
+          prop: "productDesc",
+          label: "产品说明"
+        }
+      ],
+      // 参数字段
+      propParams: [],
+      propParamsObj: {}
+    };
+  },
+  filters: {},
+  computed: {},
+  watch: {},
+  methods: {
+    // 查询PK数据
+    async queryPKList() {
+      const res = await queryProductDetailList({
+        productIds: this.$route.query.ids
+      });
+      // 产品参数
+      const data = res.data;
+      this.proInfo = data.filter(item => item && item.id);
+      // 参数分类处理
+      const arr = [];
+      this.proInfo.forEach(item => {
+        const params = {};
+        item.paramEntityList.forEach(sub => {
+          // 初始化值, 也需要读取数据
+          if (!([sub.paramType] in params)) {
+            params[sub.paramType] = {};
+          }
+          params[sub.paramType][sub.paramName] = sub.productValue || "—";
+        });
+        arr.push(params);
+      });
+      this.proParmas = arr;
+      // 参数类型处理
+      this.propParamsObj = await this.$store.dispatch("dinkle/SYNC_PARAM_LIST");
+      this.propParams = Object.keys(this.propParamsObj);
+    }
+  },
+  created() {
+    this.queryPKList();
+  }
+};
+</script>
+<style scoped lang="stylus">
+.main
+  background-color #fff
+  padding 15px 20px
+  &-top
+    display flex
+    justify-content space-between
+    align-items center
+    div
+      display flex
+      justify-content space-between
+      align-items center
+      p
+        width 18px
+        height 18px
+        background-color: #D4EBEB
+      span
+        font-size 14px
+        margin-left 5px
+  &-info
+    margin-top 30px
+    border 0.25px solid #E0E0E0
+    p
+      display flex
+      align-content: center;
+      justify-content space-between
+      text-align center
+      width 100%
+      &:nth-child(2n)
+        background #F2F2F2
+      span
+        border 0.25px solid #E0E0E0
+        line-height 20px
+        padding 10px
+        width 100%
+        font-size 11px
+  &-params
+    margin-top 0
+    > div
+      padding 10px
+      font-size 13px
+      font-weight 500
+    p
+      &:nth-child(2n)
+        background #fff
+      &:nth-child(2n -1)
+        background #F2F2F2
+  &-bottom
+    margin 20px 0 10px 0
+>>> .q-toggle__label
+  font-weight bold
+</style>

+ 122 - 0
src/modules/dinkle/views/search/index.vue

@@ -0,0 +1,122 @@
+<template lang="pug">
+  div.main
+    q-page-sticky.search(expand position="top")
+      q-input.search-input(filled v-model="search" dense placeholder="请输入町洋品号/竞品品号/订货号" clearable @input="queryReset" @keyup.enter="queryReset")
+        template(v-slot:prepend)
+          q-avatar
+            img(src="~dinkle/assets/logo/logo.png")
+    q-infinite-scroll(@load="onLoad" :disable="isFinish")
+      productList(:list="searchList" :isForbidden="isForbidden")
+      template(v-slot:loading)
+        q-spinner-dots(color="negative")
+      div.no-more(v-show="isFinish && searchList.length") — 没有更多数据了 —
+</template>
+
+<script>
+import { searchProduct } from "dinkle/service/api";
+import ProductList from "dinkle/components/productCell";
+
+export default {
+  name: "",
+  components: {
+    ProductList
+  },
+  data() {
+    return {
+      // 搜索内容
+      search: "",
+      // 搜索列表
+      searchList: [],
+      // 分页
+      paging: {
+        pageNo: 1,
+        pageSize: 10
+      },
+      // 是否查询结束
+      isFinish: false,
+      // 查询onload误触处理
+      isForbidden: true
+    };
+  },
+  computed: {},
+  watch: {},
+  filters: {},
+  created() {
+    this.search = this.$route.params.search;
+    if (this.search) {
+      this.queryReset();
+    } else {
+      this.isForbidden = true;
+    }
+  },
+  mounted() {},
+  methods: {
+    // 搜索查询
+    async querySearch(done) {
+      if (!this.search) return;
+      const { pageNo, pageSize } = this.paging;
+      const res = await searchProduct({
+        keyword: this.search,
+        pageNo,
+        pageSize
+      });
+      const arr = res.data.records.map(item => {
+        const list = item.relationEntityList || [];
+        const relation = list.map(sub => ({
+          id: sub.refProductId,
+          factory: sub.refProductFactory
+        }));
+        return {
+          id: item.id,
+          label: item.productNumber,
+          factory: item.productFactory,
+          code: item.productCode,
+          image: item.productImage,
+          isSelected: false,
+          existPK: false,
+          relation
+        };
+      });
+      this.searchList.push(...arr);
+      if (!done) this.isForbidden = false;
+      this.isFinish = this.searchList.length >= res.data.total;
+      done && done();
+    },
+    // 分页查询
+    onLoad(_, done) {
+      if (this.isForbidden) {
+        done && done();
+        return;
+      }
+      this.paging.pageNo += 1;
+      this.querySearch(() => done());
+    },
+    // 重置分页
+    queryReset() {
+      this.isForbidden = true;
+      this.paging.pageNo = 1;
+      this.searchList = [];
+      this.querySearch();
+    }
+  }
+};
+</script>
+
+<style scoped lang="stylus">
+.search
+  z-index 999
+  padding 10px
+  background-color #F0F2F5
+.no-more
+  background-color #fff
+  padding 10px
+  color #9C9C9C
+  text-align center
+  font-size 11px
+>>> .q-field
+  width 80%
+>>> .q-infinite-scroll__loading
+  text-align center
+  padding-bottom 10px
+  background-color #fff
+</style>

+ 31 - 0
src/modules/dinkle/views/website/index.vue

@@ -0,0 +1,31 @@
+<template lang="pug">
+  div.main
+    iframe.webview(frameborder=0 scrolling=auto src="https://www.dinkle.com/cn/" :style="{ 'height': webH }")
+</template>
+
+<script>
+export default {
+  name: "",
+  components: {},
+  data() {
+    return {
+      webH: 0
+    };
+  },
+  computed: {},
+  watch: {},
+  filters: {},
+  created() {},
+  mounted() {
+    setTimeout(() => {
+      this.webH = document.body.clientHeight - window.webH + "px";
+    }, 200);
+  },
+  methods: {}
+};
+</script>
+
+<style scoped lang="stylus">
+.webview
+  width 100%
+</style>

+ 3 - 0
src/modules/fushuo/remark.md

@@ -0,0 +1,3 @@
+## 复硕
+
+导出 excel, 在热敏打印机, 打印地址 + 编号

+ 18 - 0
src/modules/fushuo/router/index.js

@@ -0,0 +1,18 @@
+export default [
+  {
+    path: "/export-fei",
+    name: "export-fei",
+    meta: {
+      title: "非特标签打印 | 复硕"
+    },
+    component: () => import("@/modules/fushuo/views/export-fei")
+  },
+  {
+    path: "/export-te",
+    name: "export-te",
+    meta: {
+      title: "特化标签打印 | 复硕"
+    },
+    component: () => import("@/modules/fushuo/views/export-te")
+  }
+];

+ 33 - 0
src/modules/fushuo/service/api.js

@@ -0,0 +1,33 @@
+import request from "@/service/request";
+
+//////////////////////////// 服务地址 ////////////////////////////
+
+// 致拓宜搭服务地址
+function _aliworkZitooUrl(url) {
+  return `https://yida.100ali.com/api/yida${url}`;
+}
+
+/////////////////////  业务逻辑:thunk包装调用  /////////////////////
+
+/**
+ * @exports 查询单据数据
+ * @param conditionList: 原关联表单的字段, 如 [{"fieldName":"selectField_k9tw8gaq","value":"天城水榭一期"}] - 只能查询当前formId下的表单字段
+ **/
+export function aliworkTheDynamicSubsidiary({
+  userId,
+  appType,
+  formId,
+  conditionList,
+  pageIndex = 1,
+  pageSize = 10
+}) {
+  const params = {
+    pageIndex,
+    pageSize,
+    userId,
+    appType,
+    formId
+  };
+  if (conditionList) params.conditionList = conditionList;
+  return request.postData(_aliworkZitooUrl("/form/select"), params);
+}

+ 40 - 0
src/modules/fushuo/store/auth.js

@@ -0,0 +1,40 @@
+// states
+const state = {
+  // 客户权限
+  permission: [
+    /** 王雷 */ "0465670920955596",
+    /** 冯超 */ "0501106304684470",
+    /** 王曙 */ "2950340343943278",
+    /** 徐梦影 */ "055759385524364827",
+    /** 陈秋梅 */ "165549180737964898",
+    /** 燕江 */ "095358016621619612"
+  ]
+};
+
+// actions
+const actions = {
+  // 客户分类
+  category(_, userId) {
+    if (
+      [
+        /** 冯超 */ "0501106304684470",
+        /** 陈秋梅 */ "165549180737964898"
+      ].includes(userId)
+    )
+      return "老客户";
+    if (
+      [
+        /** 王曙 */ "2950340343943278",
+        /** 徐梦影 */ "055759385524364827"
+      ].includes(userId)
+    )
+      return "新客户";
+    return "";
+  }
+};
+
+export default {
+  namespaced: true,
+  state,
+  actions
+};

+ 50 - 0
src/modules/fushuo/views/export-fei/index.mixin.js

@@ -0,0 +1,50 @@
+export default {
+  data() {
+    // 表头格式化处理
+    const tableHeader = [
+      {
+        header: "样品名称",
+        field: "textField_khlrd6ar",
+        align: "center",
+        fixed: true
+      },
+      {
+        header: "样品编号",
+        field: "selectField_kiob6l17",
+        align: "center",
+        fixed: true
+      },
+      {
+        header: "分类",
+        field: "textField_khls8t3x",
+        align: "center",
+        fixed: true
+      },
+      {
+        header: "客户名称",
+        field: "textField_khlmcttq",
+        align: "center"
+      },
+      {
+        header: "创建时间",
+        field: "gmtCreate",
+        align: "center"
+      },
+      {
+        header: "更新时间",
+        field: "gmtModified",
+        align: "center"
+      },
+      {
+        header: "操作",
+        align: "center",
+        slot: "operate"
+      }
+    ];
+
+    return {
+      // 表头
+      tableHeader
+    };
+  }
+};

+ 184 - 0
src/modules/fushuo/views/export-fei/index.vue

@@ -0,0 +1,184 @@
+<template lang="pug">
+div.main
+  div.main-top
+    h2 {{$route.meta.title}}
+  div.main-oper
+    span.main-oper-t 样品名称:
+    el-input(v-model="filterData.name" placeholder="请输入" clearable @keyup.native.enter="queryTable()")
+    span.main-oper-t 样品编号:
+    el-input(v-model="filterData.code" placeholder="请输入" clearable @keyup.native.enter="queryTable()")
+    span.main-oper-t 客户名称:
+    el-input(v-model="filterData.cus" placeholder="请输入" clearable @keyup.native.enter="queryTable()")
+    div.main-oper-button
+      el-button(type="primary" icon="el-icon-search" @click.stop="queryTable()") 查询
+      el-button(type="warning" icon="el-icon-thumb" :disabled="canBatch" @click="handleShowDispatch()") 导出+ {{ tableSelection.length }}
+  tableList.table(:props="tableHeader" :data="tableData" :paging="paginationInitial" @paginationChanged="paginationChanged" :selection.sync="tableSelection")
+    template(v-slot:operate="{ scope }")
+      el-button(size="mini", type="primary", @click="handleSkipDetail(scope.row)") 详情
+</template>
+
+<script>
+// 宜搭参数
+const USER_ID = "yida_pub_account";
+const APP_TYPE = "APP_NZK8W31RRXC8TH2BPYBT";
+const FROM_UUID = "FORM-YSA66KC1997LC164W875RALOWF8S1YHQPXTHKUC";
+
+import { aliworkTheDynamicSubsidiary } from "@/modules/fushuo/service/api";
+import { exportExcel } from "@/utils/export";
+
+import mixin from "./index.mixin";
+
+export default {
+  name: "admin-system",
+  mixins: [mixin],
+  components: {
+    tableList: () => import("@/components/table/element-ui/Universal"),
+    datePick: () => import("@/components/common/element-ui/DatePick")
+  },
+  data() {
+    return {
+      // 表数据
+      tableData: [],
+      // 分页初始值
+      paginationInitial: {
+        page: 1,
+        size: 10,
+        total: 0
+      },
+      // 多选操作
+      tableSelection: [],
+      // 查询条件
+      filterData: {
+        name: "",
+        code: "",
+        cus: ""
+      },
+      // 历史参数记录: 比对参数是否变更, 重置查询页数和数据
+      paramsPrevious: {}
+    };
+  },
+  filters: {},
+  computed: {
+    // 是否可批量操作
+    canBatch() {
+      return !(this.tableSelection.length > 0);
+    }
+  },
+  watch: {},
+  methods: {
+    // 查询列表
+    async queryTable(noRest) {
+      const res = await aliworkTheDynamicSubsidiary(this._fmtParams(noRest));
+      this.tableData = res.list.map(item => {
+        return {
+          formInstId: item.formInstId,
+          gmtCreate: item.gmtCreate,
+          gmtModified: item.gmtModified,
+          link: `https://www.aliwork.com/${APP_TYPE}/formDetail/${FROM_UUID}?formInstId=${item.formInstId}`,
+          title: `样品编号: ${item.formData.selectField_kiob6l17}`,
+          ...item.formData
+        };
+      });
+      this.paginationInitial.total = res.total;
+    },
+    // 分页变动
+    paginationChanged({ page, size }) {
+      this.paginationInitial.page = page;
+      this.paginationInitial.size = size;
+      this.queryTable(true);
+    },
+    // 格式化请求数据
+    _fmtParams(noRest) {
+      const conditionList = [];
+      const fmtCondition = (
+        compId,
+        condition,
+        iEemployee = false,
+        isDate = false
+      ) => {
+        if (condition) {
+          if (iEemployee) condition = JSON.stringify(condition);
+          if (isDate) condition = JSON.stringify(condition);
+          conditionList.push({
+            fieldName: compId,
+            value: condition
+          });
+        }
+      };
+      fmtCondition("textField_khlrd6ar", this.filterData.name); // 样品名称
+      fmtCondition("selectField_kiob6l17", this.filterData.code); // 样品编号
+      fmtCondition("textField_khlmcttq", this.filterData.cus); // 客户名称
+      fmtCondition("textField_khls8t3x", "非特"); // 客户名称
+      // 参数格式化, 统一管理查询条件
+      const params = {
+        appType: APP_TYPE,
+        userId: USER_ID,
+        formId: FROM_UUID,
+        pageIndex: this.paginationInitial.page,
+        pageSize: this.paginationInitial.size,
+        conditionList
+      };
+      // 当查询条件发生变化, 重置查询页数和数据 - 忽略字段:更多方案详见 https://www.jianshu.com/p/435ea5cfa609
+      if (!noRest) {
+        this.tableData = [];
+        this.paginationInitial.total = 0; // 查询前重置数据: 总数为0, page会置为1
+        this.paginationInitial.page = 1; // 重置页数
+      }
+      return params;
+    },
+    // 跳转详情
+    handleSkipDetail({ link }) {
+      window.open(link);
+    },
+    // 批量导出
+    handleShowDispatch() {
+      let cols = {
+        title: "样品编号",
+        link: "二维码地址"
+      };
+      exportExcel({
+        cols,
+        list: this.tableSelection,
+        fileName: "标签批量打印",
+        bookType: "xls"
+      });
+    }
+  },
+  async created() {
+    this.$store.commit("user/SET_TOKEN", "cVhNZqaqTimBB7LRBhpA"); // token授权
+    this.queryTable();
+  },
+  async mounted() {}
+};
+</script>
+
+<style scoped lang="stylus">
+.main
+  background-color #fff
+  min-width 1080px
+  padding 30px 40px
+  &-top
+    margin-bottom 15px
+    display flex
+    align-items flex-start
+    h2
+      border-left 5px solid SlateGray
+      padding-left 10px
+    .el-link
+      margin-left 20px
+      font-size 14px
+  &-oper
+    padding-bottom 15px
+    display flex
+    align-items center
+    flex-wrap wrap
+    &-t
+      color $-color-theme-second
+      margin-right 10px
+      margin-left 20px
+    &-button
+      margin-left 50px
+      margin-right 10px
+    .el-input
+      width 200px
+</style>

+ 50 - 0
src/modules/fushuo/views/export-te/index.mixin.js

@@ -0,0 +1,50 @@
+export default {
+  data() {
+    // 表头格式化处理
+    const tableHeader = [
+      {
+        header: "样品名称",
+        field: "textField_khlrd6ar",
+        align: "center",
+        fixed: true
+      },
+      {
+        header: "样品编号",
+        field: "textField_ki2uws7m",
+        align: "center",
+        fixed: true
+      },
+      {
+        header: "分类",
+        field: "textField_khls8t3x",
+        align: "center",
+        fixed: true
+      },
+      {
+        header: "客户名称",
+        field: "textField_khlmcttq",
+        align: "center"
+      },
+      {
+        header: "创建时间",
+        field: "gmtCreate",
+        align: "center"
+      },
+      {
+        header: "更新时间",
+        field: "gmtModified",
+        align: "center"
+      },
+      {
+        header: "操作",
+        align: "center",
+        slot: "operate"
+      }
+    ];
+
+    return {
+      // 表头
+      tableHeader
+    };
+  }
+};

+ 184 - 0
src/modules/fushuo/views/export-te/index.vue

@@ -0,0 +1,184 @@
+<template lang="pug">
+div.main
+  div.main-top
+    h2 {{$route.meta.title}}
+  div.main-oper
+    span.main-oper-t 样品名称:
+    el-input(v-model="filterData.name" placeholder="请输入" clearable @keyup.native.enter="queryTable()")
+    span.main-oper-t 样品编号:
+    el-input(v-model="filterData.code" placeholder="请输入" clearable @keyup.native.enter="queryTable()")
+    span.main-oper-t 客户名称:
+    el-input(v-model="filterData.cus" placeholder="请输入" clearable @keyup.native.enter="queryTable()")
+    div.main-oper-button
+      el-button(type="primary" icon="el-icon-search" @click.stop="queryTable()") 查询
+      el-button(type="warning" icon="el-icon-thumb" :disabled="canBatch" @click="handleShowDispatch()") 导出+ {{ tableSelection.length }}
+  tableList.table(:props="tableHeader" :data="tableData" :paging="paginationInitial" @paginationChanged="paginationChanged" :selection.sync="tableSelection")
+    template(v-slot:operate="{ scope }")
+      el-button(size="mini", type="primary", @click="handleSkipDetail(scope.row)") 详情
+</template>
+
+<script>
+// 宜搭参数
+const USER_ID = "yida_pub_account";
+const APP_TYPE = "APP_NZK8W31RRXC8TH2BPYBT";
+const FROM_UUID = "FORM-FFYJVE1VBHELL1S50W6IACOS1ZYN39659Z3IKFD";
+
+import { aliworkTheDynamicSubsidiary } from "@/modules/fushuo/service/api";
+import { exportExcel } from "@/utils/export";
+
+import mixin from "./index.mixin";
+
+export default {
+  name: "admin-system",
+  mixins: [mixin],
+  components: {
+    tableList: () => import("@/components/table/element-ui/Universal"),
+    datePick: () => import("@/components/common/element-ui/DatePick")
+  },
+  data() {
+    return {
+      // 表数据
+      tableData: [],
+      // 分页初始值
+      paginationInitial: {
+        page: 1,
+        size: 10,
+        total: 0
+      },
+      // 多选操作
+      tableSelection: [],
+      // 查询条件
+      filterData: {
+        name: "",
+        code: "",
+        cus: ""
+      },
+      // 历史参数记录: 比对参数是否变更, 重置查询页数和数据
+      paramsPrevious: {}
+    };
+  },
+  filters: {},
+  computed: {
+    // 是否可批量操作
+    canBatch() {
+      return !(this.tableSelection.length > 0);
+    }
+  },
+  watch: {},
+  methods: {
+    // 查询列表
+    async queryTable(noRest) {
+      const res = await aliworkTheDynamicSubsidiary(this._fmtParams(noRest));
+      this.tableData = res.list.map(item => {
+        return {
+          formInstId: item.formInstId,
+          gmtCreate: item.gmtCreate,
+          gmtModified: item.gmtModified,
+          link: `https://www.aliwork.com/alibaba/web/${APP_TYPE}/inst/formEdit.html?formInstId=${item.formInstId}&formUuid=${FROM_UUID}`,
+          title: `样品编号: ${item.formData.textField_ki2uws7m}`,
+          ...item.formData
+        };
+      });
+      this.paginationInitial.total = res.total;
+    },
+    // 分页变动
+    paginationChanged({ page, size }) {
+      this.paginationInitial.page = page;
+      this.paginationInitial.size = size;
+      this.queryTable(true);
+    },
+    // 格式化请求数据
+    _fmtParams(noRest) {
+      const conditionList = [];
+      const fmtCondition = (
+        compId,
+        condition,
+        iEemployee = false,
+        isDate = false
+      ) => {
+        if (condition) {
+          if (iEemployee) condition = JSON.stringify(condition);
+          if (isDate) condition = JSON.stringify(condition);
+          conditionList.push({
+            fieldName: compId,
+            value: condition
+          });
+        }
+      };
+      fmtCondition("textField_khlrd6ar", this.filterData.name); // 样品名称
+      fmtCondition("textField_ki2uws7m", this.filterData.code); // 样品编号
+      fmtCondition("textField_khlmcttq", this.filterData.cus); // 客户名称
+      fmtCondition("textField_khls8t3x", "特化"); // 客户名称
+      // 参数格式化, 统一管理查询条件
+      const params = {
+        appType: APP_TYPE,
+        userId: USER_ID,
+        formId: FROM_UUID,
+        pageIndex: this.paginationInitial.page,
+        pageSize: this.paginationInitial.size,
+        conditionList
+      };
+      // 当查询条件发生变化, 重置查询页数和数据 - 忽略字段:更多方案详见 https://www.jianshu.com/p/435ea5cfa609
+      if (!noRest) {
+        this.tableData = [];
+        this.paginationInitial.total = 0; // 查询前重置数据: 总数为0, page会置为1
+        this.paginationInitial.page = 1; // 重置页数
+      }
+      return params;
+    },
+    // 跳转详情
+    handleSkipDetail({ link }) {
+      window.open(link);
+    },
+    // 批量导出
+    handleShowDispatch() {
+      let cols = {
+        title: "样品编号",
+        link: "二维码地址"
+      };
+      exportExcel({
+        cols,
+        list: this.tableSelection,
+        fileName: "标签批量打印",
+        bookType: "xls"
+      });
+    }
+  },
+  async created() {
+    this.$store.commit("user/SET_TOKEN", "cVhNZqaqTimBB7LRBhpA"); // token授权
+    this.queryTable();
+  },
+  async mounted() {}
+};
+</script>
+
+<style scoped lang="stylus">
+.main
+  background-color #fff
+  min-width 1080px
+  padding 30px 40px
+  &-top
+    margin-bottom 15px
+    display flex
+    align-items flex-start
+    h2
+      border-left 5px solid SlateGray
+      padding-left 10px
+    .el-link
+      margin-left 20px
+      font-size 14px
+  &-oper
+    padding-bottom 15px
+    display flex
+    align-items center
+    flex-wrap wrap
+    &-t
+      color $-color-theme-second
+      margin-right 10px
+      margin-left 20px
+    &-button
+      margin-left 50px
+      margin-right 10px
+    .el-input
+      width 200px
+</style>

+ 0 - 0
src/modules/opay/assets/logo/logo.png


Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä