关于koishi Schema.dynamic 控制台中的一些问题 包含 Shema.union 需要注意的事项

【问题类别】插件开发 / 控制台使用

环境信息

  • Koishi 版本:v4.18.0
  • 操作系统环境:Windows

问题描述
在使用 dynamic 插件时遇到配置持久化问题:

  1. 预期行为

    • 通过自定义 Schema 创建单选表单控件
    • 配置保存后能在 ctx.config 中读取
    • 改善复杂对象(包含 3+ 属性的对象数组)的编辑体验
  2. 实际行为

    • 控制台配置界面渲染正常
    • 点击"重载配置"后 ctx.config 未更新
    • 原生 table 控件在复杂对象时存在可读性问题
  3. 已尝试的解决方案

    • 使用 array table 通过上下移动来调整配置 – 超过三项就会退化为列表
    • 通过 dynamic 创建替代表单控件 – 存在持久化问题

最小化复现

// 插件入口
import { Context, Schema, Service } from 'koishi'
import { ConfigService, ConfigService_config } from './service/ConfigService'


export const name = 'qz-test'

// 在 Config 接口中明确类型
export interface Config {
    test: {
        id: string
        title: string
    }
    testConfig: ConfigService_config[]
}

export const Config: Schema<Config> = Schema.intersect([
    Schema.object({
        test: Schema.dynamic(`${ConfigService.DYNAMIC_NAME}`),
    }).description('测试配置'),
    Schema.object({
        testConfig: Schema.array(
            Schema.object({
                id: Schema.string().description(`唯一标识符`),
                title: Schema.string().description('标题'),
                test: Schema.string()
            })
        ).role(`table`)
    }).description(`测试案例`)
])

export const data: {
    ctx?: Context
} = {}
export async function apply(ctx: Context) {
    data.ctx = ctx
    console.log(`插件加载前读取`, ctx.config)
    ctx.plugin(ConfigService)
    // ctx.inject([`${ConfigService.SERVICE_NAME}`], async (ctx1) => {
    //     const configService = ctx1.configService
    //     // await configService.saveToDatabase(ctx.config.testConfig)
    //     // const v = await configService.getAllConfigs()
    //     // ctx1.logger.info(`整表数据: `, v)
    //     configService.dynamicConfig(ctx)
    // })

    // ctx.on(`ready`, () => {
    //     ctx.inject([`${ConfigService.SERVICE_NAME}`], async (ctx1) => {
    //         const configService = ctx1.configService
    //         // await configService.saveToDatabase(ctx.config.testConfig)
    //         // const v = await configService.getAllConfigs()
    //         // ctx1.logger.info(`整表数据: `, v)
    //         configService.dynamicConfig(ctx)
    //     })
    // })
}


// 用于dynamic的服务
import { Context, Schema, Service, sleep } from "koishi";
import { data } from "..";

export interface ConfigService_config {
    id: string
    title: string
    test: string
}
declare module "koishi" {
    interface Context {
        configService: ConfigService
    }
    interface Tables {
        qzConfig: ConfigService_config
    }
}
export class ConfigService extends Service {
    static inject = [`database`]

    static DYNAMIC_NAME = `configService`
    static SERVICE_NAME = "configService"

    constructor(ctx: Context) {
        super(ctx, ConfigService.SERVICE_NAME)
        ctx.logger.info("qz-test-config 已加载")
        this.databaseInit()
        this.dynamicConfig(data.ctx)
    }

    async databaseInit(config?: ConfigService_config[]) {
        // 定义数据库模型结构
        this.ctx.database.extend('qzConfig', {
            id: 'string',
            title: { type: 'string', length: 2048 },
        })
    }

    async saveToDatabase(config: ConfigService_config[]) {
        // 批量更新/插入操作(upsert)
        await Promise.all(
            config.map(item =>
                this.ctx.database.upsert('qzConfig', [
                    { id: item.id, title: item.title }
                ])
            )
        )

        // 或者使用更高效的批量写入方式(如果底层支持)
        // await this.ctx.model.set('qzConfig', Object.fromEntries(
        //     config.map(item => [item.id, item])
        // ))
    }

    // 可添加的扩展方法
    async getConfig(id: string) {
        return this.ctx.database.get('qzConfig', id)
    }

    async getAllConfigs() {
        return this.ctx.database
            .select('qzConfig')
            .execute()
    }

    async deleteConfig(id: string) {
        return this.ctx.database.remove('qzConfig', id)
    }

    dynamicConfig(ctx: Context) {
        const config: ConfigService_config[] = ctx.config.testConfig
        console.log(`插件加载后读取`, ctx.config)
        const unionSchema = Schema.union(config.map(item => {
            return Schema.object({
                id: Schema.const(item.id).required(),
                title: Schema.const(item.title),
            }).role(`table`).description(`${item.id}: ${item.title}`)
        })).role('select')
        // console.log(`动态配置: `, unionSchema)
        this.ctx.schema.set(ConfigService.DYNAMIC_NAME, unionSchema)
        ctx.config.test = {
            id: ctx.config.testConfig[0].id,
            title: ctx.config.testConfig[0].title
        }
        ctx.logger.info(`当前默认选择: `, ctx.config.test)
    }
}

补充信息

  1. 控制台行为:
    • 配置表单正常渲染

功能建议
是否可以增强 table 控件:

  • 支持横向滚动
  • 添加字段折叠/展开功能
  • 允许自定义列宽/冻结列
  • 添加行分割线增强可读性
2 个赞

解决方案
:one: 唯一标识约束
需确保 union 内的每个子项 Schema 满足:

Schema.object({
  id: Schema.const(item.id).required(), // 必须 required
  title: Schema.const(item.title).required() // 多字段联合标识
})

:key: 所有标识字段需同时设置 required() 才能被正确识别为唯一键

:two: 默认值陷阱
即使使用 Schema.const() 也需要显式设置 .default()

Schema.union(
  options.map(item => 
    Schema.object({
      id: Schema.const(item.id).default(item.id), // 必须设置默认值
      title: Schema.const(item.title).default(item.title)
    })
  )
)

:exclamation: 此处的 const+default 用法与直觉相悖,是否是预期行为?

2 个赞