Compare commits

...
2 Commits
Author SHA1 Message Date
xuyueandClaude Opus 5.5 cea2c7f102 fix(手机): 比赛排名表看不出能往右滑;班级 PK 每周图日期字太小;后台题目列表「藏着的」被切掉
Deploy / deploy (push) Canceled after 0s
- 比赛排名:手机上名字、做对、用时就占满一屏,每题的格子全在屏幕外。列收窄,前一两道题的格子露出来
- 班级 PK:每周人均横轴在手机上是 10px 的「10月12日」,改成 12px 的「10/12」
- 后台题目列表:手机上「全部 / 学生看得到 / 藏着的」换小号按钮,放不下就横着滑

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:44:57 +08:00
xuyueandClaude Opus 5.5 a5d5c39411 fix(手机): 收起键盘后「提交」按钮不回来;比赛领奖台第一名被挤出屏幕;iPhone 点输入框整页放大
- 题目页:安卓按返回键收键盘时编辑器不失焦,符号键一直贴底、「运行例子 / 提交」不回来。
  改成看键盘真弹着没有(可视视口变矮),不只看焦点
- 比赛排名:结束后的领奖台三块共 646px,手机上第一名被切掉、第三名看不见;改成平分一行
- iPhone:点 14/15px 的输入框和代码编辑器,Safari 会整页放大且不缩回;只给 iOS 加 maximum-scale=1
- 登录:手机上选完班不再自动聚焦搜索框(键盘弹出来盖住半张名单)
- 题单:窄屏上题目拆成两张没标题的卡片,像分了两组;放不下两栏时合成一张
- 「中等」在题目页是蓝色、在题目列表是橙色,统一成橙色
- 零碎:自学四列表格在 360 宽上收窄格子;统计「错得最多的题」表头不再挤成竖排;
  「第 N 名」「14 道」不再把最后一个字甩到下一行

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:36:33 +08:00
14 changed files with 199 additions and 35 deletions

No files matched your search

+9 -1
View File
@@ -96,6 +96,8 @@ const { query, clearQuery } = usePagination<ProblemQuery>({
}) })
const counts = ref<{ all: number; visible: number; hidden: number } | null>(null) const counts = ref<{ all: number; visible: number; hidden: number } | null>(null)
// 手机上后台左边还有一列图标,三个按钮放不下、「藏着的」被切掉:换小号,再不够就横着滑
const narrow = useMediaQuery("(max-width: 640px)")
const visibleTabs = computed(() => [ const visibleTabs = computed(() => [
{ value: "" as const, label: "全部", count: counts.value?.all }, { value: "" as const, label: "全部", count: counts.value?.all },
{ value: "1" as const, label: "学生看得到", count: counts.value?.visible }, { value: "1" as const, label: "学生看得到", count: counts.value?.visible },
@@ -324,10 +326,11 @@ watch(() => [query.page, query.limit, query.author, query.kind, query.visible],
<n-text depth="3">出题人</n-text> <n-text depth="3">出题人</n-text>
<AuthorSelect v-model:value="query.author" all /> <AuthorSelect v-model:value="query.author" all />
</n-flex> </n-flex>
<n-button-group v-if="!props.contestID"> <n-button-group v-if="!props.contestID" class="visible-tabs">
<n-button <n-button
v-for="tab in visibleTabs" v-for="tab in visibleTabs"
:key="tab.value" :key="tab.value"
:size="narrow ? 'small' : 'medium'"
:type="query.visible === tab.value ? 'primary' : 'default'" :type="query.visible === tab.value ? 'primary' : 'default'"
:secondary="query.visible === tab.value" :secondary="query.visible === tab.value"
@click="query.visible = tab.value" @click="query.visible = tab.value"
@@ -388,6 +391,11 @@ watch(() => [query.page, query.limit, query.author, query.kind, query.visible],
flex: 1 1 auto; flex: 1 1 auto;
} }
.visible-tabs {
max-width: 100%;
overflow-x: auto;
}
.tab-count { .tab-count {
margin-left: 6px; margin-left: 6px;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
+14
View File
@@ -8,6 +8,20 @@ import storage from "utils/storage"
import App from "./App.vue" import App from "./App.vue"
import { admins, ojs } from "./routes" import { admins, ojs } from "./routes"
/**
* iPhone 上点进字号小于 16px 的输入框(Naive 的输入框是 14px、代码编辑器 15px),
* Safari 会自动把整页放大,而且退出输入也不缩回去,得学生自己双指缩。
* maximum-scale=1 能关掉这个自动放大;iOS 10 起照样允许双指缩放,所以只给 iOS 加 ——
* 安卓上这么写会真的禁掉双指放大,看题面图片、代码就没法放大了。
*/
const isIOS =
/iPhone|iPad|iPod/.test(navigator.userAgent) ||
(navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1)
if (isIOS) {
const viewport = document.querySelector('meta[name="viewport"]')
viewport?.setAttribute("content", "width=device-width, initial-scale=1.0, maximum-scale=1")
}
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: [ojs, admins], routes: [ojs, admins],
+6 -1
View File
@@ -20,7 +20,12 @@ const cells = computed(() => {
const percent = r.solved ? Math.round((r.firstTry / r.solved) * 100) : 0 const percent = r.solved ? Math.round((r.firstTry / r.solved) * 100) : 0
const firstSub = `一次就对 · 占 ${percent}%` const firstSub = `一次就对 · 占 ${percent}%`
const list: Cell[] = [ const list: Cell[] = [
{ big: r.solved, unit: "道", sub: c ? `做对 · 班上中间那位 ${c.medianSolved} 道` : "做对" }, // 数和「道」之间用不换行空格:手机上两列挤,单个「道」会被甩到下一行
{
big: r.solved,
unit: "道",
sub: c ? `做对 · 班上中间那位 ${c.medianSolved}\u00a0道` : "做对",
},
{ {
big: r.firstTry, big: r.firstTry,
unit: "道", unit: "道",
+3 -2
View File
@@ -80,7 +80,8 @@ const shownRows = computed(() =>
const weeks = computed(() => { const weeks = computed(() => {
const from = Math.max(0, props.pk.weeks.length - 8) const from = Math.max(0, props.pk.weeks.length - 8)
const list = props.pk.weeks.slice(from).map((week, k) => ({ const list = props.pk.weeks.slice(from).map((week, k) => ({
label: parseTime(week, "M月D日"), // 手机上八根柱挤在一行,「10月12日」只能用 10px 才放得下,太小;改短写法换大一号字
label: parseTime(week, props.compact ? "M/D" : "M月D日"),
values: [left.value.weekly[from + k] ?? 0, right.value.weekly[from + k] ?? 0], values: [left.value.weekly[from + k] ?? 0, right.value.weekly[from + k] ?? 0],
})) }))
const max = Math.max(1, ...list.flatMap((week) => week.values)) const max = Math.max(1, ...list.flatMap((week) => week.values))
@@ -779,6 +780,6 @@ const tab = ref<"weekly" | "dist">("weekly")
} }
.compact .axis { .compact .axis {
font-size: 10px; font-size: 12px;
} }
</style> </style>
+60 -7
View File
@@ -97,10 +97,16 @@ const meBackground = computed(
`linear-gradient(${success.value.background}, ${success.value.background}), ${theme.value.cardColor}`, `linear-gradient(${success.value.background}, ${success.value.background}), ${theme.value.cardColor}`,
) )
const danger = computed(() => tone("error")) const danger = computed(() => tone("error"))
const columns = computed( // 手机上照桌面的列宽,名字 + 做对 + 用时就占满一屏,每题的格子全在屏幕外,看不出能往右滑。
() => // 收窄到第一两道题的格子露出半截
`40px ${running.value ? "36px " : ""}170px 48px 80px repeat(${problems.value.length}, 64px)`, const narrow = useMediaQuery("(max-width: 640px)")
) const columns = computed(() => {
const move = running.value ? "36px " : ""
const count = problems.value.length
return narrow.value
? `28px ${move}112px 36px 70px repeat(${count}, 52px)`
: `40px ${move}170px 48px 80px repeat(${count}, 64px)`
})
</script> </script>
<template> <template>
@@ -137,9 +143,13 @@ const columns = computed(
</svg> </svg>
<template v-if="podium[place - 1]"> <template v-if="podium[place - 1]">
<UserName class="podium-name" :username="podium[place - 1]!.username" /> <UserName class="podium-name" :username="podium[place - 1]!.username" />
<span class="muted small"> <span class="muted small podium-meta">
{{ classLabel(podium[place - 1]!.className) }} · {{ podium[place - 1]!.solved }} 道 · <span>{{ classLabel(podium[place - 1]!.className) }}</span
{{ secondsToDuration(podium[place - 1]!.totalTime) }} ><span class="sep"> · </span
><span
>{{ podium[place - 1]!.solved }} 道 ·
{{ secondsToDuration(podium[place - 1]!.totalTime) }}</span
>
</span> </span>
</template> </template>
<div class="block">{{ place }}</div> <div class="block">{{ place }}</div>
@@ -534,4 +544,47 @@ const columns = computed(
.empty { .empty {
padding: 32px var(--oj-pad-x); padding: 32px var(--oj-pad-x);
} }
/* 手机:三级台阶一共 646px 宽,原样摆第一名就被挤到屏幕外了。三块平分一行,
名字截断、班级和用时折成两行 */
@media (max-width: 640px) {
.podium-row {
gap: 16px;
}
.podium {
width: 100%;
gap: 6px;
}
.step {
flex: 1 1 0;
width: auto;
min-width: 0;
}
.podium-name,
.p1 .podium-name {
font-size: 15px;
max-width: 100%;
}
.podium-meta {
font-size: var(--oj-fs-meta);
text-align: center;
}
.podium-meta > span {
display: block;
white-space: nowrap;
}
.podium-meta > .sep {
display: none;
}
.mine {
width: 100%;
}
}
</style> </style>
@@ -75,6 +75,14 @@ function inline(text: string) {
.body :deep(.oj-markdown th) { .body :deep(.oj-markdown th) {
white-space: nowrap; white-space: nowrap;
} }
/* 360 宽的安卓机上四列表差三十来像素,最后一列被切掉、得横着滑才看得到:格子收窄一点 */
@media (max-width: 480px) {
.body :deep(.oj-markdown th),
.body :deep(.oj-markdown td) {
min-width: 3.5em;
padding: 6px 8px;
}
}
.goals { .goals {
margin: 4px 0 8px; margin: 4px 0 8px;
padding: 12px 16px; padding: 12px 16px;
@@ -5,7 +5,7 @@ import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem" import { useProblemStore } from "oj/store/problem"
import { useSubmissionStore } from "oj/store/submission" import { useSubmissionStore } from "oj/store/submission"
import SubmitCode from "./SubmitCode.vue" import SubmitCode from "./SubmitCode.vue"
import { editorFocused } from "../utils/symbolKeys" import { typing } from "../utils/symbolKeys"
const SubmitFlowchart = defineAsyncComponent(() => import("./SubmitFlowchart.vue")) const SubmitFlowchart = defineAsyncComponent(() => import("./SubmitFlowchart.vue"))
@@ -30,8 +30,9 @@ const canRunSamples = computed(
</script> </script>
<template> <template>
<!-- 写代码时(键盘弹着)让位给符号键:两条都贴底会叠在一起,安卓上还会被键盘顶上来 --> <!-- 写代码时(键盘弹着)让位给符号键:两条都贴底会叠在一起,安卓上还会被键盘顶上来。
<div v-show="!editorFocused" class="mobile-actions"> 按返回键收了键盘、编辑器还有焦点时要回来,所以看 typing 不看焦点 -->
<div v-show="!typing" class="mobile-actions">
<n-button <n-button
v-if="canRunSamples" v-if="canRunSamples"
size="large" size="large"
@@ -1,7 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { useThemeVars } from "naive-ui" import { useThemeVars } from "naive-ui"
import { useCodeStore } from "oj/store/code" import { useCodeStore } from "oj/store/code"
import { editorFocused, SYMBOL_KEYS, typeSymbol } from "../utils/symbolKeys" import {
editorFocused,
measureKeyboard,
SYMBOL_KEYS,
typeSymbol,
typing,
} from "../utils/symbolKeys"
/** /**
* 手机上贴着键盘顶的一排符号键,编辑器有焦点(键盘弹着)时才出来。 * 手机上贴着键盘顶的一排符号键,编辑器有焦点(键盘弹着)时才出来。
@@ -29,9 +35,19 @@ function place() {
top.value = viewport ? viewport.offsetTop + viewport.height - BAR_HEIGHT : null top.value = viewport ? viewport.offsetTop + viewport.height - BAR_HEIGHT : null
} }
useEventListener(() => window.visualViewport, "resize", place) // 键盘弹起之前就得量出「没键盘时多高」,所以挂上就量
onMounted(measureKeyboard)
useEventListener("resize", measureKeyboard)
useEventListener(
() => window.visualViewport,
"resize",
() => {
measureKeyboard()
place()
},
)
useEventListener(() => window.visualViewport, "scroll", place) useEventListener(() => window.visualViewport, "scroll", place)
watch(editorFocused, (focused) => focused && place(), { immediate: true }) watch(typing, (on) => on && place(), { immediate: true })
// 键盘弹着时点了别处换题、离开页面,编辑器直接卸载,不一定等得到失焦 // 键盘弹着时点了别处换题、离开页面,编辑器直接卸载,不一定等得到失焦
onBeforeUnmount(() => { onBeforeUnmount(() => {
editorFocused.value = false editorFocused.value = false
@@ -40,7 +56,7 @@ onBeforeUnmount(() => {
<template> <template>
<div <div
v-if="editorFocused && keys.length" v-if="typing && keys.length"
class="symbol-keys" class="symbol-keys"
:style="top === null ? { bottom: '0px' } : { top: `${top}px` }" :style="top === null ? { bottom: '0px' } : { top: `${top}px` }"
role="toolbar" role="toolbar"
+31 -2
View File
@@ -1,6 +1,6 @@
import { insertBracket } from "@codemirror/autocomplete" import { insertBracket } from "@codemirror/autocomplete"
import { EditorView } from "@codemirror/view" import { EditorView } from "@codemirror/view"
import { ref } from "vue" import { computed, ref } from "vue"
import { currentEditorView } from "./errorMark" import { currentEditorView } from "./errorMark"
/** /**
@@ -8,7 +8,7 @@ import { currentEditorView } from "./errorMark"
* 一行 print(int(input())) 来回切七八次。 * 一行 print(int(input())) 来回切七八次。
*/ */
/** 题目页主编辑器有没有焦点 —— 有焦点就是键盘弹着,这时才摆符号键 */ /** 题目页主编辑器有没有焦点 */
export const editorFocused = ref(false) export const editorFocused = ref(false)
export const editorFocusTracker = EditorView.focusChangeEffect.of((_state, focusing) => { export const editorFocusTracker = EditorView.focusChangeEffect.of((_state, focusing) => {
@@ -16,6 +16,35 @@ export const editorFocusTracker = EditorView.focusChangeEffect.of((_state, focus
return null return null
}) })
/**
* 软键盘是不是弹着。光看焦点不够:安卓按返回键收键盘,编辑器**不失焦**,
* 符号键就一直贴在屏幕底下,「运行例子 / 提交」也不回来,学生找不到地方交。
*
* 键盘弹起时可视视口变矮(iOS 和 Chrome 108+ 布局视口不变;更老的安卓连
* innerHeight 一起变矮),所以和这个宽度下见过的最高值比。宽度变了(转屏)重新量。
* 乘 scale 是为了双指放大时别误判成键盘弹着。
*/
const keyboardUp = ref(false)
let baseline = 0
let baselineWidth = 0
export function measureKeyboard() {
const viewport = window.visualViewport
if (!viewport) return
if (window.innerWidth !== baselineWidth) {
baselineWidth = window.innerWidth
baseline = 0
}
const visible = viewport.height * viewport.scale
baseline = Math.max(baseline, window.innerHeight, visible)
keyboardUp.value = visible < baseline - 120
}
/** 正在打字:编辑器有焦点、键盘也弹着。这时才摆符号键,底栏让位 */
export const typing = computed(
() => editorFocused.value && (!window.visualViewport || keyboardUp.value),
)
export interface SymbolKey { export interface SymbolKey {
label: string label: string
/** 插进去的字;不写就是 label */ /** 插进去的字;不写就是 label */
+11 -6
View File
@@ -83,7 +83,12 @@ const spent = computed(() => {
return `${Math.round(minutes / 1440)} 天` return `${Math.round(minutes / 1440)} 天`
}) })
// 两栏放得下(每栏 420 起)才拆;拆完在窄屏上叠起来,就成了两张没有标题的卡片,
// 看着像题单分了两组
const twoColumns = useMediaQuery("(min-width: 960px)")
const columns = computed(() => { const columns = computed(() => {
if (!twoColumns.value) return [problems.value]
const half = Math.ceil(problems.value.length / 2) const half = Math.ceil(problems.value.length / 2)
return [problems.value.slice(0, half), problems.value.slice(half)].filter((col) => col.length) return [problems.value.slice(0, half), problems.value.slice(half)].filter((col) => col.length)
}) })
@@ -232,7 +237,7 @@ const purple = { color: "#5b3fa8", background: "rgba(122, 95, 208, 0.12)" }
<ClassView v-if="userStore.isTeacherOrAbove && tab === 'class'" :problem-set-id="set.id" /> <ClassView v-if="userStore.isTeacherOrAbove && tab === 'class'" :problem-set-id="set.id" />
<template v-else> <template v-else>
<div v-if="problems.length" class="cols"> <div v-if="problems.length" class="cols" :class="{ two: twoColumns }">
<section v-for="(col, ci) in columns" :key="ci" class="card list"> <section v-for="(col, ci) in columns" :key="ci" class="card list">
<component <component
:is="canOpen ? 'router-link' : 'div'" :is="canOpen ? 'router-link' : 'div'"
@@ -461,11 +466,15 @@ const purple = { color: "#5b3fa8", background: "rgba(122, 95, 208, 0.12)" }
.cols { .cols {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); grid-template-columns: minmax(0, 1fr);
gap: 16px; gap: 16px;
align-items: start; align-items: start;
} }
.cols.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.card { .card {
border: 1px solid v-bind("theme.borderColor"); border: 1px solid v-bind("theme.borderColor");
border-radius: var(--oj-radius); border-radius: var(--oj-radius);
@@ -622,10 +631,6 @@ a.prow:hover {
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.cols {
grid-template-columns: minmax(0, 1fr);
}
.band { .band {
padding: 16px; padding: 16px;
} }
+3 -2
View File
@@ -53,13 +53,14 @@ const headline = computed(() => {
return props.lastListed ? `再做对 ${toPass(me, props.lastListed)} 道就进前 ${props.cap}` : "" return props.lastListed ? `再做对 ${toPass(me, props.lastListed)} 道就进前 ${props.cap}` : ""
if (me.rank === 1) return "你是第一名" if (me.rank === 1) return "你是第一名"
if (me.rank <= 3 && ahead) if (me.rank <= 3 && ahead)
return `你在领奖台上 · 再做对 ${toPass(me, ahead)} 道就是第 ${ahead.rank} 名` // 「第 N 名」用不换行空格连住:手机上这一句正好折行,单个「名」会掉到下一行
return `你在领奖台上 · 再做对 ${toPass(me, ahead)} 道就是第\u00a0${ahead.rank}\u00a0名`
if (third) { if (third) {
const need = toPass(me, third) const need = toPass(me, third)
if (need <= 3) return `再做对 ${need} 道,你就上领奖台` if (need <= 3) return `再做对 ${need} 道,你就上领奖台`
} }
if ((me.change ?? 0) >= 3) return `比周一升了 ${me.change} 名` if ((me.change ?? 0) >= 3) return `比周一升了 ${me.change} 名`
if (ahead) return `再做对 ${toPass(me, ahead)} 道就是第 ${ahead.rank} 名` if (ahead) return `再做对 ${toPass(me, ahead)} 道就是第\u00a0${ahead.rank}\u00a0名`
return "" return ""
}) })
@@ -572,5 +572,16 @@ function percent(value: number, total: number) {
display: block; display: block;
overflow-x: auto; overflow-x: auto;
} }
/* 表横着滑,表头别被挤成竖排(「提 / 交」) */
.hard th {
white-space: nowrap;
}
.section-title {
flex-wrap: wrap;
row-gap: 2px;
}
.section-title b {
flex: none;
}
} }
</style> </style>
+15 -4
View File
@@ -123,7 +123,7 @@ watch(klass, (value) => {
clearError() clearError()
if (value) { if (value) {
loadNames(value) loadNames(value)
focusLater(() => searchRef.value) focusSearch()
} else names.value = [] } else names.value = []
}) })
@@ -137,6 +137,15 @@ function focusLater(target: () => { focus: () => void } | null | undefined) {
nextTick(() => target()?.focus()) nextTick(() => target()?.focus())
} }
/**
* 手机上不替学生点搜索框:一聚焦键盘就弹出来,把下面的名单盖掉一半,
* 而手机上本来就是直接点名字更快。电脑上照旧聚焦,进来就能打拼音缩写。
*/
const touchScreen = useMediaQuery("(pointer: coarse)")
function focusSearch() {
if (!touchScreen.value) focusLater(() => searchRef.value)
}
function pick(name: string) { function pick(name: string) {
picked.value = name picked.value = name
password.value = "" password.value = ""
@@ -148,7 +157,7 @@ function unpick() {
picked.value = null picked.value = null
password.value = "" password.value = ""
clearError() clearError()
focusLater(() => searchRef.value) focusSearch()
} }
/** 搜到只剩一个人,回车就是点他 */ /** 搜到只剩一个人,回车就是点他 */
@@ -163,7 +172,8 @@ function switchMode(next: Mode) {
password.value = "" password.value = ""
query.value = "" query.value = ""
clearError() clearError()
focusLater(() => (next === "user" ? usernameRef.value : searchRef.value)) if (next === "user") focusLater(() => usernameRef.value)
else focusSearch()
} }
function goSignup() { function goSignup() {
@@ -249,7 +259,8 @@ watch(
query.value = "" query.value = ""
clearError() clearError()
restoreLast() restoreLast()
focusLater(() => (mode.value === "user" ? usernameRef.value : searchRef.value)) if (mode.value === "user") focusLater(() => usernameRef.value)
else focusSearch()
}, },
{ immediate: true }, { immediate: true },
) )
+4 -3
View File
@@ -68,12 +68,13 @@ export function filterEmptyValue<T extends Record<string, any>>(object: T): Part
} }
export function getTagColor(tag: "Low" | "Mid" | "High" | "简单" | "中等" | "困难") { export function getTagColor(tag: "Low" | "Mid" | "High" | "简单" | "中等" | "困难") {
return <"success" | "info" | "error">{ // 中等是橙色,和题目列表、相似题推荐一个颜色(原来这里是蓝,同一道题进了详情页就变色)
return <"success" | "warning" | "error">{
Low: "success", Low: "success",
Mid: "info", Mid: "warning",
High: "error", High: "error",
简单: "success", 简单: "success",
中等: "info", 中等: "warning",
困难: "error", 困难: "error",
}[tag] }[tag]
} }