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:
parent
b9efaee7ee
commit
a2b0cc9e08
1
.gitignore
vendored
1
.gitignore
vendored
@ -172,4 +172,3 @@ dist
|
||||
vite.config.*.timestamp-*.mjs
|
||||
*.timestamp-*
|
||||
|
||||
kv-admin
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "Classworks",
|
||||
"name": "classworks",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"version": "0.0.0",
|
||||
@ -14,17 +14,21 @@
|
||||
"@examaware-cs/player": "^1.0.2",
|
||||
"@mdi/font": "7.4.47",
|
||||
"@microsoft/clarity": "^1.0.0",
|
||||
"@vueuse/core": "^13.9.0",
|
||||
"axios": "^1.11.0",
|
||||
"idb": "^8.0.3",
|
||||
"js-base64": "^3.7.8",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lucide-vue-next": "^0.545.0",
|
||||
"marked": "^16.4.0",
|
||||
"pinyin-pro": "^3.27.0",
|
||||
"ratelimit-header-parser": "^0.1.0",
|
||||
"roboto-fontface": "*",
|
||||
"tdesign-vue-next": "^1.17.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"typewriter-effect": "^2.21.0",
|
||||
"uuid": "^9.0.1",
|
||||
"vue": "^3.5.20",
|
||||
"vue-sonner": "^2.0.9",
|
||||
"vuetify": "^3.9.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
568
pnpm-lock.yaml
generated
568
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
14
src/App.vue
14
src/App.vue
@ -1,11 +1,15 @@
|
||||
<template>
|
||||
<v-app>
|
||||
<!-- KvInitialize 组件自行决定是否展示或执行跳转 -->
|
||||
<kv-initialize />
|
||||
<!-- 正常路由 -->
|
||||
<router-view v-slot="{ Component, route }">
|
||||
<transition name="md3" mode="out-in">
|
||||
<component :is="Component" :key="route.path" />
|
||||
<transition
|
||||
name="md3"
|
||||
mode="out-in"
|
||||
>
|
||||
<component
|
||||
:is="Component"
|
||||
:key="route.path"
|
||||
/>
|
||||
</transition>
|
||||
</router-view>
|
||||
<global-message />
|
||||
@ -18,9 +22,7 @@ import { onMounted } from "vue";
|
||||
import { useTheme } from "vuetify";
|
||||
import { getSetting } from "@/utils/settings";
|
||||
import RateLimitModal from "@/components/RateLimitModal.vue";
|
||||
import KvInitialize from "@/components/KvInitialize.vue";
|
||||
import Clarity from "@microsoft/clarity";
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@ -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>
|
||||
396
src/components/ChatWidget.vue
Normal file
396
src/components/ChatWidget.vue
Normal 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>
|
||||
@ -1,77 +1,52 @@
|
||||
<template>
|
||||
<v-slide-x-transition>
|
||||
<v-card
|
||||
class="floating-icp"
|
||||
elevation="2"
|
||||
rounded="pill"
|
||||
variant="tonal"
|
||||
color="surface-variant"
|
||||
@mouseenter="isHovered = true"
|
||||
@mouseleave="isHovered = false"
|
||||
>
|
||||
<v-btn
|
||||
variant="text"
|
||||
class="icp-button"
|
||||
<a
|
||||
class="floating-icp-link"
|
||||
href="https://beian.miit.gov.cn/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="浙ICP备2024068645号"
|
||||
>
|
||||
<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>
|
||||
浙ICP备2024068645号
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'FloatingICP',
|
||||
data() {
|
||||
return {
|
||||
isHovered: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.floating-icp {
|
||||
.floating-icp-link {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
right: 4px;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
font-size: 14px;
|
||||
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 {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
|
||||
.icp-button {
|
||||
padding: 0 16px;
|
||||
height: 32px;
|
||||
min-width: unset;
|
||||
}
|
||||
|
||||
.rotate-icon {
|
||||
transform: rotate(360deg);
|
||||
transition: transform 0.6s ease;
|
||||
.floating-icp-link:hover,
|
||||
.floating-icp-link:focus,
|
||||
.floating-icp-link:active {
|
||||
color: rgb(65, 65, 65);
|
||||
text-decoration: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.floating-icp {
|
||||
.floating-icp-link {
|
||||
right: 16px;
|
||||
bottom: 80px; /* 避免与其他悬浮组件重叠 */
|
||||
}
|
||||
|
||||
.icp-button {
|
||||
padding: 0 12px;
|
||||
bottom: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
242
src/components/InitServiceChooser.vue
Normal file
242
src/components/InitServiceChooser.vue
Normal 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>
|
||||
@ -94,7 +94,7 @@ const goToAuthorize = () => {
|
||||
|
||||
const uuid =
|
||||
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 参数用于迁移
|
||||
if (uuid !== "00000000-0000-4000-8000-000000000000") {
|
||||
|
||||
11
src/main.js
11
src/main.js
@ -6,6 +6,8 @@
|
||||
|
||||
// Plugins
|
||||
import { registerPlugins } from '@/plugins'
|
||||
import { createPinia } from 'pinia'
|
||||
const pinia = createPinia()
|
||||
|
||||
// Components
|
||||
import App from './App.vue'
|
||||
@ -15,9 +17,9 @@ import GlobalMessage from '@/components/GlobalMessage.vue'
|
||||
import { createApp } from 'vue'
|
||||
import Clarity from '@microsoft/clarity';
|
||||
const projectId = "rhp8uqoc3l"
|
||||
import TDesign from 'tdesign-vue-next'
|
||||
import 'tdesign-vue-next/es/style/index.css'
|
||||
import '@examaware-cs/player/dist/player.css'
|
||||
//import TDesign from 'tdesign-vue-next'
|
||||
//import 'tdesign-vue-next/es/style/index.css'
|
||||
//import '@examaware-cs/player/dist/player.css'
|
||||
|
||||
Clarity.init(projectId);
|
||||
import messageService from './utils/message';
|
||||
@ -25,8 +27,9 @@ import messageService from './utils/message';
|
||||
const app = createApp(App)
|
||||
|
||||
registerPlugins(app)
|
||||
app.use(TDesign)
|
||||
//app.use(TDesign)
|
||||
app.use(messageService);
|
||||
app.use(pinia)
|
||||
|
||||
app.component('GlobalMessage', GlobalMessage)
|
||||
|
||||
|
||||
@ -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-text class="d-flex align-center">
|
||||
<v-icon color="info" class="mr-2">mdi-information-outline</v-icon>
|
||||
<span
|
||||
>使用此工具可以将数据从旧存储系统迁移到新的 KV
|
||||
存储系统,选择本地或云端迁移,以确保数据不会丢失。</span
|
||||
<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-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'
|
||||
|
||||
|
||||
408
src/pages/debug-socket.vue
Normal file
408
src/pages/debug-socket.vue
Normal 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>
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
@ -295,80 +316,6 @@
|
||||
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 {
|
||||
height: 100%;
|
||||
|
||||
@ -183,7 +183,7 @@ export default {
|
||||
if (autoConfigureCloud) {
|
||||
// 使用classworksCloudDefaults配置
|
||||
const classworksCloudDefaults = {
|
||||
"server.domain": "https://kv.wuyuan.dev",
|
||||
"server.domain": "http://localhost:3030",
|
||||
"server.siteKey": "",
|
||||
};
|
||||
|
||||
|
||||
@ -68,8 +68,8 @@ const SETTINGS_STORAGE_KEY = "Classworks_settings";
|
||||
|
||||
// 新增: Classworks云端存储的默认设置
|
||||
const classworksCloudDefaults = {
|
||||
"server.domain": "https://kv.wuyuan.dev",
|
||||
//"server.domain": "http://localhost:3030",
|
||||
//"server.domain": "https://kv.wuyuan.dev",
|
||||
"server.domain": "http://localhost:3030",
|
||||
"server.siteKey": "",
|
||||
};
|
||||
|
||||
@ -206,7 +206,7 @@ const settingsDefinitions = {
|
||||
},
|
||||
"server.authDomain": {
|
||||
type: "string",
|
||||
default: "https://kv.houlang.cloud",
|
||||
default: "http://localhost:5173",
|
||||
description: "授权服务器域名",
|
||||
icon: "mdi-shield-account",
|
||||
validate: (value) => {
|
||||
|
||||
87
src/utils/socketClient.js
Normal file
87
src/utils/socketClient.js
Normal 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();
|
||||
}
|
||||
@ -7,7 +7,7 @@ import Vue from '@vitejs/plugin-vue'
|
||||
import VueRouter from 'unplugin-vue-router/vite'
|
||||
import Vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { TDesignResolver } from 'unplugin-vue-components/resolvers'
|
||||
//import { TDesignResolver } from 'unplugin-vue-components/resolvers'
|
||||
|
||||
// Utilities
|
||||
import { defineConfig } from 'vite'
|
||||
@ -156,11 +156,11 @@ export default defineConfig({
|
||||
},
|
||||
}),
|
||||
Components({
|
||||
resolvers: [
|
||||
TDesignResolver({
|
||||
library: 'vue-next'
|
||||
})
|
||||
]
|
||||
//resolvers: [
|
||||
// TDesignResolver({
|
||||
// library: 'vue-next'
|
||||
// })
|
||||
//]
|
||||
}),
|
||||
Fonts({
|
||||
google: {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user