@@ -0,0 +1,135 @@
import { Code2 , FileCog , Save , X } from "lucide-react" ;
import { useCallback , useEffect , useMemo , useState } from "react" ;
import { platformApiClient } from "../api/client" ;
import type { DeclaredFileReadSnapshotResponse , PluginConfigFieldResponse , PluginLogicalFileResponse , ServerFileWorkspaceResponse , ServerInstanceResponse } from "../api/types" ;
import type { OperationTracker } from "../stores/operations" ;
import { iniValue , updateIniValues } from "../utils/iniConfig" ;
import { ErrorState , LoadingState , ResultBadge } from "./StateViews" ;
interface ServerConfigEditorProps {
instance : ServerInstanceResponse ;
operations : OperationTracker ;
requester : string ;
onClose : ( ) = > void ;
}
type EditorMode = "friendly" | "source" ;
export function ServerConfigEditor ( { instance , operations , requester , onClose } : ServerConfigEditorProps ) {
const [ workspace , setWorkspace ] = useState < ServerFileWorkspaceResponse | null > ( null ) ;
const [ selectedKey , setSelectedKey ] = useState ( "" ) ;
const [ mode , setMode ] = useState < EditorMode > ( "friendly" ) ;
const [ snapshot , setSnapshot ] = useState < DeclaredFileReadSnapshotResponse | null > ( null ) ;
const [ raw , setRaw ] = useState ( "" ) ;
const [ values , setValues ] = useState < Record < string , string > > ( { } ) ;
const [ loading , setLoading ] = useState ( true ) ;
const [ saving , setSaving ] = useState ( false ) ;
const [ message , setMessage ] = useState ( "" ) ;
const [ error , setError ] = useState ( "" ) ;
const configFiles = useMemo ( ( ) = > ( workspace ? . files ? ? [ ] ) . filter ( ( file ) = > file . kind === "config" && file . editable !== false ) , [ workspace ] ) ;
const selectedFile = configFiles . find ( ( file ) = > file . key === selectedKey ) ? ? configFiles [ 0 ] ;
const fields = useMemo ( ( ) = > ( workspace ? . configFields ? ? [ ] ) . filter ( ( field ) = > field . fileKey === selectedFile ? . key ) , [ selectedFile ? . key , workspace ? . configFields ] ) ;
const readSelectedFile = useCallback ( async ( file : PluginLogicalFileResponse ) = > {
setLoading ( true ) ;
setError ( "" ) ;
setMessage ( "正在通过 Run 读取配置文件…" ) ;
try {
let next = await platformApiClient . getServerFileReadSnapshot ( instance . id , file . key ) ;
if ( next . state !== "ready" ) {
const operationId = operations . begin ( { intent : "读取服务器配置" , targetKind : "config" , targetId : instance.id , requester } ) ;
const dispatch = await platformApiClient . readServerFile ( instance . id , { key : file.key , idempotencyKey : configOperationKey ( "read" , instance . id , file . key ) } ) ;
operations . succeed ( operationId , ` 配置读取任务 ${ dispatch . job . id } 已派发 ` , dispatch . job ) ;
for ( let attempt = 0 ; attempt < 60 && next . state !== "ready" ; attempt += 1 ) {
await new Promise ( ( resolve ) = > window . setTimeout ( resolve , 500 ) ) ;
next = await platformApiClient . getServerFileReadSnapshot ( instance . id , file . key ) ;
}
}
if ( next . state !== "ready" || next . content === undefined ) throw new Error ( next . reason ? ? "Run 尚未返回配置文件内容。" ) ;
setSnapshot ( next ) ;
setRaw ( next . content ) ;
setValues ( Object . fromEntries ( fields . map ( ( field ) = > [ field . key , iniValue ( next . content ? ? "" , field . configKey ) ] ) ) ) ;
setMessage ( ` 已读取 ${ file . label } ${ next . readAt ? ` · ${ formatReadTime ( next . readAt ) } ` : "" } ` ) ;
} catch ( readError ) {
setSnapshot ( null ) ;
setRaw ( "" ) ;
setError ( readError instanceof Error ? readError . message : "配置文件读取失败" ) ;
setMessage ( "" ) ;
} finally {
setLoading ( false ) ;
}
} , [ fields , instance . id , operations , requester ] ) ;
useEffect ( ( ) = > {
if ( ! workspace ) {
void platformApiClient . getServerFileWorkspace ( instance . id ) . then ( ( next ) = > {
setWorkspace ( next ) ;
setSelectedKey ( next . files . find ( ( file ) = > file . kind === "config" && file . editable !== false ) ? . key ? ? "" ) ;
} ) . catch ( ( loadError ) = > setError ( loadError instanceof Error ? loadError . message : "配置工作区加载失败" ) ) . finally ( ( ) = > setLoading ( false ) ) ;
}
} , [ instance . id , workspace ] ) ;
useEffect ( ( ) = > {
if ( selectedFile ) void readSelectedFile ( selectedFile ) ;
} , [ readSelectedFile , selectedFile ? . key ] ) ;
async function save() {
if ( ! selectedFile || ! snapshot || saving ) return ;
const content = mode === "source" ? raw : updateIniValues ( raw , Object . fromEntries ( fields . map ( ( field ) = > [ field . configKey , values [ field . key ] ? ? "" ] ) ) ) ;
const operationId = operations . begin ( { intent : "保存服务器配置" , targetKind : "config" , targetId : instance.id , requester } ) ;
setSaving ( true ) ;
setError ( "" ) ;
setMessage ( "正在向 Run 派发配置写入任务…" ) ;
try {
const dispatch = await platformApiClient . writeServerFile ( instance . id , { key : selectedFile.key , content , expectedVersion : snapshot.version , expectedChecksum : snapshot.checksum , idempotencyKey : configOperationKey ( "write" , instance . id , selectedFile . key ) } ) ;
operations . succeed ( operationId , ` 配置写入任务 ${ dispatch . job . id } 已派发 ` , dispatch . job ) ;
setRaw ( content ) ;
setSnapshot ( { . . . snapshot , content } ) ;
setMessage ( ` 已派发 ${ selectedFile . label } 写入任务;重启影响: ${ fields . some ( ( field ) = > field . restartImpact === "restart-required" ) ? "需要重启服务器" : "无需重启" } 。 ` ) ;
} catch ( saveError ) {
operations . fail ( operationId , saveError instanceof Error ? saveError . message : "配置写入失败" , operationId ) ;
setError ( saveError instanceof Error ? saveError . message : "配置写入失败" ) ;
} finally {
setSaving ( false ) ;
}
}
return (
< div className = "server-file-editor-overlay" role = "dialog" aria-modal = "true" aria-label = "修改服务器配置" >
< section className = "server-file-editor" aria-label = "server configuration workbench" >
< div className = "panel-header" > < h2 > < FileCog size = { 16 } style = { { verticalAlign : "-2px" } } / > 修 改 配 置 < / h2 > < span className = "page-status" > { instance . name } < / span > < button type = "button" className = "icon-command" onClick = { onClose } > < X size = { 14 } / > < span > 关 闭 < / span > < / button > < / div >
{ loading && ! workspace && < LoadingState label = "正在加载配置文件声明…" compact / > }
{ error && ! workspace && < ErrorState title = "配置工作区不可用" reason = { error } diagnosticId = { ` server-config: ${ instance . id } ` } compact / > }
{ workspace && configFiles . length === 0 && < ErrorState title = "插件没有声明可编辑配置" reason = "请先在 SCUM 插件中声明配置文件。" diagnosticId = { ` server-config: ${ instance . id } ` } compact / > }
{ workspace && configFiles . length > 0 && (
< div className = "file-workbench" >
< nav className = "file-workbench-nav" aria-label = "配置文件" >
< div className = "file-workbench-directory-heading" > < strong > 配 置 文 件 < / strong > < span > 由 插 件 声 明 < / span > < / div >
{ configFiles . map ( ( file ) = > < button key = { file . key } type = "button" className = "file-workbench-file" aria-current = { file . key === selectedFile ? . key ? "page" : undefined } onClick = { ( ) = > { setSelectedKey ( file . key ) ; setMode ( "friendly" ) ; } } > { < strong > { file . label } < / strong > } < span > { file . key === "scum-server-settings" ? "可视化字段" : "源码" } < / span > < / button > ) }
< / nav >
< div className = "file-workbench-detail" >
< div className = "panel-header" > < div > < h3 > { selectedFile ? . label } < / h3 > < span className = "page-status" > 配 置 内 容 由 Run 返 回 < / span > < / div > < / div >
< div className = "file-workbench-mode" > < span > { mode === "friendly" ? "人类可读模式:只修改插件声明的配置项" : "源码模式:编辑完整原始文件" } < / span > < button type = "button" className = { mode === "friendly" ? "file-workbench-mode-active" : undefined } onClick = { ( ) = > setMode ( "friendly" ) } > < FileCog size = { 13 } / > 人 类 可 读 < / button > < button type = "button" className = { mode === "source" ? "file-workbench-mode-active" : undefined } onClick = { ( ) = > setMode ( "source" ) } > < Code2 size = { 13 } / > 源 码 模 式 < / button > < / div >
{ message && < ResultBadge status = { error ? "failed" : "pending" } label = { message } / > }
{ error && workspace && < ErrorState title = "配置操作失败" reason = { error } diagnosticId = { ` server-config: ${ instance . id } : ${ selectedFile ? . key ? ? "unknown" } ` } compact / > }
{ loading && < LoadingState label = "正在读取配置文件…" compact / > }
{ ! loading && mode === "friendly" && < div className = "file-workbench-fields" > { fields . length === 0 && < span className = "provider-id" > 该 配 置 文 件 没 有 声 明 可 视 化 字 段 , 请 切 换 到 源 码 模 式 。 < / span > } { fields . map ( ( field ) = > < ConfigField key = { field . key } field = { field } value = { values [ field . key ] ? ? "" } onChange = { ( value ) = > setValues ( ( current ) = > ( { . . . current , [ field . key ] : value } ) ) } / > ) } < / div > }
{ ! loading && mode === "source" && < div className = "file-workbench-raw" > < textarea className = "file-workbench-raw-editor" value = { raw } spellCheck = { false } onChange = { ( event ) = > setRaw ( event . target . value ) } aria-label = { ` ${ selectedFile ? . label ? ? "配置文件" } 源码 ` } / > < small > 保 存 会 把 完 整 原 文 交 给 Run 写 回 服 务 器 工 作 区 。 < / small > < / div > }
< div className = "action-strip file-workbench-actions" > < button type = "button" className = "primary-command" disabled = { loading || saving || ! snapshot } onClick = { ( ) = > void save ( ) } > < Save size = { 14 } / > < span > { saving ? "保存中…" : "保存配置" } < / span > < / button > < button type = "button" className = "icon-command" onClick = { onClose } > 取 消 < / button > < / div >
< / div >
< / div >
) }
< / section >
< / div >
) ;
}
function ConfigField ( { field , value , onChange } : { field : PluginConfigFieldResponse ; value : string ; onChange : ( value : string ) = > void } ) {
const inputType = field . control === "number" || field . control === "port" ? "number" : "text" ;
return < label className = "file-workbench-field" > < span > < strong > { field . label } < / strong > < small > { field . description } { field . restartImpact === "restart-required" ? " · 修改后需要重启" : "" } < / small > < / span > { field . control === "boolean" ? < select value = { value || field . defaultValue || "false" } onChange = { ( event ) = > onChange ( event . target . value ) } > < option value = "true" > 是 < / option > < option value = "false" > 否 < / option > < / select > : < input type = { inputType } min = { field . minimum } max = { field . maximum } value = { value } placeholder = { field . defaultValue ? ? "" } onChange = { ( event ) = > onChange ( event . target . value ) } / > } < / label > ;
}
function configOperationKey ( operation : string , serverId : string , fileKey : string ) : string { return ` web:server-config: ${ operation } : ${ serverId } : ${ fileKey } ` ; }
function formatReadTime ( value : string ) : string { const date = new Date ( value ) ; return Number . isNaN ( date . getTime ( ) ) ? value : date.toLocaleString ( "zh-CN" ) ; }