From ae5b44e4294da2c03b81bd9c74eb45cbf9568d54 Mon Sep 17 00:00:00 2001 From: 794308525 <794308525@qq.com> Date: Tue, 11 Aug 2026 17:26:40 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E5=8D=A1=E7=89=87=E8=87=AA=E5=AE=9A=E4=B9=89=E6=8E=92?= =?UTF-8?q?=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 增加默认排序、最新添加和最早添加三种切换方式。 支持通过拖柄调整面板卡片顺序,并将默认顺序持久化保存。 增加排序字段自动迁移及后端批量保存接口。 优化拖拽换位动画,减少卡片挤位时的闪动。 --- electron/class/sqlite.js | 4 +- electron/controller/panel.js | 27 ++- frontend/electron.route.js | 19 ++ frontend/src/views/panel/controller/index.ts | 7 + frontend/src/views/panel/index.vue | 223 +++++++++++++++++-- 5 files changed, 253 insertions(+), 27 deletions(-) diff --git a/electron/class/sqlite.js b/electron/class/sqlite.js index b1cfac6..1414f89 100644 --- a/electron/class/sqlite.js +++ b/electron/class/sqlite.js @@ -55,6 +55,7 @@ class Sqlite { this.checkField('ssh_info', 'os_name', 'TEXT', '"Linux"') this.checkField('ssh_info', 'mstsc_options', 'TEXT', '"{}"') this.checkField('ssh_info', 'sort', 'INTEGER', '0') + this.checkField('panel_info', 'sort', 'INTEGER', '0') this.checkField('panel_info', 'ov', 'INTEGER', '-1') this.checkField('panel_info', 'server_id', 'TEXT', '""') this.checkField('panel_info', 'current_disk', 'TEXT', '""') @@ -253,6 +254,7 @@ class Sqlite { \`server_id\` TEXT DEFAULT "", -- server_id \`proxy_id\` INTEGER DEFAULT 0, -- 代理ID \`common_use\` INTEGER DEFAULT 0, -- 常用显示状态 1=显示 0=隐藏 + \`sort\` INTEGER DEFAULT 0, -- 排序值 \`area\` TEXT DEFAULT "" -- 服务器归属区域 )`; @@ -1210,4 +1212,4 @@ class Sqlite { } } -module.exports = { Sqlite } \ No newline at end of file +module.exports = { Sqlite } diff --git a/electron/controller/panel.js b/electron/controller/panel.js index 6e67da7..208d98b 100644 --- a/electron/controller/panel.js +++ b/electron/controller/panel.js @@ -243,7 +243,7 @@ class PanelController extends Controller { } // 获取面板列表 - result.data = pub.M(this.TABLE).where(where, params).order('panel_id DESC').select(); + result.data = pub.M(this.TABLE).where(where, params).order('sort DESC, panel_id DESC').select(); global.PanelList = result.data; for (let i = 0; i < result.data.length; i++) { @@ -278,6 +278,29 @@ class PanelController extends Controller { pub.M(this.TABLE).where('panel_id=?', panel_id).update({current_disk: disk_path}); } + /** + * @name 保存面板默认排序 + * @param {object} args { + * panel_ids: number[] - 按展示顺序排列的面板ID + * } + */ + async set_sort(args, event) { + const panel_ids = Array.isArray(args.data.panel_ids) ? args.data.panel_ids : []; + if (panel_ids.length === 0) return; + + const panel_id_set = new Set(panel_ids); + const current_list = pub.M(this.TABLE).order('sort DESC, panel_id DESC').select(); + const ordered_ids = [...panel_ids]; + const merged_ids = current_list.map(panel => { + return panel_id_set.has(panel.panel_id) ? ordered_ids.shift() : panel.panel_id; + }); + const total = merged_ids.length; + + merged_ids.forEach((panel_id, index) => { + pub.M(this.TABLE).where('panel_id=?', panel_id).update({ sort: total - index }); + }); + } + /** * @name 添加面板分组 * @param {object} args { @@ -1246,4 +1269,4 @@ class PanelController extends Controller { } PanelController.toString = () => '[class PanelController]'; -module.exports = PanelController; \ No newline at end of file +module.exports = PanelController; diff --git a/frontend/electron.route.js b/frontend/electron.route.js index da655ae..fcce067 100644 --- a/frontend/electron.route.js +++ b/frontend/electron.route.js @@ -57,6 +57,25 @@ const routes = { }, }, }, + set_sort: { + title: '保存面板默认排序', + method: 'ipc', + path: 'controller.panel.set_sort', + args: { + channel: { + type: 'string', + required: true, + description: '通道标识', + }, + data: { + panel_ids: { + type: 'array', + required: true, + description: '按展示顺序排列的面板ID', + }, + }, + }, + }, record_disk: { title: '记录面板选中的磁盘', method: 'ipc', diff --git a/frontend/src/views/panel/controller/index.ts b/frontend/src/views/panel/controller/index.ts index b7476dc..0fec7ee 100644 --- a/frontend/src/views/panel/controller/index.ts +++ b/frontend/src/views/panel/controller/index.ts @@ -43,6 +43,13 @@ export const record_disk = (parent: { panel_id: number; disk_path: string }) => common.send(routes.panel.record_disk.path, parent) } +/** + * @description 保存面板默认展示顺序 + */ +export const set_panel_sort = (panel_ids: number[]) => { + common.send(routes.panel.set_sort.path, { panel_ids }) +} + /** * @description 获取面板安装脚本列表 */ diff --git a/frontend/src/views/panel/index.vue b/frontend/src/views/panel/index.vue index 51ed8a1..a294ff4 100644 --- a/frontend/src/views/panel/index.vue +++ b/frontend/src/views/panel/index.vue @@ -8,6 +8,13 @@ }} {{ pub.lang('导入') }} {{ pub.lang('导出') }} + + +
+ +
@@ -246,7 +272,7 @@ import installResults from './components/AddPanel/installResults.vue' import AddGroup from '@views/panel/components/AddGroup/index.vue' import White from '@/assets/images/logo-white.svg' import { pub, getByteUnit } from '@utils/tools' -import { record_disk, type Panel_Params } from './controller' +import { record_disk, set_panel_sort, type Panel_Params } from './controller' import { checkIp } from '@utils/is' import { common, routes, ipc } from '@api/http' @@ -271,6 +297,22 @@ const isActive = ref(false) const searchServic = ref('') const firstLoad = ref(true) const allPanelList = ref([]) as any +const sortMode = ref<'default' | 'latest' | 'earliest'>('default') +const sortOptions = [ + { label: pub.lang('默认排序'), value: 'default' }, + { label: pub.lang('最新添加'), value: 'latest' }, + { label: pub.lang('最早添加'), value: 'earliest' }, +] +const draggedPanelID = ref(null) +let dragOrderChanged = false +let dragMoveFrame: number | null = null +let pendingDragMove: { + targetID: number + clientX: number + clientY: number + targetElement: HTMLElement +} | null = null +let lastPanelMove = { from: -1, to: -1, time: 0 } const authType = [ { name: pub.lang('免费版'), bg: 'bg-[#e7e7e7]', text: 'text-[#909399]' }, @@ -298,17 +340,126 @@ const isShow = computed(() => { return status }) +const canDragSort = computed(() => sortMode.value === 'default' && searchServic.value === '') +const dragHandleTitle = computed(() => { + if (sortMode.value !== 'default') return pub.lang('切换到默认排序后可拖动') + if (searchServic.value !== '') return pub.lang('清空搜索后可拖动') + return pub.lang('拖动排序') +}) + // Computed property for showListArray const showListArray = computed(() => { + let list = allPanelList.value if (searchServic.value !== '') { - return allPanelList.value.filter( + list = allPanelList.value.filter( (item: any) => item.title.includes(searchServic.value) || item.url.includes(searchServic.value) ) } - return allPanelList.value + if (sortMode.value === 'latest') { + return [...list].sort((a: any, b: any) => Number(b.addtime) - Number(a.addtime)) + } + if (sortMode.value === 'earliest') { + return [...list].sort((a: any, b: any) => Number(a.addtime) - Number(b.addtime)) + } + return list }) +const startPanelDrag = (index: number, event: DragEvent) => { + if (!canDragSort.value) { + event.preventDefault() + return + } + draggedPanelID.value = showListArray.value[index].panel_id + dragOrderChanged = false + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = 'move' + event.dataTransfer.setData('text/plain', String(draggedPanelID.value)) + const handle = event.currentTarget as HTMLElement + const card = handle.closest('.panel-card-col') as HTMLElement | null + if (card) { + const rect = card.getBoundingClientRect() + event.dataTransfer.setDragImage( + card, + Math.max(0, event.clientX - rect.left), + Math.max(0, event.clientY - rect.top) + ) + } + } +} + +const queuePanelMove = (targetID: number, event: DragEvent) => { + if (!canDragSort.value || draggedPanelID.value === null) return + pendingDragMove = { + targetID, + clientX: event.clientX, + clientY: event.clientY, + targetElement: event.currentTarget as HTMLElement, + } + if (dragMoveFrame !== null) return + dragMoveFrame = requestAnimationFrame(() => { + const move = pendingDragMove + dragMoveFrame = null + pendingDragMove = null + if (move) movePanel(move) + }) +} + +const movePanel = ({ targetID, clientX, clientY, targetElement }: NonNullable) => { + if (!canDragSort.value || draggedPanelID.value === null || targetID === draggedPanelID.value) return + const currentIndex = allPanelList.value.findIndex( + (item: any) => item.panel_id === draggedPanelID.value + ) + const targetIndex = allPanelList.value.findIndex((item: any) => item.panel_id === targetID) + if (currentIndex < 0 || targetIndex < 0 || currentIndex === targetIndex) return + + const now = performance.now() + if ( + now - lastPanelMove.time < 180 && + currentIndex === lastPanelMove.to && + targetIndex === lastPanelMove.from + ) { + return + } + + const targetRect = targetElement.getBoundingClientRect() + const draggedElement = document.querySelector('.panel-card-col--dragging') as HTMLElement | null + const draggedRect = draggedElement?.getBoundingClientRect() + const isSameRow = draggedRect + ? Math.abs(targetRect.top - draggedRect.top) < Math.min(targetRect.height, draggedRect.height) / 2 + : true + const pointerPosition = isSameRow ? clientX : clientY + const targetMiddle = isSameRow + ? targetRect.left + targetRect.width / 2 + : targetRect.top + targetRect.height / 2 + const isMovingForward = currentIndex < targetIndex + + if ((isMovingForward && pointerPosition < targetMiddle) || (!isMovingForward && pointerPosition > targetMiddle)) { + return + } + + const [draggedPanel] = allPanelList.value.splice(currentIndex, 1) + allPanelList.value.splice(targetIndex, 0, draggedPanel) + dragOrderChanged = true + lastPanelMove = { from: currentIndex, to: targetIndex, time: now } +} + +const finishPanelDrag = () => { + const finalMove = pendingDragMove + if (dragMoveFrame !== null) { + cancelAnimationFrame(dragMoveFrame) + dragMoveFrame = null + } + pendingDragMove = null + if (finalMove) movePanel(finalMove) + if (dragOrderChanged) { + set_panel_sort(allPanelList.value.map((item: any) => item.panel_id)) + } + dragOrderChanged = false + lastPanelMove = { from: -1, to: -1, time: 0 } + draggedPanelID.value = null +} + // 获取链接 const getUrlLink = (url: string) => { if (!url) return '' @@ -685,6 +836,30 @@ onUnmounted(() => { font-size: 1.6rem; } } +.panel-card-col { + transition: opacity 0.15s ease; + + &--dragging { + opacity: 0.25; + } +} +.panel-card-move { + transition: transform 0.2s ease; +} +.panel-drag-handle { + display: inline-flex; + align-items: center; + cursor: grab; + + &:active { + cursor: grabbing; + } + + &--disabled { + cursor: not-allowed; + opacity: 0.45; + } +} .disk-card-option { height: 2rem; line-height: 2rem; From 4964b04d54e7ac36b3f85fbfd12f8713e057f02f Mon Sep 17 00:00:00 2001 From: 794308525 <794308525@qq.com> Date: Tue, 11 Aug 2026 18:07:05 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E9=A1=B6=E9=83=A8=E5=B7=A5=E5=85=B7=E6=A0=8F=E4=B8=8E?= =?UTF-8?q?=E5=88=86=E7=BB=84=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重构顶部品牌与操作区域的视觉层级,统一分组、排序控件为带平滑选中背景的胶囊样式,并兼容深色模式。\n\n为分组列表补充面板数量统计,在分组管理中增加默认开启且可持久化的数量显示开关。 --- electron/controller/panel.js | 14 + frontend/src/store/panel/index.ts | 4 +- frontend/src/types/base.d.ts | 1 + .../panel/components/GroupManage/index.vue | 33 +- frontend/src/views/panel/index.vue | 323 +++++++++++++++--- 5 files changed, 331 insertions(+), 44 deletions(-) diff --git a/electron/controller/panel.js b/electron/controller/panel.js index 208d98b..41ac315 100644 --- a/electron/controller/panel.js +++ b/electron/controller/panel.js @@ -219,6 +219,20 @@ class PanelController extends Controller { result.groups = result.groups.concat(groups); } + // 统计各分组面板数量,全部分组展示所有面板总数 + const panel_counts = pub.M(this.TABLE).field('group_id, COUNT(*) AS panel_count').group('group_id').select(); + const group_count_map = {}; + let panel_total = 0; + panel_counts.forEach(item => { + const count = Number(item.panel_count) || 0; + group_count_map[item.group_id] = count; + panel_total += count; + }); + result.groups = result.groups.map(group => ({ + ...group, + panel_count: group.group_id === -1 ? panel_total : (group_count_map[group.group_id] || 0) + })); + // 检查分组是否存在 if (group_id !== undefined && group_id != -1){ let is_group_exists = false; diff --git a/frontend/src/store/panel/index.ts b/frontend/src/store/panel/index.ts index 1b4c200..539086c 100644 --- a/frontend/src/store/panel/index.ts +++ b/frontend/src/store/panel/index.ts @@ -34,6 +34,7 @@ export const usePanelBase = defineStore( const groupManageVisible = ref(false) // 分组管理弹窗 const isShowIP = ref(true) // 是否显示IP + const showGroupCount = ref(true) // 是否显示分组数量 const addPanelVisible = ref(false) // 添加面板弹窗 const isEdit = ref(false) // 是否编辑模式 const panelParams = ref() @@ -54,6 +55,7 @@ export const usePanelBase = defineStore( editGroupParams, groupManageVisible, isShowIP, + showGroupCount, addPanelVisible, isEdit, panelParams, @@ -64,7 +66,7 @@ export const usePanelBase = defineStore( }, { persist: { - paths: ['isShowIP', 'currentGroupID'], + paths: ['isShowIP', 'showGroupCount', 'currentGroupID'], }, } ) diff --git a/frontend/src/types/base.d.ts b/frontend/src/types/base.d.ts index 35b273f..68afe2b 100644 --- a/frontend/src/types/base.d.ts +++ b/frontend/src/types/base.d.ts @@ -14,6 +14,7 @@ declare type proxyOptions = { declare type groupOptions = { group_id: number group_name: string + panel_count?: number } /** * @description 下拉框格式 diff --git a/frontend/src/views/panel/components/GroupManage/index.vue b/frontend/src/views/panel/components/GroupManage/index.vue index 4ec753c..ca0d74d 100644 --- a/frontend/src/views/panel/components/GroupManage/index.vue +++ b/frontend/src/views/panel/components/GroupManage/index.vue @@ -12,6 +12,15 @@ v-html="pub.lang('分组管理')"> +
+
+
{{ pub.lang('显示分组数量') }}
+
+ {{ pub.lang('在顶部的分组选项中显示面板数量') }} +
+
+ +
{ } }) + diff --git a/frontend/src/views/panel/index.vue b/frontend/src/views/panel/index.vue index a294ff4..4c71e30 100644 --- a/frontend/src/views/panel/index.vue +++ b/frontend/src/views/panel/index.vue @@ -1,25 +1,84 @@ + + {{ pub.lang('演示') }} - +
@@ -311,7 +334,7 @@ defineOptions({ name: 'Home', }) import { usePanelBase } from '@store/panel' -import { Setting, Refresh, Hide, View, TopRight } from '@element-plus/icons-vue' +import { Setting, Refresh, Hide, View, TopRight, CopyDocument } from '@element-plus/icons-vue' import { useSettingStore } from '@store/setting' import { ElMessageBox } from 'element-plus' import { useMessage } from '@utils/hooks/message' @@ -323,7 +346,7 @@ import installResults from './components/AddPanel/installResults.vue' import AddGroup from '@views/panel/components/AddGroup/index.vue' import White from '@/assets/images/logo-white.svg' import LogoGreen from '@/assets/images/logo-green.svg' -import { pub, getByteUnit } from '@utils/tools' +import { pub, getByteUnit, copyText } from '@utils/tools' import { record_disk, set_panel_sort, type Panel_Params } from './controller' import { checkIp } from '@utils/is' @@ -350,7 +373,8 @@ const isActive = ref(false) const searchServic = ref('') const firstLoad = ref(true) const allPanelList = ref([]) as any -const sortMode = ref<'default' | 'latest' | 'earliest'>('default') +type SortMode = 'default' | 'latest' | 'earliest' +const sortMode = ref('default') const sortOptions = [ { label: pub.lang('默认排序'), value: 'default' }, { label: pub.lang('最新添加'), value: 'latest' }, @@ -358,8 +382,12 @@ const sortOptions = [ ] const groupSegmentRef = ref(null) const sortSegmentRef = ref(null) +const panelContentRef = ref(null) const groupIndicatorStyle = ref>({ opacity: '0', width: '0px' }) const sortIndicatorStyle = ref>({ opacity: '0', width: '0px' }) +let sortAnimations: Animation[] = [] +let sortSwitchToken = 0 +let dragAnimations: Animation[] = [] const updateSegmentIndicator = async ( segmentRef: typeof groupSegmentRef, @@ -421,7 +449,10 @@ const getDeviceStatusText = (item: any) => { } const getDeviceStatusClass = (item: any) => { if (item.device_status === 'online' && item.panelInfo.isError) return 'warning' - return item.device_status || 'unknown' + if (item.device_status === 'online') return 'online' + if (item.device_status === 'offline' || item.panelInfo.isError) return 'offline' + if (!item.device_status || item.device_status === 'unknown') return 'checking' + return 'unknown' } const isShow = computed(() => { @@ -434,7 +465,9 @@ const isShow = computed(() => { return status }) -const canDragSort = computed(() => sortMode.value === 'default' && searchServic.value === '') +const canDragSort = computed( + () => sortMode.value === 'default' && searchServic.value === '' +) const dragHandleTitle = computed(() => { if (sortMode.value !== 'default') return pub.lang('切换到默认排序后可拖动') if (searchServic.value !== '') return pub.lang('清空搜索后可拖动') @@ -458,6 +491,144 @@ const showListArray = computed(() => { } return list }) +const panelByID = computed( + () => new Map(allPanelList.value.map((item: any) => [item.panel_id, item])) +) + +const clearSortAnimations = () => { + sortAnimations.forEach(animation => animation.cancel()) + sortAnimations = [] +} + +const createSortedCardAnimations = () => { + const container = panelContentRef.value + if (!container) return [] + + const containerRect = container.getBoundingClientRect() + const animations: Animation[] = [] + const rows: HTMLElement[][] = [] + let lastRowTop: number | null = null + + container.querySelectorAll('.panel-card-group > .panel-card-col').forEach(card => { + const rect = card.getBoundingClientRect() + if ( + rect.bottom <= containerRect.top || + rect.top >= containerRect.bottom || + rect.right <= containerRect.left || + rect.left >= containerRect.right + ) { + return + } + if (lastRowTop === null || Math.abs(rect.top - lastRowTop) > 2) { + rows.push([]) + lastRowTop = rect.top + } + rows[rows.length - 1].push(card) + }) + + let rowStartDelay = 0 + rows.forEach(row => { + row.forEach((card, columnIndex) => { + animations.push( + card.animate( + [ + { opacity: 0, transform: 'translate3d(0, 6px, 0)' }, + { opacity: 1, transform: 'translate3d(0, 0, 0)' }, + ], + { + duration: 180, + delay: rowStartDelay + columnIndex * 28, + easing: 'cubic-bezier(0.22, 1, 0.36, 1)', + fill: 'both', + } + ) + ) + }) + rowStartDelay += Math.max(0, row.length - 1) * 28 + 60 + }) + + return animations +} + +const changeSortMode = async (value: unknown) => { + if (value !== 'default' && value !== 'latest' && value !== 'earliest') return + if (value === sortMode.value) return + + const token = ++sortSwitchToken + clearSortAnimations() + + const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches + const cardList = panelContentRef.value?.querySelector('.cardList') + if (!cardList || typeof cardList.animate !== 'function' || reduceMotion) { + sortMode.value = value + return + } + + const hideAnimation = cardList.animate([{ opacity: 1 }, { opacity: 0 }], { + duration: 70, + easing: 'ease-out', + fill: 'forwards', + }) + sortAnimations = [hideAnimation] + try { + await hideAnimation.finished + } catch { + return + } + if (token !== sortSwitchToken) return + + sortMode.value = value + await nextTick() + if (token !== sortSwitchToken) return + + const enterAnimations = createSortedCardAnimations() + hideAnimation.cancel() + sortAnimations = enterAnimations + await Promise.allSettled(sortAnimations.map(animation => animation.finished)) + if (token === sortSwitchToken) { + clearSortAnimations() + } +} + +const getVisiblePanelRects = () => { + const container = panelContentRef.value + const positions = new Map() + if (!container) return positions + + const containerRect = container.getBoundingClientRect() + container.querySelectorAll('.panel-card-group > .panel-card-col').forEach(card => { + const rect = card.getBoundingClientRect() + if (rect.bottom > containerRect.top && rect.top < containerRect.bottom) { + positions.set(card.dataset.panelId || '', rect) + } + }) + return positions +} + +const animatePanelDrag = async (previousPositions: Map) => { + await nextTick() + dragAnimations.forEach(animation => animation.cancel()) + dragAnimations = [] + panelContentRef.value + ?.querySelectorAll('.panel-card-group > .panel-card-col') + .forEach(card => { + const previousRect = previousPositions.get(card.dataset.panelId || '') + if (!previousRect) return + const currentRect = card.getBoundingClientRect() + const offsetX = previousRect.left - currentRect.left + const offsetY = previousRect.top - currentRect.top + if (!offsetX && !offsetY) return + dragAnimations.push( + card.animate( + [ + { transform: `translate3d(${offsetX}px, ${offsetY}px, 0)` }, + { transform: 'translate3d(0, 0, 0)' }, + ], + { duration: 160, easing: 'cubic-bezier(0.2, 0, 0, 1)' } + ) + ) + }) +} const startPanelDrag = (index: number, event: DragEvent) => { if (!canDragSort.value) { @@ -532,8 +703,10 @@ const movePanel = ({ targetID, clientX, clientY, targetElement }: NonNullable { const reg = /(http|https):\/\/([\w.]+\/?)\S*/ return url.replace(reg, '$2') } +const copyPanelAddress = (url: string) => { + copyText({ value: getUrlLink(url), success: pub.lang('IP复制成功') }) +} // 设置磁盘路径 const onChangeDiskPath = (val: any, item: any) => { + if (item.is_demo) return record_disk({ panel_id: item.panel_id, disk_path: val }) } @@ -590,6 +767,7 @@ const editPanelInfo = (item: Panel_Params) => { } // 打开面板 const openPanelView = (item: any, ev?: any) => { + if (item.is_demo) return if (ev) { const targetName = ev.target.localName const isButtonOrIcon = targetName === 'button' || targetName === 'path' || targetName === 'svg' @@ -627,6 +805,73 @@ const removePanel = (item: any) => { }) } const isRquest = ref(false) +const createDemoPanels = (count: number) => { + const now = Math.floor(Date.now() / 1000) + const areas = ['华东', '华南', '华北', '西南'] + + return Array.from({ length: count }, (_, index) => { + const sequence = index + 1 + const hasError = sequence % 17 === 0 + const cpuPercent = 8 + ((sequence * 13) % 83) + const memoryTotal = 8192 + (sequence % 3) * 8192 + const memoryUsed = Math.round(memoryTotal * (0.22 + ((sequence * 7) % 55) / 100)) + const diskPercent = 18 + ((sequence * 11) % 73) + const diskTotal = 200 + (sequence % 5) * 100 + const diskUsed = Math.round((diskTotal * diskPercent) / 100) + const ageIndex = (index * 37) % count + + return { + panel_id: -sequence, + group_id: 0, + title: `[演示] ${areas[index % areas.length]}节点 ${String(sequence).padStart(3, '0')}`, + url: `http://demo-${String(sequence).padStart(3, '0')}.local:8888`, + auth_type: 1, + addtime: now - ageIndex * 3600, + status: hasError ? 1 : 0, + ov: index % 3, + proxy_id: 0, + common_use: 0, + sort: count - index, + area: areas[index % areas.length], + current_disk: '/', + device_status: hasError ? 'offline' : sequence % 11 === 0 ? 'unknown' : 'online', + panel_status: hasError ? 'error' : 'online', + is_open: !hasError, + is_demo: true, + panelInfo: hasError + ? { + load: { one: 0, five: 0, fifteen: 0 }, + cpu: [0, 0, 0, 0, 0, 0], + mem: { memRealUsed: 0, memTotal: 0 }, + up: 0, + down: 0, + disk: [], + isError: true, + errorMsg: pub.lang('演示设备暂时不可达'), + } + : { + load: { + one: cpuPercent / 20, + five: cpuPercent / 24, + fifteen: cpuPercent / 28, + }, + cpu: [cpuPercent, 2 + (sequence % 8), 0, 0, 0, 0], + mem: { memRealUsed: memoryUsed, memTotal: memoryTotal }, + up: 1024 * 1024 * (1 + (sequence % 8)), + down: 1024 * 1024 * (5 + (sequence % 20)), + disk: [ + { + path: '/', + size: [`${diskTotal} GB`, `${diskUsed} GB`, '', `${diskPercent}%`], + }, + ], + isError: false, + errorMsg: '', + }, + } + }) +} + const getPanelList = async (Gid?: number) => { isRquest.value = false try { @@ -635,7 +880,26 @@ const getPanelList = async (Gid?: number) => { route: routes.panel.list.path, data: { limit: 9999, group_id: currentGroupID.value }, }) - allPanelList.value = res.data.data.map((item: any) => { + const demoPanelRows = import.meta.env.DEV + ? res.data.data.filter((item: any) => item.title?.startsWith('[演示]')) + : [] + const demoPanelIndex = new Map( + demoPanelRows.map((item: any, index: number) => [item.panel_id, index]) + ) + const demoPanelPresets = createDemoPanels(demoPanelRows.length) + const nextPanelList = res.data.data.map((item: any) => { + const demoIndex = demoPanelIndex.get(item.panel_id) + if (typeof demoIndex === 'number') { + return { + ...demoPanelPresets[demoIndex], + panel_id: item.panel_id, + group_id: item.group_id, + title: item.title, + url: item.url, + addtime: item.addtime, + sort: item.sort, + } + } const existingPanel = allPanelList.value.find((panel: any) => panel.panel_id === item.panel_id) // Initialize with default panelInfo structure let defaultPanelInfo = { @@ -666,6 +930,7 @@ const getPanelList = async (Gid?: number) => { item.ov = cutAuthStatus(item.ov) return item }) + allPanelList.value = nextPanelList groupList.value = res.data.groups if (firstLoad.value) { firstLoad.value = false @@ -711,8 +976,8 @@ const processUpdatesInBatches = () => { updatesToProcess.forEach((bufferedResult) => { // 找到对应的面板并更新其信息 - const item = allPanelList.value.find((panel: any) => panel.panel_id === bufferedResult.panel_id); - if (item) { + const item = panelByID.value.get(bufferedResult.panel_id); + if (item && !item.is_demo) { item.device_status = bufferedResult.device_status || item.device_status || 'unknown'; item.panel_status = bufferedResult.panel_status || item.panel_status || 'unknown'; item.panelInfo = bufferedResult.data.msg @@ -748,9 +1013,8 @@ const loadStatusSync = () => { const any_channel = 'panel_loads_recv' ipc.on(any_channel, (event: any, result: any) => { if (result.protocol_changed) { - const changedPanel = allPanelList.value.find( - (panel: any) => panel.panel_id === result.panel_id - ) + const changedPanel = panelByID.value.get(result.panel_id) + if (changedPanel?.is_demo) return if (changedPanel) changedPanel.url = result.protocol_changed.url if (result.protocol_changed.protocol === 'http') { Message.warn(result.protocol_changed.msg) @@ -831,6 +1095,10 @@ onMounted(() => { updateSegmentIndicator(sortSegmentRef, sortIndicatorStyle) }) onUnmounted(() => { + sortSwitchToken += 1 + clearSortAnimations() + dragAnimations.forEach(animation => animation.cancel()) + dragAnimations = [] // 关闭负载状态 common.send(routes.panel.stop_load.path, {}, (result: any) => {}) // 关闭事件 @@ -1082,29 +1350,16 @@ onUnmounted(() => { // transform: translateY(-5px); // box-shadow: rgba(0, 0, 0, 0.12) 0px 8px 24px; // } - &.isNoOpen { + &.isNoOpen:not(.isError):not(.isWarning) { opacity: 0.5; } &.isError { - background: #fcf1ef; + border-left: 3px solid #f56c6c; cursor: not-allowed !important; - box-shadow: - rgba(252, 241, 239, 0.3) 0 1px 2px 0, - rgba(252, 241, 239, 0.15) 0 2px 6px 2px; - :deep(.el-card__header) { - background-color: #fcf1ef; - } - :deep(.el-progress-bar__outer) { - background-color: #fde8e8; - } } &.isWarning { - background: var(--el-color-warning-light-9); + border-left: 3px solid #e6a23c; cursor: not-allowed !important; - box-shadow: 0 1px 2px rgba(230, 162, 60, 0.14), 0 2px 6px rgba(230, 162, 60, 0.1); - :deep(.el-card__header) { - background-color: var(--el-color-warning-light-9); - } } :deep(.disk-card-select) { .el-select__wrapper { @@ -1121,6 +1376,36 @@ onUnmounted(() => { font-size: 1.6rem; } } +.panel-copy-address { + display: inline-flex; + align-items: center; + gap: 0.35rem; + max-width: 100%; + padding: 0; + border: 0; + color: #6b7280; + background: transparent; + font-size: 0.9rem; + letter-spacing: 1px; + cursor: pointer; + transition: color 0.15s ease; + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .el-icon { + flex-shrink: 0; + font-size: 1.2rem; + } + + &:hover, + &:focus-visible { + color: var(--el-color-primary); + } +} .panel-device-status { display: inline-flex; align-items: center; @@ -1129,6 +1414,7 @@ onUnmounted(() => { border-radius: 999px; font-size: 1rem; white-space: nowrap; + transition: color 0.2s ease, background-color 0.2s ease; i { width: 0.55rem; @@ -1139,25 +1425,56 @@ onUnmounted(() => { } &--online { - color: var(--el-color-success); - background-color: var(--el-color-success-light-9); + color: #20a53a; + background-color: #eaf7ed; } &--warning { - color: var(--el-color-warning); - background-color: var(--el-color-warning-light-9); + color: #e6a23c; + background-color: #fdf6ec; } &--offline { - color: var(--el-color-danger); - background-color: var(--el-color-danger-light-9); + color: #f56c6c; + background-color: #fef0f0; + } + + &--checking { + color: #409eff; + background-color: #ecf5ff; + + i { + animation: panel-status-pulse 1.6s ease-out infinite; + } } &--unknown { - color: var(--el-text-color-secondary); - background-color: var(--el-fill-color-light); + color: #909399; + background-color: #f4f4f5; } } +@keyframes panel-status-pulse { + 0% { + box-shadow: 0 0 0 0 rgba(64, 158, 255, 0.4); + } + 70%, + 100% { + box-shadow: 0 0 0 0.5rem rgba(64, 158, 255, 0); + } +} +@media (prefers-reduced-motion: reduce) { + .panel-device-status--checking i { + animation: none; + } +} +.panel-demo-badge { + margin-right: 0.8rem; + padding: 0.25rem 0.65rem; + border-radius: 999px; + color: var(--el-color-primary); + background-color: var(--el-color-primary-light-9); + font-size: 1rem; +} .panel-card-col { transition: opacity 0.15s ease; @@ -1165,8 +1482,8 @@ onUnmounted(() => { opacity: 0.25; } } -.panel-card-move { - transition: transform 0.2s ease; +.panel-card-group { + display: contents; } .panel-drag-handle { display: inline-flex;