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
+140 -3

No files matched your search

+7 -1
View File
@@ -21,7 +21,13 @@ const theme = useThemeVars()
const reports = ref<AdminAiReportListItem[]>([]) const reports = ref<AdminAiReportListItem[]>([])
const total = ref(0) 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 selectedId = ref<number | null>(null)
const detail = ref<AdminAiReport | 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 { getUserBadges } from "oj/api"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import UserName from "shared/components/UserName.vue" import UserName from "shared/components/UserName.vue"
import AdminLink from "shared/components/AdminLink.vue"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
import { RARITY_COLOR, RARITY_LABEL } from "utils/constants" import { RARITY_COLOR, RARITY_LABEL } from "utils/constants"
import type { AchievementList, AchievementRarity, UserBadge } from "utils/types" import type { AchievementList, AchievementRarity, UserBadge } from "utils/types"
@@ -83,6 +84,9 @@ watch(name, load)
</span> </span>
</span> </span>
<router-link v-if="other" to="/achievement" class="mine">看我的 ›</router-link> <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> </div>
<!-- 没有班级(老师、自己注册的号)就没有「本班」,「快拿到了」三个横排占满整宽 --> <!-- 没有班级(老师、自己注册的号)就没有「本班」,「快拿到了」三个横排占满整宽 -->
@@ -168,6 +172,16 @@ h2 {
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
/* 两个都在时「看我的」占着 auto,按钮跟在它后面 */
.admin-link {
margin-left: auto;
align-self: center;
}
.mine + .admin-link {
margin-left: 0;
}
.mine { .mine {
margin-left: auto; margin-left: auto;
font-size: var(--oj-fs-sec); font-size: var(--oj-fs-sec);
+8
View File
@@ -8,6 +8,7 @@ import {
import { useRouteQuery } from "@vueuse/router" import { useRouteQuery } from "@vueuse/router"
import { useThemeVars } from "naive-ui" import { useThemeVars } from "naive-ui"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import AdminLink from "shared/components/AdminLink.vue"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
import UserName from "shared/components/UserName.vue" import UserName from "shared/components/UserName.vue"
import { classLabel } from "oj/submission/utils" import { classLabel } from "oj/submission/utils"
@@ -137,6 +138,13 @@ function goStudent(username: string | null | undefined) {
> >
看<UserName :username="viewing" />的提交 › 看<UserName :username="viewing" />的提交 ›
</router-link> </router-link>
<AdminLink
v-if="userStore.isTeacherOrAbove && isDesktop"
:to="{ path: '/admin/ai/reports', query: viewing ? { username: viewing } : {} }"
size="small"
>
AI 报告
</AdminLink>
</div> </div>
<n-spin :show="loading" :delay="150"> <n-spin :show="loading" :delay="150">
+26 -1
View File
@@ -1,8 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { useThemeVars } from "naive-ui" import { useThemeVars } from "naive-ui"
import { getAnnouncement, getAnnouncementList } from "oj/api" import { getAnnouncement, getAnnouncementList } from "oj/api"
import AdminLink from "shared/components/AdminLink.vue"
import NotFound from "shared/components/NotFound.vue" import NotFound from "shared/components/NotFound.vue"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import { useUserStore } from "shared/store/user"
import { errorCode } from "utils/api" import { errorCode } from "utils/api"
import { parseTime, zonedParts, zonedYear } from "utils/functions" import { parseTime, zonedParts, zonedYear } from "utils/functions"
import type { Announcement, AnnouncementListItem } from "utils/types" import type { Announcement, AnnouncementListItem } from "utils/types"
@@ -19,6 +21,7 @@ const LIMIT = 100
const theme = useThemeVars() const theme = useThemeVars()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const userStore = useUserStore()
const { isDesktop } = useBreakpoints() const { isDesktop } = useBreakpoints()
const announcements = ref<AnnouncementListItem[]>([]) const announcements = ref<AnnouncementListItem[]>([])
@@ -105,7 +108,19 @@ const showReader = computed(() => isDesktop.value || queryId.value !== null)
<template> <template>
<div class="page oj-page"> <div class="page oj-page">
<h2>公告</h2> <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" /> <n-empty v-if="loaded && !announcements.length" description="还没有公告" class="blank" />
@@ -177,6 +192,16 @@ h2 {
font-size: var(--oj-fs-title); font-size: var(--oj-fs-title);
} }
.head {
display: flex;
align-items: center;
gap: 8px;
}
.grow {
flex-grow: 1;
}
.blank { .blank {
margin-top: 60px; 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 Pagination from "shared/components/Pagination.vue"
import { useAuthModalStore } from "shared/store/authModal" import { useAuthModalStore } from "shared/store/authModal"
import { usePagination } from "shared/composables/pagination" 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 { useUserStore } from "shared/store/user"
import { ContestStatus } from "utils/constants" import { ContestStatus } from "utils/constants"
import ContestBadge from "./components/ContestBadge.vue" import ContestBadge from "./components/ContestBadge.vue"
@@ -19,6 +21,7 @@ import ContestBadge from "./components/ContestBadge.vue"
*/ */
const router = useRouter() const router = useRouter()
const userStore = useUserStore() const userStore = useUserStore()
const { isDesktop } = useBreakpoints()
const authStore = useAuthModalStore() const authStore = useAuthModalStore()
const theme = useThemeVars() const theme = useThemeVars()
const tone = useTone() const tone = useTone()
@@ -145,6 +148,10 @@ const warning = computed(() => tone("warning"))
{{ item.label }} {{ item.label }}
</button> </button>
<n-input v-model:value="query.keyword" clearable placeholder="比赛名" style="width: 200px" /> <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>
<div <div
+16
View File
@@ -4,6 +4,7 @@ import type { TutorialProgress, TutorialSummary } from "@oj2/contract"
import { getLearnProgress, getTutorials } from "../api" import { getLearnProgress, getTutorials } from "../api"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import { useAuthModalStore } from "shared/store/authModal" import { useAuthModalStore } from "shared/store/authModal"
import AdminLink from "shared/components/AdminLink.vue"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
import { import {
COURSES, COURSES,
@@ -251,6 +252,16 @@ onMounted(load)
> >
登录后记下学到哪 登录后记下学到哪
</button> </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>
<div v-if="current && isDesktop" class="map"> <div v-if="current && isDesktop" class="map">
@@ -483,6 +494,11 @@ onMounted(load)
font-weight: 400; font-weight: 400;
opacity: 0.6; opacity: 0.6;
} }
.admin-links {
display: flex;
align-items: center;
gap: 8px;
}
.login { .login {
border: 0; border: 0;
background: transparent; background: transparent;
+11
View File
@@ -8,6 +8,7 @@ import { parseLesson } from "./composables/useLessonParse"
import { useLearnTrace } from "./composables/useLearnTrace" import { useLearnTrace } from "./composables/useLearnTrace"
import { useBackClose } from "shared/composables/backClose" import { useBackClose } from "shared/composables/backClose"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import AdminLink from "shared/components/AdminLink.vue"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
import { import {
COURSES, COURSES,
@@ -293,6 +294,15 @@ function jumpExercise(id: number) {
<span v-if="tutorial.project">{{ tutorial.project }}</span> <span v-if="tutorial.project">{{ tutorial.project }}</span>
<span v-if="tutorial.project">·</span> <span v-if="tutorial.project">·</span>
<span class="num">第 {{ step }} / {{ titles.length }} 课</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> </div>
<h1 class="h1">{{ parts.kp }}</h1> <h1 class="h1">{{ parts.kp }}</h1>
<div v-if="parts.scene" class="scene">{{ parts.scene }}</div> <div v-if="parts.scene" class="scene">{{ parts.scene }}</div>
@@ -546,6 +556,7 @@ function jumpExercise(id: number) {
} }
.crumb { .crumb {
display: flex; display: flex;
align-items: center;
gap: 8px; gap: 8px;
font-size: 13px; font-size: 13px;
margin-bottom: 6px; margin-bottom: 6px;
@@ -58,7 +58,8 @@ export function useEditorMenu(statisticsInline: Ref<boolean>) {
// 一写回去就顺着 Yjs 同步过去,等于一键清空学生的作业,他还没法撤回 // 一写回去就顺着 Yjs 同步过去,等于一键清空学生的作业,他还没法撤回
if (!teacherCollab.value) items.push({ label: "重置代码", key: "reset" }) 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 return items
}) })
+4
View File
@@ -15,6 +15,7 @@ import Pagination from "shared/components/Pagination.vue"
import { useBreakpoints } from "shared/composables/breakpoints" import { useBreakpoints } from "shared/composables/breakpoints"
import { usePagination } from "shared/composables/pagination" import { usePagination } from "shared/composables/pagination"
import { useAuthModalStore } from "shared/store/authModal" import { useAuthModalStore } from "shared/store/authModal"
import AdminLink from "shared/components/AdminLink.vue"
import { useUserStore } from "shared/store/user" import { useUserStore } from "shared/store/user"
import ProblemStatus from "./components/ProblemStatus.vue" import ProblemStatus from "./components/ProblemStatus.vue"
import ProblemTypeTag from "./components/ProblemTypeTag.vue" import ProblemTypeTag from "./components/ProblemTypeTag.vue"
@@ -430,6 +431,9 @@ const gridColumns = computed(() =>
</div> </div>
<div class="spacer"></div> <div class="spacer"></div>
<Hitokoto class="hitokoto" /> <Hitokoto class="hitokoto" />
<AdminLink v-if="userStore.hasProblemPermission" to="/admin/problem/create" size="small">
新建题目
</AdminLink>
</div> </div>
<div class="titlebar"> <div class="titlebar">
+9
View File
@@ -1,5 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { useThemeVars } from "naive-ui" 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 { getProblemSetList } from "oj/api"
import { rgba, useTone } from "oj/submission/composables/tone" import { rgba, useTone } from "oj/submission/composables/tone"
import { parseTime } from "utils/functions" import { parseTime } from "utils/functions"
@@ -13,6 +16,8 @@ import { badgeCondition, ladder, nextBadge } from "./badges"
* 题单一共十几个,一次全取回来在前端分组,不分页 * 题单一共十几个,一次全取回来在前端分组,不分页
*/ */
const router = useRouter() const router = useRouter()
const userStore = useUserStore()
const { isDesktop } = useBreakpoints()
const theme = useThemeVars() const theme = useThemeVars()
const tone = useTone() const tone = useTone()
@@ -88,6 +93,10 @@ const doneBackground = computed(() => rgba(theme.value.successColor, 0.05))
<h2>题单</h2> <h2>题单</h2>
<div class="spacer"></div> <div class="spacer"></div>
<n-input v-model:value="keyword" placeholder="搜题单" clearable class="search" /> <n-input v-model:value="keyword" placeholder="搜题单" clearable class="search" />
<!-- 手机上一行放不下,也没人在手机上出题,只给电脑 -->
<AdminLink v-if="isDesktop && userStore.isTeacherOrAbove" to="/admin/problemset/create"
>新建题单</AdminLink
>
</div> </div>
<template v-if="assigning.length"> <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) 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() { function toggleDemoMode() {
demoSwitch.value = !demoSwitch.value demoSwitch.value = !demoSwitch.value
storage.set(STORAGE_KEY.DEMO_MODE, demoSwitch.value) storage.set(STORAGE_KEY.DEMO_MODE, demoSwitch.value)
@@ -124,6 +140,7 @@ export const useUserStore = defineStore("user", () => {
hasProblemPermission, hasProblemPermission,
demoMode, demoMode,
canToggleDemoMode, canToggleDemoMode,
canEditProblem,
toggleDemoMode, toggleDemoMode,
isAuthed, isAuthed,
showSubmissions, showSubmissions,