lrf402788946 il y a 5 ans
Parent
commit
9067e82070

+ 3 - 0
.env

@@ -0,0 +1,3 @@
+VUE_APP_AXIOS_BASE_URL = ''
+VUE_APP_ROOT_URL=/admin/
+VUE_APP_MODULE='center'

+ 33 - 0
.eslintrc.js

@@ -0,0 +1,33 @@
+// https://eslint.org/docs/user-guide/configuring
+
+module.exports = {
+  root: true,
+  env: {
+    node: true,
+  },
+  extends: ['plugin:vue/essential', '@vue/prettier'],
+  plugins: ['vue'],
+  rules: {
+    'max-len': [
+      'warn',
+      {
+        code: 250,
+      },
+    ],
+    'no-unused-vars': 'off',
+    'no-console': 'off',
+    'prettier/prettier': [
+      'warn',
+      {
+        singleQuote: true,
+        trailingComma: 'es5',
+        bracketSpacing: true,
+        jsxBracketSameLine: true,
+        printWidth: 160,
+      },
+    ],
+  },
+  parserOptions: {
+    parser: 'babel-eslint',
+  },
+};

+ 1 - 1
.gitignore

@@ -1,7 +1,7 @@
 .DS_Store
 node_modules
 /dist
-
+package-lock.json
 # local env files
 .env.local
 .env.*.local

+ 8 - 0
.prettierrc

@@ -0,0 +1,8 @@
+{
+  "trailingComma": "es5",
+  "tabWidth": 2,
+  "printWidth": 180,
+  "semi": true,
+  "singleQuote": true,
+  "bracketSpacing": true
+}

+ 26 - 0
components/README.md

@@ -0,0 +1,26 @@
+# 组件说明文档
+
+* form.vue:
+  * props: {
+      * fields:字段列表
+        > label: String 显示的字段中文
+        > type: String 这个字段要用什么类型来输出 input的基本类型+date,datetime(需要安装vantUI)
+        > required: Boolean 是否必须输入
+        > model: 必填 String 字段名
+        > placeholder: String 占位,正常用,只是个透传
+        > options: Object 标签的属性设置,例如:textarea 需要显示剩余字数,或者input限制长度,都往这里写,key-value形式(键值对,json的基本了解,不知道百度,具体属性看你具体用那个组件,那个组件有什么属性,瞎写不一定好使)
+        > custom: Boolean 是否自定义 
+        > list: Array 我定义这个属性一般不是直接写在 data语块 中的,而是下面有方法处理过后合并进fields的指定的object中,用来给选择提供选项,必须是{name:XX,value:xxx}形式
+      * rules: Object 规则 不会找el-form的例子,不过使用的async-validator这个依赖为基础,会写这个也可以(那就厉害了,反正我是不行)
+      * isNew: Boolean default=>true 用来看是不是修改,不过现在还没做修改部分,还没用呢
+      * data: null,什么类型都行,原数据
+    }
+
+  * 关于自定义的用法:
+    * 在fields中,custom:true的情况即需要自定义,写法如下
+      > <template #custom="{ item, form, fieldChange }"> ... </template>
+      其中
+      > item:fields循环的每一项
+      > form:组件内部表单的数据空间(挺抽象的,不过就是随便调用form.[props]可以用你输入过的值来判断什么的.具体怎么形容我也不太会,谁要是看到了可以告诉我下,或者我给谁讲讲到时候再整理下这个地方)
+      > fieldChange:改了,改完了将数据整理成{model:XXX,value:XXX}的形式传回去更新,应该需要吧
+    * 如果有多处需要自定义,那就在这个template中再加入template,去判断你怎么显示

+ 120 - 0
components/form.vue

@@ -0,0 +1,120 @@
+<template>
+  <div id="add">
+    <el-form ref="form" :model="form" :rules="rules" label-width="150px" class="form" size="small" @submit.native.prevent>
+      <el-form-item v-for="(item, index) in fields" :key="'form-field-' + index" :label="getField('label', item)" :prop="item.model" :required="item.required">
+        <template v-if="!item.custom">
+          <!-- <template v-if="item.type === `date` || item.type === `datetime`">
+            <date-select :value="form[item.model]" :type="item.type" :model="item.model" @handle="fieldChange" :placeholder="item.label"></date-select>
+            <el-input :readonly="true" v-model="form[item.model]" @click.native="dateShow = true" :placeholder="`请选择时间`"></el-input>
+            <van-action-sheet v-model="dateShow">
+              <van-datetime-picker
+                v-model="form[item.model]"
+                type="date"
+                :min-date="new Date(`1920/01/01`)"
+                :max-date="new Date()"
+                @cancel="show = false"
+                @confirm="data => onDateSelect(data, item.model)"
+              />
+            </van-action-sheet>
+          </template> -->
+          <template v-if="item.type === 'select'">
+            <el-select v-model="form[item.model]">
+              <slot name="options"></slot>
+            </el-select>
+          </template>
+          <template v-else>
+            <el-input v-model="form[item.model]" :type="getField('type', item)" :placeholder="getField('placeholder', item)" v-bind="item.options"></el-input>
+          </template>
+        </template>
+        <template v-else>
+          <slot name="custom" v-bind="{ item, form, fieldChange }"></slot>
+        </template>
+      </el-form-item>
+      <el-form-item>
+        <el-row type="flex" align="middle" justify="space-around">
+          <el-col :span="6">
+            <el-button type="primary" @click="save">保存</el-button>
+          </el-col>
+          <el-col :span="6">
+            <el-button @click="$emit('cancel')">返回</el-button>
+          </el-col>
+        </el-row>
+      </el-form-item>
+    </el-form>
+  </div>
+</template>
+
+<script>
+import _ from 'lodash';
+export default {
+  name: 'add',
+  props: {
+    fields: { type: Array, default: () => [] },
+    rules: { type: Object, default: () => {} },
+    isNew: { type: Boolean, default: true },
+    data: null,
+  },
+  components: {},
+  data: () => ({
+    form: {},
+    display: undefined,
+    show: false,
+    dateShow: false,
+  }),
+  created() {},
+  computed: {},
+  mounted() {},
+  watch: {
+    data: {
+      handler(val) {
+        if (val) this.$set(this, `form`, this.data);
+      },
+      immediate: true,
+    },
+  },
+  methods: {
+    getField(item, data) {
+      let res = _.get(data, item, null);
+      if (item === 'type') res = res === null ? `text` : res;
+      if (item === 'placeholder') res = res === null ? `请输入${data.label}` : res;
+      if (item === 'required') res = res === null ? false : res;
+      if (item === `error`) res = res === null ? `${data.label}错误` : res;
+      return res;
+    },
+    save() {
+      this.$refs['form'].validate(valid => {
+        if (valid) {
+          this.$emit(`save`, { isNew: this.isNew, data: this.form });
+        } else {
+          console.warn('form validate error!!!');
+        }
+      });
+    },
+    fieldChange({ model, value }) {
+      this.$set(this.form, model, value);
+    },
+    onSelect(item, model) {
+      this.$set(this, `display`, item.name);
+      this.$set(this.form, model, item.value);
+      this.show = false;
+    },
+    onDateSelect(item, model) {
+      let date = new Date(item);
+      this.$set(this.form, model, date.toLocaleDateString());
+      // this.$emit('handle', { model: this.model, value: date.toLocaleDateString() });
+      this.dateShow = false;
+    },
+    getDisplay(val) {
+      this.$set(this, `display`, val);
+      this.$set(this, `select`, new Date(val));
+    },
+  },
+};
+</script>
+
+<style lang="less" scoped>
+.form {
+  padding: 2rem;
+  background: #fff;border-radius: 20px;
+}
+</style>

+ 17 - 0
config/menu-config.js

@@ -0,0 +1,17 @@
+export const devMenu = [
+  {
+    path: '',
+    name: '测试',
+    module: 'center',
+    children: [
+      {
+        path: '/list',
+        name: '测试列表',
+      },
+      {
+        path: '/detail',
+        name: '测试详情',
+      },
+    ],
+  },
+];

+ 13 - 0
layout/admin/README.md

@@ -0,0 +1,13 @@
+* list-frame:
+  * props:{
+      * title:String 标题名
+      * filter:Array 搜索条件的数组 {
+          * label:显示查询的字符,
+          * model:数据库字段,
+          * type: 搜索类型 default=> String; select
+          * list: 如果是select类型,需要将选项放置list字段中 [{label,value}]
+          }
+      * totalRow: Number 数据总数
+      * needPag: 是否需要分页
+      * returns: 有,则会出现后退标题
+    }

+ 125 - 0
layout/admin/admin-menu.vue

@@ -0,0 +1,125 @@
+<template>
+  <div id="admin-menu" style="background-color: rgb(0, 20, 42);">
+    <scroll-bar>
+      <div class="logo">
+        <img src="https://img.alicdn.com/tfs/TB13UQpnYGYBuNjy0FoXXciBFXa-242-134.png" width="40" />
+        <span class="site-name">ADMIN LITE</span>
+      </div>
+      <el-menu mode="vertical" :show-timeout="200" background-color="#00142a" text-color="hsla(0, 0%, 100%, .65)" active-text-color="#409EFF">
+        <span v-for="(item, index) in menu" :key="index">
+          <!-- <span v-if="`${item.role}` === `${user.role}` || !item.role"> -->
+          <!--  v-if="`${item.role}` === `${user.role}`" -->
+          <span v-if="!item.children" :to="item.path" :key="item.name">
+            <el-menu-item :index="item.path" @click="selectMenu(item.path, item.module)">
+              <i v-if="item.icon" :class="item.icon"></i>
+              <span v-if="item.name" slot="title">{{ item.name }}</span>
+            </el-menu-item>
+          </span>
+
+          <el-submenu v-else :index="item.name || item.path" :key="item.name">
+            <template slot="title">
+              <i v-if="item && item.icon" :class="item.icon"></i>
+              <span v-if="item && item.name" slot="title">{{ item.name }}</span>
+            </template>
+            <template v-for="(child, childIndex) in item.children">
+              <div :key="childIndex" v-if="!child.hidden">
+                <el-menu-item :index="item.path + child.path" @click="selectMenu(item.path + child.path, item.module)">
+                  <span v-if="child && child.name" slot="title">{{ child.name }}</span>
+                </el-menu-item>
+              </div>
+            </template>
+          </el-submenu>
+        </span>
+      </el-menu>
+    </scroll-bar>
+  </div>
+</template>
+
+<script>
+import { devMenu } from '@frame/config/menu-config';
+import scrollBar from './scrollBar.vue';
+export default {
+  name: 'admin-menu',
+  props: {},
+  components: {
+    scrollBar,
+  },
+  data: () => ({
+    menu: [],
+  }),
+  created() {},
+  computed: {
+    project_modules() {
+      return process.env.VUE_APP_MODULE;
+    },
+  },
+  mounted() {
+    let arr = devMenu.filter(fil => fil.module === this.project_modules);
+    this.$set(this, `menu`, arr);
+  },
+  methods: {
+    selectMenu(path, modules) {
+      if (this.project_modules === modules) this.$router.push({ path: path });
+    },
+  },
+};
+</script>
+
+<style lang="less" scoped>
+.logo {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  height: 4rem;
+  line-height: 4rem;
+  background: #002140;
+  color: #fff;
+  text-align: center;
+  font-size: 1.1rem;
+  font-weight: 600;
+  overflow: hidden;
+}
+.site-name {
+  margin-left: 0.325rem;
+}
+.sidebar-container {
+  box-shadow: 0.125rem 0 0.375rem rgba(0, 21, 41, 0.35);
+  transition: width 0.28s;
+  width: 12rem !important;
+  height: 100%;
+  position: fixed;
+  top: 0;
+  bottom: 0;
+  left: 0;
+  z-index: 1001;
+  overflow: hidden;
+  a {
+    display: inline-block;
+    width: 100%;
+  }
+  .el-menu {
+    padding-top: 1rem;
+    width: 100% !important;
+    border: none;
+  }
+  .el-submenu .el-menu-item {
+    min-width: 16rem !important;
+    padding-left: 3rem !important;
+    background-color: #000c17 !important;
+    &:hover {
+      color: #fff !important;
+    }
+  }
+  .el-menu-item,
+  .el-submenu .el-menu-item {
+    &.is-active {
+      background-color: #188fff !important;
+      color: #fff !important;
+    }
+  }
+  .el-submenu__title i {
+    font-size: 1rem;
+    color: rgba(255, 255, 255, 0.65);
+  }
+}
+</style>

+ 63 - 0
layout/admin/breadcrumb.vue

@@ -0,0 +1,63 @@
+<template>
+  <div id="breadcrumb">
+    <el-tag v-for="(item, index) in list" :key="index" type="primary" size="small" closable class="tags" @close="closeTag(index)">{{ item.name }}</el-tag>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'breadcrumb',
+  props: {},
+  components: {},
+  data: () => ({
+    list: [],
+  }),
+  created() {
+    this.getList();
+  },
+  computed: {},
+  methods: {
+    getList() {
+      console.log('bread getlist');
+      //将面包屑列表存到sessionStorage中,存为list,{name:xxx,path:xxx}形式
+      //1)是从sessionStorage中取出之前的列表
+      let o_list = sessionStorage.getItem('bread');
+      if (o_list !== null) {
+        //如果之前有浏览记录,那就将值取出来
+        this.$set(this, `list`, JSON.parse(o_list));
+      }
+      //2)查询当前路由是否已经存在
+      let { name, path } = this.$route;
+      if (path === '/admin' || path === '/admin/index') return;
+      let object = { name: name, path: path };
+      let res = [];
+      //列表有值,过滤看看有没有当前路由
+      if (this.list.length > 0) {
+        res = this.list.filter(fil => fil.path === object.path);
+        if (res.length <= 0) {
+          //没有当前路由,加上
+          this.list.push(object);
+          sessionStorage.setItem(`bread`, JSON.stringify(this.list));
+        }
+      } else {
+        //列表没有值,直接加上
+        this.list.push(object);
+        sessionStorage.setItem(`bread`, JSON.stringify(this.list));
+      }
+    },
+    closeTag(index) {
+      let elseList = this.list.filter((fil, filIndex) => filIndex !== index);
+      this.$set(this, `list`, elseList);
+      sessionStorage.setItem('bread', JSON.stringify(elseList));
+      if (this.list.length > 0) this.$router.push(this.list[this.list.length - 1].path);
+      else this.$router.push({ path: '/admin/index' });
+    },
+  },
+};
+</script>
+
+<style lang="less" scoped>
+.tags {
+  margin: 0 0.3rem;
+}
+</style>

+ 53 - 0
layout/admin/data-table.vue

@@ -0,0 +1,53 @@
+<template>
+  <div id="data-table">
+    <el-table :data="data" border stripe>
+      <template v-for="(item, index) in fields">
+        <el-table-column :key="index" align="center" :label="item.label" :prop="item.prop" :formatter="toFormatter"></el-table-column>
+      </template>
+      <template v-if="opera.length > 0">
+        <el-table-column label="操作" align="center">
+          <template v-slot="{ row, $index }">
+            <template v-for="(item, index) in opera">
+              <el-tooltip :key="index" effect="dark" :content="item.label" placement="bottom">
+                <el-button :key="index" type="text" :icon="item.icon || ''" size="mini" @click="handleOpera(row, item.method)"></el-button>
+              </el-tooltip>
+            </template>
+          </template>
+        </el-table-column>
+      </template>
+    </el-table>
+  </div>
+</template>
+
+<script>
+import _ from 'lodash';
+export default {
+  name: 'data-table',
+  props: {
+    fields: { type: Array, required: true },
+    data: { type: Array, required: true },
+    opera: { type: Array, default: () => [] },
+  },
+  components: {},
+  data: () => ({}),
+  created() {},
+  computed: {},
+  methods: {
+    toFormatter(row, column, cellValue, index) {
+      let this_fields = this.fields.filter(fil => fil.prop === column.property);
+      if (this_fields.length > 0) {
+        let format = _.get(this_fields[0], `format`, false);
+        if (format) {
+          let res = format(cellValue);
+          return res;
+        } else return cellValue;
+      }
+    },
+    handleOpera(data, method) {
+      this.$emit(method, data);
+    },
+  },
+};
+</script>
+
+<style lang="less" scoped></style>

+ 67 - 0
layout/admin/detail-frame.vue

@@ -0,0 +1,67 @@
+<template>
+  <div id="detail-frame" :style="`height:${heights}px`">
+    <el-scrollbar style="height:100%">
+      <el-card style="background:rgb(231, 224, 235);border-radius: 60px;" shadow="hover">
+        <el-row>
+          <el-col :span="24" class="title">
+            <span v-if="returns">
+              <el-button
+                size="mini"
+                plan
+                circle
+                @click="$router.push({ path: returns })"
+                style="box-shadow: 0 2px 4px rgba(0, 0, 0, .12), 0 0 6px rgba(0, 0, 0, .04)"
+              >
+                <span class="el-icon-arrow-left" style="zoom:1.5;font-weight:700"></span>
+              </el-button>
+            </span>
+            <slot name="title">
+              {{ title }}
+            </slot>
+          </el-col>
+        </el-row>
+
+        <div style="padding:1.875rem;">
+          <slot></slot>
+        </div>
+      </el-card>
+    </el-scrollbar>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'detail-frame',
+  props: {
+    title: { type: String },
+    returns: { type: null, default: null },
+  },
+  components: {},
+  data: () => ({
+    heights: document.documentElement.clientHeight - 80,
+  }),
+  created() {},
+  mounted() {
+    const that = this;
+    window.onresize = () => {
+      return (() => {
+        window.fullHeight = document.documentElement.clientHeight - 80;
+        that.heights = window.fullHeight;
+      })();
+    };
+  },
+  computed: {},
+  methods: {},
+};
+</script>
+
+<style lang="less" scoped>
+.title {
+  font-size: 1rem;
+  font-weight: 700;
+  padding: 1rem;
+}
+/deep/.el-scrollbar__wrap {
+  overflow-x: auto;
+}
+</style>

+ 23 - 0
layout/admin/fw-admin.vue

@@ -0,0 +1,23 @@
+<template>
+  <div id="fw-admin">
+    <section class="app-main">
+      <transition name="el-fade-in-linear" mode="out-in">
+        <router-view></router-view>
+      </transition>
+    </section>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'fw-admin',
+  props: {},
+  components: {},
+  data: () => ({}),
+  created() {},
+  computed: {},
+  methods: {},
+};
+</script>
+
+<style lang="less" scoped></style>

+ 93 - 0
layout/admin/list-frame.vue

@@ -0,0 +1,93 @@
+<template>
+  <div id="list-frame">
+    <el-card style="background:rgb(231, 224, 235);border-radius: 60px;" shadow="hover">
+      <el-row>
+        <el-col :span="24" class="title">
+          <span v-if="returns">
+            <el-button
+              size="mini"
+              plan
+              circle
+              @click="$router.push({ path: returns })"
+              style="box-shadow: 0 2px 4px rgba(0, 0, 0, .12), 0 0 6px rgba(0, 0, 0, .04)"
+            >
+              <span class="el-icon-arrow-left" style="zoom:1.5;font-weight:700"></span>
+            </el-button>
+          </span>
+          <slot name="title">
+            {{ title }}
+          </slot>
+        </el-col>
+      </el-row>
+      <slot name="filter">
+        <el-form :inline="true">
+          <el-form-item v-for="(item, index) in filter" :key="index" :label="item.label" label-width="auto">
+            <template v-if="item.type === `select`">
+              <el-select v-model="searchInfo[`${item.model}`]" size="mini">
+                <el-option v-for="(select, sIndex) in item.list" :key="sIndex" :label="select.label" :value="select.value"></el-option>
+              </el-select>
+            </template>
+            <template v-else>
+              <el-input v-model="searchInfo[`${item.model}`]" size="mini"></el-input>
+            </template>
+          </el-form-item>
+          <el-form-item>
+            <el-button type="primary" size="mini">查询</el-button>
+          </el-form-item>
+        </el-form>
+      </slot>
+
+      <div style="padding:1.875rem;">
+        <slot> </slot>
+      </div>
+      <el-row type="flex" align="middle" justify="end" v-if="needPag">
+        <el-col :span="24" style="text-align:right;">
+          <el-pagination
+            background
+            layout="total, prev, pager, next"
+            :total="totalRow"
+            :page-size="limit"
+            :current-page.sync="currentPage"
+            @current-change="changePage"
+          >
+          </el-pagination>
+        </el-col>
+      </el-row>
+    </el-card>
+  </div>
+</template>
+
+<script>
+import _ from 'lodash';
+export default {
+  name: 'list-frame',
+  props: {
+    title: { type: String },
+    filter: { type: Array, default: () => [] },
+    totalRow: { type: Number, default: 0 },
+    needPag: { type: Boolean, default: true },
+    returns: { type: null, default: null },
+  },
+  components: {},
+  data: () => ({
+    limit: _.get(this, `$limit`, undefined) !== undefined ? this.$limit : 15,
+    currentPage: 1,
+    searchInfo: {},
+  }),
+  created() {},
+  computed: {},
+  methods: {
+    changePage(page) {
+      this.$emit('query', { skip: (page - 1) * this.limit, limit: this.limit, ...this.searchInfo });
+    },
+  },
+};
+</script>
+
+<style lang="less" scoped>
+.title {
+  font-size: 1rem;
+  font-weight: 700;
+  padding: 1rem;
+}
+</style>

+ 110 - 0
layout/admin/navBar.vue

@@ -0,0 +1,110 @@
+<template>
+  <div id="navBar">
+    <el-menu class="navbar" mode="horizontal">
+      <div class="user-profile-container">
+        <div class="user-profile-content">
+          <!-- <div class="menu-icons">
+          <span class="menu-icon"><i class="el-icon-search icon"></i></span>
+          <span class="menu-icon"><i class="el-icon-message icon"></i></span>
+          <span class="menu-icon">
+            <el-badge is-dot class="item">
+              <i class="el-icon-bell icon"></i>
+            </el-badge>
+          </span>
+        </div> -->
+          <el-dropdown>
+            <div class="user-profile-body">
+              <img class="user-avatar" src="https://img.alicdn.com/tfs/TB1ONhloamWBuNjy1XaXXXCbXXa-200-200.png" />
+              <span class="user-name" v-if="user && user.id">欢迎,{{ (user && user.user_name) || '' }}</span>
+              <span class="user-name" v-else @click="$router.push({ path: '/admin' })">请登录</span>
+            </div>
+            <el-dropdown-menu class="user-dropdown" slot="dropdown">
+              <!-- <router-link to="/updatePw" v-if="user && user.id">
+              <el-dropdown-item>
+                修改密码
+              </el-dropdown-item>
+            </router-link> -->
+              <el-dropdown-item v-if="user && user.id">
+                <span @click="toLogout()" style="display:block;">退出</span>
+              </el-dropdown-item>
+            </el-dropdown-menu>
+          </el-dropdown>
+        </div>
+      </div>
+    </el-menu>
+  </div>
+</template>
+
+<script>
+export default {
+  name: 'navBar',
+  props: {},
+  components: {},
+  data: () => ({
+    user: {},
+  }),
+  created() {},
+  computed: {},
+  methods: {
+    async toLogout() {
+      this.$router.push({ path: '/admin' });
+    },
+  },
+};
+</script>
+
+<style lang="less" scoped>
+.navbar {
+  height: 4rem;
+  box-shadow: 0 0.0625rem 0.25rem rgba(0, 21, 41, 0.08);
+  .user-profile-container {
+    position: absolute;
+    right: 1.25rem;
+    cursor: pointer;
+    .user-profile-content {
+      display: flex;
+      padding: 1.25rem 0;
+    }
+    .menu-icons {
+      display: flex;
+      align-items: center;
+      .menu-icon {
+        padding: 0 0.75rem;
+        .icon {
+          display: inline-block;
+          font-size: 1.125rem;
+          text-align: center;
+        }
+      }
+    }
+    .user-profile-body {
+      position: relative;
+      display: flex;
+      flex-direction: row;
+      align-items: center;
+      justify-content: center;
+      text-align: center;
+      padding-right: 0.875rem;
+    }
+    .user-avatar {
+      width: 1.5rem;
+      height: 1.5rem;
+      margin: 0 0.5rem 0 0.75rem;
+      border-radius: 0.25rem;
+    }
+    .user-name {
+      color: rgba(0, 0, 0, 0.65);
+    }
+    .user-department {
+      font-size: 0.75rem;
+      color: rgba(102, 102, 102, 0.65);
+    }
+    .el-icon-caret-bottom {
+      position: absolute;
+      right: 0;
+      top: 0.8125rem;
+      font-size: 0.75rem;
+    }
+  }
+}
+</style>

+ 31 - 0
layout/admin/scroll-page.vue

@@ -0,0 +1,31 @@
+<!--登录页面布局,只有footer-->
+<template functional>
+  <el-scrollbar class="scroll-page">
+    <slot></slot>
+  </el-scrollbar>
+</template>
+
+<style lang="less">
+html,
+body {
+  width: 100%;
+  height: 100%;
+}
+</style>
+<style lang="less">
+.el-scrollbar.scroll-page {
+  height: 100%;
+  width: 100%;
+  & > .el-scrollbar__wrap {
+    overflow-x: hidden;
+    display: flex;
+    & > .el-scrollbar__view {
+      padding: 0px;
+      display: flex;
+      flex: 1;
+      // flex-direction: column;
+      // overflow: auto;
+    }
+  }
+}
+</style>

+ 54 - 0
layout/admin/scrollBar.vue

@@ -0,0 +1,54 @@
+<template>
+  <div id="scrollBar">
+    <div class="scroll-container" ref="scrollContainer" @wheel.prevent="handleScroll">
+      <div class="scroll-wrapper" ref="scrollWrapper" :style="{ top: top + 'px' }">
+        <slot></slot>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script>
+const delta = 15;
+
+export default {
+  name: 'ScrollBar',
+  data() {
+    return {
+      top: 0,
+    };
+  },
+  methods: {
+    handleScroll(e) {
+      const eventDelta = e.wheelDelta || -e.deltaY * 3;
+      const $container = this.$refs.scrollContainer;
+      const $containerHeight = $container.offsetHeight;
+      const $wrapper = this.$refs.scrollWrapper;
+      const $wrapperHeight = $wrapper.offsetHeight;
+      if (eventDelta > 0) {
+        this.top = Math.min(0, this.top + eventDelta);
+      } else if ($containerHeight - delta < $wrapperHeight) {
+        if (this.top < -($wrapperHeight - $containerHeight + delta)) {
+          this.top = this.top;
+        } else {
+          this.top = Math.max(this.top + eventDelta, $containerHeight - $wrapperHeight - delta);
+        }
+      } else {
+        this.top = 0;
+      }
+    },
+  },
+};
+</script>
+
+<style lang="less" scoped>
+.scroll-container {
+  width: 100%;
+  height: 100%;
+  background-color: #00142a;
+  .scroll-wrapper {
+    position: absolute;
+    width: 100%;
+  }
+}
+</style>

+ 2 - 0
package.json

@@ -9,6 +9,8 @@
   },
   "dependencies": {
     "core-js": "^3.4.4",
+    "element": "^0.1.4",
+    "lodash": "^4.17.15",
     "vue": "^2.6.10",
     "vue-router": "^3.1.3",
     "vuex": "^3.1.2"

+ 1 - 0
plugins/README.md

@@ -0,0 +1 @@
+### 框架使用的 vue plugin

+ 22 - 0
plugins/axios.js

@@ -0,0 +1,22 @@
+/* eslint-disable no-console */
+/* eslint-disable no-param-reassign */
+
+import Vue from 'vue';
+import AxiosWrapper from '@frame/utils/axios-wrapper';
+
+const Plugin = {
+  install(vue, options) {
+    // 3. 注入组件
+    vue.mixin({
+      created() {
+        if (this.$store && !this.$store.$axios) {
+          this.$store.$axios = this.$axios;
+        }
+      },
+    });
+    // 4. 添加实例方法
+    vue.prototype.$axios = new AxiosWrapper(options);
+  },
+};
+
+Vue.use(Plugin, { baseUrl: process.env.VUE_APP_AXIOS_BASE_URL, unwrap: true });

+ 40 - 0
plugins/check-res.js

@@ -0,0 +1,40 @@
+/* eslint-disable no-underscore-dangle */
+/* eslint-disable no-param-reassign */
+/* eslint-disable no-unused-vars */
+/* eslint-disable no-shadow */
+import Vue from 'vue';
+import _ from 'lodash';
+import { Message } from 'element-ui';
+
+const vm = new Vue({});
+const Plugin = {
+  install(Vue, options) {
+    // 4. 添加实例方法
+    Vue.prototype.$checkRes = (res, okText, errText) => {
+      let _okText = okText;
+      let _errText = errText;
+      if (!_.isFunction(okText) && _.isObject(okText) && okText != null) {
+        ({ okText: _okText, errText: _errText } = okText);
+      }
+      const { errcode = 0, errmsg } = res || {};
+      if (errcode === 0) {
+        if (_.isFunction(_okText)) {
+          return _okText();
+        }
+        if (_okText) {
+          Message.success(_okText);
+          // Message({ message: _okText, type: 'success', duration: 60000 });
+        }
+        return true;
+      }
+      if (_.isFunction(_errText)) {
+        return _errText();
+      }
+      Message.error(_errText || errmsg);
+      // Message({ message: _errText || errmsg, duration: 60000 });
+      return false;
+    };
+  },
+};
+
+Vue.use(Plugin);

+ 5 - 0
plugins/element.js

@@ -0,0 +1,5 @@
+import Vue from 'vue';
+import Element from 'element-ui';
+import 'element-ui/lib/theme-chalk/index.css';
+// 注册element-ui组件
+Vue.use(Element);

+ 4 - 0
plugins/meta.js

@@ -0,0 +1,4 @@
+import Vue from 'vue';
+import Meta from 'vue-meta';
+
+Vue.use(Meta);

+ 26 - 0
plugins/naf-dict.js

@@ -0,0 +1,26 @@
+/**
+ * 字典数据处理插件
+ */
+
+import Vue from 'vue';
+import _ from 'lodash';
+import assert from 'assert';
+
+const Plugin = {
+  install(vue, options) {
+    // 4. 添加实例方法
+    vue.prototype.$dict = function(codeType, code) {
+      assert(_.isString(codeType));
+      const state = this.$store.state.naf.dict;
+      if (!state) {
+        throw new Error("can't find store for naf dict");
+      }
+      if (_.isString(code)) {
+        return (state.codes[codeType] && state.codes[codeType][code]) || code;
+      } else {
+        return state.items[codeType];
+      }
+    };
+  },
+};
+Vue.use(Plugin);

+ 6 - 0
plugins/nut-ui.js

@@ -0,0 +1,6 @@
+import Vue from 'vue';
+import { Toast } from '@nutui/nutui';
+// import '@nutui/nutui/dist/nutui.css';
+
+// 注册nut-ui组件
+Toast.install(Vue); // 按需加载

+ 65 - 0
plugins/stomp.js

@@ -0,0 +1,65 @@
+/**
+ * 基于WebStomp的消息处理插件
+ */
+
+import Vue from 'vue';
+import _ from 'lodash';
+import assert from 'assert';
+import { Client } from '@stomp/stompjs/esm5/client';
+
+const Plugin = {
+  install(Vue, options) {
+    assert(_.isObject(options));
+    if (options.debug && !_.isFunction(options.debug)) {
+      options.debug = str => {
+        console.log(str);
+      };
+    }
+    assert(_.isString(options.brokerURL));
+    if (!options.brokerURL.startsWith('ws://')) {
+      options.brokerURL = `ws://${location.host}${options.brokerURL}`;
+    }
+
+    // 3. 注入组件
+    Vue.mixin({
+      beforeDestroy: function() {
+        if (this.$stompClient) {
+          this.$stompClient.deactivate();
+          delete this.$stompClient;
+        }
+      },
+    });
+
+    // 4. 添加实例方法
+    Vue.prototype.$stomp = function(subscribes = {}) {
+      // connect to mq
+      const client = new Client(options);
+      client.onConnect = frame => {
+        // Do something, all subscribes must be done is this callback
+        // This is needed because this will be executed after a (re)connect
+        console.log('[stomp] connected');
+        Object.keys(subscribes)
+          .filter(p => _.isFunction(subscribes[p]))
+          .forEach(key => {
+            client.subscribe(key, subscribes[key]);
+          });
+      };
+
+      client.onStompError = frame => {
+        // Will be invoked in case of error encountered at Broker
+        // Bad login/passcode typically will cause an error
+        // Complaint brokers will set `message` header with a brief message. Body may contain details.
+        // Compliant brokers will terminate the connection after any error
+        console.log('Broker reported error: ' + frame.headers['message']);
+        console.log('Additional details: ' + frame.body);
+      };
+
+      client.activate();
+
+      this.$stompClient = client;
+    };
+  },
+};
+export default () => {
+  Vue.use(Plugin, Vue.config.stomp);
+};

+ 6 - 6
src/main.js

@@ -1,12 +1,12 @@
-import Vue from "vue";
-import App from "./App.vue";
-import router from "./router";
-import store from "./store";
+import Vue from 'vue';
+import App from './App.vue';
+import router from './router';
+import store from './store';
 
 Vue.config.productionTip = false;
 
 new Vue({
   router,
   store,
-  render: h => h(App)
-}).$mount("#app");
+  render: h => h(App),
+}).$mount('#app');

+ 129 - 0
utils/axios-wrapper.js

@@ -0,0 +1,129 @@
+/* eslint-disable require-atomic-updates */
+/* eslint-disable no-console */
+/* eslint-disable no-param-reassign */
+
+import _ from 'lodash';
+import Axios from 'axios';
+import { Util, Error } from 'naf-core';
+import { Loading } from 'element-ui';
+import UserUtil from './user-util';
+
+const { trimData, isNullOrUndefined } = Util;
+const { ErrorCode } = Error;
+
+let currentRequests = 0;
+
+export default class AxiosWrapper {
+  constructor({ baseUrl = '', unwrap = true } = {}) {
+    this.baseUrl = baseUrl;
+    this.unwrap = unwrap;
+  }
+
+  // 替换uri中的参数变量
+  static merge(uri, query = {}) {
+    if (!uri.includes(':')) {
+      return uri;
+    }
+    const keys = [];
+    const regexp = /\/:([a-z0-9_]+)/gi;
+    let res;
+    // eslint-disable-next-line no-cond-assign
+    while ((res = regexp.exec(uri)) != null) {
+      keys.push(res[1]);
+    }
+    keys.forEach(key => {
+      if (!isNullOrUndefined(query[key])) {
+        uri = uri.replace(`:${key}`, query[key]);
+      }
+    });
+    return uri;
+  }
+
+  $get(uri, query, options) {
+    return this.$request(uri, null, query, options);
+  }
+
+  $delete(uri, query, options = {}) {
+    return this.$request(uri, null, query, { ...options, method: 'delete' });
+  }
+
+  $post(uri, data = {}, query, options) {
+    return this.$request(uri, data, query, options);
+  }
+
+  async $request(uri, data, query, options) {
+    if (!uri) console.error('uri不能为空');
+    // TODO: 合并query和options
+    if (_.isObject(query) && _.isObject(options)) {
+      const params = query.params ? query.params : query;
+      options = { ...options, params };
+    } else if (_.isObject(query) && !query.params) {
+      options = { params: query };
+    } else if (_.isObject(query) && query.params) {
+      options = query;
+    }
+    if (!options) options = {};
+    if (options.params) options.params = trimData(options.params);
+    const url = AxiosWrapper.merge(uri, options.params);
+
+    currentRequests += 1;
+    const loadingInstance = Loading.service({ fullscreen: true, spinner: 'el-icon-loading' });
+
+    try {
+      const axios = Axios.create({
+        baseURL: this.baseUrl,
+      });
+      if (UserUtil.token) {
+        axios.defaults.headers.common.Authorization = UserUtil.token;
+      }
+      let res = await axios.request({
+        method: isNullOrUndefined(data) ? 'get' : 'post',
+        url,
+        data,
+        responseType: 'json',
+        ...options,
+      });
+      res = res.data || {};
+      const { errcode, errmsg, details } = res;
+      if (errcode) {
+        console.warn(`[${uri}] fail: ${errcode}-${errmsg} ${details}`);
+        return res;
+      }
+      // unwrap data
+      if (this.unwrap) {
+        res = _.omit(res, ['errcode', 'errmsg', 'details']);
+        const keys = Object.keys(res);
+        if (keys.length === 1 && keys.includes('data')) {
+          res = res.data;
+        }
+      }
+      return res;
+    } catch (err) {
+      let errmsg = '接口请求失败,请稍后重试';
+      if (err.response) {
+        const { status, data = {} } = err.response;
+        console.log(err.response);
+        if (status === 401) errmsg = '用户认证失败,请重新登录';
+        if (status === 403) errmsg = '当前用户不允许执行该操作';
+        if (status === 400 && data.errcode) {
+          const { errcode, errmsg, details } = data;
+          console.warn(`[${uri}] fail: ${errcode}-${errmsg} ${details}`);
+          return data;
+        }
+        if (data && data.error) {
+          const { status, error, message } = data;
+          console.warn(`[${uri}] fail: ${status}: ${error}-${message}`);
+          return { errcode: status || ErrorCode.SERVICE_FAULT, errmsg: error, details: message };
+        }
+      }
+      console.error(`[AxiosWrapper] 接口请求失败: ${err.config && err.config.url} - ${err.message}`);
+      return { errcode: ErrorCode.SERVICE_FAULT, errmsg, details: err.message };
+    } finally {
+      currentRequests -= 1;
+      if (currentRequests <= 0) {
+        currentRequests = 0;
+        loadingInstance.close();
+      }
+    }
+  }
+}

+ 18 - 0
utils/filters.js

@@ -0,0 +1,18 @@
+/* eslint-disable func-names */
+/* eslint-disable no-param-reassign */
+import _ from 'lodash';
+import moment from 'moment';
+
+export function dict(value, codes) {
+  if (!value) return '';
+  value = value.toString();
+  if (codes) {
+    value = _.get(codes, [value]) || value;
+  }
+  return value;
+}
+
+export function date(value, formmat) {
+  if (!value) return '';
+  return moment(value).format(formmat);
+}

+ 124 - 0
utils/store.js

@@ -0,0 +1,124 @@
+import Vue from 'vue';
+import Vuex from 'vuex';
+
+Vue.use(Vuex);
+
+const files = require.context('@/store', true, /^\.\/(?!-)[^.]+\.(js|mjs)$/);
+const filenames = files.keys();
+
+// Store
+let storeData = {};
+
+// Check if {dir.store}/index.js exists
+const indexFilename = filenames.find(filename => filename.includes('./index.'));
+
+if (indexFilename) {
+  storeData = getModule(indexFilename);
+}
+
+// If store is not an exported method = modules store
+if (typeof storeData !== 'function') {
+  // Store modules
+  if (!storeData.modules) {
+    storeData.modules = {};
+  }
+
+  for (const filename of filenames) {
+    let name = filename.replace(/^\.\//, '').replace(/\.(js|mjs)$/, '');
+    if (name === 'index') continue;
+
+    const namePath = name.split(/\//);
+
+    name = namePath[namePath.length - 1];
+    if (['state', 'getters', 'actions', 'mutations'].includes(name)) {
+      const module = getModuleNamespace(storeData, namePath, true);
+      appendModule(module, filename, name);
+      continue;
+    }
+
+    // If file is foo/index.js, it should be saved as foo
+    const isIndex = name === 'index';
+    if (isIndex) {
+      namePath.pop();
+    }
+
+    const module = getModuleNamespace(storeData, namePath);
+    const fileModule = getModule(filename);
+
+    name = namePath.pop();
+    module[name] = module[name] || {};
+
+    // if file is foo.js, existing properties take priority
+    // because it's the least specific case
+    if (!isIndex) {
+      module[name] = Object.assign({}, fileModule, module[name]);
+      module[name].namespaced = true;
+      continue;
+    }
+
+    // if file is foo/index.js we want to overwrite properties from foo.js
+    // but not from appended mods like foo/actions.js
+    const appendedMods = {};
+    if (module[name].appends) {
+      appendedMods.appends = module[name].appends;
+      for (const append of module[name].appends) {
+        appendedMods[append] = module[name][append];
+      }
+    }
+
+    module[name] = Object.assign({}, module[name], fileModule, appendedMods);
+    module[name].namespaced = true;
+  }
+}
+
+// createStore
+export const createStore =
+  storeData instanceof Function
+    ? storeData
+    : () => {
+        return new Vuex.Store(
+          Object.assign(
+            {
+              strict: process.env.NODE_ENV !== 'production',
+            },
+            storeData,
+            {
+              state: storeData.state instanceof Function ? storeData.state() : {},
+            }
+          )
+        );
+      };
+
+// Dynamically require module
+function getModule(filename) {
+  const file = files(filename);
+  const module = file.default || file;
+  if (module.commit) {
+    throw new Error('[nuxt] store/' + filename.replace('./', '') + ' should export a method which returns a Vuex instance.');
+  }
+  if (module.state && typeof module.state !== 'function') {
+    throw new Error('[nuxt] state should be a function in store/' + filename.replace('./', ''));
+  }
+  return module;
+}
+
+function getModuleNamespace(storeData, namePath, forAppend = false) {
+  if (namePath.length === 1) {
+    if (forAppend) {
+      return storeData;
+    }
+    return storeData.modules;
+  }
+  const namespace = namePath.shift();
+  storeData.modules[namespace] = storeData.modules[namespace] || {};
+  storeData.modules[namespace].namespaced = true;
+  storeData.modules[namespace].modules = storeData.modules[namespace].modules || {};
+  return getModuleNamespace(storeData.modules[namespace], namePath, forAppend);
+}
+
+function appendModule(module, filename, name) {
+  const file = files(filename);
+  module.appends = module.appends || [];
+  module.appends.push(name);
+  module[name] = file.default || file;
+}

+ 46 - 0
utils/user-util.js

@@ -0,0 +1,46 @@
+/* eslint-disable no-console */
+export default {
+  get user() {
+    const val = sessionStorage.getItem('user');
+    try {
+      if (val) return JSON.parse(val);
+    } catch (err) {
+      console.error(err);
+    }
+    return null;
+  },
+  set user(userinfo) {
+    sessionStorage.setItem('user', JSON.stringify(userinfo));
+    if (this.unit) {
+      this.lastUnit = this.unit;
+    }
+  },
+  get token() {
+    return sessionStorage.getItem('token') || '';
+  },
+  set token(token) {
+    sessionStorage.setItem('token', token);
+  },
+  get isGuest() {
+    return !this.user || this.user.role === 'guest';
+  },
+  get unit() {
+    if (!this.user || this.user.iss !== 'platform') return undefined;
+    const unit = this.user.sub.split('@', 2)[1] || 'master';
+    return unit;
+  },
+  get platform() {
+    const unit = this.unit || this.lastUnit;
+    return unit === 'master' ? 'master' : 'school';
+  },
+  set lastUnit(value) {
+    localStorage.setItem('unit', value);
+  },
+  get lastUnit() {
+    return localStorage.getItem('unit');
+  },
+  save({ userinfo, token }) {
+    this.user = userinfo;
+    this.token = token;
+  },
+};