From 3dbdc7cb1b4cbe893ced707ec5a468ab7ccd459e Mon Sep 17 00:00:00 2001
From: yuetsh <517252939@qq.com>
Date: Tue, 13 Oct 2026 22:40:39 +0800
Subject: [PATCH] =?UTF-8?q?fix(=E8=87=AA=E5=AD=A6):=20=E6=89=8B=E6=9C=BA?=
=?UTF-8?q?=E4=B8=8A=E8=AF=BB=E8=AF=BE=E5=A5=BD=E8=AF=BB=E5=A4=9A=E4=BA=86?=
=?UTF-8?q?=EF=BC=9A=E9=95=BF=E4=BB=A3=E7=A0=81=E4=B8=8D=E5=86=8D=E8=A2=AB?=
=?UTF-8?q?=E6=88=AA=E6=8E=89=E5=8D=8A=E8=A1=8C=EF=BC=88=E6=8A=98=E8=A1=8C?=
=?UTF-8?q?=E6=8E=A5=E7=9D=80=E7=BC=A9=E8=BF=9B=EF=BC=89=EF=BC=8C=E8=A1=A8?=
=?UTF-8?q?=E6=A0=BC=E4=B8=8D=E5=86=8D=E4=B8=80=E5=AD=97=E4=B8=80=E8=A1=8C?=
=?UTF-8?q?=EF=BC=8C=E5=A1=AB=E7=A9=BA=E7=9A=84=E6=B3=A8=E9=87=8A=E4=B8=8D?=
=?UTF-8?q?=E5=86=8D=E6=8A=98=E6=88=90=E5=83=8F=E5=8F=A6=E4=B8=80=E8=A1=8C?=
=?UTF-8?q?=E4=BB=A3=E7=A0=81=E3=80=81=E7=A9=BA=E4=B9=9F=E5=A4=A7=E4=BA=86?=
=?UTF-8?q?=EF=BC=9B=E5=BE=80=E4=B8=8B=E8=AF=BB=E6=97=B6=E5=BA=95=E6=A0=8F?=
=?UTF-8?q?=E6=94=B6=E8=B5=B7=E3=80=81=E8=AF=BB=E5=88=B0=E8=AF=BE=E6=9C=AB?=
=?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=9C=89=E4=B8=A4=E4=B8=AA=E3=80=8C=E4=B8=8B?=
=?UTF-8?q?=E4=B8=80=E8=AF=BE=E3=80=8D=EF=BC=9B=E7=82=B9=E4=B8=8B=E4=B8=80?=
=?UTF-8?q?=E8=AF=BE=E8=90=BD=E5=9C=A8=E6=96=B0=E8=AF=BE=E5=BC=80=E5=A4=B4?=
=?UTF-8?q?=E8=80=8C=E4=B8=8D=E6=98=AF=E6=9C=AB=E5=B0=BE=EF=BC=9B=E8=BE=93?=
=?UTF-8?q?=E5=87=BA=E9=A2=84=E6=B5=8B=E4=B8=8D=E5=86=8D=E6=8A=8A=E5=90=8C?=
=?UTF-8?q?=E4=B8=80=E4=B8=AA=E9=97=AE=E9=A2=98=E9=97=AE=E4=B8=A4=E9=81=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-Authored-By: Claude Opus 5.5
---
.../src/oj/learn/components/CodeRunBlock.vue | 9 +-
.../src/oj/learn/components/ExerciseDebug.vue | 5 +-
.../src/oj/learn/components/ExerciseFill.vue | 112 ++++++++++++------
.../oj/learn/components/ExercisePredict.vue | 6 +-
.../src/oj/learn/components/ExerciseSort.vue | 3 +-
.../src/oj/learn/components/LessonBody.vue | 9 ++
apps/web/src/oj/learn/hangIndent.ts | 78 ++++++++++++
apps/web/src/oj/learn/index.vue | 106 +++++++++++++----
8 files changed, 263 insertions(+), 65 deletions(-)
create mode 100644 apps/web/src/oj/learn/hangIndent.ts
diff --git a/apps/web/src/oj/learn/components/CodeRunBlock.vue b/apps/web/src/oj/learn/components/CodeRunBlock.vue
index 4f32afa..3aa3bee 100644
--- a/apps/web/src/oj/learn/components/CodeRunBlock.vue
+++ b/apps/web/src/oj/learn/components/CodeRunBlock.vue
@@ -10,6 +10,8 @@ import { explainRuntimeError } from "oj/problem/utils/runtimeError"
import { useAuthModalStore } from "shared/store/authModal"
import { useUserStore } from "shared/store/user"
import { mainWrapExtensions, wrapMain } from "../wrapMain"
+import { hangWrapExtensions } from "../hangIndent"
+import { useBreakpoints } from "shared/composables/breakpoints"
const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEditor.vue"))
@@ -35,7 +37,12 @@ const message = useMessage()
const { copy } = useClipboard({ legacy: true })
const original = computed(() => (props.wrapMain ? wrapMain(props.code) : props.code))
-const extensions = computed(() => (props.wrapMain ? mainWrapExtensions : []))
+const { isDesktop } = useBreakpoints()
+// 手机上折行(见 hangIndent.ts);桌面正文 720 宽,长行少,照旧横着滚
+const extensions = computed(() => [
+ ...(props.wrapMain ? mainWrapExtensions : []),
+ ...(isDesktop.value ? [] : hangWrapExtensions),
+])
const value = ref(original.value)
const input = ref("")
const running = ref(false)
diff --git a/apps/web/src/oj/learn/components/ExerciseDebug.vue b/apps/web/src/oj/learn/components/ExerciseDebug.vue
index 8b5a7a1..bc96d16 100644
--- a/apps/web/src/oj/learn/components/ExerciseDebug.vue
+++ b/apps/web/src/oj/learn/components/ExerciseDebug.vue
@@ -2,6 +2,7 @@
import ExerciseResult from "./ExerciseResult.vue"
import type { Exercise, ExerciseDebugData } from "utils/types"
import { highlightLines } from "shared/highlight"
+import { hangStyle } from "../hangIndent"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const emit = defineEmits<{
@@ -93,7 +94,7 @@ function reset() {
{{ idx + 1 }}
-
+
diff --git a/apps/web/src/oj/learn/components/ExerciseFill.vue b/apps/web/src/oj/learn/components/ExerciseFill.vue
index 2e7432a..776c34b 100644
--- a/apps/web/src/oj/learn/components/ExerciseFill.vue
+++ b/apps/web/src/oj/learn/components/ExerciseFill.vue
@@ -2,6 +2,7 @@
import ExerciseResult from "./ExerciseResult.vue"
import type { Exercise, ExerciseFillData } from "utils/types"
import { highlightWithHoles } from "shared/highlight"
+import { hangStyle } from "../hangIndent"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const emit = defineEmits<{
@@ -37,6 +38,32 @@ const segments = computed(() => {
)
})
+/**
+ * 按行切开,每行自己折行、悬挂缩进(见 hangIndent.ts)。原来整段 pre-wrap,
+ * 手机上行尾的注释折下来顶格,看着像另起了一行代码。
+ * 高亮的 HTML 在换行处是断开的(见 shared/highlight.ts 的 walk),按 "\n" 切不会切坏标签。
+ */
+const lines = computed(() => {
+ const rows: { indent: string; parts: Segment[] }[] = [{ indent: "", parts: [] }]
+ for (const seg of segments.value) {
+ if (seg.type === "blank") {
+ rows[rows.length - 1].parts.push(seg)
+ continue
+ }
+ seg.html.split("\n").forEach((html, i) => {
+ if (i > 0) rows.push({ indent: "", parts: [] })
+ if (html) rows[rows.length - 1].parts.push({ type: "code", html })
+ })
+ }
+ for (const row of rows) {
+ const first = row.parts[0]
+ if (first?.type === "code") {
+ row.indent = first.html.replace(/<[^>]*>/g, "").match(/^[ \t]*/)![0]
+ }
+ }
+ return rows
+})
+
const blankCount = computed(() => segments.value.filter((s) => s.type === "blank").length)
const userInputs = ref([])
const wrongBlanks = ref>(new Set())
@@ -78,52 +105,59 @@ function inputWidth(idx: number): string {
{{ data.question }}
-
+ >
+
+
+
+
- 这段代码会输出什么?
+
+
+ 这段代码会输出什么?
+
+
()
const emit = defineEmits<{
@@ -140,7 +141,7 @@ const lineHtml = computed(() => highlightLines(data.value.lines, props
⠿
diff --git a/apps/web/src/oj/learn/components/LessonBody.vue b/apps/web/src/oj/learn/components/LessonBody.vue
index 17625d4..93b79d6 100644
--- a/apps/web/src/oj/learn/components/LessonBody.vue
+++ b/apps/web/src/oj/learn/components/LessonBody.vue
@@ -66,6 +66,15 @@ function inline(text: string) {
margin: 28px 0 12px;
scroll-margin-top: 16px;
}
+/* 手机上四列的表会把每格挤成一两个字一行(「浮」「点」「数」):给格子留个最小宽度、表头不折,
+ 放不下就整张横着滚(Markdown.vue 已经让表格自己滚) */
+.body :deep(.oj-markdown th),
+.body :deep(.oj-markdown td) {
+ min-width: 4em;
+}
+.body :deep(.oj-markdown th) {
+ white-space: nowrap;
+}
.goals {
margin: 4px 0 8px;
padding: 12px 16px;
diff --git a/apps/web/src/oj/learn/hangIndent.ts b/apps/web/src/oj/learn/hangIndent.ts
new file mode 100644
index 0000000..7e7ee60
--- /dev/null
+++ b/apps/web/src/oj/learn/hangIndent.ts
@@ -0,0 +1,78 @@
+import { RangeSetBuilder } from "@codemirror/state"
+import {
+ Decoration,
+ type DecorationSet,
+ EditorView,
+ ViewPlugin,
+ type ViewUpdate,
+} from "@codemirror/view"
+
+/**
+ * 手机上代码折行。原来是横着滚,可没有滚动条也看不出右边还有,
+ * 综合练习那种长 print 整整半行看不见。折下来的那截缩到这一行的代码下面再进两格,
+ * 一眼看得出还是同一行。
+ */
+
+/** 行首空白占几格,tab 按 4 */
+function leadingWidth(text: string): number {
+ let width = 0
+ for (const ch of text) {
+ if (ch === " ") width++
+ else if (ch === "\t") width += 4
+ else break
+ }
+ return width
+}
+
+/** 静态的代码行(练一练的排序、找错、填空):折行,接着的那截悬挂缩进 */
+export function hangStyle(text: string): Record {
+ const width = leadingWidth(text) + 2
+ return {
+ whiteSpace: "pre-wrap",
+ // 中文连着的一串别从中间断(「类」「型」各在一行),实在放不下才随便断
+ wordBreak: "keep-all",
+ overflowWrap: "anywhere",
+ paddingLeft: `${width}ch`,
+ textIndent: `-${width}ch`,
+ }
+}
+
+function hangDecorations(view: EditorView): DecorationSet {
+ const builder = new RangeSetBuilder()
+ for (const { from, to } of view.visibleRanges) {
+ for (let pos = from; pos <= to;) {
+ const line = view.state.doc.lineAt(pos)
+ const width = leadingWidth(line.text) + 2
+ // 6px 是 CodeMirror 给 .cm-line 的左内边距,盖掉了要补回来
+ builder.add(
+ line.from,
+ line.from,
+ Decoration.line({
+ attributes: {
+ style: `padding-left: calc(${width}ch + 6px); text-indent: -${width}ch; word-break: keep-all; overflow-wrap: anywhere`,
+ },
+ }),
+ )
+ pos = line.to + 1
+ }
+ }
+ return builder.finish()
+}
+
+const hangPlugin = ViewPlugin.fromClass(
+ class {
+ decorations: DecorationSet
+ constructor(view: EditorView) {
+ this.decorations = hangDecorations(view)
+ }
+ update(update: ViewUpdate) {
+ if (update.docChanged || update.viewportChanged) {
+ this.decorations = hangDecorations(update.view)
+ }
+ }
+ },
+ { decorations: (plugin) => plugin.decorations },
+)
+
+/** 编辑器(正文里能运行的代码块):折行 + 悬挂缩进 */
+export const hangWrapExtensions = [EditorView.lineWrapping, hangPlugin]
diff --git a/apps/web/src/oj/learn/index.vue b/apps/web/src/oj/learn/index.vue
index b29c091..cd61b80 100644
--- a/apps/web/src/oj/learn/index.vue
+++ b/apps/web/src/oj/learn/index.vue
@@ -138,7 +138,11 @@ async function init() {
activeId.value = ""
await nextTick()
reader.value?.scrollTo({ top: 0 })
- if (!isDesktop.value) window.scrollTo({ top: 0 })
+ // 手机上滚的是外层 n-layout 的容器,不是 window —— 原来 window.scrollTo 什么都没滚,
+ // 点「下一课」落在下一课的末尾
+ if (!isDesktop.value) scroller()?.scrollTo({ top: 0 })
+ pagerHidden.value = false
+ atEnd.value = false
}
watch(
@@ -178,6 +182,51 @@ function onSolved(exerciseId: number) {
progress.value = { ...progress.value, [id]: { ...row, exerciseSolved: solvedCount.value } }
}
+// ------------------------------------------------------------------ 手机底栏
+// 往下读时收起上一课 / 目录 / 下一课,往回翻再出来;读到课末也收着 ——
+// 课末卡片里就有「下一课」,两个摆在一起是重复的
+const mobileRoot = ref(null)
+const endRef = ref(null)
+const pagerHidden = ref(false)
+const atEnd = ref(false)
+
+/** 往上找真正在滚的那层。n-layout-content 自己也带 .n-layout-scroll-container 类却不滚,按类名找会找错 */
+function scroller(): HTMLElement | null {
+ for (let el = mobileRoot.value?.parentElement; el; el = el.parentElement) {
+ const overflow = getComputedStyle(el).overflowY
+ if ((overflow === "auto" || overflow === "scroll") && el.scrollHeight > el.clientHeight) {
+ return el
+ }
+ }
+ return null
+}
+
+let lastTop = 0
+function onMobileScroll(event: Event) {
+ const box = event.target as HTMLElement
+ const top = box.scrollTop
+ if (top < 60) pagerHidden.value = false
+ else if (top > lastTop + 6) pagerHidden.value = true
+ else if (top < lastTop - 6) pagerHidden.value = false
+ lastTop = top
+ const end = endRef.value?.getBoundingClientRect().top
+ atEnd.value = end !== undefined && end < box.getBoundingClientRect().bottom
+}
+
+// 滚动事件不冒泡,在 document 上用捕获拿,不用先找到容器再挂
+useEventListener(
+ document,
+ "scroll",
+ (event: Event) => {
+ const box = event.target
+ if (isDesktop.value || !(box instanceof HTMLElement)) return
+ // 只认包着本页的那层(目录抽屉、代码块里横着滚的不算)
+ if (!mobileRoot.value || !box.contains(mobileRoot.value)) return
+ onMobileScroll(event)
+ },
+ { capture: true, passive: true },
+)
+
// ------------------------------------------------------------------ 读到哪一节
const reader = ref(null)
const activeId = ref("")
@@ -234,7 +283,9 @@ function jumpExercise(id: number) {
-
+
+
{{ tutorial.project }}
·
@@ -302,7 +353,7 @@ function jumpExercise(id: number) {
-
+
@@ -322,7 +373,7 @@ function jumpExercise(id: number) {
-
+
{{ tutorial.project }}
{{ parts.kp }}
{{ parts.scene }}
@@ -334,24 +385,26 @@ function jumpExercise(id: number) {
:solved-ids="solvedIds"
@solved="onSolved"
/>
-
+
+
+
-