14 Commits

Author SHA1 Message Date
pengyu.lu 30a132cb4e Merge branch 'dev-ui-0129' 2026-03-10 11:56:20 +08:00
pengyu.lu 52117f044b feat: Optimize the logic for storing data on the front end 2026-03-10 11:56:03 +08:00
pengyu.lu 884f537ae8 Merge branch 'dev-ui-0129' 2026-03-10 10:46:19 +08:00
pengyu.lu 807bab3e2a fix: Fix the issue where the version number is displayed with double quotes 2026-03-10 10:45:56 +08:00
GL.iNet-Yongping.Xie a1823096cc Merge branch 'feature/devicetable-order' 2026-03-09 19:22:38 -07:00
GL.iNet-Yongping.Xie b1aa8e3cae feat: bump version to v2.3.0
- Update project version to v2.3.0.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-03-09 19:20:30 -07:00
pengyu.lu 3d75a87140 Merge branch 'dev-ui-0129' 2026-03-10 10:17:54 +08:00
pengyu.lu d237847f0d feat: Some UI issues have been optimized to enhance user experience 2026-03-10 10:17:26 +08:00
GL.iNet-Yongping.Xie 59021535b7 feat: add sorting support for the device list
Implemented multi-column sorting (ascending and descending) for the device list, supporting ID, IP, MAC, connectedTime, description, and DDNS.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-03-08 19:37:01 -07:00
GL.iNet-Yongping.Xie 133f344713 Merge branch 'bugfix/v2.1.0' 2026-02-27 18:28:24 -08:00
GL.iNet-Yongping.Xie 7fd9c39ffb fix: resolve issues in v2.1.0
Fix reported bugs and stability issues in v2.1.0.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-02-27 18:27:43 -08:00
pengyu.lu 96936ecdd8 Merge branch 'dev-ui-0129' 2026-02-28 10:10:40 +08:00
pengyu.lu a44c9a4833 fix: Fix some UI issues 2026-02-28 10:10:11 +08:00
GL.iNet-Yongping.Xie e6da02f711 feat: allow customizing system admin username
Add support to change the default system admin login name via config file.

Signed-off-by: GL.iNet-Yongping.Xie <yongping.xie@gl-inet.com>
2026-02-25 18:23:43 -08:00
31 changed files with 1106 additions and 680 deletions
+1
View File
@@ -3,6 +3,7 @@
"ddns",
"glkvm",
"repassword",
"sortablejs",
"webrtc"
]
}
+3
View File
@@ -71,6 +71,9 @@ GLKVM_ACCESS_IP=
# rttys
RTTYS_TOKEN=DeviceTokenYouCanChangeMe
RTTYS_PASS=StrongP@ssw0rd
# Admin username (leave empty to default to "admin")
# Only letters and digits are allowed (e.g. admin, Admin01). No spaces or special characters.
RTTYS_ADMIN_NAME=
RTTYS_DEVICE_PORT=5912
RTTYS_WEBUI_PORT=443
RTTYS_HTTP_PROXY_PORT=10443
+3
View File
@@ -70,6 +70,9 @@ GLKVM_ACCESS_IP=
# rttys
RTTYS_TOKEN=DeviceTokenYouCanChangeMe
RTTYS_PASS=StrongP@ssw0rd
# Admin username (leave empty to default to "admin")
# Only letters and digits are allowed (e.g. admin, Admin01). No spaces or special characters.
RTTYS_ADMIN_NAME=
RTTYS_DEVICE_PORT=5912
RTTYS_WEBUI_PORT=443
RTTYS_HTTP_PROXY_PORT=10443
+1
View File
@@ -12,6 +12,7 @@ services:
# ---- rttys ----
RTTYS_TOKEN: ${RTTYS_TOKEN:-DeviceTokenYouCanChangeMe}
RTTYS_PASS: ${RTTYS_PASS:-StrongP@ssw0rd}
RTTYS_ADMIN_NAME: ${RTTYS_ADMIN_NAME:-}
# Ports inside container (mirrored to host via `ports` below)
RTTYS_DEVICE_PORT: ${RTTYS_DEVICE_PORT:-5912} # addr-dev
+1 -1
View File
@@ -60,7 +60,7 @@ case "$1" in
: "${TURN_PORT:=3478}"
render /tpl/rttys.conf.tmpl /home/rttys.conf \
RTTYS_TOKEN RTTYS_PASS \
RTTYS_TOKEN RTTYS_PASS RTTYS_ADMIN_NAME \
GLKVM_ACCESS_IP TURN_PORT TURN_USER TURN_PASS \
RTTYS_DEVICE_PORT RTTYS_WEBUI_PORT RTTYS_HTTP_PROXY_PORT \
LDAP_ENABLED LDAP_SERVER LDAP_PORT LDAP_USE_TLS \
@@ -4,6 +4,9 @@ token: {{RTTYS_TOKEN}}
# Web management password
password: {{RTTYS_PASS}}
# Admin username (leave empty to default to "admin")
admin-name: {{RTTYS_ADMIN_NAME}}
# WebRTC
webrtc-ip: {{GLKVM_ACCESS_IP}}
webrtc-port: {{TURN_PORT}}
+55 -1
View File
@@ -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))
+12
View File
@@ -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))
+19 -5
View File
@@ -80,7 +80,7 @@ func InitAppContainer(r *gin.Engine) (*AppContainer, error) {
if err := sqlite.InitSchema(ctx, appDB.SQL(), "/home/database/schema.sql"); err != nil {
log.Fatal().Err(err).Msg("init schema failed")
}
if err := ensureAdminUser(ctx, appDB.Gorm(), cfg.Password); err != nil {
if err := ensureAdminUser(ctx, appDB.Gorm(), cfg.AdminName, cfg.Password); err != nil {
log.Fatal().Err(err).Msg("ensure admin user failed")
}
@@ -117,7 +117,7 @@ func InitAppContainer(r *gin.Engine) (*AppContainer, error) {
return c, nil
}
func ensureAdminUser(ctx context.Context, db *gorm.DB, plainPassword string) error {
func ensureAdminUser(ctx context.Context, db *gorm.DB, adminName, plainPassword string) error {
if db == nil {
return fmt.Errorf("db is nil")
}
@@ -126,15 +126,29 @@ func ensureAdminUser(ctx context.Context, db *gorm.DB, plainPassword string) err
if err != nil {
return err
}
// First, rename the existing system admin user to the configured name (if changed).
// This handles the case where the admin username was previously "admin" (or another name)
// and the user now wants a different username via RTTYS_ADMIN_NAME.
if err := db.WithContext(ctx).Exec(
`UPDATE users SET username = ? WHERE is_system = 1 AND role = 'admin' AND username != ?`,
adminName, adminName,
).Error; err != nil {
return fmt.Errorf("rename system admin user: %w", err)
}
// 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', ?, '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`,
hash,
is_system=1,
description=CASE WHEN (description IS NULL OR description = '') THEN 'System Administrator' ELSE description END`,
adminName, hash,
).Error
}
+3 -3
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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
+13 -5
View File
@@ -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 = () => {
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -124,7 +124,9 @@
"ipNotCorrect": "IP 地址错误",
"portNotCorrect": "端口号错误",
"remoteWeb": "远程 Web",
"linuxTips": "支持 OpenWrt、树莓派、Ubuntu、CentOS 等"
"linuxTips": "支持 OpenWrt、树莓派、Ubuntu、CentOS 等",
"customColumns": "自定义列",
"dragColumnTips": "您可以自定义您的设备列表,拖动下列项右侧的按钮来调整显示顺序,也可以通过复选框来控制显示或隐藏某些列。"
},
"user": {
"user": "用户",
+2
View File
@@ -26,6 +26,8 @@ export interface DeviceQuery {
searchText: string
deviceGroupId: number
onlyShowUnassigned: boolean
sortBy?: string
order?: 'asc' | 'desc'
}
/** 执行命令参数 */
+3 -1
View File
@@ -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)
}
+1 -1
View File
@@ -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相关状态存储
*/
+15 -3
View File
@@ -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 -3
View File
@@ -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 -2
View File
@@ -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 -2
View File
@@ -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)
}) || []
+10
View File
@@ -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>
+6 -1
View File
@@ -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">
+1
View File
@@ -14,6 +14,7 @@
"strictNullChecks": false,
"composite": true,
"noEmit": true,
"moduleResolution": "bundler",
"lib": ["ES2015", "ES2017", "ES2018", "DOM", "ES2021"],
"verbatimModuleSyntax": false,
"baseUrl": ".",
+655 -627
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -27,6 +27,7 @@ package xconfig
import (
"fmt"
"os"
"regexp"
"strconv"
"strings"
"time"
@@ -45,6 +46,7 @@ type Config struct {
UserHookUrl string
LocalAuth bool
Password string
AdminName string
AllowOrigins bool
PprofAddr string
AuthSessionTTL time.Duration
@@ -168,6 +170,7 @@ func parseYamlCfg(cfg *Config, conf string) error {
getConfigOpt(yamlCfg, "user-hook-url", &cfg.UserHookUrl)
getConfigOpt(yamlCfg, "local-auth", &cfg.LocalAuth)
getConfigOpt(yamlCfg, "password", &cfg.Password)
getConfigOpt(yamlCfg, "admin-name", &cfg.AdminName)
getConfigOpt(yamlCfg, "allow-origins", &cfg.AllowOrigins)
if err := getDurationOpt(yamlCfg, "auth-session-ttl", &cfg.AuthSessionTTL); err != nil {
@@ -237,6 +240,16 @@ func parseYamlCfg(cfg *Config, conf string) error {
}
func applyEnvCfg(cfg *Config) error {
if v := strings.TrimSpace(os.Getenv("RTTYS_ADMIN_NAME")); v != "" {
cfg.AdminName = v
}
if cfg.AdminName == "" {
cfg.AdminName = "admin"
}
if !regexp.MustCompile(`^[a-zA-Z0-9]+$`).MatchString(cfg.AdminName) {
return fmt.Errorf("invalid RTTYS_ADMIN_NAME %q: only letters and digits are allowed", cfg.AdminName)
}
if v := strings.TrimSpace(os.Getenv("RTTYS_LOG")); v != "" {
cfg.LogPath = v
}