1071 lines
55 KiB
TypeScript
1071 lines
55 KiB
TypeScript
import { ChevronRight, Download, Eye, FileText, Folder, MoonStar, PackageOpen, Pencil, RefreshCw, Save, Search, ShieldCheck, Sparkles, Square, Terminal, Upload, UserRoundMinus, UserRoundPlus, WandSparkles, X } from "lucide-react";
|
||
import { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
|
||
import { platformApiClient } from "../api/client";
|
||
import type {
|
||
ConfigDiffLineResponse,
|
||
DeclaredFileReadSnapshotResponse,
|
||
GamePluginResponse,
|
||
JobResponse,
|
||
ServerInstanceResponse,
|
||
ServerMemberResponse,
|
||
ServerDeploymentResponse,
|
||
ServerConfigDiffPreviewResponse,
|
||
RunEndpointResponse,
|
||
ServerFileEntryResponse,
|
||
ServerFileListResponse,
|
||
ServerFileWorkspaceResponse
|
||
} from "../api/types";
|
||
import { ConfirmDialog } from "../components/OperationControls";
|
||
import { ServerManagementTerminalDrawer } from "../components/ServerManagementTerminalDrawer";
|
||
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||
import type { PageComponentProps } from "../contracts/page";
|
||
import { canStartServer, canStopServer, runtimeObservationFreshness, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
|
||
import {
|
||
serverDetailSections,
|
||
type ConfigDiffView,
|
||
type LlmSuggestionView,
|
||
type ServerDetailSection
|
||
} from "../contracts/workspace";
|
||
import {
|
||
serverLifecycleCommandRequest,
|
||
serverMetadataUpdateRequestFromForm
|
||
} from "../schemas/serverManagement";
|
||
import { cx } from "../utils/classes";
|
||
import { downloadServerFileResult } from "../utils/serverFileTransfer";
|
||
import { stateLabel, statusClass } from "./ServersPage";
|
||
import { PluginPageHostPage } from "./PluginPageHostPage";
|
||
|
||
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
|
||
|
||
const serverDetailRefreshMs = 5000;
|
||
|
||
export function ServerDetailPage(props: PageComponentProps) {
|
||
const { session, params, operations, onNavigate } = props;
|
||
const serverId = params.serverId ?? "";
|
||
const [section, setSection] = useState<ServerDetailSection>("manage");
|
||
const [instance, setInstance] = useState<LoadState<ServerInstanceResponse>>({ status: "loading" });
|
||
const [plugins, setPlugins] = useState<GamePluginResponse[]>([]);
|
||
const [jobs, setJobs] = useState<JobResponse[]>([]);
|
||
const [runEndpoint, setRunEndpoint] = useState<RunEndpointResponse | undefined>();
|
||
const [deployment, setDeployment] = useState<LoadState<ServerDeploymentResponse>>({ status: "loading" });
|
||
const [confirm, setConfirm] = useState<null | { title: string; description: string; danger?: boolean; run: () => Promise<void> }>(null);
|
||
const [confirmBusy, setConfirmBusy] = useState(false);
|
||
const [terminalOpen, setTerminalOpen] = useState(false);
|
||
|
||
const refresh = useCallback(async () => {
|
||
if (!serverId) {
|
||
setInstance({ status: "error", reason: "缺少服务器 ID" });
|
||
return;
|
||
}
|
||
setInstance({ status: "loading" });
|
||
try {
|
||
const [detail, pluginResponse, jobResponse, deploymentResponse, endpointResponse] = await Promise.all([
|
||
platformApiClient.getServerInstance(serverId),
|
||
platformApiClient.listGamePlugins(),
|
||
platformApiClient.listJobs(serverId),
|
||
platformApiClient
|
||
.getServerDeployment(serverId)
|
||
.then((data): LoadState<ServerDeploymentResponse> => ({ status: "ready", data }))
|
||
.catch((error): LoadState<ServerDeploymentResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "部署定义加载失败" })),
|
||
platformApiClient.listRunEndpoints().catch(() => ({ items: [], count: 0 }))
|
||
]);
|
||
setInstance({ status: "ready", data: detail });
|
||
setPlugins(pluginResponse.items);
|
||
setJobs(jobResponse.items);
|
||
setDeployment(deploymentResponse);
|
||
setRunEndpoint(endpointResponse.items.find((endpoint) => endpoint.id === detail.runEndpointId));
|
||
} catch (error) {
|
||
setInstance({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
|
||
setRunEndpoint(undefined);
|
||
setDeployment({ status: "error", reason: "部署定义加载失败" });
|
||
}
|
||
}, [serverId]);
|
||
|
||
useEffect(() => {
|
||
void refresh();
|
||
}, [refresh]);
|
||
|
||
const refreshOperationalState = useCallback(async () => {
|
||
if (!serverId) return;
|
||
try {
|
||
const [detail, jobResponse, endpointResponse] = await Promise.all([
|
||
platformApiClient.getServerInstance(serverId),
|
||
platformApiClient.listJobs(serverId),
|
||
platformApiClient.listRunEndpoints()
|
||
]);
|
||
setInstance({ status: "ready", data: detail });
|
||
setJobs(jobResponse.items);
|
||
setRunEndpoint(endpointResponse.items.find((endpoint) => endpoint.id === detail.runEndpointId));
|
||
} catch {
|
||
setRunEndpoint(undefined);
|
||
}
|
||
}, [serverId]);
|
||
|
||
useEffect(() => {
|
||
const timer = window.setInterval(() => void refreshOperationalState(), serverDetailRefreshMs);
|
||
return () => window.clearInterval(timer);
|
||
}, [refreshOperationalState]);
|
||
|
||
const canManageServers = session.capabilities.includes("servers.manage");
|
||
const readyPlugin = instance.status === "ready" ? plugins.find((plugin) => plugin.id === instance.data.pluginId) : undefined;
|
||
const detailFreshness = instance.status === "ready" ? runtimeObservationFreshness(instance.data, runEndpoint) : "unverified";
|
||
const detailStateText = instance.status === "ready" && detailFreshness === "fresh" ? stateLabel(instance.data.state) : instance.status === "ready" ? `最后观测:${stateLabel(instance.data.state)}(Run 未验证)` : "未验证";
|
||
const detailSections = useMemo(() => serverDetailSectionEntries(readyPlugin), [readyPlugin]);
|
||
|
||
useEffect(() => {
|
||
if (!params.routeKey || !readyPlugin?.pages.some((page) => page.key === params.routeKey)) return;
|
||
setSection(`plugin:${params.routeKey}`);
|
||
}, [params.routeKey, readyPlugin]);
|
||
|
||
useEffect(() => {
|
||
if (detailSections.some((entry) => entry.id === section)) return;
|
||
setSection(detailSections[0]?.id ?? "manage");
|
||
}, [detailSections, section]);
|
||
|
||
function requestLifecycle(current: ServerInstanceResponse, action: "start" | "stop") {
|
||
setConfirm({
|
||
title: action === "start" ? "启动服务器" : "停止服务器",
|
||
description:
|
||
action === "start"
|
||
? `确认启动服务器 ${current.name}(${current.id})?`
|
||
: `停止服务器 ${current.name}(${current.id})会断开所有在线玩家,确认继续?`,
|
||
danger: action === "stop",
|
||
run: async () => {
|
||
const operationId = operations.begin({
|
||
intent: action === "start" ? "启动服务器" : "停止服务器",
|
||
targetKind: "server",
|
||
targetId: current.id,
|
||
requester: session.displayName
|
||
});
|
||
try {
|
||
const result =
|
||
action === "start"
|
||
? await platformApiClient.startServerInstance(current.id, serverLifecycleCommandRequest(current, "start"))
|
||
: await platformApiClient.stopServerInstance(current.id, serverLifecycleCommandRequest(current, "stop"));
|
||
operations.succeed(operationId, `任务 ${result.job.id}(${result.job.capability})已派发`, result.job);
|
||
await refresh();
|
||
} catch (error) {
|
||
operations.fail(operationId, error instanceof Error ? error.message : "操作失败", operationId);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
if (!serverId) {
|
||
return (
|
||
<EmptyState title="未选择服务器" description="请从服务器列表进入详情页。" actionLabel="返回服务器列表" onAction={() => onNavigate("servers")} />
|
||
);
|
||
}
|
||
|
||
return (
|
||
<section className="server-detail-page" aria-labelledby="server-detail-title">
|
||
{instance.status === "loading" && <LoadingState label="正在加载服务器详情…" />}
|
||
{instance.status === "error" && (
|
||
<ErrorState title="服务器详情加载失败" reason={instance.reason} diagnosticId={`server-detail:${serverId}`} onRetry={() => void refresh()} />
|
||
)}
|
||
|
||
{instance.status !== "ready" && (
|
||
<ServerDetailSectionNav entries={detailSections} section={section} onChange={setSection} />
|
||
)}
|
||
|
||
{instance.status === "ready" && (
|
||
<>
|
||
<header className="server-detail-header">
|
||
<div className="server-detail-title-row">
|
||
<div>
|
||
<h1 id="server-detail-title">{instance.data.name}</h1>
|
||
</div>
|
||
<div className="action-strip">
|
||
<button type="button" className="icon-command" onClick={() => onNavigate("servers")}>
|
||
<MoonStar size={16} />
|
||
<span>返回列表</span>
|
||
</button>
|
||
<button type="button" className="icon-command" onClick={() => void refresh()}>
|
||
<Sparkles size={16} />
|
||
<span>刷新</span>
|
||
</button>
|
||
<span className={cx("status-pill", detailFreshness === "fresh" ? statusClass(instance.data.state) : "status-disabled")}>{detailStateText}</span>
|
||
<button
|
||
type="button"
|
||
className="icon-command"
|
||
disabled={!canManageServers}
|
||
title={canManageServers ? "打开终端" : "当前账号没有管理权限"}
|
||
onClick={() => setTerminalOpen(true)}
|
||
>
|
||
<Terminal size={15} />
|
||
<span>打开终端</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="icon-command"
|
||
disabled={!canStartServer(instance.data.state) || operations.isPending(instance.data.id, "启动服务器")}
|
||
onClick={() => requestLifecycle(instance.data, "start")}
|
||
>
|
||
<WandSparkles size={15} />
|
||
<span>启动</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="icon-command danger-command"
|
||
disabled={!canStopServer(instance.data.state) || operations.isPending(instance.data.id, "停止服务器")}
|
||
onClick={() => requestLifecycle(instance.data, "stop")}
|
||
>
|
||
<Square size={15} />
|
||
<span>停止</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<ServerDetailSectionNav entries={detailSections} section={section} onChange={setSection} />
|
||
|
||
{pluginPageKeyFromSection(section) && readyPlugin && <PluginPageSection pageProps={props} serverId={serverId} plugin={readyPlugin} routeKey={pluginPageKeyFromSection(section) ?? ""} />}
|
||
{section === "manage" && <ServerDeploymentSection instance={instance.data} deployment={deployment} />}
|
||
{section === "manage" && (
|
||
<ServerMetadataSection
|
||
instance={instance.data}
|
||
session={session}
|
||
operations={operations}
|
||
onChanged={(next) => setInstance({ status: "ready", data: next })}
|
||
/>
|
||
)}
|
||
{section === "manage" && <ServerAdministratorsSection instance={instance.data} session={session} onChanged={(next) => setInstance({ status: "ready", data: next })} />}
|
||
{section === "files" && <ServerFilesSection instance={instance.data} session={session} operations={operations} />}
|
||
{section === "llm" && <LlmSection serverId={serverId} instance={instance.data} session={session} operations={operations} />}
|
||
<ServerManagementTerminalDrawer open={terminalOpen} serverId={instance.data.id} serverName={instance.data.name} pluginId={instance.data.pluginId} canManage={canManageServers} onClose={() => setTerminalOpen(false)} />
|
||
</>
|
||
)}
|
||
|
||
<ConfirmDialog
|
||
open={confirm !== null}
|
||
title={confirm?.title ?? ""}
|
||
description={confirm?.description ?? ""}
|
||
confirmLabel="确认执行"
|
||
danger={confirm?.danger}
|
||
busy={confirmBusy}
|
||
onCancel={() => setConfirm(null)}
|
||
onConfirm={() => {
|
||
if (!confirm) {
|
||
return;
|
||
}
|
||
setConfirmBusy(true);
|
||
void confirm.run().finally(() => {
|
||
setConfirmBusy(false);
|
||
setConfirm(null);
|
||
});
|
||
}}
|
||
/>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function serverDetailSectionEntries(plugin?: GamePluginResponse): Array<{ id: ServerDetailSection; label: string }> {
|
||
const pluginPages = (plugin?.pages ?? []).map((page) => ({ id: `plugin:${page.key}` as ServerDetailSection, label: page.title }));
|
||
return [...pluginPages, ...serverDetailSections];
|
||
}
|
||
|
||
function ServerDetailSectionNav({ entries, section, onChange }: { entries: Array<{ id: ServerDetailSection; label: string }>; section: ServerDetailSection; onChange: (section: ServerDetailSection) => void }) {
|
||
const pluginEntries = entries.filter((entry) => entry.id.startsWith("plugin:"));
|
||
const platformEntries = entries.filter((entry) => !entry.id.startsWith("plugin:"));
|
||
return (
|
||
<nav className="section-tabs" aria-label="server sections">
|
||
{pluginEntries.length > 0 && (
|
||
<div className="server-plugin-section-tabs" aria-label="游戏运营功能">
|
||
<span className="server-plugin-section-label">游戏运营</span>
|
||
{pluginEntries.map((entry) => (
|
||
<button key={entry.id} type="button" className={cx("section-tab", section === entry.id && "section-tab-active")} aria-current={section === entry.id ? "page" : undefined} onClick={() => onChange(entry.id)}>
|
||
{entry.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
{platformEntries.map((entry) => (
|
||
<button key={entry.id} type="button" className={cx("section-tab", section === entry.id && "section-tab-active")} aria-current={section === entry.id ? "page" : undefined} onClick={() => onChange(entry.id)}>
|
||
{entry.label}
|
||
</button>
|
||
))}
|
||
</nav>
|
||
);
|
||
}
|
||
|
||
function pluginPageKeyFromSection(section: ServerDetailSection): string | null {
|
||
return section.startsWith("plugin:") ? section.slice("plugin:".length) : null;
|
||
}
|
||
|
||
interface PluginPageSectionProps {
|
||
pageProps: PageComponentProps;
|
||
serverId: string;
|
||
plugin: GamePluginResponse;
|
||
routeKey: string;
|
||
}
|
||
|
||
function PluginPageSection({ pageProps, serverId, plugin, routeKey }: PluginPageSectionProps) {
|
||
const params = useMemo(() => ({ ...pageProps.params, pluginId: plugin.id, routeKey, serverId }), [pageProps.params.pluginId, pageProps.params.routeKey, pageProps.params.serverId, plugin.id, routeKey, serverId]);
|
||
return <PluginPageHostPage {...pageProps} params={params} initialPlugin={plugin} embedded />;
|
||
}
|
||
|
||
interface ServerMetadataSectionProps {
|
||
instance: ServerInstanceResponse;
|
||
session: PageComponentProps["session"];
|
||
operations: PageComponentProps["operations"];
|
||
onChanged: (instance: ServerInstanceResponse) => void;
|
||
}
|
||
|
||
function ServerMetadataSection({ instance, session, operations, onChanged }: ServerMetadataSectionProps) {
|
||
const [draft, setDraft] = useState<ServerMetadataFormState>(() => serverMetadataFormFromInstance(instance));
|
||
const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null);
|
||
|
||
useEffect(() => {
|
||
setDraft(serverMetadataFormFromInstance(instance));
|
||
}, [instance.id, instance.name]);
|
||
|
||
async function saveMetadata(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
const operationId = operations.begin({ intent: "更新服务器信息", targetKind: "server", targetId: instance.id, requester: session.displayName });
|
||
setResult({ status: "pending", label: "正在保存服务器信息" });
|
||
try {
|
||
const updated = await platformApiClient.updateServerInstance(instance.id, serverMetadataUpdateRequestFromForm(draft));
|
||
onChanged(updated);
|
||
operations.succeed(operationId, `服务器信息已更新:${updated.id}`);
|
||
setResult({ status: "succeeded", label: `已更新 ${updated.name}` });
|
||
} catch (error) {
|
||
operations.fail(operationId, error instanceof Error ? error.message : "服务器信息更新失败");
|
||
setResult({ status: "failed", label: error instanceof Error ? error.message : "服务器信息更新失败" });
|
||
}
|
||
}
|
||
|
||
return (
|
||
<article className="console-panel" aria-label="server metadata">
|
||
<div className="panel-header">
|
||
<h2>
|
||
<Pencil size={16} style={{ verticalAlign: "-2px" }} /> 基本信息
|
||
</h2>
|
||
{result && <ResultBadge status={result.status} label={result.label} />}
|
||
</div>
|
||
<form className="provider-form" style={{ marginTop: 12 }} onSubmit={(event) => void saveMetadata(event)}>
|
||
<label>
|
||
显示名称
|
||
<input value={draft.name} onChange={(event) => setDraft((current) => ({ ...current, name: event.target.value }))} />
|
||
</label>
|
||
<div className="action-strip">
|
||
<button type="submit" className="primary-command" disabled={draft.name.trim() === instance.name}>
|
||
<Pencil size={14} />
|
||
<span>保存名称</span>
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
interface ServerDeploymentSectionProps {
|
||
instance: ServerInstanceResponse;
|
||
deployment: LoadState<ServerDeploymentResponse>;
|
||
}
|
||
|
||
function ServerDeploymentSection({ instance, deployment }: ServerDeploymentSectionProps) {
|
||
if (deployment.status === "loading") return <LoadingState label="正在加载部署定义…" compact />;
|
||
if (deployment.status === "error") return <ErrorState title="部署定义不可用" reason={deployment.reason} diagnosticId={`deployment:${instance.id}`} compact />;
|
||
const view = deployment.data;
|
||
const projection = view.projection;
|
||
const isScumTemplate = (instance.pluginId === "game.scum" && (view.mode === "guided-install" || view.mode === "existing-server")) || projection?.templateKey?.startsWith("scum-");
|
||
return <article className="console-panel" aria-label="server deployment">
|
||
<div className="panel-header"><h2><PackageOpen size={16} style={{ verticalAlign: "-2px" }} /> 部署定义</h2><span className="page-status">{view.mode || "未配置"} · 修订 {view.revision}</span></div>
|
||
<p className="section-copy">服务器目录是主目录;执行目录只用于高级自定义启动,留空时继承服务器目录。路径和命令均为受保护输入,不会回显。</p>
|
||
<div className="console-row-list"><div className="console-row"><span>服务器目录</span><strong>{view.serverRootConfigured ? "已配置" : "未配置"}</strong></div><div className="console-row"><span>高级执行目录</span><strong>{view.workingDirectoryConfigured ? "已配置" : "使用服务器目录"}</strong></div><div className="console-row"><span>启动设置</span><strong>{view.startCommandConfigured ? "已配置" : view.mode === "custom-command" ? "未配置" : "插件引导"}</strong></div>{view.latestDispatch && <div className="console-row"><span>最近 Run 调度</span><strong>{view.latestDispatch.deploymentDefinitionIncluded ? `部署定义已随任务发送 · r${view.latestDispatch.deploymentRevision} · ${view.latestDispatch.jobState}` : "未携带部署定义"}</strong></div>}{view.latestDispatch?.runConfirmed && <div className="console-row"><span>Run 执行确认</span><strong>已按 r{view.latestDispatch.deploymentRevision} 确认执行</strong></div>}</div>
|
||
{isScumTemplate && <div className="console-row-list" style={{ marginTop: 12 }}><div className="console-row"><span>SCUM 部署模板</span><strong>{projection?.templateVersion ? `${projection.templateKey ?? "已选择"} · v${projection.templateVersion}` : "等待 Run 预检"}</strong></div><div className="console-row"><span>预检 / 扫描</span><strong>{deploymentProjectionLabel(projection?.preflightState)} / {deploymentProjectionLabel(projection?.discoveryState)}</strong></div><div className="console-row"><span>配置映射 / 健康验证</span><strong>{deploymentProjectionLabel(projection?.mappingState)} / {deploymentProjectionLabel(projection?.verificationState)}</strong></div>{projection?.failureCode && <div className="console-row"><span>失败原因</span><strong>{projection.failureCode}</strong></div>}</div>}
|
||
</article>;
|
||
}
|
||
|
||
function deploymentProjectionLabel(value?: string): string {
|
||
switch (value) {
|
||
case "queued": return "排队中";
|
||
case "running": return "执行中";
|
||
case "passed": return "已通过";
|
||
case "applied": return "已写入";
|
||
case "unchanged": return "未变化";
|
||
case "failed": return "失败";
|
||
case "skipped": return "已跳过";
|
||
default: return "待返回";
|
||
}
|
||
}
|
||
|
||
function deploymentProgressLabel(progress: JobResponse["progress"]): string {
|
||
switch (progress.phase) {
|
||
case "queued": return "任务已排队,等待 Run 领取";
|
||
case "claimed": return "Run 已领取任务";
|
||
case "preflight": return "正在执行本机预检";
|
||
case "install": return "正在安装服务器";
|
||
case "configure": return "正在写入游戏配置";
|
||
case "start": return "正在启动服务器";
|
||
case "health": return "正在进行健康检查";
|
||
default: return "部署任务已提交";
|
||
}
|
||
}
|
||
|
||
interface ServerAdministratorsSectionProps {
|
||
instance: ServerInstanceResponse;
|
||
session: PageComponentProps["session"];
|
||
onChanged: (instance: ServerInstanceResponse) => void;
|
||
}
|
||
|
||
function ServerAdministratorsSection({ instance, session, onChanged }: ServerAdministratorsSectionProps) {
|
||
const [candidates, setCandidates] = useState<LoadState<ServerMemberResponse[]>>({ status: "loading" });
|
||
const [selectedUserId, setSelectedUserId] = useState("");
|
||
const [busyUserId, setBusyUserId] = useState<string | null>(null);
|
||
const [result, setResult] = useState<{ status: "succeeded" | "failed"; label: string } | null>(null);
|
||
const isOwner = instance.ownerUserId === session.id;
|
||
|
||
const refreshCandidates = useCallback(async () => {
|
||
if (!isOwner) {
|
||
setCandidates({ status: "ready", data: [] });
|
||
return;
|
||
}
|
||
setCandidates({ status: "loading" });
|
||
try {
|
||
const response = await platformApiClient.listServerAdministratorCandidates(instance.id);
|
||
setCandidates({ status: "ready", data: response.items });
|
||
setSelectedUserId((current) => (response.items.some((user) => user.id === current) ? current : response.items[0]?.id ?? ""));
|
||
} catch (error) {
|
||
setCandidates({ status: "error", reason: error instanceof Error ? error.message : "加载候选管理员失败" });
|
||
}
|
||
}, [instance.id, isOwner]);
|
||
|
||
useEffect(() => {
|
||
void refreshCandidates();
|
||
}, [refreshCandidates]);
|
||
|
||
async function addAdministrator(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
if (!selectedUserId) {
|
||
return;
|
||
}
|
||
setBusyUserId(selectedUserId);
|
||
setResult(null);
|
||
try {
|
||
const next = await platformApiClient.addServerAdministrator(instance.id, { userId: selectedUserId });
|
||
onChanged(next);
|
||
setResult({ status: "succeeded", label: "已添加服务器管理员" });
|
||
await refreshCandidates();
|
||
} catch (error) {
|
||
setResult({ status: "failed", label: error instanceof Error ? error.message : "添加管理员失败" });
|
||
} finally {
|
||
setBusyUserId(null);
|
||
}
|
||
}
|
||
|
||
async function removeAdministrator(userId: string) {
|
||
setBusyUserId(userId);
|
||
setResult(null);
|
||
try {
|
||
const next = await platformApiClient.removeServerAdministrator(instance.id, userId);
|
||
onChanged(next);
|
||
setResult({ status: "succeeded", label: "已移除服务器管理员" });
|
||
await refreshCandidates();
|
||
} catch (error) {
|
||
setResult({ status: "failed", label: error instanceof Error ? error.message : "移除管理员失败" });
|
||
} finally {
|
||
setBusyUserId(null);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<article className="console-panel" aria-label="server administrators">
|
||
<div className="panel-header">
|
||
<h2>
|
||
<ShieldCheck size={16} style={{ verticalAlign: "-2px" }} /> 管理成员
|
||
</h2>
|
||
<span className="page-status">{isOwner ? "服主可邀请/移除管理员" : "仅服主可调整成员"}</span>
|
||
</div>
|
||
{result && (
|
||
<div style={{ marginBottom: 10 }}>
|
||
<ResultBadge status={result.status} label={result.label} />
|
||
</div>
|
||
)}
|
||
<div className="action-list">
|
||
<span>服主:{instance.ownerUserId || "未绑定"}</span>
|
||
<span>服务器管理员:{instance.adminUserIds.length > 0 ? instance.adminUserIds.join(" / ") : "暂无"}</span>
|
||
</div>
|
||
{isOwner && (
|
||
<>
|
||
<form className="provider-form" style={{ marginTop: 12 }} onSubmit={(event) => void addAdministrator(event)} aria-label="邀请服务器管理员">
|
||
<div className="form-grid">
|
||
<label>
|
||
邀请管理员
|
||
<select value={selectedUserId} onChange={(event) => setSelectedUserId(event.target.value)} disabled={candidates.status !== "ready" || candidates.data.length === 0}>
|
||
{candidates.status === "ready" && candidates.data.length === 0 && <option value="">暂无可邀请用户</option>}
|
||
{candidates.status === "ready" &&
|
||
candidates.data.map((user) => (
|
||
<option key={user.id} value={user.id}>
|
||
{user.displayName}({user.email ?? user.id})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
{candidates.status === "loading" && <LoadingState label="正在加载可邀请用户…" compact />}
|
||
{candidates.status === "error" && (
|
||
<ErrorState title="候选管理员加载失败" reason={candidates.reason} diagnosticId={`server-admin-candidates:${instance.id}`} onRetry={() => void refreshCandidates()} compact />
|
||
)}
|
||
<button type="submit" className="primary-command" disabled={!selectedUserId || busyUserId !== null}>
|
||
<UserRoundPlus size={14} />
|
||
<span>{busyUserId === selectedUserId ? "邀请中…" : "邀请为管理员"}</span>
|
||
</button>
|
||
</form>
|
||
{instance.adminUserIds.length > 0 && (
|
||
<div className="resource-list" style={{ marginTop: 12 }}>
|
||
{instance.adminUserIds.map((userId) => (
|
||
<article key={userId} className="resource-list-item">
|
||
<span>
|
||
<strong>{userId}</strong>
|
||
<span className="provider-id">服务器管理员</span>
|
||
</span>
|
||
<button type="button" className="theme-upload" disabled={busyUserId !== null} onClick={() => void removeAdministrator(userId)}>
|
||
<UserRoundMinus size={13} />
|
||
<span>{busyUserId === userId ? "移除中…" : "移除"}</span>
|
||
</button>
|
||
</article>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
</article>
|
||
);
|
||
}
|
||
|
||
interface ServerFilesSectionProps {
|
||
instance: ServerInstanceResponse;
|
||
session: PageComponentProps["session"];
|
||
operations: PageComponentProps["operations"];
|
||
}
|
||
|
||
interface ServerFileEditorState {
|
||
entry: ServerFileEntryResponse | null;
|
||
key: string;
|
||
draft: string;
|
||
snapshot?: DeclaredFileReadSnapshotResponse;
|
||
loading: boolean;
|
||
saving: boolean;
|
||
message?: string;
|
||
error?: string;
|
||
}
|
||
|
||
function ServerFilesSection({ instance, session, operations }: ServerFilesSectionProps) {
|
||
const [workspace, setWorkspace] = useState<LoadState<ServerFileWorkspaceResponse>>({ status: "loading" });
|
||
const [list, setList] = useState<LoadState<ServerFileListResponse>>({ status: "loading" });
|
||
const [directoryKey, setDirectoryKey] = useState("");
|
||
const [relativePath, setRelativePath] = useState("");
|
||
const [searchDraft, setSearchDraft] = useState("");
|
||
const [searchQuery, setSearchQuery] = useState("");
|
||
const [recursive, setRecursive] = useState(false);
|
||
const [panelResult, setPanelResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
|
||
const [uploadBusy, setUploadBusy] = useState(false);
|
||
const [editor, setEditor] = useState<ServerFileEditorState>({ entry: null, key: "", draft: "", loading: false, saving: false });
|
||
const browseRequestRef = useRef<{ key: string; idempotencyKey: string } | null>(null);
|
||
|
||
const activeDirectory = workspace.status === "ready" ? workspace.data.directories.find((item) => item.key === directoryKey) : undefined;
|
||
const canUpload = workspace.status === "ready" && Boolean(activeDirectory) && !uploadBusy;
|
||
const entries = list.status === "ready" ? list.data.entries : [];
|
||
|
||
const loadWorkspace = useCallback(async () => {
|
||
browseRequestRef.current = null;
|
||
setWorkspace({ status: "loading" });
|
||
try {
|
||
const response = await platformApiClient.getServerFileWorkspace(instance.id);
|
||
setWorkspace({ status: "ready", data: response });
|
||
const nextDirectoryKey = response.defaultDirectoryKey || response.directories[0]?.key || "";
|
||
setDirectoryKey((current) => current || nextDirectoryKey);
|
||
if (!nextDirectoryKey) setList({ status: "ready", data: { serverInstanceId: response.serverInstanceId, pluginId: response.pluginId, directoryKey: "", state: "declared", entries: [], reason: "尚未获得服务器文件入口。" } });
|
||
} catch (error) {
|
||
setWorkspace({ status: "error", reason: error instanceof Error ? error.message : "文件工作区加载失败" });
|
||
setList({ status: "error", reason: "文件工作区不可用" });
|
||
}
|
||
}, [instance.id]);
|
||
|
||
const browseList = useCallback(async (options: { silent?: boolean; forceNew?: boolean; manual?: boolean } = {}): Promise<ServerFileListResponse | undefined> => {
|
||
if (!directoryKey) return undefined;
|
||
if (!options.silent) setList({ status: "loading" });
|
||
const browseKey = `${directoryKey}:${relativePath}:${searchQuery}:${recursive}`;
|
||
if (options.forceNew || browseRequestRef.current?.key !== browseKey) {
|
||
browseRequestRef.current = { key: browseKey, idempotencyKey: serverFileIdempotency("browse", instance.id, `${browseKey}:${Date.now()}`) };
|
||
}
|
||
const operationId = options.manual ? operations.begin({ intent: "读取文件目录", targetKind: "server", targetId: instance.id, requester: session.displayName }) : "";
|
||
if (!options.silent) setPanelResult({ status: "pending", label: "正在读取目录…" });
|
||
try {
|
||
const response = await platformApiClient.browseServerFiles(instance.id, { directoryKey, path: relativePath || undefined, query: searchQuery || undefined, recursive, idempotencyKey: browseRequestRef.current?.idempotencyKey });
|
||
setList({ status: "ready", data: response });
|
||
if (operationId) operations.succeed(operationId, response.state === "ready" ? "目录读取完成" : "正在读取目录", response.job);
|
||
if (response.state === "ready") setPanelResult(null);
|
||
else if (response.state === "failed") setPanelResult({ status: "failed", label: response.reason ?? "目录读取失败" });
|
||
else setPanelResult({ status: "pending", label: serverFileListPendingLabel(response) });
|
||
return response;
|
||
} catch (error) {
|
||
setList({ status: "error", reason: error instanceof Error ? error.message : "文件列表加载失败" });
|
||
if (operationId) operations.fail(operationId, error instanceof Error ? error.message : "目录读取失败", operationId);
|
||
setPanelResult({ status: "failed", label: error instanceof Error ? error.message : "目录读取失败" });
|
||
return undefined;
|
||
}
|
||
}, [directoryKey, instance.id, operations, recursive, relativePath, searchQuery, session.displayName]);
|
||
|
||
useEffect(() => {
|
||
void loadWorkspace();
|
||
}, [loadWorkspace]);
|
||
|
||
useEffect(() => {
|
||
if (workspace.status !== "ready" || !directoryKey) return;
|
||
void browseList({ forceNew: true });
|
||
}, [browseList, directoryKey, relativePath, recursive, searchQuery, workspace.status]);
|
||
|
||
useEffect(() => {
|
||
if (workspace.status !== "ready" || !directoryKey || list.status !== "ready" || list.data.state !== "pending") return;
|
||
const timer = window.setTimeout(() => void browseList({ silent: true }), 500);
|
||
return () => window.clearTimeout(timer);
|
||
}, [browseList, directoryKey, list, workspace.status]);
|
||
|
||
async function openEntry(entry: ServerFileEntryResponse) {
|
||
if (entry.kind === "directory") {
|
||
setDirectoryKey(entry.directoryKey || directoryKey);
|
||
setRelativePath(entry.relativePath ?? "");
|
||
setEditor({ entry: null, key: "", draft: "", loading: false, saving: false });
|
||
return;
|
||
}
|
||
await openEditor(entry);
|
||
}
|
||
|
||
async function openEditor(entry: ServerFileEntryResponse) {
|
||
const key = serverFileEntryKey(entry);
|
||
if (!key) {
|
||
setPanelResult({ status: "failed", label: "该文件缺少路径 key,不能读取。" });
|
||
return;
|
||
}
|
||
setEditor({ entry, key, draft: "", loading: true, saving: false, message: "正在读取最近快照…" });
|
||
try {
|
||
const snapshot = await platformApiClient.getServerFileReadSnapshot(instance.id, key);
|
||
if (snapshot.state === "ready") {
|
||
setEditor({ entry, key, draft: snapshot.content ?? "", snapshot, loading: false, saving: false, message: snapshot.content === undefined ? snapshot.reason ?? "文件快照已就绪,但内容不适合内联编辑。" : "已加载最近读取快照。" });
|
||
return;
|
||
}
|
||
const operationId = operations.begin({ intent: "读取文件", targetKind: "server", targetId: instance.id, requester: session.displayName });
|
||
const dispatch = await platformApiClient.readServerFile(instance.id, { key, idempotencyKey: serverFileIdempotency("read", instance.id, key) });
|
||
operations.succeed(operationId, `读取任务 ${dispatch.job.id} 已派发`, dispatch.job);
|
||
setEditor({ entry, key, draft: "", snapshot, loading: false, saving: false, message: snapshot.reason ?? "读取任务已派发;Run 返回后再次打开即可编辑。" });
|
||
setPanelResult({ status: "pending", label: "正在读取文件…" });
|
||
} catch (error) {
|
||
const reason = error instanceof Error ? error.message : "文件读取失败";
|
||
setEditor({ entry, key, draft: "", loading: false, saving: false, error: reason });
|
||
setPanelResult({ status: "failed", label: reason });
|
||
}
|
||
}
|
||
|
||
async function saveEditor() {
|
||
if (!editor.entry || !editor.key || editor.saving) return;
|
||
const operationId = operations.begin({ intent: "保存文件", targetKind: "server", targetId: instance.id, requester: session.displayName });
|
||
setEditor((current) => ({ ...current, saving: true, error: undefined, message: "正在派发写入任务…" }));
|
||
try {
|
||
const dispatch = await platformApiClient.writeServerFile(instance.id, { key: editor.key, content: editor.draft, expectedVersion: editor.snapshot?.version, expectedChecksum: editor.snapshot?.checksum, idempotencyKey: serverFileIdempotency("write", instance.id, editor.key) });
|
||
operations.succeed(operationId, `写入任务 ${dispatch.job.id} 已派发`, dispatch.job);
|
||
setEditor((current) => ({ ...current, saving: false, message: "保存任务已派发;Run 会在工作区内原子写入。" }));
|
||
setPanelResult({ status: "pending", label: "正在保存文件…" });
|
||
await browseList({ forceNew: true });
|
||
} catch (error) {
|
||
const reason = error instanceof Error ? error.message : "文件保存失败";
|
||
operations.fail(operationId, reason, operationId);
|
||
setEditor((current) => ({ ...current, saving: false, error: reason }));
|
||
setPanelResult({ status: "failed", label: reason });
|
||
}
|
||
}
|
||
|
||
async function downloadEntry(entry: ServerFileEntryResponse) {
|
||
const key = serverFileEntryKey(entry);
|
||
if (!key) return;
|
||
const operationId = operations.begin({ intent: "下载文件", targetKind: "server", targetId: instance.id, requester: session.displayName });
|
||
const idempotencyKey = serverFileIdempotency("download", instance.id, key);
|
||
setPanelResult({ status: "pending", label: "正在准备文件下载…" });
|
||
try {
|
||
let result = await platformApiClient.prepareServerFileDownload(instance.id, { key, idempotencyKey });
|
||
for (let attempt = 0; result.status === "pending" && attempt < 240; attempt += 1) {
|
||
setPanelResult({ status: "pending", label: `正在准备下载… ${Math.min(99, Math.max(1, attempt))}%` });
|
||
await new Promise((resolve) => window.setTimeout(resolve, 500));
|
||
result = await platformApiClient.prepareServerFileDownload(instance.id, { key, idempotencyKey });
|
||
}
|
||
if (result.status !== "ready") throw new Error(result.reason ?? "文件下载准备超时");
|
||
const message = await downloadServerFileResult(platformApiClient, result);
|
||
operations.succeed(operationId, message, result.job);
|
||
setPanelResult({ status: "succeeded", label: message });
|
||
} catch (error) {
|
||
const reason = error instanceof Error ? error.message : "文件下载失败";
|
||
operations.fail(operationId, reason, operationId);
|
||
setPanelResult({ status: "failed", label: reason });
|
||
}
|
||
}
|
||
|
||
async function uploadFile(event: ChangeEvent<HTMLInputElement>) {
|
||
const file = event.target.files?.[0];
|
||
event.target.value = "";
|
||
if (!file || !directoryKey || workspace.status !== "ready") return;
|
||
if (file.size > workspace.data.transfer.maxBrowserUploadBytes) {
|
||
setPanelResult({ status: "failed", label: `文件超过浏览器上传上限:${formatBytes(workspace.data.transfer.maxBrowserUploadBytes)}` });
|
||
return;
|
||
}
|
||
const operationId = operations.begin({ intent: "上传文件", targetKind: "server", targetId: instance.id, requester: session.displayName });
|
||
setUploadBusy(true);
|
||
setPanelResult({ status: "pending", label: `正在暂存上传:${file.name}` });
|
||
try {
|
||
const response = await platformApiClient.uploadServerFile(instance.id, { directoryKey, relativePath: relativePath || undefined, file, idempotencyKey: serverFileIdempotency("upload", instance.id, file.name) });
|
||
operations.succeed(operationId, `上传已暂存,写入任务 ${response.job.id} 已派发`, response.job);
|
||
setPanelResult({ status: "pending", label: `上传已提交:${response.relativePath}` });
|
||
await browseList({ forceNew: true });
|
||
} catch (error) {
|
||
const reason = error instanceof Error ? error.message : "文件上传失败";
|
||
operations.fail(operationId, reason, operationId);
|
||
setPanelResult({ status: "failed", label: reason });
|
||
} finally {
|
||
setUploadBusy(false);
|
||
}
|
||
}
|
||
|
||
function submitSearch(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
setSearchQuery(searchDraft.trim());
|
||
}
|
||
|
||
function goUp() {
|
||
if (relativePath) {
|
||
setRelativePath(relativePath.split("/").filter(Boolean).slice(0, -1).join("/"));
|
||
return;
|
||
}
|
||
if (workspace.status === "ready" && directoryKey !== workspace.data.defaultDirectoryKey) {
|
||
setDirectoryKey(workspace.data.defaultDirectoryKey || workspace.data.directories[0]?.key || directoryKey);
|
||
}
|
||
}
|
||
|
||
function closeEditor() {
|
||
setEditor({ entry: null, key: "", draft: "", loading: false, saving: false });
|
||
}
|
||
|
||
if (workspace.status === "loading") return <LoadingState label="正在加载文件工作区…" compact />;
|
||
if (workspace.status === "error") return <ErrorState title="文件工作区不可用" reason={workspace.reason} diagnosticId={`server-files:${instance.id}`} onRetry={() => void loadWorkspace()} compact />;
|
||
|
||
return (
|
||
<article className="console-panel server-file-manager" aria-label="server file manager">
|
||
<div className="panel-header">
|
||
<h2><Folder size={16} style={{ verticalAlign: "-2px" }} /> 服务器文件</h2>
|
||
</div>
|
||
<div className="server-file-toolbar">
|
||
<div className="server-file-pathbar" aria-label="当前文件路径">
|
||
<button type="button" className="icon-command" onClick={goUp} disabled={!relativePath && directoryKey === workspace.data.defaultDirectoryKey}><ChevronRight size={14} className="server-file-back-icon" /><span>上级</span></button>
|
||
<span className="server-file-path-chip">{activeDirectory?.label ?? (directoryKey || "服务器根目录")}</span>
|
||
{relativePath.split("/").filter(Boolean).map((part) => <span key={part} className="server-file-path-chip server-file-path-child"><ChevronRight size={12} />{part}</span>)}
|
||
</div>
|
||
{workspace.data.directories.length > 1 && <div className="server-file-directory-tabs" role="tablist" aria-label="文件目录">
|
||
{workspace.data.directories.map((directory) => (
|
||
<button key={directory.key} type="button" className={cx("segmented-button", directory.key === directoryKey && "segmented-button-active")} onClick={() => { setDirectoryKey(directory.key); setRelativePath(""); }}>
|
||
{directory.label}
|
||
</button>
|
||
))}
|
||
</div>}
|
||
<form className="server-file-search" onSubmit={submitSearch}>
|
||
<Search size={14} />
|
||
<input type="search" value={searchDraft} placeholder="搜索文件/目录" onChange={(event) => setSearchDraft(event.target.value)} />
|
||
<label><input type="checkbox" checked={recursive} onChange={(event) => setRecursive(event.target.checked)} /> 包含子目录</label>
|
||
<button type="submit" className="icon-command">搜索</button>
|
||
</form>
|
||
<div className="action-strip server-file-actions">
|
||
<button type="button" className="icon-command" onClick={() => void browseList({ forceNew: true, manual: true })} disabled={!directoryKey}><RefreshCw size={14} /><span>刷新</span></button>
|
||
<label className={cx("server-file-upload-control", !canUpload && "server-file-upload-disabled")} title={canUpload ? "上传到当前目录" : "当前目录不可上传或正在上传"}>
|
||
<Upload size={14} /><span>{uploadBusy ? "上传中…" : "上传"}</span><input type="file" disabled={!canUpload} onChange={(event) => void uploadFile(event)} />
|
||
</label>
|
||
</div>
|
||
</div>
|
||
{panelResult && <ResultBadge status={panelResult.status} label={panelResult.label} />}
|
||
{list.status === "loading" && <LoadingState label="正在加载文件列表…" compact />}
|
||
{list.status === "error" && <ErrorState title="文件列表不可用" reason={list.reason} diagnosticId={`server-file-list:${instance.id}:${directoryKey}`} onRetry={() => void browseList({ forceNew: true })} compact />}
|
||
{list.status === "ready" && (
|
||
<div className="resource-table-wrap server-file-table-wrap">
|
||
<table className="resource-table server-file-table">
|
||
<thead><tr><th>文件名称</th><th>大小</th><th>修改时间</th><th>备注</th><th>操作</th></tr></thead>
|
||
<tbody>
|
||
{entries.length === 0 && <tr><td colSpan={5}><span className="provider-id">{serverFileListEmptyLabel(list.data)}</span></td></tr>}
|
||
{entries.map((entry) => (
|
||
<tr key={serverFileEntryRowKey(entry)} className={entry.kind === "directory" ? "server-file-directory-row" : undefined}>
|
||
<td>
|
||
<button type="button" className="table-link-button server-file-name-button" onClick={() => void openEntry(entry)}>
|
||
{entry.kind === "directory" ? <Folder size={16} /> : <FileText size={16} />}<span>{entry.name}</span>
|
||
</button>
|
||
</td>
|
||
<td>{entry.kind === "directory" ? "--" : formatBytes(entry.sizeBytes)}</td>
|
||
<td>{formatDateTime(entry.modifiedAt)}</td>
|
||
<td>{entry.remark || entry.scope || "--"}</td>
|
||
<td>
|
||
<div className="row-actions human-row-actions">
|
||
{entry.kind === "directory" ? <button type="button" title="打开目录" onClick={() => void openEntry(entry)}><Eye size={14} /><span>打开</span></button> : <button type="button" title="读取/编辑" onClick={() => void openEditor(entry)}><Pencil size={14} /><span>编辑</span></button>}
|
||
{entry.kind === "file" && <button type="button" title="下载" onClick={() => void downloadEntry(entry)}><Download size={14} /><span>下载</span></button>}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
{editor.entry && (
|
||
<div className="server-file-editor-overlay" role="dialog" aria-modal="true" aria-label="file editor">
|
||
<section className="server-file-editor">
|
||
<div className="panel-header"><h3><FileText size={15} style={{ verticalAlign: "-2px" }} /> 文件编辑</h3><span className="page-status">{editor.entry.name}</span><button type="button" className="icon-command" onClick={closeEditor}><X size={14} /><span>关闭</span></button></div>
|
||
{editor.entry && editor.loading && <LoadingState label="正在读取文件快照…" compact />}
|
||
{editor.entry && editor.error && <ErrorState title="文件编辑不可用" reason={editor.error} diagnosticId={`server-file-edit:${instance.id}:${editor.key}`} compact />}
|
||
{editor.entry && editor.message && !editor.error && <span className="provider-id">{editor.message}</span>}
|
||
{editor.entry && editor.snapshot?.state === "ready" && editor.snapshot.content !== undefined && (
|
||
<label className="server-file-editor-field">
|
||
内容
|
||
<textarea value={editor.draft} spellCheck={false} onChange={(event) => setEditor((current) => ({ ...current, draft: event.target.value }))} />
|
||
</label>
|
||
)}
|
||
{editor.entry && (
|
||
<div className="action-strip server-file-editor-actions">
|
||
<button type="button" className="primary-command" disabled={editor.loading || editor.saving || editor.snapshot?.content === undefined} onClick={() => void saveEditor()}><Save size={14} /><span>{editor.saving ? "保存中…" : "保存"}</span></button>
|
||
<button type="button" className="icon-command" onClick={() => void downloadEntry(editor.entry!)}><Download size={14} /><span>下载</span></button>
|
||
</div>
|
||
)}
|
||
</section>
|
||
</div>
|
||
)}
|
||
</article>
|
||
);
|
||
}
|
||
|
||
function serverFileEntryKey(entry: ServerFileEntryResponse): string {
|
||
return entry.logicalKey || entry.relativePath || entry.name;
|
||
}
|
||
|
||
function serverFileEntryRowKey(entry: ServerFileEntryResponse): string {
|
||
return `${entry.kind}:${entry.directoryKey}:${entry.relativePath ?? ""}:${entry.logicalKey ?? ""}:${entry.name}`;
|
||
}
|
||
|
||
function serverFileListEmptyLabel(response: ServerFileListResponse): string {
|
||
if (response.state === "pending") return serverFileListPendingLabel(response);
|
||
if (response.state === "failed") return response.reason ?? "目录读取失败";
|
||
return "当前目录暂无文件。";
|
||
}
|
||
|
||
function serverFileListPendingLabel(response: ServerFileListResponse): string {
|
||
return "正在读取当前目录…";
|
||
}
|
||
|
||
function serverFileJobStateLabel(state: JobResponse["state"]): string {
|
||
switch (state) {
|
||
case "queued":
|
||
return "已排队,等待 Run 领取";
|
||
case "accepted":
|
||
return "Run 已领取,等待确认";
|
||
case "running":
|
||
return "运行中";
|
||
case "retrying":
|
||
return "等待重试";
|
||
case "succeeded":
|
||
return "已完成";
|
||
case "cancelled":
|
||
return "已取消";
|
||
default:
|
||
return "已失败";
|
||
}
|
||
}
|
||
|
||
function serverFileIdempotency(prefix: string, serverId: string, key: string): string {
|
||
return `web-file-${prefix}-${serverId}-${String(key).replace(/[^a-zA-Z0-9_.-]+/g, "-").slice(0, 40)}-${Date.now()}`;
|
||
}
|
||
|
||
function formatBytes(value?: number): string {
|
||
if (value === undefined || !Number.isFinite(value)) return "--";
|
||
if (value < 1024) return `${value} B`;
|
||
const units = ["KB", "MB", "GB", "TB"];
|
||
let scaled = value / 1024;
|
||
let unitIndex = 0;
|
||
while (scaled >= 1024 && unitIndex < units.length - 1) {
|
||
scaled /= 1024;
|
||
unitIndex += 1;
|
||
}
|
||
return `${scaled >= 10 ? scaled.toFixed(1) : scaled.toFixed(2)} ${units[unitIndex]}`;
|
||
}
|
||
|
||
function formatDateTime(value?: string): string {
|
||
if (!value) return "--";
|
||
const parsed = new Date(value);
|
||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
|
||
}
|
||
|
||
interface LlmSectionProps {
|
||
serverId: string;
|
||
instance: ServerInstanceResponse;
|
||
session: PageComponentProps["session"];
|
||
operations: PageComponentProps["operations"];
|
||
}
|
||
|
||
function LlmSection({ serverId, instance, session, operations }: LlmSectionProps) {
|
||
const [prompt, setPrompt] = useState("");
|
||
const [suggestion, setSuggestion] = useState<LlmSuggestionView | null>(null);
|
||
const [confirming, setConfirming] = useState(false);
|
||
const [busy, setBusy] = useState(false);
|
||
const [approvalBusy, setApprovalBusy] = useState(false);
|
||
const [suggestionError, setSuggestionError] = useState("");
|
||
|
||
async function requestSuggestion(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
if (!prompt.trim()) {
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
setSuggestion(null);
|
||
setSuggestionError("");
|
||
try {
|
||
const response = await platformApiClient.invokeAI({ requestId: `web:ai.config:${serverId}:${Date.now()}`, serverInstanceId: serverId, purpose: "config.suggest", prompt: prompt.trim() });
|
||
if (response.status !== "ok") {
|
||
throw new Error(response.error?.message ?? "AI 提供商未返回可用建议");
|
||
}
|
||
const recommendation = response.configRecommendation;
|
||
setSuggestion({
|
||
serverInstanceId: serverId,
|
||
source: "api",
|
||
recommendation: response.recommendation ?? "Platform 已返回配置建议。",
|
||
diffId: recommendation?.diffId,
|
||
expiresAt: recommendation?.expiresAt,
|
||
diffSummary: recommendation?.diffSummary
|
||
});
|
||
} catch (caught) {
|
||
setSuggestionError(caught instanceof Error ? caught.message : "AI 建议请求失败");
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
async function applySuggestion() {
|
||
if (!suggestion?.diffId || approvalBusy) {
|
||
return;
|
||
}
|
||
const operationId = operations.begin({ intent: "应用 AI 配置建议", targetKind: "llm", targetId: serverId, requester: session.displayName });
|
||
setApprovalBusy(true);
|
||
try {
|
||
const approved = await platformApiClient.approveAIConfigDiff(suggestion.diffId, `web:ai.config.approve:${suggestion.diffId}`);
|
||
const job = approved.dispatch.job;
|
||
operations.succeed(operationId, `AI 建议已确认,写入任务 ${job.id} 已派发`, job);
|
||
setSuggestion(null);
|
||
setConfirming(false);
|
||
} catch (error) {
|
||
operations.fail(operationId, error instanceof Error ? error.message : "写入任务派发失败", operationId);
|
||
setConfirming(false);
|
||
} finally {
|
||
setApprovalBusy(false);
|
||
}
|
||
}
|
||
|
||
const llmOperation = operations.operations.find((operation) => operation.intent === "应用 AI 配置建议" && operation.targetId === serverId);
|
||
|
||
return (
|
||
<article className="console-panel" aria-label="llm configuration assistance">
|
||
<div className="panel-header">
|
||
<h2>
|
||
<Sparkles size={16} style={{ verticalAlign: "-2px" }} /> AI 配置助手
|
||
</h2>
|
||
<span className="page-status">建议仅作用于 {serverId}</span>
|
||
</div>
|
||
<p style={{ margin: "0 0 12px", color: "var(--ink-soft)", fontSize: 13.5 }}>
|
||
AI 建议会先生成推荐说明和配置差异,<strong>不会自动写入</strong>。只有你确认差异后,平台才会派发写入任务。前端不会接触任何 AI 提供商密钥。
|
||
</p>
|
||
{llmOperation && (
|
||
<div style={{ marginBottom: 10 }}>
|
||
<ResultBadge
|
||
status={llmOperation.status}
|
||
label={
|
||
llmOperation.status === "pending"
|
||
? "写入中…"
|
||
: llmOperation.status === "succeeded"
|
||
? (llmOperation.message ?? "已派发")
|
||
: `失败:${llmOperation.errorReason}(诊断 ${llmOperation.diagnosticId})`
|
||
}
|
||
/>
|
||
</div>
|
||
)}
|
||
{suggestionError && <ErrorState title="AI 建议不可用" reason={suggestionError} diagnosticId={`ai-config:${serverId}`} onRetry={() => setSuggestionError("")} compact />}
|
||
<form className="provider-form" style={{ border: 0, padding: 0 }} onSubmit={(event) => void requestSuggestion(event)}>
|
||
<label>
|
||
想让 AI 帮你调整什么?
|
||
<textarea
|
||
value={prompt}
|
||
rows={3}
|
||
placeholder="例如:把最大玩家数提高到 40,并关闭 PVP"
|
||
onChange={(event) => setPrompt(event.target.value)}
|
||
/>
|
||
</label>
|
||
<button type="submit" className="primary-command" disabled={busy || !prompt.trim()}>
|
||
{busy ? "生成建议中…" : "生成建议"}
|
||
</button>
|
||
</form>
|
||
|
||
{suggestion && (
|
||
<div style={{ display: "grid", gap: 12, marginTop: 14 }}>
|
||
<div className="panel-header" style={{ marginBottom: 0 }}>
|
||
<h3>AI 建议</h3>
|
||
<span className="page-status">平台 AI Provider</span>
|
||
</div>
|
||
<p style={{ margin: 0, color: "var(--ink-soft)", fontSize: 14 }}>{suggestion.recommendation}</p>
|
||
{suggestion.diffId ? (
|
||
<>
|
||
<div className="console-record">
|
||
<div className="console-record-head"><strong>Reviewable AI diff</strong><span className="status-pill status-active">pending</span></div>
|
||
<div className="console-record-meta"><span>Diff {suggestion.diffId}</span>{suggestion.expiresAt && <span>到期 {new Date(suggestion.expiresAt).toLocaleString()}</span>}</div>
|
||
<span className="provider-id">{suggestion.diffSummary ?? "平台已保存可审查配置差异;批准后才会派发写入任务。"}</span>
|
||
</div>
|
||
<div className="confirm-actions">
|
||
<button type="button" onClick={() => setSuggestion(null)}>
|
||
放弃建议
|
||
</button>
|
||
<button type="button" className="confirm-primary" onClick={() => setConfirming(true)}>
|
||
审批 AI 差异
|
||
</button>
|
||
</div>
|
||
</>
|
||
) : (
|
||
<span className="provider-id">该建议没有生成可应用的配置差异,仅供参考。</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<ConfirmDialog
|
||
open={confirming}
|
||
title="确认应用 AI 配置建议"
|
||
description={`即将向服务器 ${instance.name}(${serverId})派发配置写入任务。写入内容以上方差异为准。`}
|
||
confirmLabel="确认写入"
|
||
busy={approvalBusy || llmOperation?.status === "pending"}
|
||
onCancel={() => setConfirming(false)}
|
||
onConfirm={() => void applySuggestion()}
|
||
/>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
export function configDiffViewFromPreview(preview: ServerConfigDiffPreviewResponse): ConfigDiffView {
|
||
const lines = preview.diff.map(configDiffLineFromPreviewLine);
|
||
const added = lines.filter((line) => line.kind === "added").length;
|
||
const removed = lines.filter((line) => line.kind === "removed").length;
|
||
return {
|
||
serverInstanceId: preview.serverInstanceId,
|
||
configVersion: preview.configVersion,
|
||
checksum: preview.checksum,
|
||
key: preview.key,
|
||
source: preview.source,
|
||
summary: `+${added} / -${removed} 行变更`,
|
||
lines,
|
||
nextContent: preview.proposedContent ?? "",
|
||
proposedContentInputRef: preview.proposedContentInputRef
|
||
};
|
||
}
|
||
|
||
function configDiffLineFromPreviewLine(line: ConfigDiffLineResponse): ConfigDiffView["lines"][number] {
|
||
return {
|
||
kind: line.kind === "context" ? "same" : line.kind,
|
||
text: line.content
|
||
};
|
||
}
|