Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7fdc11dbd | ||
|
|
e70d46436c | ||
|
|
e939bba814 | ||
|
|
8f2c9be179 | ||
|
|
cf3412db6a | ||
|
|
97c70f6706 | ||
|
|
26d77acc79 | ||
|
|
834883b2a4 | ||
|
|
202405bdf8 | ||
|
|
8950e57fab | ||
|
|
28715315b2 |
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
data
|
||||
.env
|
||||
@@ -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"]
|
||||
@@ -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 端口启动。
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -34,7 +34,9 @@ export default [
|
||||
confirm: 'readonly',
|
||||
prompt: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
clearTimeout: 'readonly',
|
||||
setInterval: 'readonly',
|
||||
clearInterval: 'readonly',
|
||||
fetch: 'readonly',
|
||||
XMLHttpRequest: 'readonly',
|
||||
URL: 'readonly',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {}
|
||||
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 650 KiB |
|
Before Width: | Height: | Size: 368 B |
|
Before Width: | Height: | Size: 368 B |
@@ -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 |
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 367 B |
|
Before Width: | Height: | Size: 12 KiB |
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -5,16 +5,32 @@
|
||||
max-width="600"
|
||||
persistent
|
||||
>
|
||||
<v-card border class="random-picker-card" rounded="xl">
|
||||
<v-card
|
||||
border
|
||||
class="random-picker-card"
|
||||
rounded="xl"
|
||||
>
|
||||
<v-card-title class="text-h5 d-flex align-center">
|
||||
<v-icon class="mr-2" icon="mdi-account-question"/>
|
||||
<v-icon
|
||||
class="mr-2"
|
||||
icon="mdi-account-question"
|
||||
/>
|
||||
随机点名
|
||||
<v-spacer/>
|
||||
<v-btn icon="mdi-close" variant="text" @click="dialog = false"/>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
variant="text"
|
||||
@click="dialog = false"
|
||||
/>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text v-if="!isPickingStarted" class="text-center py-6">
|
||||
<div class="text-h6 mb-4">请选择抽取人数</div>
|
||||
<v-card-text
|
||||
v-if="!isPickingStarted"
|
||||
class="text-center py-6"
|
||||
>
|
||||
<div class="text-h6 mb-4">
|
||||
请选择抽取人数
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-center align-center counter-container">
|
||||
<v-btn
|
||||
@@ -52,14 +68,29 @@
|
||||
mandatory
|
||||
rounded="pill"
|
||||
>
|
||||
<v-btn prepend-icon="mdi-account" value="name">姓名模式</v-btn>
|
||||
<v-btn prepend-icon="mdi-numeric" value="number">学号模式</v-btn>
|
||||
<v-btn
|
||||
prepend-icon="mdi-account"
|
||||
value="name"
|
||||
>
|
||||
姓名模式
|
||||
</v-btn>
|
||||
<v-btn
|
||||
prepend-icon="mdi-numeric"
|
||||
value="number"
|
||||
>
|
||||
学号模式
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
</div>
|
||||
|
||||
<!-- 学号范围设置 -->
|
||||
<div v-if="pickerMode === 'number'" class="number-range-container mt-4">
|
||||
<div class="text-subtitle-1 mb-2">学号范围设置</div>
|
||||
<div
|
||||
v-if="pickerMode === 'number'"
|
||||
class="number-range-container mt-4"
|
||||
>
|
||||
<div class="text-subtitle-1 mb-2">
|
||||
学号范围设置
|
||||
</div>
|
||||
<div class="d-flex justify-center align-center gap-4">
|
||||
<v-text-field
|
||||
v-model.number="minNumber"
|
||||
@@ -98,7 +129,10 @@
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredStudents.length === 0" class="mt-4 text-error">
|
||||
<div
|
||||
v-if="filteredStudents.length === 0"
|
||||
class="mt-4 text-error"
|
||||
>
|
||||
<template v-if="pickerMode === 'name'">
|
||||
没有可抽取的学生,请调整过滤选项
|
||||
</template>
|
||||
@@ -109,8 +143,11 @@
|
||||
|
||||
<div class="mt-4 text-caption">
|
||||
当前可抽取学生: {{ filteredStudents.length }}人
|
||||
<v-tooltip v-if="pickerMode === 'name'" location="bottom">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-tooltip
|
||||
v-if="pickerMode === 'name'"
|
||||
location="bottom"
|
||||
>
|
||||
<template #activator="{ props }">
|
||||
<v-icon
|
||||
class="ml-1"
|
||||
icon="mdi-information-outline"
|
||||
@@ -132,7 +169,10 @@
|
||||
</v-tooltip>
|
||||
|
||||
<!-- 添加临时过滤选项 -->
|
||||
<div v-if="pickerMode === 'name'" class="d-flex flex-wrap justify-center gap-2 mt-4">
|
||||
<div
|
||||
v-if="pickerMode === 'name'"
|
||||
class="d-flex flex-wrap justify-center gap-2 mt-4"
|
||||
>
|
||||
<v-chip
|
||||
:color="tempFilters.excludeLate ? 'warning' : 'default'"
|
||||
:variant="tempFilters.excludeLate ? 'elevated' : 'text'"
|
||||
@@ -165,8 +205,14 @@
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-text v-else class="text-center py-6">
|
||||
<div v-if="isAnimating" class="animation-container">
|
||||
<v-card-text
|
||||
v-else
|
||||
class="text-center py-6"
|
||||
>
|
||||
<div
|
||||
v-if="isAnimating"
|
||||
class="animation-container"
|
||||
>
|
||||
<div class="animation-wrapper">
|
||||
<transition-group
|
||||
class="shuffle-container"
|
||||
@@ -185,8 +231,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="result-container">
|
||||
<div class="text-h6 mb-4">抽取结果</div>
|
||||
<div
|
||||
v-else
|
||||
class="result-container"
|
||||
>
|
||||
<div class="text-h6 mb-4">
|
||||
抽取结果
|
||||
</div>
|
||||
<v-card
|
||||
v-for="(student, index) in pickedStudents"
|
||||
:key="index"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
@click:close="dismissed = true"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon icon="mdi-lock-alert"/>
|
||||
<v-icon icon="mdi-lock-alert" />
|
||||
</template>
|
||||
<v-alert-title>当前使用只读 Token</v-alert-title>
|
||||
<div class="text-body-2">
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<v-card class="settings-card rounded-lg" elevation="2">
|
||||
<v-card
|
||||
class="settings-card rounded-lg"
|
||||
elevation="2"
|
||||
>
|
||||
<v-card-item>
|
||||
<template #prepend>
|
||||
<v-icon
|
||||
@@ -8,7 +11,9 @@
|
||||
size="large"
|
||||
/>
|
||||
</template>
|
||||
<v-card-title class="text-h6">{{ title }}</v-card-title>
|
||||
<v-card-title class="text-h6">
|
||||
{{ title }}
|
||||
</v-card-title>
|
||||
</v-card-item>
|
||||
|
||||
<v-card-text>
|
||||
@@ -18,11 +23,14 @@
|
||||
color="primary"
|
||||
indeterminate
|
||||
/>
|
||||
<slot/>
|
||||
<slot />
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions v-if="$slots.actions" class="pa-4">
|
||||
<slot name="actions"/>
|
||||
<v-card-actions
|
||||
v-if="$slots.actions"
|
||||
class="pa-4"
|
||||
>
|
||||
<slot name="actions" />
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
|
||||
@@ -28,8 +28,6 @@
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<v-card>
|
||||
|
||||
|
||||
<v-card-text>
|
||||
<v-form>
|
||||
<v-row>
|
||||
@@ -42,15 +40,14 @@
|
||||
label="强调通知"
|
||||
color="red"
|
||||
inset
|
||||
>
|
||||
</v-switch>
|
||||
/>
|
||||
<v-checkbox
|
||||
v-model="notificationForm.isPersistent"
|
||||
label="常驻展示"
|
||||
color="primary"
|
||||
hide-details
|
||||
class="mt-0"
|
||||
></v-checkbox>
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<v-textarea
|
||||
@@ -81,8 +78,6 @@
|
||||
</v-btn>
|
||||
|
||||
<v-spacer />
|
||||
|
||||
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-col>
|
||||
@@ -93,11 +88,16 @@
|
||||
<v-col cols="12">
|
||||
<v-card>
|
||||
<v-card-title>
|
||||
<v-icon class="mr-2">mdi-pin</v-icon>
|
||||
<v-icon class="mr-2">
|
||||
mdi-pin
|
||||
</v-icon>
|
||||
常驻通知管理
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<div v-if="persistentNotifications.length === 0" class="text-center text-grey py-4">
|
||||
<div
|
||||
v-if="persistentNotifications.length === 0"
|
||||
class="text-center text-grey py-4"
|
||||
>
|
||||
暂无常驻通知
|
||||
</div>
|
||||
<v-list v-else>
|
||||
@@ -108,14 +108,25 @@
|
||||
:subtitle="formatTime(item.timestamp)"
|
||||
lines="two"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<template #prepend>
|
||||
<v-icon :color="item.isUrgent ? 'error' : 'primary'">
|
||||
{{ item.isUrgent ? 'mdi-alert-circle' : 'mdi-information' }}
|
||||
</v-icon>
|
||||
</template>
|
||||
<template v-slot:append>
|
||||
<v-btn icon="mdi-pencil" variant="text" size="small" @click="openEditDialog(item)"></v-btn>
|
||||
<v-btn icon="mdi-delete" variant="text" color="error" size="small" @click="deletePersistentNotification(item.id)"></v-btn>
|
||||
<template #append>
|
||||
<v-btn
|
||||
icon="mdi-pencil"
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="openEditDialog(item)"
|
||||
/>
|
||||
<v-btn
|
||||
icon="mdi-delete"
|
||||
variant="text"
|
||||
color="error"
|
||||
size="small"
|
||||
@click="deletePersistentNotification(item.id)"
|
||||
/>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
@@ -201,17 +212,14 @@
|
||||
>
|
||||
<v-card-text class="pa-2">
|
||||
<div class="align-center">
|
||||
|
||||
<span class="text-body-2 font-weight-medium">{{ device.deviceName }} </span>
|
||||
<br/>
|
||||
<br>
|
||||
|
||||
{{ device.deviceType }}
|
||||
|
||||
</div>
|
||||
<div class="text-caption mt-1">
|
||||
已读于 {{ formatDeviceTime(device.timestamp) }}
|
||||
</div>
|
||||
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
@@ -226,7 +234,6 @@
|
||||
>
|
||||
<v-card-text class="pa-2">
|
||||
<div class="align-center">
|
||||
|
||||
<span class="text-body-2 font-weight-medium">{{ device.deviceName }}</span>
|
||||
<v-spacer />
|
||||
<span class="text-caption text-grey">
|
||||
@@ -238,9 +245,9 @@
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
</div>
|
||||
<div v-else> <v-card
|
||||
<div v-else>
|
||||
<v-card
|
||||
|
||||
color="info-lighten-4"
|
||||
variant="outlined"
|
||||
@@ -249,9 +256,10 @@
|
||||
title="无设备在线"
|
||||
>
|
||||
<v-card-text>
|
||||
如果数秒后任然显示这个提示,则可能没有任何设备在线接收通知。
|
||||
如果数秒后任然显示这个提示,则可能没有任何设备在线接收通知。
|
||||
</v-card-text>
|
||||
</v-card></div>
|
||||
</v-card>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
@@ -266,12 +274,22 @@
|
||||
<EventSender ref="eventSender" />
|
||||
|
||||
<!-- 编辑常驻通知对话框 -->
|
||||
<v-dialog v-model="editDialog" max-width="500" :fullscreen="$vuetify.display.xs">
|
||||
<v-dialog
|
||||
v-model="editDialog"
|
||||
max-width="500"
|
||||
:fullscreen="$vuetify.display.xs"
|
||||
>
|
||||
<v-card>
|
||||
<v-toolbar flat density="compact">
|
||||
<v-toolbar
|
||||
flat
|
||||
density="compact"
|
||||
>
|
||||
<v-toolbar-title>编辑常驻通知</v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn icon="mdi-close" @click="editDialog = false"></v-btn>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
@click="editDialog = false"
|
||||
/>
|
||||
</v-toolbar>
|
||||
<v-card-text>
|
||||
<v-form>
|
||||
@@ -280,38 +298,66 @@
|
||||
label="通知内容"
|
||||
rows="3"
|
||||
auto-grow
|
||||
></v-textarea>
|
||||
/>
|
||||
<v-switch
|
||||
v-model="editForm.isUrgent"
|
||||
label="强调通知"
|
||||
color="error"
|
||||
hide-details
|
||||
></v-switch>
|
||||
/>
|
||||
<v-checkbox
|
||||
v-model="editForm.resend"
|
||||
label="保存并重新发送通知"
|
||||
hint="勾选后将作为新通知发送给所有在线设备"
|
||||
persistent-hint
|
||||
></v-checkbox>
|
||||
/>
|
||||
</v-form>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" @click="editDialog = false">取消</v-btn>
|
||||
<v-btn color="primary" :loading="savingEdit" @click="saveEdit">保存</v-btn>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
variant="text"
|
||||
@click="editDialog = false"
|
||||
>
|
||||
取消
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
:loading="savingEdit"
|
||||
@click="saveEdit"
|
||||
>
|
||||
保存
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- 删除确认对话框 -->
|
||||
<v-dialog v-model="deleteConfirmDialog" max-width="400">
|
||||
<v-dialog
|
||||
v-model="deleteConfirmDialog"
|
||||
max-width="400"
|
||||
>
|
||||
<v-card>
|
||||
<v-card-title class="text-h5">确认删除</v-card-title>
|
||||
<v-card-title class="text-h5">
|
||||
确认删除
|
||||
</v-card-title>
|
||||
<v-card-text>确定要删除这条常驻通知吗?此操作无法撤销。</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn color="grey-darken-1" variant="text" @click="deleteConfirmDialog = false">取消</v-btn>
|
||||
<v-btn color="error" variant="text" @click="executeDelete">删除</v-btn>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="grey-darken-1"
|
||||
variant="text"
|
||||
@click="deleteConfirmDialog = false"
|
||||
>
|
||||
取消
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="error"
|
||||
variant="text"
|
||||
@click="executeDelete"
|
||||
>
|
||||
删除
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<v-overlay
|
||||
v-model="visible"
|
||||
class="align-center justify-center"
|
||||
persistent
|
||||
opacity="0.9"
|
||||
color="#121212"
|
||||
z-index="9000"
|
||||
>
|
||||
<div
|
||||
class="text-center pa-4"
|
||||
style="max-width: 80vw; max-height: 90vh; width: 600px;"
|
||||
>
|
||||
<v-card
|
||||
color="grey-darken-4"
|
||||
elevation="24"
|
||||
class="rounded-xl border-thin"
|
||||
style="border-color: rgba(255,255,255,0.1) !important;"
|
||||
>
|
||||
<!-- Header -->
|
||||
<v-card-title class="d-flex align-center justify-center py-4 bg-grey-darken-3">
|
||||
<v-icon
|
||||
color="orange"
|
||||
size="large"
|
||||
class="mr-3"
|
||||
>
|
||||
mdi-bullhorn
|
||||
</v-icon>
|
||||
<span class="text-h5 font-weight-bold">通知/公告</span>
|
||||
</v-card-title>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<!-- Content -->
|
||||
<v-card-text class="pa-6">
|
||||
<div
|
||||
class="text-h5 text-white markdown-content text-left"
|
||||
v-html="parsedContent"
|
||||
/>
|
||||
</v-card-text>
|
||||
|
||||
<v-divider v-if="duration <= 0" />
|
||||
|
||||
<!-- Actions -->
|
||||
<v-card-actions class="pa-4 justify-center">
|
||||
<v-btn
|
||||
v-if="duration > 0"
|
||||
color="grey-lighten-1"
|
||||
variant="tonal"
|
||||
size="large"
|
||||
block
|
||||
@click="close"
|
||||
>
|
||||
关闭 ({{ remaining }}s)
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-else
|
||||
color="primary"
|
||||
variant="flat"
|
||||
size="large"
|
||||
block
|
||||
@click="close"
|
||||
>
|
||||
我知道了
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</div>
|
||||
</v-overlay>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import dataProvider from "@/utils/dataProvider";
|
||||
import { parseMarkdown } from "@/utils/markdown";
|
||||
import { playSoundAsync, stopSound } from "@/utils/soundList.js";
|
||||
|
||||
export default {
|
||||
name: "AnnouncementOverlay",
|
||||
data() {
|
||||
return {
|
||||
visible: false,
|
||||
currentNotification: null,
|
||||
currentAudio: null,
|
||||
settings: {
|
||||
notifications: [],
|
||||
enableSound: true
|
||||
},
|
||||
timer: null,
|
||||
remaining: 0,
|
||||
countdownTimer: null,
|
||||
lastTriggeredTime: null
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
parsedContent() {
|
||||
if (!this.currentNotification?.content) return '';
|
||||
return parseMarkdown(this.currentNotification.content);
|
||||
},
|
||||
duration() {
|
||||
return this.currentNotification?.duration || 0;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadSettings();
|
||||
// Check every second
|
||||
this.timer = setInterval(this.checkTime, 1000);
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
if (this.countdownTimer) clearInterval(this.countdownTimer);
|
||||
this.stopSound();
|
||||
},
|
||||
methods: {
|
||||
async loadSettings() {
|
||||
try {
|
||||
const res = await dataProvider.loadData('announcement-settings');
|
||||
if (res && res.success !== false) {
|
||||
this.settings = {
|
||||
notifications: res.notifications || [],
|
||||
enableSound: res.enableSound !== false
|
||||
};
|
||||
} else {
|
||||
this.settings = { notifications: [], enableSound: true };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Load announcement settings failed", e);
|
||||
this.settings = { notifications: [], enableSound: true };
|
||||
}
|
||||
},
|
||||
checkTime() {
|
||||
if (this.visible) return; // Already showing
|
||||
if (!this.settings || !this.settings.notifications) return;
|
||||
|
||||
const now = new Date();
|
||||
const hh = String(now.getHours()).padStart(2, '0');
|
||||
const mm = String(now.getMinutes()).padStart(2, '0');
|
||||
const ss = String(now.getSeconds()).padStart(2, '0');
|
||||
const currentTime = `${hh}:${mm}:${ss}`;
|
||||
|
||||
// Avoid re-triggering same second
|
||||
if (this.lastTriggeredTime === currentTime) return;
|
||||
|
||||
const matched = this.settings.notifications.find(n => {
|
||||
const noteTime = n.time.length === 5 ? `${n.time}:00` : n.time;
|
||||
return noteTime === currentTime;
|
||||
});
|
||||
|
||||
if (matched) {
|
||||
this.show(matched);
|
||||
this.lastTriggeredTime = currentTime;
|
||||
}
|
||||
},
|
||||
show(notification) {
|
||||
this.currentNotification = notification;
|
||||
this.visible = true;
|
||||
this.remaining = notification.duration;
|
||||
|
||||
if (this.settings.enableSound) {
|
||||
this.playSound();
|
||||
}
|
||||
|
||||
if (this.remaining > 0) {
|
||||
// Clear any existing countdown first
|
||||
if (this.countdownTimer) clearInterval(this.countdownTimer);
|
||||
|
||||
this.countdownTimer = setInterval(() => {
|
||||
this.remaining--;
|
||||
if (this.remaining <= 0) {
|
||||
this.close();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
},
|
||||
close() {
|
||||
this.visible = false;
|
||||
this.currentNotification = null;
|
||||
if (this.countdownTimer) {
|
||||
clearInterval(this.countdownTimer);
|
||||
this.countdownTimer = null;
|
||||
}
|
||||
this.stopSound();
|
||||
},
|
||||
async playSound() {
|
||||
try {
|
||||
this.stopSound();
|
||||
this.currentAudio = await playSoundAsync("universfield-new-notification-024-370048.mp3", false);
|
||||
} catch (e) {
|
||||
this.playFallbackSound();
|
||||
}
|
||||
},
|
||||
playFallbackSound() {
|
||||
try {
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.frequency.value = 440;
|
||||
osc.type = 'sine';
|
||||
gain.gain.value = 0.1;
|
||||
osc.start();
|
||||
setTimeout(() => {
|
||||
osc.stop();
|
||||
ctx.close();
|
||||
}, 500);
|
||||
} catch {
|
||||
}
|
||||
},
|
||||
stopSound() {
|
||||
if (this.currentAudio) {
|
||||
stopSound(this.currentAudio);
|
||||
this.currentAudio = null;
|
||||
}
|
||||
},
|
||||
reload() {
|
||||
this.loadSettings();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.markdown-content :deep(h1) { font-size: 1.5em; margin-bottom: 0.5em; font-weight: bold; color: white !important; }
|
||||
.markdown-content :deep(h2) { font-size: 1.3em; margin-bottom: 0.5em; font-weight: bold; color: white !important; }
|
||||
.markdown-content :deep(p) { margin-bottom: 0.5em; color: white !important; }
|
||||
.markdown-content :deep(ul) { padding-left: 1.5em; margin-bottom: 0.5em; color: white !important; }
|
||||
.markdown-content :deep(li) { margin-bottom: 0.25em; }
|
||||
.markdown-content :deep(mark) { background-color: yellow; color: black !important; padding: 0 4px; border-radius: 2px; }
|
||||
.markdown-content :deep(hr) { margin: 1em 0; border-color: rgba(255,255,255,0.2); }
|
||||
</style>
|
||||
@@ -0,0 +1,579 @@
|
||||
<template>
|
||||
<v-dialog
|
||||
v-model="dialogVisible"
|
||||
max-width="600"
|
||||
scrollable
|
||||
>
|
||||
<v-card color="#1E1E1E">
|
||||
<v-toolbar
|
||||
color="transparent"
|
||||
density="compact"
|
||||
>
|
||||
<v-toolbar-title class="text-h6 font-weight-bold ml-4">
|
||||
公告板设置
|
||||
</v-toolbar-title>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
variant="text"
|
||||
@click="dialogVisible = false"
|
||||
/>
|
||||
</v-toolbar>
|
||||
|
||||
<v-card-text class="pa-4">
|
||||
<!-- Settings Section -->
|
||||
<v-card
|
||||
variant="outlined"
|
||||
class="mb-4"
|
||||
color="grey-darken-3"
|
||||
>
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center mb-2">
|
||||
<v-icon
|
||||
start
|
||||
icon="mdi-bullhorn"
|
||||
class="mr-2"
|
||||
/>
|
||||
<span class="text-subtitle-1 font-weight-bold">公告板设置</span>
|
||||
</div>
|
||||
<div class="text-caption text-grey-lighten-1 mb-4">
|
||||
设置定时弹出的公告和提示音
|
||||
</div>
|
||||
|
||||
<v-switch
|
||||
v-model="enableSound"
|
||||
color="primary"
|
||||
label="启用提示音"
|
||||
hide-details
|
||||
inset
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-divider class="mb-4" />
|
||||
|
||||
<!-- List Section -->
|
||||
<div class="d-flex align-center mb-2">
|
||||
<span class="text-subtitle-1 font-weight-bold">定时公告列表</span>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="text"
|
||||
prepend-icon="mdi-plus"
|
||||
@click="openAddDialog"
|
||||
>
|
||||
添加公告
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="notifications.length === 0"
|
||||
class="text-center text-grey-lighten-1 py-8"
|
||||
>
|
||||
暂无定时公告
|
||||
</div>
|
||||
|
||||
<v-card
|
||||
v-for="note in notifications"
|
||||
:key="note.id"
|
||||
class="mb-2"
|
||||
variant="outlined"
|
||||
color="grey-darken-3"
|
||||
>
|
||||
<v-card-text class="d-flex align-center py-2">
|
||||
<div class="mr-4 text-h6 font-weight-bold">
|
||||
{{ note.time }}
|
||||
</div>
|
||||
<div class="flex-grow-1 text-truncate text-grey-lighten-1">
|
||||
{{ note.content || '无内容' }}
|
||||
</div>
|
||||
<div
|
||||
v-if="note.duration > 0"
|
||||
class="text-caption text-grey-lighten-1 mr-2"
|
||||
>
|
||||
{{ note.duration }}秒
|
||||
</div>
|
||||
<v-btn
|
||||
icon="mdi-pencil"
|
||||
variant="text"
|
||||
color="info"
|
||||
size="small"
|
||||
class="mr-2"
|
||||
@click="editNotification(note)"
|
||||
/>
|
||||
<v-btn
|
||||
icon="mdi-delete"
|
||||
variant="text"
|
||||
color="error"
|
||||
size="small"
|
||||
@click="deleteNotification(note.id)"
|
||||
/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="primary"
|
||||
@click="dialogVisible = false"
|
||||
>
|
||||
关闭
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
|
||||
<!-- Add/Edit Dialog -->
|
||||
<v-dialog
|
||||
v-model="addDialogVisible"
|
||||
max-width="800"
|
||||
persistent
|
||||
>
|
||||
<v-card color="#1E1E1E">
|
||||
<v-toolbar color="transparent">
|
||||
<v-toolbar-title>{{ isEditing ? '编辑公告' : '添加公告' }}</v-toolbar-title>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-help-circle-outline"
|
||||
class="mr-2"
|
||||
@click="showHelpDialog = true"
|
||||
>
|
||||
使用帮助
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
|
||||
<v-card-text>
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
<div class="text-caption text-grey-lighten-1 mb-1">
|
||||
时间 (HH:mm:ss)
|
||||
</div>
|
||||
<v-text-field
|
||||
v-model="newItem.time"
|
||||
type="time"
|
||||
step="1"
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-clock-outline"
|
||||
hide-details
|
||||
bg-color="grey-darken-4"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<div class="text-caption text-grey-lighten-1 mb-1">
|
||||
持续时间 (秒, 0为不自动关闭)
|
||||
</div>
|
||||
<v-text-field
|
||||
v-model.number="newItem.duration"
|
||||
type="number"
|
||||
variant="outlined"
|
||||
prepend-inner-icon="mdi-timer-sand"
|
||||
hide-details
|
||||
bg-color="grey-darken-4"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row class="mt-2">
|
||||
<v-col
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<div class="d-flex align-center justify-space-between mb-2 flex-wrap">
|
||||
<span class="text-caption text-grey-lighten-1">编辑内容 (Markdown)</span>
|
||||
<!-- Formatting Toolbar -->
|
||||
<div class="d-flex align-center">
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
icon="mdi-format-bold"
|
||||
@click="insertFormat('**', '**')"
|
||||
/>
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
icon="mdi-format-italic"
|
||||
@click="insertFormat('*', '*')"
|
||||
/>
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
icon="mdi-format-header-1"
|
||||
@click="insertFormat('# ', '')"
|
||||
/>
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
icon="mdi-format-header-2"
|
||||
@click="insertFormat('## ', '')"
|
||||
/>
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
icon="mdi-format-list-bulleted"
|
||||
@click="insertFormat('- ', '')"
|
||||
/>
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
icon="mdi-minus"
|
||||
@click="insertFormat('\n---\n', '')"
|
||||
/>
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
icon="mdi-marker"
|
||||
color="yellow"
|
||||
@click="insertFormat('<mark>', '</mark>')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<v-textarea
|
||||
ref="editor"
|
||||
v-model="newItem.content"
|
||||
variant="outlined"
|
||||
rows="10"
|
||||
no-resize
|
||||
bg-color="grey-darken-4"
|
||||
hide-details
|
||||
/>
|
||||
</v-col>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="6"
|
||||
>
|
||||
<div class="text-caption text-grey-lighten-1 mb-2">
|
||||
预览区域
|
||||
</div>
|
||||
<v-card
|
||||
variant="outlined"
|
||||
color="grey-darken-4"
|
||||
height="266"
|
||||
class="pa-4 overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
class="markdown-preview text-white"
|
||||
v-html="renderMarkdown(newItem.content)"
|
||||
/>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
variant="text"
|
||||
@click="addDialogVisible = false"
|
||||
>
|
||||
取消
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
@click="saveNotification"
|
||||
>
|
||||
保存
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Help Dialog -->
|
||||
<v-dialog
|
||||
v-model="showHelpDialog"
|
||||
max-width="700"
|
||||
>
|
||||
<v-card color="#1E1E1E">
|
||||
<v-toolbar
|
||||
color="blue"
|
||||
density="compact"
|
||||
>
|
||||
<v-icon class="ml-4">
|
||||
mdi-school
|
||||
</v-icon>
|
||||
<v-toolbar-title>如何使用公告板</v-toolbar-title>
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
@click="showHelpDialog = false"
|
||||
/>
|
||||
</v-toolbar>
|
||||
|
||||
<v-card-text class="pa-0">
|
||||
<v-row no-gutters>
|
||||
<v-col
|
||||
cols="12"
|
||||
md="6"
|
||||
class="pa-4 border-e border-grey-darken-3"
|
||||
>
|
||||
<h3 class="text-h6 text-blue mb-4">
|
||||
<v-icon
|
||||
color="blue"
|
||||
class="mr-2"
|
||||
>
|
||||
mdi-clock-outline
|
||||
</v-icon>
|
||||
基础设置
|
||||
</h3>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="text-subtitle-2 font-weight-bold">
|
||||
设定时间
|
||||
</div>
|
||||
<div class="text-caption text-grey">
|
||||
设置公告自动弹出的时间(24小时制)。到达该时间时,全屏会显示此公告。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="text-subtitle-2 font-weight-bold">
|
||||
持续时间
|
||||
</div>
|
||||
<div class="text-caption text-grey">
|
||||
公告显示的秒数。设为 0 则不会自动关闭,需要手动点击屏幕关闭。
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
|
||||
<v-col
|
||||
cols="12"
|
||||
md="6"
|
||||
class="pa-4"
|
||||
>
|
||||
<h3 class="text-h6 text-green mb-4">
|
||||
<v-icon
|
||||
color="green"
|
||||
class="mr-2"
|
||||
>
|
||||
mdi-format-paint
|
||||
</v-icon>
|
||||
排版与美化
|
||||
</h3>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="text-subtitle-2 font-weight-bold">
|
||||
加粗 / 斜体
|
||||
</div>
|
||||
<div class="text-caption text-grey">
|
||||
选中文字点击按钮,强调重要信息
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="text-subtitle-2 font-weight-bold">
|
||||
大标题 / 小标题
|
||||
</div>
|
||||
<div class="text-caption text-grey">
|
||||
使用 H1/H2 区分不同板块
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="text-subtitle-2 font-weight-bold">
|
||||
高亮标记 (Mark)
|
||||
</div>
|
||||
<div class="text-caption text-grey">
|
||||
先选中文字,再点击颜色按钮,会自动包裹。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="text-subtitle-2 font-weight-bold">
|
||||
分割线
|
||||
</div>
|
||||
<div class="text-caption text-grey">
|
||||
插入水平线分隔内容
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions class="bg-grey-darken-4 pa-4">
|
||||
<v-alert
|
||||
color="info"
|
||||
variant="tonal"
|
||||
icon="mdi-eye"
|
||||
density="compact"
|
||||
class="flex-grow-1 mr-4"
|
||||
>
|
||||
<div class="text-caption">
|
||||
实时预览:右侧窗口会实时显示最终效果,所见即所得。
|
||||
</div>
|
||||
</v-alert>
|
||||
<v-btn
|
||||
color="primary"
|
||||
min-width="100"
|
||||
@click="showHelpDialog = false"
|
||||
>
|
||||
我学会了
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { parseMarkdown } from "@/utils/markdown";
|
||||
import dataProvider from "@/utils/dataProvider";
|
||||
|
||||
export default {
|
||||
name: "AnnouncementSettingsDialog",
|
||||
props: {
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ["update:modelValue", "save"],
|
||||
data() {
|
||||
return {
|
||||
enableSound: true,
|
||||
notifications: [],
|
||||
addDialogVisible: false,
|
||||
showHelpDialog: false,
|
||||
isEditing: false,
|
||||
editingId: null,
|
||||
newItem: {
|
||||
time: "12:00:00",
|
||||
duration: 5,
|
||||
content: ""
|
||||
}
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
dialogVisible: {
|
||||
get() {
|
||||
return this.modelValue;
|
||||
},
|
||||
set(value) {
|
||||
this.$emit("update:modelValue", value);
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
modelValue(val) {
|
||||
if (val) {
|
||||
this.loadSettings();
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async loadSettings() {
|
||||
try {
|
||||
const res = await dataProvider.loadData('announcement-settings');
|
||||
if (res && res.success !== false) {
|
||||
this.notifications = res.notifications || [];
|
||||
this.enableSound = res.enableSound !== false;
|
||||
} else {
|
||||
this.notifications = [];
|
||||
this.enableSound = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Load announcements failed", e);
|
||||
}
|
||||
},
|
||||
openAddDialog() {
|
||||
this.isEditing = false;
|
||||
this.editingId = null;
|
||||
this.newItem = {
|
||||
time: "12:00:00",
|
||||
duration: 5,
|
||||
content: ""
|
||||
};
|
||||
this.addDialogVisible = true;
|
||||
},
|
||||
editNotification(note) {
|
||||
this.isEditing = true;
|
||||
this.editingId = note.id;
|
||||
this.newItem = { ...note };
|
||||
// Ensure time format includes seconds if missing
|
||||
if (this.newItem.time && this.newItem.time.length === 5) {
|
||||
this.newItem.time = `${this.newItem.time}:00`;
|
||||
}
|
||||
this.addDialogVisible = true;
|
||||
},
|
||||
async saveNotification() {
|
||||
if (!this.newItem.content) return;
|
||||
|
||||
// Ensure time format includes seconds if missing
|
||||
if (this.newItem.time && this.newItem.time.length === 5) {
|
||||
this.newItem.time = `${this.newItem.time}:00`;
|
||||
}
|
||||
|
||||
if (this.isEditing) {
|
||||
const index = this.notifications.findIndex(n => n.id === this.editingId);
|
||||
if (index !== -1) {
|
||||
this.notifications[index] = { ...this.newItem, id: this.editingId };
|
||||
}
|
||||
} else {
|
||||
this.notifications.push({
|
||||
id: Date.now().toString(),
|
||||
...this.newItem
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by time
|
||||
this.notifications.sort((a, b) => a.time.localeCompare(b.time));
|
||||
|
||||
await this.saveAll();
|
||||
this.addDialogVisible = false;
|
||||
},
|
||||
async deleteNotification(id) {
|
||||
this.notifications = this.notifications.filter(n => n.id !== id);
|
||||
await this.saveAll();
|
||||
},
|
||||
async saveAll() {
|
||||
await dataProvider.saveData('announcement-settings', {
|
||||
notifications: this.notifications,
|
||||
enableSound: this.enableSound
|
||||
});
|
||||
this.$emit('save'); // Notify parent to reload overlay settings
|
||||
},
|
||||
renderMarkdown(content) {
|
||||
if (!content) return '';
|
||||
try {
|
||||
return parseMarkdown(content);
|
||||
} catch (e) {
|
||||
return content;
|
||||
}
|
||||
},
|
||||
insertFormat(prefix, suffix) {
|
||||
if (!this.$refs.editor) return;
|
||||
const textarea = this.$refs.editor.$el.querySelector('textarea');
|
||||
if (!textarea) return;
|
||||
|
||||
const start = textarea.selectionStart;
|
||||
const end = textarea.selectionEnd;
|
||||
const text = this.newItem.content || "";
|
||||
|
||||
const before = text.substring(0, start);
|
||||
const selected = text.substring(start, end);
|
||||
const after = text.substring(end);
|
||||
|
||||
this.newItem.content = before + prefix + selected + suffix + after;
|
||||
|
||||
this.$nextTick(() => {
|
||||
textarea.focus();
|
||||
const newCursorPos = start + prefix.length + selected.length + suffix.length;
|
||||
textarea.setSelectionRange(newCursorPos, newCursorPos);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.gap-2 { gap: 8px; }
|
||||
/* Ensure preview text is visible against dark background */
|
||||
.markdown-preview {
|
||||
color: white !important;
|
||||
}
|
||||
.markdown-preview :deep(h1) { font-size: 1.5em; font-weight: bold; margin-bottom: 0.5em; color: white !important; }
|
||||
.markdown-preview :deep(h2) { font-size: 1.25em; font-weight: bold; margin-bottom: 0.5em; color: white !important; }
|
||||
.markdown-preview :deep(ul) { padding-left: 1.5em; margin-bottom: 0.5em; color: white !important; }
|
||||
.markdown-preview :deep(p) { margin-bottom: 0.5em; color: white !important; }
|
||||
.markdown-preview :deep(mark) { background-color: yellow; color: black !important; padding: 0 2px; border-radius: 2px; }
|
||||
.markdown-preview :deep(hr) { border-color: rgba(255, 255, 255, 0.2); margin: 1em 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<v-card
|
||||
class="mb-4"
|
||||
variant="outlined"
|
||||
color="grey-darken-3"
|
||||
>
|
||||
<v-card-title class="d-flex align-center py-2 px-3">
|
||||
<v-icon
|
||||
size="small"
|
||||
class="mr-2"
|
||||
color="orange"
|
||||
>
|
||||
mdi-bullhorn
|
||||
</v-icon>
|
||||
<span class="text-subtitle-2 font-weight-bold">公告板</span>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
icon="mdi-cog"
|
||||
variant="text"
|
||||
size="small"
|
||||
color="grey"
|
||||
@click="$emit('open-settings')"
|
||||
/>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-0">
|
||||
<div v-if="notifications.length > 0">
|
||||
<div
|
||||
v-for="(note, index) in notifications"
|
||||
:key="index"
|
||||
class="px-3 py-2 border-b cursor-pointer hover-bg"
|
||||
style="border-bottom: 1px solid rgba(255,255,255,0.05);"
|
||||
@click="showDetail(note)"
|
||||
>
|
||||
<div class="d-flex align-center justify-space-between mb-1">
|
||||
<div class="d-flex align-center">
|
||||
<v-icon
|
||||
size="x-small"
|
||||
color="primary"
|
||||
class="mr-1"
|
||||
>
|
||||
mdi-clock-outline
|
||||
</v-icon>
|
||||
<span class="text-caption font-weight-bold text-primary">{{ note.time }}</span>
|
||||
</div>
|
||||
<v-chip
|
||||
v-if="isNext(note)"
|
||||
size="x-small"
|
||||
color="success"
|
||||
variant="flat"
|
||||
class="px-1"
|
||||
style="height: 16px; font-size: 10px;"
|
||||
>
|
||||
NEXT
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="text-caption text-grey-lighten-1 markdown-preview-mini"
|
||||
v-html="renderMiniMarkdown(note.content)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="pa-4 text-center text-caption text-grey"
|
||||
>
|
||||
暂无公告安排
|
||||
</div>
|
||||
</v-card-text>
|
||||
|
||||
<!-- Detail Dialog -->
|
||||
<v-dialog
|
||||
v-model="detailDialog"
|
||||
max-width="500"
|
||||
>
|
||||
<v-card color="grey-darken-4">
|
||||
<v-card-title class="d-flex align-center">
|
||||
<v-icon
|
||||
color="primary"
|
||||
class="mr-2"
|
||||
>
|
||||
mdi-bullhorn-outline
|
||||
</v-icon>
|
||||
公告详情
|
||||
<v-spacer />
|
||||
<v-chip
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
size="small"
|
||||
>
|
||||
{{ selectedNote?.time }}
|
||||
</v-chip>
|
||||
</v-card-title>
|
||||
<v-card-text class="pa-4">
|
||||
<div
|
||||
class="markdown-preview text-white"
|
||||
v-html="renderFullMarkdown(selectedNote?.content)"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="text"
|
||||
@click="detailDialog = false"
|
||||
>
|
||||
关闭
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import dataProvider from "@/utils/dataProvider";
|
||||
import { parseMarkdown } from "@/utils/markdown";
|
||||
|
||||
export default {
|
||||
name: "AnnouncementWidget",
|
||||
emits: ["open-settings"],
|
||||
data() {
|
||||
return {
|
||||
notifications: [],
|
||||
timer: null,
|
||||
detailDialog: false,
|
||||
selectedNote: null
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.loadSettings();
|
||||
// Refresh every second to update "NEXT" status
|
||||
this.timer = setInterval(() => {
|
||||
this.$forceUpdate();
|
||||
}, 1000);
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
},
|
||||
methods: {
|
||||
async loadSettings() {
|
||||
try {
|
||||
const res = await dataProvider.loadData('announcement-settings');
|
||||
if (res && res.success !== false && res.notifications) {
|
||||
this.notifications = res.notifications.sort((a, b) => a.time.localeCompare(b.time));
|
||||
} else {
|
||||
this.notifications = [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Load announcements failed", e);
|
||||
}
|
||||
},
|
||||
isNext(note) {
|
||||
if (!this.notifications.length) return false;
|
||||
|
||||
const now = new Date();
|
||||
const hh = String(now.getHours()).padStart(2, '0');
|
||||
const mm = String(now.getMinutes()).padStart(2, '0');
|
||||
const ss = String(now.getSeconds()).padStart(2, '0');
|
||||
const currentTime = `${hh}:${mm}:${ss}`;
|
||||
|
||||
// Find the first notification that is strictly after current time
|
||||
const next = this.notifications.find(n => {
|
||||
const noteTime = n.time.length === 5 ? `${n.time}:00` : n.time;
|
||||
return noteTime > currentTime;
|
||||
});
|
||||
|
||||
if (next) {
|
||||
return note === next;
|
||||
} else {
|
||||
// If all today's times passed, the first one tomorrow is next
|
||||
return note === this.notifications[0];
|
||||
}
|
||||
},
|
||||
renderMiniMarkdown(text) {
|
||||
if (!text) return '';
|
||||
// Render full markdown but stripped of block tags for mini view or just first line?
|
||||
// Better: Render full markdown but restrict height/overflow in CSS
|
||||
// Or: Just render it. The user said "use markdown format"
|
||||
return parseMarkdown(text);
|
||||
},
|
||||
renderFullMarkdown(text) {
|
||||
return parseMarkdown(text);
|
||||
},
|
||||
showDetail(note) {
|
||||
this.selectedNote = note;
|
||||
this.detailDialog = true;
|
||||
},
|
||||
reload() {
|
||||
this.loadSettings();
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hover-bg:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
.markdown-preview-mini {
|
||||
max-height: 60px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
/* Add fade out effect at bottom */
|
||||
mask-image: linear-gradient(to bottom, black 60%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to bottom, black 60%, transparent 100%);
|
||||
}
|
||||
|
||||
/* Re-use markdown styles from settings dialog or global */
|
||||
.markdown-preview-mini :deep(h1),
|
||||
.markdown-preview :deep(h1) { font-size: 1.2em; font-weight: bold; margin: 0.2em 0; color: white !important; }
|
||||
|
||||
.markdown-preview-mini :deep(h2),
|
||||
.markdown-preview :deep(h2) { font-size: 1.1em; font-weight: bold; margin: 0.2em 0; color: white !important; }
|
||||
|
||||
.markdown-preview-mini :deep(p),
|
||||
.markdown-preview :deep(p) { margin: 0 0 0.5em 0; color: rgba(255,255,255,0.9) !important; }
|
||||
|
||||
.markdown-preview-mini :deep(ul),
|
||||
.markdown-preview :deep(ul) { padding-left: 1.2em; margin: 0; color: rgba(255,255,255,0.9) !important; }
|
||||
|
||||
.markdown-preview :deep(mark) { background-color: yellow; color: black !important; padding: 0 2px; border-radius: 2px; }
|
||||
</style>
|
||||
@@ -6,25 +6,42 @@
|
||||
max-width="900"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<v-card>
|
||||
<v-card color="black-lighten-1">
|
||||
<v-card-title class="d-flex align-center">
|
||||
<v-icon class="mr-2" icon="mdi-account-group" />
|
||||
<v-icon
|
||||
class="mr-2"
|
||||
icon="mdi-account-group"
|
||||
/>
|
||||
考勤
|
||||
<v-spacer />
|
||||
<v-chip v-if="!isMobile" class="ml-2" color="primary" size="small">
|
||||
{{ dateString }}
|
||||
</v-chip>
|
||||
<v-btn v-if="isMobile" icon="mdi-close" variant="text" @click="$emit('update:modelValue', false)" />
|
||||
<v-btn
|
||||
color="secondary"
|
||||
variant="tonal"
|
||||
prepend-icon="mdi-format-list-bulleted"
|
||||
class="mr-2"
|
||||
@click="showEditListDialog = true"
|
||||
>
|
||||
修改名单
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="isMobile"
|
||||
icon="mdi-close"
|
||||
variant="text"
|
||||
@click="$emit('update:modelValue', false)"
|
||||
/>
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text>
|
||||
<!-- 批量操作和搜索 -->
|
||||
<v-row class="mb-4">
|
||||
<v-col cols="12" md="12">
|
||||
<v-col
|
||||
cols="12"
|
||||
md="12"
|
||||
>
|
||||
<v-text-field
|
||||
v-model="attendanceSearch"
|
||||
clearable
|
||||
hint="支持筛选姓氏,如输入'孙'可筛选所有姓孙的学生"
|
||||
hint="*支持筛选姓氏"
|
||||
label="搜索学生"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
variant="outlined"
|
||||
@@ -128,7 +145,10 @@
|
||||
md="6"
|
||||
sm="6"
|
||||
>
|
||||
<v-card border class="student-card">
|
||||
<v-card
|
||||
border
|
||||
class="student-card"
|
||||
>
|
||||
<v-card-text class="d-flex align-center pa-2">
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-center">
|
||||
@@ -137,11 +157,13 @@
|
||||
class="mr-2"
|
||||
size="24"
|
||||
>
|
||||
<v-icon size="small"
|
||||
>{{ getStudentStatusIcon(student) }}
|
||||
<v-icon size="small">
|
||||
{{ getStudentStatusIcon(student) }}
|
||||
</v-icon>
|
||||
</v-avatar>
|
||||
<div class="text-subtitle-1">{{ student }}</div>
|
||||
<div class="text-subtitle-1">
|
||||
{{ student }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="attendance-actions">
|
||||
@@ -183,10 +205,19 @@
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-row>
|
||||
<v-col cols="12" md="12">
|
||||
<v-card class="mb-4" color="primary" variant="tonal">
|
||||
<v-col
|
||||
cols="12"
|
||||
md="12"
|
||||
>
|
||||
<v-card
|
||||
class="mb-4"
|
||||
color="primary"
|
||||
variant="tonal"
|
||||
>
|
||||
<v-card-text>
|
||||
<div class="text-subtitle-2 mb-2">批量操作</div>
|
||||
<div class="text-subtitle-2 mb-2">
|
||||
批量操作
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<v-btn
|
||||
class="flex-grow-1"
|
||||
@@ -232,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="张三 李四 王五"
|
||||
/>
|
||||
</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) {
|
||||
|
||||
@@ -1,89 +1,430 @@
|
||||
<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>
|
||||
import { useDisplay } from "vuetify";
|
||||
import {getSetting} from "@/utils/settings.js";
|
||||
import { getSetting } from "@/utils/settings.js";
|
||||
|
||||
export default {
|
||||
name: "AttendanceSidebar",
|
||||
@@ -100,14 +441,240 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
contentStyle: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
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>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer/>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
v-if="showCancel"
|
||||
variant="text"
|
||||
|
||||
@@ -41,9 +41,7 @@
|
||||
label="命名空间"
|
||||
prepend-inner-icon="mdi-identifier"
|
||||
variant="outlined"
|
||||
>
|
||||
|
||||
</v-text-field>
|
||||
/>
|
||||
|
||||
<v-text-field
|
||||
v-model="form.password"
|
||||
@@ -51,9 +49,7 @@
|
||||
prepend-inner-icon="mdi-lock-outline"
|
||||
type="text"
|
||||
variant="outlined"
|
||||
>
|
||||
|
||||
</v-text-field>
|
||||
/>
|
||||
|
||||
<v-alert
|
||||
v-if="error"
|
||||
@@ -77,7 +73,7 @@
|
||||
>
|
||||
取消
|
||||
</v-btn>
|
||||
<v-spacer/>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
:disabled="!form.namespace || authenticating"
|
||||
:loading="authenticating"
|
||||
|
||||
@@ -139,8 +139,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 步骤 3: 询问使用场景 -->
|
||||
@@ -280,8 +278,8 @@
|
||||
也可以手动前往 Classworks KV 控制台获取认证信息:
|
||||
</div>
|
||||
<v-card
|
||||
:color=" kvserverurl=='https://kv.houlang.cloud'? 'primary' : 'error' "
|
||||
:variant="kvserverurl=='https://kv.houlang.cloud'? 'elevated' : 'outlined'"
|
||||
color="primary"
|
||||
variant="elevated"
|
||||
class="pa-6 mb-6"
|
||||
@click="openKVSite"
|
||||
>
|
||||
@@ -293,13 +291,13 @@
|
||||
</v-icon>
|
||||
|
||||
<h4 class="text-h6 font-weight-bold">
|
||||
请访问 {{ kvserverurl=='https://kv.houlang.cloud'? 'Classworks KV' : '自定义的 Classworks KV 实例 ' }} 控制台
|
||||
请访问 Classworks KV 控制台
|
||||
</h4>
|
||||
<div class="text-h5 mb-6">
|
||||
{{ kvserverurl }}
|
||||
{{ kvserverurl || '未配置' }}
|
||||
</div>
|
||||
<h6 class="text-subtitle-2">
|
||||
{{ kvserverurl=='https://kv.houlang.cloud'? '此实例由 Classworks KV 官方提供' : '此链接由您的实例、预配代码或管理员管理,当前可能不是 Classworks KV 官方的实例地址。' }}
|
||||
此链接由您的实例、预配代码或管理员管理。
|
||||
</h6>
|
||||
</v-card>
|
||||
|
||||
@@ -553,7 +551,7 @@
|
||||
</v-icon>
|
||||
上一步
|
||||
</v-btn>
|
||||
<v-spacer/>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
v-if="currentStep < totalSteps && currentStep !== 4"
|
||||
:disabled="currentStep === 3 && !storageType"
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
variant="tonal"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon icon="mdi-information"/>
|
||||
<v-icon icon="mdi-information" />
|
||||
</template>
|
||||
系统将自动为您创建设备并获取访问令牌,无需手动配置
|
||||
</v-alert>
|
||||
@@ -52,7 +52,7 @@
|
||||
variant="tonal"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon icon="mdi-check-circle"/>
|
||||
<v-icon icon="mdi-check-circle" />
|
||||
</template>
|
||||
设备注册成功!已自动获取访问令牌
|
||||
</v-alert>
|
||||
@@ -60,7 +60,7 @@
|
||||
<v-list>
|
||||
<v-list-item>
|
||||
<template #prepend>
|
||||
<v-icon icon="mdi-identifier"/>
|
||||
<v-icon icon="mdi-identifier" />
|
||||
</template>
|
||||
<v-list-item-title>设备名称</v-list-item-title>
|
||||
<v-list-item-subtitle>{{ deviceInfo.deviceName }}</v-list-item-subtitle>
|
||||
@@ -68,7 +68,7 @@
|
||||
|
||||
<v-list-item>
|
||||
<template #prepend>
|
||||
<v-icon icon="mdi-key"/>
|
||||
<v-icon icon="mdi-key" />
|
||||
</template>
|
||||
<v-list-item-title>设备 UUID</v-list-item-title>
|
||||
<v-list-item-subtitle class="font-mono text-caption">
|
||||
@@ -83,7 +83,7 @@
|
||||
variant="tonal"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon icon="mdi-information"/>
|
||||
<v-icon icon="mdi-information" />
|
||||
</template>
|
||||
您可以点击下方按钮访问云端控制台来设置密码和管理高级功能
|
||||
</v-alert>
|
||||
@@ -97,7 +97,7 @@
|
||||
variant="tonal"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon icon="mdi-alert-circle"/>
|
||||
<v-icon icon="mdi-alert-circle" />
|
||||
</template>
|
||||
{{ errorMessage }}
|
||||
</v-alert>
|
||||
@@ -105,7 +105,7 @@
|
||||
</v-card-text>
|
||||
|
||||
<v-card-actions>
|
||||
<v-spacer/>
|
||||
<v-spacer />
|
||||
|
||||
<!-- 注册按钮 -->
|
||||
<v-btn
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</v-alert>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer/>
|
||||
<v-spacer />
|
||||
<v-btn
|
||||
v-if="showCancel"
|
||||
variant="text"
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,15 +1,29 @@
|
||||
<template>
|
||||
<v-container class="fill-height">
|
||||
<v-responsive class="align-centerfill-height mx-auto" max-width="900">
|
||||
<v-img class="mb-4" height="150" src="@/assets/logo.svg"/>
|
||||
|
||||
<div class="text-center">
|
||||
<div class="text-body-2 font-weight-light mb-n1">出现了错误</div>
|
||||
|
||||
<h1 class="text-h2 font-weight-bold">404</h1>
|
||||
<v-responsive
|
||||
class="align-centerfill-height mx-auto"
|
||||
max-width="900"
|
||||
>
|
||||
<div class="text-center mb-4">
|
||||
<v-icon
|
||||
size="150"
|
||||
color="primary"
|
||||
>
|
||||
mdi-alert-circle-outline
|
||||
</v-icon>
|
||||
</div>
|
||||
|
||||
<div class="py-4"/>
|
||||
<div class="text-center">
|
||||
<div class="text-body-2 font-weight-light mb-n1">
|
||||
出现了错误
|
||||
</div>
|
||||
|
||||
<h1 class="text-h2 font-weight-bold">
|
||||
404
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="py-4" />
|
||||
|
||||
<v-row>
|
||||
<v-col cols="12">
|
||||
@@ -21,15 +35,19 @@
|
||||
variant="outlined"
|
||||
>
|
||||
<template #image>
|
||||
<v-img position="top right"/>
|
||||
<v-img position="top right" />
|
||||
</template>
|
||||
|
||||
<template #title>
|
||||
<h2 class="text-h5 font-weight-bold">为什么会出现此错误?</h2>
|
||||
<h2 class="text-h5 font-weight-bold">
|
||||
为什么会出现此错误?
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<template #subtitle>
|
||||
<div class="text-subtitle-1">大概是页面未找到</div>
|
||||
<div class="text-subtitle-1">
|
||||
大概是页面未找到
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<v-overlay
|
||||
@@ -70,7 +88,7 @@
|
||||
rounded="lg"
|
||||
title="返回上一页"
|
||||
variant="text"
|
||||
@click="this.$router.back()"
|
||||
@click="$router.back()"
|
||||
>
|
||||
<v-overlay
|
||||
contained
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||