feat: add controlled scum player state patches
This commit is contained in:
@@ -1,20 +1,32 @@
|
||||
import { RefreshCw, ShieldAlert, UsersRound } from "lucide-react";
|
||||
import { CheckCircle2, RefreshCw, ShieldAlert, SlidersHorizontal, UsersRound } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type { GamePlayerProfileResponse, GamePlayerResponse } from "../api/types";
|
||||
import type { GamePlayerProfileResponse, GamePlayerResponse, GamePlayerStatePatchResponse, GamePlayerStateResponse } from "../api/types";
|
||||
import { ErrorState, LoadingState } from "./StateViews";
|
||||
|
||||
type State = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; players: GamePlayerResponse[]; selected?: GamePlayerProfileResponse };
|
||||
type Selected = { profile: GamePlayerProfileResponse; state?: GamePlayerStateResponse; patches: GamePlayerStatePatchResponse[]; reason: string; after: Record<string, string>; note?: string };
|
||||
type State = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; players: GamePlayerResponse[]; selected?: Selected };
|
||||
|
||||
export function GamePlayerIntelligencePanel({ serverInstanceId }: { serverInstanceId: string }) {
|
||||
const [state, setState] = useState<State>({ status: "loading" });
|
||||
const load = useCallback(async () => { setState({ status: "loading" }); try { const result = await platformApiClient.listGamePlayers(serverInstanceId); setState({ status: "ready", players: result.items }); } catch (error) { setState({ status: "error", reason: error instanceof Error ? error.message : "玩家档案读取失败。" }); } }, [serverInstanceId]);
|
||||
const load = useCallback(async () => { setState({ status: "loading" }); try { const result = await platformApiClient.listGamePlayers(serverInstanceId); setState({ status: "ready", players: result.items }); } catch (error) { setState({ status: "error", reason: message(error, "玩家档案读取失败。") }); } }, [serverInstanceId]);
|
||||
useEffect(() => { void load(); }, [load]);
|
||||
async function select(player: GamePlayerResponse) { try { const selected = await platformApiClient.getGamePlayerProfile(serverInstanceId, player.id); setState((current) => current.status === "ready" ? { ...current, selected } : current); } catch (error) { setState({ status: "error", reason: error instanceof Error ? error.message : "玩家档案读取失败。" }); } }
|
||||
async function select(player: GamePlayerResponse) { try { const [profile, stateSnapshot, patchList] = await Promise.all([platformApiClient.getGamePlayerProfile(serverInstanceId, player.id), platformApiClient.getGamePlayerState(serverInstanceId, player.id), platformApiClient.listGamePlayerStatePatches(serverInstanceId, player.id)]); const after = Object.fromEntries(stateSnapshot.fields.map((field) => [field.key, String(field.value)])); setState((current) => current.status === "ready" ? { ...current, selected: { profile, state: stateSnapshot, patches: patchList.items, reason: "", after } } : current); } catch (error) { setState((current) => current.status === "ready" ? { ...current, selected: { profile: { player, aliases: [], sessions: [], accessAttempts: [], securitySignals: [] }, patches: [], reason: "", after: {}, note: message(error, "玩家状态快照不可用,已禁用修改。") } } : { status: "error", reason: message(error, "玩家档案读取失败。") }); } }
|
||||
async function submitPatch() { if (state.status !== "ready") return; const selected = state.selected; if (!selected || !selected.state) return; const snapshot = selected.state; const changes = snapshot.fields.filter((field) => selected.after[field.key] !== String(field.value)).map((field) => ({ fieldKey: field.key, before: field.value, after: Number(selected.after[field.key]) })); try { const patch = await platformApiClient.requestGamePlayerStatePatch(serverInstanceId, selected.profile.player.id, { gameVersion: snapshot.gameVersion, expectedStateVersion: snapshot.stateVersion, safetyWindow: snapshot.safetyWindow || "", reason: selected.reason, changes }); setState((current) => current.status === "ready" && current.selected ? { ...current, selected: { ...current.selected, patches: [patch, ...current.selected.patches], note: "修改申请已保存,等待平台管理员审批。" } } : current); } catch (error) { setState((current) => current.status === "ready" && current.selected ? { ...current, selected: { ...current.selected, note: message(error, "修改申请未被接受。") } } : current); } }
|
||||
async function approvePatch(patch: GamePlayerStatePatchResponse) { if (state.status !== "ready" || !state.selected) return; try { const approved = await platformApiClient.approveGamePlayerStatePatch(serverInstanceId, state.selected.profile.player.id, patch.id); setState((current) => current.status === "ready" && current.selected ? { ...current, selected: { ...current.selected, patches: current.selected.patches.map((item) => item.id === approved.id ? approved : item), note: "已批准并通过受控游戏通道排队执行。" } } : current); } catch (error) { setState((current) => current.status === "ready" && current.selected ? { ...current, selected: { ...current.selected, note: message(error, "审批失败。") } } : current); } }
|
||||
if (state.status === "loading") return <LoadingState label="正在加载本地游戏玩家档案…" />;
|
||||
if (state.status === "error") return <ErrorState title="玩家档案不可用" reason={state.reason} onRetry={() => void load()} />;
|
||||
const profile = state.selected;
|
||||
return <section className="console-panel" aria-label="SCUM 本地玩家档案与风险信号"><div className="panel-header"><div><h2><UsersRound size={16} /> 本地玩家档案与登录轨迹</h2><p className="provider-id">游戏身份独立于平台账号;网络数据仅用于服务器内不可逆关联,绝不展示或持久化原始值。</p></div><button type="button" className="icon-command" onClick={() => void load()}><RefreshCw size={14} /><span>刷新</span></button></div><div className="console-record-list">{state.players.length ? state.players.map((player) => <button type="button" className="console-record" key={player.id} onClick={() => void select(player)}><strong>{player.displayName}</strong><span>游戏 ID:{player.gamePlayerId}</span><small>最近登录:{formatTime(player.lastSeenAt)}</small></button>) : <p className="page-status">暂无已投影的成功登录记录。</p>}</div>{profile && <div className="console-module"><div className="panel-header"><h3>{profile.player.displayName} 的审阅记录</h3><span className="page-status">别名 {profile.aliases.length} · 会话 {profile.sessions.length}</span></div><div className="console-row-list"><div className="console-row"><strong>别名</strong><span>{profile.aliases.map((alias) => alias.alias).join(" / ") || "无"}</span></div>{profile.sessions.slice(0, 10).map((session) => <div className="console-row" key={session.id}><strong>会话</strong><span>{formatTime(session.startedAt)} → {session.endedAt ? formatTime(session.endedAt) : "仍在线"} {session.endReason ? `(${session.endReason})` : ""}</span></div>)}</div><div className="console-record-list">{profile.securitySignals.length ? profile.securitySignals.map((signal) => <div className="console-record" key={signal.id}><ShieldAlert size={15} aria-hidden="true" /><strong>{signal.ruleKey}</strong><span>{signal.status === "review-required" ? "需要人工审核" : signal.status}</span><small>{signal.summary} · 证据 {signal.evidenceCount} 条</small></div>) : <p className="page-status">未发现需要人工审核的风险信号。</p>}</div>{profile.accessAttempts.length > 0 && <p className="page-status">失败尝试:{profile.accessAttempts.length} 条(仅显示结果与时间,不显示网络标识)。</p>}</div>}</section>;
|
||||
const selected = state.selected;
|
||||
return <section className="console-panel" aria-label="SCUM 本地玩家档案、风险信号与受控属性修改"><div className="panel-header"><div><h2><UsersRound size={16} /> 本地玩家档案与登录轨迹</h2><p className="provider-id">游戏身份独立于平台账号;网络数据仅用于服务器内不可逆关联,绝不展示或持久化原始值。</p></div><button type="button" className="icon-command" onClick={() => void load()}><RefreshCw size={14} /><span>刷新</span></button></div><div className="console-record-list">{state.players.length ? state.players.map((player) => <button type="button" className="console-record" key={player.id} onClick={() => void select(player)}><strong>{player.displayName}</strong><span>游戏 ID:{player.gamePlayerId}</span><small>最近登录:{formatTime(player.lastSeenAt)}</small></button>) : <p className="page-status">暂无已投影的成功登录记录。</p>}</div>{selected && <PlayerDetail selected={selected} onReason={(reason) => setSelected(setState, reason)} onAfter={(key, value) => setAfter(setState, key, value)} onSubmit={() => void submitPatch()} onApprove={(patch) => void approvePatch(patch)} />}</section>;
|
||||
}
|
||||
|
||||
function PlayerDetail({ selected, onReason, onAfter, onSubmit, onApprove }: { selected: Selected; onReason: (value: string) => void; onAfter: (key: string, value: string) => void; onSubmit: () => void; onApprove: (patch: GamePlayerStatePatchResponse) => void }) {
|
||||
const writable = Boolean(selected.state?.supported && selected.state.maintenanceVerified && !selected.state.playerOnline && selected.state.safetyWindow);
|
||||
return <div className="console-module"><div className="panel-header"><h3>{selected.profile.player.displayName} 的审阅记录</h3><span className="page-status">别名 {selected.profile.aliases.length} · 会话 {selected.profile.sessions.length}</span></div><div className="console-row-list"><div className="console-row"><strong>别名</strong><span>{selected.profile.aliases.map((alias) => alias.alias).join(" / ") || "无"}</span></div>{selected.profile.sessions.slice(0, 10).map((session) => <div className="console-row" key={session.id}><strong>会话</strong><span>{formatTime(session.startedAt)} → {session.endedAt ? formatTime(session.endedAt) : "仍在线"} {session.endReason ? `(${session.endReason})` : ""}</span></div>)}</div><div className="console-record-list">{selected.profile.securitySignals.length ? selected.profile.securitySignals.map((signal) => <div className="console-record" key={signal.id}><ShieldAlert size={15} aria-hidden="true" /><strong>{signal.ruleKey}</strong><span>{signal.status === "review-required" ? "需要人工审核" : signal.status}</span><small>{signal.summary} · 证据 {signal.evidenceCount} 条</small></div>) : <p className="page-status">未发现需要人工审核的风险信号。</p>}</div>{selected.profile.accessAttempts.length > 0 && <p className="page-status">失败尝试:{selected.profile.accessAttempts.length} 条(仅显示结果与时间,不显示网络标识)。</p>}<div className="console-module"><div className="panel-header"><h3><SlidersHorizontal size={15} /> 受控技能与角色属性修改</h3><span className="page-status">{writable ? "已验证维护/离线安全窗口" : "未验证版本或安全窗口,已禁用修改"}</span></div>{selected.state && <><p className="provider-id">版本:{selected.state.gameVersion} · 状态版本:{selected.state.stateVersion}。仅显示此服务器版本明确支持的字段。</p><div className="console-row-list">{selected.state.fields.map((field) => <label className="console-row" key={field.key}><strong>{field.label}</strong><span>当前值 {field.value},范围 {field.minimum}–{field.maximum}</span><input aria-label={`${field.label} 修改后值`} type="number" min={field.minimum} max={field.maximum} step="0.1" disabled={!writable} value={selected.after[field.key] ?? ""} onChange={(event) => onAfter(field.key, event.target.value)} /></label>)}</div><label>修改原因<textarea value={selected.reason} disabled={!writable} maxLength={240} onChange={(event) => onReason(event.target.value)} placeholder="说明需要修改的运营原因(4–240 字)" /></label><button type="button" className="command-button" disabled={!writable} onClick={onSubmit}>提交修改申请</button></>}{selected.note && <p className="page-status">{selected.note}</p>}<div className="console-record-list">{selected.patches.map((patch) => <div className="console-record" key={patch.id}><strong>{patchStatus(patch.status)}</strong><span>{patch.changes.map((change) => `${change.fieldKey}: ${change.before} → ${change.after}`).join(";")}</span><small>原因:{patch.reason} · 申请人:{patch.requesterId}{patch.approverId ? ` · 审批人:${patch.approverId}` : ""}{patch.executionSummary ? ` · 执行结果:${patch.executionSummary}` : ""}{patch.confirmedStateVersion ? ` · 确认状态版本:${patch.confirmedStateVersion}` : ""}</small>{patch.status === "pending-approval" && <button type="button" className="icon-command" onClick={() => onApprove(patch)}><CheckCircle2 size={14} /><span>平台管理员审批</span></button>}</div>)}</div></div></div>;
|
||||
}
|
||||
function setSelected(setState: React.Dispatch<React.SetStateAction<State>>, reason: string) { setState((current) => current.status === "ready" && current.selected ? { ...current, selected: { ...current.selected, reason } } : current); }
|
||||
function setAfter(setState: React.Dispatch<React.SetStateAction<State>>, key: string, value: string) { setState((current) => current.status === "ready" && current.selected ? { ...current, selected: { ...current.selected, after: { ...current.selected.after, [key]: value } } } : current); }
|
||||
function patchStatus(status: GamePlayerStatePatchResponse["status"]) { return ({ "pending-approval": "等待平台管理员审批", queued: "已批准,等待游戏侧执行", "execution-failed": "游戏侧执行失败", "execution-unknown": "游戏侧执行结果未知", "confirmation-failed": "写后读取确认失败", confirmed: "已确认生效" })[status]; }
|
||||
function message(error: unknown, fallback: string) { return error instanceof Error ? error.message : fallback; }
|
||||
function formatTime(value: string) { const date = new Date(value); return Number.isNaN(date.valueOf()) ? "--" : date.toLocaleString("zh-CN", { hour12: false }); }
|
||||
|
||||
Reference in New Issue
Block a user