style(console): 配色收敛为「简约白 / 高级灰」两套中性灰阶主题

原配色是深色 + 品牌青绿(#63e2b7)+ naive-ui 默认绿主色 —— 颜色种类多。
改为两套**只用中性灰阶**的主题,侧栏底部一行切换,选择存 localStorage:

  简约白  页底 #f4f4f5 / 侧栏 #fafafa / 卡片 #ffffff
  高级灰  页底 #1d1f22 / 侧栏 #181a1d / 卡片 #24262a

几处刻意的选择:

1. 白主题页底**不是纯白**。卡片本身是白的,两者同色就只能靠 1px 分线撑层次,
   整页发「糊」;压一档浅灰后层次为「页底 < 侧栏 < 卡片」,且仍是白为主。
2. 主色位(naive-ui 的 primary)改**中性灰** —— 主按钮、开关激活态、进度条、
   勾选框全吃这一个值。原来它们是绿色/青绿,正是「配色不简单」的主因。
3. 语义色(成功/警告/错误/信息)**降饱和而非去掉**。这是运维工具,
   「运行中 / 出错」必须一眼可辨,全部灰化会导致误读。故它们只出现在徽章与
   告警这类**小面积**上,底色观感仍是「白 + 灰」。
4. **色值只定义一份**:src/theme/index.ts 的 Palette 既生成 naive-ui 的
   themeOverrides,又由 App.vue 运行期注入成 --na-* CSS 变量。
   若「TS 调色板」与「CSS 写死色值」各存一份,换主题时会**一半新一半旧、
   不报错、类型也抓不到** —— 正是本轮要消灭的失效模式。
   为此清掉散在 8 个文件里的 17 处写死色值(日志三流着色、代码块底色、
   节点选中边框、历史悬停…),复扫后自定义样式里硬编码色为 0。
5. 主题**只存 localStorage**,不落 Rust 侧 settings:那套 Settings 是给
   「仅服务模式」(无窗口、常驻托盘)也生效的偏好用的;主题是纯图形偏好,
   没有窗口时毫无意义 —— 为一件纯前端的事去动 Rust 命令面与契约守卫不划算。
6. index.html 内联一小段脚本做**首帧防闪**(否则深色用户每次启动闪一帧白)。
   此处**刻意重复 2 个底色值**,已在 index.html 与 theme/index.ts 两处注明
   「改底色时同步」—— 因为在它执行之前没有任何模块可依赖;
   除此之外全应用再无第二份色值。

验证:vue-tsc 0 错 | 契约守卫 6/6 | 单测 280/280 | .app 重打通过(92.22 MiB)|
两套主题均以真实采集数据回填渲染核对 —— 截图脚本顺带读回 --na-bg 与 body 背景,
确认主题真的切过去了,而不是「看起来像」。
This commit is contained in:
jkooe committed 2026-10-11 14:29:50 +08:00
1 parent 591e44721a
commit 4e6eb32929
14 files changed
+719 -29

No files matched your search

+25
View File
@@ -10,6 +10,9 @@
> 主题:**控制台「去命令行化」** —— 双击应用即用,启停/重启/更新/日志/托盘常驻/开机自启全部在界面里完成。
> 目标原文:「不再打开命令行窗口,运行应用之后,有启动、停止、重启、检查更新等功能,
> 有日志功能,可以后台运行和开机自启等等功能」。
>
> 末段另含两轮纯界面调整:**一律不使用图标**(emoji 在部分渲染器里退化成豆腐块)、
> **配色收敛为「简约白 / 高级灰」两套中性灰阶主题**。
### 新增 —— 本机被控端托管(`apps/console/src-tauri/src/agent.rs`,约 560 行)
@@ -109,6 +112,28 @@
> 它们是**程序输出内容**,会原样出现在「服务日志」页里,但不是界面元素;
> ③ 控件的结构性部件(下拉箭头、折叠箭头、输入框清除钮、表格排序箭头)。
### 变更 —— 配色收敛为两套中性灰阶主题
- **两套可切换主题:`简约白`(浅)与 `高级灰`(深)**,侧栏底部一行切换,选择存 `localStorage`。
两套都**只用中性灰阶**:去掉原先的青色品牌字(`#63e2b7`)与 naive-ui 默认的绿色主色,
主按钮 / 开关激活态 / 进度条 / 勾选框统一改吃「主色位 = 中性灰」这一个值。
- 白主题的**页底刻意不是 `#ffffff`**(`#f4f4f5`):卡片本身是白的,两者同色就只能靠 1px 分线撑层次,
整页发「糊」;压一档浅灰后层次为「页底 < 侧栏 < 卡片」,且仍是白为主。
- **语义色不是去掉,而是降饱和**(灰绿 / 灰琥珀 / 灰红 / 灰蓝)。
这是运维工具,`运行中 / 出错` 必须一眼可辨,全部灰化会导致误读;故它们只出现在徽章与告警这类小面积上,
底色观感仍是「白 + 灰」。严重程度在关闭图标后本就更依赖 `type` 的底色/边框。
- **色值只有一份定义**(`src/theme/index.ts` 的 `Palette`):既生成 naive-ui 的 `themeOverrides`,
又由 `App.vue` 在运行期注入成 `--na-*` CSS 变量供自定义样式使用。
原因是若「TS 调色板」与「CSS 写死的色值」各存一份,换主题时会**一半新一半旧且不报错、类型也抓不到**
—— 这正是本轮要消灭的失效模式。
- **清掉 17 处写死的色值**(散在 8 个文件:日志三流着色、代码块/预览底色、节点选中边框、历史悬停等),
全部改为 `var(--na-*)`。复扫后自定义样式里的硬编码色为 **0**。
- **主题偏好只存 `localStorage`,不落 Rust 侧 settings**:那是给「仅服务模式」(无窗口、常驻托盘)
也要生效的偏好用的,而主题是纯图形偏好 —— 没有窗口时毫无意义。为一件纯前端的事去动 Rust 命令面与契约守卫不划算。
- **首帧防闪**:`index.html` 内联一段极小脚本,在 Vue 接管前先把上次主题的底色刷上,
否则「高级灰」用户每次启动都会看到一帧刺眼的白。此处**刻意重复了 2 个底色值**(真值在 `theme/index.ts`),
因为在此之前没有任何模块可依赖;已在两处都注明「改底色时同步」。
### 说明 —— 本轮**刻意不升版本号**
本轮新增的全部是**控制台**功能,**被控端能力集合未变**(未增删任何 `system.*` / `fs.*` / `app.*` 能力)。
+15
View File
@@ -4,6 +4,21 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>nodeagent 控制台</title>
<script>
// 首帧防闪:Vue 接管之前浏览器已经会画一次背景,若不先刷上——
// 「高级灰」用户每次启动都会看到一帧刺眼的白。
//
// 此处只重复**两个**底色值(真值在 src/theme/index.ts),因为在此之前
// 没有任何模块可依赖;其余所有颜色仍由 App.vue 运行期注入的 --na-* 负责。
// 改调色板底色时记得同步这两行。
try {
var isGray = localStorage.getItem("nodeagent.console.theme") === "gray";
document.documentElement.style.background = isGray ? "#1d1f22" : "#f4f4f5";
document.documentElement.style.colorScheme = isGray ? "dark" : "light";
} catch (e) {
/* localStorage 不可用(隐私模式)时走默认底色即可 */
}
</script>
</head>
<body>
+32 -9
View File
@@ -1,17 +1,40 @@
<script setup lang="ts">
import {
NConfigProvider,
NMessageProvider,
NDialogProvider,
darkTheme,
zhCN,
dateZhCN,
} from "naive-ui";
import { watchEffect } from "vue";
import { NConfigProvider, NMessageProvider, NDialogProvider, zhCN, dateZhCN } from "naive-ui";
import AppShell from "./components/AppShell.vue";
import { cssVars } from "./theme";
import { useThemeStore } from "./stores/theme";
const theme = useThemeStore();
/**
* 把调色板写成 `--na-*` CSS 变量挂到根元素。
*
* 色值只在 `theme/index.ts` 定义一份,此处在运行期注入给自定义样式用 ——
* 免得「TS 调色板」与「CSS 硬编码」各写一份后静默漂移(换主题时一半新一半旧,
* 不报错、类型也抓不到)。
*
* 同时设 `color-scheme`:不设的话滚动条、`<input>` 等原生控件会一直是浅色,
* 在「高级灰」下白得刺眼。
*/
watchEffect(() => {
const { palette, name } = theme.preset;
const root = document.documentElement;
for (const [k, v] of Object.entries(cssVars(palette))) {
root.style.setProperty(k, v);
}
root.style.colorScheme = palette.scheme;
root.dataset.theme = name;
});
</script>
<template>
<n-config-provider :theme="darkTheme" :locale="zhCN" :date-locale="dateZhCN">
<n-config-provider
:theme="theme.preset.base"
:theme-overrides="theme.preset.overrides"
:locale="zhCN"
:date-locale="dateZhCN"
>
<n-message-provider>
<n-dialog-provider>
<app-shell />
+52 -6
View File
@@ -12,16 +12,25 @@ import {
NSpace,
NText,
NTooltip,
NRadioGroup,
NRadioButton,
type MenuOption,
} from "naive-ui";
import { navRoutes, type NavMeta } from "../router";
import { useConnStore } from "../stores/conn";
import { useAgentStore } from "../stores/agent";
import { useThemeStore } from "../stores/theme";
import { THEME_LIST } from "../theme";
const route = useRoute();
const router = useRouter();
const conn = useConnStore();
const agent = useAgentStore();
const theme = useThemeStore();
const themeName = computed({
get: () => theme.name,
set: (v) => theme.set(v),
});
onMounted(() => {
conn.bind();
@@ -68,6 +77,15 @@ async function quickToggle() {
</div>
<n-menu :options="menuOptions" :value="activeKey" @update:value="onMenu" />
<div class="sider-foot">
<div class="foot-label">主题</div>
<n-radio-group v-model:value="themeName" size="small" class="theme-switch">
<n-radio-button v-for="t in THEME_LIST" :key="t.name" :value="t.name">
{{ t.label }}
</n-radio-button>
</n-radio-group>
<div class="foot-sep"></div>
<div class="foot-row">
<n-text depth="3" style="font-size: 12px">本机被控端</n-text>
<n-tag :type="agent.meta.type" size="tiny" round>{{ agent.meta.label }}</n-tag>
@@ -112,8 +130,12 @@ async function quickToggle() {
</template>
<style>
:root {
color-scheme: dark;
/* 配色只走 `--na-*` 变量(值在 src/theme/index.ts 单点定义,App.vue 运行期注入),
此处不留任何写死的色值 —— 否则换主题时会一半新一半旧,且不报错。
`color-scheme` 也由 App.vue 按主题设,这里不重复声明。 */
html,
body {
background: var(--na-bg);
}
body {
margin: 0;
@@ -124,12 +146,12 @@ body {
.brand-title {
font-size: 18px;
font-weight: 600;
color: #63e2b7;
color: var(--na-brand);
letter-spacing: 0.5px;
}
.brand-sub {
font-size: 12px;
opacity: 0.6;
color: var(--na-text-3);
margin-top: 2px;
}
.topbar {
@@ -139,7 +161,7 @@ body {
justify-content: space-between;
padding: 0 16px;
}
/* 侧栏做成纵向弹性布局,才能把「本机服务」快捷块压到底部 */
/* 侧栏做成纵向弹性布局,才能把底部快捷块压到底 */
:deep(.n-layout-sider-scroll-container) {
display: flex;
flex-direction: column;
@@ -147,7 +169,31 @@ body {
.sider-foot {
margin-top: auto;
padding: 12px 14px 16px;
border-top: 1px solid rgba(255, 255, 255, 0.09);
border-top: 1px solid var(--na-border);
}
.foot-label {
font-size: 11px;
color: var(--na-text-3);
margin-bottom: 6px;
}
.foot-sep {
height: 1px;
background: var(--na-border);
margin: 12px 0;
}
/* 两个主题按钮各占一半宽(侧栏只有 200px,默认内边距会挤出去) */
.theme-switch {
display: flex;
width: 100%;
}
.theme-switch :deep(.n-radio-button) {
flex: 1;
}
.theme-switch :deep(.n-radio-button__label) {
display: flex;
justify-content: center;
font-size: 12px;
padding: 0 2px;
}
.foot-row {
display: flex;
+53
View File
@@ -0,0 +1,53 @@
import { defineStore } from "pinia";
import { computed, ref, watch } from "vue";
import {
THEMES,
THEME_STORAGE_KEY,
normalizeThemeName,
type ThemeName,
} from "../theme";
/**
* 主题选择。
*
* 只存 **localStorage**,不落 Rust 侧 settings:那是「仅服务模式」(无窗口、
* 常驻托盘)也要生效的偏好,而主题是纯图形偏好 —— 没有窗口时毫无意义。
* 放 localStorage 可避免为一件纯前端的事去动 Rust 命令面与契约守卫。
*/
export const useThemeStore = defineStore("theme", () => {
const name = ref<ThemeName>(read());
const preset = computed(() => THEMES[name.value]);
const isWhite = computed(() => name.value === "white");
function read(): ThemeName {
try {
return normalizeThemeName(localStorage.getItem(THEME_STORAGE_KEY));
} catch {
// 隐私模式等场景下 localStorage 可能不可用 —— 不该因此启动失败
return "white";
}
}
function set(next: ThemeName) {
name.value = normalizeThemeName(next);
}
function toggle() {
set(name.value === "white" ? "gray" : "white");
}
watch(
name,
(v) => {
try {
localStorage.setItem(THEME_STORAGE_KEY, v);
} catch {
/* 忽略:存不下只影响下次启动的默认值 */
}
},
{ immediate: true },
);
return { name, preset, isWhite, set, toggle };
});
+478
View File
@@ -0,0 +1,478 @@
import {
darkTheme,
lightTheme,
type GlobalTheme,
type GlobalThemeOverrides,
} from "naive-ui";
/**
* 控制台只有两套主题,且都**只用中性灰阶** —— 不含彩色品牌色。
*
* 「简约白」= 白底 + 浅灰分线,靠留白与层次而不是颜色区分区域。
* 「高级灰」= 低饱和冷灰底 + 浅灰字,同样只用灰阶。
*
* 之所以真的把两套都做出来、而不是只留一套浅色:深色在长时间盯日志/终端时
* 更省眼(运维场景常态),浅色在白天与投屏时更清楚。两套共用同一份灰阶逻辑
* (只是明暗反转),故维护成本不翻倍。
*/
export type ThemeName = "white" | "gray";
export const THEME_STORAGE_KEY = "nodeagent.console.theme";
/**
* 调色板。**这是全应用唯一一份色值定义**:
* 既喂给 naive-ui 的 themeOverrides,也由 App.vue 注入成 `--na-*` CSS 变量
* 供自定义样式使用。两处各写一份必然漂移(本项目吃过这类亏),故只此一处。
*/
export interface Palette {
/** 页面底色 */
bg: string;
/** 侧栏底色(与 bg 拉开一档层次,而不是靠边框) */
sider: string;
/** 卡片 / 浮层底色 */
card: string;
/** 内嵌区:日志框、代码块、表头 */
sunken: string;
border: string;
borderStrong: string;
text1: string;
text2: string;
text3: string;
hover: string;
/** 主色位(naive-ui 的 primary)—— 刻意用中性色,不用彩色 */
accent: string;
accentHover: string;
accentPressed: string;
/** 主色底之上的文字色 */
accentText: string;
/** 品牌字(侧栏站点标题),不再用青绿 */
brand: string;
/** 语义色:统一降饱和,保证「白 + 灰」的底色观感 */
ok: string;
warn: string;
err: string;
info: string;
/** 语义色的浅底(用于 Tag / 告警块) */
okSoft: string;
warnSoft: string;
errSoft: string;
infoSoft: string;
tooltipBg: string;
tooltipText: string;
scrollbar: string;
scrollbarHover: string;
shadow: string;
/** 原生控件(滚动条、表单控件)的明暗 */
scheme: "light" | "dark";
}
/** 简约白:白为主,灰为辅。层次靠灰阶(页底 < 侧栏 < 卡片),不靠颜色。 */
const WHITE: Palette = {
// 页底刻意不是 #ffffff:卡片也是白的,两者同色就只能靠 1px 分线撑层次,
// 整页会发"糊"。压一档浅灰后卡片自然浮起来,且仍是「白」为主。
bg: "#f4f4f5",
sider: "#fafafa",
card: "#ffffff",
sunken: "#f5f5f6",
border: "#e8e8ea",
borderStrong: "#d6d6da",
text1: "#1a1a1c",
text2: "#55555c",
text3: "#8e8e97",
hover: "#eeeeef",
accent: "#1f1f23",
accentHover: "#33333a",
accentPressed: "#0d0d10",
accentText: "#ffffff",
brand: "#1a1a1c",
ok: "#4f8768",
warn: "#9c7c39",
err: "#b4635c",
info: "#5b789a",
okSoft: "#eef4f0",
warnSoft: "#f7f3e9",
errSoft: "#f8efee",
infoSoft: "#eef2f7",
tooltipBg: "#2b2b30",
tooltipText: "#f2f2f3",
scrollbar: "#d6d6da",
scrollbarHover: "#bcbcc2",
shadow: "0 1px 2px rgba(16, 16, 20, 0.05)",
scheme: "light",
};
/** 高级灰:雾霾冷灰调。低饱和、低对比,但保证正文可读。 */
const GRAY: Palette = {
bg: "#1d1f22",
sider: "#181a1d",
card: "#24262a",
sunken: "#1a1c1f",
border: "#32353a",
borderStrong: "#41454c",
text1: "#dcdee2",
text2: "#a2a6ae",
text3: "#71757c",
hover: "#2b2e33",
accent: "#dcdee2",
accentHover: "#eef0f2",
accentPressed: "#c6c9ce",
accentText: "#1d1f22",
brand: "#e6e8eb",
ok: "#83a891",
warn: "#b99a5c",
err: "#cc8b82",
info: "#8098b5",
okSoft: "#262f2a",
warnSoft: "#2f2c22",
errSoft: "#312625",
infoSoft: "#252b33",
tooltipBg: "#3a3e44",
tooltipText: "#e8eaed",
scrollbar: "#3a3e44",
scrollbarHover: "#4a4f56",
shadow: "0 1px 2px rgba(0, 0, 0, 0.35)",
scheme: "dark",
};
/**
* 调色板 → `--na-*` CSS 变量。
*
* 名字写死而不是自动 kebab 化:自动转换一旦改字段名,CSS 侧会**静默**失配
* (变量没人定义 → 颜色落回继承值,不报错、类型也抓不到)。写死则改字段时
* 至少能在这里一眼看到。
*/
export function cssVars(p: Palette): Record<string, string> {
return {
"--na-bg": p.bg,
"--na-sider": p.sider,
"--na-card": p.card,
"--na-sunken": p.sunken,
"--na-border": p.border,
"--na-border-strong": p.borderStrong,
"--na-text-1": p.text1,
"--na-text-2": p.text2,
"--na-text-3": p.text3,
"--na-hover": p.hover,
"--na-accent": p.accent,
"--na-accent-text": p.accentText,
"--na-brand": p.brand,
"--na-ok": p.ok,
"--na-warn": p.warn,
"--na-err": p.err,
"--na-info": p.info,
"--na-tooltip-bg": p.tooltipBg,
"--na-tooltip-text": p.tooltipText,
"--na-scrollbar": p.scrollbar,
"--na-scrollbar-hover": p.scrollbarHover,
"--na-shadow": p.shadow,
};
}
function overridesOf(p: Palette): GlobalThemeOverrides {
return {
common: {
// —— 主色:中性灰。naive-ui 默认主色是绿色,留着它整套配色立刻"不简单"。
// 主按钮、开关激活态、进度条、勾选框都吃这一个值。
primaryColor: p.accent,
primaryColorHover: p.accentHover,
primaryColorPressed: p.accentPressed,
primaryColorSuppl: p.accentHover,
// —— 语义色降饱和(仍保留:运维工具里"运行中/出错"必须一眼可辨,
// 全部灰化会导致误读;故只在徽章与告警这类小面积上出现)
successColor: p.ok,
successColorHover: p.ok,
successColorPressed: p.ok,
successColorSuppl: p.ok,
warningColor: p.warn,
warningColorHover: p.warn,
warningColorPressed: p.warn,
warningColorSuppl: p.warn,
errorColor: p.err,
errorColorHover: p.err,
errorColorPressed: p.err,
errorColorSuppl: p.err,
infoColor: p.info,
infoColorHover: p.info,
infoColorPressed: p.info,
infoColorSuppl: p.info,
textColorBase: p.text1,
textColor1: p.text1,
textColor2: p.text2,
textColor3: p.text3,
placeholderColor: p.text3,
placeholderColorDisabled: p.text3,
iconColor: p.text3,
iconColorHover: p.text2,
dividerColor: p.border,
borderColor: p.border,
closeIconColor: p.text3,
bodyColor: p.bg,
cardColor: p.card,
modalColor: p.card,
popoverColor: p.card,
tableColor: p.card,
inputColor: p.sunken,
inputColorDisabled: p.sunken,
tagColor: p.sunken,
actionColor: p.sunken,
avatarColor: p.sunken,
codeColor: p.sunken,
tabColor: p.sunken,
tableHeaderColor: p.sunken,
hoverColor: p.hover,
tableColorHover: p.hover,
tableColorStriped: p.sunken,
pressedColor: p.hover,
progressRailColor: p.sunken,
railColor: p.sunken,
scrollbarColor: p.scrollbar,
scrollbarColorHover: p.scrollbarHover,
borderRadius: "6px",
borderRadiusSmall: "4px",
},
Layout: {
color: p.bg,
siderColor: p.sider,
headerColor: p.bg,
siderBorderColor: p.border,
headerBorderColor: p.border,
textColor: p.text1,
},
Menu: {
color: p.sider,
itemTextColor: p.text2,
itemTextColorHover: p.text1,
itemTextColorActive: p.text1,
itemTextColorActiveHover: p.text1,
itemTextColorChildActive: p.text1,
itemColorHover: p.hover,
itemColorActive: p.hover,
itemColorActiveHover: p.hover,
itemColorActiveCollapsed: p.hover,
itemIconColor: p.text3,
itemIconColorHover: p.text2,
itemIconColorActive: p.text1,
groupTextColor: p.text3,
arrowColor: p.text3,
borderColorHorizontal: p.border,
borderRadius: "6px",
},
Card: {
color: p.card,
colorModal: p.card,
colorPopover: p.card,
colorEmbedded: p.sunken,
textColor: p.text1,
titleTextColor: p.text1,
borderColor: p.border,
actionColor: p.sunken,
titleFontWeight: "600",
boxShadow: p.shadow,
},
DataTable: {
thColor: p.sunken,
thColorHover: p.hover,
tdColor: p.card,
tdColorHover: p.hover,
tdColorStriped: p.sunken,
borderColor: p.border,
thTextColor: p.text2,
tdTextColor: p.text2,
thFontWeight: "600",
loadingColor: p.accent,
},
Input: {
color: p.card,
colorFocus: p.card,
colorDisabled: p.sunken,
textColor: p.text1,
textColorDisabled: p.text3,
placeholderColor: p.text3,
border: `1px solid ${p.border}`,
borderHover: `1px solid ${p.borderStrong}`,
borderFocus: `1px solid ${p.accent}`,
borderDisabled: `1px solid ${p.border}`,
boxShadowFocus: `0 0 0 2px ${p.hover}`,
caretColor: p.accent,
iconColor: p.text3,
clearColor: p.text3,
clearColorHover: p.text2,
},
Alert: {
color: p.sunken,
border: `1px solid ${p.border}`,
titleTextColor: p.text1,
contentTextColor: p.text2,
iconColor: p.text3,
borderInfo: `1px solid ${p.info}`,
colorInfo: p.infoSoft,
titleTextColorInfo: p.text1,
contentTextColorInfo: p.text2,
iconColorInfo: p.info,
borderSuccess: `1px solid ${p.ok}`,
colorSuccess: p.okSoft,
titleTextColorSuccess: p.text1,
contentTextColorSuccess: p.text2,
iconColorSuccess: p.ok,
borderWarning: `1px solid ${p.warn}`,
colorWarning: p.warnSoft,
titleTextColorWarning: p.text1,
contentTextColorWarning: p.text2,
iconColorWarning: p.warn,
borderError: `1px solid ${p.err}`,
colorError: p.errSoft,
titleTextColorError: p.text1,
contentTextColorError: p.text2,
iconColorError: p.err,
},
Tag: {
color: p.sunken,
textColor: p.text2,
border: "1px solid transparent",
colorBordered: p.sunken,
borderRadius: "5px",
colorPrimary: p.accent,
textColorPrimary: p.accentText,
borderPrimary: "1px solid transparent",
colorInfo: p.infoSoft,
textColorInfo: p.info,
borderInfo: `1px solid ${p.info}`,
colorSuccess: p.okSoft,
textColorSuccess: p.ok,
borderSuccess: `1px solid ${p.ok}`,
colorWarning: p.warnSoft,
textColorWarning: p.warn,
borderWarning: `1px solid ${p.warn}`,
colorError: p.errSoft,
textColorError: p.err,
borderError: `1px solid ${p.err}`,
},
Divider: {
color: p.border,
textColor: p.text3,
},
Progress: {
railColor: p.sunken,
fillColor: p.accent,
fillColorSuccess: p.ok,
fillColorWarning: p.warn,
fillColorError: p.err,
fillColorInfo: p.info,
textColorLineInner: p.accentText,
textColorLineOuter: p.text2,
},
Switch: {
railColor: p.borderStrong,
railColorActive: p.accent,
buttonColor: p.card,
iconColor: p.text3,
loadingColor: p.text3,
},
Statistic: {
labelTextColor: p.text3,
valueTextColor: p.text1,
valuePrefixTextColor: p.text2,
valueSuffixTextColor: p.text2,
},
Empty: {
textColor: p.text3,
iconColor: p.text3,
extraTextColor: p.text3,
},
Spin: {
color: p.accent,
textColor: p.text2,
},
Popover: {
color: p.card,
textColor: p.text1,
dividerColor: p.border,
boxShadow: "0 4px 16px rgba(0, 0, 0, 0.12)",
},
Tooltip: {
color: p.tooltipBg,
textColor: p.tooltipText,
},
Modal: {
color: p.card,
textColor: p.text1,
},
List: {
color: p.card,
textColor: p.text1,
borderColor: p.border,
},
Collapse: {
titleTextColor: p.text1,
textColor: p.text2,
dividerColor: p.border,
},
Checkbox: {
labelTextColor: p.text1,
color: p.card,
border: `1px solid ${p.borderStrong}`,
borderChecked: `1px solid ${p.accent}`,
colorChecked: p.accent,
checkMarkColor: p.accentText,
},
};
}
export interface ThemePreset {
name: ThemeName;
label: string;
desc: string;
base: GlobalTheme;
palette: Palette;
overrides: GlobalThemeOverrides;
}
export const THEMES: Record<ThemeName, ThemePreset> = {
white: {
name: "white",
label: "简约白",
desc: "白底浅灰分线",
base: lightTheme,
palette: WHITE,
overrides: overridesOf(WHITE),
},
gray: {
name: "gray",
label: "高级灰",
desc: "雾霾冷灰低对比",
base: darkTheme,
palette: GRAY,
overrides: overridesOf(GRAY),
},
};
export const THEME_LIST: ThemePreset[] = [THEMES.white, THEMES.gray];
/** 越界 / 脏数据一律落回默认主题,避免启动即白屏。 */
export function normalizeThemeName(v: unknown): ThemeName {
return v === "gray" ? "gray" : "white";
}
+1 -1
View File
@@ -228,7 +228,7 @@ onMounted(() => {
margin: 12px 0 0;
padding: 12px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
background: var(--na-sunken);
font-family: ui-monospace, Menlo, monospace;
font-size: 12px;
line-height: 1.6;
+2 -2
View File
@@ -415,9 +415,9 @@ function openRelease() {
flex: 0 0 auto;
}
.log-line.stderr .txt {
color: #e88080;
color: var(--na-err);
}
.log-line.system .txt {
color: #63e2b7;
color: var(--na-info);
}
</style>
+3 -3
View File
@@ -137,7 +137,7 @@ function streamType(s: string) {
height: calc(100vh - 300px);
min-height: 320px;
overflow: auto;
background: rgba(0, 0, 0, 0.18);
background: var(--na-sunken);
border-radius: 6px;
padding: 10px 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
@@ -160,10 +160,10 @@ function streamType(s: string) {
margin-top: 1px;
}
.txt.stderr {
color: #e88080;
color: var(--na-err);
}
.txt.system {
color: #63e2b7;
color: var(--na-info);
}
.empty {
opacity: 0.5;
+3 -3
View File
@@ -195,11 +195,11 @@ const capGroups = computed(() => {
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border: 1px solid rgba(255, 255, 255, 0.09);
border: 1px solid var(--na-border);
border-radius: 8px;
}
.node.active {
border-color: #63e2b7;
border-color: var(--na-accent);
}
.node-name {
font-size: 14px;
@@ -219,7 +219,7 @@ const capGroups = computed(() => {
font-size: 12px;
padding: 2px 8px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.06);
background: var(--na-sunken);
}
.logs {
font-family: ui-monospace, Menlo, monospace;
+3 -3
View File
@@ -102,7 +102,7 @@ onUnmounted(stop);
type="line"
:percentage="Math.round(status?.cpu_pct ?? 0)"
:height="10"
:color="(status?.cpu_pct ?? 0) > 80 ? '#e88080' : '#63e2b7'"
:color="(status?.cpu_pct ?? 0) > 80 ? 'var(--na-err)' : 'var(--na-ok)'"
/>
</div>
<div class="gauge">
@@ -113,7 +113,7 @@ onUnmounted(stop);
type="line"
:percentage="Math.round(status?.memory_pct ?? 0)"
:height="10"
:color="(status?.memory_pct ?? 0) > 85 ? '#e88080' : '#63e2b7'"
:color="(status?.memory_pct ?? 0) > 85 ? 'var(--na-err)' : 'var(--na-ok)'"
/>
</div>
<n-statistic
@@ -138,7 +138,7 @@ onUnmounted(stop);
type="line"
:percentage="Math.round(d.used_pct ?? 0)"
:height="8"
:color="(d.used_pct ?? 0) > 90 ? '#e88080' : '#63e2b7'"
:color="(d.used_pct ?? 0) > 90 ? 'var(--na-err)' : 'var(--na-ok)'"
/>
</div>
</div>
+1 -1
View File
@@ -175,7 +175,7 @@ onMounted(() => {
margin: 12px 0 0;
padding: 10px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
background: var(--na-sunken);
font-size: 12px;
max-height: 200px;
overflow: auto;
+1 -1
View File
@@ -161,6 +161,6 @@ function reuse(c: string) {
opacity: 0.85;
}
.hist:hover code {
color: #63e2b7;
color: var(--na-accent);
}
</style>
+50
View File
@@ -572,6 +572,56 @@ node 去跑打包内的 sidecar,并走一次真实 JSON-RPC 握手 —— 源
> 该测试**已注入故障实测会红**(改 `agent_start` → `agent_startX`、`na-agent-state` → `na-agent-statee`
> 当场 2 条失败),非空转守卫。
## 界面观感约定(2026-10-11)
两轮纯界面调整,方向都是「少即是多」。理由记下来,免得日后被当成"没做完"再补回去。
### 一、一律不使用图标
触发原因是一次真实的渲染事故:侧栏的 emoji 图标在非系统字体栈的渲染器里退化成**豆腐块**。
试过三法,分别无效或更糟 —— 给图标指定 `Apple Color Emoji`、全局字体栈加 `!important`、
乃至 `font-variant-emoji: emoji`(最后一招把 ASCII 数字也拆成了 emoji 呈现)。
「哪套字体栈一定带 emoji」跨平台并不成立 —— **与其赌字形,不如不用**。
去掉的范围比"看得见的图标"宽得多(下面这三类都算):
| 类别 | 处数 | 关法 |
|---|---|---|
| 侧栏 emoji | 9 | 路由表 `meta.icon` 字段**整体删除**,`NavMeta` 接口同步收窄 |
| `n-empty` 空态**插画** | **16** | `:show-icon="false"` —— 插画比图标更「图形」,散在 9 个页面,**最易漏** |
| `n-alert` 类型图标 | 7 | naive-ui 的 `showIcon` **默认就是 `true`**,不显式关它就一直都在 |
| `n-popconfirm` 询问图标 | 3 | 同上 |
| 文案里的符号 | 1 | 终端页「⚠ 高权限操作,请谨慎」保留纯文字 |
顺带清掉 `public/{vite,tauri}.svg` 与 `src/assets/vue.svg`:`public/` 下的文件会被**整体复制进产物**,
此前等于把 Vite / Tauri 的 logo 一并发了出去(且 `index.html` 的 favicon 正指向它)。
`index.html` 的 `<title>` 还是模板默认的「Tauri + Vue + Typescript App」,也一并改齐
(原生窗口标题取自 `tauri.conf.json`,故此前只是 dev 态标签页不一致)。
**刻意未动**:① 托盘图标 —— 菜单栏没图标就点不到,关窗后应用无法寻回,属**功能必需**;
② 被控端 CLI 自己打印的文本标记(`⚠️`/`ℹ️`/`✅`)—— 是**程序输出内容**,会原样出现在「服务日志」页里,
但不是界面元素;③ 控件的结构性部件(下拉/折叠箭头、输入框清除钮、表格排序箭头)。
### 二、配色收敛为两套中性灰阶主题
`简约白`(浅)/`高级灰`(深),侧栏底部一行切换,选择存 `localStorage`。
| 决定 | 理由 |
|---|---|
| **主色位改中性灰** | naive-ui 默认主色是**绿色**、原设计的品牌字是**青绿 `#63e2b7`** —— 都是"颜色",与「简单些」相悖。主按钮/开关激活/进度条/勾选框统一吃这一个值 |
| 白主题**页底不是纯白**(`#f4f4f5`) | 卡片本身是白的,两者同色就只能靠 1px 分线撑层次,整页发「糊」。压一档浅灰后层次为「页底 < 侧栏 < 卡片」,且仍是白为主 |
| 语义色**降饱和**而非去掉 | 这是运维工具,「运行中 / 出错」必须一眼可辨,全部灰化会导致误读。故它们只出现在徽章与告警这类**小面积**上,底色观感仍是「白 + 灰」 |
| **色值只定义一份** | `src/theme/index.ts` 的 `Palette` 同时喂 naive-ui 的 `themeOverrides` 与 `--na-*` CSS 变量(`App.vue` 运行期注入)。两处各写一份,换主题时会**一半新一半旧且不报错、类型也抓不到** |
| 主题**只存 `localStorage`**,不落 Rust settings | Rust 那套 `Settings` 是给「仅服务模式」(无窗口、常驻托盘)也要生效的偏好用的;主题是纯图形偏好,**没有窗口时毫无意义**。为一件纯前端的事去动 Rust 命令面与契约守卫不划算 |
清掉散在 **8 个文件**里的 **17 处写死色值**(日志三流着色、代码块/预览底色、节点选中边框、历史悬停…),
全部改为 `var(--na-*)`。复扫后自定义样式里的硬编码色为 **0**。
**首帧防闪**:`index.html` 内联一小段脚本,在 Vue 接管前先把上次主题的底色刷上
(否则「高级灰」用户每次启动都会看到一帧刺眼的白)。
此处**刻意重复了 2 个底色值**,已在 `index.html` 与 `theme/index.ts` 两处注明「改底色时同步」——
因为在它执行之前没有任何模块可依赖。除此之外全应用再无第二份色值。
### 本轮验证记录
| 条目 | 结果 |