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
20 changed files with 1790 additions and 476 deletions
-71
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>
+396
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>
+32 -57
View File
@@ -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"
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>
<a
class="floating-icp-link"
href="https://beian.miit.gov.cn/"
target="_blank"
rel="noopener noreferrer"
aria-label="浙ICP备2024068645号"
>
浙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>
</style>
+242
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>
+1 -1
View File
@@ -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") {