feat(前台): 各页直达后台对应功能——编辑这一课、新建比赛、编辑这条公告……
Deploy / deploy (push) Canceled after 0s

题目页「编辑题目」从只给超管放宽到有权改这道题的人(含学生管理员改自己出的题);
题目、比赛、题单列表加「新建」;自学首页加「自学情况」「管理教程」,课文加「编辑这一课」;
公告加「编辑这条」「发公告」;成就加「管理成就」;智能分析加「AI 报告」,看某个学生时
带着用户名过去直接筛到他。按钮统一走 shared/components/AdminLink.vue。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-15 14:02:29 +08:00
1 parent e7b6e27b85
commit 0ffc405a20
12 files changed
+139 -2

No files matched your search

+7 -1
View File
@@ -21,7 +21,13 @@ const theme = useThemeVars()
const reports = ref<AdminAiReportListItem[]>([])
const total = ref(0)
const query = reactive({ limit: 10, page: 1, username: "" })
// 前台「智能分析」看某个学生时的「AI 报告」按钮带着 ?username= 过来,直接筛到他
const route = useRoute()
const query = reactive({
limit: 10,
page: 1,
username: typeof route.query.username === "string" ? route.query.username : "",
})
const selectedId = ref<number | null>(null)
const detail = ref<AdminAiReport | null>(null)
+14
View File
@@ -4,6 +4,7 @@ import { getAchievements } from "oj/achievement/api"
import { getUserBadges } from "oj/api"
import { useBreakpoints } from "shared/composables/breakpoints"
import UserName from "shared/components/UserName.vue"
import AdminLink from "shared/components/AdminLink.vue"
import { useUserStore } from "shared/store/user"
import { RARITY_COLOR, RARITY_LABEL } from "utils/constants"
import type { AchievementList, AchievementRarity, UserBadge } from "utils/types"
@@ -83,6 +84,9 @@ watch(name, load)
</span>
</span>
<router-link v-if="other" to="/achievement" class="mine">看我的 ›</router-link>
<div v-if="userStore.isSuperAdmin && isDesktop" class="admin-link">
<AdminLink to="/admin/achievement/list" size="small">管理成就</AdminLink>
</div>
</div>
<!-- 没有班级(老师、自己注册的号)就没有「本班」,「快拿到了」三个横排占满整宽 -->
@@ -168,6 +172,16 @@ h2 {
font-variant-numeric: tabular-nums;
}
/* 两个都在时「看我的」占着 auto,按钮跟在它后面 */
.admin-link {
margin-left: auto;
align-self: center;
}
.mine + .admin-link {
margin-left: 0;
}
.mine {
margin-left: auto;
font-size: var(--oj-fs-sec);
+8
View File
@@ -8,6 +8,7 @@ import {
import { useRouteQuery } from "@vueuse/router"
import { useThemeVars } from "naive-ui"
import { useBreakpoints } from "shared/composables/breakpoints"
import AdminLink from "shared/components/AdminLink.vue"
import { useUserStore } from "shared/store/user"
import UserName from "shared/components/UserName.vue"
import { classLabel } from "oj/submission/utils"
@@ -137,6 +138,13 @@ function goStudent(username: string | null | undefined) {
>
看<UserName :username="viewing" />的提交 ›
</router-link>
<AdminLink
v-if="userStore.isTeacherOrAbove && isDesktop"
:to="{ path: '/admin/ai/reports', query: viewing ? { username: viewing } : {} }"
size="small"
>
AI 报告
</AdminLink>
</div>
<n-spin :show="loading" :delay="150">
+25
View File
@@ -1,8 +1,10 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import { getAnnouncement, getAnnouncementList } from "oj/api"
import AdminLink from "shared/components/AdminLink.vue"
import NotFound from "shared/components/NotFound.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useUserStore } from "shared/store/user"
import { errorCode } from "utils/api"
import { parseTime, zonedParts, zonedYear } from "utils/functions"
import type { Announcement, AnnouncementListItem } from "utils/types"
@@ -19,6 +21,7 @@ const LIMIT = 100
const theme = useThemeVars()
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const { isDesktop } = useBreakpoints()
const announcements = ref<AnnouncementListItem[]>([])
@@ -105,7 +108,19 @@ const showReader = computed(() => isDesktop.value || queryId.value !== null)
<template>
<div class="page oj-page">
<div class="head">
<h2>公告</h2>
<template v-if="userStore.isSuperAdmin">
<span class="grow"></span>
<AdminLink
v-if="showReader && detail"
:to="{ name: 'admin announcement edit', params: { announcementID: detail.id } }"
>
编辑这条
</AdminLink>
<AdminLink to="/admin/announcement/create">发公告</AdminLink>
</template>
</div>
<n-empty v-if="loaded && !announcements.length" description="还没有公告" class="blank" />
@@ -177,6 +192,16 @@ h2 {
font-size: var(--oj-fs-title);
}
.head {
display: flex;
align-items: center;
gap: 8px;
}
.grow {
flex-grow: 1;
}
.blank {
margin-top: 60px;
}
+7
View File
@@ -9,6 +9,8 @@ import ContestTitle from "shared/components/ContestTitle.vue"
import Pagination from "shared/components/Pagination.vue"
import { useAuthModalStore } from "shared/store/authModal"
import { usePagination } from "shared/composables/pagination"
import AdminLink from "shared/components/AdminLink.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useUserStore } from "shared/store/user"
import { ContestStatus } from "utils/constants"
import ContestBadge from "./components/ContestBadge.vue"
@@ -19,6 +21,7 @@ import ContestBadge from "./components/ContestBadge.vue"
*/
const router = useRouter()
const userStore = useUserStore()
const { isDesktop } = useBreakpoints()
const authStore = useAuthModalStore()
const theme = useThemeVars()
const tone = useTone()
@@ -145,6 +148,10 @@ const warning = computed(() => tone("warning"))
{{ item.label }}
</button>
<n-input v-model:value="query.keyword" clearable placeholder="比赛名" style="width: 200px" />
<!-- 手机上一行放不下,也没人在手机上出题,只给电脑 -->
<AdminLink v-if="isDesktop && userStore.isTeacherOrAbove" to="/admin/contest/create"
>新建比赛</AdminLink
>
</div>
<div
+16
View File
@@ -4,6 +4,7 @@ import type { TutorialProgress, TutorialSummary } from "@oj2/contract"
import { getLearnProgress, getTutorials } from "../api"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useAuthModalStore } from "shared/store/authModal"
import AdminLink from "shared/components/AdminLink.vue"
import { useUserStore } from "shared/store/user"
import {
COURSES,
@@ -251,6 +252,16 @@ onMounted(load)
>
登录后记下学到哪
</button>
<div v-if="isDesktop && userStore.isTeacherOrAbove" class="admin-links">
<AdminLink to="/admin/learn" size="small">自学情况</AdminLink>
<AdminLink
v-if="userStore.isSuperAdmin"
:to="{ path: '/admin/tutorial/list', query: { type: tab } }"
size="small"
>
管理教程
</AdminLink>
</div>
</div>
<div v-if="current && isDesktop" class="map">
@@ -483,6 +494,11 @@ onMounted(load)
font-weight: 400;
opacity: 0.6;
}
.admin-links {
display: flex;
align-items: center;
gap: 8px;
}
.login {
border: 0;
background: transparent;
+11
View File
@@ -8,6 +8,7 @@ import { parseLesson } from "./composables/useLessonParse"
import { useLearnTrace } from "./composables/useLearnTrace"
import { useBackClose } from "shared/composables/backClose"
import { useBreakpoints } from "shared/composables/breakpoints"
import AdminLink from "shared/components/AdminLink.vue"
import { useUserStore } from "shared/store/user"
import {
COURSES,
@@ -293,6 +294,15 @@ function jumpExercise(id: number) {
<span v-if="tutorial.project">{{ tutorial.project }}</span>
<span v-if="tutorial.project">·</span>
<span class="num">第 {{ step }} / {{ titles.length }} 课</span>
<template v-if="userStore.isSuperAdmin">
<span class="grow" />
<AdminLink
:to="{ name: 'admin tutorial edit', params: { tutorialID: tutorial.id } }"
size="tiny"
>
编辑这一课
</AdminLink>
</template>
</div>
<h1 class="h1">{{ parts.kp }}</h1>
<div v-if="parts.scene" class="scene">{{ parts.scene }}</div>
@@ -546,6 +556,7 @@ function jumpExercise(id: number) {
}
.crumb {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
margin-bottom: 6px;
@@ -58,7 +58,8 @@ export function useEditorMenu(statisticsInline: Ref<boolean>) {
// 一写回去就顺着 Yjs 同步过去,等于一键清空学生的作业,他还没法撤回
if (!teacherCollab.value) items.push({ label: "重置代码", key: "reset" })
}
if (isDesktop.value && userStore.isSuperAdmin) items.push({ label: "编辑题目", key: "edit" })
if (isDesktop.value && problem.value && userStore.canEditProblem(problem.value))
items.push({ label: "编辑题目", key: "edit" })
return items
})
+4
View File
@@ -15,6 +15,7 @@ import Pagination from "shared/components/Pagination.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { usePagination } from "shared/composables/pagination"
import { useAuthModalStore } from "shared/store/authModal"
import AdminLink from "shared/components/AdminLink.vue"
import { useUserStore } from "shared/store/user"
import ProblemStatus from "./components/ProblemStatus.vue"
import ProblemTypeTag from "./components/ProblemTypeTag.vue"
@@ -430,6 +431,9 @@ const gridColumns = computed(() =>
</div>
<div class="spacer"></div>
<Hitokoto class="hitokoto" />
<AdminLink v-if="userStore.hasProblemPermission" to="/admin/problem/create" size="small">
新建题目
</AdminLink>
</div>
<div class="titlebar">
+9
View File
@@ -1,5 +1,8 @@
<script setup lang="ts">
import { useThemeVars } from "naive-ui"
import AdminLink from "shared/components/AdminLink.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useUserStore } from "shared/store/user"
import { getProblemSetList } from "oj/api"
import { rgba, useTone } from "oj/submission/composables/tone"
import { parseTime } from "utils/functions"
@@ -13,6 +16,8 @@ import { badgeCondition, ladder, nextBadge } from "./badges"
* 题单一共十几个,一次全取回来在前端分组,不分页
*/
const router = useRouter()
const userStore = useUserStore()
const { isDesktop } = useBreakpoints()
const theme = useThemeVars()
const tone = useTone()
@@ -88,6 +93,10 @@ const doneBackground = computed(() => rgba(theme.value.successColor, 0.05))
<h2>题单</h2>
<div class="spacer"></div>
<n-input v-model:value="keyword" placeholder="搜题单" clearable class="search" />
<!-- 手机上一行放不下,也没人在手机上出题,只给电脑 -->
<AdminLink v-if="isDesktop && userStore.isTeacherOrAbove" to="/admin/problemset/create"
>新建题单</AdminLink
>
</div>
<template v-if="assigning.length">
@@ -0,0 +1,19 @@
<script setup lang="ts">
import { RouterLink, type RouteLocationRaw } from "vue-router"
/**
* 前台页面上直达后台对应功能的按钮(「编辑这一课」「新建比赛」……),和题单页的
* 「编辑题单」、比赛页的「比赛设置」同一种样子。渲染成真的 <a>:老师常要一边看前台
* 一边改,中键 / Ctrl 点开新标签页得能用。给谁看由调用方用 v-if 管,要和
* routes.ts 里目标路由的 meta 对齐,否则点下去被守卫静默踢回首页。
*/
withDefaults(defineProps<{ to: RouteLocationRaw; size?: "tiny" | "small" | "medium" }>(), {
size: "medium",
})
</script>
<template>
<RouterLink v-slot="{ href, navigate }" :to="to" custom>
<n-button tag="a" :href="href" :size="size" @click="navigate"><slot /></n-button>
</RouterLink>
</template>
+17
View File
@@ -49,6 +49,22 @@ export const useUserStore = defineStore("user", () => {
const canToggleDemoMode = computed(() => realIsSuperAdmin.value)
/**
* 前台「编辑题目」入口给谁看,对齐后端 admin/problem.ts 的 canEdit:
* 公开题看题目的创建者(或题目权限是「全部」),比赛题看比赛的创建者。
* 题目页拿不到比赛的创建者,比赛题就按题目的创建者估 —— 比赛题基本是出题老师
* 自己建或克隆的;估错了后台会拦,只是多一个点进去 404 的入口。
*/
function canEditProblem(problem: { contestId: number | null; createdBy: { id: number } }) {
if (isSuperAdmin.value) return true
const mine = problem.createdBy.id === user.value?.id
if (problem.contestId !== null) return isTeacherOrAbove.value && mine
return (
hasProblemPermission.value &&
(mine || user.value?.problemPermission === PROBLEM_PERMISSION.ALL)
)
}
function toggleDemoMode() {
demoSwitch.value = !demoSwitch.value
storage.set(STORAGE_KEY.DEMO_MODE, demoSwitch.value)
@@ -124,6 +140,7 @@ export const useUserStore = defineStore("user", () => {
hasProblemPermission,
demoMode,
canToggleDemoMode,
canEditProblem,
toggleDemoMode,
isAuthed,
showSubmissions,