first commit

This commit is contained in:
flowerstonezl
2026-04-20 16:49:18 +08:00
commit fa137c7fa0
109 changed files with 8635 additions and 0 deletions

14
.editorconfig Normal file
View File

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

5
.env.development Normal file
View File

@@ -0,0 +1,5 @@
# just a flag
ENV = 'development'
# base api
VUE_APP_BASE_API = '/dev-api'

6
.env.production Normal file
View File

@@ -0,0 +1,6 @@
# just a flag
ENV = 'production'
# base api
VUE_APP_BASE_API = '/prod-api'

8
.env.staging Normal file
View File

@@ -0,0 +1,8 @@
NODE_ENV = production
# just a flag
ENV = 'staging'
# base api
VUE_APP_BASE_API = '/stage-api'

4
.eslintignore Normal file
View File

@@ -0,0 +1,4 @@
build/*.js
src/assets
public
dist

198
.eslintrc.js Normal file
View File

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

16
.gitignore vendored Normal file
View File

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

5
.travis.yml Normal file
View File

@@ -0,0 +1,5 @@
language: node_js
node_js: 10
script: npm run test
notifications:
email: false

21
LICENSE Normal file
View File

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

111
README-zh.md Normal file
View File

@@ -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`
<p align="center">
<b>SPONSORED BY</b>
</p>
<p align="center">
<a href="https://finclip.com?from=vue_element" title="FinClip" target="_blank">
<img height="200px" src="https://gitee.com/panjiachen/gitee-cdn/raw/master/vue%E8%B5%9E%E5%8A%A9.png" title="FinClip">
</a>
</p>
## 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
![demo](https://github.com/PanJiaChen/PanJiaChen.github.io/blob/master/images/demo.gif)
## Browsers support
Modern browsers and Internet Explorer 10+.
| [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/edge/edge_48x48.png" alt="IE / Edge" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>IE / Edge | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/firefox/firefox_48x48.png" alt="Firefox" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Firefox | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/chrome/chrome_48x48.png" alt="Chrome" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>Chrome | [<img src="https://raw.githubusercontent.com/alrra/browser-logos/master/src/safari/safari_48x48.png" alt="Safari" width="24px" height="24px" />](http://godban.github.io/browsers-support-badges/)</br>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

14
babel.config.js Normal file
View File

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

35
build/index.js Normal file
View File

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

24
jest.config.js Normal file
View File

@@ -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: {
'^@/(.*)$': '<rootDir>/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: '<rootDir>/tests/unit/coverage',
// 'collectCoverage': true,
'coverageReporters': [
'lcov',
'text-summary'
],
testURL: 'http://localhost/'
}

9
jsconfig.json Normal file
View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

57
mock/index.js Normal file
View File

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

81
mock/mock-server.js Normal file
View File

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

29
mock/table.js Normal file
View File

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

84
mock/user.js Normal file
View File

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

25
mock/utils.js Normal file
View File

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

62
package.json Normal file
View File

@@ -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 <panfree23@gmail.com>",
"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"
}

8
postcss.config.js Normal file
View File

@@ -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': {}
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

17
public/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
<title><%= webpackConfig.name %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= webpackConfig.name %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
</noscript>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

11
src/App.vue Normal file
View File

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

276
src/api/softbus.js Normal file
View File

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

9
src/api/table.js Normal file
View File

@@ -0,0 +1,9 @@
import request from '@/utils/request'
export function getList(params) {
return request({
url: '/vue-admin-template/table/list',
method: 'get',
params
})
}

24
src/api/user.js Normal file
View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -0,0 +1,78 @@
<template>
<el-breadcrumb class="app-breadcrumb" separator="/">
<transition-group name="breadcrumb">
<el-breadcrumb-item v-for="(item,index) in levelList" :key="item.path">
<span v-if="item.redirect==='noRedirect'||index==levelList.length-1" class="no-redirect">{{ item.meta.title }}</span>
<a v-else @click.prevent="handleLink(item)">{{ item.meta.title }}</a>
</el-breadcrumb-item>
</transition-group>
</el-breadcrumb>
</template>
<script>
import pathToRegexp from 'path-to-regexp'
export default {
data() {
return {
levelList: null
}
},
watch: {
$route() {
this.getBreadcrumb()
}
},
created() {
this.getBreadcrumb()
},
methods: {
getBreadcrumb() {
// only show routes with meta.title
let matched = this.$route.matched.filter(item => item.meta && item.meta.title)
const first = matched[0]
if (!this.isDashboard(first)) {
matched = [{ path: '/dashboard', meta: { title: 'Dashboard' }}].concat(matched)
}
this.levelList = matched.filter(item => item.meta && item.meta.title && item.meta.breadcrumb !== false)
},
isDashboard(route) {
const name = route && route.name
if (!name) {
return false
}
return name.trim().toLocaleLowerCase() === 'Dashboard'.toLocaleLowerCase()
},
pathCompile(path) {
// To solve this problem https://github.com/PanJiaChen/vue-element-admin/issues/561
const { params } = this.$route
var toPath = pathToRegexp.compile(path)
return toPath(params)
},
handleLink(item) {
const { redirect, path } = item
if (redirect) {
this.$router.push(redirect)
return
}
this.$router.push(this.pathCompile(path))
}
}
}
</script>
<style lang="scss" scoped>
.app-breadcrumb.el-breadcrumb {
display: inline-block;
font-size: 14px;
line-height: 50px;
margin-left: 8px;
.no-redirect {
color: #97a8be;
cursor: text;
}
}
</style>

View File

@@ -0,0 +1,44 @@
<template>
<div style="padding: 0 15px;" @click="toggleClick">
<svg
:class="{'is-active':isActive}"
class="hamburger"
viewBox="0 0 1024 1024"
xmlns="http://www.w3.org/2000/svg"
width="64"
height="64"
>
<path d="M408 442h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8zm-8 204c0 4.4 3.6 8 8 8h480c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8H408c-4.4 0-8 3.6-8 8v56zm504-486H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zm0 632H120c-4.4 0-8 3.6-8 8v56c0 4.4 3.6 8 8 8h784c4.4 0 8-3.6 8-8v-56c0-4.4-3.6-8-8-8zM142.4 642.1L298.7 519a8.84 8.84 0 0 0 0-13.9L142.4 381.9c-5.8-4.6-14.4-.5-14.4 6.9v246.3a8.9 8.9 0 0 0 14.4 7z" />
</svg>
</div>
</template>
<script>
export default {
name: 'Hamburger',
props: {
isActive: {
type: Boolean,
default: false
}
},
methods: {
toggleClick() {
this.$emit('toggleClick')
}
}
}
</script>
<style scoped>
.hamburger {
display: inline-block;
vertical-align: middle;
width: 20px;
height: 20px;
}
.hamburger.is-active {
transform: rotate(180deg);
}
</style>

View File

@@ -0,0 +1,62 @@
<template>
<div v-if="isExternal" :style="styleExternalIcon" class="svg-external-icon svg-icon" v-on="$listeners" />
<svg v-else :class="svgClass" aria-hidden="true" v-on="$listeners">
<use :xlink:href="iconName" />
</svg>
</template>
<script>
// doc: https://panjiachen.github.io/vue-element-admin-site/feature/component/svg-icon.html#usage
import { isExternal } from '@/utils/validate'
export default {
name: 'SvgIcon',
props: {
iconClass: {
type: String,
required: true
},
className: {
type: String,
default: ''
}
},
computed: {
isExternal() {
return isExternal(this.iconClass)
},
iconName() {
return `#icon-${this.iconClass}`
},
svgClass() {
if (this.className) {
return 'svg-icon ' + this.className
} else {
return 'svg-icon'
}
},
styleExternalIcon() {
return {
mask: `url(${this.iconClass}) no-repeat 50% 50%`,
'-webkit-mask': `url(${this.iconClass}) no-repeat 50% 50%`
}
}
}
}
</script>
<style scoped>
.svg-icon {
width: 1em;
height: 1em;
vertical-align: -0.15em;
fill: currentColor;
overflow: hidden;
}
.svg-external-icon {
background-color: currentColor;
mask-size: cover!important;
display: inline-block;
}
</style>

9
src/icons/index.js Normal file
View File

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

View File

@@ -0,0 +1 @@
<svg width="128" height="100" xmlns="http://www.w3.org/2000/svg"><path d="M27.429 63.638c0-2.508-.893-4.65-2.679-6.424-1.786-1.775-3.94-2.662-6.464-2.662-2.524 0-4.679.887-6.465 2.662-1.785 1.774-2.678 3.916-2.678 6.424 0 2.508.893 4.65 2.678 6.424 1.786 1.775 3.94 2.662 6.465 2.662 2.524 0 4.678-.887 6.464-2.662 1.786-1.775 2.679-3.916 2.679-6.424zm13.714-31.801c0-2.508-.893-4.65-2.679-6.424-1.785-1.775-3.94-2.662-6.464-2.662-2.524 0-4.679.887-6.464 2.662-1.786 1.774-2.679 3.916-2.679 6.424 0 2.508.893 4.65 2.679 6.424 1.785 1.774 3.94 2.662 6.464 2.662 2.524 0 4.679-.888 6.464-2.662 1.786-1.775 2.679-3.916 2.679-6.424zM71.714 65.98l7.215-27.116c.285-1.23.107-2.378-.536-3.443-.643-1.064-1.56-1.762-2.75-2.094-1.19-.33-2.333-.177-3.429.462-1.095.639-1.81 1.573-2.143 2.804l-7.214 27.116c-2.857.237-5.405 1.266-7.643 3.088-2.238 1.822-3.738 4.152-4.5 6.992-.952 3.644-.476 7.098 1.429 10.364 1.905 3.265 4.69 5.37 8.357 6.317 3.667.947 7.143.474 10.429-1.42 3.285-1.892 5.404-4.66 6.357-8.305.762-2.84.619-5.607-.429-8.305-1.047-2.697-2.762-4.85-5.143-6.46zm47.143-2.342c0-2.508-.893-4.65-2.678-6.424-1.786-1.775-3.94-2.662-6.465-2.662-2.524 0-4.678.887-6.464 2.662-1.786 1.774-2.679 3.916-2.679 6.424 0 2.508.893 4.65 2.679 6.424 1.786 1.775 3.94 2.662 6.464 2.662 2.524 0 4.679-.887 6.465-2.662 1.785-1.775 2.678-3.916 2.678-6.424zm-45.714-45.43c0-2.509-.893-4.65-2.679-6.425C68.68 10.01 66.524 9.122 64 9.122c-2.524 0-4.679.887-6.464 2.661-1.786 1.775-2.679 3.916-2.679 6.425 0 2.508.893 4.65 2.679 6.424 1.785 1.774 3.94 2.662 6.464 2.662 2.524 0 4.679-.888 6.464-2.662 1.786-1.775 2.679-3.916 2.679-6.424zm32 13.629c0-2.508-.893-4.65-2.679-6.424-1.785-1.775-3.94-2.662-6.464-2.662-2.524 0-4.679.887-6.464 2.662-1.786 1.774-2.679 3.916-2.679 6.424 0 2.508.893 4.65 2.679 6.424 1.785 1.774 3.94 2.662 6.464 2.662 2.524 0 4.679-.888 6.464-2.662 1.786-1.775 2.679-3.916 2.679-6.424zM128 63.638c0 12.351-3.357 23.78-10.071 34.286-.905 1.372-2.19 2.058-3.858 2.058H13.93c-1.667 0-2.953-.686-3.858-2.058C3.357 87.465 0 76.037 0 63.638c0-8.613 1.69-16.847 5.071-24.703C8.452 31.08 13 24.312 18.714 18.634c5.715-5.68 12.524-10.199 20.429-13.559C47.048 1.715 55.333.035 64 .035c8.667 0 16.952 1.68 24.857 5.04 7.905 3.36 14.714 7.88 20.429 13.559 5.714 5.678 10.262 12.446 13.643 20.301 3.38 7.856 5.071 16.09 5.071 24.703z"/></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1 @@
<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg"><path d="M96.258 57.462h31.421C124.794 27.323 100.426 2.956 70.287.07v31.422a32.856 32.856 0 0 1 25.971 25.97zm-38.796-25.97V.07C27.323 2.956 2.956 27.323.07 57.462h31.422a32.856 32.856 0 0 1 25.97-25.97zm12.825 64.766v31.421c30.46-2.885 54.507-27.253 57.713-57.712H96.579c-2.886 13.466-13.146 23.726-26.292 26.291zM31.492 70.287H.07c2.886 30.46 27.253 54.507 57.713 57.713V96.579c-13.466-2.886-23.726-13.146-26.291-26.292z"/></svg>

After

Width:  |  Height:  |  Size: 497 B

View File

@@ -0,0 +1 @@
<svg class="icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg" width="128" height="128"><defs><style/></defs><path d="M512 128q69.675 0 135.51 21.163t115.498 54.997 93.483 74.837 73.685 82.006 51.67 74.837 32.17 54.827L1024 512q-2.347 4.992-6.315 13.483T998.87 560.17t-31.658 51.669-44.331 59.99-56.832 64.34-69.504 60.16-82.347 51.5-94.848 34.687T512 896q-69.675 0-135.51-21.163t-115.498-54.826-93.483-74.326-73.685-81.493-51.67-74.496-32.17-54.997L0 513.707q2.347-4.992 6.315-13.483t18.816-34.816 31.658-51.84 44.331-60.33 56.832-64.683 69.504-60.331 82.347-51.84 94.848-34.816T512 128.085zm0 85.333q-46.677 0-91.648 12.331t-81.152 31.83-70.656 47.146-59.648 54.485-48.853 57.686-37.675 52.821-26.325 43.99q12.33 21.674 26.325 43.52t37.675 52.351 48.853 57.003 59.648 53.845T339.2 767.02t81.152 31.488T512 810.667t91.648-12.331 81.152-31.659 70.656-46.848 59.648-54.186 48.853-57.344 37.675-52.651T927.957 512q-12.33-21.675-26.325-43.648t-37.675-52.65-48.853-57.345-59.648-54.186-70.656-46.848-81.152-31.659T512 213.334zm0 128q70.656 0 120.661 50.006T682.667 512 632.66 632.661 512 682.667 391.339 632.66 341.333 512t50.006-120.661T512 341.333zm0 85.334q-35.328 0-60.33 25.002T426.666 512t25.002 60.33T512 597.334t60.33-25.002T597.334 512t-25.002-60.33T512 426.666z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
src/icons/svg/eye.svg Normal file
View File

@@ -0,0 +1 @@
<svg width="128" height="64" xmlns="http://www.w3.org/2000/svg"><path d="M127.072 7.994c1.37-2.208.914-5.152-.914-6.87-2.056-1.717-4.797-1.226-6.396.982-.229.245-25.586 32.382-55.74 32.382-29.24 0-55.74-32.382-55.968-32.627-1.6-1.963-4.57-2.208-6.397-.49C-.17 3.086-.399 6.275 1.2 8.238c.457.736 5.94 7.36 14.62 14.72L4.17 35.96c-1.828 1.963-1.6 5.152.228 6.87.457.98 1.6 1.471 2.742 1.471s2.284-.49 3.198-1.472l12.564-13.983c5.94 4.416 13.021 8.587 20.788 11.53l-4.797 17.418c-.685 2.699.686 5.397 3.198 6.133h1.37c2.057 0 3.884-1.472 4.341-3.68L52.6 42.83c3.655.736 7.538 1.227 11.422 1.227 3.883 0 7.767-.49 11.422-1.227l4.797 17.173c.457 2.208 2.513 3.68 4.34 3.68.457 0 .914 0 1.143-.246 2.513-.736 3.883-3.434 3.198-6.133l-4.797-17.172c7.767-2.944 14.848-7.114 20.788-11.53l12.336 13.738c.913.981 2.056 1.472 3.198 1.472s2.284-.49 3.198-1.472c1.828-1.963 1.828-4.906.228-6.87l-11.65-13.001c9.366-7.36 14.849-14.474 14.849-14.474z"/></svg>

After

Width:  |  Height:  |  Size: 944 B

1
src/icons/svg/form.svg Normal file
View File

@@ -0,0 +1 @@
<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg"><path d="M84.068 23.784c-1.02 0-1.877-.32-2.572-.96a8.588 8.588 0 0 1-1.738-2.237 11.524 11.524 0 0 1-1.042-2.621c-.232-.895-.348-1.641-.348-2.238V0h.278c.834 0 1.622.085 2.363.256.742.17 1.645.575 2.711 1.214 1.066.64 2.363 1.535 3.892 2.686 1.53 1.15 3.453 2.664 5.77 4.54 2.502 2.045 4.494 3.771 5.977 5.178 1.483 1.406 2.618 2.6 3.406 3.58.787.98 1.274 1.812 1.46 2.494.185.682.277 1.278.277 1.79v2.046H84.068zM127.3 84.01c.278.682.464 1.535.556 2.558.093 1.023-.37 2.003-1.39 2.94-.463.427-.88.832-1.25 1.215-.372.384-.696.704-.974.96a6.69 6.69 0 0 1-.973.767l-11.816-10.741a44.331 44.331 0 0 0 1.877-1.535 31.028 31.028 0 0 1 1.737-1.406c1.112-.938 2.317-1.343 3.615-1.215 1.297.128 2.363.405 3.197.83.927.427 1.923 1.173 2.989 2.239 1.065 1.065 1.876 2.195 2.432 3.388zM78.23 95.902c2.038 0 3.752-.511 5.143-1.534l-26.969 25.83H18.037c-1.761 0-3.684-.47-5.77-1.407a24.549 24.549 0 0 1-5.838-3.709 21.373 21.373 0 0 1-4.518-5.306c-1.204-2.003-1.807-4.07-1.807-6.202V16.495c0-1.79.44-3.665 1.32-5.626A18.41 18.41 0 0 1 5.04 5.562a21.798 21.798 0 0 1 5.213-3.964C12.198.533 14.237 0 16.37 0h53.24v15.984c0 1.62.278 3.367.834 5.242a16.704 16.704 0 0 0 2.572 5.179c1.159 1.577 2.665 2.898 4.518 3.964 1.853 1.066 4.078 1.598 6.673 1.598h20.295v42.325L85.458 92.45c1.02-1.364 1.529-2.856 1.529-4.476 0-2.216-.857-4.113-2.572-5.69-1.714-1.577-3.776-2.366-6.186-2.366H26.1c-2.409 0-4.448.789-6.116 2.366-1.668 1.577-2.502 3.474-2.502 5.69 0 2.217.834 4.092 2.502 5.626 1.668 1.535 3.707 2.302 6.117 2.302h52.13zM26.1 47.951c-2.41 0-4.449.789-6.117 2.366-1.668 1.577-2.502 3.473-2.502 5.69 0 2.216.834 4.092 2.502 5.626 1.668 1.534 3.707 2.302 6.117 2.302h52.13c2.409 0 4.47-.768 6.185-2.302 1.715-1.534 2.572-3.41 2.572-5.626 0-2.217-.857-4.113-2.572-5.69-1.714-1.577-3.776-2.366-6.186-2.366H26.1zm52.407 64.063l1.807-1.663 3.476-3.196a479.75 479.75 0 0 0 4.587-4.284 500.757 500.757 0 0 1 5.004-4.667c3.985-3.666 8.48-7.758 13.485-12.276l11.677 10.741-13.485 12.404-5.004 4.603-4.587 4.22a179.46 179.46 0 0 0-3.267 3.068c-.88.853-1.367 1.322-1.46 1.407-.463.341-.973.703-1.529 1.087-.556.383-1.112.703-1.668.959-.556.256-1.413.575-2.572.959a83.5 83.5 0 0 1-3.545 1.087 72.2 72.2 0 0 1-3.475.895c-1.112.256-1.946.426-2.502.511-1.112.17-1.854.043-2.224-.383-.371-.426-.464-1.151-.278-2.174.092-.511.278-1.279.556-2.302.278-1.023.602-2.067.973-3.132l1.042-3.005c.325-.938.58-1.577.765-1.918a10.157 10.157 0 0 1 2.224-2.941z"/></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

1
src/icons/svg/link.svg Normal file
View File

@@ -0,0 +1 @@
<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg"><path d="M115.625 127.937H.063V12.375h57.781v12.374H12.438v90.813h90.813V70.156h12.374z"/><path d="M116.426 2.821l8.753 8.753-56.734 56.734-8.753-8.745z"/><path d="M127.893 37.982h-12.375V12.375H88.706V0h39.187z"/></svg>

After

Width:  |  Height:  |  Size: 285 B

1
src/icons/svg/nested.svg Normal file
View File

@@ -0,0 +1 @@
<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg"><path d="M.002 9.2c0 5.044 3.58 9.133 7.998 9.133 4.417 0 7.997-4.089 7.997-9.133 0-5.043-3.58-9.132-7.997-9.132S.002 4.157.002 9.2zM31.997.066h95.981V18.33H31.997V.066zm0 45.669c0 5.044 3.58 9.132 7.998 9.132 4.417 0 7.997-4.088 7.997-9.132 0-3.263-1.524-6.278-3.998-7.91-2.475-1.63-5.524-1.63-7.998 0-2.475 1.632-4 4.647-4 7.91zM63.992 36.6h63.986v18.265H63.992V36.6zm-31.995 82.2c0 5.043 3.58 9.132 7.998 9.132 4.417 0 7.997-4.089 7.997-9.132 0-5.044-3.58-9.133-7.997-9.133s-7.998 4.089-7.998 9.133zm31.995-9.131h63.986v18.265H63.992V109.67zm0-27.404c0 5.044 3.58 9.133 7.998 9.133 4.417 0 7.997-4.089 7.997-9.133 0-3.263-1.524-6.277-3.998-7.909-2.475-1.631-5.524-1.631-7.998 0-2.475 1.632-4 4.646-4 7.91zm31.995-9.13h31.991V91.4H95.987V73.135z"/></svg>

After

Width:  |  Height:  |  Size: 821 B

View File

@@ -0,0 +1 @@
<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg"><path d="M108.8 44.322H89.6v-5.36c0-9.04-3.308-24.163-25.6-24.163-23.145 0-25.6 16.881-25.6 24.162v5.361H19.2v-5.36C19.2 15.281 36.798 0 64 0c27.202 0 44.8 15.281 44.8 38.961v5.361zm-32 39.356c0-5.44-5.763-9.832-12.8-9.832-7.037 0-12.8 4.392-12.8 9.832 0 3.682 2.567 6.808 6.407 8.477v11.205c0 2.718 2.875 4.962 6.4 4.962 3.524 0 6.4-2.244 6.4-4.962V92.155c3.833-1.669 6.393-4.795 6.393-8.477zM128 64v49.201c0 8.158-8.645 14.799-19.2 14.799H19.2C8.651 128 0 121.359 0 113.201V64c0-8.153 8.645-14.799 19.2-14.799h89.6c10.555 0 19.2 6.646 19.2 14.799z"/></svg>

After

Width:  |  Height:  |  Size: 623 B

1
src/icons/svg/table.svg Normal file
View File

@@ -0,0 +1 @@
<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg"><path d="M.006.064h127.988v31.104H.006V.064zm0 38.016h38.396v41.472H.006V38.08zm0 48.384h38.396v41.472H.006V86.464zM44.802 38.08h38.396v41.472H44.802V38.08zm0 48.384h38.396v41.472H44.802V86.464zM89.598 38.08h38.396v41.472H89.598zm0 48.384h38.396v41.472H89.598z"/><path d="M.006.064h127.988v31.104H.006V.064zm0 38.016h38.396v41.472H.006V38.08zm0 48.384h38.396v41.472H.006V86.464zM44.802 38.08h38.396v41.472H44.802V38.08zm0 48.384h38.396v41.472H44.802V86.464zM89.598 38.08h38.396v41.472H89.598zm0 48.384h38.396v41.472H89.598z"/></svg>

After

Width:  |  Height:  |  Size: 597 B

1
src/icons/svg/tree.svg Normal file
View File

@@ -0,0 +1 @@
<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg"><path d="M126.713 90.023c.858.985 1.287 2.134 1.287 3.447v29.553c0 1.423-.429 2.6-1.287 3.53-.858.93-1.907 1.395-3.146 1.395H97.824c-1.145 0-2.146-.465-3.004-1.395-.858-.93-1.287-2.107-1.287-3.53V93.47c0-.875.19-1.696.572-2.462.382-.766.906-1.368 1.573-1.806a3.84 3.84 0 0 1 2.146-.657h9.725V69.007a3.84 3.84 0 0 0-.43-1.806 3.569 3.569 0 0 0-1.143-1.313 2.714 2.714 0 0 0-1.573-.492h-36.47v23.149h9.725c1.144 0 2.145.492 3.004 1.478.858.985 1.287 2.134 1.287 3.447v29.553c0 .876-.191 1.696-.573 2.463-.38.766-.905 1.368-1.573 1.806a3.84 3.84 0 0 1-2.145.656H51.915a3.84 3.84 0 0 1-2.145-.656c-.668-.438-1.216-1.04-1.645-1.806a4.96 4.96 0 0 1-.644-2.463V93.47c0-1.313.43-2.462 1.288-3.447.858-.986 1.907-1.478 3.146-1.478h9.582v-23.15h-37.9c-.953 0-1.74.356-2.359 1.068-.62.711-.93 1.56-.93 2.544v19.538h9.726c1.239 0 2.264.492 3.074 1.478.81.985 1.216 2.134 1.216 3.447v29.553c0 1.423-.405 2.6-1.216 3.53-.81.93-1.835 1.395-3.074 1.395H4.29c-.476 0-.93-.082-1.358-.246a4.1 4.1 0 0 1-1.144-.657 4.658 4.658 0 0 1-.93-1.067 5.186 5.186 0 0 1-.643-1.395 5.566 5.566 0 0 1-.215-1.56V93.47c0-.437.048-.875.143-1.313a3.95 3.95 0 0 1 .429-1.15c.19-.328.429-.656.715-.984.286-.329.572-.602.858-.821.286-.22.62-.383 1.001-.493.382-.11.763-.164 1.144-.164h9.726V61.619c0-.985.31-1.833.93-2.544.619-.712 1.358-1.068 2.216-1.068h44.335V39.62h-9.582c-1.24 0-2.288-.492-3.146-1.477a5.09 5.09 0 0 1-1.287-3.448V5.14c0-1.423.429-2.627 1.287-3.612.858-.985 1.907-1.477 3.146-1.477h25.743c.763 0 1.478.246 2.145.739a5.17 5.17 0 0 1 1.573 1.888c.382.766.573 1.587.573 2.462v29.553c0 1.313-.43 2.463-1.287 3.448-.859.985-1.86 1.477-3.004 1.477h-9.725v18.389h42.762c.954 0 1.74.355 2.36 1.067.62.711.93 1.56.93 2.545v26.925h9.582c1.239 0 2.288.492 3.146 1.478z"/></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

1
src/icons/svg/user.svg Normal file
View File

@@ -0,0 +1 @@
<svg width="130" height="130" xmlns="http://www.w3.org/2000/svg"><path d="M63.444 64.996c20.633 0 37.359-14.308 37.359-31.953 0-17.649-16.726-31.952-37.359-31.952-20.631 0-37.36 14.303-37.358 31.952 0 17.645 16.727 31.953 37.359 31.953zM80.57 75.65H49.434c-26.652 0-48.26 18.477-48.26 41.27v2.664c0 9.316 21.608 9.325 48.26 9.325H80.57c26.649 0 48.256-.344 48.256-9.325v-2.663c0-22.794-21.605-41.271-48.256-41.271z" stroke="#979797"/></svg>

After

Width:  |  Height:  |  Size: 440 B

22
src/icons/svgo.yml Normal file
View File

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

View File

@@ -0,0 +1,55 @@
<template>
<section class="app-main">
<transition name="fade-transform" mode="out-in">
<keep-alive :include="cachedViews">
<router-view :key="key" />
</keep-alive>
</transition>
</section>
</template>
<script>
export default {
name: 'AppMain',
computed: {
cachedViews() {
return this.$store.state.tagsView.cachedViews
},
key() {
return this.$route.path
}
}
}
</script>
<style scoped>
.app-main {
/*50 = navbar */
min-height: calc(100vh - 50px);
width: 100%;
position: relative;
overflow: hidden;
}
.fixed-header+.app-main {
padding-top: 50px;
}
.hasTagsView {
.app-main {
min-height: calc(100vh - 84px);
}
.fixed-header + .app-main {
padding-top: 84px;
}
}
</style>
<style lang="scss">
// fix css style bug in open el-dialog
.el-popup-parent--hidden {
.fixed-header {
padding-right: 15px;
}
}
</style>

View File

@@ -0,0 +1,139 @@
<template>
<div class="navbar">
<hamburger :is-active="sidebar.opened" class="hamburger-container" @toggleClick="toggleSideBar" />
<breadcrumb class="breadcrumb-container" />
<div class="right-menu">
<el-dropdown class="avatar-container" trigger="click">
<div class="avatar-wrapper">
<img :src="avatar+'?imageView2/1/w/80/h/80'" class="user-avatar">
<i class="el-icon-caret-bottom" />
</div>
<el-dropdown-menu slot="dropdown" class="user-dropdown">
<router-link to="/">
<el-dropdown-item>
Home
</el-dropdown-item>
</router-link>
<a target="_blank" href="https://github.com/PanJiaChen/vue-admin-template/">
<el-dropdown-item>Github</el-dropdown-item>
</a>
<a target="_blank" href="https://panjiachen.github.io/vue-element-admin-site/#/">
<el-dropdown-item>Docs</el-dropdown-item>
</a>
<el-dropdown-item divided @click.native="logout">
<span style="display:block;">Log Out</span>
</el-dropdown-item>
</el-dropdown-menu>
</el-dropdown>
</div>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import Breadcrumb from '@/components/Breadcrumb'
import Hamburger from '@/components/Hamburger'
export default {
components: {
Breadcrumb,
Hamburger
},
computed: {
...mapGetters([
'sidebar',
'avatar'
])
},
methods: {
toggleSideBar() {
this.$store.dispatch('app/toggleSideBar')
},
async logout() {
await this.$store.dispatch('user/logout')
this.$router.push(`/login?redirect=${this.$route.fullPath}`)
}
}
}
</script>
<style lang="scss" scoped>
.navbar {
height: 50px;
overflow: hidden;
position: relative;
background: #fff;
box-shadow: 0 1px 4px rgba(0,21,41,.08);
.hamburger-container {
line-height: 46px;
height: 100%;
float: left;
cursor: pointer;
transition: background .3s;
-webkit-tap-highlight-color:transparent;
&:hover {
background: rgba(0, 0, 0, .025)
}
}
.breadcrumb-container {
float: left;
}
.right-menu {
float: right;
height: 100%;
line-height: 50px;
&:focus {
outline: none;
}
.right-menu-item {
display: inline-block;
padding: 0 8px;
height: 100%;
font-size: 18px;
color: #5a5e66;
vertical-align: text-bottom;
&.hover-effect {
cursor: pointer;
transition: background .3s;
&:hover {
background: rgba(0, 0, 0, .025)
}
}
}
.avatar-container {
margin-right: 30px;
.avatar-wrapper {
margin-top: 5px;
position: relative;
.user-avatar {
cursor: pointer;
width: 40px;
height: 40px;
border-radius: 10px;
}
.el-icon-caret-bottom {
cursor: pointer;
position: absolute;
right: -20px;
top: 25px;
font-size: 12px;
}
}
}
}
}
</style>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,82 @@
<template>
<div class="sidebar-logo-container" :class="{'collapse':collapse}">
<transition name="sidebarLogoFade">
<router-link v-if="collapse" key="collapse" class="sidebar-logo-link" to="/">
<img v-if="logo" :src="logo" class="sidebar-logo">
<h1 v-else class="sidebar-title">{{ title }} </h1>
</router-link>
<router-link v-else key="expand" class="sidebar-logo-link" to="/">
<img v-if="logo" :src="logo" class="sidebar-logo">
<h1 class="sidebar-title">{{ title }} </h1>
</router-link>
</transition>
</div>
</template>
<script>
export default {
name: 'SidebarLogo',
props: {
collapse: {
type: Boolean,
required: true
}
},
data() {
return {
title: 'Vue Admin Template',
logo: 'https://wpimg.wallstcn.com/69a1c46c-eb1c-4b46-8bd4-e9e686ef5251.png'
}
}
}
</script>
<style lang="scss" scoped>
.sidebarLogoFade-enter-active {
transition: opacity 1.5s;
}
.sidebarLogoFade-enter,
.sidebarLogoFade-leave-to {
opacity: 0;
}
.sidebar-logo-container {
position: relative;
width: 100%;
height: 50px;
line-height: 50px;
background: #2b2f3a;
text-align: center;
overflow: hidden;
& .sidebar-logo-link {
height: 100%;
width: 100%;
& .sidebar-logo {
width: 32px;
height: 32px;
vertical-align: middle;
margin-right: 12px;
}
& .sidebar-title {
display: inline-block;
margin: 0;
color: #fff;
font-weight: 600;
line-height: 50px;
font-size: 14px;
font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;
vertical-align: middle;
}
}
&.collapse {
.sidebar-logo {
margin-right: 0px;
}
}
}
</style>

View File

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

View File

@@ -0,0 +1,56 @@
<template>
<div :class="{'has-logo':showLogo}">
<logo v-if="showLogo" :collapse="isCollapse" />
<el-scrollbar wrap-class="scrollbar-wrapper">
<el-menu
:default-active="activeMenu"
:collapse="isCollapse"
:background-color="variables.menuBg"
:text-color="variables.menuText"
:unique-opened="false"
:active-text-color="variables.menuActiveText"
:collapse-transition="false"
mode="vertical"
>
<sidebar-item v-for="route in routes" :key="route.path" :item="route" :base-path="route.path" />
</el-menu>
</el-scrollbar>
</div>
</template>
<script>
import { mapGetters } from 'vuex'
import Logo from './Logo'
import SidebarItem from './SidebarItem'
import variables from '@/styles/variables.scss'
export default {
components: { SidebarItem, Logo },
computed: {
...mapGetters([
'sidebar'
]),
routes() {
return this.$router.options.routes
},
activeMenu() {
const route = this.$route
const { meta, path } = route
// if set path, the sidebar will highlight the path you set
if (meta.activeMenu) {
return meta.activeMenu
}
return path
},
showLogo() {
return this.$store.state.settings.sidebarLogo
},
variables() {
return variables
},
isCollapse() {
return !this.sidebar.opened
}
}
}
</script>

View File

@@ -0,0 +1,97 @@
<template>
<el-scrollbar
ref="scrollContainer"
:vertical="false"
class="scroll-container"
@wheel.native.prevent="handleScroll"
>
<slot />
</el-scrollbar>
</template>
<script>
const tagAndTagSpacing = 4
export default {
name: 'ScrollPane',
computed: {
scrollWrapper() {
return this.$refs.scrollContainer.$refs.wrap
}
},
mounted() {
this.scrollWrapper.addEventListener('scroll', this.emitScroll, true)
},
beforeDestroy() {
this.scrollWrapper.removeEventListener('scroll', this.emitScroll)
},
methods: {
handleScroll(e) {
const eventDelta = e.wheelDelta || -e.deltaY * 40
const scrollWrapper = this.scrollWrapper
scrollWrapper.scrollLeft = scrollWrapper.scrollLeft + eventDelta / 4
},
emitScroll() {
this.$emit('scroll')
},
moveToTarget(currentTag) {
const container = this.$refs.scrollContainer.$el
const containerWidth = container.offsetWidth
const scrollWrapper = this.scrollWrapper
const tagList = this.$parent.$refs.tag || []
if (tagList.length === 0) {
return
}
const firstTag = tagList[0]
const lastTag = tagList[tagList.length - 1]
if (firstTag === currentTag) {
scrollWrapper.scrollLeft = 0
return
}
if (lastTag === currentTag) {
scrollWrapper.scrollLeft = scrollWrapper.scrollWidth - containerWidth
return
}
const currentIndex = tagList.findIndex(item => item === currentTag)
const prevTag = tagList[currentIndex - 1]
const nextTag = tagList[currentIndex + 1]
if (!prevTag || !nextTag) {
return
}
const afterNextTagOffsetLeft =
nextTag.$el.offsetLeft + nextTag.$el.offsetWidth + tagAndTagSpacing
const beforePrevTagOffsetLeft = prevTag.$el.offsetLeft - tagAndTagSpacing
if (afterNextTagOffsetLeft > scrollWrapper.scrollLeft + containerWidth) {
scrollWrapper.scrollLeft = afterNextTagOffsetLeft - containerWidth
} else if (beforePrevTagOffsetLeft < scrollWrapper.scrollLeft) {
scrollWrapper.scrollLeft = beforePrevTagOffsetLeft
}
}
}
}
</script>
<style lang="scss" scoped>
.scroll-container {
white-space: nowrap;
position: relative;
overflow: hidden;
width: 100%;
::v-deep .el-scrollbar__bar {
bottom: 0;
}
::v-deep .el-scrollbar__wrap {
height: 49px;
}
}
</style>

View File

@@ -0,0 +1,301 @@
<template>
<div class="tags-view-container" @click="closeMenu">
<scroll-pane ref="scrollPane" class="tags-view-wrapper" @scroll="closeMenu">
<router-link
v-for="tag in visitedViews"
ref="tag"
:key="tag.path"
:class="{ active: isActive(tag) }"
:to="{ path: tag.path, query: tag.query, fullPath: tag.fullPath }"
tag="span"
class="tags-view-item"
@click.middle.native="!isAffix(tag) ? closeSelectedTag(tag) : ''"
@contextmenu.prevent.native="openMenu(tag, $event)"
>
{{ tag.title }}
<span
v-if="!isAffix(tag)"
class="el-icon-close"
@click.prevent.stop="closeSelectedTag(tag)"
/>
</router-link>
</scroll-pane>
<ul
v-show="visible"
:style="{ left: left + 'px', top: top + 'px' }"
class="contextmenu"
>
<li @click="refreshSelectedTag(selectedTag)">Refresh</li>
<li v-if="selectedTag && !isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">Close</li>
<li @click="closeOthersTags">Close Others</li>
<li @click="closeAllTags">Close All</li>
</ul>
</div>
</template>
<script>
import path from 'path'
import ScrollPane from './ScrollPane'
import { constantRoutes } from '@/router'
export default {
name: 'TagsView',
components: { ScrollPane },
data() {
return {
affixTags: [],
visible: false,
top: 0,
left: 0,
selectedTag: null
}
},
computed: {
visitedViews() {
return this.$store.state.tagsView.visitedViews
}
},
watch: {
$route() {
this.addTags()
this.moveToCurrentTag()
},
visible(value) {
if (value) {
document.body.addEventListener('click', this.closeMenu)
} else {
document.body.removeEventListener('click', this.closeMenu)
}
}
},
mounted() {
this.initTags()
this.addTags()
},
beforeDestroy() {
document.body.removeEventListener('click', this.closeMenu)
},
methods: {
isActive(route) {
return route.path === this.$route.path
},
isAffix(tag) {
return tag.meta && tag.meta.affix
},
filterAffixTags(routes, basePath = '/') {
let tags = []
routes.forEach(route => {
if (route.meta && route.meta.affix) {
const tagPath = path.resolve(basePath, route.path)
tags.push({
fullPath: tagPath,
path: tagPath,
name: route.name,
meta: { ...route.meta }
})
}
if (route.children) {
const tempTags = this.filterAffixTags(route.children, route.path)
if (tempTags.length >= 1) {
tags = tags.concat(tempTags)
}
}
})
return tags
},
initTags() {
this.affixTags = this.filterAffixTags(constantRoutes)
this.affixTags.forEach(tag => {
if (tag.name) {
this.$store.dispatch('tagsView/addVisitedView', tag)
}
})
},
addTags() {
if (this.$route.name) {
this.$store.dispatch('tagsView/addView', this.$route)
}
},
moveToCurrentTag() {
this.$nextTick(() => {
const tags = this.$refs.tag || []
tags.forEach(tag => {
if (tag.to.path === this.$route.path) {
this.$refs.scrollPane.moveToTarget(tag)
if (tag.to.fullPath !== this.$route.fullPath) {
this.$store.dispatch('tagsView/updateVisitedView', this.$route)
}
}
})
})
},
closeSelectedTag(view) {
this.closeMenu()
this.$store.dispatch('tagsView/delView', view).then(({ visitedViews }) => {
if (this.isActive(view)) {
this.toLastView(visitedViews, view)
}
})
},
refreshSelectedTag(view) {
this.closeMenu()
this.$store.dispatch('tagsView/delCachedView', view).then(() => {
this.$nextTick(() => {
this.$router.replace({
path: '/redirect' + view.fullPath
})
})
})
},
closeOthersTags() {
if (!this.selectedTag) {
return
}
this.$router.push(this.selectedTag)
this.$store.dispatch('tagsView/delOthersViews', this.selectedTag).then(() => {
this.moveToCurrentTag()
})
this.closeMenu()
},
closeAllTags() {
const view = this.selectedTag || this.$route
this.$store.dispatch('tagsView/delAllViews').then(({ visitedViews }) => {
if (this.affixTags.some(tag => tag.path === view.path)) {
this.closeMenu()
return
}
this.toLastView(visitedViews, view)
this.closeMenu()
})
},
toLastView(visitedViews, view) {
const latestView = visitedViews.slice(-1)[0]
if (latestView) {
this.$router.push(latestView.fullPath || latestView.path)
} else {
if (view && view.name === 'Dashboard') {
this.$router.replace({ path: '/redirect' + view.fullPath })
} else {
this.$router.push('/')
}
}
},
openMenu(tag, e) {
const menuMinWidth = 105
const offsetLeft = this.$el.getBoundingClientRect().left
const offsetWidth = this.$el.offsetWidth
const maxLeft = offsetWidth - menuMinWidth
const left = e.clientX - offsetLeft + 15
this.left = left > maxLeft ? maxLeft : left
this.top = e.clientY
this.visible = true
this.selectedTag = tag
},
closeMenu() {
this.visible = false
}
}
}
</script>
<style lang="scss" scoped>
.tags-view-container {
height: 34px;
width: 100%;
background: #fff;
border-bottom: 1px solid #d8dce5;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.12), 0 0 3px 0 rgba(0, 0, 0, 0.04);
.tags-view-item {
display: inline-block;
position: relative;
cursor: pointer;
height: 26px;
line-height: 26px;
border: 1px solid #d8dce5;
color: #495060;
background: #fff;
padding: 0 8px;
font-size: 12px;
margin-left: 5px;
margin-top: 4px;
&:first-of-type {
margin-left: 15px;
}
&:last-of-type {
margin-right: 15px;
}
&.active {
background-color: #409eff;
color: #fff;
border-color: #409eff;
&::before {
content: '';
background: #fff;
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
position: relative;
margin-right: 4px;
}
}
}
.contextmenu {
margin: 0;
background: #fff;
z-index: 3000;
position: absolute;
list-style-type: none;
padding: 5px 0;
border-radius: 4px;
font-size: 12px;
font-weight: 400;
color: #333;
box-shadow: 2px 2px 3px 0 rgba(0, 0, 0, 0.3);
li {
margin: 0;
padding: 7px 16px;
cursor: pointer;
&:hover {
background: #eee;
}
}
}
}
</style>
<style lang="scss">
.tags-view-wrapper {
.tags-view-item {
.el-icon-close {
width: 16px;
height: 16px;
vertical-align: 2px;
border-radius: 50%;
text-align: center;
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
transform-origin: 100% 50%;
&:before {
transform: scale(0.6);
display: inline-block;
vertical-align: -3px;
}
&:hover {
background-color: #b4bccc;
color: #fff;
}
}
}
}
</style>

View File

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

98
src/layout/index.vue Normal file
View File

@@ -0,0 +1,98 @@
<template>
<div :class="classObj" class="app-wrapper">
<div v-if="device==='mobile'&&sidebar.opened" class="drawer-bg" @click="handleClickOutside" />
<sidebar class="sidebar-container" />
<div :class="{ hasTagsView: needTagsView }" class="main-container">
<div :class="{'fixed-header':fixedHeader}">
<navbar />
<tags-view v-if="needTagsView" />
</div>
<app-main />
</div>
</div>
</template>
<script>
import { Navbar, Sidebar, AppMain, TagsView } from './components'
import ResizeMixin from './mixin/ResizeHandler'
export default {
name: 'Layout',
components: {
Navbar,
Sidebar,
AppMain,
TagsView
},
mixins: [ResizeMixin],
computed: {
sidebar() {
return this.$store.state.app.sidebar
},
device() {
return this.$store.state.app.device
},
fixedHeader() {
return this.$store.state.settings.fixedHeader
},
needTagsView() {
return this.$store.state.settings.tagsView
},
classObj() {
return {
hideSidebar: !this.sidebar.opened,
openSidebar: this.sidebar.opened,
withoutAnimation: this.sidebar.withoutAnimation,
mobile: this.device === 'mobile'
}
}
},
methods: {
handleClickOutside() {
this.$store.dispatch('app/closeSideBar', { withoutAnimation: false })
}
}
}
</script>
<style lang="scss" scoped>
@import "~@/styles/mixin.scss";
@import "~@/styles/variables.scss";
.app-wrapper {
@include clearfix;
position: relative;
height: 100%;
width: 100%;
&.mobile.openSidebar{
position: fixed;
top: 0;
}
}
.drawer-bg {
background: #000;
opacity: 0.3;
width: 100%;
top: 0;
height: 100%;
position: absolute;
z-index: 999;
}
.fixed-header {
position: fixed;
top: 0;
right: 0;
z-index: 9;
width: calc(100% - #{$sideBarWidth});
transition: width 0.28s;
}
.hideSidebar .fixed-header {
width: calc(100% - 54px)
}
.mobile .fixed-header {
width: 100%;
}
</style>

View File

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

43
src/main.js Normal file
View File

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

64
src/permission.js Normal file
View File

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

192
src/router/index.js Normal file
View File

@@ -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 <keep-alive> (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

22
src/settings.js Normal file
View File

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

10
src/store/getters.js Normal file
View File

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

21
src/store/index.js Normal file
View File

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

48
src/store/modules/app.js Normal file
View File

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

View File

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

View File

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

97
src/store/modules/user.js Normal file
View File

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

View File

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

66
src/styles/index.scss Normal file
View File

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

28
src/styles/mixin.scss Normal file
View File

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

315
src/styles/prototype.scss Normal file
View File

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

226
src/styles/sidebar.scss Normal file
View File

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

View File

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

25
src/styles/variables.scss Normal file
View File

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

15
src/utils/auth.js Normal file
View File

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

View File

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

117
src/utils/index.js Normal file
View File

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

85
src/utils/request.js Normal file
View File

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

20
src/utils/validate.js Normal file
View File

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

228
src/views/404.vue Normal file
View File

@@ -0,0 +1,228 @@
<template>
<div class="wscn-http404-container">
<div class="wscn-http404">
<div class="pic-404">
<img class="pic-404__parent" src="@/assets/404_images/404.png" alt="404">
<img class="pic-404__child left" src="@/assets/404_images/404_cloud.png" alt="404">
<img class="pic-404__child mid" src="@/assets/404_images/404_cloud.png" alt="404">
<img class="pic-404__child right" src="@/assets/404_images/404_cloud.png" alt="404">
</div>
<div class="bullshit">
<div class="bullshit__oops">OOPS!</div>
<div class="bullshit__info">All rights reserved
<a style="color:#20a0ff" href="https://wallstreetcn.com" target="_blank">wallstreetcn</a>
</div>
<div class="bullshit__headline">{{ message }}</div>
<div class="bullshit__info">Please check that the URL you entered is correct, or click the button below to return to the homepage.</div>
<a href="" class="bullshit__return-home">Back to home</a>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'Page404',
computed: {
message() {
return 'The webmaster said that you can not enter this page...'
}
}
}
</script>
<style lang="scss" scoped>
.wscn-http404-container{
transform: translate(-50%,-50%);
position: absolute;
top: 40%;
left: 50%;
}
.wscn-http404 {
position: relative;
width: 1200px;
padding: 0 50px;
overflow: hidden;
.pic-404 {
position: relative;
float: left;
width: 600px;
overflow: hidden;
&__parent {
width: 100%;
}
&__child {
position: absolute;
&.left {
width: 80px;
top: 17px;
left: 220px;
opacity: 0;
animation-name: cloudLeft;
animation-duration: 2s;
animation-timing-function: linear;
animation-fill-mode: forwards;
animation-delay: 1s;
}
&.mid {
width: 46px;
top: 10px;
left: 420px;
opacity: 0;
animation-name: cloudMid;
animation-duration: 2s;
animation-timing-function: linear;
animation-fill-mode: forwards;
animation-delay: 1.2s;
}
&.right {
width: 62px;
top: 100px;
left: 500px;
opacity: 0;
animation-name: cloudRight;
animation-duration: 2s;
animation-timing-function: linear;
animation-fill-mode: forwards;
animation-delay: 1s;
}
@keyframes cloudLeft {
0% {
top: 17px;
left: 220px;
opacity: 0;
}
20% {
top: 33px;
left: 188px;
opacity: 1;
}
80% {
top: 81px;
left: 92px;
opacity: 1;
}
100% {
top: 97px;
left: 60px;
opacity: 0;
}
}
@keyframes cloudMid {
0% {
top: 10px;
left: 420px;
opacity: 0;
}
20% {
top: 40px;
left: 360px;
opacity: 1;
}
70% {
top: 130px;
left: 180px;
opacity: 1;
}
100% {
top: 160px;
left: 120px;
opacity: 0;
}
}
@keyframes cloudRight {
0% {
top: 100px;
left: 500px;
opacity: 0;
}
20% {
top: 120px;
left: 460px;
opacity: 1;
}
80% {
top: 180px;
left: 340px;
opacity: 1;
}
100% {
top: 200px;
left: 300px;
opacity: 0;
}
}
}
}
.bullshit {
position: relative;
float: left;
width: 300px;
padding: 30px 0;
overflow: hidden;
&__oops {
font-size: 32px;
font-weight: bold;
line-height: 40px;
color: #1482f0;
opacity: 0;
margin-bottom: 20px;
animation-name: slideUp;
animation-duration: 0.5s;
animation-fill-mode: forwards;
}
&__headline {
font-size: 20px;
line-height: 24px;
color: #222;
font-weight: bold;
opacity: 0;
margin-bottom: 10px;
animation-name: slideUp;
animation-duration: 0.5s;
animation-delay: 0.1s;
animation-fill-mode: forwards;
}
&__info {
font-size: 13px;
line-height: 21px;
color: grey;
opacity: 0;
margin-bottom: 30px;
animation-name: slideUp;
animation-duration: 0.5s;
animation-delay: 0.2s;
animation-fill-mode: forwards;
}
&__return-home {
display: block;
float: left;
width: 110px;
height: 36px;
background: #1482f0;
border-radius: 100px;
text-align: center;
color: #ffffff;
opacity: 0;
font-size: 14px;
line-height: 36px;
cursor: pointer;
animation-name: slideUp;
animation-duration: 0.5s;
animation-delay: 0.3s;
animation-fill-mode: forwards;
}
@keyframes slideUp {
0% {
transform: translateY(60px);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
}
}
</style>

View File

@@ -0,0 +1,273 @@
<template>
<div class="prototype-page">
<proto-page-header
eyebrow="Control Plane"
title="softbus_daemon 总览"
description="围绕系统状态、规则注册、Discovery 活跃度与 Pipeline 运行质量构建首页总览,帮助运维与接入人员在一个页面内完成状态判断与任务分发。"
tag="高保真原型"
tag-type="primary"
>
<template slot="actions">
<el-tooltip content="后端当前未提供状态快照导出接口" placement="top">
<span>
<el-button plain disabled>导出状态快照</el-button>
</span>
</el-tooltip>
<el-button type="primary" @click="$router.push('/discovery/index')">进入发现接入</el-button>
</template>
</proto-page-header>
<div v-loading="loading" class="prototype-grid metrics">
<proto-metric-card
v-for="item in statusCards"
:key="item.label"
:label="item.label"
:value="item.value"
:trend="item.trend"
:trend-type="item.trendType"
:hint="item.hint"
/>
</div>
<div class="prototype-grid two-columns dashboard-main">
<proto-section-card
title="Pipeline 计数器"
subtitle="对应 /api/v1/pipeline/counters 与 /api/v1/pipeline/status用于体现吞吐、丢弃与错误情况。"
>
<div class="proto-kpi-strip">
<div
v-for="item in pipelineCounters"
:key="item.label"
:class="['proto-kpi', 'accent-' + item.accent]"
>
<div class="label">{{ item.label }}</div>
<div class="value">{{ item.value }}</div>
</div>
</div>
<div class="mock-chart">
<div v-for="(bar, index) in chartBars" :key="index" class="bar-wrap">
<div class="bar" :style="{ height: bar + '%' }" />
</div>
</div>
</proto-section-card>
<proto-section-card
title="系统控制面"
subtitle="汇总 /v1/status、/health、D-Bus 健康探针与版本信息。"
>
<div class="proto-highlight">
<div class="proto-status-row">
<el-tag :type="serviceStatus.running ? 'success' : 'danger'">
{{ serviceStatus.running ? 'CoreService 已运行' : 'CoreService 异常' }}
</el-tag>
<el-tag type="primary">Version {{ serviceStatus.version }}</el-tag>
<el-tag type="info">PID {{ serviceStatus.pid }}</el-tag>
<el-tag :type="serviceStatus.discoveryEnabled ? 'warning' : 'info'">
{{ serviceStatus.discoveryEnabled ? 'Discovery 开启' : 'Discovery 关闭' }}
</el-tag>
</div>
<div class="status-copy">
当前控制面显示规则计数mapping 状态与 discovery 进度可作为首页首屏的操作引导区
</div>
</div>
<div class="proto-list compact">
<div class="proto-list-item">
<div class="list-title">健康检查</div>
<div class="list-value">{{ serviceStatus.health ? '/health 返回 ok' : '/health 返回异常' }}最近状态时间 {{ serviceStatus.timestamp }}</div>
</div>
<div class="proto-list-item">
<div class="list-title">运行角色</div>
<div class="list-value">role = {{ serviceStatus.role }}running = {{ serviceStatus.running }}timestamp 已同步</div>
</div>
<div class="proto-list-item">
<div class="list-title">队列容量</div>
<div class="list-value">session = {{ pipelineStatus.framerSessionCount }}queue = {{ serviceStatus.queueUsage }}插件摘要已加载</div>
</div>
</div>
</proto-section-card>
</div>
<div class="prototype-grid two-columns">
<proto-section-card
title="规则状态摘要"
subtitle="区分已生效能力与注册中能力,避免 filter 能力误导用户。"
>
<div class="rule-summary-grid">
<div v-for="item in ruleSummary" :key="item.label" class="rule-summary-card">
<div class="rule-top">
<span class="rule-name">{{ item.label }}</span>
<el-tag size="mini" :type="item.status">{{ item.value }}</el-tag>
</div>
<div class="rule-note">{{ item.note }}</div>
</div>
</div>
<el-alert
title="转换规则已经进入执行链路,可作为高频配置入口;过滤规则需通过显式状态标识说明其当前能力边界。"
type="info"
:closable="false"
show-icon
/>
</proto-section-card>
<proto-section-card
title="近期待办与风险提示"
subtitle="把接口文档中的实现差异、上线建议和工作流提醒聚合到首页。"
>
<div class="proto-list">
<div v-for="item in dashboardAlerts" :key="item.title" class="proto-list-item alert-item">
<div class="proto-status-row">
<el-tag size="mini" :type="item.level">{{ item.title }}</el-tag>
</div>
<div class="proto-rule-note">{{ item.detail }}</div>
</div>
</div>
</proto-section-card>
</div>
</div>
</template>
<script>
import ProtoMetricCard from '@/views/prototype/components/ProtoMetricCard'
import ProtoPageHeader from '@/views/prototype/components/ProtoPageHeader'
import ProtoSectionCard from '@/views/prototype/components/ProtoSectionCard'
import { getDashboardSummary } from '@/api/softbus'
export default {
name: 'Dashboard',
components: {
ProtoMetricCard,
ProtoPageHeader,
ProtoSectionCard
},
data() {
return {
dashboardAlerts: [],
pipelineCounters: [],
pipelineStatus: {},
ruleSummary: [],
serviceStatus: {},
statusCards: [],
loading: false,
chartBars: [42, 66, 58, 82, 64, 76, 88, 71, 54, 79, 68, 84]
}
},
created() {
this.fetchData()
},
methods: {
fetchData() {
this.loading = true
getDashboardSummary().then(data => {
this.statusCards = data.statusCards || []
this.pipelineCounters = data.pipelineCounters || []
this.ruleSummary = data.ruleSummary || []
this.dashboardAlerts = data.alerts || []
this.serviceStatus = data.serviceStatus || {}
this.pipelineStatus = data.pipelineStatus || {}
}).finally(() => {
this.loading = false
})
}
}
}
</script>
<style lang="scss" scoped>
.dashboard-main {
margin: 16px 0;
}
.mock-chart {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 10px;
align-items: end;
height: 190px;
margin-top: 22px;
padding: 18px 0 4px;
.bar-wrap {
display: flex;
align-items: flex-end;
justify-content: center;
height: 100%;
border-radius: 12px;
background: linear-gradient(180deg, rgba(64, 158, 255, 0.08), rgba(103, 194, 58, 0.02));
}
.bar {
width: 100%;
border-radius: 12px 12px 4px 4px;
background: linear-gradient(180deg, #73b8ff 0%, #409eff 60%, #3a8ee6 100%);
}
}
.status-copy {
margin-top: 14px;
color: #6b778c;
font-size: 13px;
line-height: 1.8;
}
.compact .proto-list-item {
padding: 14px 16px;
}
.list-title {
color: #8b95a7;
font-size: 12px;
}
.list-value {
margin-top: 8px;
color: #243243;
font-size: 14px;
font-weight: 600;
line-height: 1.7;
}
.rule-summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin-bottom: 18px;
}
.rule-summary-card {
padding: 18px;
border: 1px solid #edf1f7;
border-radius: 18px;
background: #fbfcfe;
}
.rule-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.rule-name {
color: #243243;
font-size: 15px;
font-weight: 700;
}
.rule-note {
margin-top: 12px;
color: #8b95a7;
font-size: 12px;
line-height: 1.7;
}
.alert-item {
background: #fff;
}
@media (max-width: 1200px) {
.rule-summary-grid {
grid-template-columns: 1fr;
}
}
</style>

25
src/views/form/index.vue Normal file
View File

@@ -0,0 +1,25 @@
<template>
<div class="app-container form-placeholder">
<el-card shadow="never">
<div slot="header">Form 示例占位页</div>
<p>当前仓库的路由中仍然保留了 `Form` 菜单但对应页面文件缺失</p>
<p>这里补一个最小占位页保证项目可以正常编译并继续扩展业务表单</p>
</el-card>
</div>
</template>
<script>
export default {
name: 'FormPlaceholder'
}
</script>
<style lang="scss" scoped>
.form-placeholder {
p {
margin: 0 0 12px;
color: #606266;
line-height: 1.8;
}
}
</style>

237
src/views/login/index.vue Normal file
View File

@@ -0,0 +1,237 @@
<template>
<div class="login-container">
<el-form ref="loginForm" :model="loginForm" :rules="loginRules" class="login-form" auto-complete="on" label-position="left">
<div class="title-container">
<h3 class="title">Login Form</h3>
</div>
<el-form-item prop="username">
<span class="svg-container">
<svg-icon icon-class="user" />
</span>
<el-input
ref="username"
v-model="loginForm.username"
placeholder="Username"
name="username"
type="text"
tabindex="1"
auto-complete="on"
/>
</el-form-item>
<el-form-item prop="password">
<span class="svg-container">
<svg-icon icon-class="password" />
</span>
<el-input
:key="passwordType"
ref="password"
v-model="loginForm.password"
:type="passwordType"
placeholder="Password"
name="password"
tabindex="2"
auto-complete="on"
@keyup.enter.native="handleLogin"
/>
<span class="show-pwd" @click="showPwd">
<svg-icon :icon-class="passwordType === 'password' ? 'eye' : 'eye-open'" />
</span>
</el-form-item>
<el-button :loading="loading" type="primary" style="width:100%;margin-bottom:30px;" @click.native.prevent="handleLogin">Login</el-button>
<div class="tips">
<span style="margin-right:20px;">username: admin</span>
<span> password: any</span>
</div>
</el-form>
</div>
</template>
<script>
import { validUsername } from '@/utils/validate'
export default {
name: 'Login',
data() {
const validateUsername = (rule, value, callback) => {
if (!validUsername(value)) {
callback(new Error('Please enter the correct user name'))
} else {
callback()
}
}
const validatePassword = (rule, value, callback) => {
if (value.length < 6) {
callback(new Error('The password can not be less than 6 digits'))
} else {
callback()
}
}
return {
loginForm: {
username: 'admin',
password: '111111'
},
loginRules: {
username: [{ required: true, trigger: 'blur', validator: validateUsername }],
password: [{ required: true, trigger: 'blur', validator: validatePassword }]
},
loading: false,
passwordType: 'password',
redirect: undefined
}
},
watch: {
$route: {
handler: function(route) {
this.redirect = route.query && route.query.redirect
},
immediate: true
}
},
methods: {
showPwd() {
if (this.passwordType === 'password') {
this.passwordType = ''
} else {
this.passwordType = 'password'
}
this.$nextTick(() => {
this.$refs.password.focus()
})
},
handleLogin() {
this.$refs.loginForm.validate(valid => {
if (valid) {
this.loading = true
this.$store.dispatch('user/login', this.loginForm).then(() => {
this.$router.push({ path: this.redirect || '/' })
this.loading = false
}).catch(() => {
this.loading = false
})
} else {
console.log('error submit!!')
return false
}
})
}
}
}
</script>
<style lang="scss">
/* 修复input 背景不协调 和光标变色 */
/* Detail see https://github.com/PanJiaChen/vue-element-admin/pull/927 */
$bg:#283443;
$light_gray:#fff;
$cursor: #fff;
@supports (-webkit-mask: none) and (not (cater-color: $cursor)) {
.login-container .el-input input {
color: $cursor;
}
}
/* reset element-ui css */
.login-container {
.el-input {
display: inline-block;
height: 47px;
width: 85%;
input {
background: transparent;
border: 0px;
-webkit-appearance: none;
border-radius: 0px;
padding: 12px 5px 12px 15px;
color: $light_gray;
height: 47px;
caret-color: $cursor;
&:-webkit-autofill {
box-shadow: 0 0 0px 1000px $bg inset !important;
-webkit-text-fill-color: $cursor !important;
}
}
}
.el-form-item {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.1);
border-radius: 5px;
color: #454545;
}
}
</style>
<style lang="scss" scoped>
$bg:#2d3a4b;
$dark_gray:#889aa4;
$light_gray:#eee;
.login-container {
min-height: 100%;
width: 100%;
background-color: $bg;
overflow: hidden;
.login-form {
position: relative;
width: 520px;
max-width: 100%;
padding: 160px 35px 0;
margin: 0 auto;
overflow: hidden;
}
.tips {
font-size: 14px;
color: #fff;
margin-bottom: 10px;
span {
&:first-of-type {
margin-right: 16px;
}
}
}
.svg-container {
padding: 6px 5px 6px 15px;
color: $dark_gray;
vertical-align: middle;
width: 30px;
display: inline-block;
}
.title-container {
position: relative;
.title {
font-size: 26px;
color: $light_gray;
margin: 0px auto 40px auto;
text-align: center;
font-weight: bold;
}
}
.show-pwd {
position: absolute;
right: 10px;
top: 7px;
font-size: 16px;
color: $dark_gray;
cursor: pointer;
user-select: none;
}
}
</style>

View File

@@ -0,0 +1,38 @@
<template>
<div class="proto-metric-card">
<div class="metric-label">{{ label }}</div>
<div class="metric-value">{{ value }}</div>
<div class="metric-footer">
<span :class="['metric-trend', trendType]">{{ trend }}</span>
<span class="metric-hint">{{ hint }}</span>
</div>
</div>
</template>
<script>
export default {
name: 'ProtoMetricCard',
props: {
label: {
type: String,
default: ''
},
value: {
type: String,
default: ''
},
trend: {
type: String,
default: ''
},
trendType: {
type: String,
default: 'neutral'
},
hint: {
type: String,
default: ''
}
}
}
</script>

View File

@@ -0,0 +1,44 @@
<template>
<div class="proto-page-header">
<div>
<div class="eyebrow">{{ eyebrow }}</div>
<div class="title-row">
<h2>{{ title }}</h2>
<el-tag v-if="tag" size="mini" :type="tagType">{{ tag }}</el-tag>
</div>
<p>{{ description }}</p>
</div>
<div class="actions">
<el-tag size="mini" type="success">真实后端</el-tag>
<slot name="actions" />
</div>
</div>
</template>
<script>
export default {
name: 'ProtoPageHeader',
props: {
eyebrow: {
type: String,
default: ''
},
title: {
type: String,
default: ''
},
description: {
type: String,
default: ''
},
tag: {
type: String,
default: ''
},
tagType: {
type: String,
default: ''
}
}
}
</script>

View File

@@ -0,0 +1,32 @@
<template>
<div class="proto-section-card">
<div class="section-head">
<div>
<div class="section-title">{{ title }}</div>
<div v-if="subtitle" class="section-subtitle">{{ subtitle }}</div>
</div>
<div v-if="$slots.extra" class="section-extra">
<slot name="extra" />
</div>
</div>
<div class="section-body">
<slot />
</div>
</div>
</template>
<script>
export default {
name: 'ProtoSectionCard',
props: {
title: {
type: String,
default: ''
},
subtitle: {
type: String,
default: ''
}
}
}
</script>

View File

@@ -0,0 +1,300 @@
<template>
<div class="prototype-page">
<proto-page-header
:eyebrow="eyebrow"
:title="title"
:description="description"
:tag="tag"
:tag-type="tagType"
>
<template slot="actions">
<el-button plain :disabled="disableSecondaryAction" @click="$emit('secondary-action')">{{ secondaryAction }}</el-button>
<el-button type="primary" :disabled="disablePrimaryAction" @click="$emit('primary-action')">{{ primaryAction }}</el-button>
</template>
</proto-page-header>
<proto-section-card :title="sectionTitle" :subtitle="sectionSubtitle">
<div v-if="summaryItems.length" class="summary-strip">
<div v-for="item in summaryItems" :key="item.label" class="summary-item">
<div class="summary-label">{{ item.label }}</div>
<div class="summary-value">{{ item.value }}</div>
</div>
</div>
<div class="proto-toolbar">
<div class="proto-filter-group">
<el-input v-model="keyword" :disabled="disableList" placeholder="搜索 ID / key / domPath" clearable class="toolbar-item" />
<el-select v-model="status" :disabled="disableList" placeholder="状态" class="toolbar-item">
<el-option label="全部状态" value="all" />
<el-option label="已生效" value="active" />
<el-option label="注册中" value="registered" />
<el-option label="已绑定" value="bound" />
</el-select>
</div>
<div class="proto-status-row">
<el-tag size="mini" :type="tagType">{{ tag }}</el-tag>
<el-tag size="mini" type="info">{{ modeLabel }}</el-tag>
</div>
</div>
<el-alert
v-if="disableList && disabledReason"
:title="disabledReason"
type="warning"
:closable="false"
show-icon
class="disabled-alert"
/>
<el-table v-if="!disableList" v-loading="loading" :data="filteredTableData" border>
<el-table-column v-for="column in columns" :key="column.prop" :prop="column.prop" :label="column.label" min-width="120" />
<el-table-column label="状态" width="110">
<template slot-scope="scope">
<el-tag size="mini" :type="statusType(scope.row.status)">{{ scope.row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="updatedAt" label="更新时间" width="160" />
<el-table-column label="操作" width="160" align="center">
<template slot-scope="scope">
<el-button type="text" size="mini">{{ detailAction(scope.row) }}</el-button>
<el-button type="text" size="mini" class="danger-action" @click="$emit('delete-item', scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</proto-section-card>
<div class="prototype-grid two-columns rule-detail-layout">
<proto-section-card :title="formTitle" :subtitle="drawerSubtitle">
<el-form label-position="top">
<el-form-item v-for="field in formFields" :key="field.label" :label="field.label">
<el-input v-model="localForm[field.prop]" :disabled="field.disabled" />
</el-form-item>
</el-form>
<div class="proto-status-row">
<el-button :disabled="disableSecondaryAction" @click="$emit('secondary-action')">{{ secondaryAction }}</el-button>
<el-button type="primary" :disabled="disablePrimaryAction" @click="submitPrimary">{{ primaryAction }}</el-button>
</div>
</proto-section-card>
<proto-section-card title="视觉与交互说明" subtitle="统一使用筛选区 + 表格 + 右侧抽屉的中后台模式。">
<div class="proto-list">
<div class="proto-list-item">
<div class="list-title">筛选区</div>
<div class="list-value">统一承载 IDendpointHashstableKeydomPath 等关键字段筛选</div>
</div>
<div class="proto-list-item">
<div class="list-title">表格区</div>
<div class="list-value">突出 scope换算参数单位与状态便于快速定位规则差异</div>
</div>
<div class="proto-list-item">
<div class="list-title">抽屉表单</div>
<div class="list-value">适合承载创建编辑和参数说明避免频繁跳页破坏工作流连续性</div>
</div>
<div class="proto-list-item">
<div class="list-title">状态标签</div>
<div class="list-value">{{ note }}</div>
</div>
</div>
</proto-section-card>
</div>
</div>
</template>
<script>
import ProtoPageHeader from '@/views/prototype/components/ProtoPageHeader'
import ProtoSectionCard from '@/views/prototype/components/ProtoSectionCard'
export default {
name: 'RuleWorkspace',
components: {
ProtoPageHeader,
ProtoSectionCard
},
props: {
eyebrow: {
type: String,
default: ''
},
title: {
type: String,
default: ''
},
description: {
type: String,
default: ''
},
tag: {
type: String,
default: ''
},
tagType: {
type: String,
default: 'primary'
},
sectionTitle: {
type: String,
default: ''
},
sectionSubtitle: {
type: String,
default: ''
},
drawerSubtitle: {
type: String,
default: ''
},
primaryAction: {
type: String,
default: '新建'
},
secondaryAction: {
type: String,
default: '批量导出'
},
columns: {
type: Array,
default: () => []
},
tableData: {
type: Array,
default: () => []
},
formFields: {
type: Array,
default: () => []
},
formModel: {
type: Object,
default: () => ({})
},
note: {
type: String,
default: ''
},
loading: {
type: Boolean,
default: false
},
modeLabel: {
type: String,
default: '真实后端'
},
summaryItems: {
type: Array,
default: () => []
},
disableList: {
type: Boolean,
default: false
},
disabledReason: {
type: String,
default: ''
},
disablePrimaryAction: {
type: Boolean,
default: false
},
disableSecondaryAction: {
type: Boolean,
default: false
},
formTitle: {
type: String,
default: '配置表单'
}
},
data() {
return {
keyword: '',
status: 'all',
localForm: { ...this.formModel }
}
},
computed: {
filteredTableData() {
return this.tableData.filter(item => {
const matchesStatus = this.status === 'all' || item.status === this.status
const source = Object.values(item).join(' ').toLowerCase()
const matchesKeyword = !this.keyword || source.indexOf(this.keyword.toLowerCase()) > -1
return matchesStatus && matchesKeyword
})
}
},
watch: {
formModel: {
deep: true,
handler(value) {
this.localForm = { ...value }
}
}
},
methods: {
statusType(status) {
const map = {
active: 'success',
registered: 'warning',
bound: 'primary'
}
return map[status] || 'info'
},
detailAction(row) {
return row.compositeKey ? '查看绑定' : '查看详情'
},
submitPrimary() {
this.$emit('submit-form', { ...this.localForm })
}
}
}
</script>
<style lang="scss" scoped>
.toolbar-item {
width: 220px;
}
.rule-detail-layout {
margin-top: 16px;
}
.danger-action {
color: #f56c6c;
}
.summary-strip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.summary-item {
padding: 14px 16px;
border-radius: 14px;
background: #f7f9fc;
}
.summary-label,
.list-title {
color: #8b95a7;
font-size: 12px;
}
.summary-value {
margin-top: 8px;
color: #243243;
font-size: 20px;
font-weight: 700;
}
.disabled-alert {
margin-bottom: 16px;
}
.list-value {
margin-top: 8px;
color: #243243;
font-size: 14px;
font-weight: 600;
line-height: 1.7;
}
</style>

View File

@@ -0,0 +1,242 @@
<template>
<div class="prototype-page">
<proto-page-header
eyebrow="Device Center"
title="设备管理"
description="统一查看设备树、运行态设备、节点详情和可编辑配置字段,兼顾结构浏览与运行排查。"
tag="规划页已落地"
tag-type="primary"
>
<template slot="actions">
<el-button plain @click="fetchData">刷新设备树</el-button>
<el-button type="primary" :disabled="!selectedNode" @click="openEditor">编辑当前节点</el-button>
</template>
</proto-page-header>
<div class="prototype-grid two-columns">
<proto-section-card title="设备树" subtitle="对应 /api/v1/device-tree左侧展示结构树和节点基础状态。">
<el-tree
v-loading="loading"
:data="tree"
:props="defaultProps"
node-key="id"
highlight-current
default-expand-all
@node-click="handleNodeClick"
>
<span slot-scope="{ data }" class="tree-node">
<span>{{ data.name }}</span>
<el-tag size="mini" :type="data.params && data.params.online ? 'success' : 'info'">
{{ data.type }}
</el-tag>
</span>
</el-tree>
</proto-section-card>
<proto-section-card title="节点详情" subtitle="展示基础信息、运行态和只读字段限制。">
<div v-if="selectedNode" class="proto-field-grid">
<div v-for="item in nodeFields" :key="item.label" class="proto-field">
<div class="label">{{ item.label }}</div>
<div class="value">{{ item.value }}</div>
</div>
</div>
<el-alert
title="以下字段应在前端保持只读id、params.stableKey、params.online、params.lastSeenTs。"
type="warning"
:closable="false"
show-icon
/>
</proto-section-card>
</div>
<div class="prototype-grid two-columns devices-footer">
<proto-section-card title="运行态设备列表" subtitle="对应 /api/v1/devices 与 /api/v1/devices/{id}。">
<el-table v-loading="loading" :data="deviceList" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="名称" min-width="120" />
<el-table-column prop="protocol" label="协议" width="100" />
<el-table-column prop="kindName" label="类型" width="120" />
<el-table-column prop="endpoint" label="Endpoint" min-width="180" />
<el-table-column label="状态" width="100">
<template slot-scope="scope">
<el-tag size="mini" :type="scope.row.status === 'online' ? 'success' : scope.row.status === 'degraded' ? 'warning' : 'info'">
{{ scope.row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="lastSeen" label="最近活跃" width="160" />
</el-table>
</proto-section-card>
<proto-section-card title="编辑面板建议" subtitle="采用详情抽屉或右侧表单,不破坏主列表浏览流。">
<div class="proto-list">
<div class="proto-list-item">
<div class="item-title">允许编辑</div>
<div class="item-value">nameenabledtypeendpointdrivercapabilitiesRef 与业务配置型 params</div>
</div>
<div class="proto-list-item">
<div class="item-title">推荐交互</div>
<div class="item-value">保存后同步刷新设备树当前详情和运行态设备列表</div>
</div>
<div class="proto-list-item">
<div class="item-title">页面价值</div>
<div class="item-value">同时服务结构化配置与运行态排查是设备上下文的统一入口</div>
</div>
</div>
</proto-section-card>
</div>
<el-dialog :visible.sync="dialogVisible" title="编辑设备树节点" width="520px">
<el-form label-position="top">
<el-form-item label="name">
<el-input v-model="editForm.name" />
</el-form-item>
<el-form-item label="enabled">
<el-switch v-model="editForm.enabled" />
</el-form-item>
<el-form-item label="type">
<el-input v-model="editForm.type" />
</el-form-item>
<el-form-item label="endpoint">
<el-input v-model="editForm.endpoint" />
</el-form-item>
<el-form-item label="driver">
<el-input v-model="editForm.driver" />
</el-form-item>
<el-form-item label="capabilitiesRef">
<el-input v-model="editForm.capabilitiesRef" />
</el-form-item>
</el-form>
<span slot="footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveNode">保存</el-button>
</span>
</el-dialog>
</div>
</template>
<script>
import { getDeviceDetail, getDevicesOverview, updateDeviceTreeNode } from '@/api/softbus'
import { Message } from 'element-ui'
import ProtoPageHeader from '@/views/prototype/components/ProtoPageHeader'
import ProtoSectionCard from '@/views/prototype/components/ProtoSectionCard'
export default {
name: 'DevicesPage',
components: {
ProtoPageHeader,
ProtoSectionCard
},
data() {
return {
loading: false,
tree: [],
deviceList: [],
selectedNode: null,
deviceDetail: null,
dialogVisible: false,
editForm: {
name: '',
enabled: true,
type: '',
endpoint: '',
driver: '',
capabilitiesRef: ''
},
defaultProps: {
children: 'children',
label: 'name'
}
}
},
computed: {
nodeFields() {
if (!this.selectedNode) return []
const params = this.selectedNode.params || {}
return [
{ label: 'id', value: this.selectedNode.id },
{ label: 'name', value: this.selectedNode.name },
{ label: 'type', value: this.selectedNode.type },
{ label: 'endpoint', value: this.selectedNode.endpoint },
{ label: 'driver', value: this.selectedNode.driver || '-' },
{ label: 'capabilitiesRef', value: this.selectedNode.capabilitiesRef || '-' },
{ label: 'enabled', value: String(this.selectedNode.enabled) },
{ label: 'params.online', value: String(params.online) },
{ label: 'params.lastSeenTs', value: params.lastSeenTs || '-' },
{ label: 'params.stableKey', value: params.stableKey || '-' },
{ label: '运行态协议', value: this.deviceDetail && this.deviceDetail.protocol ? this.deviceDetail.protocol : '-' }
]
}
},
created() {
this.fetchData()
},
methods: {
fetchData() {
this.loading = true
getDevicesOverview().then(data => {
this.tree = data.tree || []
this.deviceList = data.devices || []
this.selectedNode = this.tree[0] && this.tree[0].children && this.tree[0].children[0] ? this.tree[0].children[0] : this.tree[0]
}).finally(() => {
this.loading = false
})
},
handleNodeClick(node) {
this.selectedNode = node
if (node.id) {
getDeviceDetail(node.id).then(detail => {
this.deviceDetail = detail
}).catch(() => {
this.deviceDetail = null
})
}
},
openEditor() {
this.editForm = {
name: this.selectedNode.name || '',
enabled: Boolean(this.selectedNode.enabled),
type: this.selectedNode.type || '',
endpoint: this.selectedNode.endpoint || '',
driver: this.selectedNode.driver || '',
capabilitiesRef: this.selectedNode.capabilitiesRef || ''
}
this.dialogVisible = true
},
saveNode() {
updateDeviceTreeNode(this.selectedNode.id, this.editForm).then(() => {
Message.success('节点已更新')
this.dialogVisible = false
this.fetchData()
})
}
}
}
</script>
<style lang="scss" scoped>
.tree-node {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 12px;
}
.devices-footer {
margin-top: 16px;
}
.item-title {
color: #8b95a7;
font-size: 12px;
}
.item-value {
margin-top: 8px;
color: #243243;
font-size: 14px;
font-weight: 600;
line-height: 1.7;
}
</style>

View File

@@ -0,0 +1,448 @@
<template>
<div class="prototype-page">
<proto-page-header
eyebrow="Discovery Workflow"
title="发现与接入工作台"
description="把 discovery、mapping 与 rule 配置串成一条连续工作流,用三栏布局承载样本筛选、字段解读与映射绑定,体现接口文档最核心的业务闭环。"
tag="工作台原型"
tag-type="warning"
>
<template slot="actions">
<el-button plain @click="handleClear">清空发现池</el-button>
<el-button v-if="status.sniffingEnabled" plain @click="handleStop">停止 Discovery</el-button>
<el-button v-else type="primary" @click="handleStart">启动 Discovery</el-button>
</template>
</proto-page-header>
<div class="prototype-grid three-columns">
<proto-section-card title="样本筛选" subtitle="对应 /api/v1/discovery/samples补充端口/从站/寄存器等可读设备标识。">
<div class="proto-toolbar">
<el-button type="primary" @click="refreshList">刷新发现池</el-button>
<div class="proto-filter-group">
<el-select v-model="protocol" class="toolbar-item">
<el-option label="全部协议" value="all" />
<el-option label="Modbus" value="modbus" />
<el-option label="MQTT" value="mqtt" />
<el-option label="BACnet" value="bacnet" />
<el-option label="OPCUA" value="opcua" />
</el-select>
<el-input v-model="endpointHash" placeholder="endpointHash / 设备标识 / slave / register" class="toolbar-item" />
</div>
<el-tag :type="status.sniffingEnabled ? 'success' : 'info'">
{{ status.sniffingEnabled ? 'Discovery 运行中' : 'Discovery 未启动' }}
</el-tag>
</div>
<div class="proto-list">
<div
v-for="sample in filteredSamples"
:key="sample.id"
:class="['proto-list-item', { active: sample.id === activeSample.id }]"
@click="activeSample = sample"
>
<div class="sample-head">
<span class="sample-key">{{ sample.compositeKey }}</span>
<el-tag size="mini">{{ sample.protocol }}</el-tag>
</div>
<div class="sample-meta">{{ sample.endpointLabel }}</div>
<div class="sample-meta">endpointHash: {{ sample.endpointHash }} · stableKey: {{ sample.stableKey }}</div>
<div class="sample-preview">{{ sample.valuePreview }}</div>
<div class="sample-time">{{ sample.updatedAt }}</div>
</div>
</div>
</proto-section-card>
<proto-section-card title="字段解析" subtitle="中间区域用于承接样本详情、可用映射建议和校验说明。">
<div v-if="activeSample" class="proto-field-grid">
<div class="proto-field">
<div class="label">sampleId</div>
<div class="value">{{ activeSample.id }}</div>
</div>
<div class="proto-field">
<div class="label">protocol</div>
<div class="value">{{ activeSample.protocol }}</div>
</div>
<div class="proto-field">
<div class="label">endpointHash</div>
<div class="value">{{ activeSample.endpointHash }}</div>
</div>
<div class="proto-field">
<div class="label">endpoint</div>
<div class="value">{{ activeSample.endpointLabel }}</div>
</div>
<div class="proto-field">
<div class="label">stableKey</div>
<div class="value">{{ activeSample.stableKey }}</div>
</div>
<div class="proto-field">
<div class="label">compositeKey</div>
<div class="value">{{ activeSample.compositeKey }}</div>
</div>
<div class="proto-field">
<div class="label">valuePreview</div>
<div class="value">{{ activeSample.valuePreview }}</div>
</div>
<div class="proto-field">
<div class="label">rawValue</div>
<div class="value">{{ activeSample.rawValueText }}</div>
</div>
<div class="proto-field">
<div class="label">slaveId / registerAddress</div>
<div class="value">{{ activeSample.slaveIdText }} / {{ activeSample.registerAddressText }}</div>
</div>
<div class="proto-field">
<div class="label">hitCount</div>
<div class="value">{{ activeSample.hitCountText }}</div>
</div>
<div class="proto-field">
<div class="label">firstSeen</div>
<div class="value">{{ activeSample.firstSeenText }}</div>
</div>
<div class="proto-field">
<div class="label">lastSeen</div>
<div class="value">{{ activeSample.lastSeenText }}</div>
</div>
</div>
<el-alert
title="智能映射建议当前未提供后端接口,需手动输入 domPath 与单位信息。"
type="info"
:closable="false"
show-icon
class="disabled-panel"
/>
</proto-section-card>
<proto-section-card title="映射绑定" subtitle="右侧承载 /api/v1/mapping/bind 与规则补充动作。">
<el-form label-position="top">
<el-form-item label="compositeKey">
<el-input :value="activeSample ? activeSample.compositeKey : ''" disabled />
</el-form-item>
<el-form-item label="domPath">
<el-input v-model="bindingForm.domPath" placeholder="例如site.boiler.temperature" />
</el-form-item>
<el-form-item label="scale / offset / unit">
<div class="form-inline">
<el-input v-model="bindingForm.scale" />
<el-input v-model="bindingForm.offset" />
<el-input v-model="bindingForm.unit" />
</div>
</el-form-item>
<el-form-item label="补充规则">
<el-checkbox-group v-model="bindingForm.extraRules">
<el-checkbox label="filter" disabled>创建过滤规则</el-checkbox>
<el-checkbox label="transform">创建转换规则</el-checkbox>
</el-checkbox-group>
</el-form-item>
</el-form>
<div class="proto-status-row">
<el-button @click="$router.push('/rules/mapping')">查看映射规则</el-button>
<el-button type="primary" :disabled="!activeSample || !bindingForm.domPath" @click="handleBind">绑定并验证</el-button>
</div>
<div class="proto-rule-note">
绑定成功后会刷新样本与映射列表过滤规则联动因后端缺少列表与校验支撑当前仅保留禁用说明
</div>
</proto-section-card>
</div>
<div class="prototype-grid two-columns discovery-footer">
<proto-section-card title="能力说明" subtitle="当前页面仅展示后端实际支持的 Discovery / Mapping 能力。">
<el-alert
title="最近操作时间线与校验历史后端当前未提供接口,已移除 mock 展示。"
type="warning"
:closable="false"
show-icon
/>
</proto-section-card>
<proto-section-card title="校验状态" subtitle="让页面同时具备配置与结果验证能力。">
<div class="proto-list">
<div class="proto-list-item">
<div class="sample-head">
<span class="sample-key">mapping/list</span>
<el-tag size="mini" type="primary">{{ mappings.length }} 条绑定</el-tag>
</div>
<div class="proto-rule-note">当前样本已被映射规则列表自动过滤便于继续处理未绑定字段</div>
</div>
<div class="proto-list-item">
<div class="sample-head">
<span class="sample-key">rules/transform</span>
<el-tag size="mini" type="info">需手动进入规则页</el-tag>
</div>
<div class="proto-rule-note">建议在绑定成功后直接联动创建转换规则减少跨页操作成本</div>
</div>
</div>
</proto-section-card>
</div>
</div>
</template>
<script>
import { bindMapping, clearDiscovery, createTransformRule, getDiscoveryWorkbench, startDiscovery, stopDiscovery } from '@/api/softbus'
import { Message } from 'element-ui'
import ProtoPageHeader from '@/views/prototype/components/ProtoPageHeader'
import ProtoSectionCard from '@/views/prototype/components/ProtoSectionCard'
export default {
name: 'DiscoveryWorkbench',
components: {
ProtoPageHeader,
ProtoSectionCard
},
data() {
return {
discoverySamples: [],
mappings: [],
status: {},
loading: false,
protocol: 'all',
endpointHash: '',
activeSample: null,
bindingForm: {
domPath: '',
scale: '1.0',
offset: '0',
unit: '',
extraRules: []
}
}
},
computed: {
filteredSamples() {
return this.discoverySamples.filter(sample => {
const protocolMatched = this.protocol === 'all' || sample.protocolNormalized.indexOf(this.protocol) > -1
const endpointMatched = !this.endpointHash || sample.endpointSearchText.indexOf(this.endpointHash.toLowerCase()) > -1
return protocolMatched && endpointMatched
})
}
},
watch: {
filteredSamples(list) {
if (!list.length) {
this.activeSample = null
return
}
if (!this.activeSample || !list.find(item => item.id === this.activeSample.id)) {
this.activeSample = list[0]
}
}
},
created() {
this.fetchData()
},
methods: {
refreshList() {
this.fetchData()
},
fetchData() {
this.loading = true
getDiscoveryWorkbench().then(data => {
this.status = data.status || {}
this.discoverySamples = (data.samples || []).map((item, index) => this.normalizeSample(item, index))
this.mappings = data.mappings || []
this.activeSample = this.discoverySamples[0] || null
}).finally(() => {
this.loading = false
})
},
handleStart() {
startDiscovery({ ttlMs: 1800000 }).then(() => {
Message.success('Discovery 已启动')
this.fetchData()
})
},
handleStop() {
stopDiscovery().then(() => {
Message.success('Discovery 已停止')
this.fetchData()
})
},
handleClear() {
clearDiscovery().then(() => {
Message.success('发现池已清空')
this.fetchData()
})
},
handleBind() {
const payload = {
compositeKey: this.activeSample.compositeKey,
domPath: this.bindingForm.domPath,
scale: this.bindingForm.scale,
offset: this.bindingForm.offset,
unit: this.bindingForm.unit
}
bindMapping(payload).then(() => {
if (this.bindingForm.extraRules.indexOf('transform') > -1) {
return createTransformRule({
id: `tr_${this.activeSample.stableKey || this.activeSample.id}`,
stableKey: this.activeSample.stableKey,
scale: this.bindingForm.scale,
offset: this.bindingForm.offset,
unit: this.bindingForm.unit
})
}
return null
}).then(() => {
Message.success('映射已保存')
this.fetchData()
})
},
normalizeSample(sample, index) {
const protocolRaw = String(sample.protocol || '').trim()
const protocolNormalized = protocolRaw.toLowerCase()
const endpointHashText = sample.endpointHash !== undefined && sample.endpointHash !== null ? String(sample.endpointHash) : '-'
const compositeKey = sample.compositeKey || `sample_${endpointHashText}_${index}`
const stableKey = sample.stableKey || `ep_${endpointHashText}`
const endpointLabel = this.buildEndpointLabel(sample)
const valuePreview = this.pickValuePreview(sample)
const updatedAt = this.formatTime(sample.lastSeenMs || sample.updatedAt || sample.timestamp)
return {
...sample,
id: sample.id || compositeKey,
protocol: protocolRaw || '-',
protocolNormalized,
endpointHash: endpointHashText,
compositeKey,
stableKey,
endpointLabel,
valuePreview,
updatedAt,
rawValueText: this.formatValue(sample.rawValue),
slaveIdText: sample.slaveId !== undefined ? String(sample.slaveId) : '-',
registerAddressText: sample.registerAddress !== undefined ? String(sample.registerAddress) : '-',
hitCountText: sample.hitCount !== undefined ? String(sample.hitCount) : '-',
firstSeenText: this.formatTime(sample.firstSeenMs),
lastSeenText: this.formatTime(sample.lastSeenMs),
endpointSearchText: [
endpointHashText,
endpointLabel,
protocolRaw,
sample.stableKey,
sample.slaveId,
sample.registerAddress,
sample.compositeKey
].filter(Boolean).join(' ').toLowerCase()
}
},
buildEndpointLabel(sample) {
if (sample.endpoint) return `endpoint: ${sample.endpoint}`
if (sample.address && sample.port !== undefined) return `${sample.address}:${sample.port}`
if (sample.host && sample.port !== undefined) return `${sample.host}:${sample.port}`
if (sample.ip && sample.port !== undefined) return `${sample.ip}:${sample.port}`
if (sample.topic) return `topic: ${sample.topic}`
if (sample.path) return `path: ${sample.path}`
if (sample.protocol && String(sample.protocol).toUpperCase().indexOf('MODBUS') > -1) {
const slave = sample.slaveId !== undefined ? sample.slaveId : '?'
const register = sample.registerAddress !== undefined ? sample.registerAddress : '?'
return `Modbus 从站 ${slave} / 寄存器 ${register}`
}
return '仅返回 endpointHash后端未提供端口标识'
},
pickValuePreview(sample) {
if (sample.valuePreview !== undefined && sample.valuePreview !== null) return String(sample.valuePreview)
if (sample.rawValue !== undefined && sample.rawValue !== null) return String(sample.rawValue)
if (sample.value !== undefined && sample.value !== null) return String(sample.value)
if (sample.decodedValue !== undefined && sample.decodedValue !== null) return String(sample.decodedValue)
return '-'
},
formatValue(value) {
if (value === undefined || value === null) return '-'
if (typeof value === 'object') return JSON.stringify(value)
return String(value)
},
formatTime(value) {
if (!value && value !== 0) return '-'
if (typeof value === 'string') return value
const timestamp = Number(value)
if (Number.isNaN(timestamp) || timestamp <= 0) return '-'
const date = new Date(timestamp)
if (Number.isNaN(date.getTime())) return '-'
const yyyy = date.getFullYear()
const mm = String(date.getMonth() + 1).padStart(2, '0')
const dd = String(date.getDate()).padStart(2, '0')
const hh = String(date.getHours()).padStart(2, '0')
const mi = String(date.getMinutes()).padStart(2, '0')
const ss = String(date.getSeconds()).padStart(2, '0')
return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${ss}`
}
}
}
</script>
<style lang="scss" scoped>
.toolbar-item {
width: 180px;
}
.sample-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.sample-key {
color: #243243;
font-size: 14px;
font-weight: 700;
word-break: break-all;
}
.sample-meta,
.sample-time {
margin-top: 8px;
color: #8b95a7;
font-size: 12px;
}
.sample-preview {
margin-top: 12px;
color: #409eff;
font-size: 14px;
font-weight: 700;
}
.active {
border-color: #bfdcff;
box-shadow: 0 8px 20px rgba(64, 158, 255, 0.12);
}
.suggestion-panel {
margin-top: 18px;
}
.panel-title {
margin-bottom: 12px;
color: #243243;
font-size: 15px;
font-weight: 700;
}
.suggestion-item {
background: #fff;
}
.disabled-panel {
margin-top: 18px;
}
.form-inline {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.discovery-footer {
margin-top: 16px;
}
@media (max-width: 768px) {
.form-inline {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,88 @@
<template>
<div class="prototype-page">
<proto-page-header
eyebrow="Semantic Dictionary"
title="Metadata"
description="作为数据字典与语义中心,统一查看业务域、语义类型、单位和读写能力。"
tag="字典中心"
tag-type="success"
/>
<div class="prototype-grid two-columns">
<proto-section-card title="Metadata 列表" subtitle="对应 /api/v1/metadata。">
<el-table :data="metadataList" border @row-click="handleSelect">
<el-table-column prop="metadataId" label="Metadata ID" min-width="180" />
<el-table-column prop="name" label="名称" width="100" />
<el-table-column prop="domain" label="Domain" width="120" />
<el-table-column prop="semanticType" label="Type" width="100" />
<el-table-column prop="unit" label="Unit" width="80" />
</el-table>
</proto-section-card>
<proto-section-card title="Metadata 详情" subtitle="展示完整字段口径与业务说明。">
<div v-if="selected" class="proto-field-grid">
<div v-for="item in fields" :key="item.label" class="proto-field">
<div class="label">{{ item.label }}</div>
<div class="value">{{ item.value }}</div>
</div>
</div>
</proto-section-card>
</div>
</div>
</template>
<script>
import { getMetadataDetail, getMetadataOverview } from '@/api/softbus'
import ProtoPageHeader from '@/views/prototype/components/ProtoPageHeader'
import ProtoSectionCard from '@/views/prototype/components/ProtoSectionCard'
export default {
name: 'MetadataPage',
components: {
ProtoPageHeader,
ProtoSectionCard
},
data() {
return {
metadataList: [],
selected: null
}
},
computed: {
fields() {
if (!this.selected) return []
return [
{ label: 'metadataId', value: this.selected.metadataId },
{ label: 'name', value: this.selected.name },
{ label: 'domain', value: this.selected.domain },
{ label: 'semanticType', value: this.selected.semanticType },
{ label: 'unit', value: this.selected.unit },
{ label: 'minValue', value: this.selected.minValue },
{ label: 'maxValue', value: this.selected.maxValue },
{ label: 'deadband', value: this.selected.deadband },
{ label: 'isWriteable', value: String(this.selected.isWriteable) }
]
}
},
created() {
this.fetchData()
},
methods: {
fetchData() {
getMetadataOverview().then(data => {
this.metadataList = data || []
if (this.metadataList[0]) {
this.handleSelect(this.metadataList[0])
}
})
},
handleSelect(row) {
getMetadataDetail(row.metadataId).then(data => {
this.selected = data
}).catch(() => {
this.selected = row
})
}
}
}
</script>

426
src/views/prototype/mock-data.js vendored Normal file
View File

@@ -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.0offset = 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'
}
]
}
]

View File

@@ -0,0 +1,162 @@
<template>
<div class="prototype-page">
<proto-page-header
eyebrow="Runtime Monitor"
title="Pipeline 监控"
description="聚焦运行状态、计数器、配置项和插件摘要,用于排查吞吐、丢包和执行链路问题。"
tag="监控页"
tag-type="success"
>
<template slot="actions">
<el-button plain>暂停轮询</el-button>
<el-button type="primary">手动刷新</el-button>
</template>
</proto-page-header>
<div class="prototype-grid metrics">
<proto-metric-card
v-for="item in counters"
:key="item.label"
:label="item.label"
:value="String(item.value)"
:trend="item.accent"
trend-type="neutral"
hint="Pipeline counters"
/>
</div>
<div class="prototype-grid two-columns page-main">
<proto-section-card title="运行状态" subtitle="对应 /api/v1/pipeline/status。">
<div class="proto-field-grid">
<div v-for="item in statusFields" :key="item.label" class="proto-field">
<div class="label">{{ item.label }}</div>
<div class="value">{{ item.value }}</div>
</div>
</div>
</proto-section-card>
<proto-section-card title="运行配置" subtitle="对应 /api/v1/pipeline/config。">
<div class="proto-list">
<div v-for="item in configFields" :key="item.label" class="proto-list-item">
<div class="item-title">{{ item.label }}</div>
<div class="item-value">{{ item.value }}</div>
</div>
</div>
</proto-section-card>
</div>
<proto-section-card title="插件摘要" subtitle="从 Pipeline 状态中汇总 parser / framer / mapper 插件。">
<div class="plugin-groups">
<div class="plugin-group">
<div class="group-title">Parser Plugins</div>
<el-tag v-for="item in status.parserPlugins || []" :key="item" size="mini" type="primary">{{ item }}</el-tag>
</div>
<div class="plugin-group">
<div class="group-title">Framer Plugins</div>
<el-tag v-for="item in status.framerPlugins || []" :key="item" size="mini" type="success">{{ item }}</el-tag>
</div>
<div class="plugin-group">
<div class="group-title">Mapper Plugins</div>
<el-tag v-for="item in status.mapperPlugins || []" :key="item" size="mini" type="warning">{{ item }}</el-tag>
</div>
</div>
</proto-section-card>
</div>
</template>
<script>
import { getPipelineOverview } from '@/api/softbus'
import ProtoMetricCard from '@/views/prototype/components/ProtoMetricCard'
import ProtoPageHeader from '@/views/prototype/components/ProtoPageHeader'
import ProtoSectionCard from '@/views/prototype/components/ProtoSectionCard'
export default {
name: 'PipelinePage',
components: {
ProtoMetricCard,
ProtoPageHeader,
ProtoSectionCard
},
data() {
return {
status: {},
counters: [],
config: {}
}
},
computed: {
statusFields() {
return [
{ label: 'running', value: String(this.status.running) },
{ label: 'parseWorkerCount', value: this.status.parseWorkerCount },
{ label: 'framerSessionCount', value: this.status.framerSessionCount },
{ label: 'parserSessionCount', value: this.status.parserSessionCount },
{ label: 'mapperSessionCount', value: this.status.mapperSessionCount },
{ label: 'trackedEndpointCount', value: this.status.trackedEndpointCount }
]
},
configFields() {
return [
{ label: 'parallelPipeline', value: String(this.config.parallelPipeline) },
{ label: 'orderedEgress', value: String(this.config.orderedEgress) },
{ label: 'parseWorkerCount', value: this.config.parseWorkerCount },
{ label: 'framedQueueMax', value: this.config.framedQueueMax },
{ label: 'maxReorderDepth', value: this.config.maxReorderDepth }
]
}
},
created() {
this.fetchData()
},
methods: {
fetchData() {
getPipelineOverview().then(data => {
this.status = data.status || {}
this.counters = data.counters || []
this.config = data.config || {}
})
}
}
}
</script>
<style lang="scss" scoped>
.page-main {
margin: 16px 0;
}
.plugin-groups {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.plugin-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 16px;
border-radius: 16px;
background: #f7f9fc;
}
.group-title,
.item-title {
width: 100%;
color: #8b95a7;
font-size: 12px;
}
.item-value {
margin-top: 8px;
color: #243243;
font-size: 14px;
font-weight: 600;
}
@media (max-width: 1200px) {
.plugin-groups {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,87 @@
<template>
<div class="prototype-page">
<proto-page-header
eyebrow="Plugin Center"
title="插件中心"
description="用于确认 parser、framer、mapper 三类插件及协议能力矩阵是否完整加载。"
tag="能力检查"
tag-type="primary"
/>
<div class="prototype-grid two-columns">
<proto-section-card title="插件列表" subtitle="对应 /api/v1/plugins。">
<el-table :data="pluginList" border>
<el-table-column prop="category" label="Category" width="110" />
<el-table-column prop="pluginId" label="Plugin ID" min-width="160" />
<el-table-column prop="protocol" label="Protocol" width="140" />
<el-table-column prop="protocolValue" label="Value" width="100" />
</el-table>
</proto-section-card>
<proto-section-card title="Mapper 专项视图" subtitle="对应 /api/v1/plugins/mappers。">
<div class="proto-list">
<div v-for="item in mappers" :key="item.pluginId" class="proto-list-item">
<div class="item-title">{{ item.protocol }}</div>
<div class="item-value">{{ item.pluginId }}</div>
</div>
</div>
</proto-section-card>
</div>
<proto-section-card title="协议能力矩阵" subtitle="对应 /api/v1/plugins/protocols。">
<el-table :data="protocols" border>
<el-table-column prop="protocol" label="协议" min-width="140" />
<el-table-column prop="parser" label="Parser" min-width="140" />
<el-table-column prop="framer" label="Framer" min-width="160" />
<el-table-column prop="mapper" label="Mapper" min-width="140" />
</el-table>
</proto-section-card>
</div>
</template>
<script>
import { getPluginOverview } from '@/api/softbus'
import ProtoPageHeader from '@/views/prototype/components/ProtoPageHeader'
import ProtoSectionCard from '@/views/prototype/components/ProtoSectionCard'
export default {
name: 'PluginsPage',
components: {
ProtoPageHeader,
ProtoSectionCard
},
data() {
return {
pluginList: [],
protocols: [],
mappers: []
}
},
created() {
this.fetchData()
},
methods: {
fetchData() {
getPluginOverview().then(data => {
this.pluginList = data.plugins || []
this.protocols = data.protocols || []
this.mappers = data.mappers || []
})
}
}
}
</script>
<style lang="scss" scoped>
.item-title {
color: #8b95a7;
font-size: 12px;
}
.item-value {
margin-top: 8px;
color: #243243;
font-size: 14px;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,81 @@
<template>
<div class="prototype-page">
<proto-page-header
eyebrow="Device Profiles"
title="Profiles"
description="用于查看设备画像快照与点位映射详情,形成设备与 Metadata 的结构化桥梁。"
tag="画像页"
tag-type="warning"
/>
<div class="prototype-grid two-columns">
<proto-section-card title="设备画像列表" subtitle="对应 /api/v1/profiles。">
<el-table :data="profiles" border @row-click="handleSelect">
<el-table-column prop="deviceId" label="Device ID" width="100" />
<el-table-column prop="protocol" label="Protocol" width="120" />
<el-table-column label="Mappings" width="100">
<template slot-scope="scope">
{{ scope.row.mappings.length }}
</template>
</el-table-column>
</el-table>
</proto-section-card>
<proto-section-card title="Mappings 详情" subtitle="点击左侧设备查看 profile 里的点位定义。">
<el-table :data="selectedMappings" border>
<el-table-column prop="pointId" label="Point ID" min-width="140" />
<el-table-column prop="metadataId" label="Metadata ID" min-width="140" />
<el-table-column prop="rawType" label="Raw Type" width="100" />
<el-table-column prop="scaleFactor" label="Scale" width="90" />
<el-table-column prop="offsetFactor" label="Offset" width="90" />
<el-table-column prop="expression" label="Expression" min-width="120" />
</el-table>
</proto-section-card>
</div>
</div>
</template>
<script>
import { getProfileDetail, getProfilesOverview } from '@/api/softbus'
import ProtoPageHeader from '@/views/prototype/components/ProtoPageHeader'
import ProtoSectionCard from '@/views/prototype/components/ProtoSectionCard'
export default {
name: 'ProfilesPage',
components: {
ProtoPageHeader,
ProtoSectionCard
},
data() {
return {
profiles: [],
selected: null
}
},
computed: {
selectedMappings() {
return this.selected ? this.selected.mappings : []
}
},
created() {
this.fetchData()
},
methods: {
fetchData() {
getProfilesOverview().then(data => {
this.profiles = data || []
if (this.profiles[0]) {
this.handleSelect(this.profiles[0])
}
})
},
handleSelect(row) {
getProfileDetail(row.deviceId).then(data => {
this.selected = Array.isArray(data) ? { deviceId: row.deviceId, mappings: data } : data
}).catch(() => {
this.selected = row
})
}
}
}
</script>

View File

@@ -0,0 +1,94 @@
<template>
<rule-workspace
eyebrow="Rule Registry"
title="过滤规则"
description="过滤规则页聚焦注册状态、作用范围与条件表达式,并在界面上明确区分“已注册”与“已真正生效”的能力边界。"
tag="注册中"
tag-type="warning"
section-title="过滤规则列表"
section-subtitle="对应 /api/v1/rules/filter /api/v1/rules/status"
drawer-subtitle="抽屉表单用于配置 idscope condition"
primary-action="新建过滤规则"
secondary-action="导出规则"
:columns="columns"
:table-data="tableData"
:form-fields="formFields"
:form-model="formModel"
:summary-items="summaryItems"
:loading="loading"
:disable-list="true"
disabled-reason="后端当前未提供过滤规则列表查询接口仅支持状态摘要和新增/删除操作"
:disable-secondary-action="true"
form-title="新增过滤规则"
note="过滤规则当前更偏注册表管理能力,建议在列表和详情中都清晰提示其执行链路状态。"
@submit-form="handleCreate"
@secondary-action="handleExport"
/>
</template>
<script>
import { createFilterRule, getRuleWorkspace } from '@/api/softbus'
import { Message } from 'element-ui'
import RuleWorkspace from '@/views/prototype/components/RuleWorkspace'
export default {
name: 'FilterRules',
components: {
RuleWorkspace
},
data() {
return {
columns: [
{ prop: 'id', label: '规则 ID' },
{ prop: 'scope', label: '作用范围' },
{ prop: 'condition', label: '条件表达式' },
{ prop: 'effect', label: '执行说明' }
],
loading: false,
tableData: [],
summaryItems: [],
formModel: {
id: '',
endpointHash: '',
deviceId: '',
stableKey: '',
condition: ''
},
formFields: [
{ label: '规则 ID', prop: 'id' },
{ label: 'endpointHash', prop: 'endpointHash' },
{ label: 'deviceId', prop: 'deviceId' },
{ label: 'stableKey', prop: 'stableKey' },
{ label: 'condition', prop: 'condition' }
]
}
},
created() {
this.fetchData()
},
methods: {
fetchData() {
this.loading = true
getRuleWorkspace('filter').then(data => {
this.summaryItems = [
{ label: '过滤规则总数', value: data.filterRuleCount || 0 },
{ label: '转换规则总数', value: data.transformRuleCount || 0 },
{ label: '能力状态', value: '仅注册' }
]
}).finally(() => {
this.loading = false
})
},
handleCreate(payload) {
createFilterRule(payload).then(() => {
Message.success('过滤规则已创建')
this.formModel = { id: '', endpointHash: '', deviceId: '', stableKey: '', condition: '' }
this.fetchData()
})
},
handleExport() {
Message.info('后端暂未提供过滤规则导出能力')
}
}
}
</script>

View File

@@ -0,0 +1,89 @@
<template>
<rule-workspace
eyebrow="Binding Center"
title="映射规则"
description="映射规则页承载 compositeKey 到 domPath 的绑定关系,强调绑定状态、换算参数与业务域路径,是 Discovery 工作台的后续沉淀页。"
tag="已绑定"
tag-type="primary"
section-title="映射规则列表"
section-subtitle="对应 /api/v1/mapping/list /api/v1/mapping/bind"
drawer-subtitle="抽屉表单承载 domPath 选择单位换算与解绑说明"
primary-action="新建映射绑定"
secondary-action="导出映射"
:columns="columns"
:table-data="tableData"
:form-fields="formFields"
:form-model="formModel"
:loading="loading"
form-title="新增 / 更新映射"
note="映射规则应与发现工作台形成闭环,支持从样本页快速跳转并延续上下文。"
@submit-form="handleSubmit"
@delete-item="handleDelete"
/>
</template>
<script>
import { bindMapping, getRuleWorkspace, unbindMapping } from '@/api/softbus'
import { Message } from 'element-ui'
import RuleWorkspace from '@/views/prototype/components/RuleWorkspace'
export default {
name: 'MappingRules',
components: {
RuleWorkspace
},
data() {
return {
columns: [
{ prop: 'compositeKey', label: 'Composite Key' },
{ prop: 'domPath', label: 'DOM Path' },
{ prop: 'scale', label: 'Scale' },
{ prop: 'offset', label: 'Offset' },
{ prop: 'unit', label: 'Unit' }
],
loading: false,
tableData: [],
formModel: {
compositeKey: '',
domPath: '',
scale: '1.0',
offset: '0',
unit: ''
},
formFields: [
{ label: 'compositeKey', prop: 'compositeKey' },
{ label: 'domPath', prop: 'domPath' },
{ label: 'scale', prop: 'scale' },
{ label: 'offset', prop: 'offset' },
{ label: 'unit', prop: 'unit' }
]
}
},
created() {
this.fetchData()
},
methods: {
fetchData() {
this.loading = true
getRuleWorkspace('mapping').then(data => {
this.tableData = data || []
}).finally(() => {
this.loading = false
})
},
handleSubmit(payload) {
bindMapping(payload).then(() => {
Message.success('映射规则已保存')
this.formModel = { compositeKey: '', domPath: '', scale: '1.0', offset: '0', unit: '' }
this.fetchData()
})
},
handleDelete(row) {
unbindMapping({ compositeKey: row.compositeKey }).then(() => {
Message.success('映射规则已解绑')
this.fetchData()
})
}
}
}
</script>

View File

@@ -0,0 +1,100 @@
<template>
<rule-workspace
eyebrow="Transformation"
title="转换规则"
description="转换规则页重点展示 scale、offset、unit 等参数,并通过明确的状态标签表达该类规则已经进入 Pipeline 执行链路。"
tag="已生效"
tag-type="success"
section-title="转换规则列表"
section-subtitle="对应 /api/v1/rules/transform"
drawer-subtitle="抽屉表单强调换算参数与单位配置"
primary-action="新建转换规则"
secondary-action="批量校验"
:columns="columns"
:table-data="tableData"
:form-fields="formFields"
:form-model="formModel"
:summary-items="summaryItems"
:loading="loading"
:disable-list="true"
disabled-reason="后端当前未提供转换规则列表查询接口仅支持状态摘要和新增/删除操作"
:disable-secondary-action="true"
form-title="新增转换规则"
note="转换规则已经在 applyTransformAndEnrich 中生效,适合做成页面中的高频主操作入口。"
@submit-form="handleCreate"
@secondary-action="handleValidate"
/>
</template>
<script>
import { createTransformRule, getRuleWorkspace } from '@/api/softbus'
import { Message } from 'element-ui'
import RuleWorkspace from '@/views/prototype/components/RuleWorkspace'
export default {
name: 'TransformRules',
components: {
RuleWorkspace
},
data() {
return {
columns: [
{ prop: 'id', label: '规则 ID' },
{ prop: 'scope', label: '作用范围' },
{ prop: 'scale', label: 'Scale' },
{ prop: 'offset', label: 'Offset' },
{ prop: 'unit', label: 'Unit' },
{ prop: 'effect', label: '执行说明' }
],
loading: false,
tableData: [],
summaryItems: [],
formModel: {
id: '',
endpointHash: '',
deviceId: '',
stableKey: '',
scale: '1.0',
offset: '0',
unit: ''
},
formFields: [
{ label: '规则 ID', prop: 'id' },
{ label: 'endpointHash', prop: 'endpointHash' },
{ label: 'deviceId', prop: 'deviceId' },
{ label: 'stableKey', prop: 'stableKey' },
{ label: 'scale', prop: 'scale' },
{ label: 'offset', prop: 'offset' },
{ label: 'unit', prop: 'unit' }
]
}
},
created() {
this.fetchData()
},
methods: {
fetchData() {
this.loading = true
getRuleWorkspace('transform').then(data => {
this.summaryItems = [
{ label: '过滤规则总数', value: data.filterRuleCount || 0 },
{ label: '转换规则总数', value: data.transformRuleCount || 0 },
{ label: '执行链路', value: '已生效' }
]
}).finally(() => {
this.loading = false
})
},
handleCreate(payload) {
createTransformRule(payload).then(() => {
Message.success('转换规则已创建')
this.formModel = { id: '', endpointHash: '', deviceId: '', stableKey: '', scale: '1.0', offset: '0', unit: '' }
this.fetchData()
})
},
handleValidate() {
Message.info('后端暂未提供批量校验接口')
}
}
}
</script>

View File

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

5
tests/unit/.eslintrc.js Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
env: {
jest: true
}
}

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