Files
workphone-sdk/sdk/android-ui/src/lib/consoleApi.ts
2026-08-10 05:39:59 +08:00

324 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
EMPTY_CONSOLE_DATA,
emptyResource,
type ApiResource,
type AuthorizationGrant,
type AIOperationSummary,
type ConsoleApiData,
type ConsoleDevice,
type ConsoleDeviceGroup,
type ConsoleOverview,
type ConsoleRelease,
type EngineSummary,
type IntegrationApplication,
type IntegrationUsage,
type OverviewMetric,
type SecurityModule,
type SecurityModuleState,
} from "../types/console";
type RecordValue = Record<string, unknown>;
const isRecord = (value: unknown): value is RecordValue =>
typeof value === "object" && value !== null && !Array.isArray(value);
const text = (value: unknown): string | undefined => {
if (typeof value === "string" && value.trim()) return value;
if (typeof value === "number") return String(value);
return undefined;
};
const boolean = (value: unknown): boolean | undefined => {
if (typeof value === "boolean") return value;
if (["true", "1", "online", "connected", "running", "ready"].includes(String(value).toLowerCase())) return true;
if (["false", "0", "offline", "disconnected", "stopped"].includes(String(value).toLowerCase())) return false;
return undefined;
};
const number = (value: unknown): number | undefined => {
if (value === null || value === undefined || value === "") return undefined;
const parsed = typeof value === "number" ? value : Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
};
const unwrap = (payload: unknown): unknown => {
if (!isRecord(payload)) return payload;
return payload.data ?? payload;
};
const errorMessage = (payload: unknown, fallback: string) => {
if (isRecord(payload)) {
const detail = payload.detail;
if (isRecord(detail)) return text(detail.message) || text(payload.message) || fallback;
return text(payload.message) || text(detail) || fallback;
}
return fallback;
};
const sourceTime = (value: unknown): string | undefined => {
if (!isRecord(value)) return undefined;
return text(value.sampled_at) || text(value.sampledAt) || text(value.server_time) || text(value.serverTime) || text(value.updated_at) || text(value.updatedAt);
};
async function readJson(path: string): Promise<{ ok: true; payload: unknown; status: number } | { ok: false; status: number; message: string }> {
try {
const response = await fetch(path, { method: "GET", credentials: "same-origin", headers: { Accept: "application/json" } });
const payload = await response.json().catch(() => ({}));
if (!response.ok) return { ok: false, status: response.status, message: errorMessage(payload, `读取接口失败(${response.status}`) };
return { ok: true, payload, status: response.status };
} catch (error) {
return { ok: false, status: 0, message: error instanceof Error ? error.message : "读取接口失败" };
}
}
async function writeJson(path: string, method: "POST" | "PUT" | "DELETE", body?: unknown): Promise<unknown> {
const response = await fetch(path, { method, credentials: "same-origin", headers: { Accept: "application/json", "Content-Type": "application/json", "X-Actor-Id": "console" }, body: body === undefined ? undefined : JSON.stringify(body) });
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(errorMessage(payload, `操作失败(${response.status}`));
return unwrap(payload);
}
export async function readSecurityModules(): Promise<SecurityModuleState> {
const result = await readJson("/api/v3/security/modules");
if (!result.ok) throw new Error(result.message);
const value = unwrap(result.payload);
if (!isRecord(value) || !Array.isArray(value.modules)) throw new Error("安全模块接口返回格式错误");
return value as unknown as SecurityModuleState;
}
export async function setSecurityGlobal(enabled: boolean): Promise<SecurityModuleState> {
return await writeJson("/api/v3/security/modules/global", "PUT", { enabled }) as SecurityModuleState;
}
export async function updateSecurityModule(id: string, body: { enabled?: boolean; name?: string; description?: string; config?: Record<string, unknown> }): Promise<SecurityModule> {
return await writeJson(`/api/v3/security/modules/${encodeURIComponent(id)}`, "PUT", body) as SecurityModule;
}
export async function createSecurityModule(body: { id: string; name: string; description: string; runtime_binding: string; config: Record<string, unknown> }): Promise<SecurityModule> {
return await writeJson("/api/v3/security/modules", "POST", body) as SecurityModule;
}
export async function removeSecurityModule(id: string): Promise<void> {
await writeJson(`/api/v3/security/modules/${encodeURIComponent(id)}`, "DELETE");
}
const resource = <T>(source: string, result: Awaited<ReturnType<typeof readJson>>, data?: T, sourceAt?: string): ApiResource<T> => {
if (result.ok) return { state: "ready", data, source, sourceAt, status: result.status };
return {
state: result.status === 401 || result.status === 403 ? "forbidden" : "unavailable",
source,
status: result.status,
error: result.message,
};
};
async function readFirst<T>(paths: string[], source: string, parse: (payload: unknown) => { data?: T; sourceAt?: string }): Promise<ApiResource<T>> {
let last: Awaited<ReturnType<typeof readJson>> | undefined;
for (const path of paths) {
const result = await readJson(path);
if (result.ok) {
const parsed = parse(unwrap(result.payload));
return resource(`GET ${path}`, result, parsed.data, parsed.sourceAt || sourceTime(unwrap(result.payload)));
}
last = result;
if (result.status !== 404) break;
}
return resource(source, last || { ok: false, status: 0, message: "读取接口失败" });
}
const asList = (value: unknown, keys: string[]): unknown[] => {
if (Array.isArray(value)) return value;
if (!isRecord(value)) return [];
for (const key of keys) if (Array.isArray(value[key])) return value[key] as unknown[];
return [];
};
const stringList = (value: unknown): string[] | undefined => {
if (!Array.isArray(value)) return undefined;
const values = value.filter((item): item is string => typeof item === "string" && item.trim().length > 0);
return values.length ? values : undefined;
};
const normalizeDevice = (item: unknown): ConsoleDevice | undefined => {
if (!isRecord(item)) return undefined;
const deviceId = text(item.device_id) || text(item.deviceId) || text(item.id);
if (!deviceId) return undefined;
const rawWechat = isRecord(item.wechat) ? item.wechat : {};
const rawAgent = isRecord(item.agent) ? item.agent : {};
const rawHook = isRecord(item.hook) ? item.hook : {};
// NAS 设备目录保留注册档案和心跳快照;控制台必须同时读取这两层真实上报。
const deviceProfile = isRecord(item.device_profile) ? item.device_profile : {};
const quickStatus = isRecord(item.quick_status) ? item.quick_status : {};
const lastStatus = isRecord(item.last_status) ? item.last_status : {};
const runtime = Object.keys(quickStatus).length ? quickStatus : lastStatus;
return {
deviceId,
name: text(item.name) || text(item.device_name) || text(deviceProfile.name),
model: text(item.model) || text(item.model_name) || text(deviceProfile.model),
androidVersion: text(item.android_version) || text(item.androidVersion) || text(item.os_version) || text(deviceProfile.android_version) || text(deviceProfile.os_version),
projectId: text(item.project_id) || text(item.projectId),
groupId: text(item.group_id) || text(item.groupId) || text(item.project_id),
status: text(item.status),
online: boolean(item.online) ?? boolean(item.ws_online) ?? (text(item.status) === "online" ? true : undefined),
agentVersion: text(item.agent_version) || text(item.agentVersion) || text(item.app_version) || text(deviceProfile.app_version),
agentRunning: boolean(item.agent_running) ?? boolean(rawAgent.running) ?? boolean(runtime.agent_running),
hookAvailable: boolean(item.hook_available) ?? boolean(item.supports_hook) ?? boolean(rawHook.available),
wechatRunning: boolean(item.wechat_running) ?? boolean(rawWechat.running) ?? boolean(runtime.wechat_running) ?? boolean(isRecord(runtime.wechat) ? runtime.wechat.running : undefined),
wechatId: text(item.wxid) || text(item.wechat_id) || text(rawWechat.id) || text(rawWechat.wechat_id) || text(runtime.wxid) || text(runtime.wechat_id),
friendCount: number(item.friend_count) ?? number(rawWechat.friend_count) ?? number(runtime.friend_count),
wechatVersion: text(item.wechat_version) || text(rawWechat.version) || text(runtime.wechat_version) || text(deviceProfile.wechat_version),
batteryPercent: number(item.battery_percent) ?? number(item.batteryPercent) ?? number(isRecord(item.battery) ? item.battery.percent : undefined) ?? number(runtime.battery_level) ?? number(runtime.battery),
networkType: text(item.network_type) || text(item.networkType) || text(isRecord(item.network) ? item.network.type : undefined) || text(runtime.network_type),
healthScore: number(item.health_score) ?? number(item.healthScore) ?? number(isRecord(item.health) ? item.health.score : undefined) ?? number(runtime.health_score),
tags: stringList(item.tags) || stringList(item.labels) || stringList(item.tag_names),
lastHeartbeat: text(item.last_heartbeat) || text(item.lastHeartbeat),
sourceAt: sourceTime(item) || text(item.last_heartbeat),
capabilities: Array.isArray(item.capabilities) ? item.capabilities.filter((entry): entry is string => typeof entry === "string") : undefined,
sourceKind: item.source_kind === "history" || item.sourceKind === "history" ? "history" : item.source_kind === "fixture" || item.sourceKind === "fixture" ? "fixture" : "live",
sourceLabel: text(item.source_label) || text(item.sourceLabel),
statusSource: text(item.status_source) || text(item.statusSource),
raw: item,
};
};
const parseDevices = (value: unknown) => {
const rows = asList(value, ["devices", "items"]);
return rows.flatMap((item) => {
const normalized = normalizeDevice(item);
return normalized ? [normalized] : [];
});
};
const readReadonlyDeviceStateFixture = (): { label: string; devices: ConsoleDevice[] } | undefined => {
const encoded = new URLSearchParams(window.location.search).get("device_state_fixture");
if (!encoded) return undefined;
try {
const payload = JSON.parse(decodeURIComponent(escape(window.atob(encoded)))) as RecordValue;
if (payload.mode !== "readonly_fixture" || typeof payload.label !== "string") return undefined;
const devices = parseDevices(payload.devices).map((device) => ({ ...device, sourceKind: device.sourceKind || "fixture" }));
return { label: payload.label, devices };
} catch {
return undefined;
}
};
const parseGroups = (value: unknown): ConsoleDeviceGroup[] => asList(value, ["items", "groups"]).flatMap((item) => {
if (!isRecord(item)) return [];
const groupId = text(item.group_id) || text(item.groupId) || text(item.id);
if (!groupId) return [];
return [{
groupId,
name: text(item.name) || groupId,
memberCount: number(item.member_count) ?? number(item.memberCount),
deviceIds: Array.isArray(item.device_ids) ? item.device_ids.filter((entry): entry is string => typeof entry === "string") : undefined,
sourceAt: sourceTime(item),
}];
});
const parseMetric = (value: unknown): unknown => value;
export async function readConsoleData(): Promise<ConsoleApiData> {
const [overview, devices, groups, release, aiStatus, aiTasks, brain, connection, process, integrationHealth, applications, authorizations, usage, manifest, documents, knowledge] = await Promise.all([
readFirst<ConsoleOverview>(["/api/v3/workbench/overview"], "GET /api/v3/workbench/overview", (value) => ({ data: isRecord(value) ? value as ConsoleOverview : undefined, sourceAt: sourceTime(value) })),
readFirst<ConsoleDevice[]>(["/api/v3/fleet/devices", "/api/v3/devices"], "GET /api/v3/fleet/devices", (value) => ({ data: parseDevices(value), sourceAt: sourceTime(value) })),
readFirst<ConsoleDeviceGroup[]>(["/api/v3/device-groups"], "GET /api/v3/device-groups", (value) => ({ data: parseGroups(value), sourceAt: sourceTime(value) })),
readFirst<ConsoleRelease>(["/api/v3/releases/latest"], "GET /api/v3/releases/latest", (value) => ({ data: isRecord(value) ? value as ConsoleRelease : undefined, sourceAt: sourceTime(value) || (isRecord(value) ? text(value.published_at) : undefined) })),
readFirst<RecordValue>(["/api/v3/ai/status"], "GET /api/v3/ai/status", (value) => ({ data: isRecord(value) ? value : undefined, sourceAt: sourceTime(value) })),
readFirst<RecordValue>(["/api/v3/ai/tasks?limit=20"], "GET /api/v3/ai/tasks", (value) => ({ data: isRecord(value) ? value : undefined, sourceAt: sourceTime(value) })),
readFirst<RecordValue>(["/api/v3/ai/brain/dashboard"], "GET /api/v3/ai/brain/dashboard", (value) => ({ data: isRecord(value) ? value : undefined, sourceAt: sourceTime(value) })),
readFirst<RecordValue>(["/api/v3/connection/status"], "GET /api/v3/connection/status", (value) => ({ data: isRecord(value) ? value : undefined, sourceAt: sourceTime(value) })),
readFirst<RecordValue>(["/api/v3/process/status"], "GET /api/v3/process/status", (value) => ({ data: isRecord(value) ? value : undefined, sourceAt: sourceTime(value) })),
readFirst<RecordValue>(["/api/v3/integration/health"], "GET /api/v3/integration/health", (value) => ({ data: isRecord(value) ? value : undefined, sourceAt: sourceTime(value) })),
readFirst<IntegrationApplication[]>(["/api/v3/integrations/apps", "/api/v3/integrations/clients"], "GET /api/v3/integrations/apps", (value) => ({ data: asList(value, ["items", "applications"]) as IntegrationApplication[], sourceAt: sourceTime(value) })),
readFirst<AuthorizationGrant[]>(["/api/v3/integrations/authorizations", "/api/v3/authorization-grants"], "GET /api/v3/integrations/authorizations", (value) => ({ data: asList(value, ["items", "grants"]) as AuthorizationGrant[], sourceAt: sourceTime(value) })),
readFirst<IntegrationUsage>(["/api/v3/integrations/usage"], "GET /api/v3/integrations/usage", (value) => ({ data: isRecord(value) ? value as IntegrationUsage : undefined, sourceAt: sourceTime(value) })),
readFirst<RecordValue>(["/api/v3/integration/manifest"], "GET /api/v3/integration/manifest", (value) => ({ data: isRecord(value) ? value : undefined, sourceAt: sourceTime(value) })),
readFirst<Array<Record<string, unknown>>>(["/api/v3/integrations/docs/catalog", "/api/v3/docs/catalog"], "GET /api/v3/integrations/docs/catalog", (value) => ({ data: asList(value, ["items", "documents"]) as Array<Record<string, unknown>>, sourceAt: sourceTime(value) })),
readFirst<Array<Record<string, unknown>>>(["/api/v3/kb/search?limit=10"], "GET /api/v3/kb/search", (value) => ({ data: asList(value, ["items", "results", "documents"]) as Array<Record<string, unknown>>, sourceAt: sourceTime(value) })),
]);
const readonlyFixture = readReadonlyDeviceStateFixture();
const deviceResource: ApiResource<ConsoleDevice[]> = readonlyFixture && devices.state === "ready"
? {
...devices,
source: `${devices.source} + readonly_fixture:${readonlyFixture.label}`,
data: [...(devices.data || []), ...readonlyFixture.devices],
}
: devices;
const hookDeviceId = deviceResource.data?.[0]?.deviceId;
const hookStatus = hookDeviceId
? await readFirst<RecordValue>(
[`/api/v3/wechat/hook/status?device_id=${encodeURIComponent(hookDeviceId)}`],
"GET /api/v3/wechat/hook/status",
(value) => ({ data: isRecord(value) ? value : undefined, sourceAt: sourceTime(value) }),
)
: emptyResource<RecordValue>("GET /api/v3/wechat/hook/status");
const taskItems = aiTasks.state === "ready" ? asList(aiTasks.data, ["items", "tasks"]) as Array<Record<string, unknown>> : [];
const selectedTaskId = taskItems.length && isRecord(taskItems[0]) ? text(taskItems[0].task_id) : undefined;
const [taskDetail, taskLogs, taskAudit] = selectedTaskId
? await Promise.all([
readFirst<RecordValue>([`/api/v3/ai/tasks/${encodeURIComponent(selectedTaskId)}`], "GET /api/v3/ai/tasks/{task_id}", (value) => ({ data: isRecord(value) ? value : undefined, sourceAt: sourceTime(value) })),
readFirst<RecordValue>([`/api/v3/ai/tasks/${encodeURIComponent(selectedTaskId)}/logs`], "GET /api/v3/ai/tasks/{task_id}/logs", (value) => ({ data: isRecord(value) ? value : undefined, sourceAt: sourceTime(value) })),
readFirst<RecordValue>([`/api/v3/ai/tasks/${encodeURIComponent(selectedTaskId)}/audit`], "GET /api/v3/ai/tasks/{task_id}/audit", (value) => ({ data: isRecord(value) ? value : undefined, sourceAt: sourceTime(value) })),
])
: [emptyResource<RecordValue>("GET /api/v3/ai/tasks/{task_id}"), emptyResource<RecordValue>("GET /api/v3/ai/tasks/{task_id}/logs"), emptyResource<RecordValue>("GET /api/v3/ai/tasks/{task_id}/audit")];
const aiOperations: ApiResource<AIOperationSummary> = aiTasks.state === "ready"
? {
state: "ready",
source: "GET /api/v3/ai/tasks + /logs + /audit",
sourceAt: [aiTasks.sourceAt, taskDetail.sourceAt, taskLogs.sourceAt, taskAudit.sourceAt].filter(Boolean).sort().at(-1),
data: {
items: taskItems,
selected: taskDetail.data,
logs: asList(taskLogs.data, ["items", "logs"]) as Array<Record<string, unknown>>,
audit: asList(taskAudit.data, ["items", "audit"]) as Array<Record<string, unknown>>,
},
}
: { state: aiTasks.state, source: aiTasks.source, status: aiTasks.status, error: aiTasks.error };
const engineResource: ApiResource<EngineSummary> =
aiStatus.state === "ready" || brain.state === "ready" || connection.state === "ready" || process.state === "ready" || integrationHealth.state === "ready"
? {
state: "ready",
source: "GET /api/v3/ai/status + /api/v3/ai/brain/dashboard + /api/v3/connection/status + /api/v3/process/status",
sourceAt: [aiStatus.sourceAt, brain.sourceAt, connection.sourceAt, process.sourceAt, integrationHealth.sourceAt].filter(Boolean).sort().at(-1),
data: { aiStatus: aiStatus.data, brain: brain.data, connection: connection.data, process: process.data, integrationHealth: integrationHealth.data },
}
: { state: aiStatus.state === "forbidden" ? "forbidden" : "unavailable", source: "GET /api/v3/ai/status + /api/v3/ai/brain/dashboard", error: aiStatus.error || brain.error, status: aiStatus.status || brain.status };
return {
overview,
devices: deviceResource,
hookStatus,
groups,
release,
engine: engineResource,
aiOperations,
integrations: { applications, authorizations, usage, manifest, documents, knowledge },
readAt: new Date().toISOString(),
};
}
export const apiMetric = (metric: unknown): OverviewMetric => {
if (!isRecord(metric)) return {};
return {
value: metric.value as string | number | null | undefined,
display: text(metric.display),
status: text(metric.status),
source: text(metric.source),
sampled_at: text(metric.sampled_at),
};
};
export const stale = (sourceAt?: string, maxAgeMinutes = 15) => {
if (!sourceAt) return false;
const timestamp = Date.parse(sourceAt);
return Number.isFinite(timestamp) && Date.now() - timestamp > maxAgeMinutes * 60 * 1000;
};
export const readFailureReason = (item: ApiResource<unknown>) => item.error || (item.status === 403 ? "当前账号没有读取权限" : item.status === 401 ? "登录状态已失效" : "接口未接通或暂无数据");
export { EMPTY_CONSOLE_DATA };