Files
nodeagent/apps/console/src/views/FilesView.vue
T
jkooe dac8a0a2a7 feat(console): 入库 Tauri 2 桌面控制台(主控/被控一体)
apps/console —— 跨端(macOS/Windows)桌面控制台,复用 ops 侧的 Node agent 作本地被控端,
不做第二套 agent。技术栈:Tauri 2(Rust 壳 + 系统 WebView,包体小)+
Vue 3 + TS + Vite + Pinia + Naive UI(暗色)+ Vue Router(hash)。

- command 层(src-tauri/src/lib.rs):connect / disconnect / invoke_capability /
  get_capabilities / get_state;client 事件经 Emitter 桥接为 na-log / na-state / na-event 推前端。
  握手后**先 await 成功再落 state**(不持锁 await)。
- 7 页面:设备管理 / 状态总览 / 终端 / 进程与服务 / 软件 / 文件 / 审计。
- 实测:macOS .app 6.75 MiB(方案指标 <30MB;Electron 同级约 100MB+),
  直接执行二进制进程存活、无崩溃 —— `codesign --verify --deep --strict` 那条
  "code has no resources but signature indicates they must be present" 是 adhoc
  链接签名的固有噪声,实测无害(勿被误导去「修签名」)。

⚠️ 本轮用 examples/probe.rs 对真实被控端核对字段契约,抓出 9 处**静默空列**
(不报错、类型系统也抓不到,界面只显示 "-"),全部按契约修掉:
- 进程表读 r.cpu / r.memory → 真源 cpu_pct / memory_bytes(CPU、内存两列恒为 "-")
- 服务表读 r.status → 真源 state
- 磁盘读 d.mount||d.name / d.used → 真源只有 drive + total/free,
  **没有 used**(须自算 total-free)
- 网卡读 {name,rx_bytes,tx_bytes} → 真源 {adapter,ip,up}
- 软件表读 a.id → app.list 结果**没有 id**,应为 publisher
- 审计 broken_at 当数字渲染 → 真源是 {file,line,reason} 对象
另清掉幻影字段 SystemInfo.node(契约里只有 node_path)。

配套两点设计:
- **平台门控**:连接后探测 system.info.os → isWindows/platformReady;app.* 与
  system.service.list 仅 Windows 被控端可用,故软件页与服务 Tab **事前禁用并说明原因**,
  而不是发出注定失败的请求再解释错误。
- **结构化错误**:协议真源 InvokeResult.error 只有 {name,message,data?}(**没有 code**),
  故分支一律按 name;前端 CapabilityFailure 暴露 errorName,并提供
  isUnsupportedPlatform / isAclDenied / isRateLimited。未改协议(改动会扩散 4 端)。

tests/unit/console-contract.test.mjs(20 例):把「前端声明 ⊆ 契约」与「契约 ⊆ 前端声明」
双向钉死,另校验平台门控清单与 agent 侧 requireWindows 集合一致、AuditType 覆盖
apps/agent/src/audit.ts 全部取值。**已注入漂移实测会红**(把 cpu_pct 改回 cpu →
正/反向各挂 1 项),非空转守卫。
2026-10-07 10:14:12 +08:00

241 lines
6.8 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 { onMounted, ref } from "vue";
import {
NCard,
NInput,
NButton,
NSpace,
NText,
NEmpty,
NDataTable,
NModal,
NTag,
useMessage,
useDialog,
type DataTableColumns,
} from "naive-ui";
import { useConnStore } from "../stores/conn";
import type { FsEntry, FsListResult, FsReadResult, SystemInfo } from "../types";
import { formatBytes, formatTime } from "../utils/format";
const conn = useConnStore();
const msg = useMessage();
const dialog = useDialog();
const cwd = ref("");
const entries = ref<FsEntry[]>([]);
const loading = ref(false);
const truncated = ref(false);
const previewOpen = ref(false);
const previewName = ref("");
const previewText = ref("");
const previewMeta = ref("");
const writeOpen = ref(false);
const wName = ref("");
const wContent = ref("");
const wAppend = ref(false);
const cols: DataTableColumns<FsEntry> = [
{
title: "名称",
key: "name",
render: (r) => r.name + (r.type === "dir" ? "/" : ""),
},
{ title: "类型", key: "type", width: 80 },
{ title: "大小", key: "size", width: 110, render: (r) => (r.type === "file" ? formatBytes(r.size) : "-") },
{ title: "修改时间", key: "mtime", width: 180, render: (r) => formatTime(r.mtime) },
];
async function list(path: string) {
if (!path) return;
loading.value = true;
try {
const r = await conn.ok<FsListResult>("fs.list", { path });
entries.value = (r.entries ?? []).slice().sort((a, b) => {
if (a.type === "dir" && b.type !== "dir") return -1;
if (a.type !== "dir" && b.type === "dir") return 1;
return a.name.localeCompare(b.name);
});
truncated.value = !!r.truncated;
cwd.value = path;
} catch (e) {
msg.error(`列目录失败:${e}`);
} finally {
loading.value = false;
}
}
function parentOf(p: string): string {
const s = p.replace(/[\\/]+$/, "");
const idx = Math.max(s.lastIndexOf("/"), s.lastIndexOf("\\"));
if (idx < 0) return s;
if (idx === 0) return s.slice(0, 1); // POSIX 根
const r = s.slice(0, idx);
return /^[A-Za-z]:$/.test(r) ? r + "\\" : r; // Windows 盘符
}
function onRowClick(row: FsEntry) {
if (row.type === "dir") list(row.path);
else preview(row);
}
async function preview(row: FsEntry) {
try {
const r = await conn.ok<FsReadResult>("fs.read", {
path: row.path,
encoding: "utf8",
max_bytes: 262144,
});
previewName.value = row.name;
previewText.value = (r.data ?? "").replace(/\u0000/g, "");
previewMeta.value = `${formatBytes(r.total_bytes)}${r.eof ? "" : " · 已截断预览"}${r.sha256 ? ` · sha256 ${r.sha256.slice(0, 12)}…` : ""}`;
previewOpen.value = true;
} catch (e) {
msg.error(`读取失败:${e}`);
}
}
function openWrite() {
wName.value = "";
wContent.value = "";
wAppend.value = false;
writeOpen.value = true;
}
function doWrite() {
if (!wName.value) return;
const sep = cwd.value.includes("\\") ? "\\" : "/";
const path = cwd.value.replace(/[\\/]+$/, "") + sep + wName.value;
dialog.warning({
title: "确认写入被控端",
content: `目标:${path}\n模式:${wAppend.value ? "追加" : "覆盖"}\n字节:${wContent.value.length}`,
positiveText: "写入",
negativeText: "取消",
onPositiveClick: () => writeTo(path),
});
}
async function writeTo(path: string) {
try {
await conn.ok("fs.write", { path, data: wContent.value, append: wAppend.value, create_dirs: true });
msg.success(`已写入 ${path}`);
writeOpen.value = false;
list(cwd.value);
} catch (e) {
msg.error(`写入失败:${e}`);
}
}
async function ensureStart() {
try {
const info = await conn.ok<SystemInfo>("system.info", { fields: ["agent_home", "os"] });
let start = info.agent_home ?? "";
if (!start) start = info.os === "Windows" ? "C:\\" : "/";
list(start);
} catch {
list(conn.info ? "/" : "");
}
}
onMounted(() => {
if (conn.connected) ensureStart();
});
</script>
<template>
<div v-if="!conn.connected">
<n-empty description="请先在「设备管理」页连接被控端" style="margin-top: 80px" />
</div>
<div v-else class="wrap">
<n-card size="small">
<n-space align="center" :wrap="false">
<n-input
v-model:value="cwd"
size="small"
placeholder="输入路径,回车列目录(如 C:\ 或 /Users)"
style="flex: 1"
@keyup.enter="list(cwd)"
/>
<n-button size="small" @click="list(parentOf(cwd))">上级</n-button>
<n-button size="small" :loading="loading" @click="list(cwd)">刷新</n-button>
<n-button size="small" type="primary" @click="openWrite">新建文件</n-button>
</n-space>
<div class="path">
<n-text depth="3" style="font-size: 12px">
当前:{{ cwd || "-" }} · {{ entries.length }} 项
<n-tag v-if="truncated" size="tiny" type="warning" style="margin-left: 6px">结果被截断</n-tag>
</n-text>
</div>
</n-card>
<n-card size="small" style="margin-top: 16px">
<n-empty v-if="!entries.length" description="目录为空或未加载" size="small" />
<n-data-table
v-else
:columns="cols"
:data="entries"
:bordered="false"
size="small"
:max-height="520"
:row-props="(row: FsEntry) => ({ style: 'cursor:pointer', onClick: () => onRowClick(row) })"
/>
</n-card>
</div>
<!-- 预览 -->
<n-modal v-model:show="previewOpen" preset="card" :title="previewName" style="width: 820px; max-width: 90vw">
<n-text depth="3" style="font-size: 12px">{{ previewMeta }}</n-text>
<pre class="preview">{{ previewText || "(空文件)" }}</pre>
</n-modal>
<!-- 新建/写入 -->
<n-modal v-model:show="writeOpen" preset="card" title="写入文件" style="width: 640px; max-width: 90vw">
<n-space vertical>
<n-input v-model:value="wName" placeholder="文件名(写入当前目录)" size="small" />
<n-input
v-model:value="wContent"
type="textarea"
:autosize="{ minRows: 6, maxRows: 16 }"
placeholder="文件内容(utf8)"
/>
<n-space align="center">
<n-tag
size="small"
:type="wAppend ? 'warning' : 'default'"
checkable
:checked="wAppend"
@update:checked="(v: boolean) => (wAppend = v)"
>
追加模式
</n-tag>
<n-button size="small" type="primary" @click="doWrite">写入</n-button>
</n-space>
</n-space>
</n-modal>
</template>
<style scoped>
.wrap {
max-width: 1100px;
}
.path {
margin-top: 10px;
}
.preview {
margin: 12px 0 0;
padding: 12px;
border-radius: 8px;
background: rgba(0, 0, 0, 0.3);
font-family: ui-monospace, Menlo, monospace;
font-size: 12px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-all;
max-height: 60vh;
overflow: auto;
}
</style>