Compare commits
2
Commits
b757e2ac38
...
859a537804
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
859a537804 | ||
|
|
02e2fa7d9b |
No files matched your search
@@ -10,6 +10,7 @@ import {
|
|||||||
} from "admin/api"
|
} from "admin/api"
|
||||||
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
import AchievementIcon from "shared/components/AchievementIcon.vue"
|
||||||
import { RARITY_LABEL } from "utils/constants"
|
import { RARITY_LABEL } from "utils/constants"
|
||||||
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
|
|
||||||
const rarityOptions = Object.entries(RARITY_LABEL).map(([value, label]) => ({
|
const rarityOptions = Object.entries(RARITY_LABEL).map(([value, label]) => ({
|
||||||
label,
|
label,
|
||||||
@@ -44,6 +45,7 @@ function confirmDelete() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
const { isMobile } = useBreakpoints()
|
||||||
const metrics = ref<MetricOption[]>([])
|
const metrics = ref<MetricOption[]>([])
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
|
||||||
@@ -115,7 +117,8 @@ async function save() {
|
|||||||
:title="editing ? '编辑成就' : '新建成就'"
|
:title="editing ? '编辑成就' : '新建成就'"
|
||||||
@update:show="emit('update:show', $event)"
|
@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-form-item label="名称" required>
|
||||||
<n-input v-model:value="form.name" placeholder="成就名称" />
|
<n-input v-model:value="form.name" placeholder="成就名称" />
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
@@ -146,7 +149,7 @@ async function save() {
|
|||||||
<n-form-item label="指标" required>
|
<n-form-item label="指标" required>
|
||||||
<n-select v-model:value="form.metric" :options="metricOptions" filterable />
|
<n-select v-model:value="form.metric" :options="metricOptions" filterable />
|
||||||
</n-form-item>
|
</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-text depth="3">{{ metricHelp }}</n-text>
|
||||||
</n-form-item>
|
</n-form-item>
|
||||||
<n-form-item label="条件">
|
<n-form-item label="条件">
|
||||||
|
|||||||
@@ -216,4 +216,32 @@ onMounted(load)
|
|||||||
.off-note {
|
.off-note {
|
||||||
color: v-bind("theme.errorColor");
|
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>
|
</style>
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
import PageHeader from "admin/components/PageHeader.vue"
|
import PageHeader from "admin/components/PageHeader.vue"
|
||||||
import { useThemeVars } from "naive-ui"
|
import { useThemeVars } from "naive-ui"
|
||||||
import { classLabel } from "oj/submission/utils"
|
import { classLabel } from "oj/submission/utils"
|
||||||
import Markdown from "shared/components/Markdown.vue"
|
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 Pagination from "shared/components/Pagination.vue"
|
||||||
import UserName from "shared/components/UserName.vue"
|
import UserName from "shared/components/UserName.vue"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
@@ -18,6 +21,19 @@ import { getAIReportDetail, getAIReportList, pinAIReport } from "../api"
|
|||||||
* 别的那份取消,所以切完重拉列表
|
* 别的那份取消,所以切完重拉列表
|
||||||
*/
|
*/
|
||||||
const theme = useThemeVars()
|
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 reports = ref<AdminAiReportListItem[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
@@ -86,7 +102,7 @@ watchDebounced(
|
|||||||
<template>
|
<template>
|
||||||
<PageHeader title="AI 报告">
|
<PageHeader title="AI 报告">
|
||||||
<template #filters>
|
<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>
|
<n-text depth="3">共 {{ total }} 份</n-text>
|
||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
@@ -101,7 +117,7 @@ watchDebounced(
|
|||||||
type="button"
|
type="button"
|
||||||
class="item"
|
class="item"
|
||||||
:class="{ on: r.id === selectedId }"
|
:class="{ on: r.id === selectedId }"
|
||||||
@click="open(r.id)"
|
@click="pick(r.id)"
|
||||||
>
|
>
|
||||||
<span class="item-head">
|
<span class="item-head">
|
||||||
<UserName :username="r.username" />
|
<UserName :username="r.username" />
|
||||||
@@ -116,7 +132,13 @@ watchDebounced(
|
|||||||
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" simple />
|
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" simple />
|
||||||
</div>
|
</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">
|
<n-spin :show="loadingDetail">
|
||||||
<template v-if="selected">
|
<template v-if="selected">
|
||||||
<div class="detail-head">
|
<div class="detail-head">
|
||||||
@@ -262,4 +284,54 @@ watchDebounced(
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
white-space: nowrap;
|
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>
|
</style>
|
||||||
@@ -7,6 +7,7 @@ import { parseTime } from "utils/functions"
|
|||||||
import type { AdminAnnouncementListItem } from "utils/types"
|
import type { AdminAnnouncementListItem } from "utils/types"
|
||||||
import { editAnnouncement, getAnnouncement, getAnnouncementList } from "../api"
|
import { editAnnouncement, getAnnouncement, getAnnouncementList } from "../api"
|
||||||
import Actions from "./components/Actions.vue"
|
import Actions from "./components/Actions.vue"
|
||||||
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
@@ -22,25 +23,66 @@ const announcements = ref<AdminAnnouncementListItem[]>([])
|
|||||||
* 精确到秒的两列时间拿掉
|
* 精确到秒的两列时间拿掉
|
||||||
*/
|
*/
|
||||||
const theme = useThemeVars()
|
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: "标题",
|
title: "标题",
|
||||||
key: "title",
|
key: "title",
|
||||||
width: 360,
|
width: 360,
|
||||||
render: (row) =>
|
render: renderTitle,
|
||||||
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),
|
|
||||||
]),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "发布",
|
title: "发布",
|
||||||
@@ -62,28 +104,18 @@ const columns: DataTableColumn<AdminAnnouncementListItem>[] = [
|
|||||||
title: "学生看得到",
|
title: "学生看得到",
|
||||||
key: "visible",
|
key: "visible",
|
||||||
width: 100,
|
width: 100,
|
||||||
render: (row) =>
|
render: renderVisible,
|
||||||
h(NSwitch, {
|
|
||||||
value: row.visible,
|
|
||||||
size: "small",
|
|
||||||
rubberBand: false,
|
|
||||||
onUpdateValue: () => toggle(row, "visible"),
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "",
|
title: "",
|
||||||
key: "actions",
|
key: "actions",
|
||||||
minWidth: 240,
|
minWidth: 240,
|
||||||
render: (row) =>
|
render: renderActions,
|
||||||
h(Actions, {
|
|
||||||
announcementID: row.id,
|
|
||||||
top: row.top,
|
|
||||||
onDeleted: listAnnouncements,
|
|
||||||
onToggleTop: () => toggle(row, "top"),
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns))
|
||||||
|
|
||||||
// 列表响应不含 content(正文是 8MB 上限的富文本),而更新接口要求 content 必填 ——
|
// 列表响应不含 content(正文是 8MB 上限的富文本),而更新接口要求 content 必填 ——
|
||||||
// 拿列表里那行直接回传会 400。所以先取回整条再改。
|
// 拿列表里那行直接回传会 400。所以先取回整条再改。
|
||||||
async function toggle(announcement: AdminAnnouncementListItem, field: "visible" | "top") {
|
async function toggle(announcement: AdminAnnouncementListItem, field: "visible" | "top") {
|
||||||
@@ -134,6 +166,27 @@ watch(query, listAnnouncements, { deep: true })
|
|||||||
gap: 8px;
|
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) {
|
.ann-table :deep(.title-link) {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ const slots = defineSlots<{
|
|||||||
|
|
||||||
.title {
|
.title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
/* 手机上标题行挤不下时让右边的按钮折到下一行,标题自己别一个字一行 */
|
||||||
|
white-space: nowrap;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { AdminContest } from "utils/types"
|
import type { AdminContest } from "utils/types"
|
||||||
import { cloneContest } from "../../api"
|
import { cloneContest } from "../../api"
|
||||||
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
contest: AdminContest
|
contest: AdminContest
|
||||||
@@ -8,6 +9,8 @@ interface Props {
|
|||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
// 手机上在卡片里,放不下一行就折下去
|
||||||
|
const { isMobile } = useBreakpoints()
|
||||||
|
|
||||||
function goEdit() {
|
function goEdit() {
|
||||||
router.push({
|
router.push({
|
||||||
@@ -40,7 +43,7 @@ async function clone() {
|
|||||||
</script>
|
</script>
|
||||||
<template>
|
<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="goEdit">编辑</n-button>
|
||||||
<n-button size="small" @click="goClass">
|
<n-button size="small" @click="goClass">
|
||||||
{{ contest.status === "-1" ? "成绩" : "全班情况" }}
|
{{ contest.status === "-1" ? "成绩" : "全班情况" }}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { CONTEST_STATUS } from "utils/constants"
|
|||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import { editContest, getContestList } from "../api"
|
import { editContest, getContestList } from "../api"
|
||||||
import Actions from "./components/Actions.vue"
|
import Actions from "./components/Actions.vue"
|
||||||
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 后台比赛列表(设计稿「比赛重设计」CtAdminList)。一行一场:比赛名(期中 / 期末挂标,
|
* 后台比赛列表(设计稿「比赛重设计」CtAdminList)。一行一场:比赛名(期中 / 期末挂标,
|
||||||
@@ -16,6 +17,7 @@ import Actions from "./components/Actions.vue"
|
|||||||
* 会跑到最前面。ID、标签、类型三列拿掉(标签只在期中 / 期末时挂出来,类型就是那把锁)
|
* 会跑到最前面。ID、标签、类型三列拿掉(标签只在期中 / 期末时挂出来,类型就是那把锁)
|
||||||
*/
|
*/
|
||||||
const theme = useThemeVars()
|
const theme = useThemeVars()
|
||||||
|
const { isMobile } = useBreakpoints()
|
||||||
const contests = ref<AdminContestListItem[]>([])
|
const contests = ref<AdminContestListItem[]>([])
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const query = reactive({
|
const query = reactive({
|
||||||
@@ -43,16 +45,69 @@ function duration(row: AdminContestListItem) {
|
|||||||
|
|
||||||
const EXAM_TAGS = ["期中", "期末"]
|
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: "比赛",
|
title: "比赛",
|
||||||
key: "title",
|
key: "title",
|
||||||
width: 260,
|
width: 260,
|
||||||
render: (row) =>
|
render: renderTitle,
|
||||||
h("span", { class: "title-cell" }, [
|
|
||||||
h(ContestTitle, { contest: row }),
|
|
||||||
EXAM_TAGS.includes(row.tag) ? h("span", { class: "exam" }, row.tag) : null,
|
|
||||||
]),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "开始 · 多长",
|
title: "开始 · 多长",
|
||||||
@@ -68,16 +123,7 @@ const columns: DataTableColumn<AdminContestListItem>[] = [
|
|||||||
title: "状态",
|
title: "状态",
|
||||||
key: "status",
|
key: "status",
|
||||||
width: 72,
|
width: 72,
|
||||||
render: (row) =>
|
render: renderStatus,
|
||||||
h(
|
|
||||||
"span",
|
|
||||||
{ class: row.status === "0" ? "live" : row.status === "-1" ? "muted" : "" },
|
|
||||||
row.status === "-1"
|
|
||||||
? "结束了"
|
|
||||||
: row.status === "0"
|
|
||||||
? "进行中"
|
|
||||||
: CONTEST_STATUS[row.status].name,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
{ title: "题", key: "problemCount", width: 56 },
|
{ title: "题", key: "problemCount", width: 56 },
|
||||||
{
|
{
|
||||||
@@ -97,13 +143,7 @@ const columns: DataTableColumn<AdminContestListItem>[] = [
|
|||||||
title: "学生看得到",
|
title: "学生看得到",
|
||||||
key: "visible",
|
key: "visible",
|
||||||
width: 96,
|
width: 96,
|
||||||
render: (row) =>
|
render: renderVisible,
|
||||||
h(NSwitch, {
|
|
||||||
value: row.visible,
|
|
||||||
size: "small",
|
|
||||||
rubberBand: false,
|
|
||||||
onUpdateValue: () => toggleVisible(row),
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "",
|
title: "",
|
||||||
@@ -113,6 +153,8 @@ const columns: DataTableColumn<AdminContestListItem>[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns))
|
||||||
|
|
||||||
async function listContests() {
|
async function listContests() {
|
||||||
const offset = (query.page - 1) * query.limit
|
const offset = (query.page - 1) * query.limit
|
||||||
const res = await getContestList(offset, query.limit, query.keyword)
|
const res = await getContestList(offset, query.limit, query.keyword)
|
||||||
@@ -135,7 +177,12 @@ watchDebounced(() => query.keyword, listContests, {
|
|||||||
</n-button>
|
</n-button>
|
||||||
</template>
|
</template>
|
||||||
<template #filters>
|
<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>
|
</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<n-data-table striped class="contest-table" :columns="columns" :data="contests" />
|
<n-data-table striped class="contest-table" :columns="columns" :data="contests" />
|
||||||
@@ -150,6 +197,28 @@ watchDebounced(() => query.keyword, listContests, {
|
|||||||
font-weight: 500;
|
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) {
|
.contest-table :deep(.exam) {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -3,13 +3,22 @@ import { NText } from "naive-ui"
|
|||||||
import { getLearnExerciseAttempts } from "admin/api"
|
import { getLearnExerciseAttempts } from "admin/api"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import type { LearnExerciseAttempt } from "utils/types"
|
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 props = defineProps<{ exerciseId: number; className: string }>()
|
||||||
|
const { isMobile } = useBreakpoints()
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const rows = ref<LearnExerciseAttempt[]>([])
|
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: "className", width: 80 },
|
||||||
{ title: "学号", key: "username", width: 140 },
|
{ title: "学号", key: "username", width: 140 },
|
||||||
{
|
{
|
||||||
@@ -22,10 +31,7 @@ const columns: DataTableColumn<LearnExerciseAttempt>[] = [
|
|||||||
title: "结果",
|
title: "结果",
|
||||||
key: "solved",
|
key: "solved",
|
||||||
width: 130,
|
width: 130,
|
||||||
render: (row) =>
|
render: renderResult,
|
||||||
row.solved
|
|
||||||
? h(NText, { type: "success" }, () => `做对了(第 ${row.attemptsToSolve} 次)`)
|
|
||||||
: h(NText, { type: "error" }, () => "还没做对"),
|
|
||||||
},
|
},
|
||||||
{ title: "提交次数", key: "attempts", width: 100, sorter: "default" },
|
{ 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 () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
rows.value = await getLearnExerciseAttempts(props.exerciseId, {
|
rows.value = await getLearnExerciseAttempts(props.exerciseId, {
|
||||||
@@ -72,3 +99,19 @@ onMounted(async () => {
|
|||||||
style="padding: 16px 0"
|
style="padding: 16px 0"
|
||||||
/>
|
/>
|
||||||
</template>
|
</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">
|
<script setup lang="ts">
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { useThemeVars } from "naive-ui"
|
||||||
import type { LearnBoard, LearnBoardStudent, LearnExerciseProgress } from "utils/types"
|
import type { LearnBoard, LearnBoardStudent, LearnExerciseProgress } from "utils/types"
|
||||||
import { getLearnBoard, getLearnExercises } from "admin/api"
|
import { getLearnBoard, getLearnExercises } from "admin/api"
|
||||||
import { parseTime, readableDuration } from "utils/functions"
|
import { parseTime, readableDuration } from "utils/functions"
|
||||||
@@ -18,6 +19,7 @@ import ExerciseAttempts from "./ExerciseAttempts.vue"
|
|||||||
* 不选班就按排名页同一套规则猜老师在教哪个班。
|
* 不选班就按排名页同一套规则猜老师在教哪个班。
|
||||||
*/
|
*/
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
|
const theme = useThemeVars()
|
||||||
|
|
||||||
const KIND: Record<string, string> = {
|
const KIND: Record<string, string> = {
|
||||||
mcq: "选择",
|
mcq: "选择",
|
||||||
@@ -148,7 +150,7 @@ onMounted(load)
|
|||||||
:options="classOptions"
|
:options="classOptions"
|
||||||
filterable
|
filterable
|
||||||
placeholder="选班级"
|
placeholder="选班级"
|
||||||
style="width: 170px"
|
class="class-select"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -257,7 +259,7 @@ onMounted(load)
|
|||||||
v-model:value="lessonId"
|
v-model:value="lessonId"
|
||||||
:options="lessonOptions"
|
:options="lessonOptions"
|
||||||
size="small"
|
size="small"
|
||||||
style="width: 170px"
|
class="lesson-select"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!lessonExercises.length" class="muted none">这课没有练一练</div>
|
<div v-if="!lessonExercises.length" class="muted none">这课没有练一练</div>
|
||||||
@@ -595,4 +597,63 @@ onMounted(load)
|
|||||||
.flag {
|
.flag {
|
||||||
color: var(--learn-orange-text);
|
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>
|
</style>
|
||||||
@@ -1029,4 +1029,21 @@ onMounted(async () => {
|
|||||||
.stale {
|
.stale {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 手机:编号 + 题目一行,难度 + 可见折到第二行;判题方式那行也折 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.card {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headRow,
|
||||||
|
.cardHead,
|
||||||
|
.moreRow {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headRow .grow {
|
||||||
|
flex: 1 1 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRouteQuery } from "@vueuse/router"
|
import { useRouteQuery } from "@vueuse/router"
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
|
import { useThemeVars } from "naive-ui"
|
||||||
import { zonedParts } from "utils/functions"
|
import { zonedParts } from "utils/functions"
|
||||||
import type { StuckProblem, StuckProblemDetail, StuckStudent } from "utils/types"
|
import type { StuckProblem, StuckProblemDetail, StuckStudent } from "utils/types"
|
||||||
import { getStuckProblemDetail, getStuckProblems } from "admin/api"
|
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 { classLabel, groupClassOptions } from "oj/submission/utils"
|
||||||
import { periodRange, type Period } from "oj/statistics/period"
|
import { periodRange, type Period } from "oj/statistics/period"
|
||||||
import { resultName, useResultColor } from "oj/statistics/results"
|
import { resultName, useResultColor } from "oj/statistics/results"
|
||||||
|
import { useBackClose } from "shared/composables/backClose"
|
||||||
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import "oj/learn/learn.css"
|
import "oj/learn/learn.css"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,6 +24,8 @@ import "oj/learn/learn.css"
|
|||||||
*/
|
*/
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
|
const { isMobile } = useBreakpoints()
|
||||||
|
const theme = useThemeVars()
|
||||||
const resultColor = useResultColor()
|
const resultColor = useResultColor()
|
||||||
|
|
||||||
const PERIODS: { label: string; value: Period }[] = [
|
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([className, period], load)
|
||||||
watch(() => [selected.value?.id, problems.value] as const, loadDetail)
|
watch(() => [selected.value?.id, problems.value] as const, loadDetail)
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
@@ -296,24 +323,26 @@ const links = computed(() => {
|
|||||||
<span class="grow" />
|
<span class="grow" />
|
||||||
<span class="muted small">一次就对</span>
|
<span class="muted small">一次就对</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="know-list">
|
||||||
v-for="row in knowledge"
|
<button
|
||||||
:key="row.name"
|
v-for="row in knowledge"
|
||||||
type="button"
|
:key="row.name"
|
||||||
class="item"
|
type="button"
|
||||||
:class="{ on: row.name === activeTag }"
|
class="item"
|
||||||
@click="tag = row.name"
|
:class="{ on: row.name === activeTag }"
|
||||||
>
|
@click="tag = row.name"
|
||||||
<span class="line">
|
>
|
||||||
<span class="name">{{ row.label }}</span>
|
<span class="line">
|
||||||
<span class="grow" />
|
<span class="name">{{ row.label }}</span>
|
||||||
<span class="muted small num">{{ row.problems }} 道</span>
|
<span class="grow" />
|
||||||
</span>
|
<span class="muted small num">{{ row.problems }} 道</span>
|
||||||
<span class="line">
|
</span>
|
||||||
<span class="track"><span :style="{ width: `${row.rate}%` }" /></span>
|
<span class="line">
|
||||||
<span class="pct num">{{ row.rate }}%</span>
|
<span class="track"><span :style="{ width: `${row.rate}%` }" /></span>
|
||||||
</span>
|
<span class="pct num">{{ row.rate }}%</span>
|
||||||
</button>
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card problems">
|
<section class="card problems">
|
||||||
@@ -339,7 +368,7 @@ const links = computed(() => {
|
|||||||
type="button"
|
type="button"
|
||||||
class="item"
|
class="item"
|
||||||
:class="{ on: row.id === selected?.id }"
|
:class="{ on: row.id === selected?.id }"
|
||||||
@click="picked = String(row.id)"
|
@click="pick(row)"
|
||||||
>
|
>
|
||||||
<span class="line">
|
<span class="line">
|
||||||
<span class="pid muted num">{{ row.displayId }}</span>
|
<span class="pid muted num">{{ row.displayId }}</span>
|
||||||
@@ -359,7 +388,13 @@ const links = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<n-spin :show="detailLoading">
|
||||||
<div v-if="detail && links" class="detail-body">
|
<div v-if="detail && links" class="detail-body">
|
||||||
<div class="line dhead">
|
<div class="line dhead">
|
||||||
@@ -860,4 +895,112 @@ const links = computed(() => {
|
|||||||
width: 96px;
|
width: 96px;
|
||||||
flex-shrink: 0;
|
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>
|
</style>
|
||||||
@@ -150,14 +150,14 @@ onMounted(async () => {
|
|||||||
<n-select
|
<n-select
|
||||||
:value="selected || null"
|
:value="selected || null"
|
||||||
:options="gradeOptions"
|
:options="gradeOptions"
|
||||||
style="width: 100px"
|
class="grade-select"
|
||||||
@update:value="pickGrade"
|
@update:value="pickGrade"
|
||||||
/>
|
/>
|
||||||
<span class="muted">和</span>
|
<span class="muted">和</span>
|
||||||
<n-select
|
<n-select
|
||||||
:value="compare"
|
:value="compare"
|
||||||
:options="compareOptions"
|
:options="compareOptions"
|
||||||
style="width: 170px"
|
class="compare-select"
|
||||||
@update:value="(value: string) => (compare = value)"
|
@update:value="(value: string) => (compare = value)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -455,6 +455,12 @@ a.c-title:hover .ptitle {
|
|||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
background: var(--worse);
|
background: var(--worse);
|
||||||
}
|
}
|
||||||
|
.grade-select {
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
.compare-select {
|
||||||
|
width: 170px;
|
||||||
|
}
|
||||||
.dot-before {
|
.dot-before {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
@@ -462,4 +468,78 @@ a.c-title:hover .ptitle {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border: 2px solid rgba(128, 128, 128, 0.8);
|
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>
|
</style>
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { errorCode, errorMessage } from "utils/api"
|
import { errorCode, errorMessage } from "utils/api"
|
||||||
import { deleteContestProblem, deleteProblem, makeProblemPublic } from "admin/api"
|
import { deleteContestProblem, deleteProblem, makeProblemPublic } from "admin/api"
|
||||||
import download from "utils/download"
|
import download from "utils/download"
|
||||||
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 题目列表一行的操作:编辑、在前台看、下载测试数据、删除直接摆出来(用户说的:不要收进 ⋯)。
|
* 题目列表一行的操作:编辑、在前台看、下载测试数据、删除直接摆出来(用户说的:不要收进 ⋯)。
|
||||||
@@ -22,6 +23,10 @@ const route = useRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
|
// 手机上题目列表一道题一张卡,按钮在卡片里放不下一行就折下去;测试数据是 zip,手机上下了也没处用,
|
||||||
|
// 转成公开题要想好新题号,也留给电脑,都不摆
|
||||||
|
const { isMobile } = useBreakpoints()
|
||||||
|
|
||||||
const isContestProblem = computed(() => route.name === "admin contest problem list")
|
const isContestProblem = computed(() => route.name === "admin contest problem list")
|
||||||
|
|
||||||
const showMakePublicModal = ref(false)
|
const showMakePublicModal = ref(false)
|
||||||
@@ -106,11 +111,13 @@ async function handleMakePublic() {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<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" @click="goEdit">编辑</n-button>
|
||||||
<n-button size="small" title="在前台打开这道题" @click="goCheck">前台看</n-button>
|
<n-button size="small" title="在前台打开这道题" @click="goCheck">前台看</n-button>
|
||||||
<n-button size="small" title="下载测试数据" @click="downloads">测试数据</n-button>
|
<n-button v-if="!isMobile" size="small" title="下载测试数据" @click="downloads">
|
||||||
<n-button v-if="isContestProblem" size="small" @click="openMakePublicModal">
|
测试数据
|
||||||
|
</n-button>
|
||||||
|
<n-button v-if="isContestProblem && !isMobile" size="small" @click="openMakePublicModal">
|
||||||
转成公开题
|
转成公开题
|
||||||
</n-button>
|
</n-button>
|
||||||
<span :title="props.triedUsers ? `有 ${props.triedUsers} 人交过,删不掉` : undefined">
|
<span :title="props.triedUsers ? `有 ${props.triedUsers} 人交过,删不掉` : undefined">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useThemeVars } from "naive-ui"
|
import { useThemeVars } from "naive-ui"
|
||||||
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { caseStatus, type AnswerSource, type CaseRow } from "./caseRows"
|
import { caseStatus, type AnswerSource, type CaseRow } from "./caseRows"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -14,6 +15,8 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const theme = useThemeVars()
|
const theme = useThemeVars()
|
||||||
|
// 手机上「当例子」那一列的表头藏了,勾选框自己带上字
|
||||||
|
const { isMobile } = useBreakpoints()
|
||||||
|
|
||||||
/** 输出最多先露这么多行,长了点开看全 */
|
/** 输出最多先露这么多行,长了点开看全 */
|
||||||
const CLIP_LINES = 6
|
const CLIP_LINES = 6
|
||||||
@@ -106,26 +109,30 @@ const status = (row: CaseRow) => caseStatus(row, props.source)
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<n-checkbox
|
<!-- 桌面上 display: contents,三样各占一格;手机上折到输入输出下面排成一行 -->
|
||||||
v-if="!examplesOnly"
|
<div class="tools">
|
||||||
v-model:checked="row.example"
|
<n-checkbox
|
||||||
class="example"
|
v-if="!examplesOnly"
|
||||||
:aria-label="`第 ${index + 1} 组当例子`"
|
v-model:checked="row.example"
|
||||||
/>
|
class="example"
|
||||||
<span class="mark" :class="status(row)">
|
:aria-label="`第 ${index + 1} 组当例子`"
|
||||||
<template v-if="status(row) === 'ok'">✓</template>
|
:label="isMobile ? '当例子' : undefined"
|
||||||
<template v-else-if="status(row) === 'error'">✗</template>
|
/>
|
||||||
<template v-else-if="status(row) === 'changed'">●</template>
|
<span class="mark" :class="status(row)">
|
||||||
</span>
|
<template v-if="status(row) === 'ok'">✓</template>
|
||||||
<n-button
|
<template v-else-if="status(row) === 'error'">✗</template>
|
||||||
quaternary
|
<template v-else-if="status(row) === 'changed'">●</template>
|
||||||
size="tiny"
|
</span>
|
||||||
class="remove"
|
<n-button
|
||||||
:aria-label="`删掉第 ${index + 1} 组`"
|
quaternary
|
||||||
@click="remove(index)"
|
size="tiny"
|
||||||
>
|
class="remove"
|
||||||
删掉
|
:aria-label="`删掉第 ${index + 1} 组`"
|
||||||
</n-button>
|
@click="remove(index)"
|
||||||
|
>
|
||||||
|
删掉
|
||||||
|
</n-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -254,4 +261,37 @@ const status = (row: CaseRow) => caseStatus(row, props.source)
|
|||||||
.remove:focus-visible {
|
.remove:focus-visible {
|
||||||
opacity: 1;
|
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>
|
</style>
|
||||||
@@ -3,6 +3,7 @@ import { NButton, NTag } from "naive-ui"
|
|||||||
import Pagination from "shared/components/Pagination.vue"
|
import Pagination from "shared/components/Pagination.vue"
|
||||||
import type { AdminProblemRow } from "utils/types"
|
import type { AdminProblemRow } from "utils/types"
|
||||||
import { batchTagProblems, getProblemList } from "admin/api"
|
import { batchTagProblems, getProblemList } from "admin/api"
|
||||||
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
show: boolean
|
show: boolean
|
||||||
@@ -25,7 +26,9 @@ const page = ref(1)
|
|||||||
const limit = ref(10)
|
const limit = ref(10)
|
||||||
const keyword = ref("")
|
const keyword = ref("")
|
||||||
|
|
||||||
const columns: DataTableColumn<AdminProblemRow>[] = [
|
const { isMobile } = useBreakpoints()
|
||||||
|
|
||||||
|
const allColumns: DataTableColumn<AdminProblemRow>[] = [
|
||||||
{ title: "题号", key: "_id", width: 90 },
|
{ title: "题号", key: "_id", width: 90 },
|
||||||
{
|
{
|
||||||
title: "题目",
|
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() {
|
async function listProblems() {
|
||||||
if (page.value < 1) page.value = 1
|
if (page.value < 1) page.value = 1
|
||||||
const offset = (page.value - 1) * limit.value
|
const offset = (page.value - 1) * limit.value
|
||||||
@@ -117,7 +129,7 @@ watchDebounced(
|
|||||||
<span>共 {{ total }} 道题</span>
|
<span>共 {{ total }} 道题</span>
|
||||||
<n-input
|
<n-input
|
||||||
v-model:value="keyword"
|
v-model:value="keyword"
|
||||||
style="width: 220px"
|
:style="{ width: isMobile ? '160px' : '220px' }"
|
||||||
placeholder="题号或题目名"
|
placeholder="题号或题目名"
|
||||||
clearable
|
clearable
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1707,4 +1707,42 @@ onMounted(async () => {
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
overflow: hidden;
|
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>
|
</style>
|
||||||
@@ -15,6 +15,7 @@ import BatchTagModal from "./components/BatchTagModal.vue"
|
|||||||
import CreateProblemMenu from "./components/CreateProblemMenu.vue"
|
import CreateProblemMenu from "./components/CreateProblemMenu.vue"
|
||||||
import PageHeader from "admin/components/PageHeader.vue"
|
import PageHeader from "admin/components/PageHeader.vue"
|
||||||
import ProblemTypeTag from "oj/problem/components/ProblemTypeTag.vue"
|
import ProblemTypeTag from "oj/problem/components/ProblemTypeTag.vue"
|
||||||
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 后台题目列表(设计稿「后台题目列表」PlA / PlB)。一行一道题:题号、题目(点了就是编辑)、
|
* 后台题目列表(设计稿「后台题目列表」PlA / PlB)。一行一道题:题号、题目(点了就是编辑)、
|
||||||
@@ -32,6 +33,7 @@ const props = defineProps<Props>()
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const theme = useThemeVars()
|
const theme = useThemeVars()
|
||||||
|
const { isMobile } = useBreakpoints()
|
||||||
|
|
||||||
const title = computed(
|
const title = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -96,7 +98,7 @@ const { query, clearQuery } = usePagination<ProblemQuery>({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const counts = ref<{ all: number; visible: number; hidden: number } | null>(null)
|
const counts = ref<{ all: number; visible: number; hidden: number } | null>(null)
|
||||||
// 手机上后台左边还有一列图标,三个按钮放不下、「藏着的」被切掉:换小号,再不够就横着滑
|
// 手机上三个按钮放不下、「藏着的」被切掉:换小号,再不够就横着滑
|
||||||
const narrow = useMediaQuery("(max-width: 640px)")
|
const narrow = useMediaQuery("(max-width: 640px)")
|
||||||
const visibleTabs = computed(() => [
|
const visibleTabs = computed(() => [
|
||||||
{ value: "" as const, label: "全部", count: counts.value?.all },
|
{ value: "" as const, label: "全部", count: counts.value?.all },
|
||||||
@@ -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() {
|
async function listProblems() {
|
||||||
if (query.page < 1) query.page = 1
|
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"
|
v-model:value="query.keyword"
|
||||||
placeholder="题号或题目名"
|
placeholder="题号或题目名"
|
||||||
clearable
|
clearable
|
||||||
style="width: 220px"
|
:style="{ width: isMobile ? '100%' : '220px' }"
|
||||||
@clear="clearQuery"
|
@clear="clearQuery"
|
||||||
/>
|
/>
|
||||||
<n-select
|
<n-select
|
||||||
@@ -385,6 +438,7 @@ watch(() => [query.page, query.limit, query.author, query.kind, query.visible],
|
|||||||
border: 1px solid v-bind("theme.successColorSuppl");
|
border: 1px solid v-bind("theme.successColorSuppl");
|
||||||
background: v-bind("theme.hoverColor");
|
background: v-bind("theme.hoverColor");
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spacer {
|
.spacer {
|
||||||
@@ -409,6 +463,7 @@ watch(() => [query.page, query.limit, query.author, query.kind, query.visible],
|
|||||||
|
|
||||||
.problem-table :deep(.did) {
|
.problem-table :deep(.did) {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,6 +511,28 @@ watch(() => [query.page, query.limit, query.author, query.kind, query.visible],
|
|||||||
font-size: 12px;
|
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) {
|
.problem-table :deep(.num) {
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -301,4 +301,34 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
|||||||
.empty {
|
.empty {
|
||||||
margin: 16px 0;
|
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>
|
</style>
|
||||||
@@ -817,4 +817,20 @@ onMounted(load)
|
|||||||
padding: 16px 0;
|
padding: 16px 0;
|
||||||
text-align: center;
|
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>
|
</style>
|
||||||
@@ -5,13 +5,16 @@ import { usePagination } from "shared/composables/pagination"
|
|||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import type { AdminProblemSet } from "utils/types"
|
import type { AdminProblemSet } from "utils/types"
|
||||||
import { getProblemSetList, toggleProblemSetVisible } from "../api"
|
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 router = useRouter()
|
||||||
|
const theme = useThemeVars()
|
||||||
|
const { isMobile } = useBreakpoints()
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const problemSets = ref<AdminProblemSet[]>([])
|
const problemSets = ref<AdminProblemSet[]>([])
|
||||||
|
|
||||||
@@ -21,14 +24,60 @@ function edit(row: AdminProblemSet) {
|
|||||||
router.push({ name: "admin problemset edit", params: { problemSetId: row.id } })
|
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: "ID", key: "id", width: 70 },
|
||||||
{
|
{
|
||||||
title: "题单",
|
title: "题单",
|
||||||
key: "title",
|
key: "title",
|
||||||
minWidth: 200,
|
minWidth: 200,
|
||||||
render: (row) =>
|
render: renderTitle,
|
||||||
h(NButton, { text: true, onClick: () => edit(row) }, { default: () => row.title }),
|
|
||||||
},
|
},
|
||||||
{ title: "题数", key: "problemsCount", width: 70 },
|
{ title: "题数", key: "problemsCount", width: 70 },
|
||||||
{
|
{
|
||||||
@@ -42,14 +91,7 @@ const columns: DataTableColumn<AdminProblemSet>[] = [
|
|||||||
title: "布置",
|
title: "布置",
|
||||||
key: "assignedUntil",
|
key: "assignedUntil",
|
||||||
width: 130,
|
width: 130,
|
||||||
render: (row) =>
|
render: renderAssigned,
|
||||||
row.assigning
|
|
||||||
? h(
|
|
||||||
NTag,
|
|
||||||
{ type: "info", size: "small", bordered: false },
|
|
||||||
{ default: () => `到 ${parseTime(row.assignedUntil!, "M月D日")}` },
|
|
||||||
)
|
|
||||||
: "—",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "创建者",
|
title: "创建者",
|
||||||
@@ -67,16 +109,12 @@ const columns: DataTableColumn<AdminProblemSet>[] = [
|
|||||||
title: "公开",
|
title: "公开",
|
||||||
key: "visible",
|
key: "visible",
|
||||||
width: 80,
|
width: 80,
|
||||||
render: (row) =>
|
render: renderVisible,
|
||||||
h(NSwitch, {
|
|
||||||
value: row.visible,
|
|
||||||
size: "small",
|
|
||||||
rubberBand: false,
|
|
||||||
onUpdateValue: () => toggleVisible(row.id),
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns))
|
||||||
|
|
||||||
async function listProblemSets() {
|
async function listProblemSets() {
|
||||||
if (query.page < 1) query.page = 1
|
if (query.page < 1) query.page = 1
|
||||||
const offset = (query.page - 1) * query.limit
|
const offset = (query.page - 1) * query.limit
|
||||||
@@ -108,10 +146,37 @@ watch(() => [query.page, query.limit], listProblemSets)
|
|||||||
placeholder="搜题单名字"
|
placeholder="搜题单名字"
|
||||||
clearable
|
clearable
|
||||||
@clear="clearQuery"
|
@clear="clearQuery"
|
||||||
style="width: 220px"
|
:style="{ width: isMobile ? '100%' : '220px' }"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</PageHeader>
|
</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" />
|
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
|
||||||
</template>
|
</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>
|
<h3>
|
||||||
班级 <span class="count">{{ websiteConfig.classList.length }} 个</span>
|
班级 <span class="count">{{ websiteConfig.classList.length }} 个</span>
|
||||||
</h3>
|
</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>
|
<span class="grade-label">{{ group.grade === "其他" ? "其他" : `${group.grade} 级` }}</span>
|
||||||
<div class="chips">
|
<div class="chips">
|
||||||
<n-tag
|
<n-tag
|
||||||
@@ -417,4 +417,21 @@ h3 {
|
|||||||
.mono {
|
.mono {
|
||||||
font-family: Consolas, Monaco, monospace;
|
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>
|
</style>
|
||||||
@@ -226,7 +226,7 @@ onUnmounted(stopRolling)
|
|||||||
filterable
|
filterable
|
||||||
placeholder="选班级"
|
placeholder="选班级"
|
||||||
aria-label="班级"
|
aria-label="班级"
|
||||||
style="width: 160px"
|
class="lucky-select"
|
||||||
/>
|
/>
|
||||||
<span :key="pulseKey" class="lucky-name" :class="{ pulse: !isRolling && luckyGuy }">
|
<span :key="pulseKey" class="lucky-name" :class="{ pulse: !isRolling && luckyGuy }">
|
||||||
{{ luckyGuy }}
|
{{ luckyGuy }}
|
||||||
@@ -353,6 +353,10 @@ onUnmounted(stopRolling)
|
|||||||
border: 1px solid v-bind("theme.dividerColor");
|
border: 1px solid v-bind("theme.dividerColor");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lucky-select {
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
.lucky-title {
|
.lucky-title {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
@@ -386,4 +390,24 @@ onUnmounted(stopRolling)
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
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>
|
</style>
|
||||||
@@ -693,6 +693,7 @@ function typeName(type: ExerciseType) {
|
|||||||
.sub a {
|
.sub a {
|
||||||
color: #18a058;
|
color: #18a058;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.sub a.danger {
|
.sub a.danger {
|
||||||
color: #d03050;
|
color: #d03050;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from "../api"
|
} from "../api"
|
||||||
import ExerciseManager from "./components/ExerciseManager.vue"
|
import ExerciseManager from "./components/ExerciseManager.vue"
|
||||||
import RelatedProblems from "./components/RelatedProblems.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 isCreate = computed(() => route.name === "admin tutorial create")
|
||||||
|
|
||||||
|
// 手机上左右分栏各剩一百多像素,改成正文「写 / 预览」切着看
|
||||||
|
const { isMobile } = useBreakpoints()
|
||||||
|
const pane = ref<"source" | "preview">("source")
|
||||||
|
|
||||||
const tutorial = reactive<TutorialEdit>({
|
const tutorial = reactive<TutorialEdit>({
|
||||||
id: 0,
|
id: 0,
|
||||||
title: "",
|
title: "",
|
||||||
@@ -197,8 +202,15 @@ onMounted(init)
|
|||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<span class="strong">正文</span>
|
<span class="strong">正文</span>
|
||||||
<span class="muted">Markdown</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>
|
||||||
<div class="split">
|
<div class="split" :class="isMobile ? `only-${pane}` : ''">
|
||||||
<div class="source">
|
<div class="source">
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
ref="editor"
|
ref="editor"
|
||||||
@@ -355,4 +367,53 @@ onMounted(init)
|
|||||||
gap: 14px;
|
gap: 14px;
|
||||||
overflow-y: auto;
|
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>
|
</style>
|
||||||
@@ -169,7 +169,7 @@ onMounted(load)
|
|||||||
<span class="c-drag" />
|
<span class="c-drag" />
|
||||||
<span class="c-no">#</span>
|
<span class="c-no">#</span>
|
||||||
<span class="c-title">知识点 · 情境</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">相关题</span>
|
||||||
<span class="c-num wide">打开过的人</span>
|
<span class="c-num wide">打开过的人</span>
|
||||||
<span class="c-switch">公开</span>
|
<span class="c-switch">公开</span>
|
||||||
@@ -230,7 +230,7 @@ onMounted(load)
|
|||||||
<span class="kp">{{ splitTitle(item.title).kp }}</span>
|
<span class="kp">{{ splitTitle(item.title).kp }}</span>
|
||||||
<span class="scene">{{ splitTitle(item.title).scene }}</span>
|
<span class="scene">{{ splitTitle(item.title).scene }}</span>
|
||||||
</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">
|
<span class="c-num num">
|
||||||
<template v-if="item.problemCount">{{ item.problemCount }}</template>
|
<template v-if="item.problemCount">{{ item.problemCount }}</template>
|
||||||
<span v-else class="warn">还没挑</span>
|
<span v-else class="warn">还没挑</span>
|
||||||
@@ -426,4 +426,49 @@ onMounted(load)
|
|||||||
color: #18a058;
|
color: #18a058;
|
||||||
text-decoration: none;
|
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>
|
</style>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* 账号名单下成表格:用户名、密码两列。批量建号建完下名单、用户列表按班下名单共用这一份。
|
||||||
|
* 开头带 BOM,Excel 打开中文才不乱码
|
||||||
|
*/
|
||||||
|
export function downloadAccountsCsv(
|
||||||
|
rows: { username: string; password: string }[],
|
||||||
|
filename: string,
|
||||||
|
) {
|
||||||
|
const cell = (text: string) => (/[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text)
|
||||||
|
const csv =
|
||||||
|
"用户名,密码\n" + rows.map((it) => `${cell(it.username)},${cell(it.password)}`).join("\n")
|
||||||
|
const link = document.createElement("a")
|
||||||
|
link.href = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }))
|
||||||
|
link.download = `${filename}.csv`
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(link.href)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { editUser } from "admin/api"
|
import { editUser } from "admin/api"
|
||||||
import type { AdminUserForm } from "utils/types"
|
import type { AdminUserForm } from "utils/types"
|
||||||
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户列表一行的操作:重置密码、编辑、禁用、删除直接摆出来(用户说的:不要收进 ⋯)。
|
* 用户列表一行的操作:重置密码、编辑、禁用、删除直接摆出来(用户说的:不要收进 ⋯)。
|
||||||
@@ -18,6 +19,8 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
|
// 手机上在卡片里,放不下一行就折下去
|
||||||
|
const { isMobile } = useBreakpoints()
|
||||||
|
|
||||||
async function banUser() {
|
async function banUser() {
|
||||||
props.user.isDisabled = !props.user.isDisabled
|
props.user.isDisabled = !props.user.isDisabled
|
||||||
@@ -36,7 +39,7 @@ function confirmDelete() {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<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('resetPassword', props.user)">重置密码</n-button>
|
||||||
<n-button size="small" @click="emit('openEditModal', props.user)">编辑</n-button>
|
<n-button size="small" @click="emit('openEditModal', props.user)">编辑</n-button>
|
||||||
<n-button size="small" @click="banUser">
|
<n-button size="small" @click="banUser">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { downloadAccountsCsv } from "./accountsCsv"
|
||||||
import PageHeader from "admin/components/PageHeader.vue"
|
import PageHeader from "admin/components/PageHeader.vue"
|
||||||
import { classLabel } from "oj/submission/utils"
|
import { classLabel } from "oj/submission/utils"
|
||||||
import UserName from "shared/components/UserName.vue"
|
import UserName from "shared/components/UserName.vue"
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
} from "utils/constants"
|
} from "utils/constants"
|
||||||
import { parseTime } from "utils/functions"
|
import { parseTime } from "utils/functions"
|
||||||
import { checkUsernames, importUsers } from "../api"
|
import { checkUsernames, importUsers } from "../api"
|
||||||
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量建号(设计稿「后台用户与批量建号」UsGen / UsGenIssue / UsDone)。
|
* 批量建号(设计稿「后台用户与批量建号」UsGen / UsGenIssue / UsDone)。
|
||||||
@@ -21,6 +23,8 @@ import { checkUsernames, importUsers } from "../api"
|
|||||||
* 一个 CSV,Excel 打开中文还是乱码。新班级后端会顺手加进班级列表。
|
* 一个 CSV,Excel 打开中文还是乱码。新班级后端会顺手加进班级列表。
|
||||||
*/
|
*/
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
// 手机上左右两栏改成上下:先填班级和名单,预览排在下面
|
||||||
|
const { isMobile } = useBreakpoints()
|
||||||
|
|
||||||
const classNo = ref("")
|
const classNo = ref("")
|
||||||
const rawInput = ref("")
|
const rawInput = ref("")
|
||||||
@@ -148,15 +152,9 @@ function again() {
|
|||||||
passwords.clear()
|
passwords.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 下载名单:用户名、密码两列。开头带 BOM,Excel 打开中文才不乱码 */
|
|
||||||
function download() {
|
function download() {
|
||||||
if (!done.value) return
|
if (!done.value) return
|
||||||
const csv = "用户名,密码\n" + done.value.map((it) => `${it.username},${it.password}`).join("\n")
|
downloadAccountsCsv(done.value, prefix.value || "账号")
|
||||||
const link = document.createElement("a")
|
|
||||||
link.href = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }))
|
|
||||||
link.download = `${prefix.value || "账号"}.csv`
|
|
||||||
link.click()
|
|
||||||
URL.revokeObjectURL(link.href)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 打印账号条:一人一张,一页四列,打出来剪开发下去 */
|
/** 打印账号条:一人一张,一页四列,打出来剪开发下去 */
|
||||||
@@ -237,7 +235,7 @@ body{margin:12mm;font-family:"Microsoft YaHei","PingFang SC",sans-serif}
|
|||||||
v-model:value="rawInput"
|
v-model:value="rawInput"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
:input-props="{ id: 'names' }"
|
:input-props="{ id: 'names' }"
|
||||||
:rows="20"
|
:rows="isMobile ? 8 : 20"
|
||||||
placeholder="一行一个名字"
|
placeholder="一行一个名字"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -303,7 +301,11 @@ body{margin:12mm;font-family:"Microsoft YaHei","PingFang SC",sans-serif}
|
|||||||
</n-text>
|
</n-text>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<n-empty v-else description="左边粘进名单,这里就列出会建成的账号" style="margin-top: 80px" />
|
<n-empty
|
||||||
|
v-else
|
||||||
|
:description="`${isMobile ? '上面' : '左边'}粘进名单,这里就列出会建成的账号`"
|
||||||
|
:style="{ marginTop: isMobile ? '24px' : '80px' }"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -445,6 +447,26 @@ body{margin:12mm;font-family:"Microsoft YaHei","PingFang SC",sans-serif}
|
|||||||
gap: 8px;
|
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 {
|
.slip {
|
||||||
border: 1px dashed rgba(128, 128, 128, 0.5);
|
border: 1px dashed rgba(128, 128, 128, 0.5);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Icon } from "@iconify/vue"
|
||||||
import PageHeader from "admin/components/PageHeader.vue"
|
import PageHeader from "admin/components/PageHeader.vue"
|
||||||
import { errorMessage } from "utils/api"
|
import { errorMessage } from "utils/api"
|
||||||
import { DataTableRowKey, SelectOption, useThemeVars } from "naive-ui"
|
import { DataTableRowKey, SelectOption, useThemeVars } from "naive-ui"
|
||||||
@@ -12,9 +13,11 @@ import UserNameCell from "./components/UserNameCell.vue"
|
|||||||
import PasswordCell from "./components/PasswordCell.vue"
|
import PasswordCell from "./components/PasswordCell.vue"
|
||||||
import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants"
|
import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants"
|
||||||
import { useRouteQuery } from "@vueuse/router"
|
import { useRouteQuery } from "@vueuse/router"
|
||||||
import { NO_CLASS } from "@oj2/contract"
|
import { NO_CLASS, STUDENT_ROLES } from "@oj2/contract"
|
||||||
|
import { downloadAccountsCsv } from "./accountsCsv"
|
||||||
import { classLabel, groupClassOptions } from "oj/submission/utils"
|
import { classLabel, groupClassOptions } from "oj/submission/utils"
|
||||||
import { useConfigStore } from "shared/store/config"
|
import { useConfigStore } from "shared/store/config"
|
||||||
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 后台用户列表(设计稿「后台用户与批量建号」UsList)。老师进来多半是三件事:学生忘了密码、
|
* 后台用户列表(设计稿「后台用户与批量建号」UsList)。老师进来多半是三件事:学生忘了密码、
|
||||||
@@ -24,6 +27,7 @@ import { useConfigStore } from "shared/store/config"
|
|||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
const theme = useThemeVars()
|
const theme = useThemeVars()
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
|
const { isMobile } = useBreakpoints()
|
||||||
|
|
||||||
interface UserQuery {
|
interface UserQuery {
|
||||||
keyword: string
|
keyword: string
|
||||||
@@ -91,7 +95,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" },
|
{ type: "selection" },
|
||||||
{
|
{
|
||||||
title: "用户名",
|
title: "用户名",
|
||||||
@@ -111,14 +164,7 @@ const columns: DataTableColumn<AdminUserForm>[] = [
|
|||||||
title: "密码",
|
title: "密码",
|
||||||
key: "raw_password",
|
key: "raw_password",
|
||||||
width: 150,
|
width: 150,
|
||||||
render: (row) =>
|
render: renderPassword,
|
||||||
h(PasswordCell, {
|
|
||||||
user: row,
|
|
||||||
revealed: revealedPasswords.value.has(row.id),
|
|
||||||
onToggle: (id: number) => {
|
|
||||||
if (!revealedPasswords.value.delete(id)) revealedPasswords.value.add(id)
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
{ title: "上次登录", key: "last_login", width: 150, render: lastLoginText },
|
{ title: "上次登录", key: "last_login", width: 150, render: lastLoginText },
|
||||||
{
|
{
|
||||||
@@ -136,17 +182,12 @@ const columns: DataTableColumn<AdminUserForm>[] = [
|
|||||||
key: "actions",
|
key: "actions",
|
||||||
title: "",
|
title: "",
|
||||||
minWidth: 300,
|
minWidth: 300,
|
||||||
render: (row) =>
|
render: renderActions,
|
||||||
h(Actions, {
|
|
||||||
user: row,
|
|
||||||
onDeleteUser: onDeleteUsers,
|
|
||||||
onUserBanned,
|
|
||||||
onOpenEditModal,
|
|
||||||
onResetPassword,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns))
|
||||||
|
|
||||||
const options: SelectOption[] = [
|
const options: SelectOption[] = [
|
||||||
{ label: "普通", value: USER_TYPE.REGULAR_USER },
|
{ label: "普通", value: USER_TYPE.REGULAR_USER },
|
||||||
{ label: "学生管理员", value: USER_TYPE.STUDENT_ADMIN },
|
{ label: "学生管理员", value: USER_TYPE.STUDENT_ADMIN },
|
||||||
@@ -179,6 +220,39 @@ async function listUsers() {
|
|||||||
revealedPasswords.value.clear()
|
revealedPasswords.value.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 选了一个班就能把全班的用户名、密码下成表格(发给班主任、打印)。只下学生,不管搜索和角色筛选;
|
||||||
|
* 一页最多 250 人,一个班 60 人以内,循环只是保险。老班级(23x 及更早)很多人没存明文密码,那格空着
|
||||||
|
*/
|
||||||
|
const downloadingClass = ref(false)
|
||||||
|
async function downloadClassAccounts() {
|
||||||
|
const className = query.className
|
||||||
|
downloadingClass.value = true
|
||||||
|
try {
|
||||||
|
const rows: AdminUserForm[] = []
|
||||||
|
for (let offset = 0; ; offset += 250) {
|
||||||
|
const res = await getUserList(offset, 250, "", "", "username", className)
|
||||||
|
rows.push(...res.results)
|
||||||
|
if (!res.results.length || rows.length >= res.total) break
|
||||||
|
}
|
||||||
|
const students = rows.filter((it) =>
|
||||||
|
(STUDENT_ROLES as readonly string[]).includes(it.adminType),
|
||||||
|
)
|
||||||
|
if (!students.length) {
|
||||||
|
message.warning("这个班没有学生")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
downloadAccountsCsv(
|
||||||
|
students.map((it) => ({ username: it.username, password: it.rawPassword ?? "" })),
|
||||||
|
`${classLabel(className)}账号`,
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
message.error(errorMessage(err))
|
||||||
|
} finally {
|
||||||
|
downloadingClass.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function chooseUsers(rowKeys: DataTableRowKey[]) {
|
function chooseUsers(rowKeys: DataTableRowKey[]) {
|
||||||
userIDs.value = rowKeys
|
userIDs.value = rowKeys
|
||||||
}
|
}
|
||||||
@@ -314,7 +388,7 @@ watch(() => [query.page, query.limit, query.type, query.orderBy, query.className
|
|||||||
</template>
|
</template>
|
||||||
<template #filters>
|
<template #filters>
|
||||||
<n-input
|
<n-input
|
||||||
style="width: 220px"
|
:style="{ width: isMobile ? '100%' : '220px' }"
|
||||||
v-model:value="query.keyword"
|
v-model:value="query.keyword"
|
||||||
placeholder="用户名或名字"
|
placeholder="用户名或名字"
|
||||||
clearable
|
clearable
|
||||||
@@ -327,6 +401,14 @@ watch(() => [query.page, query.limit, query.type, query.orderBy, query.className
|
|||||||
aria-label="班级"
|
aria-label="班级"
|
||||||
style="width: 150px"
|
style="width: 150px"
|
||||||
/>
|
/>
|
||||||
|
<n-button
|
||||||
|
v-if="query.className && query.className !== NO_CLASS"
|
||||||
|
:loading="downloadingClass"
|
||||||
|
@click="downloadClassAccounts"
|
||||||
|
>
|
||||||
|
<template #icon><Icon icon="ph:download-simple" /></template>
|
||||||
|
下载这个班的账号
|
||||||
|
</n-button>
|
||||||
<n-select
|
<n-select
|
||||||
v-model:value="query.type"
|
v-model:value="query.type"
|
||||||
:options="adminOptions"
|
:options="adminOptions"
|
||||||
@@ -363,7 +445,7 @@ watch(() => [query.page, query.limit, query.type, query.orderBy, query.className
|
|||||||
@close="onCloseEditModal"
|
@close="onCloseEditModal"
|
||||||
>
|
>
|
||||||
<n-form label-placement="left" v-if="userEditing">
|
<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-form-item-gi :span="1" label="用户">
|
||||||
<n-input v-model:value="userEditing.username" />
|
<n-input v-model:value="userEditing.username" />
|
||||||
</n-form-item-gi>
|
</n-form-item-gi>
|
||||||
@@ -420,6 +502,29 @@ watch(() => [query.page, query.limit, query.type, query.orderBy, query.className
|
|||||||
color: v-bind("theme.textColor3");
|
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) {
|
.user-table :deep(.num) {
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|||||||
@@ -50,3 +50,8 @@ body {
|
|||||||
animation: none;
|
animation: none;
|
||||||
mix-blend-mode: normal;
|
mix-blend-mode: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 弹框各写各的定宽(420~820px),手机上一律收进屏幕里,左右各留 16px */
|
||||||
|
.n-modal {
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { h } from "vue"
|
import { h } from "vue"
|
||||||
import { Icon } from "@iconify/vue"
|
import { Icon } from "@iconify/vue"
|
||||||
import { RouterLink } from "vue-router"
|
import { RouterLink } from "vue-router"
|
||||||
|
import { useBackClose } from "shared/composables/backClose"
|
||||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||||
import { useDarkTransition } from "shared/composables/darkTransition"
|
import { useDarkTransition } from "shared/composables/darkTransition"
|
||||||
import { STORAGE_KEY } from "utils/constants"
|
import { STORAGE_KEY } from "utils/constants"
|
||||||
@@ -153,9 +154,9 @@ function toOption(i: NavItem): MenuOption {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 收起时 64px 放不下分组标题(会折成两行),换成分隔线
|
// 收起时 64px 放不下分组标题(会折成两行),换成分隔线。手机上菜单在抽屉里,总是展开的
|
||||||
const options = computed<MenuOption[]>(() =>
|
const options = computed<MenuOption[]>(() =>
|
||||||
siderCollapsed.value
|
collapsed.value && !isMobile.value
|
||||||
? groups.value.flatMap((g, idx) => [
|
? groups.value.flatMap((g, idx) => [
|
||||||
...(idx ? [{ type: "divider", key: `divider-${g.label}` }] : []),
|
...(idx ? [{ type: "divider", key: `divider-${g.label}` }] : []),
|
||||||
...g.items.map(toOption),
|
...g.items.map(toOption),
|
||||||
@@ -188,12 +189,12 @@ const current = computed(() => {
|
|||||||
|
|
||||||
const collapsed = ref<boolean>(storage.get(STORAGE_KEY.ADMIN_SIDER_COLLAPSED) ?? false)
|
const collapsed = ref<boolean>(storage.get(STORAGE_KEY.ADMIN_SIDER_COLLAPSED) ?? false)
|
||||||
watch(collapsed, (v) => storage.set(STORAGE_KEY.ADMIN_SIDER_COLLAPSED, v))
|
watch(collapsed, (v) => storage.set(STORAGE_KEY.ADMIN_SIDER_COLLAPSED, v))
|
||||||
// 窄屏一律收起,不写回偏好 —— 手机上看一眼不该改掉桌面端的习惯
|
// 手机上不留侧栏(64px 的图标条占了 390 宽屏的六分之一),菜单收进左边滑出的抽屉。
|
||||||
const siderCollapsed = computed({
|
// 按返回键先关抽屉;在抽屉里点了一项,useBackClose 会先关它再跳
|
||||||
get: () => isMobile.value || collapsed.value,
|
const menuOpen = ref(false)
|
||||||
set: (v) => {
|
useBackClose(menuOpen, isMobile)
|
||||||
if (!isMobile.value) collapsed.value = v
|
watch(isMobile, (m) => {
|
||||||
},
|
if (!m) menuOpen.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
const userOptions: DropdownOption[] = [
|
const userOptions: DropdownOption[] = [
|
||||||
@@ -227,22 +228,23 @@ onMounted(async () => {
|
|||||||
<template>
|
<template>
|
||||||
<n-layout has-sider position="absolute">
|
<n-layout has-sider position="absolute">
|
||||||
<n-layout-sider
|
<n-layout-sider
|
||||||
|
v-if="!isMobile"
|
||||||
bordered
|
bordered
|
||||||
collapse-mode="width"
|
collapse-mode="width"
|
||||||
:collapsed-width="64"
|
:collapsed-width="64"
|
||||||
:width="200"
|
:width="200"
|
||||||
:native-scrollbar="false"
|
:native-scrollbar="false"
|
||||||
:show-trigger="isMobile ? false : 'bar'"
|
show-trigger="bar"
|
||||||
v-model:collapsed="siderCollapsed"
|
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" />
|
<Icon icon="streamline-emojis:dog" :height="28" />
|
||||||
<span v-if="!siderCollapsed" class="brandText">后台管理</span>
|
<span v-if="!collapsed" class="brandText">后台管理</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<n-menu
|
<n-menu
|
||||||
:options="options"
|
:options="options"
|
||||||
:value="current?.item.path ?? null"
|
:value="current?.item.path ?? null"
|
||||||
:collapsed="siderCollapsed"
|
:collapsed="collapsed"
|
||||||
:collapsed-width="64"
|
:collapsed-width="64"
|
||||||
:collapsed-icon-size="20"
|
:collapsed-icon-size="20"
|
||||||
:indent="20"
|
:indent="20"
|
||||||
@@ -250,8 +252,29 @@ onMounted(async () => {
|
|||||||
/>
|
/>
|
||||||
</n-layout-sider>
|
</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>
|
||||||
<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 v-if="!isMobile">
|
||||||
<n-breadcrumb-item>后台</n-breadcrumb-item>
|
<n-breadcrumb-item>后台</n-breadcrumb-item>
|
||||||
<template v-if="current">
|
<template v-if="current">
|
||||||
@@ -263,7 +286,6 @@ onMounted(async () => {
|
|||||||
</n-breadcrumb-item>
|
</n-breadcrumb-item>
|
||||||
</template>
|
</template>
|
||||||
</n-breadcrumb>
|
</n-breadcrumb>
|
||||||
<div v-if="isMobile" />
|
|
||||||
<n-flex align="center" :size="8" :wrap="false">
|
<n-flex align="center" :size="8" :wrap="false">
|
||||||
<n-button v-if="!isMobile" quaternary size="small" @click="router.push('/')">
|
<n-button v-if="!isMobile" quaternary size="small" @click="router.push('/')">
|
||||||
<template #icon><Icon icon="ph:arrow-square-out" /></template>
|
<template #icon><Icon icon="ph:arrow-square-out" /></template>
|
||||||
@@ -294,7 +316,7 @@ onMounted(async () => {
|
|||||||
position="absolute"
|
position="absolute"
|
||||||
style="top: 49px"
|
style="top: 49px"
|
||||||
:native-scrollbar="false"
|
:native-scrollbar="false"
|
||||||
content-style="padding: 20px 24px"
|
:content-style="isMobile ? 'padding: 16px' : 'padding: 20px 24px'"
|
||||||
>
|
>
|
||||||
<router-view></router-view>
|
<router-view></router-view>
|
||||||
</n-layout-content>
|
</n-layout-content>
|
||||||
@@ -320,6 +342,25 @@ onMounted(async () => {
|
|||||||
padding: 0;
|
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 {
|
.brandText {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
Reference in new issue
Block a user