feat: Refactor data migration functionality and introduce cloud migration dialog

- Removed the DataMigration.vue component and integrated its functionality into KvDatabaseCard.vue.
- Added a new CloudMigrationDialog.vue component for handling cloud data migration.
- Updated KvDatabaseCard.vue to include a button for initiating local migration and to manage the visibility of the new CloudMigrationDialog.
- Cleaned up the DataProviderSettingsCard.vue by removing old data migration UI elements.
- Enhanced user experience by providing a more streamlined migration process with clear category selection and progress indication.
This commit is contained in:
Sunwuyuan
2025-12-06 21:32:22 +08:00
parent d50788c1f5
commit 1831c9144d
7 changed files with 298 additions and 1294 deletions
+3 -2
View File
@@ -1,12 +1,13 @@
<template>
<a
aria-label="浙ICP备2024068645号"
aria-label="xICP备x号"
class="floating-icp-link"
href="https://beian.miit.gov.cn/"
rel="noopener noreferrer"
target="_blank"
style="display: none;"
>
浙ICP备2024068645号
xICP备x号
</a>
</template>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,283 @@
<template>
<v-dialog v-model="dialog" max-width="600" scrollable>
<v-card>
<v-card-title>迁移到云端</v-card-title>
<v-card-text style="height: 400px;">
<div v-if="loading" class="d-flex justify-center align-center fill-height">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
</div>
<div v-else-if="keys.length === 0" class="d-flex justify-center align-center fill-height">
没有找到本地数据
</div>
<div v-else>
<!-- Category Selection -->
<v-list select-strategy="classic" class="mb-4">
<v-list-subheader>选择数据类型</v-list-subheader>
<v-list-item
v-for="category in categories"
:key="category.id"
@click="toggleCategory(category)"
>
<template v-slot:prepend>
<v-checkbox-btn
:model-value="getCategoryState(category)"
:indeterminate="getCategoryIndeterminate(category)"
@click.stop="toggleCategory(category)"
></v-checkbox-btn>
</template>
<v-list-item-title>{{ category.label }}</v-list-item-title>
<v-list-item-subtitle>{{ category.description }} ({{ getCategoryCount(category) }} 项)</v-list-item-subtitle>
</v-list-item>
</v-list>
<v-divider class="mb-4"></v-divider>
<!-- Individual Keys Expansion -->
<v-expansion-panels>
<v-expansion-panel title="详细数据列表">
<v-expansion-panel-text>
<v-list select-strategy="classic" density="compact">
<v-list-item
v-for="key in keys"
:key="key"
:value="key"
>
<template v-slot:prepend>
<v-checkbox-btn v-model="selectedKeys" :value="key"></v-checkbox-btn>
</template>
<v-list-item-title>{{ key }}</v-list-item-title>
</v-list-item>
</v-list>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
</v-card-text>
<v-divider></v-divider>
<v-card-actions>
<div class="text-caption ml-4 text-medium-emphasis">
已选择 {{ selectedKeys.length }} 项
</div>
<v-spacer></v-spacer>
<v-btn variant="text" @click="dialog = false">取消</v-btn>
<v-btn color="primary" @click="migrate" :loading="migrating" :disabled="selectedKeys.length === 0">
开始迁移
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Result Dialog -->
<v-dialog v-model="resultDialog" max-width="500">
<v-card>
<v-card-title>迁移结果</v-card-title>
<v-card-text>
<div v-if="result">
<p>总计: {{ result.summary.total }}</p>
<p>成功: {{ result.summary.successful }}</p>
<p>失败: {{ result.summary.failed }}</p>
</div>
<div v-else-if="error">
<p class="text-error">{{ error }}</p>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" @click="resultDialog = false">关闭</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import { ref, watch, computed } from 'vue';
import { kvLocalProvider } from '@/utils/providers/kvLocalProvider';
import { getSetting } from '@/utils/settings';
import axios from '@/axios/axios';
const props = defineProps({
modelValue: Boolean,
});
const emit = defineEmits(['update:modelValue']);
const dialog = ref(false);
const loading = ref(false);
const migrating = ref(false);
const keys = ref([]);
const selectedKeys = ref([]);
const resultDialog = ref(false);
const result = ref(null);
const error = ref(null);
const categories = [
{
id: 'student-list',
label: '学生列表',
description: 'classworks-list-main',
matcher: (key) => key === 'classworks-list-main' || key.startsWith('classworks-list-main')
},
{
id: 'homework-data',
label: '作业数据',
description: 'classworks-data-*',
matcher: (key) => key.startsWith('classworks-data-')
},
{
id: 'lists',
label: '列表',
description: 'classworks-list-*',
matcher: (key) => key.startsWith('classworks-list-')
},
{
id: 'other',
label: '其他',
description: '所有其他键',
matcher: (key) => !key.startsWith('classworks-data-') && !key.startsWith('classworks-list-')
}
];
watch(() => props.modelValue, (val) => {
dialog.value = val;
if (val) {
loadKeys();
}
});
watch(dialog, (val) => {
emit('update:modelValue', val);
});
const loadKeys = async () => {
loading.value = true;
try {
const res = await kvLocalProvider.loadKeys({ limit: 1000 }); // Load many keys
keys.value = res.keys || [];
selectedKeys.value = [];
} catch (e) {
console.error(e);
} finally {
loading.value = false;
}
};
const getCategoryKeys = (category) => {
return keys.value.filter(category.matcher);
};
const getCategoryCount = (category) => {
return getCategoryKeys(category).length;
};
const getCategoryState = (category) => {
const catKeys = getCategoryKeys(category);
if (catKeys.length === 0) return false;
const selectedCount = catKeys.filter(k => selectedKeys.value.includes(k)).length;
return selectedCount === catKeys.length;
};
const getCategoryIndeterminate = (category) => {
const catKeys = getCategoryKeys(category);
if (catKeys.length === 0) return false;
const selectedCount = catKeys.filter(k => selectedKeys.value.includes(k)).length;
return selectedCount > 0 && selectedCount < catKeys.length;
};
const toggleCategory = (category) => {
const catKeys = getCategoryKeys(category);
if (catKeys.length === 0) return;
const currentState = getCategoryState(category);
const newSelectedKeys = new Set(selectedKeys.value);
if (currentState) {
// Unselect
catKeys.forEach(k => newSelectedKeys.delete(k));
} else {
// Select
catKeys.forEach(k => newSelectedKeys.add(k));
}
selectedKeys.value = Array.from(newSelectedKeys);
};
const migrate = async () => {
migrating.value = true;
error.value = null;
result.value = null;
try {
const batchData = {};
for (const key of selectedKeys.value) {
const res = await kvLocalProvider.loadData(key);
if (res) {
// kvLocalProvider.loadData returns formatResponse(JSON.parse(data)) which is just the data object if successful?
// Let's check formatResponse in dataProvider.js: export const formatResponse = (data) => data;
// So it returns the data directly?
// Wait, kvLocalProvider.js: return formatResponse(JSON.parse(data));
// But if error: return formatError(...) which returns { success: false, error: ... }
// formatResponse is just identity function.
// So if success, it returns the object.
// But wait, formatError returns an object with success: false.
// If success, it returns the data object directly?
// Let's re-read dataProvider.js
// export const formatResponse = (data) => data;
// export const formatError = (message, code = "UNKNOWN_ERROR") => ({ success: false, error: {code, message} });
// So if successful, it returns the data object.
// If failed, it returns { success: false, ... }
// This is a bit ambiguous if the data object itself has a success property.
// But assuming the data is what we want.
// However, looking at kvLocalProvider.js:
// if (!data) return formatError(...)
// return formatResponse(JSON.parse(data))
// So if I get an object that has success: false, it might be an error.
// But usually data is just the stored object.
if (res && res.success === false && res.error) {
console.warn(`Skipping key ${key} due to load error`, res.error);
continue;
}
batchData[key] = res;
}
}
const serverUrl = getSetting("server.domain");
const token = getSetting("server.kvToken");
if (!serverUrl || !token) {
throw new Error("请先配置服务器地址和 Token");
}
// Remove trailing slash if present
const baseUrl = serverUrl.replace(/\/$/, '');
const response = await axios.post(`${baseUrl}/kv/_batchimport`, batchData, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (response.data && response.data.code === 200) {
result.value = response.data.data;
resultDialog.value = true;
dialog.value = false;
} else {
throw new Error(response.data?.message || "迁移失败");
}
} catch (e) {
console.error(e);
error.value = e.response?.data?.message || e.message || "发生未知错误";
resultDialog.value = true;
} finally {
migrating.value = false;
}
};
</script>
@@ -53,33 +53,7 @@
</template>
</v-list-item>
</template>
<v-list-item>
<template #prepend>
<v-icon class="mr-3" icon="mdi-database-import"/>
</template>
<v-list-item-title>迁移旧数据</v-list-item-title>
<v-list-item-subtitle
>将旧的存储格式数据转移到新的KV存储
</v-list-item-subtitle
>
<template #append>
<v-btn :loading="migrateLoading" variant="tonal" @click="migrateData">
迁移
</v-btn>
</template>
</v-list-item
><!-- 显示机器ID -->
<v-list-item>
<template #prepend>
<v-icon class="mr-3" icon="mdi-identifier"/>
</template>
<v-list-item-title>本机唯一标识符</v-list-item-title>
<v-list-item-subtitle v-if="machineId">{{
machineId
}}
</v-list-item-subtitle>
<v-list-item-subtitle v-else>正在加载...</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<template #prepend>
<v-icon class="mr-3" icon="mdi-lan-connect"/>
@@ -33,6 +33,10 @@
<v-icon class="mr-1" icon="mdi-plus"/>
新建
</v-btn>
<v-btn @click="showMigrationDialog = true">
<v-icon class="mr-1" icon="mdi-cloud-upload"/>
从本地迁移
</v-btn>
</v-btn-group>
</template>
</v-list-item>
@@ -361,11 +365,14 @@
</v-card-actions>
</v-card>
</v-dialog>
<cloud-migration-dialog v-model="showMigrationDialog" />
</settings-card>
</template>
<script>
import SettingsCard from '@/components/SettingsCard.vue';
import CloudMigrationDialog from '../CloudMigrationDialog.vue';
import dataProvider from '@/utils/dataProvider';
import {getSetting} from '@/utils/settings';
import {openDB} from 'idb';
@@ -373,7 +380,8 @@ import {openDB} from 'idb';
export default {
name: 'KvDatabaseCard',
components: {
SettingsCard
SettingsCard,
CloudMigrationDialog
},
data() {
@@ -391,6 +399,7 @@ export default {
deleteDialog: false,
createDialog: false,
cloudUrlDialog: false,
showMigrationDialog: false,
// 选中的项目
selectedItem: null,