4180f73c53
- 订单列表重构为卡片式布局并新增筛选功能 - 设置管理支持struct/struct_list类型配置 - 新增短信签名和模板独立管理页面 - 通知渠道新增短信渠道配置 - 产品参数管理优化 Co-authored-by: Cursor <cursoragent@cursor.com>
4347 lines
142 KiB
Vue
4347 lines
142 KiB
Vue
<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>
|