mirror of
https://github.com/ZeroCatDev/Classworks.git
synced 2025-12-08 13:49:37 +00:00
Merge branch 'main' of https://github.com/ZeroCatDev/Classworks
This commit is contained in:
commit
c36e4defc2
5
.env.example
Normal file
5
.env.example
Normal 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
|
||||||
4
.github/workflows/deploy.yml
vendored
4
.github/workflows/deploy.yml
vendored
@ -32,6 +32,10 @@ jobs:
|
|||||||
node-version: "20.x"
|
node-version: "20.x"
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
|
env:
|
||||||
|
VITE_APP_ID: d158067f53627d2b98babe8bffd2fd7d
|
||||||
|
VITE_DEFAULT_KV_SERVER: https://kv.wuyuan.dev
|
||||||
|
VITE_DEFAULT_AUTH_SERVER: https://kv.houlang.cloud
|
||||||
run: |
|
run: |
|
||||||
npm install
|
npm install
|
||||||
npm run build
|
npm run build
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -172,4 +172,3 @@ dist
|
|||||||
vite.config.*.timestamp-*.mjs
|
vite.config.*.timestamp-*.mjs
|
||||||
*.timestamp-*
|
*.timestamp-*
|
||||||
|
|
||||||
kv-admin
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "Classworks",
|
"name": "classworks",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
@ -14,17 +14,21 @@
|
|||||||
"@examaware-cs/player": "^1.0.2",
|
"@examaware-cs/player": "^1.0.2",
|
||||||
"@mdi/font": "7.4.47",
|
"@mdi/font": "7.4.47",
|
||||||
"@microsoft/clarity": "^1.0.0",
|
"@microsoft/clarity": "^1.0.0",
|
||||||
|
"@vueuse/core": "^13.9.0",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"idb": "^8.0.3",
|
"idb": "^8.0.3",
|
||||||
"js-base64": "^3.7.8",
|
"js-base64": "^3.7.8",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
|
"lucide-vue-next": "^0.545.0",
|
||||||
|
"marked": "^16.4.0",
|
||||||
"pinyin-pro": "^3.27.0",
|
"pinyin-pro": "^3.27.0",
|
||||||
"ratelimit-header-parser": "^0.1.0",
|
"ratelimit-header-parser": "^0.1.0",
|
||||||
"roboto-fontface": "*",
|
"roboto-fontface": "*",
|
||||||
"tdesign-vue-next": "^1.17.1",
|
"socket.io-client": "^4.8.1",
|
||||||
"typewriter-effect": "^2.21.0",
|
"typewriter-effect": "^2.21.0",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"vue": "^3.5.20",
|
"vue": "^3.5.20",
|
||||||
|
"vue-sonner": "^2.0.9",
|
||||||
"vuetify": "^3.9.6"
|
"vuetify": "^3.9.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
568
pnpm-lock.yaml
generated
568
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
14
src/App.vue
14
src/App.vue
@ -1,11 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-app>
|
<v-app>
|
||||||
<!-- KvInitialize 组件自行决定是否展示或执行跳转 -->
|
|
||||||
<kv-initialize />
|
|
||||||
<!-- 正常路由 -->
|
<!-- 正常路由 -->
|
||||||
<router-view v-slot="{ Component, route }">
|
<router-view v-slot="{ Component, route }">
|
||||||
<transition name="md3" mode="out-in">
|
<transition
|
||||||
<component :is="Component" :key="route.path" />
|
name="md3"
|
||||||
|
mode="out-in"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="Component"
|
||||||
|
:key="route.path"
|
||||||
|
/>
|
||||||
</transition>
|
</transition>
|
||||||
</router-view>
|
</router-view>
|
||||||
<global-message />
|
<global-message />
|
||||||
@ -18,9 +22,7 @@ import { onMounted } from "vue";
|
|||||||
import { useTheme } from "vuetify";
|
import { useTheme } from "vuetify";
|
||||||
import { getSetting } from "@/utils/settings";
|
import { getSetting } from "@/utils/settings";
|
||||||
import RateLimitModal from "@/components/RateLimitModal.vue";
|
import RateLimitModal from "@/components/RateLimitModal.vue";
|
||||||
import KvInitialize from "@/components/KvInitialize.vue";
|
|
||||||
import Clarity from "@microsoft/clarity";
|
import Clarity from "@microsoft/clarity";
|
||||||
|
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@ -1,71 +0,0 @@
|
|||||||
<template>
|
|
||||||
<v-footer height="40" app>
|
|
||||||
<a
|
|
||||||
v-for="item in items"
|
|
||||||
:key="item.title"
|
|
||||||
:href="item.href"
|
|
||||||
:title="item.title"
|
|
||||||
class="d-inline-block mx-2 social-link"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<v-icon :icon="item.icon" :size="item.icon === 'mdi-earth' ? 24 : 16" />
|
|
||||||
</a>
|
|
||||||
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="text-caption text-disabled"
|
|
||||||
style="position: absolute; right: 16px"
|
|
||||||
>
|
|
||||||
|
|
||||||
<a
|
|
||||||
class="text-decoration-none on-surface"
|
|
||||||
href="https://github.com/ZeroCatDev/Classworks"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
Classworks
|
|
||||||
</a> <a
|
|
||||||
class="text-decoration-none on-surface"
|
|
||||||
href="https://beian.miit.gov.cn"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
浙ICP备2024068645号
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</v-footer>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { useDisplay } from "vuetify";
|
|
||||||
const { mobile } = useDisplay();
|
|
||||||
|
|
||||||
const items = [
|
|
||||||
{
|
|
||||||
title: "Classworks",
|
|
||||||
icon: `mdi-earth`,
|
|
||||||
href: "https://cs.houlangs.com",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "ZeroCat",
|
|
||||||
icon: "mdi-xml",
|
|
||||||
href: "https://zerocat.houlangs.com",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "GitHub",
|
|
||||||
icon: "mdi-github",
|
|
||||||
href: "https://github.com/ZeroCatDev/Classworks",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="sass">
|
|
||||||
.social-link :deep(.v-icon)
|
|
||||||
color: rgba(var(--v-theme-on-background), var(--v-disabled-opacity))
|
|
||||||
text-decoration: none
|
|
||||||
transition: .2s ease-in-out
|
|
||||||
|
|
||||||
&:hover
|
|
||||||
color: rgba(25, 118, 210, 1)
|
|
||||||
</style>
|
|
||||||
396
src/components/ChatWidget.vue
Normal file
396
src/components/ChatWidget.vue
Normal file
@ -0,0 +1,396 @@
|
|||||||
|
<template>
|
||||||
|
<!-- Floating toggle button -->
|
||||||
|
<div
|
||||||
|
v-if="showToggleButton"
|
||||||
|
class="chat-toggle"
|
||||||
|
:style="toggleStyle"
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
@click="open()"
|
||||||
|
>
|
||||||
|
<v-badge
|
||||||
|
:content="unreadCount || undefined"
|
||||||
|
:model-value="unreadCount > 0"
|
||||||
|
color="error"
|
||||||
|
overlap
|
||||||
|
>
|
||||||
|
<v-icon>
|
||||||
|
mdi-chat
|
||||||
|
</v-icon>
|
||||||
|
</v-badge>
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chat panel -->
|
||||||
|
<div
|
||||||
|
v-show="visible"
|
||||||
|
class="chat-panel"
|
||||||
|
:style="panelStyle"
|
||||||
|
>
|
||||||
|
<v-card
|
||||||
|
border
|
||||||
|
elevation="8"
|
||||||
|
class="chat-card"
|
||||||
|
>
|
||||||
|
<v-card-title class="d-flex align-center">
|
||||||
|
<v-icon class="mr-2">
|
||||||
|
mdi-chat-processing
|
||||||
|
</v-icon>
|
||||||
|
<span class="text-subtitle-1">设备聊天室</span>
|
||||||
|
<v-spacer />
|
||||||
|
<v-tooltip location="top">
|
||||||
|
<template #activator="{ props }">
|
||||||
|
<v-chip
|
||||||
|
v-bind="props"
|
||||||
|
size="x-small"
|
||||||
|
:color="connected ? 'success' : 'grey'"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
{{ connected ? '已连接' : '未连接' }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
<span>Socket {{ socketId || '-' }}</span>
|
||||||
|
</v-tooltip>
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
variant="text"
|
||||||
|
@click="close()"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-close</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-card-text class="chat-body">
|
||||||
|
<div
|
||||||
|
ref="listRef"
|
||||||
|
class="messages"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
v-for="msg in decoratedMessages"
|
||||||
|
:key="msg._id"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="msg._type === 'divider'"
|
||||||
|
class="divider-row"
|
||||||
|
>
|
||||||
|
<v-divider class="my-2" />
|
||||||
|
<div class="divider-text">
|
||||||
|
今天 - 上次访问
|
||||||
|
</div>
|
||||||
|
<v-divider class="my-2" />
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="message-row"
|
||||||
|
:class="{ self: msg.self }"
|
||||||
|
>
|
||||||
|
<div class="avatar">
|
||||||
|
<v-avatar
|
||||||
|
size="24"
|
||||||
|
:color="msg.self ? 'primary' : 'grey'"
|
||||||
|
>
|
||||||
|
<v-icon size="small">
|
||||||
|
{{ msg.self ? 'mdi-account' : 'mdi-account-outline' }}
|
||||||
|
</v-icon>
|
||||||
|
</v-avatar>
|
||||||
|
</div>
|
||||||
|
<div class="bubble">
|
||||||
|
<div class="text">
|
||||||
|
{{ msg.text }}
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
{{ formatTime(msg.at) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-card-actions class="chat-input">
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
variant="text"
|
||||||
|
class="mr-1"
|
||||||
|
@click="insertEmoji('😄')"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-emoticon-outline</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
<v-textarea
|
||||||
|
ref="inputRef"
|
||||||
|
v-model="text"
|
||||||
|
class="flex-grow-1"
|
||||||
|
rows="1"
|
||||||
|
auto-grow
|
||||||
|
variant="solo"
|
||||||
|
hide-details
|
||||||
|
placeholder="输入消息"
|
||||||
|
@keydown.enter.prevent="handleEnter"
|
||||||
|
@keydown.shift.enter.stop
|
||||||
|
/>
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
:disabled="!canSend"
|
||||||
|
class="ml-2"
|
||||||
|
@click="send"
|
||||||
|
>
|
||||||
|
<v-icon start>
|
||||||
|
mdi-send
|
||||||
|
</v-icon>
|
||||||
|
发送
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { getSetting } from '@/utils/settings'
|
||||||
|
import { getSocket, joinToken, on as socketOn } from '@/utils/socketClient'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'ChatWidget',
|
||||||
|
props: {
|
||||||
|
modelValue: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
showButton: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
|
offset: {
|
||||||
|
type: Number,
|
||||||
|
default: 16,
|
||||||
|
},
|
||||||
|
width: {
|
||||||
|
type: Number,
|
||||||
|
default: 380,
|
||||||
|
},
|
||||||
|
height: {
|
||||||
|
type: Number,
|
||||||
|
default: 520,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: ['update:modelValue'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
visible: this.modelValue,
|
||||||
|
text: '',
|
||||||
|
messages: [],
|
||||||
|
lastVisit: null,
|
||||||
|
unreadCount: 0,
|
||||||
|
connected: false,
|
||||||
|
socketId: '',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
panelStyle() {
|
||||||
|
return {
|
||||||
|
right: this.offset + 'px',
|
||||||
|
bottom: this.offset + 'px',
|
||||||
|
width: this.width + 'px',
|
||||||
|
height: this.height + 'px',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
toggleStyle() {
|
||||||
|
return {
|
||||||
|
right: this.offset + 'px',
|
||||||
|
bottom: this.offset + 'px',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
canSend() {
|
||||||
|
const token = getSetting('server.kvToken')
|
||||||
|
return !!(token && this.text.trim())
|
||||||
|
},
|
||||||
|
showToggleButton() {
|
||||||
|
return this.$props.showButton && !this.visible
|
||||||
|
},
|
||||||
|
decoratedMessages() {
|
||||||
|
// Insert divider between lastVisit and now
|
||||||
|
if (!this.lastVisit) return this.messages
|
||||||
|
const idx = this.messages.findIndex(m => m.at && new Date(m.at).getTime() >= new Date(this.lastVisit).getTime())
|
||||||
|
if (idx <= 0) return this.messages
|
||||||
|
const before = this.messages.slice(0, idx)
|
||||||
|
const after = this.messages.slice(idx)
|
||||||
|
return [
|
||||||
|
...before,
|
||||||
|
{ _id: 'divider', _type: 'divider' },
|
||||||
|
...after,
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
modelValue(val) {
|
||||||
|
this.visible = val
|
||||||
|
if (val) {
|
||||||
|
this.onOpen()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('chat.lastVisit')
|
||||||
|
if (stored) this.lastVisit = stored
|
||||||
|
} catch (e) { void e }
|
||||||
|
|
||||||
|
// Prepare socket
|
||||||
|
const s = getSocket()
|
||||||
|
this.connected = !!s.connected
|
||||||
|
this.socketId = s.id || ''
|
||||||
|
|
||||||
|
s.on('connect', () => {
|
||||||
|
this.connected = true
|
||||||
|
this.socketId = s.id || ''
|
||||||
|
})
|
||||||
|
s.on('disconnect', () => {
|
||||||
|
this.connected = false
|
||||||
|
})
|
||||||
|
|
||||||
|
// Auto join by token if exists
|
||||||
|
const token = getSetting('server.kvToken')
|
||||||
|
if (token) joinToken(token)
|
||||||
|
|
||||||
|
// Listen chat messages
|
||||||
|
this.offMessage = socketOn('chat:message', (msg) => {
|
||||||
|
this.pushMessage(msg)
|
||||||
|
})
|
||||||
|
|
||||||
|
// If initially visible, run open logic
|
||||||
|
if (this.visible) this.onOpen()
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
if (this.offMessage) this.offMessage()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
open() {
|
||||||
|
this.visible = true
|
||||||
|
this.$emit('update:modelValue', true)
|
||||||
|
this.onOpen()
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
this.visible = false
|
||||||
|
this.$emit('update:modelValue', false)
|
||||||
|
try {
|
||||||
|
localStorage.setItem('chat.lastVisit', new Date().toISOString())
|
||||||
|
} catch (e) { void e }
|
||||||
|
this.unreadCount = 0
|
||||||
|
},
|
||||||
|
onOpen() {
|
||||||
|
// Scroll to bottom on open
|
||||||
|
this.$nextTick(() => this.scrollToBottom())
|
||||||
|
},
|
||||||
|
insertEmoji(ch) {
|
||||||
|
this.text += ch
|
||||||
|
this.$nextTick(() => {
|
||||||
|
if (this.$refs.inputRef?.$el?.querySelector) {
|
||||||
|
const ta = this.$refs.inputRef.$el.querySelector('textarea')
|
||||||
|
ta?.focus()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
handleEnter(e) {
|
||||||
|
if (e.shiftKey) return
|
||||||
|
this.send()
|
||||||
|
},
|
||||||
|
send() {
|
||||||
|
const val = this.text.trim()
|
||||||
|
if (!val) return
|
||||||
|
const s = getSocket()
|
||||||
|
s.emit('chat:send', val)
|
||||||
|
this.text = ''
|
||||||
|
},
|
||||||
|
pushMessage(msg) {
|
||||||
|
const entry = {
|
||||||
|
_id: `${msg.at || Date.now()}-${Math.random()}`,
|
||||||
|
text: typeof msg?.text === 'string' ? msg.text : (msg?.text || ''),
|
||||||
|
at: msg.at || new Date().toISOString(),
|
||||||
|
senderId: msg.senderId,
|
||||||
|
self: !!(msg.senderId && msg.senderId === this.socketId),
|
||||||
|
}
|
||||||
|
// ignore empty
|
||||||
|
if (!entry.text) return
|
||||||
|
this.messages.push(entry)
|
||||||
|
// unread when hidden
|
||||||
|
if (!this.visible) this.unreadCount++
|
||||||
|
this.$nextTick(() => this.scrollToBottom())
|
||||||
|
// trim
|
||||||
|
if (this.messages.length > 500) this.messages.shift()
|
||||||
|
},
|
||||||
|
formatTime(iso) {
|
||||||
|
try {
|
||||||
|
const d = new Date(iso)
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0')
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||||
|
return `${hh}:${mm}`
|
||||||
|
} catch (e) {
|
||||||
|
void e
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scrollToBottom() {
|
||||||
|
const el = this.$refs.listRef
|
||||||
|
if (!el) return
|
||||||
|
try {
|
||||||
|
el.scrollTop = el.scrollHeight
|
||||||
|
} catch (e) { void e }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat-toggle {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1100;
|
||||||
|
}
|
||||||
|
.chat-panel {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1101;
|
||||||
|
}
|
||||||
|
.chat-card {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.chat-body {
|
||||||
|
padding: 8px 12px;
|
||||||
|
height: calc(100% - 120px);
|
||||||
|
}
|
||||||
|
.messages {
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.message-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.message-row.self {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
.message-row .avatar { width: 28px; display: flex; justify-content: center; }
|
||||||
|
.message-row .bubble {
|
||||||
|
max-width: 70%;
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
margin: 0 8px;
|
||||||
|
}
|
||||||
|
.message-row.self .bubble {
|
||||||
|
background: rgba(33,150,243,0.15);
|
||||||
|
}
|
||||||
|
.bubble .text { white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.bubble .meta { font-size: 12px; opacity: 0.6; margin-top: 2px; text-align: right; }
|
||||||
|
.divider-row { text-align: center; color: rgba(255,255,255,0.6); font-size: 12px; }
|
||||||
|
.divider-text { margin: 4px 0; }
|
||||||
|
.chat-input { padding: 8px; }
|
||||||
|
</style>
|
||||||
@ -1,77 +1,52 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-slide-x-transition>
|
<a
|
||||||
<v-card
|
class="floating-icp-link"
|
||||||
class="floating-icp"
|
href="https://beian.miit.gov.cn/"
|
||||||
elevation="2"
|
target="_blank"
|
||||||
rounded="pill"
|
rel="noopener noreferrer"
|
||||||
variant="tonal"
|
aria-label="浙ICP备2024068645号"
|
||||||
color="surface-variant"
|
>
|
||||||
@mouseenter="isHovered = true"
|
浙ICP备2024068645号
|
||||||
@mouseleave="isHovered = false"
|
</a>
|
||||||
>
|
|
||||||
<v-btn
|
|
||||||
variant="text"
|
|
||||||
class="icp-button"
|
|
||||||
href="https://beian.miit.gov.cn/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<v-icon
|
|
||||||
icon="mdi-shield-check"
|
|
||||||
size="small"
|
|
||||||
:class="{ 'rotate-icon': isHovered }"
|
|
||||||
class="mr-1"
|
|
||||||
/>
|
|
||||||
<span class="text-caption">浙ICP备2024068645号</span>
|
|
||||||
</v-btn>
|
|
||||||
</v-card>
|
|
||||||
</v-slide-x-transition>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
name: 'FloatingICP',
|
name: 'FloatingICP',
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
isHovered: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.floating-icp {
|
.floating-icp-link {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 24px;
|
right: 4px;
|
||||||
right: 24px;
|
bottom: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
transition: all 0.3s ease;
|
font-size: 14px;
|
||||||
backdrop-filter: blur(10px);
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
color: rgb(107, 107, 107);
|
||||||
|
text-decoration: none;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
transition: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.floating-icp:hover {
|
.floating-icp-link:hover,
|
||||||
transform: translateX(-4px);
|
.floating-icp-link:focus,
|
||||||
}
|
.floating-icp-link:active {
|
||||||
|
color: rgb(65, 65, 65);
|
||||||
.icp-button {
|
text-decoration: none;
|
||||||
padding: 0 16px;
|
outline: none;
|
||||||
height: 32px;
|
|
||||||
min-width: unset;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rotate-icon {
|
|
||||||
transform: rotate(360deg);
|
|
||||||
transition: transform 0.6s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.floating-icp {
|
.floating-icp-link {
|
||||||
right: 16px;
|
right: 16px;
|
||||||
bottom: 80px; /* 避免与其他悬浮组件重叠 */
|
bottom: 0;
|
||||||
}
|
font-size: 14px;
|
||||||
|
|
||||||
.icp-button {
|
|
||||||
padding: 0 12px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
393
src/components/InitServiceChooser.vue
Normal file
393
src/components/InitServiceChooser.vue
Normal 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>
|
||||||
@ -94,7 +94,7 @@ const goToAuthorize = () => {
|
|||||||
|
|
||||||
const uuid =
|
const uuid =
|
||||||
getSetting("device.uuid") || "00000000-0000-4000-8000-000000000000";
|
getSetting("device.uuid") || "00000000-0000-4000-8000-000000000000";
|
||||||
let authorizeUrl = `${authDomain}/authorize?app_id=${appId}&mode=callback&callback_url=${callbackUrl}`;
|
let authorizeUrl = `${authDomain}/authorize?app_id=${appId}&mode=callback&callback_url=${callbackUrl}&remark=Classworks 自动授权 来自${window.location.hostname} ${new Date().toLocaleString()}`;
|
||||||
|
|
||||||
// 如果UUID不是默认值,附加编码后的 uuid 参数用于迁移
|
// 如果UUID不是默认值,附加编码后的 uuid 参数用于迁移
|
||||||
if (uuid !== "00000000-0000-4000-8000-000000000000") {
|
if (uuid !== "00000000-0000-4000-8000-000000000000") {
|
||||||
|
|||||||
@ -225,7 +225,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { openDB } from "idb";
|
import { openDB } from "idb";
|
||||||
import axios from "@/assets/fonts/axios/axios";
|
import axios from "@/axios/axios";
|
||||||
import { getSetting, setSetting } from "@/utils/settings";
|
import { getSetting, setSetting } from "@/utils/settings";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
125
src/components/ReadOnlyTokenWarning.vue
Normal file
125
src/components/ReadOnlyTokenWarning.vue
Normal 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>
|
||||||
261
src/components/StudentNameManager.vue
Normal file
261
src/components/StudentNameManager.vue
Normal 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>
|
||||||
68
src/components/auth/AlternativeCodeDialog.vue
Normal file
68
src/components/auth/AlternativeCodeDialog.vue
Normal 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>
|
||||||
198
src/components/auth/DeviceAuthDialog.vue
Normal file
198
src/components/auth/DeviceAuthDialog.vue
Normal 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>
|
||||||
671
src/components/auth/FirstTimeGuide.vue
Normal file
671
src/components/auth/FirstTimeGuide.vue
Normal 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>
|
||||||
133
src/components/auth/README.md
Normal file
133
src/components/auth/README.md
Normal 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` 中使用,以获得最佳的用户体验
|
||||||
103
src/components/auth/TokenInputDialog.vue
Normal file
103
src/components/auth/TokenInputDialog.vue
Normal 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>
|
||||||
@ -65,16 +65,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</v-card-text> </v-card
|
</v-card-text> </v-card
|
||||||
><v-card title="Classworks KV" subtitle="云原生键值数据库" border hover
|
><v-card title="Classworks KV" subtitle="文档形键值数据库" border hover
|
||||||
><v-card-text
|
><v-card-text
|
||||||
>Classworks KV
|
>Classworks KV
|
||||||
是厚浪云推出的云原生键值数据库,其是一个开放的云应用平台,为各种应用提供存储服务。此设备正在使用其服务,如果您希望管理设备信息,请前往
|
是厚浪云推出的文档形键值数据库,其是一个开放的云应用平台,为各种应用提供存储服务。此设备正在使用其服务,如果您希望管理设备信息,请前往
|
||||||
Classworks KV
|
Classworks KV
|
||||||
的网站,如果您在服务推出前就在使用 Classworks,您的数据已被自动迁移。
|
的网站,如果您在服务推出前就在使用 Classworks,您的数据已被自动迁移。
|
||||||
<br/><br/>Classworks KV 的全域管理员是 <a href="https://wuyuan.dev" target="_blank">孙悟元</a></v-card-text
|
<br/><br/>Classworks KV 的全域管理员是 <a href="https://wuyuan.dev" target="_blank">孙悟元</a></v-card-text
|
||||||
><v-card-actions
|
><v-card-actions
|
||||||
><v-btn
|
><v-btn
|
||||||
href="https://kv.houlang.cloud"
|
:href="defaultAuthServer"
|
||||||
class="text-none"
|
class="text-none"
|
||||||
append-icon="mdi-open-in-new"
|
append-icon="mdi-open-in-new"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@ -102,15 +102,35 @@
|
|||||||
刷新设备信息
|
刷新设备信息
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-btn color="primary" @click="reinitializeCloudStorage">
|
<v-btn color="error" variant="outlined" @click="showReinitDialog = true">
|
||||||
重新初始化云端存储
|
重新初始化云端存储
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-card-actions>
|
</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>
|
</v-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { kvServerProvider } from "@/utils/providers/kvServerProvider";
|
import { kvServerProvider } from "@/utils/providers/kvServerProvider";
|
||||||
|
import { setSetting, getSetting } from "@/utils/settings";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "CloudNamespaceInfoCard",
|
name: "CloudNamespaceInfoCard",
|
||||||
@ -125,6 +145,8 @@ export default {
|
|||||||
namespaceInfo: {},
|
namespaceInfo: {},
|
||||||
loading: false,
|
loading: false,
|
||||||
hasNamespaceInfo: false,
|
hasNamespaceInfo: false,
|
||||||
|
showReinitDialog: false, // 确认对话框显示状态
|
||||||
|
defaultAuthServer: getSetting('server.authDomain'),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@ -168,13 +190,16 @@ export default {
|
|||||||
async reloadInfo() {
|
async reloadInfo() {
|
||||||
await this.fetchNamespaceInfo();
|
await this.fetchNamespaceInfo();
|
||||||
},
|
},
|
||||||
reinitializeCloudStorage() {
|
confirmReinitialize() {
|
||||||
// 触发 KvInitialize 组件的重新初始化
|
// 删除 token 配置(设置为空字符串以触发 shouldShowInit)
|
||||||
try {
|
setSetting('server.kvToken', '');
|
||||||
window.dispatchEvent(new CustomEvent("kvinit:open"));
|
setSetting('device.uuid', '');
|
||||||
} catch (e) {
|
|
||||||
console.error("重新初始化云端存储失败:", e);
|
// 关闭对话框
|
||||||
}
|
this.showReinitDialog = false;
|
||||||
|
|
||||||
|
// 返回主页(将触发 InitServiceChooser 组件显示)
|
||||||
|
this.$router.push('/');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
11
src/main.js
11
src/main.js
@ -6,6 +6,8 @@
|
|||||||
|
|
||||||
// Plugins
|
// Plugins
|
||||||
import { registerPlugins } from '@/plugins'
|
import { registerPlugins } from '@/plugins'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
const pinia = createPinia()
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
@ -15,9 +17,9 @@ import GlobalMessage from '@/components/GlobalMessage.vue'
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import Clarity from '@microsoft/clarity';
|
import Clarity from '@microsoft/clarity';
|
||||||
const projectId = "rhp8uqoc3l"
|
const projectId = "rhp8uqoc3l"
|
||||||
import TDesign from 'tdesign-vue-next'
|
//import TDesign from 'tdesign-vue-next'
|
||||||
import 'tdesign-vue-next/es/style/index.css'
|
//import 'tdesign-vue-next/es/style/index.css'
|
||||||
import '@examaware-cs/player/dist/player.css'
|
//import '@examaware-cs/player/dist/player.css'
|
||||||
|
|
||||||
Clarity.init(projectId);
|
Clarity.init(projectId);
|
||||||
import messageService from './utils/message';
|
import messageService from './utils/message';
|
||||||
@ -25,8 +27,9 @@ import messageService from './utils/message';
|
|||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
registerPlugins(app)
|
registerPlugins(app)
|
||||||
app.use(TDesign)
|
//app.use(TDesign)
|
||||||
app.use(messageService);
|
app.use(messageService);
|
||||||
|
app.use(pinia)
|
||||||
|
|
||||||
app.component('GlobalMessage', GlobalMessage)
|
app.component('GlobalMessage', GlobalMessage)
|
||||||
|
|
||||||
|
|||||||
@ -3,24 +3,38 @@
|
|||||||
<v-row>
|
<v-row>
|
||||||
<v-col cols="12">
|
<v-col cols="12">
|
||||||
<div class="d-flex align-center mb-6">
|
<div class="d-flex align-center mb-6">
|
||||||
<v-icon size="x-large" color="primary" class="mr-3"
|
<v-icon
|
||||||
>mdi-database-sync</v-icon
|
size="x-large"
|
||||||
|
color="primary"
|
||||||
|
class="mr-3"
|
||||||
>
|
>
|
||||||
|
mdi-database-sync
|
||||||
|
</v-icon>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-h4">数据迁移工具</h1>
|
<h1 class="text-h4">
|
||||||
|
数据迁移工具
|
||||||
|
</h1>
|
||||||
<div class="text-subtitle-1 text-grey">
|
<div class="text-subtitle-1 text-grey">
|
||||||
将现有数据迁移至 KV 存储系统
|
将现有数据迁移至 KV 存储系统
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<v-card class="mb-6" variant="tonal" color="info" density="compact">
|
<v-card
|
||||||
|
class="mb-6"
|
||||||
|
variant="tonal"
|
||||||
|
color="info"
|
||||||
|
>
|
||||||
<v-card-text class="d-flex align-center">
|
<v-card-text class="d-flex align-center">
|
||||||
<v-icon color="info" class="mr-2">mdi-information-outline</v-icon>
|
<v-icon
|
||||||
<span
|
color="info"
|
||||||
>使用此工具可以将数据从旧存储系统迁移到新的 KV
|
class="mr-2"
|
||||||
存储系统,选择本地或云端迁移,以确保数据不会丢失。</span
|
|
||||||
>
|
>
|
||||||
|
mdi-information-outline
|
||||||
|
</v-icon>
|
||||||
|
<span>
|
||||||
|
使用此工具可以将数据从旧存储系统迁移到新的 KV 存储系统,选择本地或云端迁移,以确保数据不会丢失。
|
||||||
|
</span>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
@ -29,12 +43,20 @@
|
|||||||
</v-row>
|
</v-row>
|
||||||
|
|
||||||
<!-- 一键迁移对话框 -->
|
<!-- 一键迁移对话框 -->
|
||||||
<v-dialog v-model="showMigrationDialog" max-width="500" persistent>
|
<v-dialog
|
||||||
|
v-model="showMigrationDialog"
|
||||||
|
max-width="500"
|
||||||
|
persistent
|
||||||
|
>
|
||||||
<v-card>
|
<v-card>
|
||||||
<v-card-title class="text-h5 d-flex align-center">
|
<v-card-title class="text-h5 d-flex align-center">
|
||||||
<v-icon color="primary" size="large" class="mr-3"
|
<v-icon
|
||||||
>mdi-database-sync</v-icon
|
color="primary"
|
||||||
|
size="large"
|
||||||
|
class="mr-3"
|
||||||
>
|
>
|
||||||
|
mdi-database-sync
|
||||||
|
</v-icon>
|
||||||
一键数据迁移
|
一键数据迁移
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-card-text class="mt-4">
|
<v-card-text class="mt-4">
|
||||||
@ -45,8 +67,7 @@
|
|||||||
|
|
||||||
<v-alert
|
<v-alert
|
||||||
color="info"
|
color="info"
|
||||||
variant="outlined"
|
variant="tonal"
|
||||||
density="compact"
|
|
||||||
class="mt-4"
|
class="mt-4"
|
||||||
icon="mdi-information-outline"
|
icon="mdi-information-outline"
|
||||||
>
|
>
|
||||||
@ -62,7 +83,7 @@
|
|||||||
</v-alert>
|
</v-alert>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
<v-spacer></v-spacer>
|
<v-spacer />
|
||||||
<v-btn
|
<v-btn
|
||||||
color="grey-darken-1"
|
color="grey-darken-1"
|
||||||
variant="text"
|
variant="text"
|
||||||
@ -74,11 +95,16 @@
|
|||||||
color="primary"
|
color="primary"
|
||||||
size="large"
|
size="large"
|
||||||
variant="elevated"
|
variant="elevated"
|
||||||
@click="startAutoMigration"
|
|
||||||
:loading="isAutoMigrating"
|
:loading="isAutoMigrating"
|
||||||
:disabled="isAutoMigrating"
|
:disabled="isAutoMigrating"
|
||||||
|
@click="startAutoMigration"
|
||||||
>
|
>
|
||||||
<v-icon left class="mr-2">mdi-database-export</v-icon>
|
<v-icon
|
||||||
|
left
|
||||||
|
class="mr-2"
|
||||||
|
>
|
||||||
|
mdi-database-export
|
||||||
|
</v-icon>
|
||||||
开始一键迁移
|
开始一键迁移
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
|
|||||||
@ -72,9 +72,9 @@
|
|||||||
md="6"
|
md="6"
|
||||||
>
|
>
|
||||||
<v-card>
|
<v-card>
|
||||||
<v-card-title>KvInitialize 预览</v-card-title>
|
<v-card-title>初始化组件已替换</v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<kv-initialize />
|
已迁移为首页内联的 InitServiceChooser 组件。
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-col>
|
</v-col>
|
||||||
@ -84,7 +84,6 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import KvInitialize from '@/components/KvInitialize.vue'
|
|
||||||
import { getSetting, setSetting } from '@/utils/settings'
|
import { getSetting, setSetting } from '@/utils/settings'
|
||||||
import { kvServerProvider } from '@/utils/providers/kvServerProvider'
|
import { kvServerProvider } from '@/utils/providers/kvServerProvider'
|
||||||
|
|
||||||
|
|||||||
408
src/pages/debug-socket.vue
Normal file
408
src/pages/debug-socket.vue
Normal file
@ -0,0 +1,408 @@
|
|||||||
|
<template>
|
||||||
|
<v-container>
|
||||||
|
<v-row>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<v-card
|
||||||
|
border
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
<v-card-title>连接信息</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-list density="compact">
|
||||||
|
<v-list-item>
|
||||||
|
<v-list-item-title>Server URL</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>{{ serverUrl }}</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
<v-list-item>
|
||||||
|
<v-list-item-title>当前 KV Token</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>{{ currentToken || '(未配置)' }}</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
<v-list-item>
|
||||||
|
<v-list-item-title>连接状态</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>
|
||||||
|
<v-chip
|
||||||
|
:color="connected ? 'success' : 'error'"
|
||||||
|
size="small"
|
||||||
|
class="mr-2"
|
||||||
|
>
|
||||||
|
{{ connected ? 'connected' : 'disconnected' }}
|
||||||
|
</v-chip>
|
||||||
|
<span v-if="socketId">id: {{ socketId }}</span>
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
<v-list-item>
|
||||||
|
<v-list-item-title>已加入 Token</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>{{ joinedToken || '-' }}</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
<v-list-item>
|
||||||
|
<v-list-item-title>当前数据键</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>{{ currentDataKey }}</v-list-item-subtitle>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
<v-divider class="my-4" />
|
||||||
|
<v-row>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="8"
|
||||||
|
>
|
||||||
|
<v-text-field
|
||||||
|
v-model="manualToken"
|
||||||
|
label="手动加入 Token (留空使用配置的 Token)"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="4"
|
||||||
|
class="d-flex align-center"
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
class="mr-2"
|
||||||
|
@click="handleJoinToken(manualToken || currentToken)"
|
||||||
|
>
|
||||||
|
加入
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
color="warning"
|
||||||
|
class="mr-2"
|
||||||
|
:disabled="!joinedToken"
|
||||||
|
@click="handleLeaveToken(joinedToken)"
|
||||||
|
>
|
||||||
|
离开当前
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
color="error"
|
||||||
|
variant="tonal"
|
||||||
|
@click="handleLeaveAll"
|
||||||
|
>
|
||||||
|
离开全部
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-divider class="my-4" />
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-card variant="tonal" color="primary" border>
|
||||||
|
<v-card-title class="text-subtitle-1">聊天室消息</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-textarea
|
||||||
|
v-model="chatInput"
|
||||||
|
label="发送到当前已加入的设备频道"
|
||||||
|
rows="2"
|
||||||
|
auto-grow
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
<div class="d-flex">
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
:disabled="!canSendChat"
|
||||||
|
@click="sendChat"
|
||||||
|
>
|
||||||
|
发送聊天
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
<v-row>
|
||||||
|
<v-col cols="12">
|
||||||
|
<v-btn
|
||||||
|
color="secondary"
|
||||||
|
variant="tonal"
|
||||||
|
@click="reconnect"
|
||||||
|
>
|
||||||
|
重新连接
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
|
<v-card border>
|
||||||
|
<v-card-title>在线设备</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
class="mb-3"
|
||||||
|
@click="fetchOnline"
|
||||||
|
>
|
||||||
|
刷新在线列表
|
||||||
|
</v-btn>
|
||||||
|
<v-list
|
||||||
|
v-if="onlineDevices.length"
|
||||||
|
density="compact"
|
||||||
|
>
|
||||||
|
<v-list-item
|
||||||
|
v-for="dev in onlineDevices"
|
||||||
|
:key="dev.uuid"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<v-avatar
|
||||||
|
:color="dev.connections > 0 ? 'success' : 'grey'"
|
||||||
|
size="24"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>{{ dev.name || '(未命名)' }}</v-list-item-title>
|
||||||
|
<v-list-item-subtitle>{{ dev.uuid }} · 连接数 {{ dev.connections }}</v-list-item-subtitle>
|
||||||
|
<template #append>
|
||||||
|
<v-btn
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="handleSelectDevice(dev)"
|
||||||
|
>
|
||||||
|
选择
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="text-grey"
|
||||||
|
>
|
||||||
|
暂无数据
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
|
||||||
|
<v-col
|
||||||
|
cols="12"
|
||||||
|
md="6"
|
||||||
|
>
|
||||||
|
<v-card border>
|
||||||
|
<v-card-title class="d-flex align-center">
|
||||||
|
事件日志
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
color="error"
|
||||||
|
@click="clearLogs"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</v-btn>
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<v-list density="compact">
|
||||||
|
<v-list-item
|
||||||
|
v-for="(log, idx) in logs"
|
||||||
|
:key="idx"
|
||||||
|
>
|
||||||
|
<v-list-item-title>
|
||||||
|
<span class="text-caption text-grey">{{ log.time }}</span>
|
||||||
|
<span class="ml-2">{{ log.event }}</span>
|
||||||
|
</v-list-item-title>
|
||||||
|
<v-list-item-text>
|
||||||
|
<pre
|
||||||
|
class="mb-2"
|
||||||
|
style="white-space: pre-wrap"
|
||||||
|
>{{ log.payload }}</pre>
|
||||||
|
</v-list-item-text>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</v-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||||
|
import { getSetting } from '@/utils/settings'
|
||||||
|
import {
|
||||||
|
getSocket,
|
||||||
|
on as socketOn,
|
||||||
|
joinToken,
|
||||||
|
leaveToken,
|
||||||
|
leaveAll,
|
||||||
|
getServerUrl
|
||||||
|
} from '@/utils/socketClient'
|
||||||
|
|
||||||
|
const currentToken = ref(getSetting('server.kvToken') || '')
|
||||||
|
const manualToken = ref('')
|
||||||
|
const joinedToken = ref('')
|
||||||
|
const connected = ref(false)
|
||||||
|
const socketId = ref('')
|
||||||
|
const logs = ref([])
|
||||||
|
const onlineDevices = ref([])
|
||||||
|
const chatInput = ref('')
|
||||||
|
|
||||||
|
const serverUrl = computed(() => getServerUrl())
|
||||||
|
|
||||||
|
const currentDataKey = computed(() => {
|
||||||
|
const now = new Date()
|
||||||
|
const y = now.getFullYear()
|
||||||
|
const m = String(now.getMonth() + 1).padStart(2, '0')
|
||||||
|
const d = String(now.getDate()).padStart(2, '0')
|
||||||
|
return `classworks-data-${y}${m}${d}`
|
||||||
|
})
|
||||||
|
|
||||||
|
function pushLog(event, payload) {
|
||||||
|
const time = new Date().toLocaleTimeString()
|
||||||
|
logs.value.unshift({
|
||||||
|
time,
|
||||||
|
event,
|
||||||
|
payload: typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2)
|
||||||
|
})
|
||||||
|
if (logs.value.length > 200) logs.value.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireSocketBaseEvents() {
|
||||||
|
const s = getSocket()
|
||||||
|
connected.value = !!s.connected
|
||||||
|
socketId.value = s.id || ''
|
||||||
|
|
||||||
|
s.on('connect', () => {
|
||||||
|
connected.value = true
|
||||||
|
socketId.value = s.id || ''
|
||||||
|
pushLog('connect', { id: s.id })
|
||||||
|
// re-join with token if set
|
||||||
|
if (joinedToken.value) joinToken(joinedToken.value)
|
||||||
|
})
|
||||||
|
s.on('disconnect', (reason) => {
|
||||||
|
connected.value = false
|
||||||
|
pushLog('disconnect', { reason })
|
||||||
|
})
|
||||||
|
s.on('connect_error', (err) => pushLog('connect_error', { message: err?.message }))
|
||||||
|
s.on('reconnect_attempt', (n) => pushLog('reconnect_attempt', { attempt: n }))
|
||||||
|
s.on('reconnect', (n) => pushLog('reconnect', { attempt: n }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireBusinessEvents() {
|
||||||
|
// key changes
|
||||||
|
socketOn('kv-key-changed', (msg) => {
|
||||||
|
pushLog('kv-key-changed', msg)
|
||||||
|
})
|
||||||
|
// device joined count broadcast
|
||||||
|
socketOn('device-joined', (msg) => {
|
||||||
|
pushLog('device-joined', msg)
|
||||||
|
})
|
||||||
|
// join success
|
||||||
|
socketOn('joined', (msg) => {
|
||||||
|
pushLog('joined', msg)
|
||||||
|
})
|
||||||
|
// join error
|
||||||
|
socketOn('join-error', (msg) => {
|
||||||
|
pushLog('join-error', msg)
|
||||||
|
})
|
||||||
|
// chat message
|
||||||
|
socketOn('chat:message', (msg) => {
|
||||||
|
pushLog('chat:message', msg)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleJoinToken(token) {
|
||||||
|
try {
|
||||||
|
if (!token) {
|
||||||
|
pushLog('join-error', 'Token 为空')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
joinToken(token)
|
||||||
|
joinedToken.value = token
|
||||||
|
pushLog('join-token', { token })
|
||||||
|
} catch (e) {
|
||||||
|
pushLog('join-token-error', String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLeaveToken(token) {
|
||||||
|
try {
|
||||||
|
leaveToken(token)
|
||||||
|
if (joinedToken.value === token) joinedToken.value = ''
|
||||||
|
pushLog('leave-token', { token })
|
||||||
|
} catch (e) {
|
||||||
|
pushLog('leave-token-error', String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLeaveAll() {
|
||||||
|
try {
|
||||||
|
leaveAll()
|
||||||
|
joinedToken.value = ''
|
||||||
|
pushLog('leave-all', {})
|
||||||
|
} catch (e) {
|
||||||
|
pushLog('leave-all-error', String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconnect() {
|
||||||
|
try {
|
||||||
|
const s = getSocket()
|
||||||
|
s.connect()
|
||||||
|
} catch (e) {
|
||||||
|
pushLog('reconnect-error', String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSendChat = computed(() => {
|
||||||
|
const text = chatInput.value?.trim() || ''
|
||||||
|
return !!(text && (joinedToken.value || currentToken.value))
|
||||||
|
})
|
||||||
|
|
||||||
|
function sendChat() {
|
||||||
|
try {
|
||||||
|
const text = (chatInput.value || '').trim()
|
||||||
|
if (!text) return
|
||||||
|
const s = getSocket()
|
||||||
|
// send as plain string per server contract
|
||||||
|
s.emit('chat:send', text)
|
||||||
|
pushLog('chat:send', { text })
|
||||||
|
chatInput.value = ''
|
||||||
|
} catch (e) {
|
||||||
|
pushLog('chat:error', String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelectDevice(dev) {
|
||||||
|
// For now, just show a message that we need the token
|
||||||
|
pushLog('select-device', {
|
||||||
|
message: '请输入该设备对应的 KV Token 以加入频道',
|
||||||
|
device: dev
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOnline() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${serverUrl.value}/devices/online`)
|
||||||
|
const data = await resp.json()
|
||||||
|
onlineDevices.value = Array.isArray(data?.devices) ? data.devices : []
|
||||||
|
pushLog('fetch-online', { count: onlineDevices.value.length })
|
||||||
|
} catch (e) {
|
||||||
|
pushLog('fetch-online-error', String(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearLogs() {
|
||||||
|
logs.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// init socket + base events
|
||||||
|
getSocket()
|
||||||
|
wireSocketBaseEvents()
|
||||||
|
wireBusinessEvents()
|
||||||
|
|
||||||
|
// auto join with current token if present
|
||||||
|
if (currentToken.value) {
|
||||||
|
handleJoinToken(currentToken.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// prime online list
|
||||||
|
fetchOnline()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
try {
|
||||||
|
if (joinedToken.value) leaveToken(joinedToken.value)
|
||||||
|
} catch (e) {
|
||||||
|
void e
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@ -7,6 +7,31 @@
|
|||||||
<v-spacer />
|
<v-spacer />
|
||||||
|
|
||||||
<template #append>
|
<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
|
<v-btn
|
||||||
icon="mdi-bell"
|
icon="mdi-bell"
|
||||||
variant="text"
|
variant="text"
|
||||||
@ -17,7 +42,17 @@
|
|||||||
<v-btn icon="mdi-cog" variant="text" @click="$router.push('/settings')" />
|
<v-btn icon="mdi-cog" variant="text" @click="$router.push('/settings')" />
|
||||||
</template>
|
</template>
|
||||||
</v-app-bar>
|
</v-app-bar>
|
||||||
<div class="d-flex">
|
<!-- 初始化选择卡片,仅在首页且需要授权时显示;不影响顶栏 -->
|
||||||
|
<init-service-chooser v-if="shouldShowInit" @done="settingsTick++" />
|
||||||
|
|
||||||
|
<!-- 学生姓名管理组件 -->
|
||||||
|
<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>
|
<v-container class="main-window flex-grow-1 no-select" fluid>
|
||||||
<!-- 有内容的科目卡片 -->
|
<!-- 有内容的科目卡片 -->
|
||||||
@ -36,6 +71,7 @@
|
|||||||
border
|
border
|
||||||
height="100%"
|
height="100%"
|
||||||
class="glow-track"
|
class="glow-track"
|
||||||
|
:class="{ 'glow-highlight': highlightedCards[item.key] }"
|
||||||
@click="!isEditingDisabled && openDialog(item.key)"
|
@click="!isEditingDisabled && openDialog(item.key)"
|
||||||
@mousemove="handleMouseMove"
|
@mousemove="handleMouseMove"
|
||||||
@touchmove="handleTouchMove"
|
@touchmove="handleTouchMove"
|
||||||
@ -59,7 +95,7 @@
|
|||||||
<!-- 单独显示空科目 -->
|
<!-- 单独显示空科目 -->
|
||||||
<div class="empty-subjects mt-4">
|
<div class="empty-subjects mt-4">
|
||||||
<template v-if="emptySubjectDisplay === 'button'">
|
<template v-if="emptySubjectDisplay === 'button'">
|
||||||
<v-btn-group divided variant="outlined">
|
<v-btn-group divided variant="tonal">
|
||||||
<v-btn
|
<v-btn
|
||||||
v-for="subject in unusedSubjects"
|
v-for="subject in unusedSubjects"
|
||||||
:key="subject.name"
|
:key="subject.name"
|
||||||
@ -180,6 +216,7 @@
|
|||||||
|
|
||||||
<!-- 出勤统计区域 -->
|
<!-- 出勤统计区域 -->
|
||||||
<v-col
|
<v-col
|
||||||
|
v-ripple="{ class: `text-${['primary','secondary','info','success','warning','error'][Math.floor(Math.random()*6)]}` }"
|
||||||
v-if="state.studentList && state.studentList.length"
|
v-if="state.studentList && state.studentList.length"
|
||||||
class="attendance-area no-select"
|
class="attendance-area no-select"
|
||||||
cols="1"
|
cols="1"
|
||||||
@ -541,6 +578,9 @@
|
|||||||
<!-- 添加ICP备案悬浮组件 -->
|
<!-- 添加ICP备案悬浮组件 -->
|
||||||
<FloatingICP />
|
<FloatingICP />
|
||||||
|
|
||||||
|
<!-- 设备聊天室(右下角浮窗) -->
|
||||||
|
<ChatWidget v-model="isChatOpen" :show-button="false" />
|
||||||
|
|
||||||
<!-- 添加确认对话框 -->
|
<!-- 添加确认对话框 -->
|
||||||
<v-dialog v-model="confirmDialog.show" max-width="400">
|
<v-dialog v-model="confirmDialog.show" max-width="400">
|
||||||
<v-card>
|
<v-card>
|
||||||
@ -608,8 +648,8 @@
|
|||||||
确认应用
|
确认应用
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card> </v-dialog
|
||||||
</v-dialog><br/><br/><br/>
|
><br /><br /><br />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@ -617,7 +657,10 @@ import MessageLog from "@/components/MessageLog.vue";
|
|||||||
import RandomPicker from "@/components/RandomPicker.vue";
|
import RandomPicker from "@/components/RandomPicker.vue";
|
||||||
import FloatingToolbar from "@/components/FloatingToolbar.vue";
|
import FloatingToolbar from "@/components/FloatingToolbar.vue";
|
||||||
import FloatingICP from "@/components/FloatingICP.vue";
|
import FloatingICP from "@/components/FloatingICP.vue";
|
||||||
|
import ChatWidget from "@/components/ChatWidget.vue";
|
||||||
import HomeworkEditDialog from "@/components/HomeworkEditDialog.vue";
|
import HomeworkEditDialog from "@/components/HomeworkEditDialog.vue";
|
||||||
|
import InitServiceChooser from "@/components/InitServiceChooser.vue";
|
||||||
|
import StudentNameManager from "@/components/StudentNameManager.vue";
|
||||||
import dataProvider from "@/utils/dataProvider";
|
import dataProvider from "@/utils/dataProvider";
|
||||||
import {
|
import {
|
||||||
getSetting,
|
getSetting,
|
||||||
@ -631,7 +674,15 @@ import "../styles/transitions.scss";
|
|||||||
import "../styles/global.scss";
|
import "../styles/global.scss";
|
||||||
import { pinyin } from "pinyin-pro";
|
import { pinyin } from "pinyin-pro";
|
||||||
import { debounce, throttle } from "@/utils/debounce";
|
import { debounce, throttle } from "@/utils/debounce";
|
||||||
import { Base64 } from 'js-base64';
|
import { Base64 } from "js-base64";
|
||||||
|
import {
|
||||||
|
getSocket,
|
||||||
|
on as socketOn,
|
||||||
|
off as socketOff,
|
||||||
|
joinToken,
|
||||||
|
leaveAll,
|
||||||
|
onConnect as onSocketConnect,
|
||||||
|
} from "@/utils/socketClient";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "Classworks 作业板",
|
name: "Classworks 作业板",
|
||||||
@ -641,6 +692,9 @@ export default {
|
|||||||
FloatingToolbar,
|
FloatingToolbar,
|
||||||
FloatingICP,
|
FloatingICP,
|
||||||
HomeworkEditDialog,
|
HomeworkEditDialog,
|
||||||
|
InitServiceChooser,
|
||||||
|
ChatWidget,
|
||||||
|
StudentNameManager,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
const defaultSubjects = [
|
const defaultSubjects = [
|
||||||
@ -653,7 +707,7 @@ export default {
|
|||||||
{ name: "政治", order: 6 },
|
{ name: "政治", order: 6 },
|
||||||
{ name: "历史", order: 7 },
|
{ name: "历史", order: 7 },
|
||||||
{ name: "地理", order: 8 },
|
{ name: "地理", order: 8 },
|
||||||
{ name: "其他", order: 9 }
|
{ name: "其他", order: 9 },
|
||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -684,7 +738,7 @@ export default {
|
|||||||
snackbarText: "",
|
snackbarText: "",
|
||||||
fontSize: getSetting("font.size"),
|
fontSize: getSetting("font.size"),
|
||||||
datePickerDialog: false,
|
datePickerDialog: false,
|
||||||
selectedDate: new Date().toISOString().split("T")[0].replace(/-/g, ''),
|
selectedDate: new Date().toISOString().split("T")[0].replace(/-/g, ""),
|
||||||
selectedDateObj: new Date(),
|
selectedDateObj: new Date(),
|
||||||
refreshInterval: null,
|
refreshInterval: null,
|
||||||
showNoDataMessage: false,
|
showNoDataMessage: false,
|
||||||
@ -721,6 +775,28 @@ export default {
|
|||||||
cancelHandler: null,
|
cancelHandler: null,
|
||||||
icons: {},
|
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()
|
(key) => this.state.boardData.homework[key].content?.trim()
|
||||||
);
|
);
|
||||||
return this.state.availableSubjects
|
return this.state.availableSubjects
|
||||||
.filter(subject => !usedKeys.includes(subject.name))
|
.filter((subject) => !usedKeys.includes(subject.name))
|
||||||
.sort((a, b) => a.order - b.order);
|
.sort((a, b) => a.order - b.order);
|
||||||
},
|
},
|
||||||
emptySubjects() {
|
emptySubjects() {
|
||||||
@ -853,6 +929,16 @@ export default {
|
|||||||
showAntiScreenBurnCard() {
|
showAntiScreenBurnCard() {
|
||||||
return getSetting("display.showAntiScreenBurnCard");
|
return getSetting("display.showAntiScreenBurnCard");
|
||||||
},
|
},
|
||||||
|
shouldShowInit() {
|
||||||
|
const provider = getSetting("server.provider");
|
||||||
|
const isKv = provider === "kv-server" || provider === "classworkscloud";
|
||||||
|
const token = getSetting("server.kvToken");
|
||||||
|
// 仅首页
|
||||||
|
const onHome = this.$route?.path === "/";
|
||||||
|
// 依赖 settingsTick 使其在设置变更时重新计算
|
||||||
|
void this.settingsTick;
|
||||||
|
return onHome && isKv && (!token || token === "");
|
||||||
|
},
|
||||||
filteredStudents() {
|
filteredStudents() {
|
||||||
let students = [...this.state.studentList];
|
let students = [...this.state.studentList];
|
||||||
|
|
||||||
@ -915,7 +1001,7 @@ export default {
|
|||||||
subjectOrder() {
|
subjectOrder() {
|
||||||
return [...this.state.availableSubjects]
|
return [...this.state.availableSubjects]
|
||||||
.sort((a, b) => a.order - b.order)
|
.sort((a, b) => a.order - b.order)
|
||||||
.map(subject => subject.name);
|
.map((subject) => subject.name);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -956,6 +1042,24 @@ export default {
|
|||||||
this.updateSettings();
|
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(
|
document.addEventListener(
|
||||||
"fullscreenchange",
|
"fullscreenchange",
|
||||||
this.fullscreenChangeHandler
|
this.fullscreenChangeHandler
|
||||||
@ -976,6 +1080,14 @@ export default {
|
|||||||
this.checkHashForRandomPicker();
|
this.checkHashForRandomPicker();
|
||||||
|
|
||||||
window.addEventListener("hashchange", this.checkHashForRandomPicker);
|
window.addEventListener("hashchange", this.checkHashForRandomPicker);
|
||||||
|
|
||||||
|
// 实时频道:加入设备房间并监听键变化
|
||||||
|
this.setupRealtimeChannel();
|
||||||
|
|
||||||
|
// 初始化 Token 显示信息
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.updateTokenDisplayInfo();
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("初始化失败:", err);
|
console.error("初始化失败:", err);
|
||||||
this.showError("初始化失败,请刷新页面重试");
|
this.showError("初始化失败,请刷新页面重试");
|
||||||
@ -1008,9 +1120,63 @@ export default {
|
|||||||
);
|
);
|
||||||
|
|
||||||
window.removeEventListener("hashchange", this.checkHashForRandomPicker);
|
window.removeEventListener("hashchange", this.checkHashForRandomPicker);
|
||||||
|
|
||||||
|
// 退出设备房间并清理监听
|
||||||
|
try {
|
||||||
|
if (this.$offKvChanged) this.$offKvChanged();
|
||||||
|
if (this.$offConnect) this.$offConnect();
|
||||||
|
leaveAll();
|
||||||
|
} catch (e) {
|
||||||
|
void e;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
// 更新 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) {
|
ensureDate(dateInput) {
|
||||||
if (dateInput instanceof Date) {
|
if (dateInput instanceof Date) {
|
||||||
return dateInput;
|
return dateInput;
|
||||||
@ -1085,10 +1251,13 @@ export default {
|
|||||||
if (response.error.code === "NOT_FOUND") {
|
if (response.error.code === "NOT_FOUND") {
|
||||||
this.state.showNoDataMessage = true;
|
this.state.showNoDataMessage = true;
|
||||||
this.state.noDataMessage = response.error.message;
|
this.state.noDataMessage = response.error.message;
|
||||||
this.state.boardData = {
|
// 只有当前没有数据时才设置为空,避免覆盖已有的本地数据
|
||||||
homework: {},
|
if (!this.state.boardData || (!this.state.boardData.homework && !this.state.boardData.attendance)) {
|
||||||
attendance: { absent: [], late: [], exclude: [] },
|
this.state.boardData = {
|
||||||
};
|
homework: {},
|
||||||
|
attendance: { absent: [], late: [], exclude: [] },
|
||||||
|
};
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(response.error.message);
|
throw new Error(response.error.message);
|
||||||
}
|
}
|
||||||
@ -1106,11 +1275,16 @@ export default {
|
|||||||
this.$message.success("下载成功", "数据已更新");
|
this.$message.success("下载成功", "数据已更新");
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.state.boardData = {
|
// 数据加载失败时不覆盖现有数据,只显示错误信息
|
||||||
homework: {},
|
console.error("数据加载失败:", error);
|
||||||
attendance: { absent: [], late: [], exclude: [] },
|
|
||||||
};
|
|
||||||
this.$message.error("下载失败", error.message);
|
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 {
|
} finally {
|
||||||
this.loading.download = false;
|
this.loading.download = false;
|
||||||
}
|
}
|
||||||
@ -1194,9 +1368,7 @@ export default {
|
|||||||
const response = await dataProvider.loadData("classworks-list-main");
|
const response = await dataProvider.loadData("classworks-list-main");
|
||||||
|
|
||||||
if (response.success != false && Array.isArray(response)) {
|
if (response.success != false && Array.isArray(response)) {
|
||||||
this.state.studentList = response.map(
|
this.state.studentList = response.map((student) => student.name);
|
||||||
(student) => student.name
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
console.warn(
|
||||||
@ -1214,7 +1386,9 @@ export default {
|
|||||||
|
|
||||||
async loadSubjects() {
|
async loadSubjects() {
|
||||||
try {
|
try {
|
||||||
const subjectsResponse = await dataProvider.loadData("classworks-config-subject");
|
const subjectsResponse = await dataProvider.loadData(
|
||||||
|
"classworks-config-subject"
|
||||||
|
);
|
||||||
if (subjectsResponse && Array.isArray(subjectsResponse)) {
|
if (subjectsResponse && Array.isArray(subjectsResponse)) {
|
||||||
// 更新科目列表
|
// 更新科目列表
|
||||||
this.state.availableSubjects = subjectsResponse;
|
this.state.availableSubjects = subjectsResponse;
|
||||||
@ -1370,6 +1544,8 @@ export default {
|
|||||||
this.state.contentStyle = { "font-size": `${this.state.fontSize}px` };
|
this.state.contentStyle = { "font-size": `${this.state.fontSize}px` };
|
||||||
this.setupAutoRefresh();
|
this.setupAutoRefresh();
|
||||||
this.updateBackendUrl();
|
this.updateBackendUrl();
|
||||||
|
// 触发依赖刷新(例如 shouldShowInit)
|
||||||
|
this.settingsTick++;
|
||||||
},
|
},
|
||||||
|
|
||||||
async handleDateSelect(newDate) {
|
async handleDateSelect(newDate) {
|
||||||
@ -1393,10 +1569,7 @@ export default {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
|
|
||||||
// Load both data and subjects in parallel
|
// Load both data and subjects in parallel
|
||||||
await Promise.all([
|
await Promise.all([this.downloadData(), this.loadSubjects()]);
|
||||||
this.downloadData(),
|
|
||||||
this.loadSubjects()
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Date processing error:", error);
|
console.error("Date processing error:", error);
|
||||||
@ -1430,6 +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() {
|
setAllPresent() {
|
||||||
this.state.boardData.attendance = {
|
this.state.boardData.attendance = {
|
||||||
@ -1728,7 +1975,7 @@ export default {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const binaryString = atob(configParam);
|
const binaryString = atob(configParam);
|
||||||
const bytes = Uint8Array.from(binaryString, c => c.charCodeAt(0));
|
const bytes = Uint8Array.from(binaryString, (c) => c.charCodeAt(0));
|
||||||
const decodedString = new TextDecoder().decode(bytes);
|
const decodedString = new TextDecoder().decode(bytes);
|
||||||
const decodedConfig = JSON.parse(decodedString);
|
const decodedConfig = JSON.parse(decodedString);
|
||||||
console.log("从URL读取配置:", decodedConfig);
|
console.log("从URL读取配置:", decodedConfig);
|
||||||
|
|||||||
@ -11,7 +11,7 @@
|
|||||||
icon="mdi-menu"
|
icon="mdi-menu"
|
||||||
variant="text"
|
variant="text"
|
||||||
@click="drawer = !drawer"
|
@click="drawer = !drawer"
|
||||||
class="d-md-none"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<v-app-bar-title class="text-h6">设置</v-app-bar-title>
|
<v-app-bar-title class="text-h6">设置</v-app-bar-title>
|
||||||
@ -43,7 +43,32 @@
|
|||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
direction="vertical"
|
direction="vertical"
|
||||||
>
|
>
|
||||||
<v-tabs-window-item value="index">
|
<v-tabs-window-item value="index"
|
||||||
|
><v-card class="service-card gradient-right clickable" elevation="8">
|
||||||
|
<v-card-item>
|
||||||
|
<div class="card-title">
|
||||||
|
<div>
|
||||||
|
<div class="text-h6">Classworks KV</div>
|
||||||
|
<div class="text-caption text-medium-emphasis">
|
||||||
|
文档形键值数据库
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</v-card-item>
|
||||||
|
<v-card-text>
|
||||||
|
<div class="mt-4">
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
class="text-none"
|
||||||
|
append-icon="mdi-arrow-right"
|
||||||
|
rounded="xl"
|
||||||
|
@click="openClassworksKV"
|
||||||
|
>
|
||||||
|
打开 Classworks KV
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
</v-card>
|
||||||
<v-card title="Classworks" subtitle="设置" class="rounded-xl" border>
|
<v-card title="Classworks" subtitle="设置" class="rounded-xl" border>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-alert
|
<v-alert
|
||||||
@ -146,7 +171,6 @@
|
|||||||
/>
|
/>
|
||||||
</v-tabs-window-item>
|
</v-tabs-window-item>
|
||||||
|
|
||||||
|
|
||||||
<v-tabs-window-item value="randomPicker">
|
<v-tabs-window-item value="randomPicker">
|
||||||
<random-picker-card border :is-mobile="isMobile" />
|
<random-picker-card border :is-mobile="isMobile" />
|
||||||
</v-tabs-window-item>
|
</v-tabs-window-item>
|
||||||
@ -441,6 +465,9 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
openClassworksKV() {
|
||||||
|
window.open(getSetting("server.authDomain"), "_blank");
|
||||||
|
},
|
||||||
loadAllSettings() {
|
loadAllSettings() {
|
||||||
Object.keys(this.settings).forEach((section) => {
|
Object.keys(this.settings).forEach((section) => {
|
||||||
Object.keys(this.settings[section]).forEach((key) => {
|
Object.keys(this.settings[section]).forEach((key) => {
|
||||||
|
|||||||
@ -28,6 +28,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 数据更新高亮效果
|
||||||
|
.glow-highlight {
|
||||||
|
animation: glow-pulse 3s ease-in-out;
|
||||||
|
box-shadow: 0 0 20px rgba(33, 150, 243, 0.6),
|
||||||
|
0 0 40px rgba(33, 150, 243, 0.4),
|
||||||
|
0 0 60px rgba(33, 150, 243, 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glow-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow: 0 0 20px rgba(33, 150, 243, 0.6),
|
||||||
|
0 0 40px rgba(33, 150, 243, 0.4),
|
||||||
|
0 0 60px rgba(33, 150, 243, 0.2);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 30px rgba(33, 150, 243, 0.8),
|
||||||
|
0 0 60px rgba(33, 150, 243, 0.6),
|
||||||
|
0 0 90px rgba(33, 150, 243, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 添加卡片悬浮效果
|
// 添加卡片悬浮效果
|
||||||
.grid-item .v-card {
|
.grid-item .v-card {
|
||||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||||
@ -289,80 +310,6 @@
|
|||||||
transform: scale(1.02);
|
transform: scale(1.02);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 添加卡片发光效果
|
|
||||||
.glow-track {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: radial-gradient(circle at var(--x, 50%) var(--y, 50%),
|
|
||||||
rgba(255, 255, 255, 0.15) 0%,
|
|
||||||
rgba(255, 255, 255, 0) 70%);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.3s;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover::before {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加卡片悬浮效果
|
|
||||||
.grid-item .v-card {
|
|
||||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transform: translateY(-4px);
|
|
||||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 添加空科目卡片样式
|
|
||||||
.empty-subject-card {
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
opacity: 0.8;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(-4px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 修改防烧屏提示卡片,使用 tonal 样式减少信息密度
|
|
||||||
.anti-burn-card {
|
|
||||||
animation: subtle-glow 4s infinite alternate;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes subtle-glow {
|
|
||||||
0% {
|
|
||||||
box-shadow: 0 0 5px rgba(33, 150, 243, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
box-shadow: 0 0 15px rgba(33, 150, 243, 0.3);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 出勤管理对话框样式
|
// 出勤管理对话框样式
|
||||||
.attendance-stat {
|
.attendance-stat {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@ -183,7 +183,7 @@ export default {
|
|||||||
if (autoConfigureCloud) {
|
if (autoConfigureCloud) {
|
||||||
// 使用classworksCloudDefaults配置
|
// 使用classworksCloudDefaults配置
|
||||||
const classworksCloudDefaults = {
|
const classworksCloudDefaults = {
|
||||||
"server.domain": "https://kv.wuyuan.dev",
|
"server.domain": import.meta.env.VITE_DEFAULT_KV_SERVER || "https://kv.wuyuan.dev",
|
||||||
"server.siteKey": "",
|
"server.siteKey": "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -68,7 +68,7 @@ const SETTINGS_STORAGE_KEY = "Classworks_settings";
|
|||||||
|
|
||||||
// 新增: Classworks云端存储的默认设置
|
// 新增: Classworks云端存储的默认设置
|
||||||
const classworksCloudDefaults = {
|
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.domain": "http://localhost:3030",
|
||||||
"server.siteKey": "",
|
"server.siteKey": "",
|
||||||
};
|
};
|
||||||
@ -206,7 +206,7 @@ const settingsDefinitions = {
|
|||||||
},
|
},
|
||||||
"server.authDomain": {
|
"server.authDomain": {
|
||||||
type: "string",
|
type: "string",
|
||||||
default: "https://kv.houlang.cloud",
|
default: import.meta.env.VITE_DEFAULT_AUTH_SERVER || "https://kv.houlang.cloud",
|
||||||
description: "授权服务器域名",
|
description: "授权服务器域名",
|
||||||
icon: "mdi-shield-account",
|
icon: "mdi-shield-account",
|
||||||
validate: (value) => {
|
validate: (value) => {
|
||||||
|
|||||||
87
src/utils/socketClient.js
Normal file
87
src/utils/socketClient.js
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
// Lightweight reusable Socket.IO client singleton
|
||||||
|
// - Uses server domain from settings when available
|
||||||
|
// - Exposes join/leave helpers and event on/off wrappers
|
||||||
|
|
||||||
|
import { io } from 'socket.io-client';
|
||||||
|
import { getSetting } from '@/utils/settings';
|
||||||
|
|
||||||
|
let socket = null;
|
||||||
|
let connectedDomain = null;
|
||||||
|
const listeners = new Set();
|
||||||
|
|
||||||
|
export function getServerUrl() {
|
||||||
|
// Prefer configured server domain; fallback to env; then current origin
|
||||||
|
const cfg = getSetting('server.domain');
|
||||||
|
const envUrl = import.meta?.env?.VITE_SERVER_URL;
|
||||||
|
return cfg || envUrl || window.location.origin;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSocket() {
|
||||||
|
const serverUrl = getServerUrl();
|
||||||
|
if (!socket || connectedDomain !== serverUrl) {
|
||||||
|
if (socket) {
|
||||||
|
try { socket.disconnect(); } catch (e) {
|
||||||
|
void e; // ignore
|
||||||
|
}
|
||||||
|
socket = null;
|
||||||
|
}
|
||||||
|
connectedDomain = serverUrl;
|
||||||
|
socket = io(serverUrl, { transports: ['websocket'] });
|
||||||
|
|
||||||
|
// Re-attach previously registered event handlers on new socket instance
|
||||||
|
listeners.forEach(({ event, handler }) => {
|
||||||
|
socket.on(event, handler);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function on(event, handler) {
|
||||||
|
const s = getSocket();
|
||||||
|
s.on(event, handler);
|
||||||
|
listeners.add({ event, handler });
|
||||||
|
return () => off(event, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function off(event, handler) {
|
||||||
|
if (!socket) return;
|
||||||
|
socket.off(event, handler);
|
||||||
|
// Remove only matching entry
|
||||||
|
for (const item of Array.from(listeners)) {
|
||||||
|
if (item.event === event && item.handler === handler) {
|
||||||
|
listeners.delete(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function joinToken(token) {
|
||||||
|
const s = getSocket();
|
||||||
|
if (!token) return;
|
||||||
|
s.emit('join-token', { token });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function leaveToken(token) {
|
||||||
|
if (!socket) return;
|
||||||
|
socket.emit('leave-token', { token });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function leaveAll() {
|
||||||
|
if (!socket) return;
|
||||||
|
socket.emit('leave-all');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onConnect(handler) {
|
||||||
|
const s = getSocket();
|
||||||
|
s.on('connect', handler);
|
||||||
|
return () => s.off('connect', handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disconnect() {
|
||||||
|
if (!socket) return;
|
||||||
|
try { socket.disconnect(); } catch (e) {
|
||||||
|
void e; // ignore
|
||||||
|
}
|
||||||
|
socket = null;
|
||||||
|
connectedDomain = null;
|
||||||
|
listeners.clear();
|
||||||
|
}
|
||||||
@ -7,7 +7,7 @@ import Vue from '@vitejs/plugin-vue'
|
|||||||
import VueRouter from 'unplugin-vue-router/vite'
|
import VueRouter from 'unplugin-vue-router/vite'
|
||||||
import Vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
|
import Vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
|
||||||
import { VitePWA } from 'vite-plugin-pwa'
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
import { TDesignResolver } from 'unplugin-vue-components/resolvers'
|
//import { TDesignResolver } from 'unplugin-vue-components/resolvers'
|
||||||
|
|
||||||
// Utilities
|
// Utilities
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
@ -156,11 +156,11 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
Components({
|
Components({
|
||||||
resolvers: [
|
//resolvers: [
|
||||||
TDesignResolver({
|
// TDesignResolver({
|
||||||
library: 'vue-next'
|
// library: 'vue-next'
|
||||||
})
|
// })
|
||||||
]
|
//]
|
||||||
}),
|
}),
|
||||||
Fonts({
|
Fonts({
|
||||||
google: {
|
google: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user