323 lines
8.6 KiB
Vue
323 lines
8.6 KiB
Vue
|
<template>
|
|||
|
<div class="text-viewer" :class="{ 'full-screen': isFullscreen }">
|
|||
|
<!-- 在顶部添加控制按钮 -->
|
|||
|
<div class="controls">
|
|||
|
<!-- 在controls区块添加 -->
|
|||
|
<button @click="saveChanges" style="margin-right: 8px;">
|
|||
|
保存修改
|
|||
|
</button>
|
|||
|
<button @click="toggleFullscreen">
|
|||
|
{{ isFullscreen ? '退出全屏' : '全屏' }}
|
|||
|
</button>
|
|||
|
|
|||
|
</div>
|
|||
|
<div v-if="error" class="error">{{ error }}</div>
|
|||
|
<div v-else>
|
|||
|
<div v-if="isLoading" class="loading">加载进度:{{ loadedPercent }}%</div>
|
|||
|
|
|||
|
<div class="scroll-container" ref="scrollContainer" @scroll="handleScroll"
|
|||
|
:style="{ height: containerHeight + 'px' }">
|
|||
|
<div class="phantom" :style="{ height: totalHeight + 'px' }">
|
|||
|
<div v-for="row in visibleRows" :key="row.index" class="row" :style="row.style">
|
|||
|
<div v-for="(cell, i) in row.data" :key="i" class="cell">
|
|||
|
<input :value="rawData[row.index][i]" @blur="(e) => handleEdit(row.index, i, e)"
|
|||
|
class="edit-input">
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
</div>
|
|||
|
</template>
|
|||
|
|
|||
|
<script setup>
|
|||
|
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount } from 'vue';
|
|||
|
import { apicontent, batchModify } from "@/api/datamanagement";
|
|||
|
import { ElMessage } from "element-plus";
|
|||
|
const props = defineProps({
|
|||
|
fileUrl: {
|
|||
|
type: String,
|
|||
|
required: true
|
|||
|
},
|
|||
|
rowId: {
|
|||
|
type: String,
|
|||
|
default: false
|
|||
|
},
|
|||
|
});
|
|||
|
|
|||
|
// 配置项
|
|||
|
const CHUNK_SIZE = 1024 * 1024; // 1MB分块处理
|
|||
|
const ROW_HEIGHT = 40; // 行高
|
|||
|
const VISIBLE_BUFFER = 200; // 可视区外缓冲行数
|
|||
|
|
|||
|
// 响应式状态
|
|||
|
const isLoading = ref(false);
|
|||
|
const error = ref(null);
|
|||
|
const rawData = reactive([]); // 原始数据存储
|
|||
|
const scrollTop = ref(0);
|
|||
|
const totalRows = ref(0);
|
|||
|
const reader = ref(null); // 保持reader引用
|
|||
|
const loadedPercent = ref(0);
|
|||
|
// 新增响应式状态
|
|||
|
const isFullscreen = ref(false);
|
|||
|
|
|||
|
// 新增方法
|
|||
|
const toggleFullscreen = () => {
|
|||
|
isFullscreen.value = !isFullscreen.value;
|
|||
|
|
|||
|
// 可选:使用浏览器全屏API(根据需求选择其中一种方式)
|
|||
|
// if (isFullscreen.value && document.documentElement.requestFullscreen) {
|
|||
|
// document.documentElement.requestFullscreen();
|
|||
|
// } else if (document.exitFullscreen) {
|
|||
|
// document.exitFullscreen();
|
|||
|
// }
|
|||
|
};
|
|||
|
|
|||
|
// 修改容器高度计算逻辑
|
|||
|
const containerHeight = computed(() => {
|
|||
|
return isFullscreen.value ? window.innerHeight : window.innerHeight * 0.8;
|
|||
|
});
|
|||
|
const startIndex = computed(() =>
|
|||
|
Math.max(0, Math.floor(scrollTop.value / ROW_HEIGHT) - VISIBLE_BUFFER)
|
|||
|
);
|
|||
|
const endIndex = computed(() =>
|
|||
|
Math.min(totalRows.value, startIndex.value + Math.ceil(containerHeight.value / ROW_HEIGHT) + VISIBLE_BUFFER * 2)
|
|||
|
);
|
|||
|
const visibleRows = computed(() => {
|
|||
|
// console.log(rawData)
|
|||
|
return rawData.slice(startIndex.value, endIndex.value).map((row, index) => ({
|
|||
|
index: startIndex.value + index,
|
|||
|
data: row,
|
|||
|
style: {
|
|||
|
transform: `translateY(${(startIndex.value + index) * ROW_HEIGHT}px)`
|
|||
|
}
|
|||
|
}));
|
|||
|
});
|
|||
|
const totalHeight = computed(() => totalRows.value * ROW_HEIGHT);
|
|||
|
|
|||
|
// 方法
|
|||
|
const handleScroll = (e) => {
|
|||
|
scrollTop.value = e.target.scrollTop;
|
|||
|
};
|
|||
|
|
|||
|
const abortLoading = () => {
|
|||
|
if (reader.value) {
|
|||
|
reader.value.cancel();
|
|||
|
reader.value = null;
|
|||
|
}
|
|||
|
};
|
|||
|
|
|||
|
const processChunk = (chunk, isFinal) => {
|
|||
|
const lines = chunk.split('\n');
|
|||
|
// const lines = chunk.split('\n').map(line => line.replace(/\t/g, ' ')); // 新增替换逻辑
|
|||
|
if (!isFinal) {
|
|||
|
// 保留未完成的行
|
|||
|
const lastLine = lines.pop() || '';
|
|||
|
rawData.push(...lines.filter(l => l).map(line => line.split('\t')));
|
|||
|
return lastLine;
|
|||
|
}
|
|||
|
rawData.push(...lines.filter(l => l).map(line => line.split('\t')));
|
|||
|
return '';
|
|||
|
};
|
|||
|
|
|||
|
const loadStream = async () => {
|
|||
|
// debugger
|
|||
|
try {
|
|||
|
isLoading.value = true;
|
|||
|
error.value = null;
|
|||
|
rawData.length = 0;
|
|||
|
totalRows.value = 0;
|
|||
|
|
|||
|
const response = await fetch(props.fileUrl);
|
|||
|
if (!response.body) throw new Error('不支持流式读取');
|
|||
|
|
|||
|
const contentLength = response.headers.get('content-length');
|
|||
|
let receivedLength = 0;
|
|||
|
let leftover = '';
|
|||
|
|
|||
|
reader.value = response.body.getReader();
|
|||
|
const decoder = new TextDecoder();
|
|||
|
|
|||
|
while (true) {
|
|||
|
const { done, value } = await reader.value.read();
|
|||
|
if (done) break;
|
|||
|
|
|||
|
receivedLength += value.length;
|
|||
|
if (contentLength) {
|
|||
|
loadedPercent.value = Math.round((receivedLength / contentLength) * 100);
|
|||
|
}
|
|||
|
|
|||
|
const chunk = decoder.decode(value, { stream: true });
|
|||
|
// debugger
|
|||
|
leftover = processChunk(leftover + chunk, false);
|
|||
|
}
|
|||
|
|
|||
|
// 处理剩余数据
|
|||
|
if (leftover) processChunk(leftover, true);
|
|||
|
totalRows.value = rawData.length;
|
|||
|
} catch (err) {
|
|||
|
if (err.name !== 'AbortError') {
|
|||
|
error.value = `加载失败:${err.message}`;
|
|||
|
}
|
|||
|
} finally {
|
|||
|
isLoading.value = false;
|
|||
|
reader.value = null;
|
|||
|
}
|
|||
|
};
|
|||
|
|
|||
|
// 监听URL变化
|
|||
|
watch(() => props.fileUrl, (newVal) => {
|
|||
|
if (newVal) {
|
|||
|
abortLoading();
|
|||
|
loadStream();
|
|||
|
}
|
|||
|
});
|
|||
|
|
|||
|
// 生命周期
|
|||
|
onBeforeUnmount(() => {
|
|||
|
abortLoading();
|
|||
|
});
|
|||
|
|
|||
|
// 初始化加载
|
|||
|
onMounted(() => {
|
|||
|
if (props.fileUrl) loadStream();
|
|||
|
});
|
|||
|
const tabledata = ref([])
|
|||
|
// 修改后的处理方法
|
|||
|
const handleEdit = (rowIndex, colIndex, event) => {
|
|||
|
const oldValue = rawData[rowIndex][colIndex];
|
|||
|
const newValue1 = event.target.value.trim();
|
|||
|
|
|||
|
if (newValue1 !== oldValue) {
|
|||
|
console.log(`修改了第${rowIndex + 1}行第${colIndex + 1}列`,`旧值: ${oldValue} => 新值: ${newValue1}`);
|
|||
|
rawData[rowIndex][colIndex] = newValue1; // 手动更新保证数据同步
|
|||
|
tabledata.value.push({lineNum:rowIndex + 1,colNum:colIndex + 1,newValue:newValue1})
|
|||
|
// debugger
|
|||
|
}
|
|||
|
};
|
|||
|
// 在script中添加
|
|||
|
const saveChanges = () => {
|
|||
|
// 构造表格数据(示例使用\t分隔)
|
|||
|
// const csvData = rawData.map(row => row.join('\t')).join('\n');
|
|||
|
batchModify({ id: props.rowId, modifications: tabledata.value }).then((res) => {
|
|||
|
ElMessage.success('保存成功')
|
|||
|
if (props.fileUrl) {
|
|||
|
abortLoading();
|
|||
|
loadStream()
|
|||
|
tabledata.value.length = 0
|
|||
|
}
|
|||
|
})
|
|||
|
|
|||
|
};
|
|||
|
</script>
|
|||
|
|
|||
|
<style>
|
|||
|
.text-viewer {
|
|||
|
width: 100%;
|
|||
|
/* max-width: 1200px; */
|
|||
|
margin: 0 auto;
|
|||
|
|
|||
|
.edit-input {
|
|||
|
width: 100%;
|
|||
|
height: 100%;
|
|||
|
border: none;
|
|||
|
outline: none;
|
|||
|
padding: 0 12px;
|
|||
|
background: transparent;
|
|||
|
text-align: center;
|
|||
|
}
|
|||
|
|
|||
|
.edit-input:focus {
|
|||
|
background: #f0f9ff;
|
|||
|
box-shadow: 0 0 0 1px #409eff inset;
|
|||
|
}
|
|||
|
|
|||
|
.scroll-container {
|
|||
|
position: relative;
|
|||
|
overflow: auto;
|
|||
|
border: 1px solid #ddd;
|
|||
|
}
|
|||
|
|
|||
|
.phantom {
|
|||
|
position: relative;
|
|||
|
}
|
|||
|
|
|||
|
.row {
|
|||
|
position: absolute;
|
|||
|
min-width: 100%;
|
|||
|
/* 保证最小宽度与容器一致 */
|
|||
|
height: v-bind(ROW_HEIGHT + 'px');
|
|||
|
display: flex;
|
|||
|
align-items: center;
|
|||
|
border-bottom: 1px solid #eee;
|
|||
|
border-right: 1px solid #eee;
|
|||
|
box-sizing: border-box;
|
|||
|
}
|
|||
|
|
|||
|
.cell {
|
|||
|
flex: 1 0 auto;
|
|||
|
/* 允许单元格根据内容扩展 */
|
|||
|
min-width: 160px;
|
|||
|
/* 设置最小宽度防止挤压 */
|
|||
|
padding: 0 12px;
|
|||
|
white-space: nowrap;
|
|||
|
overflow: visible;
|
|||
|
/* 显示完整内容 */
|
|||
|
position: relative;
|
|||
|
text-align: center;
|
|||
|
}
|
|||
|
|
|||
|
.cell+.cell {
|
|||
|
border-left: 1px solid #f0f0f0;
|
|||
|
}
|
|||
|
|
|||
|
.scroll-container::-webkit-scrollbar {
|
|||
|
width: 8px;
|
|||
|
height: 8px;
|
|||
|
}
|
|||
|
|
|||
|
.scroll-container::-webkit-scrollbar-thumb {
|
|||
|
background-color: #c1c1c1;
|
|||
|
border-radius: 4px;
|
|||
|
}
|
|||
|
|
|||
|
.loading {
|
|||
|
padding: 10px;
|
|||
|
background: #f5f5f5;
|
|||
|
text-align: center;
|
|||
|
}
|
|||
|
|
|||
|
.error {
|
|||
|
color: #ff4444;
|
|||
|
padding: 20px;
|
|||
|
text-align: center;
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
/* 添加全屏样式 */
|
|||
|
.text-viewer.full-screen {
|
|||
|
position: fixed;
|
|||
|
top: 0;
|
|||
|
left: 0;
|
|||
|
z-index: 9999;
|
|||
|
background: white;
|
|||
|
width: 100vw;
|
|||
|
height: 100vh;
|
|||
|
}
|
|||
|
|
|||
|
.controls {
|
|||
|
position: absolute;
|
|||
|
bottom: 0px;
|
|||
|
right: 0px;
|
|||
|
z-index: 10000;
|
|||
|
}
|
|||
|
|
|||
|
button {
|
|||
|
padding: 8px 16px;
|
|||
|
background: #409eff;
|
|||
|
color: white;
|
|||
|
border: none;
|
|||
|
border-radius: 4px;
|
|||
|
cursor: pointer;
|
|||
|
}
|
|||
|
</style>
|