1
0
mirror of https://github.com/ZeroCatDev/Classworks.git synced 2025-12-07 13:03:59 +00:00

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:
SunWuyuan 2025-10-25 17:10:20 +08:00
parent b9efaee7ee
commit a2b0cc9e08
No known key found for this signature in database
GPG Key ID: A6A54CF66F56BB64
20 changed files with 1790 additions and 476 deletions

1
.gitignore vendored
View File

@ -172,4 +172,3 @@ dist
vite.config.*.timestamp-*.mjs vite.config.*.timestamp-*.mjs
*.timestamp-* *.timestamp-*
kv-admin

View File

@ -1,5 +1,5 @@
{ {
"name": "Classworks", "name": "classworks",
"private": true, "private": true,
"type": "module", "type": "module",
"version": "0.0.0", "version": "0.0.0",
@ -14,17 +14,21 @@
"@examaware-cs/player": "^1.0.2", "@examaware-cs/player": "^1.0.2",
"@mdi/font": "7.4.47", "@mdi/font": "7.4.47",
"@microsoft/clarity": "^1.0.0", "@microsoft/clarity": "^1.0.0",
"@vueuse/core": "^13.9.0",
"axios": "^1.11.0", "axios": "^1.11.0",
"idb": "^8.0.3", "idb": "^8.0.3",
"js-base64": "^3.7.8", "js-base64": "^3.7.8",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"lucide-vue-next": "^0.545.0",
"marked": "^16.4.0",
"pinyin-pro": "^3.27.0", "pinyin-pro": "^3.27.0",
"ratelimit-header-parser": "^0.1.0", "ratelimit-header-parser": "^0.1.0",
"roboto-fontface": "*", "roboto-fontface": "*",
"tdesign-vue-next": "^1.17.1", "socket.io-client": "^4.8.1",
"typewriter-effect": "^2.21.0", "typewriter-effect": "^2.21.0",
"uuid": "^9.0.1", "uuid": "^9.0.1",
"vue": "^3.5.20", "vue": "^3.5.20",
"vue-sonner": "^2.0.9",
"vuetify": "^3.9.6" "vuetify": "^3.9.6"
}, },
"devDependencies": { "devDependencies": {

568
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -1,11 +1,15 @@
<template> <template>
<v-app> <v-app>
<!-- KvInitialize 组件自行决定是否展示或执行跳转 -->
<kv-initialize />
<!-- 正常路由 --> <!-- 正常路由 -->
<router-view v-slot="{ Component, route }"> <router-view v-slot="{ Component, route }">
<transition name="md3" mode="out-in"> <transition
<component :is="Component" :key="route.path" /> name="md3"
mode="out-in"
>
<component
:is="Component"
:key="route.path"
/>
</transition> </transition>
</router-view> </router-view>
<global-message /> <global-message />
@ -18,9 +22,7 @@ import { onMounted } from "vue";
import { useTheme } from "vuetify"; import { useTheme } from "vuetify";
import { getSetting } from "@/utils/settings"; import { getSetting } from "@/utils/settings";
import RateLimitModal from "@/components/RateLimitModal.vue"; import RateLimitModal from "@/components/RateLimitModal.vue";
import KvInitialize from "@/components/KvInitialize.vue";
import Clarity from "@microsoft/clarity"; import Clarity from "@microsoft/clarity";
const theme = useTheme(); const theme = useTheme();
onMounted(() => { onMounted(() => {

View File

@ -1,71 +0,0 @@
<template>
<v-footer height="40" app>
<a
v-for="item in items"
:key="item.title"
:href="item.href"
:title="item.title"
class="d-inline-block mx-2 social-link"
rel="noopener noreferrer"
target="_blank"
>
<v-icon :icon="item.icon" :size="item.icon === 'mdi-earth' ? 24 : 16" />
</a>
<div
class="text-caption text-disabled"
style="position: absolute; right: 16px"
>
<a
class="text-decoration-none on-surface"
href="https://github.com/ZeroCatDev/Classworks"
rel="noopener noreferrer"
target="_blank"
>
Classworks
</a> <a
class="text-decoration-none on-surface"
href="https://beian.miit.gov.cn"
rel="noopener noreferrer"
target="_blank"
>
浙ICP备2024068645号
</a>
</div>
</v-footer>
</template>
<script setup>
import { useDisplay } from "vuetify";
const { mobile } = useDisplay();
const items = [
{
title: "Classworks",
icon: `mdi-earth`,
href: "https://cs.houlangs.com",
},
{
title: "ZeroCat",
icon: "mdi-xml",
href: "https://zerocat.houlangs.com",
},
{
title: "GitHub",
icon: "mdi-github",
href: "https://github.com/ZeroCatDev/Classworks",
},
];
</script>
<style scoped lang="sass">
.social-link :deep(.v-icon)
color: rgba(var(--v-theme-on-background), var(--v-disabled-opacity))
text-decoration: none
transition: .2s ease-in-out
&:hover
color: rgba(25, 118, 210, 1)
</style>

View File

@ -0,0 +1,396 @@
<template>
<!-- Floating toggle button -->
<div
v-if="showToggleButton"
class="chat-toggle"
:style="toggleStyle"
>
<v-btn
icon
color="primary"
variant="flat"
@click="open()"
>
<v-badge
:content="unreadCount || undefined"
:model-value="unreadCount > 0"
color="error"
overlap
>
<v-icon>
mdi-chat
</v-icon>
</v-badge>
</v-btn>
</div>
<!-- Chat panel -->
<div
v-show="visible"
class="chat-panel"
:style="panelStyle"
>
<v-card
border
elevation="8"
class="chat-card"
>
<v-card-title class="d-flex align-center">
<v-icon class="mr-2">
mdi-chat-processing
</v-icon>
<span class="text-subtitle-1">设备聊天室</span>
<v-spacer />
<v-tooltip location="top">
<template #activator="{ props }">
<v-chip
v-bind="props"
size="x-small"
:color="connected ? 'success' : 'grey'"
variant="tonal"
>
{{ connected ? '已连接' : '未连接' }}
</v-chip>
</template>
<span>Socket {{ socketId || '-' }}</span>
</v-tooltip>
<v-btn
icon
variant="text"
@click="close()"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-divider />
<v-card-text class="chat-body">
<div
ref="listRef"
class="messages"
>
<template
v-for="msg in decoratedMessages"
:key="msg._id"
>
<div
v-if="msg._type === 'divider'"
class="divider-row"
>
<v-divider class="my-2" />
<div class="divider-text">
今天 - 上次访问
</div>
<v-divider class="my-2" />
</div>
<div
v-else
class="message-row"
:class="{ self: msg.self }"
>
<div class="avatar">
<v-avatar
size="24"
:color="msg.self ? 'primary' : 'grey'"
>
<v-icon size="small">
{{ msg.self ? 'mdi-account' : 'mdi-account-outline' }}
</v-icon>
</v-avatar>
</div>
<div class="bubble">
<div class="text">
{{ msg.text }}
</div>
<div class="meta">
{{ formatTime(msg.at) }}
</div>
</div>
</div>
</template>
</div>
</v-card-text>
<v-divider />
<v-card-actions class="chat-input">
<v-btn
icon
variant="text"
class="mr-1"
@click="insertEmoji('😄')"
>
<v-icon>mdi-emoticon-outline</v-icon>
</v-btn>
<v-textarea
ref="inputRef"
v-model="text"
class="flex-grow-1"
rows="1"
auto-grow
variant="solo"
hide-details
placeholder="输入消息"
@keydown.enter.prevent="handleEnter"
@keydown.shift.enter.stop
/>
<v-btn
color="primary"
:disabled="!canSend"
class="ml-2"
@click="send"
>
<v-icon start>
mdi-send
</v-icon>
发送
</v-btn>
</v-card-actions>
</v-card>
</div>
</template>
<script>
import { getSetting } from '@/utils/settings'
import { getSocket, joinToken, on as socketOn } from '@/utils/socketClient'
export default {
name: 'ChatWidget',
props: {
modelValue: {
type: Boolean,
default: false,
},
showButton: {
type: Boolean,
default: true,
},
offset: {
type: Number,
default: 16,
},
width: {
type: Number,
default: 380,
},
height: {
type: Number,
default: 520,
},
},
emits: ['update:modelValue'],
data() {
return {
visible: this.modelValue,
text: '',
messages: [],
lastVisit: null,
unreadCount: 0,
connected: false,
socketId: '',
}
},
computed: {
panelStyle() {
return {
right: this.offset + 'px',
bottom: this.offset + 'px',
width: this.width + 'px',
height: this.height + 'px',
}
},
toggleStyle() {
return {
right: this.offset + 'px',
bottom: this.offset + 'px',
}
},
canSend() {
const token = getSetting('server.kvToken')
return !!(token && this.text.trim())
},
showToggleButton() {
return this.$props.showButton && !this.visible
},
decoratedMessages() {
// Insert divider between lastVisit and now
if (!this.lastVisit) return this.messages
const idx = this.messages.findIndex(m => m.at && new Date(m.at).getTime() >= new Date(this.lastVisit).getTime())
if (idx <= 0) return this.messages
const before = this.messages.slice(0, idx)
const after = this.messages.slice(idx)
return [
...before,
{ _id: 'divider', _type: 'divider' },
...after,
]
},
},
watch: {
modelValue(val) {
this.visible = val
if (val) {
this.onOpen()
}
},
},
mounted() {
try {
const stored = localStorage.getItem('chat.lastVisit')
if (stored) this.lastVisit = stored
} catch (e) { void e }
// Prepare socket
const s = getSocket()
this.connected = !!s.connected
this.socketId = s.id || ''
s.on('connect', () => {
this.connected = true
this.socketId = s.id || ''
})
s.on('disconnect', () => {
this.connected = false
})
// Auto join by token if exists
const token = getSetting('server.kvToken')
if (token) joinToken(token)
// Listen chat messages
this.offMessage = socketOn('chat:message', (msg) => {
this.pushMessage(msg)
})
// If initially visible, run open logic
if (this.visible) this.onOpen()
},
beforeUnmount() {
if (this.offMessage) this.offMessage()
},
methods: {
open() {
this.visible = true
this.$emit('update:modelValue', true)
this.onOpen()
},
close() {
this.visible = false
this.$emit('update:modelValue', false)
try {
localStorage.setItem('chat.lastVisit', new Date().toISOString())
} catch (e) { void e }
this.unreadCount = 0
},
onOpen() {
// Scroll to bottom on open
this.$nextTick(() => this.scrollToBottom())
},
insertEmoji(ch) {
this.text += ch
this.$nextTick(() => {
if (this.$refs.inputRef?.$el?.querySelector) {
const ta = this.$refs.inputRef.$el.querySelector('textarea')
ta?.focus()
}
})
},
handleEnter(e) {
if (e.shiftKey) return
this.send()
},
send() {
const val = this.text.trim()
if (!val) return
const s = getSocket()
s.emit('chat:send', val)
this.text = ''
},
pushMessage(msg) {
const entry = {
_id: `${msg.at || Date.now()}-${Math.random()}`,
text: typeof msg?.text === 'string' ? msg.text : (msg?.text || ''),
at: msg.at || new Date().toISOString(),
senderId: msg.senderId,
self: !!(msg.senderId && msg.senderId === this.socketId),
}
// ignore empty
if (!entry.text) return
this.messages.push(entry)
// unread when hidden
if (!this.visible) this.unreadCount++
this.$nextTick(() => this.scrollToBottom())
// trim
if (this.messages.length > 500) this.messages.shift()
},
formatTime(iso) {
try {
const d = new Date(iso)
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
return `${hh}:${mm}`
} catch (e) {
void e
return ''
}
},
scrollToBottom() {
const el = this.$refs.listRef
if (!el) return
try {
el.scrollTop = el.scrollHeight
} catch (e) { void e }
},
},
}
</script>
<style scoped>
.chat-toggle {
position: fixed;
z-index: 1100;
}
.chat-panel {
position: fixed;
z-index: 1101;
}
.chat-card {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.chat-body {
padding: 8px 12px;
height: calc(100% - 120px);
}
.messages {
height: 100%;
overflow: auto;
}
.message-row {
display: flex;
align-items: flex-end;
margin: 8px 0;
}
.message-row.self {
flex-direction: row-reverse;
}
.message-row .avatar { width: 28px; display: flex; justify-content: center; }
.message-row .bubble {
max-width: 70%;
background: rgba(255,255,255,0.06);
border-radius: 10px;
padding: 6px 10px;
margin: 0 8px;
}
.message-row.self .bubble {
background: rgba(33,150,243,0.15);
}
.bubble .text { white-space: pre-wrap; word-break: break-word; }
.bubble .meta { font-size: 12px; opacity: 0.6; margin-top: 2px; text-align: right; }
.divider-row { text-align: center; color: rgba(255,255,255,0.6); font-size: 12px; }
.divider-text { margin: 4px 0; }
.chat-input { padding: 8px; }
</style>

View File

@ -1,77 +1,52 @@
<template> <template>
<v-slide-x-transition> <a
<v-card class="floating-icp-link"
class="floating-icp" href="https://beian.miit.gov.cn/"
elevation="2" target="_blank"
rounded="pill" rel="noopener noreferrer"
variant="tonal" aria-label="浙ICP备2024068645号"
color="surface-variant" >
@mouseenter="isHovered = true" 浙ICP备2024068645号
@mouseleave="isHovered = false" </a>
>
<v-btn
variant="text"
class="icp-button"
href="https://beian.miit.gov.cn/"
target="_blank"
rel="noopener noreferrer"
>
<v-icon
icon="mdi-shield-check"
size="small"
:class="{ 'rotate-icon': isHovered }"
class="mr-1"
/>
<span class="text-caption">浙ICP备2024068645号</span>
</v-btn>
</v-card>
</v-slide-x-transition>
</template> </template>
<script> <script>
export default { export default {
name: 'FloatingICP', name: 'FloatingICP',
data() {
return {
isHovered: false
}
}
} }
</script> </script>
<style scoped> <style scoped>
.floating-icp { .floating-icp-link {
position: fixed; position: fixed;
bottom: 24px; right: 4px;
right: 24px; bottom: 0;
z-index: 100; z-index: 100;
transition: all 0.3s ease; font-size: 14px;
backdrop-filter: blur(10px); font-weight: 500;
letter-spacing: 0.2px;
color: rgb(107, 107, 107);
text-decoration: none;
background: transparent;
border: none;
box-shadow: none;
transition: none !important;
} }
.floating-icp:hover { .floating-icp-link:hover,
transform: translateX(-4px); .floating-icp-link:focus,
} .floating-icp-link:active {
color: rgb(65, 65, 65);
.icp-button { text-decoration: none;
padding: 0 16px; outline: none;
height: 32px;
min-width: unset;
}
.rotate-icon {
transform: rotate(360deg);
transition: transform 0.6s ease;
} }
@media (max-width: 600px) { @media (max-width: 600px) {
.floating-icp { .floating-icp-link {
right: 16px; right: 16px;
bottom: 80px; /* 避免与其他悬浮组件重叠 */ bottom: 0;
} font-size: 14px;
.icp-button {
padding: 0 12px;
} }
} }
</style> </style>

View File

@ -0,0 +1,242 @@
<template>
<div
v-if="visible"
class="init-overlay"
>
<div class="init-container">
<div class="init-header">
<div class="title">
选择要使用的服务
</div>
<div class="subtitle">
左侧为 Classworks 管理端右侧为 Classworks KV 控制台
</div>
</div>
<div class="card-row">
<!-- Classworks 卡片展开操作 -->
<v-card
class="service-card gradient-left"
elevation="8"
>
<v-card-item>
<div class="card-title">
<div>
<div class="text-h6">
Classworks
</div>
<div class="text-caption text-medium-emphasis">
适用于班级大屏的作业板工具
</div>
</div>
</div>
</v-card-item>
<v-card-text>
<div class="action-grid">
<v-btn
color="primary"
prepend-icon="mdi-flash"
@click="handleAutoAuthorize"
>
开始使用
</v-btn>
<v-btn
color="secondary"
variant="tonal"
prepend-icon="mdi-key"
@click="showManual = !showManual"
>
输入 Token
</v-btn>
<v-btn
variant="text"
prepend-icon="mdi-laptop"
@click="useLocalMode"
>
使用本地模式
</v-btn>
</div>
<v-expand-transition>
<div
v-show="showManual"
class="mt-4"
>
<v-text-field
v-model="manualToken"
label="KV 授权 Token"
placeholder="粘贴从授权页面获取的 Token"
hide-details
clearable
/>
<v-alert
v-if="verifyError"
type="error"
variant="tonal"
class="mt-2"
>
{{ verifyError }}
</v-alert>
<div class="d-flex mt-2">
<v-spacer />
<v-btn
:disabled="!manualToken || verifying"
:loading="verifying"
color="primary"
@click="saveManualToken"
>
保存 Token
</v-btn>
</div>
</div>
</v-expand-transition>
</v-card-text>
</v-card>
<!-- Classworks KV 卡片跳转 /kv -->
<v-card
class="service-card gradient-right clickable"
elevation="8"
@click="goKv"
>
<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.stop="goKv"
>
打开 Classworks KV
</v-btn>
</div>
</v-card-text>
</v-card>
</div>
<div class="footer-hint">
完成授权后可使用作业同步考试看板等在线功能
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getSetting, setSetting } from '@/utils/settings'
import axios from '@/axios/axios'
const router = useRouter()
const emit = defineEmits(['done'])
// kvToken provider kv-local
const visible = ref(false)
const showManual = ref(false)
const manualToken = ref('')
const verifying = ref(false)
const verifyError = ref('')
const provider = computed(() => getSetting('server.provider'))
const isKvProvider = computed(() => provider.value === 'kv-server' || provider.value === 'classworkscloud')
const kvToken = computed(() => getSetting('server.kvToken'))
const evaluateVisibility = () => {
const path = window.location.pathname
const onHome = path === '/' || path === '/index' || path === '/index.html'
const need = isKvProvider.value && (!kvToken.value || kvToken.value === '')
visible.value = onHome && need
}
onMounted(() => {
evaluateVisibility()
})
const handleAutoAuthorize = () => {
const authDomain = getSetting('server.authDomain')
const appId = 'd158067f53627d2b98babe8bffd2fd7d'
const currentDomain = window.location.origin
const callbackUrl = encodeURIComponent(`${currentDomain}/authorizecallback`)
const uuid = getSetting('device.uuid') || '00000000-0000-4000-8000-000000000000'
let url = `${authDomain}/authorize?app_id=${appId}&mode=callback&callback_url=${callbackUrl}&remark=Classworks 自动授权 来自${window.location.hostname} ${new Date().toLocaleString()}`
if (uuid !== '00000000-0000-4000-8000-000000000000') {
url += `&uuid=${encodeURIComponent(uuid)}`
}
window.location.href = url
}
const saveManualToken = async () => {
if (!manualToken.value || verifying.value) return
verifyError.value = ''
verifying.value = true
try {
const serverUrl = getSetting('server.domain')
if (!serverUrl) throw new Error('未配置服务器域名')
await axios.get(`${serverUrl}/kv/_info`, {
headers: {
Accept: 'application/json',
'x-app-token': manualToken.value,
},
})
//
setSetting('server.kvToken', manualToken.value)
evaluateVisibility()
emit('done')
} catch (err) {
const status = err?.response?.status
if (status === 401 || status === 403) {
verifyError.value = 'Token 无效或无权限,请确认后重试'
} else if (status === 404) {
verifyError.value = '命名空间不存在或服务器未就绪'
} else {
verifyError.value = err?.response?.data?.message || err?.message || '验证失败,请稍后重试'
}
} finally {
verifying.value = false
}
}
const useLocalMode = () => {
setSetting('server.provider', 'kv-local')
visible.value = false
//
window.location.reload()
emit('done')
}
const goKv = () => {
router.push('/kv')
}
</script>
<style scoped>
.init-overlay { position: relative; }
.init-container { max-width: 1080px; margin: 24px auto; padding: 8px 16px; }
.init-header .title { font-size: 20px; font-weight: 600; }
.init-header .subtitle { margin-top: 4px; font-size: 13px; opacity: .75; }
.card-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px; }
.service-card { min-height: 220px; }
.card-title { display: flex; align-items: center; }
.clickable { cursor: pointer; }
.gradient-left { background: linear-gradient(135deg, rgba(103,80,164,.18), rgba(103,80,164,0) 60%); }
.gradient-right { background: linear-gradient(135deg, rgba(0,184,212,.18), rgba(0,184,212,0) 60%); }
.action-grid { display: grid; grid-template-columns: repeat(3, max-content); gap: 12px; align-items: center; }
.footer-hint { margin-top: 12px; font-size: 12px; opacity: .7; }
@media (max-width: 900px) { .card-row { grid-template-columns: 1fr; } }
</style>

View File

@ -94,7 +94,7 @@ const goToAuthorize = () => {
const uuid = const uuid =
getSetting("device.uuid") || "00000000-0000-4000-8000-000000000000"; getSetting("device.uuid") || "00000000-0000-4000-8000-000000000000";
let authorizeUrl = `${authDomain}/authorize?app_id=${appId}&mode=callback&callback_url=${callbackUrl}`; let authorizeUrl = `${authDomain}/authorize?app_id=${appId}&mode=callback&callback_url=${callbackUrl}&remark=Classworks 自动授权 来自${window.location.hostname} ${new Date().toLocaleString()}`;
// UUID uuid // UUID uuid
if (uuid !== "00000000-0000-4000-8000-000000000000") { if (uuid !== "00000000-0000-4000-8000-000000000000") {

View File

@ -6,6 +6,8 @@
// Plugins // Plugins
import { registerPlugins } from '@/plugins' import { registerPlugins } from '@/plugins'
import { createPinia } from 'pinia'
const pinia = createPinia()
// Components // Components
import App from './App.vue' import App from './App.vue'
@ -15,9 +17,9 @@ import GlobalMessage from '@/components/GlobalMessage.vue'
import { createApp } from 'vue' import { createApp } from 'vue'
import Clarity from '@microsoft/clarity'; import Clarity from '@microsoft/clarity';
const projectId = "rhp8uqoc3l" const projectId = "rhp8uqoc3l"
import TDesign from 'tdesign-vue-next' //import TDesign from 'tdesign-vue-next'
import 'tdesign-vue-next/es/style/index.css' //import 'tdesign-vue-next/es/style/index.css'
import '@examaware-cs/player/dist/player.css' //import '@examaware-cs/player/dist/player.css'
Clarity.init(projectId); Clarity.init(projectId);
import messageService from './utils/message'; import messageService from './utils/message';
@ -25,8 +27,9 @@ import messageService from './utils/message';
const app = createApp(App) const app = createApp(App)
registerPlugins(app) registerPlugins(app)
app.use(TDesign) //app.use(TDesign)
app.use(messageService); app.use(messageService);
app.use(pinia)
app.component('GlobalMessage', GlobalMessage) app.component('GlobalMessage', GlobalMessage)

View File

@ -3,24 +3,38 @@
<v-row> <v-row>
<v-col cols="12"> <v-col cols="12">
<div class="d-flex align-center mb-6"> <div class="d-flex align-center mb-6">
<v-icon size="x-large" color="primary" class="mr-3" <v-icon
>mdi-database-sync</v-icon size="x-large"
color="primary"
class="mr-3"
> >
mdi-database-sync
</v-icon>
<div> <div>
<h1 class="text-h4">数据迁移工具</h1> <h1 class="text-h4">
数据迁移工具
</h1>
<div class="text-subtitle-1 text-grey"> <div class="text-subtitle-1 text-grey">
将现有数据迁移至 KV 存储系统 将现有数据迁移至 KV 存储系统
</div> </div>
</div> </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-card-text class="d-flex align-center">
<v-icon color="info" class="mr-2">mdi-information-outline</v-icon> <v-icon
<span color="info"
>使用此工具可以将数据从旧存储系统迁移到新的 KV class="mr-2"
存储系统选择本地或云端迁移以确保数据不会丢失</span
> >
mdi-information-outline
</v-icon>
<span>
使用此工具可以将数据从旧存储系统迁移到新的 KV 存储系统选择本地或云端迁移以确保数据不会丢失
</span>
</v-card-text> </v-card-text>
</v-card> </v-card>
@ -29,12 +43,20 @@
</v-row> </v-row>
<!-- 一键迁移对话框 --> <!-- 一键迁移对话框 -->
<v-dialog v-model="showMigrationDialog" max-width="500" persistent> <v-dialog
v-model="showMigrationDialog"
max-width="500"
persistent
>
<v-card> <v-card>
<v-card-title class="text-h5 d-flex align-center"> <v-card-title class="text-h5 d-flex align-center">
<v-icon color="primary" size="large" class="mr-3" <v-icon
>mdi-database-sync</v-icon color="primary"
size="large"
class="mr-3"
> >
mdi-database-sync
</v-icon>
一键数据迁移 一键数据迁移
</v-card-title> </v-card-title>
<v-card-text class="mt-4"> <v-card-text class="mt-4">
@ -45,8 +67,7 @@
<v-alert <v-alert
color="info" color="info"
variant="outlined" variant="tonal"
density="compact"
class="mt-4" class="mt-4"
icon="mdi-information-outline" icon="mdi-information-outline"
> >
@ -62,7 +83,7 @@
</v-alert> </v-alert>
</v-card-text> </v-card-text>
<v-card-actions> <v-card-actions>
<v-spacer></v-spacer> <v-spacer />
<v-btn <v-btn
color="grey-darken-1" color="grey-darken-1"
variant="text" variant="text"
@ -74,11 +95,16 @@
color="primary" color="primary"
size="large" size="large"
variant="elevated" variant="elevated"
@click="startAutoMigration"
:loading="isAutoMigrating" :loading="isAutoMigrating"
:disabled="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-btn>
</v-card-actions> </v-card-actions>

View File

@ -72,9 +72,9 @@
md="6" md="6"
> >
<v-card> <v-card>
<v-card-title>KvInitialize 预览</v-card-title> <v-card-title>初始化组件已替换</v-card-title>
<v-card-text> <v-card-text>
<kv-initialize /> 已迁移为首页内联的 InitServiceChooser 组件
</v-card-text> </v-card-text>
</v-card> </v-card>
</v-col> </v-col>
@ -84,7 +84,6 @@
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import KvInitialize from '@/components/KvInitialize.vue'
import { getSetting, setSetting } from '@/utils/settings' import { getSetting, setSetting } from '@/utils/settings'
import { kvServerProvider } from '@/utils/providers/kvServerProvider' import { kvServerProvider } from '@/utils/providers/kvServerProvider'

408
src/pages/debug-socket.vue Normal file
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>

View File

@ -7,6 +7,7 @@
<v-spacer /> <v-spacer />
<template #append> <template #append>
<v-btn icon="mdi-chat" variant="text" @click="isChatOpen = true" />
<v-btn <v-btn
icon="mdi-bell" icon="mdi-bell"
variant="text" variant="text"
@ -17,7 +18,10 @@
<v-btn icon="mdi-cog" variant="text" @click="$router.push('/settings')" /> <v-btn icon="mdi-cog" variant="text" @click="$router.push('/settings')" />
</template> </template>
</v-app-bar> </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> <v-container class="main-window flex-grow-1 no-select" fluid>
<!-- 有内容的科目卡片 --> <!-- 有内容的科目卡片 -->
@ -36,6 +40,7 @@
border border
height="100%" height="100%"
class="glow-track" class="glow-track"
:class="{ 'glow-highlight': highlightedCards[item.key] }"
@click="!isEditingDisabled && openDialog(item.key)" @click="!isEditingDisabled && openDialog(item.key)"
@mousemove="handleMouseMove" @mousemove="handleMouseMove"
@touchmove="handleTouchMove" @touchmove="handleTouchMove"
@ -59,7 +64,7 @@
<!-- 单独显示空科目 --> <!-- 单独显示空科目 -->
<div class="empty-subjects mt-4"> <div class="empty-subjects mt-4">
<template v-if="emptySubjectDisplay === 'button'"> <template v-if="emptySubjectDisplay === 'button'">
<v-btn-group divided variant="outlined"> <v-btn-group divided variant="tonal">
<v-btn <v-btn
v-for="subject in unusedSubjects" v-for="subject in unusedSubjects"
:key="subject.name" :key="subject.name"
@ -180,6 +185,7 @@
<!-- 出勤统计区域 --> <!-- 出勤统计区域 -->
<v-col <v-col
v-ripple="{ class: `text-${['primary','secondary','info','success','warning','error'][Math.floor(Math.random()*6)]}` }"
v-if="state.studentList && state.studentList.length" v-if="state.studentList && state.studentList.length"
class="attendance-area no-select" class="attendance-area no-select"
cols="1" cols="1"
@ -541,6 +547,9 @@
<!-- 添加ICP备案悬浮组件 --> <!-- 添加ICP备案悬浮组件 -->
<FloatingICP /> <FloatingICP />
<!-- 设备聊天室右下角浮窗 -->
<ChatWidget v-model="isChatOpen" :show-button="false" />
<!-- 添加确认对话框 --> <!-- 添加确认对话框 -->
<v-dialog v-model="confirmDialog.show" max-width="400"> <v-dialog v-model="confirmDialog.show" max-width="400">
<v-card> <v-card>
@ -608,8 +617,8 @@
确认应用 确认应用
</v-btn> </v-btn>
</v-card-actions> </v-card-actions>
</v-card> </v-card> </v-dialog
</v-dialog><br/><br/><br/><br/><br/><br/> ><br /><br /><br /><br /><br /><br />
</template> </template>
<script> <script>
@ -617,7 +626,9 @@ import MessageLog from "@/components/MessageLog.vue";
import RandomPicker from "@/components/RandomPicker.vue"; import RandomPicker from "@/components/RandomPicker.vue";
import FloatingToolbar from "@/components/FloatingToolbar.vue"; import FloatingToolbar from "@/components/FloatingToolbar.vue";
import FloatingICP from "@/components/FloatingICP.vue"; import FloatingICP from "@/components/FloatingICP.vue";
import ChatWidget from "@/components/ChatWidget.vue";
import HomeworkEditDialog from "@/components/HomeworkEditDialog.vue"; import HomeworkEditDialog from "@/components/HomeworkEditDialog.vue";
import InitServiceChooser from "@/components/InitServiceChooser.vue";
import dataProvider from "@/utils/dataProvider"; import dataProvider from "@/utils/dataProvider";
import { import {
getSetting, getSetting,
@ -631,7 +642,15 @@ import "../styles/transitions.scss";
import "../styles/global.scss"; import "../styles/global.scss";
import { pinyin } from "pinyin-pro"; import { pinyin } from "pinyin-pro";
import { debounce, throttle } from "@/utils/debounce"; 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 { export default {
name: "Classworks 作业板", name: "Classworks 作业板",
@ -641,6 +660,8 @@ export default {
FloatingToolbar, FloatingToolbar,
FloatingICP, FloatingICP,
HomeworkEditDialog, HomeworkEditDialog,
InitServiceChooser,
ChatWidget,
}, },
data() { data() {
const defaultSubjects = [ const defaultSubjects = [
@ -653,7 +674,7 @@ export default {
{ name: "政治", order: 6 }, { name: "政治", order: 6 },
{ name: "历史", order: 7 }, { name: "历史", order: 7 },
{ name: "地理", order: 8 }, { name: "地理", order: 8 },
{ name: "其他", order: 9 } { name: "其他", order: 9 },
]; ];
return { return {
@ -684,7 +705,7 @@ export default {
snackbarText: "", snackbarText: "",
fontSize: getSetting("font.size"), fontSize: getSetting("font.size"),
datePickerDialog: false, datePickerDialog: false,
selectedDate: new Date().toISOString().split("T")[0].replace(/-/g, ''), selectedDate: new Date().toISOString().split("T")[0].replace(/-/g, ""),
selectedDateObj: new Date(), selectedDateObj: new Date(),
refreshInterval: null, refreshInterval: null,
showNoDataMessage: false, showNoDataMessage: false,
@ -721,6 +742,18 @@ export default {
cancelHandler: null, cancelHandler: null,
icons: {}, 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() (key) => this.state.boardData.homework[key].content?.trim()
); );
return this.state.availableSubjects return this.state.availableSubjects
.filter(subject => !usedKeys.includes(subject.name)) .filter((subject) => !usedKeys.includes(subject.name))
.sort((a, b) => a.order - b.order); .sort((a, b) => a.order - b.order);
}, },
emptySubjects() { emptySubjects() {
@ -853,6 +886,16 @@ export default {
showAntiScreenBurnCard() { showAntiScreenBurnCard() {
return getSetting("display.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() { filteredStudents() {
let students = [...this.state.studentList]; let students = [...this.state.studentList];
@ -915,7 +958,7 @@ export default {
subjectOrder() { subjectOrder() {
return [...this.state.availableSubjects] return [...this.state.availableSubjects]
.sort((a, b) => a.order - b.order) .sort((a, b) => a.order - b.order)
.map(subject => subject.name); .map((subject) => subject.name);
}, },
}, },
@ -976,6 +1019,9 @@ export default {
this.checkHashForRandomPicker(); this.checkHashForRandomPicker();
window.addEventListener("hashchange", this.checkHashForRandomPicker); window.addEventListener("hashchange", this.checkHashForRandomPicker);
//
this.setupRealtimeChannel();
} catch (err) { } catch (err) {
console.error("初始化失败:", err); console.error("初始化失败:", err);
this.showError("初始化失败,请刷新页面重试"); this.showError("初始化失败,请刷新页面重试");
@ -1008,6 +1054,15 @@ export default {
); );
window.removeEventListener("hashchange", this.checkHashForRandomPicker); window.removeEventListener("hashchange", this.checkHashForRandomPicker);
// 退
try {
if (this.$offKvChanged) this.$offKvChanged();
if (this.$offConnect) this.$offConnect();
leaveAll();
} catch (e) {
void e;
}
}, },
methods: { methods: {
@ -1194,9 +1249,7 @@ export default {
const response = await dataProvider.loadData("classworks-list-main"); const response = await dataProvider.loadData("classworks-list-main");
if (response.success != false && Array.isArray(response)) { if (response.success != false && Array.isArray(response)) {
this.state.studentList = response.map( this.state.studentList = response.map((student) => student.name);
(student) => student.name
);
} }
} catch (error) { } catch (error) {
console.warn( console.warn(
@ -1214,7 +1267,9 @@ export default {
async loadSubjects() { async loadSubjects() {
try { try {
const subjectsResponse = await dataProvider.loadData("classworks-config-subject"); const subjectsResponse = await dataProvider.loadData(
"classworks-config-subject"
);
if (subjectsResponse && Array.isArray(subjectsResponse)) { if (subjectsResponse && Array.isArray(subjectsResponse)) {
// //
this.state.availableSubjects = subjectsResponse; this.state.availableSubjects = subjectsResponse;
@ -1370,6 +1425,8 @@ export default {
this.state.contentStyle = { "font-size": `${this.state.fontSize}px` }; this.state.contentStyle = { "font-size": `${this.state.fontSize}px` };
this.setupAutoRefresh(); this.setupAutoRefresh();
this.updateBackendUrl(); this.updateBackendUrl();
// shouldShowInit
this.settingsTick++;
}, },
async handleDateSelect(newDate) { async handleDateSelect(newDate) {
@ -1393,10 +1450,7 @@ export default {
.catch(() => {}); .catch(() => {});
// Load both data and subjects in parallel // Load both data and subjects in parallel
await Promise.all([ await Promise.all([this.downloadData(), this.loadSubjects()]);
this.downloadData(),
this.loadSubjects()
]);
} }
} catch (error) { } catch (error) {
console.error("Date processing error:", 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() { setAllPresent() {
this.state.boardData.attendance = { this.state.boardData.attendance = {
@ -1728,7 +1856,7 @@ export default {
try { try {
const binaryString = atob(configParam); 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 decodedString = new TextDecoder().decode(bytes);
const decodedConfig = JSON.parse(decodedString); const decodedConfig = JSON.parse(decodedString);
console.log("从URL读取配置:", decodedConfig); console.log("从URL读取配置:", decodedConfig);

View File

@ -43,7 +43,31 @@
style="width: 100%" style="width: 100%"
direction="vertical" 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 title="Classworks" subtitle="设置" class="rounded-xl" border>
<v-card-text> <v-card-text>
<v-alert <v-alert
@ -146,7 +170,6 @@
/> />
</v-tabs-window-item> </v-tabs-window-item>
<v-tabs-window-item value="randomPicker"> <v-tabs-window-item value="randomPicker">
<random-picker-card border :is-mobile="isMobile" /> <random-picker-card border :is-mobile="isMobile" />
</v-tabs-window-item> </v-tabs-window-item>

View File

@ -28,6 +28,27 @@
} }
} }
// 数据更新高亮效果
.glow-highlight {
animation: glow-pulse 3s ease-in-out;
box-shadow: 0 0 20px rgba(33, 150, 243, 0.6),
0 0 40px rgba(33, 150, 243, 0.4),
0 0 60px rgba(33, 150, 243, 0.2) !important;
}
@keyframes glow-pulse {
0%, 100% {
box-shadow: 0 0 20px rgba(33, 150, 243, 0.6),
0 0 40px rgba(33, 150, 243, 0.4),
0 0 60px rgba(33, 150, 243, 0.2);
}
50% {
box-shadow: 0 0 30px rgba(33, 150, 243, 0.8),
0 0 60px rgba(33, 150, 243, 0.6),
0 0 90px rgba(33, 150, 243, 0.4);
}
}
// 添加卡片悬浮效果 // 添加卡片悬浮效果
.grid-item .v-card { .grid-item .v-card {
transition: transform 0.3s ease, box-shadow 0.3s ease; transition: transform 0.3s ease, box-shadow 0.3s ease;
@ -295,80 +316,6 @@
transform: scale(1.02); transform: scale(1.02);
} }
// 添加卡片发光效果
.glow-track {
position: relative;
overflow: hidden;
transition: all 0.3s ease;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(circle at var(--x, 50%) var(--y, 50%),
rgba(255, 255, 255, 0.15) 0%,
rgba(255, 255, 255, 0) 70%);
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
z-index: 1;
}
&:hover::before {
opacity: 1;
}
}
// 添加卡片悬浮效果
.grid-item .v-card {
transition: transform 0.3s ease, box-shadow 0.3s ease;
&:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15) !important;
}
&:active {
transform: translateY(-2px);
}
}
// 添加空科目卡片样式
.empty-subject-card {
transition: all 0.3s ease;
opacity: 0.8;
&:hover {
opacity: 1;
transform: translateY(-4px);
}
}
// 修改防烧屏提示卡片使用 tonal 样式减少信息密度
.anti-burn-card {
animation: subtle-glow 4s infinite alternate;
transition: all 0.3s ease;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
}
}
@keyframes subtle-glow {
0% {
box-shadow: 0 0 5px rgba(33, 150, 243, 0.1);
}
100% {
box-shadow: 0 0 15px rgba(33, 150, 243, 0.3);
}
}
// 出勤管理对话框样式 // 出勤管理对话框样式
.attendance-stat { .attendance-stat {
height: 100%; height: 100%;

View File

@ -183,7 +183,7 @@ export default {
if (autoConfigureCloud) { if (autoConfigureCloud) {
// 使用classworksCloudDefaults配置 // 使用classworksCloudDefaults配置
const classworksCloudDefaults = { const classworksCloudDefaults = {
"server.domain": "https://kv.wuyuan.dev", "server.domain": "http://localhost:3030",
"server.siteKey": "", "server.siteKey": "",
}; };

View File

@ -68,8 +68,8 @@ const SETTINGS_STORAGE_KEY = "Classworks_settings";
// 新增: Classworks云端存储的默认设置 // 新增: Classworks云端存储的默认设置
const classworksCloudDefaults = { const classworksCloudDefaults = {
"server.domain": "https://kv.wuyuan.dev", //"server.domain": "https://kv.wuyuan.dev",
//"server.domain": "http://localhost:3030", "server.domain": "http://localhost:3030",
"server.siteKey": "", "server.siteKey": "",
}; };
@ -206,7 +206,7 @@ const settingsDefinitions = {
}, },
"server.authDomain": { "server.authDomain": {
type: "string", type: "string",
default: "https://kv.houlang.cloud", default: "http://localhost:5173",
description: "授权服务器域名", description: "授权服务器域名",
icon: "mdi-shield-account", icon: "mdi-shield-account",
validate: (value) => { validate: (value) => {

87
src/utils/socketClient.js Normal file
View File

@ -0,0 +1,87 @@
// Lightweight reusable Socket.IO client singleton
// - Uses server domain from settings when available
// - Exposes join/leave helpers and event on/off wrappers
import { io } from 'socket.io-client';
import { getSetting } from '@/utils/settings';
let socket = null;
let connectedDomain = null;
const listeners = new Set();
export function getServerUrl() {
// Prefer configured server domain; fallback to env; then current origin
const cfg = getSetting('server.domain');
const envUrl = import.meta?.env?.VITE_SERVER_URL;
return cfg || envUrl || window.location.origin;
}
export function getSocket() {
const serverUrl = getServerUrl();
if (!socket || connectedDomain !== serverUrl) {
if (socket) {
try { socket.disconnect(); } catch (e) {
void e; // ignore
}
socket = null;
}
connectedDomain = serverUrl;
socket = io(serverUrl, { transports: ['websocket'] });
// Re-attach previously registered event handlers on new socket instance
listeners.forEach(({ event, handler }) => {
socket.on(event, handler);
});
}
return socket;
}
export function on(event, handler) {
const s = getSocket();
s.on(event, handler);
listeners.add({ event, handler });
return () => off(event, handler);
}
export function off(event, handler) {
if (!socket) return;
socket.off(event, handler);
// Remove only matching entry
for (const item of Array.from(listeners)) {
if (item.event === event && item.handler === handler) {
listeners.delete(item);
}
}
}
export function joinToken(token) {
const s = getSocket();
if (!token) return;
s.emit('join-token', { token });
}
export function leaveToken(token) {
if (!socket) return;
socket.emit('leave-token', { token });
}
export function leaveAll() {
if (!socket) return;
socket.emit('leave-all');
}
export function onConnect(handler) {
const s = getSocket();
s.on('connect', handler);
return () => s.off('connect', handler);
}
export function disconnect() {
if (!socket) return;
try { socket.disconnect(); } catch (e) {
void e; // ignore
}
socket = null;
connectedDomain = null;
listeners.clear();
}

View File

@ -7,7 +7,7 @@ import Vue from '@vitejs/plugin-vue'
import VueRouter from 'unplugin-vue-router/vite' import VueRouter from 'unplugin-vue-router/vite'
import Vuetify, { transformAssetUrls } from 'vite-plugin-vuetify' import Vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa'
import { TDesignResolver } from 'unplugin-vue-components/resolvers' //import { TDesignResolver } from 'unplugin-vue-components/resolvers'
// Utilities // Utilities
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
@ -156,11 +156,11 @@ export default defineConfig({
}, },
}), }),
Components({ Components({
resolvers: [ //resolvers: [
TDesignResolver({ // TDesignResolver({
library: 'vue-next' // library: 'vue-next'
}) // })
] //]
}), }),
Fonts({ Fonts({
google: { google: {