feat(ui): add semantic colors and project branding
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
# serviceport
|
||||
|
||||
<img src="web/public/serviceport.svg" alt="serviceport 项目图标" width="72" height="72" />
|
||||
|
||||
正常启动时打印项目标识与版本信息;图标设计和命令输出兼容性见 [项目标识说明](docs/branding.md)。
|
||||
|
||||
本地 SQLite 文件支持:侧边栏“SQLite 文件”可测试文件连接、查看表/视图结构并执行只读 SQL 查询,详见 [SQLite 使用说明](docs/sqlite.md)。
|
||||
|
||||
开发时双击根目录 `start-dev.cmd`,系统自动分配空闲管理端口,并打开实际地址(默认免登录)。启动窗口会显示 `http://127.0.0.1:<实际端口>`,每次启动端口可能不同。该入口先切换到自身目录,不覆盖 `SERVICEPORT_DATA`;Windows 本地模式优先指定路径,否则依次查找当前目录 `data/serviceport.sqlite`、旧 `%APPDATA%\serviceport\serviceport.sqlite`,均不存在才在当前目录新建。Windows、Linux、macOS 默认均使用单 SQLite 保存配置、主密钥、审计和自动 TLS,不依赖 `master-key.dpapi` 或主密钥环境变量。升级旧库前须停服并备份完整旧目录,详见 [单文件存储与迁移](docs/single-file-storage.md)。
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package main
|
||||
|
||||
const startupBanner = `
|
||||
+----------------+
|
||||
| ____ | serviceport
|
||||
| o---| _ \ | SERVICE GATEWAY
|
||||
| | |_) | |
|
||||
| | __/ |
|
||||
| |_|----o |
|
||||
+----------------+
|
||||
`
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStartupBannerCommandBoundary(testContext *testing.T) {
|
||||
for _, command := range []string{"", "version", "--version", "data-path", "inspect-database", "restore-database", "export-ca", "reset-password", "migrate-key", "migrate-data", "invalid-command"} {
|
||||
name := command
|
||||
if name == "" {
|
||||
name = "serve"
|
||||
}
|
||||
testContext.Run(name, func(testContext *testing.T) {
|
||||
blocker := filepath.Join(testContext.TempDir(), "not-a-directory")
|
||||
if err := os.WriteFile(blocker, []byte("fixture"), 0600); err != nil {
|
||||
testContext.Fatal(err)
|
||||
}
|
||||
testContext.Setenv("SERVICEPORT_DATA", filepath.Join(blocker, "test.sqlite"))
|
||||
previousArgs, previousStdout, previousLog := os.Args, os.Stdout, log.Writer()
|
||||
reader, writer, err := os.Pipe()
|
||||
if err != nil {
|
||||
testContext.Fatal(err)
|
||||
}
|
||||
defer reader.Close()
|
||||
defer writer.Close()
|
||||
testContext.Cleanup(func() {
|
||||
os.Args, os.Stdout = previousArgs, previousStdout
|
||||
log.SetOutput(previousLog)
|
||||
})
|
||||
var messages bytes.Buffer
|
||||
log.SetOutput(&messages)
|
||||
os.Stdout = writer
|
||||
os.Args = []string{"serviceport"}
|
||||
if command != "" {
|
||||
os.Args = append(os.Args, command)
|
||||
}
|
||||
runErr := run()
|
||||
if command == "" && runErr == nil {
|
||||
testContext.Fatal("expected isolated data path to stop initialization")
|
||||
}
|
||||
writer.Close()
|
||||
output, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
testContext.Fatal(err)
|
||||
}
|
||||
expected := 0
|
||||
if command == "" {
|
||||
expected = 1
|
||||
}
|
||||
if count := strings.Count(messages.String(), startupBanner); count != expected {
|
||||
testContext.Fatalf("banner count = %d, want %d", count, expected)
|
||||
}
|
||||
if bytes.Contains(output, []byte(startupBanner)) {
|
||||
testContext.Fatal("banner must not be written to command stdout")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,9 @@ func run() error {
|
||||
fmt.Println(path)
|
||||
return nil
|
||||
}
|
||||
if len(os.Args) == 1 {
|
||||
log.Print(startupBanner)
|
||||
}
|
||||
log.Print(buildinfo.Current().String())
|
||||
if len(os.Args) > 1 && (len(os.Args) != 2 || (os.Args[1] != "reset-password" && os.Args[1] != "migrate-key" && os.Args[1] != "migrate-data" && os.Args[1] != "export-ca")) {
|
||||
return fmt.Errorf("usage: serviceport [version|data-path|reset-password|migrate-data|migrate-key|export-ca]")
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# 项目标识与启动展示
|
||||
|
||||
serviceport 的标识由蓝色圆角底、白色字母 P 和两端青色连接点组成:P 表示 port / portal,左右接入与出口表示服务通过网关连接。源图为 `web/public/serviceport.svg`,保持简洁轮廓以适应浏览器标签和侧栏的小尺寸。图标使用固定品牌配色,在浅色和深色主题保持一致。
|
||||
|
||||
登录页、工作区左上角和浏览器 favicon 共用这份 SVG,随 Vite 构建复制到 `web/dist` 并嵌入 Go 单文件程序,无外部字体、图片服务或额外部署文件。页面图标旁已有 serviceport 名称,因此内联图片使用空 alt,避免辅助阅读器重复朗读;README 图标保留替代文字。此次不修改 Windows EXE 的原生图标资源。
|
||||
|
||||
正常服务启动时在日志流打印一次对应 P 形端口的 ASCII 标识,随后打印现有版本、提交、构建时间和监听信息。终端标识使用纯 ASCII,不依赖 ANSI 色彩或特殊字体,支持 Windows、Linux、macOS 和重定向日志。标识出现在初始化阶段,不代表服务已成功监听;以随后监听日志和健康检查为准。
|
||||
|
||||
`version` / `--version`(含 JSON)、`data-path`、数据库检查与恢复、证书导出及维护命令不打印图案,保持脚本消费的输出内容。无配置或数据库迁移,现有启动参数和主题偏好不变。
|
||||
|
||||
## 实际验证(2026-09-25)
|
||||
|
||||
Node 22.22.3 前端生产构建通过(仅既有 chunk 大小提示),Go 1.25.0 全量测试及 `go vet ./...` 通过。命令边界测试使用临时无效数据路径在初始化阶段结束,验证正常启动标识出现一次、维护命令没有标识且 stdout 不被污染;原 JSON 版本及路径查询测试也通过。
|
||||
|
||||
构建 `bin/dev-build/branding-20260925/serviceport.exe` 后完成隔离启动、嵌入页面、登录冒烟;全部 124 项 Playwright 通过,包含 SVG HTTP 类型/加载、favicon 引用、主题和手机布局检查,日志实际出现 ASCII 标识及随后版本/监听信息。使用临时数据库及本机 fixture,未验证真实上游服务,也未改动真实 data。图标 PNG 及界面预览保存在 `bin/previews/branding-20260925/`,源文件仍为可缩放 SVG。
|
||||
@@ -1,5 +1,17 @@
|
||||
# 变更原因与设计记录
|
||||
|
||||
## 项目图标与启动标识(2026-09-24)
|
||||
|
||||
原因:用户要求设计项目图标并在启动时打印。新增蓝底白色 P 与青色连接点的 SVG,复用于 README、登录/侧栏和 favicon,随前端嵌入程序;正常服务启动打印纯 ASCII 对应标识,沿用现有版本日志。命令行查询、JSON、路径、证书和维护命令不打印图案,避免污染机器可读输出。只改变品牌展示及正常启动日志,无数据或配置迁移,不修改 EXE 原生资源图标。设计和使用边界见 `docs/branding.md`。
|
||||
|
||||
实际验证(2026-09-25):Node 22.22.3 生产构建、Go 1.25.0 `go test ./... -count=1 -timeout=120s` 和 `go vet ./...` 通过。新增命令边界测试验证正常启动打印一次、维护命令及 stdout 不含标识,原 JSON/路径测试仍通过。新嵌入程序 `bin/dev-build/branding-20260925/serviceport.exe` 通过隔离启动/登录验证,全部 124 项 Playwright 通过;确认 SVG 正常加载及 favicon 引用,检查实际 ASCII 启动日志和桌面/登录截图,预览保存在 `bin/previews/branding-20260925/`。测试使用临时数据与本机 fixture,不是上游真实服务器验证。差异检查通过,未部署或修改真实 data。
|
||||
|
||||
## 功能重要性与语义颜色(2026-09-24)
|
||||
|
||||
原因:用户反馈界面只有白底黑字,功能重要性难以区分。统一主题主色为蓝色(深色采用亮蓝),浅色背景使用淡蓝灰;核心导航显示文字标记,配置/监控图标使用紫/青区分,统计卡增加语义色边框、浅底和数字,网关启动/停止用绿/橙区分,状态色块及异常面板突出成功/异常。保留文字和图标、焦点与禁用态,不以颜色替代状态说明。覆盖公共主题、导航、首页、网关统计/操作及连接编辑器选中页签,不改数据、权限、网络或操作确认,无迁移。主题偏好存储值不变。
|
||||
|
||||
实际验证:`npm.cmd --prefix web run build` 通过(本机 Node 22.10 提示 Vite 引擎版本及既有 chunk 大小警告);随后 Go 1.25.0 构建新嵌入程序 `bin/dev-build/ui-colors-20260924/serviceport.exe`,以 SERVICEPORT_E2E_BINARY 指向它,在隔离数据库和 18381/18382 端口运行全部 Playwright:主题/响应式 5 项通过,其余 119 项通过。后半套因 Node 22.10 缺少 node:sqlite 改用已安装的 Node 22.22.3 执行;未修改系统默认版本。检查浅色桌面、深色手机真实截图,预览位于同目录的 light-desktop.png / dark-mobile.png;代表性主按钮和语义文字在 8% 色底上的配色计算最低对比度为浅色/海蓝 4.51:1、深色 4.99:1,不宣称已覆盖所有状态的无障碍审计。`git diff --check` 通过。浏览器后端/协议验证使用隔离本机服务或 fixture,未连接真实目标或修改真实 data;无后端业务改动,未重复运行全量 Go 单测。
|
||||
|
||||
## 完整下载地址集中输出(2026-09-24)
|
||||
|
||||
用户指出前一轮只核对已有代码而未产生修改。本次调整上传成功日志:先输出 IPFS URI,末尾集中输出下载总页及七个文件的完整 HTTP(S) URL,每个地址单独一行,使用当前 API 同源网关和实际返回 CID。仅改变日志排版,不改上传、包格式或流水线配置,无迁移要求。通过本机 HTTP fixture 验证八条独立地址及失败不输出成功汇总;未重新发布真实 IPFS 产物。
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
## DBX 风格全站主题
|
||||
|
||||
2026-09-24 在原紧凑布局上加入功能与状态配色:核心入口、选中导航和主操作使用蓝色,状态与风险采用绿/橙/红,统计卡增加彩色边框和浅底。手机保留图标和文字可访问名称,详细规则见 [功能重要性与状态配色](ui-design.md)。以下中性灰描述及受限验证为历史记录。
|
||||
|
||||
全部工作区页面、登录、通用控件和编辑器改为统一的紧凑中性灰界面,详见 [界面设计与验证边界](ui-design.md)。桌面侧栏默认 224px,可调整至 192–420px,中等宽度为 192px,手机为 52px;手机导航使用可访问名称和线性 SVG 图标,按钮高度仍至少 44px。顶部工具栏容纳资源搜索、版本、环境状态和主题选择,不再使用覆盖内容的悬浮主题控件。资源树在手机通过弹窗展开,工作区标签可横向滚动,编辑器内提供标签切换入口。
|
||||
|
||||
`theme.css` 提供深浅色与海蓝令牌,`controls.css` 统一公共控件,`responsive.css` 继续处理断点。连接编辑器保留原有尺寸、固定操作区与键盘交互,补上参数区、标签切换和内联测试反馈。本轮类型检查已通过,但生产构建和完整浏览器验证受 `spawn EPERM` 限制,不能沿用下方历史回归记录声称已通过;实际情况见设计文档和变更记录。
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# DBX 风格界面
|
||||
|
||||
## 功能重要性与状态配色(2026-09-24)
|
||||
|
||||
在紧凑布局上增加语义颜色层级:主要操作和当前选中项使用蓝色;核心入口(目标、网关)同时显示“核心”文字标记;配置类图标使用紫色,监控类使用青色,辅助功能保留低强调度。浅色背景调整为淡蓝灰,内容面板保持独立层次;深色主题保留石墨底色,以亮蓝、青色和紫色提供强调。
|
||||
|
||||
统计卡使用对应彩色顶部边框、浅色底和数字:代理总量蓝色、运行中绿色、活跃连接青色、只读配置紫色。启动操作绿色、停止操作橙色,运行/异常状态使用带文字的色块;异常网关区域仅在有异常时变红。错误和警告提示保留标题、正文及原有图标并增加彩色边线,删除操作沿用红色。颜色不代替文字,不将只读代理数或固定菜单分类误报为告警。
|
||||
|
||||
浅色、深色、海蓝及跟随系统均引用共享令牌,沿用已有主题存储键。手机隐藏核心入口的小标签但保留图标颜色、可访问名称和选中状态;按钮禁用、键盘焦点及原有确认流程保持有效。范围限展示,无 API、数据迁移或协议行为变化。本节取代下方历史的黑白主按钮描述;实际验证记录见本次变更原因。
|
||||
|
||||
本次生产构建及基于新嵌入程序的全部 124 项 Playwright 测试通过,覆盖主题、移动布局和原有交互;下方 `spawn EPERM` 为此前记录,不代表当前验证状态。隔离预览位于 `bin/dev-build/ui-colors-20260924/light-desktop.png`、`dark-mobile.png`。
|
||||
|
||||
serviceport 的全部工作区页面采用参考本地 `C:\work\github\dbx` 的紧凑桌面工具风格。参考范围是视觉体系与通用控件,而不是把 DBX 的数据库编辑器、Tauri 运行时、连接状态或业务功能移植进来。serviceport 继续使用 Vue 3、Element Plus 和原有管理 API。
|
||||
|
||||
2026-09-20 已进一步实现 [清单](todo.md) 中 UI-001 至 UI-011 的代码:资源搜索、资源树、列设置、工作区标签、操作菜单、最近访问,以及独立 URL 参数、高级连接选项、连接标识与测试反馈。工作区操作及生命周期见 [工作区交互](workspace-ui.md),协议生效范围见 [连接配置](connection-editor.md)。代码实现与运行态验收分开记录,当前浏览器验证仍受环境限制。
|
||||
@@ -37,6 +47,8 @@ serviceport 的全部工作区页面采用参考本地 `C:\work\github\dbx` 的
|
||||
|
||||
## 维护入口
|
||||
|
||||
项目品牌图标单独使用 `web/public/serviceport.svg`,登录、侧栏和浏览器 favicon 共用;普通功能图标仍由 `AppIcon.vue` 提供。启动 ASCII 标识与资源嵌入说明见 [项目标识](branding.md)。
|
||||
|
||||
`main.ts` 按 Element Plus 基础样式、深色变量、`theme.css`、`style.css`、`controls.css`、`responsive.css` 的顺序加载全局样式。颜色和尺寸定义放在 `theme.css`;布局放在 `style.css`;公共 Element Plus 外观放在 `controls.css`;断点规则放在 `responsive.css`。独立组件的局部样式应引用这些令牌,不再定义一套硬编码主题色。
|
||||
|
||||
`ThemePicker.vue` 复用原来的 `theme.ts` 状态,不创建第二套偏好存储。`SidebarNavigation.vue` 继续使用 App 提供的菜单、计数、搜索和导航事件。
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#132c2b"><title>serviceport · 连接管理</title></head><body><div id="app"></div><script type="module" src="/src/main.ts"></script></body></html>
|
||||
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#1d4ed8"><link rel="icon" type="image/svg+xml" href="/serviceport.svg"><title>serviceport · 连接管理</title></head><body><div id="app"></div><script type="module" src="/src/main.ts"></script></body></html>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-labelledby="serviceport-title">
|
||||
<title id="serviceport-title">serviceport</title>
|
||||
<rect width="64" height="64" rx="15" fill="#1d4ed8" />
|
||||
<path d="M25 49V16h13c8 0 13 5 13 12s-5 12-13 12H25" fill="none" stroke="#ffffff" stroke-width="5.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M11 28h14M25 49h22" fill="none" stroke="#67e8f9" stroke-width="4.5" stroke-linecap="round" />
|
||||
<circle cx="11" cy="28" r="4" fill="#67e8f9" />
|
||||
<circle cx="47" cy="49" r="4" fill="#67e8f9" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 605 B |
+10
-10
@@ -928,7 +928,7 @@ onUnmounted(() => {
|
||||
<div v-else-if="!user" class="login-shell">
|
||||
<section class="login-story">
|
||||
<div class="brand">
|
||||
<span class="brand-mark"><AppIcon name="network" :size="20" /></span>
|
||||
<img class="brand-mark" src="/serviceport.svg" alt="" width="28" height="28" />
|
||||
serviceport
|
||||
</div>
|
||||
<div>
|
||||
@@ -992,7 +992,7 @@ onUnmounted(() => {
|
||||
<div v-else class="app-shell" :style="{'--workspace-sidebar-width':`${sidebarSize.width.value}px`}">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<span class="brand-mark"><AppIcon name="network" :size="18" /></span>
|
||||
<img class="brand-mark" src="/serviceport.svg" alt="" width="28" height="28" />
|
||||
serviceport
|
||||
</div>
|
||||
<SidebarPanels>
|
||||
@@ -1116,12 +1116,12 @@ onUnmounted(() => {
|
||||
<div class="table-footer"><span>已加载 {{ commands.length }} 条 · 每页 200 条</span><el-button v-if="commandBefore" :loading="commandLoading" @click="loadCommands(true)">加载更早记录</el-button></div>
|
||||
</section>
|
||||
<div class="stats" v-if="page === 'proxies' || page === 'overview'">
|
||||
<article>
|
||||
<article class="stat-total">
|
||||
<span>全部代理</span>
|
||||
<strong>{{ stats.all.toString().padStart(2, "0") }}</strong>
|
||||
<small>MySQL · MongoDB · SSH</small>
|
||||
</article>
|
||||
<article>
|
||||
<article class="stat-running">
|
||||
<span>
|
||||
<i class="dot green"></i>
|
||||
运行中
|
||||
@@ -1129,12 +1129,12 @@ onUnmounted(() => {
|
||||
<strong>{{ stats.running.toString().padStart(2, "0") }}</strong>
|
||||
<small>已启动的监听服务</small>
|
||||
</article>
|
||||
<article>
|
||||
<article class="stat-connections">
|
||||
<span>活跃连接</span>
|
||||
<strong>{{ stats.connections.toString().padStart(2, "0") }}</strong>
|
||||
<small>当前客户端连接数</small>
|
||||
</article>
|
||||
<article>
|
||||
<article class="stat-readonly">
|
||||
<span>只读代理</span>
|
||||
<strong>{{ stats.readonly.toString().padStart(2, "0") }}</strong>
|
||||
<small>按需启用 · 默认读写</small>
|
||||
@@ -1144,8 +1144,8 @@ onUnmounted(() => {
|
||||
<div class="table-toolbar gateway-batch-toolbar">
|
||||
<el-checkbox :model-value="filtered.length > 0 && filtered.every(r=>selectedGateways.some(s=>s.config.id===r.config.id))" @update:model-value="selectedGateways = $event ? [...filtered] : []">选择筛选结果</el-checkbox>
|
||||
<span>已选 {{ selectedGateways.length }} 项</span>
|
||||
<el-button :disabled="busy || !selectedGateways.some(r=>!r.runtime.running && canStart(r))" @click="batchAction('start')">批量启动</el-button>
|
||||
<el-button :disabled="busy || !selectedGateways.some(r=>r.runtime.running)" @click="batchAction('stop')">批量停止</el-button>
|
||||
<el-button type="success" plain :disabled="busy || !selectedGateways.some(r=>!r.runtime.running && canStart(r))" @click="batchAction('start')">批量启动</el-button>
|
||||
<el-button type="warning" plain :disabled="busy || !selectedGateways.some(r=>r.runtime.running)" @click="batchAction('stop')">批量停止</el-button>
|
||||
</div>
|
||||
<el-table v-if="batchResults.length" :data="batchResults" aria-label="批量操作结果"><el-table-column prop="name" label="网关" /><el-table-column label="结果"><template #default="{row}"><el-tag :type="row.ok ? 'success' : 'danger'">{{ row.ok ? '成功' : '失败' }}</el-tag> {{ row.detail }}</template></el-table-column></el-table>
|
||||
<div class="table-toolbar gateway-filters">
|
||||
@@ -1265,7 +1265,7 @@ onUnmounted(() => {
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
:type="row.runtime.running ? 'warning' : 'success'"
|
||||
:disabled="busy || (!row.runtime.running && !canStart(row))"
|
||||
@click="action(row, row.runtime.running ? 'stop' : 'start')"
|
||||
>
|
||||
@@ -1346,7 +1346,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
<div class="mobile-card-actions">
|
||||
<el-button
|
||||
type="primary"
|
||||
:type="row.runtime.running ? 'warning' : 'success'"
|
||||
plain
|
||||
:disabled="busy || (!row.runtime.running && !canStart(row))"
|
||||
@click="action(row, row.runtime.running ? 'stop' : 'start')"
|
||||
|
||||
@@ -47,7 +47,7 @@ function moveTab(event: KeyboardEvent, index: number) {
|
||||
.connection-editor > .el-dialog__header .el-dialog__headerbtn { top: 10px; right: 10px; }
|
||||
.connection-editor__navigation { display: inline-flex; flex-wrap: wrap; gap: 2px; max-width: 100%; margin-bottom: 12px; padding: 3px; background: var(--el-fill-color-light); border-radius: var(--app-radius); }
|
||||
.connection-editor__navigation [role="tab"] { padding: 0 12px; min-height: 30px; border: 1px solid transparent; border-radius: var(--app-radius); font: inherit; font-size: 12px; color: var(--el-text-color-regular); background: transparent; cursor: pointer; }
|
||||
.connection-editor__navigation [aria-selected="true"] { color: var(--el-text-color-primary); background: var(--el-bg-color); border-color: var(--app-border); }
|
||||
.connection-editor__navigation [aria-selected="true"] { color: var(--el-color-primary); background: var(--el-color-primary-light-9); border-color: var(--el-color-primary-light-5); font-weight: 600; }
|
||||
.connection-editor__navigation [role="tab"]:focus-visible { outline: 2px solid var(--el-color-primary); outline-offset: 1px; }
|
||||
.connection-editor > .el-dialog__body { flex: 1; min-height: 0; padding: 18px 20px; overflow: auto; overscroll-behavior: contain; }
|
||||
.connection-editor .config-form > .el-tabs > .el-tabs__header { display: none; }
|
||||
|
||||
@@ -5,6 +5,10 @@ type MenuItem = { id: string; label: string; icon: string; count?: number; keywo
|
||||
const props = defineProps<{ items: MenuItem[]; active: string }>();
|
||||
const emit = defineEmits<{ navigate: [id: string] }>();
|
||||
const query = ref(''), searchOpen = ref(false);
|
||||
const navigationCategories: Record<string, string> = {
|
||||
targets: 'core', proxies: 'core', assets: 'configuration', configuration: 'configuration',
|
||||
events: 'monitoring', commands: 'monitoring', operations: 'monitoring', overview: 'monitoring',
|
||||
};
|
||||
const matches = computed(() => {
|
||||
const terms = query.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
return props.items.filter(item => terms.every(term => `${item.label} ${item.keywords || ''}`.toLowerCase().includes(term)));
|
||||
@@ -19,21 +23,27 @@ function firstMatch() { if (matches.value[0]) choose(matches.value[0].id); }
|
||||
<button class="menu-search-trigger" aria-label="搜索菜单" title="搜索菜单" @click="searchOpen=true"><AppIcon name="search" /></button>
|
||||
</div>
|
||||
<nav aria-label="工作空间菜单">
|
||||
<button v-for="item in matches" :key="item.id" :class="{active:active===item.id}" :title="item.label" :aria-label="item.label" :aria-current="active===item.id ? 'page' : undefined" @click="choose(item.id)">
|
||||
<AppIcon :name="item.icon" /><span class="nav-label">{{ item.label }}</span><i v-if="item.count !== undefined">{{ item.count }}</i>
|
||||
<button v-for="item in matches" :key="item.id" class="nav-item" :data-category="navigationCategories[item.id] || 'auxiliary'" :class="{active:active===item.id}" :title="item.label" :aria-label="item.label" :aria-current="active===item.id ? 'page' : undefined" @click="choose(item.id)">
|
||||
<AppIcon :name="item.icon" /><span class="nav-label">{{ item.label }}</span><span v-if="navigationCategories[item.id] === 'core'" class="nav-priority" aria-hidden="true">核心</span><i v-if="item.count !== undefined">{{ item.count }}</i>
|
||||
</button>
|
||||
<p v-if="!matches.length" class="menu-no-results" role="status">没有匹配的菜单</p>
|
||||
</nav>
|
||||
<el-dialog v-model="searchOpen" title="搜索菜单" width="min(480px, 95vw)" append-to-body @closed="query=''">
|
||||
<el-input v-model="query" aria-label="搜索菜单" placeholder="输入菜单名称,如资产、同步" clearable @keydown.enter.prevent="firstMatch" />
|
||||
<div class="menu-search-results">
|
||||
<button v-for="item in matches" :key="item.id" :class="{active:active===item.id}" @click="choose(item.id)"><AppIcon :name="item.icon" />{{ item.label }}</button>
|
||||
<button v-for="item in matches" :key="item.id" class="nav-item" :data-category="navigationCategories[item.id] || 'auxiliary'" :class="{active:active===item.id}" @click="choose(item.id)"><AppIcon :name="item.icon" />{{ item.label }}</button>
|
||||
<p v-if="!matches.length" role="status">没有匹配的菜单</p>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nav-item { --nav-category-color: var(--app-text-muted); }
|
||||
.nav-item[data-category="core"] { --nav-category-color: var(--app-primary); }
|
||||
.nav-item[data-category="configuration"] { --nav-category-color: var(--app-accent); }
|
||||
.nav-item[data-category="monitoring"] { --nav-category-color: var(--app-info); }
|
||||
.nav-item .app-icon { color: var(--nav-category-color); }
|
||||
.nav-priority { flex-shrink: 0; padding: 1px 5px; border-radius: var(--app-radius); color: var(--app-primary); background: color-mix(in srgb, var(--app-primary) 10%, var(--app-sidebar)); font-size: 10px; line-height: 16px; font-weight: 600; }
|
||||
.menu-search { flex-shrink: 0; min-width: 0; margin: 0 10px 10px; }
|
||||
.menu-search-input { --el-input-bg-color: var(--app-bg); --el-input-text-color: var(--app-text); --el-input-placeholder-color: var(--app-text-muted); --el-input-border-color: var(--app-border); }
|
||||
.menu-search-trigger { display: none; align-items: center; justify-content: center; width: 100%; min-height: 44px; border: 1px solid var(--app-border); border-radius: var(--app-radius); color: var(--app-text-muted); background: transparent; cursor: pointer; }
|
||||
@@ -41,5 +51,5 @@ function firstMatch() { if (matches.value[0]) choose(matches.value[0].id); }
|
||||
.menu-search-results { display: grid; gap: 6px; margin-top: 14px; }
|
||||
.menu-search-results button { display: flex; gap: 12px; align-items: center; padding: 12px; border: 1px solid var(--el-border-color); border-radius: var(--app-radius-panel); color: var(--el-text-color-primary); background: var(--el-bg-color); text-align: left; cursor: pointer; }
|
||||
.menu-search-results button:hover, .menu-search-results button.active { background: var(--el-color-primary-light-9); border-color: var(--el-color-primary); }
|
||||
@media (max-width: 720px) { .menu-search { margin: 0 6px 8px; } .menu-search-input { display: none; } .menu-search-trigger { display: flex; } }
|
||||
@media (max-width: 720px) { .menu-search { margin: 0 6px 8px; } .menu-search-input, .nav-priority { display: none; } .menu-search-trigger { display: flex; } }
|
||||
</style>
|
||||
|
||||
@@ -6,10 +6,10 @@ defineEmits<{navigate:[item:ResourceItem];create:[kind:'targets'|'proxies'];page
|
||||
</script>
|
||||
<template>
|
||||
<section class="workspace-home">
|
||||
<div class="home-actions"><el-button type="primary" @click="$emit('create','targets')">新建目标</el-button><el-button @click="$emit('create','proxies')">创建网关</el-button><el-button @click="$emit('page','configuration')">导入配置</el-button><el-button @click="$emit('page','operations')">监控运维</el-button></div>
|
||||
<div class="home-actions"><el-button type="primary" @click="$emit('create','targets')">新建目标</el-button><el-button type="primary" plain @click="$emit('create','proxies')">创建网关</el-button><el-button @click="$emit('page','configuration')">导入配置</el-button><el-button @click="$emit('page','operations')">监控运维</el-button></div>
|
||||
<div class="home-panels">
|
||||
<section class="table-card"><header><h3>最近访问</h3><el-button text @click="$emit('clear')">清空历史</el-button></header><p v-if="!recent.length" class="form-help">尚无访问记录。打开资源或使用搜索开始管理连接。</p><button v-for="item in recent" :key="item.key" class="home-resource" @click="$emit('navigate',item)"><ResourceLabel :name="item.name" :color="item.color" :remark="item.remark" :show-remark="true" /><small>{{ resourceKinds[item.kind] }} · {{ new Date(item.at).toLocaleString() }}</small></button></section>
|
||||
<section class="table-card"><header><h3>异常网关</h3><el-tag :type="failures.length ? 'danger' : 'success'">{{ failures.length }}</el-tag></header><p v-if="!failures.length" class="form-help">当前范围没有报告异常的网关。</p><button v-for="item in failures" :key="item.key" class="home-resource" @click="$emit('navigate',item)"><ResourceLabel :name="item.name" :color="item.color" /><small>{{ item.protocol }} · {{ item.status }}</small></button></section>
|
||||
<section class="table-card home-recent"><header><h3>最近访问</h3><el-button type="primary" text @click="$emit('clear')">清空历史</el-button></header><p v-if="!recent.length" class="form-help">尚无访问记录。打开资源或使用搜索开始管理连接。</p><button v-for="item in recent" :key="item.key" class="home-resource" @click="$emit('navigate',item)"><ResourceLabel :name="item.name" :color="item.color" :remark="item.remark" :show-remark="true" /><small>{{ resourceKinds[item.kind] }} · {{ new Date(item.at).toLocaleString() }}</small></button></section>
|
||||
<section class="table-card" :class="{'home-failures': failures.length > 0}"><header><h3>异常网关</h3><el-tag :type="failures.length ? 'danger' : 'success'">{{ failures.length }}</el-tag></header><p v-if="!failures.length" class="form-help">当前范围没有报告异常的网关。</p><button v-for="item in failures" :key="item.key" class="home-resource" @click="$emit('navigate',item)"><ResourceLabel :name="item.name" :color="item.color" /><small>{{ item.protocol }} · {{ item.status }}</small></button></section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -18,6 +18,9 @@ defineEmits<{navigate:[item:ResourceItem];create:[kind:'targets'|'proxies'];page
|
||||
.home-actions { display:flex; flex-wrap:wrap; gap:8px; margin-bottom:16px; }.home-actions .el-button { margin:0; }
|
||||
.home-panels { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; }.home-panels .table-card { padding:16px; }
|
||||
.home-panels header { display:flex; align-items:center; justify-content:space-between; gap:10px; }.home-panels h3 { margin:0; font-size:14px; }
|
||||
.home-recent { border-color:color-mix(in srgb, var(--app-primary) 30%, var(--app-border)); }.home-recent h3 { color:var(--app-primary); }
|
||||
.home-failures { border-color:var(--app-danger); background:color-mix(in srgb, var(--app-danger) 6%, var(--app-surface)); }.home-failures h3 { color:var(--app-danger); }
|
||||
.home-recent .home-resource:hover, .home-recent .home-resource:focus-visible { background:color-mix(in srgb, var(--app-primary) 6%, var(--app-surface)); }
|
||||
.home-resource { width:100%; display:flex; flex-wrap:wrap; justify-content:space-between; gap:6px; padding:12px 0; border:0; border-bottom:1px solid var(--app-border); background:transparent; color:var(--app-text); text-align:left; }.home-resource:hover { color:var(--el-color-primary); }.home-resource small { color:var(--app-text-muted); }
|
||||
@media(max-width:900px) { .home-panels { grid-template-columns:1fr; } }
|
||||
</style>
|
||||
|
||||
@@ -56,10 +56,13 @@
|
||||
.el-tabs--border-card { border-radius: var(--app-radius-panel); box-shadow: none; }
|
||||
.el-card { border-radius: var(--app-radius-panel); }
|
||||
.el-card.is-always-shadow, .el-card.is-hover-shadow:hover { box-shadow: none; }
|
||||
.el-tag { border-radius: var(--app-radius); font-weight: 400; }
|
||||
.el-tag { border-radius: var(--app-radius); font-weight: 600; }
|
||||
.el-empty { --el-empty-fill-color-0: var(--app-surface); }
|
||||
.el-empty__description p { font-size: 12px; }
|
||||
.el-alert { border-radius: var(--app-radius); }
|
||||
.el-alert--warning.is-light { border-inline-start: 3px solid var(--app-warning); }
|
||||
.el-alert--error.is-light { border-inline-start: 3px solid var(--app-danger); }
|
||||
.el-alert--success.is-light { border-inline-start: 3px solid var(--app-success); }
|
||||
.el-dialog {
|
||||
--el-dialog-padding-primary: 0px;
|
||||
border: 1px solid var(--app-border);
|
||||
@@ -84,7 +87,7 @@
|
||||
.el-message-box__header { padding-bottom: 12px; }
|
||||
.el-select__popper.el-popper, .el-dropdown__popper.el-popper { border-radius: var(--app-radius-panel); }
|
||||
.el-select-dropdown__item, .el-dropdown-menu__item { font-size: 12px; }
|
||||
.el-select-dropdown__item.is-selected { background: var(--app-subtle); color: var(--app-text); font-weight: 500; }
|
||||
.el-select-dropdown__item.is-selected { background: var(--el-color-primary-light-9); color: var(--app-primary); font-weight: 600; }
|
||||
.el-pagination { --el-pagination-button-bg-color: var(--app-surface); --el-pagination-hover-color: var(--app-text); }
|
||||
.el-pagination.is-background .el-pager li.is-active { color: var(--app-primary-foreground); }
|
||||
.el-descriptions__label.el-descriptions__cell.is-bordered-label { background: var(--app-sidebar); font-weight: 500; }
|
||||
|
||||
+25
-9
@@ -113,8 +113,8 @@ p {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sidebar nav button.active {
|
||||
background: var(--app-subtle);
|
||||
color: var(--app-text);
|
||||
background: var(--el-color-primary-light-8);
|
||||
color: var(--app-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.sidebar nav button.active::before {
|
||||
@@ -130,6 +130,9 @@ p {
|
||||
.sidebar nav button:hover {
|
||||
background: var(--app-hover);
|
||||
}
|
||||
.sidebar nav button.active:hover {
|
||||
background: var(--el-color-primary-light-7);
|
||||
}
|
||||
.sidebar nav button:focus-visible {
|
||||
outline: 2px solid var(--app-ring);
|
||||
outline-offset: -2px;
|
||||
@@ -263,19 +266,25 @@ p {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.stats article {
|
||||
border: 1px solid var(--app-border);
|
||||
background: var(--app-surface);
|
||||
--stat-color: var(--app-primary);
|
||||
border: 1px solid color-mix(in srgb, var(--stat-color) 22%, var(--app-border));
|
||||
border-top: 3px solid var(--stat-color);
|
||||
background: color-mix(in srgb, var(--stat-color) 6%, var(--app-surface));
|
||||
padding: 16px;
|
||||
border-radius: var(--app-radius-panel);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.stats .stat-running { --stat-color: var(--app-success); }
|
||||
.stats .stat-connections { --stat-color: var(--app-info); }
|
||||
.stats .stat-readonly { --stat-color: var(--app-accent); }
|
||||
.stats article > span {
|
||||
font-size: 12px;
|
||||
color: var(--app-text-muted);
|
||||
}
|
||||
.stats strong {
|
||||
color: var(--stat-color);
|
||||
font-size: 28px;
|
||||
font-weight: 550;
|
||||
letter-spacing: -1px;
|
||||
@@ -324,8 +333,8 @@ p {
|
||||
font-size: 12px;
|
||||
}
|
||||
.tabs button.active {
|
||||
background: var(--app-subtle);
|
||||
color: var(--app-text);
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--app-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.search {
|
||||
@@ -395,9 +404,16 @@ code {
|
||||
line-height: 20px;
|
||||
}
|
||||
.status {
|
||||
--status-color: var(--app-text-muted);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--status-color) 24%, var(--app-border));
|
||||
border-radius: var(--app-radius);
|
||||
background: color-mix(in srgb, var(--status-color) 8%, var(--app-surface));
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
color: var(--app-text-muted);
|
||||
color: var(--status-color);
|
||||
}
|
||||
.status i {
|
||||
display: inline-block;
|
||||
@@ -408,10 +424,10 @@ code {
|
||||
margin-right: 6px;
|
||||
}
|
||||
.status.running {
|
||||
color: var(--app-success);
|
||||
--status-color: var(--app-success);
|
||||
}
|
||||
.status.failed {
|
||||
color: var(--app-danger);
|
||||
--status-color: var(--app-danger);
|
||||
}
|
||||
.mode-tag {
|
||||
font-size: 10px;
|
||||
|
||||
+22
-18
@@ -1,24 +1,26 @@
|
||||
:root {
|
||||
--app-font-sans: "Geist Variable", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Segoe UI", system-ui, sans-serif;
|
||||
--app-font-mono: "Cascadia Code", "Cascadia Mono", "JetBrains Mono", Consolas, monospace;
|
||||
--app-text: #0a0a0a;
|
||||
--app-text-regular: #404040;
|
||||
--app-text-muted: #737373;
|
||||
--app-bg: #ffffff;
|
||||
--app-text: #17243b;
|
||||
--app-text-regular: #35445e;
|
||||
--app-text-muted: #52617a;
|
||||
--app-bg: #f4f7fc;
|
||||
--app-surface: #ffffff;
|
||||
--app-overlay: #ffffff;
|
||||
--app-subtle: #f5f5f5;
|
||||
--app-hover: #f0f0f0;
|
||||
--app-border: #e5e5e5;
|
||||
--app-input-border: #e5e5e5;
|
||||
--app-sidebar: #fafafa;
|
||||
--app-chrome: #f5f5f5;
|
||||
--app-primary: #171717;
|
||||
--app-primary-foreground: #fafafa;
|
||||
--app-ring: #a1a1a1;
|
||||
--app-subtle: #edf2fa;
|
||||
--app-hover: #e4ecf8;
|
||||
--app-border: #d9e2ef;
|
||||
--app-input-border: #c3cfe0;
|
||||
--app-sidebar: #eef3fb;
|
||||
--app-chrome: #edf2fa;
|
||||
--app-primary: #1d4ed8;
|
||||
--app-primary-foreground: #ffffff;
|
||||
--app-ring: #2563eb;
|
||||
--app-accent: #6d28d9;
|
||||
--app-info: #0e7490;
|
||||
--app-success: #16803d;
|
||||
--app-warning: #b66a05;
|
||||
--app-danger: #e7000b;
|
||||
--app-warning: #92400e;
|
||||
--app-danger: #b91c1c;
|
||||
--app-radius: 4px;
|
||||
--app-radius-panel: 6px;
|
||||
--app-sidebar-width: 280px;
|
||||
@@ -44,9 +46,11 @@
|
||||
--app-input-border: rgb(110 110 114 / 34%);
|
||||
--app-sidebar: #19191c;
|
||||
--app-chrome: #1b1b1e;
|
||||
--app-primary: #d0d0d6;
|
||||
--app-primary-foreground: #131416;
|
||||
--app-ring: #85868b;
|
||||
--app-primary: #60a5fa;
|
||||
--app-primary-foreground: #0b1730;
|
||||
--app-ring: #93c5fd;
|
||||
--app-accent: #c4b5fd;
|
||||
--app-info: #67e8f9;
|
||||
--app-success: #4ade80;
|
||||
--app-warning: #fbbf24;
|
||||
--app-danger: #f3625f;
|
||||
|
||||
@@ -2,9 +2,9 @@ import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
const pages = ['资产管理', '目标服务', '运行概览', '网关服务', '连接与审计', '命令审计', '拖拽连线', '监控运维', '配置管理', '多端同步', '密码解密', 'SQLite 文件'];
|
||||
const themes = [
|
||||
{ name: 'light', background: 'rgb(255, 255, 255)', sidebar: 'rgb(250, 250, 250)', primary: 'rgb(23, 23, 23)', foreground: 'rgb(250, 250, 250)' },
|
||||
{ name: 'dark', background: 'rgb(19, 20, 22)', sidebar: 'rgb(25, 25, 28)', primary: 'rgb(208, 208, 214)', foreground: 'rgb(19, 20, 22)' },
|
||||
{ name: 'ocean', background: 'rgb(255, 255, 255)', sidebar: 'rgb(250, 250, 250)', primary: 'rgb(37, 99, 235)', foreground: 'rgb(255, 255, 255)' },
|
||||
{ name: 'light', background: 'rgb(244, 247, 252)', sidebar: 'rgb(238, 243, 251)', primary: 'rgb(29, 78, 216)', foreground: 'rgb(255, 255, 255)' },
|
||||
{ name: 'dark', background: 'rgb(19, 20, 22)', sidebar: 'rgb(25, 25, 28)', primary: 'rgb(96, 165, 250)', foreground: 'rgb(11, 23, 48)' },
|
||||
{ name: 'ocean', background: 'rgb(244, 247, 252)', sidebar: 'rgb(238, 243, 251)', primary: 'rgb(37, 99, 235)', foreground: 'rgb(255, 255, 255)' },
|
||||
];
|
||||
|
||||
async function fitsViewport(page: Page) {
|
||||
@@ -16,6 +16,14 @@ test('DBX 视觉体系覆盖全部菜单、主题和手机导航', async ({ page
|
||||
const errors: string[] = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.goto('/');
|
||||
const brand = page.locator('.sidebar .brand-mark');
|
||||
await expect(brand).toHaveAttribute('src', '/serviceport.svg');
|
||||
await expect(brand).toBeVisible();
|
||||
await expect.poll(() => brand.evaluate(element => (element as HTMLImageElement).naturalWidth)).toBeGreaterThan(0);
|
||||
await expect(page.locator('link[rel="icon"]')).toHaveAttribute('href', '/serviceport.svg');
|
||||
const icon = await page.request.get('/serviceport.svg');
|
||||
expect(icon.ok()).toBeTruthy();
|
||||
expect(icon.headers()['content-type']).toContain('image/svg+xml');
|
||||
const navigation = page.getByRole('navigation', { name: '工作空间菜单' });
|
||||
await expect(navigation.getByRole('button')).toHaveCount(pages.length);
|
||||
for (const viewport of [{ width: 1440, height: 1000 }, { width: 390, height: 844 }]) {
|
||||
|
||||
Reference in New Issue
Block a user