feat: ship enterprise control and desktop governance
This commit is contained in:
599
src/components/access-management-client.tsx
Normal file
599
src/components/access-management-client.tsx
Normal file
@@ -0,0 +1,599 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import clsx from "clsx";
|
||||
import type {
|
||||
AccountDeviceGrant,
|
||||
AccountProjectGrant,
|
||||
AccountSkillGrant,
|
||||
AuthAccount,
|
||||
AuthRole,
|
||||
BossPermission,
|
||||
Device,
|
||||
Project,
|
||||
} from "@/lib/boss-data";
|
||||
|
||||
type PublicAuthAccount = Omit<AuthAccount, "passwordHash">;
|
||||
type SkillCatalogItem = {
|
||||
name: string;
|
||||
invocation: string;
|
||||
description: string;
|
||||
deviceCount: number;
|
||||
devices: Array<{
|
||||
skillId: string;
|
||||
deviceId: string;
|
||||
path: string;
|
||||
category: string;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
};
|
||||
type PermissionTemplate = {
|
||||
templateId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
devicePermissions: BossPermission[];
|
||||
projectPermissions: BossPermission[];
|
||||
skillPermissions: BossPermission[];
|
||||
};
|
||||
type AccessManagementView = {
|
||||
accounts: PublicAuthAccount[];
|
||||
devices: Device[];
|
||||
projects: Project[];
|
||||
skills: Array<SkillCatalogItem["devices"][number] & {
|
||||
name: string;
|
||||
description: string;
|
||||
invocation: string;
|
||||
}>;
|
||||
skillCatalog: SkillCatalogItem[];
|
||||
permissionTemplates: PermissionTemplate[];
|
||||
grants: {
|
||||
devices: AccountDeviceGrant[];
|
||||
projects: AccountProjectGrant[];
|
||||
skills: AccountSkillGrant[];
|
||||
};
|
||||
auditLogs: Array<{
|
||||
auditId: string;
|
||||
actorAccount: string;
|
||||
action: string;
|
||||
targetAccount?: string;
|
||||
deviceId?: string;
|
||||
projectId?: string;
|
||||
skillId?: string;
|
||||
permissions?: BossPermission[];
|
||||
createdAt: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const devicePermissions: BossPermission[] = ["device.view", "device.manage", "computer.control"];
|
||||
const projectPermissions: BossPermission[] = [
|
||||
"project.view",
|
||||
"thread.chat",
|
||||
"master_agent.ask",
|
||||
"master_agent.takeover",
|
||||
"computer.control",
|
||||
];
|
||||
const skillPermissions: BossPermission[] = ["skill.view", "skill.use", "skill.manage"];
|
||||
|
||||
function Section({ title, note, children }: { title: string; note?: string; children: ReactNode }) {
|
||||
return (
|
||||
<section className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="text-[16px] font-semibold text-[#111111]">{title}</div>
|
||||
{note ? <div className="mt-1 text-[12px] leading-5 text-[#8C8C8C]">{note}</div> : null}
|
||||
<div className="mt-4 space-y-3">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TextField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
secret = false,
|
||||
type = "text",
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
secret?: boolean;
|
||||
type?: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[12px] font-medium text-[#57606A]">{label}</span>
|
||||
<input
|
||||
type={secret ? "password" : type}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full rounded-2xl border border-[#E5E5EA] bg-[#F7F8FA] px-4 py-3 text-[14px] text-[#111111] outline-none transition focus:border-[#07C160] focus:bg-white"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
}) {
|
||||
return (
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-[12px] font-medium text-[#57606A]">{label}</span>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="w-full rounded-2xl border border-[#E5E5EA] bg-[#F7F8FA] px-4 py-3 text-[14px] text-[#111111] outline-none transition focus:border-[#07C160] focus:bg-white"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionPicker({
|
||||
values,
|
||||
selected,
|
||||
onChange,
|
||||
}: {
|
||||
values: BossPermission[];
|
||||
selected: BossPermission[];
|
||||
onChange: (permissions: BossPermission[]) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{values.map((permission) => {
|
||||
const active = selected.includes(permission);
|
||||
return (
|
||||
<button
|
||||
key={permission}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onChange(active ? selected.filter((item) => item !== permission) : [...selected, permission])
|
||||
}
|
||||
className={clsx(
|
||||
"rounded-full px-3 py-2 text-[12px] font-semibold transition",
|
||||
active ? "bg-[#07C160] text-white" : "bg-[#F5F5F7] text-[#57606A]",
|
||||
)}
|
||||
>
|
||||
{permission}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function grantLabel(grant: AccountDeviceGrant | AccountProjectGrant | AccountSkillGrant) {
|
||||
if ("skillId" in grant) {
|
||||
return `${grant.account} · Skill ${grant.skillId}`;
|
||||
}
|
||||
if ("projectId" in grant) {
|
||||
return `${grant.account} · 项目 ${grant.projectId}`;
|
||||
}
|
||||
return `${grant.account} · 设备 ${grant.deviceId}`;
|
||||
}
|
||||
|
||||
function formatGrantExpiry(expiresAt?: string) {
|
||||
if (!expiresAt) {
|
||||
return { label: "永久有效", expired: false };
|
||||
}
|
||||
const expiresAtMs = new Date(expiresAt).getTime();
|
||||
if (Number.isNaN(expiresAtMs)) {
|
||||
return { label: `有效期:${expiresAt}`, expired: false };
|
||||
}
|
||||
return {
|
||||
label: `${expiresAtMs <= Date.now() ? "已过期" : "有效至"}:${new Date(expiresAtMs).toLocaleString("zh-CN", {
|
||||
hour12: false,
|
||||
})}`,
|
||||
expired: expiresAtMs <= Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function formatAuditTarget(log: AccessManagementView["auditLogs"][number]) {
|
||||
return [
|
||||
log.targetAccount ? `账号 ${log.targetAccount}` : "",
|
||||
log.deviceId ? `设备 ${log.deviceId}` : "",
|
||||
log.projectId ? `项目 ${log.projectId}` : "",
|
||||
log.skillId ? `Skill ${log.skillId}` : "",
|
||||
].filter(Boolean).join(" · ") || "系统";
|
||||
}
|
||||
|
||||
export function AccessManagementClient({ initialView }: { initialView: AccessManagementView }) {
|
||||
const router = useRouter();
|
||||
const [view, setView] = useState(initialView);
|
||||
const [busy, setBusy] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [accountDraft, setAccountDraft] = useState({
|
||||
account: "",
|
||||
displayName: "",
|
||||
role: "member" as AuthRole,
|
||||
password: "",
|
||||
});
|
||||
const [deviceDraft, setDeviceDraft] = useState({
|
||||
account: "",
|
||||
deviceId: initialView.devices[0]?.id ?? "",
|
||||
permissions: ["device.view"] as BossPermission[],
|
||||
expiresAt: "",
|
||||
});
|
||||
const [projectDraft, setProjectDraft] = useState({
|
||||
account: "",
|
||||
projectId: initialView.projects[0]?.id ?? "",
|
||||
permissions: ["project.view"] as BossPermission[],
|
||||
expiresAt: "",
|
||||
});
|
||||
const [skillDraft, setSkillDraft] = useState({
|
||||
account: "",
|
||||
skillId: initialView.skills[0]?.skillId ?? "",
|
||||
deviceId: initialView.skills[0]?.deviceId ?? "",
|
||||
permissions: ["skill.view", "skill.use"] as BossPermission[],
|
||||
expiresAt: "",
|
||||
});
|
||||
const [templateDraft, setTemplateDraft] = useState({
|
||||
account: "",
|
||||
templateId: initialView.permissionTemplates.find((item) => item.templateId === "developer")?.templateId ??
|
||||
initialView.permissionTemplates[0]?.templateId ?? "",
|
||||
deviceId: initialView.devices[0]?.id ?? "",
|
||||
projectId: initialView.projects[0]?.id ?? "",
|
||||
skillId: initialView.skills[0]?.skillId ?? "",
|
||||
});
|
||||
|
||||
const accountOptions = useMemo(
|
||||
() => view.accounts.map((account) => ({ value: account.account, label: `${account.displayName} · ${account.account}` })),
|
||||
[view.accounts],
|
||||
);
|
||||
const deviceOptions = useMemo(
|
||||
() => view.devices.map((device) => ({ value: device.id, label: `${device.name} · ${device.id}` })),
|
||||
[view.devices],
|
||||
);
|
||||
const projectOptions = useMemo(
|
||||
() => view.projects.map((project) => ({ value: project.id, label: `${project.name} · ${project.id}` })),
|
||||
[view.projects],
|
||||
);
|
||||
const skillOptions = useMemo(
|
||||
() => view.skills.map((skill) => ({ value: skill.skillId, label: `${skill.name} · ${skill.deviceId}` })),
|
||||
[view.skills],
|
||||
);
|
||||
const templateOptions = useMemo(
|
||||
() => view.permissionTemplates.map((template) => ({
|
||||
value: template.templateId,
|
||||
label: template.name,
|
||||
})),
|
||||
[view.permissionTemplates],
|
||||
);
|
||||
const selectedTemplate = view.permissionTemplates.find((template) => template.templateId === templateDraft.templateId);
|
||||
|
||||
async function refreshView() {
|
||||
const response = await fetch("/api/v1/admin/access", { cache: "no-store" });
|
||||
const result = (await response.json()) as AccessManagementView & { ok: boolean; message?: string };
|
||||
if (!response.ok || !result.ok) {
|
||||
throw new Error(result.message ?? "刷新失败");
|
||||
}
|
||||
setView(result);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function submit(action: string, body: Record<string, unknown>) {
|
||||
setBusy(action);
|
||||
setMessage("");
|
||||
try {
|
||||
const response = await fetch("/api/v1/admin/access", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action, ...body }),
|
||||
});
|
||||
const result = (await response.json()) as { ok: boolean; message?: string };
|
||||
if (!response.ok || !result.ok) {
|
||||
throw new Error(result.message ?? "保存失败");
|
||||
}
|
||||
await refreshView();
|
||||
setMessage("已保存。");
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : "操作失败");
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
function fillAccountForGrants(account: string) {
|
||||
setDeviceDraft((draft) => ({ ...draft, account }));
|
||||
setProjectDraft((draft) => ({ ...draft, account }));
|
||||
setSkillDraft((draft) => ({ ...draft, account }));
|
||||
setTemplateDraft((draft) => ({ ...draft, account }));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Section title="子账号" note="创建或更新子账号。修改密码会让该账号的旧登录态失效。">
|
||||
<div className="grid gap-3">
|
||||
<TextField
|
||||
label="账号"
|
||||
value={accountDraft.account}
|
||||
onChange={(account) => {
|
||||
setAccountDraft((draft) => ({ ...draft, account }));
|
||||
fillAccountForGrants(account);
|
||||
}}
|
||||
placeholder="worker@example.com"
|
||||
/>
|
||||
<TextField
|
||||
label="显示名"
|
||||
value={accountDraft.displayName}
|
||||
onChange={(displayName) => setAccountDraft((draft) => ({ ...draft, displayName }))}
|
||||
placeholder="项目协作者"
|
||||
/>
|
||||
<SelectField
|
||||
label="角色"
|
||||
value={accountDraft.role}
|
||||
onChange={(role) => setAccountDraft((draft) => ({ ...draft, role: role as AuthRole }))}
|
||||
options={[
|
||||
{ value: "member", label: "成员" },
|
||||
{ value: "admin", label: "管理员" },
|
||||
]}
|
||||
/>
|
||||
<TextField
|
||||
label="初始密码 / 新密码"
|
||||
value={accountDraft.password}
|
||||
onChange={(password) => setAccountDraft((draft) => ({ ...draft, password }))}
|
||||
placeholder="创建账号时必填"
|
||||
secret
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void submit("upsert_account", accountDraft)}
|
||||
disabled={busy === "upsert_account"}
|
||||
className="rounded-full bg-[#07C160] px-4 py-3 text-[14px] font-semibold text-white disabled:opacity-50"
|
||||
>
|
||||
{busy === "upsert_account" ? "保存中" : "保存子账号"}
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{view.permissionTemplates.length > 0 ? (
|
||||
<Section title="权限模板" note="一次性给账号分配设备、项目和 Skill 权限,适合批量开通子账号。">
|
||||
<SelectField
|
||||
label="账号"
|
||||
value={templateDraft.account}
|
||||
onChange={(account) => setTemplateDraft((draft) => ({ ...draft, account }))}
|
||||
options={[{ value: "", label: "选择账号" }, ...accountOptions]}
|
||||
/>
|
||||
<SelectField
|
||||
label="模板"
|
||||
value={templateDraft.templateId}
|
||||
onChange={(templateId) => setTemplateDraft((draft) => ({ ...draft, templateId }))}
|
||||
options={templateOptions}
|
||||
/>
|
||||
{selectedTemplate ? (
|
||||
<div className="rounded-2xl bg-[#F1FFF7] px-4 py-3 text-[12px] leading-5 text-[#57606A]">
|
||||
{selectedTemplate.description}
|
||||
</div>
|
||||
) : null}
|
||||
<SelectField
|
||||
label="设备"
|
||||
value={templateDraft.deviceId}
|
||||
onChange={(deviceId) => setTemplateDraft((draft) => ({ ...draft, deviceId }))}
|
||||
options={[{ value: "", label: "不授权设备" }, ...deviceOptions]}
|
||||
/>
|
||||
<SelectField
|
||||
label="项目"
|
||||
value={templateDraft.projectId}
|
||||
onChange={(projectId) => setTemplateDraft((draft) => ({ ...draft, projectId }))}
|
||||
options={[{ value: "", label: "不授权项目" }, ...projectOptions]}
|
||||
/>
|
||||
<SelectField
|
||||
label="Skill"
|
||||
value={templateDraft.skillId}
|
||||
onChange={(skillId) => setTemplateDraft((draft) => ({ ...draft, skillId }))}
|
||||
options={[{ value: "", label: "不分配 Skill" }, ...skillOptions]}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void submit("apply_template", {
|
||||
account: templateDraft.account,
|
||||
templateId: templateDraft.templateId,
|
||||
deviceIds: templateDraft.deviceId ? [templateDraft.deviceId] : [],
|
||||
projectIds: templateDraft.projectId ? [templateDraft.projectId] : [],
|
||||
skillIds: templateDraft.skillId ? [templateDraft.skillId] : [],
|
||||
})}
|
||||
disabled={busy === "apply_template"}
|
||||
className="rounded-full bg-[#07C160] px-4 py-3 text-[14px] font-semibold text-white disabled:opacity-50"
|
||||
>
|
||||
{busy === "apply_template" ? "套用中" : "套用模板"}
|
||||
</button>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
<Section title="设备权限" note="设备只读权限会带出该设备下项目的只读可见性,但不会自动允许聊天或接管。">
|
||||
<SelectField
|
||||
label="账号"
|
||||
value={deviceDraft.account}
|
||||
onChange={(account) => setDeviceDraft((draft) => ({ ...draft, account }))}
|
||||
options={[{ value: "", label: "选择账号" }, ...accountOptions]}
|
||||
/>
|
||||
<SelectField
|
||||
label="设备"
|
||||
value={deviceDraft.deviceId}
|
||||
onChange={(deviceId) => setDeviceDraft((draft) => ({ ...draft, deviceId }))}
|
||||
options={deviceOptions}
|
||||
/>
|
||||
<PermissionPicker
|
||||
values={devicePermissions}
|
||||
selected={deviceDraft.permissions}
|
||||
onChange={(permissions) => setDeviceDraft((draft) => ({ ...draft, permissions }))}
|
||||
/>
|
||||
<TextField
|
||||
label="有效期"
|
||||
value={deviceDraft.expiresAt}
|
||||
onChange={(expiresAt) => setDeviceDraft((draft) => ({ ...draft, expiresAt }))}
|
||||
placeholder="留空表示永久,例如 2026-05-01T18:00:00+08:00"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void submit("grant_device", deviceDraft)}
|
||||
disabled={busy === "grant_device"}
|
||||
className="rounded-full bg-[#111111] px-4 py-3 text-[14px] font-semibold text-white disabled:opacity-50"
|
||||
>
|
||||
授权设备
|
||||
</button>
|
||||
</Section>
|
||||
|
||||
<Section title="项目权限" note="聊天、主 Agent 问询、接管和电脑控制都在这里显式授权。">
|
||||
<SelectField
|
||||
label="账号"
|
||||
value={projectDraft.account}
|
||||
onChange={(account) => setProjectDraft((draft) => ({ ...draft, account }))}
|
||||
options={[{ value: "", label: "选择账号" }, ...accountOptions]}
|
||||
/>
|
||||
<SelectField
|
||||
label="项目"
|
||||
value={projectDraft.projectId}
|
||||
onChange={(projectId) => setProjectDraft((draft) => ({ ...draft, projectId }))}
|
||||
options={projectOptions}
|
||||
/>
|
||||
<PermissionPicker
|
||||
values={projectPermissions}
|
||||
selected={projectDraft.permissions}
|
||||
onChange={(permissions) => setProjectDraft((draft) => ({ ...draft, permissions }))}
|
||||
/>
|
||||
<TextField
|
||||
label="有效期"
|
||||
value={projectDraft.expiresAt}
|
||||
onChange={(expiresAt) => setProjectDraft((draft) => ({ ...draft, expiresAt }))}
|
||||
placeholder="留空表示永久,例如 2026-05-01T18:00:00+08:00"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void submit("grant_project", projectDraft)}
|
||||
disabled={busy === "grant_project"}
|
||||
className="rounded-full bg-[#111111] px-4 py-3 text-[14px] font-semibold text-white disabled:opacity-50"
|
||||
>
|
||||
授权项目
|
||||
</button>
|
||||
</Section>
|
||||
|
||||
<Section title="Skill 分配" note="同名 Skill 会按不同电脑聚合;授权时仍精确到某台设备上的某个 Skill。">
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-5 text-[#57606A]">
|
||||
当前 Skill 目录:{view.skillCatalog.length} 类,覆盖{" "}
|
||||
{view.skillCatalog.reduce((sum, item) => sum + item.deviceCount, 0)} 个设备实例。
|
||||
</div>
|
||||
<SelectField
|
||||
label="账号"
|
||||
value={skillDraft.account}
|
||||
onChange={(account) => setSkillDraft((draft) => ({ ...draft, account }))}
|
||||
options={[{ value: "", label: "选择账号" }, ...accountOptions]}
|
||||
/>
|
||||
<SelectField
|
||||
label="Skill"
|
||||
value={skillDraft.skillId}
|
||||
onChange={(skillId) => {
|
||||
const skill = view.skills.find((item) => item.skillId === skillId);
|
||||
setSkillDraft((draft) => ({ ...draft, skillId, deviceId: skill?.deviceId ?? draft.deviceId }));
|
||||
}}
|
||||
options={skillOptions}
|
||||
/>
|
||||
<PermissionPicker
|
||||
values={skillPermissions}
|
||||
selected={skillDraft.permissions}
|
||||
onChange={(permissions) => setSkillDraft((draft) => ({ ...draft, permissions }))}
|
||||
/>
|
||||
<TextField
|
||||
label="有效期"
|
||||
value={skillDraft.expiresAt}
|
||||
onChange={(expiresAt) => setSkillDraft((draft) => ({ ...draft, expiresAt }))}
|
||||
placeholder="留空表示永久,例如 2026-05-01T18:00:00+08:00"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void submit("grant_skill", skillDraft)}
|
||||
disabled={busy === "grant_skill"}
|
||||
className="rounded-full bg-[#111111] px-4 py-3 text-[14px] font-semibold text-white disabled:opacity-50"
|
||||
>
|
||||
分配 Skill
|
||||
</button>
|
||||
</Section>
|
||||
|
||||
<Section title="已授权" note="撤销只影响当前这条授权,不会全局回收其他项目或设备能力。">
|
||||
{[...view.grants.devices, ...view.grants.projects, ...view.grants.skills].length === 0 ? (
|
||||
<div className="text-[13px] text-[#8C8C8C]">暂无授权。</div>
|
||||
) : (
|
||||
[...view.grants.devices, ...view.grants.projects, ...view.grants.skills].map((grant) => {
|
||||
const expiry = formatGrantExpiry(grant.expiresAt);
|
||||
return (
|
||||
<div key={grant.grantId} className="rounded-2xl bg-[#F7F8FA] px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[14px] font-semibold text-[#111111]">{grantLabel(grant)}</div>
|
||||
<div className="mt-1 text-[12px] leading-5 text-[#57606A]">
|
||||
{grant.permissions.join(" / ")}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={clsx(
|
||||
"shrink-0 rounded-full px-2.5 py-1 text-[11px] font-semibold",
|
||||
expiry.expired ? "bg-[#FFF1F0] text-[#FF3B30]" : "bg-[#EFFFF6] text-[#07A34A]",
|
||||
)}
|
||||
>
|
||||
{expiry.label}
|
||||
</span>
|
||||
</div>
|
||||
{grant.note ? (
|
||||
<div className="mt-2 text-[12px] leading-5 text-[#8C8C8C]">备注:{grant.note}</div>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void submit("revoke_grant", { grantId: grant.grantId })}
|
||||
className="mt-3 text-[13px] font-semibold text-[#FF3B30]"
|
||||
>
|
||||
撤销授权
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<Section title="审计记录" note="只展示最近的授权、撤销和模板操作,便于回溯谁给谁开了什么权限。">
|
||||
{view.auditLogs.length === 0 ? (
|
||||
<div className="text-[13px] text-[#8C8C8C]">暂无审计记录。</div>
|
||||
) : (
|
||||
view.auditLogs.slice(0, 20).map((log) => (
|
||||
<div key={log.auditId} className="rounded-2xl bg-[#F7F8FA] px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[14px] font-semibold text-[#111111]">
|
||||
{log.action} · {formatAuditTarget(log)}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] leading-5 text-[#57606A]">
|
||||
操作人:{log.actorAccount}
|
||||
{log.permissions?.length ? ` · ${log.permissions.join(" / ")}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right text-[11px] leading-4 text-[#8C8C8C]">
|
||||
{new Date(log.createdAt).toLocaleString("zh-CN", { hour12: false })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{message ? (
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-3 text-[13px] text-[#57606A]">
|
||||
{message}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1048
src/components/admin/admin-access-panel.tsx
Normal file
1048
src/components/admin/admin-access-panel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
900
src/components/admin/admin-skill-lifecycle-panel.tsx
Normal file
900
src/components/admin/admin-skill-lifecycle-panel.tsx
Normal file
@@ -0,0 +1,900 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
AutoComplete,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
|
||||
const skillLifecycleRequestsEndpoint = "/api/v1/admin/skills/requests";
|
||||
const adminAccessEndpoint = "/api/v1/admin/access";
|
||||
|
||||
export type AdminSkillLifecycleAction = "install" | "update" | "uninstall" | "rollback" | "version_lock";
|
||||
|
||||
export type AdminSkillLifecycleDevice = {
|
||||
id?: string;
|
||||
deviceId?: string;
|
||||
name?: string;
|
||||
deviceName?: string;
|
||||
status?: string;
|
||||
onlineStatus?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type AdminSkillLifecycleSkill = {
|
||||
skillId: string;
|
||||
deviceId?: string;
|
||||
name?: string;
|
||||
invocation?: string;
|
||||
description?: string;
|
||||
version?: string;
|
||||
category?: string;
|
||||
path?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type AdminSkillLifecycleRequest = {
|
||||
requestId?: string;
|
||||
id?: string;
|
||||
action?: AdminSkillLifecycleAction | string;
|
||||
status?: string;
|
||||
deviceId?: string;
|
||||
skillId?: string;
|
||||
sourceUrl?: string;
|
||||
trustedSource?: string;
|
||||
trustedSourceId?: string;
|
||||
checksum?: string;
|
||||
expectedChecksum?: string;
|
||||
targetVersion?: string;
|
||||
rollbackToVersion?: string;
|
||||
lockedVersion?: string;
|
||||
requestedBy?: string;
|
||||
requestedAt?: string;
|
||||
claimedByDeviceId?: string;
|
||||
claimedAt?: string;
|
||||
completedAt?: string;
|
||||
updatedAt?: string;
|
||||
note?: string;
|
||||
resultSummary?: string;
|
||||
error?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type AdminSkillLifecycleTrustedSource = {
|
||||
id?: string;
|
||||
trustedSourceId?: string;
|
||||
name?: string;
|
||||
label?: string;
|
||||
sourceUrl?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type AdminSkillLifecycleCatalogDevice = {
|
||||
skillId: string;
|
||||
deviceId: string;
|
||||
path?: string;
|
||||
category?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type AdminSkillLifecycleCatalogItem = {
|
||||
name: string;
|
||||
invocation?: string;
|
||||
description?: string;
|
||||
deviceCount?: number;
|
||||
devices?: AdminSkillLifecycleCatalogDevice[];
|
||||
};
|
||||
|
||||
export type AdminSkillLifecyclePanelProps = {
|
||||
devices?: AdminSkillLifecycleDevice[];
|
||||
skills?: AdminSkillLifecycleSkill[];
|
||||
skillCatalog?: AdminSkillLifecycleCatalogItem[];
|
||||
initialRequests?: AdminSkillLifecycleRequest[];
|
||||
initialLifecycleRequests?: AdminSkillLifecycleRequest[];
|
||||
trustedSources?: AdminSkillLifecycleTrustedSource[];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
type LifecycleFormValues = {
|
||||
action: AdminSkillLifecycleAction;
|
||||
deviceId?: string;
|
||||
skillId?: string;
|
||||
sourceUrl?: string;
|
||||
trustedSource?: string;
|
||||
trustedSourceId?: string;
|
||||
checksum?: string;
|
||||
expectedChecksum?: string;
|
||||
targetVersion?: string;
|
||||
rollbackToVersion?: string;
|
||||
lockedVersion?: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
const lifecycleActions = [
|
||||
{ value: "install", label: "安装", color: "green", description: "从 sourceUrl 或 trustedSourceId 安装新 Skill。" },
|
||||
{ value: "update", label: "更新", color: "blue", description: "更新设备上已有 Skill,可指定版本或远程来源。" },
|
||||
{ value: "uninstall", label: "卸载", color: "red", description: "卸载设备上已有 Skill,设备端会先做备份。" },
|
||||
{ value: "rollback", label: "回滚", color: "orange", description: "回滚到指定历史版本。" },
|
||||
{ value: "version_lock", label: "版本锁定", color: "purple", description: "锁定 Skill 到指定版本,写入设备端版本锁。" },
|
||||
] satisfies Array<{
|
||||
value: AdminSkillLifecycleAction;
|
||||
label: string;
|
||||
color: string;
|
||||
description: string;
|
||||
}>;
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
pending: "gold",
|
||||
accepted: "cyan",
|
||||
running: "blue",
|
||||
completed: "green",
|
||||
failed: "red",
|
||||
canceled: "default",
|
||||
};
|
||||
const adminDense = "adminDense";
|
||||
const adminCard = "boss-admin-card border-[#E9ECE9] shadow-[0_10px_36px_rgba(0,0,0,0.035)]";
|
||||
const panelSubtitle = "Skill 生命周期请求与执行结果";
|
||||
|
||||
function text(value: unknown, fallback = "-") {
|
||||
if (value === null || value === undefined || value === "") return fallback;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function trimmed(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function deviceIdOf(device: AdminSkillLifecycleDevice) {
|
||||
return text(device.id ?? device.deviceId, "");
|
||||
}
|
||||
|
||||
function deviceLabel(device: AdminSkillLifecycleDevice) {
|
||||
const id = deviceIdOf(device);
|
||||
const name = text(device.name ?? device.deviceName ?? id, id || "未命名设备");
|
||||
const status = text(device.status ?? device.onlineStatus, "");
|
||||
return status ? `${name} · ${id} · ${status}` : `${name} · ${id}`;
|
||||
}
|
||||
|
||||
function skillLabel(skill: AdminSkillLifecycleSkill) {
|
||||
const version = text(skill.version, "");
|
||||
const title = text(skill.name ?? skill.invocation ?? skill.skillId, skill.skillId);
|
||||
return version ? `${title} · ${skill.skillId} · ${version}` : `${title} · ${skill.skillId}`;
|
||||
}
|
||||
|
||||
function skillName(skill: AdminSkillLifecycleSkill) {
|
||||
return text(skill.name ?? skill.invocation ?? skill.skillId, skill.skillId);
|
||||
}
|
||||
|
||||
function deriveCatalog(skills: AdminSkillLifecycleSkill[]): AdminSkillLifecycleCatalogItem[] {
|
||||
const catalog = new Map<string, AdminSkillLifecycleCatalogItem>();
|
||||
|
||||
for (const skill of skills) {
|
||||
const name = skillName(skill);
|
||||
const existing = catalog.get(name) ?? {
|
||||
name,
|
||||
invocation: text(skill.invocation, ""),
|
||||
description: text(skill.description, ""),
|
||||
deviceCount: 0,
|
||||
devices: [],
|
||||
};
|
||||
existing.devices = [
|
||||
...(existing.devices ?? []),
|
||||
{
|
||||
skillId: skill.skillId,
|
||||
deviceId: text(skill.deviceId, ""),
|
||||
path: text(skill.path, ""),
|
||||
category: text(skill.category, ""),
|
||||
updatedAt: text(skill.updatedAt, ""),
|
||||
},
|
||||
].filter((item) => item.skillId && item.deviceId);
|
||||
existing.deviceCount = existing.devices.length;
|
||||
catalog.set(name, existing);
|
||||
}
|
||||
|
||||
return [...catalog.values()].sort((left, right) => left.name.localeCompare(right.name, "zh-CN"));
|
||||
}
|
||||
|
||||
function requestTime(request: AdminSkillLifecycleRequest) {
|
||||
return text(request.updatedAt ?? request.completedAt ?? request.claimedAt ?? request.requestedAt);
|
||||
}
|
||||
|
||||
function actionMeta(action: unknown) {
|
||||
return lifecycleActions.find((item) => item.value === action) ?? lifecycleActions[0];
|
||||
}
|
||||
|
||||
function compact(values: LifecycleFormValues) {
|
||||
const payload: Record<string, string> = {
|
||||
action: values.action,
|
||||
};
|
||||
|
||||
for (const key of [
|
||||
"deviceId",
|
||||
"skillId",
|
||||
"sourceUrl",
|
||||
"trustedSource",
|
||||
"trustedSourceId",
|
||||
"checksum",
|
||||
"expectedChecksum",
|
||||
"targetVersion",
|
||||
"rollbackToVersion",
|
||||
"lockedVersion",
|
||||
"note",
|
||||
] satisfies Array<keyof LifecycleFormValues>) {
|
||||
const value = trimmed(values[key]);
|
||||
if (value) payload[key] = value;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function readError(response: Response) {
|
||||
const payload = (await response.json().catch(() => null)) as { message?: string } | null;
|
||||
return payload?.message || `HTTP ${response.status}`;
|
||||
}
|
||||
|
||||
export function AdminSkillLifecyclePanel({
|
||||
devices = [],
|
||||
skills = [],
|
||||
skillCatalog = [],
|
||||
initialRequests,
|
||||
initialLifecycleRequests,
|
||||
trustedSources = [],
|
||||
className,
|
||||
}: AdminSkillLifecyclePanelProps) {
|
||||
const [form] = Form.useForm<LifecycleFormValues>();
|
||||
const [messageApi, messageContext] = message.useMessage();
|
||||
const [requests, setRequests] = useState<AdminSkillLifecycleRequest[]>(
|
||||
() => initialRequests ?? initialLifecycleRequests ?? [],
|
||||
);
|
||||
const [availableDevices, setAvailableDevices] = useState<AdminSkillLifecycleDevice[]>(devices);
|
||||
const [availableSkills, setAvailableSkills] = useState<AdminSkillLifecycleSkill[]>(skills);
|
||||
const [availableSkillCatalog, setAvailableSkillCatalog] =
|
||||
useState<AdminSkillLifecycleCatalogItem[]>(skillCatalog);
|
||||
const [action, setAction] = useState<AdminSkillLifecycleAction>("install");
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState("");
|
||||
const [selectedSkillName, setSelectedSkillName] = useState("");
|
||||
const [loadingRequests, setLoadingRequests] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const deviceOptions = useMemo(
|
||||
() =>
|
||||
availableDevices
|
||||
.map((device) => ({ value: deviceIdOf(device), label: deviceLabel(device) }))
|
||||
.filter((option) => option.value),
|
||||
[availableDevices],
|
||||
);
|
||||
|
||||
const skillOptions = useMemo(() => {
|
||||
const available = selectedDeviceId
|
||||
? availableSkills.filter((skill) => !skill.deviceId || skill.deviceId === selectedDeviceId)
|
||||
: availableSkills;
|
||||
return available.map((skill) => ({
|
||||
value: skill.skillId,
|
||||
label: skillLabel(skill),
|
||||
}));
|
||||
}, [selectedDeviceId, availableSkills]);
|
||||
|
||||
const trustedSourceOptions = useMemo(
|
||||
() =>
|
||||
trustedSources
|
||||
.map((source) => {
|
||||
const value = text(source.trustedSourceId ?? source.id, "");
|
||||
const label = text(source.label ?? source.name ?? source.trustedSourceId ?? source.id, value);
|
||||
return { value, label };
|
||||
})
|
||||
.filter((option) => option.value),
|
||||
[trustedSources],
|
||||
);
|
||||
|
||||
const catalogItems = useMemo(
|
||||
() => (availableSkillCatalog.length > 0 ? availableSkillCatalog : deriveCatalog(availableSkills)),
|
||||
[availableSkillCatalog, availableSkills],
|
||||
);
|
||||
|
||||
const selectedSkill = useMemo(
|
||||
() => catalogItems.find((item) => item.name === selectedSkillName) ?? catalogItems[0],
|
||||
[catalogItems, selectedSkillName],
|
||||
);
|
||||
|
||||
const deviceById = useMemo(() => {
|
||||
const next = new Map<string, AdminSkillLifecycleDevice>();
|
||||
for (const device of availableDevices) {
|
||||
next.set(deviceIdOf(device), device);
|
||||
}
|
||||
return next;
|
||||
}, [availableDevices]);
|
||||
|
||||
const activeDeviceIds = useMemo(
|
||||
() => (selectedSkill?.devices ?? []).map((device) => device.deviceId).filter(Boolean),
|
||||
[selectedSkill],
|
||||
);
|
||||
|
||||
const selectedSkillRequests = useMemo(() => {
|
||||
if (!selectedSkill) return requests.slice(0, 6);
|
||||
const selectedSkillIds = new Set((selectedSkill.devices ?? []).map((device) => device.skillId));
|
||||
const selectedDevices = new Set(activeDeviceIds);
|
||||
return requests
|
||||
.filter((request) => {
|
||||
if (request.skillId && selectedSkillIds.has(request.skillId)) return true;
|
||||
if (request.deviceId && selectedDevices.has(request.deviceId)) return true;
|
||||
return false;
|
||||
})
|
||||
.slice(0, 6);
|
||||
}, [activeDeviceIds, requests, selectedSkill]);
|
||||
|
||||
async function refreshRequests() {
|
||||
setLoadingRequests(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetch(skillLifecycleRequestsEndpoint, {
|
||||
method: "GET",
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response));
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
ok?: boolean;
|
||||
requests?: AdminSkillLifecycleRequest[];
|
||||
message?: string;
|
||||
};
|
||||
if (payload.ok === false) {
|
||||
throw new Error(payload.message || "请求列表读取失败");
|
||||
}
|
||||
setRequests(Array.isArray(payload.requests) ? payload.requests : []);
|
||||
} catch (nextError) {
|
||||
const messageText = nextError instanceof Error ? nextError.message : "请求列表读取失败";
|
||||
setError(messageText);
|
||||
messageApi.error(messageText);
|
||||
} finally {
|
||||
setLoadingRequests(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTargets() {
|
||||
const response = await fetch(adminAccessEndpoint, {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response));
|
||||
}
|
||||
const payload = (await response.json()) as {
|
||||
ok?: boolean;
|
||||
devices?: AdminSkillLifecycleDevice[];
|
||||
skills?: AdminSkillLifecycleSkill[];
|
||||
skillCatalog?: AdminSkillLifecycleCatalogItem[];
|
||||
message?: string;
|
||||
};
|
||||
if (payload.ok === false) {
|
||||
throw new Error(payload.message || "治理目标读取失败");
|
||||
}
|
||||
setAvailableDevices(Array.isArray(payload.devices) ? payload.devices : []);
|
||||
setAvailableSkills(Array.isArray(payload.skills) ? payload.skills : []);
|
||||
setAvailableSkillCatalog(Array.isArray(payload.skillCatalog) ? payload.skillCatalog : []);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const shouldLoadTargets = devices.length === 0 && skills.length === 0 && skillCatalog.length === 0;
|
||||
const tasks = [
|
||||
requests.length === 0 ? refreshRequests() : Promise.resolve(),
|
||||
shouldLoadTargets ? refreshTargets() : Promise.resolve(),
|
||||
];
|
||||
Promise.all(tasks).catch((nextError) => {
|
||||
if (!active) return;
|
||||
const messageText = nextError instanceof Error ? nextError.message : "Skill 治理数据读取失败";
|
||||
setError(messageText);
|
||||
messageApi.error(messageText);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
async function createRequest(values: LifecycleFormValues) {
|
||||
const payload = compact(values);
|
||||
if (!payload.deviceId) {
|
||||
throw new Error("请选择目标设备");
|
||||
}
|
||||
if (values.action === "install" && !payload.sourceUrl && !payload.trustedSourceId && !payload.trustedSource) {
|
||||
throw new Error("安装请求需要填写 sourceUrl 或 trustedSourceId");
|
||||
}
|
||||
if (values.action !== "install" && !payload.skillId) {
|
||||
throw new Error("该操作需要选择已有 Skill");
|
||||
}
|
||||
|
||||
const response = await fetch(skillLifecycleRequestsEndpoint, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readError(response));
|
||||
}
|
||||
|
||||
const result = (await response.json()) as { ok?: boolean; message?: string };
|
||||
if (result.ok === false) {
|
||||
throw new Error(result.message || "请求创建失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
setSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
await createRequest(values);
|
||||
messageApi.success("Skill 生命周期请求已创建");
|
||||
form.setFieldsValue({
|
||||
sourceUrl: undefined,
|
||||
trustedSource: undefined,
|
||||
trustedSourceId: undefined,
|
||||
checksum: undefined,
|
||||
expectedChecksum: undefined,
|
||||
targetVersion: undefined,
|
||||
rollbackToVersion: undefined,
|
||||
lockedVersion: undefined,
|
||||
note: undefined,
|
||||
});
|
||||
await refreshRequests();
|
||||
} catch (nextError) {
|
||||
const messageText = nextError instanceof Error ? nextError.message : "请求创建失败";
|
||||
setError(messageText);
|
||||
messageApi.error(messageText);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<AdminSkillLifecycleRequest> = [
|
||||
{
|
||||
title: "请求",
|
||||
dataIndex: "action",
|
||||
width: 170,
|
||||
render: (_, request) => {
|
||||
const meta = actionMeta(request.action);
|
||||
return (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Tag color={meta.color}>{meta.label}</Tag>
|
||||
<Typography.Text type="secondary" className="text-xs">
|
||||
{text(request.requestId ?? request.id)}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "目标",
|
||||
dataIndex: "deviceId",
|
||||
render: (_, request) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text>{text(request.deviceId)}</Typography.Text>
|
||||
<Typography.Text type="secondary" className="text-xs">
|
||||
{text(request.skillId ?? request.sourceUrl ?? request.trustedSourceId ?? request.trustedSource)}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "版本 / 来源",
|
||||
dataIndex: "targetVersion",
|
||||
render: (_, request) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text>{text(request.targetVersion ?? request.rollbackToVersion ?? request.lockedVersion)}</Typography.Text>
|
||||
<Typography.Text type="secondary" className="text-xs">
|
||||
{text(request.checksum ?? request.expectedChecksum ?? request.sourceUrl ?? request.trustedSourceId)}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "status",
|
||||
width: 130,
|
||||
render: (status) => {
|
||||
const value = text(status, "pending");
|
||||
return <Tag color={statusColors[value] ?? "default"}>{value}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "发起",
|
||||
dataIndex: "requestedAt",
|
||||
width: 190,
|
||||
render: (_, request) => (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text>{text(request.requestedBy)}</Typography.Text>
|
||||
<Typography.Text type="secondary" className="text-xs">
|
||||
{text(request.requestedAt ?? request.updatedAt)}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "结果",
|
||||
dataIndex: "resultSummary",
|
||||
render: (_, request) => text(request.resultSummary ?? request.error ?? request.note),
|
||||
},
|
||||
];
|
||||
|
||||
const currentAction = actionMeta(action);
|
||||
const needsExistingSkill = action !== "install";
|
||||
const showsSource = action === "install" || action === "update";
|
||||
const showsChecksum = action === "install" || action === "update";
|
||||
const recentRequests = requests.slice(0, 3);
|
||||
const pendingRequests = requests.filter((request) => text(request.status, "pending") === "pending").length;
|
||||
const failedRequests = requests.filter((request) => text(request.status, "").includes("failed")).length;
|
||||
const activeDeviceRows = selectedSkill?.devices ?? [];
|
||||
const checksumRows = [
|
||||
{ key: "来源", value: showsSource ? "sourceUrl / trustedSourceId" : "既有 Skill" },
|
||||
{ key: "校验", value: showsChecksum ? "checksum / expectedChecksum" : "按请求记录追踪" },
|
||||
{ key: "回滚", value: "请求记录追踪" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={`${adminDense} ${className ?? ""}`}>
|
||||
{messageContext}
|
||||
<div className="sr-only">{panelSubtitle}</div>
|
||||
<div className="mb-3 rounded-[24px] border border-[#E2E8E2] bg-white px-5 py-4 shadow-[0_10px_30px_rgba(16,24,20,0.04)]">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<Typography.Title level={4} className="!mb-1">
|
||||
Skill 中心
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
目录优先:先确认 Skill、版本、授权对象和执行轨迹,再发起安装、更新、回滚或锁版。
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Button onClick={() => void Promise.all([refreshTargets(), refreshRequests()])} loading={loadingRequests}>
|
||||
刷新 Skill
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-3 lg:grid-cols-4">
|
||||
{[
|
||||
{ label: "Skill 目录", value: catalogItems.length, tone: "text-[#06C167]" },
|
||||
{ label: "授权对象", value: activeDeviceIds.length, tone: "text-[#1677FF]" },
|
||||
{ label: "待处理请求", value: pendingRequests, tone: "text-[#FA8C16]" },
|
||||
{ label: "失败请求", value: failedRequests, tone: "text-[#FF4D4F]" },
|
||||
].map((item) => (
|
||||
<div key={item.label} className="rounded-2xl border border-[#EEF1EE] bg-[#FAFBFA] px-4 py-3">
|
||||
<div className="text-xs text-[#718075]">{item.label}</div>
|
||||
<div className={`mt-1 text-2xl font-semibold ${item.tone}`}>{item.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 xl:grid-cols-[320px_minmax(0,1fr)_420px]">
|
||||
<Card className={adminCard} title="Skill 目录">
|
||||
{catalogItems.length === 0 ? (
|
||||
<Empty description="暂无 Skill,请先等待设备同步本机 Skill 清单" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{catalogItems.map((item) => {
|
||||
const selected = selectedSkill?.name === item.name;
|
||||
const firstDevice = item.devices?.[0];
|
||||
return (
|
||||
<button
|
||||
key={item.name}
|
||||
type="button"
|
||||
className={`w-full rounded-2xl border px-3 py-3 text-left transition ${
|
||||
selected ? "border-[#06C167] bg-[#EDFFF5]" : "border-[#EEF1EE] bg-white hover:bg-[#F7FAF7]"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setSelectedSkillName(item.name);
|
||||
if (firstDevice) {
|
||||
setSelectedDeviceId(firstDevice.deviceId);
|
||||
form.setFieldsValue({
|
||||
deviceId: firstDevice.deviceId,
|
||||
skillId: action === "install" ? undefined : firstDevice.skillId,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-semibold text-[#101814]">{item.name}</span>
|
||||
<Tag color={selected ? "green" : "default"}>{text(item.deviceCount ?? item.devices?.length, "0")} 台</Tag>
|
||||
</div>
|
||||
<div className="mt-1 line-clamp-2 text-xs leading-5 text-[#718075]">
|
||||
{text(item.description ?? item.invocation, "暂无说明")}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Card className={adminCard} title="Skill 详情">
|
||||
{selectedSkill ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Typography.Title level={5} className="!mb-1">
|
||||
{selectedSkill.name}
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph className="!mb-0 text-[#5F6B63]">
|
||||
{text(selectedSkill.description, "暂无说明,建议补齐 SKILL.md 的 description,方便企业管理员判断用途。")}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
<div className="grid gap-3 lg:grid-cols-3">
|
||||
<div className="rounded-2xl bg-[#F7FAF7] px-4 py-3">
|
||||
<div className="text-xs text-[#718075]">调用方式</div>
|
||||
<div className="mt-1 truncate text-sm font-medium text-[#101814]">{text(selectedSkill.invocation)}</div>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-[#F7FAF7] px-4 py-3">
|
||||
<div className="text-xs text-[#718075]">覆盖设备</div>
|
||||
<div className="mt-1 text-sm font-medium text-[#101814]">{activeDeviceIds.length} 台</div>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-[#F7FAF7] px-4 py-3">
|
||||
<div className="text-xs text-[#718075]">最近同步</div>
|
||||
<div className="mt-1 truncate text-sm font-medium text-[#101814]">
|
||||
{text(activeDeviceRows[0]?.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Empty description="请选择一个 Skill" />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className={adminCard} title="授权对象">
|
||||
<Table
|
||||
rowKey={(row) => `${row.deviceId}-${row.skillId}`}
|
||||
pagination={false}
|
||||
size="small"
|
||||
dataSource={activeDeviceRows}
|
||||
columns={[
|
||||
{
|
||||
title: "设备",
|
||||
render: (_, row) => {
|
||||
const device = deviceById.get(row.deviceId);
|
||||
return (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Typography.Text>{text(device?.name ?? device?.deviceName ?? row.deviceId)}</Typography.Text>
|
||||
<Typography.Text type="secondary" className="text-xs">
|
||||
{row.deviceId}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "状态",
|
||||
width: 110,
|
||||
render: (_, row) => {
|
||||
const status = text(deviceById.get(row.deviceId)?.status ?? deviceById.get(row.deviceId)?.onlineStatus);
|
||||
return <Tag color={status === "online" ? "green" : "default"}>{status}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: "路径", dataIndex: "path", render: (value) => text(value) },
|
||||
{ title: "分类", dataIndex: "category", width: 140, render: (value) => text(value) },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card className={adminCard} title="执行轨迹">
|
||||
<Table
|
||||
rowKey={(request, index) => text(request.requestId ?? request.id ?? index, String(index))}
|
||||
pagination={false}
|
||||
size="small"
|
||||
dataSource={selectedSkillRequests}
|
||||
columns={[
|
||||
{ title: "动作", render: (_, request) => <Tag color={actionMeta(request.action).color}>{actionMeta(request.action).label}</Tag> },
|
||||
{ title: "设备", dataIndex: "deviceId", render: (value) => text(value) },
|
||||
{
|
||||
title: "状态",
|
||||
render: (_, request) => {
|
||||
const value = text(request.status, "pending");
|
||||
return <Tag color={statusColors[value] ?? "default"}>{value}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: "时间", render: (_, request) => requestTime(request) },
|
||||
{ title: "结果", render: (_, request) => text(request.resultSummary ?? request.error ?? request.note) },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className={adminCard} title="安装向导" extra={<Tag color={currentAction.color}>{currentAction.label}</Tag>}>
|
||||
<Alert type="info" showIcon message={currentAction.label} description={currentAction.description} />
|
||||
|
||||
<Form<LifecycleFormValues>
|
||||
form={form}
|
||||
layout="vertical"
|
||||
className="mt-4"
|
||||
initialValues={{ action: "install" }}
|
||||
onValuesChange={(changedValues, allValues) => {
|
||||
if (changedValues.action) {
|
||||
const nextAction = allValues.action ?? "install";
|
||||
setAction(nextAction);
|
||||
const firstDevice = selectedSkill?.devices?.[0];
|
||||
if (nextAction !== "install" && firstDevice) {
|
||||
setSelectedDeviceId(firstDevice.deviceId);
|
||||
form.setFieldsValue({ deviceId: firstDevice.deviceId, skillId: firstDevice.skillId });
|
||||
}
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(changedValues, "deviceId")) {
|
||||
setSelectedDeviceId(allValues.deviceId ?? "");
|
||||
form.setFieldValue("skillId", undefined);
|
||||
}
|
||||
}}
|
||||
onFinish={() => void submitForm()}
|
||||
>
|
||||
<Form.Item name="action" label="治理动作" rules={[{ required: true, message: "请选择治理动作" }]}>
|
||||
<Select
|
||||
options={lifecycleActions.map((item) => ({
|
||||
value: item.value,
|
||||
label: `${item.label} (${item.value})`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="deviceId" label="目标设备" rules={[{ required: true, message: "请选择目标设备" }]}>
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="选择要执行 Skill 操作的电脑"
|
||||
optionFilterProp="label"
|
||||
options={deviceOptions}
|
||||
notFoundContent="暂无可选设备"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{needsExistingSkill ? (
|
||||
<Form.Item name="skillId" label="目标 Skill" rules={[{ required: true, message: "请选择已有 Skill" }]}>
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="选择该设备上的 Skill"
|
||||
optionFilterProp="label"
|
||||
options={skillOptions}
|
||||
notFoundContent={selectedDeviceId ? "该设备暂无 Skill" : "请先选择设备"}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
|
||||
{showsSource ? (
|
||||
<>
|
||||
<Divider plain>来源</Divider>
|
||||
<Form.Item name="sourceUrl" label="Git / 包来源地址">
|
||||
<Input placeholder="https://git.example.com/org/skill.git" />
|
||||
</Form.Item>
|
||||
<Form.Item name="trustedSourceId" label="受信源">
|
||||
<AutoComplete
|
||||
placeholder="可选择受信源,也可手动输入 trustedSourceId"
|
||||
options={trustedSourceOptions}
|
||||
filterOption={(inputValue, option) =>
|
||||
text(option?.label ?? option?.value, "")
|
||||
.toLowerCase()
|
||||
.includes(inputValue.toLowerCase())
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="trustedSource" label="来源备注">
|
||||
<Input placeholder="可选:受信源名称或别名" />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{action === "update" ? (
|
||||
<Form.Item name="targetVersion" label="目标版本">
|
||||
<Input placeholder="例如 1.2.0 或 git ref" />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
|
||||
{action === "rollback" ? (
|
||||
<Form.Item name="rollbackToVersion" label="回滚版本">
|
||||
<Input placeholder="例如 1.1.0 或备份版本" />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
|
||||
{action === "version_lock" ? (
|
||||
<Form.Item name="lockedVersion" label="锁定版本">
|
||||
<Input placeholder="例如 1.1.0" />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
|
||||
{showsChecksum ? (
|
||||
<>
|
||||
<Divider plain>校验</Divider>
|
||||
<Form.Item name="checksum" label="当前 checksum">
|
||||
<Input placeholder="可选 sha256" />
|
||||
</Form.Item>
|
||||
<Form.Item name="expectedChecksum" label="期望 checksum">
|
||||
<Input placeholder="兼容设备端 expectedChecksum" />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Form.Item name="note" label="操作说明">
|
||||
<Input.TextArea rows={3} placeholder="说明变更原因、回滚原因或风险备注" />
|
||||
</Form.Item>
|
||||
|
||||
{error ? <Alert className="mb-4" type="warning" showIcon message={error} /> : null}
|
||||
|
||||
<Button type="primary" htmlType="submit" loading={submitting} block>
|
||||
提交治理请求
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-3">
|
||||
<Card
|
||||
className={adminCard}
|
||||
title="全部请求"
|
||||
extra={
|
||||
<Button onClick={() => void refreshRequests()} loading={loadingRequests}>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
rowKey={(request, index) =>
|
||||
text(request.requestId ?? request.id ?? `${request.action}-${request.deviceId}-${index}`, String(index))
|
||||
}
|
||||
columns={columns}
|
||||
dataSource={requests}
|
||||
loading={loadingRequests}
|
||||
pagination={{ pageSize: 8 }}
|
||||
size="small"
|
||||
scroll={{ x: 980 }}
|
||||
/>
|
||||
</Card>
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
<Card className={adminCard} title="最近结果">
|
||||
<Table
|
||||
rowKey={(request, index) => text(request.requestId ?? request.id ?? index, String(index))}
|
||||
pagination={false}
|
||||
size="small"
|
||||
dataSource={recentRequests}
|
||||
columns={[
|
||||
{ title: "设备", render: (_, request) => text(request.deviceId) },
|
||||
{ title: "动作", render: (_, request) => actionMeta(request.action).label },
|
||||
{
|
||||
title: "状态",
|
||||
render: (_, request) => {
|
||||
const value = text(request.status, "pending");
|
||||
return <Tag color={statusColors[value] ?? "default"}>{value}</Tag>;
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
<Card className={adminCard} title="校验信息">
|
||||
<Table
|
||||
rowKey="key"
|
||||
pagination={false}
|
||||
size="small"
|
||||
dataSource={checksumRows}
|
||||
columns={[
|
||||
{ title: "字段", dataIndex: "key", width: 140 },
|
||||
{ title: "值", dataIndex: "value" },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
721
src/components/admin/boss-admin-app.tsx
Normal file
721
src/components/admin/boss-admin-app.tsx
Normal file
@@ -0,0 +1,721 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Refine } from "@refinedev/core";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
ConfigProvider,
|
||||
Empty,
|
||||
Input,
|
||||
Statistic,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
theme,
|
||||
message,
|
||||
} from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { AdminAccessPanel } from "@/components/admin/admin-access-panel";
|
||||
import { AdminSkillLifecyclePanel } from "@/components/admin/admin-skill-lifecycle-panel";
|
||||
import {
|
||||
type BossAdminOverview,
|
||||
createBossAdminDataProvider,
|
||||
} from "@/components/admin/boss-admin-data-provider";
|
||||
|
||||
type AdminRow = Record<string, unknown>;
|
||||
|
||||
type BossAdminAppProps = {
|
||||
initialOverview?: BossAdminOverview | null;
|
||||
};
|
||||
|
||||
type AdminSection = "dashboard" | "customers" | "permissions" | "governance";
|
||||
type RiskAction = "ack" | "resolve" | "create_repair_ticket" | "assign_owner" | "set_sla";
|
||||
|
||||
const resources = [
|
||||
{ name: "companies", list: "/admin#companies", meta: { label: "公司" } },
|
||||
{ name: "accounts", list: "/admin#accounts", meta: { label: "账号" } },
|
||||
{ name: "devices", list: "/admin#devices", meta: { label: "设备" } },
|
||||
{ name: "risks", list: "/admin#risks", meta: { label: "风险" } },
|
||||
{ name: "notifications", list: "/admin#notifications", meta: { label: "通知" } },
|
||||
{ name: "auditLogs", list: "/admin#auditLogs", meta: { label: "审计日志" } },
|
||||
];
|
||||
|
||||
const adminShell = "min-h-screen bg-[#F3F5F2] p-5 text-[#101814]";
|
||||
const adminChrome =
|
||||
"mx-auto grid min-h-[calc(100vh-40px)] max-w-[1680px] grid-cols-[248px_minmax(0,1fr)] overflow-hidden rounded-[30px] border border-[#E0E6E1] bg-white shadow-[0_32px_100px_rgba(22,37,28,0.10)]";
|
||||
const adminSidebar = "border-r border-[#E3E8E4] bg-[#FBFCFB] px-4 py-5";
|
||||
const adminHeader = "flex min-h-[86px] items-center border-b border-[#E3E8E4] bg-white px-7";
|
||||
const adminCardClass = "boss-admin-card border-[#E3E8E4] shadow-[0_14px_42px_rgba(20,35,25,0.045)]";
|
||||
const adminDense = "boss-admin-dense";
|
||||
|
||||
const navItems: Array<{
|
||||
key: AdminSection;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
marker: string;
|
||||
}> = [
|
||||
{ key: "dashboard", title: "平台运营驾驶舱", subtitle: "全局健康与待处理事项", marker: "D" },
|
||||
{ key: "customers", title: "客户与账号", subtitle: "公司、老板账号与子账号", marker: "C" },
|
||||
{ key: "permissions", title: "授权工作台", subtitle: "设备、项目与 Skill 权限", marker: "P" },
|
||||
{ key: "governance", title: "风险与治理", subtitle: "风险、SLA、Skill", marker: "R" },
|
||||
];
|
||||
|
||||
function text(value: unknown, fallback = "-") {
|
||||
if (value === null || value === undefined || value === "") return fallback;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function numberValue(value: unknown) {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
function rowId(row: AdminRow, index?: number) {
|
||||
return text(row.id ?? row.companyId ?? row.account ?? row.deviceId ?? row.riskId ?? row.auditId, String(index ?? 0));
|
||||
}
|
||||
|
||||
function statusTag(value: unknown) {
|
||||
const status = text(value, "unknown");
|
||||
const color =
|
||||
status === "online" || status === "active" || status === "healthy" || status === "completed"
|
||||
? "green"
|
||||
: status === "offline" || status === "disabled"
|
||||
? "default"
|
||||
: status === "failed" || status === "critical"
|
||||
? "red"
|
||||
: "orange";
|
||||
return <Tag color={color}>{status}</Tag>;
|
||||
}
|
||||
|
||||
function severityTag(value: unknown) {
|
||||
const severity = text(value, "info");
|
||||
const color = severity === "critical" || severity === "high" ? "red" : severity === "warning" || severity === "medium" ? "orange" : "blue";
|
||||
return <Tag color={color}>{severity}</Tag>;
|
||||
}
|
||||
|
||||
function riskTarget(row: AdminRow) {
|
||||
return text(row.target ?? row.deviceId ?? row.projectId ?? row.account ?? row.companyId);
|
||||
}
|
||||
|
||||
function sectionTitle(section: AdminSection) {
|
||||
return navItems.find((item) => item.key === section)?.title ?? "平台运营驾驶舱";
|
||||
}
|
||||
|
||||
function currentSubtitle(section: AdminSection) {
|
||||
return navItems.find((item) => item.key === section)?.subtitle ?? "全局健康与待处理事项";
|
||||
}
|
||||
|
||||
function customerHealthTone(company: AdminRow) {
|
||||
const riskCount = numberValue(company.openRiskCount);
|
||||
const deviceCount = numberValue(company.deviceCount);
|
||||
const onlineCount = numberValue(company.onlineDeviceCount);
|
||||
if (riskCount >= 3) return { label: "需介入", color: "red" };
|
||||
if (deviceCount > 0 && onlineCount === 0) return { label: "离线", color: "orange" };
|
||||
if (riskCount > 0) return { label: "观察", color: "gold" };
|
||||
return { label: "健康", color: "green" };
|
||||
}
|
||||
|
||||
const riskColumns: ColumnsType<AdminRow> = [
|
||||
{ title: "风险", dataIndex: "title", render: (_, row) => text(row.title ?? row.name ?? row.kind) },
|
||||
{ title: "级别", dataIndex: "severity", width: 104, render: severityTag },
|
||||
{ title: "对象", dataIndex: "target", width: 170, render: (_, row) => riskTarget(row) },
|
||||
{ title: "负责人", dataIndex: "ownerAccount", width: 150, render: (_, row) => text(row.ownerAccount, "未指派") },
|
||||
{ title: "SLA", dataIndex: "slaDueAt", width: 180, render: (_, row) => text(row.slaDueAt, "未设置") },
|
||||
{ title: "状态", dataIndex: "status", width: 104, render: statusTag },
|
||||
];
|
||||
|
||||
const deviceColumns: ColumnsType<AdminRow> = [
|
||||
{ title: "设备", dataIndex: "name", render: (_, row) => text(row.name ?? row.deviceName ?? row.deviceId ?? row.id) },
|
||||
{ title: "状态", dataIndex: "status", width: 105, render: (_, row) => statusTag(row.status ?? row.onlineStatus) },
|
||||
{ title: "GUI", dataIndex: "codexGuiOnline", width: 86, render: (_, row) => statusTag(row.codexGuiOnline ? "online" : "offline") },
|
||||
{ title: "CLI", dataIndex: "codexCliOnline", width: 86, render: (_, row) => statusTag(row.codexCliOnline ? "online" : "offline") },
|
||||
{ title: "风险", dataIndex: "openRiskCount", width: 86, render: numberValue },
|
||||
{ title: "最近心跳", dataIndex: "lastSeenAt", width: 210, render: (_, row) => text(row.lastSeenAt ?? row.updatedAt) },
|
||||
];
|
||||
|
||||
const companyColumns: ColumnsType<AdminRow> = [
|
||||
{ title: "公司", dataIndex: "name", render: (_, row) => text(row.name ?? row.companyName ?? row.companyId) },
|
||||
{ title: "健康", dataIndex: "health", width: 100, render: (_, row) => {
|
||||
const tone = customerHealthTone(row);
|
||||
return <Tag color={tone.color}>{tone.label}</Tag>;
|
||||
} },
|
||||
{ title: "账号", dataIndex: "accountCount", width: 86, render: numberValue },
|
||||
{ title: "在线设备", dataIndex: "onlineDeviceCount", width: 112, render: (_, row) => `${numberValue(row.onlineDeviceCount)}/${numberValue(row.deviceCount)}` },
|
||||
{ title: "开放风险", dataIndex: "openRiskCount", width: 104, render: numberValue },
|
||||
{ title: "客户成功", dataIndex: "successOwnerAccount", width: 150, render: (_, row) => text(row.successOwnerAccount, "未指派") },
|
||||
];
|
||||
|
||||
const accountColumns: ColumnsType<AdminRow> = [
|
||||
{ title: "账号", dataIndex: "account", render: (_, row) => text(row.account ?? row.phone ?? row.id) },
|
||||
{ title: "角色", dataIndex: "role", width: 130, render: statusTag },
|
||||
{ title: "公司", dataIndex: "companyName", render: (_, row) => text(row.companyName ?? row.companyId) },
|
||||
{ title: "状态", dataIndex: "status", width: 118, render: statusTag },
|
||||
{ title: "最近登录", dataIndex: "lastLoginAt", width: 210, render: (_, row) => text(row.lastLoginAt, "暂无") },
|
||||
];
|
||||
|
||||
const notificationColumns: ColumnsType<AdminRow> = [
|
||||
{ title: "通知", dataIndex: "title", render: (_, row) => text(row.title ?? row.kind) },
|
||||
{ title: "级别", dataIndex: "severity", width: 110, render: severityTag },
|
||||
{ title: "公司", dataIndex: "companyId", width: 150, render: (_, row) => text(row.companyId) },
|
||||
{ title: "风险", dataIndex: "riskId", width: 220, render: (_, row) => text(row.riskId) },
|
||||
{ title: "时间", dataIndex: "createdAt", width: 190, render: (_, row) => text(row.createdAt) },
|
||||
];
|
||||
|
||||
async function loadOverview() {
|
||||
const response = await fetch("/api/v1/admin/overview", {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`后台总览读取失败:${response.status}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as BossAdminOverview;
|
||||
}
|
||||
|
||||
function MetricCard({
|
||||
title,
|
||||
value,
|
||||
tone = "default",
|
||||
hint,
|
||||
}: {
|
||||
title: string;
|
||||
value: number;
|
||||
tone?: "default" | "green" | "red" | "orange";
|
||||
hint?: string;
|
||||
}) {
|
||||
const valueColor = tone === "green" ? "#07A85A" : tone === "red" ? "#E23D3D" : tone === "orange" ? "#D97706" : "#101814";
|
||||
return (
|
||||
<Card className={adminCardClass}>
|
||||
<Statistic title={title} value={value} valueStyle={{ color: valueColor, fontWeight: 800 }} />
|
||||
{hint ? <div className="mt-2 text-xs text-[#758078]">{hint}</div> : null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PanelTitle({ title, subtitle, extra }: { title: string; subtitle?: string; extra?: ReactNode }) {
|
||||
return (
|
||||
<div className="mb-4 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-[18px] font-black tracking-[-0.02em] text-[#101814]">{title}</div>
|
||||
{subtitle ? <div className="mt-1 text-sm text-[#68746D]">{subtitle}</div> : null}
|
||||
</div>
|
||||
{extra ? <div className="shrink-0">{extra}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyBlock({ textValue }: { textValue: string }) {
|
||||
return <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={textValue} />;
|
||||
}
|
||||
|
||||
type RiskActionsProps = {
|
||||
selectedRisk?: AdminRow;
|
||||
actionBusy: string;
|
||||
onSubmit: (risk: AdminRow, action: RiskAction, extraBody?: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
function RiskActionPanel({ selectedRisk, actionBusy, onSubmit }: RiskActionsProps) {
|
||||
const [ownerAccount, setOwnerAccount] = useState("");
|
||||
const [slaDueAt, setSlaDueAt] = useState("");
|
||||
const riskId = selectedRisk ? text(selectedRisk.riskId ?? selectedRisk.id, "") : "";
|
||||
const kind = selectedRisk ? text(selectedRisk.kind, "") : "";
|
||||
const canAckResolve = kind === "ops_fault" || kind === "thread_context_alert";
|
||||
const canCreateTicket = kind === "ops_fault";
|
||||
|
||||
if (!selectedRisk) {
|
||||
return (
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="处理面板" subtitle="选择左侧风险后,在这里指派负责人、设置 SLA 或创建修复工单。" />
|
||||
<EmptyBlock textValue="暂无选中风险" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="处理面板" subtitle="所有动作都会写入风险时间线和权限审计。" />
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-[#E3E8E4] bg-[#F8FAF8] p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{severityTag(selectedRisk.severity)}
|
||||
<span className="font-bold text-[#101814]">{text(selectedRisk.title ?? selectedRisk.kind)}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-6 text-[#5F6B64]">{text(selectedRisk.detail ?? selectedRisk.summary, "暂无详情")}</div>
|
||||
<div className="mt-3 text-xs text-[#7B857E]">对象:{riskTarget(selectedRisk)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-bold text-[#68746D]">负责人账号</div>
|
||||
<div className="flex gap-2">
|
||||
<Input value={ownerAccount} onChange={(event) => setOwnerAccount(event.target.value)} placeholder="例如 ops@company.com" />
|
||||
<Button
|
||||
disabled={!canAckResolve || !ownerAccount.trim()}
|
||||
loading={actionBusy === `${riskId}:assign_owner`}
|
||||
onClick={() => onSubmit(selectedRisk, "assign_owner", { ownerAccount: ownerAccount.trim() })}
|
||||
>
|
||||
指派
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-bold text-[#68746D]">SLA 截止时间</div>
|
||||
<div className="flex gap-2">
|
||||
<Input value={slaDueAt} onChange={(event) => setSlaDueAt(event.target.value)} placeholder="2026-04-30T18:00:00+08:00" />
|
||||
<Button
|
||||
disabled={!canAckResolve || !slaDueAt.trim()}
|
||||
loading={actionBusy === `${riskId}:set_sla`}
|
||||
onClick={() => onSubmit(selectedRisk, "set_sla", { slaDueAt: slaDueAt.trim() })}
|
||||
>
|
||||
设置 SLA
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Button disabled={!canAckResolve} loading={actionBusy === `${riskId}:ack`} onClick={() => onSubmit(selectedRisk, "ack")}>
|
||||
确认
|
||||
</Button>
|
||||
<Button disabled={!canAckResolve} loading={actionBusy === `${riskId}:resolve`} onClick={() => onSubmit(selectedRisk, "resolve")}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!canCreateTicket}
|
||||
loading={actionBusy === `${riskId}:create_repair_ticket`}
|
||||
onClick={() => onSubmit(selectedRisk, "create_repair_ticket")}
|
||||
>
|
||||
工单
|
||||
</Button>
|
||||
</div>
|
||||
{!canAckResolve ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="该风险类型当前只读"
|
||||
description="当前动作接口暂不支持该风险类型,后台保留展示但不会假装处置成功。"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardView({
|
||||
stats,
|
||||
companies,
|
||||
devices,
|
||||
risks,
|
||||
notifications,
|
||||
timeline,
|
||||
onOpenRisk,
|
||||
}: {
|
||||
stats: AdminRow;
|
||||
companies: AdminRow[];
|
||||
devices: AdminRow[];
|
||||
risks: AdminRow[];
|
||||
notifications: AdminRow[];
|
||||
timeline: AdminRow[];
|
||||
onOpenRisk: () => void;
|
||||
}) {
|
||||
const topRisks = risks.slice(0, 5);
|
||||
const topCompanies = companies.slice().sort((left, right) => numberValue(right.openRiskCount) - numberValue(left.openRiskCount)).slice(0, 6);
|
||||
return (
|
||||
<div className={`${adminDense} space-y-5`}>
|
||||
<section>
|
||||
<PanelTitle title="今日待处理" subtitle="先看需要平台侧介入的客户、设备和主 Agent 风险。" />
|
||||
<div className="grid gap-4 lg:grid-cols-5">
|
||||
<MetricCard title="客户公司" value={numberValue(stats.companies ?? companies.length)} hint="当前纳入平台管理的公司" />
|
||||
<MetricCard title="账号" value={numberValue(stats.accounts)} hint="含最高管理员与客户账号" />
|
||||
<MetricCard title="在线设备" value={numberValue(stats.onlineDevices)} tone="green" hint={`总设备 ${numberValue(stats.devices ?? devices.length)}`} />
|
||||
<MetricCard title="开放风险" value={numberValue(stats.openRisks ?? risks.length)} tone="red" hint={`关键 ${numberValue(stats.criticalRisks)}`} />
|
||||
<MetricCard title="风险通知" value={numberValue(stats.openNotifications ?? notifications.length)} tone="orange" hint="SLA 与主动通知" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[0.95fr_1.05fr]">
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="客户健康排行" subtitle="优先跟进开放风险最多或设备离线的客户。" />
|
||||
<div className="space-y-3">
|
||||
{topCompanies.length > 0 ? topCompanies.map((company) => {
|
||||
const tone = customerHealthTone(company);
|
||||
return (
|
||||
<div key={rowId(company)} className="flex items-center justify-between rounded-2xl border border-[#E3E8E4] bg-[#FBFCFB] px-4 py-3">
|
||||
<div>
|
||||
<div className="font-bold text-[#101814]">{text(company.name ?? company.companyName ?? company.companyId)}</div>
|
||||
<div className="mt-1 text-xs text-[#68746D]">
|
||||
账号 {numberValue(company.accountCount)} · 设备 {numberValue(company.onlineDeviceCount)}/{numberValue(company.deviceCount)} · 客户成功 {text(company.successOwnerAccount, "未指派")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag color={tone.color}>{tone.label}</Tag>
|
||||
<span className="text-sm font-bold text-[#E23D3D]">{numberValue(company.openRiskCount)} 风险</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}) : <EmptyBlock textValue="暂无客户数据" />}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle
|
||||
title="关键风险队列"
|
||||
subtitle="只展示最该处理的前几条,完整队列在风险与治理里。"
|
||||
extra={<Button onClick={onOpenRisk}>进入战情室</Button>}
|
||||
/>
|
||||
{topRisks.length > 0 ? (
|
||||
<Table rowKey={rowId} columns={riskColumns} dataSource={topRisks} pagination={false} size="small" />
|
||||
) : (
|
||||
<EmptyBlock textValue="暂无开放高优风险" />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[1.05fr_0.95fr]">
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="节点健康" subtitle="集中查看客户电脑、Codex GUI/CLI 与最近心跳。" />
|
||||
<Table rowKey={rowId} columns={deviceColumns} dataSource={devices.slice(0, 8)} pagination={false} size="small" />
|
||||
</Card>
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="最近事件" subtitle="风险通知和处置时间线,避免平台侧漏跟进。" />
|
||||
<div className="space-y-3">
|
||||
{[...notifications, ...timeline].slice(0, 7).map((event, index) => (
|
||||
<div key={rowId(event, index)} className="rounded-2xl border border-[#E3E8E4] bg-[#FBFCFB] px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="font-bold text-[#101814]">{text(event.title ?? event.action ?? event.kind, "事件")}</div>
|
||||
{severityTag(event.severity ?? "info")}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-[#68746D]">{text(event.createdAt ?? event.updatedAt ?? event.time, "暂无时间")}</div>
|
||||
</div>
|
||||
))}
|
||||
{notifications.length === 0 && timeline.length === 0 ? <EmptyBlock textValue="暂无风险事件" /> : null}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomersView({ companies, accounts, devices }: { companies: AdminRow[]; accounts: AdminRow[]; devices: AdminRow[] }) {
|
||||
return (
|
||||
<div className={`${adminDense} space-y-5`}>
|
||||
<div className="grid gap-5 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="客户与账号" subtitle="先看客户公司,再进入账号、设备和权限配置。" />
|
||||
<Table rowKey={rowId} columns={companyColumns} dataSource={companies} pagination={{ pageSize: 8 }} size="small" />
|
||||
</Card>
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="客户开通任务流" subtitle="把公司、老板账号、设备和 Skill 权限串成一个交付动作。" />
|
||||
<div className="space-y-3">
|
||||
{["创建客户公司", "开通老板账号", "绑定客户电脑", "分配项目与 Skill 权限"].map((item, index) => (
|
||||
<div key={item} className="flex items-center gap-3 rounded-2xl border border-[#E3E8E4] bg-[#FBFCFB] px-4 py-3">
|
||||
<span className="grid size-8 place-items-center rounded-full bg-[#101814] text-xs font-black text-white">{index + 1}</span>
|
||||
<div>
|
||||
<div className="font-bold text-[#101814]">{item}</div>
|
||||
<div className="text-xs text-[#68746D]">当前仍复用下方授权工作台写入接口,先保证链路稳定。</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="grid gap-5 lg:grid-cols-[1fr_1fr]">
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="账号列表" subtitle="查看角色、状态、公司和最近登录。" />
|
||||
<Table rowKey={rowId} columns={accountColumns} dataSource={accounts} pagination={{ pageSize: 8 }} size="small" />
|
||||
</Card>
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="客户设备" subtitle="确认设备归属和在线状态。" />
|
||||
<Table rowKey={rowId} columns={deviceColumns} dataSource={devices} pagination={{ pageSize: 8 }} size="small" />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionsView() {
|
||||
return (
|
||||
<div className={`${adminDense} space-y-5`}>
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle
|
||||
title="授权工作台"
|
||||
subtitle="按账号分配设备、项目与 Skill 权限;高危动作保留二次确认和审计。"
|
||||
/>
|
||||
<AdminAccessPanel className={adminDense} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GovernanceView({
|
||||
risks,
|
||||
notifications,
|
||||
selectedRisk,
|
||||
setSelectedRisk,
|
||||
actionBusy,
|
||||
submitRiskAction,
|
||||
}: {
|
||||
risks: AdminRow[];
|
||||
notifications: AdminRow[];
|
||||
selectedRisk?: AdminRow;
|
||||
setSelectedRisk: (risk?: AdminRow) => void;
|
||||
actionBusy: string;
|
||||
submitRiskAction: (risk: AdminRow, action: RiskAction, extraBody?: Record<string, unknown>) => void;
|
||||
}) {
|
||||
return (
|
||||
<Tabs
|
||||
className="boss-admin-governance-tabs"
|
||||
items={[
|
||||
{
|
||||
key: "risk",
|
||||
label: "风险战情室",
|
||||
children: (
|
||||
<div className="grid gap-5 lg:grid-cols-[minmax(0,1fr)_420px]">
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="风险与治理" subtitle="按严重程度、客户影响、负责人和 SLA 推进处置。" />
|
||||
<Table
|
||||
rowKey={rowId}
|
||||
columns={riskColumns}
|
||||
dataSource={risks}
|
||||
pagination={{ pageSize: 8 }}
|
||||
size="small"
|
||||
onRow={(risk) => ({
|
||||
onClick: () => setSelectedRisk(risk),
|
||||
className: rowId(risk) === rowId(selectedRisk ?? {}) ? "cursor-pointer bg-[#F1FAF4]" : "cursor-pointer",
|
||||
})}
|
||||
/>
|
||||
</Card>
|
||||
<div className="space-y-5">
|
||||
<RiskActionPanel
|
||||
key={selectedRisk ? rowId(selectedRisk) : "empty-risk"}
|
||||
selectedRisk={selectedRisk}
|
||||
actionBusy={actionBusy}
|
||||
onSubmit={submitRiskAction}
|
||||
/>
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="风险通知" subtitle="SLA 扫描和主动通知生成的待跟进事项。" />
|
||||
<Table rowKey={rowId} columns={notificationColumns} dataSource={notifications} pagination={{ pageSize: 5 }} size="small" />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "skills",
|
||||
label: "Skill 生命周期",
|
||||
children: (
|
||||
<Card className={adminCardClass}>
|
||||
<PanelTitle title="Skill 生命周期" subtitle="安装、更新、卸载、回滚和版本锁定统一排队,设备端按安全策略执行。" />
|
||||
<AdminSkillLifecyclePanel className={adminDense} />
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BossAdminApp({ initialOverview = null }: BossAdminAppProps) {
|
||||
const [overview, setOverview] = useState<BossAdminOverview | null>(initialOverview);
|
||||
const [error, setError] = useState("");
|
||||
const [actionBusy, setActionBusy] = useState("");
|
||||
const [activeSection, setActiveSection] = useState<AdminSection>("dashboard");
|
||||
const [selectedRiskId, setSelectedRiskId] = useState("");
|
||||
const [messageApi, messageContext] = message.useMessage();
|
||||
|
||||
useEffect(() => {
|
||||
if (overview) return;
|
||||
|
||||
let active = true;
|
||||
loadOverview()
|
||||
.then((nextOverview) => {
|
||||
if (active) setOverview(nextOverview);
|
||||
})
|
||||
.catch((nextError: Error) => {
|
||||
if (active) setError(nextError.message);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [overview]);
|
||||
|
||||
const stats = overview?.summary ?? overview?.stats ?? {};
|
||||
const companies = overview?.companies ?? [];
|
||||
const accounts = overview?.accounts ?? [];
|
||||
const devices = overview?.devices ?? [];
|
||||
const risks = overview?.risks ?? [];
|
||||
const notifications = overview?.notifications ?? [];
|
||||
const timeline = Array.isArray((overview as { riskTimeline?: AdminRow[] } | null)?.riskTimeline)
|
||||
? ((overview as { riskTimeline?: AdminRow[] }).riskTimeline ?? [])
|
||||
: [];
|
||||
const selectedRisk = risks.find((risk) => rowId(risk) === selectedRiskId) ?? risks[0];
|
||||
|
||||
async function refreshOverview() {
|
||||
setOverview(await loadOverview());
|
||||
}
|
||||
|
||||
async function submitRiskAction(risk: AdminRow, action: RiskAction, extraBody: Record<string, unknown> = {}) {
|
||||
const riskId = text(risk.riskId ?? risk.id, "");
|
||||
if (!riskId) return;
|
||||
setActionBusy(`${riskId}:${action}`);
|
||||
setError("");
|
||||
try {
|
||||
const response = await fetch("/api/v1/admin/risks/actions", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ riskId, action, ...extraBody }),
|
||||
});
|
||||
const payload = (await response.json().catch(() => null)) as { ok?: boolean; message?: string } | null;
|
||||
if (!response.ok || payload?.ok === false) {
|
||||
throw new Error(payload?.message || `风险动作失败:${response.status}`);
|
||||
}
|
||||
messageApi.success(
|
||||
action === "ack"
|
||||
? "已确认风险"
|
||||
: action === "resolve"
|
||||
? "已关闭风险"
|
||||
: action === "assign_owner"
|
||||
? "已指派负责人"
|
||||
: action === "set_sla"
|
||||
? "已设置 SLA"
|
||||
: "已创建修复工单",
|
||||
);
|
||||
await refreshOverview();
|
||||
} catch (nextError) {
|
||||
const messageText = nextError instanceof Error ? nextError.message : "风险动作失败";
|
||||
setError(messageText);
|
||||
messageApi.error(messageText);
|
||||
} finally {
|
||||
setActionBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
function renderActiveSection() {
|
||||
if (activeSection === "customers") {
|
||||
return <CustomersView companies={companies} accounts={accounts} devices={devices} />;
|
||||
}
|
||||
if (activeSection === "permissions") {
|
||||
return <PermissionsView />;
|
||||
}
|
||||
if (activeSection === "governance") {
|
||||
return (
|
||||
<GovernanceView
|
||||
risks={risks}
|
||||
notifications={notifications}
|
||||
selectedRisk={selectedRisk}
|
||||
setSelectedRisk={(risk) => setSelectedRiskId(risk ? rowId(risk) : "")}
|
||||
actionBusy={actionBusy}
|
||||
submitRiskAction={(risk, action, extraBody) => void submitRiskAction(risk, action, extraBody)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DashboardView
|
||||
stats={stats}
|
||||
companies={companies}
|
||||
devices={devices}
|
||||
risks={risks}
|
||||
notifications={notifications}
|
||||
timeline={timeline}
|
||||
onOpenRisk={() => setActiveSection("governance")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
algorithm: theme.defaultAlgorithm,
|
||||
token: {
|
||||
colorPrimary: "#07C160",
|
||||
borderRadius: 14,
|
||||
fontFamily: '"PingFang SC", "Microsoft YaHei", sans-serif',
|
||||
colorBgLayout: "#F3F5F2",
|
||||
colorBorderSecondary: "#E3E8E4",
|
||||
},
|
||||
components: {
|
||||
Button: {
|
||||
controlHeight: 34,
|
||||
borderRadius: 10,
|
||||
},
|
||||
Card: {
|
||||
headerFontSize: 16,
|
||||
},
|
||||
Table: {
|
||||
headerBg: "#F7F8F7",
|
||||
cellPaddingBlock: 9,
|
||||
cellPaddingInline: 10,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Refine dataProvider={createBossAdminDataProvider(initialOverview ?? undefined)} resources={resources}>
|
||||
<main className={adminShell}>
|
||||
{messageContext}
|
||||
<div className={adminChrome}>
|
||||
<aside className={adminSidebar}>
|
||||
<div className="mb-7 px-2">
|
||||
<div className="text-[24px] font-black tracking-[-0.04em] text-[#101814]">Boss</div>
|
||||
<div className="mt-1 text-xs font-semibold text-[#7A857D]">To B 总后台</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{navItems.map((item) => {
|
||||
const active = activeSection === item.key;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => setActiveSection(item.key)}
|
||||
className={[
|
||||
"flex w-full items-center gap-3 rounded-2xl px-3 py-3 text-left transition",
|
||||
active ? "bg-[#EAF8EF] text-[#075F31] shadow-[inset_0_0_0_1px_rgba(7,193,96,0.18)]" : "text-[#46524B] hover:bg-[#F2F5F2]",
|
||||
].join(" ")}
|
||||
>
|
||||
<span className={active ? "grid size-9 place-items-center rounded-xl bg-[#07C160] text-xs font-black text-white" : "grid size-9 place-items-center rounded-xl bg-white text-xs font-black text-[#7A857D] ring-1 ring-[#E3E8E4]"}>
|
||||
{item.marker}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-black">{item.title}</span>
|
||||
<span className="mt-0.5 block truncate text-xs opacity-70">{item.subtitle}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-8 rounded-2xl border border-[#E3E8E4] bg-white p-4">
|
||||
<div className="text-xs font-bold text-[#7A857D]">当前身份</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="grid size-8 place-items-center rounded-full bg-[#07C160] text-xs font-black text-white">k</span>
|
||||
<span className="text-sm font-bold text-[#101814]">highest_admin</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<section className="min-w-0 bg-[#F8FAF8]">
|
||||
<header className={adminHeader}>
|
||||
<div>
|
||||
<div className="text-[26px] font-black tracking-[-0.04em] text-[#101814]">{sectionTitle(activeSection)}</div>
|
||||
<div className="mt-1 text-sm text-[#68746D]">{currentSubtitle(activeSection)}</div>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<Button onClick={() => void refreshOverview()}>刷新</Button>
|
||||
<div className="rounded-full border border-[#E3E8E4] bg-white px-4 py-2 text-sm font-semibold text-[#4B5750]">
|
||||
平台最高管理员
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div className="p-7">
|
||||
{error ? <Alert className="mb-5" type="warning" showIcon message="后台数据暂不可用" description={error} /> : null}
|
||||
{renderActiveSection()}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</Refine>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
121
src/components/admin/boss-admin-data-provider.ts
Normal file
121
src/components/admin/boss-admin-data-provider.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type {
|
||||
BaseRecord,
|
||||
CreateResponse,
|
||||
DataProvider,
|
||||
DeleteOneResponse,
|
||||
DeleteOneParams,
|
||||
GetListResponse,
|
||||
GetListParams,
|
||||
GetOneResponse,
|
||||
GetOneParams,
|
||||
CreateParams,
|
||||
UpdateParams,
|
||||
UpdateResponse,
|
||||
} from "@refinedev/core";
|
||||
|
||||
export type BossAdminSeverity = "critical" | "high" | "medium" | "low" | "info";
|
||||
|
||||
export type BossAdminOverview = {
|
||||
ok?: boolean;
|
||||
summary?: {
|
||||
companies?: number;
|
||||
accounts?: number;
|
||||
devices?: number;
|
||||
onlineDevices?: number;
|
||||
openRisks?: number;
|
||||
openNotifications?: number;
|
||||
criticalRisks?: number;
|
||||
};
|
||||
stats?: {
|
||||
companies?: number;
|
||||
accounts?: number;
|
||||
devices?: number;
|
||||
onlineDevices?: number;
|
||||
openRisks?: number;
|
||||
openNotifications?: number;
|
||||
criticalRisks?: number;
|
||||
};
|
||||
companies?: Array<Record<string, unknown>>;
|
||||
accounts?: Array<Record<string, unknown>>;
|
||||
devices?: Array<Record<string, unknown>>;
|
||||
risks?: Array<Record<string, unknown>>;
|
||||
notifications?: Array<Record<string, unknown>>;
|
||||
auditLogs?: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
const resourceKeys = new Set(["companies", "accounts", "devices", "risks", "notifications", "auditLogs"]);
|
||||
|
||||
async function fetchOverview() {
|
||||
const response = await fetch("/api/v1/admin/overview", {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load admin overview: ${response.status}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as BossAdminOverview;
|
||||
}
|
||||
|
||||
function listFromOverview(overview: BossAdminOverview | undefined, resource: string) {
|
||||
if (!resourceKeys.has(resource)) return [];
|
||||
const value = overview?.[resource as keyof BossAdminOverview];
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function recordId(item: Record<string, unknown>, index: number) {
|
||||
return String(item.id ?? item.companyId ?? item.account ?? item.deviceId ?? item.riskId ?? item.auditId ?? index);
|
||||
}
|
||||
|
||||
export function createBossAdminDataProvider(initialOverview?: BossAdminOverview): DataProvider {
|
||||
let overviewCache = initialOverview;
|
||||
|
||||
return {
|
||||
getList: async <TData extends BaseRecord = BaseRecord>({ resource }: GetListParams) => {
|
||||
if (!overviewCache) {
|
||||
overviewCache = await fetchOverview();
|
||||
}
|
||||
|
||||
const data = listFromOverview(overviewCache, resource).map((item, index) => ({
|
||||
id: recordId(item, index),
|
||||
...item,
|
||||
})) as TData[];
|
||||
|
||||
return {
|
||||
data,
|
||||
total: data.length,
|
||||
} satisfies GetListResponse<TData>;
|
||||
},
|
||||
getOne: async <TData extends BaseRecord = BaseRecord>({ resource, id }: GetOneParams) => {
|
||||
if (!overviewCache) {
|
||||
overviewCache = await fetchOverview();
|
||||
}
|
||||
|
||||
const item = listFromOverview(overviewCache, resource).find((entry, index) => {
|
||||
return recordId(entry, index) === String(id);
|
||||
});
|
||||
|
||||
return {
|
||||
data: {
|
||||
id,
|
||||
...(item ?? {}),
|
||||
} as TData,
|
||||
} satisfies GetOneResponse<TData>;
|
||||
},
|
||||
create: async <TData extends BaseRecord = BaseRecord, TVariables = unknown>({
|
||||
variables,
|
||||
}: CreateParams<TVariables>) =>
|
||||
({ data: variables as unknown as TData }) satisfies CreateResponse<TData>,
|
||||
update: async <TData extends BaseRecord = BaseRecord, TVariables = unknown>({
|
||||
id,
|
||||
variables,
|
||||
}: UpdateParams<TVariables>) =>
|
||||
({ data: { id, ...(variables as BaseRecord) } as TData }) satisfies UpdateResponse<TData>,
|
||||
deleteOne: async <TData extends BaseRecord = BaseRecord, TVariables = unknown>({
|
||||
id,
|
||||
}: DeleteOneParams<TVariables>) =>
|
||||
({ data: { id } as TData }) satisfies DeleteOneResponse<TData>,
|
||||
getApiUrl: () => "/api/v1/admin/overview",
|
||||
};
|
||||
}
|
||||
@@ -77,6 +77,12 @@ export function buildDeviceWorkspaceDetailCards(workspace: DeviceWorkspaceView)
|
||||
items: {
|
||||
gui: `GUI:${selectedDevice?.capabilities?.gui?.connected ? "已连接" : "未连接"}`,
|
||||
cli: `CLI:${selectedDevice?.capabilities?.cli?.connected ? "已连接" : "未连接"}`,
|
||||
browserAutomation: `浏览器自动化:${
|
||||
selectedDevice?.capabilities?.browserAutomation?.connected ? "已连接" : "未连接"
|
||||
}`,
|
||||
computerUse: `桌面控制:${
|
||||
selectedDevice?.capabilities?.computerUse?.connected ? "已连接" : "未连接"
|
||||
}`,
|
||||
preferredExecutionMode: `默认执行模式:${
|
||||
selectedDevice?.preferredExecutionMode === "gui"
|
||||
? "GUI"
|
||||
@@ -116,12 +122,17 @@ async function waitForLoginSessionReady(nativeClient: boolean) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function navigateToConversations(router: ReturnType<typeof useRouter>) {
|
||||
router.replace("/conversations", { scroll: false });
|
||||
function resolvePostLoginPath() {
|
||||
return window.location.hostname === "admin.boss.hyzq.net" ? "/admin" : "/conversations";
|
||||
}
|
||||
|
||||
function navigateAfterLogin(router: ReturnType<typeof useRouter>) {
|
||||
const targetPath = resolvePostLoginPath();
|
||||
router.replace(targetPath, { scroll: false });
|
||||
router.refresh();
|
||||
window.setTimeout(() => {
|
||||
if (window.location.pathname !== "/conversations") {
|
||||
window.location.replace("/conversations");
|
||||
if (window.location.pathname !== targetPath) {
|
||||
window.location.replace(targetPath);
|
||||
}
|
||||
}, 180);
|
||||
}
|
||||
@@ -686,6 +697,12 @@ export function DeviceEditorCard({
|
||||
<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.browserAutomation}
|
||||
</div>
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">
|
||||
{detailCards.capabilities.items.computerUse}
|
||||
</div>
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-3 py-2">
|
||||
{detailCards.capabilities.items.preferredExecutionMode}
|
||||
</div>
|
||||
@@ -1995,7 +2012,7 @@ export function AuthForm({
|
||||
}
|
||||
|
||||
await waitForLoginSessionReady(nativeClient);
|
||||
navigateToConversations(router);
|
||||
navigateAfterLogin(router);
|
||||
return;
|
||||
}
|
||||
if (result.ok && mode === "register") {
|
||||
@@ -2067,8 +2084,7 @@ export function AuthForm({
|
||||
<>
|
||||
<AuthCodeField label="登录验证码" value={code} onChange={setCode} onSend={sendCode} />
|
||||
<div className="border-t border-[#E5E5EA] px-4 py-3 text-[12px] leading-5 text-[#57606A]">
|
||||
当前固定验证码模式下,可直接输入 <span className="font-semibold text-[#111111]">000000</span>{" "}
|
||||
登录;如需确认账号状态,也可以先点“发送验证码”。
|
||||
验证码会按当前服务器配置发送;如果企业仍处于固定验证码演示模式,请以管理员配置为准。
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
@@ -2230,7 +2246,7 @@ function Field({
|
||||
export function DeviceEnrollmentBuilder() {
|
||||
const [name, setName] = useState("Mac Mini");
|
||||
const [avatar, setAvatar] = useState("M");
|
||||
const [account, setAccount] = useState("17600003315");
|
||||
const [account, setAccount] = useState("krisolo");
|
||||
const [projects, setProjects] = useState("");
|
||||
const [endpoint, setEndpoint] = useState("mac://new-device.local");
|
||||
const [note, setNote] = useState("新设备待绑定");
|
||||
|
||||
118
src/components/session-management-client.tsx
Normal file
118
src/components/session-management-client.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import clsx from "clsx";
|
||||
|
||||
type SessionSummary = {
|
||||
sessionId: string;
|
||||
account: string;
|
||||
role: string;
|
||||
displayName: string;
|
||||
loginMethod: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
lastSeenAt: string;
|
||||
current: boolean;
|
||||
};
|
||||
|
||||
function formatTime(value: string) {
|
||||
return new Date(value).toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
export function SessionManagementClient({ initialSessions }: { initialSessions: SessionSummary[] }) {
|
||||
const router = useRouter();
|
||||
const [sessions, setSessions] = useState(initialSessions);
|
||||
const [busySessionId, setBusySessionId] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
async function refresh() {
|
||||
const response = await fetch("/api/v1/auth/sessions", { cache: "no-store" });
|
||||
const result = (await response.json()) as { ok: boolean; sessions?: SessionSummary[]; message?: string };
|
||||
if (!response.ok || !result.ok) {
|
||||
throw new Error(result.message ?? "刷新失败");
|
||||
}
|
||||
setSessions(result.sessions ?? []);
|
||||
}
|
||||
|
||||
async function revoke(sessionId: string, current: boolean) {
|
||||
setBusySessionId(sessionId);
|
||||
setMessage("");
|
||||
try {
|
||||
const response = await fetch("/api/v1/auth/sessions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ action: "revoke_session", sessionId }),
|
||||
});
|
||||
const result = (await response.json()) as { ok: boolean; message?: string };
|
||||
if (!response.ok || !result.ok) {
|
||||
throw new Error(result.message ?? "撤销失败");
|
||||
}
|
||||
if (current) {
|
||||
router.replace("/auth/login");
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
setMessage("会话已撤销。");
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : "撤销失败");
|
||||
} finally {
|
||||
setBusySessionId("");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<div className="text-[16px] font-semibold text-[#111111]">登录会话</div>
|
||||
<div className="mt-1 text-[12px] leading-5 text-[#8C8C8C]">
|
||||
管理当前账号的登录端;最高管理员可看到所有账号的会话。
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[13px] text-[#8C8C8C]">
|
||||
暂无可管理会话。
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((session) => (
|
||||
<div key={session.sessionId} className="rounded-2xl bg-[#F7F8FA] px-4 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="truncate text-[14px] font-semibold text-[#111111]">
|
||||
{session.displayName || session.account}
|
||||
</div>
|
||||
{session.current ? (
|
||||
<span className="rounded-full bg-[#DFF4E8] px-2 py-0.5 text-[10px] font-semibold text-[#215B39]">
|
||||
当前
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] leading-5 text-[#57606A]">
|
||||
{session.account} · {session.loginMethod === "code" ? "验证码" : "账号密码"}
|
||||
<br />
|
||||
最近活跃:{formatTime(session.lastSeenAt)}
|
||||
<br />
|
||||
到期:{formatTime(session.expiresAt)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void revoke(session.sessionId, session.current)}
|
||||
disabled={busySessionId === session.sessionId}
|
||||
className={clsx(
|
||||
"shrink-0 rounded-full px-3 py-2 text-[12px] font-semibold",
|
||||
session.current ? "bg-[#FF3B30] text-white" : "bg-white text-[#FF3B30]",
|
||||
)}
|
||||
>
|
||||
{busySessionId === session.sessionId ? "处理中" : "撤销"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{message ? <div className="mt-3 text-[12px] text-[#57606A]">{message}</div> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
466
src/components/telegram-integration-client.tsx
Normal file
466
src/components/telegram-integration-client.tsx
Normal file
@@ -0,0 +1,466 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import clsx from "clsx";
|
||||
|
||||
type TelegramGatewayView = {
|
||||
enabled: boolean;
|
||||
mode: "webhook" | "polling";
|
||||
botTokenConfigured: boolean;
|
||||
botUsername?: string;
|
||||
dmPolicy: "allowlist" | "open" | "disabled";
|
||||
allowFrom: string[];
|
||||
groupPolicy: "allowlist" | "open" | "disabled";
|
||||
groups: string[];
|
||||
requireMentionInGroups: boolean;
|
||||
defaultProjectId: string;
|
||||
groupProjectRoutes: Array<{ chatId: string; threadId?: number; projectId: string; label?: string }>;
|
||||
webhookSecretConfigured: boolean;
|
||||
webhookUrl?: string;
|
||||
lastConfiguredAt?: string;
|
||||
lastConfiguredBy?: string;
|
||||
lastError?: string;
|
||||
processedUpdateCount: number;
|
||||
};
|
||||
|
||||
type Draft = {
|
||||
enabled: boolean;
|
||||
mode: "webhook" | "polling";
|
||||
botToken: string;
|
||||
dmPolicy: "allowlist" | "open" | "disabled";
|
||||
allowFromText: string;
|
||||
groupPolicy: "allowlist" | "open" | "disabled";
|
||||
groupsText: string;
|
||||
requireMentionInGroups: boolean;
|
||||
defaultProjectId: string;
|
||||
groupProjectRoutesText: string;
|
||||
webhookSecret: string;
|
||||
webhookUrl: string;
|
||||
};
|
||||
|
||||
function draftFromView(view: TelegramGatewayView): Draft {
|
||||
return {
|
||||
enabled: view.enabled,
|
||||
mode: view.mode,
|
||||
botToken: "",
|
||||
dmPolicy: view.dmPolicy,
|
||||
allowFromText: view.allowFrom.join("\n"),
|
||||
groupPolicy: view.groupPolicy,
|
||||
groupsText: view.groups.join("\n"),
|
||||
requireMentionInGroups: view.requireMentionInGroups,
|
||||
defaultProjectId: view.defaultProjectId,
|
||||
groupProjectRoutesText: formatGroupProjectRoutes(view.groupProjectRoutes),
|
||||
webhookSecret: "",
|
||||
webhookUrl: view.webhookUrl ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function parseLines(value: string) {
|
||||
return value
|
||||
.split(/\r?\n/)
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function formatGroupProjectRoutes(routes: TelegramGatewayView["groupProjectRoutes"]) {
|
||||
return routes
|
||||
.map((route) => {
|
||||
const chatPart = route.threadId != null ? `${route.chatId}#${route.threadId}` : route.chatId;
|
||||
return [chatPart, route.projectId, route.label].filter(Boolean).join(" ");
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function parseGroupProjectRoutes(value: string) {
|
||||
return parseLines(value)
|
||||
.map((line) => {
|
||||
const [chatAndTopic, projectId, ...labelParts] = line.split(/\s+/);
|
||||
if (!chatAndTopic || !projectId) {
|
||||
return null;
|
||||
}
|
||||
const [chatId, threadIdRaw] = chatAndTopic.split("#");
|
||||
const threadId = Number(threadIdRaw);
|
||||
return {
|
||||
chatId,
|
||||
...(threadIdRaw && Number.isFinite(threadId) ? { threadId } : {}),
|
||||
projectId,
|
||||
...(labelParts.length > 0 ? { label: labelParts.join(" ") } : {}),
|
||||
};
|
||||
})
|
||||
.filter((route): route is { chatId: string; threadId?: number; projectId: string; label?: string } =>
|
||||
Boolean(route?.chatId && route.projectId),
|
||||
);
|
||||
}
|
||||
|
||||
function SectionTitle({ title, note }: { title: string; note?: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[16px] font-semibold text-[#111111]">{title}</div>
|
||||
{note ? <div className="mt-1 text-[12px] leading-5 text-[#8C8C8C]">{note}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TextField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
secret = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
secret?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="block space-y-1.5">
|
||||
<div className="text-[12px] font-medium text-[#57606A]">{label}</div>
|
||||
<input
|
||||
type={secret ? "password" : "text"}
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full rounded-2xl border border-[#E5E5EA] bg-[#F7F8FA] px-4 py-3 text-[14px] text-[#111111] outline-none transition focus:border-[#07C160] focus:bg-white"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function TextAreaField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="block space-y-1.5">
|
||||
<div className="text-[12px] font-medium text-[#57606A]">{label}</div>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
rows={4}
|
||||
className="w-full rounded-2xl border border-[#E5E5EA] bg-[#F7F8FA] px-4 py-3 text-[14px] leading-6 text-[#111111] outline-none transition focus:border-[#07C160] focus:bg-white"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function TogglePill({
|
||||
active,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
active: boolean;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={clsx(
|
||||
"rounded-full px-4 py-2 text-[13px] font-semibold transition",
|
||||
active ? "bg-[#07C160] text-white" : "bg-[#F5F5F7] text-[#57606A]",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function TelegramIntegrationClient({ initialView }: { initialView: TelegramGatewayView }) {
|
||||
const router = useRouter();
|
||||
const [view, setView] = useState(initialView);
|
||||
const [draft, setDraft] = useState(() => draftFromView(initialView));
|
||||
const [busy, setBusy] = useState<null | "save" | "test">(null);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const webhookPath = useMemo(() => "/api/v1/integrations/telegram/webhook", []);
|
||||
|
||||
async function submit(kind: "save" | "test") {
|
||||
setBusy(kind);
|
||||
setMessage("");
|
||||
try {
|
||||
const response = await fetch("/api/v1/integrations/telegram", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
enabled: draft.enabled,
|
||||
mode: draft.mode,
|
||||
botToken: draft.botToken.trim() || undefined,
|
||||
dmPolicy: draft.dmPolicy,
|
||||
allowFrom: parseLines(draft.allowFromText),
|
||||
groupPolicy: draft.groupPolicy,
|
||||
groups: parseLines(draft.groupsText),
|
||||
requireMentionInGroups: draft.requireMentionInGroups,
|
||||
defaultProjectId: draft.defaultProjectId.trim() || "master-agent",
|
||||
groupProjectRoutes: parseGroupProjectRoutes(draft.groupProjectRoutesText),
|
||||
webhookSecret: draft.webhookSecret.trim() || undefined,
|
||||
webhookUrl: draft.webhookUrl.trim() || undefined,
|
||||
testConnection: kind === "test",
|
||||
}),
|
||||
});
|
||||
const result = (await response.json()) as {
|
||||
ok: boolean;
|
||||
message?: string;
|
||||
telegram?: TelegramGatewayView;
|
||||
probe?: { ok: boolean; username?: string };
|
||||
};
|
||||
if (!response.ok || !result.ok || !result.telegram) {
|
||||
setMessage(result.message ?? "保存失败。");
|
||||
return;
|
||||
}
|
||||
const nextView = result.telegram;
|
||||
setView(nextView);
|
||||
setDraft((current) => ({
|
||||
...draftFromView(nextView),
|
||||
botToken: "",
|
||||
webhookSecret: "",
|
||||
webhookUrl: current.webhookUrl,
|
||||
}));
|
||||
setMessage(
|
||||
kind === "test"
|
||||
? `连接测试通过${result.probe?.username ? `,当前 bot:@${result.probe.username}` : ""}。`
|
||||
: "Telegram 配置已保存。",
|
||||
);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : "请求失败。");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 px-[18px] pb-6">
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<SectionTitle title="当前状态" note="当前这里只管理 Boss 作为 Telegram Bot 的接入能力。" />
|
||||
<div className="mt-3 space-y-2 text-[13px] leading-6 text-[#57606A]">
|
||||
<div>
|
||||
开关状态:<span className="font-semibold text-[#111111]">{view.enabled ? "已开启" : "已关闭"}</span>
|
||||
</div>
|
||||
<div>
|
||||
接入模式:<span className="font-semibold text-[#111111]">{view.mode === "webhook" ? "Webhook" : "Polling"}</span>
|
||||
</div>
|
||||
<div>
|
||||
Bot Token:<span className="font-semibold text-[#111111]">{view.botTokenConfigured ? "已配置" : "未配置"}</span>
|
||||
</div>
|
||||
<div>
|
||||
Webhook Secret:
|
||||
<span className="font-semibold text-[#111111]">
|
||||
{view.webhookSecretConfigured ? " 已配置" : " 未配置"}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
默认项目:<span className="font-semibold text-[#111111]">{view.defaultProjectId}</span>
|
||||
</div>
|
||||
<div>
|
||||
已处理 update:<span className="font-semibold text-[#111111]">{view.processedUpdateCount}</span>
|
||||
</div>
|
||||
{view.botUsername ? (
|
||||
<div>
|
||||
当前 bot:<span className="font-semibold text-[#111111]">@{view.botUsername}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<SectionTitle title="接入开关" note="这里控制 Telegram 是否真正接受消息。" />
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<TogglePill
|
||||
active={draft.enabled}
|
||||
label="开启接入"
|
||||
onClick={() => setDraft((current) => ({ ...current, enabled: true }))}
|
||||
/>
|
||||
<TogglePill
|
||||
active={!draft.enabled}
|
||||
label="关闭接入"
|
||||
onClick={() => setDraft((current) => ({ ...current, enabled: false }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<SectionTitle title="Bot 配置" note="Token 和 Secret 留空时会沿用已保存值,不会被清掉。" />
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<TogglePill
|
||||
active={draft.mode === "webhook"}
|
||||
label="Webhook"
|
||||
onClick={() => setDraft((current) => ({ ...current, mode: "webhook" }))}
|
||||
/>
|
||||
<TogglePill
|
||||
active={draft.mode === "polling"}
|
||||
label="Polling"
|
||||
onClick={() => setDraft((current) => ({ ...current, mode: "polling" }))}
|
||||
/>
|
||||
</div>
|
||||
<TextField
|
||||
label="Bot Token"
|
||||
value={draft.botToken}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, botToken: value }))}
|
||||
placeholder={view.botTokenConfigured ? "已配置,留空表示沿用当前 token" : "输入 Telegram Bot Token"}
|
||||
secret
|
||||
/>
|
||||
<TextField
|
||||
label="Webhook Secret"
|
||||
value={draft.webhookSecret}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, webhookSecret: value }))}
|
||||
placeholder={
|
||||
view.webhookSecretConfigured ? "已配置,留空表示沿用当前 secret" : "建议配置一个 webhook secret"
|
||||
}
|
||||
secret
|
||||
/>
|
||||
<TextField
|
||||
label="Webhook URL(可选)"
|
||||
value={draft.webhookUrl}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, webhookUrl: value }))}
|
||||
placeholder="例如 https://boss.hyzq.net/api/v1/integrations/telegram/webhook"
|
||||
/>
|
||||
<TextField
|
||||
label="默认路由项目"
|
||||
value={draft.defaultProjectId}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, defaultProjectId: value }))}
|
||||
placeholder="默认 master-agent"
|
||||
/>
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||||
当前 webhook 路径:<span className="font-semibold text-[#111111]">{webhookPath}</span>
|
||||
<br />
|
||||
建议把公开 URL 配成:`域名 + {webhookPath}`。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-[#E5E5EA] bg-white px-4 py-4">
|
||||
<SectionTitle title="访问控制" note="支持私聊 allowlist 和群白名单,默认优先安全。" />
|
||||
<div className="mt-3 space-y-3">
|
||||
<div>
|
||||
<div className="mb-2 text-[12px] font-medium text-[#57606A]">私聊策略</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<TogglePill
|
||||
active={draft.dmPolicy === "allowlist"}
|
||||
label="Allowlist"
|
||||
onClick={() => setDraft((current) => ({ ...current, dmPolicy: "allowlist" }))}
|
||||
/>
|
||||
<TogglePill
|
||||
active={draft.dmPolicy === "open"}
|
||||
label="开放"
|
||||
onClick={() => setDraft((current) => ({ ...current, dmPolicy: "open" }))}
|
||||
/>
|
||||
<TogglePill
|
||||
active={draft.dmPolicy === "disabled"}
|
||||
label="关闭"
|
||||
onClick={() => setDraft((current) => ({ ...current, dmPolicy: "disabled" }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TextAreaField
|
||||
label="允许私聊的 Telegram 用户 ID(每行一个)"
|
||||
value={draft.allowFromText}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, allowFromText: value }))}
|
||||
placeholder={"123456789\n987654321"}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 text-[12px] font-medium text-[#57606A]">群聊策略</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<TogglePill
|
||||
active={draft.groupPolicy === "allowlist"}
|
||||
label="白名单"
|
||||
onClick={() => setDraft((current) => ({ ...current, groupPolicy: "allowlist" }))}
|
||||
/>
|
||||
<TogglePill
|
||||
active={draft.groupPolicy === "open"}
|
||||
label="开放"
|
||||
onClick={() => setDraft((current) => ({ ...current, groupPolicy: "open" }))}
|
||||
/>
|
||||
<TogglePill
|
||||
active={draft.groupPolicy === "disabled"}
|
||||
label="关闭"
|
||||
onClick={() => setDraft((current) => ({ ...current, groupPolicy: "disabled" }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TextAreaField
|
||||
label="允许的群 chat ID(每行一个)"
|
||||
value={draft.groupsText}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, groupsText: value }))}
|
||||
placeholder={"-1001234567890\n-1009876543210"}
|
||||
/>
|
||||
|
||||
<TextAreaField
|
||||
label="群 / Topic 路由到 Boss 项目"
|
||||
value={draft.groupProjectRoutesText}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, groupProjectRoutesText: value }))}
|
||||
placeholder={"-1001234567890 audit-collab 审计群\n-1001234567890#12 master-agent 主控 Topic"}
|
||||
/>
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||||
每行格式:<span className="font-semibold text-[#111111]">chatId[#topicId] projectId 可选备注</span>。
|
||||
未命中路由时会回到默认项目。
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<TogglePill
|
||||
active={draft.requireMentionInGroups}
|
||||
label="群聊需 @Bot"
|
||||
onClick={() => setDraft((current) => ({ ...current, requireMentionInGroups: true }))}
|
||||
/>
|
||||
<TogglePill
|
||||
active={!draft.requireMentionInGroups}
|
||||
label="群聊免 @"
|
||||
onClick={() => setDraft((current) => ({ ...current, requireMentionInGroups: false }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||||
开启后,群里只有 <span className="font-semibold text-[#111111]">@Bot</span> 或直接回复 Bot 上一条消息时,才会进入主 Agent。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => void submit("test")}
|
||||
className="rounded-full border border-[#E5E5EA] bg-white px-4 py-3 text-[14px] font-semibold text-[#111111] disabled:opacity-60"
|
||||
>
|
||||
{busy === "test" ? "测试中" : "测试连接"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => void submit("save")}
|
||||
className="rounded-full bg-[#07C160] px-4 py-3 text-[14px] font-semibold text-white disabled:opacity-60"
|
||||
>
|
||||
{busy === "save" ? "保存中" : "保存配置"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message ? (
|
||||
<div className="rounded-2xl bg-[#F7F8FA] px-4 py-3 text-[12px] leading-6 text-[#57606A]">
|
||||
{message}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{view.lastError ? (
|
||||
<div className="rounded-2xl border border-[#FFD6D6] bg-[#FFF5F5] px-4 py-3 text-[12px] leading-6 text-[#B42318]">
|
||||
最近错误:{view.lastError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user