fix(esp-flasher): enforce imageOptions.value uniqueness via prop validator
Replace comment-only contract with a Vue prop validator that catches duplicate .value keys at dev time. Validator is stripped from production builds. Add inline comment to demo/tsconfig.json documenting the 8 node_modules type errors (vueuse Bluetooth API, element-plus JSX/slots/icons) that require skipLibCheck: true until those packages are updated.
This commit is contained in:
parent
34eb123f5e
commit
577b845afc
|
|
@ -1,2 +1,4 @@
|
||||||
# AI
|
# AI
|
||||||
/.claude
|
/.claude
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
26
README.md
26
README.md
|
|
@ -1,26 +1,28 @@
|
||||||
# Yunsi Toolbox Vue
|
# Yunsi Toolbox Vue
|
||||||
|
|
||||||
A collection of web-based tools.
|
A collection of web-based tools with pure TypeScript parsing libraries and Vue 3 components.
|
||||||
|
|
||||||
## Components
|
## Structure
|
||||||
|
|
||||||
### [ESP Flasher](./esp-flasher/)
|
- `lib/` - Pure TypeScript libraries (no Vue dependency)
|
||||||
A browser-based firmware flasher for ESP32 microcontrollers using Web Serial.
|
- `shared/` - Binary read/write helpers, CRC32
|
||||||
|
- `nvs/` - NVS partition parser, serializer, CSV support
|
||||||
|
- `partition-table/` - ESP32 partition table parser/editor
|
||||||
|
- `app-image/` - ESP32 app image header reader
|
||||||
|
- `components/` - Vue 3 components (depend on lib/)
|
||||||
|
- `nvs-editor/` - NVS partition editor UI
|
||||||
|
- `esp-flasher/` - Browser-based ESP32 flasher (Web Serial)
|
||||||
|
- `partition-table-editor/` - Partition table editor UI
|
||||||
|
- `app-image-viewer/` - App image info viewer
|
||||||
|
|
||||||
## Installation
|
## Usage
|
||||||
|
|
||||||
This is intended to be used as a git submodule in Vue 3 projects.
|
This is intended to be used as a git submodule in Vue 3 projects.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git submodule add <repository-url> src/components/yunsi-toolbox
|
git submodule add <repository-url> src/components/yunsi-toolbox-vue
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
||||||
|
|
||||||
### Third Party Licenses
|
### Third Party Licenses
|
||||||
|
|
||||||
This project includes code from the following third-party projects:
|
|
||||||
|
|
||||||
- **esptools-js**: Licensed under the Apache License 2.0. Copyright (c) 2024 Espressif Systems (Shanghai) CO LTD.
|
- **esptools-js**: Licensed under the Apache License 2.0. Copyright (c) 2024 Espressif Systems (Shanghai) CO LTD.
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,6 @@
|
||||||
<script lang="ts">
|
|
||||||
|
|
||||||
interface Navigator {
|
|
||||||
serial: {
|
|
||||||
// Define the methods and properties you need from the Web Serial API
|
|
||||||
// For example:
|
|
||||||
requestPort: (options?: SerialPortRequestOptions) => Promise<SerialPort>;
|
|
||||||
getPorts: () => Promise<SerialPort[]>;
|
|
||||||
// Add other properties and methods as needed
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import 'xterm/css/xterm.css';
|
import 'xterm/css/xterm.css';
|
||||||
import {onBeforeMount, onMounted, reactive, ref, watch} from "vue";
|
import {computed, onBeforeMount, onMounted, reactive, ref, watch, type PropType} from "vue";
|
||||||
import {ESPLoader, type FlashOptions, type IEspLoaderTerminal, type LoaderOptions, Transport} from "./lib_esptools-js";
|
import {ESPLoader, type FlashOptions, type IEspLoaderTerminal, type LoaderOptions, Transport} from "./lib_esptools-js";
|
||||||
import CryptoJS from "crypto-js";
|
import CryptoJS from "crypto-js";
|
||||||
|
|
||||||
|
|
@ -22,19 +8,90 @@ const terminalContainer = ref();
|
||||||
let terminal: any;
|
let terminal: any;
|
||||||
let fitAddon: any;
|
let fitAddon: any;
|
||||||
|
|
||||||
const terminalConfig = {
|
const terminalDarkTheme = {
|
||||||
theme: {
|
background: '#4b4b4b',
|
||||||
background: '#4b4b4b', // dark gray background
|
foreground: '#c5c8c6',
|
||||||
foreground: '#c5c8c6', // light gray text
|
cursor: '#f0c674',
|
||||||
cursor: '#f0c674', // yellow cursor
|
black: '#1d1f21',
|
||||||
// You can also set specific ANSI colors if needed
|
red: '#cc6666',
|
||||||
black: '#1d1f21',
|
|
||||||
red: '#cc6666',
|
|
||||||
convertEol: true,
|
|
||||||
// ...and so on for other colors
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const terminalLightTheme = {
|
||||||
|
background: '#f5f5f5',
|
||||||
|
foreground: '#333333',
|
||||||
|
cursor: '#555555',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Props ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** `value` is the unique selection key — enforced by the prop validator. */
|
||||||
|
type ImageOption = {
|
||||||
|
value: string;
|
||||||
|
link: string;
|
||||||
|
target: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
imageOptions: {
|
||||||
|
type: Array as PropType<ImageOption[]>,
|
||||||
|
required: true as const,
|
||||||
|
validator(opts: ImageOption[]) {
|
||||||
|
const values = opts.map(o => o.value);
|
||||||
|
const hasDupes = values.length !== new Set(values).size;
|
||||||
|
if (hasDupes) {
|
||||||
|
console.warn(
|
||||||
|
'[EspFlasher] imageOptions contains duplicate .value keys — ' +
|
||||||
|
'each option must have a unique value. Selection behaviour is undefined.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return !hasDupes;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isDark: Boolean,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── State ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const chip = ref("");
|
||||||
|
const chip_type = ref("");
|
||||||
|
const programBaud = ref("115200");
|
||||||
|
const programBaudOption = [
|
||||||
|
{text: '115200', value: '115200'},
|
||||||
|
{text: '230400', value: '230400'},
|
||||||
|
{text: '460800', value: '460800'},
|
||||||
|
{text: '921600', value: '921600'},
|
||||||
|
]
|
||||||
|
|
||||||
|
const connectedBaud = ref("")
|
||||||
|
const programConnected = ref(false)
|
||||||
|
const serialSupported = ref(false);
|
||||||
|
|
||||||
|
// selectedValue: stable string key that survives array replacements.
|
||||||
|
// imageSelect: writable computed — getter always returns the live object from
|
||||||
|
// the current props.imageOptions; no deep watcher or manual reconciliation needed.
|
||||||
|
// el-select matches options by valueKey="value" (string), not by reference,
|
||||||
|
// so a fresh object reference from the getter is fine.
|
||||||
|
const selectedValue = ref<string | null>(props.imageOptions[0]?.value ?? null);
|
||||||
|
|
||||||
|
const imageSelect = computed<ImageOption | null>({
|
||||||
|
get() {
|
||||||
|
const opts = props.imageOptions;
|
||||||
|
if (opts.length === 0) return null;
|
||||||
|
return opts.find(o => o.value === selectedValue.value) ?? opts[0];
|
||||||
|
},
|
||||||
|
set(opt: ImageOption | null) {
|
||||||
|
selectedValue.value = opt?.value ?? null;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.isDark, (val) => {
|
||||||
|
if (terminal) {
|
||||||
|
terminal.options.theme = val ? terminalDarkTheme : terminalLightTheme;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Lifecycle ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
const notSupportedMsg = "您的浏览器不支持虚拟串口,请使用电脑版Chrome或者Edge。"
|
const notSupportedMsg = "您的浏览器不支持虚拟串口,请使用电脑版Chrome或者Edge。"
|
||||||
|
|
||||||
onBeforeMount(() => {
|
onBeforeMount(() => {
|
||||||
|
|
@ -52,7 +109,7 @@ onMounted(async () => {
|
||||||
const { Terminal } = await import('xterm');
|
const { Terminal } = await import('xterm');
|
||||||
const { FitAddon } = await import('xterm-addon-fit');
|
const { FitAddon } = await import('xterm-addon-fit');
|
||||||
fitAddon = new FitAddon();
|
fitAddon = new FitAddon();
|
||||||
terminal = new Terminal(terminalConfig);
|
terminal = new Terminal({ theme: props.isDark ? terminalDarkTheme : terminalLightTheme });
|
||||||
terminal.loadAddon(fitAddon);
|
terminal.loadAddon(fitAddon);
|
||||||
|
|
||||||
// Initialize the terminal
|
// Initialize the terminal
|
||||||
|
|
@ -71,37 +128,6 @@ onMounted(async () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const chip = ref("");
|
|
||||||
const chip_type = ref("");
|
|
||||||
const programBaud = ref("115200");
|
|
||||||
const programBaudOption = [
|
|
||||||
{text: '115200', value: '115200'},
|
|
||||||
{text: '230400', value: '230400'},
|
|
||||||
{text: '460800', value: '460800'},
|
|
||||||
{text: '921600', value: '921600'},
|
|
||||||
]
|
|
||||||
|
|
||||||
const connectedBaud = ref("")
|
|
||||||
const programConnected = ref(false)
|
|
||||||
const serialSupported = ref(false);
|
|
||||||
|
|
||||||
type ImageOption = {
|
|
||||||
value: string;
|
|
||||||
link: string;
|
|
||||||
target: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
imageOptions: ImageOption[];
|
|
||||||
isDark?: boolean;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
watch(() => props.isDark, (value) => {
|
|
||||||
// Handle dark mode change if needed for xterm
|
|
||||||
});
|
|
||||||
|
|
||||||
const imageSelect = ref(props.imageOptions[0]);
|
|
||||||
|
|
||||||
let transport: Transport | null;
|
let transport: Transport | null;
|
||||||
|
|
||||||
let esploader: ESPLoader;
|
let esploader: ESPLoader;
|
||||||
|
|
@ -216,6 +242,7 @@ const binaryLoadStatus = reactive({
|
||||||
});
|
});
|
||||||
|
|
||||||
function updateProgress(loaded: number, total: number) {
|
function updateProgress(loaded: number, total: number) {
|
||||||
|
if (!Number.isFinite(total) || total <= 0) return;
|
||||||
binaryLoadStatus.progress = Math.round((loaded / total) * 100);
|
binaryLoadStatus.progress = Math.round((loaded / total) * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -233,14 +260,13 @@ async function loadBinaryFile(imageLink: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentLength = response.headers.get('content-length');
|
const contentLength = response.headers.get('content-length');
|
||||||
if (!contentLength) {
|
const total = contentLength ? parseInt(contentLength, 10) : NaN;
|
||||||
throw new Error('Content-Length header is missing');
|
|
||||||
}
|
|
||||||
|
|
||||||
const total = parseInt(contentLength, 10);
|
|
||||||
let loaded = 0;
|
let loaded = 0;
|
||||||
|
|
||||||
// Stream response body
|
// Stream response body
|
||||||
|
if (!response.body) {
|
||||||
|
throw new Error('Response body is null');
|
||||||
|
}
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
let chunks = []; // to store chunks of data
|
let chunks = []; // to store chunks of data
|
||||||
let receivedLength = 0; // received that many bytes at the moment
|
let receivedLength = 0; // received that many bytes at the moment
|
||||||
|
|
@ -279,6 +305,10 @@ async function loadBinaryFile(imageLink: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function programFlash() {
|
async function programFlash() {
|
||||||
|
if (!imageSelect.value) {
|
||||||
|
alert('请先选择固件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const fileArray: IBinImage[] = [];
|
const fileArray: IBinImage[] = [];
|
||||||
|
|
||||||
if (chip_type.value != imageSelect.value.target) {
|
if (chip_type.value != imageSelect.value.target) {
|
||||||
|
|
@ -368,22 +398,10 @@ async function handleFileChange(e: Event) {
|
||||||
const target = e.target as HTMLInputElement
|
const target = e.target as HTMLInputElement
|
||||||
const files = target.files
|
const files = target.files
|
||||||
const fileArray: IBinImage[] = [];
|
const fileArray: IBinImage[] = [];
|
||||||
|
|
||||||
const blob = await loadBinaryFile(imageSelect.value.link);
|
|
||||||
if (blob) {
|
|
||||||
|
|
||||||
let data = arrayBufferToBinaryString(await blob.arrayBuffer());
|
|
||||||
console.log(blob.size, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
const file = files[0];
|
const file = files[0];
|
||||||
|
const data: string = arrayBufferToBinaryString(await file.arrayBuffer());
|
||||||
let data: string = arrayBufferToBinaryString(await file.arrayBuffer());
|
fileArray.push({ data, address: 0x0 });
|
||||||
fileArray.push({
|
|
||||||
data: data,
|
|
||||||
address: 0x0,
|
|
||||||
})
|
|
||||||
console.log(file, data);
|
console.log(file, data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -467,6 +485,9 @@ async function reset() {
|
||||||
</el-alert>
|
</el-alert>
|
||||||
<el-tabs>
|
<el-tabs>
|
||||||
<el-tab-pane label="烧录" :disabled="consoleStarted">
|
<el-tab-pane label="烧录" :disabled="consoleStarted">
|
||||||
|
<el-alert v-if="imageOptions.length === 0" type="warning" class="mb-4" show-icon :closable="false">
|
||||||
|
未配置固件选项,无法烧录。
|
||||||
|
</el-alert>
|
||||||
<el-alert type="info" class="mb-4" show-icon>
|
<el-alert type="info" class="mb-4" show-icon>
|
||||||
若无法连接,请先让ESP32进入下载模式,再尝试连接(按住BOOT,按一下RESET,松开BOOT)
|
若无法连接,请先让ESP32进入下载模式,再尝试连接(按住BOOT,按一下RESET,松开BOOT)
|
||||||
</el-alert>
|
</el-alert>
|
||||||
|
|
@ -506,7 +527,7 @@ async function reset() {
|
||||||
<el-button v-show="programConnected" @click="programFlash" type="primary">烧录</el-button>
|
<el-button v-show="programConnected" @click="programFlash" type="primary">烧录</el-button>
|
||||||
<el-button v-show="programConnected" @click="programErase" type="danger">全片擦除</el-button>
|
<el-button v-show="programConnected" @click="programErase" type="danger">全片擦除</el-button>
|
||||||
<el-button v-show="programConnected" @click="programDisconnect" type="info">断开连接</el-button>
|
<el-button v-show="programConnected" @click="programDisconnect" type="info">断开连接</el-button>
|
||||||
<el-link :href="imageSelect.link" :underline="false" class="ml-2">
|
<el-link v-if="imageSelect" :href="imageSelect.link" :underline="false" class="ml-2">
|
||||||
<el-button type="primary">保存固件到本地</el-button>
|
<el-button type="primary">保存固件到本地</el-button>
|
||||||
</el-link>
|
</el-link>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
// Module shims for vendored/untyped packages used by esp-flasher
|
||||||
|
declare module 'crypto-js'
|
||||||
|
|
||||||
|
// Global ambient Web Serial API types consumed by lib_esptools-js/webserial.d.ts and EspFlasher.vue.
|
||||||
|
// This file must have no imports/exports so declarations are ambient (global scope).
|
||||||
|
// This file has no imports/exports so all declarations are ambient (global).
|
||||||
|
// navigator.serial is not yet part of TypeScript's lib.dom.d.ts.
|
||||||
|
|
||||||
|
type ParityType = 'none' | 'even' | 'odd'
|
||||||
|
type FlowControlType = 'none' | 'hardware'
|
||||||
|
|
||||||
|
interface SerialPortInfo {
|
||||||
|
usbVendorId?: number
|
||||||
|
usbProductId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SerialPortFilter {
|
||||||
|
usbVendorId?: number
|
||||||
|
usbProductId?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SerialPortRequestOptions {
|
||||||
|
filters?: SerialPortFilter[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SerialPort extends EventTarget {
|
||||||
|
open(options: { baudRate: number; [k: string]: unknown }): Promise<void>
|
||||||
|
close(): Promise<void>
|
||||||
|
readonly readable: ReadableStream<Uint8Array> | null
|
||||||
|
readonly writable: WritableStream<Uint8Array> | null
|
||||||
|
getInfo(): SerialPortInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Navigator {
|
||||||
|
readonly serial: {
|
||||||
|
requestPort(options?: SerialPortRequestOptions): Promise<SerialPort>
|
||||||
|
getPorts(): Promise<SerialPort[]>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -273,7 +273,7 @@ function handleExportBinary() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = serializeBinary(partition.value, targetSize.value);
|
const data = serializeBinary(partition.value, targetSize.value);
|
||||||
downloadBlob(new Blob([data]), 'nvs.bin');
|
downloadBlob(new Blob([data as Uint8Array<ArrayBuffer>]), 'nvs.bin');
|
||||||
showStatus('已导出 nvs.bin', 'success');
|
showStatus('已导出 nvs.bin', 'success');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
showStatus(`导出失败: ${e.message}`, 'error');
|
showStatus(`导出失败: ${e.message}`, 'error');
|
||||||
|
|
|
||||||
|
|
@ -225,7 +225,7 @@ function handleExportBinary() {
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = serializeBinary(table.value);
|
const data = serializeBinary(table.value);
|
||||||
downloadBlob(new Blob([data]), 'partitions.bin');
|
downloadBlob(new Blob([data as Uint8Array<ArrayBuffer>]), 'partitions.bin');
|
||||||
showStatus('已导出 partitions.bin', 'success');
|
showStatus('已导出 partitions.bin', 'success');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
showStatus(`导出失败: ${e.message}`, 'error');
|
showStatus(`导出失败: ${e.message}`, 'error');
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Yunsi Toolbox Demo</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"name": "yunsi-toolbox-vue-demo",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"element-plus": "^2.6.3",
|
||||||
|
"vue": "^3.4.0",
|
||||||
|
"vue-router": "^4.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.0",
|
||||||
|
"autoprefixer": "^10.4.19",
|
||||||
|
"postcss": "^8.4.38",
|
||||||
|
"tailwindcss": "^3.4.3",
|
||||||
|
"typescript": "^5.4.0",
|
||||||
|
"vite": "^5.0.0",
|
||||||
|
"vue-tsc": "^2.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, provide, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const isDark = ref(false)
|
||||||
|
provide('isDark', isDark)
|
||||||
|
|
||||||
|
watch(isDark, (val) => {
|
||||||
|
document.documentElement.classList.toggle('dark', val)
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
const isHome = computed(() => route.path === '/')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="{ dark: isDark }" class="app-shell">
|
||||||
|
<header class="app-header">
|
||||||
|
<div class="app-header-inner">
|
||||||
|
<div class="app-brand" @click="router.push('/')">
|
||||||
|
<span class="app-brand-name">Yunsi Toolbox</span>
|
||||||
|
<span class="app-brand-sub">ESP32 Developer Tools</span>
|
||||||
|
</div>
|
||||||
|
<div class="app-header-actions">
|
||||||
|
<el-button v-if="!isHome" text size="small" @click="router.push('/')">← Home</el-button>
|
||||||
|
<span class="dark-label">Dark</span>
|
||||||
|
<el-switch v-model="isDark" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="app-main">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||||
|
import router from './router'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './style.css'
|
||||||
|
|
||||||
|
createApp(App).use(ElementPlus).use(router).mount('#app')
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ToolMeta } from '../tools'
|
||||||
|
export const toolMeta: ToolMeta = {
|
||||||
|
path: '/app-image',
|
||||||
|
name: 'App Image Viewer',
|
||||||
|
desc: 'Inspect ESP32 app image headers, segments and app description',
|
||||||
|
icon: 'IMG',
|
||||||
|
color: '#E6A23C',
|
||||||
|
order: 3,
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { inject, ref, type Ref } from 'vue'
|
||||||
|
import AppImageViewer from '@yunsi/components/app-image-viewer/AppImageViewer.vue'
|
||||||
|
|
||||||
|
const isDark = inject<Ref<boolean>>('isDark', ref(false))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AppImageViewer :is-dark="isDark" />
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ToolMeta } from '../tools'
|
||||||
|
export const toolMeta: ToolMeta = {
|
||||||
|
path: '/esp-flasher',
|
||||||
|
name: 'ESP Flasher',
|
||||||
|
desc: 'Flash firmware over USB via Web Serial — Chrome/Edge desktop',
|
||||||
|
icon: '⚡',
|
||||||
|
color: '#F56C6C',
|
||||||
|
order: 4,
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { inject, ref, type Ref } from 'vue'
|
||||||
|
import EspFlasher from '@yunsi/components/esp-flasher/EspFlasher.vue'
|
||||||
|
|
||||||
|
const isDark = inject<Ref<boolean>>('isDark', ref(false))
|
||||||
|
const imageOptions: { value: string; link: string; target: string }[] = []
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<EspFlasher :image-options="imageOptions" :is-dark="isDark" />
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { tools } from '../tools'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="home-page">
|
||||||
|
<div class="home-intro">
|
||||||
|
<h1 class="home-title">ESP32 Developer Tools</h1>
|
||||||
|
<p class="home-subtitle">Select a tool to get started</p>
|
||||||
|
</div>
|
||||||
|
<div class="tools-grid">
|
||||||
|
<div
|
||||||
|
v-for="tool in tools"
|
||||||
|
:key="tool.meta.path"
|
||||||
|
class="tool-card"
|
||||||
|
@click="router.push(tool.meta.path)"
|
||||||
|
>
|
||||||
|
<div class="tool-icon" :style="{ background: tool.meta.color }">
|
||||||
|
{{ tool.meta.icon }}
|
||||||
|
</div>
|
||||||
|
<div class="tool-info">
|
||||||
|
<h3>{{ tool.meta.name }}</h3>
|
||||||
|
<p>{{ tool.meta.desc }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ToolMeta } from '../tools'
|
||||||
|
export const toolMeta: ToolMeta = {
|
||||||
|
path: '/nvs',
|
||||||
|
name: 'NVS Editor',
|
||||||
|
desc: 'Create and edit ESP-IDF NVS binary partitions and CSV files',
|
||||||
|
icon: 'NVS',
|
||||||
|
color: '#409EFF',
|
||||||
|
order: 1,
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { inject, ref, type Ref } from 'vue'
|
||||||
|
import NvsEditor from '@yunsi/components/nvs-editor/NvsEditor.vue'
|
||||||
|
|
||||||
|
const isDark = inject<Ref<boolean>>('isDark', ref(false))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NvsEditor :is-dark="isDark" />
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { ToolMeta } from '../tools'
|
||||||
|
export const toolMeta: ToolMeta = {
|
||||||
|
path: '/partition-table',
|
||||||
|
name: 'Partition Table',
|
||||||
|
desc: 'Edit ESP32 flash partition tables — import/export binary and CSV',
|
||||||
|
icon: 'PT',
|
||||||
|
color: '#67C23A',
|
||||||
|
order: 2,
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { inject, ref, type Ref } from 'vue'
|
||||||
|
import PartitionTableEditor from '@yunsi/components/partition-table-editor/PartitionTableEditor.vue'
|
||||||
|
|
||||||
|
const isDark = inject<Ref<boolean>>('isDark', ref(false))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PartitionTableEditor :is-dark="isDark" />
|
||||||
|
</template>
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
|
import HomePage from './pages/HomePage.vue'
|
||||||
|
import { tools } from './tools'
|
||||||
|
|
||||||
|
export default createRouter({
|
||||||
|
history: createWebHashHistory(),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', component: HomePage },
|
||||||
|
...tools.map(t => ({ path: t.meta.path, component: t.component })),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* ── App shell ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--el-bg-color-page);
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border-bottom: 1px solid var(--el-border-color-light);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header-inner {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 14px 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-brand-name {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-brand-sub {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-label {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 32px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Home page ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.home-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-intro {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-title {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-subtitle {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tool cards ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.tools-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.tools-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-card {
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
border: 1px solid var(--el-border-color-light);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 28px 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-icon {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-info h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-info p {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import type { Component } from 'vue'
|
||||||
|
|
||||||
|
export interface ToolMeta {
|
||||||
|
path: string
|
||||||
|
name: string
|
||||||
|
desc: string
|
||||||
|
icon: string
|
||||||
|
color: string
|
||||||
|
order: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageModule {
|
||||||
|
default: Component
|
||||||
|
toolMeta?: ToolMeta
|
||||||
|
}
|
||||||
|
|
||||||
|
const modules = import.meta.glob<PageModule>('./pages/*.vue', { eager: true })
|
||||||
|
|
||||||
|
export interface Tool {
|
||||||
|
meta: ToolMeta
|
||||||
|
component: Component
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tools: Tool[] = Object.values(modules)
|
||||||
|
.filter((m): m is PageModule & { toolMeta: ToolMeta } => m.toolMeta !== undefined)
|
||||||
|
.map(m => ({ meta: m.toolMeta, component: m.default }))
|
||||||
|
.sort((a, b) => a.meta.order - b.meta.order)
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
'./src/**/*.{vue,ts,html}',
|
||||||
|
'../components/**/*.vue',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true, // required: @vueuse/core (Bluetooth API types) and element-plus (JSX namespace, __VLS_Slots, icons-vue) have .d.ts incompatible with TS 5.4+. All 8 suppressed errors are inside node_modules; project source is fully clean. Remove only after upgrading those packages.
|
||||||
|
"jsx": "preserve",
|
||||||
|
"lib": ["ESNext", "DOM"],
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@yunsi/*": ["../*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*", "../lib/**/*", "../components/**/*"]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@yunsi': resolve(__dirname, '..'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue