Compare commits
41 Commits
0f2f1365b1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ddf7b1c49 | |||
| e1d57a4816 | |||
| ea408ba924 | |||
| 6e6d2da3ce | |||
| 955dc99119 | |||
| 4378d85fe8 | |||
| 4943b7b611 | |||
| 9d4bb5d7d9 | |||
| 30e6b98cff | |||
| d705058e1d | |||
| 6b4df26886 | |||
| 614130e303 | |||
| 03a56ec480 | |||
| b841b2641c | |||
| 00bd397986 | |||
| be210a3e36 | |||
| d32d88b56c | |||
| 71df72f843 | |||
| 6069fd29ae | |||
| da34c24f91 | |||
| feada70221 | |||
| 724071af02 | |||
| f3c03fa167 | |||
| 3be23de9da | |||
| 16b65653bb | |||
| 0de433e8d4 | |||
| a5adfc6692 | |||
| 72dbed1678 | |||
| d8d0529de6 | |||
| 12c352d21e | |||
| 7643c73a11 | |||
| 4c347a5bd7 | |||
| 9ba162ad2f | |||
| d197985e6c | |||
| a2302d4116 | |||
| 9bdeca575b | |||
| dacc130293 | |||
| f192180d22 | |||
| 5e6e4b2960 | |||
| f2214f3b46 | |||
| 754ee32d79 |
9
.editorconfig
Normal file
9
.editorconfig
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
61
.gitea/ISSUE_TEMPLATE/BUG_REPORT.yaml
Normal file
61
.gitea/ISSUE_TEMPLATE/BUG_REPORT.yaml
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
name: Bug报告
|
||||||
|
about: 用于报告项目中的Bug,帮助我们改进项目
|
||||||
|
title: '[BUG] ' # 新开 Issue 时默认加的前缀
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
请在下方填写Bug的详细信息,包括重现步骤、预期结果和实际结果等。
|
||||||
|
- type: textarea
|
||||||
|
id: summary
|
||||||
|
attributes:
|
||||||
|
label: '问题概述/Summary'
|
||||||
|
description: '简要描述遇到的问题'
|
||||||
|
placeholder: '输入问题概述'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: steps
|
||||||
|
attributes:
|
||||||
|
label: '重现步骤/Steps to Reproduce'
|
||||||
|
description: '列出重现该问题的具体步骤'
|
||||||
|
placeholder: '输入重现步骤'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: expected
|
||||||
|
attributes:
|
||||||
|
label: '预期结果/Expected Behavior'
|
||||||
|
description: '描述你期望看到的结果'
|
||||||
|
placeholder: '输入预期结果'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: actual
|
||||||
|
attributes:
|
||||||
|
label: '实际结果/Actual Behavior'
|
||||||
|
description: '描述实际发生的情况'
|
||||||
|
placeholder: '输入实际结果'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: '环境信息/Environment'
|
||||||
|
description: '提供相关环境信息,如操作系统、浏览器版本等'
|
||||||
|
placeholder: '输入环境信息'
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: '检查表/Checklist'
|
||||||
|
description: '提交前,请确认以下事项'
|
||||||
|
options:
|
||||||
|
- label: 已搜索过类似问题,确保不是重复报告
|
||||||
|
required: true
|
||||||
|
- label: 提供了足够的信息以帮助我们理解和重现问题
|
||||||
|
required: true
|
||||||
|
- label: 如果可能,已附上相关截图或日志文件
|
||||||
|
required: false
|
||||||
53
.gitea/ISSUE_TEMPLATE/FEATURE_REQUEST.yaml
Normal file
53
.gitea/ISSUE_TEMPLATE/FEATURE_REQUEST.yaml
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
name: 功能请求
|
||||||
|
about: 用于提出新功能或改进建议
|
||||||
|
title: '[FEATURE] ' # 新开 Issue 时默认加的前缀
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
请在下方填写功能请求的详细信息,包括需求背景、预期效果和相关上下文。
|
||||||
|
- type: textarea
|
||||||
|
id: summary
|
||||||
|
attributes:
|
||||||
|
label: '功能概述/Summary'
|
||||||
|
description: '简要描述你希望添加的功能'
|
||||||
|
placeholder: '输入功能概述'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: motivation
|
||||||
|
attributes:
|
||||||
|
label: '需求背景/Motivation'
|
||||||
|
description: '说明为什么需要这个功能,它将如何帮助用户或改进现有流程'
|
||||||
|
placeholder: '输入需求背景'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: proposal
|
||||||
|
attributes:
|
||||||
|
label: '解决方案/Proposal'
|
||||||
|
description: '描述你对该功能的设想或建议的实现方式'
|
||||||
|
placeholder: '输入解决方案'
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: textarea
|
||||||
|
id: alternatives
|
||||||
|
attributes:
|
||||||
|
label: '替代方案/Alternatives'
|
||||||
|
description: '如果有其他可行的替代方案,请在此列出'
|
||||||
|
placeholder: '输入替代方案'
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: checkboxes
|
||||||
|
id: checklist
|
||||||
|
attributes:
|
||||||
|
label: '检查表/Checklist'
|
||||||
|
description: '提交前,请确认以下事项'
|
||||||
|
options:
|
||||||
|
- label: 已搜索过类似功能请求,确保不是重复提交
|
||||||
|
required: true
|
||||||
|
- label: 提供了足够的信息以帮助我们理解和评估该请求
|
||||||
|
required: true
|
||||||
|
- label: 如果可能,已附上相关截图或示意图
|
||||||
|
required: false
|
||||||
62
.gitea/PULL_REQUEST_TEMPLATE.yaml
Normal file
62
.gitea/PULL_REQUEST_TEMPLATE.yaml
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
name: 'Standard PR 模板'
|
||||||
|
about: '用于提交新功能/修复bug/改进重构等合并请求'
|
||||||
|
title: '[PR] ' # 新开 PR 时默认加的前缀
|
||||||
|
ref: 'master' # 默认目标分支,可根据你们主分支改为 master/develop 等
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
请在下方填写合并请求描述,包括变更目的、主要内容及相关上下文。
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: '关联Issue'
|
||||||
|
description: '比如: Fixes #123/Closes #456/如果没有可以留空'
|
||||||
|
placeholder: '输入关联 issue 编号'
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: '更改内容/What Changed'
|
||||||
|
description: '列出本次 PR 的主要变更项,便于 Review'
|
||||||
|
placeholder: '输入更改内容'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: '测试步骤/How To Test'
|
||||||
|
description: '提供复现/验证更改的步骤,含环境/命令/预期结果等'
|
||||||
|
placeholder: '输入测试步骤'
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: checkboxes
|
||||||
|
attributes:
|
||||||
|
label: '检查表/Checklist'
|
||||||
|
description: '提交前,请确认以下事项'
|
||||||
|
options:
|
||||||
|
- label: 代码格式符合团队规范
|
||||||
|
required: true
|
||||||
|
- label: 已添加或更新单元测试/集成测试
|
||||||
|
required: true
|
||||||
|
- label: 文档/README 已更新(如有必要)
|
||||||
|
required: false
|
||||||
|
- label: 所有测试通过
|
||||||
|
required: true
|
||||||
|
- label: 分支命名正确(feature/xxx或fix/xxx 等)
|
||||||
|
required: false
|
||||||
|
- label: 回归风险已评估
|
||||||
|
required: false
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: impact
|
||||||
|
attributes:
|
||||||
|
label: '影响评估/Risk & Rollback'
|
||||||
|
description: '评估变更是否有破坏性风险,以及如何回滚/缓解'
|
||||||
|
placeholder: '例如:数据库迁移、依赖变动、兼容性问题等'
|
||||||
|
validations:
|
||||||
|
required: false
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: '可选:截图或 Demo(如界面/UI 有变化)'
|
||||||
|
description: '上传新的界面/UI'
|
||||||
151
.husky/commit-msg
Normal file
151
.husky/commit-msg
Normal file
@ -0,0 +1,151 @@
|
|||||||
|
# 颜色定义
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
PURPLE='\033[0;35m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
GRAY='\033[0;37m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# 启用调试模式(可选)
|
||||||
|
DEBUG=${DEBUG:-false}
|
||||||
|
|
||||||
|
# 输出函数
|
||||||
|
print_info() {
|
||||||
|
echo -e "${BLUE}[INFO]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_success() {
|
||||||
|
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_warning() {
|
||||||
|
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_debug() {
|
||||||
|
if [ "$DEBUG" = "true" ]; then
|
||||||
|
echo -e "${GRAY}[DEBUG]${NC} $1"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
print_section() {
|
||||||
|
echo -e "${PURPLE}[SECTION]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_command() {
|
||||||
|
echo -e "${CYAN}[COMMAND]${NC} $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_section "开始执行Commit Message Hook..."
|
||||||
|
print_info "时间: $(date)"
|
||||||
|
print_info "工作目录: $(pwd)"
|
||||||
|
print_info "用户: $(whoami)"
|
||||||
|
echo
|
||||||
|
|
||||||
|
# 调试信息
|
||||||
|
print_debug "接收到的参数: $*"
|
||||||
|
print_debug "参数数量: $#"
|
||||||
|
print_debug "第一个参数 (提交消息文件): $1"
|
||||||
|
print_debug "Shell: $SHELL"
|
||||||
|
print_debug "PATH: $PATH"
|
||||||
|
print_debug "当前进程ID: $"
|
||||||
|
|
||||||
|
|
||||||
|
# 检查 commitlint 是否可用
|
||||||
|
print_section "检查 commitlint..."
|
||||||
|
if npx commitlint --version >/dev/null 2>&1; then
|
||||||
|
COMMITLINT_VERSION=$(npx commitlint --version)
|
||||||
|
print_success "commitlint 可用: $COMMITLINT_VERSION"
|
||||||
|
else
|
||||||
|
print_error "commitlint 不可用或配置有误"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查提交消息文件
|
||||||
|
echo
|
||||||
|
print_section "检查提交消息文件..."
|
||||||
|
if [ -f "$1" ]; then
|
||||||
|
print_success "文件存在: $1"
|
||||||
|
FILE_SIZE=$(wc -c < "$1")
|
||||||
|
print_info "文件大小: $FILE_SIZE 字节"
|
||||||
|
|
||||||
|
print_info "文件内容:"
|
||||||
|
echo -e "${CYAN}========================================${NC}"
|
||||||
|
cat "$1"
|
||||||
|
echo
|
||||||
|
echo -e "${CYAN}========================================${NC}"
|
||||||
|
|
||||||
|
# 检查文件权限
|
||||||
|
FILE_PERMS=$(ls -l "$1" | awk '{print $1}')
|
||||||
|
print_debug "文件权限: $FILE_PERMS"
|
||||||
|
|
||||||
|
# 检查文件内容是否为空
|
||||||
|
if [ "$FILE_SIZE" -eq 0 ] || [ -z "$(tr -d '[:space:]' < "$1")" ]; then
|
||||||
|
print_warning "提交消息为空"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
print_error "找不到提交消息文件: $1"
|
||||||
|
print_info "当前目录内容:"
|
||||||
|
ls -la
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 检查 commitlint 配置
|
||||||
|
echo
|
||||||
|
print_section "检查 commitlint 配置..."
|
||||||
|
if [ -f ".commitlintrc.js" ] || [ -f ".commitlintrc.json" ] || [ -f "commitlint.config.js" ] || [ -f "commitlint.config.mjs" ]; then
|
||||||
|
print_success "找到 commitlint 配置文件"
|
||||||
|
for config_file in .commitlintrc.js .commitlintrc.json commitlint.config.js; do
|
||||||
|
if [ -f "$config_file" ]; then
|
||||||
|
print_info "配置文件: $config_file"
|
||||||
|
print_debug "$config_file 内容预览:"
|
||||||
|
print_debug "$(head -10 "$config_file" 2>/dev/null || echo '无法读取文件')"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
else
|
||||||
|
print_warning "未找到 commitlint 配置文件"
|
||||||
|
print_info "建议创建 .commitlintrc.js 或 commitlint.config.js/commitlint.config.mjs"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 运行 commitlint
|
||||||
|
echo
|
||||||
|
print_section "执行 commitlint 检查..."
|
||||||
|
print_command "执行命令: npx commitlint --edit \"$1\""
|
||||||
|
|
||||||
|
# 显示详细的执行过程
|
||||||
|
if [ "$DEBUG" = "true" ]; then
|
||||||
|
print_debug "详细执行过程:"
|
||||||
|
npx commitlint --edit "$1" --verbose
|
||||||
|
RESULT=$?
|
||||||
|
else
|
||||||
|
npx commitlint --edit "$1"
|
||||||
|
RESULT=$?
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [ $RESULT -eq 0 ]; then
|
||||||
|
print_success "提交消息格式检查通过!"
|
||||||
|
print_info "准备提交到 Git 仓库..."
|
||||||
|
print_debug "Hook 执行成功,退出代码: 0"
|
||||||
|
else
|
||||||
|
print_error "提交消息格式检查失败! (退出代码: $RESULT)"
|
||||||
|
echo
|
||||||
|
print_warning "故障排查建议:"
|
||||||
|
echo "1. 检查提交消息是否符合约定式提交格式"
|
||||||
|
echo "2. 确认 commitlint 配置是否正确"
|
||||||
|
echo "3. 运行 'DEBUG=true git commit' 查看详细调试信息"
|
||||||
|
echo "4. 手动测试: npx commitlint --edit .git/COMMIT_EDITMSG"
|
||||||
|
echo
|
||||||
|
print_debug "Hook 执行失败,退出代码: $RESULT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
print_info "Hook 执行完成: $(date)"
|
||||||
|
exit $RESULT
|
||||||
|
|
||||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@ -0,0 +1 @@
|
|||||||
|
pnpm exec lint-staged
|
||||||
9
.prettierignore
Normal file
9
.prettierignore
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
# Package Managers
|
||||||
|
package-lock.json
|
||||||
|
pnpm-lock.yaml
|
||||||
|
yarn.lock
|
||||||
|
bun.lock
|
||||||
|
bun.lockb
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/static/
|
||||||
7
commitlint.config.ts
Normal file
7
commitlint.config.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import type { UserConfig } from '@commitlint/types';
|
||||||
|
|
||||||
|
const Configuration: UserConfig = {
|
||||||
|
extends: ['@commitlint/config-conventional']
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Configuration;
|
||||||
41
eslint.config.js
Normal file
41
eslint.config.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import prettier from 'eslint-config-prettier';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { includeIgnoreFile } from '@eslint/compat';
|
||||||
|
import js from '@eslint/js';
|
||||||
|
import svelte from 'eslint-plugin-svelte';
|
||||||
|
import { defineConfig } from 'eslint/config';
|
||||||
|
import globals from 'globals';
|
||||||
|
import ts from 'typescript-eslint';
|
||||||
|
import svelteConfig from './svelte.config.js';
|
||||||
|
|
||||||
|
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
||||||
|
|
||||||
|
export default defineConfig(
|
||||||
|
includeIgnoreFile(gitignorePath),
|
||||||
|
js.configs.recommended,
|
||||||
|
...ts.configs.recommended,
|
||||||
|
...svelte.configs.recommended,
|
||||||
|
prettier,
|
||||||
|
...svelte.configs.prettier,
|
||||||
|
{
|
||||||
|
languageOptions: { globals: { ...globals.browser, ...globals.node } },
|
||||||
|
|
||||||
|
rules: {
|
||||||
|
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||||
|
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||||
|
"no-undef": 'off'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||||
|
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
extraFileExtensions: ['.svelte'],
|
||||||
|
parser: ts.parser,
|
||||||
|
svelteConfig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
8
lint-staged.config.js
Normal file
8
lint-staged.config.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* @filename: lint-staged.config.js
|
||||||
|
* @type {import('lint-staged').Configuration}
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
'*.{ts,tsx,svelte,js,jsx}': ['eslint --fix'],
|
||||||
|
'*.{css,scss,md,json,yaml,yml}': ['prettier --write']
|
||||||
|
};
|
||||||
36
package.json
36
package.json
@ -7,9 +7,12 @@
|
|||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo '' && husky",
|
||||||
|
"prepare-husky": "husky",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"lint": "eslint . && prettier --check .",
|
||||||
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20",
|
"node": ">=20",
|
||||||
@ -17,12 +20,41 @@
|
|||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.18.2",
|
"packageManager": "pnpm@10.18.2",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@commitlint/cli": "^20.2.0",
|
||||||
|
"@commitlint/config-conventional": "^20.2.0",
|
||||||
|
"@commitlint/types": "^20.2.0",
|
||||||
|
"@eslint/compat": "^1.4.0",
|
||||||
|
"@eslint/js": "^9.39.1",
|
||||||
"@sveltejs/adapter-auto": "^7.0.0",
|
"@sveltejs/adapter-auto": "^7.0.0",
|
||||||
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "^2.49.1",
|
"@sveltejs/kit": "^2.49.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||||
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
|
"@tailwindcss/vite": "^4.1.17",
|
||||||
|
"@types/node": "^22",
|
||||||
|
"@types/w3c-web-serial": "^1.0.8",
|
||||||
|
"daisyui": "^5.5.14",
|
||||||
|
"eslint": "^9.39.1",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-svelte": "^3.13.1",
|
||||||
|
"globals": "^16.5.0",
|
||||||
|
"husky": "^9.1.7",
|
||||||
|
"lint-staged": "^16.2.7",
|
||||||
|
"prettier": "^3.7.4",
|
||||||
|
"prettier-plugin-svelte": "^3.4.0",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
|
"sass": "^1.97.0",
|
||||||
"svelte": "^5.45.6",
|
"svelte": "^5.45.6",
|
||||||
"svelte-check": "^4.3.4",
|
"svelte-check": "^4.3.4",
|
||||||
|
"tailwindcss": "^4.1.17",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
"typescript-eslint": "^8.48.1",
|
||||||
"vite": "^7.2.6"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
2811
pnpm-lock.yaml
generated
2811
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -1,2 +1,4 @@
|
|||||||
onlyBuiltDependencies:
|
onlyBuiltDependencies:
|
||||||
|
- "@parcel/watcher"
|
||||||
|
- "@tailwindcss/oxide"
|
||||||
- esbuild
|
- esbuild
|
||||||
|
|||||||
49
prettier.config.mjs
Normal file
49
prettier.config.mjs
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* @see https://prettier.io/docs/en/configuration.html
|
||||||
|
* @type {import('prettier').Config}
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
// 打印宽度
|
||||||
|
printWidth: 80,
|
||||||
|
// 缩进空格数
|
||||||
|
tabWidth: 2,
|
||||||
|
// 使用制表符而不是空格缩进
|
||||||
|
useTabs: false,
|
||||||
|
// 句尾添加分号
|
||||||
|
semi: true,
|
||||||
|
// 使用单引号
|
||||||
|
singleQuote: true,
|
||||||
|
// 尾随逗号
|
||||||
|
trailingComma: 'es5',
|
||||||
|
// 对象大括号内的空格
|
||||||
|
bracketSpacing: true,
|
||||||
|
// 箭头函数参数括号
|
||||||
|
arrowParens: 'always',
|
||||||
|
// 括号行位置
|
||||||
|
bracketSameLine: false,
|
||||||
|
// 换行符使用 lf
|
||||||
|
endOfLine: 'lf',
|
||||||
|
// HTML 空格敏感度
|
||||||
|
htmlWhitespaceSensitivity: 'css',
|
||||||
|
|
||||||
|
plugins: ['prettier-plugin-svelte', 'prettier-plugin-tailwindcss'],
|
||||||
|
tailwindStylesheet: './src/routes/layout.css',
|
||||||
|
overrides: [
|
||||||
|
{
|
||||||
|
files: '*.svelte',
|
||||||
|
options: {
|
||||||
|
parser: 'svelte',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 对 CSS 文件使用双引号
|
||||||
|
files: '*.css',
|
||||||
|
options: { singleQuote: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 对 SCSS 文件使用双引号
|
||||||
|
files: '*.scss',
|
||||||
|
options: { singleQuote: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
14
src/app.d.ts
vendored
14
src/app.d.ts
vendored
@ -1,13 +1,13 @@
|
|||||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
// for information about these interfaces
|
// for information about these interfaces
|
||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
// interface Error {}
|
// interface Error {}
|
||||||
// interface Locals {}
|
// interface Locals {}
|
||||||
// interface PageData {}
|
// interface PageData {}
|
||||||
// interface PageState {}
|
// interface PageState {}
|
||||||
// interface Platform {}
|
// interface Platform {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
16
src/app.html
16
src/app.html
@ -1,11 +1,11 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
<div style="display: contents">%sveltekit.body%</div>
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
44808
src/lib/assets/usb-device.json
Normal file
44808
src/lib/assets/usb-device.json
Normal file
File diff suppressed because it is too large
Load Diff
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',
|
||||||
|
};
|
||||||
43
src/lib/components/SettingPanel/DeviceSetting.svelte
Normal file
43
src/lib/components/SettingPanel/DeviceSetting.svelte
Normal 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>
|
||||||
@ -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>
|
||||||
189
src/lib/components/SettingPanel/components/SerialSetting.svelte
Normal file
189
src/lib/components/SettingPanel/components/SerialSetting.svelte
Normal 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>
|
||||||
52
src/lib/components/ui/button/Button.svelte
Normal file
52
src/lib/components/ui/button/Button.svelte
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<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';
|
||||||
|
|
||||||
|
type Props = WithoutChildren<BitsButton.RootProps> & {
|
||||||
|
color?: Color;
|
||||||
|
variant?: Variant;
|
||||||
|
size?: Size;
|
||||||
|
wide?: boolean;
|
||||||
|
block?: boolean;
|
||||||
|
square?: boolean;
|
||||||
|
circle?: boolean;
|
||||||
|
|
||||||
|
class?: string;
|
||||||
|
children?: Snippet;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
color,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
wide,
|
||||||
|
block,
|
||||||
|
square,
|
||||||
|
circle,
|
||||||
|
class: className = '',
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
const classes = $derived(
|
||||||
|
[
|
||||||
|
'btn',
|
||||||
|
color && COLOR_MAP[color],
|
||||||
|
variant && VARIANT_MAP[variant],
|
||||||
|
size && SIZE_MAP[size],
|
||||||
|
wide && 'btn-wide',
|
||||||
|
block && 'btn-block',
|
||||||
|
square && 'btn-square',
|
||||||
|
circle && 'btn-circle',
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BitsButton.Root class={classes.trim()} {...restProps}>
|
||||||
|
{@render children?.()}
|
||||||
|
</BitsButton.Root>
|
||||||
30
src/lib/components/ui/button/Button.variants.ts
Normal file
30
src/lib/components/ui/button/Button.variants.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
export const COLOR_MAP = {
|
||||||
|
neutral: 'btn-neutral',
|
||||||
|
primary: 'btn-primary',
|
||||||
|
secondary: 'btn-secondary',
|
||||||
|
accent: 'btn-accent',
|
||||||
|
info: 'btn-info',
|
||||||
|
success: 'btn-success',
|
||||||
|
warning: 'btn-warning',
|
||||||
|
error: 'btn-error',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const VARIANT_MAP = {
|
||||||
|
outline: 'btn-outline',
|
||||||
|
dash: 'btn-dash',
|
||||||
|
soft: 'btn-soft',
|
||||||
|
ghost: 'btn-ghost',
|
||||||
|
link: 'btn-link',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const SIZE_MAP = {
|
||||||
|
xs: 'btn-xs',
|
||||||
|
sm: 'btn-sm',
|
||||||
|
md: 'btn-md',
|
||||||
|
lg: 'btn-lg',
|
||||||
|
xl: 'btn-xl',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type Color = keyof typeof COLOR_MAP;
|
||||||
|
export type Variant = keyof typeof VARIANT_MAP;
|
||||||
|
export type Size = keyof typeof SIZE_MAP;
|
||||||
1
src/lib/components/ui/button/index.ts
Normal file
1
src/lib/components/ui/button/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { default as Button } from './Button.svelte';
|
||||||
15
src/lib/components/ui/select/Select.svelte
Normal file
15
src/lib/components/ui/select/Select.svelte
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Select as BitsSelect, type WithoutChildren } from 'bits-ui';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
type Props = WithoutChildren<BitsSelect.RootProps> & {
|
||||||
|
class?: string;
|
||||||
|
children?: Snippet;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { value = $bindable(), children, ...restProps }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BitsSelect.Root bind:value={value as never} {...restProps}>
|
||||||
|
{@render children?.()}
|
||||||
|
</BitsSelect.Root>
|
||||||
21
src/lib/components/ui/select/Select.variants.ts
Normal file
21
src/lib/components/ui/select/Select.variants.ts
Normal 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;
|
||||||
82
src/lib/components/ui/select/SelectContent.svelte
Normal file
82
src/lib/components/ui/select/SelectContent.svelte
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Select as BitsSelect, type WithoutChildren } from 'bits-ui';
|
||||||
|
import { cubicOut } from 'svelte/easing';
|
||||||
|
|
||||||
|
type Props = WithoutChildren<BitsSelect.ContentProps> & {
|
||||||
|
items: { value: string; label: string; disabled?: boolean }[];
|
||||||
|
class?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { items, class: className = '', ...restProps }: Props = $props();
|
||||||
|
|
||||||
|
function selectTransition(
|
||||||
|
node: HTMLElement,
|
||||||
|
params: {
|
||||||
|
delay?: number;
|
||||||
|
duration?: number;
|
||||||
|
easing?: (t: number) => number;
|
||||||
|
y?: number;
|
||||||
|
start?: number;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
delay = 0,
|
||||||
|
duration = 200,
|
||||||
|
easing = cubicOut,
|
||||||
|
y = -6,
|
||||||
|
start = 0.95,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
const existingTransform = getComputedStyle(node).transform.replace(
|
||||||
|
'none',
|
||||||
|
''
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
delay,
|
||||||
|
duration,
|
||||||
|
easing,
|
||||||
|
css: (t: number, u: number) => {
|
||||||
|
const translate = `translateY(${u * y}px)`;
|
||||||
|
const scale = `scale(${start + t * (1 - start)})`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
opacity: ${t};
|
||||||
|
transform: ${existingTransform} ${translate} ${scale};
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BitsSelect.Portal>
|
||||||
|
<BitsSelect.Content
|
||||||
|
forceMount
|
||||||
|
sideOffset={4}
|
||||||
|
class={`w-(--bits-select-anchor-width) min-w-(--bits-select-anchor-width) rounded-xl border-[1.5px] border-base-content/20 bg-base-100 select-none ${className}`}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{#snippet child({ wrapperProps, props, open })}
|
||||||
|
{#if open}
|
||||||
|
<div {...wrapperProps}>
|
||||||
|
<div {...props} transition:selectTransition>
|
||||||
|
<BitsSelect.ScrollUpButton>up</BitsSelect.ScrollUpButton>
|
||||||
|
<BitsSelect.Viewport class="p-1">
|
||||||
|
{#each items as { value, label, disabled } (value)}
|
||||||
|
<BitsSelect.Item
|
||||||
|
{value}
|
||||||
|
{label}
|
||||||
|
{disabled}
|
||||||
|
class="outlined-hidden flex h-10 w-full items-center rounded-lg p-2 text-sm capitalize select-none data-disabled:opacity-50 data-highlighted:bg-gray-200"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</BitsSelect.Item>
|
||||||
|
{/each}
|
||||||
|
</BitsSelect.Viewport>
|
||||||
|
<BitsSelect.ScrollDownButton>down</BitsSelect.ScrollDownButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</BitsSelect.Content>
|
||||||
|
</BitsSelect.Portal>
|
||||||
41
src/lib/components/ui/select/SelectTrigger.svelte
Normal file
41
src/lib/components/ui/select/SelectTrigger.svelte
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
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 {
|
||||||
|
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={classes.trim()} {...restProps}>
|
||||||
|
{@render children?.()}
|
||||||
|
</BitsSelect.Trigger>
|
||||||
3
src/lib/components/ui/select/index.ts
Normal file
3
src/lib/components/ui/select/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { default as Select } from './Select.svelte';
|
||||||
|
export { default as SelectTrigger } from './SelectTrigger.svelte';
|
||||||
|
export { default as SelectContent } from './SelectContent.svelte';
|
||||||
23
src/lib/components/ui/tabs/Tabs.svelte
Normal file
23
src/lib/components/ui/tabs/Tabs.svelte
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Tabs as BitsTabs } from 'bits-ui';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
// import type { SvelteComponent } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value?: string;
|
||||||
|
onValueChange?: (value: string) => void;
|
||||||
|
className?: string;
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(),
|
||||||
|
onValueChange,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BitsTabs.Root bind:value {onValueChange} class={`w-full ${className}`}>
|
||||||
|
{@render children?.()}
|
||||||
|
</BitsTabs.Root>
|
||||||
16
src/lib/components/ui/tabs/TabsContent.svelte
Normal file
16
src/lib/components/ui/tabs/TabsContent.svelte
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Tabs as BitsTabs } from 'bits-ui';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
className?: string;
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { value, className, children }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BitsTabs.Content {value} class={`flex-1 outline-none ${className}`}>
|
||||||
|
{@render children?.()}
|
||||||
|
</BitsTabs.Content>
|
||||||
17
src/lib/components/ui/tabs/TabsList.svelte
Normal file
17
src/lib/components/ui/tabs/TabsList.svelte
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Tabs as BitsTabs } from 'bits-ui';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
className?: string;
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { className, children }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BitsTabs.List
|
||||||
|
class={`tabs-box tabs flex gap-2 border-b border-gray-200 dark:border-gray-700 ${className}`}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</BitsTabs.List>
|
||||||
20
src/lib/components/ui/tabs/TabsTrigger.svelte
Normal file
20
src/lib/components/ui/tabs/TabsTrigger.svelte
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Tabs as BitsTabs } from 'bits-ui';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
className?: string;
|
||||||
|
children?: Snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { value, className, children }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<BitsTabs.Trigger
|
||||||
|
{value}
|
||||||
|
class={`tab transition-colors data-[state='active']:bg-white data-[state='active']:shadow data-[state='inactive']:hover:bg-gray-200
|
||||||
|
${className}`}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</BitsTabs.Trigger>
|
||||||
4
src/lib/components/ui/tabs/index.ts
Normal file
4
src/lib/components/ui/tabs/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export { default as Tabs } from './Tabs.svelte';
|
||||||
|
export { default as TabsList } from './TabsList.svelte';
|
||||||
|
export { default as TabsTrigger } from './TabsTrigger.svelte';
|
||||||
|
export { default as TabsContent } from './TabsContent.svelte';
|
||||||
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';
|
||||||
2
src/lib/serial/index.ts
Normal file
2
src/lib/serial/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './serial.service';
|
||||||
|
export * from './serial.types';
|
||||||
105
src/lib/serial/serial.providers.svelte
Normal file
105
src/lib/serial/serial.providers.svelte
Normal 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?.()}
|
||||||
108
src/lib/serial/serial.service.ts
Normal file
108
src/lib/serial/serial.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/lib/serial/serial.store.ts
Normal file
13
src/lib/serial/serial.store.ts
Normal 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>();
|
||||||
24
src/lib/serial/serial.types.ts
Normal file
24
src/lib/serial/serial.types.ts
Normal 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
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);
|
||||||
|
}
|
||||||
29
src/lib/stores/serial/serial.options.ts
Normal file
29
src/lib/stores/serial/serial.options.ts
Normal 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,
|
||||||
|
})
|
||||||
|
);
|
||||||
47
src/lib/stores/serial/serial.ts
Normal file
47
src/lib/stores/serial/serial.ts
Normal 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]);
|
||||||
|
}
|
||||||
60
src/lib/stores/serial/serial.ui.ts
Normal file
60
src/lib/stores/serial/serial.ui.ts
Normal 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;
|
||||||
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)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
6
src/lib/stores/utils/typeGuard.ts
Normal file
6
src/lib/stores/utils/typeGuard.ts
Normal 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]);
|
||||||
|
}
|
||||||
36
src/lib/stores/utils/uiBridge.ts
Normal file
36
src/lib/stores/utils/uiBridge.ts
Normal 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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
22
src/lib/stores/utils/useLocalStorage.ts
Normal file
22
src/lib/stores/utils/useLocalStorage.ts
Normal 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
32
src/lib/utils/device.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -1,11 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import './layout.css';
|
||||||
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
|
import SerialProvider from '$lib/serial/serial.providers.svelte';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||||
<link rel="icon" href={favicon} />
|
|
||||||
</svelte:head>
|
|
||||||
|
|
||||||
{@render children()}
|
<SerialProvider>
|
||||||
|
{@render children()}
|
||||||
|
</SerialProvider>
|
||||||
|
|||||||
1
src/routes/+layout.ts
Normal file
1
src/routes/+layout.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const ssr = false;
|
||||||
@ -1,2 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import DeviceSetting from '$lib/components/SettingPanel/DeviceSetting.svelte';
|
||||||
|
import { Toaster } from 'svelte-sonner';
|
||||||
|
</script>
|
||||||
|
|
||||||
<h1>Welcome to SvelteKit</h1>
|
<h1>Welcome to SvelteKit</h1>
|
||||||
<p>Visit <a href="https://svelte.dev/docs/kit">svelte.dev/docs/kit</a> to read the documentation</p>
|
<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 />
|
||||||
|
|||||||
60
src/routes/layout.css
Normal file
60
src/routes/layout.css
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@plugin "daisyui/theme" {
|
||||||
|
name: "lofi";
|
||||||
|
default: true;
|
||||||
|
prefersdark: false;
|
||||||
|
color-scheme: "light";
|
||||||
|
--color-base-100: oklch(100% 0 0);
|
||||||
|
--color-base-200: oklch(97% 0 0);
|
||||||
|
--color-base-300: oklch(94% 0 0);
|
||||||
|
--color-base-content: oklch(0% 0 0);
|
||||||
|
--color-primary: oklch(15.906% 0 0);
|
||||||
|
--color-primary-content: oklch(100% 0 0);
|
||||||
|
--color-secondary: oklch(21.455% 0.001 17.278);
|
||||||
|
--color-secondary-content: oklch(100% 0 0);
|
||||||
|
--color-accent: oklch(26.861% 0 0);
|
||||||
|
--color-accent-content: oklch(100% 0 0);
|
||||||
|
--color-neutral: oklch(0% 0 0);
|
||||||
|
--color-neutral-content: oklch(100% 0 0);
|
||||||
|
--color-info: oklch(79.54% 0.103 205.9);
|
||||||
|
--color-info-content: oklch(15.908% 0.02 205.9);
|
||||||
|
--color-success: oklch(90.13% 0.153 164.14);
|
||||||
|
--color-success-content: oklch(18.026% 0.03 164.14);
|
||||||
|
--color-warning: oklch(88.37% 0.135 79.94);
|
||||||
|
--color-warning-content: oklch(17.674% 0.027 79.94);
|
||||||
|
--color-error: oklch(78.66% 0.15 28.47);
|
||||||
|
--color-error-content: oklch(15.732% 0.03 28.47);
|
||||||
|
--radius-selector: 1rem;
|
||||||
|
--radius-field: 1rem;
|
||||||
|
--radius-box: 0.5rem;
|
||||||
|
--size-selector: 0.25rem;
|
||||||
|
--size-field: 0.25rem;
|
||||||
|
--border: 1.5px;
|
||||||
|
--depth: 1;
|
||||||
|
--noise: 0;
|
||||||
|
}
|
||||||
@ -1,18 +1,20 @@
|
|||||||
import adapter from '@sveltejs/adapter-auto';
|
import adapter from '@sveltejs/adapter-static';
|
||||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
// Consult https://svelte.dev/docs/kit/integrations
|
// Consult https://svelte.dev/docs/kit/integrations
|
||||||
// for more information about preprocessors
|
// for more information about preprocessors
|
||||||
preprocess: vitePreprocess(),
|
preprocess: vitePreprocess(),
|
||||||
|
|
||||||
kit: {
|
kit: {
|
||||||
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||||
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||||
adapter: adapter()
|
adapter: adapter({
|
||||||
}
|
fallback: '200.html',
|
||||||
|
}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import { defineConfig } from 'vite';
|
import { sveltekit } from "@sveltejs/kit/vite";
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({ plugins: [tailwindcss(), sveltekit()] });
|
||||||
plugins: [sveltekit()]
|
|
||||||
});
|
|
||||||
|
|||||||
Reference in New Issue
Block a user