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" /> - +
+ +
-