182 lines
6.0 KiB
TypeScript
182 lines
6.0 KiB
TypeScript
import type { ReactNode } from 'react';
|
|
import { useEffect, useRef, useState } from 'react';
|
|
|
|
import type { InstanceContext, InstanceModule } from '../app-shell.js';
|
|
import { canonicalHttpOrigin } from '../fleet/fleet-page.js';
|
|
import { displayStatus } from '../ui/locale.js';
|
|
|
|
export type CapabilityState = 'supported' | 'degraded' | 'unsupported' | 'unknown';
|
|
|
|
export interface InstanceCapability {
|
|
readonly state: CapabilityState;
|
|
readonly explanation?: string;
|
|
}
|
|
|
|
export type InstanceCapabilityMap = Readonly<Partial<Record<InstanceModule, InstanceCapability>>>;
|
|
|
|
export interface CapabilityDataSource {
|
|
load(instanceId: string, signal: AbortSignal): Promise<InstanceCapabilityMap>;
|
|
}
|
|
|
|
export interface InstanceDetailProps {
|
|
readonly instanceId: string;
|
|
readonly module: InstanceModule;
|
|
readonly instance?: InstanceContext;
|
|
readonly capabilities?: InstanceCapabilityMap;
|
|
readonly capabilityDataSource?: CapabilityDataSource;
|
|
readonly moduleContent?: ReactNode;
|
|
}
|
|
|
|
export const INSTANCE_MODULE_LABELS: Readonly<Record<InstanceModule, string>> = {
|
|
overview: '设备信息',
|
|
cellular: '蜂窝网络',
|
|
'device-network': '设备网络',
|
|
messages: '短信',
|
|
calls: '通话',
|
|
esim: 'eSIM',
|
|
notifications: '通知',
|
|
automation: '自动化',
|
|
ota: 'OTA',
|
|
};
|
|
|
|
const PRIMARY_MODULES = ['messages', 'overview'] as const satisfies readonly InstanceModule[];
|
|
|
|
const DEFAULT_EXPLANATIONS: Readonly<Record<Exclude<CapabilityState, 'supported'>, string>> = {
|
|
degraded: '此模块可用,但功能受限。',
|
|
unsupported: '此实例不支持该模块。',
|
|
unknown: '能力状态未知。',
|
|
};
|
|
|
|
function canRender(capability: InstanceCapability | undefined): boolean {
|
|
if (!capability) return true;
|
|
return capability.state === 'supported' || capability.state === 'degraded';
|
|
}
|
|
|
|
const CORE_MODULES = new Set<InstanceModule>(['overview', 'messages']);
|
|
function canOpen(module: InstanceModule, capability: InstanceCapability | undefined): boolean {
|
|
return capability ? canRender(capability) : CORE_MODULES.has(module);
|
|
}
|
|
|
|
function explanation(capability: InstanceCapability | undefined): string | null {
|
|
if (!capability) return null;
|
|
if (capability.state === 'supported') return null;
|
|
return capability.explanation?.trim() || DEFAULT_EXPLANATIONS[capability.state];
|
|
}
|
|
|
|
/**
|
|
* Owner-fenced instance context and capability navigation for every instance route.
|
|
* Capability discovery is injected: this framework deliberately assumes no API or operation IDs.
|
|
*/
|
|
export function InstanceDetail({
|
|
instanceId,
|
|
module,
|
|
instance,
|
|
capabilities,
|
|
capabilityDataSource,
|
|
moduleContent,
|
|
}: InstanceDetailProps) {
|
|
const ownsRoute = instance?.id === instanceId;
|
|
const [loadedCapabilities, setLoadedCapabilities] = useState<InstanceCapabilityMap | undefined>();
|
|
const [loading, setLoading] = useState(false);
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
const requestRef = useRef(0);
|
|
|
|
useEffect(() => {
|
|
const request = ++requestRef.current;
|
|
setLoadedCapabilities(undefined);
|
|
setLoadError(null);
|
|
|
|
if (!ownsRoute || capabilities || !capabilityDataSource) {
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
setLoading(true);
|
|
void capabilityDataSource.load(instanceId, controller.signal).then(
|
|
(result) => {
|
|
if (!controller.signal.aborted && requestRef.current === request) {
|
|
setLoadedCapabilities(result);
|
|
setLoading(false);
|
|
}
|
|
},
|
|
(error: unknown) => {
|
|
void error;
|
|
if (!controller.signal.aborted && requestRef.current === request) {
|
|
setLoadError('能力探测失败。');
|
|
setLoading(false);
|
|
}
|
|
},
|
|
);
|
|
return () => controller.abort();
|
|
}, [capabilities, capabilityDataSource, instanceId, ownsRoute]);
|
|
|
|
if (!ownsRoute) {
|
|
return (
|
|
<section>
|
|
<h1>{INSTANCE_MODULE_LABELS[module]}</h1>
|
|
<p>此路由缺少实例上下文。</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const map = capabilities ?? loadedCapabilities ?? {};
|
|
const origin = canonicalHttpOrigin(instance.origin);
|
|
const activeCapability = map[module];
|
|
|
|
return (
|
|
<section className="instance-detail">
|
|
<aside className="instance-context" aria-label="当前实例">
|
|
<strong>{instance.name}</strong>
|
|
<code>{instance.id}</code>
|
|
{instance.status !== 'unknown' ? (
|
|
<dl>
|
|
<div>
|
|
<dt>状态</dt>
|
|
<dd>{displayStatus(instance.status)}</dd>
|
|
</div>
|
|
</dl>
|
|
) : null}
|
|
{origin ? (
|
|
<a href={origin} target="_blank" rel="noopener noreferrer">
|
|
打开源站
|
|
</a>
|
|
) : null}
|
|
<a href={`/settings/instances/${encodeURIComponent(instanceId)}`}>编辑实例</a>
|
|
<nav aria-label="实例模块">
|
|
<ul>
|
|
{PRIMARY_MODULES.map((item) => {
|
|
return (
|
|
<li key={item}>
|
|
<a
|
|
href={`/instances/${encodeURIComponent(instanceId)}/${item}`}
|
|
aria-current={module === item ? 'page' : undefined}
|
|
>
|
|
{INSTANCE_MODULE_LABELS[item]}
|
|
</a>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</nav>
|
|
</aside>
|
|
<div className="instance-module-detail">
|
|
<h1>{INSTANCE_MODULE_LABELS[module]}</h1>
|
|
{loading ? <p role="status">正在加载能力…</p> : null}
|
|
{loadError ? <p role="alert">能力不可用:{loadError}</p> : null}
|
|
{!loading && canOpen(module, activeCapability) ? (
|
|
<>
|
|
{activeCapability?.state === 'degraded' ? (
|
|
<p data-capability-state="degraded">{explanation(activeCapability)}</p>
|
|
) : null}
|
|
{moduleContent ?? <p>查看{INSTANCE_MODULE_LABELS[module]}数据和可用操作。</p>}
|
|
</>
|
|
) : null}
|
|
{!loading && !canOpen(module, activeCapability) ? (
|
|
<p data-capability-state={activeCapability?.state}>{explanation(activeCapability)}</p>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|