feat(工具箱): 实现“Minecraft 聊天记录查看”工具
This commit is contained in:
@@ -148,7 +148,7 @@ export const toolList = [
|
||||
{
|
||||
id: 'minecraft-tools',
|
||||
title: 'Minecraft',
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
items: [
|
||||
{
|
||||
id: 'calc-minecraft-chunk-location',
|
||||
@@ -178,10 +178,10 @@ export const toolList = [
|
||||
title: 'Minecraft 聊天记录查看',
|
||||
iconClass: 'mdi mdi-format-list-text',
|
||||
desc: '读取并解析 latest.log 文件,显示聊天记录。',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
version: '0',
|
||||
enabled: false,
|
||||
createdAt: '2025-02-03',
|
||||
updatedAt: '2025-02-03',
|
||||
version: '1',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: 'minecraft-uuid-converter',
|
||||
|
@@ -1,9 +1,553 @@
|
||||
<template>
|
||||
<div class="tool-detail-page"></div>
|
||||
<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;
|
||||
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>
|
||||
|
Reference in New Issue
Block a user