Files
browser/platform_web/pages/ServerDetailPage.tsx
T

1085 lines
55 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 { 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 = 15000;
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 [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 defaultSectionResolvedRef = useRef(false);
const refresh = useCallback(async () => {
if (!serverId) {
setInstance({ status: "error", reason: "缺少服务器 ID" });
return;
}
setInstance({ status: "loading" });
try {
const [detail, pluginResponse, deploymentResponse, endpointResponse] = await Promise.all([
platformApiClient.getServerInstance(serverId),
platformApiClient.listGamePlugins(),
platformApiClient
.getServerDeployment(serverId)
.then((data): LoadState<ServerDeploymentResponse> => ({ status: "ready", data }))
.catch((error): LoadState<ServerDeploymentResponse> => ({ status: "error", reason: error instanceof Error ? error.message : "部署定义加载失败" })),
platformApiClient.listRunEndpoints({ status: "online" }).catch(() => ({ items: [], count: 0 }))
]);
setInstance({ status: "ready", data: detail });
setPlugins(pluginResponse.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, endpointResponse] = await Promise.all([
platformApiClient.getServerInstance(serverId),
platformApiClient.listRunEndpoints({ status: "online" })
]);
setInstance({ status: "ready", data: detail });
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]);
const defaultPluginPage = readyPlugin?.pages.find((page) => page.key === "players") ?? readyPlugin?.pages[0];
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]);
useEffect(() => {
if (params.routeKey || !defaultPluginPage || defaultSectionResolvedRef.current) return;
defaultSectionResolvedRef.current = true;
if (section !== "manage") return;
setSection(`plugin:${defaultPluginPage.key}`);
}, [defaultPluginPage, params.routeKey, 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 !== "ready" && (
<header className="server-detail-header server-detail-pending-header">
<div className="server-detail-title-row">
<div>
<h1 id="server-detail-title">服务器详情</h1>
</div>
<div className="action-strip">
<button type="button" className="icon-command" onClick={() => onNavigate("servers")}>
<MoonStar size={16} />
<span>返回列表</span>
</button>
</div>
</div>
</header>
)}
{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"
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>
<span className={cx("status-pill", detailFreshness === "fresh" ? statusClass(instance.data.state) : "status-disabled")}>{detailStateText}</span>
</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} 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="插件功能">
{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 hasProjection = Boolean(projection?.templateKey || projection?.templateVersion || projection?.preflightState || projection?.discoveryState || projection?.mappingState || projection?.verificationState || projection?.failureCode);
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>
{hasProjection && <div className="console-row-list" style={{ marginTop: 12 }}><div className="console-row"><span>部署验证模板</span><strong>{projection?.templateVersion ? `${projection.templateKey ?? "已选择"} · v${projection.templateVersion}` : projection?.templateKey ?? "等待 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
};
}