Revert "feat(frontend): unify ui with slate control center"
This reverts commit 91aa6e83ca.
This commit is contained in:
+237
-338
@@ -25,230 +25,294 @@ const authStore = useAuthStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const appearanceStore = useAppearanceStore();
|
||||
const layoutStore = useLayoutStore();
|
||||
const focusSwitcherStore = useFocusSwitcherStore();
|
||||
const sessionStore = useSessionStore();
|
||||
const dialogStore = useDialogStore();
|
||||
const { state: dialogState } = storeToRefs(dialogStore);
|
||||
const favoritePathsStore = useFavoritePathsStore();
|
||||
const focusSwitcherStore = useFocusSwitcherStore(); // +++ 实例化焦点切换 Store +++
|
||||
const sessionStore = useSessionStore(); // +++ 实例化 Session Store +++
|
||||
const dialogStore = useDialogStore(); // +++ 实例化 DialogStore +++
|
||||
const { state: dialogState } = storeToRefs(dialogStore);
|
||||
const favoritePathsStore = useFavoritePathsStore(); // +++ 实例化 favoritePathsStore +++
|
||||
const { isAuthenticated } = storeToRefs(authStore);
|
||||
const { showPopupFileEditorBoolean } = storeToRefs(settingsStore);
|
||||
const { isStyleCustomizerVisible } = storeToRefs(appearanceStore);
|
||||
const { isHeaderVisible } = storeToRefs(layoutStore);
|
||||
const { isLayoutVisible, isHeaderVisible } = storeToRefs(layoutStore); // 添加 isHeaderVisible
|
||||
const { isConfiguratorVisible: isFocusSwitcherVisible } = storeToRefs(focusSwitcherStore);
|
||||
const { isRdpModalOpen, rdpConnectionInfo, isVncModalOpen, vncConnectionInfo } = storeToRefs(sessionStore);
|
||||
const { isRdpModalOpen, rdpConnectionInfo, isVncModalOpen, vncConnectionInfo } = storeToRefs(sessionStore); // +++ 获取 RDP 和 VNC 状态 +++
|
||||
const { isMobile } = useDeviceDetection();
|
||||
|
||||
const route = useRoute();
|
||||
const navRef = ref<HTMLElement | null>(null);
|
||||
const underlineRef = ref<HTMLElement | null>(null);
|
||||
|
||||
// +++ 存储上一次由切换器聚焦的 ID +++
|
||||
const lastFocusedIdBySwitcher = ref<string | null>(null);
|
||||
const isAltPressed = ref(false);
|
||||
const isAltPressed = ref(false); // 跟踪 Alt 键是否按下
|
||||
const altShortcutKey = ref<string | null>(null);
|
||||
// --- 移除 shortcutTriggeredInKeyDown 标志 ---
|
||||
|
||||
const updateUnderline = async () => {
|
||||
await nextTick();
|
||||
await nextTick(); // 等待 DOM 更新
|
||||
if (navRef.value && underlineRef.value) {
|
||||
const activeLink = navRef.value.querySelector('.router-link-exact-active') as HTMLElement;
|
||||
if (activeLink) {
|
||||
const offsetBottom = 2; // 下划线距离文字底部的距离 (px)
|
||||
underlineRef.value.style.left = `${activeLink.offsetLeft}px`;
|
||||
underlineRef.value.style.width = `${activeLink.offsetWidth}px`;
|
||||
underlineRef.value.style.opacity = '1';
|
||||
// underlineRef.value.style.top = `${activeLink.offsetTop + activeLink.offsetHeight + offsetBottom}px`; // 移除 top 设置
|
||||
underlineRef.value.style.opacity = '1'; // Make it visible
|
||||
} else {
|
||||
underlineRef.value.style.opacity = '0';
|
||||
underlineRef.value.style.opacity = '0'; // Hide if no active link (e.g., on login page if not a nav link)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// Initial position update
|
||||
// Use setTimeout to ensure styles are applied and elements have dimensions
|
||||
setTimeout(updateUnderline, 100);
|
||||
|
||||
window.addEventListener('keydown', handleAltKeyDown);
|
||||
window.addEventListener('keyup', handleGlobalKeyUp);
|
||||
|
||||
window.addEventListener('beforeinstallprompt', () => {
|
||||
// +++ 全局 Alt 键监听器 +++
|
||||
window.addEventListener('keydown', handleAltKeyDown); // +++ 监听 keydown 设置状态 +++
|
||||
window.addEventListener('keyup', handleGlobalKeyUp); // +++ 监听 keyup 执行切换 +++
|
||||
|
||||
// PWA Install Prompt
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
console.log('[App.vue] beforeinstallprompt event fired. Browser will handle install prompt.');
|
||||
});
|
||||
|
||||
window.addEventListener('appinstalled', () => {
|
||||
console.log('[App.vue] PWA was installed');
|
||||
});
|
||||
|
||||
|
||||
// +++ 加载 Header 可见性状态 +++
|
||||
layoutStore.loadHeaderVisibility();
|
||||
|
||||
});
|
||||
|
||||
watch(
|
||||
isAuthenticated,
|
||||
(loggedIn) => {
|
||||
if (loggedIn) {
|
||||
favoritePathsStore.initializeFavoritePaths(t);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
// +++ 监听用户认证状态,登录后初始化收藏路径 +++
|
||||
watch(isAuthenticated, (loggedIn) => {
|
||||
if (loggedIn) {
|
||||
favoritePathsStore.initializeFavoritePaths(t);
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
// +++ 卸载钩子以移除监听器 +++
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleAltKeyDown);
|
||||
window.removeEventListener('keyup', handleGlobalKeyUp);
|
||||
window.removeEventListener('keydown', handleAltKeyDown); // +++ 移除 keydown 监听 +++
|
||||
window.removeEventListener('keyup', handleGlobalKeyUp); // +++ 移除 keyup 监听 +++
|
||||
});
|
||||
|
||||
|
||||
// *** 计算属性,判断是否在 workspace 路由 ***
|
||||
const isWorkspaceRoute = computed(() => route.path === '/workspace');
|
||||
|
||||
watch(
|
||||
route,
|
||||
() => {
|
||||
updateUnderline();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
watch(route, () => {
|
||||
updateUnderline();
|
||||
}, { immediate: true }); // *** 确保 immediate: true 存在 ***
|
||||
|
||||
|
||||
const handleLogout = () => {
|
||||
authStore.logout();
|
||||
};
|
||||
|
||||
// 打开样式自定义器的方法现在直接调用 store action
|
||||
const openStyleCustomizer = () => {
|
||||
appearanceStore.toggleStyleCustomizer(true);
|
||||
};
|
||||
|
||||
// 关闭样式自定义器的方法现在也调用 store action
|
||||
const closeStyleCustomizer = () => {
|
||||
appearanceStore.toggleStyleCustomizer(false);
|
||||
};
|
||||
|
||||
const handleAltKeyDown = async (event: KeyboardEvent) => {
|
||||
if (!isWorkspaceRoute.value) return;
|
||||
|
||||
// +++ 处理 Alt 键按下的事件处理函数,并记录快捷键 +++
|
||||
const handleAltKeyDown = async (event: KeyboardEvent) => { // +++ 改为 async +++
|
||||
if (!isWorkspaceRoute.value) return; // 只在 workspace 路由下执行
|
||||
// 只在 Alt 键首次按下时设置状态
|
||||
if (event.key === 'Alt' && !event.repeat) {
|
||||
isAltPressed.value = true;
|
||||
altShortcutKey.value = null;
|
||||
// console.log('[App] Alt key pressed down.');
|
||||
} else if (isAltPressed.value && !['Control', 'Shift', 'Alt', 'Meta'].includes(event.key)) {
|
||||
// 如果 Alt 正被按住,且按下了非修饰键 (移除 !shortcutTriggeredInKeyDown 检查)
|
||||
let key = event.key;
|
||||
if (key.length === 1) key = key.toUpperCase();
|
||||
|
||||
if (/^[a-zA-Z0-9]$/.test(key)) {
|
||||
altShortcutKey.value = key;
|
||||
const shortcutString = `Alt+${key}`;
|
||||
const targetId = focusSwitcherStore.getFocusTargetIdByShortcut(shortcutString);
|
||||
altShortcutKey.value = key; // 记录按键
|
||||
const shortcutString = `Alt+${key}`;
|
||||
console.log(`[App] KeyDown: Alt+${key} detected. Checking shortcut: ${shortcutString}`);
|
||||
const targetId = focusSwitcherStore.getFocusTargetIdByShortcut(shortcutString);
|
||||
|
||||
if (targetId) {
|
||||
event.preventDefault();
|
||||
const success = await focusSwitcherStore.focusTarget(targetId);
|
||||
if (success) {
|
||||
lastFocusedIdBySwitcher.value = targetId;
|
||||
if (targetId) {
|
||||
console.log(`[App] KeyDown: Shortcut match found. Targeting ID: ${targetId}`);
|
||||
event.preventDefault(); // 阻止默认行为 (如菜单)
|
||||
const success = await focusSwitcherStore.focusTarget(targetId); // +++ 立即尝试聚焦 +++
|
||||
if (success) {
|
||||
console.log(`[App] KeyDown: Successfully focused ${targetId} via shortcut.`);
|
||||
lastFocusedIdBySwitcher.value = targetId;
|
||||
// --- 移除设置标志位 ---
|
||||
} else {
|
||||
console.log(`[App] KeyDown: Failed to focus ${targetId} via shortcut action.`);
|
||||
// 聚焦失败,可以选择是否取消 Alt 状态,暂时不处理,让 keyup 重置
|
||||
}
|
||||
} else {
|
||||
console.log(`[App] KeyDown: No configured shortcut found for ${shortcutString}.`);
|
||||
// 没有匹配的快捷键,可以选择取消 Alt 状态以允许默认行为,或保持状态等待 keyup
|
||||
// isAltPressed.value = false;
|
||||
// altShortcutKey.value = null;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
isAltPressed.value = false;
|
||||
altShortcutKey.value = null;
|
||||
// 按下无效键 (非字母数字),取消 Alt 状态
|
||||
isAltPressed.value = false;
|
||||
altShortcutKey.value = null;
|
||||
// --- 移除重置标志位 ---
|
||||
console.log('[App] KeyDown: Alt sequence cancelled by non-alphanumeric key press.');
|
||||
}
|
||||
} else if (isAltPressed.value && ['Control', 'Shift', 'Meta'].includes(event.key)) {
|
||||
// 按下其他修饰键,取消 Alt 状态
|
||||
isAltPressed.value = false;
|
||||
altShortcutKey.value = null;
|
||||
// --- 移除重置标志位 ---
|
||||
console.log('[App] KeyDown: Alt sequence cancelled by other modifier key press.');
|
||||
}
|
||||
};
|
||||
|
||||
// +++ 全局键盘事件处理函数,监听 keyup,优先处理快捷键 +++
|
||||
const handleGlobalKeyUp = async (event: KeyboardEvent) => {
|
||||
if (!isWorkspaceRoute.value) return; // 只在 workspace 路由下执行
|
||||
if (event.key === 'Alt') {
|
||||
const altWasPressed = isAltPressed.value;
|
||||
const triggeredShortcutKey = altShortcutKey.value; // 记录松开时是否有记录的快捷键
|
||||
|
||||
// 总是重置状态
|
||||
isAltPressed.value = false;
|
||||
altShortcutKey.value = null;
|
||||
// --- 移除重置标志位 ---
|
||||
|
||||
if (altWasPressed && triggeredShortcutKey === null) {
|
||||
// 如果 Alt 之前是按下的,并且没有记录到有效的快捷键,则执行顺序切换
|
||||
console.log('[App] KeyUp: Alt released without a valid shortcut key captured. Attempting sequential focus switch.');
|
||||
event.preventDefault(); // 仅在执行顺序切换时阻止默认行为
|
||||
|
||||
// --- 顺序切换逻辑 (保持不变) ---
|
||||
let currentFocusId: string | null = lastFocusedIdBySwitcher.value;
|
||||
console.log(`[App] Sequential switch. Last focused by switcher: ${currentFocusId}`);
|
||||
|
||||
if (!currentFocusId) {
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
if (activeElement && activeElement.hasAttribute('data-focus-id')) {
|
||||
currentFocusId = activeElement.getAttribute('data-focus-id');
|
||||
console.log(`[App] Sequential switch. Found focus ID from activeElement: ${currentFocusId}`);
|
||||
} else {
|
||||
console.log(`[App] Sequential switch. Could not determine current focus ID.`);
|
||||
}
|
||||
}
|
||||
|
||||
const order = focusSwitcherStore.sequenceOrder; // ++ 使用新的 sequenceOrder state ++
|
||||
if (order.length === 0) { // ++ 检查新的 state ++
|
||||
console.log('[App] No focus sequence configured.');
|
||||
return;
|
||||
}
|
||||
|
||||
let focused = false;
|
||||
for (let i = 0; i < order.length; i++) { // ++ Use order.length for loop condition ++
|
||||
const nextFocusId = focusSwitcherStore.getNextFocusTargetId(currentFocusId);
|
||||
if (!nextFocusId) {
|
||||
console.warn('[App] Could not determine next focus target ID in sequence.');
|
||||
break;
|
||||
}
|
||||
|
||||
console.log(`[App] Sequential switch. Trying to focus target ID: ${nextFocusId}`);
|
||||
const success = await focusSwitcherStore.focusTarget(nextFocusId);
|
||||
|
||||
if (success) {
|
||||
console.log(`[App] Successfully focused ${nextFocusId} sequentially.`);
|
||||
lastFocusedIdBySwitcher.value = nextFocusId;
|
||||
focused = true;
|
||||
break;
|
||||
} else {
|
||||
console.log(`[App] Failed to focus ${nextFocusId} sequentially. Trying next...`);
|
||||
currentFocusId = nextFocusId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!focused) {
|
||||
console.log('[App] Cycled through sequence, no target could be focused.');
|
||||
lastFocusedIdBySwitcher.value = null;
|
||||
}
|
||||
// --- 顺序切换逻辑结束 ---
|
||||
|
||||
} else if (altWasPressed && triggeredShortcutKey !== null) {
|
||||
console.log(`[App] KeyUp: Alt released after capturing key '${triggeredShortcutKey}'. Shortcut logic handled in keydown. No sequential switch.`);
|
||||
// 快捷键逻辑已在 keydown 处理,keyup 时无需操作,也不阻止默认行为(除非特定需要)
|
||||
} else {
|
||||
// Alt 松开,但 isAltPressed 已经是 false (例如被其他键取消了)
|
||||
console.log('[App] KeyUp: Alt released, but sequence was already cancelled or not active.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleGlobalKeyUp = async (event: KeyboardEvent) => {
|
||||
if (!isWorkspaceRoute.value) return;
|
||||
if (event.key !== 'Alt') return;
|
||||
|
||||
const altWasPressed = isAltPressed.value;
|
||||
const triggeredShortcutKey = altShortcutKey.value;
|
||||
|
||||
isAltPressed.value = false;
|
||||
altShortcutKey.value = null;
|
||||
|
||||
if (altWasPressed && triggeredShortcutKey === null) {
|
||||
event.preventDefault();
|
||||
|
||||
let currentFocusId: string | null = lastFocusedIdBySwitcher.value;
|
||||
|
||||
if (!currentFocusId) {
|
||||
const activeElement = document.activeElement as HTMLElement;
|
||||
if (activeElement && activeElement.hasAttribute('data-focus-id')) {
|
||||
currentFocusId = activeElement.getAttribute('data-focus-id');
|
||||
}
|
||||
}
|
||||
|
||||
const order = focusSwitcherStore.sequenceOrder;
|
||||
if (order.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let focused = false;
|
||||
for (let i = 0; i < order.length; i += 1) {
|
||||
const nextFocusId = focusSwitcherStore.getNextFocusTargetId(currentFocusId);
|
||||
if (!nextFocusId) {
|
||||
break;
|
||||
}
|
||||
|
||||
const success = await focusSwitcherStore.focusTarget(nextFocusId);
|
||||
if (success) {
|
||||
lastFocusedIdBySwitcher.value = nextFocusId;
|
||||
focused = true;
|
||||
break;
|
||||
}
|
||||
|
||||
currentFocusId = nextFocusId;
|
||||
}
|
||||
|
||||
if (!focused) {
|
||||
lastFocusedIdBySwitcher.value = null;
|
||||
}
|
||||
// +++ 辅助函数:检查元素是否可见且可聚焦 +++
|
||||
const isElementVisibleAndFocusable = (element: HTMLElement): boolean => {
|
||||
if (!element) return false;
|
||||
// 检查元素是否在 DOM 中,并且没有 display: none
|
||||
const style = window.getComputedStyle(element);
|
||||
if (style.display === 'none' || style.visibility === 'hidden') return false;
|
||||
// 检查元素或其父元素是否被禁用
|
||||
if ((element as HTMLInputElement).disabled) return false;
|
||||
let parent = element.parentElement;
|
||||
while (parent) {
|
||||
if ((parent as HTMLFieldSetElement).disabled) return false;
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
// 检查元素是否足够在视口内(粗略检查)
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="app-container" class="app-shell">
|
||||
<div class="app-shell__backdrop"></div>
|
||||
|
||||
<header v-if="!isWorkspaceRoute || isHeaderVisible" class="app-topbar">
|
||||
<nav ref="navRef" class="app-topbar__inner">
|
||||
<div class="app-topbar__left">
|
||||
<RouterLink to="/" class="app-brand">
|
||||
<img src="./assets/logo.png" alt="Project Logo" class="app-brand__logo">
|
||||
<div class="app-brand__copy">
|
||||
<span class="app-brand__title">{{ t('projectName') }}</span>
|
||||
<span class="app-brand__subtitle">Slate Control Center</span>
|
||||
</div>
|
||||
</RouterLink>
|
||||
|
||||
<div class="app-nav">
|
||||
<RouterLink to="/" class="app-nav__link" active-class="is-active">{{ t('nav.dashboard') }}</RouterLink>
|
||||
<RouterLink to="/workspace" class="app-nav__link" active-class="is-active">{{ t('nav.terminal') }}</RouterLink>
|
||||
<RouterLink to="/connections" class="app-nav__link hidden md:inline-flex" active-class="is-active">{{ t('nav.connections') }}</RouterLink>
|
||||
<RouterLink to="/proxies" class="app-nav__link hidden md:inline-flex" active-class="is-active">{{ t('nav.proxies') }}</RouterLink>
|
||||
<RouterLink to="/notifications" class="app-nav__link hidden md:inline-flex" active-class="is-active">{{ t('nav.notifications') }}</RouterLink>
|
||||
<RouterLink to="/audit-logs" class="app-nav__link hidden md:inline-flex" active-class="is-active">{{ t('nav.auditLogs') }}</RouterLink>
|
||||
<RouterLink to="/settings" class="app-nav__link" active-class="is-active">{{ t('nav.settings') }}</RouterLink>
|
||||
<div ref="underlineRef" class="app-nav__underline"></div>
|
||||
</div>
|
||||
|
||||
<div id="app-container">
|
||||
<!-- *** 修改 v-if 条件以使用 isHeaderVisible *** -->
|
||||
<!-- Header with Tailwind classes using theme variables -->
|
||||
<header v-if="!isWorkspaceRoute || isHeaderVisible" class="sticky top-0 z-10 flex items-center h-14 pl-3 pr-6 bg-header border-b border-border shadow-sm"> <!-- 减少左侧内边距 -->
|
||||
<!-- Nav with Tailwind classes -->
|
||||
<nav ref="navRef" class="flex items-center justify-between w-full relative"> <!-- Added relative positioning for underline -->
|
||||
<!-- Left navigation links with Tailwind classes using theme variables -->
|
||||
<div class="flex items-center space-x-1">
|
||||
<!-- 项目 Logo -->
|
||||
<img src="./assets/logo.png" alt="Project Logo" class="h-10 w-auto"> <!-- 移除右侧外边距,使其更靠左 -->
|
||||
<RouterLink to="/" class="inline-flex px-3 py-2 rounded-md text-sm font-medium text-secondary hover:text-link-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out whitespace-nowrap" active-class="text-link-active bg-nav-active-bg">{{ t('nav.dashboard') }}</RouterLink> <!-- 恢复仪表盘链接, 始终可见 -->
|
||||
<RouterLink to="/workspace" class="inline-flex px-3 py-2 rounded-md text-sm font-medium text-secondary hover:text-link-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out whitespace-nowrap" active-class="text-link-active bg-nav-active-bg">{{ t('nav.terminal') }}</RouterLink> <!-- 保持可见 -->
|
||||
<RouterLink to="/connections" class="hidden md:inline-flex px-3 py-2 rounded-md text-sm font-medium text-secondary hover:text-link-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out whitespace-nowrap" active-class="text-link-active bg-nav-active-bg">{{ t('nav.connections') }}</RouterLink> <!-- 连接管理链接 -->
|
||||
<RouterLink to="/proxies" class="hidden md:inline-flex px-3 py-2 rounded-md text-sm font-medium text-secondary hover:text-link-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out whitespace-nowrap" active-class="text-link-active bg-nav-active-bg">{{ t('nav.proxies') }}</RouterLink> <!-- 移动端隐藏 -->
|
||||
<RouterLink to="/notifications" class="hidden md:inline-flex px-3 py-2 rounded-md text-sm font-medium text-secondary hover:text-link-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out whitespace-nowrap" active-class="text-link-active bg-nav-active-bg">{{ t('nav.notifications') }}</RouterLink> <!-- 移动端隐藏 -->
|
||||
<RouterLink to="/audit-logs" class="hidden md:inline-flex px-3 py-2 rounded-md text-sm font-medium text-secondary hover:text-link-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out whitespace-nowrap" active-class="text-link-active bg-nav-active-bg">{{ t('nav.auditLogs') }}</RouterLink> <!-- 移动端隐藏 -->
|
||||
<RouterLink to="/settings" class="inline-flex px-3 py-2 rounded-md text-sm font-medium text-secondary hover:text-link-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out whitespace-nowrap" active-class="text-link-active bg-nav-active-bg">{{ t('nav.settings') }}</RouterLink> <!-- 保持可见 -->
|
||||
</div>
|
||||
|
||||
<div class="app-topbar__right">
|
||||
<a
|
||||
v-if="!isMobile"
|
||||
href="https://github.com/Heavrnl/nexus-terminal"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Heavrnl/nexus-terminal"
|
||||
class="app-icon-button"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" viewBox="0 0 16 16">
|
||||
<!-- Right navigation links with Tailwind classes using theme variables -->
|
||||
<div class="flex items-center space-x-1">
|
||||
<!-- GitHub Icon (Hide on mobile) -->
|
||||
<a v-if="!isMobile" href="https://github.com/Heavrnl/nexus-terminal" target="_blank" rel="noopener noreferrer" title="Heavrnl/nexus-terminal" class="px-2 py-2 rounded-md text-lg text-icon hover:text-icon-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8"/>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<a href="#" @click.prevent="openStyleCustomizer" :title="t('nav.customizeStyle')" class="app-icon-button">
|
||||
<i class="fas fa-paint-brush"></i>
|
||||
</a>
|
||||
|
||||
<RouterLink v-if="!isAuthenticated" to="/login" class="app-auth-link">{{ t('nav.login') }}</RouterLink>
|
||||
<a v-else href="#" @click.prevent="handleLogout" class="app-auth-link app-auth-link--primary">{{ t('nav.logout') }}</a>
|
||||
<!-- PWA Install Button - REMOVED FROM HERE -->
|
||||
<a href="#" @click.prevent="openStyleCustomizer" :title="t('nav.customizeStyle')" class="px-2 py-2 rounded-md text-lg text-icon hover:text-icon-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out"><i class="fas fa-paint-brush"></i></a>
|
||||
<RouterLink v-if="!isAuthenticated" to="/login" class="px-3 py-2 rounded-md text-sm font-medium text-secondary hover:text-link-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out whitespace-nowrap">{{ t('nav.login') }}</RouterLink>
|
||||
<a href="#" v-if="isAuthenticated" @click.prevent="handleLogout" class="px-3 py-2 rounded-md text-sm font-medium text-secondary hover:text-link-hover hover:bg-nav-active-bg hover:no-underline transition duration-150 ease-in-out whitespace-nowrap">{{ t('nav.logout') }}</a>
|
||||
</div>
|
||||
<!-- Sliding underline element with Tailwind classes using theme variables (JS still controls positioning) -->
|
||||
<div ref="underlineRef" class="absolute bottom-0 h-0.5 bg-link-active rounded transition-all duration-300 ease-in-out pointer-events-none opacity-0 transform translate-y-1.5"></div> <!-- Changed translate-y-1 to translate-y-1.5 -->
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="app-main">
|
||||
<main>
|
||||
<!-- 使用 KeepAlive 包裹 RouterView,并指定缓存 WorkspaceView -->
|
||||
<RouterView v-slot="{ Component }">
|
||||
<KeepAlive :include="['WorkspaceView', 'ConnectionsView']">
|
||||
<component :is="Component" />
|
||||
@@ -256,30 +320,49 @@ const handleGlobalKeyUp = async (event: KeyboardEvent) => {
|
||||
</RouterView>
|
||||
</main>
|
||||
|
||||
<!-- 添加全局通知显示 -->
|
||||
<UINotificationDisplay />
|
||||
|
||||
<!-- 根据设置条件渲染全局文件编辑器弹窗 -->
|
||||
<FileEditorOverlay v-if="showPopupFileEditorBoolean" :is-mobile="isMobile" />
|
||||
|
||||
<!-- 条件渲染样式自定义器,使用 store 的状态和方法 -->
|
||||
<StyleCustomizer v-if="isStyleCustomizerVisible" @close="closeStyleCustomizer" />
|
||||
|
||||
<!-- +++ 条件渲染焦点切换配置器 (使用 v-show 保持实例) +++ -->
|
||||
<FocusSwitcherConfigurator
|
||||
v-show="isFocusSwitcherVisible"
|
||||
:isVisible="isFocusSwitcherVisible"
|
||||
@close="focusSwitcherStore.toggleConfigurator(false)"
|
||||
/>
|
||||
|
||||
<RemoteDesktopModal v-if="isRdpModalOpen" :connection="rdpConnectionInfo" @close="sessionStore.closeRdpModal()" />
|
||||
<VncModal v-if="isVncModalOpen" :connection="vncConnectionInfo" @close="sessionStore.closeVncModal()" />
|
||||
|
||||
<ConfirmDialog
|
||||
:visible="dialogState.visible"
|
||||
:title="dialogState.title"
|
||||
:message="dialogState.message"
|
||||
:confirm-text="dialogState.confirmText"
|
||||
:cancel-text="dialogState.cancelText"
|
||||
:is-loading="dialogState.isLoading"
|
||||
@confirm="dialogStore.handleConfirm"
|
||||
@cancel="dialogStore.handleCancel"
|
||||
@update:visible="(val: boolean) => dialogStore.state.visible = val"
|
||||
<!-- +++ 条件渲染 RDP 模态框 +++ -->
|
||||
<RemoteDesktopModal
|
||||
v-if="isRdpModalOpen"
|
||||
:connection="rdpConnectionInfo"
|
||||
@close="sessionStore.closeRdpModal()"
|
||||
/>
|
||||
|
||||
<!-- +++ 条件渲染 VNC 模态框 +++ -->
|
||||
<VncModal
|
||||
v-if="isVncModalOpen"
|
||||
:connection="vncConnectionInfo"
|
||||
@close="sessionStore.closeVncModal()"
|
||||
/>
|
||||
|
||||
<!-- +++ 全局确认对话框 +++ -->
|
||||
<ConfirmDialog
|
||||
:visible="dialogState.visible"
|
||||
:title="dialogState.title"
|
||||
:message="dialogState.message"
|
||||
:confirm-text="dialogState.confirmText"
|
||||
:cancel-text="dialogState.cancelText"
|
||||
:is-loading="dialogState.isLoading"
|
||||
@confirm="dialogStore.handleConfirm"
|
||||
@cancel="dialogStore.handleCancel"
|
||||
@update:visible="(val: boolean) => dialogStore.state.visible = val"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -288,197 +371,13 @@ const handleGlobalKeyUp = async (event: KeyboardEvent) => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
font-family: var(--font-family-sans-serif);
|
||||
font-family: var(--font-family-sans-serif); /* 使用字体变量 */
|
||||
}
|
||||
|
||||
.app-shell__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(60, 105, 231, 0.08), transparent 28%),
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.16), transparent 22%);
|
||||
}
|
||||
|
||||
.app-topbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 30;
|
||||
padding: 1rem 1rem 0;
|
||||
}
|
||||
|
||||
.app-topbar__inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.9rem 1.1rem;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(103, 124, 155, 0.18);
|
||||
background: var(--header-bg-color);
|
||||
box-shadow: var(--shadow-soft);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.app-topbar__left,
|
||||
.app-topbar__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
padding-right: 0.35rem;
|
||||
}
|
||||
|
||||
.app-brand__logo {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.app-brand__copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-brand__title {
|
||||
font-family: var(--font-family-display);
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.app-brand__subtitle {
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-color-tertiary);
|
||||
}
|
||||
|
||||
.app-nav {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem;
|
||||
border-radius: 18px;
|
||||
background: rgba(241, 245, 251, 0.9);
|
||||
}
|
||||
|
||||
.app-nav__link {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.72rem 1rem;
|
||||
border-radius: 14px;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.app-nav__link.is-active {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.app-nav__underline {
|
||||
position: absolute;
|
||||
bottom: 0.35rem;
|
||||
height: calc(100% - 0.7rem);
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(239, 244, 252, 0.94));
|
||||
box-shadow: 0 10px 24px rgba(34, 56, 93, 0.12);
|
||||
transition: all 0.3s ease-in-out;
|
||||
opacity: 0;
|
||||
transform: translateY(0);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.app-icon-button,
|
||||
.app-auth-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
min-height: 40px;
|
||||
padding: 0 0.9rem;
|
||||
border: 1px solid rgba(103, 124, 155, 0.18);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.app-icon-button:hover,
|
||||
.app-auth-link:hover {
|
||||
color: var(--text-color);
|
||||
border-color: rgba(60, 105, 231, 0.26);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
|
||||
.app-icon-button {
|
||||
width: 40px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.app-auth-link--primary {
|
||||
background: linear-gradient(135deg, rgba(60, 105, 231, 0.14), rgba(39, 70, 184, 0.08));
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
main {
|
||||
flex-grow: 1;
|
||||
position: relative;
|
||||
padding-bottom: 1rem;
|
||||
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.app-topbar__inner {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.app-topbar__left,
|
||||
.app-topbar__right {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.app-nav {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.app-topbar {
|
||||
padding: 0.75rem 0.75rem 0;
|
||||
}
|
||||
|
||||
.app-topbar__inner {
|
||||
padding: 0.8rem;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.app-brand__subtitle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.app-nav__link {
|
||||
padding: 0.64rem 0.82rem;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
accentLabel: {
|
||||
type: String,
|
||||
default: 'Slate Control Center',
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="auth-layout">
|
||||
<div class="auth-layout__panel auth-layout__panel--brand">
|
||||
<div class="auth-layout__brand-card">
|
||||
<span class="auth-layout__eyebrow">{{ accentLabel }}</span>
|
||||
<img src="../assets/logo.png" alt="Project Logo" class="auth-layout__logo" />
|
||||
<div>
|
||||
<h1 class="auth-layout__brand-title">{{ t('projectName') }}</h1>
|
||||
<p class="auth-layout__brand-copy">{{ t('slogan') }}</p>
|
||||
</div>
|
||||
<div class="auth-layout__brand-meter">
|
||||
<span>{{ subtitle }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="auth-layout__panel auth-layout__panel--content">
|
||||
<div class="auth-layout__content-card">
|
||||
<div class="auth-layout__content-head">
|
||||
<el-tag effect="plain" round size="small">{{ accentLabel }}</el-tag>
|
||||
<h2>{{ title }}</h2>
|
||||
<p>{{ subtitle }}</p>
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.auth-layout {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 440px) minmax(360px, 560px);
|
||||
justify-content: center;
|
||||
gap: 1.5rem;
|
||||
padding: 1.75rem;
|
||||
}
|
||||
|
||||
.auth-layout__panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.auth-layout__brand-card,
|
||||
.auth-layout__content-card {
|
||||
height: 100%;
|
||||
border-radius: 32px;
|
||||
border: 1px solid rgba(103, 124, 155, 0.18);
|
||||
box-shadow: var(--shadow-soft);
|
||||
backdrop-filter: blur(22px);
|
||||
}
|
||||
|
||||
.auth-layout__brand-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 2rem;
|
||||
color: #f8fbff;
|
||||
background:
|
||||
linear-gradient(160deg, rgba(17, 31, 53, 0.94), rgba(32, 58, 102, 0.92)),
|
||||
radial-gradient(circle at 20% 20%, rgba(73, 119, 255, 0.34), transparent 35%);
|
||||
}
|
||||
|
||||
.auth-layout__eyebrow {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.auth-layout__logo {
|
||||
width: 84px;
|
||||
height: auto;
|
||||
margin: 2rem 0 1.25rem;
|
||||
}
|
||||
|
||||
.auth-layout__brand-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-family-display);
|
||||
font-size: clamp(2.4rem, 4vw, 3.6rem);
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
|
||||
.auth-layout__brand-copy {
|
||||
margin: 0.85rem 0 0;
|
||||
color: rgba(240, 245, 255, 0.8);
|
||||
max-width: 24rem;
|
||||
}
|
||||
|
||||
.auth-layout__brand-meter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: fit-content;
|
||||
margin-top: 2rem;
|
||||
padding: 0.65rem 0.9rem;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(245, 248, 255, 0.78);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.auth-layout__content-card {
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(244, 248, 253, 0.86));
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.auth-layout__content-head {
|
||||
margin-bottom: 1.4rem;
|
||||
}
|
||||
|
||||
.auth-layout__content-head h2 {
|
||||
margin: 0.95rem 0 0;
|
||||
font-family: var(--font-family-display);
|
||||
font-size: clamp(1.9rem, 3vw, 2.5rem);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.auth-layout__content-head p {
|
||||
margin: 0.75rem 0 0;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.auth-layout {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: 680px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.auth-layout__brand-card {
|
||||
min-height: 260px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.auth-layout {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.auth-layout__brand-card,
|
||||
.auth-layout__content-card {
|
||||
border-radius: 24px;
|
||||
padding: 1.35rem;
|
||||
}
|
||||
|
||||
.auth-layout__logo {
|
||||
width: 66px;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,126 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
eyebrow: {
|
||||
type: String,
|
||||
default: 'Slate Control Center',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-shell">
|
||||
<header class="page-shell__hero">
|
||||
<div class="page-shell__copy">
|
||||
<div class="page-shell__eyebrow">
|
||||
<el-tag effect="plain" round size="small">{{ eyebrow }}</el-tag>
|
||||
<slot name="badge" />
|
||||
</div>
|
||||
<h1 class="page-shell__title">{{ title }}</h1>
|
||||
<p v-if="subtitle" class="page-shell__subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="page-shell__actions">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="$slots.stats" class="page-shell__stats">
|
||||
<slot name="stats" />
|
||||
</div>
|
||||
|
||||
<div class="page-shell__body">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
width: min(1360px, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 1.4rem 0 2rem;
|
||||
}
|
||||
|
||||
.page-shell__hero {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1.25rem;
|
||||
align-items: flex-end;
|
||||
padding: 1.5rem 1.6rem;
|
||||
border-radius: 28px;
|
||||
border: 1px solid rgba(103, 124, 155, 0.16);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.88), rgba(242, 247, 253, 0.78)),
|
||||
linear-gradient(180deg, rgba(60, 105, 231, 0.08), transparent);
|
||||
box-shadow: var(--shadow-soft);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.page-shell__copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-shell__eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.page-shell__title {
|
||||
margin: 0;
|
||||
font-family: var(--font-family-display);
|
||||
font-size: clamp(2rem, 3vw, 2.8rem);
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.04em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.page-shell__subtitle {
|
||||
margin: 0.75rem 0 0;
|
||||
max-width: 62ch;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.page-shell__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-shell__stats,
|
||||
.page-shell__body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.page-shell {
|
||||
width: min(100%, calc(100% - 1.25rem));
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.page-shell__hero {
|
||||
padding: 1.2rem;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.page-shell__actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,34 +1,204 @@
|
||||
<template>
|
||||
<!-- 根元素,包含内边距、背景、边框和文本样式 -->
|
||||
<div class="status-monitor p-4 bg-background text-foreground h-full overflow-y-auto text-sm" :class="{ 'bg-header': !activeSessionId }">
|
||||
<h4 v-if="activeSessionId" class="mt-0 mb-4 border-b border-border pb-2 text-base font-medium">
|
||||
{{ t('statusMonitor.title') }}
|
||||
</h4>
|
||||
|
||||
<!-- 无活动会话状态 -->
|
||||
<div v-if="!activeSessionId" class="no-session-status flex flex-col items-center justify-center text-center text-text-secondary mt-4 h-full">
|
||||
<i class="fas fa-plug text-4xl mb-3 text-text-secondary"></i>
|
||||
<span class="text-lg font-medium mb-2">{{ t('layout.noActiveSession.title') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else-if="currentStatusError" class="status-error flex flex-col items-center justify-center text-center text-red-500 mt-4 h-full">
|
||||
<i class="fas fa-exclamation-triangle text-2xl mb-2"></i>
|
||||
<span>{{ t('statusMonitor.errorPrefix') }} {{ currentStatusError }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-else-if="!currentServerStatus" class="loading-status flex flex-col items-center justify-center text-center text-text-secondary mt-4 h-full">
|
||||
<i class="fas fa-spinner fa-spin text-2xl mb-2"></i>
|
||||
<span>{{ t('statusMonitor.loading') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 状态网格 -->
|
||||
<div v-else class="status-grid grid gap-3">
|
||||
<!-- IP 地址 (如果启用) -->
|
||||
<div v-if="statusMonitorShowIpBoolean && activeSessionId && sessionIpAddress" class="status-item grid grid-cols-[auto_1fr] items-center gap-3">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">IP:</label>
|
||||
<div class="flex items-center">
|
||||
<span
|
||||
class="ip-address-value truncate text-left cursor-pointer hover:text-primary transition-colors"
|
||||
:title="sessionIpAddress"
|
||||
@click="copyIpToClipboard(sessionIpAddress)">
|
||||
{{ sessionIpAddress }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CPU 型号 -->
|
||||
<div class="status-item grid grid-cols-[auto_1fr] items-center gap-3">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">{{ t('statusMonitor.cpuModelLabel') }}</label>
|
||||
<span class="cpu-model-value truncate text-left" :title="displayCpuModel">{{ displayCpuModel }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 操作系统名称 -->
|
||||
<div class="status-item grid grid-cols-[auto_1fr] items-center gap-3">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">{{ t('statusMonitor.osLabel') }}</label>
|
||||
<span class="os-name-value truncate text-left" :title="displayOsName">{{ displayOsName }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 资源使用率分组 -->
|
||||
<div class="resource-monitor-group grid gap-3 mb-3">
|
||||
<!-- CPU 使用率 -->
|
||||
<!-- 设置第一列固定宽度为 80px -->
|
||||
<div class="status-item grid grid-cols-[40px_1fr] items-center gap-3">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">{{ t('statusMonitor.cpuLabel') }}</label>
|
||||
<div class="value-wrapper flex items-center gap-2">
|
||||
<el-progress
|
||||
:percentage="displayCpuPercent"
|
||||
:stroke-width="16"
|
||||
color="#3b82f6"
|
||||
:show-text="true"
|
||||
:text-inside="true"
|
||||
:format="formatPercentageText"
|
||||
class="themed-progress flex-grow" :class="{ 'no-transition': isSwitchingSession }"
|
||||
/>
|
||||
<!-- 移除 w-12 和 text-right 以实现左对齐 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内存使用率 -->
|
||||
<!-- 设置第一列固定宽度为 80px -->
|
||||
<div class="status-item grid grid-cols-[40px_1fr] items-center gap-3">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">{{ t('statusMonitor.memoryLabel') }}</label>
|
||||
<div class="value-wrapper flex items-center gap-2">
|
||||
<el-progress
|
||||
:percentage="displayMemPercent"
|
||||
:stroke-width="16"
|
||||
color="#22c55e"
|
||||
:show-text="true"
|
||||
:text-inside="true"
|
||||
:format="formatPercentageText"
|
||||
class="themed-progress flex-grow" :class="{ 'no-transition': isSwitchingSession }"
|
||||
/>
|
||||
<span class="mem-disk-details font-mono text-xs whitespace-nowrap text-left">{{ memDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- swap -->
|
||||
<!-- 设置第一列固定宽度为 80px -->
|
||||
<div class="status-item grid grid-cols-[40px_1fr] items-center gap-3">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">{{ t('statusMonitor.swapLabel') }}</label>
|
||||
<div class="value-wrapper flex items-center gap-2">
|
||||
<el-progress
|
||||
:percentage="displaySwapPercent"
|
||||
:stroke-width="16"
|
||||
:color="(currentServerStatus?.swapPercent ?? 0) > 0 ? '#eab308' : '#6b7280'"
|
||||
:show-text="true"
|
||||
:text-inside="true"
|
||||
:format="formatPercentageText"
|
||||
class="themed-progress flex-grow" :class="{ 'no-transition': isSwitchingSession }"
|
||||
/>
|
||||
<span class="mem-disk-details font-mono text-xs whitespace-nowrap text-left">{{ swapDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 磁盘使用率 -->
|
||||
<!-- 设置第一列固定宽度为 80px -->
|
||||
<div class="status-item grid grid-cols-[40px_1fr] items-center gap-3">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">{{ t('statusMonitor.diskLabel') }}</label>
|
||||
<div class="value-wrapper flex items-center gap-2">
|
||||
<el-progress
|
||||
:percentage="displayDiskPercent"
|
||||
:stroke-width="16"
|
||||
color="#a855f7"
|
||||
:show-text="true"
|
||||
:text-inside="true"
|
||||
:format="formatPercentageText"
|
||||
class="themed-progress flex-grow" :class="{ 'no-transition': isSwitchingSession }"
|
||||
/>
|
||||
<span class="mem-disk-details font-mono text-xs whitespace-nowrap text-left">{{ diskDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 网络速率,仅在有活动会话且有数据时显示 -->
|
||||
<div v-if="activeSessionId && currentServerStatus" class="status-item grid grid-cols-[auto_1fr] items-center gap-3 mt-2">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">{{ t('statusMonitor.networkLabel') }} ({{ currentServerStatus?.netInterface || '...' }}):</label>
|
||||
<div class="network-values flex items-center justify-start gap-4"> <!-- 减小间距 -->
|
||||
<span class="rate down inline-flex items-center gap-1 text-green-500 text-xs whitespace-nowrap">
|
||||
<i class="fas fa-arrow-down w-3 text-center"></i> <!-- Font Awesome 图标 -->
|
||||
<span class="font-mono">{{ formatBytesPerSecond(currentServerStatus?.netRxRate) }}</span>
|
||||
</span>
|
||||
<span class="rate up inline-flex items-center gap-1 text-orange-500 text-xs whitespace-nowrap">
|
||||
<i class="fas fa-arrow-up w-3 text-center"></i> <!-- Font Awesome 图标 -->
|
||||
<span class="font-mono">{{ formatBytesPerSecond(currentServerStatus?.netTxRate) }}</span>
|
||||
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div v-if="activeSessionId && currentServerStatus" class="status-item grid grid-cols-[auto_1fr] items-start gap-3 mt-2">
|
||||
<label class="font-semibold text-text-secondary text-left whitespace-nowrap">{{ t('statusMonitor.totalTrafficLabel') }}:</label>
|
||||
<div class="flex flex-col gap-1.5 text-xs">
|
||||
<span class="inline-flex items-center gap-2 whitespace-nowrap text-green-500">
|
||||
<i class="fas fa-arrow-down w-3 text-center"></i>
|
||||
<span>{{ t('statusMonitor.downloadLabel') }}</span>
|
||||
<span class="font-mono text-foreground">{{ formatBytes(currentServerStatus?.netRxTotalBytes) }}</span>
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-2 whitespace-nowrap text-orange-500">
|
||||
<i class="fas fa-arrow-up w-3 text-center"></i>
|
||||
<span>{{ t('statusMonitor.uploadLabel') }}</span>
|
||||
<span class="font-mono text-foreground">{{ formatBytes(currentServerStatus?.netTxTotalBytes) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 图表组件 -->
|
||||
<!-- 仅当有活动会话且有数据时渲染图表 -->
|
||||
<StatusCharts v-if="activeSessionId && currentServerStatus" :server-status="currentServerStatus" :active-session-id="activeSessionId" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, type PropType, nextTick } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
|
||||
import { ref, computed, watch, type PropType, nextTick } from 'vue';
|
||||
import { ElProgress } from 'element-plus';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import StatusCharts from './StatusCharts.vue';
|
||||
import { useSessionStore } from '../stores/session.store';
|
||||
import { useSettingsStore } from '../stores/settings.store';
|
||||
import { useConnectionsStore } from '../stores/connections.store';
|
||||
import { useUiNotificationsStore } from '../stores/uiNotifications.store';
|
||||
import { useSessionStore } from '../stores/session.store'; // 注入 sessionStore
|
||||
import { storeToRefs } from 'pinia'; // 导入 storeToRefs
|
||||
import { useSettingsStore } from '../stores/settings.store'; // 导入设置 store
|
||||
import { useConnectionsStore } from '../stores/connections.store'; // 导入连接 store
|
||||
import { useUiNotificationsStore } from '../stores/uiNotifications.store'; // + 导入通知 store
|
||||
import type { ServerStatus } from '../types/server.types';
|
||||
|
||||
const { t } = useI18n();
|
||||
const sessionStore = useSessionStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const connectionsStore = useConnectionsStore();
|
||||
const uiNotificationsStore = useUiNotificationsStore();
|
||||
|
||||
const { sessions } = storeToRefs(sessionStore);
|
||||
const { statusMonitorShowIpBoolean } = storeToRefs(settingsStore);
|
||||
const settingsStore = useSettingsStore(); // 实例化设置 store
|
||||
const connectionsStore = useConnectionsStore(); // 实例化连接 store
|
||||
const uiNotificationsStore = useUiNotificationsStore(); // + 实例化通知 store
|
||||
const { sessions } = storeToRefs(sessionStore); // 获取响应式的 sessions
|
||||
const { statusMonitorShowIpBoolean } = storeToRefs(settingsStore); // 获取 IP 显示设置
|
||||
const isSwitchingSession = ref(false);
|
||||
|
||||
const formatPercentageText = (percentage: number): string => `${Math.round(percentage)}%`;
|
||||
|
||||
// --- Props ---
|
||||
const props = defineProps({
|
||||
activeSessionId: {
|
||||
type: String as PropType<string | null>,
|
||||
required: false,
|
||||
required: false, // 允许为 null
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const formatPercentageText = (percentage: number): string => `${Math.round(percentage)}%`;
|
||||
|
||||
// --- Computed properties to get current session data ---
|
||||
const currentSessionState = computed(() => {
|
||||
return props.activeSessionId ? sessions.value.get(props.activeSessionId) : null;
|
||||
});
|
||||
@@ -37,546 +207,167 @@ const currentServerStatus = computed<ServerStatus | null>(() => {
|
||||
return currentSessionState.value?.statusMonitorManager?.serverStatus?.value ?? null;
|
||||
});
|
||||
|
||||
const displayCpuPercent = computed(() => currentServerStatus.value?.cpuPercent ?? 0);
|
||||
const displayMemPercent = computed(() => currentServerStatus.value?.memPercent ?? 0);
|
||||
const displaySwapPercent = computed(() => currentServerStatus.value?.swapPercent ?? 0);
|
||||
const displayDiskPercent = computed(() => currentServerStatus.value?.diskPercent ?? 0);
|
||||
// --- 计算属性,用于绑定到进度条宽度 ---
|
||||
// 始终返回当前状态的百分比。动画由 CSS 类控制。
|
||||
const displayCpuPercent = computed(() => {
|
||||
return currentServerStatus.value?.cpuPercent ?? 0;
|
||||
});
|
||||
|
||||
const displayMemPercent = computed(() => {
|
||||
return currentServerStatus.value?.memPercent ?? 0;
|
||||
});
|
||||
|
||||
const displaySwapPercent = computed(() => {
|
||||
return currentServerStatus.value?.swapPercent ?? 0;
|
||||
});
|
||||
|
||||
const displayDiskPercent = computed(() => {
|
||||
return currentServerStatus.value?.diskPercent ?? 0;
|
||||
});
|
||||
|
||||
const currentStatusError = computed<string | null>(() => {
|
||||
return currentSessionState.value?.statusMonitorManager?.statusError?.value ?? null;
|
||||
});
|
||||
|
||||
// --- 缓存逻辑保持不变 ---
|
||||
const cachedCpuModel = ref<string | null>(null);
|
||||
const cachedOsName = ref<string | null>(null);
|
||||
|
||||
watch(
|
||||
currentServerStatus,
|
||||
(newData) => {
|
||||
if (newData?.cpuModel) {
|
||||
// --- Watcher for caching CPU Model and OS Name ---
|
||||
// 现在监听 currentServerStatus
|
||||
watch(currentServerStatus, (newData) => {
|
||||
if (newData) {
|
||||
if (newData.cpuModel !== undefined && newData.cpuModel !== null && newData.cpuModel !== '') {
|
||||
cachedCpuModel.value = newData.cpuModel;
|
||||
}
|
||||
if (newData?.osName) {
|
||||
if (newData.osName !== undefined && newData.osName !== null && newData.osName !== '') {
|
||||
cachedOsName.value = newData.osName;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.activeSessionId,
|
||||
async (newId, oldId) => {
|
||||
if (newId !== oldId) {
|
||||
isSwitchingSession.value = true;
|
||||
await nextTick();
|
||||
isSwitchingSession.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
}, { immediate: true });
|
||||
|
||||
// --- 监听 activeSessionId 变化以处理会话切换状态 ---
|
||||
watch(() => props.activeSessionId, async (newId, oldId) => {
|
||||
if (newId !== oldId) {
|
||||
isSwitchingSession.value = true;
|
||||
await nextTick(); // 等待DOM更新(currentServerStatus已改变,displayPercent们会返回0)
|
||||
isSwitchingSession.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Computed properties for display ---
|
||||
const displayCpuModel = computed(() => {
|
||||
// 使用 currentServerStatus
|
||||
return (currentServerStatus.value?.cpuModel ?? cachedCpuModel.value) || t('statusMonitor.notAvailable');
|
||||
});
|
||||
|
||||
const displayOsName = computed(() => {
|
||||
// 使用 currentServerStatus
|
||||
return (currentServerStatus.value?.osName ?? cachedOsName.value) || t('statusMonitor.notAvailable');
|
||||
});
|
||||
|
||||
const formatBytesPerSecond = (bytes?: number): string => {
|
||||
if (bytes === undefined || bytes === null || Number.isNaN(bytes)) return t('statusMonitor.notAvailable');
|
||||
if (bytes < 1024) return `${bytes} ${t('statusMonitor.bytesPerSecond')}`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} ${t('statusMonitor.kiloBytesPerSecond')}`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} ${t('statusMonitor.megaBytesPerSecond')}`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} ${t('statusMonitor.gigaBytesPerSecond')}`;
|
||||
if (bytes === undefined || bytes === null || isNaN(bytes)) return t('statusMonitor.notAvailable');
|
||||
if (bytes < 1024) return `${bytes} ${t('statusMonitor.bytesPerSecond')}`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} ${t('statusMonitor.kiloBytesPerSecond')}`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} ${t('statusMonitor.megaBytesPerSecond')}`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} ${t('statusMonitor.gigaBytesPerSecond')}`;
|
||||
};
|
||||
|
||||
const formatBytes = (bytes?: number): string => {
|
||||
if (bytes === undefined || bytes === null || Number.isNaN(bytes)) return t('statusMonitor.notAvailable');
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} ${t('statusMonitor.megaBytes')}`;
|
||||
if (bytes < 1024 * 1024 * 1024 * 1024) {
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} ${t('statusMonitor.gigaBytes')}`;
|
||||
}
|
||||
return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(1)} TB`;
|
||||
if (bytes === undefined || bytes === null || isNaN(bytes)) return t('statusMonitor.notAvailable');
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} ${t('statusMonitor.megaBytes')}`;
|
||||
if (bytes < 1024 * 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} ${t('statusMonitor.gigaBytes')}`;
|
||||
return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(1)} TB`;
|
||||
};
|
||||
|
||||
const formatKbToGb = (kb?: number): string => {
|
||||
if (kb === undefined || kb === null) return t('statusMonitor.notAvailable');
|
||||
if (kb === 0) return `0.0 ${t('statusMonitor.gigaBytes')}`;
|
||||
const gb = kb / 1024 / 1024;
|
||||
return `${gb.toFixed(1)} ${t('statusMonitor.gigaBytes')}`;
|
||||
if (kb === undefined || kb === null) return t('statusMonitor.notAvailable');
|
||||
if (kb === 0) return `0.0 ${t('statusMonitor.gigaBytes')}`;
|
||||
const gb = kb / 1024 / 1024;
|
||||
return `${gb.toFixed(1)} ${t('statusMonitor.gigaBytes')}`;
|
||||
};
|
||||
|
||||
// 辅助函数,用于在需要时将 MB 格式化为 GB
|
||||
const formatMemorySize = (mb?: number): string => {
|
||||
if (mb === undefined || mb === null || Number.isNaN(mb)) return t('statusMonitor.notAvailable');
|
||||
if (mb < 1024) {
|
||||
const value = Number.isInteger(mb) ? mb : mb.toFixed(1);
|
||||
return `${value} ${t('statusMonitor.megaBytes')}`;
|
||||
}
|
||||
const gb = mb / 1024;
|
||||
return `${gb.toFixed(1)} ${t('statusMonitor.gigaBytes')}`;
|
||||
if (mb === undefined || mb === null || isNaN(mb)) return t('statusMonitor.notAvailable');
|
||||
if (mb < 1024) {
|
||||
const value = Number.isInteger(mb) ? mb : mb.toFixed(1);
|
||||
return `${value} ${t('statusMonitor.megaBytes')}`;
|
||||
} else {
|
||||
const gb = mb / 1024;
|
||||
return `${gb.toFixed(1)} ${t('statusMonitor.gigaBytes')}`;
|
||||
}
|
||||
};
|
||||
|
||||
const memDisplay = computed(() => {
|
||||
const data = currentServerStatus.value;
|
||||
if (!data || data.memUsed === undefined || data.memTotal === undefined) return t('statusMonitor.notAvailable');
|
||||
return `${formatMemorySize(data.memUsed)} / ${formatMemorySize(data.memTotal)}`;
|
||||
const data = currentServerStatus.value; // 使用 currentServerStatus
|
||||
if (!data || data.memUsed === undefined || data.memTotal === undefined) return t('statusMonitor.notAvailable');
|
||||
return `${formatMemorySize(data.memUsed)} / ${formatMemorySize(data.memTotal)}`;
|
||||
});
|
||||
|
||||
const diskDisplay = computed(() => {
|
||||
const data = currentServerStatus.value;
|
||||
if (!data || data.diskUsed === undefined || data.diskTotal === undefined) return t('statusMonitor.notAvailable');
|
||||
return `${formatKbToGb(data.diskUsed)} / ${formatKbToGb(data.diskTotal)}`;
|
||||
const data = currentServerStatus.value; // 使用 currentServerStatus
|
||||
if (!data || data.diskUsed === undefined || data.diskTotal === undefined) return t('statusMonitor.notAvailable');
|
||||
return `${formatKbToGb(data.diskUsed)} / ${formatKbToGb(data.diskTotal)}`;
|
||||
});
|
||||
|
||||
const swapDisplay = computed(() => {
|
||||
const data = currentServerStatus.value;
|
||||
const used = data?.swapUsed ?? 0;
|
||||
const total = data?.swapTotal ?? 0;
|
||||
if (total === 0) return t('statusMonitor.swapNotAvailable');
|
||||
return `${formatMemorySize(used)} / ${formatMemorySize(total)}`;
|
||||
const data = currentServerStatus.value; // 使用 currentServerStatus
|
||||
const used = data?.swapUsed ?? 0;
|
||||
const total = data?.swapTotal ?? 0;
|
||||
const percentVal = data?.swapPercent ?? 0;
|
||||
|
||||
// 仅当交换空间总量 > 0 时显示详细信息
|
||||
if (total === 0) {
|
||||
return t('statusMonitor.swapNotAvailable'); // 或更具体的消息
|
||||
}
|
||||
|
||||
return `${formatMemorySize(used)} / ${formatMemorySize(total)}`;
|
||||
});
|
||||
|
||||
const sessionIpAddress = computed(() => {
|
||||
const sessionState = currentSessionState.value;
|
||||
if (sessionState?.connectionId) {
|
||||
if (sessionState && sessionState.connectionId) {
|
||||
// 直接从 connectionsStore 的 connections 数组中查找
|
||||
const connectionIdAsNumber = parseInt(sessionState.connectionId, 10);
|
||||
if (Number.isNaN(connectionIdAsNumber)) return null;
|
||||
const connectionInfo = connectionsStore.connections.find((conn) => conn.id === connectionIdAsNumber);
|
||||
if (isNaN(connectionIdAsNumber)) {
|
||||
return null; // 如果 connectionId 不是有效的数字,则返回 null
|
||||
}
|
||||
const connectionInfo = connectionsStore.connections.find(conn => conn.id === connectionIdAsNumber);
|
||||
return connectionInfo?.host || null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const overviewStats = computed(() => {
|
||||
if (!currentServerStatus.value) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
label: t('statusMonitor.cpuLabel'),
|
||||
value: `${Math.round(displayCpuPercent.value)}%`,
|
||||
meta: displayCpuModel.value,
|
||||
color: '#3b82f6',
|
||||
},
|
||||
{
|
||||
label: t('statusMonitor.memoryLabel'),
|
||||
value: memDisplay.value,
|
||||
meta: `${Math.round(displayMemPercent.value)}%`,
|
||||
color: '#22c55e',
|
||||
},
|
||||
{
|
||||
label: t('statusMonitor.diskLabel'),
|
||||
value: diskDisplay.value,
|
||||
meta: `${Math.round(displayDiskPercent.value)}%`,
|
||||
color: '#a855f7',
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const copyIpToClipboard = async (ipAddress: string | null) => {
|
||||
if (!ipAddress) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(ipAddress);
|
||||
uiNotificationsStore.showSuccess(t('common.copied', '已复制'));
|
||||
uiNotificationsStore.showSuccess(t('common.copied', '已复制!'));
|
||||
} catch (err) {
|
||||
console.error('Failed to copy IP address: ', err);
|
||||
uiNotificationsStore.showError(t('statusMonitor.copyIpError', '复制 IP 失败'));
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="status-shell">
|
||||
<header class="status-shell__header">
|
||||
<div>
|
||||
<div class="status-shell__eyebrow">
|
||||
<el-tag round effect="light" type="success">
|
||||
{{ t('statusMonitor.title', '服务器状态') }}
|
||||
</el-tag>
|
||||
<span v-if="activeSessionId" class="status-shell__session">{{ activeSessionId }}</span>
|
||||
</div>
|
||||
<h3>{{ t('statusMonitor.title', '服务器状态') }}</h3>
|
||||
<p>{{ displayOsName }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="!activeSessionId" class="status-shell__empty">
|
||||
<el-empty :description="t('layout.noActiveSession.title', '没有活动的会话')">
|
||||
<template #image>
|
||||
<i class="fas fa-plug text-4xl text-text-secondary"></i>
|
||||
</template>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-else-if="currentStatusError"
|
||||
:title="`${t('statusMonitor.errorPrefix')} ${currentStatusError}`"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
|
||||
<div v-else-if="!currentServerStatus" class="status-shell__empty">
|
||||
<el-skeleton :rows="7" animated />
|
||||
</div>
|
||||
|
||||
<div v-else class="status-shell__body">
|
||||
<div class="control-stat-grid">
|
||||
<div v-for="stat in overviewStats" :key="stat.label" class="control-stat-card">
|
||||
<span class="control-stat-card__label">{{ stat.label }}</span>
|
||||
<span class="control-stat-card__value">{{ stat.value }}</span>
|
||||
<span class="control-stat-card__meta">{{ stat.meta }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="status-section">
|
||||
<template #header>
|
||||
<div class="status-section__title">{{ t('statusMonitor.title', '服务器状态') }}</div>
|
||||
</template>
|
||||
|
||||
<div class="status-row" v-if="statusMonitorShowIpBoolean && sessionIpAddress">
|
||||
<span>{{ t('statusMonitor.ipLabel', 'IP 地址') }}</span>
|
||||
<button class="status-link" @click="copyIpToClipboard(sessionIpAddress)">
|
||||
{{ sessionIpAddress }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="status-row">
|
||||
<span>{{ t('statusMonitor.cpuModelLabel') }}</span>
|
||||
<strong>{{ displayCpuModel }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="status-row">
|
||||
<span>{{ t('statusMonitor.osLabel') }}</span>
|
||||
<strong>{{ displayOsName }}</strong>
|
||||
</div>
|
||||
|
||||
<div class="status-metric">
|
||||
<div class="status-metric__head">
|
||||
<span>{{ t('statusMonitor.cpuLabel') }}</span>
|
||||
<strong>{{ Math.round(displayCpuPercent) }}%</strong>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="displayCpuPercent"
|
||||
:stroke-width="14"
|
||||
color="#3b82f6"
|
||||
:show-text="false"
|
||||
class="themed-progress"
|
||||
:class="{ 'no-transition': isSwitchingSession }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="status-metric">
|
||||
<div class="status-metric__head">
|
||||
<span>{{ t('statusMonitor.memoryLabel') }}</span>
|
||||
<strong>{{ memDisplay }}</strong>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="displayMemPercent"
|
||||
:stroke-width="14"
|
||||
color="#22c55e"
|
||||
:show-text="false"
|
||||
class="themed-progress"
|
||||
:class="{ 'no-transition': isSwitchingSession }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="status-metric">
|
||||
<div class="status-metric__head">
|
||||
<span>{{ t('statusMonitor.swapLabel') }}</span>
|
||||
<strong>{{ swapDisplay }}</strong>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="displaySwapPercent"
|
||||
:stroke-width="14"
|
||||
:color="(currentServerStatus?.swapPercent ?? 0) > 0 ? '#eab308' : '#94a3b8'"
|
||||
:show-text="false"
|
||||
class="themed-progress"
|
||||
:class="{ 'no-transition': isSwitchingSession }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="status-metric">
|
||||
<div class="status-metric__head">
|
||||
<span>{{ t('statusMonitor.diskLabel') }}</span>
|
||||
<strong>{{ diskDisplay }}</strong>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="displayDiskPercent"
|
||||
:stroke-width="14"
|
||||
color="#a855f7"
|
||||
:show-text="false"
|
||||
class="themed-progress"
|
||||
:class="{ 'no-transition': isSwitchingSession }"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="status-section">
|
||||
<template #header>
|
||||
<div class="status-section__title">{{ t('statusMonitor.networkLabel', '网络') }}</div>
|
||||
</template>
|
||||
|
||||
<div class="network-grid">
|
||||
<div class="network-card">
|
||||
<span class="network-card__label">
|
||||
<i class="fas fa-arrow-down"></i>
|
||||
{{ t('statusMonitor.networkLabel') }} / RX
|
||||
</span>
|
||||
<strong>{{ formatBytesPerSecond(currentServerStatus?.netRxRate) }}</strong>
|
||||
<small>{{ currentServerStatus?.netInterface || '--' }}</small>
|
||||
</div>
|
||||
|
||||
<div class="network-card">
|
||||
<span class="network-card__label">
|
||||
<i class="fas fa-arrow-up"></i>
|
||||
{{ t('statusMonitor.networkLabel') }} / TX
|
||||
</span>
|
||||
<strong>{{ formatBytesPerSecond(currentServerStatus?.netTxRate) }}</strong>
|
||||
<small>{{ currentServerStatus?.netInterface || '--' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="traffic-summary">
|
||||
<div class="traffic-summary__title">{{ t('statusMonitor.totalTrafficLabel', '开机累计流量') }}</div>
|
||||
<div class="traffic-summary__items">
|
||||
<div class="traffic-chip">
|
||||
<span><i class="fas fa-arrow-down"></i>{{ t('statusMonitor.downloadLabel', '下行') }}</span>
|
||||
<strong>{{ formatBytes(currentServerStatus?.netRxTotalBytes) }}</strong>
|
||||
</div>
|
||||
<div class="traffic-chip traffic-chip--upload">
|
||||
<span><i class="fas fa-arrow-up"></i>{{ t('statusMonitor.uploadLabel', '上行') }}</span>
|
||||
<strong>{{ formatBytes(currentServerStatus?.netTxTotalBytes) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="status-section status-section--chart">
|
||||
<template #header>
|
||||
<div class="status-section__title">{{ t('statusMonitor.cpuUsageTitle', 'CPU 使用率') }}</div>
|
||||
</template>
|
||||
<StatusCharts :server-status="currentServerStatus" :active-session-id="activeSessionId" />
|
||||
</el-card>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 1rem;
|
||||
border: 1px solid rgba(103, 124, 155, 0.18);
|
||||
border-radius: 26px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(243, 247, 252, 0.82));
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.status-shell__header h3 {
|
||||
margin: 0.8rem 0 0;
|
||||
font-family: var(--font-family-display);
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.status-shell__header p {
|
||||
margin: 0.55rem 0 0;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.status-shell__eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-shell__session {
|
||||
color: var(--text-color-tertiary);
|
||||
font-size: 0.74rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-shell__empty {
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.status-shell__body {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.status-section {
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.status-section__title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 0;
|
||||
border-bottom: 1px solid rgba(103, 124, 155, 0.12);
|
||||
}
|
||||
|
||||
.status-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.status-row span {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.status-row strong {
|
||||
color: var(--text-color);
|
||||
font-size: 0.86rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.status-link {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-metric {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.status-metric__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.55rem;
|
||||
}
|
||||
|
||||
.status-metric__head span {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.status-metric__head strong {
|
||||
color: var(--text-color);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.network-grid {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.network-card {
|
||||
padding: 1rem;
|
||||
border: 1px solid rgba(103, 124, 155, 0.14);
|
||||
border-radius: 18px;
|
||||
background: rgba(247, 250, 253, 0.9);
|
||||
}
|
||||
|
||||
.network-card__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
color: var(--text-color-tertiary);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.network-card strong {
|
||||
display: block;
|
||||
margin-top: 0.55rem;
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.network-card small {
|
||||
display: block;
|
||||
margin-top: 0.4rem;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.traffic-summary {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid rgba(103, 124, 155, 0.12);
|
||||
}
|
||||
|
||||
.traffic-summary__title {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.traffic-summary__items {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.traffic-chip {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.8rem 0.95rem;
|
||||
border-radius: 18px;
|
||||
background: rgba(24, 190, 120, 0.08);
|
||||
color: #15915e;
|
||||
}
|
||||
|
||||
.traffic-chip--upload {
|
||||
background: rgba(249, 115, 22, 0.08);
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.traffic-chip span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.traffic-chip strong {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.status-section--chart :deep(.el-card__body) {
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
::v-deep(.el-progress-bar__outer) {
|
||||
background-color: rgba(226, 233, 244, 0.86) !important;
|
||||
background-color: var(--header-bg-color) !important;
|
||||
}
|
||||
|
||||
::v-deep(.themed-progress .el-progress-bar__inner) {
|
||||
transition: width 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
::v-deep(.themed-progress.no-transition .el-progress-bar__inner) {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.network-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
::v-deep(.el-progress-bar__innerText) {
|
||||
font-size: 10px;
|
||||
position: relative;
|
||||
top: -0.5px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -743,7 +743,7 @@ watchEffect(() => {
|
||||
.terminal-inner-container :deep(.xterm),
|
||||
.terminal-inner-container :deep(.xterm-screen),
|
||||
.terminal-inner-container :deep(.xterm-viewport) {
|
||||
cursor: text !important;
|
||||
cursor: default !important;
|
||||
}
|
||||
|
||||
.terminal-inner-container :deep(.xterm .xterm-cursor-pointer) {
|
||||
|
||||
@@ -49,40 +49,31 @@ const workbenchTabs = computed(() => [
|
||||
{
|
||||
id: 'quickCommands' as const,
|
||||
label: t('workspace.workbench.tabs.quickCommands', '快捷指令'),
|
||||
shortLabel: t('workspace.workbench.tabs.quickCommands', '快捷指令'),
|
||||
icon: 'fas fa-bolt',
|
||||
hint: t('workspace.workbench.quickCommandsHint', '默认面板,用于常用命令与预置脚本。'),
|
||||
},
|
||||
{
|
||||
id: 'files' as const,
|
||||
label: t('workspace.workbench.tabs.files', '文件'),
|
||||
shortLabel: t('workspace.workbench.tabs.files', '文件'),
|
||||
icon: 'fas fa-folder-tree',
|
||||
hint: t('workspace.workbench.filesHint', '浏览远程目录、拖放文件与操作资源。'),
|
||||
icon: 'fas fa-folder-open',
|
||||
},
|
||||
{
|
||||
id: 'history' as const,
|
||||
label: t('workspace.workbench.tabs.history', '历史命令'),
|
||||
shortLabel: t('workspace.workbench.tabs.history', '历史命令'),
|
||||
icon: 'fas fa-clock-rotate-left',
|
||||
hint: t('workspace.workbench.historyHint', '回放最近命令并快速重发到当前会话。'),
|
||||
icon: 'fas fa-history',
|
||||
},
|
||||
{
|
||||
id: 'editor' as const,
|
||||
label: t('workspace.workbench.tabs.editor', '编辑器'),
|
||||
shortLabel: t('workspace.workbench.tabs.editor', '编辑器'),
|
||||
icon: 'fas fa-pen-ruler',
|
||||
hint: t('workspace.workbench.editorHint', '在工作台里直接查看并编辑当前打开的文件。'),
|
||||
icon: 'fas fa-pen-to-square',
|
||||
},
|
||||
]);
|
||||
|
||||
const activeSessionName = computed(() => {
|
||||
if (!props.sessionId) return null;
|
||||
return sessions.value.get(props.sessionId)?.connectionName ?? props.sessionId;
|
||||
});
|
||||
if (!props.sessionId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeWorkbenchMeta = computed(() => {
|
||||
return workbenchTabs.value.find((tab) => tab.id === activeWorkbenchTab.value) ?? workbenchTabs.value[0];
|
||||
return sessions.value.get(props.sessionId)?.connectionName ?? props.sessionId;
|
||||
});
|
||||
|
||||
const hasFileManagerContext = computed(() => {
|
||||
@@ -106,235 +97,134 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="workbench-shell">
|
||||
<header class="workbench-shell__header">
|
||||
<div class="workbench-shell__copy">
|
||||
<div class="workbench-shell__eyebrow">
|
||||
<el-tag round effect="light" type="primary">
|
||||
{{ t('workspace.workbench.label', '工作台') }}
|
||||
</el-tag>
|
||||
<span class="workbench-shell__session">
|
||||
<div class="flex h-full min-h-0 flex-col overflow-hidden bg-background">
|
||||
<div class="border-b border-border bg-header px-3 py-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-foreground">
|
||||
{{ t('workspace.workbench.title', 'Workbench') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-text-secondary">
|
||||
{{ activeSessionName || t('workspace.workbench.noSession', '未激活会话') }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<h3>{{ t('workspace.workbench.title', 'Workbench') }}</h3>
|
||||
<p>{{ activeWorkbenchMeta.hint }}</p>
|
||||
<span class="rounded-full border border-border bg-background px-2 py-1 text-[11px] font-medium text-text-secondary">
|
||||
{{ t('workspace.workbench.label', '工作台') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-3 grid grid-cols-2 gap-2 xl:grid-cols-4">
|
||||
<button
|
||||
v-for="tab in workbenchTabs"
|
||||
:key="tab.id"
|
||||
type="button"
|
||||
@click="activeWorkbenchTab = tab.id"
|
||||
:class="[
|
||||
'inline-flex items-center justify-center gap-2 rounded-lg border px-3 py-2 text-xs font-medium transition-colors',
|
||||
activeWorkbenchTab === tab.id
|
||||
? 'border-primary bg-primary text-white shadow-sm'
|
||||
: 'border-border bg-background text-text-secondary hover:border-primary/40 hover:text-foreground'
|
||||
]"
|
||||
>
|
||||
<i :class="tab.icon"></i>
|
||||
<span>{{ tab.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative flex-1 min-h-0 overflow-hidden bg-background">
|
||||
<div v-show="activeWorkbenchTab === 'quickCommands'" class="absolute inset-0 min-h-0 workbench-quick-commands">
|
||||
<QuickCommandsView />
|
||||
</div>
|
||||
|
||||
<div class="workbench-shell__chips">
|
||||
<div class="workbench-chip">
|
||||
<span>{{ t('workspace.workbench.tabs.quickCommands', '快捷指令') }}</span>
|
||||
<strong>Default</strong>
|
||||
</div>
|
||||
<div class="workbench-chip">
|
||||
<span>{{ t('workspace.workbench.tabs.editor', '编辑器') }}</span>
|
||||
<strong>{{ tabs.length }}</strong>
|
||||
<div v-show="activeWorkbenchTab === 'files'" class="absolute inset-0 min-h-0">
|
||||
<FileManager
|
||||
v-if="hasFileManagerContext"
|
||||
:session-id="fileManagerSessionId"
|
||||
:instance-id="fileManagerInstanceId"
|
||||
:db-connection-id="fileManagerConnectionId"
|
||||
:ws-deps="fileManagerWsDeps"
|
||||
class="h-full"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full flex-col items-center justify-center gap-3 px-6 text-center text-text-secondary"
|
||||
>
|
||||
<i class="fas fa-plug text-3xl"></i>
|
||||
<div class="text-sm font-medium">
|
||||
{{ t('layout.noActiveSession.title', '没有活动的会话') }}
|
||||
</div>
|
||||
<div class="text-xs">
|
||||
{{ t('workspace.workbench.fileManagerHint', '激活一个 SSH 会话后即可浏览远程文件。') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<el-tabs v-model="activeWorkbenchTab" class="workbench-tabs" stretch>
|
||||
<el-tab-pane v-for="tab in workbenchTabs" :key="tab.id" :name="tab.id">
|
||||
<template #label>
|
||||
<span class="workbench-tab-label">
|
||||
<i :class="tab.icon"></i>
|
||||
<span>{{ tab.shortLabel }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<div v-show="activeWorkbenchTab === 'history'" class="absolute inset-0 min-h-0">
|
||||
<CommandHistoryView />
|
||||
</div>
|
||||
|
||||
<div class="workbench-shell__panel">
|
||||
<div v-show="activeWorkbenchTab === 'quickCommands'" class="workbench-panel workbench-panel--quick">
|
||||
<QuickCommandsView />
|
||||
</div>
|
||||
|
||||
<div v-show="activeWorkbenchTab === 'files'" class="workbench-panel">
|
||||
<FileManager
|
||||
v-if="hasFileManagerContext"
|
||||
:session-id="fileManagerSessionId"
|
||||
:instance-id="fileManagerInstanceId"
|
||||
:db-connection-id="fileManagerConnectionId"
|
||||
:ws-deps="fileManagerWsDeps"
|
||||
class="h-full"
|
||||
/>
|
||||
<div v-else class="workbench-empty">
|
||||
<el-empty :description="t('layout.noActiveSession.title', '没有活动的会话')">
|
||||
<template #image>
|
||||
<i class="fas fa-folder-tree text-4xl text-text-secondary"></i>
|
||||
</template>
|
||||
<template #description>
|
||||
<div class="text-sm text-text-secondary">
|
||||
{{ t('workspace.workbench.fileManagerHint', '激活一个 SSH 会话后即可浏览远程文件。') }}
|
||||
</div>
|
||||
</template>
|
||||
</el-empty>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="activeWorkbenchTab === 'history'" class="workbench-panel">
|
||||
<CommandHistoryView />
|
||||
</div>
|
||||
|
||||
<div v-show="activeWorkbenchTab === 'editor'" class="workbench-panel">
|
||||
<FileEditorContainer :tabs="tabs" :active-tab-id="activeTabId" :session-id="sessionId" />
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</section>
|
||||
<div v-show="activeWorkbenchTab === 'editor'" class="absolute inset-0 min-h-0">
|
||||
<FileEditorContainer
|
||||
:tabs="tabs"
|
||||
:active-tab-id="activeTabId"
|
||||
:session-id="sessionId"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workbench-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(103, 124, 155, 0.18);
|
||||
border-radius: 26px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(243, 247, 252, 0.82));
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.workbench-shell__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 1.1rem 1.1rem 0.8rem;
|
||||
}
|
||||
|
||||
.workbench-shell__copy h3 {
|
||||
margin: 0.8rem 0 0;
|
||||
font-family: var(--font-family-display);
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.workbench-shell__copy p {
|
||||
margin: 0.65rem 0 0;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.workbench-shell__eyebrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.workbench-shell__session {
|
||||
color: var(--text-color-tertiary);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.workbench-shell__chips {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.workbench-chip {
|
||||
min-width: 92px;
|
||||
padding: 0.7rem 0.85rem;
|
||||
border: 1px solid rgba(103, 124, 155, 0.14);
|
||||
border-radius: 18px;
|
||||
background: rgba(247, 250, 253, 0.9);
|
||||
}
|
||||
|
||||
.workbench-chip span {
|
||||
display: block;
|
||||
color: var(--text-color-tertiary);
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.workbench-chip strong {
|
||||
display: block;
|
||||
margin-top: 0.35rem;
|
||||
color: var(--text-color);
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.workbench-tabs {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 0.85rem 0.85rem;
|
||||
}
|
||||
|
||||
.workbench-tabs :deep(.el-tabs__header) {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.workbench-tabs :deep(.el-tabs__nav-wrap) {
|
||||
padding: 0.35rem;
|
||||
border-radius: 18px;
|
||||
background: rgba(236, 242, 249, 0.78);
|
||||
}
|
||||
|
||||
.workbench-tabs :deep(.el-tabs__content),
|
||||
.workbench-tabs :deep(.el-tab-pane) {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.workbench-tab-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.workbench-shell__panel {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workbench-panel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(103, 124, 155, 0.14);
|
||||
border-radius: 22px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.workbench-panel--quick {
|
||||
.workbench-quick-commands {
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(60, 105, 231, 0.14), transparent 24%),
|
||||
linear-gradient(180deg, rgba(248, 250, 255, 0.96), rgba(239, 245, 252, 0.92));
|
||||
linear-gradient(180deg, rgba(15, 17, 22, 0.98) 0%, rgba(12, 14, 18, 1) 100%);
|
||||
}
|
||||
|
||||
.workbench-empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.workbench-panel--quick :deep(> div),
|
||||
.workbench-panel--quick :deep(> div > div) {
|
||||
.workbench-quick-commands :deep(> div),
|
||||
.workbench-quick-commands :deep(> div > div) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.workbench-shell__header {
|
||||
flex-direction: column;
|
||||
}
|
||||
.workbench-quick-commands :deep(input) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
color: #f5f7fa;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.workbench-shell__chips {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.workbench-quick-commands :deep(input::placeholder) {
|
||||
color: rgba(226, 232, 240, 0.55);
|
||||
}
|
||||
|
||||
.workbench-quick-commands :deep(button) {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.workbench-quick-commands :deep([data-command-id]) {
|
||||
position: relative;
|
||||
border-radius: 10px;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.workbench-quick-commands :deep([data-command-id]::before) {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0.2rem;
|
||||
top: 0.2rem;
|
||||
bottom: 0.2rem;
|
||||
width: 1px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.workbench-quick-commands :deep([data-command-id]:hover) {
|
||||
background: rgba(139, 92, 246, 0.14);
|
||||
}
|
||||
|
||||
.workbench-quick-commands :deep([data-command-id].bg-primary\/20) {
|
||||
background: linear-gradient(90deg, rgba(139, 92, 246, 0.3), rgba(139, 92, 246, 0.18));
|
||||
}
|
||||
|
||||
.workbench-quick-commands :deep(.font-semibold.flex.items-center) {
|
||||
color: #f8fafc;
|
||||
}
|
||||
</style>
|
||||
|
||||
+155
-292
@@ -1,24 +1,23 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Tailwind Theme Variables Mapping */
|
||||
@theme inline {
|
||||
--color-background: var(--app-bg-color);
|
||||
--color-foreground: var(--text-color);
|
||||
--color-app: var(--app-bg-color);
|
||||
--color-card: var(--card-bg-color);
|
||||
--color-card-foreground: var(--card-foreground-color);
|
||||
--color-muted: var(--muted-bg-color);
|
||||
--color-muted-foreground: var(--muted-foreground-color);
|
||||
--color-text-default: var(--text-color);
|
||||
/* Base Colors */
|
||||
--color-background: var(--app-bg-color); /* More generic name */
|
||||
--color-foreground: var(--text-color); /* More generic name */
|
||||
--color-app: var(--app-bg-color); /* Keep specific if needed */
|
||||
--color-text-default: var(--text-color); /* Keep specific if needed */
|
||||
--color-text-secondary: var(--text-color-secondary);
|
||||
--color-text-alt: var(--text-color-tertiary);
|
||||
--color-border: var(--border-color);
|
||||
--color-border: var(--border-color); /* Simplified name */
|
||||
--color-border-default: var(--border-color); /* Keep specific if needed */
|
||||
--color-link: var(--link-color);
|
||||
--color-link-hover: var(--link-hover-color);
|
||||
--color-link-active: var(--link-active-color);
|
||||
--color-primary: var(--primary-color);
|
||||
--color-primary-dark: var(--primary-dark-color);
|
||||
--color-link-active-bg: var(--link-active-bg-color);
|
||||
--color-nav-active-bg: var(--nav-item-active-bg-color);
|
||||
--color-link-active: var(--link-active-color); /* Also used as primary/theme color */
|
||||
--color-primary: var(--link-active-color); /* Map primary to active link color */
|
||||
--color-link-active-bg: var(--link-active-bg-color); /* Map active link background */
|
||||
--color-nav-active-bg: var(--nav-item-active-bg-color); /* Map specific nav active background */
|
||||
|
||||
/* Component Colors */
|
||||
--color-header: var(--header-bg-color);
|
||||
--color-footer: var(--footer-bg-color);
|
||||
--color-button: var(--button-bg-color);
|
||||
@@ -28,336 +27,200 @@
|
||||
--color-icon-hover: var(--icon-hover-color);
|
||||
--color-split-line: var(--split-line-color);
|
||||
--color-split-line-hover: var(--split-line-hover-color);
|
||||
--color-input: var(--input-bg-color);
|
||||
--color-input-focus-border: var(--input-focus-border-color);
|
||||
--color-overlay: var(--overlay-bg-color);
|
||||
--color-success: var(--success-color);
|
||||
--color-warning: var(--warning-color);
|
||||
--color-error: var(--error-color);
|
||||
--color-success: var(--color-success);
|
||||
--color-warning: var(--color-warning);
|
||||
--color-error: var(--color-error);
|
||||
--color-success-text: var(--color-success-text);
|
||||
--color-warning-text: var(--color-warning-text);
|
||||
--color-error-text: var(--color-error-text);
|
||||
}
|
||||
|
||||
/* 全局样式和 CSS 变量定义 */
|
||||
:root {
|
||||
--app-bg-color: #edf2f8;
|
||||
--app-bg-gradient: radial-gradient(circle at top left, rgba(84, 125, 255, 0.18), transparent 34%),
|
||||
radial-gradient(circle at right 16%, rgba(0, 170, 170, 0.14), transparent 26%),
|
||||
linear-gradient(180deg, #f6f8fc 0%, #ecf1f7 52%, #e7edf6 100%);
|
||||
--shell-surface-color: rgba(255, 255, 255, 0.56);
|
||||
--card-bg-color: rgba(255, 255, 255, 0.84);
|
||||
--card-foreground-color: #142033;
|
||||
--muted-bg-color: #e9eef6;
|
||||
--muted-foreground-color: #5a6b84;
|
||||
--text-color: #152338;
|
||||
--text-color-secondary: #607089;
|
||||
--text-color-tertiary: #7f8da3;
|
||||
--border-color: rgba(103, 124, 155, 0.24);
|
||||
--border-strong-color: rgba(103, 124, 155, 0.36);
|
||||
--link-color: #355fa8;
|
||||
--link-hover-color: #214d90;
|
||||
--link-active-color: #3c69e7;
|
||||
--primary-color: #3c69e7;
|
||||
--primary-dark-color: #2746b8;
|
||||
--primary-soft-color: rgba(60, 105, 231, 0.12);
|
||||
--link-active-bg-color: rgba(60, 105, 231, 0.12);
|
||||
--nav-item-active-bg-color: rgba(60, 105, 231, 0.12);
|
||||
--header-bg-color: rgba(255, 255, 255, 0.74);
|
||||
--footer-bg-color: rgba(255, 255, 255, 0.78);
|
||||
--button-bg-color: #3c69e7;
|
||||
--button-text-color: #ffffff;
|
||||
--button-hover-bg-color: #2746b8;
|
||||
--icon-color: #62748e;
|
||||
--icon-hover-color: #1d4f91;
|
||||
--split-line-color: rgba(126, 143, 168, 0.22);
|
||||
--split-line-hover-color: rgba(60, 105, 231, 0.42);
|
||||
--input-bg-color: rgba(245, 248, 252, 0.9);
|
||||
--input-focus-border-color: #3c69e7;
|
||||
--input-focus-glow-rgb: 60, 105, 231;
|
||||
--overlay-bg-color: rgba(12, 20, 32, 0.58);
|
||||
--success-color: #22a06b;
|
||||
--warning-color: #d99b24;
|
||||
--error-color: #d04b4b;
|
||||
--success-text-color: #ffffff;
|
||||
--warning-text-color: #1d1d1d;
|
||||
--error-text-color: #ffffff;
|
||||
--shadow-soft: 0 24px 60px rgba(31, 48, 84, 0.14);
|
||||
--shadow-card: 0 18px 40px rgba(24, 38, 67, 0.1);
|
||||
--shadow-inset: inset 0 1px 0 rgba(255, 255, 255, 0.72);
|
||||
--grid-line-color: rgba(116, 136, 167, 0.08);
|
||||
--font-family-sans-serif: "IBM Plex Sans", "Noto Sans SC", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
--font-family-display: "Space Grotesk", "IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif;
|
||||
--font-family-mono: "IBM Plex Mono", "JetBrains Mono", "Cascadia Code", monospace;
|
||||
--base-padding: 1rem;
|
||||
--base-margin: 0.5rem;
|
||||
--el-font-family: var(--font-family-sans-serif);
|
||||
--el-color-primary: var(--primary-color);
|
||||
--el-color-primary-light-3: #6789f0;
|
||||
--el-color-primary-light-5: #8ca5f5;
|
||||
--el-color-primary-light-7: #b3c3fa;
|
||||
--el-color-primary-light-8: #cad8fc;
|
||||
--el-color-primary-light-9: #e3ebff;
|
||||
--el-color-primary-dark-2: var(--primary-dark-color);
|
||||
--el-bg-color: rgba(255, 255, 255, 0.9);
|
||||
--el-bg-color-page: transparent;
|
||||
--el-bg-color-overlay: rgba(255, 255, 255, 0.96);
|
||||
--el-text-color-primary: var(--text-color);
|
||||
--el-text-color-regular: var(--text-color-secondary);
|
||||
--el-text-color-secondary: var(--text-color-tertiary);
|
||||
--el-border-color: rgba(103, 124, 155, 0.24);
|
||||
--el-border-color-light: rgba(103, 124, 155, 0.16);
|
||||
--el-border-color-lighter: rgba(103, 124, 155, 0.12);
|
||||
--el-border-radius-base: 16px;
|
||||
--el-border-radius-small: 12px;
|
||||
--el-box-shadow-light: var(--shadow-card);
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
min-height: 100%;
|
||||
/* 基础颜色 */
|
||||
--app-bg-color: #ffffff; /* 应用背景色 */
|
||||
--text-color: #333333; /* 主要文字颜色 */
|
||||
--text-color-secondary: #666666; /* 次要文字颜色 */
|
||||
--border-color: #cccccc; /* 边框颜色 */
|
||||
--link-color: #333; /* 链接颜色 */
|
||||
--link-hover-color: #0056b3; /* 链接悬停颜色 */
|
||||
--link-active-color: #007bff; /* 激活链接/主题色 */
|
||||
--link-active-bg-color: #e0e0ff; /* 激活链接背景色 (类似 indigo-50) */
|
||||
--nav-item-active-bg-color: var(--link-active-bg-color); /* 导航选中项背景色, 默认同激活链接背景 */
|
||||
|
||||
/* 组件颜色 */
|
||||
--header-bg-color: #f0f0f0; /* 头部背景色 */
|
||||
--footer-bg-color: #f0f0f0; /* 底部背景色 */
|
||||
--button-bg-color: #007bff; /* 按钮背景色 */
|
||||
--button-text-color: #ffffff; /* 按钮文字颜色 */
|
||||
--button-hover-bg-color: #0056b3;/* 按钮悬停背景色 */
|
||||
--icon-color: var(--text-color-secondary); /* 图标颜色 */
|
||||
--icon-hover-color: var(--link-hover-color); /* 图标悬停颜色 */
|
||||
--split-line-color: var(--border-color); /* 分割线颜色 */
|
||||
--split-line-hover-color: var(--border-color); /* 分割线悬停颜色 */
|
||||
--input-focus-border-color: var(--link-active-color); /* 输入框聚焦边框颜色 */
|
||||
--input-focus-glow: var(--link-active-color); /* 输入框聚焦光晕值 */
|
||||
--overlay-bg-color: rgba(0, 0, 0, 0.6); /* Added Overlay Background Color */
|
||||
|
||||
/* Status Colors */
|
||||
--color-success: #28a745; /* Green */
|
||||
--color-warning: #ffc107; /* Yellow */
|
||||
--color-error: #dc3545; /* Red */
|
||||
--color-success-text: #ffffff; /* White text for green bg */
|
||||
--color-warning-text: #212529; /* Dark text for yellow bg */
|
||||
--color-error-text: #ffffff; /* White text for red bg */
|
||||
|
||||
/* 字体 */
|
||||
--font-family-sans-serif: sans-serif; /* 默认字体 */
|
||||
|
||||
/* 其他 */
|
||||
--base-padding: 1rem; /* 基础内边距 */
|
||||
--base-margin: 0.5rem; /* 基础外边距 */
|
||||
}
|
||||
|
||||
/* 应用基础样式 */
|
||||
body {
|
||||
margin: 0;
|
||||
margin: 0; /* 移除默认 body margin */
|
||||
font-family: var(--font-family-sans-serif);
|
||||
background-color: var(--app-bg-color);
|
||||
color: var(--text-color);
|
||||
background: var(--app-bg-gradient);
|
||||
background-attachment: fixed;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(var(--grid-line-color) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--grid-line-color) 1px, transparent 1px);
|
||||
background-size: 28px 28px;
|
||||
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.36), transparent 82%);
|
||||
line-height: 1.6; /* 改善可读性 */
|
||||
}
|
||||
|
||||
/* 全局链接样式 */
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
/* color: var(--link-color); */ /* 注释掉全局 a 标签的颜色设置,让 Tailwind 类生效 */
|
||||
text-decoration: none; /* 移除下划线 */
|
||||
}
|
||||
|
||||
i,
|
||||
.fas,
|
||||
.far,
|
||||
.fab {
|
||||
color: inherit;
|
||||
/* Removed global a:hover underline rule to avoid conflicts with Tailwind utilities */
|
||||
|
||||
/* 全局图标样式 */
|
||||
i, .fas, .far, .fab { /* 根据你使用的图标库调整选择器 */
|
||||
color: var(--icon-color);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
a:hover i, a:hover .fas, a:hover .far, a:hover .fab, /* 链接内的图标 */
|
||||
button:hover i, button:hover .fas, button:hover .far, button:hover .fab, /* 按钮内的图标 */
|
||||
.icon-interactive:hover i, .icon-interactive:hover .fas, .icon-interactive:hover .far, .icon-interactive:hover .fab { /* 可交互图标容器 */
|
||||
color: var(--icon-hover-color);
|
||||
}
|
||||
|
||||
button:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus,
|
||||
select:focus {
|
||||
border-color: var(--input-focus-border-color) !important;
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 3px rgba(var(--input-focus-glow-rgb), 0.18) !important;
|
||||
}
|
||||
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
/* 全局分割线样式 */
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid rgba(103, 124, 155, 0.18);
|
||||
border-top: 1px solid var(--divider-color);
|
||||
margin: var(--base-margin) 0;
|
||||
}
|
||||
|
||||
.xterm {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
border: 1px solid rgba(103, 124, 155, 0.18);
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(246, 249, 253, 0.8));
|
||||
box-shadow: var(--shadow-card);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.control-panel--muted {
|
||||
background: linear-gradient(180deg, rgba(243, 247, 252, 0.82), rgba(236, 242, 248, 0.74));
|
||||
}
|
||||
|
||||
.control-toolbar {
|
||||
border: 1px solid rgba(103, 124, 155, 0.14);
|
||||
border-radius: 18px;
|
||||
background: rgba(247, 250, 253, 0.78);
|
||||
box-shadow: var(--shadow-inset);
|
||||
}
|
||||
|
||||
.control-stat-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
|
||||
}
|
||||
|
||||
.control-stat-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(103, 124, 155, 0.14);
|
||||
border-radius: 20px;
|
||||
background: linear-gradient(180deg, rgba(250, 252, 255, 0.9), rgba(241, 246, 252, 0.78));
|
||||
box-shadow: var(--shadow-inset);
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
|
||||
.control-stat-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: linear-gradient(90deg, rgba(60, 105, 231, 0.72), rgba(16, 185, 129, 0.48));
|
||||
}
|
||||
|
||||
.control-stat-card__label {
|
||||
display: block;
|
||||
color: var(--text-color-tertiary);
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.control-stat-card__value {
|
||||
margin-top: 0.65rem;
|
||||
display: block;
|
||||
color: var(--text-color);
|
||||
font-family: var(--font-family-display);
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.control-stat-card__meta {
|
||||
margin-top: 0.45rem;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.control-empty {
|
||||
padding: 2.6rem 1.4rem;
|
||||
border: 1px dashed rgba(103, 124, 155, 0.3);
|
||||
border-radius: 20px;
|
||||
background: rgba(246, 249, 253, 0.8);
|
||||
|
||||
/* 可以添加更多全局样式规则 */
|
||||
|
||||
/* 为 xterm 终端添加内边距 */
|
||||
|
||||
.xterm{
|
||||
padding: 10px 10px 10px 10px;
|
||||
|
||||
}
|
||||
|
||||
/* 为历史记录和快捷命令列表设置字体 */
|
||||
/* 注意:这里的选择器可能需要根据实际组件结构调整 */
|
||||
.command-history-item,
|
||||
.quick-command-item,
|
||||
.quick-command-item { /* 假设这些是列表项的类名 */
|
||||
font-family: var(--font-family-sans-serif);
|
||||
}
|
||||
|
||||
/* 如果是 Element Plus 的 Table 组件 */
|
||||
.el-table .cell {
|
||||
font-family: var(--font-family-sans-serif);
|
||||
}
|
||||
|
||||
.el-card {
|
||||
border-color: rgba(103, 124, 155, 0.18);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.el-card__body {
|
||||
padding: 1.15rem 1.25rem;
|
||||
}
|
||||
|
||||
.el-input__wrapper,
|
||||
.el-select__wrapper,
|
||||
.el-textarea__inner {
|
||||
background: rgba(245, 248, 252, 0.92);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
.el-button {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.el-button--primary {
|
||||
box-shadow: 0 12px 24px rgba(60, 105, 231, 0.2);
|
||||
}
|
||||
|
||||
.el-button.is-plain {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.el-tabs__nav-wrap::after {
|
||||
background-color: rgba(103, 124, 155, 0.14);
|
||||
}
|
||||
|
||||
.el-tabs__item {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.el-table {
|
||||
--el-table-border-color: rgba(103, 124, 155, 0.14);
|
||||
--el-table-header-bg-color: rgba(243, 247, 252, 0.88);
|
||||
--el-table-tr-bg-color: transparent;
|
||||
--el-table-row-hover-bg-color: rgba(60, 105, 231, 0.05);
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Override splitpanes default theme pane background */
|
||||
.splitpanes.default-theme .splitpanes__pane {
|
||||
background-color: transparent !important;
|
||||
background-color: var(--app-bg-color) !important;
|
||||
}
|
||||
|
||||
/* Style the splitpane splitter */
|
||||
.splitpanes.default-theme .splitpanes__splitter {
|
||||
background-color: transparent !important;
|
||||
border-left: 1px solid rgba(103, 124, 155, 0.18);
|
||||
border-right: 1px solid rgba(103, 124, 155, 0.18);
|
||||
background-color: var(--app-bg-color) !important; /* Use important to ensure override */
|
||||
border-left: 1px solid var(--border-color); /* Add a subtle border */
|
||||
border-right: 1px solid var(--border-color);
|
||||
box-sizing: border-box;
|
||||
transition: background-color 0.2s ease;
|
||||
transition: background-color 0.2s ease; /* Add transition for hover effect */
|
||||
}
|
||||
|
||||
.splitpanes.default-theme .splitpanes__splitter:hover {
|
||||
background-color: rgba(60, 105, 231, 0.16) !important;
|
||||
background-color: var(--link-active-color) !important; /* Highlight on hover, keep important */
|
||||
}
|
||||
|
||||
.splitpanes--vertical > .splitpanes__splitter {
|
||||
width: 8px;
|
||||
width: 7px; /* Adjust width as needed */
|
||||
border-top: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.splitpanes--horizontal > .splitpanes__splitter {
|
||||
height: 8px;
|
||||
border-top: 1px solid rgba(103, 124, 155, 0.18);
|
||||
border-bottom: 1px solid rgba(103, 124, 155, 0.18);
|
||||
height: 7px; /* Adjust height as needed */
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Style scrollbars */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
width: 8px; /* Width of vertical scrollbar */
|
||||
height: 8px; /* Height of horizontal scrollbar */
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
border-radius: 999px;
|
||||
background: var(--app-bg-color); /* Scrollbar track background */
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(104, 123, 152, 0.5);
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
background-color: var(--border-color); /* Scrollbar handle color */
|
||||
border-radius: 4px;
|
||||
border: 2px solid var(--app-bg-color); /* Creates padding around thumb */
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(61, 84, 118, 0.66);
|
||||
background-color: var(--text-color-secondary); /* Scrollbar handle hover color */
|
||||
}
|
||||
|
||||
::v-deep(.el-progress-bar__outer) {
|
||||
background-color: rgba(226, 233, 244, 0.86) !important;
|
||||
/* Input focus styles */
|
||||
input:focus, textarea:focus, select:focus {
|
||||
border-color: var(--input-focus-border-color) !important; /* Use new variable, !important might be needed depending on specificity */
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 3px rgba(var(--input-focus-glow-rgb), 0.2) !important; /* Use new variable, !important might be needed */
|
||||
}
|
||||
|
||||
/* Ensure icons inside primary buttons are white */
|
||||
button.bg-primary i,
|
||||
button.bg-primary .fas,
|
||||
button.bg-primary .far,
|
||||
button.bg-primary .fab {
|
||||
color: white !important; /* Force white color */
|
||||
}
|
||||
|
||||
/* Optional: Keep icon white even on hover for primary buttons */
|
||||
button.bg-primary:hover i,
|
||||
button.bg-primary:hover .fas,
|
||||
button.bg-primary:hover .far,
|
||||
button.bg-primary:hover .fab {
|
||||
color: white !important; /* Keep white on hover */
|
||||
}
|
||||
|
||||
/* 移除按钮的聚焦光圈 */
|
||||
button:focus {
|
||||
outline: none !important;
|
||||
box-shadow: none !important; /* 同时移除 box-shadow 以防其被用于聚焦指示 */
|
||||
}
|
||||
|
||||
/* 针对使用 :focus-visible 的浏览器 */
|
||||
button:focus-visible {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
/* 当鼠标悬停在按钮上时,鼠标指针变为手型 */
|
||||
button:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1,203 +1,228 @@
|
||||
<template>
|
||||
<div class="p-4 bg-background text-foreground"> <!-- Outer container with padding -->
|
||||
<div class="max-w-7xl mx-auto"> <!-- Inner container for max-width (slightly wider for table) and centering -->
|
||||
<h1 class="text-xl font-semibold text-foreground mb-4 pb-2 border-b border-border"> <!-- Title styling -->
|
||||
{{ $t('auditLog.title') }}
|
||||
</h1>
|
||||
|
||||
<!-- Filtering Controls -->
|
||||
<div class="flex flex-wrap items-center gap-4 mb-4 p-4 border border-border rounded-lg bg-header/50">
|
||||
<div class="flex-grow min-w-[200px]">
|
||||
<label for="search-term" class="block text-sm font-medium text-text-secondary mb-1">{{ $t('common.search') }}</label>
|
||||
<input type="text" id="search-term" v-model="searchTerm" :placeholder="$t('auditLog.searchPlaceholder')"
|
||||
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary text-sm">
|
||||
</div>
|
||||
<div class="flex-grow min-w-[200px]">
|
||||
<label for="action-type" class="block text-sm font-medium text-text-secondary mb-1">{{ $t('auditLog.table.actionType') }}</label>
|
||||
<select id="action-type" v-model="selectedActionType"
|
||||
class="w-full px-3 py-2 border border-border rounded-md shadow-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary appearance-none bg-no-repeat bg-right pr-8 text-sm"
|
||||
style="background-image: url('data:image/svg+xml,%3csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 16 16\'%3e%3cpath fill=\'none\' stroke=\'%236c757d\' stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M2 5l6 6 6-6\'/%3e%3c/svg%3e'); background-position: right 0.75rem center; background-size: 16px 12px;">
|
||||
<option value="">{{ $t('common.all') }}</option>
|
||||
<option v-for="type in allActionTypes" :key="type" :value="type">{{ translateActionType(type) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="self-end">
|
||||
<button @click="applyFilters" class="px-4 py-2 bg-button text-button-text rounded hover:bg-button-hover text-sm font-medium">
|
||||
{{ $t('common.filter') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- End Filtering Controls -->
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-if="store.error" class="p-4 mb-4 border-l-4 border-error bg-error/10 text-error rounded">
|
||||
{{ store.error }}
|
||||
</div>
|
||||
|
||||
<!-- Loading state (Only show if loading AND logs empty) -->
|
||||
<div v-else-if="store.isLoading && logs.length === 0" class="p-4 text-center text-text-secondary italic">
|
||||
{{ $t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<!-- No logs state (Show only if not loading, no error, and logs empty) -->
|
||||
<div v-else-if="!store.isLoading && !store.error && logs.length === 0" class="p-4 mb-4 border-l-4 border-blue-400 bg-blue-100 text-blue-700 rounded">
|
||||
{{ $t('auditLog.noLogs') }}
|
||||
</div>
|
||||
|
||||
<!-- Table and Pagination (Show if not loading, no error, and logs exist) -->
|
||||
<div v-else-if="!store.isLoading && !store.error && logs.length > 0">
|
||||
<!-- Pagination Controls -->
|
||||
<nav aria-label="Audit Log Pagination" v-if="totalPages > 1" class="mb-4 flex justify-center"> <!-- Removed mt-6, added mb-4 -->
|
||||
<ul class="inline-flex items-center -space-x-px">
|
||||
<li>
|
||||
<a href="#" @click.prevent="changePage(currentPage - 1)"
|
||||
:class="['px-3 py-2 ml-0 leading-tight text-text-secondary bg-background border border-border rounded-l-lg hover:bg-header hover:text-foreground', { 'opacity-50 cursor-not-allowed pointer-events-none': currentPage === 1 }]">
|
||||
«
|
||||
</a>
|
||||
</li>
|
||||
<li v-for="page in paginationRange" :key="page">
|
||||
<a v-if="page !== '...'" href="#" @click.prevent="changePage(page as number)"
|
||||
:class="['px-3 py-2 leading-tight border border-border', page === currentPage ? 'text-button-text bg-button border-button hover:bg-button-hover' : 'text-text-secondary bg-background hover:bg-header hover:text-foreground']">
|
||||
{{ page }}
|
||||
</a>
|
||||
<span v-else class="px-3 py-2 leading-tight text-text-secondary bg-background border border-border">...</span>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" @click.prevent="changePage(currentPage + 1)"
|
||||
:class="['px-3 py-2 leading-tight text-text-secondary bg-background border border-border rounded-r-lg hover:bg-header hover:text-foreground', { 'opacity-50 cursor-not-allowed pointer-events-none': currentPage === totalPages }]">
|
||||
»
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div class="text-right text-text-secondary text-sm mb-4"> <!-- Changed text-center to text-right, removed mt-3, added mb-4 -->
|
||||
{{ $t('auditLog.paginationInfo', { currentPage, totalPages, totalLogs }) }}
|
||||
</div>
|
||||
<div class="border border-border rounded-lg overflow-hidden shadow-sm bg-background"> <!-- Removed mt-4 -->
|
||||
<div class="overflow-x-auto"> <!-- Allow horizontal scroll -->
|
||||
<table class="min-w-full divide-y divide-border text-sm"> <!-- Table styling -->
|
||||
<thead class="bg-header">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left font-medium text-text-secondary tracking-wider whitespace-nowrap">{{ $t('auditLog.table.timestamp') }}</th>
|
||||
<th scope="col" class="px-6 py-3 text-left font-medium text-text-secondary tracking-wider whitespace-nowrap">{{ $t('auditLog.table.actionType') }}</th>
|
||||
<th scope="col" class="px-6 py-3 text-left font-medium text-text-secondary tracking-wider">{{ $t('auditLog.table.details') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border">
|
||||
<tr v-for="log in logs" :key="log.id" class="hover:bg-header/50"> <!-- Table rows with hover -->
|
||||
<td class="px-6 py-4 whitespace-nowrap">{{ formatTimestamp(log.timestamp) }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">{{ translateActionType(log.action_type) }}</td>
|
||||
<td class="px-6 py-4">
|
||||
<pre v-if="log.details" class="whitespace-pre-wrap break-all bg-header/50 p-2 border border-border/50 rounded text-xs font-mono max-h-40 overflow-y-auto">{{ formatDetails(log.details) }}</pre> <!-- Details pre styling -->
|
||||
<span v-else class="text-text-secondary">-</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ref, onMounted, computed } from 'vue'; // Removed watch
|
||||
import { useAuditLogStore } from '../stores/audit.store';
|
||||
import type { AuditLogEntry, AuditLogActionType } from '../types/server.types';
|
||||
import PageShell from '../components/PageShell.vue';
|
||||
import { AuditLogEntry, AuditLogActionType } from '../types/server.types';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
// Removed lodash-es import
|
||||
|
||||
const store = useAuditLogStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
// --- Filtering State ---
|
||||
const searchTerm = ref('');
|
||||
const selectedActionType = ref<AuditLogActionType | ''>('');
|
||||
const selectedActionType = ref<AuditLogActionType | ''>(''); // Allow empty string for 'All'
|
||||
|
||||
// Define all possible action types for the dropdown
|
||||
const allActionTypes: AuditLogActionType[] = [
|
||||
'LOGIN_SUCCESS',
|
||||
'LOGIN_FAILURE',
|
||||
'LOGOUT',
|
||||
'PASSWORD_CHANGED',
|
||||
'2FA_ENABLED',
|
||||
'2FA_DISABLED',
|
||||
'CONNECTION_CREATED',
|
||||
'CONNECTION_UPDATED',
|
||||
'CONNECTION_DELETED',
|
||||
'PROXY_CREATED',
|
||||
'PROXY_UPDATED',
|
||||
'PROXY_DELETED',
|
||||
'TAG_CREATED',
|
||||
'TAG_UPDATED',
|
||||
'TAG_DELETED',
|
||||
'SETTINGS_UPDATED',
|
||||
'IP_WHITELIST_UPDATED',
|
||||
'NOTIFICATION_SETTING_CREATED',
|
||||
'NOTIFICATION_SETTING_UPDATED',
|
||||
'NOTIFICATION_SETTING_DELETED',
|
||||
'SSH_CONNECT_SUCCESS',
|
||||
'SSH_CONNECT_FAILURE',
|
||||
'SSH_SHELL_FAILURE',
|
||||
'DATABASE_MIGRATION',
|
||||
'ADMIN_SETUP_COMPLETE',
|
||||
'LOGIN_SUCCESS', 'LOGIN_FAILURE', 'LOGOUT', 'PASSWORD_CHANGED',
|
||||
'2FA_ENABLED', '2FA_DISABLED',
|
||||
'CONNECTION_CREATED', 'CONNECTION_UPDATED', 'CONNECTION_DELETED',
|
||||
'PROXY_CREATED', 'PROXY_UPDATED', 'PROXY_DELETED',
|
||||
'TAG_CREATED', 'TAG_UPDATED', 'TAG_DELETED',
|
||||
'SETTINGS_UPDATED', 'IP_WHITELIST_UPDATED',
|
||||
'NOTIFICATION_SETTING_CREATED', 'NOTIFICATION_SETTING_UPDATED', 'NOTIFICATION_SETTING_DELETED',
|
||||
// SSH Actions
|
||||
'SSH_CONNECT_SUCCESS', 'SSH_CONNECT_FAILURE', 'SSH_SHELL_FAILURE',
|
||||
// System/Error
|
||||
'DATABASE_MIGRATION', 'ADMIN_SETUP_COMPLETE'
|
||||
];
|
||||
|
||||
const logs = computed(() => store.logs);
|
||||
const totalLogs = computed(() => store.totalLogs);
|
||||
const currentPage = computed(() => store.currentPage);
|
||||
const logsPerPage = computed(() => store.logsPerPage);
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(totalLogs.value / logsPerPage.value)));
|
||||
|
||||
const auditStats = computed(() => [
|
||||
{
|
||||
label: t('auditLog.title'),
|
||||
value: totalLogs.value,
|
||||
meta: `${t('common.search', '搜索')}: ${searchTerm.value || t('common.all', '全部')}`,
|
||||
},
|
||||
{
|
||||
label: t('auditLog.table.actionType'),
|
||||
value: selectedActionType.value || t('common.all', '全部'),
|
||||
meta: `${currentPage.value} / ${totalPages.value}`,
|
||||
},
|
||||
]);
|
||||
const totalPages = computed(() => Math.ceil(totalLogs.value / logsPerPage.value));
|
||||
|
||||
// Function to apply filters and fetch logs
|
||||
const applyFilters = () => {
|
||||
store.fetchLogs({
|
||||
page: 1,
|
||||
searchTerm: searchTerm.value || undefined,
|
||||
actionType: selectedActionType.value || undefined,
|
||||
});
|
||||
// Pass undefined if filter is empty, otherwise pass the value
|
||||
store.fetchLogs({
|
||||
page: 1, // Reset to page 1 when applying filters
|
||||
searchTerm: searchTerm.value || undefined,
|
||||
actionType: selectedActionType.value || undefined
|
||||
});
|
||||
};
|
||||
|
||||
// Removed watch for filters
|
||||
|
||||
onMounted(() => {
|
||||
// Fetch initial logs without filters
|
||||
store.fetchLogs();
|
||||
});
|
||||
|
||||
const formatTimestamp = (timestamp: number): string => new Date(timestamp * 1000).toLocaleString();
|
||||
const formatTimestamp = (timestamp: number): string => {
|
||||
// Convert seconds to milliseconds for Date constructor
|
||||
return new Date(timestamp * 1000).toLocaleString();
|
||||
};
|
||||
|
||||
const translateActionType = (actionType: AuditLogActionType): string => {
|
||||
const key = `auditLog.actions.${actionType}`;
|
||||
const translated = t(key);
|
||||
return translated === key ? actionType : translated;
|
||||
// Attempt to translate using a convention like auditLog.actions.ACTION_TYPE
|
||||
const key = `auditLog.actions.${actionType}`;
|
||||
const translated = t(key);
|
||||
// If translation is missing, return the original type
|
||||
return translated === key ? actionType : translated;
|
||||
};
|
||||
|
||||
const formatDetails = (details: AuditLogEntry['details']): string => {
|
||||
if (!details) return '-';
|
||||
if (typeof details === 'object') {
|
||||
if (!details) return '';
|
||||
if (typeof details === 'object' && details !== null) {
|
||||
if ('raw' in details && details.parseError) {
|
||||
return `[Parse Error] Raw: ${details.raw}`;
|
||||
return `[Parse Error] Raw: ${details.raw}`;
|
||||
}
|
||||
return JSON.stringify(details, null, 2);
|
||||
return JSON.stringify(details, null, 2); // Pretty print JSON
|
||||
}
|
||||
return String(details);
|
||||
return String(details); // Should ideally not happen if backend sends JSON string
|
||||
};
|
||||
|
||||
const changePage = (page: number) => {
|
||||
if (page >= 1 && page <= totalPages.value && page !== currentPage.value) {
|
||||
// Retain current filters when changing page
|
||||
store.fetchLogs({
|
||||
page,
|
||||
searchTerm: searchTerm.value || undefined,
|
||||
actionType: selectedActionType.value || undefined,
|
||||
page: page,
|
||||
searchTerm: searchTerm.value || undefined,
|
||||
actionType: selectedActionType.value || undefined
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Simple pagination range logic (can be improved for many pages)
|
||||
const paginationRange = computed(() => {
|
||||
const range: (number | string)[] = [];
|
||||
const delta = 2; // Number of pages around current page
|
||||
const left = currentPage.value - delta;
|
||||
const right = currentPage.value + delta + 1;
|
||||
let l: number | null = null; // Keep track of the last number added
|
||||
|
||||
for (let i = 1; i <= totalPages.value; i++) {
|
||||
if (i === 1 || i === totalPages.value || (i >= left && i < right)) {
|
||||
range.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
const result: (number | string)[] = [];
|
||||
for (const pageNum of range) {
|
||||
// Ensure pageNum is treated as number for comparison/arithmetic
|
||||
const currentNum = pageNum as number;
|
||||
if (l !== null) {
|
||||
// Calculate difference explicitly as numbers
|
||||
if (currentNum - l === 2) {
|
||||
result.push(l + 1);
|
||||
} else if (currentNum - l > 1) { // Check if difference is greater than 1
|
||||
result.push('...');
|
||||
}
|
||||
}
|
||||
result.push(currentNum);
|
||||
l = currentNum; // Store the current number
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageShell
|
||||
:title="$t('auditLog.title')"
|
||||
:subtitle="$t('auditLog.controlCenterSubtitle', '通过统一的筛选、时间线与明细面板追踪所有关键系统操作。')"
|
||||
>
|
||||
<template #actions>
|
||||
<el-button plain @click="applyFilters">
|
||||
<i class="fas fa-rotate-right mr-2"></i>
|
||||
{{ $t('common.filter') }}
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<template #stats>
|
||||
<div class="control-stat-grid">
|
||||
<div v-for="stat in auditStats" :key="stat.label" class="control-stat-card">
|
||||
<span class="control-stat-card__label">{{ stat.label }}</span>
|
||||
<span class="control-stat-card__value">{{ stat.value }}</span>
|
||||
<span class="control-stat-card__meta">{{ stat.meta }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-card shadow="never" class="control-panel">
|
||||
<div class="grid gap-3 md:grid-cols-[minmax(240px,1fr)_220px_auto]">
|
||||
<el-input v-model="searchTerm" :placeholder="$t('auditLog.searchPlaceholder')" clearable>
|
||||
<template #prefix>
|
||||
<i class="fas fa-search text-text-secondary"></i>
|
||||
</template>
|
||||
</el-input>
|
||||
|
||||
<el-select v-model="selectedActionType" clearable :placeholder="$t('auditLog.table.actionType')">
|
||||
<el-option :label="$t('common.all')" value="" />
|
||||
<el-option
|
||||
v-for="type in allActionTypes"
|
||||
:key="type"
|
||||
:label="translateActionType(type)"
|
||||
:value="type"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-button type="primary" @click="applyFilters">
|
||||
{{ $t('common.filter') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="store.error"
|
||||
class="mt-4"
|
||||
:title="store.error"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
|
||||
<div v-else-if="store.isLoading && logs.length === 0" class="control-empty mt-4">
|
||||
<el-skeleton :rows="6" animated />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!store.isLoading && logs.length === 0" class="control-empty mt-4">
|
||||
<el-empty :description="$t('auditLog.noLogs')" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<el-table :data="logs" class="mt-5" stripe>
|
||||
<el-table-column prop="timestamp" :label="$t('auditLog.table.timestamp')" min-width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatTimestamp(row.timestamp) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="action_type" :label="$t('auditLog.table.actionType')" min-width="190">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain">{{ translateActionType(row.action_type) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="details" :label="$t('auditLog.table.details')" min-width="420">
|
||||
<template #default="{ row }">
|
||||
<pre class="m-0 whitespace-pre-wrap break-all rounded-2xl bg-muted p-3 text-xs text-foreground">{{ formatDetails(row.details) }}</pre>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="mt-5 flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="text-sm text-text-secondary">
|
||||
{{ $t('auditLog.paginationInfo', { currentPage, totalPages, totalLogs }) }}
|
||||
</div>
|
||||
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:current-page="currentPage"
|
||||
:page-size="logsPerPage"
|
||||
:total="totalLogs"
|
||||
@current-change="changePage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</el-card>
|
||||
</PageShell>
|
||||
</template>
|
||||
<style scoped>
|
||||
/* Remove all scoped styles as they are now handled by Tailwind utility classes */
|
||||
</style>
|
||||
|
||||
@@ -746,4 +746,4 @@ const handleConnectAllFilteredConnections = async () => {
|
||||
@saved="handleBatchEditSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
@@ -1,51 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { zhCN, enUS, ja } from 'date-fns/locale';
|
||||
import type { Locale } from 'date-fns';
|
||||
import AddConnectionForm from '../components/AddConnectionForm.vue';
|
||||
import PageShell from '../components/PageShell.vue';
|
||||
import AddConnectionForm from '../components/AddConnectionForm.vue';
|
||||
import { useConnectionsStore } from '../stores/connections.store';
|
||||
import { useAuditLogStore } from '../stores/audit.store';
|
||||
import { useSessionStore } from '../stores/session.store';
|
||||
import { useTagsStore } from '../stores/tags.store';
|
||||
import type { TagInfo } from '../stores/tags.store';
|
||||
import { useTagsStore } from '../stores/tags.store';
|
||||
import type { TagInfo } from '../stores/tags.store';
|
||||
|
||||
import type { SortField, SortOrder } from '../stores/settings.store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useRouter } from 'vue-router';
|
||||
import type { ConnectionInfo } from '../stores/connections.store';
|
||||
import type { SortField, SortOrder } from '../stores/settings.store';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { zhCN, enUS, ja } from 'date-fns/locale';
|
||||
import type { Locale } from 'date-fns';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
const connectionsStore = useConnectionsStore();
|
||||
const auditLogStore = useAuditLogStore();
|
||||
const sessionStore = useSessionStore();
|
||||
const tagsStore = useTagsStore();
|
||||
const tagsStore = useTagsStore();
|
||||
|
||||
|
||||
const { connections, isLoading: isLoadingConnections } = storeToRefs(connectionsStore);
|
||||
const { logs: auditLogs, isLoading: isLoadingLogs, totalLogs } = storeToRefs(auditLogStore);
|
||||
const { tags, isLoading: isLoadingTags } = storeToRefs(tagsStore);
|
||||
const { tags, isLoading: isLoadingTags } = storeToRefs(tagsStore);
|
||||
|
||||
|
||||
|
||||
const LS_SORT_BY_KEY = 'dashboard_connections_sort_by';
|
||||
const LS_SORT_ORDER_KEY = 'dashboard_connections_sort_order';
|
||||
const LS_FILTER_TAG_KEY = 'dashboard_connections_filter_tag';
|
||||
|
||||
const localSortBy = ref<SortField>((localStorage.getItem(LS_SORT_BY_KEY) as SortField) || 'last_connected_at');
|
||||
const localSortOrder = ref<SortOrder>((localStorage.getItem(LS_SORT_ORDER_KEY) as SortOrder) || 'desc');
|
||||
const LS_FILTER_TAG_KEY = 'dashboard_connections_filter_tag';
|
||||
|
||||
// Initialize with localStorage values or defaults
|
||||
const localSortBy = ref<SortField>(localStorage.getItem(LS_SORT_BY_KEY) as SortField || 'last_connected_at');
|
||||
const localSortOrder = ref<SortOrder>(localStorage.getItem(LS_SORT_ORDER_KEY) as SortOrder || 'desc');
|
||||
// +++ 初始化标签筛选状态,从 localStorage 读取,注意类型转换 (修正 ref 初始化) +++
|
||||
const getInitialSelectedTagId = (): number | null => {
|
||||
const storedValue = localStorage.getItem(LS_FILTER_TAG_KEY);
|
||||
// 如果存储的值是 'null' 字符串或空,则返回 null,否则解析为数字
|
||||
return storedValue && storedValue !== 'null' ? parseInt(storedValue, 10) : null;
|
||||
};
|
||||
|
||||
const selectedTagId = ref<number | null>(getInitialSelectedTagId());
|
||||
const searchQuery = ref('');
|
||||
|
||||
// +++ 控制添加/编辑表单的显示状态 +++
|
||||
const showAddEditConnectionForm = ref(false);
|
||||
const connectionToEdit = ref<ConnectionInfo | null>(null);
|
||||
|
||||
const maxRecentLogs = 5;
|
||||
|
||||
|
||||
const sortOptions: { value: SortField; labelKey: string }[] = [
|
||||
{ value: 'last_connected_at', labelKey: 'dashboard.sortOptions.lastConnected' },
|
||||
{ value: 'name', labelKey: 'dashboard.sortOptions.name' },
|
||||
@@ -54,114 +60,98 @@ const sortOptions: { value: SortField; labelKey: string }[] = [
|
||||
{ value: 'created_at', labelKey: 'dashboard.sortOptions.created' },
|
||||
];
|
||||
|
||||
// +++ 修改计算属性,先筛选再排序 +++
|
||||
const filteredAndSortedConnections = computed(() => {
|
||||
const sortBy = localSortBy.value;
|
||||
const sortOrderVal = localSortOrder.value;
|
||||
const factor = sortOrderVal === 'desc' ? -1 : 1;
|
||||
const filterTagId = selectedTagId.value;
|
||||
const query = searchQuery.value.toLowerCase().trim();
|
||||
|
||||
const filteredByTag =
|
||||
filterTagId === null
|
||||
? [...connections.value]
|
||||
: connections.value.filter((conn) => conn.tag_ids?.includes(filterTagId));
|
||||
|
||||
const searchedConnections = query
|
||||
? filteredByTag.filter((conn) => {
|
||||
const nameMatch = conn.name?.toLowerCase().includes(query);
|
||||
const usernameMatch = conn.username?.toLowerCase().includes(query);
|
||||
const hostMatch = conn.host?.toLowerCase().includes(query);
|
||||
const portMatch = conn.port?.toString().includes(query);
|
||||
return nameMatch || usernameMatch || hostMatch || portMatch;
|
||||
})
|
||||
: filteredByTag;
|
||||
|
||||
const query = searchQuery.value.toLowerCase().trim(); // +++ 获取搜索查询 +++
|
||||
|
||||
// 1. Filter by selected tag
|
||||
let filteredByTag = filterTagId === null
|
||||
? [...connections.value] // No tag selected, show all
|
||||
: connections.value.filter(conn => conn.tag_ids?.includes(filterTagId));
|
||||
|
||||
// 2. Filter by search query
|
||||
let searchedConnections = filteredByTag;
|
||||
if (query) {
|
||||
searchedConnections = filteredByTag.filter(conn => {
|
||||
const nameMatch = conn.name?.toLowerCase().includes(query);
|
||||
const usernameMatch = conn.username?.toLowerCase().includes(query);
|
||||
const hostMatch = conn.host?.toLowerCase().includes(query);
|
||||
const portMatch = conn.port?.toString().includes(query);
|
||||
return nameMatch || usernameMatch || hostMatch || portMatch;
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Sort the searched connections
|
||||
return searchedConnections.sort((a, b) => {
|
||||
let valA: string | number;
|
||||
let valB: string | number;
|
||||
let valA: any;
|
||||
let valB: any;
|
||||
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
valA = a.name || '';
|
||||
valB = b.name || '';
|
||||
return String(valA).localeCompare(String(valB)) * factor;
|
||||
return valA.localeCompare(valB) * factor;
|
||||
case 'type':
|
||||
valA = a.type || '';
|
||||
valB = b.type || '';
|
||||
return String(valA).localeCompare(String(valB)) * factor;
|
||||
return valA.localeCompare(valB) * factor;
|
||||
case 'created_at':
|
||||
valA = a.created_at ?? 0;
|
||||
valB = b.created_at ?? 0;
|
||||
return (Number(valA) - Number(valB)) * factor;
|
||||
return (valA - valB) * factor;
|
||||
case 'updated_at':
|
||||
valA = a.updated_at ?? 0;
|
||||
valB = b.updated_at ?? 0;
|
||||
return (Number(valA) - Number(valB)) * factor;
|
||||
return (valA - valB) * factor;
|
||||
case 'last_connected_at':
|
||||
valA = a.last_connected_at ?? (sortOrderVal === 'desc' ? -Infinity : Infinity);
|
||||
valB = b.last_connected_at ?? (sortOrderVal === 'desc' ? -Infinity : Infinity);
|
||||
if (valA === valB) return 0;
|
||||
return Number(valA) < Number(valB) ? -1 * factor : 1 * factor;
|
||||
if (valA < valB) return -1 * factor;
|
||||
return 1 * factor;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const recentAuditLogs = computed(() => auditLogs.value.slice(0, maxRecentLogs));
|
||||
|
||||
const dashboardStats = computed(() => {
|
||||
const taggedConnections = connections.value.filter((conn) => (conn.tag_ids?.length ?? 0) > 0).length;
|
||||
const sshConnections = connections.value.filter((conn) => conn.type === 'SSH').length;
|
||||
|
||||
return [
|
||||
{
|
||||
label: t('dashboard.connectionList', '连接列表'),
|
||||
value: connections.value.length,
|
||||
meta: `${filteredAndSortedConnections.value.length} ${t('common.filter', '筛选')} / ${sshConnections} SSH`,
|
||||
},
|
||||
{
|
||||
label: t('settings.workspace.showConnectionTagsTitle', '连接标签'),
|
||||
value: tags.value.length,
|
||||
meta: `${taggedConnections} ${t('dashboard.filterTags.all', '已关联标签')}`,
|
||||
},
|
||||
{
|
||||
label: t('dashboard.recentActivity', '最近活动'),
|
||||
value: recentAuditLogs.value.length,
|
||||
meta: `${totalLogs.value} ${t('auditLog.title', '审计日志')}`,
|
||||
},
|
||||
{
|
||||
label: t('nav.terminal', '终端会话'),
|
||||
value: sessionStore.sessions.size,
|
||||
meta: t('workspace.workbench.label', '工作台已接入'),
|
||||
},
|
||||
];
|
||||
const recentAuditLogs = computed(() => {
|
||||
return auditLogs.value.slice(0, maxRecentLogs);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
// Load saved preferences from localStorage (already done during ref initialization)
|
||||
|
||||
// Fetch connections if not already loaded
|
||||
if (connections.value.length === 0) {
|
||||
try {
|
||||
await connectionsStore.fetchConnections();
|
||||
} catch (error) {
|
||||
console.error('Failed to load connections:', error);
|
||||
console.error("加载连接列表失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch recent audit logs
|
||||
try {
|
||||
await auditLogStore.fetchLogs({
|
||||
page: 1,
|
||||
limit: maxRecentLogs,
|
||||
sortOrder: 'desc',
|
||||
isDashboardRequest: true,
|
||||
page: 1,
|
||||
limit: maxRecentLogs,
|
||||
sortOrder: 'desc',
|
||||
isDashboardRequest: true
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load audit logs:', error);
|
||||
console.error("加载审计日志失败:", error);
|
||||
}
|
||||
|
||||
// +++ Fetch tags for filtering +++
|
||||
try {
|
||||
await tagsStore.fetchTags();
|
||||
} catch (error) {
|
||||
console.error('Failed to load tags:', error);
|
||||
console.error("加载标签列表失败:", error);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -170,11 +160,13 @@ const connectTo = (connection: ConnectionInfo) => {
|
||||
};
|
||||
|
||||
const toggleSortOrder = () => {
|
||||
// Only update the local sort order state
|
||||
localSortOrder.value = localSortOrder.value === 'asc' ? 'desc' : 'asc';
|
||||
};
|
||||
|
||||
const isAscending = computed(() => localSortOrder.value === 'asc');
|
||||
const isAscending = computed(() => localSortOrder.value === 'asc'); // Use local state
|
||||
|
||||
// Watch for changes in local sort state and save to localStorage
|
||||
watch(localSortBy, (newValue) => {
|
||||
localStorage.setItem(LS_SORT_BY_KEY, newValue);
|
||||
});
|
||||
@@ -183,7 +175,9 @@ watch(localSortOrder, (newValue) => {
|
||||
localStorage.setItem(LS_SORT_ORDER_KEY, newValue);
|
||||
});
|
||||
|
||||
// +++ Watch for changes in selected tag and save to localStorage +++
|
||||
watch(selectedTagId, (newValue) => {
|
||||
// Store 'null' as a string or the number
|
||||
localStorage.setItem(LS_FILTER_TAG_KEY, newValue === null ? 'null' : String(newValue));
|
||||
});
|
||||
|
||||
@@ -191,288 +185,239 @@ const dateFnsLocales: Record<string, Locale> = {
|
||||
'en-US': enUS,
|
||||
'zh-CN': zhCN,
|
||||
'ja-JP': ja,
|
||||
en: enUS,
|
||||
zh: zhCN,
|
||||
ja,
|
||||
// 主语言回退
|
||||
'en': enUS,
|
||||
'zh': zhCN,
|
||||
'ja': ja,
|
||||
};
|
||||
|
||||
// 修正函数签名,接受 number | null | undefined
|
||||
const formatRelativeTime = (timestampInSeconds: number | null | undefined): string => {
|
||||
if (!timestampInSeconds) return t('connections.status.never');
|
||||
|
||||
try {
|
||||
// 将秒级时间戳转换为毫秒级
|
||||
const timestampInMs = timestampInSeconds * 1000;
|
||||
if (Number.isNaN(timestampInMs)) {
|
||||
return String(timestampInSeconds);
|
||||
// 检查转换后的值是否有效
|
||||
if (isNaN(timestampInMs)) {
|
||||
console.warn(`[Dashboard] Invalid timestamp received: ${timestampInSeconds}`);
|
||||
return String(timestampInSeconds); // 返回原始值或错误提示
|
||||
}
|
||||
const date = new Date(timestampInMs);
|
||||
|
||||
const currentI18nLocale = locale.value; // 获取 vue-i18n 当前 locale (e.g., 'zh-CN')
|
||||
const langPart = currentI18nLocale.split('-')[0]; // 获取主语言部分 (e.g., 'zh')
|
||||
|
||||
// 1. 尝试精确匹配 (e.g., 'zh-CN' -> zhCN)
|
||||
let targetDateFnsLocale = dateFnsLocales[currentI18nLocale];
|
||||
|
||||
// 2. 如果无精确匹配,尝试匹配主语言 (e.g., 'zh' -> zhCN)
|
||||
if (!targetDateFnsLocale) {
|
||||
targetDateFnsLocale = dateFnsLocales[langPart];
|
||||
}
|
||||
|
||||
const date = new Date(timestampInMs);
|
||||
const currentI18nLocale = locale.value;
|
||||
const langPart = currentI18nLocale.split('-')[0];
|
||||
const targetLocale = dateFnsLocales[currentI18nLocale] || dateFnsLocales[langPart] || enUS;
|
||||
// 3. 如果仍然找不到,回退到默认 enUS
|
||||
if (!targetDateFnsLocale) {
|
||||
console.warn(`[Dashboard] date-fns locale not found for ${currentI18nLocale} or ${langPart}. Falling back to en-US.`);
|
||||
targetDateFnsLocale = enUS; // 默认回退到 enUS
|
||||
}
|
||||
|
||||
return formatDistanceToNow(date, { addSuffix: true, locale: targetLocale });
|
||||
} catch (error) {
|
||||
console.error('Failed to format date:', error);
|
||||
return String(timestampInSeconds);
|
||||
return formatDistanceToNow(date, { addSuffix: true, locale: targetDateFnsLocale });
|
||||
} catch (e) {
|
||||
console.error("格式化日期失败:", e);
|
||||
return String(timestampInSeconds); // 出错时返回原始字符串
|
||||
}
|
||||
};
|
||||
|
||||
const getActionTranslation = (actionType: string): string => {
|
||||
// 尝试从 i18n 获取翻译,如果找不到则返回原始 actionType
|
||||
const key = `auditLog.actions.${actionType}`;
|
||||
const translated = t(key);
|
||||
// 如果翻译结果等于 key 本身,说明没有找到翻译
|
||||
return translated === key ? actionType : translated;
|
||||
};
|
||||
|
||||
// 辅助函数:判断活动类型是否表示失败
|
||||
const isFailedAction = (actionType: string): boolean => {
|
||||
const lowerCaseAction = actionType.toLowerCase();
|
||||
// 检查常见的失败关键词
|
||||
return lowerCaseAction.includes('fail') || lowerCaseAction.includes('error') || lowerCaseAction.includes('denied');
|
||||
};
|
||||
|
||||
// +++ 恢复:根据 tag_ids 获取标签名称数组 +++
|
||||
const getTagNames = (tagIds: number[] | undefined): string[] => {
|
||||
if (!tagIds || tagIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const allTags = tags.value as TagInfo[];
|
||||
return tagIds
|
||||
.map((id) => allTags.find((tag) => tag.id === id)?.name)
|
||||
.filter((name): name is string => Boolean(name));
|
||||
.map(id => allTags.find(tag => tag.id === id)?.name)
|
||||
.filter((name): name is string => !!name); // 过滤掉未找到的标签并确保类型为 string
|
||||
};
|
||||
|
||||
// +++ 打开添加表单 +++
|
||||
const openAddConnectionForm = () => {
|
||||
connectionToEdit.value = null;
|
||||
showAddEditConnectionForm.value = true;
|
||||
};
|
||||
|
||||
// +++ 打开编辑表单 +++
|
||||
const openEditConnectionForm = (conn: ConnectionInfo) => {
|
||||
connectionToEdit.value = conn;
|
||||
showAddEditConnectionForm.value = true;
|
||||
};
|
||||
|
||||
// +++ 处理表单关闭事件 +++
|
||||
const handleFormClose = () => {
|
||||
showAddEditConnectionForm.value = false;
|
||||
connectionToEdit.value = null;
|
||||
connectionToEdit.value = null; // 清除编辑状态
|
||||
};
|
||||
|
||||
// +++ 处理连接添加/更新成功事件 +++
|
||||
const handleConnectionModified = async () => {
|
||||
showAddEditConnectionForm.value = false;
|
||||
connectionToEdit.value = null;
|
||||
await connectionsStore.fetchConnections();
|
||||
};
|
||||
|
||||
const openConnectionsView = () => {
|
||||
router.push('/connections');
|
||||
};
|
||||
|
||||
const openAuditLogsView = () => {
|
||||
router.push('/audit-logs');
|
||||
await connectionsStore.fetchConnections(); // 重新加载连接列表
|
||||
};
|
||||
|
||||
// --- 移除 selectTagFilter 函数 ---
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageShell
|
||||
:title="t('nav.dashboard')"
|
||||
:subtitle="t('dashboard.controlCenterSubtitle', '在一个控制中心里查看连接、审计和常用入口,快速进入工作区。')"
|
||||
>
|
||||
<template #actions>
|
||||
<el-button plain @click="openAuditLogsView">
|
||||
<i class="fas fa-shield-halved mr-2"></i>
|
||||
{{ t('dashboard.viewFullAuditLog', '查看完整审计日志') }}
|
||||
</el-button>
|
||||
<el-button type="primary" @click="openAddConnectionForm">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
{{ t('connections.addConnection', '添加新连接') }}
|
||||
</el-button>
|
||||
</template>
|
||||
<div class="p-4 md:p-6 lg:p-8 bg-background text-foreground">
|
||||
<h1 class="text-2xl font-semibold mb-6">{{ t('nav.dashboard') }}</h1>
|
||||
|
||||
<template #stats>
|
||||
<div class="control-stat-grid">
|
||||
<div v-for="stat in dashboardStats" :key="stat.label" class="control-stat-card">
|
||||
<span class="control-stat-card__label">{{ stat.label }}</span>
|
||||
<span class="control-stat-card__value">{{ stat.value }}</span>
|
||||
<span class="control-stat-card__meta">{{ stat.meta }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 lg:items-start">
|
||||
|
||||
<div class="grid gap-5 xl:grid-cols-[1.5fr_1fr]">
|
||||
<el-card shadow="never" class="control-panel">
|
||||
<template #header>
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-foreground">
|
||||
{{ t('dashboard.connectionList', '连接列表') }}
|
||||
</div>
|
||||
<div class="text-sm text-text-secondary">
|
||||
{{ filteredAndSortedConnections.length }} / {{ connections.length }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2 md:grid-cols-[minmax(200px,1fr)_150px_160px_auto_auto]">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
:placeholder="t('dashboard.searchConnectionsPlaceholder', '搜索连接...')"
|
||||
clearable
|
||||
<!-- Connection List -->
|
||||
<div class="bg-card text-card-foreground shadow rounded-lg overflow-hidden border border-border min-h-[400px]">
|
||||
<div class="px-4 py-3 border-b border-border flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2">
|
||||
<h2 class="text-lg font-medium flex-shrink-0">{{ t('dashboard.connectionList', '连接列表') }} ({{ filteredAndSortedConnections.length }})</h2>
|
||||
<div class="w-full sm:w-auto flex flex-wrap sm:flex-nowrap items-stretch sm:items-center space-y-2 sm:space-y-0 sm:space-x-2">
|
||||
<!-- Search Input (Order adjusted for button placement) -->
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchQuery"
|
||||
:placeholder="t('dashboard.searchConnectionsPlaceholder', '搜索连接...')"
|
||||
class="h-8 px-3 py-1 text-sm border border-border rounded bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary w-full sm:w-48"
|
||||
/>
|
||||
<div class="flex items-center space-x-2"> <!-- Wrapper for existing controls -->
|
||||
<!-- Tag Filter Dropdown -->
|
||||
<select
|
||||
v-model="selectedTagId"
|
||||
class="h-8 px-2 py-1 text-sm border border-border rounded bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary appearance-none bg-no-repeat bg-right pr-8"
|
||||
style="background-image: url('data:image/svg+xml,%3csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 16 16\'%3e%3cpath fill=\'none\' stroke=\'%236c757d\' stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M2 5l6 6 6-6\'/%3e%3c/svg%3e'); background-position: right 0.5rem center; background-size: 16px 12px;"
|
||||
aria-label="Filter connections by tag"
|
||||
:disabled="isLoadingTags"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-search text-text-secondary"></i>
|
||||
</template>
|
||||
</el-input>
|
||||
<option :value="null">{{ t('dashboard.filterTags.all', '所有标签') }}</option>
|
||||
<option v-if="isLoadingTags" disabled>{{ t('common.loading') }}</option>
|
||||
<!-- 修正 v-for 循环中的类型 -->
|
||||
<option v-for="tag in (tags as TagInfo[])" :key="tag.id" :value="tag.id">
|
||||
{{ tag.name }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<el-select v-model="selectedTagId" :disabled="isLoadingTags" clearable>
|
||||
<el-option :label="t('dashboard.filterTags.all', '所有标签')" :value="null" />
|
||||
<el-option
|
||||
v-for="tag in (tags as TagInfo[])"
|
||||
:key="tag.id"
|
||||
:label="tag.name"
|
||||
:value="tag.id"
|
||||
/>
|
||||
</el-select>
|
||||
<!-- Sort By Dropdown -->
|
||||
<select
|
||||
v-model="localSortBy"
|
||||
class="h-8 px-2 py-1 text-sm border border-border rounded bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary appearance-none bg-no-repeat bg-right pr-8"
|
||||
style="background-image: url('data:image/svg+xml,%3csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 16 16\'%3e%3cpath fill=\'none\' stroke=\'%236c757d\' stroke-linecap=\'round\' stroke-linejoin=\'round\' stroke-width=\'2\' d=\'M2 5l6 6 6-6\'/%3e%3c/svg%3e'); background-position: right 0.5rem center; background-size: 16px 12px;"
|
||||
aria-label="Sort connections by"
|
||||
>
|
||||
<option v-for="option in sortOptions" :key="option.value" :value="option.value">
|
||||
{{ t(option.labelKey, option.value.replace('_', ' ')) }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<el-select v-model="localSortBy">
|
||||
<el-option
|
||||
v-for="option in sortOptions"
|
||||
:key="option.value"
|
||||
:label="t(option.labelKey, option.value)"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
|
||||
<el-button plain @click="toggleSortOrder">
|
||||
<i :class="['fas', isAscending ? 'fa-arrow-up-a-z' : 'fa-arrow-down-z-a']"></i>
|
||||
</el-button>
|
||||
|
||||
<el-button plain @click="openConnectionsView">
|
||||
<i class="fas fa-layer-group mr-2"></i>
|
||||
{{ t('nav.connections') }}
|
||||
</el-button>
|
||||
<!-- Sort Order Button -->
|
||||
<button
|
||||
@click="toggleSortOrder"
|
||||
class="h-8 px-1.5 py-1 border border-border rounded hover:bg-muted focus:outline-none focus:ring-1 focus:ring-primary flex items-center justify-center"
|
||||
:aria-label="isAscending ? t('common.sortAscending') : t('common.sortDescending')"
|
||||
:title="isAscending ? t('common.sortAscending') : t('common.sortDescending')"
|
||||
>
|
||||
<i :class="['fas', isAscending ? 'fa-arrow-up-a-z' : 'fa-arrow-down-z-a', 'w-4 h-4']"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Add Connection Button -->
|
||||
<button @click="openAddConnectionForm" title="Add Connection" class="h-8 w-8 bg-button rounded-md shadow-sm hover:bg-button-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary transition duration-150 ease-in-out flex items-center justify-center flex-shrink-0 ml-2 sm:ml-0">
|
||||
<i class="fas fa-plus" style="color: white;"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="isLoadingConnections && filteredAndSortedConnections.length === 0" class="control-empty">
|
||||
<el-skeleton :rows="4" animated />
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredAndSortedConnections.length > 0" class="grid gap-3">
|
||||
<el-card
|
||||
v-for="conn in filteredAndSortedConnections"
|
||||
:key="conn.id"
|
||||
shadow="hover"
|
||||
class="border border-border/50"
|
||||
>
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 text-base font-semibold text-foreground">
|
||||
<i
|
||||
:class="[
|
||||
'fas',
|
||||
conn.type === 'VNC' ? 'fa-plug' : conn.type === 'RDP' ? 'fa-desktop' : 'fa-server',
|
||||
'text-primary',
|
||||
]"
|
||||
></i>
|
||||
<span class="truncate">{{ conn.name || conn.host || t('connections.unnamedFallback', '未命名连接') }}</span>
|
||||
<el-tag size="small" effect="plain">{{ conn.type }}</el-tag>
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-text-secondary">
|
||||
<div class="p-4">
|
||||
<!-- Use filteredAndSortedConnections and check its length -->
|
||||
<div v-if="isLoadingConnections && filteredAndSortedConnections.length === 0" class="text-center text-text-secondary">{{ t('common.loading') }}</div>
|
||||
<ul v-else-if="filteredAndSortedConnections.length > 0" class="space-y-3">
|
||||
<!-- Iterate over filteredAndSortedConnections -->
|
||||
<li v-for="conn in filteredAndSortedConnections" :key="conn.id" class="flex items-center justify-between p-3 bg-header/50 border border-border/50 rounded transition duration-150 ease-in-out">
|
||||
<div class="flex-grow mr-4 overflow-hidden">
|
||||
<span class="font-medium block truncate flex items-center" :title="conn.name || ''">
|
||||
<i :class="['fas', conn.type === 'VNC' ? 'fa-plug' : (conn.type === 'RDP' ? 'fa-desktop' : 'fa-server'), 'mr-2 w-4 text-center text-text-secondary']"></i>
|
||||
<span>{{ conn.name || conn.host || t('connections.unnamedFallback', '未命名连接') }}</span>
|
||||
</span>
|
||||
<span class="text-sm text-text-secondary block truncate" :title="`${conn.username}@${conn.host}:${conn.port}`">
|
||||
{{ conn.username }}@{{ conn.host }}:{{ conn.port }}
|
||||
</div>
|
||||
<div class="mt-2 text-xs text-text-secondary">
|
||||
</span>
|
||||
<span class="text-xs text-text-alt block mb-1"> <!-- Added margin-bottom -->
|
||||
{{ t('dashboard.lastConnected', '上次连接:') }} {{ formatRelativeTime(conn.last_connected_at) }}
|
||||
</div>
|
||||
<div v-if="getTagNames(conn.tag_ids).length > 0" class="mt-3 flex flex-wrap gap-2">
|
||||
<el-tag
|
||||
</span>
|
||||
<div v-if="getTagNames(conn.tag_ids).length > 0" class="flex flex-wrap gap-1 mt-1">
|
||||
<span
|
||||
v-for="tagName in getTagNames(conn.tag_ids)"
|
||||
:key="tagName"
|
||||
effect="plain"
|
||||
round
|
||||
size="small"
|
||||
class="px-1.5 py-0.5 text-xs rounded bg-muted text-muted-foreground border border-border"
|
||||
>
|
||||
{{ tagName }}
|
||||
</el-tag>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<el-button plain @click="openEditConnectionForm(conn)">
|
||||
<i class="fas fa-pen mr-2"></i>
|
||||
{{ t('connections.actions.edit') }}
|
||||
</el-button>
|
||||
<el-button type="primary" @click="connectTo(conn)">
|
||||
<i class="fas fa-terminal mr-2"></i>
|
||||
<div class="flex space-x-2 flex-shrink-0">
|
||||
<button @click="openEditConnectionForm(conn)" class="px-3 py-1.5 bg-transparent text-foreground border border-border rounded-md shadow-sm hover:bg-border focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary transition duration-150 ease-in-out text-sm font-medium">
|
||||
<i class="fas fa-pencil-alt"></i>
|
||||
</button>
|
||||
<button @click="connectTo(conn)" class="px-4 py-2 bg-button text-button-text rounded-md shadow-sm hover:bg-button-hover focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary transition duration-150 ease-in-out text-sm font-medium"> <!-- Applied standard button style -->
|
||||
{{ t('connections.actions.connect') }}
|
||||
</el-button>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- Adjust no connections message based on filtering and search -->
|
||||
<div v-else-if="!isLoadingConnections && searchQuery && filteredAndSortedConnections.length === 0" class="text-center text-text-secondary">{{ t('dashboard.noConnectionsMatchSearch', '没有连接匹配搜索条件') }}</div>
|
||||
<div v-else-if="!isLoadingConnections && selectedTagId !== null && filteredAndSortedConnections.length === 0" class="text-center text-text-secondary">{{ t('dashboard.noConnectionsWithTag', '该标签下没有连接记录') }}</div>
|
||||
<div v-else class="text-center text-text-secondary">{{ t('dashboard.noConnections', '没有连接记录') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="control-empty">
|
||||
<el-empty
|
||||
:description="
|
||||
searchQuery
|
||||
? t('dashboard.noConnectionsMatchSearch', '没有连接匹配搜索条件')
|
||||
: selectedTagId !== null
|
||||
? t('dashboard.noConnectionsWithTag', '该标签下没有连接记录')
|
||||
: t('dashboard.noConnections', '没有连接记录')
|
||||
"
|
||||
/>
|
||||
<!-- Recent Activity -->
|
||||
<div class="bg-card text-card-foreground shadow rounded-lg overflow-hidden border border-border min-h-[400px]">
|
||||
<div class="px-4 py-3 border-b border-border">
|
||||
<h2 class="text-lg font-medium">{{ t('dashboard.recentActivity', '最近活动') }}</h2>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="control-panel">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-foreground">
|
||||
{{ t('dashboard.recentActivity', '最近活动') }}
|
||||
<div class="p-4">
|
||||
<!-- Loading State (Only show if loading AND no logs are displayed yet) -->
|
||||
<div v-if="isLoadingLogs && recentAuditLogs.length === 0" class="text-center text-text-secondary">{{ t('common.loading') }}</div>
|
||||
<ul v-else-if="recentAuditLogs.length > 0" class="space-y-3">
|
||||
<li v-for="log in recentAuditLogs" :key="log.id" class="p-3 bg-header/50 border border-border/50 rounded"> <!-- Applied audit log item style -->
|
||||
<div class="flex justify-between items-start mb-1">
|
||||
<span class="font-medium text-sm" :class="{ 'text-error': isFailedAction(log.action_type) }">{{ getActionTranslation(log.action_type) }}</span>
|
||||
<span class="text-xs text-text-alt flex-shrink-0 ml-2">{{ formatRelativeTime(log.timestamp) }}</span>
|
||||
</div>
|
||||
<div class="text-sm text-text-secondary">
|
||||
{{ t('auditLog.paginationInfo', { currentPage: 1, totalPages: 1, totalLogs }) }}
|
||||
</div>
|
||||
</div>
|
||||
<el-button plain @click="openAuditLogsView">
|
||||
{{ t('auditLog.title', '审计日志') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="isLoadingLogs && recentAuditLogs.length === 0" class="control-empty">
|
||||
<el-skeleton :rows="5" animated />
|
||||
<p class="text-sm text-text-secondary break-words">{{ log.details }}</p>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="text-center text-text-secondary">{{ t('dashboard.noRecentActivity', '没有最近活动记录') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="recentAuditLogs.length > 0" class="grid gap-3">
|
||||
<el-card
|
||||
v-for="log in recentAuditLogs"
|
||||
:key="log.id"
|
||||
shadow="never"
|
||||
class="border border-border/50 bg-white/70"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div
|
||||
class="text-sm font-semibold"
|
||||
:class="isFailedAction(log.action_type) ? 'text-error' : 'text-foreground'"
|
||||
>
|
||||
{{ getActionTranslation(log.action_type) }}
|
||||
</div>
|
||||
<div class="mt-2 text-sm leading-6 text-text-secondary break-words">
|
||||
{{ log.details }}
|
||||
</div>
|
||||
</div>
|
||||
<el-tag size="small" effect="plain">
|
||||
{{ formatRelativeTime(log.timestamp) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</el-card>
|
||||
<div class="px-4 py-3 border-t border-border text-right">
|
||||
<RouterLink :to="{ name: 'AuditLogs' }" class="text-sm text-link hover:text-link-hover hover:underline">
|
||||
{{ t('dashboard.viewFullAuditLog', '查看完整审计日志') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="control-empty">
|
||||
<el-empty :description="t('dashboard.noRecentActivity', '没有最近活动记录')" />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Connection Form Modal -->
|
||||
<AddConnectionForm
|
||||
v-if="showAddEditConnectionForm"
|
||||
:connectionToEdit="connectionToEdit"
|
||||
@@ -480,5 +425,5 @@ const openAuditLogsView = () => {
|
||||
@connection-added="handleConnectionModified"
|
||||
@connection-updated="handleConnectionModified"
|
||||
/>
|
||||
</PageShell>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,86 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onMounted } from 'vue';
|
||||
import { reactive, ref, onMounted } from 'vue'; // computed 不再直接使用,移除
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { startAuthentication } from '@simplewebauthn/browser';
|
||||
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
|
||||
import VueRecaptcha from 'vue3-recaptcha2';
|
||||
import AuthPanelLayout from '../components/AuthPanelLayout.vue';
|
||||
import { useAuthStore } from '../stores/auth.store';
|
||||
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
|
||||
import VueRecaptcha from 'vue3-recaptcha2'; // 使用默认导入
|
||||
|
||||
const { t } = useI18n();
|
||||
const authStore = useAuthStore();
|
||||
const { isLoading, error, loginRequires2FA, publicCaptchaConfig, hasPasskeysAvailable } = storeToRefs(authStore);
|
||||
// 获取 loginRequires2FA 状态
|
||||
const { isLoading, error, loginRequires2FA, publicCaptchaConfig, hasPasskeysAvailable } = storeToRefs(authStore); // Get publicCaptchaConfig and hasPasskeysAvailable
|
||||
|
||||
// 表单数据
|
||||
const credentials = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
const twoFactorToken = ref(''); // 用于存储 2FA 验证码
|
||||
const rememberMe = ref(false); // 记住我状态,默认为 false
|
||||
const captchaToken = ref<string | null>(null); // Store CAPTCHA token
|
||||
const captchaError = ref<string | null>(null); // Store CAPTCHA specific error
|
||||
const hcaptchaWidget = ref<InstanceType<typeof VueHcaptcha> | null>(null); // Ref for hCaptcha component instance
|
||||
const recaptchaWidget = ref<InstanceType<typeof VueRecaptcha> | null>(null); // 更新 Ref 类型以匹配新导入
|
||||
|
||||
const twoFactorToken = ref('');
|
||||
const rememberMe = ref(false);
|
||||
const captchaToken = ref<string | null>(null);
|
||||
const captchaError = ref<string | null>(null);
|
||||
const hcaptchaWidget = ref<InstanceType<typeof VueHcaptcha> | null>(null);
|
||||
const recaptchaWidget = ref<InstanceType<typeof VueRecaptcha> | null>(null);
|
||||
// --- reCAPTCHA v3 Initialization ---
|
||||
// const recaptchaInstance = useReCaptcha(); // 移除 v3 实例,因为我们将使用 v2 组件
|
||||
|
||||
|
||||
// --- CAPTCHA Event Handlers ---
|
||||
const handleCaptchaVerified = (token: string) => {
|
||||
// console.log('CAPTCHA verified, token:', token);
|
||||
captchaToken.value = token;
|
||||
captchaError.value = null;
|
||||
captchaError.value = null; // Clear error on successful verification
|
||||
};
|
||||
|
||||
const handleCaptchaExpired = () => {
|
||||
// console.log('CAPTCHA expired');
|
||||
captchaToken.value = null;
|
||||
};
|
||||
|
||||
const handleCaptchaError = (errorDetails: unknown) => {
|
||||
const handleCaptchaError = (errorDetails: any) => {
|
||||
console.error('CAPTCHA error:', errorDetails);
|
||||
captchaToken.value = null;
|
||||
captchaError.value = t('login.error.captchaLoadFailed');
|
||||
};
|
||||
|
||||
const resetCaptchaWidget = () => {
|
||||
// console.log('Resetting CAPTCHA widget...');
|
||||
captchaToken.value = null;
|
||||
// Reset hCaptcha if it exists
|
||||
hcaptchaWidget.value?.reset();
|
||||
// Reset reCAPTCHA v2 if it exists
|
||||
recaptchaWidget.value?.reset();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
captchaError.value = null;
|
||||
|
||||
if (publicCaptchaConfig.value?.enabled && !loginRequires2FA.value && !captchaToken.value) {
|
||||
captchaError.value = t('login.error.captchaRequired');
|
||||
return;
|
||||
// 处理登录或 2FA 验证提交
|
||||
const handleSubmit = async () => {
|
||||
captchaError.value = null; // Clear previous CAPTCHA error
|
||||
|
||||
// --- CAPTCHA Execution & Check ---
|
||||
// --- CAPTCHA Check (v2/hCaptcha) ---
|
||||
if (publicCaptchaConfig.value?.enabled && !loginRequires2FA.value) {
|
||||
// Check if token exists (obtained via component event for v2/hCaptcha)
|
||||
if (!captchaToken.value) {
|
||||
captchaError.value = t('login.error.captchaRequired');
|
||||
return; // Stop submission if CAPTCHA is required but not completed
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (loginRequires2FA.value) {
|
||||
// 如果需要 2FA,则调用 2FA 验证 action
|
||||
await authStore.verifyLogin2FA(twoFactorToken.value);
|
||||
} else {
|
||||
// 否则,调用常规登录 action,并传递 rememberMe 和 captchaToken 状态
|
||||
await authStore.login({
|
||||
...credentials,
|
||||
rememberMe: rememberMe.value,
|
||||
captchaToken: captchaToken.value ?? undefined,
|
||||
...credentials,
|
||||
rememberMe: rememberMe.value,
|
||||
captchaToken: captchaToken.value ?? undefined // Pass token or undefined if null
|
||||
});
|
||||
}
|
||||
// 成功后的重定向由 store action 处理
|
||||
// 失败会更新 error 状态并在模板中显示
|
||||
} finally {
|
||||
if (publicCaptchaConfig.value?.enabled) {
|
||||
resetCaptchaWidget();
|
||||
}
|
||||
}
|
||||
// Reset CAPTCHA after attempt (success or failure handled by store redirect/error display)
|
||||
if (publicCaptchaConfig.value?.enabled) {
|
||||
resetCaptchaWidget(); // Reset the widget for potential retry
|
||||
}
|
||||
} // <-- Correctly closing the try block here
|
||||
};
|
||||
|
||||
// Fetch CAPTCHA config and check passkey availability on component mount
|
||||
onMounted(async () => {
|
||||
// console.log('[LoginView] Component mounted, calling fetchCaptchaConfig and checkHasPasskeysConfigured...');
|
||||
authStore.fetchCaptchaConfig();
|
||||
// Check if passkeys are available for login (uses the new public endpoint)
|
||||
// Optionally pass username if needed: await authStore.checkHasPasskeysConfigured(credentials.username);
|
||||
await authStore.checkHasPasskeysConfigured();
|
||||
});
|
||||
|
||||
// --- Passkey Login Handler ---
|
||||
const handlePasskeyLogin = async () => {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
error.value = null;
|
||||
error.value = null; // Clear previous errors
|
||||
|
||||
// Prepare body for authentication options request
|
||||
// If username is provided, include it. Otherwise, send an empty object
|
||||
// to allow the backend to attempt discoverable credential authentication.
|
||||
const authOptionsBody = credentials.username ? { username: credentials.username } : {};
|
||||
|
||||
// Step 1: Get authentication options from the server
|
||||
const optionsResponse = await fetch('/api/v1/auth/passkey/authentication-options', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -91,136 +120,138 @@ const handlePasskeyLogin = async () => {
|
||||
const errData = await optionsResponse.json();
|
||||
throw new Error(errData.message || t('login.error.passkeyAuthOptionsFailed'));
|
||||
}
|
||||
|
||||
const authOptions = await optionsResponse.json();
|
||||
|
||||
// Step 2: Use WebAuthn API to authenticate
|
||||
const authenticationResult = await startAuthentication(authOptions);
|
||||
|
||||
// Step 3: Send authentication result to the server
|
||||
// Pass username if it was used to get options, otherwise pass null or rely on backend to extract from assertion
|
||||
// For simplicity, we'll pass the username if available, or an empty string if not.
|
||||
// The store action `loginWithPasskey` expects a string.
|
||||
// The backend should ideally identify the user from the assertion if an empty username is provided.
|
||||
await authStore.loginWithPasskey(credentials.username || '', authenticationResult);
|
||||
|
||||
} catch (err: any) {
|
||||
console.error('Passkey login error:', err);
|
||||
error.value = err.message || t('login.error.passkeyAuthFailed');
|
||||
// Potentially reset CAPTCHA if it was involved, though typically not for passkey flows directly
|
||||
// if (publicCaptchaConfig.value?.enabled) {
|
||||
// resetCaptchaWidget();
|
||||
// }
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthPanelLayout
|
||||
:title="t('login.title')"
|
||||
:subtitle="t('login.controlCenterSubtitle', '使用密码、双重验证或 Passkey 安全接入你的控制中心。')"
|
||||
>
|
||||
<el-form label-position="top" @submit.prevent="handleSubmit">
|
||||
<div class="grid gap-5">
|
||||
<template v-if="!loginRequires2FA">
|
||||
<el-form-item :label="t('login.username')">
|
||||
<el-input v-model="credentials.username" :disabled="isLoading" size="large" clearable>
|
||||
<template #prefix>
|
||||
<i class="fas fa-user text-text-secondary"></i>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="t('login.password')">
|
||||
<el-input
|
||||
v-model="credentials.password"
|
||||
:disabled="isLoading"
|
||||
type="password"
|
||||
show-password
|
||||
size="large"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-lock text-text-secondary"></i>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<div class="flex items-center justify-between gap-3 rounded-2xl border border-border bg-white/60 px-4 py-3">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-foreground">{{ t('login.rememberMe', '记住我') }}</div>
|
||||
<div class="text-xs text-text-secondary">
|
||||
{{ t('login.sessionHint', '在受信任设备上保留登录状态。') }}
|
||||
</div>
|
||||
</div>
|
||||
<el-checkbox v-model="rememberMe" :disabled="isLoading" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form-item v-else :label="t('login.twoFactorPrompt')">
|
||||
<el-input
|
||||
v-model="twoFactorToken"
|
||||
:disabled="isLoading"
|
||||
maxlength="6"
|
||||
inputmode="numeric"
|
||||
size="large"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-shield-halved text-text-secondary"></i>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-card
|
||||
v-if="publicCaptchaConfig && publicCaptchaConfig.enabled && !loginRequires2FA"
|
||||
shadow="never"
|
||||
class="border border-border/70 bg-white/65"
|
||||
>
|
||||
<div class="mb-3 text-sm font-medium text-foreground">
|
||||
{{ t('login.captchaPrompt') }}
|
||||
</div>
|
||||
|
||||
<div v-if="publicCaptchaConfig?.provider === 'hcaptcha' && publicCaptchaConfig.hcaptchaSiteKey">
|
||||
<VueHcaptcha
|
||||
ref="hcaptchaWidget"
|
||||
:sitekey="publicCaptchaConfig.hcaptchaSiteKey"
|
||||
@verify="handleCaptchaVerified"
|
||||
@expired="handleCaptchaExpired"
|
||||
@error="handleCaptchaError"
|
||||
theme="light"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else-if="publicCaptchaConfig?.provider === 'recaptcha' && publicCaptchaConfig.recaptchaSiteKey">
|
||||
<VueRecaptcha
|
||||
ref="recaptchaWidget"
|
||||
:sitekey="publicCaptchaConfig.recaptchaSiteKey"
|
||||
@verify="handleCaptchaVerified"
|
||||
@expire="handleCaptchaExpired"
|
||||
@fail="handleCaptchaError"
|
||||
theme="light"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="captchaError"
|
||||
class="mt-4"
|
||||
:title="captchaError"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<el-alert
|
||||
v-if="error"
|
||||
:title="error"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
|
||||
<el-button native-type="submit" type="primary" size="large" :loading="isLoading" class="w-full">
|
||||
{{ loginRequires2FA ? t('login.verifyButton') : t('login.loginButton') }}
|
||||
</el-button>
|
||||
|
||||
<template v-if="hasPasskeysAvailable && !loginRequires2FA">
|
||||
<el-divider>{{ t('login.passkeyDivider', '或使用安全密钥') }}</el-divider>
|
||||
|
||||
<el-button plain size="large" class="w-full" :loading="isLoading" @click="handlePasskeyLogin">
|
||||
<i class="fas fa-key mr-2"></i>
|
||||
{{ t('login.loginWithPasskey') }}
|
||||
</el-button>
|
||||
</template>
|
||||
<!-- Page Container -->
|
||||
<div class="flex items-center justify-center min-h-screen bg-background p-4">
|
||||
<!-- Login Card -->
|
||||
<div class="flex w-full max-w-4xl rounded-xl shadow-2xl overflow-hidden bg-background border border-border/20">
|
||||
<!-- Left Panel (Brand) - Hidden on small screens -->
|
||||
<div class="hidden md:flex w-2/5 bg-gradient-to-br from-primary to-primary-dark flex-col items-center justify-center p-10 text-white relative">
|
||||
<!-- Subtle pattern or overlay could go here -->
|
||||
<div class="z-10 text-center">
|
||||
<img src="../assets/logo.png" alt="Project Logo" class="h-20 w-auto mb-5 mx-auto">
|
||||
<h1 class="text-3xl font-bold mb-2">{{ t('projectName') }}</h1>
|
||||
<p class="text-base opacity-80">{{ t('slogan') }}</p> <!-- Example Slogan -->
|
||||
</div>
|
||||
</div>
|
||||
</el-form>
|
||||
</AuthPanelLayout>
|
||||
|
||||
<!-- Right Panel (Login Form) -->
|
||||
<div class="w-full md:w-3/5 flex flex-col justify-center p-8 sm:p-12">
|
||||
<!-- Mobile Logo (optional) -->
|
||||
<div class="flex justify-center mb-6 md:hidden">
|
||||
<img src="../assets/logo.png" alt="Project Logo" class="h-16 w-auto">
|
||||
</div>
|
||||
<h2 class="text-2xl font-semibold mb-6 text-center text-foreground">{{ t('login.title') }}</h2>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="space-y-5"> <!-- Reduced space slightly -->
|
||||
<!-- Regular Login Fields -->
|
||||
<div v-if="!loginRequires2FA" class="space-y-6">
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-text-secondary mb-1">{{ t('login.username') }}</label>
|
||||
<input type="text" id="username" v-model="credentials.username" required :disabled="isLoading"
|
||||
class="w-full px-4 py-3 border border-border/50 rounded-lg bg-input text-foreground text-base shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition duration-150 ease-in-out disabled:bg-gray-100 disabled:cursor-not-allowed" />
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-text-secondary mb-1">{{ t('login.password') }}</label>
|
||||
<input type="password" id="password" v-model="credentials.password" required :disabled="isLoading"
|
||||
class="w-full px-4 py-3 border border-border/50 rounded-lg bg-input text-foreground text-base shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition duration-150 ease-in-out disabled:bg-gray-100 disabled:cursor-not-allowed" />
|
||||
</div>
|
||||
<!-- Remember Me Checkbox -->
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="rememberMe" v-model="rememberMe" :disabled="isLoading"
|
||||
class="w-4 h-4 mr-2 accent-primary rounded border-gray-300 focus:ring-primary disabled:cursor-not-allowed" />
|
||||
<label for="rememberMe" class="text-sm text-text-secondary cursor-pointer">{{ t('login.rememberMe', '记住我') }}</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2FA Token Input -->
|
||||
<div v-if="loginRequires2FA">
|
||||
<label for="twoFactorToken" class="block text-sm font-medium text-text-secondary mb-1">{{ t('login.twoFactorPrompt') }}</label>
|
||||
<input type="text" id="twoFactorToken" v-model="twoFactorToken" required :disabled="isLoading" pattern="\d{6}" title="请输入 6 位数字验证码"
|
||||
class="w-full px-4 py-3 border border-border/50 rounded-lg bg-input text-foreground text-base shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition duration-150 ease-in-out disabled:bg-gray-100 disabled:cursor-not-allowed" />
|
||||
</div>
|
||||
|
||||
<!-- CAPTCHA Area -->
|
||||
<!-- 恢复原始的 v-if 条件 -->
|
||||
<div v-if="publicCaptchaConfig && publicCaptchaConfig.enabled && !loginRequires2FA" class="space-y-2">
|
||||
<!-- 提示标签 -->
|
||||
<label class="block text-sm font-medium text-text-secondary">{{ t('login.captchaPrompt') }}</label>
|
||||
<!-- hCaptcha Component -->
|
||||
<div v-if="publicCaptchaConfig?.provider === 'hcaptcha' && publicCaptchaConfig.hcaptchaSiteKey">
|
||||
<VueHcaptcha
|
||||
ref="hcaptchaWidget"
|
||||
:sitekey="publicCaptchaConfig.hcaptchaSiteKey"
|
||||
@verify="handleCaptchaVerified"
|
||||
@expired="handleCaptchaExpired"
|
||||
@error="handleCaptchaError"
|
||||
theme="auto"
|
||||
></VueHcaptcha>
|
||||
</div>
|
||||
<!-- reCAPTCHA v2 Component -->
|
||||
<div v-else-if="publicCaptchaConfig?.provider === 'recaptcha' && publicCaptchaConfig.recaptchaSiteKey">
|
||||
<VueRecaptcha
|
||||
ref="recaptchaWidget"
|
||||
:sitekey="publicCaptchaConfig.recaptchaSiteKey"
|
||||
@verify="handleCaptchaVerified"
|
||||
@expire="handleCaptchaExpired"
|
||||
@fail="handleCaptchaError"
|
||||
theme="light"
|
||||
/>
|
||||
<!-- 注意: 根据 vue3-recaptcha2 文档调整事件名 @expire, @fail -->
|
||||
<!-- 注意: publicCaptchaConfig 需要包含 recaptchaSiteKey -->
|
||||
<!-- theme 可以是 'light' 或 'dark' -->
|
||||
</div>
|
||||
<!-- CAPTCHA Error Message -->
|
||||
<div v-if="captchaError" class="text-error text-sm">
|
||||
{{ captchaError }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- General Login Error -->
|
||||
<div v-if="error" class="text-error text-center text-sm -mt-2 mb-2"> <!-- Adjusted margin -->
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<button type="submit" :disabled="isLoading"
|
||||
class="w-full py-3 px-4 bg-primary text-white border-none rounded-lg text-base font-semibold cursor-pointer shadow-md transition-colors duration-200 ease-in-out hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:bg-gray-400 disabled:cursor-not-allowed disabled:opacity-70">
|
||||
{{ isLoading ? t('login.loggingIn') : (loginRequires2FA ? t('login.verifyButton') : t('login.loginButton')) }}
|
||||
</button>
|
||||
|
||||
<!-- Passkey Login Button -->
|
||||
<div v-if="hasPasskeysAvailable" class="mt-4 text-center">
|
||||
<button type="button" @click="handlePasskeyLogin" :disabled="isLoading"
|
||||
class="w-full py-3 px-4 bg-secondary text-black border-none rounded-lg text-base font-semibold cursor-pointer shadow-md transition-colors duration-200 ease-in-out hover:bg-secondary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-secondary disabled:bg-gray-400 disabled:cursor-not-allowed disabled:opacity-70 flex items-center justify-center">
|
||||
<i class="fas fa-key mr-2"></i>
|
||||
<span>{{ isLoading ? t('login.loggingIn') : t('login.loginWithPasskey') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
<template>
|
||||
<div class="p-4 bg-background text-foreground">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<NotificationSettings />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import PageShell from '../components/PageShell.vue';
|
||||
import NotificationSettings from '../components/NotificationSettings.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageShell
|
||||
:title="$t('nav.notifications')"
|
||||
:subtitle="$t('notifications.controlCenterSubtitle', '集中配置 webhook、邮件与 Telegram 通知渠道,统一管理触发事件。')"
|
||||
>
|
||||
<el-card shadow="never" class="control-panel">
|
||||
<NotificationSettings />
|
||||
</el-card>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -2,9 +2,8 @@
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useProxiesStore, ProxyInfo } from '../stores/proxies.store';
|
||||
import PageShell from '../components/PageShell.vue';
|
||||
import ProxyList from '../components/ProxyList.vue';
|
||||
import AddProxyForm from '../components/AddProxyForm.vue';
|
||||
import AddProxyForm from '../components/AddProxyForm.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const proxiesStore = useProxiesStore();
|
||||
@@ -12,6 +11,7 @@ const proxiesStore = useProxiesStore();
|
||||
const showForm = ref(false);
|
||||
const editingProxy = ref<ProxyInfo | null>(null);
|
||||
|
||||
// 组件挂载时获取代理列表
|
||||
onMounted(() => {
|
||||
proxiesStore.fetchProxies();
|
||||
});
|
||||
@@ -42,18 +42,21 @@ const closeForm = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageShell
|
||||
:title="t('proxies.title')"
|
||||
:subtitle="t('proxies.controlCenterSubtitle', '在统一的控制中心里管理代理入口、账号和转发策略。')"
|
||||
>
|
||||
<template #actions>
|
||||
<el-button type="primary" @click="openAddForm">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
{{ t('proxies.addProxy') }}
|
||||
</el-button>
|
||||
</template>
|
||||
<div class="p-4 bg-background text-foreground"> <!-- Outer container with padding -->
|
||||
<div class="max-w-6xl mx-auto"> <!-- Inner container for max-width and centering -->
|
||||
<h2 class="text-xl font-semibold text-foreground mb-4 pb-2 border-b border-border"> <!-- Title styling consistent with Notifications -->
|
||||
{{ t('proxies.title') }}
|
||||
</h2>
|
||||
|
||||
<el-card shadow="never" class="control-panel">
|
||||
<button
|
||||
@click="openAddForm"
|
||||
v-if="!showForm"
|
||||
class="px-4 py-2 bg-button text-button-text rounded hover:bg-button-hover mb-4 inline-flex items-center text-sm font-medium"
|
||||
> <!-- Button styling consistent with Notifications -->
|
||||
{{ t('proxies.addProxy') }}
|
||||
</button>
|
||||
|
||||
<!-- 添加/编辑代理表单 -->
|
||||
<AddProxyForm
|
||||
v-if="showForm"
|
||||
:proxy-to-edit="editingProxy"
|
||||
@@ -62,7 +65,13 @@ const closeForm = () => {
|
||||
@proxy-updated="handleProxyUpdated"
|
||||
/>
|
||||
|
||||
<!-- 代理列表 -->
|
||||
<ProxyList @edit-proxy="handleEditRequest" />
|
||||
</el-card>
|
||||
</PageShell>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Remove scoped styles previously handled by Tailwind */
|
||||
/* .proxies-view, button, button:hover, button:disabled, .placeholder-form, .placeholder-list rules are removed */
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,98 @@
|
||||
<template>
|
||||
<div class="p-4 bg-background text-foreground min-h-screen"> <!-- Outer container -->
|
||||
<div class="max-w-7xl mx-auto"> <!-- Inner container for max-width -->
|
||||
<!-- Tabs Navigation -->
|
||||
<div class="mb-6 flex space-x-1 bg-background z-10 py-2">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
@click="activeTab = tab.key"
|
||||
:class="['px-4 py-2 text-sm font-medium rounded-md focus:outline-none transition-colors duration-150 ease-in-out',
|
||||
activeTab === tab.key ? 'bg-primary text-white' : 'text-muted-foreground hover:bg-muted/50 hover:text-foreground']"
|
||||
>
|
||||
<span class="relative flex items-center" :class="{'text-warning': tab.key === 'about' && isUpdateAvailable}">
|
||||
{{ tab.label }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Error state (Show first if error exists) -->
|
||||
<div v-if="settingsError" class="p-4 mb-4 border-l-4 border-error bg-error/10 text-error rounded">
|
||||
{{ settingsError }}
|
||||
</div>
|
||||
|
||||
<!-- Settings Content based on activeTab -->
|
||||
<div v-else class="space-y-6">
|
||||
<!-- Security Tab Content -->
|
||||
<div v-if="activeTab === 'security'">
|
||||
<div v-if="settings" class="bg-background border border-border rounded-lg shadow-sm overflow-hidden">
|
||||
<h2 class="text-lg font-semibold text-foreground px-6 py-4 border-b border-border bg-header/50">{{ $t('settings.category.security') }}</h2>
|
||||
<div class="p-6 space-y-6">
|
||||
<ChangePasswordForm />
|
||||
<hr class="border-border/50">
|
||||
<PasskeyManagement />
|
||||
<hr class="border-border/50">
|
||||
<TwoFactorAuthSettings />
|
||||
<hr class="border-border/50">
|
||||
<CaptchaSettingsForm />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="p-4 text-center text-muted-foreground">{{ $t('settings.loading', '加载中...') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- IP Control Tab Content -->
|
||||
<div v-if="activeTab === 'ipControl'">
|
||||
<div v-if="settings" class="bg-background border border-border rounded-lg shadow-sm overflow-hidden mb-6">
|
||||
<h2 class="text-lg font-semibold text-foreground px-6 py-4 border-b border-border bg-header/50">{{ $t('settings.ipWhitelist.title') }}</h2>
|
||||
<div class="p-6 space-y-6">
|
||||
<IpWhitelistSettings />
|
||||
</div>
|
||||
</div>
|
||||
<IpBlacklistSettings v-if="settings" />
|
||||
<div v-else-if="!settings && activeTab === 'ipControl'" class="p-4 text-center text-muted-foreground">{{ $t('settings.loading', '加载中...') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Workspace Tab Content -->
|
||||
<div v-if="activeTab === 'workspace'">
|
||||
<WorkspaceSettingsSection v-if="settings" />
|
||||
<div v-else class="p-4 text-center text-muted-foreground">{{ $t('settings.loading', '加载中...') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- System Tab Content -->
|
||||
<div v-if="activeTab === 'system'">
|
||||
<SystemSettingsSection v-if="settings" />
|
||||
<div v-else class="p-4 text-center text-muted-foreground">{{ $t('settings.loading', '加载中...') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Data Management Tab Content -->
|
||||
<div v-if="activeTab === 'dataManagement'">
|
||||
<DataManagementSection v-if="settings" />
|
||||
<div v-else class="p-4 text-center text-muted-foreground">{{ $t('settings.loading', '加载中...') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Appearance Tab Content -->
|
||||
<div v-if="activeTab === 'appearance'">
|
||||
<AppearanceSection v-if="settings" />
|
||||
<div v-else class="p-4 text-center text-muted-foreground">{{ $t('settings.loading', '加载中...') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- About Tab Content -->
|
||||
<div v-if="activeTab === 'about'">
|
||||
<AboutSection />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useAuthStore } from '../stores/auth.store';
|
||||
import { useSettingsStore } from '../stores/settings.store';
|
||||
import { useAppearanceStore } from '../stores/appearance.store';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { useVersionCheck } from '../composables/settings/useVersionCheck';
|
||||
import PageShell from '../components/PageShell.vue';
|
||||
import ChangePasswordForm from '../components/settings/ChangePasswordForm.vue';
|
||||
import PasskeyManagement from '../components/settings/PasskeyManagement.vue';
|
||||
import TwoFactorAuthSettings from '../components/settings/TwoFactorAuthSettings.vue';
|
||||
@@ -17,157 +105,44 @@ import SystemSettingsSection from '../components/settings/SystemSettingsSection.
|
||||
import DataManagementSection from '../components/settings/DataManagementSection.vue';
|
||||
import AppearanceSection from '../components/settings/AppearanceSection.vue';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const settingsStore = useSettingsStore();
|
||||
const appearanceStore = useAppearanceStore(); // 实例化外观 store
|
||||
const { t } = useI18n();
|
||||
const { isUpdateAvailable, checkLatestVersion } = useVersionCheck();
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ key: 'workspace', label: t('settings.tabs.workspace', '工作区'), icon: 'fas fa-sliders' },
|
||||
{ key: 'system', label: t('settings.tabs.system', '系统'), icon: 'fas fa-server' },
|
||||
{ key: 'security', label: t('settings.tabs.security', '安全'), icon: 'fas fa-shield-halved' },
|
||||
{ key: 'ipControl', label: t('settings.tabs.ipControl', 'IP 管控'), icon: 'fas fa-network-wired' },
|
||||
{ key: 'dataManagement', label: t('settings.tabs.dataManagement', '数据管理'), icon: 'fas fa-database' },
|
||||
{ key: 'appearance', label: t('settings.tabs.appearance', '外观'), icon: 'fas fa-palette' },
|
||||
{ key: 'about', label: t('settings.tabs.about', '关于'), icon: 'fas fa-circle-info' },
|
||||
// Define tabs for settings sections
|
||||
const tabs = ref([
|
||||
{ key: 'workspace', label: t('settings.tabs.workspace', '工作区') },
|
||||
{ key: 'system', label: t('settings.tabs.system', '系统') },
|
||||
{ key: 'security', label: t('settings.tabs.security', '安全') },
|
||||
{ key: 'ipControl', label: t('settings.tabs.ipControl', 'IP 管控') },
|
||||
{ key: 'dataManagement', label: t('settings.tabs.dataManagement', '数据管理') },
|
||||
{ key: 'appearance', label: t('settings.tabs.appearance', '外观') },
|
||||
{ key: 'about', label: t('settings.tabs.about', '关于') },
|
||||
]);
|
||||
const activeTab = ref(tabs.value[0].key);
|
||||
|
||||
const activeTab = ref('workspace');
|
||||
|
||||
// --- Reactive state from store ---
|
||||
// 使用 storeToRefs 获取响应式 getter,包括 language
|
||||
const {
|
||||
settings,
|
||||
isLoading: settingsLoading,
|
||||
error: settingsError,
|
||||
settings,
|
||||
isLoading: settingsLoading,
|
||||
error: settingsError,
|
||||
language: storeLanguage,
|
||||
} = storeToRefs(settingsStore);
|
||||
|
||||
const settingsStats = computed(() => [
|
||||
{
|
||||
label: t('settings.tabs.workspace', '工作区'),
|
||||
value: activeTab.value === 'workspace' ? 'Active' : 'Ready',
|
||||
meta: t('settings.workspace.title', '工作区与终端'),
|
||||
},
|
||||
{
|
||||
label: t('settings.tabs.security', '安全'),
|
||||
value: settings.value ? 'Live' : 'Pending',
|
||||
meta: t('settings.category.security', '安全设置'),
|
||||
},
|
||||
{
|
||||
label: t('settings.tabs.appearance', '外观'),
|
||||
value: isUpdateAvailable.value ? 'Update' : 'Stable',
|
||||
meta: isUpdateAvailable.value
|
||||
? t('settings.about.updateAvailable', '发现新版本')
|
||||
: t('settings.about.latestVersion', '已是最新版本'),
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
await settingsStore.loadCaptchaSettings();
|
||||
await checkLatestVersion();
|
||||
// await fetchIpBlacklist(); // REMOVED - Handled by useIpBlacklist.ts onMounted
|
||||
await settingsStore.loadCaptchaSettings(); // <-- Load CAPTCHA settings
|
||||
await checkLatestVersion(); // 检查版本更新
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageShell
|
||||
:title="t('nav.settings')"
|
||||
:subtitle="t('settings.controlCenterSubtitle', '将系统、安全、外观与工作区配置统一收束到一个控制中心。')"
|
||||
>
|
||||
<template #badge>
|
||||
<el-tag v-if="isUpdateAvailable" type="warning" effect="light" round>
|
||||
{{ t('settings.about.updateAvailable', '发现新版本') }}
|
||||
</el-tag>
|
||||
</template>
|
||||
|
||||
<template #stats>
|
||||
<div class="control-stat-grid">
|
||||
<div v-for="stat in settingsStats" :key="stat.label" class="control-stat-card">
|
||||
<span class="control-stat-card__label">{{ stat.label }}</span>
|
||||
<span class="control-stat-card__value">{{ stat.value }}</span>
|
||||
<span class="control-stat-card__meta">{{ stat.meta }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert
|
||||
v-if="settingsError"
|
||||
:title="settingsError"
|
||||
type="error"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
|
||||
<el-card v-else shadow="never" class="control-panel">
|
||||
<el-tabs v-model="activeTab" class="settings-tabs">
|
||||
<el-tab-pane v-for="tab in tabs" :key="tab.key" :name="tab.key">
|
||||
<template #label>
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<i :class="tab.icon"></i>
|
||||
<span>{{ tab.label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div v-if="settingsLoading && !settings" class="control-empty">
|
||||
<el-skeleton :rows="6" animated />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="activeTab === 'security'" class="grid gap-4">
|
||||
<el-card shadow="never">
|
||||
<template #header>{{ t('settings.category.security') }}</template>
|
||||
<div class="grid gap-6">
|
||||
<ChangePasswordForm />
|
||||
<el-divider />
|
||||
<PasskeyManagement />
|
||||
<el-divider />
|
||||
<TwoFactorAuthSettings />
|
||||
<el-divider />
|
||||
<CaptchaSettingsForm />
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'ipControl'" class="grid gap-4">
|
||||
<el-card shadow="never">
|
||||
<template #header>{{ t('settings.ipWhitelist.title') }}</template>
|
||||
<IpWhitelistSettings />
|
||||
</el-card>
|
||||
<el-card shadow="never">
|
||||
<template #header>{{ t('settings.ipBlacklist.title', 'IP 黑名单') }}</template>
|
||||
<IpBlacklistSettings />
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-card v-if="activeTab === 'workspace'" shadow="never">
|
||||
<template #header>{{ t('settings.tabs.workspace', '工作区') }}</template>
|
||||
<WorkspaceSettingsSection />
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="activeTab === 'system'" shadow="never">
|
||||
<template #header>{{ t('settings.tabs.system', '系统') }}</template>
|
||||
<SystemSettingsSection />
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="activeTab === 'dataManagement'" shadow="never">
|
||||
<template #header>{{ t('settings.tabs.dataManagement', '数据管理') }}</template>
|
||||
<DataManagementSection />
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="activeTab === 'appearance'" shadow="never">
|
||||
<template #header>{{ t('settings.tabs.appearance', '外观') }}</template>
|
||||
<AppearanceSection />
|
||||
</el-card>
|
||||
|
||||
<el-card v-if="activeTab === 'about'" shadow="never">
|
||||
<template #header>{{ t('settings.tabs.about', '关于') }}</template>
|
||||
<AboutSection />
|
||||
</el-card>
|
||||
</template>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-tabs :deep(.el-tabs__header) {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
/* Remove all scoped styles as they are now handled by Tailwind utility classes */
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,14 +1,98 @@
|
||||
<template>
|
||||
<!-- Page Container with Subtle Dot Background -->
|
||||
<div class="flex items-center justify-center min-h-screen bg-background p-4 bg-[radial-gradient(theme(colors.border)_1px,transparent_1px)] bg-[size:16px_16px]">
|
||||
<!-- Setup Card -->
|
||||
<div class="flex w-full max-w-4xl rounded-xl shadow-2xl overflow-hidden bg-background border border-border/20">
|
||||
<!-- Left Panel (Brand) - Hidden on small screens -->
|
||||
<div class="hidden md:flex w-2/5 bg-gradient-to-br from-primary to-primary-dark flex-col items-center justify-center p-10 text-white relative">
|
||||
<!-- Subtle pattern or overlay could go here -->
|
||||
<div class="z-10 text-center">
|
||||
<img src="../assets/logo.png" alt="Project Logo" class="h-20 w-auto mb-5 mx-auto">
|
||||
<h1 class="text-3xl font-bold mb-2">{{ $t('projectName') }}</h1>
|
||||
<p class="text-base opacity-80">{{ $t('setup.description') }}</p> <!-- Moved description here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Panel (Setup Form) -->
|
||||
<div class="w-full md:w-3/5 flex flex-col justify-center p-8 sm:p-12">
|
||||
<!-- Mobile Logo & Title (optional) -->
|
||||
<div class="flex flex-col items-center mb-6 md:hidden">
|
||||
<img src="../assets/logo.png" alt="Project Logo" class="h-16 w-auto mb-3">
|
||||
<h2 class="text-xl font-semibold text-foreground">{{ $t('setup.title') }}</h2>
|
||||
<p class="text-sm text-text-secondary mt-1">{{ $t('setup.description') }}</p>
|
||||
</div>
|
||||
<!-- Desktop Title (Subtle) -->
|
||||
<h2 class="text-2xl font-semibold mb-6 text-center text-foreground hidden md:block">{{ $t('setup.title') }}</h2>
|
||||
|
||||
<form @submit.prevent="handleSetup" class="space-y-5"> <!-- Reduced space slightly -->
|
||||
<div>
|
||||
<label for="username" class="block text-sm font-medium text-text-secondary mb-1">{{ $t('setup.username') }}</label>
|
||||
<input
|
||||
id="username"
|
||||
v-model="username"
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
:disabled="isLoading"
|
||||
:placeholder="$t('setup.usernamePlaceholder')"
|
||||
class="w-full px-4 py-3 border border-border/50 rounded-lg bg-input text-foreground text-base shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition duration-150 ease-in-out disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-text-secondary mb-1">{{ $t('setup.password') }}</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
:disabled="isLoading"
|
||||
:placeholder="$t('setup.passwordPlaceholder')"
|
||||
class="w-full px-4 py-3 border border-border/50 rounded-lg bg-input text-foreground text-base shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition duration-150 ease-in-out disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="confirmPassword" class="block text-sm font-medium text-text-secondary mb-1">{{ $t('setup.confirmPassword') }}</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
v-model="confirmPassword"
|
||||
name="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
:disabled="isLoading"
|
||||
:placeholder="$t('setup.confirmPasswordPlaceholder')"
|
||||
class="w-full px-4 py-3 border border-border/50 rounded-lg bg-input text-foreground text-base shadow-sm focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary transition duration-150 ease-in-out disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="text-error bg-error/10 border border-error/20 px-4 py-2 rounded text-center text-sm"> <!-- Adjusted padding -->
|
||||
{{ error }}
|
||||
</div>
|
||||
<div v-if="successMessage" class="text-success bg-success/10 border border-success/20 px-4 py-2 rounded text-center text-sm"> <!-- Adjusted padding -->
|
||||
{{ successMessage }}
|
||||
</div>
|
||||
|
||||
<button type="submit" :disabled="isLoading"
|
||||
class="w-full py-3 px-4 bg-primary text-white border-none rounded-lg text-base font-semibold cursor-pointer shadow-md transition-colors duration-200 ease-in-out hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary disabled:bg-gray-400 disabled:opacity-70 disabled:cursor-not-allowed">
|
||||
<span v-if="isLoading">{{ $t('setup.settingUp') }}</span>
|
||||
<span v-else>{{ $t('setup.submitButton') }}</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import apiClient from '../utils/apiClient'; // 使用统一的 apiClient
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import AuthPanelLayout from '../components/AuthPanelLayout.vue';
|
||||
import apiClient from '../utils/apiClient';
|
||||
import { useAuthStore } from '../stores/auth.store';
|
||||
import { useAuthStore } from '../stores/auth.store'; // *** 导入 Auth Store ***
|
||||
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const authStore = useAuthStore(); // *** 获取 Auth Store 实例 ***
|
||||
|
||||
const username = ref('');
|
||||
const password = ref('');
|
||||
@@ -27,105 +111,44 @@ const handleSetup = async () => {
|
||||
}
|
||||
|
||||
if (!username.value || !password.value) {
|
||||
error.value = t('setup.error.fieldsRequired');
|
||||
return;
|
||||
error.value = t('setup.error.fieldsRequired');
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
await apiClient.post('/auth/setup', {
|
||||
// 确保调用正确的后端 API 端点
|
||||
await apiClient.post('/auth/setup', { // 使用 apiClient 并移除 base URL
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
confirmPassword: confirmPassword.value,
|
||||
confirmPassword: confirmPassword.value
|
||||
});
|
||||
|
||||
successMessage.value = t('setup.success');
|
||||
// *** 手动更新 needsSetup 状态 ***
|
||||
authStore.needsSetup = false;
|
||||
// *** 重置认证状态,因为设置完成后需要重新登录 ***
|
||||
authStore.isAuthenticated = false;
|
||||
authStore.user = null;
|
||||
// 禁用表单或按钮,防止重复提交
|
||||
isLoading.value = true; // Keep loading state to disable button
|
||||
// Redirect to login immediately after showing success message (removed setTimeout)
|
||||
// The success message will be briefly visible before navigation.
|
||||
router.push('/login');
|
||||
} catch (err: any) {
|
||||
console.error('Setup failed:', err);
|
||||
if (err.response?.data?.message) {
|
||||
// 尝试从后端响应中获取更具体的错误信息
|
||||
error.value = err.response.data.message;
|
||||
} else if (err.message) {
|
||||
error.value = err.message;
|
||||
error.value = err.message;
|
||||
} else {
|
||||
error.value = t('setup.error.generic');
|
||||
error.value = t('setup.error.generic');
|
||||
}
|
||||
isLoading.value = false;
|
||||
isLoading.value = false; // Re-enable button on error
|
||||
}
|
||||
// Removed finally block setting isLoading to false on success to keep button disabled
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AuthPanelLayout
|
||||
:title="t('setup.title')"
|
||||
:subtitle="t('setup.description')"
|
||||
accent-label="Slate Bootstrap"
|
||||
>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-5"
|
||||
:title="t('setup.bootstrapHint', '创建首个管理员账号后即可进入完整控制中心。')"
|
||||
/>
|
||||
|
||||
<el-form label-position="top" @submit.prevent="handleSetup">
|
||||
<div class="grid gap-5">
|
||||
<el-form-item :label="t('setup.username')">
|
||||
<el-input
|
||||
v-model="username"
|
||||
:disabled="isLoading"
|
||||
:placeholder="t('setup.usernamePlaceholder')"
|
||||
size="large"
|
||||
clearable
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-user text-text-secondary"></i>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="t('setup.password')">
|
||||
<el-input
|
||||
v-model="password"
|
||||
:disabled="isLoading"
|
||||
:placeholder="t('setup.passwordPlaceholder')"
|
||||
type="password"
|
||||
show-password
|
||||
size="large"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-lock text-text-secondary"></i>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="t('setup.confirmPassword')">
|
||||
<el-input
|
||||
v-model="confirmPassword"
|
||||
:disabled="isLoading"
|
||||
:placeholder="t('setup.confirmPasswordPlaceholder')"
|
||||
type="password"
|
||||
show-password
|
||||
size="large"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="fas fa-check text-text-secondary"></i>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
|
||||
<el-alert v-if="successMessage" :title="successMessage" type="success" :closable="false" show-icon />
|
||||
|
||||
<el-button native-type="submit" type="primary" size="large" class="w-full" :loading="isLoading">
|
||||
{{ t('setup.submitButton') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</AuthPanelLayout>
|
||||
</template>
|
||||
<!-- Copied styles from LoginView.vue -->
|
||||
Reference in New Issue
Block a user