原配色是深色 + 品牌青绿(#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 背景, 确认主题真的切过去了,而不是「看起来像」。
205 lines
5.6 KiB
Vue
205 lines
5.6 KiB
Vue
<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>
|