fix(自学): 手机上读课好读多了:长代码不再被截掉半行(折行接着缩进),表格不再一字一行,填空的注释不再折成像另一行代码、空也大了;往下读时底栏收起、读到课末不再有两个「下一课」;点下一课落在新课开头而不是末尾;输出预测不再把同一个问题问两遍
Deploy / deploy (push) Canceled after 0s
Deploy / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4c47e9389b
commit
3dbdc7cb1b
8 files changed
+263
-65
No files matched your search
@@ -10,6 +10,8 @@ import { explainRuntimeError } from "oj/problem/utils/runtimeError"
|
|||||||
import { useAuthModalStore } from "shared/store/authModal"
|
import { useAuthModalStore } from "shared/store/authModal"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import { mainWrapExtensions, wrapMain } from "../wrapMain"
|
import { mainWrapExtensions, wrapMain } from "../wrapMain"
|
||||||
|
import { hangWrapExtensions } from "../hangIndent"
|
||||||
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
|
|
||||||
const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEditor.vue"))
|
const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEditor.vue"))
|
||||||
|
|
||||||
@@ -35,7 +37,12 @@ const message = useMessage()
|
|||||||
const { copy } = useClipboard({ legacy: true })
|
const { copy } = useClipboard({ legacy: true })
|
||||||
|
|
||||||
const original = computed(() => (props.wrapMain ? wrapMain(props.code) : props.code))
|
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 value = ref(original.value)
|
||||||
const input = ref("")
|
const input = ref("")
|
||||||
const running = ref(false)
|
const running = ref(false)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import ExerciseResult from "./ExerciseResult.vue"
|
import ExerciseResult from "./ExerciseResult.vue"
|
||||||
import type { Exercise, ExerciseDebugData } from "utils/types"
|
import type { Exercise, ExerciseDebugData } from "utils/types"
|
||||||
import { highlightLines } from "shared/highlight"
|
import { highlightLines } from "shared/highlight"
|
||||||
|
import { hangStyle } from "../hangIndent"
|
||||||
|
|
||||||
const props = defineProps<{ exercise: Exercise; lang?: string }>()
|
const props = defineProps<{ exercise: Exercise; lang?: string }>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -93,7 +94,7 @@ function reset() {
|
|||||||
|
|
||||||
<n-space vertical :size="6">
|
<n-space vertical :size="6">
|
||||||
<div
|
<div
|
||||||
v-for="(_line, idx) in data.lines"
|
v-for="(line, idx) in data.lines"
|
||||||
:key="idx"
|
:key="idx"
|
||||||
:style="lineStyle(idx)"
|
:style="lineStyle(idx)"
|
||||||
@click="toggle(idx)"
|
@click="toggle(idx)"
|
||||||
@@ -101,7 +102,7 @@ function reset() {
|
|||||||
<span style="color: #bbb; width: 22px; text-align: right; flex-shrink: 0">
|
<span style="color: #bbb; width: 22px; text-align: right; flex-shrink: 0">
|
||||||
{{ idx + 1 }}
|
{{ idx + 1 }}
|
||||||
</span>
|
</span>
|
||||||
<span v-html="lineHtml[idx]" style="white-space: pre; min-width: 0; overflow-x: auto" />
|
<span v-html="lineHtml[idx]" :style="{ minWidth: 0, ...hangStyle(line) }" />
|
||||||
</div>
|
</div>
|
||||||
</n-space>
|
</n-space>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import ExerciseResult from "./ExerciseResult.vue"
|
import ExerciseResult from "./ExerciseResult.vue"
|
||||||
import type { Exercise, ExerciseFillData } from "utils/types"
|
import type { Exercise, ExerciseFillData } from "utils/types"
|
||||||
import { highlightWithHoles } from "shared/highlight"
|
import { highlightWithHoles } from "shared/highlight"
|
||||||
|
import { hangStyle } from "../hangIndent"
|
||||||
|
|
||||||
const props = defineProps<{ exercise: Exercise; lang?: string }>()
|
const props = defineProps<{ exercise: Exercise; lang?: string }>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -37,6 +38,32 @@ const segments = computed<Segment[]>(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按行切开,每行自己折行、悬挂缩进(见 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 blankCount = computed(() => segments.value.filter((s) => s.type === "blank").length)
|
||||||
const userInputs = ref<string[]>([])
|
const userInputs = ref<string[]>([])
|
||||||
const wrongBlanks = ref<Set<number>>(new Set())
|
const wrongBlanks = ref<Set<number>>(new Set())
|
||||||
@@ -78,52 +105,59 @@ function inputWidth(idx: number): string {
|
|||||||
{{ data.question }}
|
{{ data.question }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<pre
|
<div
|
||||||
:style="{
|
:style="{
|
||||||
fontFamily: 'var(--learn-code-font)',
|
fontFamily: 'var(--learn-code-font)',
|
||||||
fontSize: '16px',
|
fontSize: '16px',
|
||||||
lineHeight: '1.6',
|
lineHeight: '1.9',
|
||||||
background: 'var(--learn-soft)',
|
background: 'var(--learn-soft)',
|
||||||
border: '1px solid var(--learn-line)',
|
border: '1px solid var(--learn-line)',
|
||||||
borderRadius: '6px',
|
borderRadius: '6px',
|
||||||
padding: '12px',
|
padding: '10px 12px',
|
||||||
overflowX: 'auto',
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
margin: 0,
|
|
||||||
}"
|
}"
|
||||||
><template v-for="(seg, i) in segments" :key="i"
|
>
|
||||||
><span v-if="seg.type === 'code'" v-html="seg.html" /><input
|
<div
|
||||||
v-else
|
v-for="(row, r) in lines"
|
||||||
:value="userInputs[seg.index]"
|
:key="r"
|
||||||
:disabled="allCorrect"
|
:style="{ minHeight: '1.9em', ...hangStyle(row.indent) }"
|
||||||
autocapitalize="off"
|
>
|
||||||
autocorrect="off"
|
<template v-for="(seg, i) in row.parts" :key="i"
|
||||||
autocomplete="off"
|
><span v-if="seg.type === 'code'" v-html="seg.html" /><input
|
||||||
spellcheck="false"
|
v-else
|
||||||
:style="{
|
:value="userInputs[seg.index]"
|
||||||
width: inputWidth(seg.index),
|
:disabled="allCorrect"
|
||||||
fontFamily: 'var(--learn-code-font)',
|
autocapitalize="off"
|
||||||
fontSize: '16px',
|
autocorrect="off"
|
||||||
padding: '0 6px',
|
autocomplete="off"
|
||||||
borderRadius: '3px',
|
spellcheck="false"
|
||||||
border: `1.5px solid ${
|
:style="{
|
||||||
allCorrect
|
width: inputWidth(seg.index),
|
||||||
? '#18a058'
|
fontFamily: 'var(--learn-code-font)',
|
||||||
: wrongBlanks.has(seg.index)
|
fontSize: '16px',
|
||||||
? '#d03050'
|
lineHeight: '1.4',
|
||||||
: 'var(--learn-control-line)'
|
padding: '2px 6px',
|
||||||
}`,
|
textIndent: 0,
|
||||||
background: allCorrect
|
borderRadius: '4px',
|
||||||
? 'rgba(24,160,88,0.08)'
|
border: `1.5px solid ${
|
||||||
: wrongBlanks.has(seg.index)
|
allCorrect
|
||||||
? 'rgba(208,48,80,0.07)'
|
? '#18a058'
|
||||||
: 'transparent',
|
: wrongBlanks.has(seg.index)
|
||||||
outline: 'none',
|
? '#d03050'
|
||||||
color: 'inherit',
|
: 'var(--learn-control-line)'
|
||||||
minWidth: '4ch',
|
}`,
|
||||||
}"
|
background: allCorrect
|
||||||
@input="userInputs[seg.index] = ($event.target as HTMLInputElement).value"
|
? 'rgba(24,160,88,0.08)'
|
||||||
/></template></pre>
|
: wrongBlanks.has(seg.index)
|
||||||
|
? 'rgba(208,48,80,0.07)'
|
||||||
|
: 'var(--learn-pane)',
|
||||||
|
outline: 'none',
|
||||||
|
color: 'inherit',
|
||||||
|
minWidth: '5ch',
|
||||||
|
}"
|
||||||
|
@input="userInputs[seg.index] = ($event.target as HTMLInputElement).value"
|
||||||
|
/></template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ExerciseResult
|
<ExerciseResult
|
||||||
v-if="wrongBlanks.size > 0 || allCorrect"
|
v-if="wrongBlanks.size > 0 || allCorrect"
|
||||||
|
|||||||
@@ -74,7 +74,11 @@ function reset() {
|
|||||||
}"
|
}"
|
||||||
><code style="font-family: inherit" v-html="codeHtml" /></pre>
|
><code style="font-family: inherit" v-html="codeHtml" /></pre>
|
||||||
|
|
||||||
<p style="font-weight: 500; margin: 14px 0 8px">这段代码会输出什么?</p>
|
<!-- 题目本身多半就是这句,再写一遍就成了同一个问题问两次 -->
|
||||||
|
<p v-if="!data.question.includes('输出什么')" style="font-weight: 500; margin: 14px 0 8px">
|
||||||
|
这段代码会输出什么?
|
||||||
|
</p>
|
||||||
|
<div v-else style="height: 14px" />
|
||||||
<n-input
|
<n-input
|
||||||
v-model:value="userInput"
|
v-model:value="userInput"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { Exercise, ExerciseSortData } from "utils/types"
|
|||||||
import { shuffle } from "../composables/useShuffle"
|
import { shuffle } from "../composables/useShuffle"
|
||||||
import { highlightLines } from "shared/highlight"
|
import { highlightLines } from "shared/highlight"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
|
import { hangStyle } from "../hangIndent"
|
||||||
|
|
||||||
const props = defineProps<{ exercise: Exercise; lang?: string }>()
|
const props = defineProps<{ exercise: Exercise; lang?: string }>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -140,7 +141,7 @@ const lineHtml = computed<string[]>(() => highlightLines(data.value.lines, props
|
|||||||
<span style="color: #bbb; cursor: grab">⠿</span>
|
<span style="color: #bbb; cursor: grab">⠿</span>
|
||||||
<span
|
<span
|
||||||
v-html="lineHtml[line.originalIdx]"
|
v-html="lineHtml[line.originalIdx]"
|
||||||
style="white-space: pre; min-width: 0; overflow-x: auto"
|
:style="{ minWidth: 0, ...hangStyle(line.text) }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</n-space>
|
</n-space>
|
||||||
|
|||||||
@@ -66,6 +66,15 @@ function inline(text: string) {
|
|||||||
margin: 28px 0 12px;
|
margin: 28px 0 12px;
|
||||||
scroll-margin-top: 16px;
|
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 {
|
.goals {
|
||||||
margin: 4px 0 8px;
|
margin: 4px 0 8px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
|
|||||||
@@ -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<string, string> {
|
||||||
|
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<Decoration>()
|
||||||
|
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]
|
||||||
@@ -138,7 +138,11 @@ async function init() {
|
|||||||
activeId.value = ""
|
activeId.value = ""
|
||||||
await nextTick()
|
await nextTick()
|
||||||
reader.value?.scrollTo({ top: 0 })
|
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(
|
watch(
|
||||||
@@ -178,6 +182,51 @@ function onSolved(exerciseId: number) {
|
|||||||
progress.value = { ...progress.value, [id]: { ...row, exerciseSolved: solvedCount.value } }
|
progress.value = { ...progress.value, [id]: { ...row, exerciseSolved: solvedCount.value } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ 手机底栏
|
||||||
|
// 往下读时收起上一课 / 目录 / 下一课,往回翻再出来;读到课末也收着 ——
|
||||||
|
// 课末卡片里就有「下一课」,两个摆在一起是重复的
|
||||||
|
const mobileRoot = ref<HTMLElement | null>(null)
|
||||||
|
const endRef = ref<HTMLElement | null>(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<HTMLElement | null>(null)
|
const reader = ref<HTMLElement | null>(null)
|
||||||
const activeId = ref("")
|
const activeId = ref("")
|
||||||
@@ -234,7 +283,9 @@ function jumpExercise(id: number) {
|
|||||||
|
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<div ref="reader" class="reader" @scroll.passive="onScroll">
|
<div ref="reader" class="reader" @scroll.passive="onScroll">
|
||||||
<article v-if="tutorial" class="article">
|
<!-- 按课重建:复用的话正文里的编辑器是就地换内容,CodeMirror 会把光标滚进视野,
|
||||||
|
刚滚回顶上的页面又被拽到某个代码块那儿 -->
|
||||||
|
<article v-if="tutorial" :key="tutorial.id" class="article">
|
||||||
<div class="crumb muted">
|
<div class="crumb muted">
|
||||||
<span v-if="tutorial.project">{{ tutorial.project }}</span>
|
<span v-if="tutorial.project">{{ tutorial.project }}</span>
|
||||||
<span v-if="tutorial.project">·</span>
|
<span v-if="tutorial.project">·</span>
|
||||||
@@ -302,7 +353,7 @@ function jumpExercise(id: number) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 手机 -->
|
<!-- 手机 -->
|
||||||
<div v-else class="mobile">
|
<div v-else ref="mobileRoot" class="mobile">
|
||||||
<div class="mbar">
|
<div class="mbar">
|
||||||
<n-button @click="menuOpen = true">
|
<n-button @click="menuOpen = true">
|
||||||
<template #icon><Icon icon="ph:list" /></template>
|
<template #icon><Icon icon="ph:list" /></template>
|
||||||
@@ -322,7 +373,7 @@ function jumpExercise(id: number) {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<article v-if="tutorial" class="marticle">
|
<article v-if="tutorial" :key="tutorial.id" class="marticle">
|
||||||
<div v-if="tutorial.project" class="crumb muted">{{ tutorial.project }}</div>
|
<div v-if="tutorial.project" class="crumb muted">{{ tutorial.project }}</div>
|
||||||
<h1 class="h1 small">{{ parts.kp }}</h1>
|
<h1 class="h1 small">{{ parts.kp }}</h1>
|
||||||
<div v-if="parts.scene" class="scene">{{ parts.scene }}</div>
|
<div v-if="parts.scene" class="scene">{{ parts.scene }}</div>
|
||||||
@@ -334,24 +385,26 @@ function jumpExercise(id: number) {
|
|||||||
:solved-ids="solvedIds"
|
:solved-ids="solvedIds"
|
||||||
@solved="onSolved"
|
@solved="onSolved"
|
||||||
/>
|
/>
|
||||||
<LessonEnd
|
<div ref="endRef">
|
||||||
:step="step"
|
<LessonEnd
|
||||||
:total="titles.length"
|
:step="step"
|
||||||
:course-label="courseLabel"
|
:total="titles.length"
|
||||||
:exercise-count="parsed.exerciseCount"
|
:course-label="courseLabel"
|
||||||
:solved-count="solvedCount"
|
:exercise-count="parsed.exerciseCount"
|
||||||
:first-unsolved="firstUnsolved"
|
:solved-count="solvedCount"
|
||||||
:problems="tutorial.problems"
|
:first-unsolved="firstUnsolved"
|
||||||
:next="next"
|
:problems="tutorial.problems"
|
||||||
:groups="groups"
|
:next="next"
|
||||||
:state-of="stateOf"
|
:groups="groups"
|
||||||
:done-count="done"
|
:state-of="stateOf"
|
||||||
compact
|
:done-count="done"
|
||||||
@jump="jumpExercise"
|
compact
|
||||||
/>
|
@jump="jumpExercise"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
<n-spin v-else-if="loading" class="spin" />
|
<n-spin v-else-if="loading" class="spin" />
|
||||||
<nav class="mpager">
|
<nav class="mpager" :class="{ hidden: pagerHidden || atEnd }">
|
||||||
<n-button v-if="prev" size="large" @click="router.push(prev.to)">
|
<n-button v-if="prev" size="large" @click="router.push(prev.to)">
|
||||||
<template #icon><Icon icon="ph:caret-left-bold" /></template>
|
<template #icon><Icon icon="ph:caret-left-bold" /></template>
|
||||||
上一课
|
上一课
|
||||||
@@ -374,7 +427,14 @@ function jumpExercise(id: number) {
|
|||||||
</n-button>
|
</n-button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<n-drawer v-model:show="menuOpen" placement="bottom" height="85%" class="sheet">
|
<!-- 不自动聚焦:一打开焦点框套在「自学首页」上,手机上看着像出了错 -->
|
||||||
|
<n-drawer
|
||||||
|
v-model:show="menuOpen"
|
||||||
|
placement="bottom"
|
||||||
|
height="85%"
|
||||||
|
class="sheet"
|
||||||
|
:auto-focus="false"
|
||||||
|
>
|
||||||
<n-drawer-content :native-scrollbar="false" closable>
|
<n-drawer-content :native-scrollbar="false" closable>
|
||||||
<template #header>
|
<template #header>
|
||||||
<span class="sheet-head">
|
<span class="sheet-head">
|
||||||
@@ -582,6 +642,10 @@ function jumpExercise(id: number) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: v-bind("theme.bodyColor");
|
background: v-bind("theme.bodyColor");
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.mpager.hidden {
|
||||||
|
transform: translateY(100%);
|
||||||
}
|
}
|
||||||
.sheet-head {
|
.sheet-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
Reference in new issue
Block a user