原配色是深色 + 品牌青绿(#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 背景, 确认主题真的切过去了,而不是「看起来像」。
167 lines
4.1 KiB
Vue
167 lines
4.1 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref } from "vue";
|
||
import {
|
||
NCard,
|
||
NInput,
|
||
NButton,
|
||
NSpace,
|
||
NInputNumber,
|
||
NText,
|
||
NTag,
|
||
NEmpty,
|
||
NList,
|
||
NListItem,
|
||
useMessage,
|
||
useDialog,
|
||
} from "naive-ui";
|
||
import { useConnStore } from "../stores/conn";
|
||
|
||
interface ExecResult {
|
||
exit_code: number;
|
||
stdout?: string;
|
||
stderr?: string;
|
||
duration_ms?: number;
|
||
truncated?: boolean;
|
||
killed?: boolean;
|
||
}
|
||
|
||
const conn = useConnStore();
|
||
const msg = useMessage();
|
||
const dialog = useDialog();
|
||
|
||
const cmd = ref("");
|
||
const timeoutMs = ref(30000);
|
||
const running = ref(false);
|
||
const result = ref<ExecResult | null>(null);
|
||
const history = ref<string[]>([]);
|
||
|
||
const output = computed(() => {
|
||
if (!result.value) return "";
|
||
const r = result.value;
|
||
let out = "";
|
||
if (r.stdout) out += r.stdout;
|
||
if (r.stderr) out += (out ? "\n" : "") + r.stderr;
|
||
return out || "(无输出)";
|
||
});
|
||
|
||
async function exec() {
|
||
const command = cmd.value.trim();
|
||
if (!command) return;
|
||
if (!conn.connected) {
|
||
msg.warning("请先连接被控端");
|
||
return;
|
||
}
|
||
dialog.warning({
|
||
title: "确认执行远程命令",
|
||
content: `将在被控端执行:\n${command}`,
|
||
positiveText: "执行",
|
||
negativeText: "取消",
|
||
onPositiveClick: () => doExec(command),
|
||
});
|
||
}
|
||
|
||
async function doExec(command: string) {
|
||
running.value = true;
|
||
result.value = null;
|
||
try {
|
||
const r = await conn.ok<ExecResult>("system.shell.exec", {
|
||
command,
|
||
timeout_ms: timeoutMs.value,
|
||
});
|
||
result.value = r;
|
||
history.value = [command, ...history.value.filter((c) => c !== command)].slice(0, 20);
|
||
if (r.exit_code !== 0) msg.warning(`退出码 ${r.exit_code}`);
|
||
} catch (e) {
|
||
msg.error(`执行失败:${e}`);
|
||
} finally {
|
||
running.value = false;
|
||
}
|
||
}
|
||
|
||
function reuse(c: string) {
|
||
cmd.value = c;
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div v-if="!conn.connected">
|
||
<n-empty :show-icon="false" description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
|
||
</div>
|
||
|
||
<div v-else class="wrap">
|
||
<n-card size="small" title="命令执行(system.shell.exec)">
|
||
<template #header-extra>
|
||
<n-text depth="3" style="font-size: 12px">高权限操作,请谨慎</n-text>
|
||
</template>
|
||
<n-input
|
||
v-model:value="cmd"
|
||
type="textarea"
|
||
:autosize="{ minRows: 2, maxRows: 6 }"
|
||
placeholder="输入命令,例如:Get-Process | Select-Object -First 5"
|
||
/>
|
||
<n-space align="center" style="margin-top: 12px">
|
||
<span class="label">超时(ms)</span>
|
||
<n-input-number v-model:value="timeoutMs" :min="1000" :step="1000" size="small" style="width: 140px" />
|
||
<n-button type="primary" :loading="running" @click="exec">执行</n-button>
|
||
</n-space>
|
||
</n-card>
|
||
|
||
<n-card v-if="result" size="small" title="输出">
|
||
<template #header-extra>
|
||
<n-space align="center">
|
||
<n-tag :type="result.exit_code === 0 ? 'success' : 'error'" size="small">
|
||
退出码 {{ result.exit_code }}
|
||
</n-tag>
|
||
<n-text depth="3" style="font-size: 12px">{{ result.duration_ms }}ms</n-text>
|
||
<n-tag v-if="result.truncated" size="small" type="warning">已截断</n-tag>
|
||
<n-tag v-if="result.killed" size="small" type="error">超时终止</n-tag>
|
||
</n-space>
|
||
</template>
|
||
<pre class="out">{{ output }}</pre>
|
||
</n-card>
|
||
|
||
<n-card v-if="history.length" size="small" title="历史">
|
||
<n-list>
|
||
<n-list-item v-for="(h, i) in history" :key="i">
|
||
<div class="hist" @click="reuse(h)">
|
||
<code>{{ h }}</code>
|
||
</div>
|
||
</n-list-item>
|
||
</n-list>
|
||
</n-card>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.wrap {
|
||
max-width: 1000px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
.label {
|
||
font-size: 13px;
|
||
opacity: 0.7;
|
||
}
|
||
.out {
|
||
margin: 0;
|
||
font-family: ui-monospace, Menlo, monospace;
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
max-height: 460px;
|
||
overflow: auto;
|
||
}
|
||
.hist {
|
||
cursor: pointer;
|
||
}
|
||
.hist code {
|
||
font-size: 12px;
|
||
opacity: 0.85;
|
||
}
|
||
.hist:hover code {
|
||
color: var(--na-accent);
|
||
}
|
||
</style>
|