1
0
mirror of https://github.com/ZeroCatDev/Classworks.git synced 2025-07-03 01:39:22 +00:00
This commit is contained in:
SunWuyuan 2025-03-16 10:18:47 +08:00
parent 69c67a7e52
commit ec9f355f1b
No known key found for this signature in database
GPG Key ID: A6A54CF66F56BB64
7 changed files with 176 additions and 108 deletions

View File

@ -13,6 +13,7 @@
"@mdi/font": "7.4.47", "@mdi/font": "7.4.47",
"axios": "^1.7.7", "axios": "^1.7.7",
"idb": "^8.0.2", "idb": "^8.0.2",
"pinyin-pro": "^3.26.0",
"roboto-fontface": "*", "roboto-fontface": "*",
"vue": "^3.4.31", "vue": "^3.4.31",
"vue-masonry-wall": "^0.3.2", "vue-masonry-wall": "^0.3.2",

8
pnpm-lock.yaml generated
View File

@ -17,6 +17,9 @@ importers:
idb: idb:
specifier: ^8.0.2 specifier: ^8.0.2
version: 8.0.2 version: 8.0.2
pinyin-pro:
specifier: ^3.26.0
version: 3.26.0
roboto-fontface: roboto-fontface:
specifier: '*' specifier: '*'
version: 0.10.0 version: 0.10.0
@ -1279,6 +1282,9 @@ packages:
typescript: typescript:
optional: true optional: true
pinyin-pro@3.26.0:
resolution: {integrity: sha512-HcBZZb0pvm0/JkPhZHWA5Hqp2cWHXrrW/WrV+OtaYYM+kf35ffvZppIUuGmyuQ7gDr1JDJKMkbEE+GN0wfMoGg==}
pkg-types@1.2.1: pkg-types@1.2.1:
resolution: {integrity: sha512-sQoqa8alT3nHjGuTjuKgOnvjo4cljkufdtLMnO2LBP/wRwuDlo1tkaEdMxCRhyGRPacv/ztlZgDPm2b7FAmEvw==} resolution: {integrity: sha512-sQoqa8alT3nHjGuTjuKgOnvjo4cljkufdtLMnO2LBP/wRwuDlo1tkaEdMxCRhyGRPacv/ztlZgDPm2b7FAmEvw==}
@ -3019,6 +3025,8 @@ snapshots:
vue: 3.5.13 vue: 3.5.13
vue-demi: 0.14.10(vue@3.5.13) vue-demi: 0.14.10(vue@3.5.13)
pinyin-pro@3.26.0: {}
pkg-types@1.2.1: pkg-types@1.2.1:
dependencies: dependencies:
confbox: 0.1.8 confbox: 0.1.8

View File

@ -10,17 +10,23 @@
</template> </template>
<v-card-title class="text-h6">学生列表</v-card-title> <v-card-title class="text-h6">学生列表</v-card-title>
<template #append> <template #append>
<unsaved-warning <unsaved-warning :show="unsavedChanges" message="有未保存的更改" />
:show="unsavedChanges" <v-btn
message="有未保存的更改" prepend-icon="mdi-sort-alphabetical-variant"
/> variant="text"
class="mr-2"
@click="sortStudentsByPinyin"
:disabled="modelValue.list.length === 0"
>
按姓名首字母排序
</v-btn>
<v-btn <v-btn
:color="modelValue.advanced ? 'primary' : undefined" :color="modelValue.advanced ? 'primary' : undefined"
variant="text" variant="text"
prepend-icon="mdi-code-braces" prepend-icon="mdi-code-braces"
@click="toggleAdvanced" @click="toggleAdvanced"
> >
{{ modelValue.advanced ? '返回基础编辑' : '高级编辑' }} {{ modelValue.advanced ? "返回基础编辑" : "高级编辑" }}
</v-btn> </v-btn>
</template> </template>
</v-card-item> </v-card-item>
@ -33,13 +39,7 @@
class="mb-4" class="mb-4"
/> />
<v-alert <v-alert v-if="error" type="error" variant="tonal" closable class="mb-4">
v-if="error"
type="error"
variant="tonal"
closable
class="mb-4"
>
{{ error }} {{ error }}
</v-alert> </v-alert>
@ -83,7 +83,7 @@
<v-hover v-slot="{ isHovering, props }"> <v-hover v-slot="{ isHovering, props }">
<v-card <v-card
v-bind="props" v-bind="props"
:elevation="isMobile ? 1 : (isHovering ? 4 : 1)" :elevation="isMobile ? 1 : isHovering ? 4 : 1"
class="student-card" class="student-card"
border border
> >
@ -145,7 +145,10 @@
{{ student }} {{ student }}
</span> </span>
<div class="d-flex gap-1 action-buttons" :class="{ 'opacity-100': isHovering || isMobile }"> <div
class="d-flex gap-1 action-buttons"
:class="{ 'opacity-100': isHovering || isMobile }"
>
<v-btn <v-btn
icon="mdi-pencil" icon="mdi-pencil"
variant="text" variant="text"
@ -213,13 +216,14 @@
</template> </template>
<script> <script>
import UnsavedWarning from '../common/UnsavedWarning.vue' import UnsavedWarning from "../common/UnsavedWarning.vue";
import '@/styles/warnings.scss' import "@/styles/warnings.scss";
import { pinyin } from "pinyin-pro";
export default { export default {
name: 'StudentListCard', name: "StudentListCard",
components: { components: {
UnsavedWarning UnsavedWarning,
}, },
props: { props: {
modelValue: { modelValue: {
@ -227,27 +231,27 @@ export default {
required: true, required: true,
default: () => ({ default: () => ({
list: [], list: [],
text: '', text: "",
advanced: false advanced: false,
}) }),
}, },
loading: Boolean, loading: Boolean,
error: String, error: String,
isMobile: Boolean, isMobile: Boolean,
unsavedChanges: Boolean unsavedChanges: Boolean,
}, },
data() { data() {
return { return {
newStudentName: '', newStudentName: "",
editState: { editState: {
index: -1, index: -1,
name: '' name: "",
} },
} };
}, },
emits: ['update:modelValue', 'save', 'reload'], emits: ["update:modelValue", "save", "reload"],
computed: { computed: {
text: { text: {
@ -256,8 +260,8 @@ export default {
}, },
set(value) { set(value) {
this.handleTextInput(value); this.handleTextInput(value);
} },
} },
}, },
methods: { methods: {
@ -266,15 +270,15 @@ export default {
const advanced = !this.modelValue.advanced; const advanced = !this.modelValue.advanced;
this.updateModelValue({ this.updateModelValue({
advanced, advanced,
text: advanced ? this.modelValue.list.join('\n') : this.modelValue.text, text: advanced ? this.modelValue.list.join("\n") : this.modelValue.text,
list: this.modelValue.list list: this.modelValue.list,
}); });
}, },
updateModelValue(newData) { updateModelValue(newData) {
this.$emit('update:modelValue', { this.$emit("update:modelValue", {
...this.modelValue, ...this.modelValue,
...newData ...newData,
}); });
}, },
@ -286,16 +290,16 @@ export default {
const newList = [...this.modelValue.list, name]; const newList = [...this.modelValue.list, name];
this.updateModelValue({ this.updateModelValue({
list: newList, list: newList,
text: newList.join('\n') text: newList.join("\n"),
}); });
this.newStudentName = ''; this.newStudentName = "";
}, },
removeStudent(index) { removeStudent(index) {
const newList = this.modelValue.list.filter((_, i) => i !== index); const newList = this.modelValue.list.filter((_, i) => i !== index);
this.updateModelValue({ this.updateModelValue({
list: newList, list: newList,
text: newList.join('\n') text: newList.join("\n"),
}); });
}, },
@ -303,9 +307,9 @@ export default {
const newList = [...this.modelValue.list]; const newList = [...this.modelValue.list];
let targetIndex; let targetIndex;
if (direction === 'top') { if (direction === "top") {
targetIndex = 0; targetIndex = 0;
} else if (direction === 'up') { } else if (direction === "up") {
targetIndex = index - 1; targetIndex = index - 1;
} else { } else {
targetIndex = index + 1; targetIndex = index + 1;
@ -317,7 +321,7 @@ export default {
this.updateModelValue({ this.updateModelValue({
list: newList, list: newList,
text: newList.join('\n') text: newList.join("\n"),
}); });
} }
}, },
@ -336,9 +340,9 @@ export default {
this.updateModelValue({ this.updateModelValue({
list: newList, list: newList,
text: newList.join('\n') text: newList.join("\n"),
}); });
this.editState = { index: -1, name: '' }; this.editState = { index: -1, name: "" };
}, },
handleClick(index, student) { handleClick(index, student) {
@ -349,17 +353,30 @@ export default {
handleTextInput(value) { handleTextInput(value) {
const list = value const list = value
.split('\n') .split("\n")
.map(s => s.trim()) .map((s) => s.trim())
.filter(s => s); .filter((s) => s);
this.updateModelValue({ this.updateModelValue({
text: value, text: value,
list list,
}); });
} },
}
} sortStudentsByPinyin() {
const newList = [...this.modelValue.list].sort((a, b) => {
const pinyinA = pinyin(a, { toneType: "none" });
const pinyinB = pinyin(b, { toneType: "none" });
return pinyinA.localeCompare(pinyinB);
});
this.updateModelValue({
list: newList,
text: newList.join("\n"),
});
},
},
};
</script> </script>
<style scoped> <style scoped>
@ -379,7 +396,8 @@ export default {
} }
@keyframes pulse-warning { @keyframes pulse-warning {
0%, 100% { 0%,
100% {
border-color: rgba(var(--v-theme-warning), 1) !important; border-color: rgba(var(--v-theme-warning), 1) !important;
} }
50% { 50% {

View File

@ -1,8 +1,5 @@
<template> <template>
<settings-card <settings-card title="数据源设置" icon="mdi-database-cog">
title="数据源设置"
icon="mdi-database-cog"
>
<v-list> <v-list>
<!-- 服务器模式设置 --> <!-- 服务器模式设置 -->
<template v-if="currentProvider === 'server'"> <template v-if="currentProvider === 'server'">
@ -31,7 +28,9 @@
<v-icon icon="mdi-database" class="mr-3" /> <v-icon icon="mdi-database" class="mr-3" />
</template> </template>
<v-list-item-title>清除数据库缓存</v-list-item-title> <v-list-item-title>清除数据库缓存</v-list-item-title>
<v-list-item-subtitle>这将清除所有IndexedDB中的数据</v-list-item-subtitle> <v-list-item-subtitle
>这将清除所有IndexedDB中的数据</v-list-item-subtitle
>
<template #append> <template #append>
<v-btn <v-btn
color="error" color="error"
@ -49,11 +48,7 @@
</template> </template>
<v-list-item-title>导出数据库</v-list-item-title> <v-list-item-title>导出数据库</v-list-item-title>
<template #append> <template #append>
<v-btn <v-btn variant="tonal" size="small" @click="exportData">
variant="tonal"
size="small"
@click="exportData"
>
导出 导出
</v-btn> </v-btn>
</template> </template>
@ -68,8 +63,12 @@
<v-card-text>{{ confirmMessage }}</v-card-text> <v-card-text>{{ confirmMessage }}</v-card-text>
<v-card-actions> <v-card-actions>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn color="grey" variant="text" @click="confirmDialog = false">取消</v-btn> <v-btn color="grey" variant="text" @click="confirmDialog = false"
<v-btn color="error" variant="tonal" @click="handleConfirm">确认</v-btn> >取消</v-btn
>
<v-btn color="error" variant="tonal" @click="handleConfirm"
>确认</v-btn
>
</v-card-actions> </v-card-actions>
</v-card> </v-card>
</v-dialog> </v-dialog>
@ -77,54 +76,59 @@
</template> </template>
<script> <script>
import SettingsCard from '@/components/SettingsCard.vue'; import SettingsCard from "@/components/SettingsCard.vue";
import { getSetting } from '@/utils/settings'; import { getSetting } from "@/utils/settings";
import axios from "axios";
export default { export default {
name: 'DataProviderSettingsCard', name: "DataProviderSettingsCard",
components: { SettingsCard }, components: { SettingsCard },
data() { data() {
return { return {
loading: false, loading: false,
serverchecktime: {},
confirmDialog: false, confirmDialog: false,
confirmTitle: '', confirmTitle: "",
confirmMessage: '', confirmMessage: "",
confirmAction: null confirmAction: null,
}; };
}, },
computed: { computed: {
currentProvider() { currentProvider() {
return getSetting('server.provider'); return getSetting("server.provider");
} },
}, },
methods: { methods: {
async checkServerConnection() { async checkServerConnection() {
this.loading = true; this.loading = true;
this.serverchecktime = new Date();
try { try {
const domain = getSetting('server.domain'); const domain = getSetting("server.domain");
const response = await fetch(`${domain}`, { const response = await axios.get(`${domain}/api/test`, {
method: 'GET', method: "GET",
headers: { 'Accept': 'application/json' } headers: { Accept: "application/json" },
}); });
if (response.ok) { if (response.data.status === "success") {
this.$message.success('连接成功', '服务器连接正常'); this.$message.success(
"连接成功",
"服务器连接正常 延迟" + (new Date() - this.serverchecktime) + "ms"
);
} else { } else {
throw new Error('服务器响应异常'); throw new Error("服务器响应异常");
} }
} catch (error) { } catch (error) {
this.$message.error('连接失败', error.message || '无法连接到服务器'); this.$message.error("连接失败", error.message || "无法连接到服务器");
} finally { } finally {
this.loading = false; this.loading = false;
} }
}, },
confirmClearLocalStorage() { confirmClearLocalStorage() {
this.confirmTitle = '确认清除'; this.confirmTitle = "确认清除";
this.confirmMessage = '此操作将清除所有本地存储的数据,确定要继续吗?'; this.confirmMessage = "此操作将清除所有本地存储的数据,确定要继续吗?";
this.confirmAction = this.clearLocalStorage; this.confirmAction = this.clearLocalStorage;
this.confirmDialog = true; this.confirmDialog = true;
}, },
@ -132,35 +136,35 @@ export default {
clearLocalStorage() { clearLocalStorage() {
try { try {
localStorage.clear(); localStorage.clear();
this.$message.success('清除成功', '本地存储数据已清除'); this.$message.success("清除成功", "本地存储数据已清除");
this.confirmDialog = false; this.confirmDialog = false;
} catch (error) { } catch (error) {
this.$message.error('清除失败', error.message); this.$message.error("清除失败", error.message);
} }
}, },
confirmClearIndexedDB() { confirmClearIndexedDB() {
this.confirmTitle = '确认清除'; this.confirmTitle = "确认清除";
this.confirmMessage = '此操作将清除所有IndexedDB中的数据确定要继续吗'; this.confirmMessage = "此操作将清除所有IndexedDB中的数据确定要继续吗";
this.confirmAction = this.clearIndexedDB; this.confirmAction = this.clearIndexedDB;
this.confirmDialog = true; this.confirmDialog = true;
}, },
async clearIndexedDB() { async clearIndexedDB() {
try { try {
const DBName = 'HomeworkDB'; const DBName = "HomeworkDB";
// //
await window.indexedDB.deleteDatabase(DBName); await window.indexedDB.deleteDatabase(DBName);
this.$message.success('清除成功', '数据库缓存已清除'); this.$message.success("清除成功", "数据库缓存已清除");
this.confirmDialog = false; this.confirmDialog = false;
} catch (error) { } catch (error) {
this.$message.error('清除失败', error.message); this.$message.error("清除失败", error.message);
} }
}, },
async exportData() { async exportData() {
try { try {
const DBName = 'HomeworkDB'; const DBName = "HomeworkDB";
const data = { indexedDB: {} }; const data = { indexedDB: {} };
// //
@ -175,7 +179,7 @@ export default {
// //
for (const storeName of stores) { for (const storeName of stores) {
const transaction = db.transaction(storeName, 'readonly'); const transaction = db.transaction(storeName, "readonly");
const store = transaction.objectStore(storeName); const store = transaction.objectStore(storeName);
// //
@ -189,19 +193,21 @@ export default {
} }
// //
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement("a");
const timestamp = new Date().toISOString().split('T')[0]; const timestamp = new Date().toISOString().split("T")[0];
a.href = url; a.href = url;
a.download = `homework-indexeddb-${timestamp}.json`; a.download = `homework-indexeddb-${timestamp}.json`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
this.$message.success('导出成功', 'IndexedDB数据已导出'); this.$message.success("导出成功", "IndexedDB数据已导出");
} catch (error) { } catch (error) {
console.error('导出失败:', error); console.error("导出失败:", error);
this.$message.error('导出失败', error.message || '无法导出数据库数据'); this.$message.error("导出失败", error.message || "无法导出数据库数据");
} }
}, },
@ -209,7 +215,7 @@ export default {
if (this.confirmAction) { if (this.confirmAction) {
this.confirmAction(); this.confirmAction();
} }
} },
} },
}; };
</script> </script>

View File

@ -79,13 +79,39 @@ export default {
}, },
}, },
methods: { methods: {
save() { async save() {
Object.entries(this.localSettings).forEach(([key, value]) => { try {
setSetting(`server.${key}`, value); //
}); Object.entries(this.localSettings).forEach(([key, value]) => {
this.originalSettings = { ...this.localSettings }; const success = setSetting(`server.${key}`, value);
this.$emit("saved"); if (!success) {
window.location.reload(); throw new Error(`保存设置 ${key} 失败`);
}
});
//
if (this.localSettings.provider === 'server' && this.localSettings.domain) {
const testUrl = `${this.localSettings.domain.replace(/\/$/, '')}/api/test`;
try {
const response = await fetch(testUrl);
if (!response.ok) {
throw new Error('服务器连接测试失败');
}
} catch (error) {
throw new Error('无法连接到服务器,请检查域名设置');
}
}
this.originalSettings = { ...this.localSettings };
this.$emit('saved');
//
setTimeout(() => {
//window.location.reload();
}, 1000);
} catch (error) {
this.$message?.error('保存失败', error.message);
}
}, },
reset() { reset() {
this.localSettings = { ...this.originalSettings }; this.localSettings = { ...this.originalSettings };

View File

@ -216,7 +216,6 @@ export default {
settings, settings,
dataProviders: [ dataProviders: [
{ title: '服务器', value: 'server' }, { title: '服务器', value: 'server' },
{ title: '本地存储', value: 'localStorage' },
{ title:'本地数据库',value:'indexedDB'} { title:'本地数据库',value:'indexedDB'}
], ],
studentData: { studentData: {

View File

@ -102,7 +102,18 @@ const settingsDefinitions = {
"server.domain": { "server.domain": {
type: "string", type: "string",
default: "", default: "",
validate: (value) => !value || /^https?:\/\//.test(value), validate: value => {
// 如果不是服务器模式或值为空,直接通过
if (!value) return true;
// 验证URL格式
try {
new URL(value);
return true;
} catch (e) {
console.error('域名格式无效:', e);
return false;
}
},
description: "后端服务器域名", description: "后端服务器域名",
}, },
"server.classNumber": { "server.classNumber": {
@ -116,8 +127,7 @@ const settingsDefinitions = {
"server.provider": { "server.provider": {
type: "string", type: "string",
default: "indexedDB", default: "indexedDB",
validate: (value) => validate: (value) => ["server", "indexedDB"].includes(value),
["server", "indexedDB"].includes(value),
description: "数据提供者,用于决定数据存储方式", description: "数据提供者,用于决定数据存储方式",
}, },