@@ -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 [ ] ; selec ted ? : GamePlayerProfileResponse } ;
type Selected = { profile : GamePlayerProfileResponse ; state? : GamePlayerStateResponse ; patches : GamePlayerStatePatchResponse [ ] ; reason : string ; after : Record < string , string > ; no te? : 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 } ) ; }