Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -179,4 +179,4 @@ typed-router.d.ts

# Package lock files (using pnpm)
package-lock.json

/.trae
72 changes: 72 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# AGENTS.md

This file provides guidance to Codex (Codex.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
Empty file.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
"preview": "vite preview",
"lint": "eslint . --fix",
"pwa:validate": "node scripts/validate-pwa-build.js",
"prebuild": "node scripts/generate-sound-list.js"
"prebuild": "node scripts/generate-sound-list.js",
"sync:uaf": "node scripts/sync-uaf-browser.js",
"test:uaf": "node scripts/test-uaf-export.js"
},
"dependencies": {
"@fingerprintjs/fingerprintjs": "^5.0.1",
Expand Down
Binary file added public/uaf/NotoSansSC-Regular.otf
Binary file not shown.
Binary file added public/uaf/hb-subset.wasm
Binary file not shown.
36 changes: 36 additions & 0 deletions scripts/sync-uaf-browser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const uafRoot = resolve(root, "..", "UnifiedAssignmentFormat", "implementations", "typescript", "packages", "pdf");
const sourceBundle = resolve(uafRoot, "browser-dist", "browser.js");
const sourceFont = resolve(uafRoot, "assets", "NotoSansSC-Regular.otf");
const sourceWasm = resolve(uafRoot, "assets", "hb-subset.wasm");
const bundleTarget = resolve(root, "src", "vendor", "uaf", "browser.js");
const fontTarget = resolve(root, "public", "uaf", "NotoSansSC-Regular.otf");
const wasmTarget = resolve(root, "public", "uaf", "hb-subset.wasm");
const manifestTarget = resolve(root, "src", "vendor", "uaf", "manifest.json");

await mkdir(dirname(bundleTarget), { recursive: true });
await mkdir(dirname(fontTarget), { recursive: true });
await copyFile(sourceBundle, bundleTarget);
await copyFile(sourceFont, fontTarget);
await copyFile(sourceWasm, wasmTarget);

const [bundle, font, wasm] = await Promise.all([readFile(bundleTarget), readFile(fontTarget), readFile(wasmTarget)]);
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
await writeFile(
manifestTarget,
`${JSON.stringify({
uafVersion: "1.0",
source: "../UnifiedAssignmentFormat/implementations/typescript/packages/pdf",
bundleSha256: sha256(bundle),
fontSha256: sha256(font),
wasmSha256: sha256(wasm),
}, null, 2)}\n`,
"utf8",
);

console.log("Synced the UAF browser bundle and font into Classworks.");
93 changes: 93 additions & 0 deletions scripts/test-uaf-export.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import assert from "node:assert/strict";
import {
createExportPreview,
createImportPlan,
createUafDocument,
executeImportPlan,
findImportPlanIssues,
hasExportableHomework,
itemsFromBoardData,
normalizeUafDate,
UafExportValidationError,
} from "../src/utils/uafExport.js";

const clone = (value) => JSON.parse(JSON.stringify(value));

const items = [
{ type: "exam", name: "考试安排", content: "ignored" },
{ type: "homework", name: "数学", content: "完成第 1、2 题", tags: ["必做"] },
{ type: "time", name: "时间" },
{ type: "custom", name: "班级任务", content: "整理讲台" },
];

assert.equal(normalizeUafDate("20260711"), "2026-07-11");
assert.equal(normalizeUafDate("2026-07-11T08:30:00+08:00"), "2026-07-11");
assert.equal(hasExportableHomework(items), true);
assert.deepEqual(createUafDocument(items, "20260711"), [
{ subject: "数学", date: "2026-07-11", content: "完成第 1、2 题", tags: ["必做"] },
{ subject: "班级任务", date: "2026-07-11", content: "整理讲台", tags: [] },
]);
assert.throws(() => createUafDocument([], "20260711"), UafExportValidationError);
assert.throws(
() => createUafDocument([{ type: "homework", name: "数学", content: "x".repeat(2001) }], "20260711"),
/2000/,
);

const board = {
homework: {
数学: { content: "旧数学作业", tags: ["旧标签"] },
"custom-existing": { type: "custom", name: "班级任务", content: "旧任务" },
"exam-1": { type: "exam", examId: "1", content: "" },
},
attendance: { absent: ["张三"], late: [], exclude: [] },
};
assert.deepEqual(itemsFromBoardData(board, [{ name: "数学", order: 0 }]), [
{ key: "数学", name: "数学", type: "homework", content: "旧数学作业", tags: ["旧标签"], order: 0 },
{
key: "custom-existing",
name: "班级任务",
type: "custom",
content: "旧任务",
tags: [],
order: 9999,
},
]);
assert.equal(createExportPreview(items, "20260711")[0].selected, true);

const importedDocument = [
{ subject: "数学", date: "2026-07-11", content: "新数学作业", tags: ["导入"] },
{ subject: "班级任务", date: "2026-07-11", content: "新任务", tags: [] },
{ subject: "物理", date: "2026-07-12", content: "新日期作业", tags: ["实验"] },
];
const boardsByDate = {
20260711: board,
20260712: { homework: {}, attendance: { absent: [], late: ["李四"], exclude: [] } },
};
const plan = await createImportPlan(
importedDocument,
[{ name: "数学", order: 0 }],
async (date) => clone(boardsByDate[date]),
);
assert.deepEqual(plan.rows.map((row) => [row.targetType, row.conflict, row.action]), [
["homework", true, "keep"],
["custom", true, "keep"],
["custom", false, "import"],
]);
plan.rows[0].action = "overwrite";
const saved = new Map();
const result = await executeImportPlan(plan, async (date, value) => saved.set(date, clone(value)));
assert.deepEqual(result, { imported: 2, skipped: 1, savedDates: ["20260711", "20260712"], failedDates: [] });
assert.deepEqual(saved.get("20260711").homework.数学, { content: "新数学作业", tags: ["导入"] });
assert.deepEqual(saved.get("20260711").attendance.absent, ["张三"]);
assert.equal(saved.get("20260711").homework["exam-1"].type, "exam");
assert.equal(Object.values(saved.get("20260712").homework)[0].name, "物理");

const duplicatePlan = await createImportPlan(
[importedDocument[0], { ...importedDocument[0], content: "重复作业" }],
[{ name: "数学", order: 0 }],
async () => clone(board),
);
duplicatePlan.rows.forEach((row) => { row.action = "overwrite"; });
assert.equal(findImportPlanIssues(duplicatePlan.rows).length, 1);

console.log("Classworks UAF import/export tests passed.");
80 changes: 54 additions & 26 deletions src/components/home/HomeActions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,46 @@
@click="$emit('show-sync-message')"
>
同步完成
</v-btn><v-menu
v-if="showUafTransferButton"
location="bottom end"
>
<template #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
:disabled="uafTransferLoading"
:loading="uafTransferLoading"
class="ml-2"
color="indigo"

size="large"
rounded="xl"
><v-icon icon="mdi-swap-vertical-bold"></v-icon></v-btn>
</template>
<v-list density="comfortable">
<v-list-item
prepend-icon="mdi-file-export-outline"
title="导出 UAF"
@click="$emit('open-uaf-export')"
/>
<v-list-item
prepend-icon="mdi-file-import-outline"
title="导入 UAF"
@click="$emit('open-uaf-import')"
/>
</v-list>
</v-menu> <v-btn
v-if="showFullscreenButton"
:color="isFullscreen ? 'blue-grey' : 'blue'"
:prepend-icon="
isFullscreen ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'
"
rounded="xl"
class="ml-2"
size="large"
@click="$emit('toggle-fullscreen')"
>
{{ isFullscreen ? "退出全屏" : "全屏" }}
</v-btn>
<v-btn
v-if="showRandomPickerButton"
Expand All @@ -31,26 +71,19 @@
>
随机点名
</v-btn>
<v-btn-group
v-if="showExamScheduleButton"
class="ml-2"
color="green"
variant="elevated"
divided
>

<v-btn
v-if="showExamScheduleButton"
prepend-icon="mdi-calendar-check"
size="large"
@click="$router.push('/examschedule')"
@click="$emit('add-exam-card')"
class="ml-2"
color="green"
>
考试看板
</v-btn>
<v-btn
icon="mdi-plus"
size="large"
@click="$emit('add-exam-card')"
/>
</v-btn-group>


<v-btn
v-if="showListCardButton"
class="ml-2"
Expand All @@ -61,18 +94,7 @@
>
列表
</v-btn>
<v-btn
v-if="showFullscreenButton"
:color="isFullscreen ? 'blue-grey' : 'blue'"
:prepend-icon="
isFullscreen ? 'mdi-fullscreen-exit' : 'mdi-fullscreen'
"
class="ml-2"
size="large"
@click="$emit('toggle-fullscreen')"
>
{{ isFullscreen ? "退出全屏" : "全屏显示" }}
</v-btn>

<v-btn
v-if="showTestCardButton"
class="ml-2"
Expand All @@ -83,6 +105,7 @@
>
添加测试卡片
</v-btn>

</div>

<v-card
Expand Down Expand Up @@ -127,13 +150,18 @@ export default {
isFullscreen: Boolean,
showAntiScreenBurnCard: Boolean,
showTestCardButton: Boolean,
showUafTransferButton: Boolean,
uafTransferLoading: Boolean,
},
emits: [
"upload",
"show-sync-message",
"open-random-picker",
"toggle-fullscreen",
"add-test-card",
"add-exam-card",
"open-uaf-export",
"open-uaf-import",
],
};
</script>
Loading