Revert "feat(frontend): unify ui with slate control center"

This reverts commit 91aa6e83ca.
This commit is contained in:
yinjianm
2026-03-25 05:21:34 +08:00
parent b9a4917467
commit d8a99e55b8
20 changed files with 1638 additions and 2733 deletions
+1 -2
View File
@@ -4,6 +4,5 @@
- 2026-03-25:初始化 `.helloagents/` 知识库骨架与首批模块文档,不代表源码功能变更。 - 2026-03-25:初始化 `.helloagents/` 知识库骨架与首批模块文档,不代表源码功能变更。
- 2026-03-25:新增 GHCR Docker 发布 workflow,并将 `docker-compose.yml` 的三个业务镜像切换到 `ghcr.io/micah123321/*` - 2026-03-25:新增 GHCR Docker 发布 workflow,并将 `docker-compose.yml` 的三个业务镜像切换到 `ghcr.io/micah123321/*`
- 2026-03-25`/workspace` 默认布局改为“左侧 Workbench + 中央终端 + 右侧状态监控”,并在状态监控中新增开机累计上/下行流量展示。 - 2026-03-25`/workspace` 默认布局改为“左侧 Workbench + 中央终端 + 右侧状态监控”,并在状态监控中新增开机累计上下行流量展示。
- 2026-03-25:继续微调 `/workspace` Workbench,新增默认“快捷指令”标签、调整三栏宽度到更接近 xterminal 参考图,并修复终端区域鼠标悬停时指针异常消失的问题。 - 2026-03-25:继续微调 `/workspace` Workbench,新增默认“快捷指令”标签、调整三栏宽度到更接近 xterminal 参考图,并修复终端区域鼠标悬停时指针异常消失的问题。
- 2026-03-25:前端主站视觉语言统一升级为 `Slate Control Center`,新增公共页面壳层与认证壳层,并重做 Dashboard、Settings、Login、Setup、Notifications、Proxies、Audit Logs、Workbench、StatusMonitor 的现代化 UI 表达。
-6
View File
@@ -45,9 +45,3 @@
依赖: workspace-root, backend, remote-gateway, vue-router, pinia 依赖: workspace-root, backend, remote-gateway, vue-router, pinia
被依赖: 被依赖:
``` ```
## 最近变更
- 2026-03-25: 前端主站视觉语言统一切换为 `Slate Control Center`,新增 `PageShell.vue``AuthPanelLayout.vue` 作为主要页面和认证入口的统一壳层。
- 2026-03-25: `/workspace` 继续保持“三栏工作台”结构,但左侧 `Workbench` 与右侧 `StatusMonitor` 已重做为更现代的 Element Plus 控制中心风格;状态监控保留并展示开机累计上/下行流量。
- 2026-03-25: `Dashboard / Settings / Login / Setup / Notifications / Proxies / Audit Logs` 已统一接入新的卡片化表达、控制区和统计信息风格,后续同类页面优先复用公共壳层而不是单页散落自定义布局。
@@ -1,11 +0,0 @@
{
"status": "completed",
"completed": 8,
"failed": 0,
"pending": 0,
"total": 8,
"done": 8,
"percent": 100,
"current": "已完成 Slate Control Center 全站前端重绘并通过 packages/frontend 构建验证",
"updated_at": "2026-03-25 05:20:00"
}
@@ -1,159 +0,0 @@
# 变更提案: frontend-slate-control-center
## 元信息
```yaml
类型: 重构 / 优化
方案类型: implementation
优先级: P1
状态: 草稿
创建: 2026-03-25
```
---
## 1. 需求
### 背景
当前 `packages/frontend` 已接入 `Element Plus`,但绝大多数主页面仍停留在早期 Tailwind 原子类和零散自定义变量的混合状态。页面之间的视觉语言不统一,导航壳层、卡片、表格、筛选区、登录/初始化页、工作区侧边工作台都缺少一套一致的“控制中心”设计表达。用户已确认对整个前端站点做统一视觉重做,并指定采用“方案 A: Slate Control Center”。
### 目标
- 建立一套贯穿全站的 Slate Control Center 视觉语言,包括颜色、排版、圆角、阴影、边框、背景层次和状态语义。
- 让顶部导航、主内容区、卡片容器、过滤操作区、表格、表单、标签和空状态统一使用更现代的 `Element Plus` 风格与封装。
- 重做主要页面的壳层和关键交互,包括 `Dashboard``Workspace``Connections``Proxies``Notifications``Audit Logs``Settings``Login``Setup`
- 保持现有业务逻辑、Pinia store、路由和多语言能力不变,尽量将改动集中在壳层和组件表达层。
### 约束条件
```yaml
时间约束: 当前轮次内完成可运行的前端重构与构建验证
性能约束: 不引入新的重量级 UI 框架,仅基于现有 Element Plus、Vue 3 和样式层重构
兼容性约束: 保持现有路由、状态管理、组件接口和核心业务流程兼容
业务约束: /workspace 的三栏工作台结构保持既有决策,仅升级视觉语言和容器表达
```
### 验收标准
- [ ] 全局样式令牌和 Element Plus 主题变量统一,顶层导航和页面容器具备一致的 Slate Control Center 风格。
- [ ] `Dashboard``Connections``Settings``Login``Setup` 等主页面完成现代化重绘,优先采用 `Element Plus` 容器、表单、标签页、表格、统计卡片等组件。
- [ ] `/workspace` 的 Workbench、终端主区和状态监控面板在视觉上完成统一升级,保留当前结构与主要交互。
- [ ] `npm --workspace packages/frontend run build` 通过。
---
## 2. 方案
### 技术方案
本次改造以“壳层统一 + 样式令牌统一 + 主页面容器重做 + 工作区局部深度优化”为主:
- 在全局样式层重建 `style.css`,统一品牌色、页面背景、面板层级、边框、阴影、字体和 `Element Plus` CSS 变量映射。
- 在应用壳层引入统一页面容器、导航信息层、操作条和页面标题表达,逐步替换现有分散的 Tailwind 片段。
- 对主页面优先使用 `Element Plus``ElContainer``ElCard``ElTabs``ElTable``ElForm``ElInput``ElButton``ElTag``ElEmpty``ElAlert``ElSegmented` 等组件表达。
-`/workspace` 保持布局树与会话逻辑不变,只重做 Workbench、状态监控、终端外围容器和工作区背景层次。
- 尽量新增轻量公共组件,减少把整套视觉逻辑硬编码在每个 view 里。
### 影响范围
```yaml
涉及模块:
- frontend: 全局样式令牌、应用壳层、主页面容器、工作区视觉重构
- workspace-root: 仅同步上下文与变更记录,不改变后端/部署逻辑
预计变更文件: 12-20
```
### 风险评估
| 风险 | 等级 | 应对 |
|------|------|------|
| 全局样式变量重写影响现有细节组件 | 中 | 保留核心语义变量名,优先在壳层和新公共组件内消费 |
| 旧页面使用大量原子类,迁移后局部布局错位 | 中 | 先重做公共壳层与主页面,再做工作区和高复杂页面 |
| Element Plus 引入更多容器后局部交互样式不一致 | 中 | 统一页面级卡片、表单、筛选条和表格封装 |
| 工作区组件过多,若全量重写会超出单轮范围 | 低 | 聚焦外层容器、Workbench、状态监控与终端壳层,不动核心终端协议与会话逻辑 |
---
## 3. 技术设计
### 架构设计
```mermaid
flowchart TD
A[style.css 设计令牌] --> B[Element Plus 主题变量]
B --> C[App 全局导航壳层]
B --> D[页面级容器组件]
D --> E[Dashboard / Connections / Settings / Notifications]
D --> F[Login / Setup / Proxies / Audit Logs]
B --> G[Workspace 容器与 Workbench / StatusMonitor]
```
### 关键设计拆分
- 建立全局设计令牌层:背景分层、标题字体、面板边框、状态颜色、交互高亮、玻璃化和工业控制台感阴影。
- 构建公共页面壳层:统一页面标题、描述、右侧操作区、统计条和内容区卡片边界。
- 重做主要页面表达:从“表单/列表堆叠”升级为“控制中心”型信息组织。
- Workspace 采用“Slate 控制台”表达:左侧工作台像资源侧栏,中部终端维持主位,右侧状态监控更像运维仪表卡片。
---
## 4. 核心场景
### 场景: 主站点统一视觉语言
**模块**: frontend
**条件**: 用户进入 Dashboard、Connections、Settings 等主要页面
**行为**: 页面统一使用 Slate Control Center 风格的壳层、卡片、筛选区和内容容器
**结果**: 主站点视觉语言一致,Element Plus 使用比例显著提升
### 场景: 工作区现代化控制台
**模块**: frontend
**条件**: 用户进入 `/workspace`
**行为**: 维持三栏结构不变,但重做背景层次、Workbench 容器、状态卡片和终端外围外观
**结果**: 工作区看起来像现代控制中心,而非原始拆分面板
### 场景: 认证入口统一品牌化
**模块**: frontend
**条件**: 用户访问 `/login``/setup`
**行为**: 登录与初始化页面共享统一品牌板式、表单容器和引导文案层次
**结果**: 首次进入体验与主站点风格统一
---
## 5. 技术决策
### frontend-slate-control-center#D001: 以全局令牌 + 页面壳层重构替代逐页零散修补
**日期**: 2026-03-25
**状态**: 已采纳
**背景**: 现有页面风格分散,若继续逐页修补会反复复制样式,且无法建立统一设计语言。
**选项分析**:
| 选项 | 优点 | 缺点 |
|------|------|------|
| A: 先重建全局令牌和壳层,再逐页替换 | 一致性强,后续页面改造成本更低 | 首次改动面更大 |
| B: 逐页局部替换样式类 | 单页改动小 | 风格难统一,维护成本高 |
**决策**: 选择方案 A
**理由**: 用户明确要求“改整个前端站点,所有主要页面统一重做视觉语言和组件风格”,必须先建立统一底座。
**影响**: `style.css``App.vue`、主要视图文件、部分工作区组件都会调整
### frontend-slate-control-center#D002: 主要页面优先采用 Element Plus 容器与表单表达
**日期**: 2026-03-25
**状态**: 已采纳
**背景**: 仓库已引入 `Element Plus`,但当前使用率极低,无法体现组件库一致性。
**选项分析**:
| 选项 | 优点 | 缺点 |
|------|------|------|
| A: 以 Element Plus 为主,Tailwind 负责间距和局部布局 | 组件统一、主题变量统一、开发效率更高 | 需补主题映射 |
| B: 继续主要依赖 Tailwind 原子类 | 灵活 | 风格容易继续碎片化 |
**决策**: 选择方案 A
**理由**: 与用户要求直接一致,并且 Element Plus 已在依赖和入口中可用。
**影响**: 主页面会增加 `ElCard``ElInput``ElButton``ElTabs``ElTable` 等使用
---
## 6. 成果设计
### 设计方向
- **美学基调**: Slate Control Center。整体是石板灰、雾面蓝灰、冷白高光的专业控制中心,不走炫技霓虹,也不回到传统后台白底表格。
- **记忆点**: 顶层导航和各页面的“控制台头部条”,带有浅层玻璃感、信息徽标和有节奏的卡片层级,形成像现代运维控制中心的视觉记忆。
- **参考**: xterminal 的控制台感布局重心 + Element Plus 的信息密度与组件一致性 + 工业面板式层级。
### 视觉要素
- **配色**: 主背景使用深浅交叠的 slate 灰蓝体系,内容面板使用高亮浅灰卡片,强调色使用冷蓝与青绿色,危险态保留橙红。
- **字体**: 标题使用更有控制台气质的窄体/几何感字体栈,正文使用清晰的中文优先无衬线;代码和状态数字保持等宽字体。
- **布局**: 顶部导航更扁平且信息化;主页面采用“标题信息头 + 统计带 + 主内容卡片”的层次;Workspace 保持三栏但加重容器感与边界感。
- **动效**: 页面头部、卡片和标签切换采用短促的位移与透明度过渡;悬停强调边框和阴影,不堆砌复杂动画。
- **氛围**: 背景带有轻微径向渐变和柔和网格/噪点质感,卡片使用浅玻璃边缘和柔和阴影,突出现代专业控制中心质感。
### 技术约束
- **可访问性**: 保持表单、按钮、标签页和表格的键盘可达性,确保浅色文本对比满足阅读要求。
- **响应式**: 主站页面在桌面优先,保留既有移动端退化逻辑;Workspace 移动端不改变现有交互链路。
@@ -1,60 +0,0 @@
# 任务清单: frontend-slate-control-center
```yaml
@feature: frontend-slate-control-center
@created: 2026-03-25
@status: completed
@mode: R3
```
## 进度概览
| 完成 | 失败 | 跳过 | 总数 |
|------|------|------|------|
| 8 | 0 | 0 | 8 |
### LIVE_STATUS
```yaml
status: completed
current: 已完成 Slate Control Center 全站前端重绘并通过 packages/frontend 构建验证
completed: 8
failed: 0
pending: 0
total: 8
done: 8
percent: 100
updated_at: 2026-03-25 05:20:00
```
---
## 任务列表
### 1. 设计底座与公共壳层
- [√] 1.1 在 `packages/frontend/src/style.css` 中重建 Slate Control Center 全局设计令牌与 Element Plus 主题变量桥接 | depends_on: []
- [√] 1.2 在 `packages/frontend/src/App.vue` 中重做全局导航、页面背景和应用壳层表达 | depends_on: [1.1]
- [√] 1.3 新增公共页面容器组件,用于统一主要页面标题、描述、操作区和内容卡片风格 | depends_on: [1.1]
### 2. 主要页面现代化
- [√] 2.1 重做 `packages/frontend/src/views/DashboardView.vue`,将概览区升级为控制中心式信息卡片与操作面板 | depends_on: [1.1, 1.3]
- [√] 2.2 重做 `packages/frontend/src/views/ConnectionsView.vue``packages/frontend/src/views/ProxiesView.vue``packages/frontend/src/views/AuditLogView.vue``packages/frontend/src/views/NotificationsView.vue` 的页面容器和主操作区,优先接入 Element Plus 组件 | depends_on: [1.1, 1.3]
- [√] 2.3 重做 `packages/frontend/src/views/SettingsView.vue` 的设置导航和内容容器层次,使其符合统一控制中心风格 | depends_on: [1.1, 1.3]
### 3. 认证入口与工作区
- [√] 3.1 重做 `packages/frontend/src/views/LoginView.vue``packages/frontend/src/views/SetupView.vue`,统一品牌入口视觉和表单表达 | depends_on: [1.1]
- [√] 3.2 重做 `packages/frontend/src/components/WorkspaceWorkbench.vue``packages/frontend/src/components/StatusMonitor.vue`,并修复终端区域鼠标进入时的光标表现 | depends_on: [1.1, 1.2]
---
## 执行日志
| 时间 | 任务 | 状态 | 备注 |
|------|------|------|------|
| 2026-03-25 04:19:00 | 创建设计方案包 | completed | `create_package.py` 因编码损坏不可用,已按规则手动降级创建 |
| 2026-03-25 05:20:00 | 完成前端主页面与工作区视觉重绘 | completed | `npm --workspace packages/frontend run build` 通过 |
---
## 执行备注
> 本轮以前端视觉语言统一和页面容器重塑为主,不改动后端 API 协议与核心会话逻辑;`/workspace` 继续保持“左侧 Workbench + 中央终端 + 右侧状态监控”的主结构。
+237 -338
View File
@@ -25,230 +25,294 @@ const authStore = useAuthStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const appearanceStore = useAppearanceStore(); const appearanceStore = useAppearanceStore();
const layoutStore = useLayoutStore(); const layoutStore = useLayoutStore();
const focusSwitcherStore = useFocusSwitcherStore(); const focusSwitcherStore = useFocusSwitcherStore(); // +++ 实例化焦点切换 Store +++
const sessionStore = useSessionStore(); const sessionStore = useSessionStore(); // +++ 实例化 Session Store +++
const dialogStore = useDialogStore(); const dialogStore = useDialogStore(); // +++ 实例化 DialogStore +++
const { state: dialogState } = storeToRefs(dialogStore); const { state: dialogState } = storeToRefs(dialogStore);
const favoritePathsStore = useFavoritePathsStore(); const favoritePathsStore = useFavoritePathsStore(); // +++ 实例化 favoritePathsStore +++
const { isAuthenticated } = storeToRefs(authStore); const { isAuthenticated } = storeToRefs(authStore);
const { showPopupFileEditorBoolean } = storeToRefs(settingsStore); const { showPopupFileEditorBoolean } = storeToRefs(settingsStore);
const { isStyleCustomizerVisible } = storeToRefs(appearanceStore); const { isStyleCustomizerVisible } = storeToRefs(appearanceStore);
const { isHeaderVisible } = storeToRefs(layoutStore); const { isLayoutVisible, isHeaderVisible } = storeToRefs(layoutStore); // 添加 isHeaderVisible
const { isConfiguratorVisible: isFocusSwitcherVisible } = storeToRefs(focusSwitcherStore); 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 { isMobile } = useDeviceDetection();
const route = useRoute(); const route = useRoute();
const navRef = ref<HTMLElement | null>(null); const navRef = ref<HTMLElement | null>(null);
const underlineRef = ref<HTMLElement | null>(null); const underlineRef = ref<HTMLElement | null>(null);
// +++ 存储上一次由切换器聚焦的 ID +++
const lastFocusedIdBySwitcher = ref<string | null>(null); const lastFocusedIdBySwitcher = ref<string | null>(null);
const isAltPressed = ref(false); const isAltPressed = ref(false); // 跟踪 Alt 键是否按下
const altShortcutKey = ref<string | null>(null); const altShortcutKey = ref<string | null>(null);
// --- 移除 shortcutTriggeredInKeyDown 标志 ---
const updateUnderline = async () => { const updateUnderline = async () => {
await nextTick(); await nextTick(); // 等待 DOM 更新
if (navRef.value && underlineRef.value) { if (navRef.value && underlineRef.value) {
const activeLink = navRef.value.querySelector('.router-link-exact-active') as HTMLElement; const activeLink = navRef.value.querySelector('.router-link-exact-active') as HTMLElement;
if (activeLink) { if (activeLink) {
const offsetBottom = 2; // 下划线距离文字底部的距离 (px)
underlineRef.value.style.left = `${activeLink.offsetLeft}px`; underlineRef.value.style.left = `${activeLink.offsetLeft}px`;
underlineRef.value.style.width = `${activeLink.offsetWidth}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 { } 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(() => { onMounted(() => {
// Initial position update
// Use setTimeout to ensure styles are applied and elements have dimensions
setTimeout(updateUnderline, 100); setTimeout(updateUnderline, 100);
window.addEventListener('keydown', handleAltKeyDown); // +++ 全局 Alt 键监听器 +++
window.addEventListener('keyup', handleGlobalKeyUp); window.addEventListener('keydown', handleAltKeyDown); // +++ 监听 keydown 设置状态 +++
window.addEventListener('keyup', handleGlobalKeyUp); // +++ 监听 keyup 执行切换 +++
window.addEventListener('beforeinstallprompt', () => {
// PWA Install Prompt
window.addEventListener('beforeinstallprompt', (e) => {
console.log('[App.vue] beforeinstallprompt event fired. Browser will handle install prompt.'); console.log('[App.vue] beforeinstallprompt event fired. Browser will handle install prompt.');
}); });
window.addEventListener('appinstalled', () => { window.addEventListener('appinstalled', () => {
console.log('[App.vue] PWA was installed'); console.log('[App.vue] PWA was installed');
}); });
// +++ 加载 Header 可见性状态 +++
layoutStore.loadHeaderVisibility(); layoutStore.loadHeaderVisibility();
}); });
watch( // +++ 监听用户认证状态,登录后初始化收藏路径 +++
isAuthenticated, watch(isAuthenticated, (loggedIn) => {
(loggedIn) => { if (loggedIn) {
if (loggedIn) { favoritePathsStore.initializeFavoritePaths(t);
favoritePathsStore.initializeFavoritePaths(t); }
} }, { immediate: true });
},
{ immediate: true }
);
// +++ 卸载钩子以移除监听器 +++
onUnmounted(() => { onUnmounted(() => {
window.removeEventListener('keydown', handleAltKeyDown); window.removeEventListener('keydown', handleAltKeyDown); // +++ 移除 keydown 监听 +++
window.removeEventListener('keyup', handleGlobalKeyUp); window.removeEventListener('keyup', handleGlobalKeyUp); // +++ 移除 keyup 监听 +++
}); });
// *** 计算属性,判断是否在 workspace 路由 ***
const isWorkspaceRoute = computed(() => route.path === '/workspace'); const isWorkspaceRoute = computed(() => route.path === '/workspace');
watch( watch(route, () => {
route, updateUnderline();
() => { }, { immediate: true }); // *** 确保 immediate: true 存在 ***
updateUnderline();
},
{ immediate: true }
);
const handleLogout = () => { const handleLogout = () => {
authStore.logout(); authStore.logout();
}; };
// 打开样式自定义器的方法现在直接调用 store action
const openStyleCustomizer = () => { const openStyleCustomizer = () => {
appearanceStore.toggleStyleCustomizer(true); appearanceStore.toggleStyleCustomizer(true);
}; };
// 关闭样式自定义器的方法现在也调用 store action
const closeStyleCustomizer = () => { const closeStyleCustomizer = () => {
appearanceStore.toggleStyleCustomizer(false); appearanceStore.toggleStyleCustomizer(false);
}; };
const handleAltKeyDown = async (event: KeyboardEvent) => { // +++ 处理 Alt 键按下的事件处理函数,并记录快捷键 +++
if (!isWorkspaceRoute.value) return; const handleAltKeyDown = async (event: KeyboardEvent) => { // +++ 改为 async +++
if (!isWorkspaceRoute.value) return; // 只在 workspace 路由下执行
// 只在 Alt 键首次按下时设置状态
if (event.key === 'Alt' && !event.repeat) { if (event.key === 'Alt' && !event.repeat) {
isAltPressed.value = true; isAltPressed.value = true;
altShortcutKey.value = null; altShortcutKey.value = null;
// console.log('[App] Alt key pressed down.');
} else if (isAltPressed.value && !['Control', 'Shift', 'Alt', 'Meta'].includes(event.key)) { } else if (isAltPressed.value && !['Control', 'Shift', 'Alt', 'Meta'].includes(event.key)) {
// 如果 Alt 正被按住,且按下了非修饰键 (移除 !shortcutTriggeredInKeyDown 检查)
let key = event.key; let key = event.key;
if (key.length === 1) key = key.toUpperCase(); if (key.length === 1) key = key.toUpperCase();
if (/^[a-zA-Z0-9]$/.test(key)) { if (/^[a-zA-Z0-9]$/.test(key)) {
altShortcutKey.value = key; altShortcutKey.value = key; // 记录按键
const shortcutString = `Alt+${key}`; const shortcutString = `Alt+${key}`;
const targetId = focusSwitcherStore.getFocusTargetIdByShortcut(shortcutString); console.log(`[App] KeyDown: Alt+${key} detected. Checking shortcut: ${shortcutString}`);
const targetId = focusSwitcherStore.getFocusTargetIdByShortcut(shortcutString);
if (targetId) { if (targetId) {
event.preventDefault(); console.log(`[App] KeyDown: Shortcut match found. Targeting ID: ${targetId}`);
const success = await focusSwitcherStore.focusTarget(targetId); event.preventDefault(); // 阻止默认行为 (如菜单)
if (success) { const success = await focusSwitcherStore.focusTarget(targetId); // +++ 立即尝试聚焦 +++
lastFocusedIdBySwitcher.value = 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 { } else {
isAltPressed.value = false; // 按下无效键 (非字母数字),取消 Alt 状态
altShortcutKey.value = null; 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)) { } 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; isAltPressed.value = false;
altShortcutKey.value = null; 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; const isElementVisibleAndFocusable = (element: HTMLElement): boolean => {
if (event.key !== 'Alt') return; if (!element) return false;
// 检查元素是否在 DOM 中,并且没有 display: none
const altWasPressed = isAltPressed.value; const style = window.getComputedStyle(element);
const triggeredShortcutKey = altShortcutKey.value; if (style.display === 'none' || style.visibility === 'hidden') return false;
// 检查元素或其父元素是否被禁用
isAltPressed.value = false; if ((element as HTMLInputElement).disabled) return false;
altShortcutKey.value = null; let parent = element.parentElement;
while (parent) {
if (altWasPressed && triggeredShortcutKey === null) { if ((parent as HTMLFieldSetElement).disabled) return false;
event.preventDefault(); parent = parent.parentElement;
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 rect = element.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}; };
</script> </script>
<template> <template>
<div id="app-container" class="app-shell">
<div class="app-shell__backdrop"></div> <div id="app-container">
<!-- *** 修改 v-if 条件以使用 isHeaderVisible *** -->
<header v-if="!isWorkspaceRoute || isHeaderVisible" class="app-topbar"> <!-- Header with Tailwind classes using theme variables -->
<nav ref="navRef" class="app-topbar__inner"> <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"> <!-- 减少左侧内边距 -->
<div class="app-topbar__left"> <!-- Nav with Tailwind classes -->
<RouterLink to="/" class="app-brand"> <nav ref="navRef" class="flex items-center justify-between w-full relative"> <!-- Added relative positioning for underline -->
<img src="./assets/logo.png" alt="Project Logo" class="app-brand__logo"> <!-- Left navigation links with Tailwind classes using theme variables -->
<div class="app-brand__copy"> <div class="flex items-center space-x-1">
<span class="app-brand__title">{{ t('projectName') }}</span> <!-- 项目 Logo -->
<span class="app-brand__subtitle">Slate Control Center</span> <img src="./assets/logo.png" alt="Project Logo" class="h-10 w-auto"> <!-- 移除右侧外边距使其更靠左 -->
</div> <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> <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> <!-- 连接管理链接 -->
<div class="app-nav"> <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="/" class="app-nav__link" active-class="is-active">{{ t('nav.dashboard') }}</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="/workspace" class="app-nav__link" active-class="is-active">{{ t('nav.terminal') }}</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="/connections" class="app-nav__link hidden md:inline-flex" active-class="is-active">{{ t('nav.connections') }}</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> <!-- 保持可见 -->
<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> </div>
<!-- Right navigation links with Tailwind classes using theme variables -->
<div class="app-topbar__right"> <div class="flex items-center space-x-1">
<a <!-- GitHub Icon (Hide on mobile) -->
v-if="!isMobile" <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">
href="https://github.com/Heavrnl/nexus-terminal" <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
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">
<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"/> <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> </svg>
</a> </a>
<!-- PWA Install Button - REMOVED FROM HERE -->
<a href="#" @click.prevent="openStyleCustomizer" :title="t('nav.customizeStyle')" class="app-icon-button"> <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>
<i class="fas fa-paint-brush"></i> <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> <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>
<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>
</div> </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> </nav>
</header> </header>
<main class="app-main"> <main>
<!-- 使用 KeepAlive 包裹 RouterView并指定缓存 WorkspaceView -->
<RouterView v-slot="{ Component }"> <RouterView v-slot="{ Component }">
<KeepAlive :include="['WorkspaceView', 'ConnectionsView']"> <KeepAlive :include="['WorkspaceView', 'ConnectionsView']">
<component :is="Component" /> <component :is="Component" />
@@ -256,30 +320,49 @@ const handleGlobalKeyUp = async (event: KeyboardEvent) => {
</RouterView> </RouterView>
</main> </main>
<!-- 添加全局通知显示 -->
<UINotificationDisplay /> <UINotificationDisplay />
<!-- 根据设置条件渲染全局文件编辑器弹窗 -->
<FileEditorOverlay v-if="showPopupFileEditorBoolean" :is-mobile="isMobile" /> <FileEditorOverlay v-if="showPopupFileEditorBoolean" :is-mobile="isMobile" />
<!-- 条件渲染样式自定义器使用 store 的状态和方法 -->
<StyleCustomizer v-if="isStyleCustomizerVisible" @close="closeStyleCustomizer" /> <StyleCustomizer v-if="isStyleCustomizerVisible" @close="closeStyleCustomizer" />
<!-- +++ 条件渲染焦点切换配置器 (使用 v-show 保持实例) +++ -->
<FocusSwitcherConfigurator <FocusSwitcherConfigurator
v-show="isFocusSwitcherVisible" v-show="isFocusSwitcherVisible"
:isVisible="isFocusSwitcherVisible" :isVisible="isFocusSwitcherVisible"
@close="focusSwitcherStore.toggleConfigurator(false)" @close="focusSwitcherStore.toggleConfigurator(false)"
/> />
<RemoteDesktopModal v-if="isRdpModalOpen" :connection="rdpConnectionInfo" @close="sessionStore.closeRdpModal()" /> <!-- +++ 条件渲染 RDP 模态框 +++ -->
<VncModal v-if="isVncModalOpen" :connection="vncConnectionInfo" @close="sessionStore.closeVncModal()" /> <RemoteDesktopModal
v-if="isRdpModalOpen"
<ConfirmDialog :connection="rdpConnectionInfo"
:visible="dialogState.visible" @close="sessionStore.closeRdpModal()"
: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"
/> />
<!-- +++ 条件渲染 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> </div>
</template> </template>
@@ -288,197 +371,13 @@ const handleGlobalKeyUp = async (event: KeyboardEvent) => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100vh; 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 { main {
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 {
flex-grow: 1; 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> </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>
+276 -485
View File
@@ -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"> <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 { useI18n } from 'vue-i18n';
import StatusCharts from './StatusCharts.vue'; import StatusCharts from './StatusCharts.vue';
import { useSessionStore } from '../stores/session.store'; import { useSessionStore } from '../stores/session.store'; // 注入 sessionStore
import { useSettingsStore } from '../stores/settings.store'; import { storeToRefs } from 'pinia'; // 导入 storeToRefs
import { useConnectionsStore } from '../stores/connections.store'; import { useSettingsStore } from '../stores/settings.store'; // 导入设置 store
import { useUiNotificationsStore } from '../stores/uiNotifications.store'; import { useConnectionsStore } from '../stores/connections.store'; // 导入连接 store
import { useUiNotificationsStore } from '../stores/uiNotifications.store'; // + 导入通知 store
import type { ServerStatus } from '../types/server.types'; import type { ServerStatus } from '../types/server.types';
const { t } = useI18n(); const { t } = useI18n();
const sessionStore = useSessionStore(); const sessionStore = useSessionStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore(); // 实例化设置 store
const connectionsStore = useConnectionsStore(); const connectionsStore = useConnectionsStore(); // 实例化连接 store
const uiNotificationsStore = useUiNotificationsStore(); const uiNotificationsStore = useUiNotificationsStore(); // + 实例化通知 store
const { sessions } = storeToRefs(sessionStore); // 获取响应式的 sessions
const { sessions } = storeToRefs(sessionStore); const { statusMonitorShowIpBoolean } = storeToRefs(settingsStore); // 获取 IP 显示设置
const { statusMonitorShowIpBoolean } = storeToRefs(settingsStore);
const isSwitchingSession = ref(false); const isSwitchingSession = ref(false);
const formatPercentageText = (percentage: number): string => `${Math.round(percentage)}%`;
// --- Props ---
const props = defineProps({ const props = defineProps({
activeSessionId: { activeSessionId: {
type: String as PropType<string | null>, type: String as PropType<string | null>,
required: false, required: false, // 允许为 null
default: null, default: null,
}, },
}); });
const formatPercentageText = (percentage: number): string => `${Math.round(percentage)}%`; // --- Computed properties to get current session data ---
const currentSessionState = computed(() => { const currentSessionState = computed(() => {
return props.activeSessionId ? sessions.value.get(props.activeSessionId) : null; 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; return currentSessionState.value?.statusMonitorManager?.serverStatus?.value ?? null;
}); });
const displayCpuPercent = computed(() => currentServerStatus.value?.cpuPercent ?? 0); // --- 计算属性,用于绑定到进度条宽度 ---
const displayMemPercent = computed(() => currentServerStatus.value?.memPercent ?? 0); // 始终返回当前状态的百分比。动画由 CSS 类控制。
const displaySwapPercent = computed(() => currentServerStatus.value?.swapPercent ?? 0); const displayCpuPercent = computed(() => {
const displayDiskPercent = computed(() => currentServerStatus.value?.diskPercent ?? 0); 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>(() => { const currentStatusError = computed<string | null>(() => {
return currentSessionState.value?.statusMonitorManager?.statusError?.value ?? null; return currentSessionState.value?.statusMonitorManager?.statusError?.value ?? null;
}); });
// --- 缓存逻辑保持不变 ---
const cachedCpuModel = ref<string | null>(null); const cachedCpuModel = ref<string | null>(null);
const cachedOsName = ref<string | null>(null); const cachedOsName = ref<string | null>(null);
watch( // --- Watcher for caching CPU Model and OS Name ---
currentServerStatus, // 现在监听 currentServerStatus
(newData) => { watch(currentServerStatus, (newData) => {
if (newData?.cpuModel) { if (newData) {
if (newData.cpuModel !== undefined && newData.cpuModel !== null && newData.cpuModel !== '') {
cachedCpuModel.value = newData.cpuModel; cachedCpuModel.value = newData.cpuModel;
} }
if (newData?.osName) { if (newData.osName !== undefined && newData.osName !== null && newData.osName !== '') {
cachedOsName.value = 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(() => { const displayCpuModel = computed(() => {
// 使用 currentServerStatus
return (currentServerStatus.value?.cpuModel ?? cachedCpuModel.value) || t('statusMonitor.notAvailable'); return (currentServerStatus.value?.cpuModel ?? cachedCpuModel.value) || t('statusMonitor.notAvailable');
}); });
const displayOsName = computed(() => { const displayOsName = computed(() => {
// 使用 currentServerStatus
return (currentServerStatus.value?.osName ?? cachedOsName.value) || t('statusMonitor.notAvailable'); return (currentServerStatus.value?.osName ?? cachedOsName.value) || t('statusMonitor.notAvailable');
}); });
const formatBytesPerSecond = (bytes?: number): string => { const formatBytesPerSecond = (bytes?: number): string => {
if (bytes === undefined || bytes === null || Number.isNaN(bytes)) return t('statusMonitor.notAvailable'); if (bytes === undefined || bytes === null || isNaN(bytes)) return t('statusMonitor.notAvailable');
if (bytes < 1024) return `${bytes} ${t('statusMonitor.bytesPerSecond')}`; if (bytes < 1024) return `${bytes} ${t('statusMonitor.bytesPerSecond')}`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} ${t('statusMonitor.kiloBytesPerSecond')}`; 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')}`; if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} ${t('statusMonitor.megaBytesPerSecond')}`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} ${t('statusMonitor.gigaBytesPerSecond')}`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} ${t('statusMonitor.gigaBytesPerSecond')}`;
}; };
const formatBytes = (bytes?: number): string => { const formatBytes = (bytes?: number): string => {
if (bytes === undefined || bytes === null || Number.isNaN(bytes)) return t('statusMonitor.notAvailable'); if (bytes === undefined || bytes === null || isNaN(bytes)) return t('statusMonitor.notAvailable');
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; 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) return `${(bytes / (1024 * 1024)).toFixed(1)} ${t('statusMonitor.megaBytes')}`;
if (bytes < 1024 * 1024 * 1024 * 1024) { if (bytes < 1024 * 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} ${t('statusMonitor.gigaBytes')}`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} ${t('statusMonitor.gigaBytes')}`; return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(1)} TB`;
}
return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(1)} TB`;
}; };
const formatKbToGb = (kb?: number): string => { const formatKbToGb = (kb?: number): string => {
if (kb === undefined || kb === null) return t('statusMonitor.notAvailable'); if (kb === undefined || kb === null) return t('statusMonitor.notAvailable');
if (kb === 0) return `0.0 ${t('statusMonitor.gigaBytes')}`; if (kb === 0) return `0.0 ${t('statusMonitor.gigaBytes')}`;
const gb = kb / 1024 / 1024; const gb = kb / 1024 / 1024;
return `${gb.toFixed(1)} ${t('statusMonitor.gigaBytes')}`; return `${gb.toFixed(1)} ${t('statusMonitor.gigaBytes')}`;
}; };
// 辅助函数,用于在需要时将 MB 格式化为 GB
const formatMemorySize = (mb?: number): string => { const formatMemorySize = (mb?: number): string => {
if (mb === undefined || mb === null || Number.isNaN(mb)) return t('statusMonitor.notAvailable'); if (mb === undefined || mb === null || isNaN(mb)) return t('statusMonitor.notAvailable');
if (mb < 1024) { if (mb < 1024) {
const value = Number.isInteger(mb) ? mb : mb.toFixed(1); const value = Number.isInteger(mb) ? mb : mb.toFixed(1);
return `${value} ${t('statusMonitor.megaBytes')}`; return `${value} ${t('statusMonitor.megaBytes')}`;
} } else {
const gb = mb / 1024; const gb = mb / 1024;
return `${gb.toFixed(1)} ${t('statusMonitor.gigaBytes')}`; return `${gb.toFixed(1)} ${t('statusMonitor.gigaBytes')}`;
}
}; };
const memDisplay = computed(() => { const memDisplay = computed(() => {
const data = currentServerStatus.value; const data = currentServerStatus.value; // 使用 currentServerStatus
if (!data || data.memUsed === undefined || data.memTotal === undefined) return t('statusMonitor.notAvailable'); if (!data || data.memUsed === undefined || data.memTotal === undefined) return t('statusMonitor.notAvailable');
return `${formatMemorySize(data.memUsed)} / ${formatMemorySize(data.memTotal)}`; return `${formatMemorySize(data.memUsed)} / ${formatMemorySize(data.memTotal)}`;
}); });
const diskDisplay = computed(() => { const diskDisplay = computed(() => {
const data = currentServerStatus.value; const data = currentServerStatus.value; // 使用 currentServerStatus
if (!data || data.diskUsed === undefined || data.diskTotal === undefined) return t('statusMonitor.notAvailable'); if (!data || data.diskUsed === undefined || data.diskTotal === undefined) return t('statusMonitor.notAvailable');
return `${formatKbToGb(data.diskUsed)} / ${formatKbToGb(data.diskTotal)}`; return `${formatKbToGb(data.diskUsed)} / ${formatKbToGb(data.diskTotal)}`;
}); });
const swapDisplay = computed(() => { const swapDisplay = computed(() => {
const data = currentServerStatus.value; const data = currentServerStatus.value; // 使用 currentServerStatus
const used = data?.swapUsed ?? 0; const used = data?.swapUsed ?? 0;
const total = data?.swapTotal ?? 0; const total = data?.swapTotal ?? 0;
if (total === 0) return t('statusMonitor.swapNotAvailable'); const percentVal = data?.swapPercent ?? 0;
return `${formatMemorySize(used)} / ${formatMemorySize(total)}`;
// 仅当交换空间总量 > 0 时显示详细信息
if (total === 0) {
return t('statusMonitor.swapNotAvailable'); // 或更具体的消息
}
return `${formatMemorySize(used)} / ${formatMemorySize(total)}`;
}); });
const sessionIpAddress = computed(() => { const sessionIpAddress = computed(() => {
const sessionState = currentSessionState.value; const sessionState = currentSessionState.value;
if (sessionState?.connectionId) { if (sessionState && sessionState.connectionId) {
// 直接从 connectionsStore 的 connections 数组中查找
const connectionIdAsNumber = parseInt(sessionState.connectionId, 10); const connectionIdAsNumber = parseInt(sessionState.connectionId, 10);
if (Number.isNaN(connectionIdAsNumber)) return null; if (isNaN(connectionIdAsNumber)) {
const connectionInfo = connectionsStore.connections.find((conn) => conn.id === connectionIdAsNumber); return null; // 如果 connectionId 不是有效的数字,则返回 null
}
const connectionInfo = connectionsStore.connections.find(conn => conn.id === connectionIdAsNumber);
return connectionInfo?.host || null; return connectionInfo?.host || null;
} }
return 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) => { const copyIpToClipboard = async (ipAddress: string | null) => {
if (!ipAddress) return; if (!ipAddress) return;
try { try {
await navigator.clipboard.writeText(ipAddress); await navigator.clipboard.writeText(ipAddress);
uiNotificationsStore.showSuccess(t('common.copied', '已复制')); uiNotificationsStore.showSuccess(t('common.copied', '已复制!'));
} catch (err) { } catch (err) {
console.error('Failed to copy IP address: ', err); console.error('Failed to copy IP address: ', err);
uiNotificationsStore.showError(t('statusMonitor.copyIpError', '复制 IP 失败')); uiNotificationsStore.showError(t('statusMonitor.copyIpError', '复制 IP 失败'));
} }
}; };
</script> </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> <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) { ::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) { ::v-deep(.themed-progress .el-progress-bar__inner) {
transition: width 0.3s ease-in-out; transition: width 0.3s ease-in-out;
} }
::v-deep(.themed-progress.no-transition .el-progress-bar__inner) { ::v-deep(.themed-progress.no-transition .el-progress-bar__inner) {
transition: none !important; transition: none !important;
} }
::v-deep(.el-progress-bar__innerText) {
@media (max-width: 960px) { font-size: 10px;
.network-grid { position: relative;
grid-template-columns: 1fr; top: -0.5px;
}
} }
</style> </style>
@@ -743,7 +743,7 @@ watchEffect(() => {
.terminal-inner-container :deep(.xterm), .terminal-inner-container :deep(.xterm),
.terminal-inner-container :deep(.xterm-screen), .terminal-inner-container :deep(.xterm-screen),
.terminal-inner-container :deep(.xterm-viewport) { .terminal-inner-container :deep(.xterm-viewport) {
cursor: text !important; cursor: default !important;
} }
.terminal-inner-container :deep(.xterm .xterm-cursor-pointer) { .terminal-inner-container :deep(.xterm .xterm-cursor-pointer) {
@@ -49,40 +49,31 @@ const workbenchTabs = computed(() => [
{ {
id: 'quickCommands' as const, id: 'quickCommands' as const,
label: t('workspace.workbench.tabs.quickCommands', '快捷指令'), label: t('workspace.workbench.tabs.quickCommands', '快捷指令'),
shortLabel: t('workspace.workbench.tabs.quickCommands', '快捷指令'),
icon: 'fas fa-bolt', icon: 'fas fa-bolt',
hint: t('workspace.workbench.quickCommandsHint', '默认面板,用于常用命令与预置脚本。'),
}, },
{ {
id: 'files' as const, id: 'files' as const,
label: t('workspace.workbench.tabs.files', '文件'), label: t('workspace.workbench.tabs.files', '文件'),
shortLabel: t('workspace.workbench.tabs.files', '文件'), icon: 'fas fa-folder-open',
icon: 'fas fa-folder-tree',
hint: t('workspace.workbench.filesHint', '浏览远程目录、拖放文件与操作资源。'),
}, },
{ {
id: 'history' as const, id: 'history' as const,
label: t('workspace.workbench.tabs.history', '历史命令'), label: t('workspace.workbench.tabs.history', '历史命令'),
shortLabel: t('workspace.workbench.tabs.history', '历史命令'), icon: 'fas fa-history',
icon: 'fas fa-clock-rotate-left',
hint: t('workspace.workbench.historyHint', '回放最近命令并快速重发到当前会话。'),
}, },
{ {
id: 'editor' as const, id: 'editor' as const,
label: t('workspace.workbench.tabs.editor', '编辑器'), label: t('workspace.workbench.tabs.editor', '编辑器'),
shortLabel: t('workspace.workbench.tabs.editor', '编辑器'), icon: 'fas fa-pen-to-square',
icon: 'fas fa-pen-ruler',
hint: t('workspace.workbench.editorHint', '在工作台里直接查看并编辑当前打开的文件。'),
}, },
]); ]);
const activeSessionName = computed(() => { const activeSessionName = computed(() => {
if (!props.sessionId) return null; if (!props.sessionId) {
return sessions.value.get(props.sessionId)?.connectionName ?? props.sessionId; return null;
}); }
const activeWorkbenchMeta = computed(() => { return sessions.value.get(props.sessionId)?.connectionName ?? props.sessionId;
return workbenchTabs.value.find((tab) => tab.id === activeWorkbenchTab.value) ?? workbenchTabs.value[0];
}); });
const hasFileManagerContext = computed(() => { const hasFileManagerContext = computed(() => {
@@ -106,235 +97,134 @@ watch(
</script> </script>
<template> <template>
<section class="workbench-shell"> <div class="flex h-full min-h-0 flex-col overflow-hidden bg-background">
<header class="workbench-shell__header"> <div class="border-b border-border bg-header px-3 py-3">
<div class="workbench-shell__copy"> <div class="flex items-center justify-between gap-3">
<div class="workbench-shell__eyebrow"> <div>
<el-tag round effect="light" type="primary"> <h3 class="text-sm font-semibold text-foreground">
{{ t('workspace.workbench.label', '工作台') }} {{ t('workspace.workbench.title', 'Workbench') }}
</el-tag> </h3>
<span class="workbench-shell__session"> <p class="mt-1 text-xs text-text-secondary">
{{ activeSessionName || t('workspace.workbench.noSession', '未激活会话') }} {{ activeSessionName || t('workspace.workbench.noSession', '未激活会话') }}
</span> </p>
</div> </div>
<h3>{{ t('workspace.workbench.title', 'Workbench') }}</h3> <span class="rounded-full border border-border bg-background px-2 py-1 text-[11px] font-medium text-text-secondary">
<p>{{ activeWorkbenchMeta.hint }}</p> {{ 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>
<div class="workbench-shell__chips"> <div v-show="activeWorkbenchTab === 'files'" class="absolute inset-0 min-h-0">
<div class="workbench-chip"> <FileManager
<span>{{ t('workspace.workbench.tabs.quickCommands', '快捷指令') }}</span> v-if="hasFileManagerContext"
<strong>Default</strong> :session-id="fileManagerSessionId"
</div> :instance-id="fileManagerInstanceId"
<div class="workbench-chip"> :db-connection-id="fileManagerConnectionId"
<span>{{ t('workspace.workbench.tabs.editor', '编辑器') }}</span> :ws-deps="fileManagerWsDeps"
<strong>{{ tabs.length }}</strong> 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>
</div> </div>
</header>
<el-tabs v-model="activeWorkbenchTab" class="workbench-tabs" stretch> <div v-show="activeWorkbenchTab === 'history'" class="absolute inset-0 min-h-0">
<el-tab-pane v-for="tab in workbenchTabs" :key="tab.id" :name="tab.id"> <CommandHistoryView />
<template #label> </div>
<span class="workbench-tab-label">
<i :class="tab.icon"></i>
<span>{{ tab.shortLabel }}</span>
</span>
</template>
<div class="workbench-shell__panel"> <div v-show="activeWorkbenchTab === 'editor'" class="absolute inset-0 min-h-0">
<div v-show="activeWorkbenchTab === 'quickCommands'" class="workbench-panel workbench-panel--quick"> <FileEditorContainer
<QuickCommandsView /> :tabs="tabs"
</div> :active-tab-id="activeTabId"
:session-id="sessionId"
<div v-show="activeWorkbenchTab === 'files'" class="workbench-panel"> />
<FileManager </div>
v-if="hasFileManagerContext" </div>
:session-id="fileManagerSessionId" </div>
: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>
</template> </template>
<style scoped> <style scoped>
.workbench-shell { .workbench-quick-commands {
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 {
background: background:
radial-gradient(circle at top left, rgba(60, 105, 231, 0.14), transparent 24%), linear-gradient(180deg, rgba(15, 17, 22, 0.98) 0%, rgba(12, 14, 18, 1) 100%);
linear-gradient(180deg, rgba(248, 250, 255, 0.96), rgba(239, 245, 252, 0.92));
} }
.workbench-empty { .workbench-quick-commands :deep(> div),
display: grid; .workbench-quick-commands :deep(> div > div) {
place-items: center;
height: 100%;
}
.workbench-panel--quick :deep(> div),
.workbench-panel--quick :deep(> div > div) {
background: transparent; background: transparent;
} }
@media (max-width: 1200px) { .workbench-quick-commands :deep(input) {
.workbench-shell__header { background: rgba(255, 255, 255, 0.06);
flex-direction: column; border-color: rgba(255, 255, 255, 0.12);
} color: #f5f7fa;
box-shadow: none;
}
.workbench-shell__chips { .workbench-quick-commands :deep(input::placeholder) {
justify-content: flex-start; 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> </style>
+155 -292
View File
@@ -1,24 +1,23 @@
@import "tailwindcss"; @import "tailwindcss";
/* Tailwind Theme Variables Mapping */
@theme inline { @theme inline {
--color-background: var(--app-bg-color); /* Base Colors */
--color-foreground: var(--text-color); --color-background: var(--app-bg-color); /* More generic name */
--color-app: var(--app-bg-color); --color-foreground: var(--text-color); /* More generic name */
--color-card: var(--card-bg-color); --color-app: var(--app-bg-color); /* Keep specific if needed */
--color-card-foreground: var(--card-foreground-color); --color-text-default: var(--text-color); /* Keep specific if needed */
--color-muted: var(--muted-bg-color);
--color-muted-foreground: var(--muted-foreground-color);
--color-text-default: var(--text-color);
--color-text-secondary: var(--text-color-secondary); --color-text-secondary: var(--text-color-secondary);
--color-text-alt: var(--text-color-tertiary); --color-border: var(--border-color); /* Simplified name */
--color-border: var(--border-color); --color-border-default: var(--border-color); /* Keep specific if needed */
--color-link: var(--link-color); --color-link: var(--link-color);
--color-link-hover: var(--link-hover-color); --color-link-hover: var(--link-hover-color);
--color-link-active: var(--link-active-color); --color-link-active: var(--link-active-color); /* Also used as primary/theme color */
--color-primary: var(--primary-color); --color-primary: var(--link-active-color); /* Map primary to active link color */
--color-primary-dark: var(--primary-dark-color); --color-link-active-bg: var(--link-active-bg-color); /* Map active link background */
--color-link-active-bg: var(--link-active-bg-color); --color-nav-active-bg: var(--nav-item-active-bg-color); /* Map specific nav active background */
--color-nav-active-bg: var(--nav-item-active-bg-color);
/* Component Colors */
--color-header: var(--header-bg-color); --color-header: var(--header-bg-color);
--color-footer: var(--footer-bg-color); --color-footer: var(--footer-bg-color);
--color-button: var(--button-bg-color); --color-button: var(--button-bg-color);
@@ -28,336 +27,200 @@
--color-icon-hover: var(--icon-hover-color); --color-icon-hover: var(--icon-hover-color);
--color-split-line: var(--split-line-color); --color-split-line: var(--split-line-color);
--color-split-line-hover: var(--split-line-hover-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-input-focus-border: var(--input-focus-border-color);
--color-overlay: var(--overlay-bg-color); --color-overlay: var(--overlay-bg-color);
--color-success: var(--success-color); --color-success: var(--color-success);
--color-warning: var(--warning-color); --color-warning: var(--color-warning);
--color-error: var(--error-color); --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 { :root {
--app-bg-color: #edf2f8; /* 基础颜色 */
--app-bg-gradient: radial-gradient(circle at top left, rgba(84, 125, 255, 0.18), transparent 34%), --app-bg-color: #ffffff; /* 应用背景色 */
radial-gradient(circle at right 16%, rgba(0, 170, 170, 0.14), transparent 26%), --text-color: #333333; /* 主要文字颜色 */
linear-gradient(180deg, #f6f8fc 0%, #ecf1f7 52%, #e7edf6 100%); --text-color-secondary: #666666; /* 次要文字颜色 */
--shell-surface-color: rgba(255, 255, 255, 0.56); --border-color: #cccccc; /* 边框颜色 */
--card-bg-color: rgba(255, 255, 255, 0.84); --link-color: #333; /* 链接颜色 */
--card-foreground-color: #142033; --link-hover-color: #0056b3; /* 链接悬停颜色 */
--muted-bg-color: #e9eef6; --link-active-color: #007bff; /* 激活链接/主题色 */
--muted-foreground-color: #5a6b84; --link-active-bg-color: #e0e0ff; /* 激活链接背景色 (类似 indigo-50) */
--text-color: #152338; --nav-item-active-bg-color: var(--link-active-bg-color); /* 导航选中项背景色, 默认同激活链接背景 */
--text-color-secondary: #607089;
--text-color-tertiary: #7f8da3; /* 组件颜色 */
--border-color: rgba(103, 124, 155, 0.24); --header-bg-color: #f0f0f0; /* 头部背景色 */
--border-strong-color: rgba(103, 124, 155, 0.36); --footer-bg-color: #f0f0f0; /* 底部背景色 */
--link-color: #355fa8; --button-bg-color: #007bff; /* 按钮背景色 */
--link-hover-color: #214d90; --button-text-color: #ffffff; /* 按钮文字颜色 */
--link-active-color: #3c69e7; --button-hover-bg-color: #0056b3;/* 按钮悬停背景色 */
--primary-color: #3c69e7; --icon-color: var(--text-color-secondary); /* 图标颜色 */
--primary-dark-color: #2746b8; --icon-hover-color: var(--link-hover-color); /* 图标悬停颜色 */
--primary-soft-color: rgba(60, 105, 231, 0.12); --split-line-color: var(--border-color); /* 分割线颜色 */
--link-active-bg-color: rgba(60, 105, 231, 0.12); --split-line-hover-color: var(--border-color); /* 分割线悬停颜色 */
--nav-item-active-bg-color: rgba(60, 105, 231, 0.12); --input-focus-border-color: var(--link-active-color); /* 输入框聚焦边框颜色 */
--header-bg-color: rgba(255, 255, 255, 0.74); --input-focus-glow: var(--link-active-color); /* 输入框聚焦光晕值 */
--footer-bg-color: rgba(255, 255, 255, 0.78); --overlay-bg-color: rgba(0, 0, 0, 0.6); /* Added Overlay Background Color */
--button-bg-color: #3c69e7;
--button-text-color: #ffffff; /* Status Colors */
--button-hover-bg-color: #2746b8; --color-success: #28a745; /* Green */
--icon-color: #62748e; --color-warning: #ffc107; /* Yellow */
--icon-hover-color: #1d4f91; --color-error: #dc3545; /* Red */
--split-line-color: rgba(126, 143, 168, 0.22); --color-success-text: #ffffff; /* White text for green bg */
--split-line-hover-color: rgba(60, 105, 231, 0.42); --color-warning-text: #212529; /* Dark text for yellow bg */
--input-bg-color: rgba(245, 248, 252, 0.9); --color-error-text: #ffffff; /* White text for red bg */
--input-focus-border-color: #3c69e7;
--input-focus-glow-rgb: 60, 105, 231; /* 字体 */
--overlay-bg-color: rgba(12, 20, 32, 0.58); --font-family-sans-serif: sans-serif; /* 默认字体 */
--success-color: #22a06b;
--warning-color: #d99b24; /* 其他 */
--error-color: #d04b4b; --base-padding: 1rem; /* 基础内边距 */
--success-text-color: #ffffff; --base-margin: 0.5rem; /* 基础外边距 */
--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%;
} }
/* 应用基础样式 */
body { body {
margin: 0; margin: 0; /* 移除默认 body margin */
font-family: var(--font-family-sans-serif); font-family: var(--font-family-sans-serif);
background-color: var(--app-bg-color);
color: var(--text-color); color: var(--text-color);
background: var(--app-bg-gradient); line-height: 1.6; /* 改善可读性 */
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%);
} }
/* 全局链接样式 */
a { a {
color: inherit; /* color: var(--link-color); */ /* 注释掉全局 a 标签的颜色设置,让 Tailwind 类生效 */
text-decoration: none; text-decoration: none; /* 移除下划线 */
} }
i, /* Removed global a:hover underline rule to avoid conflicts with Tailwind utilities */
.fas,
.far, /* 全局图标样式 */
.fab { i, .fas, .far, .fab { /* 根据你使用的图标库调整选择器 */
color: inherit; color: var(--icon-color);
transition: color 0.2s ease; transition: color 0.2s ease;
} }
a:hover i, a:hover .fas, a:hover .far, a:hover .fab, /* 链接内的图标 */
button, button:hover i, button:hover .fas, button:hover .far, button:hover .fab, /* 按钮内的图标 */
input, .icon-interactive:hover i, .icon-interactive:hover .fas, .icon-interactive:hover .far, .icon-interactive:hover .fab { /* 可交互图标容器 */
textarea, color: var(--icon-hover-color);
select {
font: inherit;
} }
/* 全局分割线样式 */
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 { hr {
border: none; border: none;
border-top: 1px solid rgba(103, 124, 155, 0.18); border-top: 1px solid var(--divider-color);
margin: var(--base-margin) 0; margin: var(--base-margin) 0;
} }
.xterm {
padding: 10px; /* 可以添加更多全局样式规则 */
}
/* 为 xterm 终端添加内边距 */
.control-panel {
border: 1px solid rgba(103, 124, 155, 0.18); .xterm{
border-radius: 24px; padding: 10px 10px 10px 10px;
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);
} }
/* 为历史记录和快捷命令列表设置字体 */
/* 注意:这里的选择器可能需要根据实际组件结构调整 */
.command-history-item, .command-history-item,
.quick-command-item, .quick-command-item { /* 假设这些是列表项的类名 */
font-family: var(--font-family-sans-serif);
}
/* 如果是 Element Plus 的 Table 组件 */
.el-table .cell { .el-table .cell {
font-family: var(--font-family-sans-serif); font-family: var(--font-family-sans-serif);
} }
.el-card { /* Override splitpanes default theme pane background */
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;
}
.splitpanes.default-theme .splitpanes__pane { .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 { .splitpanes.default-theme .splitpanes__splitter {
background-color: transparent !important; background-color: var(--app-bg-color) !important; /* Use important to ensure override */
border-left: 1px solid rgba(103, 124, 155, 0.18); border-left: 1px solid var(--border-color); /* Add a subtle border */
border-right: 1px solid rgba(103, 124, 155, 0.18); border-right: 1px solid var(--border-color);
box-sizing: border-box; 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 { .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 { .splitpanes--vertical > .splitpanes__splitter {
width: 8px; width: 7px; /* Adjust width as needed */
border-top: none;
border-bottom: none;
} }
.splitpanes--horizontal > .splitpanes__splitter { .splitpanes--horizontal > .splitpanes__splitter {
height: 8px; height: 7px; /* Adjust height as needed */
border-top: 1px solid rgba(103, 124, 155, 0.18); border-left: none;
border-bottom: 1px solid rgba(103, 124, 155, 0.18); border-right: none;
border-top: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color);
} }
/* Style scrollbars */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 10px; width: 8px; /* Width of vertical scrollbar */
height: 10px; height: 8px; /* Height of horizontal scrollbar */
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.28); background: var(--app-bg-color); /* Scrollbar track background */
border-radius: 999px; border-radius: 4px;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background-color: rgba(104, 123, 152, 0.5); background-color: var(--border-color); /* Scrollbar handle color */
border-radius: 999px; border-radius: 4px;
border: 2px solid transparent; border: 2px solid var(--app-bg-color); /* Creates padding around thumb */
background-clip: padding-box;
} }
::-webkit-scrollbar-thumb:hover { ::-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) { /* Input focus styles */
background-color: rgba(226, 233, 244, 0.86) !important; 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;
}
+186 -161
View File
@@ -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 }]">
&laquo;
</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 }]">
&raquo;
</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"> <script setup lang="ts">
import { ref, onMounted, computed } from 'vue'; import { ref, onMounted, computed } from 'vue'; // Removed watch
import { useI18n } from 'vue-i18n';
import { useAuditLogStore } from '../stores/audit.store'; import { useAuditLogStore } from '../stores/audit.store';
import type { AuditLogEntry, AuditLogActionType } from '../types/server.types'; import { AuditLogEntry, AuditLogActionType } from '../types/server.types';
import PageShell from '../components/PageShell.vue'; import { useI18n } from 'vue-i18n';
// Removed lodash-es import
const store = useAuditLogStore(); const store = useAuditLogStore();
const { t } = useI18n(); const { t } = useI18n();
// --- Filtering State ---
const searchTerm = ref(''); 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[] = [ const allActionTypes: AuditLogActionType[] = [
'LOGIN_SUCCESS', 'LOGIN_SUCCESS', 'LOGIN_FAILURE', 'LOGOUT', 'PASSWORD_CHANGED',
'LOGIN_FAILURE', '2FA_ENABLED', '2FA_DISABLED',
'LOGOUT', 'CONNECTION_CREATED', 'CONNECTION_UPDATED', 'CONNECTION_DELETED',
'PASSWORD_CHANGED', 'PROXY_CREATED', 'PROXY_UPDATED', 'PROXY_DELETED',
'2FA_ENABLED', 'TAG_CREATED', 'TAG_UPDATED', 'TAG_DELETED',
'2FA_DISABLED', 'SETTINGS_UPDATED', 'IP_WHITELIST_UPDATED',
'CONNECTION_CREATED', 'NOTIFICATION_SETTING_CREATED', 'NOTIFICATION_SETTING_UPDATED', 'NOTIFICATION_SETTING_DELETED',
'CONNECTION_UPDATED', // SSH Actions
'CONNECTION_DELETED', 'SSH_CONNECT_SUCCESS', 'SSH_CONNECT_FAILURE', 'SSH_SHELL_FAILURE',
'PROXY_CREATED', // System/Error
'PROXY_UPDATED', 'DATABASE_MIGRATION', 'ADMIN_SETUP_COMPLETE'
'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',
]; ];
const logs = computed(() => store.logs); const logs = computed(() => store.logs);
const totalLogs = computed(() => store.totalLogs); const totalLogs = computed(() => store.totalLogs);
const currentPage = computed(() => store.currentPage); const currentPage = computed(() => store.currentPage);
const logsPerPage = computed(() => store.logsPerPage); const logsPerPage = computed(() => store.logsPerPage);
const totalPages = computed(() => Math.max(1, Math.ceil(totalLogs.value / logsPerPage.value)));
const auditStats = computed(() => [ const totalPages = computed(() => Math.ceil(totalLogs.value / logsPerPage.value));
{
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}`,
},
]);
// Function to apply filters and fetch logs
const applyFilters = () => { const applyFilters = () => {
store.fetchLogs({ // Pass undefined if filter is empty, otherwise pass the value
page: 1, store.fetchLogs({
searchTerm: searchTerm.value || undefined, page: 1, // Reset to page 1 when applying filters
actionType: selectedActionType.value || undefined, searchTerm: searchTerm.value || undefined,
}); actionType: selectedActionType.value || undefined
});
}; };
// Removed watch for filters
onMounted(() => { onMounted(() => {
// Fetch initial logs without filters
store.fetchLogs(); 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 translateActionType = (actionType: AuditLogActionType): string => {
const key = `auditLog.actions.${actionType}`; // Attempt to translate using a convention like auditLog.actions.ACTION_TYPE
const translated = t(key); const key = `auditLog.actions.${actionType}`;
return translated === key ? actionType : translated; const translated = t(key);
// If translation is missing, return the original type
return translated === key ? actionType : translated;
}; };
const formatDetails = (details: AuditLogEntry['details']): string => { const formatDetails = (details: AuditLogEntry['details']): string => {
if (!details) return '-'; if (!details) return '';
if (typeof details === 'object') { if (typeof details === 'object' && details !== null) {
if ('raw' in details && details.parseError) { 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) => { const changePage = (page: number) => {
if (page >= 1 && page <= totalPages.value && page !== currentPage.value) { if (page >= 1 && page <= totalPages.value && page !== currentPage.value) {
// Retain current filters when changing page
store.fetchLogs({ store.fetchLogs({
page, page: page,
searchTerm: searchTerm.value || undefined, searchTerm: searchTerm.value || undefined,
actionType: selectedActionType.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> </script>
<template> <style scoped>
<PageShell /* Remove all scoped styles as they are now handled by Tailwind utility classes */
:title="$t('auditLog.title')" </style>
: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>
@@ -746,4 +746,4 @@ const handleConnectAllFilteredConnections = async () => {
@saved="handleBatchEditSaved" @saved="handleBatchEditSaved"
/> />
</div> </div>
</template> </template>
+229 -284
View File
@@ -1,51 +1,57 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'; import { ref, computed, onMounted, watch } from 'vue';
import { storeToRefs } from 'pinia'; import AddConnectionForm from '../components/AddConnectionForm.vue';
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 { useConnectionsStore } from '../stores/connections.store'; import { useConnectionsStore } from '../stores/connections.store';
import { useAuditLogStore } from '../stores/audit.store'; import { useAuditLogStore } from '../stores/audit.store';
import { useSessionStore } from '../stores/session.store'; import { useSessionStore } from '../stores/session.store';
import { useTagsStore } from '../stores/tags.store'; import { useTagsStore } from '../stores/tags.store';
import type { TagInfo } 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 { 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 { t, locale } = useI18n();
const router = useRouter(); const router = useRouter();
const connectionsStore = useConnectionsStore(); const connectionsStore = useConnectionsStore();
const auditLogStore = useAuditLogStore(); const auditLogStore = useAuditLogStore();
const sessionStore = useSessionStore(); const sessionStore = useSessionStore();
const tagsStore = useTagsStore(); const tagsStore = useTagsStore();
const { connections, isLoading: isLoadingConnections } = storeToRefs(connectionsStore); const { connections, isLoading: isLoadingConnections } = storeToRefs(connectionsStore);
const { logs: auditLogs, isLoading: isLoadingLogs, totalLogs } = storeToRefs(auditLogStore); 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_BY_KEY = 'dashboard_connections_sort_by';
const LS_SORT_ORDER_KEY = 'dashboard_connections_sort_order'; const LS_SORT_ORDER_KEY = 'dashboard_connections_sort_order';
const LS_FILTER_TAG_KEY = 'dashboard_connections_filter_tag'; 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');
// 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 getInitialSelectedTagId = (): number | null => {
const storedValue = localStorage.getItem(LS_FILTER_TAG_KEY); const storedValue = localStorage.getItem(LS_FILTER_TAG_KEY);
// 如果存储的值是 'null' 字符串或空,则返回 null,否则解析为数字
return storedValue && storedValue !== 'null' ? parseInt(storedValue, 10) : null; return storedValue && storedValue !== 'null' ? parseInt(storedValue, 10) : null;
}; };
const selectedTagId = ref<number | null>(getInitialSelectedTagId()); const selectedTagId = ref<number | null>(getInitialSelectedTagId());
const searchQuery = ref(''); const searchQuery = ref('');
// +++ 控制添加/编辑表单的显示状态 +++
const showAddEditConnectionForm = ref(false); const showAddEditConnectionForm = ref(false);
const connectionToEdit = ref<ConnectionInfo | null>(null); const connectionToEdit = ref<ConnectionInfo | null>(null);
const maxRecentLogs = 5; const maxRecentLogs = 5;
const sortOptions: { value: SortField; labelKey: string }[] = [ const sortOptions: { value: SortField; labelKey: string }[] = [
{ value: 'last_connected_at', labelKey: 'dashboard.sortOptions.lastConnected' }, { value: 'last_connected_at', labelKey: 'dashboard.sortOptions.lastConnected' },
{ value: 'name', labelKey: 'dashboard.sortOptions.name' }, { value: 'name', labelKey: 'dashboard.sortOptions.name' },
@@ -54,114 +60,98 @@ const sortOptions: { value: SortField; labelKey: string }[] = [
{ value: 'created_at', labelKey: 'dashboard.sortOptions.created' }, { value: 'created_at', labelKey: 'dashboard.sortOptions.created' },
]; ];
// +++ 修改计算属性,先筛选再排序 +++
const filteredAndSortedConnections = computed(() => { const filteredAndSortedConnections = computed(() => {
const sortBy = localSortBy.value; const sortBy = localSortBy.value;
const sortOrderVal = localSortOrder.value; const sortOrderVal = localSortOrder.value;
const factor = sortOrderVal === 'desc' ? -1 : 1; const factor = sortOrderVal === 'desc' ? -1 : 1;
const filterTagId = selectedTagId.value; const filterTagId = selectedTagId.value;
const query = searchQuery.value.toLowerCase().trim(); const query = searchQuery.value.toLowerCase().trim(); // +++ 获取搜索查询 +++
const filteredByTag = // 1. Filter by selected tag
filterTagId === null let filteredByTag = filterTagId === null
? [...connections.value] ? [...connections.value] // No tag selected, show all
: connections.value.filter((conn) => conn.tag_ids?.includes(filterTagId)); : connections.value.filter(conn => conn.tag_ids?.includes(filterTagId));
const searchedConnections = query // 2. Filter by search query
? filteredByTag.filter((conn) => { let searchedConnections = filteredByTag;
const nameMatch = conn.name?.toLowerCase().includes(query); if (query) {
const usernameMatch = conn.username?.toLowerCase().includes(query); searchedConnections = filteredByTag.filter(conn => {
const hostMatch = conn.host?.toLowerCase().includes(query); const nameMatch = conn.name?.toLowerCase().includes(query);
const portMatch = conn.port?.toString().includes(query); const usernameMatch = conn.username?.toLowerCase().includes(query);
return nameMatch || usernameMatch || hostMatch || portMatch; const hostMatch = conn.host?.toLowerCase().includes(query);
}) const portMatch = conn.port?.toString().includes(query);
: filteredByTag; return nameMatch || usernameMatch || hostMatch || portMatch;
});
}
// 3. Sort the searched connections
return searchedConnections.sort((a, b) => { return searchedConnections.sort((a, b) => {
let valA: string | number; let valA: any;
let valB: string | number; let valB: any;
switch (sortBy) { switch (sortBy) {
case 'name': case 'name':
valA = a.name || ''; valA = a.name || '';
valB = b.name || ''; valB = b.name || '';
return String(valA).localeCompare(String(valB)) * factor; return valA.localeCompare(valB) * factor;
case 'type': case 'type':
valA = a.type || ''; valA = a.type || '';
valB = b.type || ''; valB = b.type || '';
return String(valA).localeCompare(String(valB)) * factor; return valA.localeCompare(valB) * factor;
case 'created_at': case 'created_at':
valA = a.created_at ?? 0; valA = a.created_at ?? 0;
valB = b.created_at ?? 0; valB = b.created_at ?? 0;
return (Number(valA) - Number(valB)) * factor; return (valA - valB) * factor;
case 'updated_at': case 'updated_at':
valA = a.updated_at ?? 0; valA = a.updated_at ?? 0;
valB = b.updated_at ?? 0; valB = b.updated_at ?? 0;
return (Number(valA) - Number(valB)) * factor; return (valA - valB) * factor;
case 'last_connected_at': case 'last_connected_at':
valA = a.last_connected_at ?? (sortOrderVal === 'desc' ? -Infinity : Infinity); valA = a.last_connected_at ?? (sortOrderVal === 'desc' ? -Infinity : Infinity);
valB = b.last_connected_at ?? (sortOrderVal === 'desc' ? -Infinity : Infinity); valB = b.last_connected_at ?? (sortOrderVal === 'desc' ? -Infinity : Infinity);
if (valA === valB) return 0; if (valA === valB) return 0;
return Number(valA) < Number(valB) ? -1 * factor : 1 * factor; if (valA < valB) return -1 * factor;
return 1 * factor;
default: default:
return 0; return 0;
} }
}); });
}); });
const recentAuditLogs = computed(() => auditLogs.value.slice(0, maxRecentLogs)); const recentAuditLogs = computed(() => {
return 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', '工作台已接入'),
},
];
}); });
onMounted(async () => { onMounted(async () => {
// Load saved preferences from localStorage (already done during ref initialization)
// Fetch connections if not already loaded
if (connections.value.length === 0) { if (connections.value.length === 0) {
try { try {
await connectionsStore.fetchConnections(); await connectionsStore.fetchConnections();
} catch (error) { } catch (error) {
console.error('Failed to load connections:', error); console.error("加载连接列表失败:", error);
} }
} }
// Fetch recent audit logs
try { try {
await auditLogStore.fetchLogs({ await auditLogStore.fetchLogs({
page: 1, page: 1,
limit: maxRecentLogs, limit: maxRecentLogs,
sortOrder: 'desc', sortOrder: 'desc',
isDashboardRequest: true, isDashboardRequest: true
}); });
} catch (error) { } catch (error) {
console.error('Failed to load audit logs:', error); console.error("加载审计日志失败:", error);
} }
// +++ Fetch tags for filtering +++
try { try {
await tagsStore.fetchTags(); await tagsStore.fetchTags();
} catch (error) { } catch (error) {
console.error('Failed to load tags:', error); console.error("加载标签列表失败:", error);
} }
}); });
@@ -170,11 +160,13 @@ const connectTo = (connection: ConnectionInfo) => {
}; };
const toggleSortOrder = () => { const toggleSortOrder = () => {
// Only update the local sort order state
localSortOrder.value = localSortOrder.value === 'asc' ? 'desc' : 'asc'; 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) => { watch(localSortBy, (newValue) => {
localStorage.setItem(LS_SORT_BY_KEY, newValue); localStorage.setItem(LS_SORT_BY_KEY, newValue);
}); });
@@ -183,7 +175,9 @@ watch(localSortOrder, (newValue) => {
localStorage.setItem(LS_SORT_ORDER_KEY, newValue); localStorage.setItem(LS_SORT_ORDER_KEY, newValue);
}); });
// +++ Watch for changes in selected tag and save to localStorage +++
watch(selectedTagId, (newValue) => { watch(selectedTagId, (newValue) => {
// Store 'null' as a string or the number
localStorage.setItem(LS_FILTER_TAG_KEY, newValue === null ? 'null' : String(newValue)); localStorage.setItem(LS_FILTER_TAG_KEY, newValue === null ? 'null' : String(newValue));
}); });
@@ -191,288 +185,239 @@ const dateFnsLocales: Record<string, Locale> = {
'en-US': enUS, 'en-US': enUS,
'zh-CN': zhCN, 'zh-CN': zhCN,
'ja-JP': ja, 'ja-JP': ja,
en: enUS, // 主语言回退
zh: zhCN, 'en': enUS,
ja, 'zh': zhCN,
'ja': ja,
}; };
// 修正函数签名,接受 number | null | undefined
const formatRelativeTime = (timestampInSeconds: number | null | undefined): string => { const formatRelativeTime = (timestampInSeconds: number | null | undefined): string => {
if (!timestampInSeconds) return t('connections.status.never'); if (!timestampInSeconds) return t('connections.status.never');
try { try {
// 将秒级时间戳转换为毫秒级
const timestampInMs = timestampInSeconds * 1000; 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); // 3. 如果仍然找不到,回退到默认 enUS
const currentI18nLocale = locale.value; if (!targetDateFnsLocale) {
const langPart = currentI18nLocale.split('-')[0]; console.warn(`[Dashboard] date-fns locale not found for ${currentI18nLocale} or ${langPart}. Falling back to en-US.`);
const targetLocale = dateFnsLocales[currentI18nLocale] || dateFnsLocales[langPart] || enUS; targetDateFnsLocale = enUS; // 默认回退到 enUS
}
return formatDistanceToNow(date, { addSuffix: true, locale: targetLocale }); return formatDistanceToNow(date, { addSuffix: true, locale: targetDateFnsLocale });
} catch (error) { } catch (e) {
console.error('Failed to format date:', error); console.error("格式化日期失败:", e);
return String(timestampInSeconds); return String(timestampInSeconds); // 出错时返回原始字符串
} }
}; };
const getActionTranslation = (actionType: string): string => { const getActionTranslation = (actionType: string): string => {
// 尝试从 i18n 获取翻译,如果找不到则返回原始 actionType
const key = `auditLog.actions.${actionType}`; const key = `auditLog.actions.${actionType}`;
const translated = t(key); const translated = t(key);
// 如果翻译结果等于 key 本身,说明没有找到翻译
return translated === key ? actionType : translated; return translated === key ? actionType : translated;
}; };
// 辅助函数:判断活动类型是否表示失败
const isFailedAction = (actionType: string): boolean => { const isFailedAction = (actionType: string): boolean => {
const lowerCaseAction = actionType.toLowerCase(); const lowerCaseAction = actionType.toLowerCase();
// 检查常见的失败关键词
return lowerCaseAction.includes('fail') || lowerCaseAction.includes('error') || lowerCaseAction.includes('denied'); return lowerCaseAction.includes('fail') || lowerCaseAction.includes('error') || lowerCaseAction.includes('denied');
}; };
// +++ 恢复:根据 tag_ids 获取标签名称数组 +++
const getTagNames = (tagIds: number[] | undefined): string[] => { const getTagNames = (tagIds: number[] | undefined): string[] => {
if (!tagIds || tagIds.length === 0) { if (!tagIds || tagIds.length === 0) {
return []; return [];
} }
const allTags = tags.value as TagInfo[]; const allTags = tags.value as TagInfo[];
return tagIds return tagIds
.map((id) => allTags.find((tag) => tag.id === id)?.name) .map(id => allTags.find(tag => tag.id === id)?.name)
.filter((name): name is string => Boolean(name)); .filter((name): name is string => !!name); // 过滤掉未找到的标签并确保类型为 string
}; };
// +++ 打开添加表单 +++
const openAddConnectionForm = () => { const openAddConnectionForm = () => {
connectionToEdit.value = null; connectionToEdit.value = null;
showAddEditConnectionForm.value = true; showAddEditConnectionForm.value = true;
}; };
// +++ 打开编辑表单 +++
const openEditConnectionForm = (conn: ConnectionInfo) => { const openEditConnectionForm = (conn: ConnectionInfo) => {
connectionToEdit.value = conn; connectionToEdit.value = conn;
showAddEditConnectionForm.value = true; showAddEditConnectionForm.value = true;
}; };
// +++ 处理表单关闭事件 +++
const handleFormClose = () => { const handleFormClose = () => {
showAddEditConnectionForm.value = false; showAddEditConnectionForm.value = false;
connectionToEdit.value = null; connectionToEdit.value = null; // 清除编辑状态
}; };
// +++ 处理连接添加/更新成功事件 +++
const handleConnectionModified = async () => { const handleConnectionModified = async () => {
showAddEditConnectionForm.value = false; showAddEditConnectionForm.value = false;
connectionToEdit.value = null; connectionToEdit.value = null;
await connectionsStore.fetchConnections(); await connectionsStore.fetchConnections(); // 重新加载连接列表
};
const openConnectionsView = () => {
router.push('/connections');
};
const openAuditLogsView = () => {
router.push('/audit-logs');
}; };
// --- 移除 selectTagFilter 函数 ---
</script> </script>
<template> <template>
<PageShell <div class="p-4 md:p-6 lg:p-8 bg-background text-foreground">
:title="t('nav.dashboard')" <h1 class="text-2xl font-semibold mb-6">{{ t('nav.dashboard') }}</h1>
: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>
<template #stats> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 lg:items-start">
<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 gap-5 xl:grid-cols-[1.5fr_1fr]"> <!-- Connection List -->
<el-card shadow="never" class="control-panel"> <div class="bg-card text-card-foreground shadow rounded-lg overflow-hidden border border-border min-h-[400px]">
<template #header> <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">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between"> <h2 class="text-lg font-medium flex-shrink-0">{{ t('dashboard.connectionList', '连接列表') }} ({{ filteredAndSortedConnections.length }})</h2>
<div> <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">
<div class="text-lg font-semibold text-foreground"> <!-- Search Input (Order adjusted for button placement) -->
{{ t('dashboard.connectionList', '连接列表') }} <input
</div> type="text"
<div class="text-sm text-text-secondary"> v-model="searchQuery"
{{ filteredAndSortedConnections.length }} / {{ connections.length }} :placeholder="t('dashboard.searchConnectionsPlaceholder', '搜索连接...')"
</div> 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> />
<div class="flex items-center space-x-2"> <!-- Wrapper for existing controls -->
<div class="grid gap-2 md:grid-cols-[minmax(200px,1fr)_150px_160px_auto_auto]"> <!-- Tag Filter Dropdown -->
<el-input <select
v-model="searchQuery" v-model="selectedTagId"
:placeholder="t('dashboard.searchConnectionsPlaceholder', '搜索连接...')" 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"
clearable 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> <option :value="null">{{ t('dashboard.filterTags.all', '所有标签') }}</option>
<i class="fas fa-search text-text-secondary"></i> <option v-if="isLoadingTags" disabled>{{ t('common.loading') }}</option>
</template> <!-- 修正 v-for 循环中的类型 -->
</el-input> <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> <!-- Sort By Dropdown -->
<el-option :label="t('dashboard.filterTags.all', '所有标签')" :value="null" /> <select
<el-option v-model="localSortBy"
v-for="tag in (tags as TagInfo[])" 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"
:key="tag.id" 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;"
:label="tag.name" aria-label="Sort connections by"
:value="tag.id" >
/> <option v-for="option in sortOptions" :key="option.value" :value="option.value">
</el-select> {{ t(option.labelKey, option.value.replace('_', ' ')) }}
</option>
</select>
<el-select v-model="localSortBy"> <!-- Sort Order Button -->
<el-option <button
v-for="option in sortOptions" @click="toggleSortOrder"
:key="option.value" 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"
:label="t(option.labelKey, option.value)" :aria-label="isAscending ? t('common.sortAscending') : t('common.sortDescending')"
:value="option.value" :title="isAscending ? t('common.sortAscending') : t('common.sortDescending')"
/> >
</el-select> <i :class="['fas', isAscending ? 'fa-arrow-up-a-z' : 'fa-arrow-down-z-a', 'w-4 h-4']"></i>
</button>
<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>
</div> </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> </div>
</template>
<div v-if="isLoadingConnections && filteredAndSortedConnections.length === 0" class="control-empty">
<el-skeleton :rows="4" animated />
</div> </div>
<div class="p-4">
<div v-else-if="filteredAndSortedConnections.length > 0" class="grid gap-3"> <!-- Use filteredAndSortedConnections and check its length -->
<el-card <div v-if="isLoadingConnections && filteredAndSortedConnections.length === 0" class="text-center text-text-secondary">{{ t('common.loading') }}</div>
v-for="conn in filteredAndSortedConnections" <ul v-else-if="filteredAndSortedConnections.length > 0" class="space-y-3">
:key="conn.id" <!-- Iterate over filteredAndSortedConnections -->
shadow="hover" <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">
class="border border-border/50" <div class="flex-grow mr-4 overflow-hidden">
> <span class="font-medium block truncate flex items-center" :title="conn.name || ''">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between"> <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>
<div class="min-w-0"> <span>{{ conn.name || conn.host || t('connections.unnamedFallback', '未命名连接') }}</span>
<div class="flex items-center gap-2 text-base font-semibold text-foreground"> </span>
<i <span class="text-sm text-text-secondary block truncate" :title="`${conn.username}@${conn.host}:${conn.port}`">
: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">
{{ conn.username }}@{{ conn.host }}:{{ conn.port }} {{ conn.username }}@{{ conn.host }}:{{ conn.port }}
</div> </span>
<div class="mt-2 text-xs text-text-secondary"> <span class="text-xs text-text-alt block mb-1"> <!-- Added margin-bottom -->
{{ t('dashboard.lastConnected', '上次连接:') }} {{ formatRelativeTime(conn.last_connected_at) }} {{ t('dashboard.lastConnected', '上次连接:') }} {{ formatRelativeTime(conn.last_connected_at) }}
</div> </span>
<div v-if="getTagNames(conn.tag_ids).length > 0" class="mt-3 flex flex-wrap gap-2"> <div v-if="getTagNames(conn.tag_ids).length > 0" class="flex flex-wrap gap-1 mt-1">
<el-tag <span
v-for="tagName in getTagNames(conn.tag_ids)" v-for="tagName in getTagNames(conn.tag_ids)"
:key="tagName" :key="tagName"
effect="plain" class="px-1.5 py-0.5 text-xs rounded bg-muted text-muted-foreground border border-border"
round
size="small"
> >
{{ tagName }} {{ tagName }}
</el-tag> </span>
</div> </div>
</div> </div>
<div class="flex space-x-2 flex-shrink-0">
<div class="flex flex-wrap items-center gap-2"> <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">
<el-button plain @click="openEditConnectionForm(conn)"> <i class="fas fa-pencil-alt"></i>
<i class="fas fa-pen mr-2"></i> </button>
{{ t('connections.actions.edit') }} <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 -->
</el-button>
<el-button type="primary" @click="connectTo(conn)">
<i class="fas fa-terminal mr-2"></i>
{{ t('connections.actions.connect') }} {{ t('connections.actions.connect') }}
</el-button> </button>
</div> </div>
</div> </li>
</el-card> </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>
<div v-else class="control-empty"> <!-- Recent Activity -->
<el-empty <div class="bg-card text-card-foreground shadow rounded-lg overflow-hidden border border-border min-h-[400px]">
:description=" <div class="px-4 py-3 border-b border-border">
searchQuery <h2 class="text-lg font-medium">{{ t('dashboard.recentActivity', '最近活动') }}</h2>
? t('dashboard.noConnectionsMatchSearch', '没有连接匹配搜索条件')
: selectedTagId !== null
? t('dashboard.noConnectionsWithTag', '该标签下没有连接记录')
: t('dashboard.noConnections', '没有连接记录')
"
/>
</div> </div>
</el-card> <div class="p-4">
<!-- Loading State (Only show if loading AND no logs are displayed yet) -->
<el-card shadow="never" class="control-panel"> <div v-if="isLoadingLogs && recentAuditLogs.length === 0" class="text-center text-text-secondary">{{ t('common.loading') }}</div>
<template #header> <ul v-else-if="recentAuditLogs.length > 0" class="space-y-3">
<div class="flex items-center justify-between gap-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> <div class="flex justify-between items-start mb-1">
<div class="text-lg font-semibold text-foreground"> <span class="font-medium text-sm" :class="{ 'text-error': isFailedAction(log.action_type) }">{{ getActionTranslation(log.action_type) }}</span>
{{ t('dashboard.recentActivity', '最近活动') }} <span class="text-xs text-text-alt flex-shrink-0 ml-2">{{ formatRelativeTime(log.timestamp) }}</span>
</div> </div>
<div class="text-sm text-text-secondary"> <p class="text-sm text-text-secondary break-words">{{ log.details }}</p>
{{ t('auditLog.paginationInfo', { currentPage: 1, totalPages: 1, totalLogs }) }} </li>
</div> </ul>
</div> <div v-else class="text-center text-text-secondary">{{ t('dashboard.noRecentActivity', '没有最近活动记录') }}</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 />
</div> </div>
<div class="px-4 py-3 border-t border-border text-right">
<div v-else-if="recentAuditLogs.length > 0" class="grid gap-3"> <RouterLink :to="{ name: 'AuditLogs' }" class="text-sm text-link hover:text-link-hover hover:underline">
<el-card {{ t('dashboard.viewFullAuditLog', '查看完整审计日志') }}
v-for="log in recentAuditLogs" </RouterLink>
: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> </div>
</div>
<div v-else class="control-empty">
<el-empty :description="t('dashboard.noRecentActivity', '没有最近活动记录')" />
</div>
</el-card>
</div> </div>
<!-- Add/Edit Connection Form Modal -->
<AddConnectionForm <AddConnectionForm
v-if="showAddEditConnectionForm" v-if="showAddEditConnectionForm"
:connectionToEdit="connectionToEdit" :connectionToEdit="connectionToEdit"
@@ -480,5 +425,5 @@ const openAuditLogsView = () => {
@connection-added="handleConnectionModified" @connection-added="handleConnectionModified"
@connection-updated="handleConnectionModified" @connection-updated="handleConnectionModified"
/> />
</PageShell> </div>
</template> </template>
+179 -148
View File
@@ -1,86 +1,115 @@
<script setup lang="ts"> <script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'; import { reactive, ref, onMounted } from 'vue'; // computed 不再直接使用,移除
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { startAuthentication } from '@simplewebauthn/browser'; 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 { useAuthStore } from '../stores/auth.store';
import VueHcaptcha from '@hcaptcha/vue3-hcaptcha';
import VueRecaptcha from 'vue3-recaptcha2'; // 使用默认导入
const { t } = useI18n(); const { t } = useI18n();
const authStore = useAuthStore(); 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({ const credentials = reactive({
username: '', username: '',
password: '', 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(''); // --- reCAPTCHA v3 Initialization ---
const rememberMe = ref(false); // const recaptchaInstance = useReCaptcha(); // 移除 v3 实例,因为我们将使用 v2 组件
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);
// --- CAPTCHA Event Handlers ---
const handleCaptchaVerified = (token: string) => { const handleCaptchaVerified = (token: string) => {
// console.log('CAPTCHA verified, token:', token);
captchaToken.value = token; captchaToken.value = token;
captchaError.value = null; captchaError.value = null; // Clear error on successful verification
}; };
const handleCaptchaExpired = () => { const handleCaptchaExpired = () => {
// console.log('CAPTCHA expired');
captchaToken.value = null; captchaToken.value = null;
}; };
const handleCaptchaError = (errorDetails: any) => {
const handleCaptchaError = (errorDetails: unknown) => {
console.error('CAPTCHA error:', errorDetails); console.error('CAPTCHA error:', errorDetails);
captchaToken.value = null; captchaToken.value = null;
captchaError.value = t('login.error.captchaLoadFailed'); captchaError.value = t('login.error.captchaLoadFailed');
}; };
const resetCaptchaWidget = () => { const resetCaptchaWidget = () => {
// console.log('Resetting CAPTCHA widget...');
captchaToken.value = null; captchaToken.value = null;
// Reset hCaptcha if it exists
hcaptchaWidget.value?.reset(); hcaptchaWidget.value?.reset();
// Reset reCAPTCHA v2 if it exists
recaptchaWidget.value?.reset(); recaptchaWidget.value?.reset();
}; };
const handleSubmit = async () => {
captchaError.value = null;
if (publicCaptchaConfig.value?.enabled && !loginRequires2FA.value && !captchaToken.value) { // 处理登录或 2FA 验证提交
captchaError.value = t('login.error.captchaRequired'); const handleSubmit = async () => {
return; 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 { try {
if (loginRequires2FA.value) { if (loginRequires2FA.value) {
// 如果需要 2FA,则调用 2FA 验证 action
await authStore.verifyLogin2FA(twoFactorToken.value); await authStore.verifyLogin2FA(twoFactorToken.value);
} else { } else {
// 否则,调用常规登录 action,并传递 rememberMe 和 captchaToken 状态
await authStore.login({ await authStore.login({
...credentials, ...credentials,
rememberMe: rememberMe.value, rememberMe: rememberMe.value,
captchaToken: captchaToken.value ?? undefined, captchaToken: captchaToken.value ?? undefined // Pass token or undefined if null
}); });
} }
// 成功后的重定向由 store action 处理
// 失败会更新 error 状态并在模板中显示
} finally { } finally {
if (publicCaptchaConfig.value?.enabled) { // Reset CAPTCHA after attempt (success or failure handled by store redirect/error display)
resetCaptchaWidget(); 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 () => { onMounted(async () => {
// console.log('[LoginView] Component mounted, calling fetchCaptchaConfig and checkHasPasskeysConfigured...');
authStore.fetchCaptchaConfig(); 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(); await authStore.checkHasPasskeysConfigured();
}); });
// --- Passkey Login Handler ---
const handlePasskeyLogin = async () => { const handlePasskeyLogin = async () => {
try { try {
isLoading.value = true; 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 } : {}; 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', { const optionsResponse = await fetch('/api/v1/auth/passkey/authentication-options', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -91,136 +120,138 @@ const handlePasskeyLogin = async () => {
const errData = await optionsResponse.json(); const errData = await optionsResponse.json();
throw new Error(errData.message || t('login.error.passkeyAuthOptionsFailed')); throw new Error(errData.message || t('login.error.passkeyAuthOptionsFailed'));
} }
const authOptions = await optionsResponse.json(); const authOptions = await optionsResponse.json();
// Step 2: Use WebAuthn API to authenticate
const authenticationResult = await startAuthentication(authOptions); 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); await authStore.loginWithPasskey(credentials.username || '', authenticationResult);
} catch (err: any) { } catch (err: any) {
console.error('Passkey login error:', err); console.error('Passkey login error:', err);
error.value = err.message || t('login.error.passkeyAuthFailed'); 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 { } finally {
isLoading.value = false; isLoading.value = false;
} }
}; };
</script> </script>
<template> <template>
<AuthPanelLayout <!-- Page Container -->
:title="t('login.title')" <div class="flex items-center justify-center min-h-screen bg-background p-4">
:subtitle="t('login.controlCenterSubtitle', '使用密码、双重验证或 Passkey 安全接入你的控制中心。')" <!-- Login Card -->
> <div class="flex w-full max-w-4xl rounded-xl shadow-2xl overflow-hidden bg-background border border-border/20">
<el-form label-position="top" @submit.prevent="handleSubmit"> <!-- Left Panel (Brand) - Hidden on small screens -->
<div class="grid gap-5"> <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">
<template v-if="!loginRequires2FA"> <!-- Subtle pattern or overlay could go here -->
<el-form-item :label="t('login.username')"> <div class="z-10 text-center">
<el-input v-model="credentials.username" :disabled="isLoading" size="large" clearable> <img src="../assets/logo.png" alt="Project Logo" class="h-20 w-auto mb-5 mx-auto">
<template #prefix> <h1 class="text-3xl font-bold mb-2">{{ t('projectName') }}</h1>
<i class="fas fa-user text-text-secondary"></i> <p class="text-base opacity-80">{{ t('slogan') }}</p> <!-- Example Slogan -->
</template> </div>
</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>
</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> </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"> <script setup lang="ts">
import PageShell from '../components/PageShell.vue';
import NotificationSettings from '../components/NotificationSettings.vue'; import NotificationSettings from '../components/NotificationSettings.vue';
</script> </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>
+24 -15
View File
@@ -2,9 +2,8 @@
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useProxiesStore, ProxyInfo } from '../stores/proxies.store'; import { useProxiesStore, ProxyInfo } from '../stores/proxies.store';
import PageShell from '../components/PageShell.vue';
import ProxyList from '../components/ProxyList.vue'; import ProxyList from '../components/ProxyList.vue';
import AddProxyForm from '../components/AddProxyForm.vue'; import AddProxyForm from '../components/AddProxyForm.vue';
const { t } = useI18n(); const { t } = useI18n();
const proxiesStore = useProxiesStore(); const proxiesStore = useProxiesStore();
@@ -12,6 +11,7 @@ const proxiesStore = useProxiesStore();
const showForm = ref(false); const showForm = ref(false);
const editingProxy = ref<ProxyInfo | null>(null); const editingProxy = ref<ProxyInfo | null>(null);
// 组件挂载时获取代理列表
onMounted(() => { onMounted(() => {
proxiesStore.fetchProxies(); proxiesStore.fetchProxies();
}); });
@@ -42,18 +42,21 @@ const closeForm = () => {
</script> </script>
<template> <template>
<PageShell <div class="p-4 bg-background text-foreground"> <!-- Outer container with padding -->
:title="t('proxies.title')" <div class="max-w-6xl mx-auto"> <!-- Inner container for max-width and centering -->
:subtitle="t('proxies.controlCenterSubtitle', '在统一的控制中心里管理代理入口、账号和转发策略。')" <h2 class="text-xl font-semibold text-foreground mb-4 pb-2 border-b border-border"> <!-- Title styling consistent with Notifications -->
> {{ t('proxies.title') }}
<template #actions> </h2>
<el-button type="primary" @click="openAddForm">
<i class="fas fa-plus mr-2"></i>
{{ t('proxies.addProxy') }}
</el-button>
</template>
<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 <AddProxyForm
v-if="showForm" v-if="showForm"
:proxy-to-edit="editingProxy" :proxy-to-edit="editingProxy"
@@ -62,7 +65,13 @@ const closeForm = () => {
@proxy-updated="handleProxyUpdated" @proxy-updated="handleProxyUpdated"
/> />
<!-- 代理列表 -->
<ProxyList @edit-proxy="handleEditRequest" /> <ProxyList @edit-proxy="handleEditRequest" />
</el-card> </div>
</PageShell> </div>
</template> </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>
+117 -142
View File
@@ -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"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { storeToRefs } from 'pinia'; import { useAuthStore } from '../stores/auth.store';
import { useI18n } from 'vue-i18n';
import { useSettingsStore } from '../stores/settings.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 { useVersionCheck } from '../composables/settings/useVersionCheck';
import PageShell from '../components/PageShell.vue';
import ChangePasswordForm from '../components/settings/ChangePasswordForm.vue'; import ChangePasswordForm from '../components/settings/ChangePasswordForm.vue';
import PasskeyManagement from '../components/settings/PasskeyManagement.vue'; import PasskeyManagement from '../components/settings/PasskeyManagement.vue';
import TwoFactorAuthSettings from '../components/settings/TwoFactorAuthSettings.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 DataManagementSection from '../components/settings/DataManagementSection.vue';
import AppearanceSection from '../components/settings/AppearanceSection.vue'; import AppearanceSection from '../components/settings/AppearanceSection.vue';
const authStore = useAuthStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const appearanceStore = useAppearanceStore(); // 实例化外观 store
const { t } = useI18n(); const { t } = useI18n();
const { isUpdateAvailable, checkLatestVersion } = useVersionCheck(); const { isUpdateAvailable, checkLatestVersion } = useVersionCheck();
const tabs = computed(() => [ // Define tabs for settings sections
{ key: 'workspace', label: t('settings.tabs.workspace', '工作区'), icon: 'fas fa-sliders' }, const tabs = ref([
{ key: 'system', label: t('settings.tabs.system', '系统'), icon: 'fas fa-server' }, { key: 'workspace', label: t('settings.tabs.workspace', '工作区') },
{ key: 'security', label: t('settings.tabs.security', '安全'), icon: 'fas fa-shield-halved' }, { key: 'system', label: t('settings.tabs.system', '系统') },
{ key: 'ipControl', label: t('settings.tabs.ipControl', 'IP 管控'), icon: 'fas fa-network-wired' }, { key: 'security', label: t('settings.tabs.security', '安全') },
{ key: 'dataManagement', label: t('settings.tabs.dataManagement', '数据管理'), icon: 'fas fa-database' }, { key: 'ipControl', label: t('settings.tabs.ipControl', 'IP 管控') },
{ key: 'appearance', label: t('settings.tabs.appearance', '外观'), icon: 'fas fa-palette' }, { key: 'dataManagement', label: t('settings.tabs.dataManagement', '数据管理') },
{ key: 'about', label: t('settings.tabs.about', '关于'), icon: 'fas fa-circle-info' }, { 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 { const {
settings, settings,
isLoading: settingsLoading, isLoading: settingsLoading,
error: settingsError, error: settingsError,
language: storeLanguage,
} = storeToRefs(settingsStore); } = 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 () => { onMounted(async () => {
await settingsStore.loadCaptchaSettings(); // await fetchIpBlacklist(); // REMOVED - Handled by useIpBlacklist.ts onMounted
await checkLatestVersion(); await settingsStore.loadCaptchaSettings(); // <-- Load CAPTCHA settings
await checkLatestVersion(); // 检查版本更新
}); });
</script> </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> <style scoped>
.settings-tabs :deep(.el-tabs__header) { /* Remove all scoped styles as they are now handled by Tailwind utility classes */
margin-bottom: 1.25rem;
}
</style> </style>
+105 -82
View File
@@ -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"> <script setup lang="ts">
import { ref } from 'vue'; import { ref } from 'vue';
import apiClient from '../utils/apiClient'; // 使用统一的 apiClient
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import AuthPanelLayout from '../components/AuthPanelLayout.vue'; import { useAuthStore } from '../stores/auth.store'; // *** 导入 Auth Store ***
import apiClient from '../utils/apiClient';
import { useAuthStore } from '../stores/auth.store';
const { t } = useI18n(); const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const authStore = useAuthStore(); const authStore = useAuthStore(); // *** 获取 Auth Store 实例 ***
const username = ref(''); const username = ref('');
const password = ref(''); const password = ref('');
@@ -27,105 +111,44 @@ const handleSetup = async () => {
} }
if (!username.value || !password.value) { if (!username.value || !password.value) {
error.value = t('setup.error.fieldsRequired'); error.value = t('setup.error.fieldsRequired');
return; return;
} }
isLoading.value = true; isLoading.value = true;
try { try {
await apiClient.post('/auth/setup', { // 确保调用正确的后端 API 端点
await apiClient.post('/auth/setup', { // 使用 apiClient 并移除 base URL
username: username.value, username: username.value,
password: password.value, password: password.value,
confirmPassword: confirmPassword.value, confirmPassword: confirmPassword.value
}); });
successMessage.value = t('setup.success'); successMessage.value = t('setup.success');
// *** 手动更新 needsSetup 状态 ***
authStore.needsSetup = false; authStore.needsSetup = false;
// *** 重置认证状态,因为设置完成后需要重新登录 ***
authStore.isAuthenticated = false; authStore.isAuthenticated = false;
authStore.user = null; 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'); router.push('/login');
} catch (err: any) { } catch (err: any) {
console.error('Setup failed:', err); console.error('Setup failed:', err);
if (err.response?.data?.message) { if (err.response?.data?.message) {
// 尝试从后端响应中获取更具体的错误信息
error.value = err.response.data.message; error.value = err.response.data.message;
} else if (err.message) { } else if (err.message) {
error.value = err.message; error.value = err.message;
} else { } 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> </script>
<template> <!-- Copied styles from LoginView.vue -->
<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>