feat: 自动更新

This commit is contained in:
npc0-hue
2026-07-15 19:43:06 +08:00
parent f64eb0831f
commit f3b14b7945
54 changed files with 3207 additions and 589 deletions
@@ -55,10 +55,14 @@ describe("AiProvidersPage", () => {
expect(html).toContain("配置流程");
expect(html).toContain("提供商预设");
expect(html).toContain("系统 ID");
expect(html).toContain("自动生成,不需要手填");
expect(html).toContain("高级设置:Base URL、模型、模式、超时");
expect(html).toContain("保存前检查");
expect(html).toContain("测试已保存配置");
expect(html).toContain("发现模型并填入");
expect(html).toContain("secret://providers/...");
expect(html).not.toContain('name="id"');
expect(html).not.toContain("api.example.test");
});
+96 -141
View File
@@ -2,11 +2,15 @@ import { Candy, FlaskConical, MoreHorizontal, Power, Sparkles, WandSparkles, Use
import { type ChangeEvent, type FormEvent, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type { AiProviderResponse, AiProviderStatus } from "../api/types";
import type { AiProviderKind, AiProviderResponse, AiProviderStatus } from "../api/types";
import { ConfirmDialog, ManagementDialog } from "../components/OperationControls";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import {
aiProviderKindDefaults,
applyAiProviderKindDefaults,
completeAiProviderForm,
generatedAiProviderId,
aiProviderToForm,
emptyAiProviderForm,
type AiProviderActionState,
@@ -25,79 +29,19 @@ interface AiProvidersPageProps extends Partial<PageComponentProps> {
}
interface ProviderPreset {
id: string;
kind: AiProviderKind;
label: string;
summary: string;
draft: Partial<AiProviderFormState>;
}
type FormCheckState = { status: "pending" | "succeeded" | "failed"; message: string };
const providerPresets: ProviderPreset[] = [
{
id: "openai-relay",
label: "OpenAI Relay",
summary: "平台中转,适合公网 OpenAI 兼容网关。",
draft: {
id: "ai.openai",
name: "OpenAI Relay",
kind: "openai-compatible",
baseUrl: "https://api.openai.com/v1",
apiKeyRef: "secret://providers/openai",
modelsText: "gpt-4.1, gpt-4.1-mini",
defaultModel: "gpt-4.1-mini",
relayMode: "relay",
redactionPolicy: "default"
}
},
{
id: "claude-relay",
label: "Claude Relay",
summary: "平台托管 Anthropic 兼容配置,密钥只留引用。",
draft: {
id: "ai.claude",
name: "Claude Relay",
kind: "claude",
baseUrl: "https://api.anthropic.com/v1",
apiKeyRef: "secret://providers/anthropic",
modelsText: "claude-sonnet, claude-haiku",
defaultModel: "claude-sonnet",
relayMode: "relay",
redactionPolicy: "default"
}
},
{
id: "gemini-relay",
label: "Gemini Relay",
summary: "平台托管 Google Gemini 配置,保存后发现模型。",
draft: {
id: "ai.gemini",
name: "Gemini Relay",
kind: "gemini",
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
apiKeyRef: "secret://providers/gemini",
modelsText: "gemini-pro",
defaultModel: "gemini-pro",
relayMode: "relay",
redactionPolicy: "default"
}
},
{
id: "ollama-local",
label: "Ollama Local",
summary: "本机或内网模型服务,默认走本地模式。",
draft: {
id: "ai.ollama",
name: "Ollama Local",
kind: "ollama",
baseUrl: "http://127.0.0.1:11434/v1",
apiKeyRef: "secret://providers/ollama-local",
modelsText: "llama3.1, qwen2.5",
defaultModel: "llama3.1",
relayMode: "local",
redactionPolicy: "default"
}
}
{ kind: "openai", label: "OpenAI" },
{ kind: "claude", label: "Claude" },
{ kind: "gemini", label: "Gemini" },
{ kind: "ollama", label: "Ollama" },
{ kind: "openai-compatible", label: "兼容网关" },
{ kind: "custom", label: "自定义" }
];
export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
@@ -171,7 +115,12 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
}
function handleInput(event: ChangeEvent<HTMLInputElement | HTMLSelectElement>) {
updateForm(event.target.name as keyof AiProviderFormState, event.target.value);
const key = event.target.name as keyof AiProviderFormState;
if (key === "kind") {
setForm((current) => applyAiProviderKindDefaults(current, event.target.value as AiProviderKind));
} else {
updateForm(key, event.target.value);
}
setFormCheck(null);
}
@@ -200,36 +149,30 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
}
function applyProviderPreset(preset: ProviderPreset) {
setForm((current) => ({
...current,
...preset.draft,
id: formMode === "edit" ? current.id : preset.draft.id ?? current.id
}));
setFormCheck({ status: "pending", message: `已套用 ${preset.label} 预设。保存前请确认 Base URL、secret 引用和默认模型。` });
setForm((current) => applyAiProviderKindDefaults(current, preset.kind));
setFormCheck({ status: "pending", message: `已选择 ${preset.label}。常规配置只需要平台 secret 引用;高级参数已按厂商默认值填入。` });
}
function runFormPreflight() {
const missing: string[] = [];
const models = form.modelsText
const completed = completeAiProviderForm(form);
const models = completed.modelsText
.split(",")
.map((model) => model.trim())
.filter(Boolean);
if (!form.id.trim()) {
missing.push("ID");
}
if (!form.name.trim()) {
if (!completed.name.trim()) {
missing.push("名称");
}
if (!form.baseUrl.trim()) {
if (!completed.baseUrl.trim()) {
missing.push("Base URL");
}
if (!form.apiKeyRef.trim().startsWith("secret://providers/")) {
if (completed.relayMode !== "local" && !completed.apiKeyRef.trim().startsWith("secret://providers/")) {
missing.push("secret://providers/... 密钥引用");
}
if (models.length === 0) {
missing.push("至少一个模型");
}
if (!Number.isFinite(Number.parseInt(form.timeoutMs, 10)) || Number.parseInt(form.timeoutMs, 10) <= 0) {
if (!Number.isFinite(Number.parseInt(completed.timeoutMs, 10)) || Number.parseInt(completed.timeoutMs, 10) <= 0) {
missing.push("有效超时");
}
setFormCheck(
@@ -242,12 +185,14 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setViewState("saving");
const existing = providers.some((provider) => provider.id === form.id.trim());
const completed = completeAiProviderForm(form);
const providerId = generatedAiProviderId(completed);
const existing = providers.some((provider) => provider.id === providerId);
try {
const saved = existing
? await platformApiClient.updateAiProvider(form.id.trim(), aiProviderUpdateRequestFromForm(form))
: await platformApiClient.createAiProvider(aiProviderCreateRequestFromForm(form));
? await platformApiClient.updateAiProvider(providerId, aiProviderUpdateRequestFromForm(completed))
: await platformApiClient.createAiProvider(aiProviderCreateRequestFromForm(completed));
upsertProvider(saved);
setSelectedId(saved.id);
setForm(aiProviderToForm(saved));
@@ -260,7 +205,7 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
} catch (error) {
setViewState("error");
const message = errorMessage(error, "保存失败");
setAction({ providerId: form.id.trim() || selectedId, label: "save", success: false, message });
setAction({ providerId: generatedAiProviderId(form) || selectedId, label: "save", success: false, message });
setFormCheck({ status: "failed", message });
}
}
@@ -359,6 +304,10 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
});
}
const completedForm = completeAiProviderForm(form);
const formDefaults = aiProviderKindDefaults[form.kind];
const secretRequired = completedForm.relayMode !== "local";
return (
<section className="ai-providers-page" aria-labelledby="ai-provider-title">
<header className="page-header ai-provider-header">
@@ -497,72 +446,78 @@ export function AiProvidersPage({ initialState }: AiProvidersPageProps = {}) {
<ProviderSetupGuide />
<div className="provider-preset-grid" aria-label="提供商预设">
{providerPresets.map((preset) => (
<button key={preset.id} type="button" className="provider-preset-option" onClick={() => applyProviderPreset(preset)}>
<button key={preset.kind} type="button" className={cx("provider-preset-option", form.kind === preset.kind && "provider-preset-option-active")} onClick={() => applyProviderPreset(preset)}>
<strong>{preset.label}</strong>
<span>{preset.summary}</span>
<span>{aiProviderKindDefaults[preset.kind].requirement}</span>
</button>
))}
</div>
<label>
<span>ID</span>
<input name="id" value={form.id} onChange={handleInput} disabled={formMode === "edit"} />
<small className="field-help"> ID ai.openai</small>
</label>
<label>
<span></span>
<input name="name" value={form.name} onChange={handleInput} />
<small className="field-help">使 ID </small>
</label>
<div className="form-grid">
<label>
<span></span>
<select name="kind" value={form.kind} onChange={handleInput}>
<option value="openai-compatible">OpenAI Compatible</option>
<option value="openai">OpenAI</option>
<option value="claude">Claude</option>
<option value="gemini">Gemini</option>
<option value="ollama">Ollama</option>
<option value="custom">Custom</option>
</select>
</label>
<label>
<span></span>
<select name="relayMode" value={form.relayMode} onChange={handleInput}>
<option value="direct">Direct</option>
<option value="relay">Relay</option>
<option value="local">Local</option>
</select>
</label>
<label>
<span>{secretRequired ? "平台密钥引用" : "密钥引用(本地模式可留空)"}</span>
<input name="apiKeyRef" value={form.apiKeyRef} onChange={handleInput} placeholder={secretRequired ? formDefaults.apiKeyRef : "本地服务通常不需要"} />
<small className="field-help">
{secretRequired ? "填写 secret://providers/...;真实密钥进入平台 secret store,不粘贴到页面。" : "Ollama 本地模式默认只需要 Base URL 和模型名。"}
</small>
</label>
<div className="provider-generated-id" aria-label="自动生成的提供商 ID">
<span> ID</span>
<code>{generatedAiProviderId(form)}</code>
<small></small>
</div>
<label>
<span>Base URL</span>
<input name="baseUrl" value={form.baseUrl} onChange={handleInput} />
<small className="field-help"></small>
</label>
<label>
<span></span>
<input name="apiKeyRef" value={form.apiKeyRef} onChange={handleInput} />
<small className="field-help"> secret://providers/... 引用,不要粘贴 raw API key。</small>
</label>
<label>
<span></span>
<input name="modelsText" value={form.modelsText} onChange={handleInput} />
<small className="field-help"></small>
</label>
<div className="form-grid">
<details className="provider-advanced-settings">
<summary>Base URL</summary>
<div className="form-grid">
<label>
<span></span>
<select name="kind" value={form.kind} onChange={handleInput}>
<option value="openai-compatible">OpenAI Compatible</option>
<option value="openai">OpenAI</option>
<option value="claude">Claude</option>
<option value="gemini">Gemini</option>
<option value="ollama">Ollama</option>
<option value="custom">Custom</option>
</select>
</label>
<label>
<span></span>
<select name="relayMode" value={form.relayMode} onChange={handleInput}>
<option value="direct">Direct</option>
<option value="relay">Relay</option>
<option value="local">Local</option>
</select>
</label>
</div>
<label>
<span></span>
<input name="defaultModel" value={form.defaultModel} onChange={handleInput} />
<span>Base URL</span>
<input name="baseUrl" value={form.baseUrl} onChange={handleInput} />
<small className="field-help">{formDefaults.advancedNote}</small>
</label>
<label>
<span> ms</span>
<input name="timeoutMs" value={form.timeoutMs} onChange={handleInput} inputMode="numeric" />
<span></span>
<input name="modelsText" value={form.modelsText} onChange={handleInput} />
<small className="field-help"></small>
</label>
</div>
<label>
<span></span>
<input name="redactionPolicy" value={form.redactionPolicy} onChange={handleInput} />
<small className="field-help"> default Bearer token </small>
</label>
<div className="form-grid">
<label>
<span></span>
<input name="defaultModel" value={form.defaultModel} onChange={handleInput} />
</label>
<label>
<span> ms</span>
<input name="timeoutMs" value={form.timeoutMs} onChange={handleInput} inputMode="numeric" />
</label>
</div>
<label>
<span></span>
<input name="redactionPolicy" value={form.redactionPolicy} onChange={handleInput} />
<small className="field-help"> default Bearer token </small>
</label>
</details>
<div className="form-helper-actions">
<button type="button" className="theme-upload" onClick={runFormPreflight}>
@@ -605,7 +560,7 @@ function ProviderSetupGuide() {
return (
<div className="form-guidance provider-setup-guide">
<strong></strong>
<span> secret </span>
<span> secret IDBase URL</span>
</div>
);
}
+60
View File
@@ -8,6 +8,9 @@ import { ProfileSettingsPage } from "./ProfileSettingsPage";
import { ServerDetailPage } from "./ServerDetailPage";
import { ServersPage } from "./ServersPage";
import { UsersPage } from "./UsersPage";
import runtimeTaskProgressSource from "../components/RuntimeTaskProgress.tsx?raw";
import serversPageSource from "./ServersPage.tsx?raw";
import serverDetailPageSource from "./ServerDetailPage.tsx?raw";
import type { PageComponentProps } from "../contracts/page";
import { capabilitiesForRoles, type CurrentUserView } from "../contracts/workspace";
import type { OperationTracker } from "../stores/operations";
@@ -77,6 +80,25 @@ describe("first-party console pages", () => {
expect(html).not.toContain("/Users/");
});
it("renders server runtime actions as a compact popover trigger instead of an in-card details stack", () => {
expect(serversPageSource).toContain('aria-haspopup="menu"');
expect(serversPageSource).toContain("createPortal");
expect(serversPageSource).toContain("runtime-action-popover");
expect(serversPageSource).not.toContain("runtime-action-menu");
expect(serversPageSource).not.toContain("<details");
});
it("surfaces runtime actions through progress dialogs with build stages", () => {
expect(serversPageSource).toContain("RuntimeTaskProgressDialog");
expect(serverDetailPageSource).toContain("RuntimeTaskProgressDialog");
expect(runtimeTaskProgressSource).toContain("runtimeBuildStages");
expect(runtimeTaskProgressSource).toContain("拉取代码");
expect(runtimeTaskProgressSource).toContain("安装环境");
expect(runtimeTaskProgressSource).toContain("编译构建");
expect(runtimeTaskProgressSource).toContain("打包成功");
expect(runtimeTaskProgressSource).toContain("构建成功");
});
it("renders server detail sections for daily operations", () => {
const html = renderToStaticMarkup(<ServerDetailPage {...pageProps({ serverId: "server-example-1" })} />);
@@ -125,4 +147,42 @@ describe("first-party console pages", () => {
expect(html).toContain("界面偏好");
expect(html).not.toContain("role=\"dialog\"");
});
it("labels uploaded profile backgrounds as active and built-in presets as fallback", () => {
const originalWindow = globalThis.window;
Object.defineProperty(globalThis, "window", {
configurable: true,
value: {
localStorage: {
getItem: (key: string) => {
if (key === "platform-web.theme.palette") {
return "magical-girl";
}
if (key === "platform-web.theme.backgroundPreset") {
return "mecha-grid";
}
if (key === "platform-web.theme.background") {
return "data:image/png;base64,custom";
}
return null;
}
}
}
});
try {
const html = renderToStaticMarkup(<ProfileSettingsPage {...pageProps()} />);
expect(html).toContain("自定义背景");
expect(html).toContain("机甲格纳库");
expect(html).toContain("备用");
expect(html).toContain("当前显示自定义上传背景;机甲格纳库 仅作为移除上传后的备用桌面。");
expect(html).toContain('aria-pressed="false"');
} finally {
Object.defineProperty(globalThis, "window", {
configurable: true,
value: originalWindow
});
}
});
});
+25 -8
View File
@@ -47,6 +47,8 @@ export function ProfileSettingsPage({ session, onNavigate, onLogout, onProfileSa
const activePalette = useMemo(() => themePalettes.find((palette) => palette.id === themeState.paletteId) ?? themePalettes[0], [themeState.paletteId]);
const activeBackground = useMemo(() => themeBackgroundPresets.find((preset) => preset.id === themeState.backgroundPresetId) ?? themeBackgroundPresets[0], [themeState.backgroundPresetId]);
const hasCustomBackground = Boolean(themeState.backgroundImage);
const backgroundMetricValue = hasCustomBackground ? "自定义背景" : activeBackground.label;
async function saveProfile(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -138,7 +140,7 @@ export function ProfileSettingsPage({ session, onNavigate, onLogout, onProfileSa
metrics={[
{ label: "身份", value: session.roles.length ? String(session.roles.length) : "0", tone: "neutral" },
{ label: "配色", value: activePalette.label, tone: "success" },
{ label: "背景", value: activeBackground.label, tone: "warning" }
{ label: "背景", value: backgroundMetricValue, tone: "warning" }
]}
/>
@@ -221,12 +223,27 @@ export function ProfileSettingsPage({ session, onNavigate, onLogout, onProfileSa
<strong></strong>
</div>
<div className="background-preset-grid profile-background-grid">
{themeBackgroundPresets.map((preset) => (
<button key={preset.id} type="button" className={cx("background-preset-option", preset.id === themeState.backgroundPresetId && "background-preset-option-active")} aria-pressed={preset.id === themeState.backgroundPresetId} title={themeState.backgroundImage ? `${preset.summary},移除上传背景后显示` : preset.summary} onClick={() => selectBackgroundPreset(preset.id)}>
<span className="background-preset-preview" style={{ background: preset.preview }} aria-hidden="true" />
<span className="background-preset-label">{preset.id === themeState.backgroundPresetId ? <Sparkles size={13} /> : <MoonStar size={13} />}{preset.label}</span>
</button>
))}
{themeBackgroundPresets.map((preset) => {
const isFallbackPreset = preset.id === themeState.backgroundPresetId;
const isVisiblePreset = isFallbackPreset && !hasCustomBackground;
return (
<button
key={preset.id}
type="button"
className={cx("background-preset-option", isVisiblePreset && "background-preset-option-active", hasCustomBackground && isFallbackPreset && "background-preset-option-fallback")}
aria-pressed={isVisiblePreset}
title={hasCustomBackground ? `${preset.summary},当前自定义背景正在显示;此预设会在移除上传背景后显示` : preset.summary}
onClick={() => selectBackgroundPreset(preset.id)}
>
<span className="background-preset-preview" style={{ background: preset.preview }} aria-hidden="true" />
<span className="background-preset-label">
{isVisiblePreset ? <Sparkles size={13} /> : <MoonStar size={13} />}
{preset.label}
{hasCustomBackground && isFallbackPreset && <span className="background-preset-fallback-badge"></span>}
</span>
</button>
);
})}
</div>
<div className="theme-background-actions">
<label className="theme-upload" title="上传自定义背景桌面">
@@ -241,7 +258,7 @@ export function ProfileSettingsPage({ session, onNavigate, onLogout, onProfileSa
</button>
)}
</div>
<span className="theme-background-note">{themeState.backgroundImage ? "自定义上传背景正在显示,预设会作为移除后的备用桌面。" : "当前使用内置背景桌面。"}</span>
<span className="theme-background-note">{themeState.backgroundImage ? `当前显示自定义上传背景;${activeBackground.label}作为移除上传后的备用桌面。` : "当前使用内置背景桌面。"}</span>
</section>
</section>
</div>
+5 -3
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import { configDiffViewFromPreview } from "./ServerDetailPage";
import serverDetailPageSource from "./ServerDetailPage.tsx?raw";
import artifactTransferSource from "../utils/artifactTransfer.ts?raw";
import type { ServerConfigDiffPreviewResponse } from "../api/types";
const preview: ServerConfigDiffPreviewResponse = {
@@ -104,9 +105,10 @@ describe("ServerDetailPage config write approval", () => {
it("keeps plugin lifecycle and bridge-visible output on platform-owned logical references", () => {
expect(serverDetailPageSource).toContain("parsePluginArtifactReference(result)");
expect(serverDetailPageSource).toContain("platformApiClient.openArtifactDownload(artifact.id)");
expect(serverDetailPageSource).toContain("platformApiClient.readArtifactContent(reference.artifactId");
expect(serverDetailPageSource).toContain("replace(/Bearer\\s+[^\\s]+/gi, \"[token]\")");
expect(serverDetailPageSource).toContain("replace(/sk-[A-Za-z0-9_-]+/g, \"[secret]\")");
expect(serverDetailPageSource).toContain("downloadArtifactReference(reference");
expect(artifactTransferSource).toContain("readContent(reference.artifactId");
expect(artifactTransferSource).toContain("replace(/Bearer\\s+[^\\s]+/gi, \"[token]\")");
expect(artifactTransferSource).toContain("replace(/sk-[A-Za-z0-9_-]+/g, \"[secret]\")");
expect(serverDetailPageSource).not.toContain("storage://bucket");
expect(serverDetailPageSource).not.toContain("runSocket");
expect(serverDetailPageSource).not.toContain("rawApiKey");
+182 -50
View File
@@ -20,6 +20,17 @@ import type {
ServerRuntimeActionsResponse
} from "../api/types";
import { ConfirmDialog, DiffView, UsageMeter } from "../components/OperationControls";
import {
RuntimeTaskProgressDialog,
runtimeBuildStages,
runtimeDependencyStages,
runtimeDownloadStages,
runtimeLogStages,
runtimeUpdateStages,
type RuntimeTaskDialogAction,
type RuntimeTaskStage,
useRuntimeTaskController
} from "../components/RuntimeTaskProgress";
import { DiagnosticSummary, EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
import type { PluginBridgeAction, PluginBridgeManifestContract } from "../contracts/pluginBridge";
@@ -45,6 +56,7 @@ import {
} from "../schemas/serverManagement";
import { buildConfigDiff, diffHasChanges } from "../utils/diff";
import { createPluginBridgeDispatcher, createPluginBridgeHostContext, parsePluginArtifactReference } from "../utils/pluginBridgeHost";
import { downloadArtifactReference, safeArtifactError, safeArtifactFilename } from "../utils/artifactTransfer";
import { cx } from "../utils/classes";
import { stateLabel, statusClass } from "./ServersPage";
@@ -605,6 +617,8 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
const [lastClient, setLastClient] = useState<ClientManagerDistributionResponse | null>(null);
const [lastDownload, setLastDownload] = useState<ArtifactDownloadReferenceResponse | null>(null);
const [result, setResult] = useState<{ status: "succeeded" | "failed" | "pending"; label: string } | null>(null);
const runtimeTask = useRuntimeTaskController();
const [runtimeTaskActions, setRuntimeTaskActions] = useState<RuntimeTaskDialogAction[]>([]);
const actionByKey = useMemo(() => {
if (runtimeActions.status !== "ready") {
@@ -621,19 +635,53 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
return actionByKey.get(key)?.reason ?? "平台暂未开放该操作";
}
async function runOperation<T>(intent: string, execute: () => Promise<T>, summarize: (value: T) => string) {
async function runOperation<T>(
intent: string,
execute: () => Promise<T>,
summarize: (value: T) => string,
taskOptions?: {
description: string;
stages: RuntimeTaskStage[];
executeStageIndex?: number;
trackedJobId?: (value: T) => string;
afterSuccess?: (value: T) => void;
}
) {
const operationId = operations.begin({ intent, targetKind: "server", targetId: `${instance.id}:runtime`, requester: session.displayName });
setRuntimeTaskActions([]);
setResult({ status: "pending", label: `${intent} 执行中` });
try {
const value = await execute();
const value = taskOptions?.trackedJobId
? await runtimeTask.runTrackedTask({
title: intent,
description: taskOptions.description,
stages: taskOptions.stages,
start: async () => {
const value = await execute();
return { value, jobId: taskOptions.trackedJobId?.(value) ?? "" };
},
poll: (jobId) => platformApiClient.getJob(jobId)
})
: taskOptions
? await runtimeTask.runTask({
title: intent,
description: taskOptions.description,
stages: taskOptions.stages,
executeStageIndex: taskOptions.executeStageIndex,
execute
})
: await execute();
const label = summarize(value);
operations.succeed(operationId, label);
setResult({ status: "succeeded", label });
runtimeTask.succeedTask(label);
taskOptions?.afterSuccess?.(value);
onChanged();
} catch (error) {
const reason = error instanceof Error ? error.message : `${intent} 失败`;
operations.fail(operationId, reason, operationId);
setResult({ status: "failed", label: reason });
runtimeTask.failTask(reason);
}
}
@@ -647,6 +695,61 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
return null;
}
async function downloadRunArtifact(artifact: { artifactId: string; checksum?: string }) {
setRuntimeTaskActions([]);
try {
const label = await runtimeTask.runTask({
title: "下载 run",
description: `${instance.name} 的 run 包已生成,正在打开 artifact ${artifact.artifactId}`,
stages: runtimeDownloadStages,
executeStageIndex: 1,
execute: async () => {
const reference = await platformApiClient.openArtifactDownload(artifact.artifactId);
setLastDownload(reference);
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
return `run 下载已开始,文件 ${safeArtifactFilename(reference.filename)}`;
}
});
runtimeTask.succeedTask(label);
} catch (error) {
runtimeTask.failTask(error instanceof Error ? error.message : "run 下载失败");
}
}
async function pushRunArtifact(artifact: { artifactId: string; checksum?: string }) {
setRuntimeTaskActions([]);
await runOperation(
"推送 run 更新",
() => platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, artifact.artifactId, artifact.checksum)),
(update) => `run 更新任务已排队,job ${update.jobId ?? update.id}`,
{
description: `将 artifact ${artifact.artifactId} 推送到 ${instance.runEndpointId},并等待平台 job 确认。`,
stages: runtimeUpdateStages,
executeStageIndex: 2
}
);
}
async function downloadClientArtifact(profileKeyForDownload: string) {
setRuntimeTaskActions([]);
try {
const label = await runtimeTask.runTask({
title: "下载客户端",
description: `${instance.name} 的客户端管理器已生成,正在创建下载引用。`,
stages: runtimeDownloadStages,
executeStageIndex: 1,
execute: async () => {
const reference = await platformApiClient.downloadLatestClientManager(instance.id, { profileKey: profileKeyForDownload });
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
return `客户端下载已开始,文件 ${safeArtifactFilename(reference.filename)}`;
}
});
runtimeTask.succeedTask(label);
} catch (error) {
runtimeTask.failTask(error instanceof Error ? error.message : "客户端下载失败");
}
}
return (
<article className="console-panel" aria-label="run distribution controls">
<div className="panel-header">
@@ -726,7 +829,19 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
setLastRun(distribution);
return distribution;
},
(distribution) => `run ${distribution.targetOs}/${distribution.targetArch} 已生成artifact ${distribution.artifactId}generation ${distribution.keyGeneration}`
(distribution) => `run ${distribution.targetOs}/${distribution.targetArch} 二进制已构建artifact ${distribution.artifactId}generation ${distribution.keyGeneration}`,
{
description: `${instance.name} 构建 ${targetOs}/${targetArch} run 包,包含拉取代码、安装环境、编译和打包进度。`,
stages: runtimeBuildStages,
trackedJobId: (distribution) => distribution.buildJobId,
afterSuccess: (distribution) => {
const artifact = { artifactId: distribution.artifactId, checksum: distribution.checksum };
setRuntimeTaskActions([
{ label: "下载 run", kind: "primary", onClick: () => void downloadRunArtifact(artifact) },
{ label: "推送更新", onClick: () => void pushRunArtifact(artifact) }
]);
}
}
)
}
/>
@@ -742,9 +857,15 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
async () => {
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
setLastDownload(reference);
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
return reference;
},
(reference) => `下载引用已创建artifact ${reference.artifactId}有效期 ${new Date(reference.expiresAt).toLocaleTimeString()}`
(reference) => `run 下载已开始artifact ${reference.artifactId}文件 ${safeArtifactFilename(reference.filename)}`,
{
description: `${instance.name} 创建最新 run 包下载引用,并展示 artifact 定位进度。`,
stages: runtimeDownloadStages,
executeStageIndex: 1
}
)
}
secondaryLabel="推送更新"
@@ -760,7 +881,12 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
}
return platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, artifact.artifactId, artifact.checksum));
},
(update) => `run 更新任务已排队,job ${update.jobId ?? update.id}`
(update) => `run 更新任务已排队,job ${update.jobId ?? update.id}`,
{
description: `将最近 run artifact 推送到 ${instance.runEndpointId},并等待平台 job 确认。`,
stages: runtimeUpdateStages,
executeStageIndex: 2
}
)
}
/>
@@ -796,7 +922,15 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
setLastClient(distribution);
return distribution;
},
(distribution) => `客户端管理器已生成artifact ${distribution.artifactId}secret ref ${safeRuntimeRef(distribution.secretRef)}`
(distribution) => `客户端管理器二进制已构建artifact ${distribution.artifactId}secret ref ${safeRuntimeRef(distribution.secretRef)}`,
{
description: `${profileKey} profile 拉取客户端代码、安装环境、编译并生成可下载 artifact。`,
stages: runtimeBuildStages,
trackedJobId: (distribution) => distribution.buildJobId,
afterSuccess: () => {
setRuntimeTaskActions([{ label: "下载客户端", kind: "primary", onClick: () => void downloadClientArtifact(profileKey) }]);
}
}
)
}
secondaryLabel="下载客户端"
@@ -805,8 +939,12 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
onSecondary={() =>
void runOperation(
"下载客户端管理器",
() => platformApiClient.downloadLatestClientManager(instance.id, { profileKey }),
(reference) => `客户端下载引用已创建,artifact ${reference.artifactId}`
async () => {
const reference = await platformApiClient.downloadLatestClientManager(instance.id, { profileKey });
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
return reference;
},
(reference) => `客户端下载已开始,artifact ${reference.artifactId},文件 ${safeArtifactFilename(reference.filename)}`
)
}
/>
@@ -835,7 +973,12 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
void runOperation(
"依赖检查",
() => platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, probeKey)),
(job) => `依赖检查任务已排队,job ${job.id}`
(job) => `依赖检查任务已排队,job ${job.id}`,
{
description: `使用 ${probeKey} probe 检查 ${instance.name} 的运行依赖。`,
stages: runtimeDependencyStages,
executeStageIndex: 1
}
)
}
secondaryLabel="依赖安装"
@@ -845,7 +988,12 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
void runOperation(
"依赖安装",
() => platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, probeKey, installPlanKey)),
(job) => `依赖安装任务已排队,job ${job.id}`
(job) => `依赖安装任务已排队,job ${job.id}`,
{
description: `使用 ${installPlanKey} 安装计划派发依赖安装任务,并保留 job 追踪。`,
stages: runtimeDependencyStages,
executeStageIndex: 2
}
)
}
/>
@@ -855,7 +1003,21 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
disabled={!canUse("live-logs")}
reason={reasonFor("live-logs")}
actionLabel="实时日志"
onAction={onOpenLogs}
onAction={() =>
void runOperation(
"实时日志",
async () => {
onOpenLogs();
return true;
},
() => "已打开实时日志视图",
{
description: `读取 ${instance.name} 的平台日志源并打开实时日志视图。`,
stages: runtimeLogStages,
executeStageIndex: 1
}
)
}
secondaryLabel="历史回填"
secondaryDisabled={!canUse("historical-logs")}
secondaryReason={reasonFor("historical-logs")}
@@ -863,7 +1025,12 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
void runOperation(
"历史日志回填",
() => platformApiClient.requestLogBackfill(instance.id, logBackfillRequest(instance.id, logSourceKey, checkpointRef)),
(job) => `历史日志回填任务已排队,job ${job.id}`
(job) => `历史日志回填任务已排队,job ${job.id}`,
{
description: `${logSourceKey} 日志源准备历史回填游标并派发后台 job。`,
stages: runtimeLogStages,
executeStageIndex: 1
}
)
}
>
@@ -873,6 +1040,7 @@ function RuntimeDistributionSection({ instance, runtimeActions, session, operati
</label>
</RuntimeActionRow>
</div>
<RuntimeTaskProgressDialog task={runtimeTask.task} onClose={runtimeTask.closeTask} actions={runtimeTaskActions} />
</article>
);
}
@@ -1930,19 +2098,9 @@ function ArtifactDownloadPanel({ serverId, artifacts }: ArtifactDownloadPanelPro
setResult((current) => ({ ...current, [artifact.id]: { status: "pending", label: "正在打开制品", progress: 0 } }));
try {
const reference = await platformApiClient.openArtifactDownload(artifact.id);
const chunks: ArrayBuffer[] = [];
let offset = 0;
while (offset < reference.sizeBytes) {
const chunk = await platformApiClient.readArtifactContent(reference.artifactId, offset, reference.chunkSizeBytes);
chunks.push(chunk.payload);
offset += chunk.payload.byteLength;
const progress = Math.min(100, Math.round((offset / reference.sizeBytes) * 100));
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit), (progress) => {
setResult((current) => ({ ...current, [artifact.id]: { status: "pending", label: `传输 ${progress}%`, progress } }));
if (chunk.payload.byteLength === 0) {
break;
}
}
openArtifactBlob(reference, chunks);
});
setResult((current) => ({ ...current, [artifact.id]: { status: "succeeded", label: `已打开 ${safeArtifactFilename(reference.filename)}`, progress: 100 } }));
} catch (error) {
setResult((current) => ({ ...current, [artifact.id]: { status: "failed", label: safeArtifactError(error) } }));
@@ -1992,32 +2150,6 @@ function ArtifactDownloadPanel({ serverId, artifacts }: ArtifactDownloadPanelPro
);
}
function openArtifactBlob(reference: ArtifactDownloadReferenceResponse, chunks: ArrayBuffer[]) {
if (typeof document === "undefined" || typeof URL === "undefined") {
return;
}
const blob = new Blob(chunks, { type: reference.contentType });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = safeArtifactFilename(reference.filename);
anchor.rel = "noopener";
document.body.append(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
function safeArtifactFilename(filename: string): string {
const cleaned = filename.replace(/[\\/]/g, "").trim();
return cleaned || "artifact.bin";
}
function safeArtifactError(error: unknown): string {
const message = error instanceof Error ? error.message : "制品传输失败";
return message.replace(/\/Users\/[^\s]+/g, "[path]").replace(/Bearer\s+[^\s]+/gi, "[token]").replace(/sk-[A-Za-z0-9_-]+/g, "[secret]");
}
function formatBytes(value: number): string {
if (value < 1024) {
return `${value} B`;
+314 -56
View File
@@ -1,8 +1,19 @@
import { CakeSlice, Candy, Search, Sparkles } from "lucide-react";
import { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import { type CSSProperties, type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { platformApiClient } from "../api/client";
import type { GamePluginResponse, JobResponse, RunEndpointResponse, ServerInstanceResponse, ServerMetricsResponse } from "../api/types";
import {
RuntimeTaskProgressDialog,
type RuntimeTaskDialogAction,
runtimeBuildStages,
runtimeDependencyStages,
runtimeDownloadStages,
runtimeLogStages,
runtimeUpdateStages,
useRuntimeTaskController
} from "../components/RuntimeTaskProgress";
import { UsageMeter } from "../components/OperationControls";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page";
@@ -23,6 +34,7 @@ import {
serverCreateRequestFromForm
} from "../schemas/serverManagement";
import { isPlatformAdmin } from "../contracts/workspace";
import { downloadArtifactReference, safeArtifactFilename } from "../utils/artifactTransfer";
import { cx } from "../utils/classes";
type ListState = "loading" | "ready" | "error";
@@ -47,6 +59,8 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
const [statusFilter, setStatusFilter] = useState<ServerStatusFilter>("all");
const [form, setForm] = useState<ServerCreateFormState>(() => defaultServerCreateForm([], []));
const [showCreate, setShowCreate] = useState(false);
const runtimeTask = useRuntimeTaskController();
const [runtimeTaskActions, setRuntimeTaskActions] = useState<RuntimeTaskDialogAction[]>([]);
const refresh = useCallback(async () => {
setListState("loading");
@@ -126,48 +140,175 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
const defaults = quickRuntimeDefaultsForPlugin(instance.pluginId);
const intent = quickRuntimeActionLabel(action);
const operationId = operations.begin({ intent, targetKind: "server", targetId: `${instance.id}:${action}`, requester: session.displayName });
setRuntimeTaskActions([]);
let generatedRunArtifact: { artifactId: string; checksum?: string } | null = null;
let generatedClientProfile: string | null = null;
try {
let message = "运行操作已提交";
let message: string;
if (action === "generate-run") {
const distribution = await platformApiClient.generateRunDistribution(instance.id, runDistributionGenerateRequest(instance.id, defaults.runOs, "amd64"));
message = `run 包已生成,artifact ${distribution.artifactId}`;
} else if (action === "download-run") {
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
message = `run 下载引用已创建,artifact ${reference.artifactId}`;
} else if (action === "push-run-update") {
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
const update = await platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, reference.artifactId, reference.checksum));
message = `run 更新任务已排队,job ${update.jobId ?? update.id}`;
const distribution = await runtimeTask.runTrackedTask({
title: intent,
description: quickRuntimeTaskDescription(instance, action),
stages: runtimeBuildStages,
start: async () => {
const distribution = await platformApiClient.generateRunDistribution(instance.id, runDistributionGenerateRequest(instance.id, defaults.runOs, "amd64"));
return { value: distribution, jobId: distribution.buildJobId };
},
poll: (jobId) => platformApiClient.getJob(jobId)
});
generatedRunArtifact = { artifactId: distribution.artifactId };
message = `run 二进制已构建并上传,artifact ${distribution.artifactId}`;
} else if (action === "generate-client-manager") {
const distribution = await platformApiClient.generateClientManager(
instance.id,
clientManagerBuildRequest({
serverInstanceId: instance.id,
profileKey: defaults.clientProfileKey,
targetOs: defaults.clientOs,
targetArch: "amd64",
repositoryUrl: defaults.repositoryUrl,
sourceRevision: "main"
})
);
message = `客户端管理器已生成,artifact ${distribution.artifactId}`;
} else if (action === "dependencies-check") {
const job = await platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey));
message = `依赖检查任务已排队,job ${job.id}`;
} else if (action === "dependencies-install") {
const job = await platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey, defaults.installPlanKey));
message = `依赖安装任务已排队,job ${job.id}`;
} else if (action === "live-logs") {
onNavigate("serverDetail", { serverId: instance.id });
message = "已打开服务器详情,可切换到日志页查看实时日志";
} else if (action === "historical-logs") {
const job = await platformApiClient.requestLogBackfill(instance.id, logBackfillRequest(instance.id, defaults.logSourceKey));
message = `历史日志回填任务已排队,job ${job.id}`;
const distribution = await runtimeTask.runTrackedTask({
title: intent,
description: quickRuntimeTaskDescription(instance, action),
stages: runtimeBuildStages,
start: async () => {
const distribution = await platformApiClient.generateClientManager(
instance.id,
clientManagerBuildRequest({
serverInstanceId: instance.id,
profileKey: defaults.clientProfileKey,
targetOs: defaults.clientOs,
targetArch: "amd64",
repositoryUrl: defaults.repositoryUrl,
sourceRevision: "main"
})
);
return { value: distribution, jobId: distribution.buildJobId };
},
poll: (jobId) => platformApiClient.getJob(jobId)
});
generatedClientProfile = defaults.clientProfileKey;
message = `客户端二进制已构建并上传,artifact ${distribution.artifactId}`;
} else {
message = await runtimeTask.runTask({
title: intent,
description: quickRuntimeTaskDescription(instance, action),
stages: quickRuntimeStages(action),
executeStageIndex: quickRuntimeExecuteStageIndex(action),
execute: async () => {
if (action === "download-run") {
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
return `run 下载已开始,artifact ${reference.artifactId},文件 ${safeArtifactFilename(reference.filename)}`;
}
if (action === "push-run-update") {
const reference = await platformApiClient.downloadLatestRunDistribution(instance.id);
const update = await platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, reference.artifactId, reference.checksum));
return `run 更新任务已排队,job ${update.jobId ?? update.id}`;
}
if (action === "dependencies-check") {
const job = await platformApiClient.checkDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey));
return `依赖检查任务已排队,job ${job.id}`;
}
if (action === "dependencies-install") {
const job = await platformApiClient.installDependencies(instance.id, dependencyJobRequest(instance.id, defaults.probeKey, defaults.installPlanKey));
return `依赖安装任务已排队,job ${job.id}`;
}
if (action === "live-logs") {
onNavigate("serverDetail", { serverId: instance.id });
return "已打开服务器详情,可切换到日志页查看实时日志";
}
const job = await platformApiClient.requestLogBackfill(instance.id, logBackfillRequest(instance.id, defaults.logSourceKey));
return `历史日志回填任务已排队,job ${job.id}`;
}
});
}
operations.succeed(operationId, message);
runtimeTask.succeedTask(message);
if (generatedRunArtifact) {
const artifact = generatedRunArtifact;
setRuntimeTaskActions([
{
label: "下载 run",
kind: "primary",
onClick: () => void downloadGeneratedRun(instance, artifact)
},
{
label: "推送更新",
onClick: () => void pushGeneratedRunUpdate(instance, artifact)
}
]);
} else if (generatedClientProfile) {
const clientProfile = generatedClientProfile;
setRuntimeTaskActions([
{
label: "下载客户端",
kind: "primary",
onClick: () => void downloadGeneratedClient(instance, clientProfile)
}
]);
}
await refresh();
} catch (error) {
operations.fail(operationId, error instanceof Error ? error.message : "运行操作失败", operationId);
const message = error instanceof Error ? error.message : "运行操作失败";
operations.fail(operationId, message, operationId);
runtimeTask.failTask(message);
}
}
async function downloadGeneratedRun(instance: ServerInstanceResponse, artifact: { artifactId: string; checksum?: string }) {
setRuntimeTaskActions([]);
try {
const message = await runtimeTask.runTask({
title: "下载 run",
description: `${instance.name} 的 run 包已生成,正在打开 artifact ${artifact.artifactId}`,
stages: runtimeDownloadStages,
executeStageIndex: 1,
execute: async () => {
const reference = await platformApiClient.openArtifactDownload(artifact.artifactId);
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
return `run 下载已开始,文件 ${safeArtifactFilename(reference.filename)}`;
}
});
runtimeTask.succeedTask(message);
} catch (error) {
runtimeTask.failTask(error instanceof Error ? error.message : "run 下载失败");
}
}
async function pushGeneratedRunUpdate(instance: ServerInstanceResponse, artifact: { artifactId: string; checksum?: string }) {
setRuntimeTaskActions([]);
const operationId = operations.begin({ intent: "推送 run 更新", targetKind: "server", targetId: `${instance.id}:push-run-update`, requester: session.displayName });
try {
const message = await runtimeTask.runTask({
title: "推送 run 更新",
description: `${instance.name} 将使用刚生成的 artifact ${artifact.artifactId} 派发 run 自更新任务。`,
stages: runtimeUpdateStages,
executeStageIndex: 2,
execute: async () => {
const update = await platformApiClient.pushRunUpdate(instance.id, runUpdateRequest(instance.id, artifact.artifactId, artifact.checksum));
return `run 更新任务已排队,job ${update.jobId ?? update.id}`;
}
});
operations.succeed(operationId, message);
runtimeTask.succeedTask(message);
await refresh();
} catch (error) {
const message = error instanceof Error ? error.message : "推送 run 更新失败";
operations.fail(operationId, message, operationId);
runtimeTask.failTask(message);
}
}
async function downloadGeneratedClient(instance: ServerInstanceResponse, profileKey: string) {
setRuntimeTaskActions([]);
try {
const message = await runtimeTask.runTask({
title: "下载客户端",
description: `${instance.name} 的客户端管理器已生成,正在创建下载引用。`,
stages: runtimeDownloadStages,
executeStageIndex: 1,
execute: async () => {
const reference = await platformApiClient.downloadLatestClientManager(instance.id, { profileKey });
await downloadArtifactReference(reference, (artifactId, offset, limit) => platformApiClient.readArtifactContent(artifactId, offset, limit));
return `客户端下载已开始,文件 ${safeArtifactFilename(reference.filename)}`;
}
});
runtimeTask.succeedTask(message);
} catch (error) {
runtimeTask.failTask(error instanceof Error ? error.message : "客户端下载失败");
}
}
@@ -307,6 +448,7 @@ export function ServersPage({ session, operations, onNavigate }: PageComponentPr
))}
</div>
)}
<RuntimeTaskProgressDialog task={runtimeTask.task} onClose={runtimeTask.closeTask} actions={runtimeTaskActions} />
</section>
);
}
@@ -331,6 +473,80 @@ interface ServerCardProps {
function ServerCard({ card, metricsPending, onOpen, onQuickAction }: ServerCardProps) {
const { instance, metrics, pendingJobs } = card;
const online = serverIsOnline(instance.state);
const menuButtonRef = useRef<HTMLButtonElement>(null);
const menuPanelRef = useRef<HTMLDivElement>(null);
const [menuOpen, setMenuOpen] = useState(false);
const [menuStyle, setMenuStyle] = useState<CSSProperties>({});
const closeMenu = useCallback(() => setMenuOpen(false), []);
const openMenu = useCallback(() => {
const trigger = menuButtonRef.current;
if (!trigger) {
setMenuOpen(true);
return;
}
const rect = trigger.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const menuWidth = Math.min(320, Math.max(220, viewportWidth - 24));
const estimatedMenuHeight = 232;
const left = Math.min(Math.max(12, rect.right - menuWidth), Math.max(12, viewportWidth - menuWidth - 12));
const belowTop = rect.bottom + 8;
const top = belowTop + estimatedMenuHeight <= viewportHeight - 12 ? belowTop : Math.max(12, rect.top - estimatedMenuHeight - 8);
setMenuStyle({ left, top, width: menuWidth });
setMenuOpen(true);
}, []);
const toggleMenu = useCallback(() => {
if (menuOpen) {
closeMenu();
return;
}
openMenu();
}, [closeMenu, menuOpen, openMenu]);
useEffect(() => {
if (!menuOpen) {
return undefined;
}
const handlePointerDown = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
if (menuButtonRef.current?.contains(target) || menuPanelRef.current?.contains(target)) {
return;
}
closeMenu();
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
closeMenu();
menuButtonRef.current?.focus();
}
};
document.addEventListener("pointerdown", handlePointerDown, true);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("resize", closeMenu);
window.addEventListener("scroll", closeMenu, true);
return () => {
document.removeEventListener("pointerdown", handlePointerDown, true);
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("resize", closeMenu);
window.removeEventListener("scroll", closeMenu, true);
};
}, [closeMenu, menuOpen]);
const chooseQuickAction = (action: ServerQuickRuntimeAction) => {
closeMenu();
onQuickAction(action);
};
return (
<article className="server-card" aria-label={`${instance.name} 服务器卡片`}>
<div className="server-card-head">
@@ -368,31 +584,42 @@ function ServerCard({ card, metricsPending, onOpen, onQuickAction }: ServerCardP
<Sparkles size={14} />
<span></span>
</button>
<details className="runtime-action-menu">
<summary className="icon-command"></summary>
<div className="action-list">
{serverQuickActions.map((action) => (
<button key={action} type="button" className="theme-upload" onClick={() => onQuickAction(action)}>
<Candy size={13} />
<span>{quickRuntimeActionLabel(action)}</span>
</button>
))}
</div>
</details>
<button ref={menuButtonRef} type="button" className="icon-command" aria-haspopup="menu" aria-expanded={menuOpen} onClick={toggleMenu}>
<span></span>
</button>
</div>
{menuOpen &&
typeof document !== "undefined" &&
createPortal(
<div ref={menuPanelRef} className="runtime-action-popover" style={menuStyle} role="menu" aria-label={`${instance.name} 运行操作`}>
{serverQuickActionGroups.map((group) => (
<section key={group.label} className="runtime-action-group" aria-label={group.label}>
<span className="runtime-action-group-label">{group.label}</span>
<div className="runtime-action-grid">
{group.actions.map((action) => (
<button key={action} type="button" className="runtime-action-item" role="menuitem" onClick={() => chooseQuickAction(action)}>
<span>{quickRuntimeActionLabel(action)}</span>
</button>
))}
</div>
</section>
))}
</div>,
document.body
)}
</article>
);
}
const serverQuickActions: ServerQuickRuntimeAction[] = [
"generate-run",
"download-run",
"push-run-update",
"generate-client-manager",
"dependencies-check",
"dependencies-install",
"live-logs",
"historical-logs"
const serverQuickActionGroups: Array<{ label: string; actions: ServerQuickRuntimeAction[] }> = [
{
label: "运行分发",
actions: ["generate-run", "download-run", "push-run-update", "generate-client-manager"]
},
{
label: "诊断维护",
actions: ["dependencies-check", "dependencies-install", "live-logs", "historical-logs"]
}
];
function quickRuntimeActionLabel(action: ServerQuickRuntimeAction): string {
@@ -416,6 +643,37 @@ function quickRuntimeActionLabel(action: ServerQuickRuntimeAction): string {
}
}
function quickRuntimeStages(action: ServerQuickRuntimeAction) {
if (action === "generate-run" || action === "generate-client-manager") {
return runtimeBuildStages;
}
if (action === "download-run") {
return runtimeDownloadStages;
}
if (action === "push-run-update") {
return runtimeUpdateStages;
}
if (action === "dependencies-check" || action === "dependencies-install") {
return runtimeDependencyStages;
}
return runtimeLogStages;
}
function quickRuntimeExecuteStageIndex(action: ServerQuickRuntimeAction): number {
if (action === "generate-run" || action === "generate-client-manager") {
return 3;
}
if (action === "push-run-update") {
return 2;
}
return 1;
}
function quickRuntimeTaskDescription(instance: ServerInstanceResponse, action: ServerQuickRuntimeAction): string {
const label = quickRuntimeActionLabel(action);
return `${instance.name}${instance.id}${label},通过平台 API 派发并保留可追踪进度。`;
}
function quickRuntimeDefaultsForPlugin(pluginId: string) {
const isScum = pluginId.toLowerCase().includes("scum");
return {