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

458 lines
16 KiB
Vue

<script setup lang="ts">
import { ref, computed } from 'vue';
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 });
const statusMessage = ref('');
const statusType = ref<'success' | 'error' | 'info'>('info');
// 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') {
statusMessage.value = msg;
statusType.value = type;
setTimeout(() => { statusMessage.value = ''; }, 4000);
}
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>
<!-- 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>
<!-- ── 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>