Compare commits
2
Commits
68fae03165
...
90ce1ab0f5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90ce1ab0f5 | ||
|
|
c149b0a194 |
No files matched your search
@@ -615,7 +615,13 @@ function shortTitle(title: string, size: number) {
|
|||||||
</span>
|
</span>
|
||||||
<span class="muted">没做 {{ problemStats[selectedIndex]!.none }} 人</span>
|
<span class="muted">没做 {{ problemStats[selectedIndex]!.none }} 人</span>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<n-button quaternary size="small" aria-label="收起" @click="selectedId = null">
|
<n-button
|
||||||
|
quaternary
|
||||||
|
size="small"
|
||||||
|
class="panel-close"
|
||||||
|
aria-label="收起"
|
||||||
|
@click="selectedId = null"
|
||||||
|
>
|
||||||
<template #icon><Icon icon="ph:x-bold" /></template>
|
<template #icon><Icon icon="ph:x-bold" /></template>
|
||||||
</n-button>
|
</n-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -700,8 +706,10 @@ function shortTitle(title: string, size: number) {
|
|||||||
<template v-for="student in students" :key="student.userId">
|
<template v-for="student in students" :key="student.userId">
|
||||||
<tr v-if="groupStarts.has(student.userId)" class="group-row">
|
<tr v-if="groupStarts.has(student.userId)" class="group-row">
|
||||||
<td :colspan="board.problems.length + 4">
|
<td :colspan="board.problems.length + 4">
|
||||||
{{ GROUP_LABEL[groupOf(student)] }}
|
<span class="group-label">
|
||||||
<span class="muted">{{ groupCount[groupOf(student)] }}</span>
|
{{ GROUP_LABEL[groupOf(student)] }}
|
||||||
|
<span class="muted">{{ groupCount[groupOf(student)] }}</span>
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr :class="{ 'leave-row': groupOf(student) === 'leave' }">
|
<tr :class="{ 'leave-row': groupOf(student) === 'leave' }">
|
||||||
@@ -1164,6 +1172,14 @@ a.who {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 题多了表格要横着滑,名字钉在左边,不然滑过去就不知道是谁的格子 */
|
||||||
|
.grid .name {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 1;
|
||||||
|
background: v-bind("theme.cardColor");
|
||||||
|
}
|
||||||
|
|
||||||
.grid th.col {
|
.grid th.col {
|
||||||
width: 96px;
|
width: 96px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -1213,6 +1229,12 @@ a.who {
|
|||||||
background: v-bind("theme.actionColor");
|
background: v-bind("theme.actionColor");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 和名字一样钉在左边:横着滑过去也看得见这一组是什么 */
|
||||||
|
.group-label {
|
||||||
|
position: sticky;
|
||||||
|
left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.group-row .muted {
|
.group-row .muted {
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
@@ -1314,4 +1336,57 @@ a.cell {
|
|||||||
.meta {
|
.meta {
|
||||||
font-size: var(--oj-fs-sec);
|
font-size: var(--oj-fs-sec);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- 手机 ---------- */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
/* 题名占一行(收起钉在右边),人数挪到第二行,别把「人」挤到下一行去 */
|
||||||
|
.panel-head {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head > span {
|
||||||
|
order: 2;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
flex: 1 1 calc(100% - 50px);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-head .spacer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-close {
|
||||||
|
order: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.err-group {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-row {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-input {
|
||||||
|
flex-basis: 100%;
|
||||||
|
order: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid .name {
|
||||||
|
width: auto;
|
||||||
|
padding-left: 4px;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell {
|
||||||
|
width: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -14,6 +14,8 @@ import { onKeyStroke } from "@vueuse/core"
|
|||||||
import { useThemeVars } from "naive-ui"
|
import { useThemeVars } from "naive-ui"
|
||||||
import SubmissionView from "oj/submission/components/SubmissionView.vue"
|
import SubmissionView from "oj/submission/components/SubmissionView.vue"
|
||||||
import UserName from "shared/components/UserName.vue"
|
import UserName from "shared/components/UserName.vue"
|
||||||
|
import { useBackClose } from "shared/composables/backClose"
|
||||||
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import type { ClassBoardProblem } from "utils/types"
|
import type { ClassBoardProblem } from "utils/types"
|
||||||
import ProblemBrief from "./ProblemBrief.vue"
|
import ProblemBrief from "./ProblemBrief.vue"
|
||||||
|
|
||||||
@@ -24,6 +26,9 @@ import ProblemBrief from "./ProblemBrief.vue"
|
|||||||
*
|
*
|
||||||
* 「题目」按钮:演示错误时题目和代码要并排(用户要的),点了左边出题目,再点收起。
|
* 「题目」按钮:演示错误时题目和代码要并排(用户要的),点了左边出题目,再点收起。
|
||||||
* 平时收着,代码照样占满宽。开没开记在这台电脑上,讲下一个人、下一道题还是那样
|
* 平时收着,代码照样占满宽。开没开记在这台电脑上,讲下一个人、下一道题还是那样
|
||||||
|
*
|
||||||
|
* 手机上(老师拿手机看看板):顶上拆成三行 —— 题目和关闭、一排人(横着滑)、两个按钮;
|
||||||
|
* 没有键盘所以不写「← → 换人」;题目展开时叠在代码上面,不并排
|
||||||
*/
|
*/
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
problem: ClassBoardProblem | null
|
problem: ClassBoardProblem | null
|
||||||
@@ -33,6 +38,9 @@ const show = defineModel<boolean>("show", { required: true })
|
|||||||
const index = defineModel<number>("index", { required: true })
|
const index = defineModel<number>("index", { required: true })
|
||||||
|
|
||||||
const theme = useThemeVars()
|
const theme = useThemeVars()
|
||||||
|
const { isMobile } = useBreakpoints()
|
||||||
|
// 手机上是整屏的:按返回键关掉它回到看板,不是退出看板
|
||||||
|
useBackClose(show, isMobile)
|
||||||
/**
|
/**
|
||||||
* 颜色走内联的 CSS 变量,不用 <style> 里的 v-bind():n-modal 把内容挂到 body 下面,
|
* 颜色走内联的 CSS 变量,不用 <style> 里的 v-bind():n-modal 把内容挂到 body 下面,
|
||||||
* v-bind 的变量挂在组件根上,传不过去(弹层底色整个透明)
|
* v-bind 的变量挂在组件根上,传不过去(弹层底色整个透明)
|
||||||
@@ -50,6 +58,16 @@ const vars = computed(() => ({
|
|||||||
const current = computed(() => props.people[index.value] ?? null)
|
const current = computed(() => props.people[index.value] ?? null)
|
||||||
const showProblem = useStorage("oj_explain_show_problem", false)
|
const showProblem = useStorage("oj_explain_show_problem", false)
|
||||||
|
|
||||||
|
/** 一排人横着滑的时候,换到的人要滑进来(← → 换人、或者点开的就是排在后面的) */
|
||||||
|
const peopleRef = ref<HTMLElement | null>(null)
|
||||||
|
watch([index, show], async () => {
|
||||||
|
if (!show.value) return
|
||||||
|
await nextTick()
|
||||||
|
peopleRef.value
|
||||||
|
?.querySelector<HTMLElement>(".person.on")
|
||||||
|
?.scrollIntoView({ block: "nearest", inline: "nearest" })
|
||||||
|
})
|
||||||
|
|
||||||
function step(delta: -1 | 1) {
|
function step(delta: -1 | 1) {
|
||||||
const next = index.value + delta
|
const next = index.value + delta
|
||||||
if (next >= 0 && next < props.people.length) index.value = next
|
if (next >= 0 && next < props.people.length) index.value = next
|
||||||
@@ -68,6 +86,7 @@ onKeyStroke(["ArrowLeft", "ArrowRight"], (event) => {
|
|||||||
<!-- 无 preset 的 n-modal 直接子节点要是普通元素 -->
|
<!-- 无 preset 的 n-modal 直接子节点要是普通元素 -->
|
||||||
<div
|
<div
|
||||||
class="sheet"
|
class="sheet"
|
||||||
|
:class="{ narrow: isMobile }"
|
||||||
:style="vars"
|
:style="vars"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
:aria-label="problem ? `讲 ${problem.problemDisplayId} 的错误` : '讲错误'"
|
:aria-label="problem ? `讲 ${problem.problemDisplayId} 的错误` : '讲错误'"
|
||||||
@@ -77,7 +96,7 @@ onKeyStroke(["ArrowLeft", "ArrowRight"], (event) => {
|
|||||||
<b>{{ problem.problemDisplayId }}</b> {{ problem.title }}
|
<b>{{ problem.problemDisplayId }}</b> {{ problem.title }}
|
||||||
<template v-if="people.length > 1"> · 错着的</template>
|
<template v-if="people.length > 1"> · 错着的</template>
|
||||||
</span>
|
</span>
|
||||||
<div class="people">
|
<div ref="peopleRef" class="people">
|
||||||
<button
|
<button
|
||||||
v-for="(person, i) in people"
|
v-for="(person, i) in people"
|
||||||
:key="person.userId"
|
:key="person.userId"
|
||||||
@@ -90,25 +109,31 @@ onKeyStroke(["ArrowLeft", "ArrowRight"], (event) => {
|
|||||||
<span class="kind">{{ person.label }}</span>
|
<span class="kind">{{ person.label }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="people.length > 1" class="keys"><kbd>←</kbd><kbd>→</kbd> 换人</span>
|
<span v-if="people.length > 1 && !isMobile" class="keys"
|
||||||
|
><kbd>←</kbd><kbd>→</kbd> 换人</span
|
||||||
|
>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<n-button
|
<div class="buttons">
|
||||||
v-if="problem"
|
<n-button
|
||||||
:type="showProblem ? 'primary' : 'default'"
|
v-if="problem"
|
||||||
:secondary="showProblem"
|
:size="isMobile ? 'small' : 'medium'"
|
||||||
@click="showProblem = !showProblem"
|
:type="showProblem ? 'primary' : 'default'"
|
||||||
>
|
:secondary="showProblem"
|
||||||
题目
|
@click="showProblem = !showProblem"
|
||||||
</n-button>
|
>
|
||||||
<n-button
|
题目
|
||||||
v-if="current"
|
</n-button>
|
||||||
tag="a"
|
<n-button
|
||||||
:href="'/submission/' + current.submissionId"
|
v-if="current"
|
||||||
target="_blank"
|
:size="isMobile ? 'small' : 'medium'"
|
||||||
>
|
tag="a"
|
||||||
在新页面打开
|
:href="'/submission/' + current.submissionId"
|
||||||
</n-button>
|
target="_blank"
|
||||||
<n-button quaternary aria-label="关闭" @click="show = false">
|
>
|
||||||
|
在新页面打开
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
|
<n-button quaternary class="close" aria-label="关闭" @click="show = false">
|
||||||
<template #icon><Icon icon="ph:x-bold" /></template>
|
<template #icon><Icon icon="ph:x-bold" /></template>
|
||||||
</n-button>
|
</n-button>
|
||||||
</div>
|
</div>
|
||||||
@@ -238,4 +263,70 @@ kbd {
|
|||||||
.main {
|
.main {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 手机 ---------- */
|
||||||
|
|
||||||
|
.sheet.narrow {
|
||||||
|
width: calc(100vw - 16px);
|
||||||
|
max-height: calc(100vh - 16px);
|
||||||
|
padding: 12px 14px 16px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 第一行题目 + 关闭,第二行一排人(横着滑),第三行两个按钮 */
|
||||||
|
.narrow .top {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.narrow .problem {
|
||||||
|
grid-row: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.narrow .close {
|
||||||
|
grid-row: 1;
|
||||||
|
grid-column: 2;
|
||||||
|
margin-right: -6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.narrow .people {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.narrow .person {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.narrow .spacer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.narrow .buttons {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 题目叠在代码上面,自己滚,别把代码顶出一屏 */
|
||||||
|
.narrow .body.split {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.narrow .statement {
|
||||||
|
position: static;
|
||||||
|
max-height: 40vh;
|
||||||
|
padding: 0 0 12px;
|
||||||
|
border-bottom: 1px solid var(--divider);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -6,6 +6,7 @@ import type { Exercise } from "utils/types"
|
|||||||
import { getExercises, getLearnProgress, getTutorial, getTutorials } from "../api"
|
import { getExercises, getLearnProgress, getTutorial, getTutorials } from "../api"
|
||||||
import { parseLesson } from "./composables/useLessonParse"
|
import { parseLesson } from "./composables/useLessonParse"
|
||||||
import { useLearnTrace } from "./composables/useLearnTrace"
|
import { useLearnTrace } from "./composables/useLearnTrace"
|
||||||
|
import { useBackClose } from "shared/composables/backClose"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import {
|
import {
|
||||||
@@ -52,6 +53,8 @@ const solvedIds = ref(new Set<number>())
|
|||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const isEmpty = ref(false)
|
const isEmpty = ref(false)
|
||||||
const menuOpen = ref(false)
|
const menuOpen = ref(false)
|
||||||
|
// 手机上的目录是大半屏的底部抽屉:按返回键收起它,不是退出这一课
|
||||||
|
useBackClose(menuOpen, () => !isDesktop.value)
|
||||||
|
|
||||||
const groups = computed(() => groupByProject(titles.value))
|
const groups = computed(() => groupByProject(titles.value))
|
||||||
const parsed = computed(() =>
|
const parsed = computed(() =>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { useThemeVars } from "naive-ui"
|
import { useThemeVars } from "naive-ui"
|
||||||
import { storeToRefs } from "pinia"
|
import { storeToRefs } from "pinia"
|
||||||
import { useProblemStore } from "oj/store/problem"
|
import { useProblemStore } from "oj/store/problem"
|
||||||
|
import { useBackClose } from "shared/composables/backClose"
|
||||||
import { useUserStore } from "shared/store/user"
|
import { useUserStore } from "shared/store/user"
|
||||||
import {
|
import {
|
||||||
DRAWER_TITLE,
|
DRAWER_TITLE,
|
||||||
@@ -52,6 +53,8 @@ const show = computed({
|
|||||||
if (!value) open.value = null
|
if (!value) open.value = null
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
// 手机上是大半屏的底部抽屉(没有 to):按返回键收起它,不是退出题目
|
||||||
|
useBackClose(show, () => !props.to)
|
||||||
|
|
||||||
// 内容和标题都按最后一次打开的来:关的时候有一段滑出去的动画,那时 open 已经是 null 了。
|
// 内容和标题都按最后一次打开的来:关的时候有一段滑出去的动画,那时 open 已经是 null 了。
|
||||||
// 关完 n-drawer 自己把内容卸掉
|
// 关完 n-drawer 自己把内容卸掉
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getClassDetail } from "oj/api"
|
|||||||
import { classLabel } from "oj/submission/utils"
|
import { classLabel } from "oj/submission/utils"
|
||||||
import UserName from "shared/components/UserName.vue"
|
import UserName from "shared/components/UserName.vue"
|
||||||
import { useAIStream } from "shared/composables/aiStream"
|
import { useAIStream } from "shared/composables/aiStream"
|
||||||
|
import { useBackClose } from "shared/composables/backClose"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import { useRankPalette } from "../palette"
|
import { useRankPalette } from "../palette"
|
||||||
@@ -24,6 +25,8 @@ const message = useMessage()
|
|||||||
const theme = useThemeVars()
|
const theme = useThemeVars()
|
||||||
const palette = useRankPalette()
|
const palette = useRankPalette()
|
||||||
const { isDesktop } = useBreakpoints()
|
const { isDesktop } = useBreakpoints()
|
||||||
|
// 手机上占满一屏:按返回键关掉它回到排名
|
||||||
|
useBackClose(show, () => !isDesktop.value)
|
||||||
|
|
||||||
const detail = ref<ClassDetail | null>(null)
|
const detail = ref<ClassDetail | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { DEFAULT_PERIOD } from "oj/statistics/period"
|
import { DEFAULT_PERIOD } from "oj/statistics/period"
|
||||||
import { useContestStore } from "oj/store/contest"
|
import { useContestStore } from "oj/store/contest"
|
||||||
import Pagination from "shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
|
import { useBackClose } from "shared/composables/backClose"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { usePagination } from "shared/composables/pagination"
|
import { usePagination } from "shared/composables/pagination"
|
||||||
import { useConfigStore } from "shared/store/config"
|
import { useConfigStore } from "shared/store/config"
|
||||||
@@ -71,6 +72,17 @@ const tone = useTone()
|
|||||||
const { isMobile, isDesktop } = useBreakpoints()
|
const { isMobile, isDesktop } = useBreakpoints()
|
||||||
|
|
||||||
const inContest = computed(() => route.name === "contest submissions")
|
const inContest = computed(() => route.name === "contest submissions")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手机上点开的那一条,记在地址的 `?id=` 上:详情是从右边滑出的整屏,学生下意识按返回 ——
|
||||||
|
* 返回键归路由管,退一步就是关掉详情、回到列表;刷新、转发链接也还停在这一条。
|
||||||
|
* 列表在底下不卸载,滚到哪、上一条 / 下一条都还在
|
||||||
|
*/
|
||||||
|
const paneId = computed(() =>
|
||||||
|
isMobile.value && typeof route.query.id === "string" ? route.query.id : "",
|
||||||
|
)
|
||||||
|
/** 从详情里点了「只看他」这类筛选:这一下要连 ?id= 一起换掉,见 leavePane() */
|
||||||
|
let leavingPane = false
|
||||||
const teacher = computed(() => userStore.isTeacherOrAbove)
|
const teacher = computed(() => userStore.isTeacherOrAbove)
|
||||||
|
|
||||||
// 一节课一个班要交一百来条,每页 10 条得翻十几页;30 条配上右栏的方向键正好
|
// 一节课一个班要交一百来条,每页 10 条得翻十几页;30 条配上右栏的方向键正好
|
||||||
@@ -85,7 +97,13 @@ const { query, clearQuery } = usePagination<SubmissionQuery>(
|
|||||||
// 原来写死成 "0",从别处带着 today=1 进来也不认
|
// 原来写死成 "0",从别处带着 today=1 进来也不认
|
||||||
today: useRouteQuery("today", "0").value,
|
today: useRouteQuery("today", "0").value,
|
||||||
},
|
},
|
||||||
{ defaultLimit: 30 },
|
{
|
||||||
|
defaultLimit: 30,
|
||||||
|
// 手机上开着详情:翻页、换条件时地址里的 ?id= 带着走,也不多压历史(返回键是关详情)
|
||||||
|
extraQuery: (): Record<string, string> =>
|
||||||
|
paneId.value && !leavingPane ? { id: paneId.value } : {},
|
||||||
|
replace: () => !!paneId.value && !leavingPane,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
const flowMode = computed(() => !inContest.value && query.language === "Flowchart")
|
const flowMode = computed(() => !inContest.value && query.language === "Flowchart")
|
||||||
@@ -294,6 +312,14 @@ function settleSelection() {
|
|||||||
}
|
}
|
||||||
message.info("这一页没有可以查看的代码")
|
message.info("这一页没有可以查看的代码")
|
||||||
}
|
}
|
||||||
|
// 地址里带着 ?id= 的(手机上开着详情时刷新、别人发来的链接)就停在那一条
|
||||||
|
const wanted = typeof route.query.id === "string" ? route.query.id : ""
|
||||||
|
if (wanted && rows.value.some((row) => row.id === wanted)) {
|
||||||
|
selectedId.value = wanted
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 这一页上没有那一条:详情开不出来,把 ?id= 摘掉
|
||||||
|
if (paneId.value) router.replace({ query: queryWithoutId() })
|
||||||
if (rows.value.some((row) => row.id === selectedId.value)) return
|
if (rows.value.some((row) => row.id === selectedId.value)) return
|
||||||
// 手机上没有右栏,不用预先选一条
|
// 手机上没有右栏,不用预先选一条
|
||||||
if (isMobile.value) {
|
if (isMobile.value) {
|
||||||
@@ -334,6 +360,7 @@ function move(step: 1 | -1) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [todayPanel, toggleTodayPanel] = useToggle(false)
|
const [todayPanel, toggleTodayPanel] = useToggle(false)
|
||||||
|
useBackClose(todayPanel, isMobile)
|
||||||
const statsPanel = ref(false)
|
const statsPanel = ref(false)
|
||||||
|
|
||||||
// 只用 ↑ ↓(用户定的,← → 不要)
|
// 只用 ↑ ↓(用户定的,← → 不要)
|
||||||
@@ -360,13 +387,63 @@ onKeyStroke(["ArrowUp", "ArrowDown"], (e: KeyboardEvent) => {
|
|||||||
move(e.key === "ArrowUp" ? -1 : 1)
|
move(e.key === "ArrowUp" ? -1 : 1)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 手机上没有右栏:点一行从右边滑出整屏的详情
|
// 手机上没有右栏:点一行从右边滑出整屏的详情,地址带上 ?id=(见 paneId)
|
||||||
const mobilePane = ref(false)
|
const mobilePane = computed({
|
||||||
|
get: () => !!paneId.value,
|
||||||
|
set: (open) => {
|
||||||
|
if (!open) closePane()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function queryWithoutId() {
|
||||||
|
const { id: _, ...rest } = route.query
|
||||||
|
return rest
|
||||||
|
}
|
||||||
|
|
||||||
function rowClicked(row: Row) {
|
function rowClicked(row: Row) {
|
||||||
select(row.id)
|
select(row.id)
|
||||||
if (isMobile.value) mobilePane.value = true
|
if (isMobile.value) router.push({ query: { ...route.query, id: row.id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点 × 关:从列表点开的就退一步(和按返回键一样,不留一条多余的历史);
|
||||||
|
* 直接带着 ?id= 进来的(刷新过、别人发的链接)后面没有列表可退,把地址换掉
|
||||||
|
*/
|
||||||
|
function closePane() {
|
||||||
|
const back = history.state?.back
|
||||||
|
if (typeof back === "string" && router.resolve(back).name === route.name) router.back()
|
||||||
|
else router.replace({ query: queryWithoutId() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 详情里点「只看他」「只看这道题」:先关详情(退回列表那一条),再改筛选。
|
||||||
|
* 反过来的话筛选压的那条历史在详情后面,按返回又回到详情。
|
||||||
|
* 等路由落地、再等分页把地址同步回 query 之后才改,不然改的值会被同步盖掉
|
||||||
|
*/
|
||||||
|
function leavePane(next: () => void) {
|
||||||
|
if (!paneId.value) return next()
|
||||||
|
leavingPane = true
|
||||||
|
const off = router.afterEach(() => {
|
||||||
|
off()
|
||||||
|
nextTick(() => {
|
||||||
|
leavingPane = false
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
closePane()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上一条 / 下一条换了人:地址里的 ?id= 跟着换(replace,返回键照样是关详情)
|
||||||
|
watch(selectedId, (id) => {
|
||||||
|
if (paneId.value && id && id !== paneId.value) {
|
||||||
|
router.replace({ query: { ...route.query, id } })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// 前进后退到另一条(或者刷新后列表回来之前地址就有了):选中跟着地址走
|
||||||
|
watch(paneId, (id) => {
|
||||||
|
if (id && rows.value.some((row) => row.id === id)) selectedId.value = id
|
||||||
|
})
|
||||||
|
|
||||||
// ==================== 实时更新 ====================
|
// ==================== 实时更新 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1167,8 +1244,8 @@ function dayBreak(index: number) {
|
|||||||
:teacher="teacher"
|
:teacher="teacher"
|
||||||
:position="null"
|
:position="null"
|
||||||
narrow
|
narrow
|
||||||
@filter-user="(name: string) => ((mobilePane = false), filterUser(name))"
|
@filter-user="(name: string) => leavePane(() => filterUser(name))"
|
||||||
@filter-problem="(id: string) => ((mobilePane = false), filterProblem(id))"
|
@filter-problem="(id: string) => leavePane(() => filterProblem(id))"
|
||||||
@open-problem="openProblem"
|
@open-problem="openProblem"
|
||||||
@retry="retryFlowchart"
|
@retry="retryFlowchart"
|
||||||
/>
|
/>
|
||||||
@@ -1180,10 +1257,10 @@ function dayBreak(index: number) {
|
|||||||
:contest="inContest"
|
:contest="inContest"
|
||||||
:position="null"
|
:position="null"
|
||||||
narrow
|
narrow
|
||||||
@filter-user="(name: string) => ((mobilePane = false), filterUser(name))"
|
@filter-user="(name: string) => leavePane(() => filterUser(name))"
|
||||||
@filter-class="(name: string) => ((mobilePane = false), filterClass(name))"
|
@filter-class="(name: string) => leavePane(() => filterClass(name))"
|
||||||
@clear-class="() => ((mobilePane = false), (query.className = ''))"
|
@clear-class="() => leavePane(() => (query.className = ''))"
|
||||||
@filter-problem="(id: string) => ((mobilePane = false), filterProblem(id))"
|
@filter-problem="(id: string) => leavePane(() => filterProblem(id))"
|
||||||
@open-problem="openProblem"
|
@open-problem="openProblem"
|
||||||
@rejudge="rejudge"
|
@rejudge="rejudge"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { MaybeRefOrGetter, Ref } from "vue"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手机上整屏(或大半屏)的弹框、抽屉:按返回键先关它,而不是退出整页。
|
||||||
|
*
|
||||||
|
* 学生在手机上看到一整屏的东西,下意识就按返回 —— 原来这一下直接退到了前一页(提交列表点开
|
||||||
|
* 一条、再按返回,回到的是首页)。所以打开时往历史里压一条同地址的记录:
|
||||||
|
* - 按返回:浏览器弹掉这一条,这里收到 popstate,把弹框关掉,页面原地不动;
|
||||||
|
* - 点 × / 点遮罩关掉:自己 history.back() 把那条弹掉,不然要多按一次返回才退得出去。
|
||||||
|
*
|
||||||
|
* 压的是 history.state 的原样拷贝(vue-router 要的 position 等字段都在)。弹掉它时
|
||||||
|
* **不能让 vue-router 看见**:它会当成一次同地址的跳转,route 对象换新,页面上盯着
|
||||||
|
* route.query 的同步逻辑(usePagination)跟着跑一遍,又压一条历史,返回键就要多按一次。
|
||||||
|
* 抢在它前面拦不住 —— window 上的 popstate 按注册顺序派发(捕获监听也不优先,实测过),
|
||||||
|
* 路由的监听最早注册。所以记录压着的这段时间把 router.listening 整个关掉,弹掉以后再开:
|
||||||
|
* 关着的只是「响应浏览器前进后退」这一项,弹框里点链接 router.push 照常走。
|
||||||
|
*
|
||||||
|
* 弹框开着时点了站内链接,路由守卫先把它拦下,等那条记录弹完再跳(back() 是异步的,
|
||||||
|
* 先跳的话会把刚跳过去的那一条弹掉)。
|
||||||
|
*/
|
||||||
|
export function useBackClose(show: Ref<boolean>, enabled: MaybeRefOrGetter<boolean> = true) {
|
||||||
|
const router = useRouter()
|
||||||
|
/** 压了记录、还没弹掉(这段时间路由不听前进后退) */
|
||||||
|
let pushed = false
|
||||||
|
let afterPop: (() => void) | null = null
|
||||||
|
|
||||||
|
watch(show, (open) => {
|
||||||
|
if (open && !pushed && toValue(enabled)) {
|
||||||
|
history.pushState(history.state, "")
|
||||||
|
pushed = true
|
||||||
|
router.listening = false
|
||||||
|
} else if (!open && pushed) {
|
||||||
|
// 点 × 关的:自己弹掉那条,路由照旧关着,等下面收到 popstate 再开
|
||||||
|
history.back()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
useEventListener(window, "popstate", () => {
|
||||||
|
if (!pushed) return
|
||||||
|
pushed = false
|
||||||
|
// 按了返回键的:只管关(watch 里看到 pushed 已经是 false,不会再 back)
|
||||||
|
show.value = false
|
||||||
|
router.listening = true
|
||||||
|
const next = afterPop
|
||||||
|
afterPop = null
|
||||||
|
next?.()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 弹框里点了链接要跳走(自学目录里选一课):先关弹框、把压的那条退掉,再跳。
|
||||||
|
// 直接跳的话新页面压在那条后面,回来时要多按一次返回,而且那一下路由不听(见上)
|
||||||
|
const removeGuard = router.beforeEach((to) => {
|
||||||
|
if (!pushed) return
|
||||||
|
afterPop = () => void router.push(to.fullPath)
|
||||||
|
show.value = false
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
// 弹框开着组件就没了:路由一定要开回来,不然整站的返回键都失灵
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
removeGuard()
|
||||||
|
if (!pushed) return
|
||||||
|
pushed = false
|
||||||
|
router.listening = true
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { reactive, watch } from "vue"
|
import { nextTick, reactive, watch } from "vue"
|
||||||
import { useRoute, useRouter } from "vue-router"
|
import { useRoute, useRouter } from "vue-router"
|
||||||
import { filterEmptyValue } from "utils/functions"
|
import { filterEmptyValue } from "utils/functions"
|
||||||
|
|
||||||
@@ -15,6 +15,13 @@ export interface UsePaginationOptions {
|
|||||||
defaultPage?: number
|
defaultPage?: number
|
||||||
/** 当其他查询条件变化时是否重置页码 */
|
/** 当其他查询条件变化时是否重置页码 */
|
||||||
resetPageOnChange?: boolean
|
resetPageOnChange?: boolean
|
||||||
|
/**
|
||||||
|
* 不归分页管、但同步地址时要带上的键。不给的话地址整个按 query 重写,别的键会被丢掉
|
||||||
|
* (提交列表手机上点开一条是 `?id=`,翻一页详情就关了)
|
||||||
|
*/
|
||||||
|
extraQuery?: () => Record<string, string>
|
||||||
|
/** 返回 true 时同步地址一律 replace,不压历史(提交列表手机上开着详情时翻页) */
|
||||||
|
replace?: () => boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -27,7 +34,13 @@ export function usePagination<T extends Record<string, any>>(
|
|||||||
initialQuery: Omit<T, "page" | "limit"> = {} as Omit<T, "page" | "limit">,
|
initialQuery: Omit<T, "page" | "limit"> = {} as Omit<T, "page" | "limit">,
|
||||||
options: UsePaginationOptions = {},
|
options: UsePaginationOptions = {},
|
||||||
) {
|
) {
|
||||||
const { defaultLimit = 10, defaultPage = 1, resetPageOnChange = true } = options
|
const {
|
||||||
|
defaultLimit = 10,
|
||||||
|
defaultPage = 1,
|
||||||
|
resetPageOnChange = true,
|
||||||
|
extraQuery,
|
||||||
|
replace,
|
||||||
|
} = options
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -41,8 +54,18 @@ export function usePagination<T extends Record<string, any>>(
|
|||||||
// 键是运行时按 initialQuery 枚举出来的,静态类型写不出来;写入统一走这一个口子
|
// 键是运行时按 initialQuery 枚举出来的,静态类型写不出来;写入统一走这一个口子
|
||||||
const writable = query as Record<string, unknown>
|
const writable = query as Record<string, unknown>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 正在从地址同步回来:这一拍里 query 的变化本来就是地址带来的,别再写回地址。
|
||||||
|
* 写回去的话(浏览器后退到第 1 页,地址没写默认的 today=0,比一下「不一样」)会压一条
|
||||||
|
* 新历史,把前进的那几条冲掉,返回键还得多按一次。
|
||||||
|
* 页面自己盯着 route.query 补默认值的(提交列表)也在同一拍里,一起算
|
||||||
|
*/
|
||||||
|
let syncing = false
|
||||||
|
|
||||||
// 同步 URL 查询参数到本地状态
|
// 同步 URL 查询参数到本地状态
|
||||||
function syncFromRoute() {
|
function syncFromRoute() {
|
||||||
|
syncing = true
|
||||||
|
nextTick(() => (syncing = false))
|
||||||
writable.page = parseInt(<string>route.query.page) || defaultPage
|
writable.page = parseInt(<string>route.query.page) || defaultPage
|
||||||
writable.limit = parseInt(<string>route.query.limit) || defaultLimit
|
writable.limit = parseInt(<string>route.query.limit) || defaultLimit
|
||||||
|
|
||||||
@@ -71,7 +94,7 @@ export function usePagination<T extends Record<string, any>>(
|
|||||||
|
|
||||||
// 更新 URL
|
// 更新 URL
|
||||||
function updateRoute() {
|
function updateRoute() {
|
||||||
const newQuery = filterEmptyValue(query)
|
const newQuery = { ...filterEmptyValue(query), ...extraQuery?.() }
|
||||||
// 和当前地址只差默认的 page / limit(后退到 /problem,同步完又补上 ?page=1&limit=30):
|
// 和当前地址只差默认的 page / limit(后退到 /problem,同步完又补上 ?page=1&limit=30):
|
||||||
// 是同一个页面,用 replace,别多压一条历史、让人得多按一次后退
|
// 是同一个页面,用 replace,别多压一条历史、让人得多按一次后退
|
||||||
const normalized = (q: Record<string, unknown>) =>
|
const normalized = (q: Record<string, unknown>) =>
|
||||||
@@ -85,7 +108,8 @@ export function usePagination<T extends Record<string, any>>(
|
|||||||
.map(([key, value]) => [key, String(value)])
|
.map(([key, value]) => [key, String(value)])
|
||||||
.sort(),
|
.sort(),
|
||||||
)
|
)
|
||||||
const navigate = normalized(route.query) === normalized(newQuery) ? router.replace : router.push
|
const navigate =
|
||||||
|
replace?.() || normalized(route.query) === normalized(newQuery) ? router.replace : router.push
|
||||||
navigate({
|
navigate({
|
||||||
path: route.path,
|
path: route.path,
|
||||||
query: newQuery,
|
query: newQuery,
|
||||||
@@ -115,12 +139,18 @@ export function usePagination<T extends Record<string, any>>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 监听页码变化,同步到 URL
|
// 监听页码变化,同步到 URL
|
||||||
watch(() => query.page, updateRoute)
|
watch(
|
||||||
|
() => query.page,
|
||||||
|
() => {
|
||||||
|
if (!syncing) updateRoute()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
// 监听每页条数变化,重置页码并同步到 URL
|
// 监听每页条数变化,重置页码并同步到 URL
|
||||||
watch(
|
watch(
|
||||||
() => query.limit,
|
() => query.limit,
|
||||||
() => {
|
() => {
|
||||||
|
if (syncing) return
|
||||||
if (resetPageOnChange) {
|
if (resetPageOnChange) {
|
||||||
resetPage()
|
resetPage()
|
||||||
}
|
}
|
||||||
@@ -134,6 +164,7 @@ export function usePagination<T extends Record<string, any>>(
|
|||||||
watch(
|
watch(
|
||||||
() => otherQueryKeys.map((key) => query[key]),
|
() => otherQueryKeys.map((key) => query[key]),
|
||||||
() => {
|
() => {
|
||||||
|
if (syncing) return
|
||||||
resetPage()
|
resetPage()
|
||||||
updateRoute()
|
updateRoute()
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user