1
0
Jacky 3 жил өмнө
parent
commit
dd6e19657a
100 өөрчлөгдсөн 10099 нэмэгдсэн , 48 устгасан
  1. 16 0
      Dockerfile
  2. 12 0
      build.sh
  3. 2 0
      frontend/.env.development
  4. 2 0
      frontend/.env.production
  5. 0 0
      frontend/.gitignore
  6. 0 0
      frontend/README.md
  7. 0 0
      frontend/alias.config.js
  8. 0 0
      frontend/babel.config.js
  9. 0 0
      frontend/dist/favicon.ico
  10. 8115 0
      frontend/dist/img/remixicon.symbol.f09b1c74.svg
  11. 1 0
      frontend/dist/index.html
  12. 0 0
      frontend/dist/js/chunk-15551262-legacy.ab2ff742.js
  13. 0 0
      frontend/dist/js/chunk-15551262.ab2ff742.js
  14. 0 0
      frontend/dist/js/chunk-17d07320-legacy.f20a6ac5.js
  15. 0 0
      frontend/dist/js/chunk-17d07320.f20a6ac5.js
  16. 0 0
      frontend/dist/js/chunk-1e5147a5-legacy.69288dbf.js
  17. 0 0
      frontend/dist/js/chunk-1e5147a5.69288dbf.js
  18. 0 0
      frontend/dist/js/chunk-228c37e8-legacy.3f33dfe8.js
  19. 0 0
      frontend/dist/js/chunk-228c37e8.3f33dfe8.js
  20. 0 0
      frontend/dist/js/chunk-23e3da44-legacy.08117b5e.js
  21. 0 0
      frontend/dist/js/chunk-23e3da44.08117b5e.js
  22. 0 0
      frontend/dist/js/chunk-2b94df79-legacy.6a91b4fd.js
  23. 0 0
      frontend/dist/js/chunk-2b94df79.6a91b4fd.js
  24. 0 0
      frontend/dist/js/chunk-2d0cf277-legacy.99e2e71b.js
  25. 0 0
      frontend/dist/js/chunk-2d0cf277.99e2e71b.js
  26. 0 0
      frontend/dist/js/chunk-532b3473-legacy.eaa829bf.js
  27. 0 0
      frontend/dist/js/chunk-532b3473.eaa829bf.js
  28. 0 0
      frontend/dist/js/chunk-56341220-legacy.4ea9419d.js
  29. 0 0
      frontend/dist/js/chunk-56341220.4ea9419d.js
  30. 1 1
      frontend/dist/js/chunk-742ad954-legacy.a1467ad6.js
  31. 1 1
      frontend/dist/js/chunk-742ad954.a1467ad6.js
  32. 1 0
      frontend/dist/js/chunk-96068e84-legacy.dcec99db.js
  33. 1 0
      frontend/dist/js/chunk-96068e84.dcec99db.js
  34. 1 0
      frontend/dist/js/chunk-d4ac245c-legacy.36cefb3a.js
  35. 1 0
      frontend/dist/js/chunk-d4ac245c.36cefb3a.js
  36. 0 0
      frontend/dist/js/chunk-e0ad5fdc-legacy.02ec34ba.js
  37. 0 0
      frontend/dist/js/chunk-e0ad5fdc.02ec34ba.js
  38. 0 0
      frontend/dist/js/chunk-e71b472c-legacy.345ce2d9.js
  39. 0 0
      frontend/dist/js/chunk-e71b472c.345ce2d9.js
  40. 0 0
      frontend/dist/js/chunk-vendors-legacy.ee48c25c.js
  41. 0 0
      frontend/dist/js/chunk-vendors.ee48c25c.js
  42. 0 0
      frontend/dist/js/index-legacy.a529f66e.js
  43. 0 0
      frontend/dist/js/index.c8496a25.js
  44. 1 0
      frontend/dist/version.json
  45. 18 2
      frontend/package.json
  46. 0 0
      frontend/public/favicon.ico
  47. 0 0
      frontend/public/index.html
  48. 0 0
      frontend/src/App.vue
  49. 2 2
      frontend/src/api/auth.js
  50. 0 0
      frontend/src/api/config.js
  51. 0 0
      frontend/src/api/domain.js
  52. 3 1
      frontend/src/api/index.js
  53. 23 0
      frontend/src/api/user.js
  54. 0 0
      frontend/src/assets/css/dark.less
  55. 0 0
      frontend/src/assets/css/manage.less
  56. 0 0
      frontend/src/assets/css/style.bak.less
  57. 7 0
      frontend/src/assets/css/style.less
  58. 0 0
      frontend/src/components/Breadcrumb/Breadcrumb.vue
  59. 0 0
      frontend/src/components/Chart/LineChart.vue
  60. 0 0
      frontend/src/components/FooterToolbar/FooterToolBar.vue
  61. 0 0
      frontend/src/components/FooterToolbar/index.js
  62. 0 0
      frontend/src/components/Logo/Logo.vue
  63. 0 0
      frontend/src/components/PageHeader/PageHeader.vue
  64. 0 0
      frontend/src/components/PageHeader/index.js
  65. 57 0
      frontend/src/components/RichText/CodeBlockComponent.vue
  66. 162 0
      frontend/src/components/RichText/MenuBar.vue
  67. 71 0
      frontend/src/components/RichText/MenuItem.vue
  68. 15 0
      frontend/src/components/RichText/RichText.vue
  69. 151 0
      frontend/src/components/RichText/RichTextEditor.vue
  70. 148 0
      frontend/src/components/RichText/style.less
  71. 220 0
      frontend/src/components/StdDataDisplay/StdCurd.vue
  72. 14 1
      frontend/src/components/StdDataDisplay/StdPagination.vue
  73. 339 0
      frontend/src/components/StdDataDisplay/StdTable.vue
  74. 61 0
      frontend/src/components/StdDataEntry/StdCheckTag.vue
  75. 62 6
      frontend/src/components/StdDataEntry/StdDataEntry.vue
  76. 4 2
      frontend/src/components/StdDataEntry/StdDatePicker.vue
  77. 129 0
      frontend/src/components/StdDataEntry/StdMultiFilesUpload.vue
  78. 1 1
      frontend/src/components/StdDataEntry/StdSelectOption.vue
  79. 28 16
      frontend/src/components/StdDataEntry/StdSelector.vue
  80. 83 0
      frontend/src/components/StdDataEntry/StdSingleFileUpload.vue
  81. 0 0
      frontend/src/components/StdDataEntry/StdTransfer.vue
  82. 260 0
      frontend/src/components/StdDataEntry/StdUpload.vue
  83. 0 0
      frontend/src/components/StdFormCard/StdFormCard.vue
  84. 0 0
      frontend/src/components/StdFormCard/StdFormCardContent.vue
  85. 52 0
      frontend/src/components/VueItextarea/VueItextarea.vue
  86. 0 0
      frontend/src/layouts/BaseLayout.vue
  87. 0 0
      frontend/src/layouts/BaseRouterView.vue
  88. 0 0
      frontend/src/layouts/FooterLayout.vue
  89. 1 1
      frontend/src/layouts/HeaderLayout.vue
  90. 0 0
      frontend/src/layouts/Loading.vue
  91. 18 4
      frontend/src/layouts/SideBar.vue
  92. 0 0
      frontend/src/lazy.js
  93. 0 1
      frontend/src/lib/http/index.js
  94. 0 0
      frontend/src/lib/store/index.js
  95. 0 0
      frontend/src/lib/store/user.js
  96. 3 6
      frontend/src/lib/utils/index.js
  97. 0 0
      frontend/src/lib/utils/scroll-position.js
  98. 0 0
      frontend/src/main.js
  99. 11 2
      frontend/src/router/index.js
  100. 1 1
      frontend/src/views/About.vue

+ 16 - 0
Dockerfile

@@ -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 - 0
build.sh

@@ -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 .

+ 2 - 0
frontend/.env.development

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

+ 2 - 0
frontend/.env.production

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

+ 0 - 0
nginx-ui-frontend/.gitignore → frontend/.gitignore


+ 0 - 0
nginx-ui-frontend/README.md → frontend/README.md


+ 0 - 0
nginx-ui-frontend/alias.config.js → frontend/alias.config.js


+ 0 - 0
nginx-ui-frontend/babel.config.js → frontend/babel.config.js


+ 0 - 0
nginx-ui-frontend/dist/favicon.ico → frontend/dist/favicon.ico


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 8115 - 0
frontend/dist/img/remixicon.symbol.f09b1c74.svg


+ 1 - 0
frontend/dist/index.html

@@ -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>

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
frontend/dist/js/chunk-15551262-legacy.ab2ff742.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
frontend/dist/js/chunk-15551262.ab2ff742.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
frontend/dist/js/chunk-17d07320-legacy.f20a6ac5.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
frontend/dist/js/chunk-17d07320.f20a6ac5.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
frontend/dist/js/chunk-1e5147a5-legacy.69288dbf.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
frontend/dist/js/chunk-1e5147a5.69288dbf.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
frontend/dist/js/chunk-228c37e8-legacy.3f33dfe8.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
frontend/dist/js/chunk-228c37e8.3f33dfe8.js


+ 0 - 0
nginx-ui-frontend/dist/js/chunk-23e3da44-legacy.d41ac57a.js → frontend/dist/js/chunk-23e3da44-legacy.08117b5e.js


+ 0 - 0
nginx-ui-frontend/dist/js/chunk-23e3da44.d41ac57a.js → frontend/dist/js/chunk-23e3da44.08117b5e.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
frontend/dist/js/chunk-2b94df79-legacy.6a91b4fd.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
frontend/dist/js/chunk-2b94df79.6a91b4fd.js


+ 0 - 0
nginx-ui-frontend/dist/js/chunk-2d0cf277-legacy.a3f15415.js → frontend/dist/js/chunk-2d0cf277-legacy.99e2e71b.js


+ 0 - 0
nginx-ui-frontend/dist/js/chunk-2d0cf277.a3f15415.js → frontend/dist/js/chunk-2d0cf277.99e2e71b.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
frontend/dist/js/chunk-532b3473-legacy.eaa829bf.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
frontend/dist/js/chunk-532b3473.eaa829bf.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
frontend/dist/js/chunk-56341220-legacy.4ea9419d.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
frontend/dist/js/chunk-56341220.4ea9419d.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 1 - 1
frontend/dist/js/chunk-742ad954-legacy.a1467ad6.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 1 - 1
frontend/dist/js/chunk-742ad954.a1467ad6.js


+ 1 - 0
frontend/dist/js/chunk-96068e84-legacy.dcec99db.js

@@ -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}}]);

+ 1 - 0
frontend/dist/js/chunk-96068e84.dcec99db.js

@@ -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}}]);

+ 1 - 0
frontend/dist/js/chunk-d4ac245c-legacy.36cefb3a.js

@@ -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}}]);

+ 1 - 0
frontend/dist/js/chunk-d4ac245c.36cefb3a.js

@@ -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}}]);

Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
frontend/dist/js/chunk-e0ad5fdc-legacy.02ec34ba.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
frontend/dist/js/chunk-e0ad5fdc.02ec34ba.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
frontend/dist/js/chunk-e71b472c-legacy.345ce2d9.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
frontend/dist/js/chunk-e71b472c.345ce2d9.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
frontend/dist/js/chunk-vendors-legacy.ee48c25c.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
frontend/dist/js/chunk-vendors.ee48c25c.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
frontend/dist/js/index-legacy.a529f66e.js


Файлын зөрүү хэтэрхий том тул дарагдсан байна
+ 0 - 0
frontend/dist/js/index.c8496a25.js


+ 1 - 0
frontend/dist/version.json

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

+ 18 - 2
nginx-ui-frontend/package.json → frontend/package.json

@@ -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": {

+ 0 - 0
nginx-ui-frontend/public/favicon.ico → frontend/public/favicon.ico


+ 0 - 0
nginx-ui-frontend/public/index.html → frontend/public/index.html


+ 0 - 0
nginx-ui-frontend/src/App.vue → frontend/src/App.vue


+ 2 - 2
nginx-ui-frontend/src/api/auth.js → frontend/src/api/auth.js

@@ -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')
         })
     }
 }

+ 0 - 0
nginx-ui-frontend/src/api/config.js → frontend/src/api/config.js


+ 0 - 0
nginx-ui-frontend/src/api/domain.js → frontend/src/api/domain.js


+ 3 - 1
nginx-ui-frontend/src/api/index.js → frontend/src/api/index.js

@@ -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 - 0
frontend/src/api/user.js

@@ -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

+ 0 - 0
nginx-ui-frontend/src/assets/css/dark.less → frontend/src/assets/css/dark.less


+ 0 - 0
nginx-ui-frontend/src/assets/css/manage.less → frontend/src/assets/css/manage.less


+ 0 - 0
nginx-ui-frontend/src/assets/css/style.bak.less → frontend/src/assets/css/style.bak.less


+ 7 - 0
nginx-ui-frontend/src/assets/css/style.less → frontend/src/assets/css/style.less

@@ -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;
+    }
+}

+ 0 - 0
nginx-ui-frontend/src/components/Breadcrumb/Breadcrumb.vue → frontend/src/components/Breadcrumb/Breadcrumb.vue


+ 0 - 0
nginx-ui-frontend/src/components/Chart/LineChart.vue → frontend/src/components/Chart/LineChart.vue


+ 0 - 0
nginx-ui-frontend/src/components/FooterToolbar/FooterToolBar.vue → frontend/src/components/FooterToolbar/FooterToolBar.vue


+ 0 - 0
nginx-ui-frontend/src/components/FooterToolbar/index.js → frontend/src/components/FooterToolbar/index.js


+ 0 - 0
nginx-ui-frontend/src/components/Logo/Logo.vue → frontend/src/components/Logo/Logo.vue


+ 0 - 0
nginx-ui-frontend/src/components/PageHeader/PageHeader.vue → frontend/src/components/PageHeader/PageHeader.vue


+ 0 - 0
nginx-ui-frontend/src/components/PageHeader/index.js → frontend/src/components/PageHeader/index.js


+ 57 - 0
frontend/src/components/RichText/CodeBlockComponent.vue

@@ -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>

+ 162 - 0
frontend/src/components/RichText/MenuBar.vue

@@ -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>

+ 71 - 0
frontend/src/components/RichText/MenuItem.vue

@@ -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>

+ 15 - 0
frontend/src/components/RichText/RichText.vue

@@ -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>

+ 151 - 0
frontend/src/components/RichText/RichTextEditor.vue

@@ -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>
+

+ 148 - 0
frontend/src/components/RichText/style.less

@@ -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;
+    }
+}

+ 220 - 0
frontend/src/components/StdDataDisplay/StdCurd.vue

@@ -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>

+ 14 - 1
nginx-ui-frontend/src/components/StdDataDisplay/StdPagination.vue → frontend/src/components/StdDataDisplay/StdPagination.vue

@@ -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>

+ 339 - 0
frontend/src/components/StdDataDisplay/StdTable.vue

@@ -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>

+ 61 - 0
frontend/src/components/StdDataEntry/StdCheckTag.vue

@@ -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>

+ 62 - 6
nginx-ui-frontend/src/components/StdDataEntry/StdDataEntry.vue → frontend/src/components/StdDataEntry/StdDataEntry.vue

@@ -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
         }
     }
 }

+ 4 - 2
nginx-ui-frontend/src/components/StdDataEntry/StdDatePicker.vue → frontend/src/components/StdDataEntry/StdDatePicker.vue

@@ -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 {

+ 129 - 0
frontend/src/components/StdDataEntry/StdMultiFilesUpload.vue

@@ -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>

+ 1 - 1
nginx-ui-frontend/src/components/StdDataEntry/StdSelectOption.vue → frontend/src/components/StdDataEntry/StdSelectOption.vue

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

+ 28 - 16
nginx-ui-frontend/src/components/StdDataEntry/StdSelector.vue → frontend/src/components/StdDataEntry/StdSelector.vue

@@ -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>

+ 83 - 0
frontend/src/components/StdDataEntry/StdSingleFileUpload.vue

@@ -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>

+ 0 - 0
nginx-ui-frontend/src/components/StdDataEntry/StdTransfer.vue → frontend/src/components/StdDataEntry/StdTransfer.vue


+ 260 - 0
frontend/src/components/StdDataEntry/StdUpload.vue

@@ -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>

+ 0 - 0
nginx-ui-frontend/src/components/StdFormCard/StdFormCard.vue → frontend/src/components/StdFormCard/StdFormCard.vue


+ 0 - 0
nginx-ui-frontend/src/components/StdFormCard/StdFormCardContent.vue → frontend/src/components/StdFormCard/StdFormCardContent.vue


+ 52 - 0
frontend/src/components/VueItextarea/VueItextarea.vue

@@ -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>

+ 0 - 0
nginx-ui-frontend/src/layouts/BaseLayout.vue → frontend/src/layouts/BaseLayout.vue


+ 0 - 0
nginx-ui-frontend/src/layouts/BaseRouterView.vue → frontend/src/layouts/BaseRouterView.vue


+ 0 - 0
nginx-ui-frontend/src/layouts/FooterLayout.vue → frontend/src/layouts/FooterLayout.vue


+ 1 - 1
nginx-ui-frontend/src/layouts/HeaderLayout.vue → frontend/src/layouts/HeaderLayout.vue

@@ -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>
 

+ 0 - 0
nginx-ui-frontend/src/layouts/Loading.vue → frontend/src/layouts/Loading.vue


+ 18 - 4
nginx-ui-frontend/src/layouts/SideBar.vue → frontend/src/layouts/SideBar.vue

@@ -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) {

+ 0 - 0
nginx-ui-frontend/src/lazy.js → frontend/src/lazy.js


+ 0 - 1
nginx-ui-frontend/src/lib/http/index.js → frontend/src/lib/http/index.js

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

+ 0 - 0
nginx-ui-frontend/src/lib/store/index.js → frontend/src/lib/store/index.js


+ 0 - 0
nginx-ui-frontend/src/lib/store/user.js → frontend/src/lib/store/user.js


+ 3 - 6
nginx-ui-frontend/src/lib/utils/index.js → frontend/src/lib/utils/index.js

@@ -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"]
         }
     }
 }

+ 0 - 0
nginx-ui-frontend/src/lib/utils/scroll-position.js → frontend/src/lib/utils/scroll-position.js


+ 0 - 0
nginx-ui-frontend/src/main.js → frontend/src/main.js


+ 11 - 2
nginx-ui-frontend/src/router/index.js → frontend/src/router/index.js

@@ -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 => {

+ 1 - 1
nginx-ui-frontend/src/views/About.vue → frontend/src/views/About.vue

@@ -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
         }
     },

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно