@@ -1,28 +1,432 @@
import { configurationCatalog , stateFieldCatalog , vehicleSpawnCatalog } from "./schemas.js" ;
import type { SCUMFeatureWorkspace } from "./contracts.js" ;
import { configurationCatalog } from "./schemas.js" ;
import type { SCUMConfigField , SCUMFeatureWorkspace , SCUMLogicalDirectory , SCUMLogicalFile } from "./contracts.js" ;
export type ReactLike = { createElement : ( . . . args : any [ ] ) = > any ; useMemo ? : < T > ( factory : ( ) = > T , deps : readonly unknown [ ] ) = > T } ;
export type SCUMPageContext = { serverInstanceId? : string ; permissions : string [ ] ; availability : { available : boolean ; reason? : string } ; featureAvailability? : Array < { key : string ; available : boolean ; reason? : string } > ; workspace? : SCUMFeatureWorkspace } ;
type StateSetter < T > = ( next : T | ( ( previous : T ) = > T ) ) = > void ;
export type ReactLike = {
createElement : ( . . . args : any [ ] ) = > any ;
useEffect ? : ( effect : ( ) = > void | ( ( ) = > void ) , deps : readonly unknown [ ] ) = > void ;
useState ? : < T > ( initialState : T | ( ( ) = > T ) ) = > [ T , StateSetter < T > ] ;
} ;
export type SCUMFileReadSnapshot = {
serverInstanceId : string ;
pluginId : string ;
key : string ;
state : "ready" | "pending" | "not-read" | "unavailable" | string ;
content? : string ;
version? : number ;
checksum? : string ;
sizeBytes? : number ;
jobId? : string ;
readAt? : string ;
reason? : string ;
} ;
export type SCUMPageContext = {
serverInstanceId? : string ;
permissions : string [ ] ;
availability : { available : boolean ; reason? : string } ;
featureAvailability? : Array < { key : string ; available : boolean ; reason? : string } > ;
workspace? : SCUMFeatureWorkspace ;
workspaceActions ? : {
requestFile ? : ( fileKey : string ) = > Promise < { status : string ; message : string ; jobId? : string } > ;
getFileSnapshot ? : ( fileKey : string ) = > Promise < SCUMFileReadSnapshot > ;
writeFile ? : ( fileKey : string , content : string , options ? : { expectedChecksum? : string } ) = > Promise < { status : string ; message : string ; jobId? : string } > ;
} ;
} ;
type NormalizedWorkspace = { defaultDirectoryKey : string ; directories : readonly SCUMLogicalDirectory [ ] ; files : readonly SCUMLogicalFile [ ] ; configFields : readonly SCUMConfigField [ ] } ;
type ConfigMode = "fields" | "source" ;
type RawEncoding = "utf-8" | "utf-16le" ;
type FileRequestState = { fileKey : string ; status : string ; message : string ; jobId? : string } | null ;
type PreviewState = { fileKey : string ; mode : ConfigMode ; summary : string ; proposedContent : string ; lines : readonly DiffLine [ ] } | null ;
type DiffLine = { kind : "same" | "added" | "removed" ; text : string } ;
const fallbackDirectories : readonly SCUMLogicalDirectory [ ] = [
{ key : "scum-config" , label : "服务器配置" , scope : "config" } ,
{ key : "scum-logs" , label : "日志文件" , scope : "logs" }
] ;
const fallbackFiles : readonly SCUMLogicalFile [ ] = [
{ key : "scum-server-settings" , directoryKey : "scum-config" , label : "ServerSettings.ini" , kind : "config" , editable : true } ,
{ key : "scum-game-config" , directoryKey : "scum-config" , label : "Game.ini" , kind : "config" } ,
{ key : "scum-engine-config" , directoryKey : "scum-config" , label : "Engine.ini" , kind : "config" } ,
{ key : "scum-game-user-settings" , directoryKey : "scum-config" , label : "GameUserSettings.ini" , kind : "config" } ,
{ key : "scum-admin-log" , directoryKey : "scum-logs" , label : "Admin.log" , kind : "log" , streamKey : "scum.admin" } ,
{ key : "scum-chat-log" , directoryKey : "scum-logs" , label : "Chat.log" , kind : "log" , streamKey : "scum.chat" } ,
{ key : "scum-kill-log" , directoryKey : "scum-logs" , label : "Kill.log" , kind : "log" , streamKey : "scum.kill" } ,
{ key : "scum-login-log" , directoryKey : "scum-logs" , label : "Login.log" , kind : "log" , streamKey : "scum.login" } ,
{ key : "scum-server-log" , directoryKey : "scum-logs" , label : "Server.log" , kind : "log" , streamKey : "scum.server" }
] ;
const directoryRelativePaths : Record < string , string > = { "scum-config" : "/SCUM/Saved/Config/WindowsServer" , "scum-logs" : "/SCUM/Saved/SaveFiles/Logs" } ;
export function renderSCUMFeaturePage ( react : ReactLike , input : SCUMPageContext ) {
const e = react . createElement ; const fields = input . workspace ? . configFields ? . length ? input.workspace.configFields : configurationCatalog ; const vehicleCodes = vehicleSpawnCatalog ; const scoped = Boolean ( input . serverInstanceId ) ; const canRead = scoped && input . permissions . includes ( "server.game-client.read" ) ; const canCommand = scoped && input . permissions . includes ( "server.game-client.command" ) ; const canMaintain = scoped && input . permissions . includes ( "server.game-client.maintenance" ) ;
return e ( "div" , { className : "console-page" , "aria-label" : "SCUM 插件功能页面" } ,
e ( "section" , { className : "console-panel" } , e ( "div" , { className : "panel-header" } , e ( "div" , null , e ( "h2" , null , "SCUM 插件运维" ) , e ( "p" , { className : "provider-id" } , "SCUM 语义、界面和适配器由插件提供;平台仅提供已授权的服务器隔离宿主。" ) ) , e ( "span" , { className : "page-status" } , availabilityText ( input . availability , scoped ) ) ) ,
e ( "div" , { className : "console-row-list" } , e ( "div" , { className : "console-row" } , e ( "strong" , null , "绑定服务器" ) , e ( "span" , null , input . serverInstanceId ? ? "未绑定" ) ) , e ( "div" , { className : "console-row" } , e ( "strong" , null , "运行时 schema" ) , e ( "span" , null , "按受限通道探测" ) ) , e ( "div" , { className : "console-row" } , e ( "strong" , null , "宿主权限" ) , e ( "span" , null , input . permissions . join ( "、" ) || "无" ) ) ) ) ,
configurationPanel ( e , fields , canRead , canMaintain , featureAvailability ( input , "config.manage" ) ) ,
playerPanel ( e , canRead , featureAvailability ( input , "player.intelligence" ) ) ,
rewardPanel ( e , canRead , canCommand , featureAvailability ( input , "reward.delivery" ) ) ,
statePanel ( e , canRead , canMaintain , featureAvailability ( input , "state.patch" ) ) ,
vehicleSpawnPanel ( e , vehicleCodes , canCommand , featureAvailability ( input , "vehicle.spawn" ) ) ,
trajectoryPanel ( e , canRead , featureAvailability ( input , "trajectory.collect " ) )
const e = react . createElement ;
const workspace = normalizeWorkspace ( input . workspace ) ;
const [ selectedDirectoryKey , setSelectedDirectoryKey ] = usePluginState ( react , workspace . defaultDirectoryKey ) ;
const effectiveDirectoryKey = workspace . directories . some ( ( directory ) = > directory . key === selectedDirectoryKey ) ? selectedDirectoryKey : workspace.defaultDirectoryKey ;
const directoryFiles = workspace . files . filter ( ( file ) = > file . directoryKey === effectiveDirectoryKey ) ;
const defaultFileKey = initialFileKey ( workspace , effectiveDirectoryKey ) ;
const [ selectedFileKey , setSelectedFileKey ] = usePluginState ( react , defaultFileKey ) ;
const selectedFile = directoryFiles . find ( ( file ) = > file . key === selectedFileKey ) ? ? directoryFiles [ 0 ] ? ? workspace . files [ 0 ] ;
const selectedFields = selectedFile ? workspace . configFields . filter ( ( field ) = > field . fileKey === selectedFile . key ) : [ ] ;
const [ configMode , setConfigMode ] = usePluginState < ConfigMode > ( react , "fields " ) ;
const [ fieldDraft , setFieldDraft ] = usePluginState < Record < string , string > > ( react , { } ) ;
const [ rawDraft , setRawDraft ] = usePluginState < Record < string , string > > ( react , { } ) ;
const [ rawEncoding , setRawEncoding ] = usePluginState < RawEncoding > ( react , "utf-8" ) ;
const [ snapshot , setSnapshot ] = usePluginState < SCUMFileReadSnapshot | null > ( react , null ) ;
const [ requestState , setRequestState ] = usePluginState < FileRequestState > ( react , null ) ;
const [ writeState , setWriteState ] = usePluginState < FileRequestState > ( react , null ) ;
const [ preview , setPreview ] = usePluginState < PreviewState > ( react , null ) ;
const scoped = Boolean ( input . serverInstanceId ) ;
const canFilesRead = scoped && input . permissions . includes ( "server.files.read" ) ;
const canFilesWrite = scoped && input . permissions . includes ( "server.files.write" ) ;
if ( react . useEffect ) {
react . useEffect ( ( ) = > {
let active = true ;
setPreview ( null ) ;
setWriteState ( null ) ;
if ( ! selectedFile || ! input . workspaceActions ? . getFileSnapshot ) {
setSnapshot ( null ) ;
return ( ) = > { active = false ; } ;
}
void input . workspaceActions . getFileSnapshot ( selectedFile . key ) . then ( ( next ) = > {
if ( active ) setSnapshot ( next ) ;
} ) . catch ( ( error ) = > {
if ( active ) setSnapshot ( { serverInstanceId : input.serverInstanceId ? ? "" , pluginId : "game.scum" , key : selectedFile.key , state : "unavailable" , reason : error instanceof Error ? error . message : "无法读取文件快照。" } ) ;
} ) ;
return ( ) = > { active = false ; } ;
} , [ input . serverInstanceId , input . workspaceActions ? . getFileSnapshot , selectedFile ? . key ] ) ;
}
function selectDirectory ( directoryKey : string ) {
const nextFiles = workspace . files . filter ( ( file ) = > file . directoryKey === directoryKey ) ;
const nextFile = nextFiles . find ( ( file ) = > file . kind === "config" ) ? ? nextFiles [ 0 ] ;
setSelectedDirectoryKey ( directoryKey ) ;
if ( nextFile ) selectFile ( nextFile , directoryKey ) ;
}
function selectFile ( file : SCUMLogicalFile , directoryKey = file . directoryKey ) {
setSelectedDirectoryKey ( directoryKey ) ;
setSelectedFileKey ( file . key ) ;
setConfigMode ( file . kind === "config" && workspace . configFields . some ( ( field ) = > field . fileKey === file . key ) ? "fields" : "source" ) ;
setRequestState ( null ) ;
setWriteState ( null ) ;
setPreview ( null ) ;
setRawEncoding ( "utf-8" ) ;
}
function requestSelectedFile() {
if ( ! selectedFile || ! canFilesRead || ! input . workspaceActions ? . requestFile ) return ;
setRequestState ( { fileKey : selectedFile.key , status : "pending" , message : "正在提交文件读取请求..." } ) ;
setPreview ( null ) ;
void input . workspaceActions . requestFile ( selectedFile . key ) . then ( ( result ) = > {
setRequestState ( { fileKey : selectedFile.key , status : result.status , message : result.message , jobId : result.jobId } ) ;
if ( result . status === "queued" || result . status === "ok" ) {
setSnapshot ( { serverInstanceId : input.serverInstanceId ? ? "" , pluginId : "game.scum" , key : selectedFile.key , state : "pending" , jobId : result.jobId , reason : "等待运行端完成文件读取。" } ) ;
}
} ) . catch ( ( error ) = > setRequestState ( { fileKey : selectedFile.key , status : "error" , message : error instanceof Error ? error . message : "文件读取请求失败。" } ) ) ;
}
function refreshSelectedSnapshot() {
if ( ! selectedFile || ! input . workspaceActions ? . getFileSnapshot ) return ;
setPreview ( null ) ;
void input . workspaceActions . getFileSnapshot ( selectedFile . key ) . then ( setSnapshot ) . catch ( ( error ) = > {
setSnapshot ( { serverInstanceId : input.serverInstanceId ? ? "" , pluginId : "game.scum" , key : selectedFile.key , state : "unavailable" , reason : error instanceof Error ? error . message : "无法读取文件快照。" } ) ;
} ) ;
}
function previewModeChange ( mode : ConfigMode , content : string , current : string ) {
if ( ! selectedFile ) return ;
const diff = buildSimpleDiff ( current , content ) ;
setPreview ( { fileKey : selectedFile.key , mode , proposedContent : content , summary : diff.summary , lines : diff.lines } ) ;
}
function writePreviewedContent() {
if ( ! selectedFile || ! preview || preview . fileKey !== selectedFile . key || ! input . workspaceActions ? . writeFile ) return ;
setWriteState ( { fileKey : selectedFile.key , status : "pending" , message : "正在提交声明文件写入..." } ) ;
void input . workspaceActions . writeFile ( selectedFile . key , preview . proposedContent , { expectedChecksum : snapshot?.checksum } ) . then ( ( result ) = > {
setWriteState ( { fileKey : selectedFile.key , status : result.status , message : result.message , jobId : result.jobId } ) ;
setPreview ( null ) ;
} ) . catch ( ( error ) = > setWriteState ( { fileKey : selectedFile.key , status : "error" , message : error instanceof Error ? error . message : "文件写入请求失败。" } ) ) ;
}
return e ( "section" , { className : "console-panel" , "aria-label" : "SCUM 文件管理" } ,
e ( "div" , { className : "panel-header" } ,
e ( "div" , null , e ( "h2" , null , "文件管理" ) , e ( "p" , { className : "provider-id" } , "第一级选择目录,第二级选择目录内文件;配置默认表单,日志只读原文。" ) ) ,
e ( "span" , { className : "page-status" } , scoped ? "声明文件工作区" : "插件页面未绑定服务器" )
) ,
e ( "div" , { className : "file-workbench" } ,
navigationPane ( e , workspace , effectiveDirectoryKey , directoryFiles , selectedFile ? . key , selectDirectory , selectFile ) ,
selectedFile
? fileDetail ( e , {
file : selectedFile ,
fields : selectedFields ,
mode : selectedFile.kind === "log" ? "source" : configMode ,
setMode : setConfigMode ,
fieldDraft ,
setFieldDraft ,
rawDraft ,
setRawDraft ,
rawEncoding ,
setRawEncoding ,
snapshot : snapshot?.key === selectedFile . key ? snapshot : null ,
requestState : requestState?.fileKey === selectedFile . key ? requestState : null ,
writeState : writeState?.fileKey === selectedFile . key ? writeState : null ,
preview : preview?.fileKey === selectedFile . key ? preview : null ,
canFilesRead ,
canFilesWrite ,
canRequestFile : Boolean ( input . workspaceActions ? . requestFile ) ,
canRefreshSnapshot : Boolean ( input . workspaceActions ? . getFileSnapshot ) ,
canWriteFile : Boolean ( input . workspaceActions ? . writeFile ) ,
onRequestFile : requestSelectedFile ,
onRefreshSnapshot : refreshSelectedSnapshot ,
onPreview : previewModeChange ,
onWrite : writePreviewedContent
} )
: e ( "div" , { className : "file-workbench-detail" } , e ( "p" , { className : "page-status" } , "当前插件没有可展示的声明文件。" ) )
)
) ;
}
function configurationPanel ( e : ReactLike [ "createElement" ] , fields : readonly { key : string ; label : string ; description : string ; control : string ; restartImpact : string } [ ] , canRead : boolean , canMaintain : boolean , availability : { available : boolean ; reason? : string } ) { return e ( "section" , { className : "console-panel" , "aria-label" : "SCUM 配置工作台" } , e ( "div" , { className : "panel-header" } , e ( "div" , null , e ( "h2" , null , "运行时配置字段目录" ) , e ( "p" , { className : "provider-id" } , "每项修改先生成可审查差异,再由受控 Companion 执行。" ) ) , e ( "button" , { type : "button" , className : "icon-command" , disabled : ! canRead || ! availability . available } , "读取配置" ) ) , e ( "div" , { className : "console-record-list" } , fields . map ( ( field ) = > e ( "div" , { className : "console-record" , key : field.key } , e ( "strong" , null , field . label ) , e ( "span" , null , ` ${ field . description } · ${ field . control } ` ) , e ( "small" , null , field . restartImpact === "restart-required" ? "修改后需要受控重启" : "可在安全窗口内生效" ) ) ) ) , e ( "p" , { className : "page-status" } , canMaintain ? "配置写入仅在审批与处理器可用时开放。" : "当前服务器上下文没有配置维护权限。" ) ) ; }
function playerPanel ( e : ReactLike [ "createElement" ] , canRead : boolean , availability : { available : boolean ; reason? : string } ) { return e ( "section" , { className : "console-panel" , "aria-label" : "SCUM 玩家档案" } , e ( "div" , { className : "panel-header" } , e ( "div" , null , e ( "h2" , null , "玩家、登录与风险信号" ) , e ( "p" , { className : "provider-id" } , "只展示 Companion 已验证的语义事件;网络关联是按服务器不可逆计算,不上传原始网络值。" ) ) , e ( "button" , { type : "button" , className : "icon-command" , disabled : ! canRead || ! availability . available } , "查询玩家" ) ) , e ( "p" , { className : "page-status" } , ! canRead ? "当前服务器上下文没有玩家读取权限。" : availability . available ? "等待已验证的登录或登出事件。" : availability . reason ? ? "没有兼容的事件生产者。" ) ) ; }
function rewardPanel ( e : ReactLike [ "createElement" ] , canRead : boolean , canCommand : boolean , availability : { available : boolean ; reason? : string } ) { return e ( "section" , { className : "console-panel" , "aria-label" : "SCUM 礼物与通知" } , e ( "div" , { className : "panel-header" } , e ( "div" , null , e ( "h2" , null , "冻结礼物版本与通知" ) , e ( "p" , { className : "provider-id" } , "物品投递与通知分离;未知投递结果不会自动重试。" ) ) , e ( "button" , { type : "button" , className : "icon-command" , disabled : ! canCommand || ! availability . available } , "申请投递" ) ) , e ( "p" , { className : "page-status" } , ! canRead ? "当前服务器上下文没有礼物读取权限。" : ! canCommand ? "当前服务器上下文没有受控投递权限。" : availability . reason ? ? "需要已冻结 revision、已验证玩家身份和兼容处理器。" ) ) ; }
function statePanel ( e : ReactLike [ "createElement" ] , canRead : boolean , canMaintain : boolean , availability : { available : boolean ; reason? : string } ) { const fields = stateFieldCatalog ; return e ( "section " , { className : "console-panel " , "aria-label" : "SCUM 受控状态修改" } , e ( "div" , { className : "panel-header" } , e ( "div" , null , e ( "h2" , null , "受控属性修改" ) , e ( "p" , { className : "provider-id" } , "仅列出运行时探测且在字段白名单中的字段,执行时要求预读、安全窗口与读后确认。" ) ) , e ( "button" , { type : "button" , className : "icon-command" , disabled : ! canRead || ! canMaintain || ! availability . available } , "创建修改申请" ) ) , e ( "div" , { className : "console-row-list" } , fields . length ? fields . map ( ( field ) = > e ( "div" , { className : "console-row" , key : field.key } , e ( "strong" , null , field . label ) , e ( "span" , null , ` ${ field . minimum } – ${ field . maximum } ` ) ) ) : e ( "p" , { className : "page-status" } , "当前运行时没有已验证的状态字段。" ) ) , e ( "p" , { className : "page-status" } , canMaintain ? availability . reason ? ? "等待安全窗口验证。" : "当前服务器上下文没有维护权限。" ) ) ; }
function vehicleSpawnPanel ( e : ReactLike [ "createElement" ] , vehicles : readonly { code : string ; label : string } [ ] , canCommand : boolean , availability : { available : boolean ; reason? : string } ) { return e ( "section" , { className : "console-panel" , "aria-label" : "SCUM 受限载具生成" } , e ( "div" , { className : "panel-header" } , e ( "div" , null , e ( "h2" , null , "受限载具生成" ) , e ( "p" , { className : "provider-id" } , "仅可选择受控目录中的载具;不会显示或接收原始指令、参数或回包。" ) ) , e ( "button" , { type : "button" , className : "icon-command" , disabled : ! canCommand || ! availability . available } , "生成载具" ) ) , e ( "div" , { className : "console-row-list" } , vehicles . map ( ( vehicle ) = > e ( "div" , { className : "console-row" , key : vehicle.code } , e ( "strong" , null , vehicle . label ) , e ( "span" , null , vehicle . code ) ) ) ) , e ( "p" , { className : "page-status" } , ! canCommand ? "当前服务器上下文没有受控指令权限。" : availability . available ? "仅在审批和 Companion 处理器均可用时开放。" : availability . reason ? ? "当前没有已验证的载具生成处理器。" ) ) ; }
function trajectoryPanel ( e : ReactLike [ "createElement" ] , canRead : boolean , availability : { available : boolean ; reason? : string } ) { return e ( "section" , { className : "console-panel" , "aria-label" : "SCUM 地图轨迹" } , e ( "div" , { className : "panel-header" } , e ( "div" , null , e ( "h2" , null , "玩家与载具轨迹" ) , e ( "p" , { className : "provider-id" } , "仅接受插件声明的服务器侧位置与上下车事件源;绝不使用 OCR、截图或桌面自动化。" ) ) , e ( "button" , { type : "button" , className : "icon-command" , disabled : ! canRead || ! availability . available } , "读取轨迹" ) ) , e ( "p" , { className : "page-status" } , canRead ? availability . reason ? ? "当合法位置源可用时展示采样轨迹。" : "当前服务器上下文没有轨迹读取权限。" ) ) ; }
function featureAvailability ( input : SCUMPageContext , key : string ) : { available : boolean ; reason? : string } { const feature = input . featureAvailability ? . find ( ( item ) = > item . key === key ) ; return feature ? ? { available : false , reason : "当前服务器没有已验证的 Companion 处理器或事件生产者。 " } ; }
function availabilityText ( availability : { available : boolean ; reason? : string } , scoped : boolean ) : string { if ( ! scoped ) return "不可用:插件页面必须绑定服务器。 "; return availability . available ? "已声明且已由 Companion 验证" : ` 不可用: ${ availability . reason ? ? "没有可用的 Companion 处理器或事件生产者" } ` ; }
function navigationPane ( e : ReactLike [ "createElement" ] , workspace : NormalizedWorkspace , directoryKey : string , directoryFiles : readonly SCUMLogicalFile [ ] , selectedFileKey : string | undefined , onDirectoryChange : ( directoryKey : string ) = > void , onFileSelect : ( file : SCUMLogicalFile ) = > void ) {
const activeDirectory = workspace . directories . find ( ( directory ) = > directory . key === directoryKey ) ;
const selectedFile = directoryFiles . find ( ( file ) = > file . key === selectedFileKey ) ? ? directoryFiles [ 0 ] ;
return e ( "aside " , { className : "file-workbench-nav " , "aria-label" : "SCUM 文件两级菜单" } ,
e ( "label" , { className : "file-workbench-picker" } ,
e ( "span" , null , "目录" ) ,
e ( "select" , { value : directoryKey , onChange : ( event : { target : { value : string } } ) = > onDirectoryChange ( event . target . value ) , "aria-label" : "选择目录 " } ,
workspace . directories . map ( ( directory ) = > e ( "option ", { key : directory.key , value : directory.key } , directoryRelativePath ( directory . key ) ? ? directory . label ) )
)
) ,
e ( "label" , { className : "file-workbench-picker" } ,
e ( "span" , null , "文件" ) ,
e ( "select" , { value : selectedFile?.key ? ? "" , onChange : ( event : { target : { value : string } } ) = > { const file = directoryFiles . find ( ( candidate ) = > candidate . key === event . target . value ) ; if ( file ) onFileSelect ( file ) ; } , "aria-label" : "选择文件" } ,
directoryFiles . length ? directoryFiles . map ( ( file ) = > e ( "option" , { key : file.key , value : file.key } , file . label ) ) : e ( "option" , { value : "" } , "没有已声明文件" )
)
) ,
e ( "div" , { className : "file-workbench-directory-heading" } ,
e ( "strong" , null , activeDirectory ? . label ? ? "插件声明目录" ) ,
e ( "span" , null , directoryRelativePath ( directoryKey ) ? ? "插件逻辑目录" ) ,
e ( "small" , null , ` ${ directoryFiles . length } 个声明文件 ` )
)
) ;
}
function fileDetail ( e : ReactLike [ "createElement" ] , props : {
file : SCUMLogicalFile ; fields : readonly SCUMConfigField [ ] ; mode : ConfigMode ; setMode : StateSetter < ConfigMode > ; fieldDraft : Record < string , string > ; setFieldDraft : StateSetter < Record < string , string > > ;
rawDraft : Record < string , string > ; setRawDraft : StateSetter < Record < string , string > > ; rawEncoding : RawEncoding ; setRawEncoding : StateSetter < RawEncoding > ; snapshot : SCUMFileReadSnapshot | null ;
requestState : FileRequestState ; writeState : FileRequestState ; preview : PreviewState ; canFilesRead : boolean ; canFilesWrite : boolean ; canRequestFile : boolean ; canRefreshSnapshot : boolean ; canWriteFile : boolean ;
onRequestFile : ( ) = > void ; onRefreshSnapshot : ( ) = > void ; onPreview : ( mode : ConfigMode , content : string , current : string ) = > void ; onWrite : ( ) = > void ;
} ) {
const canShowFields = props . file . kind === "config" && props . fields . length > 0 ;
const currentContent = props . snapshot ? . state === "ready" ? decodeRawContent ( props . snapshot . content ? ? "" , props . rawEncoding ) : "" ;
const snapshotValues = props . snapshot ? . state === "ready" ? parseIniAssignments ( currentContent ) : { } ;
return e ( "article" , { className : "file-workbench-detail" , "aria-label" : ` 文件 ${ props . file . label } ` } ,
e ( "div" , { className : "panel-header" } ,
e ( "div" , null , e ( "h2" , null , props . file . label ) , e ( "p" , { className : "provider-id" } , ` ${ directoryRelativePath ( props . file . directoryKey ) ? ? "插件声明目录" } · ${ props . file . kind === "config" ? "配置文件" : "日志文件" } ` ) ) ,
e ( "div" , { className : "console-row-actions" } ,
e ( "button" , { type : "button" , className : "icon-command" , disabled : ! props . canFilesRead || ! props . canRequestFile , onClick : props.onRequestFile } , "读取文件" ) ,
e ( "button" , { type : "button" , className : "icon-command" , disabled : ! props . canRefreshSnapshot , onClick : props.onRefreshSnapshot } , "刷新结果" )
)
) ,
props . requestState ? e ( "p" , { className : "page-status" , "data-state" : props . requestState . status } , props . requestState . message ) : null ,
props . writeState ? e ( "p" , { className : "page-status" , "data-state" : props . writeState . status } , props . writeState . message ) : null ,
props . file . kind === "log" || props . snapshot ? . state === "ready" ? encodingSwitcher ( e , props . rawEncoding , props . setRawEncoding ) : null ,
canShowFields
? e ( "div" , { className : "file-workbench-mode" , role : "tablist" , "aria-label" : "表单配置与原文模式" } ,
e ( "span" , null , "编辑视图" ) ,
e ( "button" , { type : "button" , role : "tab" , "aria-selected" : props . mode === "fields" , className : props.mode === "fields" ? "file-workbench-mode-active" : undefined , onClick : ( ) = > props . setMode ( "fields" ) } , "配置表单" ) ,
e ( "button" , { type : "button" , role : "tab" , "aria-selected" : props . mode === "source" , className : props.mode === "source" ? "file-workbench-mode-active" : undefined , onClick : ( ) = > props . setMode ( "source" ) } , "原文模式" )
)
: null ,
canShowFields && props . mode === "fields"
? modeledConfigurationFields ( e , props . file , props . fields , props . fieldDraft , props . setFieldDraft , snapshotValues , currentContent , props . snapshot ? . state === "ready" , props . canFilesWrite , props . canWriteFile , props . preview , props . onPreview , props . onWrite )
: rawFileView ( e , props . file , currentContent , props . rawDraft , props . setRawDraft , props . snapshot , props . canFilesWrite , props . canWriteFile , props . preview , props . onPreview , props . onWrite )
) ;
}
function encodingSwitcher ( e : ReactLike [ "createElement" ] , encoding : RawEncoding , setEncoding : StateSetter < RawEncoding > ) {
return e ( "div" , { className : "file-workbench-mode file-workbench-encoding" , role : "tablist" , "aria-label" : "原文编码" } ,
e ( "span" , null , "文本编码" ) ,
e ( "button" , { type : "button" , role : "tab" , "aria-selected" : encoding === "utf-8" , className : encoding === "utf-8" ? "file-workbench-mode-active" : undefined , onClick : ( ) = > setEncoding ( "utf-8" ) } , "UTF-8" ) ,
e ( "button" , { type : "button" , role : "tab" , "aria-selected" : encoding === "utf-16le" , className : encoding === "utf-16le" ? "file-workbench-mode-active" : undefined , onClick : ( ) = > setEncoding ( "utf-16le" ) } , "UTF-16 LE" )
) ;
}
function modeledConfigurationFields ( e : ReactLike [ "createElement" ] , file : SCUMLogicalFile , fields : readonly SCUMConfigField [ ] , draft : Record < string , string > , setDraft : StateSetter < Record < string , string > > , values : Record < string , string > , currentContent : string , hasReadSnapshot : boolean , canFilesWrite : boolean , canWriteFile : boolean , preview : PreviewState , onPreview : ( mode : ConfigMode , content : string , current : string ) = > void , onWrite : ( ) = > void ) {
const editable = hasReadSnapshot && canFilesWrite && file . editable !== false ;
const fieldKeys = new Set ( fields . map ( ( field ) = > field . key ) ) ;
const selectedDraft = Object . fromEntries ( Object . entries ( draft ) . filter ( ( [ key ] ) = > fieldKeys . has ( key ) ) ) ;
const hasChanges = Object . keys ( selectedDraft ) . length > 0 ;
const unknown = unknownIniAssignments ( currentContent , fields ) ;
return e ( "div" , { className : "file-workbench-fields" } ,
fields . map ( ( field ) = > {
const value = draft [ field . key ] ? ? values [ field . configKey ] ? ? field . defaultValue ;
return e ( "label" , { className : "file-workbench-field" , key : field.key } ,
e ( "span" , null , e ( "strong" , null , field . label ) , e ( "small" , null , ` ${ field . configKey } · ${ field . description } ` ) ) ,
fieldControl ( e , field , value , editable , ( next ) = > setDraft ( ( current ) = > ( { . . . current , [ field . key ] : next } ) ) ) ,
e ( "small" , null , ` ${ controlLabel ( field ) } · ${ field . restartImpact === "restart-required" ? "修改后需要受控重启" : "可在安全窗口内生效" } ` )
) ;
} ) ,
unknown . length ? e ( "section" , { className : "file-workbench-unknown" , "aria-label" : "未建模配置项" } , e ( "h3" , null , "未建模配置项" ) , unknown . map ( ( item ) = > e ( "div" , { className : "console-row" , key : ` ${ item . key } : ${ item . index } ` } , e ( "span" , null , item . key ) , e ( "strong" , null , item . value || "空值" ) ) ) ) : null ,
e ( "div" , { className : "console-row-actions file-workbench-actions" } ,
e ( "button" , { type : "button" , className : "icon-command" , disabled : ! editable || ! hasChanges , onClick : ( ) = > onPreview ( "fields" , composeIniContent ( currentContent , fields , selectedDraft ) , currentContent ) } , "预览改动" ) ,
e ( "button" , { type : "button" , className : "primary-command" , disabled : ! editable || ! canWriteFile || ! preview || preview . mode !== "fields" , onClick : onWrite } , "提交写入" )
) ,
preview && preview . mode === "fields" ? diffPreview ( e , preview ) : null ,
e ( "p" , { className : "page-status" } , hasReadSnapshot ? editable ? "配置表单会保留原文中的未知行;提交前先预览差异。" : "当前账号仅有读取权限,配置项为只读。" : "先读取文件后才显示服务器当前值;未读取时不会把默认值伪装成原文。" )
) ;
}
function fieldControl ( e : ReactLike [ "createElement" ] , field : SCUMConfigField , value : string , editable : boolean , onChange : ( next : string ) = > void ) {
if ( field . control === "boolean" ) {
return e ( "select" , { value : normalizeBoolean ( value ) ? "true" : "false" , disabled : ! editable , "aria-label" : field . label , onChange : ( event : { target : { value : string } } ) = > onChange ( event . target . value ) } ,
e ( "option" , { value : "true" } , "是" ) ,
e ( "option" , { value : "false" } , "否" )
) ;
}
if ( field . control === "number" || field . control === "port" ) {
return e ( "div" , { className : "file-workbench-number-control" } ,
e ( "input" , { type : "range" , value , min : field.minimum , max : field.maximum , disabled : ! editable , "aria-label" : ` ${ field . label } 滑动输入 ` , onChange : ( event : { target : { value : string } } ) = > onChange ( event . target . value ) } ) ,
e ( "input" , { type : "number" , value , min : field.minimum , max : field.maximum , readOnly : ! editable , "aria-label" : field . label , onChange : ( event : { target : { value : string } } ) = > onChange ( event . target . value ) } )
) ;
}
return e ( "input" , { type : "text" , value , readOnly : ! editable , "aria-label" : field . label , onChange : ( event : { target : { value : string } } ) = > onChange ( event . target . value ) } ) ;
}
function rawFileView ( e : ReactLike [ "createElement" ] , file : SCUMLogicalFile , currentContent : string , rawDraft : Record < string , string > , setRawDraft : StateSetter < Record < string , string > > , snapshot : SCUMFileReadSnapshot | null , canFilesWrite : boolean , canWriteFile : boolean , preview : PreviewState , onPreview : ( mode : ConfigMode , content : string , current : string ) = > void , onWrite : ( ) = > void ) {
if ( snapshot ? . state !== "ready" ) {
const message = snapshot ? . reason ? ? ( file . kind === "config" ? "尚未读取此配置文件的受控原文。" : "尚未读取此日志文件的受控内容。" ) ;
return e ( "div" , { className : "file-workbench-raw" } , e ( "p" , { className : "page-status" } , message ) , snapshot ? . jobId ? e ( "small" , null , ` 读取任务 ${ snapshot . jobId } ` ) : null ) ;
}
const rawValue = rawDraft [ file . key ] ? ? currentContent ;
const editable = file . kind === "config" && canFilesWrite && file . editable !== false ;
if ( file . kind === "log" ) {
return e ( "div" , { className : "file-workbench-raw" } , e ( "pre" , { className : "runtime-task-log" } , rawValue ) , e ( "p" , { className : "page-status" } , ` 日志原文只读 · ${ contentLineCount ( rawValue ) } 行 · ${ snapshot . sizeBytes ? ? 0 } B ` ) ) ;
}
return e ( "div" , { className : "file-workbench-raw" } ,
e ( "textarea" , { className : "file-workbench-raw-editor" , value : rawValue , readOnly : ! editable , spellCheck : false , "aria-label" : ` ${ file . label } 原文模式编辑 ` , onChange : ( event : { target : { value : string } } ) = > setRawDraft ( ( current ) = > ( { . . . current , [ file . key ] : event . target . value } ) ) } ) ,
e ( "div" , { className : "console-row-actions file-workbench-actions" } ,
e ( "button" , { type : "button" , className : "icon-command" , disabled : ! editable || rawValue === currentContent , onClick : ( ) = > onPreview ( "source" , rawValue , currentContent ) } , "预览改动" ) ,
e ( "button" , { type : "button" , className : "primary-command" , disabled : ! editable || ! canWriteFile || ! preview || preview . mode !== "source" , onClick : onWrite } , "提交写入" )
) ,
preview && preview . mode === "source" ? diffPreview ( e , preview ) : null ,
e ( "p" , { className : "page-status" } , editable ? "原文模式会整文件写入声明 file key;提交前请先预览差异。" : "原文配置当前只读。" )
) ;
}
function diffPreview ( e : ReactLike [ "createElement" ] , preview : Exclude < PreviewState , null > ) {
return e ( "section" , { className : "file-workbench-diff" , "aria-label" : "文件改动预览" } ,
e ( "div" , { className : "console-row" } , e ( "span" , null , "差异预览" ) , e ( "strong" , null , preview . summary ) ) ,
e ( "pre" , { className : "runtime-task-log" } , preview . lines . map ( ( line ) = > ` ${ line . kind === "added" ? "+" : line . kind === "removed" ? "-" : " " } ${ line . text } ` ) . join ( "\n" ) )
) ;
}
function usePluginState < T > ( react : ReactLike , initialState : T ) : [ T , StateSetter < T > ] {
return react . useState ? react . useState ( initialState ) : [ initialState , ( ) = > undefined ] ;
}
function normalizeWorkspace ( workspace? : SCUMFeatureWorkspace ) : NormalizedWorkspace {
const directories = workspace ? . directories ? . length ? workspace.directories : fallbackDirectories ;
const files = workspace ? . files ? . length ? workspace.files : fallbackFiles ;
const configFields = workspace ? . configFields ? . length ? workspace.configFields : configurationCatalog ;
return { defaultDirectoryKey : workspace?.defaultDirectoryKey && directories . some ( ( directory ) = > directory . key === workspace . defaultDirectoryKey ) ? workspace.defaultDirectoryKey : directories [ 0 ] ? . key ? ? "scum-config" , directories , files , configFields } ;
}
function initialFileKey ( workspace : NormalizedWorkspace , directoryKey : string ) : string {
return workspace . files . find ( ( file ) = > file . directoryKey === directoryKey && file . kind === "config" ) ? . key ? ? workspace . files . find ( ( file ) = > file . directoryKey === directoryKey ) ? . key ? ? workspace . files [ 0 ] ? . key ? ? "" ;
}
function directoryRelativePath ( key : string ) : string | undefined { return directoryRelativePaths [ key ] ; }
function controlLabel ( field : SCUMConfigField ) : string {
const range = field . minimum !== undefined || field . maximum !== undefined ? ` 范围 ${ field . minimum ? ? "不限" } - ${ field . maximum ? ? "不限" } ` : "" ;
const label = field . control === "boolean" ? "是/否选择" : field . control === "number" || field . control === "port" ? "滑动输入" : "文本填空" ;
return [ label , range ] . filter ( Boolean ) . join ( " · " ) ;
}
function normalizeBoolean ( value : string ) : boolean {
return /^(true|1|yes|on)$/i . test ( String ( value ) . trim ( ) ) ;
}
function parseIniAssignments ( content : string ) : Record < string , string > {
const values : Record < string , string > = { } ;
for ( const line of content . split ( "\n" ) ) {
const parsed = parseIniAssignment ( line ) ;
if ( parsed ) values [ parsed . key ] = parsed . value ;
}
return values ;
}
function unknownIniAssignments ( content : string , fields : readonly SCUMConfigField [ ] ) : Array < { key : string ; value : string ; index : number } > {
const known = new Set ( fields . map ( ( field ) = > field . configKey ) ) ;
const unknown : Array < { key : string ; value : string ; index : number } > = [ ] ;
content . split ( "\n" ) . forEach ( ( line , index ) = > {
const parsed = parseIniAssignment ( line ) ;
if ( parsed && ! known . has ( parsed . key ) ) unknown . push ( { . . . parsed , index } ) ;
} ) ;
return unknown ;
}
function parseIniAssignment ( line : string ) : { key : string ; value : string } | null {
const trimmed = line . trim ( ) ;
if ( ! trimmed || trimmed . startsWith ( "#" ) || trimmed . startsWith ( ";" ) || trimmed . startsWith ( "[" ) ) return null ;
const separator = line . indexOf ( "=" ) ;
if ( separator < 1 ) return null ;
return { key : line.slice ( 0 , separator ) . trim ( ) , value : line.slice ( separator + 1 ) . trim ( ) } ;
}
function composeIniContent ( content : string , fields : readonly SCUMConfigField [ ] , draft : Record < string , string > ) : string {
const byField = new Map ( fields . map ( ( field ) = > [ field . key , field ] ) ) ;
const changes = new Map < string , string > ( ) ;
for ( const [ fieldKey , value ] of Object . entries ( draft ) ) {
const field = byField . get ( fieldKey ) ;
if ( field ) changes . set ( field . configKey , value ) ;
}
if ( changes . size === 0 ) return content ;
const applied = new Set < string > ( ) ;
const lines = content . split ( "\n" ) . map ( ( line ) = > {
const parsed = parseIniAssignment ( line ) ;
if ( ! parsed || ! changes . has ( parsed . key ) ) return line ;
applied . add ( parsed . key ) ;
return ` ${ line . slice ( 0 , line . indexOf ( "=" ) + 1 ) } ${ changes . get ( parsed . key ) ? ? "" } ` ;
} ) ;
for ( const [ key , value ] of changes ) {
if ( ! applied . has ( key ) ) lines . push ( ` ${ key } = ${ value } ` ) ;
}
return content . endsWith ( "\n" ) ? lines . join ( "\n" ) : lines . join ( "\n" ) . replace ( /\n$/ , "" ) ;
}
function decodeRawContent ( content : string , encoding : RawEncoding ) : string {
if ( encoding === "utf-8" ) return content ;
const bytes = Uint8Array . from ( Array . from ( content ) , ( char ) = > char . charCodeAt ( 0 ) & 0xff ) ;
if ( typeof TextDecoder !== "undefined" ) return new TextDecoder ( "utf-16le" ) . decode ( bytes ) ;
let decoded = "" ;
for ( let index = 0 ; index < bytes . length ; index += 2 ) decoded += String . fromCharCode ( bytes [ index ] | ( ( bytes [ index + 1 ] ? ? 0 ) << 8 ) ) ;
return decoded ;
}
function buildSimpleDiff ( current : string , proposed : string ) : { summary : string ; lines : readonly DiffLine [ ] } {
const currentLines = current . split ( "\n" ) ;
const proposedLines = proposed . split ( "\n" ) ;
const max = Math . max ( currentLines . length , proposedLines . length ) ;
const lines : DiffLine [ ] = [ ] ;
let added = 0 ;
let removed = 0 ;
for ( let index = 0 ; index < max ; index += 1 ) {
const before = currentLines [ index ] ;
const after = proposedLines [ index ] ;
if ( before === after ) {
if ( before !== undefined ) lines . push ( { kind : "same" , text : before } ) ;
continue ;
}
if ( before !== undefined ) { removed += 1 ; lines . push ( { kind : "removed" , text : before } ) ; }
if ( after !== undefined ) { added += 1 ; lines . push ( { kind : "added" , text : after } ) ; }
}
return { summary : ` + ${ added } / - ${ removed } 行变更 ` , lines } ;
}
function contentLineCount ( content : string ) : number {
return content ? content . split ( "\n" ) . length : 0 ;
}