This commit is contained in:
Jacky 2021-08-31 12:13:12 +08:00
parent ad421905a8
commit dd6e19657a
162 changed files with 15071 additions and 932 deletions

16
Dockerfile Normal file
View file

@ -0,0 +1,16 @@
FROM debian:latest
WORKDIR /app
COPY ./sources.list /etc/apt/sources.list
RUN echo "installing nginx"
RUN apt-get update -y && apt install nginx -y
COPY ./start.sh /app/start.sh
RUN chmod a+x start.sh
COPY ./server /app/server
COPY ./html /app/html
COPY ./nginx.conf /etc/nginx/sites-available/default
EXPOSE 9180
CMD ["./start.sh"]

12
build.sh Normal file
View file

@ -0,0 +1,12 @@
echo "buil frontend"
cd frontend || exit 1
yarn build
cd .. || exit 1
echo "build server"
cd server || exit 1
GOOS=linux GOARCH=amd64 go build -o nginx-ui@linux-amd64 main.go
cd .. || exit 1
echo "build docker"
docker build -t nginx-ui .

View file

@ -0,0 +1,2 @@
VUE_APP_API_ROOT = /
VUE_APP_API_WSS_ROOT = /ws

2
frontend/.env.production Normal file
View file

@ -0,0 +1,2 @@
VUE_APP_API_ROOT = /api
VUE_APP_API_WSS_ROOT = /api

View file

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

File diff suppressed because it is too large Load diff

After

Width:  |  Height:  |  Size: 877 KiB

1
frontend/dist/index.html vendored Normal file
View file

@ -0,0 +1 @@
<!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,user-scalable=0" name="viewport"><link href="/favicon.ico" rel="icon"><title>Nginx UI</title><link href="/js/chunk-15551262.ab2ff742.js" rel="prefetch"><link href="/js/chunk-17d07320.f20a6ac5.js" rel="prefetch"><link href="/js/chunk-1e5147a5.69288dbf.js" rel="prefetch"><link href="/js/chunk-228c37e8.3f33dfe8.js" rel="prefetch"><link href="/js/chunk-23e3da44.08117b5e.js" rel="prefetch"><link href="/js/chunk-2b94df79.6a91b4fd.js" rel="prefetch"><link href="/js/chunk-2d0cf277.99e2e71b.js" rel="prefetch"><link href="/js/chunk-532b3473.eaa829bf.js" rel="prefetch"><link href="/js/chunk-56341220.4ea9419d.js" rel="prefetch"><link href="/js/chunk-742ad954.a1467ad6.js" rel="prefetch"><link href="/js/chunk-96068e84.dcec99db.js" rel="prefetch"><link href="/js/chunk-d4ac245c.36cefb3a.js" rel="prefetch"><link href="/js/chunk-e0ad5fdc.02ec34ba.js" rel="prefetch"><link href="/js/chunk-e71b472c.345ce2d9.js" rel="prefetch"><link href="/js/chunk-vendors.ee48c25c.js" rel="modulepreload" as="script"><link href="/js/index.c8496a25.js" rel="modulepreload" as="script"></head><body><noscript><strong>We're sorry but Nginx UI doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id="app"></div><script type="module" src="/js/chunk-vendors.ee48c25c.js"></script><script type="module" src="/js/index.c8496a25.js"></script><script>!function(){var e=document,t=e.createElement("script");if(!("noModule"in t)&&"onbeforeload"in t){var n=!1;e.addEventListener("beforeload",function(e){if(e.target===t)n=!0;else if(!e.target.hasAttribute("nomodule")||!n)return;e.preventDefault()},!0),t.type="module",t.src=".",e.head.appendChild(t),t.remove()}}();</script><script src="/js/chunk-vendors-legacy.ee48c25c.js" nomodule></script><script src="/js/index-legacy.a529f66e.js" nomodule></script></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
(window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["chunk-1e5147a5"],{"22e7":function(e,t,r){var n=r("24fb");t=n(!1),t.push([e.i,".project-title{margin:50px}.project-title h1{font-size:50px;font-weight:100;text-align:center}.login-form{max-width:500px;margin:0 auto}footer{padding:30px;text-align:center}",""]),e.exports=t},4871:function(e,t,r){var n=r("22e7");n.__esModule&&(n=n.default),"string"===typeof n&&(n=[[e.i,n,""]]),n.locals&&(e.exports=n.locals);var a=r("499e").default;a("749337a0",n,!0,{sourceMap:!1,shadowMode:!1})},"4fde":function(e,t,r){"use strict";r("4871")},a55b:function(e,t,r){"use strict";r.r(t);var n=function(){var e=this,t=e.$createElement,r=e._self._c||t;return r("div",{staticClass:"login-form"},[e._m(0),r("a-form",{staticClass:"login-form",attrs:{id:"components-form-demo-normal-login",form:e.form},on:{submit:e.handleSubmit}},[r("a-form-item",[r("a-input",{directives:[{name:"decorator",rawName:"v-decorator",value:["name",{rules:[{required:!0,message:"Please input your username!"}]}],expression:"[\n 'name',\n { rules: [{ required: true, message: 'Please input your username!' }] },\n ]"}],attrs:{placeholder:"Username"}},[r("a-icon",{staticStyle:{color:"rgba(0,0,0,.25)"},attrs:{slot:"prefix",type:"user"},slot:"prefix"})],1)],1),r("a-form-item",[r("a-input",{directives:[{name:"decorator",rawName:"v-decorator",value:["password",{rules:[{required:!0,message:"Please input your Password!"}]}],expression:"[\n 'password',\n { rules: [{ required: true, message: 'Please input your Password!' }] },\n ]"}],attrs:{type:"password",placeholder:"Password"}},[r("a-icon",{staticStyle:{color:"rgba(0,0,0,.25)"},attrs:{slot:"prefix",type:"lock"},slot:"prefix"})],1)],1),r("a-form-item",[r("a-button",{attrs:{type:"primary",block:!0,"html-type":"submit",loading:e.loading}},[e._v(" 登录 ")])],1)],1),r("footer",[e._v(" Copyright © 2020 - "+e._s(e.thisYear)+" 0xJacky ")])],1)},a=[function(){var e=this,t=e.$createElement,r=e._self._c||t;return r("div",{staticClass:"project-title"},[r("h1",[e._v("Nginx UI")])])}],s=r("1da1"),o=(r("96cf"),r("b0c0"),{name:"Login",data:function(){return{form:{},thisYear:(new Date).getFullYear(),loading:!1}},created:function(){this.$store.state.user.token?this.$router.push("/"):this.form=this.$form.createForm(this)},methods:{login:function(e){var t=this;this.$api.auth.login(e.name,e.password).then(Object(s["a"])(regeneratorRuntime.mark((function e(){var r;return regeneratorRuntime.wrap((function(e){while(1)switch(e.prev=e.next){case 0:return e.next=2,t.$message.success("登录成功",1);case 2:return r=t.$route.query.next?t.$route.query.next:"/",e.next=5,t.$router.push(r);case 5:case"end":return e.stop()}}),e)})))).catch((function(e){var r;console.log(e),t.$message.error(null!==(r=e.message)&&void 0!==r?r:"服务器错误")}))},handleSubmit:function(){var e=Object(s["a"])(regeneratorRuntime.mark((function e(t){var r=this;return regeneratorRuntime.wrap((function(e){while(1)switch(e.prev=e.next){case 0:return t.preventDefault(),this.loading=!0,e.next=4,this.form.validateFields(function(){var e=Object(s["a"])(regeneratorRuntime.mark((function e(t,n){return regeneratorRuntime.wrap((function(e){while(1)switch(e.prev=e.next){case 0:if(t){e.next=3;break}return e.next=3,r.login(n);case 3:case"end":return e.stop()}}),e)})));return function(t,r){return e.apply(this,arguments)}}());case 4:this.loading=!1;case 5:case"end":return e.stop()}}),e,this)})));function t(t){return e.apply(this,arguments)}return t}()}}),i=o,u=(r("4fde"),r("2877")),c=Object(u["a"])(i,n,a,!1,null,null,null);t["default"]=c.exports}}]);

View file

@ -0,0 +1 @@
(window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["chunk-1e5147a5"],{"22e7":function(e,t,r){var n=r("24fb");t=n(!1),t.push([e.i,".project-title{margin:50px}.project-title h1{font-size:50px;font-weight:100;text-align:center}.login-form{max-width:500px;margin:0 auto}footer{padding:30px;text-align:center}",""]),e.exports=t},4871:function(e,t,r){var n=r("22e7");n.__esModule&&(n=n.default),"string"===typeof n&&(n=[[e.i,n,""]]),n.locals&&(e.exports=n.locals);var a=r("499e").default;a("749337a0",n,!0,{sourceMap:!1,shadowMode:!1})},"4fde":function(e,t,r){"use strict";r("4871")},a55b:function(e,t,r){"use strict";r.r(t);var n=function(){var e=this,t=e.$createElement,r=e._self._c||t;return r("div",{staticClass:"login-form"},[e._m(0),r("a-form",{staticClass:"login-form",attrs:{id:"components-form-demo-normal-login",form:e.form},on:{submit:e.handleSubmit}},[r("a-form-item",[r("a-input",{directives:[{name:"decorator",rawName:"v-decorator",value:["name",{rules:[{required:!0,message:"Please input your username!"}]}],expression:"[\n 'name',\n { rules: [{ required: true, message: 'Please input your username!' }] },\n ]"}],attrs:{placeholder:"Username"}},[r("a-icon",{staticStyle:{color:"rgba(0,0,0,.25)"},attrs:{slot:"prefix",type:"user"},slot:"prefix"})],1)],1),r("a-form-item",[r("a-input",{directives:[{name:"decorator",rawName:"v-decorator",value:["password",{rules:[{required:!0,message:"Please input your Password!"}]}],expression:"[\n 'password',\n { rules: [{ required: true, message: 'Please input your Password!' }] },\n ]"}],attrs:{type:"password",placeholder:"Password"}},[r("a-icon",{staticStyle:{color:"rgba(0,0,0,.25)"},attrs:{slot:"prefix",type:"lock"},slot:"prefix"})],1)],1),r("a-form-item",[r("a-button",{attrs:{type:"primary",block:!0,"html-type":"submit",loading:e.loading}},[e._v(" 登录 ")])],1)],1),r("footer",[e._v(" Copyright © 2020 - "+e._s(e.thisYear)+" 0xJacky ")])],1)},a=[function(){var e=this,t=e.$createElement,r=e._self._c||t;return r("div",{staticClass:"project-title"},[r("h1",[e._v("Nginx UI")])])}],s=r("1da1"),o=(r("96cf"),r("b0c0"),{name:"Login",data:function(){return{form:{},thisYear:(new Date).getFullYear(),loading:!1}},created:function(){this.$store.state.user.token?this.$router.push("/"):this.form=this.$form.createForm(this)},methods:{login:function(e){var t=this;this.$api.auth.login(e.name,e.password).then(Object(s["a"])(regeneratorRuntime.mark((function e(){var r;return regeneratorRuntime.wrap((function(e){while(1)switch(e.prev=e.next){case 0:return e.next=2,t.$message.success("登录成功",1);case 2:return r=t.$route.query.next?t.$route.query.next:"/",e.next=5,t.$router.push(r);case 5:case"end":return e.stop()}}),e)})))).catch((function(e){var r;console.log(e),t.$message.error(null!==(r=e.message)&&void 0!==r?r:"服务器错误")}))},handleSubmit:function(){var e=Object(s["a"])(regeneratorRuntime.mark((function e(t){var r=this;return regeneratorRuntime.wrap((function(e){while(1)switch(e.prev=e.next){case 0:return t.preventDefault(),this.loading=!0,e.next=4,this.form.validateFields(function(){var e=Object(s["a"])(regeneratorRuntime.mark((function e(t,n){return regeneratorRuntime.wrap((function(e){while(1)switch(e.prev=e.next){case 0:if(t){e.next=3;break}return e.next=3,r.login(n);case 3:case"end":return e.stop()}}),e)})));return function(t,r){return e.apply(this,arguments)}}());case 4:this.loading=!1;case 5:case"end":return e.stop()}}),e,this)})));function t(t){return e.apply(this,arguments)}return t}()}}),i=o,u=(r("4fde"),r("2877")),c=Object(u["a"])(i,n,a,!1,null,null,null);t["default"]=c.exports}}]);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
(window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["chunk-96068e84"],{"0d92":function(e,t,a){"use strict";a("e6a6")},"7c22":function(e,t,a){var n=a("24fb");t=n(!1),t.push([e.i,".egg[data-v-0db462a3]{padding:10px 0}.ant-btn[data-v-0db462a3]{margin:10px 10px 0 0}",""]),e.exports=t},e6a6:function(e,t,a){var n=a("7c22");n.__esModule&&(n=n.default),"string"===typeof n&&(n=[[e.i,n,""]]),n.locals&&(e.exports=n.locals);var r=a("499e").default;r("1fb1813a",n,!0,{sourceMap:!1,shadowMode:!1})},f820:function(e,t,a){"use strict";a.r(t);var n=function(){var e=this,t=e.$createElement,a=e._self._c||t;return a("a-card",[a("h2",[e._v("Nginx UI")]),a("p",[e._v("Yet another WebUI for Nginx")]),a("p",[e._v("Version: "+e._s(e.version)+" ("+e._s(e.build_id)+")")]),a("h3",[e._v("项目组")]),a("p",[e._v("Designer"),a("a",{attrs:{href:"https://jackyu.cn/"}},[e._v("@0xJacky")])]),a("h3",[e._v("技术栈")]),a("p",[e._v("Go")]),a("p",[e._v("Gin")]),a("p",[e._v("Vue")]),a("p",[e._v("Websocket")]),a("h3",[e._v("开源协议")]),a("p",[e._v("GNU General Public License v2.0")]),a("p",[e._v("Copyright © 2020 - "+e._s(e.this_year)+" 0xJacky ")])])},r=[],s=a("1da1"),i=(a("96cf"),{name:"About",data:function(){var e,t=new Date;return{this_year:t.getFullYear(),version:"1.0.0",build_id:null!==(e="6")&&void 0!==e?e:"开发模式",api_root:"/api"}},methods:{changeUserPower:function(e){var t=this;return Object(s["a"])(regeneratorRuntime.mark((function a(){return regeneratorRuntime.wrap((function(a){while(1)switch(a.prev=a.next){case 0:return a.next=2,t.$store.dispatch("update_mock_user",{power:e});case 2:return a.next=4,t.$api.user.info();case 4:return a.next=6,t.$message.success("修改成功");case 6:case"end":return a.stop()}}),a)})))()}}}),o=i,c=(a("0d92"),a("2877")),u=Object(c["a"])(o,n,r,!1,null,"0db462a3",null);t["default"]=u.exports}}]);

View file

@ -0,0 +1 @@
(window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["chunk-96068e84"],{"0d92":function(e,t,a){"use strict";a("e6a6")},"7c22":function(e,t,a){var n=a("24fb");t=n(!1),t.push([e.i,".egg[data-v-0db462a3]{padding:10px 0}.ant-btn[data-v-0db462a3]{margin:10px 10px 0 0}",""]),e.exports=t},e6a6:function(e,t,a){var n=a("7c22");n.__esModule&&(n=n.default),"string"===typeof n&&(n=[[e.i,n,""]]),n.locals&&(e.exports=n.locals);var r=a("499e").default;r("1fb1813a",n,!0,{sourceMap:!1,shadowMode:!1})},f820:function(e,t,a){"use strict";a.r(t);var n=function(){var e=this,t=e.$createElement,a=e._self._c||t;return a("a-card",[a("h2",[e._v("Nginx UI")]),a("p",[e._v("Yet another WebUI for Nginx")]),a("p",[e._v("Version: "+e._s(e.version)+" ("+e._s(e.build_id)+")")]),a("h3",[e._v("项目组")]),a("p",[e._v("Designer"),a("a",{attrs:{href:"https://jackyu.cn/"}},[e._v("@0xJacky")])]),a("h3",[e._v("技术栈")]),a("p",[e._v("Go")]),a("p",[e._v("Gin")]),a("p",[e._v("Vue")]),a("p",[e._v("Websocket")]),a("h3",[e._v("开源协议")]),a("p",[e._v("GNU General Public License v2.0")]),a("p",[e._v("Copyright © 2020 - "+e._s(e.this_year)+" 0xJacky ")])])},r=[],s=a("1da1"),i=(a("96cf"),{name:"About",data:function(){var e,t=new Date;return{this_year:t.getFullYear(),version:"1.0.0",build_id:null!==(e="6")&&void 0!==e?e:"开发模式",api_root:"/api"}},methods:{changeUserPower:function(e){var t=this;return Object(s["a"])(regeneratorRuntime.mark((function a(){return regeneratorRuntime.wrap((function(a){while(1)switch(a.prev=a.next){case 0:return a.next=2,t.$store.dispatch("update_mock_user",{power:e});case 2:return a.next=4,t.$api.user.info();case 4:return a.next=6,t.$message.success("修改成功");case 6:case"end":return a.stop()}}),a)})))()}}}),o=i,c=(a("0d92"),a("2877")),u=Object(c["a"])(o,n,r,!1,null,"0db462a3",null);t["default"]=u.exports}}]);

View file

@ -0,0 +1 @@
(window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["chunk-d4ac245c"],{4159:function(t,e,n){var a=n("24fb");e=a(!1),e.push([t.i,".ant-card[data-v-6d72b90c]{margin:10px}@media (max-width:512px){.ant-card[data-v-6d72b90c]{margin:10px 0}}",""]),t.exports=e},9963:function(t,e,n){var a=n("4159");a.__esModule&&(a=a.default),"string"===typeof a&&(a=[[t.i,a,""]]),a.locals&&(t.exports=a.locals);var o=n("499e").default;o("7aad0ace",a,!0,{sourceMap:!1,shadowMode:!1})},afa2:function(t,e,n){"use strict";n("9963")},e33d:function(t,e,n){"use strict";n.r(e);var a=function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("a-card",{attrs:{title:"配置文件编辑"}},[n("vue-itextarea",{model:{value:t.configText,callback:function(e){t.configText=e},expression:"configText"}}),n("footer-tool-bar",[n("a-space",[n("a-button",{on:{click:function(e){return t.$router.go(-1)}}},[t._v("返回")]),n("a-button",{attrs:{type:"primary"},on:{click:t.save}},[t._v("保存")])],1)],1)],1)},o=[],c=(n("b0c0"),n("9c70")),i=n("a002"),s={name:"DomainEdit",components:{FooterToolBar:c["a"],VueItextarea:i["a"]},data:function(){return{name:this.$route.params.name,configText:""}},watch:{$route:function(){this.config={},this.configText=""},config:{handler:function(){this.unparse()},deep:!0}},created:function(){var t=this;this.name?this.$api.config.get(this.name).then((function(e){t.configText=e.config})).catch((function(e){console.log(e),t.$message.error("服务器错误")})):this.configText=""},methods:{save:function(){var t=this;this.$api.config.save(this.name?this.name:this.config.name,{content:this.configText}).then((function(e){t.configText=e.config,t.$message.success("保存成功")})).catch((function(e){console.log(e),t.$message.error("保存错误")}))}}},r=s,f=(n("afa2"),n("2877")),u=Object(f["a"])(r,a,o,!1,null,"6d72b90c",null);e["default"]=u.exports}}]);

View file

@ -0,0 +1 @@
(window["webpackJsonp"]=window["webpackJsonp"]||[]).push([["chunk-d4ac245c"],{4159:function(t,e,n){var a=n("24fb");e=a(!1),e.push([t.i,".ant-card[data-v-6d72b90c]{margin:10px}@media (max-width:512px){.ant-card[data-v-6d72b90c]{margin:10px 0}}",""]),t.exports=e},9963:function(t,e,n){var a=n("4159");a.__esModule&&(a=a.default),"string"===typeof a&&(a=[[t.i,a,""]]),a.locals&&(t.exports=a.locals);var o=n("499e").default;o("7aad0ace",a,!0,{sourceMap:!1,shadowMode:!1})},afa2:function(t,e,n){"use strict";n("9963")},e33d:function(t,e,n){"use strict";n.r(e);var a=function(){var t=this,e=t.$createElement,n=t._self._c||e;return n("a-card",{attrs:{title:"配置文件编辑"}},[n("vue-itextarea",{model:{value:t.configText,callback:function(e){t.configText=e},expression:"configText"}}),n("footer-tool-bar",[n("a-space",[n("a-button",{on:{click:function(e){return t.$router.go(-1)}}},[t._v("返回")]),n("a-button",{attrs:{type:"primary"},on:{click:t.save}},[t._v("保存")])],1)],1)],1)},o=[],c=(n("b0c0"),n("9c70")),i=n("a002"),s={name:"DomainEdit",components:{FooterToolBar:c["a"],VueItextarea:i["a"]},data:function(){return{name:this.$route.params.name,configText:""}},watch:{$route:function(){this.config={},this.configText=""},config:{handler:function(){this.unparse()},deep:!0}},created:function(){var t=this;this.name?this.$api.config.get(this.name).then((function(e){t.configText=e.config})).catch((function(e){console.log(e),t.$message.error("服务器错误")})):this.configText=""},methods:{save:function(){var t=this;this.$api.config.save(this.name?this.name:this.config.name,{content:this.configText}).then((function(e){t.configText=e.config,t.$message.success("保存成功")})).catch((function(e){console.log(e),t.$message.error("保存错误")}))}}},r=s,f=(n("afa2"),n("2877")),u=Object(f["a"])(r,a,o,!1,null,"6d72b90c",null);e["default"]=u.exports}}]);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
frontend/dist/js/index.c8496a25.js vendored Normal file

File diff suppressed because one or more lines are too long

1
frontend/dist/version.json vendored Normal file
View file

@ -0,0 +1 @@
{"version":"1.0.0","build_id":2,"total_build":6}

View file

@ -1,6 +1,6 @@
{
"name": "nginx-ui-frontend",
"version": "0.1.1",
"version": "1.0.0",
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
@ -15,12 +15,23 @@
"reconnecting-websocket": "^4.4.0",
"vue": "^2.6.11",
"vue-chartjs": "^3.5.1",
"vue-codemirror": "^4.0.6",
"vue-itextarea": "^1.0.9",
"vue-router": "^3.5.1",
"vuex": "^3.6.2",
"vuex-persist": "^3.1.3"
},
"devDependencies": {
"@tiptap/extension-character-count": "^2.0.0-beta.13",
"@tiptap/extension-code-block-lowlight": "^2.0.0-beta.33",
"@tiptap/extension-document": "^2.0.0-beta.13",
"@tiptap/extension-highlight": "^2.0.0-beta.15",
"@tiptap/extension-paragraph": "^2.0.0-beta.16",
"@tiptap/extension-task-item": "^2.0.0-beta.17",
"@tiptap/extension-task-list": "^2.0.0-beta.17",
"@tiptap/extension-text": "^2.0.0-beta.13",
"@tiptap/starter-kit": "^2.0.0-beta.90",
"@tiptap/vue-2": "^2.0.0-beta.42",
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-plugin-router": "~4.5.0",
@ -32,9 +43,14 @@
"eslint-plugin-vue": "^6.2.2",
"less": "^3.11.1",
"less-loader": "^5.0.0",
"lowlight": "^1.20.0",
"moment": "^2.24.0",
"node-sass": "^6.0.1",
"nprogress": "^0.2.0",
"vue-cli-plugin-generate-build-id": "0.1.0",
"remixicon": "^2.5.0",
"sass-loader": "^10",
"vue-cli-plugin-generate-build-id": "^0.2.0",
"vue-cropper": "^0.5.6",
"vue-template-compiler": "^2.6.11"
},
"eslintConfig": {

View file

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

View file

@ -11,8 +11,8 @@ const auth = {
})
},
logout() {
return http.delete('/logout').then(() => {
store.dispatch('logout').finally()
return http.delete('/logout').then(async () => {
await store.dispatch('logout')
})
}
}

View file

@ -1,9 +1,11 @@
import domain from './domain'
import config from './config'
import auth from './auth'
import user from './user'
export default {
domain,
config,
auth
auth,
user
}

23
frontend/src/api/user.js Normal file
View file

@ -0,0 +1,23 @@
import http from '@/lib/http'
const base_url = '/user'
const user = {
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 user

View file

@ -148,3 +148,10 @@ button {
.ant-card {
box-shadow: 0 0 30px rgba(200, 200, 200, 0.25);
}
.ant-collapse {
background: #ffffff;
@media (prefers-color-scheme: dark) {
background: #28292c;
}
}

View file

@ -0,0 +1,57 @@
<template>
<node-view-wrapper class="code-block">
<select contenteditable="false" v-model="selectedLanguage">
<option :value="null">
auto
</option>
<option disabled>
</option>
<option v-for="(language, index) in languages" :value="language" :key="index">
{{ language }}
</option>
</select>
<pre><node-view-content as="code" /></pre>
</node-view-wrapper>
</template>
<script>
import { NodeViewWrapper, NodeViewContent, nodeViewProps } from '@tiptap/vue-2'
export default {
components: {
NodeViewWrapper,
NodeViewContent,
},
props: nodeViewProps,
data() {
return {
languages: this.extension.options.lowlight.listLanguages(),
}
},
computed: {
selectedLanguage: {
get() {
return (this.node.attrs.language ? this.node.attrs.language.split('')[0] : null)
},
set(language) {
this.updateAttributes({ language })
},
},
},
}
</script>
<style lang="scss" scoped>
.code-block {
position: relative;
select {
position: absolute;
top: 0.5rem;
right: 0.5rem;
}
}
</style>

View file

@ -0,0 +1,162 @@
<template>
<div>
<template v-for="(item, index) in items">
<div class="divider" v-if="item.type === 'divider'" :key="index" />
<menu-item v-else :key="index" v-bind="item" />
</template>
</div>
</template>
<script>
import MenuItem from './MenuItem.vue'
export default {
components: {
MenuItem,
},
props: {
editor: {
type: Object,
required: true,
},
},
data() {
return {
items: [
{
icon: 'bold',
title: '加粗',
action: () => this.editor.chain().focus().toggleBold().run(),
isActive: () => this.editor.isActive('bold'),
},
{
icon: 'italic',
title: '斜体',
action: () => this.editor.chain().focus().toggleItalic().run(),
isActive: () => this.editor.isActive('italic'),
},
{
icon: 'strikethrough',
title: '删除线',
action: () => this.editor.chain().focus().toggleStrike().run(),
isActive: () => this.editor.isActive('strike'),
},
{
icon: 'code-view',
title: '行内代码',
action: () => this.editor.chain().focus().toggleCode().run(),
isActive: () => this.editor.isActive('code'),
},
{
icon: 'mark-pen-line',
title: '高亮',
action: () => this.editor.chain().focus().toggleHighlight().run(),
isActive: () => this.editor.isActive('highlight'),
},
{
type: 'divider',
},
{
icon: 'h-1',
title: '一级标题',
action: () => this.editor.chain().focus().toggleHeading({ level: 1 }).run(),
isActive: () => this.editor.isActive('heading', { level: 1 }),
},
{
icon: 'h-2',
title: '二级标题',
action: () => this.editor.chain().focus().toggleHeading({ level: 2 }).run(),
isActive: () => this.editor.isActive('heading', { level: 2 }),
},
{
icon: 'paragraph',
title: '段落',
action: () => this.editor.chain().focus().setParagraph().run(),
isActive: () => this.editor.isActive('paragraph'),
},
{
icon: 'list-unordered',
title: '无序列表',
action: () => this.editor.chain().focus().toggleBulletList().run(),
isActive: () => this.editor.isActive('bulletList'),
},
{
icon: 'list-ordered',
title: '有序列表',
action: () => this.editor.chain().focus().toggleOrderedList().run(),
isActive: () => this.editor.isActive('orderedList'),
},
{
icon: 'list-check-2',
title: '任务列表',
action: () => this.editor.chain().focus().toggleTaskList().run(),
isActive: () => this.editor.isActive('taskList'),
},
{
icon: 'code-box-line',
title: '代码块',
action: () => this.editor.chain().focus().toggleCodeBlock().run(),
isActive: () => this.editor.isActive('codeBlock'),
},
{
type: 'divider',
},
{
icon: 'double-quotes-l',
title: '引用',
action: () => this.editor.chain().focus().toggleBlockquote().run(),
isActive: () => this.editor.isActive('blockquote'),
},
{
icon: 'separator',
title: '水平分割线',
action: () => this.editor.chain().focus().setHorizontalRule().run(),
},
{
type: 'divider',
},
{
icon: 'text-wrap',
title: '换行',
action: () => this.editor.chain().focus().setHardBreak().run(),
},
{
icon: 'format-clear',
title: '清除格式',
action: () => this.editor.chain()
.focus()
.clearNodes()
.unsetAllMarks()
.run(),
},
{
type: 'divider',
},
{
icon: 'arrow-go-back-line',
title: '撤回',
action: () => this.editor.chain().focus().undo().run(),
},
{
icon: 'arrow-go-forward-line',
title: '重做',
action: () => this.editor.chain().focus().redo().run(),
},
],
}
},
}
</script>
<style lang="less" scoped>
.divider {
width: 2px;
height: 1.25rem;
background-color: rgba(#999999, 0.1);
margin-left: 0.5rem;
margin-right: 0.75rem;
display: inline-block;
}
</style>

View file

@ -0,0 +1,71 @@
<template>
<a-tooltip>
<template slot="title">
{{ title }}
</template>
<button
class="menu-item"
:class="{ 'is-active': isActive ? isActive(): null }"
@click="action"
:title="title"
>
<svg class="remix">
<use :xlink:href="require('remixicon/fonts/remixicon.symbol.svg') + `#ri-${icon}`" />
</svg>
</button>
</a-tooltip>
</template>
<script>
export default {
props: {
icon: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
action: {
type: Function,
required: true,
},
isActive: {
type: Function,
default: null,
},
},
}
</script>
<style lang="less" scoped>
.menu-item {
width: 1.75rem;
height: 1.75rem;
color: #0D0D0D;
@media (prefers-color-scheme: dark) {
color: #bdbdbd;
}
border: none;
background-color: transparent;
border-radius: 0.4rem;
padding: 0.25rem;
margin-right: 0.25rem;
svg {
width: 100%;
height: 100%;
fill: currentColor;
}
&.is-active,
&:hover {
color: #FFF;
background-color: #1e1f20;
}
}
</style>

View file

@ -0,0 +1,15 @@
<template>
<div class="ProseMirror" v-html="html"></div>
</template>
<script>
export default {
name: "RichText",
props: ['html']
}
</script>
<style lang="less">
@import "style";
</style>

View file

@ -0,0 +1,151 @@
<template>
<div class="editor" v-if="editor">
<menu-bar class="editor__header" :editor="editor"/>
<editor-content :editor="editor"/>
</div>
</template>
<script>
import {Editor, EditorContent, VueNodeViewRenderer} from '@tiptap/vue-2'
import StarterKit from '@tiptap/starter-kit'
import Document from '@tiptap/extension-document'
import Paragraph from '@tiptap/extension-paragraph'
import Highlight from '@tiptap/extension-highlight'
import Text from '@tiptap/extension-text'
import TaskList from '@tiptap/extension-task-list'
import TaskItem from '@tiptap/extension-task-item'
import CharacterCount from '@tiptap/extension-character-count'
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
import CodeBlockComponent from './CodeBlockComponent'
import MenuBar from './MenuBar.vue'
import lowlight from 'lowlight'
export default {
components: {
EditorContent,
MenuBar,
},
data() {
return {
editor: null,
}
},
props: {
value: {
type: String,
default: '',
},
},
model: {
prop: 'value',
event: 'changeValue'
},
watch: {
value(value) {
// HTML
const isSame = this.editor.getHTML() === value
// JSON
// const isSame = this.editor.getJSON().toString() === value.toString()
if (isSame) {
return
}
this.editor.commands.setContent(this.value, false)
},
},
created() {
const that = this
this.editor = new Editor({
onUpdate({editor}) {
that.$emit('changeValue', editor.getHTML())
},
content: '',
extensions: [
StarterKit,
Document,
Paragraph,
Text,
TaskList,
TaskItem,
CharacterCount,
Highlight,
CodeBlockLowlight
.extend({
addNodeView() {
return VueNodeViewRenderer(CodeBlockComponent)
},
}).configure({lowlight}),
],
})
},
mounted() {
this.editor.commands.setContent(this.value, false)
},
beforeDestroy() {
this.editor.destroy()
},
}
</script>
<style lang="less">
.ant-affix {
z-index: 8 !important;
}
</style>
<style lang="less" scoped>
.editor {
display: flex;
flex-direction: column;
border-radius: 0.75rem;
@gray: rgba(0, 0, 0, 0.2);
background-color: #FFFFFF;
@media (prefers-color-scheme: dark) {
@gray: #666666;
border: 1px solid @gray;
background-color: #28292c;
&__header {
border-bottom: 1px solid @gray;
}
}
border: 1px solid @gray;
line-height: 1.5!important;
&__header {
display: flex;
align-items: center;
flex: 0 0 auto;
flex-wrap: wrap;
padding: 0.25rem;
border-bottom: 1px solid @gray;
}
&__content {
padding: 1.25rem 1rem;
flex: 1 1 auto;
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
}
</style>
<style lang="less">
@import "style";
</style>
<style lang="less">
.editor .ProseMirror {
height: 500px;
overflow: scroll;
padding: 15px;
}
</style>

View file

@ -0,0 +1,148 @@
/* Basic editor styles */
.ProseMirror-focused {
outline: unset;
}
.ProseMirror {
> * + * {
margin-top: 0.75em;
}
ul,
ol {
padding: 0 1rem;
}
h1 {
font-size: 22px;
}
h2 {
font-size: 18px;
}
h1,
h2,
h3,
h4,
h5,
h6 {
line-height: 1.1;
font-weight: 500;
}
p {
padding: 0;
}
code {
background-color: rgba(#616161, 0.1);
color: #616161;
}
pre {
background: #0D0D0D;
color: #FFF;
font-family: 'JetBrainsMono', monospace;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
code {
color: inherit;
padding: 0;
background: none;
font-size: 0.8rem;
}
}
mark {
background-color: #FAF594;
}
img {
max-width: 100%;
height: auto;
}
hr {
margin: 1rem 0;
}
blockquote {
padding-left: 1rem;
border-left: 2px solid rgba(#0D0D0D, 0.1);
}
hr {
border: none;
border-top: 2px solid rgba(#0D0D0D, 0.1);
margin: 2rem 0;
}
ul[data-type="taskList"] {
list-style: none;
padding: 0;
li {
display: flex;
align-items: center;
> label {
flex: 0 0 auto;
margin-right: 0.5rem;
}
}
}
.hljs-comment,
.hljs-quote {
color: #616161;
}
.hljs-variable,
.hljs-template-variable,
.hljs-attribute,
.hljs-tag,
.hljs-name,
.hljs-regexp,
.hljs-link,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class {
color: #F98181;
}
.hljs-number,
.hljs-meta,
.hljs-built_in,
.hljs-builtin-name,
.hljs-literal,
.hljs-type,
.hljs-params {
color: #FBBC88;
}
.hljs-string,
.hljs-symbol,
.hljs-bullet {
color: #B9F18D;
}
.hljs-title,
.hljs-section {
color: #FAF594;
}
.hljs-keyword,
.hljs-selector-tag {
color: #70CFF8;
}
.hljs-emphasis {
font-style: italic;
}
.hljs-strong {
font-weight: 700;
}
}

View file

@ -0,0 +1,220 @@
<template>
<div class="std-curd">
<a-card :title="title">
<a v-if="!disable_add" slot="extra" @click="add">添加</a>
<std-table
ref="table"
v-bind="this.$props"
@clickEdit="edit"
@selected="onSelect"
:key="update"
>
<template v-slot:actions="slotProps">
<slot name="actions" :actions="slotProps.record"/>
</template>
</std-table>
</a-card>
<a-modal
class="std-curd-edit-modal"
:mask="false"
:title="data.id ? '编辑 ID: ' + data.id : '添加'"
:visible="visible"
cancel-text="关闭"
ok-text="保存"
@cancel="visible=false;error={}"
@ok="ok"
:width="600"
destroyOnClose
>
<std-data-entry ref="std_data_entry" :data-list="editableColumns()" :data-source="data"
:error="error">
<div slot="supplement">
<slot name="supplement"></slot>
</div>
<div slot="action">
<slot name="action"></slot>
</div>
</std-data-entry>
</a-modal>
<footer-tool-bar v-if="batch_columns.length">
<a-space>
当前已选中{{ selected.length }}条数据
<a-button :disabled="!selected.length"
@click="selected=[];update++">清空选中
</a-button>
<a-button type="primary"
:disabled="!selected.length"
@click="visible_batch_edit=true" ghost>批量修改
</a-button>
</a-space>
</footer-tool-bar>
<a-modal
:mask="false"
title="批量修改"
:visible="visible_batch_edit"
cancel-text="取消"
ok-text="保存"
@cancel="visible_batch_edit=false"
@ok="okBatchEdit"
>
留空则不修改
<std-data-entry :data-list="batch_columns" :data-source="data"/>
</a-modal>
</div>
</template>
<script>
import StdTable from './StdTable'
import StdDataEntry from '@/components/StdDataEntry/StdDataEntry'
import FooterToolBar from "@/components/FooterToolbar/FooterToolBar"
export default {
name: 'StdCurd',
components: {
StdTable,
StdDataEntry,
FooterToolBar
},
props: {
api: Object,
columns: Array,
title: {
type: String,
default: '列表'
},
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
},
get_params: {
type: Object,
default() {
return {}
}
},
editable: {
type: Boolean,
default: true
},
},
data() {
return {
visible: false,
visible_batch_edit: false,
data: {
id: null,
},
error: {},
params: {},
selected: [],
batch_columns: this.batchColumns(),
update: 0,
}
},
methods: {
onSelect(keys) {
this.selected = keys
},
batchColumns() {
return this.columns.filter((column) => {
return column.batch
&& column.edit && column.edit.type !== 'upload'
&& column.edit.type !== 'transfer'
})
},
okBatchEdit() {
this.api.batchSave(this.selected, this.data)
.then(() => {
this.$message.success('批量修改成功')
this.$refs.table.get_list()
}).catch(e => {
this.$message.error(e.message)
})
},
editableColumns() {
return this.columns.filter((c) => {
return c.edit
})
},
uploadColumns() {
return this.columns.filter(c => {
return c.edit && c.edit.type === 'upload'
})
},
async add() {
this.data = {
id: null
}
this.visible = true
},
async do_upload() {
const columns = await this.uploadColumns()
for (let i = 0; i < columns.length; i++) {
const refs = this.$refs.std_data_entry.$refs
const t = refs['std_upload_' + columns[i].dataIndex][0]
if (t) {
await t.upload()
}
}
},
async ok() {
this.error = {}
if (this.data.id) {
await this.do_upload()
this.api.save((this.data.id ? this.data.id : null), this.data).then(r => {
this.$message.success('保存成功')
this.data = Object.assign(this.data, r)
this.$refs.table.get_list()
}).catch(error => {
this.$message.error((error.message ? error.message : '保存失败'), 5)
this.error = error.errors
})
} else {
this.api.save((this.data.id ? this.data.id : null), this.data).then(r => {
this.$message.success('保存成功')
this.data = this.extend(this.data, r)
this.$nextTick().then(() => {
this.do_upload()
})
this.$refs.table.get_list()
}).catch(error => {
this.$message.error((error.message ? error.message : '保存失败'), 5)
this.error = error.errors
})
}
},
edit(id) {
this.api.get(id).then(r => {
this.data = r
this.visible = true
}).catch(e => {
console.log(e)
this.$message.error('系统错误')
})
}
}
}
</script>
<style lang="less" scoped>
</style>

View file

@ -6,6 +6,7 @@
:pageSize="pagination.per_page"
:size="size"
:total="pagination.total"
:show-total="(total, range) => `当前显示${range[0]}-${range[1]}条数据,共${total}条数据`"
class="pagination"
@change="changePage"
/>
@ -30,9 +31,21 @@ export default {
}
</script>
<style scoped>
<style lang="less">
.ant-pagination-total-text {
@media (max-width: 450px) {
display: block;
}
}
</style>
<style lang="less" scoped>
.pagination {
padding: 10px 0 0 0;
float: right;
@media (max-width: 450px) {
float: unset;
text-align: center;
}
}
</style>

View file

@ -0,0 +1,339 @@
<template>
<div class="std-table">
<std-data-entry
v-if="!disable_search"
:data-list="searchColumns"
v-model="params"
layout="inline"
>
<div slot="action">
<a-form-item :wrapper-col="{span:8}">
<a-button type="primary" @click="$router.push({
query: Object.assign({}, params),
}).catch(() => {})">查询
</a-button>
</a-form-item>
<a-form-item :wrapper-col="{span:8}">
<a-button @click="reset_search">重置</a-button>
</a-form-item>
</div>
</std-data-entry>
<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"
:customRow="row"
:data-source="data_source"
:loading="loading"
:pagination="false"
:row-key="rowKey"
:rowSelection="{selectedRowKeys: selectedRowKeys, onChange: onSelectChange,
onSelect: onSelect, type: selectionType,}"
@change="stdChange"
:scroll="{ x: scrollX }"
>
<template
v-for="c in pithyColumns"
:slot="c.scopedSlots.customRender"
slot-scope="text, record"
>
<div 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 }}
</div>
<span v-else-if="c.datetime"
:key="c.dataIndex">{{ text ? moment(text).format("yyyy-MM-DD HH:mm:ss") : '无' }}</span>
<span v-else-if="c.date" :key="c.dataIndex">{{ text ? moment(text).format("yyyy-MM-DD") : '无' }}</span>
<div 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>
</div>
<span v-else :key="c.dataIndex">{{ text != null ? (c.mask ? c.mask[text] : text) : c.default }}</span>
</template>
<div class="std_action" v-if="!pithy" slot="action" slot-scope="text, record">
<a v-if="editable" @click="$emit('clickEdit', record[rowKey], record)">
<template v-if="edit_text">{{ edit_text }}</template>
<template v-else>编辑</template>
</a>
<slot name="actions" :record="record" />
<template v-if="deletable">
<a-divider type="vertical"/>
<a-popconfirm
v-if="soft_delete&&params.trashed"
cancelText="再想想"
okText="是的" title="你确定要反删除?"
@confirm="restore(record.id)">
<a href="javascript:;">反删除</a>
</a-popconfirm>
<a-popconfirm
v-else
cancelText="再想想"
okText="是的" title="你确定要删除?"
@confirm="destroy(record.id)"
>
<a href="javascript:;">删除</a>
</a-popconfirm>
</template>
</div>
</a-table>
<std-pagination :pagination="pagination" @changePage="get_list"/>
</div>
</template>
<script>
import StdPagination from './StdPagination'
import moment from "moment"
import StdDataEntry from "@/components/StdDataEntry/StdDataEntry"
export default {
name: 'StdTable',
components: {
StdDataEntry,
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
},
editable: {
type: Boolean,
default: true
},
get_params: {
type: Object,
default() {
return {}
}
},
scrollX: {
type: [Number, Boolean],
default: true
},
rowKey: {
type: String,
default: 'id'
}
},
data() {
return {
moment,
data_source: [],
loading: true,
pagination: {
total: 1,
per_page: 10,
current_page: 1,
total_pages: 1
},
params: {
...this.$route.query,
...this.get_params
},
selectedRowKeys: [],
rowSelection: {},
searchColumns: this.get_searchColumns(),
pithyColumns: this.get_pithyColumns(),
}
},
watch: {
$route() {
this.get_list()
}
},
created() {
this.get_list()
},
methods: {
get_list(page_num = null) {
this.loading = true
if (page_num) {
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 => {
console.log(e)
if (e.message) {
this.$message.error('错误 ' + e.message)
} else {
this.$message.error('系统错误')
}
})
},
restore(id) {
this.api.restore(id).then(() => {
this.get_list()
this.$message.success('反删除 ID: ' + id + ' 成功')
}).catch(e => {
console.log(e)
if (e.message) {
this.$message.error('错误' + e.message)
} else {
this.$message.error('系统错误')
}
})
},
get_searchColumns() {
let searchColumns = []
this.columns.forEach(column => {
if (column.search) {
if (column.edit && column.edit.type !== 'upload'
&& column.edit.type !== 'transfer') {
const tmp = Object.assign({}, column)
tmp.edit = Object.assign({}, column.edit)
if (typeof column.search === "string") {
tmp.edit.type = column.search
} else if (typeof column.search === "object") {
tmp.edit = column.search
}
searchColumns.push(tmp)
}
// search edit
if (!column.edit) {
const tmp = Object.assign({}, column)
tmp.edit = Object.assign({}, column.edit)
if (typeof column.search === "object") {
tmp.edit = column.search
}
searchColumns.push(tmp)
}
}
})
return searchColumns
},
get_pithyColumns() {
if (this.pithy) {
return this.columns.filter((c, index, columns) => {
let display = c.pithy === true && c.display !== false
columns[index]['scopedSlots'] = {}
columns[index]['scopedSlots']['customRender'] =
c.dataIndex !== 'title' ? c.dataIndex : '_' + c.dataIndex
return display
})
}
return this.columns.filter((c, index, columns) => {
let display = c.display !== false
columns[index]['scopedSlots'] = {}
columns[index]['scopedSlots']['customRender'] =
c.dataIndex !== 'title' ? c.dataIndex : '_' + c.dataIndex
return display
})
},
checked(c) {
this.params[c.target.value] = c.target.checked
},
onSelectChange(selectedRowKeys) {
this.selectedRowKeys = selectedRowKeys
this.$emit('selected', selectedRowKeys)
},
onSelect(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.$router.push({query: {}}).catch(() => {
})
}
}
}
</script>
<style lang="less">
.ant-table-scroll {
.ant-table-body {
overflow-x: auto!important;
}
}
</style>
<style lang="less" scoped>
.ant-form {
margin: 10px 0 20px 0;
}
.ant-slider {
min-width: 90px;
}
.std-table {
.ant-table-wrapper {
// overflow-x: scroll;
}
}
</style>

View file

@ -0,0 +1,61 @@
<template>
<div>
<template v-for="(v,k) in options">
<a-checkable-tag
:key="k"
:checked="selectedTag === k"
@change="() => handleChange(k)"
>
{{ v }}
</a-checkable-tag>
</template>
</div>
</template>
<script>
export default {
name: "StdCheckTag",
data() {
return {
selectedTag: '',
}
},
props: {
value: [Number, String, Boolean],
options: [Array, Object],
keyType: {
type: String,
default() {
return 'int'
}
}
},
model: {
prop: 'value',
event: 'change'
},
methods: {
handleChange(tag) {
this.selectedTag = tag
this.$emit('change', isNaN(parseInt(tag)) || this.keyType === 'string' ? tag : parseInt(tag))
}
},
watch: {
value() {
this.selectedTag = this.value != null ? this.value.toString() : null
}
},
created() {
this.selectedTag = this.value != null ? this.value.toString() : null
}
}
</script>
<style lang="less" scoped>
.ant-tag {
background-color: rgba(0, 0, 0, 0.05);
}
.ant-tag-checkable-checked {
background-color: #1890ff;
}
</style>

View file

@ -7,7 +7,7 @@
: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-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
@ -15,6 +15,13 @@
v-model="temp[d.dataIndex]"
:options="d.mask"
:key-type="d.edit.key_type ? d.edit.key_type : 'int'"
style="min-width: 120px"
/>
<std-check-tag
v-else-if="d.edit.type==='check-tag'"
v-model="temp[d.dataIndex]"
:options="d.mask"
/>
<std-selector
@ -22,12 +29,24 @@
: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]"
:record-value-index="d.edit.recordValueIndex" :value="fn(temp, d.edit.valueIndex)"
:get_params="{...d.edit.get_params, ...bindModel(d.edit.bind, temp)}"
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"
:min="d.edit.min" :step="d.edit.step" :max="d.edit.max"
/>
<std-upload v-else-if="d.edit.type==='upload'" :id="temp.id?temp.id:null" :ref="'std_upload_'+d.dataIndex"
v-model="temp[d.dataIndex]" :api="d.edit.api"
:api_delete="d.edit.api_delete"
:list="temp[d.dataIndex]"
:crop="d.edit.crop"
:auto-upload="d.edit.auto_upload"
:crop-options="d.edit.cropOptions" :type="d.edit.upload_type ? d.edit.upload_type : 'img'"
@changeFileUrl="url => {$emit('change_'+d.dataIndex, url)}"
@uploaded="url => {$emit(d.dataIndex+'Uploaded', url)}"
/>
<std-date-picker v-else-if="d.edit.type==='date_picker'" v-model="temp[d.dataIndex]"
@ -44,9 +63,15 @@
<a-switch
v-else-if="d.edit.type==='switch'"
v-model="temp[d.dataIndex]"
@change="$emit(d.edit.event)"
/>
<a-checkbox
v-else-if="d.edit.type==='checkbox'"
v-model="temp[d.dataIndex]"
>
{{ d.text }}
</a-checkbox>
<std-transfer
v-else-if="d.edit.type==='transfer'"
v-model="temp[d.dataIndex]"
@ -54,12 +79,17 @@
:data-key="d.edit.dataKey"
/>
<rich-text-editor v-else-if="d.edit.type==='rich-text'" v-model="temp[d.dataIndex]" />
<p v-else-if="d.edit.type==='readonly'">
{{ d.mask ? d.mask[temp[d.dataIndex]] : temp[d.dataIndex] }}
{{ d.mask ? d.mask[fn(temp, d.dataIndex)] : fn(temp, d.dataIndex) }}
</p>
<p v-else>{{ "edit.type 参数非法 " + d.edit.type }}</p>
</a-form-item>
<a-form-item>
<slot name="supplement"/>
<slot name="action"/>
</a-form-item>
</a-form>
@ -68,16 +98,22 @@
<script>
import StdSelectOption from './StdSelectOption'
import StdSelector from './StdSelector'
import StdUpload from './StdUpload'
import StdDatePicker from './StdDatePicker'
import StdTransfer from './StdTransfer'
import RichTextEditor from "@/components/RichText/RichTextEditor";
import StdCheckTag from "@/components/StdDataEntry/StdCheckTag";
export default {
name: 'StdDataEntry',
components: {
StdCheckTag,
RichTextEditor,
StdTransfer,
StdDatePicker,
StdSelectOption,
StdSelector
StdSelector,
StdUpload
},
props: {
dataList: [Array, Object],
@ -123,6 +159,17 @@ export default {
this.M_dataList = this.editableColumns(this.dataList)
},
methods: {
fn: (obj, desc) => {
const arr = desc.split('.')
while (arr.length) {
const top = obj[arr.shift()]
if (!top) {
return null
}
obj = top
}
return obj
},
editableColumns(columns) {
if (typeof columns === 'object') {
columns = Object.values(columns)
@ -130,6 +177,15 @@ export default {
return columns.filter((c) => {
return c.edit
})
},
bindModel(bind, dataSource) {
let object = {}
if (bind) {
for (const [key, value] of Object.entries(bind)) {
object[key] = this.fn(dataSource, value)
}
}
return object
}
}
}

View file

@ -2,12 +2,14 @@
<a-date-picker
v-model="dateModel"
:show-time="showTime"
@change="(r, d) => {changeDate(d)}"
@change="r => {changeDate(r.format())}"
/>
</template>
<script>
const moment = require('moment')
import moment from 'moment'
import 'moment/locale/zh-cn'
moment.locale('zh-cn')
export default {

View file

@ -0,0 +1,129 @@
<template>
<div>
<a-upload
:before-upload="beforeUpload"
:multiple="true"
:show-upload-list="true"
:file-list="uploadList"
:remove="remove"
>
<a-button :disabled="disabled"><a-icon type="upload"/>选择文件</a-button>
</a-upload>
<a-button
type="primary"
:disabled="uploadList.length === 0 && !id"
:loading="uploading"
style="margin: 16px 0"
@click="upload"
v-if="id"
>
{{ uploading ? '上传中' : '开始上传' }}
</a-button>
<p style="margin: 15px 0" v-for="file in uploaded" :key="file.id">
<a-icon type="paper-clip" style="margin-right: 5px"/>
<a :href="server + '/' + file.path" target="_blank" @click="()=>{}">{{ getFileName(file.path) }}</a>
<a-popconfirm
title="确定要删除文件吗"
ok-text="确认"
cancel-text="取消"
@confirm="deleteFile(file.id)"
style="float: right"
>
<a-button type="link">删除</a-button>
</a-popconfirm>
</p>
</div>
</template>
<script>
export default {
name: "StdMultiFilesUpload",
props: {
api: Function,
id: {
type: Number,
default: null
},
fileList: {
default: null
},
autoUpload: {
type: Boolean,
default: false
},
api_delete: {
type: Function,
default: null
},
disabled: {
type: Boolean,
default: false
}
},
watch: {
fileList() {
this.uploaded = this.fileList
}
},
data() {
return {
uploadList: [],
uploaded: this.fileList,
lastFileTime: 0,
server: process.env["VUE_APP_API_UPLOAD_ROOT"],
uploading: false,
}
},
model: {
prop: 'fileUrl',
event: 'changeFileUrl'
},
methods: {
async upload() {
if (this.uploadList.length) {
this.uploading = true
let formData = new FormData()
while (this.uploadList.length) {
formData.append('file[]', this.uploadList.shift())
}
this.visible = false
this.uploading = true
this.$message.info('正在上传附件, 请不要关闭本页')
return this.api(this.id, formData).then(r => {
this.uploaded = [...this.uploaded, ...r]
this.uploading = false
this.$emit('uploaded', r)
this.uploading = false
this.orig = false
this.$message.success('上传成功')
}).catch(e => {
this.$message.error(e.message ? e.message : '上传失败')
})
}
},
beforeUpload(file) {
this.uploadList.push(file)
return false
},
deleteFile(file_id) {
this.api_delete(this.id, file_id).then(r => {
this.uploaded = r
})
},
getFileName(path) {
// 15
const idx = path.indexOf("/", 15)
return path.substring(idx + 1)
},
remove(r) {
this.uploadList = this.uploadList.filter(value => {
return value !== r
})
},
}
}
</script>
<style scoped>
</style>

View file

@ -11,7 +11,7 @@
export default {
name: 'StdSelectOption',
props: {
value: [Number, String],
value: [Number, String, Boolean],
options: [Array, Object],
keyType: {
type: String,

View file

@ -1,8 +1,10 @@
<template>
<div class="std-selector">
<div class="std-selector" @click="visible=true">
<a-input v-model="_key" disabled hidden/>
<a-input v-model="M_value" disabled/>
<a-button @click="visible=true">更变</a-button>
<a-input
v-model="M_value"
disabled
/>
<a-modal
:mask="false"
:visible="visible"
@ -11,14 +13,16 @@
title="选择器"
@cancel="visible=false"
@ok="ok()"
:width="600"
destroyOnClose
>
<std-table
:api="api"
:columns="columns"
:data_key="data_key"
:disable_search="disable_search"
:pagination_method="pagination_method"
:pithy="true"
:get_params="get_params"
:selectionType="selectionType"
@selected="onSelect"
@selectedRecord="r => {record = r}"
@ -28,11 +32,12 @@
</template>
<script>
import StdTable from '@/components/StdDataDisplay/StdTable'
export default {
name: 'StdSelector',
components: {StdTable},
components: {
StdTable: () => import('@/components/StdDataDisplay/StdTable')
},
props: {
_key: [Number, String],
value: String,
@ -47,15 +52,15 @@ export default {
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
default: false
},
get_params: {
type: Object,
default() {
return {}
}
}
},
model: {
@ -71,6 +76,11 @@ export default {
}
},
watch: {
_key() {
if (!this._key) {
this.M_value = null
}
},
value() {
this.M_value = this.value
}
@ -94,11 +104,13 @@ export default {
<style lang="less" scoped>
.std-selector {
min-width: 300px;
.ant-input {
width: auto;
margin: 0 10px 0 0;
cursor: pointer;
}
.ant-input-disabled {
background: unset;
color: unset;
}
}
</style>

View file

@ -0,0 +1,83 @@
<template>
<div>
<a-upload
:before-upload="beforeUpload"
:multiple="false"
:show-upload-list="true"
:file-list="uploadList"
>
<a-button :disabled="disabled"><a-icon type="upload"/>上传</a-button>
</a-upload>
<p style="margin: 15px 0" v-show="fileUrl">
<a-icon type="paper-clip" style="margin-right: 5px"/>
<a :href="server + '/' + fileUrl" target="_blank" @click="()=>{}">{{ fileUrl }}</a>
</p>
</div>
</template>
<script>
export default {
name: "StdSingleFileUpload",
props: {
api: Function,
id: {
type: Number,
default: null
},
fileUrl: {
default: null
},
autoUpload: {
type: Boolean,
default: false
},
disabled: {
type: Boolean,
default: false
}
},
data() {
return {
uploadList: [],
server: process.env["VUE_APP_API_UPLOAD_ROOT"],
}
},
model: {
prop: 'fileUrl',
event: 'changeFileUrl'
},
methods: {
async upload() {
if (this.uploadList.length) {
const formData = new FormData()
formData.append('file', this.uploadList.shift())
this.visible = false
this.uploading = true
this.$message.info('正在上传附件, 请不要关闭本页')
return this.api(this.id, formData).then(r => {
this.$emit('uploaded', r.url)
this.$emit('changeFileUrl', r.url)
this.uploading = false
this.$message.success('上传成功')
}).catch(e => {
this.$message.error(e.message ? e.message : '上传失败')
})
}
},
beforeUpload(file) {
this.uploadList = [file]
this.$emit('changeFileUrl', file.name)
// id, id
if (this.autoUpload ? this.autoUpload : (!!this.id)) {
this.upload()
}
return false
},
}
}
</script>
<style scoped>
</style>

View file

@ -0,0 +1,260 @@
<template>
<div v-if="type==='img'">
<a-upload
:before-upload="beforeUpload"
:show-upload-list="false"
class="avatar-uploader"
list-type="picture-card"
>
<img v-if="fileUrl" :src="getFileUrl()" width="100">
<div v-else>
<a-icon :type="uploading ? 'loading' : 'plus'"/>
<div class="ant-upload-text">
上传图片
</div>
</div>
</a-upload>
<a-modal
v-if="crop"
v-model="visible"
cancelText="取消上传"
class="cropper"
okText="裁切"
title="图片裁切"
@cancel="visible=false;$emit('changeFileUrl', orig)"
@ok="handleCropSuccess"
>
<div class="vue-cropper" v-if="fileUrl.substring(0,5) === 'data:'">
<VueCropper
ref="cropper"
:autoCrop="true"
:autoCropHeight="cropOptions.autoCropHeight"
:autoCropWidth="cropOptions.autoCropWidth"
:fixed="cropOptions.fixed"
:fixedNumber="cropOptions.fixedNumber"
:img="getFileUrl()"
outputType="png"
/>
</div>
<div style="margin: 10px 0">
<a-button @click="handleSingleUpload">不剪裁</a-button>
</div>
</a-modal>
</div>
<div v-else-if="type==='file'">
<std-single-file-upload
:file-url="fileUrl"
:id="id"
:api="api"
:auto-upload="autoUpload"
@changeFileUrl="url => {$emit('changeFileUrl', url)}"
:disabled="disabled"
ref="single-file"
/>
</div>
<div v-else-if="type==='multi-file'">
<std-multi-files-upload
:file-list="M_list"
:id="id"
:api="api"
:auto-upload="autoUpload"
:api_delete="api_delete"
@changeFileUrl="url => {$emit('changeFileUrl', url)}"
:disabled="disabled"
ref="multi-file"
/>
</div>
</template>
<script>
import Vue from 'vue'
import VueCropper from 'vue-cropper'
import StdSingleFileUpload from "@/components/StdDataEntry/StdSingleFileUpload";
import StdMultiFilesUpload from "@/components/StdDataEntry/StdMultiFilesUpload";
import { v4 as uuidv4 } from 'uuid';
Vue.use(VueCropper)
export default {
name: 'StdUpload',
components: {StdMultiFilesUpload, StdSingleFileUpload},
props: {
id: {
type: Number,
default: null
},
api: Function,
api_delete: {
type: Function,
default: null
},
fileUrl: {
default: ''
},
autoUpload: {
type: Boolean,
default: false
},
type: {
default: 'img',
validator: value => {
return ['img', 'file', 'multi-file'].indexOf(value) !== -1
}
},
crop: {
type: Boolean,
default: false
},
cropOptions: {
type: Object,
default: () => {
return {
fixed: true,
autoCropWidth: 200,
autoCropHeight: 200,
}
}
},
list: {
default: null
},
disabled: {
type: Boolean,
default: false
}
},
data() {
return {
uploading: false,
orig: '',
visible: false,
fileList: [],
M_list: this.list,
server: process.env["VUE_APP_API_UPLOAD_ROOT"]
}
},
created() {
this.orig = this.fileUrl
},
model: {
prop: 'fileUrl',
event: 'changeFileUrl'
},
watch: {
list() {
this.M_list = this.list
}
},
methods: {
getFileUrl() {
return this.fileUrl.substring(0,5) === 'data:' ? this.fileUrl :
this.server + '/' + this.fileUrl
},
async upload() {
if (this.type === 'multi-file') {
return await this.$refs["multi-file"].upload()
}
if (this.orig && this.fileUrl !== this.orig) {
return this.handleSingleUpload()
}
if (this.$refs['single-file']) {
return await this.$refs["single-file"].upload()
}
},
handleSingleUpload() {
const formData = new FormData()
formData.append('file', this.fileList[0])
this.visible = false
this.uploading = true
this.$message.info('正在上传附件, 请不要关闭本页')
return this.api(this.id, formData).then(r => {
this.$emit('uploaded', r.url)
this.$emit('changeFileUrl', r.url)
this.uploading = false
this.$message.success('上传成功')
this.orig = r.url
})
},
beforeUpload(file) {
// emm !!!
this.orig = this.fileUrl ? this.fileUrl : 'orig_is_empty'
this.fileList = [file]
if (this.type === 'img') {
this.visible = true
const r = new FileReader()
r.readAsDataURL(file)
r.onload = e => {
file.thumbUrl = e.target.result
this.$emit('changeFileUrl', e.target.result)
}
} else {
this.$emit('changeFileUrl', file.name)
}
return false
},
afterCropUpload(file) {
this.visible = true
const r = new FileReader()
r.readAsDataURL(file)
r.onload = e => {
file.thumbUrl = e.target.result
this.$emit('changeFileUrl', e.target.result)
}
this.fileList = [file]
this.$nextTick(() => {
this.handleSingleUpload()
})
},
handleCropSuccess() {
this.$refs.cropper.getCropBlob((data) => {
let file = new window.File([data], uuidv4() + '.png', {type: data.type})
this.afterCropUpload(file)
this.visible = false
})
},
remove(r) {
this.fileList = this.fileList.filter(value => {
return value !== r
})
},
}
}
</script>
<style lang="less" scoped>
.upload-picture-btn {
font-size: 20px;
color: #999999;
}
.cropper {
.ant-modal-body {
min-height: 256px;
}
}
.vue-cropper {
min-height: 200px;
background-image: unset;
}
.img-preview {
float: left;
border: 1px solid #8e8e904d;
border-radius: 5px;
margin: 5px;
padding: 5px;
img {
height: 90px;
width: 90px;
object-fit: cover;
}
}
</style>

View file

@ -0,0 +1,52 @@
<template>
<codemirror v-model="current_value" :options="cmOptions"/>
</template>
<style lang="less">
.cm-s-monokai {
height: auto!important;
}
</style>
<script>
import { codemirror } from 'vue-codemirror'
import 'codemirror/lib/codemirror.css'
import 'codemirror/theme/monokai.css'
import 'codemirror/mode/nginx/nginx'
export default {
name: 'vue-itextarea',
components: {
codemirror
},
props: {
value: {},
},
model: {
prop: 'value',
event: 'changeValue'
},
watch: {
value() {
this.current_value = this.value ?? ''
},
current_value() {
this.$emit('changeValue', this.current_value)
}
},
data() {
return {
current_value: this.value ?? '',
cmOptions: {
tabSize: 4,
mode: 'text/x-nginx-conf',
theme: 'monokai',
lineNumbers: true,
line: true,
highlightDifferences: true,
defaultTextHeight: 1000,
// more CodeMirror options...
}
};
},
}
</script>

View file

@ -4,7 +4,7 @@
<a-icon type="menu-unfold" @click="$emit('clickUnFold')"/>
</div>
<div class="user-wrapper">
<a href="/index.html">
<a href="/">
<a-icon type="home"/>
</a>

View file

@ -5,10 +5,10 @@
:openKeys="openKeys"
mode="inline"
@openChange="onOpenChange"
:default-selected-keys="[$route.path.substring(1)]"
v-model="selectedKey"
>
<template v-for="sidebar in visible(sidebars)">
<a-menu-item v-if="!sidebar.children" :key="sidebar.path"
<a-menu-item v-if="!sidebar.children" :key="sidebar.name"
@click="$router.push('/'+sidebar.path).catch(() => {})">
<a-icon :type="sidebar.meta.icon"/>
<span>{{ sidebar.name }}</span>
@ -36,8 +36,17 @@ export default {
data() {
return {
rootSubmenuKeys: [],
openKeys: [],
sidebars: this.$routeConfig[0]['children']
openKeys: [this.openSub()],
sidebars: this.$routeConfig[0]['children'],
selectedKey: [this.$route.name],
}
},
watch: {
'$route'() {
this.selectedKey = [this.$route.name]
const sub = this.openSub()
const p = this.openKeys.indexOf(sub)
if (p === -1) this.openKeys.push(this.openSub())
}
},
created() {
@ -46,6 +55,11 @@ export default {
})
},
methods: {
openSub() {
let path = this.$route.path
let lastSepIndex = path.lastIndexOf('/')
return path.substring(1, lastSepIndex)
},
onOpenChange(openKeys) {
const latestOpenKey = openKeys.find(key => this.openKeys.indexOf(key) === -1)
if (this.rootSubmenuKeys.indexOf(latestOpenKey) === -1) {

View file

@ -40,7 +40,6 @@ http.interceptors.response.use(
case 403:
// 无权访问时,直接登出
await store.dispatch('logout')
location.reload()
break
}
return Promise.reject(error.response.data)

View file

@ -32,12 +32,9 @@ export default {
Vue.prototype.scrollPosition = scrollPosition
Vue.prototype.getWebSocketRoot = () => {
const protocol = location.protocol === "https:" ? "wss://" : "ws://"
if (process.env["VUE_APP_API_WSS_ROOT"]) {
return process.env["VUE_APP_API_WSS_ROOT"]
}
console.log(protocol, document.domain)
return protocol + document.domain + '/ws'
const protocol = location.protocol === "https:" ? "wss://" : "ws://"
return protocol + location.host + process.env["VUE_APP_API_WSS_ROOT"]
}
}
}

View file

@ -21,6 +21,14 @@ export const routes = [
icon: 'home'
}
},
{
path: 'user',
name: '用户管理',
component: () => import('@/views/User.vue'),
meta: {
icon: 'user'
},
},
{
path: 'domain',
name: '网站管理',
@ -93,11 +101,12 @@ export const routes = [
]
const router = new VueRouter({
routes
routes,
mode: 'history'
})
router.beforeEach((to, from, next) => {
document.title = 'Nginx UI | ' + to.name
document.title = to.name + ' | Nginx UI'
if (process.env.NODE_ENV === 'production') {
axios.get('/version.json?' + Date.now()).then(r => {

View file

@ -24,7 +24,7 @@ export default {
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 : '开发模式',
build_id: process.env.VUE_APP_TOTAL_BUILD ?? '开发模式',
api_root: process.env.VUE_APP_API_ROOT
}
},

Some files were not shown because too many files have changed in this diff Show more