feat: Add Chat Widget and Init Service Chooser components
- Implemented ChatWidget component for real-time chat functionality with socket integration. - Added InitServiceChooser component for selecting services with manual token input and auto-authorization. - Updated settings and data provider to support local development with localhost. - Enhanced settings page with Classworks KV card and improved styles. - Introduced debug socket page for monitoring connection status and device interactions. - Refactored socket client utility for better connection management and event handling. - Added glow highlight effect in styles for UI enhancements.
This commit is contained in:
+42
-16
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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>
|
||||
+146
-18
@@ -7,6 +7,7 @@
|
||||
<v-spacer />
|
||||
|
||||
<template #append>
|
||||
<v-btn icon="mdi-chat" variant="text" @click="isChatOpen = true" />
|
||||
<v-btn
|
||||
icon="mdi-bell"
|
||||
variant="text"
|
||||
@@ -17,7 +18,10 @@
|
||||
<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++" />
|
||||
|
||||
<div v-if="!shouldShowInit" class="d-flex">
|
||||
<!-- 主要内容区域 -->
|
||||
<v-container class="main-window flex-grow-1 no-select" fluid>
|
||||
<!-- 有内容的科目卡片 -->
|
||||
@@ -36,6 +40,7 @@
|
||||
border
|
||||
height="100%"
|
||||
class="glow-track"
|
||||
:class="{ 'glow-highlight': highlightedCards[item.key] }"
|
||||
@click="!isEditingDisabled && openDialog(item.key)"
|
||||
@mousemove="handleMouseMove"
|
||||
@touchmove="handleTouchMove"
|
||||
@@ -59,7 +64,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 +185,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 +547,9 @@
|
||||
<!-- 添加ICP备案悬浮组件 -->
|
||||
<FloatingICP />
|
||||
|
||||
<!-- 设备聊天室(右下角浮窗) -->
|
||||
<ChatWidget v-model="isChatOpen" :show-button="false" />
|
||||
|
||||
<!-- 添加确认对话框 -->
|
||||
<v-dialog v-model="confirmDialog.show" max-width="400">
|
||||
<v-card>
|
||||
@@ -608,8 +617,8 @@
|
||||
确认应用
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog><br/><br/><br/><br/><br/><br/>
|
||||
</v-card> </v-dialog
|
||||
><br /><br /><br /><br /><br /><br />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -617,7 +626,9 @@ 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 dataProvider from "@/utils/dataProvider";
|
||||
import {
|
||||
getSetting,
|
||||
@@ -631,7 +642,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 +660,8 @@ export default {
|
||||
FloatingToolbar,
|
||||
FloatingICP,
|
||||
HomeworkEditDialog,
|
||||
InitServiceChooser,
|
||||
ChatWidget,
|
||||
},
|
||||
data() {
|
||||
const defaultSubjects = [
|
||||
@@ -653,7 +674,7 @@ export default {
|
||||
{ name: "政治", order: 6 },
|
||||
{ name: "历史", order: 7 },
|
||||
{ name: "地理", order: 8 },
|
||||
{ name: "其他", order: 9 }
|
||||
{ name: "其他", order: 9 },
|
||||
];
|
||||
|
||||
return {
|
||||
@@ -684,7 +705,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 +742,18 @@ export default {
|
||||
cancelHandler: null,
|
||||
icons: {},
|
||||
},
|
||||
settingsTick: 0,
|
||||
isChatOpen: false,
|
||||
highlightedCards: {}, // 记录哪些卡片需要高亮
|
||||
// 实时刷新信息
|
||||
realtimeInfo: {
|
||||
show: false,
|
||||
time: "",
|
||||
key: "",
|
||||
},
|
||||
$offKvChanged: null,
|
||||
$offConnect: null,
|
||||
debouncedRealtimeRefresh: null,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -782,7 +815,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 +886,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 +958,7 @@ export default {
|
||||
subjectOrder() {
|
||||
return [...this.state.availableSubjects]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map(subject => subject.name);
|
||||
.map((subject) => subject.name);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -976,6 +1019,9 @@ export default {
|
||||
this.checkHashForRandomPicker();
|
||||
|
||||
window.addEventListener("hashchange", this.checkHashForRandomPicker);
|
||||
|
||||
// 实时频道:加入设备房间并监听键变化
|
||||
this.setupRealtimeChannel();
|
||||
} catch (err) {
|
||||
console.error("初始化失败:", err);
|
||||
this.showError("初始化失败,请刷新页面重试");
|
||||
@@ -1008,6 +1054,15 @@ export default {
|
||||
);
|
||||
|
||||
window.removeEventListener("hashchange", this.checkHashForRandomPicker);
|
||||
|
||||
// 退出设备房间并清理监听
|
||||
try {
|
||||
if (this.$offKvChanged) this.$offKvChanged();
|
||||
if (this.$offConnect) this.$offConnect();
|
||||
leaveAll();
|
||||
} catch (e) {
|
||||
void e;
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
@@ -1194,9 +1249,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 +1267,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 +1425,8 @@ export default {
|
||||
this.state.contentStyle = { "font-size": `${this.state.fontSize}px` };
|
||||
this.setupAutoRefresh();
|
||||
this.updateBackendUrl();
|
||||
// 触发依赖刷新(例如 shouldShowInit)
|
||||
this.settingsTick++;
|
||||
},
|
||||
|
||||
async handleDateSelect(newDate) {
|
||||
@@ -1393,10 +1450,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 +1484,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 +1856,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);
|
||||
|
||||
+25
-2
@@ -43,7 +43,31 @@
|
||||
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"
|
||||
>
|
||||
打开 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 +170,6 @@
|
||||
/>
|
||||
</v-tabs-window-item>
|
||||
|
||||
|
||||
<v-tabs-window-item value="randomPicker">
|
||||
<random-picker-card border :is-mobile="isMobile" />
|
||||
</v-tabs-window-item>
|
||||
|
||||
Reference in New Issue
Block a user