feat(nvs-editor): blob editor dialog, mobile layout, file import for all types

- Add BlobEditorDialog with hex editing, preview, import/export
- Responsive layout with JS+CSS breakpoint at 640px
- File upload support for string, blob, and primitive types
- Fix: use formatEscapes on string file import to preserve literal content
- Fix: robust download with delayed URL revocation
This commit is contained in:
kerms 2026-03-13 15:26:10 +01:00
parent a8df9ac08d
commit 4e93301626
Signed by: kerms
GPG Key ID: 5432C10DDCF8DAD5
2 changed files with 452 additions and 95 deletions

View File

@ -0,0 +1,216 @@
<template>
<el-dialog
:model-value="props.visible"
title="编辑二进制数据"
width="min(640px, 92vw)"
:close-on-click-modal="false"
@update:model-value="$emit('update:visible', $event)"
>
<!-- toolbar -->
<div class="flex items-center gap-2 mb-2 flex-wrap">
<span class="text-sm text-gray-500">{{ byteCount }} 字节</span>
<el-button-group class="ml-2">
<el-button size="small" :type="activeTab === 'edit' ? 'primary' : ''" @click="activeTab = 'edit'">编辑</el-button>
<el-button size="small" :type="activeTab === 'preview' ? 'primary' : ''" @click="activeTab = 'preview'">预览</el-button>
</el-button-group>
<span class="flex-1" />
<el-button size="small" :icon="Upload" @click="importInput?.click()">导入文件</el-button>
<el-button size="small" :icon="Download" @click="exportFile">导出文件</el-button>
</div>
<!-- edit tab: offset + textarea -->
<div v-if="activeTab === 'edit'" class="hex-editor-wrapper">
<div ref="offsetPanel" class="hex-offset-panel" :style="{ height: editorHeight + 'px' }" aria-hidden="true">
<div v-for="(_, i) in lineCount" :key="i" class="hex-offset-row">
{{ (i * 16).toString(16).padStart(8, '0') }}
</div>
</div>
<textarea
v-model="hexText"
class="blob-hex-textarea"
:style="{ height: editorHeight + 'px', overflowY: 'auto' }"
wrap="off"
spellcheck="false"
placeholder="输入十六进制字节 (如: de ad be ef 01 02)&#10;支持空格、逗号、0x前缀分隔"
@input="onHexInput"
@scroll="syncOffsetScroll"
/>
</div>
<!-- preview tab: HexDump -->
<HexDump v-else :data="currentBytes" :height="editorHeight" />
<div v-if="parseError" class="text-red-500 text-xs mt-1">{{ parseError }}</div>
<input ref="importInput" type="file" accept="*/*" style="display:none" @change="onImport" />
<template #footer>
<el-button @click="$emit('update:visible', false)">取消</el-button>
<el-button type="primary" :disabled="!!parseError" @click="confirm">确认</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch, withDefaults } from 'vue';
import { Upload, Download } from '@element-plus/icons-vue';
import HexDump from '../app-image-viewer/HexDump.vue';
import { parseHexString } from '../../lib/nvs';
const props = withDefaults(defineProps<{
modelValue: Uint8Array;
visible: boolean;
entryKey?: string;
}>(), { entryKey: 'blob' });
const emit = defineEmits<{
'update:modelValue': [value: Uint8Array];
'update:visible': [value: boolean];
}>();
const importInput = ref<HTMLInputElement>();
const offsetPanel = ref<HTMLDivElement>();
const hexText = ref('');
const parseError = ref<string | null>(null);
const activeTab = ref<'edit' | 'preview'>('edit');
// Formatting
function formatHex(data: Uint8Array): string {
const lines: string[] = [];
for (let i = 0; i < data.length; i += 16) {
const chunk = data.subarray(i, i + 16);
lines.push(Array.from(chunk).map(b => b.toString(16).padStart(2, '0')).join(' '));
}
return lines.join('\n');
}
// Parsing
function parseHex(text: string): Uint8Array | null {
const result = parseHexString(text);
if ('error' in result) {
parseError.value = result.error;
return null;
}
parseError.value = null;
return result.bytes;
}
const currentBytes = computed<Uint8Array>(() => parseHex(hexText.value) ?? new Uint8Array(0));
const byteCount = computed(() => currentBytes.value.length);
/** Number of non-empty lines in the textarea (= number of 16-byte offset rows to show) */
const lineCount = computed(() => {
const lines = hexText.value.split('\n');
const count = (lines.length > 0 && lines[lines.length - 1].trim() === '')
? lines.length - 1
: lines.length;
return Math.max(1, count);
});
/** Editor/preview height in px — uses max of textarea line count and hex-dump row count */
const editorHeight = computed(() => {
const textRows = lineCount.value + 1;
const byteRows = Math.ceil(currentBytes.value.length / 16) + 1;
return Math.min(20, Math.max(textRows, byteRows)) * 20 + 16;
});
// Sync with prop
watch(() => props.visible, (v) => {
if (v) {
hexText.value = formatHex(props.modelValue as Uint8Array);
parseError.value = null;
activeTab.value = 'edit';
}
});
// Handlers
function onHexInput() {
parseHex(hexText.value); // side-effect: sets parseError
}
function syncOffsetScroll(e: Event) {
if (offsetPanel.value)
offsetPanel.value.scrollTop = (e.target as HTMLTextAreaElement).scrollTop;
}
function confirm() {
const bytes = parseHex(hexText.value);
if (bytes === null) return;
emit('update:modelValue', bytes);
emit('update:visible', false);
}
async function onImport(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());
hexText.value = formatHex(bytes);
parseError.value = null;
}
function exportFile() {
const bytes = parseHex(hexText.value);
if (!bytes) return;
const url = URL.createObjectURL(new Blob([bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer]));
const a = document.createElement('a');
a.href = url;
a.download = props.entryKey + '.bin';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 10000);
}
</script>
<style scoped>
.hex-editor-wrapper {
display: flex;
border: 1px solid var(--el-border-color);
border-radius: 4px;
overflow: hidden;
}
.hex-editor-wrapper:focus-within {
border-color: var(--el-color-primary);
}
.hex-offset-panel {
flex-shrink: 0;
width: 80px;
overflow: hidden;
background: var(--el-fill-color-light);
border-right: 1px solid var(--el-border-color-lighter);
user-select: none;
-webkit-user-select: none;
padding: 8px 6px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-secondary);
box-sizing: border-box;
}
.hex-offset-row {
white-space: nowrap;
}
.blob-hex-textarea {
flex: 1;
min-width: 0;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
padding: 8px 10px;
border: none;
border-radius: 0;
background: var(--el-fill-color-blank);
color: var(--el-text-color-primary);
outline: none;
box-sizing: border-box;
width: 100%;
overflow-x: auto;
}
.blob-hex-textarea:focus {
outline: none;
}
</style>

View File

@ -2,6 +2,7 @@
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,
@ -10,7 +11,7 @@ import {
createEmptyPartition, addEntry, removeEntry, updateEntry,
duplicateEntry, mergePartitions, calculateFlashStats,
validatePartition, generateEntryId, normalizePartition, reconcileBlobTypes,
checkBlobCompatibility,
checkBlobCompatibility, parseHexString,
parseBinary, serializeBinary, parseCsv, serializeCsv,
MAX_KEY_LENGTH, PAGE_SIZE,
} from '../../lib/nvs';
@ -31,6 +32,7 @@ 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: '' });
@ -46,8 +48,14 @@ const sortOrder = ref<'ascending' | 'descending' | null>(null);
// File input refs
const openInput = ref<HTMLInputElement>();
const mergeInput = ref<HTMLInputElement>();
const blobUploadInput = ref<HTMLInputElement>();
const blobUploadEntryId = ref('');
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. */
@ -133,7 +141,7 @@ const _blobHexCache = new WeakMap<Uint8Array, string>();
function valueToString(entry: NvsEntry): string {
if (entry.value instanceof Uint8Array) {
let hex = _blobHexCache.get(entry.value);
if (!hex) { hex = blobToHex(entry.value); _blobHexCache.set(entry.value, hex); }
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);
@ -300,11 +308,6 @@ function parseEscapes(s: string): string {
});
}
/** Full hex string of a Uint8Array (for editing buffer) */
function blobToHex(data: Uint8Array): string {
return Array.from(data).map(b => b.toString(16).padStart(2, '0')).join(' ');
}
/** Preview of blob value for table cell (up to 8 bytes) */
function formatValue(entry: NvsEntry): string {
if (entry.value instanceof Uint8Array) {
@ -367,13 +370,9 @@ function parseValueInput(encoding: NvsEncoding, raw: string): number | bigint |
return parseEscapes(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;
const result = parseHexString(raw);
if ('error' in result) throw new Error(result.error);
return result.bytes;
}
default:
return raw;
@ -412,6 +411,21 @@ function handleInlineAddEntry() {
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);
}
@ -523,7 +537,7 @@ function handleSortChange({ prop, order }: { prop: string; order: 'ascending' |
async function detectFileType(file: File): Promise<'bin' | 'csv' | 'json'> {
const ext = file.name.split('.').pop()?.toLowerCase();
if (ext === 'bin') return 'bin';
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
@ -641,34 +655,74 @@ function handleExportJson() {
}
}
async function onBlobUploadChange(e: Event) {
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 || !blobUploadEntryId.value) return;
try {
const entry = partition.value.entries.find(en => en.id === blobUploadEntryId.value);
if (!entry) {
showStatus('记录不存在', 'error');
return;
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');
}
if (isPrimitiveType(entry.type)) {
const text = await file.text();
const encoding = getEncodingForType(entry.type);
const value = parseValueInput(encoding, text);
partition.value = updateEntry(partition.value, blobUploadEntryId.value, { value });
showStatus(`已上传 ${file.name}`, 'success');
} else if (entry.type === NvsType.SZ) {
const text = await file.text();
partition.value = updateEntry(partition.value, blobUploadEntryId.value, { value: text });
showStatus(`已上传 ${file.name} (${text.length} 字符)`, 'success');
} else {
const buffer = await file.arrayBuffer();
const data = new Uint8Array(buffer);
partition.value = updateEntry(partition.value, blobUploadEntryId.value, { value: data });
showStatus(`已上传 ${file.name} (${data.length} 字节)`, 'success');
}
} catch (e: any) {
showStatus(`上传失败: ${e.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('');
}
}
@ -683,9 +737,11 @@ function copyToClipboard(text: string) {
<template>
<div class="nvs-editor-container">
<!-- Hidden file inputs -->
<input ref="openInput" type="file" accept=".bin,.csv,.json" style="display:none" @change="onOpenChange" />
<input ref="mergeInput" type="file" accept=".bin,.csv,.json" style="display:none" @change="onMergeChange" />
<input ref="blobUploadInput" type="file" accept="*/*" style="display:none" @change="onBlobUploadChange" />
<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"
@ -763,24 +819,26 @@ function copyToClipboard(text: string) {
</div>
</el-card>
<!-- Main Action Toolbar -->
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
<!-- 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-3">
<!-- Filter Area -->
<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: 180px;" />
<el-divider direction="vertical" />
<!-- Import / Export -->
<el-button type="primary" plain :icon="FolderOpened" @click="openInput?.click()">打开(覆盖)</el-button>
<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">
@ -812,6 +870,59 @@ function copyToClipboard(text: string) {
</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>
@ -822,7 +933,49 @@ function copyToClipboard(text: string) {
</div>
<!-- Inline Add Row -->
<div class="nvs-add-inline grid grid-cols-1 sm:grid-cols-[150px_1fr_120px_1fr_auto] gap-2 px-4 py-3 border-b" style="background: var(--vp-c-bg-soft);">
<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
@ -832,27 +985,6 @@ function copyToClipboard(text: string) {
>
<el-option v-for="ns in partition.namespaces" :key="ns" :value="ns" :label="displayStr(ns)" />
</el-select>
<el-input
v-model="newRow.key"
placeholder="新键名 (支持 \xHH)"
@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-input
v-if="newRow.encoding === 'string'"
v-model="newRow.value"
type="textarea"
:autosize="{ minRows: 1, maxRows: 3 }"
placeholder="值 (支持 \\xHH 转义)"
/>
<el-input
v-else
v-model="newRow.value"
placeholder="值"
@keyup.enter="handleInlineAddEntry"
/>
<el-button type="primary" :icon="Plus" @click="handleInlineAddEntry" title="添加记录">添加</el-button>
</div>
@ -917,17 +1049,10 @@ function copyToClipboard(text: string) {
@blur="commitEdit(row.id, 'value')"
/>
<!-- Blob -->
<el-input
v-else
:model-value="editingCells.get(row.id + ':value') ?? formatValue(row)"
class="nvs-seamless-input"
placeholder="十六进制 (如: 48 65 6C 6C 6F)"
@focus="editingCells.set(row.id + ':value', blobToHex(row.value as Uint8Array))"
@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')"
/>
<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>
@ -970,7 +1095,7 @@ function copyToClipboard(text: string) {
<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="blobUploadEntryId = row.id; blobUploadInput?.click()" title="上传文件" />
<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)">
@ -985,7 +1110,7 @@ function copyToClipboard(text: string) {
<el-button type="primary" link :icon="MoreFilled" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="blobUploadEntryId = row.id; blobUploadInput?.click()">上传文件</el-dropdown-item>
<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>
@ -998,6 +1123,14 @@ function copyToClipboard(text: string) {
</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"
@ -1131,11 +1264,19 @@ function copyToClipboard(text: string) {
/* Responsive action buttons */
.nvs-actions-mobile { display: none; }
@media (max-width: 640px) {
@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>