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

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

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

No files matched your search

@@ -1,6 +1,7 @@
<script lang="ts" setup>
import { editUser } from "admin/api"
import type { AdminUserForm } from "utils/types"
import { useBreakpoints } from "shared/composables/breakpoints"
/**
* 用户列表一行的操作:重置密码、编辑、禁用、删除直接摆出来(用户说的:不要收进 ⋯)。
@@ -18,6 +19,8 @@ const emit = defineEmits<{
}>()
const dialog = useDialog()
// 手机上在卡片里,放不下一行就折下去
const { isMobile } = useBreakpoints()
async function banUser() {
props.user.isDisabled = !props.user.isDisabled
@@ -36,7 +39,7 @@ function confirmDelete() {
}
</script>
<template>
<n-flex :size="6" :wrap="false">
<n-flex :size="6" :wrap="isMobile">
<n-button size="small" @click="emit('resetPassword', props.user)">重置密码</n-button>
<n-button size="small" @click="emit('openEditModal', props.user)">编辑</n-button>
<n-button size="small" @click="banUser">
+29 -2
View File
@@ -11,6 +11,7 @@ import {
} from "utils/constants"
import { parseTime } from "utils/functions"
import { checkUsernames, importUsers } from "../api"
import { useBreakpoints } from "shared/composables/breakpoints"
/**
* 批量建号(设计稿「后台用户与批量建号」UsGen / UsGenIssue / UsDone)。
@@ -21,6 +22,8 @@ import { checkUsernames, importUsers } from "../api"
* 一个 CSV,Excel 打开中文还是乱码。新班级后端会顺手加进班级列表。
*/
const message = useMessage()
// 手机上左右两栏改成上下:先填班级和名单,预览排在下面
const { isMobile } = useBreakpoints()
const classNo = ref("")
const rawInput = ref("")
@@ -237,7 +240,7 @@ body{margin:12mm;font-family:"Microsoft YaHei","PingFang SC",sans-serif}
v-model:value="rawInput"
type="textarea"
:input-props="{ id: 'names' }"
:rows="20"
:rows="isMobile ? 8 : 20"
placeholder="一行一个名字"
/>
</div>
@@ -303,7 +306,11 @@ body{margin:12mm;font-family:"Microsoft YaHei","PingFang SC",sans-serif}
</n-text>
</div>
</template>
<n-empty v-else description="左边粘进名单,这里就列出会建成的账号" style="margin-top: 80px" />
<n-empty
v-else
:description="`${isMobile ? '上面' : '左边'}粘进名单,这里就列出会建成的账号`"
:style="{ marginTop: isMobile ? '24px' : '80px' }"
/>
</div>
</div>
</template>
@@ -445,6 +452,26 @@ body{margin:12mm;font-family:"Microsoft YaHei","PingFang SC",sans-serif}
gap: 8px;
}
@media (max-width: 768px) {
.layout {
flex-direction: column;
align-items: stretch;
}
.left {
width: auto;
}
.done-bar {
flex-wrap: wrap;
white-space: normal;
}
.slips {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.slip {
border: 1px dashed rgba(128, 128, 128, 0.5);
border-radius: 4px;
+81 -19
View File
@@ -15,6 +15,7 @@ import { useRouteQuery } from "@vueuse/router"
import { NO_CLASS } from "@oj2/contract"
import { classLabel, groupClassOptions } from "oj/submission/utils"
import { useConfigStore } from "shared/store/config"
import { useBreakpoints } from "shared/composables/breakpoints"
/**
* 后台用户列表(设计稿「后台用户与批量建号」UsList)。老师进来多半是三件事:学生忘了密码、
@@ -24,6 +25,7 @@ import { useConfigStore } from "shared/store/config"
const message = useMessage()
const theme = useThemeVars()
const configStore = useConfigStore()
const { isMobile } = useBreakpoints()
interface UserQuery {
keyword: string
@@ -91,7 +93,56 @@ function lastLoginText(row: AdminUserForm) {
])
}
const columns: DataTableColumn<AdminUserForm>[] = [
function renderPassword(row: AdminUserForm) {
return h(PasswordCell, {
user: row,
revealed: revealedPasswords.value.has(row.id),
onToggle: (id: number) => {
if (!revealedPasswords.value.delete(id)) revealedPasswords.value.add(id)
},
})
}
function renderActions(row: AdminUserForm) {
return h(Actions, {
user: row,
onDeleteUser: onDeleteUsers,
onUserBanned,
onOpenEditModal,
onResetPassword,
})
}
/**
* 手机上一个人一张卡:用户名一行,班级 · 上次登录 · 建号一行小字(每项不断开),
* 密码单独一行(学生忘了密码是最常见的事,要一眼找到),操作按钮放不下就折行
*/
function renderMobileCard(row: AdminUserForm) {
return h("div", { class: "m-card" }, [
h(UserNameCell, { user: row }),
h("div", { class: "m-meta muted" }, [
h("span", { class: "m-item" }, row.className ? classLabel(row.className) : "没填班级"),
h("span", { class: "m-item" }, [" · ", lastLoginText(row)]),
row.createTime
? h("span", { class: "m-item" }, ` · 建号 ${parseTime(row.createTime, "YYYY-MM-DD")}`)
: null,
]),
row.rawPassword
? h("div", { class: "m-password" }, [
h("span", { class: "muted" }, "密码"),
renderPassword(row),
])
: null,
renderActions(row),
])
}
const mobileColumns: DataTableColumn<AdminUserForm>[] = [
{ type: "selection" },
{ title: "用户", key: "username", render: renderMobileCard },
]
const desktopColumns: DataTableColumn<AdminUserForm>[] = [
{ type: "selection" },
{
title: "用户名",
@@ -111,14 +162,7 @@ const columns: DataTableColumn<AdminUserForm>[] = [
title: "密码",
key: "raw_password",
width: 150,
render: (row) =>
h(PasswordCell, {
user: row,
revealed: revealedPasswords.value.has(row.id),
onToggle: (id: number) => {
if (!revealedPasswords.value.delete(id)) revealedPasswords.value.add(id)
},
}),
render: renderPassword,
},
{ title: "上次登录", key: "last_login", width: 150, render: lastLoginText },
{
@@ -136,17 +180,12 @@ const columns: DataTableColumn<AdminUserForm>[] = [
key: "actions",
title: "",
minWidth: 300,
render: (row) =>
h(Actions, {
user: row,
onDeleteUser: onDeleteUsers,
onUserBanned,
onOpenEditModal,
onResetPassword,
}),
render: renderActions,
},
]
const columns = computed(() => (isMobile.value ? mobileColumns : desktopColumns))
const options: SelectOption[] = [
{ label: "普通", value: USER_TYPE.REGULAR_USER },
{ label: "学生管理员", value: USER_TYPE.STUDENT_ADMIN },
@@ -314,7 +353,7 @@ watch(() => [query.page, query.limit, query.type, query.orderBy, query.className
</template>
<template #filters>
<n-input
style="width: 220px"
:style="{ width: isMobile ? '100%' : '220px' }"
v-model:value="query.keyword"
placeholder="用户名或名字"
clearable
@@ -363,7 +402,7 @@ watch(() => [query.page, query.limit, query.type, query.orderBy, query.className
@close="onCloseEditModal"
>
<n-form label-placement="left" v-if="userEditing">
<n-grid :cols="2" :x-gap="16">
<n-grid :cols="isMobile ? 1 : 2" :x-gap="16">
<n-form-item-gi :span="1" label="用户">
<n-input v-model:value="userEditing.username" />
</n-form-item-gi>
@@ -420,6 +459,29 @@ watch(() => [query.page, query.limit, query.type, query.orderBy, query.className
color: v-bind("theme.textColor3");
}
.user-table :deep(.m-card) {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.user-table :deep(.m-meta) {
font-size: 13px;
line-height: 1.5;
}
.user-table :deep(.m-item) {
white-space: nowrap;
}
.user-table :deep(.m-password) {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
}
.user-table :deep(.num) {
font-variant-numeric: tabular-nums;
white-space: nowrap;