You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1491 lines
38 KiB
1491 lines
38 KiB
<script setup lang="ts"> |
|
import { ref, computed, nextTick, watch } from 'vue'; |
|
import { BASE_PATH } from '@/api/request'; |
|
import { getToken } from '@/utils/token'; |
|
import feedback from '@/utils/feedback'; |
|
import { ElTooltip } from 'element-plus'; |
|
import { |
|
searchExcelData, |
|
searchExcelDataPage, |
|
deleteBatch, |
|
getBatchListPage, |
|
deleteBatchData, |
|
type ExcelRowData |
|
} from '@/api/price/excel'; |
|
import FilePreview from '@/components/file/preview.vue'; |
|
|
|
// ==================== 状态定义 ==================== |
|
|
|
/** 搜索关键字 - 双向绑定到搜索框 */ |
|
const searchKeyword = ref(''); |
|
|
|
/** 当前批次ID - 用于数据列表刷新 */ |
|
const currentBatchId = ref(''); |
|
|
|
/** 搜索加载状态 - 控制下拉 loading 动画 */ |
|
const searchLoading = ref(false); |
|
|
|
/** 导入按钮加载状态 */ |
|
const uploadLoading = ref(false); |
|
|
|
/** 弹窗显示状态 */ |
|
const detailDialogVisible = ref(false); |
|
|
|
/** 当前 Tab 页签 */ |
|
const activeTab = ref('table-list'); |
|
|
|
/** 批次列表数据 - 存储所有导入的表格 */ |
|
const batchList = ref<ExcelRowData[]>([]); |
|
|
|
/** 批次列表加载状态 */ |
|
const batchLoading = ref(false); |
|
|
|
/** 批次列表分页参数 */ |
|
const batchPagination = ref({ |
|
current: 1, |
|
size: 10, |
|
total: 0 |
|
}); |
|
|
|
/** 批次列表搜索关键字 */ |
|
const batchKeyword = ref(''); |
|
|
|
/** 搜索结果数据 - 搜索匹配到的所有数据 */ |
|
const searchResultList = ref<ExcelRowData[]>([]); |
|
|
|
/** 搜索结果加载状态 */ |
|
const searchResultLoading = ref(false); |
|
|
|
/** 保存的搜索关键字 - 用于在 autocomplete 选择后恢复原始关键字进行匹配 */ |
|
const savedKeyword = ref(''); |
|
|
|
/** 当前选中的行数据 - 用于卡片详情展示 */ |
|
const currentRow = ref<ExcelRowData | null>(null); |
|
|
|
/** 动态列名数组 - 从 rowData JSON 解析生成 */ |
|
const detailColumns = ref<string[]>([]); |
|
|
|
/** 文件预览弹窗状态 */ |
|
const previewVisible = ref(false); |
|
|
|
/** 预览文件列表 */ |
|
const previewFiles = ref<{ fileName: string; filePath: string }[]>([]); |
|
|
|
// ==================== 计算属性 ==================== |
|
|
|
/** 格式化日期 */ |
|
const formatDate = (dateStr: string) => { |
|
if (!dateStr) return '-'; |
|
const date = new Date(dateStr); |
|
return date.toLocaleDateString('zh-CN', { |
|
year: 'numeric', |
|
month: '2-digit', |
|
day: '2-digit' |
|
}); |
|
}; |
|
|
|
/** 获取纯文本(去掉HTML标签),用于悬浮提示显示 */ |
|
const getRawText = (html: string): string => { |
|
if (!html) return ''; |
|
return html.replace(/<[^>]*>/g, ''); |
|
}; |
|
|
|
/** |
|
* 高亮关键字 |
|
* @param text - 原始文本 |
|
* @returns 包含高亮的 HTML 字符串 |
|
*/ |
|
const highlightKeyword = (text: string): string => { |
|
if (!text) return ''; |
|
const keyword = searchKeyword.value.toLowerCase().trim(); |
|
if (!keyword) return text; |
|
const regex = new RegExp(`(${escapeRegExp(keyword)})`, 'gi'); |
|
return text.replace(regex, '<mark class="highlight">$1</mark>'); |
|
}; |
|
|
|
/** |
|
* 获取匹配最佳的字段值(类似百度搜索) |
|
* @param item - 选中的数据项 |
|
* @param keyword - 用户输入的关键字 |
|
* @returns 匹配最佳的字段 { key, value } |
|
*/ |
|
const getBestMatchField = (item: ExcelRowData, keyword: string) => { |
|
const keywordLower = keyword.toLowerCase(); |
|
|
|
if (activeTab.value === 'table-list') { |
|
// 表格 Tab:直接使用 batchName |
|
return { key: 'batchName', value: item.batchName || item.fileName || '' }; |
|
} |
|
|
|
// 数据 Tab:解析 rowData,找到匹配次数最多的字段 |
|
try { |
|
const rowObj = JSON.parse(item.rowData); |
|
let bestField = { key: '', value: '', matchCount: 0 }; |
|
|
|
Object.entries(rowObj).forEach(([key, val]) => { |
|
const text = String(val); |
|
const textLower = text.toLowerCase(); |
|
// 统计关键字匹配次数 |
|
const count = (textLower.match(new RegExp(escapeRegExp(keywordLower), 'gi')) || []).length; |
|
// 优先选择匹配次数多的,其次选择文本更长的 |
|
if (count > bestField.matchCount || |
|
(count === bestField.matchCount && text.length > bestField.value.length)) { |
|
bestField = { key, value: text, matchCount: count }; |
|
} |
|
}); |
|
|
|
return bestField; |
|
} catch { |
|
return { key: '', value: '' }; |
|
} |
|
}; |
|
|
|
// ==================== 搜索相关方法 ==================== |
|
|
|
/** |
|
* 执行搜索 - el-autocomplete 的 fetch-suggestions 回调格式 |
|
* |
|
* 【核心逻辑】 |
|
* 1. el-autocomplete 会传入 (queryString, callback) |
|
* 2. 根据当前 Tab 调用不同接口: |
|
* - 表格列表 Tab: 调用批次列表接口 |
|
* - 数据列表 Tab: 调用数据搜索接口 |
|
* 3. 将结果通过 callback 传递给下拉组件显示 |
|
* |
|
* @param queryString - 用户输入的搜索关键字 |
|
* @param callback - 回调函数,将结果数组传入下拉组件 |
|
*/ |
|
const performSearch = (queryString: string, callback: (results: ExcelRowData[]) => void) => { |
|
// 【边界判断】关键字为空时,直接返回空数组 |
|
if (!queryString || !queryString.trim()) { |
|
callback([]); |
|
return; |
|
} |
|
|
|
// 保存用户输入的关键字,用于选择后匹配 |
|
savedKeyword.value = queryString.trim(); |
|
|
|
// 【请求开始】开启 loading 状态 |
|
searchLoading.value = true; |
|
|
|
if (activeTab.value === 'table-list') { |
|
/** |
|
* 【表格列表 Tab】调用批次列表接口 |
|
* 接口地址: POST /price/excel/batch/page |
|
*/ |
|
getBatchListPage(queryString.trim(), 1, 20) |
|
.then((res: any) => { |
|
if (res?.records) { |
|
callback(res.records); |
|
} else { |
|
callback([]); |
|
} |
|
}) |
|
.catch((error) => { |
|
console.error('搜索失败:', error); |
|
callback([]); |
|
}) |
|
.finally(() => { |
|
searchLoading.value = false; |
|
}); |
|
} else { |
|
/** |
|
* 【数据列表 Tab】调用数据搜索接口 |
|
* 接口地址: POST /price/excel/search |
|
*/ |
|
searchExcelData(queryString.trim(), 20) |
|
.then((res: any) => { |
|
if (res?.records) { |
|
callback(res.records); |
|
} else { |
|
callback([]); |
|
} |
|
}) |
|
.catch((error) => { |
|
console.error('搜索失败:', error); |
|
callback([]); |
|
}) |
|
.finally(() => { |
|
searchLoading.value = false; |
|
}); |
|
} |
|
}; |
|
|
|
/** |
|
* 选中联想结果 - 切换到数据列表 Tab |
|
* |
|
* 【核心逻辑】 |
|
* 1. 保持搜索关键字不变 |
|
* 2. 切换到数据列表 Tab |
|
* 3. 【重要】不重新请求,数据已经在列表中 |
|
* |
|
* @param item - 选中的 ExcelRowData 对象 |
|
*/ |
|
const handleSelectSuggestion = (item: ExcelRowData) => { |
|
// 使用保存的原始关键字进行匹配,而不是已被 autocomplete 覆盖的 searchKeyword |
|
const keyword = savedKeyword.value.toLowerCase().trim(); |
|
|
|
// 获取匹配最佳的字段值 |
|
const bestMatch = getBestMatchField(item, keyword); |
|
|
|
// 填入搜索框并自动触发搜索 |
|
nextTick(() => { |
|
searchKeyword.value = bestMatch.value; |
|
handleSearchBtnClick(); |
|
}); |
|
}; |
|
|
|
/** |
|
* 点击搜索按钮 - 根据当前 Tab 执行搜索 |
|
*/ |
|
const handleSearchBtnClick = () => { |
|
if (activeTab.value === 'table-list') { |
|
/** |
|
* 【表格列表 Tab】调用批次列表接口 |
|
*/ |
|
batchPagination.value.current = 1; |
|
batchKeyword.value = searchKeyword.value; |
|
loadBatchList(); |
|
} else { |
|
/** |
|
* 【数据列表 Tab】调用数据搜索接口 |
|
*/ |
|
activeTab.value = 'data-list'; |
|
loadSearchResults(searchKeyword.value); |
|
} |
|
}; |
|
|
|
/** |
|
* 清空搜索框 - 重置筛选效果 |
|
*/ |
|
const handleClearSearch = () => { |
|
if (activeTab.value === 'table-list') { |
|
// 表格列表 Tab:重置分页和批次关键字,重新加载 |
|
batchKeyword.value = ''; |
|
batchPagination.value.current = 1; |
|
loadBatchList(); |
|
} else { |
|
// 数据列表 Tab:用空关键字刷新当前批次数据 |
|
loadSearchResults('', currentBatchId.value); |
|
} |
|
}; |
|
|
|
/** |
|
* 解析 rowData JSON 字符串为对象 |
|
* |
|
* @param rowData - JSON 字符串 |
|
* @returns 解析后的对象,解析失败返回空对象 |
|
*/ |
|
const parseRowData = (rowData: string) => { |
|
try { |
|
return JSON.parse(rowData); |
|
} catch { |
|
return {}; |
|
} |
|
}; |
|
|
|
/** |
|
* 格式化 rowData 为带高亮的键值对列表 |
|
* |
|
* 【核心逻辑】 |
|
* 解析 rowData JSON,将每个键值对格式化为可读列表 |
|
* 搜索关键字会高亮显示 |
|
* |
|
* @param item - ExcelRowData 对象 |
|
* @returns 包含高亮的 HTML 字符串 |
|
*/ |
|
const getCardPreview = (item: ExcelRowData) => { |
|
try { |
|
const rowObj = JSON.parse(item.rowData); |
|
const keyword = searchKeyword.value.toLowerCase().trim(); |
|
|
|
// 转换为键值对数组 |
|
const entries = Object.entries(rowObj).map(([key, value]) => { |
|
let displayValue = typeof value === 'object' |
|
? JSON.stringify(value) |
|
: String(value); |
|
|
|
// 高亮匹配的关键字 |
|
const highlightText = (text: string) => { |
|
if (!keyword) return text; |
|
const regex = new RegExp(`(${escapeRegExp(keyword)})`, 'gi'); |
|
return text.replace(regex, '<mark class="highlight">$1</mark>'); |
|
}; |
|
|
|
return { |
|
key: highlightText(key), |
|
value: highlightText(displayValue) |
|
}; |
|
}); |
|
|
|
return entries; |
|
} catch { |
|
return []; |
|
} |
|
}; |
|
|
|
/** |
|
* 转义正则特殊字符 |
|
*/ |
|
const escapeRegExp = (string: string) => { |
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
|
}; |
|
|
|
// ==================== 表格列表相关方法 ==================== |
|
|
|
/** |
|
* 加载所有批次列表 |
|
* |
|
* 【核心逻辑】 |
|
* 1. 调用搜索接口(关键字为空)获取所有数据 |
|
* 2. 按批次分组汇总信息 |
|
*/ |
|
const loadBatchList = async () => { |
|
debugger |
|
batchLoading.value = true; |
|
|
|
try { |
|
const res: any = await getBatchListPage( |
|
batchKeyword.value, |
|
batchPagination.value.current, |
|
batchPagination.value.size |
|
); |
|
|
|
if (res?.records) { |
|
batchList.value = res.records; |
|
batchPagination.value.total = res.total || res.records.length; |
|
} |
|
} catch (error) { |
|
console.error('加载批次列表失败:', error); |
|
} finally { |
|
batchLoading.value = false; |
|
} |
|
}; |
|
|
|
/** |
|
* 查看批次下的所有数据 |
|
* |
|
* @param batch - 批次信息 |
|
*/ |
|
const viewBatchData = (batch: ExcelRowData) => { |
|
/** |
|
* 【UI 操作】切换到数据列表 Tab |
|
*/ |
|
activeTab.value = 'data-list'; |
|
|
|
/** |
|
* 【数据赋值】保存批次名称到搜索关键字 |
|
*/ |
|
searchKeyword.value = batch.batchName; |
|
|
|
/** |
|
* 【数据赋值】保存批次ID用于刷新 |
|
*/ |
|
currentBatchId.value = batch.batchId; |
|
|
|
/** |
|
* 【数据赋值】加载该批次的所有数据 |
|
*/ |
|
loadSearchResults('', batch.batchId); |
|
}; |
|
|
|
// ==================== 数据列表相关方法 ==================== |
|
|
|
/** |
|
* 加载搜索结果 |
|
* |
|
* @param keyword - 搜索关键字(可选) |
|
* @param batchId - 批次ID(可选) |
|
*/ |
|
const loadSearchResults = async (keyword: string = '', batchId?: string) => { |
|
searchResultLoading.value = true; |
|
|
|
try { |
|
/** |
|
* 【API 请求】分页搜索数据 |
|
* 接口地址: POST /price/excel/search |
|
*/ |
|
const res: any = await searchExcelDataPage({ |
|
keyword, |
|
batchId, |
|
current: 1, |
|
size: 100 // 获取更多数据用于卡片展示 |
|
}); |
|
|
|
if (res?.records) { |
|
/** |
|
* 【数据赋值】保存搜索结果 |
|
*/ |
|
searchResultList.value = res.records; |
|
} |
|
} catch (error) { |
|
console.error('加载搜索结果失败:', error); |
|
feedback.msgError('加载数据失败'); |
|
} finally { |
|
searchResultLoading.value = false; |
|
} |
|
}; |
|
|
|
/** |
|
* 查看数据卡片详情 |
|
* |
|
* @param item - ExcelRowData 对象 |
|
*/ |
|
const viewDataDetail = (item: ExcelRowData) => { |
|
/** |
|
* 【数据赋值】保存当前行数据 |
|
*/ |
|
currentRow.value = item; |
|
|
|
/** |
|
* 【数据解析】从 rowData JSON 中提取列名 |
|
*/ |
|
try { |
|
const rowObj = JSON.parse(item.rowData); |
|
/** |
|
* 【数据赋值】将 JSON 对象的键名作为表格列名 |
|
*/ |
|
detailColumns.value = Object.keys(rowObj); |
|
} catch { |
|
detailColumns.value = []; |
|
} |
|
|
|
/** |
|
* 【UI 操作】打开详情弹窗 |
|
*/ |
|
detailDialogVisible.value = true; |
|
}; |
|
|
|
// ==================== 导入相关方法 ==================== |
|
|
|
/** |
|
* 上传前校验 |
|
* |
|
* 【校验规则】 |
|
* 1. 文件格式必须是 .xls 或 .xlsx |
|
* 2. 文件大小不能超过 100MB |
|
* |
|
* @param file - 上传的文件对象 |
|
* @returns 校验通过返回 true,否则返回 false |
|
*/ |
|
const beforeUpload = (file: File) => { |
|
// 【文件格式校验】只允许 Excel 文件 |
|
const isExcel = file.name.endsWith('.xls') || file.name.endsWith('.xlsx'); |
|
if (!isExcel) { |
|
feedback.msgError('只能上传 Excel 文件'); |
|
return false; |
|
} |
|
|
|
// 【文件大小校验】限制 100MB |
|
const isLt100M = file.size / 1024 / 1024 < 100; |
|
if (!isLt100M) { |
|
feedback.msgError('文件大小不能超过 100MB'); |
|
return false; |
|
} |
|
|
|
// 【UI 状态】开启上传 loading |
|
uploadLoading.value = true; |
|
return true; |
|
}; |
|
|
|
/** |
|
* 上传成功回调 |
|
* |
|
* @param response - 后端返回的响应数据 |
|
*/ |
|
const handleUploadSuccess = (response: any) => { |
|
// 【UI 状态】关闭上传 loading |
|
uploadLoading.value = false; |
|
|
|
// 【响应判断】根据 code 判断是否成功 |
|
if (response?.code === 200) { |
|
feedback.msgSuccess('导入成功'); |
|
|
|
/** |
|
* 【数据刷新】重新加载表格列表 |
|
*/ |
|
loadBatchList(); |
|
} else { |
|
feedback.msgError(response?.message || '导入失败'); |
|
} |
|
}; |
|
|
|
/** |
|
* 上传失败回调 |
|
*/ |
|
const handleUploadError = () => { |
|
// 【UI 状态】关闭上传 loading |
|
uploadLoading.value = false; |
|
feedback.msgError('上传失败'); |
|
}; |
|
|
|
// ==================== 弹窗方法 ==================== |
|
|
|
/** |
|
* 关闭弹窗 |
|
*/ |
|
const handleCloseDialog = () => { |
|
// 【UI 操作】关闭弹窗 |
|
detailDialogVisible.value = false; |
|
// 【数据清理】清空已选数据 |
|
currentRow.value = null; |
|
detailColumns.value = []; |
|
}; |
|
|
|
/** |
|
* 打开文件预览 |
|
* @param row - 批次数据行 |
|
*/ |
|
const handleFilePreview = (row: ExcelRowData) => { |
|
debugger |
|
previewFiles.value = [{ fileName: row.fileName, filePath: row.filePath }]; |
|
previewVisible.value = true; |
|
}; |
|
|
|
// ==================== 分页处理 ==================== |
|
|
|
/** |
|
* 批次列表分页变化 |
|
*/ |
|
const handleBatchPageChange = (page: number) => { |
|
batchPagination.value.current = page; |
|
loadBatchList(); |
|
}; |
|
|
|
/** |
|
* 批次列表每页条数变化 |
|
*/ |
|
const handleBatchSizeChange = (size: number) => { |
|
batchPagination.value.size = size; |
|
batchPagination.value.current = 1; |
|
loadBatchList(); |
|
}; |
|
|
|
// ==================== 删除操作 ==================== |
|
|
|
/** |
|
* 删除批次 |
|
*/ |
|
const handleDeleteBatch = async (batch: ExcelRowData) => { |
|
try { |
|
await feedback.confirm('确定删除该批次吗?删除后数据将无法恢复!', '确定'); |
|
await deleteBatchData(batch.batchName); |
|
feedback.msgSuccess('删除成功'); |
|
loadBatchList(); |
|
} catch (error: any) { |
|
if (error !== 'cancel') { |
|
feedback.msgError(error.message); |
|
} |
|
} |
|
}; |
|
|
|
// ==================== 响应式监听 ==================== |
|
|
|
/** |
|
* 监听搜索关键字变化,当清空时自动刷新当前 Tab 数据 |
|
*/ |
|
watch(searchKeyword, (newVal, oldVal) => { |
|
// 当关键字从有值变为空时,刷新数据 |
|
if (oldVal && oldVal.length > 0 && (!newVal || newVal.length === 0)) { |
|
handleClearSearch(); |
|
} |
|
}); |
|
|
|
// ==================== 初始化 ==================== |
|
|
|
/** |
|
* 【生命周期】组件挂载时加载数据 |
|
*/ |
|
loadBatchList(); |
|
</script> |
|
|
|
<template> |
|
<div class="container"> |
|
<!-- 页面标题 --> |
|
<h2 class="mb-20">价格库搜索</h2> |
|
|
|
<!-- ==================== 搜索区域 ==================== --> |
|
<div class="search-section mb-20"> |
|
<el-row :gutter="20"> |
|
<el-col :span="20"> |
|
<el-autocomplete |
|
v-model="searchKeyword" |
|
:fetch-suggestions="performSearch" |
|
:placeholder="activeTab === 'table-list' ? '输入关键字搜索表格...' : '输入关键字搜索表格数据...'" |
|
:trigger-on-focus="false" |
|
clearable |
|
:debounce="300" |
|
:loading="searchLoading" |
|
value-key="fileName" |
|
class="search-input" |
|
popper-class="search-suggestions-popper" |
|
@select="handleSelectSuggestion" |
|
@clear="handleClearSearch" |
|
> |
|
<template #prefix> |
|
<el-icon><Search /></el-icon> |
|
</template> |
|
<!-- 自定义下拉项模板 --> |
|
<template #default="{ item }"> |
|
<div class="suggestion-item"> |
|
<template v-if="activeTab === 'table-list'"> |
|
<!-- 表格列表 Tab:显示批次名称 --> |
|
<div class="batch-name-item"> |
|
<el-icon><Document /></el-icon> |
|
<span v-html="highlightKeyword(item.batchName || item.fileName || '')"></span> |
|
</div> |
|
</template> |
|
<template v-else> |
|
<!-- 数据列表 Tab:显示行数据预览 --> |
|
<div class="row-data-list"> |
|
<el-tooltip |
|
v-for="(field, index) in getCardPreview(item)" |
|
:key="index" |
|
:content="getRawText(field.value)" |
|
placement="bottom" |
|
effect="light" |
|
:enterable="true" |
|
:show-after="300" |
|
:hide-after="0" |
|
> |
|
<div class="row-field"> |
|
<span class="field-key" v-html="field.key"></span> |
|
<span class="field-separator">:</span> |
|
<span class="field-value" v-html="field.value"></span> |
|
</div> |
|
</el-tooltip> |
|
</div> |
|
</template> |
|
</div> |
|
</template> |
|
</el-autocomplete> |
|
</el-col> |
|
<el-col :span="4"> |
|
<el-button type="primary" class="search-btn" @click="handleSearchBtnClick"> |
|
<el-icon><Search /></el-icon> |
|
搜索 |
|
</el-button> |
|
</el-col> |
|
</el-row> |
|
</div> |
|
|
|
<!-- ==================== 导入区域 ==================== --> |
|
<div class="upload-section"> |
|
<div class="upload-card"> |
|
<div class="upload-content"> |
|
<div class="upload-icon-wrapper"> |
|
<el-icon class="upload-icon"><Upload /></el-icon> |
|
</div> |
|
<div class="upload-text"> |
|
<div class="upload-title">上传 Excel 文件</div> |
|
<div class="upload-tips">支持 .xls 和 .xlsx 格式,单个文件不超过 100MB</div> |
|
</div> |
|
</div> |
|
<el-upload |
|
:action="`${BASE_PATH}/price/excel/import`" |
|
:headers="{ Authorization: getToken() }" |
|
:before-upload="beforeUpload" |
|
:on-success="handleUploadSuccess" |
|
:on-error="handleUploadError" |
|
:show-file-list="false" |
|
:accept="'.xls,.xlsx'" |
|
class="upload-trigger" |
|
> |
|
<el-button type="primary" :loading="uploadLoading" class="upload-btn"> |
|
<el-icon v-if="!uploadLoading"><Upload /></el-icon> |
|
{{ uploadLoading ? '上传中...' : '选择文件' }} |
|
</el-button> |
|
</el-upload> |
|
</div> |
|
</div> |
|
|
|
<!-- ==================== 列表展示区域 ==================== --> |
|
<div class="list-section"> |
|
<!-- Tab 切换 --> |
|
<el-tabs v-model="activeTab" class="content-tabs"> |
|
<!-- 表格列表 Tab --> |
|
<el-tab-pane label="表格列表" name="table-list"> |
|
<template #label> |
|
<div class="tab-label"> |
|
<el-icon><FolderOpened /></el-icon> |
|
<span>表格列表</span> |
|
<el-badge :value="batchPagination.total" type="primary" /> |
|
</div> |
|
</template> |
|
|
|
<!-- 表格列表 - 表格布局 --> |
|
<div class="batch-table" v-loading="batchLoading"> |
|
<el-empty v-if="batchList.length === 0" description="暂无导入的表格" /> |
|
<el-table |
|
v-else |
|
:data="batchList" |
|
border |
|
stripe |
|
style="width: 100%" |
|
@row-click="viewBatchData" |
|
> |
|
<el-table-column label="文件名" prop="fileName" min-width="180"> |
|
<template #default="{ row }"> |
|
<div class="file-name-cell"> |
|
<el-icon class="file-icon"><Document /></el-icon> |
|
<span class="file-name-link" @click.stop="handleFilePreview(row)">{{ row.fileName }}</span> |
|
</div> |
|
</template> |
|
</el-table-column> |
|
<el-table-column label="批次名称" prop="batchName" min-width="200"> |
|
<template #default="{ row }"> |
|
<el-tag type="info" effect="plain">{{ row.batchName }}</el-tag> |
|
</template> |
|
</el-table-column> |
|
<el-table-column label="上传时间" prop="uploadTime" width="160"> |
|
<template #default="{ row }"> |
|
<span class="time-text"> |
|
<el-icon><Clock /></el-icon> |
|
{{ formatDate(row.uploadTime) }} |
|
</span> |
|
</template> |
|
</el-table-column> |
|
<el-table-column label="上传人" prop="uploadUserName" width="120" align="center"> |
|
<template #default="{ row }"> |
|
<el-tag size="small" type="success">{{ row.uploadUserName }}</el-tag> |
|
</template> |
|
</el-table-column> |
|
<el-table-column label="数据条数" prop="rowIndex" width="100" align="center"> |
|
<template #default="{ row }"> |
|
<el-tag size="small" type="primary"> |
|
<el-icon><List /></el-icon> |
|
{{ row.rowIndex }} |
|
</el-tag> |
|
</template> |
|
</el-table-column> |
|
<el-table-column label="操作" width="150" align="center"> |
|
<template #default="{ row }"> |
|
<el-button type="primary" size="small" @click.stop="viewBatchData(row)"> |
|
查看 |
|
</el-button> |
|
<el-button type="danger" size="small" @click.stop="handleDeleteBatch(row)"> |
|
删除 |
|
</el-button> |
|
</template> |
|
</el-table-column> |
|
</el-table> |
|
|
|
<!-- 分页组件 --> |
|
<div class="pagination-wrapper" v-if="batchList.length > 0"> |
|
<el-pagination |
|
v-model:current-page="batchPagination.current" |
|
v-model:page-size="batchPagination.size" |
|
:page-sizes="[10, 20, 50, 100]" |
|
:total="batchPagination.total" |
|
layout="total, sizes, prev, pager, next, jumper" |
|
@current-change="handleBatchPageChange" |
|
@size-change="handleBatchSizeChange" |
|
/> |
|
</div> |
|
</div> |
|
</el-tab-pane> |
|
|
|
<!-- 数据列表 Tab --> |
|
<el-tab-pane label="数据列表" name="data-list"> |
|
<template #label> |
|
<div class="tab-label"> |
|
<el-icon><DataLine /></el-icon> |
|
<span>数据列表</span> |
|
<el-badge :value="searchResultList.length" type="success" /> |
|
</div> |
|
</template> |
|
|
|
<!-- 搜索结果提示 --> |
|
<div class="search-tip" v-if="searchKeyword"> |
|
<el-icon><InfoFilled /></el-icon> |
|
当前显示:<strong>{{ searchKeyword }}</strong> 相关的 {{ searchResultList.length }} 条数据 |
|
</div> |
|
|
|
<!-- 数据列表 - 卡片布局 --> |
|
<div class="card-list" v-loading="searchResultLoading"> |
|
<el-empty v-if="searchResultList.length === 0" description="请通过上方搜索框搜索数据" /> |
|
<div |
|
v-for="(item, index) in searchResultList" |
|
:key="item.id || index" |
|
class="data-card" |
|
@click="viewDataDetail(item)" |
|
> |
|
<div class="data-card-header"> |
|
<div class="data-source"> |
|
<el-icon class="source-icon"><Document /></el-icon> |
|
<span class="source-name clickable" @click.stop="handleFilePreview(item)">{{ item.fileName }}</span> |
|
<el-tag size="small" type="info">{{ item.sheetName }}</el-tag> |
|
</div> |
|
<span class="row-number">第 {{ item.rowIndex }} 行</span> |
|
</div> |
|
<div class="data-card-body"> |
|
<div class="row-data-list"> |
|
<el-tooltip |
|
v-for="(field, fIdx) in getCardPreview(item)" |
|
:key="fIdx" |
|
:content="getRawText(field.value)" |
|
placement="bottom" |
|
effect="light" |
|
:enterable="true" |
|
:show-after="300" |
|
:hide-after="0" |
|
> |
|
<div class="row-field"> |
|
<span class="field-key" v-html="field.key"></span> |
|
<span class="field-separator">:</span> |
|
<span class="field-value" v-html="field.value"></span> |
|
</div> |
|
</el-tooltip> |
|
</div> |
|
</div> |
|
<div class="data-card-footer"> |
|
<span class="batch-name" v-if="item.batchName"> |
|
<el-icon><Folder /></el-icon> |
|
{{ item.batchName }} |
|
</span> |
|
<el-button type="primary" link size="small"> |
|
查看详情 |
|
<el-icon><View /></el-icon> |
|
</el-button> |
|
</div> |
|
</div> |
|
</div> |
|
</el-tab-pane> |
|
</el-tabs> |
|
</div> |
|
|
|
<!-- ==================== 数据详情弹窗 ==================== --> |
|
<el-dialog |
|
v-model="detailDialogVisible" |
|
title="数据详情" |
|
width="900px" |
|
:close-on-click-modal="false" |
|
@close="handleCloseDialog" |
|
class="detail-dialog" |
|
> |
|
<!-- 数据源信息卡片 --> |
|
<div class="info-card mb-16"> |
|
<div class="card-header"> |
|
<el-icon><Document /></el-icon> |
|
<span>数据来源</span> |
|
</div> |
|
<el-descriptions :column="2" border size="small"> |
|
<el-descriptions-item label="文件名"> |
|
<el-tag type="primary" effect="plain">{{ currentRow?.fileName }}</el-tag> |
|
</el-descriptions-item> |
|
<el-descriptions-item label="Sheet名称"> |
|
<el-tag type="success" effect="plain">{{ currentRow?.sheetName }}</el-tag> |
|
</el-descriptions-item> |
|
<el-descriptions-item label="Excel行号"> |
|
<span class="row-index">第 {{ currentRow?.rowIndex }} 行</span> |
|
</el-descriptions-item> |
|
<el-descriptions-item label="批次名称"> |
|
{{ currentRow?.batchName || '默认批次' }} |
|
</el-descriptions-item> |
|
</el-descriptions> |
|
</div> |
|
|
|
<!-- 数据内容展示 --> |
|
<div class="data-card"> |
|
<div class="card-header"> |
|
<el-icon><DataLine /></el-icon> |
|
<span>数据内容</span> |
|
</div> |
|
|
|
<!-- 表格视图 --> |
|
<div class="table-section"> |
|
<div class="section-title">表格视图</div> |
|
<el-table |
|
:data="[parseRowData(currentRow?.rowData || '')]" |
|
border |
|
size="small" |
|
stripe |
|
max-height="300" |
|
class="detail-table" |
|
> |
|
<el-table-column |
|
v-for="col in detailColumns" |
|
:key="col" |
|
:prop="col" |
|
:label="col" |
|
min-width="120" |
|
show-overflow-tooltip |
|
/> |
|
</el-table> |
|
</div> |
|
|
|
<!-- JSON原始数据 --> |
|
<el-collapse> |
|
<el-collapse-item title="查看原始JSON" name="json"> |
|
<pre class="json-block">{{ currentRow?.rowData }}</pre> |
|
</el-collapse-item> |
|
</el-collapse> |
|
</div> |
|
|
|
<template #footer> |
|
<div class="dialog-footer"> |
|
<el-button @click="handleCloseDialog">关闭</el-button> |
|
</div> |
|
</template> |
|
</el-dialog> |
|
|
|
<!-- 文件预览组件 --> |
|
<FilePreview :files="previewFiles" :show="previewVisible" @update:show="previewVisible = $event" /> |
|
</div> |
|
</template> |
|
|
|
<style scoped lang="scss"> |
|
// ==================== 搜索区域样式 ==================== |
|
.search-section { |
|
.search-input { |
|
width: 100%; |
|
|
|
:deep(.el-input__wrapper) { |
|
width: 100%; |
|
} |
|
} |
|
|
|
.search-btn { |
|
width: 100%; |
|
} |
|
} |
|
|
|
// ==================== 导入区域样式 ==================== |
|
.upload-section { |
|
margin-bottom: 20px; |
|
} |
|
|
|
.upload-card { |
|
display: flex; |
|
align-items: center; |
|
justify-content: space-between; |
|
padding: 24px 32px; |
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); |
|
border-radius: 16px; |
|
box-shadow: 0 8px 32px rgba(102, 126, 234, 0.3); |
|
|
|
.upload-content { |
|
display: flex; |
|
align-items: center; |
|
gap: 20px; |
|
|
|
.upload-icon-wrapper { |
|
width: 64px; |
|
height: 64px; |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
background: rgba(255, 255, 255, 0.2); |
|
border-radius: 16px; |
|
backdrop-filter: blur(10px); |
|
|
|
.upload-icon { |
|
font-size: 32px; |
|
color: #fff; |
|
} |
|
} |
|
|
|
.upload-text { |
|
.upload-title { |
|
font-size: 20px; |
|
font-weight: 600; |
|
color: #fff; |
|
margin-bottom: 6px; |
|
} |
|
|
|
.upload-tips { |
|
font-size: 14px; |
|
color: rgba(255, 255, 255, 0.8); |
|
} |
|
} |
|
} |
|
|
|
.upload-trigger { |
|
.upload-btn { |
|
padding: 12px 32px; |
|
font-size: 16px; |
|
font-weight: 500; |
|
background: #fff; |
|
color: #667eea; |
|
border: none; |
|
border-radius: 10px; |
|
transition: all 0.3s ease; |
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1); |
|
|
|
&:hover { |
|
transform: translateY(-2px); |
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15); |
|
} |
|
|
|
.el-icon { |
|
margin-right: 8px; |
|
} |
|
} |
|
} |
|
} |
|
|
|
// ==================== 列表区域样式 ==================== |
|
.list-section { |
|
background: #fff; |
|
border-radius: 8px; |
|
padding: 20px; |
|
border: 1px solid rgba(198, 208, 251, 1); |
|
} |
|
|
|
.content-tabs { |
|
:deep(.el-tabs__header) { |
|
margin-bottom: 20px; |
|
} |
|
|
|
:deep(.el-tabs__nav-wrap::after) { |
|
height: 1px; |
|
} |
|
} |
|
|
|
.tab-label { |
|
display: flex; |
|
align-items: center; |
|
gap: 6px; |
|
|
|
.el-badge { |
|
margin-left: 4px; |
|
} |
|
} |
|
|
|
// ==================== 表格列表样式 ==================== |
|
.batch-table { |
|
.file-name-cell { |
|
display: flex; |
|
align-items: center; |
|
gap: 8px; |
|
|
|
.file-icon { |
|
color: var(--primary-color); |
|
font-size: 18px; |
|
} |
|
|
|
.file-name-link { |
|
color: var(--primary-color); |
|
cursor: pointer; |
|
transition: all 0.2s; |
|
|
|
&:hover { |
|
text-decoration: underline; |
|
font-weight: 500; |
|
} |
|
} |
|
} |
|
|
|
.file-name-text { |
|
display: inline-flex; |
|
align-items: center; |
|
gap: 4px; |
|
color: #666; |
|
} |
|
|
|
.time-text { |
|
display: inline-flex; |
|
align-items: center; |
|
gap: 4px; |
|
color: #999; |
|
font-size: 13px; |
|
} |
|
} |
|
|
|
// ==================== 数据列表卡片 ==================== |
|
.search-tip { |
|
display: flex; |
|
align-items: center; |
|
gap: 8px; |
|
padding: 12px 16px; |
|
background: #f0f6ff; |
|
border-radius: 6px; |
|
margin-bottom: 16px; |
|
font-size: 13px; |
|
color: #666; |
|
|
|
strong { |
|
color: var(--primary-color); |
|
} |
|
} |
|
|
|
.card-list { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 12px; |
|
} |
|
|
|
.data-card { |
|
background: #ffffff; |
|
border: 1px solid #e4e7ed; |
|
border-radius: 12px; |
|
padding: 16px 20px; |
|
cursor: pointer; |
|
transition: all 0.3s ease; |
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); |
|
|
|
&:hover { |
|
border-color: #409eff; |
|
box-shadow: 0 4px 16px rgba(64, 158, 255, 0.2); |
|
transform: translateY(-1px); |
|
} |
|
|
|
.data-card-header { |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
margin-bottom: 14px; |
|
padding-bottom: 12px; |
|
border-bottom: 2px solid #f0f2f5; |
|
|
|
.data-source { |
|
display: flex; |
|
align-items: center; |
|
gap: 10px; |
|
|
|
.source-icon { |
|
color: #409eff; |
|
font-size: 20px; |
|
} |
|
|
|
.source-name { |
|
font-weight: 600; |
|
font-size: 15px; |
|
color: #303133; |
|
|
|
&.clickable { |
|
color: var(--primary-color); |
|
cursor: pointer; |
|
transition: all 0.2s; |
|
|
|
&:hover { |
|
text-decoration: underline; |
|
font-weight: 500; |
|
} |
|
} |
|
} |
|
} |
|
|
|
.row-number { |
|
font-size: 13px; |
|
color: #606266; |
|
background: #f4f4f5; |
|
padding: 4px 12px; |
|
border-radius: 12px; |
|
font-weight: 500; |
|
} |
|
} |
|
|
|
.data-card-body { |
|
margin-bottom: 12px; |
|
|
|
.row-data-list { |
|
display: grid; |
|
grid-template-columns: repeat(3, 1fr); |
|
gap: 8px 16px; |
|
} |
|
|
|
.row-field { |
|
display: flex; |
|
align-items: baseline; |
|
gap: 6px; |
|
padding: 8px 12px; |
|
background: #f8f9fa; |
|
border-radius: 8px; |
|
font-size: 13px; |
|
line-height: 1.4; |
|
border-left: 3px solid #409eff; |
|
overflow: hidden; |
|
|
|
.field-key { |
|
color: #409eff; |
|
font-weight: 600; |
|
white-space: nowrap; |
|
} |
|
|
|
.field-separator { |
|
color: #909399; |
|
} |
|
|
|
.field-value { |
|
color: #303133; |
|
word-break: break-word; |
|
overflow: hidden; |
|
text-overflow: ellipsis; |
|
white-space: nowrap; |
|
} |
|
} |
|
} |
|
|
|
.data-card-footer { |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
padding-top: 12px; |
|
border-top: 2px solid #f0f2f5; |
|
|
|
.batch-name { |
|
display: flex; |
|
align-items: center; |
|
gap: 6px; |
|
font-size: 13px; |
|
color: #909399; |
|
} |
|
} |
|
} |
|
|
|
// ==================== 下拉列表样式 ==================== |
|
.suggestion-item { |
|
padding: 12px; |
|
margin-bottom: 8px; |
|
background: rgba(240, 247, 255, 0.6); |
|
border: 1px solid #b3d8fd; |
|
border-left: 4px solid #66b1ff; |
|
border-radius: 6px; |
|
transition: all 0.2s ease; |
|
|
|
&:hover { |
|
background: rgba(64, 158, 255, 0.1); |
|
border-color: #409eff; |
|
transform: translateX(3px); |
|
} |
|
|
|
&:last-child { |
|
margin-bottom: 0; |
|
} |
|
|
|
.row-data-list { |
|
display: grid; |
|
grid-template-columns: repeat(2, 1fr); |
|
gap: 6px 12px; |
|
} |
|
|
|
.row-field { |
|
display: flex; |
|
align-items: baseline; |
|
gap: 4px; |
|
padding: 4px 8px; |
|
background: rgba(0, 0, 0, 0.03); |
|
border-radius: 4px; |
|
font-size: 12px; |
|
line-height: 1.4; |
|
overflow: hidden; |
|
|
|
.field-key { |
|
color: #409eff; |
|
font-weight: 600; |
|
white-space: nowrap; |
|
} |
|
|
|
.field-separator { |
|
color: #909399; |
|
} |
|
|
|
.field-value { |
|
color: #606266; |
|
word-break: break-word; |
|
overflow: hidden; |
|
text-overflow: ellipsis; |
|
white-space: nowrap; |
|
} |
|
} |
|
} |
|
|
|
// ==================== 悬浮提示样式 ==================== |
|
.el-tooltip__popper { |
|
font-size: 13px !important; |
|
line-height: 1.6 !important; |
|
max-width: 400px !important; |
|
padding: 10px 14px !important; |
|
background: rgba(50, 50, 50, 0.95) !important; |
|
border-radius: 8px !important; |
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15) !important; |
|
|
|
.el-tooltip__arrow::before { |
|
background: rgba(50, 50, 50, 0.95) !important; |
|
} |
|
} |
|
|
|
:deep(.highlight) { |
|
background: linear-gradient(135deg, #ffe58f 0%, #ffd591 100%); |
|
color: #d48806; |
|
padding: 1px 4px; |
|
border-radius: 4px; |
|
font-weight: 700; |
|
box-shadow: 0 1px 2px rgba(212, 136, 6, 0.2); |
|
} |
|
|
|
// ==================== 弹窗样式 ==================== |
|
.info-card { |
|
background: linear-gradient(135deg, #f8f9ff 0%, #eef1ff 100%); |
|
border: 1px solid #d9e2ff; |
|
border-radius: 8px; |
|
padding: 16px; |
|
|
|
.card-header { |
|
display: flex; |
|
align-items: center; |
|
gap: 8px; |
|
margin-bottom: 12px; |
|
color: var(--primary-color); |
|
font-weight: 600; |
|
font-size: 14px; |
|
} |
|
|
|
.row-index { |
|
color: var(--primary-color); |
|
font-weight: 600; |
|
} |
|
} |
|
|
|
.data-card { |
|
background: #fff; |
|
border: 1px solid #ebeef5; |
|
border-radius: 8px; |
|
padding: 16px; |
|
|
|
.card-header { |
|
display: flex; |
|
align-items: center; |
|
gap: 8px; |
|
margin-bottom: 16px; |
|
color: var(--primary-color); |
|
font-weight: 600; |
|
font-size: 14px; |
|
} |
|
|
|
.table-section { |
|
margin-bottom: 16px; |
|
|
|
.section-title { |
|
font-size: 13px; |
|
color: #666; |
|
margin-bottom: 10px; |
|
font-weight: 500; |
|
} |
|
|
|
.detail-table { |
|
border-radius: 6px; |
|
overflow: hidden; |
|
} |
|
} |
|
|
|
.json-block { |
|
margin: 0; |
|
padding: 12px; |
|
background: #f5f7fa; |
|
border-radius: 6px; |
|
font-size: 12px; |
|
color: #606266; |
|
white-space: pre-wrap; |
|
word-break: break-all; |
|
max-height: 200px; |
|
overflow: auto; |
|
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; |
|
} |
|
} |
|
|
|
// ==================== Element Plus 深度样式 ==================== |
|
:deep(.el-autocomplete) { |
|
width: 100%; |
|
|
|
.el-input { |
|
width: 100%; |
|
} |
|
} |
|
|
|
:deep(.el-table) { |
|
border-radius: 6px; |
|
|
|
.el-table__header th { |
|
background-color: #f5f7fa; |
|
color: #333; |
|
font-weight: 600; |
|
} |
|
|
|
.el-table__row:hover > td { |
|
background-color: #f0f6ff; |
|
} |
|
} |
|
|
|
:deep(.el-dialog__body) { |
|
padding: 16px 24px; |
|
} |
|
|
|
:deep(.el-dialog__footer) { |
|
border-top: 1px solid #ebeef5; |
|
padding: 12px 24px; |
|
} |
|
|
|
:deep(.el-collapse) { |
|
border: none; |
|
|
|
.el-collapse-item__header { |
|
font-size: 13px; |
|
color: #666; |
|
background: transparent; |
|
border: none; |
|
} |
|
|
|
.el-collapse-item__wrap { |
|
border: none; |
|
background: transparent; |
|
} |
|
|
|
.el-collapse-item__content { |
|
padding-bottom: 0; |
|
} |
|
} |
|
|
|
.dialog-footer { |
|
text-align: right; |
|
} |
|
|
|
// 下拉建议弹窗 |
|
:deep(.search-suggestions-popper) { |
|
.el-autocomplete-suggestion__wrap { |
|
padding: 8px 12px; |
|
} |
|
|
|
li { |
|
padding: 0 !important; |
|
background: transparent !important; |
|
} |
|
} |
|
|
|
// 批次名称下拉项样式 |
|
.batch-name-item { |
|
display: flex; |
|
align-items: center; |
|
gap: 8px; |
|
padding: 8px 4px; |
|
font-size: 14px; |
|
color: #333; |
|
|
|
.el-icon { |
|
color: #409eff; |
|
flex-shrink: 0; |
|
} |
|
|
|
span { |
|
overflow: hidden; |
|
text-overflow: ellipsis; |
|
white-space: nowrap; |
|
} |
|
} |
|
|
|
// 分页组件样式 |
|
.pagination-wrapper { |
|
display: flex; |
|
justify-content: flex-end; |
|
margin-top: 20px; |
|
padding: 16px 0; |
|
|
|
:deep(.el-pagination) { |
|
.el-pagination__total { |
|
font-size: 14px; |
|
color: #606266; |
|
} |
|
} |
|
} |
|
</style>
|
|
|