fix: 批改页 J/K 按键位对调;预览换内容不再进浏览器历史,“后退”能回到作品墙
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
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:
1 parent
75e20a3ae1
commit
0b3780e0cd
7 files changed
+50
-15
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user