[client] add table and filter

This commit is contained in:
Alireza Ahmadi
2024-08-05 23:51:27 +02:00
parent 0bb3a67f79
commit 6f0df2d555
4 changed files with 511 additions and 272 deletions
+3 -1
View File
@@ -2,7 +2,7 @@
<v-app-bar :elevation="5">
<v-icon v-if="isMobile" icon="mdi-menu" @click="$emit('toggleDrawer')" />
<span v-else style="width: 24px"></span>
<v-app-bar-title :text="$t(<string>$router.currentRoute.value.name)" class="align-center text-center " />
<v-app-bar-title :text="$t(<string>route.name)" class="align-center text-center " />
<v-btn prepend-icon="mdi-content-save" v-if="stateChange" :text="$t('actions.save')" @click="saveChanges"></v-btn>
<v-icon icon="mdi-theme-light-dark" @click="toggleTheme()" style="margin: 0 10px;"></v-icon>
</v-app-bar>
@@ -13,9 +13,11 @@ import { computed, ref } from "vue"
import { useTheme } from "vuetify"
import { FindDiff } from "@/plugins/utils"
import Data from "@/store/modules/data"
import { useRoute } from "vue-router";
defineProps(['isMobile'])
const route = useRoute();
const theme = useTheme()
const darkMode = ref(localStorage.getItem('theme') == "dark")
+8 -3
View File
@@ -56,7 +56,8 @@ export function updateConfigs(configs: Config, newUserName: string): Config {
export function randomConfigs(user: string): Config {
const mixedPassword = RandomUtil.randomSeq(10)
const ssPassword = RandomUtil.randomShadowsocksPassword(32)
const ssPassword16 = RandomUtil.randomShadowsocksPassword(16)
const ssPassword32 = RandomUtil.randomShadowsocksPassword(32)
const uuid = RandomUtil.randomUUID()
return {
mixed: {
@@ -73,11 +74,15 @@ export function randomConfigs(user: string): Config {
},
shadowsocks: {
name: user,
password: ssPassword,
password: ssPassword32,
},
shadowsocks16: {
name: user,
password: ssPassword16,
},
shadowtls: {
name: user,
password: ssPassword,
password: ssPassword32,
},
vmess: {
name: user,
+272 -39
View File
@@ -29,19 +29,91 @@
<v-btn color="primary" @click="showModal(-1)">{{ $t('actions.add') }}</v-btn>
</v-col>
<v-col cols="auto">
<v-select
hide-details
variant="underlined"
density="compact"
:label="$t('filter')"
:items="filterItems"
v-model="filter">
</v-select>
<v-menu v-model="filterMenu" :close-on-content-click="false" location="bottom center">
<template v-slot:activator="{ props }">
<v-btn v-bind="props" hide-details variant="tonal">{{ $t('filter') }}
<v-badge color="error" dot v-if="filterSettings.enabled" floating />
</v-btn>
</template>
<v-card>
<v-container>
<v-row>
<v-col>
<v-select
variant="underlined"
density="compact"
:label="$t('type')"
:items="filterItems"
v-model="filterSettings.state">
</v-select>
</v-col>
</v-row>
<v-row>
<v-col>
<v-select
variant="underlined"
density="compact"
:label="$t('client.group')"
:items="[ {title: $t('all'), value: '-'}, ...groups.map(g => ({ title: g.length>0 ? g : $t('none'), value: g}))]"
v-model="filterSettings.group">
</v-select>
</v-col>
</v-row>
<v-row>
<v-col>
<v-text-field
variant="underlined"
density="compact"
:label="$t('client.name')"
v-model="filterSettings.text">
</v-text-field>
</v-col>
</v-row>
</v-container>
<v-card-actions>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn
color="blue-darken-1"
variant="outlined"
@click="clearFilter"
>
{{ $t('actions.del') }}
</v-btn>
<v-btn
color="blue-darken-1"
variant="tonal"
@click="doFilter"
>
{{ $t('actions.update') }}
</v-btn>
</v-card-actions>
</v-card-actions>
</v-card>
</v-menu>
</v-col>
<v-col cols="auto">
<v-switch v-model="tableView" color="primary" hide-details>
<template v-slot:label><v-icon icon="mdi-table"></v-icon></template>
</v-switch>
</v-col>
</v-row>
<v-row>
<template v-for="(item, index) in clients" :key="item.id">
<v-col cols="12" sm="4" md="3" lg="2" :style="checkFilter(item)? '' : 'opacity: .2'">
<template v-for="group in groups" v-if="!tableView">
<v-row>
<v-col class="v-card-subtitle">
{{ group.length>0 ? group : $t('none') }}
<v-badge :content="(filterSettings.enabled ? filterSettings.filteredClients : clients).filter(c => c.group == group).length" inline color="info" />
<v-icon
:icon="openedGroups.includes(group) ? 'mdi-arrow-collapse-up' : 'mdi-arrow-collapse-down'"
size="small"
variant="text"
@click="toggleGroupOpen(group)"
></v-icon>
</v-col>
</v-row>
<v-row v-if="openedGroups.includes(group)">
<template v-for="item in (filterSettings.enabled ? filterSettings.filteredClients : clients).filter(c => c.group == group)" :key="item.id">
<v-col cols="12" sm="4" md="3" lg="2">
<v-card rounded="xl" elevation="5" min-width="200">
<v-card-title>
<v-row>
@@ -49,7 +121,7 @@
<v-spacer></v-spacer>
<v-col cols="auto">
<v-switch color="primary"
v-model="clients[index].enable"
v-model="item.enable"
@update:model-value="buildInboundsUsers(item.inbounds)"
hideDetails density="compact" />
</v-col>
@@ -98,7 +170,7 @@
<v-row>
<v-col>{{ $t('online') }}</v-col>
<v-col dir="ltr">
<template v-if="onlines[index]">
<template v-if="isOnline(item.name).value">
<v-chip density="comfortable" size="small" color="success" variant="flat">{{ $t('online') }}</v-chip>
</template>
<template v-else>-</template>
@@ -107,16 +179,16 @@
</v-card-text>
<v-divider></v-divider>
<v-card-actions style="padding: 0;">
<v-btn icon="mdi-account-edit" @click="showModal(index)">
<v-btn icon="mdi-account-edit" @click="showModal(item.id)">
<v-icon />
<v-tooltip activator="parent" location="top" :text="$t('actions.edit')"></v-tooltip>
<v-tooltip activator="parent" location="top" :text="$t('actions.edit') + item.id"></v-tooltip>
</v-btn>
<v-btn style="margin-inline-start:0;" icon="mdi-account-minus" color="warning" @click="delOverlay[index] = true">
<v-btn style="margin-inline-start:0;" icon="mdi-account-minus" color="warning" @click="delOverlay[clients.findIndex(c => c.id == item.id)] = true">
<v-icon />
<v-tooltip activator="parent" location="top" :text="$t('actions.del')"></v-tooltip>
</v-btn>
<v-overlay
v-model="delOverlay[index]"
v-model="delOverlay[clients.findIndex(c => c.id == item.id)]"
contained
class="align-center justify-center"
>
@@ -124,12 +196,12 @@
<v-divider></v-divider>
<v-card-text>{{ $t('confirm') }}</v-card-text>
<v-card-actions>
<v-btn color="error" variant="outlined" @click="delClient(index)">{{ $t('yes') }}</v-btn>
<v-btn color="success" variant="outlined" @click="delOverlay[index] = false">{{ $t('no') }}</v-btn>
<v-btn color="error" variant="outlined" @click="delClient(item.id)">{{ $t('yes') }}</v-btn>
<v-btn color="success" variant="outlined" @click="delOverlay[clients.findIndex(c => c.id == item.id)] = false">{{ $t('no') }}</v-btn>
</v-card-actions>
</v-card>
</v-overlay>
<v-btn icon="mdi-qrcode" @click="showQrCode(index)">
<v-btn icon="mdi-qrcode" @click="showQrCode(item.id)">
<v-icon />
<v-tooltip activator="parent" location="top" text="QR-Code"></v-tooltip>
</v-btn>
@@ -141,8 +213,109 @@
</v-card>
</v-col>
</template>
</v-row>
</template>
<v-row v-else>
<v-col cols="12">
<v-data-table
:headers="headers"
:items="filterSettings.enabled ? filterSettings.filteredClients : clients"
:hide-default-footer="filterSettings.enabled ? filterSettings.filteredClients.length<=10 : clients.length<=10"
hide-no-data
fixed-header
:group-by="groupBy"
item-value="name"
:mobile="smAndDown"
mobile-breakpoint="sm"
width="100%"
class="elevation-3 rounded"
>
<template v-slot:group-header="{ item, columns, toggleGroup, isGroupOpen }">
<tr>
<td :colspan="columns.length" @click="toggleGroup(item)" style="min-height: fit-content; text-align: center;">
<v-icon :icon="isGroupOpen(item) ? '$expand' : '$next'"></v-icon>
{{ item.value.length>0 ? item.value : $t('none') }}
<v-badge :content="(filterSettings.enabled ? filterSettings.filteredClients : clients).filter(c => c.group == item.value).length" inline color="success" />
</td>
</tr>
</template>
<template v-slot:item.volume="{ item }">
<div class="text-start">
<v-chip
size="small"
label
>{{ item.volume == 0 ? $t('unlimited') : HumanReadable.sizeFormat(item.volume) }}</v-chip>
</div>
</template>
<template v-slot:item.expiry="{ item }">
<div class="text-start">
<v-chip
size="small"
label
>{{ item.expiry == 0 ? $t('unlimited') : HumanReadable.remainedDays(item.expiry)?? $t('date.expired') }}</v-chip>
</div>
</template>
<template v-slot:item.online="{ item }">
<div class="text-start">
<template v-if="isOnline(item.name).value">
<v-chip density="comfortable" size="small" color="success" variant="flat">{{ $t('online') }}</v-chip>
</template>
<template v-else>-</template>
</div>
</template>
<template v-slot:item.actions="{ item }">
<v-icon
class="me-2"
@click="showModal(item.id)"
>
mdi-pencil
</v-icon>
<v-menu
v-model="delOverlay[clients.findIndex(c => c.id == item.id)]"
:close-on-content-click="false"
location="top center"
>
<template v-slot:activator="{ props }">
<v-icon
class="me-2"
color="error"
v-bind="props"
>
mdi-delete
</v-icon>
</template>
<v-card :title="$t('actions.del')" rounded="lg">
<v-divider></v-divider>
<v-card-text>{{ $t('confirm') }}</v-card-text>
<v-card-actions>
<v-btn color="error" variant="outlined" @click="delClient(item.id)">{{ $t('yes') }}</v-btn>
<v-btn color="success" variant="outlined" @click="delOverlay[clients.findIndex(c => c.id == item.id)] = false">{{ $t('no') }}</v-btn>
</v-card-actions>
</v-card>
</v-menu>
<v-icon
class="me-2"
@click="showQrCode(item.id)"
>
mdi-qrcode
</v-icon>
<v-icon icon="mdi-chart-line" @click="showStats(item.name)" v-if="v2rayStats.users.includes(item.name)">
<v-tooltip activator="parent" location="top" :text="$t('stats.graphTitle')"></v-tooltip>
</v-icon>
</template>
</v-data-table>
</v-col>
</v-row>
</template>
<style>
.v-data-table__tr--mobile td {
height: fit-content;
min-height: 36px !important;
}
.v-data-table__tr--mobile td div {
width:max-content;
}
</style>
<script lang="ts" setup>
import Data from '@/store/modules/data'
import ClientModal from '@/layouts/modals/Client.vue'
@@ -156,13 +329,16 @@ import { Link, LinkUtil } from '@/plugins/link'
import { HumanReadable } from '@/plugins/utils'
import { i18n } from '@/locales'
import { push } from 'notivue'
import { useDisplay } from 'vuetify'
const { smAndDown } = useDisplay()
const clients = computed((): any[] => {
return Data().clients
})
const onlines = computed(() => {
return Data().onlines.user ? clients.value.map(c => Data().onlines.user.includes(c.name)) : []
const isOnline = (cname: string) => computed(() => {
return Data().onlines?.user ? Data().onlines.user.includes(cname) : false
})
const appConfig = computed((): Config => {
@@ -184,9 +360,19 @@ const inboundTags = computed((): string[] => {
const groups = computed((): string[] => {
if (!clients.value) return []
if (filterSettings?.value.enabled) return Array.from(new Set(filterSettings.value.filteredClients?.map(c => c.group)))
return Array.from(new Set(clients.value?.map(c => c.group)))
})
const filter = ref("")
const filterMenu = ref(false)
const filterSettings = ref({
enabled: false,
state: '',
group: '-',
text: '',
filteredClients: <any[]>[]
})
const tableView = ref(false)
const filterItems = [
{ title: i18n.global.t('none'), value: '' },
@@ -195,18 +381,20 @@ const filterItems = [
{ title: i18n.global.t('online'), value: 'online' },
]
const checkFilter = (c:any) :boolean => {
switch (filter.value) {
case "disable":
return !c.enable
case "expired":
return HumanReadable.remainedDays(c.expiry) == null
case "online":
return Data().onlines?.user?.includes(c.name)
default:
return true
const headers = [
{ title: i18n.global.t('client.name'), key: 'name' },
{ title: i18n.global.t('client.desc'), key: 'desc', sortable: false },
{ title: i18n.global.t('actions.action'), key: 'actions', sortable: false},
{ title: i18n.global.t('stats.volume'), key: 'volume' },
{ title: i18n.global.t('date.expiry'), key: 'expiry' },
{ title: i18n.global.t('online'), key: 'online' },
{ key: 'data-table-group', width: 0 },
]
const groupBy = [
{
key: 'group'
}
}
]
const modal = ref({
visible: false,
@@ -217,7 +405,8 @@ const modal = ref({
const delOverlay = ref(new Array<boolean>(clients.value.length).fill(false))
const showModal = (index: number) => {
const showModal = (id: number) => {
const index = id == -1 ? -1 : clients.value.findIndex(c => c.id == id)
modal.value.index = index
modal.value.data = index == -1 ? '' : JSON.stringify(clients.value[index])
modal.value.stats = index == -1 ? false : v2rayStats.value.users.includes(clients.value[index].name)
@@ -316,8 +505,8 @@ const updateLinks = (c:Client):Link[] => {
return links
}
const delClient = (clientIndex: number) => {
const id = clients.value[clientIndex].id
const delClient = (id: number) => {
const clientIndex = clients.value.findIndex(c => c.id === id)
const oldData = createClient(clients.value[clientIndex])
// Delete stats if exists and will be orphaned
@@ -338,8 +527,9 @@ const qrcode = ref({
index: 0,
})
const showQrCode = (index: number) => {
qrcode.value.index = index
const showQrCode = (id: number) => {
const clientIndex = clients.value.findIndex(c => c.id === id)
qrcode.value.index = clientIndex
qrcode.value.visible = true
}
const closeQrCode = () => {
@@ -359,4 +549,47 @@ const showStats = (tag: string) => {
const closeStats = () => {
stats.value.visible = false
}
var openedGroups = ref(<string[]>[""])
const toggleGroupOpen = (g: string) => {
const index = openedGroups.value.findIndex(og => og == g)
index == -1 ? openedGroups.value.push(g) : openedGroups.value.splice(index,1)
}
const doFilter = () => {
let filteredClients = clients.value.slice()
if (filterSettings.value.group != '-') {
filteredClients = filteredClients.filter(c => c.group == filterSettings.value.group)
}
if (filterSettings.value.text.length>0) {
const txt = filterSettings.value.text
filteredClients = filteredClients.filter(c => c.name.search(txt) != -1 || c.desc.search(txt) != -1)
}
switch (filterSettings.value.state) {
case "disable":
filteredClients = filteredClients.filter(c => c.enable == false)
break
case "expired":
filteredClients = filteredClients.filter(c => HumanReadable.remainedDays(c.expiry) == null)
break
case "online":
filteredClients = filteredClients.filter(c => Data().onlines?.user?.includes(c.name))
break
}
filterSettings.value.filteredClients = filteredClients
filterSettings.value.enabled = true
filterMenu.value = false
}
const clearFilter = () => {
filterSettings.value = {
enabled: false,
state: '',
group: '-',
text: '',
filteredClients: <any[]>[]
}
filterMenu.value = false
}
</script>