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

77
apps/web/src/App.vue Normal file
View File

@@ -0,0 +1,77 @@
<script setup lang="ts">
import { darkTheme, dateZhCN, zhCN } from "naive-ui"
import "normalize.css"
import "./index.css"
import { useConfigStore } from "shared/store/config"
import { useConfigUpdate } from "shared/composables/configUpdate"
import { useMaxKB } from "shared/composables/maxkb"
import { useUserStore } from "shared/store/user"
const isDark = useDark()
const configStore = useConfigStore()
const userStore = useUserStore()
// 初始化配置和实时更新
onMounted(() => {
configStore.getConfig()
userStore.getMyProfile()
})
// 使用配置更新和 MaxKB 功能
useConfigUpdate()
useMaxKB()
// 延迟加载 highlight.js避免阻塞首屏
const hljsInstance = ref<any>(null)
const loadHighlightJS = async () => {
if (hljsInstance.value) return hljsInstance.value
const [hljs, c, cpp, python, java, javascript, go, sql] = await Promise.all([
import("highlight.js/lib/core"),
import("highlight.js/lib/languages/c"),
import("highlight.js/lib/languages/cpp"),
import("highlight.js/lib/languages/python"),
import("highlight.js/lib/languages/java"),
import("highlight.js/lib/languages/javascript"),
import("highlight.js/lib/languages/go"),
import("highlight.js/lib/languages/sql"),
]).then((modules) => modules.map((m) => m.default))
hljs.registerLanguage("c", c)
hljs.registerLanguage("python", python)
hljs.registerLanguage("cpp", cpp)
hljs.registerLanguage("java", java)
hljs.registerLanguage("javascript", javascript)
hljs.registerLanguage("go", go)
hljs.registerLanguage("sql", sql)
hljsInstance.value = hljs
return hljs
}
// 在空闲时预加载
onMounted(() => {
if ("requestIdleCallback" in window) {
requestIdleCallback(() => loadHighlightJS())
} else {
setTimeout(() => loadHighlightJS(), 1000)
}
})
provide("hljs", hljsInstance)
</script>
<template>
<n-config-provider
:theme="isDark ? darkTheme : null"
:locale="zhCN"
:date-locale="dateZhCN"
:hljs="hljsInstance"
>
<n-dialog-provider>
<n-message-provider>
<router-view></router-view>
</n-message-provider>
</n-dialog-provider>
</n-config-provider>
</template>

View File

@@ -0,0 +1,185 @@
<script setup lang="ts">
import {
createAchievement,
getMetricOptions,
updateAchievement,
type AdminAchievement,
type MetricOption,
} from "admin/api"
import AchievementIcon from "shared/components/AchievementIcon.vue"
import { RARITY_LABEL } from "utils/constants"
const rarityOptions = Object.entries(RARITY_LABEL).map(([value, label]) => ({
label,
value,
}))
const props = defineProps<{
show: boolean
editing: AdminAchievement | null
}>()
const emit = defineEmits<{ "update:show": [boolean]; saved: [] }>()
const message = useMessage()
const metrics = ref<MetricOption[]>([])
const saving = ref(false)
function emptyForm() {
return {
name: "",
description: "",
icon: "noto:trophy",
rarity: "bronze",
hidden: false,
metric: "",
operator: "gte" as "gte" | "lte",
threshold: 1,
visible: true,
order: 0,
}
}
const form = ref(emptyForm())
const metricOptions = computed(() =>
metrics.value.map((m) => ({ label: `${m.name}${m.key}`, value: m.key })),
)
const metricHelp = computed(
() => metrics.value.find((m) => m.key === form.value.metric)?.help_text ?? "",
)
watch(
() => props.show,
async (show) => {
if (!show) return
if (!metrics.value.length) {
const res = await getMetricOptions()
metrics.value = res.data
}
if (props.editing) {
form.value = { ...emptyForm(), ...props.editing }
} else {
form.value = { ...emptyForm(), metric: metrics.value[0]?.key ?? "" }
}
},
)
async function save() {
if (!form.value.name || !form.value.metric) {
message.error("名称和指标不能为空")
return
}
saving.value = true
try {
if (props.editing) {
await updateAchievement({ ...form.value, id: props.editing.id })
} else {
await createAchievement(form.value)
}
message.success("保存成功")
emit("update:show", false)
emit("saved")
} finally {
saving.value = false
}
}
</script>
<template>
<n-modal
:show="show"
preset="card"
style="width: 560px"
:title="editing ? '编辑成就' : '新建成就'"
@update:show="emit('update:show', $event)"
>
<n-form label-placement="left" :label-width="80">
<n-form-item label="名称" required>
<n-input v-model:value="form.name" placeholder="成就名称" />
</n-form-item>
<n-form-item label="描述" required>
<n-input
v-model:value="form.description"
type="textarea"
placeholder="达成条件的描述,展示给学生看"
/>
</n-form-item>
<n-form-item label="图标">
<n-flex vertical :size="4" style="flex: 1">
<n-flex align="center" :size="10" :wrap="false">
<div class="icon-preview">
<AchievementIcon :icon="form.icon" :size="28" />
</div>
<n-input
v-model:value="form.icon"
placeholder="iconify 图标名,例如 noto:owl"
/>
</n-flex>
<n-text depth="3" style="font-size: 12px">
iconify 图标名推荐 noto: 开头的彩色 emoji 图标左侧是实时
预览预览不出来说明名字写错了图标名可在 icon-sets.iconify.design
搜索
</n-text>
</n-flex>
</n-form-item>
<n-form-item label="稀有度">
<n-select v-model:value="form.rarity" :options="rarityOptions" />
</n-form-item>
<n-form-item label="指标" required>
<n-select
v-model:value="form.metric"
:options="metricOptions"
filterable
/>
</n-form-item>
<n-form-item v-if="metricHelp" label=" ">
<n-text depth="3">{{ metricHelp }}</n-text>
</n-form-item>
<n-form-item label="条件">
<n-flex align="center">
<n-select
v-model:value="form.operator"
style="width: 130px"
:options="[
{ label: '大于等于', value: 'gte' },
{ label: '小于等于', value: 'lte' },
]"
/>
<n-input-number v-model:value="form.threshold" :min="0" />
</n-flex>
</n-form-item>
<n-form-item label="隐藏成就">
<n-switch v-model:value="form.hidden" />
<n-text depth="3" style="margin-left: 12px">
未解锁时学生只能看到 ???
</n-text>
</n-form-item>
<n-form-item label="上架">
<n-switch v-model:value="form.visible" />
</n-form-item>
<n-form-item label="排序">
<n-input-number v-model:value="form.order" />
</n-form-item>
</n-form>
<template #footer>
<n-flex justify="end">
<n-button @click="emit('update:show', false)">取消</n-button>
<n-button type="primary" :loading="saving" @click="save">
保存
</n-button>
</n-flex>
</template>
</n-modal>
</template>
<style scoped>
.icon-preview {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 34px;
flex: none;
}
</style>

View File

@@ -0,0 +1,144 @@
<script setup lang="ts">
import { NButton, NFlex } from "naive-ui"
import {
deleteAchievement,
getAdminAchievements,
type AdminAchievement,
} from "admin/api"
import AchievementIcon from "shared/components/AchievementIcon.vue"
import AchievementModal from "./components/AchievementModal.vue"
import { RARITY_LABEL } from "utils/constants"
const message = useMessage()
const dialog = useDialog()
const list = ref<AdminAchievement[]>([])
const loading = ref(false)
const showModal = ref(false)
const editing = ref<AdminAchievement | null>(null)
// 下架的只在有的时候才提,全部上架时标题不啰嗦
const title = computed(() => {
if (!list.value.length) return "成就管理"
const offline = list.value.filter((a) => !a.visible).length
return offline
? `成就管理(${list.value.length} 条,${offline} 条下架)`
: `成就管理(${list.value.length} 条)`
})
async function load() {
loading.value = true
try {
const res = await getAdminAchievements()
list.value = res.data
} finally {
loading.value = false
}
}
function create() {
editing.value = null
showModal.value = true
}
function edit(row: AdminAchievement) {
editing.value = row
showModal.value = true
}
function remove(row: AdminAchievement) {
dialog.warning({
title: "删除成就",
content: `确定删除「${row.name}」?已解锁记录会一并删除。`,
positiveText: "删除",
negativeText: "取消",
onPositiveClick: async () => {
await deleteAchievement(row.id)
message.success("已删除")
load()
},
})
}
const columns: DataTableColumn<AdminAchievement>[] = [
{
title: "图标",
key: "icon",
width: 60,
render: (row) => h(AchievementIcon, { icon: row.icon, size: 24 }),
},
{ title: "名称", key: "name" },
{
title: "稀有度",
key: "rarity",
width: 90,
render: (row) => RARITY_LABEL[row.rarity] ?? row.rarity,
},
{ title: "指标", key: "metric_name" },
{
title: "条件",
key: "threshold",
width: 110,
render: (row) => `${row.operator === "gte" ? "≥" : "≤"} ${row.threshold}`,
},
{
title: "隐藏",
key: "hidden",
width: 70,
render: (row) => (row.hidden ? "是" : "—"),
},
{
title: "上架",
key: "visible",
width: 70,
render: (row) => (row.visible ? "是" : "否"),
},
{ title: "已解锁人数", key: "unlock_count", width: 110 },
{
title: "操作",
key: "actions",
width: 130,
render: (row) =>
h(NFlex, { size: 8 }, () => [
h(
NButton,
{ text: true, type: "primary", onClick: () => edit(row) },
() => "编辑",
),
h(
NButton,
{ text: true, type: "error", onClick: () => remove(row) },
() => "删除",
),
]),
},
]
onMounted(load)
</script>
<template>
<n-card :title="title">
<template #header-extra>
<n-button type="primary" @click="create">新建成就</n-button>
</template>
<n-alert type="info" style="margin-bottom: 12px">
已解锁人数是唯一的仪表盘配置一周后仍为
0多半是阈值配错了而不是太难
</n-alert>
<n-data-table
:loading="loading"
:data="list"
:columns="columns"
:row-key="(row: AdminAchievement) => row.id"
/>
<AchievementModal
v-model:show="showModal"
:editing="editing"
@saved="load"
/>
</n-card>
</template>

View File

@@ -0,0 +1,206 @@
<template>
<n-flex justify="space-between" class="titleWrapper">
<h2 class="title">AI 学习分析报告</h2>
<n-input
v-model:value="query.username"
clearable
placeholder="输入用户名筛选"
style="width: 200px"
/>
</n-flex>
<n-alert
v-if="pinnedReports.length > 0"
type="warning"
:show-icon="true"
style="margin-bottom: 12px"
>
以下 <strong>{{ pinnedReports.length }}</strong> 位用户的 AI
分析报告已被锁定前台将固定显示该报告
<n-flex style="margin-top: 8px" :wrap="true" :size="[8, 6]">
<n-tag
v-for="r in pinnedReports"
:key="r.id"
type="warning"
size="small"
closable
@close="togglePin(r)"
>
{{ r.username }}
</n-tag>
</n-flex>
</n-alert>
<n-data-table striped :columns="columns" :data="reports" />
<Pagination
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
<n-modal
v-model:show="showModal"
preset="card"
title="分析报告详情"
style="width: 800px; max-width: 95vw"
>
<n-spin :show="loadingDetail">
<div v-if="detail" class="detail">
<n-descriptions :column="2" bordered size="small" class="meta">
<n-descriptions-item label="用户">{{
detail.username
}}</n-descriptions-item>
<n-descriptions-item label="班级">{{
detail.class_name || "-"
}}</n-descriptions-item>
<n-descriptions-item label="时间" :span="2">{{
parseTime(detail.create_time, "YYYY-MM-DD HH:mm:ss")
}}</n-descriptions-item>
</n-descriptions>
<n-scrollbar style="max-height: 60vh; margin-top: 12px">
<MdPreview :model-value="detail.analysis" />
</n-scrollbar>
</div>
</n-spin>
</n-modal>
</template>
<script lang="ts" setup>
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import Pagination from "shared/components/Pagination.vue"
import { parseTime } from "utils/functions"
import {
getAIReportList,
getAIReportDetail,
pinAIReport,
getPinnedAIReports,
} from "../api"
import { NButton, NTag } from "naive-ui"
interface ReportItem {
id: number
create_time: string
username: string
analysis_excerpt: string
is_pinned: boolean
}
interface ReportDetail extends ReportItem {
analysis: string
class_name: string | null
}
const reports = ref<ReportItem[]>([])
const total = ref(0)
const query = reactive({ limit: 10, page: 1, username: "" })
const pinnedReports = ref<ReportItem[]>([])
const showModal = ref(false)
const loadingDetail = ref(false)
const detail = ref<ReportDetail | null>(null)
const columns: DataTableColumn<ReportItem>[] = [
{ title: "ID", key: "id", width: 80 },
{
title: "用户名",
key: "username",
width: 150,
render: (row) =>
h(
"span",
{ style: row.is_pinned ? "font-weight:600" : "" },
row.username,
),
},
{
title: "AI 分析内容",
key: "analysis_excerpt",
render: (row) => row.analysis_excerpt || "-",
},
{
title: "生成时间",
key: "create_time",
width: 200,
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
},
{
title: "PIN 状态",
key: "is_pinned",
width: 100,
render: (row) =>
row.is_pinned
? h(NTag, { type: "warning", size: "small" }, () => "已锁定")
: null,
},
{
title: "操作",
key: "action",
width: 160,
render: (row) =>
h("span", { style: "display:flex;gap:8px" }, [
h(
NButton,
{ size: "small", type: "primary", onClick: () => openDetail(row.id) },
() => "查看",
),
h(
NButton,
{
size: "small",
type: row.is_pinned ? "error" : "default",
onClick: () => togglePin(row),
},
() => (row.is_pinned ? "取消 PIN" : "PIN"),
),
]),
},
]
async function loadPinnedReports() {
const res = await getPinnedAIReports()
pinnedReports.value = res.data
}
async function togglePin(row: ReportItem) {
await pinAIReport(row.id)
await Promise.all([listReports(), loadPinnedReports()])
}
async function listReports() {
const offset = (query.page - 1) * query.limit
const res = await getAIReportList(offset, query.limit, query.username)
reports.value = res.data.results
total.value = res.data.total
}
async function openDetail(id: number) {
showModal.value = true
loadingDetail.value = true
detail.value = null
try {
const res = await getAIReportDetail(id)
detail.value = res.data
} finally {
loadingDetail.value = false
}
}
onMounted(() => Promise.all([listReports(), loadPinnedReports()]))
watch(() => [query.page, query.limit], listReports)
watchDebounced(() => query.username, listReports, {
debounce: 500,
maxWait: 1000,
})
</script>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
align-items: center;
}
.title {
margin: 0;
}
.detail .meta {
margin-bottom: 0;
}
</style>

View File

@@ -0,0 +1,39 @@
<script lang="ts" setup>
import { deleteAnnouncement } from "admin/api"
interface Props {
announcementID: number
}
const props = defineProps<Props>()
const emit = defineEmits(["deleted"])
const router = useRouter()
const message = useMessage()
function goEdit() {
router.push({
name: "admin announcement edit",
params: { announcementID: props.announcementID },
})
}
async function handleDelete() {
await deleteAnnouncement(props.announcementID)
message.success("删除成功")
emit("deleted")
}
</script>
<template>
<n-flex>
<n-button size="small" type="success" secondary @click="goEdit">
编辑
</n-button>
<n-popconfirm @positive-click="handleDelete">
<template #trigger>
<n-button size="small" type="error" secondary>删除</n-button>
</template>
确定删除这条公告吗
</n-popconfirm>
</n-flex>
</template>
<style scoped></style>

View File

@@ -0,0 +1,117 @@
<script lang="ts" setup>
import TextEditor from "shared/components/TextEditor.vue"
import type { AnnouncementEdit } from "utils/types"
import { createAnnouncement, editAnnouncement, getAnnouncement } from "../api"
interface Props {
announcementID?: string
}
const route = useRoute()
const router = useRouter()
const message = useMessage()
const props = defineProps<Props>()
const [ready, toggleReady] = useToggle()
const announcement = reactive<AnnouncementEdit>({
id: 0,
title: "",
tag: "公告",
content: "",
visible: false,
top: false,
})
const tags: SelectOption[] = [
{ label: "公告", value: "公告" },
{ label: "更新", value: "更新" },
]
async function init() {
if (!props.announcementID) {
toggleReady(true)
return
}
const id = parseInt(route.params.announcementID as string)
const res = await getAnnouncement(id)
toggleReady(true)
announcement.id = id
announcement.title = res.data.title
announcement.content = res.data.content
announcement.visible = res.data.visible
announcement.tag = res.data.tag
announcement.top = res.data.top
}
async function submit() {
if (announcement.content === "<p><br></p>") {
announcement.content = ""
}
if (!announcement.title || !announcement.content) {
message.error("标题和正文必填")
return
}
const api = {
"admin announcement create": createAnnouncement,
"admin announcement edit": editAnnouncement,
}[route.name as string]
try {
await api!(announcement)
if (route.name === "admin announcement create") {
message.success("成功新建公告 💐")
} else {
message.success("修改已保存")
}
router.push({ name: "admin announcement list" })
} catch (err: any) {
message.error(err.data)
}
}
onMounted(init)
</script>
<template>
<h2 class="title">
{{ route.name === "admin announcement create" ? "新建公告" : "编辑公告" }}
</h2>
<n-form inline>
<n-form-item label="标题">
<n-input class="contestTitle" v-model:value="announcement.title" />
</n-form-item>
<n-form-item label="标签">
<n-select
class="select"
v-model:value="announcement.tag"
:options="tags"
/>
</n-form-item>
<n-form-item label="可见">
<n-switch v-model:value="announcement.visible" />
</n-form-item>
<n-form-item label="置顶">
<n-switch v-model:value="announcement.top" />
</n-form-item>
</n-form>
<TextEditor
v-if="ready"
title="正文"
v-model:value="announcement.content"
:min-height="200"
/>
<n-flex style="margin-bottom: 100px" justify="end">
<n-button type="primary" @click="submit">保存</n-button>
</n-flex>
</template>
<style scoped>
.title {
margin-top: 0;
}
.select {
width: 100px;
}
.contestTitle {
width: 400px;
}
</style>

View File

@@ -0,0 +1,113 @@
<script setup lang="ts">
import { NSwitch } from "naive-ui"
import Pagination from "shared/components/Pagination.vue"
import { parseTime } from "utils/functions"
import type { Announcement } from "utils/types"
import { editAnnouncement, getAnnouncementList } from "../api"
import Actions from "./components/Actions.vue"
const total = ref(0)
const query = reactive({
limit: 10,
page: 1,
})
const announcements = ref<Announcement[]>([])
const columns: DataTableColumn<Announcement>[] = [
{ title: "ID", key: "id", width: 60 },
{ title: "标题", key: "title", minWidth: 300 },
{ title: "标签", key: "tag", width: 80 },
{
title: "置顶",
key: "top",
render: (row) => (row.top ? "置顶" : ""),
width: 80,
},
{
title: "创建时间",
key: "create_time",
width: 180,
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
},
{
title: "上次更新时间",
key: "last_update_time",
width: 180,
render: (row) => parseTime(row.last_update_time, "YYYY-MM-DD HH:mm:ss"),
},
{
title: "作者",
key: "created_by",
render: (row) => row.created_by.username,
width: 80,
},
{
title: "可见",
key: "visible",
render: (row) =>
h(NSwitch, {
value: row.visible,
size: "small",
rubberBand: false,
onUpdateValue: () => toggleVisible(row),
}),
},
{
title: "选项",
key: "actions",
width: 140,
render: (row) =>
h(Actions, { announcementID: row.id, onDeleted: listAnnouncements }),
},
]
async function toggleVisible(announcement: Announcement) {
announcement.visible = !announcement.visible
editAnnouncement({
id: announcement.id,
title: announcement.title,
tag: announcement.tag,
content: announcement.content,
visible: announcement.visible,
top: announcement.top,
})
}
async function listAnnouncements() {
const offset = (query.page - 1) * query.limit
const res = await getAnnouncementList(offset, query.limit)
announcements.value = res.data.results
total.value = res.data.total
}
onMounted(listAnnouncements)
watch(query, listAnnouncements, { deep: true })
</script>
<template>
<n-flex align="center" class="titleWrapper">
<h2 class="title">网站公告</h2>
<n-button
type="primary"
@click="$router.push({ name: 'admin announcement create' })"
>
新建
</n-button>
</n-flex>
<n-data-table striped :columns="columns" :data="announcements" />
<Pagination
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
</template>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
}
.title {
margin: 0;
}
</style>

600
apps/web/src/admin/api.ts Normal file
View File

@@ -0,0 +1,600 @@
import http from "utils/http"
import { toProblemListItem } from "admin/transforms"
import type {
AdminProblem,
AdminTag,
Announcement,
AnnouncementEdit,
BlankContest,
BlankProblem,
Contest,
Exercise,
ExerciseType,
Server,
SQLDisplay,
TestcaseUploadedReturns,
Tutorial,
User,
WebsiteConfig,
} from "utils/types"
export function getBaseInfo() {
return http.get("admin/dashboard_info")
}
export function randomUser10(classroom: string) {
return http.get("admin/random_user", { params: { classroom } })
}
export async function getProblemList(
offset = 0,
limit = 10,
keyword: string,
author?: string,
contestID?: string,
tagId?: number,
) {
const endpoint = !!contestID ? "admin/contest/problem" : "admin/problem"
const res = await http.get<{ results: AdminProblem[]; total: number }>(
endpoint,
{
params: {
paging: true,
offset,
limit,
keyword,
author,
contest_id: contestID,
tag_id: tagId,
},
},
)
return {
results: res.data.results.map(toProblemListItem),
total: res.data.total,
}
}
export function deleteProblem(id: number) {
return http.delete("admin/problem", { params: { id } })
}
export function deleteContestProblem(id: number) {
return http.delete("admin/contest/problem", { params: { id } })
}
export function editProblem(problem: AdminProblem | BlankProblem) {
return http.put("admin/problem", problem)
}
export function toggleProblemVisible(problemID: number) {
return http.put("admin/problem/visible", { id: problemID })
}
export function generateFlowchartFromPythonCode(python: string) {
return http.post("admin/problem/flowchart", { python })
}
export function editContestProblem(problem: AdminProblem | BlankProblem) {
return http.put("admin/contest/problem", problem)
}
export function getProblem(id: string | number) {
return http.get<AdminProblem>("admin/problem", { params: { id } })
}
export function getContestProblem(id: number) {
return http.get("admin/contest/problem", { params: { id } })
}
// 标签管理
export function getTagAdminList(keyword = "") {
return http.get<AdminTag[]>("admin/problem/tag", { params: { keyword } })
}
export function renameTag(id: number, name: string) {
return http.put<{
merged: boolean
id: number
name: string
affected_count: number
}>("admin/problem/tag", { id, name })
}
export function deleteTag(id: number) {
return http.delete("admin/problem/tag", { params: { id } })
}
export function batchTagProblems(
problemIds: number[],
tagNames: string[],
action: "add" | "remove",
) {
return http.post<{ problem_count: number; tag_count: number }>(
"admin/problem/batch_tag",
{ problem_ids: problemIds, tag_names: tagNames, action },
)
}
// 用户列表
export function getUserList(
offset = 0,
limit = 10,
type = "",
keyword: string,
orderBy = "",
) {
return http.get("admin/user", {
params: { paging: true, offset, limit, keyword, type, order_by: orderBy },
})
}
// 编辑用户
export function editUser(user: User) {
return http.put("admin/user", user)
}
// 重置用户密码
export function resetPassword(userID: number) {
return http.post("admin/reset_password", { id: userID })
}
// 导入用户
export function importUsers(users: string[][]) {
return http.post("admin/user", { users })
}
// 批量删除用户
export function deleteUsers(userIDs: number[]) {
return http.delete("admin/user", { params: { id: userIDs.join(",") } })
}
export function getContestList(offset = 0, limit = 10, keyword: string) {
return http.get("admin/contest", {
params: { paging: true, offset, limit, keyword },
})
}
// 上传图片
export async function uploadImage(file: File): Promise<string> {
const form = new window.FormData()
form.append("image", file)
// 该端点不走 { error, data } 信封,直接返回上传结果
const res = (await http.post("admin/upload_image", form, {
headers: { "content-type": "multipart/form-data" },
})) as unknown as { success: boolean; file_path: string; msg: "Success" }
return res.success ? res.file_path : ""
}
// 上传测试用例SQL 题的压缩包是 1.sql..N.sql每个文件一个测试点的建表+数据脚本)
export function uploadTestcases(file: File, options: { sql?: boolean } = {}) {
const form = new window.FormData()
form.append("file", file)
if (options.sql) {
form.append("sql", "1")
}
return http.post<TestcaseUploadedReturns>("admin/test_case", form, {
headers: { "content-type": "multipart/form-data" },
})
}
// SQL 题测试点预览:后端跑一遍初始化脚本+标准答案,返回数据表和期望结果展示数据
export function previewSQLTestcase(data: {
init_sql: string
ref_sql: string
mode: "query" | "modify"
}) {
return http.post<SQLDisplay>("admin/sql_test_case_preview", data)
}
// 回显已上传的 SQL 测试点脚本内容(按 1.sql, 2.sql... 排序)
export function getSQLTestcaseScripts(problemId: number) {
return http.get<{ name: string; content: string }[]>(
"admin/sql_test_case_scripts",
{ params: { problem_id: problemId } },
)
}
// AI 根据标准答案生成一个 SQL 测试点初始化脚本
export function generateSQLTestcase(data: {
ref_sql: string
mode: "query" | "modify"
}) {
return http.post<{ sql: string }>("admin/sql_test_case_ai_gen", data)
}
export function createProblem(problem: BlankProblem) {
return http.post("admin/problem", problem)
}
export function createContestProblem(problem: BlankProblem) {
return http.post("admin/contest/problem", problem)
}
export function createContest(contest: BlankContest) {
return http.post("admin/contest", contest)
}
export function editContest(contest: Contest | BlankContest) {
return http.put("admin/contest", contest)
}
export function cloneContest(contest_id: number) {
return http.post("admin/contest/clone", { contest_id })
}
export function getContest(id: string) {
return http.get<Contest & { password: string }>("admin/contest", {
params: { id },
})
}
export function addProblemForContest(
contestID: string,
problemID: number,
displayID: string,
) {
return http.post("admin/contest/add_problem_from_public", {
contest_id: contestID,
problem_id: problemID,
display_id: displayID,
})
}
export function getWebsite() {
return http.get<WebsiteConfig>("admin/website")
}
export function editWebsite(data: WebsiteConfig) {
return http.post("admin/website", data)
}
export function listInvalidTestcases() {
return http.get("admin/prune_test_case")
}
export function pruneInvalidTestcases(id?: string) {
return http.delete("admin/prune_test_case", { params: { id } })
}
export function getJudgeServer() {
return http.get<{ token: string; servers: Server[] }>("admin/judge_server")
}
export function deleteJudgeServer(hostname: string) {
return http.delete("admin/judge_server", { params: { hostname } })
}
export function getAnnouncementList(offset = 0, limit = 10) {
return http.get("admin/announcement", {
params: { paging: true, offset, limit },
})
}
export function getAnnouncement(id: number) {
return http.get<Announcement>("admin/announcement", { params: { id } })
}
export function deleteAnnouncement(id: number) {
return http.delete("admin/announcement", { params: { id } })
}
export function editAnnouncement(announcement: AnnouncementEdit) {
return http.put("admin/announcement", announcement)
}
export function createAnnouncement(announcement: AnnouncementEdit) {
return http.post("admin/announcement", announcement)
}
export async function getTutorialList() {
const res = await http.get<Tutorial[]>("admin/tutorial")
return res.data
}
export async function getTutorial(id: number) {
const res = await http.get<Tutorial>("admin/tutorial", { params: { id } })
return res.data
}
export async function createTutorial(data: Partial<Tutorial>) {
const res = await http.post<Tutorial>("admin/tutorial", data)
return res.data
}
export async function updateTutorial(data: Partial<Tutorial>) {
const res = await http.put("admin/tutorial", data)
return res.data
}
export function deleteTutorial(id: number) {
return http.delete("admin/tutorial", { params: { id } })
}
export function setTutorialVisibility(id: number, is_public: boolean) {
return http.put("admin/tutorial/visibility", { id, is_public })
}
export async function getAdminExercises(tutorialId: number) {
const res = await http.get<Exercise[]>("admin/exercise", {
params: { tutorial_id: tutorialId },
})
return res.data
}
export async function createExercise(data: {
tutorial_id: number
type: ExerciseType
data: object
order: number
}) {
const res = await http.post<Exercise>("admin/exercise", data)
return res.data
}
export async function updateExercise(data: {
id: number
type: ExerciseType
data: object
order: number
}) {
const res = await http.put("admin/exercise", data)
return res.data as Exercise
}
export function deleteExercise(id: number) {
return http.delete("admin/exercise", { params: { id } })
}
// 将竞赛题目转为公开题目
export function makeProblemPublic(id: number, display_id: string) {
return http.post("admin/contest_problem/make_public", {
id,
display_id,
})
}
// 比赛辅助检查
export function getACMHelperList(contest_id: number) {
return http.get("admin/contest/acm_helper", {
params: { contest_id },
})
}
export function updateACMHelperChecked(
contest_id: number,
rank_id: number,
problem_id: string,
checked: boolean,
) {
return http.put("admin/contest/acm_helper", {
contest_id,
rank_id,
problem_id,
checked,
})
}
// 题单管理 API
export function getProblemSetList(
offset = 0,
limit = 10,
keyword = "",
difficulty = "",
status = "",
) {
return http.get("admin/problemset", {
params: {
offset,
limit,
keyword,
difficulty,
status,
},
})
}
export function getProblemSetDetail(id: number) {
return http.get(`admin/problemset/${id}`)
}
export function createProblemSet(data: {
title: string
description: string
difficulty: string
status: string
end_time?: Date | null
}) {
return http.post("admin/problemset", data)
}
export function editProblemSet(data: {
id: number
title?: string
description?: string
difficulty?: string
status?: string
end_time?: Date | null
visible?: boolean
}) {
return http.put("admin/problemset", data)
}
export function deleteProblemSet(id: number) {
return http.delete("admin/problemset", { params: { id } })
}
export function toggleProblemSetVisible(id: number) {
return http.put("admin/problemset/visible", { id })
}
export function updateProblemSetStatus(id: number, status: string) {
return http.put("admin/problemset/status", { id, status })
}
// 题单题目管理 API
export function getProblemSetProblems(problemSetId: number) {
return http.get(`admin/problemset/${problemSetId}/problems`)
}
export function addProblemToSet(
problemSetId: number,
data: {
problem_id: string
order?: number
is_required?: boolean
score?: number
hint?: string
},
) {
return http.post(`admin/problemset/${problemSetId}/problems`, data)
}
export function editProblemInSet(
problemSetId: number,
problemSetProblemId: number,
data: {
order?: number
is_required?: boolean
score?: number
hint?: string
},
) {
return http.put(
`admin/problemset/${problemSetId}/problems/${problemSetProblemId}`,
data,
)
}
export function removeProblemFromSet(
problemSetId: number,
problemSetProblemId: number,
) {
return http.delete(
`admin/problemset/${problemSetId}/problems/${problemSetProblemId}`,
)
}
// 题单奖章管理 API
export function getProblemSetBadges(problemSetId: number) {
return http.get(`admin/problemset/${problemSetId}/badges`)
}
export function createProblemSetBadge(
problemSetId: number,
data: {
name: string
description: string
icon: string
condition_type: string
condition_value: number
level?: number
},
) {
return http.post(`admin/problemset/${problemSetId}/badges`, data)
}
export function editProblemSetBadge(
problemSetId: number,
badgeId: number,
data: {
name?: string
description?: string
icon?: string
condition_type?: string
condition_value?: number
level?: number
},
) {
return http.put(`admin/problemset/${problemSetId}/badges/${badgeId}`, data)
}
export function deleteProblemSetBadge(problemSetId: number, badgeId: number) {
return http.delete(`admin/problemset/${problemSetId}/badges/${badgeId}`)
}
// 题单进度管理 API
export function getProblemSetProgress(problemSetId: number) {
return http.get(`admin/problemset/${problemSetId}/progress`)
}
export function removeUserFromProblemSet(problemSetId: number, userId: number) {
return http.delete(`admin/problemset/${problemSetId}/progress/${userId}`)
}
// 学生卡点分析
export function getStuckProblems() {
return http.get("admin/problem/stuck")
}
export function getTopACTrend(params: {
since_year: number
until_year: number
min_per_year: number
}) {
return http.get("admin/problem/top_ac_trend", { params })
}
// AI 学习分析报告
export function getAIReportList(offset = 0, limit = 10, username = "") {
return http.get("admin/ai/reports", {
params: { paging: true, offset, limit, username: username || undefined },
})
}
export function getAIReportDetail(id: number) {
return http.get("admin/ai/reports", { params: { id } })
}
export function pinAIReport(id: number) {
return http.post("admin/ai/reports", { id })
}
export function getPinnedAIReports() {
return http.get("admin/ai/reports", { params: { pinned_only: "true" } })
}
// ==================== 成就 ====================
export interface AdminAchievement {
id: number
name: string
description: string
icon: string
rarity: string
hidden: boolean
metric: string
metric_name: string
operator: "gte" | "lte"
threshold: number
visible: boolean
unlock_count: number
order: number
create_time: string
}
export interface MetricOption {
key: string
name: string
help_text: string
}
export function getAdminAchievements() {
return http.get<AdminAchievement[]>("admin/achievement")
}
export function getMetricOptions() {
return http.get<MetricOption[]>("admin/achievement/metrics")
}
export function createAchievement(data: Partial<AdminAchievement>) {
return http.post<AdminAchievement>("admin/achievement", data)
}
export function updateAchievement(data: Partial<AdminAchievement>) {
return http.put<AdminAchievement>("admin/achievement", data)
}
export function deleteAchievement(id: number) {
return http.delete("admin/achievement", { params: { id } })
}

View File

@@ -0,0 +1,2 @@
<template>未完待续</template>
<script lang="ts" setup></script>

View File

@@ -0,0 +1,68 @@
<script lang="ts" setup>
import type { Contest } from "utils/types"
import { cloneContest } from "../../api"
interface Props {
contest: Contest
}
const props = defineProps<Props>()
const router = useRouter()
const message = useMessage()
function goEdit() {
router.push({
name: "admin contest edit",
params: { contestID: props.contest.id },
})
}
function goEditProblems() {
router.push({
name: "admin contest problem list",
params: { contestID: props.contest.id },
})
}
function goACMHelper() {
router.push({
name: "admin contest helper",
params: { contestID: props.contest.id },
})
}
async function clone() {
try {
const res = await cloneContest(props.contest.id)
message.success("复制成功")
router.push({
name: "admin contest edit",
params: { contestID: res.data.id },
})
} catch {
message.error("复制失败")
}
}
const isACM = computed(() => props.contest.rule_type === "ACM")
</script>
<template>
<n-flex>
<n-button size="small" type="primary" secondary @click="goEditProblems">
题目
</n-button>
<n-button
v-if="isACM"
size="small"
type="warning"
secondary
@click="goACMHelper"
>
审核
</n-button>
<n-button size="small" type="info" secondary @click="goEdit">
编辑
</n-button>
<n-button size="small" secondary @click="clone"> 复制 </n-button>
</n-flex>
</template>
<style scoped></style>

View File

@@ -0,0 +1,196 @@
<script setup lang="ts">
import { formatISO } from "date-fns"
import TextEditor from "shared/components/TextEditor.vue"
import { parseTime } from "utils/functions"
import type { BlankContest } from "utils/types"
import { createContest, editContest, getContest } from "../api"
interface Props {
contestID?: string
}
function getTimes() {
const timestamp = Date.now()
const rounded = timestamp - (timestamp % 60000) // 确保秒数为0
const t1 = rounded + waitMins.value * 60000
const t2 = t1 + durationMins.value * 60000
return [t1, t2]
}
// 创建的时候
const waitMins = ref(5) // 顺延5分钟
const durationMins = ref(10) // 比赛默认时长10分钟
watch([waitMins, durationMins], () => {
const times = getTimes()
contest.start_time = formatISO(times[0])
contest.end_time = formatISO(times[1])
})
// 编辑的时候
const startTime = ref(0)
const endTime = ref(0)
watch([startTime, endTime], (values) => {
contest.start_time = formatISO(values[0])
contest.end_time = formatISO(values[1])
})
const route = useRoute()
const router = useRouter()
const message = useMessage()
const props = defineProps<Props>()
const [ready, toggleReady] = useToggle()
const tags: SelectOption[] = [
{ label: "练习", value: "练习" },
{ label: "期中", value: "期中" },
{ label: "期末", value: "期末" },
]
const contest = reactive<BlankContest & { id: number }>({
id: 0,
title: "",
description: "",
tag: "练习",
start_time: "",
end_time: "",
password: "",
visible: false,
allowed_ip_ranges: [],
})
async function getContestDetail() {
if (!props.contestID) {
const times = getTimes()
contest.start_time = formatISO(times[0])
contest.end_time = formatISO(times[1])
toggleReady(true)
return
}
const { data } = await getContest(props.contestID)
toggleReady(true)
contest.id = data.id
contest.title = data.title
contest.description = data.description
contest.tag = data.tag
contest.start_time = data.start_time
contest.end_time = data.end_time
contest.password = data.password
contest.visible = data.visible
contest.allowed_ip_ranges = []
// 显示
startTime.value = Date.parse(data.start_time)
endTime.value = Date.parse(data.end_time)
}
async function submit() {
if (contest.description === "<p><br></p>") {
contest.description = contest.title
}
const api = {
"admin contest create": createContest,
"admin contest edit": editContest,
}[route.name as string]
try {
await api!(contest)
if (route.name === "admin contest create") {
message.success("成功新建比赛 💐")
} else {
message.success("修改已保存")
}
router.push({ name: "admin contest list" })
} catch (err: any) {
message.error(err.data)
}
}
onMounted(getContestDetail)
</script>
<template>
<n-flex class="titleWrapper" align="center">
<h2 class="title">
{{ route.name === "admin contest create" ? "新建比赛" : "编辑比赛" }}
</h2>
<template v-if="!props.contestID">
<n-alert type="success">
<template #header>
开始时间 {{ parseTime(contest.start_time, "YYYY年M月D日 HH:mm:ss") }}
</template>
</n-alert>
<n-alert type="warning">
<template #header>
结束时间 {{ parseTime(contest.end_time, "YYYY年M月D日 HH:mm:ss") }}
</template>
</n-alert>
</template>
</n-flex>
<n-form inline>
<n-form-item label="标题">
<n-input style="width: 300px" v-model:value="contest.title" />
</n-form-item>
<n-form-item label="标签">
<n-select
style="width: 100px"
:options="tags"
v-model:value="contest.tag"
/>
</n-form-item>
<template v-if="props.contestID">
<n-form-item label="开始">
<n-date-picker
style="width: 200px"
v-model:value="startTime"
type="datetime"
/>
</n-form-item>
<n-form-item label="结束">
<n-date-picker
style="width: 200px"
v-model:value="endTime"
type="datetime"
/>
</n-form-item>
</template>
<template v-else>
<n-form-item label="几分钟后开始">
<n-input-number style="width: 120px" v-model:value="waitMins" />
</n-form-item>
<n-form-item label="比赛时长">
<n-input-number
style="width: 120px"
step="5"
v-model:value="durationMins"
/>
</n-form-item>
</template>
<n-form-item label="密码">
<n-input style="width: 160px" v-model:value="contest.password" />
</n-form-item>
<n-form-item label="可见">
<n-switch v-model:value="contest.visible" />
</n-form-item>
</n-form>
<TextEditor
v-if="ready"
title="描述"
v-model:value="contest.description"
:min-height="200"
/>
<n-flex style="margin-bottom: 100px" justify="end">
<n-button type="primary" @click="submit">保存</n-button>
</n-flex>
</template>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
}
.title {
margin: 0;
}
</style>

View File

@@ -0,0 +1,326 @@
<script setup lang="ts">
import { NButton, NCheckbox, NSelect, NTag } from "naive-ui"
import { parseTime } from "utils/functions"
import { getACMHelperList, getContest, updateACMHelperChecked } from "../api"
import { getSubmission, getSubmissions } from "oj/api"
import SubmissionDetail from "oj/submission/detail.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
interface Props {
contestID: string
}
interface HelperItem {
id: number
username: string
real_name: string
problem_id: string
problem_display_id: string
ac_info: {
is_ac: boolean
ac_time: number
error_number: number
checked?: boolean
}
checked: boolean
}
const props = defineProps<Props>()
const message = useMessage()
const { isDesktop } = useBreakpoints()
const submissions = ref<HelperItem[]>([])
const contestStartTime = ref<Date | null>(null)
const query = reactive({
username: "",
problemId: "",
checked: "all",
})
// 检查状态选项
const checkedOptions = [
{ label: "全部", value: "all" },
{ label: "已检查", value: "checked" },
{ label: "未检查", value: "unchecked" },
]
// 代码查看模态框
const [codePanel, toggleCodePanel] = useToggle(false)
const currentSubmission = ref<any>(null)
// 格式化 AC 时间ac_time 是相对于比赛开始的秒数)
function formatACTime(relativeSeconds: number) {
if (!contestStartTime.value) return "-"
const acTime = new Date(
contestStartTime.value.getTime() + relativeSeconds * 1000,
)
return parseTime(acTime, "YYYY-MM-DD HH:mm:ss")
}
// 切换检查状态
async function toggleChecked(item: HelperItem) {
const newChecked = !item.checked
try {
await updateACMHelperChecked(
Number(props.contestID),
item.id,
item.problem_id,
newChecked,
)
// 更新本地状态
item.checked = newChecked
item.ac_info.checked = newChecked
// 强制触发响应式更新
submissions.value = [...submissions.value]
message.success(newChecked ? "已标记为已检查" : "已取消标记")
} catch (err: any) {
message.error(err.data || "操作失败")
}
}
// 批量标记为已检查
async function markAllAsChecked() {
const unchecked = filteredSubmissions.value.filter((item) => !item.checked)
if (unchecked.length === 0) {
message.info("没有需要标记的提交")
return
}
const loadingMsg = message.loading("正在标记...", { duration: 0 })
try {
for (const item of unchecked) {
await updateACMHelperChecked(
Number(props.contestID),
item.id,
item.problem_id,
true,
)
item.checked = true
item.ac_info.checked = true
}
// 强制触发响应式更新
submissions.value = [...submissions.value]
loadingMsg.destroy()
message.success(`已标记 ${unchecked.length} 个提交为已检查`)
} catch (err: any) {
loadingMsg.destroy()
message.error(err.data || "批量操作失败")
}
}
// 过滤后的提交列表
const filteredSubmissions = computed(() => {
return submissions.value.filter((item) => {
if (query.username && !item.username.includes(query.username)) return false
if (query.problemId && !item.problem_display_id.includes(query.problemId))
return false
if (query.checked === "checked" && !item.checked) return false
if (query.checked === "unchecked" && item.checked) return false
return true
})
})
// 统计信息
const stats = computed(() => {
const total = submissions.value.length
const checked = submissions.value.filter((item) => item.checked).length
const unchecked = total - checked
return { total, checked, unchecked }
})
// 查看代码 - 获取该用户在该题目的 AC 提交
async function viewSubmission(item: HelperItem) {
try {
// 查询该用户在该竞赛该题目的 AC 提交
const res = await getSubmissions({
username: item.username,
problem_id: item.problem_display_id,
contest_id: props.contestID,
result: "0", // ACCEPTED
language: "",
page: 1,
offset: 0,
limit: 1,
})
if (res.data.results.length === 0) {
message.warning("未找到该用户的 AC 提交")
return
}
// 获取提交详情
const submissionListItem = res.data.results[0]
const detailRes = await getSubmission(submissionListItem.id)
// 手动添加 contest 字段ACM模式下后端不返回此字段
currentSubmission.value = {
...detailRes.data,
contest: Number(props.contestID),
problem_display_id: item.problem_display_id,
}
toggleCodePanel(true)
} catch (err: any) {
message.error(err.data || "加载提交失败")
}
}
// 加载数据
async function loadData() {
try {
// 先获取比赛信息,获取开始时间
const contestRes = await getContest(props.contestID)
contestStartTime.value = new Date(contestRes.data.start_time)
// 再获取 AC 提交列表
const { data } = await getACMHelperList(Number(props.contestID))
submissions.value = data
} catch (err: any) {
message.error(err.data || "加载失败")
}
}
const columns: DataTableColumn<HelperItem>[] = [
{
title: "用户名",
key: "username",
width: 150,
},
{
title: "题目",
key: "problem_display_id",
width: 100,
render: (row) => h(NTag, { type: "info" }, () => row.problem_display_id),
},
{
title: "AC时间",
key: "ac_time",
width: 180,
render: (row) => formatACTime(row.ac_info.ac_time),
},
{
title: "错误次数",
key: "error_number",
width: 100,
render: (row) =>
h(
NTag,
{
type: row.ac_info.error_number > 0 ? "warning" : "success",
size: "small",
},
() => row.ac_info.error_number,
),
},
{
title: "已检查",
key: "checked",
width: 100,
render: (row) =>
h(NCheckbox, {
checked: row.checked,
onUpdateChecked: () => toggleChecked(row),
}),
},
{
title: "操作",
key: "actions",
width: 100,
render: (row) =>
h(
NButton,
{
size: "small",
type: "primary",
secondary: true,
onClick: () => viewSubmission(row),
},
() => "查看代码",
),
},
]
onMounted(loadData)
</script>
<template>
<n-flex vertical>
<n-flex justify="space-between" align="center">
<n-flex align="center">
<h2 style="margin: 0">比赛辅助检查</h2>
<n-tag type="info" size="large"> 总计: {{ stats.total }} </n-tag>
<n-tag type="success" size="large"> 已检查: {{ stats.checked }} </n-tag>
<n-tag type="warning" size="large">
未检查: {{ stats.unchecked }}
</n-tag>
</n-flex>
<n-button
type="primary"
:disabled="stats.unchecked === 0"
@click="markAllAsChecked"
>
标记全部为已检查
</n-button>
</n-flex>
<n-alert type="info" style="margin-bottom: 16px">
<template #header>使用说明</template>
此工具用于赛后人工审核代码检查是否存在抄袭作弊等行为请逐个查看通过AC的提交代码检查完成后勾选"已检查"
</n-alert>
<n-flex align="center" style="margin-bottom: 16px">
<n-input
v-model:value="query.username"
placeholder="筛选用户名"
style="width: 150px"
clearable
/>
<n-input
v-model:value="query.problemId"
placeholder="筛选题目"
style="width: 150px"
clearable
/>
<n-select
v-model:value="query.checked"
:options="checkedOptions"
style="width: 120px"
/>
</n-flex>
<n-data-table
:columns="columns"
:data="filteredSubmissions"
:pagination="{ pageSize: 20 }"
:bordered="false"
/>
<n-modal
v-model:show="codePanel"
preset="card"
:style="{ maxWidth: isDesktop && '70vw', maxHeight: '80vh' }"
:content-style="{ overflow: 'auto' }"
title="代码详情"
>
<SubmissionDetail
v-if="currentSubmission"
:submission="currentSubmission"
:problemID="currentSubmission.problem_display_id"
:submissionID="currentSubmission.id"
hideList
@copied="toggleCodePanel(false)"
/>
</n-modal>
</n-flex>
</template>
<style scoped>
:deep(.n-data-table) {
margin-top: 16px;
}
</style>

View File

@@ -0,0 +1,132 @@
<script setup lang="ts">
import { NSwitch, NTag } from "naive-ui"
import ContestTitle from "shared/components/ContestTitle.vue"
import ContestType from "shared/components/ContestType.vue"
import Pagination from "shared/components/Pagination.vue"
import { CONTEST_STATUS } from "utils/constants"
import { parseTime } from "utils/functions"
import type { Contest } from "utils/types"
import { editContest, getContestList } from "../api"
import Actions from "./components/Actions.vue"
const contests = ref<Contest[]>([])
const total = ref(0)
const query = reactive({
limit: 10,
page: 1,
keyword: "",
})
function toggleVisible(contest: Contest) {
contest.visible = !contest.visible
editContest(contest)
}
const columns: DataTableColumn<Contest>[] = [
{ title: "ID", key: "id", width: 60 },
{
title: "比赛",
key: "title",
minWidth: 200,
render: (row) => h(ContestTitle, { contest: row }),
},
{
title: "标签",
key: "tag",
width: 100,
},
{
title: "类型",
key: "contest_type",
width: 100,
render: (row) => h(ContestType, { contest: row, size: "small" }),
},
{
title: "状态",
key: "status",
width: 100,
render: (row) =>
h(
NTag,
{ type: CONTEST_STATUS[row.status]["type"], size: "small" },
() => CONTEST_STATUS[row.status]["name"],
),
},
{
title: "创建者",
key: "created_by",
width: 120,
render: (row) => row.created_by.username,
},
{
title: "创建时间",
key: "create_time",
width: 160,
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm"),
},
{
title: "可见",
key: "visible",
width: 100,
render: (row) =>
h(NSwitch, {
value: row.visible,
size: "small",
rubberBand: false,
onUpdateValue: () => toggleVisible(row),
}),
},
{
title: "选项",
key: "actions",
width: 300,
render: (row) => h(Actions, { contest: row }),
},
]
async function listContests() {
const offset = (query.page - 1) * query.limit
const res = await getContestList(offset, query.limit, query.keyword)
contests.value = res.data.results
total.value = res.data.total
}
onMounted(listContests)
watch(() => [query.page, query.limit], listContests)
watchDebounced(() => query.keyword, listContests, {
debounce: 500,
maxWait: 1000,
})
</script>
<template>
<n-flex justify="space-between" class="titleWrapper">
<n-flex align="center">
<h2 class="title">比赛列表</h2>
<n-button
type="primary"
@click="$router.push({ name: 'admin contest create' })"
>
新建
</n-button>
</n-flex>
<div>
<n-input v-model:value="query.keyword" placeholder="输入标题关键字" />
</div>
</n-flex>
<n-data-table :columns="columns" :data="contests" />
<Pagination
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
</template>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
}
.title {
margin: 0;
}
</style>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import { getStuckProblems } from "admin/api"
interface StuckProblem {
problem_id: string
problem_title: string
total: number
failed: number
failed_users: number
ac_rate: number
}
const loading = ref(true)
const data = ref<StuckProblem[]>([])
const columns: DataTableColumn<StuckProblem>[] = [
{ title: "题目 ID", key: "problem_id", width: 100 },
{ title: "题目名称", key: "problem_title", minWidth: 200 },
{ title: "总提交", key: "total", width: 100, sorter: "default" },
{ title: "失败次数", key: "failed", width: 100, sorter: "default" },
{
title: "卡住学生数",
key: "failed_users",
width: 120,
sorter: "default",
defaultSortOrder: "descend",
},
{
title: "AC 率",
key: "ac_rate",
width: 100,
sorter: "default",
render: (row) => `${row.ac_rate}%`,
},
]
onMounted(async () => {
try {
const res = await getStuckProblems()
data.value = res.data
} finally {
loading.value = false
}
})
</script>
<template>
<h2 style="margin-top: 0">学生卡点分析只分析前40道题目</h2>
<n-data-table
:loading="loading"
:columns="columns"
:data="data"
striped
:pagination="{ pageSize: 20 }"
/>
</template>

View File

@@ -0,0 +1,202 @@
<script setup lang="ts">
import { Line } from "vue-chartjs"
import {
Chart as ChartJS,
CategoryScale,
Filler,
LinearScale,
LineElement,
PointElement,
Title,
Tooltip,
} from "chart.js"
import { getTopACTrend } from "admin/api"
ChartJS.register(
CategoryScale,
Filler,
LinearScale,
LineElement,
PointElement,
Title,
Tooltip,
)
interface YearlyEntry {
year: number
total: number
accepted: number
ac_rate: number
}
interface ProblemTrend {
problem_id: string
problem_title: string
yearly: YearlyEntry[]
}
const currentYear = new Date().getFullYear()
const yearOptions = Array.from({ length: currentYear - 2022 + 1 }, (_, i) => ({
label: String(2022 + i),
value: 2022 + i,
}))
const minPerYearOptions = [
{ label: "50", value: 50 },
{ label: "100", value: 100 },
{ label: "200", value: 200 },
]
const sinceYear = ref(2023)
const untilYear = ref(new Date().getFullYear() - 1)
const minPerYear = ref(100)
const loading = ref(false)
const data = ref<ProblemTrend[]>([])
const acLabelPlugin = {
id: "acLabel",
afterDatasetsDraw(chart: any) {
const ctx = chart.ctx
chart.data.datasets.forEach((_: any, i: number) => {
const meta = chart.getDatasetMeta(i)
meta.data.forEach((point: any, j: number) => {
const value = chart.data.datasets[i].data[j]
if (value === null || value === undefined) return
ctx.save()
ctx.font = "bold 11px sans-serif"
ctx.fillStyle = "rgba(99, 179, 237, 1)"
ctx.textAlign = "center"
ctx.textBaseline = "bottom"
ctx.fillText(`${value}%`, point.x, point.y - 6)
ctx.restore()
})
})
},
}
function getChartData(problem: ProblemTrend) {
return {
labels: problem.yearly.map((y) => String(y.year)),
datasets: [
{
label: "AC 率",
data: problem.yearly.map((y) => y.ac_rate),
fill: true,
tension: 0.3,
backgroundColor: "rgba(99, 179, 237, 0.2)",
borderColor: "rgba(99, 179, 237, 1)",
pointBackgroundColor: "rgba(99, 179, 237, 1)",
pointRadius: 4,
},
],
}
}
function getChartOptions(problem: ProblemTrend) {
return {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: `${problem.problem_id} · ${problem.problem_title}`,
font: { size: 14 },
},
tooltip: {
callbacks: {
label: (ctx: any) => {
const entry = problem.yearly[ctx.dataIndex]
return `AC 率: ${entry.ac_rate}% (${entry.accepted}/${entry.total})`
},
},
},
},
scales: {
y: {
min: 0,
max: 100,
ticks: { callback: (v: any) => `${v}%` },
},
x: {
title: { display: true, text: "年份" },
},
},
}
}
async function fetchData() {
loading.value = true
try {
const res = await getTopACTrend({
since_year: sinceYear.value,
until_year: untilYear.value,
min_per_year: minPerYear.value,
})
data.value = res.data
} finally {
loading.value = false
}
}
onMounted(fetchData)
</script>
<template>
<h2 style="margin-top: 0">年度趋势</h2>
<n-space align="center" style="margin-bottom: 16px">
<span>年份范围</span>
<n-select
v-model:value="sinceYear"
:options="yearOptions"
style="width: 100px"
@update:value="fetchData"
/>
<span></span>
<n-select
v-model:value="untilYear"
:options="yearOptions"
style="width: 100px"
@update:value="fetchData"
/>
<span>年提交下限</span>
<n-select
v-model:value="minPerYear"
:options="minPerYearOptions"
style="width: 90px"
@update:value="fetchData"
/>
<n-tag type="info" size="small"> {{ data.length }} </n-tag>
</n-space>
<n-spin :show="loading">
<div
v-if="!loading && data.length === 0"
style="text-align: center; padding: 40px"
>
暂无数据
</div>
<div v-else class="grid">
<div v-for="problem in data" :key="problem.problem_id" class="chart-card">
<Line
:data="getChartData(problem)"
:options="getChartOptions(problem)"
:plugins="[acLabelPlugin]"
/>
</div>
</div>
</n-spin>
</template>
<style scoped>
.grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
padding: 8px 0;
}
.chart-card {
height: 260px;
border-radius: 8px;
padding: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
</style>

View File

@@ -0,0 +1,160 @@
<script lang="ts" setup>
import {
deleteContestProblem,
deleteProblem,
makeProblemPublic,
} from "admin/api"
import download from "utils/download"
interface Props {
problemID: number
problemDisplayID: string
}
const props = defineProps<Props>()
const emit = defineEmits(["updated"])
const route = useRoute()
const router = useRouter()
const message = useMessage()
const isContestProblem = computed(
() => route.name === "admin contest problem list",
)
const showMakePublicModal = ref(false)
const newDisplayID = ref("")
async function handleDeleteProblem() {
try {
if (route.name === "admin contest problem list") {
await deleteContestProblem(props.problemID)
} else {
await deleteProblem(props.problemID)
}
message.success("删除成功")
emit("updated")
} catch (err: any) {
if (err.data === "Can't delete the problem as it has submissions") {
message.error("这道题有提交之后,就不能被删除")
} else {
message.error("删除失败")
}
}
}
function downloads() {
download("test_case?problem_id=" + props.problemID)
}
function goEdit() {
const name = route.name!.toString().replace("list", "edit")
router.push({ name, params: { problemID: props.problemID } })
}
function goCheck() {
let data = router.resolve("/problem/" + props.problemDisplayID)
if (route.name === "admin contest problem list") {
data = router.resolve({
name: "contest problem",
params: {
contestID: route.params.contestID,
problemID: props.problemDisplayID,
},
})
}
window.open(data.href, "_blank")
}
function openMakePublicModal() {
newDisplayID.value = ""
showMakePublicModal.value = true
}
async function handleMakePublic() {
if (!newDisplayID.value.trim()) {
message.error("请输入新的题目编号")
return
}
try {
await makeProblemPublic(props.problemID, newDisplayID.value.trim())
message.success("已成功转为公开题目(需要手动设置可见)")
showMakePublicModal.value = false
emit("updated") // 刷新列表
} catch (err: any) {
if (err.data === "Duplicate display ID") {
message.error("该题目编号已存在,请使用其他编号")
} else if (err.data === "Already be a public problem") {
message.error("该题目已经是公开题目")
} else {
message.error("转换失败:" + (err.data || "未知错误"))
}
}
}
</script>
<template>
<n-flex>
<n-button size="small" secondary type="primary" @click="goEdit">
编辑
</n-button>
<n-button size="small" secondary type="info" @click="goCheck">
查看
</n-button>
<n-tooltip v-if="isContestProblem">
<template #trigger>
<n-button
size="small"
secondary
type="warning"
@click="openMakePublicModal"
>
公开
</n-button>
</template>
将此竞赛题目转为公开题目
</n-tooltip>
<n-popconfirm @positive-click="handleDeleteProblem">
<template #trigger>
<n-button secondary size="small" type="error">删除</n-button>
</template>
确定删除这道题目吗相关的提交也会被相应删除哦 😯
</n-popconfirm>
<n-tooltip>
<template #trigger>
<n-button size="small" secondary @click="downloads">下载</n-button>
</template>
下载测试用例
</n-tooltip>
</n-flex>
<n-modal
v-model:show="showMakePublicModal"
preset="card"
title="转为公开题目"
style="width: 500px"
>
<n-space vertical>
<p>
将竞赛题目转为公开题目后会创建一个新的公开题目副本原题目保持不变
</p>
<n-form>
<n-form-item label="新的题目编号" required>
<n-input
v-model:value="newDisplayID"
placeholder="例如: 1001"
clearable
@keyup.enter="handleMakePublic"
/>
</n-form-item>
</n-form>
<n-alert type="info" title="提示:请输入一个未被使用的题目编号">
</n-alert>
</n-space>
<template #footer>
<n-flex justify="end">
<n-button @click="showMakePublicModal = false">取消</n-button>
<n-button type="primary" @click="handleMakePublic">确认</n-button>
</n-flex>
</template>
</n-modal>
</template>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import { addProblemForContest } from "admin/api"
interface Props {
problemID: number
contestID: string
nextDisplayId?: string
}
const props = defineProps<Props>()
const emit = defineEmits(["added"])
const message = useMessage()
const displayID = ref(props.nextDisplayId || "")
async function addProblem() {
if (!displayID.value) return
try {
await addProblemForContest(
props.contestID,
props.problemID,
displayID.value,
)
emit("added")
} catch (err: any) {
if (err.data === "Duplicate display id in this contest") {
message.error("显示编号重复了,请重新写一个")
} else if (err.data === "Contest has ended") {
message.error("这场比赛已经结束了,不能添加题目")
} else {
message.error(err.data)
}
}
}
</script>
<template>
<n-popconfirm :show-icon="false" @positive-click="addProblem">
<template #trigger>
<n-button secondary size="small" type="primary">添加</n-button>
</template>
<n-flex vertical>
<span>请输入在这场比赛中的显示编号</span>
<n-input autofocus v-model:value="displayID" />
</n-flex>
</n-popconfirm>
</template>
<style scoped></style>

View File

@@ -0,0 +1,397 @@
<script setup lang="ts">
import type { LANGUAGE } from "utils/types"
interface AstRule {
engine: string
target?: string
label?: string
exact?: number
min?: number
max?: number
message: string
}
interface Props {
modelValue: { [key: string]: AstRule[] } | null
languages: LANGUAGE[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: "update:modelValue", value: { [key: string]: AstRule[] } | null): void
}>()
const activeTab = ref(props.languages[0] || "Python3")
const ENGINE_OPTIONS: SelectOption[] = [
{
label: "节点检查",
type: "group",
key: "node_group",
children: [
{ label: "必须存在", value: "must_exist_node" },
{ label: "不能存在", value: "must_not_exist_node" },
{ label: "出现次数", value: "count_node" },
],
},
{
label: "函数调用",
type: "group",
key: "func_group",
children: [
{ label: "必须调用函数", value: "must_call_function" },
{ label: "不能调用函数", value: "must_not_call_function" },
{ label: "函数调用次数", value: "count_function_call" },
],
},
{
label: "方法调用",
type: "group",
key: "method_group",
children: [
{ label: "必须调用方法", value: "must_call_method" },
{ label: "不能调用方法", value: "must_not_call_method" },
],
},
{
label: "运算符",
type: "group",
key: "op_group",
children: [{ label: "必须使用运算符", value: "must_use_operator" }],
},
]
const NODE_TARGET_OPTIONS: SelectOption[] = [
{ label: "for 循环", value: "for_loop" },
{ label: "while 循环", value: "while_loop" },
{ label: "if 条件", value: "if_statement" },
{ label: "else 子句", value: "else_clause" },
{ label: "函数定义", value: "function_definition" },
{ label: "return 语句", value: "return" },
{ label: "break 语句", value: "break" },
{ label: "continue 语句", value: "continue" },
{ label: "列表推导式", value: "list_comprehension" },
{ label: "列表", value: "list_literal" },
{ label: "字典", value: "dict_literal" },
{ label: "集合", value: "set_literal" },
{ label: "f-string", value: "f_string" },
{ label: "try-except", value: "try_except" },
{ label: "类定义", value: "class_definition" },
]
const OPERATOR_TARGET_OPTIONS: SelectOption[] = [
{ label: "+", value: "+" },
{ label: "-", value: "-" },
{ label: "*", value: "*" },
{ label: "/", value: "/" },
{ label: "//", value: "//" },
{ label: "%", value: "%" },
{ label: "**", value: "**" },
{ label: "+=", value: "+=" },
{ label: "-=", value: "-=" },
{ label: "==", value: "==" },
{ label: "!=", value: "!=" },
{ label: ">", value: ">" },
{ label: ">=", value: ">=" },
{ label: "<", value: "<" },
{ label: "<=", value: "<=" },
{ label: "and / &&", value: "and" },
{ label: "or / ||", value: "or" },
{ label: "not / !", value: "not" },
]
const NODE_ENGINES = ["must_exist_node", "must_not_exist_node", "count_node"]
const FUNCTION_ENGINES = [
"must_call_function",
"must_not_call_function",
"count_function_call",
]
const METHOD_ENGINES = ["must_call_method", "must_not_call_method"]
const OPERATOR_ENGINES = ["must_use_operator"]
const COUNT_ENGINES = ["count_node", "count_function_call"]
function isNodeEngine(engine: string) {
return NODE_ENGINES.includes(engine)
}
function isFunctionEngine(engine: string) {
return FUNCTION_ENGINES.includes(engine)
}
function isMethodEngine(engine: string) {
return METHOD_ENGINES.includes(engine)
}
function isOperatorEngine(engine: string) {
return OPERATOR_ENGINES.includes(engine)
}
function isCountEngine(engine: string) {
return COUNT_ENGINES.includes(engine)
}
const COUNT_MODE_OPTIONS: SelectOption[] = [
{ label: "精确", value: "exact" },
{ label: "范围", value: "range" },
]
function getCountMode(rule: AstRule): "exact" | "range" {
return rule.exact !== undefined ? "exact" : "range"
}
function updateCountMode(lang: string, index: number, mode: "exact" | "range") {
const rules = [...getRulesForLang(lang)]
const rule = { ...rules[index] }
if (mode === "exact") {
rule.exact = rule.min ?? 1
delete rule.min
delete rule.max
} else {
delete rule.exact
}
rules[index] = rule
updateRules(lang, rules)
}
function updateExactCount(lang: string, index: number, v: number | null) {
const rules = [...getRulesForLang(lang)]
const rule = { ...rules[index] }
if (v === null) delete rule.exact
else rule.exact = v
rules[index] = rule
updateRules(lang, rules)
}
function needsTargetDropdown(engine: string) {
return isNodeEngine(engine)
}
function needsTargetInput(engine: string) {
return isFunctionEngine(engine) || isMethodEngine(engine)
}
function needsOperatorDropdown(engine: string) {
return isOperatorEngine(engine)
}
function getRulesForLang(lang: string): AstRule[] {
if (!props.modelValue) return []
return props.modelValue[lang] || []
}
function updateRules(lang: string, rules: AstRule[]) {
const current = { ...(props.modelValue || {}) }
if (rules.length === 0) {
delete current[lang]
} else {
current[lang] = rules
}
emit("update:modelValue", Object.keys(current).length > 0 ? current : null)
}
function getTargetLabel(engine: string, target: string): string | undefined {
if (isNodeEngine(engine))
return (NODE_TARGET_OPTIONS.find((o) => o.value === target) as any)?.label
if (isOperatorEngine(engine))
return (OPERATOR_TARGET_OPTIONS.find((o) => o.value === target) as any)
?.label
return undefined
}
function addRule(lang: string) {
const rules = [...getRulesForLang(lang)]
rules.push({
engine: "must_exist_node",
target: "for_loop",
label: "for 循环",
message: "",
})
updateRules(lang, rules)
}
function removeRule(lang: string, index: number) {
const rules = [...getRulesForLang(lang)]
rules.splice(index, 1)
updateRules(lang, rules)
}
function updateRule(lang: string, index: number, field: string, value: any) {
const rules = [...getRulesForLang(lang)]
const rule = { ...rules[index] }
if (field === "engine") {
rule.engine = value
if (isNodeEngine(value)) {
rule.target = "for_loop"
rule.label = "for 循环"
} else if (isOperatorEngine(value)) {
rule.target = "+"
rule.label = "+"
} else {
rule.target = ""
delete rule.label
}
delete rule.min
delete rule.max
delete rule.exact
} else if (field === "target") {
rule.target = value
const lbl = getTargetLabel(rule.engine, value)
if (lbl) rule.label = lbl
else delete rule.label
} else if (field === "min") {
if (value === null || value === undefined) delete rule.min
else rule.min = value
} else if (field === "max") {
if (value === null || value === undefined) delete rule.max
else rule.max = value
} else if (field === "message") {
rule.message = value
}
rules[index] = rule
updateRules(lang, rules)
}
watch(
() => props.languages,
(langs) => {
if (langs.length && !langs.includes(activeTab.value as LANGUAGE)) {
activeTab.value = langs[0]
}
},
)
</script>
<template>
<n-collapse>
<n-collapse-item title="代码规则检查(选填)" name="ast-rules">
<n-tabs v-if="languages.length" type="segment" v-model:value="activeTab">
<n-tab-pane
v-for="lang in languages"
:key="lang"
:name="lang"
:tab="lang"
>
<n-flex vertical>
<div
v-for="(rule, index) in getRulesForLang(lang)"
:key="index"
style="margin-bottom: 8px"
>
<n-flex align="center" :wrap="false">
<n-select
:options="ENGINE_OPTIONS"
:value="rule.engine"
@update:value="
(v: string) => updateRule(lang, index, 'engine', v)
"
style="width: 150px"
size="small"
/>
<n-select
v-if="needsTargetDropdown(rule.engine)"
:options="NODE_TARGET_OPTIONS"
:value="rule.target"
@update:value="
(v: string) => updateRule(lang, index, 'target', v)
"
style="width: 150px"
size="small"
filterable
/>
<n-input
v-if="needsTargetInput(rule.engine)"
:value="rule.target"
@update:value="
(v: string) => updateRule(lang, index, 'target', v)
"
placeholder="函数/方法名"
style="width: 150px"
size="small"
/>
<n-select
v-if="needsOperatorDropdown(rule.engine)"
:options="OPERATOR_TARGET_OPTIONS"
:value="rule.target"
@update:value="
(v: string) => updateRule(lang, index, 'target', v)
"
style="width: 150px"
size="small"
/>
<template v-if="isCountEngine(rule.engine)">
<n-select
:options="COUNT_MODE_OPTIONS"
:value="getCountMode(rule)"
@update:value="
(v: 'exact' | 'range') => updateCountMode(lang, index, v)
"
style="width: 80px"
size="small"
/>
<n-input-number
v-if="getCountMode(rule) === 'exact'"
:value="rule.exact ?? null"
@update:value="
(v: number | null) => updateExactCount(lang, index, v)
"
placeholder="次数"
style="width: 100px"
size="small"
:min="1"
clearable
/>
<template v-else>
<n-input-number
:value="rule.min ?? null"
@update:value="
(v: number | null) => updateRule(lang, index, 'min', v)
"
placeholder="最少"
style="width: 100px"
size="small"
:min="0"
clearable
/>
<n-input-number
:value="rule.max ?? null"
@update:value="
(v: number | null) => updateRule(lang, index, 'max', v)
"
placeholder="最多"
style="width: 100px"
size="small"
:min="0"
clearable
/>
</template>
</template>
<n-input
:value="rule.message"
@update:value="
(v: string) => updateRule(lang, index, 'message', v)
"
placeholder="错误提示(选填)"
style="flex: 1"
size="small"
/>
<n-button
size="small"
tertiary
type="error"
@click="removeRule(lang, index)"
>
删除
</n-button>
</n-flex>
</div>
<n-button
size="small"
tertiary
type="primary"
@click="addRule(lang)"
>
添加规则
</n-button>
</n-flex>
</n-tab-pane>
</n-tabs>
<n-empty v-else description="请先选择编程语言" />
</n-collapse-item>
</n-collapse>
</template>

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
import type { AdminTag } from "utils/types"
import { batchTagProblems, getTagAdminList } from "admin/api"
interface Props {
show: boolean
problemIds: number[]
action: "add" | "remove"
}
const props = defineProps<Props>()
const emit = defineEmits<{
"update:show": [value: boolean]
done: []
}>()
const message = useMessage()
const tags = ref<AdminTag[]>([])
const selected = ref<string[]>([])
const newTags = ref<string[]>([])
const title = computed(() =>
props.action === "add" ? "批量添加标签" : "批量移除标签",
)
const selectedSet = computed(() => new Set(selected.value))
const names = computed(() =>
props.action === "add"
? Array.from(new Set([...selected.value, ...newTags.value]))
: selected.value,
)
function toggleTag(name: string) {
const set = new Set(selected.value)
if (set.has(name)) set.delete(name)
else set.add(name)
selected.value = Array.from(set)
}
async function listTags() {
const res = await getTagAdminList()
tags.value = res.data
}
function close() {
emit("update:show", false)
}
async function submit() {
if (!names.value.length) {
message.error("请先选择标签")
return
}
const res = await batchTagProblems(
props.problemIds,
names.value,
props.action,
)
const verb = props.action === "add" ? "添加" : "移除"
message.success(
`已为 ${res.data.problem_count} 道题${verb} ${res.data.tag_count} 个标签`,
)
close()
emit("done")
}
watch(
() => props.show,
(show) => {
if (!show) return
selected.value = []
newTags.value = []
listTags()
},
)
</script>
<template>
<n-modal
:show="show"
preset="card"
:title="title"
style="width: 600px"
:mask-closable="false"
@close="close"
>
<n-flex vertical size="large">
<div>已选中 {{ problemIds.length }} 道题目</div>
<n-flex size="small">
<n-tag
v-for="tag in tags"
:key="tag.id"
checkable
:checked="selectedSet.has(tag.name)"
@update:checked="toggleTag(tag.name)"
>
{{ tag.name }}{{ tag.problem_count }}
</n-tag>
</n-flex>
<n-dynamic-tags v-if="action === 'add'" v-model:value="newTags" />
<n-flex justify="end">
<n-button @click="close">取消</n-button>
<n-button type="primary" @click="submit">确定</n-button>
</n-flex>
</n-flex>
</n-modal>
</template>

View File

@@ -0,0 +1,94 @@
<script lang="ts" setup>
import { getProblemList } from "admin/api"
import Pagination from "shared/components/Pagination.vue"
import type { AdminProblemFiltered } from "utils/types"
import AddButton from "./AddButton.vue"
interface Props {
show: boolean
count: number
nextDisplayId?: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: "update:show", value: boolean): void
(e: "change"): void
}>()
const route = useRoute()
const query = reactive({
page: 1,
limit: 10,
keyword: "",
})
const total = ref(0)
const problems = shallowRef<AdminProblemFiltered[]>([])
const columns: DataTableColumn<AdminProblemFiltered>[] = [
{ title: "编号", key: "_id", width: 80 },
{ title: "标题", key: "title" },
{
title: "选项",
key: "add",
render: (row) =>
h(AddButton, {
problemID: row.id,
contestID: route.params.contestID as string,
nextDisplayId: props.nextDisplayId,
onAdded: () => emit("change"),
}),
width: 60,
},
]
async function getList() {
const offset = (query.page - 1) * query.limit
const res = await getProblemList(offset, query.limit, query.keyword, "", "")
total.value = res.total
problems.value = res.results
}
watch(
() => props.show,
(value) => {
if (value) getList()
},
)
watch(() => [query.limit, query.page], getList)
watchDebounced(
() => query.keyword,
() => {
query.page = 1
getList()
},
{ debounce: 500, maxWait: 1000 },
)
</script>
<template>
<n-modal
:mask-closable="false"
:show="props.show"
preset="card"
style="width: 600px"
title="从题库中添加"
@close="$emit('update:show', false)"
>
<n-input
class="search"
v-model:value="query.keyword"
clearable
placeholder="搜索标题或编号"
/>
<n-data-table striped :columns="columns" :data="problems" />
<Pagination
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
</n-modal>
</template>
<style scoped>
.search {
margin-bottom: 20px;
}
</style>

View File

@@ -0,0 +1,345 @@
<script setup lang="ts">
import type { LANGUAGE, SQLDisplay, Testcase } from "utils/types"
import { createZipBlob } from "utils/functions"
import SQLDataTable from "oj/problem/components/SQLDataTable.vue"
import {
generateSQLTestcase,
getSQLTestcaseScripts,
previewSQLTestcase,
uploadTestcases,
} from "../../api"
interface ScriptEntry {
id: number
sql: string
display: SQLDisplay | null
error: string
// 标准答案或题型改过之后,旧预览结果作废,需重新预览才能上传
stale: boolean
}
interface Props {
answers: { language: LANGUAGE; code: string }[]
mode: "query" | "modify"
problemId?: number
}
const props = defineProps<Props>()
const emit = defineEmits<{
uploaded: [testCaseId: string, testCaseScore: Testcase[]]
}>()
const message = useMessage()
let nextId = 0
function blankEntry(): ScriptEntry {
return { id: nextId++, sql: "", display: null, error: "", stale: false }
}
const scripts = ref<ScriptEntry[]>([blankEntry(), blankEntry(), blankEntry()])
const refSQL = computed(
() =>
props.answers.find((a) => a.language === "SQL" && a.code.trim())?.code ??
"",
)
const isPreviewing = ref(false)
const isUploading = ref(false)
const isGenerating = ref(false)
const hasAnyScript = computed(() => scripts.value.some((s) => s.sql.trim()))
const hasBlankScript = computed(() => scripts.value.some((s) => !s.sql.trim()))
const filledCount = computed(
() => scripts.value.filter((s) => s.sql.trim()).length,
)
const canUpload = computed(() => {
const filled = scripts.value.filter((s) => s.sql.trim())
return (
!isPreviewing.value &&
// 至少 2 个数据不同的测试点,防止学生对照题目页的期望结果硬编码
filled.length >= 2 &&
filled.every((s) => s.display && !s.error && !s.stale)
)
})
watch([refSQL, () => props.mode], () => {
for (const s of scripts.value) {
if (s.display || s.error) s.stale = true
}
})
// 编辑已有 SQL 题时回显已上传的脚本;新题或旧格式测试点则保持空白
onMounted(async () => {
if (!props.problemId) return
try {
const res = await getSQLTestcaseScripts(props.problemId)
if (res.data.length) {
scripts.value = res.data.map((f) => ({ ...blankEntry(), sql: f.content }))
}
} catch {}
})
function add() {
scripts.value.push(blankEntry())
}
function remove(index: number) {
scripts.value.splice(index, 1)
}
function reset() {
scripts.value = [blankEntry(), blankEntry(), blankEntry()]
}
function expectedQuery(d: SQLDisplay) {
return "columns" in d.expected ? d.expected : null
}
function changedTables(d: SQLDisplay) {
return "changed_tables" in d.expected ? d.expected.changed_tables : []
}
async function generate() {
const blanks = scripts.value.filter((s) => !s.sql.trim())
if (!blanks.length) return
isGenerating.value = true
await Promise.all(
blanks.map(async (s) => {
try {
const res = await generateSQLTestcase({
ref_sql: refSQL.value,
mode: props.mode,
})
s.sql = res.data.sql
} catch (err) {
const data = (err as { data?: unknown })?.data
message.error(typeof data === "string" ? data : "AI 生成失败")
}
}),
)
isGenerating.value = false
await preview()
}
async function preview() {
// 丢弃空脚本
scripts.value = scripts.value.filter((s) => s.sql.trim())
if (!scripts.value.length) {
scripts.value = [blankEntry()]
return
}
isPreviewing.value = true
await Promise.all(
scripts.value.map(async (s) => {
s.display = null
s.error = ""
s.stale = false
try {
const res = await previewSQLTestcase({
init_sql: s.sql,
ref_sql: refSQL.value,
mode: props.mode,
})
s.display = res.data
} catch (err) {
const data = (err as { data?: unknown })?.data
s.error = typeof data === "string" ? data : "预览失败"
}
}),
)
isPreviewing.value = false
}
async function upload() {
isUploading.value = true
try {
const data = scripts.value
.filter((s) => s.sql.trim())
.map((s, i) => ({
name: `${i + 1}.sql`,
content: s.sql,
}))
const blob = createZipBlob(data)
const file = new File([blob], "testcase.zip", { type: "application/zip" })
const res = await uploadTestcases(file, { sql: true })
const testcases: Testcase[] = res.data.info
const baseScore = Math.floor(100 / testcases.length)
const remainder = 100 - baseScore * testcases.length
testcases.forEach((tc, i) => {
tc.score = String(
i === testcases.length - 1 ? baseScore + remainder : baseScore,
)
})
emit("uploaded", res.data.id, testcases)
message.success("上传成功")
} catch {
message.error("上传失败")
} finally {
isUploading.value = false
}
}
</script>
<template>
<n-flex vertical>
<n-alert
v-if="!refSQL"
type="warning"
:show-icon="false"
style="margin-bottom: 8px"
>
还没有填写 SQL 标准答案请先在上方"本题参考答案"中填写再来编写测试点
</n-alert>
<n-flex align="center" wrap>
<n-button :disabled="isPreviewing || isGenerating" @click="reset">
清空
</n-button>
<n-button :disabled="isPreviewing || isGenerating" @click="add">
+1
</n-button>
<n-tooltip :disabled="!!refSQL && hasBlankScript">
<template #trigger>
<span>
<n-button
:loading="isGenerating"
:disabled="!refSQL || !hasBlankScript || isPreviewing"
@click="generate"
>
AI 生成
</n-button>
</span>
</template>
{{ !refSQL ? "请先填写 SQL 标准答案" : "所有脚本都写好了,无需生成" }}
</n-tooltip>
<n-tooltip :disabled="!!refSQL && hasAnyScript">
<template #trigger>
<span>
<n-button
type="success"
:loading="isPreviewing"
:disabled="!refSQL || !hasAnyScript || isGenerating"
@click="preview"
>
预览验证
</n-button>
</span>
</template>
{{ !refSQL ? "请先填写 SQL 标准答案" : "请先填写数据脚本" }}
</n-tooltip>
<n-tooltip :disabled="canUpload || isPreviewing">
<template #trigger>
<span>
<n-button
type="primary"
:loading="isUploading"
:disabled="!canUpload || isGenerating"
@click="upload"
>
上传
</n-button>
</span>
</template>
{{
filledCount < 2
? "SQL 题至少需要 2 个数据不同的测试点,防止硬编码期望结果"
: "所有脚本预览验证通过后才能上传"
}}
</n-tooltip>
</n-flex>
<div v-for="(s, index) in scripts" :key="s.id" class="scriptBox">
<n-flex justify="space-between" align="center">
<strong>{{ index + 1 }}.sql</strong>
<n-button
size="small"
:disabled="scripts.length === 1 || isPreviewing || isGenerating"
@click="remove(index)"
>
删除
</n-button>
</n-flex>
<n-input
type="textarea"
v-model:value="s.sql"
:rows="8"
placeholder="-- 本测试点的建表 + 插入数据脚本
CREATE TABLE ...;
INSERT INTO ...;"
:status="
s.error ? 'error' : s.display && !s.stale ? 'success' : undefined
"
/>
<n-alert v-if="s.error" type="error" :show-icon="false">
{{ s.error }}
</n-alert>
<template v-if="s.display">
<n-alert v-if="s.stale" type="warning" :show-icon="false">
标准答案或题型已修改以下预览已过期请重新预览
</n-alert>
<div :class="{ stalePreview: s.stale }">
<p class="previewTitle">数据表</p>
<div v-for="t in s.display.tables" :key="t.name">
<p class="sqlTableName">{{ t.name }}</p>
<SQLDataTable
:columns="t.columns"
:rows="t.rows"
:total-rows="t.total_rows"
:truncated="t.truncated"
/>
</div>
<p class="previewTitle">期望结果</p>
<SQLDataTable
v-if="expectedQuery(s.display)"
:columns="expectedQuery(s.display)!.columns"
:rows="expectedQuery(s.display)!.rows"
:total-rows="expectedQuery(s.display)!.total_rows"
:truncated="expectedQuery(s.display)!.truncated"
/>
<div v-for="t in changedTables(s.display)" :key="t.name">
<p class="sqlTableName">
{{ t.dropped ? `${t.name} 表已被删除` : `执行后的 ${t.name}` }}
</p>
<SQLDataTable
v-if="!t.dropped"
:columns="t.columns"
:rows="t.rows"
:total-rows="t.total_rows"
:truncated="t.truncated"
/>
</div>
</div>
</template>
</div>
</n-flex>
</template>
<style scoped>
.scriptBox {
border: 1px solid var(--n-border-color, rgba(128, 128, 128, 0.2));
border-radius: 6px;
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.previewTitle {
font-weight: bold;
margin: 4px 0;
}
.sqlTableName {
font-weight: 500;
margin: 4px 0;
opacity: 0.85;
}
.stalePreview {
opacity: 0.45;
}
</style>

View File

@@ -0,0 +1,147 @@
<script setup lang="ts">
import { NButton, NTag } from "naive-ui"
import Pagination from "shared/components/Pagination.vue"
import type { AdminProblemFiltered } from "utils/types"
import { batchTagProblems, getProblemList } from "admin/api"
interface Props {
show: boolean
tagId: number
tagName: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
"update:show": [value: boolean]
changed: []
}>()
const router = useRouter()
const message = useMessage()
const problems = ref<AdminProblemFiltered[]>([])
const total = ref(0)
const page = ref(1)
const limit = ref(10)
const keyword = ref("")
const columns: DataTableColumn<AdminProblemFiltered>[] = [
{ title: "显示编号", key: "_id", width: 100 },
{
title: "标题",
key: "title",
minWidth: 200,
render: (row) =>
h(
NButton,
{ text: true, type: "primary", onClick: () => goEdit(row) },
() => row.title,
),
},
{
title: "可见",
key: "visible",
width: 80,
render: (row) =>
h(
NTag,
{ size: "small", type: row.visible ? "success" : "default" },
() => (row.visible ? "公开" : "隐藏"),
),
},
{
title: "选项",
key: "actions",
width: 110,
render: (row) =>
h(
NButton,
{ size: "small", type: "error", onClick: () => removeTag(row) },
() => "移除标签",
),
},
]
async function listProblems() {
if (page.value < 1) page.value = 1
const offset = (page.value - 1) * limit.value
const res = await getProblemList(
offset,
limit.value,
keyword.value,
"",
undefined,
props.tagId,
)
problems.value = res.results
total.value = res.total
}
function close() {
emit("update:show", false)
}
function goEdit(row: AdminProblemFiltered) {
close()
router.push({ name: "admin problem edit", params: { problemID: row.id } })
}
async function removeTag(row: AdminProblemFiltered) {
await batchTagProblems([row.id], [props.tagName], "remove")
message.success(`已移除「${row.title}」的标签`)
emit("changed")
// 移掉本页最后一条时退回上一页,交给下面的 watcher 重新拉取
if (problems.value.length === 1 && page.value > 1) {
page.value -= 1
} else {
listProblems()
}
}
// 改搜索词就回到第一页
watch(keyword, () => (page.value = 1))
// 每次打开弹窗重置状态,拉取交给下面的 watcher
watch(
() => props.show,
(show) => {
if (!show) return
page.value = 1
keyword.value = ""
},
)
// 打开 / 翻页 / 改每页条数 / 改搜索词都走这里,防抖把同一批变更合并成一次请求
watchDebounced(
() => [props.show, props.tagId, page.value, limit.value, keyword.value],
() => {
if (!props.show) return
listProblems()
},
{ debounce: 300, maxWait: 800 },
)
</script>
<template>
<n-modal
:show="show"
preset="card"
:title="`标签「${tagName}」下的题目`"
style="width: 720px"
@close="close"
>
<n-flex vertical size="large">
<n-flex justify="space-between" align="center">
<span> {{ total }} 道题</span>
<n-input
v-model:value="keyword"
style="width: 220px"
placeholder="输入标题关键字"
clearable
/>
</n-flex>
<n-data-table striped :columns="columns" :data="problems" />
<Pagination :total="total" v-model:limit="limit" v-model:page="page" />
</n-flex>
</n-modal>
</template>

View File

@@ -0,0 +1,263 @@
<script setup lang="ts">
import type { LANGUAGE, Testcase } from "utils/types"
import { createZipBlob } from "utils/functions"
import { createTestSubmission } from "utils/judge"
import { uploadTestcases } from "../../api"
interface FileEntry {
id: number
in: string
out: string
error: boolean
}
interface Props {
answers: { language: LANGUAGE; code: string }[]
samples?: { input: string; output: string }[]
}
const props = defineProps<Props>()
const emit = defineEmits<{
uploaded: [testCaseId: string, testCaseScore: Testcase[]]
}>()
const message = useMessage()
let nextId = 0
function makeInitialFiles(): FileEntry[] {
const fromSamples = (props.samples ?? []).map((s) => ({
id: nextId++,
in: s.input,
out: s.output,
error: false,
}))
const total = Math.ceil(Math.max(fromSamples.length, 1) / 5) * 5
const extra = total - fromSamples.length
return [
...fromSamples,
...Array.from({ length: extra }, () => ({
id: nextId++,
in: "",
out: "",
error: false,
})),
]
}
const files = ref<FileEntry[]>(makeInitialFiles())
const selectedLanguage = ref<LANGUAGE>("Python3")
// 始终显示所有语言,不管有没有答案代码
const availableLanguages = computed(() =>
props.answers.map((a) => ({ label: a.language, value: a.language })),
)
const hasAnyAnswerCode = computed(() =>
props.answers.some((a) => a.code.trim()),
)
// 当前选中语言是否有答案代码(用于控制"先运行"按钮)
const hasAnswerCode = computed(() => {
const answer = props.answers.find(
(a) => a.language === selectedLanguage.value,
)
return !!answer?.code.trim()
})
// 当语言列表变化时,确保 selectedLanguage 始终指向一个有效值
watch(
availableLanguages,
(langs) => {
if (
langs.length &&
!langs.find((l) => l.value === selectedLanguage.value)
) {
selectedLanguage.value = langs[0].value
}
},
{ immediate: true },
)
const isRunning = ref(false)
const isUploading = ref(false)
const hasAnyInput = computed(() => files.value.some((f) => f.in.trim()))
const canUpload = computed(
() =>
!isRunning.value &&
hasAnyInput.value &&
files.value.filter((f) => f.in.trim()).every((f) => f.out && !f.error),
)
function reset() {
files.value = Array.from({ length: 5 }, () => ({
id: nextId++,
in: "",
out: "",
error: false,
}))
}
function add(n: number) {
files.value.push(
...Array.from({ length: n }, () => ({
id: nextId++,
in: "",
out: "",
error: false,
})),
)
}
function remove(index: number) {
files.value.splice(index, 1)
}
async function run() {
const answer = props.answers.find(
(a) => a.language === selectedLanguage.value,
)
if (!answer?.code.trim()) return
// 过滤空行,去重(按输入内容)
const seen = new Set<string>()
files.value = files.value.filter((f) => {
if (!f.in.trim()) return false
if (seen.has(f.in)) return false
seen.add(f.in)
return true
})
// 清空旧输出
files.value = files.value.map((f) => ({ ...f, out: "", error: false }))
isRunning.value = true
await Promise.all(
files.value.map(async (_, i) => {
try {
const result = await createTestSubmission(
{ language: selectedLanguage.value, value: answer.code },
files.value[i].in,
)
files.value[i] = {
...files.value[i],
out: result.output,
error: result.status !== 3,
}
} catch {
files.value[i] = { ...files.value[i], out: "", error: true }
}
}),
)
isRunning.value = false
}
async function upload() {
isUploading.value = true
try {
const data = files.value
.filter((f) => f.in.trim() && f.out && !f.error)
.flatMap((f, i) => [
{ name: `${i + 1}.in`, content: f.in },
{ name: `${i + 1}.out`, content: f.out },
])
const blob = createZipBlob(data)
const file = new File([blob], "testcase.zip", { type: "application/zip" })
const res = await uploadTestcases(file)
const testcases: Testcase[] = res.data.info
const baseScore = Math.floor(100 / testcases.length)
const remainder = 100 - baseScore * testcases.length
testcases.forEach((tc, i) => {
tc.score = String(
i === testcases.length - 1 ? baseScore + remainder : baseScore,
)
})
emit("uploaded", res.data.id, testcases)
message.success("上传成功")
} catch {
message.error("上传失败")
} finally {
isUploading.value = false
}
}
</script>
<template>
<n-flex vertical>
<n-alert
v-if="!hasAnyAnswerCode"
type="warning"
:show-icon="false"
style="margin-bottom: 8px"
>
还没有填写答案代码请先在上方"本题参考答案"中填写至少一种语言的答案再来生成测试用例
</n-alert>
<n-flex align="center" wrap>
<n-select
style="width: 120px"
:options="availableLanguages"
v-model:value="selectedLanguage"
/>
<n-button :disabled="isRunning" @click="reset">清空</n-button>
<n-button :disabled="isRunning" @click="add(1)">+1</n-button>
<n-button :disabled="isRunning" @click="add(5)">+5</n-button>
<n-tooltip :disabled="hasAnswerCode && hasAnyInput">
<template #trigger>
<span>
<n-button
type="success"
:loading="isRunning"
:disabled="!hasAnswerCode || !hasAnyInput"
@click="run"
>
先运行
</n-button>
</span>
</template>
{{ !hasAnswerCode ? "请先在题目中填写答案代码" : "请先填写输入" }}
</n-tooltip>
<n-button
type="primary"
:loading="isUploading"
:disabled="!canUpload"
@click="upload"
>
上传
</n-button>
</n-flex>
<n-flex
v-for="(file, index) in files"
:key="file.id"
align="start"
style="gap: 8px"
>
<n-flex vertical style="flex: 1">
<span>{{ index + 1 }}.in</span>
<n-input type="textarea" v-model:value="file.in" :rows="3" />
</n-flex>
<n-flex vertical style="flex: 1">
<span>{{ index + 1 }}.out</span>
<n-input
type="textarea"
v-model:value="file.out"
:rows="3"
:status="file.out ? (file.error ? 'error' : 'success') : undefined"
/>
</n-flex>
<n-button
:disabled="files.length === 1 || isRunning"
style="margin-top: 22px"
@click="remove(index)"
>
删除
</n-button>
</n-flex>
</n-flex>
</template>

View File

@@ -0,0 +1,920 @@
<script setup lang="ts">
import { getProblemTagList } from "shared/api"
import TextEditor from "shared/components/TextEditor.vue"
import TestcaseGenerator from "./components/TestcaseGenerator.vue"
import SQLTestcaseEditor from "./components/SQLTestcaseEditor.vue"
import AstRulesEditor from "./components/AstRulesEditor.vue"
import {
CODE_TEMPLATES,
LANGUAGE_SHOW_VALUE,
STORAGE_KEY,
} from "utils/constants"
import download from "utils/download"
import { unique } from "utils/functions"
import type {
BlankProblem,
LANGUAGE,
SQLConfig,
Tag,
Testcase,
} from "utils/types"
import {
createContestProblem,
createProblem,
editContestProblem,
editProblem,
generateFlowchartFromPythonCode,
getProblem,
uploadTestcases,
} from "../api"
const CodeEditor = defineAsyncComponent(
() => import("shared/components/CodeEditor.vue"),
)
const MermaidEditor = defineAsyncComponent(
() => import("shared/components/MermaidEditor.vue"),
)
interface Props {
problemID?: string
contestID?: string
}
const message = useMessage()
const route = useRoute()
const router = useRouter()
const props = defineProps<Props>()
const title = computed(
() =>
({
"admin problem create": "新建题目",
"admin problem edit": "编辑题目",
"admin contest problem create": "新建比赛题目",
"admin contest problem edit": "编辑比赛题目",
})[route.name as string],
)
const isAIGenerating = ref(false)
const problem = useLocalStorage<BlankProblem>(STORAGE_KEY.ADMIN_PROBLEM, {
_id: "",
title: "",
description: "",
input_description: "",
output_description: "",
time_limit: 1000,
memory_limit: 64,
difficulty: "Low" as "Low" | "Mid" | "High",
visible: false,
share_submission: false,
tags: [],
languages: ["Python3", "C"] as LANGUAGE[],
template: {} as { [key in LANGUAGE]?: string },
samples: [
{ input: "", output: "" },
{ input: "", output: "" },
{ input: "", output: "" },
],
test_case_id: "",
test_case_score: [] as Testcase[],
hint: "",
source: "",
prompt: "",
answers: [] as { language: LANGUAGE; code: string }[],
contest_id: "",
allow_flowchart: false,
mermaid_code: "",
flowchart_data: {},
flowchart_hint: "",
show_flowchart: false,
ast_rules: null as { [key: string]: any[] } | null,
sql_config: null as SQLConfig | null,
})
// 从服务器来的tag列表
const tagList = shallowRef<Tag[]>([])
const tagListLoaded = ref(false)
const selectedTags = ref<string[]>([])
const newTags = ref<string[]>([])
const selectedTagSet = computed(() => new Set(selectedTags.value))
let syncingTagInputs = false
function normalizeTagNames(tags: unknown): string[] {
if (!Array.isArray(tags)) return []
return unique(
tags
.map((tag) => (typeof tag === "string" ? tag : tag?.name))
.filter((tag): tag is string => !!tag),
)
}
function syncProblemTags() {
problem.value.tags = unique([...selectedTags.value, ...newTags.value])
}
function syncTagInputsFromProblemTags(tags: unknown = problem.value.tags) {
const tagNames = normalizeTagNames(tags)
const existingTagNames = new Set(tagList.value.map((tag) => tag.name))
syncingTagInputs = true
if (!tagListLoaded.value) {
selectedTags.value = tagNames
newTags.value = []
} else {
selectedTags.value = tagNames.filter((tag) => existingTagNames.has(tag))
newTags.value = tagNames.filter((tag) => !existingTagNames.has(tag))
}
syncingTagInputs = false
syncProblemTags()
}
function toggleTag(name: string) {
const set = new Set(selectedTags.value)
if (set.has(name)) set.delete(name)
else set.add(name)
selectedTags.value = Array.from(set)
}
function validateNewTags(v: string[]) {
const existing = new Set(tagList.value.map((t) => t.name))
const blanks: string[] = []
for (const tag of unique(v)) {
if (existing.has(tag)) {
message.error("已经存在标签:" + tag)
break
}
blanks.push(tag)
}
newTags.value = blanks
}
// 这几个用的少,就不缓存本地了
const [needTemplate, toggleNeedTemplate] = useToggle(false)
const template = reactive(JSON.parse(JSON.stringify(CODE_TEMPLATES)))
const currentActiveTemplate = ref<LANGUAGE>("Python3")
const currentActiveAnswer = ref<LANGUAGE>("Python3")
// 给 TextEditor 用
const [ready, toggleReady] = useToggle(false)
// Mermaid 渲染状态
const mermaidRenderSuccess = ref(false)
const difficultyOptions: SelectOption[] = [
{ label: "简单", value: "Low" },
{ label: "中等", value: "Mid" },
{ label: "困难", value: "High" },
]
const languageOptions = [
{ label: LANGUAGE_SHOW_VALUE["Python3"], value: "Python3" },
{ label: LANGUAGE_SHOW_VALUE["C"], value: "C" },
{ label: LANGUAGE_SHOW_VALUE["C++"], value: "C++" },
{ label: LANGUAGE_SHOW_VALUE["SQL"], value: "SQL" },
]
const isSQLProblem = computed(() => !!problem.value?.languages.includes("SQL"))
// SQL 题联动SQL 必须是唯一语言(后端强校验),不需要预制代码,自动初始化 sql_config
watch(
() => problem.value?.languages,
(langs) => {
if (!langs) return
if (langs.includes("SQL")) {
if (langs.length > 1) {
problem.value.languages = ["SQL"]
return
}
needTemplate.value = false
if (!problem.value.sql_config) {
problem.value.sql_config = { mode: "query", order_sensitive: false }
}
currentActiveAnswer.value = "SQL"
// 代码规则检查基于 Python/C 的 AST 解析,对 SQL 没有意义,清空避免脏数据
if (problem.value.ast_rules) {
problem.value.ast_rules = null
}
// 流程图依赖 Python 答案生成,对 SQL 没有意义
problem.value.allow_flowchart = false
problem.value.show_flowchart = false
} else if (problem.value.sql_config) {
problem.value.sql_config = null
}
},
{ immediate: true },
)
async function getProblemDetail() {
if (!props.problemID) {
syncTagInputsFromProblemTags()
toggleReady(true)
return
}
try {
const { data } = await getProblem(props.problemID)
problem.value.id = data.id
problem.value._id = data._id
problem.value.title = data.title
problem.value.description = data.description
problem.value.input_description = data.input_description
problem.value.output_description = data.output_description
problem.value.time_limit = data.time_limit
problem.value.memory_limit = data.memory_limit
problem.value.memory_limit = data.memory_limit
problem.value.difficulty = data.difficulty
problem.value.visible = data.visible
problem.value.share_submission = data.share_submission
problem.value.tags = normalizeTagNames(data.tags)
problem.value.languages = data.languages
problem.value.template = data.template
problem.value.samples = data.samples
problem.value.samples = data.samples
problem.value.test_case_id = data.test_case_id
problem.value.test_case_score = data.test_case_score
problem.value.hint = data.hint
problem.value.source = data.source
problem.value.prompt = data.prompt
// 流程图相关字段
problem.value.allow_flowchart = data.allow_flowchart
problem.value.show_flowchart = data.show_flowchart
problem.value.mermaid_code = data.mermaid_code ?? ""
problem.value.flowchart_hint = data.flowchart_hint ?? ""
problem.value.flowchart_data = data.flowchart_data
problem.value.ast_rules = data.ast_rules ?? null
problem.value.sql_config = data.sql_config ?? null
if (data.answers && data.answers.length) {
problem.value.answers = data.answers
} else {
problem.value.answers = data.languages.map((lang: LANGUAGE) => ({
language: lang,
code: "",
}))
}
if (problem.value.contest_id) {
problem.value.contest_id = problem.value.contest_id
}
// 下面是用来显示的:
// 代码模板 和 模板开关
problem.value.languages.forEach((lang) => {
if (data.template[lang]) {
template[lang] = data.template[lang]
toggleNeedTemplate(true)
}
})
// 标签
syncTagInputsFromProblemTags(problem.value.tags)
toggleReady(true)
} catch (error) {
message.error("获取题目失败")
router.push({ name: "admin problem list" })
}
}
async function getTagList() {
const res = await getProblemTagList()
tagList.value = res.data
tagListLoaded.value = true
syncTagInputsFromProblemTags()
}
function addSample() {
problem.value.samples.push({ input: "", output: "" })
}
function removeSample(index: number) {
problem.value.samples.splice(index, 1)
}
function resetTemplate(language: LANGUAGE) {
template[language] = CODE_TEMPLATES[language]
}
async function handleUploadTestcases({ file }: UploadCustomRequestOptions) {
try {
const res = await uploadTestcases(file.file!, { sql: isSQLProblem.value })
// @ts-ignore
if (res.error) {
message.error("上传测试用例失败")
return
}
const testcases = res.data.info
for (let file of testcases) {
file.score = (100 / testcases.length).toFixed(0)
}
problem.value.test_case_score = testcases
problem.value.test_case_id = res.data.id
} catch (err) {
message.error("上传测试用例失败")
}
}
function downloadTestcases() {
download("test_case?problem_id=" + problem.value.id)
}
// Mermaid 渲染事件处理
function onMermaidRenderSuccess() {
mermaidRenderSuccess.value = true
}
// 题目是否有漏写的
async function validateProblem() {
let hasErrors = false
// 标题
if (!problem.value._id || !problem.value.title) {
message.error("编号或标题没有填写")
hasErrors = true
}
// 标签
else if (selectedTags.value.length === 0 && newTags.value.length === 0) {
message.error("标签没有填写")
hasErrors = true
}
// 题目
else if (
!problem.value.description ||
(!isSQLProblem.value &&
(!problem.value.input_description || !problem.value.output_description))
) {
message.error("题目或输入或输出没有填写")
hasErrors = true
}
// 样例
else if (!isSQLProblem.value && problem.value.samples.length == 0) {
message.error("样例没有填写")
hasErrors = true
}
// 样例是空的
else if (
!isSQLProblem.value &&
problem.value.samples.some(
(sample) => sample.output === "" || sample.input === "",
)
) {
message.error("空样例没有删干净")
hasErrors = true
}
// 测试用例
else if (problem.value.test_case_score.length === 0) {
message.error("测试用例没有上传")
hasErrors = true
} else if (problem.value.languages.length === 0) {
message.error("编程语言没有选择")
hasErrors = true
}
// SQL 题验证
else if (isSQLProblem.value && !problem.value.sql_config?.mode) {
message.error("SQL 题需要选择题型(查询题/增删改题)")
hasErrors = true
} else if (
isSQLProblem.value &&
!problem.value.answers.find(
(ans) => ans.language === "SQL" && ans.code.trim() !== "",
)
) {
message.error("SQL 题必须填写标准答案(判题时用它生成期望结果)")
hasErrors = true
}
// 流程图验证
else if (problem.value.show_flowchart || problem.value.allow_flowchart) {
if (
!problem.value.mermaid_code ||
problem.value.mermaid_code.trim() === ""
) {
message.error("启用了流程图功能,但流程图代码为空")
hasErrors = true
} else if (!mermaidRenderSuccess.value) {
message.error("Mermaid 代码尚未成功渲染,请检查代码语法")
hasErrors = true
}
}
// 通过了
else {
hasErrors = false
}
return hasErrors
}
function getTemplate() {
if (!needTemplate.value) {
problem.value.template = {}
} else {
problem.value.languages.forEach((lang) => {
if (CODE_TEMPLATES[lang] !== template[lang]) {
problem.value.template[lang] = template[lang]
} else {
delete problem.value.template[lang]
}
})
}
}
function filterHint() {
// 编辑器会自动添加一段 HTML
if (problem.value.hint === "<p><br></p>") {
problem.value.hint = ""
}
}
function filterAnswers() {
problem.value.answers = problem.value.answers.filter(
(ans) => ans.code.trim() !== "",
)
}
function filterSamplesForSQL() {
// SQL 题不展示样例;后端 CreateSampleSerializer 也不接受空字符串样例
if (isSQLProblem.value) problem.value.samples = []
}
async function submit() {
const hasValidationErrors = await validateProblem()
if (hasValidationErrors) return
filterHint()
getTemplate()
filterAnswers()
filterSamplesForSQL()
syncProblemTags()
const api = {
"admin problem create": createProblem,
"admin problem edit": editProblem,
"admin contest problem create": createContestProblem,
"admin contest problem edit": editContestProblem,
}[route.name as string]
if (
route.name === "admin contest problem create" ||
route.name === "admin contest problem edit"
) {
problem.value.contest_id = props.contestID
}
try {
await api!(problem.value)
problem.value = null
selectedTags.value = []
newTags.value = []
if (
route.name === "admin problem create" ||
route.name === "admin contest problem create"
) {
message.success("恭喜你 💐 出题成功")
}
if (
route.name === "admin problem create" ||
route.name === "admin problem edit"
) {
router.push({ name: "admin problem list" })
} else {
router.push({
name: "admin contest problem list",
params: { contestID: props.contestID },
})
}
} catch (err: any) {
if (err.data === "Display ID already exists") {
message.error("显示编号重复了,请换一个显示编号")
} else {
message.error(err.data)
}
}
}
const showClear = computed(
() =>
route.name === "admin problem create" ||
route.name === "admin contest problem create",
)
function clear() {
problem.value = null
selectedTags.value = []
newTags.value = []
// 为了给所有状态初始化,刷新页面
location.reload()
}
async function generateMermaid() {
isAIGenerating.value = true
const res = await generateFlowchartFromPythonCode(
problem.value.answers.filter((a) => a.language === "Python3")[0].code,
)
isAIGenerating.value = false
message.warning("如果渲染不成功,请复制到外部 AI 网站检查语法")
problem.value.mermaid_code = res.data.flowchart
}
const showGeneratorModal = ref(false)
function handleTestcasesGenerated(
testCaseId: string,
testCaseScore: Testcase[],
) {
problem.value.test_case_id = testCaseId
problem.value.test_case_score = testCaseScore
showGeneratorModal.value = false
}
onMounted(() => {
getTagList()
getProblemDetail()
})
watch([selectedTags, newTags], ([sel, newT]) => {
if (syncingTagInputs) return
problem.value.tags = unique([...sel, ...newT])
})
watch(
() => problem.value.languages,
(langs) => {
const answers = langs.map((lang) => {
const existing = problem.value.answers.find(
(ans) => ans.language === lang,
)
return existing || { language: lang, code: "" }
})
problem.value.answers = answers
},
{ immediate: true },
)
</script>
<template>
<n-flex>
<h2 class="title">{{ title }}</h2>
<n-button v-if="showClear" @click="clear">清空缓存</n-button>
</n-flex>
<n-form inline label-placement="left">
<n-form-item label="显示编号">
<n-input class="w-100" v-model:value="problem._id" />
</n-form-item>
<n-form-item label="题目">
<n-input class="problemTitleInput" v-model:value="problem.title" />
</n-form-item>
<n-form-item label="难度">
<n-select
class="w-100"
:options="difficultyOptions"
v-model:value="problem.difficulty"
/>
</n-form-item>
<n-form-item label="可见">
<n-switch v-model:value="problem.visible" />
</n-form-item>
</n-form>
<n-form label-placement="left" :show-feedback="false">
<n-form-item label="标签">
<n-flex vertical style="width: 100%">
<n-flex size="small" style="flex-wrap: wrap">
<n-tag
v-for="tag in tagList"
:key="tag.id"
checkable
:checked="selectedTagSet.has(tag.name)"
@update:checked="toggleTag(tag.name)"
>
{{ tag.name }}
</n-tag>
</n-flex>
<n-dynamic-tags
v-model:value="newTags"
@update:value="validateNewTags"
/>
</n-flex>
</n-form-item>
</n-form>
<TextEditor
v-if="ready"
v-model:value="problem.description"
title="题目的描述"
:min-height="300"
/>
<TextEditor
v-if="ready && !isSQLProblem"
v-model:value="problem.input_description"
title="输入的描述"
/>
<TextEditor
v-if="ready && !isSQLProblem"
v-model:value="problem.output_description"
title="输出的描述"
/>
<template v-if="!isSQLProblem">
<div class="box" v-for="(sample, index) in problem.samples" :key="index">
<n-flex justify="space-between" align="center">
<strong>测试样例 {{ index + 1 }}</strong>
<n-button
tertiary
type="warning"
size="small"
@click="removeSample(index)"
>
删除 {{ index + 1 }}
</n-button>
</n-flex>
<n-grid x-gap="20" cols="2">
<n-gi span="1">
<n-flex vertical>
<span>输入样例</span>
<n-input type="textarea" v-model:value="sample.input" />
</n-flex>
</n-gi>
<n-gi span="1">
<n-flex vertical>
<span>输出样例</span>
<n-input type="textarea" v-model:value="sample.output" />
</n-flex>
</n-gi>
</n-grid>
</div>
<n-button class="addSamples box" tertiary type="primary" @click="addSample">
添加用例
</n-button>
</template>
<TextEditor v-if="ready" v-model:value="problem.hint" title="提示(选填)" />
<n-form>
<n-form-item label="题目的来源(选填)">
<n-input
v-model:value="problem.source"
placeholder="比如来自某道题的改编等,或者网上的资料"
/>
</n-form-item>
<n-form-item label="本题的考察知识点(选填,用于 AI 分析)">
<n-input
v-model:value="problem.prompt"
placeholder="比如考察选择、循环、算法等知识点"
/>
</n-form-item>
</n-form>
<n-divider />
<h2 class="title">代码区域</h2>
<n-form inline label-placement="left">
<n-form-item label="编程语言">
<n-checkbox-group v-model:value="problem.languages">
<n-flex align="center">
<n-checkbox
v-for="(language, index) in languageOptions"
:key="index"
:value="language.value"
:label="language.label"
/>
</n-flex>
</n-checkbox-group>
</n-form-item>
<n-form-item v-if="!isSQLProblem">
<n-checkbox
v-model:checked="needTemplate"
label="预制代码(显示在编辑器中,帮助快速上手)"
/>
</n-form-item>
<n-form-item>
<n-button
v-if="needTemplate"
size="small"
tertiary
type="warning"
@click="resetTemplate(currentActiveTemplate)"
>
重置 {{ LANGUAGE_SHOW_VALUE[currentActiveTemplate] }} 的预制代码
</n-button>
</n-form-item>
</n-form>
<n-form
v-if="isSQLProblem && problem.sql_config"
inline
label-placement="left"
>
<n-form-item label="SQL 题型">
<n-radio-group v-model:value="problem.sql_config.mode">
<n-radio-button value="query">查询题比对查询结果</n-radio-button>
<n-radio-button value="modify">
增删改题比对执行后的表数据
</n-radio-button>
</n-radio-group>
</n-form-item>
<n-form-item label="严格比对行顺序">
<n-switch v-model:value="problem.sql_config.order_sensitive" />
<n-text depth="3" style="margin-left: 12px">
题目要求 ORDER BY 时开启关闭则按无序集合比对
</n-text>
</n-form-item>
</n-form>
<n-grid :cols="2" x-gap="20">
<n-gi>
<n-form>
<n-form-item
:label="
isSQLProblem
? '标准答案(必填,判题依据:每个测试点会运行它生成期望结果)'
: '本题参考答案(选填,用于 AI 分析,不会泄露)'
"
>
<n-tabs
type="segment"
default-value="Python3"
v-model:value="currentActiveAnswer"
>
<n-tab-pane
v-for="(answer, index) in problem.answers"
:key="index"
:name="answer.language"
>
<CodeEditor
v-model:value="answer.code"
:language="answer.language"
:font-size="16"
height="300px"
/>
</n-tab-pane>
</n-tabs>
</n-form-item>
</n-form>
</n-gi>
<n-gi>
<n-form v-if="needTemplate">
<n-form-item label="编写预制代码">
<n-tabs
type="segment"
default-value="Python3"
v-model:value="currentActiveTemplate"
>
<n-tab-pane
v-for="(lang, index) in problem.languages"
:key="index"
:name="lang"
>
<CodeEditor
v-model:value="template[lang]"
:language="lang"
:font-size="16"
height="300px"
/>
</n-tab-pane>
</n-tabs>
</n-form-item>
</n-form>
</n-gi>
</n-grid>
<n-grid v-if="!isSQLProblem" :cols="2">
<n-gi :span="1">
<AstRulesEditor
v-model="problem.ast_rules!"
:languages="problem.languages"
/>
</n-gi>
</n-grid>
<n-divider />
<h2 class="title">测试用例区域</h2>
<n-flex v-if="!isSQLProblem" align="center" style="margin-bottom: 12px">
<div>
<n-button type="success" @click="showGeneratorModal = true">
直接生成
</n-button>
</div>
<div>
<n-upload
:show-file-list="false"
accept=".zip"
:custom-request="handleUploadTestcases"
>
<n-button type="info">手动上传</n-button>
</n-upload>
</div>
<n-tooltip placement="right" style="max-width: 320px; white-space: normal">
<template #trigger>
<n-button text>温馨提醒</n-button>
</template>
测试用例最好要有10个要考虑边界情况且不要跟测试样例一模一样
</n-tooltip>
</n-flex>
<SQLTestcaseEditor
v-if="isSQLProblem"
:answers="problem.answers"
:mode="problem.sql_config?.mode ?? 'query'"
:problem-id="problem.id"
@uploaded="handleTestcasesGenerated"
/>
<n-alert
class="box"
v-if="problem.test_case_score.length"
:show-icon="false"
type="info"
>
<template #header>
<n-flex align="center">
<div>
测试组编号 {{ problem.test_case_id.slice(0, 12) }} 共有
{{ problem.test_case_score.length }}
条测试用例
</div>
<n-button
v-if="problem.id"
tertiary
type="info"
size="small"
@click="downloadTestcases"
>
下载
</n-button>
</n-flex>
</template>
</n-alert>
<n-modal
v-model:show="showGeneratorModal"
preset="card"
title="测试用例生成器"
style="width: 80vw; max-width: 900px"
:mask-closable="false"
display-directive="show"
>
<TestcaseGenerator
:answers="problem.answers"
:samples="problem.samples"
@uploaded="handleTestcasesGenerated"
/>
</n-modal>
<template v-if="!isSQLProblem">
<n-divider />
<h2 class="title">流程图区域</h2>
<!-- 流程图相关设置 -->
<n-form inline label-placement="left" :show-feedback="false">
<n-form-item label="根据上面的【Python答案】智能生成 Mermaid 代码">
<n-button
type="primary"
size="small"
:disabled="
!problem.answers.filter((a) => a.language === 'Python3')[0]?.code
.length
"
:loading="isAIGenerating"
@click="generateMermaid"
>
AI 生成
</n-button>
</n-form-item>
<n-form-item label="允许提交流程图">
<n-switch v-model:value="problem.allow_flowchart" />
</n-form-item>
<n-form-item label="显示标准流程图">
<n-switch v-model:value="problem.show_flowchart" />
</n-form-item>
</n-form>
<n-form>
<n-form-item>
<MermaidEditor
v-model="problem.mermaid_code"
@render-success="onMermaidRenderSuccess"
/>
</n-form-item>
<n-form-item label="流程图提示信息(选填)">
<n-input
v-model:value="problem.flowchart_hint"
placeholder="请输入流程图相关的提示信息,帮助学生理解题目要求"
/>
</n-form-item>
</n-form>
</template>
<n-flex style="margin: 16px 0 120px" align="center" justify="end">
<n-button type="primary" @click="submit">提交</n-button>
</n-flex>
</template>
<style scoped>
.title {
margin-top: 0;
}
.box {
margin-bottom: 20px;
}
.w-100 {
width: 100px;
}
.problemTitleInput {
width: 300px;
}
.addSamples {
width: 100%;
}
</style>

View File

@@ -0,0 +1,335 @@
<script setup lang="ts">
import { NFlex, NSwitch, NTag, NTooltip } from "naive-ui"
import { Icon } from "@iconify/vue"
import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination"
import { getTagColor, parseTime } from "utils/functions"
import type { AdminProblemFiltered } from "utils/types"
import { DIFFICULTY, REACTIONS } from "utils/constants"
import { getProblemList, toggleProblemVisible } from "../api"
import Actions from "./components/Actions.vue"
import Modal from "./components/Modal.vue"
import { useRouteQuery } from "@vueuse/router"
import AuthorSelect from "shared/components/AuthorSelect.vue"
import type { DataTableRowKey } from "naive-ui"
import BatchTagModal from "./components/BatchTagModal.vue"
interface Props {
contestID?: string
}
const props = defineProps<Props>()
const route = useRoute()
const router = useRouter()
const title = computed(
() =>
({
"admin problem list": "题目列表",
"admin contest problem list": "比赛题目列表",
})[route.name as string],
)
const isContestProblemList = computed(
() => route.name === "admin contest problem list",
)
const [show, toggleShow] = useToggle()
const { count, inc } = useCounter(0)
const total = ref(0)
const problems = ref<AdminProblemFiltered[]>([])
const selectedRowKeys = ref<DataTableRowKey[]>([])
const batchTagAction = ref<"add" | "remove">("add")
const [showBatchTag, toggleBatchTag] = useToggle(false)
const selectedProblemIds = computed(() =>
selectedRowKeys.value.map((key) => Number(key)),
)
const rowKey = (row: AdminProblemFiltered) => row.id
function chooseProblems(rowKeys: DataTableRowKey[]) {
selectedRowKeys.value = rowKeys
}
function openBatchTag(action: "add" | "remove") {
batchTagAction.value = action
toggleBatchTag(true)
}
function onBatchTagDone() {
selectedRowKeys.value = []
listProblems()
}
const nextDisplayID = computed(() => {
if (!isContestProblemList.value) return ""
if (problems.value.length === 0) return "1"
const ids = problems.value.map((p) => p._id)
if (ids.every((id) => /^\d+$/.test(id))) {
return String(Math.max(...ids.map((id) => parseInt(id))) + 1)
}
return ""
})
interface ProblemQuery {
keyword: string
author: string
}
// 使用分页 composable
const { query, clearQuery } = usePagination<ProblemQuery>({
keyword: useRouteQuery("keyword", "").value,
author: useRouteQuery("author", "").value,
})
const baseColumns: DataTableColumn<AdminProblemFiltered>[] = [
{ title: "ID", key: "id", width: 100 },
{ title: "显示编号", key: "_id", width: 100 },
{ title: "标题", key: "title", minWidth: 200 },
{
title: "难度",
key: "difficulty",
width: 80,
render: (row) =>
h(
NTag,
{ type: getTagColor(row.difficulty), size: "small" },
() => DIFFICULTY[row.difficulty],
),
},
{
title: "标签",
key: "tags",
minWidth: 120,
render: (row) =>
h(NFlex, { size: 4 }, () =>
row.tags.map((t) => h(NTag, { key: t, size: "small" }, () => t)),
),
},
{
title: "功能",
key: "features",
width: 80,
render: (row) =>
h(NFlex, { size: 4, align: "center" }, () => [
row.allow_flowchart
? h(Icon, {
width: 18,
icon: "vscode-icons:file-type-drawio",
title: "绘图",
})
: row.show_flowchart
? h(Icon, {
width: 18,
icon: "vscode-icons:file-type-graphql",
title: "流程图",
})
: null,
row.has_ast_rules
? h(Icon, {
width: 18,
icon: "vscode-icons:file-type-light-todo",
title: "AST",
})
: null,
]),
},
{
title: "反馈",
key: "top_reaction",
width: 60,
render: (row) => {
const top = row.top_reaction
if (!top) return null
const reaction = REACTIONS.find((it) => it.key === top.type)
if (!reaction) return null
return h(NTooltip, null, {
trigger: () => h(Icon, { width: 18, icon: reaction.icon }),
default: () => `${reaction.label} ${top.count}`,
})
},
},
{ title: "出题人", key: "username", width: 120 },
{
title: "创建时间",
key: "create_time",
width: 200,
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
},
{
title: "可见",
key: "visible",
minWidth: 100,
render: (row) =>
h(NSwitch, {
value: row.visible,
size: "small",
rubberBand: false,
onUpdateValue: () => toggleVisible(row.id),
}),
},
{
title: "选项",
key: "actions",
width: 320,
render: (row) =>
h(Actions, {
problemID: row.id,
problemDisplayID: row._id,
onUpdated: listProblems,
}),
},
]
// 比赛题目接口不返回 top_reaction这一列只在普通题目列表里显示
const columns = computed<DataTableColumn<AdminProblemFiltered>[]>(() =>
isContestProblemList.value
? baseColumns.filter((it) => !("key" in it) || it.key !== "top_reaction")
: [{ type: "selection" }, ...baseColumns],
)
async function listProblems() {
if (query.page < 1) query.page = 1
const offset = (query.page - 1) * query.limit
const res = await getProblemList(
offset,
query.limit,
query.keyword,
query.author,
props.contestID,
)
total.value = res.total
problems.value = res.results
}
async function toggleVisible(problemID: number) {
await toggleProblemVisible(problemID)
problems.value = problems.value.map((it) => {
if (it.id === problemID) {
it.visible = !it.visible
}
return it
})
}
function createContestProblem() {
router.push({
name: "admin contest problem create",
params: { contestID: props.contestID },
})
}
async function selectProblems() {
toggleShow(true)
inc()
}
onMounted(listProblems)
// 监听搜索关键词变化(防抖)
watchDebounced(() => query.keyword, listProblems, {
debounce: 500,
maxWait: 1000,
})
// 监听其他查询条件变化
watch(() => [query.page, query.limit, query.author], listProblems)
</script>
<template>
<n-flex class="titleWrapper" justify="space-between">
<n-flex align="center">
<h2 class="title">{{ title }}</h2>
<n-button
v-if="!isContestProblemList"
type="primary"
@click="$router.push({ name: 'admin problem create' })"
>
新建
</n-button>
<n-button
v-if="!isContestProblemList"
@click="$router.push({ name: 'admin stuck problems' })"
>
卡点分析
</n-button>
<n-button
v-if="!isContestProblemList"
@click="$router.push({ name: 'admin top ac trend' })"
>
年度趋势
</n-button>
<n-button
v-if="!isContestProblemList"
@click="$router.push({ name: 'admin tag list' })"
>
标签管理
</n-button>
</n-flex>
<n-flex>
<template v-if="!isContestProblemList && selectedProblemIds.length">
<n-button type="primary" @click="openBatchTag('add')">
添加标签{{ selectedProblemIds.length }}
</n-button>
<n-button @click="openBatchTag('remove')">移除标签</n-button>
</template>
<n-button v-if="isContestProblemList" @click="createContestProblem">
新建比赛题目
</n-button>
<n-button
v-if="isContestProblemList"
type="primary"
@click="selectProblems"
>
从题目中选择
</n-button>
<n-flex align="center" v-if="!props.contestID">
<span>出题人</span>
<AuthorSelect v-model:value="query.author" all />
</n-flex>
<div>
<n-input
v-model:value="query.keyword"
placeholder="输入标题关键字"
clearable
@clear="clearQuery"
/>
</div>
</n-flex>
</n-flex>
<n-data-table
striped
:columns="columns"
:data="problems"
:row-key="rowKey"
@update:checked-row-keys="chooseProblems"
/>
<Pagination
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
<Modal
v-model:show="show"
:count="count"
:next-display-id="nextDisplayID"
@change="listProblems"
/>
<BatchTagModal
v-model:show="showBatchTag"
:problem-ids="selectedProblemIds"
:action="batchTagAction"
@done="onBatchTagDone"
/>
</template>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
}
.title {
margin: 0;
}
</style>

View File

@@ -0,0 +1,186 @@
<script setup lang="ts">
import { NButton, NFlex, NInput } from "naive-ui"
import type { AdminTag } from "utils/types"
import { deleteTag, getTagAdminList, renameTag } from "../api"
import TagProblemsModal from "./components/TagProblemsModal.vue"
const message = useMessage()
const dialog = useDialog()
const tags = ref<AdminTag[]>([])
const keyword = ref("")
const editingId = ref<number | null>(null)
const editingName = ref("")
const activeTag = ref<AdminTag | null>(null)
const [showTagProblems, toggleTagProblems] = useToggle(false)
function openTagProblems(tag: AdminTag) {
activeTag.value = tag
toggleTagProblems(true)
}
const columns: DataTableColumn<AdminTag>[] = [
{ title: "ID", key: "id", width: 80 },
{
title: "标签名",
key: "name",
minWidth: 200,
render: (row) =>
editingId.value === row.id
? h(NInput, {
value: editingName.value,
autofocus: true,
size: "small",
style: "max-width: 240px",
onUpdateValue: (v: string) => (editingName.value = v),
onKeyup: (e: KeyboardEvent) => {
if (e.key === "Enter") saveTag(row)
if (e.key === "Escape") cancelEdit()
},
})
: h(
NButton,
{
text: true,
type: "primary",
onClick: () => openTagProblems(row),
},
() => row.name,
),
},
{
title: "题目数",
key: "problem_count",
width: 100,
render: (row) =>
h(
NButton,
{ text: true, type: "primary", onClick: () => openTagProblems(row) },
() => String(row.problem_count),
),
},
{
title: "选项",
key: "actions",
width: 200,
render: (row) =>
h(NFlex, { size: 8 }, () =>
editingId.value === row.id
? [
h(
NButton,
{ size: "small", type: "primary", onClick: () => saveTag(row) },
() => "保存",
),
h(NButton, { size: "small", onClick: cancelEdit }, () => "取消"),
]
: [
h(
NButton,
{ size: "small", onClick: () => startEdit(row) },
() => "重命名",
),
h(
NButton,
{
size: "small",
type: "error",
onClick: () => confirmDelete(row),
},
() => "删除",
),
],
),
},
]
async function listTags() {
const res = await getTagAdminList(keyword.value)
tags.value = res.data
}
function startEdit(tag: AdminTag) {
editingId.value = tag.id
editingName.value = tag.name
}
function cancelEdit() {
editingId.value = null
editingName.value = ""
}
async function saveTag(tag: AdminTag) {
const name = editingName.value.trim()
if (!name) {
message.error("标签名不能为空")
return
}
if (name === tag.name) {
cancelEdit()
return
}
const res = await renameTag(tag.id, name)
if (res.data.merged) {
message.success(
`已合并到「${res.data.name}」,影响 ${res.data.affected_count} 道题`,
)
} else {
message.success("已重命名")
}
cancelEdit()
listTags()
}
function confirmDelete(tag: AdminTag) {
dialog.warning({
title: "删除标签",
content: `确定删除标签「${tag.name}」吗?当前有 ${tag.problem_count} 道题在使用它,删除后这些题目会失去该标签。`,
positiveText: "删除",
negativeText: "取消",
onPositiveClick: async () => {
await deleteTag(tag.id)
message.success("已删除")
listTags()
},
})
}
onMounted(listTags)
watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
</script>
<template>
<n-flex class="titleWrapper" justify="space-between">
<n-flex align="center">
<h2 class="title">标签管理</h2>
<n-button @click="$router.push({ name: 'admin problem list' })">
返回题目列表
</n-button>
</n-flex>
<n-input
v-model:value="keyword"
style="width: 200px"
placeholder="搜索标签"
clearable
/>
</n-flex>
<n-data-table striped :columns="columns" :data="tags" />
<TagProblemsModal
v-model:show="showTagProblems"
:tag-id="activeTag?.id ?? 0"
:tag-name="activeTag?.name ?? ''"
@changed="listTags"
/>
</template>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
}
.title {
margin: 0;
}
</style>

View File

@@ -0,0 +1,105 @@
<script lang="ts" setup>
import { deleteProblemSet, updateProblemSetStatus } from "admin/api"
interface Props {
problemSetId: number
}
const props = defineProps<Props>()
const emit = defineEmits(["updated"])
const router = useRouter()
const message = useMessage()
const showStatusModal = ref(false)
const newStatus = ref<"active" | "archived" | "draft">("active")
const statusOptions = [
{ label: "活跃", value: "active" },
{ label: "已归档", value: "archived" },
{ label: "草稿", value: "draft" },
]
async function handleDeleteProblemSet() {
try {
await deleteProblemSet(props.problemSetId)
message.success("删除成功")
emit("updated")
} catch (err: any) {
message.error("删除失败:" + (err.data || "未知错误"))
}
}
function goEdit() {
router.push({
name: "admin problemset edit",
params: { problemSetId: props.problemSetId },
})
}
function goDetail() {
router.push({
name: "admin problemset detail",
params: { problemSetId: props.problemSetId },
})
}
function openStatusModal() {
showStatusModal.value = true
}
async function handleUpdateStatus() {
try {
await updateProblemSetStatus(props.problemSetId, newStatus.value)
message.success("状态更新成功")
showStatusModal.value = false
emit("updated")
} catch (err: any) {
message.error("状态更新失败:" + (err.data || "未知错误"))
}
}
</script>
<template>
<n-flex>
<n-button size="small" secondary type="primary" @click="goEdit">
编辑
</n-button>
<n-button size="small" secondary type="info" @click="goDetail">
详情
</n-button>
<n-button size="small" secondary type="warning" @click="openStatusModal">
状态
</n-button>
<n-popconfirm @positive-click="handleDeleteProblemSet">
<template #trigger>
<n-button secondary size="small" type="error">删除</n-button>
</template>
确定删除这个题单吗删除后题单将不可见
</n-popconfirm>
</n-flex>
<n-modal
v-model:show="showStatusModal"
preset="card"
title="更新题单状态"
style="width: 400px"
>
<n-space vertical>
<n-form>
<n-form-item label="状态" required>
<n-select
v-model:value="newStatus"
:options="statusOptions"
placeholder="选择状态"
/>
</n-form-item>
</n-form>
</n-space>
<template #footer>
<n-flex justify="end">
<n-button @click="showStatusModal = false">取消</n-button>
<n-button type="primary" @click="handleUpdateStatus">确认</n-button>
</n-flex>
</template>
</n-modal>
</template>

View File

@@ -0,0 +1,162 @@
<script setup lang="ts">
interface Props {
show: boolean
}
interface Emits {
(e: "update:show", value: boolean): void
(
e: "confirm",
data: {
name: string
description: string
icon: string
condition_type: "all_problems" | "problem_count" | "score"
condition_value?: number
},
): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const newBadgeName = ref("")
const newBadgeDescription = ref("")
const newBadgeIcon = ref("")
const newBadgeConditionType = ref<"all_problems" | "problem_count" | "score">(
"all_problems",
)
const newBadgeConditionValue = ref(1)
const BADGE_LEN = 6
const badgeIconOptions = []
for (let i = 1; i <= BADGE_LEN; i++) {
badgeIconOptions.push({
label: `奖章${i}`,
value: `/badge-${i}.png`,
icon: `/badge-${i}.png`,
})
}
const conditionTypeOptions = [
{ label: "完成所有题目", value: "all_problems" },
{ label: "完成指定数量题目", value: "problem_count" },
{ label: "达到指定分数", value: "score" },
]
function handleConfirm() {
const data: any = {
name: newBadgeName.value,
description: newBadgeDescription.value,
icon: newBadgeIcon.value,
condition_type: newBadgeConditionType.value,
}
// 只有非"完成所有题目"时才添加条件值
if (newBadgeConditionType.value !== "all_problems") {
data.condition_value = newBadgeConditionValue.value
}
emit("confirm", data)
}
function handleCancel() {
emit("update:show", false)
}
// 重置表单
watch(
() => props.show,
(newVal) => {
if (newVal) {
newBadgeName.value = ""
newBadgeDescription.value = ""
newBadgeIcon.value = ""
newBadgeConditionType.value = "all_problems"
newBadgeConditionValue.value = 1
}
},
)
</script>
<template>
<n-modal
:show="show"
preset="card"
title="添加奖章"
style="width: 500px"
@update:show="emit('update:show', $event)"
>
<n-form>
<n-form-item label="奖章名称" required>
<n-input v-model:value="newBadgeName" placeholder="请输入奖章名称" />
</n-form-item>
<n-form-item label="描述">
<n-input
v-model:value="newBadgeDescription"
type="textarea"
placeholder="奖章描述"
required
/>
</n-form-item>
<n-form-item label="图标" required>
<n-flex align="center" gap="small">
<div
v-for="option in badgeIconOptions"
:key="option.value"
@click="newBadgeIcon = option.value"
:style="{
width: '60px',
height: '60px',
border:
newBadgeIcon === option.value
? '2px solid #1890ff'
: '1px solid #d9d9d9',
borderRadius: '4px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor:
newBadgeIcon === option.value ? '#f0f8ff' : 'transparent',
}"
>
<n-image
:src="option.icon"
width="50"
height="50"
object-fit="cover"
preview-disabled
style="border-radius: 2px"
/>
</div>
</n-flex>
</n-form-item>
<n-flex align="center">
<n-form-item label="获得条件">
<n-select
style="width: 200px"
v-model:value="newBadgeConditionType"
:options="conditionTypeOptions"
/>
</n-form-item>
<n-form-item
label="条件值"
v-if="newBadgeConditionType !== 'all_problems'"
>
<n-input-number
style="width: 120px"
v-model:value="newBadgeConditionValue"
placeholder="条件值"
/>
</n-form-item>
</n-flex>
</n-form>
<template #footer>
<n-flex justify="end">
<n-button @click="handleCancel">取消</n-button>
<n-button type="primary" @click="handleConfirm">确认</n-button>
</n-flex>
</template>
</n-modal>
</template>

View File

@@ -0,0 +1,103 @@
<script setup lang="ts">
interface Props {
show: boolean
}
interface Emits {
(e: "update:show", value: boolean): void
(
e: "confirm",
data: {
problem_id: string
order: number
is_required: boolean
score: number
hint: string
},
): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const newProblemId = ref("")
const newProblemOrder = ref(0)
const newProblemRequired = ref(true)
const newProblemScore = ref(0)
const newProblemHint = ref("")
function handleConfirm() {
emit("confirm", {
problem_id: newProblemId.value,
order: newProblemOrder.value,
is_required: newProblemRequired.value,
score: newProblemScore.value,
hint: newProblemHint.value,
})
}
function handleCancel() {
emit("update:show", false)
}
// 重置表单
watch(
() => props.show,
(newVal) => {
if (newVal) {
newProblemId.value = ""
newProblemOrder.value = 0
newProblemRequired.value = true
newProblemScore.value = 0
newProblemHint.value = ""
}
},
)
</script>
<template>
<n-modal
:show="show"
preset="card"
title="添加题目"
style="width: 500px"
@update:show="emit('update:show', $event)"
>
<n-form>
<n-form-item label="题目ID" required>
<n-input
v-model:value="newProblemId"
placeholder="请输入题目的显示ID1001"
/>
</n-form-item>
<n-form-item label="顺序">
<n-input-number
v-model:value="newProblemOrder"
placeholder="题目在题单中的顺序"
/>
</n-form-item>
<n-form-item label="是否必做">
<n-switch v-model:value="newProblemRequired" />
</n-form-item>
<n-form-item label="分数">
<n-input-number
v-model:value="newProblemScore"
placeholder="题目分数"
/>
</n-form-item>
<n-form-item label="提示">
<n-input
v-model:value="newProblemHint"
type="textarea"
placeholder="题目提示"
/>
</n-form-item>
</n-form>
<template #footer>
<n-flex justify="end">
<n-button @click="handleCancel">取消</n-button>
<n-button type="primary" @click="handleConfirm">确认</n-button>
</n-flex>
</template>
</n-modal>
</template>

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import { h } from "vue"
import type { ProblemSetBadge } from "utils/types"
import { NButton, NImage } from "naive-ui"
interface Props {
badges: ProblemSetBadge[]
}
interface Emits {
(e: "add-badge"): void
(e: "edit-badge", badge: ProblemSetBadge): void
(e: "delete-badge", badgeId: number): void
}
defineProps<Props>()
defineEmits<Emits>()
</script>
<template>
<div>
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
<h3>奖章列表</h3>
<n-button type="primary" @click="$emit('add-badge')"> 添加奖章 </n-button>
</n-flex>
<n-data-table
:columns="[
{
title: '图标',
key: 'icon',
render: (row) =>
h(NImage, {
src: row.icon,
width: 40,
height: 40,
objectFit: 'cover',
previewDisabled: true,
style: 'border-radius: 4px; border: 1px solid #d9d9d9',
}),
},
{ title: '名称', key: 'name' },
{
title: '条件类型',
key: 'condition_type',
render: (row) => {
const typeMap: Record<string, string> = {
all_problems: '完成所有题目',
problem_count: '完成指定数量题目',
score: '达到指定分数',
}
return typeMap[row.condition_type] || row.condition_type
},
},
{
title: '条件值',
key: 'condition_value',
render: (row) => {
return row.condition_type === 'all_problems'
? '-'
: row.condition_value
},
},
{ title: '描述', key: 'description' },
{
title: '操作',
key: 'actions',
width: 160,
render: (row) =>
h('div', { style: 'display: flex; gap: 8px;' }, [
h(
NButton,
{
size: 'small',
type: 'primary',
secondary: true,
onClick: () => $emit('edit-badge', row),
},
{ default: () => '编辑' },
),
h(
NButton,
{
size: 'small',
type: 'error',
secondary: true,
onClick: () => $emit('delete-badge', row.id),
},
{ default: () => '删除' },
),
]),
},
]"
:data="badges"
/>
</div>
</template>

View File

@@ -0,0 +1,167 @@
<script setup lang="ts">
import type { ProblemSetBadge } from "utils/types"
interface Props {
show: boolean
badge: ProblemSetBadge | null
}
interface Emits {
(e: "update:show", value: boolean): void
(
e: "confirm",
data: {
name: string
description: string
icon: string
condition_type: "all_problems" | "problem_count" | "score"
condition_value?: number
},
): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const editBadgeName = ref("")
const editBadgeDescription = ref("")
const editBadgeIcon = ref("")
const editBadgeConditionType = ref<"all_problems" | "problem_count" | "score">(
"all_problems",
)
const editBadgeConditionValue = ref(1)
// 预设奖章图标选项
const BADGE_LEN = 6
const badgeIconOptions = []
for (let i = 1; i <= BADGE_LEN; i++) {
badgeIconOptions.push({
label: `奖章${i}`,
value: `/badge-${i}.png`,
icon: `/badge-${i}.png`,
})
}
const conditionTypeOptions = [
{ label: "完成所有题目", value: "all_problems" },
{ label: "完成指定数量题目", value: "problem_count" },
{ label: "达到指定分数", value: "score" },
]
function handleConfirm() {
const data: any = {
name: editBadgeName.value,
description: editBadgeDescription.value,
icon: editBadgeIcon.value,
condition_type: editBadgeConditionType.value,
}
// 只有非"完成所有题目"时才添加条件值
if (editBadgeConditionType.value !== "all_problems") {
data.condition_value = editBadgeConditionValue.value
}
emit("confirm", data)
}
function handleCancel() {
emit("update:show", false)
}
// 当奖章数据变化时,更新表单数据
watch(
() => props.badge,
(newBadge) => {
if (newBadge) {
editBadgeName.value = newBadge.name
editBadgeDescription.value = newBadge.description
editBadgeIcon.value = newBadge.icon
editBadgeConditionType.value = newBadge.condition_type
editBadgeConditionValue.value = newBadge.condition_value
}
},
{ immediate: true },
)
</script>
<template>
<n-modal
:show="show"
preset="card"
title="编辑奖章"
style="width: 500px"
@update:show="emit('update:show', $event)"
>
<n-form v-if="badge">
<n-form-item label="奖章名称" required>
<n-input v-model:value="editBadgeName" placeholder="请输入奖章名称" />
</n-form-item>
<n-form-item label="描述">
<n-input
v-model:value="editBadgeDescription"
type="textarea"
placeholder="奖章描述"
required
/>
</n-form-item>
<n-form-item label="图标" required>
<n-flex align="center" gap="small">
<div
v-for="option in badgeIconOptions"
:key="option.value"
@click="editBadgeIcon = option.value"
:style="{
width: '60px',
height: '60px',
border:
editBadgeIcon === option.value
? '2px solid #1890ff'
: '1px solid #d9d9d9',
borderRadius: '4px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor:
editBadgeIcon === option.value ? '#f0f8ff' : 'transparent',
}"
>
<n-image
:src="option.icon"
width="50"
height="50"
object-fit="cover"
style="border-radius: 2px"
preview-disabled
/>
</div>
</n-flex>
</n-form-item>
<n-flex align="center">
<n-form-item label="获得条件">
<n-select
style="width: 200px"
v-model:value="editBadgeConditionType"
:options="conditionTypeOptions"
/>
</n-form-item>
<n-form-item
label="条件值"
v-if="editBadgeConditionType !== 'all_problems'"
>
<n-input-number
style="width: 120px"
v-model:value="editBadgeConditionValue"
placeholder="条件值"
/>
</n-form-item>
</n-flex>
</n-form>
<template #footer>
<n-flex justify="end">
<n-button @click="handleCancel">取消</n-button>
<n-button type="primary" @click="handleConfirm">确认</n-button>
</n-flex>
</template>
</n-modal>
</template>

View File

@@ -0,0 +1,100 @@
<script setup lang="ts">
import type { ProblemSetProblem } from "utils/types"
interface Props {
show: boolean
problem: ProblemSetProblem | null
}
interface Emits {
(e: "update:show", value: boolean): void
(
e: "confirm",
data: {
order: number
is_required: boolean
score: number
hint: string
},
): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const editProblemOrder = ref(0)
const editProblemRequired = ref(true)
const editProblemScore = ref(0)
const editProblemHint = ref("")
function handleConfirm() {
emit("confirm", {
order: editProblemOrder.value,
is_required: editProblemRequired.value,
score: editProblemScore.value,
hint: editProblemHint.value || "",
})
}
function handleCancel() {
emit("update:show", false)
}
// 当问题数据变化时,更新表单数据
watch(
() => props.problem,
(newProblem) => {
if (newProblem) {
editProblemOrder.value = newProblem.order
editProblemRequired.value = newProblem.is_required
editProblemScore.value = newProblem.score
editProblemHint.value = newProblem.hint || ""
}
},
{ immediate: true },
)
</script>
<template>
<n-modal
:show="show"
preset="card"
title="编辑题目"
style="width: 500px"
@update:show="emit('update:show', $event)"
>
<n-form v-if="problem">
<n-form-item label="题目标题">
<n-input :value="problem.problem.title" disabled />
</n-form-item>
<n-form-item label="顺序">
<n-input-number
v-model:value="editProblemOrder"
placeholder="题目在题单中的顺序"
/>
</n-form-item>
<n-form-item label="是否必做">
<n-switch v-model:value="editProblemRequired" />
</n-form-item>
<n-form-item label="分数">
<n-input-number
v-model:value="editProblemScore"
placeholder="题目分数"
/>
</n-form-item>
<n-form-item label="提示">
<n-input
v-model:value="editProblemHint"
type="textarea"
placeholder="题目提示"
/>
</n-form-item>
</n-form>
<template #footer>
<n-flex justify="end">
<n-button @click="handleCancel">取消</n-button>
<n-button type="primary" @click="handleConfirm">确认</n-button>
</n-flex>
</template>
</n-modal>
</template>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import { h } from "vue"
import { NDataTable, NButton, NFlex } from "naive-ui"
import type { ProblemSetProblem } from "utils/types"
interface Props {
problems: ProblemSetProblem[]
}
interface Emits {
(e: "add-problem"): void
(e: "edit-problem", problem: ProblemSetProblem): void
(e: "remove-problem", problemSetProblemId: number): void
}
defineProps<Props>()
defineEmits<Emits>()
</script>
<template>
<div>
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
<h3>题目列表</h3>
<n-button type="primary" @click="$emit('add-problem')">
添加题目
</n-button>
</n-flex>
<n-data-table
:columns="[
{ title: '题目ID', key: 'problem._id', width: 80 },
{ title: '题目标题', key: 'problem.title', minWidth: 200 },
{ title: '顺序', key: 'order', width: 80 },
{
title: '必做',
key: 'is_required',
width: 80,
render: (row) => (row.is_required ? '是' : '否'),
},
{ title: '分数', key: 'score', width: 80 },
{ title: '提示', key: 'hint', minWidth: 200 },
{
title: '操作',
key: 'actions',
width: 160,
render: (row) =>
h('div', { style: 'display: flex; gap: 8px;' }, [
h(
NButton,
{
size: 'small',
type: 'primary',
secondary: true,
onClick: () => $emit('edit-problem', row),
},
{ default: () => '编辑' },
),
h(
NButton,
{
size: 'small',
type: 'error',
secondary: true,
onClick: () => $emit('remove-problem', row.id),
},
{ default: () => '移除' },
),
]),
},
]"
:data="problems"
/>
</div>
</template>

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import { parseTime } from "utils/functions"
import type { ProblemSet } from "utils/types"
interface Props {
problemSet: ProblemSet
}
defineProps<Props>()
</script>
<template>
<n-card title="题单信息" style="margin-bottom: 16px">
<n-descriptions :column="4" bordered>
<n-descriptions-item label="描述">
{{ problemSet.description }}
</n-descriptions-item>
<n-descriptions-item label="创建者">
{{ problemSet.created_by.username }}
</n-descriptions-item>
<n-descriptions-item label="难度">
<n-tag
:type="
problemSet.difficulty === 'Easy'
? 'success'
: problemSet.difficulty === 'Medium'
? 'warning'
: 'error'
"
>
{{
problemSet.difficulty === "Easy"
? "简单"
: problemSet.difficulty === "Medium"
? "中等"
: "困难"
}}
</n-tag>
</n-descriptions-item>
<n-descriptions-item label="状态">
<n-tag
:type="
problemSet.status === 'active'
? 'success'
: problemSet.status === 'archived'
? 'default'
: 'info'
"
>
{{
problemSet.status === "active"
? "活跃"
: problemSet.status === "archived"
? "已归档"
: "草稿"
}}
</n-tag>
</n-descriptions-item>
<n-descriptions-item label="可见">
{{ problemSet.visible ? "是" : "否" }}
</n-descriptions-item>
<n-descriptions-item label="题目数量">
{{ problemSet.problems_count }}
</n-descriptions-item>
<n-descriptions-item label="创建时间">
{{ parseTime(problemSet.create_time, "YYYY-MM-DD HH:mm:ss") }}
</n-descriptions-item>
</n-descriptions>
</n-card>
</template>

View File

@@ -0,0 +1,69 @@
<script setup lang="ts">
import { h } from "vue"
import { NDataTable, NButton, NFlex } from "naive-ui"
import { parseTime } from "utils/functions"
import type { ProblemSetProgress } from "utils/types"
interface Props {
progress: ProblemSetProgress[]
}
interface Emits {
(e: "remove-user", userId: number): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
// 定义表格列
const progressColumns = [
{ title: "用户", key: "user.username", width: 120 },
{
title: "加入时间",
key: "join_time",
width: 180,
render: (row: ProblemSetProgress) =>
parseTime(row.join_time, "YYYY-MM-DD HH:mm:ss"),
},
{ title: "已完成", key: "completed_problems_count", width: 100 },
{ title: "总题目", key: "total_problems_count", width: 100 },
{
title: "进度",
key: "progress_percentage",
width: 100,
render: (row: ProblemSetProgress) =>
`${row.progress_percentage.toFixed(0)}%`,
},
{
title: "是否完成",
key: "is_completed",
width: 100,
render: (row: ProblemSetProgress) => (row.is_completed ? "是" : "否"),
},
{
title: "操作",
key: "actions",
width: 120,
render: (row: ProblemSetProgress) =>
h(
NButton,
{
size: "small",
type: "error",
secondary: true,
onClick: () => emit("remove-user", row.user.id),
},
{ default: () => "移除" },
),
},
]
</script>
<template>
<div>
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
<h3>用户进度</h3>
</n-flex>
<n-data-table :columns="progressColumns" :data="progress" />
</div>
</template>

View File

@@ -0,0 +1,270 @@
<script setup lang="ts">
import { NTabPane, NTabs, NButton, NFlex } from "naive-ui"
import type {
ProblemSet,
ProblemSetProblem,
ProblemSetBadge,
ProblemSetProgress,
} from "utils/types"
import {
getProblemSetDetail,
getProblemSetProblems,
getProblemSetBadges,
getProblemSetProgress,
addProblemToSet,
editProblemInSet,
removeProblemFromSet,
createProblemSetBadge,
editProblemSetBadge,
deleteProblemSetBadge,
removeUserFromProblemSet,
} from "../api"
import ProblemSetInfo from "./components/ProblemSetInfo.vue"
import ProblemManagement from "./components/ProblemManagement.vue"
import BadgeManagement from "./components/BadgeManagement.vue"
import ProgressManagement from "./components/ProgressManagement.vue"
import AddProblemModal from "./components/AddProblemModal.vue"
import EditProblemModal from "./components/EditProblemModal.vue"
import AddBadgeModal from "./components/AddBadgeModal.vue"
import EditBadgeModal from "./components/EditBadgeModal.vue"
const route = useRoute()
const router = useRouter()
const message = useMessage()
const problemSetId = computed(() => Number(route.params.problemSetId))
const problemSet = ref<ProblemSet | null>(null)
const problems = ref<ProblemSetProblem[]>([])
const badges = ref<ProblemSetBadge[]>([])
const progress = ref<ProblemSetProgress[]>([])
// 模态框状态
const showAddProblemModal = ref(false)
const showEditProblemModal = ref(false)
const showAddBadgeModal = ref(false)
const showEditBadgeModal = ref(false)
// 编辑数据
const editingProblem = ref<ProblemSetProblem | null>(null)
const editingBadge = ref<ProblemSetBadge | null>(null)
async function loadProblemSetDetail() {
try {
const res = await getProblemSetDetail(problemSetId.value)
problemSet.value = res.data
} catch (err: any) {
message.error("加载题单详情失败:" + (err.data || "未知错误"))
}
}
async function loadProblems() {
try {
const res = await getProblemSetProblems(problemSetId.value)
problems.value = res.data
} catch (err: any) {
message.error("加载题目列表失败:" + (err.data || "未知错误"))
}
}
async function loadBadges() {
try {
const res = await getProblemSetBadges(problemSetId.value)
badges.value = res.data
} catch (err: any) {
message.error("加载奖章列表失败:" + (err.data || "未知错误"))
}
}
async function loadProgress() {
try {
const res = await getProblemSetProgress(problemSetId.value)
progress.value = res.data
} catch (err: any) {
message.error("加载进度列表失败:" + (err.data || "未知错误"))
}
}
async function handleAddProblem(data: any) {
try {
await addProblemToSet(problemSetId.value, data)
message.success("题目添加成功")
showAddProblemModal.value = false
loadProblems()
loadProblemSetDetail() // 刷新题目数量
} catch (err: any) {
message.error("添加题目失败:" + (err.data || "未知错误"))
}
}
async function handleRemoveProblem(problemSetProblemId: number) {
try {
await removeProblemFromSet(problemSetId.value, problemSetProblemId)
message.success("题目移除成功")
loadProblems()
loadProblemSetDetail() // 刷新题目数量
} catch (err: any) {
message.error("移除题目失败:" + (err.data || "未知错误"))
}
}
async function handleEditProblem(data: any) {
if (!editingProblem.value) return
try {
await editProblemInSet(problemSetId.value, editingProblem.value.id, data)
message.success("题目编辑成功")
showEditProblemModal.value = false
loadProblems()
} catch (err: any) {
message.error("编辑题目失败:" + (err.data || "未知错误"))
}
}
async function handleAddBadge(data: any) {
try {
await createProblemSetBadge(problemSetId.value, data)
message.success("奖章创建成功")
showAddBadgeModal.value = false
loadBadges()
} catch (err: any) {
message.error("创建奖章失败:" + (err.data || "未知错误"))
}
}
async function handleDeleteBadge(badgeId: number) {
try {
await deleteProblemSetBadge(problemSetId.value, badgeId)
message.success("奖章删除成功")
loadBadges()
} catch (err: any) {
message.error("删除奖章失败:" + (err.data || "未知错误"))
}
}
async function handleEditBadge(data: any) {
if (!editingBadge.value) return
try {
await editProblemSetBadge(problemSetId.value, editingBadge.value.id, data)
message.success("奖章编辑成功")
showEditBadgeModal.value = false
loadBadges()
} catch (err: any) {
message.error("编辑奖章失败:" + (err.data || "未知错误"))
}
}
async function handleRemoveUser(userId: number) {
try {
await removeUserFromProblemSet(problemSetId.value, userId)
message.success("用户移除成功")
loadProgress()
} catch (err: any) {
message.error("移除用户失败:" + (err.data || "未知错误"))
}
}
function openAddProblemModal() {
showAddProblemModal.value = true
}
function openAddBadgeModal() {
showAddBadgeModal.value = true
}
function openEditProblemModal(problem: ProblemSetProblem) {
editingProblem.value = problem
showEditProblemModal.value = true
}
function openEditBadgeModal(badge: ProblemSetBadge) {
editingBadge.value = badge
showEditBadgeModal.value = true
}
onMounted(() => {
loadProblemSetDetail()
loadProblems()
loadBadges()
loadProgress()
})
</script>
<template>
<div v-if="problemSet">
<n-flex class="titleWrapper" justify="space-between" align="center">
<h2 class="title">{{ problemSet.title }}</h2>
<n-button
type="primary"
@click="
router.push({
name: 'admin problemset edit',
params: { problemSetId },
})
"
>
编辑题单
</n-button>
</n-flex>
<ProblemSetInfo :problem-set="problemSet" />
<n-tabs type="line">
<n-tab-pane name="problems" tab="题目管理">
<ProblemManagement
:problems="problems"
@add-problem="openAddProblemModal"
@edit-problem="openEditProblemModal"
@remove-problem="handleRemoveProblem"
/>
</n-tab-pane>
<n-tab-pane name="badges" tab="奖章管理">
<BadgeManagement
:badges="badges"
@add-badge="openAddBadgeModal"
@edit-badge="openEditBadgeModal"
@delete-badge="handleDeleteBadge"
/>
</n-tab-pane>
<n-tab-pane name="progress" tab="进度管理">
<ProgressManagement
:progress="progress"
@remove-user="handleRemoveUser"
/>
</n-tab-pane>
</n-tabs>
<!-- 模态框组件 -->
<AddProblemModal
v-model:show="showAddProblemModal"
@confirm="handleAddProblem"
/>
<EditProblemModal
v-model:show="showEditProblemModal"
:problem="editingProblem"
@confirm="handleEditProblem"
/>
<AddBadgeModal v-model:show="showAddBadgeModal" @confirm="handleAddBadge" />
<EditBadgeModal
v-model:show="showEditBadgeModal"
:badge="editingBadge"
@confirm="handleEditBadge"
/>
</div>
</template>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
}
.title {
margin: 0;
}
</style>

View File

@@ -0,0 +1,169 @@
<script setup lang="ts">
import type { CreateProblemSetData, EditProblemSetData } from "utils/types"
import { getProblemSetDetail, createProblemSet, editProblemSet } from "../api"
const route = useRoute()
const router = useRouter()
const message = useMessage()
const problemSetId = computed(() => Number(route.params.problemSetId))
const isEdit = computed(() => !!problemSetId.value)
const formData = ref<CreateProblemSetData & Partial<EditProblemSetData>>({
title: "",
description: "",
difficulty: "Easy",
status: "draft",
visible: false,
end_time: null,
})
const endTimeTimestamp = computed({
get: () =>
formData.value.end_time
? new Date(formData.value.end_time).getTime()
: null,
set: (val: number | null) => {
formData.value.end_time = val ? new Date(val) : null
},
})
const difficultyOptions = [
{ label: "简单", value: "Easy" },
{ label: "中等", value: "Medium" },
{ label: "困难", value: "Hard" },
]
const statusOptions = [
{ label: "活跃", value: "active" },
{ label: "已归档", value: "archived" },
{ label: "草稿", value: "draft" },
]
const loading = ref(false)
async function loadProblemSetDetail() {
if (!isEdit.value) return
try {
const res = await getProblemSetDetail(problemSetId.value)
const data = res.data
formData.value = {
id: data.id,
title: data.title,
description: data.description,
difficulty: data.difficulty,
status: data.status,
visible: data.visible,
end_time: data.end_time ? new Date(data.end_time) : null,
}
} catch (err: any) {
message.error("加载题单详情失败:" + (err.data || "未知错误"))
}
}
async function handleSubmit() {
if (!formData.value.title?.trim()) {
message.error("请输入题单标题")
return
}
if (!formData.value.description?.trim()) {
message.error("请输入题单描述")
return
}
loading.value = true
try {
if (isEdit.value) {
await editProblemSet(formData.value as EditProblemSetData)
message.success("题单更新成功")
} else {
await createProblemSet(formData.value as CreateProblemSetData)
message.success("题单创建成功")
}
router.push({ name: "admin problemset list" })
} catch (err: any) {
message.error(
(isEdit.value ? "更新" : "创建") +
"题单失败:" +
(err.data || "未知错误"),
)
} finally {
loading.value = false
}
}
onMounted(() => {
if (isEdit.value) {
loadProblemSetDetail()
}
})
</script>
<template>
<div>
<h2 class="title">{{ isEdit ? "编辑题单" : "创建题单" }}</h2>
<n-form :model="formData" label-placement="top">
<n-flex>
<n-form-item label="题单标题" required>
<n-input
v-model:value="formData.title"
placeholder="请输入题单标题"
maxlength="200"
show-count
/>
</n-form-item>
<n-form-item label="难度">
<n-select
style="width: 100px"
v-model:value="formData.difficulty"
:options="difficultyOptions"
placeholder="选择难度"
/>
</n-form-item>
<n-form-item label="状态">
<n-select
style="width: 100px"
v-model:value="formData.status"
:options="statusOptions"
placeholder="选择状态"
/>
</n-form-item>
<n-form-item label="截止时间">
<n-date-picker
v-model:value="endTimeTimestamp"
type="datetime"
clearable
placeholder="不设置则无截止时间"
/>
</n-form-item>
<n-form-item v-if="isEdit" label="是否可见">
<n-switch v-model:value="formData.visible" />
</n-form-item>
</n-flex>
<n-form-item label="题单描述" required>
<n-input
v-model:value="formData.description"
type="textarea"
placeholder="请输入题单描述"
:rows="4"
/>
</n-form-item>
<n-form-item>
<n-button type="primary" :loading="loading" @click="handleSubmit">
{{ isEdit ? "更新" : "创建" }}
</n-button>
</n-form-item>
</n-form>
</div>
</template>
<style scoped>
.title {
margin: 0 0 16px 0;
}
</style>

View File

@@ -0,0 +1,212 @@
<script setup lang="ts">
import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination"
import { parseTime } from "utils/functions"
import type { ProblemSetList } from "utils/types"
import { getProblemSetList, toggleProblemSetVisible } from "../api"
import Actions from "./components/Actions.vue"
import { NTag, NSwitch } from "naive-ui"
const total = ref(0)
const problemSets = ref<ProblemSetList[]>([])
interface ProblemSetQuery {
keyword: string
difficulty: string
status: string
}
// 使用分页 composable
const { query, clearQuery } = usePagination<ProblemSetQuery>({
keyword: "",
difficulty: "",
status: "",
})
const difficultyOptions = [
{ label: "全部", value: "" },
{ label: "简单", value: "Easy" },
{ label: "中等", value: "Medium" },
{ label: "困难", value: "Hard" },
]
const statusOptions = [
{ label: "全部", value: "" },
{ label: "活跃", value: "active" },
{ label: "已归档", value: "archived" },
{ label: "草稿", value: "draft" },
]
const columns: DataTableColumn<ProblemSetList>[] = [
{ title: "ID", key: "id", width: 80 },
{ title: "标题", key: "title", minWidth: 200 },
{ title: "描述", key: "description", minWidth: 300, ellipsis: true },
{
title: "创建者",
key: "created_by",
width: 120,
render: (row) => row.created_by.username,
},
{
title: "难度",
key: "difficulty",
width: 100,
render: (row) => {
const difficultyMap = {
Easy: { type: "success" as const, text: "简单" },
Medium: { type: "warning" as const, text: "中等" },
Hard: { type: "error" as const, text: "困难" },
}
const config = difficultyMap[row.difficulty]
return h(
NTag,
{ type: config.type, size: "small" },
{ default: () => config.text },
)
},
},
{
title: "状态",
key: "status",
width: 100,
render: (row) => {
const statusMap = {
active: { type: "success" as const, text: "活跃" },
archived: { type: "default" as const, text: "已归档" },
draft: { type: "info" as const, text: "草稿" },
}
const config = statusMap[row.status]
return h(
NTag,
{ type: config.type, size: "small" },
{ default: () => config.text },
)
},
},
{
title: "创建时间",
key: "create_time",
width: 180,
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
},
{
title: "可见",
key: "visible",
width: 100,
render: (row) =>
h(NSwitch, {
value: row.visible,
size: "small",
rubberBand: false,
onUpdateValue: () => toggleVisible(row.id),
}),
},
{
title: "选项",
key: "actions",
width: 300,
render: (row) =>
h(Actions, {
problemSetId: row.id,
onUpdated: listProblemSets,
}),
},
]
async function listProblemSets() {
if (query.page < 1) query.page = 1
const offset = (query.page - 1) * query.limit
const res = await getProblemSetList(
offset,
query.limit,
query.keyword,
query.difficulty,
query.status,
)
total.value = res.data.total
problemSets.value = res.data.results
}
async function toggleVisible(problemSetId: number) {
await toggleProblemSetVisible(problemSetId)
problemSets.value = problemSets.value.map((it) => {
if (it.id === problemSetId) {
it.visible = !it.visible
}
return it
})
}
onMounted(listProblemSets)
// 监听搜索关键词变化(防抖)
watchDebounced(() => query.keyword, listProblemSets, {
debounce: 500,
maxWait: 1000,
})
// 监听其他查询条件变化
watch(
() => [query.page, query.limit, query.difficulty, query.status],
listProblemSets,
)
</script>
<template>
<n-flex class="titleWrapper" justify="space-between">
<n-flex align="center">
<h2 class="title">题单管理</h2>
<n-button
type="primary"
@click="$router.push({ name: 'admin problemset create' })"
>
新建题单
</n-button>
</n-flex>
<n-flex align="center">
<n-flex align="center">
<span>难度</span>
<n-select
v-model:value="query.difficulty"
:options="difficultyOptions"
placeholder="选择难度"
style="width: 120px"
clearable
/>
</n-flex>
<n-flex align="center">
<span>状态</span>
<n-select
v-model:value="query.status"
:options="statusOptions"
placeholder="选择状态"
style="width: 120px"
clearable
/>
</n-flex>
<n-input
v-model:value="query.keyword"
placeholder="输入标题关键字"
clearable
@clear="clearQuery"
style="width: 200px"
/>
</n-flex>
</n-flex>
<n-data-table striped :columns="columns" :data="problemSets" />
<Pagination
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
</template>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
}
.title {
margin: 0;
}
</style>

View File

@@ -0,0 +1,314 @@
<script setup lang="ts">
import { NButton, NTag } from "naive-ui"
import {
CLASS_NAME_MAX_DIGITS,
CLASS_NAME_MIN_DIGITS,
CLASS_NAME_RE,
} from "utils/constants"
import { parseTime } from "utils/functions"
import type { Server } from "utils/types"
import { useConfigStore } from "shared/store/config"
import { useConfigWebSocket } from "shared/composables/websocket"
import {
deleteJudgeServer,
editWebsite,
getJudgeServer,
getWebsite,
listInvalidTestcases,
pruneInvalidTestcases,
} from "../api"
import { useUserStore } from "shared/store/user"
interface Testcase {
id: string
create_time: string
}
const message = useMessage()
const configStore = useConfigStore()
const userStore = useUserStore()
const { updateConfig } = useConfigWebSocket()
// 确保只有登录用户才能使用WebSocket
watch(
() => userStore.isAuthed,
(isAuthed) => {
if (!isAuthed) {
// 如果用户未登录禁用WebSocket功能
console.warn("用户未登录WebSocket配置更新功能已禁用")
}
},
{ immediate: true },
)
const testcaseColumns: DataTableColumn<Testcase>[] = [
{ title: "测试用例 ID", key: "id" },
{
title: "选项",
key: "delete",
render: (row) =>
h(
NButton,
{ size: "small", onClick: () => deleteTestcase(row.id) },
() => "删除",
),
},
]
const statusMap: {
[key in "normal" | "abnormal"]: { color: "primary" | "error"; label: string }
} = {
normal: { color: "primary", label: "正常" },
abnormal: { color: "error", label: "异常" },
}
const serverColumns: DataTableColumn<Server>[] = [
{
title: "状态",
key: "status",
width: 80,
render: (row) =>
h(
NTag,
{ type: statusMap[row.status].color, size: "small" },
() => statusMap[row.status].label,
),
},
{
title: "选项",
key: "options",
width: 80,
render: (row) =>
h(
NButton,
{
type: "primary",
size: "small",
disabled: row.status === "normal",
onClick: () => delJudgeServer(row.hostname),
},
() => "删除",
),
},
{ title: "主机", key: "hostname", width: 140 },
{
title: "内存占用",
key: "memory_usage",
render: (row) => row.memory_usage + "%",
width: 100,
},
{ title: "IP", key: "ip", width: 140 },
{ title: "判题机版本", key: "judger_version", width: 100 },
{ title: "服务器 URL", key: "service_url", width: 200 },
{
title: "上一次心跳",
key: "last_heartbeat",
render: (row) => parseTime(row.last_heartbeat, "YYYY-MM-DD HH:mm:ss"),
width: 120,
},
{
title: "创建时间",
key: "create_time",
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
width: 120,
},
]
const testcases = ref<Testcase[]>([])
const token = ref("")
const servers = ref<Server[]>([])
const abnormalServers = computed(() =>
servers.value.filter((item) => item.status === "abnormal"),
)
const websiteConfig = reactive({
website_base_url: import.meta.env.PUBLIC_OJ_URL,
website_name: "判题狗",
website_name_shortcut: "判题狗",
website_footer: "所有权归属于徐越,感谢青岛大学开源 OJ 系统,感谢开源社区",
allow_register: true,
submission_list_show_all: true,
class_list: [],
enable_maxkb: true,
})
async function getWebsiteConfig() {
const res = await getWebsite()
websiteConfig.website_base_url = res.data.website_base_url
websiteConfig.website_name = res.data.website_name
websiteConfig.website_name_shortcut = res.data.website_name_shortcut
websiteConfig.website_footer = res.data.website_footer
websiteConfig.allow_register = res.data.allow_register
websiteConfig.submission_list_show_all = res.data.submission_list_show_all
websiteConfig.class_list = res.data.class_list
websiteConfig.enable_maxkb = res.data.enable_maxkb
}
async function saveWebsiteConfig() {
// 班级号要和用户名里 ks 后面那段对得上,位数不对登录页会查不到该班学生。
// 后端 CreateEditWebsiteConfigSerializer 也会拦,这里先报更明确的错
const invalid = websiteConfig.class_list.filter((c) => !CLASS_NAME_RE.test(c))
if (invalid.length) {
message.error(
`班级号 ${invalid.join("、")} 必须是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`,
)
return
}
try {
await editWebsite(websiteConfig)
} catch (err: any) {
message.error("保存失败:" + err.data)
return
}
message.success("网站配置保存成功")
getWebsiteConfig()
configStore.getConfig()
// 通过 WebSocket 广播配置变化,实现实时切换
updateConfig("enable_maxkb", websiteConfig.enable_maxkb)
updateConfig(
"submission_list_show_all",
websiteConfig.submission_list_show_all,
)
}
async function deleteTestcase(id?: string) {
await pruneInvalidTestcases(id)
message.success("删除成功")
getTestcases()
}
async function getTestcases() {
const res = await listInvalidTestcases()
testcases.value = res.data
}
async function getJudgeServerData() {
const res = await getJudgeServer()
token.value = res.data.token
servers.value = res.data.servers
}
async function delJudgeServer(hostname: string) {
await deleteJudgeServer(hostname)
message.success("删除成功")
}
async function deleteAbnormalServers() {
const dels = abnormalServers.value.map((item) =>
deleteJudgeServer(item.hostname),
)
await Promise.all(dels)
message.success("删除成功")
getJudgeServerData()
}
onMounted(() => {
getWebsiteConfig()
getTestcases()
getJudgeServerData()
})
</script>
<template>
<n-card class="box">
<template #header>
<n-flex align="center">
网站设置
<n-button type="primary" size="small" @click="saveWebsiteConfig">
保存
</n-button>
</n-flex>
</template>
<n-form inline label-placement="left">
<n-form-item label="网站 URL">
<n-input class="url" v-model:value="websiteConfig.website_base_url" />
</n-form-item>
<n-form-item label="网站名">
<n-input v-model:value="websiteConfig.website_name" />
</n-form-item>
<n-form-item label="网站简称">
<n-input v-model:value="websiteConfig.website_name_shortcut" />
</n-form-item>
</n-form>
<n-form label-placement="left">
<n-form-item label="班级列表">
<n-flex vertical size="small">
<n-dynamic-tags v-model:value="websiteConfig.class_list" />
<n-text depth="3" style="font-size: 12px">
{{ CLASS_NAME_MIN_DIGITS }}~{{ CLASS_NAME_MAX_DIGITS }}
位数字 2512510要和用户名里 ks 后面那段一致
</n-text>
</n-flex>
</n-form-item>
</n-form>
<n-flex align="center">
<n-flex align="center">
<span>是否允许注册</span>
<n-switch v-model:value="websiteConfig.allow_register" />
</n-flex>
<n-flex align="center">
<span>显示所有提交</span>
<n-switch v-model:value="websiteConfig.submission_list_show_all" />
</n-flex>
<n-flex align="center">
<span>启用AI小助手</span>
<n-switch v-model:value="websiteConfig.enable_maxkb" />
</n-flex>
</n-flex>
</n-card>
<n-card class="box">
<template #header>
<n-flex align="center">
判题服务器
<n-button
v-if="abnormalServers.length"
size="small"
type="warning"
@click="deleteAbnormalServers"
>
删除无效服务器
</n-button>
</n-flex>
</template>
<div class="box">
接口凭证 <n-tag size="small">{{ token }}</n-tag>
</div>
<n-data-table
:single-line="false"
striped
:columns="serverColumns"
:data="servers"
/>
</n-card>
<n-card class="box" v-if="testcases.length">
<template #header>
<n-flex align="center">
无效的测试用例
<n-button size="small" type="warning" @click="() => deleteTestcase()">
全部删除
</n-button>
</n-flex>
</template>
<n-data-table
striped
class="table"
:columns="testcaseColumns"
:data="testcases"
/>
</n-card>
</template>
<style scoped>
.url {
width: 200px;
}
.box {
margin-bottom: 16px;
}
.table {
width: 40%;
}
</style>

View File

@@ -0,0 +1,244 @@
<script setup lang="ts">
import { h, onMounted, reactive, ref, watch } from "vue"
import { useRouter } from "vue-router"
import { NButton } from "naive-ui"
import { getRank } from "oj/api"
import Pagination from "shared/components/Pagination.vue"
import { useUserStore } from "shared/store/user"
import { getACRate } from "utils/functions"
import type { Rank } from "utils/types"
import { getBaseInfo, randomUser10 } from "../api"
const userCount = ref(0)
const submissionCount = ref(0)
const contestCount = ref(0)
const userStore = useUserStore()
const router = useRouter()
const showModal = ref(false)
const luckyGuy = ref("")
const isRolling = ref(false)
const rollingNames = ref<string[]>([])
const pulseKey = ref(0)
let rollingTimer: ReturnType<typeof setInterval> | null = null
let rollingStopper: ReturnType<typeof setTimeout> | null = null
const data = ref<Rank[]>([])
const total = ref(0)
const query = reactive({
limit: 10,
page: 1,
classroom: "",
})
const columns: DataTableColumn<Rank>[] = [
{
title: "排名",
key: "index",
width: 80,
align: "center",
render: (_, index) => index + (query.page - 1) * query.limit + 1,
},
{
title: "用户",
key: "username",
width: 200,
render: (row) =>
h(
NButton,
{
text: true,
type: "info",
onClick: () => router.push("/user?name=" + row.user.username),
},
() => row.user.username,
),
},
{ title: "个性签名", key: "mood" },
{ title: "已解决", key: "accepted_number", width: 100 },
{ title: "提交数", key: "submission_number", width: 100 },
{
title: "正确率",
key: "rate",
width: 100,
render: (row) => getACRate(row.accepted_number, row.submission_number),
},
]
onMounted(async () => {
const res = await getBaseInfo()
userCount.value = res.data.user_count
submissionCount.value = res.data.today_submission_count
contestCount.value = res.data.recent_contest_count
})
async function listRanks() {
const offset = (query.page - 1) * query.limit
const res = await getRank(offset, query.limit, 0, query.classroom)
data.value = res.data.results
total.value = res.data.total
}
function stopRolling() {
if (rollingTimer) {
clearInterval(rollingTimer)
rollingTimer = null
}
if (rollingStopper) {
clearTimeout(rollingStopper)
rollingStopper = null
}
isRolling.value = false
}
function startRolling(finalName: string) {
stopRolling()
if (!rollingNames.value.length) return
isRolling.value = true
const interval = 80
const duration = 2000
let index = 0
rollingTimer = setInterval(() => {
luckyGuy.value = rollingNames.value[index % rollingNames.value.length]
index += 1
}, interval)
rollingStopper = setTimeout(() => {
stopRolling()
luckyGuy.value = finalName
pulseKey.value += 1
}, duration)
}
async function getRandom() {
const res = await randomUser10(query.classroom)
const names = (res.data as string[]).map(
(name) => name.split(query.classroom)[1],
)
rollingNames.value = names
const finalName = names[names.length - 1]
startRolling(finalName)
}
async function getRandomModal() {
showModal.value = true
stopRolling()
luckyGuy.value = ""
}
watch(() => query.page, listRanks)
watch(
() => query.limit,
() => {
query.page = 1
listRanks()
},
)
watch(
() => query.classroom,
(v) => {
query.page = 1
if (!v) {
data.value = []
total.value = 0
}
},
)
watch(showModal, (v) => {
if (!v) {
stopRolling()
luckyGuy.value = ""
}
})
</script>
<template>
<n-flex align="center">
<n-avatar round :size="60" :src="userStore.profile?.avatar" />
<h1 class="name">亲爱的管理员{{ userStore.user?.username }}</h1>
</n-flex>
<n-flex>
<h2>
<n-gradient-text type="info"> 总用户数{{ userCount }} </n-gradient-text>
</h2>
<h2>
<n-gradient-text type="error">
今日提交{{ submissionCount }}
</n-gradient-text>
</h2>
<h2>
<n-gradient-text type="warning">
近期比赛{{ contestCount }}
</n-gradient-text>
</h2>
</n-flex>
<n-flex align="center" class="actions">
<span>我猜你要</span>
<n-button @click="router.push('/admin/problem/create')">新题目</n-button>
<n-button @click="router.push('/admin/contest/create')">新比赛</n-button>
<div>
<n-input
style="width: 200px"
clearable
v-model:value="query.classroom"
placeholder="班级前缀"
/>
</div>
<n-button @click="listRanks">用户排名</n-button>
<n-button @click="getRandomModal" v-if="query.classroom">随机抽签</n-button>
<Pagination
class="pagination"
:total="total"
v-model:page="query.page"
v-model:limit="query.limit"
/>
</n-flex>
<n-data-table v-if="data.length" striped :data="data" :columns="columns" />
<n-modal
preset="card"
title="猜猜看幸运儿是谁?"
v-model:show="showModal"
style="width: 400px"
>
<n-flex vertical justify="center" align="center">
<n-h1 :key="pulseKey" class="lucky pulse">{{ luckyGuy }}</n-h1>
<n-button block :disabled="isRolling" @click="getRandom">
{{ luckyGuy ? "再来一次" : "开始抽签" }}
</n-button>
</n-flex>
</n-modal>
</template>
<style scoped>
.name {
font-size: 32px;
margin: 0;
}
.actions {
margin-bottom: 20px;
}
.pagination {
margin: 0;
}
.lucky {
height: 48px;
}
.pulse {
animation: lucky-pulse 0.6s ease-out;
}
@keyframes lucky-pulse {
0% {
transform: scale(0.9);
}
60% {
transform: scale(1.18);
}
100% {
transform: scale(1);
}
}
</style>

View File

@@ -0,0 +1,20 @@
import type { AdminProblem } from "utils/types"
// 把后端的 AdminProblem 塑形成管理端列表项,与请求逻辑解耦。
export function toProblemListItem(result: AdminProblem) {
return {
id: result.id,
_id: result._id,
title: result.title,
username: result.created_by.username,
create_time: result.create_time,
visible: result.visible,
difficulty: result.difficulty,
tags: result.tags,
has_ast_rules: result.has_ast_rules,
allow_flowchart: result.allow_flowchart,
show_flowchart: result.show_flowchart,
// 比赛题目列表接口不返回这个字段
top_reaction: result.top_reaction ?? null,
}
}

View File

@@ -0,0 +1,43 @@
<script lang="ts" setup>
import { deleteTutorial } from "admin/api"
interface Props {
tutorialID: number
}
const props = defineProps<Props>()
const emit = defineEmits(["deleted"])
const router = useRouter()
const message = useMessage()
function goEdit() {
router.push({
name: "admin tutorial edit",
params: { tutorialID: props.tutorialID },
})
}
async function handleDelete() {
try {
await deleteTutorial(props.tutorialID)
message.success("删除成功")
emit("deleted")
} catch (err: any) {
message.error(err.data)
}
}
</script>
<template>
<n-flex>
<n-button size="small" type="success" secondary @click="goEdit">
编辑
</n-button>
<n-popconfirm @positive-click="handleDelete">
<template #trigger>
<n-button size="small" type="error" secondary>删除</n-button>
</template>
确定删除这个教程吗
</n-popconfirm>
</n-flex>
</template>
<style scoped></style>

View File

@@ -0,0 +1,652 @@
<script setup lang="ts">
import type {
Exercise,
ExerciseType,
ExerciseMcqData,
ExerciseSortData,
ExerciseFillData,
ExerciseMatchData,
ExercisePredictData,
ExerciseDebugData,
ExerciseGroupData,
} from "utils/types"
import {
getAdminExercises,
createExercise,
updateExercise,
deleteExercise,
} from "admin/api"
const props = defineProps<{ tutorialId: number }>()
const message = useMessage()
const dialog = useDialog()
const exercises = ref<Exercise[]>([])
const showForm = ref(false)
const editingId = ref<number | null>(null)
const formType = ref<ExerciseType>("mcq")
const formOrder = ref(0)
const mcqQuestion = ref("")
const mcqOptions = ref(["", ""])
const mcqAnswer = ref<number[]>([])
const sortQuestion = ref("")
const sortCode = ref("")
const fillQuestion = ref("")
const fillCode = ref("")
const matchQuestion = ref("")
const matchLeft = ref("")
const matchRight = ref("")
const predictQuestion = ref("")
const predictCode = ref("")
const predictAnswer = ref("")
const debugQuestion = ref("")
const debugCode = ref("")
const debugAnswer = ref<number[]>([])
const debugExplanation = ref("")
const groupQuestion = ref("")
const groupBuckets = ref("")
const groupItems = ref("")
const debugLines = computed(() =>
debugCode.value === "" ? [] : debugCode.value.split("\n"),
)
async function load() {
exercises.value = await getAdminExercises(props.tutorialId)
}
onMounted(load)
function resetForms() {
mcqQuestion.value = ""
mcqOptions.value = ["", ""]
mcqAnswer.value = []
sortQuestion.value = ""
sortCode.value = ""
fillQuestion.value = ""
fillCode.value = ""
matchQuestion.value = ""
matchLeft.value = ""
matchRight.value = ""
predictQuestion.value = ""
predictCode.value = ""
predictAnswer.value = ""
debugQuestion.value = ""
debugCode.value = ""
debugAnswer.value = []
debugExplanation.value = ""
groupQuestion.value = ""
groupBuckets.value = ""
groupItems.value = ""
}
function openCreate() {
editingId.value = null
formType.value = "mcq"
formOrder.value = exercises.value.length
resetForms()
showForm.value = true
}
function openEdit(ex: Exercise) {
editingId.value = ex.id
formType.value = ex.type
formOrder.value = ex.order
resetForms()
if (ex.type === "mcq") {
const d = ex.data as ExerciseMcqData
mcqQuestion.value = d.question
mcqOptions.value = [...d.options]
mcqAnswer.value = [...d.answer]
} else if (ex.type === "sort") {
const d = ex.data as ExerciseSortData
sortQuestion.value = d.question
sortCode.value = d.lines.join("\n")
} else if (ex.type === "fill") {
const d = ex.data as ExerciseFillData
fillQuestion.value = d.question
fillCode.value = d.code
} else if (ex.type === "match") {
const d = ex.data as ExerciseMatchData
matchQuestion.value = d.question
matchLeft.value = d.left.join("\n")
// 按答案顺序还原右列,重存时识别答案保持为顺序对应
matchRight.value = d.answer.map((a) => d.right[a]).join("\n")
} else if (ex.type === "predict") {
const d = ex.data as ExercisePredictData
predictQuestion.value = d.question
predictCode.value = d.code
predictAnswer.value = d.answer.join("\n===\n")
} else if (ex.type === "debug") {
const d = ex.data as ExerciseDebugData
debugQuestion.value = d.question
debugCode.value = d.lines.join("\n")
debugAnswer.value = [...d.answer]
debugExplanation.value = d.explanation ?? ""
} else if (ex.type === "group") {
const d = ex.data as ExerciseGroupData
groupQuestion.value = d.question
groupBuckets.value = d.buckets.join("\n")
groupItems.value = d.items
.map((it, i) => `${it} => ${d.buckets[d.answer[i]]}`)
.join("\n")
}
showForm.value = true
}
function toggleAnswer(i: number) {
const idx = mcqAnswer.value.indexOf(i)
if (idx === -1) mcqAnswer.value.push(i)
else mcqAnswer.value.splice(idx, 1)
}
function toggleDebug(i: number) {
const idx = debugAnswer.value.indexOf(i)
if (idx === -1) debugAnswer.value.push(i)
else debugAnswer.value.splice(idx, 1)
}
function splitLines(text: string): string[] {
return text
.split("\n")
.map((l) => l.trim())
.filter((l) => l !== "")
}
function buildData(): Record<string, unknown> | null {
if (formType.value === "mcq") {
if (mcqAnswer.value.length === 0) {
message.error("请至少勾选一个正确答案")
return null
}
return {
question: mcqQuestion.value || "下面选项中正确是哪个?",
options: mcqOptions.value,
answer: mcqAnswer.value,
}
}
if (formType.value === "sort") {
return {
question: sortQuestion.value || "将下列代码行排列为正确顺序",
lines: sortCode.value.split("\n").filter((l) => l.trim() !== ""),
}
}
if (formType.value === "fill") {
return { question: fillQuestion.value, code: fillCode.value }
}
if (formType.value === "match") {
const left = splitLines(matchLeft.value)
const right = splitLines(matchRight.value)
if (left.length < 2 || left.length !== right.length) {
message.error("左右两列需各至少 2 项且行数相等(按行一一对应)")
return null
}
return {
question: matchQuestion.value || "把左右两列正确连线",
left,
right,
answer: left.map((_, i) => i),
}
}
if (formType.value === "predict") {
if (predictCode.value.trim() === "") {
message.error("请填写代码")
return null
}
const answer = predictAnswer.value
.split(/\n===\n/)
.map((a) => a.replace(/\s+$/, ""))
.filter((a) => a.trim() !== "")
if (answer.length === 0) {
message.error("请填写至少一个正确输出")
return null
}
return {
question: predictQuestion.value || "这段代码会输出什么?",
code: predictCode.value,
answer,
}
}
if (formType.value === "debug") {
const lines = debugCode.value.split("\n")
const answer = debugAnswer.value
.filter((i) => i < lines.length)
.sort((a, b) => a - b)
if (lines.length === 0 || answer.length === 0) {
message.error("请填写代码并勾选至少一行错误")
return null
}
const data: Record<string, unknown> = {
question: debugQuestion.value || "下面代码哪几行有错?",
lines,
answer,
}
if (debugExplanation.value.trim() !== "") {
data.explanation = debugExplanation.value.trim()
}
return data
}
// group
const buckets = splitLines(groupBuckets.value)
if (buckets.length < 2) {
message.error("请至少填写 2 个分组")
return null
}
const items: string[] = []
const answer: number[] = []
for (const line of groupItems.value.split("\n")) {
if (line.trim() === "") continue
const parts = line.split("=>")
if (parts.length !== 2) {
message.error(`项目格式应为「项目 => 分组名」:${line}`)
return null
}
const item = parts[0].trim()
const bucket = buckets.indexOf(parts[1].trim())
if (item === "" || bucket === -1) {
message.error(`项目或分组名无效:${line}`)
return null
}
items.push(item)
answer.push(bucket)
}
if (items.length === 0) {
message.error("请至少填写一个项目")
return null
}
return {
question: groupQuestion.value || "把下列项目归类到正确的分组",
buckets,
items,
answer,
}
}
async function save() {
const data = buildData()
if (data === null) return
try {
if (editingId.value) {
await updateExercise({
id: editingId.value,
type: formType.value,
data,
order: formOrder.value,
})
message.success("练习题已更新")
} else {
await createExercise({
tutorial_id: props.tutorialId,
type: formType.value,
data,
order: formOrder.value,
})
message.success("练习题已创建")
}
showForm.value = false
await load()
} catch (e: any) {
message.error(e.data ?? "保存失败")
}
}
function confirmDelete(id: number) {
dialog.warning({
title: "删除练习题",
content: "此操作不可撤销",
positiveText: "删除",
onPositiveClick: async () => {
await deleteExercise(id)
message.success("已删除")
await load()
},
})
}
function copyPlaceholder(id: number) {
navigator.clipboard.writeText(`[[exercise:${id}]]`)
message.success(`已复制 [[exercise:${id}]]`)
}
const TYPE_NAMES: Record<ExerciseType, string> = {
mcq: "选择题",
sort: "代码排序",
fill: "代码填空",
match: "连线匹配",
predict: "输出预测",
debug: "代码找错",
group: "归类分组",
}
const TYPE_TAGS: Record<
ExerciseType,
"success" | "info" | "warning" | "error" | "primary" | "default"
> = {
mcq: "success",
sort: "info",
fill: "warning",
match: "primary",
predict: "error",
debug: "info",
group: "warning",
}
function typeName(type: ExerciseType) {
return TYPE_NAMES[type] ?? type
}
function typeTagType(type: ExerciseType) {
return TYPE_TAGS[type] ?? "default"
}
</script>
<template>
<div>
<n-flex justify="space-between" align="center" style="margin-bottom: 16px">
<n-text> {{ exercises.length }} 道练习题</n-text>
<n-button type="primary" size="small" @click="openCreate"
>+ 添加练习题</n-button
>
</n-flex>
<n-empty v-if="exercises.length === 0" description="暂无练习题" />
<n-list v-else bordered>
<n-list-item v-for="ex in exercises" :key="ex.id">
<n-flex justify="space-between" align="center">
<div>
<n-tag size="small" :type="typeTagType(ex.type)" :bordered="false">
{{ typeName(ex.type) }}
</n-tag>
<n-text style="margin-left: 10px">
{{ (ex.data as any).question }}
</n-text>
</div>
<n-space :size="8">
<n-tooltip trigger="hover">
<template #trigger>
<n-button size="small" @click="copyPlaceholder(ex.id)">
复制占位符
</n-button>
</template>
[[exercise:{{ ex.id }}]] 粘贴到 Markdown 内容中
</n-tooltip>
<n-button size="small" @click="openEdit(ex)">编辑</n-button>
<n-button size="small" type="error" @click="confirmDelete(ex.id)">
删除
</n-button>
</n-space>
</n-flex>
</n-list-item>
</n-list>
<n-modal
v-model:show="showForm"
:title="editingId ? '编辑练习题' : '新建练习题'"
preset="card"
style="width: 560px"
>
<n-form label-placement="top">
<n-form-item label="题型">
<n-radio-group v-model:value="formType" :disabled="!!editingId">
<n-radio value="mcq">选择题</n-radio>
<n-radio value="sort">代码排序</n-radio>
<n-radio value="fill">代码填空</n-radio>
<n-radio value="match">连线匹配</n-radio>
<n-radio value="predict">输出预测</n-radio>
<n-radio value="debug">代码找错</n-radio>
<n-radio value="group">归类分组</n-radio>
</n-radio-group>
</n-form-item>
<n-form-item label="顺序">
<n-input-number
v-model:value="formOrder"
:min="0"
style="width: 100px"
/>
</n-form-item>
<template v-if="formType === 'mcq'">
<n-form-item label="题目">
<n-input
v-model:value="mcqQuestion"
type="textarea"
:rows="2"
placeholder="下面选项中正确是哪个?"
/>
</n-form-item>
<n-form-item label="选项(勾选所有正确答案)">
<n-space vertical style="width: 100%">
<n-flex
v-for="(opt, i) in mcqOptions"
:key="i"
align="center"
:size="8"
>
<n-checkbox
:checked="mcqAnswer.includes(i)"
@update:checked="toggleAnswer(i)"
/>
<n-input
v-model:value="mcqOptions[i]"
:placeholder="`选项 ${String.fromCharCode(65 + i)}`"
style="flex: 1"
/>
<n-button
size="small"
:disabled="mcqOptions.length <= 2"
@click="
() => {
mcqOptions.splice(i, 1)
mcqAnswer = mcqAnswer
.filter((a) => a !== i)
.map((a) => (a > i ? a - 1 : a))
}
"
>
</n-button>
</n-flex>
<n-button size="small" @click="mcqOptions.push('')">
+ 添加选项
</n-button>
</n-space>
</n-form-item>
</template>
<template v-else-if="formType === 'sort'">
<n-form-item label="题目">
<n-input
v-model:value="sortQuestion"
type="textarea"
:rows="2"
placeholder="将下列代码行排列为正确顺序"
/>
</n-form-item>
<n-form-item label="正确代码(每行将自动成为一道排序项)">
<n-input
v-model:value="sortCode"
type="textarea"
:rows="10"
placeholder="在此粘贴正确的代码,保存后将自动按行拆分并乱序"
style="font-family: &quot;Monaco&quot;"
/>
</n-form-item>
</template>
<template v-else-if="formType === 'fill'">
<n-form-item label="题目说明">
<n-input
v-model:value="fillQuestion"
type="textarea"
:rows="2"
placeholder="例:补全下面的循环语句"
/>
</n-form-item>
<n-form-item label="含空位的代码">
<n-input
v-model:value="fillCode"
type="textarea"
:rows="10"
placeholder="用 {{答案}} 标记空位,多个合法答案用 | 分隔例如for {{i|idx}} in range(10):"
style="font-family: &quot;Monaco&quot;"
/>
</n-form-item>
</template>
<template v-else-if="formType === 'match'">
<n-form-item label="题目说明">
<n-input
v-model:value="matchQuestion"
type="textarea"
:rows="2"
placeholder="例:把函数和它的功能连起来"
/>
</n-form-item>
<n-form-item label="左列(每行一项)">
<n-input
v-model:value="matchLeft"
type="textarea"
:rows="6"
placeholder="print&#10;len&#10;type"
/>
</n-form-item>
<n-form-item label="右列(与左列按行一一对应,保存后右列自动乱序)">
<n-input
v-model:value="matchRight"
type="textarea"
:rows="6"
placeholder="输出内容&#10;返回长度&#10;返回类型"
/>
</n-form-item>
</template>
<template v-else-if="formType === 'predict'">
<n-form-item label="题目说明">
<n-input
v-model:value="predictQuestion"
type="textarea"
:rows="2"
placeholder="例:这段代码会输出什么?"
/>
</n-form-item>
<n-form-item label="代码">
<n-input
v-model:value="predictCode"
type="textarea"
:rows="8"
placeholder="print(1 + 2)"
style="font-family: &quot;Monaco&quot;"
/>
</n-form-item>
<n-form-item
label="正确输出(多个可接受答案之间用单独一行 === 分隔)"
>
<n-input
v-model:value="predictAnswer"
type="textarea"
:rows="4"
placeholder="3"
style="font-family: &quot;Monaco&quot;"
/>
</n-form-item>
</template>
<template v-else-if="formType === 'debug'">
<n-form-item label="题目说明">
<n-input
v-model:value="debugQuestion"
type="textarea"
:rows="2"
placeholder="例:下面代码哪几行有错?"
/>
</n-form-item>
<n-form-item label="代码(每行一项)">
<n-input
v-model:value="debugCode"
type="textarea"
:rows="8"
placeholder="在此粘贴含错误的代码"
style="font-family: &quot;Monaco&quot;"
/>
</n-form-item>
<n-form-item label="勾选错误行">
<n-space vertical style="width: 100%">
<n-empty
v-if="debugLines.length === 0"
description="先填写代码"
size="small"
/>
<n-flex
v-for="(line, i) in debugLines"
:key="i"
align="center"
:size="8"
>
<n-checkbox
:checked="debugAnswer.includes(i)"
@update:checked="toggleDebug(i)"
/>
<n-text style="font-family: Monaco; white-space: pre">
{{ i + 1 }}. {{ line }}
</n-text>
</n-flex>
</n-space>
</n-form-item>
<n-form-item label="错误说明(可选,提交后展示)">
<n-input
v-model:value="debugExplanation"
type="textarea"
:rows="2"
placeholder="例:第 2 行少了冒号"
/>
</n-form-item>
</template>
<template v-else-if="formType === 'group'">
<n-form-item label="题目说明">
<n-input
v-model:value="groupQuestion"
type="textarea"
:rows="2"
placeholder="例:把下面的值归类到正确的类型"
/>
</n-form-item>
<n-form-item label="分组(每行一个分组名)">
<n-input
v-model:value="groupBuckets"
type="textarea"
:rows="4"
placeholder="int&#10;float&#10;str"
/>
</n-form-item>
<n-form-item label="项目(每行「项目 => 分组名」)">
<n-input
v-model:value="groupItems"
type="textarea"
:rows="6"
placeholder="3 => int&#10;3.14 => float&#10;hello => str"
style="font-family: &quot;Monaco&quot;"
/>
</n-form-item>
</template>
</n-form>
<template #footer>
<n-flex justify="end" :size="8">
<n-button @click="showForm = false">取消</n-button>
<n-button type="primary" @click="save">保存</n-button>
</n-flex>
</template>
</n-modal>
</div>
</template>

View File

@@ -0,0 +1,133 @@
<script lang="ts" setup>
import CodeEditor from "shared/components/CodeEditor.vue"
import MarkdownEditor from "shared/components/MarkdownEditor.vue"
import type { Tutorial } from "utils/types"
import { createTutorial, getTutorial, updateTutorial } from "../api"
import ExerciseManager from "./components/ExerciseManager.vue"
interface Props {
tutorialID?: string
}
const route = useRoute()
const router = useRouter()
const message = useMessage()
const props = defineProps<Props>()
const tutorial = reactive<Tutorial>({
id: 0,
title: "",
content: "",
code: "",
is_public: false,
order: 0,
type: "python", // 默认选择 Python
})
const typeOptions = [
{ label: "Python", value: "python" },
{ label: "C 语言", value: "c" },
]
async function init() {
if (!props.tutorialID) {
return
}
const id = parseInt(route.params.tutorialID as string)
const data = await getTutorial(id)
tutorial.id = data.id
tutorial.title = data.title
tutorial.content = data.content
tutorial.code = data.code || ""
tutorial.is_public = data.is_public
tutorial.order = data.order
tutorial.type = data.type || "python"
}
async function submit() {
if (!tutorial.title || !tutorial.content) {
message.error("标题和正文必填")
return
}
try {
if (route.name === "admin tutorial create") {
await createTutorial({
title: tutorial.title,
content: tutorial.content,
code: tutorial.code,
is_public: tutorial.is_public,
order: tutorial.order,
type: tutorial.type,
})
message.success("成功新建教程 💐")
} else {
await updateTutorial(tutorial)
message.success("修改已保存")
}
} catch (err: any) {
message.error(err.data)
}
}
onMounted(init)
</script>
<template>
<h2 class="title">
{{ route.name === "admin tutorial create" ? "新建教程" : "编辑教程" }}
</h2>
<n-form inline>
<n-form-item label="标题">
<n-input class="contestTitle" v-model:value="tutorial.title" />
</n-form-item>
<n-form-item label="语言">
<n-select
v-model:value="tutorial.type"
:options="typeOptions"
class="select"
/>
</n-form-item>
<n-form-item label="顺序">
<n-input-number
style="width: 100px"
v-model:value="tutorial.order"
:min="0"
/>
</n-form-item>
<n-form-item label="可见">
<n-switch v-model:value="tutorial.is_public" />
</n-form-item>
<n-form-item>
<n-button type="primary" @click="submit">保存</n-button>
</n-form-item>
</n-form>
<n-tabs type="line" animated>
<n-tab-pane name="content" tab="教程内容">
<MarkdownEditor v-model:value="tutorial.content" />
</n-tab-pane>
<n-tab-pane name="code" tab="示例代码">
<CodeEditor
v-model:value="tutorial.code"
:language="tutorial.type === 'python' ? 'Python3' : 'C'"
height="400px"
/>
</n-tab-pane>
<n-tab-pane name="exercises" tab="练习题" :disabled="!tutorial.id">
<ExerciseManager v-if="tutorial.id" :tutorial-id="tutorial.id" />
<n-empty v-else description="请先保存教程后再添加练习题" />
</n-tab-pane>
</n-tabs>
</template>
<style scoped>
.title {
margin-top: 0;
}
.select {
width: 100px;
}
.contestTitle {
width: 400px;
}
</style>

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import { NSwitch } from "naive-ui"
import { parseTime } from "utils/functions"
import type { Tutorial } from "utils/types"
import { getTutorialList, setTutorialVisibility } from "../api"
import Actions from "./components/Actions.vue"
const tutorials = ref<{ [key: string]: Tutorial[] }>({
python: [],
c: [],
})
const message = useMessage()
const activeTab = ref("python")
const columns: DataTableColumn<Tutorial>[] = [
{
title: "顺序",
key: "order",
width: 80,
},
{ title: "标题", key: "title", minWidth: 200 },
{
title: "作者",
key: "created_by",
render: (row) => row.created_by?.username,
width: 80,
},
{
title: "创建时间",
key: "created_at",
width: 180,
render: (row) => parseTime(row.created_at!, "YYYY-MM-DD HH:mm:ss"),
},
{
title: "更新时间",
key: "updated_at",
width: 180,
render: (row) => parseTime(row.updated_at!, "YYYY-MM-DD HH:mm:ss"),
},
{
title: "可见",
key: "is_public",
width: 100,
render: (row) =>
h(NSwitch, {
value: row.is_public,
size: "small",
rubberBand: false,
onUpdateValue: () => toggleVisible(row),
}),
},
{
title: "操作",
key: "actions",
width: 140,
render: (row) =>
h(Actions, { tutorialID: row.id, onDeleted: listTutorials }),
},
]
async function toggleVisible(tutorial: Tutorial) {
tutorial.is_public = !tutorial.is_public
try {
await setTutorialVisibility(tutorial.id, tutorial.is_public)
message.success("更新成功")
} catch (err: any) {
message.error(err.data)
tutorial.is_public = !tutorial.is_public
}
}
async function listTutorials() {
tutorials.value = await getTutorialList()
}
onMounted(listTutorials)
</script>
<template>
<n-flex align="center" class="titleWrapper">
<h2 class="title">教程列表</h2>
<n-button
type="primary"
@click="$router.push({ name: 'admin tutorial create' })"
>
新建
</n-button>
</n-flex>
<n-tabs v-model:value="activeTab" type="line" animated>
<n-tab-pane name="python" tab="Python">
<n-data-table striped :columns="columns" :data="tutorials.python" />
</n-tab-pane>
<n-tab-pane name="c" tab="C 语言">
<n-data-table striped :columns="columns" :data="tutorials.c" />
</n-tab-pane>
</n-tabs>
</template>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
}
.title {
margin: 0;
}
</style>

View File

@@ -0,0 +1,55 @@
<script lang="ts" setup>
import { editUser } from "admin/api"
import type { User } from "utils/types"
interface Props {
user: User
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: "deleteUser", value: number[]): void
(e: "userBanned", value: User): void
(e: "openEditModal", value: User): void
(e: "resetPassword", value: User): void
}>()
async function banUser() {
props.user.is_disabled = !props.user.is_disabled
await editUser(props.user)
emit("userBanned", props.user)
}
</script>
<template>
<n-flex>
<n-button
size="small"
type="error"
secondary
@click="$emit('resetPassword', props.user)"
>
重置密码
</n-button>
<n-button
size="small"
type="primary"
secondary
@click="$emit('openEditModal', props.user)"
>
编辑
</n-button>
<n-button
size="small"
secondary
:type="props.user.is_disabled ? 'info' : 'error'"
@click="banUser"
>
{{ props.user.is_disabled ? "解封" : "封号" }}
</n-button>
<n-popconfirm @positive-click="$emit('deleteUser', [props.user.id])">
<template #trigger>
<n-button size="small" secondary type="warning">删除</n-button>
</template>
确定删除这个用户吗删除后无法恢复
</n-popconfirm>
</n-flex>
</template>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants"
import { getUserRole } from "utils/functions"
import type { User } from "utils/types"
import TextCopy from "shared/components/TextCopy.vue"
interface Props {
user: User
}
const props = defineProps<Props>()
const isNotRegularUser = computed(
() => props.user.admin_type !== USER_TYPE.REGULAR_USER,
)
</script>
<template>
<n-flex align="center">
<n-tag v-if="props.user.is_disabled" type="error" size="small">
封号中
</n-tag>
<n-tag
v-if="isNotRegularUser"
:type="getUserRole(props.user.admin_type).type"
size="small"
>
{{ getUserRole(props.user.admin_type).label }}
</n-tag>
<n-tag
size="small"
v-if="
props.user.admin_type === USER_TYPE.STUDENT_ADMIN ||
props.user.admin_type === USER_TYPE.TEACHER_ADMIN
"
>
{{
props.user.problem_permission === PROBLEM_PERMISSION.ALL
? "全部"
: "仅自己"
}}
</n-tag>
<TextCopy>{{ props.user.username }}</TextCopy>
</n-flex>
</template>

View File

@@ -0,0 +1,107 @@
<script setup lang="ts">
import {
CLASS_NAME_MAX_DIGITS,
CLASS_NAME_MAX_VALUE,
CLASS_NAME_MIN_DIGITS,
CLASS_NAME_MIN_VALUE,
} from "utils/constants"
import { importUsers } from "../api"
const message = useMessage()
const prefix = ref(0)
const rawInput = ref("")
const [loading, toggleLoading] = useToggle()
const users = shallowRef<string[][]>([])
function generateUsers() {
if (!rawInput.value || !rawInput.value.trim()) {
message.info("请填写相关内容")
return false
}
// 后端 get_class_name 只认合法位数的班级号,位数不对会整批拒绝导入,
// 这里先拦一道,省得填完一屏用户名才被打回来
if (
prefix.value &&
(prefix.value < CLASS_NAME_MIN_VALUE || prefix.value > CLASS_NAME_MAX_VALUE)
) {
message.error(
`班级号 ${prefix.value} 必须是 ${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位数字`,
)
return false
}
let className = !!prefix.value ? `ks${prefix.value}` : ""
rawInput.value = rawInput.value.trim()
const inputs = rawInput.value.split("\n")
users.value = inputs.map((u, i) => {
const username = className + u
let password = ""
for (let j = 0; j < 6; j++) {
password += "123456789".charAt(Math.floor(Math.random() * 9))
}
const realName = u
const email = `${className}.${i + 1}@example.com`
return [username, password, email, realName]
})
return true
}
async function uploadUsers() {
try {
toggleLoading(true)
await importUsers(users.value)
message.success("用户已上传成功")
const csv = users.value.map((u) => u.join(",")).join("\n")
const hiddenElement = document.createElement("a")
hiddenElement.href = "data:text/csv;charset=utf-8," + encodeURI(csv)
hiddenElement.target = "_blank"
hiddenElement.download = prefix.value + ".csv"
hiddenElement.click()
hiddenElement.remove()
} catch (err: any) {
message.error("上传失败:" + err.data)
} finally {
toggleLoading(false)
}
}
async function submit() {
const ok = generateUsers()
if (ok) {
uploadUsers()
}
}
</script>
<template>
<n-space>
<n-flex vertical>
<n-flex align="center">
<div style="width: 18px; font-size: 1.2rem">ks</div>
<n-input-number
style="width: 170px"
v-model:value="prefix"
clearable
:max="CLASS_NAME_MAX_VALUE"
:min="0"
:placeholder="`班级号(${CLASS_NAME_MIN_DIGITS}~${CLASS_NAME_MAX_DIGITS} 位)`"
/>
</n-flex>
<n-input
type="textarea"
class="inputArea"
placeholder="每行一个用户名"
v-model:value="rawInput"
/>
<n-button type="warning" :loading="loading" @click="submit">
确定导入
</n-button>
</n-flex>
</n-space>
</template>
<style scoped>
.inputArea {
width: 200px;
height: 500px;
}
</style>

View File

@@ -0,0 +1,359 @@
<script setup lang="ts">
import { DataTableRowKey, SelectOption } from "naive-ui"
import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination"
import { parseTime } from "utils/functions"
import type { User } from "utils/types"
import {
deleteUsers,
editUser,
getUserList,
importUsers,
resetPassword,
} from "../api"
import Actions from "./components/Actions.vue"
import Name from "./components/Name.vue"
import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants"
import { useRouteQuery } from "@vueuse/router"
import TextCopy from "shared/components/TextCopy.vue"
const message = useMessage()
interface UserQuery {
keyword: string
type: string
orderBy: string
}
// 使用分页 composable
const { query, clearQuery } = usePagination<UserQuery>({
keyword: useRouteQuery("keyword", "").value,
type: useRouteQuery("type", "").value,
orderBy: useRouteQuery("orderBy", "").value,
})
const total = ref(0)
const users = ref<User[]>([])
const userEditing = ref<User | null>(null)
const adminOptions = [
{ label: "全部用户", value: "" },
{ label: "学生管理员", value: USER_TYPE.STUDENT_ADMIN },
{ label: "教师管理员", value: USER_TYPE.TEACHER_ADMIN },
{ label: "超级管理员", value: USER_TYPE.SUPER_ADMIN },
]
const sortOptions = [
{ label: "默认排序", value: "" },
{ label: "最近登录", value: "-last_login" },
]
const [create, toggleCreate] = useToggle(false)
const password = ref("")
const userIDs = ref<DataTableRowKey[]>([])
const rowKey = (row: User) => row.id
const columns: DataTableColumn<User>[] = [
{ type: "selection" },
{ title: "ID", key: "id", width: 80 },
{
title: "用户名",
key: "username",
width: 220,
render: (row) => h(Name, { user: row }),
},
{
title: "密码",
key: "raw_password",
width: 100,
render: (row) => h(TextCopy, () => row.raw_password),
},
{
title: "创建时间",
key: "create_time",
width: 200,
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
},
{
title: "上次登录",
key: "last_login",
width: 200,
render: (row) =>
row.last_login
? parseTime(row.last_login, "YYYY-MM-DD HH:mm:ss")
: "从未登录",
},
{
title: "真名",
key: "real_name",
width: 100,
render: (row) => h(TextCopy, () => row.real_name),
},
{ title: "邮箱", key: "email", width: 200 },
{
key: "actions",
title: "选项",
width: 280,
render: (row) =>
h(Actions, {
user: row,
onDeleteUser: onDeleteUsers,
onUserBanned,
onOpenEditModal,
onResetPassword,
}),
},
]
const options: SelectOption[] = [
{ label: "普通", value: USER_TYPE.REGULAR_USER },
{ label: "学生管理员", value: USER_TYPE.STUDENT_ADMIN },
{ label: "教师管理员", value: USER_TYPE.TEACHER_ADMIN },
{ label: "超级管理员", value: USER_TYPE.SUPER_ADMIN },
]
const problemPermissionOptions: SelectOption[] = [
{ label: "无权限", value: PROBLEM_PERMISSION.NONE },
{ label: "仅管理自己创建", value: PROBLEM_PERMISSION.OWN },
{ label: "管理全部题目", value: PROBLEM_PERMISSION.ALL },
]
async function listUsers() {
if (query.page < 1) query.page = 1
const offset = (query.page - 1) * query.limit
const res = await getUserList(
offset,
query.limit,
query.type,
query.keyword,
query.orderBy,
)
total.value = res.data.total
users.value = res.data.results
}
function chooseUsers(rowKeys: DataTableRowKey[]) {
userIDs.value = rowKeys
}
async function onDeleteUsers(userIDs: DataTableRowKey[] | Ref<number[]>) {
await deleteUsers(toRaw(userIDs) as number[])
listUsers()
}
async function onResetPassword(user: User) {
const res = await resetPassword(user.id)
message.success(`${user.username}】的密码已重置成【${res.data}`)
users.value = users.value.map((it) => {
if (it.id === user.id && user.admin_type === USER_TYPE.REGULAR_USER) {
it.raw_password = res.data
}
return it
})
}
async function onUserBanned(user: User) {
users.value = users.value.map((it) => {
if (it.id === user.id) {
it.is_disabled = user.is_disabled
}
return it
})
}
function createNewUser() {
toggleCreate(true)
userEditing.value = {
id: 0,
username: "",
real_name: "",
email: "",
admin_type: "Student Admin",
problem_permission: "None",
create_time: new Date(),
last_login: new Date(),
open_api: false,
is_disabled: false,
password: "",
}
password.value = ""
}
function onOpenEditModal(user: User) {
userEditing.value = user
password.value = ""
}
function onCloseEditModal() {
userEditing.value = null
password.value = ""
toggleCreate(false)
}
async function handleEditUser() {
if (!userEditing.value) return
if (password.value && password.value.length < 6) {
message.error("密码长度不得小于 6")
return
}
// http 拦截器只对 login-required / permission-denied 自动弹提示,
// 其余业务错误(比如班级号位数不对)不接住就什么都不显示
try {
if (create.value) {
const newUser = [
[
userEditing.value.username,
password.value,
userEditing.value.email,
userEditing.value.real_name,
],
]
await importUsers(newUser)
listUsers()
} else {
const user = Object.assign(userEditing.value, {
password: password.value,
})
await editUser(user)
}
} catch (err: any) {
message.error("保存失败:" + err.data)
return
}
userEditing.value = null
password.value = ""
toggleCreate(false)
}
onMounted(listUsers)
// 监听搜索关键词变化(防抖)
watchDebounced(() => query.keyword, listUsers, { debounce: 500, maxWait: 1000 })
// 监听其他查询条件变化
watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
</script>
<template>
<n-flex class="titleWrapper" justify="space-between">
<n-flex>
<h2 class="title">用户列表</h2>
<n-button type="primary" @click="createNewUser">新建</n-button>
<n-button @click="$router.push({ name: 'admin user generate' })">
导入
</n-button>
</n-flex>
<n-flex>
<n-popconfirm
v-if="userIDs.length"
@positive-click="onDeleteUsers(userIDs)"
>
<template #trigger>
<n-button type="warning">删除</n-button>
</template>
确定删除选中的用户吗删除后无法恢复
</n-popconfirm>
<n-flex align="center">
<n-select
v-model:value="query.orderBy"
:options="sortOptions"
placeholder="排序方式"
style="width: 120px"
/>
<n-select
v-model:value="query.type"
:options="adminOptions"
placeholder="选择用户类型"
style="width: 120px"
/>
<div>
<n-input
style="width: 200px"
v-model:value="query.keyword"
clearable
@clear="clearQuery"
/>
</div>
</n-flex>
</n-flex>
</n-flex>
<n-data-table
:data="users"
:columns="columns"
striped
:row-key="rowKey"
@update:checked-row-keys="chooseUsers"
/>
<Pagination
:total="total"
v-model:limit="query.limit"
v-model:page="query.page"
/>
<n-modal
:mask-closable="false"
:show="!!userEditing"
preset="card"
:title="create ? '新建用户' : '编辑用户'"
style="width: 700px"
@close="onCloseEditModal"
>
<n-form label-placement="left" v-if="userEditing">
<n-grid :cols="2" :x-gap="16">
<n-form-item-gi :span="1" label="用户">
<n-input v-model:value="userEditing.username" />
</n-form-item-gi>
<n-form-item-gi :span="1" label="真名">
<n-input v-model:value="userEditing.real_name" />
</n-form-item-gi>
<n-form-item-gi v-if="!create" :span="1" label="班级">
<n-input v-model:value="userEditing.class_name" />
</n-form-item-gi>
<n-form-item-gi :span="1" label="邮箱">
<n-input v-model:value="userEditing.email" />
</n-form-item-gi>
<n-form-item-gi v-if="!create" :span="1" label="类型">
<n-select v-model:value="userEditing.admin_type" :options="options" />
</n-form-item-gi>
<n-form-item-gi
:span="1"
label="密码"
label-style="color: red; font-weight: bold"
>
<n-input v-model:value="password" />
</n-form-item-gi>
<n-form-item-gi
v-if="
!create &&
(userEditing.admin_type === USER_TYPE.STUDENT_ADMIN ||
userEditing.admin_type === USER_TYPE.TEACHER_ADMIN)
"
:span="1"
label="出题权限"
>
<n-select
v-model:value="userEditing.problem_permission"
:options="problemPermissionOptions"
/>
</n-form-item-gi>
<n-form-item-gi v-if="!create" :span="1" label="是否封禁">
<n-switch v-model:value="userEditing.is_disabled">封号</n-switch>
</n-form-item-gi>
</n-grid>
<n-flex justify="end">
<n-button @click="onCloseEditModal">取消</n-button>
<n-button type="primary" @click="handleEditUser">保存</n-button>
</n-flex>
</n-form>
</n-modal>
</template>
<style scoped>
.titleWrapper {
margin-bottom: 16px;
}
.title {
margin: 0;
}
</style>

16
apps/web/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly PUBLIC_ENV: string
readonly PUBLIC_MAXKB_URL: string
readonly PUBLIC_OJ_URL: string
readonly PUBLIC_CODE_URL: string
readonly PUBLIC_JUDGE0_URL: string
readonly PUBLIC_ICONIFY_URL: string
readonly PUBLIC_SIGNALING_URL: string
readonly PUBLIC_WS_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

29
apps/web/src/index.css Normal file
View File

@@ -0,0 +1,29 @@
body {
height: 100vh;
}
.md-editor-dark {
--md-bk-color: var(--n-body-color) !important;
}
.md-editor-dark div.vuepress-theme {
--md-theme-color: var(--n-text-color) !important;
}
.oj-mermaid-surface {
box-sizing: border-box;
padding: 18px;
overflow: auto;
border: 1px solid rgba(148, 163, 184, 0.24);
border-radius: 8px;
}
.oj-mermaid-surface > svg {
max-width: 100%;
}
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}

92
apps/web/src/main.ts Normal file
View File

@@ -0,0 +1,92 @@
import { addAPIProvider } from "@iconify/vue"
import { createPinia } from "pinia"
import { createRouter, createWebHistory } from "vue-router"
import { STORAGE_KEY } from "utils/constants"
import storage from "utils/storage"
import App from "./App.vue"
import { admins, ojs } from "./routes"
const router = createRouter({
history: createWebHistory(),
routes: [ojs, admins],
})
const pinia = createPinia()
// 创建 app 并安装插件
const app = createApp(App)
app.use(pinia)
app.use(router)
// 现在可以安全地使用 Store
import { useAuthModalStore } from "./shared/store/authModal"
import { useUserStore } from "./shared/store/user"
const authStore = useAuthModalStore()
router.beforeEach(async (to, from, next) => {
// 检查是否需要认证
if (to.matched.some((record) => record.meta.requiresAuth)) {
if (!storage.get(STORAGE_KEY.AUTHED)) {
authStore.openLoginModal()
next("/")
return
}
}
// 检查权限
if (
to.matched.some(
(record) =>
record.meta.requiresSuperAdmin ||
record.meta.requiresTeacherAdmin ||
record.meta.requiresProblemPermission,
)
) {
if (!storage.get(STORAGE_KEY.AUTHED)) {
authStore.openLoginModal()
next("/")
return
}
const userStore = useUserStore()
if (!userStore.user) {
try {
await userStore.getMyProfile()
} catch (error) {
next("/")
return
}
}
if (to.matched.some((record) => record.meta.requiresSuperAdmin)) {
if (!userStore.isSuperAdmin) {
next("/")
return
}
} else if (to.matched.some((record) => record.meta.requiresTeacherAdmin)) {
if (!userStore.isTeacherOrAbove) {
next("/")
return
}
} else if (
to.matched.some((record) => record.meta.requiresProblemPermission)
) {
if (!userStore.hasProblemPermission) {
next("/")
return
}
}
}
next()
})
app.mount("#app")
if (!!import.meta.env.PUBLIC_ICONIFY_URL) {
addAPIProvider("", {
resources: [import.meta.env.PUBLIC_ICONIFY_URL],
})
}

1
apps/web/src/mermaid-legacy.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module "mermaid-legacy"

View File

@@ -0,0 +1,27 @@
import http from "utils/http"
import type {
Achievement,
AchievementSummary,
PendingAchievement,
} from "utils/types"
export function getAchievements(name?: string) {
return http.get<{ username: string; achievements: Achievement[] }>(
"achievements",
{ params: name ? { name } : {} },
)
}
export function getAchievementSummary(name?: string) {
return http.get<AchievementSummary>("achievements/summary", {
params: name ? { name } : {},
})
}
export function getPendingAchievements() {
return http.get<PendingAchievement[]>("achievements/pending")
}
export function markAchievementsRead(ids: number[]) {
return http.post("achievements/pending", { ids })
}

View File

@@ -0,0 +1,134 @@
<script setup lang="ts">
import AchievementIcon from "shared/components/AchievementIcon.vue"
import { useRarityColor } from "shared/composables/rarity"
import { RARITY_COLOR, RARITY_LABEL } from "utils/constants"
import type { Achievement } from "utils/types"
const props = defineProps<{ achievement: Achievement }>()
// 边框用原色tag 里的文字用跟主题走的那套
const rarityTextColor = useRarityColor()
// 隐藏且未解锁:后端已把名称/描述/图标和条件三件套都遮成 ??? 和 null
// 这里只负责不要把 null 渲染出来,也不要画出会泄露门槛的进度条
const masked = computed(
() => props.achievement.hidden && !props.achievement.unlocked,
)
// 获得率低于 5% 的加稀有闪光边框
const isRare = computed(
() => props.achievement.unlock_rate > 0 && props.achievement.unlock_rate < 5,
)
// 只有"越多越好"的成就画进度条。lte 类(如最短 AC 代码 ≤ 50 字符)
// 画成百分比毫无意义,改成直接显示当前最好成绩
const showProgressBar = computed(
() =>
!masked.value &&
!props.achievement.unlocked &&
props.achievement.operator === "gte" &&
props.achievement.threshold !== null,
)
const showBestSoFar = computed(
() =>
!masked.value &&
!props.achievement.unlocked &&
props.achievement.operator === "lte" &&
props.achievement.threshold !== null,
)
const percent = computed(() => {
const { progress, threshold } = props.achievement
if (threshold === null || threshold <= 0) return 100
return Math.min(100, Math.round(((progress ?? 0) / threshold) * 100))
})
const unlockDate = computed(() => {
const { unlock_time, backfilled } = props.achievement
// 补发的记录不显示具体日期:一次补发会给几百人盖上同一个时间戳
if (backfilled || !unlock_time) return "已获得"
return `${new Date(unlock_time).toLocaleDateString()} 获得`
})
</script>
<template>
<n-card
size="small"
:class="{ locked: !achievement.unlocked, rare: isRare }"
:style="{ borderColor: RARITY_COLOR[achievement.rarity] }"
>
<n-thing>
<template #avatar>
<AchievementIcon :icon="achievement.icon" :size="32" />
</template>
<template #header>
<n-flex align="center" :size="8">
<n-text strong>{{ achievement.name }}</n-text>
<n-tag
size="tiny"
:color="{
borderColor: RARITY_COLOR[achievement.rarity],
textColor: rarityTextColor[achievement.rarity],
}"
>
{{ RARITY_LABEL[achievement.rarity] }}
</n-tag>
</n-flex>
</template>
<template #description>
<n-text depth="3">{{ achievement.description }}</n-text>
</template>
<n-flex align="center" :size="8" :wrap="false">
<template v-if="achievement.unlocked">
<n-text depth="3" class="nowrap">{{ unlockDate }}</n-text>
<n-text depth="3" class="nowrap">
{{ achievement.unlock_rate }}% 的人获得
</n-text>
</template>
<template v-else-if="showProgressBar">
<n-progress
style="flex: 1"
type="line"
:percentage="percent"
:height="6"
:show-indicator="false"
/>
<n-text depth="3" class="nowrap">
{{ achievement.progress ?? 0 }} / {{ achievement.threshold }}
</n-text>
</template>
<template v-else-if="showBestSoFar">
<n-text depth="3" class="nowrap">
目标 {{ achievement.threshold }}
</n-text>
<n-text v-if="achievement.progress !== null" depth="3" class="nowrap">
当前最好 {{ achievement.progress }}
</n-text>
</template>
<n-text v-else depth="3" class="nowrap">
{{ achievement.unlock_rate }}% 的人获得
</n-text>
</n-flex>
</n-thing>
</n-card>
</template>
<style scoped>
.nowrap {
white-space: nowrap;
}
.locked {
filter: grayscale(1);
opacity: 0.55;
}
.rare {
box-shadow: 0 0 12px rgba(125, 211, 252, 0.55);
}
</style>

View File

@@ -0,0 +1,227 @@
<script setup lang="ts">
import { getAchievements, getAchievementSummary } from "oj/achievement/api"
import { getUserBadges } from "oj/api"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useRarityColor } from "shared/composables/rarity"
import type {
Achievement,
AchievementRarity,
AchievementSummary,
} from "utils/types"
import AchievementCard from "./components/AchievementCard.vue"
interface UserBadge {
id: number
earned_time: string
badge: {
id: number
name: string
description: string
icon: string
}
// 奖章来自哪个题单,接口在 UserBadgeSerializer 里带出来
problemset: {
id: number
title: string
} | null
}
const route = useRoute()
const name = computed(() => (route.query.name as string) || undefined)
// 标签和进度条同色,整行读作一个单位
const rarityColor = useRarityColor()
const achievements = ref<Achievement[]>([])
const summary = ref<AchievementSummary | null>(null)
// 白金排最前,青铜垫底:稀有的先亮相,接口给的顺序是反的
const RARITY_RANK: Record<AchievementRarity, number> = {
platinum: 0,
gold: 1,
silver: 2,
bronze: 3,
}
const rarities = computed(() =>
[...(summary.value?.rarity ?? [])].sort(
(a, b) => RARITY_RANK[a.rarity] - RARITY_RANK[b.rarity],
),
)
const badges = ref<UserBadge[]>([])
const { isDesktop } = useBreakpoints()
const tab = ref("all")
const loading = ref(true)
const filtered = computed(() => {
if (tab.value === "unlocked")
return achievements.value.filter((a) => a.unlocked)
if (tab.value === "locked")
return achievements.value.filter((a) => !a.unlocked)
return achievements.value
})
async function load() {
loading.value = true
try {
const [list, sum, badgeRes] = await Promise.all([
getAchievements(name.value),
getAchievementSummary(name.value),
getUserBadges(name.value),
])
// http 客户端返回 ApiResponse<T>,真实载荷在 .data 里
achievements.value = list.data.achievements
summary.value = sum.data
badges.value = (badgeRes.data ?? []) as UserBadge[]
} finally {
loading.value = false
}
}
onMounted(load)
watch(name, load)
</script>
<template>
<div class="hall">
<!-- delay 50ms缓存命中时数据几乎立刻回来不闪一下转圈 -->
<n-spin :show="loading" :delay="50" style="min-height: 240px">
<n-card v-if="summary">
<n-flex align="center" :wrap="false" :size="isDesktop ? 32 : 16">
<n-flex vertical align="center" :size="6">
<n-progress
type="circle"
:percentage="summary.percent"
:stroke-width="8"
>
<n-text strong :style="{ fontSize: isDesktop ? '20px' : '14px' }">
{{ summary.percent }}%
</n-text>
</n-progress>
<n-text depth="3" class="nowrap">
已获得 {{ summary.unlocked }} / {{ summary.total }}
</n-text>
</n-flex>
<n-flex vertical :size="8" class="rarity">
<n-flex
v-for="r in rarities"
:key="r.rarity"
align="center"
:wrap="false"
:size="10"
>
<n-text strong :style="{ color: rarityColor[r.rarity] }">
{{ r.label }}
</n-text>
<n-progress
style="flex: 1"
type="line"
:percentage="r.total ? (r.unlocked / r.total) * 100 : 0"
:height="6"
:border-radius="3"
:fill-border-radius="3"
:color="rarityColor[r.rarity]"
:show-indicator="false"
/>
<n-text depth="3" class="nowrap">
{{ r.unlocked }} / {{ r.total }}
</n-text>
</n-flex>
</n-flex>
</n-flex>
</n-card>
<n-tabs v-model:value="tab" type="line" class="tabs">
<n-tab name="all">全部</n-tab>
<n-tab name="unlocked">已获得</n-tab>
<n-tab name="locked">未获得</n-tab>
<n-tab name="badges">题单奖章</n-tab>
</n-tabs>
<template v-if="tab !== 'badges'">
<n-grid
v-if="filtered.length"
responsive="screen"
cols="1 s:2 l:3"
:x-gap="12"
:y-gap="12"
>
<n-gi v-for="a in filtered" :key="a.id">
<AchievementCard :achievement="a" />
</n-gi>
</n-grid>
<!-- 加载中不显示空态不然首屏会闪一下"什么都没有" -->
<n-empty v-else-if="!loading" description="这里还什么都没有" />
</template>
<template v-else>
<n-grid
v-if="badges.length"
responsive="screen"
cols="1 s:2 l:3"
:x-gap="12"
:y-gap="12"
>
<n-gi v-for="b in badges" :key="b.id">
<n-card size="small">
<n-thing
:title="b.badge?.name"
:description="b.badge?.description"
>
<template #avatar v-if="b.badge?.icon">
<n-avatar
:size="40"
:src="b.badge.icon"
color="transparent"
object-fit="contain"
/>
</template>
<n-text v-if="b.problemset" depth="3" class="source">
来自题单
<router-link
:to="{
name: 'problemset',
params: { problemSetId: b.problemset.id },
}"
>
{{ b.problemset.title }}
</router-link>
</n-text>
</n-thing>
</n-card>
</n-gi>
</n-grid>
<n-empty v-else-if="!loading" description="还没有获得任何题单奖章" />
</template>
</n-spin>
</div>
</template>
<style scoped>
.hall {
max-width: 1100px;
margin: 0 auto;
padding: 16px;
}
.rarity {
flex: 1;
max-width: 420px;
}
.nowrap {
white-space: nowrap;
}
.tabs {
margin: 16px 0;
}
.source {
display: block;
margin-top: 6px;
font-size: 13px;
}
.source a {
color: inherit;
text-decoration: underline;
text-underline-offset: 2px;
}
</style>

View File

@@ -0,0 +1,122 @@
<template>
<n-spin :show="aiStore.loading.fetching" :delay="50">
<n-grid :cols="isDesktop ? 2 : 1" :x-gap="20" :y-gap="20">
<n-gi :span="1">
<n-flex vertical size="large">
<n-flex align="center" justify="space-between">
<n-h3 style="margin: 0">请选择时间范围智能分析学习情况</n-h3>
<n-flex align="center">
<n-input
v-if="userStore.isSuperAdmin"
v-model:value="urlUsername"
placeholder="查看指定用户"
clearable
style="width: 140px"
@change="onUsernameChange"
@clear="onUsernameChange"
/>
<n-select
style="width: 140px"
:options="options"
v-model:value="urlDuration"
/>
</n-flex>
</n-flex>
<Overview />
<n-grid :cols="2" :x-gap="20" :y-gap="20">
<n-gi :span="isDesktop ? 1 : 2">
<DifficultyGradeChart />
</n-gi>
<n-gi :span="isDesktop ? 1 : 2">
<TagsRadarChart />
</n-gi>
<n-gi :span="isDesktop ? 1 : 2">
<RankDistributionChart />
</n-gi>
<n-gi :span="isDesktop ? 1 : 2">
<TimeActivityHeatmap />
</n-gi>
</n-grid>
<SolvedTable />
</n-flex>
</n-gi>
<n-gi :span="1">
<n-flex vertical size="large">
<Heatmap />
<ProgressChart />
<EfficiencyChart />
<DurationChart />
<AI v-if="aiStore.detailsData.solved.length > 10" />
</n-flex>
</n-gi>
<n-gi :span="2">
<AI
v-if="
aiStore.detailsData.solved.length > 0 &&
aiStore.detailsData.solved.length <= 10
"
/>
</n-gi>
</n-grid>
</n-spin>
</template>
<script setup lang="ts">
import { useBreakpoints } from "shared/composables/breakpoints"
import { formatISO, sub, type Duration } from "date-fns"
import { useRouteQuery } from "@vueuse/router"
import TagsRadarChart from "./components/TagsRadarChart.vue"
import DifficultyGradeChart from "./components/DifficultyGradeChart.vue"
import TimeActivityHeatmap from "./components/TimeActivityHeatmap.vue"
import RankDistributionChart from "./components/RankDistributionChart.vue"
import Overview from "./components/Overview.vue"
import Heatmap from "./components/Heatmap.vue"
import ProgressChart from "./components/ProgressChart.vue"
import DurationChart from "./components/DurationChart.vue"
import EfficiencyChart from "./components/EfficiencyChart.vue"
import AI from "./components/AI.vue"
import SolvedTable from "./components/SolvedTable.vue"
import { useAIStore } from "../store/ai"
import { useUserStore } from "shared/store/user"
import { DURATION_OPTIONS } from "utils/constants"
const aiStore = useAIStore()
const userStore = useUserStore()
const { isDesktop } = useBreakpoints()
const options = [...DURATION_OPTIONS]
const urlUsername = useRouteQuery<string>("username", "")
const urlDuration = useRouteQuery<string>("duration", "months:6")
// Initialize store synchronously from URL params before watch fires
aiStore.targetUsername = urlUsername.value
aiStore.duration = urlDuration.value
const subOptions = computed<Duration>(() => {
let dur = options.find((it) => it.value === aiStore.duration) ?? options[0]
const x = dur.value!.toString().split(":")
return { [x[0]]: parseInt(x[1]) } as Duration
})
const start = computed(() => formatISO(sub(new Date(), subOptions.value)))
const end = computed(() => formatISO(new Date()))
function onUsernameChange() {
aiStore.targetUsername = urlUsername.value
aiStore.fetchHeatmapData()
aiStore.fetchAnalysisData(start.value, end.value, aiStore.duration)
}
onMounted(() => {
aiStore.fetchHeatmapData()
})
watch(
() => urlDuration.value,
(val) => {
aiStore.duration = val
aiStore.fetchAnalysisData(start.value, end.value, val)
},
{ immediate: true },
)
</script>

View File

@@ -0,0 +1,91 @@
<template>
<n-card size="small">
<template #header>
<div class="cool-title">
<span class="title-text">AI 帮你分析</span>
</div>
</template>
<n-spin :show="aiStore.loading.ai" :delay="50">
<n-flex align="center" justify="center" class="container">
<n-button
v-if="!aiStore.mdContent && !aiStore.loading.ai"
type="primary"
size="large"
:loading="aiStore.loading.fetching"
@click="handleAnalyze"
>
<template #icon>
<Icon icon="ph:sparkle" />
</template>
开始分析
</n-button>
<MdPreview v-else :model-value="aiStore.mdContent" />
</n-flex>
</n-spin>
</n-card>
</template>
<script setup lang="ts">
import { useAIStore } from "oj/store/ai"
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import { Icon } from "@iconify/vue"
const aiStore = useAIStore()
async function handleAnalyze() {
if (aiStore.loading.fetching || aiStore.loading.ai) {
return
}
if (aiStore.pinnedReport) {
await aiStore.simulatePinnedStream()
} else {
await aiStore.fetchAIAnalysis()
}
}
onMounted(async () => {
if (!aiStore.targetUsername) {
await aiStore.fetchPinnedReport()
}
})
</script>
<style scoped>
.cool-title {
position: relative;
padding: 8px 0;
}
.title-text {
font-size: 16px;
font-weight: 700;
background: linear-gradient(45deg, #667eea, #764ba2, #f093fb);
background-size: 200% 200%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: 0.8px;
position: relative;
z-index: 2;
animation: gradient-flow 3s ease infinite;
}
@keyframes gradient-flow {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.container {
min-height: 200px;
}
:deep(.md-editor-preview h1) {
margin-top: 0;
}
</style>

View File

@@ -0,0 +1,144 @@
<template>
<n-card title="难度掌握情况" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">
了解不同难度题目的完成等级分布
</n-text>
</template>
<div style="height: 300px">
<Bar :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import { Bar } from "vue-chartjs"
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
} from "chart.js"
import { useAIStore } from "oj/store/ai"
import type { Grade } from "utils/types"
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
const aiStore = useAIStore()
// 难度和等级的顺序(后端返回的是中文)
const difficultyOrder = ["简单", "中等", "困难"]
const gradeOrder: Grade[] = ["S", "A", "B", "C"]
// 统计每个难度-等级组合的题目数量
const matrix = computed(() => {
const result: { [difficulty: string]: { [grade: string]: number } } = {}
// 初始化矩阵
difficultyOrder.forEach((diff) => {
result[diff] = {}
gradeOrder.forEach((grade) => {
result[diff][grade] = 0
})
})
// 统计数据
aiStore.detailsData.solved.forEach((item) => {
const diff = item.difficulty
const grade = item.grade
if (diff && grade && result[diff]) {
result[diff][grade]++
}
})
return result
})
const show = computed(() => {
return aiStore.detailsData.solved.length > 0
})
// 为每个等级准备数据集
const data = computed(() => {
// 为每个等级生成一个 dataset
const datasets = gradeOrder.map((grade) => {
return {
label: `等级 ${grade}`,
data: difficultyOrder.map((diff) => matrix.value[diff][grade]),
backgroundColor: getGradeColor(grade),
borderColor: getGradeColor(grade),
borderWidth: 1,
}
})
return {
labels: difficultyOrder,
datasets,
}
})
// 根据等级返回对应的颜色
function getGradeColor(grade: Grade): string {
const colors: { [key in Grade]: string } = {
S: "#FF6384",
A: "#FFCE56",
B: "#36A2EB",
C: "#95F204",
}
return colors[grade]
}
const options = {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: "index" as const,
},
scales: {
x: {
stacked: true,
grid: {
display: false,
},
},
y: {
stacked: true,
ticks: {
stepSize: 1,
},
title: {
display: true,
text: "题目数量",
},
},
},
plugins: {
legend: {
display: true,
position: "bottom" as const,
labels: {
boxWidth: 12,
padding: 8,
font: {
size: 11,
},
},
},
title: {
display: false,
},
tooltip: {
callbacks: {
footer: (items: any[]) => {
const total = items.reduce((sum, item) => sum + item.parsed.y, 0)
return `该难度总计: ${total}`
},
},
},
},
}
</script>

View File

@@ -0,0 +1,204 @@
<template>
<n-card :title="title" size="small">
<template #header-extra>
<n-text depth="3" style="font-size: 12px"> 全面评估学习情况 </n-text>
</template>
<div class="chart">
<Chart type="bar" :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import type { ChartData, ChartOptions, TooltipItem } from "chart.js"
import { Chart } from "vue-chartjs"
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
LineElement,
PointElement,
Title,
Tooltip,
Legend,
Colors,
LineController,
} from "chart.js"
import { useAIStore } from "oj/store/ai"
import { parseTime } from "utils/functions"
// 注册混合图表Bar + Line所需的 Chart.js 组件
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
LineElement,
PointElement,
Title,
Tooltip,
Legend,
Colors,
LineController,
)
const aiStore = useAIStore()
const gradeOrder = ["C", "B", "A", "S"] as const
const title = computed(() => {
if (aiStore.duration === "months:2") {
return "过去两个月的每周综合情况"
} else if (aiStore.duration === "months:6") {
return "过去半年的每月综合情况"
} else if (aiStore.duration === "years:1") {
return "过去一年的每月综合情况"
} else {
return "过去四周的综合情况"
}
})
const data = computed<ChartData<"bar" | "line">>(() => {
return {
labels: aiStore.durationData.map((duration) => {
let prefix = "周"
if (duration.unit === "months") {
prefix = "月"
}
return [
parseTime(duration.start, "M月D日"),
parseTime(duration.end, "M月D日"),
].join("")
}),
datasets: [
{
type: "bar",
label: "完成题目数",
data: aiStore.durationData.map((duration) => duration.problem_count),
yAxisID: "y",
order: 2,
},
{
type: "bar",
label: "总提交次数",
data: aiStore.durationData.map((duration) => duration.submission_count),
yAxisID: "y",
order: 2,
},
{
type: "line",
label: "等级",
data: aiStore.durationData.map((duration) =>
duration.grade ? gradeOrder.indexOf(duration.grade) : null,
),
spanGaps: false,
tension: 0.4,
yAxisID: "y1",
barThickness: 10,
order: 1,
borderWidth: 2,
pointRadius: 4,
pointHoverRadius: 6,
},
],
}
})
const options = computed<ChartOptions<"bar" | "line">>(() => {
return {
interaction: {
intersect: false,
},
maintainAspectRatio: false,
scales: {
x: {
grid: {
display: false,
},
},
y: {
ticks: {
stepSize: 1,
},
title: {
display: true,
text: "数量",
},
beginAtZero: true,
},
y1: {
type: "linear",
position: "right",
min: -0.5,
max: gradeOrder.length - 0.5,
ticks: {
stepSize: 1,
callback: (v) => {
const idx = Number(v)
return gradeOrder[idx] || ""
},
},
title: {
display: true,
text: "等级",
},
grid: {
display: false,
},
},
},
plugins: {
legend: {
display: true,
position: "bottom" as const,
labels: {
boxWidth: 12,
padding: 8,
font: {
size: 11,
},
},
},
title: {
display: false,
},
tooltip: {
callbacks: {
label: (ctx: TooltipItem<"bar" | "line">) => {
const dsLabel = ctx.dataset.label || ""
if ((ctx.dataset as any).yAxisID === "y1") {
const idx = Number(ctx.parsed.y)
return `${dsLabel}: ${gradeOrder[idx] || ""}`
}
return `${dsLabel}: ${ctx.formattedValue}`
},
footer: (items: TooltipItem<"bar" | "line">[]) => {
const barItems = items.filter(
(item) => (item.dataset as any).yAxisID === "y",
)
if (barItems.length >= 2) {
const problemCount =
barItems.find((item) => item.dataset.label === "完成题目数")
?.parsed.y || 0
const submissionCount =
barItems.find((item) => item.dataset.label === "总提交次数")
?.parsed.y || 0
const efficiency =
submissionCount > 0
? ((problemCount / submissionCount) * 100).toFixed(1)
: "0"
return `AC率: ${efficiency}%`
}
return ""
},
},
},
},
}
})
</script>
<style scoped>
.chart {
height: 300px;
width: 100%;
}
</style>

View File

@@ -0,0 +1,234 @@
<template>
<n-card :title="title" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">反映刷题质量提升</n-text>
</template>
<div class="chart">
<Chart type="line" :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import type { ChartData, ChartOptions, TooltipItem } from "chart.js"
import { Chart } from "vue-chartjs"
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler,
} from "chart.js"
import { useAIStore } from "oj/store/ai"
import { parseTime } from "utils/functions"
// 注册折线图所需的 Chart.js 组件
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler,
)
const aiStore = useAIStore()
const title = computed(() => {
if (aiStore.duration === "months:2") {
return "过去两个月的每周提交效率"
} else if (aiStore.duration === "months:6") {
return "过去半年的每月提交效率"
} else if (aiStore.duration === "years:1") {
return "过去一年的每月提交效率"
} else {
return "过去四周的提交效率"
}
})
// 判断是否有数据
const show = computed(() => {
return aiStore.durationData.length > 0
})
// 计算提交效率数据
const efficiencyData = computed(() => {
return aiStore.durationData.map((duration) => {
const problemCount = duration.problem_count || 0
const submissionCount = duration.submission_count || 0
// 计算效率:提交次数/完成题目数
// 值越接近1说明一次AC率越高
const efficiency = problemCount > 0 ? submissionCount / problemCount : 0
// AC率AC题目数 / 总提交次数(越高说明提交质量越好)
const onePassRate =
submissionCount > 0 ? (problemCount / submissionCount) * 100 : 0
return {
label: [
parseTime(duration.start, "M月D日"),
parseTime(duration.end, "M月D日"),
].join(""),
efficiency: efficiency,
onePassRate: onePassRate,
problemCount: problemCount,
submissionCount: submissionCount,
}
})
})
// 图表数据
const data = computed<ChartData<"line">>(() => {
const efficiency = efficiencyData.value
return {
labels: efficiency.map((e) => e.label),
datasets: [
{
label: "平均提交次数",
data: efficiency.map((e) => e.efficiency),
borderColor: "rgb(99, 102, 241)",
backgroundColor: "rgba(99, 102, 241, 0.1)",
tension: 0.4,
fill: true,
pointRadius: 5,
pointHoverRadius: 7,
borderWidth: 2.5,
pointBackgroundColor: "rgb(99, 102, 241)",
pointBorderColor: "#fff",
pointBorderWidth: 2,
yAxisID: "y",
},
{
label: "提交AC率",
data: efficiency.map((e) => e.onePassRate),
borderColor: "rgb(34, 197, 94)",
backgroundColor: "rgba(34, 197, 94, 0.1)",
tension: 0.4,
fill: true,
pointRadius: 5,
pointHoverRadius: 7,
borderWidth: 2.5,
pointBackgroundColor: "rgb(34, 197, 94)",
pointBorderColor: "#fff",
pointBorderWidth: 2,
yAxisID: "y1",
},
],
}
})
// 图表配置
const options = computed(() => {
return {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: "index" as const,
intersect: false,
},
scales: {
x: {
ticks: {
maxRotation: 0,
minRotation: 0,
autoSkip: true,
},
},
y: {
type: "linear" as const,
position: "left" as const,
title: {
display: true,
text: "平均提交次数(次/题)",
font: {
size: 13,
},
},
beginAtZero: true,
ticks: {
callback: function (value: string | number) {
return Number(value).toFixed(1)
},
},
},
y1: {
type: "linear" as const,
position: "right" as const,
min: 0,
max: 100,
title: {
display: true,
text: "提交AC率%",
font: {
size: 13,
},
},
ticks: {
callback: function (value: string | number) {
return Number(value).toFixed(0) + "%"
},
},
grid: {
drawOnChartArea: false,
},
},
},
plugins: {
title: {
display: false,
},
tooltip: {
backgroundColor: "rgba(0, 0, 0, 0.8)",
padding: 12,
callbacks: {
label: function (ctx: TooltipItem<"line">) {
const index = ctx.dataIndex
const item = efficiencyData.value[index]
const dsLabel = ctx.dataset.label || ""
if (ctx.datasetIndex === 0) {
// 平均提交次数
return [
`${dsLabel}: ${item.efficiency.toFixed(2)} 次/题`,
`完成题目: ${item.problemCount}`,
`总提交: ${item.submissionCount}`,
]
} else {
// 提交AC率
return [
`${dsLabel}: ${item.onePassRate.toFixed(1)}%`,
`提示: AC题目数 / 总提交次数,越高表示提交质量越好`,
]
}
},
},
},
legend: {
display: true,
position: "bottom" as const,
labels: {
boxWidth: 12,
boxHeight: 12,
padding: 8,
font: {
size: 12,
},
},
},
},
}
})
</script>
<style scoped>
.chart {
height: 300px;
width: 100%;
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<div align="center" style="display: inline-flex; margin: 0 10px">
<img src="/S.png" alt="S Grade" v-if="props.grade === 'S'" />
<img src="/A.png" alt="A Grade" v-if="props.grade === 'A'" />
<img src="/B.png" alt="B Grade" v-if="props.grade === 'B'" />
<img src="/C.png" alt="C Grade" v-if="props.grade === 'C'" />
<n-tooltip trigger="hover">
<template #trigger>
<n-icon size="16" style="cursor: help">
<svg viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H8c0-2.21 1.79-4 4-4s4 1.79 4 4c0 .88-.36 1.68-.93 2.25z"
/>
</svg>
</n-icon>
</template>
<div style="max-width: 300px; line-height: 1.4">
<div style="font-weight: bold; margin-bottom: 8px">等级计算说明</div>
<div>使用加权平均方法计算综合等级</div>
<div> S级 = 4A级 = 3B级 = 2C级 = 1</div>
<div> 根据平均分数确定最终等级</div>
<div>- S级3.5</div>
<div>- A级2.5-3.5</div>
<div>- B级1.5-2.5</div>
<div>- C级<1.5分</div>
</div>
</n-tooltip>
</div>
</template>
<script setup lang="ts">
const props = defineProps<{
grade: "S" | "A" | "B" | "C"
}>()
</script>
<style scoped>
img {
animation: shake 0.5s infinite;
width: 30px;
height: 30px;
}
@keyframes shake {
0% {
transform: translateY(0) scale(1);
}
50% {
transform: translateY(-10px) scale(1.1);
}
100% {
transform: translateY(0) scale(1);
}
}
</style>

View File

@@ -0,0 +1,251 @@
<template>
<n-card title="过去一年的提交热力图" size="small">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">激励持续学习</n-text>
</template>
<n-spin :show="aiStore.loading.heatmap" :delay="50">
<div class="heatmap-container" ref="containerRef">
<svg
:viewBox="`0 0 ${svgWidth} ${svgHeight}`"
preserveAspectRatio="xMinYMin meet"
class="heatmap-svg"
>
<g v-for="label in monthLabels" :key="`${label.text}-${label.x}`">
<text :x="label.x" :y="10" class="label" font-size="10">
{{ label.text }}
</text>
</g>
<g v-for="(day, i) in WEEK_DAYS" :key="i">
<text
:x="0"
:y="MONTH_HEIGHT + i * CELL_TOTAL + 8"
class="label"
font-size="9"
>
{{ day }}
</text>
</g>
<g :transform="`translate(${DAY_WIDTH}, ${MONTH_HEIGHT})`">
<rect
v-for="(cell, i) in cells"
:key="i"
:x="cell.x"
:y="cell.y"
:width="CELL_SIZE"
:height="CELL_SIZE"
:fill="cell.color"
class="cell"
rx="2"
@mouseenter="(e) => showTooltip(e, cell)"
@mouseleave="hideTooltip"
/>
</g>
</svg>
<div v-if="tooltip" class="tooltip" :style="tooltipStyle">
<div class="tooltip-date">{{ tooltip.date }}</div>
<div class="tooltip-count" :class="{ active: tooltip.count > 0 }">
{{ tooltip.text }}
</div>
</div>
</div>
</n-spin>
</n-card>
</template>
<script setup lang="ts">
import { useAIStore } from "oj/store/ai"
import { parseTime } from "utils/functions"
const aiStore = useAIStore()
const containerRef = useTemplateRef<HTMLElement>("containerRef")
const CELL_SIZE = 12
const CELL_GAP = 3
const CELL_TOTAL = CELL_SIZE + CELL_GAP
const DAY_WIDTH = 20
const MONTH_HEIGHT = 20
const RIGHT_PADDING = 5
const COLORS = ["#ebedf0", "#c6e48b", "#7bc96f", "#239a3b", "#196127"]
const WEEK_DAYS = ["", "一", "", "三", "", "五", ""]
const getColor = (count: number) =>
count === 0
? COLORS[0]
: count <= 2
? COLORS[1]
: count <= 4
? COLORS[2]
: count <= 7
? COLORS[3]
: COLORS[4]
const cells = computed(() =>
aiStore.heatmapData.map((item, i) => ({
date: new Date(item.timestamp),
count: item.value,
color: getColor(item.value),
week: Math.floor(i / 7),
day: i % 7,
x: Math.floor(i / 7) * CELL_TOTAL,
y: (i % 7) * CELL_TOTAL,
})),
)
const monthLabels = computed(() => {
const labels: { text: string; x: number }[] = []
let lastMonth = -1
cells.value.forEach((cell, i) => {
const month = cell.date.getMonth()
const isWeekStart = cell.date.getDay() === 0 || i === 0
if (month !== lastMonth && (isWeekStart || cell.date.getDay() <= 3)) {
labels.push({
text: `${month + 1}`,
x: DAY_WIDTH + cell.week * CELL_TOTAL,
})
lastMonth = month
}
})
return labels
})
const svgWidth = computed(
() =>
DAY_WIDTH + Math.ceil(cells.value.length / 7) * CELL_TOTAL + RIGHT_PADDING,
)
const svgHeight = computed(() => MONTH_HEIGHT + 7 * CELL_TOTAL)
interface Cell {
date: Date
count: number
color: string
week: number
day: number
x: number
y: number
}
const tooltip = ref<{
x: number
y: number
date: string
text: string
count: number
} | null>(null)
const tooltipStyle = computed(() => ({
left: `${tooltip.value?.x}px`,
top: `${tooltip.value?.y}px`,
}))
const getTooltipText = (count: number) =>
count === 0 ? "没有提交记录" : `提交了 ${count}`
const showTooltip = (e: MouseEvent, cell: Cell) => {
const rect = (e.target as HTMLElement).getBoundingClientRect()
const containerRect = containerRef.value?.getBoundingClientRect()
if (containerRect) {
tooltip.value = {
x: rect.left - containerRect.left + rect.width / 2,
y: rect.top - containerRect.top - 10,
date: parseTime(cell.date, "YYYY年M月D日"),
text: getTooltipText(cell.count),
count: cell.count,
}
}
}
const hideTooltip = () => {
tooltip.value = null
}
</script>
<style scoped>
.heatmap-container {
width: 100%;
padding: 10px 0;
position: relative;
}
.heatmap-svg {
width: 100%;
height: auto;
display: block;
}
.label {
fill: currentColor;
opacity: 0.7;
}
.cell {
cursor: pointer;
transition: all 0.2s ease;
stroke: rgba(0, 0, 0, 0.05);
stroke-width: 0.5;
}
.cell:hover {
stroke: rgba(0, 0, 0, 0.3);
stroke-width: 1.5;
filter: brightness(0.9);
}
.tooltip {
position: absolute;
transform: translate(-50%, -100%);
background: rgba(0, 0, 0, 0.9);
color: white;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
line-height: 1.5;
pointer-events: none;
z-index: 1000;
white-space: nowrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: fade-in 0.2s ease;
}
.tooltip::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 6px solid transparent;
border-top-color: rgba(0, 0, 0, 0.9);
}
.tooltip-date {
font-weight: 500;
margin-bottom: 2px;
}
.tooltip-count {
opacity: 0.6;
}
.tooltip-count.active {
color: #7bc96f;
opacity: 0.9;
}
@keyframes fade-in {
from {
opacity: 0;
transform: translate(-50%, calc(-100% - 5px));
}
to {
opacity: 1;
transform: translate(-50%, -100%);
}
}
</style>

View File

@@ -0,0 +1,63 @@
<template>
<n-alert
:show-icon="false"
type="success"
v-if="aiStore.detailsData.solved.length"
>
<span>{{ durationLabel }}</span>
<span>你一共解决 </span>
<b class="charming"> {{ aiStore.detailsData.solved.length }} </b>
<span> 道题</span>
<span v-if="aiStore.detailsData.contest_count > 0">
并且参加
<b class="charming"> {{ aiStore.detailsData.contest_count }} </b>
次比赛
</span>
<span>综合评价给到</span>
<Grade :grade="aiStore.detailsData.grade" />
<span>{{ greeting }}</span>
</n-alert>
<n-flex vertical size="large" v-else>
<n-alert type="error" title="你还没有完成任何题目">
开始解题看看你的学习能力吧
</n-alert>
<AI />
</n-flex>
</template>
<script lang="ts" setup>
import Grade from "./Grade.vue"
import { parseTime } from "utils/functions"
import { useAIStore } from "oj/store/ai"
import AI from "./AI.vue"
const aiStore = useAIStore()
const durationLabel = computed(() => {
if (aiStore.duration.includes("hours")) {
return `${parseTime(aiStore.detailsData.start, "HH:mm")} - ${parseTime(aiStore.detailsData.end, "HH:mm")} 期间`
} else if (aiStore.duration.includes("days")) {
return `${parseTime(aiStore.detailsData.end, "MM月DD日")}`
} else if (
aiStore.duration.includes("weeks") ||
aiStore.duration.includes("months")
) {
return `${parseTime(aiStore.detailsData.start, "MM月DD日")} - ${parseTime(aiStore.detailsData.end, "MM月DD日")} 期间`
} else {
return `${parseTime(aiStore.detailsData.start, "YYYY年MM月DD日")} - ${parseTime(aiStore.detailsData.end, "YYYY年MM月DD日")} 期间`
}
})
const greeting = computed(() => {
return {
S: "要不试试高难度题目?",
A: "你很棒,继续保持!",
B: "请再接再厉!",
C: "你还需要努力!",
}[aiStore.detailsData.grade]
})
</script>
<style scoped>
.charming {
font-size: 1.2rem;
}
</style>

View File

@@ -0,0 +1,271 @@
<template>
<n-card :title="title" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">追踪学习成长轨迹</n-text>
</template>
<div class="chart">
<Chart type="line" :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import type { ChartData, ChartOptions, TooltipItem } from "chart.js"
import { Chart } from "vue-chartjs"
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Colors,
Filler,
} from "chart.js"
import { useAIStore } from "oj/store/ai"
import { parseTime } from "utils/functions"
import type { Grade } from "utils/types"
// 注册折线图所需的 Chart.js 组件
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Colors,
Filler,
)
const aiStore = useAIStore()
const gradeOrder = ["C", "B", "A", "S"] as const
const gradeColors: Record<Grade, string> = {
C: "#95F204",
B: "#36A2EB",
A: "#FFCE56",
S: "#FF6384",
}
const title = computed(() => {
if (aiStore.duration === "months:2") {
return "过去两个月的进步曲线"
} else if (aiStore.duration === "months:6") {
return "过去半年的进步曲线"
} else if (aiStore.duration === "years:1") {
return "过去一年的进步曲线"
} else {
return "过去四周的进步曲线"
}
})
// 判断是否有数据
const show = computed(() => {
return aiStore.durationData.length > 0
})
// 计算累计题目数量和等级趋势
const progressData = computed(() => {
let cumulativeCount = 0
let totalWeightedGrade = 0 // 累计加权等级
let totalProblems = 0 // 累计题目总数
return aiStore.durationData.map((duration) => {
const problemCount = duration.problem_count || 0
cumulativeCount += problemCount
// 计算本期等级的权重值
const currentGradeValue = gradeOrder.indexOf(duration.grade || "C")
// 累加加权等级
totalWeightedGrade += currentGradeValue * problemCount
totalProblems += problemCount
// 计算累计平均等级
const avgGradeValue =
totalProblems > 0 ? totalWeightedGrade / totalProblems : 0
return {
label: [
parseTime(duration.start, "M月D日"),
parseTime(duration.end, "M月D日"),
].join(""),
start: parseTime(duration.start, "YYYY-MM-DD"),
end: parseTime(duration.end, "YYYY-MM-DD"),
count: cumulativeCount,
grade: duration.grade || "C",
gradeValue: currentGradeValue,
avgGradeValue: avgGradeValue, // 累计平均等级
problemCount: problemCount,
}
})
})
// 图表数据
const data = computed<ChartData<"line">>(() => {
const progress = progressData.value
return {
labels: progress.map((p) => p.label),
datasets: [
{
type: "line",
label: "累计完成题目",
data: progress.map((p) => p.count),
borderColor: "#4CAF50",
backgroundColor: "rgba(76, 175, 80, 0.1)",
tension: 0.4,
yAxisID: "y",
fill: true,
pointRadius: 5,
pointHoverRadius: 7,
borderWidth: 2.5,
pointBackgroundColor: "#4CAF50",
pointBorderColor: "#fff",
pointBorderWidth: 2,
},
{
type: "line",
label: "累计平均等级",
data: progress.map((p) => p.avgGradeValue),
borderColor: "#FF9800",
backgroundColor: "rgba(255, 152, 0, 0.1)",
tension: 0.4,
yAxisID: "y1",
fill: false,
pointRadius: 5,
pointHoverRadius: 7,
borderWidth: 2.5,
pointBackgroundColor: progress.map((p) => gradeColors[p.grade]),
pointBorderColor: "#fff",
pointBorderWidth: 2,
},
],
}
})
// 图表配置
const options = computed<ChartOptions<"line">>(() => {
return {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: "index",
intersect: false,
},
scales: {
x: {
ticks: {
maxRotation: 0,
minRotation: 0,
autoSkip: true,
maxTicksLimit: 15,
},
},
y: {
type: "linear",
position: "left",
title: {
display: true,
text: "累计题目数",
font: {
size: 14,
},
},
ticks: {
stepSize: 1,
},
beginAtZero: true,
},
y1: {
type: "linear",
position: "right",
min: -0.5,
max: gradeOrder.length - 0.5,
title: {
display: true,
text: "累计平均等级",
font: {
size: 14,
},
},
ticks: {
stepSize: 1,
callback: (v: string | number) => {
const idx = Math.round(Number(v))
return gradeOrder[idx] || ""
},
},
grid: {
drawOnChartArea: false,
},
},
},
plugins: {
title: {
display: false,
},
tooltip: {
backgroundColor: "rgba(0, 0, 0, 0.8)",
padding: 12,
callbacks: {
title: (items: TooltipItem<"line">[]) => {
if (items.length > 0) {
const idx = items[0].dataIndex
const progress = progressData.value[idx]
return progress ? `${progress.start} ~ ${progress.end}` : ""
}
return ""
},
label: (ctx: TooltipItem<"line">) => {
const dsLabel = ctx.dataset.label || ""
const idx = ctx.dataIndex
const progress = progressData.value[idx]
if (!progress) {
return `${dsLabel}: ${ctx.formattedValue}`
}
if ((ctx.dataset as any).yAxisID === "y1") {
// 累计平均等级轴
const avgIdx = Math.round(Number(ctx.parsed.y))
return [
`${dsLabel}: ${gradeOrder[avgIdx] || ""}`,
`本期等级: ${progress.grade}`,
`本期完成: ${progress.problemCount}`,
]
} else {
// 累计题目数轴
return [
`${dsLabel}: ${ctx.formattedValue}`,
`本期完成: ${progress.problemCount}`,
]
}
},
},
},
legend: {
display: true,
position: "bottom" as const,
labels: {
boxWidth: 12,
boxHeight: 12,
padding: 8,
font: {
size: 12,
},
},
},
},
}
})
</script>
<style scoped>
.chart {
height: 300px;
width: 100%;
}
</style>

View File

@@ -0,0 +1,132 @@
<template>
<n-card title="同期解题排名分布" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">
了解同期解题速度和竞争力
</n-text>
</template>
<div style="height: 300px">
<Pie :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import { Pie } from "vue-chartjs"
import { Chart as ChartJS, ArcElement, Title, Tooltip, Legend } from "chart.js"
import { useAIStore } from "oj/store/ai"
ChartJS.register(ArcElement, Title, Tooltip, Legend)
const aiStore = useAIStore()
// 排名区间定义
const RANK_RANGES = [
{ label: "前10%", min: 0, max: 10, color: "#FF6384" },
{ label: "10-30%", min: 10, max: 30, color: "#FFCE56" },
{ label: "30-50%", min: 30, max: 50, color: "#36A2EB" },
{ label: "50-70%", min: 50, max: 70, color: "#4BC0C0" },
{ label: "70%以后", min: 70, max: 100, color: "#9966FF" },
]
// 计算每道题的排名百分位并分类
const rankDistribution = computed(() => {
const distribution = RANK_RANGES.map((range) => ({
...range,
count: 0,
problems: [] as string[],
}))
aiStore.detailsData.solved.forEach((item) => {
const rank = item.period_rank
const acCount = item.period_ac_count
if (rank && acCount && acCount > 0) {
const percentile = (rank / acCount) * 100
// 找到对应的区间
const rangeIndex = RANK_RANGES.findIndex(
(r) => percentile >= r.min && percentile < r.max,
)
if (rangeIndex !== -1) {
distribution[rangeIndex].count++
distribution[rangeIndex].problems.push(
`${item.problem.display_id}: ${item.problem.title}`,
)
}
}
})
return distribution
})
const show = computed(() => {
return aiStore.detailsData.solved.length > 0
})
const data = computed(() => {
return {
labels: RANK_RANGES.map((r) => r.label),
datasets: [
{
label: "题目数量",
data: rankDistribution.value.map((r) => r.count),
backgroundColor: RANK_RANGES.map((r) => r.color),
borderColor: RANK_RANGES.map((r) => r.color),
borderWidth: 1,
},
],
}
})
const options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: "bottom" as const,
labels: {
boxWidth: 12,
boxHeight: 12,
padding: 8,
font: {
size: 12,
},
},
},
title: {
display: false,
},
tooltip: {
callbacks: {
label: (context: any) => {
const count = context.parsed
const total = rankDistribution.value.reduce(
(sum, r) => sum + r.count,
0,
)
const percentage =
total > 0 ? ((count / total) * 100).toFixed(1) : "0.0"
const label = context.label || ""
return `${label}: ${count} 道题 (${percentage}%)`
},
afterLabel: (context: any) => {
const index = context.dataIndex
const problems = rankDistribution.value[index].problems
if (problems.length > 0 && problems.length <= 5) {
return problems
} else if (problems.length > 5) {
return [
...problems.slice(0, 3),
`... 还有 ${problems.length - 3} 道题`,
]
}
return ""
},
},
},
},
}
</script>

View File

@@ -0,0 +1,154 @@
<template>
<n-tabs animated v-if="submissions.length && flowcharts.length">
<n-tab-pane name="代码提交">
<n-data-table
striped
:data="submissions"
:columns="columns"
:max-height="isDesktop ? 1500 : 500"
/>
</n-tab-pane>
<n-tab-pane name="流程图提交">
<n-data-table
striped
:data="flowcharts"
:columns="flowchartsColumns"
:max-height="isDesktop ? 1500 : 500"
/>
</n-tab-pane>
</n-tabs>
<n-data-table
v-else-if="submissions.length"
striped
:data="submissions"
:columns="columns"
:max-height="isDesktop ? 1500 : 500"
/>
<n-data-table
v-else-if="flowcharts.length"
striped
:data="flowcharts"
:columns="flowchartsColumns"
:max-height="isDesktop ? 1500 : 500"
/>
</template>
<script lang="ts" setup>
import { NButton, NTooltip } from "naive-ui"
import TagTitle from "./TagTitle.vue"
import type { FlowchartSummary, SolvedProblem } from "utils/types"
import { useAIStore } from "oj/store/ai"
import { useBreakpoints } from "shared/composables/breakpoints"
import { parseTime } from "utils/functions"
const router = useRouter()
const aiStore = useAIStore()
const { isDesktop } = useBreakpoints()
const submissions = computed(() => aiStore.detailsData.solved)
const flowcharts = computed(() => aiStore.detailsData.flowcharts)
const columns: DataTableColumn<SolvedProblem>[] = [
{
title: "完成的题目",
key: "problem.title",
render: (row) =>
h(
NButton,
{
text: true,
onClick: () => {
if (row.problem.contest_id) {
router.push(
"/contest/" +
row.problem.contest_id +
"/problem/" +
row.problem.display_id,
)
} else {
router.push("/problem/" + row.problem.display_id)
}
},
},
() => {
if (row.problem.contest_id) {
return h(TagTitle, { problem: row.problem })
} else {
return row.problem.display_id + " " + row.problem.title
}
},
),
},
{
title: () => (aiStore.detailsData.class_name ? "班级排名" : "全服排名"),
key: "rank",
width: 100,
align: "center",
render: (row) => row.rank + " / " + row.ac_count,
},
{
title: "同期排名",
key: "period_rank",
width: 100,
align: "center",
render: (row) => row.period_rank + " / " + row.period_ac_count,
},
{
title: () =>
h(NTooltip, null, {
trigger: () =>
h(
"span",
{ style: "cursor:help; border-bottom: 1px dashed" },
"等级",
),
default: () =>
h("div", null, [
h("div", null, "基于同时段排名的百分位:"),
h("div", null, "S — 前 10%"),
h("div", null, "A — 前 35%"),
h("div", null, "B — 前 75%"),
h("div", null, "C — 其余"),
]),
}),
key: "grade",
width: 100,
align: "center",
},
]
const flowchartsColumns: DataTableColumn<FlowchartSummary>[] = [
{
title: "完成的题目",
key: "problem_title",
width: 300,
render: (row) =>
h(
NButton,
{
text: true,
onClick: () => {
router.push("/problem/" + row.problem__id)
},
},
() => `${row.problem__id} ${row.problem_title}`,
),
},
{ title: "提交次数", key: "submission_count", width: 100, align: "center" },
{
title: "最高分",
key: "best",
width: 100,
align: "center",
render: (row) => `${row.best_score} (${row.best_grade})`,
},
{
title: "最新提交时间",
key: "latest_submission_time",
width: 200,
align: "center",
render: (row) => parseTime(row.latest_submission_time),
},
{ title: "平均分", key: "avg_score", width: 100, align: "center" },
]
</script>

View File

@@ -0,0 +1,177 @@
<template>
<n-card title="连续做题统计" size="small">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">激励持续学习</n-text>
</template>
<n-spin :show="aiStore.loading.heatmap" :delay="50">
<n-grid :cols="2" :x-gap="12" :y-gap="12">
<n-gi>
<n-statistic label="当前连续" :value="currentStreak">
<template #suffix>
<span style="font-size: 14px"></span>
<span
v-if="currentStreak > 0"
style="font-size: 20px; margin-left: 4px"
>
🔥
</span>
</template>
</n-statistic>
</n-gi>
<n-gi>
<n-statistic label="最长连续" :value="maxStreak">
<template #suffix>
<span style="font-size: 14px"></span>
<span
v-if="maxStreak >= 7"
style="font-size: 20px; margin-left: 4px"
>
</span>
</template>
</n-statistic>
</n-gi>
<n-gi>
<n-statistic label="本周做题" :value="weekCount">
<template #suffix>
<span style="font-size: 14px"></span>
</template>
</n-statistic>
</n-gi>
<n-gi>
<n-statistic label="本月做题" :value="monthCount">
<template #suffix>
<span style="font-size: 14px"></span>
</template>
</n-statistic>
</n-gi>
</n-grid>
<n-divider style="margin: 12px 0" />
<n-flex vertical size="small">
<n-text depth="2" style="font-size: 12px">
<span v-if="currentStreak === 0"> 开始做题建立学习连续记录 </span>
<span v-else-if="currentStreak < 3"> 继续保持争取连续3天 </span>
<span v-else-if="currentStreak < 7">
很棒继续保持一周连续记录
</span>
<span v-else-if="currentStreak < 30">
太棒了坚持满30天将获得持之以恒成就
</span>
<span v-else>
🎉 恭喜你你已经连续学习 {{ currentStreak }} 真的非常厉害
</span>
</n-text>
</n-flex>
</n-spin>
</n-card>
</template>
<script setup lang="ts">
import { useAIStore } from "oj/store/ai"
const aiStore = useAIStore()
// 计算连续天数
const streakData = computed(() => {
const heatmap = aiStore.heatmapData
if (!heatmap || heatmap.length === 0) {
return {
currentStreak: 0,
maxStreak: 0,
weekCount: 0,
monthCount: 0,
}
}
// 按时间戳排序
const sortedData = [...heatmap].sort((a, b) => a.timestamp - b.timestamp)
let currentStreak = 0
let maxStreak = 0
let tempStreak = 0
let lastDate: Date | null = null
const now = new Date()
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000)
const monthAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000)
let weekCount = 0
let monthCount = 0
// 检查今天是否有做题
const todayData = sortedData.find((item) => {
const itemDate = new Date(item.timestamp)
return (
itemDate.getFullYear() === today.getFullYear() &&
itemDate.getMonth() === today.getMonth() &&
itemDate.getDate() === today.getDate()
)
})
const hasToday = todayData && todayData.value > 0
// 遍历数据计算连续天数
for (const item of sortedData) {
if (item.value > 0) {
const currentDate = new Date(item.timestamp)
// 统计本周和本月
if (currentDate >= weekAgo) {
weekCount++
}
if (currentDate >= monthAgo) {
monthCount++
}
if (lastDate === null) {
tempStreak = 1
} else {
const dayDiff = Math.floor(
(currentDate.getTime() - lastDate.getTime()) / (24 * 60 * 60 * 1000),
)
if (dayDiff === 1) {
tempStreak++
} else {
maxStreak = Math.max(maxStreak, tempStreak)
tempStreak = 1
}
}
lastDate = currentDate
}
}
maxStreak = Math.max(maxStreak, tempStreak)
// 计算当前连续天数(必须包含今天或昨天)
if (lastDate) {
const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000)
const lastDateOnly = new Date(
lastDate.getFullYear(),
lastDate.getMonth(),
lastDate.getDate(),
)
if (
lastDateOnly.getTime() === today.getTime() ||
lastDateOnly.getTime() === yesterday.getTime()
) {
currentStreak = tempStreak
} else {
currentStreak = 0
}
}
return {
currentStreak,
maxStreak,
weekCount,
monthCount,
}
})
const currentStreak = computed(() => streakData.value.currentStreak)
const maxStreak = computed(() => streakData.value.maxStreak)
const weekCount = computed(() => streakData.value.weekCount)
const monthCount = computed(() => streakData.value.monthCount)
</script>

View File

@@ -0,0 +1,21 @@
<template>
<n-flex vertical align="start">
<n-flex align="center">
<n-tag type="info" size="small" :bordered="false">比赛</n-tag>
<span>{{ problem.contest_title }}</span>
</n-flex>
<span>{{ problem.display_id }} {{ problem.title }}</span>
</n-flex>
</template>
<script setup lang="ts">
interface Props {
problem: {
title: string
display_id: string
contest_title: string
contest_id: number
}
}
const props = defineProps<Props>()
</script>

View File

@@ -0,0 +1,154 @@
<template>
<n-card :title="title" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">可视化知识点覆盖面</n-text>
</template>
<div class="chart">
<Radar :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import { Radar } from "vue-chartjs"
import {
Chart as ChartJS,
RadialLinearScale,
PointElement,
LineElement,
Filler,
Tooltip,
Legend,
} from "chart.js"
import { useAIStore } from "oj/store/ai"
// 注册雷达图所需的 Chart.js 组件
ChartJS.register(
RadialLinearScale,
PointElement,
LineElement,
Filler,
Tooltip,
Legend,
)
const aiStore = useAIStore()
const show = computed(() => {
return Object.keys(aiStore.detailsData.tags).length > 0
})
// 最多显示前10个标签避免雷达图过于拥挤
const MAX_TAGS = 10
const title = computed(() => {
const totalTags = Object.keys(aiStore.detailsData.tags).length
const displayTags = Math.min(totalTags, MAX_TAGS)
return `标签雷达图(前${displayTags}个)`
})
// 计算归一化的数据(用于雷达图展示)
const normalizedData = computed(() => {
const tags = aiStore.detailsData.tags
// 按题目数量降序排序取前MAX_TAGS个
const sortedTags = Object.entries(tags)
.sort(([, a], [, b]) => b - a)
.slice(0, MAX_TAGS)
const values = sortedTags.map(([, value]) => value)
const maxValue = Math.max(...values, 1) // 避免除以0
// 归一化到0-100的范围
return sortedTags.map(([label, value]) => ({
label,
value,
normalized: (value / maxValue) * 100,
}))
})
const data = computed(() => {
const tagData = normalizedData.value
return {
labels: tagData.map((item) => item.label),
datasets: [
{
label: "掌握程度",
data: tagData.map((item) => item.normalized),
backgroundColor: "rgba(99, 102, 241, 0.25)",
borderColor: "rgb(99, 102, 241)",
borderWidth: 2.5,
pointBackgroundColor: "rgb(99, 102, 241)",
pointBorderColor: "#fff",
pointHoverBackgroundColor: "#fff",
pointHoverBorderColor: "rgb(99, 102, 241)",
pointRadius: 5,
pointHoverRadius: 7,
pointBorderWidth: 2,
},
],
}
})
const options = computed(() => {
const tagData = normalizedData.value
return {
responsive: true,
maintainAspectRatio: false,
scales: {
r: {
beginAtZero: true,
max: 100,
min: 0,
ticks: {
stepSize: 20,
backdropColor: "transparent",
callback: function (value: string | number) {
return Number(value) + "%"
},
font: {
size: 11,
},
},
grid: {
color: "rgba(0, 0, 0, 0.1)",
circular: true,
},
angleLines: {
color: "rgba(0, 0, 0, 0.1)",
},
pointLabels: {
font: {
size: 13,
weight: 500 as const,
},
padding: 10,
},
},
},
plugins: {
legend: {
display: false,
},
tooltip: {
backgroundColor: "rgba(0, 0, 0, 0.8)",
callbacks: {
label: function (context: any) {
const index = context.dataIndex
const actualValue = tagData[index].value
const percentage = Math.round(Number(context.parsed.r))
return `完成 ${actualValue} 道题 (掌握度 ${percentage}%)`
},
},
},
},
}
})
</script>
<style scoped>
.chart {
height: 300px;
width: 100%;
}
</style>

View File

@@ -0,0 +1,154 @@
<template>
<n-card title="时间活跃度分析" size="small" v-if="show">
<template #header-extra>
<n-text depth="3" style="font-size: 12px">
基于 AC 时间发现解题高峰时段
</n-text>
</template>
<div style="height: 300px">
<Bar :data="data" :options="options" />
</div>
</n-card>
</template>
<script setup lang="ts">
import { Bar } from "vue-chartjs"
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
} from "chart.js"
import { useAIStore } from "oj/store/ai"
ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend)
const aiStore = useAIStore()
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]
const TIME_PERIODS = [
{ label: "凌晨(0-6)", start: 0, end: 6 },
{ label: "上午(6-12)", start: 6, end: 12 },
{ label: "下午(12-18)", start: 12, end: 18 },
{ label: "晚上(18-24)", start: 18, end: 24 },
]
// 统计每个星期几和时间段的做题数量
const activityMatrix = computed(() => {
const matrix: { [weekday: number]: { [period: number]: number } } = {}
// 初始化矩阵
for (let i = 0; i < 7; i++) {
matrix[i] = {}
for (let j = 0; j < TIME_PERIODS.length; j++) {
matrix[i][j] = 0
}
}
// 统计数据
aiStore.detailsData.solved.forEach((item) => {
const date = new Date(item.ac_time)
const weekday = date.getDay() // 0-60是周日
const hour = date.getHours() // 0-23
// 找到对应的时间段
const periodIndex = TIME_PERIODS.findIndex(
(p) => hour >= p.start && hour < p.end,
)
if (periodIndex !== -1) {
matrix[weekday][periodIndex]++
}
})
return matrix
})
const show = computed(() => {
return aiStore.detailsData.solved.length > 0
})
// 为每个时间段准备数据集
const data = computed(() => {
const datasets = TIME_PERIODS.map((period, periodIndex) => {
return {
label: period.label,
data: WEEKDAYS.map(
(_, weekday) => activityMatrix.value[weekday][periodIndex],
),
backgroundColor: getTimePeriodColor(periodIndex),
borderColor: getTimePeriodColor(periodIndex),
borderWidth: 1,
}
})
return {
labels: WEEKDAYS,
datasets,
}
})
// 根据时间段返回对应的颜色
function getTimePeriodColor(periodIndex: number): string {
const colors = [
"#9D9D9D", // 凌晨 - 灰色
"#FFD700", // 上午 - 金色
"#4ECDC4", // 下午 - 青色
"#5B5F97", // 晚上 - 深蓝紫
]
return colors[periodIndex] || "#999"
}
const options = {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: "index" as const,
},
scales: {
x: {
stacked: true,
grid: {
display: false,
},
},
y: {
stacked: true,
ticks: {
stepSize: 1,
},
title: {
display: true,
text: "完成题目数",
},
},
},
plugins: {
legend: {
display: true,
position: "bottom" as const,
labels: {
boxWidth: 12,
padding: 8,
font: {
size: 11,
},
},
},
title: {
display: false,
},
tooltip: {
callbacks: {
footer: (items: any[]) => {
const total = items.reduce((sum, item) => sum + item.parsed.y, 0)
return `当天总计: ${total}`
},
},
},
},
}
</script>

View File

@@ -0,0 +1,14 @@
<template>
<n-flex align="center">
<n-tag type="error" v-if="top">置顶</n-tag>
<span>{{ title }}</span>
</n-flex>
</template>
<script setup lang="ts">
interface Props {
top: boolean
title: string
}
defineProps<Props>()
</script>

View File

@@ -0,0 +1,94 @@
<script lang="ts" setup>
import { NTag } from "naive-ui"
import { getAnnouncement, getAnnouncementList } from "oj/api"
import Pagination from "shared/components/Pagination.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { parseTime } from "utils/functions"
import { renderTableTitle } from "utils/renders"
import type { Announcement } from "utils/types"
import TitleWithTag from "./components/TitleWithTag.vue"
const total = ref(0)
const content = ref("")
const title = ref("")
const [show, toggleShow] = useToggle(false)
const { isDesktop } = useBreakpoints()
const query = reactive({
limit: 10,
page: 1,
})
const columns: DataTableColumn<Announcement>[] = [
{
key: "title",
title: renderTableTitle("公告标题", "streamline-emojis:fire"),
render: (row) => h(TitleWithTag, { title: row.title, top: row.top }),
minWidth: 300,
},
{
key: "tag",
title: renderTableTitle("标签", "fluent-emoji-flat:keycap-hashtag"),
width: 100,
render: (row) => h(NTag, () => row.tag || "公告"),
},
{
key: "create_time",
title: renderTableTitle("发布时间", "fluent-emoji-flat:eight-oclock"),
render: (row) => parseTime(row.create_time),
width: 180,
},
{
key: "username",
title: renderTableTitle("发布人", "streamline-emojis:ghost"),
render: (row) => row.created_by.username,
width: 120,
},
]
function rowProps(row: Announcement) {
return {
style: "cursor: pointer",
onclick: () => showContent(row),
}
}
async function showContent(announcement: Announcement) {
const res = await getAnnouncement(announcement.id)
toggleShow(true)
title.value = announcement.title
content.value = res.data.content
}
const announcements = ref<Announcement[]>([])
async function listAnnouncements() {
const offset = (query.page - 1) * query.limit
const res = await getAnnouncementList(offset, query.limit)
total.value = res.data.total
announcements.value = res.data.results
}
onMounted(listAnnouncements)
watch(query, listAnnouncements, { deep: true })
</script>
<template>
<n-data-table
:bordered="false"
:data="announcements"
:columns="columns"
:row-props="rowProps"
/>
<Pagination
v-model:limit="query.limit"
v-model:page="query.page"
:total="total"
/>
<n-modal
v-model:show="show"
preset="card"
:style="{ maxWidth: isDesktop && '70vw', maxHeight: '80vh' }"
:content-style="{ overflow: 'auto' }"
:title="title"
>
<div v-html="content"></div>
</n-modal>
</template>

435
apps/web/src/oj/api.ts Normal file
View File

@@ -0,0 +1,435 @@
import http from "utils/http"
import { filterResult } from "oj/transforms"
import type {
Exercise,
Problem,
ReactionKey,
ReactionState,
Submission,
SubmissionListPayload,
SubmitCodePayload,
} from "utils/types"
export function getWebsiteConfig() {
return http.get("website")
}
export async function getProblemList(
offset = 0,
limit = 10,
searchParams: any = {},
) {
const res = await http.get<{ results: Problem[]; total: number }>("problem", {
params: { paging: true, offset, limit, ...searchParams },
})
return {
results: res.data.results.map(filterResult),
total: res.data.total,
}
}
export function getAuthors(all = false) {
return http.get("problem/author", {
params: {
all: all ? "1" : "0",
},
})
}
export function getRandomProblemID() {
return http.get("pickone")
}
export function getProblem(problemID: string, contestID: string) {
const endpoint = !!contestID ? "contest/problem" : "problem"
return http.get(endpoint, {
params: {
problem_id: problemID,
contest_id: contestID,
},
})
}
export function getProblemBeatRate(problemID: number) {
return http.get("problem/beat_count", { params: { problem_id: problemID } })
}
export function getSubmission(id: string) {
return http.get<Submission>("submission", {
params: { id },
})
}
export function submitCode(data: SubmitCodePayload) {
return http.post("submission", data)
}
export function formatCode(data: { code: string; language: string }) {
return http.post<{ code: string }>("format_code", data)
}
export function getSubmissions(params: Partial<SubmissionListPayload>) {
const endpoint = !!params.contest_id ? "contest_submissions" : "submissions"
return http.get(endpoint, { params })
}
export function getRankOfProblem(problem_id: string) {
return http.get("user_problem_rank", { params: { problem_id: problem_id } })
}
export function getTodaySubmissionCount(language?: string) {
return http.get("submissions/today_count", { params: { language } })
}
export function adminRejudge(id: string) {
return http.get("admin/submission/rejudge", {
params: { id },
})
}
export function getSubmissionStatistics(
duration: { start?: string; end: string },
problemID?: string,
username?: string,
) {
return http.get("admin/submission/statistics", {
params: {
...duration,
problem_id: problemID,
username,
},
})
}
export function getRank(
offset: number,
limit: number,
n: number,
username?: string,
) {
return http.get("user_rank", {
params: { offset, limit, rule: "acm", username, n },
})
}
export function getActivityRank(start: string) {
return http.get("user_activity_rank", {
params: { start },
})
}
export function getClassRank(grade?: number | null) {
return http.get("class_rank", {
params: { grade },
})
}
export function getUserClassRank(
scope?: "all" | "window",
offset?: number,
limit?: number,
) {
return http.get("user_class_rank", { params: { scope, offset, limit } })
}
export function getClassPK(
classNames: string[],
startTime?: string,
endTime?: string,
) {
const payload: any = {
class_name: classNames,
}
if (startTime) {
payload.start_time = startTime
}
if (endTime) {
payload.end_time = endTime
}
return http.post("class_pk", payload)
}
export function getContestList(query: {
offset: number
limit: number
keyword: string
status: string
tag: string
}) {
return http.get("contests", { params: query })
}
export function getContest(id: string) {
return http.get("contest", { params: { id } })
}
export function getContestAccess(id: string) {
return http.get("contest/access", { params: { contest_id: id } })
}
export function checkContestPassword(contestID: string, password: string) {
return http.post("contest/password", {
contest_id: contestID,
password,
})
}
export async function getContestProblems(contestID: string) {
const res = await http.get<Problem[]>("contest/problem", {
params: { contest_id: contestID },
})
return res.data.map(filterResult)
}
export function getContestRank(
contestID: string,
query: { limit: number; offset: number },
) {
return http.get("contest_rank", {
params: {
contest_id: contestID,
...query,
},
})
}
export function uploadAvatar(file: File) {
const form = new window.FormData()
form.append("image", file)
return http.post("upload_avatar", form, {
headers: { "content-type": "multipart/form-data" },
})
}
export function updateProfile(data: { real_name: string; mood: string }) {
return http.put("profile", data)
}
export function getAnnouncementList(offset = 0, limit = 10) {
return http.get("announcement", { params: { limit, offset } })
}
export function getAnnouncement(id: number) {
return http.get("announcement", { params: { id } })
}
export function createMessage(data: {
recipient: number
message: string
submission: string
}) {
return http.post("message", data)
}
export function getMessageList(offset = 0, limit = 10) {
return http.get("message", { params: { limit, offset } })
}
export function getReaction(problemID: number) {
return http.get<ReactionState>("reaction", {
params: { problem_id: problemID },
})
}
export function setReaction(problemID: number, type: ReactionKey) {
return http.post<ReactionState>("reaction", {
problem_id: problemID,
type,
})
}
// TODO: 这个API有问题
export function refreshUserProblemDisplayIds() {
return http.get("profile/fresh_display_id")
}
export function getMetrics(userid: number) {
return http.get("metrics", { params: { userid } })
}
export function getTutorial(id: number) {
return http.get("tutorial", { params: { id } })
}
export function getTutorials(type: "python" | "c") {
return http.get("tutorials", { params: { type } })
}
export function getAIDetailData(start: string, end: string, username?: string) {
return http.get("ai/detail", { params: { start, end, username } })
}
export function getAIDurationData(
end: string,
duration: string,
username?: string,
) {
return http.get("ai/duration", { params: { end, duration, username } })
}
export function getAIHeatmapData(username?: string) {
return http.get("ai/heatmap", { params: username ? { username } : {} })
}
export function getAILoginSummary() {
return http.get("ai/login_summary")
}
export function getAIPinnedReport() {
return http.get("ai/pinned")
}
// ==================== 相似题目推荐 ====================
export function getSimilarProblems(problemId: string) {
return http.get("problem/similar", { params: { problem_id: problemId } })
}
export interface YearlyACData {
year: number
total: number
accepted: number
ac_rate: number
}
export function getProblemYearlyAC(problemId: string) {
return http.get<YearlyACData[]>("problem/yearly_ac", {
params: { problem_id: problemId },
})
}
// ==================== 流程图相关API ====================
export function submitFlowchart(data: {
problem_id: number
mermaid_code: string
flowchart_data: any // 这个是压缩之后的,元数据太长了
}) {
return http.post("flowchart/submission", data)
}
export function getFlowchartSubmission(id: string) {
return http.get("flowchart/submission", {
params: { id },
})
}
export function getFlowchartSubmissions(params: {
username?: string
problem_id?: string
myself?: string
offset?: number
limit?: number
today?: string
grade?: string
}) {
return http.get("flowchart/submissions", { params })
}
export function getFlowchartStatistics(
duration: { start?: string; end: string },
problemID?: string,
username?: string,
) {
return http.get("admin/flowchart/statistics", {
params: {
...duration,
problem_id: problemID,
username,
},
})
}
export function retryFlowchartSubmission(submissionId: string) {
return http.post("flowchart/submission/retry", {
submission_id: submissionId,
})
}
export function getCurrentProblemFlowchartSubmission(problemId: number) {
return http.get("flowchart/submission/current", {
params: { problem_id: problemId },
})
}
export function getFlowchartSubmissionDetail(problemId: number, page = 0) {
return http.get("flowchart/submission/detail", {
params: { problem_id: problemId, page },
})
}
// ==================== 题单相关API ====================
export function getProblemSetList(
offset = 0,
limit = 10,
keyword = "",
difficulty = "",
status = "",
) {
return http.get("problemset", {
params: {
offset,
limit,
keyword,
difficulty,
status,
},
})
}
export function getProblemSetDetail(id: number) {
return http.get(`problemset/${id}`)
}
export function getProblemSetProblems(problemSetId: number) {
return http.get(`problemset/${problemSetId}/problems`)
}
export function joinProblemSet(problemSetId: number) {
return http.post("problemset/progress", {
problemset_id: problemSetId,
})
}
export function updateProblemSetProgress(
problemSetId: number,
problemId: number,
submissionId: string,
) {
return http.put("problemset/progress", {
problemset_id: problemSetId,
problem_id: problemId,
submission_id: submissionId,
})
}
// 获取用户徽章列表
export function getUserBadges(username?: string) {
return http.get("user/badges", { params: username ? { username } : {} })
}
// 获取题单徽章列表
export function getProblemSetBadges(problemSetId: number) {
return http.get(`problemset/${problemSetId}/badges`)
}
// 获取题单用户进度列表
export function getProblemSetUserProgress(
problemSetId: number,
params?: {
limit?: number
offset?: number
class_name?: string
completion_status?: "" | "completed" | "in_progress" | "not_started"
},
) {
return http.get(`problemset/${problemSetId}/users_progress`, { params })
}
export async function getExercises(tutorialId: number): Promise<Exercise[]> {
const res = await http.get<Exercise[]>("exercises", {
params: { tutorial_id: tutorialId },
})
return res.data
}

1260
apps/web/src/oj/class/pk.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,73 @@
import { ref, provide, inject } from "vue"
/**
* 同步状态管理 composable
* 使用 provide/inject 模式在组件树中共享状态
*/
export interface SyncStatusState {
hadConnection: boolean
otherUser?: { name: string; isSuperAdmin: boolean }
lastLeftUser?: { name: string; isSuperAdmin: boolean } // 保存离开之人的信息
}
// 提供/注入的 key
export const SYNC_STATUS_KEY = Symbol("syncStatus")
/**
* 创建同步状态实例
* 每次调用创建新的状态实例
*/
export function createSyncStatus() {
const otherUser = ref<{ name: string; isSuperAdmin: boolean }>()
const hadConnection = ref(false)
const lastLeftUser = ref<{ name: string; isSuperAdmin: boolean }>()
const setOtherUser = (user?: { name: string; isSuperAdmin: boolean }) => {
// 如果之前有其他用户,现在没有了,说明用户离开了
if (otherUser.value && !user) {
lastLeftUser.value = otherUser.value
}
otherUser.value = user
if (user) {
hadConnection.value = true
}
}
const reset = () => {
otherUser.value = undefined
hadConnection.value = false
lastLeftUser.value = undefined
}
return {
otherUser,
hadConnection,
lastLeftUser,
setOtherUser,
reset,
}
}
/**
* 提供同步状态到子组件
* 在父组件中调用
*/
export function provideSyncStatus() {
const syncStatus = createSyncStatus()
provide(SYNC_STATUS_KEY, syncStatus)
return syncStatus
}
/**
* 注入同步状态
* 在子组件中调用,获取父组件提供的状态
*/
export function injectSyncStatus() {
const syncStatus =
inject<ReturnType<typeof createSyncStatus>>(SYNC_STATUS_KEY)
if (!syncStatus) {
throw new Error("syncStatus must be provided by a parent component")
}
return syncStatus
}

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import type { ContestRank } from "utils/types"
interface Props {
rank: ContestRank
}
const props = defineProps<Props>()
const router = useRouter()
function goto() {
router.push({
name: "contest submissions",
query: { username: props.rank.user.username },
})
}
</script>
<template>
{{ rank.accepted_number }} /
<n-button text type="primary" @click="goto">
{{ rank.submission_number }}
</n-button>
</template>
<style scoped></style>

View File

@@ -0,0 +1,42 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { useContestStore } from "oj/store/contest"
import { parseTime } from "utils/functions"
import ContestType from "shared/components/ContestType.vue"
const contestStore = useContestStore()
</script>
<template>
<n-popover
v-if="contestStore.contest"
placement="bottom-end"
:show-arrow="false"
>
<template #trigger>
<n-button>
<template #icon>
<Icon icon="streamline-emojis:exclamation-mark"></Icon>
</template>
比赛信息
</n-button>
</template>
<div v-html="contestStore.contest.description"></div>
<n-descriptions bordered label-placement="left" :column="1">
<n-descriptions-item label="开始时间">
{{
parseTime(contestStore.contest.start_time, "YYYY年M月D日 HH:mm:ss")
}}
</n-descriptions-item>
<n-descriptions-item label="结束时间">
{{ parseTime(contestStore.contest.end_time, "YYYY年M月D日 HH:mm:ss") }}
</n-descriptions-item>
<n-descriptions-item label="比赛类型">
<ContestType :contest="contestStore.contest" />
</n-descriptions-item>
<n-descriptions-item label="发起人">
{{ contestStore.contest.created_by.username }}
</n-descriptions-item>
</n-descriptions>
</n-popover>
</template>

View File

@@ -0,0 +1,56 @@
<script setup lang="ts">
import { useContestStore } from "oj/store/contest"
import { useBreakpoints } from "shared/composables/breakpoints"
import { ContestStatus } from "utils/constants"
const route = useRoute()
const router = useRouter()
const contestStore = useContestStore()
const { isDesktop } = useBreakpoints()
const contestMenuVisible = computed(() => {
if (contestStore.isContestAdmin) return true
if (!contestStore.isPrivate) {
return contestStore.contestStatus !== ContestStatus.not_started
}
return contestStore.access
})
function goto(name: string) {
router.push({ name: "contest " + name })
}
function getCurrentType(name: string): "primary" | "default" {
if (route.name === "contest " + name) return "primary"
return "default"
}
const options: DropdownOption[] = [
{ label: "比赛题目", key: "problems" },
{ label: "提交信息", key: "submissions" },
{ label: "比赛排名", key: "rank" },
]
</script>
<template>
<div v-if="contestMenuVisible">
<n-flex v-if="isDesktop">
<n-button :type="getCurrentType('problems')" @click="goto('problems')">
比赛题目
</n-button>
<n-button
:type="getCurrentType('submissions')"
@click="goto('submissions')"
>
提交信息
</n-button>
<n-button :type="getCurrentType('rank')" @click="goto('rank')">
比赛排名
</n-button>
</n-flex>
<n-dropdown v-else :options="options" @select="goto">
<n-button>菜单</n-button>
</n-dropdown>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,231 @@
<template>
<div class="chart" v-if="showChart">
<Line :data="chartData" :options="chartOptions" />
</div>
</template>
<script setup lang="ts">
import { Line } from "vue-chartjs"
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
} from "chart.js"
import type { ContestRank } from "utils/types"
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
)
interface Props {
ranks: ContestRank[]
problems: Array<{ id: number; title: string }>
}
const props = defineProps<Props>()
const PENALTY_SECONDS = 20 * 60
const showChart = computed(() => {
const hasRanks = props.ranks.length > 0
const hasProblems = props.problems.length >= 3
return hasProblems && hasRanks
})
const colorPalette = [
"#3B82F6",
"#EF4444",
"#10B981",
"#F59E0B",
"#8B5CF6",
"#EC4899",
"#06B6D4",
"#84CC16",
"#F97316",
"#6366F1",
]
function formatTime(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
if (h > 0) return `${h}h${m}m`
return `${m}m`
}
interface AcEvent {
time: number
userIndex: number
problemId: string
}
const chartData = computed(() => {
if (!props.ranks || props.ranks.length === 0) {
return { labels: [], datasets: [] }
}
const topUsers = props.ranks.slice(0, 10)
// 收集所有AC事件并按时间排序
const events: AcEvent[] = []
topUsers.forEach((rank, userIndex) => {
Object.entries(rank.submission_info).forEach(([problemId, info]) => {
if (info.is_ac) {
events.push({ time: info.ac_time, userIndex, problemId })
}
})
})
events.sort((a, b) => a.time - b.time)
if (events.length === 0) {
return { labels: [], datasets: [] }
}
// 在每个时间点计算所有人的排名
// 状态: 每个用户当前已AC题数和罚时
const userState = topUsers.map(() => ({
solved: 0,
penalty: 0,
}))
// 用于记录每个用户每道题的错误次数
const userErrors: Map<string, number>[] = topUsers.map(() => new Map())
topUsers.forEach((rank, i) => {
Object.entries(rank.submission_info).forEach(([problemId, info]) => {
if (info.error_number > 0) {
userErrors[i].set(problemId, info.error_number)
}
})
})
function calcRanks(): number[] {
const indexed = userState.map((s, i) => ({ ...s, i }))
indexed.sort((a, b) => {
if (b.solved !== a.solved) return b.solved - a.solved
return a.penalty - b.penalty
})
const ranks = new Array(topUsers.length).fill(0)
indexed.forEach((item, pos) => {
ranks[item.i] = pos + 1
})
return ranks
}
// 时间轴上的数据点: [时间标签, 各用户排名]
const timePoints: number[] = [0]
const rankSnapshots: number[][] = [calcRanks()]
// 按时间处理事件(合并同一时刻的事件)
let i = 0
while (i < events.length) {
const currentTime = events[i].time
// 处理同一时刻的所有事件
while (i < events.length && events[i].time === currentTime) {
const ev = events[i]
userState[ev.userIndex].solved++
const errors = userErrors[ev.userIndex].get(ev.problemId) || 0
userState[ev.userIndex].penalty =
userState[ev.userIndex].penalty + ev.time + errors * PENALTY_SECONDS
i++
}
timePoints.push(currentTime)
rankSnapshots.push(calcRanks())
}
const labels = timePoints.map((t) => formatTime(t))
const datasets = topUsers.map((rank, userIndex) => {
const color = colorPalette[userIndex % colorPalette.length]
const finalRank = rankSnapshots[rankSnapshots.length - 1][userIndex]
return {
label: `#${finalRank} ${rank.user.username}`,
data: rankSnapshots.map((snapshot) => snapshot[userIndex]),
borderColor: color,
backgroundColor: color,
tension: 0.3,
fill: false,
pointRadius: 3,
pointHoverRadius: 6,
pointBackgroundColor: color,
pointBorderColor: "#fff",
pointBorderWidth: 1,
borderWidth: 2.5,
}
})
return { labels, datasets }
})
const chartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: "index" as const,
intersect: false,
},
plugins: {
legend: {
display: true,
position: "top" as const,
maxHeight: 80,
labels: {
boxWidth: 14,
boxHeight: 3,
padding: 10,
font: { size: 12 },
},
},
tooltip: {
mode: "index" as const,
intersect: false,
itemSort: (a: any, b: any) => a.parsed.y - b.parsed.y,
callbacks: {
title: (context: any) => `比赛进行: ${context[0].label}`,
label: (context: any) => {
const rank = context.parsed.y
const name = context.dataset.label
return `${rank}名 — ${name}`
},
},
},
},
scales: {
x: {
title: {
display: true,
text: "比赛时间",
},
},
y: {
title: {
display: true,
text: "排名",
},
reverse: true,
min: 1,
max: 10,
ticks: {
stepSize: 1,
callback: (value: any) => `${value}`,
},
},
},
}))
</script>
<style scoped>
.chart {
height: 420px;
width: 100%;
margin-bottom: 24px;
}
</style>

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { CONTEST_STATUS, ContestStatus } from "utils/constants"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useContestStore } from "../store/contest"
import ContestInfo from "./components/ContestInfo.vue"
import ContestMenu from "./components/ContestMenu.vue"
const props = defineProps<{
contestID: string
}>()
const contestStore = useContestStore()
const message = useMessage()
const { isDesktop } = useBreakpoints()
const password = ref("")
async function check() {
await contestStore.checkPassword(props.contestID, password.value)
if (!contestStore.access) {
message.error("密码错误")
}
}
watch(
() => contestStore.contestStatus,
(nv, ov) => {
if (nv === ContestStatus.underway && ov == ContestStatus.not_started) {
contestStore.init(props.contestID)
}
},
)
onMounted(() => {
contestStore.init(props.contestID)
})
onBeforeUnmount(contestStore.clear)
const passwordFormVisible = computed(
() =>
contestStore.isPrivate &&
!contestStore.access &&
!contestStore.isContestAdmin,
)
</script>
<template>
<n-flex vertical size="large" v-if="contestStore.contest">
<n-flex align="center" justify="space-between">
<n-flex align="center">
<n-tag :type="CONTEST_STATUS[contestStore.contestStatus]['type']">
{{ contestStore.countdown }}
</n-tag>
<Icon
v-if="contestStore.isPrivate"
icon="streamline-ultimate-color:shield-lock"
:height="30"
></Icon>
<h2 class="contestTitle">{{ contestStore.contest.title }}</h2>
</n-flex>
<n-flex align="center">
<ContestInfo />
<ContestMenu />
</n-flex>
</n-flex>
<n-form
:inline="isDesktop"
label-placement="left"
v-if="passwordFormVisible"
>
<n-form-item label="需要输入密码才能看到题目">
<n-input
name="ContestPassword"
type="password"
v-model:value="password"
/>
</n-form-item>
<n-form-item>
<n-button @click="check" :disabled="!password">确认</n-button>
</n-form-item>
</n-form>
<router-view></router-view>
</n-flex>
</template>
<style scoped>
.contestTitle {
font-weight: 500;
margin: 0;
}
</style>

View File

@@ -0,0 +1,180 @@
<script setup lang="ts">
import { useRouteQuery } from "@vueuse/router"
import { NTag } from "naive-ui"
import { getContestList } from "oj/api"
import { duration, parseTime } from "utils/functions"
import type { Contest } from "utils/types"
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 { useUserStore } from "shared/store/user"
import { CONTEST_STATUS, ContestType } from "utils/constants"
import { renderTableTitle } from "utils/renders"
const router = useRouter()
const userStore = useUserStore()
const authStore = useAuthModalStore()
interface ContestQuery {
keyword: string
status: string
tag: string
}
// 使用分页 composable
const { query, clearQuery } = usePagination<ContestQuery>({
keyword: useRouteQuery("keyword", "").value,
status: useRouteQuery("status", "").value,
tag: useRouteQuery("tag", "").value,
})
const data = ref<Contest[]>([])
const total = ref(0)
const options: SelectOption[] = [
{ label: "全部", value: "" },
{ label: "未开始", value: "1" },
{ label: "进行中", value: "0" },
{ label: "已结束", value: "-1" },
]
const tags: SelectOption[] = [
{ label: "全部", value: "" },
{ label: "练习", value: "练习" },
{ label: "期中", value: "期中" },
{ label: "期末", value: "期末" },
]
const columns: DataTableColumn<Contest>[] = [
{
title: renderTableTitle("状态", "streamline-emojis:collision"),
key: "status",
width: 100,
render: (row) =>
h(
NTag,
{ type: CONTEST_STATUS[row.status]["type"] },
() => CONTEST_STATUS[row.status]["name"],
),
},
{
title: renderTableTitle("比赛", "streamline-emojis:bouquet"),
key: "title",
minWidth: 360,
render: (row) => h(ContestTitle, { contest: row }),
},
{
title: renderTableTitle("标签", "fluent-emoji-flat:keycap-hashtag"),
key: "tag",
width: 100,
render: (row) => h(NTag, () => row.tag),
},
{
title: renderTableTitle("开始时间", "fluent-emoji-flat:eleven-thirty"),
key: "start_time",
width: 180,
render: (row) => parseTime(row.start_time),
},
{
title: renderTableTitle("比赛时长", "streamline-emojis:fishing-pole"),
key: "duration",
width: 180,
render: (row) => duration(row.start_time, row.end_time),
},
]
async function listContests() {
const offset = (query.page - 1) * query.limit
const res = await getContestList({
offset,
limit: query.limit,
keyword: query.keyword,
status: query.status,
tag: query.tag,
})
data.value = res.data.results
total.value = res.data.total
}
function search(value: string) {
query.keyword = value
}
function clear() {
clearQuery()
}
onMounted(listContests)
// 监听搜索关键词变化(防抖)
watchDebounced(() => query.keyword, listContests, {
debounce: 500,
maxWait: 1000,
})
// 监听其他查询条件变化
watch(() => [query.page, query.limit, query.status, query.tag], listContests)
function rowProps(row: Contest) {
return {
style: "cursor: pointer",
onClick() {
if (!userStore.isAuthed && row.contest_type === ContestType.private) {
authStore.openLoginModal()
} else {
router.push("/contest/" + row.id)
}
},
}
}
</script>
<template>
<n-flex vertical size="large">
<n-space>
<n-form :show-feedback="false" label-placement="left" inline>
<n-form-item label="比赛状态">
<n-select
style="width: 120px"
:options="options"
v-model:value="query.status"
/>
</n-form-item>
<n-form-item label="标签">
<n-select
style="width: 120px"
:options="tags"
v-model:value="query.tag"
/>
</n-form-item>
</n-form>
<n-form :show-feedback="false" label-placement="left" inline>
<n-form-item>
<n-input
style="width: 180px"
clearable
v-model:value="query.keyword"
placeholder="比赛标题"
/>
</n-form-item>
<n-form-item>
<n-flex :wrap="false">
<n-button @click="search(query.keyword)">搜索</n-button>
<n-button @click="clear" quaternary>重置</n-button>
</n-flex>
</n-form-item>
</n-form>
</n-space>
<n-data-table
:bordered="false"
:columns="columns"
:data="data"
:row-props="rowProps"
/>
</n-flex>
<Pagination
v-model:limit="query.limit"
v-model:page="query.page"
:total="total"
/>
</template>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import type { ProblemFiltered } from "utils/types"
import ProblemStatus from "oj/problem/components/ProblemStatus.vue"
import { useContestStore } from "oj/store/contest"
import { renderTableTitle } from "utils/renders"
const props = defineProps<{ contestID: string }>()
const router = useRouter()
const contestStore = useContestStore()
const problemsColumns: DataTableColumn<ProblemFiltered>[] = [
{
title: renderTableTitle("状态", "streamline-ultimate-color:music-note-1"),
key: "status",
width: 100,
render: (row) => h(ProblemStatus, { status: row.status }),
},
{
title: renderTableTitle("编号", "fluent-emoji-flat:input-numbers"),
key: "_id",
width: 100,
},
{
title: renderTableTitle("题目", "streamline-emojis:rice-ball"),
key: "title",
minWidth: 200,
},
{
title: renderTableTitle("提交数", "streamline-emojis:clinking-beer-mugs"),
key: "submission",
align: "center",
width: 120,
},
{
title: renderTableTitle("通过率", "streamline-emojis:clapping-hands-1"),
key: "rate",
align: "center",
width: 120,
},
]
function rowProps(row: ProblemFiltered) {
return {
style: "cursor: pointer",
onClick() {
router.push(`/contest/${props.contestID}/problem/${row._id}`)
},
}
}
</script>
<template>
<n-data-table
striped
:data="contestStore.problems"
:columns="problemsColumns"
:row-props="rowProps"
/>
</template>
<style scoped></style>

View File

@@ -0,0 +1,341 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { NButton, useThemeVars } from "naive-ui"
import { getContestProblems, getContestRank } from "oj/api"
import { secondsToDuration } from "utils/functions"
import { useContestStore } from "oj/store/contest"
import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination"
import { ContestStatus } from "utils/constants"
import { renderTableTitle } from "utils/renders"
import type { ContestRank, ProblemFiltered } from "utils/types"
import AcAndSubmission from "../components/AcAndSubmission.vue"
import LineChart from "../components/LineChart.vue"
interface Props {
contestID: string
}
const props = defineProps<Props>()
const route = useRoute()
const router = useRouter()
const theme = useThemeVars()
const contestStore = useContestStore()
const total = ref(0)
const data = ref<ContestRank[]>([])
const chart = ref<ContestRank[]>([])
const problems = ref<ProblemFiltered[]>([])
const [autoRefresh] = useToggle(true)
const { resume, pause } = useIntervalFn(
() => {
query.page = 1
listRanks()
},
10000,
{
immediate: false,
},
)
// 使用分页 composable
const { query } = usePagination({}, { defaultLimit: 50 })
const columns = ref<DataTableColumn<ContestRank>[]>([
{
title: renderTableTitle("编号", "fluent-emoji-flat:input-numbers"),
key: "id",
width: 80,
fixed: "left",
align: "center",
render: (_, index) => index + (query.page - 1) * query.limit + 1,
},
{
title: renderTableTitle(
"用户",
"streamline-emojis:smiling-face-with-sunglasses",
),
key: "username",
width: 120,
fixed: "left",
align: "center",
render: (row) =>
h(
NButton,
{
text: true,
type: "info",
onClick: () => router.push("/user?name=" + row.user.username),
},
() => row.user.username,
),
},
{
title: renderTableTitle(
"正确数/总提交",
"streamline-ultimate-color:color-palette",
),
key: "submission",
width: 140,
align: "center",
render: (row) => h(AcAndSubmission, { rank: row }),
},
{
title: "总时间",
key: "total_time",
width: 120,
align: "center",
render: (row) => secondsToDuration(row.total_time),
},
])
async function listRanks() {
const res = await getContestRank(props.contestID, {
limit: query.limit,
offset: query.limit * (query.page - 1),
})
total.value = res.data.total
data.value = res.data.results
if (query.page === 1) {
chart.value = data.value
}
}
async function addColumns() {
try {
problems.value = await getContestProblems(props.contestID)
problems.value.map((problem) => {
columns.value.push({
align: "center",
title: () =>
h(
NButton,
{
text: true,
type: "primary",
onClick: () => {
const data = router.resolve({
name: "contest problem",
params: {
contestID: route.params.contestID,
problemID: problem._id,
},
})
window.open(data.href, "_blank")
},
},
() => problem.title,
),
render: (row) => {
if (row.submission_info[problem.id]) {
const status = row.submission_info[problem.id]
let acTime
let errorNumber
if (status.is_ac) {
acTime = h("span", secondsToDuration(status.ac_time))
}
if (status.is_first_ac) {
acTime = [
h(Icon, {
icon: "fluent-emoji:1st-place-medal",
height: 20,
width: 20,
}),
h("span", secondsToDuration(status.ac_time)),
]
}
if (status.error_number) {
errorNumber = h(
"span",
{ style: "margin: 0" },
`(-${status.error_number})`,
)
}
return h("div", { class: "oj-time-with-modal" }, [
acTime,
errorNumber,
])
}
},
cellProps: (row) => {
let backgroundColor = ""
let color = theme.value.textColorBase
if (row.submission_info[problem.id]) {
const status = row.submission_info[problem.id]
if (status.is_first_ac) {
backgroundColor = theme.value.primaryColor
color = theme.value.baseColor
} else if (status.is_ac) {
const success = theme.value.successColor
backgroundColor = success + "50"
color = theme.value.textColorBase
} else {
const error = theme.value.errorColor
backgroundColor = error + "50"
color = theme.value.textColorBase
}
}
return { style: { backgroundColor, color } }
},
key: problem.id,
width: 150,
ellipsis: true,
})
})
} catch (err) {
problems.value = []
}
}
// 导出弹窗
const showExportModal = ref(false)
const exportLoading = ref(false)
const exportForm = reactive({
first: 0,
second: 0,
third: 0,
})
watch(
() => total.value,
(val) => {
if (val > 0) {
exportForm.first = Math.round(val * 0.1)
exportForm.second = Math.round(val * 0.2)
exportForm.third = Math.round(val * 0.3)
}
},
)
function openExportModal() {
if (total.value > 0) {
exportForm.first = Math.round(total.value * 0.1)
exportForm.second = Math.round(total.value * 0.2)
exportForm.third = Math.round(total.value * 0.3)
}
showExportModal.value = true
}
async function downloadExcel() {
exportLoading.value = true
try {
const res = await getContestRank(props.contestID, {
limit: total.value || 10000,
offset: 0,
})
const allRanks: ContestRank[] = res.data.results
const rows = allRanks.map((rank, index) => {
const rank1 = index + 1
let level = ""
if (rank1 <= exportForm.first) {
level = "一等奖"
} else if (rank1 <= exportForm.first + exportForm.second) {
level = "二等奖"
} else if (
rank1 <=
exportForm.first + exportForm.second + exportForm.third
) {
level = "三等奖"
} else {
level = "参与奖"
}
return { 用户名: rank.user.username, 等级: level }
})
const csv =
"用户名,等级\n" + rows.map((r) => `${r.用户名},${r.等级}`).join("\n")
const blob = new Blob(["" + csv], { type: "text/csv;charset=utf-8" })
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `${contestStore.contest?.title ?? "contest"}获奖情况.csv`
a.click()
URL.revokeObjectURL(url)
showExportModal.value = false
} finally {
exportLoading.value = false
}
}
// 监听分页参数变化
watch([() => query.page, () => query.limit], listRanks)
watch(autoRefresh, (checked) => (checked ? resume() : pause()))
onMounted(() => {
listRanks()
addColumns()
})
</script>
<template>
<!-- 排名变化图表 -->
<LineChart :ranks="chart" :problems="problems" v-if="chart.length > 0" />
<!-- 排名表格 -->
<n-data-table
striped
:single-line="false"
:scroll-x="1200"
:columns="columns"
:data="data"
/>
<n-space justify="end" align="center">
<n-form
label-placement="left"
inline
:show-feedback="false"
v-if="contestStore.contestStatus === ContestStatus.underway"
>
<n-form-item label="开启自动刷新">
<n-switch v-model:value="autoRefresh" />
</n-form-item>
</n-form>
<n-button
v-if="contestStore.contestStatus === ContestStatus.finished"
type="primary"
@click="openExportModal"
>
导出数据
</n-button>
<Pagination
:total="total"
:limit="query.limit"
:page="query.page"
@update:limit="(limit: number) => (query.limit = limit)"
@update:page="(page: number) => (query.page = page)"
/>
</n-space>
<n-modal v-model:show="showExportModal" preset="dialog" title="导出获奖数据">
<n-form
label-placement="left"
label-width="auto"
:show-feedback="false"
style="margin-top: 16px"
>
<n-form-item label="一等奖人数" style="margin-bottom: 12px">
<n-input-number v-model:value="exportForm.first" :min="0" />
</n-form-item>
<n-form-item label="二等奖人数" style="margin-bottom: 12px">
<n-input-number v-model:value="exportForm.second" :min="0" />
</n-form-item>
<n-form-item label="三等奖人数">
<n-input-number v-model:value="exportForm.third" :min="0" />
</n-form-item>
</n-form>
<template #action>
<n-button @click="showExportModal = false">取消</n-button>
<n-button type="primary" :loading="exportLoading" @click="downloadExcel">
下载 CSV
</n-button>
</template>
</n-modal>
</template>
<style>
.oj-time-with-modal {
display: flex;
}
</style>

View File

@@ -0,0 +1,7 @@
<script lang="ts" setup>
import FlowchartEditor from "shared/components/FlowchartEditor/index.vue"
</script>
<template>
<FlowchartEditor />
</template>
<style scoped></style>

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import type { Exercise, ExerciseDebugData } from "utils/types"
import { highlightLines } from "../composables/useCodeHighlight"
import "./exercise-highlight.css"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const data = computed(() => props.exercise.data as ExerciseDebugData)
const lineHtml = computed(() => highlightLines(data.value.lines, props.lang))
const selected = ref<Set<number>>(new Set())
const submitted = ref(false)
watch(() => props.exercise.id, reset, { immediate: true })
const allCorrect = computed(() => {
const ans = new Set(data.value.answer)
if (selected.value.size !== ans.size) return false
for (const i of selected.value) if (!ans.has(i)) return false
return true
})
const locked = computed(() => submitted.value && allCorrect.value)
function toggle(i: number) {
if (locked.value) return
submitted.value = false
const s = new Set(selected.value)
if (s.has(i)) s.delete(i)
else s.add(i)
selected.value = s
}
function lineStatus(i: number): "correct" | "wrong" | "selected" | "default" {
if (!submitted.value) return selected.value.has(i) ? "selected" : "default"
const isAns = data.value.answer.includes(i)
const isSel = selected.value.has(i)
if (isAns) return isSel ? "correct" : "wrong" // 漏选也标红
if (isSel) return "wrong"
return "default"
}
function lineStyle(i: number): Record<string, string> {
const status = lineStatus(i)
const color =
status === "correct"
? "#18a058"
: status === "wrong"
? "#d03050"
: status === "selected"
? "#2080f0"
: "var(--n-border-color)"
const plain = color === "var(--n-border-color)"
return {
display: "flex",
alignItems: "center",
gap: "10px",
padding: "6px 12px",
borderRadius: "6px",
border: `1.5px solid ${color}`,
background: plain ? "transparent" : color + "14",
cursor: locked.value ? "default" : "pointer",
fontFamily: "Monaco",
userSelect: "none",
}
}
function submit() {
submitted.value = true
}
function reset() {
selected.value = new Set()
submitted.value = false
}
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-tag type="info" :bordered="false">练一练 · 代码找错</n-tag>
</template>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 8px">
{{ data.question }}
</p>
<p style="color: var(--n-text-color-3); font-size: 13px; margin: 0 0 12px">
点击你认为有错误的代码行可多选
</p>
<n-space vertical :size="6">
<div
v-for="(line, idx) in data.lines"
:key="idx"
:style="lineStyle(idx)"
@click="toggle(idx)"
>
<span
style="color: #bbb; width: 22px; text-align: right; flex-shrink: 0"
>
{{ idx + 1 }}
</span>
<span v-html="lineHtml[idx]" style="white-space: pre" />
</div>
</n-space>
<n-alert
v-if="submitted"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '找对了!' : '还没找全,红色行是错误所在'"
style="margin-top: 12px"
>
<template v-if="submitted && data.explanation" #default>
{{ data.explanation }}
</template>
</n-alert>
<n-space style="margin-top: 12px" :size="8">
<n-button type="info" :disabled="locked" @click="submit">提交</n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</template>

View File

@@ -0,0 +1,135 @@
<script setup lang="ts">
import type { Exercise, ExerciseFillData } from "utils/types"
import { highlight } from "../composables/useCodeHighlight"
import "./exercise-highlight.css"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const data = computed(() => props.exercise.data as ExerciseFillData)
type CodeSeg = { type: "code"; html: string }
type BlankSeg = { type: "blank"; answers: string[]; index: number }
type Segment = CodeSeg | BlankSeg
const segments = computed<Segment[]>(() => {
const blanks: string[][] = []
const markedCode = data.value.code.replace(/\{\{([^}]+)\}\}/g, (_, inner) => {
blanks.push(inner.split("|"))
return `____${blanks.length - 1}____`
})
const highlighted = highlight(markedCode, props.lang)
const parts = highlighted.split(/____(\d+)____/)
const result: Segment[] = []
for (let i = 0; i < parts.length; i++) {
if (i % 2 === 0) {
if (parts[i]) result.push({ type: "code", html: parts[i] })
} else {
const idx = parseInt(parts[i])
result.push({ type: "blank", answers: blanks[idx], index: idx })
}
}
return result
})
const blankCount = computed(
() => segments.value.filter((s) => s.type === "blank").length,
)
const userInputs = ref<string[]>([])
const wrongBlanks = ref<Set<number>>(new Set())
const allCorrect = ref(false)
watch(() => props.exercise.id, reset, { immediate: true })
function reset() {
userInputs.value = Array(blankCount.value).fill("")
wrongBlanks.value = new Set()
allCorrect.value = false
}
function submit() {
if (allCorrect.value) return
const wrong = new Set<number>()
for (const seg of segments.value) {
if (seg.type !== "blank") continue
if (!seg.answers.includes(userInputs.value[seg.index]?.trim() ?? "")) {
wrong.add(seg.index)
}
}
wrongBlanks.value = wrong
allCorrect.value = wrong.size === 0
}
function inputWidth(idx: number): string {
return Math.max(4, (userInputs.value[idx]?.length ?? 0) + 2) + "ch"
}
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-tag type="warning" :bordered="false">练一练 · 代码填空</n-tag>
</template>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 12px">
{{ data.question }}
</p>
<pre
:style="{
fontFamily: 'Monaco',
fontSize: '16px',
lineHeight: '1.6',
background: 'var(--n-color)',
border: '1px solid var(--n-border-color)',
borderRadius: '6px',
padding: '12px',
overflowX: 'auto',
whiteSpace: 'pre-wrap',
margin: 0,
}"
><template v-for="(seg, i) in segments" :key="i"
><span v-if="seg.type === 'code'" v-html="seg.html" /><input
v-else
:value="userInputs[seg.index]"
:disabled="allCorrect"
:style="{
width: inputWidth(seg.index),
fontFamily: 'Monaco',
fontSize: '16px',
padding: '2px 6px',
borderRadius: '3px',
border: `1.5px solid ${
allCorrect
? '#18a058'
: wrongBlanks.has(seg.index)
? '#d03050'
: 'var(--n-border-color)'
}`,
background: allCorrect
? 'rgba(24,160,88,0.08)'
: wrongBlanks.has(seg.index)
? 'rgba(208,48,80,0.07)'
: 'transparent',
outline: 'none',
color: 'inherit',
minWidth: '4ch',
}"
@input="userInputs[seg.index] = ($event.target as HTMLInputElement).value"
/></template></pre>
<n-alert
v-if="wrongBlanks.size > 0 || allCorrect"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '全部正确!' : '有填写错误,请检查红色标注的空位'"
style="margin-top: 12px"
/>
<n-space style="margin-top: 12px" :size="8">
<n-button type="warning" :disabled="allCorrect" @click="submit">
提交
</n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</template>

View File

@@ -0,0 +1,183 @@
<script setup lang="ts">
import type { Exercise, ExerciseGroupData } from "utils/types"
import { shuffle } from "../composables/useShuffle"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const data = computed(() => props.exercise.data as ExerciseGroupData)
const order = ref<number[]>([]) // item 的稳定展示顺序(初始乱序)
const placement = ref<number[]>([]) // placement[itemIdx] = 桶下标,-1 表示在池中
const dragIdx = ref<number | null>(null)
const submitted = ref(false)
function init() {
order.value = shuffle(data.value.items.map((_, i) => i))
placement.value = Array(data.value.items.length).fill(-1)
dragIdx.value = null
submitted.value = false
}
onMounted(init)
watch(() => props.exercise.id, init)
const allPlaced = computed(() => placement.value.every((p) => p !== -1))
const allCorrect = computed(() =>
placement.value.every((p, i) => p === data.value.answer[i]),
)
const locked = computed(() => submitted.value && allCorrect.value)
function onDragStart(i: number) {
if (locked.value) return
dragIdx.value = i
}
function dropTo(bucket: number) {
if (locked.value || dragIdx.value === null) return
placement.value[dragIdx.value] = bucket
dragIdx.value = null
submitted.value = false
}
const poolItems = computed(() =>
order.value.filter((i) => placement.value[i] === -1),
)
function itemsIn(bucket: number): number[] {
return order.value.filter((i) => placement.value[i] === bucket)
}
function itemStatus(i: number): "correct" | "wrong" | "default" {
if (!submitted.value || placement.value[i] === -1) return "default"
return placement.value[i] === data.value.answer[i] ? "correct" : "wrong"
}
function chipStyle(i: number): Record<string, string> {
const status = itemStatus(i)
const color =
status === "correct"
? "#18a058"
: status === "wrong"
? "#d03050"
: "var(--n-border-color)"
const plain = color === "var(--n-border-color)"
return {
padding: "6px 12px",
borderRadius: "6px",
border: `1.5px solid ${color}`,
background: plain ? "var(--n-color)" : color + "14",
cursor: locked.value ? "default" : "grab",
userSelect: "none",
fontSize: "15px",
}
}
function submit() {
submitted.value = true
}
function reset() {
init()
}
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-tag type="warning" :bordered="false">练一练 · 归类分组</n-tag>
</template>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 8px">
{{ data.question }}
</p>
<p style="color: var(--n-text-color-3); font-size: 13px; margin: 0 0 12px">
把下面的项目拖到对应的分组里可在分组间拖动调整
</p>
<div
:style="{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
minHeight: '48px',
padding: '10px',
border: '1.5px dashed var(--n-border-color)',
borderRadius: '8px',
marginBottom: '14px',
}"
@dragover.prevent
@drop="dropTo(-1)"
>
<span
v-if="poolItems.length === 0"
style="color: var(--n-text-color-3); font-size: 13px"
>
已全部归类
</span>
<div
v-for="i in poolItems"
:key="i"
draggable="true"
:style="chipStyle(i)"
@dragstart="onDragStart(i)"
>
{{ data.items[i] }}
</div>
</div>
<div
:style="{
display: 'grid',
gridTemplateColumns: `repeat(${data.buckets.length}, 1fr)`,
gap: '12px',
}"
>
<div
v-for="(bucket, b) in data.buckets"
:key="b"
:style="{
minHeight: '88px',
padding: '10px',
border: '1.5px solid var(--n-border-color)',
borderRadius: '8px',
}"
@dragover.prevent
@drop="dropTo(b)"
>
<p
style="
font-weight: 600;
margin: 0 0 8px;
text-align: center;
font-size: 14px;
"
>
{{ bucket }}
</p>
<n-space :size="8">
<div
v-for="i in itemsIn(b)"
:key="i"
draggable="true"
:style="chipStyle(i)"
@dragstart="onDragStart(i)"
>
{{ data.items[i] }}
</div>
</n-space>
</div>
</div>
<n-alert
v-if="submitted"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '归类全部正确!' : '有归类错误,红色项需要调整'"
style="margin-top: 12px"
/>
<n-space style="margin-top: 12px" :size="8">
<n-button type="warning" :disabled="!allPlaced || locked" @click="submit">
提交
</n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</template>

View File

@@ -0,0 +1,184 @@
<script setup lang="ts">
import type { Exercise, ExerciseMatchData } from "utils/types"
import { shuffle } from "../composables/useShuffle"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const data = computed(() => props.exercise.data as ExerciseMatchData)
const PALETTE = [
"#2080f0",
"#18a058",
"#f0a020",
"#d03050",
"#8a2be2",
"#0891b2",
"#db2777",
"#65a30d",
]
const rightOrder = ref<number[]>([]) // 显示顺序里的 right 原始下标
const pairs = ref<(number | null)[]>([]) // pairs[leftIdx] = 配对的 right 原始下标
const selectedLeft = ref<number | null>(null)
const submitted = ref(false)
function init() {
const n = data.value.right.length
rightOrder.value = shuffle(Array.from({ length: n }, (_, i) => i))
pairs.value = Array(data.value.left.length).fill(null)
selectedLeft.value = null
submitted.value = false
}
onMounted(init)
watch(() => props.exercise.id, init)
const allPaired = computed(() => pairs.value.every((p) => p !== null))
const allCorrect = computed(() =>
pairs.value.every((p, i) => p === data.value.answer[i]),
)
const locked = computed(() => submitted.value && allCorrect.value)
function leftOf(rightIdx: number): number {
return pairs.value.findIndex((p) => p === rightIdx)
}
function onLeftClick(i: number) {
if (locked.value) return
submitted.value = false
if (pairs.value[i] !== null) {
pairs.value[i] = null
selectedLeft.value = i
return
}
selectedLeft.value = selectedLeft.value === i ? null : i
}
function onRightClick(rightIdx: number) {
if (locked.value) return
submitted.value = false
if (selectedLeft.value === null) {
const l = leftOf(rightIdx)
if (l !== -1) pairs.value[l] = null
return
}
const prev = leftOf(rightIdx)
if (prev !== -1) pairs.value[prev] = null
pairs.value[selectedLeft.value] = rightIdx
selectedLeft.value = null
}
function submit() {
submitted.value = true
}
function reset() {
init()
}
function leftColor(i: number): string {
if (submitted.value) {
if (pairs.value[i] === null) return "#d03050"
return pairs.value[i] === data.value.answer[i] ? "#18a058" : "#d03050"
}
if (selectedLeft.value === i) return "#2080f0"
if (pairs.value[i] !== null) return PALETTE[i % PALETTE.length]
return "var(--n-border-color)"
}
function rightColor(rightIdx: number): string {
const l = leftOf(rightIdx)
if (submitted.value) {
if (l === -1) return "var(--n-border-color)"
return pairs.value[l] === data.value.answer[l] ? "#18a058" : "#d03050"
}
if (l === -1) return "var(--n-border-color)"
return PALETTE[l % PALETTE.length]
}
function itemStyle(color: string, selected: boolean): Record<string, string> {
const plain = color === "var(--n-border-color)"
return {
display: "flex",
alignItems: "center",
gap: "8px",
padding: "10px 12px",
borderRadius: "6px",
border: `${selected ? "2px" : "1.5px"} solid ${color}`,
background: plain ? "transparent" : color + "14",
cursor: locked.value ? "default" : "pointer",
userSelect: "none",
fontSize: "15px",
}
}
function dotStyle(color: string): Record<string, string> {
return {
width: "10px",
height: "10px",
borderRadius: "50%",
background: color,
flexShrink: "0",
}
}
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-tag type="primary" :bordered="false">练一练 · 连线匹配</n-tag>
</template>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 8px">
{{ data.question }}
</p>
<p style="color: var(--n-text-color-3); font-size: 13px; margin: 0 0 12px">
先点左边一项再点右边一项即可连线点击已连线的项可取消
</p>
<div style="display: flex; gap: 24px; align-items: flex-start">
<n-space vertical :size="8" style="flex: 1">
<div
v-for="(item, i) in data.left"
:key="'l' + i"
:style="itemStyle(leftColor(i), selectedLeft === i)"
@click="onLeftClick(i)"
>
<span
v-if="pairs[i] !== null && !submitted"
:style="dotStyle(PALETTE[i % PALETTE.length])"
/>
<span>{{ item }}</span>
</div>
</n-space>
<n-space vertical :size="8" style="flex: 1">
<div
v-for="rightIdx in rightOrder"
:key="'r' + rightIdx"
:style="itemStyle(rightColor(rightIdx), false)"
@click="onRightClick(rightIdx)"
>
<span
v-if="leftOf(rightIdx) !== -1 && !submitted"
:style="dotStyle(PALETTE[leftOf(rightIdx) % PALETTE.length])"
/>
<span>{{ data.right[rightIdx] }}</span>
</div>
</n-space>
</div>
<n-alert
v-if="submitted"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '全部匹配正确!' : '有匹配错误,红色项需要重新连线'"
style="margin-top: 12px"
/>
<n-space style="margin-top: 12px" :size="8">
<n-button type="primary" :disabled="!allPaired || locked" @click="submit">
提交
</n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</template>

View File

@@ -0,0 +1,123 @@
<script setup lang="ts">
import type { Exercise, ExerciseMcqData } from "utils/types"
const props = defineProps<{ exercise: Exercise }>()
const data = computed(() => props.exercise.data as ExerciseMcqData)
const isSingle = computed(() => data.value.answer.length === 1)
const selected = ref<Set<number>>(new Set())
const correct = ref(false)
const wrong = ref(false)
const partial = ref(false)
function select(idx: number) {
if (correct.value) return
const s = new Set(selected.value)
if (isSingle.value) {
s.clear()
if (!selected.value.has(idx)) s.add(idx)
} else {
if (s.has(idx)) s.delete(idx)
else s.add(idx)
}
selected.value = s
wrong.value = false
partial.value = false
}
function submit() {
if (selected.value.size === 0 || correct.value) return
const answer = new Set(data.value.answer)
const sel = selected.value
const isEqual =
sel.size === answer.size && [...sel].every((v) => answer.has(v))
if (isEqual) {
correct.value = true
wrong.value = false
partial.value = false
} else {
selected.value = new Set()
const hasIntersection = [...sel].some((v) => answer.has(v))
if (hasIntersection) {
partial.value = true
wrong.value = false
} else {
wrong.value = true
partial.value = false
}
}
}
function reset() {
selected.value = new Set()
correct.value = false
wrong.value = false
partial.value = false
}
function optionType(idx: number): "default" | "primary" | "success" {
if (correct.value && data.value.answer.includes(idx)) return "success"
if (selected.value.has(idx)) return "primary"
return "default"
}
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-space align="center" :size="8">
<n-tag type="success" :bordered="false">
练一练 · {{ isSingle ? "单选题" : "多选题" }}
</n-tag>
</n-space>
</template>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 12px">
{{ data.question }}
</p>
<n-space vertical :size="8">
<n-button
v-for="(opt, idx) in data.options"
:key="idx"
:type="optionType(idx)"
:secondary="optionType(idx) !== 'default'"
:tertiary="optionType(idx) === 'default'"
:strong="selected.has(idx)"
:style="{
justifyContent: 'flex-start',
width: '100%',
textAlign: 'left',
}"
@click="select(idx)"
>
<template #icon>
<span style="font-weight: 700">{{
String.fromCharCode(65 + idx)
}}</span>
</template>
{{ opt }}
</n-button>
</n-space>
<n-alert
v-if="correct || wrong || partial"
:type="correct ? 'success' : partial ? 'warning' : 'error'"
:title="
correct ? '正确!' : partial ? '部分正确,请重试' : '选择有误,请重试'
"
style="margin-top: 12px"
/>
<n-space style="margin-top: 12px" :size="8">
<n-button
type="primary"
:disabled="selected.size === 0 || correct"
@click="submit"
>
提交
</n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</template>

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
import type { Exercise, ExercisePredictData } from "utils/types"
import { highlight } from "../composables/useCodeHighlight"
import "./exercise-highlight.css"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const data = computed(() => props.exercise.data as ExercisePredictData)
const codeHtml = computed(() => highlight(data.value.code, props.lang))
const userInput = ref("")
const submitted = ref(false)
watch(() => props.exercise.id, reset, { immediate: true })
function normalize(s: string): string {
return s
.replace(/\r\n/g, "\n")
.split("\n")
.map((l) => l.replace(/\s+$/, ""))
.join("\n")
.replace(/^\n+/, "")
.replace(/\n+$/, "")
}
const allCorrect = computed(() =>
data.value.answer.some((a) => normalize(a) === normalize(userInput.value)),
)
function submit() {
submitted.value = true
}
function reset() {
userInput.value = ""
submitted.value = false
}
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-tag type="error" :bordered="false">练一练 · 输出预测</n-tag>
</template>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 12px">
{{ data.question }}
</p>
<pre
:style="{
fontFamily: 'Monaco',
fontSize: '16px',
lineHeight: '1.6',
background: 'var(--n-color)',
border: '1px solid var(--n-border-color)',
borderRadius: '6px',
padding: '12px',
overflowX: 'auto',
margin: 0,
}"
><code v-html="codeHtml" /></pre>
<p style="font-weight: 500; margin: 14px 0 8px">这段代码会输出什么</p>
<n-input
v-model:value="userInput"
type="textarea"
:rows="3"
:disabled="submitted && allCorrect"
placeholder="在这里输入程序会打印的内容"
style="font-family: Monaco"
/>
<n-alert
v-if="submitted"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '输出正确!' : '输出不正确,再读读代码看看'"
style="margin-top: 12px"
/>
<n-space style="margin-top: 12px" :size="8">
<n-button
type="error"
:disabled="submitted && allCorrect"
@click="submit"
>
提交
</n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</template>

View File

@@ -0,0 +1,130 @@
<script setup lang="ts">
import type { Exercise, ExerciseSortData } from "utils/types"
import { shuffle } from "../composables/useShuffle"
import { highlightLines } from "../composables/useCodeHighlight"
import "./exercise-highlight.css"
const props = defineProps<{ exercise: Exercise; lang?: string }>()
const data = computed(() => props.exercise.data as ExerciseSortData)
type LineItem = { originalIdx: number; text: string }
const lines = ref<LineItem[]>([])
const submitted = ref(false)
function init() {
const shuffled = shuffle(
data.value.lines.map((text, idx) => ({ originalIdx: idx, text })),
)
// 打乱后若恰好与原顺序一致,交换前两项,避免一进入就是已解出状态
const isCorrect = shuffled.every((item, i) => item.originalIdx === i)
if (isCorrect && shuffled.length > 1) {
;[shuffled[0], shuffled[1]] = [shuffled[1], shuffled[0]]
}
lines.value = shuffled
submitted.value = false
}
onMounted(init)
watch(() => props.exercise.id, init)
const dragIdx = ref<number | null>(null)
function onDragStart(idx: number) {
dragIdx.value = idx
}
function onDrop(targetIdx: number) {
if (dragIdx.value === null || dragIdx.value === targetIdx) return
const newLines = [...lines.value]
const [moved] = newLines.splice(dragIdx.value, 1)
newLines.splice(targetIdx, 0, moved)
lines.value = newLines
dragIdx.value = null
submitted.value = false
}
function lineStatus(idx: number): "correct" | "wrong" | "default" {
if (!submitted.value) return "default"
return lines.value[idx].originalIdx === idx ? "correct" : "wrong"
}
const allCorrect = computed(() =>
lines.value.every((item, i) => item.originalIdx === i),
)
function submit() {
submitted.value = true
}
function reset() {
init()
}
const lineHtml = computed<string[]>(() =>
highlightLines(data.value.lines, props.lang),
)
</script>
<template>
<n-card style="margin: 16px 0; border: 1.5px solid var(--n-border-color)">
<template #header>
<n-tag type="info" :bordered="false">练一练 · 代码排序</n-tag>
</template>
<p style="font-weight: 500; font-size: 16px; margin-bottom: 12px">
{{ data.question }}
</p>
<n-space vertical :size="6">
<div
v-for="(line, idx) in lines"
:key="line.originalIdx"
draggable="true"
:style="{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '8px 12px',
borderRadius: '6px',
border: `1.5px ${submitted ? 'solid' : 'dashed'} ${
lineStatus(idx) === 'correct'
? '#18a058'
: lineStatus(idx) === 'wrong'
? '#d03050'
: 'var(--n-border-color)'
}`,
background:
lineStatus(idx) === 'correct'
? 'rgba(24,160,88,0.08)'
: lineStatus(idx) === 'wrong'
? 'rgba(208,48,80,0.07)'
: 'transparent',
cursor: 'grab',
fontFamily: 'Monaco',
userSelect: 'none',
}"
@dragstart="onDragStart(idx)"
@dragover.prevent
@drop="onDrop(idx)"
>
<span style="color: #bbb; cursor: grab"></span>
<span v-html="lineHtml[line.originalIdx]" style="white-space: pre" />
</div>
</n-space>
<n-alert
v-if="submitted"
:type="allCorrect ? 'success' : 'error'"
:title="allCorrect ? '顺序正确!' : '顺序有误,红色行需要调整'"
style="margin-top: 12px"
/>
<n-space style="margin-top: 12px" :size="8">
<n-button type="info" :disabled="submitted && allCorrect" @click="submit">
提交
</n-button>
<n-button @click="reset">重置</n-button>
</n-space>
</n-card>
</template>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import type { Exercise } from "utils/types"
const ExerciseMcq = defineAsyncComponent(() => import("./ExerciseMcq.vue"))
const ExerciseSort = defineAsyncComponent(() => import("./ExerciseSort.vue"))
const ExerciseFill = defineAsyncComponent(() => import("./ExerciseFill.vue"))
const ExerciseMatch = defineAsyncComponent(() => import("./ExerciseMatch.vue"))
const ExercisePredict = defineAsyncComponent(
() => import("./ExercisePredict.vue"),
)
const ExerciseDebug = defineAsyncComponent(() => import("./ExerciseDebug.vue"))
const ExerciseGroup = defineAsyncComponent(() => import("./ExerciseGroup.vue"))
defineProps<{ exercise: Exercise; lang?: string }>()
</script>
<template>
<ExerciseMcq v-if="exercise.type === 'mcq'" :exercise="exercise" />
<ExerciseSort
v-else-if="exercise.type === 'sort'"
:exercise="exercise"
:lang="lang"
/>
<ExerciseFill
v-else-if="exercise.type === 'fill'"
:exercise="exercise"
:lang="lang"
/>
<ExerciseMatch v-else-if="exercise.type === 'match'" :exercise="exercise" />
<ExercisePredict
v-else-if="exercise.type === 'predict'"
:exercise="exercise"
:lang="lang"
/>
<ExerciseDebug
v-else-if="exercise.type === 'debug'"
:exercise="exercise"
:lang="lang"
/>
<ExerciseGroup v-else-if="exercise.type === 'group'" :exercise="exercise" />
</template>

View File

@@ -0,0 +1,50 @@
/* 练一练代码高亮配色(明 / 暗),由涉及代码高亮的题型组件统一引入 */
.hljs-keyword,
.hljs-operator,
.hljs-selector-tag {
color: #d73a49;
}
.hljs-string,
.hljs-regexp,
.hljs-template-literal {
color: #032f62;
}
.hljs-comment,
.hljs-quote {
color: #6a737d;
font-style: italic;
}
.hljs-number,
.hljs-literal {
color: #005cc5;
}
.hljs-built_in,
.hljs-title.function_,
.hljs-class .hljs-title {
color: #6f42c1;
}
.dark .hljs-keyword,
.dark .hljs-operator,
.dark .hljs-selector-tag {
color: #c678dd;
}
.dark .hljs-string,
.dark .hljs-regexp,
.dark .hljs-template-literal {
color: #98c379;
}
.dark .hljs-comment,
.dark .hljs-quote {
color: #7f848e;
font-style: italic;
}
.dark .hljs-number,
.dark .hljs-literal {
color: #e5c07b;
}
.dark .hljs-built_in,
.dark .hljs-title.function_,
.dark .hljs-class .hljs-title {
color: #61afef;
}

View File

@@ -0,0 +1,41 @@
import hljs from "highlight.js/lib/core"
import python from "highlight.js/lib/languages/python"
import c from "highlight.js/lib/languages/c"
hljs.registerLanguage("python", python)
hljs.registerLanguage("c", c)
export function escapeHtml(text: string): string {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
}
function normalizeLang(lang?: string): "python" | "c" | null {
return lang === "python" ? "python" : lang === "c" ? "c" : null
}
// 把整段代码高亮为 HTML不支持的语言或异常时回退为转义文本
export function highlight(code: string, lang?: string): string {
const language = normalizeLang(lang)
if (language) {
try {
return hljs.highlight(code, { language }).value
} catch {
// fall through
}
}
return escapeHtml(code)
}
// 按行高亮:整体高亮后再按行切分,保证跨行 token 着色正确,返回逐行 HTML 数组
export function highlightLines(lines: string[], lang?: string): string[] {
const language = normalizeLang(lang)
if (language) {
try {
const html = hljs.highlight(lines.join("\n"), { language }).value
return html.split("\n")
} catch {
// fall through
}
}
return lines.map((line) => escapeHtml(line))
}

View File

@@ -0,0 +1,36 @@
import type { Exercise } from "utils/types"
type Segment =
{ type: "md"; content: string } | { type: "exercise"; exercise: Exercise }
export function parseExercises(
content: string,
exercises: Exercise[],
): Segment[] {
const exerciseMap = new Map(exercises.map((e) => [e.id, e]))
const segments: Segment[] = []
const regex = /\[\[exercise:(\d+)\]\]/g
let lastIndex = 0
let match: RegExpExecArray | null
while ((match = regex.exec(content)) !== null) {
if (match.index > lastIndex) {
segments.push({
type: "md",
content: content.slice(lastIndex, match.index),
})
}
const id = parseInt(match[1])
const exercise = exerciseMap.get(id)
if (exercise) {
segments.push({ type: "exercise", exercise })
}
lastIndex = regex.lastIndex
}
if (lastIndex < content.length) {
segments.push({ type: "md", content: content.slice(lastIndex) })
}
return segments
}

View File

@@ -0,0 +1,9 @@
// FisherYates 洗牌,返回新数组,不修改原数组
export function shuffle<T>(arr: T[]): T[] {
const a = [...arr]
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[a[i], a[j]] = [a[j], a[i]]
}
return a
}

View File

@@ -0,0 +1,255 @@
<template>
<div class="learn-container">
<!-- 桌面端布局 -->
<n-grid
:cols="5"
:x-gap="16"
v-if="tutorial.id && isDesktop"
class="learn-grid"
>
<n-gi :span="1" class="learn-col">
<n-card title="教程目录" :bordered="false" size="small">
<n-list hoverable clickable>
<n-list-item
v-for="(item, index) in titles"
:key="item.id"
@click="goToLesson(index + 1)"
>
<n-text
:type="step === index + 1 ? 'primary' : undefined"
:strong="step === index + 1"
>
{{ index + 1 }}. {{ item.title }}
</n-text>
</n-list-item>
</n-list>
</n-card>
</n-gi>
<n-gi :span="tutorial.code ? 2 : 4" class="learn-col">
<n-card
:title="`第 ${step} 课:${titles[step - 1]?.title}`"
:bordered="false"
size="small"
>
<template v-for="(seg, i) in segments" :key="i">
<MdPreview
v-if="seg.type === 'md'"
preview-theme="vuepress"
:theme="isDark ? 'dark' : 'light'"
:model-value="seg.content"
/>
<ExerciseWidget
v-else
:exercise="seg.exercise"
:lang="tutorial.type"
/>
</template>
</n-card>
</n-gi>
<n-gi :span="2" v-if="tutorial.code" class="learn-col learn-col--code">
<n-card
title="示例代码"
:bordered="false"
size="small"
class="code-card"
content-style="height: calc(100% - 44px); padding: 0;"
>
<CodeEditor
:language="editorLanguage"
v-model="tutorial.code"
height="100%"
/>
</n-card>
</n-gi>
</n-grid>
<!-- 手机端布局 -->
<template v-if="tutorial.id && !isDesktop">
<n-tabs type="line" animated v-model:value="activeTab">
<n-tab-pane name="catalog" tab="目录">
<n-list hoverable clickable>
<n-list-item
v-for="(item, index) in titles"
:key="item.id"
@click="goToLesson(index + 1)"
>
<n-text
:type="step === index + 1 ? 'primary' : undefined"
:strong="step === index + 1"
>
{{ index + 1 }}. {{ item.title }}
</n-text>
</n-list-item>
</n-list>
</n-tab-pane>
<n-tab-pane name="content" :tab="`第 ${step} 课`">
<template v-for="(seg, i) in segments" :key="i">
<MdPreview
v-if="seg.type === 'md'"
preview-theme="vuepress"
:theme="isDark ? 'dark' : 'light'"
:model-value="seg.content"
/>
<ExerciseWidget
v-else
:exercise="seg.exercise"
:lang="tutorial.type"
/>
</template>
</n-tab-pane>
<n-tab-pane name="code" tab="示例代码" v-if="tutorial.code">
<CodeEditor :language="editorLanguage" v-model="tutorial.code" />
</n-tab-pane>
</n-tabs>
<n-divider style="margin: 12px 0" />
<n-flex align="center" justify="space-between">
<n-button
secondary
type="primary"
:disabled="isFirstLesson"
@click="goToPrevLesson"
>
上一课
</n-button>
<n-text>{{ step }} / {{ titles.length }}</n-text>
<n-button
secondary
type="primary"
:disabled="isLastLesson"
@click="goToNextLesson"
>
下一课
</n-button>
</n-flex>
</template>
<n-empty
v-if="isEmpty"
description="该教程还没有公开"
style="margin-top: 80px"
/>
</div>
</template>
<script setup lang="ts">
import { MdPreview } from "md-editor-v3"
import "md-editor-v3/lib/preview.css"
import type { Tutorial, Exercise, LANGUAGE } from "utils/types"
import { getTutorial, getTutorials, getExercises } from "../api"
import { parseExercises } from "./composables/useExerciseParse"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useLearnProgress } from "shared/composables/learnProgress"
const ExerciseWidget = defineAsyncComponent(
() => import("./components/ExerciseWidget.vue"),
)
const CodeEditor = defineAsyncComponent(
() => import("shared/components/CodeEditor.vue"),
)
const isDark = useDark()
const route = useRoute()
const router = useRouter()
const { isDesktop } = useBreakpoints()
const { learnStep } = useLearnProgress()
const step = computed(() => {
const value = route.params.step as string | undefined
if (!value) return 1
return parseInt(value)
})
const type = computed<"python" | "c">(() =>
route.params.type === "c" ? "c" : "python",
)
const tutorial = ref<Partial<Tutorial>>({
id: 0,
title: "",
content: "",
code: "",
})
const editorLanguage = computed<LANGUAGE>(() =>
tutorial.value.type === "c" ? "C" : "Python3",
)
const titles = ref<{ id: number; title: string }[]>([])
const exercises = ref<Exercise[]>([])
const activeTab = ref("content")
const isEmpty = ref(false)
const segments = computed(() =>
parseExercises(tutorial.value.content ?? "", exercises.value),
)
const isFirstLesson = computed(() => step.value === 1)
const isLastLesson = computed(() => step.value === titles.value.length)
function goToLesson(lessonNumber: number) {
activeTab.value = "content"
router.push(
`/learn/${type.value}/${lessonNumber.toString().padStart(2, "0")}`,
)
}
function goToPrevLesson() {
if (step.value > 1) goToLesson(step.value - 1)
}
function goToNextLesson() {
if (step.value < titles.value.length) goToLesson(step.value + 1)
}
async function init() {
const res1 = await getTutorials(type.value)
titles.value = res1.data
isEmpty.value = titles.value.length === 0
if (isEmpty.value) return
const id = titles.value[step.value - 1].id
const [res2, exs] = await Promise.allSettled([
getTutorial(id),
getExercises(id),
])
if (res2.status === "fulfilled") tutorial.value = res2.value.data
exercises.value = exs.status === "fulfilled" ? exs.value : []
learnStep.value[type.value] = step.value
}
watch(
() => [route.params.type, route.params.step],
async () => {
if (route.name === "learn") init()
},
{ immediate: true },
)
</script>
<style scoped>
/* 桌面端固定高度,让目录/内容/代码三栏各自内部滚动;移动端不限高,交给页面整体滚动 */
@media (min-width: 769px) {
.learn-container {
height: calc(100vh - 138px);
}
}
.learn-grid {
height: 100%;
}
.learn-col {
overflow-y: auto;
height: 100%;
}
.learn-col--code {
overflow-y: hidden;
}
.code-card {
height: 100%;
}
</style>

View File

@@ -0,0 +1,69 @@
<script lang="ts" setup>
import { storeToRefs } from "pinia"
import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import { SOURCES } from "utils/constants"
import CodeEditor from "shared/components/CodeEditor.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
import { provideSyncStatus } from "oj/composables/syncStatus"
import storage from "utils/storage"
import type { LANGUAGE } from "utils/types"
import Form from "./Form.vue"
const route = useRoute()
const codeStore = useCodeStore()
const problemStore = useProblemStore()
const { problem } = storeToRefs(problemStore)
const { isDesktop } = useBreakpoints()
// 提供空的同步状态,避免 Form 组件注入错误
// 在竞赛模式下,同步功能会被 showSyncFeature 自动禁用
provideSyncStatus()
const contestID = route.params.contestID || null
const storageKey = computed(
() =>
`problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
)
const editorHeight = computed(() =>
isDesktop.value ? "calc(100vh - 133px)" : "calc(100vh - 172px)",
)
onMounted(() => {
const savedCode = storage.get(storageKey.value)
codeStore.setCode(
savedCode ||
problem.value!.template[codeStore.code.language] ||
SOURCES[codeStore.code.language],
)
})
const changeCode = (v: string) => {
storage.set(storageKey.value, v)
}
const changeLanguage = (v: LANGUAGE) => {
const savedCode = storage.get(storageKey.value)
codeStore.setCode(
savedCode && storageKey.value.split("_").pop() === v
? savedCode
: problem.value!.template[codeStore.code.language] ||
SOURCES[codeStore.code.language],
)
}
</script>
<template>
<n-flex vertical>
<Form :storage-key="storageKey" @change-language="changeLanguage" />
<CodeEditor
v-model:value="codeStore.code.value"
:language="codeStore.code.language"
:height="editorHeight"
@update:model-value="changeCode"
/>
</n-flex>
</template>

View File

@@ -0,0 +1,161 @@
<script lang="ts" setup>
import { storeToRefs } from "pinia"
import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import { SOURCES } from "utils/constants"
import CodeEditor from "shared/components/CodeEditor.vue"
import storage from "utils/storage"
import { createTestSubmission } from "utils/judge"
import { LANGUAGE_SHOW_VALUE } from "utils/constants"
import type { DropdownOption } from "naive-ui"
import { copyToClipboard } from "utils/functions"
const message = useMessage()
const route = useRoute()
const contestID = !!route.params.contestID ? route.params.contestID : null
const codeStore = useCodeStore()
const problemStore = useProblemStore()
const { input, output } = storeToRefs(codeStore)
const { problem } = storeToRefs(problemStore)
const storageKey = computed(
() =>
`problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
)
onMounted(() => {
if (storage.get(storageKey.value)) {
codeStore.setCode(storage.get(storageKey.value))
} else {
codeStore.setCode(
problem.value!.template[codeStore.code.language] ||
SOURCES[codeStore.code.language],
)
}
})
function changeCode(v: string) {
storage.set(storageKey.value, v)
}
function changeLanguage(v: string) {
if (
storage.get(storageKey.value) &&
storageKey.value.split("_").pop() === v
) {
codeStore.setCode(storage.get(storageKey.value))
} else {
codeStore.setCode(
problem.value!.template[codeStore.code.language] ||
SOURCES[codeStore.code.language],
)
}
}
const copy = async () => {
const success = await copyToClipboard(codeStore.code.value)
message[success ? "success" : "error"](`代码复制${success ? "成功" : "失败"}`)
}
const reset = () => {
codeStore.setCode(
problem.value!.template[codeStore.code.language] ||
SOURCES[codeStore.code.language],
)
storage.remove(storageKey.value)
message.success("代码重置成功")
}
const runCode = async () => {
const res = await createTestSubmission(codeStore.code, input.value)
output.value = res.output
}
const languageOptions: DropdownOption[] = problem.value!.languages.map(
(it) => ({
label: () => LANGUAGE_SHOW_VALUE[it],
value: it,
}),
)
</script>
<template>
<n-flex vertical style="height: calc(100vh - 92px)">
<n-split direction="horizontal" :min="1 / 3" :max="4 / 5">
<template #1>
<n-flex vertical>
<n-flex align="center">
<n-select
v-model:value="codeStore.code.language"
style="width: 120px"
:options="languageOptions"
@update:value="changeLanguage"
/>
<n-button @click="copy">复制代码</n-button>
<n-button @click="reset">重置代码</n-button>
<n-button type="primary" secondary @click="runCode">
运行代码
</n-button>
</n-flex>
<CodeEditor
v-model:value="codeStore.code.value"
@update:model-value="changeCode"
:language="codeStore.code.language"
/>
</n-flex>
</template>
<template #2>
<n-split
direction="vertical"
:default-size="1 / 3"
:min="1 / 5"
:max="3 / 5"
>
<template #1>
<div class="title">输入框</div>
<n-input
v-model:value="input"
type="textarea"
:bordered="false"
:resizable="false"
class="box"
/>
</template>
<template #2>
<div class="title">输出框</div>
<n-input
class="box output"
v-model:value="output"
placeholder=""
type="textarea"
:bordered="false"
:resizable="false"
readonly
/>
</template>
</n-split>
</template>
</n-split>
</n-flex>
</template>
<style scoped>
.title {
height: 40px;
line-height: 40px;
padding-left: 20px;
font-size: 16px;
}
.box {
padding-left: 10px;
box-sizing: border-box;
height: calc(100% - 40px);
font-size: 20px;
}
.output {
font-family: "Monaco";
}
</style>

Some files were not shown because too many files have changed in this diff Show More