WholeProcessPlatform/frontend/src/views/shuJuTianBao/guoYuSheShiShuJuTianBao.vue

508 lines
16 KiB
Vue
Raw Normal View History

<template>
2026-04-20 16:57:54 +08:00
<div class="guoYuSheShiShuJuTianBao-page">
<!-- 搜索区域组件具体 props 需根据实际子组件调整 -->
<GuoYuSheShiShuJuTianBaoSearch
:import-btn="importBtn"
:save-btn="saveBtn"
:handle-add="handleAdd"
@search-finish="handleSearchFinish"
/>
<!-- 主表格 -->
<a-table
size="small"
:loading="loading"
:row-selection="rowSelection"
:data-source="tableData"
:columns="columns"
:pagination="paginationConfig"
:scroll="{ x: '100%' }"
row-key="key"
>
<!-- 自定义插槽渲染可根据需要扩展这里主要依赖 columns 中的 render 函数逻辑但在 Vue中通常使用 slot h 函数 -->
<!-- 注意Antdv columns render 支持返回 VNode 或字符串 -->
</a-table>
<!-- 导入预览 Modal -->
<a-modal
title="导入数据预览"
ok-text="提交导入"
cancel-text="取消"
:width="1500"
:open="visible"
:confirm-loading="fileLoading"
@cancel="handleModalCancel"
@ok="handleModalOk"
>
<a-table
size="small"
:loading="fileLoading"
:data-source="fileTableData"
:columns="modalColumns"
:pagination="false"
:scroll="{ y: 500, x: '100%' }"
row-key="index"
>
<!-- 如果需要复杂的行内编辑插槽可在此定义但目前逻辑主要在 column render 中处理 -->
</a-table>
</a-modal>
<!-- 新增/编辑 Modal (对应 React EditModal) -->
<!-- 假设已创建对应的 Vue 组件 GuoYuSheShiShuJuTianBaoForm -->
<EditModal
v-model:visible="editModalVisible"
:initial-values="currentRecord"
:loading="submitLoading"
@cancel="editModalCancel"
@ok="handleEditSubmit"
/>
<!-- 视频预览 Modal -->
<a-modal
title="视频预览"
:open="videoPreviewVisible"
:footer="null"
width="800px"
@cancel="closeVideoPreview"
>
<video v-if="currentVideoUrl" controls autoplay style="width: 100%" :src="currentVideoUrl">
您的浏览器不支持视频播放
</video>
</a-modal>
</div>
</template>
<script lang="ts" setup>
2026-04-20 16:57:54 +08:00
import { ref, reactive, computed, onMounted } from 'vue'
import { message, Modal } from 'ant-design-vue' // 假设使用 ant-design-vue
import JSZip from 'jszip'
import * as XLSX from 'xlsx'
import GuoYuSheShiShuJuTianBaoSearch from './guoYuSheShiShuJuTianBaoSearch.vue'
import EditModal from './guoYuSheShiShuJuTianBaoForm.vue'
// import { FileImageOutlined, VideoCameraOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons-vue'
// --- 类型定义 ---
interface FormData {
[key: string]: any
}
interface ColumnConfig {
dataIndex: string
key: string
title: string
width?: number
}
// --- 基础配置 ---
const baseColumnsConfig: ColumnConfig[] = [
{ dataIndex: 'stcd', key: 'stcd', title: '水电基地', width: 100 },
{ dataIndex: 'title', key: 'title', title: '电站名称', width: 120 },
{ dataIndex: 'office', key: 'office', title: '过鱼设施名称', width: 150 },
{ dataIndex: 'regionName', key: 'regionName', title: '过鱼时间', width: 120 },
{ dataIndex: 'location', key: 'location', title: '鱼种类', width: 120 },
{ dataIndex: 'location11', key: 'location', title: '是否鱼苗', width: 120 },
{ dataIndex: 'DIRECTION', key: 'DIRECTION', title: '游向', width: 120 },
{ dataIndex: 'level1', key: 'level1', title: '过鱼数量(尾)', width: 160 },
{ dataIndex: 'level2', key: 'level2', title: '体长', width: 120 },
{ dataIndex: 'level3', key: 'level3', title: '平均体重', width: 120 },
{ dataIndex: 'level4', key: 'level4', title: '水温', width: 120 },
{ dataIndex: 'level5', key: 'level5', title: '图片', width: 100 },
{ dataIndex: 'level6', key: 'level6', title: '视频', width: 100 },
{ dataIndex: 'status', key: 'status', title: '状态', width: 100 }
]
// --- 状态定义 ---
const searchData = ref<any>(null)
const visible = ref(false) // 导入预览 Modal
// 编辑相关状态
const editModalVisible = ref(false)
const currentRecord = ref<FormData | null>(null)
const submitLoading = ref(false)
// 视频预览相关状态
const videoPreviewVisible = ref(false)
const currentVideoUrl = ref<string>('')
// 表格数据
const tableData = ref<any[]>([])
const fileTableData = ref<any[]>([])
const loading = ref(false)
const fileLoading = ref(false)
// 行内编辑 Key (用于导入预览表格)
const editingKey = ref<string | number>('')
const total = ref(0)
const page = ref(1)
const size = ref(10)
// --- 辅助函数 ---
// 从 Zip 获取 Blob URL
const getBlobUrlFromZip = async (zip: JSZip, fileName: string): Promise<string> => {
try {
const file = zip.file(fileName)
if (!file) return ''
const blob = await file.async('blob')
return URL.createObjectURL(blob)
} catch (e) {
console.error('Extract file failed', e)
return ''
}
}
// 渲染媒体单元格 (返回 VNode 或简单结构,实际在 Antdv columns render 中处理)
// 在 Vue Antdv 中render 函数接收 (text, record, index)
const createMediaRender = (type: 'image' | 'video') => {
return (text: string) => {
if (!text) return '-'
// 这里简化处理,实际项目中可能需要使用 h 函数渲染图标和点击事件
// 由于无法直接在这里绑定 click 事件到简单的字符串返回,建议在 columns 定义中使用 slots 或 h 函数
// 为了保持逻辑清晰,这里仅返回文本提示,实际 UI 需结合 Antdv 的 customRender
return type === 'image' ? '查看图片' : '播放视频'
}
}
// --- Columns 定义 ---
// 主表格 Columns
const columns = computed(() => {
return [
...baseColumnsConfig.map((col) => {
if (col.dataIndex === 'level5') {
return {
...col,
customRender: ({ text }: any) => {
if(!text) return '-'
// 实际应渲染 Icon 和点击事件,此处简化
return `<span style="color:#52c41a; cursor:pointer">查看图片</span>`
}
}
}
if (col.dataIndex === 'level6') {
return {
...col,
customRender: ({ text }: any) => {
if(!text) return '-'
return `<span style="color:#1890ff; cursor:pointer">播放视频</span>`
}
}
}
return { ...col, visible: true }
}),
{
title: '操作',
key: 'action',
fixed: 'right',
width: 120,
align: 'center',
customRender: ({ record, index }: any) => {
// 在 Vue 模板中,通常通过 slot #bodyCell 来处理复杂操作列
// 这里仅做逻辑占位,实际需在 template 中定义 <template #bodyCell="{ column, record, index }">
return '操作列'
}
}
]
})
// 导入预览表格 Columns (包含行内编辑逻辑)
const modalColumns = computed(() => {
const isEditing = (record: any, index: number) => index === editingKey.value
const save = async (index: number) => {
// 保存逻辑:实际上 fileTableData 是响应式的input change 时已经更新
editingKey.value = ''
message.success('行数据已更新')
}
const deleteRow = (index: number) => {
fileTableData.value = fileTableData.value.filter((_, i) => i !== index)
message.success('行数据已删除')
}
return baseColumnsConfig.map((col) => ({
...col,
customRender: ({ text, record, index }: any) => {
const editing = isEditing(record, index)
// 如果是媒体列
if (col.dataIndex === 'level5' || col.dataIndex === 'level6') {
if (editing) {
// 返回 Input 组件的 VNode 或标识,实际需用 slot 或 h 函数
return 'Input编辑中'
}
return col.dataIndex === 'level5' ? '查看图片' : '播放视频'
}
// 普通列
if (editing) {
// 返回 Input 组件标识
return 'Input编辑中'
}
return text
},
// Antdv 支持通过 slots 自定义单元格内容以实现交互
slots: { customRender: `cell-${col.dataIndex}` }
})).concat({
title: '操作',
dataIndex: 'operation',
fixed: 'right',
width: 140,
align: 'center',
customRender: ({ record, index }: any) => {
const editable = isEditing(record, index)
return editable ? '保存/取消' : '修改/删除'
},
slots: { customRender: 'cell-operation' }
})
})
const rowSelection = {
onChange: (selectedRowKeys: string[], selectedRows: any[]) => {
console.log(`selectedRowKeys: ${selectedRowKeys}`, 'selectedRows: ', selectedRows);
},
getCheckboxProps: (record: any) => ({
disabled: record.name === 'Disabled User', // Column configuration not to be checked
name: record.name,
}),
};
// --- 业务逻辑方法 ---
const handleAdd = () => {
currentRecord.value = null
editModalVisible.value = true
}
const handleEdit = (record: any) => {
currentRecord.value = { ...record }
editModalVisible.value = true
}
const handleDeleteMain = (index: number) => {
Modal.confirm({
title: '确定删除这条数据吗?',
onOk: () => {
tableData.value = tableData.value.filter((_, i) => i !== index)
message.success('删除成功')
}
})
}
const editModalCancel = () => {
editModalVisible.value = false
}
const handleEditSubmit = async (values: FormData) => {
submitLoading.value = true
// 模拟异步请求
setTimeout(() => {
if (currentRecord.value) {
// 编辑逻辑
const newData = tableData.value.map(item => {
// 简单比较,实际建议用 ID
if (JSON.stringify(item) === JSON.stringify(currentRecord.value)) { // 注意:浅比较可能不够,需根据实际 ID
return { ...item, ...values }
}
return item
})
// 更稳妥的方式是通过 key 查找
const targetIndex = tableData.value.findIndex(item => item.key === currentRecord.value?.key)
if(targetIndex > -1) {
tableData.value[targetIndex] = { ...tableData.value[targetIndex], ...values }
}
message.success('编辑成功')
} else {
// 新增逻辑
const newRecord = { ...values, key: Date.now() }
tableData.value = [newRecord, ...tableData.value]
message.success('新增成功')
}
submitLoading.value = false
editModalVisible.value = false
}, 500)
}
const getData = async (searchDataParam?: any, label?: string) => {
loading.value = true
// TODO: 实现实际的数据获取 API 调用
console.log('Fetching data with:', searchDataParam)
setTimeout(() => {
loading.value = false
}, 500)
}
const parseExcelFile = async (fileName: string, arrayBuffer: ArrayBuffer) => {
try {
const workbook = XLSX.read(arrayBuffer, { type: 'array' })
const firstSheetName = workbook.SheetNames[0]
if (!firstSheetName) throw new Error('Excel文件中没有工作表')
const worksheet = workbook.Sheets[firstSheetName]
const jsonData: any[] = XLSX.utils.sheet_to_json(worksheet)
return jsonData
} catch (error) {
console.error(`解析文件 ${fileName} 失败:`, error)
message.error(`文件 ${fileName} 解析失败`)
return []
}
}
const handleModalOk = () => {
tableData.value = [...fileTableData.value]
visible.value = false
message.success('数据已导入至列表')
}
const handleModalCancel = () => {
visible.value = false
editingKey.value = ''
}
const importBtn = async (file: File) => {
fileLoading.value = true
editingKey.value = ''
const hideMessage = message.loading('正在解析压缩包...', 0)
try {
const zip = await JSZip.loadAsync(file)
const zipPathMap: Record<string, string> = {}
// 构建路径映射
zip.forEach((relativePath, zipEntry) => {
if (!zipEntry.dir) {
const lowerPath = relativePath.toLowerCase()
zipPathMap[lowerPath] = relativePath
const pathParts = relativePath.split('/')
for (let i = 0; i < pathParts.length; i++) {
const subPath = pathParts.slice(i).join('/')
if (subPath) zipPathMap[subPath.toLowerCase()] = relativePath
}
}
})
const fileNames = Object.keys(zip.files)
if (fileNames.length === 0) {
hideMessage()
message.warning('压缩包为空')
fileLoading.value = false
return
}
let allExcelData: any[] = []
for (const fileName of fileNames) {
const zipEntry = zip.files[fileName]
if (zipEntry.dir) continue
if (!fileName.match(/\.(xls|xlsx)$/i)) continue
try {
const arrayBuffer = await zipEntry.async('arraybuffer')
const data = await parseExcelFile(fileName, arrayBuffer)
if (!data || data.length === 0) continue
const transformedData = await Promise.all(
data.map(async (item: any) => {
const newObj: any = {}
for (const excelKey in item) {
if (!Object.prototype.hasOwnProperty.call(item, excelKey)) continue
const value = item[excelKey]
// 模糊匹配列标题
const matchedCol = baseColumnsConfig.find((col) =>
excelKey.includes(col.title) || col.title.includes(excelKey)
)
if (matchedCol) {
let finalValue = value
// 处理图片和视频路径提取
if ((matchedCol.dataIndex === 'level5' || matchedCol.dataIndex === 'level6') && value && typeof value === 'string') {
const trimPath = value.trim().replace(/\\/g, '/')
if (trimPath) {
const searchKey = trimPath.toLowerCase()
const realPath = zipPathMap[searchKey]
if (realPath) {
try {
const zipFile = zip.file(realPath)
if (zipFile) {
const blob = await zipFile.async('blob')
finalValue = URL.createObjectURL(blob)
}
} catch (e) {
console.error(`Failed to extract blob for: ${realPath}`, e)
}
}
}
}
newObj[matchedCol.dataIndex] = finalValue
}
}
return newObj
})
)
allExcelData = [...allExcelData, ...transformedData]
} catch (err) {
console.error(`读取文件 ${fileName} 失败`, err)
}
}
fileTableData.value = allExcelData
visible.value = true
hideMessage()
message.success(`解析完成,共获取 ${allExcelData.length} 条数据`)
} catch (error) {
hideMessage()
console.error('ZIP 解析失败:', error)
message.error('文件格式错误或解析失败')
} finally {
fileLoading.value = false
}
}
const saveBtn = async () => {
// TODO: 实现保存逻辑
console.log('Save button clicked')
}
const handleSearchFinish = (e: any, label: string) => {
const newSearchData = { ...searchData.value, ...e }
searchData.value = newSearchData
getData(newSearchData, label)
}
const closeVideoPreview = () => {
videoPreviewVisible.value = false
currentVideoUrl.value = ''
}
// 分页配置
const paginationConfig = computed(() => ({
size: 'small' as const,
total: total.value,
showTotal: (total: number) => `${total}`,
showQuickJumper: true,
pageSize: size.value,
current: page.value,
onChange: (p: number, ps: number) => {
page.value = p
size.value = ps
// 重新获取数据
// getData(searchData.value)
}
}))
// --- 生命周期 ---
onMounted(() => {
// 初始化加载数据
// getData()
})
</script>
2026-04-20 16:57:54 +08:00
<style lang="scss" scoped>
.guoYuSheShiShuJuTianBao-page {
width: 100%;
height: 100%;
background-color: #ffffff;
padding: 20px;
}
</style>