commit fa137c7fa091d34557f1da4b60bbe5649726fa06
Author: flowerstonezl <125468358+flowerstonezl@users.noreply.github.com>
Date: Mon Apr 20 16:49:18 2026 +0800
first commit
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..ea6e20f
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,14 @@
+# http://editorconfig.org
+root = true
+
+[*]
+charset = utf-8
+indent_style = space
+indent_size = 2
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.md]
+insert_final_newline = false
+trim_trailing_whitespace = false
diff --git a/.env.development b/.env.development
new file mode 100644
index 0000000..de583d0
--- /dev/null
+++ b/.env.development
@@ -0,0 +1,5 @@
+# just a flag
+ENV = 'development'
+
+# base api
+VUE_APP_BASE_API = '/dev-api'
diff --git a/.env.production b/.env.production
new file mode 100644
index 0000000..80c8103
--- /dev/null
+++ b/.env.production
@@ -0,0 +1,6 @@
+# just a flag
+ENV = 'production'
+
+# base api
+VUE_APP_BASE_API = '/prod-api'
+
diff --git a/.env.staging b/.env.staging
new file mode 100644
index 0000000..a8793a0
--- /dev/null
+++ b/.env.staging
@@ -0,0 +1,8 @@
+NODE_ENV = production
+
+# just a flag
+ENV = 'staging'
+
+# base api
+VUE_APP_BASE_API = '/stage-api'
+
diff --git a/.eslintignore b/.eslintignore
new file mode 100644
index 0000000..e6529fc
--- /dev/null
+++ b/.eslintignore
@@ -0,0 +1,4 @@
+build/*.js
+src/assets
+public
+dist
diff --git a/.eslintrc.js b/.eslintrc.js
new file mode 100644
index 0000000..c977505
--- /dev/null
+++ b/.eslintrc.js
@@ -0,0 +1,198 @@
+module.exports = {
+ root: true,
+ parserOptions: {
+ parser: 'babel-eslint',
+ sourceType: 'module'
+ },
+ env: {
+ browser: true,
+ node: true,
+ es6: true,
+ },
+ extends: ['plugin:vue/recommended', 'eslint:recommended'],
+
+ // add your custom rules here
+ //it is base on https://github.com/vuejs/eslint-config-vue
+ rules: {
+ "vue/max-attributes-per-line": [2, {
+ "singleline": 10,
+ "multiline": {
+ "max": 1,
+ "allowFirstLine": false
+ }
+ }],
+ "vue/singleline-html-element-content-newline": "off",
+ "vue/multiline-html-element-content-newline":"off",
+ "vue/name-property-casing": ["error", "PascalCase"],
+ "vue/no-v-html": "off",
+ 'accessor-pairs': 2,
+ 'arrow-spacing': [2, {
+ 'before': true,
+ 'after': true
+ }],
+ 'block-spacing': [2, 'always'],
+ 'brace-style': [2, '1tbs', {
+ 'allowSingleLine': true
+ }],
+ 'camelcase': [0, {
+ 'properties': 'always'
+ }],
+ 'comma-dangle': [2, 'never'],
+ 'comma-spacing': [2, {
+ 'before': false,
+ 'after': true
+ }],
+ 'comma-style': [2, 'last'],
+ 'constructor-super': 2,
+ 'curly': [2, 'multi-line'],
+ 'dot-location': [2, 'property'],
+ 'eol-last': 2,
+ 'eqeqeq': ["error", "always", {"null": "ignore"}],
+ 'generator-star-spacing': [2, {
+ 'before': true,
+ 'after': true
+ }],
+ 'handle-callback-err': [2, '^(err|error)$'],
+ 'indent': [2, 2, {
+ 'SwitchCase': 1
+ }],
+ 'jsx-quotes': [2, 'prefer-single'],
+ 'key-spacing': [2, {
+ 'beforeColon': false,
+ 'afterColon': true
+ }],
+ 'keyword-spacing': [2, {
+ 'before': true,
+ 'after': true
+ }],
+ 'new-cap': [2, {
+ 'newIsCap': true,
+ 'capIsNew': false
+ }],
+ 'new-parens': 2,
+ 'no-array-constructor': 2,
+ 'no-caller': 2,
+ 'no-console': 'off',
+ 'no-class-assign': 2,
+ 'no-cond-assign': 2,
+ 'no-const-assign': 2,
+ 'no-control-regex': 0,
+ 'no-delete-var': 2,
+ 'no-dupe-args': 2,
+ 'no-dupe-class-members': 2,
+ 'no-dupe-keys': 2,
+ 'no-duplicate-case': 2,
+ 'no-empty-character-class': 2,
+ 'no-empty-pattern': 2,
+ 'no-eval': 2,
+ 'no-ex-assign': 2,
+ 'no-extend-native': 2,
+ 'no-extra-bind': 2,
+ 'no-extra-boolean-cast': 2,
+ 'no-extra-parens': [2, 'functions'],
+ 'no-fallthrough': 2,
+ 'no-floating-decimal': 2,
+ 'no-func-assign': 2,
+ 'no-implied-eval': 2,
+ 'no-inner-declarations': [2, 'functions'],
+ 'no-invalid-regexp': 2,
+ 'no-irregular-whitespace': 2,
+ 'no-iterator': 2,
+ 'no-label-var': 2,
+ 'no-labels': [2, {
+ 'allowLoop': false,
+ 'allowSwitch': false
+ }],
+ 'no-lone-blocks': 2,
+ 'no-mixed-spaces-and-tabs': 2,
+ 'no-multi-spaces': 2,
+ 'no-multi-str': 2,
+ 'no-multiple-empty-lines': [2, {
+ 'max': 1
+ }],
+ 'no-native-reassign': 2,
+ 'no-negated-in-lhs': 2,
+ 'no-new-object': 2,
+ 'no-new-require': 2,
+ 'no-new-symbol': 2,
+ 'no-new-wrappers': 2,
+ 'no-obj-calls': 2,
+ 'no-octal': 2,
+ 'no-octal-escape': 2,
+ 'no-path-concat': 2,
+ 'no-proto': 2,
+ 'no-redeclare': 2,
+ 'no-regex-spaces': 2,
+ 'no-return-assign': [2, 'except-parens'],
+ 'no-self-assign': 2,
+ 'no-self-compare': 2,
+ 'no-sequences': 2,
+ 'no-shadow-restricted-names': 2,
+ 'no-spaced-func': 2,
+ 'no-sparse-arrays': 2,
+ 'no-this-before-super': 2,
+ 'no-throw-literal': 2,
+ 'no-trailing-spaces': 2,
+ 'no-undef': 2,
+ 'no-undef-init': 2,
+ 'no-unexpected-multiline': 2,
+ 'no-unmodified-loop-condition': 2,
+ 'no-unneeded-ternary': [2, {
+ 'defaultAssignment': false
+ }],
+ 'no-unreachable': 2,
+ 'no-unsafe-finally': 2,
+ 'no-unused-vars': [2, {
+ 'vars': 'all',
+ 'args': 'none'
+ }],
+ 'no-useless-call': 2,
+ 'no-useless-computed-key': 2,
+ 'no-useless-constructor': 2,
+ 'no-useless-escape': 0,
+ 'no-whitespace-before-property': 2,
+ 'no-with': 2,
+ 'one-var': [2, {
+ 'initialized': 'never'
+ }],
+ 'operator-linebreak': [2, 'after', {
+ 'overrides': {
+ '?': 'before',
+ ':': 'before'
+ }
+ }],
+ 'padded-blocks': [2, 'never'],
+ 'quotes': [2, 'single', {
+ 'avoidEscape': true,
+ 'allowTemplateLiterals': true
+ }],
+ 'semi': [2, 'never'],
+ 'semi-spacing': [2, {
+ 'before': false,
+ 'after': true
+ }],
+ 'space-before-blocks': [2, 'always'],
+ 'space-before-function-paren': [2, 'never'],
+ 'space-in-parens': [2, 'never'],
+ 'space-infix-ops': 2,
+ 'space-unary-ops': [2, {
+ 'words': true,
+ 'nonwords': false
+ }],
+ 'spaced-comment': [2, 'always', {
+ 'markers': ['global', 'globals', 'eslint', 'eslint-disable', '*package', '!', ',']
+ }],
+ 'template-curly-spacing': [2, 'never'],
+ 'use-isnan': 2,
+ 'valid-typeof': 2,
+ 'wrap-iife': [2, 'any'],
+ 'yield-star-spacing': [2, 'both'],
+ 'yoda': [2, 'never'],
+ 'prefer-const': 2,
+ 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0,
+ 'object-curly-spacing': [2, 'always', {
+ objectsInObjects: false
+ }],
+ 'array-bracket-spacing': [2, 'never']
+ }
+}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9ad28d2
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,16 @@
+.DS_Store
+node_modules/
+dist/
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+package-lock.json
+tests/**/coverage/
+
+# Editor directories and files
+.idea
+.vscode
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..f4be7a0
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,5 @@
+language: node_js
+node_js: 10
+script: npm run test
+notifications:
+ email: false
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..6151575
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2017-present PanJiaChen
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README-zh.md b/README-zh.md
new file mode 100644
index 0000000..1beec9b
--- /dev/null
+++ b/README-zh.md
@@ -0,0 +1,111 @@
+# vue-admin-template
+
+> 这是一个极简的 vue admin 管理后台。它只包含了 Element UI & axios & iconfont & permission control & lint,这些搭建后台必要的东西。
+
+[线上地址](http://panjiachen.github.io/vue-admin-template)
+
+[国内访问](https://panjiachen.gitee.io/vue-admin-template)
+
+目前版本为 `v4.0+` 基于 `vue-cli` 进行构建,若你想使用旧版本,可以切换分支到[tag/3.11.0](https://github.com/PanJiaChen/vue-admin-template/tree/tag/3.11.0),它不依赖 `vue-cli`。
+
+
+ SPONSORED BY
+
+
+
+
+
+
+
+## Extra
+
+如果你想要根据用户角色来动态生成侧边栏和 router,你可以使用该分支[permission-control](https://github.com/PanJiaChen/vue-admin-template/tree/permission-control)
+
+## 相关项目
+
+- [vue-element-admin](https://github.com/PanJiaChen/vue-element-admin)
+
+- [electron-vue-admin](https://github.com/PanJiaChen/electron-vue-admin)
+
+- [vue-typescript-admin-template](https://github.com/Armour/vue-typescript-admin-template)
+
+- [awesome-project](https://github.com/PanJiaChen/vue-element-admin/issues/2312)
+
+写了一个系列的教程配套文章,如何从零构建后一个完整的后台项目:
+
+- [手摸手,带你用 vue 撸后台 系列一(基础篇)](https://juejin.im/post/59097cd7a22b9d0065fb61d2)
+- [手摸手,带你用 vue 撸后台 系列二(登录权限篇)](https://juejin.im/post/591aa14f570c35006961acac)
+- [手摸手,带你用 vue 撸后台 系列三 (实战篇)](https://juejin.im/post/593121aa0ce4630057f70d35)
+- [手摸手,带你用 vue 撸后台 系列四(vueAdmin 一个极简的后台基础模板,专门针对本项目的文章,算作是一篇文档)](https://juejin.im/post/595b4d776fb9a06bbe7dba56)
+- [手摸手,带你封装一个 vue component](https://segmentfault.com/a/1190000009090836)
+
+## Build Setup
+
+```bash
+# 克隆项目
+git clone https://github.com/PanJiaChen/vue-admin-template.git
+
+# 进入项目目录
+cd vue-admin-template
+
+# 安装依赖
+npm install
+
+# 建议不要直接使用 cnpm 安装以来,会有各种诡异的 bug。可以通过如下操作解决 npm 下载速度慢的问题
+npm install --registry=https://registry.npm.taobao.org
+
+# 启动服务
+npm run dev
+```
+
+浏览器访问 [http://localhost:9528](http://localhost:9528)
+
+## 发布
+
+```bash
+# 构建测试环境
+npm run build:stage
+
+# 构建生产环境
+npm run build:prod
+```
+
+## 其它
+
+```bash
+# 预览发布环境效果
+npm run preview
+
+# 预览发布环境效果 + 静态资源分析
+npm run preview -- --report
+
+# 代码格式检查
+npm run lint
+
+# 代码格式检查并自动修复
+npm run lint -- --fix
+```
+
+更多信息请参考 [使用文档](https://panjiachen.github.io/vue-element-admin-site/zh/)
+
+## 购买贴纸
+
+你也可以通过 购买[官方授权的贴纸](https://smallsticker.com/product/vue-element-admin) 的方式来支持 vue-element-admin - 每售出一张贴纸,我们将获得 2 元的捐赠。
+
+## Demo
+
+
+
+## Browsers support
+
+Modern browsers and Internet Explorer 10+.
+
+| [
](http://godban.github.io/browsers-support-badges/)IE / Edge | [
](http://godban.github.io/browsers-support-badges/)Firefox | [
](http://godban.github.io/browsers-support-badges/)Chrome | [
](http://godban.github.io/browsers-support-badges/)Safari |
+| --------- | --------- | --------- | --------- |
+| IE10, IE11, Edge| last 2 versions| last 2 versions| last 2 versions
+
+## License
+
+[MIT](https://github.com/PanJiaChen/vue-admin-template/blob/master/LICENSE) license.
+
+Copyright (c) 2017-present PanJiaChen
diff --git a/babel.config.js b/babel.config.js
new file mode 100644
index 0000000..fb82b27
--- /dev/null
+++ b/babel.config.js
@@ -0,0 +1,14 @@
+module.exports = {
+ presets: [
+ // https://github.com/vuejs/vue-cli/tree/master/packages/@vue/babel-preset-app
+ '@vue/cli-plugin-babel/preset'
+ ],
+ 'env': {
+ 'development': {
+ // babel-plugin-dynamic-import-node plugin only does one thing by converting all import() to require().
+ // This plugin can significantly increase the speed of hot updates, when you have a large number of pages.
+ // https://panjiachen.github.io/vue-element-admin-site/guide/advanced/lazy-loading.html
+ 'plugins': ['dynamic-import-node']
+ }
+ }
+}
diff --git a/build/index.js b/build/index.js
new file mode 100644
index 0000000..0c57de2
--- /dev/null
+++ b/build/index.js
@@ -0,0 +1,35 @@
+const { run } = require('runjs')
+const chalk = require('chalk')
+const config = require('../vue.config.js')
+const rawArgv = process.argv.slice(2)
+const args = rawArgv.join(' ')
+
+if (process.env.npm_config_preview || rawArgv.includes('--preview')) {
+ const report = rawArgv.includes('--report')
+
+ run(`vue-cli-service build ${args}`)
+
+ const port = 9526
+ const publicPath = config.publicPath
+
+ var connect = require('connect')
+ var serveStatic = require('serve-static')
+ const app = connect()
+
+ app.use(
+ publicPath,
+ serveStatic('./dist', {
+ index: ['index.html', '/']
+ })
+ )
+
+ app.listen(port, function () {
+ console.log(chalk.green(`> Preview at http://localhost:${port}${publicPath}`))
+ if (report) {
+ console.log(chalk.green(`> Report at http://localhost:${port}${publicPath}report.html`))
+ }
+
+ })
+} else {
+ run(`vue-cli-service build ${args}`)
+}
diff --git a/jest.config.js b/jest.config.js
new file mode 100644
index 0000000..143cdc8
--- /dev/null
+++ b/jest.config.js
@@ -0,0 +1,24 @@
+module.exports = {
+ moduleFileExtensions: ['js', 'jsx', 'json', 'vue'],
+ transform: {
+ '^.+\\.vue$': 'vue-jest',
+ '.+\\.(css|styl|less|sass|scss|svg|png|jpg|ttf|woff|woff2)$':
+ 'jest-transform-stub',
+ '^.+\\.jsx?$': 'babel-jest'
+ },
+ moduleNameMapper: {
+ '^@/(.*)$': '/src/$1'
+ },
+ snapshotSerializers: ['jest-serializer-vue'],
+ testMatch: [
+ '**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)'
+ ],
+ collectCoverageFrom: ['src/utils/**/*.{js,vue}', '!src/utils/auth.js', '!src/utils/request.js', 'src/components/**/*.{js,vue}'],
+ coverageDirectory: '/tests/unit/coverage',
+ // 'collectCoverage': true,
+ 'coverageReporters': [
+ 'lcov',
+ 'text-summary'
+ ],
+ testURL: 'http://localhost/'
+}
diff --git a/jsconfig.json b/jsconfig.json
new file mode 100644
index 0000000..ed079e2
--- /dev/null
+++ b/jsconfig.json
@@ -0,0 +1,9 @@
+{
+ "compilerOptions": {
+ "baseUrl": "./",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/mock/index.js b/mock/index.js
new file mode 100644
index 0000000..c514c13
--- /dev/null
+++ b/mock/index.js
@@ -0,0 +1,57 @@
+const Mock = require('mockjs')
+const { param2Obj } = require('./utils')
+
+const user = require('./user')
+const table = require('./table')
+
+const mocks = [
+ ...user,
+ ...table
+]
+
+// for front mock
+// please use it cautiously, it will redefine XMLHttpRequest,
+// which will cause many of your third-party libraries to be invalidated(like progress event).
+function mockXHR() {
+ // mock patch
+ // https://github.com/nuysoft/Mock/issues/300
+ Mock.XHR.prototype.proxy_send = Mock.XHR.prototype.send
+ Mock.XHR.prototype.send = function() {
+ if (this.custom.xhr) {
+ this.custom.xhr.withCredentials = this.withCredentials || false
+
+ if (this.responseType) {
+ this.custom.xhr.responseType = this.responseType
+ }
+ }
+ this.proxy_send(...arguments)
+ }
+
+ function XHR2ExpressReqWrap(respond) {
+ return function(options) {
+ let result = null
+ if (respond instanceof Function) {
+ const { body, type, url } = options
+ // https://expressjs.com/en/4x/api.html#req
+ result = respond({
+ method: type,
+ body: JSON.parse(body),
+ query: param2Obj(url)
+ })
+ } else {
+ result = respond
+ }
+ return Mock.mock(result)
+ }
+ }
+
+ for (const i of mocks) {
+ Mock.mock(new RegExp(i.url), i.type || 'get', XHR2ExpressReqWrap(i.response))
+ }
+}
+
+module.exports = {
+ mocks,
+ mockXHR
+}
+
diff --git a/mock/mock-server.js b/mock/mock-server.js
new file mode 100644
index 0000000..8941ec0
--- /dev/null
+++ b/mock/mock-server.js
@@ -0,0 +1,81 @@
+const chokidar = require('chokidar')
+const bodyParser = require('body-parser')
+const chalk = require('chalk')
+const path = require('path')
+const Mock = require('mockjs')
+
+const mockDir = path.join(process.cwd(), 'mock')
+
+function registerRoutes(app) {
+ let mockLastIndex
+ const { mocks } = require('./index.js')
+ const mocksForServer = mocks.map(route => {
+ return responseFake(route.url, route.type, route.response)
+ })
+ for (const mock of mocksForServer) {
+ app[mock.type](mock.url, mock.response)
+ mockLastIndex = app._router.stack.length
+ }
+ const mockRoutesLength = Object.keys(mocksForServer).length
+ return {
+ mockRoutesLength: mockRoutesLength,
+ mockStartIndex: mockLastIndex - mockRoutesLength
+ }
+}
+
+function unregisterRoutes() {
+ Object.keys(require.cache).forEach(i => {
+ if (i.includes(mockDir)) {
+ delete require.cache[require.resolve(i)]
+ }
+ })
+}
+
+// for mock server
+const responseFake = (url, type, respond) => {
+ return {
+ url: new RegExp(`${process.env.VUE_APP_BASE_API}${url}`),
+ type: type || 'get',
+ response(req, res) {
+ console.log('request invoke:' + req.path)
+ res.json(Mock.mock(respond instanceof Function ? respond(req, res) : respond))
+ }
+ }
+}
+
+module.exports = app => {
+ // parse app.body
+ // https://expressjs.com/en/4x/api.html#req.body
+ app.use(bodyParser.json())
+ app.use(bodyParser.urlencoded({
+ extended: true
+ }))
+
+ const mockRoutes = registerRoutes(app)
+ var mockRoutesLength = mockRoutes.mockRoutesLength
+ var mockStartIndex = mockRoutes.mockStartIndex
+
+ // watch files, hot reload mock server
+ chokidar.watch(mockDir, {
+ ignored: /mock-server/,
+ ignoreInitial: true
+ }).on('all', (event, path) => {
+ if (event === 'change' || event === 'add') {
+ try {
+ // remove mock routes stack
+ app._router.stack.splice(mockStartIndex, mockRoutesLength)
+
+ // clear routes cache
+ unregisterRoutes()
+
+ const mockRoutes = registerRoutes(app)
+ mockRoutesLength = mockRoutes.mockRoutesLength
+ mockStartIndex = mockRoutes.mockStartIndex
+
+ console.log(chalk.magentaBright(`\n > Mock Server hot reload success! changed ${path}`))
+ } catch (error) {
+ console.log(chalk.redBright(error))
+ }
+ }
+ })
+}
diff --git a/mock/table.js b/mock/table.js
new file mode 100644
index 0000000..bd0e013
--- /dev/null
+++ b/mock/table.js
@@ -0,0 +1,29 @@
+const Mock = require('mockjs')
+
+const data = Mock.mock({
+ 'items|30': [{
+ id: '@id',
+ title: '@sentence(10, 20)',
+ 'status|1': ['published', 'draft', 'deleted'],
+ author: 'name',
+ display_time: '@datetime',
+ pageviews: '@integer(300, 5000)'
+ }]
+})
+
+module.exports = [
+ {
+ url: '/vue-admin-template/table/list',
+ type: 'get',
+ response: config => {
+ const items = data.items
+ return {
+ code: 20000,
+ data: {
+ total: items.length,
+ items: items
+ }
+ }
+ }
+ }
+]
diff --git a/mock/user.js b/mock/user.js
new file mode 100644
index 0000000..7555338
--- /dev/null
+++ b/mock/user.js
@@ -0,0 +1,84 @@
+
+const tokens = {
+ admin: {
+ token: 'admin-token'
+ },
+ editor: {
+ token: 'editor-token'
+ }
+}
+
+const users = {
+ 'admin-token': {
+ roles: ['admin'],
+ introduction: 'I am a super administrator',
+ avatar: 'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif',
+ name: 'Super Admin'
+ },
+ 'editor-token': {
+ roles: ['editor'],
+ introduction: 'I am an editor',
+ avatar: 'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif',
+ name: 'Normal Editor'
+ }
+}
+
+module.exports = [
+ // user login
+ {
+ url: '/vue-admin-template/user/login',
+ type: 'post',
+ response: config => {
+ const { username } = config.body
+ const token = tokens[username]
+
+ // mock error
+ if (!token) {
+ return {
+ code: 60204,
+ message: 'Account and password are incorrect.'
+ }
+ }
+
+ return {
+ code: 20000,
+ data: token
+ }
+ }
+ },
+
+ // get user info
+ {
+ url: '/vue-admin-template/user/info\.*',
+ type: 'get',
+ response: config => {
+ const { token } = config.query
+ const info = users[token]
+
+ // mock error
+ if (!info) {
+ return {
+ code: 50008,
+ message: 'Login failed, unable to get user details.'
+ }
+ }
+
+ return {
+ code: 20000,
+ data: info
+ }
+ }
+ },
+
+ // user logout
+ {
+ url: '/vue-admin-template/user/logout',
+ type: 'post',
+ response: _ => {
+ return {
+ code: 20000,
+ data: 'success'
+ }
+ }
+ }
+]
diff --git a/mock/utils.js b/mock/utils.js
new file mode 100644
index 0000000..95cc27d
--- /dev/null
+++ b/mock/utils.js
@@ -0,0 +1,25 @@
+/**
+ * @param {string} url
+ * @returns {Object}
+ */
+function param2Obj(url) {
+ const search = decodeURIComponent(url.split('?')[1]).replace(/\+/g, ' ')
+ if (!search) {
+ return {}
+ }
+ const obj = {}
+ const searchArr = search.split('&')
+ searchArr.forEach(v => {
+ const index = v.indexOf('=')
+ if (index !== -1) {
+ const name = v.substring(0, index)
+ const val = v.substring(index + 1, v.length)
+ obj[name] = val
+ }
+ })
+ return obj
+}
+
+module.exports = {
+ param2Obj
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..2413824
--- /dev/null
+++ b/package.json
@@ -0,0 +1,62 @@
+{
+ "name": "vue-admin-template",
+ "version": "4.4.0",
+ "description": "A vue admin template with Element UI & axios & iconfont & permission control & lint",
+ "author": "Pan ",
+ "scripts": {
+ "dev": "vue-cli-service serve",
+ "build:prod": "vue-cli-service build",
+ "build:stage": "vue-cli-service build --mode staging",
+ "preview": "node build/index.js --preview",
+ "svgo": "svgo -f src/icons/svg --config=src/icons/svgo.yml",
+ "lint": "eslint --ext .js,.vue src",
+ "test:unit": "jest --clearCache && vue-cli-service test:unit",
+ "test:ci": "npm run lint && npm run test:unit"
+ },
+ "dependencies": {
+ "axios": "0.18.1",
+ "core-js": "3.6.5",
+ "element-ui": "2.13.2",
+ "js-cookie": "2.2.0",
+ "normalize.css": "7.0.0",
+ "nprogress": "0.2.0",
+ "path-to-regexp": "2.4.0",
+ "vue": "2.6.10",
+ "vue-router": "3.0.6",
+ "vuex": "3.1.0"
+ },
+ "devDependencies": {
+ "@vue/cli-plugin-babel": "4.4.4",
+ "@vue/cli-plugin-eslint": "4.4.4",
+ "@vue/cli-plugin-unit-jest": "4.4.4",
+ "@vue/cli-service": "4.4.4",
+ "@vue/test-utils": "1.0.0-beta.29",
+ "autoprefixer": "9.5.1",
+ "babel-eslint": "10.1.0",
+ "babel-jest": "23.6.0",
+ "babel-plugin-dynamic-import-node": "2.3.3",
+ "chalk": "2.4.2",
+ "connect": "3.6.6",
+ "eslint": "6.7.2",
+ "eslint-plugin-vue": "6.2.2",
+ "html-webpack-plugin": "3.2.0",
+ "mockjs": "1.0.1-beta3",
+ "runjs": "4.3.2",
+ "sass": "1.26.8",
+ "sass-loader": "8.0.2",
+ "script-ext-html-webpack-plugin": "2.1.3",
+ "serve-static": "1.13.2",
+ "svg-sprite-loader": "4.1.3",
+ "svgo": "1.2.2",
+ "vue-template-compiler": "2.6.10"
+ },
+ "browserslist": [
+ "> 1%",
+ "last 2 versions"
+ ],
+ "engines": {
+ "node": ">=8.9",
+ "npm": ">= 3.0.0"
+ },
+ "license": "MIT"
+}
diff --git a/postcss.config.js b/postcss.config.js
new file mode 100644
index 0000000..10473ef
--- /dev/null
+++ b/postcss.config.js
@@ -0,0 +1,8 @@
+// https://github.com/michael-ciniawsky/postcss-load-config
+
+module.exports = {
+ 'plugins': {
+ // to edit target browsers: use "browserslist" field in package.json
+ 'autoprefixer': {}
+ }
+}
diff --git a/public/favicon.ico b/public/favicon.ico
new file mode 100644
index 0000000..34b63ac
Binary files /dev/null and b/public/favicon.ico differ
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..fa2be91
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+ <%= webpackConfig.name %>
+
+
+
+
+
+
+
diff --git a/src/App.vue b/src/App.vue
new file mode 100644
index 0000000..ec9032c
--- /dev/null
+++ b/src/App.vue
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
diff --git a/src/api/softbus.js b/src/api/softbus.js
new file mode 100644
index 0000000..479b15a
--- /dev/null
+++ b/src/api/softbus.js
@@ -0,0 +1,276 @@
+import axios from 'axios'
+import { Message } from 'element-ui'
+
+const service = axios.create({
+ // Default through devServer proxy. Set VUE_APP_SOFTBUS_BASE_API to direct-call backend.
+ baseURL: 'http://192.168.31.108:18080' || '/softbus-api',
+ timeout: Number(process.env.VUE_APP_SOFTBUS_TIMEOUT || 15000)
+})
+
+function unwrapResponse(response) {
+ const { data } = response
+ if (data && typeof data === 'object') {
+ if (Object.prototype.hasOwnProperty.call(data, 'code') && Object.prototype.hasOwnProperty.call(data, 'data')) {
+ if (data.code === 0 || data.code === 20000) {
+ return data.data
+ }
+ throw new Error(data.message || 'Request failed')
+ }
+ if (Object.prototype.hasOwnProperty.call(data, 'ok') && Object.prototype.hasOwnProperty.call(data, 'data')) {
+ if (data.ok) {
+ return data.data
+ }
+ throw new Error(data.error || 'Request failed')
+ }
+ }
+ return data
+}
+
+export function getDataSourceMode() {
+ return 'api'
+}
+
+export function isMockMode() {
+ return false
+}
+
+export function setDataSourceMode() {}
+
+function request(config) {
+ return service(config)
+ .then(unwrapResponse)
+ .catch(error => {
+ Message({
+ message: error.message || '真实后端请求失败',
+ type: 'error',
+ duration: 4 * 1000
+ })
+ return Promise.reject(error)
+ })
+}
+
+function toCounterList(counters) {
+ if (Array.isArray(counters)) return counters
+ if (!counters || typeof counters !== 'object') return []
+ const accentMap = {
+ in: 'blue',
+ out: 'green',
+ drop: 'orange',
+ error: 'red',
+ mergeDrop: 'purple'
+ }
+ return Object.keys(counters).map(key => ({
+ label: key,
+ value: counters[key],
+ accent: accentMap[key] || 'blue'
+ }))
+}
+
+function toArray(data, key) {
+ if (Array.isArray(data)) return data
+ if (data && Array.isArray(data[key])) return data[key]
+ return []
+}
+
+function pickFirstArray(data) {
+ if (Array.isArray(data)) return data
+ if (!data || typeof data !== 'object') return []
+ const matchedKey = Object.keys(data).find(key => Array.isArray(data[key]))
+ return matchedKey ? data[matchedKey] : []
+}
+
+function normalizeRuleStatus(status) {
+ return {
+ filterRuleCount: status.filterRuleCount || status.filters || 0,
+ transformRuleCount: status.transformRuleCount || status.transforms || 0,
+ mappingRuleCount: status.mappingRuleCount || status.mappings || 0
+ }
+}
+
+function normalizeServiceStatus(status, health) {
+ const data = status || {}
+ return {
+ running: Boolean(data.running),
+ version: data.version || '-',
+ pid: data.pid || '-',
+ role: data.role || '-',
+ timestamp: data.timestamp || '-',
+ discoveryEnabled: Boolean((data.discovery && data.discovery.enabled) || data.discoveryEnabled),
+ health: Boolean(health && (health.ok !== undefined ? health.ok : health)),
+ queueUsage: data.queueUsage || '-'
+ }
+}
+
+function normalizeDashboard(summary) {
+ const deviceCount = Array.isArray(summary.devices) ? summary.devices.length : 0
+ const onlineCount = Array.isArray(summary.devices)
+ ? summary.devices.filter(item => item.status === 'online' || item.isActive).length
+ : 0
+ const ruleSummary = [
+ { label: '过滤规则', value: summary.ruleStatus.filterRuleCount, status: 'warning', note: '当前以注册管理为主' },
+ { label: '转换规则', value: summary.ruleStatus.transformRuleCount, status: 'success', note: '已接入执行链路' },
+ { label: '映射规则', value: summary.ruleStatus.mappingRuleCount, status: 'primary', note: '已绑定 domPath' }
+ ]
+ const statusCards = [
+ { label: '在线设备', value: String(onlineCount), trend: `${deviceCount} 台总设备`, trendType: 'neutral', hint: '来自 /api/v1/devices' },
+ { label: '规则总数', value: String(ruleSummary.reduce((acc, item) => acc + Number(item.value || 0), 0)), trend: '实时统计', trendType: 'neutral', hint: '来自 /api/v1/rules/status' },
+ { label: 'Discovery 状态', value: summary.discovery.sniffingEnabled ? '运行中' : '未启动', trend: summary.discovery.sniffingEnabled ? 'active' : 'idle', trendType: 'neutral', hint: '来自 /api/v1/discovery/status' },
+ { label: 'Pipeline 错误', value: String(summary.counterMap.error || 0), trend: `drop ${summary.counterMap.drop || 0}`, trendType: Number(summary.counterMap.error || 0) > 0 ? 'down' : 'up', hint: '来自 /api/v1/pipeline/counters' }
+ ]
+ const alerts = []
+ if (!summary.serviceStatus.running) {
+ alerts.push({ level: 'danger', title: '核心服务未运行', detail: '请先检查守护进程状态和健康检查接口。' })
+ }
+ alerts.push({ level: 'warning', title: '过滤规则未完全接入执行链路', detail: '过滤规则当前以注册管理为主,不保证参与真实过滤。' })
+ if (Number(summary.counterMap.error || 0) > 0 || Number(summary.counterMap.drop || 0) > 0) {
+ alerts.push({ level: 'warning', title: 'Pipeline 存在异常计数', detail: `当前 drop=${summary.counterMap.drop || 0},error=${summary.counterMap.error || 0}。` })
+ } else {
+ alerts.push({ level: 'success', title: 'Pipeline 运行稳定', detail: '当前 counters 未发现明显错误或丢弃峰值。' })
+ }
+ return {
+ statusCards,
+ pipelineCounters: summary.pipelineCounters,
+ ruleSummary,
+ alerts,
+ serviceStatus: summary.serviceStatus,
+ pipelineStatus: summary.pipelineStatus
+ }
+}
+
+function normalizePlugins(data) {
+ return toArray(data, 'items').length ? toArray(data, 'items') : pickFirstArray(data)
+}
+
+export function getDashboardSummary() {
+ return Promise.all([
+ request({ url: '/v1/status', method: 'get' }),
+ request({ url: '/health', method: 'get' }),
+ request({ url: '/api/v1/rules/status', method: 'get' }),
+ request({ url: '/api/v1/pipeline/status', method: 'get' }),
+ request({ url: '/api/v1/pipeline/counters', method: 'get' }),
+ request({ url: '/api/v1/discovery/status', method: 'get' }),
+ request({ url: '/api/v1/devices', method: 'get' })
+ ]).then(([status, health, ruleStatus, pipelineStatus, counters, discovery, devices]) => normalizeDashboard({
+ serviceStatus: normalizeServiceStatus(status, health),
+ ruleStatus: normalizeRuleStatus(ruleStatus || {}),
+ pipelineStatus: pipelineStatus || {},
+ pipelineCounters: toCounterList(counters),
+ counterMap: counters || {},
+ discovery: discovery || {},
+ devices: toArray(devices, 'items').length ? toArray(devices, 'items') : pickFirstArray(devices)
+ }))
+}
+
+export function getDiscoveryWorkbench() {
+ return Promise.all([
+ request({ url: '/api/v1/discovery/status', method: 'get' }),
+ request({ url: '/api/v1/discovery/samples', method: 'get' }),
+ request({ url: '/api/v1/mapping/list', method: 'get' })
+ ]).then(([status, samples, mappings]) => ({
+ status,
+ samples: pickFirstArray(samples),
+ mappings: pickFirstArray(mappings)
+ }))
+}
+
+export function getRuleWorkspace(type) {
+ if (type === 'mapping') {
+ return request({ url: '/api/v1/mapping/list', method: 'get' }).then(data => pickFirstArray(data))
+ }
+ return request({ url: '/api/v1/rules/status', method: 'get' }).then(data => normalizeRuleStatus(data || {}))
+}
+
+export function getDevicesOverview() {
+ return Promise.all([
+ request({ url: '/api/v1/device-tree', method: 'get' }),
+ request({ url: '/api/v1/devices', method: 'get' })
+ ]).then(([tree, deviceList]) => ({
+ tree: pickFirstArray(tree),
+ devices: pickFirstArray(deviceList)
+ }))
+}
+
+export function getPipelineOverview() {
+ return Promise.all([
+ request({ url: '/api/v1/pipeline/status', method: 'get' }),
+ request({ url: '/api/v1/pipeline/counters', method: 'get' }),
+ request({ url: '/api/v1/pipeline/config', method: 'get' })
+ ]).then(([status, counters, config]) => ({
+ status,
+ counters: toCounterList(counters),
+ config
+ }))
+}
+
+export function getPluginOverview() {
+ return Promise.all([
+ request({ url: '/api/v1/plugins', method: 'get' }),
+ request({ url: '/api/v1/plugins/protocols', method: 'get' }),
+ request({ url: '/api/v1/plugins/mappers', method: 'get' })
+ ]).then(([pluginList, protocols, mappers]) => ({
+ plugins: normalizePlugins(pluginList),
+ protocols: normalizePlugins(protocols),
+ mappers: normalizePlugins(mappers)
+ }))
+}
+
+export function getMetadataOverview() {
+ return request({ url: '/api/v1/metadata', method: 'get' }).then(data => pickFirstArray(data))
+}
+
+export function getProfilesOverview() {
+ return request({ url: '/api/v1/profiles', method: 'get' }).then(data => pickFirstArray(data))
+}
+
+export function getDeviceDetail(id) {
+ return request({ url: `/api/v1/devices/${id}`, method: 'get' })
+}
+
+export function updateDeviceTreeNode(id, payload) {
+ return request({ url: `/api/v1/device-tree/nodes/${id}`, method: 'patch', data: payload })
+}
+
+export function startDiscovery(payload) {
+ const body = payload || {}
+ return request({ url: '/api/v1/discovery/start', method: 'post', data: body })
+}
+
+export function stopDiscovery() {
+ return request({ url: '/api/v1/discovery/stop', method: 'post' })
+}
+
+export function clearDiscovery() {
+ return request({ url: '/api/v1/discovery/clear', method: 'post' })
+}
+
+export function bindMapping(payload) {
+ return request({ url: '/api/v1/mapping/bind', method: 'post', data: payload })
+}
+
+export function unbindMapping(payload) {
+ return request({ url: '/api/v1/mapping/unbind', method: 'post', data: payload })
+}
+
+export function createFilterRule(payload) {
+ return request({ url: '/api/v1/rules/filter', method: 'post', data: payload })
+}
+
+export function deleteFilterRule(id) {
+ return request({ url: `/api/v1/rules/filter/${id}`, method: 'delete' })
+}
+
+export function createTransformRule(payload) {
+ return request({ url: '/api/v1/rules/transform', method: 'post', data: payload })
+}
+
+export function deleteTransformRule(id) {
+ return request({ url: `/api/v1/rules/transform/${id}`, method: 'delete' })
+}
+
+export function getMetadataDetail(metadataId) {
+ return request({ url: `/api/v1/metadata/${metadataId}`, method: 'get' })
+}
+
+export function getProfileDetail(deviceId) {
+ return request({ url: `/api/v1/profiles/${deviceId}`, method: 'get' })
+}
diff --git a/src/api/table.js b/src/api/table.js
new file mode 100644
index 0000000..2752f52
--- /dev/null
+++ b/src/api/table.js
@@ -0,0 +1,9 @@
+import request from '@/utils/request'
+
+export function getList(params) {
+ return request({
+ url: '/vue-admin-template/table/list',
+ method: 'get',
+ params
+ })
+}
diff --git a/src/api/user.js b/src/api/user.js
new file mode 100644
index 0000000..8ff4389
--- /dev/null
+++ b/src/api/user.js
@@ -0,0 +1,24 @@
+import request from '@/utils/request'
+
+export function login(data) {
+ return request({
+ url: '/vue-admin-template/user/login',
+ method: 'post',
+ data
+ })
+}
+
+export function getInfo(token) {
+ return request({
+ url: '/vue-admin-template/user/info',
+ method: 'get',
+ params: { token }
+ })
+}
+
+export function logout() {
+ return request({
+ url: '/vue-admin-template/user/logout',
+ method: 'post'
+ })
+}
diff --git a/src/assets/404_images/404.png b/src/assets/404_images/404.png
new file mode 100644
index 0000000..3d8e230
Binary files /dev/null and b/src/assets/404_images/404.png differ
diff --git a/src/assets/404_images/404_cloud.png b/src/assets/404_images/404_cloud.png
new file mode 100644
index 0000000..c6281d0
Binary files /dev/null and b/src/assets/404_images/404_cloud.png differ
diff --git a/src/components/Breadcrumb/index.vue b/src/components/Breadcrumb/index.vue
new file mode 100644
index 0000000..29f9a04
--- /dev/null
+++ b/src/components/Breadcrumb/index.vue
@@ -0,0 +1,78 @@
+
+
+
+
+ {{ item.meta.title }}
+ {{ item.meta.title }}
+
+
+
+
+
+
+
+
diff --git a/src/components/Hamburger/index.vue b/src/components/Hamburger/index.vue
new file mode 100644
index 0000000..368b002
--- /dev/null
+++ b/src/components/Hamburger/index.vue
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
diff --git a/src/components/SvgIcon/index.vue b/src/components/SvgIcon/index.vue
new file mode 100644
index 0000000..b07ded2
--- /dev/null
+++ b/src/components/SvgIcon/index.vue
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
diff --git a/src/icons/index.js b/src/icons/index.js
new file mode 100644
index 0000000..2c6b309
--- /dev/null
+++ b/src/icons/index.js
@@ -0,0 +1,9 @@
+import Vue from 'vue'
+import SvgIcon from '@/components/SvgIcon'// svg component
+
+// register globally
+Vue.component('svg-icon', SvgIcon)
+
+const req = require.context('./svg', false, /\.svg$/)
+const requireAll = requireContext => requireContext.keys().map(requireContext)
+requireAll(req)
diff --git a/src/icons/svg/dashboard.svg b/src/icons/svg/dashboard.svg
new file mode 100644
index 0000000..5317d37
--- /dev/null
+++ b/src/icons/svg/dashboard.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/icons/svg/example.svg b/src/icons/svg/example.svg
new file mode 100644
index 0000000..46f42b5
--- /dev/null
+++ b/src/icons/svg/example.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/icons/svg/eye-open.svg b/src/icons/svg/eye-open.svg
new file mode 100644
index 0000000..88dcc98
--- /dev/null
+++ b/src/icons/svg/eye-open.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/icons/svg/eye.svg b/src/icons/svg/eye.svg
new file mode 100644
index 0000000..16ed2d8
--- /dev/null
+++ b/src/icons/svg/eye.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/icons/svg/form.svg b/src/icons/svg/form.svg
new file mode 100644
index 0000000..dcbaa18
--- /dev/null
+++ b/src/icons/svg/form.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/icons/svg/link.svg b/src/icons/svg/link.svg
new file mode 100644
index 0000000..48197ba
--- /dev/null
+++ b/src/icons/svg/link.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/icons/svg/nested.svg b/src/icons/svg/nested.svg
new file mode 100644
index 0000000..06713a8
--- /dev/null
+++ b/src/icons/svg/nested.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/icons/svg/password.svg b/src/icons/svg/password.svg
new file mode 100644
index 0000000..e291d85
--- /dev/null
+++ b/src/icons/svg/password.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/icons/svg/table.svg b/src/icons/svg/table.svg
new file mode 100644
index 0000000..0e3dc9d
--- /dev/null
+++ b/src/icons/svg/table.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/icons/svg/tree.svg b/src/icons/svg/tree.svg
new file mode 100644
index 0000000..dd4b7dd
--- /dev/null
+++ b/src/icons/svg/tree.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/icons/svg/user.svg b/src/icons/svg/user.svg
new file mode 100644
index 0000000..0ba0716
--- /dev/null
+++ b/src/icons/svg/user.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/icons/svgo.yml b/src/icons/svgo.yml
new file mode 100644
index 0000000..d11906a
--- /dev/null
+++ b/src/icons/svgo.yml
@@ -0,0 +1,22 @@
+# replace default config
+
+# multipass: true
+# full: true
+
+plugins:
+
+ # - name
+ #
+ # or:
+ # - name: false
+ # - name: true
+ #
+ # or:
+ # - name:
+ # param1: 1
+ # param2: 2
+
+- removeAttrs:
+ attrs:
+ - 'fill'
+ - 'fill-rule'
diff --git a/src/layout/components/AppMain.vue b/src/layout/components/AppMain.vue
new file mode 100644
index 0000000..c1a5375
--- /dev/null
+++ b/src/layout/components/AppMain.vue
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/layout/components/Navbar.vue b/src/layout/components/Navbar.vue
new file mode 100644
index 0000000..0ca5cf6
--- /dev/null
+++ b/src/layout/components/Navbar.vue
@@ -0,0 +1,139 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/layout/components/Sidebar/FixiOSBug.js b/src/layout/components/Sidebar/FixiOSBug.js
new file mode 100644
index 0000000..bc14856
--- /dev/null
+++ b/src/layout/components/Sidebar/FixiOSBug.js
@@ -0,0 +1,26 @@
+export default {
+ computed: {
+ device() {
+ return this.$store.state.app.device
+ }
+ },
+ mounted() {
+ // In order to fix the click on menu on the ios device will trigger the mouseleave bug
+ // https://github.com/PanJiaChen/vue-element-admin/issues/1135
+ this.fixBugIniOS()
+ },
+ methods: {
+ fixBugIniOS() {
+ const $subMenu = this.$refs.subMenu
+ if ($subMenu) {
+ const handleMouseleave = $subMenu.handleMouseleave
+ $subMenu.handleMouseleave = (e) => {
+ if (this.device === 'mobile') {
+ return
+ }
+ handleMouseleave(e)
+ }
+ }
+ }
+ }
+}
diff --git a/src/layout/components/Sidebar/Item.vue b/src/layout/components/Sidebar/Item.vue
new file mode 100644
index 0000000..aa1f5da
--- /dev/null
+++ b/src/layout/components/Sidebar/Item.vue
@@ -0,0 +1,41 @@
+
+
+
diff --git a/src/layout/components/Sidebar/Link.vue b/src/layout/components/Sidebar/Link.vue
new file mode 100644
index 0000000..530b3d5
--- /dev/null
+++ b/src/layout/components/Sidebar/Link.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
diff --git a/src/layout/components/Sidebar/Logo.vue b/src/layout/components/Sidebar/Logo.vue
new file mode 100644
index 0000000..040fab6
--- /dev/null
+++ b/src/layout/components/Sidebar/Logo.vue
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
diff --git a/src/layout/components/Sidebar/SidebarItem.vue b/src/layout/components/Sidebar/SidebarItem.vue
new file mode 100644
index 0000000..a418c3d
--- /dev/null
+++ b/src/layout/components/Sidebar/SidebarItem.vue
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/layout/components/Sidebar/index.vue b/src/layout/components/Sidebar/index.vue
new file mode 100644
index 0000000..da39034
--- /dev/null
+++ b/src/layout/components/Sidebar/index.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/layout/components/TagsView/ScrollPane.vue b/src/layout/components/TagsView/ScrollPane.vue
new file mode 100644
index 0000000..74e2927
--- /dev/null
+++ b/src/layout/components/TagsView/ScrollPane.vue
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/layout/components/TagsView/index.vue b/src/layout/components/TagsView/index.vue
new file mode 100644
index 0000000..018c925
--- /dev/null
+++ b/src/layout/components/TagsView/index.vue
@@ -0,0 +1,301 @@
+
+
+
+
+ {{ tag.title }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/layout/components/index.js b/src/layout/components/index.js
new file mode 100644
index 0000000..686faf6
--- /dev/null
+++ b/src/layout/components/index.js
@@ -0,0 +1,4 @@
+export { default as Navbar } from './Navbar'
+export { default as Sidebar } from './Sidebar'
+export { default as AppMain } from './AppMain'
+export { default as TagsView } from './TagsView/index.vue'
diff --git a/src/layout/index.vue b/src/layout/index.vue
new file mode 100644
index 0000000..fe8be08
--- /dev/null
+++ b/src/layout/index.vue
@@ -0,0 +1,98 @@
+
+
+
+
+
+
+
diff --git a/src/layout/mixin/ResizeHandler.js b/src/layout/mixin/ResizeHandler.js
new file mode 100644
index 0000000..e8d0df8
--- /dev/null
+++ b/src/layout/mixin/ResizeHandler.js
@@ -0,0 +1,45 @@
+import store from '@/store'
+
+const { body } = document
+const WIDTH = 992 // refer to Bootstrap's responsive design
+
+export default {
+ watch: {
+ $route(route) {
+ if (this.device === 'mobile' && this.sidebar.opened) {
+ store.dispatch('app/closeSideBar', { withoutAnimation: false })
+ }
+ }
+ },
+ beforeMount() {
+ window.addEventListener('resize', this.$_resizeHandler)
+ },
+ beforeDestroy() {
+ window.removeEventListener('resize', this.$_resizeHandler)
+ },
+ mounted() {
+ const isMobile = this.$_isMobile()
+ if (isMobile) {
+ store.dispatch('app/toggleDevice', 'mobile')
+ store.dispatch('app/closeSideBar', { withoutAnimation: true })
+ }
+ },
+ methods: {
+ // use $_ for mixins properties
+ // https://vuejs.org/v2/style-guide/index.html#Private-property-names-essential
+ $_isMobile() {
+ const rect = body.getBoundingClientRect()
+ return rect.width - 1 < WIDTH
+ },
+ $_resizeHandler() {
+ if (!document.hidden) {
+ const isMobile = this.$_isMobile()
+ store.dispatch('app/toggleDevice', isMobile ? 'mobile' : 'desktop')
+
+ if (isMobile) {
+ store.dispatch('app/closeSideBar', { withoutAnimation: true })
+ }
+ }
+ }
+ }
+}
diff --git a/src/main.js b/src/main.js
new file mode 100644
index 0000000..01cba2f
--- /dev/null
+++ b/src/main.js
@@ -0,0 +1,43 @@
+import Vue from 'vue'
+
+import 'normalize.css/normalize.css' // A modern alternative to CSS resets
+
+import ElementUI from 'element-ui'
+import 'element-ui/lib/theme-chalk/index.css'
+import locale from 'element-ui/lib/locale/lang/en' // lang i18n
+
+import '@/styles/index.scss' // global css
+
+import App from './App'
+import store from './store'
+import router from './router'
+
+import '@/icons' // icon
+import '@/permission' // permission control
+
+/**
+ * If you don't want to use mock-server
+ * you want to use MockJs for mock api
+ * you can execute: mockXHR()
+ *
+ * Currently MockJs will be used in the production environment,
+ * please remove it before going online ! ! !
+ */
+if (process.env.NODE_ENV === 'production') {
+ const { mockXHR } = require('../mock')
+ mockXHR()
+}
+
+// set ElementUI lang to EN
+Vue.use(ElementUI, { locale })
+// 如果想要中文版 element-ui,按如下方式声明
+// Vue.use(ElementUI)
+
+Vue.config.productionTip = false
+
+new Vue({
+ el: '#app',
+ router,
+ store,
+ render: h => h(App)
+})
diff --git a/src/permission.js b/src/permission.js
new file mode 100644
index 0000000..fa1ea19
--- /dev/null
+++ b/src/permission.js
@@ -0,0 +1,64 @@
+import router from './router'
+import store from './store'
+import { Message } from 'element-ui'
+import NProgress from 'nprogress' // progress bar
+import 'nprogress/nprogress.css' // progress bar style
+import { getToken } from '@/utils/auth' // get token from cookie
+import getPageTitle from '@/utils/get-page-title'
+
+NProgress.configure({ showSpinner: false }) // NProgress Configuration
+
+const whiteList = ['/login'] // no redirect whitelist
+
+router.beforeEach(async(to, from, next) => {
+ // start progress bar
+ NProgress.start()
+
+ // set page title
+ document.title = getPageTitle(to.meta.title)
+
+ // determine whether the user has logged in
+ const hasToken = getToken()
+
+ if (hasToken) {
+ if (to.path === '/login') {
+ // if is logged in, redirect to the home page
+ next({ path: '/' })
+ NProgress.done()
+ } else {
+ const hasGetUserInfo = store.getters.name
+ if (hasGetUserInfo) {
+ next()
+ } else {
+ try {
+ // get user info
+ await store.dispatch('user/getInfo')
+
+ next()
+ } catch (error) {
+ // remove token and go to login page to re-login
+ await store.dispatch('user/resetToken')
+ Message.error(error || 'Has Error')
+ next(`/login?redirect=${to.path}`)
+ NProgress.done()
+ }
+ }
+ }
+ } else {
+ /* has no token*/
+
+ if (whiteList.indexOf(to.path) !== -1) {
+ // in the free login whitelist, go directly
+ next()
+ } else {
+ // other pages that do not have permission to access are redirected to the login page.
+ next(`/login?redirect=${to.path}`)
+ NProgress.done()
+ }
+ }
+})
+
+router.afterEach(() => {
+ // finish progress bar
+ NProgress.done()
+})
diff --git a/src/router/index.js b/src/router/index.js
new file mode 100644
index 0000000..0a5e98e
--- /dev/null
+++ b/src/router/index.js
@@ -0,0 +1,192 @@
+import Vue from 'vue'
+import Router from 'vue-router'
+
+Vue.use(Router)
+
+/* Layout */
+import Layout from '@/layout'
+
+/**
+ * Note: sub-menu only appear when route children.length >= 1
+ * Detail see: https://panjiachen.github.io/vue-element-admin-site/guide/essentials/router-and-nav.html
+ *
+ * hidden: true if set true, item will not show in the sidebar(default is false)
+ * alwaysShow: true if set true, will always show the root menu
+ * if not set alwaysShow, when item has more than one children route,
+ * it will becomes nested mode, otherwise not show the root menu
+ * redirect: noRedirect if set noRedirect will no redirect in the breadcrumb
+ * name:'router-name' the name is used by (must set!!!)
+ * meta : {
+ roles: ['admin','editor'] control the page roles (you can set multiple roles)
+ title: 'title' the name show in sidebar and breadcrumb (recommend set)
+ icon: 'svg-name'/'el-icon-x' the icon show in the sidebar
+ breadcrumb: false if set false, the item will hidden in breadcrumb(default is true)
+ activeMenu: '/example/list' if set path, the sidebar will highlight the path you set
+ }
+ */
+
+/**
+ * constantRoutes
+ * a base page that does not have permission requirements
+ * all roles can be accessed
+ */
+export const constantRoutes = [
+ {
+ path: '/redirect',
+ component: Layout,
+ hidden: true,
+ children: [
+ {
+ path: '/redirect/:path(.*)',
+ component: () => import('@/views/redirect/index')
+ }
+ ]
+ },
+ {
+ path: '/login',
+ component: () => import('@/views/login/index'),
+ hidden: true
+ },
+
+ {
+ path: '/404',
+ component: () => import('@/views/404'),
+ hidden: true
+ },
+
+ {
+ path: '/',
+ component: Layout,
+ redirect: '/dashboard',
+ children: [{
+ path: 'dashboard',
+ name: 'Dashboard',
+ component: () => import('@/views/dashboard/index'),
+ meta: { title: 'Dashboard', icon: 'dashboard', affix: true }
+ }]
+ },
+ {
+ path: '/discovery',
+ component: Layout,
+ children: [
+ {
+ path: 'index',
+ name: 'DiscoveryWorkbench',
+ component: () => import('@/views/prototype/discovery/index'),
+ meta: { title: '发现接入', icon: 'search' }
+ }
+ ]
+ },
+
+ {
+ path: '/rules',
+ component: Layout,
+ redirect: '/rules/filter',
+ name: 'RuleCenter',
+ meta: { title: '规则映射', icon: 'table' },
+ children: [
+ {
+ path: 'filter',
+ name: 'FilterRules',
+ component: () => import('@/views/prototype/rules/filter'),
+ meta: { title: '过滤规则', icon: 'edit' }
+ },
+ {
+ path: 'transform',
+ name: 'TransformRules',
+ component: () => import('@/views/prototype/rules/transform'),
+ meta: { title: '转换规则', icon: 'form' }
+ },
+ {
+ path: 'mapping',
+ name: 'MappingRules',
+ component: () => import('@/views/prototype/rules/mapping'),
+ meta: { title: '映射规则', icon: 'link' }
+ }
+ ]
+ },
+
+ {
+ path: '/devices',
+ component: Layout,
+ children: [
+ {
+ path: 'index',
+ name: 'DevicesPage',
+ component: () => import('@/views/prototype/devices/index'),
+ meta: { title: '设备管理', icon: 'el-icon-monitor' }
+ }
+ ]
+ },
+
+ {
+ path: '/pipeline',
+ component: Layout,
+ children: [
+ {
+ path: 'index',
+ name: 'PipelinePage',
+ component: () => import('@/views/prototype/pipeline/index'),
+ meta: { title: 'Pipeline 监控', icon: 'el-icon-data-line' }
+ }
+ ]
+ },
+
+ {
+ path: '/plugins',
+ component: Layout,
+ children: [
+ {
+ path: 'index',
+ name: 'PluginsPage',
+ component: () => import('@/views/prototype/plugins/index'),
+ meta: { title: '插件中心', icon: 'el-icon-connection' }
+ }
+ ]
+ },
+
+ {
+ path: '/metadata',
+ component: Layout,
+ children: [
+ {
+ path: 'index',
+ name: 'MetadataPage',
+ component: () => import('@/views/prototype/metadata/index'),
+ meta: { title: 'Metadata', icon: 'el-icon-collection-tag' }
+ }
+ ]
+ },
+
+ {
+ path: '/profiles',
+ component: Layout,
+ children: [
+ {
+ path: 'index',
+ name: 'ProfilesPage',
+ component: () => import('@/views/prototype/profiles/index'),
+ meta: { title: 'Profiles', icon: 'el-icon-tickets' }
+ }
+ ]
+ },
+
+ // 404 page must be placed at the end !!!
+ { path: '*', redirect: '/404', hidden: true }
+]
+
+const createRouter = () => new Router({
+ // mode: 'history', // require service support
+ scrollBehavior: () => ({ y: 0 }),
+ routes: constantRoutes
+})
+
+const router = createRouter()
+
+// Detail see: https://github.com/vuejs/vue-router/issues/1234#issuecomment-357941465
+export function resetRouter() {
+ const newRouter = createRouter()
+ router.matcher = newRouter.matcher // reset router
+}
+
+export default router
diff --git a/src/settings.js b/src/settings.js
new file mode 100644
index 0000000..6115db1
--- /dev/null
+++ b/src/settings.js
@@ -0,0 +1,22 @@
+module.exports = {
+
+ title: 'Vue Admin Template',
+
+ /**
+ * @type {boolean} true | false
+ * @description Whether fix the header
+ */
+ fixedHeader: false,
+
+ /**
+ * @type {boolean} true | false
+ * @description Whether show tags view
+ */
+ tagsView: true,
+
+ /**
+ * @type {boolean} true | false
+ * @description Whether show the logo in sidebar
+ */
+ sidebarLogo: false
+}
diff --git a/src/store/getters.js b/src/store/getters.js
new file mode 100644
index 0000000..f69530c
--- /dev/null
+++ b/src/store/getters.js
@@ -0,0 +1,10 @@
+const getters = {
+ sidebar: state => state.app.sidebar,
+ device: state => state.app.device,
+ token: state => state.user.token,
+ avatar: state => state.user.avatar,
+ name: state => state.user.name,
+ visitedViews: state => state.tagsView.visitedViews,
+ cachedViews: state => state.tagsView.cachedViews
+}
+export default getters
diff --git a/src/store/index.js b/src/store/index.js
new file mode 100644
index 0000000..7a9d994
--- /dev/null
+++ b/src/store/index.js
@@ -0,0 +1,21 @@
+import Vue from 'vue'
+import Vuex from 'vuex'
+import getters from './getters'
+import app from './modules/app'
+import settings from './modules/settings'
+import tagsView from './modules/tagsView'
+import user from './modules/user'
+
+Vue.use(Vuex)
+
+const store = new Vuex.Store({
+ modules: {
+ app,
+ settings,
+ tagsView,
+ user
+ },
+ getters
+})
+
+export default store
diff --git a/src/store/modules/app.js b/src/store/modules/app.js
new file mode 100644
index 0000000..7ea7e33
--- /dev/null
+++ b/src/store/modules/app.js
@@ -0,0 +1,48 @@
+import Cookies from 'js-cookie'
+
+const state = {
+ sidebar: {
+ opened: Cookies.get('sidebarStatus') ? !!+Cookies.get('sidebarStatus') : true,
+ withoutAnimation: false
+ },
+ device: 'desktop'
+}
+
+const mutations = {
+ TOGGLE_SIDEBAR: state => {
+ state.sidebar.opened = !state.sidebar.opened
+ state.sidebar.withoutAnimation = false
+ if (state.sidebar.opened) {
+ Cookies.set('sidebarStatus', 1)
+ } else {
+ Cookies.set('sidebarStatus', 0)
+ }
+ },
+ CLOSE_SIDEBAR: (state, withoutAnimation) => {
+ Cookies.set('sidebarStatus', 0)
+ state.sidebar.opened = false
+ state.sidebar.withoutAnimation = withoutAnimation
+ },
+ TOGGLE_DEVICE: (state, device) => {
+ state.device = device
+ }
+}
+
+const actions = {
+ toggleSideBar({ commit }) {
+ commit('TOGGLE_SIDEBAR')
+ },
+ closeSideBar({ commit }, { withoutAnimation }) {
+ commit('CLOSE_SIDEBAR', withoutAnimation)
+ },
+ toggleDevice({ commit }, device) {
+ commit('TOGGLE_DEVICE', device)
+ }
+}
+
+export default {
+ namespaced: true,
+ state,
+ mutations,
+ actions
+}
diff --git a/src/store/modules/settings.js b/src/store/modules/settings.js
new file mode 100644
index 0000000..4776f4b
--- /dev/null
+++ b/src/store/modules/settings.js
@@ -0,0 +1,33 @@
+import defaultSettings from '@/settings'
+
+const { showSettings, fixedHeader, sidebarLogo, tagsView } = defaultSettings
+
+const state = {
+ showSettings: showSettings,
+ fixedHeader: fixedHeader,
+ tagsView: tagsView,
+ sidebarLogo: sidebarLogo
+}
+
+const mutations = {
+ CHANGE_SETTING: (state, { key, value }) => {
+ // eslint-disable-next-line no-prototype-builtins
+ if (state.hasOwnProperty(key)) {
+ state[key] = value
+ }
+ }
+}
+
+const actions = {
+ changeSetting({ commit }, data) {
+ commit('CHANGE_SETTING', data)
+ }
+}
+
+export default {
+ namespaced: true,
+ state,
+ mutations,
+ actions
+}
+
diff --git a/src/store/modules/tagsView.js b/src/store/modules/tagsView.js
new file mode 100644
index 0000000..ba223dc
--- /dev/null
+++ b/src/store/modules/tagsView.js
@@ -0,0 +1,149 @@
+const state = {
+ visitedViews: [],
+ cachedViews: []
+}
+
+const mutations = {
+ ADD_VISITED_VIEW: (state, view) => {
+ if (state.visitedViews.some(v => v.path === view.path)) return
+ state.visitedViews.push(
+ Object.assign({}, view, {
+ title: view.meta && view.meta.title ? view.meta.title : 'no-name'
+ })
+ )
+ },
+ ADD_CACHED_VIEW: (state, view) => {
+ if (!view.name || state.cachedViews.includes(view.name)) return
+ if (!(view.meta && view.meta.noCache)) {
+ state.cachedViews.push(view.name)
+ }
+ },
+ DEL_VISITED_VIEW: (state, view) => {
+ for (const [index, item] of state.visitedViews.entries()) {
+ if (item.path === view.path) {
+ state.visitedViews.splice(index, 1)
+ break
+ }
+ }
+ },
+ DEL_CACHED_VIEW: (state, view) => {
+ const index = state.cachedViews.indexOf(view.name)
+ if (index > -1) {
+ state.cachedViews.splice(index, 1)
+ }
+ },
+ DEL_OTHERS_VISITED_VIEWS: (state, view) => {
+ state.visitedViews = state.visitedViews.filter(item => item.meta.affix || item.path === view.path)
+ },
+ DEL_OTHERS_CACHED_VIEWS: (state, view) => {
+ const index = state.cachedViews.indexOf(view.name)
+ if (index > -1) {
+ state.cachedViews = state.cachedViews.slice(index, index + 1)
+ } else {
+ state.cachedViews = []
+ }
+ },
+ DEL_ALL_VISITED_VIEWS: state => {
+ state.visitedViews = state.visitedViews.filter(tag => tag.meta.affix)
+ },
+ DEL_ALL_CACHED_VIEWS: state => {
+ state.cachedViews = []
+ },
+ UPDATE_VISITED_VIEW: (state, view) => {
+ for (const item of state.visitedViews) {
+ if (item.path === view.path) {
+ Object.assign(item, view)
+ break
+ }
+ }
+ }
+}
+
+const actions = {
+ addView({ dispatch }, view) {
+ dispatch('addVisitedView', view)
+ dispatch('addCachedView', view)
+ },
+ addVisitedView({ commit }, view) {
+ commit('ADD_VISITED_VIEW', view)
+ },
+ addCachedView({ commit }, view) {
+ commit('ADD_CACHED_VIEW', view)
+ },
+ delView({ dispatch, state }, view) {
+ return new Promise(resolve => {
+ dispatch('delVisitedView', view)
+ dispatch('delCachedView', view)
+ resolve({
+ visitedViews: [...state.visitedViews],
+ cachedViews: [...state.cachedViews]
+ })
+ })
+ },
+ delVisitedView({ commit, state }, view) {
+ return new Promise(resolve => {
+ commit('DEL_VISITED_VIEW', view)
+ resolve([...state.visitedViews])
+ })
+ },
+ delCachedView({ commit, state }, view) {
+ return new Promise(resolve => {
+ commit('DEL_CACHED_VIEW', view)
+ resolve([...state.cachedViews])
+ })
+ },
+ delOthersViews({ dispatch, state }, view) {
+ return new Promise(resolve => {
+ dispatch('delOthersVisitedViews', view)
+ dispatch('delOthersCachedViews', view)
+ resolve({
+ visitedViews: [...state.visitedViews],
+ cachedViews: [...state.cachedViews]
+ })
+ })
+ },
+ delOthersVisitedViews({ commit, state }, view) {
+ return new Promise(resolve => {
+ commit('DEL_OTHERS_VISITED_VIEWS', view)
+ resolve([...state.visitedViews])
+ })
+ },
+ delOthersCachedViews({ commit, state }, view) {
+ return new Promise(resolve => {
+ commit('DEL_OTHERS_CACHED_VIEWS', view)
+ resolve([...state.cachedViews])
+ })
+ },
+ delAllViews({ dispatch, state }) {
+ return new Promise(resolve => {
+ dispatch('delAllVisitedViews')
+ dispatch('delAllCachedViews')
+ resolve({
+ visitedViews: [...state.visitedViews],
+ cachedViews: [...state.cachedViews]
+ })
+ })
+ },
+ delAllVisitedViews({ commit, state }) {
+ return new Promise(resolve => {
+ commit('DEL_ALL_VISITED_VIEWS')
+ resolve([...state.visitedViews])
+ })
+ },
+ delAllCachedViews({ commit, state }) {
+ return new Promise(resolve => {
+ commit('DEL_ALL_CACHED_VIEWS')
+ resolve([...state.cachedViews])
+ })
+ },
+ updateVisitedView({ commit }, view) {
+ commit('UPDATE_VISITED_VIEW', view)
+ }
+}
+
+export default {
+ namespaced: true,
+ state,
+ mutations,
+ actions
+}
diff --git a/src/store/modules/user.js b/src/store/modules/user.js
new file mode 100644
index 0000000..2f6423f
--- /dev/null
+++ b/src/store/modules/user.js
@@ -0,0 +1,97 @@
+import { login, logout, getInfo } from '@/api/user'
+import { getToken, setToken, removeToken } from '@/utils/auth'
+import { resetRouter } from '@/router'
+
+const getDefaultState = () => {
+ return {
+ token: getToken(),
+ name: '',
+ avatar: ''
+ }
+}
+
+const state = getDefaultState()
+
+const mutations = {
+ RESET_STATE: (state) => {
+ Object.assign(state, getDefaultState())
+ },
+ SET_TOKEN: (state, token) => {
+ state.token = token
+ },
+ SET_NAME: (state, name) => {
+ state.name = name
+ },
+ SET_AVATAR: (state, avatar) => {
+ state.avatar = avatar
+ }
+}
+
+const actions = {
+ // user login
+ login({ commit }, userInfo) {
+ const { username, password } = userInfo
+ return new Promise((resolve, reject) => {
+ login({ username: username.trim(), password: password }).then(response => {
+ const { data } = response
+ commit('SET_TOKEN', data.token)
+ setToken(data.token)
+ resolve()
+ }).catch(error => {
+ reject(error)
+ })
+ })
+ },
+
+ // get user info
+ getInfo({ commit, state }) {
+ return new Promise((resolve, reject) => {
+ getInfo(state.token).then(response => {
+ const { data } = response
+
+ if (!data) {
+ return reject('Verification failed, please Login again.')
+ }
+
+ const { name, avatar } = data
+
+ commit('SET_NAME', name)
+ commit('SET_AVATAR', avatar)
+ resolve(data)
+ }).catch(error => {
+ reject(error)
+ })
+ })
+ },
+
+ // user logout
+ logout({ commit, state }) {
+ return new Promise((resolve, reject) => {
+ logout(state.token).then(() => {
+ removeToken() // must remove token first
+ resetRouter()
+ commit('RESET_STATE')
+ resolve()
+ }).catch(error => {
+ reject(error)
+ })
+ })
+ },
+
+ // remove token
+ resetToken({ commit }) {
+ return new Promise(resolve => {
+ removeToken() // must remove token first
+ commit('RESET_STATE')
+ resolve()
+ })
+ }
+}
+
+export default {
+ namespaced: true,
+ state,
+ mutations,
+ actions
+}
+
diff --git a/src/styles/element-ui.scss b/src/styles/element-ui.scss
new file mode 100644
index 0000000..0062411
--- /dev/null
+++ b/src/styles/element-ui.scss
@@ -0,0 +1,49 @@
+// cover some element-ui styles
+
+.el-breadcrumb__inner,
+.el-breadcrumb__inner a {
+ font-weight: 400 !important;
+}
+
+.el-upload {
+ input[type="file"] {
+ display: none !important;
+ }
+}
+
+.el-upload__input {
+ display: none;
+}
+
+
+// to fixed https://github.com/ElemeFE/element/issues/2461
+.el-dialog {
+ transform: none;
+ left: 0;
+ position: relative;
+ margin: 0 auto;
+}
+
+// refine element ui upload
+.upload-container {
+ .el-upload {
+ width: 100%;
+
+ .el-upload-dragger {
+ width: 100%;
+ height: 200px;
+ }
+ }
+}
+
+// dropdown
+.el-dropdown-menu {
+ a {
+ display: block
+ }
+}
+
+// to fix el-date-picker css style
+.el-range-separator {
+ box-sizing: content-box;
+}
diff --git a/src/styles/index.scss b/src/styles/index.scss
new file mode 100644
index 0000000..6a10673
--- /dev/null
+++ b/src/styles/index.scss
@@ -0,0 +1,66 @@
+@import './variables.scss';
+@import './mixin.scss';
+@import './transition.scss';
+@import './element-ui.scss';
+@import './sidebar.scss';
+@import './prototype.scss';
+
+body {
+ height: 100%;
+ -moz-osx-font-smoothing: grayscale;
+ -webkit-font-smoothing: antialiased;
+ text-rendering: optimizeLegibility;
+ font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif;
+}
+
+label {
+ font-weight: 700;
+}
+
+html {
+ height: 100%;
+ box-sizing: border-box;
+}
+
+#app {
+ height: 100%;
+}
+
+*,
+*:before,
+*:after {
+ box-sizing: inherit;
+}
+
+a:focus,
+a:active {
+ outline: none;
+}
+
+a,
+a:focus,
+a:hover {
+ cursor: pointer;
+ color: inherit;
+ text-decoration: none;
+}
+
+div:focus {
+ outline: none;
+}
+
+.clearfix {
+ &:after {
+ visibility: hidden;
+ display: block;
+ font-size: 0;
+ content: " ";
+ clear: both;
+ height: 0;
+ }
+}
+
+// main-container global css
+.app-container {
+ padding: 20px;
+}
diff --git a/src/styles/mixin.scss b/src/styles/mixin.scss
new file mode 100644
index 0000000..36b74bb
--- /dev/null
+++ b/src/styles/mixin.scss
@@ -0,0 +1,28 @@
+@mixin clearfix {
+ &:after {
+ content: "";
+ display: table;
+ clear: both;
+ }
+}
+
+@mixin scrollBar {
+ &::-webkit-scrollbar-track-piece {
+ background: #d3dce6;
+ }
+
+ &::-webkit-scrollbar {
+ width: 6px;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: #99a9bf;
+ border-radius: 20px;
+ }
+}
+
+@mixin relative {
+ position: relative;
+ width: 100%;
+ height: 100%;
+}
diff --git a/src/styles/prototype.scss b/src/styles/prototype.scss
new file mode 100644
index 0000000..cc0feea
--- /dev/null
+++ b/src/styles/prototype.scss
@@ -0,0 +1,315 @@
+.prototype-page {
+ min-height: calc(100vh - 90px);
+ padding: 24px;
+ background: #f5f7fb;
+}
+
+.prototype-grid {
+ display: grid;
+ gap: 16px;
+}
+
+.prototype-grid.metrics {
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+}
+
+.prototype-grid.two-columns {
+ grid-template-columns: minmax(0, 2fr) minmax(320px, 1fr);
+}
+
+.prototype-grid.three-columns {
+ grid-template-columns: minmax(280px, 1.2fr) minmax(320px, 1.5fr) minmax(300px, 1fr);
+}
+
+.proto-page-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 24px;
+
+ .eyebrow {
+ margin-bottom: 8px;
+ color: #8b95a7;
+ font-size: 12px;
+ font-weight: 600;
+ letter-spacing: 1px;
+ text-transform: uppercase;
+ }
+
+ .title-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ }
+
+ h2 {
+ margin: 0;
+ color: #1f2d3d;
+ font-size: 28px;
+ font-weight: 700;
+ }
+
+ p {
+ max-width: 760px;
+ margin: 10px 0 0;
+ color: #6b778c;
+ font-size: 14px;
+ line-height: 1.7;
+ }
+
+ .actions {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+ }
+}
+
+.proto-metric-card,
+.proto-section-card {
+ border: 1px solid #e8ecf3;
+ border-radius: 20px;
+ background: #fff;
+ box-shadow: 0 10px 30px rgba(31, 45, 61, 0.06);
+}
+
+.proto-metric-card {
+ padding: 22px;
+
+ .metric-label {
+ color: #909bb0;
+ font-size: 13px;
+ font-weight: 600;
+ }
+
+ .metric-value {
+ margin: 14px 0 16px;
+ color: #1f2d3d;
+ font-size: 34px;
+ font-weight: 700;
+ line-height: 1;
+ }
+
+ .metric-footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ }
+
+ .metric-trend {
+ display: inline-flex;
+ align-items: center;
+ padding: 4px 10px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 600;
+ }
+
+ .metric-trend.up {
+ color: #1f9d55;
+ background: #e9f8ef;
+ }
+
+ .metric-trend.down {
+ color: #f56c6c;
+ background: #feeeee;
+ }
+
+ .metric-trend.neutral {
+ color: #409eff;
+ background: #ecf5ff;
+ }
+
+ .metric-hint {
+ color: #a0a9b8;
+ font-size: 12px;
+ }
+}
+
+.proto-section-card {
+ padding: 22px 24px;
+
+ .section-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 18px;
+ }
+
+ .section-title {
+ color: #1f2d3d;
+ font-size: 17px;
+ font-weight: 700;
+ }
+
+ .section-subtitle {
+ margin-top: 6px;
+ color: #8b95a7;
+ font-size: 13px;
+ line-height: 1.6;
+ }
+}
+
+.proto-kpi-strip {
+ display: grid;
+ grid-template-columns: repeat(5, minmax(0, 1fr));
+ gap: 12px;
+}
+
+.proto-kpi {
+ padding: 14px 16px;
+ border-radius: 16px;
+ background: #f7f9fc;
+
+ .label {
+ color: #8b95a7;
+ font-size: 12px;
+ text-transform: uppercase;
+ }
+
+ .value {
+ margin-top: 10px;
+ color: #1f2d3d;
+ font-size: 22px;
+ font-weight: 700;
+ }
+}
+
+.accent-blue { border-left: 4px solid #409eff; }
+.accent-green { border-left: 4px solid #67c23a; }
+.accent-orange { border-left: 4px solid #e6a23c; }
+.accent-red { border-left: 4px solid #f56c6c; }
+.accent-purple { border-left: 4px solid #9b59b6; }
+
+.proto-list {
+ display: grid;
+ gap: 12px;
+}
+
+.proto-list-item {
+ padding: 16px 18px;
+ border: 1px solid #edf1f7;
+ border-radius: 16px;
+ background: #fbfcfe;
+}
+
+.proto-toolbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 16px;
+ flex-wrap: wrap;
+}
+
+.proto-filter-group {
+ display: flex;
+ gap: 12px;
+ flex-wrap: wrap;
+}
+
+.proto-field-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 14px 16px;
+}
+
+.proto-field {
+ padding: 14px 16px;
+ border-radius: 14px;
+ background: #f7f9fc;
+
+ .label {
+ color: #8b95a7;
+ font-size: 12px;
+ }
+
+ .value {
+ margin-top: 8px;
+ color: #243243;
+ font-size: 14px;
+ font-weight: 600;
+ word-break: break-all;
+ }
+}
+
+.proto-timeline {
+ display: grid;
+ gap: 12px;
+}
+
+.proto-timeline-item {
+ display: flex;
+ gap: 12px;
+ align-items: flex-start;
+
+ .dot {
+ width: 10px;
+ height: 10px;
+ margin-top: 7px;
+ border-radius: 50%;
+ background: #d3dce6;
+ }
+
+ .dot.success { background: #67c23a; }
+ .dot.warning { background: #e6a23c; }
+ .dot.primary { background: #409eff; }
+
+ .title {
+ color: #243243;
+ font-size: 14px;
+ font-weight: 600;
+ }
+
+ .desc,
+ .time {
+ color: #8b95a7;
+ font-size: 12px;
+ line-height: 1.6;
+ }
+}
+
+.proto-highlight {
+ padding: 18px;
+ border-radius: 18px;
+ background: linear-gradient(135deg, #eef5ff 0%, #f9fbff 100%);
+}
+
+.proto-status-row {
+ display: flex;
+ gap: 10px;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.proto-rule-note {
+ margin-top: 8px;
+ color: #8b95a7;
+ font-size: 12px;
+ line-height: 1.6;
+}
+
+@media (max-width: 1200px) {
+ .prototype-grid.two-columns,
+ .prototype-grid.three-columns,
+ .proto-kpi-strip {
+ grid-template-columns: 1fr;
+ }
+}
+
+@media (max-width: 768px) {
+ .prototype-page {
+ padding: 16px;
+ }
+
+ .proto-page-header {
+ flex-direction: column;
+ }
+
+ .proto-field-grid {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/src/styles/sidebar.scss b/src/styles/sidebar.scss
new file mode 100644
index 0000000..94760cc
--- /dev/null
+++ b/src/styles/sidebar.scss
@@ -0,0 +1,226 @@
+#app {
+
+ .main-container {
+ min-height: 100%;
+ transition: margin-left .28s;
+ margin-left: $sideBarWidth;
+ position: relative;
+ }
+
+ .sidebar-container {
+ transition: width 0.28s;
+ width: $sideBarWidth !important;
+ background-color: $menuBg;
+ height: 100%;
+ position: fixed;
+ font-size: 0px;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 1001;
+ overflow: hidden;
+
+ // reset element-ui css
+ .horizontal-collapse-transition {
+ transition: 0s width ease-in-out, 0s padding-left ease-in-out, 0s padding-right ease-in-out;
+ }
+
+ .scrollbar-wrapper {
+ overflow-x: hidden !important;
+ }
+
+ .el-scrollbar__bar.is-vertical {
+ right: 0px;
+ }
+
+ .el-scrollbar {
+ height: 100%;
+ }
+
+ &.has-logo {
+ .el-scrollbar {
+ height: calc(100% - 50px);
+ }
+ }
+
+ .is-horizontal {
+ display: none;
+ }
+
+ a {
+ display: inline-block;
+ width: 100%;
+ overflow: hidden;
+ }
+
+ .svg-icon {
+ margin-right: 16px;
+ }
+
+ .sub-el-icon {
+ margin-right: 12px;
+ margin-left: -2px;
+ }
+
+ .el-menu {
+ border: none;
+ height: 100%;
+ width: 100% !important;
+ }
+
+ // menu hover
+ .submenu-title-noDropdown,
+ .el-submenu__title {
+ &:hover {
+ background-color: $menuHover !important;
+ }
+ }
+
+ .is-active>.el-submenu__title {
+ color: $subMenuActiveText !important;
+ }
+
+ & .nest-menu .el-submenu>.el-submenu__title,
+ & .el-submenu .el-menu-item {
+ min-width: $sideBarWidth !important;
+ background-color: $subMenuBg !important;
+
+ &:hover {
+ background-color: $subMenuHover !important;
+ }
+ }
+ }
+
+ .hideSidebar {
+ .sidebar-container {
+ width: 54px !important;
+ }
+
+ .main-container {
+ margin-left: 54px;
+ }
+
+ .submenu-title-noDropdown {
+ padding: 0 !important;
+ position: relative;
+
+ .el-tooltip {
+ padding: 0 !important;
+
+ .svg-icon {
+ margin-left: 20px;
+ }
+
+ .sub-el-icon {
+ margin-left: 19px;
+ }
+ }
+ }
+
+ .el-submenu {
+ overflow: hidden;
+
+ &>.el-submenu__title {
+ padding: 0 !important;
+
+ .svg-icon {
+ margin-left: 20px;
+ }
+
+ .sub-el-icon {
+ margin-left: 19px;
+ }
+
+ .el-submenu__icon-arrow {
+ display: none;
+ }
+ }
+ }
+
+ .el-menu--collapse {
+ .el-submenu {
+ &>.el-submenu__title {
+ &>span {
+ height: 0;
+ width: 0;
+ overflow: hidden;
+ visibility: hidden;
+ display: inline-block;
+ }
+ }
+ }
+ }
+ }
+
+ .el-menu--collapse .el-menu .el-submenu {
+ min-width: $sideBarWidth !important;
+ }
+
+ // mobile responsive
+ .mobile {
+ .main-container {
+ margin-left: 0px;
+ }
+
+ .sidebar-container {
+ transition: transform .28s;
+ width: $sideBarWidth !important;
+ }
+
+ &.hideSidebar {
+ .sidebar-container {
+ pointer-events: none;
+ transition-duration: 0.3s;
+ transform: translate3d(-$sideBarWidth, 0, 0);
+ }
+ }
+ }
+
+ .withoutAnimation {
+
+ .main-container,
+ .sidebar-container {
+ transition: none;
+ }
+ }
+}
+
+// when menu collapsed
+.el-menu--vertical {
+ &>.el-menu {
+ .svg-icon {
+ margin-right: 16px;
+ }
+ .sub-el-icon {
+ margin-right: 12px;
+ margin-left: -2px;
+ }
+ }
+
+ .nest-menu .el-submenu>.el-submenu__title,
+ .el-menu-item {
+ &:hover {
+ // you can use $subMenuHover
+ background-color: $menuHover !important;
+ }
+ }
+
+ // the scroll bar appears when the subMenu is too long
+ >.el-menu--popup {
+ max-height: 100vh;
+ overflow-y: auto;
+
+ &::-webkit-scrollbar-track-piece {
+ background: #d3dce6;
+ }
+
+ &::-webkit-scrollbar {
+ width: 6px;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: #99a9bf;
+ border-radius: 20px;
+ }
+ }
+}
diff --git a/src/styles/transition.scss b/src/styles/transition.scss
new file mode 100644
index 0000000..4cb27cc
--- /dev/null
+++ b/src/styles/transition.scss
@@ -0,0 +1,48 @@
+// global transition css
+
+/* fade */
+.fade-enter-active,
+.fade-leave-active {
+ transition: opacity 0.28s;
+}
+
+.fade-enter,
+.fade-leave-active {
+ opacity: 0;
+}
+
+/* fade-transform */
+.fade-transform-leave-active,
+.fade-transform-enter-active {
+ transition: all .5s;
+}
+
+.fade-transform-enter {
+ opacity: 0;
+ transform: translateX(-30px);
+}
+
+.fade-transform-leave-to {
+ opacity: 0;
+ transform: translateX(30px);
+}
+
+/* breadcrumb transition */
+.breadcrumb-enter-active,
+.breadcrumb-leave-active {
+ transition: all .5s;
+}
+
+.breadcrumb-enter,
+.breadcrumb-leave-active {
+ opacity: 0;
+ transform: translateX(20px);
+}
+
+.breadcrumb-move {
+ transition: all .5s;
+}
+
+.breadcrumb-leave-active {
+ position: absolute;
+}
diff --git a/src/styles/variables.scss b/src/styles/variables.scss
new file mode 100644
index 0000000..be55772
--- /dev/null
+++ b/src/styles/variables.scss
@@ -0,0 +1,25 @@
+// sidebar
+$menuText:#bfcbd9;
+$menuActiveText:#409EFF;
+$subMenuActiveText:#f4f4f5; //https://github.com/ElemeFE/element/issues/12951
+
+$menuBg:#304156;
+$menuHover:#263445;
+
+$subMenuBg:#1f2d3d;
+$subMenuHover:#001528;
+
+$sideBarWidth: 210px;
+
+// the :export directive is the magic sauce for webpack
+// https://www.bluematador.com/blog/how-to-share-variables-between-js-and-sass
+:export {
+ menuText: $menuText;
+ menuActiveText: $menuActiveText;
+ subMenuActiveText: $subMenuActiveText;
+ menuBg: $menuBg;
+ menuHover: $menuHover;
+ subMenuBg: $subMenuBg;
+ subMenuHover: $subMenuHover;
+ sideBarWidth: $sideBarWidth;
+}
diff --git a/src/utils/auth.js b/src/utils/auth.js
new file mode 100644
index 0000000..059af18
--- /dev/null
+++ b/src/utils/auth.js
@@ -0,0 +1,15 @@
+import Cookies from 'js-cookie'
+
+const TokenKey = 'vue_admin_template_token'
+
+export function getToken() {
+ return Cookies.get(TokenKey)
+}
+
+export function setToken(token) {
+ return Cookies.set(TokenKey, token)
+}
+
+export function removeToken() {
+ return Cookies.remove(TokenKey)
+}
diff --git a/src/utils/get-page-title.js b/src/utils/get-page-title.js
new file mode 100644
index 0000000..a6de99d
--- /dev/null
+++ b/src/utils/get-page-title.js
@@ -0,0 +1,10 @@
+import defaultSettings from '@/settings'
+
+const title = defaultSettings.title || 'Vue Admin Template'
+
+export default function getPageTitle(pageTitle) {
+ if (pageTitle) {
+ return `${pageTitle} - ${title}`
+ }
+ return `${title}`
+}
diff --git a/src/utils/index.js b/src/utils/index.js
new file mode 100644
index 0000000..4830c04
--- /dev/null
+++ b/src/utils/index.js
@@ -0,0 +1,117 @@
+/**
+ * Created by PanJiaChen on 16/11/18.
+ */
+
+/**
+ * Parse the time to string
+ * @param {(Object|string|number)} time
+ * @param {string} cFormat
+ * @returns {string | null}
+ */
+export function parseTime(time, cFormat) {
+ if (arguments.length === 0 || !time) {
+ return null
+ }
+ const format = cFormat || '{y}-{m}-{d} {h}:{i}:{s}'
+ let date
+ if (typeof time === 'object') {
+ date = time
+ } else {
+ if ((typeof time === 'string')) {
+ if ((/^[0-9]+$/.test(time))) {
+ // support "1548221490638"
+ time = parseInt(time)
+ } else {
+ // support safari
+ // https://stackoverflow.com/questions/4310953/invalid-date-in-safari
+ time = time.replace(new RegExp(/-/gm), '/')
+ }
+ }
+
+ if ((typeof time === 'number') && (time.toString().length === 10)) {
+ time = time * 1000
+ }
+ date = new Date(time)
+ }
+ const formatObj = {
+ y: date.getFullYear(),
+ m: date.getMonth() + 1,
+ d: date.getDate(),
+ h: date.getHours(),
+ i: date.getMinutes(),
+ s: date.getSeconds(),
+ a: date.getDay()
+ }
+ const time_str = format.replace(/{([ymdhisa])+}/g, (result, key) => {
+ const value = formatObj[key]
+ // Note: getDay() returns 0 on Sunday
+ if (key === 'a') { return ['日', '一', '二', '三', '四', '五', '六'][value ] }
+ return value.toString().padStart(2, '0')
+ })
+ return time_str
+}
+
+/**
+ * @param {number} time
+ * @param {string} option
+ * @returns {string}
+ */
+export function formatTime(time, option) {
+ if (('' + time).length === 10) {
+ time = parseInt(time) * 1000
+ } else {
+ time = +time
+ }
+ const d = new Date(time)
+ const now = Date.now()
+
+ const diff = (now - d) / 1000
+
+ if (diff < 30) {
+ return '刚刚'
+ } else if (diff < 3600) {
+ // less 1 hour
+ return Math.ceil(diff / 60) + '分钟前'
+ } else if (diff < 3600 * 24) {
+ return Math.ceil(diff / 3600) + '小时前'
+ } else if (diff < 3600 * 24 * 2) {
+ return '1天前'
+ }
+ if (option) {
+ return parseTime(time, option)
+ } else {
+ return (
+ d.getMonth() +
+ 1 +
+ '月' +
+ d.getDate() +
+ '日' +
+ d.getHours() +
+ '时' +
+ d.getMinutes() +
+ '分'
+ )
+ }
+}
+
+/**
+ * @param {string} url
+ * @returns {Object}
+ */
+export function param2Obj(url) {
+ const search = decodeURIComponent(url.split('?')[1]).replace(/\+/g, ' ')
+ if (!search) {
+ return {}
+ }
+ const obj = {}
+ const searchArr = search.split('&')
+ searchArr.forEach(v => {
+ const index = v.indexOf('=')
+ if (index !== -1) {
+ const name = v.substring(0, index)
+ const val = v.substring(index + 1, v.length)
+ obj[name] = val
+ }
+ })
+ return obj
+}
diff --git a/src/utils/request.js b/src/utils/request.js
new file mode 100644
index 0000000..2fb95ac
--- /dev/null
+++ b/src/utils/request.js
@@ -0,0 +1,85 @@
+import axios from 'axios'
+import { MessageBox, Message } from 'element-ui'
+import store from '@/store'
+import { getToken } from '@/utils/auth'
+
+// create an axios instance
+const service = axios.create({
+ baseURL: process.env.VUE_APP_BASE_API, // url = base url + request url
+ // withCredentials: true, // send cookies when cross-domain requests
+ timeout: 5000 // request timeout
+})
+
+// request interceptor
+service.interceptors.request.use(
+ config => {
+ // do something before request is sent
+
+ if (store.getters.token) {
+ // let each request carry token
+ // ['X-Token'] is a custom headers key
+ // please modify it according to the actual situation
+ config.headers['X-Token'] = getToken()
+ }
+ return config
+ },
+ error => {
+ // do something with request error
+ console.log(error) // for debug
+ return Promise.reject(error)
+ }
+)
+
+// response interceptor
+service.interceptors.response.use(
+ /**
+ * If you want to get http information such as headers or status
+ * Please return response => response
+ */
+
+ /**
+ * Determine the request status by custom code
+ * Here is just an example
+ * You can also judge the status by HTTP Status Code
+ */
+ response => {
+ const res = response.data
+
+ // if the custom code is not 20000, it is judged as an error.
+ if (res.code !== 20000) {
+ Message({
+ message: res.message || 'Error',
+ type: 'error',
+ duration: 5 * 1000
+ })
+
+ // 50008: Illegal token; 50012: Other clients logged in; 50014: Token expired;
+ if (res.code === 50008 || res.code === 50012 || res.code === 50014) {
+ // to re-login
+ MessageBox.confirm('You have been logged out, you can cancel to stay on this page, or log in again', 'Confirm logout', {
+ confirmButtonText: 'Re-Login',
+ cancelButtonText: 'Cancel',
+ type: 'warning'
+ }).then(() => {
+ store.dispatch('user/resetToken').then(() => {
+ location.reload()
+ })
+ })
+ }
+ return Promise.reject(new Error(res.message || 'Error'))
+ } else {
+ return res
+ }
+ },
+ error => {
+ console.log('err' + error) // for debug
+ Message({
+ message: error.message,
+ type: 'error',
+ duration: 5 * 1000
+ })
+ return Promise.reject(error)
+ }
+)
+
+export default service
diff --git a/src/utils/validate.js b/src/utils/validate.js
new file mode 100644
index 0000000..8d962ad
--- /dev/null
+++ b/src/utils/validate.js
@@ -0,0 +1,20 @@
+/**
+ * Created by PanJiaChen on 16/11/18.
+ */
+
+/**
+ * @param {string} path
+ * @returns {Boolean}
+ */
+export function isExternal(path) {
+ return /^(https?:|mailto:|tel:)/.test(path)
+}
+
+/**
+ * @param {string} str
+ * @returns {Boolean}
+ */
+export function validUsername(str) {
+ const valid_map = ['admin', 'editor']
+ return valid_map.indexOf(str.trim()) >= 0
+}
diff --git a/src/views/404.vue b/src/views/404.vue
new file mode 100644
index 0000000..1791f55
--- /dev/null
+++ b/src/views/404.vue
@@ -0,0 +1,228 @@
+
+
+
+
+
+
OOPS!
+
+
{{ message }}
+
Please check that the URL you entered is correct, or click the button below to return to the homepage.
+
Back to home
+
+
+
+
+
+
+
+
diff --git a/src/views/dashboard/index.vue b/src/views/dashboard/index.vue
new file mode 100644
index 0000000..82b2af3
--- /dev/null
+++ b/src/views/dashboard/index.vue
@@ -0,0 +1,273 @@
+
+
+
+
+
+
+ 导出状态快照
+
+
+ 进入发现接入
+
+
+
+
+
+
+
+
+
+
{{ item.label }}
+
{{ item.value }}
+
+
+
+
+
+
+
+
+
+ {{ serviceStatus.running ? 'CoreService 已运行' : 'CoreService 异常' }}
+
+ Version {{ serviceStatus.version }}
+ PID {{ serviceStatus.pid }}
+
+ {{ serviceStatus.discoveryEnabled ? 'Discovery 开启' : 'Discovery 关闭' }}
+
+
+
+ 当前控制面显示规则计数、mapping 状态与 discovery 进度,可作为首页首屏的操作引导区。
+
+
+
+
+
+
健康检查
+
{{ serviceStatus.health ? '/health 返回 ok' : '/health 返回异常' }},最近状态时间 {{ serviceStatus.timestamp }}
+
+
+
运行角色
+
role = {{ serviceStatus.role }},running = {{ serviceStatus.running }},timestamp 已同步
+
+
+
队列容量
+
session = {{ pipelineStatus.framerSessionCount }},queue = {{ serviceStatus.queueUsage }},插件摘要已加载
+
+
+
+
+
+
+
+
+
+
+ {{ item.label }}
+ {{ item.value }}
+
+
{{ item.note }}
+
+
+
+
+
+
+
+
+
+ {{ item.title }}
+
+
{{ item.detail }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/form/index.vue b/src/views/form/index.vue
new file mode 100644
index 0000000..047e9bd
--- /dev/null
+++ b/src/views/form/index.vue
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
diff --git a/src/views/login/index.vue b/src/views/login/index.vue
new file mode 100644
index 0000000..1db2464
--- /dev/null
+++ b/src/views/login/index.vue
@@ -0,0 +1,237 @@
+
+
+
+
+
+
Login Form
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Login
+
+
+ username: admin
+ password: any
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/prototype/components/ProtoMetricCard.vue b/src/views/prototype/components/ProtoMetricCard.vue
new file mode 100644
index 0000000..e1ae958
--- /dev/null
+++ b/src/views/prototype/components/ProtoMetricCard.vue
@@ -0,0 +1,38 @@
+
+
+
{{ label }}
+
{{ value }}
+
+
+
+
+
diff --git a/src/views/prototype/components/ProtoPageHeader.vue b/src/views/prototype/components/ProtoPageHeader.vue
new file mode 100644
index 0000000..97e8df6
--- /dev/null
+++ b/src/views/prototype/components/ProtoPageHeader.vue
@@ -0,0 +1,44 @@
+
+
+
+
+
diff --git a/src/views/prototype/components/ProtoSectionCard.vue b/src/views/prototype/components/ProtoSectionCard.vue
new file mode 100644
index 0000000..7118fac
--- /dev/null
+++ b/src/views/prototype/components/ProtoSectionCard.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
{{ title }}
+
{{ subtitle }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/prototype/components/RuleWorkspace.vue b/src/views/prototype/components/RuleWorkspace.vue
new file mode 100644
index 0000000..1b5673c
--- /dev/null
+++ b/src/views/prototype/components/RuleWorkspace.vue
@@ -0,0 +1,300 @@
+
+
+
+
+ {{ secondaryAction }}
+ {{ primaryAction }}
+
+
+
+
+
+
+
{{ item.label }}
+
{{ item.value }}
+
+
+
+
+
+
+
+
+
+
+ {{ scope.row.status }}
+
+
+
+
+
+ {{ detailAction(scope.row) }}
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ secondaryAction }}
+ {{ primaryAction }}
+
+
+
+
+
+
+
筛选区
+
统一承载 ID、endpointHash、stableKey、domPath 等关键字段筛选。
+
+
+
表格区
+
突出 scope、换算参数、单位与状态,便于快速定位规则差异。
+
+
+
抽屉表单
+
适合承载创建、编辑和参数说明,避免频繁跳页破坏工作流连续性。
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/prototype/devices/index.vue b/src/views/prototype/devices/index.vue
new file mode 100644
index 0000000..b850fe1
--- /dev/null
+++ b/src/views/prototype/devices/index.vue
@@ -0,0 +1,242 @@
+
+
+
+
+ 刷新设备树
+ 编辑当前节点
+
+
+
+
+
+
+
+ {{ data.name }}
+
+ {{ data.type }}
+
+
+
+
+
+
+
+
+
{{ item.label }}
+
{{ item.value }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 保存
+
+
+
+
+
+
+
+
diff --git a/src/views/prototype/discovery/index.vue b/src/views/prototype/discovery/index.vue
new file mode 100644
index 0000000..96c9f9d
--- /dev/null
+++ b/src/views/prototype/discovery/index.vue
@@ -0,0 +1,448 @@
+
+
+
+
+ 清空发现池
+ 停止 Discovery
+ 启动 Discovery
+
+
+
+
+
+
+
+
+
+
+
+ {{ sample.compositeKey }}
+ {{ sample.protocol }}
+
+
{{ sample.endpointLabel }}
+
endpointHash: {{ sample.endpointHash }} · stableKey: {{ sample.stableKey }}
+
{{ sample.valuePreview }}
+
{{ sample.updatedAt }}
+
+
+
+
+
+
+
+
sampleId
+
{{ activeSample.id }}
+
+
+
protocol
+
{{ activeSample.protocol }}
+
+
+
endpointHash
+
{{ activeSample.endpointHash }}
+
+
+
endpoint
+
{{ activeSample.endpointLabel }}
+
+
+
stableKey
+
{{ activeSample.stableKey }}
+
+
+
compositeKey
+
{{ activeSample.compositeKey }}
+
+
+
valuePreview
+
{{ activeSample.valuePreview }}
+
+
+
rawValue
+
{{ activeSample.rawValueText }}
+
+
+
slaveId / registerAddress
+
{{ activeSample.slaveIdText }} / {{ activeSample.registerAddressText }}
+
+
+
hitCount
+
{{ activeSample.hitCountText }}
+
+
+
firstSeen
+
{{ activeSample.firstSeenText }}
+
+
+
lastSeen
+
{{ activeSample.lastSeenText }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 创建过滤规则
+ 创建转换规则
+
+
+
+
+
+ 查看映射规则
+ 绑定并验证
+
+
+
+ 绑定成功后会刷新样本与映射列表。过滤规则联动因后端缺少列表与校验支撑,当前仅保留禁用说明。
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/prototype/metadata/index.vue b/src/views/prototype/metadata/index.vue
new file mode 100644
index 0000000..5f61e37
--- /dev/null
+++ b/src/views/prototype/metadata/index.vue
@@ -0,0 +1,88 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ item.label }}
+
{{ item.value }}
+
+
+
+
+
+
+
+
diff --git a/src/views/prototype/mock-data.js b/src/views/prototype/mock-data.js
new file mode 100644
index 0000000..c006cb4
--- /dev/null
+++ b/src/views/prototype/mock-data.js
@@ -0,0 +1,426 @@
+export const statusCards = [
+ {
+ label: '在线设备',
+ value: '128',
+ trend: '+12.4%',
+ trendType: 'up',
+ hint: '来自 /api/v1/devices'
+ },
+ {
+ label: '规则总数',
+ value: '42',
+ trend: '+6',
+ trendType: 'up',
+ hint: 'filter / transform / mapping'
+ },
+ {
+ label: 'Discovery 样本',
+ value: '1,284',
+ trend: '运行中',
+ trendType: 'neutral',
+ hint: '来自 /api/v1/discovery/status'
+ },
+ {
+ label: 'Pipeline 错误',
+ value: '3',
+ trend: '-2',
+ trendType: 'down',
+ hint: '过去 24 小时'
+ }
+]
+
+export const pipelineCounters = [
+ { label: 'in', value: '12.8k', accent: 'blue' },
+ { label: 'out', value: '12.1k', accent: 'green' },
+ { label: 'drop', value: '214', accent: 'orange' },
+ { label: 'error', value: '3', accent: 'red' },
+ { label: 'mergeDrop', value: '41', accent: 'purple' }
+]
+
+export const ruleSummary = [
+ { label: '过滤规则', value: 12, status: 'warning', note: '当前以注册管理为主' },
+ { label: '转换规则', value: 18, status: 'success', note: '已接入执行链路' },
+ { label: '映射规则', value: 12, status: 'primary', note: '已绑定 domPath' }
+]
+
+export const discoverySamples = [
+ {
+ id: 'sample-001',
+ endpointHash: 'ep_7A9D',
+ protocol: 'modbus',
+ compositeKey: 'boiler.temp.01',
+ stableKey: 'boiler_temp',
+ updatedAt: '2026-04-16 18:20',
+ valuePreview: '68.2 C'
+ },
+ {
+ id: 'sample-002',
+ endpointHash: 'ep_21BD',
+ protocol: 'mqtt',
+ compositeKey: 'lineA.pressure.03',
+ stableKey: 'pressure_main',
+ updatedAt: '2026-04-16 18:18',
+ valuePreview: '0.83 MPa'
+ },
+ {
+ id: 'sample-003',
+ endpointHash: 'ep_F18C',
+ protocol: 'bacnet',
+ compositeKey: 'ahu.energy.09',
+ stableKey: 'ahu_energy',
+ updatedAt: '2026-04-16 18:15',
+ valuePreview: '118.4 kWh'
+ },
+ {
+ id: 'sample-004',
+ endpointHash: 'ep_4DD3',
+ protocol: 'opcua',
+ compositeKey: 'tank.level.05',
+ stableKey: 'tank_level',
+ updatedAt: '2026-04-16 18:11',
+ valuePreview: '73.4 %'
+ }
+]
+
+export const bindingSuggestions = [
+ { domPath: 'site.boiler.temperature', unit: 'C', score: '98%' },
+ { domPath: 'site.production.pressure', unit: 'MPa', score: '93%' },
+ { domPath: 'site.energy.ahu.total', unit: 'kWh', score: '89%' }
+]
+
+export const recentActivities = [
+ {
+ title: 'discovery_start',
+ description: '已开启嗅探模式,TTL = 30 min',
+ time: '2 分钟前',
+ type: 'success'
+ },
+ {
+ title: 'mapping_bind',
+ description: 'boiler.temp.01 成功绑定到 site.boiler.temperature',
+ time: '8 分钟前',
+ type: 'primary'
+ },
+ {
+ title: 'transform_rule',
+ description: '新增温度换算规则,scale = 1.0,offset = 0',
+ time: '15 分钟前',
+ type: 'warning'
+ }
+]
+
+export const ruleFilters = {
+ filter: [
+ {
+ id: 'flt_temp_guard',
+ scope: 'stableKey: boiler_temp',
+ condition: 'value >= -40 && value <= 180',
+ status: 'registered',
+ effect: '待接入 stage3Filter',
+ updatedAt: '2026-04-16 17:10'
+ },
+ {
+ id: 'flt_pressure_range',
+ scope: 'deviceId: 3021',
+ condition: 'value > 0',
+ status: 'registered',
+ effect: '注册中',
+ updatedAt: '2026-04-16 16:48'
+ }
+ ],
+ transform: [
+ {
+ id: 'tr_temp_celsius',
+ scope: 'stableKey: boiler_temp',
+ scale: '1.0',
+ offset: '0',
+ unit: 'C',
+ status: 'active',
+ effect: 'applyTransformAndEnrich',
+ updatedAt: '2026-04-16 17:25'
+ },
+ {
+ id: 'tr_pressure_convert',
+ scope: 'endpointHash: ep_21BD',
+ scale: '0.001',
+ offset: '0',
+ unit: 'MPa',
+ status: 'active',
+ effect: '已生效',
+ updatedAt: '2026-04-16 17:02'
+ }
+ ],
+ mapping: [
+ {
+ compositeKey: 'boiler.temp.01',
+ domPath: 'site.boiler.temperature',
+ scale: '1.0',
+ offset: '0',
+ unit: 'C',
+ status: 'bound',
+ updatedAt: '2026-04-16 18:12'
+ },
+ {
+ compositeKey: 'lineA.pressure.03',
+ domPath: 'site.production.pressure',
+ scale: '0.001',
+ offset: '0',
+ unit: 'MPa',
+ status: 'bound',
+ updatedAt: '2026-04-16 17:58'
+ }
+ ]
+}
+
+export const dashboardAlerts = [
+ {
+ level: 'warning',
+ title: '过滤规则未完全接入执行链路',
+ detail: 'filter 路由当前更偏注册表管理能力,建议在界面中明确标注执行状态。'
+ },
+ {
+ level: 'success',
+ title: '转换规则运行稳定',
+ detail: 'transform 规则已在 Pipeline 中生效,可作为主配置入口优先展示。'
+ },
+ {
+ level: 'primary',
+ title: '映射能力可直接形成工作流',
+ detail: '发现样本、映射绑定与规则配置可以组合成一条高价值业务闭环。'
+ }
+]
+
+export const serviceStatus = {
+ running: true,
+ version: '2.4.1',
+ pid: 9812,
+ role: 'daemon',
+ timestamp: '2026-04-16 19:10:00',
+ discoveryEnabled: true,
+ health: true,
+ queueUsage: '68%'
+}
+
+export const pipelineStatus = {
+ running: true,
+ parseWorkerCount: 4,
+ framerSessionCount: 12,
+ parserSessionCount: 9,
+ mapperSessionCount: 7,
+ endpointFramerCount: 16,
+ trackedEndpointCount: 42,
+ hasPluginManager: true,
+ parserPlugins: ['modbus_rtu', 'mqtt_json', 'bacnet_ip'],
+ framerPlugins: ['modbus_rtu_framer', 'mqtt_line_framer'],
+ mapperPlugins: ['modbus_mapper', 'energy_mapper', 'opcua_mapper']
+}
+
+export const pipelineConfig = {
+ parallelPipeline: true,
+ orderedEgress: false,
+ parseWorkerCount: 4,
+ framedQueueMax: 2048,
+ maxReorderDepth: 128
+}
+
+export const devices = [
+ {
+ id: 3021,
+ name: '锅炉一号',
+ endpoint: '10.0.0.12:502',
+ type: 'sensor',
+ kind: 'boiler',
+ kindName: 'Boiler',
+ protocol: 'modbus',
+ status: 'online',
+ isActive: true,
+ lastSeen: '2026-04-16 19:08:10'
+ },
+ {
+ id: 3022,
+ name: '产线主压',
+ endpoint: 'mqtt://line-a/main',
+ type: 'gauge',
+ kind: 'pressure',
+ kindName: 'Pressure',
+ protocol: 'mqtt',
+ status: 'online',
+ isActive: true,
+ lastSeen: '2026-04-16 19:07:44'
+ },
+ {
+ id: 3023,
+ name: '空调主机能耗',
+ endpoint: 'bacnet://ahu/09',
+ type: 'meter',
+ kind: 'energy',
+ kindName: 'Energy',
+ protocol: 'bacnet',
+ status: 'degraded',
+ isActive: true,
+ lastSeen: '2026-04-16 18:59:21'
+ },
+ {
+ id: 3024,
+ name: '储罐液位',
+ endpoint: 'opc.tcp://tank/05',
+ type: 'level',
+ kind: 'tank',
+ kindName: 'Tank',
+ protocol: 'opcua',
+ status: 'offline',
+ isActive: false,
+ lastSeen: '2026-04-16 18:40:03'
+ }
+]
+
+export const deviceTree = [
+ {
+ id: 1,
+ name: '站点 A',
+ type: 'site',
+ endpoint: '-',
+ enabled: true,
+ driver: 'site-driver',
+ capabilitiesRef: 'site-cap',
+ params: {
+ online: true,
+ lastSeenTs: '2026-04-16 19:08:10',
+ stableKey: 'site_a',
+ serial: 'SITEA-001'
+ },
+ children: [
+ {
+ id: 11,
+ name: '锅炉系统',
+ type: 'boiler',
+ endpoint: '10.0.0.12:502',
+ enabled: true,
+ driver: 'modbus-driver',
+ capabilitiesRef: 'boiler-cap',
+ params: {
+ online: true,
+ lastSeenTs: '2026-04-16 19:08:10',
+ stableKey: 'boiler_temp',
+ serial: 'BLR-3021',
+ vid: '0x01',
+ pid: '0x21'
+ },
+ children: []
+ },
+ {
+ id: 12,
+ name: '空调系统',
+ type: 'ahu',
+ endpoint: 'bacnet://ahu/09',
+ enabled: true,
+ driver: 'bacnet-driver',
+ capabilitiesRef: 'ahu-cap',
+ params: {
+ online: true,
+ lastSeenTs: '2026-04-16 18:59:21',
+ stableKey: 'ahu_energy',
+ serial: 'AHU-009'
+ },
+ children: []
+ }
+ ]
+ }
+]
+
+export const plugins = [
+ { category: 'parser', pluginId: 'modbus_rtu', protocol: 'MODBUS_RTU', protocolValue: 1 },
+ { category: 'framer', pluginId: 'modbus_rtu_framer', protocol: 'MODBUS_RTU', protocolValue: 1 },
+ { category: 'mapper', pluginId: 'modbus_mapper', protocol: 'MODBUS_RTU', protocolValue: 1 },
+ { category: 'parser', pluginId: 'mqtt_json', protocol: 'MQTT_JSON', protocolValue: 2 },
+ { category: 'mapper', pluginId: 'energy_mapper', protocol: 'BACNET_IP', protocolValue: 3 }
+]
+
+export const protocolCapabilities = [
+ { protocol: 'MODBUS_RTU', parser: 'modbus_rtu', framer: 'modbus_rtu_framer', mapper: 'modbus_mapper' },
+ { protocol: 'MQTT_JSON', parser: 'mqtt_json', framer: 'mqtt_line_framer', mapper: '-' },
+ { protocol: 'BACNET_IP', parser: 'bacnet_ip', framer: '-', mapper: 'energy_mapper' }
+]
+
+export const mapperPlugins = [
+ { protocol: 'MODBUS_RTU', pluginId: 'modbus_mapper', category: 'mapper' },
+ { protocol: 'BACNET_IP', pluginId: 'energy_mapper', category: 'mapper' },
+ { protocol: 'OPCUA', pluginId: 'opcua_mapper', category: 'mapper' }
+]
+
+export const metadataList = [
+ {
+ metadataId: 'meta.temperature',
+ name: '温度',
+ domain: 'energy',
+ semanticType: 'number',
+ unit: 'C',
+ minValue: -40,
+ maxValue: 180,
+ deadband: 0.2,
+ isWriteable: false
+ },
+ {
+ metadataId: 'meta.pressure',
+ name: '压力',
+ domain: 'production',
+ semanticType: 'number',
+ unit: 'MPa',
+ minValue: 0,
+ maxValue: 2,
+ deadband: 0.01,
+ isWriteable: false
+ },
+ {
+ metadataId: 'meta.energy.total',
+ name: '总能耗',
+ domain: 'energy',
+ semanticType: 'number',
+ unit: 'kWh',
+ minValue: 0,
+ maxValue: 999999,
+ deadband: 1,
+ isWriteable: false
+ }
+]
+
+export const profileList = [
+ {
+ deviceId: 3021,
+ protocol: 'modbus',
+ mappings: [
+ {
+ pointId: 'boiler.temp.01',
+ metadataId: 'meta.temperature',
+ registerAddress: 40001,
+ byteOffset: 0,
+ byteLength: 2,
+ bitOffset: 0,
+ endianness: 'big',
+ rawType: 'int16',
+ scaleFactor: 1,
+ offsetFactor: 0,
+ expression: 'x'
+ }
+ ]
+ },
+ {
+ deviceId: 3022,
+ protocol: 'mqtt',
+ mappings: [
+ {
+ pointId: 'lineA.pressure.03',
+ metadataId: 'meta.pressure',
+ registerAddress: '-',
+ byteOffset: 0,
+ byteLength: 4,
+ bitOffset: 0,
+ endianness: 'little',
+ rawType: 'float',
+ scaleFactor: 0.001,
+ offsetFactor: 0,
+ expression: 'x * 0.001'
+ }
+ ]
+ }
+]
diff --git a/src/views/prototype/pipeline/index.vue b/src/views/prototype/pipeline/index.vue
new file mode 100644
index 0000000..ae7d713
--- /dev/null
+++ b/src/views/prototype/pipeline/index.vue
@@ -0,0 +1,162 @@
+
+
+
+
+ 暂停轮询
+ 手动刷新
+
+
+
+
+
+
+
+
+
+
{{ item.label }}
+
{{ item.value }}
+
+
+
+
+
+
+
+
{{ item.label }}
+
{{ item.value }}
+
+
+
+
+
+
+
+
+
Parser Plugins
+
{{ item }}
+
+
+
Framer Plugins
+
{{ item }}
+
+
+
Mapper Plugins
+
{{ item }}
+
+
+
+
+
+
+
+
+
diff --git a/src/views/prototype/plugins/index.vue b/src/views/prototype/plugins/index.vue
new file mode 100644
index 0000000..6504874
--- /dev/null
+++ b/src/views/prototype/plugins/index.vue
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ item.protocol }}
+
{{ item.pluginId }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/prototype/profiles/index.vue b/src/views/prototype/profiles/index.vue
new file mode 100644
index 0000000..2693eb4
--- /dev/null
+++ b/src/views/prototype/profiles/index.vue
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ scope.row.mappings.length }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/prototype/rules/filter.vue b/src/views/prototype/rules/filter.vue
new file mode 100644
index 0000000..fa72cb1
--- /dev/null
+++ b/src/views/prototype/rules/filter.vue
@@ -0,0 +1,94 @@
+
+
+
+
+
diff --git a/src/views/prototype/rules/mapping.vue b/src/views/prototype/rules/mapping.vue
new file mode 100644
index 0000000..bf3eafe
--- /dev/null
+++ b/src/views/prototype/rules/mapping.vue
@@ -0,0 +1,89 @@
+
+
+
+
+
diff --git a/src/views/prototype/rules/transform.vue b/src/views/prototype/rules/transform.vue
new file mode 100644
index 0000000..300ba7a
--- /dev/null
+++ b/src/views/prototype/rules/transform.vue
@@ -0,0 +1,100 @@
+
+
+
+
+
diff --git a/src/views/redirect/index.vue b/src/views/redirect/index.vue
new file mode 100644
index 0000000..36063e9
--- /dev/null
+++ b/src/views/redirect/index.vue
@@ -0,0 +1,12 @@
+
diff --git a/tests/unit/.eslintrc.js b/tests/unit/.eslintrc.js
new file mode 100644
index 0000000..958d51b
--- /dev/null
+++ b/tests/unit/.eslintrc.js
@@ -0,0 +1,5 @@
+module.exports = {
+ env: {
+ jest: true
+ }
+}
diff --git a/tests/unit/components/Breadcrumb.spec.js b/tests/unit/components/Breadcrumb.spec.js
new file mode 100644
index 0000000..1d94c8f
--- /dev/null
+++ b/tests/unit/components/Breadcrumb.spec.js
@@ -0,0 +1,98 @@
+import { mount, createLocalVue } from '@vue/test-utils'
+import VueRouter from 'vue-router'
+import ElementUI from 'element-ui'
+import Breadcrumb from '@/components/Breadcrumb/index.vue'
+
+const localVue = createLocalVue()
+localVue.use(VueRouter)
+localVue.use(ElementUI)
+
+const routes = [
+ {
+ path: '/',
+ name: 'home',
+ children: [{
+ path: 'dashboard',
+ name: 'dashboard'
+ }]
+ },
+ {
+ path: '/menu',
+ name: 'menu',
+ children: [{
+ path: 'menu1',
+ name: 'menu1',
+ meta: { title: 'menu1' },
+ children: [{
+ path: 'menu1-1',
+ name: 'menu1-1',
+ meta: { title: 'menu1-1' }
+ },
+ {
+ path: 'menu1-2',
+ name: 'menu1-2',
+ redirect: 'noredirect',
+ meta: { title: 'menu1-2' },
+ children: [{
+ path: 'menu1-2-1',
+ name: 'menu1-2-1',
+ meta: { title: 'menu1-2-1' }
+ },
+ {
+ path: 'menu1-2-2',
+ name: 'menu1-2-2'
+ }]
+ }]
+ }]
+ }]
+
+const router = new VueRouter({
+ routes
+})
+
+describe('Breadcrumb.vue', () => {
+ const wrapper = mount(Breadcrumb, {
+ localVue,
+ router
+ })
+ it('dashboard', () => {
+ router.push('/dashboard')
+ const len = wrapper.findAll('.el-breadcrumb__inner').length
+ expect(len).toBe(1)
+ })
+ it('normal route', () => {
+ router.push('/menu/menu1')
+ const len = wrapper.findAll('.el-breadcrumb__inner').length
+ expect(len).toBe(2)
+ })
+ it('nested route', () => {
+ router.push('/menu/menu1/menu1-2/menu1-2-1')
+ const len = wrapper.findAll('.el-breadcrumb__inner').length
+ expect(len).toBe(4)
+ })
+ it('no meta.title', () => {
+ router.push('/menu/menu1/menu1-2/menu1-2-2')
+ const len = wrapper.findAll('.el-breadcrumb__inner').length
+ expect(len).toBe(3)
+ })
+ // it('click link', () => {
+ // router.push('/menu/menu1/menu1-2/menu1-2-2')
+ // const breadcrumbArray = wrapper.findAll('.el-breadcrumb__inner')
+ // const second = breadcrumbArray.at(1)
+ // console.log(breadcrumbArray)
+ // const href = second.find('a').attributes().href
+ // expect(href).toBe('#/menu/menu1')
+ // })
+ // it('noRedirect', () => {
+ // router.push('/menu/menu1/menu1-2/menu1-2-1')
+ // const breadcrumbArray = wrapper.findAll('.el-breadcrumb__inner')
+ // const redirectBreadcrumb = breadcrumbArray.at(2)
+ // expect(redirectBreadcrumb.contains('a')).toBe(false)
+ // })
+ it('last breadcrumb', () => {
+ router.push('/menu/menu1/menu1-2/menu1-2-1')
+ const breadcrumbArray = wrapper.findAll('.el-breadcrumb__inner')
+ const redirectBreadcrumb = breadcrumbArray.at(3)
+ expect(redirectBreadcrumb.contains('a')).toBe(false)
+ })
+})
diff --git a/tests/unit/components/Hamburger.spec.js b/tests/unit/components/Hamburger.spec.js
new file mode 100644
index 0000000..01ea303
--- /dev/null
+++ b/tests/unit/components/Hamburger.spec.js
@@ -0,0 +1,18 @@
+import { shallowMount } from '@vue/test-utils'
+import Hamburger from '@/components/Hamburger/index.vue'
+describe('Hamburger.vue', () => {
+ it('toggle click', () => {
+ const wrapper = shallowMount(Hamburger)
+ const mockFn = jest.fn()
+ wrapper.vm.$on('toggleClick', mockFn)
+ wrapper.find('.hamburger').trigger('click')
+ expect(mockFn).toBeCalled()
+ })
+ it('prop isActive', () => {
+ const wrapper = shallowMount(Hamburger)
+ wrapper.setProps({ isActive: true })
+ expect(wrapper.contains('.is-active')).toBe(true)
+ wrapper.setProps({ isActive: false })
+ expect(wrapper.contains('.is-active')).toBe(false)
+ })
+})
diff --git a/tests/unit/components/SvgIcon.spec.js b/tests/unit/components/SvgIcon.spec.js
new file mode 100644
index 0000000..31467a9
--- /dev/null
+++ b/tests/unit/components/SvgIcon.spec.js
@@ -0,0 +1,22 @@
+import { shallowMount } from '@vue/test-utils'
+import SvgIcon from '@/components/SvgIcon/index.vue'
+describe('SvgIcon.vue', () => {
+ it('iconClass', () => {
+ const wrapper = shallowMount(SvgIcon, {
+ propsData: {
+ iconClass: 'test'
+ }
+ })
+ expect(wrapper.find('use').attributes().href).toBe('#icon-test')
+ })
+ it('className', () => {
+ const wrapper = shallowMount(SvgIcon, {
+ propsData: {
+ iconClass: 'test'
+ }
+ })
+ expect(wrapper.classes().length).toBe(1)
+ wrapper.setProps({ className: 'test' })
+ expect(wrapper.classes().includes('test')).toBe(true)
+ })
+})
diff --git a/tests/unit/utils/formatTime.spec.js b/tests/unit/utils/formatTime.spec.js
new file mode 100644
index 0000000..24e165b
--- /dev/null
+++ b/tests/unit/utils/formatTime.spec.js
@@ -0,0 +1,30 @@
+import { formatTime } from '@/utils/index.js'
+
+describe('Utils:formatTime', () => {
+ const d = new Date('2018-07-13 17:54:01') // "2018-07-13 17:54:01"
+ const retrofit = 5 * 1000
+
+ it('ten digits timestamp', () => {
+ expect(formatTime((d / 1000).toFixed(0))).toBe('7月13日17时54分')
+ })
+ it('test now', () => {
+ expect(formatTime(+new Date() - 1)).toBe('刚刚')
+ })
+ it('less two minute', () => {
+ expect(formatTime(+new Date() - 60 * 2 * 1000 + retrofit)).toBe('2分钟前')
+ })
+ it('less two hour', () => {
+ expect(formatTime(+new Date() - 60 * 60 * 2 * 1000 + retrofit)).toBe('2小时前')
+ })
+ it('less one day', () => {
+ expect(formatTime(+new Date() - 60 * 60 * 24 * 1 * 1000)).toBe('1天前')
+ })
+ it('more than one day', () => {
+ expect(formatTime(d)).toBe('7月13日17时54分')
+ })
+ it('format', () => {
+ expect(formatTime(d, '{y}-{m}-{d} {h}:{i}')).toBe('2018-07-13 17:54')
+ expect(formatTime(d, '{y}-{m}-{d}')).toBe('2018-07-13')
+ expect(formatTime(d, '{y}/{m}/{d} {h}-{i}')).toBe('2018/07/13 17-54')
+ })
+})
diff --git a/tests/unit/utils/param2Obj.spec.js b/tests/unit/utils/param2Obj.spec.js
new file mode 100644
index 0000000..e106ed8
--- /dev/null
+++ b/tests/unit/utils/param2Obj.spec.js
@@ -0,0 +1,14 @@
+import { param2Obj } from '@/utils/index.js'
+describe('Utils:param2Obj', () => {
+ const url = 'https://github.com/PanJiaChen/vue-element-admin?name=bill&age=29&sex=1&field=dGVzdA==&key=%E6%B5%8B%E8%AF%95'
+
+ it('param2Obj test', () => {
+ expect(param2Obj(url)).toEqual({
+ name: 'bill',
+ age: '29',
+ sex: '1',
+ field: window.btoa('test'),
+ key: '测试'
+ })
+ })
+})
diff --git a/tests/unit/utils/parseTime.spec.js b/tests/unit/utils/parseTime.spec.js
new file mode 100644
index 0000000..56045af
--- /dev/null
+++ b/tests/unit/utils/parseTime.spec.js
@@ -0,0 +1,35 @@
+import { parseTime } from '@/utils/index.js'
+
+describe('Utils:parseTime', () => {
+ const d = new Date('2018-07-13 17:54:01') // "2018-07-13 17:54:01"
+ it('timestamp', () => {
+ expect(parseTime(d)).toBe('2018-07-13 17:54:01')
+ })
+ it('timestamp string', () => {
+ expect(parseTime((d + ''))).toBe('2018-07-13 17:54:01')
+ })
+ it('ten digits timestamp', () => {
+ expect(parseTime((d / 1000).toFixed(0))).toBe('2018-07-13 17:54:01')
+ })
+ it('new Date', () => {
+ expect(parseTime(new Date(d))).toBe('2018-07-13 17:54:01')
+ })
+ it('format', () => {
+ expect(parseTime(d, '{y}-{m}-{d} {h}:{i}')).toBe('2018-07-13 17:54')
+ expect(parseTime(d, '{y}-{m}-{d}')).toBe('2018-07-13')
+ expect(parseTime(d, '{y}/{m}/{d} {h}-{i}')).toBe('2018/07/13 17-54')
+ })
+ it('get the day of the week', () => {
+ expect(parseTime(d, '{a}')).toBe('五') // 星期五
+ })
+ it('get the day of the week', () => {
+ expect(parseTime(+d + 1000 * 60 * 60 * 24 * 2, '{a}')).toBe('日') // 星期日
+ })
+ it('empty argument', () => {
+ expect(parseTime()).toBeNull()
+ })
+
+ it('null', () => {
+ expect(parseTime(null)).toBeNull()
+ })
+})
diff --git a/tests/unit/utils/validate.spec.js b/tests/unit/utils/validate.spec.js
new file mode 100644
index 0000000..f774905
--- /dev/null
+++ b/tests/unit/utils/validate.spec.js
@@ -0,0 +1,17 @@
+import { validUsername, isExternal } from '@/utils/validate.js'
+
+describe('Utils:validate', () => {
+ it('validUsername', () => {
+ expect(validUsername('admin')).toBe(true)
+ expect(validUsername('editor')).toBe(true)
+ expect(validUsername('xxxx')).toBe(false)
+ })
+ it('isExternal', () => {
+ expect(isExternal('https://github.com/PanJiaChen/vue-element-admin')).toBe(true)
+ expect(isExternal('http://github.com/PanJiaChen/vue-element-admin')).toBe(true)
+ expect(isExternal('github.com/PanJiaChen/vue-element-admin')).toBe(false)
+ expect(isExternal('/dashboard')).toBe(false)
+ expect(isExternal('./dashboard')).toBe(false)
+ expect(isExternal('dashboard')).toBe(false)
+ })
+})
diff --git a/vue.config.js b/vue.config.js
new file mode 100644
index 0000000..3dbf97d
--- /dev/null
+++ b/vue.config.js
@@ -0,0 +1,132 @@
+'use strict'
+const path = require('path')
+const defaultSettings = require('./src/settings.js')
+
+function resolve(dir) {
+ return path.join(__dirname, dir)
+}
+
+const name = defaultSettings.title || 'vue Admin Template' // page title
+
+// If your port is set to 80,
+// use administrator privileges to execute the command line.
+// For example, Mac: sudo npm run
+// You can change the port by the following methods:
+// port = 9528 npm run dev OR npm run dev --port = 9528
+const port = process.env.port || process.env.npm_config_port || 9528 // dev port
+
+// All configuration item explanations can be find in https://cli.vuejs.org/config/
+module.exports = {
+ /**
+ * You will need to set publicPath if you plan to deploy your site under a sub path,
+ * for example GitHub Pages. If you plan to deploy your site to https://foo.github.io/bar/,
+ * then publicPath should be set to "/bar/".
+ * In most cases please use '/' !!!
+ * Detail: https://cli.vuejs.org/config/#publicpath
+ */
+ publicPath: '/',
+ outputDir: 'dist',
+ assetsDir: 'static',
+ lintOnSave: process.env.NODE_ENV === 'development',
+ productionSourceMap: false,
+ devServer: {
+ port: port,
+ open: true,
+ overlay: {
+ warnings: false,
+ errors: true
+ },
+ proxy: {
+ '/softbus-api': {
+ target: 'http://192.168.31.108:18080',
+ changeOrigin: true,
+ pathRewrite: {
+ '^/softbus-api': ''
+ }
+ }
+ },
+ before: require('./mock/mock-server.js')
+ },
+ configureWebpack: {
+ // provide the app's title in webpack's name field, so that
+ // it can be accessed in index.html to inject the correct title.
+ name: name,
+ resolve: {
+ alias: {
+ '@': resolve('src')
+ }
+ }
+ },
+ chainWebpack(config) {
+ // it can improve the speed of the first screen, it is recommended to turn on preload
+ config.plugin('preload').tap(() => [
+ {
+ rel: 'preload',
+ // to ignore runtime.js
+ // https://github.com/vuejs/vue-cli/blob/dev/packages/@vue/cli-service/lib/config/app.js#L171
+ fileBlacklist: [/\.map$/, /hot-update\.js$/, /runtime\..*\.js$/],
+ include: 'initial'
+ }
+ ])
+
+ // when there are many pages, it will cause too many meaningless requests
+ config.plugins.delete('prefetch')
+
+ // set svg-sprite-loader
+ config.module
+ .rule('svg')
+ .exclude.add(resolve('src/icons'))
+ .end()
+ config.module
+ .rule('icons')
+ .test(/\.svg$/)
+ .include.add(resolve('src/icons'))
+ .end()
+ .use('svg-sprite-loader')
+ .loader('svg-sprite-loader')
+ .options({
+ symbolId: 'icon-[name]'
+ })
+ .end()
+
+ config
+ .when(process.env.NODE_ENV !== 'development',
+ config => {
+ config
+ .plugin('ScriptExtHtmlWebpackPlugin')
+ .after('html')
+ .use('script-ext-html-webpack-plugin', [{
+ // `runtime` must same as runtimeChunk name. default is `runtime`
+ inline: /runtime\..*\.js$/
+ }])
+ .end()
+ config
+ .optimization.splitChunks({
+ chunks: 'all',
+ cacheGroups: {
+ libs: {
+ name: 'chunk-libs',
+ test: /[\\/]node_modules[\\/]/,
+ priority: 10,
+ chunks: 'initial' // only package third parties that are initially dependent
+ },
+ elementUI: {
+ name: 'chunk-elementUI', // split elementUI into a single package
+ priority: 20, // the weight needs to be larger than libs and app or it will be packaged into libs or app
+ test: /[\\/]node_modules[\\/]_?element-ui(.*)/ // in order to adapt to cnpm
+ },
+ commons: {
+ name: 'chunk-commons',
+ test: resolve('src/components'), // can customize your rules
+ minChunks: 3, // minimum common number
+ priority: 5,
+ reuseExistingChunk: true
+ }
+ }
+ })
+ // https:// webpack.js.org/configuration/optimization/#optimizationruntimechunk
+ config.optimization.runtimeChunk('single')
+ }
+ )
+ }
+}
diff --git a/前端设计说明.md b/前端设计说明.md
new file mode 100644
index 0000000..c396fb1
--- /dev/null
+++ b/前端设计说明.md
@@ -0,0 +1,1114 @@
+# SoftBus 运维控制台前端设计说明
+
+## 1. 文档说明
+
+本文档用于定义 `softbus_daemon` 运维控制台的界面功能设计,目标不是单纯罗列接口字段,而是明确以下内容:
+
+- 当前控制台面向哪些角色
+- 页面为什么存在,各自解决什么任务
+- 页面之间如何组成一条完整的操作闭环
+- 每个页面依赖哪些接口与状态数据
+- 哪些能力属于当前核心版本,哪些属于后续扩展版本
+
+本文档覆盖两类范围:
+
+- 当前已形成原型方向的核心页面
+- 已进入产品规划、尚未全部实现的扩展页面
+
+## 2. 产品定位与设计目标
+
+### 2.1 产品定位
+
+SoftBus 运维控制台是一个面向设备接入、运行监控、规则配置和数据映射的后台管理界面,主要服务于以下角色:
+
+- 设备接入调试人员
+- 协议与插件开发人员
+- 运维人员
+- 配置管理人员
+- 前后端联调人员
+
+### 2.2 核心目标
+
+控制台需要同时承担四类职责:
+
+- 查看系统当前运行状态,包括健康、版本、计数器、异常和规则状态
+- 支撑 discovery 到 mapping 的接入配置流程
+- 支撑 filter、transform、mapping 三类规则的查看与配置
+- 为设备、Pipeline、插件、Metadata、Profiles 等信息提供统一的浏览入口
+
+### 2.3 设计原则
+
+- 以工作流为核心组织页面,而不是仅按接口目录平铺
+- 首页负责判断状态和分发任务,不承载所有详情
+- 高频配置优先采用“筛选区 + 列表/表格 + 抽屉/侧栏”的后台交互模型
+- 对“已生效”和“仅注册未接入执行链路”的能力必须明确区分
+- 对实时状态、空态、异常态和后端能力边界做清晰提示
+
+## 3. 信息架构与导航设计
+
+### 3.1 总体导航结构
+
+建议后台采用左侧导航,结构如下:
+
+- 总览 Dashboard
+- 发现接入工作台
+- 规则映射中心
+ - 过滤规则
+ - 转换规则
+ - 映射规则
+- 设备管理
+- Pipeline 监控
+- 插件中心
+- Metadata
+- Profiles
+
+### 3.2 当前原型与规划页面分层
+
+当前文档需要明确区分“已经进入当前原型主线”的页面与“后续继续建设”的页面:
+
+- 当前核心原型页
+ - Dashboard
+ - 发现接入工作台
+ - 过滤规则
+ - 转换规则
+ - 映射规则
+- 规划扩展页
+ - 设备管理
+ - Pipeline 监控
+ - 插件中心
+ - Metadata
+ - Profiles
+
+### 3.3 页面关系图
+
+```mermaid
+flowchart TD
+ dashboard[总览Dashboard]
+ discoveryWorkbench[发现接入工作台]
+ rulesCenter[规则映射中心]
+ deviceCenter[设备管理]
+ pipelineCenter[Pipeline监控]
+ pluginCenter[插件中心]
+ metadataCenter[Metadata与Profiles]
+
+ dashboard --> discoveryWorkbench
+ dashboard --> rulesCenter
+ dashboard --> pipelineCenter
+ dashboard --> deviceCenter
+ discoveryWorkbench --> mappingFlow[发现到映射闭环]
+ rulesCenter --> filterRules[过滤规则]
+ rulesCenter --> transformRules[转换规则]
+ rulesCenter --> mappingRules[映射规则]
+ deviceCenter --> deviceTree[设备树]
+ deviceCenter --> deviceRuntime[运行态设备]
+ pluginCenter --> protocolMatrix[协议能力矩阵]
+ metadataCenter --> metadataPage[Metadata]
+ metadataCenter --> profilesPage[Profiles]
+```
+
+### 3.4 当前原型对应关系
+
+当前项目已经围绕以下结构形成原型入口:
+
+- `Dashboard` 作为系统总览首页
+- `发现接入` 作为 discovery 和 mapping 的工作流页
+- `规则映射` 作为规则中心,拆分为过滤、转换、映射三个页面
+
+这一结构已经体现在路由层:
+
+```33:109:src/router/index.js
+export const constantRoutes = [
+ {
+ path: '/',
+ component: Layout,
+ redirect: '/dashboard',
+ children: [{
+ path: 'dashboard',
+ name: 'Dashboard',
+ component: () => import('@/views/dashboard/index'),
+ meta: { title: 'Dashboard', icon: 'dashboard', affix: true }
+ }]
+ },
+ {
+ path: '/discovery',
+ component: Layout,
+ children: [
+ {
+ path: 'index',
+ name: 'DiscoveryWorkbench',
+ component: () => import('@/views/prototype/discovery/index'),
+ meta: { title: '发现接入', icon: 'search' }
+ }
+ ]
+ },
+ {
+ path: '/rules',
+ component: Layout,
+ redirect: '/rules/filter',
+ name: 'RuleCenter',
+ meta: { title: '规则映射', icon: 'table' },
+ children: [
+ // ...
+ ]
+ }
+]
+```
+
+## 4. 当前核心工作流
+
+### 4.1 首页总览工作流
+
+首页的职责不是展示所有细节,而是让用户在进入系统后立刻判断:
+
+- 系统是否健康
+- 当前是否存在错误、丢包和风险提示
+- Discovery 是否活跃
+- 当前规则配置状态是否正常
+- 下一步应进入哪个功能页继续操作
+
+### 4.2 发现到映射闭环
+
+核心业务闭环应围绕以下路径组织:
+
+1. 开启 Discovery
+2. 查看发现样本
+3. 识别 `compositeKey` 与业务语义
+4. 绑定 `domPath`
+5. 配置 `scale / offset / unit`
+6. 按需联动创建 filter 或 transform 规则
+7. 回到状态页验证结果
+
+### 4.3 规则配置闭环
+
+规则中心负责承接工作流中的沉淀配置,功能上分为三类:
+
+- 过滤规则:负责注册和条件约束,但需强调其执行链路边界
+- 转换规则:负责换算和单位转换,当前已进入执行链路
+- 映射规则:负责 `compositeKey -> domPath` 的绑定关系维护
+
+## 5. 页面功能设计
+
+本章按统一模板描述每个页面:
+
+- 页面定位
+- 目标用户与核心任务
+- 页面布局
+- 核心模块
+- 关键交互
+- 接口来源
+- 状态与异常提示
+- 页面关系
+
+### 5.1 Dashboard
+
+#### 页面定位
+
+Dashboard 是控制台首屏,负责承接控制面信息、全局状态判断和任务分流。
+
+#### 目标用户与核心任务
+
+- 运维人员快速判断系统健康
+- 接入人员判断是否需要进入 Discovery 工作台
+- 配置人员判断当前规则数量和规则状态
+
+#### 页面布局
+
+建议采用“顶部页头 + 指标卡片 + 左右两列摘要区”的结构:
+
+- 顶部页头:页面标题、说明、主操作按钮
+- 第一屏卡片区:设备、规则、Discovery、错误等核心指标
+- 中部区域:Pipeline 计数器与系统控制面
+- 下部区域:规则状态摘要与风险提示
+
+当前原型已经采用这一结构:
+
+```1:18:src/views/dashboard/index.vue
+
+```
+
+#### 核心模块
+
+1. 状态卡片
+- 在线设备
+- 规则总数
+- Discovery 样本数
+- Pipeline 错误数
+
+2. Pipeline 摘要
+- `in / out / drop / error / mergeDrop`
+- 趋势图或柱状图摘要
+
+3. 系统控制面
+- 健康检查结果
+- 当前版本
+- PID
+- Discovery 开关状态
+
+4. 规则状态摘要
+- 过滤规则数量
+- 转换规则数量
+- 映射规则数量
+- 规则生效范围说明
+
+5. 风险提示区
+- filter 是否只注册未执行
+- transform 是否已生效
+- mapping 是否已形成闭环
+
+#### 关键交互
+
+- 点击卡片跳转对应详情页
+- 支持导出状态快照
+- 支持从首页直接进入发现接入页
+- 错误和异常使用醒目标签或提示卡片
+
+#### 接口来源
+
+- `GET /v1/status`
+- `GET /health`
+- `GET /api/v1/rules/status`
+- `GET /api/v1/pipeline/status`
+- `GET /api/v1/pipeline/counters`
+- `GET /api/v1/discovery/status`
+- `GET /api/v1/devices`
+
+#### 状态与异常提示
+
+- `running = false` 时置顶异常提示
+- `drop > 0` 或 `error > 0` 时高亮 counters
+- filter 类规则必须显示“注册中/实验性”提示
+
+#### 页面关系
+
+- 跳转到 `发现接入工作台`
+- 跳转到 `规则映射中心`
+- 跳转到 `Pipeline 监控`
+- 跳转到 `设备管理`
+
+### 5.2 发现接入工作台
+
+#### 页面定位
+
+发现接入工作台是本系统最核心的业务流程页,用于承接 discovery、mapping 和规则补充配置。
+
+#### 目标用户与核心任务
+
+- 设备接入调试人员发现未映射字段
+- 配置人员建立字段映射关系
+- 规则配置人员在映射过程中补充转换或过滤规则
+
+#### 页面布局
+
+建议采用三栏布局:
+
+- 左栏:样本筛选与样本列表
+- 中栏:字段解析与映射建议
+- 右栏:映射绑定表单与补充规则入口
+- 底部:最近操作与校验反馈
+
+当前原型已经围绕该模型实现:
+
+```16:33:src/views/prototype/discovery/index.vue
+