# 游戏内容配置系统 > 面向静态游戏内容的 AI-First 配表方案 该配置系统用于管理怪物、物品、技能、任务等静态内容数据。 它与 `GFramework.Core.Configuration` 不同,后者面向运行时键值配置;它也不同于 `GFramework.Game.Setting`,后者面向玩家设置和持久化。 ## 当前能力 - YAML 作为配置源文件 - JSON Schema 作为结构描述 - 一对象一文件的目录组织 - 运行时只读查询 - Runtime / Generator / Tooling 共享支持 `minimum`、`maximum`、`exclusiveMinimum`、`exclusiveMaximum`、`minLength`、`maxLength`、`pattern`、`minItems`、`maxItems` - Source Generator 生成配置类型、表包装、单表注册/访问辅助,以及项目级聚合注册目录 - VS Code 插件提供配置浏览、raw 编辑、schema 打开、递归轻量校验和嵌套对象表单入口 ## 推荐目录结构 ```text GameProject/ ├─ config/ │ ├─ monster/ │ │ ├─ slime.yaml │ │ └─ goblin.yaml │ └─ item/ │ └─ potion.yaml ├─ schemas/ │ ├─ monster.schema.json │ └─ item.schema.json ``` ## Schema 示例 ```json { "title": "Monster Config", "description": "定义怪物静态配置。", "type": "object", "required": ["id", "name"], "properties": { "id": { "type": "integer", "description": "怪物主键。" }, "name": { "type": "string", "title": "Monster Name", "description": "怪物显示名。", "default": "Slime" }, "hp": { "type": "integer", "default": 10 }, "rarity": { "type": "string", "enum": ["common", "rare", "boss"] }, "dropItems": { "type": "array", "description": "掉落物品表主键。", "items": { "type": "string", "enum": ["potion", "slime_gel", "bomb"] }, "x-gframework-ref-table": "item" } } } ``` ## YAML 示例 ```yaml id: 1 name: Slime hp: 10 dropItems: - potion - slime_gel ``` ## 推荐接入模板 如果你准备在一个真实游戏项目里首次接入这套配置系统,建议直接采用下面这套目录与启动模板,而不是零散拼装。 ### 目录模板 ```text GameProject/ ├─ GameProject.csproj ├─ Config/ │ ├─ GameConfigBootstrap.cs │ └─ GameConfigRuntime.cs ├─ config/ │ ├─ monster/ │ │ ├─ slime.yaml │ │ └─ goblin.yaml │ └─ item/ │ └─ potion.yaml └─ schemas/ ├─ monster.schema.json └─ item.schema.json ``` 推荐约定如下: - `schemas/` 放所有 `*.schema.json`,由 Source Generator 自动拾取 - `config/` 放运行时加载的 YAML 数据,一对象一文件 - `Config/` 放你自己的接入代码,例如启动注册、热重载句柄和对外读取入口 ### `csproj` 模板 如果你在仓库内直接用项目引用,最小模板可以写成下面这样: ```xml net10.0 disable enable ``` 这段配置的作用: - `GFramework.Game` 提供运行时 `YamlConfigLoader`、`ConfigRegistry`、`GameConfigBootstrap` 和只读表实现 - 三个 `ProjectReference(... OutputItemType="Analyzer")` 把生成器接进当前消费者项目 - `GeWuYou.GFramework.SourceGenerators.targets` 自动把 `schemas/**/*.schema.json` 加入 `AdditionalFiles` 如果你使用打包后的 NuGet,而不是仓库内项目引用,原则保持不变: - 运行时项目需要引用 `GeWuYou.GFramework.Game` - 生成器项目需要引用 `GeWuYou.GFramework.SourceGenerators` - schema 目录默认仍然是 `schemas/` 如果你的 schema 不放在默认目录,可以在项目文件里覆盖: ```xml GameSchemas ``` ### 官方启动帮助器 `GFramework.Game` 现在内置 `GameConfigBootstrap` 与 `GameConfigBootstrapOptions`,用于把 `ConfigRegistry`、`YamlConfigLoader`、`LoadAsync` 和热重载句柄收敛到一个正式的 C# 入口中。 推荐直接组合这个帮助器,而不是继续在消费者项目里复制文档模板: ```csharp using GFramework.Game.Abstractions.Config; using GFramework.Game.Config; using GFramework.Game.Config.Generated; var bootstrap = new GameConfigBootstrap( new GameConfigBootstrapOptions { RootPath = configRootPath, ConfigureLoader = static loader => loader.RegisterAllGeneratedConfigTables(), EnableHotReload = true, HotReloadOptions = new YamlConfigHotReloadOptions { OnTableReloaded = tableName => Console.WriteLine($"Reloaded config table: {tableName}"), OnTableReloadFailed = static (_, exception) => { var diagnostic = (exception as ConfigLoadException)?.Diagnostic; Console.WriteLine($"Config reload failed: {diagnostic?.FailureKind}"); } } }); await bootstrap.InitializeAsync(); var registry = bootstrap.Registry; var monsterTable = registry.GetMonsterTable(); var slime = monsterTable.Get(1); bootstrap.Dispose(); ``` 如果你希望把它继续包装进自己的进程级入口,也建议只包一层生命周期壳,而不是重新拼装底层加载器: ```csharp using GFramework.Game.Abstractions.Config; using GFramework.Game.Config; using GFramework.Game.Config.Generated; namespace GameProject.Config; /// /// 封装当前游戏进程的配置启动生命周期。 /// public sealed class GameConfigRuntime : IDisposable { private readonly GameConfigBootstrap _bootstrap; /// /// 使用指定配置根目录创建运行时入口。 /// /// 配置根目录。 public GameConfigRuntime(string configRootPath) { _bootstrap = new GameConfigBootstrap( new GameConfigBootstrapOptions { RootPath = configRootPath, ConfigureLoader = static loader => loader.RegisterAllGeneratedConfigTables() }); } /// /// 获取共享配置注册表。 /// public IConfigRegistry Registry => _bootstrap.Registry; /// /// 执行初次配置加载。 /// public Task InitializeAsync() { return _bootstrap.InitializeAsync(); } /// /// 释放底层热重载句柄等资源。 /// public void Dispose() { _bootstrap.Dispose(); } } ``` 这个官方帮助器刻意遵循几个约定: - 优先通过 `ConfigureLoader` 调用生成器产出的 `RegisterAllGeneratedConfigTables()`,把多表注册收敛为一个稳定入口 - 由 `GameConfigBootstrap` 持有 `ConfigRegistry`、`YamlConfigLoader` 和热重载句柄 - `InitializeAsync()` 只在首次加载完整成功后才公开运行时状态,避免半初始化对象泄漏到业务层 - 热重载既可以在初始化时自动启用,也可以在初次加载后显式调用 `StartHotReload(...)` ### 运行时读取模板 推荐不要在业务代码里直接散落字符串表名查询,而是统一依赖生成的强类型入口: ```csharp using GFramework.Game.Config.Generated; namespace GameProject.Config; /// /// 封装游戏内容配置读取入口。 /// public sealed class GameConfigRuntime { private readonly IConfigRegistry _registry; /// /// 使用已初始化的配置注册表创建读取入口。 /// /// 配置注册表。 public GameConfigRuntime(IConfigRegistry registry) { _registry = registry ?? throw new ArgumentNullException(nameof(registry)); } /// /// 获取指定怪物配置。 /// /// 怪物主键。 /// 强类型怪物配置。 public MonsterConfig GetMonster(int monsterId) { return _registry.GetMonsterTable().Get(monsterId); } /// /// 获取怪物配置表。 /// /// 生成的强类型表包装。 public MonsterTable GetMonsterTable() { return _registry.GetMonsterTable(); } } ``` 这样做的收益: - 配置系统对业务层暴露的是强类型表,而不是 `"monster"` 这类 magic string - 后续如果你要复用配置域、schema 路径或引用元数据,可以继续依赖 `MonsterConfigBindings.Metadata` 和 `MonsterConfigBindings.References` - 如果未来把配置初始化接入 `Architecture` 或 `Module`,迁移成本也更低 ### 生成查询辅助 从当前阶段开始,生成的 `*Table` 包装会为“顶层、非主键、非引用的标量字段”额外产出轻量查询辅助。 如果 `monster.schema.json` 包含顶层标量字段 `name`、`faction`,则可以直接这样使用: ```csharp var monsterTable = registry.GetMonsterTable(); var slime = monsterTable.FindByName("Slime"); if (monsterTable.TryFindFirstByFaction("dungeon", out var firstDungeonMonster)) { Console.WriteLine(firstDungeonMonster.Name); } ``` 当前生成规则刻意保持保守: - 只为顶层标量字段生成 `FindBy*` 与 `TryFindFirstBy*` - 主键字段继续只走 `Get / TryGet` - 嵌套对象、对象数组、标量数组和 `x-gframework-ref-table` 字段暂不生成查询辅助 - 查询实现基于 `All()` 做线性扫描,不引入运行时索引或缓存 这意味着它的定位是“减少业务层手写过滤样板”,而不是“替代专门索引结构”。 如果你依赖 `TryFindFirstBy*`,应当把它理解为“返回当前表快照遍历顺序下的第一个匹配项”,而不是固定排序语义。 ### Architecture 推荐接入模板 如果你的项目已经基于 `GFramework.Core.Architectures.Architecture` 组织初始化流程,推荐把配置系统接到 `OnInitialize()` 阶段,并把 `GameConfigBootstrap.Registry` 注册为 utility: ```csharp using GFramework.Core.Architectures; using GFramework.Game.Abstractions.Config; using GFramework.Game.Config; using GFramework.Game.Config.Generated; public sealed class GameArchitecture : Architecture { private readonly GameConfigBootstrap _configBootstrap; public GameArchitecture(string configRootPath) { _configBootstrap = new GameConfigBootstrap( new GameConfigBootstrapOptions { RootPath = configRootPath, ConfigureLoader = static loader => loader.RegisterAllGeneratedConfigTables() }); } protected override void OnInitialize() { RegisterUtility(_configBootstrap.Registry); _configBootstrap.InitializeAsync().GetAwaiter().GetResult(); } public override async ValueTask DestroyAsync() { _configBootstrap.Dispose(); await base.DestroyAsync(); } } ``` 初始化完成后,业务组件可以继续通过架构上下文读取 utility,再走生成的强类型入口: ```csharp var registry = Context.GetUtility(); var monsterTable = registry.GetMonsterTable(); var slime = monsterTable.Get(1); ``` 推荐遵循以下顺序: - 先构造 `GameConfigBootstrap` - 在 `OnInitialize()` 里注册 `bootstrap.Registry` - 再调用 `bootstrap.InitializeAsync()` 完成首次加载 - 架构销毁时释放 `GameConfigBootstrap` - 初始化完成后只通过注册表和生成表包装访问配置 当前阶段不建议为了配置系统额外引入新的 `IArchitectureModule` 或 service module 抽象;现有 `Architecture + GameConfigBootstrap + RegisterAllGeneratedConfigTables()` 组合已经足够作为官方推荐接入路径。 ### 热重载模板 如果你希望把开发期热重载显式收敛为一个可选能力,推荐直接通过 `GameConfigBootstrap.StartHotReload(...)` 管理,而不是让监听句柄散落在启动层之外: ```csharp await bootstrap.InitializeAsync(); bootstrap.StartHotReload( new YamlConfigHotReloadOptions { OnTableReloaded = tableName => Console.WriteLine($"Reloaded: {tableName}"), OnTableReloadFailed = (tableName, exception) => { var diagnostic = (exception as ConfigLoadException)?.Diagnostic; Console.WriteLine($"Reload failed: {tableName}"); Console.WriteLine($"Failure kind: {diagnostic?.FailureKind}"); Console.WriteLine($"Yaml path: {diagnostic?.YamlPath}"); Console.WriteLine($"Display path: {diagnostic?.DisplayPath}"); } }); ``` 建议只在开发期启用这项能力: - 生产环境默认更适合静态加载和固定生命周期 - 热重载失败时应优先依赖 `ConfigLoadException.Diagnostic` 做稳定日志或 UI 提示 - 如果你的项目已经有统一日志系统,建议在这里把诊断字段转成结构化日志,而不是拼接一整段字符串 如果你确实需要直接控制底层加载器,`YamlConfigLoader.EnableHotReload(...)` 仍然保留;但在一般启动路径下,优先让 `GameConfigBootstrap` 持有并停止监听句柄。 ## 运行时接入 当你希望加载后的配置在运行时以只读表形式暴露时,优先使用生成器产出的注册与访问辅助: ```csharp using GFramework.Game.Config; using GFramework.Game.Config.Generated; var registry = new ConfigRegistry(); var loader = new YamlConfigLoader("config-root") .RegisterAllGeneratedConfigTables(); await loader.LoadAsync(registry); var monsterTable = registry.GetMonsterTable(); var slime = monsterTable.Get(1); ``` 这里推荐把“注册全部已生成配置表”和“读取单表强类型元数据”分成两层: - 启动层优先走 `RegisterAllGeneratedConfigTables()`,避免每新增一个 schema 都要回到启动代码继续补链式调用 - 消费层继续通过 `GetMonsterTable()`、`MonsterConfigBindings.Metadata` 这类单表入口读取强类型信息 这组辅助会把以下约定固化到生成代码里: - 配置域常量,例如 `MonsterConfigBindings.ConfigDomain` - 表注册名,例如 `monster` - 配置目录相对路径,例如 `monster` - schema 相对路径,例如 `schemas/monster.schema.json` - 主键提取逻辑,例如 `config => config.Id` 如果你希望把这些约定作为一个统一入口传递或复用,也可以优先读取 `MonsterConfigBindings.Metadata` 下的常量: ```csharp var domain = MonsterConfigBindings.Metadata.ConfigDomain; var tableName = MonsterConfigBindings.Metadata.TableName; var configPath = MonsterConfigBindings.Metadata.ConfigRelativePath; var schemaPath = MonsterConfigBindings.Metadata.SchemaRelativePath; ``` 如果你需要在启动或诊断代码里枚举当前消费者项目里有哪些生成表,也可以直接读取项目级目录: ```csharp foreach (var metadata in GeneratedConfigCatalog.Tables) { Console.WriteLine($"{metadata.TableName} -> {metadata.SchemaRelativePath}"); } ``` 也可以按表名回查: ```csharp if (GeneratedConfigCatalog.TryGetByTableName("monster", out var metadata)) { Console.WriteLine(metadata.ConfigRelativePath); } ``` 如果你需要为某些表保留自定义 key comparer,也可以继续走聚合注册入口,而不是被迫退回逐表手写: ```csharp var loader = new YamlConfigLoader("config-root") .RegisterAllGeneratedConfigTables( new GeneratedConfigRegistrationOptions { ItemComparer = StringComparer.OrdinalIgnoreCase }); ``` 如果项目已经生成了多张表,但当前场景只想注册其中一部分,也可以直接在聚合入口上加筛选,而不必退回手写逐表注册: ```csharp var loader = new YamlConfigLoader("config-root") .RegisterAllGeneratedConfigTables( new GeneratedConfigRegistrationOptions { IncludedConfigDomains = new[] { MonsterConfigBindings.ConfigDomain } }); ``` 如果你更习惯按表名白名单或自定义谓词裁剪启动集,也可以继续在同一个 options 对象里完成: ```csharp var loader = new YamlConfigLoader("config-root") .RegisterAllGeneratedConfigTables( new GeneratedConfigRegistrationOptions { IncludedTableNames = new[] { MonsterConfigBindings.TableName, ItemConfigBindings.TableName }, TableFilter = static metadata => metadata.SchemaRelativePath.EndsWith(".schema.json", StringComparison.Ordinal) }); ``` 这里的规则是: - `IncludedConfigDomains` 与 `IncludedTableNames` 都按 `StringComparison.Ordinal` 做白名单匹配;传 `null` 或空集合表示“不限制” - `TableFilter` 会在上述白名单通过后执行,适合继续按 schema 路径、配置目录等元数据做更细的启动裁剪 - 未显式配置 comparer 的表,仍然使用各自 `Register{Entity}Table()` 的默认行为 - 需要自定义 comparer 的表,可以通过 `GeneratedConfigRegistrationOptions` 按表覆盖 - 当前 `ConfigDomain` 约定仍与生成表名保持一致,但建议优先引用 `*ConfigBindings.ConfigDomain`,为后续更细的分组策略保留稳定入口 - 如果项目希望继续完全手写某张表的注册流程,逐表 `Register*Table(...)` 入口仍然保留,作为兼容逃生通道 如果你需要自定义目录、表名或 key selector,仍然可以直接调用 `YamlConfigLoader.RegisterTable(...)` 原始重载。 如果你希望把 schema 路径、比较器以及未来扩展开关集中到一个对象里,推荐改用选项对象入口: ```csharp var loader = new YamlConfigLoader("config-root") .RegisterTable( new YamlConfigTableRegistrationOptions( "monster", "monster", static config => config.Id) { SchemaRelativePath = "schemas/monster.schema.json" }); ``` ## 运行时校验行为 绑定 schema 的表在加载时会拒绝以下问题: - 缺失必填字段 - 未在 schema 中声明的未知字段 - 标量类型不匹配 - 数组元素类型不匹配 - 嵌套对象字段类型不匹配 - 对象数组元素结构不匹配 - 数值字段违反 `minimum` / `maximum` - 数值字段违反 `exclusiveMinimum` / `exclusiveMaximum` - 字符串字段违反 `minLength` / `maxLength` - 字符串字段违反 `pattern` - 数组字段违反 `minItems` / `maxItems` - 标量 `enum` 不匹配 - 标量数组元素 `enum` 不匹配 - 通过 `x-gframework-ref-table` 声明的跨表引用缺失目标行 跨表引用当前使用最小扩展关键字: ```json { "type": "object", "required": ["id", "dropItemId"], "properties": { "id": { "type": "integer" }, "dropItemId": { "type": "string", "x-gframework-ref-table": "item" } } } ``` 约束如下: - 仅支持 `string`、`integer` 及其标量数组声明跨表引用 - 引用目标表需要由同一个 `YamlConfigLoader` 注册,或已存在于当前 `IConfigRegistry` - 热重载中若目标表变更导致依赖表引用失效,会整体回滚受影响表,避免注册表进入不一致状态 如果你希望在消费者代码里复用这些跨表约定,而不是继续手写字段路径或目标表名,生成的 `*ConfigBindings` 还会暴露引用元数据: ```csharp var allReferences = MonsterConfigBindings.References.All; if (MonsterConfigBindings.References.TryGetByDisplayPath("dropItems", out var reference)) { Console.WriteLine(reference.ReferencedTableName); Console.WriteLine(reference.ValueSchemaType); Console.WriteLine(reference.IsCollection); } ``` 当 schema 中存在具体引用字段时,还可以直接通过生成成员访问,例如 `MonsterConfigBindings.References.DropItems`。 当前还支持以下“轻量元数据”: - `title`:供 VS Code 插件表单和批量编辑入口显示更友好的字段标题 - `description`:供表单提示、生成代码 XML 文档和接入说明复用 - `default`:供生成类型属性初始值和工具提示复用 - `enum`:供运行时校验、VS Code 校验和表单枚举选择复用 - `minimum` / `maximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用 - `exclusiveMinimum` / `exclusiveMaximum`:供运行时校验、VS Code 校验和生成代码 XML 文档复用 - `minLength` / `maxLength`:供运行时校验、VS Code 校验和生成代码 XML 文档复用 - `pattern`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用 - `minItems` / `maxItems`:供运行时校验、VS Code 校验、表单提示和生成代码 XML 文档复用 这样可以避免错误配置被默认值或 `IgnoreUnmatchedProperties` 静默吞掉。 加载失败时,`YamlConfigLoader` 会抛出 `ConfigLoadException`。你可以通过 `exception.Diagnostic` 读取稳定字段,而不必解析消息文本: ```csharp using GFramework.Game.Abstractions.Config; try { await loader.LoadAsync(registry); } catch (ConfigLoadException exception) { Console.WriteLine(exception.Diagnostic.FailureKind); Console.WriteLine(exception.Diagnostic.TableName); Console.WriteLine(exception.Diagnostic.YamlPath); Console.WriteLine(exception.Diagnostic.SchemaPath); Console.WriteLine(exception.Diagnostic.DisplayPath); } ``` 当前诊断对象会优先暴露这些字段: - `FailureKind` - `TableName` - `YamlPath` - `SchemaPath` - `DisplayPath` - `ReferencedTableName` - `RawValue` ## 开发期热重载 如果你希望在开发期修改配置文件后自动刷新运行时表,可以在初次加载完成后启用热重载: ```csharp using GFramework.Game.Abstractions.Config; using GFramework.Game.Config; using GFramework.Game.Config.Generated; var registry = new ConfigRegistry(); var loader = new YamlConfigLoader("config-root") .RegisterAllGeneratedConfigTables(); await loader.LoadAsync(registry); var hotReload = loader.EnableHotReload( registry, onTableReloaded: tableName => Console.WriteLine($"Reloaded: {tableName}"), onTableReloadFailed: (tableName, exception) => { var diagnostic = (exception as ConfigLoadException)?.Diagnostic; Console.WriteLine($"Reload failed: {tableName}, {exception.Message}"); Console.WriteLine($"Failure kind: {diagnostic?.FailureKind}"); }); ``` 当前热重载行为如下: - 监听已注册表对应的配置目录 - 监听该表绑定的 schema 文件 - 检测到变更后按表粒度重载 - 若变更表被其他表通过跨表引用依赖,会联动重验受影响表 - 重载成功后替换该表在 `IConfigRegistry` 中的注册 - 重载失败时保留旧表,并通过失败回调提供诊断 这项能力默认定位为开发期工具,不承诺生产环境热更新平台语义。 ## 生成器接入约定 配置生成器会从 `*.schema.json` 生成以下代码: - 配置类型 - 表包装类型 - `YamlConfigLoader` 注册辅助 - `IConfigRegistry` 强类型访问辅助 通过已打包的 Source Generator 使用时,默认会自动收集 `schemas/**/*.schema.json` 作为 `AdditionalFiles`。 如果你在仓库内直接使用项目引用而不是打包后的 NuGet,请确认 schema 文件同样被加入 `AdditionalFiles`。 ## VS Code 工具 仓库中的 `tools/gframework-config-tool` 当前提供以下能力: - 浏览 `config/` 目录 - 打开 raw YAML 文件 - 打开匹配的 schema 文件 - 根据 VS Code 当前界面语言在英文和简体中文之间切换主要工具界面文本 - 对嵌套对象中的必填字段、未知字段、基础标量类型、标量数组和对象数组元素做轻量校验 - 对嵌套对象字段、对象数组、顶层标量字段和顶层标量数组提供轻量表单入口 - 在表单中渲染已有 YAML 注释,并允许直接编辑字段级 YAML 注释 - 对带 `x-gframework-ref-table` 的字段提供引用 schema / 配置域 / 引用文件跳转入口 - 对空配置文件提供基于 schema 的示例 YAML 初始化入口 - 对同一配置域内的多份 YAML 文件执行批量字段更新 - 在表单和批量编辑入口中显示 `title / description / default / enum / ref-table` 元数据 当前表单入口适合编辑嵌套对象中的标量字段、标量数组,以及对象数组中的对象项。 对象数组编辑器当前支持: - 新增和删除对象项 - 编辑对象项中的标量字段 - 编辑对象项中的标量数组 - 编辑对象项中的嵌套对象字段 如果对象数组项内部继续包含对象数组,当前仍建议回退到 raw YAML 完成。 当前批量编辑入口仍刻意限制在“同域文件统一改动顶层标量字段和顶层标量数组”,避免复杂结构批量写回时破坏人工维护的 YAML 排版。 ## 当前限制 以下能力尚未完全完成: - 更完整的 JSON Schema 支持 - VS Code 中更深层对象数组嵌套的安全表单编辑器 - 更强的复杂数组与更深 schema 关键字支持 因此,现阶段更适合作为你游戏项目的“受控试点配表系统”,而不是完全无约束的大规模内容生产平台。 ## 独立 Config Studio 评估 当前阶段的结论是:`不建议立即启动独立 Config Studio`,继续以 `VS Code Extension` 作为主工具形态更合适。 当前不单独启动桌面版的原因: - 当前已落地的能力主要仍围绕 schema 校验、轻量表单、批量编辑和 raw YAML 回退,这些都能在 VS Code 宿主里低成本迭代 - runtime、generator、tooling 之间仍在持续收敛 schema 子集和元数据语义,过早拆出桌面工具会放大版本协同成本 - 当前待补强点仍是更完整 schema 支持和复杂编辑体验,先在插件里验证真实工作流更稳妥 - 仓库当前的主要使用者仍偏开发者和技术策划,独立桌面版带来的“免开发环境”收益还不足以抵消额外维护面 只有在以下条件明显成立时,再建议启动独立 `Config Studio`: - 主要使用者变成非开发人员,且 VS Code 安装与使用成本成为持续阻力 - 需要更重的表格视图、跨表可视化关系编辑、复杂审批流或离线发布流程 - 插件形态已经频繁受限于 VS Code Webview/Extension API,而不是 schema 与工作流本身 - 已经沉淀出稳定的 schema 元数据约定,能够支撑单独桌面产品的长期维护