1
0
mirror of https://github.com/ZeroCatDev/Classworks.git synced 2025-12-08 22:03:09 +00:00

feat: Add ReadOnlyTokenWarning component and implement student name management dialog

- Introduced ReadOnlyTokenWarning.vue to alert users when using a read-only token.
- Added StudentNameManager.vue for managing student names with a dialog interface.
- Implemented AlternativeCodeDialog.vue for entering alternative codes (functionality pending).
- Created DeviceAuthDialog.vue for device authentication using namespace and password.
- Developed FirstTimeGuide.vue to guide users through the initial setup of Classworks KV.
- Added TokenInputDialog.vue for manual input of KV authorization tokens.
- Updated settings.vue to include a button for opening Classworks KV.
- Enhanced error handling and user feedback across components.
This commit is contained in:
SunWuyuan 2025-11-01 19:31:41 +08:00
parent a2b0cc9e08
commit df3c8e5a12
No known key found for this signature in database
GPG Key ID: A6A54CF66F56BB64
12 changed files with 2003 additions and 154 deletions

View File

@ -6,149 +6,201 @@
<div class="init-container"> <div class="init-container">
<div class="init-header"> <div class="init-header">
<div class="title"> <div class="title">
选择要使用的服务 欢迎使用 Classworks
</div> </div>
<div class="subtitle"> <div class="subtitle">
左侧为 Classworks 管理端右侧为 Classworks KV 控制台 请选择你的使用方式
</div> </div>
</div> </div>
<div class="card-row"> <!-- 主要选择卡片 -->
<!-- Classworks 卡片展开操作 --> <div class="main-card-row">
<!-- 初次使用 -->
<v-card <v-card
class="service-card gradient-left" class="main-service-card gradient-new clickable"
elevation="8" elevation="4"
@click="showGuideDialog = true"
> >
<v-card-item> <v-card-item>
<div class="card-title"> <div class="card-horizontal-layout">
<div> <div class="card-icon-wrapper">
<div class="text-h6"> <v-icon
Classworks size="48"
color="primary"
>
mdi-new-box
</v-icon>
</div>
<div class="card-content">
<div class="text-h6 font-weight-bold">
初次使用
</div> </div>
<div class="text-caption text-medium-emphasis"> <div class="text-body-2 text-medium-emphasis mt-1">
适用于班级大屏的作业板工具 了解 Classworks KV 并开始使用
</div> </div>
</div> </div>
</div> </div>
</v-card-item> </v-card-item>
<v-card-text>
<div class="action-grid">
<v-btn
color="primary"
prepend-icon="mdi-flash"
@click="handleAutoAuthorize"
>
开始使用
</v-btn>
<v-btn
color="secondary"
variant="tonal"
prepend-icon="mdi-key"
@click="showManual = !showManual"
>
输入 Token
</v-btn>
<v-btn
variant="text"
prepend-icon="mdi-laptop"
@click="useLocalMode"
>
使用本地模式
</v-btn>
</div>
<v-expand-transition>
<div
v-show="showManual"
class="mt-4"
>
<v-text-field
v-model="manualToken"
label="KV 授权 Token"
placeholder="粘贴从授权页面获取的 Token"
hide-details
clearable
/>
<v-alert
v-if="verifyError"
type="error"
variant="tonal"
class="mt-2"
>
{{ verifyError }}
</v-alert>
<div class="d-flex mt-2">
<v-spacer />
<v-btn
:disabled="!manualToken || verifying"
:loading="verifying"
color="primary"
@click="saveManualToken"
>
保存 Token
</v-btn>
</div>
</div>
</v-expand-transition>
</v-card-text>
</v-card> </v-card>
<!-- Classworks KV 卡片跳转 /kv --> <!-- 已注册设备 -->
<v-card <v-card
class="service-card gradient-right clickable" class="main-service-card gradient-registered clickable"
elevation="8" elevation="4"
@click="goKv" @click="showDeviceAuthDialog = true"
> >
<v-card-item> <v-card-item>
<div class="card-title"> <div class="card-horizontal-layout">
<div> <div class="card-icon-wrapper">
<div class="text-h6"> <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 Classworks KV
</div> </div>
<div class="text-caption text-medium-emphasis"> <div class="text-body-2 text-medium-emphasis mt-1">
云原生键值数据库 打开云端控制台管理数据
</div> </div>
</div> </div>
</div> </div>
</v-card-item> </v-card-item>
<v-card-text>
<div class="mt-4">
<v-btn
variant="text"
class="text-none"
append-icon="mdi-arrow-right"
rounded="xl"
@click.stop="goKv"
>
打开 Classworks KV
</v-btn>
</div>
</v-card-text>
</v-card> </v-card>
</div> </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 class="footer-hint">
完成授权后可使用作业同步考试看板等在线功能 完成授权后可使用作业同步考试看板等在线功能
</div> </div>
</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> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { getSetting, setSetting } from '@/utils/settings' import { getSetting, setSetting } from '@/utils/settings'
import axios from '@/axios/axios' 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 router = useRouter()
const emit = defineEmits(['done']) const emit = defineEmits(['done'])
// kvToken provider kv-local // kvToken provider kv-local
const visible = ref(false) const visible = ref(false)
const showManual = ref(false)
const manualToken = ref('') //
const verifying = ref(false) const showGuideDialog = ref(false)
const verifyError = ref('') const showDeviceAuthDialog = ref(false)
const showTokenDialog = ref(false)
const showAlternativeCodeDialog = ref(false)
const provider = computed(() => getSetting('server.provider')) const provider = computed(() => getSetting('server.provider'))
const isKvProvider = computed(() => provider.value === 'kv-server' || provider.value === 'classworkscloud') const isKvProvider = computed(() => provider.value === 'kv-server' || provider.value === 'classworkscloud')
@ -179,37 +231,22 @@ const handleAutoAuthorize = () => {
window.location.href = url window.location.href = url
} }
const saveManualToken = async () => { const handleAuthSuccess = () => {
if (!manualToken.value || verifying.value) return showDeviceAuthDialog.value = false
verifyError.value = '' evaluateVisibility()
verifying.value = true emit('done')
try { }
const serverUrl = getSetting('server.domain')
if (!serverUrl) throw new Error('未配置服务器域名')
await axios.get(`${serverUrl}/kv/_info`, { const handleTokenSuccess = () => {
headers: { showTokenDialog.value = false
Accept: 'application/json', evaluateVisibility()
'x-app-token': manualToken.value, emit('done')
}, }
})
// const handleAlternativeCodeSubmit = (code) => {
setSetting('server.kvToken', manualToken.value) console.log('替代代码:', code)
evaluateVisibility() // TODO:
emit('done') showAlternativeCodeDialog.value = false
} catch (err) {
const status = err?.response?.status
if (status === 401 || status === 403) {
verifyError.value = 'Token 无效或无权限,请确认后重试'
} else if (status === 404) {
verifyError.value = '命名空间不存在或服务器未就绪'
} else {
verifyError.value = err?.response?.data?.message || err?.message || '验证失败,请稍后重试'
}
} finally {
verifying.value = false
}
} }
const useLocalMode = () => { const useLocalMode = () => {
@ -220,23 +257,137 @@ const useLocalMode = () => {
emit('done') emit('done')
} }
const goKv = () => { const openClassworksKV = () => {
router.push('/kv') window.open(getSetting('server.authDomain'), '_blank')
} }
</script> </script>
<style scoped> <style scoped>
.init-overlay { position: relative; } .init-overlay {
.init-container { max-width: 1080px; margin: 24px auto; padding: 8px 16px; } position: relative;
.init-header .title { font-size: 20px; font-weight: 600; } }
.init-header .subtitle { margin-top: 4px; font-size: 13px; opacity: .75; }
.card-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px; } .init-container {
.service-card { min-height: 220px; } max-width: 900px;
.card-title { display: flex; align-items: center; } margin: 24px auto;
.clickable { cursor: pointer; } padding: 8px 16px;
.gradient-left { background: linear-gradient(135deg, rgba(103,80,164,.18), rgba(103,80,164,0) 60%); } }
.gradient-right { background: linear-gradient(135deg, rgba(0,184,212,.18), rgba(0,184,212,0) 60%); }
.action-grid { display: grid; grid-template-columns: repeat(3, max-content); gap: 12px; align-items: center; } .init-header .title {
.footer-hint { margin-top: 12px; font-size: 12px; opacity: .7; } font-size: 28px;
@media (max-width: 900px) { .card-row { grid-template-columns: 1fr; } } 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> </style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 } from "@/utils/settings";
export default { export default {
name: "CloudNamespaceInfoCard", name: "CloudNamespaceInfoCard",
@ -125,6 +145,7 @@ export default {
namespaceInfo: {}, namespaceInfo: {},
loading: false, loading: false,
hasNamespaceInfo: false, hasNamespaceInfo: false,
showReinitDialog: false, //
}; };
}, },
watch: { watch: {
@ -168,13 +189,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('/');
}, },
}, },
}; };

View File

@ -7,6 +7,30 @@
<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 icon="mdi-chat" variant="text" @click="isChatOpen = true" />
<v-btn <v-btn
icon="mdi-bell" icon="mdi-bell"
@ -21,6 +45,13 @@
<!-- 初始化选择卡片仅在首页且需要授权时显示不影响顶栏 --> <!-- 初始化选择卡片仅在首页且需要授权时显示不影响顶栏 -->
<init-service-chooser v-if="shouldShowInit" @done="settingsTick++" /> <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"> <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>
@ -629,6 +660,7 @@ import FloatingICP from "@/components/FloatingICP.vue";
import ChatWidget from "@/components/ChatWidget.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 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,
@ -662,6 +694,7 @@ export default {
HomeworkEditDialog, HomeworkEditDialog,
InitServiceChooser, InitServiceChooser,
ChatWidget, ChatWidget,
StudentNameManager,
}, },
data() { data() {
const defaultSubjects = [ const defaultSubjects = [
@ -745,6 +778,16 @@ export default {
settingsTick: 0, settingsTick: 0,
isChatOpen: false, isChatOpen: false,
highlightedCards: {}, // highlightedCards: {}, //
// Token token
tokenDisplayInfo: {
show: false,
readonly: false, // token
text: '',
color: 'primary',
variant: 'tonal',
icon: 'mdi-account',
disabled: false
},
// //
realtimeInfo: { realtimeInfo: {
show: false, show: false,
@ -999,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
@ -1022,6 +1083,11 @@ export default {
// //
this.setupRealtimeChannel(); this.setupRealtimeChannel();
// Token
this.$nextTick(() => {
this.updateTokenDisplayInfo();
});
} catch (err) { } catch (err) {
console.error("初始化失败:", err); console.error("初始化失败:", err);
this.showError("初始化失败,请刷新页面重试"); this.showError("初始化失败,请刷新页面重试");
@ -1066,6 +1132,51 @@ export default {
}, },
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;

View File

@ -62,6 +62,7 @@
class="text-none" class="text-none"
append-icon="mdi-arrow-right" append-icon="mdi-arrow-right"
rounded="xl" rounded="xl"
@click="openClassworksKV"
> >
打开 Classworks KV 打开 Classworks KV
</v-btn> </v-btn>
@ -464,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) => {