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