Compare commits

..
11 Commits
Author SHA1 Message Date
Henry_Du c7fdc11dbd 首次提交
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
2026-10-24 01:29:24 +08:00
Sunwuyuan e70d46436c Refactor code structure for improved readability and maintainability 2026-03-03 21:38:32 +08:00
Sunwuyuan e939bba814 更新 SEO 和社交媒体元数据,增强网站可见性和分享效果 2026-03-03 21:37:34 +08:00
Sunwuyuan 8f2c9be179 优化设备名称展示逻辑,支持离线模式下使用本地班级编号 2026-03-03 21:28:45 +08:00
Sunwuyuan cf3412db6a 重构代码,优化组件加载和样式,移除不必要的依赖,增强首屏渲染性能 2026-03-03 21:16:32 +08:00
Sunwuyuan 97c70f6706 Merge branch 'main' of https://github.com/ZeroCatDev/Classworks 2026-03-03 19:53:33 +08:00
Sunwuyuan 26d77acc79 添加时间卡片组件及相关设置 2026-03-03 19:53:29 +08:00
孙悟元 834883b2a4 Update Sentry DSN for error reporting 2026-02-19 14:07:09 +08:00
孙悟元 202405bdf8 Update Sentry DSN and trace propagation targets 2026-02-19 12:26:10 +08:00
斩月 8950e57fab Merge pull request #42 from ZeroCatDev/Sunwuyuan-patch-4
Update copyright year in README.md
2026-02-15 09:10:38 +08:00
Sunwuyuan 28715315b2 Update copyright year in README.md 2026-02-14 21:17:24 +08:00
151 changed files with 10164 additions and 5645 deletions
+1
View File
@@ -2,6 +2,7 @@
node_modules
/dist
/dev-dist
/data
# local env files
.env.local
@@ -0,0 +1,10 @@
- [x] Announcement Settings Dialog Markdown toolbar wraps correctly
- [x] Announcement Settings Dialog Preview works
- [x] Announcement Widget is hidden when no announcements exist
- [x] Clicking Announcement Widget opens detail popup
- [x] "Self Correction" tag in Homework Grid is Green
- [x] "Add Item" button in Homework Edit Dialog is White and distinct
- [x] Metadata buttons in Homework Edit Dialog match Preset chips size/shape
- [x] Refresh button in Floating Toolbar does not stick in loading state
- [x] "Save Success" green notification appears after saving Homework
- [x] "Save Success" green notification appears after saving Announcement
+55
View File
@@ -0,0 +1,55 @@
# UI and Functional Improvements Spec
## Why
User reported several UI inconsistencies, visual bugs, and missing feedback mechanisms that affect the usability and aesthetic quality of the application. Specifically, the announcement feature needs polish, font colors need adjustment for dark mode, and feedback for save operations is missing.
## What Changes
### Announcement Feature
- **Settings Dialog (`AnnouncementSettingsDialog.vue`)**:
- Fix Markdown toolbar layout to prevent conflicts/overflow (use `flex-wrap`).
- Fix Preview functionality (ensure `parseMarkdown` works).
- Add "Save Success" feedback when adding/modifying announcements.
- **Widget (`AnnouncementWidget.vue`)**:
- **Hide the widget completely** when there are no active/upcoming announcements (User requirement: "only shows when there are announcements").
- Make the widget clickable to open a detail popup of the current announcement.
- **Detail Popup (`AnnouncementOverlay.vue` or new)**:
- Ensure manually clicking the widget opens the detail view.
### Homework UI
- **Homework Grid (`HomeworkGrid.vue`)**:
- Change "Self Correction" (自批) tag color from Orange/Warning to **Green/Success**.
- **Homework Edit Dialog (`HomeworkEditDialog.vue`)**:
- **Add Item Button**: Change style to be more obvious (White background, black text).
- **Metadata Buttons** (Submission Time, Self Correction): Adjust height and border-radius to match the "Preset" chips on the left for visual consistency.
### General UI
- **Font Colors**:
- Change gray text to **White** in key locations (e.g., Homework Edit dialog controls, Announcement widget text) for better contrast in dark mode.
- **Floating Toolbar**:
- Fix the "Refresh" button loading state (it appears stuck).
- **Feedback**:
- Implement a "Save Success" (green) notification (Snackbar/Toast) that triggers after successful save operations (Homework save, Announcement save, etc.).
## Impact
- **Affected Components**: `AnnouncementSettingsDialog`, `AnnouncementWidget`, `HomeworkGrid`, `HomeworkEditDialog`, `FloatingToolbar`, `index.vue`.
- **User Experience**: Improved visual consistency, better feedback on actions, and cleaner interface when no announcements are present.
## ADDED Requirements
### Requirement: Save Feedback
The system SHALL display a green "Save Success" notification (Snackbar) when:
- A homework entry is saved (auto-save or manual).
- An announcement is added or modified.
- Attendance is saved.
### Requirement: Announcement Widget Behavior
- **WHEN** there are no active announcements, **THEN** the widget on the main board SHALL be hidden.
- **WHEN** the widget is visible and clicked, **THEN** it SHALL open the announcement detail popup.
## MODIFIED Requirements
### Requirement: Visual Styles
- **Self Correction Tag**: SHALL be Green.
- **Add Item Button**: SHALL be White (filled).
- **Metadata Buttons**: SHALL match Preset Chips in size and shape.
- **Refresh Button**: SHALL NOT show loading state indefinitely.
+27
View File
@@ -0,0 +1,27 @@
# Tasks
- [x] Task 1: Update Announcement System
- [x] SubTask 1.1: Fix `AnnouncementSettingsDialog.vue`:
- Fix Markdown toolbar layout (flex-wrap).
- Ensure Preview works (check `parseMarkdown` usage).
- Add "Save Success" notification trigger on save.
- [x] SubTask 1.2: Update `AnnouncementWidget.vue`:
- Add logic to hide component if `notifications` is empty.
- Add click handler to open detail popup (emit event or call method).
- [x] SubTask 1.3: Update `index.vue` to handle widget click and show detail popup.
- [x] Task 2: Update Homework UI
- [x] SubTask 2.1: Update `HomeworkGrid.vue`:
- Change "Self Correction" chip color to `success`.
- [x] SubTask 2.2: Update `HomeworkEditDialog.vue`:
- Style "Add Item" button (color="white", variant="flat").
- Style "Submission Time" and "Self Correction" buttons to match `v-chip` style (height, rounded).
- Ensure text colors are white where appropriate.
- [x] Task 3: Fix General UI & Feedback
- [x] SubTask 3.1: Fix `FloatingToolbar.vue` refresh button loading state (debug `loading` prop).
- [x] SubTask 3.2: Implement Global "Save Success" Snackbar in `index.vue` and expose method/event to trigger it from child components.
- [x] SubTask 3.3: specific font color adjustments (Grey to White) in `AttendanceSidebar` and `HomeworkGrid` as requested.
# Task Dependencies
- Task 3.2 (Global Snackbar) should be done before Task 1.1 and 1.3 to ensure feedback works.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+1 -4
View File
@@ -8,8 +8,6 @@
适用于班级大屏的作业板小工具
请打开 [https://cs.houlangs.com](https://cs.houlang.cloud) 立刻使用
## 交流
QQ:[964979747](https://qm.qq.com/q/4RX45b1Oac)
@@ -61,5 +59,4 @@ ZeroCat 的项目 遵循 [Contributor Covenant](http://contributor-covenant.org/
ZeroCat 社区项目遵循 [AGPL-3.0 许可证](LICENSE)。
版权所有 (C) 2020-2024 孙悟元。
Copyright (C) 2020-2024 Sun Wuyuan.
Copyright (C) 2020-2026 Sunwuyuan.
+3
View File
@@ -0,0 +1,3 @@
node_modules
data
.env
+22
View File
@@ -0,0 +1,22 @@
FROM node:18-alpine
WORKDIR /app
# Copy package files and install production dependencies
COPY package*.json ./
RUN npm install --production
# Copy server code
COPY server.js ./
# Copy the pre-built frontend files
COPY dist ./dist
# Create a data directory to store JSON files locally
RUN mkdir -p data
# Expose port
EXPOSE 3001
# Start the server
CMD ["npm", "start"]
+43
View File
@@ -0,0 +1,43 @@
# ClassHomeworkBoard - Docker 部署版本
这是一个专门为服务器部署设计的 Node.js 后端程序,它包含了 Express 服务器和构建好的前端静态文件。此版本去除了对 Windows 特有功能(如通过 `user32.dll` 抓取 PPT 和 WPS 窗口)的依赖,完美适配 Linux 和 Docker 环境。
## 包含内容
- `server.js`: 提供 `/api/local-file/*` 本地存储接口的 Express 后端。
- `dist/`: 已经构建好的前端静态资源文件。
- `Dockerfile`: 用于构建 Docker 镜像的配置文件。
- `package.json`: 服务器的 npm 依赖配置文件。
## 如何使用 Docker 部署
1. **构建 Docker 镜像**
在 `docker-server` 目录下运行以下命令:
```bash
docker build -t classhomeworkboard .
```
2. **运行 Docker 容器**
```bash
docker run -d -p 3001:3001 -v chwb_data:/app/data --name chwb classhomeworkboard
```
> 这里的 `-v chwb_data:/app/data` 表示将容器内的 `/app/data`(存储 JSON 数据的文件夹)挂载到外部卷,防止数据随容器销毁而丢失。
3. **访问服务**
打开浏览器,访问 `http://<服务器IP>:3001` 即可使用。
## 如果你需要更新前端代码
如果你在主项目中修改了 Vue 前端代码:
1. 在项目根目录运行 `npm run build`(或 `pnpm run build`)重新打包前端。
2. 将打包后生成的 `dist` 文件夹覆盖到本目录的 `dist` 文件夹。
3. 重新执行 Docker 镜像的构建与部署步骤。
## 本地(不使用 Docker)直接运行
如果你想直接在服务器的 Node 环境中运行:
```bash
npm install
npm start
```
默认会在 3001 端口启动。
+14
View File
@@ -0,0 +1,14 @@
{
"name": "classhomeworkboard-server",
"version": "1.0.0",
"description": "Dockerized server for ClassHomeworkBoard",
"main": "server.js",
"type": "module",
"scripts": {
"start": "node server.js"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5"
}
}
+123
View File
@@ -0,0 +1,123 @@
import express from 'express';
import cors from 'cors';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const port = process.env.PORT || 3001;
// Middlewares
app.use(cors());
app.use(express.json({ limit: '50mb' }));
// Data Directory for JSON files
const dataDir = path.resolve(__dirname, 'data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
// =======================
// API Routes
// =======================
// Mock PPT APIs (Windows GUI features are not supported on Linux/Docker)
app.get('/api/ppt/check', (req, res) => {
res.json({ success: true, hasPpt: false, ppts: [] });
});
app.post('/api/ppt/control', (req, res) => {
res.json({ success: false, error: 'Not supported on server environment' });
});
// Local File Storage APIs
app.post('/api/local-file/save', (req, res) => {
try {
const { key, data } = req.body;
if (!key || !data) throw new Error('Missing key or data');
// Sanitize key
const safeKey = String(key).replace(/[^a-zA-Z0-9_-]/g, '_');
const filePath = path.join(dataDir, `${safeKey}.json`);
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
res.json({ success: true });
} catch (e) {
res.status(500).json({ success: false, error: e.message });
}
});
app.get('/api/local-file/load', (req, res) => {
try {
const key = req.query.key;
if (!key) {
return res.status(400).json({ success: false, error: 'Missing key' });
}
const safeKey = String(key).replace(/[^a-zA-Z0-9_-]/g, '_');
const filePath = path.join(dataDir, `${safeKey}.json`);
if (fs.existsSync(filePath)) {
const data = fs.readFileSync(filePath, 'utf-8');
res.json({ success: true, data: JSON.parse(data) });
} else {
res.status(404).json({ success: false, error: 'Not found' });
}
} catch (e) {
res.status(500).json({ success: false, error: e.message });
}
});
app.get('/api/local-file/keys', (req, res) => {
try {
const files = fs.readdirSync(dataDir);
const keys = files
.filter(f => f.endsWith('.json'))
.map(f => f.replace('.json', ''));
res.json({ success: true, keys });
} catch (e) {
res.status(500).json({ success: false, error: e.message });
}
});
// =======================
// Static Files & SPA
// =======================
const staticDir = path.resolve(__dirname, 'dist');
if (fs.existsSync(staticDir)) {
// Serve static files
app.use(express.static(staticDir, {
setHeaders: (res, path, stat) => {
// Prevent caching for sw.js and index.html
if (path.endsWith('sw.js') || path.endsWith('index.html')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
} else {
// Cache other static assets
res.setHeader('Cache-Control', 'public, max-age=3600');
}
}
}));
// Catch-all route for SPA fallback
app.get('*', (req, res) => {
res.sendFile(path.join(staticDir, 'index.html'));
});
} else {
// Fallback if dist doesn't exist yet
app.get('*', (req, res) => {
res.send('<h3>Frontend not found!</h3><p>Please build the Vue frontend (<code>npm run build</code>) and copy the <code>dist</code> folder into the <code>docker-server</code> directory before running.</p>');
});
}
// Start Server
app.listen(port, () => {
console.log(`ClassHomeworkBoard Server running on port ${port}`);
console.log(`Data directory: ${dataDir}`);
console.log(`Static files directory: ${staticDir}`);
});
+2
View File
@@ -34,7 +34,9 @@ export default [
confirm: 'readonly',
prompt: 'readonly',
setTimeout: 'readonly',
clearTimeout: 'readonly',
setInterval: 'readonly',
clearInterval: 'readonly',
fetch: 'readonly',
XMLHttpRequest: 'readonly',
URL: 'readonly',
+24 -68
View File
@@ -2,80 +2,36 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Classworks 作业板</title>
<meta name="description" content="记录,查看并同步作业" />
<title>CHWB作业板</title>
<!-- SEO -->
<meta name="description" content="Classworks —— 适用于班级大屏的作业板小工具,支持记录、查看并同步作业,开源免费。" />
<meta name="keywords" content="Classworks,作业板,班级大屏,作业管理,课表,作业同步,开源" />
<meta name="author" content="Sunwuyuan" />
<meta name="robots" content="index, follow" />
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:title" content="Classworks 作业板" />
<meta property="og:description" content="适用于班级大屏的作业板小工具,支持记录、查看并同步作业,开源免费。" />
<meta property="og:site_name" content="Classworks" />
<meta property="og:image:width" content="512" />
<meta property="og:image:height" content="512" />
<meta property="og:locale" content="zh_CN" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="Classworks 作业板" />
<meta name="twitter:description" content="适用于班级大屏的作业板小工具,支持记录、查看并同步作业,开源免费。" />
<link rel="apple-touch-icon" href="/image/apple-touch-icon.png" sizes="180x180" />
<link rel="mask-icon" href="/image/mask-icon.svg" color="#212121" />
<meta name="theme-color" content="#212121" />
<style>
/* Material 3 风格:纯 CSS 加载覆盖层 */
:root {
color-scheme: light dark;
/* 作为主色的近似值,后续由应用接管主题 */
--md3-primary: #6750A4; /* light primary */
--md3-primary-dark: #D0BCFF; /* dark primary */
--loader-bg: #ffffff;
--loader-fg: var(--md3-primary);
}
@media (prefers-color-scheme: dark) {
:root {
--loader-bg: #121212;
--loader-fg: var(--md3-primary-dark);
}
}
#app-loader {
position: fixed;
inset: 0;
z-index: 2147483647; /* 确保在最上层 */
display: grid;
place-items: center;
background: var(--loader-bg);
transition: opacity .2s ease;
}
#app-loader .md3-loader {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 14px;
color: var(--loader-fg);
}
/* 圆形不确定进度条(近似 M3) */
#app-loader .spinner {
width: 48px;
height: 48px;
border-radius: 50%;
/* 通过 conic-gradient 形成 90° 弧,并旋转实现不确定动画 */
background:
conic-gradient(from 0deg, currentColor 0 90deg, transparent 90deg 360deg);
/* 用 mask 形成环形厚度(4px) */
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 4px), #000 calc(100% - 4px));
mask: radial-gradient(farthest-side, transparent calc(100% - 4px), #000 calc(100% - 4px));
animation: md3-spin 1s linear infinite;
}
@keyframes md3-spin {
to { transform: rotate(360deg); }
}
#app-loader .label {
font: 500 14px/1.2 system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji";
color: var(--loader-fg);
letter-spacing: .2px;
opacity: .85;
-webkit-user-select: none;
user-select: none;
}
/* 当被移除或隐藏时可渐隐(由应用控制) */
body.app-loaded #app-loader { opacity: 0; pointer-events: none; }
</style>
</head>
<body>
<!-- 应用加载前显示的覆盖层:纯 CSS,无脚本依赖 -->
<div id="app-loader" aria-live="polite" aria-busy="true">
<div class="md3-loader">
<div class="spinner" role="progressbar" aria-label="正在加载"></div>
</div>
</div>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener" style="display: none;">xICP备x号-4</a>
+6 -7
View File
@@ -1,8 +1,8 @@
{
"name": "classworks",
"name": "classhomeworkboard",
"private": true,
"type": "module",
"version": "0.0.0",
"version": "2.1.0",
"scripts": {
"dev": "vite --host",
"build": "vite build",
@@ -13,15 +13,13 @@
"dependencies": {
"@fingerprintjs/fingerprintjs": "^5.0.1",
"@mdi/font": "7.4.47",
"@microsoft/clarity": "^1.0.2",
"@sentry/vue": "^10.36.0",
"@vue-office/pptx": "^1.0.1",
"@vueuse/core": "^14.1.0",
"axios": "^1.13.2",
"idb": "^8.0.3",
"js-base64": "^3.7.8",
"js-yaml": "^4.1.1",
"lucide-vue-next": "^0.555.0",
"marked": "^17.0.1",
"markdown-it": "^14.1.1",
"pinyin-pro": "^3.27.0",
"ratelimit-header-parser": "^0.1.0",
"roboto-fontface": "*",
@@ -30,7 +28,8 @@
"uuid": "^13.0.0",
"vue": "^3.5.25",
"vue-sonner": "^2.0.9",
"vuetify": "^3.11.0"
"vuetify": "^3.11.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
+1 -13
View File
@@ -32,15 +32,9 @@ importers:
js-base64:
specifier: ^3.7.8
version: 3.7.8
js-yaml:
specifier: ^4.1.1
version: 4.1.1
lucide-vue-next:
specifier: ^0.555.0
version: 0.555.0(vue@3.5.25(typescript@5.9.3))
marked:
specifier: ^17.0.1
version: 17.0.1
pinyin-pro:
specifier: ^3.27.0
version: 3.27.0
@@ -2186,6 +2180,7 @@ packages:
glob@11.1.0:
resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==}
engines: {node: 20 || >=22}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
globals@14.0.0:
@@ -2552,11 +2547,6 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
marked@17.0.1:
resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==}
engines: {node: '>= 20'}
hasBin: true
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -6261,8 +6251,6 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
marked@17.0.1: {}
math-intrinsics@1.1.0: {}
merge2@1.4.1: {}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 650 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 368 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 368 B

-13
View File
@@ -1,13 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="256" height="256" viewBox="0 0 256 256" fill="none">
<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="M48 208L128 128L208 128L128 208L48 208Z" fill-rule="evenodd" fill="#241A04" >
</path>
<path d="M48 128L128 48L208 48L128 128L48 128Z" fill-rule="evenodd" fill="#52452A" >
</path>
</svg>

Before

Width:  |  Height:  |  Size: 554 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3 -2
View File
@@ -34,6 +34,7 @@ function getSoundFiles() {
// 生成文件内容
function generateSoundList() {
const soundFiles = getSoundFiles();
const defaultSound = 'universfield-new-notification-024-370048.mp3';
const fileContent = `/**
* 自动生成的音频文件列表
@@ -45,10 +46,10 @@ function generateSoundList() {
export const soundFiles = ${JSON.stringify(soundFiles, null, 2)};
// 默认的单次通知铃声
export const defaultSingleSound = 'Teams 默认.mp3';
export const defaultSingleSound = '${defaultSound}';
// 默认的持续通知铃声
export const defaultUrgentSound = 'Teams 默认通话铃.mp3';
export const defaultUrgentSound = '${defaultSound}';
// 获取音频文件的完整路径
export function getSoundPath(filename) {
+13 -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 />
@@ -32,6 +38,11 @@ onMounted(() => {
});
</script>
<style>
/* 全局样式(从 index.vue 迁移,确保全局可用且仅加载一次) */
@import "@/styles/index.scss";
@import "@/styles/transitions.scss";
@import "@/styles/global.scss";
.md3-enter-active,
.md3-leave-active {
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1),
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>
+272 -85
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,7 +782,10 @@
</v-card>
</v-menu>
</v-col>
<v-col cols="12" md="2">
<v-col
cols="12"
md="2"
>
<v-text-field
v-model="examInfo.durationMinutes"
:rules="[
@@ -647,7 +809,11 @@
</v-col>
<!-- 提醒时间输入框(仅在启用自定义时显示) -->
<v-col v-if="enableCustomAlertTime" cols="12" md="2">
<v-col
v-if="enableCustomAlertTime"
cols="12"
md="2"
>
<v-text-field
v-model="examInfo.alertTime"
:rules="[
@@ -672,11 +838,20 @@
</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,
+26 -9
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,8 +86,11 @@
<!-- Side Action Button -->
<v-slide-x-reverse-transition>
<v-btn
<div
v-if="!isToday"
class="side-action-container"
>
<v-btn
:loading="copyToTodayLoading"
:disabled="copyToTodayLoading"
class="side-action-btn"
@@ -86,9 +99,11 @@
prepend-icon="mdi-content-copy"
rounded="xl"
size="large"
text="复制作业内容到今天"
@click="$emit('copy-to-today')"
>复制到今天</v-btn>
>
复制到今天
</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 {
+179 -55
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>
<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
+26 -9
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">{{
<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>
+68 -17
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-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>
+11 -3
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>
@@ -21,7 +26,10 @@
<slot />
</v-card-text>
<v-card-actions v-if="$slots.actions" class="pa-4">
<v-card-actions
v-if="$slots.actions"
class="pa-4"
>
<slot name="actions" />
</v-card-actions>
</v-card>
+158 -67
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 }}
</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,7 +131,11 @@
class="mr-2 mb-1"
size="small"
>
<v-icon size="small" start>{{
<v-icon
size="small"
start
>
{{
preconfigForm.autoExecute ? "mdi-play-circle" : "mdi-hand-back-left"
}}
</v-icon>
@@ -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"
+151
View File
@@ -0,0 +1,151 @@
<template>
<v-card
class="time-card"
elevation="2"
border
rounded="xl"
height="100%"
>
<v-card-text
class="pa-6 d-flex flex-column "
style="height: 100%"
>
<!-- 时间显示 -->
<div
class="time-display"
:style="timeStyle"
>
{{ timeString }}<span
class="seconds-text"
:style="secondsStyle"
>{{ secondsString }}</span>
</div>
<!-- 日期 + 星期 + 时段 -->
<div
class="date-line mt-3"
:style="dateStyle"
>
{{ dateString }} {{ weekdayString }} {{ periodOfDay }}
</div>
</v-card-text>
</v-card>
</template>
<script>
// 时间字体大小比例(大屏场景)
const TIME_FONT_RATIO = 2.0
const SECONDS_FONT_RATIO = 0.9
const DATE_FONT_RATIO = 0.6
export default {
name: 'TimeCard',
data() {
return {
now: new Date(),
timer: null,
fontSize: 28,
}
},
computed: {
timeString() {
const h = String(this.now.getHours()).padStart(2, '0')
const m = String(this.now.getMinutes()).padStart(2, '0')
return `${h}:${m}`
},
secondsString() {
return `:${String(this.now.getSeconds()).padStart(2, '0')}`
},
dateString() {
const y = this.now.getFullYear()
const m = this.now.getMonth() + 1
const d = this.now.getDate()
return `${y}年${m}月${d}日`
},
weekdayString() {
const days = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
return days[this.now.getDay()]
},
periodOfDay() {
const h = this.now.getHours()
if (h < 6) return '凌晨'
if (h < 8) return '早晨'
if (h < 11) return '上午'
if (h < 13) return '中午'
if (h < 17) return '下午'
if (h < 19) return '傍晚'
if (h < 22) return '晚上'
return '深夜'
},
timeStyle() {
return {
'font-size': `${this.fontSize * TIME_FONT_RATIO}px`,
'font-weight': '700',
'line-height': '1',
'letter-spacing': '4px',
'font-variant-numeric': 'tabular-nums',
}
},
secondsStyle() {
return {
'font-size': `${this.fontSize * SECONDS_FONT_RATIO}px`,
'font-variant-numeric': 'tabular-nums',
'vertical-align': 'baseline',
'margin-left': '4px',
'opacity': '0.6',
}
},
dateStyle() {
return {
'font-size': `${this.fontSize * DATE_FONT_RATIO}px`,
'letter-spacing': '1px',
}
},
},
mounted() {
this.startTimer()
},
beforeUnmount() {
this.stopTimer()
},
methods: {
startTimer() {
// 每秒更新一次
this.timer = setInterval(() => {
this.now = new Date()
}, 1000)
},
stopTimer() {
if (this.timer) {
clearInterval(this.timer)
this.timer = null
}
},
},
}
</script>
<style scoped>
.time-card {
transition: all 0.3s ease;
overflow: hidden;
}
.time-card:hover {
transform: translateY(-2px);
}
.time-display {
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
white-space: nowrap;
}
.seconds-text {
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
}
.date-line {
opacity: 0.75;
letter-spacing: 1px;
}
</style>
+31 -19
View File
@@ -22,11 +22,21 @@
<!-- 多通知导航 -->
<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();
+82 -36
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"
@@ -251,7 +258,8 @@
<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,19 +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 { pinyin } from "pinyin-pro";
import { useDisplay } from "vuetify";
import { getSetting } from "@/utils/settings";
import dataProvider from "@/utils/dataProvider";
export default {
name: "AttendanceManagementDialog",
@@ -266,7 +361,7 @@ export default {
default: "",
},
},
emits: ["update:modelValue", "save", "change"],
emits: ["update:modelValue", "save", "change", "list-updated"],
setup() {
const { mobile } = useDisplay();
return { mobile };
@@ -275,6 +370,8 @@ export default {
return {
attendanceSearch: "",
attendanceFilter: [],
showEditListDialog: false,
editingStudentList: "",
};
},
computed: {
@@ -328,14 +425,114 @@ export default {
return Array.from(surnameMap.entries())
.map(([name, count]) => ({ name, count }))
.sort((a, b) => {
const pinyinA = pinyin(a.name, { toneType: "none" });
const pinyinB = pinyin(b.name, { toneType: "none" });
return pinyinA.localeCompare(pinyinB);
});
.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) {
+669 -78
View File
@@ -1,84 +1,425 @@
<template>
<v-card
v-if="!inDialog"
class="attendance-sidebar"
:class="compact ? 'mb-2' : 'mb-4'"
:style="contentStyle"
variant="outlined"
color="grey-darken-3"
elevation="0"
>
<!-- 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;"
>
<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="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-if="isAllPresent && compact"
:cols="isAllPresent && compact ? 4 : 12"
class="d-flex align-center justify-center pa-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"
<div class="text-center text-success d-flex flex-row align-center justify-center">
<v-icon
color="success"
size="24"
class="mr-1"
>
<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"
mdi-check-circle
</v-icon>
<div
class="font-weight-bold"
style="font-size: 0.9em;"
>
<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>
全勤
</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"
>
<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>
@@ -100,14 +441,240 @@ export default {
type: Boolean,
default: false,
},
contentStyle: {
type: Object,
default: () => ({}),
},
emits: ["click", "disabled-click"],
inDialog: {
type: Boolean,
default: false,
},
compact: {
type: Boolean,
default: false,
},
},
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>
+2 -6
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"
+5 -7
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>
@@ -0,0 +1,29 @@
<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>
</template>
<script setup>
defineProps({
height: {
type: String,
default: '120px'
}
})
</script>
<style scoped>
.async-loading-placeholder {
width: 100%;
opacity: 0.6;
}
</style>
+74
View File
@@ -0,0 +1,74 @@
<template>
<!-- 纯 CSS 骨架屏,避免使用 VSkeletonLoader 引起的渲染冲突 -->
<v-container
class="main-window"
fluid
>
<div class="skeleton-grid">
<div
v-for="n in cardCount"
:key="n"
class="skeleton-card"
>
<div class="skeleton-heading skeleton-pulse" />
<div class="skeleton-line skeleton-pulse" />
<div class="skeleton-line skeleton-line--short skeleton-pulse" />
</div>
</div>
<div class="d-flex justify-center mt-6 ga-3">
<div class="skeleton-btn skeleton-pulse" />
<div class="skeleton-btn skeleton-pulse" />
</div>
</v-container>
</template>
<script setup>
import { computed } from 'vue'
import { useDisplay } from 'vuetify'
const { mobile } = useDisplay()
const cardCount = computed(() => mobile.value ? 3 : 6)
</script>
<style scoped>
.skeleton-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.skeleton-card {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity, 0.12));
border-radius: 12px;
padding: 16px;
}
.skeleton-heading {
height: 24px;
width: 60%;
background: rgba(var(--v-theme-on-surface), 0.08);
border-radius: 4px;
margin-bottom: 12px;
}
.skeleton-line {
height: 14px;
width: 100%;
background: rgba(var(--v-theme-on-surface), 0.08);
border-radius: 4px;
margin-bottom: 8px;
}
.skeleton-line--short {
width: 40%;
}
.skeleton-btn {
height: 36px;
width: 100px;
background: rgba(var(--v-theme-on-surface), 0.08);
border-radius: 8px;
}
.skeleton-pulse {
animation: skeleton-pulse 1.5s ease-in-out infinite;
}
@keyframes skeleton-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
</style>
+25 -7
View File
@@ -1,12 +1,26 @@
<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"/>
<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="text-center">
<div class="text-body-2 font-weight-light mb-n1">出现了错误</div>
<div class="text-body-2 font-weight-light mb-n1">
出现了错误
</div>
<h1 class="text-h2 font-weight-bold">404</h1>
<h1 class="text-h2 font-weight-bold">
404
</h1>
</div>
<div class="py-4" />
@@ -25,11 +39,15 @@
</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>
+66 -14
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,10 +44,22 @@
{{ 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">
<v-avatar
color="primary"
variant="tonal"
size="large"
class="mr-3 font-weight-bold elevation-1"
>
{{ info.name.charAt(0) }}
</v-avatar>
</template>
@@ -38,11 +70,23 @@
<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>
<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>
<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>
@@ -50,8 +94,16 @@
</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">
+285 -122
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"
@@ -11,94 +14,28 @@
}"
class="grid-item"
>
<!-- 时间卡片 -->
<div
v-if="item.type === 'time'"
style="height: 100%"
>
<time-card />
</div>
<!-- 一言卡片 -->
<div v-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)"
@@ -106,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">
@@ -118,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"
>
{{ text }}
</v-list-item>
</v-list>
{{ 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' }"
>
{{ 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>
@@ -144,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"
@@ -153,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 }}
@@ -161,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"
@@ -174,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>
@@ -204,19 +245,14 @@
<script>
import HitokotoCard from "@/components/HitokotoCard.vue";
import ConciseExamCard from "@/components/home/ConciseExamCard.vue";
import TimeCard from "@/components/TimeCard.vue";
import {getSetting} from "@/utils/settings.js";
export default {
name: "HomeworkGrid",
computed: {
settings() {
return settings
}
},
components: {
HitokotoCard,
ConciseExamCard,
TimeCard,
},
props: {
sortedItems: {
@@ -243,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) {
@@ -302,7 +340,9 @@ export default {
}
},
methods: {
getSetting,
getSetting(key) {
return getSetting(key);
},
async checkReadOnlyStatus() {
// 尝试获取父组件中的StudentNameManager引用
try {
@@ -320,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) {
@@ -354,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;
@@ -366,10 +407,19 @@ export default {
// 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;
@@ -383,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;
@@ -427,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>
+37 -13
View File
@@ -1,5 +1,8 @@
<template>
<v-list-item :disabled="disabled" class="setting-item">
<v-list-item
:disabled="disabled"
class="setting-item"
>
<template #prepend>
<v-icon :icon="settingIcon" />
</template>
@@ -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"

Some files were not shown because too many files have changed in this diff Show More