feat(阶段1): 搬入 ojnext 为 apps/web,未改业务代码

This commit is contained in:
2026-08-06 21:18:16 -06:00
parent 3c975e85ee
commit ae1fb329b5
258 changed files with 40490 additions and 91 deletions

View File

@@ -0,0 +1,204 @@
<script setup lang="ts">
import { h } from "vue"
import { RouterLink } from "vue-router"
import { STORAGE_KEY } from "utils/constants"
import storage from "utils/storage"
import { useUserStore } from "../store/user"
import type { MenuOption } from "naive-ui"
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
// 根据用户权限动态生成菜单选项
const options = computed<MenuOption[]>(() => {
const baseOptions: MenuOption[] = [
{
label: () => h(RouterLink, { to: "/" }, { default: () => "前台" }),
key: "return to OJ",
},
]
// Student Admin: only problems
if (userStore.isStudentAdmin) {
baseOptions.push({
label: () =>
h(RouterLink, { to: "/admin/problem/list" }, { default: () => "题目" }),
key: "admin problem list",
})
}
// Teacher Admin: problems + contests + problemsets
if (userStore.isTeacherAdmin) {
baseOptions.push(
{
label: () =>
h(
RouterLink,
{ to: "/admin/problem/list" },
{ default: () => "题目" },
),
key: "admin problem list",
},
{
label: () =>
h(
RouterLink,
{ to: "/admin/contest/list" },
{ default: () => "比赛" },
),
key: "admin contest list",
},
{
label: () =>
h(
RouterLink,
{ to: "/admin/problemset/list" },
{ default: () => "题单" },
),
key: "admin problemset list",
},
{
label: () =>
h(
RouterLink,
{ to: "/admin/ai/reports" },
{ default: () => "AI报告" },
),
key: "admin ai reports",
},
)
}
// Super Admin: everything
if (userStore.isSuperAdmin) {
baseOptions.push(
{
label: () => h(RouterLink, { to: "/admin" }, { default: () => "管理" }),
key: "admin home",
},
{
label: () =>
h(RouterLink, { to: "/admin/config" }, { default: () => "设置" }),
key: "admin config",
},
{
label: () =>
h(
RouterLink,
{ to: "/admin/problem/list" },
{ default: () => "题目" },
),
key: "admin problem list",
},
{
label: () =>
h(
RouterLink,
{ to: "/admin/contest/list" },
{ default: () => "比赛" },
),
key: "admin contest list",
},
{
label: () =>
h(
RouterLink,
{ to: "/admin/problemset/list" },
{ default: () => "题单" },
),
key: "admin problemset list",
},
{
label: () =>
h(RouterLink, { to: "/admin/user/list" }, { default: () => "用户" }),
key: "admin user list",
},
{
label: () =>
h(
RouterLink,
{ to: "/admin/announcement/list" },
{ default: () => "公告" },
),
key: "admin announcement list",
},
{
label: () =>
h(
RouterLink,
{ to: "/admin/tutorial/list" },
{ default: () => "教程" },
),
key: "admin tutorial list",
},
{
label: () =>
h(
RouterLink,
{ to: "/admin/achievement/list" },
{ default: () => "成就" },
),
key: "admin achievement list",
},
{
label: () =>
h(
RouterLink,
{ to: "/admin/ai/reports" },
{ default: () => "AI报告" },
),
key: "admin ai reports",
},
)
}
return baseOptions
})
// 根据路径计算当前激活的菜单项
const active = computed(() => {
const path = route.path
if (path === "/") return "return to OJ"
if (path === "/admin") return "admin home"
if (path.startsWith("/admin/config")) return "admin config"
if (path.startsWith("/admin/problemset")) return "admin problemset list"
if (path.startsWith("/admin/problem/stuck")) return "admin stuck problems"
if (path.startsWith("/admin/problem/tags")) return "admin problem list"
if (path.startsWith("/admin/problem")) return "admin problem list"
if (path.startsWith("/admin/contest")) return "admin contest list"
if (path.startsWith("/admin/user")) return "admin user list"
if (path.startsWith("/admin/announcement")) return "admin announcement list"
if (path.startsWith("/admin/tutorial")) return "admin tutorial list"
if (path.startsWith("/admin/ai")) return "admin ai reports"
return route.name as string
})
onMounted(async () => {
if (!storage.get(STORAGE_KEY.AUTHED)) {
router.replace("/")
} else {
await userStore.getMyProfile()
if (!userStore.isAdminRole) {
router.replace("/")
}
}
})
</script>
<template>
<n-layout has-sider position="absolute">
<!-- 侧边栏 -->
<n-layout-sider bordered :width="100" :native-scrollbar="false">
<!-- 菜单 -->
<n-menu :options="options" :value="active" />
</n-layout-sider>
<!-- 主内容区域 -->
<n-layout>
<n-layout-content content-style="padding: 20px">
<router-view></router-view>
</n-layout-content>
</n-layout>
</n-layout>
</template>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import Beian from "../components/Beian.vue"
import Header from "../components/Header.vue"
import Login from "../components/Login.vue"
import Signup from "../components/Signup.vue"
import LoginSummaryModal from "../components/LoginSummaryModal.vue"
import AchievementToast from "../components/AchievementToast.vue"
import { useAchievementStore } from "shared/store/achievement"
import { useUserStore } from "shared/store/user"
const achievementStore = useAchievementStore()
const userStore = useUserStore()
const route = useRoute()
// 拉取才是主通道WebSocket 不是常驻连接(只在问题页且有提交监听时建连),
// 所以任何页面、任何时刻解锁的成就都靠这里补上
watch(
() => route.path,
() => {
if (userStore.isAuthed) achievementStore.fetchPending()
},
{ immediate: true },
)
</script>
<template>
<n-layout position="absolute">
<n-layout-header bordered style="padding: 8px">
<Header class="header" />
</n-layout-header>
<n-layout-content
content-style="padding: 16px; overflow-x: initial; max-width: 2000px; margin: 0 auto;"
>
<router-view></router-view>
</n-layout-content>
<Login />
<Signup />
<LoginSummaryModal />
<AchievementToast />
<Beian />
</n-layout>
</template>
<style scoped>
.header {
max-width: 2000px;
margin-left: auto;
margin-right: auto;
}
</style>