feat(frontend): unify ui with slate control center

add shared page and auth shells to standardize the main
application layout and authentication entry experience

refresh dashboard, settings, login, setup, notifications,
proxies, and audit logs with a consistent Element Plus based
control-center presentation

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