mirror of
https://github.com/acepanel/panel.git
synced 2026-02-04 09:13:49 +08:00
feat: 前端优化
This commit is contained in:
@@ -32,6 +32,7 @@
|
||||
"luxon": "^3.5.0",
|
||||
"marked": "^14.1.2",
|
||||
"pinia": "^2.2.4",
|
||||
"pinia-plugin-persistedstate": "^4.1.1",
|
||||
"remove": "^0.1.5",
|
||||
"vue": "^3.5.11",
|
||||
"vue-echarts": "^7.0.3",
|
||||
|
||||
745
web/pnpm-lock.yaml
generated
745
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,6 @@
|
||||
import AppMain from './AppMain.vue'
|
||||
import AppHeader from './header/IndexView.vue'
|
||||
import SideBar from './sidebar/IndexView.vue'
|
||||
import AppTab from './tab/IndexView.vue'
|
||||
|
||||
import { useThemeStore } from '@/store'
|
||||
|
||||
@@ -47,9 +46,6 @@ const themeStore = useThemeStore()
|
||||
>
|
||||
<AppHeader />
|
||||
</header>
|
||||
<section v-if="themeStore.tab.visible" border-b p-10 bc-eee sm:block dark:border-0>
|
||||
<AppTab />
|
||||
</section>
|
||||
<section bg="#f5f6fb" flex-1 overflow-hidden dark:bg-hex-101014>
|
||||
<AppMain />
|
||||
</section>
|
||||
|
||||
@@ -1,21 +1,28 @@
|
||||
<script lang="ts" setup>
|
||||
import ReloadPage from '@/layout/header/components/ReloadPage.vue'
|
||||
import BreadCrumb from './components/BreadCrumb.vue'
|
||||
import AppTab from '@/layout/tab/IndexView.vue'
|
||||
import FullScreen from './components/FullScreen.vue'
|
||||
import MenuCollapse from './components/MenuCollapse.vue'
|
||||
import ThemeMode from './components/ThemeMode.vue'
|
||||
import UserAvatar from './components/UserAvatar.vue'
|
||||
|
||||
import MenuCollapse from '@/layout/header/components/MenuCollapse.vue'
|
||||
import { useThemeStore } from '@/store'
|
||||
|
||||
const themeStore = useThemeStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div flex items-center>
|
||||
<div w-full flex items-center justify-between>
|
||||
<MenuCollapse />
|
||||
<BreadCrumb ml-15 hidden sm:block />
|
||||
</div>
|
||||
<div ml-auto flex items-center>
|
||||
<ReloadPage />
|
||||
<ThemeMode />
|
||||
<FullScreen />
|
||||
<UserAvatar />
|
||||
<section v-if="!themeStore.isMobile && themeStore.tab.visible" w-0 flex-1 px-12>
|
||||
<AppTab />
|
||||
</section>
|
||||
<span v-if="!themeStore.isMobile && themeStore.tab.visible" mx-6 opacity-20>|</span>
|
||||
<div ml-auto flex flex-shrink-0 items-center px-12>
|
||||
<ReloadPage />
|
||||
<ThemeMode />
|
||||
<FullScreen />
|
||||
<UserAvatar />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
<script lang="ts" setup>
|
||||
import { renderIcon } from '@/utils'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Meta } from '~/types/router'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const generator: any = (routerMap: any) => {
|
||||
return routerMap.map((item: any) => {
|
||||
const currentMenu = {
|
||||
...item,
|
||||
label: t(String(item.meta.title)),
|
||||
key: item.path,
|
||||
disabled: item.path === '/',
|
||||
icon: getIcon(item.meta)
|
||||
}
|
||||
// 是否有子菜单,并递归处理
|
||||
if (item.children && item.children.length > 0) {
|
||||
currentMenu.children = generator(item.children, currentMenu)
|
||||
}
|
||||
return currentMenu
|
||||
})
|
||||
}
|
||||
|
||||
const breadcrumbList = computed(() => {
|
||||
return generator(route.matched)
|
||||
})
|
||||
|
||||
function handleBreadClick(path: string) {
|
||||
if (path === route.path) return
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
function getIcon(meta?: Meta, size = 16) {
|
||||
if (meta?.icon) return renderIcon(meta.icon, { size })
|
||||
return ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-breadcrumb>
|
||||
<template v-for="routeItem in breadcrumbList" :key="routeItem.name">
|
||||
<n-breadcrumb-item v-if="routeItem.meta.title">
|
||||
<n-dropdown
|
||||
v-if="routeItem.children.length"
|
||||
:options="routeItem.children"
|
||||
@select="handleBreadClick"
|
||||
>
|
||||
<span class="link-text">
|
||||
<component :is="routeItem.icon" v-if="routeItem.icon" />
|
||||
{{ $t(routeItem.meta.title) }}
|
||||
</span>
|
||||
</n-dropdown>
|
||||
<span v-else class="link-text">
|
||||
<component :is="routeItem.icon" v-if="routeItem.icon" />
|
||||
{{ $t(routeItem.meta.title) }}
|
||||
</span>
|
||||
</n-breadcrumb-item>
|
||||
</template>
|
||||
</n-breadcrumb>
|
||||
</template>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import user from '@/api/panel/user'
|
||||
import { router } from '@/router'
|
||||
import { useUserStore } from '@/store'
|
||||
import { renderIcon } from '@/utils'
|
||||
@@ -18,7 +19,7 @@ const options = [
|
||||
}
|
||||
]
|
||||
|
||||
function handleSelect(key: string) {
|
||||
const handleSelect = (key: string) => {
|
||||
if (key === 'logout') {
|
||||
window.$dialog.info({
|
||||
content: '确认退出?',
|
||||
@@ -26,7 +27,9 @@ function handleSelect(key: string) {
|
||||
positiveText: '确定',
|
||||
negativeText: '取消',
|
||||
onPositiveClick() {
|
||||
userStore.logout()
|
||||
user.logout().then(() => {
|
||||
userStore.logout()
|
||||
})
|
||||
window.$message.success('已退出登录!')
|
||||
}
|
||||
})
|
||||
@@ -35,12 +38,19 @@ function handleSelect(key: string) {
|
||||
router.push({ name: 'setting-index' })
|
||||
}
|
||||
}
|
||||
|
||||
const username = computed(() => {
|
||||
if (userStore.username !== '') {
|
||||
return userStore.username
|
||||
}
|
||||
return '未知'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-dropdown :options="options" @select="handleSelect">
|
||||
<div flex cursor-pointer items-center>
|
||||
<span hidden sm:block>{{ userStore.username }}</span>
|
||||
<span>{{ username }}</span>
|
||||
</div>
|
||||
</n-dropdown>
|
||||
</template>
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { TabItem } from '@/store'
|
||||
import { useTabStore } from '@/store'
|
||||
import ContextMenu from './components/ContextMenu.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const tabStore = useTabStore()
|
||||
|
||||
@@ -21,16 +20,6 @@ const contextMenuOption = reactive<ContextMenuOption>({
|
||||
currentPath: ''
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
const { name, fullPath: path } = route
|
||||
const title = (route.meta?.title as string) || ''
|
||||
tabStore.addTab({ name: name as string, path, title })
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function handleTagClick(path: string) {
|
||||
tabStore.setActiveTab(path)
|
||||
router.push(path)
|
||||
@@ -61,12 +50,10 @@ async function handleContextMenu(e: MouseEvent, tabItem: TabItem) {
|
||||
<template>
|
||||
<div>
|
||||
<n-tabs
|
||||
:value="tabStore.activeTab"
|
||||
:value="tabStore.active"
|
||||
:closable="tabStore.tabs.length > 1"
|
||||
type="card"
|
||||
@close="(path: string) => tabStore.removeTab(path)"
|
||||
bg-white
|
||||
dark:bg-dark
|
||||
>
|
||||
<n-tab
|
||||
v-for="item in tabStore.tabs"
|
||||
|
||||
@@ -23,7 +23,7 @@ const options = computed(() => [
|
||||
{
|
||||
label: '重新加载',
|
||||
key: 'reload',
|
||||
disabled: props.currentPath !== tabStore.activeTab,
|
||||
disabled: props.currentPath !== tabStore.active,
|
||||
icon: renderIcon('mdi:refresh', { size: 14 })
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createTabGuard } from '@/router/guard/tab-guard'
|
||||
import type { Router } from 'vue-router'
|
||||
import { createAppInstallGuard } from './app-install-guard'
|
||||
import { createPageLoadingGuard } from './page-loading-guard'
|
||||
@@ -6,5 +7,6 @@ import { createPageTitleGuard } from './page-title-guard'
|
||||
export function setupRouterGuard(router: Router) {
|
||||
createPageLoadingGuard(router)
|
||||
createPageTitleGuard(router)
|
||||
createTabGuard(router)
|
||||
createAppInstallGuard(router)
|
||||
}
|
||||
|
||||
18
web/src/router/guard/tab-guard.ts
Normal file
18
web/src/router/guard/tab-guard.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useTabStore } from '@/store'
|
||||
import type { Router } from 'vue-router'
|
||||
|
||||
export const EXCLUDE_TAB = ['/404', '/403', '/login']
|
||||
|
||||
export function createTabGuard(router: Router) {
|
||||
router.afterEach((to) => {
|
||||
if (EXCLUDE_TAB.includes(to.path)) return
|
||||
const tabStore = useTabStore()
|
||||
const { name, fullPath: path } = to
|
||||
const title = String(to.meta?.title)
|
||||
tabStore.addTab({
|
||||
name: String(name),
|
||||
path,
|
||||
title
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { usePermissionStore, useUserStore } from '@/store'
|
||||
import { usePermissionStore } from '@/store'
|
||||
import type { App } from 'vue'
|
||||
import { createRouter, createWebHashHistory, createWebHistory } from 'vue-router'
|
||||
import type { RoutesType, RouteType } from '~/types/router'
|
||||
@@ -22,8 +22,6 @@ export async function setupRouter(app: App) {
|
||||
|
||||
export async function addDynamicRoutes() {
|
||||
try {
|
||||
const userStore = useUserStore()
|
||||
await userStore.getUserInfo()
|
||||
const permissionStore = usePermissionStore()
|
||||
const accessRoutes = permissionStore.generateRoutes(['admin'])
|
||||
accessRoutes.forEach((route: RouteType) => {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { createPinia } from 'pinia'
|
||||
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
|
||||
import type { App } from 'vue'
|
||||
|
||||
export async function setupStore(app: App) {
|
||||
app.use(createPinia())
|
||||
const pinia = createPinia()
|
||||
pinia.use(piniaPluginPersistedstate)
|
||||
app.use(pinia)
|
||||
}
|
||||
|
||||
export * from './modules'
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import { getSession } from '@/utils'
|
||||
|
||||
export const activeTab = getSession('activeTab')
|
||||
export const tabs = getSession('tabs')
|
||||
|
||||
export const WITHOUT_TAB_PATHS = ['/404', '/login']
|
||||
@@ -1,29 +1,32 @@
|
||||
import { router } from '@/router'
|
||||
import { setSession } from '@/utils'
|
||||
import { defineStore } from 'pinia'
|
||||
import { activeTab, tabs, WITHOUT_TAB_PATHS } from './helpers'
|
||||
|
||||
export const WITHOUT_TAB_PATHS = ['/404', '/login']
|
||||
|
||||
export interface Tab {
|
||||
active: string
|
||||
tabs: Array<TabItem>
|
||||
}
|
||||
|
||||
export interface TabItem {
|
||||
name: string
|
||||
path: string
|
||||
title?: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export const useTabStore = defineStore('tab', {
|
||||
state() {
|
||||
state: (): Tab => {
|
||||
return {
|
||||
tabs: <Array<TabItem>>tabs || [],
|
||||
activeTab: <string>activeTab || ''
|
||||
active: '',
|
||||
tabs: []
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
setActiveTab(path: string) {
|
||||
this.activeTab = path
|
||||
setSession('activeTab', path)
|
||||
this.active = path
|
||||
},
|
||||
setTabs(tabs: Array<TabItem>) {
|
||||
this.tabs = tabs
|
||||
setSession('tabs', tabs)
|
||||
},
|
||||
addTab(tab: TabItem) {
|
||||
this.setActiveTab(tab.path)
|
||||
@@ -32,7 +35,7 @@ export const useTabStore = defineStore('tab', {
|
||||
this.setTabs([...this.tabs, tab])
|
||||
},
|
||||
removeTab(path: string) {
|
||||
if (path === this.activeTab) {
|
||||
if (path === this.active) {
|
||||
const activeIndex = this.tabs.findIndex((item) => item.path === path)
|
||||
if (activeIndex > 0) router.push(this.tabs[activeIndex - 1].path)
|
||||
else router.push(this.tabs[activeIndex + 1].path)
|
||||
@@ -41,25 +44,26 @@ export const useTabStore = defineStore('tab', {
|
||||
},
|
||||
removeOther(curPath: string) {
|
||||
this.setTabs(this.tabs.filter((tab) => tab.path === curPath))
|
||||
if (curPath !== this.activeTab) router.push(this.tabs[this.tabs.length - 1].path)
|
||||
if (curPath !== this.active) router.push(this.tabs[this.tabs.length - 1].path)
|
||||
},
|
||||
removeLeft(curPath: string) {
|
||||
const curIndex = this.tabs.findIndex((item) => item.path === curPath)
|
||||
const filterTabs = this.tabs.filter((item, index) => index >= curIndex)
|
||||
this.setTabs(filterTabs)
|
||||
if (!filterTabs.find((item) => item.path === this.activeTab))
|
||||
if (!filterTabs.find((item) => item.path === this.active))
|
||||
router.push(filterTabs[filterTabs.length - 1].path)
|
||||
},
|
||||
removeRight(curPath: string) {
|
||||
const curIndex = this.tabs.findIndex((item) => item.path === curPath)
|
||||
const filterTabs = this.tabs.filter((item, index) => index <= curIndex)
|
||||
this.setTabs(filterTabs)
|
||||
if (!filterTabs.find((item) => item.path === this.activeTab))
|
||||
if (!filterTabs.find((item) => item.path === this.active))
|
||||
router.push(filterTabs[filterTabs.length - 1].path)
|
||||
},
|
||||
resetTabs() {
|
||||
this.setTabs([])
|
||||
this.setActiveTab('')
|
||||
}
|
||||
}
|
||||
},
|
||||
persist: true
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ interface ColorAction {
|
||||
}
|
||||
|
||||
/** 初始化主题配置 */
|
||||
export function initThemeSettings(): Theme.Setting {
|
||||
export function defaultSettings(): Theme.Setting {
|
||||
const isMobile = themeSetting.isMobile || false
|
||||
const darkMode = themeSetting.darkMode || false
|
||||
const sider = themeSetting.sider || {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from 'naive-ui'
|
||||
import type { BuiltInGlobalTheme } from 'naive-ui/es/themes/interface'
|
||||
import { defineStore } from 'pinia'
|
||||
import { getNaiveThemeOverrides, initThemeSettings } from './helpers'
|
||||
import { defaultSettings, getNaiveThemeOverrides } from './helpers'
|
||||
|
||||
type ThemeState = Theme.Setting
|
||||
|
||||
@@ -19,8 +19,8 @@ const locales: Record<string, { locale: NLocale; dateLocale: NDateLocale }> = {
|
||||
en: { locale: enUS, dateLocale: dateEnUS }
|
||||
}
|
||||
|
||||
export const useThemeStore = defineStore('theme-store', {
|
||||
state: (): ThemeState => initThemeSettings(),
|
||||
export const useThemeStore = defineStore('theme', {
|
||||
state: (): ThemeState => defaultSettings(),
|
||||
getters: {
|
||||
naiveThemeOverrides(): GlobalThemeOverrides {
|
||||
return getNaiveThemeOverrides({
|
||||
@@ -66,5 +66,6 @@ export const useThemeStore = defineStore('theme-store', {
|
||||
setLocale(locale: string) {
|
||||
this.locale = locale
|
||||
}
|
||||
}
|
||||
},
|
||||
persist: true
|
||||
})
|
||||
|
||||
@@ -1,56 +1,37 @@
|
||||
import user from '@/api/panel/user'
|
||||
import { resetRouter } from '@/router'
|
||||
import { usePermissionStore, useTabStore } from '@/store'
|
||||
import { toLogin } from '@/utils'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
interface UserInfo {
|
||||
export interface UserInfo {
|
||||
id?: string
|
||||
username?: string
|
||||
role?: Array<string>
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore('user', {
|
||||
state() {
|
||||
state: (): UserInfo => {
|
||||
return {
|
||||
userInfo: <UserInfo>{}
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
userId(): string {
|
||||
return this.userInfo.id || ''
|
||||
},
|
||||
username(): string {
|
||||
return this.userInfo.username || ''
|
||||
},
|
||||
role(): Array<string> {
|
||||
return this.userInfo.role || []
|
||||
id: '',
|
||||
username: '',
|
||||
role: []
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
async getUserInfo() {
|
||||
try {
|
||||
const res: any = await user.info()
|
||||
const { id, username, role } = res.data
|
||||
this.userInfo = { id, username, role }
|
||||
return Promise.resolve(res.data)
|
||||
} catch (error) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
set(info: UserInfo) {
|
||||
this.id = info.id
|
||||
this.username = info.username
|
||||
this.role = info.role
|
||||
},
|
||||
async logout() {
|
||||
user.logout().then(() => {
|
||||
const { resetTabs } = useTabStore()
|
||||
const { resetPermission } = usePermissionStore()
|
||||
resetPermission()
|
||||
resetTabs()
|
||||
resetRouter()
|
||||
this.$reset()
|
||||
toLogin()
|
||||
})
|
||||
},
|
||||
setUserInfo(userInfo = {}) {
|
||||
this.userInfo = { ...this.userInfo, ...userInfo }
|
||||
logout() {
|
||||
const { resetTabs } = useTabStore()
|
||||
const { resetPermission } = usePermissionStore()
|
||||
resetPermission()
|
||||
resetTabs()
|
||||
resetRouter()
|
||||
this.$reset()
|
||||
toLogin()
|
||||
}
|
||||
}
|
||||
},
|
||||
persist: true
|
||||
})
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export * from './local'
|
||||
export * from './session'
|
||||
|
||||
@@ -1,31 +1,28 @@
|
||||
import { decrypto, encrypto } from '@/utils'
|
||||
|
||||
interface StorageData {
|
||||
value: unknown
|
||||
value: any
|
||||
expire: number | null
|
||||
}
|
||||
|
||||
/** 默认缓存期限为7天 */
|
||||
const DEFAULT_CACHE_TIME = 60 * 60 * 24 * 7
|
||||
/** 默认保存期限为永久 */
|
||||
const DEFAULT_CACHE_TIME = 0
|
||||
|
||||
export function setLocal(key: string, value: unknown, expire: number | null = DEFAULT_CACHE_TIME) {
|
||||
export function setLocal(key: string, value: any, expire: number | null = DEFAULT_CACHE_TIME) {
|
||||
const storageData: StorageData = {
|
||||
value,
|
||||
expire: expire !== null ? new Date().getTime() + expire * 1000 : null
|
||||
expire: expire !== 0 && expire !== null ? new Date().getTime() + expire * 1000 : 0
|
||||
}
|
||||
const json = encrypto(storageData)
|
||||
const json = JSON.stringify(storageData)
|
||||
window.localStorage.setItem(key, json)
|
||||
}
|
||||
|
||||
export function getLocal<T>(key: string) {
|
||||
const json = window.localStorage.getItem(key)
|
||||
if (json) {
|
||||
let storageData: StorageData | null = null
|
||||
storageData = decrypto(json)
|
||||
const storageData = JSON.parse(json)
|
||||
if (storageData) {
|
||||
const { value, expire } = storageData
|
||||
// 没有过期时间或者在有效期内则直接返回
|
||||
if (expire === null || expire >= Date.now()) return value as T
|
||||
if (expire === 0 || expire >= Date.now()) return value as T
|
||||
}
|
||||
removeLocal(key)
|
||||
return null
|
||||
@@ -36,8 +33,7 @@ export function getLocal<T>(key: string) {
|
||||
export function getLocalExpire(key: string): number | null {
|
||||
const json = window.localStorage.getItem(key)
|
||||
if (json) {
|
||||
let storageData: StorageData | null = null
|
||||
storageData = decrypto(json)
|
||||
const storageData = JSON.parse(json)
|
||||
if (storageData) {
|
||||
return storageData.expire
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { decrypto, encrypto } from '@/utils'
|
||||
|
||||
export function setSession(key: string, value: unknown) {
|
||||
const json = encrypto(value)
|
||||
sessionStorage.setItem(key, json)
|
||||
}
|
||||
|
||||
export function getSession<T>(key: string) {
|
||||
const json = sessionStorage.getItem(key)
|
||||
let data: T | null = null
|
||||
if (json) {
|
||||
data = decrypto(json)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
export function removeSession(key: string) {
|
||||
window.sessionStorage.removeItem(key)
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
window.sessionStorage.clear()
|
||||
}
|
||||
@@ -28,9 +28,9 @@ if (localLoginInfo) {
|
||||
loginInfo.value.password = localLoginInfo.password || ''
|
||||
}
|
||||
|
||||
const userStore = useUserStore()
|
||||
const loging = ref<boolean>(false)
|
||||
const isRemember = useStorage('isRemember', false)
|
||||
const userStore = useUserStore()
|
||||
|
||||
async function handleLogin() {
|
||||
const { username, password } = loginInfo.value
|
||||
@@ -49,7 +49,8 @@ async function handleLogin() {
|
||||
}
|
||||
|
||||
await addDynamicRoutes()
|
||||
await userStore.getUserInfo()
|
||||
const { data } = await user.info()
|
||||
userStore.set(data)
|
||||
if (query.redirect) {
|
||||
const path = query.redirect as string
|
||||
Reflect.deleteProperty(query, 'redirect')
|
||||
@@ -69,7 +70,8 @@ onMounted(async () => {
|
||||
await user.isLogin().then(async (res) => {
|
||||
if (res.data) {
|
||||
await addDynamicRoutes()
|
||||
await userStore.getUserInfo()
|
||||
const { data } = await user.info()
|
||||
userStore.set(data)
|
||||
if (query.redirect) {
|
||||
const path = query.redirect as string
|
||||
Reflect.deleteProperty(query, 'redirect')
|
||||
|
||||
Reference in New Issue
Block a user