diff --git a/src/lib/stores/datacode.ts b/src/lib/stores/datacode.ts new file mode 100644 index 0000000..085824f --- /dev/null +++ b/src/lib/stores/datacode.ts @@ -0,0 +1,113 @@ +import { writable, get } from 'svelte/store'; +import { TextEncoder, TextDecoder } from '@kayahr/text-encoding'; + +/** + * 编码状态 + */ + +export type DataCode = 'UTF-8' | 'GBK'; + +const STORAGE_KEY = 'data-code'; + +const initialCode = + (typeof localStorage !== 'undefined' + ? (localStorage.getItem(STORAGE_KEY) as DataCode) + : null) ?? 'UTF-8'; + +export const dataCode = writable(initialCode); + +dataCode.subscribe((code) => { + if (typeof localStorage !== 'undefined') { + localStorage.setItem(STORAGE_KEY, code); + } +}); + +/** + * Encoder / Decoder + */ + +const gbkDecoder = new TextDecoder('gbk'); +const utf8Decoder = new TextDecoder(); + +const gbkEncoder = new TextEncoder('gbk'); +const utf8Encoder = new TextEncoder(); + +export function hexStringToHexFormat(str: string) { + return `0x${ + str + .match(/.{1,2}/g) + ?.map((i) => i.toUpperCase()) + .join(', 0x') ?? '' + }`; +} + +export function hexStringToBuffer(str: string) { + return Uint8Array.from( + str.match(/.{1,2}/g)?.map((b) => Number.parseInt(b, 16)) ?? [] + ); +} + +export function bufferToHexString(buffer: Uint8Array) { + return Array.from(buffer) + .map((i) => i.toString(16).padStart(2, '0').toUpperCase()) + .join(''); +} + +export function bufferToHexFormat(buffer: Uint8Array) { + return Array.from(buffer) + .map((i) => `0x${i.toString(16).padStart(2, '0').toUpperCase()}`) + .join(', '); +} + +export function bufferToString(buffer: Uint8Array) { + return get(dataCode) === 'UTF-8' + ? utf8Decoder.decode(buffer) + : gbkDecoder.decode(buffer); +} + +export function stringToBuffer(str: string) { + return get(dataCode) === 'UTF-8' + ? utf8Encoder.encode(str) + : gbkEncoder.encode(str); +} + +export function stringToHexFormat(str: string) { + return bufferToHexFormat(stringToBuffer(str)); +} + +export function stringToHexString(str: string) { + return bufferToHexString(stringToBuffer(str)); +} + +export function decStringToBuffer(str: string) { + return hexStringToBuffer(Number.parseInt(str, 10).toString(16)); +} + +export function bufferToDecString(buffer: Uint8Array) { + return Number.parseInt(bufferToHexString(buffer), 16).toString(); +} + +/** + * HTML Safety + */ +export function stringToSafeHtml(str: string) { + return str + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll(' ', ' ') + .replaceAll('\r\n', '
') + .replaceAll('\n', '
') + .replaceAll('\r', '
'); +} + +export function stringToText(str: string) { + return str; +} + +/* + * 工具函数 + */ +export function isHexString(str: string) { + return /^[0-9a-f]+$/i.test(str); +} diff --git a/src/lib/stores/record/record.ts b/src/lib/stores/record/record.ts new file mode 100644 index 0000000..8ec5fcf --- /dev/null +++ b/src/lib/stores/record/record.ts @@ -0,0 +1,133 @@ +import { writable, get } from 'svelte/store'; +import { toast } from 'svelte-sonner'; +import { + bufferToDecString, + bufferToHexFormat, + bufferToString, +} from '../datacode'; +import { formatTimestamp } from '$lib/stores/utils/time'; + +/** + * Record定义 + */ +export type RecordItem = { + readonly type: 'read' | 'write' | 'system'; + data: Uint8Array; + timestamp?: number; + display: 'hex' | 'ascii'; +}; + +/* ---------------------------- + * 基础状态(writable) + * ---------------------------- */ +export const records = writable([]); +export const readingRecord = writable(undefined); +export const pinBottom = writable(true); +export const scrollToRecordIndex = writable(-1); + +const rxCount = writable(0); +const txCount = writable(0); + +/* ---------------------------- + * 核心操作 + * ---------------------------- */ +export function addRecord(record: RecordItem) { + records.update((list) => { + const next = [...list, record]; + + if (record.type === 'read') { + rxCount.update((v) => v + record.data.length); + } + if (record.type === 'write') { + txCount.update((v) => v + record.data.length); + } + + return next; + }); +} + +export function clearRecords() { + records.set([]); + rxCount.set(0); + txCount.set(0); +} + +/** + * 导出 / 工具方法 + */ + +export function exportRecords(list: RecordItem[] = get(records)) { + if (!list.length) { + toast.error('记录为空,导出失败'); + return; + } + + const exportData = list.map((record) => { + const timestamp = record.timestamp ?? null; + const time = timestamp ? formatTimestamp(timestamp) : null; + + const data = + record.display === 'hex' + ? bufferToHexFormat(record.data) + : bufferToString(record.data); + + return { + type: record.type, + data, + timestamp, + time, + display: record.display, + }; + }); + + const json = JSON.stringify(exportData, null, 2); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + + const now = Date.now(); + const d = new Date(now); + const pad = (n: number) => String(n).padStart(2, '0'); + + const fileName = + `records-${d.getFullYear()}-` + + `${pad(d.getMonth() + 1)}-` + + `${pad(d.getDate())}-` + + `${pad(d.getHours())}-` + + `${pad(d.getMinutes())}-` + + `${pad(d.getSeconds())}.json`; + + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + + URL.revokeObjectURL(url); + a.remove(); +} + +export async function copyRecordContent(record: RecordItem) { + let content = ''; + + if (record.display === 'hex') { + content = bufferToHexFormat(record.data); + } else if (record.display === 'ascii') { + content = bufferToString(record.data); + } else { + content = bufferToDecString(record.data); + } + + try { + await navigator.clipboard.writeText(content); + toast.success('复制消息内容成功'); + } catch (err) { + console.error('复制失败:', err); + toast.error('复制消息内容失败'); + } +} + +export function scrollToRecord(index: number) { + scrollToRecordIndex.set(index); + + setTimeout(() => { + scrollToRecordIndex.set(-1); + }, 100); +} diff --git a/src/lib/stores/utils/time.ts b/src/lib/stores/utils/time.ts new file mode 100644 index 0000000..8437760 --- /dev/null +++ b/src/lib/stores/utils/time.ts @@ -0,0 +1,15 @@ +export function formatTimestamp(ts: number) { + const d = new Date(ts); + + const pad = (n: number, l = 2) => String(n).padStart(l, '0'); + + return ( + `${d.getFullYear()}-` + + `${pad(d.getMonth() + 1)}-` + + `${pad(d.getDate())} ` + + `${pad(d.getHours())}:` + + `${pad(d.getMinutes())}:` + + `${pad(d.getSeconds())}:` + + `${pad(d.getMilliseconds(), 3)}` + ); +}