mirror of
https://github.com/ZeroCatDev/Classworks.git
synced 2026-08-12 17:40:32 +00:00
Compare commits
No commits in common. "5bfb5c9abb089ecb1856e7041e240540aa40e1c8" and "b63f453aaf5940db7bd857bec27505a3da686943" have entirely different histories.
5bfb5c9abb
...
b63f453aaf
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
72
AGENTS.md
@ -1,72 +0,0 @@
|
|||||||
# 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,9 +10,7 @@
|
|||||||
"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.
Binary file not shown.
@ -1,36 +0,0 @@
|
|||||||
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.");
|
|
||||||
@ -1,93 +0,0 @@
|
|||||||
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,46 +19,6 @@
|
|||||||
@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"
|
||||||
@ -71,19 +31,26 @@
|
|||||||
>
|
>
|
||||||
随机点名
|
随机点名
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
<v-btn-group
|
||||||
<v-btn
|
|
||||||
v-if="showExamScheduleButton"
|
v-if="showExamScheduleButton"
|
||||||
|
class="ml-2"
|
||||||
|
color="green"
|
||||||
|
variant="elevated"
|
||||||
|
divided
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
prepend-icon="mdi-calendar-check"
|
prepend-icon="mdi-calendar-check"
|
||||||
size="large"
|
size="large"
|
||||||
@click="$emit('add-exam-card')"
|
@click="$router.push('/examschedule')"
|
||||||
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"
|
||||||
@ -94,7 +61,18 @@
|
|||||||
>
|
>
|
||||||
列表
|
列表
|
||||||
</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"
|
||||||
@ -105,7 +83,6 @@
|
|||||||
>
|
>
|
||||||
添加测试卡片
|
添加测试卡片
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<v-card
|
<v-card
|
||||||
@ -150,8 +127,6 @@ export default {
|
|||||||
isFullscreen: Boolean,
|
isFullscreen: Boolean,
|
||||||
showAntiScreenBurnCard: Boolean,
|
showAntiScreenBurnCard: Boolean,
|
||||||
showTestCardButton: Boolean,
|
showTestCardButton: Boolean,
|
||||||
showUafTransferButton: Boolean,
|
|
||||||
uafTransferLoading: Boolean,
|
|
||||||
},
|
},
|
||||||
emits: [
|
emits: [
|
||||||
"upload",
|
"upload",
|
||||||
@ -159,9 +134,6 @@ 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>
|
||||||
|
|||||||
@ -1,495 +0,0 @@
|
|||||||
<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,9 +68,6 @@
|
|||||||
<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,28 +200,12 @@
|
|||||||
: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 />
|
||||||
@ -487,10 +471,9 @@
|
|||||||
>
|
>
|
||||||
<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"
|
||||||
@ -601,10 +584,6 @@ 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,
|
||||||
@ -682,7 +661,6 @@ export default {
|
|||||||
ExamScheduleCard,
|
ExamScheduleCard,
|
||||||
ExamConfigEditor,
|
ExamConfigEditor,
|
||||||
HomeSkeleton,
|
HomeSkeleton,
|
||||||
UafTransferDialog,
|
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const { mobile } = useDisplay();
|
const { mobile } = useDisplay();
|
||||||
@ -754,11 +732,6 @@ 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,
|
||||||
@ -915,7 +888,6 @@ 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
|
||||||
});
|
});
|
||||||
@ -956,7 +928,6 @@ 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
|
||||||
});
|
});
|
||||||
@ -1070,9 +1041,6 @@ 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";
|
||||||
@ -1684,7 +1652,6 @@ 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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -1818,7 +1785,6 @@ 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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -2086,25 +2052,6 @@ 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 = {
|
||||||
@ -2422,7 +2369,6 @@ export default {
|
|||||||
cardHoverEffect: "卡片悬浮效果",
|
cardHoverEffect: "卡片悬浮效果",
|
||||||
enhancedTouchMode: "增强触摸模式",
|
enhancedTouchMode: "增强触摸模式",
|
||||||
showAntiScreenBurnCard: "防烧屏卡片",
|
showAntiScreenBurnCard: "防烧屏卡片",
|
||||||
showUafTransfer: "UAF导入导出",
|
|
||||||
|
|
||||||
mode: "主题模式",
|
mode: "主题模式",
|
||||||
|
|
||||||
@ -2508,10 +2454,7 @@ 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,12 +203,6 @@ 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,
|
||||||
|
|||||||
@ -1,291 +0,0 @@
|
|||||||
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
56883
src/vendor/uaf/browser.js
vendored
File diff suppressed because one or more lines are too long
7
src/vendor/uaf/manifest.json
vendored
7
src/vendor/uaf/manifest.json
vendored
@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"uafVersion": "1.0",
|
|
||||||
"source": "../UnifiedAssignmentFormat/implementations/typescript/packages/pdf",
|
|
||||||
"bundleSha256": "1e059777c77c22569ed628bbf3f4b20f59abab46337ff6a6d814c2497d17e4e1",
|
|
||||||
"fontSha256": "a3041811a78c361b1de50f953c805e0244951c21c5bd412f7232ef0d899af0da",
|
|
||||||
"wasmSha256": "1bf32603c1dfe17e1b9d54acaec6adfd0fc5c517e088648ba7e44a24213ae93e"
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user