Files
ApiServer-Web-admin_dashboa…/src/views/system/SettingManage.vue
T
shiran 4180f73c53
Build and Deploy Vue3 / build (push) Successful in 1m27s
Build and Deploy Vue3 / deploy (push) Successful in 36s
feat(admin): 订单管理重构、设置管理增强、短信签名模板管理及通知渠道优化
- 订单列表重构为卡片式布局并新增筛选功能

- 设置管理支持struct/struct_list类型配置

- 新增短信签名和模板独立管理页面

- 通知渠道新增短信渠道配置

- 产品参数管理优化

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-15 18:27:23 +08:00

4347 lines
142 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="setting-manage-container">
<!-- 顶部操作栏 -->
<div class="top-action-bar">
<div class="action-left">
<el-input
v-model="queryParams.key"
placeholder="搜索配置名称..."
clearable
prefix-icon="Search"
class="search-input"
@keyup.enter="handleQuery"
@clear="handleQuery"
/>
<el-button type="primary" @click="handleQuery">
<el-icon><Search /></el-icon>查询
</el-button>
</div>
<div class="action-right">
<el-button type="primary" @click="handleAddGroup">
<el-icon><FolderAdd /></el-icon>新增配置组
</el-button>
<el-button type="primary" @click="handleAddSetting">
<el-icon><Plus /></el-icon>新增配置
</el-button>
<el-button type="success" @click="handleBatchImport">
<el-icon><UploadFilled /></el-icon>导入配置
</el-button>
</div>
</div>
<!-- 配置组 Tab -->
<div class="tabs-wrapper">
<el-tabs
v-model="activeGroupId"
type="card"
class="group-tabs"
@tab-change="handleTabChange"
>
<el-tab-pane
v-for="group in allGroupList"
:key="group.id"
:name="String(group.id)"
>
<template #label>
<div class="tab-label">
<el-icon class="tab-icon"><Folder /></el-icon>
<span class="tab-name">{{ group.name }}</span>
<el-badge v-if="group._settingCount" :value="group._settingCount" :max="99" class="tab-badge" />
</div>
</template>
</el-tab-pane>
</el-tabs>
<!-- 当前配置组信息与操作 -->
<div class="group-info-bar" v-if="activeGroupId && currentGroup">
<div class="group-meta">
<div class="group-meta-item">
<el-icon class="meta-icon"><InfoFilled /></el-icon>
<span class="meta-label">备注</span>
<span class="meta-value">{{ currentGroup.note || '暂无备注' }}</span>
</div>
<div class="group-meta-item">
<el-icon class="meta-icon"><Clock /></el-icon>
<span class="meta-label">创建时间</span>
<span class="meta-value">{{ formatDate(currentGroup.CreatedAt) }}</span>
</div>
<div class="group-meta-item" v-if="currentGroup._settingCount !== undefined">
<el-icon class="meta-icon"><Document /></el-icon>
<span class="meta-label">配置数</span>
<span class="meta-value meta-count">{{ currentGroup._settingCount }}</span>
</div>
</div>
<div class="group-actions">
<el-button class="action-btn action-btn--edit" @click="handleEditGroup(currentGroup)">
<el-icon><Edit /></el-icon><span>编辑</span>
</el-button>
<el-button class="action-btn action-btn--add" @click="handleAddSettingToGroup(currentGroup)">
<el-icon><Plus /></el-icon><span>新增配置</span>
</el-button>
<el-button class="action-btn action-btn--copy" @click="handleCopyGroupSettings(currentGroup)">
<el-icon><CopyDocument /></el-icon><span>复制</span>
</el-button>
<el-button class="action-btn action-btn--import" @click="handleImportToGroup(currentGroup)">
<el-icon><UploadFilled /></el-icon><span>导入</span>
</el-button>
<el-button class="action-btn action-btn--delete" @click="handleDeleteGroup(currentGroup)">
<el-icon><Delete /></el-icon><span>删除</span>
</el-button>
</div>
</div>
</div>
<!-- 配置项卡片区域 -->
<div class="cards-section" v-loading="settingLoading">
<transition-group name="card-list" tag="div" class="cards-grid" v-if="settingList.length > 0">
<div
v-for="setting in settingList"
:key="setting.id"
class="setting-card"
:class="{ 'is-private': !setting.open }"
@click="handleEditSetting(setting)"
>
<!-- 卡片头部 -->
<div class="card-header">
<div class="card-title-row">
<div class="card-name-wrap">
<span class="card-name" :title="setting.name">{{ setting.name }}</span>
<el-icon class="copy-name-btn" @click.stop="copySettingName(setting.name)"><CopyDocument /></el-icon>
</div>
<el-tag :type="getTypeColor(setting.type)" size="small" effect="light" class="card-type-tag">
{{ setting.type }}
</el-tag>
</div>
<div class="card-status">
<el-tag :type="setting.open ? 'success' : 'info'" size="small" effect="plain" round>
{{ setting.open ? '公开' : '私有' }}
</el-tag>
</div>
</div>
<!-- 卡片内容 -->
<div class="card-body">
<div class="card-value">
<span v-if="setting.type === 'bool'" class="value-bool">
<el-icon :class="setting.value ? 'bool-true' : 'bool-false'">
<component :is="setting.value ? 'CircleCheck' : 'CircleClose'" />
</el-icon>
{{ setting.value ? '是' : '否' }}
</span>
<span v-else-if="setting.type === 'file'" class="value-file">
<el-icon><Picture /></el-icon>
文件ID: {{ truncateText(setting.value, 12) }}
</span>
<span v-else-if="setting.type === 'file_list'" class="value-file-list">
<el-icon><Folder /></el-icon>
{{ getFileList(setting.value).length }} 个文件
</span>
<span v-else-if="setting.type === 'string_list'" class="value-string-list">
<el-icon><Document /></el-icon>
{{ getStringList(setting.value).length }}
</span>
<span v-else-if="setting.type === 'struct'" class="value-struct">
<el-icon><Connection /></el-icon>
复合结构 ({{ getStructFieldCount(setting) }} 个字段)
</span>
<span v-else-if="setting.type === 'struct_list'" class="value-struct">
<el-icon><Connection /></el-icon>
结构列表 ({{ getStructListCardCount(setting) }} )
</span>
<span v-else class="value-text" :title="setting.value">
{{ truncateText(setting.value, 40) || '-' }}
</span>
</div>
</div>
<!-- 卡片底部 -->
<div class="card-footer">
<span class="card-note" :title="setting.note">{{ setting.note || '暂无备注' }}</span>
<div class="card-actions" @click.stop>
<el-button type="primary" link size="small" @click.stop="handleEditSetting(setting)">
<el-icon><Edit /></el-icon>
</el-button>
<el-button type="danger" link size="small" @click.stop="handleDeleteSetting(setting)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
</div>
</transition-group>
<!-- 空状态 -->
<div v-else-if="!settingLoading" class="empty-state">
<el-empty :description="activeGroupId ? '该配置组暂无配置项' : '请选择一个配置组'">
<el-button v-if="activeGroupId" type="primary" @click="handleAddSettingToGroup(currentGroup)">
<el-icon><Plus /></el-icon>新增配置
</el-button>
</el-empty>
</div>
<!-- 分页 -->
<div class="pagination-wrapper" v-if="settingTotal > 0">
<el-pagination
v-model:current-page="settingPagination.page"
v-model:page-size="settingPagination.pageSize"
:page-sizes="[12, 24, 36, 48]"
:total="settingTotal"
layout="total, sizes, prev, pager, next, jumper"
background
@size-change="handlePageSizeChange"
@current-change="handlePageChange"
/>
</div>
</div>
<!-- 配置组表单对话框 -->
<el-dialog
v-model="groupDialogVisible"
:title="groupDialogTitle"
width="500px"
destroy-on-close
class="dialog-scrollable"
>
<el-form
ref="groupFormRef"
:model="groupForm"
:rules="groupRules"
label-width="100px"
>
<el-form-item label="名称" prop="name">
<el-input v-model="groupForm.name" placeholder="请输入配置组名称" />
</el-form-item>
<el-form-item label="备注" prop="note">
<el-input
v-model="groupForm.note"
type="textarea"
:rows="3"
placeholder="请输入备注信息"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="groupDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitGroupForm">确定</el-button>
</template>
</el-dialog>
<!-- 配置表单对话框 -->
<el-dialog
v-model="settingDialogVisible"
:title="settingDialogTitle"
width="1000px"
destroy-on-close
class="dialog-scrollable"
>
<el-form
ref="settingFormRef"
:model="settingForm"
:rules="settingRules"
label-width="120px"
>
<el-form-item label="配置组" prop="settingGroupID">
<el-select v-model="settingForm.settingGroupID" placeholder="请选择配置组" style="width: 100%">
<el-option
v-for="group in allGroupList"
:key="group.id"
:label="group.name"
:value="group.id"
/>
</el-select>
</el-form-item>
<el-form-item label="名称" prop="name">
<el-input v-model="settingForm.name" placeholder="请输入配置名称" />
</el-form-item>
<el-form-item label="类型" prop="type">
<el-select v-model="settingForm.type" placeholder="请选择类型" style="width: 100%" @change="handleTypeChange">
<el-option label="字符串 (string)" value="string" />
<el-option label="长文本 (text)" value="text" />
<el-option label="整数 (int)" value="int" />
<el-option label="浮点数 (float)" value="float" />
<el-option label="布尔值 (bool)" value="bool" />
<el-option label="文件 (file)" value="file" />
<el-option label="多文件 (file_list)" value="file_list" />
<el-option label="字符串列表 (string_list)" value="string_list" />
<el-option label="复合结构 (struct)" value="struct" />
<el-option label="结构列表 (struct_list)" value="struct_list" />
</el-select>
</el-form-item>
<el-form-item label="值" prop="value">
<div v-if="settingForm.type === 'string' || settingForm.type === 'text'" style="width: 100%">
<div v-if="isJsonValue" class="json-toolbar">
<el-tag size="small" type="success">JSON</el-tag>
<el-button size="small" @click="formatJson">格式化</el-button>
<el-button size="small" @click="compressJson">压缩</el-button>
</div>
<el-input
v-model="settingForm.value"
type="textarea"
:rows="settingForm.type === 'text' ? 8 : (isJsonValue ? 12 : 3)"
placeholder="请输入配置值"
:input-style="isJsonValue ? { fontFamily: 'Consolas, Monaco, monospace', fontSize: '13px', lineHeight: '1.5' } : {}"
/>
</div>
<el-input-number
v-else-if="settingForm.type === 'int'"
v-model="settingForm.value"
:controls="false"
placeholder="请输入整数"
style="width: 100%"
/>
<el-input-number
v-else-if="settingForm.type === 'float'"
v-model="settingForm.value"
:controls="false"
:precision="2"
placeholder="请输入浮点数"
style="width: 100%"
/>
<el-switch
v-else-if="settingForm.type === 'bool'"
v-model="settingForm.value"
/>
<div v-else-if="settingForm.type === 'file'" class="file-upload-section">
<div class="file-info-display" v-if="settingForm.value && fileInfo">
<div class="file-item" :class="{ 'uploading': fileUploading && fileInfo.isLocal }">
<div class="file-preview" v-if="fileInfo.url || fileInfo.localUrl" @click="openImageSelector" style="cursor: pointer;">
<img :src="fileInfo.localUrl || processImageUrl(fileInfo.url)" :alt="fileInfo.realName" class="preview-image" @click.stop="previewImage(fileInfo.localUrl || fileInfo.url)" @error="handleImageError" />
<div v-if="fileUploading && fileInfo.isLocal" class="upload-overlay">
<el-icon class="is-loading"><Loading /></el-icon>
<span>上传中...</span>
</div>
</div>
<div class="file-preview" v-else @click="openImageSelector" style="cursor: pointer;">
<div class="file-placeholder">
<el-icon><Document /></el-icon>
</div>
</div>
<div class="file-details">
<span class="file-name" :title="fileInfo.realName || fileInfo.saveName">{{ truncateFileName(fileInfo.realName || fileInfo.saveName) }}</span>
<span class="file-id">文件ID: {{ settingForm.value }}</span>
<span class="file-size">{{ formatFileSize(fileInfo.size) }}</span>
</div>
<div class="file-actions">
<el-button type="primary" size="small" @click="openImageSelector" :icon="Picture">
修改图片
</el-button>
<el-button type="danger" size="small" @click="clearFile">删除</el-button>
</div>
</div>
</div>
<div v-else class="file-upload-options">
<div class="upload-methods">
<el-button
type="primary"
@click="openImageSelector"
:icon="Picture"
class="image-selector-btn"
>
从文件库选择图片
</el-button>
</div>
</div>
</div>
<div v-else-if="settingForm.type === 'file_list'" class="file-list-section">
<div class="editable-file-list">
<div class="file-list-header">
<span class="list-title">文件列表 ({{ getEditableFormFileList().length }} )</span>
<div class="header-actions">
<el-button size="small" @click="openImageSelectorForList" :icon="Picture">
从文件库选择
</el-button>
</div>
</div>
<div class="file-list-items">
<div
v-for="(fileItem, index) in getEditableFormFileList()"
:key="`form-file-${index}`"
class="file-list-item"
draggable="true"
@dragstart="handleFormFileDragStart($event, index)"
@dragover.prevent
@drop="handleFormFileDrop($event, index)"
>
<div class="drag-handle">
<el-icon><Rank /></el-icon>
</div>
<div class="file-preview">
<img
v-if="fileItem.url || fileItem.localUrl"
:src="fileItem.localUrl || processImageUrl(fileItem.url)"
:alt="fileItem.realName"
class="preview-image"
@click="previewImage(fileItem.localUrl || fileItem.url)"
@error="handleImageError"
/>
<div v-else class="file-placeholder">
<el-icon><Document /></el-icon>
</div>
<div v-if="fileItem.uploading" class="upload-overlay">
<el-icon class="is-loading"><Loading /></el-icon>
<span>上传中...</span>
</div>
</div>
<div class="file-info">
<div class="file-name" :title="fileItem.realName || fileItem.saveName">
{{ truncateFileName(fileItem.realName || fileItem.saveName, 30) }}
</div>
<div class="file-id">ID: {{ fileItem.id || '上传中' }}</div>
<div class="file-size">{{ formatFileSize(fileItem.size) }}</div>
</div>
<div class="file-actions">
<el-button type="text" size="small" @click="openImageSelectorForListItem(index)" :icon="Edit" title="替换文件" />
<el-button type="text" size="small" @click="removeFormEditableFileItem(index)" :icon="Delete" class="danger-btn" title="删除文件" />
</div>
</div>
</div>
</div>
</div>
<div v-else-if="settingForm.type === 'struct'" class="struct-section">
<!-- Schema 定义 -->
<div class="struct-schema-editor">
<div class="schema-header">
<span class="schema-title">
<el-icon><Setting /></el-icon>
结构定义 (Schema)
</span>
<el-button type="primary" size="small" @click="addStructSchemaField()">
<el-icon><Plus /></el-icon>添加字段
</el-button>
</div>
<div class="schema-fields" v-if="structSchema.length > 0">
<div v-for="(field, index) in structSchema" :key="index" class="schema-field-row">
<el-input v-model="field.key" placeholder="字段名" style="width: 160px" @change="syncStructValueFromSchema" />
<el-select v-model="field.type" placeholder="类型" style="width: 150px" @change="(val) => handleSchemaFieldTypeChange(field, val)">
<el-option label="字符串 (string)" value="string" />
<el-option label="文本 (text)" value="text" />
<el-option label="整数 (int)" value="int" />
<el-option label="浮点数 (float)" value="float" />
<el-option label="布尔值 (bool)" value="bool" />
<el-option label="文件 (file)" value="file" />
<el-option label="多文件 (file_list)" value="file_list" />
<el-option label="字符串列表 (string_list)" value="string_list" />
<el-option label="嵌套结构 (struct)" value="struct" />
<el-option label="结构列表 (struct_list)" value="struct_list" />
</el-select>
<el-button type="danger" :icon="Delete" circle size="small" @click="removeStructSchemaField(index)" />
<!-- 嵌套 struct 展开 -->
<div v-if="field.type === 'struct'" class="nested-schema">
<div class="nested-schema-header">
<span class="nested-label">{{ field.key }} 的子字段</span>
<el-button size="small" @click="addNestedSchemaField(field)">
<el-icon><Plus /></el-icon>添加
</el-button>
</div>
<div v-for="(sub, si) in (field.schema || [])" :key="si" class="schema-field-row nested">
<el-input v-model="sub.key" placeholder="子字段名" style="width: 140px" @change="syncStructValueFromSchema" />
<el-select v-model="sub.type" placeholder="类型" style="width: 150px">
<el-option label="字符串 (string)" value="string" />
<el-option label="文本 (text)" value="text" />
<el-option label="整数 (int)" value="int" />
<el-option label="浮点数 (float)" value="float" />
<el-option label="布尔值 (bool)" value="bool" />
<el-option label="文件 (file)" value="file" />
<el-option label="多文件 (file_list)" value="file_list" />
<el-option label="字符串列表 (string_list)" value="string_list" />
<el-option label="嵌套结构 (struct)" value="struct" />
<el-option label="结构列表 (struct_list)" value="struct_list" />
</el-select>
<el-button type="danger" :icon="Delete" circle size="small" @click="removeNestedSchemaField(field, si)" />
</div>
</div>
<!-- 嵌套 struct_list 展开 -->
<div v-if="field.type === 'struct_list'" class="nested-schema">
<div class="nested-schema-header">
<span class="nested-label">{{ field.key }} 的列表项子字段</span>
<el-button size="small" @click="addNestedSchemaField(field)">
<el-icon><Plus /></el-icon>添加
</el-button>
</div>
<div v-for="(sub, si) in (field.schema || [])" :key="si" class="schema-field-row nested">
<el-input v-model="sub.key" placeholder="子字段名" style="width: 140px" @change="syncStructValueFromSchema" />
<el-select v-model="sub.type" placeholder="类型" style="width: 150px">
<el-option label="字符串 (string)" value="string" />
<el-option label="文本 (text)" value="text" />
<el-option label="整数 (int)" value="int" />
<el-option label="浮点数 (float)" value="float" />
<el-option label="布尔值 (bool)" value="bool" />
<el-option label="文件 (file)" value="file" />
<el-option label="多文件 (file_list)" value="file_list" />
<el-option label="字符串列表 (string_list)" value="string_list" />
<el-option label="嵌套结构 (struct)" value="struct" />
<el-option label="结构列表 (struct_list)" value="struct_list" />
</el-select>
<el-button type="danger" :icon="Delete" circle size="small" @click="removeNestedSchemaField(field, si)" />
</div>
</div>
</div>
</div>
<div v-else class="schema-empty">
<span>暂无字段定义请点击上方添加字段</span>
</div>
</div>
<!-- 值编辑 -->
<div class="struct-value-editor" v-if="structSchema.length > 0">
<div class="value-header">
<span class="value-title">
<el-icon><EditPen /></el-icon>
配置值
</span>
<el-button size="small" @click="toggleStructRawJson">
{{ showStructRawJson ? '表单模式' : 'JSON 模式' }}
</el-button>
</div>
<div v-if="showStructRawJson" class="struct-raw-json">
<el-input v-model="structRawJson" type="textarea" :rows="8" placeholder="JSON 格式的配置值" :input-style="{ fontFamily: 'Consolas, Monaco, monospace', fontSize: '13px' }" @blur="syncStructValueFromJson" />
</div>
<div v-else class="struct-value-fields">
<div v-for="(field, index) in structSchema" :key="index" class="struct-value-row">
<span class="field-label">{{ field.key }}</span>
<el-tag size="small" :type="getStructFieldTypeColor(field.type)" style="margin-right: 8px">{{ field.type }}</el-tag>
<div class="field-input" v-if="field.type !== 'struct' && field.type !== 'struct_list'">
<el-input v-if="field.type === 'string'" v-model="structValue[field.key]" placeholder="请输入字符串" />
<el-input v-else-if="field.type === 'text'" v-model="structValue[field.key]" type="textarea" :rows="3" placeholder="请输入文本" />
<el-input-number v-else-if="field.type === 'int'" v-model="structValue[field.key]" :controls="false" style="width: 100%" />
<el-input-number v-else-if="field.type === 'float'" v-model="structValue[field.key]" :controls="false" :precision="4" style="width: 100%" />
<el-switch v-else-if="field.type === 'bool'" v-model="structValue[field.key]" />
<div v-else-if="field.type === 'file'" class="struct-file-picker">
<div class="file-thumb" @click="openStructFileSelector('struct', field.key)">
<img v-if="structFileUrls[field.key]" :src="structFileUrls[field.key]" @error="(e) => e.target.style.display='none'" />
<div v-else class="file-thumb-empty">
<el-icon><Picture /></el-icon>
</div>
</div>
<el-button v-if="structValue[field.key]" type="danger" link size="small" class="file-clear-btn" @click="structValue[field.key] = ''; delete structFileUrls[field.key]"><el-icon><Delete /></el-icon></el-button>
</div>
<div v-else-if="field.type === 'file_list'" class="struct-file-list-picker">
<div class="file-thumb-list">
<div v-for="(fid, fi) in getFileList(structValue[field.key])" :key="fi" class="file-thumb-item"
draggable="true"
@dragstart="startInlineListDrag($event, 'file_list', 'struct', field.key, null, -1, fi)"
@dragover.prevent="inlineListDragOver($event)"
@dragleave="inlineListDragLeave($event)"
@drop="dropInlineListItem($event, 'file_list', 'struct', field.key, null, -1, fi)"
>
<div class="file-thumb" @click="previewFile(fid)">
<img v-if="structFileUrls[field.key + '_' + fi]" :src="structFileUrls[field.key + '_' + fi]" @error="(e) => e.target.style.display='none'" />
<div v-else class="file-thumb-empty"><el-icon><Picture /></el-icon></div>
</div>
<el-button class="thumb-del-btn" type="danger" link size="small" @click="removeInlineListItem('file_list', 'struct', field.key, null, -1, fi)"><el-icon><Delete /></el-icon></el-button>
</div>
<div class="file-thumb file-thumb-add" @click="openStructFileListSelector('struct', field.key)">
<el-icon><Plus /></el-icon>
</div>
</div>
</div>
<div v-else-if="field.type === 'string_list'" class="struct-string-list">
<div class="string-list-items">
<div v-for="(sval, si) in ensureStringListArr('struct', field.key, null, -1)" :key="si" class="string-list-item"
draggable="true"
@dragstart="startInlineListDrag($event, 'string_list', 'struct', field.key, null, -1, si)"
@dragover.prevent="inlineListDragOver($event)"
@dragleave="inlineListDragLeave($event)"
@drop="dropInlineListItem($event, 'string_list', 'struct', field.key, null, -1, si)"
>
<el-icon class="string-drag-handle"><Rank /></el-icon>
<el-input v-model="structStringLists[getSlKey('struct', field.key, null, -1)][si]" size="small" @change="syncStringListBack('struct', field.key, null, -1)" />
<el-button type="danger" link size="small" @click="removeInlineListItem('string_list', 'struct', field.key, null, -1, si)"><el-icon><Delete /></el-icon></el-button>
</div>
</div>
<el-button size="small" type="primary" link @click="addInlineStringItem('struct', field.key, null, -1)"><el-icon><Plus /></el-icon> 添加</el-button>
</div>
</div>
<div v-else-if="field.type === 'struct_list'" class="struct-list-value">
<div class="struct-list-header">
<span class="struct-list-hint">列表项结构</span>
<el-tag size="small" type="info">{{ getNestedStructListItems(field.key).length }} </el-tag>
<el-button size="small" type="primary" link @click="addNestedStructListItem(field.key, field.schema)"><el-icon><Plus /></el-icon> 添加</el-button>
</div>
<div v-if="field.schema && field.schema.length > 0" class="nested-struct-list-cards">
<div v-for="(nItem, nIdx) in getNestedStructListItems(field.key)" :key="nIdx" class="nested-struct-list-card">
<div class="nested-card-header">
<span class="nested-card-index">#{{ nIdx + 1 }}</span>
<el-button type="danger" link size="small" @click="removeNestedStructListItem(field.key, nIdx)"><el-icon><Delete /></el-icon></el-button>
</div>
<div class="nested-card-body">
<div v-for="sub in field.schema" :key="sub.key" class="nested-card-field">
<span class="field-label sub">{{ sub.key }}</span>
<el-input v-if="sub.type === 'string'" v-model="nItem[sub.key]" size="small" placeholder="请输入" @change="syncNestedStructList(field.key)" />
<el-input v-else-if="sub.type === 'text'" v-model="nItem[sub.key]" type="textarea" :rows="2" size="small" placeholder="请输入" @change="syncNestedStructList(field.key)" />
<el-input-number v-else-if="sub.type === 'int'" v-model="nItem[sub.key]" :controls="false" size="small" style="width: 100%" @change="syncNestedStructList(field.key)" />
<el-input-number v-else-if="sub.type === 'float'" v-model="nItem[sub.key]" :controls="false" :precision="4" size="small" style="width: 100%" @change="syncNestedStructList(field.key)" />
<el-switch v-else-if="sub.type === 'bool'" v-model="nItem[sub.key]" @change="syncNestedStructList(field.key)" />
<el-input v-else v-model="nItem[sub.key]" size="small" placeholder="请输入" @change="syncNestedStructList(field.key)" />
</div>
</div>
</div>
</div>
<el-input v-else v-model="structValue[field.key]" type="textarea" :rows="4" placeholder='[{"key": "value"}, ...]' :input-style="{ fontFamily: 'Consolas, Monaco, monospace', fontSize: '13px' }" />
</div>
<div v-else class="nested-value-fields">
<div v-for="(sub, si) in (field.schema || [])" :key="si" class="struct-value-row nested">
<span class="field-label sub">{{ field.key }}.{{ sub.key }}</span>
<el-tag size="small" :type="getStructFieldTypeColor(sub.type)" style="margin-right: 8px">{{ sub.type }}</el-tag>
<div class="field-input">
<el-input v-if="sub.type === 'string'" v-model="getNestedValue(field.key)[sub.key]" placeholder="请输入字符串" />
<el-input v-else-if="sub.type === 'text'" v-model="getNestedValue(field.key)[sub.key]" type="textarea" :rows="3" placeholder="请输入文本" />
<el-input-number v-else-if="sub.type === 'int'" v-model="getNestedValue(field.key)[sub.key]" :controls="false" style="width: 100%" />
<el-input-number v-else-if="sub.type === 'float'" v-model="getNestedValue(field.key)[sub.key]" :controls="false" :precision="4" style="width: 100%" />
<el-switch v-else-if="sub.type === 'bool'" v-model="getNestedValue(field.key)[sub.key]" />
<div v-else-if="sub.type === 'file'" class="struct-file-picker">
<div class="file-thumb" @click="openStructFileSelector('struct-nested', field.key, sub.key)">
<img v-if="structFileUrls[field.key + '.' + sub.key]" :src="structFileUrls[field.key + '.' + sub.key]" @error="(e) => e.target.style.display='none'" />
<div v-else class="file-thumb-empty"><el-icon><Picture /></el-icon></div>
</div>
<el-button v-if="getNestedValue(field.key)[sub.key]" type="danger" link size="small" class="file-clear-btn" @click="getNestedValue(field.key)[sub.key] = ''; delete structFileUrls[field.key + '.' + sub.key]"><el-icon><Delete /></el-icon></el-button>
</div>
<div v-else-if="sub.type === 'file_list'" class="struct-file-list-picker">
<div class="file-thumb-list">
<div v-for="(fid, fi) in getFileList(getNestedValue(field.key)[sub.key])" :key="fi" class="file-thumb-item"
draggable="true"
@dragstart="startInlineListDrag($event, 'file_list', 'struct-nested', field.key, sub.key, -1, fi)"
@dragover.prevent="inlineListDragOver($event)"
@dragleave="inlineListDragLeave($event)"
@drop="dropInlineListItem($event, 'file_list', 'struct-nested', field.key, sub.key, -1, fi)"
>
<div class="file-thumb" @click="previewFile(fid)">
<img v-if="structFileUrls[field.key + '.' + sub.key + '_' + fi]" :src="structFileUrls[field.key + '.' + sub.key + '_' + fi]" @error="(e) => e.target.style.display='none'" />
<div v-else class="file-thumb-empty"><el-icon><Picture /></el-icon></div>
</div>
<el-button class="thumb-del-btn" type="danger" link size="small" @click="removeInlineListItem('file_list', 'struct-nested', field.key, sub.key, -1, fi)"><el-icon><Delete /></el-icon></el-button>
</div>
<div class="file-thumb file-thumb-add" @click="openStructFileListSelector('struct-nested', field.key, sub.key)">
<el-icon><Plus /></el-icon>
</div>
</div>
</div>
<div v-else-if="sub.type === 'string_list'" class="struct-string-list">
<div class="string-list-items">
<div v-for="(sval, si) in ensureStringListArr('struct-nested', field.key, sub.key, -1)" :key="si" class="string-list-item"
draggable="true"
@dragstart="startInlineListDrag($event, 'string_list', 'struct-nested', field.key, sub.key, -1, si)"
@dragover.prevent="inlineListDragOver($event)"
@dragleave="inlineListDragLeave($event)"
@drop="dropInlineListItem($event, 'string_list', 'struct-nested', field.key, sub.key, -1, si)"
>
<el-icon class="string-drag-handle"><Rank /></el-icon>
<el-input v-model="structStringLists[getSlKey('struct-nested', field.key, sub.key, -1)][si]" size="small" @change="syncStringListBack('struct-nested', field.key, sub.key, -1)" />
<el-button type="danger" link size="small" @click="removeInlineListItem('string_list', 'struct-nested', field.key, sub.key, -1, si)"><el-icon><Delete /></el-icon></el-button>
</div>
</div>
<el-button size="small" type="primary" link @click="addInlineStringItem('struct-nested', field.key, sub.key, -1)"><el-icon><Plus /></el-icon> 添加</el-button>
</div>
<el-input v-else-if="sub.type === 'struct_list'" v-model="getNestedValue(field.key)[sub.key]" type="textarea" :rows="3" placeholder='[{"key": "value"}, ...]' :input-style="{ fontFamily: 'Consolas, Monaco, monospace', fontSize: '12px' }" />
<div v-else-if="sub.type === 'struct' && sub.schema && sub.schema.length > 0" class="nested-sub-struct">
<div v-for="ss in sub.schema" :key="ss.key" class="nested-card-field">
<span class="field-label sub">{{ ss.key }}</span>
<el-input v-if="ss.type === 'string' || ss.type === 'text'" v-model="ensureDeepNested(field.key, sub.key)[ss.key]" size="small" :placeholder="ss.key" />
<el-input-number v-else-if="ss.type === 'int'" v-model="ensureDeepNested(field.key, sub.key)[ss.key]" :controls="false" size="small" style="width: 100%" />
<el-input-number v-else-if="ss.type === 'float'" v-model="ensureDeepNested(field.key, sub.key)[ss.key]" :controls="false" :precision="4" size="small" style="width: 100%" />
<el-switch v-else-if="ss.type === 'bool'" v-model="ensureDeepNested(field.key, sub.key)[ss.key]" />
<el-input v-else v-model="ensureDeepNested(field.key, sub.key)[ss.key]" size="small" :placeholder="ss.key" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else-if="settingForm.type === 'struct_list'" class="struct-section">
<!-- Schema 定义 struct 共用 -->
<div class="struct-schema-editor">
<div class="schema-header">
<span class="schema-title">
<el-icon><Setting /></el-icon>
列表项结构定义 (Schema)
</span>
<el-button type="primary" size="small" @click="addStructSchemaField()">
<el-icon><Plus /></el-icon>添加字段
</el-button>
</div>
<div class="schema-fields" v-if="structSchema.length > 0">
<div v-for="(field, index) in structSchema" :key="index" class="schema-field-row">
<el-input v-model="field.key" placeholder="字段名" style="width: 160px" @change="syncStructValueFromSchema" />
<el-select v-model="field.type" placeholder="类型" style="width: 150px" @change="(val) => handleSchemaFieldTypeChange(field, val)">
<el-option label="字符串 (string)" value="string" />
<el-option label="文本 (text)" value="text" />
<el-option label="整数 (int)" value="int" />
<el-option label="浮点数 (float)" value="float" />
<el-option label="布尔值 (bool)" value="bool" />
<el-option label="文件 (file)" value="file" />
<el-option label="多文件 (file_list)" value="file_list" />
<el-option label="字符串列表 (string_list)" value="string_list" />
<el-option label="嵌套结构 (struct)" value="struct" />
<el-option label="结构列表 (struct_list)" value="struct_list" />
</el-select>
<el-button type="danger" :icon="Delete" circle size="small" @click="removeStructSchemaField(index)" />
<!-- struct_list schema editor: nested struct -->
<div v-if="field.type === 'struct'" class="nested-schema">
<div class="nested-schema-header">
<span class="nested-label">{{ field.key }} 的子字段</span>
<el-button size="small" @click="addNestedSchemaField(field)">
<el-icon><Plus /></el-icon>添加
</el-button>
</div>
<div v-for="(sub, si) in (field.schema || [])" :key="si" class="schema-field-row nested">
<el-input v-model="sub.key" placeholder="子字段名" style="width: 140px" @change="syncStructValueFromSchema" />
<el-select v-model="sub.type" placeholder="类型" style="width: 150px">
<el-option label="字符串 (string)" value="string" />
<el-option label="文本 (text)" value="text" />
<el-option label="整数 (int)" value="int" />
<el-option label="浮点数 (float)" value="float" />
<el-option label="布尔值 (bool)" value="bool" />
<el-option label="文件 (file)" value="file" />
<el-option label="多文件 (file_list)" value="file_list" />
<el-option label="字符串列表 (string_list)" value="string_list" />
</el-select>
<el-button type="danger" :icon="Delete" circle size="small" @click="removeNestedSchemaField(field, si)" />
</div>
</div>
<!-- struct_list schema editor: nested struct_list -->
<div v-if="field.type === 'struct_list'" class="nested-schema">
<div class="nested-schema-header">
<span class="nested-label">{{ field.key }} 的列表项子字段</span>
<el-button size="small" @click="addNestedSchemaField(field)">
<el-icon><Plus /></el-icon>添加
</el-button>
</div>
<div v-for="(sub, si) in (field.schema || [])" :key="si" class="schema-field-row nested">
<el-input v-model="sub.key" placeholder="子字段名" style="width: 140px" @change="syncStructValueFromSchema" />
<el-select v-model="sub.type" placeholder="类型" style="width: 150px">
<el-option label="字符串 (string)" value="string" />
<el-option label="文本 (text)" value="text" />
<el-option label="整数 (int)" value="int" />
<el-option label="浮点数 (float)" value="float" />
<el-option label="布尔值 (bool)" value="bool" />
<el-option label="文件 (file)" value="file" />
<el-option label="多文件 (file_list)" value="file_list" />
<el-option label="字符串列表 (string_list)" value="string_list" />
</el-select>
<el-button type="danger" :icon="Delete" circle size="small" @click="removeNestedSchemaField(field, si)" />
</div>
</div>
</div>
</div>
<div v-else class="schema-empty">
<span>暂无字段定义请点击上方添加字段定义每个列表项的结构</span>
</div>
</div>
<!-- 值编辑 -->
<div class="struct-value-editor" v-if="structSchema.length > 0">
<div class="value-header">
<span class="value-title">
<el-icon><EditPen /></el-icon>
配置值
</span>
<div class="value-header-actions">
<el-tag size="small" type="info">{{ structListItems.length }} </el-tag>
<el-button size="small" @click="toggleStructListRawMode">
{{ structListRawMode ? '表单模式' : 'JSON 模式' }}
</el-button>
<el-button type="primary" size="small" @click="addStructListItem">
<el-icon><Plus /></el-icon>添加
</el-button>
</div>
</div>
<!-- JSON 模式 -->
<div v-if="structListRawMode" class="struct-raw-json">
<el-input v-model="structListRawJson" type="textarea" :rows="10" placeholder='[{"field": "value"}, ...]' :input-style="{ fontFamily: 'Consolas, Monaco, monospace', fontSize: '13px', lineHeight: '1.5' }" @blur="syncStructListFromJson" />
<div class="struct-list-actions">
<el-button size="small" @click="formatStructListJson">格式化</el-button>
</div>
</div>
<!-- 表单模式 -->
<div v-else class="struct-list-cards">
<div v-if="structListItems.length === 0" class="schema-empty">
<span>暂无数据项点击上方添加按钮创建</span>
</div>
<div
v-for="(item, itemIndex) in structListItems"
:key="item._id"
class="struct-list-card"
:class="{ 'is-collapsed': item._collapsed }"
draggable="true"
@dragstart="handleStructListDragStart($event, itemIndex)"
@dragover.prevent="handleStructListDragOver($event, itemIndex)"
@dragleave="handleStructListDragLeave($event)"
@drop="handleStructListDrop($event, itemIndex)"
>
<div class="struct-list-card-header" @click="toggleStructListItemCollapse(itemIndex)">
<div class="card-header-left">
<div class="drag-handle" @click.stop @mousedown.stop>
<el-icon><Rank /></el-icon>
</div>
<el-icon class="collapse-arrow" :class="{ rotated: !item._collapsed }"><ArrowRight /></el-icon>
<span class="card-index">#{{ itemIndex + 1 }}</span>
<span class="card-summary">{{ getStructListItemSummary(item) }}</span>
</div>
<div class="card-header-right" @click.stop>
<el-button type="danger" link size="small" @click="removeStructListItem(itemIndex)">
<el-icon><Delete /></el-icon>
</el-button>
</div>
</div>
<div class="struct-list-card-body" v-show="!item._collapsed">
<div v-for="field in structSchema" :key="field.key" class="struct-list-field-row">
<span class="field-label">{{ field.key }}</span>
<el-tag size="small" :type="getStructFieldTypeColor(field.type)">{{ field.type }}</el-tag>
<div class="field-input">
<el-input v-if="field.type === 'string'" v-model="item[field.key]" placeholder="请输入" size="small" />
<el-input v-else-if="field.type === 'text'" v-model="item[field.key]" type="textarea" :rows="2" placeholder="请输入" size="small" />
<el-input-number v-else-if="field.type === 'int'" v-model="item[field.key]" :controls="false" size="small" style="width: 100%" />
<el-input-number v-else-if="field.type === 'float'" v-model="item[field.key]" :controls="false" :precision="4" size="small" style="width: 100%" />
<el-switch v-else-if="field.type === 'bool'" v-model="item[field.key]" />
<div v-else-if="field.type === 'file'" class="struct-file-picker">
<div class="file-thumb small" @click="openStructFileSelector('struct-list', field.key, null, itemIndex)">
<img v-if="structFileUrls['list_' + itemIndex + '_' + field.key]" :src="structFileUrls['list_' + itemIndex + '_' + field.key]" @error="(e) => e.target.style.display='none'" />
<div v-else class="file-thumb-empty"><el-icon><Picture /></el-icon></div>
</div>
<el-button v-if="item[field.key]" type="danger" link size="small" class="file-clear-btn" @click="item[field.key] = ''; delete structFileUrls['list_' + itemIndex + '_' + field.key]"><el-icon><Delete /></el-icon></el-button>
</div>
<div v-else-if="field.type === 'file_list'" class="struct-file-list-picker">
<div class="file-thumb-list">
<div v-for="(fid, fi) in getFileList(item[field.key])" :key="fi" class="file-thumb-item"
draggable="true"
@dragstart="startInlineListDrag($event, 'file_list', 'struct-list', field.key, null, itemIndex, fi)"
@dragover.prevent="inlineListDragOver($event)"
@dragleave="inlineListDragLeave($event)"
@drop="dropInlineListItem($event, 'file_list', 'struct-list', field.key, null, itemIndex, fi)"
>
<div class="file-thumb small" @click="previewFile(fid)">
<img v-if="structFileUrls['list_' + itemIndex + '_' + field.key + '_' + fi]" :src="structFileUrls['list_' + itemIndex + '_' + field.key + '_' + fi]" @error="(e) => e.target.style.display='none'" />
<div v-else class="file-thumb-empty"><el-icon><Picture /></el-icon></div>
</div>
<el-button class="thumb-del-btn" type="danger" link size="small" @click="removeInlineListItem('file_list', 'struct-list', field.key, null, itemIndex, fi)"><el-icon><Delete /></el-icon></el-button>
</div>
<div class="file-thumb small file-thumb-add" @click="openStructFileListSelector('struct-list', field.key, null, itemIndex)">
<el-icon><Plus /></el-icon>
</div>
</div>
</div>
<div v-else-if="field.type === 'string_list'" class="struct-string-list">
<div class="string-list-items">
<div v-for="(sval, si) in ensureStringListArr('struct-list', field.key, null, itemIndex)" :key="si" class="string-list-item"
draggable="true"
@dragstart="startInlineListDrag($event, 'string_list', 'struct-list', field.key, null, itemIndex, si)"
@dragover.prevent="inlineListDragOver($event)"
@dragleave="inlineListDragLeave($event)"
@drop="dropInlineListItem($event, 'string_list', 'struct-list', field.key, null, itemIndex, si)"
>
<el-icon class="string-drag-handle"><Rank /></el-icon>
<el-input v-model="structStringLists[getSlKey('struct-list', field.key, null, itemIndex)][si]" size="small" @change="syncStringListBack('struct-list', field.key, null, itemIndex)" />
<el-button type="danger" link size="small" @click="removeInlineListItem('string_list', 'struct-list', field.key, null, itemIndex, si)"><el-icon><Delete /></el-icon></el-button>
</div>
</div>
<el-button size="small" type="primary" link @click="addInlineStringItem('struct-list', field.key, null, itemIndex)"><el-icon><Plus /></el-icon> 添加</el-button>
</div>
<el-input v-else v-model="item[field.key]" placeholder="请输入" size="small" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else-if="settingForm.type === 'string_list'" class="string-list-section">
<div class="editable-string-list">
<div class="string-list-header">
<span class="list-title">字符串列表 ({{ getEditableFormStringList().length }} )</span>
<el-button type="primary" size="small" @click="addFormEditableStringItem">添加项目</el-button>
</div>
<div class="string-list-items">
<div
v-for="(item, index) in getEditableFormStringList()"
:key="`form-editable-${index}`"
class="string-list-item"
draggable="true"
@dragstart="handleFormDragStart($event, index)"
@dragover.prevent
@drop="handleFormDrop($event, index)"
>
<div class="drag-handle">
<el-icon><Rank /></el-icon>
</div>
<div class="item-content">
<el-input
v-if="item.editing"
v-model="item.value"
size="small"
@blur="finishFormEditItem(index)"
@keyup.enter="finishFormEditItem(index)"
ref="formEditInput"
@mounted="focusFormEditInput"
/>
<span v-else class="item-text" @dblclick="startFormEditItem(index)">
{{ truncateFileName(item.value, 40) }}
</span>
</div>
<div class="item-actions">
<el-button type="text" size="small" @click="startFormEditItem(index)" :icon="Edit" />
<el-button type="text" size="small" @click="removeFormEditableStringItem(index)" :icon="Delete" class="danger-btn" />
</div>
</div>
</div>
</div>
</div>
<el-input
v-else
v-model="settingForm.value"
placeholder="请输入配置值"
/>
</el-form-item>
<el-form-item label="是否开放访问">
<el-switch v-model="settingForm.open" />
<span style="margin-left: 10px; color: #909399; font-size: 12px;">
开启后允许公开访问
</span>
</el-form-item>
<el-form-item label="备注" prop="note">
<el-input
v-model="settingForm.note"
type="textarea"
:rows="3"
placeholder="请输入备注信息"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="settingDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitSettingForm">确定</el-button>
</template>
</el-dialog>
<!-- 一键导入配置对话框 -->
<el-dialog
v-model="batchImportDialogVisible"
title="一键导入配置"
width="900px"
destroy-on-close
class="dialog-scrollable"
:close-on-click-modal="!batchImportLoading"
:close-on-press-escape="!batchImportLoading"
:show-close="!batchImportLoading"
>
<el-alert
title="使用说明"
type="info"
:closable="false"
show-icon
style="margin-bottom: 16px"
>
<template #default>
<div style="line-height: 1.8">
粘贴通过一键复制导出的内容即可自动导入系统会自动识别配置组名称不存在则自动创建<br />
格式示例<br />
<code>[配置组] 移动端-全局配置</code><br />
<code>| 配置名 | 类型 | 默认值 | 说明 |</code><br />
<code>|--------|------|--------|------|</code><br />
<code>| `移动端_主题主色` | `string` | `#2B7EFB` | 按钮链接选中态主色 |</code>
</div>
</template>
</el-alert>
<div v-if="batchImportLoading" class="import-progress-panel">
<div class="progress-header">
<el-icon class="is-loading" :size="20" color="#409eff"><Loading /></el-icon>
<span style="font-weight: 600; font-size: 15px; color: #303133">正在导入...</span>
</div>
<div class="progress-info">
<div v-if="batchImportStatusText" style="margin-bottom: 12px; color: #606266; font-size: 13px;">
{{ batchImportStatusText }}
</div>
<el-progress
:percentage="batchImportTotal > 0 ? Math.round(batchImportProgress / batchImportTotal * 100) : 0"
:stroke-width="18"
:text-inside="true"
striped
striped-flow
/>
<div style="margin-top: 8px; color: #909399; font-size: 12px; text-align: center;">
{{ batchImportProgress }} / {{ batchImportTotal }}
</div>
</div>
</div>
<template v-if="!batchImportLoading">
<el-form label-width="100px">
<el-form-item label="配置组">
<div v-if="batchImportGroupName" style="display: flex; align-items: center; gap: 8px; width: 100%;">
<el-tag :type="batchImportGroupExists ? 'success' : 'warning'" size="large" style="font-size: 14px;">
<el-icon style="margin-right: 4px;"><Folder /></el-icon>
{{ batchImportGroupName }}
</el-tag>
<span v-if="batchImportGroupExists" style="color: #67c23a; font-size: 12px;">已存在将导入到此配置组</span>
<span v-else style="color: #e6a23c; font-size: 12px;">不存在将自动创建后导入</span>
</div>
<span v-else style="color: #c0c4cc; font-size: 13px;">粘贴内容后自动识别首行 <code>[配置组] 名称</code></span>
</el-form-item>
<el-form-item label="默认公开">
<el-switch v-model="batchImportOpen" />
<span style="margin-left: 8px; color: #909399; font-size: 13px">导入的配置项是否默认对外公开</span>
</el-form-item>
<el-form-item label="导入内容">
<el-input
v-model="batchImportText"
type="textarea"
:rows="10"
placeholder="粘贴通过「一键复制」导出的内容..."
style="font-family: monospace"
/>
</el-form-item>
</el-form>
<div v-if="batchImportParsed.length > 0" style="margin-top: 8px">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px">
<span style="font-weight: 600; font-size: 14px; color: #303133">
解析预览 {{ batchImportParsed.length }}
</span>
<el-button type="primary" link @click="parseBatchImportText">
<el-icon><Search /></el-icon>重新解析
</el-button>
</div>
<el-table :data="batchImportParsed" border size="small" max-height="300">
<el-table-column type="index" label="#" width="50" />
<el-table-column prop="name" label="配置名" min-width="180">
<template #default="{ row }">
<span :style="{ color: row._duplicate ? '#F56C6C' : '' }">
{{ row.name }}
<el-tag v-if="row._duplicate" type="danger" size="small" style="margin-left: 4px">重复</el-tag>
</span>
</template>
</el-table-column>
<el-table-column prop="type" label="类型" width="120">
<template #default="{ row }">
<el-tag size="small">{{ row.type }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="value" label="默认值" min-width="160">
<template #default="{ row }">
<span class="text-value">{{ row.value }}</span>
</template>
</el-table-column>
<el-table-column prop="note" label="说明" min-width="200" />
<el-table-column label="操作" width="70" fixed="right">
<template #default="{ $index }">
<el-button link type="danger" size="small" @click="batchImportParsed.splice($index, 1)">移除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</template>
<template #footer>
<el-button @click="batchImportDialogVisible = false" :disabled="batchImportLoading">取消</el-button>
<el-button type="primary" @click="parseBatchImportText" :disabled="!batchImportText.trim() || batchImportLoading">
解析预览
</el-button>
<el-button
type="success"
:loading="batchImportLoading"
:disabled="batchImportParsed.length === 0 || !batchImportGroupName"
@click="submitBatchImport"
>
确认导入 ({{ batchImportParsed.length }})
</el-button>
</template>
</el-dialog>
<!-- 图片查看器 -->
<el-dialog v-model="imageViewerVisible" width="auto" destroy-on-close>
<img :src="currentViewImage" style="max-width: 100%; max-height: 80vh;" />
</el-dialog>
<!-- 图片选择器 -->
<ImageSelector
v-model="imageSelectorVisible"
:current-file-id="currentImageSelectorFileId"
:multiple="imageSelectorMode === 'list' || imageSelectorMode === 'struct-file-list'"
@confirm="handleImageSelectorConfirm"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, watch, nextTick, computed } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Plus, Delete, UploadFilled, Folder, Document, Loading, Picture, Edit, Rank, FolderAdd, CopyDocument, CircleCheck, CircleClose, InfoFilled, Clock, Setting, EditPen, Connection, ArrowRight, View } from '@element-plus/icons-vue'
import {
getSettingGroupList,
getSettingGroupInfo,
createSettingGroup,
updateSettingGroup,
deleteSettingGroup,
getSettingList,
getSettingInfo,
createSetting,
updateSetting,
setSettingOpen,
deleteSetting
} from '@/api/admin/setting'
import { uploadFile, getFileDetail, downloadFile } from '@/api/admin/file'
import ImageSelector from '@/components/admin/ImageSelector.vue'
const route = useRoute()
// ==================== 配置组 Tab 相关 ====================
const activeGroupId = ref('')
const allGroupList = ref([])
const currentGroup = computed(() => {
return allGroupList.value.find(g => String(g.id) === activeGroupId.value) || null
})
// ==================== 配置项列表 ====================
const settingLoading = ref(false)
const settingList = ref([])
const settingTotal = ref(0)
const settingPagination = reactive({
page: 1,
pageSize: 12
})
// 查询参数
const queryParams = reactive({
key: ''
})
// ==================== 配置组表单 ====================
const groupForm = reactive({
id: undefined,
name: '',
note: ''
})
const groupRules = {
name: [
{ required: true, message: '请输入配置组名称', trigger: 'blur' }
]
}
const groupLoading = ref(false)
const groupDialogVisible = ref(false)
const groupDialogTitle = ref('新增配置组')
const groupFormRef = ref(null)
// ==================== 配置表单 ====================
const settingForm = reactive({
id: undefined,
name: '',
value: '',
type: 'string',
settingGroupID: undefined,
open: false,
note: ''
})
const settingRules = {
name: [
{ required: true, message: '请输入配置名称', trigger: 'blur' }
],
type: [
{ required: true, message: '请选择配置类型', trigger: 'change' }
],
settingGroupID: [
{ required: true, message: '请选择配置组', trigger: 'change' }
]
}
const settingDialogVisible = ref(false)
const settingDialogTitle = ref('新增配置')
const settingFormRef = ref(null)
const fileInfo = ref(null)
const fileUploading = ref(false)
const fileListInfo = ref([])
const newStringItem = ref('')
const imageViewerVisible = ref(false)
const currentViewImage = ref('')
const imageSelectorVisible = ref(false)
const currentImageSelectorFileId = ref('')
const imageSelectorMode = ref('single')
// 表单相关可编辑列表
const editableFormStringList = ref([])
const editableFormFileList = ref([])
const formDraggedIndex = ref(-1)
const formFileDraggedIndex = ref(-1)
// ==================== 批量导入 ====================
const batchImportDialogVisible = ref(false)
const batchImportText = ref('')
const batchImportParsed = ref([])
const batchImportGroupId = ref(undefined)
const batchImportGroupName = ref('')
const batchImportGroupExists = ref(false)
const batchImportOpen = ref(true)
const batchImportLoading = ref(false)
const batchImportProgress = ref(0)
const batchImportTotal = ref(0)
const batchImportStatusText = ref('')
// ==================== Struct 相关 ====================
const structSchema = ref([])
const structValue = reactive({})
const showStructRawJson = ref(false)
const structRawJson = ref('')
const addStructSchemaField = () => {
structSchema.value.push({ key: '', type: 'string', schema: [] })
}
const removeStructSchemaField = (index) => {
const field = structSchema.value[index]
if (field.key && structValue[field.key] !== undefined) {
delete structValue[field.key]
}
structSchema.value.splice(index, 1)
}
const handleSchemaFieldTypeChange = (field, newType) => {
if (newType === 'struct') {
if (!field.schema) field.schema = []
if (field.key) structValue[field.key] = {}
} else if (newType === 'struct_list') {
field.schema = []
if (field.key) structValue[field.key] = '[]'
} else {
field.schema = []
if (field.key) {
if (newType === 'bool') structValue[field.key] = false
else if (newType === 'int' || newType === 'float') structValue[field.key] = 0
else structValue[field.key] = ''
}
}
}
const STRUCT_TYPE_LABELS = {
string: '字符串', text: '文本', int: '整数', float: '浮点数',
bool: '布尔值', file: '文件', file_list: '多文件', string_list: '字符串列表',
struct: '嵌套结构', struct_list: '结构列表'
}
const addNestedSchemaField = (parentField) => {
if (!parentField.schema) parentField.schema = []
parentField.schema.push({ key: '', type: 'string' })
}
const removeNestedSchemaField = (parentField, index) => {
const sub = parentField.schema[index]
if (parentField.key && sub.key && structValue[parentField.key]) {
delete structValue[parentField.key][sub.key]
}
parentField.schema.splice(index, 1)
}
const syncStructValueFromSchema = () => {
structSchema.value.forEach(field => {
if (!field.key) return
if (field.type === 'struct') {
if (!structValue[field.key] || typeof structValue[field.key] !== 'object') {
structValue[field.key] = {}
}
} else if (structValue[field.key] === undefined) {
if (field.type === 'bool') structValue[field.key] = false
else if (field.type === 'int' || field.type === 'float') structValue[field.key] = 0
else structValue[field.key] = ''
}
})
}
const getStructFieldDefaultValue = (type) => {
if (type === 'bool') return false
if (type === 'int' || type === 'float') return 0
if (type === 'struct') return {}
if (type === 'struct_list') return '[]'
return ''
}
// ==================== Struct List 相关 ====================
const structListRawJson = ref('[]')
const structListRawMode = ref(false)
const structListItems = ref([])
const structListDragIndex = ref(-1)
let structListIdSeq = 0
const toggleStructListRawMode = () => {
if (!structListRawMode.value) {
structListRawJson.value = JSON.stringify(
structListItems.value.map(item => {
const clean = { ...item }
delete clean._id
delete clean._collapsed
return clean
}), null, 2
)
}
structListRawMode.value = !structListRawMode.value
}
const syncStructListFromJson = () => {
try {
const arr = JSON.parse(structListRawJson.value)
if (!Array.isArray(arr)) { ElMessage.warning('值必须是 JSON 数组'); return }
structListItems.value = arr.map(item => ({ ...item, _id: ++structListIdSeq, _collapsed: false }))
} catch {
ElMessage.warning('JSON 格式不合法')
}
}
const formatStructListJson = () => {
try {
const parsed = JSON.parse(structListRawJson.value)
structListRawJson.value = JSON.stringify(parsed, null, 2)
} catch {
ElMessage.warning('JSON 格式不合法')
}
}
const addStructListItem = () => {
const newItem = { _id: ++structListIdSeq, _collapsed: false }
structSchema.value.forEach(field => {
if (!field.key) return
newItem[field.key] = getStructFieldDefaultValue(field.type)
})
structListItems.value.push(newItem)
}
const removeStructListItem = (index) => {
structListItems.value.splice(index, 1)
}
const toggleStructListItemCollapse = (index) => {
structListItems.value[index]._collapsed = !structListItems.value[index]._collapsed
}
const getStructListItemSummary = (item) => {
const parts = []
for (const field of structSchema.value) {
if (!field.key || field.type === 'struct' || field.type === 'struct_list') continue
const val = item[field.key]
if (val !== undefined && val !== '' && val !== null) {
const str = String(val)
parts.push(str.length > 20 ? str.slice(0, 20) + '...' : str)
}
if (parts.length >= 3) break
}
return parts.length > 0 ? parts.join(' | ') : '(空)'
}
const handleStructListDragStart = (event, index) => {
structListDragIndex.value = index
event.dataTransfer.effectAllowed = 'move'
event.target.style.opacity = '0.5'
}
const handleStructListDragOver = (event, index) => {
event.dataTransfer.dropEffect = 'move'
const el = event.currentTarget
if (el) el.classList.add('drag-over')
}
const handleStructListDragLeave = (event) => {
const el = event.currentTarget
if (el) el.classList.remove('drag-over')
}
const handleStructListDrop = (event, dropIndex) => {
event.preventDefault()
const el = event.currentTarget
if (el) el.classList.remove('drag-over')
const dragIndex = structListDragIndex.value
if (dragIndex === dropIndex || dragIndex < 0) return
const items = [...structListItems.value]
const [dragged] = items.splice(dragIndex, 1)
items.splice(dropIndex, 0, dragged)
structListItems.value = items
structListDragIndex.value = -1
event.target.style.opacity = '1'
}
// inline list helpers for file_list / string_list within struct
const inlineListDragIndex = ref(-1)
const structStringLists = reactive({})
const getSlKey = (source, fieldKey, subKey, itemIndex) => {
if (source === 'struct') return 'sl_' + fieldKey
if (source === 'struct-nested') return 'sl_' + fieldKey + '.' + subKey
if (source === 'struct-list') return 'sl_list_' + itemIndex + '_' + fieldKey
return 'sl_' + fieldKey
}
const ensureStringListArr = (source, fieldKey, subKey, itemIndex) => {
const key = getSlKey(source, fieldKey, subKey, itemIndex)
if (!structStringLists[key]) {
const raw = getInlineListValue(source, fieldKey, subKey, itemIndex)
structStringLists[key] = raw ? (Array.isArray(raw) ? [...raw] : String(raw).split(',').filter(Boolean)) : []
}
return structStringLists[key]
}
const syncStringListBack = (source, fieldKey, subKey, itemIndex) => {
const key = getSlKey(source, fieldKey, subKey, itemIndex)
const arr = structStringLists[key] || []
const val = arr.filter(Boolean).join(',')
setInlineListValue(source, fieldKey, subKey, itemIndex, val)
}
const getInlineListValue = (source, fieldKey, subKey, itemIndex) => {
if (source === 'struct') return structValue[fieldKey] || ''
if (source === 'struct-nested') return getNestedValue(fieldKey)[subKey] || ''
if (source === 'struct-list') return structListItems.value[itemIndex]?.[fieldKey] || ''
return ''
}
const setInlineListValue = (source, fieldKey, subKey, itemIndex, val) => {
if (source === 'struct') structValue[fieldKey] = val
else if (source === 'struct-nested') getNestedValue(fieldKey)[subKey] = val
else if (source === 'struct-list' && structListItems.value[itemIndex]) structListItems.value[itemIndex][fieldKey] = val
}
const getFileListPrefix = (source, fieldKey, subKey, itemIndex) => {
if (source === 'struct') return fieldKey + '_'
if (source === 'struct-nested') return fieldKey + '.' + subKey + '_'
if (source === 'struct-list') return 'list_' + itemIndex + '_' + fieldKey + '_'
return ''
}
const reindexFileUrls = (prefix, urlsByOldIndex, indexMap) => {
Object.keys(structFileUrls).forEach(k => { if (k.startsWith(prefix)) delete structFileUrls[k] })
for (const [newIdx, oldIdx] of Object.entries(indexMap)) {
if (urlsByOldIndex[oldIdx]) structFileUrls[prefix + newIdx] = urlsByOldIndex[oldIdx]
}
}
const removeInlineListItem = (listType, source, fieldKey, subKey, itemIndex, removeIndex) => {
if (listType === 'string_list') {
const key = getSlKey(source, fieldKey, subKey, itemIndex)
structStringLists[key].splice(removeIndex, 1)
syncStringListBack(source, fieldKey, subKey, itemIndex)
} else {
const prefix = getFileListPrefix(source, fieldKey, subKey, itemIndex)
const raw = getInlineListValue(source, fieldKey, subKey, itemIndex)
const arr = getFileList(raw)
const oldUrls = {}
arr.forEach((_, i) => { oldUrls[i] = structFileUrls[prefix + i] })
arr.splice(removeIndex, 1)
setInlineListValue(source, fieldKey, subKey, itemIndex, arr.join(','))
const indexMap = {}
let oi = 0
for (let ni = 0; ni < arr.length; ni++) {
if (oi === removeIndex) oi++
indexMap[ni] = oi
oi++
}
reindexFileUrls(prefix, oldUrls, indexMap)
}
}
const startInlineListDrag = (event, listType, source, fieldKey, subKey, itemIndex, dragIdx) => {
inlineListDragIndex.value = dragIdx
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', String(dragIdx))
const item = event.target.closest('.file-thumb-item, .string-list-item')
if (item) item.style.opacity = '0.5'
}
const inlineListDragOver = (event) => {
event.dataTransfer.dropEffect = 'move'
const el = event.currentTarget
if (el) el.classList.add('drag-over')
}
const inlineListDragLeave = (event) => {
const el = event.currentTarget
if (el) el.classList.remove('drag-over')
}
const dropInlineListItem = (event, listType, source, fieldKey, subKey, itemIndex, dropIdx) => {
event.preventDefault()
const el = event.currentTarget
if (el) el.classList.remove('drag-over')
const dragIdx = inlineListDragIndex.value
if (dragIdx === dropIdx || dragIdx < 0) { inlineListDragIndex.value = -1; return }
if (listType === 'string_list') {
const key = getSlKey(source, fieldKey, subKey, itemIndex)
const arr = structStringLists[key]
const [dragged] = arr.splice(dragIdx, 1)
arr.splice(dropIdx, 0, dragged)
syncStringListBack(source, fieldKey, subKey, itemIndex)
} else {
const prefix = getFileListPrefix(source, fieldKey, subKey, itemIndex)
const raw = getInlineListValue(source, fieldKey, subKey, itemIndex)
const arr = getFileList(raw)
const oldUrls = {}
arr.forEach((_, i) => { oldUrls[i] = structFileUrls[prefix + i] })
const [dragged] = arr.splice(dragIdx, 1)
arr.splice(dropIdx, 0, dragged)
setInlineListValue(source, fieldKey, subKey, itemIndex, arr.join(','))
// build index map: new position -> old position
const order = Array.from({ length: arr.length + 1 }, (_, i) => i)
const [d] = order.splice(dragIdx, 1)
order.splice(dropIdx, 0, d)
// order[newIdx] = oldIdx
const indexMap = {}
for (let i = 0; i < arr.length; i++) indexMap[i] = order[i]
reindexFileUrls(prefix, oldUrls, indexMap)
}
inlineListDragIndex.value = -1
}
const addInlineStringItem = (source, fieldKey, subKey, itemIndex) => {
const key = getSlKey(source, fieldKey, subKey, itemIndex)
if (!structStringLists[key]) structStringLists[key] = []
structStringLists[key].push('')
}
const resetStructStringLists = () => {
Object.keys(structStringLists).forEach(k => delete structStringLists[k])
Object.keys(nestedStructListCache).forEach(k => delete nestedStructListCache[k])
}
const buildStructListValue = () => {
return JSON.stringify(
structListItems.value.map(item => {
const clean = { ...item }
delete clean._id
delete clean._collapsed
return clean
})
)
}
const initStructListFromValue = (value, parsedValue = null) => {
Object.keys(structFileUrls).forEach(k => { if (k.startsWith('list_')) delete structFileUrls[k] })
Object.keys(structStringLists).forEach(k => { if (k.startsWith('sl_list_')) delete structStringLists[k] })
try {
const arr = typeof value === 'string' ? JSON.parse(value) : (Array.isArray(value) ? value : [])
structListItems.value = arr.map(item => ({ ...item, _id: ++structListIdSeq, _collapsed: true }))
} catch {
structListItems.value = []
}
if (parsedValue && Array.isArray(parsedValue)) {
const schema = structSchema.value || []
parsedValue.forEach((parsedItem, itemIndex) => {
if (!parsedItem || typeof parsedItem !== 'object') return
for (const field of schema) {
if (field.type === 'file' && parsedItem[field.key]) {
structFileUrls['list_' + itemIndex + '_' + field.key] = processImageUrl(parsedItem[field.key])
} else if (field.type === 'file_list' && parsedItem[field.key]) {
const urls = Array.isArray(parsedItem[field.key]) ? parsedItem[field.key] : []
urls.forEach((url, fi) => {
structFileUrls['list_' + itemIndex + '_' + field.key + '_' + fi] = processImageUrl(url)
})
}
}
})
}
structListRawMode.value = false
}
const getNestedValue = (parentKey) => {
if (!structValue[parentKey] || typeof structValue[parentKey] !== 'object') {
structValue[parentKey] = {}
}
return structValue[parentKey]
}
const ensureDeepNested = (parentKey, subKey) => {
const parent = getNestedValue(parentKey)
if (!parent[subKey] || typeof parent[subKey] !== 'object') {
parent[subKey] = {}
}
return parent[subKey]
}
const nestedStructListCache = reactive({})
const getNestedStructListItems = (fieldKey) => {
if (!nestedStructListCache[fieldKey]) {
try {
const raw = structValue[fieldKey]
const arr = typeof raw === 'string' ? JSON.parse(raw) : (Array.isArray(raw) ? raw : [])
nestedStructListCache[fieldKey] = Array.isArray(arr) ? arr : []
} catch {
nestedStructListCache[fieldKey] = []
}
}
return nestedStructListCache[fieldKey]
}
const syncNestedStructList = (fieldKey) => {
structValue[fieldKey] = JSON.stringify(nestedStructListCache[fieldKey] || [])
}
const addNestedStructListItem = (fieldKey, schema) => {
if (!nestedStructListCache[fieldKey]) nestedStructListCache[fieldKey] = []
const newItem = {}
if (schema) {
for (const s of schema) {
if (s.type === 'int' || s.type === 'float') newItem[s.key] = 0
else if (s.type === 'bool') newItem[s.key] = false
else newItem[s.key] = ''
}
}
nestedStructListCache[fieldKey].push(newItem)
syncNestedStructList(fieldKey)
}
const removeNestedStructListItem = (fieldKey, index) => {
if (nestedStructListCache[fieldKey]) {
nestedStructListCache[fieldKey].splice(index, 1)
syncNestedStructList(fieldKey)
}
}
const getFileList = (val) => {
if (!val) return []
return String(val).split(',').filter(Boolean)
}
const toggleStructRawJson = () => {
if (!showStructRawJson.value) {
structRawJson.value = JSON.stringify(structValue, null, 2)
}
showStructRawJson.value = !showStructRawJson.value
}
const syncStructValueFromJson = () => {
try {
const parsed = JSON.parse(structRawJson.value)
Object.keys(structValue).forEach(k => delete structValue[k])
Object.assign(structValue, parsed)
} catch (e) {
ElMessage.warning('JSON 格式不合法')
}
}
const buildSchemaJson = () => {
const schema = {}
structSchema.value.forEach(field => {
if (!field.key) return
if ((field.type === 'struct' || field.type === 'struct_list') && field.schema && field.schema.length > 0) {
const innerSchema = {}
field.schema.forEach(sub => {
if (sub.key) innerSchema[sub.key] = sub.type
})
schema[field.key] = { type: field.type, schema: innerSchema }
} else {
schema[field.key] = field.type
}
})
return JSON.stringify(schema)
}
const parseSchemaJson = (schemaStr) => {
try {
const schema = typeof schemaStr === 'string' ? JSON.parse(schemaStr) : schemaStr
const fields = []
Object.entries(schema).forEach(([key, val]) => {
if (typeof val === 'object' && (val.type === 'struct' || val.type === 'struct_list')) {
const subFields = []
if (val.schema) {
Object.entries(val.schema).forEach(([subKey, subType]) => {
subFields.push({ key: subKey, type: subType })
})
}
fields.push({ key, type: val.type, schema: subFields })
} else {
fields.push({ key, type: typeof val === 'string' ? val : 'string', schema: [] })
}
})
return fields
} catch {
return []
}
}
const getStructFieldTypeColor = (type) => {
const map = { string: 'primary', text: 'primary', int: 'success', float: 'warning', bool: 'info', file: 'danger', file_list: 'danger', string_list: '', struct: '', struct_list: 'warning' }
return map[type] || ''
}
const getStructListCount = (key) => {
const val = structValue[key]
if (!val) return 0
try {
const arr = typeof val === 'string' ? JSON.parse(val) : val
return Array.isArray(arr) ? arr.length : 0
} catch { return 0 }
}
const getStructFieldCount = (setting) => {
if (!setting.schema) return 0
try {
const schema = typeof setting.schema === 'string' ? JSON.parse(setting.schema) : setting.schema
return Object.keys(schema).length
} catch {
return 0
}
}
const getStructListCardCount = (setting) => {
if (!setting.value) return 0
try {
const arr = typeof setting.value === 'string' ? JSON.parse(setting.value) : setting.value
return Array.isArray(arr) ? arr.length : 0
} catch {
return 0
}
}
const initStructFromSetting = (data) => {
structSchema.value = parseSchemaJson(data.schema)
Object.keys(structValue).forEach(k => delete structValue[k])
Object.keys(structFileUrls).forEach(k => delete structFileUrls[k])
resetStructStringLists()
try {
const rawVal = typeof data.value === 'string' ? JSON.parse(data.value) : {}
Object.assign(structValue, rawVal)
} catch {
// value parse failed, leave empty
}
if (data.parsedValue && typeof data.parsedValue === 'object') {
const schema = structSchema.value || []
for (const field of schema) {
if (field.type === 'file' && data.parsedValue[field.key]) {
structFileUrls[field.key] = processImageUrl(data.parsedValue[field.key])
} else if (field.type === 'file_list' && data.parsedValue[field.key]) {
const urls = Array.isArray(data.parsedValue[field.key]) ? data.parsedValue[field.key] : []
urls.forEach((url, fi) => {
structFileUrls[field.key + '_' + fi] = processImageUrl(url)
})
} else if (field.type === 'struct' && field.schema && data.parsedValue[field.key]) {
const nested = data.parsedValue[field.key]
for (const sub of field.schema) {
if (sub.type === 'file' && nested[sub.key]) {
structFileUrls[field.key + '.' + sub.key] = processImageUrl(nested[sub.key])
} else if (sub.type === 'file_list' && nested[sub.key]) {
const subUrls = Array.isArray(nested[sub.key]) ? nested[sub.key] : []
subUrls.forEach((url, fi) => {
structFileUrls[field.key + '.' + sub.key + '_' + fi] = processImageUrl(url)
})
}
}
}
}
}
showStructRawJson.value = false
structRawJson.value = ''
}
// ==================== 数据加载 ====================
const loadGroups = async () => {
try {
const res = await getSettingGroupList({ page: 1, count: 100 })
if (res.data.code === 200) {
const groups = res.data.data.data || []
allGroupList.value = groups
if (groups.length > 0 && !activeGroupId.value) {
activeGroupId.value = String(groups[0].id)
loadSettings()
}
}
} catch (error) {
console.error('加载配置组失败:', error)
ElMessage.error('加载配置组失败')
}
}
const loadSettings = async () => {
if (!activeGroupId.value) {
settingList.value = []
settingTotal.value = 0
return
}
settingLoading.value = true
try {
const params = {
group_id: activeGroupId.value,
page: settingPagination.page,
count: settingPagination.pageSize
}
if (queryParams.key) {
params.key = queryParams.key
}
const res = await getSettingList(params)
if (res.data.code === 200) {
settingList.value = sortBySimilarity(res.data.data.data || [])
settingTotal.value = res.data.data.all_count || 0
// 更新当前组的配置数量
const groupIndex = allGroupList.value.findIndex(g => String(g.id) === activeGroupId.value)
if (groupIndex !== -1) {
allGroupList.value[groupIndex]._settingCount = settingTotal.value
}
}
} catch (error) {
console.error('加载配置列表失败:', error)
ElMessage.error('加载配置列表失败')
} finally {
settingLoading.value = false
}
}
const sortBySimilarity = (settings) => {
if (!settings || settings.length <= 1) return settings
return settings.sort((a, b) => {
const nameA = (a.name || '').toLowerCase()
const nameB = (b.name || '').toLowerCase()
const prefixA = nameA.substring(0, 2)
const prefixB = nameB.substring(0, 2)
if (prefixA !== prefixB) {
return prefixA.localeCompare(prefixB, 'zh-CN')
}
return nameA.localeCompare(nameB, 'zh-CN')
})
}
// ==================== Tab / 分页 事件 ====================
const handleTabChange = (tabId) => {
settingPagination.page = 1
loadSettings()
}
const handlePageChange = (page) => {
loadSettings()
}
const handlePageSizeChange = (size) => {
settingPagination.page = 1
loadSettings()
}
const handleQuery = () => {
settingPagination.page = 1
loadSettings()
}
// ==================== 配置组方法 ====================
const handleAddGroup = () => {
groupDialogTitle.value = '新增配置组'
Object.assign(groupForm, {
id: undefined,
name: '',
note: ''
})
groupDialogVisible.value = true
}
const handleEditGroup = async (group) => {
if (!group) return
groupDialogTitle.value = '编辑配置组'
try {
const res = await getSettingGroupInfo({ setting_group_id: group.id })
if (res.data.code === 200) {
Object.assign(groupForm, {
id: res.data.data.id,
name: res.data.data.name || '',
note: res.data.data.note || ''
})
groupDialogVisible.value = true
}
} catch (error) {
console.error('获取配置组详情失败:', error)
ElMessage.error('获取配置组详情失败')
}
}
const handleDeleteGroup = (group) => {
if (!group) return
ElMessageBox.confirm(`确认删除配置组 "${group.name}" 吗?删除后其下所有配置项也将被删除。`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteSettingGroup({ setting_group_id: group.id })
if (res.data.code === 200) {
ElMessage.success('删除成功')
activeGroupId.value = ''
await loadGroups()
}
} catch (error) {
console.error('删除失败:', error)
ElMessage.error(error.response?.data?.message || '删除失败')
}
}).catch(() => {})
}
const submitGroupForm = () => {
groupFormRef.value?.validate(async (valid) => {
if (valid) {
try {
const submitData = {
name: groupForm.name,
note: groupForm.note
}
if (groupForm.id) {
submitData.id = groupForm.id
}
const res = groupForm.id
? await updateSettingGroup(submitData)
: await createSettingGroup(submitData)
if (res.data.code === 200) {
ElMessage.success(groupForm.id ? '修改成功' : '创建成功')
groupDialogVisible.value = false
await loadGroups()
if (!groupForm.id && res.data.data?.id) {
activeGroupId.value = String(res.data.data.id)
loadSettings()
}
}
} catch (error) {
console.error('提交失败:', error)
ElMessage.error(error.response?.data?.message || '提交失败')
}
}
})
}
// ==================== 配置方法 ====================
const handleAddSetting = () => {
settingDialogTitle.value = '新增配置'
Object.assign(settingForm, {
id: undefined,
name: '',
value: '',
type: 'string',
settingGroupID: activeGroupId.value ? Number(activeGroupId.value) : undefined,
open: true,
note: ''
})
fileInfo.value = null
fileListInfo.value = []
newStringItem.value = ''
editableFormStringList.value = []
editableFormFileList.value = []
structSchema.value = []
Object.keys(structValue).forEach(k => delete structValue[k])
Object.keys(structFileUrls).forEach(k => delete structFileUrls[k])
resetStructStringLists()
showStructRawJson.value = false
structListItems.value = []
structListRawJson.value = '[]'
structListRawMode.value = false
settingDialogVisible.value = true
}
const handleAddSettingToGroup = (group) => {
if (!group) return
settingDialogTitle.value = '新增配置'
Object.assign(settingForm, {
id: undefined,
name: '',
value: '',
type: 'string',
settingGroupID: group.id,
open: true,
note: ''
})
fileInfo.value = null
fileListInfo.value = []
newStringItem.value = ''
editableFormStringList.value = []
editableFormFileList.value = []
structSchema.value = []
Object.keys(structValue).forEach(k => delete structValue[k])
Object.keys(structFileUrls).forEach(k => delete structFileUrls[k])
resetStructStringLists()
showStructRawJson.value = false
structListItems.value = []
structListRawJson.value = '[]'
structListRawMode.value = false
settingDialogVisible.value = true
}
const handleEditSetting = async (row) => {
settingDialogTitle.value = '编辑配置'
try {
const res = await getSettingInfo({ id: row.id })
if (res.data.code === 200) {
const data = res.data.data
Object.assign(settingForm, {
id: data.id,
name: data.name || '',
value: data.value,
type: data.type || 'string',
settingGroupID: data.settingGroupID,
open: data.open || false,
note: data.note || ''
})
if (data.type === 'bool') {
settingForm.value = data.value === true || data.value === 'true' || data.value === 1
} else if (data.type === 'int') {
settingForm.value = parseInt(data.value) || 0
} else if (data.type === 'float') {
settingForm.value = parseFloat(data.value) || 0
} else if (data.type === 'struct') {
initStructFromSetting(data)
} else if (data.type === 'struct_list') {
structSchema.value = parseSchemaJson(data.schema)
initStructListFromValue(data.value, data.parsedValue)
} else if (data.type === 'file') {
if (data.parsedValue && typeof data.parsedValue === 'string') {
fileInfo.value = {
id: data.value,
url: processImageUrl(data.parsedValue),
realName: '文件',
saveName: 'file',
size: 0
}
} else {
fileInfo.value = null
}
} else if (data.type === 'string_list') {
if (data.parsedValue && Array.isArray(data.parsedValue)) {
const truncatedValues = data.parsedValue.map(item => truncateFileName(item, 25))
settingForm.value = truncatedValues.join(',')
initEditableFormStringList()
} else {
settingForm.value = ''
editableFormStringList.value = []
}
newStringItem.value = ''
} else if (data.type === 'file_list') {
if (data.parsedValue && Array.isArray(data.parsedValue)) {
fileListInfo.value = data.parsedValue.map((url, index) => {
const fileIds = getFileList(data.value)
return {
id: fileIds[index] || `file_${index}`,
url: processImageUrl(url),
realName: `文件${index + 1}`,
saveName: `file_${index}`,
size: 0
}
})
nextTick(() => {
initEditableFormFileList()
})
} else {
fileListInfo.value = []
editableFormFileList.value = []
}
}
settingDialogVisible.value = true
}
} catch (error) {
console.error('获取配置详情失败:', error)
ElMessage.error('获取配置详情失败')
}
}
const handleDeleteSetting = (setting) => {
ElMessageBox.confirm(`确认删除配置 "${setting.name}" 吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteSetting({ id: setting.id })
if (res.data.code === 200) {
ElMessage.success('删除成功')
loadSettings()
}
} catch (error) {
console.error('删除失败:', error)
ElMessage.error(error.response?.data?.message || '删除失败')
}
}).catch(() => {})
}
const copySettingName = async (name) => {
try {
await navigator.clipboard.writeText(name)
ElMessage.success('已复制配置名称')
} catch (e) {
ElMessage.error('复制失败')
}
}
const getTypeColor = (type) => {
const colorMap = {
'string': 'primary',
'text': 'primary',
'int': 'success',
'float': 'warning',
'bool': 'info',
'file': 'danger',
'file_list': 'danger',
'string_list': 'primary',
'struct': '',
'struct_list': 'warning'
}
return colorMap[type] || ''
}
const isJsonValue = computed(() => {
if ((settingForm.type !== 'string' && settingForm.type !== 'text') || !settingForm.value) return false
const v = String(settingForm.value).trim()
return (v.startsWith('{') && v.endsWith('}')) || (v.startsWith('[') && v.endsWith(']'))
})
const formatJson = () => {
try {
const parsed = JSON.parse(settingForm.value)
settingForm.value = JSON.stringify(parsed, null, 2)
} catch (e) {
ElMessage.warning('JSON 格式不合法,无法格式化')
}
}
const compressJson = () => {
try {
const parsed = JSON.parse(settingForm.value)
settingForm.value = JSON.stringify(parsed)
} catch (e) {
ElMessage.warning('JSON 格式不合法,无法压缩')
}
}
const handleTypeChange = (newType) => {
if (newType === 'bool') {
settingForm.value = false
} else if (newType === 'int') {
settingForm.value = 0
} else if (newType === 'float') {
settingForm.value = 0.0
} else if (newType === 'struct') {
settingForm.value = '{}'
structSchema.value = []
Object.keys(structValue).forEach(k => delete structValue[k])
showStructRawJson.value = false
} else if (newType === 'struct_list') {
settingForm.value = '[]'
structSchema.value = []
structListItems.value = []
structListRawJson.value = '[]'
structListRawMode.value = false
} else {
settingForm.value = ''
}
fileInfo.value = null
fileListInfo.value = []
editableFormStringList.value = []
editableFormFileList.value = []
newStringItem.value = ''
}
const submitSettingForm = async () => {
if (!settingFormRef.value) return
try {
await settingFormRef.value.validate()
let submitValue = settingForm.value
if (settingForm.type === 'struct') {
submitValue = JSON.stringify(structValue)
} else if (settingForm.type === 'struct_list') {
submitValue = structListRawMode.value ? structListRawJson.value : buildStructListValue()
}
const submitData = {
id: settingForm.id,
name: settingForm.name,
value: submitValue,
type: settingForm.type,
setting_group_id: settingForm.settingGroupID,
open: settingForm.open,
note: settingForm.note
}
if (settingForm.type === 'struct' || settingForm.type === 'struct_list') {
submitData.schema = buildSchemaJson()
}
const originalOpen = settingList.value.find(s => s.id === settingForm.id)?.open
const newOpen = settingForm.open
const res = settingForm.id
? await updateSetting(submitData)
: await createSetting(submitData)
if (res.data.code === 200) {
if (settingForm.id && originalOpen !== newOpen) {
try {
await setSettingOpen({ id: settingForm.id, open: newOpen })
} catch (openError) {
console.error('更新开放状态失败:', openError)
ElMessage.warning('配置已更新,但开放状态更新失败')
}
}
ElMessage.success(settingForm.id ? '修改成功' : '创建成功')
settingDialogVisible.value = false
loadSettings()
} else {
ElMessage.error(res.data.message || '操作失败')
}
} catch (error) {
console.error('提交配置失败:', error)
ElMessage.error('操作失败')
}
}
// ==================== 文件相关 ====================
const clearFile = () => {
if (fileInfo.value?.localUrl) {
URL.revokeObjectURL(fileInfo.value.localUrl)
}
if (fileInfo.value?.isLocal && fileInfo.value?.url) {
URL.revokeObjectURL(fileInfo.value.url)
}
settingForm.value = ''
fileInfo.value = null
}
const formatFileSize = (bytes) => {
if (!bytes) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const truncateFileName = (fileName, maxLength = 25) => {
if (!fileName) return ''
if (fileName.length <= maxLength) return fileName
const extension = fileName.includes('.') ? '.' + fileName.split('.').pop() : ''
const nameWithoutExt = fileName.substring(0, fileName.lastIndexOf('.'))
if (nameWithoutExt.length <= maxLength) {
return fileName
}
const truncatedName = nameWithoutExt.substring(0, maxLength - 3)
return truncatedName + '...' + extension
}
const getStringList = (value) => {
if (!value || typeof value !== 'string') return []
return value.split(',').filter(str => str && str.trim())
}
const truncateText = (text, maxLength = 30) => {
if (!text) return ''
if (typeof text !== 'string') text = String(text)
if (text.length <= maxLength) return text
return text.substring(0, maxLength) + '...'
}
const formatDate = (dateString) => {
if (!dateString) return '-'
const date = new Date(dateString)
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
const seconds = String(date.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
// ==================== 表单可编辑字符串列表 ====================
const getEditableFormStringList = () => {
return editableFormStringList.value
}
const initEditableFormStringList = () => {
const stringItems = getStringList(settingForm.value)
editableFormStringList.value = stringItems.map(item => ({
value: item,
editing: false
}))
}
const handleFormDragStart = (event, index) => {
formDraggedIndex.value = index
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/html', event.target.outerHTML)
event.target.style.opacity = '0.5'
}
const handleFormDrop = (event, dropIndex) => {
event.preventDefault()
const dragIndex = formDraggedIndex.value
if (dragIndex === dropIndex) return
const newList = [...editableFormStringList.value]
const [draggedItem] = newList.splice(dragIndex, 1)
newList.splice(dropIndex, 0, draggedItem)
editableFormStringList.value = newList
formDraggedIndex.value = -1
const updatedValues = newList.map(item => item.value)
settingForm.value = updatedValues.join(',')
event.target.style.opacity = '1'
}
const addFormEditableStringItem = () => {
const newItem = { value: '', editing: true }
editableFormStringList.value.push(newItem)
const updatedValues = editableFormStringList.value.map(item => item.value)
settingForm.value = updatedValues.join(',')
nextTick(() => {
const inputs = document.querySelectorAll('.string-list-item input')
if (inputs.length > 0) {
inputs[inputs.length - 1].focus()
}
})
}
const startFormEditItem = (index) => {
editableFormStringList.value[index].editing = true
nextTick(() => {
const inputs = document.querySelectorAll('.string-list-item input')
if (inputs[index]) {
inputs[index].focus()
inputs[index].select()
}
})
}
const finishFormEditItem = (index) => {
editableFormStringList.value[index].editing = false
const updatedValues = editableFormStringList.value.map(item => item.value)
settingForm.value = updatedValues.join(',')
}
const removeFormEditableStringItem = (index) => {
ElMessageBox.confirm('确定要删除这个项目吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
editableFormStringList.value.splice(index, 1)
const updatedValues = editableFormStringList.value.map(item => item.value)
settingForm.value = updatedValues.join(',')
ElMessage.success('删除成功')
})
}
const focusFormEditInput = () => {}
// ==================== 表单可编辑文件列表 ====================
const getEditableFormFileList = () => {
return editableFormFileList.value
}
const initEditableFormFileList = () => {
if (settingForm.type === 'file_list' && fileListInfo.value && fileListInfo.value.length > 0) {
editableFormFileList.value = fileListInfo.value.map(file => ({
id: file.id || '',
url: file.url || '',
localUrl: file.localUrl || '',
realName: file.realName || '文件',
saveName: file.saveName || 'file',
size: file.size || 0,
uploading: false
}))
} else {
editableFormFileList.value = []
}
}
const handleFormFileDragStart = (event, index) => {
formFileDraggedIndex.value = index
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/html', event.target.outerHTML)
event.target.style.opacity = '0.5'
}
const handleFormFileDrop = (event, dropIndex) => {
event.preventDefault()
const dragIndex = formFileDraggedIndex.value
if (dragIndex === dropIndex) return
const newList = [...editableFormFileList.value]
const [draggedItem] = newList.splice(dragIndex, 1)
newList.splice(dropIndex, 0, draggedItem)
editableFormFileList.value = newList
formFileDraggedIndex.value = -1
fileListInfo.value = newList.map(item => ({ ...item }))
updateFormFileListValue()
event.target.style.opacity = '1'
}
const removeFormEditableFileItem = (index) => {
ElMessageBox.confirm('确定要删除这个文件吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
if (editableFormFileList.value[index].localUrl) {
URL.revokeObjectURL(editableFormFileList.value[index].localUrl)
}
if (editableFormFileList.value[index].isLocal && editableFormFileList.value[index].url) {
URL.revokeObjectURL(editableFormFileList.value[index].url)
}
editableFormFileList.value.splice(index, 1)
fileListInfo.value = editableFormFileList.value
updateFormFileListValue()
ElMessage.success('删除成功')
})
}
const updateFormFileListValue = () => {
if (!editableFormFileList.value || !Array.isArray(editableFormFileList.value)) return
const fileIds = editableFormFileList.value.map(file => file.id).filter(id => id)
settingForm.value = fileIds.join(',')
}
// ==================== 文件预览 ====================
const previewFile = async (fileId) => {
if (!fileId) return
try {
const res = await getFileDetail({ file_id: fileId })
if (res.data.code === 200 && res.data.data?.url) {
const url = processImageUrl(res.data.data.url)
const fileName = res.data.data.data?.realName || ''
const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg']
const ext = fileName.toLowerCase().substring(fileName.lastIndexOf('.'))
if (imageExts.includes(ext) || url.match(/\.(jpg|jpeg|png|gif|bmp|webp|svg)/i)) {
currentViewImage.value = url
imageViewerVisible.value = true
} else {
window.open(url, '_blank')
}
} else {
const downRes = await downloadFile({ file_id: fileId })
if (downRes.data.code === 200 && downRes.data.data?.url) {
window.open(processImageUrl(downRes.data.data.url), '_blank')
} else {
ElMessage.error('获取文件预览失败')
}
}
} catch (error) {
console.error('文件预览失败:', error)
ElMessage.error('文件预览失败')
}
}
const processImageUrl = (url) => {
if (!url) return ''
let processedUrl = url.replace(/\\u0026/g, '&')
return decodeURIComponent(processedUrl)
}
const previewImage = (url) => {
currentViewImage.value = processImageUrl(url)
imageViewerVisible.value = true
}
const handleImageError = (event) => {
console.error('图片加载失败:', event.target.src)
}
// ==================== 图像选择器 ====================
const openImageSelector = () => {
imageSelectorMode.value = 'single'
currentImageSelectorFileId.value = settingForm.value || ''
imageSelectorVisible.value = true
}
const openImageSelectorForList = () => {
imageSelectorMode.value = 'list'
currentImageSelectorFileId.value = ''
imageSelectorVisible.value = true
}
const openImageSelectorForListItem = (index) => {
imageSelectorMode.value = 'list-item'
currentImageSelectorFileId.value = index
imageSelectorVisible.value = true
}
const handleImageSelectorConfirm = (selectedFileOrFiles) => {
if (imageSelectorMode.value === 'struct-file') {
const selectedFile = selectedFileOrFiles
if (!selectedFile || !selectedFile.id) { ElMessage.warning('选择的文件无效'); return }
setStructFileValue(String(selectedFile.id), selectedFile.url ? processImageUrl(selectedFile.url) : '')
imageSelectorVisible.value = false
return
}
if (imageSelectorMode.value === 'struct-file-list') {
const files = Array.isArray(selectedFileOrFiles) ? selectedFileOrFiles : [selectedFileOrFiles]
if (files.length === 0 || !files[0]?.id) { ElMessage.warning('未选择任何文件'); return }
const currentVal = getStructFileCurrentValue(structFileTarget.source, structFileTarget.fieldKey, structFileTarget.subKey, structFileTarget.itemIndex)
const existingIds = currentVal ? currentVal.split(',').filter(Boolean) : []
const newIds = files.map(f => String(f.id))
const newUrls = files.map(f => f.url ? processImageUrl(f.url) : '')
const merged = [...existingIds, ...newIds].join(',')
setStructFileValue(merged, null, newUrls, existingIds.length)
ElMessage.success(`已添加 ${newIds.length} 个文件`)
imageSelectorVisible.value = false
return
}
if (imageSelectorMode.value === 'list' && Array.isArray(selectedFileOrFiles)) {
if (selectedFileOrFiles.length === 0) {
ElMessage.warning('未选择任何文件')
return
}
if (!fileListInfo.value) {
fileListInfo.value = []
}
for (const selectedFile of selectedFileOrFiles) {
if (!selectedFile || !selectedFile.id) continue
const newFile = {
id: selectedFile.id,
url: processImageUrl(selectedFile.url || ''),
realName: selectedFile.realName || '文件',
saveName: selectedFile.realName || 'file',
size: selectedFile.size || 0
}
fileListInfo.value.push(newFile)
editableFormFileList.value.push({
id: selectedFile.id,
url: processImageUrl(selectedFile.url || ''),
localUrl: '',
realName: selectedFile.realName || '文件',
saveName: selectedFile.realName || 'file',
size: selectedFile.size || 0,
uploading: false
})
}
updateFormFileListValue()
ElMessage.success(`已添加 ${selectedFileOrFiles.length} 个文件`)
imageSelectorVisible.value = false
return
}
const selectedFile = selectedFileOrFiles
if (!selectedFile || !selectedFile.id) {
ElMessage.warning('选择的文件无效')
return
}
if (imageSelectorMode.value === 'single') {
settingForm.value = selectedFile.id
fileInfo.value = {
id: selectedFile.id,
url: processImageUrl(selectedFile.url || ''),
realName: selectedFile.realName || '文件',
saveName: selectedFile.realName || 'file',
size: selectedFile.size || 0
}
} else if (imageSelectorMode.value === 'list') {
if (!fileListInfo.value) {
fileListInfo.value = []
}
const newFile = {
id: selectedFile.id,
url: processImageUrl(selectedFile.url || ''),
realName: selectedFile.realName || '文件',
saveName: selectedFile.realName || 'file',
size: selectedFile.size || 0
}
fileListInfo.value.push(newFile)
editableFormFileList.value.push({
id: selectedFile.id,
url: processImageUrl(selectedFile.url || ''),
localUrl: '',
realName: selectedFile.realName || '文件',
saveName: selectedFile.realName || 'file',
size: selectedFile.size || 0,
uploading: false
})
updateFormFileListValue()
} else if (imageSelectorMode.value === 'list-item') {
const index = currentImageSelectorFileId.value
const updatedFile = {
id: selectedFile.id,
url: processImageUrl(selectedFile.url || ''),
realName: selectedFile.realName || '文件',
saveName: selectedFile.realName || 'file',
size: selectedFile.size || 0
}
if (fileListInfo.value && fileListInfo.value[index] !== undefined) {
fileListInfo.value[index] = updatedFile
}
if (editableFormFileList.value && editableFormFileList.value[index] !== undefined) {
editableFormFileList.value[index] = {
...updatedFile,
localUrl: '',
uploading: false
}
}
updateFormFileListValue()
ElMessage.success('文件替换成功')
}
imageSelectorVisible.value = false
}
// ==================== Struct 文件选择器 ====================
const structFileUrls = reactive({})
const structFileTarget = reactive({
source: '',
fieldKey: '',
subKey: '',
itemIndex: -1
})
const openStructFileSelector = (source, fieldKey, subKey = null, itemIndex = -1) => {
structFileTarget.source = source
structFileTarget.fieldKey = fieldKey
structFileTarget.subKey = subKey || ''
structFileTarget.itemIndex = itemIndex
imageSelectorMode.value = 'struct-file'
currentImageSelectorFileId.value = getStructFileCurrentValue(source, fieldKey, subKey, itemIndex) || ''
imageSelectorVisible.value = true
}
const openStructFileListSelector = (source, fieldKey, subKey = null, itemIndex = -1) => {
structFileTarget.source = source
structFileTarget.fieldKey = fieldKey
structFileTarget.subKey = subKey || ''
structFileTarget.itemIndex = itemIndex
imageSelectorMode.value = 'struct-file-list'
currentImageSelectorFileId.value = ''
imageSelectorVisible.value = true
}
const getStructFileCurrentValue = (source, fieldKey, subKey, itemIndex) => {
if (source === 'struct') {
return structValue[fieldKey] || ''
} else if (source === 'struct-nested') {
return getNestedValue(fieldKey)[subKey] || ''
} else if (source === 'struct-list') {
return structListItems.value[itemIndex]?.[fieldKey] || ''
}
return ''
}
const setStructFileValue = (value, url = null, urls = null, startIndex = 0) => {
const { source, fieldKey, subKey, itemIndex } = structFileTarget
if (source === 'struct') {
structValue[fieldKey] = value
if (url) structFileUrls[fieldKey] = url
if (urls) urls.forEach((u, i) => { if (u) structFileUrls[fieldKey + '_' + (startIndex + i)] = u })
} else if (source === 'struct-nested') {
getNestedValue(fieldKey)[subKey] = value
if (url) structFileUrls[fieldKey + '.' + subKey] = url
if (urls) urls.forEach((u, i) => { if (u) structFileUrls[fieldKey + '.' + subKey + '_' + (startIndex + i)] = u })
} else if (source === 'struct-list') {
if (structListItems.value[itemIndex]) {
structListItems.value[itemIndex][fieldKey] = value
if (url) structFileUrls['list_' + itemIndex + '_' + fieldKey] = url
if (urls) urls.forEach((u, i) => { if (u) structFileUrls['list_' + itemIndex + '_' + fieldKey + '_' + (startIndex + i)] = u })
}
}
}
// ==================== 批量导入 ====================
const handleBatchImport = async () => {
batchImportText.value = ''
batchImportParsed.value = []
batchImportGroupId.value = undefined
batchImportGroupName.value = ''
batchImportGroupExists.value = false
batchImportOpen.value = true
batchImportProgress.value = 0
batchImportTotal.value = 0
batchImportStatusText.value = ''
batchImportDialogVisible.value = true
try {
const clipText = await navigator.clipboard.readText()
if (clipText && clipText.trim()) {
batchImportText.value = clipText.trim()
}
} catch (e) {
console.warn('读取剪贴板失败:', e)
}
}
const handleImportToGroup = (group) => {
if (!group) return
batchImportText.value = ''
batchImportParsed.value = []
batchImportGroupId.value = group.id
batchImportGroupName.value = group.name
batchImportGroupExists.value = true
batchImportOpen.value = true
batchImportProgress.value = 0
batchImportTotal.value = 0
batchImportStatusText.value = ''
batchImportDialogVisible.value = true
}
const parseBatchImportText = () => {
const text = batchImportText.value.trim()
if (!text) {
ElMessage.warning('请先粘贴导入内容')
return
}
const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0)
const validTypes = ['string', 'text', 'int', 'float', 'bool', 'file', 'file_list', 'string_list', 'struct', 'struct_list']
const results = []
const stripBackticks = (str) => str.replace(/`/g, '').trim()
let detectedGroupName = ''
for (const line of lines) {
const groupMatch = line.match(/^\[配置组\]\s*(.+)$/)
if (groupMatch) {
detectedGroupName = groupMatch[1].trim()
continue
}
if (/^[\s|:\-]+$/.test(line)) continue
if (/配置名|类型.*默认值|名称.*类型/.test(line)) continue
const parts = line.split('|').map(s => s.trim()).filter(s => s.length > 0)
if (parts.length < 3) continue
const name = stripBackticks(parts[0])
const type = stripBackticks(parts[1]).toLowerCase()
const value = stripBackticks(parts[2])
const note = parts.length >= 4 ? stripBackticks(parts[3]) : ''
if (!name) continue
if (!validTypes.includes(type)) continue
results.push({ name, type, value, note, _duplicate: false })
}
if (detectedGroupName) {
batchImportGroupName.value = detectedGroupName
const existingGroup = allGroupList.value.find(g => g.name === detectedGroupName)
if (existingGroup) {
batchImportGroupId.value = existingGroup.id
batchImportGroupExists.value = true
} else {
batchImportGroupId.value = undefined
batchImportGroupExists.value = false
}
}
const nameSet = new Set()
results.forEach(item => {
if (nameSet.has(item.name)) {
item._duplicate = true
} else {
nameSet.add(item.name)
}
})
batchImportParsed.value = results
if (results.length === 0) {
ElMessage.warning('未能解析到有效的配置项,请检查格式')
} else {
const groupInfo = detectedGroupName ? `,配置组:${detectedGroupName}` : ''
ElMessage.success(`成功解析 ${results.length} 条配置项${groupInfo}`)
}
}
watch(batchImportText, (val) => {
if (val && val.trim().split('\n').length >= 2) {
parseBatchImportText()
}
})
const submitBatchImport = async () => {
if (!batchImportGroupName.value) {
ElMessage.warning('未识别到配置组名称,请检查内容格式')
return
}
const items = batchImportParsed.value.filter(i => !i._duplicate)
if (items.length === 0) {
ElMessage.warning('没有可导入的配置项')
return
}
batchImportLoading.value = true
batchImportProgress.value = 0
batchImportTotal.value = items.length
let successCount = 0
let failCount = 0
const errors = []
let targetGroupId = batchImportGroupId.value
if (!targetGroupId) {
batchImportStatusText.value = `正在创建配置组「${batchImportGroupName.value}」...`
try {
const res = await createSettingGroup({ name: batchImportGroupName.value, note: '' })
if (res.data.code === 200) {
targetGroupId = res.data.data?.id || res.data.data?.ID
batchImportGroupId.value = targetGroupId
batchImportGroupExists.value = true
} else {
batchImportLoading.value = false
batchImportStatusText.value = ''
ElMessage.error(`创建配置组失败:${res.data.message || '未知错误'}`)
return
}
} catch (error) {
batchImportLoading.value = false
batchImportStatusText.value = ''
ElMessage.error(`创建配置组失败:${error.response?.data?.message || error.message}`)
return
}
}
for (let i = 0; i < items.length; i++) {
const item = items[i]
batchImportStatusText.value = `正在导入:${item.name}`
batchImportProgress.value = i
try {
const res = await createSetting({
name: item.name,
value: item.value,
type: item.type,
setting_group_id: targetGroupId,
open: batchImportOpen.value,
note: item.note
})
if (res.data.code === 200) {
if (batchImportOpen.value && res.data.data?.id) {
try {
await setSettingOpen({ id: res.data.data.id, open: true })
} catch (e) {
console.warn('设置公开状态失败:', item.name, e)
}
}
successCount++
} else {
failCount++
errors.push(`${item.name}: ${res.data.message || '未知错误'}`)
}
} catch (error) {
failCount++
errors.push(`${item.name}: ${error.response?.data?.message || error.message || '请求失败'}`)
}
}
batchImportProgress.value = items.length
batchImportStatusText.value = '导入完成'
batchImportLoading.value = false
if (failCount === 0) {
ElMessage.success(`全部导入成功!共 ${successCount} 条,配置组:${batchImportGroupName.value}`)
batchImportDialogVisible.value = false
} else {
ElMessage.warning(`导入完成:成功 ${successCount} 条,失败 ${failCount}`)
if (errors.length > 0) {
console.error('批量导入失败详情:', errors)
}
}
await loadGroups()
if (targetGroupId) {
activeGroupId.value = String(targetGroupId)
await loadSettings()
}
}
// 一键复制
const handleCopyGroupSettings = async (group) => {
if (!group) return
const groupId = group.id
const groupName = group.name
try {
const res = await getSettingList({ group_id: groupId, page: 1, count: 100 })
let settings = []
if (res.data.code === 200) {
settings = res.data.data.data || []
}
if (settings.length === 0) {
ElMessage.warning(`配置组「${groupName}」下没有配置项`)
return
}
const lines = [
`[配置组] ${groupName}`,
'',
'| 配置名 | 类型 | 默认值 | 说明 |',
'|--------|------|--------|------|'
]
settings.forEach(s => {
const name = s.name || ''
const type = s.type || 'string'
const value = (s.value != null ? String(s.value) : '').replace(/\|/g, '\\|')
const note = (s.note || '-').replace(/\|/g, '\\|')
lines.push(`| \`${name}\` | \`${type}\` | \`${value}\` | ${note} |`)
})
const text = lines.join('\n')
await navigator.clipboard.writeText(text)
ElMessage.success(`已复制「${groupName}」的 ${settings.length} 条配置项到剪贴板`)
} catch (error) {
console.error('一键复制失败:', error)
ElMessage.error('复制失败,请重试')
}
}
// ==================== 初始化 ====================
onMounted(() => {
loadGroups()
})
</script>
<style scoped>
.setting-manage-container {
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
min-height: 100%;
}
/* 顶部操作栏 */
.top-action-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
border: 1px solid #f0f2f5;
}
.action-left {
display: flex;
align-items: center;
gap: 12px;
}
.search-input {
width: 280px;
}
.search-input :deep(.el-input__wrapper) {
border-radius: 8px;
box-shadow: 0 0 0 1px #e4e7ed inset;
transition: all 0.3s;
}
.search-input :deep(.el-input__wrapper:hover) {
box-shadow: 0 0 0 1px #c0c4cc inset;
}
.search-input :deep(.el-input__wrapper.is-focus) {
box-shadow: 0 0 0 1px #409eff inset;
}
.action-right {
display: flex;
align-items: center;
gap: 10px;
}
/* Tab 栏 */
.tabs-wrapper {
background: #ffffff;
border-radius: 12px;
padding: 16px 20px 0;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
border: 1px solid #f0f2f5;
position: relative;
}
.group-tabs :deep(.el-tabs__header) {
margin: 0;
border-bottom: none;
}
.group-tabs :deep(.el-tabs__nav-wrap) {
margin-bottom: 0;
}
.group-tabs :deep(.el-tabs__nav-wrap::after) {
display: none;
}
.group-tabs :deep(.el-tabs__item) {
border: 1px solid transparent;
border-radius: 8px 8px 0 0;
padding: 0 20px;
height: 42px;
line-height: 42px;
margin-right: 4px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: #f5f7fa;
}
.group-tabs :deep(.el-tabs__item:hover) {
background: #ecf5ff;
border-color: #d9ecff;
color: #409eff;
}
.group-tabs :deep(.el-tabs__item.is-active) {
background: linear-gradient(135deg, #409eff 0%, #66b1ff 100%);
color: #ffffff;
border-color: transparent;
font-weight: 600;
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
}
.group-tabs :deep(.el-tabs__item.is-active .tab-icon) {
color: #ffffff;
}
.group-tabs :deep(.el-tabs__item.is-active .tab-badge .el-badge__content) {
background-color: #ffffff;
color: #409eff;
}
.tab-label {
display: flex;
align-items: center;
gap: 6px;
}
.tab-icon {
font-size: 15px;
color: #909399;
transition: color 0.3s;
}
.tab-name {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tab-badge {
margin-left: 4px;
}
.tab-badge :deep(.el-badge__content) {
font-size: 10px;
height: 16px;
line-height: 16px;
padding: 0 5px;
}
.group-info-bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 0 16px;
border-top: 1px solid #f0f2f5;
margin-top: 12px;
gap: 20px;
flex-wrap: wrap;
}
.group-meta {
display: flex;
align-items: center;
gap: 20px;
flex-wrap: wrap;
}
.group-meta-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: #606266;
}
.meta-icon {
font-size: 14px;
color: #909399;
}
.meta-label {
color: #909399;
white-space: nowrap;
}
.meta-value {
color: #303133;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta-count {
font-weight: 600;
color: #409eff;
}
.group-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 14px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
border: none;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
height: 32px;
}
.action-btn span {
line-height: 1;
}
.action-btn--edit {
background: #ecf5ff;
color: #409eff;
}
.action-btn--edit:hover {
background: #409eff;
color: #ffffff;
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.35);
transform: translateY(-1px);
}
.action-btn--add {
background: #f0f9eb;
color: #67c23a;
}
.action-btn--add:hover {
background: #67c23a;
color: #ffffff;
box-shadow: 0 4px 12px rgba(103, 194, 58, 0.35);
transform: translateY(-1px);
}
.action-btn--copy {
background: #fdf6ec;
color: #e6a23c;
}
.action-btn--copy:hover {
background: #e6a23c;
color: #ffffff;
box-shadow: 0 4px 12px rgba(230, 162, 60, 0.35);
transform: translateY(-1px);
}
.action-btn--import {
background: #f4f4f5;
color: #909399;
}
.action-btn--import:hover {
background: #909399;
color: #ffffff;
box-shadow: 0 4px 12px rgba(144, 147, 153, 0.35);
transform: translateY(-1px);
}
.action-btn--delete {
background: #fef0f0;
color: #f56c6c;
}
.action-btn--delete:hover {
background: #f56c6c;
color: #ffffff;
box-shadow: 0 4px 12px rgba(245, 108, 108, 0.35);
transform: translateY(-1px);
}
/* 卡片区域 */
.cards-section {
background: #ffffff;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
border: 1px solid #f0f2f5;
min-height: 400px;
flex: 1;
}
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 16px;
}
/* 卡片样式 */
.setting-card {
background: #ffffff;
border: 1px solid #ebeef5;
border-radius: 12px;
padding: 20px;
cursor: pointer;
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.setting-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, #409eff, #66b1ff);
opacity: 0;
transition: opacity 0.3s;
}
.setting-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 28px rgba(64, 158, 255, 0.12);
border-color: #d9ecff;
}
.setting-card:hover::before {
opacity: 1;
}
.setting-card.is-private {
border-left: 3px solid #909399;
}
.setting-card.is-private::before {
background: linear-gradient(90deg, #909399, #b1b3b8);
}
/* 卡片头部 */
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 14px;
}
.card-title-row {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.card-name-wrap {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
flex: 1;
}
.card-name {
font-size: 15px;
font-weight: 600;
color: #303133;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.copy-name-btn {
font-size: 16px;
color: #c0c4cc;
cursor: pointer;
flex-shrink: 0;
padding: 4px;
border-radius: 4px;
transition: all 0.25s;
}
.copy-name-btn:hover {
color: #409eff;
background: #ecf5ff;
}
.card-type-tag {
flex-shrink: 0;
}
/* 卡片内容 */
.card-body {
margin-bottom: 14px;
padding: 12px;
background: #f8f9fc;
border-radius: 8px;
min-height: 44px;
display: flex;
align-items: center;
}
.card-value {
font-size: 13px;
color: #606266;
width: 100%;
overflow: hidden;
}
.value-bool {
display: flex;
align-items: center;
gap: 6px;
font-weight: 500;
}
.bool-true {
color: #67c23a;
font-size: 18px;
}
.bool-false {
color: #f56c6c;
font-size: 18px;
}
.value-file,
.value-file-list,
.value-string-list,
.value-struct {
display: flex;
align-items: center;
gap: 6px;
color: #409eff;
}
.value-text {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: 'SF Mono', Consolas, monospace;
font-size: 12.5px;
color: #5a6b7b;
}
/* 卡片底部 */
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 12px;
border-top: 1px solid #f2f3f5;
}
.card-note {
font-size: 12px;
color: #909399;
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.3s;
}
.setting-card:hover .card-actions {
opacity: 1;
}
/* 空状态 */
.empty-state {
display: flex;
align-items: center;
justify-content: center;
min-height: 300px;
}
/* 分页 */
.pagination-wrapper {
display: flex;
justify-content: center;
padding-top: 24px;
margin-top: 16px;
border-top: 1px solid #f2f3f5;
}
/* 卡片列表动画 */
.card-list-enter-active,
.card-list-leave-active {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.card-list-enter-from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
.card-list-leave-to {
opacity: 0;
transform: translateY(-10px) scale(0.95);
}
.card-list-move {
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
/* 弹窗样式 */
:deep(.dialog-scrollable .el-dialog) {
max-height: 90vh;
display: flex;
flex-direction: column;
border-radius: 12px;
}
:deep(.dialog-scrollable .el-dialog__body) {
max-height: calc(90vh - 120px);
overflow-y: auto;
padding: 20px;
scrollbar-width: none;
-ms-overflow-style: none;
}
:deep(.dialog-scrollable .el-dialog__body)::-webkit-scrollbar {
display: none;
}
:deep(.dialog-scrollable .el-dialog__header) {
flex-shrink: 0;
}
:deep(.dialog-scrollable .el-dialog__footer) {
flex-shrink: 0;
padding: 20px;
border-top: 1px solid #e4e7ed;
}
/* JSON 工具栏 */
.json-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
padding: 6px 10px;
background: #f5f7fa;
border-radius: 6px;
}
/* 文件相关样式 */
.file-upload-section {
width: 100%;
}
.file-info-display {
width: 100%;
}
.file-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
border: 1px solid #e4e7ed;
border-radius: 8px;
background: #f8f9fc;
}
.file-item.uploading {
border-color: #409eff;
background: #f0f9ff;
}
.file-preview {
width: 80px;
height: 80px;
border-radius: 6px;
overflow: hidden;
border: 1px solid #e4e7ed;
flex-shrink: 0;
position: relative;
}
.preview-image {
width: 100%;
height: 100%;
object-fit: cover;
cursor: pointer;
transition: transform 0.2s;
}
.preview-image:hover {
transform: scale(1.05);
}
.file-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7fa;
color: #909399;
font-size: 24px;
}
.file-details {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.file-name {
font-weight: 500;
color: #303133;
max-width: 200px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.file-id {
font-size: 12px;
color: #909399;
}
.file-size {
font-size: 12px;
color: #909399;
}
.file-actions {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-end;
flex-shrink: 0;
}
.upload-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: white;
font-size: 12px;
border-radius: 6px;
}
.upload-overlay .el-icon {
font-size: 20px;
margin-bottom: 4px;
}
.upload-overlay span {
font-size: 11px;
}
.image-selector-btn {
width: 200px;
height: 40px;
}
/* 文件列表样式 */
.file-list-section {
width: 100%;
}
.editable-file-list {
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 16px;
background: #fafbfc;
}
.file-list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #ebeef5;
}
.header-actions {
display: flex;
gap: 8px;
}
.list-title {
font-weight: 500;
color: #303133;
font-size: 14px;
}
.file-list-items {
display: flex;
flex-direction: column;
gap: 10px;
}
.file-list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: #ffffff;
border: 1px solid #e4e7ed;
border-radius: 8px;
transition: all 0.3s;
cursor: move;
}
.file-list-item:hover {
border-color: #c0c4cc;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.file-list-item .file-preview {
width: 50px;
height: 50px;
border-radius: 6px;
overflow: hidden;
border: 1px solid #e4e7ed;
flex-shrink: 0;
position: relative;
}
.file-list-item .preview-image {
width: 100%;
height: 100%;
object-fit: cover;
cursor: pointer;
}
.file-list-item .file-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7fa;
color: #909399;
}
.file-list-item .file-info {
flex: 1;
min-width: 0;
}
.file-list-item .file-name {
font-weight: 500;
color: #303133;
margin-bottom: 2px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.file-list-item .file-id {
font-size: 12px;
color: #909399;
}
.file-list-item .file-size {
font-size: 12px;
color: #909399;
}
.file-list-item .file-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
flex-direction: row;
}
/* 字符串列表样式 */
.string-list-section {
width: 100%;
}
.editable-string-list {
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 16px;
background: #fafbfc;
}
.string-list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #ebeef5;
}
.string-list-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.string-list-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: #ffffff;
border: 1px solid #e4e7ed;
border-radius: 6px;
transition: all 0.3s;
cursor: move;
}
.string-list-item:hover {
border-color: #c0c4cc;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.06);
}
.drag-handle {
color: #909399;
cursor: grab;
flex-shrink: 0;
}
.drag-handle:hover {
color: #606266;
}
.item-content {
flex: 1;
min-width: 0;
}
.item-text {
padding: 4px 8px;
border-radius: 4px;
transition: background-color 0.3s;
cursor: pointer;
word-break: break-word;
white-space: normal;
line-height: 1.4;
}
.item-text:hover {
background: #f5f7fa;
}
.item-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.danger-btn {
color: #f56c6c;
}
.danger-btn:hover {
color: #f78989;
}
/* 导入进度面板 */
.import-progress-panel {
padding: 24px;
text-align: center;
}
.progress-header {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 20px;
}
.progress-info {
max-width: 400px;
margin: 0 auto;
}
/* 文本值 */
.text-value {
display: inline-block;
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
}
/* Struct 编辑器样式 */
.struct-section {
width: 100%;
}
.struct-schema-editor,
.struct-value-editor {
border: 1px solid #e4e7ed;
border-radius: 8px;
padding: 16px;
background: #fafbfc;
margin-bottom: 16px;
}
.schema-header,
.value-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px solid #ebeef5;
}
.schema-title,
.value-title {
font-weight: 600;
font-size: 14px;
color: #303133;
display: flex;
align-items: center;
gap: 6px;
}
.schema-fields {
display: flex;
flex-direction: column;
gap: 12px;
}
.schema-field-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.schema-field-row .nested-schema {
width: 100%;
margin-top: 8px;
margin-left: 32px;
padding: 12px;
border: 1px dashed #d9ecff;
border-radius: 6px;
background: #f0f9ff;
}
.nested-schema-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.nested-label {
font-size: 12px;
color: #606266;
font-weight: 500;
}
.schema-field-row.nested {
margin-left: 0;
}
.schema-empty {
padding: 24px;
text-align: center;
color: #909399;
font-size: 13px;
}
.struct-value-fields {
display: flex;
flex-direction: column;
gap: 12px;
}
.struct-value-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: #ffffff;
border: 1px solid #e4e7ed;
border-radius: 6px;
flex-wrap: wrap;
}
.struct-value-row.nested {
margin-left: 24px;
border-style: dashed;
}
.field-label {
font-size: 13px;
font-weight: 500;
color: #303133;
min-width: 100px;
flex-shrink: 0;
}
.field-label.sub {
color: #606266;
font-size: 12px;
}
.field-input {
flex: 1;
min-width: 200px;
}
.nested-value-fields {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
.struct-raw-json {
width: 100%;
}
.struct-list-value {
width: 100%;
margin-top: 8px;
}
.struct-list-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.nested-struct-list-cards {
display: flex;
flex-direction: column;
gap: 8px;
}
.nested-struct-list-card {
border: 1px solid #ebeef5;
border-radius: 6px;
padding: 10px;
background: #fafbfc;
}
.nested-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid #ebeef5;
}
.nested-card-index {
font-size: 12px;
color: #909399;
font-weight: 600;
}
.nested-card-body {
display: flex;
flex-direction: column;
gap: 8px;
}
.nested-card-field {
display: flex;
align-items: center;
gap: 8px;
}
.nested-card-field .field-label {
min-width: 80px;
font-size: 12px;
}
.nested-card-field .el-input,
.nested-card-field .el-input-number {
flex: 1;
}
.nested-sub-struct {
width: 100%;
padding: 8px;
border: 1px dashed #dcdfe6;
border-radius: 4px;
background: #f9f9f9;
display: flex;
flex-direction: column;
gap: 6px;
}
.struct-list-hint {
font-size: 12px;
color: #909399;
}
.struct-list-actions {
display: flex;
gap: 8px;
margin-top: 10px;
}
.value-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
/* Struct List Cards */
.struct-list-cards {
display: flex;
flex-direction: column;
gap: 10px;
}
.struct-list-card {
border: 1px solid #e4e7ed;
border-radius: 8px;
background: #ffffff;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.struct-list-card:hover {
border-color: #c0c4cc;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.struct-list-card.drag-over {
border-color: #409eff;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.15);
}
.struct-list-card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: #f8f9fc;
cursor: pointer;
user-select: none;
transition: background 0.2s;
}
.struct-list-card-header:hover {
background: #f0f2f5;
}
.card-header-left {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
}
.card-header-left .drag-handle {
cursor: grab;
color: #c0c4cc;
transition: color 0.2s;
flex-shrink: 0;
}
.card-header-left .drag-handle:hover {
color: #606266;
}
.collapse-arrow {
font-size: 12px;
color: #909399;
transition: transform 0.3s;
flex-shrink: 0;
}
.collapse-arrow.rotated {
transform: rotate(90deg);
}
.card-index {
font-size: 12px;
font-weight: 600;
color: #409eff;
background: #ecf5ff;
padding: 1px 8px;
border-radius: 10px;
flex-shrink: 0;
}
.card-summary {
font-size: 12px;
color: #606266;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-header-right {
flex-shrink: 0;
}
.struct-list-card-body {
padding: 14px 16px;
border-top: 1px solid #f0f2f5;
display: flex;
flex-direction: column;
gap: 10px;
}
.struct-list-field-row {
display: flex;
align-items: center;
gap: 10px;
}
.struct-list-field-row .field-label {
min-width: 90px;
font-size: 13px;
font-weight: 500;
color: #303133;
flex-shrink: 0;
}
.struct-list-field-row .field-input {
flex: 1;
min-width: 0;
}
.struct-file-picker {
display: flex;
align-items: center;
gap: 8px;
}
.struct-file-list-picker {
width: 100%;
}
.file-thumb-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.file-thumb {
width: 80px;
height: 80px;
border: 1px dashed #dcdfe6;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
overflow: hidden;
transition: border-color 0.2s;
background: #fafafa;
}
.file-thumb:hover {
border-color: #409eff;
}
.file-thumb.small {
width: 60px;
height: 60px;
}
.file-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.file-thumb-empty {
color: #c0c4cc;
font-size: 24px;
display: flex;
align-items: center;
justify-content: center;
}
.file-thumb.small .file-thumb-empty {
font-size: 18px;
}
.file-thumb-add {
border-style: dashed;
color: #c0c4cc;
font-size: 20px;
}
.file-thumb-add:hover {
color: #409eff;
border-color: #409eff;
}
.file-clear-btn {
flex-shrink: 0;
}
.file-thumb-item {
position: relative;
display: inline-flex;
flex-direction: column;
align-items: center;
cursor: grab;
transition: opacity 0.2s, transform 0.2s;
}
.file-thumb-item:active {
cursor: grabbing;
}
.file-thumb-item.drag-over {
transform: scale(1.05);
outline: 2px dashed #409eff;
border-radius: 6px;
}
.file-thumb-item .thumb-del-btn {
position: absolute;
top: -6px;
right: -6px;
background: #fff;
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
width: 18px;
height: 18px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s;
}
.file-thumb-item:hover .thumb-del-btn {
opacity: 1;
}
.struct-string-list {
width: 100%;
}
.string-list-items {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 6px;
}
.string-list-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border: 1px solid #ebeef5;
border-radius: 4px;
background: #fafafa;
cursor: grab;
transition: border-color 0.2s, box-shadow 0.2s;
}
.string-list-item:active {
cursor: grabbing;
}
.string-list-item.drag-over {
border-color: #409eff;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
}
.string-list-item .string-drag-handle {
color: #c0c4cc;
cursor: grab;
flex-shrink: 0;
}
.string-list-item .el-input {
flex: 1;
}
/* 响应式 */
@media (max-width: 1200px) {
.cards-grid {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
}
@media (max-width: 768px) {
.setting-manage-container {
padding: 12px;
gap: 12px;
}
.top-action-bar {
flex-direction: column;
gap: 12px;
padding: 12px;
}
.action-left {
width: 100%;
}
.search-input {
width: 100%;
flex: 1;
}
.action-right {
width: 100%;
flex-wrap: wrap;
}
.cards-grid {
grid-template-columns: 1fr;
}
.group-info-bar {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.group-actions {
flex-wrap: wrap;
}
.cards-section {
padding: 16px;
}
:deep(.dialog-scrollable .el-dialog) {
max-height: 95vh;
width: 95vw !important;
margin: 0 auto;
}
:deep(.dialog-scrollable .el-dialog__body) {
max-height: calc(95vh - 140px);
padding: 15px;
}
}
</style>