mirror of
https://github.com/gl-inet/glkvm-cloud.git
synced 2026-10-04 20:54:47 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 30a132cb4e | |||
| 52117f044b | |||
| 884f537ae8 | |||
| 807bab3e2a | |||
| a1823096cc | |||
| b1aa8e3cae | |||
| 3d75a87140 | |||
| d237847f0d | |||
| 59021535b7 | |||
| 133f344713 | |||
| 7fd9c39ffb | |||
| 96936ecdd8 | |||
| a44c9a4833 | |||
| e6da02f711 |
Vendored
+1
@@ -3,6 +3,7 @@
|
||||
"ddns",
|
||||
"glkvm",
|
||||
"repassword",
|
||||
"sortablejs",
|
||||
"webrtc"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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))
|
||||
|
||||
+19
-5
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user