Compare commits

...

5 Commits

Author SHA1 Message Date
0ddf7b1c49 chore: 添加项目依赖
- text-encoding:用于gbk编码
- idb:用于IndexedDb
2025-12-26 13:34:54 +08:00
e1d57a4816 feat: 实现record全局store
- 工具函数:datacode用于处理编码,time用于格式化时间
2025-12-26 13:34:17 +08:00
ea408ba924 feat: 实现readingRecord 2025-12-26 13:33:28 +08:00
6e6d2da3ce feat: 实现串口信息显示
- 以chat-bubble形式显示串口信息
2025-12-26 13:33:08 +08:00
955dc99119 feat: 实现Tooltip组件 2025-12-25 15:44:48 +08:00
13 changed files with 598 additions and 0 deletions

View File

@ -52,7 +52,9 @@
"vite": "^7.2.6" "vite": "^7.2.6"
}, },
"dependencies": { "dependencies": {
"@kayahr/text-encoding": "^2.1.0",
"bits-ui": "^2.14.4", "bits-ui": "^2.14.4",
"idb": "^8.0.3",
"svelte-sonner": "^1.0.7" "svelte-sonner": "^1.0.7"
} }
} }

16
pnpm-lock.yaml generated
View File

@ -8,9 +8,15 @@ importers:
.: .:
dependencies: dependencies:
'@kayahr/text-encoding':
specifier: ^2.1.0
version: 2.1.0
bits-ui: bits-ui:
specifier: ^2.14.4 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) 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: svelte-sonner:
specifier: ^1.0.7 specifier: ^1.0.7
version: 1.0.7(svelte@5.46.0) version: 1.0.7(svelte@5.46.0)
@ -432,6 +438,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} 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': '@parcel/watcher-android-arm64@2.5.1':
resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
@ -1251,6 +1260,9 @@ packages:
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
idb@8.0.3:
resolution: {integrity: sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==}
ignore@5.3.2: ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
@ -2335,6 +2347,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@kayahr/text-encoding@2.1.0': {}
'@parcel/watcher-android-arm64@2.5.1': '@parcel/watcher-android-arm64@2.5.1':
optional: true optional: true
@ -3107,6 +3121,8 @@ snapshots:
husky@9.1.7: {} husky@9.1.7: {}
idb@8.0.3: {}
ignore@5.3.2: {} ignore@5.3.2: {}
ignore@7.0.5: {} ignore@7.0.5: {}

View 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>

View 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>

View File

@ -0,0 +1,5 @@
export const POSITION_MAP = {
read: 'chat-start',
write: 'chat-end',
system: 'chat-start',
};

View File

@ -8,12 +8,15 @@
} from '$lib/stores/serial/serial.ui'; } from '$lib/stores/serial/serial.ui';
import { serialOptions } from '$lib/stores/serial/serial.options'; import { serialOptions } from '$lib/stores/serial/serial.options';
import SerialParamSelect from './SerialParamSelect.svelte'; import SerialParamSelect from './SerialParamSelect.svelte';
import { addRecord, readingRecord } from '$lib/stores/record/record';
import { get } from 'svelte/store';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { getSerialContext } from '$lib/serial/serial.store'; import { getSerialContext } from '$lib/serial/serial.store';
import { startReadLoop } from '$lib/serial';
const serial = getSerialContext(); const serial = getSerialContext();
const serialState = serial.state; const serialState = serial.state;
@ -36,6 +39,46 @@
{ value: '2', label: '2' }, { 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() { async function connect() {
await serial.requestPort(); await serial.requestPort();
await serial.openPort($serialOptions); await serial.openPort($serialOptions);
@ -44,6 +87,19 @@
} else { } else {
toast.success('串口连接成功'); 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> </script>

View 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>

View 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>

View 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>

View 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
View 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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll(' ', '&nbsp;')
.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);
}

View 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);
}

View 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)}`
);
}