feat: add type display in key browser

This commit is contained in:
Lykin 2023-12-04 15:43:25 +08:00
parent b688ded610
commit af6b4257f9
12 changed files with 239 additions and 95 deletions

View File

@ -516,6 +516,35 @@ func (b *browserService) LoadAllKeys(connName string, db int, match, keyType str
return return
} }
func (b *browserService) GetKeyType(param types.KeySummaryParam) (resp types.JSResp) {
item, err := b.getRedisClient(param.Server, param.DB)
if err != nil {
resp.Msg = err.Error()
return
}
client, ctx := item.client, item.ctx
key := strutil.DecodeRedisKey(param.Key)
var keyType string
keyType, err = client.Type(ctx, key).Result()
if err != nil {
resp.Msg = err.Error()
return
}
if keyType == "none" {
resp.Msg = "key not exists"
return
}
var data types.KeySummary
data.Type = strings.ToLower(keyType)
resp.Success = true
resp.Data = data
return
}
// GetKeySummary get key summary info // GetKeySummary get key summary info
func (b *browserService) GetKeySummary(param types.KeySummaryParam) (resp types.JSResp) { func (b *browserService) GetKeySummary(param types.KeySummaryParam) (resp types.JSResp) {
item, err := b.getRedisClient(param.Server, param.DB) item, err := b.getRedisClient(param.Server, param.DB)

View File

@ -14,9 +14,9 @@ type KeySummaryParam struct {
type KeySummary struct { type KeySummary struct {
Type string `json:"type"` Type string `json:"type"`
TTL int64 `json:"ttl"` TTL int64 `json:"ttl,omitempty"`
Size int64 `json:"size"` Size int64 `json:"size,omitempty"`
Length int64 `json:"length"` Length int64 `json:"length,omitempty"`
} }
type KeyDetailParam struct { type KeyDetailParam struct {

View File

@ -2,7 +2,7 @@
import ContentPane from './components/content/ContentPane.vue' import ContentPane from './components/content/ContentPane.vue'
import BrowserPane from './components/sidebar/BrowserPane.vue' import BrowserPane from './components/sidebar/BrowserPane.vue'
import { computed, onMounted, reactive, ref, watch } from 'vue' import { computed, onMounted, reactive, ref, watch } from 'vue'
import { debounce, get } from 'lodash' import { debounce } from 'lodash'
import { useThemeVars } from 'naive-ui' import { useThemeVars } from 'naive-ui'
import Ribbon from './components/sidebar/Ribbon.vue' import Ribbon from './components/sidebar/Ribbon.vue'
import ConnectionPane from './components/sidebar/ConnectionPane.vue' import ConnectionPane from './components/sidebar/ConnectionPane.vue'
@ -139,7 +139,7 @@ onMounted(async () => {
<div style="min-width: 68px; font-weight: 800">Tiny RDM</div> <div style="min-width: 68px; font-weight: 800">Tiny RDM</div>
<transition name="fade"> <transition name="fade">
<n-text v-if="tabStore.nav === 'browser'" class="ellipsis" strong style="font-size: 13px"> <n-text v-if="tabStore.nav === 'browser'" class="ellipsis" strong style="font-size: 13px">
- {{ get(tabStore.currentTab, 'name') }} - {{ tabStore.currentTabName }}
</n-text> </n-text>
</transition> </transition>
</n-space> </n-space>
@ -174,13 +174,15 @@ onMounted(async () => {
@update:size="handleResize"> @update:size="handleResize">
<browser-pane <browser-pane
v-for="t in tabStore.tabs" v-for="t in tabStore.tabs"
v-show="get(tabStore.currentTab, 'name') === t.name" v-show="tabStore.currentTabName === t.name"
:key="t.name" :key="t.name"
:db="t.db"
:server="t.name"
class="app-side flex-item-expand" /> class="app-side flex-item-expand" />
</resizeable-wrapper> </resizeable-wrapper>
<content-pane <content-pane
v-for="t in tabStore.tabs" v-for="t in tabStore.tabs"
v-show="get(tabStore.currentTab, 'name') === t.name" v-show="tabStore.currentTabName === t.name"
:key="t.name" :key="t.name"
:server="t.name" :server="t.name"
class="flex-item-expand" /> class="flex-item-expand" />

View File

@ -3,6 +3,7 @@ import { computed, h } from 'vue'
import { useThemeVars } from 'naive-ui' import { useThemeVars } from 'naive-ui'
import { types, typesBgColor, typesColor } from '@/consts/support_redis_type.js' import { types, typesBgColor, typesColor } from '@/consts/support_redis_type.js'
import { get, map, toUpper } from 'lodash' import { get, map, toUpper } from 'lodash'
import RedisTypeTag from '@/components/common/RedisTypeTag.vue'
const props = defineProps({ const props = defineProps({
value: { value: {
@ -23,15 +24,24 @@ const options = computed(() => {
const themeVars = useThemeVars() const themeVars = useThemeVars()
const renderIcon = (option) => { const renderIcon = (option) => {
if (option.key === props.value) { return h(RedisTypeTag, {
const backgroundColor = get(typesColor, option.key, themeVars.value.textColor3) type: option.key,
return h('div', { style: { borderRadius: '999px', width: '10px', height: '10px', backgroundColor } }, '') short: true,
} size: 'small',
inverse: option.key === props.value,
})
} }
const renderLabel = (option) => { const renderLabel = (option) => {
const color = get(typesColor, option.key, '') return h(
return h('div', { style: { color, fontWeight: '450' } }, option.label) 'div',
{
style: {
fontWeight: option.key === props.value ? 'bold' : 'normal',
},
},
option.label,
)
} }
const fontColor = computed(() => { const fontColor = computed(() => {

View File

@ -1,45 +1,69 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { typesBgColor, typesColor, validType } from '@/consts/support_redis_type.js' import { typesBgColor, typesColor, typesShortName } from '@/consts/support_redis_type.js'
import Binary from '@/components/icons/Binary.vue' import Binary from '@/components/icons/Binary.vue'
import { toUpper } from 'lodash'
const props = defineProps({ const props = defineProps({
type: { type: {
type: String, type: String,
validator(value) {
return validType(value)
},
default: 'STRING', default: 'STRING',
}, },
binaryKey: Boolean, binaryKey: Boolean,
size: String, size: String,
short: Boolean,
round: Boolean,
inverse: Boolean,
}) })
const fontColor = computed(() => { const fontColor = computed(() => {
if (props.inverse) {
return typesBgColor[props.type]
} else {
return typesColor[props.type] return typesColor[props.type]
}
}) })
const backgroundColor = computed(() => { const backgroundColor = computed(() => {
if (props.inverse) {
return typesColor[props.type]
} else {
return typesBgColor[props.type] return typesBgColor[props.type]
}
})
const label = computed(() => {
if (props.short) {
return typesShortName[toUpper(props.type)] || 'N'
}
return toUpper(props.type)
}) })
</script> </script>
<template> <template>
<n-tag <n-tag
:class="[props.size === 'small' ? 'redis-type-tag-small' : 'redis-type-tag']" :class="{
'redis-type-tag-normal': !props.short && props.size !== 'small',
'redis-type-tag-small': !props.short && props.size === 'small',
'redis-type-tag-round': props.round,
}"
:color="{ color: backgroundColor, textColor: fontColor }" :color="{ color: backgroundColor, textColor: fontColor }"
:size="props.size" :size="props.size"
bordered
strong> strong>
{{ props.type }} <b>{{ label }}</b>
<template #icon> <template #icon>
<n-icon v-if="binaryKey" :component="Binary" size="18" /> <n-icon v-if="binaryKey" :component="Binary" size="18" />
</template> </template>
</n-tag> </n-tag>
<!-- <div class="redis-type-tag flex-box-h" :style="{backgroundColor: backgroundColor}">{{ props.type }}</div>-->
</template> </template>
<style lang="scss"> <style lang="scss">
.redis-type-tag { .redis-type-tag-round {
border-radius: 9999px;
}
.redis-type-tag-normal {
padding: 0 12px; padding: 0 12px;
} }

View File

@ -87,7 +87,7 @@ const renderTypeLabel = (option) => {
default: () => [ default: () => [
h('div', { h('div', {
style: { style: {
borderRadius: '50%', borderRadius: '9999px',
backgroundColor: typesColor[option.value], backgroundColor: typesColor[option.value],
width: '13px', width: '13px',
height: '13px', height: '13px',

View File

@ -3,8 +3,8 @@ import { useThemeVars } from 'naive-ui'
import BrowserTree from './BrowserTree.vue' import BrowserTree from './BrowserTree.vue'
import IconButton from '@/components/common/IconButton.vue' import IconButton from '@/components/common/IconButton.vue'
import useTabStore from 'stores/tab.js' import useTabStore from 'stores/tab.js'
import { computed, nextTick, onMounted, reactive, ref, unref, watch } from 'vue' import { computed, nextTick, onMounted, reactive, ref, unref } from 'vue'
import { find, get, map } from 'lodash' import { find, map } from 'lodash'
import Refresh from '@/components/icons/Refresh.vue' import Refresh from '@/components/icons/Refresh.vue'
import useDialogStore from 'stores/dialog.js' import useDialogStore from 'stores/dialog.js'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
@ -24,6 +24,16 @@ import ListCheckbox from '@/components/icons/ListCheckbox.vue'
import Close from '@/components/icons/Close.vue' import Close from '@/components/icons/Close.vue'
import More from '@/components/icons/More.vue' import More from '@/components/icons/More.vue'
const props = defineProps({
server: String,
db: {
type: Number,
default: 0,
},
})
const emit = defineEmits(['update:db'])
const themeVars = useThemeVars() const themeVars = useThemeVars()
const i18n = useI18n() const i18n = useI18n()
const dialogStore = useDialogStore() const dialogStore = useDialogStore()
@ -32,21 +42,16 @@ const tabStore = useTabStore()
const browserStore = useBrowserStore() const browserStore = useBrowserStore()
const connectionStore = useConnectionStore() const connectionStore = useConnectionStore()
const render = useRender() const render = useRender()
const currentName = computed(() => get(tabStore.currentTab, 'name', ''))
const browserTreeRef = ref(null) const browserTreeRef = ref(null)
const loading = ref(false) const loading = ref(false)
const fullyLoaded = ref(false) const fullyLoaded = ref(false)
const inCheckState = ref(false) const inCheckState = ref(false)
const checkedCount = ref(0) const checkedCount = ref(0)
const selectedDB = computed(() => {
return browserStore.selectedDatabases[currentName.value] || 0
})
const dbSelectOptions = computed(() => { const dbSelectOptions = computed(() => {
const dblist = browserStore.getDBList(currentName.value) const dblist = browserStore.getDBList(props.server)
return map(dblist, (db) => { return map(dblist, (db) => {
if (selectedDB.value === db.db) { if (props.db === db.db) {
return { return {
value: db.db, value: db.db,
label: `db${db.db} (${db.keys}/${db.maxKeys})`, label: `db${db.db} (${db.keys}/${db.maxKeys})`,
@ -71,7 +76,7 @@ const moreOptions = computed(() => {
}) })
const loadProgress = computed(() => { const loadProgress = computed(() => {
const db = browserStore.getDatabase(currentName.value, selectedDB.value) const db = browserStore.getDatabase(props.server, props.db)
if (db.maxKeys <= 0) { if (db.maxKeys <= 0) {
return 100 return 100
} }
@ -79,29 +84,29 @@ const loadProgress = computed(() => {
}) })
const checkedTip = computed(() => { const checkedTip = computed(() => {
const dblist = browserStore.getDBList(currentName.value) const dblist = browserStore.getDBList(props.server)
const db = find(dblist, { db: selectedDB.value }) const db = find(dblist, { db: props.db })
return `${checkedCount.value} / ${db.maxKeys}` return `${checkedCount.value} / ${db.maxKeys}`
}) })
const onReload = async () => { const onReload = async () => {
try { try {
loading.value = true loading.value = true
tabStore.setSelectedKeys(currentName.value) tabStore.setSelectedKeys(props.server)
const db = selectedDB.value const db = props.db
browserStore.closeDatabase(currentName.value, db) browserStore.closeDatabase(props.server, db)
browserTreeRef.value?.resetExpandKey(currentName.value, db) browserTreeRef.value?.resetExpandKey(props.server, db)
let matchType = unref(filterForm.type) let matchType = unref(filterForm.type)
if (!types.hasOwnProperty(matchType)) { if (!types.hasOwnProperty(matchType)) {
matchType = '' matchType = ''
} }
browserStore.setKeyFilter(currentName.value, { browserStore.setKeyFilter(props.server, {
type: matchType, type: matchType,
pattern: unref(filterForm.pattern), pattern: unref(filterForm.pattern),
}) })
await browserStore.openDatabase(currentName.value, db) await browserStore.openDatabase(props.server, db)
fullyLoaded.value = await browserStore.loadMoreKeys(currentName.value, db) fullyLoaded.value = await browserStore.loadMoreKeys(props.server, db)
// $message.success(i18n.t('dialogue.reload_succ')) // $message.success(i18n.t('dialogue.reload_succ'))
} catch (e) { } catch (e) {
console.warn(e) console.warn(e)
@ -111,13 +116,13 @@ const onReload = async () => {
} }
const onAddKey = () => { const onAddKey = () => {
dialogStore.openNewKeyDialog('', currentName.value, selectedDB.value) dialogStore.openNewKeyDialog('', props.server, props.db)
} }
const onLoadMore = async () => { const onLoadMore = async () => {
try { try {
loading.value = true loading.value = true
fullyLoaded.value = await browserStore.loadMoreKeys(currentName.value, selectedDB.value) fullyLoaded.value = await browserStore.loadMoreKeys(props.server, props.db)
} catch (e) { } catch (e) {
$message.error(e.message) $message.error(e.message)
} finally { } finally {
@ -128,7 +133,7 @@ const onLoadMore = async () => {
const onLoadAll = async () => { const onLoadAll = async () => {
try { try {
loading.value = true loading.value = true
await browserStore.loadAllKeys(currentName.value, selectedDB.value) await browserStore.loadAllKeys(props.server, props.db)
fullyLoaded.value = true fullyLoaded.value = true
} catch (e) { } catch (e) {
$message.error(e.message) $message.error(e.message)
@ -142,15 +147,31 @@ const onDeleteChecked = () => {
} }
const onFlush = () => { const onFlush = () => {
dialogStore.openFlushDBDialog(currentName.value, selectedDB.value) dialogStore.openFlushDBDialog(props.server, props.db)
} }
const onDisconnect = () => { const onDisconnect = () => {
browserStore.closeConnection(currentName.value) browserStore.closeConnection(props.server)
} }
const handleSelectDB = async (db, prevDB) => { const handleSelectDB = async (db) => {
// watch 'browserStore.openedDB[currentName.value]' instead try {
loading.value = true
browserStore.closeDatabase(props.server, props.db)
browserStore.setKeyFilter(props.server, {})
await browserStore.openDatabase(props.server, db)
// browserTreeRef.value?.resetExpandKey(props.server, db)
fullyLoaded.value = await browserStore.loadMoreKeys(props.server, db)
browserTreeRef.value?.refreshTree()
nextTick().then(() => connectionStore.saveLastDB(props.server, db))
} catch (e) {
$message.error(e.message)
} finally {
loading.value = false
// emit('update:db', db)
// tabStore.switchTab()
}
} }
const filterForm = reactive({ const filterForm = reactive({
@ -183,30 +204,30 @@ const onSelectOptions = (select) => {
} }
} }
watch( // watch(
() => browserStore.openedDB[currentName.value], // () => props.db,
async (db, prevDB) => { // async (db, prevDB) => {
if (db === undefined) { // if (db === undefined) {
return // return
} // }
//
try { // try {
loading.value = true // loading.value = true
browserStore.closeDatabase(currentName.value, prevDB) // browserStore.closeDatabase(props.server, prevDB)
browserStore.setKeyFilter(currentName.value, {}) // browserStore.setKeyFilter(props.server, {})
await browserStore.openDatabase(currentName.value, db) // await browserStore.openDatabase(props.server, db)
// browserTreeRef.value?.resetExpandKey(currentName.value, db) // // browserTreeRef.value?.resetExpandKey(props.server, db)
fullyLoaded.value = await browserStore.loadMoreKeys(currentName.value, db) // fullyLoaded.value = await browserStore.loadMoreKeys(props.server, db)
browserTreeRef.value?.refreshTree() // browserTreeRef.value?.refreshTree()
//
nextTick().then(() => connectionStore.saveLastDB(currentName.value, db)) // nextTick().then(() => connectionStore.saveLastDB(props.server, db))
} catch (e) { // } catch (e) {
$message.error(e.message) // $message.error(e.message)
} finally { // } finally {
loading.value = false // loading.value = false
} // }
}, // },
) // )
onMounted(() => onReload()) onMounted(() => onReload())
// forbid dynamic switch key view due to performance issues // forbid dynamic switch key view due to performance issues
@ -271,11 +292,11 @@ onMounted(() => onReload())
ref="browserTreeRef" ref="browserTreeRef"
v-model:checked-count="checkedCount" v-model:checked-count="checkedCount"
:check-mode="inCheckState" :check-mode="inCheckState"
:db="browserStore.openedDB[currentName]" :db="props.db"
:full-loaded="fullyLoaded" :full-loaded="fullyLoaded"
:loading="loading && loadProgress <= 0" :loading="loading && loadProgress <= 0"
:pattern="filterForm.filter" :pattern="filterForm.filter"
:server="currentName" /> :server="props.server" />
<!-- bottom function bar --> <!-- bottom function bar -->
<div class="nav-pane-bottom flex-box-v"> <div class="nav-pane-bottom flex-box-v">
<!-- <switch-button--> <!-- <switch-button-->
@ -288,7 +309,7 @@ onMounted(() => onReload())
<transition mode="out-in" name="fade"> <transition mode="out-in" name="fade">
<div v-if="!inCheckState" class="flex-box-h nav-pane-func"> <div v-if="!inCheckState" class="flex-box-h nav-pane-func">
<n-select <n-select
v-model:value="browserStore.openedDB[currentName]" :value="props.db"
:consistent-menu-width="false" :consistent-menu-width="false"
:filter="(pattern, option) => option.value.toString() === pattern" :filter="(pattern, option) => option.value.toString() === pattern"
:options="dbSelectOptions" :options="dbSelectOptions"
@ -372,16 +393,6 @@ onMounted(() => onReload())
border-color: #0000; border-color: #0000;
} }
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.nav-pane-top { .nav-pane-top {
//@include bottom-shadow(0.1); //@include bottom-shadow(0.1);
color: v-bind('themeVars.iconColor'); color: v-bind('themeVars.iconColor');

View File

@ -5,7 +5,7 @@ import { NIcon, NSpace, useThemeVars } from 'naive-ui'
import Key from '@/components/icons/Key.vue' import Key from '@/components/icons/Key.vue'
import Binary from '@/components/icons/Binary.vue' import Binary from '@/components/icons/Binary.vue'
import Database from '@/components/icons/Database.vue' import Database from '@/components/icons/Database.vue'
import { filter, find, get, includes, indexOf, isEmpty, map, remove, size, startsWith } from 'lodash' import { filter, find, get, includes, indexOf, isEmpty, map, remove, size, startsWith, toUpper } from 'lodash'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Refresh from '@/components/icons/Refresh.vue' import Refresh from '@/components/icons/Refresh.vue'
import CopyLink from '@/components/icons/CopyLink.vue' import CopyLink from '@/components/icons/CopyLink.vue'
@ -23,6 +23,7 @@ import LoadList from '@/components/icons/LoadList.vue'
import LoadAll from '@/components/icons/LoadAll.vue' import LoadAll from '@/components/icons/LoadAll.vue'
import useBrowserStore from 'stores/browser.js' import useBrowserStore from 'stores/browser.js'
import { useRender } from '@/utils/render.js' import { useRender } from '@/utils/render.js'
import RedisTypeTag from '@/components/common/RedisTypeTag.vue'
const props = defineProps({ const props = defineProps({
server: String, server: String,
@ -364,6 +365,14 @@ const renderPrefix = ({ option }) => {
}, },
) )
case ConnectionType.RedisValue: case ConnectionType.RedisValue:
if (option.redisType == null || option.redisType === 'loading') {
browserStore.loadKeyType({
server: props.server,
db: option.db,
key: option.redisKey,
keyCode: option.redisKeyCode,
})
// in loading
return h( return h(
NIcon, NIcon,
{ size: 20 }, { size: 20 },
@ -372,6 +381,13 @@ const renderPrefix = ({ option }) => {
}, },
) )
} }
return h(RedisTypeTag, {
type: toUpper(option.redisType),
short: true,
size: 'small',
inverse: includes(selectedKeys.value, option.key),
})
}
} }
// render tree item label // render tree item label

View File

@ -11,6 +11,15 @@ export const types = {
STREAM: 'STREAM', STREAM: 'STREAM',
} }
export const typesShortName = {
STRING: 'S',
HASH: 'H',
LIST: 'L',
SET: 'S',
ZSET: 'Z',
STREAM: 'X',
}
/** /**
* mark color for redis types * mark color for redis types
* @enum {string} * @enum {string}

View File

@ -30,6 +30,7 @@ import {
GetCmdHistory, GetCmdHistory,
GetKeyDetail, GetKeyDetail,
GetKeySummary, GetKeySummary,
GetKeyType,
GetSlowLogs, GetSlowLogs,
LoadAllKeys, LoadAllKeys,
LoadNextKeys, LoadNextKeys,
@ -72,6 +73,7 @@ const useBrowserStore = defineStore('browser', {
* @property {boolean} [opened] - redis db is opened, type == ConnectionType.RedisDB only * @property {boolean} [opened] - redis db is opened, type == ConnectionType.RedisDB only
* @property {boolean} [expanded] - current node is expanded * @property {boolean} [expanded] - current node is expanded
* @property {DatabaseItem[]} [children] * @property {DatabaseItem[]} [children]
* @property {string} [redisType] - redis type name, 'loading' indicate that is in loading progress
*/ */
/** /**
@ -339,6 +341,7 @@ const useBrowserStore = defineStore('browser', {
selDB.opened = true selDB.opened = true
selDB.maxKeys = maxKeys selDB.maxKeys = maxKeys
this.openedDB[server] = db
set(this.loadingState, 'fullLoaded', end) set(this.loadingState, 'fullLoaded', end)
if (isEmpty(keys)) { if (isEmpty(keys)) {
selDB.children = [] selDB.children = []
@ -465,6 +468,29 @@ const useBrowserStore = defineStore('browser', {
} }
}, },
/**
* load key type
* @param {string} server
* @param {number} db
* @param {string} key
* @param {number[]} keyCode
* @return {Promise<void>}
*/
async loadKeyType({ server, db, key, keyCode }) {
try {
const nodeMap = this._getNodeMap(server, db)
const node = nodeMap.get(`${ConnectionType.RedisValue}/${key}`)
if (node == null || node.redisType != null) {
return
}
node.redisType = 'loading'
const { data } = await GetKeyType({ server, db, key: keyCode || key })
const { type } = data || {}
node.redisType = type
} finally {
}
},
/** /**
* reload key * reload key
* @param {string} server * @param {string} server
@ -752,6 +778,7 @@ const useBrowserStore = defineStore('browser', {
keys: 0, keys: 0,
redisKey: k, redisKey: k,
redisKeyCode: isBinaryKey ? key : undefined, redisKeyCode: isBinaryKey ? key : undefined,
redisKeyType: undefined,
type: ConnectionType.RedisValue, type: ConnectionType.RedisValue,
isLeaf: true, isLeaf: true,
} }
@ -818,6 +845,7 @@ const useBrowserStore = defineStore('browser', {
keys: 0, keys: 0,
redisKey: handlePath, redisKey: handlePath,
redisKeyCode: isBinaryKey ? key : undefined, redisKeyCode: isBinaryKey ? key : undefined,
redisKeyType: undefined,
type: ConnectionType.RedisValue, type: ConnectionType.RedisValue,
isLeaf: true, isLeaf: true,
} }

View File

@ -119,6 +119,10 @@ const useTabStore = defineStore('tab', {
// return current // return current
}, },
currentTabName() {
return get(this.tabs, [this.activatedIndex, 'name'])
},
currentSelectedKeys() { currentSelectedKeys() {
const tab = this.currentTab() const tab = this.currentTab()
return get(tab, 'selectedKeys', []) return get(tab, 'selectedKeys', [])

View File

@ -157,3 +157,14 @@ body {
.n-tabs .n-tabs-nav { .n-tabs .n-tabs-nav {
line-height: 1.3; line-height: 1.3;
} }
// animations
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}