feat(后台): 手机上也能用了——菜单收进左边抽屉,列表一行一张卡,不用再横着滑
- 侧栏手机上不再占一列图标,顶栏左边 ☰ 拉出整张菜单,按返回键先关菜单 - 题目、比赛、用户、公告、题单列表手机上一项一张卡:名字一行、小字一行、按钮一行, 「学生看得到」开关单独一列靠右 - 卡点分析、AI 报告手机上点开一项整屏看,返回键关掉;年度趋势、自学情况、首页改成上下排 - 出题页、教程编辑、题单编辑、标签、设置手机上排成一列;测试数据「删掉」手机上常驻 - 弹框一律收进屏幕宽度 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b757e2ac38
commit
02e2fa7d9b
30 files changed
+1328
-183
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="条件">
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,25 +23,66 @@ const announcements = ref<AdminAnnouncementListItem[]>([])
|
||||
* 精确到秒的两列时间拿掉
|
||||
*/
|
||||
const theme = useThemeVars()
|
||||
const { isMobile } = useBreakpoints()
|
||||
|
||||
const columns: DataTableColumn<AdminAnnouncementListItem>[] = [
|
||||
function renderTitle(row: AdminAnnouncementListItem) {
|
||||
return h("span", { class: "title-cell" }, [
|
||||
h(
|
||||
RouterLink,
|
||||
{
|
||||
class: "title-link",
|
||||
to: { name: "admin announcement edit", params: { announcementID: row.id } },
|
||||
},
|
||||
() => row.title,
|
||||
),
|
||||
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: (row) =>
|
||||
h("span", { class: "title-cell" }, [
|
||||
h(
|
||||
RouterLink,
|
||||
{
|
||||
class: "title-link",
|
||||
to: { name: "admin announcement edit", params: { announcementID: row.id } },
|
||||
},
|
||||
() => row.title,
|
||||
),
|
||||
row.top ? h("span", { class: "top" }, "置顶") : null,
|
||||
h("span", { class: "muted small" }, row.tag),
|
||||
]),
|
||||
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" ? "成绩" : "全班情况" }}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,24 +323,26 @@ const links = computed(() => {
|
||||
<span class="grow" />
|
||||
<span class="muted small">一次就对</span>
|
||||
</div>
|
||||
<button
|
||||
v-for="row in knowledge"
|
||||
:key="row.name"
|
||||
type="button"
|
||||
class="item"
|
||||
:class="{ on: row.name === activeTag }"
|
||||
@click="tag = row.name"
|
||||
>
|
||||
<span class="line">
|
||||
<span class="name">{{ row.label }}</span>
|
||||
<span class="grow" />
|
||||
<span class="muted small num">{{ row.problems }} 道</span>
|
||||
</span>
|
||||
<span class="line">
|
||||
<span class="track"><span :style="{ width: `${row.rate}%` }" /></span>
|
||||
<span class="pct num">{{ row.rate }}%</span>
|
||||
</span>
|
||||
</button>
|
||||
<div class="know-list">
|
||||
<button
|
||||
v-for="row in knowledge"
|
||||
:key="row.name"
|
||||
type="button"
|
||||
class="item"
|
||||
:class="{ on: row.name === activeTag }"
|
||||
@click="tag = row.name"
|
||||
>
|
||||
<span class="line">
|
||||
<span class="name">{{ row.label }}</span>
|
||||
<span class="grow" />
|
||||
<span class="muted small num">{{ row.problems }} 道</span>
|
||||
</span>
|
||||
<span class="line">
|
||||
<span class="track"><span :style="{ width: `${row.rate}%` }" /></span>
|
||||
<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>
|
||||
@@ -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,26 +109,30 @@ const status = (row: CaseRow) => caseStatus(row, props.source)
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<n-checkbox
|
||||
v-if="!examplesOnly"
|
||||
v-model:checked="row.example"
|
||||
class="example"
|
||||
:aria-label="`第 ${index + 1} 组当例子`"
|
||||
/>
|
||||
<span class="mark" :class="status(row)">
|
||||
<template v-if="status(row) === 'ok'">✓</template>
|
||||
<template v-else-if="status(row) === 'error'">✗</template>
|
||||
<template v-else-if="status(row) === 'changed'">●</template>
|
||||
</span>
|
||||
<n-button
|
||||
quaternary
|
||||
size="tiny"
|
||||
class="remove"
|
||||
:aria-label="`删掉第 ${index + 1} 组`"
|
||||
@click="remove(index)"
|
||||
>
|
||||
删掉
|
||||
</n-button>
|
||||
<!-- 桌面上 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>
|
||||
<template v-else-if="status(row) === 'error'">✗</template>
|
||||
<template v-else-if="status(row) === 'changed'">●</template>
|
||||
</span>
|
||||
<n-button
|
||||
quaternary
|
||||
size="tiny"
|
||||
class="remove"
|
||||
:aria-label="`删掉第 ${index + 1} 组`"
|
||||
@click="remove(index)"
|
||||
>
|
||||
删掉
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>[]>(() =>
|
||||
isContestProblemList.value
|
||||
? baseColumns.filter((it) => !("key" in it) || it.key !== "users")
|
||||
: [{ type: "selection" }, ...baseColumns],
|
||||
)
|
||||
/**
|
||||
* 手机上一道题一张卡:题号 + 题目一行,知识点 / 人数 / 出题人 / 日期一行小字,操作按钮一行;
|
||||
* 「学生看得到」开关单独一列靠右,不用横着滑就能点到
|
||||
*/
|
||||
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
|
||||
? 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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -50,3 +50,8 @@ body {
|
||||
animation: none;
|
||||
mix-blend-mode: normal;
|
||||
}
|
||||
|
||||
/* 弹框各写各的定宽(420~820px),手机上一律收进屏幕里,左右各留 16px */
|
||||
.n-modal {
|
||||
max-width: calc(100vw - 32px);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user