feat: 实现串口信息显示
- 以chat-bubble形式显示串口信息
This commit is contained in:
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',
|
||||
};
|
||||
Reference in New Issue
Block a user