Files
frost-navigation/src/views/ToolboxView/Minecraft/ChatHistoryReader/ChatHistoryReader.vue

557 lines
12 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="tool-detail-page">
<!-- 说明 -->
<n-card size="small" title="说明">
<n-p>已测试游戏版本1.12.2 ~ 1.21.4</n-p>
<n-p>若内容出现乱码请尝试更改文件编码后重新打开文件</n-p>
</n-card>
<!-- 设置 -->
<n-card size="small" title="设置">
<n-flex>
<!-- 文件编码 -->
<div class="config-item">
<div class="config-item__label">文件编码</div>
<div class="config-item__content">
<n-select
v-model:value="currState.textEncoding"
:options="[
{ label: 'GBK', value: 'gbk' },
{ label: 'UTF-8', value: 'utf-8' },
]"
/>
</div>
</div>
<!-- 读取间隔 -->
<div class="config-item">
<div class="config-item__label">读取间隔</div>
<div class="config-item__content">
<n-input-number
v-model:value="currState.readInterval"
:disabled="currState.isReadingFile"
:min="1"
:max="60"
:precision="0"
:step="1"
></n-input-number>
<span
style="margin-left: 0.5em;"
></span>
</div>
</div>
</n-flex>
</n-card>
<!-- 操作 -->
<n-card size="small" title="操作">
<n-flex class="action-row">
<n-button
type="success"
@click="selectLogFile"
>选择文件</n-button>
<n-button
type="primary"
:disabled="!currState.isOpenedFile"
@click="parseLogFileData(true)"
>刷新内容</n-button>
<n-button
type="primary"
:disabled="!currState.isOpenedFile || currState.isReadingFile"
@click="setAutoReading(true)"
>开始读取</n-button>
<n-button
type="error"
:disabled="!currState.isOpenedFile || !currState.isReadingFile"
@click="setAutoReading(false)"
>停止读取</n-button>
<n-button
type="error"
@click="clearHistory"
>清空内容</n-button>
</n-flex>
</n-card>
<!-- 信息 -->
<n-card size="small" title="信息">
<n-flex>
<n-tag type="info">文件名称{{ fsFileName || '-' }}</n-tag>
<n-tag type="info">文件大小{{ fileSizeDisplay }}</n-tag>
<n-tag type="info">修改时间{{ fileLastModifiedDisplay }}</n-tag>
</n-flex>
</n-card>
<!-- 内容 -->
<n-card
size="small"
title="内容"
:class="{
'chat-history': true,
'chat-history--is-full': currState.isFullView,
}"
>
<template #header-extra>
<div
class="chat-history__toggle-full"
@click="currState.isFullView = !currState.isFullView"
>
<span
v-if="currState.isFullView"
class="mdi mdi-arrow-collapse"
></span>
<span
v-else
class="mdi mdi-arrow-expand"
></span>
</div>
</template>
<div class="chat-history__wrapper" @contextmenu.stop>
<div ref="chatHistoryListRef" class="chat-history__list">
<div
v-for="item in currState.textRows"
:key="item.id"
class="chat-history__item"
>
<n-tag
class="chat-history__time"
size="small"
type="info"
>{{ item.time }}</n-tag>
<div
class="chat-history__text"
>{{ item.text }}</div>
</div>
</div>
</div>
</n-card>
</div>
</template>
<script setup>
import {
NButton, NCard, NFlex,
NInputNumber, NP, NSelect, NTag,
} from 'naive-ui';
import {
computed, reactive, ref,
nextTick, onBeforeMount, onBeforeUnmount,
} from 'vue';
import {
getCommonDateTime,
} from '@frost-utils/javascript/common/index';
import {
useFileSystemAccess,
} from '@vueuse/core';
import {
$message,
} from '@/assets/js/naive-ui';
import {
getUuidV4,
} from '@/assets/js/utils';
/**
* @typedef TextRowItem
* @property {string} id UUID
* @property {TextRowType} type 文本类型
* @property {string} time 时间信息
* @property {string} text 文本内容
*/
/** @typedef {''|'chat'} TextRowType */
/** 正则表达式列表 */
const REG_EXP = {
LOG_CHAT_MSG_1: /\[CHAT\]\s+(.*)$/,
LOG_TIME: /\[(\d{2}:\d{2}:\d{2})\]/,
};
const {
data: fsData,
fileLastModified: fsFileLastModified,
fileName: fsFileName,
fileSize: fsFileSize,
isSupported: fsIsSupported,
open: fsOpen,
updateData: fsUpdateData,
} = useFileSystemAccess({
dataType: 'ArrayBuffer',
excludeAcceptAllOption: true,
types: [{
accept: { 'text/plain': ['.log'] },
description: 'Minecraft 日志文件',
}],
});
/**
* @desc 聊天内容列表 ref
* @type {VueRef<HTMLElement>}
*/
const chatHistoryListRef = ref(null);
/** 状态信息 */
const currState = reactive({
/** 内容列表是否满屏显示 */
isFullView: false,
/** 是否正在持续读取文件 */
isReadingFile: false,
/** 是否已选择文件 */
isOpenedFile: false,
/** 最后一次读取时的文件大小 */
lastFileSize: 0,
/** 最后一次读取时的文件更新时间 */
LastModifiedTime: 0,
/** 最后一次读取时的行数 */
lastReadLineNumber: 0,
/** 读取间隔秒数 */
readInterval: 2,
/** 自动读取定时器 */
readTimer: null,
/** 文本内容编码类型 */
textEncoding: 'utf-8',
/**
* @desc 显示的文本行列表
* @type {TextRowItem[]}
*/
textRows: [],
});
/** 显示的文件大小 */
const fileSizeDisplay = computed(() => {
let value = fsFileSize.value;
return (value ? `${(value / 1024).toFixed(2)} KB` : '-');
});
/** 显示的文件修改时间 */
const fileLastModifiedDisplay = computed(() => {
let value = fsFileLastModified.value;
return (value ? getCommonDateTime(value, 'all') : '-');
});
/** 自动滚动聊天内容列表到底部 */
function autoScrollChatHistory() {
let element = chatHistoryListRef.value;
if (!element) {
console.error('自动滚动失败:元素不存在');
return;
}
let elRect = element.getBoundingClientRect();
let elHeight = Math.round(elRect.height);
let scrollHeight0 = element.scrollHeight;
let scrollHeight1 = 0;
let scrollTop = element.scrollTop;
// 检测当前是否位于底部
if (scrollHeight0 - scrollTop === elHeight) {
// 渲染新的内容后滚动到底部
nextTick(() => {
scrollHeight1 = element.scrollHeight;
if (scrollHeight1 > elHeight) {
element.scrollTo({
behavior: 'instant',
top: scrollHeight1 - elHeight,
});
}
});
}
}
/** 清空记录内容 */
function clearHistory() {
currState.textRows = [];
}
/**
* @description 解码字符串
* @param {ArrayBuffer} data
*/
function decodeFileData(data = null) {
try {
let decoder = new TextDecoder(currState.textEncoding);
let text = decoder.decode(data);
return text;
} catch (error) {
console.error('解码失败:');
console.error(error);
return '';
}
}
/** 初始化数据 */
function initData(isFirst = false) {
currState.lastFileSize = 0;
currState.LastModifiedTime = 0;
currState.lastReadLineNumber = 0;
currState.textRows = isFirst ? [{
id: getUuidV4(),
type: 'chat',
time: getCommonDateTime(null, 'time'),
text: '请点击按钮选择日志文件以开始。',
}] : [];
}
/** 解析日志文件内容 */
async function parseLogFileData(manualUpdate = false) {
if (manualUpdate) {
await fsUpdateData();
}
let currFileSize = fsFileSize.value;
let currFileTime = fsFileLastModified.value;
let currFileData = fsData.value;
let currFileText = '';
let readLineNumber = currState.lastReadLineNumber;
let readLineStrs = [];
let parsedData = [];
// 若文件内容没有变化,不处理
if (currFileTime === currState.LastModifiedTime) {
return false;
} else {
currState.LastModifiedTime = currFileTime;
}
// 解码字符串
if (currFileData instanceof ArrayBuffer) {
currFileText = decodeFileData(currFileData);
}
if (currFileText) {
// 以 \n 拆分,移除末尾 \r
readLineStrs = currFileText.split('\n').map((text) => {
return text.replace(/\r$/, '');
}).filter((text) => {
return Boolean(text);
});
} else {
console.warn('文件内容为空或解码失败');
return false;
}
// 若已重新创建日志文件,则从头开始读取
if (currFileSize < currState.lastFileSize) {
readLineNumber = 0;
}
// 跳过读取过的行
readLineStrs.splice(0, readLineNumber);
readLineNumber += readLineStrs.length;
readLineStrs.forEach((text) => {
let parsed = parseLogLine(text);
if (parsed.type === 'chat') {
parsedData.push(parsed);
}
});
currState.lastFileSize = currFileSize;
currState.lastReadLineNumber = readLineNumber;
currState.textRows.push.apply(currState.textRows, parsedData);
autoScrollChatHistory();
return true;
}
/**
* @description 解析日志文件行内容
* @returns {TextRowItem}
*/
function parseLogLine(text = '') {
try {
let msgTextMatched = text.match(REG_EXP.LOG_CHAT_MSG_1);
let msgTextStr = msgTextMatched ? msgTextMatched[1] : '';
let timeTextMatched = text.match(REG_EXP.LOG_TIME);
let timeTextStr = timeTextMatched ? timeTextMatched[1] : '';
if (msgTextStr) {
// 处理换行和 §
msgTextStr = msgTextStr.replace(/\\n/g, '\n').replace(/§\w/g, '');
return {
id: getUuidV4(),
type: 'chat',
time: timeTextStr,
text: msgTextStr,
};
}
} catch (error) {
console.error('解析内容失败:');
console.error(error);
}
return {
id: null,
type: '',
time: '',
text: '',
};
}
/** 选择日志文件 */
async function selectLogFile() {
if (!fsIsSupported.value) {
$message.error('当前浏览器不支持该功能');
return false;
}
try {
await fsOpen();
$message.success('打开文件成功');
currState.isOpenedFile = true;
initData(false);
parseLogFileData(false);
setAutoReading(true);
return true;
} catch (error) {
console.error('打开文件失败:');
console.error(error);
$message.error('打开文件失败');
return false;
}
}
/** 开始或停止自动读取 */
function setAutoReading(isStart = false) {
if (currState.readTimer) {
clearInterval(currState.readTimer);
}
if (!isStart) {
currState.isReadingFile = false;
currState.readTimer = null;
return;
}
currState.isReadingFile = true;
currState.readTimer = setInterval(() => {
parseLogFileData(true);
}, currState.readInterval * 1000);
}
onBeforeMount(() => {
initData(true);
});
onBeforeUnmount(() => {
setAutoReading(false);
});
</script>
<style lang="less" scoped>
.config-item {
display: flex;
align-items: center;
.config-item__content {
display: flex;
align-items: center;
}
.n-input-number, .n-select {
width: 128px;
}
}
.chat-history {
position: relative;
background-color: #FFF;
&.chat-history--is-full {
position: fixed;
left: 0;
top: 0;
margin-top: 0 !important;
width: 100%;
height: 100%;
.chat-history__wrapper,
.chat-history__list {
height: 100%;
}
}
.chat-history__toggle-full {
cursor: pointer;
}
.chat-history__wrapper {
--line-margin: 8px;
padding: 16px;
border: 1px solid #F0F0F0;
border-radius: 4px;
font-family: monospace;
line-height: calc(1em + var(--line-margin));
word-break: break-all;
}
.chat-history__list {
height: 320px;
overflow-y: auto;
}
.chat-history__item {
display: flex;
margin: var(--line-margin) 0;
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
}
.chat-history__time {
margin: auto 0;
margin-right: 0.5em;
}
.chat-history__text {
margin: auto 0;
user-select: text;
white-space: pre-wrap;
}
}
</style>