Compare commits
5 Commits
4378d85fe8
...
0ddf7b1c49
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ddf7b1c49 | |||
| e1d57a4816 | |||
| ea408ba924 | |||
| 6e6d2da3ce | |||
| 955dc99119 |
@ -52,7 +52,9 @@
|
||||
"vite": "^7.2.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kayahr/text-encoding": "^2.1.0",
|
||||
"bits-ui": "^2.14.4",
|
||||
"idb": "^8.0.3",
|
||||
"svelte-sonner": "^1.0.7"
|
||||
}
|
||||
}
|
||||
|
||||
16
pnpm-lock.yaml
generated
16
pnpm-lock.yaml
generated
@ -8,9 +8,15 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@kayahr/text-encoding':
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
bits-ui:
|
||||
specifier: ^2.14.4
|
||||
version: 2.14.4(@internationalized/date@3.10.1)(@sveltejs/kit@2.49.2(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.46.0)(vite@7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.0)(yaml@2.8.2)))(svelte@5.46.0)(vite@7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.0)(yaml@2.8.2)))(svelte@5.46.0)
|
||||
idb:
|
||||
specifier: ^8.0.3
|
||||
version: 8.0.3
|
||||
svelte-sonner:
|
||||
specifier: ^1.0.7
|
||||
version: 1.0.7(svelte@5.46.0)
|
||||
@ -432,6 +438,9 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@kayahr/text-encoding@2.1.0':
|
||||
resolution: {integrity: sha512-U/2bmZyKG0TvhIws4+tfA8AWtl0RHOaFENQG8DAe7UyEhIYuhpSgijOh4WDFHLIDO6+WyFRM9nZtKm5Pe6iquw==}
|
||||
|
||||
'@parcel/watcher-android-arm64@2.5.1':
|
||||
resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==}
|
||||
engines: {node: '>= 10.0.0'}
|
||||
@ -1251,6 +1260,9 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
idb@8.0.3:
|
||||
resolution: {integrity: sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==}
|
||||
|
||||
ignore@5.3.2:
|
||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||
engines: {node: '>= 4'}
|
||||
@ -2335,6 +2347,8 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@kayahr/text-encoding@2.1.0': {}
|
||||
|
||||
'@parcel/watcher-android-arm64@2.5.1':
|
||||
optional: true
|
||||
|
||||
@ -3107,6 +3121,8 @@ snapshots:
|
||||
|
||||
husky@9.1.7: {}
|
||||
|
||||
idb@8.0.3: {}
|
||||
|
||||
ignore@5.3.2: {}
|
||||
|
||||
ignore@7.0.5: {}
|
||||
|
||||
92
src/lib/components/RecordPanel/RecordPanel.svelte
Normal file
92
src/lib/components/RecordPanel/RecordPanel.svelte
Normal file
@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import ChatBubble from './components/ChatBubble.svelte';
|
||||
|
||||
import {
|
||||
records,
|
||||
readingRecord,
|
||||
pinBottom,
|
||||
scrollToRecordIndex,
|
||||
} from '$lib/stores/record/record';
|
||||
|
||||
let rootEl: HTMLDivElement | null = null;
|
||||
let showFullDate = $state(false);
|
||||
|
||||
async function scrollToBottom() {
|
||||
if (!rootEl) return;
|
||||
rootEl.scrollTop = rootEl.scrollHeight + 2000;
|
||||
}
|
||||
|
||||
let lastLength = 0;
|
||||
|
||||
$effect(() => {
|
||||
const len = $records.length;
|
||||
const pinned = $pinBottom;
|
||||
|
||||
if (pinned && len !== lastLength) {
|
||||
lastLength = len;
|
||||
(async () => {
|
||||
await tick();
|
||||
|
||||
scrollToBottom();
|
||||
})();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const index = $scrollToRecordIndex;
|
||||
|
||||
if (index >= 0 && rootEl) {
|
||||
const els = rootEl.querySelectorAll('.chat');
|
||||
const el = els[index];
|
||||
|
||||
if (el) {
|
||||
el.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
}
|
||||
|
||||
scrollToRecordIndex.set(-1);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={rootEl}
|
||||
class="record-panel relative w-full overflow-y-auto scroll-smooth p-2 pb-10"
|
||||
>
|
||||
{#each $records as record (record.timestamp)}
|
||||
<ChatBubble {record} bind:fullDate={showFullDate} />
|
||||
{/each}
|
||||
|
||||
{#if $readingRecord}
|
||||
<ChatBubble
|
||||
bind:record={$readingRecord}
|
||||
reading={true}
|
||||
bind:fullDate={showFullDate}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #ccc;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
border-radius: 4px;
|
||||
margin: 15px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
</style>
|
||||
103
src/lib/components/RecordPanel/components/ChatBubble.svelte
Normal file
103
src/lib/components/RecordPanel/components/ChatBubble.svelte
Normal file
@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
bufferToHexFormat,
|
||||
bufferToDecString,
|
||||
bufferToString,
|
||||
} from '$lib/stores/datacode';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '$lib/components/ui/tooltip';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import Copy from 'phosphor-svelte/lib/Copy';
|
||||
import { formatTimestamp } from '$lib/stores/utils/time';
|
||||
import type { RecordItem } from '$lib/stores/record/record';
|
||||
import { POSITION_MAP } from './ChatBubble.variant';
|
||||
import { copyRecordContent } from '$lib/stores/record/record';
|
||||
|
||||
type Props = {
|
||||
record: RecordItem;
|
||||
reading?: boolean;
|
||||
fullDate?: boolean;
|
||||
};
|
||||
|
||||
let {
|
||||
record = $bindable(),
|
||||
fullDate = $bindable(),
|
||||
reading = false,
|
||||
}: Props = $props();
|
||||
|
||||
type DisplayType = 'hex' | 'ascii';
|
||||
|
||||
const types = ['hex', 'ascii'] satisfies DisplayType[];
|
||||
|
||||
const positionClass = $derived(POSITION_MAP[record.type]);
|
||||
|
||||
let currentDisplay: DisplayType = $state('ascii');
|
||||
|
||||
function toggleDisplay() {
|
||||
const idx = types.indexOf(currentDisplay);
|
||||
const next = types[(idx + 1) % types.length];
|
||||
|
||||
currentDisplay = next;
|
||||
}
|
||||
|
||||
function toggleTimeFormat() {
|
||||
if (fullDate === undefined) return;
|
||||
fullDate = !fullDate;
|
||||
}
|
||||
|
||||
function formatTime(ts?: number) {
|
||||
if (!ts) return '';
|
||||
const full = formatTimestamp(ts);
|
||||
return fullDate ? full : full.slice(11); // HH:mm:ss:SSS
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class={`group chat ${positionClass}`}>
|
||||
<div class="chat-header mx-2 flex">
|
||||
<button
|
||||
class="cursor-pointer text-sm opacity-70 hover:opacity-100"
|
||||
onclick={toggleTimeFormat}
|
||||
>
|
||||
{formatTime(record.timestamp)}
|
||||
</button>
|
||||
|
||||
<div class="w-4"></div>
|
||||
|
||||
{#if !reading}
|
||||
<button
|
||||
class="cursor-pointer text-sm font-medium opacity-70 hover:opacity-100"
|
||||
onclick={toggleDisplay}
|
||||
>
|
||||
{currentDisplay.toUpperCase()}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="chat-bubble max-w-[50%] text-sm wrap-break-word">
|
||||
{#if currentDisplay === 'hex'}
|
||||
{bufferToHexFormat(record.data)}
|
||||
{:else if currentDisplay === 'ascii'}
|
||||
{bufferToString(record.data)}
|
||||
{:else}
|
||||
{bufferToDecString(record.data)}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="chat-footer mt-1 opacity-0 transition group-hover:opacity-100">
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button
|
||||
circle
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
onclick={() => copyRecordContent(record)}
|
||||
>
|
||||
<Copy size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>复制</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
@ -0,0 +1,5 @@
|
||||
export const POSITION_MAP = {
|
||||
read: 'chat-start',
|
||||
write: 'chat-end',
|
||||
system: 'chat-start',
|
||||
};
|
||||
@ -8,12 +8,15 @@
|
||||
} from '$lib/stores/serial/serial.ui';
|
||||
import { serialOptions } from '$lib/stores/serial/serial.options';
|
||||
import SerialParamSelect from './SerialParamSelect.svelte';
|
||||
import { addRecord, readingRecord } from '$lib/stores/record/record';
|
||||
import { get } from 'svelte/store';
|
||||
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
|
||||
import { getSerialContext } from '$lib/serial/serial.store';
|
||||
import { startReadLoop } from '$lib/serial';
|
||||
|
||||
const serial = getSerialContext();
|
||||
const serialState = serial.state;
|
||||
@ -36,6 +39,46 @@
|
||||
{ value: '2', label: '2' },
|
||||
];
|
||||
|
||||
function concatUint8(a: Uint8Array, b: Uint8Array) {
|
||||
const out = new Uint8Array(a.length + b.length);
|
||||
out.set(a, 0);
|
||||
out.set(b, a.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
function appendReadingChunk(chunk: Uint8Array) {
|
||||
readingRecord.update((current) => {
|
||||
// ① 还没有 readingRecord → 新建
|
||||
if (!current) {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'read',
|
||||
data: chunk,
|
||||
timestamp: Date.now(),
|
||||
display: 'ascii',
|
||||
};
|
||||
}
|
||||
|
||||
// ② 已存在 → 追加数据
|
||||
return {
|
||||
...current,
|
||||
data: concatUint8(current.data, chunk),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function finalizeReadingRecord() {
|
||||
const record = get(readingRecord);
|
||||
if (!record) return;
|
||||
|
||||
addRecord(record);
|
||||
readingRecord.set(undefined);
|
||||
}
|
||||
|
||||
function isRecordFinished(chunk: Uint8Array) {
|
||||
return chunk.includes(0x0a); // '\n'
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
await serial.requestPort();
|
||||
await serial.openPort($serialOptions);
|
||||
@ -44,6 +87,19 @@
|
||||
} else {
|
||||
toast.success('串口连接成功');
|
||||
}
|
||||
await startReadLoop(
|
||||
(chunk) => {
|
||||
appendReadingChunk(chunk);
|
||||
if (isRecordFinished(chunk)) {
|
||||
console.log('Received chunk:', chunk);
|
||||
finalizeReadingRecord();
|
||||
}
|
||||
},
|
||||
(err) => {
|
||||
console.error('Read loop error:', err);
|
||||
toast.error(`读取数据失败: ${err}`);
|
||||
}
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
16
src/lib/components/ui/tooltip/Tooltip.svelte
Normal file
16
src/lib/components/ui/tooltip/Tooltip.svelte
Normal file
@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { Tooltip as BitsTooltip, type WithoutChildren } from 'bits-ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
type Props = WithoutChildren<BitsTooltip.RootProps> & {
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
let { children, ...restProps }: Props = $props();
|
||||
</script>
|
||||
|
||||
<BitsTooltip.Provider delayDuration={0}>
|
||||
<BitsTooltip.Root {...restProps}>
|
||||
{@render children?.()}
|
||||
</BitsTooltip.Root>
|
||||
</BitsTooltip.Provider>
|
||||
30
src/lib/components/ui/tooltip/TooltipContent.svelte
Normal file
30
src/lib/components/ui/tooltip/TooltipContent.svelte
Normal file
@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { Tooltip as BitsTooltip, type WithoutChildren } from 'bits-ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { fly } from 'svelte/transition';
|
||||
|
||||
type Props = WithoutChildren<BitsTooltip.ContentProps> & {
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
let { children, ...restProps }: Props = $props();
|
||||
</script>
|
||||
|
||||
<BitsTooltip.Portal>
|
||||
<BitsTooltip.Content
|
||||
forceMount
|
||||
sideOffset={8}
|
||||
class="tooltip z-0 flex items-center justify-center rounded-xl border border-base-content/20 bg-base-200 p-3 text-sm font-medium shadow-xl outline-hidden"
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet child({ wrapperProps, props, open })}
|
||||
{#if open}
|
||||
<div {...wrapperProps}>
|
||||
<div {...props} transition:fly={{ y: 5, duration: 150 }}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</BitsTooltip.Content>
|
||||
</BitsTooltip.Portal>
|
||||
14
src/lib/components/ui/tooltip/TooltipTrigger.svelte
Normal file
14
src/lib/components/ui/tooltip/TooltipTrigger.svelte
Normal file
@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { Tooltip as BitsTooltip, type WithoutChildren } from 'bits-ui';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
type Props = WithoutChildren<BitsTooltip.TriggerProps> & {
|
||||
children?: Snippet;
|
||||
};
|
||||
|
||||
let { children, ...restProps }: Props = $props();
|
||||
</script>
|
||||
|
||||
<BitsTooltip.Trigger class="tooltip tooltip-primary" {...restProps}>
|
||||
{@render children?.()}
|
||||
</BitsTooltip.Trigger>
|
||||
3
src/lib/components/ui/tooltip/index.ts
Normal file
3
src/lib/components/ui/tooltip/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { default as Tooltip } from './Tooltip.svelte';
|
||||
export { default as TooltipTrigger } from './TooltipTrigger.svelte';
|
||||
export { default as TooltipContent } from './TooltipContent.svelte';
|
||||
113
src/lib/stores/datacode.ts
Normal file
113
src/lib/stores/datacode.ts
Normal file
@ -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<DataCode>(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', '<br/>')
|
||||
.replaceAll('\n', '<br/>')
|
||||
.replaceAll('\r', '<br/>');
|
||||
}
|
||||
|
||||
export function stringToText(str: string) {
|
||||
return str;
|
||||
}
|
||||
|
||||
/*
|
||||
* 工具函数
|
||||
*/
|
||||
export function isHexString(str: string) {
|
||||
return /^[0-9a-f]+$/i.test(str);
|
||||
}
|
||||
133
src/lib/stores/record/record.ts
Normal file
133
src/lib/stores/record/record.ts
Normal file
@ -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<RecordItem[]>([]);
|
||||
export const readingRecord = writable<RecordItem | undefined>(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);
|
||||
}
|
||||
15
src/lib/stores/utils/time.ts
Normal file
15
src/lib/stores/utils/time.ts
Normal file
@ -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)}`
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user