mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
fix: Template config optimization (#8228)
* fix: improve template list config handling * feat(webui): show template list display item * feat(webui): allow hiding template list hints * docs: document template list metadata fields * fix: support file fields in template list configs
This commit is contained in:
@@ -95,7 +95,7 @@ def _validate_template_list(value, meta, path_key, errors, validate_fn) -> None:
|
||||
validate_fn(
|
||||
item,
|
||||
template_meta.get("items", {}),
|
||||
path=f"{item_path}.",
|
||||
path=f"{path_key}.templates.{template_key}.",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ def get_schema_item(schema: dict | None, key_path: str) -> dict | None:
|
||||
同时支持:
|
||||
- 扁平 schema(直接 key 命中)
|
||||
- 嵌套 object schema({type: "object", items: {...}})
|
||||
- template_list schema(<field>.templates.<template>.items)
|
||||
"""
|
||||
|
||||
if not isinstance(schema, dict) or not key_path:
|
||||
@@ -23,17 +24,31 @@ def get_schema_item(schema: dict | None, key_path: str) -> dict | None:
|
||||
if key_path in schema:
|
||||
return schema.get(key_path)
|
||||
|
||||
current = schema
|
||||
parts = key_path.split(".")
|
||||
for idx, part in enumerate(parts):
|
||||
current = schema
|
||||
idx = 0
|
||||
while idx < len(parts):
|
||||
part = parts[idx]
|
||||
if part not in current:
|
||||
return None
|
||||
meta = current.get(part)
|
||||
if idx == len(parts) - 1:
|
||||
return meta
|
||||
if not isinstance(meta, dict) or meta.get("type") != "object":
|
||||
return None
|
||||
if not isinstance(meta, dict) or meta.get("type") != "template_list":
|
||||
return None
|
||||
if idx + 2 >= len(parts) or parts[idx + 1] != "templates":
|
||||
return None
|
||||
template_meta = meta.get("templates", {}).get(parts[idx + 2])
|
||||
if not isinstance(template_meta, dict):
|
||||
return None
|
||||
if idx + 2 == len(parts) - 1:
|
||||
return template_meta
|
||||
current = template_meta.get("items", {})
|
||||
idx += 3
|
||||
continue
|
||||
current = meta.get("items", {})
|
||||
idx += 1
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -25,13 +25,25 @@ const props = defineProps({
|
||||
searchKeyword: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
pluginName: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
pluginI18n: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
pathPrefix: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const { getRaw } = useModuleI18n('features/config-metadata')
|
||||
const { tm: tmConfig } = useModuleI18n('features/config')
|
||||
const { translateIfKey } = useConfigTextResolver()
|
||||
const { translateIfKey, resolveConfigText } = useConfigTextResolver(props)
|
||||
|
||||
const hintMarkdown = new MarkdownIt({
|
||||
linkify: true,
|
||||
@@ -114,6 +126,18 @@ function createSelectorModel(selector) {
|
||||
})
|
||||
}
|
||||
|
||||
function getItemPath(key) {
|
||||
return props.pathPrefix ? `${props.pathPrefix}.${key}` : key
|
||||
}
|
||||
|
||||
function getItemDescription(itemKey, itemMeta) {
|
||||
return resolveConfigText(getItemPath(itemKey), 'description', itemMeta?.description) || itemKey
|
||||
}
|
||||
|
||||
function getItemHint(itemKey, itemMeta) {
|
||||
return resolveConfigText(getItemPath(itemKey), 'hint', itemMeta?.hint)
|
||||
}
|
||||
|
||||
function openEditorDialog(key, value, theme, language) {
|
||||
currentEditingKey.value = key
|
||||
currentEditingLanguage.value = language || 'json'
|
||||
@@ -143,8 +167,8 @@ function shouldShowItem(itemMeta, itemKey) {
|
||||
|
||||
const searchableText = [
|
||||
itemKey,
|
||||
translateIfKey(itemMeta?.description || ''),
|
||||
translateIfKey(itemMeta?.hint || '')
|
||||
getItemDescription(itemKey, itemMeta),
|
||||
getItemHint(itemKey, itemMeta)
|
||||
].join(' ').toLowerCase()
|
||||
|
||||
return searchableText.includes(keyword)
|
||||
@@ -259,13 +283,13 @@ function getSpecialSubtype(value) {
|
||||
<v-col cols="12" sm="6" class="property-info">
|
||||
<v-list-item density="compact">
|
||||
<v-list-item-title class="property-name">
|
||||
{{ translateIfKey(itemMeta?.description) || itemKey }}
|
||||
{{ getItemDescription(itemKey, itemMeta) }}
|
||||
<span class="property-key">({{ itemKey }})</span>
|
||||
</v-list-item-title>
|
||||
|
||||
<v-list-item-subtitle class="property-hint">
|
||||
<span v-if="itemMeta?.obvious_hint && itemMeta?.hint" class="important-hint">‼️</span>
|
||||
<span v-html="renderHint(itemMeta?.hint)"></span>
|
||||
<span v-html="renderHint(getItemHint(itemKey, itemMeta))"></span>
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-col>
|
||||
@@ -274,12 +298,18 @@ function getSpecialSubtype(value) {
|
||||
v-if="itemMeta?.type === 'template_list'"
|
||||
v-model="createSelectorModel(itemKey).value"
|
||||
:templates="itemMeta?.templates || {}"
|
||||
:plugin-name="pluginName"
|
||||
:plugin-i18n="pluginI18n"
|
||||
:config-path="getItemPath(itemKey)"
|
||||
class="config-field"
|
||||
/>
|
||||
<ConfigItemRenderer
|
||||
v-else
|
||||
v-model="createSelectorModel(itemKey).value"
|
||||
:item-meta="itemMeta || null"
|
||||
:plugin-name="pluginName"
|
||||
:plugin-i18n="pluginI18n"
|
||||
:config-key="getItemPath(itemKey)"
|
||||
:show-fullscreen-btn="!!itemMeta?.editor_mode"
|
||||
@open-fullscreen="openEditorDialog(itemKey, iterable, itemMeta?.editor_theme, itemMeta?.editor_language)"
|
||||
/>
|
||||
@@ -339,13 +369,13 @@ function getSpecialSubtype(value) {
|
||||
<v-col cols="12" sm="6" class="property-info">
|
||||
<v-list-item density="compact">
|
||||
<v-list-item-title class="property-name">
|
||||
{{ translateIfKey(itemMeta?.description) || itemKey }}
|
||||
{{ getItemDescription(itemKey, itemMeta) }}
|
||||
<span class="property-key">({{ itemKey }})</span>
|
||||
</v-list-item-title>
|
||||
|
||||
<v-list-item-subtitle class="property-hint">
|
||||
<span v-if="itemMeta?.obvious_hint && itemMeta?.hint" class="important-hint">‼️</span>
|
||||
<span v-html="renderHint(itemMeta?.hint)"></span>
|
||||
<span v-html="renderHint(getItemHint(itemKey, itemMeta))"></span>
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-col>
|
||||
@@ -354,12 +384,18 @@ function getSpecialSubtype(value) {
|
||||
v-if="itemMeta?.type === 'template_list'"
|
||||
v-model="createSelectorModel(itemKey).value"
|
||||
:templates="itemMeta?.templates || {}"
|
||||
:plugin-name="pluginName"
|
||||
:plugin-i18n="pluginI18n"
|
||||
:config-path="getItemPath(itemKey)"
|
||||
class="config-field"
|
||||
/>
|
||||
<ConfigItemRenderer
|
||||
v-else
|
||||
v-model="createSelectorModel(itemKey).value"
|
||||
:item-meta="itemMeta || null"
|
||||
:plugin-name="pluginName"
|
||||
:plugin-i18n="pluginI18n"
|
||||
:config-key="getItemPath(itemKey)"
|
||||
:show-fullscreen-btn="!!itemMeta?.editor_mode"
|
||||
@open-fullscreen="openEditorDialog(itemKey, iterable, itemMeta?.editor_theme, itemMeta?.editor_language)"
|
||||
/>
|
||||
|
||||
@@ -57,8 +57,11 @@
|
||||
</v-btn>
|
||||
<div class="d-flex flex-column">
|
||||
<v-list-item-title class="property-name">{{ templateLabel(entry.__template_key) }}</v-list-item-title>
|
||||
<v-list-item-subtitle class="property-hint" v-if="getTemplate(entry)?.hint || getTemplate(entry)?.description">
|
||||
{{ templateText(entry.__template_key, 'hint', getTemplate(entry)?.hint || getTemplate(entry)?.description) }}
|
||||
<v-list-item-subtitle class="property-hint entry-display-text" v-if="templateDisplayText(entry)">
|
||||
{{ templateDisplayText(entry) }}
|
||||
</v-list-item-subtitle>
|
||||
<v-list-item-subtitle class="property-hint" v-if="templateHintText(entry)">
|
||||
{{ templateHintText(entry) }}
|
||||
</v-list-item-subtitle>
|
||||
</div>
|
||||
</div>
|
||||
@@ -201,6 +204,7 @@ const defaultValueMap = {
|
||||
string: '',
|
||||
text: '',
|
||||
list: [],
|
||||
file: [],
|
||||
object: {},
|
||||
template_list: []
|
||||
}
|
||||
@@ -348,6 +352,49 @@ function getTemplate(entry) {
|
||||
return props.templates?.[key] || null
|
||||
}
|
||||
|
||||
function templateHintText(entry) {
|
||||
const template = getTemplate(entry)
|
||||
if (!template || template.hide_hint_in_list) return ''
|
||||
return templateText(entry.__template_key, 'hint', template.hint || template.description || '')
|
||||
}
|
||||
|
||||
function getItemMetaBySelector(itemsMeta = {}, selector = '') {
|
||||
const keys = selector.split('.').filter(Boolean)
|
||||
let currentItems = itemsMeta
|
||||
let currentMeta = null
|
||||
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
currentMeta = currentItems?.[keys[i]]
|
||||
if (!currentMeta) return null
|
||||
if (i < keys.length - 1) {
|
||||
if (currentMeta.type !== 'object') return null
|
||||
currentItems = currentMeta.items || {}
|
||||
}
|
||||
}
|
||||
|
||||
return currentMeta
|
||||
}
|
||||
|
||||
function templateDisplayText(entry) {
|
||||
const template = getTemplate(entry)
|
||||
const displayItem = template?.display_item
|
||||
if (!template || typeof displayItem !== 'string' || !displayItem) return ''
|
||||
|
||||
const displayMeta = getItemMetaBySelector(template.items || {}, displayItem)
|
||||
if (displayMeta?.type !== 'string') return ''
|
||||
|
||||
const value = getValueBySelector(entry, displayItem)
|
||||
if (typeof value !== 'string' || !value.trim()) return ''
|
||||
|
||||
const label = templateItemText(
|
||||
entry.__template_key,
|
||||
displayItem,
|
||||
'description',
|
||||
displayMeta.description || displayItem,
|
||||
)
|
||||
return `${label}: ${value.trim()}`
|
||||
}
|
||||
|
||||
function getValueBySelector(obj, selector) {
|
||||
const keys = selector.split('.')
|
||||
let current = obj
|
||||
@@ -450,6 +497,11 @@ function hasVisibleItemsAfter(entries, currentIndex, entry) {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.entry-display-text {
|
||||
color: var(--v-theme-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.property-key {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
|
||||
@@ -146,7 +146,14 @@ Plugin developers can add a template-style configuration to `_conf_schema` in th
|
||||
"template_1": {
|
||||
"name": "Template One",
|
||||
"hint":"hint",
|
||||
"display_item": "attr_name",
|
||||
"hide_hint_in_list": true,
|
||||
"items": {
|
||||
"attr_name": {
|
||||
"description": "Attribute Name",
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"attr_a": {
|
||||
"description": "Attribute A",
|
||||
"type": "int",
|
||||
@@ -187,6 +194,7 @@ Saved config example:
|
||||
"field_id": [
|
||||
{
|
||||
"__template_key": "template_1",
|
||||
"attr_name": "",
|
||||
"attr_a": 10,
|
||||
"attr_b": true
|
||||
},
|
||||
@@ -198,6 +206,11 @@ Saved config example:
|
||||
]
|
||||
```
|
||||
|
||||
Templates also support these optional fields:
|
||||
|
||||
- `display_item`: Specifies the key of a `string` item inside the template `items`. When set, the WebUI shows that field's current value in the collapsed list of added template entries, for example `Attribute Name: my-adapter`, making it easier to distinguish multiple entries created from the same template. Dot paths are supported for fields inside nested objects, for example `meta.name`.
|
||||
- `hide_hint_in_list`: When set to `true`, the WebUI hides the template `hint` in the collapsed list of added template entries. The template selection dropdown still shows the `hint`, and hints for fields inside the expanded entry are not affected.
|
||||
|
||||
<img width="1000" alt="image" src="https://github.com/user-attachments/assets/74876d30-11a4-491b-a7a0-8ebe8d603782" />
|
||||
|
||||
|
||||
|
||||
@@ -146,7 +146,14 @@ AstrBot 提供了“强大”的配置解析和可视化功能。能够让用户
|
||||
"template_1": {
|
||||
"name": "Template One",
|
||||
"hint":"hint",
|
||||
"display_item": "attr_name",
|
||||
"hide_hint_in_list": true,
|
||||
"items": {
|
||||
"attr_name": {
|
||||
"description": "Attribute Name",
|
||||
"type": "string",
|
||||
"default": ""
|
||||
},
|
||||
"attr_a": {
|
||||
"description": "Attribute A",
|
||||
"type": "int",
|
||||
@@ -187,6 +194,7 @@ AstrBot 提供了“强大”的配置解析和可视化功能。能够让用户
|
||||
"field_id": [
|
||||
{
|
||||
"__template_key": "template_1",
|
||||
"attr_name": "",
|
||||
"attr_a": 10,
|
||||
"attr_b": true
|
||||
},
|
||||
@@ -198,6 +206,11 @@ AstrBot 提供了“强大”的配置解析和可视化功能。能够让用户
|
||||
]
|
||||
```
|
||||
|
||||
模板本身还支持以下可选字段:
|
||||
|
||||
- `display_item`: 指定模板 `items` 中一个 `string` 类型字段的 key。设置后,WebUI 会在已添加模板条目的折叠列表中显示该字段当前值,例如 `Attribute Name: my-adapter`,便于添加多个同类型模板时快速区分。支持用点号选择嵌套 object 中的字段,例如 `meta.name`。
|
||||
- `hide_hint_in_list`: 设置为 `true` 时,WebUI 会在已添加模板条目的折叠列表中隐藏该模板的 `hint`。添加模板时的下拉菜单仍会显示 `hint`,展开条目后各配置项自己的 `hint` 也不受影响。
|
||||
|
||||
<img width="1000" alt="image" src="https://github.com/user-attachments/assets/74876d30-11a4-491b-a7a0-8ebe8d603782" />
|
||||
|
||||
## 在插件中使用配置
|
||||
|
||||
88
tests/unit/test_dashboard_util.py
Normal file
88
tests/unit/test_dashboard_util.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Tests for dashboard route utility helpers."""
|
||||
|
||||
from astrbot.dashboard.routes.config import validate_config
|
||||
from astrbot.dashboard.routes.util import get_schema_item
|
||||
|
||||
|
||||
def test_get_schema_item_template_list_file_item():
|
||||
schema = {
|
||||
"demo_templates": {
|
||||
"type": "template_list",
|
||||
"templates": {
|
||||
"api_provider": {
|
||||
"items": {
|
||||
"tls_certificate_files": {"type": "file"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
meta = get_schema_item(
|
||||
schema,
|
||||
"demo_templates.templates.api_provider.tls_certificate_files",
|
||||
)
|
||||
|
||||
assert meta == {"type": "file"}
|
||||
|
||||
|
||||
def test_get_schema_item_nested_template_list_file_item():
|
||||
schema = {
|
||||
"group": {
|
||||
"type": "object",
|
||||
"items": {
|
||||
"demo_templates": {
|
||||
"type": "template_list",
|
||||
"templates": {
|
||||
"nested_profile": {
|
||||
"items": {
|
||||
"profile": {
|
||||
"type": "object",
|
||||
"items": {
|
||||
"attachments": {"type": "file"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
meta = get_schema_item(
|
||||
schema,
|
||||
"group.demo_templates.templates.nested_profile.profile.attachments",
|
||||
)
|
||||
|
||||
assert meta == {"type": "file"}
|
||||
|
||||
|
||||
def test_validate_config_template_list_file_path_uses_template_schema_path():
|
||||
schema = {
|
||||
"demo_templates": {
|
||||
"type": "template_list",
|
||||
"templates": {
|
||||
"api_provider": {
|
||||
"items": {
|
||||
"tls_certificate_files": {"type": "file"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
data = {
|
||||
"demo_templates": [
|
||||
{
|
||||
"__template_key": "api_provider",
|
||||
"tls_certificate_files": [
|
||||
"files/demo_templates/templates/api_provider/tls_certificate_files/cert.pem"
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
errors, validated = validate_config(data, schema, is_core=False)
|
||||
|
||||
assert errors == []
|
||||
assert validated == data
|
||||
Reference in New Issue
Block a user