This commit is contained in:
干冰DryIce
2025-11-02 12:09:46 +08:00
31 changed files with 3677 additions and 496 deletions
+42 -16
View File
@@ -3,24 +3,38 @@
<v-row>
<v-col cols="12">
<div class="d-flex align-center mb-6">
<v-icon size="x-large" color="primary" class="mr-3"
>mdi-database-sync</v-icon
<v-icon
size="x-large"
color="primary"
class="mr-3"
>
mdi-database-sync
</v-icon>
<div>
<h1 class="text-h4">数据迁移工具</h1>
<h1 class="text-h4">
数据迁移工具
</h1>
<div class="text-subtitle-1 text-grey">
将现有数据迁移至 KV 存储系统
</div>
</div>
</div>
<v-card class="mb-6" variant="tonal" color="info" density="compact">
<v-card
class="mb-6"
variant="tonal"
color="info"
>
<v-card-text class="d-flex align-center">
<v-icon color="info" class="mr-2">mdi-information-outline</v-icon>
<span
>使用此工具可以将数据从旧存储系统迁移到新的 KV
存储系统,选择本地或云端迁移,以确保数据不会丢失。</span
<v-icon
color="info"
class="mr-2"
>
mdi-information-outline
</v-icon>
<span>
使用此工具可以将数据从旧存储系统迁移到新的 KV 存储系统,选择本地或云端迁移,以确保数据不会丢失。
</span>
</v-card-text>
</v-card>
@@ -29,12 +43,20 @@
</v-row>
<!-- 一键迁移对话框 -->
<v-dialog v-model="showMigrationDialog" max-width="500" persistent>
<v-dialog
v-model="showMigrationDialog"
max-width="500"
persistent
>
<v-card>
<v-card-title class="text-h5 d-flex align-center">
<v-icon color="primary" size="large" class="mr-3"
>mdi-database-sync</v-icon
<v-icon
color="primary"
size="large"
class="mr-3"
>
mdi-database-sync
</v-icon>
一键数据迁移
</v-card-title>
<v-card-text class="mt-4">
@@ -45,8 +67,7 @@
<v-alert
color="info"
variant="outlined"
density="compact"
variant="tonal"
class="mt-4"
icon="mdi-information-outline"
>
@@ -62,7 +83,7 @@
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-spacer />
<v-btn
color="grey-darken-1"
variant="text"
@@ -74,11 +95,16 @@
color="primary"
size="large"
variant="elevated"
@click="startAutoMigration"
:loading="isAutoMigrating"
:disabled="isAutoMigrating"
@click="startAutoMigration"
>
<v-icon left class="mr-2">mdi-database-export</v-icon>
<v-icon
left
class="mr-2"
>
mdi-database-export
</v-icon>
开始一键迁移
</v-btn>
</v-card-actions>
+2 -3
View File
@@ -72,9 +72,9 @@
md="6"
>
<v-card>
<v-card-title>KvInitialize 预览</v-card-title>
<v-card-title>初始化组件已替换</v-card-title>
<v-card-text>
<kv-initialize />
已迁移为首页内联的 InitServiceChooser 组件。
</v-card-text>
</v-card>
</v-col>
@@ -84,7 +84,6 @@
<script setup>
import { ref, computed } from 'vue'
import KvInitialize from '@/components/KvInitialize.vue'
import { getSetting, setSetting } from '@/utils/settings'
import { kvServerProvider } from '@/utils/providers/kvServerProvider'
+408
View File
@@ -0,0 +1,408 @@
<template>
<v-container>
<v-row>
<v-col
cols="12"
md="6"
>
<v-card
border
class="mb-4"
>
<v-card-title>连接信息</v-card-title>
<v-card-text>
<v-list density="compact">
<v-list-item>
<v-list-item-title>Server URL</v-list-item-title>
<v-list-item-subtitle>{{ serverUrl }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title>当前 KV Token</v-list-item-title>
<v-list-item-subtitle>{{ currentToken || '(未配置)' }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title>连接状态</v-list-item-title>
<v-list-item-subtitle>
<v-chip
:color="connected ? 'success' : 'error'"
size="small"
class="mr-2"
>
{{ connected ? 'connected' : 'disconnected' }}
</v-chip>
<span v-if="socketId">id: {{ socketId }}</span>
</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title>已加入 Token</v-list-item-title>
<v-list-item-subtitle>{{ joinedToken || '-' }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title>当前数据键</v-list-item-title>
<v-list-item-subtitle>{{ currentDataKey }}</v-list-item-subtitle>
</v-list-item>
</v-list>
<v-divider class="my-4" />
<v-row>
<v-col
cols="12"
md="8"
>
<v-text-field
v-model="manualToken"
label="手动加入 Token (留空使用配置的 Token)"
clearable
/>
</v-col>
<v-col
cols="12"
md="4"
class="d-flex align-center"
>
<v-btn
color="primary"
class="mr-2"
@click="handleJoinToken(manualToken || currentToken)"
>
加入
</v-btn>
<v-btn
color="warning"
class="mr-2"
:disabled="!joinedToken"
@click="handleLeaveToken(joinedToken)"
>
离开当前
</v-btn>
<v-btn
color="error"
variant="tonal"
@click="handleLeaveAll"
>
离开全部
</v-btn>
</v-col>
</v-row>
<v-divider class="my-4" />
<v-row>
<v-col cols="12">
<v-card variant="tonal" color="primary" border>
<v-card-title class="text-subtitle-1">聊天室消息</v-card-title>
<v-card-text>
<v-textarea
v-model="chatInput"
label="发送到当前已加入的设备频道"
rows="2"
auto-grow
clearable
/>
<div class="d-flex">
<v-spacer />
<v-btn
color="primary"
:disabled="!canSendChat"
@click="sendChat"
>
发送聊天
</v-btn>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-btn
color="secondary"
variant="tonal"
@click="reconnect"
>
重新连接
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
<v-card border>
<v-card-title>在线设备</v-card-title>
<v-card-text>
<v-btn
color="primary"
class="mb-3"
@click="fetchOnline"
>
刷新在线列表
</v-btn>
<v-list
v-if="onlineDevices.length"
density="compact"
>
<v-list-item
v-for="dev in onlineDevices"
:key="dev.uuid"
>
<template #prepend>
<v-avatar
:color="dev.connections > 0 ? 'success' : 'grey'"
size="24"
/>
</template>
<v-list-item-title>{{ dev.name || '(未命名)' }}</v-list-item-title>
<v-list-item-subtitle>{{ dev.uuid }} · 连接数 {{ dev.connections }}</v-list-item-subtitle>
<template #append>
<v-btn
size="small"
variant="text"
@click="handleSelectDevice(dev)"
>
选择
</v-btn>
</template>
</v-list-item>
</v-list>
<div
v-else
class="text-grey"
>
暂无数据
</div>
</v-card-text>
</v-card>
</v-col>
<v-col
cols="12"
md="6"
>
<v-card border>
<v-card-title class="d-flex align-center">
事件日志
<v-spacer />
<v-btn
size="small"
variant="text"
color="error"
@click="clearLogs"
>
清空
</v-btn>
</v-card-title>
<v-card-text>
<v-list density="compact">
<v-list-item
v-for="(log, idx) in logs"
:key="idx"
>
<v-list-item-title>
<span class="text-caption text-grey">{{ log.time }}</span>
<span class="ml-2">{{ log.event }}</span>
</v-list-item-title>
<v-list-item-text>
<pre
class="mb-2"
style="white-space: pre-wrap"
>{{ log.payload }}</pre>
</v-list-item-text>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
import { getSetting } from '@/utils/settings'
import {
getSocket,
on as socketOn,
joinToken,
leaveToken,
leaveAll,
getServerUrl
} from '@/utils/socketClient'
const currentToken = ref(getSetting('server.kvToken') || '')
const manualToken = ref('')
const joinedToken = ref('')
const connected = ref(false)
const socketId = ref('')
const logs = ref([])
const onlineDevices = ref([])
const chatInput = ref('')
const serverUrl = computed(() => getServerUrl())
const currentDataKey = computed(() => {
const now = new Date()
const y = now.getFullYear()
const m = String(now.getMonth() + 1).padStart(2, '0')
const d = String(now.getDate()).padStart(2, '0')
return `classworks-data-${y}${m}${d}`
})
function pushLog(event, payload) {
const time = new Date().toLocaleTimeString()
logs.value.unshift({
time,
event,
payload: typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2)
})
if (logs.value.length > 200) logs.value.pop()
}
function wireSocketBaseEvents() {
const s = getSocket()
connected.value = !!s.connected
socketId.value = s.id || ''
s.on('connect', () => {
connected.value = true
socketId.value = s.id || ''
pushLog('connect', { id: s.id })
// re-join with token if set
if (joinedToken.value) joinToken(joinedToken.value)
})
s.on('disconnect', (reason) => {
connected.value = false
pushLog('disconnect', { reason })
})
s.on('connect_error', (err) => pushLog('connect_error', { message: err?.message }))
s.on('reconnect_attempt', (n) => pushLog('reconnect_attempt', { attempt: n }))
s.on('reconnect', (n) => pushLog('reconnect', { attempt: n }))
}
function wireBusinessEvents() {
// key changes
socketOn('kv-key-changed', (msg) => {
pushLog('kv-key-changed', msg)
})
// device joined count broadcast
socketOn('device-joined', (msg) => {
pushLog('device-joined', msg)
})
// join success
socketOn('joined', (msg) => {
pushLog('joined', msg)
})
// join error
socketOn('join-error', (msg) => {
pushLog('join-error', msg)
})
// chat message
socketOn('chat:message', (msg) => {
pushLog('chat:message', msg)
})
}
function handleJoinToken(token) {
try {
if (!token) {
pushLog('join-error', 'Token 为空')
return
}
joinToken(token)
joinedToken.value = token
pushLog('join-token', { token })
} catch (e) {
pushLog('join-token-error', String(e))
}
}
function handleLeaveToken(token) {
try {
leaveToken(token)
if (joinedToken.value === token) joinedToken.value = ''
pushLog('leave-token', { token })
} catch (e) {
pushLog('leave-token-error', String(e))
}
}
function handleLeaveAll() {
try {
leaveAll()
joinedToken.value = ''
pushLog('leave-all', {})
} catch (e) {
pushLog('leave-all-error', String(e))
}
}
function reconnect() {
try {
const s = getSocket()
s.connect()
} catch (e) {
pushLog('reconnect-error', String(e))
}
}
const canSendChat = computed(() => {
const text = chatInput.value?.trim() || ''
return !!(text && (joinedToken.value || currentToken.value))
})
function sendChat() {
try {
const text = (chatInput.value || '').trim()
if (!text) return
const s = getSocket()
// send as plain string per server contract
s.emit('chat:send', text)
pushLog('chat:send', { text })
chatInput.value = ''
} catch (e) {
pushLog('chat:error', String(e))
}
}
function handleSelectDevice(dev) {
// For now, just show a message that we need the token
pushLog('select-device', {
message: '请输入该设备对应的 KV Token 以加入频道',
device: dev
})
}
async function fetchOnline() {
try {
const resp = await fetch(`${serverUrl.value}/devices/online`)
const data = await resp.json()
onlineDevices.value = Array.isArray(data?.devices) ? data.devices : []
pushLog('fetch-online', { count: onlineDevices.value.length })
} catch (e) {
pushLog('fetch-online-error', String(e))
}
}
function clearLogs() {
logs.value = []
}
onMounted(() => {
// init socket + base events
getSocket()
wireSocketBaseEvents()
wireBusinessEvents()
// auto join with current token if present
if (currentToken.value) {
handleJoinToken(currentToken.value)
}
// prime online list
fetchOnline()
})
onBeforeUnmount(() => {
try {
if (joinedToken.value) leaveToken(joinedToken.value)
} catch (e) {
void e
}
})
</script>
+273 -26
View File
@@ -7,6 +7,31 @@
<v-spacer />
<template #append>
<!-- 只读 Token 警告 -->
<v-chip
v-if="tokenDisplayInfo.readonly"
color="warning"
variant="tonal"
class="mx-2"
prepend-icon="mdi-lock-alert"
>
只读
</v-chip>
<!-- 学生名称显示 chip(始终蓝色) -->
<v-chip
v-if="tokenDisplayInfo.show"
color="primary"
variant="tonal"
class="mx-2"
prepend-icon="mdi-account"
:style="{ cursor: tokenDisplayInfo.disabled ? 'default' : 'pointer' }"
@click="handleTokenChipClick"
>
{{ tokenDisplayInfo.text }}
</v-chip>
<v-btn icon="mdi-chat" variant="text" @click="isChatOpen = true" />
<v-btn
icon="mdi-bell"
variant="text"
@@ -17,7 +42,17 @@
<v-btn icon="mdi-cog" variant="text" @click="$router.push('/settings')" />
</template>
</v-app-bar>
<div class="d-flex">
<!-- 初始化选择卡片,仅在首页且需要授权时显示;不影响顶栏 -->
<init-service-chooser v-if="shouldShowInit" @done="settingsTick++" />
<!-- 学生姓名管理组件 -->
<StudentNameManager
v-if="!shouldShowInit"
ref="studentNameManager"
@token-info-updated="updateTokenDisplayInfo"
/>
<div v-if="!shouldShowInit" class="d-flex">
<!-- 主要内容区域 -->
<v-container class="main-window flex-grow-1 no-select" fluid>
<!-- 有内容的科目卡片 -->
@@ -36,6 +71,7 @@
border
height="100%"
class="glow-track"
:class="{ 'glow-highlight': highlightedCards[item.key] }"
@click="!isEditingDisabled && openDialog(item.key)"
@mousemove="handleMouseMove"
@touchmove="handleTouchMove"
@@ -59,7 +95,7 @@
<!-- 单独显示空科目 -->
<div class="empty-subjects mt-4">
<template v-if="emptySubjectDisplay === 'button'">
<v-btn-group divided variant="outlined">
<v-btn-group divided variant="tonal">
<v-btn
v-for="subject in unusedSubjects"
:key="subject.name"
@@ -180,6 +216,7 @@
<!-- 出勤统计区域 -->
<v-col
v-ripple="{ class: `text-${['primary','secondary','info','success','warning','error'][Math.floor(Math.random()*6)]}` }"
v-if="state.studentList && state.studentList.length"
class="attendance-area no-select"
cols="1"
@@ -541,6 +578,9 @@
<!-- 添加ICP备案悬浮组件 -->
<FloatingICP />
<!-- 设备聊天室(右下角浮窗) -->
<ChatWidget v-model="isChatOpen" :show-button="false" />
<!-- 添加确认对话框 -->
<v-dialog v-model="confirmDialog.show" max-width="400">
<v-card>
@@ -608,8 +648,8 @@
确认应用
</v-btn>
</v-card-actions>
</v-card>
</v-dialog><br/><br/><br/>
</v-card> </v-dialog
><br /><br /><br />
</template>
<script>
@@ -617,7 +657,10 @@ import MessageLog from "@/components/MessageLog.vue";
import RandomPicker from "@/components/RandomPicker.vue";
import FloatingToolbar from "@/components/FloatingToolbar.vue";
import FloatingICP from "@/components/FloatingICP.vue";
import ChatWidget from "@/components/ChatWidget.vue";
import HomeworkEditDialog from "@/components/HomeworkEditDialog.vue";
import InitServiceChooser from "@/components/InitServiceChooser.vue";
import StudentNameManager from "@/components/StudentNameManager.vue";
import dataProvider from "@/utils/dataProvider";
import {
getSetting,
@@ -631,7 +674,15 @@ import "../styles/transitions.scss";
import "../styles/global.scss";
import { pinyin } from "pinyin-pro";
import { debounce, throttle } from "@/utils/debounce";
import { Base64 } from 'js-base64';
import { Base64 } from "js-base64";
import {
getSocket,
on as socketOn,
off as socketOff,
joinToken,
leaveAll,
onConnect as onSocketConnect,
} from "@/utils/socketClient";
export default {
name: "Classworks 作业板",
@@ -641,6 +692,9 @@ export default {
FloatingToolbar,
FloatingICP,
HomeworkEditDialog,
InitServiceChooser,
ChatWidget,
StudentNameManager,
},
data() {
const defaultSubjects = [
@@ -653,7 +707,7 @@ export default {
{ name: "政治", order: 6 },
{ name: "历史", order: 7 },
{ name: "地理", order: 8 },
{ name: "其他", order: 9 }
{ name: "其他", order: 9 },
];
return {
@@ -684,7 +738,7 @@ export default {
snackbarText: "",
fontSize: getSetting("font.size"),
datePickerDialog: false,
selectedDate: new Date().toISOString().split("T")[0].replace(/-/g, ''),
selectedDate: new Date().toISOString().split("T")[0].replace(/-/g, ""),
selectedDateObj: new Date(),
refreshInterval: null,
showNoDataMessage: false,
@@ -721,6 +775,28 @@ export default {
cancelHandler: null,
icons: {},
},
settingsTick: 0,
isChatOpen: false,
highlightedCards: {}, // 记录哪些卡片需要高亮
// Token 显示信息(统一显示 token 信息和学生姓名)
tokenDisplayInfo: {
show: false,
readonly: false, // 是否是只读 token
text: '',
color: 'primary',
variant: 'tonal',
icon: 'mdi-account',
disabled: false
},
// 实时刷新信息
realtimeInfo: {
show: false,
time: "",
key: "",
},
$offKvChanged: null,
$offConnect: null,
debouncedRealtimeRefresh: null,
};
},
@@ -782,7 +858,7 @@ export default {
(key) => this.state.boardData.homework[key].content?.trim()
);
return this.state.availableSubjects
.filter(subject => !usedKeys.includes(subject.name))
.filter((subject) => !usedKeys.includes(subject.name))
.sort((a, b) => a.order - b.order);
},
emptySubjects() {
@@ -853,6 +929,16 @@ export default {
showAntiScreenBurnCard() {
return getSetting("display.showAntiScreenBurnCard");
},
shouldShowInit() {
const provider = getSetting("server.provider");
const isKv = provider === "kv-server" || provider === "classworkscloud";
const token = getSetting("server.kvToken");
// 仅首页
const onHome = this.$route?.path === "/";
// 依赖 settingsTick 使其在设置变更时重新计算
void this.settingsTick;
return onHome && isKv && (!token || token === "");
},
filteredStudents() {
let students = [...this.state.studentList];
@@ -915,7 +1001,7 @@ export default {
subjectOrder() {
return [...this.state.availableSubjects]
.sort((a, b) => a.order - b.order)
.map(subject => subject.name);
.map((subject) => subject.name);
},
},
@@ -956,6 +1042,24 @@ export default {
this.updateSettings();
});
// 连接学生姓名管理组件
this.$nextTick(() => {
const studentNameManager = this.$refs.studentNameManager;
if (studentNameManager) {
this.studentNameInfo.name = studentNameManager.currentStudentName;
this.studentNameInfo.isStudent = studentNameManager.isStudentToken;
this.studentNameInfo.openDialog = () => studentNameManager.openDialog();
// 监听学生姓名变化
this.$watch(() => studentNameManager.currentStudentName, (newName) => {
this.studentNameInfo.name = newName;
});
this.$watch(() => studentNameManager.isStudentToken, (isStudent) => {
this.studentNameInfo.isStudent = isStudent;
});
}
});
document.addEventListener(
"fullscreenchange",
this.fullscreenChangeHandler
@@ -976,6 +1080,14 @@ export default {
this.checkHashForRandomPicker();
window.addEventListener("hashchange", this.checkHashForRandomPicker);
// 实时频道:加入设备房间并监听键变化
this.setupRealtimeChannel();
// 初始化 Token 显示信息
this.$nextTick(() => {
this.updateTokenDisplayInfo();
});
} catch (err) {
console.error("初始化失败:", err);
this.showError("初始化失败,请刷新页面重试");
@@ -1008,9 +1120,63 @@ export default {
);
window.removeEventListener("hashchange", this.checkHashForRandomPicker);
// 退出设备房间并清理监听
try {
if (this.$offKvChanged) this.$offKvChanged();
if (this.$offConnect) this.$offConnect();
leaveAll();
} catch (e) {
void e;
}
},
methods: {
// 更新 Token 显示信息
updateTokenDisplayInfo() {
const manager = this.$refs.studentNameManager
if (!manager || !manager.hasToken) {
this.tokenDisplayInfo.show = false
this.tokenDisplayInfo.readonly = false
return
}
const displayName = manager.displayName
const isReadOnly = manager.isReadOnly
const isStudent = manager.isStudentToken
// 设置只读状态(对所有类型的 token 都显示)
this.tokenDisplayInfo.readonly = isReadOnly
// 只有学生类型的 token 才显示名称 chip
if (!isStudent) {
this.tokenDisplayInfo.show = false
return
}
// 设置学生名称显示(始终蓝色)
this.tokenDisplayInfo.text = displayName
this.tokenDisplayInfo.color = 'primary'
this.tokenDisplayInfo.icon = 'mdi-account'
this.tokenDisplayInfo.disabled = isReadOnly // 只读时不可点击
this.tokenDisplayInfo.show = true
},
// 处理 Token Chip 点击
handleTokenChipClick() {
console.log('Token chip clicked')
const manager = this.$refs.studentNameManager
console.log('Manager:', manager)
console.log('Is student token:', manager?.isStudentToken)
if (manager && manager.isStudentToken) {
console.log('Opening dialog...')
manager.openDialog()
} else {
console.log('Cannot open dialog - conditions not met')
}
},
ensureDate(dateInput) {
if (dateInput instanceof Date) {
return dateInput;
@@ -1085,10 +1251,13 @@ export default {
if (response.error.code === "NOT_FOUND") {
this.state.showNoDataMessage = true;
this.state.noDataMessage = response.error.message;
this.state.boardData = {
homework: {},
attendance: { absent: [], late: [], exclude: [] },
};
// 只有当前没有数据时才设置为空,避免覆盖已有的本地数据
if (!this.state.boardData || (!this.state.boardData.homework && !this.state.boardData.attendance)) {
this.state.boardData = {
homework: {},
attendance: { absent: [], late: [], exclude: [] },
};
}
} else {
throw new Error(response.error.message);
}
@@ -1106,11 +1275,16 @@ export default {
this.$message.success("下载成功", "数据已更新");
}
} catch (error) {
this.state.boardData = {
homework: {},
attendance: { absent: [], late: [], exclude: [] },
};
// 数据加载失败时不覆盖现有数据,只显示错误信息
console.error("数据加载失败:", error);
this.$message.error("下载失败", error.message);
// 如果当前没有任何数据,才初始化为空数据
if (!this.state.boardData || (!this.state.boardData.homework && !this.state.boardData.attendance)) {
this.state.boardData = {
homework: {},
attendance: { absent: [], late: [], exclude: [] },
};
}
} finally {
this.loading.download = false;
}
@@ -1194,9 +1368,7 @@ export default {
const response = await dataProvider.loadData("classworks-list-main");
if (response.success != false && Array.isArray(response)) {
this.state.studentList = response.map(
(student) => student.name
);
this.state.studentList = response.map((student) => student.name);
}
} catch (error) {
console.warn(
@@ -1214,7 +1386,9 @@ export default {
async loadSubjects() {
try {
const subjectsResponse = await dataProvider.loadData("classworks-config-subject");
const subjectsResponse = await dataProvider.loadData(
"classworks-config-subject"
);
if (subjectsResponse && Array.isArray(subjectsResponse)) {
// 更新科目列表
this.state.availableSubjects = subjectsResponse;
@@ -1370,6 +1544,8 @@ export default {
this.state.contentStyle = { "font-size": `${this.state.fontSize}px` };
this.setupAutoRefresh();
this.updateBackendUrl();
// 触发依赖刷新(例如 shouldShowInit)
this.settingsTick++;
},
async handleDateSelect(newDate) {
@@ -1393,10 +1569,7 @@ export default {
.catch(() => {});
// Load both data and subjects in parallel
await Promise.all([
this.downloadData(),
this.loadSubjects()
]);
await Promise.all([this.downloadData(), this.loadSubjects()]);
}
} catch (error) {
console.error("Date processing error:", error);
@@ -1430,6 +1603,80 @@ export default {
}));
},
// 实时频道:加入设备房间并监听键变化
setupRealtimeChannel() {
try {
const token = getSetting("server.kvToken");
if (!token) {
console.warn("未配置 KV Token,无法加入实时频道");
return;
}
// Ensure socket created
getSocket();
joinToken(token);
// Re-join on reconnect
this.$offConnect = onSocketConnect(() => joinToken(token));
// Debounce refresh to avoid storms
if (!this.debouncedRealtimeRefresh) {
this.debouncedRealtimeRefresh = debounce(async () => {
const oldHomework = JSON.parse(
JSON.stringify(this.state.boardData.homework)
);
await this.downloadData();
const now = new Date();
const hh = String(now.getHours()).padStart(2, "0");
const mm = String(now.getMinutes()).padStart(2, "0");
const ss = String(now.getSeconds()).padStart(2, "0");
// 使用消息记录工具发送通知
this.$message?.info(
"数据已更新",
`已于 ${hh}:${mm}:${ss} 自动刷新`
); // 检测哪些科目发生了变化
const changed = {};
for (const key in this.state.boardData.homework) {
const oldContent = oldHomework[key]?.content || "";
const newContent =
this.state.boardData.homework[key]?.content || "";
if (oldContent !== newContent) {
changed[key] = true;
}
}
// 删除的科目也算变化
for (const key in oldHomework) {
if (!this.state.boardData.homework[key]) {
changed[key] = true;
}
}
// 设置高亮
this.highlightedCards = changed;
// 3秒后移除高亮
setTimeout(() => {
this.highlightedCards = {};
}, 10000);
}, 800);
}
const handler = (msg) => {
// Expect msg = { uuid, key, action, created?, updatedAt?, deletedAt?, batch? }
if (!msg) return;
// We only care about current date key changes
const expectedKey = `classworks-data-${this.state.dateString}`;
if (msg.key !== expectedKey) return;
if (msg.action !== "upsert" && msg.action !== "delete") return;
// Trigger a debounced refresh
this.debouncedRealtimeRefresh?.(msg.key);
};
this.$offKvChanged = socketOn("kv-key-changed", handler);
} catch (e) {
console.warn("实时频道初始化失败", e);
}
},
setAllPresent() {
this.state.boardData.attendance = {
@@ -1728,7 +1975,7 @@ export default {
try {
const binaryString = atob(configParam);
const bytes = Uint8Array.from(binaryString, c => c.charCodeAt(0));
const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0));
const decodedString = new TextDecoder().decode(bytes);
const decodedConfig = JSON.parse(decodedString);
console.log("从URL读取配置:", decodedConfig);
+30 -3
View File
@@ -11,7 +11,7 @@
icon="mdi-menu"
variant="text"
@click="drawer = !drawer"
class="d-md-none"
/>
</template>
<v-app-bar-title class="text-h6">设置</v-app-bar-title>
@@ -43,7 +43,32 @@
style="width: 100%"
direction="vertical"
>
<v-tabs-window-item value="index">
<v-tabs-window-item value="index"
><v-card class="service-card gradient-right clickable" elevation="8">
<v-card-item>
<div class="card-title">
<div>
<div class="text-h6">Classworks KV</div>
<div class="text-caption text-medium-emphasis">
文档形键值数据库
</div>
</div>
</div>
</v-card-item>
<v-card-text>
<div class="mt-4">
<v-btn
variant="text"
class="text-none"
append-icon="mdi-arrow-right"
rounded="xl"
@click="openClassworksKV"
>
打开 Classworks KV
</v-btn>
</div>
</v-card-text>
</v-card>
<v-card title="Classworks" subtitle="设置" class="rounded-xl" border>
<v-card-text>
<v-alert
@@ -146,7 +171,6 @@
/>
</v-tabs-window-item>
<v-tabs-window-item value="randomPicker">
<random-picker-card border :is-mobile="isMobile" />
</v-tabs-window-item>
@@ -441,6 +465,9 @@ export default {
},
methods: {
openClassworksKV() {
window.open(getSetting("server.authDomain"), "_blank");
},
loadAllSettings() {
Object.keys(this.settings).forEach((section) => {
Object.keys(this.settings[section]).forEach((key) => {