首次提交
Azure Static Web Apps CI/CD / Close Pull Request Job (push) Skipped
Build and Deploy / build (push) Failing after 9m48s
Azure Static Web Apps CI/CD / Build and Deploy Job (push) Failing after 9m50s

This commit is contained in:
2026-10-24 01:29:24 +08:00
parent e70d46436c
commit c7fdc11dbd
146 changed files with 9750 additions and 5578 deletions
+8 -2
View File
@@ -2,8 +2,14 @@
<v-app>
<!-- 正常路由 -->
<router-view v-slot="{ Component, route }">
<transition mode="out-in" name="md3">
<component :is="Component" :key="route.path" />
<transition
mode="out-in"
name="md3"
>
<component
:is="Component"
:key="route.path"
/>
</transition>
</router-view>
<global-message />
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 367 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

-25
View File
@@ -1,25 +0,0 @@
<svg xmlns:xlink="http://www.w3.org/1999/xlink" xmlns="http://www.w3.org/2000/svg" width="256" height="256"
viewBox="0 0 256 256" fill="none">
<g clip-path="url(#clip-path-74_1)">
<path fill="#FFFFFF" d="M0 256L256 256L256 0L0 0L0 256Z">
</path>
<rect x="0" y="0" width="256" height="128" fill="#D8C4A0">
</rect>
<rect x="0" y="128" width="256" height="128" fill="#F5E0BB">
</rect>
<path d="M28 228L128 128L228 128L128 228L28 228Z" fill-rule="evenodd" fill="#241A04">
</path>
<path d="M28 128L128 28L228 28L128 128L28 128Z" fill-rule="evenodd" fill="#52452A">
</path>
<g>
<path fill="#000000"
d="M-3049.01 2467.94L-3043.48 2467.94L-3043.48 2466.99L-3045.92 2466.99C-3046.36 2466.99 -3046.9 2467.04 -3047.36 2467.08C-3045.29 2465.12 -3043.9 2463.33 -3043.9 2461.57C-3043.9 2460.01 -3044.9 2458.99 -3046.47 2458.99C-3047.58 2458.99 -3048.35 2459.49 -3049.06 2460.27L-3048.43 2460.9C-3047.93 2460.31 -3047.32 2459.88 -3046.6 2459.88C-3045.51 2459.88 -3044.98 2460.61 -3044.98 2461.62C-3044.98 2463.13 -3046.25 2464.88 -3049.01 2467.29L-3049.01 2467.94ZM-3039.27 2468.1C-3037.9 2468.1 -3036.74 2466.95 -3036.74 2465.24C-3036.74 2463.39 -3037.7 2462.48 -3039.19 2462.48C-3039.87 2462.48 -3040.64 2462.88 -3041.18 2463.54C-3041.13 2460.81 -3040.13 2459.89 -3038.91 2459.89C-3038.38 2459.89 -3037.85 2460.15 -3037.52 2460.56L-3036.89 2459.89C-3037.39 2459.36 -3038.04 2458.99 -3038.96 2458.99C-3040.66 2458.99 -3042.21 2460.3 -3042.21 2463.74C-3042.21 2466.65 -3040.95 2468.1 -3039.27 2468.1ZM-3041.15 2464.41C-3040.58 2463.6 -3039.91 2463.3 -3039.36 2463.3C-3038.3 2463.3 -3037.78 2464.05 -3037.78 2465.24C-3037.78 2466.44 -3038.43 2467.23 -3039.27 2467.23C-3040.37 2467.23 -3041.03 2466.24 -3041.15 2464.41ZM-3035.17 2467.94L-3030.34 2467.94L-3030.34 2467.03L-3032.1 2467.03L-3032.1 2459.15L-3032.95 2459.15C-3033.43 2459.42 -3033.99 2459.62 -3034.77 2459.77L-3034.77 2460.47L-3033.2 2460.47L-3033.2 2467.03L-3035.17 2467.03L-3035.17 2467.94ZM-3029.51 2467.94L-3028.4 2467.94L-3027.54 2465.25L-3024.33 2465.25L-3023.49 2467.94L-3022.31 2467.94L-3025.3 2459.15L-3026.54 2459.15L-3029.51 2467.94ZM-3027.27 2464.38L-3026.84 2463.02C-3026.52 2462.02 -3026.24 2461.08 -3025.96 2460.04L-3025.91 2460.04C-3025.62 2461.06 -3025.35 2462.02 -3025.02 2463.02L-3024.6 2464.38L-3027.27 2464.38ZM-3018.93 2468.1C-3017.26 2468.1 -3016.19 2466.58 -3016.19 2463.51C-3016.19 2460.47 -3017.26 2458.99 -3018.93 2458.99C-3020.61 2458.99 -3021.67 2460.47 -3021.67 2463.51C-3021.67 2466.58 -3020.61 2468.1 -3018.93 2468.1ZM-3018.93 2467.21C-3019.93 2467.21 -3020.61 2466.09 -3020.61 2463.51C-3020.61 2460.95 -3019.93 2459.85 -3018.93 2459.85C-3017.93 2459.85 -3017.25 2460.95 -3017.25 2463.51C-3017.25 2466.09 -3017.93 2467.21 -3018.93 2467.21ZM-3012.27 2468.1C-3010.6 2468.1 -3009.53 2466.58 -3009.53 2463.51C-3009.53 2460.47 -3010.6 2458.99 -3012.27 2458.99C-3013.95 2458.99 -3015.01 2460.47 -3015.01 2463.51C-3015.01 2466.58 -3013.95 2468.1 -3012.27 2468.1ZM-3012.27 2467.21C-3013.27 2467.21 -3013.95 2466.09 -3013.95 2463.51C-3013.95 2460.95 -3013.27 2459.85 -3012.27 2459.85C-3011.27 2459.85 -3010.59 2460.95 -3010.59 2463.51C-3010.59 2466.09 -3011.27 2467.21 -3012.27 2467.21Z">
</path>
</g>
</g>
<defs>
<clipPath id="clip-path-74_1">
<path d="M0 256L256 256L256 0L0 0L0 256Z" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.4 KiB

+2 -2
View File
@@ -1,7 +1,7 @@
<template>
<v-app-bar :elevation="2">
<template v-slot:prepend>
<v-app-bar-nav-icon></v-app-bar-nav-icon>
<template #prepend>
<v-app-bar-nav-icon />
</template>
<v-app-bar-title>作业</v-app-bar-title>
+56 -13
View File
@@ -2,48 +2,85 @@
<v-card>
<v-card-title class="d-flex align-center">
<span>缓存管理</span>
<v-spacer></v-spacer>
<v-btn :loading="loading" color="error" @click="clearAllCaches">
<v-spacer />
<v-btn
:loading="loading"
color="error"
@click="clearAllCaches"
>
清除所有缓存
</v-btn>
<v-btn class="ml-2" icon @click="refreshCaches">
<v-btn
class="ml-2"
icon
@click="refreshCaches"
>
<v-icon>mdi-refresh</v-icon>
</v-btn>
</v-card-title>
<v-card-text>
<v-alert v-if="!serviceWorkerActive" class="mb-4" type="warning">
<v-alert
v-if="!serviceWorkerActive"
class="mb-4"
type="warning"
>
Service Worker 未激活,缓存管理功能不可用。
</v-alert>
<v-alert v-if="message" :type="messageType" class="mb-4">
<v-alert
v-if="message"
:type="messageType"
class="mb-4"
>
{{ message }}
</v-alert>
<v-expansion-panels v-if="caches.length > 0">
<v-expansion-panel v-for="cache in caches" :key="cache.name">
<v-expansion-panel
v-for="cache in caches"
:key="cache.name"
>
<v-expansion-panel-title>
<div class="d-flex align-center">
<span>{{ formatCacheName(cache.name) }}</span>
<v-chip class="ml-2" size="small">{{ cache.urls.length }} 个文件</v-chip>
<v-chip
class="ml-2"
size="small"
>
{{ cache.urls.length }} 个文件
</v-chip>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text>
<div class="d-flex justify-end mb-2">
<v-btn :loading="loading" color="error" size="small" @click="clearCache(cache.name)">
<v-btn
:loading="loading"
color="error"
size="small"
@click="clearCache(cache.name)"
>
清除此缓存
</v-btn>
</div>
<v-list lines="two">
<v-list-item v-for="(url, index) in cache.urls" :key="index">
<v-list-item
v-for="(url, index) in cache.urls"
:key="index"
>
<v-list-item-title class="text-truncate">
{{ getFileName(url) }}
</v-list-item-title>
<v-list-item-subtitle class="text-truncate">
{{ url }}
</v-list-item-subtitle>
<template v-slot:append>
<v-btn color="error" icon size="small" @click="clearUrl(cache.name, url)">
<template #append>
<v-btn
color="error"
icon
size="small"
@click="clearUrl(cache.name, url)"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
</template>
@@ -53,9 +90,15 @@
</v-expansion-panel>
</v-expansion-panels>
<v-skeleton-loader v-else-if="loading" type="article"/>
<v-skeleton-loader
v-else-if="loading"
type="article"
/>
<v-alert v-else type="info">
<v-alert
v-else
type="info"
>
没有找到缓存数据。
</v-alert>
</v-card-text>
+314 -127
View File
@@ -11,7 +11,9 @@
@click:close="error = ''"
>
<div class="d-flex align-center">
<v-icon class="mr-2">mdi-alert-circle</v-icon>
<v-icon class="mr-2">
mdi-alert-circle
</v-icon>
{{ error }}
</div>
</v-alert>
@@ -27,7 +29,9 @@
@click:close="success = ''"
>
<div class="d-flex align-center">
<v-icon class="mr-2">mdi-check-circle</v-icon>
<v-icon class="mr-2">
mdi-check-circle
</v-icon>
{{ success }}
</div>
</v-alert>
@@ -43,29 +47,49 @@
<div class="d-flex align-center">
<span class="font-weight-bold">配置验证失败,请检查以下问题:</span>
</div>
<v-list class="bg-transparent" density="compact">
<v-list
class="bg-transparent"
density="compact"
>
<v-list-item
v-for="(error, index) in validationErrors"
:key="index"
class="px-0 py-0"
>
<template v-slot:prepend>
<v-icon color="warning" size="small">mdi-circle-small</v-icon>
<template #prepend>
<v-icon
color="warning"
size="small"
>
mdi-circle-small
</v-icon>
</template>
<v-list-item-title class="text-body-2">{{ error }}</v-list-item-title>
<v-list-item-title class="text-body-2">
{{ error }}
</v-list-item-title>
</v-list-item>
</v-list>
</v-alert>
<!-- 加载状态 -->
<v-card v-if="loading" class="my-4" outlined>
<v-card
v-if="loading"
class="my-4"
outlined
>
<v-card-text>
<v-skeleton-loader class="mx-auto" type="article"></v-skeleton-loader>
<v-skeleton-loader
class="mx-auto"
type="article"
/>
</v-card-text>
</v-card>
<!-- 模式切换按钮和操作按钮 -->
<div v-if="!loading" class="d-flex justify-space-between align-center mb-4">
<div
v-if="!loading"
class="d-flex justify-space-between align-center mb-4"
>
<div class="d-flex align-center gap-2">
<v-btn
:disabled="!isValidConfig"
@@ -140,19 +164,31 @@
class="text-error"
prepend-icon="mdi-delete"
@click="confirmDelete"
>
删除配置
</v-btn>
<v-btn :value="false" prepend-icon="mdi-eye"> 预览</v-btn>
<v-btn :value="true" prepend-icon="mdi-pencil"> 编辑</v-btn>
<v-btn
:value="false"
prepend-icon="mdi-eye"
>
预览
</v-btn>
<v-btn
:value="true"
prepend-icon="mdi-pencil"
>
编辑
</v-btn>
</v-btn-toggle>
</div>
<!-- 预览模式 -->
<div v-if="!loading && !isEditMode">
<div class="mb-8">
<div class="text-h3 font-weight-bold" style="line-height: 1.2">
<div
class="text-h3 font-weight-bold"
style="line-height: 1.2"
>
{{ localConfig.examName || "未设置考试名称" }}
</div>
<div
@@ -161,8 +197,14 @@
>
{{ localConfig.message || "未设置考试提示" }}
</div>
<v-chip v-if="localConfig.room" class="px-4 py-2" size="large">
<v-icon start>mdi-home</v-icon>
<v-chip
v-if="localConfig.room"
class="px-4 py-2"
size="large"
>
<v-icon start>
mdi-home
</v-icon>
考场:{{ localConfig.room }}
</v-chip>
</div>
@@ -178,20 +220,29 @@
lg="4"
md="6"
>
<v-card class="h-100" hover variant="tonal">
<v-card
class="h-100"
hover
variant="tonal"
>
<v-card-title class="bg-primary-lighten-5 pa-4">
<div class="d-flex align-center">
<v-icon class="mr-2">mdi-book-open-page-variant</v-icon>
<v-icon class="mr-2">
mdi-book-open-page-variant
</v-icon>
<span class="">{{ examInfo.name || "未设置科目" }}</span>
</div>
</v-card-title>
<v-card-text class="pa-4">
<div class="mb-3">
<div class="d-flex align-center mb-1">
<v-icon class="mr-2" color="success" size="small"
>mdi-clock-start
</v-icon
<v-icon
class="mr-2"
color="success"
size="small"
>
mdi-clock-start
</v-icon>
<span class="text-body-2 text-grey-darken-1">开始时间</span>
</div>
<div class="text-h6 font-weight-medium text-success">
@@ -200,10 +251,13 @@
</div>
<div>
<div class="d-flex align-center mb-1">
<v-icon class="mr-2" color="error" size="small"
>mdi-clock-end
</v-icon
<v-icon
class="mr-2"
color="error"
size="small"
>
mdi-clock-end
</v-icon>
<span class="text-body-2 text-grey-darken-1">结束时间</span>
</div>
<div class="text-h6 font-weight-medium text-error">
@@ -215,29 +269,50 @@
</v-col>
</v-row>
</div>
<div v-else class="text-center py-12">
<v-icon class="mb-4" color="grey-lighten-2" size="80">
<div
v-else
class="text-center py-12"
>
<v-icon
class="mb-4"
color="grey-lighten-2"
size="80"
>
mdi-calendar-blank
</v-icon>
<div class="text-h5 text-grey-darken-1 mb-2">暂无考试科目安排</div>
<div class="text-h5 text-grey-darken-1 mb-2">
暂无考试科目安排
</div>
<div class="text-body-1 text-grey mb-4">
点击上方"添加科目"按钮开始配置考试时间表
</div>
<v-btn color="primary" variant="outlined" @click="quickEdit">
<v-icon start>mdi-plus</v-icon>
<v-btn
color="primary"
variant="outlined"
@click="quickEdit"
>
<v-icon start>
mdi-plus
</v-icon>
立即添加
</v-btn>
</div>
<!-- JSON预览 -->
<v-card border class="mb-4" elevation="2">
<v-card
border
class="mb-4"
elevation="2"
>
<v-card-title
class="d-flex align-center text-white cursor-pointer"
@click="showJsonPreview = !showJsonPreview"
>
<v-icon class="mr-2">mdi-code-json</v-icon>
<v-icon class="mr-2">
mdi-code-json
</v-icon>
配置预览
<v-spacer></v-spacer>
<v-spacer />
<v-btn
color="white"
@@ -255,12 +330,17 @@
color="white"
size="small"
variant="text"
>
</v-btn>
/>
</v-card-title>
<v-expand-transition>
<v-card-text v-show="showJsonPreview" class="pa-4">
<v-card class="pa-4" variant="tonal">
<v-card-text
v-show="showJsonPreview"
class="pa-4"
>
<v-card
class="pa-4"
variant="tonal"
>
<pre class="json-preview"><code>{{ formattedStorageJson }}</code></pre>
</v-card>
</v-card-text>
@@ -271,14 +351,23 @@
<!-- 编辑模式 -->
<div v-if="!loading && isEditMode">
<!-- 基本信息 -->
<v-card border class="mb-4" elevation="1">
<v-card
border
class="mb-4"
elevation="1"
>
<v-card-title class="d-flex align-center bg-primary-lighten-5 pa-4">
<v-icon class="mr-2">mdi-information</v-icon>
<v-icon class="mr-2">
mdi-information
</v-icon>
<span class="font-weight-bold">基本信息</span>
</v-card-title>
<v-card-text class="pa-6">
<v-row>
<v-col cols="12" md="6">
<v-col
cols="12"
md="6"
>
<v-text-field
v-model="localConfig.examName"
:rules="[
@@ -293,9 +382,12 @@
prepend-inner-icon="mdi-calendar-text"
required
variant="outlined"
></v-text-field>
/>
</v-col>
<v-col cols="12" md="6">
<v-col
cols="12"
md="6"
>
<v-text-field
v-model="localConfig.room"
clearable
@@ -304,13 +396,16 @@
placeholder="如:一号考场"
prepend-inner-icon="mdi-home"
variant="outlined"
></v-text-field>
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<span class="text-subtitle-2 font-weight-bold d-block mb-2">
<v-icon size="small" class="mr-1">mdi-message-text</v-icon>
<v-icon
size="small"
class="mr-1"
>mdi-message-text</v-icon>
考试提示
</span>
<v-textarea
@@ -326,10 +421,13 @@
placeholder="例如:请保持卷面整洁,诚信应考。在听到终考铃时立刻停止作答。"
rows="3"
variant="outlined"
></v-textarea>
/>
<!-- 默认提示选项 -->
<div v-if="!localConfig.message || localConfig.message.trim() === ''" class="mt-3">
<div
v-if="!localConfig.message || localConfig.message.trim() === ''"
class="mt-3"
>
<v-chip-group
class="d-flex gap-2"
column
@@ -343,12 +441,22 @@
variant="outlined"
@click="selectDefaultTip(tip)"
>
<v-icon size="small" start>mdi-plus</v-icon>
<v-icon
size="small"
start
>
mdi-plus
</v-icon>
{{ tip.substring(0, 20) }}...
</v-chip>
</v-chip-group>
<div class="text-caption text-medium-emphasis mt-2 ml-2">
<v-icon class="mr-1" size="x-small">mdi-lightbulb-outline</v-icon>
<v-icon
class="mr-1"
size="x-small"
>
mdi-lightbulb-outline
</v-icon>
点击上方选项快速添加常用考试提示
</div>
</div>
@@ -358,11 +466,17 @@
</v-card>
<!-- 考试科目安排 -->
<v-card border class="mb-4" elevation="1">
<v-card
border
class="mb-4"
elevation="1"
>
<v-card-title class="d-flex align-center bg-success-lighten-5 pa-4">
<v-icon class="mr-2">mdi-format-list-bulleted</v-icon>
<v-icon class="mr-2">
mdi-format-list-bulleted
</v-icon>
<span class="font-weight-bold">考试科目安排</span>
<v-spacer></v-spacer>
<v-spacer />
<!-- 提醒时间开关 -->
<div class="d-flex align-center mr-4">
@@ -373,7 +487,7 @@
hide-details
@change="toggleAlertTimeMode"
>
<template v-slot:label>
<template #label>
<span class="text-body-2">自定义提醒时间</span>
</template>
</v-switch>
@@ -408,14 +522,19 @@
variant="tonal"
class="mr-3"
>
<v-icon start size="small">mdi-numeric-{{ index + 1 }}-circle</v-icon>
<v-icon
start
size="small"
>
mdi-numeric-{{ index + 1 }}-circle
</v-icon>
第 {{ index + 1 }} 科目
</v-chip>
<!-- 考试时长显示 -->
<v-spacer></v-spacer>
<v-spacer />
<div class="d-flex gap-1">
<v-btn
v-if="index > 0"
@@ -425,7 +544,12 @@
variant="text"
@click="moveExamInfo(index, -1)"
>
<v-tooltip activator="parent" location="bottom">上移</v-tooltip>
<v-tooltip
activator="parent"
location="bottom"
>
上移
</v-tooltip>
</v-btn>
<v-btn
v-if="index < localConfig.examInfos.length - 1"
@@ -435,7 +559,12 @@
variant="text"
@click="moveExamInfo(index, 1)"
>
<v-tooltip activator="parent" location="bottom">下移</v-tooltip>
<v-tooltip
activator="parent"
location="bottom"
>
下移
</v-tooltip>
</v-btn>
<v-btn
color="error"
@@ -444,13 +573,21 @@
variant="text"
@click="removeExamInfo(index)"
>
<v-tooltip activator="parent" location="bottom">删除</v-tooltip>
<v-tooltip
activator="parent"
location="bottom"
>
删除
</v-tooltip>
</v-btn>
</div>
</div>
<v-row class="align-start">
<v-col cols="12" md="4">
<v-col
cols="12"
md="4"
>
<!-- 科目名称自动完成选择器 -->
<v-autocomplete
v-model="examInfo.name"
@@ -467,20 +604,23 @@
prepend-inner-icon="mdi-book"
variant="outlined"
>
<template v-slot:prepend-item>
<template #prepend-item>
<v-list-item
v-if="customSubjectInput"
title="自定义:"
>
<template v-slot:append>
<template #append>
<span class="text-primary font-weight-bold">{{ customSubjectInput }}</span>
</template>
</v-list-item>
<v-divider v-if="customSubjectInput"></v-divider>
<v-divider v-if="customSubjectInput" />
</template>
</v-autocomplete>
</v-col>
<v-col cols="12" md="3">
<v-col
cols="12"
md="3"
>
<v-menu
v-model="examInfo.startDateMenu"
:close-on-content-click="false"
@@ -488,7 +628,7 @@
offset-y
transition="scale-transition"
>
<template v-slot:activator="{ props }">
<template #activator="{ props }">
<v-text-field
v-model="examInfo.startFormatted"
:rules="[
@@ -504,19 +644,27 @@
variant="outlined"
@blur="updateStartDateTimeFromInput(index)"
>
<template v-slot:append>
<template #append>
<v-icon>mdi-calendar-clock</v-icon>
</template>
</v-text-field>
</template>
<v-card min-width="500">
<v-card-title class="text-center py-3 bg-primary-lighten-5">
<v-icon class="mr-2" color="primary">mdi-clock-start</v-icon>
<v-icon
class="mr-2"
color="primary"
>
mdi-clock-start
</v-icon>
选择开始时间
</v-card-title>
<v-card-text class="pa-0">
<v-row no-gutters>
<v-col class="border-e" cols="6">
<v-col
class="border-e"
cols="6"
>
<v-date-picker
v-model="examInfo.startDate"
color="primary"
@@ -524,7 +672,7 @@
locale="zh-cn"
show-adjacent-months
@update:model-value="updateStartDateTime(index)"
></v-date-picker>
/>
</v-col>
<v-col cols="6">
<v-time-picker
@@ -534,12 +682,12 @@
format="24hr"
scrollable
@update:model-value="updateStartDateTime(index)"
></v-time-picker>
/>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-spacer />
<v-btn
color="grey"
variant="text"
@@ -551,7 +699,10 @@
</v-card>
</v-menu>
</v-col>
<v-col cols="12" md="3">
<v-col
cols="12"
md="3"
>
<v-menu
v-model="examInfo.endDateMenu"
:close-on-content-click="false"
@@ -559,7 +710,7 @@
offset-y
transition="scale-transition"
>
<template v-slot:activator="{ props }">
<template #activator="{ props }">
<v-text-field
v-model="examInfo.endFormatted"
:rules="[
@@ -576,19 +727,27 @@
variant="outlined"
@blur="updateEndDateTimeFromInput(index)"
>
<template v-slot:append>
<template #append>
<v-icon>mdi-calendar-clock</v-icon>
</template>
</v-text-field>
</template>
<v-card min-width="500">
<v-card-title class="text-center py-3 bg-error-lighten-5">
<v-icon class="mr-2" color="error">mdi-clock-end</v-icon>
<v-icon
class="mr-2"
color="error"
>
mdi-clock-end
</v-icon>
选择结束时间
</v-card-title>
<v-card-text class="pa-0">
<v-row no-gutters>
<v-col class="border-e" cols="6">
<v-col
class="border-e"
cols="6"
>
<v-date-picker
v-model="examInfo.endDate"
color="error"
@@ -596,7 +755,7 @@
locale="zh-cn"
show-adjacent-months
@update:model-value="updateEndDateTime(index)"
></v-date-picker>
/>
</v-col>
<v-col cols="6">
<v-time-picker
@@ -606,12 +765,12 @@
format="24hr"
scrollable
@update:model-value="updateEndDateTime(index)"
></v-time-picker>
/>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-spacer />
<v-btn
color="grey"
variant="text"
@@ -623,60 +782,76 @@
</v-card>
</v-menu>
</v-col>
<v-col cols="12" md="2">
<v-text-field
v-model="examInfo.durationMinutes"
:rules="[
(v) => !!v || '时长不能为空',
(v) => !isNaN(v) || '时长必须是数字',
(v) => parseInt(v) > 0 || '时长必须大于0',
(v) => parseInt(v) <= 1440 || '时长不能超过1440分钟(24小时)'
]"
type="number"
min="1"
max="1440"
density="comfortable"
label="时长(分钟)"
placeholder="例如:120"
prepend-inner-icon="mdi-timer"
variant="outlined"
:hint="durationHint(examInfo)"
persistent-hint
@blur="updateDurationFromInput(index)"
/>
</v-col>
<v-col
cols="12"
md="2"
>
<v-text-field
v-model="examInfo.durationMinutes"
:rules="[
(v) => !!v || '时长不能为空',
(v) => !isNaN(v) || '时长必须是数字',
(v) => parseInt(v) > 0 || '时长必须大于0',
(v) => parseInt(v) <= 1440 || '时长不能超过1440分钟(24小时)'
]"
type="number"
min="1"
max="1440"
density="comfortable"
label="时长(分钟)"
placeholder="例如:120"
prepend-inner-icon="mdi-timer"
variant="outlined"
:hint="durationHint(examInfo)"
persistent-hint
@blur="updateDurationFromInput(index)"
/>
</v-col>
<!-- 提醒时间输入框(仅在启用自定义时显示) -->
<v-col v-if="enableCustomAlertTime" cols="12" md="2">
<v-text-field
v-model="examInfo.alertTime"
:rules="[
(v) => !!v || '提醒时间不能为空',
(v) => !isNaN(v) || '提醒时间必须是数字',
(v) => parseInt(v) >= 0 || '提醒时间不能为负数',
(v) => parseInt(v) <= 120 || '提醒时间不能超过120分钟'
]"
type="number"
min="0"
max="120"
density="comfortable"
label="提醒时间(分钟)"
placeholder="例如:15"
prepend-inner-icon="mdi-bell-ring"
variant="outlined"
hint="考试结束前提醒"
persistent-hint
/>
</v-col>
<!-- 提醒时间输入框(仅在启用自定义时显示) -->
<v-col
v-if="enableCustomAlertTime"
cols="12"
md="2"
>
<v-text-field
v-model="examInfo.alertTime"
:rules="[
(v) => !!v || '提醒时间不能为空',
(v) => !isNaN(v) || '提醒时间必须是数字',
(v) => parseInt(v) >= 0 || '提醒时间不能为负数',
(v) => parseInt(v) <= 120 || '提醒时间不能超过120分钟'
]"
type="number"
min="0"
max="120"
density="comfortable"
label="提醒时间(分钟)"
placeholder="例如:15"
prepend-inner-icon="mdi-bell-ring"
variant="outlined"
hint="考试结束前提醒"
persistent-hint
/>
</v-col>
</v-row>
</div>
</v-list-item>
</v-list>
<div v-else class="text-center py-12">
<v-icon class="mb-4" color="grey-lighten-2" size="80">
<div
v-else
class="text-center py-12"
>
<v-icon
class="mb-4"
color="grey-lighten-2"
size="80"
>
mdi-calendar-blank
</v-icon>
<div class="text-h5 text-grey-darken-1 mb-2">暂无考试科目安排</div>
<div class="text-h5 text-grey-darken-1 mb-2">
暂无考试科目安排
</div>
<div class="text-body-1 text-grey mb-4">
点击上方"添加科目"按钮开始配置
</div>
@@ -695,10 +870,18 @@
</div>
<!-- 删除确认对话框 -->
<v-dialog v-model="deleteDialog" max-width="400">
<v-dialog
v-model="deleteDialog"
max-width="400"
>
<v-card>
<v-card-title class="d-flex align-center">
<v-icon class="mr-2" color="error">mdi-delete-alert</v-icon>
<v-icon
class="mr-2"
color="error"
>
mdi-delete-alert
</v-icon>
确认删除配置
</v-card-title>
<v-card-text>
@@ -706,7 +889,7 @@
<br><small class="text-grey">此操作不可撤销,将会删除所有相关数据</small>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-spacer />
<v-btn
color="grey"
variant="text"
@@ -945,8 +1128,12 @@ export default {
async loadSubjects() {
try {
const response = await dataProvider.loadData("classworks-config-subject");
if (response && Array.isArray(response)) {
this.availableSubjects = response
// 兼容返回数组或包含 value 属性的对象
const dataArray = Array.isArray(response) ? response : (response && Array.isArray(response.value) ? response.value : null);
if (response && response.success !== false && dataArray) {
this.availableSubjects = dataArray
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map(subject => ({
name: subject.name,
+35 -18
View File
@@ -7,7 +7,10 @@
elevation="4"
rounded="xl"
>
<v-btn-group class="toolbar-buttons" variant="text">
<v-btn-group
class="toolbar-buttons"
variant="text"
>
<v-btn
v-ripple
:title="'查看昨天'"
@@ -32,7 +35,10 @@
variant="text"
@click="$emit('zoom', 'up')"
/>
<v-menu :close-on-content-click="false" location="top">
<v-menu
:close-on-content-click="false"
location="top"
>
<template #activator="{ props }">
<v-btn
v-ripple
@@ -43,7 +49,10 @@
variant="text"
/>
</template>
<v-card border class="date-picker-card">
<v-card
border
class="date-picker-card"
>
<v-date-picker
:model-value="selectedDate"
color="primary"
@@ -52,6 +61,7 @@
</v-card>
</v-menu>
<v-btn
:key="loading"
v-ripple
:loading="loading"
:title="'刷新数据'"
@@ -76,19 +86,24 @@
<!-- Side Action Button -->
<v-slide-x-reverse-transition>
<v-btn
<div
v-if="!isToday"
:loading="copyToTodayLoading"
:disabled="copyToTodayLoading"
class="side-action-btn"
color="primary"
elevation="4"
prepend-icon="mdi-content-copy"
rounded="xl"
size="large"
text="复制作业内容到今天"
@click="$emit('copy-to-today')"
>复制到今天</v-btn>
class="side-action-container"
>
<v-btn
:loading="copyToTodayLoading"
:disabled="copyToTodayLoading"
class="side-action-btn"
color="primary"
elevation="4"
prepend-icon="mdi-content-copy"
rounded="xl"
size="large"
@click="$emit('copy-to-today')"
>
复制到今天
</v-btn>
</div>
</v-slide-x-reverse-transition>
</div>
</template>
@@ -138,9 +153,8 @@ export default {
left: 0;
width: 100%;
height: 0;
overflow: visible;
z-index: 100;
display: flex;
justify-content: center;
pointer-events: none;
}
@@ -191,12 +205,15 @@ export default {
transform: scale(0.95);
}
.side-action-btn {
.side-action-container {
position: absolute;
bottom: 24px;
right: 24px;
pointer-events: auto;
z-index: 101;
}
.side-action-btn {
background: rgba(255, 255, 255, 0.9) !important;
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.2);
+18 -4
View File
@@ -8,14 +8,28 @@
variant="tonal"
>
<div class="d-flex align-center">
<v-icon :icon="icons[message?.type] || icons.info" class="mr-2"/>
<v-icon
:icon="icons[message?.type] || icons.info"
class="mr-2"
/>
<div>
<div class="text-subtitle-2 font-weight-medium">{{ message?.title }}</div>
<div v-if="message?.content" class="text-body-2">{{ message?.content }}</div>
<div class="text-subtitle-2 font-weight-medium">
{{ message?.title }}
</div>
<div
v-if="message?.content"
class="text-body-2"
>
{{ message?.content }}
</div>
</div>
</div>
<template #actions>
<v-btn icon="mdi-close" variant="text" @click="snackbar = false"/>
<v-btn
icon="mdi-close"
variant="text"
@click="snackbar = false"
/>
</template>
</v-snackbar>
</template>
-164
View File
@@ -1,164 +0,0 @@
<template>
<v-container class="fill-height">
<v-responsive
class="align-centerfill-height mx-auto"
max-width="900"
>
<v-img
class="mb-4"
height="150"
src="@/assets/logo.png"
/>
<div class="text-center">
<div class="text-body-2 font-weight-light mb-n1">Welcome to</div>
<h1 class="text-h2 font-weight-bold">Vuetify</h1>
</div>
<div class="py-4"/>
<v-row>
<v-col cols="12">
<v-card
class="py-4"
color="surface-variant"
image="https://cdn.vuetifyjs.com/docs/images/one/create/feature.png"
prepend-icon="mdi-rocket-launch-outline"
rounded="lg"
variant="outlined"
>
<template #image>
<v-img position="top right"/>
</template>
<template #title>
<h2 class="text-h5 font-weight-bold">Get started</h2>
</template>
<template #subtitle>
<div class="text-subtitle-1">
Replace this page by removing
<v-kbd>{{ `
<HelloWorld/>
` }}
</v-kbd>
in
<v-kbd>pages/index.vue</v-kbd>
.
</div>
</template>
<v-overlay
contained
model-value
opacity=".12"
persistent
scrim="primary"
/>
</v-card>
</v-col>
<v-col cols="6">
<v-card
append-icon="mdi-open-in-new"
class="py-4"
color="surface-variant"
href="https://vuetifyjs.com/"
prepend-icon="mdi-text-box-outline"
rel="noopener noreferrer"
rounded="lg"
subtitle="Learn about all things Vuetify in our documentation."
target="_blank"
title="Documentation"
variant="text"
>
<v-overlay
contained
model-value
opacity=".06"
persistent
scrim="primary"
/>
</v-card>
</v-col>
<v-col cols="6">
<v-card
append-icon="mdi-open-in-new"
class="py-4"
color="surface-variant"
href="https://vuetifyjs.com/introduction/why-vuetify/#feature-guides"
prepend-icon="mdi-star-circle-outline"
rel="noopener noreferrer"
rounded="lg"
subtitle="Explore available framework Features."
target="_blank"
title="Features"
variant="text"
>
<v-overlay
contained
model-value
opacity=".06"
persistent
scrim="primary"
/>
</v-card>
</v-col>
<v-col cols="6">
<v-card
append-icon="mdi-open-in-new"
class="py-4"
color="surface-variant"
href="https://vuetifyjs.com/components/all"
prepend-icon="mdi-widgets-outline"
rel="noopener noreferrer"
rounded="lg"
subtitle="Discover components in the API Explorer."
target="_blank"
title="Components"
variant="text"
>
<v-overlay
contained
model-value
opacity=".06"
persistent
scrim="primary"
/>
</v-card>
</v-col>
<v-col cols="6">
<v-card
append-icon="mdi-open-in-new"
class="py-4"
color="surface-variant"
href="https://discord.vuetifyjs.com"
prepend-icon="mdi-account-group-outline"
rel="noopener noreferrer"
rounded="lg"
subtitle="Connect with Vuetify developers."
target="_blank"
title="Community"
variant="text"
>
<v-overlay
contained
model-value
opacity=".06"
persistent
scrim="primary"
/>
</v-card>
</v-col>
</v-row>
</v-responsive>
</v-container>
</template>
<script setup>
//
</script>
+2 -2
View File
@@ -69,7 +69,7 @@ export default {
loading: false,
timer: null,
unwatch: null,
fontSize: 28 // Will be updated in mounted()
fontSize: 28
}
},
computed: {
@@ -111,7 +111,7 @@ export default {
loadLocalSettings() {
this.enabled = SettingsManager.getSetting('hitokoto.enabled')
this.refreshInterval = SettingsManager.getSetting('hitokoto.refreshInterval')
this.fontSize = SettingsManager.getSetting('font.size')
this.fontSize = 28
},
async loadKvSettings() {
try {
+180 -56
View File
@@ -1,15 +1,26 @@
<template>
<div>
<setting-group title="一言设置" icon="mdi-comment-quote">
<setting-group
title="一言设置"
icon="mdi-comment-quote"
>
<setting-item setting-key="hitokoto.enabled" />
<setting-item setting-key="hitokoto.refreshInterval" />
</setting-group>
<setting-group title="数据源配置" icon="mdi-cloud-sync" class="mt-4">
<div class="text-caption text-grey px-4 pt-2 pb-2">以下配置将同步到云端,对所有连接此班级的设备生效。</div>
<setting-group
title="数据源配置"
icon="mdi-cloud-sync"
class="mt-4"
>
<div class="text-caption text-grey px-4 pt-2 pb-2">
以下配置将同步到云端,对所有连接此班级的设备生效。
</div>
<v-list-item>
<v-list-item-title class="mb-2">启用数据源</v-list-item-title>
<v-list-item-title class="mb-2">
启用数据源
</v-list-item-title>
<div class="d-flex flex-wrap gap-2">
<v-checkbox
v-model="kvConfig.sources"
@@ -42,7 +53,13 @@
/>
</div>
<div class="text-caption text-orange mt-2">
<v-icon size="x-small" color="orange" class="mr-1">mdi-alert</v-icon>
<v-icon
size="x-small"
color="orange"
class="mr-1"
>
mdi-alert
</v-icon>
一言(Hitokoto)数据源已收到关于存在负面内容的大量反馈,请用户谨慎启用。
</div>
</v-list-item>
@@ -99,39 +116,62 @@
</v-list-item>
<v-list-item>
<v-checkbox
v-model="enableCloudSensitiveWords"
label="启用云端敏感词列表"
hide-details
v-model="enableCloudSensitiveWords"
density="compact"
disabled
class="mb-2"
/>
<div class="text-caption text-grey">
已启用的数据源将在获取一言时随机尝试,直到成功获取内容为止。<br/>
敏感词过滤会将包含任意敏感词的句子过滤掉,避免显示不当内容。<br/>
已启用的数据源将在获取一言时随机尝试,直到成功获取内容为止。<br>
敏感词过滤会将包含任意敏感词的句子过滤掉,避免显示不当内容。<br>
</div>
</v-list-item>
</v-list-item>
<div v-if="loading" class="text-center pb-4">
<v-progress-circular indeterminate size="24" color="primary" />
<div
v-if="loading"
class="text-center pb-4"
>
<v-progress-circular
indeterminate
size="24"
color="primary"
/>
<span class="ml-2 text-caption">正在同步配置...</span>
</div>
</setting-group>
<v-dialog v-model="testResultDialog" max-width="600">
<v-card v-if="testResultData" class="rounded-lg">
<v-dialog
v-model="testResultDialog"
max-width="600"
>
<v-card
v-if="testResultData"
class="rounded-lg"
>
<v-card-text class="pa-0">
<v-list lines="two" class="py-0">
<v-list
lines="two"
class="py-0"
>
<v-list-item class="px-4 py-3">
<template v-slot:prepend>
<v-avatar color="primary" variant="tonal" class="mr-2">
<template #prepend>
<v-avatar
color="primary"
variant="tonal"
class="mr-2"
>
<v-icon icon="mdi-key-variant" />
</v-avatar>
</template>
<v-list-item-title class="text-subtitle-2 font-weight-bold mb-1">Token</v-list-item-title>
<v-list-item-subtitle class="text-body-2 text-high-emphasis" style="word-break: break-all;">
<v-list-item-title class="text-subtitle-2 font-weight-bold mb-1">
Token
</v-list-item-title>
<v-list-item-subtitle
class="text-body-2 text-high-emphasis"
style="word-break: break-all;"
>
{{ testResultData.data.token }}
</v-list-item-subtitle>
</v-list-item>
@@ -141,20 +181,36 @@
<v-row no-gutters>
<v-col cols="6">
<v-list-item class="px-4 py-2">
<template v-slot:prepend>
<v-icon icon="mdi-ip-network" color="grey-darken-1" class="mr-2" />
<template #prepend>
<v-icon
icon="mdi-ip-network"
color="grey-darken-1"
class="mr-2"
/>
</template>
<v-list-item-title class="text-caption text-grey-darken-1">IP 地址</v-list-item-title>
<v-list-item-subtitle class="text-body-2">{{ testResultData.data.ip }}</v-list-item-subtitle>
<v-list-item-title class="text-caption text-grey-darken-1">
IP 地址
</v-list-item-title>
<v-list-item-subtitle class="text-body-2">
{{ testResultData.data.ip }}
</v-list-item-subtitle>
</v-list-item>
</v-col>
<v-col cols="6">
<v-list-item class="px-4 py-2">
<template v-slot:prepend>
<v-icon icon="mdi-map-marker-radius" color="grey-darken-1" class="mr-2" />
<template #prepend>
<v-icon
icon="mdi-map-marker-radius"
color="grey-darken-1"
class="mr-2"
/>
</template>
<v-list-item-title class="text-caption text-grey-darken-1">地区</v-list-item-title>
<v-list-item-subtitle class="text-body-2">{{ testResultData.data.region }}</v-list-item-subtitle>
<v-list-item-title class="text-caption text-grey-darken-1">
地区
</v-list-item-title>
<v-list-item-subtitle class="text-body-2">
{{ testResultData.data.region }}
</v-list-item-subtitle>
</v-list-item>
</v-col>
</v-row>
@@ -165,59 +221,123 @@
<v-container class="px-4 py-3">
<v-row dense>
<v-col cols="6" sm="4">
<v-col
cols="6"
sm="4"
>
<div class="d-flex align-center mb-2">
<v-icon icon="mdi-thermometer" color="orange" class="mr-2" />
<v-icon
icon="mdi-thermometer"
color="orange"
class="mr-2"
/>
<div>
<div class="text-caption text-grey">温度</div>
<div class="text-body-1 font-weight-medium">{{ testResultData.data.weatherData.temperature }}°C</div>
<div class="text-caption text-grey">
温度
</div>
<div class="text-body-1 font-weight-medium">
{{ testResultData.data.weatherData.temperature }}°C
</div>
</div>
</div>
</v-col>
<v-col cols="6" sm="4">
<v-col
cols="6"
sm="4"
>
<div class="d-flex align-center mb-2">
<v-icon icon="mdi-weather-cloudy" color="blue-grey" class="mr-2" />
<v-icon
icon="mdi-weather-cloudy"
color="blue-grey"
class="mr-2"
/>
<div>
<div class="text-caption text-grey">天气</div>
<div class="text-body-1 font-weight-medium">{{ testResultData.data.weatherData.weather }}</div>
<div class="text-caption text-grey">
天气
</div>
<div class="text-body-1 font-weight-medium">
{{ testResultData.data.weatherData.weather }}
</div>
</div>
</div>
</v-col>
<v-col cols="6" sm="4">
<v-col
cols="6"
sm="4"
>
<div class="d-flex align-center mb-2">
<v-icon icon="mdi-water-percent" color="blue" class="mr-2" />
<v-icon
icon="mdi-water-percent"
color="blue"
class="mr-2"
/>
<div>
<div class="text-caption text-grey">湿度</div>
<div class="text-body-1 font-weight-medium">{{ testResultData.data.weatherData.humidity }}%</div>
<div class="text-caption text-grey">
湿度
</div>
<div class="text-body-1 font-weight-medium">
{{ testResultData.data.weatherData.humidity }}%
</div>
</div>
</div>
</v-col>
<v-col cols="6" sm="4">
<v-col
cols="6"
sm="4"
>
<div class="d-flex align-center mb-2">
<v-icon icon="mdi-weather-windy" color="teal" class="mr-2" />
<v-icon
icon="mdi-weather-windy"
color="teal"
class="mr-2"
/>
<div>
<div class="text-caption text-grey">风向/风力</div>
<div class="text-caption text-grey">
风向/风力
</div>
<div class="text-body-2 font-weight-medium">
{{ testResultData.data.weatherData.windDirection }} {{ testResultData.data.weatherData.windPower }}级
</div>
</div>
</div>
</v-col>
<v-col cols="6" sm="4">
<v-col
cols="6"
sm="4"
>
<div class="d-flex align-center mb-2">
<v-icon icon="mdi-blur" color="grey" class="mr-2" />
<v-icon
icon="mdi-blur"
color="grey"
class="mr-2"
/>
<div>
<div class="text-caption text-grey">PM2.5</div>
<div class="text-body-1 font-weight-medium">{{ testResultData.data.weatherData.pm25 }}</div>
<div class="text-caption text-grey">
PM2.5
</div>
<div class="text-body-1 font-weight-medium">
{{ testResultData.data.weatherData.pm25 }}
</div>
</div>
</div>
</v-col>
<v-col cols="6" sm="4">
<v-col
cols="6"
sm="4"
>
<div class="d-flex align-center mb-2">
<v-icon icon="mdi-eye" color="indigo" class="mr-2" />
<v-icon
icon="mdi-eye"
color="indigo"
class="mr-2"
/>
<div>
<div class="text-caption text-grey">能见度</div>
<div class="text-body-1 font-weight-medium">{{ testResultData.data.weatherData.visibility }}</div>
<div class="text-caption text-grey">
能见度
</div>
<div class="text-body-1 font-weight-medium">
{{ testResultData.data.weatherData.visibility }}
</div>
</div>
</div>
</v-col>
@@ -227,7 +347,9 @@
<v-divider />
<div class="px-4 py-3">
<div class="text-caption text-grey mb-2">环境标签</div>
<div class="text-caption text-grey mb-2">
环境标签
</div>
<div class="d-flex flex-wrap gap-2">
<v-chip
v-for="tag in testResultData.data.tags"
@@ -245,8 +367,12 @@
<v-divider />
<v-list-item class="px-4 py-2">
<template v-slot:prepend>
<v-icon icon="mdi-clock-outline" size="small" class="mr-2" />
<template #prepend>
<v-icon
icon="mdi-clock-outline"
size="small"
class="mr-2"
/>
</template>
<v-list-item-title class="text-caption text-grey-darken-1">
北京时间: {{ new Date(testResultData.data.beijingTime).toLocaleString() }}
@@ -254,8 +380,6 @@
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</v-dialog>
</div>
File diff suppressed because it is too large Load Diff
+27 -10
View File
@@ -1,33 +1,50 @@
<template>
<v-navigation-drawer v-if="drawer" v-model="drawer" location="right" temporary width="400">
<v-navigation-drawer
v-if="drawer"
v-model="drawer"
location="right"
temporary
width="400"
>
<v-toolbar color="primary">
<v-toolbar-title>消息记录</v-toolbar-title>
</v-toolbar>
<v-list>
<v-list-item v-for="msg in messages" :key="msg.id" rounded>
<v-list-item
v-for="msg in messages"
:key="msg.id"
rounded
>
<template #prepend>
<v-icon :color="colors[msg.type]" :icon="icons[msg.type]" size="20"/>
<v-icon
:color="colors[msg.type]"
:icon="icons[msg.type]"
size="20"
/>
</template>
<v-list-item-title>{{ msg.title }}</v-list-item-title>
<v-list-item-subtitle v-if="msg.content">{{
msg.content
<v-list-item-subtitle v-if="msg.content">
{{
msg.content
}}
</v-list-item-subtitle>
<span class="text-caption text-grey">
{{ new Date(msg.timestamp).toLocaleTimeString() }}
</span>
</v-list-item>
<v-list-item v-if="!messages.length">
<template #prepend>
<v-icon color="grey" icon="mdi-inbox"/>
<v-icon
color="grey"
icon="mdi-inbox"
/>
</template>
<v-list-item-title class="text-grey">暂无消息</v-list-item-title>
<v-list-item-title class="text-grey">
暂无消息
</v-list-item-title>
</v-list-item>
</v-list>
</v-navigation-drawer>
+38 -11
View File
@@ -16,7 +16,10 @@
</v-card-subtitle>
<v-card-text class="pt-0 pb-1">
<v-list density="comfortable" lines="two">
<v-list
density="comfortable"
lines="two"
>
<v-list-item
v-for="item in chipList"
:key="item.key"
@@ -24,8 +27,12 @@
@click="() => handleSingleRequest(item.key)"
>
<template #prepend>
<v-avatar :color="chipColors[item.status]" size="32" variant="tonal">
<v-icon :icon="statusIcons[item.status]"></v-icon>
<v-avatar
:color="chipColors[item.status]"
size="32"
variant="tonal"
>
<v-icon :icon="statusIcons[item.status]" />
</v-avatar>
</template>
@@ -33,7 +40,12 @@
<v-list-item-subtitle>{{ item.description }}</v-list-item-subtitle>
<template #append>
<v-chip :color="chipColors[item.status]" size="small" variant="tonal" class="me-2">
<v-chip
:color="chipColors[item.status]"
size="small"
variant="tonal"
class="me-2"
>
{{ statusText[item.status] }}
</v-chip>
<v-btn
@@ -42,7 +54,7 @@
size="small"
:disabled="isRequesting"
@click.stop="() => openHelp(item.key)"
></v-btn>
/>
</template>
</v-list-item>
</v-list>
@@ -76,15 +88,25 @@
size="100"
rounded="0"
>
<v-icon icon="mdi-monitor-cellphone" size="80"></v-icon>
<v-icon
icon="mdi-monitor-cellphone"
size="80"
/>
</v-avatar>
</div>
<v-dialog v-model="helpDialog" max-width="520">
<v-dialog
v-model="helpDialog"
max-width="520"
>
<v-card>
<v-card-title class="text-h6">{{ helpContent.title }}</v-card-title>
<v-card-title class="text-h6">
{{ helpContent.title }}
</v-card-title>
<v-card-text>
<p class="mb-3">{{ helpContent.message }}</p>
<p class="mb-3">
{{ helpContent.message }}
</p>
<v-list density="comfortable">
<v-list-item
v-for="(link, index) in helpContent.links"
@@ -99,8 +121,13 @@
</v-list>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn variant="text" @click="helpDialog = false">我知道了</v-btn>
<v-spacer />
<v-btn
variant="text"
@click="helpDialog = false"
>
我知道了
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
+69 -18
View File
@@ -5,16 +5,32 @@
max-width="600"
persistent
>
<v-card border class="random-picker-card" rounded="xl">
<v-card
border
class="random-picker-card"
rounded="xl"
>
<v-card-title class="text-h5 d-flex align-center">
<v-icon class="mr-2" icon="mdi-account-question"/>
<v-icon
class="mr-2"
icon="mdi-account-question"
/>
随机点名
<v-spacer/>
<v-btn icon="mdi-close" variant="text" @click="dialog = false"/>
<v-spacer />
<v-btn
icon="mdi-close"
variant="text"
@click="dialog = false"
/>
</v-card-title>
<v-card-text v-if="!isPickingStarted" class="text-center py-6">
<div class="text-h6 mb-4">请选择抽取人数</div>
<v-card-text
v-if="!isPickingStarted"
class="text-center py-6"
>
<div class="text-h6 mb-4">
请选择抽取人数
</div>
<div class="d-flex justify-center align-center counter-container">
<v-btn
@@ -52,14 +68,29 @@
mandatory
rounded="pill"
>
<v-btn prepend-icon="mdi-account" value="name">姓名模式</v-btn>
<v-btn prepend-icon="mdi-numeric" value="number">学号模式</v-btn>
<v-btn
prepend-icon="mdi-account"
value="name"
>
姓名模式
</v-btn>
<v-btn
prepend-icon="mdi-numeric"
value="number"
>
学号模式
</v-btn>
</v-btn-toggle>
</div>
<!-- 学号范围设置 -->
<div v-if="pickerMode === 'number'" class="number-range-container mt-4">
<div class="text-subtitle-1 mb-2">学号范围设置</div>
<div
v-if="pickerMode === 'number'"
class="number-range-container mt-4"
>
<div class="text-subtitle-1 mb-2">
学号范围设置
</div>
<div class="d-flex justify-center align-center gap-4">
<v-text-field
v-model.number="minNumber"
@@ -98,7 +129,10 @@
</v-btn>
</div>
<div v-if="filteredStudents.length === 0" class="mt-4 text-error">
<div
v-if="filteredStudents.length === 0"
class="mt-4 text-error"
>
<template v-if="pickerMode === 'name'">
没有可抽取的学生,请调整过滤选项
</template>
@@ -109,8 +143,11 @@
<div class="mt-4 text-caption">
当前可抽取学生: {{ filteredStudents.length }}人
<v-tooltip v-if="pickerMode === 'name'" location="bottom">
<template v-slot:activator="{ props }">
<v-tooltip
v-if="pickerMode === 'name'"
location="bottom"
>
<template #activator="{ props }">
<v-icon
class="ml-1"
icon="mdi-information-outline"
@@ -132,7 +169,10 @@
</v-tooltip>
<!-- 添加临时过滤选项 -->
<div v-if="pickerMode === 'name'" class="d-flex flex-wrap justify-center gap-2 mt-4">
<div
v-if="pickerMode === 'name'"
class="d-flex flex-wrap justify-center gap-2 mt-4"
>
<v-chip
:color="tempFilters.excludeLate ? 'warning' : 'default'"
:variant="tempFilters.excludeLate ? 'elevated' : 'text'"
@@ -165,8 +205,14 @@
</div>
</v-card-text>
<v-card-text v-else class="text-center py-6">
<div v-if="isAnimating" class="animation-container">
<v-card-text
v-else
class="text-center py-6"
>
<div
v-if="isAnimating"
class="animation-container"
>
<div class="animation-wrapper">
<transition-group
class="shuffle-container"
@@ -185,8 +231,13 @@
</div>
</div>
<div v-else class="result-container">
<div class="text-h6 mb-4">抽取结果</div>
<div
v-else
class="result-container"
>
<div class="text-h6 mb-4">
抽取结果
</div>
<v-card
v-for="(student, index) in pickedStudents"
:key="index"
+32 -10
View File
@@ -1,22 +1,39 @@
<template>
<v-dialog v-model="isVisible" max-width="500" persistent>
<v-dialog
v-model="isVisible"
max-width="500"
persistent
>
<v-card class="rate-limit-modal">
<v-card-title class="text-center pa-4 bg-error text-white">
<v-icon class="mr-2" icon="mdi-clock-alert-outline" size="large"/>
<v-icon
class="mr-2"
icon="mdi-clock-alert-outline"
size="large"
/>
请求频率超限
</v-card-title>
<v-card-text class="pa-6">
<div class="text-body-1 mb-4">您的请求过于频繁,请稍后再试。</div>
<div class="text-body-1 mb-4">
您的请求过于频繁,请稍后再试。
</div>
<v-card v-if="activeRequests.length > 0" class="mb-4" flat>
<v-card
v-if="activeRequests.length > 0"
class="mb-4"
flat
>
<v-card-text>
<v-list
v-for="(request, index) in activeRequests"
:key="index"
class="mb-4"
>
<v-list-item color="primary" prepend-icon="mdi-web">
<v-list-item
color="primary"
prepend-icon="mdi-web"
>
<v-list-item-title>
等待时间:
<span class="text-primary font-weight-bold">{{
@@ -27,12 +44,11 @@
{{ request.method }} {{ request.path }}
</v-list-item-subtitle>
</v-list-item>
</v-list
>
</v-list>
<v-divider
v-if="index < activeRequests.length - 1"
class="my-3"
></v-divider>
/>
</v-card-text>
</v-card>
@@ -42,8 +58,14 @@
</v-card-text>
<v-card-actions class="pa-4 pt-0">
<v-spacer></v-spacer>
<v-btn color="primary" variant="tonal" @click="close"> 我知道了</v-btn>
<v-spacer />
<v-btn
color="primary"
variant="tonal"
@click="close"
>
我知道了
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
+1 -1
View File
@@ -9,7 +9,7 @@
@click:close="dismissed = true"
>
<template #prepend>
<v-icon icon="mdi-lock-alert"/>
<v-icon icon="mdi-lock-alert" />
</template>
<v-alert-title>当前使用只读 Token</v-alert-title>
<div class="text-body-2">
+13 -5
View File
@@ -1,5 +1,8 @@
<template>
<v-card class="settings-card rounded-lg" elevation="2">
<v-card
class="settings-card rounded-lg"
elevation="2"
>
<v-card-item>
<template #prepend>
<v-icon
@@ -8,7 +11,9 @@
size="large"
/>
</template>
<v-card-title class="text-h6">{{ title }}</v-card-title>
<v-card-title class="text-h6">
{{ title }}
</v-card-title>
</v-card-item>
<v-card-text>
@@ -18,11 +23,14 @@
color="primary"
indeterminate
/>
<slot/>
<slot />
</v-card-text>
<v-card-actions v-if="$slots.actions" class="pa-4">
<slot name="actions"/>
<v-card-actions
v-if="$slots.actions"
class="pa-4"
>
<slot name="actions" />
</v-card-actions>
</v-card>
</template>
+160 -69
View File
@@ -1,9 +1,16 @@
<template>
<div>
<!-- 统一链接生成器卡片 -->
<v-card border class="unified-link-generator">
<v-card
border
class="unified-link-generator"
>
<v-card-title class="text-h6">
<v-icon class="mr-2" icon="mdi-link-variant" start/>
<v-icon
class="mr-2"
icon="mdi-link-variant"
start
/>
统一链接生成器
</v-card-title>
@@ -13,15 +20,23 @@
</div>
<!-- 预配置认证信息部分 -->
<v-card class="mb-4" variant="tonal">
<v-card
class="mb-4"
variant="tonal"
>
<v-card-title class="text-subtitle-1">
<v-icon start>mdi-account-key</v-icon>
<v-icon start>
mdi-account-key
</v-icon>
预配置认证信息
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<v-col
cols="12"
md="6"
>
<v-text-field
v-model="preconfigForm.namespace"
hint="设备的命名空间标识符"
@@ -32,7 +47,10 @@
variant="outlined"
/>
</v-col>
<v-col cols="12" md="6">
<v-col
cols="12"
md="6"
>
<v-text-field
v-model="preconfigForm.authCode"
hint="留空则需要用户手动输入"
@@ -64,9 +82,19 @@
type="info"
variant="tonal"
>
<div class="text-subtitle-2 mb-2">预配置信息:</div>
<v-chip class="mr-2 mb-1" size="small">
<v-icon size="small" start>mdi-identifier</v-icon>
<div class="text-subtitle-2 mb-2">
预配置信息:
</div>
<v-chip
class="mr-2 mb-1"
size="small"
>
<v-icon
size="small"
start
>
mdi-identifier
</v-icon>
命名空间: {{ preconfigForm.namespace }}
</v-chip>
<v-chip
@@ -75,12 +103,27 @@
color="warning"
size="small"
>
<v-icon size="small" start>mdi-lock</v-icon>
<v-icon
size="small"
start
>
mdi-lock
</v-icon>
认证码: {{ preconfigForm.authCode.length > 8 ? preconfigForm.authCode.substring(0, 8) + "..." :
preconfigForm.authCode }}
preconfigForm.authCode }}
</v-chip>
<v-chip v-else class="mr-2 mb-1" color="grey" size="small">
<v-icon size="small" start>mdi-lock-open</v-icon>
<v-chip
v-else
class="mr-2 mb-1"
color="grey"
size="small"
>
<v-icon
size="small"
start
>
mdi-lock-open
</v-icon>
无认证码
</v-chip>
<v-chip
@@ -88,8 +131,12 @@
class="mr-2 mb-1"
size="small"
>
<v-icon size="small" start>{{
preconfigForm.autoExecute ? "mdi-play-circle" : "mdi-hand-back-left"
<v-icon
size="small"
start
>
{{
preconfigForm.autoExecute ? "mdi-play-circle" : "mdi-hand-back-left"
}}
</v-icon>
{{ preconfigForm.autoExecute ? "自动认证" : "手动认证" }}
@@ -99,9 +146,14 @@
</v-card>
<!-- 设置分享部分 -->
<v-card class="mb-4" variant="tonal">
<v-card
class="mb-4"
variant="tonal"
>
<v-card-title class="text-subtitle-1">
<v-icon start>mdi-cog-transfer</v-icon>
<v-icon start>
mdi-cog-transfer
</v-icon>
设置分享(可选)
</v-card-title>
@@ -152,7 +204,10 @@
<!-- 选择摘要 -->
<div class="d-flex align-center mb-3 flex-wrap gap-2">
<v-chip class="mr-2" color="primary">
<v-chip
class="mr-2"
color="primary"
>
已选 {{ selectedItems.length }} 项设置
</v-chip>
@@ -183,7 +238,9 @@
<v-expansion-panel-title>
<template #default="{ expanded }">
<div class="d-flex align-center">
<v-icon class="mr-2">{{ expanded ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
<v-icon class="mr-2">
{{ expanded ? 'mdi-chevron-up' : 'mdi-chevron-down' }}
</v-icon>
显示设置列表详情
</div>
</template>
@@ -261,9 +318,14 @@
</v-card>
<!-- 链接生成和操作部分 -->
<v-card class="mb-4" variant="outlined">
<v-card
class="mb-4"
variant="outlined"
>
<v-card-title class="text-subtitle-1">
<v-icon start>mdi-link</v-icon>
<v-icon start>
mdi-link
</v-icon>
生成的统一链接
</v-card-title>
@@ -317,10 +379,20 @@
type="success"
variant="tonal"
>
<div class="text-subtitle-2 mb-2">链接包含内容:</div>
<div class="text-subtitle-2 mb-2">
链接包含内容:
</div>
<div class="d-flex flex-wrap gap-1">
<v-chip color="primary" size="small">
<v-icon size="small" start>mdi-account-key</v-icon>
<v-chip
color="primary"
size="small"
>
<v-icon
size="small"
start
>
mdi-account-key
</v-icon>
预配置认证
</v-chip>
<v-chip
@@ -328,11 +400,25 @@
color="secondary"
size="small"
>
<v-icon size="small" start>mdi-cog</v-icon>
<v-icon
size="small"
start
>
mdi-cog
</v-icon>
{{ selectedItems.length }} 项设置
</v-chip>
<v-chip v-else color="grey" size="small">
<v-icon size="small" start>mdi-cog-off</v-icon>
<v-chip
v-else
color="grey"
size="small"
>
<v-icon
size="small"
start
>
mdi-cog-off
</v-icon>
无额外设置
</v-chip>
</div>
@@ -341,8 +427,13 @@
</v-card>
<!-- 安全提醒 -->
<v-alert type="warning" variant="tonal">
<div class="text-subtitle-2 mb-2">⚠️ 安全提醒</div>
<v-alert
type="warning"
variant="tonal"
>
<div class="text-subtitle-2 mb-2">
⚠️ 安全提醒
</div>
<ul class="text-body-2 pl-4">
<li>认证码和设置信息会在URL中传输,请谨慎分发</li>
<li>建议仅在受信任的网络环境中使用</li>
@@ -509,6 +600,45 @@ export default {
},
},
watch: {
// 监听选择变化,自动生成统一链接
selectedItems: {
handler() {
if (this.preconfigForm.namespace.trim()) {
this.generateUnifiedLink();
}
},
deep: true,
},
// 监听预配置表单变化,自动生成统一链接
"preconfigForm.namespace": {
handler() {
if (this.preconfigForm.namespace.trim()) {
this.generateUnifiedLink();
} else {
this.unifiedLink = "";
}
},
},
"preconfigForm.authCode": {
handler() {
if (this.preconfigForm.namespace.trim()) {
this.generateUnifiedLink();
}
},
},
"preconfigForm.autoExecute": {
handler() {
if (this.preconfigForm.namespace.trim()) {
this.generateUnifiedLink();
}
},
},
},
methods: {
/**
* 处理表格选择变化
@@ -709,7 +839,7 @@ export default {
// 转换为JSON并进行base64编码
const jsonString = JSON.stringify(configObj);
const utf8Encoder = new TextEncoder();
const utf8Encoder = new window.TextEncoder();
const utf8Bytes = utf8Encoder.encode(jsonString);
const base64String = btoa(
Array.from(utf8Bytes)
@@ -782,44 +912,5 @@ export default {
this.linkCopied = false;
},
},
watch: {
// 监听选择变化,自动生成统一链接
selectedItems: {
handler() {
if (this.preconfigForm.namespace.trim()) {
this.generateUnifiedLink();
}
},
deep: true,
},
// 监听预配置表单变化,自动生成统一链接
"preconfigForm.namespace": {
handler() {
if (this.preconfigForm.namespace.trim()) {
this.generateUnifiedLink();
} else {
this.unifiedLink = "";
}
},
},
"preconfigForm.authCode": {
handler() {
if (this.preconfigForm.namespace.trim()) {
this.generateUnifiedLink();
}
},
},
"preconfigForm.autoExecute": {
handler() {
if (this.preconfigForm.namespace.trim()) {
this.generateUnifiedLink();
}
},
},
},
};
</script>
-2
View File
@@ -35,7 +35,6 @@
<!-- 教师模式 -->
<template v-else-if="isTeacherToken">
<!-- 名称输入框 -->
<v-text-field
v-model="teacherForm.name"
@@ -48,7 +47,6 @@
class="mt-2 mb-4"
>
<div class="d-flex flex-wrap gap-2">
<v-chip
v-for="teacher in filteredTeacherSuggestions"
-13
View File
@@ -33,8 +33,6 @@
</template>
<script>
import { SettingsManager, watchSettings } from '@/utils/settings'
// 时间字体大小比例(大屏场景)
const TIME_FONT_RATIO = 2.0
const SECONDS_FONT_RATIO = 0.9
@@ -46,7 +44,6 @@ export default {
return {
now: new Date(),
timer: null,
unwatch: null,
fontSize: 28,
}
},
@@ -106,22 +103,12 @@ export default {
},
},
mounted() {
this.loadSettings()
this.startTimer()
this.unwatch = watchSettings(() => {
this.loadSettings()
})
},
beforeUnmount() {
this.stopTimer()
if (this.unwatch) {
this.unwatch()
}
},
methods: {
loadSettings() {
this.fontSize = SettingsManager.getSetting('font.size')
},
startTimer() {
// 每秒更新一次
this.timer = setInterval(() => {
+32 -20
View File
@@ -15,18 +15,28 @@
<div class="urgent-title mb-6">
{{ currentNotification?.content?.message || "无内容" }}
</div>
<div class="urgent-subtitle mb-6">
<div class="urgent-subtitle mb-6">
{{ senderName }} {{ deviceType }} {{ formatTime(currentNotification?.timestamp) }}
</div>
<!-- 多通知导航 -->
<div v-if="hasMultipleNotifications" class="navigation-controls mt-6">
<v-card variant="flat" color="rgba(255,255,255,0.1)">
<div
v-if="hasMultipleNotifications"
class="navigation-controls mt-6"
>
<v-card
variant="flat"
color="rgba(255,255,255,0.1)"
>
<v-card-text class="text-center">
<div class="notification-counter mb-3">
<v-chip color="white" variant="flat" size="small">
<v-chip
color="white"
variant="flat"
size="small"
>
{{ notificationCountText }}
</v-chip>
</div>
@@ -59,8 +69,15 @@
<!-- 操作按钮 -->
<div class="mt-8">
<v-btn color="white" size="large" variant="flat" @click="close">
<v-icon left> mdi-check </v-icon>
<v-btn
color="white"
size="large"
variant="flat"
@click="close"
>
<v-icon left>
mdi-check
</v-icon>
我知道了
</v-btn>
</div>
@@ -75,7 +92,7 @@
<script>
import EventSender from "@/components/EventSender.vue";
import { getSetting } from "@/utils/settings.js";
import { playSound, stopSound } from "@/utils/soundList.js";
import { playSoundAsync, stopSound } from "@/utils/soundList.js";
export default {
name: "UrgentNotification",
@@ -297,7 +314,7 @@ export default {
};
return labels[deviceType] || deviceType;
},
playNotificationSound() {
async playNotificationSound() {
try {
// 停止之前的音频
this.stopNotificationSound();
@@ -307,15 +324,10 @@ export default {
? getSetting('notification.urgentSound')
: getSetting('notification.singleSound');
// 使用配置的铃声文件
this.currentAudio = playSound(soundFile, false);
if (!this.currentAudio) {
// 如果播放失败,使用备用蜂鸣音
this.playFallbackSound();
}
this.currentAudio = await playSoundAsync(soundFile, false);
} catch (error) {
console.warn("无法播放通知音效:", error);
this.currentAudio = null;
this.playFallbackSound();
}
},
@@ -405,7 +417,7 @@ export default {
}
},
// 启动加急通知的定时音效
startUrgentSound() {
async startUrgentSound() {
this.stopUrgentSound(); // 先清除之前的定时器
// 停止之前的音频
@@ -413,10 +425,10 @@ export default {
// 播放循环铃声
const soundFile = getSetting('notification.urgentSound');
this.currentAudio = playSound(soundFile, true); // 循环播放
if (!this.currentAudio) {
// 如果播放失败,使用备用方案:每秒播放一次提示音
try {
this.currentAudio = await playSoundAsync(soundFile, true);
} catch {
this.currentAudio = null;
this.urgentSoundTimer = setInterval(() => {
if (this.visible && this.isUrgent) {
this.playFallbackSound();
+83 -37
View File
@@ -28,8 +28,6 @@
<v-row>
<v-col cols="12">
<v-card>
<v-card-text>
<v-form>
<v-row>
@@ -42,15 +40,14 @@
label="强调通知"
color="red"
inset
>
</v-switch>
/>
<v-checkbox
v-model="notificationForm.isPersistent"
label="常驻展示"
color="primary"
hide-details
class="mt-0"
></v-checkbox>
/>
</v-col>
<v-col cols="12">
<v-textarea
@@ -81,8 +78,6 @@
</v-btn>
<v-spacer />
</v-card-actions>
</v-card>
</v-col>
@@ -93,11 +88,16 @@
<v-col cols="12">
<v-card>
<v-card-title>
<v-icon class="mr-2">mdi-pin</v-icon>
<v-icon class="mr-2">
mdi-pin
</v-icon>
常驻通知管理
</v-card-title>
<v-card-text>
<div v-if="persistentNotifications.length === 0" class="text-center text-grey py-4">
<div
v-if="persistentNotifications.length === 0"
class="text-center text-grey py-4"
>
暂无常驻通知
</div>
<v-list v-else>
@@ -108,14 +108,25 @@
:subtitle="formatTime(item.timestamp)"
lines="two"
>
<template v-slot:prepend>
<template #prepend>
<v-icon :color="item.isUrgent ? 'error' : 'primary'">
{{ item.isUrgent ? 'mdi-alert-circle' : 'mdi-information' }}
</v-icon>
</template>
<template v-slot:append>
<v-btn icon="mdi-pencil" variant="text" size="small" @click="openEditDialog(item)"></v-btn>
<v-btn icon="mdi-delete" variant="text" color="error" size="small" @click="deletePersistentNotification(item.id)"></v-btn>
<template #append>
<v-btn
icon="mdi-pencil"
variant="text"
size="small"
@click="openEditDialog(item)"
/>
<v-btn
icon="mdi-delete"
variant="text"
color="error"
size="small"
@click="deletePersistentNotification(item.id)"
/>
</template>
</v-list-item>
</v-list>
@@ -201,17 +212,14 @@
>
<v-card-text class="pa-2">
<div class="align-center">
<span class="text-body-2 font-weight-medium">{{ device.deviceName }} </span>
<br/>
<br>
{{ device.deviceType }}
</div>
<div class="text-caption mt-1">
已读于 {{ formatDeviceTime(device.timestamp) }}
</div>
</v-card-text>
</v-card>
@@ -226,7 +234,6 @@
>
<v-card-text class="pa-2">
<div class="align-center">
<span class="text-body-2 font-weight-medium">{{ device.deviceName }}</span>
<v-spacer />
<span class="text-caption text-grey">
@@ -238,9 +245,9 @@
</div>
</v-card-text>
</v-card>
</div>
<div v-else> <v-card
<div v-else>
<v-card
color="info-lighten-4"
variant="outlined"
@@ -249,9 +256,10 @@
title="无设备在线"
>
<v-card-text>
如果数秒后任然显示这个提示,则可能没有任何设备在线接收通知。
如果数秒后任然显示这个提示,则可能没有任何设备在线接收通知。
</v-card-text>
</v-card></div>
</v-card>
</div>
</v-col>
</v-row>
</v-card-text>
@@ -266,12 +274,22 @@
<EventSender ref="eventSender" />
<!-- 编辑常驻通知对话框 -->
<v-dialog v-model="editDialog" max-width="500" :fullscreen="$vuetify.display.xs">
<v-dialog
v-model="editDialog"
max-width="500"
:fullscreen="$vuetify.display.xs"
>
<v-card>
<v-toolbar flat density="compact">
<v-toolbar
flat
density="compact"
>
<v-toolbar-title>编辑常驻通知</v-toolbar-title>
<v-spacer></v-spacer>
<v-btn icon="mdi-close" @click="editDialog = false"></v-btn>
<v-spacer />
<v-btn
icon="mdi-close"
@click="editDialog = false"
/>
</v-toolbar>
<v-card-text>
<v-form>
@@ -280,38 +298,66 @@
label="通知内容"
rows="3"
auto-grow
></v-textarea>
/>
<v-switch
v-model="editForm.isUrgent"
label="强调通知"
color="error"
hide-details
></v-switch>
/>
<v-checkbox
v-model="editForm.resend"
label="保存并重新发送通知"
hint="勾选后将作为新通知发送给所有在线设备"
persistent-hint
></v-checkbox>
/>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn variant="text" @click="editDialog = false">取消</v-btn>
<v-btn color="primary" :loading="savingEdit" @click="saveEdit">保存</v-btn>
<v-spacer />
<v-btn
variant="text"
@click="editDialog = false"
>
取消
</v-btn>
<v-btn
color="primary"
:loading="savingEdit"
@click="saveEdit"
>
保存
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 删除确认对话框 -->
<v-dialog v-model="deleteConfirmDialog" max-width="400">
<v-dialog
v-model="deleteConfirmDialog"
max-width="400"
>
<v-card>
<v-card-title class="text-h5">确认删除</v-card-title>
<v-card-title class="text-h5">
确认删除
</v-card-title>
<v-card-text>确定要删除这条常驻通知吗?此操作无法撤销。</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey-darken-1" variant="text" @click="deleteConfirmDialog = false">取消</v-btn>
<v-btn color="error" variant="text" @click="executeDelete">删除</v-btn>
<v-spacer />
<v-btn
color="grey-darken-1"
variant="text"
@click="deleteConfirmDialog = false"
>
取消
</v-btn>
<v-btn
color="error"
variant="text"
@click="executeDelete"
>
删除
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
@@ -0,0 +1,230 @@
<template>
<v-overlay
v-model="visible"
class="align-center justify-center"
persistent
opacity="0.9"
color="#121212"
z-index="9000"
>
<div
class="text-center pa-4"
style="max-width: 80vw; max-height: 90vh; width: 600px;"
>
<v-card
color="grey-darken-4"
elevation="24"
class="rounded-xl border-thin"
style="border-color: rgba(255,255,255,0.1) !important;"
>
<!-- Header -->
<v-card-title class="d-flex align-center justify-center py-4 bg-grey-darken-3">
<v-icon
color="orange"
size="large"
class="mr-3"
>
mdi-bullhorn
</v-icon>
<span class="text-h5 font-weight-bold">通知/公告</span>
</v-card-title>
<v-divider />
<!-- Content -->
<v-card-text class="pa-6">
<div
class="text-h5 text-white markdown-content text-left"
v-html="parsedContent"
/>
</v-card-text>
<v-divider v-if="duration <= 0" />
<!-- Actions -->
<v-card-actions class="pa-4 justify-center">
<v-btn
v-if="duration > 0"
color="grey-lighten-1"
variant="tonal"
size="large"
block
@click="close"
>
关闭 ({{ remaining }}s)
</v-btn>
<v-btn
v-else
color="primary"
variant="flat"
size="large"
block
@click="close"
>
我知道了
</v-btn>
</v-card-actions>
</v-card>
</div>
</v-overlay>
</template>
<script>
import dataProvider from "@/utils/dataProvider";
import { parseMarkdown } from "@/utils/markdown";
import { playSoundAsync, stopSound } from "@/utils/soundList.js";
export default {
name: "AnnouncementOverlay",
data() {
return {
visible: false,
currentNotification: null,
currentAudio: null,
settings: {
notifications: [],
enableSound: true
},
timer: null,
remaining: 0,
countdownTimer: null,
lastTriggeredTime: null
};
},
computed: {
parsedContent() {
if (!this.currentNotification?.content) return '';
return parseMarkdown(this.currentNotification.content);
},
duration() {
return this.currentNotification?.duration || 0;
}
},
mounted() {
this.loadSettings();
// Check every second
this.timer = setInterval(this.checkTime, 1000);
},
beforeUnmount() {
if (this.timer) clearInterval(this.timer);
if (this.countdownTimer) clearInterval(this.countdownTimer);
this.stopSound();
},
methods: {
async loadSettings() {
try {
const res = await dataProvider.loadData('announcement-settings');
if (res && res.success !== false) {
this.settings = {
notifications: res.notifications || [],
enableSound: res.enableSound !== false
};
} else {
this.settings = { notifications: [], enableSound: true };
}
} catch (e) {
console.error("Load announcement settings failed", e);
this.settings = { notifications: [], enableSound: true };
}
},
checkTime() {
if (this.visible) return; // Already showing
if (!this.settings || !this.settings.notifications) return;
const now = new Date();
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
const ss = String(now.getSeconds()).padStart(2, '0');
const currentTime = `${hh}:${mm}:${ss}`;
// Avoid re-triggering same second
if (this.lastTriggeredTime === currentTime) return;
const matched = this.settings.notifications.find(n => {
const noteTime = n.time.length === 5 ? `${n.time}:00` : n.time;
return noteTime === currentTime;
});
if (matched) {
this.show(matched);
this.lastTriggeredTime = currentTime;
}
},
show(notification) {
this.currentNotification = notification;
this.visible = true;
this.remaining = notification.duration;
if (this.settings.enableSound) {
this.playSound();
}
if (this.remaining > 0) {
// Clear any existing countdown first
if (this.countdownTimer) clearInterval(this.countdownTimer);
this.countdownTimer = setInterval(() => {
this.remaining--;
if (this.remaining <= 0) {
this.close();
}
}, 1000);
}
},
close() {
this.visible = false;
this.currentNotification = null;
if (this.countdownTimer) {
clearInterval(this.countdownTimer);
this.countdownTimer = null;
}
this.stopSound();
},
async playSound() {
try {
this.stopSound();
this.currentAudio = await playSoundAsync("universfield-new-notification-024-370048.mp3", false);
} catch (e) {
this.playFallbackSound();
}
},
playFallbackSound() {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.frequency.value = 440;
osc.type = 'sine';
gain.gain.value = 0.1;
osc.start();
setTimeout(() => {
osc.stop();
ctx.close();
}, 500);
} catch {
}
},
stopSound() {
if (this.currentAudio) {
stopSound(this.currentAudio);
this.currentAudio = null;
}
},
reload() {
this.loadSettings();
}
}
};
</script>
<style scoped>
.markdown-content :deep(h1) { font-size: 1.5em; margin-bottom: 0.5em; font-weight: bold; color: white !important; }
.markdown-content :deep(h2) { font-size: 1.3em; margin-bottom: 0.5em; font-weight: bold; color: white !important; }
.markdown-content :deep(p) { margin-bottom: 0.5em; color: white !important; }
.markdown-content :deep(ul) { padding-left: 1.5em; margin-bottom: 0.5em; color: white !important; }
.markdown-content :deep(li) { margin-bottom: 0.25em; }
.markdown-content :deep(mark) { background-color: yellow; color: black !important; padding: 0 4px; border-radius: 2px; }
.markdown-content :deep(hr) { margin: 1em 0; border-color: rgba(255,255,255,0.2); }
</style>
@@ -0,0 +1,579 @@
<template>
<v-dialog
v-model="dialogVisible"
max-width="600"
scrollable
>
<v-card color="#1E1E1E">
<v-toolbar
color="transparent"
density="compact"
>
<v-toolbar-title class="text-h6 font-weight-bold ml-4">
公告板设置
</v-toolbar-title>
<v-spacer />
<v-btn
icon="mdi-close"
variant="text"
@click="dialogVisible = false"
/>
</v-toolbar>
<v-card-text class="pa-4">
<!-- Settings Section -->
<v-card
variant="outlined"
class="mb-4"
color="grey-darken-3"
>
<v-card-text>
<div class="d-flex align-center mb-2">
<v-icon
start
icon="mdi-bullhorn"
class="mr-2"
/>
<span class="text-subtitle-1 font-weight-bold">公告板设置</span>
</div>
<div class="text-caption text-grey-lighten-1 mb-4">
设置定时弹出的公告和提示音
</div>
<v-switch
v-model="enableSound"
color="primary"
label="启用提示音"
hide-details
inset
/>
</v-card-text>
</v-card>
<v-divider class="mb-4" />
<!-- List Section -->
<div class="d-flex align-center mb-2">
<span class="text-subtitle-1 font-weight-bold">定时公告列表</span>
<v-spacer />
<v-btn
color="primary"
variant="text"
prepend-icon="mdi-plus"
@click="openAddDialog"
>
添加公告
</v-btn>
</div>
<div
v-if="notifications.length === 0"
class="text-center text-grey-lighten-1 py-8"
>
暂无定时公告
</div>
<v-card
v-for="note in notifications"
:key="note.id"
class="mb-2"
variant="outlined"
color="grey-darken-3"
>
<v-card-text class="d-flex align-center py-2">
<div class="mr-4 text-h6 font-weight-bold">
{{ note.time }}
</div>
<div class="flex-grow-1 text-truncate text-grey-lighten-1">
{{ note.content || '无内容' }}
</div>
<div
v-if="note.duration > 0"
class="text-caption text-grey-lighten-1 mr-2"
>
{{ note.duration }}秒
</div>
<v-btn
icon="mdi-pencil"
variant="text"
color="info"
size="small"
class="mr-2"
@click="editNotification(note)"
/>
<v-btn
icon="mdi-delete"
variant="text"
color="error"
size="small"
@click="deleteNotification(note.id)"
/>
</v-card-text>
</v-card>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="primary"
@click="dialogVisible = false"
>
关闭
</v-btn>
</v-card-actions>
</v-card>
<!-- Add/Edit Dialog -->
<v-dialog
v-model="addDialogVisible"
max-width="800"
persistent
>
<v-card color="#1E1E1E">
<v-toolbar color="transparent">
<v-toolbar-title>{{ isEditing ? '编辑公告' : '添加公告' }}</v-toolbar-title>
<v-spacer />
<v-btn
color="primary"
variant="tonal"
prepend-icon="mdi-help-circle-outline"
class="mr-2"
@click="showHelpDialog = true"
>
使用帮助
</v-btn>
</v-toolbar>
<v-card-text>
<v-row>
<v-col cols="12">
<div class="text-caption text-grey-lighten-1 mb-1">
时间 (HH:mm:ss)
</div>
<v-text-field
v-model="newItem.time"
type="time"
step="1"
variant="outlined"
prepend-inner-icon="mdi-clock-outline"
hide-details
bg-color="grey-darken-4"
/>
</v-col>
<v-col cols="12">
<div class="text-caption text-grey-lighten-1 mb-1">
持续时间 (秒, 0为不自动关闭)
</div>
<v-text-field
v-model.number="newItem.duration"
type="number"
variant="outlined"
prepend-inner-icon="mdi-timer-sand"
hide-details
bg-color="grey-darken-4"
/>
</v-col>
</v-row>
<v-row class="mt-2">
<v-col
cols="12"
md="6"
>
<div class="d-flex align-center justify-space-between mb-2 flex-wrap">
<span class="text-caption text-grey-lighten-1">编辑内容 (Markdown)</span>
<!-- Formatting Toolbar -->
<div class="d-flex align-center">
<v-btn
size="x-small"
variant="text"
icon="mdi-format-bold"
@click="insertFormat('**', '**')"
/>
<v-btn
size="x-small"
variant="text"
icon="mdi-format-italic"
@click="insertFormat('*', '*')"
/>
<v-btn
size="x-small"
variant="text"
icon="mdi-format-header-1"
@click="insertFormat('# ', '')"
/>
<v-btn
size="x-small"
variant="text"
icon="mdi-format-header-2"
@click="insertFormat('## ', '')"
/>
<v-btn
size="x-small"
variant="text"
icon="mdi-format-list-bulleted"
@click="insertFormat('- ', '')"
/>
<v-btn
size="x-small"
variant="text"
icon="mdi-minus"
@click="insertFormat('\n---\n', '')"
/>
<v-btn
size="x-small"
variant="text"
icon="mdi-marker"
color="yellow"
@click="insertFormat('<mark>', '</mark>')"
/>
</div>
</div>
<v-textarea
ref="editor"
v-model="newItem.content"
variant="outlined"
rows="10"
no-resize
bg-color="grey-darken-4"
hide-details
/>
</v-col>
<v-col
cols="12"
md="6"
>
<div class="text-caption text-grey-lighten-1 mb-2">
预览区域
</div>
<v-card
variant="outlined"
color="grey-darken-4"
height="266"
class="pa-4 overflow-y-auto"
>
<div
class="markdown-preview text-white"
v-html="renderMarkdown(newItem.content)"
/>
</v-card>
</v-col>
</v-row>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
variant="text"
@click="addDialogVisible = false"
>
取消
</v-btn>
<v-btn
color="primary"
@click="saveNotification"
>
保存
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Help Dialog -->
<v-dialog
v-model="showHelpDialog"
max-width="700"
>
<v-card color="#1E1E1E">
<v-toolbar
color="blue"
density="compact"
>
<v-icon class="ml-4">
mdi-school
</v-icon>
<v-toolbar-title>如何使用公告板</v-toolbar-title>
<v-btn
icon="mdi-close"
@click="showHelpDialog = false"
/>
</v-toolbar>
<v-card-text class="pa-0">
<v-row no-gutters>
<v-col
cols="12"
md="6"
class="pa-4 border-e border-grey-darken-3"
>
<h3 class="text-h6 text-blue mb-4">
<v-icon
color="blue"
class="mr-2"
>
mdi-clock-outline
</v-icon>
基础设置
</h3>
<div class="mb-4">
<div class="text-subtitle-2 font-weight-bold">
设定时间
</div>
<div class="text-caption text-grey">
设置公告自动弹出的时间(24小时制)。到达该时间时,全屏会显示此公告。
</div>
</div>
<div class="mb-4">
<div class="text-subtitle-2 font-weight-bold">
持续时间
</div>
<div class="text-caption text-grey">
公告显示的秒数。设为 0 则不会自动关闭,需要手动点击屏幕关闭。
</div>
</div>
</v-col>
<v-col
cols="12"
md="6"
class="pa-4"
>
<h3 class="text-h6 text-green mb-4">
<v-icon
color="green"
class="mr-2"
>
mdi-format-paint
</v-icon>
排版与美化
</h3>
<div class="mb-2">
<div class="text-subtitle-2 font-weight-bold">
加粗 / 斜体
</div>
<div class="text-caption text-grey">
选中文字点击按钮,强调重要信息
</div>
</div>
<div class="mb-2">
<div class="text-subtitle-2 font-weight-bold">
大标题 / 小标题
</div>
<div class="text-caption text-grey">
使用 H1/H2 区分不同板块
</div>
</div>
<div class="mb-2">
<div class="text-subtitle-2 font-weight-bold">
高亮标记 (Mark)
</div>
<div class="text-caption text-grey">
先选中文字,再点击颜色按钮,会自动包裹。
</div>
</div>
<div class="mb-2">
<div class="text-subtitle-2 font-weight-bold">
分割线
</div>
<div class="text-caption text-grey">
插入水平线分隔内容
</div>
</div>
</v-col>
</v-row>
</v-card-text>
<v-card-actions class="bg-grey-darken-4 pa-4">
<v-alert
color="info"
variant="tonal"
icon="mdi-eye"
density="compact"
class="flex-grow-1 mr-4"
>
<div class="text-caption">
实时预览:右侧窗口会实时显示最终效果,所见即所得。
</div>
</v-alert>
<v-btn
color="primary"
min-width="100"
@click="showHelpDialog = false"
>
我学会了
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-dialog>
</template>
<script>
import { parseMarkdown } from "@/utils/markdown";
import dataProvider from "@/utils/dataProvider";
export default {
name: "AnnouncementSettingsDialog",
props: {
modelValue: {
type: Boolean,
required: true
}
},
emits: ["update:modelValue", "save"],
data() {
return {
enableSound: true,
notifications: [],
addDialogVisible: false,
showHelpDialog: false,
isEditing: false,
editingId: null,
newItem: {
time: "12:00:00",
duration: 5,
content: ""
}
};
},
computed: {
dialogVisible: {
get() {
return this.modelValue;
},
set(value) {
this.$emit("update:modelValue", value);
}
}
},
watch: {
modelValue(val) {
if (val) {
this.loadSettings();
}
}
},
methods: {
async loadSettings() {
try {
const res = await dataProvider.loadData('announcement-settings');
if (res && res.success !== false) {
this.notifications = res.notifications || [];
this.enableSound = res.enableSound !== false;
} else {
this.notifications = [];
this.enableSound = true;
}
} catch (e) {
console.error("Load announcements failed", e);
}
},
openAddDialog() {
this.isEditing = false;
this.editingId = null;
this.newItem = {
time: "12:00:00",
duration: 5,
content: ""
};
this.addDialogVisible = true;
},
editNotification(note) {
this.isEditing = true;
this.editingId = note.id;
this.newItem = { ...note };
// Ensure time format includes seconds if missing
if (this.newItem.time && this.newItem.time.length === 5) {
this.newItem.time = `${this.newItem.time}:00`;
}
this.addDialogVisible = true;
},
async saveNotification() {
if (!this.newItem.content) return;
// Ensure time format includes seconds if missing
if (this.newItem.time && this.newItem.time.length === 5) {
this.newItem.time = `${this.newItem.time}:00`;
}
if (this.isEditing) {
const index = this.notifications.findIndex(n => n.id === this.editingId);
if (index !== -1) {
this.notifications[index] = { ...this.newItem, id: this.editingId };
}
} else {
this.notifications.push({
id: Date.now().toString(),
...this.newItem
});
}
// Sort by time
this.notifications.sort((a, b) => a.time.localeCompare(b.time));
await this.saveAll();
this.addDialogVisible = false;
},
async deleteNotification(id) {
this.notifications = this.notifications.filter(n => n.id !== id);
await this.saveAll();
},
async saveAll() {
await dataProvider.saveData('announcement-settings', {
notifications: this.notifications,
enableSound: this.enableSound
});
this.$emit('save'); // Notify parent to reload overlay settings
},
renderMarkdown(content) {
if (!content) return '';
try {
return parseMarkdown(content);
} catch (e) {
return content;
}
},
insertFormat(prefix, suffix) {
if (!this.$refs.editor) return;
const textarea = this.$refs.editor.$el.querySelector('textarea');
if (!textarea) return;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const text = this.newItem.content || "";
const before = text.substring(0, start);
const selected = text.substring(start, end);
const after = text.substring(end);
this.newItem.content = before + prefix + selected + suffix + after;
this.$nextTick(() => {
textarea.focus();
const newCursorPos = start + prefix.length + selected.length + suffix.length;
textarea.setSelectionRange(newCursorPos, newCursorPos);
});
}
}
};
</script>
<style scoped>
.gap-2 { gap: 8px; }
/* Ensure preview text is visible against dark background */
.markdown-preview {
color: white !important;
}
.markdown-preview :deep(h1) { font-size: 1.5em; font-weight: bold; margin-bottom: 0.5em; color: white !important; }
.markdown-preview :deep(h2) { font-size: 1.25em; font-weight: bold; margin-bottom: 0.5em; color: white !important; }
.markdown-preview :deep(ul) { padding-left: 1.5em; margin-bottom: 0.5em; color: white !important; }
.markdown-preview :deep(p) { margin-bottom: 0.5em; color: white !important; }
.markdown-preview :deep(mark) { background-color: yellow; color: black !important; padding: 0 2px; border-radius: 2px; }
.markdown-preview :deep(hr) { border-color: rgba(255, 255, 255, 0.2); margin: 1em 0; }
</style>
@@ -0,0 +1,224 @@
<template>
<v-card
class="mb-4"
variant="outlined"
color="grey-darken-3"
>
<v-card-title class="d-flex align-center py-2 px-3">
<v-icon
size="small"
class="mr-2"
color="orange"
>
mdi-bullhorn
</v-icon>
<span class="text-subtitle-2 font-weight-bold">公告板</span>
<v-spacer />
<v-btn
icon="mdi-cog"
variant="text"
size="small"
color="grey"
@click="$emit('open-settings')"
/>
</v-card-title>
<v-card-text class="pa-0">
<div v-if="notifications.length > 0">
<div
v-for="(note, index) in notifications"
:key="index"
class="px-3 py-2 border-b cursor-pointer hover-bg"
style="border-bottom: 1px solid rgba(255,255,255,0.05);"
@click="showDetail(note)"
>
<div class="d-flex align-center justify-space-between mb-1">
<div class="d-flex align-center">
<v-icon
size="x-small"
color="primary"
class="mr-1"
>
mdi-clock-outline
</v-icon>
<span class="text-caption font-weight-bold text-primary">{{ note.time }}</span>
</div>
<v-chip
v-if="isNext(note)"
size="x-small"
color="success"
variant="flat"
class="px-1"
style="height: 16px; font-size: 10px;"
>
NEXT
</v-chip>
</div>
<div
class="text-caption text-grey-lighten-1 markdown-preview-mini"
v-html="renderMiniMarkdown(note.content)"
/>
</div>
</div>
<div
v-else
class="pa-4 text-center text-caption text-grey"
>
暂无公告安排
</div>
</v-card-text>
<!-- Detail Dialog -->
<v-dialog
v-model="detailDialog"
max-width="500"
>
<v-card color="grey-darken-4">
<v-card-title class="d-flex align-center">
<v-icon
color="primary"
class="mr-2"
>
mdi-bullhorn-outline
</v-icon>
公告详情
<v-spacer />
<v-chip
color="primary"
variant="outlined"
size="small"
>
{{ selectedNote?.time }}
</v-chip>
</v-card-title>
<v-card-text class="pa-4">
<div
class="markdown-preview text-white"
v-html="renderFullMarkdown(selectedNote?.content)"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
color="primary"
variant="text"
@click="detailDialog = false"
>
关闭
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-card>
</template>
<script>
import dataProvider from "@/utils/dataProvider";
import { parseMarkdown } from "@/utils/markdown";
export default {
name: "AnnouncementWidget",
emits: ["open-settings"],
data() {
return {
notifications: [],
timer: null,
detailDialog: false,
selectedNote: null
};
},
mounted() {
this.loadSettings();
// Refresh every second to update "NEXT" status
this.timer = setInterval(() => {
this.$forceUpdate();
}, 1000);
},
beforeUnmount() {
if (this.timer) clearInterval(this.timer);
},
methods: {
async loadSettings() {
try {
const res = await dataProvider.loadData('announcement-settings');
if (res && res.success !== false && res.notifications) {
this.notifications = res.notifications.sort((a, b) => a.time.localeCompare(b.time));
} else {
this.notifications = [];
}
} catch (e) {
console.error("Load announcements failed", e);
}
},
isNext(note) {
if (!this.notifications.length) return false;
const now = new Date();
const hh = String(now.getHours()).padStart(2, '0');
const mm = String(now.getMinutes()).padStart(2, '0');
const ss = String(now.getSeconds()).padStart(2, '0');
const currentTime = `${hh}:${mm}:${ss}`;
// Find the first notification that is strictly after current time
const next = this.notifications.find(n => {
const noteTime = n.time.length === 5 ? `${n.time}:00` : n.time;
return noteTime > currentTime;
});
if (next) {
return note === next;
} else {
// If all today's times passed, the first one tomorrow is next
return note === this.notifications[0];
}
},
renderMiniMarkdown(text) {
if (!text) return '';
// Render full markdown but stripped of block tags for mini view or just first line?
// Better: Render full markdown but restrict height/overflow in CSS
// Or: Just render it. The user said "use markdown format"
return parseMarkdown(text);
},
renderFullMarkdown(text) {
return parseMarkdown(text);
},
showDetail(note) {
this.selectedNote = note;
this.detailDialog = true;
},
reload() {
this.loadSettings();
}
}
};
</script>
<style scoped>
.hover-bg:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.markdown-preview-mini {
max-height: 60px;
overflow: hidden;
position: relative;
/* Add fade out effect at bottom */
mask-image: linear-gradient(to bottom, black 60%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, black 60%, transparent 100%);
}
/* Re-use markdown styles from settings dialog or global */
.markdown-preview-mini :deep(h1),
.markdown-preview :deep(h1) { font-size: 1.2em; font-weight: bold; margin: 0.2em 0; color: white !important; }
.markdown-preview-mini :deep(h2),
.markdown-preview :deep(h2) { font-size: 1.1em; font-weight: bold; margin: 0.2em 0; color: white !important; }
.markdown-preview-mini :deep(p),
.markdown-preview :deep(p) { margin: 0 0 0.5em 0; color: rgba(255,255,255,0.9) !important; }
.markdown-preview-mini :deep(ul),
.markdown-preview :deep(ul) { padding-left: 1.2em; margin: 0; color: rgba(255,255,255,0.9) !important; }
.markdown-preview :deep(mark) { background-color: yellow; color: black !important; padding: 0 2px; border-radius: 2px; }
</style>
@@ -6,25 +6,42 @@
max-width="900"
@update:model-value="$emit('update:modelValue', $event)"
>
<v-card>
<v-card color="black-lighten-1">
<v-card-title class="d-flex align-center">
<v-icon class="mr-2" icon="mdi-account-group" />
<v-icon
class="mr-2"
icon="mdi-account-group"
/>
考勤
<v-spacer />
<v-chip v-if="!isMobile" class="ml-2" color="primary" size="small">
{{ dateString }}
</v-chip>
<v-btn v-if="isMobile" icon="mdi-close" variant="text" @click="$emit('update:modelValue', false)" />
<v-btn
color="secondary"
variant="tonal"
prepend-icon="mdi-format-list-bulleted"
class="mr-2"
@click="showEditListDialog = true"
>
修改名单
</v-btn>
<v-btn
v-if="isMobile"
icon="mdi-close"
variant="text"
@click="$emit('update:modelValue', false)"
/>
</v-card-title>
<v-card-text>
<!-- 批量操作和搜索 -->
<v-row class="mb-4">
<v-col cols="12" md="12">
<v-col
cols="12"
md="12"
>
<v-text-field
v-model="attendanceSearch"
clearable
hint="支持筛选姓氏,如输入'孙'可筛选所有姓孙的学生"
hint="*支持筛选姓氏"
label="搜索学生"
prepend-inner-icon="mdi-magnify"
variant="outlined"
@@ -128,7 +145,10 @@
md="6"
sm="6"
>
<v-card border class="student-card">
<v-card
border
class="student-card"
>
<v-card-text class="d-flex align-center pa-2">
<div class="flex-grow-1">
<div class="d-flex align-center">
@@ -137,11 +157,13 @@
class="mr-2"
size="24"
>
<v-icon size="small"
>{{ getStudentStatusIcon(student) }}
<v-icon size="small">
{{ getStudentStatusIcon(student) }}
</v-icon>
</v-avatar>
<div class="text-subtitle-1">{{ student }}</div>
<div class="text-subtitle-1">
{{ student }}
</div>
</div>
</div>
<div class="attendance-actions">
@@ -183,10 +205,19 @@
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="12">
<v-card class="mb-4" color="primary" variant="tonal">
<v-col
cols="12"
md="12"
>
<v-card
class="mb-4"
color="primary"
variant="tonal"
>
<v-card-text>
<div class="text-subtitle-2 mb-2">批量操作</div>
<div class="text-subtitle-2 mb-2">
批量操作
</div>
<div class="d-flex flex-wrap gap-2">
<v-btn
class="flex-grow-1"
@@ -232,18 +263,83 @@
<v-card-actions>
<v-spacer />
<v-btn color="primary" @click="$emit('save')">
<v-icon start>mdi-content-save</v-icon>
<v-btn
color="primary"
@click="$emit('save')"
>
<v-icon start>
mdi-content-save
</v-icon>
保存
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Edit List Dialog -->
<v-dialog
v-model="showEditListDialog"
max-width="500"
>
<v-card>
<v-card-title>修改班级名单</v-card-title>
<v-card-text>
<v-alert
type="info"
variant="tonal"
class="mb-4"
density="compact"
>
请输入学生姓名,每行一个。
</v-alert>
<div class="d-flex justify-end mb-2">
<v-btn
color="success"
variant="text"
prepend-icon="mdi-microsoft-excel"
size="small"
@click="triggerFileInput"
>
从Excel导入
</v-btn>
<input
ref="fileInput"
type="file"
accept=".xlsx, .xls"
style="display: none"
@change="handleFileUpload"
>
</div>
<v-textarea
v-model="editingStudentList"
rows="10"
auto-grow
placeholder="张三&#10;李四&#10;王五"
/>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn
variant="text"
@click="showEditListDialog = false"
>
取消
</v-btn>
<v-btn
color="primary"
@click="saveStudentList"
>
保存名单
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script>
import { useDisplay } from "vuetify";
import { getSetting } from "@/utils/settings";
import dataProvider from "@/utils/dataProvider";
export default {
name: "AttendanceManagementDialog",
@@ -265,7 +361,7 @@ export default {
default: "",
},
},
emits: ["update:modelValue", "save", "change"],
emits: ["update:modelValue", "save", "change", "list-updated"],
setup() {
const { mobile } = useDisplay();
return { mobile };
@@ -274,6 +370,8 @@ export default {
return {
attendanceSearch: "",
attendanceFilter: [],
showEditListDialog: false,
editingStudentList: "",
};
},
computed: {
@@ -330,7 +428,111 @@ export default {
.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
},
},
watch: {
showEditListDialog(val) {
if (val) {
this.editingStudentList = this.studentList.join('\n');
}
}
},
methods: {
triggerFileInput() {
this.$refs.fileInput.click();
},
async handleFileUpload(event) {
const file = event.target.files[0];
if (!file) return;
try {
const XLSX = await import("xlsx");
const data = await file.arrayBuffer();
const workbook = XLSX.read(data);
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
if (jsonData.length === 0) {
this.$message?.warning("提示", "文件为空");
return;
}
// Find "姓名" column index
let nameColIndex = -1;
let headerRowIndex = 0;
// Search in first 10 rows
for (let i = 0; i < Math.min(jsonData.length, 10); i++) {
const row = jsonData[i];
if (Array.isArray(row)) {
const idx = row.findIndex(
(cell) =>
cell && typeof cell === "string" && cell.includes("姓名")
);
if (idx !== -1) {
nameColIndex = idx;
headerRowIndex = i;
break;
}
}
}
if (nameColIndex === -1) {
this.$message?.warning("提示", "未找到“姓名”列,请检查Excel文件");
return;
}
const names = [];
for (let i = headerRowIndex + 1; i < jsonData.length; i++) {
const row = jsonData[i];
if (row[nameColIndex]) {
names.push(String(row[nameColIndex]).trim());
}
}
if (names.length > 0) {
if (this.editingStudentList.trim()) {
// 直接覆盖而不是弹出 confirm 对话框
this.editingStudentList = names.join("\n");
} else {
this.editingStudentList = names.join("\n");
}
this.$message?.success("导入成功", `成功读取 ${names.length} 个名字,请点击保存名单`);
} else {
this.$message?.warning("提示", "未找到名字数据");
}
} catch (e) {
console.error("Excel import failed", e);
this.$message?.error("导入失败", e.message);
} finally {
event.target.value = ""; // Reset input
}
},
async saveStudentList() {
const list = this.editingStudentList
.split('\n')
.map(s => s.trim())
.filter(s => s);
try {
const studentObjects = list.map((name, index) => ({ name, id: index + 1 }));
const response = await dataProvider.saveData("classworks-list-main", studentObjects);
if (response && response.success === false) {
throw new Error(response.error?.message || "保存失败");
}
// Update the dialog content to match what was saved
this.editingStudentList = list.join('\n');
this.$emit("list-updated");
this.showEditListDialog = false;
this.$message?.success("保存成功", "学生列表已更新");
} catch (e) {
console.error("Failed to save student list", e);
this.$message?.error("保存失败", e.message || "无法保存学生列表");
}
},
toggleFilter(filter) {
const index = this.attendanceFilter.indexOf(filter);
if (index === -1) {
+672 -81
View File
@@ -1,89 +1,430 @@
<template>
<v-col
v-if="studentList && studentList.length"
v-ripple="!isEditingDisabled ? {
class: `text-${
['primary', 'secondary', 'info', 'success', 'warning', 'error'][
Math.floor(Math.random() * 6)
]
}`,
} : false"
:class="{ 'cursor-not-allowed': isEditingDisabled }"
class="attendance-area no-select"
cols="1"
@click="handleClick"
<v-card
v-if="!inDialog"
class="attendance-sidebar"
:class="compact ? 'mb-2' : 'mb-4'"
:style="contentStyle"
variant="outlined"
color="grey-darken-3"
elevation="0"
>
<h1>出勤</h1>
<h2>
<span style="white-space: nowrap"> 应到</span>
:
<span style="white-space: nowrap">
{{ studentList.length - attendance.exclude.length }}人
</span>
</h2>
<h2>
<span style="white-space: nowrap"> 实到</span>
:
<span style="white-space: nowrap">
{{
studentList.length -
attendance.absent.length -
(!getSetting("display.lateStudentsArePresent")) * attendance.late.length -
attendance.exclude.length
}}人
</span>
</h2>
<h2>
<span style="white-space: nowrap"> 请假</span>
:
<span style="white-space: nowrap">
{{ attendance.absent.length }}人
</span>
</h2>
<h3
v-for="(name, index) in attendance.absent"
:key="'absent-' + index"
class="gray-text"
<!-- Header with Animation -->
<v-card-title
v-if="!compact"
class="bg-primary text-white d-flex align-center py-3 px-4"
style="min-height: 52px;"
>
<span v-if="display.lgAndUp.value">{{ `${index + 1}. ` }}</span
><span style="white-space: nowrap">{{ name }}</span>
</h3>
<h2>
<span style="white-space: nowrap">迟到</span>
:
<span style="white-space: nowrap">
{{ attendance.late.length }}人
</span>
</h2>
<h3
v-for="(name, index) in attendance.late"
:key="'late-' + index"
class="gray-text"
<v-icon
start
class="animate-pulse"
size="small"
>
mdi-account-group
</v-icon>
<span
class="font-weight-bold text-subtitle-1"
style="font-size: 1.25em !important;"
>出勤</span>
<v-spacer />
<v-btn
icon="mdi-pencil"
variant="text"
size="small"
color="white"
@click.stop="$emit('click')"
/>
</v-card-title>
<v-card-text class="pa-0">
<div
v-ripple="!isEditingDisabled"
class="attendance-content"
:class="[{ 'disabled': isEditingDisabled, 'cursor-pointer': !isEditingDisabled }, compact ? 'pa-1' : 'pa-3']"
:style="contentStyle"
@click.stop="$emit('click')"
>
<!-- Statistics Grid -->
<v-row
dense
class="ma-0"
:class="compact ? 'mb-0' : 'mb-2'"
>
<!-- 全勤时显示在左侧的图标 (仅在 compact 模式) -->
<v-col
v-if="isAllPresent && compact"
:cols="isAllPresent && compact ? 4 : 12"
class="d-flex align-center justify-center pa-0"
>
<div class="text-center text-success d-flex flex-row align-center justify-center">
<v-icon
color="success"
size="24"
class="mr-1"
>
mdi-check-circle
</v-icon>
<div
class="font-weight-bold"
style="font-size: 0.9em;"
>
全勤
</div>
</div>
</v-col>
<v-col
:cols="isAllPresent && compact ? 4 : 6"
class="pa-0"
>
<div class="stat-item d-flex flex-column align-center justify-center">
<div
class="text-caption text-white"
:style="{ fontSize: compact ? '0.7em !important' : '0.8em !important', lineHeight: compact ? '1' : '' }"
>
应到
</div>
<div
class="font-weight-bold text-white"
:class="compact ? 'text-subtitle-2' : 'text-h6'"
:style="{ fontSize: compact ? '1.1em !important' : '1.5em !important', lineHeight: compact ? '1' : '' }"
>
{{ stats.expected }}
</div>
</div>
</v-col>
<v-col
:cols="isAllPresent && compact ? 4 : 6"
class="pa-0"
>
<div class="stat-item d-flex flex-column align-center justify-center">
<div
class="text-caption text-white"
:style="{ fontSize: compact ? '0.7em !important' : '0.8em !important', lineHeight: compact ? '1' : '' }"
>
实到
</div>
<div
class="font-weight-bold text-success"
:class="compact ? 'text-subtitle-2' : 'text-h6'"
:style="{ fontSize: compact ? '1.1em !important' : '1.5em !important', lineHeight: compact ? '1' : '' }"
>
{{ stats.actual }}
</div>
</div>
</v-col>
</v-row>
<v-divider
v-if="!isAllPresent || !compact"
:class="compact ? 'mb-1 mt-0' : 'mb-2'"
/>
<!-- Scrollable Area -->
<div
v-if="!isAllPresent || !compact"
ref="scrollContainer"
class="status-lists"
:style="{ maxHeight: compact ? '80px' : '300px', overflowY: 'auto', ...contentStyle }"
@mouseenter="isHovering = true"
@mouseleave="isHovering = false"
>
<!-- Status Lists -->
<div
v-if="attendance.absent.length > 0"
class="status-group mb-1"
>
<div
class="d-flex align-center mb-0 sticky-header bg-surface"
style="position: sticky; top: 0; z-index: 1;"
>
<v-icon
:size="compact ? '12' : 'x-small'"
start
color="error"
:style="compact ? 'margin-right: 4px;' : ''"
>
mdi-account-off
</v-icon>
<span
class="font-weight-bold text-error"
:class="compact ? 'text-caption' : 'text-caption'"
:style="{ fontSize: compact ? '0.7em !important' : '0.9em !important', lineHeight: compact ? '1.2' : '' }"
>请假 ({{ attendance.absent.length }})</span>
</div>
<div class="d-flex flex-column gap-0 pl-1 border-s-sm border-error">
<div
v-for="name in attendance.absent"
:key="'absent-' + name"
class="font-weight-bold text-white py-0"
:class="compact ? 'text-caption' : 'text-caption'"
:style="{ fontSize: compact ? '0.8em !important' : '1.1em !important', padding: compact ? '0 !important' : '4px 0 !important', lineHeight: compact ? '1.2' : '' }"
>
{{ name }}
</div>
</div>
</div>
<div
v-if="attendance.late.length > 0"
class="status-group mb-1"
>
<div
class="d-flex align-center mb-0 sticky-header bg-surface"
style="position: sticky; top: 0; z-index: 1;"
>
<v-icon
:size="compact ? '12' : 'x-small'"
start
color="warning"
:style="compact ? 'margin-right: 4px;' : ''"
>
mdi-clock-alert
</v-icon>
<span
class="font-weight-bold text-warning"
:class="compact ? 'text-caption' : 'text-caption'"
:style="{ fontSize: compact ? '0.7em !important' : '0.9em !important', lineHeight: compact ? '1.2' : '' }"
>迟到 ({{ attendance.late.length }})</span>
</div>
<div class="d-flex flex-column gap-0 pl-1 border-s-sm border-warning">
<div
v-for="name in attendance.late"
:key="'late-' + name"
class="font-weight-bold text-white py-0"
:class="compact ? 'text-caption' : 'text-caption'"
:style="{ fontSize: compact ? '0.8em !important' : '1.1em !important', padding: compact ? '0 !important' : '4px 0 !important', lineHeight: compact ? '1.2' : '' }"
>
{{ name }}
</div>
</div>
</div>
<div
v-if="attendance.exclude.length > 0"
class="status-group mb-1"
>
<div
class="d-flex align-center mb-0 sticky-header bg-surface"
style="position: sticky; top: 0; z-index: 1;"
>
<v-icon
:size="compact ? '12' : 'x-small'"
start
color="grey"
:style="compact ? 'margin-right: 4px;' : ''"
>
mdi-account-cancel
</v-icon>
<span
class="font-weight-bold text-grey"
:class="compact ? 'text-caption' : 'text-caption'"
:style="{ fontSize: compact ? '0.7em !important' : '0.9em !important', lineHeight: compact ? '1.2' : '' }"
>不参与 ({{ attendance.exclude.length }})</span>
</div>
<div class="d-flex flex-column gap-0 pl-1 border-s-sm border-grey">
<div
v-for="name in attendance.exclude"
:key="'exclude-' + name"
class="text-white py-0"
:class="compact ? 'text-caption' : 'text-caption'"
:style="{ fontSize: compact ? '0.75em !important' : '0.9em !important', padding: compact ? '0 !important' : '4px 0 !important', lineHeight: compact ? '1.2' : '' }"
>
{{ name }}
</div>
</div>
</div>
</div>
<div
v-if="isAllPresent && !compact"
class="text-center text-success d-flex flex-column align-center justify-center"
:class="compact ? 'py-1' : 'py-6'"
>
<v-icon
color="success"
:size="compact ? '28' : '64'"
:class="compact ? 'mb-0' : 'mb-2'"
>
mdi-check-circle
</v-icon>
<div
class="font-weight-bold"
:class="compact ? 'text-subtitle-2' : 'text-h5'"
>
全勤
</div>
</div>
</div>
</v-card-text>
</v-card>
<div
v-else
:style="contentStyle"
>
<v-container
fluid
class="pa-4"
>
<span v-if="display.lgAndUp.value">{{ `${index + 1}. ` }}</span
><span style="white-space: nowrap">{{ name }}</span>
</h3>
<h2>
<span style="white-space: nowrap">不参与</span>
:
<span style="white-space: nowrap">
{{ attendance.exclude.length }}人
</span>
</h2>
<h3
v-for="(name, index) in attendance.exclude"
:key="'exclude-' + index"
class="gray-text"
>
<span v-if="display.lgAndUp.value">{{ `${index + 1}. ` }}</span
><span style="white-space: nowrap">{{ name }}</span>
</h3>
</v-col>
<v-text-field
v-model="search"
prepend-inner-icon="mdi-magnify"
label="搜索学生"
variant="outlined"
density="compact"
hide-details
class="mb-4"
bg-color="surface"
/>
<!-- 统计信息 -->
<div class="d-flex justify-space-between mb-4 text-body-2 font-weight-bold px-2">
<span
v-for="(count, status) in statusCounts"
:key="status"
>
{{ status }} ({{ count }})
</span>
</div>
<!-- 状态筛选 Chip -->
<div class="d-flex flex-wrap gap-2 mb-4">
<v-chip
v-for="status in ['all', 'present', 'absent', 'late', 'exclude']"
:key="status"
:color="filterStatus === status ? 'primary' : 'default'"
:variant="filterStatus === status ? 'flat' : 'outlined'"
class="mr-2"
@click="filterStatus = status"
>
<v-icon
start
size="small"
>
{{ getStatusIconByString(status) }}
</v-icon>
{{ getStatusLabel(status) }}
</v-chip>
</div>
<!-- 学生网格 -->
<v-row dense>
<v-col
v-for="student in filteredStudents"
:key="student"
cols="6"
sm="4"
md="3"
>
<v-card
v-ripple
variant="outlined"
class="d-flex align-center pa-2"
:color="getStatusColor(student) !== 'grey-lighten-1' ? getStatusColor(student) : 'grey-darken-3'"
:style="{ borderColor: getStatusColor(student) === 'grey-lighten-1' ? 'rgba(255,255,255,0.1)' : 'currentColor' }"
@click="toggleStatus(student)"
>
<v-avatar
size="32"
:color="getStatusColor(student) === 'grey-lighten-1' ? 'grey-darken-4' : getStatusColor(student)"
variant="tonal"
class="mr-2"
>
<v-icon
size="small"
:color="getStatusColor(student) === 'grey-lighten-1' ? 'white' : ''"
>
{{ getStatusIcon(student) }}
</v-icon>
</v-avatar>
<div class="text-truncate flex-grow-1 font-weight-medium text-white">
{{ student }}
</div>
<v-icon
v-if="getStatus(student)"
size="small"
:color="getStatusColor(student) === 'grey-lighten-1' ? 'transparent' : getStatusColor(student)"
>
{{ getStatusIcon(student) }}
</v-icon>
</v-card>
</v-col>
</v-row>
<!-- 批量操作 -->
<v-card
class="mt-6 pa-4 bg-surface-variant"
variant="flat"
>
<div class="text-subtitle-2 font-weight-bold mb-3 text-primary">
批量操作
</div>
<v-row dense>
<v-col
cols="6"
sm="3"
>
<v-btn
block
color="success"
variant="flat"
prepend-icon="mdi-account-check"
@click="batchSetStatus(null)"
>
全部到齐
</v-btn>
</v-col>
<v-col
cols="6"
sm="3"
>
<v-btn
block
color="error"
variant="flat"
prepend-icon="mdi-account-off"
@click="batchSetStatus('absent')"
>
全部请假
</v-btn>
</v-col>
<v-col
cols="6"
sm="3"
>
<v-btn
block
color="warning"
variant="flat"
prepend-icon="mdi-clock-alert"
@click="batchSetStatus('late')"
>
全部迟到
</v-btn>
</v-col>
<v-col
cols="6"
sm="3"
>
<v-btn
block
color="grey"
variant="flat"
prepend-icon="mdi-account-cancel"
@click="batchSetStatus('exclude')"
>
全部不参与
</v-btn>
</v-col>
</v-row>
</v-card>
</v-container>
</div>
</template>
<script>
import { useDisplay } from "vuetify";
import {getSetting} from "@/utils/settings.js";
import { getSetting } from "@/utils/settings.js";
export default {
name: "AttendanceSidebar",
@@ -100,14 +441,240 @@ export default {
type: Boolean,
default: false,
},
contentStyle: {
type: Object,
default: () => ({}),
},
inDialog: {
type: Boolean,
default: false,
},
compact: {
type: Boolean,
default: false,
},
},
emits: ["click", "disabled-click"],
emits: ["click", "disabled-click", "edit-list", "save"],
setup() {
const display = useDisplay();
return { display };
},
data() {
return {
scrollTimer: null,
scrollPosition: 0,
isScrollingUp: false,
isHovering: false,
search: "",
filterStatus: "all",
};
},
computed: {
stats() {
const total = this.studentList.length;
const excludeCount = this.attendance.exclude.length;
const absentCount = this.attendance.absent.length;
// 迟到也算实到
const expected = total - excludeCount;
const actual = total - absentCount - excludeCount;
return {
expected: Math.max(0, expected),
actual: Math.max(0, actual),
};
},
statusCounts() {
const total = this.studentList.length;
const absent = this.attendance.absent.length;
const late = this.attendance.late.length;
const exclude = this.attendance.exclude.length;
const present = total - absent - late - exclude;
return {
'全部': total,
'到课': present,
'请假': absent,
'迟到': late,
'不参与': exclude
};
},
isAllPresent() {
return (
this.attendance.absent.length === 0 &&
this.attendance.late.length === 0 &&
this.studentList.length > 0
);
},
filteredStudents() {
let result = this.studentList;
if (this.search) {
result = result.filter(student =>
student.toLowerCase().includes(this.search.toLowerCase())
);
}
if (this.filterStatus !== 'all') {
result = result.filter(student => {
const status = this.getStatus(student);
if (this.filterStatus === 'present') return status === null; // null means present (conceptually)
return status === this.filterStatus;
});
}
return result;
}
},
mounted() {
this.startAutoScroll();
},
beforeUnmount() {
this.stopAutoScroll();
},
methods: {
getSetting,
getStatusLabel(status) {
const map = {
'all': '全部',
'present': '到课',
'absent': '请假',
'late': '迟到',
'exclude': '不参与'
};
return map[status] || status;
},
getStatusIconByString(status) {
const map = {
'all': 'mdi-account-group',
'present': 'mdi-account-check',
'absent': 'mdi-account-off',
'late': 'mdi-clock-alert',
'exclude': 'mdi-account-cancel'
};
return map[status] || 'mdi-circle-small';
},
batchSetStatus(status) {
if (this.isEditingDisabled) {
this.$emit('disabled-click');
return;
}
// Clear all statuses first
this.attendance.absent = [];
this.attendance.late = [];
this.attendance.exclude = [];
if (status === null) {
// Everyone present, lists already cleared
} else if (status === 'absent') {
this.attendance.absent = [...this.studentList];
} else if (status === 'late') {
this.attendance.late = [...this.studentList];
} else if (status === 'exclude') {
this.attendance.exclude = [...this.studentList];
}
this.$emit('save');
},
getStatus(student) {
if (this.attendance.absent.includes(student)) return 'absent';
if (this.attendance.late.includes(student)) return 'late';
if (this.attendance.exclude.includes(student)) return 'exclude';
return null;
},
getStatusColor(student) {
const status = this.getStatus(student);
if (status === 'absent') return 'error';
if (status === 'late') return 'warning';
if (status === 'exclude') return 'grey';
return 'grey-lighten-1';
},
getStatusIcon(student) {
const status = this.getStatus(student);
if (status === 'absent') return 'mdi-account-off';
if (status === 'late') return 'mdi-clock-alert';
if (status === 'exclude') return 'mdi-account-cancel';
return 'mdi-account-check';
},
getStatusText(student) {
const status = this.getStatus(student);
if (status === 'absent') return '请假';
if (status === 'late') return '迟到';
if (status === 'exclude') return '不参与';
return '';
},
toggleStatus(student) {
if (this.isEditingDisabled) {
this.$emit('disabled-click');
return;
}
const currentStatus = this.getStatus(student);
let newStatus = null;
// Cycle: null -> absent -> late -> exclude -> null
if (currentStatus === null) newStatus = 'absent';
else if (currentStatus === 'absent') newStatus = 'late';
else if (currentStatus === 'late') newStatus = 'exclude';
else if (currentStatus === 'exclude') newStatus = null;
// Update attendance object locally (since it's passed by reference, but we should emit)
// Actually, since we are modifying the prop object directly (Vue 3 reactive), it might update the parent state.
// However, best practice is to emit an event or update the object carefully.
// The parent passes `state.boardData.attendance` which is reactive.
// Remove from all lists first
const remove = (arr, val) => {
const idx = arr.indexOf(val);
if (idx > -1) arr.splice(idx, 1);
};
remove(this.attendance.absent, student);
remove(this.attendance.late, student);
remove(this.attendance.exclude, student);
// Add to new list
if (newStatus === 'absent') this.attendance.absent.push(student);
else if (newStatus === 'late') this.attendance.late.push(student);
else if (newStatus === 'exclude') this.attendance.exclude.push(student);
this.$emit('save');
},
startAutoScroll() {
if (this.scrollTimer) return;
this.scrollTimer = setInterval(() => {
const el = this.$refs.scrollContainer;
if (!el || this.isHovering) return;
// 如果内容高度没有超过容器高度,不需要滚动
if (el.scrollHeight <= el.clientHeight) return;
if (this.isScrollingUp) {
el.scrollTop -= 1;
if (el.scrollTop <= 0) {
this.isScrollingUp = false;
// 触顶后暂停2秒再向下滚
clearInterval(this.scrollTimer);
this.scrollTimer = null;
setTimeout(() => this.startAutoScroll(), 2000);
}
} else {
el.scrollTop += 1;
// 检查是否到底部 (允许1px误差)
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 1) {
this.isScrollingUp = true;
// 触底后暂停2秒再向上滚
clearInterval(this.scrollTimer);
this.scrollTimer = null;
setTimeout(() => this.startAutoScroll(), 2000);
}
}
}, 50); // 50ms滚动一次,速度比较平缓
},
stopAutoScroll() {
if (this.scrollTimer) {
clearInterval(this.scrollTimer);
this.scrollTimer = null;
}
},
handleClick() {
if (this.isEditingDisabled) {
this.$emit('disabled-click');
@@ -120,5 +687,29 @@ export default {
</script>
<style scoped>
/* Add any specific styles here if needed, or rely on global styles */
.attendance-sidebar {
transition: transform 0.2s;
}
.attendance-sidebar:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.attendance-content {
position: relative;
}
.attendance-content.disabled {
cursor: not-allowed;
opacity: 0.7;
}
.gap-1 {
gap: 4px;
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.1); }
100% { transform: scale(1); }
}
.animate-pulse {
animation: pulse 2s infinite;
}
</style>
@@ -20,7 +20,7 @@
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer/>
<v-spacer />
<v-btn
v-if="showCancel"
variant="text"
+3 -7
View File
@@ -41,9 +41,7 @@
label="命名空间"
prepend-inner-icon="mdi-identifier"
variant="outlined"
>
</v-text-field>
/>
<v-text-field
v-model="form.password"
@@ -51,9 +49,7 @@
prepend-inner-icon="mdi-lock-outline"
type="text"
variant="outlined"
>
</v-text-field>
/>
<v-alert
v-if="error"
@@ -77,7 +73,7 @@
>
取消
</v-btn>
<v-spacer/>
<v-spacer />
<v-btn
:disabled="!form.namespace || authenticating"
:loading="authenticating"
+6 -8
View File
@@ -139,8 +139,6 @@
</div>
</div>
</v-card>
</div>
<!-- 步骤 3: 询问使用场景 -->
@@ -280,8 +278,8 @@
也可以手动前往 Classworks KV 控制台获取认证信息:
</div>
<v-card
:color=" kvserverurl=='https://kv.houlang.cloud'? 'primary' : 'error' "
:variant="kvserverurl=='https://kv.houlang.cloud'? 'elevated' : 'outlined'"
color="primary"
variant="elevated"
class="pa-6 mb-6"
@click="openKVSite"
>
@@ -293,13 +291,13 @@
</v-icon>
<h4 class="text-h6 font-weight-bold">
请访问 {{ kvserverurl=='https://kv.houlang.cloud'? 'Classworks KV' : '自定义的 Classworks KV 实例 ' }} 控制台
请访问 Classworks KV 控制台
</h4>
<div class="text-h5 mb-6">
{{ kvserverurl }}
{{ kvserverurl || '未配置' }}
</div>
<h6 class="text-subtitle-2">
{{ kvserverurl=='https://kv.houlang.cloud'? '此实例由 Classworks KV 官方提供' : '此链接由您的实例、预配代码或管理员管理,当前可能不是 Classworks KV 官方的实例地址。' }}
此链接由您的实例、预配代码或管理员管理。
</h6>
</v-card>
@@ -553,7 +551,7 @@
</v-icon>
上一步
</v-btn>
<v-spacer/>
<v-spacer />
<v-btn
v-if="currentStep < totalSteps && currentStep !== 4"
:disabled="currentStep === 3 && !storageType"
@@ -20,7 +20,7 @@
variant="tonal"
>
<template #prepend>
<v-icon icon="mdi-information"/>
<v-icon icon="mdi-information" />
</template>
系统将自动为您创建设备并获取访问令牌,无需手动配置
</v-alert>
@@ -52,7 +52,7 @@
variant="tonal"
>
<template #prepend>
<v-icon icon="mdi-check-circle"/>
<v-icon icon="mdi-check-circle" />
</template>
设备注册成功!已自动获取访问令牌
</v-alert>
@@ -60,7 +60,7 @@
<v-list>
<v-list-item>
<template #prepend>
<v-icon icon="mdi-identifier"/>
<v-icon icon="mdi-identifier" />
</template>
<v-list-item-title>设备名称</v-list-item-title>
<v-list-item-subtitle>{{ deviceInfo.deviceName }}</v-list-item-subtitle>
@@ -68,7 +68,7 @@
<v-list-item>
<template #prepend>
<v-icon icon="mdi-key"/>
<v-icon icon="mdi-key" />
</template>
<v-list-item-title>设备 UUID</v-list-item-title>
<v-list-item-subtitle class="font-mono text-caption">
@@ -83,7 +83,7 @@
variant="tonal"
>
<template #prepend>
<v-icon icon="mdi-information"/>
<v-icon icon="mdi-information" />
</template>
您可以点击下方按钮访问云端控制台来设置密码和管理高级功能
</v-alert>
@@ -97,7 +97,7 @@
variant="tonal"
>
<template #prepend>
<v-icon icon="mdi-alert-circle"/>
<v-icon icon="mdi-alert-circle" />
</template>
{{ errorMessage }}
</v-alert>
@@ -105,7 +105,7 @@
</v-card-text>
<v-card-actions>
<v-spacer/>
<v-spacer />
<!-- 注册按钮 -->
<v-btn
+1 -1
View File
@@ -21,7 +21,7 @@
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer/>
<v-spacer />
<v-btn
v-if="showCancel"
variant="text"
@@ -1,6 +1,14 @@
<template>
<div class="async-loading-placeholder d-flex justify-center align-center" :style="{ minHeight: height }">
<v-progress-circular indeterminate color="primary" size="28" width="2" />
<div
class="async-loading-placeholder d-flex justify-center align-center"
:style="{ minHeight: height }"
>
<v-progress-circular
indeterminate
color="primary"
size="28"
width="2"
/>
</div>
</template>
+4 -1
View File
@@ -1,6 +1,9 @@
<template>
<!-- 纯 CSS 骨架屏,避免使用 VSkeletonLoader 引起的渲染冲突 -->
<v-container class="main-window" fluid>
<v-container
class="main-window"
fluid
>
<div class="skeleton-grid">
<div
v-for="n in cardCount"
+30 -12
View File
@@ -1,15 +1,29 @@
<template>
<v-container class="fill-height">
<v-responsive class="align-centerfill-height mx-auto" max-width="900">
<v-img class="mb-4" height="150" src="@/assets/logo.svg"/>
<div class="text-center">
<div class="text-body-2 font-weight-light mb-n1">出现了错误</div>
<h1 class="text-h2 font-weight-bold">404</h1>
<v-responsive
class="align-centerfill-height mx-auto"
max-width="900"
>
<div class="text-center mb-4">
<v-icon
size="150"
color="primary"
>
mdi-alert-circle-outline
</v-icon>
</div>
<div class="py-4"/>
<div class="text-center">
<div class="text-body-2 font-weight-light mb-n1">
出现了错误
</div>
<h1 class="text-h2 font-weight-bold">
404
</h1>
</div>
<div class="py-4" />
<v-row>
<v-col cols="12">
@@ -21,15 +35,19 @@
variant="outlined"
>
<template #image>
<v-img position="top right"/>
<v-img position="top right" />
</template>
<template #title>
<h2 class="text-h5 font-weight-bold">为什么会出现此错误?</h2>
<h2 class="text-h5 font-weight-bold">
为什么会出现此错误?
</h2>
</template>
<template #subtitle>
<div class="text-subtitle-1">大概是页面未找到</div>
<div class="text-subtitle-1">
大概是页面未找到
</div>
</template>
<v-overlay
@@ -70,7 +88,7 @@
rounded="lg"
title="返回上一页"
variant="text"
@click="this.$router.back()"
@click="$router.back()"
>
<v-overlay
contained
+24 -6
View File
@@ -11,13 +11,19 @@
<span class="text-truncate">{{ exam?.examName || "加载中..." }}</span>
</v-card-title>
<v-card-text class="flex-grow-1 pa-4 overflow-y-auto" :style="contentStyle">
<div v-if="loading" class="d-flex justify-center align-center py-4">
<v-card-text
class="flex-grow-1 pa-4 overflow-y-auto"
:style="contentStyle"
>
<div
v-if="loading"
class="d-flex justify-center align-center py-4"
>
<v-progress-circular
indeterminate
size="24"
color="primary"
></v-progress-circular>
/>
</div>
<template v-else-if="exam">
@@ -26,7 +32,11 @@
</div>-->
<div class="d-flex flex-column">
<div v-for="(group, gIndex) in groupedExamInfos" :key="gIndex" class="mb-3">
<div
v-for="(group, gIndex) in groupedExamInfos"
:key="gIndex"
class="mb-3"
>
<div class="text-subtitle-2 font-weight-bold text-primary mb-1">
<RelativeTimeDisplay :time="group.date" />
</div>
@@ -39,7 +49,10 @@
'text-grey': isPast(info.end),
}"
>
<div class="font-weight-bold mr-2" style="font-size: 1.1em">
<div
class="font-weight-bold mr-2"
style="font-size: 1.1em"
>
{{ info.name }}
</div>
<div
@@ -54,7 +67,12 @@
</div>
</template>
<div v-else class="text-center text-caption text-grey py-2">无法加载</div>
<div
v-else
class="text-center text-caption text-grey py-2"
>
无法加载
</div>
</v-card-text>
</v-card>
</template>
+70 -18
View File
@@ -1,15 +1,35 @@
<template>
<v-card class="fill-height d-flex flex-column rounded-xl" elevation="2">
<v-card
class="fill-height d-flex flex-column rounded-xl"
elevation="2"
>
<v-card-title class="d-flex align-center py-3 px-4 bg-primary text-white">
<v-icon class="mr-2">mdi-calendar-clock</v-icon>
<v-icon class="mr-2">
mdi-calendar-clock
</v-icon>
<span class="text-truncate">{{ exam?.examName || '加载中...' }}</span>
<v-spacer></v-spacer>
<v-btn icon="mdi-close" variant="text" density="comfortable" @click="$emit('close')"></v-btn>
<v-spacer />
<v-btn
icon="mdi-close"
variant="text"
density="comfortable"
@click="$emit('close')"
/>
</v-card-title>
<v-card-text class="flex-grow-1 pa-4 overflow-y-auto" :style="contentStyle">
<div v-if="loading" class="d-flex justify-center align-center fill-height" style="min-height: 200px;">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
<v-card-text
class="flex-grow-1 pa-4 overflow-y-auto"
:style="contentStyle"
>
<div
v-if="loading"
class="d-flex justify-center align-center fill-height"
style="min-height: 200px;"
>
<v-progress-circular
indeterminate
color="primary"
/>
</div>
<template v-else-if="exam">
@@ -24,12 +44,24 @@
{{ exam.message }}
</v-alert>
<v-list density="comfortable" class="pa-0 bg-transparent">
<v-list-item v-for="(info, index) in exam.examInfos" :key="index" class="px-0 mb-3">
<v-list
density="comfortable"
class="pa-0 bg-transparent"
>
<v-list-item
v-for="(info, index) in exam.examInfos"
:key="index"
class="px-0 mb-3"
>
<template #prepend>
<v-avatar color="primary" variant="tonal" size="large" class="mr-3 font-weight-bold elevation-1">
{{ info.name.charAt(0) }}
</v-avatar>
<v-avatar
color="primary"
variant="tonal"
size="large"
class="mr-3 font-weight-bold elevation-1"
>
{{ info.name.charAt(0) }}
</v-avatar>
</template>
<v-list-item-title class="font-weight-bold text-h6 mb-1">
@@ -38,20 +70,40 @@
<v-list-item-subtitle class="text-body-1">
<div class="d-flex align-center mb-1">
<v-icon size="small" color="success" class="mr-2">mdi-clock-start</v-icon>
<span class="font-weight-medium">{{ formatTime(info.start) }}</span>
<v-icon
size="small"
color="success"
class="mr-2"
>
mdi-clock-start
</v-icon>
<span class="font-weight-medium">{{ formatTime(info.start) }}</span>
</div>
<div class="d-flex align-center">
<v-icon size="small" color="error" class="mr-2">mdi-clock-end</v-icon>
<span class="font-weight-medium">{{ formatTime(info.end) }}</span>
<v-icon
size="small"
color="error"
class="mr-2"
>
mdi-clock-end
</v-icon>
<span class="font-weight-medium">{{ formatTime(info.end) }}</span>
</div>
</v-list-item-subtitle>
</v-list-item>
</v-list>
</template>
<div v-else class="d-flex flex-column align-center justify-center fill-height text-grey mt-4">
<v-icon size="large" class="mb-2">mdi-alert-circle-outline</v-icon>
<div
v-else
class="d-flex flex-column align-center justify-center fill-height text-grey mt-4"
>
<v-icon
size="large"
class="mb-2"
>
mdi-alert-circle-outline
</v-icon>
无法加载考试信息
</div>
</v-card-text>
+11 -2
View File
@@ -10,7 +10,12 @@
>
上传
</v-btn>
<v-btn v-else color="success" size="large" @click="$emit('show-sync-message')">
<v-btn
v-else
color="success"
size="large"
@click="$emit('show-sync-message')"
>
同步完成
</v-btn>
<v-btn
@@ -86,7 +91,11 @@
variant="tonal"
>
<v-card-title class="text-subtitle-1">
<v-icon icon="mdi-shield-check" size="small" start />
<v-icon
icon="mdi-shield-check"
size="small"
start
/>
屏幕保护技术已启用
</v-card-title>
<v-card-text class="text-body-2">
+279 -123
View File
@@ -1,5 +1,8 @@
<template>
<div ref="gridContainer" class="grid-masonry">
<div
ref="gridContainer"
class="grid-masonry"
>
<TransitionGroup name="grid">
<div
v-for="item in sortedItems"
@@ -12,98 +15,27 @@
class="grid-item"
>
<!-- 时间卡片 -->
<div v-if="item.type === 'time'" style="height: 100%">
<div
v-if="item.type === 'time'"
style="height: 100%"
>
<time-card />
</div>
<!-- 一言卡片 -->
<div v-else-if="item.type === 'hitokoto'" style="height: 100%">
<div
v-else-if="item.type === 'hitokoto'"
style="height: 100%"
>
<hitokoto-card />
</div>
<!-- 考试卡片 -->
<div v-else-if="item.type === 'exam'" style="height: 100%">
<concise-exam-card
:exam-id="item.data.examId"
:content-style="contentStyle"
@click="$emit('open-exam-detail', item.data.examId)"
/>
</div>
<!-- 出勤卡片 -->
<v-card
v-else-if="item.type === 'attendance'"
:class="{ 'glow-highlight': highlightedCards[item.key], 'cursor-not-allowed': isEditingDisabled, 'cursor-pointer': !isEditingDisabled }"
border
class="glow-track"
height="100%"
@click="handleCardClick('attendance', null)"
@mousemove="handleMouseMove"
@touchmove="handleTouchMove"
>
<v-card-title class="d-flex align-center">
<v-icon class="mr-2" color="primary" icon="mdi-account-group" />
出勤统计
</v-card-title>
<v-card-text>
<div class="d-flex justify-space-between align-center mb-2">
<span>应到/实到</span>
<span class="text-h6">
{{ item.data.total - item.data.exclude.length }}/{{
item.data.total -
item.data.absent.length -
(!getSetting("display.lateStudentsArePresent")) * item.data.late.length -
item.data.exclude.length
}}
</span>
</div>
<v-divider class="mb-2" />
<div v-if="item.data.absent.length > 0" class="mb-2">
<div class="text-error text-caption mb-1">请假 ({{ item.data.absent.length }})</div>
<div class="d-flex flex-wrap" style="gap: 4px">
<v-chip v-for="name in item.data.absent" :key="name" color="error" size="x-small" variant="flat">
{{ name }}
</v-chip>
</div>
</div>
<div v-if="item.data.late.length > 0" class="mb-2">
<div class="text-warning text-caption mb-1">迟到 ({{ item.data.late.length }})</div>
<div class="d-flex flex-wrap" style="gap: 4px">
<v-chip v-for="name in item.data.late" :key="name" color="warning" size="x-small" variant="flat">
{{ name }}
</v-chip>
</div>
</div>
<div v-if="item.data.exclude.length > 0" class="mb-2">
<div class="text-grey text-caption mb-1">不参与 ({{ item.data.exclude.length }})</div>
<div class="d-flex flex-wrap" style="gap: 4px">
<v-chip v-for="name in item.data.exclude" :key="name" color="grey" size="x-small" variant="flat">
{{ name }}
</v-chip>
</div>
</div>
<div
v-if="
item.data.absent.length === 0 &&
item.data.late.length === 0 &&
item.data.exclude.length === 0
"
class="text-success text-center mt-2"
>
全勤
</div>
</v-card-text>
</v-card>
<!-- 自定义/测试卡片 -->
<v-card
v-else-if="item.type === 'custom'"
:class="{ 'glow-highlight': highlightedCards[item.key], 'cursor-not-allowed': isEditingDisabled, 'cursor-pointer': !isEditingDisabled }"
border
variant="outlined"
color="grey-darken-3"
class="glow-track"
height="100%"
@click="handleCardClick('dialog', item.key)"
@@ -111,7 +43,11 @@
@touchmove="handleTouchMove"
>
<v-card-title class="text-primary">
<v-icon class="mr-2" icon="mdi-card-text-outline" size="small" />
<v-icon
class="mr-2"
icon="mdi-card-text-outline"
size="small"
/>
{{ item.name }}
</v-card-title>
<v-card-text :style="contentStyle">
@@ -123,23 +59,96 @@
<v-card
v-else
:class="{ 'glow-highlight': highlightedCards[item.key], 'cursor-not-allowed': isEditingDisabled, 'cursor-pointer': !isEditingDisabled }"
border
variant="outlined"
color="grey-darken-3"
class="glow-track"
height="100%"
style="border-radius: 6px !important"
@click="handleCardClick('dialog', item.key)"
@mousemove="handleMouseMove"
@touchmove="handleTouchMove"
>
<v-card-title>{{ item.name }}</v-card-title>
<v-card-text :style="contentStyle">
<v-list>
<v-list-item
v-for="text in splitPoint(item.content)"
:key="text"
<v-card-title
class="text-white"
:style="titleStyle"
>
{{ item.name }}
</v-card-title>
<v-card-text
:style="contentStyle"
class="text-white"
>
<div
v-for="(line, index) in splitPoint(item.content)"
:key="index"
class="d-flex align-start flex-wrap mb-1"
>
<span class="mr-2 item-line-text">{{ line.text }}</span>
<!-- Submission Time -->
<v-chip
v-if="line.submissionTime"
size="small"
color="info"
variant="flat"
prepend-icon="mdi-clock-outline"
class="mr-1"
:style="{ fontSize: '0.65em !important', height: '1.6em !important', padding: '0 0.5em !important' }"
>
{{ text }}
</v-list-item>
</v-list>
{{ formatSubmissionTime(line.submissionTime).replace('上交|', '') }}
<span
class="text-caption ml-1 font-weight-bold"
style="font-size: 0.8em !important; opacity: 0.7;"
>上交</span>
</v-chip>
<!-- Submit To -->
<v-chip
v-if="line.submitTo"
size="small"
color="primary"
variant="flat"
prepend-icon="mdi-account"
class="mr-1"
:style="{ fontSize: '0.65em !important', height: '1.6em !important', padding: '0 0.5em !important' }"
>
交给: {{ line.submitTo }}
</v-chip>
<v-chip
v-if="line.optional"
size="small"
color="orange"
variant="flat"
prepend-icon="mdi-star-outline"
class="mr-1 text-white"
:style="{ fontSize: '0.65em !important', height: '1.6em !important', padding: '0 0.5em !important' }"
>
选做
</v-chip>
<!-- Not Submit Button -->
<v-chip
v-if="line.notSubmit"
size="small"
color="warning"
variant="flat"
class="mr-1"
:style="{ fontSize: '0.65em !important', height: '1.6em !important', padding: '0 0.5em !important' }"
>
不上交
</v-chip>
<!-- Self Correction -->
<v-chip
v-if="line.selfCorrection"
size="small"
color="success"
variant="flat"
:style="{ fontSize: '0.65em !important', height: '1.6em !important', padding: '0 0.5em !important' }"
>
自批
</v-chip>
</div>
</v-card-text>
</v-card>
</div>
@@ -149,7 +158,10 @@
<!-- 单独显示空科目 -->
<div class="empty-subjects mt-4">
<!-- 移动端优化视图 -->
<div v-if="isMobile" class="d-flex flex-wrap justify-center">
<div
v-if="isMobile"
class="d-flex flex-wrap justify-center"
>
<v-chip
v-for="subject in unusedSubjects"
:key="subject.name"
@@ -158,7 +170,10 @@
variant="tonal"
@click="handleCardClick('dialog', subject.name)"
>
<v-icon start size="small">
<v-icon
start
size="small"
>
{{ isReadOnlyToken ? 'mdi-cancel' : 'mdi-plus' }}
</v-icon>
{{ subject.name }}
@@ -166,7 +181,10 @@
</div>
<template v-else-if="emptySubjectDisplay === 'button'">
<v-btn-group divided variant="tonal">
<v-btn-group
divided
variant="tonal"
>
<v-btn
v-for="subject in unusedSubjects"
:key="subject.name"
@@ -179,26 +197,44 @@
</v-btn>
</v-btn-group>
</template>
<div v-else class="empty-subjects-grid">
<div
v-else
class="empty-subjects-grid"
>
<TransitionGroup name="v-list">
<v-card
v-for="subject in unusedSubjects"
:key="subject.name"
border
style="border-radius: 6px !important"
class="empty-subject-card"
@click="handleCardClick('dialog', subject.name)"
>
<v-card-title class="text-subtitle-1">
<v-card-title class="text-subtitle-1 text-white">
{{ subject.name }}
</v-card-title>
<v-card-text class="text-center">
<template v-if="isReadOnlyToken">
<v-icon color="grey" size="small"> mdi-cancel </v-icon>
<div class="text-caption text-grey"> 当日无作业 </div>
<v-icon
color="white"
size="small"
>
mdi-cancel
</v-icon>
<div class="text-caption text-white">
当日无作业
</div>
</template>
<template v-else>
<v-icon color="grey" size="small"> mdi-plus </v-icon>
<div class="text-caption text-grey"> 点击添加作业 </div>
<v-icon
color="white"
size="small"
>
mdi-plus
</v-icon>
<div class="text-caption text-white">
点击添加作业
</div>
</template>
</v-card-text>
</v-card>
@@ -210,20 +246,13 @@
<script>
import HitokotoCard from "@/components/HitokotoCard.vue";
import TimeCard from "@/components/TimeCard.vue";
import ConciseExamCard from "@/components/home/ConciseExamCard.vue";
import {getSetting} from "@/utils/settings.js";
export default {
name: "HomeworkGrid",
computed: {
settings() {
return settings
}
},
components: {
HitokotoCard,
TimeCard,
ConciseExamCard,
},
props: {
sortedItems: {
@@ -250,23 +279,25 @@ export default {
type: Object,
default: () => ({}),
},
titleStyle: {
type: Object,
default: () => ({}),
},
highlightedCards: {
type: Object,
default: () => ({}),
},
},
emits: ["open-dialog", "open-attendance", "disabled-click"],
emits: ["open-dialog", "disabled-click"],
data() {
return {
isReadOnlyToken: false,
}
},
async mounted() {
/* eslint-disable no-undef */
this.resizeObserver = new ResizeObserver(() => {
this.resizeObserver = new window.ResizeObserver(() => {
this.resizeAllGridItems();
});
/* eslint-enable no-undef */
// Observe the grid container for width changes
if (this.$refs.gridContainer) {
@@ -309,7 +340,9 @@ export default {
}
},
methods: {
getSetting,
getSetting(key) {
return getSetting(key);
},
async checkReadOnlyStatus() {
// 尝试获取父组件中的StudentNameManager引用
try {
@@ -327,8 +360,6 @@ export default {
this.isReadOnlyToken = manager.isReadOnly;
} else {
// 如果无法直接访问manager,尝试通过全局设置获取token信息
// 这里需要使用utils/settings中的函数
const { getSetting } = await import('@/utils/settings');
const token = getSetting('server.kvToken');
if (token) {
@@ -361,8 +392,11 @@ export default {
const grid = this.$refs.gridContainer;
if (!grid) return;
const rowHeight = parseInt(window.getComputedStyle(grid).getPropertyValue('grid-auto-rows'));
const rowGap = parseInt(window.getComputedStyle(grid).getPropertyValue('gap'));
const rowHeightVal = window.getComputedStyle(grid).getPropertyValue('grid-auto-rows');
const rowGapVal = window.getComputedStyle(grid).getPropertyValue('gap');
const rowHeight = parseInt(rowHeightVal) || 1;
const rowGap = parseInt(rowGapVal) || 16;
// Find the content element (v-card or div)
const content = item.firstElementChild;
@@ -372,11 +406,20 @@ export default {
// We use scrollHeight to get the full height of content
// Add a small buffer to prevent scrollbars
const contentHeight = content.getBoundingClientRect().height;
// If content height is 0, it might not be rendered yet.
// Set a min height to ensure visibility if data is present
if (contentHeight === 0 && item.dataset.key) {
// Fallback for initial render
}
// Formula: span = ceil((contentHeight + gap) / (rowHeight + gap))
const rowSpan = Math.ceil((contentHeight + rowGap) / (rowHeight + rowGap));
item.style.gridRowEnd = `span ${rowSpan}`;
// Ensure minimum span
const finalSpan = Math.max(rowSpan, 10);
item.style.gridRowEnd = `span ${finalSpan}`;
},
resizeAllGridItems() {
const items = this.$refs.items;
@@ -390,14 +433,123 @@ export default {
return;
}
if (type === 'attendance') {
this.$emit('open-attendance');
} else if (type === 'dialog') {
if (type === 'dialog') {
this.$emit('open-dialog', key);
}
},
splitPoint(content) {
return content.split("\n").filter((text) => text.trim());
if (typeof content !== 'string') return [];
const normalized = content.replace(/\r\n/g, "\n");
const blocks = /\n\s*\n/.test(normalized)
? normalized.split(/\n\s*\n+/)
: normalized.split("\n");
const items = blocks.map((t) => t.trim()).filter(Boolean);
const showIndex = items.length > 1;
return items.map((text, index) => {
let processedText = text;
let submissionTime = null;
let submitTo = null;
let selfCorrection = false;
let optional = false;
let notSubmit = false;
// Match [YYYY-MM-DD]
const timeMatch = processedText.match(/\[(\d{4}-\d{2}-\d{2})\]/);
if (timeMatch) {
submissionTime = timeMatch[1];
processedText = processedText.replace(timeMatch[0], '');
}
// Match [交给:XXX]
const submitToMatch = processedText.match(/\[交给:(.*?)\]/);
if (submitToMatch) {
submitTo = submitToMatch[1];
processedText = processedText.replace(submitToMatch[0], '');
}
// Match [自批]
const selfCorrectMatch = processedText.match(/\[自批\]/);
if (selfCorrectMatch) {
selfCorrection = true;
processedText = processedText.replace(selfCorrectMatch[0], '');
}
const optionalMatch = processedText.match(/\[选做\]/);
if (optionalMatch) {
optional = true;
processedText = processedText.replace(optionalMatch[0], '');
}
// Match [不上交]
const notSubmitMatch = processedText.match(/\[不上交\]/);
if (notSubmitMatch) {
notSubmit = true;
processedText = processedText.replace(notSubmitMatch[0], '');
}
// 添加序号
const displayText = showIndex
? `${index + 1}. ${processedText.trim()}`
: processedText.trim();
return {
text: displayText,
submissionTime,
submitTo,
selfCorrection,
optional,
notSubmit
};
});
},
formatSubmissionTime(timeStr) {
if (!timeStr) return '';
const date = new Date(timeStr);
if (isNaN(date.getTime())) return timeStr;
const today = new Date();
// Reset time parts for comparison
today.setHours(0,0,0,0);
const targetDate = new Date(date);
targetDate.setHours(0,0,0,0);
const diffTime = targetDate - today;
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 0) return '上交|今天';
if (diffDays === 1) return '上交|明天';
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
const targetDay = weekdays[targetDate.getDay()];
// Calculate week difference
// Get the Monday of the current week
const currentMonday = new Date(today);
const day = currentMonday.getDay() || 7; // Get current day number, converting Sun (0) to 7
if (day !== 1) currentMonday.setHours(-24 * (day - 1));
// Get the Monday of the target date's week
const targetMonday = new Date(targetDate);
const tDay = targetMonday.getDay() || 7;
if (tDay !== 1) targetMonday.setHours(-24 * (tDay - 1));
const weekDiff = Math.round((targetMonday - currentMonday) / (7 * 24 * 60 * 60 * 1000));
if (weekDiff === 0) {
return `上交|${targetDay}`;
} else if (weekDiff === 1) {
return `上交|下${targetDay}`;
} else if (weekDiff === 2) {
return `上交|下下${targetDay}`;
} else {
const m = targetDate.getMonth() + 1;
const d = targetDate.getDate();
return `上交|${m}.${d}`;
}
},
handleMouseMove(e) {
const card = e.currentTarget;
@@ -434,4 +586,8 @@ export default {
.v-card.cursor-not-allowed:hover {
transform: none !important;
}
.item-line-text {
white-space: pre-line;
}
</style>
+124 -81
View File
@@ -1,15 +1,29 @@
<template>
<v-card border hover rounded="xl">
<v-card
border
hover
rounded="xl"
>
<v-card-item>
<template #prepend>
<v-icon class="mr-2" icon="mdi-information" size="large"/>
<v-icon
class="mr-2"
icon="mdi-information"
size="large"
/>
</template>
<v-card-title class="text-h6">关于</v-card-title>
<v-card-title class="text-h6">
关于
</v-card-title>
</v-card-item>
<v-card-text>
<v-row>
<v-col class="mx-auto" cols="12" md="8">
<v-col
class="mx-auto"
cols="12"
md="8"
>
<!-- 捐赠卡片 -->
<v-card
border
@@ -24,8 +38,9 @@
<v-card-item>
<div class="card-content">
<div>
<div class="text-h6 font-weight-bold">请支持我们 Classworks</div>
<div class="text-h6 font-weight-bold">
请支持我们 Classworks
</div>
</div>
</div>
</v-card-item>
@@ -50,15 +65,22 @@
</v-card>
<div class="d-flex flex-column align-start">
<v-avatar class="mb-4" size="120">
<v-img
alt="Classworks"
src="../../assets/cslogo.png"
/>
<v-avatar
class="mb-4"
color="primary"
size="120"
>
<v-icon size="80">
mdi-school
</v-icon>
</v-avatar>
<h2 class="text-h5 mb-2">Classworks</h2>
<p class="text-body-1 mb-4">适用于班级大屏的作业板小工具</p>
<h2 class="text-h5 mb-2">
Classworks
</h2>
<p class="text-body-1 mb-4">
适用于班级大屏的作业板小工具
</p>
<div class="d-flex gap-2 flex-wrap mb-6">
<v-btn
@@ -96,9 +118,11 @@
</v-btn>
</div>
<v-divider class="mb-4 w-100"></v-divider>
<v-divider class="mb-4 w-100" />
<h3 class="text-h6 mb-2">备注与致谢</h3>
<h3 class="text-h6 mb-2">
备注与致谢
</h3>
<v-list class="mb-4 bg-transparent">
<v-list-item
append-icon="mdi-link"
@@ -113,31 +137,7 @@
等人的贡献
</v-list-item-subtitle>
</v-list-item>
<v-list-item
append-icon="mdi-link"
href="https://hlyun.org"
target="_blank"
>
<v-list-item-title>
Classworks 由<strong>厚浪云</strong>提供
</v-list-item-title>
<v-list-item-subtitle>
长江后浪推前浪 浮事新人换旧人
</v-list-item-subtitle>
</v-list-item>
<v-list-item
append-icon="mdi-link"
href="https://zerocat.houlangs.com"
target="_blank"
>
<v-list-item-title>
感谢 ZeroCat 社区的开发者们
</v-list-item-title>
<v-list-item-subtitle>
新一代,开源,编程社区
</v-list-item-subtitle>
</v-list-item>
<v-divider class="ma-1"></v-divider>
<v-divider class="ma-1" />
<v-list-item
append-icon="mdi-link"
href="https://github.com/HUSX100/IslandCaller"
@@ -180,12 +180,14 @@
fullscreen
transition="dialog-bottom-transition"
>
<v-card
>
<v-card>
<v-toolbar>
<v-btn icon="mdi-close" @click="showDeps = false"></v-btn>
<v-btn
icon="mdi-close"
@click="showDeps = false"
/>
<v-toolbar-title>使用的第三方库</v-toolbar-title>
<v-spacer></v-spacer>
<v-spacer />
</v-toolbar>
<v-card-text>
<v-list>
@@ -209,57 +211,108 @@
</v-dialog>
<!-- 报告问题对话框 -->
<v-dialog v-model="showReportDialog" max-width="640">
<v-dialog
v-model="showReportDialog"
max-width="640"
>
<v-card>
<v-toolbar density="compact">
<v-btn icon="mdi-close" @click="showReportDialog = false"></v-btn>
<v-btn
icon="mdi-close"
@click="showReportDialog = false"
/>
<v-toolbar-title>报告问题</v-toolbar-title>
<v-spacer></v-spacer>
<v-spacer />
</v-toolbar>
<v-card-text>
<p class="mb-4">
调试ID与下方的浏览器环境信息将帮助我们快速定位问题,请在反馈中一并附上。
</p>
<v-sheet class="mb-3 pa-3 bg-grey-lighten-4 rounded" style="max-height: 260px; overflow: auto;">
<pre class="text-body-2" style="white-space: pre-wrap; margin: 0;">{{ envBoxText }}</pre>
<v-sheet
class="mb-3 pa-3 bg-grey-lighten-4 rounded"
style="max-height: 260px; overflow: auto;"
>
<pre
class="text-body-2"
style="white-space: pre-wrap; margin: 0;"
>{{ envBoxText }}</pre>
</v-sheet>
<div class="d-flex gap-2 flex-wrap mb-4">
<v-btn size="small" variant="text" prepend-icon="mdi-refresh" @click="reloadVisitorId" :loading="visitorLoading">刷新</v-btn>
<v-btn size="small" variant="text" prepend-icon="mdi-content-copy" @click="copyEnvInfo">复制信息</v-btn>
<v-btn size="small" variant="text" prepend-icon="mdi-open-in-new" @click="goToDebug">查看 /debug 页面</v-btn>
</div>
<v-alert v-if="copyOk" type="success" density="compact" class="mb-4">已复制到剪贴板</v-alert>
<div class="d-flex gap-2 mb-4">
<v-btn
size="small"
color="primary"
variant="elevated"
prepend-icon="mdi-message-alert"
@click="openFeedback"
block
variant="text"
prepend-icon="mdi-refresh"
:loading="visitorLoading"
@click="reloadVisitorId"
>
发送错误反馈到 Sentry
刷新
</v-btn>
<v-btn
size="small"
variant="text"
prepend-icon="mdi-content-copy"
@click="copyEnvInfo"
>
复制信息
</v-btn>
<v-btn
size="small"
variant="text"
prepend-icon="mdi-open-in-new"
@click="goToDebug"
>
查看 /debug 页面
</v-btn>
</div>
<h4 class="text-subtitle-1 mb-2">反馈渠道</h4>
<v-list lines="one" class="bg-transparent">
<v-list-item :href="qqGroupLink" target="_blank" prepend-icon="mdi-qqchat">
<v-alert
v-if="copyOk"
type="success"
density="compact"
class="mb-4"
>
已复制到剪贴板
</v-alert>
<h4 class="text-subtitle-1 mb-2">
反馈渠道
</h4>
<v-list
lines="one"
class="bg-transparent"
>
<v-list-item
:href="qqGroupLink"
target="_blank"
prepend-icon="mdi-qqchat"
>
<v-list-item-title>QQ群 ({{ qqGroupNumber }})</v-list-item-title>
<v-list-item-subtitle>964979747</v-list-item-subtitle>
</v-list-item>
<v-list-item :href="githubIssueUrl" target="_blank" prepend-icon="mdi-github">
<v-list-item
:href="githubIssueUrl"
target="_blank"
prepend-icon="mdi-github"
>
<v-list-item-title>GitHub Issue</v-list-item-title>
<v-list-item-subtitle>ZeroCatDev/Classworks</v-list-item-subtitle>
</v-list-item>
<v-list-item :href="mailtoLink" target="_blank" prepend-icon="mdi-email">
<v-list-item
:href="mailtoLink"
target="_blank"
prepend-icon="mdi-email"
>
<v-list-item-title>邮件</v-list-item-title>
<v-list-item-subtitle>sun@wuyuan.dev</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn variant="text" @click="showReportDialog = false">关闭</v-btn>
<v-spacer />
<v-btn
variant="text"
@click="showReportDialog = false"
>
关闭
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
@@ -364,17 +417,17 @@ export default {
const nav = navigator || {};
const intl = (typeof Intl !== 'undefined' && Intl.DateTimeFormat) ? Intl.DateTimeFormat().resolvedOptions() : {};
const tz = intl && intl.timeZone ? intl.timeZone : '';
const routePath = router.currentRoute?.value?.fullPath || location.pathname;
const routePath = router.currentRoute?.value?.fullPath || window.location.pathname;
const lines = [
`App 版本: v${packageJson?.version || 'unknown'}`,
`URL: ${location.href}`,
`URL: ${window.location.href}`,
`路由: ${routePath}`,
`UserAgent: ${nav.userAgent || ''}`,
`语言: ${nav.language || ''}`,
`时区: ${tz}`,
`平台: ${nav.platform || ''}`,
`在线: ${String(nav.onLine)}`,
`屏幕: ${screen?.width || '-'}x${screen?.height || '-'}`,
`屏幕: ${window.screen?.width || '-'}x${window.screen?.height || '-'}`,
`视口: ${window.innerWidth || '-'}x${window.innerHeight || '-'}`,
];
return lines.join('\n');
@@ -408,15 +461,6 @@ export default {
return `mailto:sun@wuyuan.dev?subject=${subject}&body=${body}`;
});
const openFeedback = () => {
// 打开反馈对话框
if (typeof window.openSentryFeedback === 'function') {
window.openSentryFeedback();
} else {
console.warn('Sentry Feedback 功能不可用');
}
};
const openDonationLink = () => {
window.open('https://afdian.com/a/wydev', '_blank');
};
@@ -438,7 +482,6 @@ export default {
reloadVisitorId,
openReportDialog,
copyEnvInfo,
openFeedback,
openDonationLink,
envBoxText,
envInfo,
@@ -1,17 +1,33 @@
<template>
<v-dialog v-model="dialog" max-width="600" scrollable>
<v-dialog
v-model="dialog"
max-width="600"
scrollable
>
<v-card>
<v-card-title>迁移到云端</v-card-title>
<v-card-text style="height: 400px;">
<div v-if="loading" class="d-flex justify-center align-center fill-height">
<v-progress-circular indeterminate color="primary"></v-progress-circular>
<div
v-if="loading"
class="d-flex justify-center align-center fill-height"
>
<v-progress-circular
indeterminate
color="primary"
/>
</div>
<div v-else-if="keys.length === 0" class="d-flex justify-center align-center fill-height">
<div
v-else-if="keys.length === 0"
class="d-flex justify-center align-center fill-height"
>
没有找到本地数据
</div>
<div v-else>
<!-- Category Selection -->
<v-list select-strategy="classic" class="mb-4">
<v-list
select-strategy="classic"
class="mb-4"
>
<v-list-subheader>选择数据类型</v-list-subheader>
<v-list-item
@@ -19,32 +35,38 @@
:key="category.id"
@click="toggleCategory(category)"
>
<template v-slot:prepend>
<template #prepend>
<v-checkbox-btn
:model-value="getCategoryState(category)"
:indeterminate="getCategoryIndeterminate(category)"
@click.stop="toggleCategory(category)"
></v-checkbox-btn>
/>
</template>
<v-list-item-title>{{ category.label }}</v-list-item-title>
<v-list-item-subtitle>{{ category.description }} ({{ getCategoryCount(category) }} 项)</v-list-item-subtitle>
</v-list-item>
</v-list>
<v-divider class="mb-4"></v-divider>
<v-divider class="mb-4" />
<!-- Individual Keys Expansion -->
<v-expansion-panels>
<v-expansion-panel title="详细数据列表">
<v-expansion-panel-text>
<v-list select-strategy="classic" density="compact">
<v-list
select-strategy="classic"
density="compact"
>
<v-list-item
v-for="key in keys"
:key="key"
:value="key"
>
<template v-slot:prepend>
<v-checkbox-btn v-model="selectedKeys" :value="key"></v-checkbox-btn>
<template #prepend>
<v-checkbox-btn
v-model="selectedKeys"
:value="key"
/>
</template>
<v-list-item-title>{{ key }}</v-list-item-title>
</v-list-item>
@@ -54,14 +76,24 @@
</v-expansion-panels>
</div>
</v-card-text>
<v-divider></v-divider>
<v-divider />
<v-card-actions>
<div class="text-caption ml-4 text-medium-emphasis">
已选择 {{ selectedKeys.length }} 项
</div>
<v-spacer></v-spacer>
<v-btn variant="text" @click="dialog = false">取消</v-btn>
<v-btn color="primary" @click="migrate" :loading="migrating" :disabled="selectedKeys.length === 0">
<v-spacer />
<v-btn
variant="text"
@click="dialog = false"
>
取消
</v-btn>
<v-btn
color="primary"
:loading="migrating"
:disabled="selectedKeys.length === 0"
@click="migrate"
>
开始迁移
</v-btn>
</v-card-actions>
@@ -69,7 +101,10 @@
</v-dialog>
<!-- Result Dialog -->
<v-dialog v-model="resultDialog" max-width="500">
<v-dialog
v-model="resultDialog"
max-width="500"
>
<v-card>
<v-card-title>迁移结果</v-card-title>
<v-card-text>
@@ -79,12 +114,19 @@
<p>失败: {{ result.summary.failed }}</p>
</div>
<div v-else-if="error">
<p class="text-error">{{ error }}</p>
<p class="text-error">
{{ error }}
</p>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" @click="resultDialog = false">关闭</v-btn>
<v-spacer />
<v-btn
color="primary"
@click="resultDialog = false"
>
关闭
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
@@ -15,7 +15,9 @@
@click:close="showAutoplayWarning = false"
>
<div class="d-flex align-center">
<v-icon class="mr-2">mdi-information</v-icon>
<v-icon class="mr-2">
mdi-information
</v-icon>
<span>首次使用请点击试听按钮测试音频播放是否正常</span>
</div>
</v-alert>
@@ -25,7 +27,9 @@
<v-col cols="12">
<v-card border>
<v-card-title class="text-subtitle-1">
<v-icon left>mdi-bell-ring</v-icon>
<v-icon left>
mdi-bell-ring
</v-icon>
单次通知铃声
</v-card-title>
<v-card-text>
@@ -66,13 +70,17 @@
variant="tonal"
@click="previewSound(singleSound)"
>
<v-icon left>mdi-play</v-icon>
<v-icon left>
mdi-play
</v-icon>
试听
</v-btn>
<v-btn
@click="resetSingleSound"
>
<v-icon left>mdi-restore</v-icon>
<v-icon left>
mdi-restore
</v-icon>
恢复
</v-btn>
</div>
@@ -84,7 +92,12 @@
<v-col cols="12">
<v-card border>
<v-card-title class="text-subtitle-1">
<v-icon left color="error">mdi-bell-alert</v-icon>
<v-icon
left
color="error"
>
mdi-bell-alert
</v-icon>
紧急通知铃声
</v-card-title>
<v-card-text>
@@ -125,13 +138,17 @@
variant="tonal"
@click="previewSound(urgentSound)"
>
<v-icon left>mdi-play</v-icon>
<v-icon left>
mdi-play
</v-icon>
试听
</v-btn>
<v-btn
@click="resetUrgentSound"
>
<v-icon left>mdi-restore</v-icon>
<v-icon left>
mdi-restore
</v-icon>
恢复
</v-btn>
</div>
@@ -145,7 +162,7 @@
<script>
import { getSetting, setSetting } from '@/utils/settings.js';
import { soundFiles, stopSound } from '@/utils/soundList.js';
import { soundFiles, stopSound, defaultSingleSound, defaultUrgentSound } from '@/utils/soundList.js';
export default {
name: 'NotificationSoundSettings',
@@ -175,6 +192,14 @@ export default {
loadSettings() {
this.singleSound = getSetting('notification.singleSound');
this.urgentSound = getSetting('notification.urgentSound');
if (!soundFiles.includes(this.singleSound)) {
this.singleSound = defaultSingleSound;
setSetting('notification.singleSound', this.singleSound);
}
if (!soundFiles.includes(this.urgentSound)) {
this.urgentSound = defaultUrgentSound;
setSetting('notification.urgentSound', this.urgentSound);
}
},
onSingleSoundChange(value) {
setSetting('notification.singleSound', value);
@@ -256,12 +281,12 @@ export default {
}
},
resetSingleSound() {
this.singleSound = 'Teams 默认.mp3';
this.singleSound = defaultSingleSound;
setSetting('notification.singleSound', this.singleSound);
this.$message?.success('已恢复单次通知铃声默认设置');
},
resetUrgentSound() {
this.urgentSound = 'Teams 默认通话铃.mp3';
this.urgentSound = defaultUrgentSound;
setSetting('notification.urgentSound', this.urgentSound);
this.$message?.success('已恢复紧急通知铃声默认设置');
},
+14 -4
View File
@@ -1,7 +1,17 @@
<template>
<v-card :border="border" class="setting-group">
<v-card-title v-if="title" class="d-flex align-center">
<v-icon v-if="icon" :icon="icon" class="mr-2"/>
<v-card
:border="border"
class="setting-group"
>
<v-card-title
v-if="title"
class="d-flex align-center"
>
<v-icon
v-if="icon"
:icon="icon"
class="mr-2"
/>
{{ title }}
</v-card-title>
@@ -18,7 +28,7 @@
</v-card-text>
<v-card-actions v-if="$slots.actions">
<slot name="actions"></slot>
<slot name="actions" />
</v-card-actions>
</v-card>
</template>
+38 -14
View File
@@ -1,7 +1,10 @@
<template>
<v-list-item :disabled="disabled" class="setting-item">
<v-list-item
:disabled="disabled"
class="setting-item"
>
<template #prepend>
<v-icon :icon="settingIcon"/>
<v-icon :icon="settingIcon" />
</template>
<v-list-item-title class="text-wrap">
@@ -14,7 +17,10 @@
<template #append>
<div class="d-flex flex-column flex-sm-row align-center">
<div v-if="type !== 'string' || hasOptions" class="me-2">
<div
v-if="type !== 'string' || hasOptions"
class="me-2"
>
<!-- 根据设置类型渲染不同的控件 -->
<v-switch
v-if="type === 'boolean'"
@@ -40,7 +46,10 @@
@update:model-value="updateSetting"
/>
<div v-else-if="type === 'number'" class="d-flex align-center">
<div
v-else-if="type === 'number'"
class="d-flex align-center"
>
<v-btn
:disabled="disabled || localValue <= minValue"
icon="mdi-minus"
@@ -76,7 +85,7 @@
</div>
<v-menu location="bottom">
<template v-slot:activator="{ props }">
<template #activator="{ props }">
<v-btn
:disabled="disabled"
class="ml-2"
@@ -88,24 +97,36 @@
</template>
<v-list density="compact">
<v-list-item @click="copySettingId">
<template v-slot:prepend>
<v-icon icon="mdi-key" size="small"/>
<template #prepend>
<v-icon
icon="mdi-key"
size="small"
/>
</template>
<v-list-item-title>复制设置ID</v-list-item-title>
</v-list-item>
<v-list-item @click="copySettingValue">
<template v-slot:prepend>
<v-icon icon="mdi-content-copy" size="small"/>
<template #prepend>
<v-icon
icon="mdi-content-copy"
size="small"
/>
</template>
<v-list-item-title>复制设置值</v-list-item-title>
</v-list-item>
<v-divider></v-divider>
<v-divider />
<v-list-item :disabled="isDefaultValue" @click="resetToDefault">
<template v-slot:prepend>
<v-icon icon="mdi-restore" size="small"/>
<v-list-item
:disabled="isDefaultValue"
@click="resetToDefault"
>
<template #prepend>
<v-icon
icon="mdi-restore"
size="small"
/>
</template>
<v-list-item-title>重置为默认值</v-list-item-title>
</v-list-item>
@@ -116,7 +137,10 @@
</v-list-item>
<!-- 文本框显示在下方 -->
<div v-if="type === 'string' && !hasOptions" class="px-4 pb-2 pt-0">
<div
v-if="type === 'string' && !hasOptions"
class="px-4 pb-2 pt-0"
>
<v-text-field
v-model="localValue"
:disabled="disabled"
+28 -12
View File
@@ -1,30 +1,46 @@
<template>
<div class="settings-explorer">
<div>
<v-text-field v-model="searchQuery" class="mb-4" clearable density="comfortable" label="搜索设置"
prepend-inner-icon="mdi-magnify" variant="outlined"/>
<v-text-field
v-model="searchQuery"
class="mb-4"
clearable
density="comfortable"
label="搜索设置"
prepend-inner-icon="mdi-magnify"
variant="outlined"
/>
<v-list>
<div v-for="setting in allSettings" :key="setting.key">
<setting-item :key="setting.key" :disabled="setting.requireDeveloper && !isDeveloperMode"
:setting-key="setting.key" @error="onSettingError"
@update="onSettingUpdate"/>
<v-divider class="my-2"/>
<div
v-for="setting in allSettings"
:key="setting.key"
>
<setting-item
:key="setting.key"
:disabled="setting.requireDeveloper && !isDeveloperMode"
:setting-key="setting.key"
@error="onSettingError"
@update="onSettingUpdate"
/>
<v-divider class="my-2" />
</div>
</v-list>
<v-card border>
<v-card-title class="text-subtitle-1">当前配置</v-card-title>
<v-card-title class="text-subtitle-1">
当前配置
</v-card-title>
<v-card-text>
<pre class="settings-json">{{ formattedSettings }}</pre>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-spacer />
<v-btn @click="copySettingsToClipboard">
复制到剪贴板
<v-icon right>mdi-content-copy</v-icon>
<v-icon right>
mdi-content-copy
</v-icon>
</v-btn>
</v-card-actions>
</v-card>
+65 -33
View File
@@ -6,11 +6,20 @@
>
<v-card-item>
<template #prepend>
<v-icon class="mr-2" icon="mdi-account-group" size="large"/>
<v-icon
class="mr-2"
icon="mdi-account-group"
size="large"
/>
</template>
<v-card-title class="text-h6">学生列表</v-card-title>
<v-card-title class="text-h6">
学生列表
</v-card-title>
<template #append>
<unsaved-warning :show="unsavedChanges" message="有未保存的更改"/>
<unsaved-warning
:show="unsavedChanges"
message="有未保存的更改"
/>
<v-btn
:disabled="modelValue.list.length === 0"
class="mr-2"
@@ -39,7 +48,13 @@
indeterminate
/>
<v-alert v-if="error" class="mb-4" closable type="error" variant="tonal">
<v-alert
v-if="error"
class="mb-4"
closable
type="error"
variant="tonal"
>
{{ error }}
</v-alert>
@@ -47,7 +62,11 @@
<!-- 普通编辑模式 -->
<div v-if="!modelValue.advanced">
<v-row class="mb-6">
<v-col cols="12" md="4" sm="6">
<v-col
cols="12"
md="4"
sm="6"
>
<v-text-field
v-model="newStudentName"
class="mb-4"
@@ -88,7 +107,10 @@
v-bind="props"
>
<v-card-text class="d-flex align-center pa-3">
<v-menu :open-on-hover="!isMobile" location="bottom">
<v-menu
:open-on-hover="!isMobile"
location="bottom"
>
<template #activator="{ props: menuProps }">
<v-btn
class="mr-3 font-weight-medium"
@@ -100,7 +122,10 @@
</v-btn>
</template>
<v-list density="compact" nav>
<v-list
density="compact"
nav
>
<v-list-item
:disabled="index === 0"
prepend-icon="mdi-arrow-up-bold"
@@ -108,7 +133,7 @@
>
置顶
</v-list-item>
<v-divider/>
<v-divider />
<v-list-item
:disabled="index === 0"
prepend-icon="mdi-arrow-up"
@@ -172,7 +197,10 @@
</div>
<!-- 高级编辑模式 -->
<div v-else class="pt-2">
<div
v-else
class="pt-2"
>
<v-textarea
v-model="modelValue.text"
hint="使用文本编辑模式批量编辑学生名单,保存时会自动去除空行"
@@ -187,7 +215,10 @@
</v-expand-transition>
<v-row class="mt-6">
<v-col class="d-flex gap-2" cols="12">
<v-col
class="d-flex gap-2"
cols="12"
>
<v-btn
:disabled="loading"
:loading="loading"
@@ -228,8 +259,6 @@ async function loadPinyin() {
}
return _pinyin;
}
import {getSetting} from "@/utils/settings";
export default {
name: "StudentListCard",
components: {
@@ -267,7 +296,6 @@ export default {
if (!this.modelValue.advanced) {
this.modelValue.text = newData.list
.slice()
.sort((a, b) => a.id - b.id)
.map(s => s.name)
.join("\n");
}
@@ -285,22 +313,20 @@ export default {
this.error = null;
try {
this.loading = true;
const classNum = getSetting("server.classNumber");
if (!classNum) {
throw new Error("请先设置班号");
}
try {
const response = await dataProvider.loadData("classworks-list-main");
console.log("loadStudents response:", response);
if (response.success != false && Array.isArray(response)) {
this.modelValue.list = response.map((item, index) => {
// 兼容返回数组或包含 value 属性的对象
const dataArray = Array.isArray(response) ? response : (response && Array.isArray(response.value) ? response.value : null);
if (response.success != false && dataArray) {
this.modelValue.list = dataArray.map((item, index) => {
if (typeof item === 'string') {
return {id: index + 1, name: item};
}
return {
id: item.id || index + 1,
id: Number(item.id) || index + 1,
name: item.name || item.toString()
};
});
@@ -328,15 +354,9 @@ export default {
async saveStudents() {
try {
const classNum = getSetting("server.classNumber");
if (!classNum) {
throw new Error("请先设置班号");
}
this.loading = true;
const formattedList = this.modelValue.list
.slice()
.sort((a, b) => a.id - b.id)
.map((student, index) => ({
id: index + 1,
name: student.name
@@ -358,6 +378,8 @@ export default {
} catch (error) {
console.error("保存学生列表失败:", error);
this.$message?.error("保存失败", error.message || "请重试");
} finally {
this.loading = false;
}
},
@@ -373,7 +395,7 @@ export default {
// Create a map of existing student names to their IDs
const currentIds = new Map(this.modelValue.list.map(s => [s.name, s.id]));
let maxId = Math.max(0, ...this.modelValue.list.map(s => s.id));
let maxId = Math.max(0, ...this.modelValue.list.map(s => Number(s.id) || 0));
// Create new list preserving IDs for existing names and generating new IDs for new names
const newList = lines.map(name => {
@@ -386,18 +408,23 @@ export default {
// Update the list
this.modelValue.list = newList;
// Also update the text representation for standard mode
if (this.modelValue.advanced) {
this.modelValue.text = text;
}
},
addStudent() {
const name = this.newStudentName.trim();
if (name && !this.modelValue.list.some(s => s.name === name)) {
const maxId = Math.max(0, ...this.modelValue.list.map(s => s.id));
const maxId = Math.max(0, ...this.modelValue.list.map(s => Number(s.id) || 0));
this.modelValue.list.push({id: maxId + 1, name});
this.newStudentName = "";
}
},
startEdit(index, student) {
if (this._editTimeout) clearTimeout(this._editTimeout);
this.editState.index = index;
this.editState.name = student.name;
},
@@ -408,8 +435,13 @@ export default {
if (newName && newName !== this.modelValue.list[this.editState.index].name) {
this.modelValue.list[this.editState.index].name = newName;
}
this.editState.index = -1;
this.editState.name = "";
// 延迟重置状态,防止DOM突变导致丢失按钮点击事件
if (this._editTimeout) clearTimeout(this._editTimeout);
this._editTimeout = setTimeout(() => {
this.editState.index = -1;
this.editState.name = "";
}, 200);
}
},
+77 -35
View File
@@ -6,11 +6,20 @@
>
<v-card-item>
<template #prepend>
<v-icon class="mr-2" icon="mdi-account-tie" size="large"/>
<v-icon
class="mr-2"
icon="mdi-account-tie"
size="large"
/>
</template>
<v-card-title class="text-h6">教师列表</v-card-title>
<v-card-title class="text-h6">
教师列表
</v-card-title>
<template #append>
<unsaved-warning :show="unsavedChanges" message="有未保存的更改"/>
<unsaved-warning
:show="unsavedChanges"
message="有未保存的更改"
/>
<v-btn
:color="modelValue.advanced ? 'primary' : undefined"
prepend-icon="mdi-code-braces"
@@ -30,7 +39,13 @@
indeterminate
/>
<v-alert v-if="error" class="mb-4" closable type="error" variant="tonal">
<v-alert
v-if="error"
class="mb-4"
closable
type="error"
variant="tonal"
>
{{ error }}
</v-alert>
@@ -38,10 +53,16 @@
<!-- 普通编辑模式 -->
<div v-if="!modelValue.advanced">
<!-- 添加教师表单 -->
<v-card class="mb-6" variant="outlined">
<v-card
class="mb-6"
variant="outlined"
>
<v-card-text>
<v-row>
<v-col cols="12" md="4">
<v-col
cols="12"
md="4"
>
<v-text-field
v-model="newTeacher.name"
density="comfortable"
@@ -52,7 +73,10 @@
variant="outlined"
/>
</v-col>
<v-col cols="12" md="5">
<v-col
cols="12"
md="5"
>
<v-combobox
v-model="newTeacher.subjects"
:items="commonSubjects"
@@ -68,7 +92,11 @@
variant="outlined"
/>
</v-col>
<v-col cols="12" md="3" class="d-flex align-center gap-2">
<v-col
cols="12"
md="3"
class="d-flex align-center gap-2"
>
<v-checkbox
v-model="newTeacher.isHeadTeacher"
density="comfortable"
@@ -91,7 +119,10 @@
<!-- 教师列表 -->
<v-row v-if="modelValue.list.length === 0">
<v-col cols="12">
<v-alert type="info" variant="tonal">
<v-alert
type="info"
variant="tonal"
>
暂无教师信息,请添加教师
</v-alert>
</v-col>
@@ -155,7 +186,10 @@
</v-chip>
</div>
<div v-if="editState.index === index" class="mt-2">
<div
v-if="editState.index === index"
class="mt-2"
>
<v-combobox
v-model="editState.teacher.subjects"
:items="commonSubjects"
@@ -175,7 +209,10 @@
label="班主任"
/>
</div>
<div v-else class="mt-1">
<div
v-else
class="mt-1"
>
<v-chip
v-for="(subject, sIndex) in teacher.subjects"
:key="sIndex"
@@ -235,13 +272,16 @@
</div>
<!-- 高级编辑模式 -->
<div v-else class="pt-2">
<div
v-else
class="pt-2"
>
<v-textarea
v-model="modelValue.text"
hint="JSON 格式编辑教师列表。每个教师需包含 name、subjects(数组)、isHeadTeacher(布尔值)"
label="批量编辑教师列表 (JSON)"
persistent-hint
placeholder='[{"name":"教师姓名","subjects":["语文","数学"],"isHeadTeacher":true}]'
placeholder="[{&quot;name&quot;:&quot;教师姓名&quot;,&quot;subjects&quot;:[&quot;语文&quot;,&quot;数学&quot;],&quot;isHeadTeacher&quot;:true}]"
rows="15"
variant="outlined"
@update:model-value="handleTextInput"
@@ -250,7 +290,10 @@
</v-expand-transition>
<v-row class="mt-6">
<v-col class="d-flex gap-2" cols="12">
<v-col
class="d-flex gap-2"
cols="12"
>
<v-btn
:disabled="loading"
:loading="loading"
@@ -282,7 +325,6 @@
import UnsavedWarning from "../common/UnsavedWarning.vue";
import "@/styles/warnings.scss";
import dataProvider from "@/utils/dataProvider";
import {getSetting} from "@/utils/settings";
export default {
name: "TeacherListCard",
@@ -354,17 +396,15 @@ export default {
this.error = null;
try {
this.loading = true;
const classNum = getSetting("server.classNumber");
if (!classNum) {
throw new Error("请先设置班号");
}
try {
const response = await dataProvider.loadData("classworks-list-teacher");
console.log("loadTeachers response:", response);
if (response.success !== false && Array.isArray(response)) {
this.modelValue.list = response.map((item) => ({
// 兼容返回数组或包含 value 属性的对象
const dataArray = Array.isArray(response) ? response : (response && Array.isArray(response.value) ? response.value : null);
if (response.success != false && dataArray) {
this.modelValue.list = dataArray.map((item) => ({
name: item.name || "",
subjects: Array.isArray(item.subjects) ? item.subjects : [],
isHeadTeacher: Boolean(item.isHeadTeacher),
@@ -391,12 +431,7 @@ export default {
async saveTeachers() {
try {
const classNum = getSetting("server.classNumber");
if (!classNum) {
throw new Error("请先设置班号");
}
this.loading = true;
const formattedList = this.modelValue.list.map((teacher) => ({
name: teacher.name,
subjects: Array.isArray(teacher.subjects) ? teacher.subjects : [],
@@ -419,6 +454,8 @@ export default {
} catch (error) {
console.error("保存教师列表失败:", error);
this.$message?.error("保存失败", error.message || "请重试");
} finally {
this.loading = false;
}
},
@@ -476,16 +513,17 @@ export default {
},
startEdit(index, teacher) {
if (this._editTimeout) clearTimeout(this._editTimeout);
this.editState.index = index;
// 深度拷贝以防直接修改原对象
this.editState.teacher = {
name: teacher.name,
subjects: [...teacher.subjects],
isHeadTeacher: teacher.isHeadTeacher,
...teacher,
subjects: [...(teacher.subjects || [])],
};
},
saveEdit() {
if (this.editState.index !== -1) {
if (this.editState.index !== -1 && this.editState.teacher) {
const newName = this.editState.teacher.name.trim();
if (!newName) {
this.$message?.warning("提示", "教师姓名不能为空");
@@ -502,8 +540,12 @@ export default {
isHeadTeacher: this.editState.teacher.isHeadTeacher,
};
this.editState.index = -1;
this.editState.teacher = null;
// 延迟重置状态,防止DOM突变导致丢失按钮点击事件
if (this._editTimeout) clearTimeout(this._editTimeout);
this._editTimeout = setTimeout(() => {
this.editState.index = -1;
this.editState.teacher = null;
}, 200);
}
},
@@ -1,7 +1,15 @@
<template>
<v-card :disabled="!hasNamespaceInfo" :loading="loading" class="my-4">
<v-card
:disabled="!hasNamespaceInfo"
:loading="loading"
class="my-4"
>
<template #loader>
<v-progress-linear v-if="loading" color="primary" indeterminate/>
<v-progress-linear
v-if="loading"
color="primary"
indeterminate
/>
</template>
@@ -140,16 +148,8 @@
title="Classworks KV"
>
<v-card-text>
Classworks KV 是厚浪云推出的文档形键值数据库,其是一个开放的云应用平台,为各种应用提供存储服务。此设备正在使用其服务,如果您希望管理设备信息,请前往
Classworks KV 是一个文档形键值数据库,其是一个开放的云应用平台,为各种应用提供存储服务。此设备正在使用其服务,如果您希望管理设备信息,请前往
Classworks KV 的网站,如果您在服务推出前就在使用 Classworks,您的数据已被自动迁移。
<br><br>
Classworks KV 的全域管理员是
<a
href="https://wuyuan.dev"
target="_blank"
>
孙悟元
</a>
</v-card-text>
<v-card-actions>
<v-btn
@@ -175,7 +175,7 @@
</v-card-text>
<v-card-actions>
<v-spacer/>
<v-spacer />
<v-btn
:loading="loading"
color="primary"
@@ -213,7 +213,7 @@
<p>您确定要重新初始化云端存储吗?</p>
</v-card-text>
<v-card-actions>
<v-spacer/>
<v-spacer />
<v-btn
variant="text"
@click="showReinitDialog = false"
@@ -1,16 +1,22 @@
<template>
<settings-card icon="mdi-database-cog" title="数据源设置">
<settings-card
icon="mdi-database-cog"
title="数据源设置"
>
<v-list>
<!-- 服务器模式设置 -->
<template
v-if="
currentProvider === 'kv-server' ||
currentProvider === 'classworkscloud'
currentProvider === 'classworkscloud'
"
>
<v-list-item>
<template #prepend>
<v-icon class="mr-3" icon="mdi-lan-connect"/>
<v-icon
class="mr-3"
icon="mdi-lan-connect"
/>
</template>
<v-list-item-title>检查服务器连接</v-list-item-title>
<template #append>
@@ -22,64 +28,94 @@
测试连接
</v-btn>
</template>
</v-list-item
><!-- 数据迁移,仅对KV本地存储有效 -->
</v-list-item><!-- 数据迁移,仅对KV本地存储有效 -->
</template>
<!-- 本地存储设置 -->
<template v-if="currentProvider === 'kv-local'">
<v-list-item>
<template #prepend>
<v-icon class="mr-3" icon="mdi-database"/>
<v-icon
class="mr-3"
icon="mdi-database"
/>
</template>
<v-list-item-title>清除数据库缓存</v-list-item-title>
<v-list-item-subtitle
>这将清除所有本地数据库中的数据
</v-list-item-subtitle
>
<v-list-item-subtitle>
这将清除所有本地数据库中的数据
</v-list-item-subtitle>
<template #append>
<v-btn color="error" variant="tonal" @click="confirmClearIndexedDB">
<v-btn
color="error"
variant="tonal"
@click="confirmClearIndexedDB"
>
清除
</v-btn>
</template>
</v-list-item>
<v-list-item>
<template #prepend>
<v-icon class="mr-3" icon="mdi-database-export"/>
<v-icon
class="mr-3"
icon="mdi-database-export"
/>
</template>
<v-list-item-title>导出数据库</v-list-item-title>
<template #append>
<v-btn variant="tonal" @click="exportData"> 导出</v-btn>
<v-btn
variant="tonal"
@click="exportData"
>
导出
</v-btn>
</template>
</v-list-item>
</template>
<v-list-item>
<template #prepend>
<v-icon class="mr-3" icon="mdi-lan-connect"/>
<v-icon
class="mr-3"
icon="mdi-lan-connect"
/>
</template>
<v-list-item-title>查看本地缓存</v-list-item-title>
<template #append>
<v-btn to="/cachemanagement" variant="tonal"> 查看</v-btn>
<v-btn
to="/cachemanagement"
variant="tonal"
>
查看
</v-btn>
</template>
</v-list-item>
</v-list>
<!-- 确认对话框 -->
<v-dialog v-model="confirmDialog" max-width="400">
<v-dialog
v-model="confirmDialog"
max-width="400"
>
<v-card>
<v-card-title>{{ confirmTitle }}</v-card-title>
<v-card-text>{{ confirmMessage }}</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" variant="text" @click="confirmDialog = false"
>取消
</v-btn
<v-spacer />
<v-btn
color="grey"
variant="text"
@click="confirmDialog = false"
>
<v-btn color="error" variant="tonal" @click="handleConfirm"
>确认
</v-btn
取消
</v-btn>
<v-btn
color="error"
variant="tonal"
@click="handleConfirm"
>
确认
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
@@ -1,39 +1,42 @@
<template>
<settings-card border icon="mdi-monitor" title="显示设置">
<settings-card
border
icon="mdi-monitor"
title="显示设置"
>
<v-list>
<setting-item :setting-key="'display.emptySubjectDisplay'"/>
<setting-item :setting-key="'display.emptySubjectDisplay'" />
<v-divider class="my-2"/>
<setting-item :setting-key="'display.dynamicSort'"/>
<v-divider class="my-2" />
<setting-item :setting-key="'display.dynamicSort'" />
<v-divider class="my-2"/>
<setting-item :setting-key="'display.showRandomButton'"/>
<v-divider class="my-2" />
<setting-item :setting-key="'display.showRandomButton'" />
<v-divider class="my-2"/>
<setting-item :setting-key="'display.showFullscreenButton'"/>
<v-divider class="my-2" />
<setting-item :setting-key="'display.showFullscreenButton'" />
<v-divider class="my-2"/>
<setting-item :setting-key="'display.cardHoverEffect'"/>
<v-divider class="my-2" />
<setting-item :setting-key="'display.cardHoverEffect'" />
<v-divider class="my-2"/>
<setting-item :setting-key="'display.enhancedTouchMode'"/>
<v-divider class="my-2" />
<setting-item :setting-key="'display.enhancedTouchMode'" />
<v-divider class="my-2"/>
<setting-item :setting-key="'display.showQuickTools'"/>
<v-divider class="my-2" />
<setting-item :setting-key="'display.showQuickTools'" />
<v-divider class="my-2"/>
<setting-item :setting-key="'display.showAntiScreenBurnCard'"/>
<v-divider class="my-2" />
<setting-item :setting-key="'display.showAntiScreenBurnCard'" />
<v-divider class="my-2"/>
<setting-item :setting-key="'display.showExamScheduleButton'"/>
<v-divider class="my-2" />
<setting-item :setting-key="'display.showExamScheduleButton'" />
<v-divider class="my-2"/>
<setting-item :setting-key="'display.forceDesktopMode'"/>
<v-divider class="my-2"/>
<setting-item :setting-key="'display.lateStudentsArePresent'"/>
<v-divider class="my-2" />
<setting-item :setting-key="'display.forceDesktopMode'" />
<v-divider class="my-2" />
<setting-item :setting-key="'display.lateStudentsArePresent'" />
</v-list>
</settings-card>
</template>
@@ -6,13 +6,22 @@
@click="handleClick"
>
<v-card-text>
<div ref="typewriter" class="typewriter-text"></div>
<div ref="sourceWriter" class="source-text"></div>
<div
ref="typewriter"
class="typewriter-text"
/>
<div
ref="sourceWriter"
class="source-text"
/>
</v-card-text>
<transition name="fade">
<v-chip v-if="currentQuote?.contributor" class="contributor">
<v-chip
v-if="currentQuote?.contributor"
class="contributor"
>
<v-avatar start>
<v-img :src="`https://github.com/${currentQuote.contributor}.png`"/>
<v-img :src="`https://github.com/${currentQuote.contributor}.png`" />
</v-avatar>
{{ currentQuote.contributor }}
</v-chip>
@@ -57,6 +66,10 @@ export default {
this.initTypewriters();
},
beforeUnmount() {
[this.typewriter, this.sourceWriter].forEach(writer => writer?.stop());
},
methods: {
async initTypewriters() {
const Typewriter = await getTypewriter();
@@ -102,10 +115,6 @@ export default {
console.error("复制失败:", err);
}
}
},
beforeUnmount() {
[this.typewriter, this.sourceWriter].forEach(writer => writer?.stop());
}
};
</script>
@@ -1,28 +1,30 @@
<template>
<settings-card icon="mdi-cog" title="编辑设置">
<settings-card
icon="mdi-cog"
title="编辑设置"
>
<v-list>
<setting-item :setting-key="'edit.autoSave'"/>
<setting-item :setting-key="'edit.autoSave'" />
<v-divider class="my-2"/>
<setting-item :setting-key="'edit.blockNonTodayAutoSave'"/>
<v-divider class="my-2" />
<setting-item :setting-key="'edit.blockNonTodayAutoSave'" />
<v-divider class="my-2"/>
<setting-item :setting-key="'edit.confirmNonTodaySave'"/>
<v-divider class="my-2" />
<setting-item :setting-key="'edit.confirmNonTodaySave'" />
<v-divider class="my-2"/>
<setting-item :setting-key="'edit.refreshBeforeEdit'"/>
<v-divider class="my-2" />
<setting-item :setting-key="'edit.refreshBeforeEdit'" />
<v-divider class="my-2"/>
<setting-item :setting-key="'edit.autoSavePromptText'"/>
<v-divider class="my-2" />
<setting-item :setting-key="'edit.autoSavePromptText'" />
<v-divider class="my-2"/>
<setting-item :setting-key="'edit.manualSavePromptText'"/>
<v-divider class="my-2" />
<setting-item :setting-key="'edit.manualSavePromptText'" />
</v-list>
</settings-card>
</template>
@@ -48,8 +48,15 @@
</div>
<v-row>
<v-col cols="12" md="6">
<setting-group border icon="mdi-book" title="科目配置">
<v-col
cols="12"
md="6"
>
<setting-group
border
icon="mdi-book"
title="科目配置"
>
<v-list>
<v-list-item>
<v-text-field
@@ -63,8 +70,14 @@
/>
</v-list-item>
<v-list-item v-for="subject in subjectList" :key="subject">
<v-card border class="w-100 mb-2">
<v-list-item
v-for="subject in subjectList"
:key="subject"
>
<v-card
border
class="w-100 mb-2"
>
<v-card-title class="d-flex align-center">
<v-text-field
v-model="editedSubjects[subject]"
@@ -74,7 +87,7 @@
variant="plain"
@blur="updateSubject(subject)"
/>
<v-spacer/>
<v-spacer />
<v-btn
color="error"
icon="mdi-delete"
@@ -95,17 +108,24 @@
@keyup.enter="() => addBookType(subject)"
/>
<v-list border density="compact" rounded>
<v-list
border
density="compact"
rounded
>
<v-list-item
v-for="(books, bookType) in config.subjects[subject].books"
:key="bookType"
:title="bookType"
@click="openSubjectBookDialog(subject, bookType, books)"
>
<template v-slot:prepend>
<v-icon class="mr-2" icon="mdi-book-open-variant"/>
<template #prepend>
<v-icon
class="mr-2"
icon="mdi-book-open-variant"
/>
</template>
<template v-slot:append>
<template #append>
<v-chip
class="mr-2"
color="info"
@@ -130,8 +150,15 @@
</setting-group>
</v-col>
<v-col cols="12" md="6">
<setting-group border icon="mdi-cog" title="通用配置">
<v-col
cols="12"
md="6"
>
<setting-group
border
icon="mdi-cog"
title="通用配置"
>
<v-list>
<v-list-item>
<v-text-field
@@ -146,17 +173,24 @@
</v-list-item>
<v-list-item>
<v-list border density="compact" rounded>
<v-list
border
density="compact"
rounded
>
<v-list-item
v-for="(books, bookType) in config.commonSubject.books"
:key="bookType"
:title="bookType"
@click="openSubjectBookDialog('common', bookType, books)"
>
<template v-slot:prepend>
<v-icon class="mr-2" icon="mdi-book-multiple"/>
<template #prepend>
<v-icon
class="mr-2"
icon="mdi-book-multiple"
/>
</template>
<template v-slot:append>
<template #append>
<v-chip
class="mr-2"
color="info"
@@ -176,7 +210,7 @@
</v-list>
</v-list-item>
<v-divider class="my-2"/>
<v-divider class="my-2" />
<v-list-item>
<v-text-field
@@ -191,14 +225,18 @@
</v-list-item>
<v-list-item>
<v-list border density="compact" rounded>
<v-list
border
density="compact"
rounded
>
<v-list-item
v-for="action in config.actions"
:key="action"
:title="action"
@click="openActionDialog(action)"
>
<template v-slot:append>
<template #append>
<v-btn
color="error"
icon="mdi-delete"
@@ -216,7 +254,10 @@
</v-row>
<!-- 编辑弹框 -->
<v-dialog v-model="dialog.show" max-width="600px">
<v-dialog
v-model="dialog.show"
max-width="600px"
>
<v-card>
<v-card-title class="text-h5 pa-4">
{{ dialog.title }}
@@ -235,22 +276,43 @@
/>
</v-col>
<v-col v-if="dialog.editedItem.type === 'subjectBook'" cols="12">
<div class="text-subtitle-2 mb-2">所属科目</div>
<v-chip color="primary">{{ dialog.editedItem.subject }}</v-chip>
<v-col
v-if="dialog.editedItem.type === 'subjectBook'"
cols="12"
>
<div class="text-subtitle-2 mb-2">
所属科目
</div>
<v-chip color="primary">
{{ dialog.editedItem.subject }}
</v-chip>
</v-col>
<v-col v-if="['subjectBook', 'commonBook'].includes(dialog.editedItem.type)" cols="12">
<v-col
v-if="['subjectBook', 'commonBook'].includes(dialog.editedItem.type)"
cols="12"
>
<v-card variant="outlined">
<v-card-title class="text-subtitle-1 py-2">需完成部分</v-card-title>
<v-card-title class="text-subtitle-1 py-2">
需完成部分
</v-card-title>
<v-card-text class="pt-0">
<v-list border class="mb-2" density="compact" rounded>
<v-list
border
class="mb-2"
density="compact"
rounded
>
<v-list-item
v-for="(task, index) in dialog.editedItem.tasks"
:key="index"
>
<template v-slot:prepend>
<v-icon class="mr-2" icon="mdi-checkbox-blank-circle-outline" size="small"/>
<template #prepend>
<v-icon
class="mr-2"
icon="mdi-checkbox-blank-circle-outline"
size="small"
/>
</template>
<v-text-field
v-model="dialog.editedItem.tasks[index]"
@@ -258,7 +320,7 @@
hide-details
variant="plain"
/>
<template v-slot:append>
<template #append>
<v-btn
color="error"
icon="mdi-delete"
@@ -287,7 +349,7 @@
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-spacer />
<v-btn
color="primary"
variant="elevated"
+199 -55
View File
@@ -1,40 +1,67 @@
<template>
<settings-card :loading="loading" icon="mdi-database-edit" title="KV数据库管理">
<settings-card
:loading="loading"
icon="mdi-database-edit"
title="KV数据库管理"
>
<v-list>
<!-- 数据库连接状态 -->
<v-list-item>
<template #prepend>
<v-icon :color="connectionColor" :icon="connectionIcon" class="mr-3"/>
<v-icon
:color="connectionColor"
:icon="connectionIcon"
class="mr-3"
/>
</template>
<v-list-item-title>数据库状态</v-list-item-title>
<v-list-item-subtitle>{{ connectionStatus }}</v-list-item-subtitle>
<template #append>
<v-btn :loading="loading" variant="tonal" @click="refreshConnection">
<v-btn
:loading="loading"
variant="tonal"
@click="refreshConnection"
>
刷新
</v-btn>
</template>
</v-list-item>
<v-divider class="my-2"/>
<v-divider class="my-2" />
<!-- 数据列表 -->
<v-list-item>
<template #prepend>
<v-icon class="mr-3" icon="mdi-format-list-bulleted"/>
<v-icon
class="mr-3"
icon="mdi-format-list-bulleted"
/>
</template>
<v-list-item-title>数据条目</v-list-item-title>
<v-list-item-subtitle>共 {{ kvData.length }} 条记录</v-list-item-subtitle>
<template #append>
<v-btn-group variant="tonal">
<v-btn :loading="loadingData" @click="loadKvData">
<v-btn
:loading="loadingData"
@click="loadKvData"
>
加载数据
</v-btn>
<v-btn :disabled="!isKvProvider" @click="createNewItem">
<v-icon class="mr-1" icon="mdi-plus"/>
<v-btn
:disabled="!isKvProvider"
@click="createNewItem"
>
<v-icon
class="mr-1"
icon="mdi-plus"
/>
新建
</v-btn>
<v-btn @click="showMigrationDialog = true">
<v-icon class="mr-1" icon="mdi-cloud-upload"/>
<v-icon
class="mr-1"
icon="mdi-cloud-upload"
/>
从本地迁移
</v-btn>
</v-btn-group>
@@ -43,11 +70,18 @@
</v-list>
<!-- 数据表格 -->
<v-card v-if="kvData.length > 0" class="mt-4" variant="outlined">
<v-card
v-if="kvData.length > 0"
class="mt-4"
variant="outlined"
>
<v-card-title class="d-flex align-center">
<v-icon class="mr-2" icon="mdi-table"/>
<v-icon
class="mr-2"
icon="mdi-table"
/>
KV数据列表
<v-spacer/>
<v-spacer />
<v-text-field
v-model="searchQuery"
clearable
@@ -73,7 +107,10 @@
</template>
<template #[`item.actions`]="{ item }">
<v-btn-group density="compact" variant="text">
<v-btn-group
density="compact"
variant="text"
>
<v-btn
icon="mdi-eye"
size="small"
@@ -106,13 +143,23 @@
</v-card>
<!-- 查看数据对话框 -->
<v-dialog v-model="viewDialog" max-width="800px">
<v-dialog
v-model="viewDialog"
max-width="800px"
>
<v-card>
<v-card-title class="d-flex align-center">
<v-icon class="mr-2" icon="mdi-eye"/>
<v-icon
class="mr-2"
icon="mdi-eye"
/>
查看数据
<v-spacer/>
<v-btn icon="mdi-close" variant="text" @click="viewDialog = false"/>
<v-spacer />
<v-btn
icon="mdi-close"
variant="text"
@click="viewDialog = false"
/>
</v-card-title>
<v-card-subtitle v-if="selectedItem">
@@ -132,12 +179,21 @@
</v-card-text>
<v-card-actions>
<v-spacer/>
<v-btn variant="tonal" @click="copyToClipboard(selectedItem?.value)">
<v-icon class="mr-1" icon="mdi-content-copy"/>
<v-spacer />
<v-btn
variant="tonal"
@click="copyToClipboard(selectedItem?.value)"
>
<v-icon
class="mr-1"
icon="mdi-content-copy"
/>
复制数据
</v-btn>
<v-btn variant="text" @click="viewDialog = false">
<v-btn
variant="text"
@click="viewDialog = false"
>
关闭
</v-btn>
</v-card-actions>
@@ -145,13 +201,23 @@
</v-dialog>
<!-- 编辑数据对话框 -->
<v-dialog v-model="editDialog" max-width="800px">
<v-dialog
v-model="editDialog"
max-width="800px"
>
<v-card>
<v-card-title class="d-flex align-center">
<v-icon class="mr-2" icon="mdi-pencil"/>
<v-icon
class="mr-2"
icon="mdi-pencil"
/>
编辑数据
<v-spacer/>
<v-btn icon="mdi-close" variant="text" @click="closeEditDialog"/>
<v-spacer />
<v-btn
icon="mdi-close"
variant="text"
@click="closeEditDialog"
/>
</v-card-title>
<v-card-subtitle v-if="editingItem">
@@ -171,8 +237,11 @@
</v-card-text>
<v-card-actions>
<v-spacer/>
<v-btn variant="text" @click="closeEditDialog">
<v-spacer />
<v-btn
variant="text"
@click="closeEditDialog"
>
取消
</v-btn>
<v-btn
@@ -189,13 +258,23 @@
</v-dialog>
<!-- 新建数据对话框 -->
<v-dialog v-model="createDialog" max-width="800px">
<v-dialog
v-model="createDialog"
max-width="800px"
>
<v-card>
<v-card-title class="d-flex align-center">
<v-icon class="mr-2" icon="mdi-plus"/>
<v-icon
class="mr-2"
icon="mdi-plus"
/>
新建数据
<v-spacer/>
<v-btn icon="mdi-close" variant="text" @click="closeCreateDialog"/>
<v-spacer />
<v-btn
icon="mdi-close"
variant="text"
@click="closeCreateDialog"
/>
</v-card-title>
<v-card-text>
@@ -215,15 +294,18 @@
:error-messages="isValidNewJson ? [] : ['请输入有效的JSON格式']"
class="font-monospace"
label="数据内容 (JSON格式)"
placeholder='请输入JSON数据,如:{"name": "value"}'
placeholder="请输入JSON数据,如:{&quot;name&quot;: &quot;value&quot;}"
rows="15"
variant="outlined"
/>
</v-card-text>
<v-card-actions>
<v-spacer/>
<v-btn variant="text" @click="closeCreateDialog">
<v-spacer />
<v-btn
variant="text"
@click="closeCreateDialog"
>
取消
</v-btn>
<v-btn
@@ -240,13 +322,23 @@
</v-dialog>
<!-- 云端地址对话框 -->
<v-dialog v-model="cloudUrlDialog" max-width="800px">
<v-dialog
v-model="cloudUrlDialog"
max-width="800px"
>
<v-card>
<v-card-title class="d-flex align-center">
<v-icon class="mr-2" icon="mdi-cloud-download"/>
<v-icon
class="mr-2"
icon="mdi-cloud-download"
/>
获取云端访问地址
<v-spacer/>
<v-btn icon="mdi-close" variant="text" @click="cloudUrlDialog = false"/>
<v-spacer />
<v-btn
icon="mdi-close"
variant="text"
@click="cloudUrlDialog = false"
/>
</v-card-title>
<v-card-subtitle v-if="selectedCloudItem">
@@ -254,19 +346,43 @@
</v-card-subtitle>
<v-card-text>
<v-alert v-if="cloudUrlError" class="mb-4" type="error" variant="tonal">
<v-alert
v-if="cloudUrlError"
class="mb-4"
type="error"
variant="tonal"
>
{{ cloudUrlError }}
</v-alert>
<v-alert v-if="cloudUrlResult && cloudUrlResult.success" class="mb-4" type="success" variant="tonal">
<v-alert
v-if="cloudUrlResult && cloudUrlResult.success"
class="mb-4"
type="success"
variant="tonal"
>
<v-alert-title>云端地址获取成功</v-alert-title>
<div class="mt-2">
<div v-if="cloudUrlResult.migrated" class="mb-2">
<v-icon class="mr-1" color="success" icon="mdi-database-arrow-up"/>
<div
v-if="cloudUrlResult.migrated"
class="mb-2"
>
<v-icon
class="mr-1"
color="success"
icon="mdi-database-arrow-up"
/>
数据已从本地迁移到云端
</div>
<div v-if="cloudUrlResult.configured" class="mb-2">
<v-icon class="mr-1" color="info" icon="mdi-cog"/>
<div
v-if="cloudUrlResult.configured"
class="mb-2"
>
<v-icon
class="mr-1"
color="info"
icon="mdi-cog"
/>
云端配置已自动设置
</div>
</div>
@@ -283,10 +399,16 @@
@click:append-inner="copyCloudUrl"
/>
<v-expansion-panels v-if="cloudUrlResult && cloudUrlResult.url" class="mt-4">
<v-expansion-panels
v-if="cloudUrlResult && cloudUrlResult.url"
class="mt-4"
>
<v-expansion-panel>
<v-expansion-panel-title>
<v-icon class="mr-2" icon="mdi-cog"/>
<v-icon
class="mr-2"
icon="mdi-cog"
/>
高级选项
</v-expansion-panel-title>
<v-expansion-panel-text>
@@ -307,7 +429,10 @@
variant="tonal"
@click="refreshCloudUrl"
>
<v-icon class="mr-1" icon="mdi-refresh"/>
<v-icon
class="mr-1"
icon="mdi-refresh"
/>
重新获取
</v-btn>
</v-expansion-panel-text>
@@ -316,8 +441,11 @@
</v-card-text>
<v-card-actions>
<v-spacer/>
<v-btn variant="text" @click="cloudUrlDialog = false">
<v-spacer />
<v-btn
variant="text"
@click="cloudUrlDialog = false"
>
关闭
</v-btn>
<v-btn
@@ -326,7 +454,10 @@
variant="tonal"
@click="openCloudUrl"
>
<v-icon class="mr-1" icon="mdi-open-in-new"/>
<v-icon
class="mr-1"
icon="mdi-open-in-new"
/>
在新窗口打开
</v-btn>
</v-card-actions>
@@ -334,24 +465,37 @@
</v-dialog>
<!-- 删除确认对话框 -->
<v-dialog v-model="deleteDialog" max-width="400px">
<v-dialog
v-model="deleteDialog"
max-width="400px"
>
<v-card>
<v-card-title class="d-flex align-center text-error">
<v-icon class="mr-2" icon="mdi-alert"/>
<v-icon
class="mr-2"
icon="mdi-alert"
/>
确认删除
</v-card-title>
<v-card-text>
确定要删除键名为 <code>{{ itemToDelete?.key }}</code> 的数据吗?
<br><br>
<v-alert class="mt-2" type="warning" variant="tonal">
<v-alert
class="mt-2"
type="warning"
variant="tonal"
>
此操作不可撤销,请谨慎操作!
</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer/>
<v-btn variant="text" @click="deleteDialog = false">
<v-spacer />
<v-btn
variant="text"
@click="deleteDialog = false"
>
取消
</v-btn>
<v-btn
@@ -1,22 +1,23 @@
<template>
<settings-card icon="mdi-cog" title="编辑设置">
<settings-card
icon="mdi-cog"
title="编辑设置"
>
<v-list>
<setting-item setting-key="randomPicker.enabled"/>
<v-divider class="my-2"/>
<setting-item setting-key="randomPicker.enabled" />
<v-divider class="my-2" />
<setting-item setting-key="randomPicker.mode"/>
<v-divider class="my-2"/>
<setting-item setting-key="randomPicker.minNumber"/>
<v-divider class="my-2"/>
<setting-item setting-key="randomPicker.maxNumber"/>
<v-divider class="my-2"/>
<setting-item setting-key="randomPicker.defaultCount"/>
<v-divider class="my-2"/>
<setting-item setting-key="randomPicker.animation"/>
<setting-item setting-key="randomPicker.mode" />
<v-divider class="my-2" />
<setting-item setting-key="randomPicker.minNumber" />
<v-divider class="my-2" />
<setting-item setting-key="randomPicker.maxNumber" />
<v-divider class="my-2" />
<setting-item setting-key="randomPicker.defaultCount" />
<v-divider class="my-2" />
<setting-item setting-key="randomPicker.animation" />
</v-list>
</settings-card>
</template>
@@ -1,13 +1,21 @@
<template>
<settings-card icon="mdi-refresh-circle" title="刷新设置">
<settings-card
icon="mdi-refresh-circle"
title="刷新设置"
>
<v-form>
<v-list>
<setting-item setting-key="refresh.auto" title="自动刷新"/>
<v-divider class="my-2"/>
<setting-item
setting-key="refresh.auto"
title="自动刷新"
/>
<v-divider class="my-2" />
<setting-item setting-key="refresh.interval" title="刷新间隔"/>
<setting-item
setting-key="refresh.interval"
title="刷新间隔"
/>
</v-list>
</v-form>
</settings-card>
</template>
@@ -118,6 +118,7 @@
import SettingsCard from "@/components/SettingsCard.vue";
import CloudNamespaceInfoCard from "./CloudNamespaceInfoCard.vue";
import {getSetting, setSetting, watchSettings} from "@/utils/settings";
import dataProvider from "@/utils/dataProvider";
export default {
name: "ServerSettingsCard",
@@ -132,7 +133,7 @@ export default {
serverSettings: {
provider: getSetting("server.provider"),
domain: getSetting("server.domain"),
classNumber: getSetting("server.classNumber"),
classNumber: "加载中...",
kvToken: getSetting("server.kvToken"),
},
// 用于监听设置变化时刷新 UI
@@ -186,19 +187,41 @@ export default {
},
methods: {
// 从全局设置加载所有设置到本地
loadAllSettings() {
async loadAllSettings() {
this.serverSettings = {
provider: getSetting("server.provider"),
domain: getSetting("server.domain"),
classNumber: getSetting("server.classNumber"),
classNumber: "加载中...",
kvToken: getSetting("server.kvToken"),
};
try {
const res = await dataProvider.loadData("classworks-config-basic");
if (res && res.success !== false && res.classNumber) {
this.serverSettings.classNumber = res.classNumber;
} else {
this.serverSettings.classNumber = "我的班级";
}
} catch (e) {
this.serverSettings.classNumber = "我的班级";
}
},
// 保存所有本地设置到全局
saveAllSettings() {
Object.entries(this.serverSettings).forEach(([key, value]) => {
const settingKey = `server.${key}`;
if (key === 'classNumber') {
// Sync to server data so it's consistent
dataProvider.saveData("classworks-config-basic", { classNumber: value }).then(() => {
console.log(`班级名称已同步到服务器: ${value}`);
}).catch(e => {
console.error("无法保存班级到服务器配置", e);
});
return; // Skip saving classNumber to localStorage
}
const currentValue = getSetting(settingKey);
// 只有当值发生变化时才进行设置
@@ -58,10 +58,16 @@
</div>
<!-- 添加新科目 -->
<v-card class="mb-4" variant="outlined">
<v-card
class="mb-4"
variant="outlined"
>
<v-card-text>
<v-row>
<v-col cols="12" sm="6">
<v-col
cols="12"
sm="6"
>
<v-text-field
v-model="newSubjectName"
:rules="[v => !!v || '科目名称不能为空']"
@@ -85,7 +91,7 @@
v-for="(subject, index) in subjects"
:key="subject.order"
>
<template v-slot:prepend>
<template #prepend>
<div class="d-flex flex-column align-center mr-2">
<v-btn
:disabled="index === 0"
@@ -114,7 +120,7 @@
/>
</v-list-item-title>
<template v-slot:append>
<template #append>
<v-btn
color="error"
icon="mdi-delete"
@@ -191,9 +197,13 @@ export default {
this.loading = true;
try {
const response = await dataProvider.loadData("classworks-config-subject");
if (response) {
// 兼容返回数组或包含 value 属性的对象
const dataArray = Array.isArray(response) ? response : (response && Array.isArray(response.value) ? response.value : null);
if (response && response.success !== false && dataArray) {
// 数据存在且加载成功
this.subjects = response.map((subject, index) => ({
this.subjects = dataArray.map((subject, index) => ({
name: subject.name,
order: subject.order ?? index
})).sort((a, b) => a.order - b.order);
@@ -203,7 +213,9 @@ export default {
// 数据不存在,使用空数组
this.subjects = [];
this.originalSubjects = [];
this.showMessage('使用默认配置', 'info');
if (response && response.success === false) {
this.showMessage('未找到已有配置,使用默认配置', 'info');
}
}
} catch (error) {
console.error('Failed to load config:', error);
@@ -1,9 +1,15 @@
<template>
<settings-card icon="mdi-palette" title="主题设置">
<settings-card
icon="mdi-palette"
title="主题设置"
>
<v-list>
<v-list-item>
<template #prepend>
<v-icon class="mr-3" icon="mdi-theme-light-dark"/>
<v-icon
class="mr-3"
icon="mdi-theme-light-dark"
/>
</template>
<v-list-item-title>主题模式</v-list-item-title>
<v-list-item-subtitle>选择明亮或暗黑主题</v-list-item-subtitle>
@@ -14,11 +20,17 @@
density="comfortable"
>
<v-btn value="light">
<v-icon class="mr-2" icon="mdi-white-balance-sunny"/>
<v-icon
class="mr-2"
icon="mdi-white-balance-sunny"
/>
明亮
</v-btn>
<v-btn value="dark">
<v-icon class="mr-2" icon="mdi-moon-waning-crescent"/>
<v-icon
class="mr-2"
icon="mdi-moon-waning-crescent"
/>
暗黑
</v-btn>
</v-btn-toggle>
@@ -37,6 +49,11 @@ export default {
name: 'ThemeSettingsCard',
components: {SettingsCard},
setup() {
const theme = useTheme();
return {theme};
},
data() {
return {
localTheme: getSetting('theme.mode')
@@ -50,11 +67,6 @@ export default {
}
},
setup() {
const theme = useTheme();
return {theme};
},
methods: {
updateTheme(mode) {
this.theme.global.name.value = mode;
-32
View File
@@ -27,35 +27,3 @@ app.component('GlobalMessage', GlobalMessage)
app.mount('#app')
// ====== 以下全部异步,不阻塞首屏渲染 ======
// 异步初始化 Sentry(延迟到首帧渲染完成后,防止 errorHandler 与渲染周期冲突)
setTimeout(() => {
import('./utils/sentry').then(({ initSentry }) => {
const router = app.config.globalProperties.$router
initSentry(app, router)
}).catch((err) => {
console.warn('Sentry 初始化失败:', err)
})
}, 1000)
// 异步加载 Clarity(在页面完全加载后)
const loadClarity = async () => {
try {
const { getVisitorId } = await import('./utils/visitorId')
const Clarity = (await import('@microsoft/clarity')).default
Clarity.init('rhp8uqoc3l')
const visitorId = await getVisitorId()
console.log('Visitor ID:', visitorId)
Clarity.identify(visitorId)
Clarity.setTag('fingerprintjs', visitorId)
} catch (error) {
console.warn('Clarity 加载或标识设置失败:', error)
}
}
if (document.readyState === 'complete') {
loadClarity()
} else {
window.addEventListener('load', loadClarity, { once: true })
}
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<error404/>
<error404 />
</template>
<script setup>
+56 -13
View File
@@ -3,39 +3,82 @@
<v-row>
<v-col cols="12">
<div class="d-flex align-center mb-6">
<v-icon class="mr-3" color="primary" size="x-large">mdi-database-cog-outline</v-icon>
<v-icon
class="mr-3"
color="primary"
size="x-large"
>
mdi-database-cog-outline
</v-icon>
<div>
<h1 class="text-h4 ">缓存管理</h1>
<div class="text-subtitle-1 text-grey">管理应用的本地缓存资源</div>
<h1 class="text-h4 ">
缓存管理
</h1>
<div class="text-subtitle-1 text-grey">
管理应用的本地缓存资源
</div>
</div>
</div>
<v-card class="mb-6" color="info" density="compact" variant="tonal">
<v-card
class="mb-6"
color="info"
density="compact"
variant="tonal"
>
<v-card-text class="d-flex align-center">
<v-icon class="mr-2" color="info">mdi-information-outline</v-icon>
<v-icon
class="mr-2"
color="info"
>
mdi-information-outline
</v-icon>
<span>在这里您可以查看和管理应用的缓存文件。清除缓存可能会导致应用需要重新下载资源,但有助于解决某些显示问题。</span>
</v-card-text>
</v-card>
<v-row>
<v-col cols="12" md="8">
<v-card class="mb-4" variant="tonal">
<v-col
cols="12"
md="8"
>
<v-card
class="mb-4"
variant="tonal"
>
<v-card-text>
<div class="d-flex align-center mb-2">
<v-icon class="mr-2" color="primary">mdi-information</v-icon>
<v-icon
class="mr-2"
color="primary"
>
mdi-information
</v-icon>
<span class="text-h6">什么是缓存?</span>
</div>
<p>
缓存是浏览器在本地存储的网站资源副本,如图片、脚本和样式表等。这些缓存可以加快页面加载速度,减少数据使用,并在离线时提供基本功能。</p>
缓存是浏览器在本地存储的网站资源副本,如图片、脚本和样式表等。这些缓存可以加快页面加载速度,减少数据使用,并在离线时提供基本功能。
</p>
</v-card-text>
</v-card>
</v-col>
<v-col cols="12" md="4">
<v-card class="mb-4" variant="tonal">
<v-col
cols="12"
md="4"
>
<v-card
class="mb-4"
variant="tonal"
>
<v-card-text>
<div class="d-flex align-center mb-2">
<v-icon class="mr-2" color="warning">mdi-lightbulb-outline</v-icon>
<v-icon
class="mr-2"
color="warning"
>
mdi-lightbulb-outline
</v-icon>
<span class="text-h6">何时清除缓存?</span>
</div>
<ul class="pl-4">
@@ -48,7 +91,7 @@
</v-col>
</v-row>
<CacheManager/>
<CacheManager />
</v-col>
</v-row>
</v-container>
+21 -6
View File
@@ -1,7 +1,17 @@
<template>
<v-container class="fill-height" fluid>
<v-row align="center" justify="center">
<v-col cols="12" md="6" sm="8">
<v-container
class="fill-height"
fluid
>
<v-row
align="center"
justify="center"
>
<v-col
cols="12"
md="6"
sm="8"
>
<v-card>
<v-card-title class="text-h5">
{{ status === 'processing' ? '正在处理授权...' : status === 'success' ? '授权成功' : '授权失败' }}
@@ -12,12 +22,17 @@
class="mb-4"
color="primary"
indeterminate
></v-progress-linear>
/>
<p>{{ message }}</p>
</v-card-text>
<v-card-actions v-if="status !== 'processing'">
<v-spacer></v-spacer>
<v-btn color="primary" @click="goToHome">返回首页</v-btn>
<v-spacer />
<v-btn
color="primary"
@click="goToHome"
>
返回首页
</v-btn>
</v-card-actions>
</v-card>
</v-col>
+376 -143
View File
@@ -2,9 +2,17 @@
<v-container class="fill-height">
<v-row>
<v-col cols="12">
<v-card class="elevation-12" border>
<v-card
class="elevation-12"
border
>
<v-card-title class="d-flex align-center primary lighten-1 white--text py-3 px-4">
<v-icon color="white" class="mr-2">mdi-swap-horizontal</v-icon>
<v-icon
color="white"
class="mr-2"
>
mdi-swap-horizontal
</v-icon>
课程表转换工具
</v-card-title>
<v-card-subtitle>
@@ -22,7 +30,9 @@
@click:close="error = ''"
>
<div class="d-flex align-center">
<v-icon class="mr-2">mdi-alert-circle</v-icon>
<v-icon class="mr-2">
mdi-alert-circle
</v-icon>
{{ error }}
</div>
</v-alert>
@@ -38,38 +48,75 @@
@click:close="success = ''"
>
<div class="d-flex align-center">
<v-icon class="mr-2">mdi-check-circle</v-icon>
<v-icon class="mr-2">
mdi-check-circle
</v-icon>
{{ success }}
</div>
</v-alert>
<!-- 输入方式选择 -->
<v-tabs v-model="activeTab" class="mb-4 mx-2" color="primary" rounded>
<v-tab value="text" class="px-5">
<v-icon start>mdi-text-box</v-icon>
<v-tabs
v-model="activeTab"
class="mb-4 mx-2"
color="primary"
rounded
>
<v-tab
value="text"
class="px-5"
>
<v-icon start>
mdi-text-box
</v-icon>
文本粘贴
</v-tab>
<v-tab value="file" class="px-5">
<v-icon start>mdi-file-upload</v-icon>
<v-tab
value="file"
class="px-5"
>
<v-icon start>
mdi-file-upload
</v-icon>
文件上传
</v-tab>
</v-tabs>
<!-- 格式选择 -->
<v-btn-toggle v-model="formatMode" color="primary" class="mb-4 mx-2" mandatory density="comfortable" border
rounded>
<v-btn value="auto">自动检测</v-btn>
<v-btn value="json">JSON</v-btn>
<v-btn value="yaml" :disabled="!yamlLibLoaded">
<v-btn-toggle
v-model="formatMode"
color="primary"
class="mb-4 mx-2"
mandatory
density="comfortable"
border
rounded
>
<v-btn value="auto">
自动检测
</v-btn>
<v-btn value="json">
JSON
</v-btn>
<v-btn
value="yaml"
:disabled="!yamlLibLoaded"
>
YAML
<v-tooltip activator="parent" location="bottom">
<v-tooltip
activator="parent"
location="bottom"
>
{{ yamlLibLoaded ? 'YAML解析库已加载' : '正在加载YAML解析库...' }}
</v-tooltip>
</v-btn>
</v-btn-toggle>
<!-- 添加当前检测到的格式提示 -->
<div v-if="jsonText && formatMode === 'auto'" class="text-caption mb-2">
<div
v-if="jsonText && formatMode === 'auto'"
class="text-caption mb-2"
>
检测到的格式: {{ isYaml(jsonText) ? 'YAML' : 'JSON' }}
</div>
@@ -85,7 +132,7 @@
rows="6"
placeholder="请在此粘贴CSES格式的数据..."
@input="handleTextChange"
></v-textarea>
/>
</div>
</v-window-item>
<v-window-item value="file">
@@ -96,13 +143,13 @@
prepend-icon="mdi-file-upload"
:loading="loading"
:disabled="loading"
@change="handleFileChange"
hint="支持JSON、YAML格式文件"
persistent-hint
:rules="[
v => !v || v.size < 2000000 || '文件大小不能超过 2 MB',
]"
></v-file-input>
@change="handleFileChange"
/>
<v-alert
v-if="file && formatMode === 'auto'"
@@ -118,17 +165,33 @@
<!-- 设置面板 -->
<v-col cols="12">
<v-card flat class="pa-4 rounded-lg" border>
<v-card
flat
class="pa-4 rounded-lg"
border
>
<div class="d-flex align-center mb-3">
<v-icon color="primary" class="mr-2">mdi-calendar-multiselect</v-icon>
<h3 class="text-subtitle-1 font-weight-medium mr-auto">选择导出天数</h3>
<v-icon
color="primary"
class="mr-2"
>
mdi-calendar-multiselect
</v-icon>
<h3 class="text-subtitle-1 font-weight-medium mr-auto">
选择导出天数
</h3>
<v-btn
variant="text"
color="primary"
class="ml-2"
@click="selectAllDays"
>
<v-icon start size="small">mdi-checkbox-multiple-marked</v-icon>
<v-icon
start
size="small"
>
mdi-checkbox-multiple-marked
</v-icon>
全选
</v-btn>
<v-btn
@@ -137,19 +200,37 @@
class="ml-2"
@click="clearSelectedDays"
>
<v-icon start size="small">mdi-checkbox-multiple-blank-outline</v-icon>
<v-icon
start
size="small"
>
mdi-checkbox-multiple-blank-outline
</v-icon>
清除
</v-btn>
</div>
<v-chip-group v-model="selectedDays" multiple class="mb-2" color="primary">
<v-chip v-for="day in 7" :key="day" :value="day" filter variant="tonal" class="filter-chip" label>
<v-chip-group
v-model="selectedDays"
multiple
class="mb-2"
color="primary"
>
<v-chip
v-for="day in 7"
:key="day"
:value="day"
filter
variant="tonal"
class="filter-chip"
label
>
{{ dayNames[day] }}
<v-badge
v-if="getDaySchedule(day).length > 0"
:content="getDaySchedule(day).length"
color="primary"
inline
></v-badge>
/>
</v-chip>
</v-chip-group>
</v-card>
@@ -157,31 +238,51 @@
<!-- 改进设置选项卡,显示为开关组 -->
<v-col cols="12">
<v-card flat class="pa-4 rounded-lg" border>
<v-card
flat
class="pa-4 rounded-lg"
border
>
<div class="d-flex align-center mb-3">
<v-icon color="primary" class="mr-2">mdi-cog</v-icon>
<h3 class="text-subtitle-1 font-weight-medium">显示配置</h3>
<v-icon
color="primary"
class="mr-2"
>
mdi-cog
</v-icon>
<h3 class="text-subtitle-1 font-weight-medium">
显示配置
</h3>
</div>
<v-row>
<v-col cols="12" sm="6">
<v-col
cols="12"
sm="6"
>
<v-switch
v-model="settings.hideTeacherName"
label="不显示教师姓名"
color="primary"
inset
hide-details
></v-switch>
/>
</v-col>
<v-col cols="12" sm="6">
<v-col
cols="12"
sm="6"
>
<v-switch
v-model="settings.hideRoom"
label="不显示教室信息"
color="primary"
inset
hide-details
></v-switch>
/>
</v-col>
<v-col cols="12" sm="6">
<v-col
cols="12"
sm="6"
>
<v-text-field
v-model.number="settings.totalWeeks"
label="总周数"
@@ -193,19 +294,23 @@
variant="outlined"
prepend-inner-icon="mdi-calendar-week"
class="mt-3"
></v-text-field>
/>
</v-col>
</v-row>
</v-card>
</v-col>
<!-- 添加加载状态的骨架屏 -->
<v-card v-if="loading" class="my-4" outlined>
<v-card
v-if="loading"
class="my-4"
outlined
>
<v-card-text>
<v-skeleton-loader
type="table"
class="mx-auto"
></v-skeleton-loader>
/>
</v-card-text>
</v-card>
@@ -230,12 +335,31 @@
</v-alert>
<!-- 课程表预览 -->
<v-card v-if="processedData" class="my-4" elevation="1">
<v-card
v-if="processedData"
class="my-4"
elevation="1"
>
<v-card-title class="d-flex align-center pa-4 bg-primary-lighten-5">
<v-icon color="primary" class="mr-2">mdi-table</v-icon>
<v-icon
color="primary"
class="mr-2"
>
mdi-table
</v-icon>
<span class="font-weight-bold">课程表</span>
<v-chip color="primary" class="ml-3" size="small" pill>
<v-icon start size="x-small">mdi-book-open-variant</v-icon>
<v-chip
color="primary"
class="ml-3"
size="small"
pill
>
<v-icon
start
size="x-small"
>
mdi-book-open-variant
</v-icon>
{{ processedData.tableData.length }} 节课程
</v-chip>
</v-card-title>
@@ -263,8 +387,15 @@
/>
</template>
<template v-for="day in 7" #[`item.${day}`]="{ item }" :key="day">
<div v-if="item[day]" class="course-cell">
<template
v-for="day in 7"
#[`item.${day}`]="{ item }"
:key="day"
>
<div
v-if="item[day]"
class="course-cell"
>
<template v-if="Array.isArray(item[day])">
<div
v-for="(course, index) in item[day]"
@@ -275,12 +406,12 @@
<span
v-if="!settings.hideTeacherName && course.teacher"
>
<br/>{{ course.teacher }}
<br>{{ course.teacher }}
</span>
<span
v-if="!settings.hideRoom && course.room"
>
<br/>{{ course.room }}
<br>{{ course.room }}
</span>
<span
v-if="course.weekType"
@@ -295,12 +426,12 @@
<span
v-if="!settings.hideTeacherName && item[day].teacher"
>
<br/>{{ item[day].teacher }}
<br>{{ item[day].teacher }}
</span>
<span
v-if="!settings.hideRoom && item[day].room"
>
<br/>{{ item[day].room }}
<br>{{ item[day].room }}
</span>
<span
v-if="item[day].weekType"
@@ -316,18 +447,48 @@
</v-card>
<!-- 时间表 -->
<v-card v-if="hasExportData" class="my-4" elevation="1">
<v-card
v-if="hasExportData"
class="my-4"
elevation="1"
>
<v-card-title class="d-flex align-center pa-4 bg-primary-lighten-5">
<v-icon color="primary" class="mr-2">mdi-timetable</v-icon>
<v-icon
color="primary"
class="mr-2"
>
mdi-timetable
</v-icon>
<span class="font-weight-bold">每日课程时间表</span>
<v-chip class="ml-3" size="small" color="primary" pill>
<v-icon start size="x-small">mdi-clock-outline</v-icon>
<v-chip
class="ml-3"
size="small"
color="primary"
pill
>
<v-icon
start
size="x-small"
>
mdi-clock-outline
</v-icon>
{{ totalClassHours }} 课时
</v-chip>
<v-tooltip v-if="exportPeriods.length > 0">
<template v-slot:activator="{ props }">
<v-chip class="ml-2" size="small" color="info" v-bind="props" pill>
<v-icon start size="x-small">mdi-information-outline</v-icon>
<template #activator="{ props }">
<v-chip
class="ml-2"
size="small"
color="info"
v-bind="props"
pill
>
<v-icon
start
size="x-small"
>
mdi-information-outline
</v-icon>
节次已重排
</v-chip>
</template>
@@ -336,93 +497,165 @@
</v-card-title>
<v-card-text>
<!-- 美化日期导航标签 -->
<v-tabs v-if="daysWithSchedule.length > 0" v-model="activeDay" class="mb-4" color="primary" grow
align-tabs="center">
<v-tab v-for="day in daysWithSchedule" :key="day" :value="day" class="px-2 font-weight-medium">
<v-tabs
v-if="daysWithSchedule.length > 0"
v-model="activeDay"
class="mb-4"
color="primary"
grow
align-tabs="center"
>
<v-tab
v-for="day in daysWithSchedule"
:key="day"
:value="day"
class="px-2 font-weight-medium"
>
{{ dayNames[day] }}
<v-badge
:content="getDaySchedule(day).length"
color="primary"
inline
></v-badge>
/>
</v-tab>
</v-tabs>
<!-- 当前选中日期的课程表 -->
<v-window v-model="activeDay">
<v-window-item v-for="day in daysWithSchedule" :key="day" :value="day">
<v-table density="compact" class="rounded" :headers-length="6" disable-sort>
<v-window-item
v-for="day in daysWithSchedule"
:key="day"
:value="day"
>
<v-table
density="compact"
class="rounded"
:headers-length="6"
disable-sort
>
<thead>
<tr>
<th class="text-center">节次</th>
<th>课程</th>
<th>时间</th>
<th>教师</th>
<th>教室</th>
<th>周次</th>
</tr>
<tr>
<th class="text-center">
节次
</th>
<th>课程</th>
<th>时间</th>
<th>教师</th>
<th>教室</th>
<th>周次</th>
</tr>
</thead>
<tbody>
<template v-for="(group, index) in groupByPeriod(getDaySchedule(day))" :key="index">
<tr>
<td class="text-center font-weight-bold">
{{ group.period }}
<v-tooltip v-if="group.originalPeriod !== group.period">
<template v-slot:activator="{ props }">
<v-icon size="x-small" v-bind="props" color="info" class="ml-1">mdi-sync</v-icon>
<template
v-for="(group, index) in groupByPeriod(getDaySchedule(day))"
:key="index"
>
<tr>
<td class="text-center font-weight-bold">
{{ group.period }}
<v-tooltip v-if="group.originalPeriod !== group.period">
<template #activator="{ props }">
<v-icon
size="x-small"
v-bind="props"
color="info"
class="ml-1"
>
mdi-sync
</v-icon>
</template>
原节次: {{ group.originalPeriod }}
</v-tooltip>
</td>
<td>
<div
v-for="(item, i) in group.items"
:key="i"
class="mb-1"
>
<v-chip
size="small"
:color="getSubjectColor(item.subject)"
label
text-color="white"
class="mr-1"
>
{{ item.subject }}
</v-chip>
<v-chip
v-if="group.items.length > 1"
size="x-small"
class="ml-1"
:color="item.weekType === '单' ? 'warning' : 'success'"
>
{{ item.weekType }}周
</v-chip>
</div>
</td>
<td>
<div
v-for="(timeSlot, i) in group.uniqueTimeSlots"
:key="i"
class="mb-1"
>
<v-chip
size="x-small"
class="time-chip"
>
{{ formatTime(timeSlot.startTime) }} - {{ formatTime(timeSlot.endTime) }}
</v-chip>
</div>
</td>
<td>
<template v-if="!settings.hideTeacherName">
<div
v-for="(item, i) in group.items"
:key="i"
class="mb-1"
>
{{ item.teacher || '-' }}
</div>
</template>
原节次: {{ group.originalPeriod }}
</v-tooltip>
</td>
<td>
<div v-for="(item, i) in group.items" :key="i" class="mb-1">
<v-chip size="small" :color="getSubjectColor(item.subject)" label text-color="white"
class="mr-1">
{{ item.subject }}
</v-chip>
<v-chip v-if="group.items.length > 1" size="x-small" class="ml-1"
:color="item.weekType === '单' ? 'warning' : 'success'">
{{ item.weekType }}周
</v-chip>
</div>
</td>
<td>
<div v-for="(timeSlot, i) in group.uniqueTimeSlots" :key="i" class="mb-1">
<v-chip size="x-small" class="time-chip">
{{ formatTime(timeSlot.startTime) }} - {{ formatTime(timeSlot.endTime) }}
</v-chip>
</div>
</td>
<td>
<template v-if="!settings.hideTeacherName">
<div v-for="(item, i) in group.items" :key="i" class="mb-1">
{{ item.teacher || '-' }}
<template v-else>
-
</template>
</td>
<td>
<template v-if="!settings.hideRoom">
<div
v-for="(item, i) in group.items"
:key="i"
class="mb-1"
>
{{ item.room || '-' }}
</div>
</template>
<template v-else>
-
</template>
</td>
<td>
<div
v-for="(item, i) in group.items"
:key="i"
class="mb-1"
>
{{ item.weeks }}
</div>
</template>
<template v-else>-</template>
</td>
<td>
<template v-if="!settings.hideRoom">
<div v-for="(item, i) in group.items" :key="i" class="mb-1">
{{ item.room || '-' }}
</div>
</template>
<template v-else>-</template>
</td>
<td>
<div v-for="(item, i) in group.items" :key="i" class="mb-1">
{{ item.weeks }}
</div>
</td>
</tr>
</template>
</td>
</tr>
</template>
</tbody>
</v-table>
</v-window-item>
</v-window>
<!-- 无数据提示 -->
<v-alert v-if="hasExportData && daysWithSchedule.length === 0" type="info" class="mt-3">
<v-alert
v-if="hasExportData && daysWithSchedule.length === 0"
type="info"
class="mt-3"
>
没有找到任何课程数据
</v-alert>
</v-card-text>
@@ -430,24 +663,24 @@
</v-card-text>
<v-card-actions class="">
<v-spacer></v-spacer>
<v-spacer />
<v-btn
color="primary"
variant="outlined"
:loading="loading"
:disabled="(!jsonText && !file) || loading"
@click="processInput"
prepend-icon="mdi-cog-refresh"
@click="processInput"
>
处理数据
</v-btn>
<v-btn
color="info"
:disabled="!hasExportData"
@click="showExportPreview"
class="ml-2"
prepend-icon="mdi-eye"
border
@click="showExportPreview"
>
刷新
</v-btn>
@@ -455,10 +688,10 @@
color="success"
variant="outlined"
:disabled="!hasExportData"
@click="downloadCSV"
class="ml-2"
prepend-icon="mdi-download"
border
@click="downloadCSV"
>
下载CSV
</v-btn>
@@ -735,6 +968,22 @@ export default {
return days;
}
},
async mounted() {
// 加载YAML解析库
try {
await loadJsYaml();
this.yamlLibLoaded = true;
} catch (error) {
this.error = error.message;
}
// 监听daysWithSchedule变化,设置默认选中的日期
this.$watch('daysWithSchedule', (newVal) => {
if (newVal.length > 0 && !this.activeDay) {
this.activeDay = newVal[0];
}
});
},
methods: {
async handleFileChange() {
this.resetData();
@@ -762,7 +1011,7 @@ export default {
// 读取文件内容
try {
const reader = new FileReader();
const reader = new window.FileReader();
reader.onload = async (fileEvent) => {
try {
this.jsonText = fileEvent.target.result;
@@ -1026,7 +1275,7 @@ export default {
// 添加BOM头以确保Excel正确识别UTF-8编码
const BOM = "\uFEFF";
const blob = new Blob([BOM + csvContent], {
const blob = new window.Blob([BOM + csvContent], {
type: "text/csv;charset=utf-8",
});
@@ -1241,22 +1490,6 @@ export default {
clearSelectedDays() {
this.selectedDays = [];
}
},
async mounted() {
// 加载YAML解析库
try {
await loadJsYaml();
this.yamlLibLoaded = true;
} catch (error) {
this.error = error.message;
}
// 监听daysWithSchedule变化,设置默认选中的日期
this.$watch('daysWithSchedule', (newVal) => {
if (newVal.length > 0 && !this.activeDay) {
this.activeDay = newVal[0];
}
});
}
};
</script>
+1 -1
View File
@@ -26,7 +26,7 @@
label="server.authDomain"
/>
</v-form>
<v-divider class="my-4"/>
<v-divider class="my-4" />
<v-btn
class="me-2"
+12 -6
View File
@@ -42,7 +42,7 @@
<v-list-item-subtitle>{{ currentDataKey }}</v-list-item-subtitle>
</v-list-item>
</v-list>
<v-divider class="my-4"/>
<v-divider class="my-4" />
<v-row>
<v-col
cols="12"
@@ -83,11 +83,17 @@
</v-btn>
</v-col>
</v-row>
<v-divider class="my-4"/>
<v-divider class="my-4" />
<v-row>
<v-col cols="12">
<v-card border color="primary" variant="tonal">
<v-card-title class="text-subtitle-1">聊天室消息</v-card-title>
<v-card
border
color="primary"
variant="tonal"
>
<v-card-title class="text-subtitle-1">
聊天室消息
</v-card-title>
<v-card-text>
<v-textarea
v-model="chatInput"
@@ -97,7 +103,7 @@
rows="2"
/>
<div class="d-flex">
<v-spacer/>
<v-spacer />
<v-btn
:disabled="!canSendChat"
color="primary"
@@ -178,7 +184,7 @@
<v-card border>
<v-card-title class="d-flex align-center">
事件日志
<v-spacer/>
<v-spacer />
<v-btn
color="error"
size="small"
+8 -2
View File
@@ -6,13 +6,19 @@
请将这个ID复制并私聊给开发者,以便进行问题排查。
</v-card-subtitle>
<v-card-text>
<div class="text-h6 mb-2">访客 ID</div>
<div class="text-h6 mb-2">
访客 ID
</div>
<v-code class="d-block pa-2 bg-grey-lighten-4 rounded mb-4">
{{ visitorId || '加载中...' }}
</v-code>
</v-card-text>
<v-card-actions>
<v-btn color="primary" @click="loadData" :loading="loading">
<v-btn
color="primary"
:loading="loading"
@click="loadData"
>
Refresh
</v-btn>
</v-card-actions>
+1 -1
View File
@@ -11,7 +11,7 @@
<v-app-bar-title class="text-h6">
编辑考试配置
</v-app-bar-title>
<v-spacer/>
<v-spacer />
<v-btn
:loading="saving"
color="success"
File diff suppressed because it is too large Load Diff
+2190 -1572
View File
File diff suppressed because it is too large Load Diff
+119 -35
View File
@@ -7,11 +7,20 @@
@click="$router.push('/')"
/>
</template>
<v-app-bar-title v-if="list && !isRenaming" class="text-h6">{{ list.name }}</v-app-bar-title>
<v-app-bar-title v-else class="text-h6">列表</v-app-bar-title>
<v-app-bar-title
v-if="list && !isRenaming"
class="text-h6"
>
{{ list.name }}
</v-app-bar-title>
<v-app-bar-title
v-else
class="text-h6"
>
列表
</v-app-bar-title>
</v-app-bar>
<v-container>
<div class="d-flex align-center mb-4">
<v-btn
border
@@ -23,11 +32,19 @@
</v-btn>
<h1 v-if="list && !isRenaming">
{{ list.name }}
<v-btn border icon size="small" @click="startRenaming">
<v-btn
border
icon
size="small"
@click="startRenaming"
>
<v-icon>mdi-pencil</v-icon>
</v-btn>
</h1>
<div v-else-if="list && isRenaming" class="d-flex align-center">
<div
v-else-if="list && isRenaming"
class="d-flex align-center"
>
<v-text-field
v-model="newListName"
autofocus
@@ -37,11 +54,20 @@
label="列表名称"
style="min-width: 200px;"
@keyup.enter="saveListName"
></v-text-field>
<v-btn class="mr-2" color="primary" size="small" @click="saveListName">
/>
<v-btn
class="mr-2"
color="primary"
size="small"
@click="saveListName"
>
<v-icon>mdi-check</v-icon>
</v-btn>
<v-btn color="error" size="small" @click="cancelRenaming">
<v-btn
color="error"
size="small"
@click="cancelRenaming"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
@@ -51,10 +77,14 @@
</div>
<v-card border class="mb-5" rounded="xl">
<v-card
border
class="mb-5"
rounded="xl"
>
<v-card-title class="d-flex align-center">
项目列表
<v-spacer/>
<v-spacer />
<v-btn-toggle
v-model="sortType"
mandatory
@@ -97,7 +127,7 @@
</v-list-item>
</v-list>
<v-card-actions v-if="sortedItems.length > 0">
<v-spacer/>
<v-spacer />
<v-btn
:disabled="!hasCompletedItems"
color="error"
@@ -108,7 +138,11 @@
</v-btn>
</v-card-actions>
</v-card>
<v-card border class="mb-5" rounded="xl">
<v-card
border
class="mb-5"
rounded="xl"
>
<v-card-title>添加新项目</v-card-title>
<v-card-text>
<v-text-field
@@ -126,7 +160,11 @@
</v-card-text>
</v-card>
<v-card border class="mb-5" rounded="xl">
<v-card
border
class="mb-5"
rounded="xl"
>
<v-card-title>列表排序</v-card-title>
<v-card-text>
<v-text-field
@@ -153,16 +191,30 @@
</v-card>
<!-- 确认删除对话框 -->
<v-dialog v-model="deleteDialog.show" max-width="500">
<v-card border rounded="xl">
<v-dialog
v-model="deleteDialog.show"
max-width="500"
>
<v-card
border
rounded="xl"
>
<v-card-title>{{ deleteDialog.title }}</v-card-title>
<v-card-text>{{ deleteDialog.text }}</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" variant="text" @click="deleteDialog.show = false">
<v-spacer />
<v-btn
color="primary"
variant="text"
@click="deleteDialog.show = false"
>
取消
</v-btn>
<v-btn color="error" variant="text" @click="confirmDelete">
<v-btn
color="error"
variant="text"
@click="confirmDelete"
>
确认删除
</v-btn>
</v-card-actions>
@@ -170,8 +222,14 @@
</v-dialog>
<!-- 项目详情对话框 -->
<v-dialog v-model="itemDialog.show" max-width="600">
<v-card border rounded="xl">
<v-dialog
v-model="itemDialog.show"
max-width="600"
>
<v-card
border
rounded="xl"
>
<v-card-title>
<span v-if="!itemDialog.isEditing">项目详情</span>
<span v-else>编辑项目</span>
@@ -181,13 +239,16 @@
<div v-if="!itemDialog.isEditing && itemDialog.item">
<v-list>
<v-list-item>
<v-list-item-title class="text-subtitle-1 font-weight-bold">{{ itemDialog.item.name }}
<v-list-item-title class="text-subtitle-1 font-weight-bold">
{{ itemDialog.item.name }}
</v-list-item-title>
<v-list-item-subtitle>{{ itemDialog.item.id }}</v-list-item-subtitle>
</v-list-item>
<v-list-item>
<v-list-item-title class="text-subtitle-1 font-weight-bold">状态</v-list-item-title>
<v-list-item-title class="text-subtitle-1 font-weight-bold">
状态
</v-list-item-title>
<v-list-item-subtitle>
<v-chip
:color="itemDialog.item.completed ? 'success' : 'warning'"
@@ -199,20 +260,24 @@
</v-list-item>
<v-list-item v-if="itemDialog.item.description">
<v-list-item-title class="text-subtitle-1 font-weight-bold">描述</v-list-item-title>
<v-list-item-title class="text-subtitle-1 font-weight-bold">
描述
</v-list-item-title>
<v-list-item-subtitle>{{ itemDialog.item.description }}</v-list-item-subtitle>
</v-list-item>
</v-list>
</div>
<div v-else-if="itemDialog.isEditing && itemDialog.item" class="pa-2">
<div
v-else-if="itemDialog.isEditing && itemDialog.item"
class="pa-2"
>
<v-text-field
v-model="itemDialog.editedItem.name"
class="mb-3"
label="名称"
variant="outlined"
></v-text-field>
/>
<v-textarea
v-model="itemDialog.editedItem.description"
@@ -220,7 +285,7 @@
label="描述"
rows="3"
variant="outlined"
></v-textarea>
/>
<v-switch
@@ -228,37 +293,56 @@
color="success"
hide-details
label="已完成"
></v-switch>
/>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-spacer />
<template v-if="!itemDialog.isEditing">
<v-btn color="primary" variant="text" @click="startEditingItem">
<v-btn
color="primary"
variant="text"
@click="startEditingItem"
>
编辑
</v-btn>
<v-btn color="error" variant="text" @click="confirmDeleteItem(itemDialog.item?.id)">
<v-btn
color="error"
variant="text"
@click="confirmDeleteItem(itemDialog.item?.id)"
>
删除
</v-btn>
<v-btn color="secondary" variant="text" @click="itemDialog.show = false">
<v-btn
color="secondary"
variant="text"
@click="itemDialog.show = false"
>
关闭
</v-btn>
</template>
<template v-else>
<v-btn color="success" variant="text" @click="saveItemChanges">
<v-btn
color="success"
variant="text"
@click="saveItemChanges"
>
保存
</v-btn>
<v-btn color="secondary" variant="text" @click="cancelEditingItem">
<v-btn
color="secondary"
variant="text"
@click="cancelEditingItem"
>
取消
</v-btn>
</template>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
+68 -19
View File
@@ -7,12 +7,16 @@
@click="$router.push('/')"
/>
</template>
<v-app-bar-title class="text-h6">列表</v-app-bar-title>
<v-app-bar-title class="text-h6">
列表
</v-app-bar-title>
</v-app-bar>
<v-container>
<v-card border class="mb-5" rounded="xl">
<v-card
border
class="mb-5"
rounded="xl"
>
<v-card-title>现有列表</v-card-title>
<v-card-text v-if="lists.length === 0">
暂无列表,请创建新列表
@@ -27,7 +31,10 @@
<div v-if="list.id !== editingListId">
<v-list-item-title>{{ list.name }}</v-list-item-title>
</div>
<div v-else class="d-flex align-center w-100">
<div
v-else
class="d-flex align-center w-100"
>
<v-text-field
v-model="editListName"
autofocus
@@ -36,21 +43,41 @@
hide-details
label="列表名称"
@keyup.enter="saveListName"
></v-text-field>
<v-btn border class="mr-2" color="primary" icon @click.stop.prevent="saveListName">
/>
<v-btn
border
class="mr-2"
color="primary"
icon
@click.stop.prevent="saveListName"
>
<v-icon>mdi-check</v-icon>
</v-btn>
<v-btn border color="error" icon @click.stop.prevent="cancelEditing">
<v-btn
border
color="error"
icon
@click.stop.prevent="cancelEditing"
>
<v-icon>mdi-close</v-icon>
</v-btn>
</div>
<template #append>
<div v-if="list.id !== editingListId">
<v-btn border class="mr-2" icon @click.stop.prevent="startEditing(list.id)">
<v-btn
border
class="mr-2"
icon
@click.stop.prevent="startEditing(list.id)"
>
<v-icon>mdi-pencil</v-icon>
</v-btn>
<v-btn border icon @click.stop.prevent="confirmDeleteList(list.id)">
<v-btn
border
icon
@click.stop.prevent="confirmDeleteList(list.id)"
>
<v-icon>mdi-delete</v-icon>
</v-btn>
</div>
@@ -58,30 +85,49 @@
</v-list-item>
</v-list>
</v-card>
<v-card border class="mb-5" rounded="xl">
<v-card
border
class="mb-5"
rounded="xl"
>
<v-card-title>创建新列表</v-card-title>
<v-card-text>
<v-text-field
v-model="newListName"
:rules="[v => !!v || '名称不能为空']"
label="列表名称"
></v-text-field>
<v-btn :disabled="!newListName" color="primary" @click="createNewList">
/>
<v-btn
:disabled="!newListName"
color="primary"
@click="createNewList"
>
创建列表
</v-btn>
</v-card-text>
</v-card>
<!-- 确认删除对话框 -->
<v-dialog v-model="deleteDialog.show" max-width="500">
<v-dialog
v-model="deleteDialog.show"
max-width="500"
>
<v-card border>
<v-card-title>删除列表</v-card-title>
<v-card-text>{{ deleteDialog.text }}</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="primary" variant="text" @click="deleteDialog.show = false">
<v-spacer />
<v-btn
color="primary"
variant="text"
@click="deleteDialog.show = false"
>
取消
</v-btn>
<v-btn color="error" variant="text" @click="confirmDelete">
<v-btn
color="error"
variant="text"
@click="confirmDelete"
>
确认删除
</v-btn>
</v-card-actions>
@@ -134,8 +180,11 @@ export default {
try {
// 从classworks-list-main加载学生列表数据
const response = await dataProvider.loadData("classworks-list-main");
if (response && Array.isArray(response)) {
this.studentList = response;
const dataArray = Array.isArray(response) ? response : (response && Array.isArray(response.value) ? response.value : null);
if (response && response.success !== false && dataArray) {
this.studentList = dataArray;
} else {
this.studentList = [];
}
+161 -57
View File
@@ -11,10 +11,11 @@
icon="mdi-menu"
variant="text"
@click="drawer = !drawer"
/>
</template>
<v-app-bar-title class="text-h6">设置</v-app-bar-title>
<v-app-bar-title class="text-h6">
设置
</v-app-bar-title>
</v-app-bar>
<v-container fluid>
@@ -43,14 +44,23 @@
direction="vertical"
style="width: 100%"
>
<v-tabs-window-item value="index"
>
<v-card border class="service-card gradient-right clickable mb-4" color="primary" elevation="8" hover
rounded="xl" variant="tonal" @click="openClassworksKV">
<v-tabs-window-item value="index">
<v-card
border
class="service-card gradient-right clickable mb-4"
color="primary"
elevation="8"
hover
rounded="xl"
variant="tonal"
@click="openClassworksKV"
>
<v-card-item>
<div class="card-title">
<div>
<div class="text-h6">在寻找 Classworks KV ?</div>
<div class="text-h6">
在寻找 Classworks KV ?
</div>
<div class="text-caption text-medium-emphasis">
文档形键值数据库
</div>
@@ -71,50 +81,53 @@
</div>
</v-card-text>
</v-card>
<v-card border class="rounded-xl mb-4" subtitle="设置" title="Classworks">
<v-card
border
class="rounded-xl mb-4"
subtitle="设置"
title="Classworks"
>
<v-card-text>
<v-alert
class="rounded-xl"
color="error"
icon="mdi-alert-circle"
variant="tonal"
>Classworks
>
Classworks
是开源免费的软件,官方没有提供任何形式的付费支持服务,源代码仓库地址在
<a
href="https://github.com/ZeroCatDev/Classworks"
target="_blank"
>https://github.com/ZeroCatDev/Classworks</a
>。如果您通过有偿协助等付费方式取得本应用,在遇到问题时请在与卖家约定的服务框架下,优先向卖家求助。如果卖家没有提供您预期的服务,请退款或通过其它形式积极维护您的合法权益。
</v-alert
>
>https://github.com/ZeroCatDev/Classworks</a>。如果您通过有偿协助等付费方式取得本应用,在遇到问题时请在与卖家约定的服务框架下,优先向卖家求助。如果卖家没有提供您预期的服务,请退款或通过其它形式积极维护您的合法权益。
</v-alert>
<v-alert
class="mt-4 rounded-xl"
color="info"
icon="mdi-information"
variant="tonal"
>请不要使用浏览器清除缓存功能,否则会导致配置丢失。
<del
>恶意的操作可能导致您受到贵校教师的处理
</del
>
</v-alert
>
请不要使用浏览器清除缓存功能,否则会导致配置丢失。
<del>恶意的操作可能导致您受到贵校教师的处理
</del>
</v-alert>
<v-alert
class="mt-4 rounded-xl"
color="warning"
icon="mdi-information"
variant="tonal"
><p>
请不要使用包括但不限于360极速浏览器、360安全浏览器、夸克浏览器、QQ浏览器等浏览器使用
Classworks
,这些浏览器过时且存在严重的一致性问题。在Windows上,使用新版
Microsoft Edge 浏览器是最推荐的选择。
</p>
>
<p>
请不要使用包括但不限于360极速浏览器、360安全浏览器、夸克浏览器、QQ浏览器等浏览器使用
Classworks
,这些浏览器过时且存在严重的一致性问题。在Windows上,使用新版
Microsoft Edge 浏览器是最推荐的选择。
</p>
<p style="color: #666">
上述浏览器商标为其所属公司所有,Classworks™
与上述浏览器所属公司无竞争关系。
</p>
<br/>
<br>
<v-btn
append-icon="mdi-open-in-new"
class="text-none rounded-xl"
@@ -122,14 +135,13 @@
href="https://www.microsoft.com/zh-cn/windows/microsoft-edge"
target="_blank"
variant="tonal"
>下载 Microsoft Edge(微软边缘浏览器)
</v-btn
>
</v-alert
>
下载 Microsoft Edge(微软边缘浏览器)
</v-btn>
</v-alert>
</v-card-text>
</v-card>
<about-card/>
<about-card />
</v-tabs-window-item>
<v-tabs-window-item value="server">
@@ -138,16 +150,32 @@
border
@saved="onSettingsSaved"
/>
<data-provider-settings-card border class="mt-4"/>
<kv-database-card border class="mt-4"/>
<data-provider-settings-card
border
class="mt-4"
/>
<kv-database-card
border
class="mt-4"
/>
</v-tabs-window-item>
<v-tabs-window-item value="student">
<student-list-card :is-mobile="isMobile" border/>
<teacher-list-card :is-mobile="isMobile" border class="mt-4"/>
<student-list-card
:is-mobile="isMobile"
border
/>
<teacher-list-card
:is-mobile="isMobile"
border
class="mt-4"
/>
</v-tabs-window-item>
<v-tabs-window-item value="share">
<settings-link-generator border class="mt-4"/>
<settings-link-generator
border
class="mt-4"
/>
</v-tabs-window-item>
<v-tabs-window-item value="refresh">
@@ -191,27 +219,35 @@
</v-tabs-window-item>
<v-tabs-window-item value="randomPicker">
<random-picker-card :is-mobile="isMobile" border/>
<random-picker-card
:is-mobile="isMobile"
border
/>
</v-tabs-window-item>
<v-tabs-window-item value="subject">
<subject-management-card border/>
<br/>
<homework-template-card border/>
<subject-management-card border />
<br>
<homework-template-card border />
</v-tabs-window-item>
<v-tabs-window-item value="developer"
>
<settings-card border icon="mdi-developer-board" title="开发者选项">
<v-tabs-window-item value="developer">
<settings-card
border
icon="mdi-developer-board"
title="开发者选项"
>
<v-list>
<v-list-item>
<template #prepend>
<v-icon class="mr-3" icon="mdi-code-tags"/>
<v-icon
class="mr-3"
icon="mdi-code-tags"
/>
</template>
<v-list-item-title>启用开发者选项</v-list-item-title>
<v-list-item-subtitle
>启用后可以查看和修改开发者设置
</v-list-item-subtitle
>
<v-list-item-subtitle>
启用后可以查看和修改开发者设置
</v-list-item-subtitle>
<template #append>
<v-switch
v-model="settings.developer.enabled"
@@ -229,29 +265,41 @@
@saved="onSettingsSaved"
/>
<template v-if="settings.developer.enabled">
<v-card border class="mt-4 rounded-lg">
<v-card
border
class="mt-4 rounded-lg"
>
<v-card-title class="d-flex align-center">
<v-icon class="mr-2" icon="mdi-cog-outline"/>
<v-icon
class="mr-2"
icon="mdi-cog-outline"
/>
所有设置
</v-card-title>
<v-card-subtitle> 浏览和修改所有可用设置</v-card-subtitle>
<v-card-text>
<settings-explorer @update="onSettingUpdate"/>
<settings-explorer @update="onSettingUpdate" />
</v-card-text>
</v-card>
</template>
<v-col v-if="settings.developer.enabled" cols="12"></v-col>
<v-col
v-if="settings.developer.enabled"
cols="12"
/>
</v-tabs-window-item>
<v-tabs-window-item value="about">
<about-card/>
<echo-chamber-card border class="mt-4"/>
<about-card />
<echo-chamber-card
border
class="mt-4"
/>
</v-tabs-window-item>
</v-tabs-window>
</v-container>
<!-- 消息记录组件 -->
<message-log ref="messageLog"/>
<message-log ref="messageLog" />
</div>
</template>
@@ -319,7 +367,7 @@ export default {
const settings = {
server: {
domain: getSetting("server.domain"),
classNumber: getSetting("server.classNumber"),
classNumber: "加载中...",
provider: getSetting("server.provider"),
},
refresh: {
@@ -488,8 +536,23 @@ export default {
},
},
mounted() {
async mounted() {
this.loadAllSettings();
try {
const dataProvider = (await import("@/utils/dataProvider")).default;
const res = await dataProvider.loadData("classworks-config-basic");
if (res && res.success !== false && res.classNumber) {
this.settings.server.classNumber = res.classNumber;
} else {
this.settings.server.classNumber = "我的班级";
}
} catch (e) {
this.settings.server.classNumber = "我的班级";
}
this.originalSettings = JSON.parse(JSON.stringify(this.settings));
this.unwatchSettings = watchSettings(() => {
this.loadAllSettings();
});
@@ -508,8 +571,49 @@ export default {
window.open(getSetting("server.authDomain"), "_blank");
},
loadAllSettings() {
// Keep existing classNumber or set to 加载中
const currentClassNumber = this.settings ? this.settings.server.classNumber : "加载中...";
this.settings = {
server: {
domain: getSetting("server.domain"),
classNumber: currentClassNumber,
provider: getSetting("server.provider"),
},
refresh: {
auto: getSetting("refresh.auto"),
interval: getSetting("refresh.interval"),
},
font: {
size: getSetting("font.size"),
},
edit: {
autoSave: getSetting("edit.autoSave"),
blockNonTodayAutoSave: getSetting("edit.blockNonTodayAutoSave"),
confirmNonTodaySave: getSetting("edit.confirmNonTodaySave"),
refreshBeforeEdit: getSetting("edit.refreshBeforeEdit"),
},
display: {
emptySubjectDisplay: getSetting("display.emptySubjectDisplay"),
dynamicSort: getSetting("display.dynamicSort"),
showRandomButton: getSetting("display.showRandomButton"),
showFullscreenButton: getSetting("display.showFullscreenButton"),
},
developer: {
enabled: getSetting("developer.enabled"),
showDebugConfig: getSetting("developer.showDebugConfig"),
},
message: {
showSidebar: getSetting("message.showSidebar"),
maxActiveMessages: getSetting("message.maxActiveMessages"),
timeout: getSetting("message.timeout"),
saveHistory: getSetting("message.saveHistory"),
},
};
Object.keys(this.settings).forEach((section) => {
Object.keys(this.settings[section]).forEach((key) => {
if (section === "server" && key === "classNumber") return;
this.settings[section][key] = getSetting(`${section}.${key}`);
});
});
+104 -27
View File
@@ -5,10 +5,18 @@
<v-col cols="12">
<v-card border>
<v-card-title class="d-flex align-center">
<v-icon class="mr-2" color="primary">mdi-network</v-icon>
<v-icon
class="mr-2"
color="primary"
>
mdi-network
</v-icon>
Socket.IO 连接调试器
<v-spacer />
<v-chip :color="connectionStatus.color" size="small">
<v-chip
:color="connectionStatus.color"
size="small"
>
{{ connectionStatus.text }}
</v-chip>
</v-card-title>
@@ -16,7 +24,10 @@
</v-col>
<!-- 连接信息卡片 -->
<v-col cols="12" md="6">
<v-col
cols="12"
md="6"
>
<v-card border>
<v-card-title>连接信息</v-card-title>
<v-card-text>
@@ -43,7 +54,10 @@
</template>
<v-list-item-title>传输方式</v-list-item-title>
<v-list-item-subtitle>
<v-chip size="x-small" :color="transportColor">
<v-chip
size="x-small"
:color="transportColor"
>
{{ transport || '未知' }}
</v-chip>
</v-list-item-subtitle>
@@ -113,40 +127,71 @@
</v-col>
<!-- 统计信息卡片 -->
<v-col cols="12" md="6">
<v-col
cols="12"
md="6"
>
<v-card border>
<v-card-title>统计信息</v-card-title>
<v-card-text>
<v-row>
<v-col cols="6">
<v-card variant="tonal" color="success">
<v-card
variant="tonal"
color="success"
>
<v-card-text class="text-center">
<div class="text-h4">{{ stats.eventsReceived }}</div>
<div class="text-caption">接收事件</div>
<div class="text-h4">
{{ stats.eventsReceived }}
</div>
<div class="text-caption">
接收事件
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="6">
<v-card variant="tonal" color="primary">
<v-card
variant="tonal"
color="primary"
>
<v-card-text class="text-center">
<div class="text-h4">{{ stats.eventsSent }}</div>
<div class="text-caption">发送事件</div>
<div class="text-h4">
{{ stats.eventsSent }}
</div>
<div class="text-caption">
发送事件
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="6">
<v-card variant="tonal" color="warning">
<v-card
variant="tonal"
color="warning"
>
<v-card-text class="text-center">
<div class="text-h4">{{ stats.errors }}</div>
<div class="text-caption">错误次数</div>
<div class="text-h4">
{{ stats.errors }}
</div>
<div class="text-caption">
错误次数
</div>
</v-card-text>
</v-card>
</v-col>
<v-col cols="6">
<v-card variant="tonal" color="info">
<v-card
variant="tonal"
color="info"
>
<v-card-text class="text-center">
<div class="text-h4">{{ stats.reconnects }}</div>
<div class="text-caption">重连次数</div>
<div class="text-h4">
{{ stats.reconnects }}
</div>
<div class="text-caption">
重连次数
</div>
</v-card-text>
</v-card>
</v-col>
@@ -175,14 +220,26 @@
</v-col>
<!-- 事件监控 -->
<v-col cols="12" md="6">
<v-col
cols="12"
md="6"
>
<v-card border>
<v-card-title>
事件监控
<v-chip class="ml-2" size="small">{{ activeListeners.size }} 个监听器</v-chip>
<v-chip
class="ml-2"
size="small"
>
{{ activeListeners.size }} 个监听器
</v-chip>
</v-card-title>
<v-card-text>
<v-list density="compact" max-height="300" style="overflow-y: auto">
<v-list
density="compact"
max-height="300"
style="overflow-y: auto"
>
<v-list-item v-if="activeListeners.size === 0">
<v-list-item-title class="text-center text-disabled">
暂无活动监听器
@@ -193,7 +250,9 @@
:key="listener"
>
<template #prepend>
<v-icon size="small">mdi-eye</v-icon>
<v-icon size="small">
mdi-eye
</v-icon>
</template>
<v-list-item-title>{{ listener }}</v-list-item-title>
</v-list-item>
@@ -203,7 +262,10 @@
</v-col>
<!-- 自定义事件发送 -->
<v-col cols="12" md="6">
<v-col
cols="12"
md="6"
>
<v-card border>
<v-card-title>发送自定义事件</v-card-title>
<v-card-text>
@@ -272,9 +334,15 @@
<span class="log-time">[{{ log.time }}]</span>
<span class="log-type">[{{ log.type.toUpperCase() }}]</span>
<span class="log-message">{{ log.message }}</span>
<pre v-if="log.data" class="log-data">{{ log.data }}</pre>
<pre
v-if="log.data"
class="log-data"
>{{ log.data }}</pre>
</div>
<div v-if="logs.length === 0" class="text-center text-disabled pa-8">
<div
v-if="logs.length === 0"
class="text-center text-disabled pa-8"
>
暂无日志
</div>
</div>
@@ -288,7 +356,10 @@
<v-card-title>连接诊断</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="4">
<v-col
cols="12"
md="4"
>
<v-btn
block
color="info"
@@ -298,7 +369,10 @@
测试连接
</v-btn>
</v-col>
<v-col cols="12" md="4">
<v-col
cols="12"
md="4"
>
<v-btn
block
color="success"
@@ -309,7 +383,10 @@
测量延迟
</v-btn>
</v-col>
<v-col cols="12" md="4">
<v-col
cols="12"
md="4"
>
<v-btn
block
color="warning"
+4 -2
View File
@@ -15,8 +15,10 @@ export const useExamStore = defineStore('exam', {
this.loadingList = true
try {
const response = await dataProvider.loadData('es_list')
if (Array.isArray(response)) {
this.examList = response
const dataArray = Array.isArray(response) ? response : (response && Array.isArray(response.value) ? response.value : null)
if (response && response.success !== false && dataArray) {
this.examList = dataArray
} else {
this.examList = []
}
+16
View File
@@ -236,3 +236,19 @@ $standard-accelerate: cubic-bezier(0.3, 0.0, 1.0, 1.0);
opacity: 0;
transform: scale(0.9);
}
// Slide X Transition
.slide-x-transition-enter-active,
.slide-x-transition-leave-active {
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.5, 1), opacity 0.3s ease;
}
.slide-x-transition-enter-from {
transform: translateX(15px);
opacity: 0;
}
.slide-x-transition-leave-to {
transform: translateX(-15px);
opacity: 0;
}
+8 -243
View File
@@ -1,7 +1,4 @@
import {kvLocalProvider} from "./providers/kvLocalProvider";
import {kvServerProvider} from "./providers/kvServerProvider";
import {getSetting, setSetting} from "./settings";
import {getEffectiveServerUrl} from "./serverRotation";
import {kvFileProvider} from "./providers/kvFileProvider";
export const formatResponse = (data) => data;
@@ -10,264 +7,32 @@ export const formatError = (message, code = "UNKNOWN_ERROR") => ({
error: {code, message},
});
// Main data provider with simplified API
// Main data provider with simplified API - Local File Storage
export default {
// Provider API methods
loadData: async (key) => {
const provider = getSetting("server.provider");
const useServer =
provider === "kv-server" || provider === "classworkscloud";
if (useServer) {
return kvServerProvider.loadData(key);
} else {
return kvLocalProvider.loadData(key);
}
return kvFileProvider.loadData(key);
},
saveData: async (key, data) => {
const provider = getSetting("server.provider");
const useServer =
provider === "kv-server" || provider === "classworkscloud";
if (useServer) {
return kvServerProvider.saveData(key, data);
} else {
return kvLocalProvider.saveData(key, data);
}
return kvFileProvider.saveData(key, data);
},
/**
* 获取键名列表
* @param {Object} options - 查询选项
* @param {string} options.sortBy - 排序字段,默认为 "key"
* @param {string} options.sortDir - 排序方向,"asc" 或 "desc",默认为 "asc"
* @param {number} options.limit - 每页返回的记录数,默认为 100
* @param {number} options.skip - 跳过的记录数,默认为 0
* @returns {Promise<Object>} 包含键名列表和分页信息的响应对象
*
* 使用示例:
* ```javascript
* // 获取前10个键名
* const result = await dataProvider.loadKeys({ limit: 10 });
* if (result.success !== false) {
* console.log('键名列表:', result.keys);
* console.log('总数:', result.total_rows);
* }
*
* // 获取第二页数据(跳过前10个)
* const page2 = await dataProvider.loadKeys({ limit: 10, skip: 10 });
*
* // 按键名降序排列
* const sorted = await dataProvider.loadKeys({ sortDir: 'desc' });
* ```
*
* 返回值格式:
* ```javascript
* {
* keys: ["key1", "key2", "key3"],
* total_rows: 150,
* current_page: {
* limit: 10,
* skip: 0,
* count: 10
* },
* load_more: "/api/kv/namespace/_keys?..." // 仅服务器模式
* }
* ```
*/
loadKeys: async (options = {}) => {
const provider = getSetting("server.provider");
const useServer =
provider === "kv-server" || provider === "classworkscloud";
if (useServer) {
return kvServerProvider.loadKeys(options);
} else {
return kvLocalProvider.loadKeys(options);
}
return kvFileProvider.loadKeys(options);
},
/**
* 获取键的云端访问地址,并处理本地到云端的数据迁移
*
* 功能说明:
* 1. 如果用户选择本地存储,则将本地键数据读取并存储到云端
* 2. 如果云端配置为空或错误则自动改成classworksCloudDefaults的配置
* 3. 根据网站验证情况(私有则添加token,公开或受保护则不需要)拼接键的get路径并返回
*
* @param {string} key - 要获取地址的键名
* @param {Object} options - 选项配置
* @param {boolean} options.migrateFromLocal - 是否从本地迁移数据到云端,默认为true
* @param {boolean} options.autoConfigureCloud - 是否自动配置云端默认设置,默认为true
* @returns {Promise<Object>} 包含键访问地址和操作结果的响应对象
*
* 使用示例:
* ```javascript
* import dataProvider from '@/utils/dataProvider';
*
* // 基本用法:获取键的云端地址并自动迁移本地数据
* const result = await dataProvider.getKeyCloudUrl('exam_configs');
* if (result.success) {
* console.log('云端访问地址:', result.url);
* console.log('是否已迁移数据:', result.migrated);
* console.log('是否自动配置:', result.configured);
* } else {
* console.error('获取失败:', result.error.message);
* }
*
* // 仅获取地址,不迁移数据
* const urlOnly = await dataProvider.getKeyCloudUrl('my_data', {
* migrateFromLocal: false
* });
*
* // 不自动配置云端设置
* const noAutoConfig = await dataProvider.getKeyCloudUrl('my_data', {
* autoConfigureCloud: false
* });
* ```
*
* 传入参数示例:
* ```javascript
* // 参数1: key (必需)
* 'exam_configs' // 字符串类型的键名
*
* // 参数2: options (可选)
* {
* migrateFromLocal: true, // 是否迁移本地数据
* autoConfigureCloud: true // 是否自动配置云端
* }
* ```
*
* 返回值格式:
* ```javascript
* // 成功时返回:
* {
* success: true,
* url: "https://kv-service.houlang.cloud/device-uuid-123/exam_configs?token=abc123", // 私有访问时包含token
* migrated: true, // 是否成功迁移了本地数据
* configured: false // 是否自动配置了云端设置
* }
*
* // 公开访问时返回:
* {
* success: true,
* url: "https://kv-service.houlang.cloud/device-uuid-123/exam_configs", // 公开访问不包含token
* migrated: false,
* configured: true
* }
*
* // 失败时返回:
* {
* success: false,
* error: {
* code: "CLOUD_URL_ERROR",
* message: "获取键云端地址失败"
* }
* }
* ```
* Removed cloud related methods
*/
async getKeyCloudUrl(key, options = {}) {
const {
migrateFromLocal = true,
autoConfigureCloud = true
} = options;
try {
const provider = getSetting("server.provider");
let serverUrl;
// Use effective server URL for classworkscloud provider
if (provider === "classworkscloud") {
serverUrl = getEffectiveServerUrl();
} else {
serverUrl = getSetting("server.domain");
}
let siteKey = getSetting("server.siteKey");
const machineId = getSetting("device.uuid");
let configured = false;
// 检查云端配置是否为空或错误,如果是则使用默认配置
if (!serverUrl || !machineId) {
if (autoConfigureCloud) {
// 使用classworksCloudDefaults配置
const classworksCloudDefaults = {
"server.domain": import.meta.env.VITE_DEFAULT_KV_SERVER || "https://kv-service.houlang.cloud",
"server.siteKey": "",
};
if (!serverUrl) {
setSetting("server.domain", classworksCloudDefaults["server.domain"]);
serverUrl = classworksCloudDefaults["server.domain"];
configured = true;
}
if (!siteKey) {
setSetting("server.siteKey", classworksCloudDefaults["server.siteKey"]);
siteKey = classworksCloudDefaults["server.siteKey"];
}
// 设置provider为classworkscloud
setSetting("server.provider", "classworkscloud");
// Get effective URL after setting provider
serverUrl = getEffectiveServerUrl();
} else {
return formatError("云端配置无效,请检查服务器域名和设备UUID", "CONFIG_ERROR");
}
}
let migrated = false;
// 如果需要迁移本地数据到云端
if (migrateFromLocal) {
try {
// 尝试从本地读取数据
const localData = await kvLocalProvider.loadData(key);
// 如果本地有数据且不是错误响应
if (localData && localData.success !== false) {
// 检查云端是否已有数据
const cloudData = await kvServerProvider.loadData(key);
// 如果云端没有数据,则迁移本地数据
if (cloudData && cloudData.success === false && cloudData.error?.code === "NOT_FOUND") {
const saveResult = await kvServerProvider.saveData(key, localData);
if (saveResult && saveResult.success !== false) {
migrated = true;
console.log(`已成功将键 ${key} 的数据从本地迁移到云端`);
}
}
}
} catch (error) {
console.warn(`迁移键 ${key} 的数据时出错:`, error);
// 迁移失败不影响URL生成,继续执行
}
}
// 获取认证token
const authtoken = getSetting("server.kvToken");
// 构建云端访问URL
let url = `${serverUrl}/kv/${key}?token=${authtoken}`;
return {
success: true,
url,
migrated,
configured
};
} catch (error) {
console.error('获取键云端地址时出错:', error);
return formatError(
error.message || "获取键云端地址失败",
"CLOUD_URL_ERROR"
);
}
getKeyCloudUrl: async () => {
return formatError("Cloud features are disabled", "NOT_SUPPORTED");
},
};
export const ErrorCodes = {
NOT_FOUND: "数据不存在",
NETWORK_ERROR: "网络连接失败",
+8
View File
@@ -1,3 +1,11 @@
/*
* @Author: error: error: git config user.name & please set dead value or install git && error: git config user.email & please set dead value or install git & please set dead value or install git
* @Date: 2026-03-07 23:45:42
* @LastEditors: error: error: git config user.name & please set dead value or install git && error: git config user.email & please set dead value or install git & please set dead value or install git
* @LastEditTime: 2026-03-20 22:54:42
* @FilePath: \ClassHomeworkBoard\src\utils\defaults\defaultData.js
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
*/
export const defaultConfig = {
studentList: [
"Classworks可以管理学生列表",
+88
View File
@@ -0,0 +1,88 @@
export function parseMarkdown(val) {
if (val == null) return '';
let text = String(val);
// 1. Normalize newlines
text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
// 2. Escape HTML
text = text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
// 3. Process Block Elements (Line-based)
const lines = text.split('\n');
let inList = false;
const output = [];
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
// Headers (H1-H3)
// ^# space ...
if (line.match(/^#\s+(.+)$/)) {
line = line.replace(/^#\s+(.+)$/, '<h1>$1</h1>');
} else if (line.match(/^##\s+(.+)$/)) {
line = line.replace(/^##\s+(.+)$/, '<h2>$1</h2>');
} else if (line.match(/^###\s+(.+)$/)) {
line = line.replace(/^###\s+(.+)$/, '<h3>$1</h3>');
}
// Horizontal Rule
else if (line.match(/^---$/)) {
line = '<hr>';
}
// Lists (- item)
if (line.match(/^\-\s+(.+)$/)) {
if (!inList) {
output.push('<ul>');
inList = true;
}
line = line.replace(/^\-\s+(.+)$/, '<li>$1</li>');
} else {
// Not a list item
if (inList) {
output.push('</ul>');
inList = false;
}
}
output.push(line);
}
if (inList) {
output.push('</ul>');
}
text = output.join('\n');
// 4. Process Inline Elements (Global)
// Bold (**text**)
text = text.replace(/\*\*(.*?)\*\*/g, '<b>$1</b>');
// Italic (*text*)
text = text.replace(/\*(.*?)\*/g, '<i>$1</i>');
// Highlight (<mark>text</mark>) -> escaped as &lt;mark&gt;
text = text.replace(/&lt;mark&gt;(.*?)&lt;\/mark&gt;/g, '<mark>$1</mark>');
// 5. Layout & Spacing
// Double newlines -> Paragraphs
text = text.replace(/\n\n/g, '<p>');
// Cleanup newlines around block tags
// Remove newline after block closing tags
text = text.replace(/(<\/h[1-3]>|<\/ul>|<\/li>|<hr>)\n/g, '$1');
// Remaining single newlines -> <br>
// But be careful not to break HTML structure we just built.
// Actually, standard markdown treats single newline as space, but here we usually want <br>.
// Let's replace remaining \n with <br>, but only if not inside tags?
// Simple approach:
text = text.replace(/\n/g, '<br>');
return text;
}
+112
View File
@@ -0,0 +1,112 @@
import { formatResponse, formatError } from "../dataProvider";
// Dynamically use the current origin instead of hardcoding localhost:3031
// This handles preview modes, different ports, and local network IPs automatically
const getApiBase = () => {
if (typeof window !== 'undefined') {
return `${window.location.origin}/api/local-file`;
}
return "/api/local-file";
};
export const kvFileProvider = {
async loadData(key) {
try {
const response = await fetch(`${getApiBase()}/load?key=${encodeURIComponent(key)}&_t=${Date.now()}`, {
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache'
}
});
if (response.status === 404) {
return formatError("数据不存在", "NOT_FOUND");
}
const contentType = response.headers.get("content-type");
if (!contentType || !contentType.includes("application/json")) {
// If we didn't get JSON, it might be the dev server serving index.html
// This happens if the plugin isn't intercepting the request properly
throw new Error("Server did not return JSON. Is the local file plugin running?");
}
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result.success) {
return formatResponse(result.data);
} else {
return formatError(result.error);
}
} catch (error) {
return formatError("读取本地文件失败:" + error.message);
}
},
async saveData(key, data) {
try {
const response = await fetch(`${getApiBase()}/save`, {
method: "POST",
headers: {
"Content-Type": "application/json; charset=utf-8",
Accept: "application/json",
},
body: JSON.stringify({ key, data }),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result.success) {
return formatResponse(true);
} else {
return formatError(result.error);
}
} catch (error) {
return formatError("保存本地文件失败:" + error.message);
}
},
async loadKeys(options = {}) {
try {
const response = await fetch(`${getApiBase()}/keys`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result.success) {
const allKeys = result.keys;
const {
sortDir = "asc",
limit = 100,
skip = 0
} = options;
const sortedKeys = allKeys.sort((a, b) => {
if (sortDir === "desc") {
return b.localeCompare(a);
}
return a.localeCompare(b);
});
const paginatedKeys = sortedKeys.slice(skip, skip + limit);
return formatResponse({
keys: paginatedKeys,
total_rows: sortedKeys.length,
current_page: {
limit,
skip,
count: paginatedKeys.length
},
load_more: null
});
} else {
return formatError(result.error);
}
} catch (error) {
return formatError("获取文件列表失败:" + error.message);
}
},
};
+5 -5
View File
@@ -1,3 +1,6 @@
import { on } from '@/utils/socketClient'
import { ref, onBeforeUnmount } from 'vue'
/**
* Vue 组件安全事件处理工具
* 防止组件卸载时的事件处理错误
@@ -63,7 +66,7 @@ export function createSafeEventMixin() {
if (this._isDestroying || !this.$el) return
try {
requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
if (!this._isDestroying && this.$el) {
domOperation()
}
@@ -114,7 +117,6 @@ export const socketEventMixin = {
*/
$socketOn(event, handler) {
return this.$safeOn(() => {
const { on } = require('@/utils/socketClient')
return on(event, this.$safeHandler(handler))
})
}
@@ -156,8 +158,6 @@ export function withSafeEvents(component) {
* Composition API 版本的安全事件处理
*/
export function useSafeEvents() {
const { ref, onBeforeUnmount } = require('vue')
const isDestroying = ref(false)
const cleanupFunctions = ref([])
@@ -193,7 +193,7 @@ export function useSafeEvents() {
if (isDestroying.value) return
try {
requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
if (!isDestroying.value) {
domOperation()
}
-128
View File
@@ -1,128 +0,0 @@
/**
* Sentry 异步初始化模块
*
* 从 main.js 中抽离,在 Vue app 挂载后异步加载,
* 避免 @sentry/vue (~60KB gzip) 阻塞首屏渲染。
*/
import * as Sentry from '@sentry/vue'
import { getVisitorId } from './visitorId'
// 保存 feedback integration 实例的引用
let feedbackIntegration = null
/**
* 异步初始化 Sentry(在 app mount 后调用)
* @param {import('vue').App} app - Vue app 实例
* @param {import('vue-router').Router} router - Vue Router 实例
*/
export function initSentry(app, router) {
Sentry.init({
app,
dsn: 'https://dc34ab47426f49c0925445f0d87b7007@report.houlang.cloud/6',
sendDefaultPii: true,
integrations: [
Sentry.browserTracingIntegration({ router }),
Sentry.replayIntegration({
maskAllText: false,
blockAllMedia: false,
}),
feedbackIntegration = Sentry.feedbackIntegration({
autoInject: false,
colorScheme: 'system',
showBranding: false,
showName: true,
showEmail: true,
isNameRequired: false,
isEmailRequired: false,
useSentryUser: {
name: 'username',
email: 'email',
},
themeDark: {
submitBackground: '#6200EA',
submitBackgroundHover: '#7C4DFF',
},
themeLight: {
submitBackground: '#6200EA',
submitBackgroundHover: '#7C4DFF',
},
}),
],
tracesSampleRate: 1.0,
tracePropagationTargets: [
'localhost',
/^https?:\/\/cs\.(houlang\.cloud|houlangs\.com)/,
],
replaysSessionSampleRate: 0,
replaysOnErrorSampleRate: 0,
enableLogs: true,
beforeSend(event) {
return event
},
})
// 异步设置用户 fingerprint
getVisitorId()
.then((visitorId) => {
Sentry.setUser({ id: visitorId, username: visitorId })
Sentry.setTag('fingerprint', visitorId)
console.log('Sentry 用户标识已设置:', visitorId)
})
.catch((error) => {
console.warn('设置 Sentry 用户标识失败:', error)
})
// 注册全局函数:打开反馈表单
window.openSentryFeedback = () => {
try {
if (!feedbackIntegration) {
console.warn('Sentry Feedback integration 未初始化')
return false
}
if (typeof feedbackIntegration.createWidget === 'function') {
const widget = feedbackIntegration.createWidget()
if (widget && typeof widget.open === 'function') {
widget.open()
console.log('Sentry Feedback 对话框已打开')
return true
}
}
if (typeof feedbackIntegration.openDialog === 'function') {
feedbackIntegration.openDialog()
console.log('Sentry Feedback 对话框已打开')
return true
}
console.warn('无法找到打开 Feedback 的方法')
console.log('可用方法:', Object.keys(feedbackIntegration))
return false
} catch (error) {
console.error('打开 Sentry Feedback 时出错:', error)
return false
}
}
// 注册全局函数:手动启动录制
window.startSentryReplay = () => {
try {
const client = Sentry.getClient()
if (!client) {
console.warn('Sentry 客户端未初始化')
return false
}
const integrations = client.getOptions().integrations || []
const replayIntegration = integrations.find(
(integration) => integration && integration.name === 'Replay'
)
if (replayIntegration && typeof replayIntegration.start === 'function') {
replayIntegration.start()
console.log('Sentry Replay 已手动启动')
return true
}
console.warn('无法找到 Sentry Replay integration')
return false
} catch (error) {
console.error('启动 Sentry Replay 时出错:', error)
return false
}
}
}
+2 -3
View File
@@ -7,8 +7,7 @@ import { getSetting } from "./settings";
// Server list for classworkscloud provider (in priority order)
const CLASSWORKS_CLOUD_SERVERS = [
"https://kv-service.houlang.cloud",
"https://kv-service.wuyuan.dev",
// You can add default servers here
];
/**
@@ -90,7 +89,7 @@ export function getEffectiveServerUrl() {
const provider = getSetting("server.provider");
if (provider === "classworkscloud") {
return CLASSWORKS_CLOUD_SERVERS[0];
return CLASSWORKS_CLOUD_SERVERS[0] || "";
}
return getSetting("server.domain") || "";
+27 -10
View File
@@ -65,8 +65,7 @@ const SETTINGS_STORAGE_KEY = "Classworks_settings";
// 新增: Classworks云端存储的默认设置
const classworksCloudDefaults = {
"server.domain": import.meta.env.VITE_DEFAULT_KV_SERVER || "https://kv-service.houlang.cloud",
//"server.domain": "http://localhost:3030",
"server.domain": import.meta.env.VITE_DEFAULT_KV_SERVER || "",
"server.siteKey": "",
};
@@ -216,7 +215,7 @@ const settingsDefinitions = {
},
"server.classNumber": {
type: "string",
default: "高三八班",
default: "我的班级",
//validate: (value) => /^[A-Za-z0-9]*$/.test(value),
validate: (value) => /.*/.test(value),
description: "班级编号",
@@ -239,7 +238,7 @@ const settingsDefinitions = {
},
"server.authDomain": {
type: "string",
default: import.meta.env.VITE_DEFAULT_AUTH_SERVER || "https://kv.houlang.cloud",
default: import.meta.env.VITE_DEFAULT_AUTH_SERVER || "",
description: "授权服务器域名",
icon: "mdi-shield-account",
validate: (value) => {
@@ -418,14 +417,14 @@ const settingsDefinitions = {
// 通知铃声设置
"notification.singleSound": {
type: "string",
default: "Teams 默认.mp3",
default: "universfield-new-notification-024-370048.mp3",
description: "单次通知铃声",
icon: "mdi-bell-ring",
// 设置单次通知时播放的音频文件
},
"notification.urgentSound": {
type: "string",
default: "Teams 默认通话铃.mp3",
default: "universfield-new-notification-024-370048.mp3",
description: "持续通知铃声",
icon: "mdi-bell-alert",
// 设置紧急通知时循环播放的音频文件
@@ -556,10 +555,18 @@ class SettingsManagerClass {
if (typeof localStorage === "undefined") return;
try {
const newValue = JSON.stringify(this.settingsCache);
localStorage.setItem(
SETTINGS_STORAGE_KEY,
JSON.stringify(this.settingsCache)
newValue
);
// 触发同页面的自定义事件,以便当前页面也能监听到设置改变
if (typeof window !== "undefined" && typeof window.CustomEvent !== "undefined") {
window.dispatchEvent(new window.CustomEvent('classworks-settings-changed', {
detail: this.settingsCache
}));
}
} catch (error) {
console.error("保存设置失败:", error);
}
@@ -717,15 +724,25 @@ class SettingsManagerClass {
if (typeof window === "undefined") return () => {
};
const handler = (event) => {
const storageHandler = (event) => {
if (event.key === SETTINGS_STORAGE_KEY) {
this.settingsCache = JSON.parse(event.newValue);
callback(this.settingsCache);
}
};
const customEventHandler = (event) => {
this.settingsCache = event.detail;
callback(this.settingsCache);
};
window.addEventListener("storage", handler);
return () => window.removeEventListener("storage", handler);
window.addEventListener("storage", storageHandler);
window.addEventListener("classworks-settings-changed", customEventHandler);
return () => {
window.removeEventListener("storage", storageHandler);
window.removeEventListener("classworks-settings-changed", customEventHandler);
};
}
/**
+3 -37
View File
@@ -6,48 +6,14 @@
// 所有可用的音频文件列表
export const soundFiles = [
"Teams Ping.mp3",
"Teams Remix.mp3",
"Teams bounce.mp3",
"Teams incoming-ringtone-level30.mp3",
"Teams incoming-ringtone-level40.mp3",
"Teams meetup_ring.mp3",
"Teams screenshare_ring.mp3",
"Teams teams_meet_up_reminder.mp3",
"Teams teams_notification.mp3",
"Teams 优先处理.mp3",
"Teams 共鸣.mp3",
"Teams 召唤.mp3",
"Teams 叮铃.mp3",
"Teams 增强.mp3",
"Teams 尤里卡.mp3",
"Teams 弹拨.mp3",
"Teams 提醒.mp3",
"Teams 摇摆.mp3",
"Teams 时空.mp3",
"Teams 气泡(大声).mp3",
"Teams 气泡.mp3",
"Teams 波普.mp3",
"Teams 波纹.mp3",
"Teams 滴水.mp3",
"Teams 点击.mp3",
"Teams 蜂鸣声.mp3",
"Teams 警报.mp3",
"Teams 赋予希望.mp3",
"Teams 轻弹.mp3",
"Teams 进阶.mp3",
"Teams 重复振铃.mp3",
"Teams 颤振.mp3",
"Teams 高分.mp3",
"Teams 默认.mp3",
"Teams 默认通话铃.mp3"
"universfield-new-notification-024-370048.mp3"
];
// 默认的单次通知铃声
export const defaultSingleSound = 'Teams 默认.mp3';
export const defaultSingleSound = 'universfield-new-notification-024-370048.mp3';
// 默认的持续通知铃声
export const defaultUrgentSound = 'Teams 默认通话铃.mp3';
export const defaultUrgentSound = 'universfield-new-notification-024-370048.mp3';
// 获取音频文件的完整路径
export function getSoundPath(filename) {