功能修改

This commit is contained in:
npc0-hue
2026-07-20 16:42:33 +08:00
parent 48b8ad8d6c
commit a0e69417db
224 changed files with 22015 additions and 884 deletions
@@ -0,0 +1,436 @@
import { Activity, BellRing, Database, RefreshCw, ShieldCheck, UsersRound, Wrench } from "lucide-react";
import { type FormEvent, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { platformApiClient } from "../api/client";
import type {
BackupResponse,
GameClientBridgeCommandResponse,
GameClientBridgeJsonObject,
GameClientBridgeSnapshotResponse,
GameClientBridgeStatusResponse,
LogEntryBody,
LogStreamResponse
} from "../api/types";
import type { ScumOperationsPageContract, ScumOperationsSnapshotView } from "../contracts/scumOperations";
import { projectScumOperationsSnapshots } from "../schemas/scumOperations";
import { cx } from "../utils/classes";
import { safeDiagnosticText } from "../utils/safeDiagnosticText";
import { ErrorState, LoadingState, ResultBadge } from "./StateViews";
type SnapshotSection = "players" | "sessions" | "squads" | "vehicles" | "flags";
export interface ScumSemanticLogView {
streamKey: string;
eventType: string;
entry: LogEntryBody;
}
export interface ScumOperationsPanelData {
status?: GameClientBridgeStatusResponse;
commands: GameClientBridgeCommandResponse[];
snapshots: GameClientBridgeSnapshotResponse[];
logs: ScumSemanticLogView[];
backups: BackupResponse[];
errors: string[];
}
type ScumOperationsPanelState =
| { status: "loading" }
| { status: "error"; reason: string }
| { status: "ready"; data: ScumOperationsPanelData };
interface ScumOperationsPanelProps {
contract: ScumOperationsPageContract;
initialData?: ScumOperationsPanelData;
}
export function ScumOperationsPanel({ contract, initialData }: ScumOperationsPanelProps) {
const [state, setState] = useState<ScumOperationsPanelState>(() => initialData ? { status: "ready", data: initialData } : { status: "loading" });
const [snapshotSection, setSnapshotSection] = useState<SnapshotSection>("players");
const [announcement, setAnnouncement] = useState("");
const [pendingCommand, setPendingCommand] = useState<string | null>(null);
const [actionResult, setActionResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
const refresh = useCallback(async () => {
setState({ status: "loading" });
const results = await Promise.allSettled([
platformApiClient.getGameClientBridgeStatus(contract.serverInstanceId),
platformApiClient.listGameClientBridgeCommands(contract.serverInstanceId),
platformApiClient.listGameClientBridgeSnapshots(contract.serverInstanceId, { limit: 200 }),
loadSemanticLogs(contract),
platformApiClient.listBackups(contract.serverInstanceId)
]);
const errors = results.flatMap((result, index) => result.status === "rejected" ? [loadErrorLabel(index, result.reason)] : []);
const status = settledValue(results[0]);
const commands = settledValue(results[1]);
const snapshots = settledValue(results[2]);
const logs = settledValue(results[3]);
const backups = settledValue(results[4]);
if (!status && !commands && !snapshots) {
setState({ status: "error", reason: errors.join("") || "Game Client Bridge 核心投影不可用。" });
return;
}
setState({
status: "ready",
data: {
status,
commands: commands?.items ?? [],
snapshots: snapshots?.items ?? [],
logs: logs ?? [],
backups: backups?.items ?? [],
errors
}
});
}, [contract]);
useEffect(() => {
if (!initialData) {
void refresh();
}
}, [initialData, refresh]);
const snapshots = useMemo(() => state.status === "ready" ? projectScumOperationsSnapshots(state.data.snapshots) : emptySnapshotView(), [state]);
if (state.status === "loading") {
return <LoadingState label="正在加载 SCUM Bridge 运维投影…" />;
}
if (state.status === "error") {
return <ErrorState title="SCUM 运维投影不可用" reason={state.reason} onRetry={() => void refresh()} />;
}
const data = state.data;
const bridgeAvailable = data.status?.available === true;
const bridgeReason = data.status?.reason || data.status?.profiles.find((profile) => !profile.available)?.reason;
const profile = data.status?.profiles.find((candidate) => candidate.available) ?? data.status?.profiles[0];
const canQueue = bridgeAvailable && profile?.available === true;
const diagnostics = contract.commands.find((command) => command.type === "companion.diagnostics");
const announcementDeclaration = contract.commands.find((command) => command.type === "announcement.send");
const announcementAvailable = canQueue && data.status?.profiles.some((candidate) => candidate.available && candidate.commandTypes.includes("announcement.send")) === true;
const diagnosticsAvailable = canQueue && data.status?.profiles.some((candidate) => candidate.available && candidate.commandTypes.includes("companion.diagnostics")) === true;
const latestSnapshotAt = data.snapshots.reduce((latest, snapshot) => snapshot.observedAt > latest ? snapshot.observedAt : latest, "");
async function queueCommand(commandType: string, payload: GameClientBridgeJsonObject) {
const declaration = contract.commands.find((command) => command.type === commandType);
const commandProfile = data.status?.profiles.find((candidate) => candidate.available && candidate.commandTypes.includes(commandType));
if (!declaration || !commandProfile) {
setActionResult({ status: "failed", label: "命令未在当前可用 Bridge profile 中声明。" });
return;
}
setPendingCommand(commandType);
setActionResult({ status: "pending", label: `正在提交 ${declaration.title}` });
try {
const expirySeconds = Math.max(300, Math.min(3600, declaration.timeoutSeconds * 2));
const queued = await platformApiClient.queueGameClientBridgeCommand(contract.serverInstanceId, {
profileKey: commandProfile.profileKey,
commandType,
payload,
idempotencyKey: `web-scum-${commandType.replaceAll(".", "-")}-${Date.now()}`,
priority: 50,
expiresAt: new Date(Date.now() + expirySeconds * 1000).toISOString()
});
setState((current) => current.status === "ready" ? {
status: "ready",
data: { ...current.data, commands: [queued, ...current.data.commands.filter((command) => command.id !== queued.id)] }
} : current);
setAnnouncement("");
setActionResult({ status: "succeeded", label: `命令已进入队列,审批状态:${approvalLabel(queued.approvalState)}` });
} catch (error) {
setActionResult({ status: "failed", label: error instanceof Error ? error.message : "命令提交失败" });
} finally {
setPendingCommand(null);
}
}
function submitAnnouncement(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const message = announcement.trim();
if (!message || message.length > 500) {
setActionResult({ status: "failed", label: "公告内容必须为 1500 个字符。" });
return;
}
void queueCommand("announcement.send", { message });
}
return (
<div className="console-page" aria-label="SCUM operations">
<div className="panel-header">
<div>
<strong>Game Client Bridge </strong>
<p className="provider-id"> Platform DTO game.scum Run socket</p>
</div>
<button type="button" className="icon-command" onClick={() => void refresh()}>
<RefreshCw size={14} aria-hidden="true" />
<span></span>
</button>
</div>
{data.errors.length > 0 && <ErrorState title="部分运维投影不可用" reason={data.errors.join("")} compact />}
<div className="console-grid" aria-label="SCUM operations summary">
<Metric label="Bridge" value={bridgeAvailable ? "可用" : "不可用"} detail={bridgeReason || profile?.profileKey || "未声明 profile"} tone={bridgeAvailable ? "success" : "warning"} />
<Metric label="Companion" value={healthLabel(snapshots.health?.status)} detail={snapshots.health?.observedAt ? `观测于 ${formatTime(snapshots.health.observedAt)}` : "暂无健康快照"} tone={snapshots.health?.status === "online" ? "success" : "warning"} />
<Metric label="在线会话" value={String(snapshots.sessions.total)} detail={snapshots.sessions.observedAt ? `快照 ${formatTime(snapshots.sessions.observedAt)}` : "暂无会话快照"} tone="neutral" />
<Metric label="最近快照" value={latestSnapshotAt ? formatTime(latestSnapshotAt) : "--"} detail={`${data.snapshots.length} 条安全投影`} tone="neutral" />
</div>
<section className="console-panel" aria-label="SCUM command queue">
<div className="panel-header">
<h2><BellRing size={16} aria-hidden="true" /> </h2>
<span className="page-status">{canQueue ? "Bridge 可提交" : bridgeReason || "Bridge 不可提交"}</span>
</div>
<div className="action-list">
{announcementDeclaration && (
<form className="provider-form" onSubmit={submitAnnouncement} aria-label="发送 SCUM 公告">
<div className="form-grid">
<label>
<input value={announcement} maxLength={500} onChange={(event) => setAnnouncement(event.target.value)} placeholder="输入 1500 字公告" disabled={!announcementAvailable || pendingCommand !== null} />
</label>
</div>
<button type="submit" className="icon-command" disabled={!announcementAvailable || pendingCommand !== null || !announcement.trim()}>
<BellRing size={14} aria-hidden="true" />
<span>{pendingCommand === "announcement.send" ? "提交中" : `提交公告 · ${approvalLevelLabel(announcementDeclaration.approvalLevel)}`}</span>
</button>
</form>
)}
{diagnostics && (
<div className="plugin-control-row">
<span><strong>{diagnostics.title}</strong><p> 10 </p></span>
<button type="button" className="icon-command" disabled={!diagnosticsAvailable || pendingCommand !== null} onClick={() => void queueCommand("companion.diagnostics", { includeWindowState: true, maxEntries: 10 })}>
<Activity size={14} aria-hidden="true" />
<span>{pendingCommand === "companion.diagnostics" ? "提交中" : "采集诊断"}</span>
</button>
</div>
)}
{actionResult && <ResultBadge status={actionResult.status} label={safeDiagnosticText(actionResult.label) ?? actionResult.label} />}
</div>
<CommandTable commands={data.commands} />
</section>
<section className="console-panel" aria-label="SCUM snapshot projections">
<div className="panel-header">
<h2><UsersRound size={16} aria-hidden="true" /> </h2>
<div className="action-strip" role="group" aria-label="SCUM snapshot sections">
{snapshotTabs(snapshots).map((tab) => (
<button key={tab.key} type="button" className={cx("icon-command")} aria-pressed={snapshotSection === tab.key} onClick={() => setSnapshotSection(tab.key)}>
<span>{tab.label} {tab.count}</span>
</button>
))}
</div>
</div>
<SnapshotTable section={snapshotSection} snapshots={snapshots} />
</section>
<section className="console-panel" aria-label="SCUM semantic logs">
<div className="panel-header">
<h2><Database size={16} aria-hidden="true" /> </h2>
<span className="page-status">{contract.logEvents.length} </span>
</div>
<div className="action-list">
<span>{contract.logEvents.map((event) => event.eventType).join(" / ") || "插件未声明语义日志事件"}</span>
<span>线</span>
</div>
<SemanticLogTable logs={data.logs} />
</section>
<section className="console-panel" aria-label="SCUM maintenance policy">
<div className="panel-header">
<h2><Wrench size={16} aria-hidden="true" /> </h2>
<span className="page-status"></span>
</div>
<div className="action-list">
<span><strong></strong> {contract.productionLifecycle.dependencyPolicy === "required" ? "必须满足依赖后执行" : "依赖为可选项"}</span>
<span><strong></strong> {contract.productionLifecycle.approvalRequired.join(" / ") || "无额外声明"}</span>
{contract.commands.filter((command) => command.permission === "server.game-client.maintenance").map((command) => (
<span key={command.type}><ShieldCheck size={14} aria-hidden="true" /> {command.title} · {approvalLevelLabel(command.approvalLevel)}</span>
))}
<span>Platform </span>
</div>
<BackupTable backups={data.backups} />
</section>
</div>
);
}
async function loadSemanticLogs(contract: ScumOperationsPageContract): Promise<ScumSemanticLogView[]> {
const streamsResponse = await platformApiClient.listLogStreams();
const sourceByKey = new Map(contract.logSources.map((source) => [source.key, source]));
const eventTypesByStream = new Map<string, string[]>();
for (const event of contract.logEvents) {
const streamKey = sourceByKey.get(event.sourceKey)?.streamKey;
if (!streamKey) continue;
eventTypesByStream.set(streamKey, [...(eventTypesByStream.get(streamKey) ?? []), event.eventType]);
}
const streams = streamsResponse.items.filter((stream) => stream.serverInstanceId === contract.serverInstanceId && eventTypesByStream.has(stream.streamKey)).slice(0, 12);
const results = await Promise.allSettled(streams.map(async (stream) => ({
stream,
response: await platformApiClient.queryLogStream({ logStreamId: stream.id, afterSeq: Math.max(0, stream.latestSeq - 50), limit: 50 })
})));
return results.flatMap((result) => {
if (result.status === "rejected") return [];
return result.value.response.entries.map((entry) => ({
streamKey: result.value.stream.streamKey,
eventType: declaredEventType(entry, result.value.stream, eventTypesByStream),
entry
}));
}).sort((left, right) => right.entry.timestamp.localeCompare(left.entry.timestamp)).slice(0, 100);
}
function declaredEventType(entry: LogEntryBody, stream: LogStreamResponse, eventTypesByStream: Map<string, string[]>): string {
const declared = eventTypesByStream.get(stream.streamKey) ?? [];
const projected = entry.fields?.eventType;
return projected && declared.includes(projected) ? projected : declared.join(" / ") || stream.streamKey;
}
function settledValue<T>(result: PromiseSettledResult<T>): T | undefined {
return result.status === "fulfilled" ? result.value : undefined;
}
function loadErrorLabel(index: number, reason: unknown): string {
const labels = ["Bridge 状态", "命令队列", "快照", "语义日志", "备份记录"];
const detail = safeDiagnosticText(reason instanceof Error ? reason.message : String(reason), "加载失败");
return `${labels[index] ?? "运维数据"}${detail}`;
}
function Metric({ label, value, detail, tone }: { label: string; value: string; detail: string; tone: "neutral" | "success" | "warning" }) {
return (
<article className={cx("metric-card", `metric-tone-${tone}`)}>
<span className="metric-label">{label}</span>
<strong className="metric-value">{value}</strong>
<p className="metric-detail">{detail}</p>
</article>
);
}
function CommandTable({ commands }: { commands: GameClientBridgeCommandResponse[] }) {
if (commands.length === 0) return <p className="provider-id"></p>;
return (
<div className="resource-table-wrap">
<table className="resource-table">
<thead><tr><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{commands.slice(0, 50).map((command) => (
<tr key={command.id}>
<td><strong>{command.commandType}</strong><span className="provider-id">{command.id}</span></td>
<td>{commandStateLabel(command.state)}</td>
<td>{approvalLabel(command.approvalState)}</td>
<td>{safeDiagnosticText(command.result?.summary || command.resultSummary, "--") || "--"}</td>
<td>{formatTime(command.updatedAt)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function snapshotTabs(snapshots: ScumOperationsSnapshotView): Array<{ key: SnapshotSection; label: string; count: number }> {
return [
{ key: "players", label: "玩家", count: snapshots.players.total },
{ key: "sessions", label: "会话", count: snapshots.sessions.total },
{ key: "squads", label: "小队", count: snapshots.squads.total },
{ key: "vehicles", label: "载具", count: snapshots.vehicles.total },
{ key: "flags", label: "旗帜", count: snapshots.flags.total }
];
}
function SnapshotTable({ section, snapshots }: { section: SnapshotSection; snapshots: ScumOperationsSnapshotView }) {
const configuration: Record<SnapshotSection, { headers: string[]; rows: ReactNode[][]; total: number }> = {
players: { headers: ["玩家", "状态", "小队", "延迟", "最后出现"], total: snapshots.players.total, rows: snapshots.players.items.map((item) => [`${item.playerName} · ${item.playerId}`, item.status, item.squadId ?? "--", item.pingMs === undefined ? "--" : `${item.pingMs} ms`, formatTime(item.lastSeenAt)]) },
sessions: { headers: ["会话", "玩家", "开始时间"], total: snapshots.sessions.total, rows: snapshots.sessions.items.map((item) => [item.sessionId, item.playerName, formatTime(item.startedAt)]) },
squads: { headers: ["小队", "成员", "队长", "最后活跃"], total: snapshots.squads.total, rows: snapshots.squads.items.map((item) => [`${item.name} · ${item.squadId}`, item.memberCount, item.leaderPlayerId ?? "--", formatTime(item.lastActiveAt)]) },
vehicles: { headers: ["载具", "状态", "所有者 / 小队", "燃油 / 耐久", "最后出现"], total: snapshots.vehicles.total, rows: snapshots.vehicles.items.map((item) => [`${item.vehicleType} · ${item.vehicleId}`, item.status, `${item.ownerPlayerId ?? "--"} / ${item.squadId ?? "--"}`, `${percent(item.fuelPercent)} / ${percent(item.healthPercent)}`, formatTime(item.lastSeenAt)]) },
flags: { headers: ["旗帜", "状态", "所有者 / 小队", "半径", "更新时间"], total: snapshots.flags.total, rows: snapshots.flags.items.map((item) => [item.flagId, item.status, `${item.ownerPlayerId ?? "--"} / ${item.squadId ?? "--"}`, item.radiusMeters === undefined ? "--" : `${item.radiusMeters} m`, formatTime(item.lastUpdatedAt)]) }
};
const table = configuration[section];
if (table.total === 0) return <p className="provider-id"></p>;
return (
<div className="resource-table-wrap">
<table className="resource-table">
<thead><tr>{table.headers.map((header) => <th key={header}>{header}</th>)}</tr></thead>
<tbody>{table.rows.map((row, rowIndex) => <tr key={`${section}-${rowIndex}`}>{row.map((cell, index) => <td key={`${index}-${String(cell)}`}>{cell}</td>)}</tr>)}</tbody>
</table>
{table.total > table.rows.length && <p className="provider-id"> {table.rows.length} / {table.total} </p>}
</div>
);
}
function SemanticLogTable({ logs }: { logs: ScumSemanticLogView[] }) {
if (logs.length === 0) return <p className="provider-id"></p>;
return (
<div className="resource-table-wrap">
<table className="resource-table">
<thead><tr><th></th><th></th><th></th><th></th></tr></thead>
<tbody>{logs.slice(0, 100).map((log) => (
<tr key={`${log.streamKey}-${log.entry.seq}`}>
<td>{log.eventType}</td>
<td>{formatTime(log.entry.timestamp)}</td>
<td>{log.entry.level || "info"}{log.entry.redacted ? " · 已脱敏" : ""}</td>
<td>{safeDiagnosticText(log.entry.line, "日志内容已隐藏")}</td>
</tr>
))}</tbody>
</table>
</div>
);
}
function BackupTable({ backups }: { backups: BackupResponse[] }) {
if (backups.length === 0) return <p className="provider-id"> Platform </p>;
return (
<div className="resource-table-wrap">
<table className="resource-table">
<thead><tr><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>{backups.slice(0, 20).map((backup) => (
<tr key={backup.id}><td>{backup.id}</td><td>{backup.state}</td><td>{formatBytes(backup.sizeBytes)}</td><td>{backup.recoveryStatus || "--"}</td><td>{formatTime(backup.retentionUntil)}</td></tr>
))}</tbody>
</table>
</div>
);
}
function emptySnapshotView(): ScumOperationsSnapshotView {
const empty = { total: 0, items: [] };
return { sessions: { ...empty }, players: { ...empty }, squads: { ...empty }, vehicles: { ...empty }, flags: { ...empty } };
}
function approvalLevelLabel(level: string): string {
if (level === "platform-admin") return "需平台管理员审批";
if (level === "operator") return "需操作员审批";
return "无需额外审批";
}
function approvalLabel(state: string): string {
if (state === "not_required") return "无需审批";
if (state === "approved") return "已批准";
if (state === "rejected") return "已拒绝";
return "待审批";
}
function commandStateLabel(state: string): string {
const labels: Record<string, string> = { pending: "等待领取", claimed: "执行中", succeeded: "成功", failed: "失败", cancelled: "已取消", expired: "已过期" };
return labels[state] ?? state;
}
function healthLabel(status: string | undefined): string {
if (status === "online") return "在线";
if (status === "degraded") return "降级";
if (status === "offline") return "离线";
return "未知";
}
function formatTime(value: string | undefined): string {
if (!value) return "--";
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? "--" : parsed.toLocaleString("zh-CN", { hour12: false });
}
function percent(value: number | undefined): string {
return value === undefined ? "--" : `${Math.round(value)}%`;
}
function formatBytes(value: number): string {
if (!Number.isFinite(value) || value <= 0) return "0 B";
if (value < 1024) return `${value} B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KiB`;
return `${(value / 1024 / 1024).toFixed(1)} MiB`;
}