题目页「编辑题目」从只给超管放宽到有权改这道题的人(含学生管理员改自己出的题); 题目、比赛、题单列表加「新建」;自学首页加「自学情况」「管理教程」,课文加「编辑这一课」; 公告加「编辑这条」「发公告」;成就加「管理成就」;智能分析加「AI 报告」,看某个学生时 带着用户名过去直接筛到他。按钮统一走 shared/components/AdminLink.vue。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e7b6e27b85
commit
0ffc405a20
12 files changed
+139
-2
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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,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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user