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 files (using pnpm)
|
||||||
package-lock.json
|
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",
|
"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",
|
||||||
|
|||||||
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>
|
||||||
|
<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>
|
||||||
|
|||||||
@ -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
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