feat: 添加前端代码

This commit is contained in:
fit2cloud-chenyw 2021-02-18 18:14:45 +08:00
parent 0ad50b12ac
commit 4bed85dd9b
84 changed files with 3476 additions and 0 deletions

17
fit2cloud-view/PENDING.md Normal file
View File

@ -0,0 +1,17 @@
# 功能
- [x] 登录页面
- [x] 整体布局
- [x] 路由基础框架
- [x] 左侧菜单
- [x] API基础框架
- [x] mock
- [x] 国际化及规范
- [x] 加载FIT2CLOUD UI
- [ ] 权限控制
- [ ] 完整Demo路由及页面
- [ ] 权限 Demo
- [ ] Form Demo
- [ ] Table Demo
- [ ] 说明文档

1
fit2cloud-view/README.md Normal file
View File

@ -0,0 +1 @@
# FIT2CLOUD 应用模板

View File

@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

View File

@ -0,0 +1,54 @@
const Mock = require('mockjs')
const {param2Obj} = require('./utils')
const user = require('./user')
const mocks = [
...user,
]
// for front mock
// please use it cautiously, it will redefine XMLHttpRequest,
// which will cause many of your third-party libraries to be invalidated(like progress event).
function mockXHR() {
// mock patch
// https://github.com/nuysoft/Mock/issues/300
Mock.XHR.prototype.proxy_send = Mock.XHR.prototype.send
Mock.XHR.prototype.send = function () {
if (this.custom.xhr) {
this.custom.xhr.withCredentials = this.withCredentials || false
if (this.responseType) {
this.custom.xhr.responseType = this.responseType
}
}
this.proxy_send(...arguments)
}
function XHR2ExpressReqWrap(respond) {
return function (options) {
let result;
if (respond instanceof Function) {
const {body, type, url} = options
// https://expressjs.com/en/4x/api.html#req
result = respond({
method: type,
body: JSON.parse(body),
query: param2Obj(url)
})
} else {
result = respond
}
return Mock.mock(result)
}
}
for (const i of mocks) {
Mock.mock(new RegExp(i.url), i.type || 'get', XHR2ExpressReqWrap(i.response))
}
}
module.exports = {
mocks,
mockXHR
}

View File

@ -0,0 +1,51 @@
const {success, error} = require("./result-holder")
const licenses = {
valid: {
status: "valid",
license: {
"corporation": "xxxxxxxxxxxx",
"expired": "2030-07-03",
"licenseVersion": "v2",
"product": "cmp",
"generateTime": "1593763389356",
"edition": "Enterprise",
"count": 11
},
message: ""
},
invalid: {
status: "invalid",
license: {},
message: "license has invalid"
},
expired: {
status: "expired",
license: {
"corporation": "xxxxxxxxxxxx",
"expired": "2020-07-03",
"licenseVersion": "v2",
"product": "cmp",
"generateTime": "1593763389356",
"edition": "Enterprise",
"count": 11
},
message: "license has expired since 2020-07-03"
},
}
module.exports = [
{
url: '/samples/license/save',
type: 'post',
response: config => {
const {license} = config.body
const data = licenses[license];
if (!data) {
return success(licenses.invalid)
}
return success(data)
}
},
]

View File

@ -0,0 +1,81 @@
const chokidar = require('chokidar')
const bodyParser = require('body-parser')
const chalk = require('chalk')
const path = require('path')
const Mock = require('mockjs')
const mockDir = path.join(process.cwd(), 'mock')
function registerRoutes(app) {
let mockLastIndex
const { mocks } = require('./index.js')
const mocksForServer = mocks.map(route => {
return responseFake(route.url, route.type, route.response)
})
for (const mock of mocksForServer) {
app[mock.type](mock.url, mock.response)
mockLastIndex = app._router.stack.length
}
const mockRoutesLength = Object.keys(mocksForServer).length
return {
mockRoutesLength: mockRoutesLength,
mockStartIndex: mockLastIndex - mockRoutesLength
}
}
function unregisterRoutes() {
Object.keys(require.cache).forEach(i => {
if (i.includes(mockDir)) {
delete require.cache[require.resolve(i)]
}
})
}
// for mock server
const responseFake = (url, type, respond) => {
return {
url: new RegExp(`${process.env.VUE_APP_BASE_API}${url}`),
type: type || 'get',
response(req, res) {
console.log('request invoke:' + req.path)
res.json(Mock.mock(respond instanceof Function ? respond(req, res) : respond))
}
}
}
module.exports = app => {
// parse app.body
// https://expressjs.com/en/4x/api.html#req.body
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({
extended: true
}))
const mockRoutes = registerRoutes(app)
var mockRoutesLength = mockRoutes.mockRoutesLength
var mockStartIndex = mockRoutes.mockStartIndex
// watch files, hot reload mock server
chokidar.watch(mockDir, {
ignored: /mock-server/,
ignoreInitial: true
}).on('all', (event, path) => {
if (event === 'change' || event === 'add') {
try {
// remove mock routes stack
app._router.stack.splice(mockStartIndex, mockRoutesLength)
// clear routes cache
unregisterRoutes()
const mockRoutes = registerRoutes(app)
mockRoutesLength = mockRoutes.mockRoutesLength
mockStartIndex = mockRoutes.mockStartIndex
console.log(chalk.magentaBright(`\n > Mock Server hot reload success! changed ${path}`))
} catch (error) {
console.log(chalk.redBright(error))
}
}
})
}

View File

@ -0,0 +1,20 @@
class ResultHolder {
constructor(success, data, message) {
this.success = success;
this.data = data;
this.message = message;
}
}
const success = data => {
return new ResultHolder(true, data)
}
const error = message => {
return new ResultHolder(false, undefined, message)
}
module.exports = {
success,
error
}

View File

@ -0,0 +1,97 @@
const {success, error} = require("./result-holder")
const TOKEN_KEY = "App-Token"
/* 前后端分离用Token验证登录*/
const tokens = {
admin: {
token: 'admin-token'
},
editor: {
token: 'editor-token'
},
readonly: {
token: 'readonly-token'
}
}
const users = {
'admin-token': {
id: "admin",
name: 'Administrator',
email: "admin@fit2cloud.com",
roles: ['admin'],
language: "zh-CN"
},
'editor-token': {
id: "editor",
name: 'Editor',
email: "editor@fit2cloud.com",
roles: ['editor'],
language: "zh-CN"
},
'readonly-token': {
id: "readonly",
name: 'Readonly User',
email: "readonly@fit2cloud.com",
roles: ['readonly'],
language: "zh-CN"
}
}
module.exports = [
// user login
{
url: '/samples/user-token/login',
type: 'post',
response: config => {
const {username} = config.body
const {token} = tokens[username];
// mock error
if (!token) {
return error("用户名或密码错误")
}
return success(token)
}
},
// get user info
{
url: '/samples/user-token/info',
type: 'get',
response: (config) => {
let token = config.headers[TOKEN_KEY]
const info = users[token]
// mock error
if (!info) {
return error("无法获取用户[" + token + "]详细信息")
}
return success(info)
}
},
// update user info
{
url: '/samples/user/info/update',
type: 'put',
response: config => {
let token = config.headers[TOKEN_KEY]
const {language} = config.body
users[token].language = language;
return success(users[token])
}
},
// user logout
{
url: '/samples/user/logout',
type: 'post',
response: () => {
// do something
return success()
}
}
]

View File

@ -0,0 +1,98 @@
const {success, error} = require("./result-holder")
/* 前后端不分离的接口用Session验证登录*/
let currentUser
const users = {
admin: {
id: "admin",
name: 'Administrator',
email: "admin@fit2cloud.com",
roles: ['admin'],
language: "zh-CN"
},
editor: {
id: "editor",
name: 'Editor',
email: "editor@fit2cloud.com",
roles: ['editor'],
language: "zh-CN"
},
readonly: {
id: "readonly",
name: 'Readonly User',
email: "readonly@fit2cloud.com",
roles: ['readonly'],
language: "zh-CN"
}
}
module.exports = [
// user login
{
url: '/samples/user/login',
type: 'post',
response: config => {
const {username} = config.body
const user = users[username];
// mock error
if (!user) {
return error("用户名或密码错误")
}
currentUser = user;
return success(user)
}
},
{
url: '/samples/user/is-login',
type: 'get',
response: () => {
if (currentUser) {
return success()
} else {
return error()
}
}
},
// get user info
{
url: '/samples/user/current',
type: 'get',
response: () => {
const info = currentUser
// mock error
if (!info) {
return error("用户未登录")
}
return success(info)
}
},
// update user info
{
url: '/samples/user/info/update',
type: 'put',
response: config => {
const {language} = config.body
if (currentUser) {
currentUser.language = language;
}
return success(currentUser)
}
},
// user logout
{
url: '/samples/user/logout',
type: 'post',
response: () => {
currentUser = undefined;
return success()
}
}
]

View File

@ -0,0 +1,48 @@
/**
* @param {string} url
* @returns {Object}
*/
function param2Obj(url) {
const search = decodeURIComponent(url.split('?')[1]).replace(/\+/g, ' ')
if (!search) {
return {}
}
const obj = {}
const searchArr = search.split('&')
searchArr.forEach(v => {
const index = v.indexOf('=')
if (index !== -1) {
const name = v.substring(0, index)
const val = v.substring(index + 1, v.length)
obj[name] = val
}
})
return obj
}
/**
* This is just a simple version of deep copy
* Has a lot of edge cases bug
* If you want to use a perfect deep copy, use lodash's _.cloneDeep
* @param {Object} source
* @returns {Object}
*/
function deepClone(source) {
if (!source && typeof source !== 'object') {
throw new Error('error arguments', 'deepClone')
}
const targetObj = source.constructor === Array ? [] : {}
Object.keys(source).forEach(keys => {
if (source[keys] && typeof source[keys] === 'object') {
targetObj[keys] = deepClone(source[keys])
} else {
targetObj[keys] = source[keys]
}
})
return targetObj
}
module.exports = {
param2Obj,
deepClone
}

View File

@ -0,0 +1,58 @@
{
"name": "samples",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.34",
"@fortawesome/free-brands-svg-icons": "^5.15.2",
"@fortawesome/free-regular-svg-icons": "^5.15.2",
"@fortawesome/free-solid-svg-icons": "^5.15.2",
"@fortawesome/vue-fontawesome": "^2.0.2",
"axios": "^0.21.1",
"core-js": "^3.6.5",
"element-ui": "^2.15.0",
"fit2cloud-ui": "^0.1.2",
"js-cookie": "^2.2.1",
"normalize.css": "^8.0.1",
"nprogress": "^0.2.0",
"vue": "^2.6.11",
"vue-i18n": "^8.22.4",
"vuex": "^3.6.0"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"babel-eslint": "^10.1.0",
"eslint": "^6.7.2",
"eslint-plugin-vue": "^6.2.2",
"mockjs": "^1.1.0",
"sass": "^1.32.5",
"sass-loader": "^10.1.1",
"vue-template-compiler": "^2.6.11"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "babel-eslint"
},
"rules": {}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> 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>

View File

@ -0,0 +1,12 @@
<template>
<div id="app">
<router-view/>
</div>
</template>
<script>
export default {
name: 'App',
}
</script>

View File

@ -0,0 +1,7 @@
import {post} from "@/plugins/request"
export function saveLicense(data) {
return post("/samples/license/save", data)
}

View File

@ -0,0 +1,21 @@
/* 前后端分离的登录方式 */
import {get, post, put} from "@/plugins/request"
export function login(data) {
return post("/samples/user-token/login", data)
}
export function logout() {
return post("/samples/user-token/logout")
}
export function getCurrentUser() {
return get("/samples/user-token/current")
}
export function updateInfo(data) {
return put("/samples/user-token/update", data)
}

View File

@ -0,0 +1,25 @@
/* 前后端不分离的登录方式 */
import {get, post, put} from "@/plugins/request"
export function login(data) {
return post("/samples/user/login", data)
}
export function logout() {
return post("/samples/user/logout")
}
export function isLogin() {
return get("/samples/user/is-login")
}
export function getCurrentUser() {
return get("/samples/user/current")
}
export function updateInfo(id, data) {
return put("/samples/user/info/update/" + id, data)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

View File

@ -0,0 +1,57 @@
/* cyrillic-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-display: swap;
src: url(Roboto-Regular.ttf) format('truetype');
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-display: swap;
src: url(Roboto-Regular.ttf) format('truetype');
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-display: swap;
src: url(Roboto-Regular.ttf) format('truetype');
unicode-range: U+1F00-1FFF;
}
/* greek */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-display: swap;
src: url(Roboto-Regular.ttf) format('truetype');
unicode-range: U+0370-03FF;
}
/* vietnamese */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-display: swap;
src: url(Roboto-Regular.ttf) format('truetype');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-display: swap;
src: url(Roboto-Regular.ttf) format('truetype');
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-display: swap;
src: url(Roboto-Regular.ttf) format('truetype');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 KiB

View File

@ -0,0 +1,62 @@
<template>
<el-menu :unique-opened="true"
:default-active="language"
class="header-menu"
text-color="inherit"
mode="horizontal">
<el-submenu index="1" popper-class="header-menu-popper">
<template slot="title">
<font-awesome-icon class="language-icon" :icon="['fas', 'globe']"/>
<span>{{ languageMap[language] }}</span>
</template>
<el-menu-item v-for="(value, key) in languageMap" :key="key" :index="key" @click="setLanguage(key)">
<span>{{ value }}</span>
<i class="el-icon-check" v-if="key === language"/>
</el-menu-item>
</el-submenu>
</el-menu>
</template>
<script>
export default {
name: "LanguageSwitch",
data() {
return {
languageMap: {
"zh-CN": "中文(简体)",
"en-US": "English",
}
};
},
computed: {
language() {
return this.$store.getters.language
}
},
methods: {
setLanguage(lang) {
this.$store.dispatch('user/setLanguage', lang).then(() => {
// do something
})
}
}
}
</script>
<style lang="scss">
@import "~@/styles/business/header-menu.scss";
.header-menu {
.language-icon {
width: 24px;
}
}
.header-menu-popper {
.el-icon-check {
margin-left: 10px;
color: $--color-primary;
}
}
</style>

View File

@ -0,0 +1,48 @@
<template>
<el-menu class="header-menu" text-color="inherit" mode="horizontal">
<el-submenu index="none" popper-class="header-menu-popper">
<template slot="title">
<span>{{ name }}</span>
</template>
<el-menu-item @click="toPersonal">
<span>{{ $t('commons.personal.personal_information') }}</span>
</el-menu-item>
<el-menu-item @click="toHelp">
<span>{{ $t('commons.personal.help_documentation') }}</span>
</el-menu-item>
<el-menu-item @click="logout">
<span>{{ $t('commons.personal.exit_system') }}</span>
</el-menu-item>
</el-submenu>
</el-menu>
</template>
<script>
import {mapGetters} from "vuex";
export default {
name: "PersonalSetting",
computed: {
...mapGetters([
'name'
])
},
methods: {
toPersonal() {
},
toHelp() {
window.open("https://github.com/fit2cloud-ui/samples", "_blank");
},
logout() {
this.$store.dispatch("user/logout").then(() => {
location.reload()
})
}
}
}
</script>
<style lang="scss">
@import "~@/styles/business/header-menu.scss";
</style>

View File

@ -0,0 +1,67 @@
<template>
<div class="horizontal-header">
<div class="header-left">
<sidebar-toggle-button/>
</div>
<div class="header-right">
<div class="navbar-item">
<language-switch/>
</div>
<div class="navbar-item">
<personal-setting/>
</div>
</div>
</div>
</template>
<script>
import SidebarToggleButton from "@/components/layout/sidebar/SidebarToggleButton";
import LanguageSwitch from "@/business/app-layout/header-components/LanguageSwitch";
import PersonalSetting from "@/business/app-layout/header-components/PersonalSetting";
export default {
name: "HorizontalHeader",
components: {PersonalSetting, LanguageSwitch, SidebarToggleButton}
}
</script>
<style lang="scss" scoped>
@import "~@/styles/common/mixins";
.horizontal-header {
@include flex-row(flex-start, center);
position: relative;
height: 100%;
// 线
//&:after {
// content: "";
// position: absolute;
// bottom: 0;
// left: 0;
// height: 1px;
// width: 100%;
// background-color: #D5D5D5;
//}
.header-left {
@include flex-row(flex-start, center);
position: relative;
height: 100%;
}
.header-right {
@include flex-row(flex-end, center);
flex: auto;
height: 100%;
.navbar-item {
color: #2E2E2E;
}
.navbar-item + .navbar-item {
margin-left: 20px;
}
}
}
</style>

View File

@ -0,0 +1,18 @@
<template>
<layout>
<template v-slot:header>
<horizontal-header/>
</template>
</layout>
</template>
<script>
import Layout from "@/components/layout";
import HorizontalHeader from "@/business/app-layout/horizontal-layout/HorizontalHeader";
export default {
name: "HorizontalLayout",
components: {HorizontalHeader, Layout},
}
</script>

View File

@ -0,0 +1,15 @@
<template>
<div>
{{ $t('commons.message_box.prompt') }}
</div>
</template>
<script>
export default {
name: "dashboard"
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,13 @@
<template>
<div></div>
</template>
<script>
export default {
name: "ClickOutsideDemo"
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,49 @@
<template>
<div class="app-container">
<h2>切换admineditorreadonly用户看到不同的内容</h2>
<br/>
<div class="permission-block admin" v-permission="['admin']">
需要admin角色才能看到, 指令设置v-permission="['admin']"
</div>
<div class="permission-block editor" v-permission="['editor']">
需要editor角色才能看到, 指令设置v-permission="['editor']"
</div>
<div class="permission-block admin-editor" v-permission="['admin', 'editor']">
需要admin或者editor角色才能看到, 指令设置v-permission="['admin', 'editor']"
</div>
<div class="permission-block">
任何人都能看到
</div>
</div>
</template>
<script>
export default {
name: "PermissionDemo"
}
</script>
<style lang="scss" scoped>
.permission-block {
width: 100%;
height: 40px;
line-height: 40px;
font-size: 18px;
&.admin {
color: #2D61A2;
}
&.admin-editor {
color: mix(#2D61A2, #FFBA00)
}
&.editor {
color: #FFBA00;
}
}
</style>

View File

@ -0,0 +1,239 @@
<template>
<div class="login-background">
<div class="login-container">
<el-row type="flex" v-loading="loading">
<el-col :span="12">
<el-form :model="form" :rules="rules" ref="form" size="default">
<div class="login-logo">
<img src="../../assets/RackShift-black.png" alt="">
</div>
<div class="login-title">
{{ $t('login.title') }}
</div>
<div class="login-border"></div>
<div class="login-welcome">
{{ $t('login.welcome') }}
</div>
<div class="login-form">
<el-form-item prop="username">
<el-input v-model="form.username" :placeholder="$t('login.username')" autofocus/>
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" :placeholder="$t('login.password')"
show-password maxlength="30" show-word-limit
autocomplete="new-password"/>
</el-form-item>
</div>
<div class="login-btn">
<el-button type="primary" class="submit" @click="submit('form')" size="default">
{{ $t('commons.button.login') }}
</el-button>
</div>
<div class="login-msg">
{{ msg }}
</div>
</el-form>
</el-col>
<el-col :span="12">
<div class="login-image"></div>
</el-col>
</el-row>
</div>
</div>
</template>
<script>
export default {
name: "Login",
data() {
return {
loading: false,
form: {
username: 'admin',
password: '123456'
},
rules: {
username: [
{required: true, message: this.$tm('commons.validate.input', 'login.username'), trigger: 'blur'},
],
password: [
// 便
{required: true, message: this.$tm('commons.validate.input', 'login.password'), trigger: 'blur'},
{min: 6, max: 30, message: this.$t('commons.validate.limit', [6, 30]), trigger: 'blur'}
]
},
msg: '',
redirect: undefined,
otherQuery: {}
}
},
watch: {
$route: {
handler: function (route) {
const query = route.query
if (query) {
this.redirect = query.redirect
this.otherQuery = this.getOtherQuery(query)
}
},
immediate: true
}
},
created: function () {
document.addEventListener("keydown", this.watchEnter);
},
destroyed() {
document.removeEventListener("keydown", this.watchEnter);
},
methods: {
watchEnter(e) {
let keyCode = e.keyCode;
if (keyCode === 13) {
this.submit('form');
}
},
submit(form) {
this.$refs[form].validate((valid) => {
if (valid) {
this.loading = true;
this.$store.dispatch('user/login', this.form).then(() => {
this.$router.push({path: this.redirect || '/', query: this.otherQuery})
this.loading = false
}).catch(error => {
this.msg = error.message
this.loading = false
})
} else {
return false;
}
});
},
getOtherQuery(query) {
return Object.keys(query).reduce((acc, cur) => {
if (cur !== 'redirect') {
acc[cur] = query[cur]
}
return acc
}, {})
}
}
}
</script>
<style lang="scss" scoped>
@import "../../styles/common/variables";
@mixin login-center {
display: flex;
justify-content: center;
align-items: center;
}
.login-background {
background-color: $--background-color-base;
height: 100%;
@include login-center;
}
.login-container {
min-width: 900px;
width: 1280px;
height: 520px;
background-color: #FFFFFF;
@media only screen and (max-width: 1280px) {
width: 900px;
height: 380px;
}
.login-logo {
margin-top: 30px;
margin-left: 30px;
@media only screen and (max-width: 1280px) {
margin-top: 20px;
}
img {
height: 45px;
}
}
.login-title {
margin-top: 50px;
font-size: 32px;
letter-spacing: 0;
text-align: center;
color: #999999;
@media only screen and (max-width: 1280px) {
margin-top: 20px;
}
}
.login-border {
height: 2px;
margin: 20px auto 20px;
position: relative;
width: 80px;
background: $--color-primary;
@media only screen and (max-width: 1280px) {
margin: 10px auto 10px;
}
}
.login-welcome {
margin-top: 50px;
font-size: 14px;
color: #999999;
letter-spacing: 0;
line-height: 18px;
text-align: center;
@media only screen and (max-width: 1280px) {
margin-top: 20px;
}
}
.login-form {
margin-top: 30px;
padding: 0 40px;
@media only screen and (max-width: 1280px) {
margin-top: 10px;
}
& ::v-deep .el-input__inner {
border-radius: 0;
}
}
.login-btn {
margin-top: 40px;
padding: 0 40px;
@media only screen and (max-width: 1280px) {
margin-top: 20px;
}
.submit {
width: 100%;
border-radius: 0;
}
}
.login-msg {
margin-top: 10px;
padding: 0 40px;
color: $--color-danger;
text-align: center;
}
.login-image {
background: url(../../assets/login-desc.png) no-repeat;
background-size: cover;
width: 100%;
height: 520px;
@media only screen and (max-width: 1280px) {
height: 380px;
}
}
}
</style>

View File

@ -0,0 +1,13 @@
<template>
<div>参数设置</div>
</template>
<script>
export default {
name: "ParamsSetting"
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,46 @@
<template>
<layout-content>
<dynamic-table>
<fu-search-bar quick-placeholder=" 姓名/邮箱 搜索" :components="components" @exec="search"/>
</dynamic-table>
</layout-content>
</template>
<script>
import DynamicTable from "@/components/dynamic-table";
import LayoutContent from "@/components/layout/LayoutContent";
export default {
name: "UserManagement",
components: {LayoutContent, DynamicTable},
data() {
return {
components: [
{field: "name", label: "姓名", component: "FuInputComponent", defaultOperator: "eq"},
{field: "email", label: "Email", component: "FuInputComponent"},
{
field: "status",
label: "状态",
component: "FuSelectComponent",
options: [
{label: "运行中", value: "Running"},
{label: "成功", value: "Success"},
{label: "失败", value: "Fail"}
],
multiple: true
},
{field: "create_time", label: "创建时间", component: "FuDateTimeComponent"},
]
}
},
methods: {
search(condition) {
console.log(condition)
}
}
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,59 @@
<template>
<el-row type="flex" justify="end">
<div class="table-pagination">
<el-pagination
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="currentPage"
:page-sizes="pageSizes"
:page-size="pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="total">
</el-pagination>
</div>
</el-row>
</template>
<script>
export default {
name: "TablePagination",
props: {
page: Object,
currentPage: {
type: Number,
default: 1
},
pageSize: {
type: Number,
default: 5
},
pageSizes: {
type: Array,
default: function () {
return [5, 10, 20, 50, 100]
}
},
total: {
type: Number,
default: 0
},
change: Function
},
methods: {
handleSizeChange: function (size) {
this.$emit('update:pageSize', size)
this.change();
},
handleCurrentChange(current) {
this.$emit('update:currentPage', current)
this.change();
}
}
}
</script>
<style lang="scss" scoped>
.table-pagination {
padding-top: 20px;
}
</style>

View File

@ -0,0 +1,33 @@
<template>
<div class="dynamic-table">
<div class="dynamic-table__header" v-if="$slots.header || header">
<slot name="header">{{ header }}</slot>
</div>
<div class="dynamic-table__body">
<slot></slot>
</div>
</div>
</template>
<script>
export default {
name: "DynamicTable",
props: {
header: {},
}
}
</script>
<style lang="scss" scoped>
@import "~@/styles/common/mixins.scss";
.dynamic-table {
}
.dynamic-table__header {
@include flex-row(flex-start, center);
height: 60px;
font-size: 20px;
}
</style>

View File

@ -0,0 +1,27 @@
<template>
<div class="content-container">
<slot></slot>
</div>
</template>
<script>
export default {
name: "LayoutContent"
}
</script>
<style lang="scss">
@import "~@/styles/common/variables";
.content-container {
transition: 0.3s;
color: $--color-text-primary;
background-color: #FFFFFF;
overflow: auto;
height: 100%;
padding: 20px;
border-radius: 4px;
box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 14%);
box-sizing: border-box;
}
</style>

View File

@ -0,0 +1,11 @@
<template>
<header class="header-container">
<slot></slot>
</header>
</template>
<script>
export default {
name: "LayoutHeader",
}
</script>

View File

@ -0,0 +1,16 @@
<template>
<el-container class="main-container" direction="vertical">
<slot></slot>
</el-container>
</template>
<script>
export default {
name: "LayoutMain",
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,25 @@
<template>
<aside :class="['sidebar-container', {'is-collapse': isCollapse}]">
<slot>
<sidebar/>
</slot>
</aside>
</template>
<script>
import {mapGetters} from "vuex";
import Sidebar from "@/components/layout/sidebar";
export default {
name: "LayoutSidebar",
components: {Sidebar},
computed: {
...mapGetters([
'sidebar'
]),
isCollapse() {
return !this.sidebar.opened
},
}
}
</script>

View File

@ -0,0 +1,20 @@
<template>
<main class="view-container">
<transition name="el-fade-in" mode="out-in">
<keep-alive>
<router-view :key="key"/>
</keep-alive>
</transition>
</main>
</template>
<script>
export default {
name: "LayoutView",
computed: {
key() {
return this.$route.path
}
}
}
</script>

View File

@ -0,0 +1,66 @@
<template>
<el-container class="layout-container">
<slot>
<layout-sidebar/>
<layout-main>
<layout-header>
<slot name="header"></slot>
</layout-header>
<layout-view/>
</layout-main>
</slot>
</el-container>
</template>
<script>
import LayoutSidebar from "@/components/layout/LayoutSidebar";
import LayoutMain from "@/components/layout/LayoutMain";
import LayoutHeader from "@/components/layout/LayoutHeader";
import LayoutView from "@/components/layout/LayoutView";
export default {
name: "Layout",
components: {LayoutView, LayoutHeader, LayoutMain, LayoutSidebar},
}
</script>
<style lang="scss">
@import "~@/styles/common/variables";
.layout-container {
min-width: 1024px;
height: 100%;
background-color: $layout-bg-color;
}
.main-container {
position: relative;
}
.sidebar-container {
position: relative;
transition: width 0.28s;
width: $sidebar-open-width;
min-width: $sidebar-open-width;
background-color: $sidebar-bg-color;
background-image: $sidebar-bg-gradient;
&.is-collapse {
width: $sidebar-close-width;
min-width: $sidebar-close-width;
}
}
.header-container {
height: $header-height;
padding: 0 $header-padding;
}
.view-container {
display: block;
flex: auto;
overflow: auto;
box-sizing: border-box;
padding: $view-padding;
}
</style>

View File

@ -0,0 +1,26 @@
export default {
computed: {
device() {
return this.$store.state.app.device
}
},
mounted() {
// In order to fix the click on menu on the ios device will trigger the mouseleave bug
// https://github.com/PanJiaChen/vue-element-admin/issues/1135
this.fixBugIniOS()
},
methods: {
fixBugIniOS() {
const $subMenu = this.$refs.subMenu
if ($subMenu) {
const handleMouseleave = $subMenu.handleMouseleave
$subMenu.handleMouseleave = (e) => {
if (this.device === 'mobile') {
return
}
handleMouseleave(e)
}
}
}
}
}

View File

@ -0,0 +1,37 @@
<script>
export default {
name: 'MenuItem',
functional: true,
props: {
icon: {
type: String,
default: ''
},
title: {
type: String,
default: ''
}
},
render(h, context) {
const {icon, title} = context.props
const vnodes = []
if (icon) {
vnodes.push(<i class={[icon, 'sub-el-icon']}/>)
}
if (title) {
vnodes.push(<span slot='title'>{(title)}</span>)
}
return vnodes
}
}
</script>
<style scoped>
.sub-el-icon {
font-size: 20px;
width: 20px;
height: 20px;
}
</style>

View File

@ -0,0 +1,43 @@
<template>
<component :is="type" v-bind="linkProps(to)">
<slot/>
</component>
</template>
<script>
import {isExternal} from '@/utils/validate'
export default {
props: {
to: {
type: String,
required: true
}
},
computed: {
isExternal() {
return isExternal(this.to)
},
type() {
if (this.isExternal) {
return 'a'
}
return 'router-link'
}
},
methods: {
linkProps(to) {
if (this.isExternal) {
return {
href: to,
target: '_blank',
rel: 'noopener'
}
}
return {
to: to
}
}
}
}
</script>

View File

@ -0,0 +1,85 @@
<template>
<div class="sidebar-logo-container" :class="{'collapse':collapse}">
<transition name="sidebar-logo-fade">
<router-link v-if="collapse" key="collapse" class="sidebar-logo-link" to="/">
<img v-if="collapseLogo" :src="collapseLogo" class="sidebar-logo" alt="Sidebar Logo">
</router-link>
<router-link v-else key="expand" class="sidebar-logo-link" to="/">
<img v-if="logo" :src="logo" class="sidebar-logo" alt="Sidebar Logo">
</router-link>
</transition>
</div>
</template>
<script>
export default {
name: 'SidebarLogo',
props: {
collapse: {
type: Boolean,
required: true
}
},
data() {
return {
title: 'FIT2CLOUD',
logo: require('@/assets/RackShift-white.png'),
collapseLogo: require('@/assets/RackShift-assist-white.png')
}
}
}
</script>
<style lang="scss">
@import "~@/styles/common/variables";
.sidebar-logo-container {
position: relative;
height: $header-height;
line-height: $header-height;
overflow: hidden;
&:after {
content: "";
position: absolute;
bottom: 0;
right: #{$sidebar-close-width / 4};
height: 1px;
width: calc(100% - #{$sidebar-close-width / 2});
background-color: hsla(0, 0%, 100%, .5);
}
& .sidebar-logo-link {
display: flex;
align-items: center;
height: 100%;
width: 100%;
& .sidebar-logo {
margin-left: #{$sidebar-close-width / 4};
height: $logo-height;
vertical-align: middle;
}
}
&.collapse {
.sidebar-logo-link {
justify-content: center;
}
.sidebar-logo {
margin: 0;
}
}
}
.sidebar-logo-fade-enter-active {
transition: opacity 0.3s;
transition-delay: 0.1s
}
.sidebar-logo-fade-enter,
.sidebar-logo-fade-leave-to {
opacity: 0;
}
</style>

View File

@ -0,0 +1,97 @@
<template>
<div v-if="!item.hidden">
<template
v-if="hasOneShowingChild(item.children,item) && (!onlyOneChild.children||onlyOneChild.noShowingChildren)&&!item.alwaysShow">
<app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path)">
<el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{'submenu-title-no-dropdown':!isNest}">
<item :icon="onlyOneChild.meta.icon||(item.meta&&item.meta.icon)" :title="$tk(onlyOneChild.meta.title)"/>
</el-menu-item>
</app-link>
</template>
<el-submenu v-else ref="subMenu" :index="resolvePath(item.path)" popper-append-to-body
popper-class="sidebar-popper">
<template slot="title">
<item v-if="item.meta" :icon="item.meta && item.meta.icon" :title="$tk(item.meta.title)"/>
</template>
<sidebar-item
v-for="child in item.children"
:key="child.path"
:is-nest="true"
:item="child"
:base-path="resolvePath(child.path)"
class="nest-menu"
/>
</el-submenu>
</div>
</template>
<script>
import path from 'path'
import {isExternal} from '@/utils/validate'
import Item from './Item'
import AppLink from './Link'
import FixiOSBug from './FixiOSBug'
export default {
name: 'SidebarItem',
components: {Item, AppLink},
mixins: [FixiOSBug],
props: {
// route object
item: {
type: Object,
required: true
},
isNest: {
type: Boolean,
default: false
},
basePath: {
type: String,
default: ''
}
},
data() {
// To fix https://github.com/PanJiaChen/vue-admin-template/issues/237
// TODO: refactor with render function
this.onlyOneChild = null
return {}
},
methods: {
hasOneShowingChild(children = [], parent) {
const showingChildren = children.filter(item => {
if (item.hidden) {
return false
} else {
// Temp set(will be used if only has one showing child)
this.onlyOneChild = item
return true
}
})
// When there is only one child router, the child router is displayed by default
if (showingChildren.length === 1) {
return true
}
// Show parent if there are no child router to display
if (showingChildren.length === 0) {
this.onlyOneChild = {...parent, path: '', noShowingChildren: true}
return true
}
return false
},
resolvePath(routePath) {
if (isExternal(routePath)) {
return routePath
}
if (isExternal(this.basePath)) {
return this.basePath
}
return path.resolve(this.basePath, routePath)
}
}
}
</script>

View File

@ -0,0 +1,31 @@
<template>
<el-button circle :class="['sidebar-toggle-button', icon]" @click="toggle"></el-button>
</template>
<script>
import {mapGetters} from "vuex";
export default {
name: "SidebarToggleButton",
methods: {
toggle() {
this.$store.dispatch('app/toggleSideBar');
}
},
computed: {
...mapGetters([
'sidebar'
]),
icon() {
return this.sidebar.opened ? "el-icon-s-fold" : "el-icon-s-unfold"
},
}
}
</script>
<style lang="scss" scoped>
.sidebar-toggle-button.el-button {
font-size: 18px;
}
</style>

View File

@ -0,0 +1,266 @@
<template>
<div class="sidebar">
<logo :collapse="isCollapse"/>
<el-scrollbar wrap-class="scrollbar-wrapper">
<el-menu
:default-active="activeMenu"
:collapse="isCollapse"
:collapse-transition="false"
:unique-opened="false"
mode="vertical">
<sidebar-item v-for="route in permission_routes" :key="route.path" :item="route" :base-path="route.path"/>
</el-menu>
</el-scrollbar>
</div>
</template>
<script>
import {mapGetters} from 'vuex'
import SidebarItem from './SidebarItem'
import Logo from "@/components/layout/sidebar/Logo";
export default {
name: "Sidebar",
components: {Logo, SidebarItem},
computed: {
...mapGetters([
'permission_routes',
'sidebar'
]),
activeMenu() {
const route = this.$route
const {meta, path} = route
if (meta.activeMenu) {
return meta.activeMenu
}
return path
},
isCollapse() {
return !this.sidebar.opened
},
}
}
</script>
<style lang="scss">
@import "~@/styles/common/variables";
@mixin sidebar-base-item {
padding-left: 10px !important;
border-radius: 2px;
color: $menu-color;
}
@mixin menu-item {
@include sidebar-base-item;
margin: 2px 10px;
line-height: $menu-height;
height: $menu-height;
}
@mixin submenu-item {
@include sidebar-base-item;
margin: 2px 10px;
line-height: $submenu-height;
height: $submenu-height;
}
@mixin popper-submenu-item {
@include sidebar-base-item;
margin: 2px 0;
line-height: $submenu-height;
height: $submenu-height;
}
@mixin menu-item-active {
color: $menu-active-color;
background-color: $menu-active-bg-color;
}
@mixin submenu-item-active {
color: $submenu-active-color;
background-color: $submenu-active-bg-color;
}
.sidebar {
height: 100%;
.horizontal-collapse-transition {
transition: 0s width ease-in-out, 0s padding-left ease-in-out, 0s padding-right ease-in-out;
}
.el-scrollbar {
box-sizing: border-box;
padding: 10px 0;
height: calc(100% - #{$header-height});
.el-scrollbar__bar {
&.is-vertical {
right: 0;
}
&.is-horizontal {
display: none;
}
}
.scrollbar-wrapper {
height: 100%;
overflow-x: hidden;
}
}
a {
width: 100%;
overflow: hidden;
}
.el-menu {
border: none;
height: 100%;
width: 100%;
background-color: $menu-bg-color;
.submenu-title-no-dropdown {
@include menu-item;
&:hover {
background-color: $menu-bg-color-hover;
}
&.is-active {
@include menu-item-active;
}
}
.el-submenu {
.el-submenu__title {
@include menu-item;
&:hover {
background-color: $menu-bg-color-hover;
}
}
&.is-active {
.el-submenu__title, {
@include menu-item-active;
.sub-el-icon, span {
color: #FFF;
}
}
}
.el-menu-item {
@include submenu-item;
&:hover {
background-color: $menu-bg-color-hover;
}
&.is-active {
@include submenu-item-active
}
}
}
.nest-menu, .el-submenu__title, .submenu-title-no-dropdown {
span {
padding-left: 30px;
}
.sub-el-icon {
margin-right: 10px;
+ span {
padding-left: 0;
}
}
}
&.el-menu--collapse {
.el-tooltip {
padding: 0 !important;
text-align: center;
line-height: $menu-height;
}
.el-submenu__title {
padding-left: 20px !important;
}
.submenu-title-no-dropdown, .el-submenu__title {
max-width: 60px;
text-align: center;
span {
display: none;
}
.sub-el-icon {
margin: 0;
}
.el-submenu__icon-arrow {
display: none;
}
}
}
}
}
.sidebar-popper {
& > .el-menu {
display: block;
background-color: $sidebar-bg-color;
.sub-el-icon {
margin-right: 12px;
margin-left: -2px;
}
}
.nest-menu .el-submenu > .el-submenu__title, .el-menu-item {
&.is-active {
@include submenu-item-active
}
@include popper-submenu-item;
span {
padding-left: 30px;
}
.sub-el-icon {
margin-right: 10px;
+ span {
padding-left: 0;
}
}
&:hover {
background-color: $menu-bg-color-hover;
}
}
> .el-menu--popup {
max-height: 100vh;
overflow-y: auto;
&::-webkit-scrollbar-track-piece {
background: #d3dce6;
}
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-thumb {
background: #99a9bf;
border-radius: 20px;
}
}
}
</style>

View File

@ -0,0 +1,12 @@
<script>
export default {
created() {
const { params, query } = this.$route
const { path } = params
this.$router.replace({ path: '/' + path, query })
},
render: function(h) {
return h() // avoid warning message
}
}
</script>

View File

@ -0,0 +1,8 @@
import ClickOutside from "element-ui/src/utils/clickoutside";
const install = function (Vue) {
Vue.directive("click-outside", ClickOutside)
}
ClickOutside.install = install
export default ClickOutside

View File

@ -0,0 +1,11 @@
import ClickOutside from "element-ui/src/utils/clickoutside";
import permission from "@/directive/permission";
export default {
install(Vue) {
Vue.directive('click-outside', ClickOutside);
Vue.directive('permission', permission);
}
}

View File

@ -0,0 +1,29 @@
import store from '@/store'
function checkPermission(el, binding) {
const {value} = binding
const roles = store.getters && store.getters.roles
if (value && value instanceof Array) {
if (value.length > 0) {
const permissionRoles = value
const hasPermission = roles.some(role => {
return permissionRoles.includes(role)
})
if (!hasPermission) {
el.parentNode && el.parentNode.removeChild(el)
}
}
}
}
export default {
inserted(el, binding) {
checkPermission(el, binding)
},
update(el, binding) {
checkPermission(el, binding)
}
}

View File

@ -0,0 +1,75 @@
import Vue from 'vue';
import VueI18n from "vue-i18n";
Vue.use(VueI18n);
// 直接加载翻译的语言文件
const LOADED_LANGUAGES = ['zh-CN', 'en-US'];
const LANG_FILES = require.context('./lang', true, /\.js$/)
// 自动加载lang目录下语言文件默认只加载LOADED_LANGUAGES中规定的语言文件其他的语言动态加载
const messages = LANG_FILES.keys().reduce((messages, path) => {
const value = LANG_FILES(path)
const lang = path.replace(/^\.\/(.*)\.\w+$/, '$1');
if (LOADED_LANGUAGES.includes(lang)) {
messages[lang] = value.default
}
return messages;
}, {})
export const getLanguage = () => {
let language = localStorage.getItem('language')
if (!language) {
language = (navigator.language || navigator.browserLanguage).toLowerCase()
}
return language;
}
const i18n = new VueI18n({
locale: getLanguage(),
messages,
});
const importLanguage = lang => {
if (!LOADED_LANGUAGES.includes(lang)) {
return import(`./lang/${lang}`).then(response => {
i18n.mergeLocaleMessage(lang, response.default);
LOADED_LANGUAGES.push(lang);
return Promise.resolve(lang)
})
}
return Promise.resolve(lang)
}
const setLang = lang => {
localStorage.setItem('language', lang)
i18n.locale = lang;
}
export const setLanguage = lang => {
if (i18n.locale !== lang) {
importLanguage(lang).then(setLang)
}
}
// 组合翻译例如key为'请输入{0}'keys为login.username则自动将keys翻译并替换到{0} {1}...
Vue.prototype.$tm = function (key, ...keys) {
let values = [];
for (const k of keys) {
values.push(i18n.t(k))
}
return i18n.t(key, values);
};
// 忽略警告不存在Key直接返回Key
Vue.prototype.$tk = function (key) {
const hasKey = i18n.te(key)
if (hasKey) {
return i18n.t(key)
}
return key
};
// 设置当前语言LOADED_LANGUAGES以外的翻译文件会自动从lang目录获取(如果有的话), 如果不需要动态加载语言文件直接用setLang
Vue.prototype.$setLang = setLanguage;
export default i18n;

View File

@ -0,0 +1,13 @@
import el from "element-ui/lib/locale/lang/en";
import fu from "fit2cloud-ui/src/locale/lang/en_US"; // 加载fit2cloud的内容
const message = {
// TODO
}
export default {
...el,
...fu,
...message
};

View File

@ -0,0 +1,54 @@
import el from "element-ui/lib/locale/lang/zh-CN"; // 加载element的内容
import fu from "fit2cloud-ui/src/locale/lang/zh-CN"; // 加载fit2cloud的内容
const message = {
commons: {
message_box: {
alert: "警告",
confirm: "确认",
prompt: "提示",
},
button: {
login: "登录",
ok: "确定",
save: "保存",
delete: "删除",
cancel: "取消",
return: "返回",
},
msg: {
success: "{0}成功",
op_success: "操作成功",
save_success: "保存成功",
delete_success: "删除成功",
},
validate: {
limit: '长度在 {0} 到 {1} 个字符',
input: "请输入{0}",
select: "请选择{0}",
},
personal: {
personal_information: "个人信息",
help_documentation: "帮助文档",
exit_system: "退出系统",
}
},
login: {
username: "用户名",
password: "密码",
title: "登录 FIT2CLOUD",
welcome: "欢迎回来,请输入用户名和密码登录",
expires: '认证信息已过期,请重新登录',
},
route: {
system_setting: "系统设置",
user_management: "用户管理",
params_setting: "参数设置",
},
}
export default {
...el,
...fu,
...message
};

View File

@ -0,0 +1,10 @@
import el from "element-ui/lib/locale/lang/zh-TW";
const message = {
// TODO
}
export default {
...el,
...message
};

View File

@ -0,0 +1,12 @@
import {library} from '@fortawesome/fontawesome-svg-core'
import {fas} from '@fortawesome/free-solid-svg-icons'
import {far} from '@fortawesome/free-regular-svg-icons'
import {fab} from '@fortawesome/free-brands-svg-icons'
import {FontAwesomeIcon} from '@fortawesome/vue-fontawesome'
export default {
install(Vue) {
library.add(fas, far, fab);
Vue.component('font-awesome-icon', FontAwesomeIcon);
}
}

View File

@ -0,0 +1,33 @@
import Vue from 'vue'
import "@/styles/index.scss"
import Fit2CloudUI from 'fit2cloud-ui';
import ElementUI from 'element-ui';
import App from './App.vue'
import i18n from "./i18n";
import router from './router'
import store from './store'
import icons from './icons'
import plugins from "./plugins";
import directives from "./directive";
import "./permission"
Vue.config.productionTip = false
Vue.use(ElementUI, {
size: 'small',
i18n: (key, value) => i18n.t(key, value)
});
Vue.use(Fit2CloudUI, {
i18n: (key, value) => i18n.t(key, value)
});
Vue.use(icons);
Vue.use(plugins);
Vue.use(directives);
new Vue({
el: '#app',
i18n,
router,
store,
render: h => h(App),
})

View File

@ -0,0 +1,57 @@
import router from './router'
import store from './store'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
NProgress.configure({showSpinner: false}) // NProgress Configuration
const whiteList = ['/login'] // no redirect whitelist
const generateRoutes = async (to, from, next) => {
const hasRoles = store.getters.roles && store.getters.roles.length > 0
if (hasRoles) {
next()
} else {
try {
const {roles} = await store.dispatch('user/getCurrentUser')
const accessRoutes = await store.dispatch('permission/generateRoutes', roles)
router.addRoutes(accessRoutes)
next({...to, replace: true})
} catch (error) {
await store.dispatch('user/logout')
next(`/login?redirect=${to.path}`)
NProgress.done()
}
}
}
// 路由前置钩子,根据实际需求修改
router.beforeEach(async (to, from, next) => {
NProgress.start()
const isLogin = await store.dispatch('user/isLogin') // 或者user-token/isLogin
if (isLogin) {
if (to.path === '/login') {
next({path: '/'})
NProgress.done()
} else {
await generateRoutes(to, from, next)
}
} else {
/* has not login*/
if (whiteList.indexOf(to.path) !== -1) {
// in the free login whitelist, go directly
next()
} else {
// other pages that do not have permission to access are redirected to the login page.
next(`/login?redirect=${to.path}`)
NProgress.done()
}
}
})
router.afterEach(() => {
// finish progress bar
NProgress.done()
})

View File

@ -0,0 +1,9 @@
import message from "@/plugins/message";
import request from "@/plugins/request";
export default {
install(Vue) {
Vue.use(message);
Vue.use(request);
}
}

View File

@ -0,0 +1,71 @@
import {MessageBox, Message} from 'element-ui';
import i18n from "@/i18n";
export const $alert = (message, callback, options) => {
let title = i18n.t("common.message_box.alert");
MessageBox.alert(message, title, options).then(() => {
callback();
});
}
export const $confirm = (message, callback, options = {}) => {
let defaultOptions = {
confirmButtonText: i18n.t("common.button.ok"),
cancelButtonText: i18n.t("common.button.cancel"),
type: 'warning',
...options
}
let title = i18n.t("common.message_box.confirm");
MessageBox.confirm(message, title, defaultOptions).then(() => {
callback();
});
}
export const $success = (message, duration) => {
Message.success({
message: message,
type: "success",
showClose: true,
duration: duration || 1500
})
}
export const $info = (message, duration) => {
Message.info({
message: message,
type: "info",
showClose: true,
duration: duration || 3000
})
}
export const $warning = (message, duration) => {
Message.warning({
message: message,
type: "warning",
showClose: true,
duration: duration || 5000
})
}
export const $error = (message, duration) => {
Message.error({
message: message,
type: "error",
showClose: true,
duration: duration || 10000
})
}
export default {
install(Vue) {
// 使用$$前缀避免与Element UI的冲突
Vue.prototype.$$confirm = $confirm;
Vue.prototype.$$alert = $alert;
Vue.prototype.$success = $success;
Vue.prototype.$info = $info;
Vue.prototype.$warning = $warning;
Vue.prototype.$error = $error;
}
}

View File

@ -0,0 +1,108 @@
import axios from 'axios'
import {$alert, $error} from "./message"
import store from '@/store'
import i18n from "@/i18n";
import {TokenKey, getToken} from '@/utils/token'
const instance = axios.create({
baseURL: process.env.VUE_APP_BASE_API, // url = base url + request url
withCredentials: true,
timeout: 60000 // request timeout, default 1 min
})
// 每次请求加上Token。如果没用使用Token删除这个拦截器
instance.interceptors.request.use(
config => {
if (store.getters.token) {
config.headers[TokenKey] = getToken()
}
return config
},
error => {
console.log(error) // for debug
return Promise.reject(error)
}
)
const checkAuth = response => {
// 请根据实际需求修改
if (response.headers["authentication-status"] === "invalid" || response.status === 401) {
let message = i18n.t('login.expires');
$alert(message, () => {
store.dispatch('user/logout').then(() => {
location.reload()
})
});
}
}
const checkPermission = response => {
// 请根据实际需求修改
if (response.status === 403) {
location.href = "/403";
}
}
// 请根据实际需求修改
instance.interceptors.response.use(response => {
checkAuth(response);
return response;
}, error => {
let msg;
if (error.response) {
checkAuth(error.response);
checkPermission(error.response);
msg = error.response.data.message || error.response.data;
} else {
console.log('error: ' + error) // for debug
msg = error.message;
}
$error(msg)
return Promise.reject(error);
});
export const request = instance
/* 简化请求方法统一处理返回结果并增加loading处理这里以{success,data,message}格式的返回值为例,具体项目根据实际需求修改 */
const promise = (request, loading = {}) => {
return new Promise((resolve, reject) => {
loading.status = true;
request.then(response => {
if (response.data.success) {
resolve(response.data);
} else {
reject(response.data)
}
loading.status = false;
}).catch(error => {
reject(error)
loading.status = false;
})
})
}
export const get = (url, data, loading) => {
return promise(request({url: url, method: "get", params: data}), loading)
};
export const post = (url, data, loading) => {
return promise(request({url: url, method: "post", data}), loading)
};
export const put = (url, data, loading) => {
return promise(request({url: url, method: "put", data}), loading)
};
export const del = (url, loading) => {
return promise(request({url: url, method: "delete"}), loading)
};
export default {
install(Vue) {
Vue.prototype.$get = get;
Vue.prototype.$post = post;
Vue.prototype.$put = put;
Vue.prototype.$delete = del;
Vue.prototype.$request = request;
}
}

View File

@ -0,0 +1,69 @@
import Vue from 'vue'
import Router from 'vue-router'
// 加载modules中的路由
const modules = require.context('./modules', true, /\.js$/)
// 修复路由变更后报错的问题
const routerPush = Router.prototype.push;
Router.prototype.push = function push(location) {
return routerPush.call(this, location).catch(error => error)
}
Vue.use(Router)
import Layout from '@/business/app-layout/horizontal-layout'
export const constantRoutes = [
{
path: '/redirect',
component: Layout,
hidden: true,
children: [
{
path: '/redirect/:path(.*)',
component: () => import('@/components/redirect')
}
]
},
{
path: '/login',
component: () => import('@/business/login'),
hidden: true
},
{
path: '/',
component: Layout,
redirect: '/dashboard',
children: [
{
path: 'dashboard',
component: () => import('@/business/dashboard'),
name: 'Dashboard',
meta: {title: 'Dashboard', icon: 'el-icon-s-marketing', affix: true}
}
]
}
]
/**
* 用户登录后根据角色加载的路由
*/
export const rolesRoutes = [
...modules.keys().map(key => modules(key).default),
{path: '*', redirect: '/', hidden: true}
]
const createRouter = () => new Router({
scrollBehavior: () => ({y: 0}),
routes: constantRoutes
})
const router = createRouter()
export function resetRouter() {
const newRouter = createRouter()
router.matcher = newRouter.matcher // reset router
}
export default router

View File

@ -0,0 +1,30 @@
import Layout from "@/business/app-layout/horizontal-layout";
const Directive = {
path: '/directive',
component: Layout,
name: 'Directive',
meta: {
title: "指令示例",
icon: 'el-icon-setting',
},
children: [
{
path: 'click-outside',
component: () => import('@/business/directive/ClickOutsideDemo'),
name: "ClickOutside",
meta: {
title: "点击外部指令"
}
},
{
path: 'permission',
component: () => import('@/business/directive/PermissionDemo'),
name: "Permission",
meta: {
title: "权限指令"
}
}
]
}
export default Directive

View File

@ -0,0 +1,12 @@
import Layout from "@/business/app-layout/horizontal-layout";
const Filters = {
path: '/filters',
component: Layout,
name: 'Filters',
meta: {
title: "过滤器示例",
icon: 'el-icon-setting',
}
}
export default Filters

View File

@ -0,0 +1,33 @@
import Layout from "@/business/app-layout/horizontal-layout";
const SystemSetting = {
path: '/system-setting',
component: Layout,
name: 'SystemSetting',
meta: {
title: "route.system_setting",
icon: 'el-icon-setting',
roles: ['admin']
},
children: [
{
path: 'user-management',
component: () => import('@/business/system-setting/UserManagement'),
name: "UserManagement",
meta: {
title: "route.user_management",
roles: ['admin']
}
},
{
path: 'params-setting',
component: () => import('@/business/system-setting/ParamsSetting'),
name: "ParamsSetting",
meta: {
title: "route.params_setting",
roles: ['admin']
}
}
]
}
export default SystemSetting

View File

@ -0,0 +1,10 @@
// 根据实际需要修改
const getters = {
sidebar: state => state.app.sidebar,
name: state => state.user.name,
language: state => state.user.language,
roles: state => state.user.roles,
permission_routes: state => state.permission.routes,
license: state => state.license,
}
export default getters

View File

@ -0,0 +1,23 @@
import Vue from 'vue'
import Vuex from 'vuex'
import getters from './getters'
Vue.use(Vuex)
// 自动从modules目录下获取模块
const MODULES_FILES = require.context('./modules', true, /\.js$/)
// 模块名为js文件名例如user.js 则模块名为user
const modules = MODULES_FILES.keys().reduce((modules, modulePath) => {
const value = MODULES_FILES(modulePath)
const moduleName = modulePath.replace(/^\.\/(.*)\.\w+$/, '$1')
modules[moduleName] = value.default
return modules
}, {})
const store = new Vuex.Store({
modules,
getters
})
export default store

View File

@ -0,0 +1,50 @@
const get = () => {
return localStorage.getItem('sidebarStatus')
}
const set = value => {
localStorage.setItem('sidebarStatus', value)
}
const state = {
sidebar: {
opened: get() ? !!+get() : true
},
device: 'desktop'
}
const mutations = {
TOGGLE_SIDEBAR: state => {
state.sidebar.opened = !state.sidebar.opened
if (state.sidebar.opened) {
set(1)
} else {
set(0)
}
},
OPEN_SIDEBAR: (state) => {
set('sidebarStatus', 1)
state.sidebar.opened = true
},
CLOSE_SIDEBAR: (state) => {
set('sidebarStatus', 0)
state.sidebar.opened = false
}
}
const actions = {
toggleSideBar({commit}) {
commit('TOGGLE_SIDEBAR')
},
openSideBar({commit}) {
commit('OPEN_SIDEBAR')
},
closeSideBar({commit}) {
commit('CLOSE_SIDEBAR')
}
}
export default {
namespaced: true,
state,
mutations,
actions
}

View File

@ -0,0 +1,64 @@
import {saveLicense} from "@/api/license"
const LicenseKey = "X-License";
const Status = {
valid: "valid",
invalid: "invalid",
expired: "expired",
}
const get = () => {
return localStorage.getItem(LicenseKey)
}
const set = value => {
localStorage.setItem(LicenseKey, value)
}
const state = {
status: get(),
license: {},
message: ""
}
const mutations = {
SET_STATUS: (state, status) => {
set(LicenseKey, status)
state.status = status;
},
SET_LICENSE: (state, license) => {
state.license = license;
},
SET_MESSAGE: (state, message) => {
state.message = message;
}
}
const actions = {
saveLicense({commit}, content) {
return new Promise((resolve, reject) => {
saveLicense({license: content}).then(response => {
const {status, license, message} = response.data;
commit('SET_STATUS', status)
commit('SET_LICENSE', license)
commit('SET_MESSAGE', message)
resolve(status)
}).catch(error => {
commit('SET_STATUS', Status.invalid)
reject(error)
})
})
},
isValid({state}) {
return state.status === Status.valid
},
isExpired({state}) {
return state.status === Status.expired
}
}
export default {
namespaced: true,
state,
mutations,
actions
}

View File

@ -0,0 +1,61 @@
import {rolesRoutes, constantRoutes} from '@/router'
function hasPermission(roles, route) {
if (route.meta && route.meta.roles) {
return roles.some(role => route.meta.roles.includes(role))
} else {
return true
}
}
export function filterRolesRoutes(routes, roles) {
const res = []
routes.forEach(route => {
const tmp = {...route}
if (hasPermission(roles, tmp)) {
if (tmp.children) {
tmp.children = filterRolesRoutes(tmp.children, roles)
}
res.push(tmp)
}
})
return res
}
const state = {
routes: [],
addRoutes: []
}
const mutations = {
SET_ROUTES: (state, routes) => {
state.addRoutes = routes
state.routes = constantRoutes.concat(routes)
}
}
const actions = {
generateRoutes({commit}, roles) {
return new Promise(resolve => {
let accessedRoutes
if (roles.includes('admin')) {
// admin角色加载所有路由
accessedRoutes = rolesRoutes || []
} else {
// 其他角色加载对应角色的路由
accessedRoutes = filterRolesRoutes(rolesRoutes, roles)
}
commit('SET_ROUTES', accessedRoutes)
resolve(accessedRoutes)
})
}
}
export default {
namespaced: true,
state,
mutations,
actions
}

View File

@ -0,0 +1,97 @@
import {login, getCurrentUser, updateInfo, logout} from '@/api/user-token'
import {resetRouter} from '@/router'
import {getToken, setToken, removeToken} from '@/utils/token'
import {getLanguage, setLanguage} from "@/i18n";
/* 前后端不分离的登录办法*/
const state = {
token: getToken(),
name: "",
language: getLanguage(),
roles: []
}
const mutations = {
SET_TOKEN: (state, token) => {
state.token = token
},
SET_NAME: (state, name) => {
state.name = name
},
SET_LANGUAGE: (state, language) => {
state.language = language
setLanguage(language)
},
SET_ROLES: (state, roles) => {
state.roles = roles
}
}
const actions = {
login({commit}, userInfo) {
const {username, password} = userInfo
return new Promise((resolve, reject) => {
login({username: username.trim(), password: password}).then(response => {
let token = response.data
commit('SET_TOKEN', token)
setToken(token)
resolve(response)
}).catch(error => {
reject(error)
})
})
},
isLogin({commit}) {
return new Promise((resolve, reject) => {
let token = getToken()
if (token) {
commit('SET_TOKEN', token);
resolve(true)
} else {
reject(false)
}
});
},
getCurrentUser({commit}) {
return new Promise((resolve, reject) => {
getCurrentUser().then(response => {
const {name, roles, language} = response.data
commit('SET_NAME', name)
commit('SET_ROLES', roles)
commit('SET_LANGUAGE', language)
resolve(response.data)
}).catch(error => {
reject(error)
})
});
},
setLanguage({commit, state}, language) {
commit('SET_LANGUAGE', language)
return new Promise((resolve, reject) => {
updateInfo(state.id, {language: language}).then(response => {
resolve(response)
}).catch(error => {
reject(error)
})
})
},
logout({commit}) {
logout().then(() => {
commit('SET_TOKEN', "");
commit('SET_ROLES', [])
removeToken()
resetRouter()
})
},
}
export default {
namespaced: true,
state,
mutations,
actions
}

View File

@ -0,0 +1,99 @@
/* 前后端不分离的登录方式*/
import {login, isLogin, getCurrentUser, updateInfo, logout} from '@/api/user'
import {resetRouter} from '@/router'
import {getLanguage, setLanguage} from "@/i18n";
const state = {
login: false,
name: "",
language: getLanguage(),
roles: []
}
const mutations = {
LOGIN: (state) => {
state.login = true
},
LOGOUT: (state) => {
state.login = false
},
SET_NAME: (state, name) => {
state.name = name
},
SET_LANGUAGE: (state, language) => {
state.language = language
setLanguage(language)
},
SET_ROLES: (state, roles) => {
state.roles = roles
}
}
const actions = {
login({commit}, userInfo) {
const {username, password} = userInfo
return new Promise((resolve, reject) => {
login({username: username.trim(), password: password}).then(response => {
commit('LOGIN')
resolve(response)
}).catch(error => {
reject(error)
})
})
},
isLogin({commit}) {
return new Promise((resolve) => {
if (state.login) {
resolve(true)
return;
}
isLogin().then(() => {
commit('LOGIN')
resolve(true)
}).catch(() => {
resolve(false)
})
});
},
getCurrentUser({commit}) {
return new Promise((resolve, reject) => {
getCurrentUser().then(response => {
const {name, roles, language} = response.data
commit('SET_NAME', name)
commit('SET_ROLES', roles)
commit('SET_LANGUAGE', language)
resolve(response.data)
}).catch(error => {
reject(error)
})
});
},
setLanguage({commit, state}, language) {
commit('SET_LANGUAGE', language)
return new Promise((resolve, reject) => {
updateInfo(state.id, {language: language}).then(response => {
resolve(response)
}).catch(error => {
reject(error)
})
})
},
logout({commit}) {
logout().then(() => {
commit('LOGOUT')
commit('SET_ROLES', [])
resetRouter()
})
},
}
export default {
namespaced: true,
state,
mutations,
actions
}

View File

@ -0,0 +1,54 @@
@import "~@/assets/font/Roboto/index.css";
html {
height: 100%;
box-sizing: border-box;
}
body {
font-family: Roboto, Helvetica, PingFang SC, Arial, sans-serif;
font-size: 14px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
height: 100%;
}
#app {
height: 100%;
}
:focus {
outline: none;
}
a:active {
outline: none;
}
a,
a:focus,
a:hover {
cursor: pointer;
color: inherit;
text-decoration: none;
}
// 滚动条整体部分
::-webkit-scrollbar {
width: 6px; // 纵向滚动条宽度
height: 6px; // 横向滚动条高度
}
// 滑块
::-webkit-scrollbar-thumb {
border-radius: 5px;
background-color: #4A4B4D;
}
// 轨道
::-webkit-scrollbar-track {
border-radius: 5px;
background-color: transparent;
}

View File

@ -0,0 +1,39 @@
@import "~@/styles/common/variables.scss";
.header-menu {
min-width: 150px;
color: #3E3E3D;
&.el-menu {
background-color: transparent;
&.el-menu--horizontal {
border: none;
.el-submenu__title {
border: none;
min-width: 150px;
height: 40px;
line-height: 40px;
}
}
}
}
.header-menu-popper {
color: #3E3E3D;
.el-menu--popup {
min-width: 150px;
}
.el-menu-item {
&.is-active {
color: $--color-primary;
}
&:hover {
background-color: #D5D5D5;
}
}
}

View File

@ -0,0 +1,15 @@
@mixin flex-row($justify: flex-start, $align: stretch) {
display: flex;
@if $justify != flex-start {
justify-content: $justify;
}
@if $align != stretch {
align-items: $align;
}
}
@mixin click-active-scale($scale:0.95) {
&:active {
transform: scale($scale);
}
}

View File

@ -0,0 +1,48 @@
/* Element 变量 */
$--color-primary: #447DF7;
$--color-success: #87CB16;
$--color-warning: #FFA534;
$--color-danger: #FB404B;
$--box-shadow-light: 0 1px 4px 0 rgb(0 0 0 / 14%);
$--color-text-primary: #3c4858;
/* layout */
$layout-bg-color: #F2F2F2;
/* sidebar */
$sidebar-open-width: 260px;
$sidebar-close-width: 80px;
$sidebar-bg-color: #30373d;
$sidebar-bg-gradient: linear-gradient(to bottom right, #30373D, #3E3E3D);
/* menu */
$menu-color: #BFCBD9;
$menu-active-color: #FFF;
$menu-active-bg-color: rgb(200 200 200 / 20%);
$menu-bg-color: transparent;
$menu-bg-color-hover: #4A4B4D;
$menu-height: 50px;
$submenu-height: 40px;
$submenu-active-color: #FFF;
$submenu-active-bg-color: $menu-active-bg-color;
/* logo */
$logo-height: 40px;
$logo-bg-color: #4E5051;
/* header */
$header-height: 60px;
$header-padding: 30px;
/* main */
$view-padding: 15px;
/* fit2cloud-ui的variables加载了element-ui的变量 */
@import "~fit2cloud-ui/src/styles/common/variables";
:export {
theme: $--color-primary;
}

View File

@ -0,0 +1,4 @@
@import '~normalize.css/normalize.css';
@import "./common/variables";
@import "~fit2cloud-ui/src/styles";
@import "./business/app";

View File

@ -0,0 +1,15 @@
import Cookies from 'js-cookie'
export const TokenKey = 'App-Token' // 自行修改
export function getToken() {
return Cookies.get(TokenKey)
}
export function setToken(token) {
return Cookies.set(TokenKey, token)
}
export function removeToken() {
return Cookies.remove(TokenKey)
}

View File

@ -0,0 +1,3 @@
export function isExternal(path) {
return /^(https?:|mailto:|tel:)/.test(path)
}

View File

@ -0,0 +1,37 @@
const path = require('path')
function resolve(dir) {
return path.join(__dirname, dir)
}
module.exports = {
productionSourceMap: true,
// 使用mock-server
devServer: {
port: 8080,
open: true,
overlay: {
warnings: false,
errors: true
},
before: require('./mock/mock-server.js')
},
// 不使用mock-server直接连接开发服务器
// devServer: {
// port: 8080,
// proxy: {
// ['^(?!/login)']: {
// target: 'http://localhost:8081',
// ws: true,
// }
// }
// },
configureWebpack: {
devtool: 'source-map',
resolve: {
alias: {
'@': resolve('src')
}
}
}
};

View File

@ -0,0 +1,23 @@
####文件命名:
- html 小写字母+横线,例如:index.htmlorg-list.html
- js 小写字母+横线,例如:i18n.jsen-US.js
- vue 驼峰命名首字母大写例如Login.vueHeaderUser.vue
####变量命名:
- 常量 大写字母加下划线,例如:const ROLE_ADMIN='admin'
- 变量 驼峰命名首字母小写例如let namelet currentProject
- 方法 驼峰命名首字母小写例如function open(){}function openDialog()
####Vue组件:
- 导出名称 驼峰命名首字母大写以Ms开头例如MsUser
####样式规范:
- 控件的样式写在vue文件的<style scoped></style>中
- 公共样式多个控件使用写在单独的scss文件中
- 命名 小写字母+横线,例如.menu.header-menu#header-top
####格式要求:
- 遵循.editorconfig
####Vue风格指南:
- https://cn.vuejs.org/v2/style-guide/

View File

@ -0,0 +1,74 @@
# 国际化文件书写规范
### 文件内容
每个语言文件由element-ui的国际化内容和自定义国际化内容组成以zh_CN.js为例
```js
import el from "element-ui/lib/locale/lang/zh-CN";
const message = {
...
}
export default {
...el, // element-ui的国际化内容
...message // 自定义内容
};
```
### 自定义内容
自定义部分按照业务模块划分通用的写在commons内例如
```js
const message = {
commons: { // 通用
...
},
login: { // 登录
...
},
... // 其他模块
}
```
### 层级结构
按照业务模块划分后仍然可以按照子业务或功能再进行划分但每个业务模块下不要超过3层例如
```js
const message = {
user_manager: {
user_list: { // 用户列表
name: "姓名",
search: {
... // 用户列表查询
},
... // 用户列表
},
user_edit: {
... // 编辑用户
}
},
... // 其他模块
}
```
### Key命名
所有Key的命名必须采用英文单词的方式命名多个单词之间用下划线( _ )连接尽量让人一看就知道这个key代表的意思 例如user_list
```js
const message = {
user_manager: {
user_list: {
...
},
user_edit: {}
},
}
```

View File

@ -0,0 +1,33 @@
# 目录结构
```text
├── public // 静态资源
│ ├── favicon.icon // 图标
│ └── index.html // 入口html
│ ├── mock // 项目mock 模拟数据
├── src // 源代码
│ ├── api // 所有请求
│ ├── assets // 主题 字体等静态资源
│ ├── business // 业务组件
│ ├── components // 全局公用组件
│ ├── directive // 全局指令
│ ├── filters // 全局 filter
│ ├── icons // 项目所有 svg icons
│ ├── lang // 国际化 language
│ ├── plugins // Vue插件
│ ├── router // 路由
│ ├── store // 全局 store管理
│ ├── styles // 全局样式
│ ├── utils // 全局公用方法
│ ├── App.vue // 入口应用组件
│ ├── main.js // 入口js
│ └── permission.js // 权限管理
├── .editorconfig // 代码规范配置
├── .gitignore // git 忽略项
├── favicon.ico // favicon图标
├── index.html // html模板
├── vue.config.js // 构建配置
└── package.json // package.json
```