Files
nodeagent/apps/console/src/components/AppShell.vue
T
jkooe 4e6eb32929 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 背景,
确认主题真的切过去了,而不是「看起来像」。
2026-10-11 14:29:50 +08:00

205 lines
5.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted } from "vue";
import { RouterView, useRoute, useRouter } from "vue-router";
import {
NLayout,
NLayoutSider,
NLayoutHeader,
NLayoutContent,
NMenu,
NTag,
NButton,
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();
agent.bind();
});
// 纯文字导航:应用内不使用图标(emoji 图标在部分渲染器里会退化成豆腐块)。
const menuOptions: MenuOption[] = navRoutes.map((r) => ({
key: r.name as string,
label: (r.meta as NavMeta).title,
}));
const activeKey = computed(() => (route.name as string) ?? "local");
function onMenu(key: string) {
router.push({ name: key });
}
const stateType = computed(() => {
if (conn.state === "connected") return "success" as const;
if (conn.state === "reconnecting") return "warning" as const;
return "error" as const;
});
const stateLabel = computed(() => {
if (conn.state === "connected") return "已连接";
if (conn.state === "reconnecting") return "重连中";
return "未连接";
});
/** 从侧栏快速启停本机服务(不必切页面)。 */
async function quickToggle() {
if (agent.running) await agent.stop();
else await agent.start();
}
</script>
<template>
<n-layout has-sider style="height: 100vh">
<n-layout-sider bordered :width="200" :native-scrollbar="false">
<div class="brand">
<div class="brand-title">nodeagent</div>
<div class="brand-sub">桌面控制台</div>
</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>
</div>
<n-button
size="tiny"
block
:loading="agent.busy"
:disabled="agent.busy || agent.externalInstance"
@click="quickToggle"
>
{{ agent.running ? "停止服务" : "启动服务" }}
</n-button>
</div>
</n-layout-sider>
<n-layout>
<n-layout-header bordered class="topbar">
<n-space align="center">
<n-tag :type="stateType" size="small" round>{{ stateLabel }}</n-tag>
<n-text depth="3" style="font-size: 12px">{{ conn.url }}</n-text>
<n-text v-if="conn.info" depth="3" style="font-size: 12px">
· agent {{ conn.info.agent_version ?? "?" }} · {{ conn.info.capabilities.length }} 项能力
</n-text>
</n-space>
<n-space align="center">
<n-tooltip v-if="agent.sysInfo?.agent_only" trigger="hover">
<template #trigger>
<n-tag size="small" type="info" round>仅服务模式</n-tag>
</template>
本控制台以「仅服务模式」启动(无窗口,常驻托盘)—— 通常来自开机自启
</n-tooltip>
<n-button size="small" :disabled="!conn.info" @click="conn.disconnect()">断开</n-button>
</n-space>
</n-layout-header>
<n-layout-content :native-scrollbar="false" content-style="padding: 20px;">
<router-view />
</n-layout-content>
</n-layout>
</n-layout>
</template>
<style>
/* 配色只走 `--na-*` 变量(值在 src/theme/index.ts 单点定义,App.vue 运行期注入),
此处不留任何写死的色值 —— 否则换主题时会一半新一半旧,且不报错。
`color-scheme` 也由 App.vue 按主题设,这里不重复声明。 */
html,
body {
background: var(--na-bg);
}
body {
margin: 0;
}
.brand {
padding: 18px 20px 10px;
}
.brand-title {
font-size: 18px;
font-weight: 600;
color: var(--na-brand);
letter-spacing: 0.5px;
}
.brand-sub {
font-size: 12px;
color: var(--na-text-3);
margin-top: 2px;
}
.topbar {
height: 48px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
}
/* 侧栏做成纵向弹性布局,才能把底部快捷块压到底 */
:deep(.n-layout-sider-scroll-container) {
display: flex;
flex-direction: column;
}
.sider-foot {
margin-top: auto;
padding: 12px 14px 16px;
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;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
</style>