1
0
mirror of https://github.com/ZeroCatDev/Classworks.git synced 2026-08-12 09:20:31 +00:00

feat.UAF统一作业展示交换格式导出支持

This commit is contained in:
lincube 2026-07-11 03:04:21 +09:00
parent b63f453aaf
commit d4024ec14a
12 changed files with 57144 additions and 2 deletions

1
.gitignore vendored
View File

@ -179,4 +179,3 @@ typed-router.d.ts
# Package lock files (using pnpm) # Package lock files (using pnpm)
package-lock.json package-lock.json

72
AGENTS.md Normal file
View File

@ -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

View File

@ -10,7 +10,9 @@
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint . --fix", "lint": "eslint . --fix",
"pwa:validate": "node scripts/validate-pwa-build.js", "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": { "dependencies": {
"@fingerprintjs/fingerprintjs": "^5.0.1", "@fingerprintjs/fingerprintjs": "^5.0.1",

Binary file not shown.

BIN
public/uaf/hb-subset.wasm Normal file

Binary file not shown.

View File

@ -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.");

View File

@ -0,0 +1,22 @@
import assert from "node:assert/strict";
import { createUafDocument, hasExportableHomework, normalizeUafDate, UafExportValidationError } from "../src/utils/uafExport.js";
const items = [
{ type: "exam", name: "考试安排", content: "ignored" },
{ type: "homework", name: "数学", content: "完成第 1、2 题" },
{ type: "time", name: "时间" },
{ type: "custom", name: "班级任务", content: "整理讲台" },
];
assert.equal(normalizeUafDate("20260711"), "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/,
);
console.log("Classworks UAF export mapping tests passed.");

View File

@ -83,6 +83,18 @@
> >
添加测试卡片 添加测试卡片
</v-btn> </v-btn>
<v-btn
:disabled="uafExportDisabled"
:loading="uafExportLoading"
class="ml-2"
color="indigo"
prepend-icon="mdi-file-pdf-box"
size="large"
rounded="xl"
@click="$emit('export-uaf')"
>
导出 UAF
</v-btn>
</div> </div>
<v-card <v-card
@ -127,6 +139,8 @@ export default {
isFullscreen: Boolean, isFullscreen: Boolean,
showAntiScreenBurnCard: Boolean, showAntiScreenBurnCard: Boolean,
showTestCardButton: Boolean, showTestCardButton: Boolean,
uafExportDisabled: Boolean,
uafExportLoading: Boolean,
}, },
emits: [ emits: [
"upload", "upload",
@ -134,6 +148,8 @@ export default {
"open-random-picker", "open-random-picker",
"toggle-fullscreen", "toggle-fullscreen",
"add-test-card", "add-test-card",
"add-exam-card",
"export-uaf",
], ],
}; };
</script> </script>

View File

@ -200,12 +200,15 @@
:is-fullscreen="state.isFullscreen" :is-fullscreen="state.isFullscreen"
:show-anti-screen-burn-card="showAntiScreenBurnCard" :show-anti-screen-burn-card="showAntiScreenBurnCard"
:show-test-card-button="showTestCardButton" :show-test-card-button="showTestCardButton"
:uaf-export-disabled="!hasExportableHomework"
:uaf-export-loading="loading.exportUaf"
@upload="manualUpload" @upload="manualUpload"
@show-sync-message="showSyncMessage" @show-sync-message="showSyncMessage"
@open-random-picker="openRandomPicker" @open-random-picker="openRandomPicker"
@toggle-fullscreen="toggleFullscreen" @toggle-fullscreen="toggleFullscreen"
@add-test-card="addTestCard" @add-test-card="addTestCard"
@add-exam-card="showAddExamDialog = true" @add-exam-card="showAddExamDialog = true"
@export-uaf="exportUaf"
/> />
<pwa-install-card /> <pwa-install-card />
@ -561,6 +564,11 @@ import HomeActions from "@/components/home/HomeActions.vue";
import FloatingICP from "@/components/FloatingICP.vue"; import FloatingICP from "@/components/FloatingICP.vue";
import HitokotoCard from "@/components/HitokotoCard.vue"; import HitokotoCard from "@/components/HitokotoCard.vue";
import HomeSkeleton from "@/components/common/HomeSkeleton.vue"; import HomeSkeleton from "@/components/common/HomeSkeleton.vue";
import {
downloadUafDocument,
hasExportableHomework as containsExportableHomework,
UafExportValidationError,
} from "@/utils/uafExport.js";
// ===== / ===== // ===== / =====
const MessageLog = defineAsyncComponent({ const MessageLog = defineAsyncComponent({
@ -732,6 +740,7 @@ export default {
upload: false, upload: false,
students: false, students: false,
copyToToday: false, copyToToday: false,
exportUaf: false,
}, },
dataReady: false, dataReady: false,
debouncedUpload: null, debouncedUpload: null,
@ -1085,6 +1094,9 @@ export default {
.sort((a, b) => a.order - b.order) .sort((a, b) => a.order - b.order)
.map((subject) => subject.name); .map((subject) => subject.name);
}, },
hasExportableHomework() {
return containsExportableHomework(this.sortedItems);
},
}, },
watch: { watch: {
@ -2052,6 +2064,24 @@ export default {
this.state.synced = false; this.state.synced = false;
}, },
async exportUaf() {
if (this.loading.exportUaf) return;
this.loading.exportUaf = true;
try {
const filename = await downloadUafDocument(this.sortedItems, this.state.dateString);
this.$message.success("导出成功", filename);
} catch (error) {
console.error("UAF export failed:", error);
if (error instanceof UafExportValidationError) {
this.$message.error("无法导出 UAF", error.issues.join("\n"));
} else {
this.$message.error("导出失败", error?.message || "无法生成 UAF PDF");
}
} finally {
this.loading.exportUaf = false;
}
},
showConfirmDialog() { showConfirmDialog() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.confirmDialog = { this.confirmDialog = {

93
src/utils/uafExport.js Normal file
View File

@ -0,0 +1,93 @@
const LIMITS = {
subjectMax: 200,
contentMax: 2000,
tagMax: 50,
tagCountMax: 20,
};
export class UafExportValidationError extends Error {
constructor(issues) {
super(issues.join(""));
this.name = "UafExportValidationError";
this.issues = issues;
}
}
export function normalizeUafDate(value) {
if (/^\d{8}$/.test(value)) {
return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`;
}
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
throw new UafExportValidationError([`日期 ${value || "(空)"} 不是有效的 YYYYMMDD 或 YYYY-MM-DD 格式`]);
}
export function createUafDocument(items, dateValue) {
const date = normalizeUafDate(dateValue);
const assignments = items
.filter((item) => item && (item.type === "homework" || item.type === "custom"))
.filter((item) => typeof item.content === "string" && item.content.trim().length > 0)
.map((item) => ({
subject: String(item.name || "").trim(),
date,
content: item.content,
tags: [],
}));
if (assignments.length === 0) {
throw new UafExportValidationError(["当前日期没有可导出的作业"]);
}
const issues = [];
assignments.forEach((assignment, index) => {
const label = assignment.subject || `${index + 1} 张卡片`;
if (!assignment.subject) issues.push(`${label}:科目名称不能为空`);
if (assignment.subject.length > LIMITS.subjectMax) {
issues.push(`${label}:科目名称超过 ${LIMITS.subjectMax} 个字符`);
}
if (!assignment.content) issues.push(`${label}:正文不能为空`);
if (assignment.content.length > LIMITS.contentMax) {
issues.push(`${label}:正文超过 ${LIMITS.contentMax} 个字符`);
}
if (assignment.tags.length > LIMITS.tagCountMax) {
issues.push(`${label}:标签超过 ${LIMITS.tagCountMax}`);
}
for (const tag of assignment.tags) {
if (!tag || tag.length > LIMITS.tagMax || tag.includes(";")) {
issues.push(`${label}:包含无效标签`);
break;
}
}
});
if (issues.length > 0) throw new UafExportValidationError(issues);
return assignments;
}
export function hasExportableHomework(items) {
return items.some(
(item) =>
item &&
(item.type === "homework" || item.type === "custom") &&
typeof item.content === "string" &&
item.content.trim().length > 0,
);
}
export async function downloadUafDocument(items, dateValue) {
const assignments = createUafDocument(items, dateValue);
const { createUafPdf } = await import("../vendor/uaf/browser.js");
const base = import.meta.env.BASE_URL || "/";
const fontUrl = new URL(`${base}uaf/NotoSansSC-Regular.otf`, window.location.origin);
const wasmUrl = new URL(`${base}uaf/hb-subset.wasm`, window.location.origin);
const pdfBytes = await createUafPdf(assignments, { fontUrl, wasmUrl });
const blob = new window.Blob([pdfBytes], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
const date = normalizeUafDate(dateValue);
link.href = url;
link.download = `Classworks-作业-${date}.uaf.pdf`;
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
return link.download;
}

56865
src/vendor/uaf/browser.js vendored Normal file

File diff suppressed because one or more lines are too long

7
src/vendor/uaf/manifest.json vendored Normal file
View File

@ -0,0 +1,7 @@
{
"uafVersion": "1.0",
"source": "../UnifiedAssignmentFormat/implementations/typescript/packages/pdf",
"bundleSha256": "6086d88815b14b0daeb5c27782f0eeb5b4a9dd0fdc51ff5371166bf79dd6e2b2",
"fontSha256": "a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da",
"wasmSha256": "1bf32603c1dfe17e1b9d54acaec6adfd0fc5c517e088648ba7e44a24213ae93e"
}