feat(后台): 手机上也能用了——菜单收进左边抽屉,列表一行一张卡,不用再横着滑

- 侧栏手机上不再占一列图标,顶栏左边 ☰ 拉出整张菜单,按返回键先关菜单
- 题目、比赛、用户、公告、题单列表手机上一项一张卡:名字一行、小字一行、按钮一行,
  「学生看得到」开关单独一列靠右
- 卡点分析、AI 报告手机上点开一项整屏看,返回键关掉;年度趋势、自学情况、首页改成上下排
- 出题页、教程编辑、题单编辑、标签、设置手机上排成一列;测试数据「删掉」手机上常驻
- 弹框一律收进屏幕宽度

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-16 11:21:58 +08:00
1 parent b757e2ac38
commit 02e2fa7d9b
30 files changed
+1282 -137

No files matched your search

@@ -10,6 +10,7 @@ import {
} from "admin/api"
import AchievementIcon from "shared/components/AchievementIcon.vue"
import { RARITY_LABEL } from "utils/constants"
import { useBreakpoints } from "shared/composables/breakpoints"
const rarityOptions = Object.entries(RARITY_LABEL).map(([value, label]) => ({
label,
@@ -44,6 +45,7 @@ function confirmDelete() {
}
const message = useMessage()
const { isMobile } = useBreakpoints()
const metrics = ref<MetricOption[]>([])
const saving = ref(false)
@@ -115,7 +117,8 @@ async function save() {
:title="editing ? '编辑成就' : '新建成就'"
@update:show="emit('update:show', $event)"
>
<n-form label-placement="left" :label-width="80">
<!-- 手机上弹框只有 350 宽,标签放到上面,输入框才有地方 -->
<n-form :label-placement="isMobile ? 'top' : 'left'" :label-width="80">
<n-form-item label="名称" required>
<n-input v-model:value="form.name" placeholder="成就名称" />
</n-form-item>
@@ -146,7 +149,7 @@ async function save() {
<n-form-item label="指标" required>
<n-select v-model:value="form.metric" :options="metricOptions" filterable />
</n-form-item>
<n-form-item v-if="metricHelp" label=" ">
<n-form-item v-if="metricHelp" label=" " :show-label="!isMobile">
<n-text depth="3">{{ metricHelp }}</n-text>
</n-form-item>
<n-form-item label="条件">
+28
View File
@@ -216,4 +216,32 @@ onMounted(load)
.off-note {
color: v-bind("theme.errorColor");
}
/* 手机上指标名放在一组上头,下面两档一行;箭头拿掉(折行之后箭头会单独落在行首) */
@media (max-width: 768px) {
.group {
flex-direction: column;
align-items: stretch;
gap: 8px;
padding: 12px 10px;
}
.metric {
width: auto;
font-weight: 600;
}
.steps {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.arrow {
display: none;
}
.step {
min-width: 0;
}
}
</style>
+75 -3
View File
@@ -1,8 +1,11 @@
<script lang="ts" setup>
import { Icon } from "@iconify/vue"
import PageHeader from "admin/components/PageHeader.vue"
import { useThemeVars } from "naive-ui"
import { classLabel } from "oj/submission/utils"
import Markdown from "shared/components/Markdown.vue"
import { useBackClose } from "shared/composables/backClose"
import { useBreakpoints } from "shared/composables/breakpoints"
import Pagination from "shared/components/Pagination.vue"
import UserName from "shared/components/UserName.vue"
import { parseTime } from "utils/functions"
@@ -18,6 +21,19 @@ import { getAIReportDetail, getAIReportList, pinAIReport } from "../api"
* 别的那份取消,所以切完重拉列表
*/
const theme = useThemeVars()
const { isMobile } = useBreakpoints()
// 手机上左右两栏放不下:只列报告,点一份全文盖满整屏,按返回键回到列表
const detailOpen = ref(false)
useBackClose(detailOpen, isMobile)
watch(isMobile, (mobile) => {
if (!mobile) detailOpen.value = false
})
function pick(id: number) {
if (isMobile.value) detailOpen.value = true
open(id)
}
const reports = ref<AdminAiReportListItem[]>([])
const total = ref(0)
@@ -86,7 +102,7 @@ watchDebounced(
<template>
<PageHeader title="AI 报告">
<template #filters>
<n-input v-model:value="query.username" clearable placeholder="用户名" style="width: 200px" />
<n-input v-model:value="query.username" clearable placeholder="用户名" class="search" />
<n-text depth="3">共 {{ total }} 份</n-text>
</template>
</PageHeader>
@@ -101,7 +117,7 @@ watchDebounced(
type="button"
class="item"
:class="{ on: r.id === selectedId }"
@click="open(r.id)"
@click="pick(r.id)"
>
<span class="item-head">
<UserName :username="r.username" />
@@ -116,7 +132,13 @@ watchDebounced(
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" simple />
</div>
<div class="detail">
<div class="detail" :class="{ open: detailOpen }">
<div v-if="isMobile" class="detail-bar">
<n-button quaternary circle @click="detailOpen = false">
<template #icon><Icon icon="ph:arrow-left" /></template>
</n-button>
<span>AI 报告</span>
</div>
<n-spin :show="loadingDetail">
<template v-if="selected">
<div class="detail-head">
@@ -262,4 +284,54 @@ watchDebounced(
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.search {
width: 200px;
}
@media (max-width: 768px) {
.search {
flex: 1;
width: auto;
min-width: 0;
}
.list-col {
width: 100%;
}
.detail {
display: none;
}
.detail.open {
display: block;
position: fixed;
inset: 0;
z-index: 2000;
overflow-y: auto;
padding: 0 16px 32px;
background: v-bind("theme.bodyColor");
}
.detail-bar {
position: sticky;
top: 0;
z-index: 1;
display: flex;
align-items: center;
gap: 4px;
height: 48px;
margin: 0 -16px 12px;
padding: 0 8px;
font-weight: 600;
border-bottom: 1px solid v-bind("theme.dividerColor");
background: v-bind("theme.bodyColor");
}
.detail-head {
flex-wrap: wrap;
row-gap: 6px;
}
}
</style>
+74 -21
View File
@@ -7,6 +7,7 @@ import { parseTime } from "utils/functions"
import type { AdminAnnouncementListItem } from "utils/types"
import { editAnnouncement, getAnnouncement, getAnnouncementList } from "../api"
import Actions from "./components/Actions.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
const message = useMessage()
const total = ref(0)
@@ -22,14 +23,10 @@ const announcements = ref<AdminAnnouncementListItem[]>([])
* 精确到秒的两列时间拿掉
*/
const theme = useThemeVars()
const { isMobile } = useBreakpoints()
const columns: DataTableColumn<AdminAnnouncementListItem>[] = [
{
title: "标题",
key: "title",
width: 360,
render: (row) =>
h("span", { class: "title-cell" }, [
function renderTitle(row: AdminAnnouncementListItem) {
return h("span", { class: "title-cell" }, [
h(
RouterLink,
{
@@ -40,7 +37,52 @@ const columns: DataTableColumn<AdminAnnouncementListItem>[] = [
),
row.top ? h("span", { class: "top" }, "置顶") : null,
h("span", { class: "muted small" }, row.tag),
])
}
function renderVisible(row: AdminAnnouncementListItem) {
return h(NSwitch, {
value: row.visible,
size: isMobile.value ? "medium" : "small",
rubberBand: false,
onUpdateValue: () => toggle(row, "visible"),
})
}
function renderActions(row: AdminAnnouncementListItem) {
return h(Actions, {
announcementID: row.id,
top: row.top,
onDeleted: listAnnouncements,
onToggleTop: () => toggle(row, "top"),
})
}
/** 手机上一条一张卡:标题一行,发布 · 改过一行小字,操作一行;「学生看得到」单独一列靠右 */
function renderMobileCard(row: AdminAnnouncementListItem) {
const created = parseTime(row.createTime, "YYYY-MM-DD")
const updated = parseTime(row.lastUpdateTime, "YYYY-MM-DD")
return h("div", { class: "m-card" }, [
renderTitle(row),
h("div", { class: "m-meta muted num" }, [
h("span", { class: "m-item" }, `${created} 发布`),
updated === created ? null : h("span", { class: "m-item" }, ` · ${updated} 改过`),
]),
renderActions(row),
])
}
const mobileColumns: DataTableColumn<AdminAnnouncementListItem>[] = [
{ title: "标题", key: "title", render: renderMobileCard },
{ title: "看得到", key: "visible", width: 72, align: "center", render: renderVisible },
]
const desktopColumns: DataTableColumn<AdminAnnouncementListItem>[] = [
{
title: "标题",
key: "title",
width: 360,
render: renderTitle,
},
{
title: "发布",
@@ -62,28 +104,18 @@ const columns: DataTableColumn<AdminAnnouncementListItem>[] = [
title: "学生看得到",
key: "visible",
width: 100,
render: (row) =>
h(NSwitch, {
value: row.visible,
size: "small",
rubberBand: false,
onUpdateValue: () => toggle(row, "visible"),
}),
render: renderVisible,
},
{
title: "",
key: "actions",
minWidth: 240,
render: (row) =>
h(Actions, {
announcementID: row.id,
top: row.top,
onDeleted: listAnnouncements,
onToggleTop: () => toggle(row, "top"),
}),
render: renderActions,
},
]
const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns))
// 列表响应不含 content(正文是 8MB 上限的富文本),而更新接口要求 content 必填 ——
// 拿列表里那行直接回传会 400。所以先取回整条再改。
async function toggle(announcement: AdminAnnouncementListItem, field: "visible" | "top") {
@@ -134,6 +166,27 @@ watch(query, listAnnouncements, { deep: true })
gap: 8px;
}
.ann-table :deep(.m-card) {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
min-width: 0;
}
.ann-table :deep(.m-card .title-cell) {
display: flex;
flex-wrap: wrap;
}
.ann-table :deep(.m-meta) {
font-size: 13px;
}
.ann-table :deep(.m-item) {
white-space: nowrap;
}
.ann-table :deep(.title-link) {
color: inherit;
font-weight: 500;
@@ -71,6 +71,8 @@ const slots = defineSlots<{
.title {
margin: 0;
/* 手机上标题行挤不下时让右边的按钮折到下一行,标题自己别一个字一行 */
white-space: nowrap;
font-size: 20px;
font-weight: 600;
line-height: 1.4;
@@ -1,6 +1,7 @@
<script lang="ts" setup>
import type { AdminContest } from "utils/types"
import { cloneContest } from "../../api"
import { useBreakpoints } from "shared/composables/breakpoints"
interface Props {
contest: AdminContest
@@ -8,6 +9,8 @@ interface Props {
const props = defineProps<Props>()
const router = useRouter()
const message = useMessage()
// 手机上在卡片里,放不下一行就折下去
const { isMobile } = useBreakpoints()
function goEdit() {
router.push({
@@ -40,7 +43,7 @@ async function clone() {
</script>
<template>
<!-- 操作直接摆出来,不收进 ⋯(用户说的) -->
<n-flex :size="4" :wrap="false">
<n-flex :size="4" :wrap="isMobile">
<n-button size="small" @click="goEdit">编辑</n-button>
<n-button size="small" @click="goClass">
{{ contest.status === "-1" ? "成绩" : "全班情况" }}
+93 -24
View File
@@ -8,6 +8,7 @@ import { CONTEST_STATUS } from "utils/constants"
import { parseTime } from "utils/functions"
import { editContest, getContestList } from "../api"
import Actions from "./components/Actions.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
/**
* 后台比赛列表(设计稿「比赛重设计」CtAdminList)。一行一场:比赛名(期中 / 期末挂标,
@@ -16,6 +17,7 @@ import Actions from "./components/Actions.vue"
* 会跑到最前面。ID、标签、类型三列拿掉(标签只在期中 / 期末时挂出来,类型就是那把锁)
*/
const theme = useThemeVars()
const { isMobile } = useBreakpoints()
const contests = ref<AdminContestListItem[]>([])
const total = ref(0)
const query = reactive({
@@ -43,16 +45,69 @@ function duration(row: AdminContestListItem) {
const EXAM_TAGS = ["期中", "期末"]
const columns: DataTableColumn<AdminContestListItem>[] = [
function renderTitle(row: AdminContestListItem) {
return h("span", { class: "title-cell" }, [
h(ContestTitle, { contest: row }),
EXAM_TAGS.includes(row.tag) ? h("span", { class: "exam" }, row.tag) : null,
])
}
function renderStatus(row: AdminContestListItem) {
return h(
"span",
{ class: row.status === "0" ? "live" : row.status === "-1" ? "muted" : "" },
row.status === "-1"
? "结束了"
: row.status === "0"
? "进行中"
: CONTEST_STATUS[row.status].name,
)
}
function renderVisible(row: AdminContestListItem) {
return h(NSwitch, {
value: row.visible,
size: isMobile.value ? "medium" : "small",
rubberBand: false,
onUpdateValue: () => toggleVisible(row),
})
}
/**
* 手机上一场一张卡:比赛名一行;开始 · 多长 · 状态 · 题数 · 参加 · 出卷一行小字(每项不断开);
* 操作按钮放不下就折行。「学生看得到」开关单独一列靠右
*/
function renderMobileCard(row: AdminContestListItem) {
const items = [
h("span", { class: "num" }, parseTime(row.startTime, "YYYY-MM-DD HH:mm")),
duration(row),
renderStatus(row),
`${row.problemCount} 题`,
row.participantCount ? `${row.participantCount} 人参加` : null,
row.createdBy.username,
].filter((it) => it !== null)
return h("div", { class: "m-card" }, [
renderTitle(row),
h(
"div",
{ class: "m-meta muted" },
items.map((it, i) => h("span", { class: "m-item", key: i }, i ? [" · ", it] : [it])),
),
h(Actions, { contest: row }),
])
}
const mobileColumns: DataTableColumn<AdminContestListItem>[] = [
{ title: "比赛", key: "title", render: renderMobileCard },
{ title: "看得到", key: "visible", width: 72, align: "center", render: renderVisible },
]
const desktopColumns: DataTableColumn<AdminContestListItem>[] = [
{
title: "比赛",
key: "title",
width: 260,
render: (row) =>
h("span", { class: "title-cell" }, [
h(ContestTitle, { contest: row }),
EXAM_TAGS.includes(row.tag) ? h("span", { class: "exam" }, row.tag) : null,
]),
render: renderTitle,
},
{
title: "开始 · 多长",
@@ -68,16 +123,7 @@ const columns: DataTableColumn<AdminContestListItem>[] = [
title: "状态",
key: "status",
width: 72,
render: (row) =>
h(
"span",
{ class: row.status === "0" ? "live" : row.status === "-1" ? "muted" : "" },
row.status === "-1"
? "结束了"
: row.status === "0"
? "进行中"
: CONTEST_STATUS[row.status].name,
),
render: renderStatus,
},
{ title: "题", key: "problemCount", width: 56 },
{
@@ -97,13 +143,7 @@ const columns: DataTableColumn<AdminContestListItem>[] = [
title: "学生看得到",
key: "visible",
width: 96,
render: (row) =>
h(NSwitch, {
value: row.visible,
size: "small",
rubberBand: false,
onUpdateValue: () => toggleVisible(row),
}),
render: renderVisible,
},
{
title: "",
@@ -113,6 +153,8 @@ const columns: DataTableColumn<AdminContestListItem>[] = [
},
]
const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns))
async function listContests() {
const offset = (query.page - 1) * query.limit
const res = await getContestList(offset, query.limit, query.keyword)
@@ -135,7 +177,12 @@ watchDebounced(() => query.keyword, listContests, {
</n-button>
</template>
<template #filters>
<n-input v-model:value="query.keyword" placeholder="比赛名" clearable style="width: 220px" />
<n-input
v-model:value="query.keyword"
placeholder="比赛名"
clearable
:style="{ width: isMobile ? '100%' : '220px' }"
/>
</template>
</PageHeader>
<n-data-table striped class="contest-table" :columns="columns" :data="contests" />
@@ -150,6 +197,28 @@ watchDebounced(() => query.keyword, listContests, {
font-weight: 500;
}
.contest-table :deep(.m-card) {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
min-width: 0;
}
.contest-table :deep(.m-card .title-cell) {
display: flex;
flex-wrap: wrap;
}
.contest-table :deep(.m-meta) {
font-size: 13px;
line-height: 1.5;
}
.contest-table :deep(.m-item) {
white-space: nowrap;
}
.contest-table :deep(.exam) {
display: inline-flex;
align-items: center;
+48 -5
View File
@@ -3,13 +3,22 @@ import { NText } from "naive-ui"
import { getLearnExerciseAttempts } from "admin/api"
import { parseTime } from "utils/functions"
import type { LearnExerciseAttempt } from "utils/types"
import UserName from "shared/components/UserName.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
const props = defineProps<{ exerciseId: number; className: string }>()
const { isMobile } = useBreakpoints()
const loading = ref(true)
const rows = ref<LearnExerciseAttempt[]>([])
const columns: DataTableColumn<LearnExerciseAttempt>[] = [
function renderResult(row: LearnExerciseAttempt) {
return row.solved
? h(NText, { type: "success" }, () => `做对了(第 ${row.attemptsToSolve} 次)`)
: h(NText, { type: "error" }, () => "还没做对")
}
const desktopColumns: DataTableColumn<LearnExerciseAttempt>[] = [
{ title: "班级", key: "className", width: 80 },
{ title: "学号", key: "username", width: 140 },
{
@@ -22,10 +31,7 @@ const columns: DataTableColumn<LearnExerciseAttempt>[] = [
title: "结果",
key: "solved",
width: 130,
render: (row) =>
row.solved
? h(NText, { type: "success" }, () => `做对了(第 ${row.attemptsToSolve} 次)`)
: h(NText, { type: "error" }, () => "还没做对"),
render: renderResult,
},
{ title: "提交次数", key: "attempts", width: 100, sorter: "default" },
{
@@ -45,6 +51,27 @@ const columns: DataTableColumn<LearnExerciseAttempt>[] = [
},
]
/** 手机上七列放不下:一个人一格,名字和结果一行,下面一行小字是交了几次、最后错在哪 */
const mobileColumns: DataTableColumn<LearnExerciseAttempt>[] = [
{
title: "学生",
key: "username",
render: (row) =>
h("div", { class: "m-row" }, [
h("div", { class: "m-line" }, [h(UserName, { username: row.username }), renderResult(row)]),
h(
NText,
{ depth: 3, class: "m-sub" },
() =>
`交了 ${row.attempts} 次 · ${parseTime(row.lastAttemptAt, "M月D日 HH:mm")}` +
(row.lastWrongAnswer ? ` · 最后错在 ${row.lastWrongAnswer}` : ""),
),
]),
},
]
const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns))
onMounted(async () => {
try {
rows.value = await getLearnExerciseAttempts(props.exerciseId, {
@@ -72,3 +99,19 @@ onMounted(async () => {
style="padding: 16px 0"
/>
</template>
<style scoped>
:deep(.m-row) {
display: flex;
flex-direction: column;
gap: 2px;
}
:deep(.m-line) {
display: flex;
justify-content: space-between;
gap: 8px;
}
:deep(.m-sub) {
font-size: 12px;
}
</style>
+63 -2
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import type { LearnBoard, LearnBoardStudent, LearnExerciseProgress } from "utils/types"
import { getLearnBoard, getLearnExercises } from "admin/api"
import { parseTime, readableDuration } from "utils/functions"
@@ -18,6 +19,7 @@ import ExerciseAttempts from "./ExerciseAttempts.vue"
* 不选班就按排名页同一套规则猜老师在教哪个班。
*/
const configStore = useConfigStore()
const theme = useThemeVars()
const KIND: Record<string, string> = {
mcq: "选择",
@@ -148,7 +150,7 @@ onMounted(load)
:options="classOptions"
filterable
placeholder="选班级"
style="width: 170px"
class="class-select"
/>
</div>
@@ -257,7 +259,7 @@ onMounted(load)
v-model:value="lessonId"
:options="lessonOptions"
size="small"
style="width: 170px"
class="lesson-select"
/>
</div>
<div v-if="!lessonExercises.length" class="muted none">这课没有练一练</div>
@@ -595,4 +597,63 @@ onMounted(load)
.flag {
color: var(--learn-orange-text);
}
.class-select,
.lesson-select {
width: 170px;
}
/* 手机上:方块格在自己的框里横着滑(名字列钉在左边),练一练排到下面 */
@media (max-width: 768px) {
.top {
flex-wrap: wrap;
}
.top .title {
width: 100%;
}
.top .grow {
display: none;
}
.class-select {
flex: 1;
width: auto;
min-width: 0;
}
.stats {
gap: 8px;
margin-bottom: 12px;
}
.stat {
padding: 10px 12px;
}
.stat-label {
font-size: 12px;
}
.stat-value {
font-size: 18px;
}
.unit {
font-size: 13px;
}
.main {
flex-direction: column;
align-items: stretch;
}
.c-name {
position: sticky;
left: 0;
z-index: 1;
width: 96px;
margin-left: -16px;
padding-left: 16px;
background: v-bind("theme.bodyColor");
}
.side {
width: auto;
}
.lesson-select {
flex: 1;
width: auto;
min-width: 0;
}
}
</style>
+17
View File
@@ -1029,4 +1029,21 @@ onMounted(async () => {
.stale {
opacity: 0.5;
}
/* 手机:编号 + 题目一行,难度 + 可见折到第二行;判题方式那行也折 */
@media (max-width: 768px) {
.card {
padding: 12px;
}
.headRow,
.cardHead,
.moreRow {
flex-wrap: wrap;
}
.headRow .grow {
flex: 1 1 180px;
}
}
</style>
+145 -2
View File
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { useRouteQuery } from "@vueuse/router"
import { Icon } from "@iconify/vue"
import { useThemeVars } from "naive-ui"
import { zonedParts } from "utils/functions"
import type { StuckProblem, StuckProblemDetail, StuckStudent } from "utils/types"
import { getStuckProblemDetail, getStuckProblems } from "admin/api"
@@ -8,6 +10,8 @@ import UserName from "shared/components/UserName.vue"
import { classLabel, groupClassOptions } from "oj/submission/utils"
import { periodRange, type Period } from "oj/statistics/period"
import { resultName, useResultColor } from "oj/statistics/results"
import { useBackClose } from "shared/composables/backClose"
import { useBreakpoints } from "shared/composables/breakpoints"
import "oj/learn/learn.css"
/**
@@ -20,6 +24,8 @@ import "oj/learn/learn.css"
*/
const router = useRouter()
const configStore = useConfigStore()
const { isMobile } = useBreakpoints()
const theme = useThemeVars()
const resultColor = useResultColor()
const PERIODS: { label: string; value: Period }[] = [
@@ -145,6 +151,27 @@ async function loadDetail() {
}
}
/**
* 手机上三栏放不下:知识点横着滑一排、题目列表占满一屏,点一道题,这道题的详情盖满整屏,
* 按返回键回到列表
*/
const detailOpen = ref(false)
useBackClose(detailOpen, isMobile)
watch(isMobile, (mobile) => {
if (!mobile) detailOpen.value = false
})
const route = useRoute()
async function pick(row: StuckProblem) {
if (!isMobile.value) {
picked.value = String(row.id)
return
}
// 先把地址换好再打开:开着的时候 useBackClose 会拦下一切路由跳转(连 replace 也拦)
await router.replace({ query: { ...route.query, problem: String(row.id) } })
detailOpen.value = true
}
watch([className, period], load)
watch(() => [selected.value?.id, problems.value] as const, loadDetail)
onMounted(load)
@@ -296,6 +323,7 @@ const links = computed(() => {
<span class="grow" />
<span class="muted small">一次就对</span>
</div>
<div class="know-list">
<button
v-for="row in knowledge"
:key="row.name"
@@ -314,6 +342,7 @@ const links = computed(() => {
<span class="pct num">{{ row.rate }}%</span>
</span>
</button>
</div>
</section>
<section class="card problems">
@@ -339,7 +368,7 @@ const links = computed(() => {
type="button"
class="item"
:class="{ on: row.id === selected?.id }"
@click="picked = String(row.id)"
@click="pick(row)"
>
<span class="line">
<span class="pid muted num">{{ row.displayId }}</span>
@@ -359,7 +388,13 @@ const links = computed(() => {
</div>
</section>
<section v-if="list.length" class="card detail">
<section v-if="list.length" class="card detail" :class="{ open: detailOpen }">
<div v-if="isMobile" class="detail-bar">
<n-button quaternary circle @click="detailOpen = false">
<template #icon><Icon icon="ph:arrow-left" /></template>
</n-button>
<span class="strong">{{ activeTag || "全部" }}</span>
</div>
<n-spin :show="detailLoading">
<div v-if="detail && links" class="detail-body">
<div class="line dhead">
@@ -860,4 +895,112 @@ const links = computed(() => {
width: 96px;
flex-shrink: 0;
}
@media (max-width: 768px) {
.top {
flex-wrap: wrap;
}
.top .grow {
display: none;
}
.title {
width: 100%;
}
.top .n-select {
flex: 1 1 100%;
min-width: 0;
}
.seg {
width: 100%;
}
.seg button {
flex: 1;
padding: 0 4px;
}
.main {
flex-direction: column;
align-items: stretch;
}
.know,
.problems {
width: auto;
}
/* 知识点横着一排滑 */
.know-list {
display: flex;
overflow-x: auto;
border-top: 1px solid var(--learn-line);
}
.know .item {
flex: 0 0 150px;
border-top: 0;
border-left: 1px solid var(--learn-line);
}
.know .item:first-child {
border-left: 0;
}
.know .item.on {
box-shadow: inset 0 -3px 0 var(--learn-green);
}
.know .track {
width: auto;
flex: 1;
}
.scroll {
max-height: none;
}
/* 一道题的详情盖满整屏 */
.detail {
display: none;
}
.detail.open {
display: block;
position: fixed;
inset: 0;
z-index: 2000;
border: 0;
border-radius: 0;
overflow-y: auto;
background: v-bind("theme.bodyColor");
}
.detail-bar {
position: sticky;
top: 0;
z-index: 1;
display: flex;
align-items: center;
gap: 4px;
height: 48px;
padding: 0 8px;
border-bottom: 1px solid var(--learn-line);
background: v-bind("theme.bodyColor");
}
.detail-body {
padding: 14px 16px 32px;
}
.links {
margin-left: 0;
width: 100%;
}
.stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
row-gap: 12px;
}
.cavg {
display: none;
}
.cname {
width: 84px;
}
.track.mid {
width: 64px;
}
.dcol {
flex: 1;
width: auto;
}
.names {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
+82 -2
View File
@@ -150,14 +150,14 @@ onMounted(async () => {
<n-select
:value="selected || null"
:options="gradeOptions"
style="width: 100px"
class="grade-select"
@update:value="pickGrade"
/>
<span class="muted">和</span>
<n-select
:value="compare"
:options="compareOptions"
style="width: 170px"
class="compare-select"
@update:value="(value: string) => (compare = value)"
/>
</div>
@@ -455,6 +455,12 @@ a.c-title:hover .ptitle {
border-radius: 5px;
background: var(--worse);
}
.grade-select {
width: 100px;
}
.compare-select {
width: 170px;
}
.dot-before {
width: 10px;
height: 10px;
@@ -462,4 +468,78 @@ a.c-title:hover .ptitle {
box-sizing: border-box;
border: 2px solid rgba(128, 128, 128, 0.8);
}
/* 手机上一道题两行:题名和两个百分比一行,下面一整行是轨道;人数藏掉 */
@media (max-width: 768px) {
.top {
flex-wrap: wrap;
}
.top .title {
width: 100%;
}
.top .grow {
display: none;
}
.grade-select {
width: 90px;
}
.compare-select {
flex: 1;
width: auto;
min-width: 0;
}
.stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.stat {
padding: 12px 14px;
}
.value {
font-size: 22px;
}
.row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
column-gap: 10px;
row-gap: 4px;
}
.axis {
height: auto;
grid-template-columns: minmax(0, 1fr);
}
.axis .c-title {
display: none;
}
.legend {
grid-row: 1;
margin-left: 0;
}
.item {
height: auto;
padding-top: 8px;
padding-bottom: 8px;
}
.c-title {
width: auto;
}
.c-track {
grid-column: 1 / -1;
min-width: 0;
}
.item .c-track {
grid-row: 2;
}
.c-values {
grid-row: 1;
grid-column: 2;
width: auto;
}
.c-delta {
grid-row: 1;
grid-column: 3;
}
.c-users {
display: none;
}
}
</style>
@@ -2,6 +2,7 @@
import { errorCode, errorMessage } from "utils/api"
import { deleteContestProblem, deleteProblem, makeProblemPublic } from "admin/api"
import download from "utils/download"
import { useBreakpoints } from "shared/composables/breakpoints"
/**
* 题目列表一行的操作:编辑、在前台看、下载测试数据、删除直接摆出来(用户说的:不要收进 ⋯)。
@@ -22,6 +23,10 @@ const route = useRoute()
const router = useRouter()
const message = useMessage()
// 手机上题目列表一道题一张卡,按钮在卡片里放不下一行就折下去;测试数据是 zip,手机上下了也没处用,
// 转成公开题要想好新题号,也留给电脑,都不摆
const { isMobile } = useBreakpoints()
const isContestProblem = computed(() => route.name === "admin contest problem list")
const showMakePublicModal = ref(false)
@@ -106,11 +111,13 @@ async function handleMakePublic() {
}
</script>
<template>
<n-flex :size="6" :wrap="false">
<n-flex :size="6" :wrap="isMobile">
<n-button size="small" @click="goEdit">编辑</n-button>
<n-button size="small" title="在前台打开这道题" @click="goCheck">前台看</n-button>
<n-button size="small" title="下载测试数据" @click="downloads">测试数据</n-button>
<n-button v-if="isContestProblem" size="small" @click="openMakePublicModal">
<n-button v-if="!isMobile" size="small" title="下载测试数据" @click="downloads">
测试数据
</n-button>
<n-button v-if="isContestProblem && !isMobile" size="small" @click="openMakePublicModal">
转成公开题
</n-button>
<span :title="props.triedUsers ? `有 ${props.triedUsers} 人交过,删不掉` : undefined">
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { useBreakpoints } from "shared/composables/breakpoints"
import { caseStatus, type AnswerSource, type CaseRow } from "./caseRows"
/**
@@ -14,6 +15,8 @@ const props = defineProps<{
}>()
const theme = useThemeVars()
// 手机上「当例子」那一列的表头藏了,勾选框自己带上字
const { isMobile } = useBreakpoints()
/** 输出最多先露这么多行,长了点开看全 */
const CLIP_LINES = 6
@@ -106,11 +109,14 @@ const status = (row: CaseRow) => caseStatus(row, props.source)
</div>
</template>
</div>
<!-- 桌面上 display: contents,三样各占一格;手机上折到输入输出下面排成一行 -->
<div class="tools">
<n-checkbox
v-if="!examplesOnly"
v-model:checked="row.example"
class="example"
:aria-label="`第 ${index + 1} 组当例子`"
:label="isMobile ? '当例子' : undefined"
/>
<span class="mark" :class="status(row)">
<template v-if="status(row) === 'ok'">✓</template>
@@ -128,6 +134,7 @@ const status = (row: CaseRow) => caseStatus(row, props.source)
</n-button>
</div>
</div>
</div>
</template>
<style scoped>
@@ -254,4 +261,37 @@ const status = (row: CaseRow) => caseStatus(row, props.source)
.remove:focus-visible {
opacity: 1;
}
.tools {
display: contents;
}
/* 手机:一组占两行 —— 编号 / 输入 / 输出,下面一行 当例子 ✓ 删掉(没有悬停,删掉常显) */
@media (max-width: 768px) {
.row,
.examplesOnly .row {
grid-template-columns: 18px minmax(0, 1fr) minmax(0, 1fr);
gap: 6px;
}
.row.head > :nth-child(n + 4) {
display: none;
}
.tools {
grid-column: 2 / -1;
display: flex;
align-items: center;
gap: 10px;
}
.example {
margin-top: 0;
}
.remove {
margin-left: auto;
opacity: 1;
}
}
</style>
@@ -3,6 +3,7 @@ import { NButton, NTag } from "naive-ui"
import Pagination from "shared/components/Pagination.vue"
import type { AdminProblemRow } from "utils/types"
import { batchTagProblems, getProblemList } from "admin/api"
import { useBreakpoints } from "shared/composables/breakpoints"
interface Props {
show: boolean
@@ -25,7 +26,9 @@ const page = ref(1)
const limit = ref(10)
const keyword = ref("")
const columns: DataTableColumn<AdminProblemRow>[] = [
const { isMobile } = useBreakpoints()
const allColumns: DataTableColumn<AdminProblemRow>[] = [
{ title: "题号", key: "_id", width: 90 },
{
title: "题目",
@@ -51,6 +54,15 @@ const columns: DataTableColumn<AdminProblemRow>[] = [
},
]
// 手机上放不下四列:不摆「学生看得到」,题号收窄,题目吃剩下的宽度
const mobileColumns: DataTableColumn<AdminProblemRow>[] = [
{ ...allColumns[0], width: 64 } as DataTableColumn<AdminProblemRow>,
{ ...allColumns[1], minWidth: undefined } as DataTableColumn<AdminProblemRow>,
{ ...allColumns[3], width: 64 } as DataTableColumn<AdminProblemRow>,
]
const columns = computed(() => (isMobile.value ? mobileColumns : allColumns))
async function listProblems() {
if (page.value < 1) page.value = 1
const offset = (page.value - 1) * limit.value
@@ -117,7 +129,7 @@ watchDebounced(
<span>共 {{ total }} 道题</span>
<n-input
v-model:value="keyword"
style="width: 220px"
:style="{ width: isMobile ? '160px' : '220px' }"
placeholder="题号或题目名"
clearable
/>
+38
View File
@@ -1707,4 +1707,42 @@ onMounted(async () => {
border-radius: 6px;
overflow: hidden;
}
/* 手机:卡片收窄,挤在一行的东西折下去 */
@media (max-width: 768px) {
.card {
padding: 12px;
}
/* 编号 + 题目一行,难度 + 可见折到第二行 */
.headRow {
flex-wrap: wrap;
}
.headRow .grow {
flex: 1 1 180px;
}
.io {
grid-template-columns: minmax(0, 1fr);
}
/* 老题面里的图片带着写死的像素宽(有 1118px 的),把整页撑出横向滚动 */
.card :deep(.w-e-image-container) {
max-width: 100%;
height: auto !important;
}
.card :deep(.w-e-image-container img) {
height: auto !important;
}
.inlineRow {
flex-wrap: wrap;
}
.blockers {
-webkit-line-clamp: 3;
}
}
</style>
+83 -6
View File
@@ -15,6 +15,7 @@ import BatchTagModal from "./components/BatchTagModal.vue"
import CreateProblemMenu from "./components/CreateProblemMenu.vue"
import PageHeader from "admin/components/PageHeader.vue"
import ProblemTypeTag from "oj/problem/components/ProblemTypeTag.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
/**
* 后台题目列表(设计稿「后台题目列表」PlA / PlB)。一行一道题:题号、题目(点了就是编辑)、
@@ -32,6 +33,7 @@ const props = defineProps<Props>()
const route = useRoute()
const router = useRouter()
const theme = useThemeVars()
const { isMobile } = useBreakpoints()
const title = computed(
() =>
@@ -96,7 +98,7 @@ const { query, clearQuery } = usePagination<ProblemQuery>({
})
const counts = ref<{ all: number; visible: number; hidden: number } | null>(null)
// 手机上后台左边还有一列图标,三个按钮放不下、「藏着的」被切掉:换小号,再不够就横着滑
// 手机上三个按钮放不下、「藏着的」被切掉:换小号,再不够就横着滑
const narrow = useMediaQuery("(max-width: 640px)")
const visibleTabs = computed(() => [
{ value: "" as const, label: "全部", count: counts.value?.all },
@@ -206,11 +208,62 @@ const baseColumns: DataTableColumn<AdminProblemRow>[] = [
},
]
const columns = computed<DataTableColumn<AdminProblemRow>[]>(() =>
/**
* 手机上一道题一张卡:题号 + 题目一行,知识点 / 人数 / 出题人 / 日期一行小字,操作按钮一行;
* 「学生看得到」开关单独一列靠右,不用横着滑就能点到
*/
function renderMobileCard(row: AdminProblemRow) {
const meta = [
row.tags.length
? row.tags.slice(0, 2).join("、") + (row.tags.length > 2 ? ` +${row.tags.length - 2}` : "")
: null,
isContestProblemList.value
? baseColumns.filter((it) => !("key" in it) || it.key !== "users")
: [{ type: "selection" }, ...baseColumns],
)
? null
: row.triedUsers
? `做对 ${row.solvedUsers} / ${row.triedUsers}`
: "没人做过",
row.username,
parseTime(row.createTime, "YYYY-MM-DD"),
].filter((it): it is string => !!it)
return h("div", { class: "m-card" }, [
h("div", { class: "m-head" }, [h("span", { class: "did" }, row._id), renderTitle(row)]),
// 每一项自己不断开(「做对 8 / 14」折成两半看不懂),放不下整项换行
h(
"div",
{ class: "m-meta muted" },
meta.map((text, i) => h("span", { class: "m-item", key: i }, i ? ` · ${text}` : text)),
),
h(Actions, {
problemID: row.id,
problemDisplayID: row._id,
triedUsers: row.triedUsers,
onUpdated: listProblems,
}),
])
}
const mobileColumns: DataTableColumn<AdminProblemRow>[] = [
{ title: "题目", key: "title", render: renderMobileCard },
{
title: "看得到",
key: "visible",
width: 72,
align: "center",
render: (row) =>
h(NSwitch, {
value: row.visible,
rubberBand: false,
onUpdateValue: () => toggleVisible(row.id),
}),
},
]
const columns = computed<DataTableColumn<AdminProblemRow>[]>(() => {
const cols = isMobile.value ? mobileColumns : baseColumns
return isContestProblemList.value
? cols.filter((it) => !("key" in it) || it.key !== "users")
: [{ type: "selection" }, ...cols]
})
async function listProblems() {
if (query.page < 1) query.page = 1
@@ -312,7 +365,7 @@ watch(() => [query.page, query.limit, query.author, query.kind, query.visible],
v-model:value="query.keyword"
placeholder="题号或题目名"
clearable
style="width: 220px"
:style="{ width: isMobile ? '100%' : '220px' }"
@clear="clearQuery"
/>
<n-select
@@ -385,6 +438,7 @@ watch(() => [query.page, query.limit, query.author, query.kind, query.visible],
border: 1px solid v-bind("theme.successColorSuppl");
background: v-bind("theme.hoverColor");
white-space: nowrap;
flex-wrap: wrap;
}
.spacer {
@@ -409,6 +463,7 @@ watch(() => [query.page, query.limit, query.author, query.kind, query.visible],
.problem-table :deep(.did) {
font-weight: 600;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
@@ -456,6 +511,28 @@ watch(() => [query.page, query.limit, query.author, query.kind, query.visible],
font-size: 12px;
}
.problem-table :deep(.m-card) {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.problem-table :deep(.m-head) {
display: flex;
align-items: baseline;
gap: 8px;
}
.problem-table :deep(.m-meta) {
font-size: 13px;
line-height: 1.5;
}
.problem-table :deep(.m-item) {
white-space: nowrap;
}
.problem-table :deep(.num) {
font-variant-numeric: tabular-nums;
white-space: nowrap;
+30
View File
@@ -301,4 +301,34 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
.empty {
margin: 16px 0;
}
/* 手机:两栏上下排;题数条不画,名字吃剩下的宽度,三个按钮放得下 */
@media (max-width: 768px) {
.columns {
flex-direction: column;
align-items: stretch;
}
.column {
flex: none;
}
.row {
gap: 6px;
padding: 0 0 0 6px;
}
.name {
flex: 1 1 auto;
}
.bar {
display: none;
}
.count {
width: auto;
white-space: nowrap;
}
}
</style>
+16
View File
@@ -817,4 +817,20 @@ onMounted(load)
padding: 16px 0;
text-align: center;
}
/* 手机:奖章一行放不下说明,藏掉,名字吃剩下的宽度 */
@media (max-width: 768px) {
.picker-head b {
flex-shrink: 0;
}
.item.badge .grow {
display: none;
}
.item.badge .badge-name {
flex: 1 1 auto;
width: auto;
}
}
</style>
+86 -21
View File
@@ -5,13 +5,16 @@ import { usePagination } from "shared/composables/pagination"
import { parseTime } from "utils/functions"
import type { AdminProblemSet } from "utils/types"
import { getProblemSetList, toggleProblemSetVisible } from "../api"
import { NButton, NSwitch, NTag } from "naive-ui"
import { NButton, NSwitch, NTag, useThemeVars } from "naive-ui"
import { useBreakpoints } from "shared/composables/breakpoints"
/**
* 后台题单列表。难度、状态两列和筛选拿掉了(线上全是简单 / 活跃,「状态 + 可见」合成了「公开」),
* 换成老师真正关心的:几道题、多少人加入 / 做完、布置到哪天。点一行进一屏编辑
*/
const router = useRouter()
const theme = useThemeVars()
const { isMobile } = useBreakpoints()
const total = ref(0)
const problemSets = ref<AdminProblemSet[]>([])
@@ -21,14 +24,60 @@ function edit(row: AdminProblemSet) {
router.push({ name: "admin problemset edit", params: { problemSetId: row.id } })
}
const columns: DataTableColumn<AdminProblemSet>[] = [
function renderTitle(row: AdminProblemSet) {
return h(NButton, { text: true, onClick: () => edit(row) }, { default: () => row.title })
}
function renderAssigned(row: AdminProblemSet) {
return row.assigning
? h(
NTag,
{ type: "info", size: "small", bordered: false },
{ default: () => `到 ${parseTime(row.assignedUntil!, "M月D日")}` },
)
: "—"
}
function renderVisible(row: AdminProblemSet) {
return h(NSwitch, {
value: row.visible,
size: isMobile.value ? "medium" : "small",
rubberBand: false,
onUpdateValue: () => toggleVisible(row.id),
})
}
/** 手机上一个题单一张卡:名字一行(点了编辑),题数 · 加入 / 做完 · 奖章 · 创建者 · 日期一行小字 */
function renderMobileCard(row: AdminProblemSet) {
const items = [
`${row.problemsCount} 道题`,
`${row.participantCount} 人加入 · ${row.completedCount} 人做完`,
row.badgeCount ? `${row.badgeCount} 个奖章` : null,
row.createdBy.username,
parseTime(row.createTime, "YYYY-MM-DD"),
].filter((it): it is string => !!it)
return h("div", { class: "m-card" }, [
h("div", { class: "m-head" }, [renderTitle(row), row.assigning ? renderAssigned(row) : null]),
h(
"div",
{ class: "m-meta" },
items.map((text, i) => h("span", { class: "m-item", key: i }, i ? ` · ${text}` : text)),
),
])
}
const mobileColumns: DataTableColumn<AdminProblemSet>[] = [
{ title: "题单", key: "title", render: renderMobileCard },
{ title: "公开", key: "visible", width: 72, align: "center", render: renderVisible },
]
const desktopColumns: DataTableColumn<AdminProblemSet>[] = [
{ title: "ID", key: "id", width: 70 },
{
title: "题单",
key: "title",
minWidth: 200,
render: (row) =>
h(NButton, { text: true, onClick: () => edit(row) }, { default: () => row.title }),
render: renderTitle,
},
{ title: "题数", key: "problemsCount", width: 70 },
{
@@ -42,14 +91,7 @@ const columns: DataTableColumn<AdminProblemSet>[] = [
title: "布置",
key: "assignedUntil",
width: 130,
render: (row) =>
row.assigning
? h(
NTag,
{ type: "info", size: "small", bordered: false },
{ default: () => `到 ${parseTime(row.assignedUntil!, "M月D日")}` },
)
: "—",
render: renderAssigned,
},
{
title: "创建者",
@@ -67,16 +109,12 @@ const columns: DataTableColumn<AdminProblemSet>[] = [
title: "公开",
key: "visible",
width: 80,
render: (row) =>
h(NSwitch, {
value: row.visible,
size: "small",
rubberBand: false,
onUpdateValue: () => toggleVisible(row.id),
}),
render: renderVisible,
},
]
const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns))
async function listProblemSets() {
if (query.page < 1) query.page = 1
const offset = (query.page - 1) * query.limit
@@ -108,10 +146,37 @@ watch(() => [query.page, query.limit], listProblemSets)
placeholder="搜题单名字"
clearable
@clear="clearQuery"
style="width: 220px"
:style="{ width: isMobile ? '100%' : '220px' }"
/>
</template>
</PageHeader>
<n-data-table striped :columns="columns" :data="problemSets" />
<n-data-table striped class="set-table" :columns="columns" :data="problemSets" />
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
</template>
<style scoped>
.set-table :deep(.m-card) {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.set-table :deep(.m-head) {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
font-weight: 500;
}
.set-table :deep(.m-meta) {
font-size: 13px;
line-height: 1.5;
color: v-bind("theme.textColor3");
}
.set-table :deep(.m-item) {
white-space: nowrap;
}
</style>
+18 -1
View File
@@ -212,7 +212,7 @@ onMounted(() => {
<h3>
班级 <span class="count">{{ websiteConfig.classList.length }} 个</span>
</h3>
<div v-for="group in classGroups" :key="group.grade" class="grade">
<div v-for="group in classGroups" :key="group.grade" class="grade listed">
<span class="grade-label">{{ group.grade === "其他" ? "其他" : `${group.grade} 级` }}</span>
<div class="chips">
<n-tag
@@ -417,4 +417,21 @@ h3 {
.mono {
font-family: Consolas, Monaco, monospace;
}
/* 手机:班多的年级整排折到年级名下面去了,和班少的年级对不齐;班级在年级名右边自己折行 */
@media (max-width: 768px) {
.grade.listed {
flex-wrap: nowrap;
align-items: flex-start;
}
.grade.listed .grade-label {
line-height: 28px;
}
.grade .chips {
flex: 1 1 auto;
min-width: 0;
}
}
</style>
+25 -1
View File
@@ -226,7 +226,7 @@ onUnmounted(stopRolling)
filterable
placeholder="选班级"
aria-label="班级"
style="width: 160px"
class="lucky-select"
/>
<span :key="pulseKey" class="lucky-name" :class="{ pulse: !isRolling && luckyGuy }">
{{ luckyGuy }}
@@ -353,6 +353,10 @@ onUnmounted(stopRolling)
border: 1px solid v-bind("theme.dividerColor");
}
.lucky-select {
width: 160px;
}
.lucky-title {
font-size: 15px;
}
@@ -386,4 +390,24 @@ onUnmounted(stopRolling)
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
/* 手机上一行放不下:标题 + 选班一行,抽到的名字和按钮各占一整行 */
@media (max-width: 768px) {
.lucky {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 12px;
box-sizing: border-box;
width: 100%;
}
.lucky-select {
width: 100%;
}
.lucky-name,
.lucky > .n-button {
grid-column: 1 / -1;
}
}
</style>
@@ -693,6 +693,7 @@ function typeName(type: ExerciseType) {
.sub a {
color: #18a058;
text-decoration: none;
white-space: nowrap;
}
.sub a.danger {
color: #d03050;
+62 -1
View File
@@ -17,6 +17,7 @@ import {
} from "../api"
import ExerciseManager from "./components/ExerciseManager.vue"
import RelatedProblems from "./components/RelatedProblems.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
/**
* 编辑一课(设计稿「后台 · 编辑一课」):知识点 / 情境 / 项目三个字段,正文左边写、
@@ -31,6 +32,10 @@ const message = useMessage()
const isCreate = computed(() => route.name === "admin tutorial create")
// 手机上左右分栏各剩一百多像素,改成正文「写 / 预览」切着看
const { isMobile } = useBreakpoints()
const pane = ref<"source" | "preview">("source")
const tutorial = reactive<TutorialEdit>({
id: 0,
title: "",
@@ -197,8 +202,15 @@ onMounted(init)
<div class="card-head">
<span class="strong">正文</span>
<span class="muted">Markdown</span>
<template v-if="isMobile">
<span class="grow" />
<n-radio-group v-model:value="pane" size="small">
<n-radio-button value="source">写</n-radio-button>
<n-radio-button value="preview">预览</n-radio-button>
</n-radio-group>
</template>
</div>
<div class="split">
<div class="split" :class="isMobile ? `only-${pane}` : ''">
<div class="source">
<MarkdownEditor
ref="editor"
@@ -355,4 +367,53 @@ onMounted(init)
gap: 14px;
overflow-y: auto;
}
/* 手机:整页跟着滚,正文一次只露写或预览,练一练和相关题排到下面 */
@media (max-width: 768px) {
.page {
height: auto;
min-height: 0;
}
.top {
flex-wrap: wrap;
gap: 8px;
}
.field {
width: 100%;
}
.field > span {
width: 3em;
flex-shrink: 0;
}
.field > :deep(.n-input),
.field > :deep(.n-base-selection),
.field > :deep(.n-select) {
flex: 1;
width: auto !important;
min-width: 0;
}
.work {
flex-direction: column;
}
.editor-card {
flex: none;
height: 70vh;
}
.only-source .preview,
.only-preview .source {
display: none;
}
.split .source,
.split .preview {
flex-basis: 100%;
border-right: 0;
}
.preview {
padding: 12px;
}
.side {
width: auto;
overflow-y: visible;
}
}
</style>
+47 -2
View File
@@ -169,7 +169,7 @@ onMounted(load)
<span class="c-drag" />
<span class="c-no">#</span>
<span class="c-title">知识点 · 情境</span>
<span class="c-num">练一练</span>
<span class="c-num c-ex">练一练</span>
<span class="c-num">相关题</span>
<span class="c-num wide">打开过的人</span>
<span class="c-switch">公开</span>
@@ -230,7 +230,7 @@ onMounted(load)
<span class="kp">{{ splitTitle(item.title).kp }}</span>
<span class="scene">{{ splitTitle(item.title).scene }}</span>
</span>
<span class="c-num num">{{ item.exerciseCount || "-" }}</span>
<span class="c-num c-ex num">{{ item.exerciseCount || "-" }}</span>
<span class="c-num num">
<template v-if="item.problemCount">{{ item.problemCount }}</template>
<span v-else class="warn">还没挑</span>
@@ -426,4 +426,49 @@ onMounted(load)
color: #18a058;
text-decoration: none;
}
/* 手机:拖动排序(触屏拖不动)、练一练数、打开过的人、情境都不摆,留 知识点 / 相关题 / 公开 / 编辑 */
@media (max-width: 768px) {
.top {
flex-wrap: wrap;
gap: 8px;
}
.title,
.seg button {
white-space: nowrap;
}
.row {
gap: 8px;
padding: 0 12px;
}
.grouprow {
flex-wrap: wrap;
height: auto;
min-height: 44px;
padding: 8px 12px;
}
.grouprow a {
white-space: nowrap;
}
.c-drag,
.c-ex,
.c-num.wide,
.scene {
display: none;
}
.kp {
overflow: hidden;
text-overflow: ellipsis;
}
.c-num {
width: 48px;
}
.c-switch {
width: 44px;
}
.c-edit {
width: auto;
white-space: nowrap;
}
}
</style>
@@ -1,6 +1,7 @@
<script lang="ts" setup>
import { editUser } from "admin/api"
import type { AdminUserForm } from "utils/types"
import { useBreakpoints } from "shared/composables/breakpoints"
/**
* 用户列表一行的操作:重置密码、编辑、禁用、删除直接摆出来(用户说的:不要收进 ⋯)。
@@ -18,6 +19,8 @@ const emit = defineEmits<{
}>()
const dialog = useDialog()
// 手机上在卡片里,放不下一行就折下去
const { isMobile } = useBreakpoints()
async function banUser() {
props.user.isDisabled = !props.user.isDisabled
@@ -36,7 +39,7 @@ function confirmDelete() {
}
</script>
<template>
<n-flex :size="6" :wrap="false">
<n-flex :size="6" :wrap="isMobile">
<n-button size="small" @click="emit('resetPassword', props.user)">重置密码</n-button>
<n-button size="small" @click="emit('openEditModal', props.user)">编辑</n-button>
<n-button size="small" @click="banUser">
+29 -2
View File
@@ -11,6 +11,7 @@ import {
} from "utils/constants"
import { parseTime } from "utils/functions"
import { checkUsernames, importUsers } from "../api"
import { useBreakpoints } from "shared/composables/breakpoints"
/**
* 批量建号(设计稿「后台用户与批量建号」UsGen / UsGenIssue / UsDone)。
@@ -21,6 +22,8 @@ import { checkUsernames, importUsers } from "../api"
* 一个 CSV,Excel 打开中文还是乱码。新班级后端会顺手加进班级列表。
*/
const message = useMessage()
// 手机上左右两栏改成上下:先填班级和名单,预览排在下面
const { isMobile } = useBreakpoints()
const classNo = ref("")
const rawInput = ref("")
@@ -237,7 +240,7 @@ body{margin:12mm;font-family:"Microsoft YaHei","PingFang SC",sans-serif}
v-model:value="rawInput"
type="textarea"
:input-props="{ id: 'names' }"
:rows="20"
:rows="isMobile ? 8 : 20"
placeholder="一行一个名字"
/>
</div>
@@ -303,7 +306,11 @@ body{margin:12mm;font-family:"Microsoft YaHei","PingFang SC",sans-serif}
</n-text>
</div>
</template>
<n-empty v-else description="左边粘进名单,这里就列出会建成的账号" style="margin-top: 80px" />
<n-empty
v-else
:description="`${isMobile ? '上面' : '左边'}粘进名单,这里就列出会建成的账号`"
:style="{ marginTop: isMobile ? '24px' : '80px' }"
/>
</div>
</div>
</template>
@@ -445,6 +452,26 @@ body{margin:12mm;font-family:"Microsoft YaHei","PingFang SC",sans-serif}
gap: 8px;
}
@media (max-width: 768px) {
.layout {
flex-direction: column;
align-items: stretch;
}
.left {
width: auto;
}
.done-bar {
flex-wrap: wrap;
white-space: normal;
}
.slips {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.slip {
border: 1px dashed rgba(128, 128, 128, 0.5);
border-radius: 4px;
+81 -19
View File
@@ -15,6 +15,7 @@ import { useRouteQuery } from "@vueuse/router"
import { NO_CLASS } from "@oj2/contract"
import { classLabel, groupClassOptions } from "oj/submission/utils"
import { useConfigStore } from "shared/store/config"
import { useBreakpoints } from "shared/composables/breakpoints"
/**
* 后台用户列表(设计稿「后台用户与批量建号」UsList)。老师进来多半是三件事:学生忘了密码、
@@ -24,6 +25,7 @@ import { useConfigStore } from "shared/store/config"
const message = useMessage()
const theme = useThemeVars()
const configStore = useConfigStore()
const { isMobile } = useBreakpoints()
interface UserQuery {
keyword: string
@@ -91,7 +93,56 @@ function lastLoginText(row: AdminUserForm) {
])
}
const columns: DataTableColumn<AdminUserForm>[] = [
function renderPassword(row: AdminUserForm) {
return h(PasswordCell, {
user: row,
revealed: revealedPasswords.value.has(row.id),
onToggle: (id: number) => {
if (!revealedPasswords.value.delete(id)) revealedPasswords.value.add(id)
},
})
}
function renderActions(row: AdminUserForm) {
return h(Actions, {
user: row,
onDeleteUser: onDeleteUsers,
onUserBanned,
onOpenEditModal,
onResetPassword,
})
}
/**
* 手机上一个人一张卡:用户名一行,班级 · 上次登录 · 建号一行小字(每项不断开),
* 密码单独一行(学生忘了密码是最常见的事,要一眼找到),操作按钮放不下就折行
*/
function renderMobileCard(row: AdminUserForm) {
return h("div", { class: "m-card" }, [
h(UserNameCell, { user: row }),
h("div", { class: "m-meta muted" }, [
h("span", { class: "m-item" }, row.className ? classLabel(row.className) : "没填班级"),
h("span", { class: "m-item" }, [" · ", lastLoginText(row)]),
row.createTime
? h("span", { class: "m-item" }, ` · 建号 ${parseTime(row.createTime, "YYYY-MM-DD")}`)
: null,
]),
row.rawPassword
? h("div", { class: "m-password" }, [
h("span", { class: "muted" }, "密码"),
renderPassword(row),
])
: null,
renderActions(row),
])
}
const mobileColumns: DataTableColumn<AdminUserForm>[] = [
{ type: "selection" },
{ title: "用户", key: "username", render: renderMobileCard },
]
const desktopColumns: DataTableColumn<AdminUserForm>[] = [
{ type: "selection" },
{
title: "用户名",
@@ -111,14 +162,7 @@ const columns: DataTableColumn<AdminUserForm>[] = [
title: "密码",
key: "raw_password",
width: 150,
render: (row) =>
h(PasswordCell, {
user: row,
revealed: revealedPasswords.value.has(row.id),
onToggle: (id: number) => {
if (!revealedPasswords.value.delete(id)) revealedPasswords.value.add(id)
},
}),
render: renderPassword,
},
{ title: "上次登录", key: "last_login", width: 150, render: lastLoginText },
{
@@ -136,17 +180,12 @@ const columns: DataTableColumn<AdminUserForm>[] = [
key: "actions",
title: "",
minWidth: 300,
render: (row) =>
h(Actions, {
user: row,
onDeleteUser: onDeleteUsers,
onUserBanned,
onOpenEditModal,
onResetPassword,
}),
render: renderActions,
},
]
const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns))
const options: SelectOption[] = [
{ label: "普通", value: USER_TYPE.REGULAR_USER },
{ label: "学生管理员", value: USER_TYPE.STUDENT_ADMIN },
@@ -314,7 +353,7 @@ watch(() => [query.page, query.limit, query.type, query.orderBy, query.className
</template>
<template #filters>
<n-input
style="width: 220px"
:style="{ width: isMobile ? '100%' : '220px' }"
v-model:value="query.keyword"
placeholder="用户名或名字"
clearable
@@ -363,7 +402,7 @@ watch(() => [query.page, query.limit, query.type, query.orderBy, query.className
@close="onCloseEditModal"
>
<n-form label-placement="left" v-if="userEditing">
<n-grid :cols="2" :x-gap="16">
<n-grid :cols="isMobile ? 1 : 2" :x-gap="16">
<n-form-item-gi :span="1" label="用户">
<n-input v-model:value="userEditing.username" />
</n-form-item-gi>
@@ -420,6 +459,29 @@ watch(() => [query.page, query.limit, query.type, query.orderBy, query.className
color: v-bind("theme.textColor3");
}
.user-table :deep(.m-card) {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.user-table :deep(.m-meta) {
font-size: 13px;
line-height: 1.5;
}
.user-table :deep(.m-item) {
white-space: nowrap;
}
.user-table :deep(.m-password) {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.user-table :deep(.num) {
font-variant-numeric: tabular-nums;
white-space: nowrap;
+5
View File
@@ -50,3 +50,8 @@ body {
animation: none;
mix-blend-mode: normal;
}
/* 弹框各写各的定宽(420~820px),手机上一律收进屏幕里,左右各留 16px */
.n-modal {
max-width: calc(100vw - 32px);
}
+57 -16
View File
@@ -2,6 +2,7 @@
import { h } from "vue"
import { Icon } from "@iconify/vue"
import { RouterLink } from "vue-router"
import { useBackClose } from "shared/composables/backClose"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useDarkTransition } from "shared/composables/darkTransition"
import { STORAGE_KEY } from "utils/constants"
@@ -153,9 +154,9 @@ function toOption(i: NavItem): MenuOption {
}
}
// 收起时 64px 放不下分组标题(会折成两行),换成分隔线
// 收起时 64px 放不下分组标题(会折成两行),换成分隔线。手机上菜单在抽屉里,总是展开的
const options = computed<MenuOption[]>(() =>
siderCollapsed.value
collapsed.value && !isMobile.value
? groups.value.flatMap((g, idx) => [
...(idx ? [{ type: "divider", key: `divider-${g.label}` }] : []),
...g.items.map(toOption),
@@ -188,12 +189,12 @@ const current = computed(() => {
const collapsed = ref<boolean>(storage.get(STORAGE_KEY.ADMIN_SIDER_COLLAPSED) ?? false)
watch(collapsed, (v) => storage.set(STORAGE_KEY.ADMIN_SIDER_COLLAPSED, v))
// 窄屏一律收起,不写回偏好 —— 手机上看一眼不该改掉桌面端的习惯
const siderCollapsed = computed({
get: () => isMobile.value || collapsed.value,
set: (v) => {
if (!isMobile.value) collapsed.value = v
},
// 手机上不留侧栏(64px 的图标条占了 390 宽屏的六分之一),菜单收进左边滑出的抽屉。
// 按返回键先关抽屉;在抽屉里点了一项,useBackClose 会先关它再跳
const menuOpen = ref(false)
useBackClose(menuOpen, isMobile)
watch(isMobile, (m) => {
if (!m) menuOpen.value = false
})
const userOptions: DropdownOption[] = [
@@ -227,22 +228,23 @@ onMounted(async () => {
<template>
<n-layout has-sider position="absolute">
<n-layout-sider
v-if="!isMobile"
bordered
collapse-mode="width"
:collapsed-width="64"
:width="200"
:native-scrollbar="false"
:show-trigger="isMobile ? false : 'bar'"
v-model:collapsed="siderCollapsed"
show-trigger="bar"
v-model:collapsed="collapsed"
>
<RouterLink to="/" class="brand" :class="{ collapsed: siderCollapsed }" title="回到前台">
<RouterLink to="/" class="brand" :class="{ collapsed: collapsed }" title="回到前台">
<Icon icon="streamline-emojis:dog" :height="28" />
<span v-if="!siderCollapsed" class="brandText">后台管理</span>
<span v-if="!collapsed" class="brandText">后台管理</span>
</RouterLink>
<n-menu
:options="options"
:value="current?.item.path ?? null"
:collapsed="siderCollapsed"
:collapsed="collapsed"
:collapsed-width="64"
:collapsed-icon-size="20"
:indent="20"
@@ -250,8 +252,29 @@ onMounted(async () => {
/>
</n-layout-sider>
<n-drawer v-if="isMobile" v-model:show="menuOpen" placement="left" :width="240">
<n-drawer-content :native-scrollbar="false" body-content-style="padding: 0">
<RouterLink to="/" class="brand" title="回到前台">
<Icon icon="streamline-emojis:dog" :height="28" />
<span class="brandText">后台管理</span>
</RouterLink>
<n-menu
:options="options"
:value="current?.item.path ?? null"
:indent="20"
:theme-overrides="{ itemHeight: '44px' }"
/>
</n-drawer-content>
</n-drawer>
<n-layout>
<n-layout-header bordered class="topbar">
<n-layout-header bordered class="topbar" :class="{ mobile: isMobile }">
<div v-if="isMobile" class="mobileTitle">
<n-button quaternary circle @click="menuOpen = true" title="菜单">
<template #icon><Icon icon="ph:list" :width="22" /></template>
</n-button>
<span class="mobileTitleText">{{ current?.item.label ?? "后台" }}</span>
</div>
<n-breadcrumb v-if="!isMobile">
<n-breadcrumb-item>后台</n-breadcrumb-item>
<template v-if="current">
@@ -263,7 +286,6 @@ onMounted(async () => {
</n-breadcrumb-item>
</template>
</n-breadcrumb>
<div v-if="isMobile" />
<n-flex align="center" :size="8" :wrap="false">
<n-button v-if="!isMobile" quaternary size="small" @click="router.push('/')">
<template #icon><Icon icon="ph:arrow-square-out" /></template>
@@ -294,7 +316,7 @@ onMounted(async () => {
position="absolute"
style="top: 49px"
:native-scrollbar="false"
content-style="padding: 20px 24px"
:content-style="isMobile ? 'padding: 16px' : 'padding: 20px 24px'"
>
<router-view></router-view>
</n-layout-content>
@@ -320,6 +342,25 @@ onMounted(async () => {
padding: 0;
}
.mobileTitle {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.mobileTitleText {
font-size: 16px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.topbar.mobile {
padding: 0 8px;
}
.brandText {
font-size: 16px;
font-weight: 600;