unfinished WebUI

This commit is contained in:
Jacky 2021-03-16 13:30:07 +08:00
parent 0ad1b3af4b
commit 070b34be8a
69 changed files with 13932 additions and 140 deletions

15
.editorconfig Executable file
View file

@ -0,0 +1,15 @@
root = true
[*]
block_comment_start = /*
block_comment_end = */
charset = utf-8
indent_style = space
indent_size = 4
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[README.md]
indent_style = space
indent_size = 4

3
.gitignore vendored
View file

@ -2,5 +2,6 @@
.idea .idea
database.db database.db
server/tmp/main server/tmp/main
node_modules
dist

View file

@ -0,0 +1,4 @@
VUE_APP_API_ROOT = /
VUE_APP_API_CLIENT = ''
VUE_APP_API_CLIENT_SECRET = ''
VUE_APP_RECAPTCHA_SITEKEY = ''

View file

@ -0,0 +1,4 @@
VUE_APP_API_ROOT = mock
VUE_APP_API_CLIENT = ''
VUE_APP_API_CLIENT_SECRET = ''
VUE_APP_RECAPTCHA_SITEKEY = ''

23
nginx-ui-frontend/.gitignore vendored Normal file
View file

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

View file

@ -0,0 +1,29 @@
# nginx-ui-frontend
## Project setup
```
yarn install
```
### Compiles and hot-reloads for development
```
yarn serve
```
### Compiles and minifies for production
```
yarn build
```
### Lints and fixes files
```
yarn lint
```
### Customize configuration
See [Configuration Reference](https://cli.vuejs.org/config/).

View file

@ -0,0 +1,9 @@
const resolve = dir => require('path').join(__dirname, dir)
module.exports = {
resolve: {
alias: {
'@': resolve('src')
}
}
}

View file

@ -0,0 +1,8 @@
module.exports = {
presets: [
"@vue/cli-plugin-babel/preset"
],
"plugins": [
["import", {"libraryName": "ant-design-vue", "libraryDirectory": "es", "style": true}, "syntax-dynamic-import"]
],
}

View file

@ -0,0 +1,60 @@
{
"name": "nginx-ui-frontend",
"version": "0.1.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"lint": "vue-cli-service lint"
},
"dependencies": {
"ant-design-vue": "^1.7.3",
"axios": "^0.21.1",
"core-js": "^3.9.0",
"vue": "^2.6.11",
"vue-router": "^3.5.1",
"vuex": "^3.6.2",
"vuex-persist": "^3.1.3"
},
"devDependencies": {
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-plugin-router": "~4.5.0",
"@vue/cli-plugin-vuex": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"babel-eslint": "^10.1.0",
"babel-plugin-import": "^1.13.3",
"eslint": "^6.7.2",
"eslint-plugin-vue": "^6.2.2",
"less": "^3.11.1",
"less-loader": "^5.0.0",
"moment": "^2.24.0",
"nprogress": "^0.2.0",
"vue-template-compiler": "^2.6.11",
"vue-cli-plugin-generate-build-id": "0.1.0"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
},
"extends": [
"plugin:vue/essential",
"eslint:recommended"
],
"parserOptions": {
"parser": "babel-eslint"
},
"rules": {}
},
"postcss": {
"plugins": {
"autoprefixer": {}
}
},
"browserslist": [
"> 1%",
"last 2 versions",
"not dead"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<meta content="IE=edge" http-equiv="X-UA-Compatible">
<meta content="width=device-width,initial-scale=1.0" name="viewport">
<link href="<%= BASE_URL %>favicon.ico" rel="icon">
<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>
<style lang="less">
#app {
height: 100%;
}
</style>

View file

@ -0,0 +1,23 @@
import http from "@/lib/http"
const base_url = '/config'
const config = {
get_list(params) {
return http.get(base_url + 's', {params: params})
},
get(id) {
return http.get(base_url + '/' + id)
},
save(id = null, data) {
return http.post(base_url + (id ? '/' + id : ''), data)
},
destroy(id) {
return http.delete(base_url + '/' + id)
}
}
export default config

View file

@ -0,0 +1,35 @@
import http from "@/lib/http"
const base_url = '/domain'
const domain = {
get_list(params) {
return http.get(base_url + 's', {params: params})
},
get(id) {
return http.get(base_url + '/' + id)
},
save(id = null, data) {
return http.post(base_url + (id ? '/' + id : ''), data)
},
destroy(id) {
return http.delete(base_url + '/' + id)
},
enable(name) {
return http.post(base_url + '/' + name + '/enable')
},
disable(name) {
return http.post(base_url + '/' + name + '/disable')
},
get_template(name) {
return http.get('template/' + name)
}
}
export default domain

View file

@ -0,0 +1,7 @@
import domain from "./domain"
import config from "./config"
export default {
domain,
config
}

View file

@ -0,0 +1,58 @@
@media (prefers-color-scheme: dark) {
@import '~ant-design-vue/dist/antd.less';
@black_bg: #1e1f20;
@background-color-base: @black_bg;
@background-color-light: @black_bg;
@btn-default-bg: @black_bg;
@black_content: #28292c;
@text-color: #fff;
@text-color-secondary: #bdbdbd;
@heading-color: #fff;
@body-background: @black_bg;
@component-background: @black_content;
@layout-body-background: @black_bg;
@layout-header-background: @black_bg;
@layout-sider-background: @black_content;
@ant-layout-sider-light: @black_content;
@menu-bg: @black_content;
@layout-trigger-background: @black_content;
@layout-sider-background-light: @black_content;
@layout-trigger-background-light: @black_content;
@item-active-bg: #161717;
@link-color: #fff;
@table-row-hover-bg: @black_bg;
@table-selected-row-bg: @black_bg;
@input-bg: @black_bg;
@disabled-bg: #363636;
@btn-default-bg: @black_bg;
@table-header-bg: @black_bg;
@border-color-base: #666666;
@disabled-color: #bcbcbc;
@radio-button-bg: @black_bg;
@checkbox-check-color: @black_bg;
@popover-bg: @black_bg;
.ant-select-dropdown-menu-item:hover {
background: @black_bg;
}
.ant-layout-sider {
background-color: @black_content;
}
.ant-checkbox-indeterminate {
.ant-checkbox-inner {
background-color: transparent !important;
}
}
.ant-transfer-list-content-item:not(.ant-transfer-list-content-item-disabled):hover,
.ant-transfer-list-content-item-highlight-enter, .ant-transfer-list-content-item-highlight-enter-active {
background-color: @black_bg !important;
}
}

View file

@ -0,0 +1,49 @@
/* Copyright 2019 - 2020 0xJacky */
@dark: ~"(prefers-color-scheme: dark)";
p {
padding: 0 0 10px 0;
}
.ant-layout-sider {
background-color: #ffffff;
@media @dark {
background-color: #28292c;
}
box-shadow: 2px 0 6px rgba(0, 21, 41, 0.01);
}
@media @dark {
.ant-checkbox-indeterminate {
.ant-checkbox-inner {
background-color: transparent !important;
}
}
}
.ant-layout-header {
padding: 0;
}
.ant-table-small {
font-size: 13px;
}
.ant-card-bordered {
border: unset;
}
.header-notice-wrapper .ant-tabs-content {
max-height: 250px;
}
.header-notice-wrapper .ant-tabs-tabpane-active {
overflow-y: scroll;
}
.ant-layout-footer {
@media (max-width: 320px) {
padding: 10px;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,150 @@
/* 基准 */
* {
-webkit-tap-highlight-color: transparent
}
body {
margin: 0;
padding: 0;
//cursor: default;
//-webkit-user-select: none;
-webkit-text-size-adjust: none;
-webkit-font-smoothing: antialiased;
font-family: "Myriad Pro", "PingFang SC", "Helvetica Neue", Helvetica, Arial, sans-serif
}
::selection {
background: #5aaafc;
//color: unset;
}
::-moz-selection {
background: #5aaafc;
//color: unset;
}
a {
color: #444;
@media (prefers-color-scheme: dark) {
color: #f5f5f5;
}
text-decoration: none;
-webkit-transition: all .3s ease;
-moz-transition: all .3s ease;
-ms-transition: all .3s ease;
-o-transition: all .3s ease;
transition: all .3s ease;
&:hover {
color: #19B5FE;
text-decoration: none;
}
&:focus {
outline: 0;
text-decoration: none;
}
}
b {
font-weight: normal
}
i {
font-style: normal
}
p {
margin: 0
}
h1, h2, h4 {
margin: 0;
font-weight: normal;
}
h3 {
font-size: 17px;
font-weight: 400
}
h4 {
font-size: 15px;
}
ul {
margin: 0;
padding: 0;
list-style: none
}
img {
border: 0
}
code {
font-family: Menlo, monospace;
font-size: 90%
}
p > code {
padding: 0.2em 0.4em;
background: #e1e9ed
}
pre {
text-align: left;
overflow-x: scroll;
background-color: #f5f5f5;
padding: 10pt 15pt;
font-size: 13px;
border-radius: 4px;
color: #333;
border: 1px solid rgba(0, 0, 0, 0.15)
}
button {
box-shadow: unset !important;
}
.clear {
clear: both
}
.center {
display: flex;
justify-content: center;
}
.limit-max {
max-width: 1000px;
}
.ant-notification {
z-index: 2100
}
.ant-comment-actions {
margin-top: unset;
}
.ant-input:hover {
}
#nprogress .bar {
z-index: 2030 !important;
background: #1890ff !important;
}
.grecaptcha-badge {
visibility: hidden;
}
.std-border-radius {
border-radius: 25px;
}
.ant-card {
box-shadow: 0 0 30px rgba(200, 200, 200, 0.25);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View file

@ -0,0 +1,46 @@
<template>
<a-breadcrumb class="breadcrumb">
<a-breadcrumb-item v-for="(item, index) in breadList" :key="item.name">
<router-link
v-if="item.name != name && index != 1"
:to="{ path: item.path === '' ? '/' : item.path }"
>{{ item.name }}
</router-link>
<span v-else>{{ item.name }}</span>
</a-breadcrumb-item>
</a-breadcrumb>
</template>
<script>
export default {
data() {
return {
name: '',
breadList: []
}
},
created() {
this.getBreadcrumb()
},
methods: {
getBreadcrumb() {
this.breadList = []
//this.breadList.push({name: 'index', path: '/dashboard/', meta: {title: ''}})
this.name = this.$route.name
this.$route.matched.forEach(item => {
//item.name !== 'index' && this.breadList.push(item)
this.breadList.push(item)
})
}
},
watch: {
$route() {
this.getBreadcrumb()
}
}
}
</script>
<style scoped>
</style>

View file

@ -0,0 +1,52 @@
<template>
<div class="ant-pro-footer-toolbar">
<div style="float: left">
<slot name="extra">{{ extra }}</slot>
</div>
<div style="float: right">
<slot></slot>
</div>
</div>
</template>
<script>
export default {
name: 'FooterToolBar',
props: {
prefixCls: {
type: String,
default: 'ant-pro-footer-toolbar'
},
extra: {
type: [String, Object],
default: ''
}
}
}
</script>
<style lang="less" scoped>
.ant-pro-footer-toolbar {
position: fixed;
width: 100%;
bottom: 0;
right: 0;
height: 56px;
line-height: 56px;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.03);
background: #ffffff8c;
border-top: 1px solid #e8e8e8;
@media (prefers-color-scheme: dark) {
background: rgba(24, 24, 24, 0.62);
border-top: unset;
}
padding: 0 24px;
z-index: 9;
&:after {
content: "";
display: block;
clear: both;
}
}
</style>

View file

@ -0,0 +1,3 @@
import FooterToolBar from './FooterToolBar'
export default FooterToolBar

View file

@ -0,0 +1,45 @@
<template>
<div class="logo">
<img :src="logo"/>
<div class="text">Nginx UI</div>
<div class="clear"></div>
</div>
</template>
<script>
export default {
name: 'Logo',
data() {
return {
logo: require('@/assets/img/logo.png')
}
}
}
</script>
<style lang="less" scoped>
.logo {
margin: 8px 18px;
border-bottom: 1px solid #e8e8e8;
transition: all 0.3s;
height: 56px;
width: 80%;
overflow: hidden;
display: inline-block;
img {
height: 46px;
float: left;
}
.text {
float: left;
font-weight: 300;
font-size: 22px;
line-height: 48px;
height: 48px;
display: inline-block;
margin-left: 13px;
}
}
</style>

View file

@ -0,0 +1,206 @@
<template>
<div v-if="!$route.meta.hiddenHeaderContent" class="page-header">
<div class="page-header-index-wide">
<s-breadcrumb/>
<div class="detail">
<div class="main">
<div class="row">
<img v-if="logo" :src="logo" class="logo"/>
<h1 v-if="title" class="title">{{ title }}</h1>
<div class="action">
<slot name="action"></slot>
</div>
</div>
<div class="row">
<div v-if="avatar" class="avatar">
<a-avatar :src="avatar"/>
</div>
<div v-if="this.$slots.content" class="headerContent">
<slot name="content"></slot>
</div>
<div v-if="this.$slots.extra" class="extra">
<slot name="extra"></slot>
</div>
</div>
<div>
<slot name="pageMenu"></slot>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import Breadcrumb from '@/components/Breadcrumb/Breadcrumb'
export default {
name: 'PageHeader',
components: {
's-breadcrumb': Breadcrumb
},
props: {
title: {
type: [String, Boolean],
default: true,
required: false
},
logo: {
type: String,
default: '',
required: false
},
avatar: {
type: String,
default: '',
required: false
}
},
data() {
return {}
}
}
</script>
<style lang="less" scoped>
.page-header {
background: #fff;
padding: 16px 32px 0;
border-bottom: 1px solid #e8e8e8;
@media (prefers-color-scheme: dark) {
background: transparent;
border-bottom: unset;
}
.breadcrumb {
margin-bottom: 16px;
}
.detail {
display: flex;
/*margin-bottom: 16px;*/
.avatar {
flex: 0 1 72px;
margin: 0 24px 8px 0;
& > span {
border-radius: 72px;
display: block;
width: 72px;
height: 72px;
}
}
.main {
width: 100%;
flex: 0 1 auto;
.row {
display: flex;
width: 100%;
.avatar {
margin-bottom: 16px;
}
}
.title {
font-size: 20px;
font-weight: 500;
line-height: 28px;
margin-bottom: 16px;
flex: auto;
}
.logo {
width: 28px;
height: 28px;
border-radius: 4px;
margin-right: 16px;
}
.content,
.headerContent {
flex: auto;
line-height: 22px;
.link {
margin-top: 16px;
line-height: 24px;
a {
font-size: 14px;
margin-right: 32px;
}
}
}
.extra {
flex: 0 1 auto;
margin-left: 88px;
min-width: 242px;
text-align: right;
}
.action {
margin-left: 56px;
min-width: 266px;
flex: 0 1 auto;
text-align: right;
&:empty {
display: none;
}
}
}
}
}
.mobile .page-header {
.main {
.row {
flex-wrap: wrap;
.avatar {
flex: 0 1 25%;
margin: 0 2% 8px 0;
}
.content,
.headerContent {
flex: 0 1 70%;
.link {
margin-top: 16px;
line-height: 24px;
a {
font-size: 14px;
margin-right: 10px;
}
}
}
.extra {
flex: 1 1 auto;
margin-left: 0;
min-width: 0;
text-align: right;
}
.action {
margin-left: unset;
min-width: 266px;
flex: 0 1 auto;
text-align: left;
margin-bottom: 12px;
&:empty {
display: none;
}
}
}
}
}
</style>

View file

@ -0,0 +1,3 @@
import PageHeader from './PageHeader'
export default PageHeader

View file

@ -0,0 +1,129 @@
<template>
<div>
<a-card :title="title">
<a v-if="!disable_add" slot="extra" @click="add">添加</a>
<std-table
ref="table"
:api="api"
:columns="columns"
:data_key="data_key"
:deletable="deletable"
:disable_search="disable_search"
:edit_text="edit_text"
:soft_delete="soft_delete"
@clickEdit="edit"
/>
</a-card>
<a-modal
:mask="false"
:title="data.id ? '编辑 ID: ' + data.id : '添加'"
:visible="visible"
cancel-text="取消"
ok-text="保存"
@cancel="visible=false;error={}"
@ok="ok"
>
<std-data-entry ref="std_data_entry" :data-list="editableColumns(columns)" :data-source="data"
:error="error"/>
</a-modal>
</div>
</template>
<script>
import StdTable from './StdTable'
import StdDataEntry from '@/components/StdDataEntry/StdDataEntry'
export default {
name: 'StdCurd',
components: {
StdTable,
StdDataEntry
},
props: {
title: {
type: String,
default: '列表'
},
api: Object,
columns: Array,
data_key: {
type: String,
default: 'data'
},
disable_search: {
type: Boolean,
default: false
},
disable_add: {
type: Boolean,
default: false
},
soft_delete: {
type: Boolean,
default: false
},
edit_text: String,
deletable: {
type: Boolean,
default: true
}
},
data() {
return {
visible: false,
data: {
id: null,
},
error: {}
}
},
methods: {
editableColumns(columns) {
return columns.filter((c) => {
return c.edit
})
},
uploadColumns(columns) {
return columns.filter((c) => {
return c.edit && c.edit.type === 'upload'
})
},
add() {
this.visible = true
this.data = {
id: null
}
},
ok() {
this.api.save((this.data.id ? this.data.id : null), this.data).then(r => {
this.$message.success('保存成功')
const refs = this.$refs.std_data_entry.$refs
this.uploadColumns(this.columns).forEach(c => {
const t = refs['std_upload_' + c.dataIndex][0]
if (t) {
t.upload()
}
delete r[c.dataIndex]
})
this.data = this.extend(this.data, r)
this.$refs.table.get_list()
}).catch(error => {
this.$message.error('保存失败')
this.error = error.errors
})
},
edit(id) {
this.api.get(id).then(r => {
this.data = r
this.visible = true
}).catch(() => {
this.$message.error('服务器错误')
})
}
}
}
</script>
<style scoped>
</style>

View file

@ -0,0 +1,38 @@
<template>
<div v-if="Object.keys(pagination).length !== 0">
<a-pagination
:current="pagination.current_page"
:hideOnSinglePage="true"
:pageSize="pagination.per_page"
:size="size"
:total="pagination.total"
class="pagination"
@change="changePage"
/>
<div class="clear"></div>
</div>
</template>
<script>
export default {
name: 'StdPagination',
props: {
pagination: Object,
size: {
default: ''
}
},
methods: {
changePage(num) {
return this.$emit('changePage', num)
}
}
}
</script>
<style scoped>
.pagination {
padding: 10px 0 0 0;
float: right;
}
</style>

View file

@ -0,0 +1,271 @@
<template>
<div class="std-table">
<a-form v-if="!disable_search" layout="inline">
<a-form-item
v-for="c in searchColumns(columns)" :key="c.dataIndex?c.dataIndex:c.name"
:label="c.title">
<a-input v-if="c.search.type==='input'" v-model="params[c.dataIndex]"/>
<a-checkbox
v-if="c.search.type==='checkbox'"
:default-checked="c.search.default"
:name="c.search.condition?c.search.condition:c.dataIndex"
@change="checked"/>
<a-slider
v-else-if="c.search.type==='slider'"
v-model="params[c.dataIndex]"
:marks="c.mask"
:max="c.search.max"
:min="c.search.min"
style="width: 130px"
/>
<a-select v-if="c.search.type==='select'" v-model="params[c.dataIndex]"
style="width: 130px">
<a-select-option v-for="(v,k) in c.mask" :key="k" :value="k">{{ v }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item :wrapper-col="{span:8}">
<a-button type="primary" @click="get_list()">查询</a-button>
</a-form-item>
<a-form-item :wrapper-col="{span:8}">
<a-button @click="reset_search">重置</a-button>
</a-form-item>
</a-form>
<div v-if="soft_delete" style="text-align: right">
<a v-if="params['trashed']" href="javascript:;"
@click="params['trashed']=false; get_list()">返回</a>
<a v-else href="javascript:;" @click="params['trashed']=true; get_list()">回收站</a>
</div>
<a-table
:columns="pithyColumns(columns)"
:customRow="row"
:data-source="data_source"
:loading="loading"
:pagination="false"
:row-key="'name'"
:rowSelection="{
selectedRowKeys: selectedRowKeys,
onChange: onSelectChange,
onSelect: onSelect,
type: selectionType,
}"
@change="stdChange"
>
<template
v-for="c in pithyColumns(columns)"
:slot="c.scopedSlots.customRender"
slot-scope="text, record">
<span v-if="c.badge" :key="c.dataIndex">
<a-badge v-if="text === true || text > 0" status="success"/>
<a-badge v-else status="error"/>
{{ c.mask ? c.mask[text] : text }}
</span>
<span v-else-if="c.datetime" :key="c.dataIndex">{{ moment(text).format("yyyy-MM-DD HH:mm:ss") }}</span>
<span v-else-if="c.click" :key="c.dataIndex">
<a href="javascript:;" @click="handleClick(record[c.click.index?c.click.index:c.dataIndex],
c.click.index?c.click.index:c.dataIndex,
c.click.method, c.click.path)">
{{ text != null ? text : c.default }}
</a>
</span>
<span v-else :key="c.dataIndex">
{{ text != null ? (c.mask ? c.mask[text] : text) : c.default }}
</span>
</template>
<span v-if="!pithy" slot="action" slot-scope="text, record">
<slot name="action" :record="record" />
<a href="javascript:;" @click="$emit('clickEdit', record)">
<template v-if="edit_text">{{ edit_text }}</template>
<template v-else>编辑</template>
</a>
<template v-if="deletable">
<a-divider type="vertical"/>
<a-popconfirm
v-if="soft_delete&&params.trashed"
cancelText="再想想"
okText="是的" title="你确定要反删除?"
@confirm="restore(record.name)">
<a href="javascript:;">反删除</a>
</a-popconfirm>
<a-popconfirm
v-else
cancelText="再想想"
okText="是的" title="你确定要删除?"
@confirm="destroy(record.name)"
>
<a href="javascript:;">删除</a>
</a-popconfirm>
</template>
</span>
</a-table>
<std-pagination :pagination="pagination" @changePage="get_list"/>
</div>
</template>
<script>
import StdPagination from './StdPagination'
import moment from "moment"
export default {
name: 'StdTable',
components: {
StdPagination,
},
props: {
columns: Array,
api: Object,
data_key: String,
selectionType: {
type: String,
default: 'checkbox',
validator: function (value) {
return ['checkbox', 'radio'].indexOf(value) !== -1
}
},
pithy: {
type: Boolean,
default: false
},
disable_search: {
type: Boolean,
default: false
},
soft_delete: {
type: Boolean,
default: false
},
edit_text: String,
deletable: {
type: Boolean,
default: true
}
},
data() {
return {
moment,
data_source: [],
loading: true,
pagination: {
total: 1,
per_page: 10,
current_page: 1,
total_pages: 1
},
params: {},
selectedRowKeys: [],
}
},
mounted() {
this.get_list()
},
methods: {
get_list(page_num = null) {
this.loading = true
this.params['page'] = page_num
this.api.get_list(this.params).then(response => {
if (response[this.data_key] === undefined && response.data !== undefined) {
this.data_source = response.data
} else {
this.data_source = response[this.data_key]
}
if (response.pagination !== undefined) {
this.pagination = response.pagination
}
this.loading = false
}).catch(e => {
console.log(e)
this.$message.error('服务器错误')
})
},
stdChange(pagination, filters, sorter) {
if (sorter) {
this.params['order_by'] = sorter.field
this.params['sort'] = sorter.order === 'ascend' ? 'asc' : 'desc'
this.$nextTick(() => {
this.get_list()
})
}
},
destroy(id) {
this.api.destroy(id).then(() => {
this.get_list()
this.$message.success('删除 ID: ' + id + ' 成功')
}).catch(e => {
this.$message.error('服务器错误' + (e.message ? " " + e.message : ""))
})
},
restore(id) {
this.api.restore(id).then(() => {
this.get_list()
this.$message.success('反删除 ID: ' + id + ' 成功')
}).catch(() => {
this.$message.error('服务器错误')
})
},
searchColumns(columns) {
return columns.filter((column) => {
return column.search
})
},
pithyColumns(columns) {
if (this.pithy) {
return columns.filter((c) => {
return c.pithy === true && c.display !== false
})
}
return columns.filter((c) => {
return c.display !== false
})
},
checked(c) {
this.params[c.target.value] = c.target.checked
},
onSelectChange(selectedRowKeys) {
this.selectedRowKeys = selectedRowKeys
this.$emit('selected', selectedRowKeys)
},
onSelect(record) {
console.log(record)
this.$emit('selectedRecord', record)
},
handleClick(data, index, method = '', path = '') {
if (method === 'router') {
this.$router.push(path + '/' + data).then()
} else {
this.params[index] = data
this.get_list()
}
},
row(record) {
return {
on: {
click: () => {
this.$emit('clickRow', record.id)
}
}
}
},
async reset_search() {
this.params = {}
await this.get_list()
}
}
}
</script>
<style lang="less" scoped>
.ant-form {
margin: 10px 0 20px 0;
}
.ant-slider {
min-width: 90px;
}
.std-table {
.ant-table-wrapper {
overflow: scroll;
}
}
</style>

View file

@ -0,0 +1,140 @@
<template>
<a-form :layout="layout" class="std-data-entry">
<a-form-item
v-for="d in M_dataList" :key="d.dataIndex" :help="error[d.dataIndex] ? error[d.dataIndex].toString() : null"
:label="d.title"
:labelCol="d.edit.labelCol"
:validate-status="error[d.dataIndex] ? 'error' :'success'"
:wrapperCol="d.edit.wrapperCol"
>
<a-input v-if="d.edit.type==='input'" v-model="dataSource[d.dataIndex]" :placeholder="d.edit.placeholder"/>
<a-textarea v-else-if="d.edit.type==='textarea'" v-model="dataSource[d.dataIndex]"
:rows="d.edit.row?d.edit.row:5"/>
<std-select-option
v-else-if="d.edit.type==='select'"
v-model="temp[d.dataIndex]"
:options="d.mask"
:key-type="d.edit.key_type ? d.edit.key_type : 'int'"
/>
<std-selector
v-else-if="d.edit.type==='selector'" v-model="temp[d.dataIndex]" :api="d.edit.api"
:columns="d.edit.columns"
:data_key="d.edit.data_key"
:disable_search="d.edit.disable_search" :pagination_method="d.edit.pagination_method"
:record-value-index="d.edit.recordValueIndex" :value="temp[d.edit.valueIndex]"
selection-type="radio"
/>
<a-input-number v-else-if="d.edit.type==='number'" v-model="temp[d.dataIndex]"
:min="d.edit.min" :step="d.edit.step"
/>
<std-date-picker v-else-if="d.edit.type==='date_picker'" v-model="temp[d.dataIndex]"
:show-time="d.edit.showTime"/>
<a-slider
v-else-if="d.edit.type==='slider'"
v-model="temp[d.dataIndex]"
:marks="d.mask"
:max="d.edit.max"
:min="d.edit.min"
/>
<a-switch
v-else-if="d.edit.type==='switch'"
v-model="temp[d.dataIndex]"
@change="$emit(d.edit.event)"
/>
<std-transfer
v-else-if="d.edit.type==='transfer'"
v-model="temp[d.dataIndex]"
:api="d.edit.api"
:data-key="d.edit.dataKey"
/>
<p v-else-if="d.edit.type==='readonly'">
{{ d.mask ? d.mask[temp[d.dataIndex]] : temp[d.dataIndex] }}
</p>
</a-form-item>
<a-form-item>
<slot name="action"/>
</a-form-item>
</a-form>
</template>
<script>
import StdSelectOption from './StdSelectOption'
import StdSelector from './StdSelector'
import StdDatePicker from './StdDatePicker'
import StdTransfer from './StdTransfer'
export default {
name: 'StdDataEntry',
components: {
StdTransfer,
StdDatePicker,
StdSelectOption,
StdSelector
},
props: {
dataList: [Array, Object],
dataSource: Object,
error: {
type: Object,
default() {
return {}
}
},
layout: {
default: 'vertical',
validator: value => {
return ['horizontal', 'vertical', 'inline'].indexOf(value) !== -1
}
}
},
model: {
prop: 'dataSource',
event: 'changeDataSource'
},
data() {
return {
temp: null,
i: 0,
M_dataList: {}
}
},
watch: {
dataSource() {
this.temp = this.dataSource
},
dataList() {
this.M_dataList = this.editableColumns(this.dataList)
}
},
created() {
this.temp = this.dataSource
if (this.layout === 'horizontal') {
this.labelCol = {span: 4}
this.wrapperCol = {span: 18}
}
this.M_dataList = this.editableColumns(this.dataList)
},
methods: {
editableColumns(columns) {
if (typeof columns === 'object') {
columns = Object.values(columns)
}
return columns.filter((c) => {
return c.edit
})
}
}
}
</script>
<style scoped>
</style>

View file

@ -0,0 +1,50 @@
<template>
<a-date-picker
v-model="dateModel"
:show-time="showTime"
@change="(r, d) => {changeDate(d)}"
/>
</template>
<script>
const moment = require('moment')
moment.locale('zh-cn')
export default {
name: 'StdDatePicker',
props: {
date: String,
showTime: {
type: [Object, Boolean],
default() {
return false
}
}
},
model: {
prop: 'date',
event: 'changeDate'
},
data() {
return {
moment,
dateModel: this.date ? moment(this.date) : null
}
},
watch: {
date() {
this.dateModel = this.date ? moment(this.date) : null
}
},
methods: {
changeDate(d) {
this.$emit('changeDate', d)
}
}
}
</script>
<style scoped>
</style>

View file

@ -0,0 +1,47 @@
<template>
<a-select
v-model="tempValue"
:defaultValue="Object.keys(options)[0]"
@change="$emit('change', isNaN(parseInt(tempValue)) || keyType === 'string' ? tempValue : parseInt(tempValue) )">
<a-select-option v-for="(v,k) in options" :key="k">{{ v }}</a-select-option>
</a-select>
</template>
<script>
export default {
name: 'StdSelectOption',
props: {
value: [Number, String],
options: [Array, Object],
keyType: {
type: String,
default() {
return 'int'
}
}
},
model: {
prop: 'value',
event: 'change'
},
data() {
return {
tempValue: null
}
},
watch: {
value() {
this.tempValue = this.value != null ? this.value.toString() : null
}
},
created() {
this.tempValue = this.value != null ? this.value.toString() : null
}
}
</script>
<style lang="less" scoped>
.ant-select {
min-width: 80px;
}
</style>

View file

@ -0,0 +1,104 @@
<template>
<div class="std-selector">
<a-input v-model="_key" disabled hidden/>
<a-input v-model="M_value" disabled/>
<a-button @click="visible=true">更变</a-button>
<a-modal
:mask="false"
:visible="visible"
cancel-text="取消"
ok-text="选择"
title="选择器"
@cancel="visible=false"
@ok="ok()"
>
<std-table
:api="api"
:columns="columns"
:data_key="data_key"
:disable_search="disable_search"
:pagination_method="pagination_method"
:pithy="true"
:selectionType="selectionType"
@selected="onSelect"
@selectedRecord="r => {record = r}"
/>
</a-modal>
</div>
</template>
<script>
import StdTable from '@/components/StdDataDisplay/StdTable'
export default {
name: 'StdSelector',
components: {StdTable},
props: {
_key: [Number, String],
value: String,
recordValueIndex: [Number, String],
selectionType: {
type: String,
default: 'checkbox',
validator: function (value) {
return ['checkbox', 'radio'].indexOf(value) !== -1
}
},
api: Object,
columns: Array,
data_key: String,
pagination_method: {
type: String,
validator: function (value) {
return ['a', 'b'].indexOf(value) !== -1
}
},
disable_search: {
type: Boolean,
default: true
}
},
model: {
prop: '_key',
event: 'changeSelect'
},
data() {
return {
visible: false,
selected: [],
record: {},
M_value: this.value
}
},
watch: {
value() {
this.M_value = this.value
}
},
methods: {
onSelect(selected) {
this.selected = selected
},
ok() {
this.visible = false
let selected = this.selected
if (this.selectionType === 'radio') {
selected = this.selected[0]
}
this.M_value = this.record[this.recordValueIndex]
this.$emit('changeSelect', selected)
}
}
}
</script>
<style lang="less" scoped>
.std-selector {
min-width: 300px;
.ant-input {
width: auto;
margin: 0 10px 0 0;
}
}
</style>

View file

@ -0,0 +1,75 @@
<template>
<a-transfer
:data-source="dataSource"
:render="item=>item.title"
:selectedKeys="selectedKeys"
:targetKeys="targetKeys"
:titles="['可添加', '已添加']"
@change="handleChange"
@selectChange="handleSelectChange"
/>
</template>
<script>
const mockData = []
for (let i = 0; i < 20; i++) {
mockData.push({
key: i.toString(),
title: `content${i + 1}`,
description: `description of content${i + 1}`
})
}
export default {
name: 'StdTransfer',
props: {
api: Function,
dataKey: String,
target: String
},
model: {
prop: 'target',
event: 'changeTarget'
},
data() {
return {
targetKeys: [],
selectedKeys: [],
dataSource: []
}
},
created() {
this.targetKeys = this.target.split(',')
if (this.api) {
this.api().then(r => {
const dataSource = []
r[this.dataKey ? this.dataKey : 'data'].forEach(v => {
dataSource.push({
key: v.id.toString(),
title: `${v.title}`,
description: `${v.description}`
})
})
this.dataSource = dataSource
})
}
},
watch: {
targetKeys() {
this.$emit('changeTarget', this.targetKeys.toString())
}
},
methods: {
handleChange(nextTargetKeys) {
this.targetKeys = nextTargetKeys
},
handleSelectChange(sourceSelectedKeys, targetSelectedKeys) {
this.selectedKeys = [...sourceSelectedKeys, ...targetSelectedKeys]
},
}
}
</script>
<style scoped>
</style>

View file

@ -0,0 +1,78 @@
<template>
<div class="std-form-card">
<a-row
:align="'middle'"
:type="'flex'"
class="container"
>
<a-col
:lg="8"
:md="10"
:sm="12"
:xl="6"
:xs="24"
:xxl="5"
class="content"
>
<std-form-card-content
:error="error" :options="options"
@onSubmit="value => {$emit('onSubmit', value)}"/>
<vue-particles
:click-effect="false"
:hover-effect="false"
:move-speed="3"
:particlesNumber="60"
class="particles"
color="#dedede"/>
</a-col>
</a-row>
</div>
</template>
<script>
import Vue from 'vue'
import VueParticles from 'vue-particles'
import StdFormCardContent from '@/components/StdFormCard/StdFormCardContent'
Vue.use(VueParticles)
export default {
name: 'StdFormCard',
components: {
StdFormCardContent
},
props: {
options: Object,
error: {
type: Object,
default() {
return {}
}
},
}
}
</script>
<style lang="less" scoped>
.particles {
position: fixed;
z-index: -1;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
.container {
position: fixed;
height: 100%;
width: 100%;
background-color: #f5f5f5;
.content {
max-width: 360px;
margin: 0 auto;
}
}
</style>

View file

@ -0,0 +1,139 @@
<template>
<a-card :bordered="false" class="form-card">
<a-form :form="form" @submit="handleSubmit">
<div class="logo">
<img :src="logo"/>
</div>
<p class="title">
{{ options.title }}
</p>
<a-form-item
v-for="item in options.items"
:key="item.label"
:help="errors[item.decorator[0]] ? errors[item.decorator[0]] : null"
:label="item.label"
:validate-status="errors[item.decorator[0]] ? 'error' :'success'"
>
<a-input
v-decorator="item.decorator"
:autocomplate="item.autocomplate ? 'on' : 'off'"
:placeholder="item.placeholder"
:type="item.type"
>
<a-icon slot="prefix" :type="item.icon" style="color: rgba(0,0,0,.25)"/>
</a-input>
</a-form-item>
<div class="action">
<div class="center">
<a-button
:loading="loading"
class="std-border-radius"
html-type="submit"
type="primary"
>
{{ options.button_text }}
</a-button>
</div>
<div class="small-link center">
<slot name="small-link"/>
</div>
</div>
</a-form>
</a-card>
</template>
<script>
//import {VueReCaptcha} from 'vue-recaptcha-v3'
//import Vue from 'vue'
/*Vue.use(VueReCaptcha, {
siteKey: process.env.VUE_APP_RECAPTCHA_SITEKEY,
loaderOptions: {
useRecaptchaNet: true
}
})*/
export default {
name: 'StdFormCardContent',
props: {
options: Object,
errors: {
type: Object,
default() {
return {}
}
},
},
data() {
return {
logo: require('@/assets/img/logo.png'),
loading: false,
form: null
}
},
mounted() {
this.form = this.$form.createForm(this)
},
methods: {
async handleSubmit(e) {
e.preventDefault()
this.form.validateFields((err, values) => {
if (!err) {
this.loading = true
//this.$recaptchaLoaded().then(() => {
//this.$recaptcha('std_form').then(token => {
//values.token = token
this.$emit('onSubmit', values)
//})
// })
this.loading = false
}
})
}
}
}
</script>
<style lang="less">
.form-card {
.ant-form-item {
input {
border-radius: 20px;
}
}
}
</style>
<style lang="less" scoped>
.form-card {
box-shadow: 0 0 30px rgba(200, 200, 200, 0.25);
.ant-form {
max-width: 250px;
margin: 0 auto;
.title {
text-align: center;
font-size: 17px;
margin: 10px 0;
}
}
.logo {
display: grid;
padding: 10px;
img {
height: 80px;
margin: 0 auto;
}
}
.action {
.small-link {
font-size: 13px;
padding: 15px 0 0 0;
}
}
}
</style>

View file

@ -0,0 +1,142 @@
<template>
<a-config-provider :locale="zh_CN">
<a-layout style="min-height: 100%;">
<a-drawer
v-show="clientWidth<512"
:closable="false"
:visible="collapsed"
placement="left"
@close="collapsed=false"
>
<side-bar/>
</a-drawer>
<a-layout-sider
v-show="clientWidth>=512"
v-model="collapsed"
:collapsible="true"
:style="{zIndex: 11}"
theme="light"
>
<side-bar/>
</a-layout-sider>
<a-layout>
<a-layout-header :style="{position: 'fixed', zIndex: 10, width:'100%'}">
<header-layout @clickUnFold="collapsed=true"/>
</a-layout-header>
<a-layout-content>
<page-header :title="$route.name"/>
<div class="router-view">
<router-view/>
</div>
</a-layout-content>
<a-layout-footer>
<footer-layout/>
</a-layout-footer>
</a-layout>
</a-layout>
</a-config-provider>
</template>
<script>
import HeaderLayout from './HeaderLayout'
import SideBar from './SideBar'
import FooterLayout from './FooterLayout'
import PageHeader from '@/components/PageHeader/PageHeader'
import zh_CN from 'ant-design-vue/lib/locale-provider/zh_CN'
export default {
name: 'BaseLayout',
data() {
return {
collapsed: this.collapse(),
zh_CN,
clientWidth: document.body.clientWidth
}
},
mounted() {
window.onresize = () => {
this.collapsed = this.collapse()
this.clientWidth = this.getClientWidth()
}
},
components: {
SideBar,
PageHeader,
HeaderLayout,
FooterLayout
},
methods: {}
}
</script>
<style lang="less">
@dark: ~"(prefers-color-scheme: dark)";
body {
overflow: unset !important;
}
p {
padding: 0 0 10px 0;
}
.ant-layout-sider {
background-color: #ffffff;
@media @dark {
background-color: #28292c;
}
box-shadow: 2px 0 6px rgba(0, 21, 41, 0.01);
}
@media @dark {
.ant-checkbox-indeterminate {
.ant-checkbox-inner {
background-color: transparent !important;
}
}
}
.ant-layout-header {
padding: 0;
}
.ant-table-small {
font-size: 13px;
}
.ant-card-bordered {
border: unset;
}
.header-notice-wrapper .ant-tabs-content {
max-height: 250px;
}
.header-notice-wrapper .ant-tabs-tabpane-active {
overflow-y: scroll;
}
.ant-layout-footer {
@media (max-width: 320px) {
padding: 10px;
}
}
.ant-layout-content {
margin: 64px 0;
min-height: auto;
.router-view {
padding: 20px;
@media (max-width: 512px) {
padding: 20px 0;
}
position: relative;
}
}
</style>

View file

@ -0,0 +1,13 @@
<template>
<router-view/>
</template>
<script>
export default {
name: 'BaseRouterView'
}
</script>
<style scoped>
</style>

View file

@ -0,0 +1,22 @@
<template>
<div class="footer center">
Copyright © 2020 - {{ thisYear }} 0xJacky
</div>
</template>
<script>
export default {
name: 'FooterComponent',
data() {
return {
thisYear: new Date().getFullYear()
}
}
}
</script>
<style scoped>
.footer {
}
</style>

View file

@ -0,0 +1,50 @@
<template>
<div class="header">
<div class="tool">
<a-icon type="menu-unfold" @click="$emit('clickUnFold')"/>
</div>
<div class="user-wrapper">
<a href="/index.html">
<a-icon type="home"/>
</a>
</div>
</div>
</template>
<script>
export default {
name: 'HeaderComponent',
components: {},
}
</script>
<style lang="less" scoped>
.header {
height: 64px;
padding: 0 20px 0 0;
background: #fff;
box-shadow: 0 0 20px 0 rgba(0, 0, 0, 0.05);
border-bottom: 1px solid #e8e8e8;
@media (prefers-color-scheme: dark) {
background: #28292c;
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
border-bottom: unset;
}
position: fixed;
width: 100%;
}
.tool {
position: fixed;
left: 20px;
@media (min-width: 512px) {
display: none;
}
}
.user-wrapper {
position: fixed;
right: 20px;
}
</style>

View file

@ -0,0 +1,33 @@
<template>
<div
v-show="loading"
class="loading"
>
<div class="wrapper center">
<a-spin>
<a-icon
slot="indicator"
spin
style="font-size: 30px"
type="loading"
/>
</a-spin>
</div>
</div>
</template>
<script>
export default {
name: 'Loading',
props: {
loading: {
type: [Boolean, String],
default: false
}
}
}
</script>
<style scoped>
</style>

View file

@ -0,0 +1,81 @@
<template>
<div class="sidebar">
<logo/>
<a-menu
:openKeys="openKeys"
mode="inline"
@openChange="onOpenChange"
>
<template v-for="sidebar in visible(sidebars)">
<a-menu-item v-if="!sidebar.children" :key="sidebar.path"
@click="$router.push('/'+sidebar.path).catch(() => {})">
<a-icon :type="sidebar.meta.icon"/>
<span>{{ sidebar.name }}</span>
</a-menu-item>
<a-sub-menu v-else :key="sidebar.path">
<span slot="title"><a-icon :type="sidebar.meta.icon"/><span>{{ sidebar.name }}</span></span>
<a-menu-item v-for="child in visible(sidebar.children)" :key="child.name">
<router-link :to="'/'+sidebar.path+'/'+child.path">
{{ child.name }}
</router-link>
</a-menu-item>
</a-sub-menu>
</template>
</a-menu>
</div>
</template>
<script>
import Logo from '@/components/Logo/Logo'
export default {
name: 'SideBar',
components: {Logo},
data() {
return {
rootSubmenuKeys: [],
openKeys: [],
sidebars: this.$routeConfig[0]['children']
}
},
created() {
this.sidebars.forEach((element) => {
this.rootSubmenuKeys.push(element)
})
},
methods: {
onOpenChange(openKeys) {
const latestOpenKey = openKeys.find(key => this.openKeys.indexOf(key) === -1)
if (this.rootSubmenuKeys.indexOf(latestOpenKey) === -1) {
this.openKeys = openKeys
} else {
this.openKeys = latestOpenKey ? [latestOpenKey] : []
}
},
visible(sidebars) {
return sidebars.filter(c => {
return c.meta === undefined || (c.meta.hiddenInSidebar === undefined || c.meta.hiddenInSidebar !== true)
})
}
}
}
</script>
<style lang="less" scoped>
.sidebar {
position: fixed;
width: 200px;
}
.ant-layout-sider-collapsed .logo {
width: 48px;
overflow: hidden;
}
.ant-menu-inline, .ant-menu-vertical, .ant-menu-vertical-left {
border-right: unset;
}
</style>

View file

@ -0,0 +1,105 @@
import Vue from 'vue'
import {
Alert,
Avatar,
Badge,
Breadcrumb,
Button,
Card,
Checkbox,
Col,
Collapse,
Comment,
ConfigProvider,
DatePicker,
Descriptions,
Divider,
Drawer,
Dropdown,
Empty,
Form,
Icon,
Input,
InputNumber,
Layout,
List,
Menu,
message,
Modal,
notification,
pageHeader,
Pagination,
Popconfirm,
Popover,
Progress,
Radio,
Result,
Row,
Select,
Skeleton,
Slider,
Spin,
Statistic,
Steps,
Table,
Tabs,
Tooltip,
Transfer,
Upload,
Switch
} from 'ant-design-vue'
Vue.use(ConfigProvider)
Vue.use(Layout)
Vue.use(Input)
Vue.use(InputNumber)
Vue.use(Button)
Vue.use(Radio)
Vue.use(Checkbox)
Vue.use(Select)
Vue.use(Collapse)
Vue.use(Card)
Vue.use(Form)
Vue.use(Row)
Vue.use(Col)
Vue.use(Modal)
Vue.use(Table)
Vue.use(Tabs)
Vue.use(Icon)
Vue.use(Badge)
Vue.use(Popover)
Vue.use(Dropdown)
Vue.use(List)
Vue.use(Avatar)
Vue.use(Breadcrumb)
Vue.use(Steps)
Vue.use(Spin)
Vue.use(Menu)
Vue.use(Drawer)
Vue.use(Tooltip)
Vue.use(Alert)
Vue.use(Divider)
Vue.use(DatePicker)
Vue.use(Upload)
Vue.use(Progress)
Vue.use(Skeleton)
Vue.use(Popconfirm)
Vue.use(notification)
Vue.use(Empty)
Vue.use(Statistic)
Vue.use(Pagination)
Vue.use(Slider)
Vue.use(Transfer)
Vue.use(Comment)
Vue.use(Descriptions)
Vue.use(Result)
Vue.use(pageHeader)
Vue.use(Switch)
Vue.prototype.$confirm = Modal.confirm
Vue.prototype.$message = message
Vue.prototype.$notification = notification
Vue.prototype.$info = Modal.info
Vue.prototype.$success = Modal.success
Vue.prototype.$error = Modal.error
Vue.prototype.$warning = Modal.warning

View file

@ -0,0 +1,47 @@
import axios from 'axios'
import store from '../store'
/* 创建 axios 实例 */
let http = axios.create({
baseURL: process.env.VUE_APP_API_ROOT,
timeout: 50000,
headers: {'Content-Type': 'application/json'},
transformRequest: [function (data, headers) {
if (headers['Content-Type'] === 'multipart/form-data;charset=UTF-8') {
return data
} else {
headers['Content-Type'] = 'application/json'
}
return JSON.stringify(data)
}],
})
/* http request 拦截器 */
http.interceptors.request.use(
config => {
return config
},
err => {
return Promise.reject(err)
}
)
/* response 拦截器 */
http.interceptors.response.use(
response => {
return Promise.resolve(response.data)
},
async error => {
console.log(error)
switch (error.response.status) {
case 401:
case 403:
// 无权访问时,直接登出
await store.dispatch('logout')
break
}
return Promise.reject(error.response.data)
}
)
export default http

View file

@ -0,0 +1,19 @@
import Vue from 'vue'
import Vuex from 'vuex'
import VuexPersistence from 'vuex-persist'
Vue.use(Vuex)
const debug = process.env.NODE_ENV !== 'production'
const vuexLocal = new VuexPersistence({
storage: window.localStorage,
modules: []
})
export default new Vuex.Store({
// 将各组件分别模块化存入 Store
modules: {},
plugins: [vuexLocal.plugin],
strict: debug
})

View file

@ -0,0 +1,38 @@
export const mock = {
namespace: true,
state: {
user: {
name: 'mock 用户',
school_id: '201904020209',
superuser: true,
// 0学生 1企业 2教师 3学院
power: 2,
gender: 1,
phone: "10086",
email: 'me@jackyu.cn',
description: '前端、后端、系统架构',
college_id: 1,
college_name: "大数据与互联网学院",
major: 1,
major_name: "物联网工程",
position: 'HR'
}
},
mutations: {
update_mock_user(state, payload) {
for (const k in payload) {
state.user[k] = payload[k]
}
}
},
actions: {
async update_mock_user({commit}, data) {
commit('update_mock_user', data)
}
},
getters: {
user(state) {
return state.user
},
}
}

View file

@ -0,0 +1,51 @@
export const user = {
namespace: true,
state: {
info: {
id: null,
name: null,
power: null,
college_id: null,
college_name: null,
major_id: null,
major_name: null,
position: null
},
token: null
},
mutations: {
login(state, payload) {
state.token = payload.token
},
logout(state) {
sessionStorage.clear()
state.info = {}
state.token = null
},
update_user(state, payload) {
state.info = payload
}
},
actions: {
async login({commit}, data) {
commit('login', data)
},
async logout({commit}) {
commit('logout')
},
async update_user({commit}, data) {
commit('update_user', data)
}
},
getters: {
info(state) {
return state.info
},
token(state) {
return state.token
},
isLogin(state) {
return !!state.token
}
}
}

View file

@ -0,0 +1,50 @@
import scrollPosition from './scroll-position'
export default {
// eslint-disable-next-line no-unused-vars
install(Vue, options) {
Vue.prototype.extend = (target, source) => {
for (let obj in source) {
target[obj] = source[obj]
}
return target
}
Vue.prototype.getClientWidth = () => {
return document.body.clientWidth
}
Vue.prototype.collapse = () => {
return !(Vue.prototype.getClientWidth() > 768 || Vue.prototype.getClientWidth() < 512)
}
Vue.prototype.bytesToSize = (bytes) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return (bytes / Math.pow(k, i)).toPrecision(3) + ' ' + sizes[i]
}
Vue.prototype.transformUserType = (power) => {
const type = ['学生', '企业', '教师', '学院']
return type[power]
}
Vue.prototype.transformGrade = {
7: 'A+',
6: 'A',
5: 'B+',
4: 'B',
3: 'C+',
2: 'C',
1: 'D',
0: 'F'
}
Vue.prototype.scrollPosition = scrollPosition
}
}

View file

@ -0,0 +1,27 @@
import Vue from 'vue'
let cache = {}
const scrollPosition = {
// 保存滚动条位置
save(path) {
cache[path] = document.documentElement.scrollTop || document.body.scrollTop
},
// 重置滚动条位置
get() {
const path = this.$route.path
Vue.prototype.$nextTick(() => {
document.documentElement.scrollTop = document.body.scrollTop = cache[path] || 0
})
},
// 设置滚动条到顶部
goTop() {
Vue.prototype.$nextTick(() => {
document.documentElement.scrollTop = document.body.scrollTop = 0
})
}
}
export default scrollPosition

View file

@ -0,0 +1,41 @@
import Vue from 'vue'
import App from './App.vue'
import store from './lib/store'
import '@/lazy'
import '@/assets/css/dark.less'
import '@/assets/css/style.less'
import {router, routes} from './router'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import utils from '@/lib/utils'
import api from '@/api'
Vue.use(utils)
Vue.config.productionTip = false
Vue.prototype.$routeConfig = routes
Vue.prototype.$api = api
NProgress.configure({
easing: 'ease',
speed: 500,
showSpinner: false,
trickleSpeed: 200,
minimum: 0.3
})
router.beforeEach((to, from, next) => {
NProgress.start()
next()
})
router.afterEach(() => {
NProgress.done()
})
new Vue({
store,
router,
render: h => h(App)
}).$mount('#app')

View file

@ -0,0 +1,114 @@
import Vue from 'vue'
import VueRouter from 'vue-router'
import axios from 'axios'
Vue.use(VueRouter)
export const routes = [
{
path: '/',
name: '首页',
component: () => import('@/layouts/BaseLayout'),
redirect: '/domain',
children: [
/*{
path: 'dashboard',
//component: () => import('@/views/dashboard/DashBoard'),
name: '仪表盘',
meta: {
//hiddenHeaderContent: true,
icon: 'home'
}
},*/
{
path: 'domain',
name: '网站管理',
component: () => import('@/layouts/BaseRouterView'),
meta: {
icon: 'cloud'
},
redirect: '/domain/list',
children: [{
path: 'list',
name: '网站列表',
component: () => import('@/views/Domain.vue'),
}, {
path: 'add',
name: '添加站点',
component: () => import('@/views/DomainEdit.vue'),
}, {
path: ':name',
name: '编辑站点',
component: () => import('@/views/DomainEdit.vue'),
meta: {
hiddenInSidebar: true
}
}, ]
},
{
path: 'config',
name: '配置管理',
component: () => import('@/views/Config.vue'),
meta: {
icon: 'file'
},
},
{
path: 'config/:name',
name: '配置编辑',
component: () => import('@/views/ConfigEdit.vue'),
meta: {
hiddenInSidebar: true
},
},
{
path: 'about',
name: '关于',
component: () => import('@/views/About.vue'),
meta: {
icon: 'info-circle'
}
},
]
},
{
path: '/404',
name: '404 Not Found',
component: () => import('@/views/Error'),
meta: {noAuth: true, status_code: 404, error: 'Not Found'}
},
{
path: '*',
name: '未找到页面',
redirect: '/404',
meta: {noAuth: true}
}
]
const router = new VueRouter({
routes
})
router.beforeEach((to, from, next) => {
document.title = 'Nginx UI | ' + to.name
if (process.env.NODE_ENV === 'production') {
axios.get('/version.json?' + Date.now()).then(r => {
if (!(process.env.VUE_APP_VERSION === r.data.version
&& Number(process.env.VUE_APP_BUILD_ID) === r.data.build_id)) {
Vue.prototype.$info({
title: '系统信息',
content: '检测到版本更新,将会自动刷新本页',
onOk() {
location.reload()
},
okText: '好的'
})
}
})
}
next()
})
export {router}

View file

@ -0,0 +1,48 @@
<template>
<a-card>
<h2>Nginx UI</h2>
<p>Yet another WebUI for Nginx</p>
<p>Version: {{ version }}-{{ build_id }}</p>
<h3>项目组</h3>
<p>前端0xJacky</p>
<p>后端0xJacky</p>
<h3>技术栈</h3>
<p>Go</p>
<p>Gin</p>
<p>Vue</p>
<p>Copyright © 2020 - {{ this_year }} 0xJacky </p>
</a-card>
</template>
<script>
export default {
name: 'About',
data() {
const date = new Date()
return {
this_year: date.getFullYear(),
version: process.env.VUE_APP_VERSION,
build_id: process.env.VUE_APP_BUILD_ID ? process.env.VUE_APP_BUILD_ID : 'dev',
api_root: process.env.VUE_APP_API_ROOT
}
},
methods: {
async changeUserPower(power) {
await this.$store.dispatch('update_mock_user', {power: power})
await this.$api.user.info()
await this.$message.success("修改成功")
}
}
}
</script>
<style lang="less" scoped>
.egg {
padding: 10px 0;
}
.ant-btn {
margin: 10px 10px 0 0;
}
</style>

View file

@ -0,0 +1,53 @@
<template>
<a-card title="配置文件">
<std-table
:api="api"
:columns="columns"
:deletable="false"
data_key="configs"
@clickEdit="item => {
$router.push({
path: '/config/' + item.name
})
}"
/>
</a-card>
</template>
<script>
import StdTable from "@/components/StdDataDisplay/StdTable"
const columns = [{
title: "名称",
dataIndex: "name",
scopedSlots: {customRender: "名称"},
sorter: true,
pithy: true
}, {
title: "修改时间",
dataIndex: "modify",
datetime: true,
scopedSlots: {customRender: "modify"},
sorter: true,
pithy: true
}, {
title: "操作",
dataIndex: "action",
scopedSlots: {customRender: "action"}
}]
export default {
name: "Config",
components: {StdTable},
data() {
return {
api: this.$api.config,
columns
}
}
}
</script>
<style scoped>
</style>

View file

@ -0,0 +1,68 @@
<template>
<a-card title="配置文件实时编辑">
<a-textarea v-model="configText" :rows="36"/>
<footer-tool-bar>
<a-button type="primary" @click="save">保存</a-button>
</footer-tool-bar>
</a-card>
</template>
<script>
import FooterToolBar from "@/components/FooterToolbar/FooterToolBar"
export default {
name: "DomainEdit",
components: {FooterToolBar},
data() {
return {
name: this.$route.params.name,
configText: ""
}
},
watch: {
$route() {
this.config = {}
this.configText = ""
},
config: {
handler() {
this.unparse()
},
deep: true
}
},
created() {
if (this.name) {
this.$api.config.get(this.name).then(r => {
this.configText = r.config
}).catch(r => {
console.log(r)
this.$message.error("服务器错误")
})
} else {
this.configText = ""
}
},
methods: {
save() {
this.$api.config.save(this.name ? this.name : this.config.name, {content: this.configText}).then(r => {
this.configText = r.config
this.$message.success("保存成功")
}).catch(r => {
console.log(r)
this.$message.error("保存错误")
})
}
}
}
</script>
<style lang="less" scoped>
.ant-card {
margin: 10px;
@media (max-width: 512px) {
margin: 10px 0;
}
}
</style>

View file

@ -0,0 +1,88 @@
<template>
<a-card title="网站管理">
<std-table
:api="api"
:columns="columns"
data_key="configs"
ref="table"
@clickEdit="r => this.$router.push({
path: '/domain/' + r.name
})"
>
<template #action="{record}">
<a v-if="record.enabled" @click="disable(record.name)">禁用</a>
<a v-else @click="enable(record.name)">启用</a>
<a-divider type="vertical"/>
</template>
</std-table>
</a-card>
</template>
<script>
import StdTable from "@/components/StdDataDisplay/StdTable"
const columns = [{
title: "名称",
dataIndex: "name",
scopedSlots: {customRender: "名称"},
sorter: true,
pithy: true
}, {
title: "状态",
dataIndex: "enabled",
badge: true,
scopedSlots: {customRender: "enabled"},
mask: {
true: "启用",
false: "未启用"
},
sorter: true,
pithy: true
}, {
title: "修改时间",
dataIndex: "modify",
datetime: true,
scopedSlots: {customRender: "modify"},
sorter: true,
pithy: true
}, {
title: "操作",
dataIndex: "action",
scopedSlots: {customRender: "action"}
}]
export default {
name: "Domain",
components: {StdTable},
data() {
return {
api: this.$api.domain,
columns
}
},
methods: {
enable(name) {
this.$api.domain.enable(name).then(() => {
this.$message.success("启用成功")
this.$refs.table.get_list()
}).catch(r => {
console.log(r)
this.$message.error("启用失败")
})
},
disable(name) {
this.$api.domain.disable(name).then(() => {
this.$message.success("禁用成功")
this.$refs.table.get_list()
}).catch(r => {
console.log(r)
this.$message.error("禁用失败")
})
}
}
}
</script>
<style scoped>
</style>

View file

@ -0,0 +1,237 @@
<template>
<a-row>
<a-col :md="12" :sm="24">
<a-card :title="name ? '编辑站点:' + name : '添加站点'">
<std-data-entry :data-list="columns" v-model="config" @change_support_ssl="change_support_ssl"/>
</a-card>
</a-col>
<a-col :md="12" :sm="24">
<a-card title="配置文件实时编辑">
<a-textarea v-model="configText" :rows="36"/>
</a-card>
</a-col>
<footer-tool-bar>
<a-button type="primary" @click="save">保存</a-button>
</footer-tool-bar>
</a-row>
</template>
<script>
import StdDataEntry from "@/components/StdDataEntry/StdDataEntry"
import FooterToolBar from "@/components/FooterToolbar/FooterToolBar"
const columns = [{
title: "配置文件名称",
dataIndex: "name",
edit: {
type: "input"
}
}, {
title: "网站域名 (server_name)",
dataIndex: "server_name",
edit: {
type: "input"
}
}, {
title: "http 监听端口",
dataIndex: "http_listen_port",
edit: {
type: "number",
min: 80
}
}, {
title: "支持 SSL",
dataIndex: "support_ssl",
edit: {
type: "switch",
event: "change_support_ssl"
}
}, {
title: "https 监听端口",
dataIndex: "https_listen_port",
edit: {
type: "number",
min: 443
}
}, {
title: "SSL 证书路径 (ssl_certificate)",
dataIndex: "ssl_certificate",
edit: {
type: "input"
}
}, {
title: "SSL 证书私钥路径 (ssl_certificate_key)",
dataIndex: "ssl_certificate_key",
edit: {
type: "input"
}
}, {
title: "网站根目录 (root)",
dataIndex: "root",
edit: {
type: "input"
}
}, {
title: "网站首页 (index)",
dataIndex: "index",
edit: {
type: "input"
}
}]
export default {
name: "DomainEdit",
components: {FooterToolBar, StdDataEntry},
data() {
return {
name: this.$route.params.name,
columns,
config: {
http_listen_port: 80,
https_listen_port: 443,
server_name: "",
index: "",
root: "",
ssl_certificate: "",
ssl_certificate_key: "",
support_ssl: false
},
configText: ""
}
},
watch: {
$route() {
this.config = {}
this.configText = ""
},
config: {
handler() {
this.unparse()
},
deep: true
}
},
created() {
if (this.name) {
this.$api.domain.get(this.name).then(r => {
this.configText = r.config
this.parse(r.config)
}).catch(r => {
console.log(r)
this.$message.error("服务器错误")
})
} else {
this.config = {
http_listen_port: 80,
https_listen_port: 443,
server_name: "",
index: "",
root: "",
ssl_certificate: "",
ssl_certificate_key: "",
support_ssl: false
}
this.get_template()
}
},
methods: {
parse(r) {
const text = r.config
const reg = {
http_listen_port: /listen[\s](.*);/i,
https_listen_port: /listen[\s](.*) ssl/i,
server_name: /server_name[\s](.*);/i,
index: /index[\s](.*);/i,
root: /root[\s](.*);/i,
ssl_certificate: /ssl_certificate[\s](.*);/i,
ssl_certificate_key: /ssl_certificate_key[\s](.*);/i
}
this.config['name'] = r.name
for (let r in reg) {
const match = text.match(reg[r])
// console.log(r, match)
if (match !== null) {
if (match[1] !== undefined) {
this.config[r] = match[1]
} else {
this.config[r] = match[0]
}
}
}
if (this.config.https_listen_port) {
this.config.support_ssl = true
}
},
unparse() {
let text = this.configText
// http_listen_port: /listen (.*);/i,
// https_listen_port: /listen (.*) ssl/i,
const reg = {
server_name: /server_name[\s](.*);/ig,
index: /index[\s](.*);/i,
root: /root[\s](.*);/i,
ssl_certificate: /ssl_certificate[\s](.*);/i,
ssl_certificate_key: /ssl_certificate_key[\s](.*);/i
}
text = text.replace(/listen[\s](.*);/i, "listen\t"
+ this.config['http_listen_port'] + ';')
text = text.replace(/listen[\s](.*) ssl/i, "listen\t"
+ this.config['https_listen_port'] + ' ssl')
text = text.replace(/listen(.*):(.*);/i, "listen\t[::]:"
+ this.config['http_listen_port'] + ';')
text = text.replace(/listen(.*):(.*) ssl/i, "listen\t[::]:"
+ this.config['https_listen_port'] + ' ssl')
for (let k in reg) {
text = text.replace(new RegExp(reg[k]), k + "\t" +
(this.config[k] !== undefined ? this.config[k] : " ") + ";")
}
this.configText = text
},
async get_template() {
if (this.config.support_ssl) {
await this.$api.domain.get_template('https-conf').then(r => {
this.configText = r.template
})
} else {
await this.$api.domain.get_template('http-conf').then(r => {
this.configText = r.template
})
}
await this.unparse()
},
change_support_ssl() {
const that = this
this.$confirm({
title: '您已修改 SSL 支持状态,是否需要更换配置文件模板?',
content: '更换配置文件模板将会丢失自定义配置',
onOk() {
that.get_template()
},
onCancel() {
},
})
},
save() {
this.$api.domain.save(this.name ? this.name : this.config.name, {content: this.configText}).then(r => {
this.parse(r)
this.$message.success("保存成功")
}).catch(r => {
console.log(r)
this.$message.error("保存错误")
})
}
}
}
</script>
<style lang="less" scoped>
.ant-card {
margin: 10px;
@media (max-width: 512px) {
margin: 10px 0;
}
}
</style>

View file

@ -0,0 +1,62 @@
<template>
<div class="wrapper">
<h1 class="title">{{ $route.meta.status_code ? $route.meta.status_code : 404 }}</h1>
<p>{{ $route.meta.error ? $route.meta.error : '找不到文件' }}</p>
</div>
</template>
<script>
export default {
name: 'Error'
}
</script>
<style lang="less" scoped>
body, div, h1, html {
padding: 0;
margin: 0
}
body, html {
color: #444;
position: relative;
font-family: "PingFang SC", "Helvetica Neue", Helvetica, Arial, CustomFont, "Microsoft YaHei UI", "Microsoft YaHei", "Hiragino Sans GB", sans-serif;
background: #fcfcfc;
height: 100%
}
h1 {
font-size: 8em;
font-weight: 100
}
a {
color: #4181b9;
text-decoration: none;
-webkit-transition: all .3s ease;
-moz-transition: all .3s ease;
-ms-transition: all .3s ease;
-o-transition: all .3s ease;
transition: all .3s ease;
&:active, &:hover {
color: #5bb0ed
}
}
.wrapper {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
font-size: 1em;
font-weight: 400;
width: 100%;
height: 30%;
line-height: 1;
margin: auto;
text-align: center
}
</style>

View file

@ -0,0 +1,18 @@
<template>
<div class="home">
<img alt="Vue logo" src="../assets/img/logo.png">
<HelloWorld msg="Welcome to Your Vue.js App"/>
</div>
</template>
<script>
// @ is an alias to /src
import HelloWorld from '@/components/HelloWorld.vue'
export default {
name: 'Home',
components: {
HelloWorld
}
}
</script>

View file

@ -0,0 +1,4 @@
{
"version": "0.1.0",
"build_id": 1
}

View file

@ -0,0 +1,53 @@
const webpack = require('webpack')
module.exports = {
pages: {
index: {
// pages 的入口
entry: 'src/main.js',
// 模板来源
template: 'public/index.html',
// 在 dist/index.html 的输出
filename: 'index.html',
// 当使用 title 选项时,
// template 中的 title 标签需要是 <title><%= htmlWebpackPlugin.options.title %></title>
title: 'Nginx UI',
// 在这个页面中包含的块,默认情况下会包含
// 提取出来的通用 chunk 和 vendor chunk。
chunks: ['chunk-vendors', 'chunk-common', 'index']
},
},
devServer: {
proxy: 'http://localhost:9000'
},
productionSourceMap: false,
css: {
loaderOptions: {
css: {},
postcss: {},
less: {
javascriptEnabled: true
}
},
extract: false
},
configureWebpack: config => {
config.plugins.push(new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/))
if (process.env.NODE_ENV === 'production') {
config.performance = {
hints: 'warning',
// 入口起点的最大体积
maxEntrypointSize: 50000000,
// 生成文件的最大体积
maxAssetSize: 30000000,
// 只给出 js 文件的性能提示
assetFilter: function (assetFilename) {
return assetFilename.endsWith('.js')
}
}
}
}
}

8880
nginx-ui-frontend/yarn.lock Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,19 +1,25 @@
package api package api
import ( import (
"github.com/0xJacky/Nginx-UI/model" "github.com/0xJacky/Nginx-UI/tool"
"github.com/0xJacky/Nginx-UI/tool" "github.com/gin-gonic/gin"
"github.com/gin-gonic/gin" "io/ioutil"
"io/ioutil" "log"
"log" "net/http"
"net/http" "os"
"os" "path/filepath"
"path/filepath"
"strconv"
) )
func GetConfigs(c *gin.Context) { func GetConfigs(c *gin.Context) {
configFiles, err := ioutil.ReadDir(tool.GetNginxConfPath("/")) orderBy := c.Query("order_by")
sort := c.DefaultQuery("sort", "desc")
mySort := map[string]string{
"name": "string",
"modify": "time",
}
configFiles, err := ioutil.ReadDir(tool.GetNginxConfPath("/"))
if err != nil { if err != nil {
ErrorHandler(c, err) ErrorHandler(c, err)
@ -34,6 +40,8 @@ func GetConfigs(c *gin.Context) {
} }
} }
configs = tool.Sort(orderBy, sort, mySort[orderBy], configs)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"configs": configs, "configs": configs,
}) })
@ -114,21 +122,15 @@ func EditConfig(c *gin.Context) {
path := filepath.Join(tool.GetNginxConfPath("/"), name) path := filepath.Join(tool.GetNginxConfPath("/"), name)
content := request.Content content := request.Content
s, err := strconv.Unquote(`"` + content + `"`)
if err != nil {
ErrorHandler(c, err)
return
}
origContent, err := ioutil.ReadFile(path) origContent, err := ioutil.ReadFile(path)
if err != nil { if err != nil {
ErrorHandler(c, err) ErrorHandler(c, err)
return return
} }
if s != "" && s != string(origContent) { if content != "" && content != string(origContent) {
model.CreateBackup(path) // model.CreateBackup(path)
err := ioutil.WriteFile(path, []byte(s), 0644) err := ioutil.WriteFile(path, []byte(content), 0644)
if err != nil { if err != nil {
ErrorHandler(c, err) ErrorHandler(c, err)
return return

View file

@ -1,18 +1,24 @@
package api package api
import ( import (
"github.com/0xJacky/Nginx-UI/model" "github.com/0xJacky/Nginx-UI/tool"
"github.com/0xJacky/Nginx-UI/tool" "github.com/gin-gonic/gin"
"github.com/gin-gonic/gin" "io/ioutil"
"io/ioutil" "net/http"
"log" "os"
"net/http" "path/filepath"
"os"
"path/filepath"
"strings"
) )
func GetDomains(c *gin.Context) { func GetDomains(c *gin.Context) {
orderBy := c.Query("order_by")
sort := c.DefaultQuery("sort", "desc")
mySort := map[string]string{
"enabled": "bool",
"name": "string",
"modify": "time",
}
configFiles, err := ioutil.ReadDir(tool.GetNginxConfPath("sites-available")) configFiles, err := ioutil.ReadDir(tool.GetNginxConfPath("sites-available"))
if err != nil { if err != nil {
@ -46,6 +52,8 @@ func GetDomains(c *gin.Context) {
} }
} }
configs = tool.Sort(orderBy, sort, mySort[orderBy], configs)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"configs": configs, "configs": configs,
}) })
@ -67,6 +75,7 @@ func GetDomain(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{ c.JSON(http.StatusNotFound, gin.H{
"message": err.Error(), "message": err.Error(),
}) })
return
} }
ErrorHandler(c, err) ErrorHandler(c, err)
return return
@ -74,81 +83,8 @@ func GetDomain(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"enabled": enabled, "enabled": enabled,
"config": string(content),
})
}
func AddDomain(c *gin.Context) {
request := make(gin.H)
err := c.BindJSON(&request)
if err != nil {
ErrorHandler(c, err)
return
}
name := request["name"].(string)
path := filepath.Join(tool.GetNginxConfPath("sites-available"), name)
log.Println(path)
if _, err = os.Stat(path); err == nil {
c.JSON(http.StatusNotAcceptable, gin.H{
"message": "site exist",
})
return
}
SupportSSL := request["support_ssl"]
baseKeys := []string{"http_listen_port",
"https_listen_port",
"server_name",
"ssl_certificate", "ssl_certificate_key",
"index", "root", "extra",
}
tmp, err := ioutil.ReadFile("template/http-conf")
if err != nil {
ErrorHandler(c, err)
return
}
if SupportSSL == true {
tmp, err = ioutil.ReadFile("template/https-conf")
}
if err != nil {
ErrorHandler(c, err)
return
}
template := string(tmp)
content := template
for i := range baseKeys {
val, ok := request[baseKeys[i]]
replace := ""
if ok {
replace = val.(string)
}
content = strings.Replace(content, "{{ "+baseKeys[i]+" }}",
replace, -1)
}
log.Println(name, content)
err = ioutil.WriteFile(path, []byte(content), 0644)
if err != nil {
ErrorHandler(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"name": name, "name": name,
"content": content, "config": string(content),
}) })
} }
@ -161,21 +97,16 @@ func EditDomain(c *gin.Context) {
err = c.BindJSON(&request) err = c.BindJSON(&request)
path := filepath.Join(tool.GetNginxConfPath("sites-available"), name) path := filepath.Join(tool.GetNginxConfPath("sites-available"), name)
if _, err = os.Stat(path); os.IsNotExist(err) { if _, err = os.Stat(path); err == nil {
c.JSON(http.StatusNotFound, gin.H{ origContent, err = ioutil.ReadFile(path)
"message": "site not found", if err != nil {
}) ErrorHandler(c, err)
return return
} }
origContent, err = ioutil.ReadFile(path)
if err != nil {
ErrorHandler(c, err)
return
} }
if request["content"] != "" && request["content"] != string(origContent) { if request["content"] != "" && request["content"] != string(origContent) {
model.CreateBackup(path) // model.CreateBackup(path)
err := ioutil.WriteFile(path, []byte(request["content"].(string)), 0644) err := ioutil.WriteFile(path, []byte(request["content"].(string)), 0644)
if err != nil { if err != nil {
ErrorHandler(c, err) ErrorHandler(c, err)
@ -242,8 +173,6 @@ func DisableDomain(c *gin.Context) {
func DeleteDomain(c *gin.Context) { func DeleteDomain(c *gin.Context) {
var err error var err error
name := c.Param("name") name := c.Param("name")
request := make(gin.H)
err = c.BindJSON(request)
availablePath := filepath.Join(tool.GetNginxConfPath("sites-available"), name) availablePath := filepath.Join(tool.GetNginxConfPath("sites-available"), name)
enabledPath := filepath.Join(tool.GetNginxConfPath("sites-enabled"), name) enabledPath := filepath.Join(tool.GetNginxConfPath("sites-enabled"), name)

31
server/api/template.go Normal file
View file

@ -0,0 +1,31 @@
package api
import (
"github.com/gin-gonic/gin"
"io/ioutil"
"net/http"
"os"
"path/filepath"
)
func GetTemplate(c *gin.Context) {
name := c.Param("name")
path := filepath.Join("template", name)
content, err := ioutil.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusNotFound, gin.H{
"message": err.Error(),
})
return
}
ErrorHandler(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"message": "ok",
"template": string(content),
})
}

View file

@ -22,7 +22,6 @@ func InitRouter() *gin.Engine {
{ {
endpoint.GET("domains", api.GetDomains) endpoint.GET("domains", api.GetDomains)
endpoint.GET("domain/:name", api.GetDomain) endpoint.GET("domain/:name", api.GetDomain)
endpoint.POST("domain", api.AddDomain)
endpoint.POST("domain/:name", api.EditDomain) endpoint.POST("domain/:name", api.EditDomain)
endpoint.POST("domain/:name/enable", api.EnableDomain) endpoint.POST("domain/:name/enable", api.EnableDomain)
endpoint.POST("domain/:name/disable", api.DisableDomain) endpoint.POST("domain/:name/disable", api.DisableDomain)
@ -35,7 +34,9 @@ func InitRouter() *gin.Engine {
endpoint.GET("backups", api.GetFileBackupList) endpoint.GET("backups", api.GetFileBackupList)
endpoint.GET("backup/:id", api.GetFileBackup) endpoint.GET("backup/:id", api.GetFileBackup)
endpoint.GET("template/:name", api.GetTemplate)
} }
return r return r
} }

View file

@ -4,10 +4,7 @@ server {
server_name {{ server_name }}; server_name {{ server_name }};
{{ index }} root ;
{{ root }} index ;
# extra
{{ extra }}
} }

View file

@ -13,10 +13,10 @@ server {
server_name {{ server_name }}; server_name {{ server_name }};
{{ index }} ssl_certificate {{ ssl_certificate }};
ssl_certificate_key {{ ssl_certificate_key }};
{{ root }} root ;
# extra index ;
{{ extra }}
} }

62
server/tool/configList.go Normal file
View file

@ -0,0 +1,62 @@
package tool
import (
"github.com/gin-gonic/gin"
"sort"
"time"
)
type MapsSort struct {
Key string
Type string
Order string
MapList []gin.H
}
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
func (m MapsSort) Len() int {
return len(m.MapList)
}
func (m MapsSort) Less(i, j int) bool {
flag := false
if m.Type == "int" {
flag = m.MapList[i][m.Key].(int) > m.MapList[j][m.Key].(int)
} else if m.Type == "bool" {
flag = boolToInt(m.MapList[i][m.Key].(bool)) > boolToInt(m.MapList[j][m.Key].(bool))
} else if m.Type == "bool" {
flag = m.MapList[i][m.Key].(string) > m.MapList[j][m.Key].(string)
} else if m.Type == "time" {
flag = m.MapList[i][m.Key].(time.Time).After(m.MapList[j][m.Key].(time.Time))
}
if m.Order == "asc" {
flag = !flag
}
return flag
}
func (m MapsSort) Swap(i, j int) {
m.MapList[i], m.MapList[j] = m.MapList[j], m.MapList[i]
}
func Sort(key string, order string, Type string, maps []gin.H) []gin.H {
mapsSort := MapsSort{
Key: key,
MapList: maps,
Type: Type,
Order: order,
}
sort.Sort(mapsSort)
return mapsSort.MapList
}

View file

@ -1,24 +1,20 @@
package tool package tool
import ( import (
"bytes" "log"
"log" "os/exec"
"os/exec" "path/filepath"
"path/filepath" "regexp"
"regexp"
) )
func ReloadNginx() { func ReloadNginx() {
cmd := exec.Command("systemctl", "reload nginx") out, err := exec.Command("nginx", "-s", "reload").CombinedOutput()
var out bytes.Buffer
cmd.Stdout = &out
err := cmd.Run()
if err != nil { if err != nil {
log.Println(err) log.Println(err)
} }
log.Println(out.String()) log.Println(string(out))
} }
func GetNginxConfPath(dir string) string { func GetNginxConfPath(dir string) string {