Compare commits

..

16 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
4378d85fe8 style: dash动画 2025-12-24 16:46:09 +08:00
4943b7b611 feat: 为连接中状态添加圆形进度条 2025-12-24 16:46:00 +08:00
9d4bb5d7d9 style: 调整选择框样式 2025-12-24 16:26:28 +08:00
30e6b98cff feat: 添加DeviceName显示
- 根据usb-device.json映射vendorId与productId
- 为页面添加toast
2025-12-24 16:10:11 +08:00
d705058e1d chore!: 调整目录结构
- 将serial相关store移动至次级目录store/serial
2025-12-24 16:09:02 +08:00
6b4df26886 chore: 添加svelte-sonner 2025-12-24 16:08:21 +08:00
614130e303 feat: 实现参数设置与设备连接 2025-12-24 15:03:28 +08:00
03a56ec480 feat: 串口连接参数存储 2025-12-24 15:02:00 +08:00
b841b2641c feat: 实现SerialProvider
- SerialProvider用于为串口连接提供context
2025-12-24 15:01:38 +08:00
00bd397986 feat: 为Select组件实现DaisyUI样式 2025-12-23 14:39:03 +08:00
be210a3e36 fix: 修正文件命名
- 与组件命名一致:button.variants.ts -> Button.variants.ts
2025-12-23 14:23:48 +08:00
36 changed files with 46219 additions and 15 deletions

View File

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

39
pnpm-lock.yaml generated
View File

@ -8,9 +8,18 @@ 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)
devDependencies:
'@commitlint/cli':
specifier: ^20.2.0
@ -429,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'}
@ -1248,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'}
@ -1743,6 +1758,11 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
runed@0.28.0:
resolution: {integrity: sha512-k2xx7RuO9hWcdd9f+8JoBeqWtYrm5CALfgpkg2YDB80ds/QE4w0qqu34A7fqiAwiBBSBQOid7TLxwxVC27ymWQ==}
peerDependencies:
svelte: ^5.7.0
runed@0.35.1:
resolution: {integrity: sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==}
peerDependencies:
@ -1849,6 +1869,11 @@ packages:
svelte:
optional: true
svelte-sonner@1.0.7:
resolution: {integrity: sha512-1EUFYmd7q/xfs2qCHwJzGPh9n5VJ3X6QjBN10fof2vxgy8fYE7kVfZ7uGnd7i6fQaWIr5KvXcwYXE/cmTEjk5A==}
peerDependencies:
svelte: ^5.0.0
svelte-toolbelt@0.10.6:
resolution: {integrity: sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==}
engines: {node: '>=18', pnpm: '>=8.7.0'}
@ -2322,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
@ -3094,6 +3121,8 @@ snapshots:
husky@9.1.7: {}
idb@8.0.3: {}
ignore@5.3.2: {}
ignore@7.0.5: {}
@ -3466,6 +3495,11 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.53.5
fsevents: 2.3.3
runed@0.28.0(svelte@5.46.0):
dependencies:
esm-env: 1.2.2
svelte: 5.46.0
runed@0.35.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):
dependencies:
dequal: 2.0.3
@ -3574,6 +3608,11 @@ snapshots:
optionalDependencies:
svelte: 5.46.0
svelte-sonner@1.0.7(svelte@5.46.0):
dependencies:
runed: 0.28.0(svelte@5.46.0)
svelte: 5.46.0
svelte-toolbelt@0.10.6(@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):
dependencies:
clsx: 2.1.1

44808
src/lib/assets/usb-device.json Normal file

File diff suppressed because it is too large Load Diff

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

@ -0,0 +1,43 @@
<script lang="ts">
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from '$lib/components/ui/tabs';
import SerialSetting from './components/SerialSetting.svelte';
let activeTab = 'serial';
</script>
<Tabs bind:value={activeTab} className="p-2">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="serial">
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
><!-- Icon from Material Design Icons by Pictogrammers - https://github.com/Templarian/MaterialDesign/blob/master/LICENSE --><path
fill="currentColor"
d="M7 3h10v2h2v3h-3v6H8V8H5V5h2zm10 6h2v5h-2zm-6 6h2v7h-2zM5 9h2v5H5z"
/></svg
>
串口</TabsTrigger
>
<TabsTrigger value="ble">
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
><!-- Icon from Material Design Icons by Pictogrammers - https://github.com/Templarian/MaterialDesign/blob/master/LICENSE --><path
fill="currentColor"
d="M14.88 16.29L13 18.17v-3.76m0-8.58l1.88 1.88L13 9.58m4.71-1.87L12 2h-1v7.58L6.41 5L5 6.41L10.59 12L5 17.58L6.41 19L11 14.41V22h1l5.71-5.71l-4.3-4.29z"
/></svg
>
蓝牙</TabsTrigger
>
</TabsList>
<TabsContent value="serial"><SerialSetting /></TabsContent>
<TabsContent value="ble">蓝牙设置</TabsContent>
</Tabs>

View File

@ -0,0 +1,35 @@
<script lang="ts">
import {
Select,
SelectContent,
SelectTrigger,
} from '$lib/components/ui/select';
type Props = {
id: string;
label: string;
items: { label: string; value: string }[];
value: string;
placeholder: string;
};
let { id, label, items, value = $bindable(), placeholder }: Props = $props();
</script>
<label
for={id}
class="text-md leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{label}
</label>
<Select type="single" bind:value>
<SelectTrigger
{id}
class="w-full shadow outline-none focus:border-base-content/20 focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
>
{items.find((i) => i.value === value)?.label ?? placeholder}
</SelectTrigger>
<SelectContent {items} />
</Select>

View File

@ -0,0 +1,189 @@
<script lang="ts">
import {
baudRateValue,
baudRateItems,
dataBitsValue,
parityValue,
stopBitsValue,
} 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;
$inspect($serialState);
const dataBitsItems = [
{ value: '7', label: '7' },
{ value: '8', label: '8' },
];
const parityItems = [
{ value: 'none', label: 'None' },
{ value: 'even', label: 'Even' },
{ value: 'odd', label: 'Odd' },
];
const stopBitsItems = [
{ value: '1', label: '1' },
{ 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);
if ($serialState.error) {
toast.error(`连接串口失败: ${$serialState.error}`);
} 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>
<div class="flex flex-col p-4">
<div class="flex flex-col gap-y-1.5 pb-4">
<h3 class="leading-none font-semibold tracking-tight">
{$serialState.portName ?? '串口设置'}
</h3>
<p class="text-neutral/50">请选择串口连接相关参数</p>
</div>
<div class="flex flex-col space-y-3 pb-4">
<SerialParamSelect
id="baud-rate"
label="波特率"
items={$baudRateItems}
bind:value={$baudRateValue}
placeholder="选择波特率"
/>
<SerialParamSelect
id="data-bits"
label="数据位"
items={dataBitsItems}
bind:value={$dataBitsValue}
placeholder="选择数据位"
/>
<SerialParamSelect
id="parity"
label="校验位"
items={parityItems}
bind:value={$parityValue}
placeholder="选择校验位"
/>
<SerialParamSelect
id="stop-bits"
label="停止位"
items={stopBitsItems}
bind:value={$stopBitsValue}
placeholder="选择停止位"
/>
<div class="flex grid-cols-1 flex-col gap-4">
{#if $serialState.status === 'idle'}
{#if $serialState.port !== null}
<Button class="w-full" color="primary" onclick={connect}
>重新选择设备</Button
>
<Button
class="w-full"
color="primary"
onclick={() => {
serial.reopenPort($serialOptions);
}}>重新连接</Button
>
{:else}
<Button class="w-full" color="primary" onclick={connect}
>选择串口设备</Button
>
{/if}
{:else if $serialState.status === 'requesting' || $serialState.status === 'connecting'}
<Button class="w-full" disabled
><svg class="h-5 w-5 animate-spin" viewBox="0 0 50 50">
<circle
class="animate-dash stroke-base-content/10 stroke-8"
cx="25"
cy="25"
r="20"
fill="none"
/>
</svg>
连接中...</Button
>
{:else if $serialState.status === 'connected'}
<Button class="w-full" color="error" onclick={serial.closePort}
>断开连接</Button
>
{:else}
<Button class="w-full" color="error" onclick={connect}
>连接失败,点击重试</Button
>
{/if}
</div>
</div>
</div>

View File

@ -1,8 +1,8 @@
<script lang="ts">
import { Button as BitsButton, type WithoutChildren } from 'bits-ui';
import type { Snippet } from 'svelte';
import { COLOR_MAP, VARIANT_MAP, SIZE_MAP } from './button.variants';
import type { Color, Variant, Size } from './button.variants';
import { COLOR_MAP, VARIANT_MAP, SIZE_MAP } from './Button.variants';
import type { Color, Variant, Size } from './Button.variants';
type Props = WithoutChildren<BitsButton.RootProps> & {
color?: Color;

View File

@ -0,0 +1,21 @@
export const COLOR_MAP = {
neutral: 'select-neutral',
primary: 'select-primary',
secondary: 'select-secondary',
accent: 'select-accent',
info: 'select-info',
success: 'select-success',
warning: 'select-warning',
error: 'select-error',
} as const;
export const SIZE_MAP = {
xs: 'select-xs',
sm: 'select-sm',
md: 'select-md',
lg: 'select-lg',
xl: 'select-xl',
} as const;
export type Color = keyof typeof COLOR_MAP;
export type Size = keyof typeof SIZE_MAP;

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { Select as BitsSelect, type WithoutChildren } from 'bits-ui';
import { elasticOut, cubicOut } from 'svelte/easing';
import { cubicOut } from 'svelte/easing';
type Props = WithoutChildren<BitsSelect.ContentProps> & {
items: { value: string; label: string; disabled?: boolean }[];
@ -21,7 +21,7 @@
) {
const {
delay = 0,
duration = 1000,
duration = 200,
easing = cubicOut,
y = -6,
start = 0.95,
@ -59,11 +59,7 @@ transform: ${existingTransform} ${translate} ${scale};
{#snippet child({ wrapperProps, props, open })}
{#if open}
<div {...wrapperProps}>
<div
{...props}
in:selectTransition={{ easing: elasticOut }}
out:selectTransition={{ duration: 200 }}
>
<div {...props} transition:selectTransition>
<BitsSelect.ScrollUpButton>up</BitsSelect.ScrollUpButton>
<BitsSelect.Viewport class="p-1">
{#each items as { value, label, disabled } (value)}

View File

@ -2,16 +2,40 @@
import { Select as BitsSelect, type WithoutChildren } from 'bits-ui';
import type { Snippet } from 'svelte';
import { COLOR_MAP, SIZE_MAP } from './Select.variants';
import type { Color, Size } from './Select.variants';
type Props = WithoutChildren<BitsSelect.TriggerProps> & {
color?: Color;
size?: Size;
ghost?: boolean;
class?: string;
children?: Snippet;
};
let { children, class: className = '' }: Props = $props();
let {
color,
size,
ghost,
children,
class: className = '',
...restProps
}: Props = $props();
const classes = $derived(
[
'select',
color && COLOR_MAP[color],
size && SIZE_MAP[size],
ghost && 'select-ghost',
className,
]
.filter(Boolean)
.join(' ')
);
</script>
<BitsSelect.Trigger
class={`select outline-none focus:border-base-content/20 focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 ${className}`}
>
<BitsSelect.Trigger class={classes.trim()} {...restProps}>
{@render children?.()}
</BitsSelect.Trigger>

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';

2
src/lib/serial/index.ts Normal file
View File

@ -0,0 +1,2 @@
export * from './serial.service';
export * from './serial.types';

View File

@ -0,0 +1,105 @@
<script lang="ts">
import { onDestroy, type Snippet } from 'svelte';
import { get } from 'svelte/store';
import { serialState, setSerialContext } from './serial.store';
import * as service from './serial.service';
import { getDeviceName } from '$lib/utils/device';
interface Props {
children?: Snippet;
}
let { children }: Props = $props();
async function requestPort() {
serialState.update((state) => ({
...state,
status: 'requesting',
error: undefined,
}));
try {
const port = await service.requestPort();
if (!port) return null;
const info = port.getInfo?.();
const name = info
? (getDeviceName(info) ??
`USB ${info.usbVendorId ?? ''}:${info.usbProductId ?? ''}`)
: 'Serial Device';
serialState.update((s) => ({
...s,
port,
portName: name,
status: 'idle',
}));
return port;
} catch (err) {
serialState.update((s) => ({
...s,
status: 'error',
error: String(err),
}));
return null;
}
}
async function openPort(options: SerialOptions) {
let port = get(serialState).port;
if (!port) return;
serialState.update((s) => ({
...s,
status: 'connecting',
error: undefined,
}));
try {
await service.openPort(port, options);
serialState.update((s) => ({ ...s, status: 'connected' }));
} catch (err) {
serialState.update((s) => ({
...s,
status: 'error',
error: String(err),
}));
}
}
async function closePort() {
let port = get(serialState).port;
if (!port) return;
serialState.update((s) => ({ ...s, status: 'disconnecting' }));
await service.closePort(port);
serialState.update((s) => ({
...s,
status: 'idle',
}));
}
async function reopenPort(options: SerialOptions) {
await closePort();
await openPort(options);
}
setSerialContext({
state: {
subscribe: serialState.subscribe,
},
requestPort,
openPort,
reopenPort,
closePort,
});
onDestroy(() => {
let port = get(serialState).port;
if (port) service.closePort(port);
});
</script>
{@render children?.()}

View File

@ -0,0 +1,108 @@
// src/lib/serial/serial.service.ts
// Web Serial API implementation
// Requires: @types/w3c-web-serial
/**
* 内部IO资源
*/
let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
let writer: WritableStreamDefaultWriter<Uint8Array> | null = null;
let isOpen = false;
/**
* 判断当前环境是否支持Web Serial API
*/
export function isWebSerialSupported(): boolean {
return typeof navigator !== 'undefined' && 'serial' in navigator;
}
/**
* 请求用户选择串口设备
*/
export async function requestPort(
filters?: SerialPortRequestOptions['filters']
): Promise<SerialPort | null> {
if (!isWebSerialSupported()) {
throw new Error('Web Serial API is not supported in this environment.');
}
const port = await navigator.serial.requestPort(
filters ? { filters } : undefined
);
return port ?? null;
}
/**
* 打开串口
*/
export async function openPort(
port: SerialPort,
options: SerialOptions
): Promise<void> {
if (isOpen) {
// 串口已经打开,直接返回
return;
}
await port.open(options);
isOpen = true;
// 初始化reader和writer
reader = port.readable?.getReader() ?? null;
writer = port.writable?.getWriter() ?? null;
}
/**
* 关闭串口
*/
export async function closePort(port: SerialPort) {
if (!isOpen) {
// 串口未打开,直接返回
return;
}
await reader?.cancel();
reader?.releaseLock();
await writer?.close();
writer?.releaseLock();
reader = null;
writer = null;
isOpen = false;
await port.close();
}
/**
* 向串口写入数据
*/
export async function write(data: Uint8Array): Promise<void> {
if (!writer) {
throw new Error('Serial port is not writable.');
}
await writer.write(data);
}
/**
* 开始读取串口数据循环
*/
export async function startReadLoop(
onData: (chunk: Uint8Array) => void,
onError?: (err: unknown) => void
): Promise<void> {
if (!reader) {
throw new Error('Serial port is not readable.');
}
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (value) onData(value);
}
} catch (err) {
onError?.(err);
}
}

View File

@ -0,0 +1,13 @@
import { writable } from 'svelte/store';
import { createContext } from 'svelte';
import type { SerialState, SerialContext } from './serial.types';
export const serialState = writable<SerialState>({
port: null,
portName: null,
status: 'idle',
error: undefined,
});
export const [getSerialContext, setSerialContext] =
createContext<SerialContext>();

View File

@ -0,0 +1,24 @@
import type { Readable } from 'svelte/store';
export type SerialStatus =
| 'idle'
| 'requesting'
| 'connecting'
| 'connected'
| 'disconnecting'
| 'error';
export type SerialState = {
port: SerialPort | null;
portName: string | null;
status: SerialStatus;
error?: string;
};
export type SerialContext = {
state: Readable<SerialState>;
requestPort: () => Promise<SerialPort | null>;
openPort: (options: SerialOptions) => Promise<void>;
reopenPort: (options: SerialOptions) => Promise<void>;
closePort: () => Promise<void>;
};

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,29 @@
import { derived, get } from 'svelte/store';
import { baudRate, dataBits, stopBits, parity, flowControl } from './serial';
export function getSerialOptions(): SerialOptions {
return {
baudRate: get(baudRate),
dataBits: get(dataBits) as 7 | 8,
stopBits: get(stopBits) as 1 | 2,
parity: get(parity),
flowControl: get(flowControl),
};
}
export const serialOptions = derived(
[baudRate, dataBits, stopBits, parity, flowControl],
([
$baudRate,
$dataBits,
$stopBits,
$parity,
$flowControl,
]): SerialOptions => ({
baudRate: $baudRate,
dataBits: $dataBits as 7 | 8,
stopBits: $stopBits as 1 | 2,
parity: $parity,
flowControl: $flowControl,
})
);

View File

@ -0,0 +1,47 @@
import { derived, writable, get } from 'svelte/store';
import { useLocalStorage } from '../utils/useLocalStorage';
export type ReadType = 'hex' | 'ascii' | 'dec';
const defaultBaudRatesList = [9600, 19200, 38400, 57600, 115200];
/* ---------------- 派生状态 ---------------- */
export const baudRate = useLocalStorage<number>('baudRate', 9600);
export const baudRateList = useLocalStorage<number[]>(
'baudRateList',
defaultBaudRatesList
);
export const dataBits = writable<number>(8);
export const stopBits = writable<number>(1);
export const parity = writable<ParityType>('none');
export const flowControl = writable<FlowControlType>('none');
export const readType = useLocalStorage<ReadType>('readType', 'hex');
export const sendType = useLocalStorage<ReadType>('sendType', 'hex');
export const hasDecTypes = useLocalStorage<boolean>('hasDecTypes', false);
/* ---------------- 派生状态 ---------------- */
export const recordTypes = derived(hasDecTypes, ($hasDecTypes) =>
$hasDecTypes
? (['hex', 'ascii', 'dec'] satisfies ReadType[])
: (['hex', 'ascii'] satisfies ReadType[])
);
/* ---------------- Actions ---------------- */
export function nextReadType() {
const types = get(recordTypes);
const current = get(readType);
const index = types.indexOf(current);
readType.set(types[(index + 1) % types.length]);
}
export function nextSendType() {
const types = get(recordTypes);
const current = get(sendType);
const index = types.indexOf(current);
sendType.set(types[(index + 1) % types.length]);
}

View File

@ -0,0 +1,60 @@
import { derived } from 'svelte/store';
import { baudRate, baudRateList, dataBits, parity, stopBits } from './serial';
import { createUIBridge } from '../utils/uiBridge';
import { isOneOf } from '../utils/typeGuard';
export const baudRateItems = derived(baudRateList, ($list) =>
$list.map((rate) => ({
value: String(rate),
label: `${rate}`,
}))
);
const baudRateBridge = createUIBridge(
baudRate,
(v) => String(v),
(v) => {
const n = Number(v);
return Number.isFinite(n) ? n : undefined;
}
);
export const baudRateValue = baudRateBridge.store;
const dataBitsBridge = createUIBridge(
dataBits,
(v) => String(v),
(v) => {
const n = Number(v);
return n;
}
);
export const dataBitsValue = dataBitsBridge.store;
const parityBridge = createUIBridge(
parity,
(v) => String(v),
(v) => {
const PARITY_VALUES = ['none', 'odd', 'even'] as const;
if (!isOneOf(v, PARITY_VALUES)) {
console.warn(`Invalid parity value: ${v}`);
return undefined;
}
const n = v;
return n;
}
);
export const parityValue = parityBridge.store;
const stopBitsBridge = createUIBridge(
stopBits,
(v) => String(v),
(v) => {
const n = Number(v);
return n;
}
);
export const stopBitsValue = stopBitsBridge.store;

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

View File

@ -0,0 +1,6 @@
export function isOneOf<T extends readonly unknown[]>(
value: unknown,
allowed: T
): value is T[number] {
return allowed.includes(value as T[number]);
}

View File

@ -0,0 +1,36 @@
import { get, writable, type Writable } from 'svelte/store';
export function createUIBridge<T, U>(
source: Writable<T>,
toUI: (value: T) => U,
fromUI: (value: U) => T | undefined
) {
const ui = writable<U>(toUI(get(source)));
let syncing = false;
const unsubSource = source.subscribe((v) => {
if (syncing) return;
syncing = true;
ui.set(toUI(v));
syncing = false;
});
const unsubUI = ui.subscribe((v) => {
if (syncing) return;
const next = fromUI(v);
if (next === undefined) return;
syncing = true;
source.set(next);
syncing = false;
});
return {
store: ui,
destroy() {
unsubSource();
unsubUI();
},
};
}

View File

@ -0,0 +1,22 @@
import { writable, type Writable } from 'svelte/store';
export function useLocalStorage<T>(key: string, initialValue: T): Writable<T> {
let startValue = initialValue;
if (typeof localStorage !== 'undefined') {
const stored = localStorage.getItem(key);
if (stored !== null) {
startValue = JSON.parse(stored);
}
}
const store = writable<T>(startValue);
store.subscribe((value) => {
if (typeof localStorage !== 'undefined') {
localStorage.setItem(key, JSON.stringify(value));
}
});
return store;
}

32
src/lib/utils/device.ts Normal file
View File

@ -0,0 +1,32 @@
import USBJson from '$lib/assets/usb-device.json';
type UsbIds = {
[vendorId: string]: {
name: string;
devices: {
[productId: string]: {
devname: string;
};
};
};
};
void (USBJson satisfies UsbIds);
const USB_IDS: UsbIds = USBJson;
export function getDeviceName(info: SerialPortInfo): string | undefined {
if (!info) {
return undefined;
}
const { usbProductId, usbVendorId } = info;
if (!usbVendorId || !usbProductId) {
return undefined;
}
const vendor = USB_IDS[usbVendorId.toString(16).padStart(4, '0')];
if (!vendor) {
return undefined;
}
const product = vendor.devices[usbProductId.toString(16).padStart(4, '0')];
return product ? product.devname : undefined;
}

View File

@ -1,9 +1,13 @@
<script lang="ts">
import './layout.css';
import favicon from '$lib/assets/favicon.svg';
import SerialProvider from '$lib/serial/serial.providers.svelte';
let { children } = $props();
</script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
{@render children()}
<SerialProvider>
{@render children()}
</SerialProvider>

View File

@ -1,6 +1,20 @@
<script lang="ts">
import DeviceSetting from '$lib/components/SettingPanel/DeviceSetting.svelte';
import { Toaster } from 'svelte-sonner';
</script>
<h1>Welcome to SvelteKit</h1>
<p>
Visit <a class="text-orange-400" href="https://svelte.dev/docs/kit"
>svelte.dev/docs/kit</a
> to read the documentation
</p>
<div class="flex">
<div class="flex w-60">
<DeviceSetting />
</div>
<div class="flex">123</div>
</div>
<Toaster />

View File

@ -1,5 +1,25 @@
@import "tailwindcss";
@plugin "@tailwindcss/forms";
@theme {
--animate-dash: dash 1.5s cubic-bezier(0.4, 0, 0.2, 1) infinite;
@keyframes dash {
0% {
stroke-dasharray: 1, 150;
stroke-dashoffset: 0;
}
50% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -40;
}
100% {
stroke-dasharray: 90, 150;
stroke-dashoffset: -120;
}
}
}
@plugin "daisyui" {
logs: false;
}