mirror of
https://github.com/gl-inet/glkvm-cloud.git
synced 2026-10-06 04:56:31 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 30a132cb4e | |||
| 52117f044b | |||
| 884f537ae8 | |||
| 807bab3e2a | |||
| a1823096cc | |||
| b1aa8e3cae | |||
| 3d75a87140 | |||
| d237847f0d | |||
| 59021535b7 | |||
| 133f344713 | |||
| 7fd9c39ffb | |||
| 96936ecdd8 | |||
| a44c9a4833 |
Vendored
+1
@@ -3,6 +3,7 @@
|
||||
"ddns",
|
||||
"glkvm",
|
||||
"repassword",
|
||||
"sortablejs",
|
||||
"webrtc"
|
||||
]
|
||||
}
|
||||
@@ -118,13 +118,67 @@ func (h *DeviceHandler) ListDevices(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// Parse sort parameters: sortBy and order
|
||||
sortBy := strings.TrimSpace(c.Query("sortBy")) // id, ip, mac, connectedTime, description, ddns
|
||||
sortOrder := strings.TrimSpace(c.Query("order")) // asc, desc (default: asc)
|
||||
|
||||
ascending := true
|
||||
if strings.EqualFold(sortOrder, "desc") {
|
||||
ascending = false
|
||||
}
|
||||
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
// Online devices always come first regardless of sort field/order
|
||||
oi := items[i].Status == device.StatusOnline
|
||||
oj := items[j].Status == device.StatusOnline
|
||||
if oi != oj {
|
||||
return oi
|
||||
}
|
||||
return items[i].Ddns < items[j].Ddns
|
||||
|
||||
// Secondary sort by the requested field
|
||||
// cmp: -1 means i<j, 0 means equal, 1 means i>j
|
||||
var cmp int
|
||||
switch sortBy {
|
||||
case "id":
|
||||
switch {
|
||||
case items[i].ID < items[j].ID:
|
||||
cmp = -1
|
||||
case items[i].ID > items[j].ID:
|
||||
cmp = 1
|
||||
}
|
||||
case "ip":
|
||||
cmp = strings.Compare(items[i].IP, items[j].IP)
|
||||
case "mac":
|
||||
cmp = strings.Compare(items[i].Mac, items[j].Mac)
|
||||
case "connectedTime":
|
||||
var ti, tj int64
|
||||
if items[i].LastSeenAt != nil {
|
||||
ti = *items[i].LastSeenAt
|
||||
}
|
||||
if items[j].LastSeenAt != nil {
|
||||
tj = *items[j].LastSeenAt
|
||||
}
|
||||
switch {
|
||||
case ti < tj:
|
||||
cmp = -1
|
||||
case ti > tj:
|
||||
cmp = 1
|
||||
}
|
||||
case "description":
|
||||
cmp = strings.Compare(items[i].Description, items[j].Description)
|
||||
case "ddns":
|
||||
cmp = strings.Compare(items[i].Ddns, items[j].Ddns)
|
||||
default:
|
||||
cmp = strings.Compare(items[i].Ddns, items[j].Ddns)
|
||||
}
|
||||
|
||||
if cmp == 0 {
|
||||
return false // equal, preserve original order
|
||||
}
|
||||
if ascending {
|
||||
return cmp < 0
|
||||
}
|
||||
return cmp > 0
|
||||
})
|
||||
|
||||
out := make([]dto.Device, 0, len(items))
|
||||
|
||||
@@ -161,6 +161,18 @@ func (h *UserHandler) UpdateUser(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
target, err := h.userSvc.FindByID(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeNotFound, "Not found", nil))
|
||||
return
|
||||
}
|
||||
if target.IsSystem {
|
||||
req.Username = nil
|
||||
req.Role = nil
|
||||
req.Password = nil
|
||||
req.Repassword = nil
|
||||
}
|
||||
|
||||
if err := h.userSvc.UpdateUser(c.Request.Context(), id, req.Username, req.Description, req.Password, req.Role, nil); err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "not found") {
|
||||
dto.Write(c, dto.Err(traceID, dto.CodeNotFound, "Not found", nil))
|
||||
|
||||
@@ -138,14 +138,16 @@ func ensureAdminUser(ctx context.Context, db *gorm.DB, adminName, plainPassword
|
||||
}
|
||||
|
||||
// Upsert: create the admin user if not exists, or update password/role/status.
|
||||
// On conflict, also set description to 'System Administrator' if it is currently empty.
|
||||
return db.WithContext(ctx).Exec(
|
||||
`INSERT INTO users (username, description, password_hash, role, status, is_system)
|
||||
VALUES (?, 'Admin', ?, 'admin', 'active', 1)
|
||||
VALUES (?, 'System Administrator', ?, 'admin', 'active', 1)
|
||||
ON CONFLICT(username) DO UPDATE SET
|
||||
password_hash=excluded.password_hash,
|
||||
role='admin',
|
||||
status='active',
|
||||
is_system=1`,
|
||||
is_system=1,
|
||||
description=CASE WHEN (description IS NULL OR description = '') THEN 'System Administrator' ELSE description END`,
|
||||
adminName, hash,
|
||||
).Error
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package server
|
||||
|
||||
const RttysVersion = "5.2.0"
|
||||
const KVMCloudVersion = "v2.0.0"
|
||||
const KVMCloudVersion = "v2.3.0"
|
||||
|
||||
var (
|
||||
GitCommit = ""
|
||||
BuildTime = ""
|
||||
GitCommit = ""
|
||||
BuildTime = ""
|
||||
)
|
||||
|
||||
+2
-1
@@ -25,12 +25,13 @@
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"axios": "^1.9.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"gl-web-main": "^1.0.0",
|
||||
"gl-web-main": "1.0.2",
|
||||
"js-cookie": "^3.0.5",
|
||||
"jsencrypt": "^3.3.2",
|
||||
"pinia": "^3.0.2",
|
||||
"sass": "^1.89.0",
|
||||
"simple-keyboard": "3.8.69",
|
||||
"sortablejs": "^1.15.7",
|
||||
"vite-plugin-remove-console": "^2.2.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-clipboard3": "2.0.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: shufei.han
|
||||
* @Date: 2025-06-11 11:48:02
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2026-02-04 09:32:31
|
||||
* @LastEditTime: 2026-03-09 12:08:45
|
||||
* @FilePath: \glkvm-cloud\ui\src\api\device.ts
|
||||
* @Description: 设备相关API
|
||||
*/
|
||||
@@ -10,7 +10,7 @@ import { ExecuteCommandParams, type DeviceInfo } from '@/models/device'
|
||||
import request, { httpService } from './request'
|
||||
|
||||
/** 获取设备列表 */
|
||||
export const getDeviceListApi = (params?: { groupId: number }) => {
|
||||
export const getDeviceListApi = (params?: { groupId: number, sortBy?: string, order?: 'asc' | 'desc' }) => {
|
||||
return request<{ items: DeviceInfo[]}>({
|
||||
url: '/api/devices',
|
||||
params,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -2,7 +2,7 @@
|
||||
* @Author: LPY
|
||||
* @Date: 2025-05-30 10:18:18
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2026-01-05 14:22:12
|
||||
* @LastEditTime: 2026-03-10 11:52:27
|
||||
* @FilePath: \glkvm-cloud\ui\src\hooks\useLocalStorage.ts
|
||||
* @Description: 存储hook
|
||||
*/
|
||||
@@ -20,6 +20,8 @@ export enum LocalStorageKeys {
|
||||
SIDEBAR_MANUAL_CONTROL_KEY = 'sidebar-manual-control',
|
||||
/** 版本号 */
|
||||
VERSION = 'version',
|
||||
/** 设备列表列表顺序 */
|
||||
DEVICE_LIST_COLUMNS_KEY = 'device-list-columns',
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,7 +29,7 @@ export enum LocalStorageKeys {
|
||||
* @param key 需要使用哪种数据
|
||||
* @param {T} initValue 如果没有存储,则返回的初始值
|
||||
*/
|
||||
export function useLocalStorage <T extends {toString: () => string}> (
|
||||
export function useLocalStorage <T> (
|
||||
key: LocalStorageKeys,
|
||||
initValue: T = null,
|
||||
transform: (value: string) => T = (value) => value as unknown as T,
|
||||
@@ -36,9 +38,15 @@ export function useLocalStorage <T extends {toString: () => string}> (
|
||||
const storageValue = ref<T>(initValue)
|
||||
/** 获取本地存储的值 */
|
||||
const getValue = () => {
|
||||
const storageData = localStorage.getItem(key)
|
||||
// 特殊兼容以前的版本直接存储字符串的情况
|
||||
let storageData: T = null
|
||||
try {
|
||||
storageData = JSON.parse(localStorage.getItem(key))
|
||||
} catch {
|
||||
storageData = localStorage.getItem(key) as unknown as T
|
||||
}
|
||||
if (storageData !== null) {
|
||||
return transform(storageData)
|
||||
return transform(storageData as unknown as string)
|
||||
}
|
||||
else {
|
||||
return initValue
|
||||
@@ -46,7 +54,7 @@ export function useLocalStorage <T extends {toString: () => string}> (
|
||||
}
|
||||
/** 设置本地存储的值 */
|
||||
const setValue = (value: T) => {
|
||||
localStorage.setItem(key, value?.toString())
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
}
|
||||
/** 清除本地存储的值 */
|
||||
const removeValue = () => {
|
||||
|
||||
@@ -124,7 +124,9 @@
|
||||
"ipNotCorrect": "IP address is incorrect",
|
||||
"portNotCorrect": "Port is incorrect",
|
||||
"remoteWeb": "Remote Web",
|
||||
"linuxTips": "Supports OpenWrt, Raspberry PI, Ubuntu, CentOS, etc."
|
||||
"linuxTips": "Supports OpenWrt, Raspberry PI, Ubuntu, CentOS, etc.",
|
||||
"customColumns": "Custom Columns",
|
||||
"dragColumnTips": "You can customize your device list. Drag the button on the right side of the following items to adjust the display order, or use the checkboxes to control the display or hide of certain columns."
|
||||
},
|
||||
"user": {
|
||||
"user": "User",
|
||||
|
||||
@@ -124,7 +124,9 @@
|
||||
"ipNotCorrect": "IP 地址错误",
|
||||
"portNotCorrect": "端口号错误",
|
||||
"remoteWeb": "远程 Web",
|
||||
"linuxTips": "支持 OpenWrt、树莓派、Ubuntu、CentOS 等"
|
||||
"linuxTips": "支持 OpenWrt、树莓派、Ubuntu、CentOS 等",
|
||||
"customColumns": "自定义列",
|
||||
"dragColumnTips": "您可以自定义您的设备列表,拖动下列项右侧的按钮来调整显示顺序,也可以通过复选框来控制显示或隐藏某些列。"
|
||||
},
|
||||
"user": {
|
||||
"user": "用户",
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface DeviceQuery {
|
||||
searchText: string
|
||||
deviceGroupId: number
|
||||
onlyShowUnassigned: boolean
|
||||
sortBy?: string
|
||||
order?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
/** 执行命令参数 */
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: LPY
|
||||
* @Date: 2025-05-30 10:54:44
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2026-02-03 10:22:56
|
||||
* @LastEditTime: 2026-03-09 14:30:29
|
||||
* @FilePath: \glkvm-cloud\ui\src\projectInitialize\loadAdvComponent.ts
|
||||
* @Description: 加载Ant 组件
|
||||
*/
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Select,
|
||||
Tabs,
|
||||
Radio,
|
||||
Popover,
|
||||
} from 'ant-design-vue'
|
||||
|
||||
export default function (app: any) {
|
||||
@@ -46,4 +47,5 @@ export default function (app: any) {
|
||||
app.use(Select)
|
||||
app.use(Tabs)
|
||||
app.use(Radio)
|
||||
app.use(Popover)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: LPY
|
||||
* @Date: 2025-05-30 09:37:06
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2026-01-05 14:35:02
|
||||
* @LastEditTime: 2026-03-10 11:44:32
|
||||
* @FilePath: \glkvm-cloud\ui\src\stores\modules\app.ts
|
||||
* @Description: app相关状态存储
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: shufei.han
|
||||
* @Date: 2025-06-10 16:46:00
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2026-01-30 17:41:49
|
||||
* @LastEditTime: 2026-03-09 12:17:03
|
||||
* @FilePath: \glkvm-cloud\ui\src\stores\modules\device.ts
|
||||
* @Description: 设备有关的状态管理
|
||||
*/
|
||||
@@ -36,6 +36,10 @@ export const useDeviceStore = defineStore('device', () => {
|
||||
onlyShowUnassigned: false,
|
||||
/** 这个字段存储是否有设备,因为UI上没有设备和没有筛选出来的设备是对应不同的展示画面的 */
|
||||
hasDevice: false,
|
||||
/** 排序字段 */
|
||||
sortBy: undefined,
|
||||
/** 排序方式 */
|
||||
order: undefined,
|
||||
})
|
||||
|
||||
const pageLink = ref(new PageLink({ size: DEVICE_VIEW_PAGE_SIZE }))
|
||||
@@ -49,6 +53,8 @@ export const useDeviceStore = defineStore('device', () => {
|
||||
searchText: state.searchText?.replaceAll(':','').toLowerCase(),
|
||||
deviceGroupId: state.deviceGroupId,
|
||||
onlyShowUnassigned: state.onlyShowUnassigned,
|
||||
sortBy: state.sortBy,
|
||||
order: state.order,
|
||||
}
|
||||
return query
|
||||
})
|
||||
@@ -66,7 +72,11 @@ export const useDeviceStore = defineStore('device', () => {
|
||||
try {
|
||||
console.log('getDeviceList', computedDeviceQuery.value)
|
||||
!isPolling && (state.getDeviceLoading = true)
|
||||
const res = await getDeviceListApi()
|
||||
const res = await getDeviceListApi({
|
||||
groupId: computedDeviceQuery.value.deviceGroupId,
|
||||
sortBy: computedDeviceQuery.value.sortBy,
|
||||
order: computedDeviceQuery.value.order,
|
||||
})
|
||||
console.log(res)
|
||||
|
||||
if (isGetAll) {
|
||||
@@ -77,7 +87,9 @@ export const useDeviceStore = defineStore('device', () => {
|
||||
}
|
||||
pageLink.value.setTotal(res.data.items.length)
|
||||
state.deviceList = res.data.items.filter(d => {
|
||||
return (d?.id?.toString().toLowerCase()?.indexOf(computedDeviceQuery.value.searchText) > -1
|
||||
return (d?.ddns?.toString().toLowerCase()?.indexOf(computedDeviceQuery.value.searchText) > -1
|
||||
|| d?.mac?.toString().toLowerCase()?.indexOf(computedDeviceQuery.value.searchText) > -1
|
||||
|| d?.ip?.toString().toLowerCase()?.indexOf(computedDeviceQuery.value.searchText) > -1
|
||||
|| d?.description?.toLowerCase()?.indexOf(computedDeviceQuery.value.searchText) > -1) &&
|
||||
(computedDeviceQuery.value.deviceGroupId ? d.deviceGroupId === computedDeviceQuery.value.deviceGroupId : true) &&
|
||||
(!computedDeviceQuery.value.onlyShowUnassigned || (computedDeviceQuery.value.onlyShowUnassigned && !d.deviceGroupId))
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: LPY
|
||||
* @Date: 2026-01-30 10:19:24
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2026-02-04 10:58:29
|
||||
* @LastEditTime: 2026-02-28 09:28:39
|
||||
* @FilePath: \glkvm-cloud\ui\src\stores\modules\deviceGroup.ts
|
||||
* @Description: 设备组有关的状态管理
|
||||
*/
|
||||
@@ -63,8 +63,7 @@ export const useDeviceGroupStore = defineStore('deviceGroup', () => {
|
||||
const res = await reqDeviceGroupList()
|
||||
pageLink.value.setTotal(res.data.items.length)
|
||||
state.deviceGroupList = res.data.items.filter(d => {
|
||||
return (d?.id?.toString()?.indexOf(computedDeviceGroupQuery.value.searchText) > -1
|
||||
|| d?.description?.indexOf(computedDeviceGroupQuery.value.searchText) > -1
|
||||
return (d?.description?.indexOf(computedDeviceGroupQuery.value.searchText) > -1
|
||||
|| d?.name?.indexOf(computedDeviceGroupQuery.value.searchText) > -1)
|
||||
}) || []
|
||||
state.completeDeviceGroupList = res.data.items || []
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: LPY
|
||||
* @Date: 2026-02-03 12:07:45
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2026-02-04 10:56:54
|
||||
* @LastEditTime: 2026-02-28 09:27:05
|
||||
* @FilePath: \glkvm-cloud\ui\src\stores\modules\userGroupManage.ts
|
||||
* @Description: 用户组管理相关状态管理
|
||||
*/
|
||||
@@ -65,7 +65,7 @@ export const useUserGroupManageStore = defineStore('userGroupManage', () => {
|
||||
const res = await reqUserGroupList()
|
||||
pageLink.value.setTotal(res.data.items.length)
|
||||
state.userGroupList = res.data.items.filter(d => {
|
||||
return (d?.id?.toString()?.indexOf(computedUserGroupManageQuery.value.searchText) > -1
|
||||
return (d?.description?.toString()?.toLowerCase()?.indexOf(computedUserGroupManageQuery.value.searchText) > -1
|
||||
|| d?.userGroup?.indexOf(computedUserGroupManageQuery.value.searchText) > -1) &&
|
||||
(computedUserGroupManageQuery.value.deviceGroupId ?
|
||||
d.deviceGroupList.some(u => u.deviceGroupId === computedUserGroupManageQuery.value.deviceGroupId) : true)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: LPY
|
||||
* @Date: 2026-02-02 15:00:13
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2026-02-03 12:08:51
|
||||
* @LastEditTime: 2026-02-28 09:26:50
|
||||
* @FilePath: \glkvm-cloud\ui\src\stores\modules\userManage.ts
|
||||
* @Description: 用户管理相关状态管理
|
||||
*/
|
||||
@@ -69,7 +69,7 @@ export const useUserManageStore = defineStore('userManage', () => {
|
||||
const res = await reqUserList()
|
||||
pageLink.value.setTotal(res.data.items.length)
|
||||
state.userList = res.data.items.filter(d => {
|
||||
return (d?.id?.toString().toLowerCase()?.indexOf(computedUserManageQuery.value.searchText) > -1
|
||||
return (d?.description?.toString().toLowerCase()?.indexOf(computedUserManageQuery.value.searchText) > -1
|
||||
|| d?.username?.toLowerCase()?.indexOf(computedUserManageQuery.value.searchText) > -1) &&
|
||||
(computedUserManageQuery.value.userGroupId ? d.userGroupList.some(u => u.userGroupId === computedUserManageQuery.value.userGroupId) : true)
|
||||
}) || []
|
||||
|
||||
@@ -60,6 +60,16 @@ a {
|
||||
padding: 10px 12px !important;
|
||||
}
|
||||
}
|
||||
|
||||
// 增加类名,可以让列的表头左对齐(例如筛选按钮紧挨着列标题)
|
||||
.custom-table-header-cell-to-left {
|
||||
.ant-table-column-sorters {
|
||||
justify-content: flex-start !important;
|
||||
}
|
||||
.ant-table-column-title {
|
||||
flex: unset !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.text-nowrap {
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
<!--
|
||||
* @Author: LPY
|
||||
* @Date: 2026-03-09 14:27:32
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2026-03-10 11:48:15
|
||||
* @FilePath: \glkvm-cloud\ui\src\views\device\components\components\customColumns.vue
|
||||
* @Description: 自定义table列组件
|
||||
-->
|
||||
<template>
|
||||
<APopover trigger="click" placement="bottom" :arrow="false" overlayClassName="custom-columns-popper" @openChange="handleOpenChange">
|
||||
<template #content>
|
||||
<div class="top-tips">
|
||||
<BaseText type="head-m">{{ $t('device.customColumns') }}</BaseText>
|
||||
<GlSvg name="gl-icon-help" tooltip :size="20">{{ $t('device.dragColumnTips') }}</GlSvg>
|
||||
</div>
|
||||
<div class="dividing-line"></div>
|
||||
<div ref="customColumnsBoxRef" class="custom-columns-box">
|
||||
<div v-for="item in clonedColumns" :key="item.key" class="custom-columns-item" :style="{'cursor': dragging ? 'grabbing' : undefined}">
|
||||
<div class="custom-columns-item-left">
|
||||
<ACheckbox :checked="item.show" @change="handleColumnVisibilityChange(item)"></ACheckbox>
|
||||
<BaseText v-ellipsis class="title">{{ item.title }}</BaseText>
|
||||
</div>
|
||||
<div class="custom-columns-item-right">
|
||||
<GlSvg name="gl-icon-grip-dots-vertical-regular"></GlSvg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<GlSvg name="gl-icon-gear-regular" :size="20" class="custom-columns-icon"></GlSvg>
|
||||
</APopover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { GlSvg } from 'gl-web-main/components'
|
||||
import Sortable from 'sortablejs'
|
||||
import { LocalStorageKeys, useLocalStorage } from '@/hooks/useLocalStorage'
|
||||
import { TableColumnType } from 'ant-design-vue'
|
||||
|
||||
// Avoid deep type instantiation by defining only the properties you use
|
||||
interface CustomTableColumnType extends TableColumnType {
|
||||
show?: boolean
|
||||
title?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
storageName: LocalStorageKeys
|
||||
columns: CustomTableColumnType[]
|
||||
completeColumns: CustomTableColumnType[]
|
||||
}>(), {
|
||||
})
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'change', value: TableColumnType[]): void
|
||||
}>()
|
||||
|
||||
const customColumnsBoxRef = ref<HTMLDivElement>()
|
||||
|
||||
const dragging = ref(false)
|
||||
|
||||
const clonedColumns = ref<CustomTableColumnType[]>([])
|
||||
|
||||
const initDragFn = () => {
|
||||
// 注册拖拽元素
|
||||
Sortable.create(customColumnsBoxRef.value, {
|
||||
group: 'columns',
|
||||
animation: 150,
|
||||
draggable: '.custom-columns-item',
|
||||
dragClass: 'custom-columns-item-dragging',
|
||||
forceFallback: true,
|
||||
handle: '.custom-columns-item-right',
|
||||
onStart () {
|
||||
dragging.value = true
|
||||
},
|
||||
onEnd: (evt) => handleSortEnd(evt),
|
||||
})
|
||||
}
|
||||
|
||||
const handleSortEnd = ({
|
||||
oldIndex,
|
||||
newIndex,
|
||||
from,
|
||||
to,
|
||||
}) => {
|
||||
dragging.value = false
|
||||
console.log(oldIndex, newIndex, from, to)
|
||||
const movedColumn = clonedColumns.value[oldIndex]
|
||||
clonedColumns.value.splice(oldIndex, 1)
|
||||
clonedColumns.value.splice(newIndex, 0, movedColumn)
|
||||
emits('change', clonedColumns.value)
|
||||
useLocalStorage(props.storageName).setValue(clonedColumns.value)
|
||||
}
|
||||
|
||||
const handleColumnVisibilityChange = (column: CustomTableColumnType) => {
|
||||
column.show = !column.show
|
||||
// 找到对应的列并更新show属性
|
||||
emits('change', clonedColumns.value)
|
||||
useLocalStorage(props.storageName).setValue(clonedColumns.value)
|
||||
}
|
||||
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (open) {
|
||||
// 使用 nextTick 确保 DOM 已渲染
|
||||
nextTick(() => {
|
||||
if (customColumnsBoxRef.value) {
|
||||
initDragFn()
|
||||
|
||||
// 初始化数据,若有存储,则以存储为准,否则使用 completeColumns 的默认值
|
||||
const storedColumns = useLocalStorage(props.storageName).getValue() as CustomTableColumnType[] | null
|
||||
if (storedColumns) {
|
||||
clonedColumns.value = storedColumns
|
||||
} else {
|
||||
clonedColumns.value = props.completeColumns.map(col => ({
|
||||
...col,
|
||||
show: props.columns.find(c => c.key === col.key)?.show ?? true,
|
||||
}))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.top-tips {
|
||||
width: 200px;
|
||||
padding: 0 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.dividing-line {
|
||||
height: 1px;
|
||||
background-color: var(--gl-color-line-divider1);
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.custom-columns-box {
|
||||
width: 200px;
|
||||
.custom-columns-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
border-radius: 4px;
|
||||
height: 36px;
|
||||
padding: 0 8px;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--gl-color-bg-item-hover);
|
||||
}
|
||||
.custom-columns-item-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.title {
|
||||
max-width: 134px;
|
||||
padding-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-columns-item-right {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.custom-columns-icon {
|
||||
display: inline-block;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
margin-right: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.custom-columns-item-dragging {
|
||||
box-shadow: 0px 3px 14px 0px rgba(0,0,0,0.25);
|
||||
opacity: 1 !important;
|
||||
background-color: var(--gl-color-bg-surface1);
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: shufei.han
|
||||
* @Date: 2025-06-11 12:04:48
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2026-02-06 10:42:16
|
||||
* @LastEditTime: 2026-03-10 11:47:57
|
||||
* @FilePath: \glkvm-cloud\ui\src\views\device\components\deviceListView.vue
|
||||
* @Description:
|
||||
-->
|
||||
@@ -26,7 +26,13 @@
|
||||
</ASelect>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex">
|
||||
<CustomColumns
|
||||
:columns="deviceColumns"
|
||||
:completeColumns="deviceCompleteColumns"
|
||||
:storageName="LocalStorageKeys.DEVICE_LIST_COLUMNS_KEY"
|
||||
@change="handleDeviceColumnsChange"
|
||||
/>
|
||||
<BaseButton size="middle" style="margin-right: 12px;" @click="refresh">{{ $t('common.refresh') }}</BaseButton>
|
||||
<BaseButton size="middle" style="margin-right: 12px;" @click="executeCommand">{{ $t('device.executeCommand') }}</BaseButton>
|
||||
<BaseButton
|
||||
@@ -46,6 +52,7 @@
|
||||
:columns="deviceColumns"
|
||||
rowKey="id"
|
||||
:rowSelection="{ selectedRowKeys: state.selectedRowKeys, onChange: onSelectChange }"
|
||||
@change="tableChange"
|
||||
>
|
||||
<template #mac="{ record }">
|
||||
{{ macAddressFormatter(record.mac) }}
|
||||
@@ -165,14 +172,14 @@ import BasePagination from '@/components/base/basePagination.vue'
|
||||
import BaseTable from '@/components/base/baseTable.vue'
|
||||
import { t } from '@/hooks/useLanguage'
|
||||
import { useDeviceStore } from '@/stores/modules/device'
|
||||
import { message, type TableColumnType } from 'ant-design-vue'
|
||||
import { message, TableProps, type TableColumnType } from 'ant-design-vue'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import ExecuteCommandDialog from './executeCommandDialog.vue'
|
||||
import { DeviceInfo, DeviceStatusEnum, ExecuteCommandFormData } from '@/models/device'
|
||||
import CommandResponseDialog from './commandResponseDialog.vue'
|
||||
import { BaseDropdownSelect, BaseTag, GlSvg } from 'gl-web-main/components'
|
||||
import EditDescriptionDialog from './editDescriptionDialog.vue'
|
||||
import { baseCustomModal, macAddressFormatter, SelectOptions } from 'gl-web-main'
|
||||
import { baseCustomModal, deepClone, macAddressFormatter, SelectOptions } from 'gl-web-main'
|
||||
import { reqDeleteDevice, reqDeviceGroupListOptions } from '@/api/device'
|
||||
import AddDeviceDialog from './addDeviceDialog.vue'
|
||||
import MoveToDeviceGroupDialog from './moveToDeviceGroupDialog.vue'
|
||||
@@ -180,21 +187,33 @@ import { PermissionEnum } from '@/models/permission'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import AccessDeviceWebDialog from './accessDeviceWebDialog.vue'
|
||||
import dayjs from 'dayjs'
|
||||
import CustomColumns from './components/customColumns.vue'
|
||||
import { LocalStorageKeys, useLocalStorage } from '@/hooks/useLocalStorage'
|
||||
|
||||
const deviceStore = useDeviceStore()
|
||||
|
||||
const deviceColumns = computed<TableColumnType[]>(() => {
|
||||
return [
|
||||
{title: t('device.deviceID'), dataIndex: 'ddns', ellipsis: true},
|
||||
{title: t('device.IPAddress'), dataIndex: 'ip', ellipsis: true},
|
||||
{title: t('device.mac'), dataIndex: 'mac', ellipsis: true},
|
||||
{title: t('device.status'), dataIndex: 'status', ellipsis: true},
|
||||
{title: t('device.connectedTime'), dataIndex: 'connectedTime', ellipsis: true},
|
||||
{title: t('user.associatedDeviceGroup'), dataIndex: 'deviceGroupName', ellipsis: true, width: 190},
|
||||
{title: t('device.description'), dataIndex: 'description'},
|
||||
{title: t('common.action'), dataIndex: 'action', width: 270},
|
||||
]
|
||||
})
|
||||
const deviceColumns = ref<TableColumnType[]>([
|
||||
{title: t('device.deviceID'), dataIndex: 'ddns', key: 'ddns', ellipsis: true,
|
||||
sorter: true, customHeaderCell: () => {return {class: 'custom-table-header-cell-to-left'}},
|
||||
},
|
||||
{title: t('device.IPAddress'), dataIndex: 'ip', key: 'ip', ellipsis: true,
|
||||
sorter: true, customHeaderCell: () => {return {class: 'custom-table-header-cell-to-left'}},
|
||||
},
|
||||
{title: t('device.mac'), dataIndex: 'mac', key: 'mac', ellipsis: true,
|
||||
sorter: true, customHeaderCell: () => {return {class: 'custom-table-header-cell-to-left'}},
|
||||
},
|
||||
{title: t('device.status'), dataIndex: 'status', key: 'status', ellipsis: true},
|
||||
{title: t('device.connectedTime'), dataIndex: 'connectedTime', key: 'connectedTime', ellipsis: true,
|
||||
sorter: true, customHeaderCell: () => {return {class: 'custom-table-header-cell-to-left'}},
|
||||
},
|
||||
{title: t('user.associatedDeviceGroup'), dataIndex: 'deviceGroupName', key: 'deviceGroupName', ellipsis: true, width: 190},
|
||||
{title: t('device.description'), dataIndex: 'description', key: 'description',
|
||||
sorter: true, customHeaderCell: () => {return {class: 'custom-table-header-cell-to-left'}},
|
||||
},
|
||||
{title: t('common.action'), dataIndex: 'action', key: 'action', width: 270},
|
||||
])
|
||||
|
||||
const deviceCompleteColumns = deepClone(deviceColumns.value)
|
||||
|
||||
const page = computed({
|
||||
get: () => deviceStore.pageLink.page,
|
||||
@@ -228,6 +247,14 @@ const onSelectChange = (selectedRowKeys: Key[], selectedRows: DeviceInfo[]) => {
|
||||
state.selectedRows = selectedRows
|
||||
}
|
||||
|
||||
const tableChange: TableProps['onChange'] = (pagination, filters, sorter: any) => {
|
||||
console.log('params', pagination, filters, sorter)
|
||||
if (sorter) {
|
||||
deviceStore.state.sortBy = sorter.field as string
|
||||
deviceStore.state.order = sorter.order === 'ascend' ? 'asc' : 'desc'
|
||||
}
|
||||
}
|
||||
|
||||
/** 计算时间 */
|
||||
// const calculateWithDuration = (connected: number, isMilliseconds: boolean = false) => {
|
||||
// const time = isMilliseconds ? connected / 1000 : connected
|
||||
@@ -389,7 +416,35 @@ const getDeviceGroupListOptions = async () => {
|
||||
state.groupList = res.data.items
|
||||
}
|
||||
|
||||
getDeviceGroupListOptions()
|
||||
const handleDeviceColumnsChange = (columns: TableColumnType[]) => {
|
||||
deviceColumns.value = columns.filter(col => (col as any).show)
|
||||
// 兼容JSON.parse后丢失的函数等属性,重新赋值customHeaderCell属性
|
||||
deviceColumns.value.forEach((col: any) => {
|
||||
const completeCol = deviceCompleteColumns.find(c => c.key === col.key)
|
||||
if (completeCol) {
|
||||
col.customHeaderCell = completeCol.customHeaderCell
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const init = () => {
|
||||
getDeviceGroupListOptions()
|
||||
|
||||
const storedColumns = useLocalStorage(LocalStorageKeys.DEVICE_LIST_COLUMNS_KEY).getValue()
|
||||
if (storedColumns) {
|
||||
const parsedColumns = storedColumns as Array<TableColumnType & { show: boolean }>
|
||||
// 兼容JSON.parse后丢失的函数等属性,重新赋值customHeaderCell属性
|
||||
parsedColumns.forEach((col: any) => {
|
||||
const completeCol = deviceCompleteColumns.find(c => c.key === col.key)
|
||||
if (completeCol) {
|
||||
col.customHeaderCell = completeCol.customHeaderCell
|
||||
}
|
||||
})
|
||||
deviceColumns.value = parsedColumns.filter(col => (col as any).show)
|
||||
}
|
||||
}
|
||||
|
||||
init()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: LPY
|
||||
* @Date: 2025-05-30 10:48:43
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2026-02-05 17:34:48
|
||||
* @LastEditTime: 2026-02-28 09:32:36
|
||||
* @FilePath: \glkvm-cloud\ui\src\views\login\loginPage.vue
|
||||
* @Description: 登录页面
|
||||
-->
|
||||
@@ -137,6 +137,11 @@ onMounted(async () => {
|
||||
// 提取配置数据 (Extract config data)
|
||||
authConfig.value = response.data
|
||||
useAppStore().setVersion(authConfig.value.kvmCloudVersion)
|
||||
|
||||
// 若支持LDAP且当前登录方式为legacy,则切换到ldap (If LDAP is supported and current auth method is legacy, switch to ldap)
|
||||
if (authConfig.value.ldapEnabled && state.formModel.authMethod === 'legacy') {
|
||||
state.formModel.authMethod = 'ldap'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load auth config:', error)
|
||||
// 回退 - 无LDAP可用 (Fallback - no LDAP available)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* @Author: LPY
|
||||
* @Date: 2026-02-03 11:24:20
|
||||
* @LastEditors: LPY
|
||||
* @LastEditTime: 2026-02-09 09:14:59
|
||||
* @LastEditTime: 2026-02-28 09:51:05
|
||||
* @FilePath: \glkvm-cloud\ui\src\views\userManage\components\editUserDialog.vue
|
||||
* @Description: 编辑用户弹窗
|
||||
-->
|
||||
@@ -44,7 +44,12 @@
|
||||
</ARadioGroup>
|
||||
</AFormItem>
|
||||
<AFormItem name="username" :label="$t('user.userName')" labelAlign="left">
|
||||
<AInput v-model:value="state.formData.username" :maxlength="32" :placeholder="$t('device.requiredDeviceGroupName')" style="width: 100%;" />
|
||||
<AInput
|
||||
v-model:value="state.formData.username"
|
||||
:maxlength="32"
|
||||
:placeholder="$t('device.requiredDeviceGroupName')"
|
||||
:disabled="props.currentUser?.isSystem"
|
||||
style="width: 100%;" />
|
||||
</AFormItem>
|
||||
<AFormItem name="description" :label="$t('device.description')" labelAlign="left">
|
||||
<ATextarea
|
||||
@@ -58,6 +63,7 @@
|
||||
v-model:value="state.formData.password"
|
||||
:placeholder="$t('user.enterPassword')"
|
||||
autocomplete="off"
|
||||
:disabled="props.currentUser?.isSystem"
|
||||
style="width: 100%;" />
|
||||
</AFormItem>
|
||||
<AFormItem name="repassword" :label="$t('user.reEnterPassword')" labelAlign="left">
|
||||
@@ -65,6 +71,7 @@
|
||||
v-model:value="state.formData.repassword"
|
||||
:placeholder="$t('user.reEnterPasswordPlc')"
|
||||
autocomplete="off"
|
||||
:disabled="props.currentUser?.isSystem"
|
||||
style="width: 100%;" />
|
||||
</AFormItem>
|
||||
<div class="flex-end">
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"strictNullChecks": false,
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2015", "ES2017", "ES2018", "DOM", "ES2021"],
|
||||
"verbatimModuleSyntax": false,
|
||||
"baseUrl": ".",
|
||||
|
||||
+655
-627
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user