fix: 批改页 J/K 按键位对调;预览换内容不再进浏览器历史,“后退”能回到作品墙
Deploy / deploy (build, debian, 22) (push) Canceled after 0s

- J 上一个、K 下一个,和面板上的左右箭头一致
- 打开的作品放进地址栏(?sub=):点开记一步历史,J/K 切换只替换,关闭 / Esc 退回那一步
- 所有会换内容的预览 iframe 加 key,换内容时换新的 iframe:在原 iframe 里改 srcdoc 是一次导航,
  会进浏览器历史,“后退”就成了在预览里倒退(批改页切换学生、挑战页改代码都会这样)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-27 09:31:29 +08:00
1 parent 75e20a3ae1
commit 0b3780e0cd
7 files changed
+50 -15

No files matched your search

+1 -1
View File
@@ -32,7 +32,7 @@ The backend was rewritten from Django in 2026-10 to cut memory use (~25 MB vs. 1
- `src/icons.ts` — `<Icon icon="lucide:name" />` renders bundled `lucide-vue-next` icons (works offline); register new names in its map. Fonts are bundled too (`@fontsource/jetbrains-mono`)
- `components/workbench/Workbench.vue` — shared challenge / creation page: left = brief + AI chat (`PromptPanel`), right = preview / code (`CodeTabs`) + `VersionBar`. Every assistant message with code is a version; hand edits and pasted external-AI code are saved as versions via `POST /prompt/versions` (`source` `edit` / `manual`, stored as a user+assistant pair). Challenges are submitted only when the student clicks “交这一版” (`POST /submission/` with `message_id`), never automatically. Editor code is per task (`composables/useTaskCode.ts`, localStorage `web-code:<user>:<task>`); the tutorial page edits the shared `store/editors.ts` refs but `Workspace.vue` saves them under the same per-task key and `TutorialContent` restores them (example code only the first time)
- `pages/ChallengeHome.vue` — challenge list page; `pages/TaskEditor.vue` (prop `kind`) is the single tutorial / challenge authoring page
- `pages/Review.vue` (“批改”, default teaching page) + `components/review/ReviewPanel.vue` (also opened from the gradebook) — opens as a large preview (wall hidden, Esc returns; “小面板” switches to a side panel, remembered); keys: J/K next/prev student, 1–5 rate, P present; “全部班级” groups the wall by class; `components/review/PeersDrawer.vue` is the students' “同学作品” (本班 / 全部班级)
- `pages/Review.vue` (“批改”, default teaching page) + `components/review/ReviewPanel.vue` (also opened from the gradebook) — opens as a large preview (wall hidden, Esc returns; “小面板” switches to a side panel, remembered); keys: J/K previous/next student (by key position), 1–5 rate, P present; “全部班级” groups the wall by class; `components/review/PeersDrawer.vue` is the students' “同学作品” (本班 / 全部班级)
- `components/layout/AppShell.vue` — top bar for every page; `pages/Dashboard.vue` is the teaching sidebar (“教学”) under `/dashboard`
- `src/store/` — Reactive state (plain Vue refs, not Vuex/Pinia); `prompt.ts` / `guidance.ts` own the WebSockets
- `src/pages/`, `src/components/` — user management uses `components/dashboard/UserFormModal.vue` (there is no Django Admin anymore)
+5
View File
@@ -46,6 +46,7 @@
ref="frame"
class="iframe"
:sandbox="PREVIEW_SANDBOX"
:key="frameKey"
:srcdoc="previewContent"
></iframe>
</div>
@@ -141,8 +142,12 @@ function getContent(forDownload = false) {
})
}
// 每次内容变了换一个新的 iframe:在同一个 iframe 里改 srcdoc 会进浏览器历史,
// 按“后退”就成了在预览里倒退,而不是离开页面
const frameKey = ref(0)
function preview() {
previewContent.value = getContent()
frameKey.value++
}
function download() {
+1 -1
View File
@@ -61,7 +61,7 @@
@update:show="(v: boolean) => !v && (large = null)"
>
<div v-if="large" class="large">
<iframe :sandbox="PREVIEW_SANDBOX" :srcdoc="docOf(large)"></iframe>
<iframe :key="large.id" :sandbox="PREVIEW_SANDBOX" :srcdoc="docOf(large)"></iframe>
</div>
</n-modal>
</template>
+4 -3
View File
@@ -13,7 +13,7 @@
<Icon icon="lucide:arrow-left" />
</n-button>
</template>
上一个学生(K)
上一个学生(J)
</n-tooltip>
<n-tooltip>
<template #trigger>
@@ -21,7 +21,7 @@
<Icon icon="lucide:arrow-right" />
</n-button>
</template>
下一个学生(J)
下一个学生(K)
</n-tooltip>
</template>
<n-button
@@ -61,7 +61,8 @@
</button>
</div>
<div class="frame">
<iframe :sandbox="PREVIEW_SANDBOX" :srcdoc="doc"></iframe>
<!-- key:换作品时换新的 iframe,不进浏览器历史 -->
<iframe :key="currentId" :sandbox="PREVIEW_SANDBOX" :srcdoc="doc"></iframe>
</div>
<n-flex justify="space-between" align="center" class="meta">
<span class="muted">
@@ -10,6 +10,7 @@
<div v-else-if="current" class="body">
<button class="thumb" title="点击放大看" @click="showLarge = true">
<iframe
:key="current.submission_id"
:sandbox="PREVIEW_SANDBOX"
:srcdoc="doc"
tabindex="-1"
@@ -39,7 +40,7 @@
style="width: 90vw; max-width: 1000px"
>
<div class="large">
<iframe :sandbox="PREVIEW_SANDBOX" :srcdoc="doc"></iframe>
<iframe :key="current?.submission_id" :sandbox="PREVIEW_SANDBOX" :srcdoc="doc"></iframe>
</div>
<div class="large-rates">
<button
+36 -8
View File
@@ -71,11 +71,12 @@
:key="s.user_id"
:class="['card', { sel: s.submission?.id === selectedId, missing: !s.submission }]"
:disabled="!s.submission"
@click="s.submission && (selectedId = s.submission.id)"
@click="s.submission && openDetail(s.submission.id)"
>
<div class="thumb">
<iframe
v-if="s.submission"
:key="s.submission.id"
:sandbox="PREVIEW_SANDBOX"
:srcdoc="docOf(s)"
loading="lazy"
@@ -112,7 +113,7 @@
:nav="{ prev: neighbor(-1) !== null, next: neighbor(1) !== null }"
@prev="move(-1)"
@next="move(1)"
@close="selectedId = null"
@close="closeDetail"
@changed="applyPatch"
/>
</aside>
@@ -127,6 +128,7 @@
<iframe
v-if="presentList[presentIndex]"
:sandbox="PREVIEW_SANDBOX"
:key="presentIndex"
:srcdoc="docOf(presentList[presentIndex]!)"
></iframe>
</div>
@@ -152,7 +154,31 @@ const loadingWall = ref(false)
const classname = ref<string | null>((route.query.class as string) || null)
const taskId = ref<number | null>(route.query.task ? Number(route.query.task) : null)
const filter = ref<"all" | "unrated" | "missing" | "present">("all")
const selectedId = ref<string | null>(null)
// 打开的作品放在地址栏(?sub=),浏览器“后退”就能回到作品墙
const selectedId = ref<string | null>((route.query.sub as string) || null)
watch(
() => route.query.sub,
(v) => {
selectedId.value = (v as string) || null
},
)
// 是不是从作品墙点开的(点开时记了一步历史,关闭时退回那一步)
let pushedDetail = false
function openDetail(id: string) {
pushedDetail = true
router.push({ query: { ...route.query, sub: id } })
}
function closeDetail() {
if (pushedDetail) {
pushedDetail = false
router.back()
} else {
const { sub: _, ...rest } = route.query
router.replace({ query: rest })
}
}
const presenting = ref(false)
// 点开作品默认用大图(预览占满中间),老师也可以切回旁边的小面板;记住选择
const bigPreview = useStorage("review-big-preview", true)
@@ -251,7 +277,7 @@ watch(classname, (c) => {
})
watch([classname, taskId], () => {
router.replace({ query: { class: classname.value ?? undefined, task: taskId.value ?? undefined } })
selectedId.value = null
pushedDetail = false
filter.value = "all"
students.value = []
loadWall()
@@ -263,9 +289,10 @@ function neighbor(step: number): string | null {
const i = list.findIndex((s) => s.submission!.id === selectedId.value)
return list[i + step]?.submission?.id ?? null
}
// 切换学生不新增历史,免得要连按很多次后退
function move(step: number) {
const id = neighbor(step)
if (id) selectedId.value = id
if (id) router.replace({ query: { ...route.query, sub: id } })
}
function applyPatch(p: { id: string; my_score?: number; score?: number; flag?: FlagType }) {
@@ -290,9 +317,10 @@ function onKey(e: KeyboardEvent) {
}
const t = e.target as HTMLElement
if (t.closest("input, textarea, [contenteditable]") || e.ctrlKey || e.metaKey || e.altKey) return
if (e.key === "Escape" && selectedId.value) selectedId.value = null
else if (e.key === "j" || e.key === "J") move(1)
else if (e.key === "k" || e.key === "K") move(-1)
if (e.key === "Escape" && selectedId.value) closeDetail()
// 按键盘上的位置:J 在左边是上一个,K 在右边是下一个(和面板上的左右箭头一致)
else if (e.key === "j" || e.key === "J") move(-1)
else if (e.key === "k" || e.key === "K") move(1)
}
onMounted(async () => {
+1 -1
View File
@@ -24,7 +24,7 @@ async function init() {
onMounted(init)
</script>
<template>
<iframe class="iframe" :sandbox="PREVIEW_SANDBOX" :srcdoc="content"></iframe>
<iframe v-if="content" class="iframe" :sandbox="PREVIEW_SANDBOX" :srcdoc="content"></iframe>
</template>
<style scoped>
.iframe {