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

1283 lines
50 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted, reactive } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { Upload, View, CopyDocument, Delete, Plus, Search, ArrowDown, FolderOpened, Download, Document, MoreFilled } from '@element-plus/icons-vue';
import BlobEditorDialog from './BlobEditorDialog.vue';
import {
type NvsPartition, type NvsEntry, type NvsEncoding, type NvsFlashStats, type ValidationError,
NvsType, NvsVersion,
ENCODING_TO_TYPE, TYPE_TO_ENCODING,
isPrimitiveType,
createEmptyPartition, addEntry, removeEntry, updateEntry,
duplicateEntry, mergePartitions, calculateFlashStats,
validatePartition, generateEntryId, normalizePartition, reconcileBlobTypes,
checkBlobCompatibility, parseHexString,
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);
// ── UI state ───────────────────────────────────────────────────────
const namespaceFilter = ref('');
const searchQuery = ref('');
const mergeMode = ref<'overwrite' | 'skip'>('overwrite');
const showHex = ref(false);
const isSmallScreen = ref(false);
const mobileTab = ref<'add' | 'search'>('add');
// Inline new row state
const newRow = ref({ namespace: '', key: '', encoding: 'u8' as NvsEncoding, value: '' });
// Value viewer dialog
const showValueDialog = ref(false);
const valueDialogEntry = ref<NvsEntry | null>(null);
// Column sort state
const sortProp = ref<'namespace' | 'key' | 'value' | null>(null);
const sortOrder = ref<'ascending' | 'descending' | null>(null);
// File input refs
const openInput = ref<HTMLInputElement>();
const mergeInput = ref<HTMLInputElement>();
const newRowBlobInput = ref<HTMLInputElement>();
const newRowStringInput = ref<HTMLInputElement>();
// Blob editor dialog state
const blobEditorVisible = ref(false);
const blobEditorData = ref(new Uint8Array(0));
const blobEditorEntryId = ref(''); // '' = new-row mode
const blobEditorKey = ref('');
/** Transient editing buffers for inline table editing.
* Key: "entryId:field" → raw string value. */
const editingCells = reactive(new Map<string, string>());
// ── Persistence ────────────────────────────────────────────────────
const STORAGE_KEY = 'nvs-editor-v1';
const STORAGE_SIZE_KEY = 'nvs-editor-size-v1';
function partitionToJson(p: NvsPartition): string {
return JSON.stringify(p, (_, v) => {
if (v instanceof Uint8Array) return { __t: 'u8a', d: Array.from(v) };
if (typeof v === 'bigint') return { __t: 'bi', d: v.toString() };
return v;
});
}
function partitionFromJson(s: string): NvsPartition {
return JSON.parse(s, (_, v) => {
if (v && typeof v === 'object' && v.__t === 'u8a') return new Uint8Array(v.d);
if (v && typeof v === 'object' && v.__t === 'bi') return BigInt(v.d);
return v;
});
}
let _persistTimer: ReturnType<typeof setTimeout> | null = null;
watch(partition, (val) => {
if (_persistTimer !== null) clearTimeout(_persistTimer);
_persistTimer = setTimeout(() => {
try { localStorage.setItem(STORAGE_KEY, partitionToJson(val)); } catch {}
_persistTimer = null;
}, 500);
}, { deep: true });
watch(targetSize, (val) => {
try { localStorage.setItem(STORAGE_SIZE_KEY, String(val)); } catch {}
});
function _onResize() { isSmallScreen.value = window.innerWidth < 640; }
onMounted(() => {
isSmallScreen.value = window.innerWidth < 640;
window.addEventListener('resize', _onResize);
try {
const s = localStorage.getItem(STORAGE_KEY);
if (s) partition.value = normalizePartition(partitionFromJson(s)).partition;
} catch {}
try {
const sz = localStorage.getItem(STORAGE_SIZE_KEY);
if (sz) {
const parsed = parseInt(sz, 10);
if (!Number.isNaN(parsed) && parsed > 0) targetSize.value = parsed;
}
} catch {}
});
onUnmounted(() => { window.removeEventListener('resize', _onResize); });
// ── Computed ───────────────────────────────────────────────────────
const flashStats = computed<NvsFlashStats>(() =>
calculateFlashStats(partition.value, targetSize.value),
);
const errors = computed(() => validatePartition(partition.value));
/** Map entry ID → validation errors for that entry (for red highlighting) */
const entryErrors = computed(() => {
const map = new Map<string, string[]>();
for (const err of errors.value) {
if (err.entryId) {
if (!map.has(err.entryId)) map.set(err.entryId, []);
map.get(err.entryId)!.push(err.message);
}
}
return map;
});
const _blobHexCache = new WeakMap<Uint8Array, string>();
/** Stringify entry value for search/sort comparison */
function valueToString(entry: NvsEntry): string {
if (entry.value instanceof Uint8Array) {
let hex = _blobHexCache.get(entry.value);
if (!hex) { hex = Array.from(entry.value).map(b => b.toString(16).padStart(2, '0')).join(' '); _blobHexCache.set(entry.value, hex); }
return hex;
}
return String(entry.value);
}
const filteredEntries = computed(() => {
let entries = partition.value.entries;
if (namespaceFilter.value) entries = entries.filter(e => e.namespace === namespaceFilter.value);
if (searchQuery.value) {
const q = searchQuery.value.toLowerCase();
entries = entries.filter(e =>
e.key.toLowerCase().includes(q) || valueToString(e).toLowerCase().includes(q),
);
}
if (sortProp.value && sortOrder.value) {
const col = sortProp.value;
const dir = sortOrder.value === 'ascending' ? 1 : -1;
if (col === 'value') {
entries = [...entries].sort((a, b) => {
const av = a.value, bv = b.value;
if (typeof av === 'number' && typeof bv === 'number') return dir * (av - bv);
if (typeof av === 'bigint' && typeof bv === 'bigint') return dir * (av < bv ? -1 : av > bv ? 1 : 0);
// Mixed integer types: coerce to BigInt for precision-safe comparison
if ((typeof av === 'number' || typeof av === 'bigint') &&
(typeof bv === 'number' || typeof bv === 'bigint')) {
const an = typeof av === 'bigint' ? av : BigInt(av);
const bn = typeof bv === 'bigint' ? bv : BigInt(bv);
return dir * (an < bn ? -1 : an > bn ? 1 : 0);
}
// For blob sort: use cheap 8-byte preview, not full blobToHex
const as_ = av instanceof Uint8Array ? formatValue(a) : String(av);
const bs_ = bv instanceof Uint8Array ? formatValue(b) : String(bv);
return dir * as_.localeCompare(bs_);
});
} else {
entries = [...entries].sort((a, b) => dir * a[col].localeCompare(b[col]));
}
}
return entries;
});
const progressColor = computed(() => {
const pct = flashStats.value.usagePercent;
if (pct >= 85) return '#F56C6C';
if (pct >= 60) return '#E6A23C';
return '#67C23A';
});
const targetSizePages = computed({
get: () => Math.floor(targetSize.value / 4096),
set: (pages: number) => {
targetSize.value = Math.max(3, pages) * 4096;
},
});
const sizeInPages = ref(true);
const targetSizeKB = computed({
get: () => targetSizePages.value * 4,
set: (kb: number) => { targetSizePages.value = Math.max(3, Math.round(kb / 4)); },
});
const encodingOptions = computed<NvsEncoding[]>(() => {
const base: NvsEncoding[] = ['u8', 'i8', 'u16', 'i16', 'u32', 'i32', 'u64', 'i64', 'string'];
return partition.value.version === NvsVersion.V2 ? [...base, 'binary'] : [...base, 'blob'];
});
// ── Helpers ────────────────────────────────────────────────────────
function handleVersionChange(version: NvsVersion) {
const reconciledEntries = reconcileBlobTypes(partition.value.entries, version);
partition.value = { ...partition.value, version, entries: reconciledEntries };
if (newRow.value.encoding === 'blob' || newRow.value.encoding === 'binary')
newRow.value.encoding = version === NvsVersion.V2 ? 'binary' : 'blob';
const blobWarnings = checkBlobCompatibility(reconciledEntries, version);
if (blobWarnings.length > 0) {
showStatus(`${blobWarnings.length} 个 blob 超出 ${version === NvsVersion.V1 ? 'V1' : 'V2'} 大小限制,请查看验证面板`, 'info');
}
}
function showStatus(msg: string, type: 'success' | 'error' | 'info' = 'info') {
ElMessage({ message: msg, type, duration: 4000, showClose: true });
}
function getEncodingForType(type: NvsType): NvsEncoding {
return TYPE_TO_ENCODING[type] ?? 'u8';
}
/** Check if string contains non-printable bytes (< 0x20 or 0x7F) */
function hasNonPrintable(s: string): boolean {
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i);
if (c < 0x20 || c === 0x7F) return true;
}
return false;
}
/** Hex-dump a string as space-separated UTF-8 byte values (for string values) */
function displayHex(s: string): string {
const bytes = new TextEncoder().encode(s);
return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join(' ');
}
/** Hex-dump a string as space-separated Latin-1 byte values (for keys and namespaces) */
function displayHexLatin1(s: string): string {
return Array.from(s, c => c.charCodeAt(0).toString(16).padStart(2, '0')).join(' ');
}
/** Display a namespace/key string with hex mode support (Latin-1 encoding) */
function displayStr(s: string): string {
return showHex.value ? displayHexLatin1(s) : s;
}
/** Format entry value for hex display */
function formatValueHex(entry: NvsEntry): string {
if (entry.value instanceof Uint8Array) return formatValue(entry);
if (typeof entry.value === 'bigint') {
// Negative I64: show two's complement unsigned representation
const v = entry.value < 0n ? BigInt.asUintN(64, entry.value) : entry.value;
return '0x' + v.toString(16).toUpperCase();
}
if (typeof entry.value === 'number') {
// Negative signed integers: mask to proper unsigned width (two's complement)
let v = entry.value;
if (v < 0) {
switch (entry.type) {
case NvsType.I8: v = v & 0xFF; break;
case NvsType.I16: v = v & 0xFFFF; break;
case NvsType.I32: v = v >>> 0; break;
}
}
return '0x' + v.toString(16).toUpperCase();
}
if (typeof entry.value === 'string') return displayHex(entry.value);
return String(entry.value);
}
/** Format raw string for display — non-printable bytes become \xHH, backslash becomes \\\\ */
function formatEscapes(s: string): string {
let out = '';
for (const ch of s) {
const c = ch.charCodeAt(0);
if (c === 0x5C) out += '\\\\';
else if (c < 0x20 || c === 0x7F) out += '\\x' + c.toString(16).padStart(2, '0');
else out += ch;
}
return out;
}
/** Parse C-style escape sequences back to raw string.
* \0 and \x00 are intentionally excluded: NVS keys are null-terminated in binary,
* so a NUL byte would silently truncate the key on round-trip. */
function parseEscapes(s: string): string {
return s.replace(/\\(x[0-9a-fA-F]{2}|[nrt\\])/g, (_, esc: string) => {
if (esc[0] === 'x') {
const code = parseInt(esc.slice(1), 16);
return code === 0 ? '\\x00' : String.fromCharCode(code);
}
if (esc === 'n') return '\n';
if (esc === 'r') return '\r';
if (esc === 't') return '\t';
if (esc === '\\') return '\\';
return '\\' + esc;
});
}
/** Preview of blob value for table cell (up to 8 bytes) */
function formatValue(entry: NvsEntry): string {
if (entry.value instanceof Uint8Array) {
const preview = entry.value.subarray(0, 8);
const hex = Array.from(preview).map(b => b.toString(16).padStart(2, '0')).join(' ');
if (entry.value.length > 8) return `${hex} …(${entry.value.length}B)`;
return hex;
}
return String(entry.value);
}
/** Full value text for the viewer dialog */
function fullValueText(entry: NvsEntry): string {
if (entry.value instanceof Uint8Array) {
const lines: string[] = [];
for (let i = 0; i < entry.value.length; i += 16) {
const chunk = entry.value.subarray(i, i + 16);
const hex = Array.from(chunk).map(b => b.toString(16).padStart(2, '0')).join(' ');
const asc = Array.from(chunk).map(b => (b >= 0x20 && b < 0x7F) ? String.fromCharCode(b) : '.').join('');
lines.push(`${i.toString(16).padStart(4, '0')}: ${hex.padEnd(47)} ${asc}`);
}
return lines.join('\n');
}
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);
}
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 parseEscapes(raw);
case 'blob':
case 'binary': {
const result = parseHexString(raw);
if ('error' in result) throw new Error(result.error);
return result.bytes;
}
default:
return raw;
}
}
// ── Actions: CRUD ──────────────────────────────────────────────────
function autoIncrementKey(key: string): string {
const m = key.match(/^(.*?)(\d+)$/);
if (m) return m[1] + String(parseInt(m[2], 10) + 1).padStart(m[2].length, '0');
return key + '_1';
}
function handleInlineAddEntry() {
const rawKey = parseEscapes(newRow.value.key);
if (!newRow.value.namespace || !rawKey) {
showStatus('命名空间和键名不能为空', 'error');
return;
}
const type = ENCODING_TO_TYPE[newRow.value.encoding];
let value: ReturnType<typeof parseValueInput>;
try {
value = parseValueInput(newRow.value.encoding, newRow.value.value);
} catch (e: any) {
showStatus(e.message ?? '值格式错误', 'error');
return;
}
partition.value = addEntry(partition.value, {
namespace: newRow.value.namespace,
key: rawKey,
type,
value,
});
newRow.value.key = autoIncrementKey(newRow.value.key);
showStatus('已添加', 'success');
}
async function onNewRowStringChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
(e.target as HTMLInputElement).value = '';
if (!file) return;
newRow.value.value = formatEscapes(await file.text());
}
async function onNewRowBlobChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
(e.target as HTMLInputElement).value = '';
if (!file) return;
const bytes = new Uint8Array(await file.arrayBuffer());
newRow.value.value = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
}
function handleDeleteEntry(entryId: string) {
partition.value = removeEntry(partition.value, entryId);
}
function handleDuplicateEntry(entryId: string) {
partition.value = duplicateEntry(partition.value, entryId);
showStatus('已复制记录', 'success');
}
function handleClear() {
partition.value = createEmptyPartition(partition.value.version);
showStatus('已清空所有记录', 'info');
}
// ── Actions: Inline edit ───────────────────────────────────────────
function handleUpdateKey(entryId: string, newKey: string): boolean {
if (!newKey) { showStatus('键名不能为空', 'error'); return false; }
if (newKey.length > MAX_KEY_LENGTH) { showStatus(`键名超过 ${MAX_KEY_LENGTH} 字符`, 'error'); return false; }
partition.value = updateEntry(partition.value, entryId, { key: newKey });
return true;
}
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];
const entry = partition.value.entries.find(e => e.id === entryId);
if (!entry) return;
const oldValue = entry.value;
let value: NvsEntry['value'];
const isBigIntType = type === NvsType.U64 || type === NvsType.I64;
if (isBigIntType) {
if (typeof oldValue === 'bigint') value = oldValue;
else if (typeof oldValue === 'number') value = BigInt(Math.trunc(oldValue));
else if (typeof oldValue === 'string') {
try { value = BigInt(oldValue); } catch { value = 0n; }
} else value = 0n;
} else if (isPrimitiveType(type)) {
if (typeof oldValue === 'number') value = oldValue;
else if (typeof oldValue === 'bigint') value = Number(oldValue);
else if (typeof oldValue === 'string') {
const n = Number(oldValue);
value = Number.isNaN(n) ? 0 : Math.trunc(n);
} else value = 0;
} else if (type === NvsType.SZ) {
if (typeof oldValue === 'string') value = oldValue;
else if (typeof oldValue === 'number' || typeof oldValue === 'bigint') value = String(oldValue);
else value = '';
} else {
value = oldValue instanceof Uint8Array ? oldValue : new Uint8Array(0);
}
partition.value = updateEntry(partition.value, entryId, { type, value });
editingCells.delete(entryId + ':value');
}
function handleUpdateValue(entryId: string, encoding: NvsEncoding, raw: string): boolean {
let value: ReturnType<typeof parseValueInput>;
try {
value = parseValueInput(encoding, raw);
} catch (e: any) {
showStatus(e.message ?? '值格式错误', 'error');
return false;
}
partition.value = updateEntry(partition.value, entryId, { value });
return true;
}
/** Commit the editing buffer for a cell. Buffer is cleared only on success. */
function commitEdit(entryId: string, field: string) {
const bufKey = entryId + ':' + field;
const val = editingCells.get(bufKey);
if (val === undefined) return;
let ok: boolean;
if (field === 'key') {
ok = handleUpdateKey(entryId, parseEscapes(val));
} else {
const entry = partition.value.entries.find(e => e.id === entryId);
if (!entry) { editingCells.delete(bufKey); return; }
if (entry.type === NvsType.SZ) {
partition.value = updateEntry(partition.value, entryId, { value: parseEscapes(val) });
ok = true;
} else {
ok = handleUpdateValue(entryId, getEncodingForType(entry.type), val);
}
}
if (ok) editingCells.delete(bufKey);
}
/** Cancel editing: discard buffer so display reverts to stored value. */
function cancelEdit(entryId: string, field: string) {
editingCells.delete(entryId + ':' + field);
}
// ── Actions: Sort ──────────────────────────────────────────────────
function handleSortChange({ prop, order }: { prop: string; order: 'ascending' | 'descending' | null }) {
sortProp.value = prop as 'namespace' | 'key' | 'value' | null;
sortOrder.value = order;
}
// ── Actions: File I/O ──────────────────────────────────────────────
async function detectFileType(file: File): Promise<'bin' | 'csv' | 'json'> {
const ext = file.name.split('.').pop()?.toLowerCase();
if (ext === 'bin' || ext === 'nvs' || ext === 'part' || ext === 'img') return 'bin';
if (ext === 'csv') return 'csv';
if (ext === 'json') return 'json';
// Fallback: size multiple of page size → binary; first byte '{' → json; else csv
if (file.size > 0 && file.size % PAGE_SIZE === 0) return 'bin';
const firstByte = new Uint8Array(await file.slice(0, 1).arrayBuffer())[0];
return firstByte === 0x7b ? 'json' : 'csv';
}
async function handleImport(file: File, mode: 'open' | 'merge') {
const type = await detectFileType(file);
try {
if (type === 'bin') {
const buffer = await file.arrayBuffer();
const data = new Uint8Array(buffer);
const incoming = parseBinary(data);
if (mode === 'open') {
partition.value = incoming;
targetSize.value = data.byteLength;
showStatus(`已加载 ${file.name} (${data.byteLength} 字节)`, 'success');
} else {
partition.value = mergePartitions(partition.value, incoming, mergeMode.value);
showStatus(`已合并 ${file.name} (${incoming.entries.length} 条记录)`, 'success');
const blobWarnings = checkBlobCompatibility(partition.value.entries, partition.value.version);
if (blobWarnings.length > 0) showStatus(`${blobWarnings.length} 个 blob 超出大小限制,请查看验证面板`, 'info');
}
} else if (type === 'csv') {
const text = await file.text();
const incoming = parseCsv(text);
if (mode === 'open') {
partition.value = incoming;
showStatus(`已加载 ${file.name}`, 'success');
} else {
partition.value = mergePartitions(partition.value, incoming, mergeMode.value);
showStatus(`已合并 ${file.name} (${incoming.entries.length} 条记录)`, 'success');
const blobWarnings = checkBlobCompatibility(partition.value.entries, partition.value.version);
if (blobWarnings.length > 0) showStatus(`${blobWarnings.length} 个 blob 超出大小限制,请查看验证面板`, 'info');
}
} else {
const text = await file.text();
const raw = partitionFromJson(text);
const { partition: incoming, dropped, clamped } = normalizePartition(raw);
if (mode === 'open') {
partition.value = incoming;
const parts: string[] = [`已加载 ${file.name}`];
if (dropped > 0 || clamped > 0) {
const details: string[] = [];
if (dropped > 0) details.push(`丢弃 ${dropped} 条无效记录`);
if (clamped > 0) details.push(`${clamped} 条值被截断`);
parts.push(`${incoming.entries.length} 条,${details.join('')}`);
}
showStatus(parts.join(''), (dropped > 0 || clamped > 0) ? 'info' : 'success');
} else {
partition.value = mergePartitions(partition.value, incoming, mergeMode.value);
const parts: string[] = [`已合并 ${file.name}${incoming.entries.length} 条记录`];
if (dropped > 0 || clamped > 0) {
const details: string[] = [];
if (dropped > 0) details.push(`丢弃 ${dropped} 条无效记录`);
if (clamped > 0) details.push(`${clamped} 条值被截断`);
parts.push(`${details.join('')}`);
}
parts.push('');
showStatus(parts.join(''), (dropped > 0 || clamped > 0) ? 'info' : 'success');
const blobWarnings = checkBlobCompatibility(partition.value.entries, partition.value.version);
if (blobWarnings.length > 0) showStatus(`${blobWarnings.length} 个 blob 超出大小限制,请查看验证面板`, 'info');
}
}
} catch (e: any) {
showStatus(`${mode === 'open' ? '加载' : '合并'}失败: ${e.message}`, 'error');
}
}
async function onOpenChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
(e.target as HTMLInputElement).value = '';
if (!file) return;
await handleImport(file, 'open');
}
async function onMergeChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
(e.target as HTMLInputElement).value = '';
if (!file) return;
await handleImport(file, 'merge');
}
function handleExportBinary() {
try {
const errs = validatePartition(partition.value);
if (errs.length > 0) { showStatus(`验证错误: ${errs[0].message}`, 'error'); return; }
const data = serializeBinary(partition.value, targetSize.value);
downloadBlob(new Blob([data as Uint8Array<ArrayBuffer>]), 'nvs.bin');
showStatus('已导出 nvs.bin', 'success');
} catch (e: any) {
showStatus(`导出失败: ${e.message}`, 'error');
}
}
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');
}
}
function handleExportJson() {
try {
const text = partitionToJson(partition.value);
downloadBlob(new Blob([text], { type: 'application/json;charset=utf-8' }), 'nvs.json');
showStatus('已导出 nvs.json', 'success');
} catch (e: any) {
showStatus(`导出失败: ${e.message}`, 'error');
}
}
function isBlobEntry(row: NvsEntry): boolean {
return row.value instanceof Uint8Array;
}
function isUploadableEntry(_row: NvsEntry): boolean {
return true;
}
const fileUploadInput = ref<HTMLInputElement>();
let fileUploadEntryId = '';
function triggerUpload(row: NvsEntry) {
if (row.value instanceof Uint8Array) {
openBlobEditor(row.id);
} else {
fileUploadEntryId = row.id;
fileUploadInput.value?.click();
}
}
async function onFileUpload(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0];
(e.target as HTMLInputElement).value = '';
if (!file) return;
const entry = partition.value.entries.find(e => e.id === fileUploadEntryId);
if (!entry) { showStatus('记录不存在', 'error'); return; }
const text = await file.text();
if (isPrimitiveType(entry.type)) {
try {
const value = parseValueInput(getEncodingForType(entry.type), text.trim());
partition.value = updateEntry(partition.value, fileUploadEntryId, { value });
showStatus(`已导入 ${file.name}`, 'success');
} catch (err: any) {
showStatus(`导入失败: ${err.message}`, 'error');
}
} else {
partition.value = updateEntry(partition.value, fileUploadEntryId, { value: text });
showStatus('已导入文本', 'success');
}
}
function openBlobEditor(entryId: string) {
const entry = partition.value.entries.find(e => e.id === entryId);
if (!entry || !(entry.value instanceof Uint8Array)) return;
blobEditorEntryId.value = entryId;
blobEditorKey.value = entry.key;
blobEditorData.value = new Uint8Array(entry.value);
blobEditorVisible.value = true;
}
function openBlobEditorForNewRow() {
const result = parseHexString(newRow.value.value);
if ('error' in result && newRow.value.value.trim()) {
showStatus(`十六进制格式错误: ${result.error}`, 'error');
return;
}
blobEditorEntryId.value = '';
blobEditorKey.value = newRow.value.key || 'blob';
blobEditorData.value = 'bytes' in result ? new Uint8Array(result.bytes) : new Uint8Array(0);
blobEditorVisible.value = true;
}
function onBlobEditorConfirm(data: Uint8Array) {
if (blobEditorEntryId.value) {
partition.value = updateEntry(partition.value, blobEditorEntryId.value, { value: data });
showStatus(`已更新 (${data.length} 字节)`, 'success');
} else {
newRow.value.value = Array.from(data).map(b => b.toString(16).padStart(2, '0')).join('');
}
}
function copyToClipboard(text: string) {
navigator.clipboard.writeText(text).then(
() => showStatus('已复制到剪贴板', 'success'),
() => showStatus('复制失败', 'error'),
);
}
</script>
<template>
<div class="nvs-editor-container">
<!-- Hidden file inputs -->
<input ref="openInput" type="file" accept=".bin,.nvs,.part,.img,.csv,.json" style="display:none" @change="onOpenChange" />
<input ref="mergeInput" type="file" accept=".bin,.nvs,.part,.img,.csv,.json" style="display:none" @change="onMergeChange" />
<input ref="newRowBlobInput" type="file" accept="*/*" style="display:none" @change="onNewRowBlobChange" />
<input ref="newRowStringInput" type="file" accept="*/*" style="display:none" @change="onNewRowStringChange" />
<input ref="fileUploadInput" type="file" accept="*/*" style="display:none" @change="onFileUpload" />
<el-alert
v-if="errors.length > 0"
type="warning"
show-icon
class="mb-4"
:closable="false"
>
<template #title>验证问题 ({{ errors.length }})</template>
<div v-for="(err, i) in errors" :key="i" class="text-xs">{{ err.message }}</div>
</el-alert>
<!-- ── Stats and Configuration Card ── -->
<el-card shadow="never" class="mb-4 nvs-stats-card">
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center flex-wrap gap-4">
<div class="stat-item">
<span class="stat-label">分区大小</span>
<div class="flex items-center gap-2 mt-1">
<el-input-number
v-if="sizeInPages"
v-model="targetSizePages"
:min="3" :step="1"
size="small" style="width: 120px;"
/>
<el-input-number
v-else
v-model="targetSizeKB"
:min="12" :step="4"
size="small" style="width: 120px;"
/>
<span class="text-[13px] cursor-pointer underline underline-offset-[3px] nvs-stats-unit" style="color: var(--vp-c-text-2);" @click="sizeInPages = !sizeInPages" title="点击切换单位">
{{ sizeInPages ? '页' : 'KB' }}
</span>
<span class="text-[13px]" style="color: var(--vp-c-text-3);">
({{ sizeInPages ? targetSizePages * 4 + ' KB' : targetSizePages + ' 页' }})
</span>
</div>
</div>
<el-divider direction="vertical" class="hidden sm:block h-8" />
<div class="stat-item">
<span class="stat-label">IDF 版本</span>
<div class="mt-1">
<el-select
:model-value="partition.version"
size="small"
style="width: 160px;"
@change="handleVersionChange"
>
<el-option :value="NvsVersion.V2" label="IDF v4.0+ (V2)" />
<el-option :value="NvsVersion.V1" label="IDF < v4.0 (V1)" />
</el-select>
</div>
</div>
</div>
<div class="stat-item progress-item">
<div class="flex justify-between w-full mb-1">
<span class="stat-label">空间使用率</span>
<span class="text-[12px]" style="color: var(--vp-c-text-2);">
{{ flashStats.usedEntries }} / {{ flashStats.maxEntries }} 条目
</span>
</div>
<el-progress
:percentage="flashStats.usagePercent"
:color="progressColor"
:stroke-width="10"
class="nvs-progress"
>
<span class="text-[12px] ml-2 font-medium">{{ flashStats.usagePercent.toFixed(1) }}%</span>
</el-progress>
</div>
</div>
</el-card>
<!-- ── Main Action Toolbar (Desktop) ── -->
<div v-if="!isSmallScreen" class="flex flex-wrap items-center justify-between gap-3 mb-4">
<div class="flex items-center gap-3">
<el-button type="danger" plain :icon="Delete" @click="handleClear">清空数据</el-button>
<el-checkbox v-model="showHex" border>HEX 模式</el-checkbox>
</div>
<div class="flex flex-wrap items-center gap-2">
<!-- Filter group — wraps as a unit -->
<div class="flex items-center gap-2 flex-nowrap min-w-0">
<el-select v-model="namespaceFilter" placeholder="全部命名空间" clearable style="width: 150px;">
<el-option v-for="ns in partition.namespaces" :key="ns" :label="displayStr(ns)" :value="ns" />
</el-select>
<el-input v-model="searchQuery" placeholder="搜索键名/值..." clearable :prefix-icon="Search" style="width: 160px;" />
</div>
<!-- Action group — wraps as a unit -->
<div class="flex items-center gap-2 flex-wrap">
<el-divider direction="vertical" />
<el-button type="primary" plain :icon="FolderOpened" @click="openInput?.click()">打开(覆盖)</el-button>
<el-dropdown trigger="click">
<el-button type="primary" plain :icon="Upload">
导入 <el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<div class="px-3 py-2 border-b border-gray-100 flex flex-col gap-1 bg-gray-50">
<span class="text-xs text-gray-500 font-semibold">合并策略</span>
<el-radio-group v-model="mergeMode" size="small">
<el-radio value="overwrite">覆盖同名</el-radio>
<el-radio value="skip">跳过同名</el-radio>
</el-radio-group>
</div>
<el-dropdown-item @click="mergeInput?.click()">选择文件合并</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown trigger="click">
<el-button type="primary" :icon="Download">
导出 <el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handleExportBinary">导出为 BIN</el-dropdown-item>
<el-dropdown-item @click="handleExportCsv">导出为 CSV</el-dropdown-item>
<el-dropdown-item @click="handleExportJson">导出为 JSON</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div><!-- end action group -->
</div>
</div>
<!-- ── Mobile Toolbar (<640px) ── -->
<div v-else class="mb-3">
<!-- Row 1: actions -->
<div class="flex flex-wrap items-center gap-2 mb-3">
<el-button type="primary" plain size="small" :icon="FolderOpened" @click="openInput?.click()">打开</el-button>
<el-dropdown trigger="click">
<el-button type="primary" plain size="small" :icon="Upload">
导入 <el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<div class="px-3 py-2 border-b border-gray-100 flex flex-col gap-1 bg-gray-50">
<span class="text-xs text-gray-500 font-semibold">合并策略</span>
<el-radio-group v-model="mergeMode" size="small">
<el-radio value="overwrite">覆盖同名</el-radio>
<el-radio value="skip">跳过同名</el-radio>
</el-radio-group>
</div>
<el-dropdown-item @click="mergeInput?.click()">选择文件合并</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown trigger="click">
<el-button type="primary" size="small" :icon="Download">
导出 <el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handleExportBinary">导出为 BIN</el-dropdown-item>
<el-dropdown-item @click="handleExportCsv">导出为 CSV</el-dropdown-item>
<el-dropdown-item @click="handleExportJson">导出为 JSON</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<span class="flex-1" />
<el-button type="danger" plain size="small" :icon="Delete" @click="handleClear">清空</el-button>
<el-checkbox v-model="showHex" border size="small">HEX</el-checkbox>
</div>
<!-- Row 2: tabs -->
<el-tabs v-model="mobileTab" type="card" class="nvs-mobile-tabs">
<el-tab-pane label="新增" name="add" />
<el-tab-pane label="搜索" name="search" />
</el-tabs>
<!-- Search tab content -->
<div v-if="mobileTab === 'search'" class="flex flex-col gap-2 mt-2">
<el-select v-model="namespaceFilter" placeholder="全部命名空间" clearable>
<el-option v-for="ns in partition.namespaces" :key="ns" :label="displayStr(ns)" :value="ns" />
</el-select>
<el-input v-model="searchQuery" placeholder="搜索键名/值..." clearable :prefix-icon="Search" />
</div>
</div>
<!-- ── Data Table Card ── -->
<el-card shadow="never" class="nvs-table-card" :body-style="{ padding: '0px' }">
<div class="flex items-center justify-between px-4 py-3 bg-gray-50 border-b nvs-card-header">
<span class="text-[14px] font-semibold text-gray-700">数据列表 <span class="font-normal text-gray-500 ml-1">({{ filteredEntries.length }} 条)</span></span>
</div>
<!-- Inline Add Row -->
<div v-show="!isSmallScreen || mobileTab === 'add'" class="nvs-add-inline grid grid-cols-1 sm:grid-cols-[1fr_1fr_120px_150px_auto] gap-2 px-4 py-3 border-b" style="background: var(--vp-c-bg-soft);">
<el-input
v-model="newRow.key"
placeholder="新键名 (支持 \xHH)"
@keyup.enter="handleInlineAddEntry"
/>
<div
v-if="newRow.encoding === 'binary' || newRow.encoding === 'blob'"
class="flex items-center gap-1 min-w-0"
>
<el-input
v-model="newRow.value"
placeholder="十六进制 (e.g. deadbeef)"
@keyup.enter="handleInlineAddEntry"
style="flex:1;min-width:0"
/>
<el-button-group>
<el-button :icon="Upload" @click="newRowBlobInput?.click()" title="从文件导入" />
<el-button :icon="MoreFilled" @click="openBlobEditorForNewRow" title="详细编辑" />
</el-button-group>
</div>
<div
v-else-if="newRow.encoding === 'string'"
class="flex items-center gap-1 min-w-0"
>
<el-input
v-model="newRow.value"
type="textarea"
:autosize="{ minRows: 1, maxRows: 3 }"
placeholder="值 (支持 \\xHH 转义)"
style="flex:1;min-width:0"
/>
<el-button :icon="Upload" @click="newRowStringInput?.click()" title="从文件导入" />
</div>
<el-input
v-else
v-model="newRow.value"
placeholder="值"
@keyup.enter="handleInlineAddEntry"
/>
<el-select v-model="newRow.encoding">
<el-option v-for="enc in encodingOptions" :key="enc" :value="enc" :label="enc" />
</el-select>
<el-select
v-model="newRow.namespace"
filterable
allow-create
placeholder="命名空间"
@keyup.enter="handleInlineAddEntry"
>
<el-option v-for="ns in partition.namespaces" :key="ns" :value="ns" :label="displayStr(ns)" />
</el-select>
<el-button type="primary" :icon="Plus" @click="handleInlineAddEntry" title="添加记录">添加</el-button>
</div>
<!-- Data table -->
<el-table
:data="filteredEntries"
stripe
row-key="id"
empty-text="暂无记录,请在上方添加或导入数据"
max-height="600"
@sort-change="handleSortChange"
class="w-full"
>
<!-- Key -->
<el-table-column prop="key" label="键名" min-width="120" sortable="custom" fixed="left">
<template #default="{ row }">
<div :class="['flex items-center gap-2 w-full', { 'nvs-cell-error': entryErrors.has(row.id) }]">
<el-icon v-if="hasNonPrintable(row.key)" class="shrink-0 text-warning" title="含非打印字节"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/></svg></el-icon>
<el-input
:model-value="editingCells.get(row.id + ':key') ?? (showHex ? displayHexLatin1(row.key) : formatEscapes(row.key))"
class="nvs-seamless-input"
:placeholder="showHex ? '' : '支持 \\xHH'"
:readonly="showHex"
@focus="!showHex && editingCells.set(row.id + ':key', formatEscapes(row.key))"
@input="(val: string) => editingCells.set(row.id + ':key', val)"
@keyup.enter="commitEdit(row.id, 'key')"
@keyup.escape="cancelEdit(row.id, 'key')"
@blur="commitEdit(row.id, 'key')"
/>
</div>
</template>
</el-table-column>
<!-- Value -->
<el-table-column prop="value" label="值" min-width="200" sortable="custom">
<template #default="{ row }">
<div :class="['flex items-center gap-1 w-full', { 'nvs-cell-error': entryErrors.has(row.id) }]">
<!-- Primitive -->
<el-input
v-if="isPrimitiveType(row.type)"
:model-value="editingCells.get(row.id + ':value') ?? (showHex ? formatValueHex(row) : String(row.value))"
class="nvs-seamless-input"
:readonly="showHex"
@focus="!showHex && editingCells.set(row.id + ':value', String(row.value))"
@input="(val: string) => editingCells.set(row.id + ':value', val)"
@keyup.enter="commitEdit(row.id, 'value')"
@keyup.escape="cancelEdit(row.id, 'value')"
@blur="commitEdit(row.id, 'value')"
/>
<!-- String -->
<el-input
v-else-if="row.type === NvsType.SZ"
:model-value="editingCells.get(row.id + ':value') ?? (showHex ? displayHex(row.value as string) : formatEscapes(row.value as string))"
type="textarea"
:autosize="{ minRows: 1, maxRows: 3 }"
class="nvs-seamless-input"
:placeholder="showHex ? '' : '支持 \\xHH 转义'"
:readonly="showHex"
@focus="!showHex && editingCells.set(row.id + ':value', formatEscapes(row.value as string))"
@input="(val: string) => editingCells.set(row.id + ':value', val)"
@keyup.escape="cancelEdit(row.id, 'value')"
@blur="commitEdit(row.id, 'value')"
/>
<!-- Blob -->
<div v-else class="flex items-center gap-1 w-full min-w-0">
<span class="text-sm font-mono text-gray-500 flex-1 truncate">{{ formatValue(row) }}</span>
<el-button size="small" link :icon="Document" @click="openBlobEditor(row.id)">编辑</el-button>
</div>
</div>
</template>
</el-table-column>
<!-- Type -->
<el-table-column label="类型" width="130">
<template #default="{ row }">
<div :class="{ 'nvs-cell-error': entryErrors.has(row.id) }">
<el-select
:model-value="getEncodingForType(row.type)"
class="nvs-seamless-select"
@change="(val: NvsEncoding) => handleUpdateEncoding(row.id, val)"
>
<el-option v-for="enc in encodingOptions" :key="enc" :label="enc" :value="enc" />
</el-select>
</div>
</template>
</el-table-column>
<!-- Namespace -->
<el-table-column prop="namespace" label="命名空间" width="180" sortable="custom">
<template #default="{ row }">
<div :class="['flex items-center gap-2 w-full', { 'nvs-cell-error': entryErrors.has(row.id) }]">
<el-icon v-if="hasNonPrintable(row.namespace)" class="shrink-0 text-warning" title="含非打印字节"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 9v4m0 4h.01M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/></svg></el-icon>
<el-tooltip :content="displayStr(row.namespace)" placement="top" :show-after="300">
<el-select
:model-value="row.namespace"
class="nvs-seamless-select"
@change="(val: string) => handleUpdateNamespace(row.id, val)"
>
<el-option v-for="ns in partition.namespaces" :key="ns" :label="displayStr(ns)" :value="ns" />
</el-select>
</el-tooltip>
</div>
</template>
</el-table-column>
<!-- Actions -->
<el-table-column label="操作" :width="isSmallScreen ? 60 : 130" fixed="right">
<template #default="{ row }">
<!-- Desktop: compact icon buttons -->
<div class="nvs-actions-desktop flex items-center">
<el-button type="primary" link size="small" :icon="Upload" @click="triggerUpload(row)" :title="isBlobEntry(row) ? '编辑二进制' : '从文件导入'" />
<el-button type="primary" link size="small" :icon="Document" @click="valueDialogEntry = row; showValueDialog = true" title="查看完整值" />
<el-button type="primary" link size="small" :icon="CopyDocument" @click="handleDuplicateEntry(row.id)" title="复制记录" />
<el-popconfirm title="确定删除此记录?" @confirm="handleDeleteEntry(row.id)">
<template #reference>
<el-button type="danger" link size="small" :icon="Delete" title="删除" />
</template>
</el-popconfirm>
</div>
<!-- Mobile: dropdown menu -->
<div class="nvs-actions-mobile">
<el-dropdown trigger="click">
<el-button type="primary" link :icon="MoreFilled" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="triggerUpload(row)">{{ isBlobEntry(row) ? '编辑二进制' : '从文件导入' }}</el-dropdown-item>
<el-dropdown-item @click="valueDialogEntry = row; showValueDialog = true">查看完整值</el-dropdown-item>
<el-dropdown-item @click="handleDuplicateEntry(row.id)">复制记录</el-dropdown-item>
<el-dropdown-item divided @click="ElMessageBox.confirm('确定删除此记录?', '确认', { type: 'warning' }).then(() => handleDeleteEntry(row.id)).catch(() => {})">删除</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- ── Blob editor dialog ── -->
<BlobEditorDialog
v-model:visible="blobEditorVisible"
:model-value="blobEditorData"
:entry-key="blobEditorKey"
@update:model-value="onBlobEditorConfirm"
/>
<!-- ── Value viewer dialog ── -->
<el-dialog
v-model="showValueDialog"
:title="`值查看器 — ${valueDialogEntry?.namespace} / ${valueDialogEntry?.key}`"
width="640px"
destroy-on-close
>
<pre class="nvs-value-viewer">{{ valueDialogEntry ? fullValueText(valueDialogEntry) : '' }}</pre>
<template #footer>
<span class="dialog-footer">
<el-button @click="copyToClipboard(valueDialogEntry ? fullValueText(valueDialogEntry) : '')">复制内容</el-button>
<el-button type="primary" @click="showValueDialog = false">关闭</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.nvs-editor-container {
max-width: 100%;
margin: 0 auto;
}
.nvs-stats-card :deep(.el-card__body) {
padding: 16px 20px;
}
.stat-item {
display: flex;
flex-direction: column;
}
.stat-label {
font-size: 12px;
font-weight: 600;
color: var(--vp-c-text-2);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.progress-item {
min-width: 200px;
flex: 1;
max-width: 400px;
}
.nvs-progress :deep(.el-progress-bar__outer) {
background-color: var(--vp-c-bg-soft);
}
.nvs-stats-unit:hover {
color: var(--vp-c-brand);
}
.nvs-table-card {
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--vp-c-divider);
}
.nvs-card-header {
background-color: var(--vp-c-bg-soft);
border-bottom: 1px solid var(--vp-c-divider);
}
/* Seamless Inputs for Table */
.nvs-seamless-input :deep(.el-input__wrapper),
.nvs-seamless-select :deep(.el-input__wrapper),
.nvs-seamless-input :deep(.el-textarea__inner) {
box-shadow: none !important;
background-color: transparent;
padding: 0 8px;
}
.nvs-seamless-input :deep(.el-input__wrapper:hover),
.nvs-seamless-select :deep(.el-input__wrapper:hover),
.nvs-seamless-input :deep(.el-textarea__inner:hover),
.nvs-seamless-input :deep(.el-input__wrapper.is-focus),
.nvs-seamless-select :deep(.el-input__wrapper.is-focus),
.nvs-seamless-input :deep(.el-textarea__inner:focus) {
box-shadow: 0 0 0 1px var(--el-color-primary) inset !important;
background-color: var(--vp-c-bg);
border-radius: 4px;
}
/* Value viewer */
.nvs-value-viewer {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace;
font-size: 13px;
line-height: 1.5;
overflow: auto;
max-height: 400px;
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
padding: 16px;
margin: 0;
white-space: pre-wrap;
word-break: break-all;
color: var(--vp-c-text-1);
}
/* Dark mode overrides and utility classes mapping */
.bg-gray-50 {
background-color: var(--vp-c-bg-soft);
}
.border-b {
border-bottom: 1px solid var(--vp-c-divider);
}
.text-gray-500 {
color: var(--vp-c-text-2);
}
.text-gray-700 {
color: var(--vp-c-text-1);
}
.border-gray-100 {
border-color: var(--vp-c-divider);
}
.text-warning {
color: #E6A23C;
}
/* Error highlighting for validation conflicts */
.nvs-cell-error :deep(.el-input__wrapper),
.nvs-cell-error :deep(.el-select .el-input__wrapper),
.nvs-cell-error :deep(.el-textarea__inner) {
background-color: rgba(245, 108, 108, 0.1) !important;
}
/* Responsive action buttons */
.nvs-actions-mobile { display: none; }
@media (max-width: 639px) {
.nvs-add-inline {
grid-template-columns: 1fr;
}
.nvs-actions-desktop { display: none; }
.nvs-actions-mobile { display: block; }
}
/* Mobile tabs: remove content padding since tab-pane is empty */
.nvs-mobile-tabs :deep(.el-tabs__content) {
display: none;
}
.nvs-mobile-tabs :deep(.el-tabs__header) {
margin-bottom: 0;
}
</style>