yunsi-toolbox-vue/components/nvs-editor/NvsEditor.vue

650 lines
21 KiB
Vue

<script setup lang="ts">
import { ref, computed, reactive } from 'vue';
import {
type NvsPartition, type NvsEntry, type NvsEncoding, type NvsFlashStats,
NvsType, NvsVersion,
ENCODING_OPTIONS, ENCODING_TO_TYPE, TYPE_TO_ENCODING,
isPrimitiveType,
createEmptyPartition, addEntry, removeEntry, updateEntry,
duplicateEntry, mergePartitions, calculateFlashStats,
validatePartition, sortEntries, generateEntryId,
parseBinary, serializeBinary, parseCsv, serializeCsv,
MAX_KEY_LENGTH, PAGE_SIZE,
} from '../../lib/nvs';
const props = defineProps<{
isDark?: boolean;
}>();
// ── Core state ─────────────────────────────────────────────────────
const partition = ref<NvsPartition>(createEmptyPartition());
const targetSize = ref(0x4000); // 16KB default
// ── UI state ───────────────────────────────────────────────────────
const namespaceFilter = ref('');
const keySearch = ref('');
const mergeMode = ref<'overwrite' | 'skip'>('overwrite');
const statusMessage = ref('');
const statusType = ref<'success' | 'error' | 'info'>('info');
// Add entry dialog
const showAddDialog = ref(false);
const newEntry = reactive({
namespace: '',
key: '',
encoding: 'u8' as NvsEncoding,
value: '',
});
// Add namespace dialog
const showNsDialog = ref(false);
const newNamespace = ref('');
// ── Computed ───────────────────────────────────────────────────────
const flashStats = computed<NvsFlashStats>(() =>
calculateFlashStats(partition.value, targetSize.value),
);
const errors = computed(() => validatePartition(partition.value));
const filteredEntries = computed(() => {
let entries = partition.value.entries;
if (namespaceFilter.value) {
entries = entries.filter(e => e.namespace === namespaceFilter.value);
}
if (keySearch.value) {
entries = entries.filter(e => e.key.includes(keySearch.value));
}
return entries;
});
const progressColor = computed(() => {
const pct = flashStats.value.usagePercent;
if (pct >= 85) return '#F56C6C';
if (pct >= 60) return '#E6A23C';
return '#67C23A';
});
const sizeOptions = [
{ label: '12 KB (3页)', value: 0x3000 },
{ label: '16 KB (4页)', value: 0x4000 },
{ label: '20 KB (5页)', value: 0x5000 },
{ label: '24 KB (6页)', value: 0x6000 },
{ label: '32 KB (8页)', value: 0x8000 },
{ label: '64 KB (16页)', value: 0x10000 },
{ label: '128 KB (32页)', value: 0x20000 },
{ label: '256 KB (64页)', value: 0x40000 },
];
// ── Helpers ────────────────────────────────────────────────────────
function showStatus(msg: string, type: 'success' | 'error' | 'info' = 'info') {
statusMessage.value = msg;
statusType.value = type;
setTimeout(() => { statusMessage.value = ''; }, 4000);
}
function getEncodingForType(type: NvsType): NvsEncoding {
return TYPE_TO_ENCODING[type] ?? 'u8';
}
function formatValue(entry: NvsEntry): string {
if (entry.value instanceof Uint8Array) {
if (entry.value.length <= 32) {
return Array.from(entry.value).map(b => b.toString(16).padStart(2, '0')).join(' ');
}
return Array.from(entry.value.subarray(0, 32))
.map(b => b.toString(16).padStart(2, '0')).join(' ') +
` ... (${entry.value.length} 字节)`;
}
return String(entry.value);
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 10000);
}
/** Parse a value string based on encoding */
function parseValueInput(encoding: NvsEncoding, raw: string): number | bigint | string | Uint8Array {
switch (encoding) {
case 'u8': case 'u16': case 'u32':
case 'i8': case 'i16': case 'i32': {
const str = raw.trim();
if (str.startsWith('0x') || str.startsWith('0X')) {
if (!/^-?0[xX][0-9a-fA-F]+$/.test(str)) throw new Error(`无效的整数值: "${str}"`);
return parseInt(str, 16);
}
if (!/^-?\d+$/.test(str)) throw new Error(`无效的整数值: "${str}"`);
return parseInt(str, 10);
}
case 'u64': case 'i64': {
const str64 = raw.trim();
if (str64.startsWith('-0x') || str64.startsWith('-0X')) {
if (!/^-0[xX][0-9a-fA-F]+$/.test(str64)) throw new Error(`无效的整数值: "${str64}"`);
return -BigInt(str64.slice(1));
}
try { return BigInt(str64); } catch { throw new Error(`无效的整数值: "${str64}"`); }
}
case 'string':
return raw;
case 'blob':
case 'binary': {
const hex = raw.replace(/\s/g, '');
if (hex.length === 0) return new Uint8Array(0);
if (hex.length % 2 !== 0) throw new Error(`十六进制字符串长度必须为偶数`);
if (!/^[0-9a-fA-F]+$/.test(hex)) throw new Error(`十六进制字符串包含无效字符`);
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.substring(i * 2, i * 2 + 2), 16);
}
return bytes;
}
default:
return raw;
}
}
// ── Actions: CRUD ──────────────────────────────────────────────────
function handleAddEntry() {
if (!newEntry.namespace || !newEntry.key) return;
const type = ENCODING_TO_TYPE[newEntry.encoding];
let value: ReturnType<typeof parseValueInput>;
try {
value = parseValueInput(newEntry.encoding, newEntry.value);
} catch (e: any) {
showStatus(e.message ?? '值格式错误', 'error');
return;
}
partition.value = addEntry(partition.value, {
namespace: newEntry.namespace,
key: newEntry.key,
type,
value,
});
showAddDialog.value = false;
newEntry.key = '';
newEntry.value = '';
showStatus('已添加记录', 'success');
}
function handleAddNamespace() {
const ns = newNamespace.value.trim();
if (!ns) return;
if (partition.value.namespaces.includes(ns)) {
showStatus('命名空间已存在', 'error');
return;
}
partition.value = {
...partition.value,
namespaces: [...partition.value.namespaces, ns],
};
showNsDialog.value = false;
newNamespace.value = '';
// Auto-select new namespace in add dialog
newEntry.namespace = ns;
showStatus(`已添加命名空间 "${ns}"`, 'success');
}
function handleDeleteEntry(entryId: string) {
partition.value = removeEntry(partition.value, entryId);
}
function handleDuplicateEntry(entryId: string) {
partition.value = duplicateEntry(partition.value, entryId);
showStatus('已复制记录', 'success');
}
function handleSort() {
partition.value = sortEntries(partition.value);
}
function handleClear() {
partition.value = createEmptyPartition(partition.value.version);
showStatus('已清空所有记录', 'info');
}
// ── Actions: Inline edit ───────────────────────────────────────────
function handleUpdateKey(entryId: string, newKey: string) {
partition.value = updateEntry(partition.value, entryId, { key: newKey });
}
function handleUpdateNamespace(entryId: string, ns: string) {
partition.value = updateEntry(partition.value, entryId, { namespace: ns });
}
function handleUpdateEncoding(entryId: string, encoding: NvsEncoding) {
const type = ENCODING_TO_TYPE[encoding];
// Reset value to sensible default when type changes
let value: NvsEntry['value'];
if (isPrimitiveType(type)) value = 0;
else if (type === NvsType.SZ) value = '';
else value = new Uint8Array(0);
partition.value = updateEntry(partition.value, entryId, { type, value });
}
function handleUpdateValue(entryId: string, encoding: NvsEncoding, raw: string) {
let value: ReturnType<typeof parseValueInput>;
try {
value = parseValueInput(encoding, raw);
} catch (e: any) {
showStatus(e.message ?? '值格式错误', 'error');
return;
}
partition.value = updateEntry(partition.value, entryId, { value });
}
// ── Actions: File I/O ──────────────────────────────────────────────
async function handleOpenBinary(file: File): Promise<false> {
try {
const buffer = await file.arrayBuffer();
const data = new Uint8Array(buffer);
partition.value = parseBinary(data);
// Auto-set target size to match file
targetSize.value = data.byteLength;
showStatus(`已加载 ${file.name} (${data.byteLength} 字节)`, 'success');
} catch (e: any) {
showStatus(`加载失败: ${e.message}`, 'error');
}
return false; // prevent el-upload auto-upload
}
function handleExportBinary() {
try {
const errs = validatePartition(partition.value);
if (errs.length > 0) {
showStatus(`验证错误: ${errs[0]}`, 'error');
return;
}
const data = serializeBinary(partition.value, targetSize.value);
downloadBlob(new Blob([data]), 'nvs.bin');
showStatus('已导出 nvs.bin', 'success');
} catch (e: any) {
showStatus(`导出失败: ${e.message}`, 'error');
}
}
async function handleMergeBinary(file: File): Promise<false> {
try {
const buffer = await file.arrayBuffer();
const incoming = parseBinary(new Uint8Array(buffer));
partition.value = mergePartitions(partition.value, incoming, mergeMode.value);
showStatus(`已合并 ${file.name} (${incoming.entries.length} 条记录)`, 'success');
} catch (e: any) {
showStatus(`合并失败: ${e.message}`, 'error');
}
return false;
}
async function handleOpenCsv(file: File): Promise<false> {
try {
const text = await file.text();
partition.value = parseCsv(text);
showStatus(`已加载 ${file.name}`, 'success');
} catch (e: any) {
showStatus(`加载失败: ${e.message}`, 'error');
}
return false;
}
function handleExportCsv() {
try {
const text = serializeCsv(partition.value);
downloadBlob(new Blob([text], { type: 'text/csv;charset=utf-8' }), 'nvs.csv');
showStatus('已导出 nvs.csv', 'success');
} catch (e: any) {
showStatus(`导出失败: ${e.message}`, 'error');
}
}
async function handleMergeCsv(file: File): Promise<false> {
try {
const text = await file.text();
const incoming = parseCsv(text);
partition.value = mergePartitions(partition.value, incoming, mergeMode.value);
showStatus(`已合并 ${file.name} (${incoming.entries.length} 条记录)`, 'success');
} catch (e: any) {
showStatus(`合并失败: ${e.message}`, 'error');
}
return false;
}
async function handleBlobFileUpload(entryId: string, file: File): Promise<false> {
try {
const buffer = await file.arrayBuffer();
const data = new Uint8Array(buffer);
partition.value = updateEntry(partition.value, entryId, { value: data });
showStatus(`已上传 ${file.name} (${data.length} 字节)`, 'success');
} catch (e: any) {
showStatus(`上传失败: ${e.message}`, 'error');
}
return false;
}
</script>
<template>
<div>
<!-- Status message -->
<transition name="el-fade-in">
<el-alert
v-if="statusMessage"
:title="statusMessage"
:type="statusType"
show-icon
closable
class="mb-3"
@close="statusMessage = ''"
/>
</transition>
<!-- Validation errors -->
<el-alert
v-if="errors.length > 0"
type="warning"
show-icon
class="mb-3"
:closable="false"
>
<template #title>
验证问题 ({{ errors.length }})
</template>
<div v-for="(err, i) in errors" :key="i" class="text-xs">{{ err }}</div>
</el-alert>
<!-- ── Toolbar ── -->
<div class="flex flex-wrap items-center gap-2 mb-3">
<el-button type="primary" @click="showAddDialog = true">
添加记录
</el-button>
<el-button @click="showNsDialog = true">
添加命名空间
</el-button>
<el-button @click="handleSort">排序</el-button>
<el-button type="danger" plain @click="handleClear">清空</el-button>
<el-divider direction="vertical" />
<span class="text-sm">分区大小:</span>
<el-select v-model="targetSize" style="width: 160px;">
<el-option
v-for="opt in sizeOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
<el-divider direction="vertical" />
<div class="flex items-center gap-2 min-w-[200px]">
<el-progress
:percentage="flashStats.usagePercent"
:color="progressColor"
:stroke-width="14"
:show-text="false"
style="flex: 1;"
/>
<el-text size="small">
{{ flashStats.usedEntries }} / {{ flashStats.maxEntries }} 条目
</el-text>
</div>
</div>
<!-- ── Filter row ── -->
<div class="flex flex-wrap items-center gap-2 mb-3">
<el-select
v-model="namespaceFilter"
placeholder="全部命名空间"
clearable
style="width: 180px;"
>
<el-option
v-for="ns in partition.namespaces"
:key="ns"
:label="ns"
:value="ns"
/>
</el-select>
<el-input
v-model="keySearch"
placeholder="搜索键名..."
clearable
style="width: 200px;"
/>
</div>
<!-- ── Data table ── -->
<el-table
:data="filteredEntries"
border
stripe
size="small"
row-key="id"
empty-text="暂无记录,请添加或导入数据"
max-height="500"
>
<el-table-column label="命名空间" width="150">
<template #default="{ row }">
<el-select
:model-value="row.namespace"
size="small"
@change="(val: string) => handleUpdateNamespace(row.id, val)"
>
<el-option
v-for="ns in partition.namespaces"
:key="ns"
:label="ns"
:value="ns"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="键名" width="180">
<template #default="{ row }">
<el-input
:model-value="row.key"
size="small"
:maxlength="MAX_KEY_LENGTH"
@change="(val: string) => handleUpdateKey(row.id, val)"
/>
</template>
</el-table-column>
<el-table-column label="类型" width="120">
<template #default="{ row }">
<el-select
:model-value="getEncodingForType(row.type)"
size="small"
@change="(val: NvsEncoding) => handleUpdateEncoding(row.id, val)"
>
<el-option
v-for="enc in ENCODING_OPTIONS"
:key="enc"
:label="enc"
:value="enc"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="值" min-width="250">
<template #default="{ row }">
<!-- Primitive types: input -->
<el-input
v-if="isPrimitiveType(row.type)"
:model-value="String(row.value)"
size="small"
@change="(val: string) => handleUpdateValue(row.id, getEncodingForType(row.type), val)"
/>
<!-- String type -->
<el-input
v-else-if="row.type === NvsType.SZ"
:model-value="row.value as string"
size="small"
type="textarea"
:autosize="{ minRows: 1, maxRows: 3 }"
@change="(val: string) => handleUpdateValue(row.id, 'string', val)"
/>
<!-- Blob types -->
<div v-else class="flex items-center gap-1">
<el-text size="small" class="font-mono" truncated>
{{ formatValue(row) }}
</el-text>
<el-upload
:before-upload="(file: File) => handleBlobFileUpload(row.id, file)"
:show-file-list="false"
accept="*/*"
>
<el-button size="small" type="info" plain>上传文件</el-button>
</el-upload>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button
size="small"
text
@click="handleDuplicateEntry(row.id)"
title="复制"
>
复制
</el-button>
<el-popconfirm
title="确定删除?"
@confirm="handleDeleteEntry(row.id)"
>
<template #reference>
<el-button size="small" text type="danger" title="删除">
删除
</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- ── Import/Export section ── -->
<el-divider />
<div class="flex flex-wrap gap-4">
<!-- Binary -->
<div>
<el-text tag="b" class="block mb-2">二进制文件 (.bin)</el-text>
<div class="flex flex-wrap gap-2">
<el-upload :before-upload="handleOpenBinary" :show-file-list="false" accept=".bin">
<el-button>打开</el-button>
</el-upload>
<el-button type="primary" @click="handleExportBinary">导出</el-button>
<el-upload :before-upload="handleMergeBinary" :show-file-list="false" accept=".bin">
<el-button>合并</el-button>
</el-upload>
</div>
</div>
<!-- CSV -->
<div>
<el-text tag="b" class="block mb-2">CSV文件 (.csv)</el-text>
<div class="flex flex-wrap gap-2">
<el-upload :before-upload="handleOpenCsv" :show-file-list="false" accept=".csv">
<el-button>打开</el-button>
</el-upload>
<el-button type="primary" @click="handleExportCsv">导出</el-button>
<el-upload :before-upload="handleMergeCsv" :show-file-list="false" accept=".csv">
<el-button>合并</el-button>
</el-upload>
</div>
</div>
<!-- Merge options -->
<div>
<el-text tag="b" class="block mb-2">合并选项</el-text>
<el-radio-group v-model="mergeMode">
<el-radio value="overwrite">覆盖同名键</el-radio>
<el-radio value="skip">跳过同名键</el-radio>
</el-radio-group>
</div>
</div>
<!-- ── Add entry dialog ── -->
<el-dialog v-model="showAddDialog" title="添加记录" width="450px">
<el-form label-width="80px">
<el-form-item label="命名空间">
<el-select v-model="newEntry.namespace" placeholder="选择命名空间">
<el-option
v-for="ns in partition.namespaces"
:key="ns"
:label="ns"
:value="ns"
/>
</el-select>
</el-form-item>
<el-form-item label="键名">
<el-input v-model="newEntry.key" :maxlength="MAX_KEY_LENGTH" placeholder="key name" />
</el-form-item>
<el-form-item label="类型">
<el-select v-model="newEntry.encoding">
<el-option v-for="enc in ENCODING_OPTIONS" :key="enc" :label="enc" :value="enc" />
</el-select>
</el-form-item>
<el-form-item label="值">
<el-input
v-model="newEntry.value"
:type="newEntry.encoding === 'string' ? 'textarea' : 'text'"
placeholder="value"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddDialog = false">取消</el-button>
<el-button type="primary" @click="handleAddEntry" :disabled="!newEntry.namespace || !newEntry.key">
添加
</el-button>
</template>
</el-dialog>
<!-- ── Add namespace dialog ── -->
<el-dialog v-model="showNsDialog" title="添加命名空间" width="400px">
<el-form label-width="80px">
<el-form-item label="名称">
<el-input
v-model="newNamespace"
:maxlength="MAX_KEY_LENGTH"
placeholder="namespace name"
@keyup.enter="handleAddNamespace"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showNsDialog = false">取消</el-button>
<el-button type="primary" @click="handleAddNamespace" :disabled="!newNamespace.trim()">
添加
</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.font-mono {
font-family: 'Courier New', Courier, monospace;
}
</style>