mirror of
https://github.com/ZeroCatDev/Classworks.git
synced 2026-08-12 09:20:31 +00:00
Compare commits
4 Commits
b63f453aaf
...
5bfb5c9abb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bfb5c9abb | ||
|
|
8238d94f52 | ||
|
|
08fcc5cb4f | ||
|
|
d4024ec14a |
2
.gitignore
vendored
2
.gitignore
vendored
@ -179,4 +179,4 @@ typed-router.d.ts
|
|||||||
|
|
||||||
# Package lock files (using pnpm)
|
# Package lock files (using pnpm)
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
/.trae
|
||||||
|
|||||||
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
|
||||||
0
docs/auto_commit_md/20260711_d4024ec.md
Normal file
0
docs/auto_commit_md/20260711_d4024ec.md
Normal 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",
|
||||||
|
|||||||
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.");
|
||||||
93
scripts/test-uaf-export.js
Normal file
93
scripts/test-uaf-export.js
Normal file
@ -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.");
|
||||||
@ -19,6 +19,46 @@
|
|||||||
@click="$emit('show-sync-message')"
|
@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-btn
|
<v-btn
|
||||||
v-if="showRandomPickerButton"
|
v-if="showRandomPickerButton"
|
||||||
@ -31,26 +71,19 @@
|
|||||||
>
|
>
|
||||||
随机点名
|
随机点名
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn-group
|
|
||||||
v-if="showExamScheduleButton"
|
|
||||||
class="ml-2"
|
|
||||||
color="green"
|
|
||||||
variant="elevated"
|
|
||||||
divided
|
|
||||||
>
|
|
||||||
<v-btn
|
<v-btn
|
||||||
|
v-if="showExamScheduleButton"
|
||||||
prepend-icon="mdi-calendar-check"
|
prepend-icon="mdi-calendar-check"
|
||||||
size="large"
|
size="large"
|
||||||
@click="$router.push('/examschedule')"
|
@click="$emit('add-exam-card')"
|
||||||
|
class="ml-2"
|
||||||
|
color="green"
|
||||||
>
|
>
|
||||||
考试看板
|
考试看板
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn
|
|
||||||
icon="mdi-plus"
|
|
||||||
size="large"
|
|
||||||
@click="$emit('add-exam-card')"
|
|
||||||
/>
|
|
||||||
</v-btn-group>
|
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="showListCardButton"
|
v-if="showListCardButton"
|
||||||
class="ml-2"
|
class="ml-2"
|
||||||
@ -61,18 +94,7 @@
|
|||||||
>
|
>
|
||||||
列表
|
列表
|
||||||
</v-btn>
|
</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-btn
|
||||||
v-if="showTestCardButton"
|
v-if="showTestCardButton"
|
||||||
class="ml-2"
|
class="ml-2"
|
||||||
@ -83,6 +105,7 @@
|
|||||||
>
|
>
|
||||||
添加测试卡片
|
添加测试卡片
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<v-card
|
<v-card
|
||||||
@ -127,6 +150,8 @@ export default {
|
|||||||
isFullscreen: Boolean,
|
isFullscreen: Boolean,
|
||||||
showAntiScreenBurnCard: Boolean,
|
showAntiScreenBurnCard: Boolean,
|
||||||
showTestCardButton: Boolean,
|
showTestCardButton: Boolean,
|
||||||
|
showUafTransferButton: Boolean,
|
||||||
|
uafTransferLoading: Boolean,
|
||||||
},
|
},
|
||||||
emits: [
|
emits: [
|
||||||
"upload",
|
"upload",
|
||||||
@ -134,6 +159,9 @@ export default {
|
|||||||
"open-random-picker",
|
"open-random-picker",
|
||||||
"toggle-fullscreen",
|
"toggle-fullscreen",
|
||||||
"add-test-card",
|
"add-test-card",
|
||||||
|
"add-exam-card",
|
||||||
|
"open-uaf-export",
|
||||||
|
"open-uaf-import",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
495
src/components/home/UafTransferDialog.vue
Normal file
495
src/components/home/UafTransferDialog.vue
Normal file
@ -0,0 +1,495 @@
|
|||||||
|
<template>
|
||||||
|
<v-dialog
|
||||||
|
v-model="dialog"
|
||||||
|
:fullscreen="mobile"
|
||||||
|
max-width="920"
|
||||||
|
scrollable
|
||||||
|
>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="d-flex align-center">
|
||||||
|
<v-icon
|
||||||
|
:icon="mode === 'export' ? 'mdi-file-export-outline' : 'mdi-file-import-outline'"
|
||||||
|
class="mr-2"
|
||||||
|
/>
|
||||||
|
{{ mode === "export" ? "导出 UAF" : "导入 UAF" }}
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
icon="mdi-close"
|
||||||
|
variant="text"
|
||||||
|
@click="dialog = false"
|
||||||
|
/>
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<v-card-text class="transfer-content">
|
||||||
|
<template v-if="mode === 'export'">
|
||||||
|
<v-text-field
|
||||||
|
v-model="exportDate"
|
||||||
|
class="mb-3"
|
||||||
|
label="导出日期"
|
||||||
|
prepend-inner-icon="mdi-calendar"
|
||||||
|
type="date"
|
||||||
|
hide-details
|
||||||
|
:disabled="busy"
|
||||||
|
@update:model-value="loadExportPreview"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-progress-linear
|
||||||
|
v-if="loadingPreview"
|
||||||
|
class="mb-3"
|
||||||
|
indeterminate
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-alert
|
||||||
|
v-if="exportError"
|
||||||
|
class="mb-3"
|
||||||
|
type="warning"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
{{ exportError }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="previewRows.length"
|
||||||
|
class="d-flex align-center mb-2"
|
||||||
|
>
|
||||||
|
<v-checkbox-btn
|
||||||
|
:model-value="allValidSelected"
|
||||||
|
:indeterminate="someValidSelected && !allValidSelected"
|
||||||
|
@click="toggleAll"
|
||||||
|
/>
|
||||||
|
<span class="text-body-2">选择全部有效作业</span>
|
||||||
|
<v-spacer />
|
||||||
|
<span class="text-caption text-medium-emphasis">
|
||||||
|
已选择 {{ selectedAssignments.length }} / {{ previewRows.length }} 项
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-list
|
||||||
|
v-if="previewRows.length"
|
||||||
|
border
|
||||||
|
lines="three"
|
||||||
|
>
|
||||||
|
<v-list-item
|
||||||
|
v-for="row in previewRows"
|
||||||
|
:key="row.id"
|
||||||
|
>
|
||||||
|
<template #prepend>
|
||||||
|
<v-checkbox-btn
|
||||||
|
v-model="row.selected"
|
||||||
|
:disabled="row.issues.length > 0"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<v-list-item-title>{{ row.assignment.subject }}</v-list-item-title>
|
||||||
|
<v-list-item-subtitle class="content-preview">
|
||||||
|
{{ row.assignment.content }}
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
<div class="d-flex flex-wrap align-center mt-1 ga-1">
|
||||||
|
<v-chip
|
||||||
|
size="x-small"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
{{ row.assignment.date }}
|
||||||
|
</v-chip>
|
||||||
|
<v-chip
|
||||||
|
v-for="tag in row.assignment.tags"
|
||||||
|
:key="tag"
|
||||||
|
size="x-small"
|
||||||
|
color="primary"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</v-chip>
|
||||||
|
<span
|
||||||
|
v-if="row.issues.length"
|
||||||
|
class="text-caption text-error"
|
||||||
|
>
|
||||||
|
{{ row.issues.join(";") }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
|
||||||
|
<v-empty-state
|
||||||
|
v-else-if="!loadingPreview && !exportError"
|
||||||
|
icon="mdi-book-open-blank-variant-outline"
|
||||||
|
text="该日期没有可导出的作业"
|
||||||
|
title="暂无作业"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<v-file-input
|
||||||
|
v-model="importFile"
|
||||||
|
accept="application/pdf,.pdf"
|
||||||
|
clearable
|
||||||
|
label="选择 UAF PDF"
|
||||||
|
prepend-icon="mdi-file-pdf-box"
|
||||||
|
:disabled="busy"
|
||||||
|
@update:model-value="prepareImport"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-progress-linear
|
||||||
|
v-if="loadingImport"
|
||||||
|
class="mb-3"
|
||||||
|
indeterminate
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-alert
|
||||||
|
v-if="importError"
|
||||||
|
class="mb-3"
|
||||||
|
type="error"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
{{ importError }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<v-alert
|
||||||
|
v-if="planIssues.length"
|
||||||
|
class="mb-3"
|
||||||
|
type="warning"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
{{ planIssues.join(";") }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<template v-if="importPlan">
|
||||||
|
<div class="text-body-2 mb-3">
|
||||||
|
共 {{ importPlan.rows.length }} 项作业,分布在 {{ groupedRows.length }} 个日期。
|
||||||
|
冲突项默认保留现有内容。
|
||||||
|
</div>
|
||||||
|
<v-expansion-panels
|
||||||
|
multiple
|
||||||
|
variant="accordion"
|
||||||
|
>
|
||||||
|
<v-expansion-panel
|
||||||
|
v-for="group in groupedRows"
|
||||||
|
:key="group.date"
|
||||||
|
>
|
||||||
|
<v-expansion-panel-title>
|
||||||
|
{{ displayDate(group.date) }}
|
||||||
|
<v-chip
|
||||||
|
class="ml-2"
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
{{ group.rows.length }} 项
|
||||||
|
</v-chip>
|
||||||
|
</v-expansion-panel-title>
|
||||||
|
<v-expansion-panel-text>
|
||||||
|
<v-list lines="three">
|
||||||
|
<v-list-item
|
||||||
|
v-for="row in group.rows"
|
||||||
|
:key="row.id"
|
||||||
|
>
|
||||||
|
<v-list-item-title class="d-flex align-center">
|
||||||
|
{{ row.assignment.subject }}
|
||||||
|
<v-chip
|
||||||
|
v-if="row.conflict"
|
||||||
|
class="ml-2"
|
||||||
|
color="warning"
|
||||||
|
size="x-small"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
已有同名卡片
|
||||||
|
</v-chip>
|
||||||
|
</v-list-item-title>
|
||||||
|
<v-list-item-subtitle class="content-preview">
|
||||||
|
{{ row.assignment.content }}
|
||||||
|
</v-list-item-subtitle>
|
||||||
|
<template #append>
|
||||||
|
<v-select
|
||||||
|
v-model="row.action"
|
||||||
|
class="action-select"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
:items="actionOptions(row)"
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-expansion-panel-text>
|
||||||
|
</v-expansion-panel>
|
||||||
|
</v-expansion-panels>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
<v-card-actions>
|
||||||
|
<span class="text-caption text-medium-emphasis ml-2">
|
||||||
|
{{ footerText }}
|
||||||
|
</span>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
:disabled="busy"
|
||||||
|
@click="dialog = false"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
:disabled="primaryDisabled"
|
||||||
|
:loading="busy"
|
||||||
|
:prepend-icon="mode === 'export' ? 'mdi-download' : 'mdi-database-import-outline'"
|
||||||
|
@click="mode === 'export' ? exportSelected() : importSelected()"
|
||||||
|
>
|
||||||
|
{{ mode === "export" ? "导出所选作业" : "确认导入" }}
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import { useDisplay } from "vuetify";
|
||||||
|
import dataProvider from "@/utils/dataProvider";
|
||||||
|
import {
|
||||||
|
createExportPreview,
|
||||||
|
createImportPlan,
|
||||||
|
downloadUafAssignments,
|
||||||
|
executeImportPlan,
|
||||||
|
findImportPlanIssues,
|
||||||
|
itemsFromBoardData,
|
||||||
|
parseUafPdf,
|
||||||
|
UafExportValidationError,
|
||||||
|
} from "@/utils/uafExport";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: Boolean,
|
||||||
|
mode: {
|
||||||
|
type: String,
|
||||||
|
default: "export",
|
||||||
|
},
|
||||||
|
currentDate: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
currentItems: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
currentBoardData: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
subjects: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:modelValue", "success", "error", "imported"]);
|
||||||
|
const { mobile } = useDisplay();
|
||||||
|
const dialog = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (value) => emit("update:modelValue", value),
|
||||||
|
});
|
||||||
|
const exportDate = ref("");
|
||||||
|
const previewRows = ref([]);
|
||||||
|
const exportError = ref("");
|
||||||
|
const loadingPreview = ref(false);
|
||||||
|
const exporting = ref(false);
|
||||||
|
const importFile = ref(null);
|
||||||
|
const importPlan = ref(null);
|
||||||
|
const importError = ref("");
|
||||||
|
const loadingImport = ref(false);
|
||||||
|
const importing = ref(false);
|
||||||
|
const busy = computed(() => exporting.value || importing.value || loadingImport.value);
|
||||||
|
|
||||||
|
const selectedAssignments = computed(() =>
|
||||||
|
previewRows.value.filter((row) => row.selected).map((row) => row.assignment),
|
||||||
|
);
|
||||||
|
const validRows = computed(() => previewRows.value.filter((row) => row.issues.length === 0));
|
||||||
|
const allValidSelected = computed(
|
||||||
|
() => validRows.value.length > 0 && validRows.value.every((row) => row.selected),
|
||||||
|
);
|
||||||
|
const someValidSelected = computed(() => validRows.value.some((row) => row.selected));
|
||||||
|
const groupedRows = computed(() => {
|
||||||
|
if (!importPlan.value) return [];
|
||||||
|
const groups = new Map();
|
||||||
|
for (const row of importPlan.value.rows) {
|
||||||
|
if (!groups.has(row.date)) groups.set(row.date, []);
|
||||||
|
groups.get(row.date).push(row);
|
||||||
|
}
|
||||||
|
return [...groups].map(([date, rows]) => ({ date, rows }));
|
||||||
|
});
|
||||||
|
const planIssues = computed(() => findImportPlanIssues(importPlan.value?.rows || []));
|
||||||
|
const primaryDisabled = computed(() =>
|
||||||
|
props.mode === "export"
|
||||||
|
? selectedAssignments.value.length === 0 || loadingPreview.value
|
||||||
|
: !importPlan.value || planIssues.value.length > 0 || importing.value,
|
||||||
|
);
|
||||||
|
const footerText = computed(() => {
|
||||||
|
if (props.mode === "export") return `文件日期:${exportDate.value || "未选择"}`;
|
||||||
|
if (!importPlan.value) return "仅支持内嵌 uaf_payload.csv 的 UAF PDF";
|
||||||
|
const active = importPlan.value.rows.filter((row) => row.action !== "keep").length;
|
||||||
|
return `将导入 ${active} 项,保留 ${importPlan.value.rows.length - active} 项`;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(open) => {
|
||||||
|
if (!open) return;
|
||||||
|
if (props.mode === "export") {
|
||||||
|
exportDate.value = displayDate(props.currentDate);
|
||||||
|
loadExportPreview();
|
||||||
|
} else {
|
||||||
|
importFile.value = null;
|
||||||
|
importPlan.value = null;
|
||||||
|
importError.value = "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function cloneBoard(board) {
|
||||||
|
return JSON.parse(JSON.stringify(board || { homework: {}, attendance: {} }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBoard(date) {
|
||||||
|
if (date === props.currentDate) return cloneBoard(props.currentBoardData);
|
||||||
|
const result = await dataProvider.loadData(`classworks-data-${date}`);
|
||||||
|
if (result?.success === false) {
|
||||||
|
if (result.error?.code === "NOT_FOUND") {
|
||||||
|
return { homework: {}, attendance: { absent: [], late: [], exclude: [] } };
|
||||||
|
}
|
||||||
|
throw new Error(result.error?.message || `无法读取 ${date} 的作业`);
|
||||||
|
}
|
||||||
|
return cloneBoard(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadExportPreview() {
|
||||||
|
if (!exportDate.value) return;
|
||||||
|
loadingPreview.value = true;
|
||||||
|
exportError.value = "";
|
||||||
|
try {
|
||||||
|
const date = exportDate.value.replaceAll("-", "");
|
||||||
|
const items = date === props.currentDate
|
||||||
|
? props.currentItems
|
||||||
|
: itemsFromBoardData(await loadBoard(date), props.subjects);
|
||||||
|
previewRows.value = createExportPreview(items, exportDate.value);
|
||||||
|
} catch (error) {
|
||||||
|
previewRows.value = [];
|
||||||
|
exportError.value = error.message || "无法加载该日期的作业";
|
||||||
|
} finally {
|
||||||
|
loadingPreview.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll() {
|
||||||
|
const selected = !allValidSelected.value;
|
||||||
|
for (const row of validRows.value) row.selected = selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportSelected() {
|
||||||
|
exporting.value = true;
|
||||||
|
try {
|
||||||
|
const filename = await downloadUafAssignments(selectedAssignments.value, exportDate.value);
|
||||||
|
emit("success", "导出成功", filename);
|
||||||
|
dialog.value = false;
|
||||||
|
} catch (error) {
|
||||||
|
emitError("导出失败", error);
|
||||||
|
} finally {
|
||||||
|
exporting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareImport(value) {
|
||||||
|
const file = Array.isArray(value) ? value[0] : value;
|
||||||
|
importPlan.value = null;
|
||||||
|
importError.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
loadingImport.value = true;
|
||||||
|
try {
|
||||||
|
const document = await parseUafPdf(file);
|
||||||
|
importPlan.value = await createImportPlan(document, props.subjects, loadBoard);
|
||||||
|
} catch (error) {
|
||||||
|
importError.value = formatError(error);
|
||||||
|
} finally {
|
||||||
|
loadingImport.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionOptions(row) {
|
||||||
|
return row.conflict
|
||||||
|
? [
|
||||||
|
{ title: "保留现有", value: "keep" },
|
||||||
|
{ title: "覆盖现有", value: "overwrite" },
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ title: "导入", value: "import" },
|
||||||
|
{ title: "跳过", value: "keep" },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importSelected() {
|
||||||
|
importing.value = true;
|
||||||
|
try {
|
||||||
|
const result = await executeImportPlan(importPlan.value, async (date, board) => {
|
||||||
|
const response = await dataProvider.saveData(`classworks-data-${date}`, board);
|
||||||
|
if (response?.success === false) throw new Error(response.error?.message || "保存失败");
|
||||||
|
});
|
||||||
|
emit("imported", result);
|
||||||
|
if (result.failedDates.length) {
|
||||||
|
emit(
|
||||||
|
"error",
|
||||||
|
"部分日期导入失败",
|
||||||
|
`已保存:${result.savedDates.join("、") || "无"}\n失败:${result.failedDates.map((item) => item.date).join("、")}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
emit("success", "导入完成", `已导入 ${result.imported} 项,保留 ${result.skipped} 项`);
|
||||||
|
dialog.value = false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
emitError("导入失败", error);
|
||||||
|
} finally {
|
||||||
|
importing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitError(title, error) {
|
||||||
|
emit("error", title, formatError(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatError(error) {
|
||||||
|
if (error instanceof UafExportValidationError) return error.issues.join("\n");
|
||||||
|
return error?.message || "发生未知错误";
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayDate(date) {
|
||||||
|
if (/^\d{8}$/.test(date)) return `${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)}`;
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.transfer-content {
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-preview {
|
||||||
|
display: -webkit-box;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: normal;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-select {
|
||||||
|
min-width: 132px;
|
||||||
|
width: 132px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.action-select {
|
||||||
|
min-width: 112px;
|
||||||
|
width: 112px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -68,6 +68,9 @@
|
|||||||
<v-divider class="my-2" />
|
<v-divider class="my-2" />
|
||||||
<setting-item :setting-key="'display.showExamScheduleButton'" />
|
<setting-item :setting-key="'display.showExamScheduleButton'" />
|
||||||
|
|
||||||
|
<v-divider class="my-2" />
|
||||||
|
<setting-item :setting-key="'display.showUafTransfer'" />
|
||||||
|
|
||||||
<v-divider class="my-2" />
|
<v-divider class="my-2" />
|
||||||
<setting-item :setting-key="'display.forceDesktopMode'" />
|
<setting-item :setting-key="'display.forceDesktopMode'" />
|
||||||
|
|
||||||
|
|||||||
@ -200,12 +200,28 @@
|
|||||||
: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"
|
||||||
|
:show-uaf-transfer-button="showUafTransferButton"
|
||||||
|
:uaf-transfer-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"
|
||||||
|
@open-uaf-export="openUafTransfer('export')"
|
||||||
|
@open-uaf-import="openUafTransfer('import')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<uaf-transfer-dialog
|
||||||
|
v-model="uafTransfer.show"
|
||||||
|
:mode="uafTransfer.mode"
|
||||||
|
:current-date="state.dateString"
|
||||||
|
:current-items="sortedItems"
|
||||||
|
:current-board-data="state.boardData"
|
||||||
|
:subjects="state.availableSubjects"
|
||||||
|
@success="handleUafSuccess"
|
||||||
|
@error="handleUafError"
|
||||||
|
@imported="handleUafImported"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<pwa-install-card />
|
<pwa-install-card />
|
||||||
@ -471,9 +487,10 @@
|
|||||||
>
|
>
|
||||||
<v-card>
|
<v-card>
|
||||||
<v-card-title class="text-h6">
|
<v-card-title class="text-h6">
|
||||||
预览考试看板
|
考试看板
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
|
<v-list><v-list-item active color="green" @click="$router.push('/examschedule')" append-icon="mdi-arrow-right">打开考试看板</v-list-item></v-list>
|
||||||
<v-list v-if="examStore.examList.length > 0">
|
<v-list v-if="examStore.examList.length > 0">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-for="exam in examStore.examList"
|
v-for="exam in examStore.examList"
|
||||||
@ -584,6 +601,10 @@ const HomeworkEditDialog = defineAsyncComponent({
|
|||||||
loader: () => import("@/components/HomeworkEditDialog.vue"),
|
loader: () => import("@/components/HomeworkEditDialog.vue"),
|
||||||
delay: 0,
|
delay: 0,
|
||||||
});
|
});
|
||||||
|
const UafTransferDialog = defineAsyncComponent({
|
||||||
|
loader: () => import("@/components/home/UafTransferDialog.vue"),
|
||||||
|
delay: 0,
|
||||||
|
});
|
||||||
const InitServiceChooser = defineAsyncComponent({
|
const InitServiceChooser = defineAsyncComponent({
|
||||||
loader: () => import("@/components/InitServiceChooser.vue"),
|
loader: () => import("@/components/InitServiceChooser.vue"),
|
||||||
loadingComponent: AsyncLoadingPlaceholder,
|
loadingComponent: AsyncLoadingPlaceholder,
|
||||||
@ -661,6 +682,7 @@ export default {
|
|||||||
ExamScheduleCard,
|
ExamScheduleCard,
|
||||||
ExamConfigEditor,
|
ExamConfigEditor,
|
||||||
HomeSkeleton,
|
HomeSkeleton,
|
||||||
|
UafTransferDialog,
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const { mobile } = useDisplay();
|
const { mobile } = useDisplay();
|
||||||
@ -732,6 +754,11 @@ export default {
|
|||||||
upload: false,
|
upload: false,
|
||||||
students: false,
|
students: false,
|
||||||
copyToToday: false,
|
copyToToday: false,
|
||||||
|
exportUaf: false,
|
||||||
|
},
|
||||||
|
uafTransfer: {
|
||||||
|
show: false,
|
||||||
|
mode: "export",
|
||||||
},
|
},
|
||||||
dataReady: false,
|
dataReady: false,
|
||||||
debouncedUpload: null,
|
debouncedUpload: null,
|
||||||
@ -888,6 +915,7 @@ export default {
|
|||||||
name: subjectKey,
|
name: subjectKey,
|
||||||
type: 'homework',
|
type: 'homework',
|
||||||
content: subjectData.content,
|
content: subjectData.content,
|
||||||
|
tags: Array.isArray(subjectData.tags) ? subjectData.tags : [],
|
||||||
order: subject.order,
|
order: subject.order,
|
||||||
rowSpan: estimatedHeight, // Used for sorting only
|
rowSpan: estimatedHeight, // Used for sorting only
|
||||||
});
|
});
|
||||||
@ -928,6 +956,7 @@ export default {
|
|||||||
name: card.name,
|
name: card.name,
|
||||||
type: 'custom',
|
type: 'custom',
|
||||||
content: card.content,
|
content: card.content,
|
||||||
|
tags: Array.isArray(card.tags) ? card.tags : [],
|
||||||
order: 9999, // Put at the end
|
order: 9999, // Put at the end
|
||||||
rowSpan: estimatedHeight, // Used for sorting only
|
rowSpan: estimatedHeight, // Used for sorting only
|
||||||
});
|
});
|
||||||
@ -1041,6 +1070,9 @@ export default {
|
|||||||
showTestCardButton() {
|
showTestCardButton() {
|
||||||
return getSetting("developer.enabled");
|
return getSetting("developer.enabled");
|
||||||
},
|
},
|
||||||
|
showUafTransferButton() {
|
||||||
|
return getSetting("display.showUafTransfer");
|
||||||
|
},
|
||||||
shouldShowInit() {
|
shouldShowInit() {
|
||||||
const provider = getSetting("server.provider");
|
const provider = getSetting("server.provider");
|
||||||
const isKv = provider === "kv-server" || provider === "classworkscloud";
|
const isKv = provider === "kv-server" || provider === "classworkscloud";
|
||||||
@ -1652,6 +1684,7 @@ export default {
|
|||||||
this.state.boardData.homework[this.currentEditSubject].content = content;
|
this.state.boardData.homework[this.currentEditSubject].content = content;
|
||||||
} else {
|
} else {
|
||||||
this.state.boardData.homework[this.currentEditSubject] = {
|
this.state.boardData.homework[this.currentEditSubject] = {
|
||||||
|
...this.state.boardData.homework[this.currentEditSubject],
|
||||||
content: content,
|
content: content,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -1785,6 +1818,7 @@ export default {
|
|||||||
this.state.boardData.homework[this.currentEditSubject].content = content;
|
this.state.boardData.homework[this.currentEditSubject].content = content;
|
||||||
} else {
|
} else {
|
||||||
this.state.boardData.homework[this.currentEditSubject] = {
|
this.state.boardData.homework[this.currentEditSubject] = {
|
||||||
|
...this.state.boardData.homework[this.currentEditSubject],
|
||||||
content: content,
|
content: content,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -2052,6 +2086,25 @@ export default {
|
|||||||
this.state.synced = false;
|
this.state.synced = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
openUafTransfer(mode) {
|
||||||
|
this.uafTransfer.mode = mode;
|
||||||
|
this.uafTransfer.show = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
handleUafSuccess(title, content) {
|
||||||
|
this.$message.success(title, content);
|
||||||
|
},
|
||||||
|
|
||||||
|
handleUafError(title, content) {
|
||||||
|
this.$message.error(title, content);
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleUafImported(result) {
|
||||||
|
if (result.savedDates.includes(this.state.dateString)) {
|
||||||
|
await this.downloadData(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
showConfirmDialog() {
|
showConfirmDialog() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
this.confirmDialog = {
|
this.confirmDialog = {
|
||||||
@ -2369,6 +2422,7 @@ export default {
|
|||||||
cardHoverEffect: "卡片悬浮效果",
|
cardHoverEffect: "卡片悬浮效果",
|
||||||
enhancedTouchMode: "增强触摸模式",
|
enhancedTouchMode: "增强触摸模式",
|
||||||
showAntiScreenBurnCard: "防烧屏卡片",
|
showAntiScreenBurnCard: "防烧屏卡片",
|
||||||
|
showUafTransfer: "UAF导入导出",
|
||||||
|
|
||||||
mode: "主题模式",
|
mode: "主题模式",
|
||||||
|
|
||||||
@ -2454,7 +2508,10 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
// 普通作业,只复制内容
|
// 普通作业,只复制内容
|
||||||
newHomework[key] = {
|
newHomework[key] = {
|
||||||
content: sourceHomework[key].content
|
content: sourceHomework[key].content,
|
||||||
|
tags: Array.isArray(sourceHomework[key].tags)
|
||||||
|
? [...sourceHomework[key].tags]
|
||||||
|
: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -203,6 +203,12 @@ const settingsDefinitions = {
|
|||||||
icon: "mdi-calendar-check",
|
icon: "mdi-calendar-check",
|
||||||
// 控制是否在主页显示考试看板按钮,指向考试安排页面
|
// 控制是否在主页显示考试看板按钮,指向考试安排页面
|
||||||
},
|
},
|
||||||
|
"display.showUafTransfer": {
|
||||||
|
type: "boolean",
|
||||||
|
default: true,
|
||||||
|
description: "是否显示UAF作业导入导出",
|
||||||
|
icon: "mdi-swap-vertical-bold",
|
||||||
|
},
|
||||||
"display.showQuickTools": {
|
"display.showQuickTools": {
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
default: true,
|
default: true,
|
||||||
|
|||||||
291
src/utils/uafExport.js
Normal file
291
src/utils/uafExport.js
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
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;
|
||||||
|
if (
|
||||||
|
/^\d{4}-\d{2}-\d{2}T/.test(value) &&
|
||||||
|
!Number.isNaN(Date.parse(value))
|
||||||
|
) {
|
||||||
|
return value.slice(0, 10);
|
||||||
|
}
|
||||||
|
throw new UafExportValidationError([
|
||||||
|
`日期 ${value || "(空)"} 不是有效的 YYYYMMDD 或 YYYY-MM-DD 格式`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toClassworksDate(value) {
|
||||||
|
return normalizeUafDate(value).replaceAll("-", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTags(tags) {
|
||||||
|
return Array.isArray(tags) ? tags.map((tag) => String(tag)) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateUafAssignment(assignment, label = assignment.subject || "作业") {
|
||||||
|
const issues = [];
|
||||||
|
if (!assignment.subject) issues.push(`${label}:科目名称不能为空`);
|
||||||
|
if (assignment.subject.length > LIMITS.subjectMax) {
|
||||||
|
issues.push(`${label}:科目名称超过 ${LIMITS.subjectMax} 个字符`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
normalizeUafDate(assignment.date);
|
||||||
|
} catch (error) {
|
||||||
|
issues.push(...error.issues.map((issue) => `${label}:${issue}`));
|
||||||
|
}
|
||||||
|
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} 个`);
|
||||||
|
}
|
||||||
|
if (assignment.tags.some((tag) => !tag || tag.length > LIMITS.tagMax || tag.includes(";"))) {
|
||||||
|
issues.push(`${label}:包含无效标签`);
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
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: normalizeTags(item.tags),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (assignments.length === 0) {
|
||||||
|
throw new UafExportValidationError(["所选日期没有可导出的作业"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const issues = assignments.flatMap((assignment, index) =>
|
||||||
|
validateUafAssignment(assignment, assignment.subject || `第 ${index + 1} 张卡片`),
|
||||||
|
);
|
||||||
|
if (issues.length > 0) throw new UafExportValidationError(issues);
|
||||||
|
return assignments;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createExportPreview(items, dateValue) {
|
||||||
|
const date = normalizeUafDate(dateValue);
|
||||||
|
return items
|
||||||
|
.filter((item) => item && (item.type === "homework" || item.type === "custom"))
|
||||||
|
.filter((item) => typeof item.content === "string" && item.content.trim().length > 0)
|
||||||
|
.map((item, index) => {
|
||||||
|
const assignment = {
|
||||||
|
subject: String(item.name || "").trim(),
|
||||||
|
date,
|
||||||
|
content: item.content,
|
||||||
|
tags: normalizeTags(item.tags),
|
||||||
|
};
|
||||||
|
const issues = validateUafAssignment(assignment, assignment.subject || `第 ${index + 1} 张卡片`);
|
||||||
|
return { id: `${item.key || index}-${index}`, assignment, selected: issues.length === 0, issues };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function itemsFromBoardData(boardData, subjects = []) {
|
||||||
|
const homework = boardData?.homework || {};
|
||||||
|
const items = [];
|
||||||
|
for (const subject of subjects) {
|
||||||
|
const card = homework[subject.name];
|
||||||
|
if (card?.content?.trim()) {
|
||||||
|
items.push({
|
||||||
|
key: subject.name,
|
||||||
|
name: subject.name,
|
||||||
|
type: "homework",
|
||||||
|
content: card.content,
|
||||||
|
tags: normalizeTags(card.tags),
|
||||||
|
order: subject.order ?? 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [key, card] of Object.entries(homework)) {
|
||||||
|
if (key.startsWith("custom-") && card?.content?.trim()) {
|
||||||
|
items.push({
|
||||||
|
key,
|
||||||
|
name: card.name,
|
||||||
|
type: "custom",
|
||||||
|
content: card.content,
|
||||||
|
tags: normalizeTags(card.tags),
|
||||||
|
order: 9999,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items.sort((a, b) => a.order - b.order);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasExportableHomework(items) {
|
||||||
|
return items.some(
|
||||||
|
(item) =>
|
||||||
|
item &&
|
||||||
|
(item.type === "homework" || item.type === "custom") &&
|
||||||
|
typeof item.content === "string" &&
|
||||||
|
item.content.trim().length > 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadBrowserUaf() {
|
||||||
|
return import("../vendor/uaf/browser.js");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseUafPdf(file) {
|
||||||
|
if (!file || !file.name?.toLowerCase().endsWith(".pdf")) {
|
||||||
|
throw new UafExportValidationError(["请选择 UAF PDF 文件"]);
|
||||||
|
}
|
||||||
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||||
|
const { validateUafPdf, extractUafPayload } = await loadBrowserUaf();
|
||||||
|
const validation = await validateUafPdf(bytes);
|
||||||
|
if (!validation.valid) {
|
||||||
|
throw new UafExportValidationError(
|
||||||
|
validation.errors.length ? validation.errors : ["文件不是有效的 UAF PDF"],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return validation.payload || extractUafPayload(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createImportPlan(document, subjects, loadBoardData) {
|
||||||
|
const subjectNames = new Set(subjects.map((subject) => subject.name));
|
||||||
|
const dates = [...new Set(document.map((assignment) => toClassworksDate(assignment.date)))];
|
||||||
|
const boards = new Map();
|
||||||
|
for (const date of dates) boards.set(date, await loadBoardData(date));
|
||||||
|
|
||||||
|
const rows = document.map((assignment, index) => {
|
||||||
|
const date = toClassworksDate(assignment.date);
|
||||||
|
const board = boards.get(date);
|
||||||
|
const isSubject = subjectNames.has(assignment.subject);
|
||||||
|
let targetKey = isSubject ? assignment.subject : null;
|
||||||
|
if (!isSubject) {
|
||||||
|
targetKey = Object.keys(board.homework || {}).find(
|
||||||
|
(key) => key.startsWith("custom-") && board.homework[key]?.name === assignment.subject,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const conflict = Boolean(targetKey && board.homework?.[targetKey]?.content?.trim());
|
||||||
|
return {
|
||||||
|
id: `uaf-import-${index}`,
|
||||||
|
index,
|
||||||
|
date,
|
||||||
|
assignment: { ...assignment, tags: normalizeTags(assignment.tags) },
|
||||||
|
targetType: isSubject ? "homework" : "custom",
|
||||||
|
targetKey,
|
||||||
|
conflict,
|
||||||
|
action: conflict ? "keep" : "import",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { rows, boards };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findImportPlanIssues(rows) {
|
||||||
|
const issues = [];
|
||||||
|
const activeTargets = new Map();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.action === "keep") continue;
|
||||||
|
const key = row.targetKey ? `${row.date}:${row.targetKey}` : null;
|
||||||
|
if (key && activeTargets.has(key)) {
|
||||||
|
issues.push(`${row.date} 的“${row.assignment.subject}”有多条记录指向同一卡片`);
|
||||||
|
}
|
||||||
|
if (key) activeTargets.set(key, row.id);
|
||||||
|
}
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeImportPlan(plan, saveBoardData) {
|
||||||
|
const issues = findImportPlanIssues(plan.rows);
|
||||||
|
if (issues.length) throw new UafExportValidationError(issues);
|
||||||
|
const changedDates = new Set();
|
||||||
|
let imported = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const row of plan.rows) {
|
||||||
|
if (row.action === "keep") {
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const board = plan.boards.get(row.date);
|
||||||
|
board.homework ||= {};
|
||||||
|
if (row.targetType === "homework") {
|
||||||
|
board.homework[row.assignment.subject] = {
|
||||||
|
...(board.homework[row.assignment.subject] || {}),
|
||||||
|
content: row.assignment.content,
|
||||||
|
tags: row.assignment.tags,
|
||||||
|
};
|
||||||
|
} else if (row.targetKey) {
|
||||||
|
board.homework[row.targetKey] = {
|
||||||
|
...board.homework[row.targetKey],
|
||||||
|
name: row.assignment.subject,
|
||||||
|
type: "custom",
|
||||||
|
content: row.assignment.content,
|
||||||
|
tags: row.assignment.tags,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const key = `custom-uaf-${Date.now()}-${row.index}`;
|
||||||
|
board.homework[key] = {
|
||||||
|
name: row.assignment.subject,
|
||||||
|
type: "custom",
|
||||||
|
content: row.assignment.content,
|
||||||
|
tags: row.assignment.tags,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
imported += 1;
|
||||||
|
changedDates.add(row.date);
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedDates = [];
|
||||||
|
const failedDates = [];
|
||||||
|
for (const date of changedDates) {
|
||||||
|
try {
|
||||||
|
await saveBoardData(date, plan.boards.get(date));
|
||||||
|
savedDates.push(date);
|
||||||
|
} catch (error) {
|
||||||
|
failedDates.push({ date, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { imported, skipped, savedDates, failedDates };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadUafAssignments(assignments, dateValue) {
|
||||||
|
if (!assignments.length) throw new UafExportValidationError(["请至少选择一项作业"]);
|
||||||
|
const issues = assignments.flatMap((assignment) => validateUafAssignment(assignment));
|
||||||
|
if (issues.length) throw new UafExportValidationError(issues);
|
||||||
|
const { createUafPdf } = await loadBrowserUaf();
|
||||||
|
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,
|
||||||
|
theme: "classworks-dark",
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadUafDocument(items, dateValue) {
|
||||||
|
return downloadUafAssignments(createUafDocument(items, dateValue), dateValue);
|
||||||
|
}
|
||||||
56883
src/vendor/uaf/browser.js
vendored
Normal file
56883
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": "1e059777c77c22569ed628bbf3f4b20f59abab46337ff6a6d814c2497d17e4e1",
|
||||||
|
"fontSha256": "a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da",
|
||||||
|
"wasmSha256": "1bf32603c1dfe17e1b9d54acaec6adfd0fc5c517e088648ba7e44a24213ae93e"
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user