Wire device execution mode controls into UI

This commit is contained in:
kris
2026-04-06 11:10:51 +08:00
parent 27ab594921
commit 43c733069c
11 changed files with 1033 additions and 19 deletions

View File

@@ -37,7 +37,7 @@ import type {
UserProfile,
UserSettings,
} from "@/lib/boss-data";
import type { ConversationItem } from "@/lib/boss-projections";
import type { ConversationItem, DeviceWorkspaceView } from "@/lib/boss-projections";
import { formatTimestampLabel } from "@/lib/boss-projections";
function formatClock(value: string) {
@@ -55,6 +55,40 @@ function boundDeviceIdFromDom() {
return document.body.dataset.boundDeviceId || "mac-studio";
}
export function buildDeviceWorkspaceDetailCards(workspace: DeviceWorkspaceView) {
const selectedDevice = workspace.selectedDevice;
const primaryPolicy = workspace.projectExecutionPolicies?.[0];
return {
capabilities: {
title: "执行能力",
items: {
gui: `GUI${selectedDevice?.capabilities?.gui?.connected ? "已连接" : "未连接"}`,
cli: `CLI${selectedDevice?.capabilities?.cli?.connected ? "已连接" : "未连接"}`,
preferredExecutionMode: `默认执行模式:${
selectedDevice?.preferredExecutionMode === "gui"
? "GUI"
: selectedDevice?.preferredExecutionMode === "cli"
? "CLI"
: "未知"
}`,
},
},
conflicts: {
title: "异常项目 / 文件夹冲突",
scopeLabel: "仅作用于当前异常项目 / 文件夹",
actions: ["禁止", "允许本次", "永久放行"],
items: {
device: `设备:${selectedDevice?.name ?? selectedDevice?.id ?? "未知设备"}`,
folderKey: `文件夹:${primaryPolicy?.folderKey ?? "暂无"}`,
projectId: `项目:${primaryPolicy?.projectId ?? "暂无"}`,
allowPolicy: `当前策略:${primaryPolicy?.allowPolicy ?? "暂无"}`,
conflictState: `冲突态:${primaryPolicy?.conflictState ?? "暂无"}`,
},
},
};
}
async function waitForLoginSessionReady(nativeClient: boolean) {
for (let attempt = 0; attempt < 5; attempt += 1) {
const response = await fetch("/api/auth/session", {
@@ -560,16 +594,23 @@ export function DeviceEditorCard({
device,
relatedThreads,
activeEnrollment,
workspace,
}: {
device: Device;
relatedThreads: ThreadContextSnapshot[];
activeEnrollment?: DeviceEnrollment;
workspace: DeviceWorkspaceView;
}) {
const router = useRouter();
const detailCards = buildDeviceWorkspaceDetailCards(workspace);
const primaryPolicy = workspace.projectExecutionPolicies?.[0];
const [name, setName] = useState(device.name);
const [avatar, setAvatar] = useState(device.avatar);
const [account, setAccount] = useState(device.account);
const [status, setStatus] = useState<Device["status"]>(device.status);
const [preferredExecutionMode, setPreferredExecutionMode] = useState<
Device["preferredExecutionMode"]
>(device.preferredExecutionMode ?? "cli");
const [endpoint, setEndpoint] = useState(device.endpoint ?? "");
const [note, setNote] = useState(device.note ?? "");
const [projects, setProjects] = useState(device.projects.join(", "));
@@ -586,6 +627,7 @@ export function DeviceEditorCard({
status,
endpoint,
note,
preferredExecutionMode,
projects: projects
.split(",")
.map((item) => item.trim())
@@ -597,6 +639,25 @@ export function DeviceEditorCard({
if (result.ok) router.refresh();
}
async function saveConflictDecision(decision: "forbid" | "allow_once" | "allow_always") {
if (!primaryPolicy?.projectId) {
setMessage("当前没有可操作的异常项目 / 文件夹。");
return;
}
const response = await fetch(`/api/v1/devices/${device.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectId: primaryPolicy.projectId,
folderKey: primaryPolicy.folderKey,
conflictDecision: decision,
}),
});
const result = (await response.json()) as { ok: boolean; message?: string };
setMessage(result.ok ? "冲突策略已更新。" : result.message ?? "更新失败");
if (result.ok) router.refresh();
}
return (
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="flex items-center justify-between">
@@ -607,10 +668,88 @@ export function DeviceEditorCard({
<Field label="设备名称" value={name} onChange={setName} />
<Field label="头像缩写" value={avatar} onChange={setAvatar} />
</div>
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="text-[16px] font-semibold text-[#111111]">{detailCards.capabilities.title}</div>
<div className="grid gap-2 text-[13px] leading-6 text-[#57606A]">
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">{detailCards.capabilities.items.gui}</div>
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">{detailCards.capabilities.items.cli}</div>
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">
{detailCards.capabilities.items.preferredExecutionMode}
</div>
</div>
<div className="space-y-1">
<div className="text-[12px] text-[#8C8C8C]"></div>
<div className="flex gap-2">
{(["gui", "cli"] as const).map((mode) => (
<button
key={mode}
type="button"
onClick={() => setPreferredExecutionMode(mode)}
className={clsx(
"rounded-full px-3 py-2 text-[12px] font-semibold",
preferredExecutionMode === mode
? "bg-[#07C160] text-white"
: "bg-[#F5F5F7] text-[#57606A]",
)}
>
{mode === "gui" ? "GUI" : "CLI"}
</button>
))}
</div>
</div>
</div>
<Field label="账号" value={account} onChange={setAccount} />
<Field label="Endpoint" value={endpoint} onChange={setEndpoint} />
<Field label="备注" value={note} onChange={setNote} />
<Field label="项目列表(逗号分隔)" value={projects} onChange={setProjects} />
<div className="space-y-3 rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div className="text-[16px] font-semibold text-[#111111]">{detailCards.conflicts.title}</div>
<div className="text-[12px] text-[#8C8C8C]"></div>
</div>
<div className="grid gap-2 text-[13px] leading-6 text-[#57606A]">
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">{detailCards.conflicts.items.device}</div>
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">{detailCards.conflicts.items.folderKey}</div>
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">{detailCards.conflicts.items.projectId}</div>
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">{detailCards.conflicts.items.allowPolicy}</div>
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">{detailCards.conflicts.items.conflictState}</div>
</div>
<div className="text-[12px] text-[#8C8C8C]">{detailCards.conflicts.scopeLabel}</div>
{primaryPolicy ? (
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => void saveConflictDecision("forbid")}
className={clsx(
"rounded-full px-3 py-2 text-[12px] font-semibold",
primaryPolicy.allowPolicy === "forbid" ? "bg-[#111111] text-white" : "bg-[#F5F5F7] text-[#57606A]",
)}
>
{detailCards.conflicts.actions[0]}
</button>
<button
type="button"
onClick={() => void saveConflictDecision("allow_once")}
className={clsx(
"rounded-full px-3 py-2 text-[12px] font-semibold",
primaryPolicy.allowPolicy === "allow_once" ? "bg-[#07C160] text-white" : "bg-[#F5F5F7] text-[#57606A]",
)}
>
{detailCards.conflicts.actions[1]}
</button>
<button
type="button"
onClick={() => void saveConflictDecision("allow_always")}
className={clsx(
"rounded-full px-3 py-2 text-[12px] font-semibold",
primaryPolicy.allowPolicy === "allow_always" ? "bg-[#2563EB] text-white" : "bg-[#F5F5F7] text-[#57606A]",
)}
>
{detailCards.conflicts.actions[2]}
</button>
</div>
) : null}
</div>
<div className="space-y-1">
<div className="text-[12px] text-[#8C8C8C]"></div>
<div className="flex gap-2">