feat: add template_list config type to support multiple repeated core/plugin config sets (#4208)
* feat: 添加模板列表配置支持,包含验证和编辑功能 * refactor(dashboard): extract ConfigItemRenderer to eliminate code duplication - Create ConfigItemRenderer.vue to centralize rendering logic for various config types (string, int, bool, selectors, etc.) - Refactor TemplateListEditor.vue to use the new renderer for entry fields - Refactor AstrBotConfig.vue and AstrBotConfigV4.vue to simplify metadata-driven rendering - Resolve circular dependency by decoupling TemplateListEditor from the base renderer * ruff format * refactor: improve config validation and fix unidirection data flow - Frontend: Fix one-way data flow in TemplateListEditor.vue by cloning entries before applying defaults and emitting updates instead of in-place modification. - Frontend: Remove unused TemplateListEditor import in ConfigItemRenderer.vue. - Backend: Refactor validate_config in config.py by extracting _expect_type and _validate_template_list helpers to reduce nesting and complexity.
This commit is contained in:
@@ -80,6 +80,8 @@ class AstrBotConfig(dict):
|
||||
if v["type"] == "object":
|
||||
conf[k] = {}
|
||||
_parse_schema(v["items"], conf[k])
|
||||
elif v["type"] == "template_list":
|
||||
conf[k] = default
|
||||
else:
|
||||
conf[k] = default
|
||||
|
||||
|
||||
@@ -3064,4 +3064,5 @@ DEFAULT_VALUE_MAP = {
|
||||
"text": "",
|
||||
"list": [],
|
||||
"object": {},
|
||||
"template_list": [],
|
||||
}
|
||||
|
||||
@@ -46,6 +46,46 @@ def try_cast(value: Any, type_: str):
|
||||
return None
|
||||
|
||||
|
||||
def _expect_type(value, expected_type, path_key, errors, expected_name=None):
|
||||
if not isinstance(value, expected_type):
|
||||
errors.append(
|
||||
f"错误的类型 {path_key}: 期望是 {expected_name or expected_type.__name__}, "
|
||||
f"得到了 {type(value).__name__}"
|
||||
)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _validate_template_list(value, meta, path_key, errors, validate_fn):
|
||||
if not _expect_type(value, list, path_key, errors, "list"):
|
||||
return
|
||||
|
||||
templates = meta.get("templates")
|
||||
if not isinstance(templates, dict):
|
||||
templates = {}
|
||||
|
||||
for idx, item in enumerate(value):
|
||||
item_path = f"{path_key}[{idx}]"
|
||||
if not _expect_type(item, dict, item_path, errors, "dict"):
|
||||
continue
|
||||
|
||||
template_key = item.get("__template_key") or item.get("template")
|
||||
if not template_key:
|
||||
errors.append(f"缺少模板选择 {item_path}: 需要 __template_key")
|
||||
continue
|
||||
|
||||
template_meta = templates.get(template_key)
|
||||
if not template_meta:
|
||||
errors.append(f"未知模板 {item_path}: {template_key}")
|
||||
continue
|
||||
|
||||
validate_fn(
|
||||
item,
|
||||
template_meta.get("items", {}),
|
||||
path=f"{item_path}.",
|
||||
)
|
||||
|
||||
|
||||
def validate_config(data, schema: dict, is_core: bool) -> tuple[list[str], dict]:
|
||||
errors = []
|
||||
|
||||
@@ -61,6 +101,11 @@ def validate_config(data, schema: dict, is_core: bool) -> tuple[list[str], dict]
|
||||
if value is None:
|
||||
data[key] = DEFAULT_VALUE_MAP[meta["type"]]
|
||||
continue
|
||||
|
||||
if meta["type"] == "template_list":
|
||||
_validate_template_list(value, meta, f"{path}{key}", errors, validate)
|
||||
continue
|
||||
|
||||
if meta["type"] == "list" and not isinstance(value, list):
|
||||
errors.append(
|
||||
f"错误的类型 {path}{key}: 期望是 list, 得到了 {type(value).__name__}",
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
<script setup>
|
||||
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
|
||||
import { ref, computed } from 'vue'
|
||||
import ListConfigItem from './ListConfigItem.vue'
|
||||
import ObjectEditor from './ObjectEditor.vue'
|
||||
import ProviderSelector from './ProviderSelector.vue'
|
||||
import PersonaSelector from './PersonaSelector.vue'
|
||||
import KnowledgeBaseSelector from './KnowledgeBaseSelector.vue'
|
||||
import ConfigItemRenderer from './ConfigItemRenderer.vue'
|
||||
import TemplateListEditor from './TemplateListEditor.vue'
|
||||
import { useI18n } from '@/i18n/composables'
|
||||
import axios from 'axios'
|
||||
import { useToast } from '@/utils/toast'
|
||||
@@ -159,6 +156,30 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Template List -->
|
||||
<div v-else-if="metadata[metadataKey].items[key]?.type === 'template_list'" class="nested-object w-100">
|
||||
<div v-if="!metadata[metadataKey].items[key]?.invisible && shouldShowItem(metadata[metadataKey].items[key], key)" class="nested-container">
|
||||
<div class="config-section mb-2">
|
||||
<v-list-item-title class="config-title">
|
||||
<span v-if="metadata[metadataKey].items[key]?.description">
|
||||
{{ metadata[metadataKey].items[key]?.description }}
|
||||
<span class="property-key">({{ key }})</span>
|
||||
</span>
|
||||
<span v-else>{{ key }}</span>
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="config-hint">
|
||||
<span v-if="metadata[metadataKey].items[key]?.obvious_hint && metadata[metadataKey].items[key]?.hint" class="important-hint">‼️</span>
|
||||
{{ metadata[metadataKey].items[key]?.hint }}
|
||||
</v-list-item-subtitle>
|
||||
</div>
|
||||
<TemplateListEditor
|
||||
v-model="iterable[key]"
|
||||
:templates="metadata[metadataKey].items[key]?.templates || {}"
|
||||
class="config-field"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Regular Property -->
|
||||
<template v-else>
|
||||
<v-row v-if="!metadata[metadataKey].items[key]?.invisible && shouldShowItem(metadata[metadataKey].items[key], key)" class="config-row">
|
||||
@@ -181,202 +202,15 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6" class="config-input">
|
||||
<div v-if="metadata[metadataKey].items[key]" class="w-100">
|
||||
<!-- Special handling for specific metadata types -->
|
||||
<div v-if="metadata[metadataKey].items[key]?._special === 'select_provider'">
|
||||
<ProviderSelector
|
||||
v-model="iterable[key]"
|
||||
:provider-type="'chat_completion'"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="metadata[metadataKey].items[key]?._special === 'select_provider_stt'">
|
||||
<ProviderSelector
|
||||
v-model="iterable[key]"
|
||||
:provider-type="'speech_to_text'"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="metadata[metadataKey].items[key]?._special === 'select_provider_tts'">
|
||||
<ProviderSelector
|
||||
v-model="iterable[key]"
|
||||
:provider-type="'text_to_speech'"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="metadata[metadataKey].items[key]?._special === 'select_persona'">
|
||||
<PersonaSelector
|
||||
v-model="iterable[key]"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="metadata[metadataKey].items[key]?._special === 'select_knowledgebase'">
|
||||
<KnowledgeBaseSelector
|
||||
v-model="iterable[key]"
|
||||
/>
|
||||
</div>
|
||||
<!-- Numeric input with get_embedding_dim button -->
|
||||
<div v-else-if="metadata[metadataKey].items[key]?._special === 'get_embedding_dim'"
|
||||
class="d-flex align-center gap-2">
|
||||
<v-text-field
|
||||
v-model="iterable[key]"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="config-field"
|
||||
type="number"
|
||||
hide-details
|
||||
></v-text-field>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
@click="getEmbeddingDimensions(iterable)"
|
||||
:loading="loadingEmbeddingDim"
|
||||
class="ml-2"
|
||||
>
|
||||
自动检测
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- List item with options-->
|
||||
<div v-else-if="metadata[metadataKey].items[key]?.type === 'list' && metadata[metadataKey].items[key]?.options && !metadata[metadataKey].items[key]?.invisible && metadata[metadataKey].items[key]?.render_type === 'checkbox'"
|
||||
class="d-flex flex-wrap gap-20">
|
||||
<v-checkbox
|
||||
v-for="(option, index) in metadata[metadataKey].items[key]?.options"
|
||||
v-model="iterable[key]"
|
||||
:label="metadata[metadataKey].items[key]?.labels ? metadata[metadataKey].items[key].labels[index] : option"
|
||||
:value="option"
|
||||
class="mr-2"
|
||||
color="primary"
|
||||
hide-details
|
||||
></v-checkbox>
|
||||
</div>
|
||||
<!-- List item with options-->
|
||||
<v-combobox
|
||||
v-else-if="metadata[metadataKey].items[key]?.type === 'list' && metadata[metadataKey].items[key]?.options && !metadata[metadataKey].items[key]?.invisible"
|
||||
v-model="iterable[key]"
|
||||
:items="metadata[metadataKey].items[key]?.options"
|
||||
:disabled="metadata[metadataKey].items[key]?.readonly"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="config-field"
|
||||
hide-details
|
||||
chips
|
||||
multiple
|
||||
></v-combobox>
|
||||
<!-- Select input -->
|
||||
<v-select
|
||||
v-else-if="metadata[metadataKey].items[key]?.options && !metadata[metadataKey].items[key]?.invisible"
|
||||
v-model="iterable[key]"
|
||||
:items="metadata[metadataKey].items[key]?.options"
|
||||
:disabled="metadata[metadataKey].items[key]?.readonly"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="config-field"
|
||||
hide-details
|
||||
></v-select>
|
||||
|
||||
<!-- Code Editor with Full Screen Option -->
|
||||
<div v-else-if="metadata[metadataKey].items[key]?.editor_mode && !metadata[metadataKey].items[key]?.invisible" class="editor-container">
|
||||
<VueMonacoEditor
|
||||
:theme="metadata[metadataKey].items[key]?.editor_theme || 'vs-light'"
|
||||
:language="metadata[metadataKey].items[key]?.editor_language || 'json'"
|
||||
style="min-height: 100px; flex-grow: 1; border: 1px solid rgba(0, 0, 0, 0.1);"
|
||||
v-model:value="iterable[key]"
|
||||
>
|
||||
</VueMonacoEditor>
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
class="editor-fullscreen-btn"
|
||||
@click="openEditorDialog(key, iterable, metadata[metadataKey].items[key]?.editor_theme, metadata[metadataKey].items[key]?.editor_language)"
|
||||
:title="t('core.common.editor.fullscreen')"
|
||||
>
|
||||
<v-icon>mdi-fullscreen</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- String input -->
|
||||
<v-text-field
|
||||
v-else-if="metadata[metadataKey].items[key]?.type === 'string' && !metadata[metadataKey].items[key]?.invisible"
|
||||
v-model="iterable[key]"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="config-field"
|
||||
hide-details
|
||||
></v-text-field>
|
||||
|
||||
<!-- Numeric input with optional slider -->
|
||||
<div
|
||||
v-else-if="(metadata[metadataKey].items[key]?.type === 'int' || metadata[metadataKey].items[key]?.type === 'float') && !metadata[metadataKey]?.invisible"
|
||||
class="d-flex align-center gap-3"
|
||||
>
|
||||
<v-slider
|
||||
v-if="metadata[metadataKey].items[key]?.slider"
|
||||
v-model.number="iterable[key]"
|
||||
:min="metadata[metadataKey].items[key]?.slider?.min ?? 0"
|
||||
:max="metadata[metadataKey].items[key]?.slider?.max ?? 100"
|
||||
:step="metadata[metadataKey].items[key]?.slider?.step ?? 1"
|
||||
color="primary"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="flex-grow-1"
|
||||
></v-slider>
|
||||
<v-text-field
|
||||
v-model.number="iterable[key]"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="config-field"
|
||||
type="number"
|
||||
hide-details
|
||||
style="max-width: 140px;"
|
||||
></v-text-field>
|
||||
</div>
|
||||
|
||||
<!-- Text area -->
|
||||
<v-textarea
|
||||
v-else-if="metadata[metadataKey].items[key]?.type === 'text' && !metadata[metadataKey].items[key]?.invisible"
|
||||
v-model="iterable[key]"
|
||||
variant="outlined"
|
||||
rows="3"
|
||||
class="config-field"
|
||||
hide-details
|
||||
></v-textarea>
|
||||
|
||||
<!-- Boolean switch -->
|
||||
<v-switch
|
||||
v-else-if="metadata[metadataKey].items[key]?.type === 'bool' && !metadata[metadataKey].items[key]?.invisible"
|
||||
v-model="iterable[key]"
|
||||
color="primary"
|
||||
inset
|
||||
density="compact"
|
||||
hide-details
|
||||
></v-switch>
|
||||
|
||||
<!-- List item -->
|
||||
<ListConfigItem
|
||||
v-else-if="metadata[metadataKey].items[key]?.type === 'list' && !metadata[metadataKey].items[key]?.invisible"
|
||||
v-model="iterable[key]"
|
||||
class="config-field"
|
||||
/>
|
||||
|
||||
<!-- Dict item (key-value editor) -->
|
||||
<ObjectEditor
|
||||
v-else-if="metadata[metadataKey].items[key]?.type === 'dict' && !metadata[metadataKey].items[key]?.invisible"
|
||||
v-model="iterable[key]"
|
||||
class="config-field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Fallback for unknown metadata -->
|
||||
<div v-else class="w-100">
|
||||
<v-text-field
|
||||
v-model="iterable[key]"
|
||||
:label="key"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="config-field"
|
||||
hide-details
|
||||
></v-text-field>
|
||||
</div>
|
||||
<ConfigItemRenderer
|
||||
v-if="metadata[metadataKey].items[key]"
|
||||
v-model="iterable[key]"
|
||||
:item-meta="metadata[metadataKey].items[key]"
|
||||
:loading="loadingEmbeddingDim"
|
||||
:show-fullscreen-btn="!!metadata[metadataKey].items[key]?.editor_mode"
|
||||
@get-embedding-dim="getEmbeddingDimensions(iterable)"
|
||||
@open-fullscreen="openEditorDialog(key, iterable, metadata[metadataKey].items[key]?.editor_theme, metadata[metadataKey].items[key]?.editor_language)"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
@@ -406,84 +240,17 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="5" class="config-input">
|
||||
<div class="w-100">
|
||||
<!-- Select input -->
|
||||
<v-select
|
||||
v-if="metadata[metadataKey]?.options && !metadata[metadataKey]?.invisible"
|
||||
v-model="iterable[metadataKey]"
|
||||
:items="metadata[metadataKey]?.options"
|
||||
:disabled="metadata[metadataKey]?.readonly"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="config-field"
|
||||
hide-details
|
||||
></v-select>
|
||||
|
||||
<!-- String input -->
|
||||
<v-text-field
|
||||
v-else-if="metadata[metadataKey]?.type === 'string' && !metadata[metadataKey]?.invisible"
|
||||
v-model="iterable[metadataKey]"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="config-field"
|
||||
hide-details
|
||||
></v-text-field>
|
||||
|
||||
<!-- Numeric input with optional slider -->
|
||||
<div
|
||||
v-else-if="(metadata[metadataKey]?.type === 'int' || metadata[metadataKey]?.type === 'float') && !metadata[metadataKey]?.invisible"
|
||||
class="d-flex align-center gap-3"
|
||||
>
|
||||
<v-slider
|
||||
v-if="metadata[metadataKey]?.slider"
|
||||
v-model.number="iterable[metadataKey]"
|
||||
:min="metadata[metadataKey]?.slider?.min ?? 0"
|
||||
:max="metadata[metadataKey]?.slider?.max ?? 100"
|
||||
:step="metadata[metadataKey]?.slider?.step ?? 1"
|
||||
color="primary"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="flex-grow-1"
|
||||
></v-slider>
|
||||
<v-text-field
|
||||
v-model.number="iterable[metadataKey]"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="config-field"
|
||||
type="number"
|
||||
hide-details
|
||||
style="max-width: 140px;"
|
||||
></v-text-field>
|
||||
</div>
|
||||
|
||||
<!-- Text area -->
|
||||
<v-textarea
|
||||
v-else-if="metadata[metadataKey]?.type === 'text' && !metadata[metadataKey]?.invisible"
|
||||
v-model="iterable[metadataKey]"
|
||||
variant="outlined"
|
||||
auto-grow
|
||||
rows="3"
|
||||
class="config-field"
|
||||
hide-details
|
||||
></v-textarea>
|
||||
|
||||
<!-- Boolean switch -->
|
||||
<v-switch
|
||||
v-else-if="metadata[metadataKey]?.type === 'bool' && !metadata[metadataKey]?.invisible"
|
||||
v-model="iterable[metadataKey]"
|
||||
color="primary"
|
||||
inset
|
||||
density="compact"
|
||||
hide-details
|
||||
></v-switch>
|
||||
|
||||
<!-- List item -->
|
||||
<ListConfigItem
|
||||
v-else-if="metadata[metadataKey]?.type === 'list' && !metadata[metadataKey]?.invisible"
|
||||
v-model="iterable[metadataKey]"
|
||||
class="config-field"
|
||||
/>
|
||||
</div>
|
||||
<TemplateListEditor
|
||||
v-if="metadata[metadataKey]?.type === 'template_list' && !metadata[metadataKey]?.invisible"
|
||||
v-model="iterable[metadataKey]"
|
||||
:templates="metadata[metadataKey]?.templates || {}"
|
||||
class="config-field"
|
||||
/>
|
||||
<ConfigItemRenderer
|
||||
v-else
|
||||
v-model="iterable[metadataKey]"
|
||||
:item-meta="metadata[metadataKey]"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
<script setup>
|
||||
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
|
||||
import { ref, computed } from 'vue'
|
||||
import ListConfigItem from './ListConfigItem.vue'
|
||||
import ObjectEditor from './ObjectEditor.vue'
|
||||
import ProviderSelector from './ProviderSelector.vue'
|
||||
import PersonaSelector from './PersonaSelector.vue'
|
||||
import KnowledgeBaseSelector from './KnowledgeBaseSelector.vue'
|
||||
import PluginSetSelector from './PluginSetSelector.vue'
|
||||
import T2ITemplateEditor from './T2ITemplateEditor.vue'
|
||||
import ConfigItemRenderer from './ConfigItemRenderer.vue'
|
||||
import TemplateListEditor from './TemplateListEditor.vue'
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables'
|
||||
|
||||
|
||||
@@ -215,118 +210,19 @@ function getSpecialSubtype(value) {
|
||||
</v-list-item>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" class="config-input">
|
||||
<div class="w-100" v-if="!itemMeta?._special">
|
||||
<!-- Select input for JSON selector -->
|
||||
<v-select v-if="itemMeta?.options" v-model="createSelectorModel(itemKey).value"
|
||||
:items="(() => {
|
||||
const labels = getTranslatedLabels(itemMeta);
|
||||
return labels
|
||||
? itemMeta.options.map((value, index) => ({ title: labels[index] || value, value: value }))
|
||||
: itemMeta.options;
|
||||
})()"
|
||||
:disabled="itemMeta?.readonly" density="compact" variant="outlined"
|
||||
class="config-field" hide-details></v-select>
|
||||
|
||||
<!-- Code Editor for JSON selector -->
|
||||
<div v-else-if="itemMeta?.editor_mode" class="editor-container">
|
||||
<VueMonacoEditor :theme="itemMeta?.editor_theme || 'vs-light'"
|
||||
:language="itemMeta?.editor_language || 'json'"
|
||||
style="min-height: 100px; flex-grow: 1; border: 1px solid rgba(0, 0, 0, 0.1);"
|
||||
v-model:value="createSelectorModel(itemKey).value">
|
||||
</VueMonacoEditor>
|
||||
<v-btn icon size="small" variant="text" color="primary" class="editor-fullscreen-btn"
|
||||
@click="openEditorDialog(itemKey, iterable, itemMeta?.editor_theme, itemMeta?.editor_language)"
|
||||
:title="t('core.common.editor.fullscreen')">
|
||||
<v-icon>mdi-fullscreen</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<!-- String input for JSON selector -->
|
||||
<v-text-field v-else-if="itemMeta?.type === 'string'" v-model="createSelectorModel(itemKey).value"
|
||||
density="compact" variant="outlined" class="config-field" hide-details></v-text-field>
|
||||
|
||||
<!-- Numeric input with optional slider for JSON selector -->
|
||||
<div v-else-if="itemMeta?.type === 'int' || itemMeta?.type === 'float'" class="d-flex align-center gap-3">
|
||||
<v-slider
|
||||
v-if="itemMeta?.slider"
|
||||
v-model.number="createSelectorModel(itemKey).value"
|
||||
:min="itemMeta?.slider?.min ?? 0"
|
||||
:max="itemMeta?.slider?.max ?? 100"
|
||||
:step="itemMeta?.slider?.step ?? 1"
|
||||
color="primary"
|
||||
density="compact"
|
||||
hide-details
|
||||
style="flex: 3"
|
||||
></v-slider>
|
||||
<v-text-field
|
||||
v-model.number="createSelectorModel(itemKey).value"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="config-field"
|
||||
style="flex: 2"
|
||||
type="number"
|
||||
hide-details
|
||||
></v-text-field>
|
||||
</div>
|
||||
|
||||
<!-- Text area for JSON selector -->
|
||||
<v-textarea v-else-if="itemMeta?.type === 'text'" v-model="createSelectorModel(itemKey).value"
|
||||
variant="outlined" rows="3" class="config-field" hide-details></v-textarea>
|
||||
|
||||
<!-- Boolean switch for JSON selector -->
|
||||
<v-switch v-else-if="itemMeta?.type === 'bool'" v-model="createSelectorModel(itemKey).value"
|
||||
color="primary" inset density="compact" hide-details
|
||||
style="display: flex; justify-content: end;"></v-switch>
|
||||
|
||||
<!-- List item for JSON selector -->
|
||||
<ListConfigItem v-else-if="itemMeta?.type === 'list'" v-model="createSelectorModel(itemKey).value"
|
||||
button-text="修改" class="config-field" />
|
||||
|
||||
<!-- Object editor for JSON selector -->
|
||||
<ObjectEditor v-else-if="itemMeta?.type === 'dict'" v-model="createSelectorModel(itemKey).value"
|
||||
class="config-field" />
|
||||
|
||||
<!-- Fallback for JSON selector -->
|
||||
<v-text-field v-else v-model="createSelectorModel(itemKey).value" density="compact" variant="outlined"
|
||||
class="config-field" hide-details></v-text-field>
|
||||
</div>
|
||||
|
||||
<!-- Special handling for specific metadata types -->
|
||||
<div v-else-if="itemMeta?._special === 'select_provider'">
|
||||
<ProviderSelector v-model="createSelectorModel(itemKey).value" :provider-type="'chat_completion'" />
|
||||
</div>
|
||||
<div v-else-if="itemMeta?._special === 'select_provider_stt'">
|
||||
<ProviderSelector v-model="createSelectorModel(itemKey).value" :provider-type="'speech_to_text'" />
|
||||
</div>
|
||||
<div v-else-if="itemMeta?._special === 'select_provider_tts'">
|
||||
<ProviderSelector v-model="createSelectorModel(itemKey).value" :provider-type="'text_to_speech'" />
|
||||
</div>
|
||||
<div v-else-if="getSpecialName(itemMeta?._special) === 'select_agent_runner_provider'">
|
||||
<ProviderSelector
|
||||
v-model="createSelectorModel(itemKey).value"
|
||||
:provider-type="'agent_runner'"
|
||||
:provider-subtype="getSpecialSubtype(itemMeta?._special)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="itemMeta?._special === 'provider_pool'">
|
||||
<ProviderSelector v-model="createSelectorModel(itemKey).value" :provider-type="'chat_completion'"
|
||||
button-text="选择提供商池..." />
|
||||
</div>
|
||||
<div v-else-if="itemMeta?._special === 'select_persona'">
|
||||
<PersonaSelector v-model="createSelectorModel(itemKey).value" />
|
||||
</div>
|
||||
<div v-else-if="itemMeta?._special === 'persona_pool'">
|
||||
<PersonaSelector v-model="createSelectorModel(itemKey).value" button-text="选择人格池..." />
|
||||
</div>
|
||||
<div v-else-if="itemMeta?._special === 'select_knowledgebase'">
|
||||
<KnowledgeBaseSelector v-model="createSelectorModel(itemKey).value" />
|
||||
</div>
|
||||
<div v-else-if="itemMeta?._special === 'select_plugin_set'">
|
||||
<PluginSetSelector v-model="createSelectorModel(itemKey).value" />
|
||||
</div>
|
||||
<div v-else-if="itemMeta?._special === 't2i_template'">
|
||||
<T2ITemplateEditor />
|
||||
</div>
|
||||
<TemplateListEditor
|
||||
v-if="itemMeta?.type === 'template_list'"
|
||||
v-model="createSelectorModel(itemKey).value"
|
||||
:templates="itemMeta?.templates || {}"
|
||||
class="config-field"
|
||||
/>
|
||||
<ConfigItemRenderer
|
||||
v-else
|
||||
v-model="createSelectorModel(itemKey).value"
|
||||
:item-meta="itemMeta"
|
||||
:show-fullscreen-btn="!!itemMeta?.editor_mode"
|
||||
@open-fullscreen="openEditorDialog(itemKey, iterable, itemMeta?.editor_theme, itemMeta?.editor_language)"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
<template>
|
||||
<div class="w-100">
|
||||
<!-- Special handling for specific metadata types -->
|
||||
<template v-if="itemMeta?._special === 'select_provider'">
|
||||
<ProviderSelector :model-value="modelValue" @update:model-value="emitUpdate" :provider-type="'chat_completion'" />
|
||||
</template>
|
||||
<template v-else-if="itemMeta?._special === 'select_provider_stt'">
|
||||
<ProviderSelector :model-value="modelValue" @update:model-value="emitUpdate" :provider-type="'speech_to_text'" />
|
||||
</template>
|
||||
<template v-else-if="itemMeta?._special === 'select_provider_tts'">
|
||||
<ProviderSelector :model-value="modelValue" @update:model-value="emitUpdate" :provider-type="'text_to_speech'" />
|
||||
</template>
|
||||
<template v-else-if="getSpecialName(itemMeta?._special) === 'select_agent_runner_provider'">
|
||||
<ProviderSelector
|
||||
:model-value="modelValue"
|
||||
@update:model-value="emitUpdate"
|
||||
:provider-type="'agent_runner'"
|
||||
:provider-subtype="getSpecialSubtype(itemMeta?._special)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="itemMeta?._special === 'provider_pool'">
|
||||
<ProviderSelector :model-value="modelValue" @update:model-value="emitUpdate" :provider-type="'chat_completion'"
|
||||
button-text="选择提供商池..." />
|
||||
</template>
|
||||
<template v-else-if="itemMeta?._special === 'select_persona'">
|
||||
<PersonaSelector :model-value="modelValue" @update:model-value="emitUpdate" />
|
||||
</template>
|
||||
<template v-else-if="itemMeta?._special === 'persona_pool'">
|
||||
<PersonaSelector :model-value="modelValue" @update:model-value="emitUpdate" button-text="选择人格池..." />
|
||||
</template>
|
||||
<template v-else-if="itemMeta?._special === 'select_knowledgebase'">
|
||||
<KnowledgeBaseSelector :model-value="modelValue" @update:model-value="emitUpdate" />
|
||||
</template>
|
||||
<template v-else-if="itemMeta?._special === 'select_plugin_set'">
|
||||
<PluginSetSelector :model-value="modelValue" @update:model-value="emitUpdate" />
|
||||
</template>
|
||||
<template v-else-if="itemMeta?._special === 't2i_template'">
|
||||
<T2ITemplateEditor />
|
||||
</template>
|
||||
<template v-else-if="itemMeta?._special === 'get_embedding_dim'">
|
||||
<div class="d-flex align-center gap-2">
|
||||
<v-text-field
|
||||
:model-value="modelValue"
|
||||
@update:model-value="emitUpdate"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="config-field"
|
||||
type="number"
|
||||
hide-details
|
||||
></v-text-field>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
@click="$emit('get-embedding-dim')"
|
||||
:loading="loading"
|
||||
class="ml-2"
|
||||
>
|
||||
自动检测
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-else-if="itemMeta?.type === 'list' && itemMeta?.options && itemMeta?.render_type === 'checkbox'"
|
||||
class="d-flex flex-wrap gap-20"
|
||||
>
|
||||
<v-checkbox
|
||||
v-for="(option, optionIndex) in itemMeta.options"
|
||||
:key="optionIndex"
|
||||
:model-value="modelValue"
|
||||
@update:model-value="emitUpdate"
|
||||
:label="getLabel(itemMeta, optionIndex, option)"
|
||||
:value="option"
|
||||
class="mr-2"
|
||||
color="primary"
|
||||
hide-details
|
||||
></v-checkbox>
|
||||
</div>
|
||||
|
||||
<v-combobox
|
||||
v-else-if="itemMeta?.type === 'list' && itemMeta?.options"
|
||||
:model-value="modelValue"
|
||||
@update:model-value="emitUpdate"
|
||||
:items="itemMeta.options"
|
||||
:disabled="itemMeta?.readonly"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="config-field"
|
||||
hide-details
|
||||
chips
|
||||
multiple
|
||||
></v-combobox>
|
||||
|
||||
<v-select
|
||||
v-else-if="itemMeta?.options"
|
||||
:model-value="modelValue"
|
||||
@update:model-value="emitUpdate"
|
||||
:items="getSelectItems(itemMeta)"
|
||||
:disabled="itemMeta?.readonly"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="config-field"
|
||||
hide-details
|
||||
></v-select>
|
||||
|
||||
<div v-else-if="itemMeta?.editor_mode" class="editor-container">
|
||||
<VueMonacoEditor
|
||||
:theme="itemMeta?.editor_theme || 'vs-light'"
|
||||
:language="itemMeta?.editor_language || 'json'"
|
||||
style="min-height: 100px; flex-grow: 1; border: 1px solid rgba(0, 0, 0, 0.1);"
|
||||
:value="modelValue"
|
||||
@update:value="emitUpdate"
|
||||
>
|
||||
</VueMonacoEditor>
|
||||
<v-btn v-if="showFullscreenBtn" icon size="small" variant="text" color="primary" class="editor-fullscreen-btn"
|
||||
@click="$emit('open-fullscreen')"
|
||||
:title="t('core.common.editor.fullscreen')">
|
||||
<v-icon>mdi-fullscreen</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-text-field
|
||||
v-else-if="itemMeta?.type === 'string'"
|
||||
:model-value="modelValue"
|
||||
@update:model-value="emitUpdate"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="config-field"
|
||||
hide-details
|
||||
></v-text-field>
|
||||
|
||||
<div
|
||||
v-else-if="itemMeta?.type === 'int' || itemMeta?.type === 'float'"
|
||||
class="d-flex align-center gap-3"
|
||||
>
|
||||
<v-slider
|
||||
v-if="itemMeta?.slider"
|
||||
:model-value="toNumber(modelValue)"
|
||||
@update:model-value="val => emitUpdate(toNumber(val))"
|
||||
:min="itemMeta?.slider?.min ?? 0"
|
||||
:max="itemMeta?.slider?.max ?? 100"
|
||||
:step="itemMeta?.slider?.step ?? 1"
|
||||
color="primary"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="flex-grow-1"
|
||||
></v-slider>
|
||||
<v-text-field
|
||||
:model-value="modelValue"
|
||||
@update:model-value="val => emitUpdate(toNumber(val))"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="config-field"
|
||||
type="number"
|
||||
hide-details
|
||||
style="max-width: 140px;"
|
||||
></v-text-field>
|
||||
</div>
|
||||
|
||||
<v-textarea
|
||||
v-else-if="itemMeta?.type === 'text'"
|
||||
:model-value="modelValue"
|
||||
@update:model-value="emitUpdate"
|
||||
variant="outlined"
|
||||
rows="3"
|
||||
class="config-field"
|
||||
hide-details
|
||||
></v-textarea>
|
||||
|
||||
<v-switch
|
||||
v-else-if="itemMeta?.type === 'bool'"
|
||||
:model-value="modelValue"
|
||||
@update:model-value="emitUpdate"
|
||||
color="primary"
|
||||
inset
|
||||
density="compact"
|
||||
hide-details
|
||||
></v-switch>
|
||||
|
||||
<ListConfigItem
|
||||
v-else-if="itemMeta?.type === 'list'"
|
||||
:model-value="modelValue"
|
||||
@update:model-value="emitUpdate"
|
||||
class="config-field"
|
||||
/>
|
||||
|
||||
<ObjectEditor
|
||||
v-else-if="itemMeta?.type === 'dict'"
|
||||
:model-value="modelValue"
|
||||
@update:model-value="emitUpdate"
|
||||
class="config-field"
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-else
|
||||
:model-value="modelValue"
|
||||
@update:model-value="emitUpdate"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
class="config-field"
|
||||
hide-details
|
||||
></v-text-field>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
|
||||
import ListConfigItem from './ListConfigItem.vue'
|
||||
import ObjectEditor from './ObjectEditor.vue'
|
||||
import ProviderSelector from './ProviderSelector.vue'
|
||||
import PersonaSelector from './PersonaSelector.vue'
|
||||
import KnowledgeBaseSelector from './KnowledgeBaseSelector.vue'
|
||||
import PluginSetSelector from './PluginSetSelector.vue'
|
||||
import T2ITemplateEditor from './T2ITemplateEditor.vue'
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Number, Boolean, Array, Object],
|
||||
default: null
|
||||
},
|
||||
itemMeta: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showFullscreenBtn: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'get-embedding-dim', 'open-fullscreen'])
|
||||
const { t } = useI18n()
|
||||
const { getRaw } = useModuleI18n('features/config-metadata')
|
||||
|
||||
function emitUpdate(val) {
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
|
||||
function toNumber(val) {
|
||||
const n = parseFloat(val)
|
||||
return isNaN(n) ? 0 : n
|
||||
}
|
||||
|
||||
function getLabel(itemMeta, index, option) {
|
||||
const labels = getTranslatedLabels(itemMeta)
|
||||
return labels ? labels[index] : option
|
||||
}
|
||||
|
||||
function getTranslatedLabels(itemMeta) {
|
||||
if (!itemMeta?.labels) return null
|
||||
if (typeof itemMeta.labels === 'string') {
|
||||
const translatedLabels = getRaw(itemMeta.labels)
|
||||
if (Array.isArray(translatedLabels)) {
|
||||
return translatedLabels
|
||||
}
|
||||
}
|
||||
if (Array.isArray(itemMeta.labels)) {
|
||||
return itemMeta.labels
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getSelectItems(itemMeta) {
|
||||
const labels = getTranslatedLabels(itemMeta)
|
||||
if (labels && itemMeta.options) {
|
||||
return itemMeta.options.map((value, index) => ({
|
||||
title: labels[index] || value,
|
||||
value: value
|
||||
}))
|
||||
}
|
||||
return itemMeta.options || []
|
||||
}
|
||||
|
||||
function parseSpecialValue(value) {
|
||||
if (!value || typeof value !== 'string') {
|
||||
return { name: '', subtype: '' }
|
||||
}
|
||||
const [name, ...rest] = value.split(':')
|
||||
return {
|
||||
name,
|
||||
subtype: rest.join(':') || ''
|
||||
}
|
||||
}
|
||||
|
||||
function getSpecialName(value) {
|
||||
return parseSpecialValue(value).name
|
||||
}
|
||||
|
||||
function getSpecialSubtype(value) {
|
||||
return parseSpecialValue(value).subtype
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.config-field {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editor-fullscreen-btn {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
z-index: 10;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.editor-fullscreen-btn:hover {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.gap-20 {
|
||||
gap: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,450 @@
|
||||
<template>
|
||||
<div class="template-list-editor">
|
||||
<div class="top-bar d-flex align-center justify-end mb-3">
|
||||
<v-menu transition="fade-transition">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
size="small"
|
||||
v-bind="menuProps"
|
||||
prepend-icon="mdi-plus"
|
||||
>
|
||||
{{ addButtonText }}
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="option in templateOptions"
|
||||
:key="option.value"
|
||||
@click="addEntry(option.value)"
|
||||
>
|
||||
<v-list-item-title>{{ option.label }}</v-list-item-title>
|
||||
<v-list-item-subtitle v-if="option.hint">{{ option.hint }}</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
|
||||
<v-alert
|
||||
v-if="!modelValue || modelValue.length === 0"
|
||||
type="info"
|
||||
variant="tonal"
|
||||
density="compact"
|
||||
class="mb-3"
|
||||
>
|
||||
{{ emptyHintText }}
|
||||
</v-alert>
|
||||
|
||||
<v-card
|
||||
v-for="(entry, entryIndex) in modelValue"
|
||||
:key="entryIndex"
|
||||
variant="outlined"
|
||||
class="mb-3"
|
||||
>
|
||||
<v-card-title
|
||||
class="d-flex align-center justify-space-between entry-header"
|
||||
@click="toggleEntry(entryIndex)"
|
||||
>
|
||||
<div class="d-flex align-center ga-2">
|
||||
<v-btn
|
||||
icon
|
||||
size="small"
|
||||
variant="text"
|
||||
:title="expandedEntries[entryIndex] ? (t('core.common.collapse') || '收起') : (t('core.common.expand') || '展开')"
|
||||
>
|
||||
<v-icon>{{ expandedEntries[entryIndex] ? 'mdi-chevron-down' : 'mdi-chevron-right' }}</v-icon>
|
||||
</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">
|
||||
{{ getTemplate(entry)?.hint || getTemplate(entry)?.description }}
|
||||
</v-list-item-subtitle>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-center ga-1">
|
||||
<v-btn icon size="small" variant="text" color="error" @click.stop="removeEntry(entryIndex)">
|
||||
<v-icon>mdi-delete</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-title>
|
||||
<v-expand-transition>
|
||||
<v-card-text v-show="expandedEntries[entryIndex]" class="px-0 py-1">
|
||||
<div v-if="!getTemplate(entry)" class="px-4 py-2">
|
||||
<v-alert type="error" variant="tonal" density="compact">{{ t('core.common.templateList.missingTemplate') || '找不到对应模板,请删除后重新添加。' }}</v-alert>
|
||||
</div>
|
||||
<div v-else class="template-entry-body">
|
||||
<template v-for="(itemMeta, itemKey, metaIndex) in getTemplate(entry).items" :key="itemKey">
|
||||
<!-- Nested Object -->
|
||||
<div
|
||||
v-if="itemMeta?.type === 'object' && !itemMeta?.invisible && shouldShowItem(itemMeta, entry)"
|
||||
class="nested-container mx-4"
|
||||
>
|
||||
<div class="config-section mb-2">
|
||||
<v-list-item-title class="config-title">
|
||||
{{ itemMeta?.description || itemKey }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="config-hint" v-if="itemMeta?.hint">
|
||||
{{ itemMeta.hint }}
|
||||
</v-list-item-subtitle>
|
||||
</div>
|
||||
<div v-for="(childMeta, childKey, childIndex) in itemMeta.items" :key="childKey">
|
||||
<template v-if="!childMeta?.invisible && shouldShowItem(childMeta, entry)">
|
||||
<v-row class="config-row">
|
||||
<v-col cols="12" sm="6" class="property-info">
|
||||
<v-list-item density="compact">
|
||||
<v-list-item-title class="property-name">
|
||||
{{ childMeta?.description || childKey }}
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="property-hint">
|
||||
{{ childMeta?.hint }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" class="config-input">
|
||||
<ConfigItemRenderer
|
||||
v-model="entry[itemKey][childKey]"
|
||||
:item-meta="childMeta"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-divider
|
||||
v-if="hasVisibleItemsAfter(Object.entries(itemMeta.items), childIndex, entry)"
|
||||
class="config-divider"
|
||||
></v-divider>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Regular Property -->
|
||||
<template v-else-if="!itemMeta?.invisible && shouldShowItem(itemMeta, entry)">
|
||||
<v-row class="config-row">
|
||||
<v-col cols="12" sm="6" class="property-info">
|
||||
<v-list-item density="compact">
|
||||
<v-list-item-title class="property-name">
|
||||
<span v-if="itemMeta?.description">{{ itemMeta?.description }} <span class="property-key">({{ itemKey }})</span></span>
|
||||
<span v-else>{{ itemKey }}</span>
|
||||
</v-list-item-title>
|
||||
<v-list-item-subtitle class="property-hint">
|
||||
{{ itemMeta?.hint }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" class="config-input">
|
||||
<ConfigItemRenderer
|
||||
v-model="entry[itemKey]"
|
||||
:item-meta="itemMeta"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-divider
|
||||
v-if="hasVisibleItemsAfter(Object.entries(getTemplate(entry).items), metaIndex, entry)"
|
||||
class="config-divider"
|
||||
></v-divider>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-expand-transition>
|
||||
</v-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import ConfigItemRenderer from './ConfigItemRenderer.vue'
|
||||
import { useI18n } from '@/i18n/composables'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
templates: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const { t } = useI18n()
|
||||
|
||||
const expandedEntries = ref({})
|
||||
|
||||
const safeText = (val, fallback) => (val && typeof val === 'string' ? val : fallback)
|
||||
const addButtonText = computed(() => safeText(t('core.common.templateList.addEntry'), '添加条目'))
|
||||
const emptyHintText = computed(() => safeText(t('core.common.templateList.empty'), '暂无条目,请先选择模板并添加。'))
|
||||
const defaultValueMap = {
|
||||
int: 0,
|
||||
float: 0.0,
|
||||
bool: false,
|
||||
string: '',
|
||||
text: '',
|
||||
list: [],
|
||||
object: {},
|
||||
template_list: []
|
||||
}
|
||||
|
||||
const templateOptions = computed(() => {
|
||||
return Object.entries(props.templates || {}).map(([value, meta]) => ({
|
||||
label: meta?.name || value,
|
||||
value,
|
||||
hint: meta?.hint || meta?.description || ''
|
||||
}))
|
||||
})
|
||||
|
||||
function templateLabel(key) {
|
||||
if (!key) return t('core.common.templateList.unknownTemplate') || '未指定模板'
|
||||
return props.templates?.[key]?.name || key
|
||||
}
|
||||
|
||||
function buildDefaults(itemsMeta = {}) {
|
||||
const result = {}
|
||||
for (const [k, meta] of Object.entries(itemsMeta)) {
|
||||
if (!meta || !meta.type) continue
|
||||
const fallback = Object.prototype.hasOwnProperty.call(meta, 'default')
|
||||
? meta.default
|
||||
: defaultValueMap[meta.type]
|
||||
|
||||
if (meta.type === 'object') {
|
||||
result[k] = buildDefaults(meta.items || {})
|
||||
} else {
|
||||
result[k] = fallback
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function applyDefaults(target, itemsMeta = {}) {
|
||||
let changed = false
|
||||
for (const [k, meta] of Object.entries(itemsMeta)) {
|
||||
if (!meta || !meta.type) continue
|
||||
const hasDefault = Object.prototype.hasOwnProperty.call(meta, 'default')
|
||||
const fallback = hasDefault ? meta.default : defaultValueMap[meta.type]
|
||||
|
||||
if (meta.type === 'object') {
|
||||
if (!target[k] || typeof target[k] !== 'object') {
|
||||
target[k] = buildDefaults(meta.items || {})
|
||||
changed = true
|
||||
} else {
|
||||
if (applyDefaults(target[k], meta.items || {})) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
} else if (!(k in target)) {
|
||||
target[k] = fallback
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
function ensureEntryDefaults() {
|
||||
if (!Array.isArray(props.modelValue)) return
|
||||
|
||||
let totalChanged = false
|
||||
const nextValue = props.modelValue.map((entry, idx) => {
|
||||
const template = getTemplate(entry)
|
||||
if (!template || !template.items) return entry
|
||||
|
||||
// 我们必须克隆以避免就地修改
|
||||
const newEntry = JSON.parse(JSON.stringify(entry))
|
||||
let entryChanged = applyDefaults(newEntry, template.items)
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(newEntry, '__template_key')) {
|
||||
newEntry.__template_key = ''
|
||||
entryChanged = true
|
||||
}
|
||||
|
||||
if (!(idx in expandedEntries.value)) {
|
||||
expandedEntries.value[idx] = false
|
||||
}
|
||||
|
||||
if (entryChanged) {
|
||||
totalChanged = true
|
||||
}
|
||||
return newEntry
|
||||
})
|
||||
|
||||
if (totalChanged) {
|
||||
emit('update:modelValue', nextValue)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
() => ensureEntryDefaults(),
|
||||
{ immediate: true, deep: true }
|
||||
)
|
||||
|
||||
function addEntry(templateKey) {
|
||||
if (!templateKey) return
|
||||
const template = props.templates?.[templateKey]
|
||||
if (!template) return
|
||||
const newEntry = {
|
||||
__template_key: templateKey,
|
||||
...buildDefaults(template.items || {})
|
||||
}
|
||||
emit('update:modelValue', [...(props.modelValue || []), newEntry])
|
||||
expandedEntries.value[props.modelValue.length] = true
|
||||
}
|
||||
|
||||
function removeEntry(index) {
|
||||
const next = [...(props.modelValue || [])]
|
||||
next.splice(index, 1)
|
||||
const rebuilt = {}
|
||||
next.forEach((_, idx) => {
|
||||
const sourceIdx = idx >= index ? idx + 1 : idx
|
||||
rebuilt[idx] = expandedEntries.value[sourceIdx] ?? false
|
||||
})
|
||||
expandedEntries.value = rebuilt
|
||||
emit('update:modelValue', next)
|
||||
}
|
||||
|
||||
function toggleEntry(index) {
|
||||
expandedEntries.value[index] = !expandedEntries.value[index]
|
||||
}
|
||||
|
||||
function getTemplate(entry) {
|
||||
if (!entry) return null
|
||||
const key = entry.__template_key
|
||||
if (!key) return null
|
||||
return props.templates?.[key] || null
|
||||
}
|
||||
|
||||
function getValueBySelector(obj, selector) {
|
||||
const keys = selector.split('.')
|
||||
let current = obj
|
||||
for (const key of keys) {
|
||||
if (current && typeof current === 'object' && key in current) {
|
||||
current = current[key]
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
return current
|
||||
}
|
||||
|
||||
function shouldShowItem(itemMeta, entry) {
|
||||
if (!itemMeta?.condition) {
|
||||
return true
|
||||
}
|
||||
for (const [conditionKey, expectedValue] of Object.entries(itemMeta.condition)) {
|
||||
const actualValue = getValueBySelector(entry, conditionKey)
|
||||
if (actualValue !== expectedValue) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function hasVisibleItemsAfter(entries, currentIndex, entry) {
|
||||
for (let i = currentIndex + 1; i < entries.length; i++) {
|
||||
const [k, meta] = entries[i]
|
||||
if (!meta?.invisible && shouldShowItem(meta, entry)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.template-list-editor {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.entry-header {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.entry-header:hover {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.config-section {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.config-title {
|
||||
font-weight: 600;
|
||||
font-size: 1rem;
|
||||
color: var(--v-theme-primaryText);
|
||||
}
|
||||
|
||||
.config-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--v-theme-secondaryText);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.template-entry-body {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.config-row {
|
||||
margin: 0;
|
||||
align-items: center;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.config-row:hover {
|
||||
background-color: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.property-info {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.property-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--v-theme-primaryText);
|
||||
}
|
||||
|
||||
.property-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--v-theme-secondaryText);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.property-key {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.7;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.config-input {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.config-field {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.config-divider {
|
||||
border-color: rgba(0, 0, 0, 0.05);
|
||||
margin: 0px 16px;
|
||||
}
|
||||
|
||||
.nested-container {
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin: 12px 0;
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -65,6 +65,12 @@
|
||||
"fullscreen": "Fullscreen Edit",
|
||||
"editingTitle": "Editing Content"
|
||||
},
|
||||
"templateList": {
|
||||
"addEntry": "Add Entry",
|
||||
"empty": "No entries yet, pick a template to add",
|
||||
"missingTemplate": "Template not found, please remove and add again.",
|
||||
"unknownTemplate": "Template not specified"
|
||||
},
|
||||
"list": {
|
||||
"addItemPlaceholder": "Add new item, press Enter to confirm",
|
||||
"addButton": "Add",
|
||||
@@ -84,7 +90,6 @@
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"delete": "Delete",
|
||||
"copy": "Copy",
|
||||
"edit": "Edit",
|
||||
"copy": "Copy",
|
||||
"noData": "No data available"
|
||||
|
||||
@@ -65,6 +65,12 @@
|
||||
"fullscreen": "全屏编辑",
|
||||
"editingTitle": "编辑内容"
|
||||
},
|
||||
"templateList": {
|
||||
"addEntry": "添加条目",
|
||||
"empty": "暂无条目,请选择模板添加",
|
||||
"missingTemplate": "找不到对应模板,请删除后重新添加。",
|
||||
"unknownTemplate": "未指定模板"
|
||||
},
|
||||
"list": {
|
||||
"addItemPlaceholder": "添加新项,按回车确认添加",
|
||||
"addButton": "添加",
|
||||
|
||||
Reference in New Issue
Block a user