1
0
mirror of https://github.com/ZeroCatDev/Classworks.git synced 2025-12-07 13:03:59 +00:00
This commit is contained in:
干冰DryIce 2025-11-02 12:09:46 +08:00
commit c36e4defc2
31 changed files with 3677 additions and 496 deletions

5
.env.example Normal file
View File

@ -0,0 +1,5 @@
# Classworks KV 默认服务器域名
VITE_DEFAULT_KV_SERVER=https://kv.wuyuan.dev
# Classworks KV 授权服务器域名
VITE_DEFAULT_AUTH_SERVER=https://kv.houlang.cloud

View File

@ -32,6 +32,10 @@ jobs:
node-version: "20.x"
- name: Build
env:
VITE_APP_ID: d158067f53627d2b98babe8bffd2fd7d
VITE_DEFAULT_KV_SERVER: https://kv.wuyuan.dev
VITE_DEFAULT_AUTH_SERVER: https://kv.houlang.cloud
run: |
npm install
npm run build

1
.gitignore vendored
View File

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

View File

@ -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

File diff suppressed because it is too large Load Diff

View File

@ -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(() => {

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>
<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>

View File

@ -0,0 +1,393 @@
<template>
<div
v-if="visible"
class="init-overlay"
>
<div class="init-container">
<div class="init-header">
<div class="title">
欢迎使用 Classworks
</div>
<div class="subtitle">
请选择你的使用方式
</div>
</div>
<!-- 主要选择卡片 -->
<div class="main-card-row">
<!-- 初次使用 -->
<v-card
class="main-service-card gradient-new clickable"
elevation="4"
@click="showGuideDialog = true"
>
<v-card-item>
<div class="card-horizontal-layout">
<div class="card-icon-wrapper">
<v-icon
size="48"
color="primary"
>
mdi-new-box
</v-icon>
</div>
<div class="card-content">
<div class="text-h6 font-weight-bold">
初次使用
</div>
<div class="text-body-2 text-medium-emphasis mt-1">
了解 Classworks KV 并开始使用
</div>
</div>
</div>
</v-card-item>
</v-card>
<!-- 已注册设备 -->
<v-card
class="main-service-card gradient-registered clickable"
elevation="4"
@click="showDeviceAuthDialog = true"
>
<v-card-item>
<div class="card-horizontal-layout">
<div class="card-icon-wrapper">
<v-icon
size="48"
color="success"
>
mdi-account-check
</v-icon>
</div>
<div class="card-content">
<div class="text-h6 font-weight-bold">
已注册
</div>
<div class="text-body-2 text-medium-emphasis mt-1">
使用设备 Namespace 登录
</div>
</div>
</div>
</v-card-item>
</v-card>
<!-- Classworks KV 控制台 -->
<v-card
class="main-service-card clickable"
elevation="4"
@click="openClassworksKV"
>
<v-card-item>
<div class="card-horizontal-layout">
<div class="card-icon-wrapper">
<v-icon
size="48"
color="info"
>
mdi-database-cog
</v-icon>
</div>
<div class="card-content">
<div class="text-h6 font-weight-bold">
Classworks KV
</div>
<div class="text-body-2 text-medium-emphasis mt-1">
打开云端控制台管理数据
</div>
</div>
</div>
</v-card-item>
</v-card>
</div>
<div class="options-buttons">
<v-btn
variant="tonal"
prepend-icon="mdi-laptop"
size="small"
@click="useLocalMode"
>
使用本地模式
</v-btn>
<v-btn
variant="tonal"
prepend-icon="mdi-flash"
size="small"
@click="handleAutoAuthorize"
>
授权码式授权弃用
</v-btn>
<v-btn
variant="tonal"
prepend-icon="mdi-key"
size="small"
@click="showTokenDialog = true"
>
输入 Token
</v-btn>
<v-btn
variant="tonal"
prepend-icon="mdi-code-tags"
size="small"
@click="showAlternativeCodeDialog = true"
>
输入替代代码
</v-btn>
</div>
<div class="footer-hint">
完成授权后可使用作业同步考试看板等在线功能
</div>
</div>
<!-- 对话框 -->
<v-dialog
v-model="showGuideDialog"
max-width="600"
>
<FirstTimeGuide @close="showGuideDialog = false" />
</v-dialog>
<v-dialog
v-model="showDeviceAuthDialog"
max-width="500"
>
<DeviceAuthDialog
:show-cancel="true"
@success="handleAuthSuccess"
@cancel="showDeviceAuthDialog = false"
/>
</v-dialog>
<v-dialog
v-model="showTokenDialog"
max-width="500"
>
<TokenInputDialog
:show-cancel="true"
@success="handleTokenSuccess"
@cancel="showTokenDialog = false"
/>
</v-dialog>
<v-dialog
v-model="showAlternativeCodeDialog"
max-width="500"
>
<AlternativeCodeDialog
:show-cancel="true"
@submit="handleAlternativeCodeSubmit"
@cancel="showAlternativeCodeDialog = false"
/>
</v-dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { getSetting, setSetting } from '@/utils/settings'
import DeviceAuthDialog from './auth/DeviceAuthDialog.vue'
import TokenInputDialog from './auth/TokenInputDialog.vue'
import AlternativeCodeDialog from './auth/AlternativeCodeDialog.vue'
import FirstTimeGuide from './auth/FirstTimeGuide.vue'
const emit = defineEmits(['done'])
// kvToken provider kv-local
const visible = ref(false)
//
const showGuideDialog = ref(false)
const showDeviceAuthDialog = ref(false)
const showTokenDialog = ref(false)
const showAlternativeCodeDialog = ref(false)
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 handleAuthSuccess = () => {
showDeviceAuthDialog.value = false
evaluateVisibility()
emit('done')
}
const handleTokenSuccess = () => {
showTokenDialog.value = false
evaluateVisibility()
emit('done')
}
const handleAlternativeCodeSubmit = (code) => {
console.log('替代代码:', code)
// TODO:
showAlternativeCodeDialog.value = false
}
const useLocalMode = () => {
setSetting('server.provider', 'kv-local')
visible.value = false
//
window.location.reload()
emit('done')
}
const openClassworksKV = () => {
window.open(getSetting('server.authDomain'), '_blank')
}
</script>
<style scoped>
.init-overlay {
position: relative;
}
.init-container {
max-width: 900px;
margin: 24px auto;
padding: 8px 16px;
}
.init-header .title {
font-size: 28px;
font-weight: 700;
text-align:left;
margin-bottom: 8px;
}
.init-header .subtitle {
font-size: 14px;
opacity: .75;
text-align: left;
}
/* 主要卡片 */
.main-card-row {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 32px;
}
.main-service-card {
min-height: 100px;
cursor: pointer;
transition: all 0.3s ease;
}
.main-service-card:hover {
box-shadow: 0 4px 12px rgba(0,0,0,0.15) !important;
}
.main-service-card .v-card-item {
padding: 20px 24px;
}
.card-horizontal-layout {
display: flex;
align-items: center;
gap: 20px;
}
.card-icon-wrapper {
flex-shrink: 0;
}
.card-content {
flex: 1;
text-align: left;
}
.gradient-new {
background: linear-gradient(135deg, rgba(33,150,243,.12), rgba(103,80,164,0.08) 60%);
border: 2px solid rgba(33,150,243,.2);
}
.gradient-registered {
background: linear-gradient(135deg, rgba(76,175,80,.12), rgba(0,184,212,0.08) 60%);
border: 2px solid rgba(76,175,80,.2);
}
.gradient-kv {
background: linear-gradient(135deg, rgba(0,184,212,.12), rgba(33,150,243,0.08) 60%);
border: 2px solid rgba(0,184,212,.2);
}
/* 其他选项 */
.alternative-options {
margin-top: 40px;
padding: 20px;
background: rgba(var(--v-theme-surface-variant), 0.3);
border-radius: 12px;
}
.options-title {
font-size: 14px;
font-weight: 600;
opacity: 0.8;
margin-bottom: 12px;
text-align: left;
}
.options-buttons {
margin-top: 24px;
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-start;
}
.clickable {
cursor: pointer;
}
.footer-hint {
margin-top: 24px;
font-size: 13px;
opacity: .7;
text-align: left;
}
@media (max-width: 768px) {
.card-horizontal-layout {
gap: 16px;
}
.card-icon-wrapper .v-icon {
font-size: 40px !important;
}
.options-buttons {
flex-direction: column;
}
.options-buttons .v-btn {
width: 100%;
}
}
</style>

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") {

View File

@ -225,7 +225,7 @@
<script>
import { openDB } from "idb";
import axios from "@/assets/fonts/axios/axios";
import axios from "@/axios/axios";
import { getSetting, setSetting } from "@/utils/settings";
export default {

View File

@ -0,0 +1,125 @@
<template>
<v-alert
v-if="isReadOnly"
type="warning"
variant="tonal"
prominent
closable
class="readonly-warning"
@click:close="dismissed = true"
>
<template #prepend>
<v-icon icon="mdi-lock-alert" />
</template>
<v-alert-title>当前使用只读 Token</v-alert-title>
<div class="text-body-2">
您当前的访问令牌为只读权限无法修改数据如需编辑权限请联系管理员或重新授权
</div>
<template v-if="tokenInfo">
<div class="mt-2 text-caption">
<div>
<strong>设备类型</strong>{{ deviceTypeLabel }}
</div>
<div v-if="tokenInfo.note">
<strong>备注</strong>{{ tokenInfo.note }}
</div>
<div v-if="tokenInfo.device">
<strong>设备</strong>{{ tokenInfo.device.name }} ({{ tokenInfo.device.namespace }})
</div>
</div>
</template>
</v-alert>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { getSetting } from '@/utils/settings'
import axios from '@/axios/axios'
const props = defineProps({
autoCheck: {
type: Boolean,
default: true
}
})
const tokenInfo = ref(null)
const loading = ref(false)
const dismissed = ref(false)
const isReadOnly = computed(() => {
return !dismissed.value && tokenInfo.value?.isReadOnly === true
})
const deviceTypeLabel = computed(() => {
const typeMap = {
student: '学生',
teacher: '教师',
admin: '管理员',
readonly: '只读',
}
return typeMap[tokenInfo.value?.deviceType] || tokenInfo.value?.deviceType || '未知'
})
const checkTokenPermission = async () => {
const provider = getSetting('server.provider')
const isKvProvider = provider === 'kv-server' || provider === 'classworkscloud'
if (!isKvProvider) {
return
}
const kvToken = getSetting('server.kvToken')
if (!kvToken) {
return
}
loading.value = true
try {
const serverUrl = getSetting('server.domain')
if (!serverUrl) return
const response = await axios.get(`${serverUrl}/kv/_token`, {
headers: {
Authorization: `Bearer ${kvToken}`
}
})
if (response.data) {
tokenInfo.value = response.data
}
} catch (err) {
console.error('获取 Token 信息失败:', err)
} finally {
loading.value = false
}
}
//
const kvToken = computed(() => getSetting('server.kvToken'))
watch(kvToken, () => {
if (props.autoCheck) {
checkTokenPermission()
}
})
onMounted(() => {
if (props.autoCheck) {
checkTokenPermission()
}
})
//
defineExpose({
checkTokenPermission,
tokenInfo,
isReadOnly
})
</script>
<style scoped>
.readonly-warning {
margin: 16px;
border-left: 4px solid rgb(var(--v-theme-warning));
}
</style>

View File

@ -0,0 +1,261 @@
<template>
<!-- 学生姓名选择对话框 -->
<v-dialog
v-model="showDialog"
max-width="500"
persistent
>
<v-card>
<v-card-title>设置学生姓名</v-card-title>
<v-card-text>
<div class="mb-2">
请从列表中选择您的姓名
</div>
<v-autocomplete
v-model="selectedName"
:items="studentList"
item-title="name"
item-value="name"
label="学生姓名"
placeholder="选择您的姓名"
clearable
hide-details
/>
<div
v-if="studentList.length > 0"
class="mt-2 text-caption text-medium-emphasis"
>
{{ studentList.length }} 位学生
</div>
<v-alert
v-if="error"
type="error"
variant="tonal"
class="mt-3"
>
{{ error }}
</v-alert>
</v-card-text>
<v-card-actions>
<v-btn
variant="text"
@click="skipSetting"
>
稍后设置
</v-btn>
<v-spacer />
<v-btn
:disabled="!selectedName || saving"
:loading="saving"
color="primary"
@click="saveStudentName"
>
确认
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 顶栏学生姓名显示通过插槽暴露给父组件 -->
<slot
name="header-display"
:student-name="currentStudentName"
:is-student="isStudentToken"
:open-dialog="openDialog"
/>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { getSetting, watchSettings } from '@/utils/settings'
import axios from '@/axios/axios'
import dataProvider from '@/utils/dataProvider'
const emit = defineEmits(['token-info-updated'])
const showDialog = ref(false)
const selectedName = ref('')
const studentList = ref([])
const currentStudentName = ref('')
const saving = ref(false)
const error = ref('')
const tokenInfo = ref(null)
const isStudentToken = computed(() => tokenInfo.value?.deviceType === 'student')
const isReadOnly = computed(() => tokenInfo.value?.isReadOnly === true)
const displayName = computed(() => tokenInfo.value?.note || '设置名称')
const hasToken = computed(() => !!kvToken.value)
const kvToken = computed(() => getSetting('server.kvToken'))
const provider = computed(() => getSetting('server.provider'))
const isKvProvider = computed(() => provider.value === 'kv-server' || provider.value === 'classworkscloud')
//
const checkStudentNameStatus = async () => {
if (!isKvProvider.value || !kvToken.value) {
return
}
try {
const serverUrl = getSetting('server.domain')
if (!serverUrl) return
// Token
const tokenResponse = await axios.get(`${serverUrl}/kv/_token`, {
headers: {
Authorization: `Bearer ${kvToken.value}`
}
})
tokenInfo.value = tokenResponse.data
//
if (tokenInfo.value.deviceType !== 'student') {
return
}
//
currentStudentName.value = tokenInfo.value.note || ''
//
const listResponse = await axios.get(`${serverUrl}/kv/classworks-list-main`, {
headers: {
Authorization: `Bearer ${kvToken.value}`
}
})
const list = listResponse.data.value || []
studentList.value = Array.isArray(list) ? list : []
//
if (studentList.value.length === 0) {
return
}
//
const currentNote = tokenInfo.value.note || ''
const nameExists = studentList.value.some(student => student.name === currentNote)
//
if (!currentNote || !nameExists) {
showDialog.value = true
selectedName.value = ''
}
} catch (err) {
console.error('检查学生姓名状态失败:', err)
}
}
//
const saveStudentName = async () => {
if (!selectedName.value || saving.value) return
error.value = ''
saving.value = true
try {
const serverUrl = getSetting('server.domain')
const token = kvToken.value
const response = await axios.post(
`${serverUrl}/apps/tokens/${token}/set-student-name`,
{
name: selectedName.value
}
)
if (response.data.success) {
currentStudentName.value = selectedName.value
showDialog.value = false
// token
await checkStudentNameStatus()
//
emit('token-info-updated')
}
} catch (err) {
const status = err?.response?.status
if (status === 400) {
error.value = '该名称不在学生列表中,请选择正确的姓名'
} else if (status === 403) {
error.value = '只有学生类型的 Token 可以设置姓名'
} else if (status === 404) {
error.value = '设备未设置学生列表或 Token 不存在'
} else {
error.value = err?.response?.data?.error?.message || err?.message || '设置失败,请稍后重试'
}
} finally {
saving.value = false
}
}
//
const skipSetting = () => {
showDialog.value = false
}
//
const openDialog = async () => {
console.log('StudentNameManager.openDialog called')
console.log('isStudentToken:', isStudentToken.value)
console.log('studentList.length:', studentList.value.length)
console.log('currentStudentName:', currentStudentName.value)
if (!isStudentToken.value) {
console.log('Not a student token, cannot open dialog')
return
}
studentList.value = await dataProvider.loadData('classworks-list-main')
// token使
//
if (studentList.value.length === 0) {
console.log('Student list is empty, trying to load...')
//
checkStudentNameStatus().then(() => {
if (studentList.value.length > 0) {
selectedName.value = currentStudentName.value
showDialog.value = true
} else {
console.warn('Student list is still empty after reload')
}
})
} else {
selectedName.value = currentStudentName.value
showDialog.value = true
console.log('Dialog opened, showDialog:', showDialog.value)
}
}
// token
watch(kvToken, () => {
checkStudentNameStatus()
})
//
watchSettings(() => {
checkStudentNameStatus()
})
// tokenInfo
watch(tokenInfo, () => {
emit('token-info-updated')
}, { deep: true })
//
onMounted(() => {
checkStudentNameStatus()
})
//
defineExpose({
checkStudentNameStatus,
openDialog,
currentStudentName,
isStudentToken,
isReadOnly,
displayName,
hasToken,
tokenInfo
})
</script>
<style scoped>
/* 组件样式 */
</style>

View File

@ -0,0 +1,68 @@
<template>
<v-card>
<v-card-title>输入替代代码</v-card-title>
<v-card-text>
<v-textarea
v-model="code"
label="替代代码"
placeholder="请输入替代代码"
variant="outlined"
density="comfortable"
rows="5"
hide-details="auto"
/>
<v-alert
type="info"
variant="tonal"
class="mt-3"
>
替代代码功能暂未实现敬请期待
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
v-if="showCancel"
variant="text"
@click="$emit('cancel')"
>
取消
</v-btn>
<v-btn
:disabled="!code"
color="primary"
@click="submit"
>
提交
</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup>
import { ref } from 'vue'
defineProps({
showCancel: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['submit', 'cancel'])
const code = ref('')
const submit = () => {
if (!code.value) return
// TODO:
emit('submit', code.value)
}
//
defineExpose({
reset: () => {
code.value = ''
}
})
</script>

View File

@ -0,0 +1,198 @@
<template>
<v-card class="auth-card">
<v-card-text class="pa-8">
<div class="text-center mb-6">
<v-icon
size="80"
color="success"
class="mb-4"
>
mdi-account-key
</v-icon>
<h2 class="text-h4 mb-3">
设备认证
</h2>
<p class="text-body-1 text-medium-emphasis">
输入你在 Classworks KV 获取的认证信息
</p>
</div>
<v-card
variant="tonal"
color="info"
class="pa-4 mb-6"
>
<div class="text-body-2">
<v-icon
size="20"
class="mr-2"
>
mdi-information
</v-icon>
对于已有UUID的用户您应当使用UUID与您的密码登录
</div>
</v-card>
<div class="form-section">
<v-text-field
v-model="form.namespace"
label="命名空间"
class="mb-4"
variant="outlined"
hide-details="auto"
prepend-inner-icon="mdi-identifier"
>
</v-text-field>
<v-text-field
v-model="form.password"
label="认证码"
type="text"
variant="outlined"
prepend-inner-icon="mdi-lock-outline"
>
</v-text-field>
<v-alert
v-if="error"
type="error"
variant="tonal"
class="mt-4"
closable
@click:close="error = ''"
>
{{ error }}
</v-alert>
</div>
</v-card-text>
<v-card-actions class="pa-6 pt-0">
<v-btn
v-if="showCancel"
size="large"
variant="text"
@click="$emit('cancel')"
>
取消
</v-btn>
<v-spacer />
<v-btn
:disabled="!form.namespace || authenticating"
:loading="authenticating"
size="x-large"
color="primary"
variant="elevated"
class="px-8"
@click="authenticate"
>
<v-icon
start
size="24"
>
mdi-login
</v-icon>
<span class="text-h6">认证并登录</span>
</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup>
import { ref } from 'vue'
import { getSetting, setSetting } from '@/utils/settings'
import axios from '@/axios/axios'
defineProps({
showCancel: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['success', 'cancel'])
const form = ref({
namespace: '',
password: ''
})
const authenticating = ref(false)
const error = ref('')
const authenticate = async () => {
if (!form.value.namespace || authenticating.value) return
error.value = ''
authenticating.value = true
try {
const serverUrl = getSetting('server.domain')
if (!serverUrl) throw new Error('未配置服务器域名')
// token
const response = await axios.post(`${serverUrl}/apps/auth/token`, {
namespace: form.value.namespace,
password: form.value.password || undefined,
appId: "d158067f53627d2b98babe8bffd2fd7d"
})
if (!response.data.success) {
throw new Error('设备验证失败')
}
const tokenData = response.data
// Token
setSetting('server.kvToken', tokenData.token)
// device uuid
if (tokenData.device?.uuid) {
setSetting('device.uuid', tokenData.device.uuid)
}
emit('success', tokenData)
} catch (err) {
const status = err?.response?.status
if (status === 401 || status === 403) {
error.value = '密码错误或无权限访问'
} else if (status === 404) {
error.value = '设备不存在,请检查 namespace 是否正确'
} else {
error.value = err?.response?.data?.error?.message || err?.message || '认证失败,请稍后重试'
}
} finally {
authenticating.value = false
}
}
//
defineExpose({
reset: () => {
form.value = { namespace: '', password: '' }
error.value = ''
}
})
</script>
<style scoped>
.auth-card {
max-width: 100%;
min-height: 500px;
}
.form-section {
max-width: 600px;
margin: 0 auto;
}
/* 触屏优化 */
.v-btn {
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.v-btn.v-btn--size-x-large {
min-height: 60px;
}
</style>

View File

@ -0,0 +1,671 @@
<template>
<v-card class="guide-card">
<!-- 进度指示器 -->
<v-progress-linear
:model-value="(currentStep / totalSteps) * 100"
color="primary"
height="6"
/>
<v-card-text class="pa-8">
<!-- 步骤 1: 欢迎 -->
<div
v-show="currentStep === 1"
class="step-content"
>
<div class="text-center mb-6">
<v-icon
size="80"
color="primary"
class="mb-4"
>
mdi-hand-wave
</v-icon>
<h2 class="text-h4 mb-3">
欢迎使用 Classworks
</h2>
<p class="text-body-1 text-medium-emphasis">
适用于班级大屏的作业板小工具
</p>
</div>
</div>
<!-- 步骤 2: Classworks KV 的关系图 -->
<div
v-show="currentStep === 2"
class="step-content"
>
<h3 class="text-h5 mb-6 text-center">
Classworks Classworks KV 的关系
</h3>
<v-card
variant="tonal"
color="primary"
class="pa-6 mb-6"
>
<div class="relationship-diagram">
<!-- Classworks 应用 -->
<div class="diagram-item">
<v-card
elevation="8"
color="blue-darken-1"
class="pa-4"
>
<div class="text-center">
<v-icon
size="60"
color="white"
>
mdi-laptop
</v-icon>
<h4 class="text-h6 text-white mt-2">
Classworks
</h4>
<p class="text-caption text-white mt-1">
作业板应用
</p>
</div>
</v-card>
<div class="diagram-description mt-3">
<v-chip
color="blue"
variant="flat"
size="small"
class="mb-2"
>
前端应用
</v-chip>
<div class="text-body-2">
显示作业内容<br>
管理班级信息<br>
提供用户界面
</div>
</div>
</div>
<!-- 连接线 -->
<div class="diagram-connector">
<v-icon
size="40"
color="primary"
>
mdi-swap-horizontal
</v-icon>
<div class="text-caption font-weight-bold mt-2">
数据同步
</div>
</div>
<!-- Classworks KV -->
<div class="diagram-item">
<v-card
elevation="8"
color="green-darken-1"
class="pa-4"
>
<div class="text-center">
<v-icon
size="60"
color="white"
>
mdi-cloud-sync
</v-icon>
<h4 class="text-h6 text-white mt-2">
Classworks KV
</h4>
<p class="text-caption text-white mt-1">
云端数据库
</p>
</div>
</v-card>
<div class="diagram-description mt-3">
<v-chip
color="green"
variant="flat"
size="small"
class="mb-2"
>
后端服务
</v-chip>
<div class="text-body-2">
存储作业数据<br>
多设备同步<br>
权限管理
</div>
</div>
</div>
</div>
</v-card>
<!-- 工作流程说明 -->
<v-card
variant="outlined"
class="pa-5 mb-4"
>
<h4 class="text-h6 mb-4">
<v-icon
color="primary"
class="mr-2"
>
mdi-information
</v-icon>
工作流程
</h4>
<v-timeline
side="end"
density="compact"
line-thickness="2"
>
<v-timeline-item
dot-color="primary"
size="small"
>
<div class="text-body-2">
<strong>步骤 1:</strong> Classworks 应用中编辑作业
</div>
</v-timeline-item>
<v-timeline-item
dot-color="success"
size="small"
>
<div class="text-body-2">
<strong>步骤 2:</strong> 数据自动上传到 Classworks KV
</div>
</v-timeline-item>
<v-timeline-item
dot-color="info"
size="small"
>
<div class="text-body-2">
<strong>步骤 3:</strong> 其他设备从 Classworks KV 同步数据
</div>
</v-timeline-item>
<v-timeline-item
dot-color="warning"
size="small"
>
<div class="text-body-2">
<strong>步骤 4:</strong> 所有设备显示相同的作业内容
</div>
</v-timeline-item>
</v-timeline>
</v-card>
<!-- 优势说明 -->
<v-row>
<v-col
cols="12"
md="4"
>
<v-card
variant="tonal"
color="blue"
class="pa-4 h-100"
>
<v-icon
size="40"
color="blue-darken-2"
class="mb-2"
>
mdi-devices
</v-icon>
<h5 class="text-subtitle-1 font-weight-bold mb-2">
多设备访问
</h5>
<p class="text-body-2">
在教室办公室家中的任何设备上访问相同的数据
</p>
</v-card>
</v-col>
<v-col
cols="12"
md="4"
>
<v-card
variant="tonal"
color="green"
class="pa-4 h-100"
>
<v-icon
size="40"
color="green-darken-2"
class="mb-2"
>
mdi-sync
</v-icon>
<h5 class="text-subtitle-1 font-weight-bold mb-2">
实时同步
</h5>
<p class="text-body-2">
修改后立即同步所有设备保持数据一致
</p>
</v-card>
</v-col>
<v-col
cols="12"
md="4"
>
<v-card
variant="tonal"
color="orange"
class="pa-4 h-100"
>
<v-icon
size="40"
color="orange-darken-2"
class="mb-2"
>
mdi-shield-lock
</v-icon>
<h5 class="text-subtitle-1 font-weight-bold mb-2">
安全可靠
</h5>
<p class="text-body-2">
通过密码和命名空间隔离保护班级数据安全
</p>
</v-card>
</v-col>
</v-row>
</div>
<!-- 步骤 3: 询问使用场景 -->
<div
v-show="currentStep === 3"
class="step-content"
>
<h3 class="text-h5 mb-6 text-center">
你需要在多个设备上查看作业吗
</h3>
<v-card
variant="tonal"
color="info"
class="mb-6 pa-4"
>
<div class="text-body-2">
比如在家里电脑手机上查看或者多个教室设备共享数据
</div>
</v-card>
<div class="button-group">
<v-btn
size="x-large"
block
variant="elevated"
color="primary"
class="mb-4 py-6"
@click="selectStorageType('cloud')"
>
<div class="d-flex flex-column align-center py-2">
<v-icon
size="40"
class="mb-2"
>
mdi-cloud-check
</v-icon>
<span class="text-h6">需要使用云同步</span>
<span class="text-caption mt-1">多设备访问</span>
</div>
</v-btn>
<v-btn
size="x-large"
block
variant="outlined"
class="py-6"
@click="selectStorageType('local')"
>
<div class="d-flex flex-column align-center py-2">
<v-icon
size="40"
class="mb-2"
>
mdi-laptop
</v-icon>
<span class="text-h6">不需要只用这台设备</span>
<span class="text-caption mt-1">本地存储</span>
</div>
</v-btn>
</div>
</div>
<!-- 步骤 4a: 本地存储确认 -->
<div
v-show="currentStep === 4 && storageType === 'local'"
class="step-content"
>
<div class="text-center mb-6">
<v-icon
size="80"
color="success"
class="mb-4"
>
mdi-check-circle
</v-icon>
<h3 class="text-h5 mb-4">
您可以使用本地模式
</h3>
<v-card
variant="tonal"
class="pa-4 text-left"
>
<div class="text-body-1 mb-2">
此数据将存储在您的浏览器中如果您的浏览器不支持IndexedDB可能会出现问题如果您经常清除浏览器数据请谨慎使用本地模式
</div>
<div class="text-body-1 mb-2">
在刚才地方点击使用本地模式的按钮使用
</div>
</v-card>
</div>
</div>
<!-- 步骤 4b: 云存储说明 -->
<div
v-show="currentStep === 4 && storageType === 'cloud'"
class="step-content"
>
<div class="text-center mb-6">
<v-icon
size="80"
color="primary"
class="mb-4"
>
mdi-cloud-cog
</v-icon>
<h3 class="text-h5 mb-4">
需要先设置云端账号
</h3>
</div>
<v-card
:variant="kvserverurl=='https://kv.houlang.cloud'? 'elevated' : 'outlined'"
:color=" kvserverurl=='https://kv.houlang.cloud'? 'primary' : 'error' "
class="pa-6 mb-6"
@click="openKVSite"
>
<v-icon
size="48"
class="mb-3"
>
mdi-open-in-new
</v-icon>
<h4 class="text-h6 font-weight-bold">
请访问 {{ kvserverurl=='https://kv.houlang.cloud'? 'Classworks KV' : '自定义的 Classworks KV 实例 ' }} 控制台
</h4>
<div class="text-h5 mb-6">
{{ kvserverurl }}
</div>
<h6 class="text-subtitle-2">
{{ kvserverurl=='https://kv.houlang.cloud'? '此实例由 Classworks KV 官方提供' : '此链接由您的实例、预配代码或管理员管理,当前可能不是 Classworks KV 官方的实例地址。' }}
</h6>
</v-card>
<v-card
variant="tonal"
color="info"
class="pa-5"
>
<div class="text-body-1 mb-3">
<v-icon
size="20"
class="mr-2"
>
mdi-information
</v-icon>
在控制台完成以下操作
</div>
<div class="text-body-2 mb-2">
1. 注册或登录账号
</div>
<div class="text-body-2 mb-2">
2. 创建班级空间
</div>
<div class="text-body-2 mb-2">
3. 获取命名空间和密码
</div>
<div class="text-body-2">
4. 返回这里输入认证信息
</div>
</v-card>
<!-- 常见问题 -->
<v-expansion-panels
class="mt-6"
variant="accordion"
>
<v-expansion-panel>
<v-expansion-panel-title>
<div class="d-flex align-center">
<v-icon
class="mr-3"
color="warning"
>
mdi-help-circle
</v-icon>
<span class="text-subtitle-1 font-weight-medium">我以前已经使用过 Classworks KV</span>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-card
variant="tonal"
color="success"
class="pa-4"
>
<div class="text-body-2 mb-2">
如果您之前已经使用过 Classworks KV可以直接使用您的 <strong>UUID命名空间</strong> <strong>设置的密码</strong> 进行认证
</div>
<div class="text-body-2">
返回上一页点击"已注册"按钮输入您的认证信息即可登录
</div>
</v-card>
</v-expansion-panel-text>
</v-expansion-panel>
<v-expansion-panel>
<v-expansion-panel-title>
<div class="d-flex align-center">
<v-icon
class="mr-3"
color="info"
>
mdi-help-circle
</v-icon>
<span class="text-subtitle-1 font-weight-medium">我如何配置不同类型的设备</span>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-card
variant="tonal"
color="info"
class="pa-4"
>
<div class="text-body-2 mb-2">
不同的密码对应不同的设备类型这将由 <strong>管理员管理</strong>
</div>
<div class="text-body-2 mb-2">
例如
</div>
<ul class="text-body-2 ml-4">
<li class="mb-1">
班级大屏使用一个密码
</li>
<li class="mb-1">
教师设备使用另一个密码
</li>
<li>学生设备使用不同的密码</li>
</ul>
<div class="text-body-2 mt-3">
请联系您的管理员获取对应设备类型的密码
</div>
</v-card>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
</v-card-text>
<!-- 底部操作按钮 -->
<v-card-actions class="pa-6 pt-0">
<v-btn
v-if="currentStep > 1"
size="large"
variant="text"
@click="prevStep"
>
<v-icon start>
mdi-chevron-left
</v-icon>
上一步
</v-btn>
<v-spacer />
<v-btn
v-if="currentStep < 4"
:disabled="currentStep === 3 && !storageType"
size="large"
color="primary"
variant="elevated"
@click="nextStep"
>
下一步
<v-icon end>
mdi-chevron-right
</v-icon>
</v-btn>
<v-btn
v-else
size="large"
color="primary"
variant="elevated"
@click="finish"
>
关闭
</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup>
import { ref } from 'vue'
import { getSetting } from '@/utils/settings'
const emit = defineEmits(['close'])
const kvserverurl = getSetting('server.authDomain')
const currentStep = ref(1)
const totalSteps = 4
const storageType = ref('')
const nextStep = () => {
if (currentStep.value < totalSteps) {
currentStep.value++
}
}
const prevStep = () => {
if (currentStep.value > 1) {
currentStep.value--
}
}
const selectStorageType = (type) => {
storageType.value = type
nextStep()
}
const finish = () => {
emit('close')
}
const openKVSite = () => {
window.open(kvserverurl, '_blank')
}
</script>
<style scoped>
.guide-card {
max-width: 100%;
min-height: 500px;
}
.step-content {
min-height: 400px;
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.button-group {
max-width: 600px;
margin: 0 auto;
}
.step-item {
cursor: default;
}
/* 触屏优化 */
.v-btn {
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
/* 大按钮区域便于点击 */
.v-btn.v-btn--size-x-large {
min-height: 120px;
}
/* 关系图样式 */
.relationship-diagram {
display: flex;
justify-content: space-around;
align-items: flex-start;
gap: 20px;
flex-wrap: wrap;
}
.diagram-item {
flex: 1;
min-width: 200px;
max-width: 300px;
}
.diagram-connector {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-width: 100px;
}
.diagram-description {
text-align: center;
}
@media (max-width: 768px) {
.relationship-diagram {
flex-direction: column;
align-items: center;
}
.diagram-connector {
transform: rotate(90deg);
margin: 20px 0;
}
}
</style>

View File

@ -0,0 +1,133 @@
# 认证组件
这个目录包含可复用的认证相关组件,可以在应用的任何地方使用。
## 组件列表
### DeviceAuthDialog.vue
设备认证对话框,用于通过 namespace 和密码进行设备认证。
**Props:**
- `showCancel` (Boolean): 是否显示取消按钮,默认为 `false`
**Events:**
- `@success`: 认证成功时触发,传递认证数据
- `@cancel`: 点击取消按钮时触发
**暴露的方法:**
- `reset()`: 清空表单和错误信息
**使用示例:**
```vue
<template>
<v-dialog v-model="dialog">
<DeviceAuthDialog
:show-cancel="true"
@success="handleSuccess"
@cancel="dialog = false"
/>
</v-dialog>
</template>
<script setup>
import DeviceAuthDialog from '@/components/auth/DeviceAuthDialog.vue'
</script>
```
---
### TokenInputDialog.vue
Token 输入对话框,用于手动输入 KV 授权 Token。
**Props:**
- `showCancel` (Boolean): 是否显示取消按钮,默认为 `false`
**Events:**
- `@success`: Token 验证成功时触发
- `@cancel`: 点击取消按钮时触发
**暴露的方法:**
- `reset()`: 清空表单和错误信息
**使用示例:**
```vue
<template>
<v-dialog v-model="dialog">
<TokenInputDialog
:show-cancel="true"
@success="handleSuccess"
@cancel="dialog = false"
/>
</v-dialog>
</template>
<script setup>
import TokenInputDialog from '@/components/auth/TokenInputDialog.vue'
</script>
```
---
### AlternativeCodeDialog.vue
替代代码输入对话框(功能暂未实现)。
**Props:**
- `showCancel` (Boolean): 是否显示取消按钮,默认为 `false`
**Events:**
- `@submit`: 提交代码时触发,传递代码内容
- `@cancel`: 点击取消按钮时触发
**暴露的方法:**
- `reset()`: 清空表单
**使用示例:**
```vue
<template>
<v-dialog v-model="dialog">
<AlternativeCodeDialog
:show-cancel="true"
@submit="handleSubmit"
@cancel="dialog = false"
/>
</v-dialog>
</template>
<script setup>
import AlternativeCodeDialog from '@/components/auth/AlternativeCodeDialog.vue'
</script>
```
---
### FirstTimeGuide.vue
初次使用指南,介绍 Classworks KV 的功能和使用方式。
**Events:**
- `@close`: 关闭指南时触发
**使用示例:**
```vue
<template>
<v-dialog v-model="dialog">
<FirstTimeGuide @close="dialog = false" />
</v-dialog>
</template>
<script setup>
import FirstTimeGuide from '@/components/auth/FirstTimeGuide.vue'
</script>
```
## 设计原则
1. **可复用性**: 所有组件都被设计为独立可复用的,可以在应用的任何地方使用
2. **独立性**: 每个组件都包含自己的逻辑和样式,不依赖外部状态
3. **统一接口**: 所有对话框组件都遵循相同的 props 和 events 模式
4. **响应式设计**: 组件适配各种屏幕尺寸
## 注意事项
- 这些组件需要配合 Vuetify 使用
- 组件内部使用了 `@/utils/settings``@/axios/axios`,确保这些依赖可用
- 建议将这些组件包裹在 `v-dialog` 中使用,以获得最佳的用户体验

View File

@ -0,0 +1,103 @@
<template>
<v-card>
<v-card-title>输入授权 Token</v-card-title>
<v-card-text>
<v-text-field
v-model="token"
label="KV 授权 Token"
placeholder="粘贴从授权页面获取的 Token"
variant="outlined"
density="comfortable"
hide-details="auto"
clearable
/>
<v-alert
v-if="error"
type="error"
variant="tonal"
class="mt-3"
>
{{ error }}
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
v-if="showCancel"
variant="text"
@click="$emit('cancel')"
>
取消
</v-btn>
<v-btn
:disabled="!token || verifying"
:loading="verifying"
color="primary"
@click="saveToken"
>
保存 Token
</v-btn>
</v-card-actions>
</v-card>
</template>
<script setup>
import { ref } from 'vue'
import { getSetting, setSetting } from '@/utils/settings'
import axios from '@/axios/axios'
defineProps({
showCancel: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['success', 'cancel'])
const token = ref('')
const verifying = ref(false)
const error = ref('')
const saveToken = async () => {
if (!token.value || verifying.value) return
error.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': token.value,
},
})
//
setSetting('server.kvToken', token.value)
emit('success')
} catch (err) {
const status = err?.response?.status
if (status === 401 || status === 403) {
error.value = 'Token 无效或无权限,请确认后重试'
} else if (status === 404) {
error.value = '命名空间不存在或服务器未就绪'
} else {
error.value = err?.response?.data?.message || err?.message || '验证失败,请稍后重试'
}
} finally {
verifying.value = false
}
}
//
defineExpose({
reset: () => {
token.value = ''
error.value = ''
}
})
</script>

View File

@ -65,16 +65,16 @@
</div>
</div>
</v-card-text> </v-card
><v-card title="Classworks KV" subtitle="云原生键值数据库" border hover
><v-card title="Classworks KV" subtitle="文档形键值数据库" border hover
><v-card-text
>Classworks KV
是厚浪云推出的云原生键值数据库其是一个开放的云应用平台为各种应用提供存储服务此设备正在使用其服务如果您希望管理设备信息请前往
是厚浪云推出的文档形键值数据库其是一个开放的云应用平台为各种应用提供存储服务此设备正在使用其服务如果您希望管理设备信息请前往
Classworks KV
的网站如果您在服务推出前就在使用 Classworks您的数据已被自动迁移
<br/><br/>Classworks KV 的全域管理员是 <a href="https://wuyuan.dev" target="_blank">孙悟元</a></v-card-text
><v-card-actions
><v-btn
href="https://kv.houlang.cloud"
:href="defaultAuthServer"
class="text-none"
append-icon="mdi-open-in-new"
target="_blank"
@ -102,15 +102,35 @@
刷新设备信息
</v-btn>
<v-btn color="primary" @click="reinitializeCloudStorage">
<v-btn color="error" variant="outlined" @click="showReinitDialog = true">
重新初始化云端存储
</v-btn>
</v-card-actions>
<!-- 重新初始化确认对话框 -->
<v-dialog v-model="showReinitDialog" max-width="500">
<v-card>
<v-card-title>确认重新初始化</v-card-title>
<v-card-text>
<v-alert type="warning" variant="tonal" class="mb-3">
<v-alert-title>警告</v-alert-title>
此操作将清除当前的云端存储配置包括 Token您需要重新进行授权
</v-alert>
<p>您确定要重新初始化云端存储吗</p>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showReinitDialog = false">取消</v-btn>
<v-btn color="error" @click="confirmReinitialize">确认</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-card>
</template>
<script>
import { kvServerProvider } from "@/utils/providers/kvServerProvider";
import { setSetting, getSetting } from "@/utils/settings";
export default {
name: "CloudNamespaceInfoCard",
@ -125,6 +145,8 @@ export default {
namespaceInfo: {},
loading: false,
hasNamespaceInfo: false,
showReinitDialog: false, //
defaultAuthServer: getSetting('server.authDomain'),
};
},
watch: {
@ -168,13 +190,16 @@ export default {
async reloadInfo() {
await this.fetchNamespaceInfo();
},
reinitializeCloudStorage() {
// KvInitialize
try {
window.dispatchEvent(new CustomEvent("kvinit:open"));
} catch (e) {
console.error("重新初始化云端存储失败:", e);
}
confirmReinitialize() {
// token shouldShowInit
setSetting('server.kvToken', '');
setSetting('device.uuid', '');
//
this.showReinitDialog = false;
// InitServiceChooser
this.$router.push('/');
},
},
};

View File

@ -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)

View File

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

View File

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

408
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,31 @@
<v-spacer />
<template #append>
<!-- 只读 Token 警告 -->
<v-chip
v-if="tokenDisplayInfo.readonly"
color="warning"
variant="tonal"
class="mx-2"
prepend-icon="mdi-lock-alert"
>
只读
</v-chip>
<!-- 学生名称显示 chip始终蓝色 -->
<v-chip
v-if="tokenDisplayInfo.show"
color="primary"
variant="tonal"
class="mx-2"
prepend-icon="mdi-account"
:style="{ cursor: tokenDisplayInfo.disabled ? 'default' : 'pointer' }"
@click="handleTokenChipClick"
>
{{ tokenDisplayInfo.text }}
</v-chip>
<v-btn icon="mdi-chat" variant="text" @click="isChatOpen = true" />
<v-btn
icon="mdi-bell"
variant="text"
@ -17,7 +42,17 @@
<v-btn icon="mdi-cog" variant="text" @click="$router.push('/settings')" />
</template>
</v-app-bar>
<div class="d-flex">
<!-- 初始化选择卡片仅在首页且需要授权时显示不影响顶栏 -->
<init-service-chooser v-if="shouldShowInit" @done="settingsTick++" />
<!-- 学生姓名管理组件 -->
<StudentNameManager
v-if="!shouldShowInit"
ref="studentNameManager"
@token-info-updated="updateTokenDisplayInfo"
/>
<div v-if="!shouldShowInit" class="d-flex">
<!-- 主要内容区域 -->
<v-container class="main-window flex-grow-1 no-select" fluid>
<!-- 有内容的科目卡片 -->
@ -36,6 +71,7 @@
border
height="100%"
class="glow-track"
:class="{ 'glow-highlight': highlightedCards[item.key] }"
@click="!isEditingDisabled && openDialog(item.key)"
@mousemove="handleMouseMove"
@touchmove="handleTouchMove"
@ -59,7 +95,7 @@
<!-- 单独显示空科目 -->
<div class="empty-subjects mt-4">
<template v-if="emptySubjectDisplay === 'button'">
<v-btn-group divided variant="outlined">
<v-btn-group divided variant="tonal">
<v-btn
v-for="subject in unusedSubjects"
:key="subject.name"
@ -180,6 +216,7 @@
<!-- 出勤统计区域 -->
<v-col
v-ripple="{ class: `text-${['primary','secondary','info','success','warning','error'][Math.floor(Math.random()*6)]}` }"
v-if="state.studentList && state.studentList.length"
class="attendance-area no-select"
cols="1"
@ -541,6 +578,9 @@
<!-- 添加ICP备案悬浮组件 -->
<FloatingICP />
<!-- 设备聊天室右下角浮窗 -->
<ChatWidget v-model="isChatOpen" :show-button="false" />
<!-- 添加确认对话框 -->
<v-dialog v-model="confirmDialog.show" max-width="400">
<v-card>
@ -608,8 +648,8 @@
确认应用
</v-btn>
</v-card-actions>
</v-card>
</v-dialog><br/><br/><br/>
</v-card> </v-dialog
><br /><br /><br />
</template>
<script>
@ -617,7 +657,10 @@ import MessageLog from "@/components/MessageLog.vue";
import RandomPicker from "@/components/RandomPicker.vue";
import FloatingToolbar from "@/components/FloatingToolbar.vue";
import FloatingICP from "@/components/FloatingICP.vue";
import ChatWidget from "@/components/ChatWidget.vue";
import HomeworkEditDialog from "@/components/HomeworkEditDialog.vue";
import InitServiceChooser from "@/components/InitServiceChooser.vue";
import StudentNameManager from "@/components/StudentNameManager.vue";
import dataProvider from "@/utils/dataProvider";
import {
getSetting,
@ -631,7 +674,15 @@ import "../styles/transitions.scss";
import "../styles/global.scss";
import { pinyin } from "pinyin-pro";
import { debounce, throttle } from "@/utils/debounce";
import { Base64 } from 'js-base64';
import { Base64 } from "js-base64";
import {
getSocket,
on as socketOn,
off as socketOff,
joinToken,
leaveAll,
onConnect as onSocketConnect,
} from "@/utils/socketClient";
export default {
name: "Classworks 作业板",
@ -641,6 +692,9 @@ export default {
FloatingToolbar,
FloatingICP,
HomeworkEditDialog,
InitServiceChooser,
ChatWidget,
StudentNameManager,
},
data() {
const defaultSubjects = [
@ -653,7 +707,7 @@ export default {
{ name: "政治", order: 6 },
{ name: "历史", order: 7 },
{ name: "地理", order: 8 },
{ name: "其他", order: 9 }
{ name: "其他", order: 9 },
];
return {
@ -684,7 +738,7 @@ export default {
snackbarText: "",
fontSize: getSetting("font.size"),
datePickerDialog: false,
selectedDate: new Date().toISOString().split("T")[0].replace(/-/g, ''),
selectedDate: new Date().toISOString().split("T")[0].replace(/-/g, ""),
selectedDateObj: new Date(),
refreshInterval: null,
showNoDataMessage: false,
@ -721,6 +775,28 @@ export default {
cancelHandler: null,
icons: {},
},
settingsTick: 0,
isChatOpen: false,
highlightedCards: {}, //
// Token token
tokenDisplayInfo: {
show: false,
readonly: false, // token
text: '',
color: 'primary',
variant: 'tonal',
icon: 'mdi-account',
disabled: false
},
//
realtimeInfo: {
show: false,
time: "",
key: "",
},
$offKvChanged: null,
$offConnect: null,
debouncedRealtimeRefresh: null,
};
},
@ -782,7 +858,7 @@ export default {
(key) => this.state.boardData.homework[key].content?.trim()
);
return this.state.availableSubjects
.filter(subject => !usedKeys.includes(subject.name))
.filter((subject) => !usedKeys.includes(subject.name))
.sort((a, b) => a.order - b.order);
},
emptySubjects() {
@ -853,6 +929,16 @@ export default {
showAntiScreenBurnCard() {
return getSetting("display.showAntiScreenBurnCard");
},
shouldShowInit() {
const provider = getSetting("server.provider");
const isKv = provider === "kv-server" || provider === "classworkscloud";
const token = getSetting("server.kvToken");
//
const onHome = this.$route?.path === "/";
// settingsTick 使
void this.settingsTick;
return onHome && isKv && (!token || token === "");
},
filteredStudents() {
let students = [...this.state.studentList];
@ -915,7 +1001,7 @@ export default {
subjectOrder() {
return [...this.state.availableSubjects]
.sort((a, b) => a.order - b.order)
.map(subject => subject.name);
.map((subject) => subject.name);
},
},
@ -956,6 +1042,24 @@ export default {
this.updateSettings();
});
//
this.$nextTick(() => {
const studentNameManager = this.$refs.studentNameManager;
if (studentNameManager) {
this.studentNameInfo.name = studentNameManager.currentStudentName;
this.studentNameInfo.isStudent = studentNameManager.isStudentToken;
this.studentNameInfo.openDialog = () => studentNameManager.openDialog();
//
this.$watch(() => studentNameManager.currentStudentName, (newName) => {
this.studentNameInfo.name = newName;
});
this.$watch(() => studentNameManager.isStudentToken, (isStudent) => {
this.studentNameInfo.isStudent = isStudent;
});
}
});
document.addEventListener(
"fullscreenchange",
this.fullscreenChangeHandler
@ -976,6 +1080,14 @@ export default {
this.checkHashForRandomPicker();
window.addEventListener("hashchange", this.checkHashForRandomPicker);
//
this.setupRealtimeChannel();
// Token
this.$nextTick(() => {
this.updateTokenDisplayInfo();
});
} catch (err) {
console.error("初始化失败:", err);
this.showError("初始化失败,请刷新页面重试");
@ -1008,9 +1120,63 @@ export default {
);
window.removeEventListener("hashchange", this.checkHashForRandomPicker);
// 退
try {
if (this.$offKvChanged) this.$offKvChanged();
if (this.$offConnect) this.$offConnect();
leaveAll();
} catch (e) {
void e;
}
},
methods: {
// Token
updateTokenDisplayInfo() {
const manager = this.$refs.studentNameManager
if (!manager || !manager.hasToken) {
this.tokenDisplayInfo.show = false
this.tokenDisplayInfo.readonly = false
return
}
const displayName = manager.displayName
const isReadOnly = manager.isReadOnly
const isStudent = manager.isStudentToken
// token
this.tokenDisplayInfo.readonly = isReadOnly
// token chip
if (!isStudent) {
this.tokenDisplayInfo.show = false
return
}
//
this.tokenDisplayInfo.text = displayName
this.tokenDisplayInfo.color = 'primary'
this.tokenDisplayInfo.icon = 'mdi-account'
this.tokenDisplayInfo.disabled = isReadOnly //
this.tokenDisplayInfo.show = true
},
// Token Chip
handleTokenChipClick() {
console.log('Token chip clicked')
const manager = this.$refs.studentNameManager
console.log('Manager:', manager)
console.log('Is student token:', manager?.isStudentToken)
if (manager && manager.isStudentToken) {
console.log('Opening dialog...')
manager.openDialog()
} else {
console.log('Cannot open dialog - conditions not met')
}
},
ensureDate(dateInput) {
if (dateInput instanceof Date) {
return dateInput;
@ -1085,10 +1251,13 @@ export default {
if (response.error.code === "NOT_FOUND") {
this.state.showNoDataMessage = true;
this.state.noDataMessage = response.error.message;
this.state.boardData = {
homework: {},
attendance: { absent: [], late: [], exclude: [] },
};
//
if (!this.state.boardData || (!this.state.boardData.homework && !this.state.boardData.attendance)) {
this.state.boardData = {
homework: {},
attendance: { absent: [], late: [], exclude: [] },
};
}
} else {
throw new Error(response.error.message);
}
@ -1106,11 +1275,16 @@ export default {
this.$message.success("下载成功", "数据已更新");
}
} catch (error) {
this.state.boardData = {
homework: {},
attendance: { absent: [], late: [], exclude: [] },
};
//
console.error("数据加载失败:", error);
this.$message.error("下载失败", error.message);
//
if (!this.state.boardData || (!this.state.boardData.homework && !this.state.boardData.attendance)) {
this.state.boardData = {
homework: {},
attendance: { absent: [], late: [], exclude: [] },
};
}
} finally {
this.loading.download = false;
}
@ -1194,9 +1368,7 @@ export default {
const response = await dataProvider.loadData("classworks-list-main");
if (response.success != false && Array.isArray(response)) {
this.state.studentList = response.map(
(student) => student.name
);
this.state.studentList = response.map((student) => student.name);
}
} catch (error) {
console.warn(
@ -1214,7 +1386,9 @@ export default {
async loadSubjects() {
try {
const subjectsResponse = await dataProvider.loadData("classworks-config-subject");
const subjectsResponse = await dataProvider.loadData(
"classworks-config-subject"
);
if (subjectsResponse && Array.isArray(subjectsResponse)) {
//
this.state.availableSubjects = subjectsResponse;
@ -1370,6 +1544,8 @@ export default {
this.state.contentStyle = { "font-size": `${this.state.fontSize}px` };
this.setupAutoRefresh();
this.updateBackendUrl();
// shouldShowInit
this.settingsTick++;
},
async handleDateSelect(newDate) {
@ -1393,10 +1569,7 @@ export default {
.catch(() => {});
// Load both data and subjects in parallel
await Promise.all([
this.downloadData(),
this.loadSubjects()
]);
await Promise.all([this.downloadData(), this.loadSubjects()]);
}
} catch (error) {
console.error("Date processing error:", error);
@ -1430,6 +1603,80 @@ export default {
}));
},
//
setupRealtimeChannel() {
try {
const token = getSetting("server.kvToken");
if (!token) {
console.warn("未配置 KV Token无法加入实时频道");
return;
}
// Ensure socket created
getSocket();
joinToken(token);
// Re-join on reconnect
this.$offConnect = onSocketConnect(() => joinToken(token));
// Debounce refresh to avoid storms
if (!this.debouncedRealtimeRefresh) {
this.debouncedRealtimeRefresh = debounce(async () => {
const oldHomework = JSON.parse(
JSON.stringify(this.state.boardData.homework)
);
await this.downloadData();
const now = new Date();
const hh = String(now.getHours()).padStart(2, "0");
const mm = String(now.getMinutes()).padStart(2, "0");
const ss = String(now.getSeconds()).padStart(2, "0");
// 使
this.$message?.info(
"数据已更新",
`已于 ${hh}:${mm}:${ss} 自动刷新`
); //
const changed = {};
for (const key in this.state.boardData.homework) {
const oldContent = oldHomework[key]?.content || "";
const newContent =
this.state.boardData.homework[key]?.content || "";
if (oldContent !== newContent) {
changed[key] = true;
}
}
//
for (const key in oldHomework) {
if (!this.state.boardData.homework[key]) {
changed[key] = true;
}
}
//
this.highlightedCards = changed;
// 3
setTimeout(() => {
this.highlightedCards = {};
}, 10000);
}, 800);
}
const handler = (msg) => {
// Expect msg = { uuid, key, action, created?, updatedAt?, deletedAt?, batch? }
if (!msg) return;
// We only care about current date key changes
const expectedKey = `classworks-data-${this.state.dateString}`;
if (msg.key !== expectedKey) return;
if (msg.action !== "upsert" && msg.action !== "delete") return;
// Trigger a debounced refresh
this.debouncedRealtimeRefresh?.(msg.key);
};
this.$offKvChanged = socketOn("kv-key-changed", handler);
} catch (e) {
console.warn("实时频道初始化失败", e);
}
},
setAllPresent() {
this.state.boardData.attendance = {
@ -1728,7 +1975,7 @@ export default {
try {
const binaryString = atob(configParam);
const bytes = Uint8Array.from(binaryString, c => c.charCodeAt(0));
const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0));
const decodedString = new TextDecoder().decode(bytes);
const decodedConfig = JSON.parse(decodedString);
console.log("从URL读取配置:", decodedConfig);

View File

@ -11,7 +11,7 @@
icon="mdi-menu"
variant="text"
@click="drawer = !drawer"
class="d-md-none"
/>
</template>
<v-app-bar-title class="text-h6">设置</v-app-bar-title>
@ -43,7 +43,32 @@
style="width: 100%"
direction="vertical"
>
<v-tabs-window-item value="index">
<v-tabs-window-item value="index"
><v-card class="service-card gradient-right clickable" elevation="8">
<v-card-item>
<div class="card-title">
<div>
<div class="text-h6">Classworks KV</div>
<div class="text-caption text-medium-emphasis">
文档形键值数据库
</div>
</div>
</div>
</v-card-item>
<v-card-text>
<div class="mt-4">
<v-btn
variant="text"
class="text-none"
append-icon="mdi-arrow-right"
rounded="xl"
@click="openClassworksKV"
>
打开 Classworks KV
</v-btn>
</div>
</v-card-text>
</v-card>
<v-card title="Classworks" subtitle="设置" class="rounded-xl" border>
<v-card-text>
<v-alert
@ -146,7 +171,6 @@
/>
</v-tabs-window-item>
<v-tabs-window-item value="randomPicker">
<random-picker-card border :is-mobile="isMobile" />
</v-tabs-window-item>
@ -441,6 +465,9 @@ export default {
},
methods: {
openClassworksKV() {
window.open(getSetting("server.authDomain"), "_blank");
},
loadAllSettings() {
Object.keys(this.settings).forEach((section) => {
Object.keys(this.settings[section]).forEach((key) => {

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 {
transition: transform 0.3s ease, box-shadow 0.3s ease;
@ -289,80 +310,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%;

View File

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

View File

@ -68,7 +68,7 @@ const SETTINGS_STORAGE_KEY = "Classworks_settings";
// 新增: Classworks云端存储的默认设置
const classworksCloudDefaults = {
"server.domain": "https://kv.wuyuan.dev",
"server.domain": import.meta.env.VITE_DEFAULT_KV_SERVER || "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: import.meta.env.VITE_DEFAULT_AUTH_SERVER || "https://kv.houlang.cloud",
description: "授权服务器域名",
icon: "mdi-shield-account",
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 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: {