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

feat.改进UAF样式

This commit is contained in:
lincube 2026-07-11 17:02:22 +09:00
parent d4024ec14a
commit 08fcc5cb4f
9 changed files with 939 additions and 118 deletions

1
.gitignore vendored
View File

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

View File

View File

@ -1,17 +1,30 @@
import assert from "node:assert/strict";
import { createUafDocument, hasExportableHomework, normalizeUafDate, UafExportValidationError } from "../src/utils/uafExport.js";
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 题" },
{ 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: "完成第 1、2 题", tags: ["必做"] },
{ subject: "班级任务", date: "2026-07-11", content: "整理讲台", tags: [] },
]);
assert.throws(() => createUafDocument([], "20260711"), UafExportValidationError);
@ -19,4 +32,62 @@ assert.throws(
() => createUafDocument([{ type: "homework", name: "数学", content: "x".repeat(2001) }], "20260711"),
/2000/,
);
console.log("Classworks UAF export mapping tests passed.");
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.");

View File

@ -83,18 +83,35 @@
>
添加测试卡片
</v-btn>
<v-menu location="bottom end">
<template #activator="{ props: menuProps }">
<v-btn
:disabled="uafExportDisabled"
:loading="uafExportLoading"
v-bind="menuProps"
:disabled="uafTransferLoading"
:loading="uafTransferLoading"
append-icon="mdi-menu-down"
class="ml-2"
color="indigo"
prepend-icon="mdi-file-pdf-box"
prepend-icon="mdi-swap-vertical-bold"
size="large"
rounded="xl"
@click="$emit('export-uaf')"
>
导出 UAF
作业导出导入
</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>
</div>
<v-card
@ -139,8 +156,7 @@ export default {
isFullscreen: Boolean,
showAntiScreenBurnCard: Boolean,
showTestCardButton: Boolean,
uafExportDisabled: Boolean,
uafExportLoading: Boolean,
uafTransferLoading: Boolean,
},
emits: [
"upload",
@ -149,7 +165,8 @@ export default {
"toggle-fullscreen",
"add-test-card",
"add-exam-card",
"export-uaf",
"open-uaf-export",
"open-uaf-import",
],
};
</script>

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

View File

@ -200,15 +200,27 @@
:is-fullscreen="state.isFullscreen"
:show-anti-screen-burn-card="showAntiScreenBurnCard"
:show-test-card-button="showTestCardButton"
:uaf-export-disabled="!hasExportableHomework"
:uaf-export-loading="loading.exportUaf"
:uaf-transfer-loading="loading.exportUaf"
@upload="manualUpload"
@show-sync-message="showSyncMessage"
@open-random-picker="openRandomPicker"
@toggle-fullscreen="toggleFullscreen"
@add-test-card="addTestCard"
@add-exam-card="showAddExamDialog = true"
@export-uaf="exportUaf"
@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 />
@ -564,11 +576,6 @@ import HomeActions from "@/components/home/HomeActions.vue";
import FloatingICP from "@/components/FloatingICP.vue";
import HitokotoCard from "@/components/HitokotoCard.vue";
import HomeSkeleton from "@/components/common/HomeSkeleton.vue";
import {
downloadUafDocument,
hasExportableHomework as containsExportableHomework,
UafExportValidationError,
} from "@/utils/uafExport.js";
// ===== / =====
const MessageLog = defineAsyncComponent({
@ -592,6 +599,10 @@ const HomeworkEditDialog = defineAsyncComponent({
loader: () => import("@/components/HomeworkEditDialog.vue"),
delay: 0,
});
const UafTransferDialog = defineAsyncComponent({
loader: () => import("@/components/home/UafTransferDialog.vue"),
delay: 0,
});
const InitServiceChooser = defineAsyncComponent({
loader: () => import("@/components/InitServiceChooser.vue"),
loadingComponent: AsyncLoadingPlaceholder,
@ -669,6 +680,7 @@ export default {
ExamScheduleCard,
ExamConfigEditor,
HomeSkeleton,
UafTransferDialog,
},
setup() {
const { mobile } = useDisplay();
@ -742,6 +754,10 @@ export default {
copyToToday: false,
exportUaf: false,
},
uafTransfer: {
show: false,
mode: "export",
},
dataReady: false,
debouncedUpload: null,
debouncedAttendanceSave: null,
@ -897,6 +913,7 @@ export default {
name: subjectKey,
type: 'homework',
content: subjectData.content,
tags: Array.isArray(subjectData.tags) ? subjectData.tags : [],
order: subject.order,
rowSpan: estimatedHeight, // Used for sorting only
});
@ -937,6 +954,7 @@ export default {
name: card.name,
type: 'custom',
content: card.content,
tags: Array.isArray(card.tags) ? card.tags : [],
order: 9999, // Put at the end
rowSpan: estimatedHeight, // Used for sorting only
});
@ -1094,9 +1112,6 @@ export default {
.sort((a, b) => a.order - b.order)
.map((subject) => subject.name);
},
hasExportableHomework() {
return containsExportableHomework(this.sortedItems);
},
},
watch: {
@ -1664,6 +1679,7 @@ export default {
this.state.boardData.homework[this.currentEditSubject].content = content;
} else {
this.state.boardData.homework[this.currentEditSubject] = {
...this.state.boardData.homework[this.currentEditSubject],
content: content,
};
}
@ -1797,6 +1813,7 @@ export default {
this.state.boardData.homework[this.currentEditSubject].content = content;
} else {
this.state.boardData.homework[this.currentEditSubject] = {
...this.state.boardData.homework[this.currentEditSubject],
content: content,
};
}
@ -2064,21 +2081,22 @@ export default {
this.state.synced = false;
},
async exportUaf() {
if (this.loading.exportUaf) return;
this.loading.exportUaf = true;
try {
const filename = await downloadUafDocument(this.sortedItems, this.state.dateString);
this.$message.success("导出成功", filename);
} catch (error) {
console.error("UAF export failed:", error);
if (error instanceof UafExportValidationError) {
this.$message.error("无法导出 UAF", error.issues.join("\n"));
} else {
this.$message.error("导出失败", error?.message || "无法生成 UAF PDF");
}
} finally {
this.loading.exportUaf = false;
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);
}
},
@ -2484,7 +2502,10 @@ export default {
} else {
//
newHomework[key] = {
content: sourceHomework[key].content
content: sourceHomework[key].content,
tags: Array.isArray(sourceHomework[key].tags)
? [...sourceHomework[key].tags]
: [],
};
}
}

View File

@ -18,7 +18,47 @@ export function normalizeUafDate(value) {
return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}`;
}
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
throw new UafExportValidationError([`日期 ${value || "(空)"} 不是有效的 YYYYMMDD 或 YYYY-MM-DD 格式`]);
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) {
@ -30,38 +70,68 @@ export function createUafDocument(items, dateValue) {
subject: String(item.name || "").trim(),
date,
content: item.content,
tags: [],
tags: normalizeTags(item.tags),
}));
if (assignments.length === 0) {
throw new UafExportValidationError(["当前日期没有可导出的作业"]);
throw new UafExportValidationError(["所选日期没有可导出的作业"]);
}
const issues = [];
assignments.forEach((assignment, index) => {
const label = assignment.subject || `${index + 1} 张卡片`;
if (!assignment.subject) issues.push(`${label}:科目名称不能为空`);
if (assignment.subject.length > LIMITS.subjectMax) {
issues.push(`${label}:科目名称超过 ${LIMITS.subjectMax} 个字符`);
}
if (!assignment.content) issues.push(`${label}:正文不能为空`);
if (assignment.content.length > LIMITS.contentMax) {
issues.push(`${label}:正文超过 ${LIMITS.contentMax} 个字符`);
}
if (assignment.tags.length > LIMITS.tagCountMax) {
issues.push(`${label}:标签超过 ${LIMITS.tagCountMax}`);
}
for (const tag of assignment.tags) {
if (!tag || tag.length > LIMITS.tagMax || tag.includes(";")) {
issues.push(`${label}:包含无效标签`);
break;
}
}
});
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) =>
@ -72,13 +142,137 @@ export function hasExportableHomework(items) {
);
}
export async function downloadUafDocument(items, dateValue) {
const assignments = createUafDocument(items, dateValue);
const { createUafPdf } = await import("../vendor/uaf/browser.js");
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 });
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");
@ -91,3 +285,7 @@ export async function downloadUafDocument(items, dateValue) {
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
return link.download;
}
export async function downloadUafDocument(items, dateValue) {
return downloadUafAssignments(createUafDocument(items, dateValue), dateValue);
}

View File

@ -56374,13 +56374,14 @@ function drawPill(page, x, y, w, h, fill2, border) {
// src/renderCard.ts
var PAGE_WIDTH = 595.28;
var PAGE_HEIGHT = 841.89;
var PAGE_MARGIN = 36;
var PAGE_MARGIN = 40;
var WATERMARK_SPACE = 24;
var COLUMN_GAP = 14;
var ROW_GAP = 14;
var CARD_WIDTH = (PAGE_WIDTH - PAGE_MARGIN * 2 - COLUMN_GAP) / 2;
var CARD_PAD = 14;
var HEADER_HEIGHT = 48;
var CARD_PAD = 16;
var HEADER_HEIGHT = 56;
var CARD_RADIUS = 16;
var CONTENT_FONT = 13.5;
var CONTENT_LINE_HEIGHT = 19;
var MAX_LINES_PER_FRAGMENT = 12;
@ -56389,20 +56390,34 @@ var MIN_CARD_HEIGHT = 140;
var SUBJECT_FONT = 17;
var DATE_FONT = 9.5;
var TAG_FONT = 9.5;
var WATERMARK_FONT = 9;
var COLORS = {
pageBg: rgb(248 / 255, 250 / 255, 252 / 255),
shadow: rgb(203 / 255, 213 / 255, 225 / 255),
border: rgb(226 / 255, 232 / 255, 240 / 255),
card: rgb(1, 1, 1),
header: rgb(37 / 255, 99 / 255, 235 / 255),
headerText: rgb(1, 1, 1),
dateText: rgb(219 / 255, 234 / 255, 254 / 255),
content: rgb(15 / 255, 23 / 255, 42 / 255),
chip: rgb(224 / 255, 231 / 255, 255 / 255),
chipText: rgb(55 / 255, 48 / 255, 163 / 255),
muted: rgb(100 / 255, 116 / 255, 139 / 255),
watermark: rgb(148 / 255, 163 / 255, 184 / 255)
var WATERMARK_FONT = 10;
var DEFAULT_COLORS = {
pageBg: rgb(255 / 255, 251 / 255, 254 / 255),
shadow: rgb(0 / 255, 0 / 255, 0 / 255),
border: rgb(121 / 255, 116 / 255, 126 / 255),
card: rgb(255 / 255, 251 / 255, 254 / 255),
header: rgb(24 / 255, 103 / 255, 192 / 255),
headerText: rgb(29 / 255, 27 / 255, 32 / 255),
dateText: rgb(73 / 255, 69 / 255, 79 / 255),
content: rgb(29 / 255, 27 / 255, 32 / 255),
chip: rgb(232 / 255, 222 / 255, 248 / 255),
chipText: rgb(29 / 255, 25 / 255, 43 / 255),
muted: rgb(73 / 255, 69 / 255, 79 / 255),
watermark: rgb(73 / 255, 69 / 255, 79 / 255)
};
var CLASSWORKS_DARK_COLORS = {
pageBg: rgb(20 / 255, 18 / 255, 24 / 255),
shadow: rgb(0 / 255, 0 / 255, 0 / 255),
border: rgb(73 / 255, 69 / 255, 79 / 255),
card: rgb(29 / 255, 27 / 255, 32 / 255),
header: rgb(24 / 255, 103 / 255, 192 / 255),
headerText: rgb(230 / 255, 225 / 255, 229 / 255),
dateText: rgb(202 / 255, 196 / 255, 208 / 255),
content: rgb(230 / 255, 225 / 255, 229 / 255),
chip: rgb(74 / 255, 68 / 255, 88 / 255),
chipText: rgb(232 / 255, 222 / 255, 248 / 255),
muted: rgb(202 / 255, 196 / 255, 208 / 255),
watermark: rgb(255 / 255, 255 / 255, 255 / 255)
};
function widthOf(font, text, size) {
return font.widthOfTextAtSize(text, size);
@ -56467,19 +56482,18 @@ function formatDate(date, mode) {
const parsed = new Date(date);
return Number.isNaN(parsed.getTime()) ? date : `${parsed.getFullYear()}\u5E74${parsed.getMonth() + 1}\u6708${parsed.getDate()}\u65E5`;
}
function drawPageBackground(page, font, canRenderCjk) {
page.drawRectangle({ x: 0, y: 0, width: PAGE_WIDTH, height: PAGE_HEIGHT, color: COLORS.pageBg });
function drawPageBackground(page, font, canRenderCjk, colors) {
page.drawRectangle({ x: 0, y: 0, width: PAGE_WIDTH, height: PAGE_HEIGHT, color: colors.pageBg });
const watermark = canRenderCjk ? "\u4F7F\u7528 UAF v1.0 \u5BFC\u51FA" : "Exported with UAF v1.0";
page.drawText(watermark, {
x: PAGE_WIDTH - PAGE_MARGIN - widthOf(font, watermark, WATERMARK_FONT),
y: PAGE_MARGIN - 4,
size: WATERMARK_FONT,
font,
color: COLORS.watermark,
opacity: 0.65
color: colors.watermark
});
}
function drawTags(page, tags, x, y, font) {
function drawTags(page, tags, x, y, font, colors) {
if (tags.length === 0) {
page.drawText("", { x, y, font, size: TAG_FONT });
return;
@ -56490,20 +56504,17 @@ function drawTags(page, tags, x, y, font) {
const label = ellipsize(tag2, font, TAG_FONT, CARD_WIDTH - CARD_PAD * 2 - 16);
const width = Math.min(widthOf(font, label, TAG_FONT) + 16, CARD_WIDTH - CARD_PAD * 2);
if (cursor + width > maxX) break;
drawPill(page, cursor, y, width, 19, COLORS.chip);
page.drawText(label, { x: cursor + 8, y: y + 5.2, size: TAG_FONT, font, color: COLORS.chipText });
drawPill(page, cursor, y, width, 19, colors.chip);
page.drawText(label, { x: cursor + 8, y: y + 5.2, size: TAG_FONT, font, color: colors.chipText });
cursor += width + 6;
}
}
function drawFragment(page, fragment, x, top, font, fontBold, dateDisplay, canRenderCjk) {
function drawFragment(page, fragment, x, top, font, fontBold, dateDisplay, canRenderCjk, colors) {
const y = top - fragment.height;
drawRoundedRect(page, x + 3, y - 3, CARD_WIDTH, fragment.height, 12, COLORS.shadow);
drawRoundedRect(page, x, y, CARD_WIDTH, fragment.height, 12, COLORS.card, {
color: COLORS.border,
drawRoundedRect(page, x, y, CARD_WIDTH, fragment.height, CARD_RADIUS, colors.card, {
color: colors.border,
width: 1
});
drawRoundedRect(page, x, top - HEADER_HEIGHT, CARD_WIDTH, HEADER_HEIGHT, 12, COLORS.header);
page.drawRectangle({ x, y: top - HEADER_HEIGHT, width: CARD_WIDTH, height: 12, color: COLORS.header });
const continuation = fragment.continuation ? canRenderCjk ? "\uFF08\u7EED\uFF09" : " (cont.)" : "";
const subject = ellipsize(
`${fragment.assignment.subject}${continuation}`,
@ -56513,39 +56524,39 @@ function drawFragment(page, fragment, x, top, font, fontBold, dateDisplay, canRe
);
page.drawText(subject, {
x: x + CARD_PAD,
y: top - 23,
y: top - CARD_PAD - 20,
size: SUBJECT_FONT,
font: fontBold,
color: COLORS.headerText
color: colors.headerText
});
page.drawText(formatDate(fragment.assignment.date, dateDisplay), {
x: x + CARD_PAD,
y: top - 39,
y: top - CARD_PAD - 40,
size: DATE_FONT,
font,
color: COLORS.dateText
color: colors.dateText
});
let lineY = top - HEADER_HEIGHT - 24;
let lineY = top - CARD_PAD - HEADER_HEIGHT - 12;
for (const line of fragment.lines) {
page.drawText(line || " ", {
x: x + CARD_PAD,
y: lineY,
size: CONTENT_FONT,
font,
color: COLORS.content
color: colors.content
});
lineY -= CONTENT_LINE_HEIGHT;
}
if (fragment.showTags) {
drawTags(page, fragment.assignment.tags, x + CARD_PAD, y + 13, font);
drawTags(page, fragment.assignment.tags, x + CARD_PAD, y + CARD_PAD + 5, font, colors);
} else {
const continued = canRenderCjk ? "\u6B63\u6587\u4E0B\u9875\u7EE7\u7EED" : "Continued on next card";
page.drawText(continued, {
x: x + CARD_PAD,
y: y + 15,
y: y + CARD_PAD + 8,
size: TAG_FONT,
font,
color: COLORS.muted
color: colors.muted
});
}
}
@ -56553,6 +56564,7 @@ function renderAssignmentDocument(pdfDoc, document, font, fontBold, options = {}
const fragments = createFragments(document, font);
const pages = [];
const dateDisplay = options.dateDisplay ?? "zh";
const colors = options.theme === "classworks-dark" ? CLASSWORKS_DARK_COLORS : DEFAULT_COLORS;
const pageBottom = PAGE_MARGIN + WATERMARK_SPACE;
let page;
let cursorTop = PAGE_HEIGHT - PAGE_MARGIN;
@ -56562,7 +56574,7 @@ function renderAssignmentDocument(pdfDoc, document, font, fontBold, options = {}
if (!page || cursorTop - rowHeight < pageBottom) {
page = pdfDoc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
pages.push(page);
drawPageBackground(page, font, options.canRenderCjk !== false);
drawPageBackground(page, font, options.canRenderCjk !== false, colors);
cursorTop = PAGE_HEIGHT - PAGE_MARGIN;
}
pair.forEach((fragment, column) => {
@ -56574,7 +56586,8 @@ function renderAssignmentDocument(pdfDoc, document, font, fontBold, options = {}
font,
fontBold,
dateDisplay,
options.canRenderCjk !== false
options.canRenderCjk !== false,
colors
);
});
cursorTop -= rowHeight + ROW_GAP;
@ -56597,7 +56610,8 @@ async function createUafPdfWithFont(document, options = {}) {
}
renderAssignmentDocument(pdfDoc, validated, font, font, {
dateDisplay: options.useStandardFont ? "iso" : "zh",
canRenderCjk: !options.useStandardFont
canRenderCjk: !options.useStandardFont,
theme: options.theme
});
await pdfDoc.attach(csvBytes, UAF_PAYLOAD_FILENAME, {
mimeType: "text/csv",
@ -56833,7 +56847,11 @@ async function createUafPdf(document, options = {}) {
const fontBytes = await loadBrowserFont(options);
const wasmUrl = options.wasmUrl ?? new URL("../assets/hb-subset.wasm", import.meta.url);
const subset = await subsetFontInBrowser(fontBytes, collectDocumentText(document), wasmUrl);
return createUafPdfWithFont(document, { fontBytes: subset, subsetFont: false });
return createUafPdfWithFont(document, {
fontBytes: subset,
subsetFont: false,
theme: options.theme
});
}
async function createUafPdfFromCsv(csv, options = {}) {
return createUafPdf(parsePayload(csv), options);

View File

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