mirror of
https://github.com/ZeroCatDev/Classworks.git
synced 2026-08-12 09:20:31 +00:00
feat.UAF统一作业展示交换格式导出支持
This commit is contained in:
parent
b63f453aaf
commit
d4024ec14a
1
.gitignore
vendored
1
.gitignore
vendored
@ -179,4 +179,3 @@ typed-router.d.ts
|
||||
|
||||
# Package lock files (using pnpm)
|
||||
package-lock.json
|
||||
|
||||
|
||||
72
AGENTS.md
Normal file
72
AGENTS.md
Normal 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
|
||||
@ -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",
|
||||
|
||||
BIN
public/uaf/NotoSansSC-Regular.otf
Normal file
BIN
public/uaf/NotoSansSC-Regular.otf
Normal file
Binary file not shown.
BIN
public/uaf/hb-subset.wasm
Normal file
BIN
public/uaf/hb-subset.wasm
Normal file
Binary file not shown.
36
scripts/sync-uaf-browser.js
Normal file
36
scripts/sync-uaf-browser.js
Normal 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.");
|
||||
22
scripts/test-uaf-export.js
Normal file
22
scripts/test-uaf-export.js
Normal 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.");
|
||||
@ -83,6 +83,18 @@
|
||||
>
|
||||
添加测试卡片
|
||||
</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>
|
||||
|
||||
<v-card
|
||||
@ -127,6 +139,8 @@ export default {
|
||||
isFullscreen: Boolean,
|
||||
showAntiScreenBurnCard: Boolean,
|
||||
showTestCardButton: Boolean,
|
||||
uafExportDisabled: Boolean,
|
||||
uafExportLoading: Boolean,
|
||||
},
|
||||
emits: [
|
||||
"upload",
|
||||
@ -134,6 +148,8 @@ export default {
|
||||
"open-random-picker",
|
||||
"toggle-fullscreen",
|
||||
"add-test-card",
|
||||
"add-exam-card",
|
||||
"export-uaf",
|
||||
],
|
||||
};
|
||||
</script>
|
||||
|
||||
@ -200,12 +200,15 @@
|
||||
:is-fullscreen="state.isFullscreen"
|
||||
:show-anti-screen-burn-card="showAntiScreenBurnCard"
|
||||
:show-test-card-button="showTestCardButton"
|
||||
:uaf-export-disabled="!hasExportableHomework"
|
||||
:uaf-export-loading="loading.exportUaf"
|
||||
@upload="manualUpload"
|
||||
@show-sync-message="showSyncMessage"
|
||||
@open-random-picker="openRandomPicker"
|
||||
@toggle-fullscreen="toggleFullscreen"
|
||||
@add-test-card="addTestCard"
|
||||
@add-exam-card="showAddExamDialog = true"
|
||||
@export-uaf="exportUaf"
|
||||
/>
|
||||
|
||||
<pwa-install-card />
|
||||
@ -561,6 +564,11 @@ import HomeActions from "@/components/home/HomeActions.vue";
|
||||
import FloatingICP from "@/components/FloatingICP.vue";
|
||||
import HitokotoCard from "@/components/HitokotoCard.vue";
|
||||
import HomeSkeleton from "@/components/common/HomeSkeleton.vue";
|
||||
import {
|
||||
downloadUafDocument,
|
||||
hasExportableHomework as containsExportableHomework,
|
||||
UafExportValidationError,
|
||||
} from "@/utils/uafExport.js";
|
||||
|
||||
// ===== 非首屏 / 条件渲染组件(异步懒加载)=====
|
||||
const MessageLog = defineAsyncComponent({
|
||||
@ -732,6 +740,7 @@ export default {
|
||||
upload: false,
|
||||
students: false,
|
||||
copyToToday: false,
|
||||
exportUaf: false,
|
||||
},
|
||||
dataReady: false,
|
||||
debouncedUpload: null,
|
||||
@ -1085,6 +1094,9 @@ export default {
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((subject) => subject.name);
|
||||
},
|
||||
hasExportableHomework() {
|
||||
return containsExportableHomework(this.sortedItems);
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
@ -2052,6 +2064,24 @@ export default {
|
||||
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() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.confirmDialog = {
|
||||
|
||||
93
src/utils/uafExport.js
Normal file
93
src/utils/uafExport.js
Normal 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
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
7
src/vendor/uaf/manifest.json
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"uafVersion": "1.0",
|
||||
"source": "../UnifiedAssignmentFormat/implementations/typescript/packages/pdf",
|
||||
"bundleSha256": "6086d88815b14b0daeb5c27782f0eeb5b4a9dd0fdc51ff5371166bf79dd6e2b2",
|
||||
"fontSha256": "a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da",
|
||||
"wasmSha256": "1bf32603c1dfe17e1b9d54acaec6adfd0fc5c517e088648ba7e44a24213ae93e"
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user