mirror of
https://github.com/ZeroCatDev/Classworks.git
synced 2026-06-16 21:05:09 +00:00
Compare commits
No commits in common. "5d0b0bb1758c6c0ab67edc8621e8ab8ecb6497e0" and "8479ab4fff65c9cf51db24488394bfe7aeaf669e" have entirely different histories.
5d0b0bb175
...
8479ab4fff
72
CLAUDE.md
72
CLAUDE.md
@ -1,72 +0,0 @@
|
||||
# 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
|
||||
@ -499,11 +499,11 @@ export default {
|
||||
|
||||
const openFeedback = () => {
|
||||
// 打开反馈对话框
|
||||
// if (typeof window.openSentryFeedback === 'function') {
|
||||
// window.openSentryFeedback();
|
||||
// } else {
|
||||
if (typeof window.openSentryFeedback === 'function') {
|
||||
window.openSentryFeedback();
|
||||
} else {
|
||||
console.warn('Sentry Feedback 功能不可用');
|
||||
//}
|
||||
}
|
||||
};
|
||||
|
||||
const openDonationLink = () => {
|
||||
|
||||
16
src/main.js
16
src/main.js
@ -29,14 +29,14 @@ app.mount('#app')
|
||||
// ====== 以下全部异步,不阻塞首屏渲染 ======
|
||||
|
||||
// 异步初始化 Sentry(延迟到首帧渲染完成后,防止 errorHandler 与渲染周期冲突)
|
||||
// setTimeout(() => {
|
||||
// import('./utils/sentry').then(({ initSentry }) => {
|
||||
// const router = app.config.globalProperties.$router
|
||||
// initSentry(app, router)
|
||||
// }).catch((err) => {
|
||||
// console.warn('Sentry 初始化失败:', err)
|
||||
// })
|
||||
//}, 1000)
|
||||
setTimeout(() => {
|
||||
import('./utils/sentry').then(({ initSentry }) => {
|
||||
const router = app.config.globalProperties.$router
|
||||
initSentry(app, router)
|
||||
}).catch((err) => {
|
||||
console.warn('Sentry 初始化失败:', err)
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
// 异步加载 Clarity(在页面完全加载后)
|
||||
const loadClarity = async () => {
|
||||
|
||||
@ -619,7 +619,7 @@ const ExamConfigEditor = defineAsyncComponent({
|
||||
loader: () => import("@/components/ExamConfigEditor.vue"),
|
||||
delay: 0,
|
||||
});
|
||||
import dataProvider, {syncManager} from "@/utils/dataProvider";
|
||||
import dataProvider from "@/utils/dataProvider";
|
||||
import { useExamStore } from "@/stores/examStore";
|
||||
import {
|
||||
getSetting,
|
||||
@ -1207,9 +1207,6 @@ export default {
|
||||
// 实时频道:加入设备房间并监听键变化
|
||||
this.setupRealtimeChannel();
|
||||
|
||||
// 初始化离线同步管理器
|
||||
syncManager.init();
|
||||
|
||||
// 初始化 Token 显示信息
|
||||
this.$nextTick(() => {
|
||||
this.updateTokenDisplayInfo();
|
||||
@ -1227,7 +1224,6 @@ export default {
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
syncManager.destroy();
|
||||
if (this.unwatchSettings) {
|
||||
this.unwatchSettings();
|
||||
}
|
||||
@ -1574,11 +1570,7 @@ export default {
|
||||
};
|
||||
this.state.synced = true;
|
||||
this.state.showNoDataMessage = false;
|
||||
if (response.fromCache) {
|
||||
this.$message.warning("离线模式", "显示缓存数据,联网后将自动同步");
|
||||
} else {
|
||||
this.$message.success("下载成功", "数据已更新");
|
||||
}
|
||||
this.$message.success("下载成功", "数据已更新");
|
||||
}
|
||||
} catch (error) {
|
||||
// 数据加载失败时的处理
|
||||
@ -1680,11 +1672,7 @@ export default {
|
||||
}
|
||||
|
||||
this.state.synced = true;
|
||||
if (response.queuedForSync) {
|
||||
this.$message.warning("已保存到本地", "网络恢复后将自动同步到服务器");
|
||||
} else {
|
||||
this.$message.success(response.message || "保存成功");
|
||||
}
|
||||
this.$message.success(response.message || "保存成功");
|
||||
} finally {
|
||||
this.loading.upload = false;
|
||||
}
|
||||
|
||||
@ -230,7 +230,6 @@
|
||||
<background-settings-card border />
|
||||
</v-tabs-window-item>
|
||||
|
||||
|
||||
<v-tabs-window-item value="developer">
|
||||
<settings-card
|
||||
border
|
||||
|
||||
@ -10,133 +10,163 @@ export const formatError = (message, code = "UNKNOWN_ERROR") => ({
|
||||
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
|
||||
export default {
|
||||
// Provider API methods
|
||||
loadData: async (key) => {
|
||||
if (!useServerProvider()) {
|
||||
const provider = getSetting("server.provider");
|
||||
const useServer =
|
||||
provider === "kv-server" || provider === "classworkscloud";
|
||||
|
||||
if (useServer) {
|
||||
return kvServerProvider.loadData(key);
|
||||
} else {
|
||||
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) => {
|
||||
if (!useServerProvider()) {
|
||||
const provider = getSetting("server.provider");
|
||||
const useServer =
|
||||
provider === "kv-server" || provider === "classworkscloud";
|
||||
|
||||
if (useServer) {
|
||||
return kvServerProvider.saveData(key, data);
|
||||
} else {
|
||||
return kvLocalProvider.saveData(key, data);
|
||||
}
|
||||
|
||||
// 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 = {}) => {
|
||||
if (!useServerProvider()) {
|
||||
const provider = getSetting("server.provider");
|
||||
const useServer =
|
||||
provider === "kv-server" || provider === "classworkscloud";
|
||||
|
||||
if (useServer) {
|
||||
return kvServerProvider.loadKeys(options);
|
||||
} else {
|
||||
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 = {}) {
|
||||
const {
|
||||
migrateFromLocal = true,
|
||||
@ -146,14 +176,14 @@ export default {
|
||||
try {
|
||||
const provider = getSetting("server.provider");
|
||||
let serverUrl;
|
||||
|
||||
|
||||
// Use effective server URL for classworkscloud provider
|
||||
if (provider === "classworkscloud") {
|
||||
serverUrl = getEffectiveServerUrl();
|
||||
} else {
|
||||
serverUrl = getSetting("server.domain");
|
||||
}
|
||||
|
||||
|
||||
let siteKey = getSetting("server.siteKey");
|
||||
const machineId = getSetting("device.uuid");
|
||||
let configured = false;
|
||||
|
||||
@ -3,7 +3,7 @@ import {formatResponse, formatError} from "../dataProvider";
|
||||
|
||||
// Database initialization for local storage
|
||||
const DB_NAME = "ClassworksDB";
|
||||
const DB_VERSION = 3;
|
||||
const DB_VERSION = 2;
|
||||
|
||||
const initDB = async () => {
|
||||
return openDB(DB_NAME, DB_VERSION, {
|
||||
@ -17,11 +17,6 @@ const initDB = async () => {
|
||||
if (!db.objectStoreNames.contains("system")) {
|
||||
db.createObjectStore("system");
|
||||
}
|
||||
|
||||
// Add a sync queue store for offline write buffering
|
||||
if (!db.objectStoreNames.contains("syncQueue")) {
|
||||
db.createObjectStore("syncQueue");
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -117,42 +112,4 @@ export const kvLocalProvider = {
|
||||
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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@ -209,7 +209,7 @@ export default defineConfig({
|
||||
// UI 框架
|
||||
'vendor-vuetify': ['vuetify'],
|
||||
// 监控(异步加载,独立 chunk)
|
||||
// 'vendor-sentry': ['@sentry/vue'],
|
||||
'vendor-sentry': ['@sentry/vue'],
|
||||
// 实时通信
|
||||
'vendor-socket': ['socket.io-client'],
|
||||
// 通用工具库
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user