mirror of
https://github.com/ZeroCatDev/Classworks.git
synced 2025-12-07 21:13:11 +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:
parent
a2b0cc9e08
commit
df3c8e5a12
@ -6,149 +6,201 @@
|
||||
<div class="init-container">
|
||||
<div class="init-header">
|
||||
<div class="title">
|
||||
选择要使用的服务
|
||||
欢迎使用 Classworks
|
||||
</div>
|
||||
<div class="subtitle">
|
||||
左侧为 Classworks 管理端,右侧为 Classworks KV 控制台
|
||||
请选择你的使用方式
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-row">
|
||||
<!-- 左:Classworks 卡片(展开操作) -->
|
||||
<!-- 主要选择卡片 -->
|
||||
<div class="main-card-row">
|
||||
<!-- 初次使用 -->
|
||||
<v-card
|
||||
class="service-card gradient-left"
|
||||
elevation="8"
|
||||
class="main-service-card gradient-new clickable"
|
||||
elevation="4"
|
||||
@click="showGuideDialog = true"
|
||||
>
|
||||
<v-card-item>
|
||||
<div class="card-title">
|
||||
<div>
|
||||
<div class="text-h6">
|
||||
Classworks
|
||||
<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-caption text-medium-emphasis">
|
||||
适用于班级大屏的作业板工具
|
||||
<div class="text-body-2 text-medium-emphasis mt-1">
|
||||
了解 Classworks KV 并开始使用
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-item>
|
||||
<v-card-text>
|
||||
<div class="action-grid">
|
||||
<v-btn
|
||||
color="primary"
|
||||
prepend-icon="mdi-flash"
|
||||
@click="handleAutoAuthorize"
|
||||
>
|
||||
开始使用
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="secondary"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-key"
|
||||
@click="showManual = !showManual"
|
||||
>
|
||||
输入 Token
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="text"
|
||||
prepend-icon="mdi-laptop"
|
||||
@click="useLocalMode"
|
||||
>
|
||||
使用本地模式
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-expand-transition>
|
||||
<div
|
||||
v-show="showManual"
|
||||
class="mt-4"
|
||||
>
|
||||
<v-text-field
|
||||
v-model="manualToken"
|
||||
label="KV 授权 Token"
|
||||
placeholder="粘贴从授权页面获取的 Token"
|
||||
hide-details
|
||||
clearable
|
||||
/>
|
||||
<v-alert
|
||||
v-if="verifyError"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
class="mt-2"
|
||||
>
|
||||
{{ verifyError }}
|
||||
</v-alert>
|
||||
<div class="d-flex mt-2">
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
:disabled="!manualToken || verifying"
|
||||
:loading="verifying"
|
||||
color="primary"
|
||||
@click="saveManualToken"
|
||||
>
|
||||
保存 Token
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- 右:Classworks KV 卡片(跳转 /kv) -->
|
||||
<!-- 已注册设备 -->
|
||||
<v-card
|
||||
class="service-card gradient-right clickable"
|
||||
elevation="8"
|
||||
@click="goKv"
|
||||
class="main-service-card gradient-registered clickable"
|
||||
elevation="4"
|
||||
@click="showDeviceAuthDialog = true"
|
||||
>
|
||||
<v-card-item>
|
||||
<div class="card-title">
|
||||
<div>
|
||||
<div class="text-h6">
|
||||
<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-caption text-medium-emphasis">
|
||||
云原生键值数据库
|
||||
<div class="text-body-2 text-medium-emphasis mt-1">
|
||||
打开云端控制台管理数据
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-item>
|
||||
<v-card-text>
|
||||
<div class="mt-4">
|
||||
<v-btn
|
||||
variant="text"
|
||||
class="text-none"
|
||||
append-icon="mdi-arrow-right"
|
||||
rounded="xl"
|
||||
@click.stop="goKv"
|
||||
>
|
||||
打开 Classworks KV
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</div>
|
||||
<div class="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 { useRouter } from 'vue-router'
|
||||
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'])
|
||||
|
||||
// 控制显示:仅首页且无 kvToken(且 provider 不是 kv-local)显示
|
||||
const visible = ref(false)
|
||||
const showManual = ref(false)
|
||||
const manualToken = ref('')
|
||||
const verifying = ref(false)
|
||||
const verifyError = ref('')
|
||||
|
||||
// 对话框控制
|
||||
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')
|
||||
@ -179,37 +231,22 @@ const handleAutoAuthorize = () => {
|
||||
window.location.href = url
|
||||
}
|
||||
|
||||
const saveManualToken = async () => {
|
||||
if (!manualToken.value || verifying.value) return
|
||||
verifyError.value = ''
|
||||
verifying.value = true
|
||||
try {
|
||||
const serverUrl = getSetting('server.domain')
|
||||
if (!serverUrl) throw new Error('未配置服务器域名')
|
||||
const handleAuthSuccess = () => {
|
||||
showDeviceAuthDialog.value = false
|
||||
evaluateVisibility()
|
||||
emit('done')
|
||||
}
|
||||
|
||||
await axios.get(`${serverUrl}/kv/_info`, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'x-app-token': manualToken.value,
|
||||
},
|
||||
})
|
||||
const handleTokenSuccess = () => {
|
||||
showTokenDialog.value = false
|
||||
evaluateVisibility()
|
||||
emit('done')
|
||||
}
|
||||
|
||||
// 验证通过再保存
|
||||
setSetting('server.kvToken', manualToken.value)
|
||||
evaluateVisibility()
|
||||
emit('done')
|
||||
} catch (err) {
|
||||
const status = err?.response?.status
|
||||
if (status === 401 || status === 403) {
|
||||
verifyError.value = 'Token 无效或无权限,请确认后重试'
|
||||
} else if (status === 404) {
|
||||
verifyError.value = '命名空间不存在或服务器未就绪'
|
||||
} else {
|
||||
verifyError.value = err?.response?.data?.message || err?.message || '验证失败,请稍后重试'
|
||||
}
|
||||
} finally {
|
||||
verifying.value = false
|
||||
}
|
||||
const handleAlternativeCodeSubmit = (code) => {
|
||||
console.log('替代代码:', code)
|
||||
// TODO: 实现替代代码逻辑
|
||||
showAlternativeCodeDialog.value = false
|
||||
}
|
||||
|
||||
const useLocalMode = () => {
|
||||
@ -220,23 +257,137 @@ const useLocalMode = () => {
|
||||
emit('done')
|
||||
}
|
||||
|
||||
const goKv = () => {
|
||||
router.push('/kv')
|
||||
const openClassworksKV = () => {
|
||||
window.open(getSetting('server.authDomain'), '_blank')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.init-overlay { position: relative; }
|
||||
.init-container { max-width: 1080px; margin: 24px auto; padding: 8px 16px; }
|
||||
.init-header .title { font-size: 20px; font-weight: 600; }
|
||||
.init-header .subtitle { margin-top: 4px; font-size: 13px; opacity: .75; }
|
||||
.card-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px; }
|
||||
.service-card { min-height: 220px; }
|
||||
.card-title { display: flex; align-items: center; }
|
||||
.clickable { cursor: pointer; }
|
||||
.gradient-left { background: linear-gradient(135deg, rgba(103,80,164,.18), rgba(103,80,164,0) 60%); }
|
||||
.gradient-right { background: linear-gradient(135deg, rgba(0,184,212,.18), rgba(0,184,212,0) 60%); }
|
||||
.action-grid { display: grid; grid-template-columns: repeat(3, max-content); gap: 12px; align-items: center; }
|
||||
.footer-hint { margin-top: 12px; font-size: 12px; opacity: .7; }
|
||||
@media (max-width: 900px) { .card-row { grid-template-columns: 1fr; } }
|
||||
.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>
|
||||
|
||||
@ -225,7 +225,7 @@
|
||||
|
||||
<script>
|
||||
import { openDB } from "idb";
|
||||
import axios from "@/assets/fonts/axios/axios";
|
||||
import axios from "@/axios/axios";
|
||||
import { getSetting, setSetting } from "@/utils/settings";
|
||||
|
||||
export default {
|
||||
|
||||
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>
|
||||
@ -102,15 +102,35 @@
|
||||
刷新设备信息
|
||||
</v-btn>
|
||||
|
||||
<v-btn color="primary" @click="reinitializeCloudStorage">
|
||||
<v-btn color="error" variant="outlined" @click="showReinitDialog = true">
|
||||
重新初始化云端存储
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
|
||||
<!-- 重新初始化确认对话框 -->
|
||||
<v-dialog v-model="showReinitDialog" max-width="500">
|
||||
<v-card>
|
||||
<v-card-title>确认重新初始化</v-card-title>
|
||||
<v-card-text>
|
||||
<v-alert type="warning" variant="tonal" class="mb-3">
|
||||
<v-alert-title>警告</v-alert-title>
|
||||
此操作将清除当前的云端存储配置(包括 Token),您需要重新进行授权。
|
||||
</v-alert>
|
||||
<p>您确定要重新初始化云端存储吗?</p>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="showReinitDialog = false">取消</v-btn>
|
||||
<v-btn color="error" @click="confirmReinitialize">确认</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { kvServerProvider } from "@/utils/providers/kvServerProvider";
|
||||
import { setSetting } from "@/utils/settings";
|
||||
|
||||
export default {
|
||||
name: "CloudNamespaceInfoCard",
|
||||
@ -125,6 +145,7 @@ export default {
|
||||
namespaceInfo: {},
|
||||
loading: false,
|
||||
hasNamespaceInfo: false,
|
||||
showReinitDialog: false, // 确认对话框显示状态
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
@ -168,13 +189,16 @@ export default {
|
||||
async reloadInfo() {
|
||||
await this.fetchNamespaceInfo();
|
||||
},
|
||||
reinitializeCloudStorage() {
|
||||
// 触发 KvInitialize 组件的重新初始化
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent("kvinit:open"));
|
||||
} catch (e) {
|
||||
console.error("重新初始化云端存储失败:", e);
|
||||
}
|
||||
confirmReinitialize() {
|
||||
// 删除 token 配置(设置为空字符串以触发 shouldShowInit)
|
||||
setSetting('server.kvToken', '');
|
||||
setSetting('device.uuid', '');
|
||||
|
||||
// 关闭对话框
|
||||
this.showReinitDialog = false;
|
||||
|
||||
// 返回主页(将触发 InitServiceChooser 组件显示)
|
||||
this.$router.push('/');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -7,6 +7,30 @@
|
||||
<v-spacer />
|
||||
|
||||
<template #append>
|
||||
<!-- 只读 Token 警告 -->
|
||||
<v-chip
|
||||
v-if="tokenDisplayInfo.readonly"
|
||||
color="warning"
|
||||
variant="tonal"
|
||||
class="mx-2"
|
||||
prepend-icon="mdi-lock-alert"
|
||||
>
|
||||
只读
|
||||
</v-chip>
|
||||
|
||||
<!-- 学生名称显示 chip(始终蓝色) -->
|
||||
<v-chip
|
||||
v-if="tokenDisplayInfo.show"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
class="mx-2"
|
||||
prepend-icon="mdi-account"
|
||||
:style="{ cursor: tokenDisplayInfo.disabled ? 'default' : 'pointer' }"
|
||||
@click="handleTokenChipClick"
|
||||
>
|
||||
{{ tokenDisplayInfo.text }}
|
||||
</v-chip>
|
||||
|
||||
<v-btn icon="mdi-chat" variant="text" @click="isChatOpen = true" />
|
||||
<v-btn
|
||||
icon="mdi-bell"
|
||||
@ -21,6 +45,13 @@
|
||||
<!-- 初始化选择卡片,仅在首页且需要授权时显示;不影响顶栏 -->
|
||||
<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>
|
||||
@ -629,6 +660,7 @@ import FloatingICP from "@/components/FloatingICP.vue";
|
||||
import ChatWidget from "@/components/ChatWidget.vue";
|
||||
import HomeworkEditDialog from "@/components/HomeworkEditDialog.vue";
|
||||
import InitServiceChooser from "@/components/InitServiceChooser.vue";
|
||||
import StudentNameManager from "@/components/StudentNameManager.vue";
|
||||
import dataProvider from "@/utils/dataProvider";
|
||||
import {
|
||||
getSetting,
|
||||
@ -662,6 +694,7 @@ export default {
|
||||
HomeworkEditDialog,
|
||||
InitServiceChooser,
|
||||
ChatWidget,
|
||||
StudentNameManager,
|
||||
},
|
||||
data() {
|
||||
const defaultSubjects = [
|
||||
@ -745,6 +778,16 @@ export default {
|
||||
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,
|
||||
@ -999,6 +1042,24 @@ export default {
|
||||
this.updateSettings();
|
||||
});
|
||||
|
||||
// 连接学生姓名管理组件
|
||||
this.$nextTick(() => {
|
||||
const studentNameManager = this.$refs.studentNameManager;
|
||||
if (studentNameManager) {
|
||||
this.studentNameInfo.name = studentNameManager.currentStudentName;
|
||||
this.studentNameInfo.isStudent = studentNameManager.isStudentToken;
|
||||
this.studentNameInfo.openDialog = () => studentNameManager.openDialog();
|
||||
|
||||
// 监听学生姓名变化
|
||||
this.$watch(() => studentNameManager.currentStudentName, (newName) => {
|
||||
this.studentNameInfo.name = newName;
|
||||
});
|
||||
this.$watch(() => studentNameManager.isStudentToken, (isStudent) => {
|
||||
this.studentNameInfo.isStudent = isStudent;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener(
|
||||
"fullscreenchange",
|
||||
this.fullscreenChangeHandler
|
||||
@ -1022,6 +1083,11 @@ export default {
|
||||
|
||||
// 实时频道:加入设备房间并监听键变化
|
||||
this.setupRealtimeChannel();
|
||||
|
||||
// 初始化 Token 显示信息
|
||||
this.$nextTick(() => {
|
||||
this.updateTokenDisplayInfo();
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("初始化失败:", err);
|
||||
this.showError("初始化失败,请刷新页面重试");
|
||||
@ -1066,6 +1132,51 @@ export default {
|
||||
},
|
||||
|
||||
methods: {
|
||||
// 更新 Token 显示信息
|
||||
updateTokenDisplayInfo() {
|
||||
const manager = this.$refs.studentNameManager
|
||||
if (!manager || !manager.hasToken) {
|
||||
this.tokenDisplayInfo.show = false
|
||||
this.tokenDisplayInfo.readonly = false
|
||||
return
|
||||
}
|
||||
|
||||
const displayName = manager.displayName
|
||||
const isReadOnly = manager.isReadOnly
|
||||
const isStudent = manager.isStudentToken
|
||||
|
||||
// 设置只读状态(对所有类型的 token 都显示)
|
||||
this.tokenDisplayInfo.readonly = isReadOnly
|
||||
|
||||
// 只有学生类型的 token 才显示名称 chip
|
||||
if (!isStudent) {
|
||||
this.tokenDisplayInfo.show = false
|
||||
return
|
||||
}
|
||||
|
||||
// 设置学生名称显示(始终蓝色)
|
||||
this.tokenDisplayInfo.text = displayName
|
||||
this.tokenDisplayInfo.color = 'primary'
|
||||
this.tokenDisplayInfo.icon = 'mdi-account'
|
||||
this.tokenDisplayInfo.disabled = isReadOnly // 只读时不可点击
|
||||
this.tokenDisplayInfo.show = true
|
||||
},
|
||||
|
||||
// 处理 Token Chip 点击
|
||||
handleTokenChipClick() {
|
||||
console.log('Token chip clicked')
|
||||
const manager = this.$refs.studentNameManager
|
||||
console.log('Manager:', manager)
|
||||
console.log('Is student token:', manager?.isStudentToken)
|
||||
|
||||
if (manager && manager.isStudentToken) {
|
||||
console.log('Opening dialog...')
|
||||
manager.openDialog()
|
||||
} else {
|
||||
console.log('Cannot open dialog - conditions not met')
|
||||
}
|
||||
},
|
||||
|
||||
ensureDate(dateInput) {
|
||||
if (dateInput instanceof Date) {
|
||||
return dateInput;
|
||||
|
||||
@ -62,6 +62,7 @@
|
||||
class="text-none"
|
||||
append-icon="mdi-arrow-right"
|
||||
rounded="xl"
|
||||
@click="openClassworksKV"
|
||||
>
|
||||
打开 Classworks KV
|
||||
</v-btn>
|
||||
@ -464,6 +465,9 @@ export default {
|
||||
},
|
||||
|
||||
methods: {
|
||||
openClassworksKV() {
|
||||
window.open(getSetting("server.authDomain"), "_blank");
|
||||
},
|
||||
loadAllSettings() {
|
||||
Object.keys(this.settings).forEach((section) => {
|
||||
Object.keys(this.settings[section]).forEach((key) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user