feat: 扩展数据窗口能力并完善设置统计

This commit is contained in:
yu 2026-10-27 13:28:48 +08:00
parent 21b5186442
commit d4799a4285
9 changed files with 778 additions and 90 deletions

View File

@ -50,6 +50,6 @@ docs 现状分析、实施方案
## 边界
- 1016 项目录中当前有 316 项通过能力注册表或运行时容器标记为可执行,覆盖控制/触发、数据与二维表格、文件、浏览器元素常用变体、HTTP、Excel/CSV与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口/剪贴板/对话框、PDF、Word、邮件发送、屏幕截图、离线图像查找与点击;其余灰标「待接入」。运行前检查会提前定位待接入组件、必填参数、多出口与断链节点。
- 1016 项目录中当前有 363 项通过能力注册表或运行时容器标记为可执行,覆盖控制/触发、字符串/集合/INI/二维表格、文件、浏览器元素常用变体、HTTP、Excel/CSV与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口对象/剪贴板/对话框、PDF、Word、邮件发送、屏幕截图、离线图像查找与点击;其余灰标「待接入」。运行前检查会提前定位待接入组件、必填参数、多出口与断链节点。
- 不包含厂商代码、二进制、模型或前端资产;目录是从厂商元数据派生的静态定义。
- 运行期不访问云服务;HTTP/邮件属于用户显式配置的外部系统。

View File

@ -4,7 +4,7 @@
> 依据:`D:\code\Agent-Employee`(只读)、`C:\rpa-demo`(只读)、2026-10-26 需求确认
> 本方案是执行基线:范围、契约、排期、验收、降级顺序都在这里;实施中变更必须回写本文件。
> **2026-10-27 当前状态**:计划点名的代表能力已全部进入注册表或运行时容器,覆盖控制/触发、数据/文本与二维表格、文件、浏览器元素常用变体、HTTP、Excel/CSV与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口/剪贴板/对话框、PDF、Word、邮件发送、屏幕截图、离线图像查找与点击;启动后目录显示 **316 项可执行 / 1016 项总目录**。当前 11 个测试文件共 79 条测试,覆盖计划能力审计、浏览器参数路由与变体、Excel/PDF/Word/SMTP/模板匹配、文件与二维表格扩展、系统与进程、直线/IF/循环/重试/跳过、主出口与异常出口语义、运行记录回写和运行前检查。未通过实际场景验证的变体仍不计入最终交付数量。
> **2026-10-27 当前状态**:计划点名的代表能力已全部进入注册表或运行时容器,覆盖控制/触发、字符串/集合/INI/二维表格、文件、浏览器元素常用变体、HTTP、Excel/CSV与表格辅助、系统路径与环境变量、进程管理、桌面键鼠/窗口对象/剪贴板/对话框、PDF、Word、邮件发送、屏幕截图、离线图像查找与点击;启动后目录显示 **363 项可执行 / 1016 项总目录**。当前 13 个测试文件共 89 条测试,覆盖计划能力审计、浏览器参数路由与变体、Excel/PDF/Word/SMTP/模板匹配、文件与数据扩展、系统/进程/窗口解析、直线/IF/循环/重试/跳过、主出口与异常出口语义、运行记录回写和运行前检查。设置页可查看分类覆盖和处理器 ID 数量。未通过实际场景验证的变体仍不计入最终交付数量。
---

View File

@ -0,0 +1,132 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Catalog, CatalogComponent, FlowNode } from "../../shared/types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./data-extended";
const ids = [
"Collection_Create", "Collection_HasItem", "Collection_Count", "Collection_Remove", "Collection_Add",
"Ini_Read", "Ini_Write", "Ini_ListSection", "Ini_ListKey", "Ini_DeleteKey", "Ini_DeleteSection",
"Log_Info", "Log_Error", "ExceptionUtil_Throws",
"Bivartable_CreateDf", "Bivartable_CreateDfV1", "Bivartable_DoCreate",
"Bivartable_GetMaxValue", "Bivartable_GetMinValue", "Bivartable_GetSumValue", "Bivartable_GetAvgValue",
"Bivartable_SummaryCalculationDf", "Bivartable_DeleteDuplicateRow", "Bivartable_MergeDf",
"Bivartable_DoMerge", "Bivartable_FilterDf", "Bivartable_DoFilter"
] as const;
const temporaryDirectories: string[] = [];
const log = vi.fn();
const context = { log } as unknown as RunContext;
const component = {} as CatalogComponent;
const node = (componentId: string): FlowNode => ({
id: `data_${componentId}`,
componentId,
position: { x: 0, y: 0 },
values: {},
options: {}
});
async function temporaryDirectory(): Promise<string> {
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-data-extended-"));
temporaryDirectories.push(directory);
return directory;
}
async function run(componentId: string, args: Record<string, unknown> = {}): Promise<unknown> {
const handler = getHandler(componentId);
expect(handler, `${componentId} should be registered`).toBeTruthy();
return handler!(context, node(componentId), args, component);
}
afterEach(async () => {
log.mockReset();
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
});
describe("extended local data capabilities", () => {
it("registers only real catalog component ids", async () => {
const catalog = JSON.parse(await readFile("resources/catalog.json", "utf8")) as Catalog;
const catalogIds = new Set(catalog.components.map((item) => item.id));
expect(ids.length).toBeGreaterThanOrEqual(15);
for (const id of ids) {
expect(catalogIds.has(id), `${id} should exist in catalog`).toBe(true);
expect(getHandler(id), `${id} should be registered`).toBeTruthy();
}
});
it("creates and mutates unique local collections", async () => {
const collection = await run("Collection_Create") as unknown[];
await run("Collection_Add", { collection, value: "A" });
await run("Collection_Add", { collection, value: "A" });
await run("Collection_Add", { collection, value: "B" });
await expect(run("Collection_HasItem", { collection, item: "A" })).resolves.toBe(true);
await expect(run("Collection_Count", { collection })).resolves.toBe(2);
await run("Collection_Remove", { collection, value: "A" });
expect(collection).toEqual(["B"]);
});
it("persists, lists, reads, and deletes INI values", async () => {
const directory = await temporaryDirectory();
const file = path.join(directory, "nested", "settings.ini");
await run("Ini_Write", { file, section: "App", key: "name", value: "RPA Studio" });
await run("Ini_Write", { file, section: "App", key: "workers", value: 3 });
await run("Ini_Write", { file, section: "Network", key: "timeout", value: 30 });
await expect(run("Ini_Read", { file, section: "App", key: "name" })).resolves.toBe("RPA Studio");
await expect(run("Ini_Read", { file, section: "Missing", key: "key", defaultValue: "fallback" })).resolves.toBe("fallback");
await expect(run("Ini_ListSection", { file })).resolves.toEqual(["App", "Network"]);
await expect(run("Ini_ListKey", { file, section: "App" })).resolves.toEqual(["name", "workers"]);
await run("Ini_DeleteKey", { file, section: "App", key: "workers" });
await run("Ini_DeleteSection", { file, section: "Network" });
await expect(run("Ini_ListKey", { file, section: "App" })).resolves.toEqual(["name"]);
await expect(run("Ini_ListSection", { file })).resolves.toEqual(["App"]);
await expect(readFile(file, "utf8")).resolves.toContain("name=RPA Studio");
});
it("writes runtime logs and raises a located user exception", async () => {
await run("Log_Info", { msg: "started" });
await run("Log_Error", { msg: "failed" });
expect(log).toHaveBeenNthCalledWith(1, "started", "info", "data_Log_Info");
expect(log).toHaveBeenNthCalledWith(2, "failed", "error", "data_Log_Error");
await expect(run("ExceptionUtil_Throws", { msg: "stop now" })).rejects.toMatchObject({
name: "RuntimeError", code: "user_exception", message: "stop now", nodeId: "data_ExceptionUtil_Throws"
});
});
it("creates tables from arrays, records, and column dictionaries", async () => {
await expect(run("Bivartable_CreateDf", { data: [[1, 2], [3, 4]], columnList: ["A", "B"] }))
.resolves.toEqual([["A", "B"], [1, 2], [3, 4]]);
await expect(run("Bivartable_CreateDfV1", { data: [{ name: "A", score: 2 }, { name: "B", score: 4 }] }))
.resolves.toEqual([["name", "score"], ["A", 2], ["B", 4]]);
await expect(run("Bivartable_DoCreate", { data: { name: ["A", "B"], score: [2, 4] } }))
.resolves.toEqual([["name", "score"], ["A", 2], ["B", 4]]);
});
it("calculates scalar and grouped table aggregates", async () => {
const input = [["team", "score"], ["A", 2], ["A", 4], ["B", 3]];
await expect(run("Bivartable_GetMaxValue", { df: input, maxvaluecols: "score" })).resolves.toBe(4);
await expect(run("Bivartable_GetMinValue", { df: input, minvaluecols: "score" })).resolves.toBe(2);
await expect(run("Bivartable_GetSumValue", { df: input, sumvaluecols: "score" })).resolves.toBe(9);
await expect(run("Bivartable_GetAvgValue", { df: input, avgvaluecols: "score", groupby: "team" }))
.resolves.toEqual([["team", "score"], ["A", 3], ["B", 3]]);
await expect(run("Bivartable_SummaryCalculationDf", { df: input, name: "score", method: "sum" })).resolves.toBe(9);
});
it("deduplicates, merges, and filters local tables", async () => {
const duplicated = [["id", "name"], [1, "A"], [1, "A2"], [2, "B"]];
await expect(run("Bivartable_DeleteDuplicateRow", { df: duplicated, change_type: "name", col_name: ["id"], mode: "first" }))
.resolves.toEqual([["id", "name"], [1, "A"], [2, "B"]]);
const left = [["id", "name"], [1, "A"], [2, "B"]];
const right = [["id", "score"], [1, 90], [3, 70]];
await expect(run("Bivartable_MergeDf", { df1: left, df2: right, on_name: "id", merge_type: "left" }))
.resolves.toEqual([["id", "name", "score"], [1, "A", 90], [2, "B", null]]);
await expect(run("Bivartable_DoMerge", { df1: left, df2: right, onx: "id", howx: "inner" }))
.resolves.toEqual([["id", "name", "score"], [1, "A", 90]]);
await expect(run("Bivartable_FilterDf", { df: left, col_name: "id", choice: ">", criteria: 1 }))
.resolves.toEqual([["id", "name"], [2, "B"]]);
await expect(run("Bivartable_DoFilter", { df: [["id", "score"], [1, 70], [2, 90], [3, 95]], filter: "(df['id'] > 1) & (df['score'] >= 90)" }))
.resolves.toEqual([["id", "score"], [2, 90], [3, 95]]);
});
});

View File

@ -0,0 +1,392 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
type Table = unknown[][];
type IniDocument = Map<string, Map<string, string>>;
function value(args: Record<string, unknown>, keys: string[], fallback?: unknown): unknown {
for (const key of keys) {
const candidate = args[key];
if (candidate !== undefined && candidate !== null && candidate !== "") return candidate;
}
return fallback;
}
function text(candidate: unknown): string {
return candidate == null ? "" : String(candidate);
}
function parse(candidate: unknown): unknown {
if (typeof candidate !== "string") return candidate;
const source = candidate.trim();
if (!source) return candidate;
try { return JSON.parse(source); } catch { return candidate; }
}
function list(candidate: unknown): unknown[] {
const parsed = parse(candidate);
if (Array.isArray(parsed)) return parsed;
if (parsed === undefined || parsed === null || parsed === "") return [];
if (typeof parsed === "string") return parsed.split(/[,\uFF0C\n]/).map((item) => item.trim()).filter(Boolean);
return [parsed];
}
function table(candidate: unknown): Table {
const parsed = parse(candidate);
if (Array.isArray(parsed)) {
if (!parsed.length) return [];
if (parsed.every((row) => Array.isArray(row))) return parsed.map((row) => [...row as unknown[]]);
if (parsed.every((row) => row && typeof row === "object" && !Array.isArray(row))) {
const headers = [...new Set(parsed.flatMap((row) => Object.keys(row as Record<string, unknown>)))];
return [headers, ...parsed.map((row) => headers.map((header) => (row as Record<string, unknown>)[header]))];
}
return parsed.map((item) => [item]);
}
if (parsed && typeof parsed === "object") {
const source = parsed as Record<string, unknown>;
const headers = Object.keys(source);
const columns = headers.map((header) => list(source[header]));
const rowCount = Math.max(0, ...columns.map((column) => column.length));
return [headers, ...Array.from({ length: rowCount }, (_, row) => columns.map((column) => column[row]))];
}
return parsed === undefined || parsed === null || parsed === "" ? [] : [[parsed]];
}
function columnNames(candidate: unknown): string[] {
return list(candidate).map((item) => text(item)).filter(Boolean);
}
function columnIndex(headers: unknown[], name: unknown): number {
const source = text(name).trim();
const byName = headers.findIndex((header) => text(header) === source);
if (byName >= 0) return byName;
if (/^\d+$/.test(source)) return Math.max(0, Number(source) - 1);
if (/^[A-Za-z]+$/.test(source)) {
let result = 0;
for (const character of source.toUpperCase()) result = result * 26 + character.charCodeAt(0) - 64;
return Math.max(0, result - 1);
}
return -1;
}
function selectedIndexes(rows: Table, candidate: unknown): number[] {
const headers = rows[0] ?? [];
const requested = columnNames(candidate);
return requested.map((name) => columnIndex(headers, name)).filter((index) => index >= 0);
}
function comparable(candidate: unknown): string | number | boolean | null {
if (candidate === null || candidate === undefined) return null;
if (typeof candidate === "number" || typeof candidate === "boolean") return candidate;
const source = text(candidate).trim().replace(/^\(+|\)+$/g, "").trim();
if (/^-?\d+(\.\d+)?$/.test(source)) return Number(source);
if (source === "true" || source === "false") return source === "true";
return source.replace(/^['"]|['"]$/g, "");
}
function equal(left: unknown, right: unknown): boolean {
return comparable(left) === comparable(right);
}
function collection(candidate: unknown): Set<unknown> | unknown[] {
if (candidate instanceof Set || Array.isArray(candidate)) return candidate;
return list(candidate);
}
register("Collection_Create", () => []);
register("Collection_HasItem", (_ctx, _node, args) => {
const source = collection(args.collection);
return source instanceof Set ? source.has(args.item) : source.some((item) => equal(item, args.item));
});
register("Collection_Count", (_ctx, _node, args) => {
const source = collection(args.collection);
return source instanceof Set ? source.size : source.length;
});
register("Collection_Add", (_ctx, _node, args) => {
const source = collection(args.collection);
if (source instanceof Set) source.add(args.value);
else if (!source.some((item) => equal(item, args.value))) source.push(args.value);
return source;
});
register("Collection_Remove", (_ctx, _node, args) => {
const source = collection(args.collection);
if (source instanceof Set) source.delete(args.value);
else {
const index = source.findIndex((item) => equal(item, args.value));
if (index >= 0) source.splice(index, 1);
}
return source;
});
function requiredFile(args: Record<string, unknown>, nodeId: string, componentId: string): string {
const candidate = text(args.file).trim();
if (!candidate) {
throw new RuntimeError("validation_error", "INI file path is empty", {
nodeId, componentId, arg: "file", recommendation: "Select an INI file"
});
}
return path.resolve(candidate);
}
function parseIni(source: string): IniDocument {
const document: IniDocument = new Map([["", new Map()]]);
let section = "";
for (const rawLine of source.replace(/^\uFEFF/, "").split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith(";") || line.startsWith("#")) continue;
const sectionMatch = line.match(/^\[([^\]]+)]$/);
if (sectionMatch) {
section = sectionMatch[1].trim();
if (!document.has(section)) document.set(section, new Map());
continue;
}
const separator = rawLine.indexOf("=");
if (separator < 0) continue;
document.get(section)!.set(rawLine.slice(0, separator).trim(), rawLine.slice(separator + 1).trim());
}
return document;
}
function serializeIni(document: IniDocument): string {
const lines: string[] = [];
for (const [section, entries] of document) {
if (section) {
if (lines.length) lines.push("");
lines.push(`[${section}]`);
}
for (const [key, entryValue] of entries) lines.push(`${key}=${entryValue}`);
}
return `${lines.join("\r\n")}\r\n`;
}
async function loadIni(filePath: string): Promise<IniDocument> {
try { return parseIni(await fs.readFile(filePath, "utf8")); }
catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return parseIni("");
throw error;
}
}
async function saveIni(filePath: string, document: IniDocument): Promise<void> {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, serializeIni(document), "utf8");
}
register("Ini_Read", async (_ctx, node, args) => {
const document = await loadIni(requiredFile(args, node.id, node.componentId));
return document.get(text(args.section))?.get(text(args.key)) ?? text(args.defaultValue);
});
register("Ini_Write", async (_ctx, node, args) => {
const filePath = requiredFile(args, node.id, node.componentId);
const document = await loadIni(filePath);
const section = text(args.section);
if (!document.has(section)) document.set(section, new Map());
document.get(section)!.set(text(args.key), text(args.value));
await saveIni(filePath, document);
return filePath;
});
register("Ini_ListSection", async (_ctx, node, args) => {
const document = await loadIni(requiredFile(args, node.id, node.componentId));
return [...document.keys()].filter(Boolean);
});
register("Ini_ListKey", async (_ctx, node, args) => {
const document = await loadIni(requiredFile(args, node.id, node.componentId));
return [...(document.get(text(args.section))?.keys() ?? [])];
});
register("Ini_DeleteKey", async (_ctx, node, args) => {
const filePath = requiredFile(args, node.id, node.componentId);
const document = await loadIni(filePath);
const removed = document.get(text(args.section))?.delete(text(args.key)) ?? false;
await saveIni(filePath, document);
return removed;
});
register("Ini_DeleteSection", async (_ctx, node, args) => {
const filePath = requiredFile(args, node.id, node.componentId);
const document = await loadIni(filePath);
const removed = document.delete(text(args.section));
await saveIni(filePath, document);
return removed;
});
register("Log_Info", (ctx, node, args) => {
const message = text(args.msg);
ctx.log(message, "info", node.id);
return message;
});
register("Log_Error", (ctx, node, args) => {
const message = text(args.msg);
ctx.log(message, "error", node.id);
return message;
});
register("ExceptionUtil_Throws", (_ctx, node, args) => {
throw new RuntimeError("user_exception", text(args.msg) || "User exception", {
nodeId: node.id, componentId: node.componentId
});
});
const createTable: CapabilityHandler = (_ctx, _node, args) => {
const rows = table(value(args, ["data"]));
const columns = columnNames(args.columnList);
if (!columns.length) return rows;
if (!rows.length) return [columns];
if (rows[0].length !== columns.length) throw new RuntimeError("validation_error", "Column count does not match data");
return [columns, ...rows];
};
register(["Bivartable_CreateDf", "Bivartable_CreateDfV1", "Bivartable_DoCreate"], createTable);
function aggregate(args: Record<string, unknown>, operation: "max" | "min" | "sum" | "avg"): unknown {
const rows = table(args.df);
if (rows.length < 2) return 0;
const field = operation === "max" ? "maxvaluecols" : operation === "min" ? "minvaluecols" : operation === "sum" ? "sumvaluecols" : "avgvaluecols";
const indexes = selectedIndexes(rows, args[field]);
if (!indexes.length) throw new RuntimeError("validation_error", "No aggregate column was found");
const groups = selectedIndexes(rows, args.groupby);
const calculate = (body: unknown[][], index: number): number => {
const numbers = body.map((row) => Number(row[index])).filter(Number.isFinite);
if (!numbers.length) return 0;
if (operation === "max") return Math.max(...numbers);
if (operation === "min") return Math.min(...numbers);
const sum = numbers.reduce((total, current) => total + current, 0);
return operation === "avg" ? sum / numbers.length : sum;
};
if (!groups.length) {
const results = indexes.map((index) => calculate(rows.slice(1), index));
return results.length === 1 ? results[0] : Object.fromEntries(indexes.map((index, position) => [text(rows[0][index]), results[position]]));
}
const buckets = new Map<string, unknown[][]>();
for (const row of rows.slice(1)) {
const key = JSON.stringify(groups.map((index) => row[index]));
if (!buckets.has(key)) buckets.set(key, []);
buckets.get(key)!.push(row);
}
return [
[...groups.map((index) => rows[0][index]), ...indexes.map((index) => rows[0][index])],
...[...buckets.entries()].map(([key, body]) => [...JSON.parse(key), ...indexes.map((index) => calculate(body, index))])
];
}
register("Bivartable_GetMaxValue", (_ctx, _node, args) => aggregate(args, "max"));
register("Bivartable_GetMinValue", (_ctx, _node, args) => aggregate(args, "min"));
register("Bivartable_GetSumValue", (_ctx, _node, args) => aggregate(args, "sum"));
register("Bivartable_GetAvgValue", (_ctx, _node, args) => aggregate(args, "avg"));
register("Bivartable_SummaryCalculationDf", (_ctx, _node, args) => {
const method = text(args.method).toLowerCase();
const operation = method.includes("max") ? "max" : method.includes("min") ? "min" : method.includes("avg") || method.includes("mean") ? "avg" : "sum";
const field = operation === "max" ? "maxvaluecols" : operation === "min" ? "minvaluecols" : operation === "avg" ? "avgvaluecols" : "sumvaluecols";
return aggregate({ ...args, [field]: args.name }, operation);
});
register("Bivartable_DeleteDuplicateRow", (_ctx, _node, args) => {
const rows = table(args.df);
if (rows.length < 2) return rows;
const requested = text(args.change_type) === "name" ? args.col_name : args.col_num;
const indexes = selectedIndexes(rows, requested);
const compared = indexes.length ? indexes : rows[0].map((_item, index) => index);
const keys = rows.slice(1).map((row) => JSON.stringify(compared.map((index) => row[index])));
const counts = new Map<string, number>();
for (const key of keys) counts.set(key, (counts.get(key) ?? 0) + 1);
const mode = text(args.mode).toLowerCase();
const seen = new Set<string>();
const body = rows.slice(1).filter((_row, index) => {
const key = keys[index];
if (mode === "false" || mode === "none" || mode.includes("all")) return counts.get(key) === 1;
if (mode === "last") return keys.lastIndexOf(key) === index;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
return [rows[0], ...body];
});
function mergeTables(args: Record<string, unknown>): Table {
const left = table(args.df1);
const right = table(args.df2);
if (!left.length) return right;
if (!right.length) return left;
const mode = text(value(args, ["merge_type", "howx"], "inner")).toLowerCase();
const common = left[0].filter((header) => right[0].some((candidate) => text(candidate) === text(header))).map(text);
const sameKeys = columnNames(value(args, ["on_name", "onx"]));
const leftKeys = columnNames(value(args, ["on_left", "left_onx"], sameKeys.length ? sameKeys : common));
const rightKeys = columnNames(value(args, ["on_right", "right_onx"], sameKeys.length ? sameKeys : common));
if (!leftKeys.length || leftKeys.length !== rightKeys.length) throw new RuntimeError("validation_error", "Invalid table join keys");
const leftIndexes = leftKeys.map((key) => columnIndex(left[0], key));
const rightIndexes = rightKeys.map((key) => columnIndex(right[0], key));
if ([...leftIndexes, ...rightIndexes].some((index) => index < 0)) throw new RuntimeError("validation_error", "Table join key does not exist");
const rightDataIndexes = right[0].map((_header, index) => index).filter((index) => !rightIndexes.includes(index));
const outputHeaders = [...left[0], ...rightDataIndexes.map((index) => {
const header = text(right[0][index]);
return left[0].some((candidate) => text(candidate) === header) ? `${header}_right` : right[0][index];
})];
const rightBuckets = new Map<string, unknown[][]>();
for (const row of right.slice(1)) {
const key = JSON.stringify(rightIndexes.map((index) => row[index]));
if (!rightBuckets.has(key)) rightBuckets.set(key, []);
rightBuckets.get(key)!.push(row);
}
const matchedRight = new Set<unknown[]>();
const output: Table = [outputHeaders];
for (const row of left.slice(1)) {
const matches = rightBuckets.get(JSON.stringify(leftIndexes.map((index) => row[index]))) ?? [];
if (matches.length) {
for (const match of matches) {
matchedRight.add(match);
output.push([...row, ...rightDataIndexes.map((index) => match[index])]);
}
} else if (mode === "left" || mode === "outer") {
output.push([...row, ...rightDataIndexes.map(() => null)]);
}
}
if (mode === "right" || mode === "outer") {
for (const row of right.slice(1)) {
if (matchedRight.has(row)) continue;
const leftPart = left[0].map((header) => {
const keyPosition = leftKeys.indexOf(text(header));
return keyPosition >= 0 ? row[rightIndexes[keyPosition]] : null;
});
output.push([...leftPart, ...rightDataIndexes.map((index) => row[index])]);
}
}
return output;
}
register(["Bivartable_MergeDf", "Bivartable_DoMerge"], (_ctx, _node, args) => mergeTables(args));
function predicate(operator: string, expected: unknown): (actual: unknown) => boolean {
const normalized = operator.trim().toLowerCase();
if (["2", "!=", "<>", "ne"].includes(normalized)) return (actual) => !equal(actual, expected);
if (["3", ">", "gt"].includes(normalized)) return (actual) => Number(actual) > Number(expected);
if (["4", ">=", "gte"].includes(normalized)) return (actual) => Number(actual) >= Number(expected);
if (["5", "<", "lt"].includes(normalized)) return (actual) => Number(actual) < Number(expected);
if (["6", "<=", "lte"].includes(normalized)) return (actual) => Number(actual) <= Number(expected);
if (["7", "contains"].includes(normalized)) return (actual) => text(actual).includes(text(expected));
if (["8", "not contains"].includes(normalized)) return (actual) => !text(actual).includes(text(expected));
if (["9", "starts", "startswith"].includes(normalized)) return (actual) => text(actual).startsWith(text(expected));
if (["10", "ends", "endswith"].includes(normalized)) return (actual) => text(actual).endsWith(text(expected));
return (actual) => equal(actual, expected);
}
function expressionFilter(rows: Table, expression: string): unknown[][] {
const conditions = [...expression.matchAll(/(?:\w+\s*)?\[\s*['"]([^'"]+)['"]\s*]\s*(==|!=|>=|<=|>|<)\s*([^&|]+)/g)];
if (!conditions.length) throw new RuntimeError("validation_error", "Unsupported filter expression");
const operators = [...expression.matchAll(/\s*([&|])\s*/g)].map((match) => match[1]);
return rows.slice(1).filter((row) => {
const results = conditions.map((match) => {
const index = columnIndex(rows[0], match[1]);
return index >= 0 && predicate(match[2], comparable(match[3]))(row[index]);
});
return results.slice(1).reduce((result, current, index) => operators[index] === "|" ? result || current : result && current, results[0]);
});
}
function filterTable(args: Record<string, unknown>): Table {
const rows = table(args.df);
if (rows.length < 2) return rows;
const expression = text(value(args, ["rule", "filter"])).trim();
if (expression || text(args.filter_mode).toLowerCase().includes("advanced")) return [rows[0], ...expressionFilter(rows, expression)];
const index = columnIndex(rows[0], args.col_name);
if (index < 0) throw new RuntimeError("validation_error", `Filter column does not exist: ${text(args.col_name)}`);
const matches = predicate(text(args.choice || "1"), parse(args.criteria));
return [rows[0], ...rows.slice(1).filter((row) => matches(row[index]))];
}
register(["Bivartable_FilterDf", "Bivartable_DoFilter"], (_ctx, _node, args) => filterTable(args));

View File

@ -13,5 +13,7 @@ import "./vision";
import "./extended";
import "./system-extended";
import "./office-file-extended";
import "./window-extended";
import "./data-extended";
export { capabilityCount, capabilityIds, getHandler, hasCapability, register } from "../engine/registry";

View File

@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../../shared/types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./window-extended";
const context = {} as RunContext;
const component = {} as CatalogComponent;
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
const ids = [
"Window_ActiveWindow_v1", "Window_Get", "Window_GetWindow", "Window_GetWindow_v1", "Window_GetWindowMessage",
"Window_GetWindowMessage_v1", "Window_SetPos", "Window_MoveWindow", "Window_MoveWindow_v1", "Window_SizeWindow",
"Mouse_MoveToV1", "Mouse_MoveToV2", "Mouse_DragToV1", "Mouse_DragToV2", "Mouse_DragToNew",
"Mouse_ClickCombine", "Mouse_ClickCombine_v1", "Mouse_ClickCombine_v2", "Mouse_ClickCombine_v3", "Mouse_ClickCombine_v4"
];
describe("window extended capabilities", () => {
it("registers audited window and mouse variants", () => {
for (const id of ids) expect(getHandler(id), id).toBeTruthy();
});
it("rejects missing window selectors before touching the desktop", async () => {
for (const id of ["Window_GetWindow", "Window_ActiveWindow_v1", "Window_MoveWindow", "Window_SizeWindow"]) {
await expect(getHandler(id)!(context, node(id), {}, component)).rejects.toMatchObject({ code: "validation_error" });
}
});
it("rejects malformed mouse coordinates before injecting input", async () => {
await expect(getHandler("Mouse_MoveToV1")!(context, node("Mouse_MoveToV1"), { location: "bad" }, component)).rejects.toMatchObject({ code: "validation_error" });
await expect(getHandler("Mouse_DragToV1")!(context, node("Mouse_DragToV1"), { Start: "bad", End: "[1,2]" }, component)).rejects.toMatchObject({ code: "validation_error" });
await expect(getHandler("Mouse_ClickCombine")!(context, node("Mouse_ClickCombine"), { target: "1", location: "[1,2]" }, component)).rejects.toMatchObject({ code: "validation_error" });
});
});

View File

@ -0,0 +1,133 @@
import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
import { powershell, psString } from "./desktop";
const WINDOW_API = `Add-Type @'
using System;
using System.Runtime.InteropServices;
public static class RpaWindowExtended {
[StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; public int Top; public int Right; public int Bottom; }
[DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT rect);
[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int command);
[DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int x, int y, int width, int height, uint flags);
[DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")] public static extern void mouse_event(uint flags, uint dx, uint dy, uint data, UIntPtr extra);
}
'@`;
function value(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) {
const current = args[key];
if (current !== undefined && current !== null && current !== "") return String(current);
}
return fallback;
}
function parseObject(candidate: unknown): Record<string, unknown> {
if (candidate && typeof candidate === "object") return candidate as Record<string, unknown>;
if (typeof candidate === "string" && candidate.trim().startsWith("{")) {
try { return JSON.parse(candidate); } catch { return {}; }
}
return {};
}
function titleOf(args: Record<string, unknown>): string {
const objectCandidate = args.window_obj ?? args["窗口对象"] ?? args.uiWindowSelector ?? args.target_window;
const object = parseObject(objectCandidate);
return String(object.title ?? object.windowTitle ?? args.title ?? args.match_title ?? args.select_title ?? objectCandidate ?? "").trim();
}
function requiredTitle(args: Record<string, unknown>, nodeId: string, componentId: string): string {
const title = titleOf(args);
if (!title || title === "{}" || title === "窗口对象") throw new RuntimeError("validation_error", "窗口标题为空", { nodeId, componentId, recommendation: "填写窗口标题或传入窗口对象" });
return title;
}
function findWindow(title: string): string {
return `$p = Get-Process | Where-Object { $_.MainWindowHandle -ne 0 -and $_.MainWindowTitle -like ${psString(`*${title}*`)} } | Select-Object -First 1; if (-not $p) { throw '找不到窗口:${title.replace(/'/g, "''")}' }`;
}
function parsePoint(candidate: unknown, label: string, nodeId: string, componentId: string): [number, number] {
let parsed: unknown = candidate;
if (typeof parsed === "string") {
const source = parsed;
try { parsed = JSON.parse(source); } catch { parsed = source.split(/[,,xX ]+/).map(Number); }
}
if (!Array.isArray(parsed) || parsed.length < 2 || parsed.slice(0, 2).some((item) => !Number.isFinite(Number(item)))) {
throw new RuntimeError("validation_error", `${label}无效`, { nodeId, componentId });
}
return [Number(parsed[0]), Number(parsed[1])];
}
async function windowObject(title: string): Promise<Record<string, unknown>> {
const output = await powershell(`${WINDOW_API}; ${findWindow(title)}; $r = New-Object RpaWindowExtended+RECT; [RpaWindowExtended]::GetWindowRect($p.MainWindowHandle,[ref]$r) | Out-Null; @{pid=$p.Id;title=$p.MainWindowTitle;hwnd=$p.MainWindowHandle.ToInt64();x=$r.Left;y=$r.Top;width=$r.Right-$r.Left;height=$r.Bottom-$r.Top} | ConvertTo-Json -Compress`);
return JSON.parse(output);
}
const getWindow: CapabilityHandler = async (_ctx, node, args) => windowObject(requiredTitle(args, node.id, node.componentId));
register(["Window_Get", "Window_GetWindow", "Window_GetWindow_v1"], getWindow);
register("Window_ActiveWindow_v1", async (_ctx, node, args) => {
const title = requiredTitle(args, node.id, node.componentId);
const maximize = [true, 1, "1", "true", "True"].includes(args.isMax as never);
await powershell(`${WINDOW_API}; ${findWindow(title)}; [RpaWindowExtended]::SetForegroundWindow($p.MainWindowHandle) | Out-Null; ${maximize ? "[RpaWindowExtended]::ShowWindow($p.MainWindowHandle,3) | Out-Null" : ""}`);
return windowObject(title);
});
register(["Window_GetWindowMessage", "Window_GetWindowMessage_v1"], async (_ctx, node, args) => {
const object = await windowObject(requiredTitle(args, node.id, node.componentId));
const type = value(args, ["switchType"], "all").toLowerCase();
if (type === "title") return object.title;
if (type === "pid") return object.pid;
if (type === "position" || type === "location") return [object.x, object.y];
if (type === "size") return [object.width, object.height];
return object;
});
register(["Window_SetPos", "Window_MoveWindow", "Window_MoveWindow_v1", "Window_SizeWindow"], async (_ctx, node, args) => {
const title = requiredTitle(args, node.id, node.componentId);
const current = await windowObject(title);
const moveOnly = node.componentId.includes("MoveWindow");
const sizeOnly = node.componentId === "Window_SizeWindow";
const position = args.location !== undefined ? parsePoint(args.location, "窗口位置", node.id, node.componentId) : [Number(args.x ?? current.x), Number(args.y ?? current.y)] as [number, number];
const sizeCandidate = args.size ?? args.window_size;
const size = sizeCandidate !== undefined ? parsePoint(sizeCandidate, "窗口尺寸", node.id, node.componentId) : [Number(args.width ?? current.width), Number(args.height ?? current.height)] as [number, number];
const x = sizeOnly ? Number(current.x) : position[0];
const y = sizeOnly ? Number(current.y) : position[1];
const width = moveOnly ? Number(current.width) : size[0];
const height = moveOnly ? Number(current.height) : size[1];
if (!(width > 0 && height > 0)) throw new RuntimeError("validation_error", "窗口尺寸必须大于 0", { nodeId: node.id, componentId: node.componentId });
await powershell(`${WINDOW_API}; ${findWindow(title)}; [RpaWindowExtended]::SetWindowPos($p.MainWindowHandle,[IntPtr]::Zero,${x},${y},${width},${height},4) | Out-Null`);
return { ...(await windowObject(title)), x, y, width, height };
});
const moveMouse: CapabilityHandler = async (_ctx, node, args) => {
const [x, y] = parsePoint(args.location ?? args.position, "鼠标坐标", node.id, node.componentId);
await powershell(`${WINDOW_API}; [RpaWindowExtended]::SetCursorPos(${x},${y}) | Out-Null`);
return { x, y };
};
register(["Mouse_MoveToV1", "Mouse_MoveToV2"], moveMouse);
const dragMouse: CapabilityHandler = async (_ctx, node, args) => {
const [sx, sy] = parsePoint(args.Start ?? args.start, "起始坐标", node.id, node.componentId);
const [ex, ey] = parsePoint(args.End ?? args.end, "结束坐标", node.id, node.componentId);
const right = ["1", "right"].includes(value(args, ["side"], "0").toLowerCase());
const down = right ? 8 : 2;
const up = right ? 16 : 4;
await powershell(`${WINDOW_API}; [RpaWindowExtended]::SetCursorPos(${sx},${sy}) | Out-Null; [RpaWindowExtended]::mouse_event(${down},0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 80; [RpaWindowExtended]::SetCursorPos(${ex},${ey}) | Out-Null; [RpaWindowExtended]::mouse_event(${up},0,0,0,[UIntPtr]::Zero)`);
return { start: [sx, sy], end: [ex, ey] };
};
register(["Mouse_DragToV1", "Mouse_DragToV2", "Mouse_DragToNew"], dragMouse);
register(["Mouse_ClickCombine", "Mouse_ClickCombine_v1", "Mouse_ClickCombine_v2", "Mouse_ClickCombine_v3", "Mouse_ClickCombine_v4"], async (_ctx, node, args) => {
if (value(args, ["target"], "0") !== "0") throw new RuntimeError("validation_error", "组合点击当前仅支持坐标目标", { nodeId: node.id, componentId: node.componentId });
const [x, y] = parsePoint(args.location, "点击坐标", node.id, node.componentId);
const right = value(args, ["click_key"], "left").toLowerCase() === "right";
const doubleClick = value(args, ["click_time"], "click").toLowerCase().includes("double");
const down = right ? 8 : 2;
const up = right ? 16 : 4;
const repeats = doubleClick ? 2 : 1;
await powershell(`${WINDOW_API}; [RpaWindowExtended]::SetCursorPos(${x},${y}) | Out-Null; 1..${repeats} | ForEach-Object { [RpaWindowExtended]::mouse_event(${down},0,0,0,[UIntPtr]::Zero); [RpaWindowExtended]::mouse_event(${up},0,0,0,[UIntPtr]::Zero); Start-Sleep -Milliseconds 80 }`);
return { x, y, button: right ? "right" : "left", clicks: repeats };
});

View File

@ -1,112 +1,75 @@
import { useEffect, useState } from "react";
import { Cpu, Database, FolderCog, HardDrive, Info } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Cpu, Database, FolderCog, Globe2, HardDrive, Info, ShieldCheck } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import type { AppInfo, Catalog } from "@shared/types";
type SettingsTab = "about" | "catalog" | "execution" | "browser";
export function SettingsPage({ catalog }: { catalog: Catalog }) {
const [tab, setTab] = useState<SettingsTab>("about");
const [info, setInfo] = useState<AppInfo | null>(null);
const [handlers, setHandlers] = useState<{ count: number; ids: string[] } | null>(null);
useEffect(() => {
void window.rpa.app.info().then(setInfo).catch(() => setInfo(null));
void window.rpa.run.capabilities().then(setHandlers).catch(() => setHandlers(null));
}, []);
const executable = catalog.components.filter((item) => item.implemented).length;
const groups = useMemo(() => catalog.groups.map((group) => {
const components = catalog.components.filter((component) => component.group === group.id);
const connected = components.filter((component) => component.implemented).length;
return { ...group, connected, pending: components.length - connected };
}), [catalog]);
return (
<div className="settings">
<aside className="panel setting-nav">
<button type="button" className="active">
关于与运行环境
</button>
<button type="button">组件目录</button>
<button type="button">执行与证据</button>
<button type="button">浏览器白名单</button>
<button type="button" className={tab === "about" ? "active" : ""} onClick={() => setTab("about")}>关于与运行环境</button>
<button type="button" className={tab === "catalog" ? "active" : ""} onClick={() => setTab("catalog")}>组件目录</button>
<button type="button" className={tab === "execution" ? "active" : ""} onClick={() => setTab("execution")}>执行与证据</button>
<button type="button" className={tab === "browser" ? "active" : ""} onClick={() => setTab("browser")}>浏览器白名单</button>
</aside>
<section className="panel setting-body">
<div className="setting-card">
<h3>
<Info className="icon" style={{ width: 14, height: 14, display: "inline-block", marginRight: 5 }} />
版本与运行环境
</h3>
<dl className="kv">
<dt>应用版本</dt>
<dd>{info?.version || "0.1.0"}</dd>
<dt>Electron / Chromium</dt>
<dd>
{info?.electron || "-"} / {info?.chrome || "-"}
</dd>
<dt>Node</dt>
<dd>{info?.node || "-"}</dd>
<dt>运行模式</dt>
<dd>{info?.packaged ? "打包运行(离线)" : "开发模式"}</dd>
<dt>数据目录</dt>
<dd>{info?.userData || "-"}</dd>
<dt>目录文件</dt>
<dd>{info?.catalogPath || "-"}</dd>
</dl>
</div>
{tab === "about" && (
<>
<SettingCard icon={Info} title="版本与运行环境">
<dl className="kv"><dt>应用版本</dt><dd>{info?.version || "0.1.0"}</dd><dt>Electron / Chromium</dt><dd>{info?.electron || "-"} / {info?.chrome || "-"}</dd><dt>Node</dt><dd>{info?.node || "-"}</dd><dt>运行模式</dt><dd>{info?.packaged ? "打包运行(离线)" : "开发模式"}</dd><dt>数据目录</dt><dd>{info?.userData || "-"}</dd><dt>目录文件</dt><dd>{info?.catalogPath || "-"}</dd></dl>
</SettingCard>
<SettingCard icon={FolderCog} title="产品边界"><p className="field-hint setting-copy">组件元数据来自项目内 1016 项目录。移动端、机械臂、U 盾、远程桌面、SAP/IE 专用通道、云服务、数据库和 FTP 保持待接入,不随当前版本承诺执行。</p></SettingCard>
</>
)}
<div className="setting-card">
<h3>
<Database className="icon" style={{ width: 14, height: 14, display: "inline-block", marginRight: 5 }} />
组件目录
</h3>
<dl className="kv">
<dt>目录总数</dt>
<dd>{catalog.counts.total} 项</dd>
<dt>厂商组件 / 控制 / 触发</dt>
<dd>
{catalog.counts.vendor} / {catalog.counts.native} / {catalog.counts.triggers}
</dd>
<dt>字段总数</dt>
<dd>{catalog.counts.fields}</dd>
<dt>已接入执行器</dt>
<dd>
{executable} 项({((executable / catalog.counts.total) * 100).toFixed(1)}%)
</dd>
<dt>待接入</dt>
<dd>{catalog.counts.total - executable} 项(灰标展示)</dd>
</dl>
</div>
{tab === "catalog" && (
<>
<SettingCard icon={Database} title="组件接入概览">
<dl className="kv"><dt>目录总数</dt><dd>{catalog.counts.total} 项</dd><dt>已接入执行器</dt><dd>{executable} 项({((executable / catalog.counts.total) * 100).toFixed(1)}%)</dd><dt>待接入</dt><dd>{catalog.counts.total - executable} 项</dd><dt>注册处理器 ID</dt><dd>{handlers?.count ?? "—"}</dd></dl>
</SettingCard>
<div className="setting-card"><h3><Database className="icon" />分类覆盖</h3><div className="setting-group-table">{groups.map((group) => <div key={group.id}><strong>{group.label}</strong><span>{group.connected} 已接入</span><span>{group.pending} 待接入</span><progress max={group.count} value={group.connected} /></div>)}</div></div>
</>
)}
<div className="setting-card">
<h3>
<Cpu className="icon" style={{ width: 14, height: 14, display: "inline-block", marginRight: 5 }} />
执行方式
</h3>
<dl className="kv">
<dt>执行位置</dt>
<dd>本机主进程(无云端依赖)</dd>
<dt>浏览器</dt>
<dd>内嵌 WebContentsView + CDP(Day 2)</dd>
<dt>桌面</dt>
<dd>PowerShell 桥:UIA + SendInput(Day 2)</dd>
<dt>Office</dt>
<dd>exceljs / COM(Day 3)</dd>
</dl>
</div>
{tab === "execution" && (
<>
<SettingCard icon={Cpu} title="执行方式"><dl className="kv"><dt>执行位置</dt><dd>本机主进程</dd><dt>浏览器</dt><dd>独立 Electron 运行窗口 + CDP</dd><dt>桌面</dt><dd>PowerShell / user32 本地桥</dd><dt>Office</dt><dd>ExcelJS / PDF.js / pdf-lib / Word COM</dd><dt>运行前检查</dt><dd>待接入、必填、多出口、断链</dd></dl></SettingCard>
<SettingCard icon={HardDrive} title="数据与证据"><p className="field-hint setting-copy">流程保存到数据目录的 flows/;运行结果、错误和截图写入 runs/&lt;runId&gt;/。任务列表会读取最近一次运行的状态、时间、耗时和失败原因。</p></SettingCard>
<SettingCard icon={ShieldCheck} title="安全边界"><p className="field-hint setting-copy">运行期默认离线。桌面动作只在流程显式运行时执行;未接入能力不会静默成功,而是在运行前检查或节点执行时返回明确错误。</p></SettingCard>
</>
)}
<div className="setting-card">
<h3>
<HardDrive className="icon" style={{ width: 14, height: 14, display: "inline-block", marginRight: 5 }} />
数据与证据
</h3>
<p className="field-hint" style={{ lineHeight: "18px" }}>
流程以 JSON 原子写入数据目录;运行记录与截图将写入 runs/&lt;runId&gt;/。运行期不加载 CDN、字体或云服务。
</p>
</div>
<div className="setting-card">
<h3>
<FolderCog className="icon" style={{ width: 14, height: 14, display: "inline-block", marginRight: 5 }} />
参考与边界
</h3>
<p className="field-hint" style={{ lineHeight: "18px" }}>
组件元数据来自项目内 1016 项目录(997 厂商 + 16 控制 + 3 触发)。厂商代码、二进制与模型不随产品分发;未接入执行器的组件
以灰标展示,运行到该节点会明确报错。
</p>
</div>
{tab === "browser" && (
<>
<SettingCard icon={Globe2} title="浏览器域名白名单"><dl className="kv"><dt>默认允许</dt><dd>127.0.0.1、localhost、example.com</dd><dt>扩展方式</dt><dd>环境变量 RPA_ALLOWED_DOMAINS</dd><dt>格式</dt><dd>逗号分隔域名,不含协议和路径</dd></dl></SettingCard>
<SettingCard icon={ShieldCheck} title="限制说明"><p className="field-hint setting-copy">导航只允许 HTTP/HTTPS。未列入白名单的域名会返回 blocked_url;文件协议、脚本协议和任意本地路径不会由浏览器导航组件打开。</p></SettingCard>
</>
)}
</section>
</div>
);
}
function SettingCard({ icon: Icon, title, children }: { icon: LucideIcon; title: string; children: ReactNode }) {
return <div className="setting-card"><h3><Icon className="icon" />{title}</h3>{children}</div>;
}

View File

@ -1653,6 +1653,39 @@ textarea:focus-visible {
margin-bottom: 9px;
}
.setting-card h3 .icon {
width: 14px;
height: 14px;
display: inline-block;
margin-right: 6px;
vertical-align: -2px;
}
.setting-copy {
line-height: 19px;
max-width: 880px;
}
.setting-group-table {
display: grid;
gap: 2px;
}
.setting-group-table > div {
min-height: 42px;
display: grid;
grid-template-columns: minmax(150px, 1fr) 90px 90px minmax(120px, 1fr);
gap: 12px;
align-items: center;
border-bottom: 1px solid var(--line);
font-size: 10.5px;
}
.setting-group-table > div:last-child { border-bottom: 0; }
.setting-group-table strong { font-size: 11px; }
.setting-group-table span { color: var(--muted); }
.setting-group-table progress { width: 100%; height: 7px; accent-color: var(--blue); }
.kv {
display: grid;
grid-template-columns: 150px minmax(0, 1fr);