1
0
mirror of https://github.com/ZeroCatDev/Classworks.git synced 2026-06-16 21:05:09 +00:00

Compare commits

..

2 Commits

Author SHA1 Message Date
Sunwuyuan
5d0b0bb175
feat: 添加离线缓冲层,支持断网编辑和数据持久化
- dataProvider.js: 添加 cache-aside 读取和 write-through 写入策略
- kvLocalProvider.js: IndexedDB 升级到 v3,新增 syncQueue store
- index.vue: 集成 syncManager,处理离线/待同步状态提示
2026-05-23 19:24:03 +08:00
Sunwuyuan
e4116d7ec4
refactor: improve code formatting and structure across multiple components
- Updated Vue components in `src/pages/list/index.vue`, `src/pages/settings.vue`, and `src/pages/socket-debugger.vue` for better readability by adjusting indentation and line breaks.
- Enhanced the `dataProvider.js` utility to streamline server and local data handling, including the introduction of a sync queue for offline write buffering.
- Incremented the local database version in `kvLocalProvider.js` to accommodate new sync queue features.
- Added a new `CLAUDE.md` file to provide project overview and development guidelines.
- Commented out the Sentry vendor chunk in `vite.config.mjs` for potential future use.
2026-05-23 19:17:14 +08:00
8 changed files with 256 additions and 158 deletions

72
CLAUDE.md Normal file
View File

@ -0,0 +1,72 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Classworks (作业板) is a homework board widget for classroom large screens. It's a Vue 3 + Vuetify 3 PWA with real-time sync via Socket.IO. The UI is in Chinese.
## Commands
```bash
pnpm install # Install dependencies
pnpm run dev # Dev server at localhost:3031 (network-accessible)
pnpm run build # Production build (auto-runs prebuild to regenerate sound list)
pnpm run preview # Preview production build
pnpm run lint # ESLint with auto-fix
```
## Tech Stack
- **Framework**: Vue 3 (Composition API + Options API mixed), JavaScript (no TypeScript)
- **UI**: Vuetify 3 (Material Design 3), `@mdi/font` icons, SCSS
- **State**: Pinia 3
- **Routing**: Vue Router 4 with file-based routes (`unplugin-vue-router` + `vite-plugin-vue-layouts`)
- **Build**: Vite 5, pnpm
- **Real-time**: Socket.IO client (singleton in `src/utils/socketClient.js`)
- **Data**: Pluggable KV provider abstraction (`src/utils/dataProvider.js`) with IndexedDB local and HTTP server backends
- **PWA**: `vite-plugin-pwa` with Workbox service worker
## Architecture
### Data Layer
`src/utils/dataProvider.js` abstracts data operations. It routes to either:
- `src/utils/providers/kvLocalProvider.js` — IndexedDB via `idb`
- `src/utils/providers/kvServerProvider.js` — HTTP API via axios
Server failover is handled by `src/utils/serverRotation.js`.
### Real-time Layer
`src/utils/socketClient.js` — Socket.IO singleton with room-based token join/leave for live updates.
### Settings Layer
`src/utils/settings.js` — Comprehensive localStorage-based settings with typed definitions, defaults, and legacy migration. ~600 lines.
### UI Layer
File-based routing: each `.vue` in `src/pages/` becomes a route. Layouts in `src/layouts/`. The main dashboard is `src/pages/index.vue` (78KB — the core view composing homework grid, time card, noise monitor, random picker, exam schedule, etc.).
Components are organized by feature:
- `src/components/home/` — Home page components
- `src/components/settings/` — Settings cards
- `src/components/auth/` — Authentication flow
- `src/components/attendance/` — Attendance management
- `src/components/common/` — Shared components
### Key Utilities
- `src/axios/axios.js` — Axios instance with auth interceptors and rate limit handling
- `src/utils/api.js` — API helpers, namespace info, server rotation
- `src/utils/visitorId.js` — FingerprintJS device identification
- `src/utils/soundList.js` — Auto-generated from `public/sounds/` by `scripts/generate-sound-list.js` (runs as `prebuild`)
## Code Style
- 2-space indent, trim trailing whitespace (`.editorconfig`)
- Path alias: `@/` maps to `src/` (`jsconfig.json`)
- ESLint flat config (ESLint 9) with Vue recommended rules (`eslint.config.js`)
- Mixed Composition API and Options API usage
- No TypeScript

View File

@ -499,11 +499,11 @@ export default {
const openFeedback = () => { const openFeedback = () => {
// //
if (typeof window.openSentryFeedback === 'function') { // if (typeof window.openSentryFeedback === 'function') {
window.openSentryFeedback(); // window.openSentryFeedback();
} else { // } else {
console.warn('Sentry Feedback 功能不可用'); console.warn('Sentry Feedback 功能不可用');
} //}
}; };
const openDonationLink = () => { const openDonationLink = () => {

View File

@ -29,14 +29,14 @@ app.mount('#app')
// ====== 以下全部异步,不阻塞首屏渲染 ====== // ====== 以下全部异步,不阻塞首屏渲染 ======
// 异步初始化 Sentry延迟到首帧渲染完成后防止 errorHandler 与渲染周期冲突) // 异步初始化 Sentry延迟到首帧渲染完成后防止 errorHandler 与渲染周期冲突)
setTimeout(() => { // setTimeout(() => {
import('./utils/sentry').then(({ initSentry }) => { // import('./utils/sentry').then(({ initSentry }) => {
const router = app.config.globalProperties.$router // const router = app.config.globalProperties.$router
initSentry(app, router) // initSentry(app, router)
}).catch((err) => { // }).catch((err) => {
console.warn('Sentry 初始化失败:', err) // console.warn('Sentry 初始化失败:', err)
}) // })
}, 1000) //}, 1000)
// 异步加载 Clarity在页面完全加载后 // 异步加载 Clarity在页面完全加载后
const loadClarity = async () => { const loadClarity = async () => {

View File

@ -619,7 +619,7 @@ const ExamConfigEditor = defineAsyncComponent({
loader: () => import("@/components/ExamConfigEditor.vue"), loader: () => import("@/components/ExamConfigEditor.vue"),
delay: 0, delay: 0,
}); });
import dataProvider from "@/utils/dataProvider"; import dataProvider, {syncManager} from "@/utils/dataProvider";
import { useExamStore } from "@/stores/examStore"; import { useExamStore } from "@/stores/examStore";
import { import {
getSetting, getSetting,
@ -1207,6 +1207,9 @@ export default {
// //
this.setupRealtimeChannel(); this.setupRealtimeChannel();
// 线
syncManager.init();
// Token // Token
this.$nextTick(() => { this.$nextTick(() => {
this.updateTokenDisplayInfo(); this.updateTokenDisplayInfo();
@ -1224,6 +1227,7 @@ export default {
}, },
beforeUnmount() { beforeUnmount() {
syncManager.destroy();
if (this.unwatchSettings) { if (this.unwatchSettings) {
this.unwatchSettings(); this.unwatchSettings();
} }
@ -1570,7 +1574,11 @@ export default {
}; };
this.state.synced = true; this.state.synced = true;
this.state.showNoDataMessage = false; this.state.showNoDataMessage = false;
this.$message.success("下载成功", "数据已更新"); if (response.fromCache) {
this.$message.warning("离线模式", "显示缓存数据,联网后将自动同步");
} else {
this.$message.success("下载成功", "数据已更新");
}
} }
} catch (error) { } catch (error) {
// //
@ -1672,7 +1680,11 @@ export default {
} }
this.state.synced = true; this.state.synced = true;
this.$message.success(response.message || "保存成功"); if (response.queuedForSync) {
this.$message.warning("已保存到本地", "网络恢复后将自动同步到服务器");
} else {
this.$message.success(response.message || "保存成功");
}
} finally { } finally {
this.loading.upload = false; this.loading.upload = false;
} }

View File

@ -230,6 +230,7 @@
<background-settings-card border /> <background-settings-card border />
</v-tabs-window-item> </v-tabs-window-item>
<v-tabs-window-item value="developer"> <v-tabs-window-item value="developer">
<settings-card <settings-card
border border

View File

@ -10,163 +10,133 @@ export const formatError = (message, code = "UNKNOWN_ERROR") => ({
error: {code, message}, error: {code, message},
}); });
// Cache key prefix to avoid collision with kv-local mode data
const CACHE_PREFIX = "_cache:";
function isServerError(result) {
return result && result.success === false;
}
function isNetworkError(result) {
return isServerError(result) && result.error?.code === "NETWORK_ERROR";
}
// --- Sync manager: flushes queued writes when back online ---
let _onlineHandler = null;
let _flushing = false;
async function flushSyncQueue() {
if (_flushing) return;
_flushing = true;
try {
const queueResult = await kvLocalProvider.getSyncQueue();
if (queueResult.success === false || !Array.isArray(queueResult) || queueResult.length === 0) {
return;
}
for (const entry of queueResult) {
try {
const result = await kvServerProvider.saveData(entry.key, entry.data);
if (result.success !== false) {
await kvLocalProvider.removeFromSyncQueue(entry.key);
}
} catch {
// If a single item fails, stop — will retry on next online event
break;
}
}
} finally {
_flushing = false;
}
}
export const syncManager = {
init() {
if (_onlineHandler) return;
_onlineHandler = () => flushSyncQueue();
window.addEventListener("online", _onlineHandler);
// Attempt flush on startup in case items were queued before last exit
if (navigator.onLine) flushSyncQueue();
},
destroy() {
if (_onlineHandler) {
window.removeEventListener("online", _onlineHandler);
_onlineHandler = null;
}
},
flushNow: flushSyncQueue,
};
// Helper: check if we should use the server provider
function useServerProvider() {
const provider = getSetting("server.provider");
return provider === "kv-server" || provider === "classworkscloud";
}
// Main data provider with simplified API // Main data provider with simplified API
export default { export default {
// Provider API methods // Provider API methods
loadData: async (key) => { loadData: async (key) => {
const provider = getSetting("server.provider"); if (!useServerProvider()) {
const useServer =
provider === "kv-server" || provider === "classworkscloud";
if (useServer) {
return kvServerProvider.loadData(key);
} else {
return kvLocalProvider.loadData(key); return kvLocalProvider.loadData(key);
} }
// Server mode: network-first with cache fallback
const result = await kvServerProvider.loadData(key);
if (!isNetworkError(result)) {
// Success or non-network error (e.g. NOT_FOUND) — cache on success
if (result.success !== false) {
kvLocalProvider.saveData(CACHE_PREFIX + key, result);
}
return result;
}
// Network error — try local cache
const cached = await kvLocalProvider.loadData(CACHE_PREFIX + key);
if (cached.success !== false) {
return {...cached, fromCache: true};
}
return result;
}, },
saveData: async (key, data) => { saveData: async (key, data) => {
const provider = getSetting("server.provider"); if (!useServerProvider()) {
const useServer =
provider === "kv-server" || provider === "classworkscloud";
if (useServer) {
return kvServerProvider.saveData(key, data);
} else {
return kvLocalProvider.saveData(key, data); return kvLocalProvider.saveData(key, data);
} }
// Server mode: write-through — persist locally first
await kvLocalProvider.saveData(CACHE_PREFIX + key, data);
const result = await kvServerProvider.saveData(key, data);
if (result.success !== false) {
// Server save succeeded — remove from sync queue if present
await kvLocalProvider.removeFromSyncQueue(key);
return result;
}
// Server save failed — queue for later sync
await kvLocalProvider.addToSyncQueue({key, data, timestamp: Date.now()});
return {success: true, queuedForSync: true};
}, },
/**
* 获取键名列表
* @param {Object} options - 查询选项
* @param {string} options.sortBy - 排序字段默认为 "key"
* @param {string} options.sortDir - 排序方向"asc" "desc"默认为 "asc"
* @param {number} options.limit - 每页返回的记录数默认为 100
* @param {number} options.skip - 跳过的记录数默认为 0
* @returns {Promise<Object>} 包含键名列表和分页信息的响应对象
*
* 使用示例:
* ```javascript
* // 获取前10个键名
* const result = await dataProvider.loadKeys({ limit: 10 });
* if (result.success !== false) {
* console.log('键名列表:', result.keys);
* console.log('总数:', result.total_rows);
* }
*
* // 获取第二页数据跳过前10个
* const page2 = await dataProvider.loadKeys({ limit: 10, skip: 10 });
*
* // 按键名降序排列
* const sorted = await dataProvider.loadKeys({ sortDir: 'desc' });
* ```
*
* 返回值格式:
* ```javascript
* {
* keys: ["key1", "key2", "key3"],
* total_rows: 150,
* current_page: {
* limit: 10,
* skip: 0,
* count: 10
* },
* load_more: "/api/kv/namespace/_keys?..." // 仅服务器模式
* }
* ```
*/
loadKeys: async (options = {}) => { loadKeys: async (options = {}) => {
const provider = getSetting("server.provider"); if (!useServerProvider()) {
const useServer =
provider === "kv-server" || provider === "classworkscloud";
if (useServer) {
return kvServerProvider.loadKeys(options);
} else {
return kvLocalProvider.loadKeys(options); return kvLocalProvider.loadKeys(options);
} }
const result = await kvServerProvider.loadKeys(options);
if (!isNetworkError(result)) {
return result;
}
// Network error — fall back to local cache keys
return kvLocalProvider.loadKeys(options);
}, },
/**
* 获取键的云端访问地址并处理本地到云端的数据迁移
*
* 功能说明:
* 1. 如果用户选择本地存储则将本地键数据读取并存储到云端
* 2. 如果云端配置为空或错误则自动改成classworksCloudDefaults的配置
* 3. 根据网站验证情况私有则添加token公开或受保护则不需要拼接键的get路径并返回
*
* @param {string} key - 要获取地址的键名
* @param {Object} options - 选项配置
* @param {boolean} options.migrateFromLocal - 是否从本地迁移数据到云端默认为true
* @param {boolean} options.autoConfigureCloud - 是否自动配置云端默认设置默认为true
* @returns {Promise<Object>} 包含键访问地址和操作结果的响应对象
*
* 使用示例:
* ```javascript
* import dataProvider from '@/utils/dataProvider';
*
* // 基本用法:获取键的云端地址并自动迁移本地数据
* const result = await dataProvider.getKeyCloudUrl('exam_configs');
* if (result.success) {
* console.log('云端访问地址:', result.url);
* console.log('是否已迁移数据:', result.migrated);
* console.log('是否自动配置:', result.configured);
* } else {
* console.error('获取失败:', result.error.message);
* }
*
* // 仅获取地址,不迁移数据
* const urlOnly = await dataProvider.getKeyCloudUrl('my_data', {
* migrateFromLocal: false
* });
*
* // 不自动配置云端设置
* const noAutoConfig = await dataProvider.getKeyCloudUrl('my_data', {
* autoConfigureCloud: false
* });
* ```
*
* 传入参数示例:
* ```javascript
* // 参数1: key (必需)
* 'exam_configs' // 字符串类型的键名
*
* // 参数2: options (可选)
* {
* migrateFromLocal: true, // 是否迁移本地数据
* autoConfigureCloud: true // 是否自动配置云端
* }
* ```
*
* 返回值格式:
* ```javascript
* // 成功时返回:
* {
* success: true,
* url: "https://kv-service.houlang.cloud/device-uuid-123/exam_configs?token=abc123", // 私有访问时包含token
* migrated: true, // 是否成功迁移了本地数据
* configured: false // 是否自动配置了云端设置
* }
*
* // 公开访问时返回:
* {
* success: true,
* url: "https://kv-service.houlang.cloud/device-uuid-123/exam_configs", // 公开访问不包含token
* migrated: false,
* configured: true
* }
*
* // 失败时返回:
* {
* success: false,
* error: {
* code: "CLOUD_URL_ERROR",
* message: "获取键云端地址失败"
* }
* }
* ```
*/
async getKeyCloudUrl(key, options = {}) { async getKeyCloudUrl(key, options = {}) {
const { const {
migrateFromLocal = true, migrateFromLocal = true,

View File

@ -3,7 +3,7 @@ import {formatResponse, formatError} from "../dataProvider";
// Database initialization for local storage // Database initialization for local storage
const DB_NAME = "ClassworksDB"; const DB_NAME = "ClassworksDB";
const DB_VERSION = 2; const DB_VERSION = 3;
const initDB = async () => { const initDB = async () => {
return openDB(DB_NAME, DB_VERSION, { return openDB(DB_NAME, DB_VERSION, {
@ -17,6 +17,11 @@ const initDB = async () => {
if (!db.objectStoreNames.contains("system")) { if (!db.objectStoreNames.contains("system")) {
db.createObjectStore("system"); db.createObjectStore("system");
} }
// Add a sync queue store for offline write buffering
if (!db.objectStoreNames.contains("syncQueue")) {
db.createObjectStore("syncQueue");
}
}, },
}); });
}; };
@ -112,4 +117,42 @@ export const kvLocalProvider = {
return formatError("获取本地键名列表失败:" + error.message); return formatError("获取本地键名列表失败:" + error.message);
} }
}, },
// --- Sync queue operations for offline write buffering ---
async addToSyncQueue(entry) {
try {
const db = await initDB();
await db.put("syncQueue", JSON.stringify(entry), entry.key);
return formatResponse(true);
} catch (error) {
return formatError("添加同步队列失败:" + error);
}
},
async getSyncQueue() {
try {
const db = await initDB();
const keys = await db.getAllKeys("syncQueue");
const entries = [];
for (const key of keys) {
const raw = await db.get("syncQueue", key);
if (raw) entries.push(JSON.parse(raw));
}
entries.sort((a, b) => a.timestamp - b.timestamp);
return formatResponse(entries);
} catch (error) {
return formatError("获取同步队列失败:" + error);
}
},
async removeFromSyncQueue(key) {
try {
const db = await initDB();
await db.delete("syncQueue", key);
return formatResponse(true);
} catch (error) {
return formatError("删除同步队列项失败:" + error);
}
},
}; };

View File

@ -209,7 +209,7 @@ export default defineConfig({
// UI 框架 // UI 框架
'vendor-vuetify': ['vuetify'], 'vendor-vuetify': ['vuetify'],
// 监控(异步加载,独立 chunk // 监控(异步加载,独立 chunk
'vendor-sentry': ['@sentry/vue'], // 'vendor-sentry': ['@sentry/vue'],
// 实时通信 // 实时通信
'vendor-socket': ['socket.io-client'], 'vendor-socket': ['socket.io-client'],
// 通用工具库 // 通用工具库