code cleanup

This commit is contained in:
Marvin Zhang
2021-11-01 12:15:18 +08:00
451 changed files with 2076 additions and 30472 deletions

View File

@@ -1,18 +0,0 @@
FROM golang:1.12-alpine AS build
WORKDIR /go/src/app
COPY . .
ENV GO111MODULE on
ENV GOPROXY https://mirrors.aliyun.com/goproxy/
RUN go mod vendor
RUN go install -v ./...
FROM alpine:latest
WORKDIR /root
COPY --from=build /go/src/app .
COPY --from=build /go/bin/crawlab /usr/local/bin
EXPOSE 8000
CMD ["crawlab"]

View File

@@ -2,30 +2,11 @@ package cmd
import (
"crawlab/apps"
"fmt"
"github.com/crawlab-team/crawlab-core/entity"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var (
runOnMaster bool
masterConfigPath string
masterGrpcAddress string
masterGrpcAuthKey string
)
func init() {
rootCmd.AddCommand(masterCmd)
masterCmd.PersistentFlags().StringVarP(&masterConfigPath, "config-path", "c", "", "Config path of master node")
_ = viper.BindPFlag("configPath", masterCmd.PersistentFlags().Lookup("configPath"))
masterCmd.PersistentFlags().StringVarP(&masterGrpcAddress, "grpc-address", "g", "", "gRPC address of master node")
_ = viper.BindPFlag("grpcAddress", masterCmd.PersistentFlags().Lookup("grpcAddress"))
masterCmd.PersistentFlags().StringVarP(&masterGrpcAuthKey, "grpc-auth-key", "a", "", "gRPC auth key of master node")
_ = viper.BindPFlag("grpcAuthKey", masterCmd.PersistentFlags().Lookup("grpcAuthKey"))
}
var masterCmd = &cobra.Command{
@@ -37,23 +18,6 @@ which runs api and assign tasks to worker nodes`,
Run: func(cmd *cobra.Command, args []string) {
// options
var opts []apps.MasterOption
if masterConfigPath != "" {
opts = append(opts, apps.WithMasterConfigPath(masterConfigPath))
viper.Set("config.path", masterConfigPath)
}
opts = append(opts, apps.WithRunOnMaster(runOnMaster))
if masterGrpcAddress != "" {
address, err := entity.NewAddressFromString(masterGrpcAddress)
if err != nil {
fmt.Println(fmt.Sprintf("invalid grpc-address: %s", masterGrpcAddress))
}
opts = append(opts, apps.WithMasterGrpcAddress(address))
viper.Set("grpc.address", masterGrpcAddress)
viper.Set("grpc.server.address", masterGrpcAddress)
}
if masterGrpcAuthKey != "" {
viper.Set("grpc.authKey", masterGrpcAuthKey)
}
// app
master := apps.NewMaster(opts...)

View File

@@ -50,6 +50,7 @@ func initConfig() {
replacer := strings.NewReplacer(".", "_")
viper.SetEnvKeyReplacer(replacer)
// read config file
if err := viper.ReadInConfig(); err == nil {
fmt.Println("Using config file:", viper.ConfigFileUsed())
}

View File

@@ -2,29 +2,11 @@ package cmd
import (
"crawlab/apps"
"fmt"
"github.com/crawlab-team/crawlab-core/entity"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var (
workerConfigPath string
workerGrpcAddress string
workerGrpcAuthKey string
)
func init() {
rootCmd.AddCommand(workerCmd)
workerCmd.PersistentFlags().StringVarP(&workerConfigPath, "config-path", "c", "", "Config path of worker node")
_ = viper.BindPFlag("configPath", workerCmd.PersistentFlags().Lookup("configPath"))
workerCmd.PersistentFlags().StringVarP(&workerGrpcAddress, "grpc-address", "g", "", "gRPC address of worker node")
_ = viper.BindPFlag("grpcAddress", workerCmd.PersistentFlags().Lookup("grpcAddress"))
workerCmd.PersistentFlags().StringVarP(&workerGrpcAuthKey, "grpc-auth-key", "a", "", "gRPC auth key of worker node")
_ = viper.BindPFlag("grpcAuthKey", workerCmd.PersistentFlags().Lookup("grpcAuthKey"))
}
var workerCmd = &cobra.Command{
@@ -37,22 +19,6 @@ assigned by the master node`,
Run: func(cmd *cobra.Command, args []string) {
// options
var opts []apps.WorkerOption
if workerConfigPath != "" {
opts = append(opts, apps.WithWorkerConfigPath(workerConfigPath))
viper.Set("config.path", workerConfigPath)
}
if workerGrpcAddress != "" {
address, err := entity.NewAddressFromString(workerGrpcAddress)
if err != nil {
fmt.Println(fmt.Sprintf("invalid grpc-address: %s", workerGrpcAddress))
return
}
opts = append(opts, apps.WithWorkerGrpcAddress(address))
viper.Set("grpc.address", workerGrpcAddress)
}
if workerGrpcAuthKey != "" {
viper.Set("grpc.authKey", workerGrpcAuthKey)
}
// app
master := apps.NewWorker(opts...)

View File

@@ -1,7 +1,7 @@
{
"key": "master",
"is_master": true,
"name": "master",
"name": "Master Node",
"ip": "",
"mac": "",
"hostname": "",

View File

@@ -1,7 +1,7 @@
{
"key": "worker",
"key": "worker-01",
"is_master": false,
"name": "worker",
"name": "Worker Node 01",
"ip": "",
"mac": "",
"hostname": "",

View File

@@ -0,0 +1,10 @@
{
"key": "worker-02",
"is_master": false,
"name": "Worker Node 02",
"ip": "",
"mac": "",
"hostname": "",
"description": "",
"auth_key": "Crawlab2021!"
}

View File

@@ -1,3 +0,0 @@
> 1%
last 2 versions
not dead

View File

@@ -1 +0,0 @@
node_modules/

View File

@@ -1,32 +0,0 @@
root = true
[*]
# charset = utf-8
# end_of_line = lf
# indent_size = 4
# indent_style = space
# insert_final_newline = true
# max_line_length = 120
# tab_width = 4
# trim_trailing_whitespace = true
[*.scss]
# indent_size = 2
[{*.ats, *.ts}]
# indent_size = 2
# tab_width = 2
[{*.js, *.cjs}]
# indent_size = 2
# tab_width = 2
[{*.sht, *.html, *.shtm, *.shtml, *.htm, *.ng}]
# indent_size = 2
# tab_width = 2
[{.analysis_options, *.yml, *.yaml}]
# indent_size = 2
[{.babelrc, .prettierrc, .stylelintrc, .eslintrc, jest.config, *.json, *.jsb3, *.jsb2, *.bowerrc}]
# indent_size = 2

View File

@@ -1,2 +0,0 @@
NODE_ENV='development'
VUE_APP_API_BASE_URL=http://localhost:8000

View File

@@ -1,2 +0,0 @@
NODE_ENV='production'
VUE_APP_API_BASE_URL='VUE_APP_API_BASE_URL'

View File

@@ -1,2 +0,0 @@
NODE_ENV='production'
VUE_APP_API_BASE_URL=http://localhost:8000

View File

@@ -1,2 +0,0 @@
NODE_ENV='test'
VUE_APP_API_BASE_URL=http://localhost:8000

View File

@@ -1,2 +1,3 @@
*/**/*.js
./src/i18n/**/*.ts
*/**/*.js
*.js

6
frontend/.gitignore vendored
View File

@@ -23,3 +23,9 @@ pnpm-debug.log*
*.sw?
tmp/
lib/
**/.DS_Store
**/.idea
**/dist
**/node_modules
**/package-lock.json

View File

@@ -1,29 +0,0 @@
BSD 3-Clause License
Copyright (c) 2020, Crawlab Team
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

View File

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

View File

@@ -1,73 +1,23 @@
{
"name": "@crawlab/frontend",
"version": "0.6.0-beta.20210715",
"private": false,
"name": "@crawlab/app",
"version": "0.6.0-beta.202109062026",
"description": "",
"scripts": {
"serve": "vue-cli-service serve --port=8081",
"serve:build:local": "vue-cli-service serve --port=8082 --model local",
"build": "vue-cli-service build",
"build:docker": "vue-cli-service build --mode docker",
"build:local": "vue-cli-service build --mode local",
"lint": "vue-cli-service lint",
"test": "jest"
"serve": "vue-cli-service serve",
"buid": "vue-cli-service build"
},
"author": {
"name": "Marvin Zhang",
"email": "tikazyq@163.com"
},
"license": "BSD-3-Clause",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.32",
"@fortawesome/free-brands-svg-icons": "^5.15.1",
"@fortawesome/free-regular-svg-icons": "^5.15.1",
"@fortawesome/free-solid-svg-icons": "^5.15.1",
"@fortawesome/vue-fontawesome": "^3.0.0-2",
"@popperjs/core": "^2.6.0",
"@types/codemirror": "^0.0.103",
"@types/echarts": "^4.9.8",
"@types/getos": "^3.0.1",
"@types/humanize-duration": "^3.25.0",
"@types/javascript-time-ago": "^2.0.2",
"@types/md5": "^2.2.1",
"@types/pinyin": "^2.8.2",
"atom-material-icons": "^3.0.0",
"axios": "^0.21.1",
"codemirror": "^5.59.1",
"core-js": "^3.6.5",
"cron-parser": "^3.5.0",
"cronstrue": "^1.114.0",
"dayjs": "^1.10.5",
"echarts": "^5.1.2",
"element-plus": "1.0.2-beta.40",
"font-awesome": "^4.7.0",
"getos": "^3.2.1",
"humanize-duration": "^3.26.0",
"javascript-time-ago": "^2.3.6",
"md5": "^2.3.0",
"node-sass": "^5.0.0",
"normalize.css": "^8.0.1",
"pinyin": "^2.10.2",
"point-cluster": "^3.1.8",
"vue": "^3.0.4",
"vue-clipboard3": "^1.0.1",
"vue-i18n": "^9.0.0-beta.11",
"vue-router": "^4.0.0-0",
"vue3-dropzone": "^0.0.7",
"vue3-sfc-loader": "^0.8.4",
"vuex": "^4.0.0-0"
"crawlab-ui": "0.6.0-beta.202109082041",
"vue": "3.0.11",
"vue-router": "^4.0.11"
},
"devDependencies": {
"@babel/preset-typescript": "^7.12.7",
"@types/jest": "^26.0.19",
"@typescript-eslint/eslint-plugin": "^2.33.0",
"@typescript-eslint/parser": "^2.33.0",
"@vue/cli-plugin-babel": "~4.5.0",
"@vue/cli-plugin-eslint": "~4.5.0",
"@vue/cli-plugin-router": "~4.5.0",
"@vue/cli-plugin-typescript": "~4.5.0",
"@vue/cli-plugin-vuex": "~4.5.0",
"@vue/cli-service": "~4.5.0",
"@vue/compiler-sfc": "^3.0.0",
"@vue/eslint-config-typescript": "^5.0.2",
"eslint": "^6.7.2",
"eslint-plugin-vue": "^7.0.0-0",
"sass-loader": "^10.1.0",
"scss-loader": "^0.0.1",
"typescript": "~3.9.3"
"@vue/cli-service": "^4.5.13",
"@vue/compiler-sfc": "^3.0.11"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

File diff suppressed because one or more lines are too long

View File

@@ -1,215 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta content="IE=edge" http-equiv="X-UA-Compatible">
<meta content="width=device-width,initial-scale=1.0" name="viewport">
<link href="<%= BASE_URL %>favicon.ico" rel="icon">
<link href="font-awesome.min.css" rel="stylesheet">
<title><%= htmlWebpackPlugin.options.title %></title>
<script src="/js/vue3-sfc-loader.js"></script>
<style>
#loading-placeholder {
position: fixed;
background: white;
z-index: -1;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
#loading-placeholder .title-wrapper {
height: 54px;
}
#loading-placeholder .title {
font-family: "Verdana", serif;
font-weight: 600;
font-size: 48px;
color: #409EFF;
text-align: center;
cursor: default;
letter-spacing: -5px;
margin: 0;
}
#loading-placeholder .title > span {
display: inline-block;
animation: change-shape 1s infinite;
}
#loading-placeholder .title > span:nth-child(1) {
animation-delay: calc(1s / 7 * 0 / 2);
}
#loading-placeholder .title > span:nth-child(2) {
animation-delay: calc(1s / 7 * 1 / 2);
}
#loading-placeholder .title > span:nth-child(3) {
animation-delay: calc(1s / 7 * 2 / 2);
}
#loading-placeholder .title > span:nth-child(4) {
animation-delay: calc(1s / 7 * 3 / 2);
}
#loading-placeholder .title > span:nth-child(5) {
animation-delay: calc(1s / 7 * 4 / 2);
}
#loading-placeholder .title > span:nth-child(6) {
animation-delay: calc(1s / 7 * 5 / 2);
}
#loading-placeholder .title > span:nth-child(7) {
animation-delay: calc(1s / 7 * 6 / 2);
}
#loading-placeholder .sub-title-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 10px;
height: 28px;
}
#loading-placeholder .sub-title-wrapper .sub-title {
position: absolute;
font-size: 18px;
font-weight: 300;
font-family: "Verdana", serif;
font-style: italic;
color: #67C23A;
transform: rotate3d(1, 0, 0, 90deg);
animation: flip 20s infinite;
/*color: #E6A23C;*/
/*color: #F56C6C;*/
}
#loading-placeholder .sub-title-wrapper > .sub-title:nth-child(1) {
animation-delay: calc(20s / 4 * 0);
}
#loading-placeholder .sub-title-wrapper > .sub-title:nth-child(2) {
animation-delay: calc(20s / 4 * 1);
}
#loading-placeholder .sub-title-wrapper > .sub-title:nth-child(3) {
animation-delay: calc(20s / 4 * 2);
}
#loading-placeholder .sub-title-wrapper > .sub-title:nth-child(4) {
animation-delay: calc(20s / 4 * 3);
}
#loading-placeholder .loading-text {
text-align: center;
font-weight: bolder;
font-family: "Verdana", serif;
font-style: italic;
color: #889aa4;
font-size: 14px;
animation: blink-loading 2s ease-in infinite;
}
@keyframes blink-loading {
0% {
opacity: 100%;
}
50% {
opacity: 50%;
}
100% {
opacity: 100%;
}
}
@keyframes change-shape {
0% {
transform: scale(1);
}
25% {
transform: scale(1.2);
}
50% {
transform: scale(1);
}
100% {
transform: scale(1);
}
}
@keyframes flip {
0% {
transform: rotate3d(1, 0, 0, 90deg);
}
2% {
transform: rotate3d(1, 0, 0, 90deg);
}
7% {
transform: rotate3d(1, 0, 0, 0);
}
23% {
transform: rotate3d(1, 0, 0, 0);
}
27% {
transform: rotate3d(1, 0, 0, 90deg);
}
50% {
transform: rotate3d(1, 0, 0, 90deg);
}
100% {
transform: rotate3d(1, 0, 0, 90deg);
}
}
</style>
<meta charset="utf-8">
<meta content="IE=edge" http-equiv="X-UA-Compatible">
<meta content="width=device-width,initial-scale=1.0" name="viewport">
<script src="/js/vue3-sfc-loader.js"></script>
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<noscript>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled.
Please enable it to continue.</strong>
<strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled.
Please enable it to continue.</strong>
</noscript>
<div id="loading-placeholder">
<div style="margin-bottom: 150px">
<div class="title-wrapper">
<h3 class="title">
<span>C</span>
<span>R</span>
<span>A</span>
<span>W</span>
<span>L</span>
<span>A</span>
<span>B</span>
</h3>
</div>
<div class="sub-title-wrapper">
<span class="sub-title"><i class="fa fa-cloud-download"></i> Easy Crawling</span>
<span class="sub-title"><i class="fa fa-diamond"></i> Better Management</span>
<span class="sub-title"><i class="fa fa-dollar"></i> Gain Data Value</span>
<span class="sub-title"><i class="fa fa-server"></i> Good Scalability</span>
</div>
<div class="loading-text">
Loading...
</div>
</div>
</div>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>

File diff suppressed because one or more lines are too long

View File

@@ -1,18 +0,0 @@
<template>
App
<hello-world/>
</template>
<script lang="ts">
import HelloWorld from './HelloWorld.vue';
export default {
name: 'App',
components: {
HelloWorld,
},
setup() {
return {};
},
};
</script>

View File

@@ -1,12 +0,0 @@
<template>
Hello World
</template>
<script lang="ts">
export default {
name: 'HelloWorld',
setup() {
return {};
},
};
</script>

View File

@@ -1,21 +0,0 @@
<template>
<router-view />
</template>
<script lang="ts">
import {defineComponent, onBeforeMount} from 'vue';
import {initPlugins} from '@/utils/plugin';
import {useStore} from 'vuex';
export default defineComponent({
name: 'App',
setup() {
onBeforeMount(() => {
const store = useStore();
initPlugins(store);
});
return {};
},
});
</script>

View File

@@ -1,12 +0,0 @@
// baidu tongji
export const initBaiduTonji = () => {
if (localStorage.getItem('useStats') !== '0') {
window._hmt = window._hmt || [];
(function () {
const hm = document.createElement('script');
hm.src = 'https://hm.baidu.com/hm.js?c35e3a563a06caee2524902c81975add';
const s = document.getElementsByTagName('script')[0];
s?.parentNode?.insertBefore(hm, s);
})();
}
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,262 +0,0 @@
function initCanvas() {
let canvas, ctx, circ, nodes, mouse, SENSITIVITY, SIBLINGS_LIMIT, DENSITY, NODES_QTY, ANCHOR_LENGTH, MOUSE_RADIUS,
TURBULENCE, MOUSE_MOVING_TURBULENCE, MOUSE_ANGLE_TURBULENCE, MOUSE_MOVING_RADIUS, BASE_BRIGHTNESS, RADIUS_DEGRADE,
SAMPLE_SIZE
let handle
// how close next node must be to activate connection (in px)
// shorter distance == better connection (line width)
SENSITIVITY = 200
// note that siblings limit is not 'accurate' as the node can actually have more connections than this value that's because the node accepts sibling nodes with no regard to their current connections this is acceptable because potential fix would not result in significant visual difference
// more siblings == bigger node
SIBLINGS_LIMIT = 10
// default node margin
DENSITY = 100
// total number of nodes used (incremented after creation)
NODES_QTY = 0
// avoid nodes spreading
ANCHOR_LENGTH = 100
// highlight radius
MOUSE_RADIUS = 200
// turbulence of randomness
TURBULENCE = 3
// turbulence of mouse moving
MOUSE_MOVING_TURBULENCE = 50
// turbulence of mouse moving angle
MOUSE_ANGLE_TURBULENCE = 0.002
// moving radius of mouse
MOUSE_MOVING_RADIUS = 600
// base brightness
BASE_BRIGHTNESS = 0.12
// radius degrade
RADIUS_DEGRADE = 0.4
// sample size
SAMPLE_SIZE = 0.5
circ = 2 * Math.PI
nodes = []
canvas = document.querySelector('canvas')
resizeWindow()
ctx = canvas.getContext('2d')
if (!ctx) {
alert('Ooops! Your browser does not support canvas :\'(')
}
function Mouse(x, y) {
this.anchorX = x
this.anchorY = y
this.x = x
this.y = y - MOUSE_RADIUS / 2
this.angle = 0
}
Mouse.prototype.computePosition = function () {
// this.x = this.anchorX + MOUSE_MOVING_RADIUS / 2 * Math.sin(this.angle)
// this.y = this.anchorY - MOUSE_MOVING_RADIUS / 2 * Math.cos(this.angle)
}
Mouse.prototype.move = function () {
let vx = Math.random() * MOUSE_MOVING_TURBULENCE
let vy = Math.random() * MOUSE_MOVING_TURBULENCE
if (this.x + vx + MOUSE_RADIUS / 2 > window.innerWidth || this.x + vx - MOUSE_RADIUS / 2 < 0) {
vx = -vx
}
if (this.y + vy + MOUSE_RADIUS / 2 > window.innerHeight || this.y + vy - MOUSE_RADIUS / 2 < 0) {
vy = -vy
}
this.x += vx
this.y += vy
// this.angle += Math.random() * MOUSE_ANGLE_TURBULENCE * 2 * Math.PI
// this.angle -= Math.floor(this.angle / (2 * Math.PI)) * 2 * Math.PI
// this.computePosition()
}
function Node(x, y) {
this.anchorX = x
this.anchorY = y
this.x = Math.random() * (x - (x - ANCHOR_LENGTH)) + (x - ANCHOR_LENGTH)
this.y = Math.random() * (y - (y - ANCHOR_LENGTH)) + (y - ANCHOR_LENGTH)
this.vx = Math.random() * TURBULENCE - 1
this.vy = Math.random() * TURBULENCE - 1
this.energy = Math.random() * 100
this.radius = Math.random()
this.siblings = []
this.brightness = 0
}
Node.prototype.drawNode = function () {
let color = 'rgba(64, 156, 255, ' + this.brightness + ')'
ctx.beginPath()
ctx.arc(this.x, this.y, 2 * this.radius + 2 * this.siblings.length / SIBLINGS_LIMIT / 1.5, 0, circ)
ctx.fillStyle = color
ctx.fill()
}
Node.prototype.drawConnections = function () {
for (let i = 0; i < this.siblings.length; i++) {
let color = 'rgba(64, 156, 255, ' + this.brightness + ')'
ctx.beginPath()
ctx.moveTo(this.x, this.y)
ctx.lineTo(this.siblings[i].x, this.siblings[i].y)
ctx.lineWidth = 1 - calcDistance(this, this.siblings[i]) / SENSITIVITY
ctx.strokeStyle = color
ctx.stroke()
}
}
Node.prototype.moveNode = function () {
this.energy -= 2
if (this.energy < 1) {
this.energy = Math.random() * 100
if (this.x - this.anchorX < -ANCHOR_LENGTH) {
this.vx = Math.random() * TURBULENCE
} else if (this.x - this.anchorX > ANCHOR_LENGTH) {
this.vx = Math.random() * -TURBULENCE
} else {
this.vx = Math.random() * 2 * TURBULENCE - TURBULENCE
}
if (this.y - this.anchorY < -ANCHOR_LENGTH) {
this.vy = Math.random() * TURBULENCE
} else if (this.y - this.anchorY > ANCHOR_LENGTH) {
this.vy = Math.random() * -TURBULENCE
} else {
this.vy = Math.random() * 2 * TURBULENCE - TURBULENCE
}
}
this.x += this.vx * this.energy / 100
this.y += this.vy * this.energy / 100
}
function Handle() {
this.isStopped = false
}
Handle.prototype.stop = function () {
this.isStopped = true
}
function initNodes() {
ctx.clearRect(0, 0, canvas.width, canvas.height)
nodes = []
for (let i = DENSITY; i < canvas.width; i += DENSITY) {
for (let j = DENSITY; j < canvas.height; j += DENSITY) {
nodes.push(new Node(i, j))
NODES_QTY++
}
}
}
function initMouse() {
mouse = new Mouse(canvas.width / 2, canvas.height / 2)
}
function initHandle() {
handle = new Handle()
}
function calcDistance(node1, node2) {
return Math.sqrt(Math.pow(node1.x - node2.x, 2) + (Math.pow(node1.y - node2.y, 2)))
}
function findSiblings() {
let node1, node2, distance
for (let i = 0; i < NODES_QTY; i++) {
node1 = nodes[i]
node1.siblings = []
for (let j = 0; j < NODES_QTY; j++) {
node2 = nodes[j]
if (node1 !== node2) {
distance = calcDistance(node1, node2)
if (distance < SENSITIVITY) {
if (node1.siblings.length < SIBLINGS_LIMIT) {
node1.siblings.push(node2)
} else {
let node_sibling_distance = 0
let max_distance = 0
let s
for (let k = 0; k < SIBLINGS_LIMIT; k++) {
node_sibling_distance = calcDistance(node1, node1.siblings[k])
if (node_sibling_distance > max_distance) {
max_distance = node_sibling_distance
s = k
}
}
if (distance < max_distance) {
node1.siblings.splice(s, 1)
node1.siblings.push(node2)
}
}
}
}
}
}
}
function redrawScene() {
if (handle && handle.isStopped) {
return
}
resizeWindow()
ctx.clearRect(0, 0, canvas.width, canvas.height)
findSiblings()
let i, node, distance
for (i = 0; i < NODES_QTY; i++) {
node = nodes[i]
distance = calcDistance({
x: mouse.x,
y: mouse.y
}, node)
node.brightness = (1 - Math.log(distance / MOUSE_RADIUS * RADIUS_DEGRADE)) * BASE_BRIGHTNESS
}
for (i = 0; i < NODES_QTY; i++) {
node = nodes[i]
if (node.brightness) {
node.drawNode()
node.drawConnections()
}
node.moveNode()
}
// mouse.move()
setTimeout(() => {
requestAnimationFrame(redrawScene)
}, 50)
}
function initHandlers() {
document.addEventListener('resize', resizeWindow, false)
// canvas.addEventListener('mousemove', mousemoveHandler, false)
}
function resizeWindow() {
canvas.width = window.innerWidth
canvas.height = window.innerHeight
}
function mousemoveHandler(e) {
mouse.x = e.clientX
mouse.y = e.clientY
}
function init() {
initHandlers()
initNodes()
initMouse()
initHandle()
redrawScene()
}
function reset() {
handle.isStopped = true
}
init()
window.resetCanvas = reset
}
(function () {
window.initCanvas = initCanvas
window.initCanvas()
}())

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -1,16 +0,0 @@
<svg width="300" height="300" xmlns="http://www.w3.org/2000/svg">
<g fill="none">
<circle cx="150" cy="150" r="130" fill="none" stroke-width="40" stroke="#409eff">
</circle>
<circle cx="150" cy="150" r="110" fill="white">
</circle>
<circle cx="150" cy="150" r="70" fill="#409eff">
</circle>
<path d="
M 150,150
L 280,225
A 150,150 90 0 0 280,75
" fill="#409eff">
</path>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 503 B

View File

@@ -1,106 +0,0 @@
<template>
<el-tooltip :content="tooltip" :disabled="!tooltip">
<span :class="[noMargin ? 'no-margin' : '']" class="button-wrapper">
<el-button
:circle="circle"
:class="[isIcon ? 'icon-button' : '']"
:disabled="disabled"
:plain="plain"
:round="round"
:size="size"
:title="tooltip"
:type="type"
:loading="loading"
class="button"
@click="() => $emit('click')"
>
<slot></slot>
</el-button>
</span>
</el-tooltip>
</template>
<script lang="ts">
import {defineComponent, PropType} from 'vue';
export const buttonProps = {
tooltip: {
type: String,
required: false,
default: '',
},
type: {
type: String as PropType<BasicType>,
required: false,
default: 'primary',
},
size: {
type: String as PropType<BasicSize>,
required: false,
default: 'mini',
},
round: {
type: Boolean,
required: false,
default: false,
},
circle: {
type: Boolean,
required: false,
default: false,
},
plain: {
type: Boolean,
required: false,
default: false,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
isIcon: {
type: Boolean,
required: false,
default: false,
},
noMargin: {
type: Boolean,
required: false,
default: false,
},
loading: {
type: Boolean,
required: false,
default: false,
}
};
export default defineComponent({
name: 'Button',
props: buttonProps,
emits: [
'click',
],
setup(props) {
return {};
},
});
</script>
<style lang="scss" scoped>
.button-wrapper {
position: relative;
margin-right: 10px;
&.no-margin {
margin-right: 0;
}
}
</style>
<style scoped>
.button-wrapper >>> .icon-button {
padding: 7px;
}
</style>

View File

@@ -1,71 +0,0 @@
<template>
<Button
:circle="circle"
:disabled="disabled"
:plain="plain"
:round="round"
:size="size"
:tooltip="tooltip"
:type="type"
is-icon
class="fa-icon-button"
@click="() => $emit('click')"
>
<font-awesome-icon :icon="icon"/>
<div v-if="badgeIcon" class="badge-icon">
<font-awesome-icon :icon="badgeIcon"/>
</div>
</Button>
</template>
<script lang="ts">
import {defineComponent, PropType} from 'vue';
import {buttonProps} from './Button.vue';
import Button from '@/components/button/Button.vue';
export const faIconButtonProps = {
icon: {
type: [Array, String] as PropType<Icon>,
required: true,
},
badgeIcon: {
type: [Array, String] as PropType<Icon>,
required: false,
},
...buttonProps,
};
export default defineComponent({
name: 'FaIconButton',
components: {Button},
props: faIconButtonProps,
emits: [
'click',
],
setup() {
return {};
},
});
</script>
<style lang="scss" scoped>
@import "../../styles/variables";
.badge-icon {
position: absolute;
top: -2px;
right: 2px;
font-size: 8px;
color: $white;
}
</style>
<style scoped>
.el-button,
.el-button--mini,
.fa-icon-button,
.fa-icon-button >>> .el-button,
.fa-icon-button >>> .el-button--mini,
.fa-icon-button >>> .button {
padding: 7px;
}
</style>

View File

@@ -1,47 +0,0 @@
<template>
<el-tooltip :content="tooltip ? tooltip : undefined">
<span>
<el-button
:circle="circle"
:disabled="disabled"
:icon="icon"
:plain="plain"
:round="round"
:size="size"
:title="tooltip"
:type="type"
class="button"
style="padding: 7px"
@click="() => $emit('click')"
/>
</span>
</el-tooltip>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
import {buttonProps} from '@/components/button/Button.vue';
export const iconButtonProps = {
icon: {
type: String,
required: true,
},
...buttonProps,
};
export default defineComponent({
name: 'IconButton',
props: iconButtonProps,
emits: [
'click',
],
setup() {
return {};
},
});
</script>
<style lang="scss" scoped>
</style>

View File

@@ -1,50 +0,0 @@
<template>
<Button
:circle="circle"
:disabled="disabled"
:plain="plain"
:round="round"
:size="size"
:tooltip="tooltip"
:type="type"
class="label-button"
@click="() => $emit('click')"
>
<font-awesome-icon v-if="icon" :icon="icon" class="icon"/>
{{ label }}
</Button>
</template>
<script lang="ts">
import {defineComponent, PropType} from 'vue';
import Button, {buttonProps} from '@/components/button/Button.vue';
export default defineComponent({
name: 'LabelButton',
components: {Button},
props: {
icon: {
type: [Array, String] as PropType<Icon>,
},
label: {
type: String,
required: true,
},
...buttonProps
},
emits: [
'click',
],
setup(props: LabelButtonProps, {emit}) {
return {};
},
});
</script>
<style lang="scss" scoped>
.label-button {
.icon {
margin-right: 3px;
}
}
</style>

View File

@@ -1,172 +0,0 @@
<template>
<div :style="style" class="line-chart">
<div ref="elRef" class="echarts-element"></div>
</div>
</template>
<script lang="ts">
import {computed, defineComponent, onMounted, PropType, ref, watch} from 'vue';
import {init} from 'echarts';
export default defineComponent({
name: 'LineChart',
props: {
config: {
type: Object as PropType<EChartsConfig>,
required: true,
},
width: {
type: String,
default: '100%',
},
height: {
type: String,
default: '100%',
},
theme: {
type: String,
default: 'light',
},
labelKey: {
type: String,
},
valueKey: {
type: String,
},
isTimeSeries: {
type: Boolean,
default: true,
},
},
setup(props: LineChartProps, {emit}) {
const style = computed<Partial<CSSStyleDeclaration>>(() => {
const {width, height} = props;
return {
width,
height,
};
});
const elRef = ref<HTMLDivElement>();
const chart = ref<ECharts>();
const isMultiSeries = computed<boolean>(() => {
const {config} = props;
const {dataMetas} = config;
return dataMetas ? dataMetas.length > 1 : false;
});
const getSeriesData = (data: StatsResult[], key?: string) => {
const {valueKey, labelKey, isTimeSeries} = props;
const _valueKey = !key ? valueKey : key;
if (_valueKey) {
if (isTimeSeries) {
// time series
return data.map(d => [d[labelKey || 'date'], d[_valueKey] || 0]);
} else {
// not time series
return data.map(d => d[_valueKey] || 0);
}
} else {
// default
return data;
}
};
const getSeries = (): EChartSeries[] => {
const {config} = props;
const {data, dataMetas} = config;
if (!isMultiSeries.value) {
// single series
return [{
type: 'line',
data: getSeriesData(data),
}];
} else {
// multiple series
const series = [] as EChartSeries[];
if (!dataMetas) return series;
dataMetas.forEach(({key, name, yAxisIndex}) => {
series.push({
name,
yAxisIndex,
type: 'line',
data: getSeriesData(data, key),
});
});
return series;
}
};
const render = () => {
const {config, theme, isTimeSeries} = props;
const {option} = config;
// dom
const el = elRef.value;
if (!el) return;
// xAxis
if (!option.xAxis) {
option.xAxis = {};
if (isTimeSeries) {
option.xAxis.type = 'time';
}
}
// yAxis
if (!option.yAxis) {
option.yAxis = {};
}
// series
option.series = getSeries();
// tooltip
if (!option.tooltip) {
option.tooltip = {
// trigger: 'axis',
// position: ['50%', '50%'],
// axisPointer: {
// type: 'cross',
// },
};
}
// legend
option.legend = {};
// render
if (!chart.value) {
// init
chart.value = init(el, theme);
}
(chart.value as ECharts).setOption(option);
};
watch(() => props.config.data, render);
watch(() => props.theme, render);
onMounted(() => {
render();
});
return {
style,
elRef,
render,
};
},
});
</script>
<style lang="scss" scoped>
.line-chart {
.echarts-element {
width: 100%;
height: 100%;
}
}
</style>

View File

@@ -1,128 +0,0 @@
<template>
<div :class="[clickable ? 'clickable' : '']" :style="style" class="metric" @click="onClick">
<div class="background"/>
<div class="icon">
<font-awesome-icon :icon="icon"/>
</div>
<div class="info">
<div class="title">
{{ title }}
</div>
<div class="value">
{{ value }}
</div>
</div>
</div>
</template>
<script lang="ts">
import {computed, defineComponent, PropType} from 'vue';
export default defineComponent({
name: 'Metric',
props: {
title: {
type: String,
},
value: {
type: [Number, String],
},
icon: {
type: [String, Array] as PropType<Icon>,
},
color: {
type: String,
},
clickable: {
type: Boolean,
}
},
emits: [
'click',
],
setup(props: MetricProps, {emit}) {
const style = computed<Partial<CSSStyleDeclaration>>(() => {
const {color} = props;
return {
backgroundColor: color,
};
});
const onClick = () => {
const {clickable} = props;
if (!clickable) return;
emit('click');
};
return {
style,
onClick,
};
},
});
</script>
<style lang="scss" scoped>
@import "../../styles/variables";
.metric {
padding: 10px;
margin: 20px;
display: flex;
border: 1px solid $infoLightColor;
border-radius: 5px;
position: relative;
&.clickable {
cursor: pointer;
&:hover {
opacity: 0.9;
}
}
.background {
position: absolute;
left: calc(64px + 10px);
top: 0;
width: calc(100% - 64px - 10px);
height: 100%;
background-color: white;
filter: alpha(0.3);
z-index: 1;
}
.icon {
display: flex;
align-items: center;
justify-content: center;
flex-basis: 64px;
font-size: 32px;
color: white;
padding-right: 10px;
z-index: 2;
}
.info {
margin-left: 20px;
height: 48px;
color: white;
z-index: 2;
.title {
height: 24px;
line-height: 24px;
font-weight: bolder;
//padding: 5px;
}
.value {
height: 24px;
line-height: 24px;
font-weight: bold;
//padding: 5px;
}
}
}
</style>

View File

@@ -1,164 +0,0 @@
<template>
<div :style="style" class="pie-chart">
<div v-if="isEmpty" class="empty-placeholder">
No Data Available
</div>
<div ref="elRef" class="echarts-element"></div>
</div>
</template>
<script lang="ts">
import {computed, defineComponent, onMounted, PropType, ref, watch} from 'vue';
import {init} from 'echarts';
export default defineComponent({
name: 'PieChart',
props: {
config: {
type: Object as PropType<EChartsConfig>,
required: true,
},
width: {
type: String,
default: '100%',
},
height: {
type: String,
default: '100%',
},
theme: {
type: String,
default: 'light',
},
labelKey: {
type: String,
},
valueKey: {
type: String,
},
},
setup(props: PieChartProps, {emit}) {
const style = computed<Partial<CSSStyleDeclaration>>(() => {
const {width, height} = props;
return {
width,
height,
};
});
const elRef = ref<HTMLDivElement>();
const chart = ref<ECharts>();
const isEmpty = computed<boolean>(() => {
const {config} = props;
const {data} = config;
if (!data) return true;
return data.length === 0;
});
const getSeriesData = (data: StatsResult[], key?: string) => {
const {valueKey, labelKey} = props;
const _valueKey = !key ? valueKey : key;
if (_valueKey) {
return data.map(d => {
return {
name: d[labelKey || '_id'],
value: d[_valueKey] || 0,
};
});
} else {
// default
return data;
}
};
const getSeries = (): EChartSeries[] => {
const {config} = props;
const {data, itemStyleColorFunc} = config;
const seriesItem = {
type: 'pie',
data: getSeriesData(data || []),
radius: ['40%', '70%'],
alignTo: 'labelLine',
} as EChartSeries;
if (itemStyleColorFunc) {
seriesItem.itemStyle = {color: itemStyleColorFunc};
}
return [seriesItem];
};
const render = () => {
const {config, theme} = props;
const {option} = config;
// dom
const el = elRef.value;
if (!el) return;
// series
option.series = getSeries();
// tooltip
if (!option.tooltip) {
option.tooltip = {
// trigger: 'axis',
// position: ['50%', '50%'],
// axisPointer: {
// type: 'cross',
// },
};
}
// render
if (!chart.value) {
// init
chart.value = init(el, theme);
}
(chart.value as ECharts).setOption(option);
};
watch(() => props.config.data, render);
watch(() => props.theme, render);
onMounted(() => {
render();
});
return {
isEmpty,
style,
elRef,
render,
};
},
});
</script>
<style lang="scss" scoped>
@import "../../styles/variables";
.pie-chart {
position: relative;
.empty-placeholder {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
.echarts-element {
width: 100%;
height: 100%;
}
}
</style>

View File

@@ -1,74 +0,0 @@
<template>
<div class="color-picker">
<el-color-picker
v-model="internalValue"
:disabled="disabled"
:predefine="predefine"
:show-alpha="showAlpha"
@change="onChange"
/>
</div>
</template>
<script lang="ts">
import {defineComponent, onMounted, PropType, ref, watch} from 'vue';
export default defineComponent({
name: 'ColorPicker',
props: {
modelValue: {
type: String,
},
disabled: {
type: Boolean,
},
predefine: {
type: Array as PropType<string[]>,
},
showAlpha: {
type: Boolean,
default: true,
},
},
emits: [
'update:model-value',
'change',
],
setup(props: ColorPickerProps, {emit}) {
const internalValue = ref<string>();
watch(() => props.modelValue, () => {
internalValue.value = props.modelValue;
});
const onChange = (value: string) => {
emit('update:model-value', value);
emit('change', value);
};
onMounted(() => {
const {modelValue} = props;
internalValue.value = modelValue;
});
return {
internalValue,
onChange,
};
},
});
</script>
<style scoped>
.color-picker >>> .el-color-picker__trigger {
border: none;
padding: 0;
}
.color-picker >>> .el-color-picker__mask {
background: transparent;
border-radius: 0;
left: 0;
}
</style>

View File

@@ -1,65 +0,0 @@
<template>
<el-popover
:placement="placement"
:show-arrow="false"
:visible="visible"
popper-class="context-menu"
trigger="manual"
>
<template #default>
<slot name="default"></slot>
</template>
<template #reference>
<div v-click-outside="onClickOutside">
<slot name="reference"></slot>
</div>
</template>
</el-popover>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
import {ClickOutside} from 'element-plus/lib/directives';
export const contextMenuDefaultProps = {
visible: {
type: Boolean,
default: false,
},
placement: {
type: String,
default: 'right-start',
},
clicking: {
type: Boolean,
default: false,
}
};
export const contextMenuDefaultEmits = [
'hide',
];
export default defineComponent({
name: 'ContextMenu',
directives: {
ClickOutside,
},
emits: contextMenuDefaultEmits,
props: contextMenuDefaultProps,
setup(props, {emit}) {
const onClickOutside = () => {
if (props.clicking) return;
emit('hide');
};
return {
onClickOutside,
};
},
});
</script>
<style lang="scss" scoped>
</style>

View File

@@ -1,86 +0,0 @@
<template>
<ul class="context-menu-list">
<li
v-for="(item, $index) in items"
:key="$index"
class="context-menu-item"
@click="onClick(item)"
>
<span class="prefix">
<template v-if="item.icon">
<font-awesome-icon v-if="Array.isArray(item.icon)" :icon="item.icon"/>
<atom-material-icon v-else-if="typeof item.icon === 'string'" :is-dir="false" :name="item.icon"/>
</template>
</span>
<span class="title">
{{ item.title }}
</span>
</li>
</ul>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
import AtomMaterialIcon from '@/components/icon/AtomMaterialIcon.vue';
export default defineComponent({
name: 'ContextMenuList',
components: {AtomMaterialIcon},
props: {
items: {
type: [Array, String],
default: () => {
return [];
},
},
},
setup(props, {emit}) {
const onClick = (item: ContextMenuItem) => {
if (!item.action) return;
item.action();
emit('hide');
};
return {
onClick,
};
},
});
</script>
<style lang="scss" scoped>
@import "../../styles/variables.scss";
.context-menu-list {
list-style: none;
margin: 0;
padding: 0;
min-width: auto;
.context-menu-item {
height: $contextMenuItemHeight;
max-width: $contextMenuItemMaxWidth;
display: flex;
align-items: center;
margin: 0;
padding: 10px;
cursor: pointer;
&:hover {
background-color: $primaryPlainColor;
}
.title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.prefix {
width: 24px;
display: flex;
align-items: center;
}
}
}
</style>

View File

@@ -1,74 +0,0 @@
<template>
<Dialog
:confirm-loading="confirmLoading"
:title="title"
:visible="visible"
@cancel="onCancel"
@confirm="onConfirm"
>
<template v-if="content">
{{ content }}
</template>
<template v-else>
<slot></slot>
</template>
</Dialog>
</template>
<script lang="ts">
import {defineComponent, ref} from 'vue';
import Dialog from '@/components/dialog/Dialog.vue';
import {voidFunc} from '@/utils/func';
export default defineComponent({
name: 'ConfirmDialog',
components: {Dialog},
props: {
confirmFunc: {
type: Function,
default: voidFunc,
},
title: {
type: String,
required: true,
},
content: {
type: String,
}
},
emits: [
'confirm',
'cancel',
],
setup(props: ConfirmDialogProps, {emit}) {
const visible = ref<boolean>(false);
const confirmLoading = ref<boolean>(false);
const onCancel = () => {
visible.value = false;
emit('cancel');
};
const onConfirm = async () => {
const {confirmFunc} = props;
confirmLoading.value = true;
await confirmFunc();
confirmLoading.value = false;
visible.value = false;
emit('confirm');
};
return {
visible,
confirmLoading,
onCancel,
onConfirm,
};
},
});
</script>
<style lang="scss" scoped>
</style>

View File

@@ -1,105 +0,0 @@
<template>
<div class="create-dialog-content-batch">
<el-form class="control-panel" inline>
<el-form-item>
<Button type="primary" @click="onAdd">
<font-awesome-icon :icon="['fa', 'plus']"/>
Add
</Button>
</el-form-item>
<el-form-item label="Edit All">
<Switch v-model="editAll"/>
</el-form-item>
</el-form>
<FormTable
:data="data"
:fields="fields"
@add="onAdd"
@clone="onClone"
@delete="onDelete"
@field-change="onFieldChange"
@field-register="onFieldRegister"
/>
</div>
</template>
<script lang="ts">
import {defineComponent, inject, PropType, Ref, ref} from 'vue';
import FormTable from '@/components/form/FormTable.vue';
import {emptyArrayFunc} from '@/utils/func';
import Switch from '@/components/switch/Switch.vue';
import Button from '@/components/button/Button.vue';
export default defineComponent({
name: 'CreateDialogContentBatch',
components: {
Button,
Switch,
FormTable,
},
props: {
data: {
type: Array as PropType<TableData>,
required: true,
default: emptyArrayFunc,
},
fields: {
type: Array as PropType<FormTableField[]>,
required: true,
default: emptyArrayFunc,
}
},
setup(props: CreateDialogContentBatchProps) {
const editAll = ref<boolean>(false);
const actionFunctions = inject('action-functions') as CreateEditDialogActionFunctions;
const onAdd = (rowIndex: number) => {
actionFunctions?.onAdd?.(rowIndex);
};
const onClone = (rowIndex: number) => {
actionFunctions?.onClone?.(rowIndex);
};
const onDelete = (rowIndex: number) => {
actionFunctions?.onDelete?.(rowIndex);
};
const onFieldChange = (rowIndex: number, prop: string, value: any) => {
if (editAll.value) {
// edit all rows
rowIndex = -1;
}
actionFunctions?.onFieldChange?.(rowIndex, prop, value);
};
const onFieldRegister = (rowIndex: number, prop: string, formRef: Ref) => {
actionFunctions?.onFieldRegister(rowIndex, prop, formRef);
};
return {
editAll,
onAdd,
onClone,
onDelete,
onFieldChange,
onFieldRegister,
};
},
});
</script>
<style lang="scss" scoped>
.create-dialog-content-batch {
.control-panel {
margin-bottom: 10px;
.el-form-item {
margin: 0;
}
}
}
</style>

View File

@@ -1,151 +0,0 @@
<template>
<Dialog
:title="computedTitle"
:visible="visible"
:width="width"
:confirm-loading="confirmLoading"
:confirm-disabled="confirmDisabled"
@close="onClose"
@confirm="onConfirm"
>
<el-tabs
v-model="internalTabName"
:class="[type, visible ? 'visible' : '']"
class="create-edit-dialog-tabs"
@tab-click="onTabChange"
>
<el-tab-pane label="Single" name="single">
<slot/>
</el-tab-pane>
<el-tab-pane v-if="!noBatch" label="Batch" name="batch">
<CreateDialogContentBatch
:data="batchFormData"
:fields="batchFormFields"
/>
</el-tab-pane>
</el-tabs>
</Dialog>
</template>
<script lang="ts">
import {computed, defineComponent, PropType, provide, ref, SetupContext, watch} from 'vue';
import CreateDialogContentBatch from '@/components/dialog/CreateDialogContentBatch.vue';
import Dialog from '@/components/dialog/Dialog.vue';
import {emptyArrayFunc, emptyObjectFunc} from '@/utils/func';
import {Pane} from 'element-plus/lib/el-tabs/src/tabs.vue';
export default defineComponent({
name: 'CreateEditDialog',
components: {
Dialog,
CreateDialogContentBatch,
},
props: {
visible: {
type: Boolean,
default: false,
},
type: {
type: String as PropType<CreateEditDialogType>,
default: 'create',
},
width: {
type: String,
default: '80vw',
},
batchFormData: {
type: Array as PropType<TableData>,
default: emptyArrayFunc,
},
batchFormFields: {
type: Array as PropType<FormTableField[]>,
default: emptyArrayFunc,
},
confirmDisabled: {
type: Boolean,
default: false,
},
confirmLoading: {
type: Boolean,
default: false,
},
actionFunctions: {
type: Object as PropType<CreateEditDialogActionFunctions>,
default: emptyObjectFunc,
},
tabName: {
type: String as PropType<CreateEditTabName>,
default: 'single',
},
title: {
type: String,
default: undefined,
},
noBatch: {
type: Boolean,
default: false,
},
formRules: {
type: Array as PropType<FormRuleItem[]>,
default: emptyArrayFunc,
},
},
setup(props: CreateEditDialogProps, ctx: SetupContext) {
const computedTitle = computed<string>(() => {
const {visible, type, title} = props;
if (title) return title;
if (!visible) return '';
switch (type) {
case 'create':
return 'Create';
case 'edit':
return 'Edit';
default:
return 'Dialog';
}
});
const onClose = () => {
const {actionFunctions} = props;
actionFunctions?.onClose?.();
};
const onConfirm = () => {
const {actionFunctions} = props;
actionFunctions?.onConfirm?.();
};
const internalTabName = ref<CreateEditTabName>('single');
const onTabChange = (tab: Pane) => {
const tabName = tab.paneName as unknown as CreateEditTabName;
const {actionFunctions} = props;
actionFunctions?.onTabChange?.(tabName);
};
watch(() => props.tabName, () => {
internalTabName.value = props.tabName as CreateEditTabName;
});
provide<CreateEditDialogActionFunctions | undefined>('action-functions', props.actionFunctions);
provide<FormRuleItem[] | undefined>('form-rules', props.formRules);
return {
computedTitle,
onClose,
onConfirm,
internalTabName,
onTabChange,
};
},
});
</script>
<style lang="scss">
.create-edit-dialog-tabs {
&.edit,
&:not(.visible) {
.el-tabs__header {
display: none;
}
}
}
</style>

View File

@@ -1,93 +0,0 @@
<template>
<el-dialog
:modal-class="modalClass"
:before-close="onClose"
:model-value="visible"
:title="title"
:top="top"
:width="width"
:z-index="zIndex"
>
<slot/>
<template #footer>
<slot name="prefix"/>
<Button plain size="mini" type="info" @click="onClose">Cancel</Button>
<Button
:disabled="confirmDisabled"
:loading="confirmLoading"
size="mini"
type="primary"
@click="onConfirm"
>
Confirm
</Button>
<slot name="suffix"/>
</template>
</el-dialog>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
import Button from '@/components/button/Button.vue';
export default defineComponent({
name: 'Dialog',
components: {Button},
props: {
visible: {
type: Boolean,
required: false,
default: false,
},
modalClass: {
type: String,
},
title: {
type: String,
required: false,
},
top: {
type: String,
required: false,
},
width: {
type: String,
required: false,
},
zIndex: {
type: Number,
required: false,
},
confirmDisabled: {
type: Boolean,
default: false,
},
confirmLoading: {
type: Boolean,
default: false,
},
},
emits: [
'close',
'confirm',
],
setup(props: DialogProps, {emit}) {
const onClose = () => {
emit('close');
};
const onConfirm = () => {
emit('confirm');
};
return {
onClose,
onConfirm,
};
},
});
</script>
<style lang="scss" scoped>
</style>

View File

@@ -1,71 +0,0 @@
<template>
<div
:class="classes"
:draggable="true"
class="draggable-item"
@dragstart="$emit('d-start', item)"
@dragend="$emit('d-end', item)"
@dragenter="$emit('d-enter', item)"
@dragleave="$emit('d-leave', item)"
>
<DraggableItemContent :item="item"/>
</div>
</template>
<script lang="ts">
import {computed, defineComponent} from 'vue';
import DraggableItemContent from '@/components/drag/DraggableItemContent.vue';
export default defineComponent({
name: 'DraggableItem',
components: {DraggableItemContent},
props: {
item: {
type: Object,
required: true,
},
dragging: {
type: Boolean,
default: false,
}
},
emits: [
'd-start',
'd-end',
'd-enter',
'd-leave',
],
setup(props) {
const dragging = computed(() => {
const {item} = props as DraggableItemProps;
return item.dragging;
});
const classes = computed(() => {
const cls = [];
if (dragging.value) cls.push('dragging');
return cls;
});
return {
classes,
};
},
});
</script>
<style lang="scss" scoped>
@import "../../styles/variables.scss";
.draggable-item {
position: relative;
&.dragging {
visibility: hidden;
}
&.dragging * {
pointer-events: none;
}
}
</style>

View File

@@ -1,21 +0,0 @@
<script lang="ts">
import {defineComponent, inject} from 'vue';
export default defineComponent({
name: 'DraggableItemContent',
props: {
item: {
type: Object,
required: true,
},
},
setup(props) {
return () => {
const {item} = props;
const content = inject<DraggableListContext>('list');
if (!content || !content.ctx || !content.ctx.slots || !content.ctx.slots.default) return '';
return content.ctx.slots.default({item});
};
},
});
</script>

View File

@@ -1,111 +0,0 @@
<template>
<div class="draggable-list">
<DraggableItem
v-for="(item, $index) in orderedItems"
:key="item[itemKey] === undefined ? $index : item[itemKey]"
:item="item"
@d-end="onTabDragEnd"
@d-enter="onTabDragEnter"
@d-leave="onTabDragLeave"
@d-start="onTabDragStart"
/>
</div>
</template>
<script lang="ts">
import {computed, defineComponent, provide, reactive, ref} from 'vue';
import DraggableItem from '@/components/drag/DraggableItem.vue';
import {plainClone} from '@/utils/object';
export default defineComponent({
name: 'DraggableList',
components: {DraggableItem},
props: {
items: {
type: Array,
required: true,
},
itemKey: {
type: String,
default: 'key',
},
},
emits: [
'd-end',
],
setup(props, ctx) {
const {emit} = ctx;
const internalItems = reactive<DraggableListInternalItems>({});
const isDragging = ref(false);
const orderedItems = computed(() => {
const {items, itemKey} = props as DraggableListProps;
const {draggingItem, targetItem} = internalItems;
if (!draggingItem || !targetItem) return items;
const orderedItems = plainClone(items) as DraggableItemData[];
const draggingIdx = orderedItems.map(t => t[itemKey]).indexOf(draggingItem[itemKey]);
const targetIdx = orderedItems.map(t => t[itemKey]).indexOf(targetItem[itemKey]);
if (draggingIdx === -1 || targetIdx === -1) return items;
orderedItems.splice(draggingIdx, 1);
orderedItems.splice(targetIdx, 0, plainClone(draggingItem));
return orderedItems;
});
const onTabDragStart = (item: DraggableItemData) => {
internalItems.draggingItem = plainClone(item) as DraggableItemData;
internalItems.draggingItem.dragging = true;
isDragging.value = true;
};
const onTabDragEnd = () => {
const items = orderedItems.value.map(d => {
d.dragging = false;
return d;
});
isDragging.value = false;
internalItems.draggingItem = undefined;
internalItems.targetItem = undefined;
emit('d-end', items);
};
const onTabDragEnter = (item: DraggableItemData) => {
const {itemKey} = props as DraggableListProps;
const {draggingItem} = internalItems;
if (!draggingItem || (draggingItem ? draggingItem[itemKey] : undefined) === item[itemKey]) return;
const _item = plainClone(item) as DraggableItemData;
_item.dragging = true;
internalItems.targetItem = _item;
};
const onTabDragLeave = (item: DraggableItemData) => {
const {itemKey} = props as DraggableListProps;
const {draggingItem, targetItem} = internalItems;
if (!!targetItem || !draggingItem || (draggingItem ? draggingItem[itemKey] : undefined) === item[itemKey]) return;
internalItems.targetItem = undefined;
};
provide('list', {
ctx,
props,
} as DraggableListContext);
return {
orderedItems,
onTabDragStart,
onTabDragEnd,
onTabDragEnter,
onTabDragLeave,
};
},
});
</script>
<style lang="scss" scoped>
.draggable-list {
list-style: none;
display: flex;
align-items: center;
margin: 0;
padding: 0;
}
</style>

View File

@@ -1,3 +0,0 @@
type BasicType = 'primary' | 'success' | 'warning' | 'danger' | 'info';
type BasicEffect = 'dark' | 'light' | 'plain';
type BasicSize = 'mini' | 'small' | 'medium' | 'large';

View File

@@ -1,42 +0,0 @@
<template>
<div class="empty">
<ImgEmpty/>
<div class="description">
{{ description }}
</div>
</div>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
import ImgEmpty from '@/components/empty/ImgEmpty.vue';
export default defineComponent({
name: 'Empty',
components: {ImgEmpty},
props: {
description: {
type: String,
required: false,
default: 'No Data Available'
}
},
setup() {
return {};
},
});
</script>
<style lang="scss" scoped>
.empty {
max-height: 240px;
display: flex;
flex-direction: column;
.description {
margin-top: 10px;
display: flex;
justify-content: center;
}
}
</style>

View File

@@ -1,138 +0,0 @@
<template>
<svg
class="img-empty"
version="1.1"
viewBox="0 0 79 86"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
>
<title>Group 2</title>
<defs>
<linearGradient
id="linearGradient-1"
x1="38.8503086%"
x2="61.1496914%"
y1="0%"
y2="100%"
>
<stop offset="0%" stop-color="#FCFCFD"/>
<stop offset="100%" stop-color="#EEEFF3"/>
</linearGradient>
<linearGradient
id="linearGradient-2"
x1="0%"
x2="100%"
y1="9.5%"
y2="90.5%"
>
<stop offset="0%" stop-color="#FCFCFD"/>
<stop offset="100%" stop-color="#E9EBEF"/>
</linearGradient>
<rect
id="path-3"
height="36"
width="17"
x="0"
y="0"
/>
</defs>
<g
id="Illustrations"
fill="none"
fill-rule="evenodd"
stroke="none"
stroke-width="1"
>
<g id="B-type" transform="translate(-1268.000000, -535.000000)">
<g id="Group-2" transform="translate(1268.000000, 535.000000)">
<path
id="Oval-Copy-2"
d="M39.5,86 C61.3152476,86 79,83.9106622 79,81.3333333 C79,78.7560045 57.3152476,78 35.5,78 C13.6847524,78 0,78.7560045 0,81.3333333 C0,83.9106622 17.6847524,86 39.5,86 Z"
fill="#F7F8FC"
/>
<polygon
id="Rectangle-Copy-14"
fill="#E5E7E9"
points="13 58 53 58 42 45 2 45"
transform="translate(27.500000, 51.500000) scale(1, -1) translate(-27.500000, -51.500000) "
/>
<g id="Group-Copy"
transform="translate(34.500000, 31.500000) scale(-1, 1) rotate(-25.000000) translate(-34.500000, -31.500000) translate(7.000000, 10.000000)">
<polygon
id="Rectangle-Copy-10"
fill="#E5E7E9"
points="2.84078316e-14 3 18 3 23 7 5 7"
transform="translate(11.500000, 5.000000) scale(1, -1) translate(-11.500000, -5.000000) "
/>
<polygon id="Rectangle-Copy-11" fill="#EDEEF2" points="-3.69149156e-15 7 38 7 38 43 -3.69149156e-15 43"/>
<rect
id="Rectangle-Copy-12"
fill="url(#linearGradient-1)"
height="36"
transform="translate(46.500000, 25.000000) scale(-1, 1) translate(-46.500000, -25.000000) "
width="17"
x="38"
y="7"
/>
<polygon
id="Rectangle-Copy-13"
fill="#F8F9FB"
points="24 7 41 7 55 -3.63806207e-12 38 -3.63806207e-12"
transform="translate(39.500000, 3.500000) scale(-1, 1) translate(-39.500000, -3.500000) "
/>
</g>
<rect
id="Rectangle-Copy-15"
fill="url(#linearGradient-2)"
height="36"
width="40"
x="13"
y="45"
/>
<g id="Rectangle-Copy-17" transform="translate(53.000000, 45.000000)">
<mask id="mask-4" fill="white">
<use xlink:href="#path-3"/>
</mask>
<use
id="Mask"
fill="#E0E3E9"
transform="translate(8.500000, 18.000000) scale(-1, 1) translate(-8.500000, -18.000000) "
xlink:href="#path-3"
/>
<polygon
id="Rectangle-Copy"
fill="#D5D7DE"
mask="url(#mask-4)"
points="7 0 24 0 20 18 -1.70530257e-13 16"
transform="translate(12.000000, 9.000000) scale(-1, 1) translate(-12.000000, -9.000000) "
/>
</g>
<polygon
id="Rectangle-Copy-18"
fill="#F8F9FB"
points="62 45 79 45 70 58 53 58"
transform="translate(66.000000, 51.500000) scale(-1, 1) translate(-66.000000, -51.500000) "
/>
</g>
</g>
</g>
</svg>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
export default defineComponent({
name: 'ImgEmpty',
setup() {
return {};
},
});
</script>
<style lang="scss" scoped>
.img-empty {
max-width: 100%;
max-height: 100%;
}
</style>

View File

@@ -1,823 +0,0 @@
<template>
<div ref="fileEditor" class="file-editor">
<div :class="navMenuCollapsed ? 'collapsed' : ''" class="nav-menu">
<div
:style="{
backgroundColor: style.backgroundColorGutters,
color: style.color,
}"
class="nav-menu-top-bar"
>
<div class="left">
<el-input
v-model="fileSearchString"
:style="{
color: style.color,
}"
class="search"
clearable
placeholder="Search files..."
prefix-icon="fa fa-search"
size="mini"
/>
</div>
<div class="right">
<el-tooltip content="Settings">
<span class="action-icon" @click="showSettings = true">
<div class="background"/>
<font-awesome-icon :icon="['fa', 'cog']"/>
</span>
</el-tooltip>
<el-tooltip content="Hide files">
<span class="action-icon" @click="onToggleNavMenu">
<div class="background"/>
<font-awesome-icon :icon="['fa', 'minus']"/>
</span>
</el-tooltip>
</div>
</div>
<FileEditorNavMenu
:active-item="activeFileItem"
:default-expand-all="!!fileSearchString"
:items="files"
:style="style"
@node-click="onNavItemClick"
@node-db-click="onNavItemDbClick"
@node-drop="onNavItemDrop"
@ctx-menu-new-file="onContextMenuNewFile"
@ctx-menu-new-directory="onContextMenuNewDirectory"
@ctx-menu-rename="onContextMenuRename"
@ctx-menu-clone="onContextMenuClone"
@ctx-menu-delete="onContextMenuDelete"
@drop-files="onDropFiles"
/>
</div>
<div class="file-editor-content">
<FileEditorNavTabs
ref="navTabs"
:active-tab="activeFileItem"
:tabs="tabs"
:style="style"
@tab-click="onTabClick"
@tab-close="onTabClose"
@tab-close-others="onTabCloseOthers"
@tab-close-all="onTabCloseAll"
@tab-dragend="onTabDragEnd"
>
<template v-if="navMenuCollapsed" #prefix>
<el-tooltip content="Show files">
<span class="action-icon expand-files" @click="onToggleNavMenu">
<div class="background"/>
<font-awesome-icon :icon="['fa', 'bars']"/>
</span>
</el-tooltip>
</template>
</FileEditorNavTabs>
<div
ref="codeMirrorEditor"
:class="showCodeMirrorEditor ? '' : 'hidden'"
:style="{
scrollbar: style.backgroundColorGutters,
}"
class="code-mirror-editor"
/>
<div
v-show="!showCodeMirrorEditor"
:style="{
backgroundColor: style.backgroundColor,
color: style.color,
}"
class="empty-content"
>
You can edit or view a file by double-clicking one of the files on the left.
</div>
<template v-if="navTabs && navTabs.showMoreVisible">
<FileEditorNavTabsShowMoreContextMenu
:tabs="tabs"
:visible="showMoreContextMenuVisible"
@hide="onShowMoreHide"
@tab-click="onClickShowMoreContextMenuItem"
>
<div
:style="{
background: style.backgroundColor,
color: style.color,
}"
class="nav-tabs-suffix"
>
<el-tooltip content="Show more">
<span class="action-icon" @click.prevent="onShowMoreShow">
<div class="background"/>
<font-awesome-icon :icon="['fa', 'angle-down']"/>
</span>
</el-tooltip>
</div>
</FileEditorNavTabsShowMoreContextMenu>
</template>
</div>
</div>
<div ref="codeMirrorTemplate" class="code-mirror-template"/>
<div ref="styleRef" v-html="extraStyle"/>
<FileEditorSettingsDialog/>
</template>
<script lang="ts">
import {computed, defineComponent, onMounted, onUnmounted, PropType, ref, watch} from 'vue';
import CodeMirror, {Editor, EditorConfiguration, KeyMap} from 'codemirror';
import {MimeType} from 'codemirror/mode/meta';
import {useStore} from 'vuex';
import {getCodemirrorEditor, getCodeMirrorTemplate, initTheme} from '@/utils/codemirror';
import variables from '@/styles/variables.scss';
import {FILE_ROOT} from '@/constants/file';
// codemirror css
import 'codemirror/lib/codemirror.css';
// codemirror mode
import 'codemirror/mode/meta';
// codemirror utils
import '@/utils/codemirror';
// components
import FileEditorNavMenu from '@/components/file/FileEditorNavMenu.vue';
import FileEditorNavTabs from '@/components/file/FileEditorNavTabs.vue';
import FileEditorSettingsDialog from '@/components/file/FileEditorSettingsDialog.vue';
import FileEditorNavTabsShowMoreContextMenu from '@/components/file/FileEditorNavTabsShowMoreContextMenu.vue';
// codemirror mode import cache
const codeMirrorModeCache = new Set<string>();
// codemirror tab content cache
const codeMirrorTabContentCache = new Map<string, string>();
export default defineComponent({
name: 'FileEditor',
components: {
FileEditorSettingsDialog,
FileEditorNavTabs,
FileEditorNavMenu,
FileEditorNavTabsShowMoreContextMenu,
},
props: {
content: {
type: String,
required: true,
default: '',
},
activeNavItem: {
type: Object as PropType<FileNavItem>,
required: false,
},
navItems: {
type: Array,
required: true,
default: () => {
return [];
},
},
},
emits: [
'content-change',
'tab-click',
'node-click',
'node-db-click',
'node-drop',
'save-file',
'ctx-menu-new-file',
'ctx-menu-new-directory',
'ctx-menu-rename',
'ctx-menu-clone',
'ctx-menu-delete',
'drop-files',
],
setup(props, {emit}) {
const ns = 'spider';
const store = useStore();
const {file} = store.state as RootStoreState;
const fileEditor = ref<HTMLDivElement>();
const codeMirrorEditor = ref<HTMLDivElement>();
const tabs = ref<FileNavItem[]>([]);
const activeFileItem = computed<FileNavItem | undefined>(() => props.activeNavItem);
const style = ref<FileEditorStyle>({});
const fileSearchString = ref<string>('');
const navMenuCollapsed = ref<boolean>(false);
const showSettings = ref<boolean>(false);
const styleRef = ref<HTMLDivElement>();
let editor: Editor | null = null;
let codeMirrorTemplateEditor: Editor | null = null;
const codeMirrorTemplate = ref<HTMLDivElement>();
let codeMirrorEditorSearchLabel: HTMLSpanElement | undefined;
let codeMirrorEditorSearchInput: HTMLInputElement | undefined;
const navTabs = ref<typeof FileEditorNavTabs>();
const showMoreContextMenuVisible = ref<boolean>(false);
const showCodeMirrorEditor = computed<boolean>(() => {
return !!activeFileItem.value;
});
const language = computed<MimeType | undefined>(() => {
const fileName = activeFileItem.value?.name;
if (!fileName) return;
return CodeMirror.findModeByFileName(fileName);
});
const languageMime = computed<string | undefined>(() => language.value?.mime);
const options = computed<FileEditorConfiguration>(() => {
const {editorOptions} = file as FileStoreState;
return {
mode: languageMime.value || 'text',
...editorOptions,
};
});
const content = computed<string>(() => {
const {content} = props as FileEditorProps;
return content || '';
});
const extraStyle = computed<string>(() => {
return `<style>
.file-editor .file-editor-nav-menu::-webkit-scrollbar {
background-color: ${style.value.backgroundColor};
width: 8px;
height: 8px;
}
.file-editor .file-editor-nav-menu::-webkit-scrollbar-thumb {
background-color: ${variables.primaryColor};
border-radius: 4px;
}
.file-editor .file-editor-content .code-mirror-editor .CodeMirror-vscrollbar::-webkit-scrollbar {
background-color: ${style.value.backgroundColor};
width: 8px;
}
.file-editor .file-editor-content .code-mirror-editor .CodeMirror-hscrollbar::-webkit-scrollbar {
background-color: ${style.value.backgroundColor};
height: 8px;
}
.file-editor .file-editor-content .code-mirror-editor .CodeMirror-vscrollbar::-webkit-scrollbar-thumb,
.file-editor .file-editor-content .code-mirror-editor .CodeMirror-hscrollbar::-webkit-scrollbar-thumb {
background-color: ${variables.primaryColor};
border-radius: 4px;
}
.file-editor .file-editor-nav-tabs::-webkit-scrollbar {
display: none;
}
</style>`;
});
const codeMirrorTemplateContent = computed<string>(() => {
return getCodeMirrorTemplate();
});
const updateEditorOptions = () => {
for (const k in options.value) {
const key = k as keyof EditorConfiguration;
const value = options.value[key];
editor?.setOption(key, value);
}
};
const updateEditorContent = () => {
editor?.setValue(content.value || '');
};
const updateStyle = () => {
// codemirror style: background color / color / height
const el = codeMirrorEditor.value as HTMLElement;
const cm = el.querySelector('.CodeMirror');
if (!cm) return;
const computedStyle = window.getComputedStyle(cm);
style.value = {
backgroundColor: computedStyle.backgroundColor,
color: computedStyle.color,
height: computedStyle.height,
};
// gutter
const cmGutters = el.querySelector('.CodeMirror-gutters');
if (!cmGutters) return;
const computedStyleGutters = window.getComputedStyle(cmGutters);
style.value.backgroundColorGutters = computedStyleGutters.backgroundColor;
};
const updateTheme = async () => {
await initTheme(options.value.theme);
};
const updateMode = async () => {
const mode = language.value?.mode;
if (!mode || codeMirrorModeCache.has(mode)) return;
// eslint-disable-next-line @typescript-eslint/ban-ts-ignore
// @ts-ignore
await import(`codemirror/mode/${mode}/${mode}.js`);
codeMirrorModeCache.add(mode);
};
const updateSearchInput = () => {
codeMirrorEditorSearchLabel = codeMirrorEditor.value?.querySelector<HTMLSpanElement>('.CodeMirror-search-label') || undefined;
codeMirrorEditorSearchInput = codeMirrorEditor.value?.querySelector<HTMLInputElement>('.CodeMirror-search-field') || undefined;
if (!codeMirrorEditorSearchInput) return;
codeMirrorEditorSearchInput.onblur = () => {
if (codeMirrorEditorSearchLabel?.textContent?.includes('Search')) {
setTimeout(() => {
codeMirrorEditorSearchInput?.parentElement?.remove();
editor?.focus();
}, 10);
}
};
};
const getContentCache = (tab: FileNavItem) => {
if (!tab.path) return;
const key = tab.path;
const content = codeMirrorTabContentCache.get(key);
emit('content-change', content as string);
setTimeout(updateEditorContent, 0);
};
const updateContentCache = (tab: FileNavItem, content: string) => {
if (!tab.path) return;
const key = tab.path as string;
codeMirrorTabContentCache.set(key, content as string);
};
const deleteContentCache = (tab: FileNavItem) => {
if (!tab.path) return;
const key = tab.path;
codeMirrorTabContentCache.delete(key);
};
const deleteOtherContentCache = (tab: FileNavItem) => {
if (!tab.path) return;
const key = tab.path;
const content = codeMirrorTabContentCache.get(key);
codeMirrorTabContentCache.clear();
codeMirrorTabContentCache.set(key, content as string);
};
const clearContentCache = () => {
codeMirrorTabContentCache.clear();
};
const getFilteredFiles = (items: FileNavItem[]): FileNavItem[] => {
return items
.filter(d => {
if (!d.is_dir) {
return d.name?.toLowerCase().includes(fileSearchString.value.toLowerCase());
}
if (d.children) {
const children = getFilteredFiles(d.children);
if (children.length > 0) {
return true;
}
}
return false;
})
.map(d => {
if (!d.is_dir) return d;
d.children = getFilteredFiles(d.children || []);
return d;
});
};
const files = computed<FileNavItem[]>(() => {
const {navItems} = props as FileEditorProps;
const root: FileNavItem = {
path: FILE_ROOT,
name: FILE_ROOT,
is_dir: true,
children: fileSearchString.value ? getFilteredFiles(navItems) : navItems,
};
return [root];
});
const updateTabs = (item?: FileNavItem) => {
// add tab
if (item && !tabs.value.find(t => t.path === item.path)) {
if (tabs.value.length === 0) {
store.commit(`${ns}/setActiveFileNavItem`, item);
editor?.focus();
}
tabs.value.push(item);
getContentCache(item);
}
};
const onNavItemClick = (item: FileNavItem) => {
emit('node-click', item);
};
const onNavItemDbClick = (item: FileNavItem) => {
store.commit(`${ns}/setActiveFileNavItem`, item);
emit('node-db-click', item);
// update tabs
updateTabs(item);
};
const onNavItemDrop = (draggingItem: FileNavItem, dropItem: FileNavItem) => {
emit('node-drop', draggingItem, dropItem);
};
const onContextMenuNewFile = (item: FileNavItem, name: string) => {
emit('ctx-menu-new-file', item, name);
};
const onContextMenuNewDirectory = (item: FileNavItem, name: string) => {
emit('ctx-menu-new-directory', item, name);
};
const onContextMenuRename = (item: FileNavItem, name: string) => {
emit('ctx-menu-rename', item, name);
};
const onContextMenuClone = (item: FileNavItem, name: string) => {
emit('ctx-menu-clone', item, name);
};
const onContextMenuDelete = (item: FileNavItem) => {
emit('ctx-menu-delete', item);
};
const onContentChange = (cm: Editor) => {
const content = cm.getValue();
if (!activeFileItem.value) return;
emit('content-change', content);
// update in cache
updateContentCache(activeFileItem.value, content);
};
const onTabClick = (tab: FileNavItem) => {
store.commit(`${ns}/setActiveFileNavItem`, tab);
emit('tab-click', tab);
// get from cache and update content
getContentCache(tab);
};
const closeTab = (tab: FileNavItem) => {
const idx = tabs.value.findIndex(t => t.path === tab.path);
if (idx !== -1) {
tabs.value.splice(idx, 1);
}
if (activeFileItem.value) {
if (activeFileItem.value.path === tab.path) {
if (idx === 0) {
store.commit(`${ns}/setActiveFileNavItem`, tabs.value[0]);
} else {
store.commit(`${ns}/setActiveFileNavItem`, tabs.value[idx - 1]);
}
}
// get from cache
setTimeout(() => {
if (activeFileItem.value) {
getContentCache(activeFileItem.value);
}
}, 0);
}
// delete in cache
deleteContentCache(tab);
};
const onTabClose = (tab: FileNavItem) => {
closeTab(tab);
};
const onTabCloseOthers = (tab: FileNavItem) => {
tabs.value = [tab];
store.commit(`${ns}/setActiveFileNavItem`, tab);
// clear cache and update current tab content
deleteOtherContentCache(tab);
};
const onTabCloseAll = () => {
tabs.value = [];
store.commit(`${ns}/resetActiveFileNavItem`);
// clear cache
clearContentCache();
};
const onTabDragEnd = (newTabs: FileNavItem[]) => {
tabs.value = newTabs;
};
const onShowMoreShow = () => {
showMoreContextMenuVisible.value = true;
};
const onShowMoreHide = () => {
showMoreContextMenuVisible.value = false;
};
const onClickShowMoreContextMenuItem = (tab: FileNavItem) => {
store.commit(`${ns}/setActiveFileNavItem`, tab);
emit('tab-click', tab);
};
const keyMapSave = () => {
if (!activeFileItem.value) return;
emit('save-file', activeFileItem.value);
};
const keyMapClose = () => {
if (!activeFileItem.value) return;
closeTab(activeFileItem.value);
};
const addSaveKeyMap = (cm: Editor) => {
const map = {
'Cmd-S': keyMapSave,
'Ctrl-S': keyMapSave,
// 'Cmd-W': keyMapClose,
'Ctrl-W': keyMapClose,
} as KeyMap;
cm.addKeyMap(map);
};
const onToggleNavMenu = () => {
navMenuCollapsed.value = !navMenuCollapsed.value;
};
const listenToKeyboardEvents = () => {
editor?.on('blur', () => {
updateSearchInput();
});
};
const unlistenToKeyboardEvents = () => {
document.onkeydown = null;
};
watch(options, async () => {
await Promise.all([
updateMode(),
updateTheme(),
]);
updateEditorOptions();
updateStyle();
});
const onDropFiles = (files: InputFile[]) => {
emit('drop-files', files);
};
onMounted(async () => {
// init codemirror editor
const el = codeMirrorEditor.value as HTMLElement;
editor = getCodemirrorEditor(el, options.value);
// add save key map
addSaveKeyMap(editor);
// on editor change
editor.on('change', onContentChange);
// update editor options
updateEditorOptions();
// update editor content
updateEditorContent();
// update editor theme
await updateTheme();
// update styles
updateStyle();
// listen to keyboard events key
listenToKeyboardEvents();
// init codemirror template
const elTemplate = codeMirrorTemplate.value as HTMLElement;
codeMirrorTemplateEditor = getCodemirrorEditor(elTemplate, options.value);
codeMirrorTemplateEditor.setValue(codeMirrorTemplateContent.value);
codeMirrorTemplateEditor.setOption('mode', 'text/x-python');
});
onUnmounted(() => {
// turnoff listening to keyboard events
unlistenToKeyboardEvents();
});
return {
fileEditor,
codeMirrorEditor,
tabs,
activeFileItem,
fileSearchString,
navMenuCollapsed,
styleRef,
codeMirrorTemplate,
showSettings,
showCodeMirrorEditor,
navTabs,
showMoreContextMenuVisible,
languageMime,
options,
style,
files,
extraStyle,
variables,
onNavItemClick,
onNavItemDbClick,
onNavItemDrop,
onContextMenuNewFile,
onContextMenuNewDirectory,
onContextMenuRename,
onContextMenuClone,
onContextMenuDelete,
onContentChange,
onTabClick,
onTabClose,
onTabCloseOthers,
onTabCloseAll,
onTabDragEnd,
onToggleNavMenu,
onShowMoreShow,
onShowMoreHide,
onClickShowMoreContextMenuItem,
updateTabs,
updateEditorContent,
updateContentCache,
onDropFiles,
};
},
});
</script>
<style lang="scss" scoped>
@import "../../styles/variables.scss";
.file-editor {
height: 100%;
display: flex;
.nav-menu {
flex-basis: $fileEditorNavMenuWidth;
min-width: $fileEditorNavMenuWidth;
display: flex;
flex-direction: column;
transition: all $fileEditorNavMenuCollapseTransitionDuration;
&.collapsed {
min-width: 0;
flex-basis: 0;
overflow: hidden;
}
.nav-menu-top-bar {
flex-basis: $fileEditorNavMenuTopBarHeight;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12px;
padding: 0 10px 0 0;
.left,
.right {
display: flex;
}
}
}
.file-editor-content {
position: relative;
flex: 1;
display: flex;
min-width: calc(100% - #{$fileEditorNavMenuWidth});
flex-direction: column;
.code-mirror-editor {
flex: 1;
&.hidden {
position: fixed;
top: -100vh;
left: 0;
height: 100vh;
}
}
.empty-content {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.nav-tabs-suffix {
width: 30px;
position: absolute;
top: 0;
right: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
height: $fileEditorNavTabsHeight;
}
}
.action-icon {
position: relative;
height: 16px;
width: 16px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 12px;
&:hover {
.background {
background-color: $fileEditorMaskBg;
border-radius: 8px;
}
}
&.expand-files {
width: 29px;
text-align: center;
}
.background {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
}
}
.code-mirror-template {
position: fixed;
top: -100vh;
left: 0;
height: 100vh;
}
</style>
<style scoped>
.file-editor .nav-menu .nav-menu-top-bar >>> .search.el-input .el-input__inner {
border: none;
background: transparent;
color: inherit;
}
.file-editor .file-editor-content .code-mirror-editor >>> .CodeMirror {
position: relative;
min-height: 100%;
}
.file-editor .file-editor-content .code-mirror-editor >>> .CodeMirror.dialog-opened {
position: relative;
}
.file-editor .file-editor-content .code-mirror-editor >>> .CodeMirror-dialog {
font-size: 14px;
}
.file-editor .file-editor-content .code-mirror-editor >>> .CodeMirror-dialog.CodeMirror-dialog-top {
position: absolute;
top: 10px;
right: 10px;
z-index: 2;
}
.file-editor .file-editor-content .code-mirror-editor >>> .CodeMirror-dialog.CodeMirror-dialog-bottom {
position: absolute;
bottom: 10px;
right: 10px;
z-index: 2;
}
.file-editor .file-editor-content .code-mirror-editor >>> .CodeMirror-search-field {
background-color: transparent;
border: 1px solid;
color: inherit;
outline: none;
}
</style>

View File

@@ -1,479 +0,0 @@
<template>
<div
:style="{
backgroundColor: style.backgroundColorGutters,
borderRight: `1px solid ${style.backgroundColor}`
}"
ref="fileEditorNavMenu"
class="file-editor-nav-menu"
>
<el-tree
ref="tree"
:render-after-expand="defaultExpandAll"
:data="items"
:expand-on-click-node="false"
:highlight-current="false"
:allow-drop="allowDrop"
empty-text="No files available"
icon-class="fa fa-angle-right"
:style="{
backgroundColor: style.backgroundColorGutters,
color: style.color,
}"
node-key="path"
:default-expanded-keys="defaultExpandedKeys"
draggable
@node-drag-enter="onNodeDragEnter"
@node-drag-leave="onNodeDragLeave"
@node-drag-end="onNodeDragEnd"
@node-drop="onNodeDrop"
@node-click="onNodeClick"
@node-contextmenu="onNodeContextMenuShow"
@node-expand="onNodeExpand"
@node-collapse="onNodeCollapse"
>
<template #default="{ data }">
<FileEditorNavMenuContextMenu
:clicking="contextMenuClicking"
:visible="isShowContextMenu(data)"
@hide="onNodeContextMenuHide"
@clone="onNodeContextMenuClone(data)"
@delete="onNodeContextMenuDelete(data)"
@rename="onNodeContextMenuRename(data)"
@new-file="onNodeContextMenuNewFile(data)"
@new-directory="onNodeContextMenuNewDirectory(data)"
>
<div
v-bind="getBindDir(data)"
:class="getItemClass(data)"
class="nav-item-wrapper"
>
<div class="background"/>
<div class="nav-item">
<span class="icon">
<atom-material-icon :is-dir="data.is_dir" :name="data.name"/>
</span>
<span class="title">
{{ data.name }}
</span>
</div>
</div>
</FileEditorNavMenuContextMenu>
</template>
</el-tree>
</div>
</template>
<script lang="ts">
import {computed, defineComponent, onMounted, onUnmounted, reactive, ref} from 'vue';
import {ClickOutside} from 'element-plus/lib/directives';
import Node from 'element-plus/lib/el-tree/src/model/node';
import {DropType} from 'element-plus/lib/el-tree/src/tree.type';
import AtomMaterialIcon from '@/components/icon/AtomMaterialIcon.vue';
import {KEY_CONTROL, KEY_META} from '@/constants/keyboard';
import FileEditorNavMenuContextMenu from '@/components/file/FileEditorNavMenuContextMenu.vue';
import {ElMessageBox, ElTree} from 'element-plus';
import {useDropzone} from 'vue3-dropzone';
export default defineComponent({
name: 'FileEditorNavMenu',
components: {
FileEditorNavMenuContextMenu,
AtomMaterialIcon
},
directives: {
ClickOutside,
},
props: {
activeItem: {
type: Object,
required: false,
},
items: {
type: Array,
required: true,
default: () => {
return [];
},
},
defaultExpandAll: {
type: Boolean,
required: true,
default: false,
},
style: {
type: Object,
required: false,
default: () => {
return {};
},
},
},
emits: [
'node-click',
'node-db-click',
'node-drop',
'ctx-menu-new-file',
'ctx-menu-new-directory',
'ctx-menu-rename',
'ctx-menu-clone',
'ctx-menu-delete',
'drop-files',
],
setup(props, ctx) {
const {emit} = ctx;
const tree = ref<typeof ElTree>();
const fileEditorNavMenu = ref<HTMLDivElement>();
const clickStatus = reactive<FileEditorNavMenuClickStatus>({
clicked: false,
item: undefined,
});
const selectedCache = reactive<FileEditorNavMenuCache<boolean>>({});
const dragCache = reactive<FileEditorNavMenuCache<boolean>>({});
const isCtrlKeyPressed = ref<boolean>(false);
const activeContextMenuItem = ref<FileNavItem>();
const contextMenuClicking = ref<boolean>(false);
const expandedKeys = ref<string[]>([]);
const defaultExpandedKeys = computed<string[]>(() => {
return ['~'].concat(expandedKeys.value);
});
const addDefaultExpandedKey = (key: string) => {
if (!expandedKeys.value.includes(key)) expandedKeys.value.push(key);
};
const removeDefaultExpandedKey = (key: string) => {
if (!expandedKeys.value.includes(key)) return;
const idx = expandedKeys.value.indexOf(key);
expandedKeys.value.splice(idx, 1);
};
const resetDefaultExpandedKeys = () => {
expandedKeys.value = [];
};
const resetClickStatus = () => {
clickStatus.clicked = false;
clickStatus.item = undefined;
activeContextMenuItem.value = undefined;
};
const updateSelectedMap = (item: FileNavItem) => {
const key = item.path;
if (!key) {
console.warn('No path specified for FileNavItem');
return;
}
if (!selectedCache[key]) {
selectedCache[key] = false;
}
selectedCache[key] = !selectedCache[key];
// if Ctrl key is not pressed, clear other selection
if (!isCtrlKeyPressed.value) {
Object.keys(selectedCache).filter(k => k !== key).forEach(k => {
selectedCache[k] = false;
});
}
};
const onNodeClick = (item: FileNavItem) => {
if (clickStatus.clicked && clickStatus.item?.path === item.path) {
emit('node-db-click', item);
updateSelectedMap(item);
resetClickStatus();
return;
}
clickStatus.item = item;
clickStatus.clicked = true;
setTimeout(() => {
if (clickStatus.clicked) {
emit('node-click', item);
updateSelectedMap(item);
}
resetClickStatus();
}, 200);
};
const onNodeContextMenuShow = (ev: Event, item: FileNavItem) => {
contextMenuClicking.value = true;
activeContextMenuItem.value = item;
setTimeout(() => {
contextMenuClicking.value = false;
}, 500);
};
const onNodeContextMenuHide = () => {
activeContextMenuItem.value = undefined;
};
const onNodeContextMenuNewFile = async (item: FileNavItem) => {
const res = await ElMessageBox.prompt('Please enter the name of the new file', 'New File');
emit('ctx-menu-new-file', item, res.value);
};
const onNodeContextMenuNewDirectory = async (item: FileNavItem) => {
const res = await ElMessageBox.prompt('Please enter the name of the new directory', 'New Directory');
emit('ctx-menu-new-directory', item, res.value);
};
const onNodeContextMenuRename = async (item: FileNavItem) => {
const res = await ElMessageBox.prompt('Please enter the new name', 'Rename');
emit('ctx-menu-rename', item, res.value);
};
const onNodeContextMenuClone = async (item: FileNavItem) => {
const res = await ElMessageBox.prompt('Please enter the new name', 'Clone');
emit('ctx-menu-clone', item, res.value);
};
const onNodeContextMenuDelete = async (item: FileNavItem) => {
await ElMessageBox.confirm('Are you sure to delete?', 'Delete');
emit('ctx-menu-delete', item);
};
const onNodeDragEnter = (draggingNode: Node, dropNode: Node) => {
const item = dropNode.data as FileNavItem;
if (!item.path) return;
dragCache[item.path] = true;
};
const onNodeDragLeave = (draggingNode: Node, dropNode: Node) => {
const item = dropNode.data as FileNavItem;
if (!item.path) return;
dragCache[item.path] = false;
};
const onNodeDragEnd = () => {
for (const key in dragCache) {
dragCache[key] = false;
}
};
const onNodeDrop = (draggingNode: Node, dropNode: Node) => {
const draggingItem = draggingNode.data as FileNavItem;
const dropItem = dropNode.data as FileNavItem;
emit('node-drop', draggingItem, dropItem);
};
const onNodeExpand = (data: FileNavItem) => {
addDefaultExpandedKey(data.path as string);
};
const onNodeCollapse = (data: FileNavItem) => {
removeDefaultExpandedKey(data.path as string);
};
const isSelected = (item: FileNavItem): boolean => {
if (!item.path) return false;
return selectedCache[item.path] || false;
};
const isDroppable = (item: FileNavItem): boolean => {
if (!item.path) return false;
return dragCache[item.path] || false;
};
const isShowContextMenu = (item: FileNavItem) => {
return activeContextMenuItem.value?.path === item.path;
};
const allowDrop = (draggingNode: Node, dropNode: Node, type: DropType) => {
if (type !== 'inner') return false;
if (draggingNode.data?.path === dropNode.data?.path) return false;
if (draggingNode.parent?.data?.path === dropNode.data?.path) return false;
const item = dropNode.data as FileNavItem;
return item.is_dir;
};
const getItemClass = (item: FileNavItem): string[] => {
const cls = [];
if (isSelected(item)) cls.push('selected');
if (isDroppable(item)) cls.push('droppable');
return cls;
};
const {
getRootProps,
} = useDropzone({
onDrop: (files: InputFile[]) => {
emit('drop-files', files);
},
});
const getBindDir = (item: FileNavItem) => getRootProps({
onDragEnter: (ev: DragEvent) => {
ev.stopPropagation();
if (!item.is_dir || !item.path) return;
dragCache[item.path] = true;
},
onDragLeave: (ev: DragEvent) => {
ev.stopPropagation();
if (!item.is_dir || !item.path) return;
dragCache[item.path] = false;
},
onDrop: () => {
for (const key in dragCache) {
dragCache[key] = false;
}
},
});
onMounted(() => {
// listen to keyboard events
document.onkeydown = (ev: KeyboardEvent) => {
if (!ev) return;
if (ev.key === KEY_CONTROL || ev.key === KEY_META) {
isCtrlKeyPressed.value = true;
}
};
document.onkeyup = (ev: KeyboardEvent) => {
if (!ev) return;
if (ev.key === KEY_CONTROL || ev.key === KEY_META) {
isCtrlKeyPressed.value = false;
}
};
});
onUnmounted(() => {
// turnoff listening to keyboard events
document.onkeydown = null;
document.onkeyup = null;
});
return {
tree,
activeContextMenuItem,
fileEditorNavMenu,
contextMenuClicking,
defaultExpandedKeys,
onNodeClick,
onNodeContextMenuShow,
onNodeContextMenuHide,
onNodeContextMenuNewFile,
onNodeContextMenuNewDirectory,
onNodeContextMenuRename,
onNodeContextMenuClone,
onNodeContextMenuDelete,
onNodeDragEnter,
onNodeDragLeave,
onNodeDragEnd,
onNodeDrop,
onNodeExpand,
onNodeCollapse,
isSelected,
isDroppable,
isShowContextMenu,
allowDrop,
getItemClass,
resetDefaultExpandedKeys,
addDefaultExpandedKey,
removeDefaultExpandedKey,
getBindDir,
};
},
});
</script>
<style lang="scss" scoped>
@import "../../styles/variables.scss";
.file-editor-nav-menu {
flex: 1;
max-height: 100%;
overflow: auto;
.el-tree {
height: 100%;
min-width: 100%;
max-width: fit-content;
.el-tree-node {
.nav-item-wrapper {
z-index: 2;
& * {
pointer-events: none;
}
&.selected {
.background {
background-color: $fileEditorMaskBg;
}
.nav-item {
color: $fileEditorNavMenuItemSelectedColor;
}
}
&.droppable {
& * {
pointer-events: none;
}
.nav-item {
border: 1px dashed $fileEditorNavMenuItemDragTargetBorderColor;
}
}
.nav-item:hover,
.background:hover + .nav-item {
color: $fileEditorNavMenuItemSelectedColor;
}
.background {
position: absolute;
width: 100%;
height: 100%;
left: 0;
top: 0;
z-index: -1;
}
.nav-item {
display: flex;
align-items: center;
font-size: 14px;
user-select: none;
.icon {
margin-right: 5px;
}
}
}
}
}
}
</style>
<style scoped>
.file-editor-nav-menu >>> .el-tree .el-tree-node > .el-tree-node__content {
background-color: inherit;
position: relative;
z-index: 0;
}
.file-editor-nav-menu >>> .el-tree .el-tree-node > .el-tree-node__content .el-tree-node__expand-icon {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
font-size: 16px;
padding: 0;
margin: 0;
}
.file-editor-nav-menu >>> .el-tree .el-tree-node * {
transition: none;
}
</style>

View File

@@ -1,47 +0,0 @@
<template>
<ContextMenu :clicking="clicking" :placement="placement" :visible="visible" @hide="$emit('hide')">
<template #default>
<ContextMenuList :items="items" @hide="$emit('hide')"/>
</template>
<template #reference>
<slot></slot>
</template>
</ContextMenu>
</template>
<script lang="ts">
import {defineComponent, readonly} from 'vue';
import ContextMenu, {contextMenuDefaultProps} from '@/components/context-menu/ContextMenu.vue';
import ContextMenuList from '@/components/context-menu/ContextMenuList.vue';
export default defineComponent({
name: 'FileEditorNavMenuContextMenu',
components: {ContextMenuList, ContextMenu},
props: contextMenuDefaultProps,
emits: [
'hide',
'new-file',
'new-directory',
'rename',
'clone',
'delete',
],
setup(props, {emit}) {
const items = readonly<ContextMenuItem[]>([
{title: 'New File', icon: ['fa', 'file-alt'], action: () => emit('new-file')},
{title: 'New Directory', icon: ['fa', 'folder-plus'], action: () => emit('new-directory')},
{title: 'Rename', icon: ['fa', 'edit'], action: () => emit('rename')},
{title: 'Duplicate', icon: ['fa', 'clone'], action: () => emit('clone')},
{title: 'Delete', icon: ['fa', 'trash'], action: () => emit('delete')},
]);
return {
items,
};
},
});
</script>
<style lang="scss" scoped>
</style>

View File

@@ -1,271 +0,0 @@
<template>
<div
ref="navTabs"
:style="{
backgroundColor: style.backgroundColorGutters,
color: style.color,
}"
class="file-editor-nav-tabs"
>
<slot name="prefix"></slot>
<DraggableList
item-key="path"
:items="tabs"
@d-end="onDragEnd"
>
<template v-slot="{item}">
<FileEditorNavTabsContextMenu
:clicking="contextMenuClicking"
:visible="isShowContextMenu(item)"
@close="onClose(item)"
@hide="onContextMenuHide"
@close-others="onCloseOthers(item)"
@close-all="onCloseAll"
>
<div
:class="activeTab && activeTab.path === item.path ? 'active' : ''"
:style="{
backgroundColor: style.backgroundColor,
}"
class="file-editor-nav-tab"
@click="onClick(item)"
@contextmenu.prevent="onContextMenuShow(item)"
>
<span class="icon">
<atom-material-icon :is-dir="item.is_dir" :name="item.name"/>
</span>
<el-tooltip :content="getTitle(item)" :show-after="500">
<span class="title">
{{ getTitle(item) }}
</span>
</el-tooltip>
<span class="close-btn" @click.stop="onClose(item)">
<i class="el-icon-close"></i>
</span>
<div class="background"/>
</div>
</FileEditorNavTabsContextMenu>
</template>
</DraggableList>
</div>
</template>
<script lang="ts">
import {computed, defineComponent, onMounted, ref, watch} from 'vue';
import DraggableList from '@/components/drag/DraggableList.vue';
import AtomMaterialIcon from '@/components/icon/AtomMaterialIcon.vue';
import FileEditorNavTabsContextMenu from '@/components/file/FileEditorNavTabsContextMenu.vue';
export default defineComponent({
name: 'FileEditorNavTabs',
components: {FileEditorNavTabsContextMenu, AtomMaterialIcon, DraggableList},
props: {
activeTab: {
type: Object,
required: false,
},
tabs: {
type: Array,
required: true,
default: () => {
return [];
},
},
style: {
type: Object,
required: false,
default: () => {
return {};
},
},
},
emits: [
'tab-click',
'tab-close',
'tab-close-others',
'tab-close-all',
'tab-dragend',
'show-more',
],
setup(props, {emit}) {
const activeContextMenuItem = ref<FileNavItem>();
const navTabs = ref<HTMLDivElement>();
const navTabsWidth = ref<number>();
const navTabsOverflowWidth = ref<number>();
const showMoreVisible = computed<boolean>(() => {
if (navTabsWidth.value === undefined || navTabsOverflowWidth.value === undefined) return false;
return navTabsOverflowWidth.value > navTabsWidth.value;
});
const contextMenuClicking = ref<boolean>(false);
const tabs = computed<FileNavItem[]>(() => {
const {tabs} = props as FileEditorNavTabsProps;
return tabs;
});
const getTitle = (item: FileNavItem) => {
return item.name;
};
const onClick = (item: FileNavItem) => {
emit('tab-click', item);
};
const onClose = (item: FileNavItem) => {
emit('tab-close', item);
};
const onCloseOthers = (item: FileNavItem) => {
emit('tab-close-others', item);
};
const onCloseAll = () => {
emit('tab-close-all');
};
const onDragEnd = (items: FileNavItem[]) => {
emit('tab-dragend', items);
};
const onContextMenuShow = (item: FileNavItem) => {
contextMenuClicking.value = true;
activeContextMenuItem.value = item;
setTimeout(() => {
contextMenuClicking.value = false;
}, 500);
};
const onContextMenuHide = () => {
activeContextMenuItem.value = undefined;
};
const isShowContextMenu = (item: FileNavItem) => {
return activeContextMenuItem.value?.path === item.path;
};
const updateWidths = () => {
if (!navTabs.value) return;
// width
navTabsWidth.value = Number(getComputedStyle(navTabs.value).width.replace('px', ''));
// overflow width
const el = navTabs.value.querySelector('.draggable-list');
if (el) {
navTabsOverflowWidth.value = Number(getComputedStyle(el).width.replace('px', ''));
}
};
watch(tabs.value, () => {
setTimeout(updateWidths, 100);
});
onMounted(() => {
// update tabs widths
updateWidths();
});
return {
activeContextMenuItem,
navTabs,
navTabsWidth,
navTabsOverflowWidth,
showMoreVisible,
contextMenuClicking,
getTitle,
onClick,
onClose,
onCloseOthers,
onCloseAll,
onDragEnd,
onContextMenuShow,
onContextMenuHide,
isShowContextMenu,
};
},
});
</script>
<style lang="scss" scoped>
@import "../../styles/variables.scss";
.file-editor-nav-tabs {
position: relative;
display: flex;
align-items: center;
overflow: auto;
height: $fileEditorNavTabsHeight;
.file-editor-nav-tab {
position: relative;
display: flex;
align-items: center;
justify-content: left;
height: $fileEditorNavTabsHeight;
max-width: $fileEditorNavTabsItemMaxWidth;
white-space: nowrap;
text-overflow: ellipsis;
padding: 0 10px;
font-size: 14px;
cursor: pointer;
box-sizing: border-box;
z-index: 1;
&:hover {
.background {
background-color: $fileEditorMaskBg;
}
}
&.active {
border-bottom: 2px solid $primaryColor;
.title {
color: $fileEditorNavTabsItemActiveColor;
}
}
.background {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
z-index: 0;
}
.icon {
margin-right: 5px;
z-index: 1;
}
.title {
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
color: $fileEditorNavTabsItemColor;
z-index: 1;
}
.close-btn {
margin-left: 5px;
z-index: 1;
}
}
.suffix {
position: absolute;
right: 0;
top: 0;
height: 100%;
display: flex;
align-items: center;
}
}
</style>

View File

@@ -1,43 +0,0 @@
<template>
<ContextMenu :clicking="clicking" :placement="placement" :visible="visible" @hide="$emit('hide')">
<template #default>
<ContextMenuList :items="items" @hide="$emit('hide')"/>
</template>
<template #reference>
<slot></slot>
</template>
</ContextMenu>
</template>
<script lang="ts">
import {defineComponent, readonly} from 'vue';
import ContextMenu, {contextMenuDefaultProps} from '@/components/context-menu/ContextMenu.vue';
import ContextMenuList from '@/components/context-menu/ContextMenuList.vue';
export default defineComponent({
name: 'FileEditorNavTabsContextMenu',
components: {ContextMenuList, ContextMenu},
props: contextMenuDefaultProps,
emits: [
'hide',
'close',
'close-others',
'close-all',
],
setup(props, {emit}) {
const items = readonly<ContextMenuItem[]>([
{title: 'Close', icon: ['fa', 'times'], action: () => emit('close')},
{title: 'Close Others', action: () => emit('close-others')},
{title: 'Close All', action: () => emit('close-all')},
]);
return {
items,
};
},
});
</script>
<style lang="scss" scoped>
</style>

View File

@@ -1,54 +0,0 @@
<template>
<ContextMenu :placement="placement" :visible="visible" @hide="$emit('hide')">
<template #default>
<ContextMenuList :items="items" @hide="$emit('hide')"/>
</template>
<template #reference>
<slot></slot>
</template>
</ContextMenu>
</template>
<script lang="ts">
import {computed, defineComponent} from 'vue';
import ContextMenu, {contextMenuDefaultProps} from '@/components/context-menu/ContextMenu.vue';
import ContextMenuList from '@/components/context-menu/ContextMenuList.vue';
export default defineComponent({
name: 'FileEditorNavTabsShowMoreContextMenu',
components: {ContextMenuList, ContextMenu},
props: {
tabs: {
type: Array,
default: () => {
return [];
},
},
...contextMenuDefaultProps,
},
emits: [
'tab-click',
],
setup(props, {emit}) {
const items = computed<ContextMenuItem[]>(() => {
const {tabs} = props as FileEditorNavTabsShowMoreContextMenuProps;
const contextMenuItems: ContextMenuItem[] = tabs.map(t => {
return {
title: t.path || '',
icon: t.name || '',
action: () => emit('tab-click', t),
};
});
return contextMenuItems;
});
return {
items,
};
},
});
</script>
<style lang="scss" scoped>
</style>

View File

@@ -1,198 +0,0 @@
<template>
<div class="file-editor-settings-dialog">
<el-dialog
:model-value="visible"
title="File Editor Settings"
@close="onClose"
>
<el-menu :default-active="activeTabName" class="nav-menu" mode="horizontal" @select="onTabChange">
<el-menu-item v-for="tab in tabs" :key="tab.name" :index="tab.name">
{{ tab.title }}
</el-menu-item>
</el-menu>
<el-form
:label-width="variables.fileEditorSettingsDialogLabelWidth"
class="form"
size="small"
>
<el-form-item
v-for="name in optionNames[activeTabName]"
:key="name"
>
<template #label>
<el-tooltip :content="getDefinitionDescription(name)" popper-class="help-tooltip" trigger="click">
<font-awesome-icon :icon="['far', 'question-circle']" class="icon" size="sm"/>
</el-tooltip>
{{ getDefinitionTitle(name) }}
</template>
<FileEditorSettingsFormItem v-model="options[name]" :name="name"/>
</el-form-item>
</el-form>
<template #footer>
<el-button plain size="small" type="info" @click="onClose">Cancel</el-button>
<el-button size="small" type="primary" @click="onConfirm">Save</el-button>
</template>
</el-dialog>
</div>
</template>
<script lang="ts">
import {computed, defineComponent, onBeforeMount, readonly, ref} from 'vue';
import {useStore} from 'vuex';
import {plainClone} from '@/utils/object';
import variables from '@/styles/variables.scss';
import {getOptionDefinition, getThemes} from '@/utils/codemirror';
import FileEditorSettingsFormItem from '@/components/file/FileEditorSettingsFormItem.vue';
import {onBeforeRouteLeave} from 'vue-router';
export default defineComponent({
name: 'FileEditorSettingsDialog',
components: {FileEditorSettingsFormItem},
setup() {
const storeNamespace = 'file';
const store = useStore();
const {file} = store.state as RootStoreState;
const options = ref<FileEditorConfiguration>({});
const tabs = readonly([
{name: 'general', title: 'General'},
{name: 'edit', title: 'Edit'},
{name: 'indentation', title: 'Indentation'},
{name: 'cursor', title: 'Cursor'},
]);
const optionNames = readonly({
general: [
'theme',
'keyMap',
'lineWrapping',
'lineNumbers',
'maxHighlightLength',
'spellcheck',
'autocorrect',
'autocapitalize',
],
edit: [
'lineWiseCopyCut',
'pasteLinesPerSelection',
'undoDepth',
],
indentation: [
'indentUnit',
'smartIndent',
'tabSize',
'indentWithTabs',
'electricChars',
],
cursor: [
'showCursorWhenSelecting',
'cursorBlinkRate',
'cursorScrollMargin',
'cursorHeight',
],
});
const activeTabName = ref<string>(tabs[0].name);
const visible = computed<boolean>(() => {
const {editorSettingsDialogVisible} = file;
return editorSettingsDialogVisible;
});
const themes = computed<string[]>(() => {
return getThemes();
});
const resetOptions = () => {
const {editorOptions} = file;
options.value = plainClone(editorOptions);
};
const onClose = () => {
store.commit(`${storeNamespace}/setEditorSettingsDialogVisible`, false);
resetOptions();
};
const onConfirm = () => {
store.commit(`${storeNamespace}/setEditorOptions`, options.value);
store.commit(`${storeNamespace}/setEditorSettingsDialogVisible`, false);
resetOptions();
};
const onTabChange = (tabName: string) => {
activeTabName.value = tabName;
};
const getDefinitionDescription = (name: string) => {
return getOptionDefinition(name)?.description;
};
const getDefinitionTitle = (name: string) => {
return getOptionDefinition(name)?.title;
};
onBeforeMount(() => {
resetOptions();
});
onBeforeRouteLeave(() => {
store.commit(`${storeNamespace}/setEditorSettingsDialogVisible`, false);
});
return {
variables,
options,
activeTabName,
tabs,
optionNames,
visible,
themes,
onClose,
onConfirm,
onTabChange,
getDefinitionDescription,
getDefinitionTitle,
};
},
});
</script>
<style lang="scss" scoped>
.file-editor-settings-dialog {
.nav-menu {
.el-menu-item {
height: 40px;
line-height: 40px;
}
}
.form {
margin: 20px;
}
}
</style>
<style scoped>
.file-editor-settings-dialog >>> .el-dialog .el-dialog__body {
padding: 10px 20px;
}
.file-editor-settings-dialog >>> .el-form-item > .el-form-item__label .icon {
cursor: pointer;
}
.file-editor-settings-dialog >>> .el-form-item > .el-form-item__content {
width: 240px;
}
.file-editor-settings-dialog >>> .el-form-item > .el-form-item__content .el-input,
.file-editor-settings-dialog >>> .el-form-item > .el-form-item__content .el-select {
width: 100%;
}
</style>
<style>
.help-tooltip {
max-width: 240px;
}
</style>

View File

@@ -1,50 +0,0 @@
<template>
<el-select v-if="type === 'select'" :value="value" @change="onChange">
<el-option v-for="op in data.options" :key="op" :label="op" :value="op"/>
</el-select>
<el-input-number
v-else-if="type === 'input-number'"
:min="data.min !== undefined ? data.min : 0"
:step="data.step !== undefined ? data.step : 1"
:value="value"
@change="onChange"
/>
<el-switch v-else-if="type === 'switch'" :value="value" @change="onChange"/>
</template>
<script lang="ts">
import {computed, defineComponent} from 'vue';
import {getOptionDefinition} from '@/utils/codemirror';
export default defineComponent({
name: 'FileEditorSettingsFormItem',
props: {
value: {
type: Object,
required: false,
},
name: {
type: String,
required: true,
},
},
setup(props, {emit}) {
const def = computed<FileEditorOptionDefinition | undefined>(() => {
const {name} = props;
return getOptionDefinition(name);
});
const type = computed<FileEditorOptionDefinitionType | undefined>(() => def.value?.type);
const data = computed<any>(() => def.value?.data);
const onChange = (value: any) => {
emit('input', value);
};
return {
type,
data,
onChange,
};
},
});
</script>

View File

@@ -1,210 +0,0 @@
<template>
<div class="file-upload">
<div class="mode-select">
<el-radio-group v-model="internalMode" @change="onModeChange">
<el-radio
v-for="{value, label} in modeOptions"
:key="value"
:label="value"
>
{{ label }}
</el-radio>
</el-radio-group>
</div>
<template v-if="mode === FILE_UPLOAD_MODE_FILES">
<el-upload
ref="uploadRef"
:on-change="onFileChange"
:http-request="() => {}"
drag
multiple
:show-file-list="false"
>
<i class="el-icon-upload"></i>
<div class="el-upload__text">Drag files here, or <em>click to upload</em></div>
</el-upload>
<input v-bind="getInputProps()" multiple>
</template>
<template v-else-if="mode === FILE_UPLOAD_MODE_DIR">
<div class="folder-upload">
<Button size="large" @click="open">
<i class="fa fa-folder"></i>
Click to Select Folder to Upload
</Button>
<template v-if="!!dirInfo?.dirName && dirInfo?.fileCount">
<Tag
type="primary"
class="info-tag"
:label="dirInfo?.dirName"
:icon="['fa', 'folder']"
tooltip="Folder Name"
/>
<Tag
type="success"
class="info-tag"
:label="dirInfo?.fileCount"
:icon="['fa', 'hashtag']"
tooltip="Files Count"
/>
</template>
</div>
<input v-bind="getInputProps()" webkitdirectory multiple>
</template>
<div v-if="dirInfo?.filePaths?.length > 0" class="file-list-wrapper">
<h4 class="title">Files to Upload</h4>
<ul class="file-list">
<li v-for="(path, $index) in dirInfo?.filePaths" :key="$index" class="file-item">
{{ path }}
</li>
</ul>
</div>
</div>
</template>
<script lang="ts">
import {defineComponent, onBeforeMount, ref, watch} from 'vue';
import {FILE_UPLOAD_MODE_DIR, FILE_UPLOAD_MODE_FILES} from '@/constants/file';
import {ElUpload} from 'element-plus/lib/el-upload/src/upload.type';
import {UploadFile} from 'element-plus/packages/upload/src/upload.type';
import Button from '@/components/button/Button.vue';
import Tag from '@/components/tag/Tag.vue';
import {plainClone} from '@/utils/object';
export default defineComponent({
name: 'FileUpload',
components: {
Tag,
Button,
},
props: {
mode: {
type: String,
},
getInputProps: {
type: Function,
},
open: {
type: Function,
},
},
emits: [
'mode-change',
'files-change',
],
setup(props: FileUploadProps, {emit}) {
const modeOptions: FileUploadModeOption[] = [
{
label: 'Folder',
value: FILE_UPLOAD_MODE_DIR,
},
{
label: 'Files',
value: FILE_UPLOAD_MODE_FILES,
},
];
const internalMode = ref<string>();
const uploadRef = ref<ElUpload>();
const dirPath = ref<string>();
watch(() => props.mode, () => {
internalMode.value = props.mode;
uploadRef.value?.clearFiles();
});
const onFileChange = (file: UploadFile, fileList: UploadFile[]) => {
emit('files-change', fileList.map(f => f.raw));
};
const clearFiles = () => {
uploadRef.value?.clearFiles();
};
const onModeChange = (mode: string) => {
emit('mode-change', mode);
};
onBeforeMount(() => {
const {mode} = props;
internalMode.value = mode;
});
const dirInfo = ref<FileUploadInfo>();
const setInfo = (info: FileUploadInfo) => {
dirInfo.value = plainClone(info);
}
const resetInfo = (info: FileUploadInfo) => {
dirInfo.value = undefined;
};
return {
uploadRef,
FILE_UPLOAD_MODE_FILES,
FILE_UPLOAD_MODE_DIR,
modeOptions,
internalMode,
dirPath,
onFileChange,
clearFiles,
onModeChange,
dirInfo,
setInfo,
resetInfo,
};
},
});
</script>
<style scoped lang="scss">
@import "../../styles/variables";
.file-upload {
.mode-select {
margin-bottom: 20px;
}
.el-upload {
width: 100%;
}
.folder-upload {
display: flex;
align-items: center;
}
.file-list-wrapper {
.title {
margin-bottom: 0;
padding-bottom: 0;
}
.file-list {
list-style: none;
max-height: 400px;
overflow: auto;
border: 1px solid $infoPlainColor;
padding: 10px;
margin-top: 10px;
.file-item {
}
}
}
}
</style>
<style scoped>
.file-upload >>> .el-upload,
.file-upload >>> .el-upload .el-upload-dragger {
width: 100%;
}
.file-upload >>> .folder-upload .info-tag {
margin-left: 10px;
}
</style>

View File

@@ -1,23 +0,0 @@
import {useDropzone} from 'vue3-dropzone';
const useFileEditorDropZone = () => {
const onDrop = (acceptedFiles: InputFile[], rejectReasons: FileRejectReason[], event: Event) => {
console.log(acceptedFiles);
};
const {
getRootProps,
getInputProps,
open,
} = useDropzone({
onDrop,
});
return {
getRootProps,
getInputProps,
open,
};
};
export default useFileEditorDropZone;

View File

@@ -1,164 +0,0 @@
<template>
<div class="filter-condition">
<el-select
:model-value="condition.type"
:popper-append-to-body="false"
class="filter-condition-type"
size="mini"
@change="onTypeChange"
>
<el-option v-for="op in conditionTypesOptions" :key="op.value" :label="op.label" :value="op.value"/>
</el-select>
<el-input
:model-value="condition.value"
class="filter-condition-value"
:class="isInvalidValue ? 'invalid' : ''"
size="mini"
placeholder="Value"
:disabled="condition.type === FILTER_OP_NOT_SET"
@input="onValueChange"
/>
<el-tooltip content="Delete Condition">
<el-icon class="icon" name="circle-close" @click="onDelete"/>
</el-tooltip>
</div>
</template>
<script lang="ts">
import {computed, defineComponent} from 'vue';
import {
FILTER_OP_CONTAINS,
FILTER_OP_EQUAL,
FILTER_OP_GREATER_THAN,
FILTER_OP_GREATER_THAN_EQUAL,
FILTER_OP_LESS_THAN,
FILTER_OP_LESS_THAN_EQUAL,
FILTER_OP_NOT_CONTAINS,
FILTER_OP_NOT_EQUAL,
FILTER_OP_NOT_SET,
FILTER_OP_REGEX,
} from '@/constants/filter';
import {plainClone} from '@/utils/object';
export const defaultFilterCondition: FilterConditionData = {
op: FILTER_OP_NOT_SET,
value: '',
};
export const getDefaultFilterCondition = () => {
return plainClone(defaultFilterCondition);
};
export const conditionTypesOptions: SelectOption[] = [
{value: FILTER_OP_NOT_SET, label: 'Not Set'},
{value: FILTER_OP_CONTAINS, label: 'Contains'},
{value: FILTER_OP_NOT_CONTAINS, label: 'Not Contains'},
{value: FILTER_OP_REGEX, label: 'Regex'},
{value: FILTER_OP_EQUAL, label: 'Equal to'},
{value: FILTER_OP_NOT_EQUAL, label: 'Not Equal to'},
{value: FILTER_OP_GREATER_THAN, label: 'Greater than'},
{value: FILTER_OP_LESS_THAN, label: 'Less than'},
{value: FILTER_OP_GREATER_THAN_EQUAL, label: 'Greater than or Equal to'},
{value: FILTER_OP_LESS_THAN_EQUAL, label: 'Less than or Equal to'},
];
export const conditionTypesMap: { [key: string]: string } = (() => {
const map: { [key: string]: string } = {};
conditionTypesOptions.forEach(d => {
map[d.value] = d.label as string;
});
return map;
})();
export default defineComponent({
name: 'FilterCondition',
props: {
condition: {
type: Object,
required: false,
},
},
emits: [
'change',
'delete',
],
setup(props, {emit}) {
const isInvalidValue = computed<boolean>(() => {
const {condition} = props as FilterConditionProps;
if (condition?.op === FILTER_OP_NOT_SET) {
return false;
}
return !condition?.value;
});
const onTypeChange = (conditionType: string) => {
const {condition} = props as FilterConditionProps;
if (condition) {
condition.op = conditionType;
if (condition.op === FILTER_OP_NOT_SET) {
condition.value = undefined;
}
}
emit('change', condition);
};
const onValueChange = (conditionValue: string) => {
const {condition} = props as FilterConditionProps;
if (condition) {
condition.value = conditionValue;
}
emit('change', condition);
};
const onDelete = () => {
emit('delete');
};
return {
FILTER_OP_NOT_SET,
conditionTypesOptions,
isInvalidValue,
onTypeChange,
onValueChange,
onDelete,
};
},
});
</script>
<style lang="scss" scoped>
.filter-condition {
display: flex;
align-items: center;
.filter-condition-type {
min-width: 180px;
}
.filter-condition-value {
flex: 1;
}
.icon {
cursor: pointer;
margin-left: 5px;
}
}
</style>
<style scoped>
.filter-condition >>> .filter-condition-type.el-select .el-input__inner {
border-color: #DCDFE6 !important;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right: none;
}
.filter-condition >>> .filter-condition-value.el-input .el-input__inner {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.filter-condition >>> .filter-condition-value.el-input.invalid .el-input__inner {
border-color: #F56C6C;
}
</style>

View File

@@ -1,68 +0,0 @@
<template>
<ul class="filter-condition-list">
<li
v-for="(cond, $index) in conditions"
:key="$index"
class="filter-condition-item"
>
<FilterCondition :condition="cond" @change="onChange($index, $event)" @delete="onDelete($index)"/>
</li>
</ul>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
import FilterCondition, {getDefaultFilterCondition} from '@/components/filter/FilterCondition.vue';
export default defineComponent({
name: 'FilterConditionList',
components: {
FilterCondition,
},
props: {
conditions: {
type: Array,
required: false,
default: () => {
return [];
}
}
},
emits: [
'change',
],
setup(props, {emit}) {
const onChange = (index: number, condition: FilterConditionData) => {
const {conditions} = props as FilterConditionListProps;
conditions[index] = condition;
emit('change', conditions);
};
const onDelete = (index: number) => {
const {conditions} = props as FilterConditionListProps;
conditions.splice(index, 1);
if (conditions.length === 0) {
conditions.push(getDefaultFilterCondition());
}
emit('change', conditions);
};
return {
onChange,
onDelete,
};
},
});
</script>
<style lang="scss" scoped>
.filter-condition-list {
list-style: none;
padding: 0;
margin: 0;
.filter-condition-item {
margin-bottom: 10px;
}
}
</style>

View File

@@ -1,90 +0,0 @@
<template>
<el-form
ref="formRef"
:inline="inline"
:label-width="labelWidth"
:size="size"
:model="model"
class="form"
:rules="rules"
hide-required-asterisk
@validate="$emit('validate')"
>
<slot></slot>
</el-form>
</template>
<script lang="ts">
import {computed, defineComponent, PropType, provide, reactive, ref} from 'vue';
import {ElForm} from 'element-plus';
export default defineComponent({
name: 'Form',
props: {
model: {
type: Object as PropType<FormModel>,
default: () => {
return {};
}
},
inline: {
type: Boolean,
default: true,
},
labelWidth: {
type: String,
default: '150px',
},
size: {
type: String,
default: 'mini',
},
grid: {
type: Number,
default: 4,
},
rules: {
type: Object as PropType<FormRules>,
},
},
emits: [
'validate',
],
setup(props: FormProps, {emit}) {
const form = computed<FormContext>(() => {
const {labelWidth, size, grid} = props;
return {labelWidth, size, grid};
});
provide('form-context', reactive<FormContext>(form.value));
const formRef = ref<typeof ElForm>();
const validate = async () => {
return await formRef.value?.validate();
};
const resetFields = () => {
return formRef.value?.resetFields();
};
const clearValidate = () => {
return formRef.value?.clearValidate();
};
return {
formRef,
validate,
resetFields,
clearValidate,
};
},
});
</script>
<style lang="scss" scoped>
.form {
display: flex;
flex-wrap: wrap;
}
</style>

View File

@@ -1,209 +0,0 @@
<template>
<div ref="formItem" :style="style" class="form-item">
<el-form-item
:prop="prop"
:label="label"
:required="isRequired"
:rules="rules"
:size="size || formContext?.size"
>
<template #label>
<el-tooltip :content="labelTooltip" :disabled="!labelTooltip">
<span class="form-item-label">
<span :class="showRequiredAsterisk ? 'required' : ''" class="form-item-label-text">
{{ label }}
</span>
<el-tooltip v-if="isSelectiveForm" :content="editableTooltip">
<el-checkbox
v-model="internalEditable"
:disabled="notEditable"
class="editable-checkbox"
@change="onEditableChange"
/>
</el-tooltip>
</span>
</el-tooltip>
</template>
<div class="form-item-content">
<slot></slot>
</div>
</el-form-item>
</div>
</template>
<script lang="ts">
import {computed, defineComponent, inject, onMounted, PropType, ref, watch} from 'vue';
import {RuleItem} from 'async-validator';
import {cloneArray} from '@/utils/object';
export default defineComponent({
name: 'FormItem',
props: {
prop: {
type: String,
required: false,
},
label: {
type: String,
required: false,
},
labelTooltip: {
type: String,
required: false,
},
labelWidth: {
type: String,
required: false,
},
size: {
type: String,
required: false,
},
required: {
type: Boolean,
required: false,
default: false,
},
span: {
type: Number,
required: false,
default: 1,
},
offset: {
type: Number,
required: false,
default: 0,
},
rules: {
type: [Object, Array] as PropType<RuleItem | RuleItem[]>,
},
notEditable: {
type: Boolean,
default: false,
},
},
setup(props: FormItemProps, {emit}) {
const formItem = ref<HTMLDivElement>();
// form context
const formContext = inject<FormContext>('form-context', {} as FormContext);
// store context
const storeContext = inject<ListStoreContext<BaseModel>>('store-context');
const ns = storeContext?.namespace;
const store = storeContext?.store;
const state = storeContext?.state;
const isSelectiveForm = computed<boolean | undefined>(() => state?.isSelectiveForm);
const selectedFormFields = computed<string[] | undefined>(() => state?.selectedFormFields);
const isBatchForm = computed<boolean>(() => store?.getters[`${ns}/isBatchForm`]);
const activeDialogKey = computed<DialogKey | undefined>(() => state?.activeDialogKey);
const style = computed<Partial<CSSStyleDeclaration>>(() => {
const {span, offset} = props;
return {
flexBasis: `calc(100% / ${formContext.grid} * ${span})`,
marginRight: `calc(100% / ${formContext.grid} * ${offset})`,
};
});
const internalEditable = ref<boolean>(false);
watch(() => state?.activeDialogKey, () => internalEditable.value = false);
const editableTooltip = computed<string>(() => {
const {notEditable} = props;
if (notEditable) return 'Unable to edit';
return internalEditable.value ? 'Uncheck to disable editing' : 'Check to enable editing';
});
const onEditableChange = (value: boolean) => {
const {prop} = props;
if (!selectedFormFields.value || !prop) return;
const fields = cloneArray<string>(selectedFormFields.value);
if (value) {
if (!fields.includes(prop)) {
fields.push(prop);
}
} else {
if (fields.includes(prop)) {
const idx = fields.findIndex(field => field === prop);
fields.splice(idx, 1);
}
}
store?.commit(`${ns}/setSelectedFormFields`, fields);
};
const isRequired = computed<boolean>(() => {
switch (activeDialogKey.value) {
case 'edit':
if (isBatchForm.value) {
if (!internalEditable.value) return false;
}
break;
}
const {required} = props;
return required;
});
const showRequiredAsterisk = computed<boolean>(() => {
if (isSelectiveForm.value) return false;
const {required} = props;
return required;
});
onMounted(() => {
if (formItem.value) {
const {labelWidth} = formContext;
const el = formItem.value?.querySelector('.el-form-item__content') as HTMLDivElement;
if (labelWidth && el.style) {
el.style.width = `calc(100% - ${labelWidth})`;
}
}
});
return {
formItem,
formContext,
style,
isSelectiveForm,
internalEditable,
editableTooltip,
onEditableChange,
isRequired,
showRequiredAsterisk,
};
},
});
</script>
<style lang="scss" scoped>
@import "../../styles/variables";
.form-item {
.el-form-item {
width: 100%;
margin-right: 0;
.form-item-label {
.editable-checkbox {
margin-left: 10px;
}
.form-item-label-text.required {
&:before {
content: "*";
color: $red;
margin-right: 4px;
}
}
}
}
}
</style>
<style scoped>
.form-item >>> .form-item-content > .el-select,
.form-item >>> .form-item-content > .el-autocomplete,
.form-item >>> .form-item-content > .el-input {
width: 100%;
}
</style>

View File

@@ -1,31 +0,0 @@
<template>
<div class="form-readonly-value">
{{ value }}
</div>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
export default defineComponent({
name: 'FormReadonlyValue',
props: {
value: {
type: String,
required: true,
},
},
setup(props: FormReadonlyValueProps, {emit}) {
return {};
},
});
</script>
<style lang="scss" scoped>
@import "../../styles/variables";
.form-readonly-value {
font-size: 14px;
color: $infoColor;
}
</style>

View File

@@ -1,125 +0,0 @@
<template>
<div class="form-table">
<Table
:columns="columns"
:data="data"
hide-footer
/>
</div>
</template>
<script lang="ts">
import {computed, defineComponent, h, inject, PropType, Ref} from 'vue';
import {emptyArrayFunc} from '@/utils/func';
import Table from '@/components/table/Table.vue';
import FormTableField from '@/components/form/FormTableField.vue';
import {TABLE_COLUMN_NAME_ACTIONS} from '@/constants/table';
export default defineComponent({
name: 'FormTable',
components: {
Table,
},
props: {
data: {
type: Array as PropType<TableData>,
required: false,
default: emptyArrayFunc,
},
fields: {
type: Array as PropType<FormTableField[]>,
required: false,
default: emptyArrayFunc,
}
},
emits: [
'add',
'clone',
'delete',
'field-change',
'field-register',
],
setup(props: FormTableProps, {emit}) {
const columns = computed<TableColumns>(() => {
const {fields} = props;
const formRules = inject<FormRuleItem[]>('form-rules');
const columns = fields.map(f => {
const {
prop,
label,
width,
fieldType,
options,
required,
placeholder,
disabled,
} = f;
return {
key: prop,
label,
width,
required,
value: (row: BaseModel, rowIndex: number) => h(FormTableField, {
form: row,
formRules,
prop,
fieldType,
options,
required,
placeholder,
disabled: typeof disabled === 'function' ? disabled(row) : disabled,
onChange: (value: any) => {
emit('field-change', rowIndex, prop, value);
},
onRegister: (formRef: Ref) => {
if (rowIndex < 0) return;
emit('field-register', rowIndex, prop, formRef);
},
} as FormTableFieldProps, emptyArrayFunc),
} as TableColumn;
}) as TableColumns;
columns.push({
key: TABLE_COLUMN_NAME_ACTIONS,
label: 'Actions',
width: '150',
// fixed: 'right',
buttons: [
{
type: 'primary',
icon: ['fa', 'plus'],
tooltip: 'Add',
onClick: (_, rowIndex) => {
emit('add', rowIndex);
},
},
{
type: 'info',
icon: ['fa', 'clone'],
tooltip: 'Clone',
onClick: (_, rowIndex) => {
emit('clone', rowIndex);
},
},
{
type: 'danger',
icon: ['fa', 'trash-alt'],
tooltip: 'Delete',
onClick: (_, rowIndex) => {
emit('delete', rowIndex);
},
}
]
});
return columns;
});
return {
columns,
};
},
});
</script>
<style lang="scss" scoped>
</style>

View File

@@ -1,239 +0,0 @@
<template>
<el-form ref="formRef" :model="form" :rules="computedFormRules" inline-message>
<el-form-item ref="formItemRef" :prop="prop" :required="isRequired">
<el-input
v-if="fieldType === FORM_FIELD_TYPE_INPUT"
v-model="internalValue"
:placeholder="placeholder"
size="mini"
:disabled="disabled"
@input="onInputChange"
/>
<el-input
v-else-if="fieldType === FORM_FIELD_TYPE_INPUT_PASSWORD"
v-model="internalValue"
:disabled="disabled"
:placeholder="placeholder"
size="mini"
type="password"
@input="onInputChange"
/>
<el-input
v-else-if="fieldType === FORM_FIELD_TYPE_INPUT_TEXTAREA"
v-model="internalValue"
:placeholder="placeholder"
size="mini"
type="textarea"
:disabled="disabled"
@input="onInputChange"
/>
<el-select
v-else-if="fieldType === FORM_FIELD_TYPE_SELECT"
v-model="internalValue"
:placeholder="placeholder"
size="mini"
:disabled="disabled"
@change="onInputChange"
>
<el-option
v-for="op in options"
:key="op.value"
:label="op.label"
:value="op.value"
/>
</el-select>
<InputWithButton
v-else-if="fieldType === FORM_FIELD_TYPE_INPUT_WITH_BUTTON"
v-model="internalValue"
:placeholder="placeholder"
button-label="Edit"
size="mini"
:disabled="disabled"
@input="onInputChange"
/>
<TagInput
v-else-if="fieldType === FORM_FIELD_TYPE_TAG_INPUT"
v-model="internalValue"
:disabled="disabled"
@change="onInputChange"
/>
<Switch
v-else-if="fieldType === FORM_FIELD_TYPE_SWITCH"
v-model="internalValue"
:disabled="disabled"
@change="onInputChange"
/>
<!-- TODO: implement more field types -->
</el-form-item>
</el-form>
</template>
<script lang="ts">
import {
computed,
defineComponent,
inject,
onBeforeMount,
onMounted,
PropType,
Ref,
ref,
SetupContext,
watch
} from 'vue';
import {
FORM_FIELD_TYPE_CHECK_TAG_GROUP,
FORM_FIELD_TYPE_INPUT,
FORM_FIELD_TYPE_INPUT_PASSWORD,
FORM_FIELD_TYPE_INPUT_TEXTAREA,
FORM_FIELD_TYPE_INPUT_WITH_BUTTON,
FORM_FIELD_TYPE_SELECT,
FORM_FIELD_TYPE_SWITCH,
FORM_FIELD_TYPE_TAG_INPUT,
FORM_FIELD_TYPE_TAG_SELECT,
} from '@/constants/form';
import TagInput from '@/components/input/TagInput.vue';
import {emptyArrayFunc, voidFunc} from '@/utils/func';
import {ElForm, ElFormItem} from 'element-plus';
import InputWithButton from '@/components/input/InputWithButton.vue';
import Switch from '@/components/switch/Switch.vue';
export default defineComponent({
name: 'FormTableField',
components: {
Switch,
InputWithButton,
TagInput,
},
props: {
form: {
type: Object as PropType<any>,
required: true,
},
formRules: {
type: Object as PropType<FormRuleItem[]>,
required: false,
},
prop: {
type: String,
required: true,
},
fieldType: {
type: String as PropType<FormFieldType>,
required: true,
},
options: {
type: Array as PropType<SelectOption[]>,
default: emptyArrayFunc,
},
required: {
type: Boolean,
default: false,
},
placeholder: {
type: String,
default: 'Please enter value',
},
disabled: {
type: Boolean,
default: false,
},
onChange: {
type: Function as PropType<(value: any) => void>,
default: voidFunc,
},
onRegister: {
type: Function as PropType<(formRef: Ref) => void>,
default: voidFunc,
}
},
setup(props: FormTableFieldProps, {emit}: SetupContext) {
// form ref
const formRef = ref<typeof ElForm>();
// form item ref
const formItemRef = ref<typeof ElFormItem>();
// internal value
const internalValue = ref<any>();
// computed field value
const fieldValue = computed(() => {
const {form, prop} = props;
return form[prop];
});
watch(() => fieldValue.value, () => {
if (internalValue.value !== fieldValue.value) {
internalValue.value = fieldValue.value;
}
});
const onInputChange = (value: any) => {
const {onChange} = props;
onChange?.(value);
};
const isEmptyForm = inject('fn:isEmptyForm') as (d: any) => boolean;
const isRequired = computed<boolean>(() => {
const {form, required} = props;
if (isEmptyForm(form)) return false;
return required || false;
});
const isErrorMessageVisible = computed<boolean>(() => !!formItemRef.value?.validateMessage);
const computedFormRules = computed<FormRuleItem[]>(() => {
const {form, formRules} = props;
if (isEmptyForm(form)) {
return [];
} else {
return formRules || [];
}
});
onBeforeMount(() => {
const {form, prop} = props;
// initialize internal value
internalValue.value = form[prop];
});
onMounted(() => {
const {onRegister} = props;
// register form ref
onRegister?.(formRef);
});
return {
FORM_FIELD_TYPE_INPUT,
FORM_FIELD_TYPE_INPUT_PASSWORD,
FORM_FIELD_TYPE_INPUT_TEXTAREA,
FORM_FIELD_TYPE_INPUT_WITH_BUTTON,
FORM_FIELD_TYPE_SELECT,
FORM_FIELD_TYPE_TAG_INPUT,
FORM_FIELD_TYPE_TAG_SELECT,
FORM_FIELD_TYPE_CHECK_TAG_GROUP,
FORM_FIELD_TYPE_SWITCH,
formRef,
formItemRef,
internalValue,
onInputChange,
isRequired,
isErrorMessageVisible,
computedFormRules,
};
},
});
</script>
<style lang="scss" scoped>
.el-form {
margin: 0;
.el-form-item {
margin: 0;
}
}
</style>

View File

@@ -1,269 +0,0 @@
import {computed, provide} from 'vue';
import {Store} from 'vuex';
import useFormTable from '@/components/form/formTable';
import {EMPTY_OBJECT_ID} from '@/utils/mongo';
const useForm = (ns: ListStoreNamespace, store: Store<RootStoreState>, services: Services<BaseModel>, data: FormComponentData<BaseModel>) => {
const {
form: newForm,
formRef,
formTableFieldRefsMap,
} = data;
const getNewForm = () => {
return {...newForm.value};
};
const getNewFormList = () => {
const list = [];
for (let i = 0; i < 5; i++) {
list.push(getNewForm());
}
return list;
};
// store state
const state = store.state[ns];
// form
const form = computed<BaseModel>(() => state.form);
// form list
const formList = computed<BaseModel[]>(() => state.formList);
// active dialog key
const activeDialogKey = computed<DialogKey | undefined>(() => state.activeDialogKey);
// is selective form
const isSelectiveForm = computed<boolean>(() => state.isSelectiveForm);
// selected form fields
const selectedFormFields = computed<string[]>(() => state.selectedFormFields);
// readonly form fields
const readonlyFormFields = computed<string[]>(() => state.readonlyFormFields);
// is batch form getters
const isBatchForm = computed<boolean>(() => store.getters[`${ns}/isBatchForm`]);
// form list ids getters
const formListIds = computed<string[]>(() => store.getters[`${ns}/formListIds`]);
const validateForm = async () => {
if (isBatchForm.value && activeDialogKey.value === 'create') {
let valid = true;
for (const formRef of formTableFieldRefsMap.value.values()) {
try {
await formRef.value?.validate?.();
} catch (e) {
valid = false;
}
}
return valid;
} else {
return await formRef.value?.validate();
}
};
const resetForm = () => {
if (activeDialogKey.value) {
switch (activeDialogKey.value) {
case 'create':
store.commit(`${ns}/setForm`, getNewForm());
store.commit(`${ns}/setFormList`, getNewFormList());
break;
case 'edit':
// store.commit(`${ns}/setForm`, plainClone(state.form))
formRef.value?.clearValidate();
break;
}
} else {
formRef.value?.resetFields();
formTableFieldRefsMap.value = new Map();
}
};
// whether form item is disabled
const isFormItemDisabled = (prop: string) => {
if (readonlyFormFields.value.includes(prop)) {
return true;
}
if (!isSelectiveForm.value) return false;
if (!prop) return false;
return !selectedFormFields.value.includes(prop);
};
// whether the form is empty
const isEmptyForm = (d: any): boolean => {
return JSON.stringify(d) === JSON.stringify(getNewForm());
};
provide<(d: any) => boolean>('fn:isEmptyForm', isEmptyForm);
// all list select options
const allListSelectOptions = computed<SelectOption[]>(() => store.getters[`${ns}/allListSelectOptions`]);
// all list select options with empty
const allListSelectOptionsWithEmpty = computed<SelectOption[]>(() => allListSelectOptions.value.concat({
label: 'Unassigned',
value: EMPTY_OBJECT_ID,
}));
// all dict
const allDict = computed<Map<string, BaseModel>>(() => store.getters[`${ns}/allDict`]);
// all tags
const allTags = computed<string[]>(() => store.getters[`${ns}/allTags`]);
const {
getList,
create,
updateById,
createList,
updateList,
} = services;
// dialog create edit
const createEditDialogVisible = computed<boolean>(() => {
const {activeDialogKey} = state;
if (!activeDialogKey) return false;
return ['create', 'edit'].includes(activeDialogKey);
});
// dialog create edit tab name
const createEditDialogTabName = computed<CreateEditTabName>(() => state.createEditDialogTabName);
// dialog confirm
const confirmDisabled = computed<boolean>(() => {
return isSelectiveForm.value &&
selectedFormFields.value.length === 0;
});
const confirmLoading = computed<boolean>(() => state.confirmLoading);
const setConfirmLoading = (value: boolean) => store.commit(`${ns}/setConfirmLoading`, value);
const onConfirm = async () => {
// validate
try {
const valid = await validateForm();
if (!valid) return;
} catch (ex) {
console.error(ex);
return;
}
if (!form.value) {
console.error(new Error('form is undefined'));
return;
}
// flag of request finished
let isRequestFinished = false;
// start loading
setTimeout(() => {
if (isRequestFinished) return;
setConfirmLoading(true);
}, 50);
// request
try {
let res: HttpResponse;
switch (activeDialogKey.value) {
case 'create':
if (isBatchForm.value) {
const changedFormList = formList.value.filter(d => !isEmptyForm(d));
res = await createList(changedFormList);
} else {
res = await create(form.value);
}
break;
case 'edit':
if (isBatchForm.value) {
res = await updateList(formListIds.value, form.value, selectedFormFields.value);
} else {
res = await updateById(form.value._id as string, form.value);
}
break;
default:
console.error(`activeDialogKey "${activeDialogKey.value}" is invalid`);
return;
}
if (res.error) {
console.error(res.error);
return;
}
} finally {
// flag request finished as true
isRequestFinished = true;
// stop loading
setConfirmLoading(false);
}
// close
store.commit(`${ns}/hideDialog`);
// request list
await getList();
};
// dialog close
const onClose = () => {
store.commit(`${ns}/hideDialog`);
};
// dialog tab change
const onTabChange = (tabName: CreateEditTabName) => {
// if (tabName === 'batch') {
// store.commit(`${ns}/setFormList`, getNewFormList());
// }
store.commit(`${ns}/setCreateEditDialogTabName`, tabName);
};
// use form table
const formTable = useFormTable(ns, store, services, data);
const {
onAdd,
onClone,
onDelete,
onFieldChange,
onFieldRegister,
} = formTable;
// action functions
const actionFunctions = {
onClose,
onConfirm,
onTabChange,
onAdd,
onClone,
onDelete,
onFieldChange,
onFieldRegister,
} as CreateEditDialogActionFunctions;
return {
...formTable,
getNewForm,
getNewFormList,
form,
formRef,
isSelectiveForm,
selectedFormFields,
formList,
isBatchForm,
validateForm,
resetForm,
isFormItemDisabled,
activeDialogKey,
createEditDialogTabName,
createEditDialogVisible,
allListSelectOptions,
allListSelectOptionsWithEmpty,
allDict,
allTags,
confirmDisabled,
confirmLoading,
setConfirmLoading,
actionFunctions,
};
};
export default useForm;

View File

@@ -1,67 +0,0 @@
import {Store} from 'vuex';
import {plainClone} from '@/utils/object';
import {computed, Ref} from 'vue';
const useFormTable = (ns: ListStoreNamespace, store: Store<RootStoreState>, services: Services<BaseModel>, data: FormComponentData<BaseModel>) => {
const {
form,
formTableFieldRefsMap,
} = data;
// state
const state = store.state[ns];
// form list
const formList = computed(() => state.formList);
const getNewForm = () => {
return {...form.value};
};
const onAdd = (index: number) => {
formList.value.splice(index, 0, getNewForm());
};
const onClone = (index: number) => {
const form = plainClone(formList.value[index]);
formList.value.splice(index, 0, form);
};
const onDelete = (index: number) => {
formList.value.splice(index, 1);
for (const key of formTableFieldRefsMap.value.keys()) {
const rowIndex = key[0];
if (rowIndex === index) {
formTableFieldRefsMap.value.delete(key);
}
}
};
const onFieldChange = (rowIndex: number, prop: string, value: any) => {
if (rowIndex !== -1) {
// one row change
const item = formList.value[rowIndex] as BaseModel;
item[prop] = value;
} else {
// all rows change
for (let i = 0; i < formList.value.length; i++) {
onFieldChange(i, prop, value);
}
}
};
const onFieldRegister = (rowIndex: number, prop: string, formRef: Ref) => {
const key = [rowIndex, prop] as FormTableFieldRefsMapKey;
formTableFieldRefsMap.value.set(key, formRef);
};
return {
onAdd,
onClone,
onDelete,
onFieldChange,
onFieldRegister,
};
};
export default useFormTable;

View File

@@ -1,36 +0,0 @@
<template>
<span class="atom-material-icon" v-html="html"/>
</template>
<script lang="ts">
import {computed, defineComponent} from 'vue';
import {getFileIconFromName, getFolderIconFromName} from 'atom-material-icons';
export default defineComponent({
name: 'AtomMaterialIcon',
props: {
name: {
type: String,
required: true,
},
isDir: {
type: Boolean,
required: false,
}
},
setup(props: AtomMaterialIconProps) {
const html = computed<string>(() => {
const {name, isDir} = props;
const icon = isDir ? getFolderIconFromName(name) : getFileIconFromName(name);
return icon.default;
});
return {
html,
};
},
});
</script>
<style lang="scss" scoped>
</style>

View File

@@ -1,64 +0,0 @@
<template>
<template v-if="icon">
<font-awesome-icon
v-if="isFaIcon"
:class="spinning ? 'fa-spin' : ''"
:icon="icon"
:style="{fontSize}"
class="icon"
/>
<i
v-else
:class="[spinning ? 'fa-spin' : '', icon, 'icon']"
:style="{fontSize}"
/>
</template>
</template>
<script lang="ts">
import {computed, defineComponent, PropType} from 'vue';
import useIcon from '@/components/icon/icon';
export default defineComponent({
name: 'Icon',
props: {
icon: {
type: [String, Array] as PropType<Icon>,
},
spinning: {
type: Boolean,
default: false,
},
size: {
type: String as PropType<IconSize>,
default: 'mini',
}
},
setup(props: IconProps, {emit}) {
const {
isFaIcon: _isFaIcon,
getFontSize,
} = useIcon();
const fontSize = computed(() => {
const {size} = props;
return getFontSize(size);
});
const isFaIcon = computed<boolean>(() => {
const {icon} = props;
if (!icon) return false;
return _isFaIcon(icon);
});
return {
isFaIcon,
fontSize,
};
},
});
</script>
<style lang="scss" scoped>
</style>

View File

@@ -1,51 +0,0 @@
<template>
<template v-if="!item || !item.icon">
<i :style="{'font-size': fontSize}" class="menu-item-icon fa fa-circle-o"></i>
</template>
<template v-else-if="Array.isArray(item.icon)">
<font-awesome-icon
:icon="item.icon"
:style="{'font-size': fontSize}"
class="menu-item-icon"
/>
</template>
<template v-else>
<i :class="item.icon" :style="{'font-size': fontSize}" class="menu-item-icon"></i>
</template>
</template>
<script lang="ts">
import {computed, defineComponent, PropType} from 'vue';
import useIcon from '@/components/icon/icon';
export default defineComponent({
name: 'MenuItemIcon',
props: {
item: {
type: Object as PropType<MenuItem>,
},
size: {
type: String as PropType<IconSize>,
default: 'mini',
}
},
setup(props: MenuItemIconProps) {
const {
getFontSize,
} = useIcon();
const fontSize = computed(() => {
const {size} = props as MenuItemIconProps;
return getFontSize(size);
});
return {
fontSize,
};
},
});
</script>
<style lang="scss" scoped>
.menu-item-icon {
width: 20px;
}
</style>

View File

@@ -1,33 +0,0 @@
const useIcon = () => {
// implementation
const isFaIcon = (icon: Icon) => {
if (Array.isArray(icon)) {
return icon.length > 0 && icon[0].substr(0, 2) === 'fa';
} else {
return icon?.substr(0, 2) === 'fa';
}
};
const getFontSize = (size: IconSize) => {
switch (size) {
case 'large':
return '24px';
case 'normal':
return '16px';
case 'small':
return '12px';
case 'mini':
return '10px';
default:
return size || '16px';
}
};
return {
// public variables and methods
isFaIcon,
getFontSize,
};
};
export default useIcon;

View File

@@ -1,188 +0,0 @@
<template>
<div class="input-with-button">
<!-- Input -->
<el-input
v-model="internalValue"
:placeholder="placeholder"
:size="size"
class="input"
:disabled="disabled"
@input="onInput"
@blur="onBlur"
@focus="onFocus"
@keyup.enter="onBlur"
/>
<!-- ./Input -->
<!-- Button -->
<Button
v-if="buttonLabel"
:disabled="disabled"
:size="size"
:type="buttonType"
class="button"
no-margin
@click="onClick"
>
<Icon v-if="buttonIcon" :icon="buttonIcon" />
{{ buttonLabel }}
</Button>
<template v-else-if="buttonIcon">
<FaIconButton
v-if="isFaIcon"
:disabled="disabled"
:icon="buttonIcon"
:size="size"
:type="buttonType"
class="button"
@click="onClick"
/>
<IconButton
v-else
:disabled="disabled"
:icon="buttonIcon"
:size="size"
:type="buttonType"
class="button"
@click="onClick"
/>
</template>
<!-- ./Button -->
</div>
</template>
<script lang="ts">
import {defineComponent, onMounted, PropType, ref, watch} from 'vue';
import Button from '@/components/button/Button.vue';
import Icon from '@/components/icon/Icon.vue';
import FaIconButton from '@/components/button/FaIconButton.vue';
import useIcon from '@/components/icon/icon';
import IconButton from '@/components/button/IconButton.vue';
export default defineComponent({
name: 'InputWithButton',
components: {
IconButton,
FaIconButton,
Icon,
Button,
},
props: {
modelValue: {
type: String,
},
placeholder: {
type: String,
},
size: {
type: String,
default: 'mini',
},
buttonType: {
type: String as PropType<BasicType>,
default: 'primary',
},
buttonLabel: {
type: String,
default: 'Click',
},
buttonIcon: {
type: [String, Array] as PropType<string | string[]>,
},
disabled: {
type: Boolean,
default: false,
}
},
emits: [
'update:model-value',
'input',
'click',
'blur',
'focus',
'keyup.enter',
],
setup(props: InputWithButtonProps, {emit}) {
const internalValue = ref<string>();
const {
isFaIcon: _isFaIcon,
} = useIcon();
const isFaIcon = () => {
const {buttonIcon} = props;
if (!buttonIcon) return false;
return _isFaIcon(buttonIcon);
};
watch(() => props.modelValue, () => {
internalValue.value = props.modelValue;
});
const onInput = (value: string) => {
emit('update:model-value', value);
emit('input', value);
};
const onClick = () => {
emit('click');
};
const onBlur = () => {
emit('blur');
};
const onFocus = () => {
emit('focus');
};
const onKeyUpEnter = () => {
emit('keyup.enter');
};
onMounted(() => {
const {modelValue} = props;
internalValue.value = modelValue;
});
return {
internalValue,
isFaIcon,
onClick,
onInput,
onBlur,
onFocus,
onKeyUpEnter,
};
},
});
</script>
<style lang="scss" scoped>
.input-with-button {
display: inline-table;
vertical-align: middle;
//align-items: start;
.input {
display: table-cell;
}
.button {
display: table-cell;
}
}
</style>
<style scoped>
.input-with-button >>> .input.el-input .el-input__inner {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.input-with-button >>> .button .el-button {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
height: 28px;
}
</style>

View File

@@ -1,228 +0,0 @@
<template>
<div class="tag-input">
<template v-for="(item, $index) in selectedValue" :key="$index">
<TagInputItem
v-if="item.isEdit"
ref="inputItemRef"
v-model="selectedValue[$index]"
:disabled="disabled"
placeholder="Tag Name"
size="mini"
@blur="onBlur($index, $event)"
@check="onCheck($index, $event)"
@close="onClose($index, $event)"
@delete="onDelete($index, $event)"
@focus="onFocus($index, $event)"
/>
<Tag
v-else
:closable="!disabled"
:color="item.color"
:disabled="disabled"
:label="item.name"
clickable
size="small"
type="plain"
@click="onEdit($index, $event)"
@close="onDelete($index, $event)"
/>
</template>
<el-tooltip :content="addButtonTooltip" :disabled="!addButtonTooltip">
<Tab
:icon="['fa', 'plus']"
:show-close="false"
:show-title="false"
class="add-btn"
:class="disabled ? 'disabled' : ''"
@click="onAdd"
/>
</el-tooltip>
</div>
</template>
<script lang="ts">
import {computed, defineComponent, PropType, ref, watch} from 'vue';
import TagComp from '@/components/tag/Tag.vue';
import Tab from '@/components/tab/Tab.vue';
import TagInputItem from '@/components/input/TagInputItem.vue';
import {cloneArray} from '@/utils/object';
import {getNewTag} from '@/components/tag/tag';
export default defineComponent({
name: 'TagInput',
components: {
TagInputItem,
Tag: TagComp,
Tab,
},
props: {
modelValue: {
type: Array as PropType<Tag[]>,
default: () => {
return [];
}
},
disabled: {
type: Boolean,
default: false,
}
},
emits: [
'change',
'update:model-value',
],
setup(props: TagInputProps, {emit}) {
const activeIndex = ref<number>(-1);
const inputItemRef = ref<typeof TagInputItem>();
const selectedValue = ref<TagInputOption[]>([]);
const emitValue = () => {
emit('change', selectedValue.value);
emit('update:model-value', selectedValue.value.map(d => {
return {
_id: d._id,
name: d.name,
color: d.color,
} as Tag;
}));
};
const disabled = computed<boolean>(() => props.disabled);
const addButtonTooltip = computed<string>(() => disabled.value ? '' : 'Add Tag');
const onEdit = (index: number, ev?: Event) => {
// check disabled
if (disabled.value) return;
ev?.stopPropagation();
const item = selectedValue.value[index];
item.isEdit = true;
// auto focus
setTimeout(() => inputItemRef.value?.focus(), 0);
};
const onDelete = (index: number, ev?: Event) => {
// check disabled
if (disabled.value) return;
ev?.stopPropagation();
selectedValue.value.splice(index, 1);
// commit change
emitValue();
};
const onFocus = (index: number, ev?: Event) => {
ev?.stopPropagation();
activeIndex.value = index;
};
const onBlur = (index: number, ev?: Event) => {
ev?.stopPropagation();
activeIndex.value = -1;
};
const onCheck = (index: number, value?: Tag, ev?: Event) => {
ev?.stopPropagation();
const item = selectedValue.value[index];
if (!item) return;
item.isEdit = false;
if (!value) return;
const {name, hex} = value;
item.name = name;
item.hex = hex;
// commit change
emitValue();
};
const onClose = (index: number, ev?: Event) => {
ev?.stopPropagation();
const item = selectedValue.value[index];
if (!item) return;
item.isEdit = false;
if (!item.name) {
selectedValue.value.splice(index, 1);
}
};
const onAdd = () => {
// check disabled
if (disabled.value) return;
// add value to array
selectedValue.value.push({
...getNewTag(),
isEdit: true,
});
// auto focus
setTimeout(() => inputItemRef.value?.focus(), 0);
};
watch(() => props.modelValue, () => {
const modelValue = props.modelValue || [];
selectedValue.value = cloneArray(modelValue);
});
return {
inputItemRef,
selectedValue,
addButtonTooltip,
onFocus,
onBlur,
onAdd,
onEdit,
onDelete,
onCheck,
onClose,
};
},
});
</script>
<style lang="scss" scoped>
@import "../../styles/variables.scss";
.tag-input {
display: flex;
flex-wrap: wrap;
align-items: center;
min-height: 28px;
.tag-input-item {
margin-right: 10px;
&:last-child {
margin-right: 0;
}
.el-input {
width: 100px;
}
}
.add-btn {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
&:not(.disabled) {
background-color: $white;
color: $infoMediumColor;
}
}
}
</style>
<style scoped>
.tag-input >>> .tag {
margin-right: 10px;
}
</style>

View File

@@ -1,353 +0,0 @@
<template>
<div
:class="[
isFocus ? 'is-focus' : '',
isNew ? 'is-new' : '',
]"
class="tag-input-item"
>
<!-- Input -->
<div class="input-wrapper">
<el-autocomplete
ref="inputRef"
v-model="internalValue.name"
:disabled="disabled"
:fetch-suggestions="fetchSuggestions"
:placeholder="placeholder"
:size="size"
popper-class="tag-input-item-popper"
class="input"
value-key="name"
@blur="onBlur"
@focus="onFocus"
@select="onSelect"
@keyup.enter="onCheck"
/>
<div class="actions">
<font-awesome-icon
:class="[isDisabled('check') ? 'disabled' : '']"
:icon="['fa', 'check']"
class="action-btn check"
@click="onCheck"
/>
<font-awesome-icon
:class="[isDisabled('close') ? 'disabled' : '']"
:icon="['fa', 'times']"
class="action-btn close"
@click="onClose"
/>
<font-awesome-icon
:class="[isDisabled('delete') ? 'disabled' : '']"
:icon="['fa', 'trash']"
class="action-btn delete"
@click="onDelete"
/>
</div>
</div>
<!-- ./Input -->
<!-- Color Picker -->
<ColorPicker
v-model="internalValue.color"
:disabled="!isNew"
:predefine="predefinedColors"
class="color-picker"
show-alpha
/>
<!-- <el-color-picker-->
<!-- v-model="internalValue.color"-->
<!-- :disabled="!isNew"-->
<!-- :predefine="predefinedColors"-->
<!-- class="color-picker"-->
<!-- show-alpha-->
<!-- />-->
<!-- ./Color Picker -->
</div>
</template>
<script lang="ts">
import {computed, defineComponent, inject, onMounted, PropType, readonly, ref, watch} from 'vue';
import {ElInput} from 'element-plus';
import {plainClone} from '@/utils/object';
import useTagService from '@/services/tag/tagService';
import {useStore} from 'vuex';
import {FILTER_OP_CONTAINS, FILTER_OP_EQUAL} from '@/constants/filter';
import {getNewTag} from '@/components/tag/tag';
import {getPredefinedColors} from '@/utils/color';
import ColorPicker from '@/components/color/ColorPicker.vue';
export default defineComponent({
name: 'TagInputItem',
components: {
ColorPicker,
},
props: {
modelValue: {
type: Object as PropType<Tag>,
},
placeholder: {
type: String,
},
size: {
type: String as PropType<BasicSize>,
default: 'mini',
},
disabled: {
type: Boolean,
default: false,
}
},
emits: [
'update:model-value',
'input',
'click',
'blur',
'focus',
'keyup.enter',
'close',
'check',
'delete',
],
setup(props: TagInputItemProps, {emit}) {
const store = useStore();
const internalValue = ref<Tag>(getNewTag());
const isFocus = ref<boolean>(false);
const inputRef = ref<typeof ElInput>();
const isNew = computed<boolean>(() => !internalValue.value._id);
// predefined colors
const predefinedColors = readonly<string[]>(getPredefinedColors());
watch(() => props.modelValue, () => {
if (!props.modelValue) {
internalValue.value = getNewTag();
} else {
internalValue.value = plainClone(props.modelValue);
}
});
const isDisabled = (key: string) => {
switch (key) {
case 'check':
return !internalValue.value.name;
case 'close':
return false;
case 'delete':
return false;
default:
return false;
}
};
const onInput = (name: string) => {
const value = {...props.modelValue, name};
emit('input', value);
};
const onClick = () => {
emit('click');
};
const onBlur = () => {
isFocus.value = false;
emit('blur');
};
const onFocus = () => {
isFocus.value = true;
emit('focus');
};
const focus = () => {
inputRef.value?.focus();
};
const onSelect = (value: Tag) => {
internalValue.value = value;
};
const onCheck = () => {
if (isDisabled('check')) return;
emit('update:model-value', internalValue.value);
emit('check', internalValue.value);
};
const onClose = () => {
if (isDisabled('close')) return;
emit('close');
};
const onDelete = () => {
if (isDisabled('delete')) return;
emit('delete');
};
const ctx = inject<ListStoreContext<BaseModel>>('store-context');
const fetchSuggestions = async (queryString: string, callback: (data: Tag[]) => void) => {
const {
getList,
} = useTagService(store);
const params = {
page: 1,
size: 50,
conditions: [
{key: 'col', op: FILTER_OP_EQUAL, value: `${ctx?.namespace}s`}
]
} as ListRequestParams;
if (queryString) {
const conditions = params.conditions as FilterConditionData[];
conditions.push({key: 'name', op: FILTER_OP_CONTAINS, value: queryString});
}
try {
const res = await getList(params);
return callback(res.data || []);
} catch (e) {
console.error(e);
callback([]);
}
};
onMounted(() => {
if (!props.modelValue) {
internalValue.value = getNewTag();
} else {
internalValue.value = plainClone(props.modelValue);
}
});
return {
predefinedColors,
internalValue,
isFocus,
inputRef,
isNew,
onClick,
onInput,
onBlur,
onFocus,
focus,
onCheck,
onClose,
onDelete,
onSelect,
isDisabled,
fetchSuggestions,
};
},
});
</script>
<style lang="scss" scoped>
@import "../../styles/variables.scss";
.tag-input-item {
display: flex;
align-items: center;
//height: 28px;
.input-wrapper {
display: inherit;
border: none;
position: relative;
height: 28px;
.actions {
position: absolute;
top: 0;
right: 5px;
.action-btn {
width: 14px;
height: 14px;
padding: 3px;
color: $infoMediumColor;
cursor: pointer;
&:hover:not(.disabled) {
&.check {
color: $successColor;
}
&.close {
color: $infoColor;
}
&.delete {
color: $dangerColor;
}
}
&.disabled {
color: $infoMediumLightColor;
cursor: not-allowed;
}
}
}
}
}
</style>
<style scoped>
.tag-input-item >>> .input,
.tag-input-item >>> .actions,
.tag-input-item >>> .color-picker,
.tag-input-item >>> .color-picker .el-color-picker {
margin: 0;
padding: 0;
height: 28px;
line-height: 28px;
}
.tag-input-item >>> .input {
display: inherit;
}
.tag-input-item >>> .input .el-input__inner {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-right: none;
transition: none;
}
.tag-input-item >>> .color-picker .el-color-picker__trigger {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
border-left: none;
border-top: 1px solid #DCDFE6;
border-right: 1px solid #DCDFE6;
border-bottom: 1px solid #DCDFE6;
padding: 0;
}
.tag-input-item.is-focus >>> .color-picker .el-color-picker__trigger {
border-color: #409eff;
}
.tag-input-item >>> .color-picker .el-color-picker__color {
border: none;
}
.tag-input-item >>> .color-picker .el-color-picker__mask {
background: transparent;
border-radius: 0;
left: 0;
height: 28px;
width: 28px;
}
.tag-input-item >>> .el-autocomplete-suggestion__list > li {
height: 28px;
}
</style>
<style>
.tag-input-item-popper >>> .el-autocomplete-suggestion__list > li {
height: 28px;
}
</style>

View File

@@ -1,65 +0,0 @@
<template>
<NavActionGroup>
<NavActionButton
:button-type="buttonType"
:disabled="disabled"
:icon="icon"
:label="label"
:tooltip="tooltip"
type="primary"
@click="() => $emit('click')"
/>
</NavActionGroup>
</template>
<script lang="ts">
import {defineComponent, PropType} from 'vue';
import NavActionGroup from '@/components/nav/NavActionGroup.vue';
import NavActionButton from '@/components/nav/NavActionButton.vue';
export default defineComponent({
name: 'NavActionBack',
components: {NavActionButton, NavActionGroup},
props: {
buttonType: {
type: String as PropType<ButtonType>,
default: 'label',
},
label: {
type: String,
default: 'Back'
},
tooltip: {
type: String,
},
icon: {
type: [String, Array] as PropType<Icon>,
default: () => {
return ['fa', 'undo'];
}
},
type: {
type: String as PropType<BasicType>,
default: 'primary'
},
size: {
type: String as PropType<BasicSize>,
default: 'mini'
},
disabled: {
type: Boolean,
default: false
}
},
emits: [
'click',
],
setup() {
return {};
},
});
</script>
<style lang="scss" scoped>
</style>

View File

@@ -1,59 +0,0 @@
<template>
<LabelButton
v-if="buttonType === 'label'"
:disabled="disabled"
:icon="icon"
:label="label"
:size="size"
:tooltip="tooltip"
:type="type"
@click="onClick"
/>
<FaIconButton
v-else-if="buttonType === 'fa-icon'"
:disabled="disabled"
:icon="icon"
:size="size"
:tooltip="tooltip"
:type="type"
@click="onClick"
/>
</template>
<script lang="ts">
import {defineComponent, PropType} from 'vue';
import {buttonProps} from '@/components/button/Button.vue';
import LabelButton from '@/components/button/LabelButton.vue';
import FaIconButton from '@/components/button/FaIconButton.vue';
export default defineComponent({
name: 'NavActionButton',
components: {
LabelButton,
FaIconButton,
},
props: {
buttonType: {
type: String as PropType<ButtonType>,
required: true,
},
label: {
type: String,
},
icon: {
type: [String, Array] as PropType<Icon>
},
onClick: {
type: Function as PropType<() => void>
},
...buttonProps,
},
setup(props, {emit}) {
return {};
},
});
</script>
<style lang="scss" scoped>
</style>

View File

@@ -1,33 +0,0 @@
<template>
<NavActionItem class="nav-action-fa-icon" is-label>
<el-tooltip :content="tooltip" :disabled="!tooltip">
<font-awesome-icon :icon="icon" class="title"/>
</el-tooltip>
</NavActionItem>
</template>
<script lang="ts">
import {defineComponent, PropType} from 'vue';
import NavActionItem from '@/components/nav/NavActionItem.vue';
export default defineComponent({
name: 'NavActionFaIcon',
components: {
NavActionItem,
},
props: {
tooltip: {
type: String,
},
icon: {
type: [String, Array] as PropType<Icon>,
},
},
setup(props, {emit}) {
return {};
},
});
</script>
<style lang="scss" scoped>
</style>

View File

@@ -1,46 +0,0 @@
<template>
<div class="nav-action-group">
<div class="border"/>
<slot></slot>
</div>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
export default defineComponent({
name: 'NavActionGroup',
setup() {
return {};
},
});
</script>
<style lang="scss" scoped>
@import "../../styles/variables.scss";
.nav-action-group {
height: auto;
display: flex;
align-items: center;
box-sizing: border-box;
& + .nav-action-group {
//padding-left: 10px;
margin-left: 10px;
.border {
margin-left: -10px;
margin-right: 10px;
border-left: 1px solid $navActionsGroupBorderColor;
height: calc(100% - 20px);
}
}
}
</style>
<style scoped>
.nav-action-group >>> .nav-action-item:last-child {
margin-right: 0;
}
</style>

View File

@@ -1,47 +0,0 @@
<template>
<NavActionGroup>
<NavActionButton
:disabled="disabled"
:icon="['fa', 'undo']"
button-type="label"
label="Back"
type="primary"
@click="() => $emit('back')"
/>
<NavActionButton
:disabled="disabled"
:icon="['fa', 'save']"
button-type="label"
label="Save"
type="success"
@click="() => $emit('save')"
/>
</NavActionGroup>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
import NavActionGroup from '@/components/nav/NavActionGroup.vue';
import NavActionButton from '@/components/nav/NavActionButton.vue';
export default defineComponent({
name: 'NavActionGroupDetailCommon',
components: {NavActionButton, NavActionGroup},
props: {
disabled: {
type: Boolean,
default: false
}
},
emits: [
'back',
],
setup() {
return {};
},
});
</script>
<style lang="scss" scoped>
</style>

View File

@@ -1,78 +0,0 @@
<template>
<div :class="[isLabel ? 'is-label' : '']" class="nav-action-item">
<span v-if="label" class="nav-action-item-label">
{{ label }}
</span>
<slot></slot>
</div>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
export default defineComponent({
name: 'NavActionItem',
props: {
isLabel: {
type: Boolean,
default: false,
},
label: {
type: String,
},
},
setup() {
return {};
},
});
</script>
<style lang="scss" scoped>
@import "../../styles/variables.scss";
.nav-action-item {
margin: 10px 0;
height: auto;
display: flex;
align-items: center;
color: $navActionsItemColor;
& + .nav-action-item {
//margin-left: 10px;
}
&.is-label {
margin-right: 10px;
}
}
</style>
<style scoped>
.nav-action-item >>> .title {
color: inherit;
}
.nav-action-item >>> .label {
color: inherit;
font-size: 14px;
margin-right: 5px;
margin-left: 5px;
}
.nav-action-item >>> .el-button.el-button--small {
height: 32px;
}
.nav-action-item >>> .el-button:not(.is-circle) .fa {
margin-right: 0;
}
.nav-action-item >>> .el-button .icon + span {
margin-left: 5px;
}
.nav-action-item >>> .nav-action-item-label {
color: inherit;
font-size: 12px;
margin-right: 5px;
}
</style>

View File

@@ -1,103 +0,0 @@
<template>
<div
ref="navActions"
:class="classes"
:style="style"
class="nav-actions"
>
<slot></slot>
</div>
</template>
<script lang="ts">
import {computed, defineComponent, onMounted, ref, watch} from 'vue';
export default defineComponent({
name: 'NavActions',
props: {
collapsed: Boolean,
minHeight: String,
},
setup(props) {
const originalHeight = ref<string | null>(null);
const height = ref<string | null>(null);
const navActions = ref<HTMLDivElement | null>(null);
const unmounted = ref<boolean>(true);
const collapsed = computed<boolean>(() => {
const {collapsed} = props as NavActionsProps;
return collapsed || false;
});
const style = computed(() => {
return {
height: height.value,
};
});
const classes = computed<string[]>(() => {
const cls = [];
if (collapsed.value) cls.push('collapsed');
if (unmounted.value) cls.push('unmounted');
return cls;
});
const updateHeight = () => {
if (!collapsed.value) {
if (originalHeight.value === null) {
if (!navActions.value) return;
originalHeight.value = `calc(${window.getComputedStyle(navActions.value).height} - 1px)`;
}
height.value = originalHeight.value;
} else {
height.value = '0';
}
};
const getHeight = () => {
return height.value;
};
watch(collapsed, () => updateHeight());
onMounted(() => {
updateHeight();
unmounted.value = false;
});
return {
navActions,
style,
classes,
getHeight,
};
},
});
</script>
<style lang="scss" scoped>
@import "../../styles/variables.scss";
.nav-actions {
margin: 0;
padding: 0 10px;
min-height: 50px;
display: flex;
flex-wrap: wrap;
height: fit-content;
border-bottom: 1px solid $infoBorderColor;
transition: all $navActionsCollapseTransitionDuration;
overflow-y: hidden;
box-sizing: border-box;
&.collapsed {
border-bottom: none;
}
&.unmounted {
position: absolute;
}
}
</style>

View File

@@ -1,66 +0,0 @@
<template>
<div class="nav-link" @click="onClick">
<Icon :icon="icon" class="icon"/>
<span class="title">{{ label }}</span>
</div>
</template>
<script lang="ts">
import {defineComponent, PropType} from 'vue';
import Icon from '@/components/icon/Icon.vue';
import {useRouter} from 'vue-router';
export default defineComponent({
name: 'NavLink',
components: {Icon},
props: {
path: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
icon: {
type: [String, Array] as PropType<Icon>,
default: '',
},
},
emits: [
'click',
],
setup(props: NavLinkProps, {emit}) {
const router = useRouter();
const onClick = () => {
const {path} = props;
if (path) {
router.push(path);
}
emit('click');
};
return {
onClick,
};
},
});
</script>
<style lang="scss" scoped>
@import "../../styles/color";
.nav-link {
cursor: pointer;
color: $blue;
&:hover {
text-decoration: underline;
}
.icon {
margin-right: 3px;
}
}
</style>

View File

@@ -1,271 +0,0 @@
<template>
<div class="nav-sidebar" :class="classes">
<div class="search">
<el-input
v-model="searchString"
class="search-input"
:prefix-icon="collapsed ? '' : 'fa fa-search'"
placeholder="Search..."
:clearable="true"
/>
<div v-if="!collapsed" class="search-suffix" @click.stop="onToggle">
<el-tooltip
v-model="toggleTooltipValue"
content="Collapse sidebar"
>
<font-awesome-icon :icon="['fa', 'outdent']" class="icon"/>
</el-tooltip>
</div>
</div>
<el-menu
ref="navMenu"
v-if="filteredItems && filteredItems.length > 0"
class="nav-menu"
:default-active="activeKey"
@select="onSelect"
>
<el-menu-item v-for="item in filteredItems" :key="item.id" :index="item.id" class="nav-menu-item">
<span class="title">{{ item.title }}</span>
<!-- TODO: implement -->
<span v-if="false" class="actions">
<font-awesome-icon class="icon" :icon="['far', 'star']" @click="onStar(item.id)"/>
<font-awesome-icon class="icon" :icon="['far', 'clone']" @click="onDuplicate(item.id)"/>
<font-awesome-icon class="icon" :icon="['fa', 'trash-alt']" @click="onRemove(item.id)"/>
</span>
</el-menu-item>
</el-menu>
<Empty
v-else
description="No Items"
/>
</div>
</template>
<script lang="ts">
import {computed, defineComponent, ref} from 'vue';
import {ElMenu} from 'element-plus';
import variables from '@/styles/variables.scss';
import Empty from '@/components/empty/Empty.vue';
export default defineComponent({
name: 'NavSidebar',
components: {Empty},
props: {
items: Array,
activeKey: String,
collapsed: Boolean,
showActions: Boolean,
},
setup(props, {emit}) {
const toggling = ref(false);
const searchString = ref('');
const navMenu = ref<typeof ElMenu | null>(null);
const toggleTooltipValue = ref(false);
const filteredItems = computed<NavItem[]>(() => {
const items = props.items as NavItem[];
if (!searchString.value) return items;
return items.filter(d => d.title?.toLocaleLowerCase().includes(searchString.value.toLocaleLowerCase()));
});
const classes = computed(() => {
const {collapsed} = props as NavSidebarProps;
const cls = [];
if (collapsed) cls.push('collapsed');
// if (toggling.value) cls.push('toggling');
return cls;
});
const onSelect = (index: string) => {
emit('select', index);
};
const onStar = (index: string) => {
emit('star', index);
};
const onDuplicate = (index: string) => {
emit('duplicate', index);
};
const onRemove = (index: string) => {
emit('remove', index);
};
const onToggle = () => {
const {collapsed} = props as NavSidebarProps;
emit('toggle', !collapsed);
toggleTooltipValue.value = false;
};
const scroll = (id: string) => {
const idx = filteredItems.value.findIndex(d => d.id === id);
if (idx === -1) return;
const {navSidebarItemHeight} = variables;
const navSidebarItemHeightNumber = Number(navSidebarItemHeight.replace('px', ''));
if (!navMenu.value) return;
const $el = navMenu.value.$el as HTMLDivElement;
$el.scrollTo({
top: navSidebarItemHeightNumber * idx,
});
};
return {
toggling,
searchString,
navMenu,
toggleTooltipValue,
filteredItems,
classes,
onSelect,
onStar,
onDuplicate,
onRemove,
onToggle,
scroll,
};
},
});
</script>
<style scoped lang="scss">
@import "../../styles/variables.scss";
.nav-sidebar {
position: relative;
//margin: 10px;
width: $navSidebarWidth;
border-right: 1px solid $navSidebarBorderColor;
background-color: $navSidebarBg;
height: calc(100vh - #{$headerHeight} - #{$tabsViewHeight} - 1px);
transition: width $navSidebarCollapseTransitionDuration;
&.collapsed {
margin: 10px 0;
width: 0;
border: none;
.search {
position: relative;
}
}
.search {
position: relative;
height: $navSidebarSearchHeight;
box-sizing: content-box;
border-bottom: 1px solid $navSidebarBorderColor;
.search-input {
width: 100%;
border: none;
padding: 0;
margin: 0;
}
.search-suffix {
position: absolute;
top: 0;
right: 0;
display: inline-flex;
align-items: center;
height: 40px;
width: 25px;
color: $navSidebarItemActionColor;
cursor: pointer;
//transition: right $navSidebarCollapseTransitionDuration;
}
}
.nav-menu {
list-style: none;
padding: 0;
margin: 0;
border: none;
max-height: calc(100% - #{$navSidebarSearchHeight});
overflow-y: auto;
color: $navSidebarColor;
&.empty {
height: $navSidebarItemHeight;
display: flex;
align-items: center;
padding-left: 20px;
font-size: 14px;
}
.nav-menu-item {
position: relative;
height: $navSidebarItemHeight;
line-height: $navSidebarItemHeight;
&:hover {
.actions {
display: inherit;
}
}
.title {
font-size: 14px;
margin-bottom: 3px;
}
.subtitle {
font-size: 12px;
}
.actions {
display: none;
position: absolute;
top: 0;
right: 10px;
.icon {
color: $navSidebarItemActionColor;
margin-left: 3px;
&:hover {
color: $primaryColor;
}
}
}
}
}
.toggle-expand {
position: absolute;
top: 0;
left: 0;
height: 100%;
display: flex;
align-items: center;
z-index: 100;
cursor: pointer;
&:hover {
opacity: 0.7;
}
.wrapper {
height: 24px;
width: 24px;
background-color: $infoPlainColor;
border: 1px solid $infoColor;
border-bottom-right-radius: 5px;
border-top-right-radius: 5px;
border-left: none;
display: flex;
align-items: center;
justify-content: center;
}
}
}
</style>
<style scoped>
.nav-sidebar > .search >>> .el-input__inner {
border: none;
}
.nav-sidebar.collapsed > .search >>> .el-input__inner {
padding: 0;
width: 0;
}
</style>

View File

@@ -1,75 +0,0 @@
<template>
<div class="nav-tabs">
<el-menu
:default-active="activeKey"
mode="horizontal"
@select="onSelect"
>
<el-menu-item
v-for="item in items"
:key="item.id"
:class="item.emphasis ? 'emphasis' : ''"
:index="item.id"
:style="item.style"
>
<el-tooltip :content="item.tooltip" :disabled="!item.tooltip">
<template v-if="!!item.icon">
<font-awesome-icon :icon="item.icon"/>
</template>
<template v-else>
{{ item.title }}
</template>
</el-tooltip>
</el-menu-item>
<div class="extra">
<slot name="extra">
</slot>
</div>
</el-menu>
</div>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
export default defineComponent({
name: 'NavTabs',
props: {
items: Array,
activeKey: String,
},
setup(props, {emit}) {
const onSelect = (index: string) => {
emit('select', index);
};
return {
onSelect,
};
},
});
</script>
<style lang="scss" scoped>
@import "../../styles/variables.scss";
.nav-tabs {
.el-menu {
height: calc(#{$navTabsHeight} + 1px);
.el-menu-item {
height: $navTabsHeight;
line-height: $navTabsHeight;
&.emphasis {
color: $infoColor;
border-bottom: none;
}
}
.extra {
float: right;
height: $navTabsHeight;
line-height: $navTabsHeight;
}
}
}
</style>

View File

@@ -1,45 +0,0 @@
<template>
<CreateEditDialog
:action-functions="actionFunctions"
:batch-form-data="formList"
:batch-form-fields="batchFormFields"
:confirm-disabled="confirmDisabled"
:confirm-loading="confirmLoading"
:tab-name="createEditDialogTabName"
:type="activeDialogKey"
:visible="createEditDialogVisible"
:form-rules="formRules"
>
<template #default>
<NodeForm/>
</template>
</CreateEditDialog>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
import {useStore} from 'vuex';
import CreateEditDialog from '@/components/dialog/CreateEditDialog.vue';
import NodeForm from '@/components/node/NodeForm.vue';
import useNode from '@/components/node/node';
export default defineComponent({
name: 'CreateEditProjectDialog',
components: {
CreateEditDialog,
NodeForm,
},
setup() {
// store
const store = useStore();
return {
...useNode(store),
};
},
});
</script>
<style lang="scss" scoped>
</style>

View File

@@ -1,50 +0,0 @@
<template>
<el-tag :type="type" class="node-active" size="mini">
<font-awesome-icon :icon="icon" class="icon"/>
<span>{{ label }}</span>
</el-tag>
</template>
<script lang="ts">
import {computed, defineComponent} from 'vue';
export default defineComponent({
name: 'NodeActive',
props: {
active: {
type: Boolean,
default: false,
}
},
setup(props: NodeActiveProps, {emit}) {
const type = computed<string>(() => {
const {active} = props;
return active ? 'success' : 'info';
});
const label = computed<string>(() => {
const {active} = props;
return active ? 'Online' : 'Offline';
});
const icon = computed<string[]>(() => {
const {active} = props;
return active ? ['fa', 'check-circle'] : ['fa', 'times-circle'];
});
return {
type,
label,
icon,
};
},
});
</script>
<style lang="scss" scoped>
.node-active {
.icon {
margin-right: 5px;
}
}
</style>

View File

@@ -1,105 +0,0 @@
<template>
<Form
v-if="form"
ref="formRef"
:model="form"
:selective="isSelectiveForm"
>
<!--Row-->
<FormItem v-if="readonly" :offset="2" :span="2" label="Key" not-editable prop="key">
<el-input :value="form.key" disabled/>
</FormItem>
<!--./Row-->
<!--Row-->
<FormItem :span="2" label="Name" not-editable prop="name" required>
<el-input v-model="form.name" :disabled="isFormItemDisabled('name')" placeholder="Name"/>
</FormItem>
<FormItem :span="2" label="Tags" prop="tags">
<TagInput v-model="form.tags" :disabled="isFormItemDisabled('tags')"/>
</FormItem>
<!--./Row-->
<!--Row-->
<FormItem :span="2" label="Type" not-editable prop="type">
<NodeType :is-master="form.is_master"/>
</FormItem>
<FormItem :span="2" label="IP" prop="ip">
<el-input v-model="form.ip" :disabled="isFormItemDisabled('ip')" placeholder="IP"/>
</FormItem>
<!--./Row-->
<!--Row-->
<FormItem :span="2" label="MAC Address" prop="mac">
<el-input v-model="form.mac" :disabled="isFormItemDisabled('mac')" placeholder="MAC Address"/>
</FormItem>
<FormItem :span="2" label="Hostname" prop="hostname">
<el-input v-model="form.hostname" :disabled="isFormItemDisabled('hostname')" placeholder="Hostname"/>
</FormItem>
<!--./Row-->
<!--Row-->
<FormItem :span="2" label="Enabled" prop="enabled">
<Switch v-model="form.enabled" :disabled="isFormItemDisabled('enabled')"/>
</FormItem>
<FormItem :span="2" label="Max Runners" prop="max_runners">
<el-input-number
v-model="form.max_runners"
:disabled="isFormItemDisabled('max_runners')"
:min="0"
placeholder="Max Runners"
/>
</FormItem>
<!--./Row-->
<!--Row-->
<FormItem :span="4" label="Description" prop="description">
<el-input
v-model="form.description"
:disabled="isFormItemDisabled('description')"
placeholder="Description"
type="textarea"
/>
</FormItem>
</Form>
<!--./Row-->
</template>
<script lang="ts">
import {defineComponent} from 'vue';
import {useStore} from 'vuex';
import useNode from '@/components/node/node';
import TagInput from '@/components/input/TagInput.vue';
import Form from '@/components/form/Form.vue';
import FormItem from '@/components/form/FormItem.vue';
import NodeType from '@/components/node/NodeType.vue';
import Switch from '@/components/switch/Switch.vue';
export default defineComponent({
name: 'NodeForm',
props: {
readonly: {
type: Boolean,
}
},
components: {
Switch,
NodeType,
Form,
FormItem,
TagInput,
},
setup(props, {emit}) {
// store
const store = useStore();
return {
...useNode(store),
};
},
});
</script>
<style lang="scss" scoped>
</style>

View File

@@ -1,99 +0,0 @@
<template>
<Tag
:key="data"
:icon="data.icon"
:label="data.label"
:size="size"
:spinning="data.spinning"
:tooltip="data.tooltip"
:type="data.type"
@click="$emit('click')"
/>
</template>
<script lang="ts">
import {computed, defineComponent, PropType} from 'vue';
import Tag from '@/components/tag/Tag.vue';
export default defineComponent({
name: 'NodeRunners',
components: {
Tag,
},
props: {
available: {
type: Number as PropType<number | undefined>,
required: false,
},
max: {
type: Number as PropType<number | undefined>,
required: false,
},
size: {
type: String as PropType<BasicSize>,
required: false,
default: 'mini',
}
},
emits: ['click'],
setup(props: NodeRunnersProps, {emit}) {
const running = computed<number>(() => {
const {available, max} = props;
if (available === undefined ||
max === undefined ||
isNaN(available) ||
isNaN(max)
) {
return 0;
}
return (max - available) as number;
});
const label = computed<string>(() => {
const {max} = props;
return `${running.value} / ${max}`;
});
const data = computed<TagData>(() => {
const max = props.max as number;
if (running.value === max) {
return {
label: label.value,
tooltip: 'No runners available at this moment',
type: 'danger',
icon: ['fa', 'ban'],
};
} else if (running.value > 0) {
return {
label: label.value,
tooltip: `${running.value} out of ${max} runners are running`,
type: 'warning',
icon: ['far', 'check-square'],
};
} else {
return {
label: label.value,
tooltip: `All runners available`,
type: 'success',
icon: ['far', 'check-square'],
};
}
});
return {
label,
data,
};
},
});
</script>
<style lang="scss" scoped>
.node-runners {
cursor: pointer;
.icon {
margin-right: 5px;
}
}
</style>

View File

@@ -1,100 +0,0 @@
<template>
<Tag
:key="data"
:icon="data.icon"
:label="data.label"
:size="size"
:spinning="data.spinning"
:tooltip="data.tooltip"
:type="data.type"
@click="$emit('click')"
/>
</template>
<script lang="ts">
import {computed, defineComponent, PropType} from 'vue';
import Tag from '@/components/tag/Tag.vue';
import {
NODE_STATUS_OFFLINE,
NODE_STATUS_ONLINE,
NODE_STATUS_REGISTERED,
NODE_STATUS_UNREGISTERED
} from '@/constants/node';
export default defineComponent({
name: 'NodeStatus',
components: {
Tag,
},
props: {
status: {
type: String as PropType<TaskStatus>,
required: false,
},
size: {
type: String as PropType<BasicSize>,
required: false,
default: 'mini',
},
},
emits: ['click'],
setup(props: NodeStatusProps, {emit}) {
const data = computed<TagData>(() => {
const {status} = props;
switch (status) {
case NODE_STATUS_UNREGISTERED:
return {
label: 'Unregistered',
tooltip: 'Node is waiting to be registered',
type: 'danger',
icon: ['fa', 'exclamation'],
};
case NODE_STATUS_REGISTERED:
return {
label: 'Registered',
tooltip: 'Node is registered and wait to be online',
type: 'warning',
icon: ['far', 'check-square'],
};
case NODE_STATUS_ONLINE:
return {
label: 'Online',
tooltip: 'Node is currently online',
type: 'success',
icon: ['fa', 'check'],
};
case NODE_STATUS_OFFLINE:
return {
label: 'Offline',
tooltip: 'Node is currently offline',
type: 'info',
icon: ['fa', 'times'],
};
default:
return {
label: 'Unknown',
tooltip: 'Unknown node status',
type: 'info',
icon: ['fa', 'question'],
};
}
});
return {
data,
};
},
});
</script>
<style lang="scss" scoped>
.task-status {
width: 80px;
cursor: default;
.icon {
width: 10px;
margin-right: 5px;
}
}
</style>

View File

@@ -1,55 +0,0 @@
<template>
<el-tag :type="type" class="node-type" size="mini">
<font-awesome-icon :icon="icon" class="icon"/>
<span>{{ computedLabel }}</span>
</el-tag>
</template>
<script lang="ts">
import {computed, defineComponent} from 'vue';
export default defineComponent({
name: 'NodeType',
props: {
isMaster: {
type: Boolean,
},
label: {
type: String,
},
},
setup(props: NodeTypeProps, {emit}) {
const type = computed<string>(() => {
const {isMaster} = props;
return isMaster ? 'primary' : 'warning';
});
const computedLabel = computed<string>(() => {
const {isMaster, label} = props;
if (label) return label;
return isMaster ? 'Master' : 'Worker';
});
const icon = computed<string[]>(() => {
const {isMaster} = props;
return isMaster ? ['fa', 'home'] : ['fa', 'server'];
});
return {
type,
computedLabel,
icon,
};
},
});
</script>
<style lang="scss" scoped>
.node-type {
cursor: pointer;
.icon {
margin-right: 5px;
}
}
</style>

View File

@@ -1,61 +0,0 @@
import {readonly} from 'vue';
import {Store} from 'vuex';
import useForm from '@/components/form/form';
import useNodeService from '@/services/node/nodeService';
import {getDefaultFormComponentData} from '@/utils/form';
import {FORM_FIELD_TYPE_INPUT, FORM_FIELD_TYPE_INPUT_TEXTAREA, FORM_FIELD_TYPE_SWITCH} from '@/constants/form';
type Node = CNode;
// get new node
export const getNewNode = (): Node => {
return {
tags: [],
max_runners: 8,
enabled: true,
};
};
// form component data
const formComponentData = getDefaultFormComponentData<Node>(getNewNode);
const useNode = (store: Store<RootStoreState>) => {
// store
const ns = 'node';
const {node: state} = store.state as RootStoreState;
// batch form fields
const batchFormFields: FormTableField[] = [
{
prop: 'name',
label: 'Name',
width: '150',
fieldType: FORM_FIELD_TYPE_INPUT,
required: true,
placeholder: 'Name',
},
{
prop: 'enabled',
label: 'Enabled',
width: '120',
fieldType: FORM_FIELD_TYPE_SWITCH,
},
{
prop: 'description',
label: 'Description',
width: '200',
fieldType: FORM_FIELD_TYPE_INPUT_TEXTAREA,
},
];
// form rules
const formRules = readonly<FormRules>({});
return {
...useForm(ns, store, useNodeService(store), formComponentData),
batchFormFields,
formRules,
};
};
export default useNode;

View File

@@ -1,44 +0,0 @@
<template>
<CreateEditDialog
:action-functions="actionFunctions"
:confirm-disabled="confirmDisabled"
:confirm-loading="confirmLoading"
:tab-name="createEditDialogTabName"
:type="activeDialogKey"
:visible="createEditDialogVisible"
:form-rules="formRules"
no-batch
>
<template #default>
<PluginForm/>
</template>
</CreateEditDialog>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
import {useStore} from 'vuex';
import CreateEditDialog from '@/components/dialog/CreateEditDialog.vue';
import PluginForm from '@/components/plugin/PluginForm.vue';
import usePlugin from '@/components/plugin/plugin';
export default defineComponent({
name: 'CreateEditProjectDialog',
components: {
CreateEditDialog,
PluginForm,
},
setup() {
// store
const store = useStore();
return {
...usePlugin(store),
};
},
});
</script>
<style lang="scss" scoped>
</style>

View File

@@ -1,68 +0,0 @@
<template>
<Form
v-if="form"
ref="formRef"
:model="form"
:selective="isSelectiveForm"
>
<!--Row-->
<FormItem :span="2" :offset="2" label="Name" not-editable prop="name" required>
<el-input v-model="form.name" disabled placeholder="Name" />
</FormItem>
<!--./Row-->
<!--Row-->
<FormItem :span="2" label="Execute Command" prop="cmd">
<el-input
v-model="form.cmd"
disabled
placeholder="cmd"
/>
</FormItem>
<!--./Row-->
<!--Row-->
<FormItem :span="4" label="Description" prop="description">
<el-input
v-model="form.description"
disabled
placeholder="Description"
type="textarea"
/>
</FormItem>
</Form>
<!--./Row-->
</template>
<script lang="ts">
import {defineComponent} from 'vue';
import {useStore} from 'vuex';
import usePlugin from '@/components/plugin/plugin';
import Form from '@/components/form/Form.vue';
import FormItem from '@/components/form/FormItem.vue';
export default defineComponent({
name: 'PluginForm',
props: {
readonly: {
type: Boolean,
}
},
components: {
Form,
FormItem,
},
setup(props, {emit}) {
// store
const store = useStore();
return {
...usePlugin(store),
};
},
});
</script>
<style lang="scss" scoped>
</style>

View File

@@ -1,30 +0,0 @@
import {readonly} from 'vue';
import {Store} from 'vuex';
import useForm from '@/components/form/form';
import usePluginService from '@/services/plugin/pluginService';
import {getDefaultFormComponentData} from '@/utils/form';
type Plugin = CPlugin;
// get new plugin
export const getNewPlugin = (): Plugin => {
return {};
};
// form component data
const formComponentData = getDefaultFormComponentData<Plugin>(getNewPlugin);
const usePlugin = (store: Store<RootStoreState>) => {
// store
const ns = 'plugin';
// form rules
const formRules = readonly<FormRules>({});
return {
...useForm(ns, store, usePluginService(store), formComponentData),
formRules,
};
};
export default usePlugin;

View File

@@ -1,45 +0,0 @@
<template>
<CreateEditDialog
:type="activeDialogKey"
:tab-name="createEditDialogTabName"
:visible="createEditDialogVisible"
:action-functions="actionFunctions"
:batch-form-data="formList"
:confirm-disabled="confirmDisabled"
:confirm-loading="confirmLoading"
:batch-form-fields="batchFormFields"
:form-rules="formRules"
>
<template #default>
<ProjectForm/>
</template>
</CreateEditDialog>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
import {useStore} from 'vuex';
import CreateEditDialog from '@/components/dialog/CreateEditDialog.vue';
import ProjectForm from '@/components/project/ProjectForm.vue';
import useProject from '@/components/project/project';
export default defineComponent({
name: 'CreateEditProjectDialog',
components: {
CreateEditDialog,
ProjectForm,
},
setup() {
// store
const store = useStore();
return {
...useProject(store),
};
},
});
</script>
<style lang="scss" scoped>
</style>

View File

@@ -1,50 +0,0 @@
<template>
<Form
v-if="form"
ref="formRef"
:model="form"
:rules="formRules"
:selective="isSelectiveForm"
>
<FormItem :span="2" label="Name" not-editable prop="name" required>
<el-input v-model="form.name" :disabled="isFormItemDisabled('name')" placeholder="Name"/>
</FormItem>
<FormItem :span="2" label="Tags" prop="tags">
<TagInput v-model="form.tags" :disabled="isFormItemDisabled('tags')"/>
</FormItem>
<FormItem :span="4" label="Description" prop="description">
<el-input
v-model="form.description"
:disabled="isFormItemDisabled('description')"
placeholder="Description"
type="textarea"
/>
</FormItem>
</Form>
</template>
<script lang="ts">
import {defineComponent} from 'vue';
import {useStore} from 'vuex';
import useProject from '@/components/project/project';
import Form from '@/components/form/Form.vue';
import FormItem from '@/components/form/FormItem.vue';
import TagInput from '@/components/input/TagInput.vue';
export default defineComponent({
name: 'ProjectForm',
components: {TagInput, FormItem, Form},
setup() {
// store
const store = useStore();
return {
...useProject(store),
};
},
});
</script>
<style lang="scss" scoped>
</style>

View File

@@ -1,33 +0,0 @@
<template>
<el-tag :color="color" :type="type" class="project-tag" size="mini">
<span>{{ label }}</span>
</el-tag>
</template>
<script lang="ts">
import {defineComponent, PropType} from 'vue';
export default defineComponent({
name: 'ProjectTag',
props: {
label: {
type: String,
},
type: {
type: String as PropType<BasicType>,
},
color: {
type: String,
}
},
setup(props: ProjectTagProps, {emit}) {
return {};
},
});
</script>
<style lang="scss" scoped>
.project-tag {
margin-right: 10px;
}
</style>

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