Files
ApiServer-Web-admin_dashboa…/src/views/system/SettingManage.vue
T
lin f0e89695f4
Build and Deploy Vue3 / build (push) Successful in 4m9s
Build and Deploy Vue3 / deploy (push) Successful in 1m3s
fix: 修改新增用户商品的配置项逻辑
2026-04-06 18:44:11 +08:00

3740 lines
108 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="setting-manage-container">
<el-card class="main-container" shadow="never">
<!-- 搜索和操作栏 -->
<div class="filter-section">
<div class="filter-content">
<el-form :inline="true" :model="queryParams" class="search-form">
<el-form-item label="关键词筛选">
<el-input v-model="queryParams.key" placeholder="请输入关键词" clearable style="width: 200px" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleQuery">
<el-icon><Search /></el-icon>查询
</el-button>
<el-button @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<div class="action-bar">
<el-button type="primary" @click="handleAddGroup">
<el-icon><Plus /></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>
<el-button type="danger" :disabled="selectedRows.length === 0" @click="handleBatchDelete">
<el-icon><Delete /></el-icon>批量删除 ({{ selectedRows.length }})
</el-button>
</div>
</div>
</div>
<!-- 树状图显示区域 -->
<div class="tree-section">
<!-- 树形表格视图 -->
<el-table
ref="treeTableRef"
:data="treeDisplayData"
style="width: 100%"
row-key="id"
:header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }"
class="setting-tree-table"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="55" />
<el-table-column label="名称" min-width="300">
<template #default="{ row }">
<div class="tree-item-content" :style="{ paddingLeft: (row.level - 1) * 24 + 'px' }">
<!-- 展开/收起按钮 -->
<span
v-if="row.type === 'group' && row.hasChildren"
class="expand-icon"
@click="toggleExpand(row)"
>
<el-icon v-if="row._loading"><Loading /></el-icon>
<el-icon v-else :class="{ 'is-expanded': row._expanded }"><ArrowRight /></el-icon>
</span>
<span v-else class="expand-placeholder"></span>
<div class="tree-node">
<el-icon class="tree-icon" v-if="row.type === 'group'">
<Folder />
</el-icon>
<el-icon class="tree-icon" v-else-if="row.type === 'setting'">
<Document />
</el-icon>
<span class="tree-label">{{ row.label }}</span>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="类型" width="100">
<template #default="{ row }">
<el-tag v-if="row.type === 'group'" type="primary" size="small">配置组</el-tag>
<el-tag v-else-if="row.type === 'setting'" :type="getTypeColor(row.data.type)" size="small">
{{ row.data.type }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="值" min-width="200">
<template #default="{ row }">
<span v-if="row.type === 'group'" style="cursor: default;">{{ row.data.note || '-' }}</span>
<span v-else-if="row.type === 'setting'" style="cursor: pointer;" @click="handleEditSetting(row.data)">
<span v-if="row.data.type === 'bool'">{{ row.data.value ? '' : '' }}</span>
<span v-else-if="row.data.type === 'file'">
<el-link v-if="row.data.value" type="primary" @click.stop="previewFile(row.data.value)">
文件ID: {{ row.data.value }}
</el-link>
<span v-else>-</span>
</span>
<span v-else-if="row.data.type === 'file_list'">
<div v-if="row.data.value" class="file-list-summary">
<el-tag type="primary" size="small" @click.stop="handleEditSetting(row.data)" style="cursor: pointer;">
<el-icon><Folder /></el-icon>
{{ getFileList(row.data.value).length }} 个文件
</el-tag>
</div>
<span v-else>-</span>
</span>
<span v-else-if="row.data.type === 'string_list'">
<div v-if="row.data.value" class="string-list-summary">
<el-tag type="success" size="small" @click.stop="handleEditSetting(row.data)" style="cursor: pointer;">
<el-icon><Document /></el-icon>
{{ getStringList(row.data.value).length }} 条字符串
</el-tag>
</div>
<span v-else>-</span>
</span>
<span v-else>
<span class="text-value" :title="row.data.value">{{ truncateText(row.data.value, 30) }}</span>
</span>
</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag v-if="row.type === 'group'" type="success" size="small">正常</el-tag>
<el-tag v-else-if="row.type === 'setting'" :type="row.data.open ? 'success' : 'info'" size="small">
{{ row.data.open ? '开放' : '私有' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" width="180">
<template #default="{ row }">
{{ formatDate(row.data.CreatedAt) }}
</template>
</el-table-column>
<el-table-column label="备注" min-width="150" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.type === 'group'">{{ row.data.note || '-' }}</span>
<span v-else-if="row.type === 'setting'">{{ row.data.note || '-' }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="320" fixed="right">
<template #default="{ row }">
<el-button v-if="row.type === 'group'" type="primary" link size="small" @click="handleEditGroup(row.data)">
编辑
</el-button>
<el-button v-if="row.type === 'group'" type="success" link size="small" @click="handleAddSettingToGroup(row.data)">
新增配置
</el-button>
<el-button v-if="row.type === 'group'" type="warning" link size="small" @click="handleCopyGroupSettings(row)">
一键复制
</el-button>
<el-button v-if="row.type === 'group'" type="success" link size="small" @click="handleImportToGroup(row.data)">
一键导入
</el-button>
<el-button v-if="row.type === 'setting'" type="primary" link size="small" @click="handleEditSetting(row.data)">
编辑
</el-button>
<el-button type="danger" link size="small" @click="handleDelete(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</div>
<!-- 详细信息面板 -->
<div class="detail-section" v-if="selectedNode">
<el-divider content-position="left">{{ selectedNode.type === 'group' ? '配置组详情' : '配置详情' }}</el-divider>
<div class="detail-content">
<template v-if="selectedNode.type === 'group'">
<el-descriptions :column="2" border>
<el-descriptions-item label="ID">{{ selectedNode.data.id }}</el-descriptions-item>
<el-descriptions-item label="名称">{{ selectedNode.data.name }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ selectedNode.data.note || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDate(selectedNode.data.CreatedAt) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDate(selectedNode.data.UpdatedAt) }}</el-descriptions-item>
</el-descriptions>
</template>
<template v-else-if="selectedNode.type === 'setting'">
<el-descriptions :column="2" border>
<el-descriptions-item label="ID">{{ selectedNode.data.id }}</el-descriptions-item>
<el-descriptions-item label="名称">{{ selectedNode.data.name }}</el-descriptions-item>
<el-descriptions-item label="类型">
<el-tag :type="getTypeColor(selectedNode.data.type)">{{ selectedNode.data.type }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="配置组">{{ selectedNode.data.settingGroupID || '-' }}</el-descriptions-item>
<el-descriptions-item label="值" :span="2" class="value-descriptions-item">
<div class="value-content">
<span v-if="selectedNode.data.type === 'bool'">{{ selectedNode.data.value ? '' : '' }}</span>
<span v-else-if="selectedNode.data.type === 'file'">
<el-link v-if="selectedNode.data.value" type="primary" @click="previewFile(selectedNode.data.value)">
文件ID: {{ selectedNode.data.value }}
</el-link>
<span v-else>-</span>
</span>
<span v-else-if="selectedNode.data.type === 'file_list'">
<div v-if="selectedNode.data.value" class="table-file-list">
<div
v-for="(fileId, index) in getFileList(selectedNode.data.value)"
:key="fileId"
class="table-file-item"
>
<div class="file-preview-mini">
<img
v-if="selectedNode.data.parsedValue && selectedNode.data.parsedValue[index]"
:src="processImageUrl(selectedNode.data.parsedValue[index])"
:alt="`文件${index + 1}`"
class="preview-mini-image"
@click="previewImage(selectedNode.data.parsedValue[index])"
@error="handleImageError"
/>
<div v-else class="file-placeholder-mini">
<el-icon><Document /></el-icon>
</div>
</div>
<div class="file-info-mini">
<div class="file-name-mini" :title="`文件${index + 1}: ${fileId}`">
文件{{ index + 1 }}
</div>
<div class="file-id-mini">ID: {{ fileId }}</div>
</div>
</div>
</div>
<span v-else>-</span>
</span>
<span v-else-if="selectedNode.data.type === 'string_list'">
<div class="editable-string-list">
<div class="string-list-header">
<span class="list-title">字符串列表 ({{ getStringList(selectedNode.data.value).length }} )</span>
<el-button type="primary" size="small" @click="addEditableStringItem">添加项目</el-button>
</div>
<div class="string-list-items">
<div
v-for="(item, index) in getEditableStringList()"
:key="`editable-${index}`"
class="string-list-item"
draggable="true"
@dragstart="handleDragStart($event, index)"
@dragover.prevent
@drop="handleDrop($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="finishEditItem(index)"
@keyup.enter="finishEditItem(index)"
ref="editInput"
@mounted="focusEditInput"
/>
<span v-else class="item-text" @dblclick="startEditItem(index)">
{{ truncateFileName(item.value, 40) }}
</span>
</div>
<div class="item-actions">
<el-button type="text" size="small" @click="startEditItem(index)" :icon="Edit" />
<el-button type="text" size="small" @click="removeEditableStringItem(index)" :icon="Delete" class="danger-btn" />
</div>
</div>
</div>
</div>
</span>
<span v-else class="text-value">
{{ selectedNode.data.value }}
</span>
</div>
</el-descriptions-item>
<el-descriptions-item label="是否开放">
<el-tag :type="selectedNode.data.open ? 'success' : 'info'">
{{ selectedNode.data.open ? '开放' : '私有' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ selectedNode.data.note || '-' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDate(selectedNode.data.CreatedAt) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDate(selectedNode.data.UpdatedAt) }}</el-descriptions-item>
</el-descriptions>
</template>
</div>
</div>
</el-card>
<!-- 配置组表单对话框 -->
<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="整数 (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-form-item>
<el-form-item label="值" prop="value">
<div v-if="settingForm.type === 'string'" 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="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="(fileInfo, 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="fileInfo.url || fileInfo.localUrl"
:src="fileInfo.localUrl || processImageUrl(fileInfo.url)"
:alt="fileInfo.realName"
class="preview-image"
@click="previewImage(fileInfo.localUrl || fileInfo.url)"
@error="handleImageError"
/>
<div v-else class="file-placeholder">
<el-icon><Document /></el-icon>
</div>
<div v-if="fileInfo.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="fileInfo.realName || fileInfo.saveName">
{{ truncateFileName(fileInfo.realName || fileInfo.saveName, 30) }}
</div>
<div class="file-id">ID: {{ fileInfo.id || '上传中' }}</div>
<div class="file-size">{{ formatFileSize(fileInfo.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 === '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'"
@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, ArrowRight, Loading, Picture, Edit, Rank } 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()
// 树状图相关状态
const treeTableRef = ref(null)
const treeData = ref([])
const allGroupList = ref([]) // 存储所有已加载的配置组
const treeDataMap = ref(new Map()) // 存储树形数据,key为group_id
const selectedNode = ref(null)
const editableStringList = ref([]) // 弹窗中可编辑的字符串列表
const draggedIndex = ref(-1) // 拖拽的索引
// 表单相关可编辑列表
const editableFormStringList = ref([]) // 表单中可编辑的字符串列表
const editableFormFileList = ref([]) // 表单中可编辑的文件列表
const formDraggedIndex = ref(-1) // 表单拖拽索引
const formFileDraggedIndex = ref(-1) // 表单文件拖拽索引
// 查询参数
const queryParams = reactive({
key: '',
page: 1,
count: 10
})
// 树状图配置
const treeProps = {
children: 'children',
label: 'label',
hasChildren: 'hasChildren'
}
// 树形显示数据(扁平化用于表格显示)
const treeDisplayData = computed(() => {
const result = []
const rootItems = treeData.value || []
const flatten = (items, parentExpanded = true) => {
if (!parentExpanded) return
items.forEach(item => {
result.push(item)
if (item._expanded && item._children && item._children.length > 0) {
flatten(item._children, true)
}
})
}
flatten(rootItems)
return result
})
// ==================== 配置组相关 ====================
const groupForm = reactive({
id: undefined,
name: '',
note: ''
})
const groupRules = {
name: [
{ required: true, message: '请输入配置组名称', trigger: 'blur' }
]
}
const groupLoading = ref(false)
const groupList = ref([])
const groupTotal = ref(0)
const groupDialogVisible = ref(false)
const groupDialogTitle = ref('新增配置组')
const groupFormRef = ref(null)
// 添加缺失的变量
const groupQueryParams = reactive({
key: '',
page: 1,
count: 10
})
const settingQueryParams = reactive({
group_id: undefined,
key: '',
page: 1,
count: 10
})
// ==================== 配置相关 ====================
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 settingLoading = ref(false)
const settingList = ref([])
const settingTotal = ref(0)
const settingDialogVisible = ref(false)
const settingDialogTitle = ref('新增配置')
const settingFormRef = ref(null)
const toggleLoading = 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') // 'single' 或 'list'
// 批量选择相关
const selectedRows = ref([])
// 格式化日期时间
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 sortBySimilarity = (settings) => {
if (!settings || settings.length <= 1) return settings
// 提取所有配置项的名称
const names = settings.map(item => item.name || '')
// 按名称排序,相近的名称会排在一起
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')
})
}
// 加载配置项子节点
const loadChildren = async (row) => {
if (!row || row.type !== 'group') return
row._loading = true
try {
const groupId = row.data.id
console.log('Loading children for group:', groupId)
const res = await getSettingList({ group_id: groupId, page: 1, count: 10 })
console.log('getSettingList response:', res.data)
if (res.data.code === 200) {
let settings = res.data.data.data || []
console.log('settings loaded:', settings)
// 应用相近字排序
settings = sortBySimilarity(settings)
console.log('settings after sorting:', settings)
const settingNodes = settings.map(setting => ({
id: `setting_${setting.id}`,
label: setting.name,
type: 'setting',
data: setting,
level: row.level + 1,
hasChildren: false, // 配置项没有子节点
_expanded: false,
_children: [],
_loading: false
}))
console.log('settingNodes created:', settingNodes)
row._children = settingNodes
console.log('updated row with children:', row)
}
} catch (error) {
console.error('加载配置列表失败:', error)
ElMessage.error('加载配置列表失败')
} finally {
row._loading = false
}
}
// 切换展开状态
const toggleExpand = async (row) => {
if (row._loading) return
if (row._expanded) {
// 收起
row._expanded = false
} else {
// 展开 - 如果还没加载子级,先加载
if (row.type === 'group' && (!row._children || row._children.length === 0)) {
await loadChildren(row)
}
row._expanded = true
}
}
// 初始化加载配置组
const loadGroups = async () => {
try {
const res = await getSettingGroupList({ page: 1, count: 10 })
console.log('getSettingGroupList response:', res.data)
if (res.data.code === 200) {
const groups = res.data.data.data || []
console.log('groups loaded:', groups)
const treeNodes = groups.map(group => ({
id: `group_${group.id}`,
label: group.name,
type: 'group',
data: group,
level: 1,
hasChildren: true, // 标记有子节点
_expanded: false,
_children: [],
_loading: false
}))
console.log('treeNodes created:', treeNodes)
treeData.value = treeNodes
allGroupList.value = groups
console.log('data set to reactive refs')
}
} catch (error) {
console.error('加载配置组失败:', error)
ElMessage.error('加载配置组失败')
}
}
// 节点点击事件
const handleNodeClick = (row) => {
selectedNode.value = row
// 如果是字符串列表类型,初始化可编辑列表
if (row.data.type === 'string_list') {
initEditableStringList()
}
}
// 查询处理
const handleQuery = () => {
// 重新加载树
treeData.value = []
selectedNode.value = null
// 重新加载配置组
loadGroups()
}
// 重置查询
const resetQuery = () => {
queryParams.key = ''
handleQuery()
}
// 批量删除
const handleBatchDelete = () => {
if (selectedRows.value.length === 0) {
ElMessage.warning('请先选择要删除的项目')
return
}
// 分离配置组和配置项
const groups = selectedRows.value.filter(row => row.type === 'group')
const settings = selectedRows.value.filter(row => row.type === 'setting')
let confirmMessage = ''
if (groups.length > 0 && settings.length > 0) {
confirmMessage = `确认删除选中的 ${groups.length} 个配置组和 ${settings.length} 个配置项吗?删除配置组将同时删除其下的所有配置项。`
} else if (groups.length > 0) {
confirmMessage = `确认删除选中的 ${groups.length} 个配置组吗?删除配置组将同时删除其下的所有配置项。`
} else {
confirmMessage = `确认删除选中的 ${settings.length} 个配置项吗?`
}
ElMessageBox.confirm(confirmMessage, '批量删除警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
const deletePromises = []
// 删除配置组
groups.forEach(row => {
deletePromises.push(deleteSettingGroup({ setting_group_id: row.data.id }))
})
// 删除配置项
settings.forEach(row => {
deletePromises.push(deleteSetting({ id: row.data.id }))
})
try {
const results = await Promise.all(deletePromises)
const successCount = results.filter(res => res.data.code === 200).length
if (successCount === selectedRows.value.length) {
ElMessage.success('批量删除成功')
} else {
ElMessage.warning(`成功删除 ${successCount} 项,失败 ${selectedRows.value.length - successCount}`)
}
// 清空选择
selectedRows.value = []
handleQuery()
} catch (error) {
console.error('批量删除失败:', error)
ElMessage.error('批量删除失败')
}
}).catch(() => {})
}
// 删除节点
const handleDelete = (data) => {
const type = data.type
const rowData = data.data
if (type === 'group') {
ElMessageBox.confirm(`确认删除配置组 "${rowData.name}" 吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteSettingGroup({ setting_group_id: rowData.id })
if (res.data.code === 200) {
ElMessage.success('删除成功')
handleQuery()
}
} catch (error) {
console.error('删除失败:', error)
ElMessage.error(error.response?.data?.message || '删除失败')
}
}).catch(() => {})
} else if (type === 'setting') {
ElMessageBox.confirm(`确认删除配置 "${rowData.name}" 吗?`, '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(async () => {
try {
const res = await deleteSetting({ id: rowData.id })
if (res.data.code === 200) {
ElMessage.success('删除成功')
handleQuery()
}
} catch (error) {
console.error('删除失败:', error)
ElMessage.error(error.response?.data?.message || '删除失败')
}
}).catch(() => {})
}
}
// 处理表格选择变化
const handleSelectionChange = (selection) => {
selectedRows.value = selection
}
// ==================== 配置组方法 ====================
// 新增配置组
const handleAddGroup = () => {
groupDialogTitle.value = '新增配置组'
Object.assign(groupForm, {
id: undefined,
name: '',
note: ''
})
groupDialogVisible.value = true
}
// 编辑配置组
const handleEditGroup = async (row) => {
groupDialogTitle.value = '编辑配置组'
try {
const res = await getSettingGroupInfo({ setting_group_id: row.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 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
handleQuery()
}
} 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: selectedNode.value?.type === 'group' ? selectedNode.value.data.id : undefined,
open: true,
note: ''
})
fileInfo.value = null
fileListInfo.value = []
newStringItem.value = ''
settingDialogVisible.value = true
}
// 为指定配置组新增配置
const handleAddSettingToGroup = (groupData) => {
settingDialogTitle.value = '新增配置'
Object.assign(settingForm, {
id: undefined,
name: '',
value: '',
type: 'string',
settingGroupID: groupData.id,
open: true,
note: ''
})
fileInfo.value = null
fileListInfo.value = []
newStringItem.value = ''
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 === 'file') {
// 处理单个文件类型,获取文件信息
if (data.parsedValue && typeof data.parsedValue === 'string') {
fileInfo.value = {
id: data.value,
url: processImageUrl(data.parsedValue), // 使用processImageUrl处理URL
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') {
// 处理文件列表类型,使用parsedValue来获取文件信息
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), // 使用processImageUrl处理URL
realName: `文件${index + 1}`,
saveName: `file_${index}`,
size: 0
}
})
// 确保在设置fileListInfo后再初始化表单可编辑文件列表
nextTick(() => {
initEditableFormFileList()
})
} else {
fileListInfo.value = []
editableFormFileList.value = []
}
}
settingDialogVisible.value = true
}
} catch (error) {
console.error('获取配置详情失败:', error)
ElMessage.error('获取配置详情失败')
}
}
// 获取类型颜色
const getTypeColor = (type) => {
const colorMap = {
'string': 'primary',
'int': 'success',
'float': 'warning',
'bool': 'info',
'file': 'danger',
'file_list': 'danger',
'string_list': 'primary'
}
return colorMap[type] || ''
}
// 添加缺失的辅助函数
const fetchGroupList = async () => {
groupLoading.value = true
try {
const res = await getSettingGroupList(groupQueryParams)
if (res.data.code === 200) {
groupList.value = res.data.data.data || []
groupTotal.value = res.data.data.all_count || 0
}
} catch (error) {
console.error('获取配置组列表失败:', error)
ElMessage.error('获取配置组列表失败')
} finally {
groupLoading.value = false
}
}
const fetchAllGroupList = async () => {
try {
const res = await getSettingGroupList({ page: 1, count: 10 })
if (res.data.code === 200) {
allGroupList.value = res.data.data.data || []
}
} catch (error) {
console.error('获取配置组列表失败:', error)
}
}
// JSON 值检测与格式化
const isJsonValue = computed(() => {
if (settingForm.type !== 'string' || !settingForm.value) return false
const v = 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 {
settingForm.value = ''
}
// 重置所有相关状态
fileInfo.value = null
fileListInfo.value = []
editableFormStringList.value = []
editableFormFileList.value = []
newStringItem.value = ''
}
// 文件相关函数
const handleFileChange = async (file) => {
fileUploading.value = true
// 创建本地预览URL
const localUrl = URL.createObjectURL(file.raw)
const isImage = file.raw.type.startsWith('image/')
// 立即显示本地预览
fileInfo.value = {
id: '', // 暂时为空,上传成功后会更新
url: isImage ? localUrl : '',
realName: file.name,
saveName: file.name,
size: file.size,
isLocal: true // 标记为本地文件
}
try {
const formData = new FormData()
formData.append('file_names', file.name)
formData.append('files', file.raw)
formData.append('update_type','cover')
formData.append('open_down','true')
const res = await uploadFile(formData)
if (res.data.code === 200 && res.data.data && res.data.data.length > 0) {
const uploadedFile = res.data.data[0]
settingForm.value = String(uploadedFile.id || '')
// 释放本地URL(暂时不释放,保留用于渲染)
// if (fileInfo.value?.isLocal) {
// URL.revokeObjectURL(fileInfo.value.url)
// }
// 更新为服务器返回的文件信息,但保留本地URL用于渲染
fileInfo.value = {
id: uploadedFile.id || '',
url: processImageUrl(uploadedFile.url || uploadedFile.realName || ''),
localUrl: fileInfo.value?.url || '', // 保留本地URL用于渲染
realName: uploadedFile.realName || '文件',
saveName: uploadedFile.saveName || 'file',
size: uploadedFile.size || 0,
isLocal: false // 标记为已上传,但保留本地渲染
}
ElMessage.success('文件上传成功')
} else {
// 上传失败,保留本地预览,但显示错误消息
ElMessage.error(res.data.message || '文件上传失败')
// 注意:不清理本地预览,让用户可以重新上传或删除
}
} catch (error) {
console.error('文件上传失败:', error)
ElMessage.error('文件上传失败')
} finally {
fileUploading.value = false
}
}
const clearFile = () => {
// 释放本地URL
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) // 留3个字符给"...和扩展名"
console.log('111',truncatedName + '...' + extension)
return truncatedName + '...' + extension
}
// ==================== 可编辑字符串列表功能 ====================
// 获取可编辑的字符串列表
const getEditableStringList = () => {
if (!selectedNode.value || selectedNode.value.data.type !== 'string_list') {
return []
}
return editableStringList.value
}
// 初始化可编辑字符串列表
const initEditableStringList = () => {
if (!selectedNode.value || selectedNode.value.data.type !== 'string_list') {
editableStringList.value = []
return
}
const stringItems = getStringList(selectedNode.value.data.value)
editableStringList.value = stringItems.map(item => ({
value: item,
editing: false
}))
}
// 开始拖拽
const handleDragStart = (event, index) => {
draggedIndex.value = index
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/html', event.target.outerHTML)
event.target.style.opacity = '0.5'
}
// 拖拽放下
const handleDrop = (event, dropIndex) => {
event.preventDefault()
const dragIndex = draggedIndex.value
if (dragIndex === dropIndex) return
// 重新排列数组
const newList = [...editableStringList.value]
const [draggedItem] = newList.splice(dragIndex, 1)
newList.splice(dropIndex, 0, draggedItem)
editableStringList.value = newList
draggedIndex.value = -1
// 更新selectedNode中的数据
const updatedValues = newList.map(item => item.value)
selectedNode.value.data.value = updatedValues.join(',')
event.target.style.opacity = '1'
}
// 添加新字符串项目
const addEditableStringItem = () => {
const newItem = {
value: '',
editing: true
}
editableStringList.value.push(newItem)
// 更新selectedNode中的数据
const updatedValues = editableStringList.value.map(item => item.value)
selectedNode.value.data.value = updatedValues.join(',')
// 聚焦到新添加的输入框
nextTick(() => {
const inputs = document.querySelectorAll('.string-list-item input')
if (inputs.length > 0) {
inputs[inputs.length - 1].focus()
}
})
}
// 开始编辑项目
const startEditItem = (index) => {
editableStringList.value[index].editing = true
nextTick(() => {
const inputs = document.querySelectorAll('.string-list-item input')
if (inputs[index]) {
inputs[index].focus()
inputs[index].select()
}
})
}
// 完成编辑项目
const finishEditItem = (index) => {
editableStringList.value[index].editing = false
// 更新selectedNode中的数据
const updatedValues = editableStringList.value.map(item => item.value)
selectedNode.value.data.value = updatedValues.join(',')
}
// 删除字符串项目
const removeEditableStringItem = (index) => {
ElMessageBox.confirm('确定要删除这个项目吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
editableStringList.value.splice(index, 1)
// 更新selectedNode中的数据
const updatedValues = editableStringList.value.map(item => item.value)
selectedNode.value.data.value = updatedValues.join(',')
ElMessage.success('删除成功')
})
}
// 聚焦输入框
const focusEditInput = () => {
// 这个方法会被 @mounted 调用
}
// ==================== 表单可编辑字符串列表功能 ====================
// 获取表单可编辑的字符串列表
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 = () => {
// 这个方法会被 @mounted 调用
}
// ==================== 表单可编辑文件列表功能 ====================
// 获取表单可编辑的文件列表
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 || '', // 保留本地URL字段
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和表单值(使用独立副本,避免引用同一数组)
fileListInfo.value = newList.map(item => ({ ...item }))
updateFormFileListValue()
event.target.style.opacity = '1'
}
// 表单文件列表变更
const handleFormFileListChange = async (file) => {
// 创建本地预览URL
const localUrl = URL.createObjectURL(file.raw)
const isImage = file.raw.type.startsWith('image/')
// 立即添加到表单文件列表
const tempFile = {
id: '', // 暂时为空,上传成功后会更新
url: isImage ? localUrl : '',
realName: file.name,
saveName: file.name,
size: file.size,
isLocal: true,
uploading: true
}
editableFormFileList.value.push(tempFile)
try {
const formData = new FormData()
formData.append('file_names', file.name)
formData.append('files', file.raw)
formData.append('update_type','cover')
formData.append('open_down','true')
const res = await uploadFile(formData)
if (res.data.code === 200 && res.data.data && res.data.data.length > 0) {
const uploadedFile = res.data.data[0]
// 找到对应的本地文件并更新
const index = editableFormFileList.value.findIndex(f => f.isLocal && f.realName === file.name)
if (index !== -1) {
editableFormFileList.value[index] = {
id: uploadedFile.id || '',
url: processImageUrl(uploadedFile.url || uploadedFile.realName || ''),
localUrl: localUrl, // 保留本地URL用于渲染
realName: uploadedFile.realName || '文件',
saveName: uploadedFile.saveName || 'file',
size: uploadedFile.size || 0,
isLocal: false,
uploading: false
}
}
updateFormFileListValue()
ElMessage.success('文件上传成功')
} else {
// 上传失败,移除临时文件
const index = editableFormFileList.value.findIndex(f => f.isLocal && f.realName === file.name)
if (index !== -1) {
editableFormFileList.value.splice(index, 1)
}
ElMessage.error(res.data.message || '文件上传失败')
}
} catch (error) {
console.error('文件上传失败:', error)
// 上传失败,移除临时文件
const index = editableFormFileList.value.findIndex(f => f.isLocal && f.realName === file.name)
if (index !== -1) {
editableFormFileList.value.splice(index, 1)
}
ElMessage.error('文件上传失败')
}
}
// 表单文件替换
const handleFormFileReplace = async (file, index) => {
if (!editableFormFileList.value[index]) return
// 创建本地预览URL
const localUrl = URL.createObjectURL(file.raw)
const isImage = file.raw.type.startsWith('image/')
// 更新文件信息
editableFormFileList.value[index] = {
...editableFormFileList.value[index],
url: isImage ? localUrl : '',
realName: file.name,
saveName: file.name,
size: file.size,
isLocal: true,
uploading: true
}
try {
const formData = new FormData()
formData.append('file_names', file.name)
formData.append('files', file.raw)
formData.append('update_type','cover')
formData.append('open_down','true')
const res = await uploadFile(formData)
if (res.data.code === 200 && res.data.data && res.data.data.length > 0) {
const uploadedFile = res.data.data[0]
// 更新文件信息
editableFormFileList.value[index] = {
id: uploadedFile.id || '',
url: processImageUrl(uploadedFile.url || uploadedFile.realName || ''),
localUrl: localUrl, // 保留本地URL用于渲染
realName: uploadedFile.realName || '文件',
saveName: uploadedFile.saveName || 'file',
size: uploadedFile.size || 0,
isLocal: false,
uploading: false
}
updateFormFileListValue()
ElMessage.success('文件替换成功')
} else {
// 上传失败,恢复原文件信息
ElMessage.error(res.data.message || '文件替换失败')
}
} catch (error) {
console.error('文件替换失败:', error)
ElMessage.error('文件替换失败')
}
}
// 删除表单文件项目
const removeFormEditableFileItem = (index) => {
ElMessageBox.confirm('确定要删除这个文件吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
// 释放本地URL
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和表单值
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 {
// 先获取文件详情拿到下载URL
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 previewUrl = (url) => {
window.open(url, '_blank')
}
// 检查是否为图片文件
const isImageFile = (url) => {
if (!url) return false
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg']
// 使用相同的URL处理逻辑
const processedUrl = processImageUrl(url)
const extension = processedUrl.toLowerCase().substring(processedUrl.lastIndexOf('.'))
return imageExtensions.includes(extension)
}
// 处理图片URL,确保正确显示
const processImageUrl = (url) => {
if (!url) return ''
// 先处理转义字符:将 \u0026 替换为 &
let processedUrl = url.replace(/\\u0026/g, '&')
// 再进行URL解码
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 === '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(表单UI读取的数据源)
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(表单UI读取的数据源)
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
}
// 同步更新 editableFormFileList
if (editableFormFileList.value && editableFormFileList.value[index] !== undefined) {
editableFormFileList.value[index] = {
...updatedFile,
localUrl: '',
uploading: false
}
}
updateFormFileListValue()
ElMessage.success('文件替换成功')
}
imageSelectorVisible.value = false
}
const getFileList = (value) => {
if (!value || typeof value !== 'string') return []
return value.split(',').filter(id => id && id.trim())
}
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 (text.length <= maxLength) return text
return text.substring(0, maxLength) + '...'
}
const handleFileListChange = async (file) => {
fileUploading.value = true
// 创建本地预览URL
const localUrl = URL.createObjectURL(file.raw)
const isImage = file.raw.type.startsWith('image/')
// 立即添加本地预览到列表
if (!fileListInfo.value) {
fileListInfo.value = []
}
const tempFile = {
id: '', // 暂时为空,上传成功后会更新
url: isImage ? localUrl : '',
realName: file.name,
saveName: file.name,
size: file.size,
isLocal: true // 标记为本地文件
}
fileListInfo.value.push(tempFile)
try {
const formData = new FormData()
formData.append('file_names', file.name)
formData.append('files', file.raw)
formData.append('update_type','cover')
formData.append('open_down','true')
const res = await uploadFile(formData)
if (res.data.code === 200 && res.data.data && res.data.data.length > 0) {
const uploadedFile = res.data.data[0]
const currentFileIds = getFileList(settingForm.value)
currentFileIds.push(String(uploadedFile.id || ''))
settingForm.value = currentFileIds.join(',')
// 找到对应的本地文件并更新
const index = fileListInfo.value.findIndex(f => f.isLocal && f.realName === file.name)
if (index !== -1) {
// 释放本地URL(暂时不释放,保留用于渲染)
// if (fileListInfo.value[index].isLocal) {
// URL.revokeObjectURL(fileListInfo.value[index].url)
// }
// 更新为服务器返回的文件信息,但保留本地URL用于渲染
fileListInfo.value[index] = {
id: uploadedFile.id || '',
url: processImageUrl(uploadedFile.url || uploadedFile.realName || ''),
localUrl: fileListInfo.value[index]?.url || '', // 保留本地URL用于渲染
realName: uploadedFile.realName || '文件',
saveName: uploadedFile.saveName || 'file',
size: uploadedFile.size || 0,
isLocal: false // 标记为已上传,但保留本地渲染
}
}
updateFileListValue()
ElMessage.success('文件上传成功')
} else {
// 上传失败,不清理本地预览,让用户可以重新上传或删除
ElMessage.error(res.data.message || '文件上传失败')
}
} catch (error) {
console.error('文件上传失败:', error)
// 上传失败,不清理本地预览,让用户可以重新上传或删除
ElMessage.error('文件上传失败')
} finally {
fileUploading.value = false
}
}
const removeFile = (index) => {
if (!fileListInfo.value || index < 0 || index >= fileListInfo.value.length) {
return
}
// 释放本地URL
if (fileListInfo.value[index].localUrl) {
URL.revokeObjectURL(fileListInfo.value[index].localUrl)
}
if (fileListInfo.value[index].isLocal && fileListInfo.value[index].url) {
URL.revokeObjectURL(fileListInfo.value[index].url)
}
const currentFileIds = getFileList(settingForm.value) || []
currentFileIds.splice(index, 1)
settingForm.value = currentFileIds.join(',')
fileListInfo.value.splice(index, 1)
}
// 更新文件列表值
const updateFileListValue = () => {
if (!fileListInfo.value || !Array.isArray(fileListInfo.value)) {
return
}
const fileIds = fileListInfo.value.map(file => file.id).filter(id => id)
settingForm.value = fileIds.join(',')
}
const addStringItem = () => {
if (newStringItem.value.trim()) {
const currentItems = getStringList(settingForm.value)
currentItems.push(newStringItem.value.trim())
settingForm.value = currentItems.join(',')
newStringItem.value = ''
}
}
const removeStringItem = (index) => {
const currentItems = getStringList(settingForm.value)
currentItems.splice(index, 1)
settingForm.value = currentItems.join(',')
}
// 提交配置表单
const submitSettingForm = async () => {
if (!settingFormRef.value) return
try {
await settingFormRef.value.validate()
const submitData = {
id: settingForm.id,
name: settingForm.name,
value: settingForm.value,
type: settingForm.type,
setting_group_id: settingForm.settingGroupID,
open: settingForm.open,
note: settingForm.note
}
// 保存原始的开放状态用于比较
const originalOpen = selectedNode.value?.data?.open
const newOpen = settingForm.open
const res = settingForm.id
? await updateSetting(submitData)
: await createSetting(submitData)
if (res.data.code === 200) {
// 如果是修改配置且开放状态有变化,调用setSettingOpen
if (settingForm.id && originalOpen !== newOpen) {
try {
await setSettingOpen({
id: settingForm.id,
open: newOpen
})
console.log('配置开放状态已更新:', { id: settingForm.id, open: newOpen })
} catch (openError) {
console.error('更新开放状态失败:', openError)
ElMessage.warning('配置已更新,但开放状态更新失败')
}
}
ElMessage.success(settingForm.id ? '修改成功' : '创建成功')
settingDialogVisible.value = false
// 重新加载数据
if (settingForm.settingGroupID) {
// 找到对应的配置组并重新加载其配置项
const groupNode = treeData.value.find(item =>
item.type === 'group' && item.data.id === settingForm.settingGroupID
)
if (groupNode) {
groupNode._children = []
groupNode._expanded = false
await toggleExpand(groupNode)
}
} else {
// 重新加载所有数据
loadGroups()
}
} else {
ElMessage.error(res.data.message || '操作失败')
}
} catch (error) {
console.error('提交配置失败:', error)
ElMessage.error('操作失败')
}
}
// ==================== 一键导入配置 ====================
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('')
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)
}
}
/**
* 解析 Markdown 表格文本为配置项数组
* 支持格式:
* | 配置名 | 类型 | 默认值 | 说明 |
* |--------|------|--------|------|
* | `移动端_主题主色` | `string` | `#2B7EFB` | 按钮、链接、选中态主色 |
*/
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', 'int', 'float', 'bool', 'file', 'file_list', 'string_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)) {
console.warn(`跳过无效类型 "${type}" (配置名: ${name})`)
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 = []
// 步骤 1:确保配置组存在
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
}
}
// 步骤 2:逐条导入配置项
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()
await nextTick()
const groupNode = treeData.value.find(item =>
item.type === 'group' && item.data.id === targetGroupId
)
if (groupNode) {
groupNode._children = []
groupNode._expanded = false
await toggleExpand(groupNode)
}
}
// 一键复制:将配置组的所有配置项格式化为 Markdown 批量导入表格并复制到剪贴板
const handleCopyGroupSettings = async (row) => {
const groupId = row.data.id
const groupName = row.data.name
try {
let settings = []
if (row._expanded && row._children && row._children.length > 0) {
settings = row._children.map(child => child.data)
} else {
const res = await getSettingList({ group_id: groupId, page: 1, count: 10 })
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('复制失败,请重试')
}
}
// 一键导入:打开批量导入弹窗并预选当前配置组
const handleImportToGroup = (groupData) => {
batchImportText.value = ''
batchImportParsed.value = []
batchImportGroupId.value = groupData.id
batchImportGroupName.value = groupData.name
batchImportGroupExists.value = true
batchImportOpen.value = true
batchImportProgress.value = 0
batchImportTotal.value = 0
batchImportStatusText.value = ''
batchImportDialogVisible.value = true
}
// 初始化
onMounted(() => {
// 初始化时加载配置组数据
loadGroups()
})
</script>
<style scoped>
.setting-manage-container {
padding: 0;
}
.main-container {
border: 1px solid #e1e8ed;
background: #ffffff;
}
/* 树状图样式 */
.tree-section {
padding: 20px;
min-height: 400px;
max-height: 500px;
overflow-y: auto;
/* 隐藏滚动条 */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
.tree-section::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
.setting-tree-table {
background: white;
}
/* 树状层级样式 */
.tree-item-content {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
}
.expand-icon {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: #606266;
transition: transform 0.2s ease;
}
.expand-icon:hover {
color: #409eff;
}
.expand-icon .is-expanded {
transform: rotate(90deg);
}
.expand-placeholder {
width: 20px;
height: 20px;
}
.tree-node {
display: flex;
align-items: center;
gap: 8px;
}
.tree-icon {
color: #409eff;
font-size: 16px;
flex-shrink: 0;
display: flex;
align-items: center;
}
.tree-label {
font-weight: 500;
color: #303133;
font-size: 14px;
flex: 1;
line-height: 16px; /* 与图标高度保持一致 */
}
/* 表格中文字值样式 */
.text-value {
display: inline-block;
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
}
/* 文件预览样式 */
.file-preview {
margin-top: 8px;
position: relative;
flex-shrink: 0;
}
.preview-image {
width: 80px;
height: 80px;
border-radius: 4px;
cursor: pointer;
transition: transform 0.2s ease;
border: 1px solid #e1e8ed;
object-fit: cover;
}
.preview-image:hover {
transform: scale(1.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.file-preview-link {
margin-top: 8px;
}
.file-item {
border: 1px solid #e1e8ed;
border-radius: 4px;
padding: 12px;
margin-bottom: 8px;
background: #fafbfc;
display: flex;
align-items: center;
gap: 12px;
}
.file-placeholder {
width: 80px;
height: 80px;
border-radius: 4px;
border: 1px solid #e1e8ed;
background: #f5f7fa;
display: flex;
align-items: center;
justify-content: center;
color: #909399;
font-size: 24px;
}
.file-details {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.file-actions {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-end;
flex-shrink: 0;
}
.file-name {
font-weight: 500;
color: #303133;
}
.file-id {
font-size: 12px;
color: #909399;
}
.file-size {
font-size: 12px;
color: #909399;
}
/* 表格行样式 */
:deep(.setting-tree-table .el-table__row--level-1) {
background-color: #fafbfc;
}
:deep(.setting-tree-table .el-table__row--level-2) {
background-color: #f5f7fa;
}
/* 表格样式优化 */
:deep(.setting-tree-table .el-table) {
border: none;
color: #2c3e50;
}
:deep(.setting-tree-table .el-table__header) {
background: #f8f9fa;
}
:deep(.setting-tree-table .el-table th) {
background: #f8f9fa !important;
border-bottom: 2px solid #e1e8ed;
color: #2c3e50;
font-weight: 600;
font-size: 13px;
}
:deep(.setting-tree-table .el-table td) {
border-bottom: 1px solid #f0f2f5;
color: #34495e;
}
:deep(.setting-tree-table .el-table tr:hover > td) {
background-color: #f8f9fa !important;
}
/* 详细信息面板样式 */
.detail-section {
padding: 20px;
background: #fafbfc;
border-top: 1px solid #e1e8ed;
}
.detail-content {
background: white;
border-radius: 8px;
padding: 20px;
}
.filter-section {
padding: 0;
border-bottom: 1px solid #e1e8ed;
background: #fafbfc;
margin: 0 -20px;
}
.filter-content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
gap: 20px;
flex-wrap: wrap;
}
.search-form {
margin: 0;
flex: 1;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.search-form :deep(.el-form-item) {
margin-bottom: 0;
margin-right: 12px;
}
.action-bar {
display: flex;
gap: 12px;
flex-shrink: 0;
}
.table-section {
padding: 20px 0;
}
.pagination {
margin-top: 20px;
padding: 16px 0;
border-top: 1px solid #e1e8ed;
justify-content: flex-end;
}
/* 表格样式优化 */
:deep(.el-table) {
border: none;
color: #2c3e50;
}
:deep(.el-table__header) {
background: #f8f9fa;
}
:deep(.el-table th) {
background: #f8f9fa !important;
border-bottom: 2px solid #e1e8ed;
color: #2c3e50;
font-weight: 600;
font-size: 13px;
}
:deep(.el-table td) {
border-bottom: 1px solid #f0f2f5;
color: #34495e;
}
:deep(.el-table tr:hover > td) {
background-color: #f8f9fa !important;
}
:deep(.dialog-scrollable .el-dialog) {
max-height: 90vh;
display: flex;
flex-direction: column;
}
:deep(.dialog-scrollable .el-dialog__body) {
max-height: calc(90vh - 120px);
overflow-y: auto;
padding: 20px;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
/* 完全隐藏滚动条但保持滚动功能 */
:deep(.dialog-scrollable .el-dialog__body)::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
/* 确保弹窗内容区域正确布局 */
: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;
}
/* 移动端适配 */
@media (max-width: 768px) {
: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;
}
:deep(.dialog-scrollable .el-dialog__footer) {
padding: 15px;
}
.tree-section {
max-height: 300px;
min-height: 300px;
padding: 10px;
}
.tree-item-content {
padding: 4px 0;
}
.tree-label {
font-size: 13px;
line-height: 14px; /* 移动端调整行高 */
}
.tree-icon {
font-size: 14px;
}
:deep(.setting-tree-table .el-table th) {
font-size: 12px;
padding: 8px 4px;
}
:deep(.setting-tree-table .el-table td) {
font-size: 12px;
padding: 8px 4px;
}
}
/* 确保表单在弹窗中正确显示 */
:deep(.dialog-scrollable .el-form) {
margin: 0;
}
:deep(.dialog-scrollable .el-form-item) {
margin-bottom: 20px;
}
/* 文件上传组件在弹窗中的样式优化 */
:deep(.dialog-scrollable .file-uploader) {
margin-bottom: 10px;
}
:deep(.dialog-scrollable .file-info-display),
:deep(.dialog-scrollable .file-list-info-display) {
margin-bottom: 15px;
}
:deep(.el-tabs__header) {
margin: 0;
padding: 0 0 0 0;
border-bottom: 1px solid #e1e8ed;
}
:deep(.el-tabs__nav-wrap::after) {
display: none;
}
:deep(.el-tabs__item) {
padding: 0 20px;
height: 50px;
line-height: 50px;
font-size: 14px;
font-weight: 500;
}
:deep(.el-tabs__item.is-active) {
color: #2c3e50;
font-weight: 600;
}
:deep(.el-tabs__active-bar) {
background-color: #2c3e50;
}
/* JSON 编辑工具栏 */
.json-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
/* 导入进度面板 */
.import-progress-panel {
padding: 40px 20px;
text-align: center;
}
.progress-header {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-bottom: 24px;
}
.progress-info {
max-width: 500px;
margin: 0 auto;
}
/* 文件上传相关样式 */
.file-upload-section {
width: 100%;
}
.file-uploader {
width: 100%;
}
.file-upload-options {
width: 100%;
}
.upload-methods {
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
}
.divider {
position: relative;
text-align: center;
color: #909399;
font-size: 14px;
margin: 8px 0;
}
.divider::before {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
background: #dcdfe6;
}
.divider {
background: #fff;
padding: 0 16px;
}
.image-selector-btn {
width: 200px;
height: 40px;
}
:deep(.file-uploader .el-upload) {
width: 100%;
}
:deep(.file-uploader .el-upload-dragger) {
width: 100%;
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: border-color 0.3s;
}
:deep(.file-uploader .el-upload-dragger:hover) {
border-color: #409eff;
}
.file-info-display {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
border: 1px solid #e4e7ed;
border-radius: 6px;
background-color: #f5f7fa;
}
.file-details {
display: flex;
flex-direction: column;
gap: 4px;
}
.file-name {
font-weight: 500;
color: #303133;
max-width: 200px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
display: inline-block;
}
.file-id {
font-size: 12px;
color: #909399;
}
.file-size {
font-size: 12px;
color: #909399;
}
/* 文件列表相关样式 */
.file-list-section {
width: 100%;
}
.file-list-info-display {
margin-bottom: 16px;
}
.file-list-details {
display: flex;
flex-direction: column;
gap: 8px;
}
.file-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px;
border: 1px solid #e4e7ed;
border-radius: 6px;
background-color: #f5f7fa;
}
.file-item .file-details {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.file-item .file-name {
font-weight: 500;
color: #303133;
max-width: 180px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
display: inline-block;
cursor: pointer;
transition: color 0.3s;
}
.file-item .file-name:hover {
color: #409eff;
}
/* 上传状态指示器样式 */
.file-item.uploading {
border-color: #409eff;
background-color: #f0f9ff;
}
.upload-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: white;
font-size: 12px;
border-radius: 4px;
}
.upload-overlay .el-icon {
font-size: 20px;
margin-bottom: 4px;
}
.upload-overlay span {
font-size: 11px;
}
/* 字符串列表相关样式 */
.string-list-section {
width: 100%;
}
.string-list-display {
margin-bottom: 16px;
}
.string-list-items {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.string-item {
margin: 0;
}
.string-list-input {
display: flex;
flex-direction: column;
gap: 8px;
}
/* 文件列表摘要样式 */
.file-list-summary {
display: flex;
align-items: center;
}
.file-list-summary .el-tag {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
transition: all 0.2s ease;
}
.file-list-summary .el-tag:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* 字符串列表摘要样式 */
.string-list-summary {
display: flex;
align-items: center;
}
.string-list-summary .el-tag {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
transition: all 0.2s ease;
}
.string-list-summary .el-tag:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* 表格中的文件列表和字符串列表样式 */
.table-file-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 120px;
overflow-y: auto;
}
.table-file-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 6px;
background-color: #f8f9fa;
border-radius: 4px;
border: 1px solid #e9ecef;
transition: all 0.2s ease;
}
.table-file-item:hover {
background-color: #e9ecef;
border-color: #dee2e6;
}
.file-preview-mini {
width: 24px;
height: 24px;
border-radius: 3px;
overflow: hidden;
flex-shrink: 0;
border: 1px solid #dee2e6;
}
.preview-mini-image {
width: 100%;
height: 100%;
object-fit: cover;
cursor: pointer;
}
.file-placeholder-mini {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: #f8f9fa;
color: #6c757d;
font-size: 10px;
}
.file-info-mini {
flex: 1;
min-width: 0;
}
.file-name-mini {
font-size: 12px;
font-weight: 500;
color: #495057;
margin-bottom: 2px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.file-id-mini {
font-size: 10px;
color: #6c757d;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.table-string-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 120px;
overflow-y: auto;
}
.table-string-item {
display: flex;
align-items: center;
gap: 6px;
padding: 3px 6px;
background-color: #f8f9fa;
border-radius: 3px;
border-left: 3px solid #007bff;
transition: all 0.2s ease;
cursor: pointer;
}
.table-string-item:hover {
background-color: #e9ecef;
border-left-color: #0056b3;
}
.string-index {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
background-color: #007bff;
color: white;
font-size: 10px;
font-weight: bold;
border-radius: 50%;
flex-shrink: 0;
}
.string-content {
font-size: 12px;
color: #495057;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
flex: 1;
}
.file-list-display {
display: flex;
flex-direction: column;
gap: 4px;
}
.file-link {
font-size: 12px;
margin-right: 8px;
max-width: 150px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
display: inline-block;
vertical-align: middle;
}
.string-list-display {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.string-tag {
margin: 0;
max-width: 120px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
display: inline-block;
}
/* 可编辑字符串列表样式 */
.editable-string-list {
border: 1px solid #e4e7ed;
border-radius: 4px;
padding: 16px;
background-color: #fafafa;
}
.string-list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #ebeef5;
}
.list-title {
font-weight: 500;
color: #303133;
font-size: 14px;
}
.string-list-items {
display: flex;
flex-direction: column;
gap: 8px;
}
.string-list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background-color: #ffffff;
border: 1px solid #e4e7ed;
border-radius: 4px;
transition: all 0.3s ease;
cursor: move;
}
.string-list-item:hover {
border-color: #c0c4cc;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.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 ease;
cursor: pointer;
word-break: break-word;
white-space: normal;
line-height: 1.4;
}
.item-text:hover {
background-color: #f5f7fa;
}
.item-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.danger-btn {
color: #f56c6c;
}
.danger-btn:hover {
color: #f78989;
}
/* 可编辑文件列表样式 */
.editable-file-list {
border: 1px solid #e4e7ed;
border-radius: 4px;
padding: 16px;
background-color: #fafafa;
}
.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;
}
.file-list-items {
display: flex;
flex-direction: column;
gap: 12px;
}
.file-list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background-color: #ffffff;
border: 1px solid #e4e7ed;
border-radius: 4px;
transition: all 0.3s ease;
cursor: move;
}
.file-list-item:hover {
border-color: #c0c4cc;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.file-list-item .file-preview {
width: 60px;
height: 60px;
border-radius: 4px;
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-color: #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: 4px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.file-list-item .file-id {
font-size: 12px;
color: #909399;
margin-bottom: 2px;
}
.file-list-item .file-size {
font-size: 12px;
color: #909399;
}
.file-list-item .file-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
/* 弹窗中值内容的省略号样式 */
.value-content {
max-width: 100%;
}
.value-content .text-value {
display: block;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
word-break: break-all;
}
/* 响应式样式 */
@media (max-width: 768px) {
.table-file-list {
max-height: 80px;
}
.table-string-list {
max-height: 80px;
}
.file-preview-mini {
width: 20px;
height: 20px;
}
.file-name-mini {
font-size: 11px;
}
.file-id-mini {
font-size: 9px;
}
.string-index {
width: 16px;
height: 16px;
font-size: 9px;
}
.string-content {
font-size: 11px;
}
.file-link {
max-width: 100px !important;
}
.string-tag {
max-width: 80px !important;
}
}
@media (max-width: 1200px) {
.table-file-list {
max-height: 100px;
}
.table-string-list {
max-height: 100px;
}
}
@media (max-width: 768px) {
.file-name {
max-width: 120px !important;
}
.file-item .file-name {
max-width: 100px !important;
}
.file-link {
max-width: 100px !important;
}
.string-tag {
max-width: 80px !important;
}
}
</style>