feat(题目页): 手机上写代码顺手多了:键盘上面多一排常用符号(括号、引号、冒号、缩进…点一下就进去,括号自动配对),代码字号改小、长行折行不再看不见右半截;输入输出说明上下排、例子不再被左边两列标签挤窄;题单里那条不再超出屏幕
Deploy / deploy (push) Canceled after 0s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-13 22:56:02 +08:00
1 parent 3dbdc7cb1b
commit 7e68e11371
13 files changed
+262 -16

No files matched your search

@@ -311,7 +311,8 @@ onSolvedHere(() => {
<router-link :to="setBar.home" class="back set-back" :title="`回到题单:${setBar.title}`">
‹ {{ setBar.title }}
</router-link>
<div v-if="setBar.joined" class="set-progress" aria-hidden="true">
<!-- 手机上一排放不下题单名 + 进度条 + 做完几道 + 两个大箭头,进度条让掉,「做完 x/y」还在 -->
<div v-if="setBar.joined && !isMobile" class="set-progress" aria-hidden="true">
<div class="set-progress-fill" :style="{ width: `${setBar.percentage}%` }" />
</div>
<span v-if="setBar.joined" class="meta">
@@ -319,7 +320,9 @@ onSolvedHere(() => {
<template v-if="setBar.position"> · 第 {{ setBar.position }} 题</template>
</span>
<template v-else>
<span class="meta">还没加入,在这里做对了不算进度</span>
<span class="meta">{{
isMobile ? "没加入,不算进度" : "还没加入,在这里做对了不算进度"
}}</span>
<n-button size="tiny" type="primary" :loading="joiningSet" @click="joinHere"
>加入题单</n-button
>
@@ -5,6 +5,7 @@ import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import { useSubmissionStore } from "oj/store/submission"
import SubmitCode from "./SubmitCode.vue"
import { editorFocused } from "../utils/symbolKeys"
const SubmitFlowchart = defineAsyncComponent(() => import("./SubmitFlowchart.vue"))
@@ -29,7 +30,8 @@ const canRunSamples = computed(
</script>
<template>
<div class="mobile-actions">
<!-- 写代码时(键盘弹着)让位给符号键:两条都贴底会叠在一起,安卓上还会被键盘顶上来 -->
<div v-show="!editorFocused" class="mobile-actions">
<n-button
v-if="canRunSamples"
size="large"
@@ -154,10 +154,16 @@ const sampleTextWidth = computed(() => {
const sampleTable = useTemplateRef<HTMLElement>("sampleTable")
const { width: sampleTableWidth } = useElementSize(sampleTable)
/**
* 窄(手机,或桌面左栏拖到最窄):「例子 N」不占一列,改成每个例子头上一行小字。
* 390 宽的手机上那一列连同间距要吃掉 64px,框只剩一半多
*/
const sampleNarrow = computed(() => sampleTableWidth.value > 0 && sampleTableWidth.value < 480)
/** 摆成表时,半格里留给文字的宽度 */
const sampleHalfWidth = computed(
() =>
(sampleTableWidth.value - SAMPLE_NO_W - SAMPLE_GAP * 2) / 2 -
(sampleTableWidth.value - (sampleNarrow.value ? 0 : SAMPLE_NO_W + SAMPLE_GAP) - SAMPLE_GAP) /
2 -
SAMPLE_PAD_L -
SAMPLE_PAD_R -
SAMPLE_COPY_W,
@@ -179,7 +185,7 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
</script>
<template>
<div v-if="problem">
<div v-if="problem" class="problem-content">
<header class="problem-head">
<div class="title-row">
<span class="pid">{{ problem._id }}</span>
@@ -329,12 +335,12 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
<div
ref="sampleTable"
class="sample-table"
:class="{ stacked: samplesStacked }"
:class="{ stacked: samplesStacked, narrow: sampleNarrow }"
role="table"
aria-label="例子"
>
<div v-if="!samplesStacked" class="sample-row" role="row">
<span role="columnheader"></span>
<span v-if="!sampleNarrow" role="columnheader"></span>
<span role="columnheader" class="sample-label">输入</span>
<span role="columnheader" class="sample-label">输出</span>
</div>
@@ -437,6 +443,10 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
margin: 0;
}
.problem-content {
container: problem-content / inline-size;
}
.block-link {
margin-left: auto;
}
@@ -460,6 +470,13 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
gap: 16px;
}
/* 窄的时候输入、输出上下排:并排的话长一点的说明挤成一字两行的窄条,旁边一栏空着 */
@container problem-content (max-width: 480px) {
.io {
grid-template-columns: minmax(0, 1fr);
}
}
/* 语法要求:一行的浅绿框,硬性的判定条件,放在最前面 */
.requirements {
padding: 8px 12px;
@@ -584,6 +601,26 @@ const astRequirements = computed(() => Object.entries(problem.value?.astRequirem
line-height: 1.5;
}
/* 窄:「例子 N」占满一行当小标题,下面输入、输出(并排或上下排) */
.narrow .sample-row {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
.narrow.stacked .sample-row {
grid-template-columns: 32px minmax(0, 1fr);
}
.narrow .sample-no,
.narrow.stacked .sample-no {
grid-column: 1 / -1;
grid-row: auto;
padding-top: 4px;
}
.sample-table.narrow {
gap: 10px;
}
.sample-cell {
min-width: 0;
display: flex;
@@ -10,6 +10,8 @@ import storage from "utils/storage"
import type { LANGUAGE } from "utils/types"
import { beginEditTrace, editTraceExtensions } from "oj/problem/utils/editTrace"
import { errorMarkExtensions } from "oj/problem/utils/errorMark"
import { editorFocusTracker } from "oj/problem/utils/symbolKeys"
import { hangWrapExtensions } from "shared/extensions/hangIndent"
import EditorToolbar from "./EditorToolbar.vue"
import { useDraftKey } from "../composables/draftKey"
import { useCollabHere, useTeacherCollab } from "../composables/teacherCollab"
@@ -85,8 +87,11 @@ watch(
const { isDesktop } = useBreakpoints()
// 拼一次、留同一个数组:每次渲染新建会让编辑器反复重配扩展
const editorExtensions = [...editTraceExtensions, ...errorMarkExtensions]
// 拼一次、留同一个数组:每次渲染新建会让编辑器反复重配扩展。
// 手机上另加折行(20px 一行只放得下二十来个字,长行看不见右半截)
const desktopExtensions = [...editTraceExtensions, ...errorMarkExtensions, editorFocusTracker]
const mobileExtensions = [...desktopExtensions, ...hangWrapExtensions]
const editorExtensions = computed(() => (isDesktop.value ? desktopExtensions : mobileExtensions))
const contestID = route.params.contestID || null
// 草稿存在哪个键下(题单入口单独一份),和「重置代码」共用一份算法
@@ -223,6 +228,7 @@ onBeforeUnmount(() => flowchartStore.attachEditor(null))
:collab="ctx.collab"
:height="editorHeight"
:extra-extensions="editorExtensions"
:font-size="isDesktop ? 20 : 15"
@update:model-value="changeCode"
/>
</div>
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { useCodeStore } from "oj/store/code"
import { editorFocused, SYMBOL_KEYS, typeSymbol } from "../utils/symbolKeys"
/**
* 手机上贴着键盘顶的一排符号键,编辑器有焦点(键盘弹着)时才出来。
*
* 位置按 visualViewport 算:iOS 弹键盘时布局视口不变,position: fixed; bottom: 0 的东西
* 会被键盘盖住,只有可视视口的底边才是键盘的上沿。
* 按下时 preventDefault:不抢编辑器的焦点,键盘不收起来。
*/
const theme = useThemeVars()
const codeStore = useCodeStore()
const keys = computed(() => {
const language = codeStore.code.language
if (language === "SQL") return SYMBOL_KEYS.sql
if (language === "Python") return SYMBOL_KEYS.python
if (language === "C" || language === "C++") return SYMBOL_KEYS.c
return []
})
const BAR_HEIGHT = 44
const top = ref<number | null>(null)
function place() {
const viewport = window.visualViewport
top.value = viewport ? viewport.offsetTop + viewport.height - BAR_HEIGHT : null
}
useEventListener(() => window.visualViewport, "resize", place)
useEventListener(() => window.visualViewport, "scroll", place)
watch(editorFocused, (focused) => focused && place(), { immediate: true })
// 键盘弹着时点了别处换题、离开页面,编辑器直接卸载,不一定等得到失焦
onBeforeUnmount(() => {
editorFocused.value = false
})
</script>
<template>
<div
v-if="editorFocused && keys.length"
class="symbol-keys"
:style="top === null ? { bottom: '0px' } : { top: `${top}px` }"
role="toolbar"
aria-label="常用符号"
>
<button
v-for="key in keys"
:key="key.label"
type="button"
class="key"
:class="{ wide: key.label.length > 1 }"
@pointerdown.prevent
@mousedown.prevent
@click="typeSymbol(key)"
>
{{ key.label }}
</button>
</div>
</template>
<style scoped>
.symbol-keys {
position: fixed;
left: 0;
right: 0;
z-index: 20;
height: 44px;
box-sizing: border-box;
padding: 5px 8px;
display: flex;
gap: 6px;
overflow-x: auto;
scrollbar-width: none;
background-color: v-bind("theme.bodyColor");
border-top: 1px solid rgba(128, 128, 128, 0.2);
}
.symbol-keys::-webkit-scrollbar {
display: none;
}
.key {
flex: none;
min-width: 36px;
height: 34px;
padding: 0 6px;
border: 1px solid rgba(128, 128, 128, 0.3);
border-radius: 6px;
background-color: rgba(128, 128, 128, 0.06);
font-family: Consolas, Monaco, monospace;
font-size: 17px;
color: inherit;
touch-action: manipulation;
}
.key.wide {
font-family: inherit;
font-size: 14px;
padding: 0 10px;
}
.key:active {
background-color: rgba(24, 160, 88, 0.15);
}
</style>