yunsi-toolbox-vue/components/partition-table-editor/PartitionTableEditor.vue

443 lines
15 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 } from 'vue';
import { ElMessage } from 'element-plus';
import {
type PartitionTable, type PartitionEntry,
PartitionType, PartitionFlags,
TYPE_NAMES, NAME_TO_TYPE,
getSubtypeName, subtypeFromName,
AppSubtype, DataSubtype,
APP_SUBTYPE_NAMES, DATA_SUBTYPE_NAMES,
parseBinary, serializeBinary,
parseCsv, serializeCsv,
validateTable,
NAME_FIELD_SIZE,
} from '../../lib/partition-table';
const props = defineProps<{
isDark?: boolean;
}>();
// ── Core state ─────────────────────────────────────────────────────
const table = ref<PartitionTable>({ entries: [], md5Valid: false });
// Add dialog
const showAddDialog = ref(false);
const newEntry = ref<PartitionEntry>({
name: '',
type: PartitionType.DATA,
subtype: DataSubtype.NVS,
offset: 0,
size: 0x1000,
flags: PartitionFlags.NONE,
});
// ── Computed ───────────────────────────────────────────────────────
const subtypeOptions = computed(() => {
return newEntry.value.type === PartitionType.APP
? Object.entries(APP_SUBTYPE_NAMES)
: Object.entries(DATA_SUBTYPE_NAMES);
});
function getSubtypeOptionsForType(type: PartitionType) {
return type === PartitionType.APP
? Object.entries(APP_SUBTYPE_NAMES)
: Object.entries(DATA_SUBTYPE_NAMES);
}
// ── Helpers ────────────────────────────────────────────────────────
function showStatus(msg: string, type: 'success' | 'error' | 'info' = 'info') {
ElMessage({ message: msg, type, duration: 4000, showClose: true });
}
function formatHex(val: number): string {
return '0x' + val.toString(16).toUpperCase();
}
function formatSize(size: number): string {
if (size >= 1024 * 1024 && size % (1024 * 1024) === 0) {
return `${size / (1024 * 1024)} MB`;
}
if (size >= 1024 && size % 1024 === 0) {
return `${size / 1024} KB`;
}
return `${size} B`;
}
function parseHexOrDec(str: string): number {
str = str.trim();
if (str.startsWith('0x') || str.startsWith('0X')) {
if (!/^0x[0-9a-f]+$/i.test(str)) throw new Error(`"${str}"`);
const v = parseInt(str, 16);
if (isNaN(v) || v < 0 || v > 0xFFFF_FFFF) throw new Error(`"${str}"`);
return v;
}
// Support K/M suffixes
const match = str.match(/^(\d+(?:\.\d+)?)\s*([KkMm])?$/);
if (match) {
const num = parseFloat(match[1]);
const unit = (match[2] || '').toUpperCase();
let result: number;
if (unit === 'K') result = Math.floor(num * 1024);
else if (unit === 'M') result = Math.floor(num * 1024 * 1024);
else result = Math.floor(num);
if (result > 0xFFFF_FFFF) throw new Error(`"${str}" (超出 32 位范围)`);
return result;
}
throw new Error(`"${str}"`);
}
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);
}
// ── Actions ────────────────────────────────────────────────────────
function handleAddEntry() {
if (!newEntry.value.name) return;
table.value = {
...table.value,
entries: [...table.value.entries, { ...newEntry.value }],
};
showAddDialog.value = false;
newEntry.value = {
name: '',
type: PartitionType.DATA,
subtype: DataSubtype.NVS,
offset: 0,
size: 0x1000,
flags: PartitionFlags.NONE,
};
showStatus('已添加分区', 'success');
}
function handleDeleteEntry(index: number) {
const entries = [...table.value.entries];
entries.splice(index, 1);
table.value = { ...table.value, entries };
}
function handleMoveUp(index: number) {
if (index <= 0) return;
const entries = [...table.value.entries];
[entries[index - 1], entries[index]] = [entries[index], entries[index - 1]];
table.value = { ...table.value, entries };
}
function handleMoveDown(index: number) {
if (index >= table.value.entries.length - 1) return;
const entries = [...table.value.entries];
[entries[index], entries[index + 1]] = [entries[index + 1], entries[index]];
table.value = { ...table.value, entries };
}
function handleUpdateName(index: number, val: string) {
const entries = [...table.value.entries];
entries[index] = { ...entries[index], name: val.substring(0, NAME_FIELD_SIZE - 1) };
table.value = { ...table.value, entries };
}
function handleUpdateType(index: number, val: PartitionType) {
const entries = [...table.value.entries];
// Reset subtype when type changes
const defaultSubtype = val === PartitionType.APP ? AppSubtype.FACTORY : DataSubtype.NVS;
entries[index] = { ...entries[index], type: val, subtype: defaultSubtype };
table.value = { ...table.value, entries };
}
function handleUpdateSubtype(index: number, val: number) {
const entries = [...table.value.entries];
entries[index] = { ...entries[index], subtype: val };
table.value = { ...table.value, entries };
}
function handleUpdateOffset(index: number, val: string) {
try {
const entries = [...table.value.entries];
entries[index] = { ...entries[index], offset: parseHexOrDec(val) };
table.value = { ...table.value, entries };
} catch (e: any) {
showStatus(`无效的偏移量: ${e.message}`, 'error');
}
}
function handleUpdateSize(index: number, val: string) {
try {
const entries = [...table.value.entries];
entries[index] = { ...entries[index], size: parseHexOrDec(val) };
table.value = { ...table.value, entries };
} catch (e: any) {
showStatus(`无效的大小: ${e.message}`, 'error');
}
}
function handleUpdateFlags(index: number, encrypted: boolean) {
const entries = [...table.value.entries];
const cur = entries[index].flags;
entries[index] = { ...entries[index], flags: encrypted
? cur | PartitionFlags.ENCRYPTED
: cur & ~PartitionFlags.ENCRYPTED };
table.value = { ...table.value, entries };
}
function handleClear() {
table.value = { entries: [], md5Valid: false };
showStatus('已清空分区表', 'info');
}
// ── File I/O ───────────────────────────────────────────────────────
async function handleOpenBinary(file: File): Promise<false> {
try {
const buffer = await file.arrayBuffer();
table.value = parseBinary(new Uint8Array(buffer));
const md5Status = table.value.md5Valid ? '有效' : '无效/缺失';
const corruptedNote = table.value.corrupted ? ', 检测到二进制损坏' : '';
showStatus(
`已加载 ${file.name} (${table.value.entries.length} 分区, MD5: ${md5Status}${corruptedNote})`,
table.value.corrupted ? 'error' : 'success',
);
} catch (e: any) {
showStatus(`加载失败: ${e.message}`, 'error');
}
return false;
}
function handleExportBinary() {
const errors = validateTable(table.value);
if (errors.length > 0) {
const suffix = errors.length > 1 ? `${errors.length} 个问题` : '';
showStatus(`导出取消: ${errors[0].message}${suffix}`, 'error');
return;
}
try {
const data = serializeBinary(table.value);
downloadBlob(new Blob([data as Uint8Array<ArrayBuffer>]), 'partitions.bin');
showStatus('已导出 partitions.bin', 'success');
} catch (e: any) {
showStatus(`导出失败: ${e.message}`, 'error');
}
}
async function handleOpenCsv(file: File): Promise<false> {
try {
const text = await file.text();
const warnings: string[] = [];
table.value = parseCsv(text, (line, msg) => warnings.push(`${line}: ${msg}`));
if (warnings.length > 0) {
const preview = warnings.slice(0, 2).join('; ') + (warnings.length > 2 ? '…' : '');
showStatus(`已加载 ${file.name} (${table.value.entries.length} 分区,${warnings.length} 个警告: ${preview})`, 'error');
} else {
showStatus(`已加载 ${file.name} (${table.value.entries.length} 分区)`, 'success');
}
} catch (e: any) {
showStatus(`加载失败: ${e.message}`, 'error');
}
return false;
}
function handleExportCsv() {
try {
const text = serializeCsv(table.value);
downloadBlob(new Blob([text], { type: 'text/csv;charset=utf-8' }), 'partitions.csv');
showStatus('已导出 partitions.csv', 'success');
} catch (e: any) {
showStatus(`导出失败: ${e.message}`, 'error');
}
}
</script>
<template>
<div>
<!-- Toolbar -->
<div class="flex flex-wrap items-center gap-2 mb-3">
<el-button type="primary" @click="showAddDialog = true">添加分区</el-button>
<el-button type="danger" plain @click="handleClear">清空</el-button>
<el-divider direction="vertical" />
<el-text size="small">{{ table.entries.length }} 个分区</el-text>
</div>
<!-- Data table -->
<el-table
:data="table.entries"
border
stripe
size="small"
empty-text="暂无分区请添加或导入"
max-height="500"
>
<el-table-column label="名称" width="160">
<template #default="{ row, $index }">
<el-input
:model-value="row.name"
size="small"
:maxlength="15"
@change="(val: string) => handleUpdateName($index, val)"
/>
</template>
</el-table-column>
<el-table-column label="类型" width="110">
<template #default="{ row, $index }">
<el-select
:model-value="row.type"
size="small"
@change="(val: PartitionType) => handleUpdateType($index, val)"
>
<el-option label="app" :value="PartitionType.APP" />
<el-option label="data" :value="PartitionType.DATA" />
</el-select>
</template>
</el-table-column>
<el-table-column label="子类型" width="140">
<template #default="{ row, $index }">
<el-select
:model-value="row.subtype"
size="small"
@change="(val: number) => handleUpdateSubtype($index, val)"
>
<el-option
v-for="[val, name] in getSubtypeOptionsForType(row.type)"
:key="val"
:label="name"
:value="Number(val)"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="偏移量" width="140">
<template #default="{ row, $index }">
<el-input
:model-value="formatHex(row.offset)"
size="small"
@change="(val: string) => handleUpdateOffset($index, val)"
/>
</template>
</el-table-column>
<el-table-column label="大小" width="140">
<template #default="{ row, $index }">
<el-input
:model-value="formatHex(row.size)"
size="small"
@change="(val: string) => handleUpdateSize($index, val)"
>
<template #append>
<span class="text-xs">{{ formatSize(row.size) }}</span>
</template>
</el-input>
</template>
</el-table-column>
<el-table-column label="加密" width="70" align="center">
<template #default="{ row, $index }">
<el-checkbox
:model-value="(row.flags & 0x01) !== 0"
@change="(val: boolean) => handleUpdateFlags($index, val)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<template #default="{ $index }">
<el-button size="small" text @click="handleMoveUp($index)" :disabled="$index === 0">上</el-button>
<el-button size="small" text @click="handleMoveDown($index)" :disabled="$index === table.entries.length - 1">下</el-button>
<el-popconfirm title="确定删除?" @confirm="handleDeleteEntry($index)">
<template #reference>
<el-button size="small" text type="danger">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
<!-- ── Import/Export section ── -->
<el-divider />
<div class="flex flex-wrap gap-4">
<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>
</div>
</div>
<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>
</div>
</div>
</div>
<!-- ── Add partition dialog ── -->
<el-dialog v-model="showAddDialog" title="添加分区" width="450px">
<el-form label-width="80px">
<el-form-item label="名称">
<el-input v-model="newEntry.name" :maxlength="15" placeholder="partition name" />
</el-form-item>
<el-form-item label="类型">
<el-select v-model="newEntry.type">
<el-option label="app" :value="PartitionType.APP" />
<el-option label="data" :value="PartitionType.DATA" />
</el-select>
</el-form-item>
<el-form-item label="子类型">
<el-select v-model="newEntry.subtype">
<el-option
v-for="[val, name] in subtypeOptions"
:key="val"
:label="name"
:value="Number(val)"
/>
</el-select>
</el-form-item>
<el-form-item label="偏移量">
<el-input
:model-value="formatHex(newEntry.offset)"
@change="(val: string) => { try { newEntry.offset = parseHexOrDec(val) } catch { showStatus('无效的偏移量', 'error') } }"
placeholder="0x9000"
/>
</el-form-item>
<el-form-item label="大小">
<el-input
:model-value="formatHex(newEntry.size)"
@change="(val: string) => { try { newEntry.size = parseHexOrDec(val) } catch { showStatus('无效的大小', 'error') } }"
placeholder="0x6000 or 24K"
/>
</el-form-item>
<el-form-item label="加密">
<el-checkbox
:model-value="(newEntry.flags & PartitionFlags.ENCRYPTED) !== 0"
@change="(val: boolean) => newEntry.flags = val
? newEntry.flags | PartitionFlags.ENCRYPTED
: newEntry.flags & ~PartitionFlags.ENCRYPTED"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showAddDialog = false">取消</el-button>
<el-button type="primary" @click="handleAddEntry" :disabled="!newEntry.name">添加</el-button>
</template>
</el-dialog>
</div>
</template>