1
0
mirror of https://github.com/ZeroCatDev/Classworks.git synced 2025-08-31 20:29:23 +00:00

添加key查看器

This commit is contained in:
SunWuyuan 2025-08-29 21:24:21 +08:00
parent c744f37f39
commit cd10d0f49a
No known key found for this signature in database
GPG Key ID: A6A54CF66F56BB64
6 changed files with 826 additions and 13 deletions

View File

@ -0,0 +1,616 @@
<template>
<settings-card title="KV数据库管理" icon="mdi-database-edit" :loading="loading">
<v-list>
<!-- 数据库连接状态 -->
<v-list-item>
<template #prepend>
<v-icon :icon="connectionIcon" :color="connectionColor" class="mr-3" />
</template>
<v-list-item-title>数据库状态</v-list-item-title>
<v-list-item-subtitle>{{ connectionStatus }}</v-list-item-subtitle>
<template #append>
<v-btn variant="tonal" @click="refreshConnection" :loading="loading">
刷新
</v-btn>
</template>
</v-list-item>
<v-divider class="my-2" />
<!-- 数据列表 -->
<v-list-item>
<template #prepend>
<v-icon icon="mdi-format-list-bulleted" class="mr-3" />
</template>
<v-list-item-title>数据条目</v-list-item-title>
<v-list-item-subtitle> {{ kvData.length }} 条记录</v-list-item-subtitle>
<template #append>
<v-btn-group variant="tonal">
<v-btn @click="loadKvData" :loading="loadingData">
加载数据
</v-btn>
<v-btn @click="createNewItem" :disabled="!isKvProvider">
<v-icon icon="mdi-plus" class="mr-1" />
新建
</v-btn>
</v-btn-group>
</template>
</v-list-item>
</v-list>
<!-- 数据表格 -->
<v-card v-if="kvData.length > 0" class="mt-4" variant="outlined">
<v-card-title class="d-flex align-center">
<v-icon icon="mdi-table" class="mr-2" />
KV数据列表
<v-spacer />
<v-text-field
v-model="searchQuery"
label="搜索键名"
prepend-inner-icon="mdi-magnify"
variant="outlined"
density="compact"
hide-details
clearable
style="max-width: 300px;"
/>
</v-card-title>
<v-data-table
:headers="tableHeaders"
:items="filteredKvData"
:loading="loadingData"
item-value="key"
class="elevation-0"
:items-per-page="10"
>
<template #[`item.key`]="{ item }">
<code class="text-primary">{{ item.key }}</code>
</template>
<template #[`item.actions`]="{ item }">
<v-btn-group variant="text" density="compact">
<v-btn
icon="mdi-eye"
size="small"
@click="viewItem(item)"
title="查看"
/>
<v-btn
icon="mdi-pencil"
size="small"
@click="editItem(item)"
title="编辑"
/>
<v-btn
icon="mdi-delete"
size="small"
color="error"
@click="confirmDelete(item)"
title="删除"
/>
</v-btn-group>
</template>
</v-data-table>
</v-card>
<!-- 查看数据对话框 -->
<v-dialog v-model="viewDialog" max-width="800px">
<v-card>
<v-card-title class="d-flex align-center">
<v-icon icon="mdi-eye" class="mr-2" />
查看数据
<v-spacer />
<v-btn icon="mdi-close" variant="text" @click="viewDialog = false" />
</v-card-title>
<v-card-subtitle v-if="selectedItem">
键名: <code>{{ selectedItem.key }}</code>
</v-card-subtitle>
<v-card-text>
<v-textarea
v-if="selectedItem"
:model-value="formatJsonData(selectedItem.value)"
label="数据内容"
variant="outlined"
readonly
rows="15"
class="font-monospace"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="copyToClipboard(selectedItem?.value)" variant="tonal">
<v-icon icon="mdi-content-copy" class="mr-1" />
复制数据
</v-btn>
<v-btn @click="viewDialog = false" variant="text">
关闭
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 编辑数据对话框 -->
<v-dialog v-model="editDialog" max-width="800px">
<v-card>
<v-card-title class="d-flex align-center">
<v-icon icon="mdi-pencil" class="mr-2" />
编辑数据
<v-spacer />
<v-btn icon="mdi-close" variant="text" @click="closeEditDialog" />
</v-card-title>
<v-card-subtitle v-if="editingItem">
键名: <code>{{ editingItem.key }}</code>
</v-card-subtitle>
<v-card-text>
<v-textarea
v-model="editingData"
label="数据内容 (JSON格式)"
variant="outlined"
rows="15"
class="font-monospace"
:error="!isValidJson"
:error-messages="isValidJson ? [] : ['请输入有效的JSON格式']"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="closeEditDialog" variant="text">
取消
</v-btn>
<v-btn
@click="saveEditedData"
variant="tonal"
color="primary"
:disabled="!isValidJson"
:loading="savingData"
>
保存
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 新建数据对话框 -->
<v-dialog v-model="createDialog" max-width="800px">
<v-card>
<v-card-title class="d-flex align-center">
<v-icon icon="mdi-plus" class="mr-2" />
新建数据
<v-spacer />
<v-btn icon="mdi-close" variant="text" @click="closeCreateDialog" />
</v-card-title>
<v-card-text>
<v-text-field
v-model="newKey"
label="键名"
variant="outlined"
class="mb-4"
:error="!isValidKey"
:error-messages="isValidKey ? [] : ['键名不能为空且不能与现有键重复']"
placeholder="请输入键名my-config"
/>
<v-textarea
v-model="newData"
label="数据内容 (JSON格式)"
variant="outlined"
rows="15"
class="font-monospace"
:error="!isValidNewJson"
:error-messages="isValidNewJson ? [] : ['请输入有效的JSON格式']"
placeholder='请输入JSON数据{"name": "value"}'
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="closeCreateDialog" variant="text">
取消
</v-btn>
<v-btn
@click="saveNewData"
variant="tonal"
color="primary"
:disabled="!isValidKey || !isValidNewJson"
:loading="savingData"
>
创建
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 删除确认对话框 -->
<v-dialog v-model="deleteDialog" max-width="400px">
<v-card>
<v-card-title class="d-flex align-center text-error">
<v-icon icon="mdi-alert" class="mr-2" />
确认删除
</v-card-title>
<v-card-text>
确定要删除键名为 <code>{{ itemToDelete?.key }}</code> 的数据吗
<br><br>
<v-alert type="warning" variant="tonal" class="mt-2">
此操作不可撤销请谨慎操作
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn @click="deleteDialog = false" variant="text">
取消
</v-btn>
<v-btn
@click="deleteItem"
variant="tonal"
color="error"
:loading="deletingData"
>
删除
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</settings-card>
</template>
<script>
import SettingsCard from '@/components/SettingsCard.vue';
import dataProvider from '@/utils/dataProvider';
import { getSetting } from '@/utils/settings';
import { openDB } from 'idb';
export default {
name: 'KvDatabaseCard',
components: {
SettingsCard
},
data() {
return {
loading: false,
loadingData: false,
savingData: false,
deletingData: false,
kvData: [],
searchQuery: '',
//
viewDialog: false,
editDialog: false,
deleteDialog: false,
createDialog: false,
//
selectedItem: null,
editingItem: null,
itemToDelete: null,
//
editingData: '',
newKey: '',
newData: '',
//
tableHeaders: [
{ title: '键名', key: 'key', sortable: true },
{ title: '操作', key: 'actions', sortable: false, width: '120px' }
]
};
},
computed: {
currentProvider() {
return getSetting('server.provider');
},
isKvProvider() {
return this.currentProvider === 'kv-local' || this.currentProvider === 'kv-server'||this.currentProvider === 'classworkscloud'
},
connectionStatus() {
if (!this.isKvProvider) {
return '当前数据提供者不支持KV数据库管理';
}
return this.currentProvider === 'kv-local' ? '本地数据库' : '服务器数据库';
},
connectionIcon() {
if (!this.isKvProvider) return 'mdi-database-off';
return this.currentProvider === 'kv-local' ? 'mdi-database' : 'mdi-database-sync';
},
connectionColor() {
if (!this.isKvProvider) return 'error';
return 'success';
},
filteredKvData() {
if (!this.searchQuery) return this.kvData;
return this.kvData.filter(item =>
item.key.toLowerCase().includes(this.searchQuery.toLowerCase())
);
},
isValidJson() {
if (!this.editingData) return true;
try {
JSON.parse(this.editingData);
return true;
} catch {
return false;
}
},
isValidNewJson() {
if (!this.newData) return true;
try {
JSON.parse(this.newData);
return true;
} catch {
return false;
}
},
isValidKey() {
if (!this.newKey || this.newKey.trim() === '') return false;
//
return !this.kvData.some(item => item.key === this.newKey.trim());
}
},
async mounted() {
if (this.isKvProvider) {
await this.loadKvData();
}
},
methods: {
async refreshConnection() {
this.loading = true;
try {
//
await new Promise(resolve => setTimeout(resolve, 500));
this.$message.success('连接状态已刷新');
} catch (error) {
this.$message.error('刷新失败', error.message);
} finally {
this.loading = false;
}
},
async loadKvData() {
if (!this.isKvProvider) {
this.$message.warning('当前数据提供者不支持KV数据库管理');
return;
}
this.loadingData = true;
try {
this.kvData = [];
// 使loadKeys API
const result = await dataProvider.loadKeys({
sortBy: 'key',
sortDir: 'asc',
limit: 1000 //
});
if (result.success === false) {
throw new Error(result.error?.message || '获取键名列表失败');
}
//
this.kvData = result.keys.map(key => ({
key,
value: null, //
loaded: false //
}));
this.$message.success('键名加载完成', `共找到 ${this.kvData.length} 个键,总计 ${result.total_rows} 个键`);
} catch (error) {
this.$message.error('加载数据失败', error.message);
} finally {
this.loadingData = false;
}
},
async viewItem(item) {
this.selectedItem = item;
this.viewDialog = true;
//
if (!item.loaded || item.value === null) {
await this.loadItemData(item);
}
},
async editItem(item) {
this.editingItem = item;
//
if (!item.loaded || item.value === null) {
await this.loadItemData(item);
}
this.editingData = this.formatJsonData(item.value);
this.editDialog = true;
},
async loadItemData(item) {
try {
const data = await dataProvider.loadData(item.key);
if (data && data.success !== false) {
item.value = data;
item.loaded = true;
} else {
throw new Error('数据加载失败');
}
} catch (error) {
this.$message.error('加载数据失败', error.message);
item.value = null;
item.loaded = false;
}
},
closeEditDialog() {
this.editDialog = false;
this.editingItem = null;
this.editingData = '';
},
createNewItem() {
this.newKey = '';
this.newData = '{\n "example": "value"\n}';
this.createDialog = true;
},
closeCreateDialog() {
this.createDialog = false;
this.newKey = '';
this.newData = '';
},
async saveNewData() {
if (!this.isValidKey || !this.isValidNewJson) return;
this.savingData = true;
try {
const parsedData = JSON.parse(this.newData);
const key = this.newKey.trim();
const result = await dataProvider.saveData(key, parsedData);
if (result && !result.error) {
//
this.kvData.push({
key,
value: parsedData,
loaded: true
});
this.$message.success('数据创建成功');
this.closeCreateDialog();
} else {
throw new Error(result.error?.message || '创建失败');
}
} catch (error) {
this.$message.error('创建失败', error.message);
} finally {
this.savingData = false;
}
},
async saveEditedData() {
if (!this.isValidJson || !this.editingItem) return;
this.savingData = true;
try {
const parsedData = JSON.parse(this.editingData);
const result = await dataProvider.saveData(this.editingItem.key, parsedData);
if (result && !result.error) {
//
const index = this.kvData.findIndex(item => item.key === this.editingItem.key);
if (index !== -1) {
this.kvData[index].value = parsedData;
this.kvData[index].loaded = true;
}
this.$message.success('数据保存成功');
this.closeEditDialog();
} else {
throw new Error(result.error?.message || '保存失败');
}
} catch (error) {
this.$message.error('保存失败', error.message);
} finally {
this.savingData = false;
}
},
confirmDelete(item) {
this.itemToDelete = item;
this.deleteDialog = true;
},
async deleteItem() {
if (!this.itemToDelete) return;
this.deletingData = true;
try {
//
if (this.currentProvider === 'kv-local') {
const db = await openDB('ClassworksDB', 2);
const tx = db.transaction('kv', 'readwrite');
const store = tx.objectStore('kv');
await store.delete(this.itemToDelete.key);
} else {
// API
// KVnull
await dataProvider.saveData(this.itemToDelete.key, null);
}
//
const index = this.kvData.findIndex(item => item.key === this.itemToDelete.key);
if (index !== -1) {
this.kvData.splice(index, 1);
}
this.$message.success('数据删除成功');
this.deleteDialog = false;
this.itemToDelete = null;
} catch (error) {
this.$message.error('删除失败', error.message);
} finally {
this.deletingData = false;
}
},
formatJsonData(data) {
try {
return JSON.stringify(data, null, 2);
} catch {
return String(data);
}
},
async copyToClipboard(data) {
try {
const text = this.formatJsonData(data);
await navigator.clipboard.writeText(text);
this.$message.success('数据已复制到剪贴板');
} catch (error) {
this.$message.error('复制失败', error.message);
}
}
}
};
</script>
<style scoped>
.font-monospace {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
}
code {
background-color: rgba(0, 0, 0, 0.05);
padding: 2px 4px;
border-radius: 4px;
font-size: 0.875em;
}
</style>

View File

@ -1194,23 +1194,26 @@ export default {
);
}
//
try {
const subjectsResponse = await dataProvider.loadData("classworks-config-subject");
if (subjectsResponse && Array.isArray(subjectsResponse)) {
//
this.state.availableSubjects = subjectsResponse;
}
} catch (error) {
console.warn("Failed to load subject configuration:", error);
//
}
await this.loadSubjects();
} catch (error) {
console.error("加载配置失败:", error);
this.$message.error("加载配置失败", error.message);
}
},
async loadSubjects() {
try {
const subjectsResponse = await dataProvider.loadData("classworks-config-subject");
if (subjectsResponse && Array.isArray(subjectsResponse)) {
//
this.state.availableSubjects = subjectsResponse;
}
} catch (error) {
console.warn("Failed to load subject configuration:", error);
//
}
},
showSyncMessage() {
this.$message.success("数据已同步", "数据已完成与服务器同步");
},
@ -1358,7 +1361,7 @@ export default {
this.updateBackendUrl();
},
handleDateSelect(newDate) {
async handleDateSelect(newDate) {
if (!newDate) return;
try {
@ -1377,7 +1380,12 @@ export default {
query: { date: formattedDate },
})
.catch(() => {});
this.downloadData();
// Load both data and subjects in parallel
await Promise.all([
this.downloadData(),
this.loadSubjects()
]);
}
} catch (error) {
console.error("Date processing error:", error);

View File

@ -104,6 +104,7 @@
@saved="onSettingsSaved"
/>
<data-provider-settings-card border class="mt-4" />
<kv-database-card border class="mt-4" />
</v-tabs-window-item>
<v-tabs-window-item value="namespace">
@ -241,6 +242,7 @@ import NamespaceSettingsCard from "@/components/settings/cards/NamespaceSettings
import RandomPickerCard from "@/components/settings/cards/RandomPickerCard.vue";
import HomeworkTemplateCard from "@/components/settings/cards/HomeworkTemplateCard.vue";
import SubjectManagementCard from "@/components/settings/cards/SubjectManagementCard.vue";
import KvDatabaseCard from "@/components/settings/cards/KvDatabaseCard.vue";
export default {
name: "Settings",
components: {
@ -261,6 +263,7 @@ export default {
RandomPickerCard,
HomeworkTemplateCard,
SubjectManagementCard,
KvDatabaseCard,
},
setup() {
const { mobile } = useDisplay();

View File

@ -35,6 +35,57 @@ export default {
return kvLocalProvider.saveData(key, data);
}
},
/**
* 获取键名列表
* @param {Object} options - 查询选项
* @param {string} options.sortBy - 排序字段默认为 "key"
* @param {string} options.sortDir - 排序方向"asc" "desc"默认为 "asc"
* @param {number} options.limit - 每页返回的记录数默认为 100
* @param {number} options.skip - 跳过的记录数默认为 0
* @returns {Promise<Object>} 包含键名列表和分页信息的响应对象
*
* 使用示例:
* ```javascript
* // 获取前10个键名
* const result = await dataProvider.loadKeys({ limit: 10 });
* if (result.success !== false) {
* console.log('键名列表:', result.keys);
* console.log('总数:', result.total_rows);
* }
*
* // 获取第二页数据跳过前10个
* const page2 = await dataProvider.loadKeys({ limit: 10, skip: 10 });
*
* // 按键名降序排列
* const sorted = await dataProvider.loadKeys({ sortDir: 'desc' });
* ```
*
* 返回值格式:
* ```javascript
* {
* keys: ["key1", "key2", "key3"],
* total_rows: 150,
* current_page: {
* limit: 10,
* skip: 0,
* count: 10
* },
* load_more: "/api/kv/namespace/_keys?..." // 仅服务器模式
* }
* ```
*/
loadKeys: async (options = {}) => {
const provider = getSetting("server.provider");
const useServer =
provider === "kv-server" || provider === "classworkscloud";
if (useServer) {
return kvServerProvider.loadKeys(options);
} else {
return kvLocalProvider.loadKeys(options);
}
},
};
export const ErrorCodes = {
@ -43,5 +94,7 @@ export const ErrorCodes = {
SERVER_ERROR: "服务器错误",
SAVE_ERROR: "保存失败",
CONFIG_ERROR: "配置错误",
PERMISSION_DENIED: "无权限访问",
UNAUTHORIZED: "认证失败",
UNKNOWN_ERROR: "未知错误",
};

View File

@ -46,4 +46,72 @@ export const kvLocalProvider = {
return formatError("保存本地数据失败:" + error);
}
},
/**
* 获取本地存储的键名列表
* @param {Object} options - 查询选项
* @param {string} options.sortBy - 排序字段默认为 "key"
* @param {string} options.sortDir - 排序方向"asc" "desc"默认为 "asc"
* @param {number} options.limit - 每页返回的记录数默认为 100
* @param {number} options.skip - 跳过的记录数默认为 0
* @returns {Promise<Object>} 包含键名列表和分页信息的响应对象
*
* 返回值示例:
* {
* keys: ["key1", "key2", "key3"],
* total_rows: 150,
* current_page: {
* limit: 10,
* skip: 0,
* count: 10
* },
* load_more: null // 本地存储不需要分页URL
* }
*/
async loadKeys(options = {}) {
try {
const db = await initDB();
const transaction = db.transaction(["kv"], "readonly");
const store = transaction.objectStore("kv");
// 获取所有键名
const allKeys = await store.getAllKeys();
// 设置默认参数
const {
sortBy = "key",
sortDir = "asc",
limit = 100,
skip = 0
} = options;
// 排序键名(本地存储只支持按键名排序)
const sortedKeys = allKeys.sort((a, b) => {
if (sortDir === "desc") {
return b.localeCompare(a);
}
return a.localeCompare(b);
});
// 应用分页
const totalRows = sortedKeys.length;
const paginatedKeys = sortedKeys.slice(skip, skip + limit);
// 构建响应数据
const responseData = {
keys: paginatedKeys,
total_rows: totalRows,
current_page: {
limit,
skip,
count: paginatedKeys.length
},
load_more: null // 本地存储不需要分页URL
};
return formatResponse(responseData);
} catch (error) {
return formatError("获取本地键名列表失败:" + error.message);
}
},
};

View File

@ -157,4 +157,69 @@ export const kvServerProvider = {
);
}
},
/**
* 获取键名列表
* @param {Object} options - 查询选项
* @param {string} options.sortBy - 排序字段默认为 "key"
* @param {string} options.sortDir - 排序方向"asc" "desc"默认为 "asc"
* @param {number} options.limit - 每页返回的记录数默认为 100
* @param {number} options.skip - 跳过的记录数默认为 0
* @returns {Promise<Object>} 包含键名列表和分页信息的响应对象
*
* 返回值示例:
* {
* keys: ["key1", "key2", "key3"],
* total_rows: 150,
* current_page: {
* limit: 10,
* skip: 0,
* count: 10
* },
* load_more: "/api/kv/namespace/_keys?sortBy=key&sortDir=asc&limit=10&skip=10"
* }
*/
async loadKeys(options = {}) {
try {
const serverUrl = getSetting("server.domain");
const machineId = getSetting("device.uuid");
// 设置默认参数
const {
sortBy = "key",
sortDir = "asc",
limit = 100,
skip = 0
} = options;
// 构建查询参数
const params = new URLSearchParams({
sortBy,
sortDir,
limit: limit.toString(),
skip: skip.toString()
});
const res = await axios.get(`${serverUrl}/${machineId}/_keys?${params}`, {
headers: getHeaders(),
});
return formatResponse(res.data);
} catch (error) {
if (error.response?.status === 404) {
return formatError("命名空间不存在", "NOT_FOUND");
}
if (error.response?.status === 403) {
return formatError("无权限访问此命名空间", "PERMISSION_DENIED");
}
if (error.response?.status === 401) {
return formatError("认证失败", "UNAUTHORIZED");
}
console.log(error);
return formatError(
error.response?.data?.message || "获取键名列表失败",
"NETWORK_ERROR"
);
}
},
};