Add server file manager workflow
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { MoonStar, PackageOpen, Pencil, ShieldCheck, Sparkles, Square, Terminal, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
|
||||
import { type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ChevronRight, Download, Eye, FileText, Folder, MoonStar, PackageOpen, Pencil, RefreshCw, Save, Search, ShieldCheck, Sparkles, Square, Terminal, Upload, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react";
|
||||
import { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type {
|
||||
ConfigDiffLineResponse,
|
||||
DeclaredFileReadSnapshotResponse,
|
||||
GamePluginResponse,
|
||||
JobResponse,
|
||||
ServerInstanceResponse,
|
||||
@@ -11,7 +12,10 @@ import type {
|
||||
ServerMetricsResponse,
|
||||
ServerDeploymentResponse,
|
||||
ServerConfigDiffPreviewResponse,
|
||||
RunEndpointResponse
|
||||
RunEndpointResponse,
|
||||
ServerFileEntryResponse,
|
||||
ServerFileListResponse,
|
||||
ServerFileWorkspaceResponse
|
||||
} from "../api/types";
|
||||
import { ConfirmDialog, UsageMeter } from "../components/OperationControls";
|
||||
import { ServerManagementTerminalDrawer } from "../components/ServerManagementTerminalDrawer";
|
||||
@@ -30,6 +34,7 @@ import {
|
||||
serverMetadataUpdateRequestFromForm
|
||||
} from "../schemas/serverManagement";
|
||||
import { cx } from "../utils/classes";
|
||||
import { downloadServerFileResult } from "../utils/serverFileTransfer";
|
||||
import { stateLabel, statusClass } from "./ServersPage";
|
||||
import { PluginPageHostPage } from "./PluginPageHostPage";
|
||||
|
||||
@@ -281,6 +286,7 @@ export function ServerDetailPage(props: PageComponentProps) {
|
||||
/>
|
||||
)}
|
||||
{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)} />
|
||||
</>
|
||||
@@ -560,6 +566,321 @@ function ServerAdministratorsSection({ instance, session, onChanged }: ServerAdm
|
||||
);
|
||||
}
|
||||
|
||||
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 activeDirectory = workspace.status === "ready" ? workspace.data.directories.find((item) => item.key === directoryKey) : undefined;
|
||||
const canUpload = workspace.status === "ready" && activeDirectory?.scope !== "logs" && !uploadBusy;
|
||||
const entries = list.status === "ready" ? list.data.entries : [];
|
||||
|
||||
const loadWorkspace = useCallback(async () => {
|
||||
setWorkspace({ status: "loading" });
|
||||
try {
|
||||
const response = await platformApiClient.getServerFileWorkspace(instance.id);
|
||||
setWorkspace({ status: "ready", data: response });
|
||||
setDirectoryKey((current) => current || response.defaultDirectoryKey || response.directories[0]?.key || "");
|
||||
} catch (error) {
|
||||
setWorkspace({ status: "error", reason: error instanceof Error ? error.message : "文件工作区加载失败" });
|
||||
setList({ status: "error", reason: "文件工作区不可用" });
|
||||
}
|
||||
}, [instance.id]);
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
if (!directoryKey) return;
|
||||
setList({ status: "loading" });
|
||||
try {
|
||||
const response = await platformApiClient.listServerFiles(instance.id, { directoryKey, path: relativePath || undefined, query: searchQuery || undefined, recursive });
|
||||
setList({ status: "ready", data: response });
|
||||
} catch (error) {
|
||||
setList({ status: "error", reason: error instanceof Error ? error.message : "文件列表加载失败" });
|
||||
}
|
||||
}, [directoryKey, instance.id, recursive, relativePath, searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadWorkspace();
|
||||
}, [loadWorkspace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (workspace.status !== "ready" || !directoryKey) return;
|
||||
void loadList();
|
||||
}, [directoryKey, loadList, workspace.status]);
|
||||
|
||||
async function refreshRuntimeList() {
|
||||
if (!directoryKey) return;
|
||||
const operationId = operations.begin({ intent: "刷新文件目录", targetKind: "server", targetId: instance.id, requester: session.displayName });
|
||||
setPanelResult({ status: "pending", label: "正在向 Run 请求实时目录…" });
|
||||
try {
|
||||
const response = await platformApiClient.refreshServerFiles(instance.id, { directoryKey, path: relativePath || undefined, query: searchQuery || undefined, recursive, idempotencyKey: serverFileIdempotency("list", instance.id, directoryKey) });
|
||||
setList({ status: "ready", data: response });
|
||||
operations.succeed(operationId, `目录刷新任务 ${response.job?.id ?? "已派发"}`, response.job);
|
||||
setPanelResult({ status: "pending", label: response.reason ?? "目录刷新任务已派发,稍后可再次刷新查看实时结果。" });
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : "目录刷新失败";
|
||||
operations.fail(operationId, reason, operationId);
|
||||
setPanelResult({ status: "failed", label: reason });
|
||||
}
|
||||
}
|
||||
|
||||
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: `读取任务已派发:${dispatch.job.id}` });
|
||||
} 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 || !editor.entry.editable) 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: `写入任务已派发:${dispatch.job.id}` });
|
||||
await loadList();
|
||||
} 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 || !entry.downloadable) return;
|
||||
const operationId = operations.begin({ intent: "下载文件", targetKind: "server", targetId: instance.id, requester: session.displayName });
|
||||
setPanelResult({ status: "pending", label: "正在准备文件下载…" });
|
||||
try {
|
||||
const result = await platformApiClient.prepareServerFileDownload(instance.id, { key, idempotencyKey: serverFileIdempotency("download", instance.id, key) });
|
||||
const message = await downloadServerFileResult(platformApiClient, result);
|
||||
operations.succeed(operationId, message, result.job);
|
||||
setPanelResult({ status: result.status === "ready" ? "succeeded" : "pending", 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 loadList();
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<span className="page-status">独立通道 {workspace.data.transfer.channel} · 内联编辑 {formatBytes(workspace.data.transfer.maxInlineEditBytes)}</span>
|
||||
</div>
|
||||
<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>
|
||||
<div className="server-file-toolbar">
|
||||
<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 refreshRuntimeList()} 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 loadList()} compact />}
|
||||
{list.status === "ready" && (
|
||||
<div className="server-file-layout">
|
||||
<div className="resource-table-wrap server-file-table-wrap">
|
||||
<table className="resource-table server-file-table">
|
||||
<thead><tr><th aria-label="选择"><input type="checkbox" disabled /></th><th>文件名称</th><th>大小</th><th>修改时间</th><th>备注</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{entries.length === 0 && <tr><td colSpan={6}><span className="provider-id">当前目录没有可展示文件;可刷新实时目录或换一个插件声明目录。</span></td></tr>}
|
||||
{entries.map((entry) => (
|
||||
<tr key={serverFileEntryRowKey(entry)} className={entry.kind === "directory" ? "server-file-directory-row" : undefined}>
|
||||
<td><input type="checkbox" disabled /></td>
|
||||
<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>
|
||||
<span className="provider-id">{entry.logicalKey || entry.relativePath || entry.directoryKey}</span>
|
||||
</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="读取/编辑" disabled={!entry.editable} onClick={() => void openEditor(entry)}><Pencil size={14} /><span>编辑</span></button>}
|
||||
{entry.kind === "file" && <button type="button" title="下载" disabled={!entry.downloadable} onClick={() => void downloadEntry(entry)}><Download size={14} /><span>下载</span></button>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<aside className="server-file-editor" aria-label="file editor">
|
||||
<div className="panel-header"><h3><FileText size={15} style={{ verticalAlign: "-2px" }} /> 文件编辑</h3>{editor.entry && <span className="page-status">{editor.entry.name}</span>}</div>
|
||||
{!editor.entry && <p className="section-copy">选择一个插件声明为可编辑的文件后,这里会显示最近读取快照;保存会派发 Run 侧写入任务。</p>}
|
||||
{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.entry.editable || 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" disabled={!editor.entry.downloadable} onClick={() => void downloadEntry(editor.entry!)}><Download size={14} /><span>下载</span></button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</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 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();
|
||||
}
|
||||
|
||||
function HeaderStat({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<span className="server-card-stat">
|
||||
|
||||
Reference in New Issue
Block a user