1 parent
23844ae301
commit
d25b5dbe6d
268 files changed
+3684
-8861
No files matched your search
@@ -112,15 +112,11 @@ async function save() {
|
||||
<div class="icon-preview">
|
||||
<AchievementIcon :icon="form.icon" :size="28" />
|
||||
</div>
|
||||
<n-input
|
||||
v-model:value="form.icon"
|
||||
placeholder="iconify 图标名,例如 noto:owl"
|
||||
/>
|
||||
<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
|
||||
搜索。
|
||||
预览;预览不出来说明名字写错了。图标名可在 icon-sets.iconify.design 搜索。
|
||||
</n-text>
|
||||
</n-flex>
|
||||
</n-form-item>
|
||||
@@ -128,11 +124,7 @@ async function save() {
|
||||
<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-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>
|
||||
@@ -152,9 +144,7 @@ async function save() {
|
||||
</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-text depth="3" style="margin-left: 12px"> 未解锁时学生只能看到 ??? </n-text>
|
||||
</n-form-item>
|
||||
<n-form-item label="上架">
|
||||
<n-switch v-model:value="form.visible" />
|
||||
@@ -166,9 +156,7 @@ async function save() {
|
||||
<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-button type="primary" :loading="saving" @click="save"> 保存 </n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-modal>
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { NButton, NFlex } from "naive-ui"
|
||||
import {
|
||||
deleteAchievement,
|
||||
getAdminAchievements,
|
||||
type AdminAchievement,
|
||||
} from "admin/api"
|
||||
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"
|
||||
@@ -100,16 +96,8 @@ const columns: DataTableColumn<AdminAchievement>[] = [
|
||||
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) },
|
||||
() => "删除",
|
||||
),
|
||||
h(NButton, { text: true, type: "primary", onClick: () => edit(row) }, () => "编辑"),
|
||||
h(NButton, { text: true, type: "error", onClick: () => remove(row) }, () => "删除"),
|
||||
]),
|
||||
},
|
||||
]
|
||||
@@ -124,8 +112,7 @@ onMounted(load)
|
||||
</template>
|
||||
|
||||
<n-alert type="info" style="margin-bottom: 12px">
|
||||
「已解锁人数」是唯一的仪表盘:配置一周后仍为
|
||||
0,多半是阈值配错了而不是太难。
|
||||
「已解锁人数」是唯一的仪表盘:配置一周后仍为 0,多半是阈值配错了而不是太难。
|
||||
</n-alert>
|
||||
|
||||
<n-data-table
|
||||
@@ -135,10 +122,6 @@ onMounted(load)
|
||||
:row-key="(row: AdminAchievement) => row.id"
|
||||
/>
|
||||
|
||||
<AchievementModal
|
||||
v-model:show="showModal"
|
||||
:editing="editing"
|
||||
@saved="load"
|
||||
/>
|
||||
<AchievementModal v-model:show="showModal" :editing="editing" @saved="load" />
|
||||
</n-card>
|
||||
</template>
|
||||
@@ -30,11 +30,7 @@
|
||||
</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"
|
||||
/>
|
||||
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
|
||||
|
||||
<n-modal
|
||||
v-model:show="showModal"
|
||||
@@ -68,12 +64,7 @@ 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 { getAIReportList, getAIReportDetail, pinAIReport, getPinnedAIReports } from "../api"
|
||||
import { NButton, NTag } from "naive-ui"
|
||||
import type { AdminAiReport, AdminAiReportListItem } from "utils/types"
|
||||
|
||||
@@ -95,8 +86,7 @@ const columns: DataTableColumn<ReportItem>[] = [
|
||||
title: "用户名",
|
||||
key: "username",
|
||||
width: 150,
|
||||
render: (row) =>
|
||||
h("span", { style: row.isPinned ? "font-weight:600" : "" }, row.username),
|
||||
render: (row) => h("span", { style: row.isPinned ? "font-weight:600" : "" }, row.username),
|
||||
},
|
||||
{
|
||||
title: "AI 分析内容",
|
||||
@@ -114,9 +104,7 @@ const columns: DataTableColumn<ReportItem>[] = [
|
||||
key: "is_pinned",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
row.isPinned
|
||||
? h(NTag, { type: "warning", size: "small" }, () => "已锁定")
|
||||
: null,
|
||||
row.isPinned ? h(NTag, { type: "warning", size: "small" }, () => "已锁定") : null,
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
|
||||
@@ -25,9 +25,7 @@ async function handleDelete() {
|
||||
</script>
|
||||
<template>
|
||||
<n-flex>
|
||||
<n-button size="small" type="success" secondary @click="goEdit">
|
||||
编辑
|
||||
</n-button>
|
||||
<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>
|
||||
|
||||
@@ -80,11 +80,7 @@ onMounted(init)
|
||||
<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-select class="select" v-model:value="announcement.tag" :options="tags" />
|
||||
</n-form-item>
|
||||
<n-form-item label="可见">
|
||||
<n-switch v-model:value="announcement.visible" />
|
||||
@@ -93,12 +89,7 @@ onMounted(init)
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
|
||||
@@ -57,8 +57,7 @@ const columns: DataTableColumn<AdminAnnouncementListItem>[] = [
|
||||
title: "选项",
|
||||
key: "actions",
|
||||
width: 140,
|
||||
render: (row) =>
|
||||
h(Actions, { announcementID: row.id, onDeleted: listAnnouncements }),
|
||||
render: (row) => h(Actions, { announcementID: row.id, onDeleted: listAnnouncements }),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -96,19 +95,12 @@ watch(query, listAnnouncements, { deep: true })
|
||||
<template>
|
||||
<n-flex align="center" class="titleWrapper">
|
||||
<h2 class="title">网站公告</h2>
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="$router.push({ name: 'admin announcement create' })"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
+34
-99
@@ -63,9 +63,7 @@ export async function getProblemList(
|
||||
contestID?: string,
|
||||
tagId?: number,
|
||||
) {
|
||||
const endpoint = contestID
|
||||
? `admin/contests/${contestID}/problems`
|
||||
: "admin/problems"
|
||||
const endpoint = contestID ? `admin/contests/${contestID}/problems` : "admin/problems"
|
||||
const res = await api.get<AdminProblemList>(endpoint, {
|
||||
params: { offset, limit, keyword, author, tagId },
|
||||
})
|
||||
@@ -133,24 +131,14 @@ export function batchTagProblems(
|
||||
}
|
||||
|
||||
// 用户排名(后台版,无 100 名上限;公开榜单是 oj/api.ts 的 getRank)
|
||||
export function getAdminUserRank(
|
||||
offset: number,
|
||||
limit: number,
|
||||
keyword: string,
|
||||
) {
|
||||
export function getAdminUserRank(offset: number, limit: number, keyword: string) {
|
||||
return api.get<AdminUserRank>("admin/rankings/users", {
|
||||
params: { offset, limit, keyword },
|
||||
})
|
||||
}
|
||||
|
||||
// 用户列表
|
||||
export function getUserList(
|
||||
offset = 0,
|
||||
limit = 10,
|
||||
type = "",
|
||||
keyword: string,
|
||||
orderBy = "",
|
||||
) {
|
||||
export function getUserList(offset = 0, limit = 10, type = "", keyword: string, orderBy = "") {
|
||||
return api.get<AdminUserList>("admin/users", {
|
||||
// "-last_login" 是旧接口传下来的取值(路由 query 里可能还存着),改叫 "-lastLogin";
|
||||
// "-online" 是新增的,原样透传
|
||||
@@ -179,9 +167,7 @@ export function editUser(user: AdminUserForm) {
|
||||
|
||||
// 重置用户密码,返回新密码
|
||||
export async function resetPassword(userID: number) {
|
||||
const res = await api.post<{ password: string }>(
|
||||
`admin/users/${userID}/reset-password`,
|
||||
)
|
||||
const res = await api.post<{ password: string }>(`admin/users/${userID}/reset-password`)
|
||||
return res.password
|
||||
}
|
||||
|
||||
@@ -242,14 +228,8 @@ export function getSQLTestcaseScripts(problemId: number) {
|
||||
}
|
||||
|
||||
// AI 根据标准答案生成一个 SQL 测试点初始化脚本
|
||||
export function generateSQLTestcase(data: {
|
||||
refSql: string
|
||||
mode: "query" | "modify"
|
||||
}) {
|
||||
return api.post<GenerateSqlTestCaseResponse>(
|
||||
"admin/sql-test-cases/generate",
|
||||
data,
|
||||
)
|
||||
export function generateSQLTestcase(data: { refSql: string; mode: "query" | "modify" }) {
|
||||
return api.post<GenerateSqlTestCaseResponse>("admin/sql-test-cases/generate", data)
|
||||
}
|
||||
|
||||
/** 出站补默认值。字段名两边已经一致,不再做键名转换 */
|
||||
@@ -328,15 +308,11 @@ export function getContest(id: string) {
|
||||
return api.get<AdminContest>(`admin/contests/${id}`)
|
||||
}
|
||||
|
||||
export function addProblemForContest(
|
||||
contestID: string,
|
||||
problemID: number,
|
||||
displayID: string,
|
||||
) {
|
||||
return api.post<AdminProblem>(
|
||||
`admin/contests/${contestID}/problems/from-public`,
|
||||
{ problemId: problemID, displayId: displayID },
|
||||
)
|
||||
export function addProblemForContest(contestID: string, problemID: number, displayID: string) {
|
||||
return api.post<AdminProblem>(`admin/contests/${contestID}/problems/from-public`, {
|
||||
problemId: problemID,
|
||||
displayId: displayID,
|
||||
})
|
||||
}
|
||||
|
||||
export function getWebsite() {
|
||||
@@ -364,10 +340,9 @@ export function deleteJudgeServer(hostname: string) {
|
||||
}
|
||||
|
||||
export function getAnnouncementList(offset = 0, limit = 10) {
|
||||
return api.get<{ results: AdminAnnouncementListItem[]; total: number }>(
|
||||
"admin/announcements",
|
||||
{ params: { offset, limit } },
|
||||
)
|
||||
return api.get<{ results: AdminAnnouncementListItem[]; total: number }>("admin/announcements", {
|
||||
params: { offset, limit },
|
||||
})
|
||||
}
|
||||
|
||||
export function getAnnouncement(id: number) {
|
||||
@@ -464,9 +439,9 @@ export function makeProblemPublic(id: number, displayId: string) {
|
||||
export function getACMHelperList(contestId: number) {
|
||||
// acInfo 在契约里是 Record<string, unknown>(acm_contest_rank 的 JSONB 原文),
|
||||
// 组件侧按 ContestSubmissionInfo 读,收窄放在这里
|
||||
return api.get<
|
||||
Array<Omit<AcmHelperItem, "acInfo"> & { acInfo: ContestSubmissionInfo }>
|
||||
>(`admin/contests/${contestId}/acm-helper`)
|
||||
return api.get<Array<Omit<AcmHelperItem, "acInfo"> & { acInfo: ContestSubmissionInfo }>>(
|
||||
`admin/contests/${contestId}/acm-helper`,
|
||||
)
|
||||
}
|
||||
|
||||
export function updateACMHelperChecked(
|
||||
@@ -525,10 +500,7 @@ export function createProblemSet(data: ProblemSetBody) {
|
||||
}
|
||||
|
||||
export function editProblemSet(data: ProblemSetBody & { id: number }) {
|
||||
return api.put<AdminProblemSet>(
|
||||
`admin/problem-sets/${data.id}`,
|
||||
toProblemSetBody(data),
|
||||
)
|
||||
return api.put<AdminProblemSet>(`admin/problem-sets/${data.id}`, toProblemSetBody(data))
|
||||
}
|
||||
|
||||
export function deleteProblemSet(id: number) {
|
||||
@@ -545,9 +517,7 @@ export function updateProblemSetStatus(id: number, status: string) {
|
||||
|
||||
// 题单题目管理 API
|
||||
export function getProblemSetProblems(problemSetId: number) {
|
||||
return api.get<AdminProblemSetProblem[]>(
|
||||
`admin/problem-sets/${problemSetId}/problems`,
|
||||
)
|
||||
return api.get<AdminProblemSetProblem[]>(`admin/problem-sets/${problemSetId}/problems`)
|
||||
}
|
||||
|
||||
export function addProblemToSet(
|
||||
@@ -579,26 +549,16 @@ export function editProblemInSet(
|
||||
hint?: string
|
||||
},
|
||||
) {
|
||||
return api.put(
|
||||
`admin/problem-sets/${problemSetId}/problems/${problemSetProblemId}`,
|
||||
data,
|
||||
)
|
||||
return api.put(`admin/problem-sets/${problemSetId}/problems/${problemSetProblemId}`, data)
|
||||
}
|
||||
|
||||
export function removeProblemFromSet(
|
||||
problemSetId: number,
|
||||
problemSetProblemId: number,
|
||||
) {
|
||||
return api.delete(
|
||||
`admin/problem-sets/${problemSetId}/problems/${problemSetProblemId}`,
|
||||
)
|
||||
export function removeProblemFromSet(problemSetId: number, problemSetProblemId: number) {
|
||||
return api.delete(`admin/problem-sets/${problemSetId}/problems/${problemSetProblemId}`)
|
||||
}
|
||||
|
||||
// 题单奖章管理 API
|
||||
export function getProblemSetBadges(problemSetId: number) {
|
||||
return api.get<AdminProblemSetBadge[]>(
|
||||
`admin/problem-sets/${problemSetId}/badges`,
|
||||
)
|
||||
return api.get<AdminProblemSetBadge[]>(`admin/problem-sets/${problemSetId}/badges`)
|
||||
}
|
||||
|
||||
interface BadgeBody {
|
||||
@@ -626,11 +586,7 @@ export function createProblemSetBadge(problemSetId: number, data: BadgeBody) {
|
||||
)
|
||||
}
|
||||
|
||||
export function editProblemSetBadge(
|
||||
problemSetId: number,
|
||||
badgeId: number,
|
||||
data: BadgeBody,
|
||||
) {
|
||||
export function editProblemSetBadge(problemSetId: number, badgeId: number, data: BadgeBody) {
|
||||
return api.put<AdminProblemSetBadge>(
|
||||
`admin/problem-sets/${problemSetId}/badges/${badgeId}`,
|
||||
toBadgeBody(data),
|
||||
@@ -644,9 +600,7 @@ export function deleteProblemSetBadge(problemSetId: number, badgeId: number) {
|
||||
// 题单进度管理 API
|
||||
// 注意:返回的是裸数组,不是分页信封 —— 和 oj 侧的 /user-progress 不同
|
||||
export function getProblemSetProgress(problemSetId: number) {
|
||||
return api.get<AdminProblemSetProgress[]>(
|
||||
`admin/problem-sets/${problemSetId}/progress`,
|
||||
)
|
||||
return api.get<AdminProblemSetProgress[]>(`admin/problem-sets/${problemSetId}/progress`)
|
||||
}
|
||||
|
||||
export function removeUserFromProblemSet(problemSetId: number, userId: number) {
|
||||
@@ -658,41 +612,28 @@ export function getStuckProblems() {
|
||||
return api.get<StuckProblem[]>("admin/problem-analytics/stuck")
|
||||
}
|
||||
|
||||
export function getLearnStudents(params: {
|
||||
type: "python" | "c"
|
||||
className?: string
|
||||
}) {
|
||||
export function getLearnStudents(params: { type: "python" | "c"; className?: string }) {
|
||||
return api.get<LearnStudentProgressList>("admin/learn-analytics/students", {
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
export function getLearnTutorials(params: {
|
||||
type: "python" | "c"
|
||||
className?: string
|
||||
}) {
|
||||
export function getLearnTutorials(params: { type: "python" | "c"; className?: string }) {
|
||||
return api.get<LearnTutorialProgressList>("admin/learn-analytics/tutorials", {
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
export function getLearnExercises(params: {
|
||||
type: "python" | "c"
|
||||
className?: string
|
||||
}) {
|
||||
export function getLearnExercises(params: { type: "python" | "c"; className?: string }) {
|
||||
return api.get<LearnExerciseProgressList>("admin/learn-analytics/exercises", {
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
export function getLearnExerciseAttempts(
|
||||
exerciseId: number,
|
||||
params: { className?: string },
|
||||
) {
|
||||
return api.get<LearnExerciseAttempt[]>(
|
||||
`admin/learn-analytics/exercises/${exerciseId}/attempts`,
|
||||
{ params },
|
||||
)
|
||||
export function getLearnExerciseAttempts(exerciseId: number, params: { className?: string }) {
|
||||
return api.get<LearnExerciseAttempt[]>(`admin/learn-analytics/exercises/${exerciseId}/attempts`, {
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
export function getTopACTrend(params: {
|
||||
@@ -757,17 +698,11 @@ export function getMetricOptions() {
|
||||
}
|
||||
|
||||
export function createAchievement(data: Partial<AdminAchievement>) {
|
||||
return api.post<AdminAchievement>(
|
||||
"admin/achievements",
|
||||
toAchievementBody(data),
|
||||
)
|
||||
return api.post<AdminAchievement>("admin/achievements", toAchievementBody(data))
|
||||
}
|
||||
|
||||
export function updateAchievement(data: Partial<AdminAchievement>) {
|
||||
return api.put<AdminAchievement>(
|
||||
`admin/achievements/${data.id}`,
|
||||
toAchievementBody(data),
|
||||
)
|
||||
return api.put<AdminAchievement>(`admin/achievements/${data.id}`, toAchievementBody(data))
|
||||
}
|
||||
|
||||
export function deleteAchievement(id: number) {
|
||||
|
||||
@@ -45,15 +45,9 @@ async function clone() {
|
||||
</script>
|
||||
<template>
|
||||
<n-flex>
|
||||
<n-button size="small" type="primary" secondary @click="goEditProblems">
|
||||
题目
|
||||
</n-button>
|
||||
<n-button size="small" type="warning" secondary @click="goACMHelper">
|
||||
审核
|
||||
</n-button>
|
||||
<n-button size="small" type="info" secondary @click="goEdit">
|
||||
编辑
|
||||
</n-button>
|
||||
<n-button size="small" type="primary" secondary @click="goEditProblems"> 题目 </n-button>
|
||||
<n-button 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>
|
||||
|
||||
@@ -134,26 +134,14 @@ onMounted(getContestDetail)
|
||||
<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-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-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-date-picker style="width: 200px" v-model:value="endTime" type="datetime" />
|
||||
</n-form-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
@@ -161,11 +149,7 @@ onMounted(getContestDetail)
|
||||
<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-input-number style="width: 120px" step="5" v-model:value="durationMins" />
|
||||
</n-form-item>
|
||||
</template>
|
||||
<n-form-item label="密码">
|
||||
@@ -175,12 +159,7 @@ onMounted(getContestDetail)
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
|
||||
@@ -47,9 +47,7 @@ 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,
|
||||
)
|
||||
const acTime = new Date(contestStartTime.value.getTime() + relativeSeconds * 1000)
|
||||
return parseTime(acTime, "YYYY-MM-DD HH:mm:ss")
|
||||
}
|
||||
|
||||
@@ -57,12 +55,7 @@ function formatACTime(relativeSeconds: number) {
|
||||
async function toggleChecked(item: HelperItem) {
|
||||
const newChecked = !item.checked
|
||||
try {
|
||||
await updateACMHelperChecked(
|
||||
Number(props.contestID),
|
||||
item.id,
|
||||
item.problemId,
|
||||
newChecked,
|
||||
)
|
||||
await updateACMHelperChecked(Number(props.contestID), item.id, item.problemId, newChecked)
|
||||
// 更新本地状态
|
||||
item.checked = newChecked
|
||||
item.acInfo.checked = newChecked
|
||||
@@ -87,12 +80,7 @@ async function markAllAsChecked() {
|
||||
const loadingMsg = message.loading("正在标记...", { duration: 0 })
|
||||
try {
|
||||
for (const item of unchecked) {
|
||||
await updateACMHelperChecked(
|
||||
Number(props.contestID),
|
||||
item.id,
|
||||
item.problemId,
|
||||
true,
|
||||
)
|
||||
await updateACMHelperChecked(Number(props.contestID), item.id, item.problemId, true)
|
||||
item.checked = true
|
||||
item.acInfo.checked = true
|
||||
}
|
||||
@@ -112,8 +100,7 @@ async function markAllAsChecked() {
|
||||
const filteredSubmissions = computed(() => {
|
||||
return submissions.value.filter((item) => {
|
||||
if (query.username && !item.username.includes(query.username)) return false
|
||||
if (query.problemId && !item.problemDisplayId.includes(query.problemId))
|
||||
return false
|
||||
if (query.problemId && !item.problemDisplayId.includes(query.problemId)) return false
|
||||
if (query.checked === "checked" && !item.checked) return false
|
||||
if (query.checked === "unchecked" && item.checked) return false
|
||||
return true
|
||||
@@ -250,15 +237,9 @@ onMounted(loadData)
|
||||
<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-tag type="warning" size="large"> 未检查: {{ stats.unchecked }} </n-tag>
|
||||
</n-flex>
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="stats.unchecked === 0"
|
||||
@click="markAllAsChecked"
|
||||
>
|
||||
<n-button type="primary" :disabled="stats.unchecked === 0" @click="markAllAsChecked">
|
||||
标记全部为已检查
|
||||
</n-button>
|
||||
</n-flex>
|
||||
@@ -281,11 +262,7 @@ onMounted(loadData)
|
||||
style="width: 150px"
|
||||
clearable
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="query.checked"
|
||||
:options="checkedOptions"
|
||||
style="width: 120px"
|
||||
/>
|
||||
<n-select v-model:value="query.checked" :options="checkedOptions" style="width: 120px" />
|
||||
</n-flex>
|
||||
|
||||
<n-data-table
|
||||
|
||||
@@ -102,10 +102,7 @@ watchDebounced(() => query.keyword, listContests, {
|
||||
<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 type="primary" @click="$router.push({ name: 'admin contest create' })">
|
||||
新建
|
||||
</n-button>
|
||||
</n-flex>
|
||||
@@ -114,11 +111,7 @@ watchDebounced(() => query.keyword, listContests, {
|
||||
</div>
|
||||
</n-flex>
|
||||
<n-data-table :columns="columns" :data="contests" />
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:limit="query.limit"
|
||||
v-model:page="query.page"
|
||||
/>
|
||||
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -24,11 +24,7 @@ const columns: DataTableColumn<LearnExerciseAttempt>[] = [
|
||||
width: 130,
|
||||
render: (row) =>
|
||||
row.solved
|
||||
? h(
|
||||
NText,
|
||||
{ type: "success" },
|
||||
() => `做对了(第 ${row.attemptsToSolve} 次)`,
|
||||
)
|
||||
? h(NText, { type: "success" }, () => `做对了(第 ${row.attemptsToSolve} 次)`)
|
||||
: h(NText, { type: "error" }, () => "还没做对"),
|
||||
},
|
||||
{ title: "提交次数", key: "attempts", width: 100, sorter: "default" },
|
||||
|
||||
+136
-186
@@ -1,11 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { TUTORIAL_READ_SECONDS } from "@oj2/contract"
|
||||
import { NProgress, NTag, NText } from "naive-ui"
|
||||
import {
|
||||
getLearnStudents,
|
||||
getLearnTutorials,
|
||||
getLearnExercises,
|
||||
} from "admin/api"
|
||||
import { getLearnStudents, getLearnTutorials, getLearnExercises } from "admin/api"
|
||||
import { readableDuration, parseTime } from "utils/functions"
|
||||
import type {
|
||||
LearnStudentProgress,
|
||||
@@ -92,16 +88,11 @@ const statusCounts = computed(() => {
|
||||
return counts
|
||||
})
|
||||
|
||||
const startedCount = computed(
|
||||
() => students.value.length - statusCounts.value.idle,
|
||||
)
|
||||
const startedCount = computed(() => students.value.length - statusCounts.value.idle)
|
||||
|
||||
const avgRead = computed(() =>
|
||||
students.value.length
|
||||
? (
|
||||
students.value.reduce((n, row) => n + row.readCount, 0) /
|
||||
students.value.length
|
||||
).toFixed(1)
|
||||
? (students.value.reduce((n, row) => n + row.readCount, 0) / students.value.length).toFixed(1)
|
||||
: "0",
|
||||
)
|
||||
|
||||
@@ -147,11 +138,7 @@ const studentColumns = computed<DataTableColumn<LearnStudentProgress>[]>(() => [
|
||||
width: 110,
|
||||
render: (row) => {
|
||||
const meta = STATUS_META[statusOf(row)]
|
||||
return h(
|
||||
NTag,
|
||||
{ size: "small", type: meta.type, bordered: false },
|
||||
() => meta.label,
|
||||
)
|
||||
return h(NTag, { size: "small", type: meta.type, bordered: false }, () => meta.label)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -193,11 +180,7 @@ const studentColumns = computed<DataTableColumn<LearnStudentProgress>[]>(() => [
|
||||
)
|
||||
: null,
|
||||
row.exerciseAttempts
|
||||
? h(
|
||||
NText,
|
||||
{ depth: 3, style: "font-size: 12px" },
|
||||
() => `共 ${row.exerciseAttempts} 次`,
|
||||
)
|
||||
? h(NText, { depth: 3, style: "font-size: 12px" }, () => `共 ${row.exerciseAttempts} 次`)
|
||||
: null,
|
||||
]),
|
||||
},
|
||||
@@ -217,150 +200,134 @@ const studentColumns = computed<DataTableColumn<LearnStudentProgress>[]>(() => [
|
||||
},
|
||||
])
|
||||
|
||||
const tutorialColumns = computed<DataTableColumn<LearnTutorialProgress>[]>(
|
||||
() => [
|
||||
{
|
||||
title: "#",
|
||||
key: "order",
|
||||
width: 60,
|
||||
render: (_, index) => index + 1,
|
||||
},
|
||||
{ title: "课程", key: "title", minWidth: 200 },
|
||||
{
|
||||
title: `读过的人(共 ${studentCount.value} 人)`,
|
||||
key: "readers",
|
||||
width: 200,
|
||||
sorter: "default",
|
||||
render: (row) =>
|
||||
h("div", { style: "display: flex; align-items: center; gap: 8px" }, [
|
||||
h("span", `${row.readers} / ${studentCount.value}`),
|
||||
h(NProgress, {
|
||||
type: "line",
|
||||
percentage: studentCount.value
|
||||
? Math.round((row.readers / studentCount.value) * 100)
|
||||
: 0,
|
||||
showIndicator: false,
|
||||
status: row.readers === 0 ? "error" : "success",
|
||||
style: "width: 70px",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: "人均时长",
|
||||
key: "avgSeconds",
|
||||
width: 130,
|
||||
sorter: "default",
|
||||
render: (row) => readableDuration(row.avgSeconds),
|
||||
},
|
||||
{
|
||||
title: "累计时长",
|
||||
key: "totalSeconds",
|
||||
width: 130,
|
||||
sorter: "default",
|
||||
render: (row) => readableDuration(row.totalSeconds),
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
const exerciseColumns = computed<DataTableColumn<LearnExerciseProgress>[]>(
|
||||
() => [
|
||||
{
|
||||
type: "expand",
|
||||
renderExpand: (row) =>
|
||||
h(ExerciseAttempts, {
|
||||
exerciseId: row.exerciseId,
|
||||
className: className.value.trim(),
|
||||
const tutorialColumns = computed<DataTableColumn<LearnTutorialProgress>[]>(() => [
|
||||
{
|
||||
title: "#",
|
||||
key: "order",
|
||||
width: 60,
|
||||
render: (_, index) => index + 1,
|
||||
},
|
||||
{ title: "课程", key: "title", minWidth: 200 },
|
||||
{
|
||||
title: `读过的人(共 ${studentCount.value} 人)`,
|
||||
key: "readers",
|
||||
width: 200,
|
||||
sorter: "default",
|
||||
render: (row) =>
|
||||
h("div", { style: "display: flex; align-items: center; gap: 8px" }, [
|
||||
h("span", `${row.readers} / ${studentCount.value}`),
|
||||
h(NProgress, {
|
||||
type: "line",
|
||||
percentage: studentCount.value ? Math.round((row.readers / studentCount.value) * 100) : 0,
|
||||
showIndicator: false,
|
||||
status: row.readers === 0 ? "error" : "success",
|
||||
style: "width: 70px",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: "人均时长",
|
||||
key: "avgSeconds",
|
||||
width: 130,
|
||||
sorter: "default",
|
||||
render: (row) => readableDuration(row.avgSeconds),
|
||||
},
|
||||
{
|
||||
title: "累计时长",
|
||||
key: "totalSeconds",
|
||||
width: 130,
|
||||
sorter: "default",
|
||||
render: (row) => readableDuration(row.totalSeconds),
|
||||
},
|
||||
])
|
||||
|
||||
const exerciseColumns = computed<DataTableColumn<LearnExerciseProgress>[]>(() => [
|
||||
{
|
||||
type: "expand",
|
||||
renderExpand: (row) =>
|
||||
h(ExerciseAttempts, {
|
||||
exerciseId: row.exerciseId,
|
||||
className: className.value.trim(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: "课",
|
||||
key: "tutorialOrder",
|
||||
width: 160,
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row) => `${row.tutorialOrder}. ${row.tutorialTitle}`,
|
||||
},
|
||||
{
|
||||
title: "题型",
|
||||
key: "type",
|
||||
width: 90,
|
||||
render: (row) => EXERCISE_TYPE_LABEL[row.type] ?? row.type,
|
||||
},
|
||||
{
|
||||
title: "题干",
|
||||
key: "question",
|
||||
minWidth: 220,
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row) => row.question || "(无题干)",
|
||||
},
|
||||
{
|
||||
// 试的人不少、却没人一次做对,或者一半以上的人没做对 —— 多半是题有坑,
|
||||
// 老师应该先去看展开里全班「最后一次错在」是不是同一个干扰项
|
||||
title: "提示",
|
||||
key: "flag",
|
||||
width: 100,
|
||||
render: (row) => {
|
||||
if (row.triedUsers < 3) return null
|
||||
if (row.firstTryUsers === 0 && row.solvedUsers > 0) {
|
||||
return h(NTag, { size: "small", type: "warning", bordered: false }, () => "没人一次对")
|
||||
}
|
||||
if (row.solvedUsers / row.triedUsers < 0.5) {
|
||||
return h(NTag, { size: "small", type: "error", bordered: false }, () => "多数人卡住")
|
||||
}
|
||||
return null
|
||||
},
|
||||
{
|
||||
title: "课",
|
||||
key: "tutorialOrder",
|
||||
width: 160,
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row) => `${row.tutorialOrder}. ${row.tutorialTitle}`,
|
||||
},
|
||||
{
|
||||
title: "题型",
|
||||
key: "type",
|
||||
width: 90,
|
||||
render: (row) => EXERCISE_TYPE_LABEL[row.type] ?? row.type,
|
||||
},
|
||||
{
|
||||
title: "题干",
|
||||
key: "question",
|
||||
minWidth: 220,
|
||||
ellipsis: { tooltip: true },
|
||||
render: (row) => row.question || "(无题干)",
|
||||
},
|
||||
{
|
||||
// 试的人不少、却没人一次做对,或者一半以上的人没做对 —— 多半是题有坑,
|
||||
// 老师应该先去看展开里全班「最后一次错在」是不是同一个干扰项
|
||||
title: "提示",
|
||||
key: "flag",
|
||||
width: 100,
|
||||
render: (row) => {
|
||||
if (row.triedUsers < 3) return null
|
||||
if (row.firstTryUsers === 0 && row.solvedUsers > 0) {
|
||||
return h(
|
||||
NTag,
|
||||
{ size: "small", type: "warning", bordered: false },
|
||||
() => "没人一次对",
|
||||
)
|
||||
}
|
||||
if (row.solvedUsers / row.triedUsers < 0.5) {
|
||||
return h(
|
||||
NTag,
|
||||
{ size: "small", type: "error", bordered: false },
|
||||
() => "多数人卡住",
|
||||
)
|
||||
}
|
||||
return null
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "做对 / 做过",
|
||||
key: "solvedUsers",
|
||||
width: 150,
|
||||
sorter: "default",
|
||||
render: (row) =>
|
||||
h("div", { style: "display: flex; align-items: center; gap: 8px" }, [
|
||||
h("span", `${row.solvedUsers} / ${row.triedUsers}`),
|
||||
h(NProgress, {
|
||||
type: "line",
|
||||
percentage: row.triedUsers
|
||||
? Math.round((row.solvedUsers / row.triedUsers) * 100)
|
||||
: 0,
|
||||
showIndicator: false,
|
||||
status: row.triedUsers === 0 ? "error" : "success",
|
||||
style: "width: 60px",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: "一次做对",
|
||||
key: "firstTryUsers",
|
||||
width: 110,
|
||||
sorter: "default",
|
||||
render: (row) => `${row.firstTryUsers} 人`,
|
||||
},
|
||||
{
|
||||
// 做对的人平均试了几次。它和「一次做对」一起看才分得清难题和歧义题:
|
||||
// 平均 3 次但没人一次对 → 题目本身有坑
|
||||
title: "平均试几次",
|
||||
key: "avgAttemptsToSolve",
|
||||
width: 120,
|
||||
sorter: "default",
|
||||
defaultSortOrder: "descend",
|
||||
render: (row) => (row.solvedUsers ? `${row.avgAttemptsToSolve} 次` : "-"),
|
||||
},
|
||||
{
|
||||
title: "提交总次数",
|
||||
key: "attempts",
|
||||
width: 120,
|
||||
sorter: "default",
|
||||
},
|
||||
],
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "做对 / 做过",
|
||||
key: "solvedUsers",
|
||||
width: 150,
|
||||
sorter: "default",
|
||||
render: (row) =>
|
||||
h("div", { style: "display: flex; align-items: center; gap: 8px" }, [
|
||||
h("span", `${row.solvedUsers} / ${row.triedUsers}`),
|
||||
h(NProgress, {
|
||||
type: "line",
|
||||
percentage: row.triedUsers ? Math.round((row.solvedUsers / row.triedUsers) * 100) : 0,
|
||||
showIndicator: false,
|
||||
status: row.triedUsers === 0 ? "error" : "success",
|
||||
style: "width: 60px",
|
||||
}),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: "一次做对",
|
||||
key: "firstTryUsers",
|
||||
width: 110,
|
||||
sorter: "default",
|
||||
render: (row) => `${row.firstTryUsers} 人`,
|
||||
},
|
||||
{
|
||||
// 做对的人平均试了几次。它和「一次做对」一起看才分得清难题和歧义题:
|
||||
// 平均 3 次但没人一次对 → 题目本身有坑
|
||||
title: "平均试几次",
|
||||
key: "avgAttemptsToSolve",
|
||||
width: 120,
|
||||
sorter: "default",
|
||||
defaultSortOrder: "descend",
|
||||
render: (row) => (row.solvedUsers ? `${row.avgAttemptsToSolve} 次` : "-"),
|
||||
},
|
||||
{
|
||||
title: "提交总次数",
|
||||
key: "attempts",
|
||||
width: 120,
|
||||
sorter: "default",
|
||||
},
|
||||
])
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
@@ -410,9 +377,7 @@ onMounted(load)
|
||||
@clear="load"
|
||||
/>
|
||||
<n-button type="primary" secondary @click="load">查询</n-button>
|
||||
<n-text depth="3">
|
||||
{{ studentCount }} 名学生,{{ startedCount }} 人已经开始学
|
||||
</n-text>
|
||||
<n-text depth="3"> {{ studentCount }} 名学生,{{ startedCount }} 人已经开始学 </n-text>
|
||||
<!-- 口径写在表上方,免得老师对着「已读 0 课 / 累计 25 分钟」猜是不是坏了 -->
|
||||
<n-text depth="3" style="font-size: 12px">
|
||||
「已读」按累计停留满
|
||||
@@ -420,13 +385,7 @@ onMounted(load)
|
||||
</n-text>
|
||||
</n-flex>
|
||||
|
||||
<n-grid
|
||||
cols="2 s:3 m:5"
|
||||
:x-gap="12"
|
||||
:y-gap="12"
|
||||
responsive="screen"
|
||||
style="margin-bottom: 16px"
|
||||
>
|
||||
<n-grid cols="2 s:3 m:5" :x-gap="12" :y-gap="12" responsive="screen" style="margin-bottom: 16px">
|
||||
<n-gi>
|
||||
<n-card size="small" :bordered="true">
|
||||
<n-statistic label="学生" :value="studentCount" />
|
||||
@@ -448,10 +407,7 @@ onMounted(load)
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-card size="small">
|
||||
<n-statistic
|
||||
label="练一练做对率"
|
||||
:value="solveRate === null ? '-' : `${solveRate}%`"
|
||||
/>
|
||||
<n-statistic label="练一练做对率" :value="solveRate === null ? '-' : `${solveRate}%`" />
|
||||
</n-card>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
@@ -472,16 +428,10 @@ onMounted(load)
|
||||
clearable
|
||||
style="width: 200px"
|
||||
/>
|
||||
<n-text v-if="keyword.trim()" depth="3">
|
||||
找到 {{ filteredStudents.length }} 人
|
||||
</n-text>
|
||||
<n-text v-if="keyword.trim()" depth="3"> 找到 {{ filteredStudents.length }} 人 </n-text>
|
||||
</n-flex>
|
||||
<n-flex :size="8" style="margin-bottom: 12px">
|
||||
<n-tag
|
||||
checkable
|
||||
:checked="statusFilter === 'all'"
|
||||
@update:checked="statusFilter = 'all'"
|
||||
>
|
||||
<n-tag checkable :checked="statusFilter === 'all'" @update:checked="statusFilter = 'all'">
|
||||
全部 {{ students.length }}
|
||||
</n-tag>
|
||||
<n-tag
|
||||
|
||||
@@ -14,15 +14,7 @@ import {
|
||||
import { getTopACTrend } from "admin/api"
|
||||
import { zonedYear } from "utils/functions"
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
Filler,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
)
|
||||
ChartJS.register(CategoryScale, Filler, LinearScale, LineElement, PointElement, Title, Tooltip)
|
||||
|
||||
type ProblemTrend = AcTrend
|
||||
|
||||
@@ -161,10 +153,7 @@ onMounted(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 v-if="!loading && data.length === 0" style="text-align: center; padding: 40px">
|
||||
暂无数据
|
||||
</div>
|
||||
<div v-else class="grid">
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { errorCode, errorMessage } from "utils/api"
|
||||
import {
|
||||
deleteContestProblem,
|
||||
deleteProblem,
|
||||
makeProblemPublic,
|
||||
} from "admin/api"
|
||||
import { deleteContestProblem, deleteProblem, makeProblemPublic } from "admin/api"
|
||||
import download from "utils/download"
|
||||
|
||||
interface Props {
|
||||
@@ -18,9 +14,7 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const isContestProblem = computed(
|
||||
() => route.name === "admin contest problem list",
|
||||
)
|
||||
const isContestProblem = computed(() => route.name === "admin contest problem list")
|
||||
|
||||
const showMakePublicModal = ref(false)
|
||||
const newDisplayID = ref("")
|
||||
@@ -95,20 +89,11 @@ async function handleMakePublic() {
|
||||
</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-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 size="small" secondary type="warning" @click="openMakePublicModal">
|
||||
公开
|
||||
</n-button>
|
||||
</template>
|
||||
@@ -135,9 +120,7 @@ async function handleMakePublic() {
|
||||
style="width: 500px"
|
||||
>
|
||||
<n-space vertical>
|
||||
<p>
|
||||
将竞赛题目转为公开题目后,会创建一个新的公开题目副本,原题目保持不变。
|
||||
</p>
|
||||
<p>将竞赛题目转为公开题目后,会创建一个新的公开题目副本,原题目保持不变。</p>
|
||||
<n-form>
|
||||
<n-form-item label="新的题目编号" required>
|
||||
<n-input
|
||||
@@ -148,8 +131,7 @@ async function handleMakePublic() {
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-alert type="info" title="提示:请输入一个未被使用的题目编号">
|
||||
</n-alert>
|
||||
<n-alert type="info" title="提示:请输入一个未被使用的题目编号"> </n-alert>
|
||||
</n-space>
|
||||
<template #footer>
|
||||
<n-flex justify="end">
|
||||
|
||||
@@ -17,11 +17,7 @@ const displayID = ref(props.nextDisplayId || "")
|
||||
async function addProblem() {
|
||||
if (!displayID.value) return
|
||||
try {
|
||||
await addProblemForContest(
|
||||
props.contestID,
|
||||
props.problemID,
|
||||
displayID.value,
|
||||
)
|
||||
await addProblemForContest(props.contestID, props.problemID, displayID.value)
|
||||
emit("added")
|
||||
} catch (err) {
|
||||
if (errorCode(err) === "display-id-exists") {
|
||||
|
||||
@@ -73,18 +73,9 @@ watchDebounced(
|
||||
title="从题库中添加"
|
||||
@close="$emit('update:show', false)"
|
||||
>
|
||||
<n-input
|
||||
class="search"
|
||||
v-model:value="query.keyword"
|
||||
clearable
|
||||
placeholder="搜索标题或编号"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
|
||||
</n-modal>
|
||||
</template>
|
||||
<style scoped>
|
||||
|
||||
@@ -70,27 +70,22 @@ const ENGINE_OPTIONS: SelectOption[] = [
|
||||
// 列表推导式、f-string 这些 C 根本没有的东西 —— 存得进去,判题时永远失败
|
||||
// (或者反过来,「不能使用 f-string」永远通过),两头都不报错。
|
||||
function nodeTargetOptions(lang: string): SelectOption[] {
|
||||
return Object.entries(AST_NODE_TARGETS_BY_LANGUAGE[lang] ?? {}).map(
|
||||
([value, entry]) => ({ label: entry.label, value }),
|
||||
)
|
||||
return Object.entries(AST_NODE_TARGETS_BY_LANGUAGE[lang] ?? {}).map(([value, entry]) => ({
|
||||
label: entry.label,
|
||||
value,
|
||||
}))
|
||||
}
|
||||
|
||||
// 逻辑名 and/or/not 在 C 里显示成 && / || / !,存进去的还是逻辑名
|
||||
function operatorTargetOptions(lang: string): SelectOption[] {
|
||||
return Object.entries(AST_OPERATOR_TARGETS_BY_LANGUAGE[lang] ?? {}).map(
|
||||
([value, label]) => ({
|
||||
label: label === value ? value : `${label}(${value})`,
|
||||
value,
|
||||
}),
|
||||
)
|
||||
return Object.entries(AST_OPERATOR_TARGETS_BY_LANGUAGE[lang] ?? {}).map(([value, label]) => ({
|
||||
label: label === value ? value : `${label}(${value})`,
|
||||
value,
|
||||
}))
|
||||
}
|
||||
|
||||
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 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"]
|
||||
@@ -171,13 +166,8 @@ function updateRules(lang: string, rules: AstRule[]) {
|
||||
emit("update:modelValue", Object.keys(current).length > 0 ? current : null)
|
||||
}
|
||||
|
||||
function getTargetLabel(
|
||||
lang: string,
|
||||
engine: string,
|
||||
target: string,
|
||||
): string | undefined {
|
||||
if (isNodeEngine(engine))
|
||||
return AST_NODE_TARGETS_BY_LANGUAGE[lang]?.[target]?.label
|
||||
function getTargetLabel(lang: string, engine: string, target: string): string | undefined {
|
||||
if (isNodeEngine(engine)) return AST_NODE_TARGETS_BY_LANGUAGE[lang]?.[target]?.label
|
||||
// 运算符不写 label:判题结果的文案按语言翻译(astOperatorLabel),
|
||||
// 存一个固定 label 反而会把 C 的 && 钉死成 and
|
||||
return undefined
|
||||
@@ -260,17 +250,8 @@ watch(supportedLanguages, (langs) => {
|
||||
暂不支持代码规则检查,判题机只能检查
|
||||
{{ AST_SUPPORTED_LANGUAGES.join(" / ") }}
|
||||
</n-alert>
|
||||
<n-tabs
|
||||
v-if="supportedLanguages.length"
|
||||
type="segment"
|
||||
v-model:value="activeTab"
|
||||
>
|
||||
<n-tab-pane
|
||||
v-for="lang in supportedLanguages"
|
||||
:key="lang"
|
||||
:name="lang"
|
||||
:tab="lang"
|
||||
>
|
||||
<n-tabs v-if="supportedLanguages.length" type="segment" v-model:value="activeTab">
|
||||
<n-tab-pane v-for="lang in supportedLanguages" :key="lang" :name="lang" :tab="lang">
|
||||
<n-flex vertical>
|
||||
<div
|
||||
v-for="(rule, index) in getRulesForLang(lang)"
|
||||
@@ -281,9 +262,7 @@ watch(supportedLanguages, (langs) => {
|
||||
<n-select
|
||||
:options="ENGINE_OPTIONS"
|
||||
:value="rule.engine"
|
||||
@update:value="
|
||||
(v: string) => updateRule(lang, index, 'engine', v)
|
||||
"
|
||||
@update:value="(v: string) => updateRule(lang, index, 'engine', v)"
|
||||
style="width: 150px"
|
||||
size="small"
|
||||
/>
|
||||
@@ -291,9 +270,7 @@ watch(supportedLanguages, (langs) => {
|
||||
v-if="needsTargetDropdown(rule.engine)"
|
||||
:options="nodeTargetOptions(lang)"
|
||||
:value="rule.target"
|
||||
@update:value="
|
||||
(v: string) => updateRule(lang, index, 'target', v)
|
||||
"
|
||||
@update:value="(v: string) => updateRule(lang, index, 'target', v)"
|
||||
style="width: 150px"
|
||||
size="small"
|
||||
filterable
|
||||
@@ -301,9 +278,7 @@ watch(supportedLanguages, (langs) => {
|
||||
<n-input
|
||||
v-if="needsTargetInput(rule.engine)"
|
||||
:value="rule.target"
|
||||
@update:value="
|
||||
(v: string) => updateRule(lang, index, 'target', v)
|
||||
"
|
||||
@update:value="(v: string) => updateRule(lang, index, 'target', v)"
|
||||
placeholder="函数/方法名"
|
||||
style="width: 150px"
|
||||
size="small"
|
||||
@@ -312,9 +287,7 @@ watch(supportedLanguages, (langs) => {
|
||||
v-if="needsOperatorDropdown(rule.engine)"
|
||||
:options="operatorTargetOptions(lang)"
|
||||
:value="rule.target"
|
||||
@update:value="
|
||||
(v: string) => updateRule(lang, index, 'target', v)
|
||||
"
|
||||
@update:value="(v: string) => updateRule(lang, index, 'target', v)"
|
||||
style="width: 150px"
|
||||
size="small"
|
||||
/>
|
||||
@@ -322,18 +295,14 @@ watch(supportedLanguages, (langs) => {
|
||||
<n-select
|
||||
:options="COUNT_MODE_OPTIONS"
|
||||
:value="getCountMode(rule)"
|
||||
@update:value="
|
||||
(v: 'exact' | 'range') => updateCountMode(lang, index, v)
|
||||
"
|
||||
@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)
|
||||
"
|
||||
@update:value="(v: number | null) => updateExactCount(lang, index, v)"
|
||||
placeholder="次数"
|
||||
style="width: 100px"
|
||||
size="small"
|
||||
@@ -343,9 +312,7 @@ watch(supportedLanguages, (langs) => {
|
||||
<template v-else>
|
||||
<n-input-number
|
||||
:value="rule.min ?? null"
|
||||
@update:value="
|
||||
(v: number | null) => updateRule(lang, index, 'min', v)
|
||||
"
|
||||
@update:value="(v: number | null) => updateRule(lang, index, 'min', v)"
|
||||
placeholder="最少"
|
||||
style="width: 100px"
|
||||
size="small"
|
||||
@@ -354,9 +321,7 @@ watch(supportedLanguages, (langs) => {
|
||||
/>
|
||||
<n-input-number
|
||||
:value="rule.max ?? null"
|
||||
@update:value="
|
||||
(v: number | null) => updateRule(lang, index, 'max', v)
|
||||
"
|
||||
@update:value="(v: number | null) => updateRule(lang, index, 'max', v)"
|
||||
placeholder="最多"
|
||||
style="width: 100px"
|
||||
size="small"
|
||||
@@ -367,29 +332,17 @@ watch(supportedLanguages, (langs) => {
|
||||
</template>
|
||||
<n-input
|
||||
:value="rule.message"
|
||||
@update:value="
|
||||
(v: string) => updateRule(lang, index, 'message', v)
|
||||
"
|
||||
@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 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 size="small" tertiary type="primary" @click="addRule(lang)">
|
||||
添加规则
|
||||
</n-button>
|
||||
</n-flex>
|
||||
@@ -397,9 +350,7 @@ watch(supportedLanguages, (langs) => {
|
||||
</n-tabs>
|
||||
<n-empty
|
||||
v-else
|
||||
:description="
|
||||
languages.length ? '当前语言不支持代码规则检查' : '请先选择编程语言'
|
||||
"
|
||||
:description="languages.length ? '当前语言不支持代码规则检查' : '请先选择编程语言'"
|
||||
/>
|
||||
</n-collapse-item>
|
||||
</n-collapse>
|
||||
|
||||
@@ -20,9 +20,7 @@ const tags = ref<AdminTag[]>([])
|
||||
const selected = ref<string[]>([])
|
||||
const newTags = ref<string[]>([])
|
||||
|
||||
const title = computed(() =>
|
||||
props.action === "add" ? "批量添加标签" : "批量移除标签",
|
||||
)
|
||||
const title = computed(() => (props.action === "add" ? "批量添加标签" : "批量移除标签"))
|
||||
|
||||
const selectedSet = computed(() => new Set(selected.value))
|
||||
|
||||
@@ -53,11 +51,7 @@ async function submit() {
|
||||
message.error("请先选择标签")
|
||||
return
|
||||
}
|
||||
const res = await batchTagProblems(
|
||||
props.problemIds,
|
||||
names.value,
|
||||
props.action,
|
||||
)
|
||||
const res = await batchTagProblems(props.problemIds, names.value, props.action)
|
||||
const verb = props.action === "add" ? "添加" : "移除"
|
||||
message.success(`已为 ${res.problemCount} 道题${verb} ${res.tagCount} 个标签`)
|
||||
close()
|
||||
|
||||
@@ -40,9 +40,7 @@ function blankEntry(): ScriptEntry {
|
||||
const scripts = ref<ScriptEntry[]>([blankEntry(), blankEntry(), blankEntry()])
|
||||
|
||||
const refSQL = computed(
|
||||
() =>
|
||||
props.answers.find((a) => a.language === "SQL" && a.code.trim())?.code ??
|
||||
"",
|
||||
() => props.answers.find((a) => a.language === "SQL" && a.code.trim())?.code ?? "",
|
||||
)
|
||||
|
||||
/** 已有脚本读取失败时的提示。空白编辑器和「本来就没测试点」长得一样,必须区分开 */
|
||||
@@ -54,9 +52,7 @@ 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 filledCount = computed(() => scripts.value.filter((s) => s.sql.trim()).length)
|
||||
|
||||
const canUpload = computed(() => {
|
||||
const filled = scripts.value.filter((s) => s.sql.trim())
|
||||
@@ -206,29 +202,15 @@ async function upload() {
|
||||
|
||||
<template>
|
||||
<n-flex vertical>
|
||||
<n-alert
|
||||
v-if="!refSQL"
|
||||
type="warning"
|
||||
:show-icon="false"
|
||||
style="margin-bottom: 8px"
|
||||
>
|
||||
<n-alert v-if="!refSQL" type="warning" :show-icon="false" style="margin-bottom: 8px">
|
||||
还没有填写 SQL 标准答案,请先在上方"本题参考答案"中填写,再来编写测试点
|
||||
</n-alert>
|
||||
<n-alert
|
||||
v-if="loadError"
|
||||
type="error"
|
||||
:show-icon="false"
|
||||
style="margin-bottom: 8px"
|
||||
>
|
||||
<n-alert v-if="loadError" type="error" :show-icon="false" style="margin-bottom: 8px">
|
||||
{{ loadError }}
|
||||
</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-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>
|
||||
@@ -297,9 +279,7 @@ async function upload() {
|
||||
placeholder="-- 本测试点的建表 + 插入数据脚本
|
||||
CREATE TABLE ...;
|
||||
INSERT INTO ...;"
|
||||
:status="
|
||||
s.error ? 'error' : s.display && !s.stale ? 'success' : undefined
|
||||
"
|
||||
:status="s.error ? 'error' : s.display && !s.stale ? 'success' : undefined"
|
||||
/>
|
||||
<n-alert v-if="s.error" type="error" :show-icon="false">
|
||||
{{ s.error }}
|
||||
|
||||
@@ -32,21 +32,15 @@ const columns: DataTableColumn<AdminProblemRow>[] = [
|
||||
key: "title",
|
||||
minWidth: 200,
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{ text: true, type: "primary", onClick: () => goEdit(row) },
|
||||
() => row.title,
|
||||
),
|
||||
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 ? "公开" : "隐藏"),
|
||||
h(NTag, { size: "small", type: row.visible ? "success" : "default" }, () =>
|
||||
row.visible ? "公开" : "隐藏",
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -54,25 +48,14 @@ const columns: DataTableColumn<AdminProblemRow>[] = [
|
||||
key: "actions",
|
||||
width: 110,
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{ size: "small", type: "error", onClick: () => removeTag(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,
|
||||
)
|
||||
const res = await getProblemList(offset, limit.value, keyword.value, "", undefined, props.tagId)
|
||||
problems.value = res.results
|
||||
total.value = res.total
|
||||
}
|
||||
|
||||
@@ -54,15 +54,11 @@ const availableLanguages = computed(() =>
|
||||
props.answers.map((a) => ({ label: a.language, value: a.language })),
|
||||
)
|
||||
|
||||
const hasAnyAnswerCode = computed(() =>
|
||||
props.answers.some((a) => a.code.trim()),
|
||||
)
|
||||
const hasAnyAnswerCode = computed(() => props.answers.some((a) => a.code.trim()))
|
||||
|
||||
// 当前选中语言是否有答案代码(用于控制"先运行"按钮)
|
||||
const hasAnswerCode = computed(() => {
|
||||
const answer = props.answers.find(
|
||||
(a) => a.language === selectedLanguage.value,
|
||||
)
|
||||
const answer = props.answers.find((a) => a.language === selectedLanguage.value)
|
||||
return !!answer?.code.trim()
|
||||
})
|
||||
|
||||
@@ -70,10 +66,7 @@ const hasAnswerCode = computed(() => {
|
||||
watch(
|
||||
availableLanguages,
|
||||
(langs) => {
|
||||
if (
|
||||
langs.length &&
|
||||
!langs.find((l) => l.value === selectedLanguage.value)
|
||||
) {
|
||||
if (langs.length && !langs.find((l) => l.value === selectedLanguage.value)) {
|
||||
selectedLanguage.value = langs[0].value
|
||||
}
|
||||
},
|
||||
@@ -136,9 +129,7 @@ function remove(index: number) {
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const answer = props.answers.find(
|
||||
(a) => a.language === selectedLanguage.value,
|
||||
)
|
||||
const answer = props.answers.find((a) => a.language === selectedLanguage.value)
|
||||
if (!answer?.code.trim()) return
|
||||
|
||||
// 过滤没填过的行,去重(按输入内容)。全没填就留一行,当成无输入题跑一次 ——
|
||||
@@ -210,12 +201,7 @@ async function upload() {
|
||||
|
||||
<template>
|
||||
<n-flex vertical>
|
||||
<n-alert
|
||||
v-if="!hasAnyAnswerCode"
|
||||
type="warning"
|
||||
:show-icon="false"
|
||||
style="margin-bottom: 8px"
|
||||
>
|
||||
<n-alert v-if="!hasAnyAnswerCode" type="warning" :show-icon="false" style="margin-bottom: 8px">
|
||||
还没有填写答案代码,请先在上方"本题参考答案"中填写至少一种语言的答案,再来生成测试用例
|
||||
</n-alert>
|
||||
<n-flex align="center" wrap>
|
||||
@@ -230,34 +216,19 @@ async function upload() {
|
||||
<n-tooltip :disabled="hasAnswerCode">
|
||||
<template #trigger>
|
||||
<span>
|
||||
<n-button
|
||||
type="success"
|
||||
:loading="isRunning"
|
||||
:disabled="!hasAnswerCode"
|
||||
@click="run"
|
||||
>
|
||||
<n-button type="success" :loading="isRunning" :disabled="!hasAnswerCode" @click="run">
|
||||
先运行
|
||||
</n-button>
|
||||
</span>
|
||||
</template>
|
||||
请先在题目中填写答案代码
|
||||
</n-tooltip>
|
||||
<n-button
|
||||
type="primary"
|
||||
:loading="isUploading"
|
||||
:disabled="!canUpload"
|
||||
@click="upload"
|
||||
>
|
||||
<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 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" />
|
||||
|
||||
@@ -6,11 +6,7 @@ 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 { CODE_TEMPLATES, LANGUAGE_SHOW_VALUE, STORAGE_KEY } from "utils/constants"
|
||||
import download from "utils/download"
|
||||
import { unique } from "utils/functions"
|
||||
import type { BlankProblem, LANGUAGE, Tag, Testcase } from "utils/types"
|
||||
@@ -23,13 +19,9 @@ import {
|
||||
uploadTestcases,
|
||||
} from "../api"
|
||||
|
||||
const CodeEditor = defineAsyncComponent(
|
||||
() => import("shared/components/CodeEditor.vue"),
|
||||
)
|
||||
const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEditor.vue"))
|
||||
|
||||
const MermaidEditor = defineAsyncComponent(
|
||||
() => import("shared/components/MermaidEditor.vue"),
|
||||
)
|
||||
const MermaidEditor = defineAsyncComponent(() => import("shared/components/MermaidEditor.vue"))
|
||||
|
||||
interface Props {
|
||||
problemID?: string
|
||||
@@ -349,8 +341,7 @@ async function validateProblem() {
|
||||
// 题目
|
||||
else if (
|
||||
!problem.value.description ||
|
||||
(!isSQLProblem.value &&
|
||||
(!problem.value.inputDescription || !problem.value.outputDescription))
|
||||
(!isSQLProblem.value && (!problem.value.inputDescription || !problem.value.outputDescription))
|
||||
) {
|
||||
message.error("题目或输入或输出没有填写")
|
||||
hasErrors = true
|
||||
@@ -363,9 +354,7 @@ async function validateProblem() {
|
||||
// 样例是空的
|
||||
else if (
|
||||
!isSQLProblem.value &&
|
||||
problem.value.samples.some(
|
||||
(sample) => sample.output === "" || sample.input === "",
|
||||
)
|
||||
problem.value.samples.some((sample) => sample.output === "" || sample.input === "")
|
||||
) {
|
||||
message.error("空样例没有删干净")
|
||||
hasErrors = true
|
||||
@@ -384,9 +373,7 @@ async function validateProblem() {
|
||||
hasErrors = true
|
||||
} else if (
|
||||
isSQLProblem.value &&
|
||||
!problem.value.answers.find(
|
||||
(ans) => ans.language === "SQL" && ans.code.trim() !== "",
|
||||
)
|
||||
!problem.value.answers.find((ans) => ans.language === "SQL" && ans.code.trim() !== "")
|
||||
) {
|
||||
message.error("SQL 题必须填写标准答案(判题时用它生成期望结果)")
|
||||
hasErrors = true
|
||||
@@ -430,9 +417,7 @@ function filterHint() {
|
||||
}
|
||||
|
||||
function filterAnswers() {
|
||||
problem.value.answers = problem.value.answers.filter(
|
||||
(ans) => ans.code.trim() !== "",
|
||||
)
|
||||
problem.value.answers = problem.value.answers.filter((ans) => ans.code.trim() !== "")
|
||||
}
|
||||
|
||||
function filterSamplesForSQL() {
|
||||
@@ -465,16 +450,10 @@ async function submit() {
|
||||
problem.value = null
|
||||
selectedTags.value = []
|
||||
newTags.value = []
|
||||
if (
|
||||
route.name === "admin problem create" ||
|
||||
route.name === "admin contest problem create"
|
||||
) {
|
||||
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"
|
||||
) {
|
||||
if (route.name === "admin problem create" || route.name === "admin problem edit") {
|
||||
router.push({ name: "admin problem list" })
|
||||
} else {
|
||||
router.push({
|
||||
@@ -492,9 +471,7 @@ async function submit() {
|
||||
}
|
||||
|
||||
const showClear = computed(
|
||||
() =>
|
||||
route.name === "admin problem create" ||
|
||||
route.name === "admin contest problem create",
|
||||
() => route.name === "admin problem create" || route.name === "admin contest problem create",
|
||||
)
|
||||
|
||||
function clear() {
|
||||
@@ -523,10 +500,7 @@ async function generateMermaid() {
|
||||
|
||||
const showGeneratorModal = ref(false)
|
||||
|
||||
function handleTestcasesGenerated(
|
||||
testCaseId: string,
|
||||
testCaseScore: Testcase[],
|
||||
) {
|
||||
function handleTestcasesGenerated(testCaseId: string, testCaseScore: Testcase[]) {
|
||||
problem.value.testCaseId = testCaseId
|
||||
problem.value.testCaseScore = testCaseScore
|
||||
showGeneratorModal.value = false
|
||||
@@ -545,9 +519,7 @@ watch(
|
||||
() => problem.value.languages,
|
||||
(langs) => {
|
||||
const answers = langs.map((lang) => {
|
||||
const existing = problem.value.answers.find(
|
||||
(ans) => ans.language === lang,
|
||||
)
|
||||
const existing = problem.value.answers.find((ans) => ans.language === lang)
|
||||
return existing || { language: lang, code: "" }
|
||||
})
|
||||
problem.value.answers = answers
|
||||
@@ -569,11 +541,7 @@ watch(
|
||||
<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-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" />
|
||||
@@ -621,12 +589,7 @@ watch(
|
||||
<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)"
|
||||
>
|
||||
<n-button tertiary type="warning" size="small" @click="removeSample(index)">
|
||||
删除 {{ index + 1 }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
@@ -658,10 +621,7 @@ watch(
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item label="本题的考察知识点(选填,用于 AI 分析)">
|
||||
<n-input
|
||||
v-model:value="problem.prompt"
|
||||
placeholder="比如考察选择、循环、算法等知识点"
|
||||
/>
|
||||
<n-input v-model:value="problem.prompt" placeholder="比如考察选择、循环、算法等知识点" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
@@ -683,10 +643,7 @@ watch(
|
||||
</n-checkbox-group>
|
||||
</n-form-item>
|
||||
<n-form-item v-if="!isSQLProblem">
|
||||
<n-checkbox
|
||||
v-model:checked="needTemplate"
|
||||
label="预制代码(显示在编辑器中,帮助快速上手)"
|
||||
/>
|
||||
<n-checkbox v-model:checked="needTemplate" label="预制代码(显示在编辑器中,帮助快速上手)" />
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-button
|
||||
@@ -701,17 +658,11 @@ watch(
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
|
||||
<n-form
|
||||
v-if="isSQLProblem && problem.sqlConfig"
|
||||
inline
|
||||
label-placement="left"
|
||||
>
|
||||
<n-form v-if="isSQLProblem && problem.sqlConfig" inline label-placement="left">
|
||||
<n-form-item label="SQL 题型">
|
||||
<n-radio-group v-model:value="problem.sqlConfig.mode">
|
||||
<n-radio-button value="query">查询题(比对查询结果)</n-radio-button>
|
||||
<n-radio-button value="modify">
|
||||
增删改题(比对执行后的表数据)
|
||||
</n-radio-button>
|
||||
<n-radio-button value="modify"> 增删改题(比对执行后的表数据) </n-radio-button>
|
||||
</n-radio-group>
|
||||
</n-form-item>
|
||||
<n-form-item label="严格比对行顺序">
|
||||
@@ -732,11 +683,7 @@ watch(
|
||||
: '本题参考答案(选填,用于 AI 分析,不会泄露)'
|
||||
"
|
||||
>
|
||||
<n-tabs
|
||||
type="segment"
|
||||
default-value="Python"
|
||||
v-model:value="currentActiveAnswer"
|
||||
>
|
||||
<n-tabs type="segment" default-value="Python" v-model:value="currentActiveAnswer">
|
||||
<n-tab-pane
|
||||
v-for="(answer, index) in problem.answers"
|
||||
:key="index"
|
||||
@@ -756,16 +703,8 @@ watch(
|
||||
<n-gi>
|
||||
<n-form v-if="needTemplate">
|
||||
<n-form-item label="编写预制代码">
|
||||
<n-tabs
|
||||
type="segment"
|
||||
default-value="Python"
|
||||
v-model:value="currentActiveTemplate"
|
||||
>
|
||||
<n-tab-pane
|
||||
v-for="(lang, index) in problem.languages"
|
||||
:key="index"
|
||||
:name="lang"
|
||||
>
|
||||
<n-tabs type="segment" default-value="Python" 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"
|
||||
@@ -781,10 +720,7 @@ watch(
|
||||
|
||||
<n-grid v-if="!isSQLProblem" :cols="2">
|
||||
<n-gi :span="1">
|
||||
<AstRulesEditor
|
||||
v-model="problem.astRules!"
|
||||
:languages="problem.languages"
|
||||
/>
|
||||
<AstRulesEditor v-model="problem.astRules!" :languages="problem.languages" />
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
|
||||
@@ -794,16 +730,10 @@ watch(
|
||||
|
||||
<n-flex v-if="!isSQLProblem" align="center" style="margin-bottom: 12px">
|
||||
<div>
|
||||
<n-button type="success" @click="showGeneratorModal = true">
|
||||
(新)直接生成
|
||||
</n-button>
|
||||
<n-button type="success" @click="showGeneratorModal = true"> (新)直接生成 </n-button>
|
||||
</div>
|
||||
<div>
|
||||
<n-upload
|
||||
:show-file-list="false"
|
||||
accept=".zip"
|
||||
:custom-request="handleUploadTestcases"
|
||||
>
|
||||
<n-upload :show-file-list="false" accept=".zip" :custom-request="handleUploadTestcases">
|
||||
<n-button type="info">(老)手动上传</n-button>
|
||||
</n-upload>
|
||||
</div>
|
||||
@@ -823,12 +753,7 @@ watch(
|
||||
@uploaded="handleTestcasesGenerated"
|
||||
/>
|
||||
|
||||
<n-alert
|
||||
class="box"
|
||||
v-if="problem.testCaseScore.length"
|
||||
:show-icon="false"
|
||||
type="info"
|
||||
>
|
||||
<n-alert class="box" v-if="problem.testCaseScore.length" :show-icon="false" type="info">
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
<div>
|
||||
@@ -836,13 +761,7 @@ watch(
|
||||
{{ problem.testCaseScore.length }}
|
||||
条测试用例
|
||||
</div>
|
||||
<n-button
|
||||
v-if="problem.id"
|
||||
tertiary
|
||||
type="info"
|
||||
size="small"
|
||||
@click="downloadTestcases"
|
||||
>
|
||||
<n-button v-if="problem.id" tertiary type="info" size="small" @click="downloadTestcases">
|
||||
下载
|
||||
</n-button>
|
||||
</n-flex>
|
||||
@@ -875,10 +794,7 @@ watch(
|
||||
<n-button
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="
|
||||
!problem.answers.filter((a) => a.language === 'Python')[0]?.code
|
||||
.length
|
||||
"
|
||||
:disabled="!problem.answers.filter((a) => a.language === 'Python')[0]?.code.length"
|
||||
:loading="isAIGenerating"
|
||||
@click="generateMermaid"
|
||||
>
|
||||
@@ -890,15 +806,8 @@ watch(
|
||||
</n-form-item>
|
||||
<n-form-item label="显示标准流程图">
|
||||
<n-flex align="center">
|
||||
<n-switch
|
||||
v-model:value="problem.showFlowchart"
|
||||
:disabled="problem.allowFlowchart"
|
||||
/>
|
||||
<n-text
|
||||
v-if="problem.allowFlowchart"
|
||||
depth="3"
|
||||
style="font-size: 12px"
|
||||
>
|
||||
<n-switch v-model:value="problem.showFlowchart" :disabled="problem.allowFlowchart" />
|
||||
<n-text v-if="problem.allowFlowchart" depth="3" style="font-size: 12px">
|
||||
让学生自己画图时,标准流程图不会下发给学生,这个开关没有意义
|
||||
</n-text>
|
||||
</n-flex>
|
||||
@@ -907,10 +816,7 @@ watch(
|
||||
|
||||
<n-form>
|
||||
<n-form-item>
|
||||
<MermaidEditor
|
||||
v-model="problem.mermaidCode"
|
||||
@render-state="onMermaidRenderState"
|
||||
/>
|
||||
<MermaidEditor v-model="problem.mermaidCode" @render-state="onMermaidRenderState" />
|
||||
</n-form-item>
|
||||
<n-form-item label="流程图提示信息(选填)">
|
||||
<n-input
|
||||
|
||||
@@ -29,9 +29,7 @@ const title = computed(
|
||||
"admin contest problem list": "比赛题目列表",
|
||||
})[route.name as string],
|
||||
)
|
||||
const isContestProblemList = computed(
|
||||
() => route.name === "admin contest problem list",
|
||||
)
|
||||
const isContestProblemList = computed(() => route.name === "admin contest problem list")
|
||||
|
||||
const [show, toggleShow] = useToggle()
|
||||
const { count, inc } = useCounter(0)
|
||||
@@ -42,9 +40,7 @@ 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 selectedProblemIds = computed(() => selectedRowKeys.value.map((key) => Number(key)))
|
||||
|
||||
const rowKey = (row: AdminProblemRow) => row.id
|
||||
|
||||
@@ -103,9 +99,7 @@ const baseColumns: DataTableColumn<AdminProblemRow>[] = [
|
||||
key: "tags",
|
||||
minWidth: 120,
|
||||
render: (row) =>
|
||||
h(NFlex, { size: 4 }, () =>
|
||||
row.tags.map((t) => h(NTag, { key: t, size: "small" }, () => t)),
|
||||
),
|
||||
h(NFlex, { size: 4 }, () => row.tags.map((t) => h(NTag, { key: t, size: "small" }, () => t))),
|
||||
},
|
||||
{
|
||||
title: "功能",
|
||||
@@ -254,16 +248,10 @@ watch(() => [query.page, query.limit, query.author], listProblems)
|
||||
>
|
||||
卡点分析
|
||||
</n-button>
|
||||
<n-button
|
||||
v-if="!isContestProblemList"
|
||||
@click="$router.push({ name: 'admin top ac trend' })"
|
||||
>
|
||||
<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 v-if="!isContestProblemList" @click="$router.push({ name: 'admin tag list' })">
|
||||
标签管理
|
||||
</n-button>
|
||||
</n-flex>
|
||||
@@ -274,14 +262,8 @@ watch(() => [query.page, query.limit, query.author], listProblems)
|
||||
</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 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">
|
||||
@@ -305,11 +287,7 @@ watch(() => [query.page, query.limit, query.author], listProblems)
|
||||
:row-key="rowKey"
|
||||
@update:checked-row-keys="chooseProblems"
|
||||
/>
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:limit="query.limit"
|
||||
v-model:page="query.page"
|
||||
/>
|
||||
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
|
||||
<AddFromLibraryModal
|
||||
v-model:show="show"
|
||||
:count="count"
|
||||
|
||||
@@ -54,10 +54,8 @@ const columns: DataTableColumn<AdminTag>[] = [
|
||||
key: "problem_count",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{ text: true, type: "primary", onClick: () => openTagProblems(row) },
|
||||
() => String(row.problemCount),
|
||||
h(NButton, { text: true, type: "primary", onClick: () => openTagProblems(row) }, () =>
|
||||
String(row.problemCount),
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -76,11 +74,7 @@ const columns: DataTableColumn<AdminTag>[] = [
|
||||
h(NButton, { size: "small", onClick: cancelEdit }, () => "取消"),
|
||||
]
|
||||
: [
|
||||
h(
|
||||
NButton,
|
||||
{ size: "small", onClick: () => startEdit(row) },
|
||||
() => "重命名",
|
||||
),
|
||||
h(NButton, { size: "small", onClick: () => startEdit(row) }, () => "重命名"),
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
@@ -153,16 +147,9 @@ watchDebounced(keyword, listTags, { debounce: 500, maxWait: 1000 })
|
||||
<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-button @click="$router.push({ name: 'admin problem list' })"> 返回题目列表 </n-button>
|
||||
</n-flex>
|
||||
<n-input
|
||||
v-model:value="keyword"
|
||||
style="width: 200px"
|
||||
placeholder="搜索标签"
|
||||
clearable
|
||||
/>
|
||||
<n-input v-model:value="keyword" style="width: 200px" placeholder="搜索标签" clearable />
|
||||
</n-flex>
|
||||
<n-data-table striped :columns="columns" :data="tags" />
|
||||
<TagProblemsModal
|
||||
|
||||
@@ -62,15 +62,9 @@ async function handleUpdateStatus() {
|
||||
|
||||
<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-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>
|
||||
@@ -79,20 +73,11 @@ async function handleUpdateStatus() {
|
||||
</n-popconfirm>
|
||||
</n-flex>
|
||||
|
||||
<n-modal
|
||||
v-model:show="showStatusModal"
|
||||
preset="card"
|
||||
title="更新题单状态"
|
||||
style="width: 400px"
|
||||
>
|
||||
<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-select v-model:value="newStatus" :options="statusOptions" placeholder="选择状态" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-space>
|
||||
|
||||
@@ -55,9 +55,7 @@ defineEmits<Emits>()
|
||||
title: '条件值',
|
||||
key: 'condition_value',
|
||||
render: (row) => {
|
||||
return row.conditionType === 'all_problems'
|
||||
? '-'
|
||||
: row.conditionValue
|
||||
return row.conditionType === 'all_problems' ? '-' : row.conditionValue
|
||||
},
|
||||
},
|
||||
{ title: '描述', key: 'description' },
|
||||
|
||||
@@ -28,9 +28,7 @@ const emit = defineEmits<Emits>()
|
||||
const badgeName = ref("")
|
||||
const badgeDescription = ref("")
|
||||
const badgeIcon = ref("")
|
||||
const badgeConditionType = ref<"all_problems" | "problem_count" | "score">(
|
||||
"all_problems",
|
||||
)
|
||||
const badgeConditionType = ref<"all_problems" | "problem_count" | "score">("all_problems")
|
||||
const badgeConditionValue = ref(1)
|
||||
|
||||
// 预设奖章图标选项
|
||||
@@ -99,12 +97,7 @@ watch(
|
||||
<n-input v-model:value="badgeName" placeholder="请输入奖章名称" />
|
||||
</n-form-item>
|
||||
<n-form-item label="描述">
|
||||
<n-input
|
||||
v-model:value="badgeDescription"
|
||||
type="textarea"
|
||||
placeholder="奖章描述"
|
||||
required
|
||||
/>
|
||||
<n-input v-model:value="badgeDescription" type="textarea" placeholder="奖章描述" required />
|
||||
</n-form-item>
|
||||
<n-form-item label="图标" required>
|
||||
<n-flex align="center" gap="small">
|
||||
@@ -115,17 +108,13 @@ watch(
|
||||
:style="{
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
border:
|
||||
badgeIcon === option.value
|
||||
? '2px solid #1890ff'
|
||||
: '1px solid #d9d9d9',
|
||||
border: badgeIcon === option.value ? '2px solid #1890ff' : '1px solid #d9d9d9',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor:
|
||||
badgeIcon === option.value ? '#f0f8ff' : 'transparent',
|
||||
backgroundColor: badgeIcon === option.value ? '#f0f8ff' : 'transparent',
|
||||
}"
|
||||
>
|
||||
<n-image
|
||||
@@ -147,10 +136,7 @@ watch(
|
||||
:options="conditionTypeOptions"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item
|
||||
label="条件值"
|
||||
v-if="badgeConditionType !== 'all_problems'"
|
||||
>
|
||||
<n-form-item label="条件值" v-if="badgeConditionType !== 'all_problems'">
|
||||
<n-input-number
|
||||
style="width: 120px"
|
||||
v-model:value="badgeConditionValue"
|
||||
|
||||
@@ -21,9 +21,7 @@ defineEmits<Emits>()
|
||||
<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-button type="primary" @click="$emit('add-problem')"> 添加题目 </n-button>
|
||||
</n-flex>
|
||||
<n-data-table
|
||||
:columns="[
|
||||
|
||||
@@ -75,16 +75,10 @@ watch(
|
||||
<n-input :value="problem.title" disabled />
|
||||
</n-form-item>
|
||||
<n-form-item v-else label="题目ID" required>
|
||||
<n-input
|
||||
v-model:value="problemId"
|
||||
placeholder="请输入题目的显示ID(如:1001)"
|
||||
/>
|
||||
<n-input v-model:value="problemId" placeholder="请输入题目的显示ID(如:1001)" />
|
||||
</n-form-item>
|
||||
<n-form-item label="顺序">
|
||||
<n-input-number
|
||||
v-model:value="problemOrder"
|
||||
placeholder="题目在题单中的顺序"
|
||||
/>
|
||||
<n-input-number v-model:value="problemOrder" placeholder="题目在题单中的顺序" />
|
||||
</n-form-item>
|
||||
<n-form-item label="是否必做">
|
||||
<n-switch v-model:value="problemRequired" />
|
||||
@@ -93,11 +87,7 @@ watch(
|
||||
<n-input-number v-model:value="problemScore" placeholder="题目分数" />
|
||||
</n-form-item>
|
||||
<n-form-item label="提示">
|
||||
<n-input
|
||||
v-model:value="problemHint"
|
||||
type="textarea"
|
||||
placeholder="题目提示"
|
||||
/>
|
||||
<n-input v-model:value="problemHint" type="textarea" placeholder="题目提示" />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<template #footer>
|
||||
|
||||
@@ -22,8 +22,7 @@ const progressColumns = [
|
||||
title: "加入时间",
|
||||
key: "joinTime",
|
||||
width: 180,
|
||||
render: (row: AdminProblemSetProgress) =>
|
||||
parseTime(row.joinTime, "YYYY-MM-DD HH:mm:ss"),
|
||||
render: (row: AdminProblemSetProgress) => parseTime(row.joinTime, "YYYY-MM-DD HH:mm:ss"),
|
||||
},
|
||||
{ title: "已完成", key: "completedProblemsCount", width: 100 },
|
||||
{ title: "总题目", key: "totalProblemsCount", width: 100 },
|
||||
@@ -31,8 +30,7 @@ const progressColumns = [
|
||||
title: "进度",
|
||||
key: "progressPercentage",
|
||||
width: 100,
|
||||
render: (row: AdminProblemSetProgress) =>
|
||||
`${row.progressPercentage.toFixed(0)}%`,
|
||||
render: (row: AdminProblemSetProgress) => `${row.progressPercentage.toFixed(0)}%`,
|
||||
},
|
||||
{
|
||||
title: "是否完成",
|
||||
|
||||
@@ -237,10 +237,7 @@ onMounted(() => {
|
||||
</n-tab-pane>
|
||||
|
||||
<n-tab-pane name="progress" tab="进度管理">
|
||||
<ProgressManagement
|
||||
:progress="progress"
|
||||
@remove-user="handleRemoveUser"
|
||||
/>
|
||||
<ProgressManagement :progress="progress" @remove-user="handleRemoveUser" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
|
||||
|
||||
@@ -23,10 +23,7 @@ const formData = ref<CreateProblemSetData & Partial<EditProblemSetData>>({
|
||||
// n-date-picker 按浏览器本地渲染,所以要平移一次再绑定(见 utils/functions.ts
|
||||
// 的 toPickerValue)。`formData.endTime` 里始终存**真实时刻**,只有喂给选择器那一步换。
|
||||
const endTimeTimestamp = computed({
|
||||
get: () =>
|
||||
formData.value.endTime
|
||||
? toPickerValue(formData.value.endTime.getTime())
|
||||
: null,
|
||||
get: () => (formData.value.endTime ? toPickerValue(formData.value.endTime.getTime()) : null),
|
||||
set: (val: number | null) => {
|
||||
formData.value.endTime = val ? new Date(fromPickerValue(val)) : null
|
||||
},
|
||||
@@ -89,11 +86,7 @@ async function handleSubmit() {
|
||||
}
|
||||
router.push({ name: "admin problemset list" })
|
||||
} catch (err) {
|
||||
message.error(
|
||||
(isEdit.value ? "更新" : "创建") +
|
||||
"题单失败:" +
|
||||
errorMessage(err, "未知错误"),
|
||||
)
|
||||
message.error((isEdit.value ? "更新" : "创建") + "题单失败:" + errorMessage(err, "未知错误"))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -58,11 +58,7 @@ const columns: DataTableColumn<AdminProblemSet>[] = [
|
||||
Hard: { type: "error" as const, text: "困难" },
|
||||
}
|
||||
const config = difficultyMap[row.difficulty]
|
||||
return h(
|
||||
NTag,
|
||||
{ type: config.type, size: "small" },
|
||||
{ default: () => config.text },
|
||||
)
|
||||
return h(NTag, { type: config.type, size: "small" }, { default: () => config.text })
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -76,11 +72,7 @@ const columns: DataTableColumn<AdminProblemSet>[] = [
|
||||
draft: { type: "info" as const, text: "草稿" },
|
||||
}
|
||||
const config = statusMap[row.status]
|
||||
return h(
|
||||
NTag,
|
||||
{ type: config.type, size: "small" },
|
||||
{ default: () => config.text },
|
||||
)
|
||||
return h(NTag, { type: config.type, size: "small" }, { default: () => config.text })
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -146,20 +138,14 @@ watchDebounced(() => query.keyword, listProblemSets, {
|
||||
})
|
||||
|
||||
// 监听其他查询条件变化
|
||||
watch(
|
||||
() => [query.page, query.limit, query.difficulty, query.status],
|
||||
listProblemSets,
|
||||
)
|
||||
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 type="primary" @click="$router.push({ name: 'admin problemset create' })">
|
||||
新建题单
|
||||
</n-button>
|
||||
</n-flex>
|
||||
@@ -194,11 +180,7 @@ watch(
|
||||
</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"
|
||||
/>
|
||||
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { errorMessage } from "utils/api"
|
||||
import { NButton, NTag } from "naive-ui"
|
||||
import {
|
||||
CLASS_NAME_MAX_DIGITS,
|
||||
CLASS_NAME_MIN_DIGITS,
|
||||
CLASS_NAME_RE,
|
||||
} from "utils/constants"
|
||||
import { CLASS_NAME_MAX_DIGITS, CLASS_NAME_MIN_DIGITS, CLASS_NAME_RE } from "utils/constants"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { OrphanTestCase, Server, WebsiteConfig } from "utils/types"
|
||||
import { useConfigStore } from "shared/store/config"
|
||||
@@ -41,11 +37,7 @@ const testcaseColumns: DataTableColumn<OrphanTestCase>[] = [
|
||||
title: "选项",
|
||||
key: "delete",
|
||||
render: (row) =>
|
||||
h(
|
||||
NButton,
|
||||
{ size: "small", onClick: () => deleteTestcase(row.id) },
|
||||
() => "删除",
|
||||
),
|
||||
h(NButton, { size: "small", onClick: () => deleteTestcase(row.id) }, () => "删除"),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -111,9 +103,7 @@ const serverColumns: DataTableColumn<Server>[] = [
|
||||
const testcases = ref<OrphanTestCase[]>([])
|
||||
const token = ref("")
|
||||
const servers = ref<Server[]>([])
|
||||
const abnormalServers = computed(() =>
|
||||
servers.value.filter((item) => item.status === "abnormal"),
|
||||
)
|
||||
const abnormalServers = computed(() => servers.value.filter((item) => item.status === "abnormal"))
|
||||
|
||||
const websiteConfig = reactive<WebsiteConfig>({
|
||||
websiteBaseUrl: "",
|
||||
@@ -152,9 +142,7 @@ function renderClassTag(tag: string | { label: string }, index: number) {
|
||||
opacity: dragFrom.value === index ? 0.4 : 1,
|
||||
},
|
||||
onClose: () => {
|
||||
websiteConfig.classList = websiteConfig.classList.filter(
|
||||
(_, i) => i !== index,
|
||||
)
|
||||
websiteConfig.classList = websiteConfig.classList.filter((_, i) => i !== index)
|
||||
},
|
||||
onDragstart: (e: DragEvent) => {
|
||||
dragFrom.value = index
|
||||
@@ -248,9 +236,7 @@ async function delJudgeServer(hostname: string) {
|
||||
}
|
||||
|
||||
async function deleteAbnormalServers() {
|
||||
const dels = abnormalServers.value.map((item) =>
|
||||
deleteJudgeServer(item.hostname),
|
||||
)
|
||||
const dels = abnormalServers.value.map((item) => deleteJudgeServer(item.hostname))
|
||||
await Promise.all(dels)
|
||||
message.success("删除成功")
|
||||
getJudgeServerData()
|
||||
@@ -268,9 +254,7 @@ onMounted(() => {
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
网站设置
|
||||
<n-button type="primary" size="small" @click="saveWebsiteConfig">
|
||||
保存
|
||||
</n-button>
|
||||
<n-button type="primary" size="small" @click="saveWebsiteConfig"> 保存 </n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
<n-form inline label-placement="left">
|
||||
@@ -287,10 +271,7 @@ onMounted(() => {
|
||||
<n-form label-placement="left">
|
||||
<n-form-item label="班级列表">
|
||||
<n-flex vertical size="small">
|
||||
<n-dynamic-tags
|
||||
v-model:value="websiteConfig.classList"
|
||||
:render-tag="renderClassTag"
|
||||
/>
|
||||
<n-dynamic-tags v-model:value="websiteConfig.classList" :render-tag="renderClassTag" />
|
||||
<n-text depth="3" style="font-size: 12px">
|
||||
填 {{ CLASS_NAME_MIN_DIGITS }}~{{ CLASS_NAME_MAX_DIGITS }}
|
||||
位数字,如 251、2510,要和用户名里 ks 后面那段一致;
|
||||
@@ -331,28 +312,16 @@ onMounted(() => {
|
||||
<div class="box">
|
||||
接口凭证 <n-tag size="small">{{ token }}</n-tag>
|
||||
</div>
|
||||
<n-data-table
|
||||
:single-line="false"
|
||||
striped
|
||||
:columns="serverColumns"
|
||||
:data="servers"
|
||||
/>
|
||||
<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-button size="small" type="warning" @click="() => deleteTestcase()"> 全部删除 </n-button>
|
||||
</n-flex>
|
||||
</template>
|
||||
<n-data-table
|
||||
striped
|
||||
class="table"
|
||||
:columns="testcaseColumns"
|
||||
:data="testcases"
|
||||
/>
|
||||
<n-data-table striped class="table" :columns="testcaseColumns" :data="testcases" />
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -160,14 +160,10 @@ watch(showModal, (v) => {
|
||||
<n-gradient-text type="info"> 总用户数:{{ userCount }} </n-gradient-text>
|
||||
</h2>
|
||||
<h2>
|
||||
<n-gradient-text type="error">
|
||||
今日提交:{{ submissionCount }}
|
||||
</n-gradient-text>
|
||||
<n-gradient-text type="error"> 今日提交:{{ submissionCount }} </n-gradient-text>
|
||||
</h2>
|
||||
<h2>
|
||||
<n-gradient-text type="warning">
|
||||
近期比赛:{{ contestCount }}
|
||||
</n-gradient-text>
|
||||
<n-gradient-text type="warning"> 近期比赛:{{ contestCount }} </n-gradient-text>
|
||||
</h2>
|
||||
<h2>
|
||||
<!-- 判题机数量后端一直在下发,这里以前没显示 —— 判题机全掉线的时候,
|
||||
@@ -199,12 +195,7 @@ watch(showModal, (v) => {
|
||||
/>
|
||||
</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-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">
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { AdminProblemRow, AdminProblemListItem } from "utils/types"
|
||||
|
||||
// 把后端的列表项塑形成管理端列表行,与请求逻辑解耦。
|
||||
export function toAdminProblemRow(
|
||||
result: AdminProblemListItem,
|
||||
): AdminProblemRow {
|
||||
export function toAdminProblemRow(result: AdminProblemListItem): AdminProblemRow {
|
||||
return {
|
||||
id: result.id,
|
||||
_id: result._id,
|
||||
|
||||
@@ -30,9 +30,7 @@ async function handleDelete() {
|
||||
</script>
|
||||
<template>
|
||||
<n-flex>
|
||||
<n-button size="small" type="success" secondary @click="goEdit">
|
||||
编辑
|
||||
</n-button>
|
||||
<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>
|
||||
|
||||
@@ -11,12 +11,7 @@ import type {
|
||||
ExerciseDebugData,
|
||||
ExerciseGroupData,
|
||||
} from "utils/types"
|
||||
import {
|
||||
getAdminExercises,
|
||||
createExercise,
|
||||
updateExercise,
|
||||
deleteExercise,
|
||||
} from "admin/api"
|
||||
import { getAdminExercises, createExercise, updateExercise, deleteExercise } from "admin/api"
|
||||
|
||||
const props = defineProps<{ tutorialId: number }>()
|
||||
const message = useMessage()
|
||||
@@ -55,9 +50,7 @@ const groupQuestion = ref("")
|
||||
const groupBuckets = ref("")
|
||||
const groupItems = ref("")
|
||||
|
||||
const debugLines = computed(() =>
|
||||
debugCode.value === "" ? [] : debugCode.value.split("\n"),
|
||||
)
|
||||
const debugLines = computed(() => (debugCode.value === "" ? [] : debugCode.value.split("\n")))
|
||||
|
||||
async function load() {
|
||||
exercises.value = await getAdminExercises(props.tutorialId)
|
||||
@@ -135,9 +128,7 @@ function openEdit(ex: Exercise) {
|
||||
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")
|
||||
groupItems.value = d.items.map((it, i) => `${it} => ${d.buckets[d.answer[i]]}`).join("\n")
|
||||
}
|
||||
showForm.value = true
|
||||
}
|
||||
@@ -241,9 +232,7 @@ function buildData(): Record<string, unknown> | null {
|
||||
}
|
||||
if (formType.value === "debug") {
|
||||
const lines = debugCode.value.split("\n")
|
||||
const answer = debugAnswer.value
|
||||
.filter((i) => i < lines.length)
|
||||
.sort((a, b) => a - b)
|
||||
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
|
||||
@@ -377,9 +366,7 @@ function typeTagType(type: ExerciseType) {
|
||||
<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-button type="primary" size="small" @click="openCreate">+ 添加练习题</n-button>
|
||||
</n-flex>
|
||||
|
||||
<n-empty v-if="exercises.length === 0" description="暂无练习题" />
|
||||
@@ -398,16 +385,12 @@ function typeTagType(type: ExerciseType) {
|
||||
<n-space :size="8">
|
||||
<n-tooltip trigger="hover">
|
||||
<template #trigger>
|
||||
<n-button size="small" @click="copyPlaceholder(ex.id)">
|
||||
复制占位符
|
||||
</n-button>
|
||||
<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-button size="small" type="error" @click="confirmDelete(ex.id)"> 删除 </n-button>
|
||||
</n-space>
|
||||
</n-flex>
|
||||
</n-list-item>
|
||||
@@ -433,11 +416,7 @@ function typeTagType(type: ExerciseType) {
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item label="顺序">
|
||||
<n-input-number
|
||||
v-model:value="formOrder"
|
||||
:min="0"
|
||||
style="width: 100px"
|
||||
/>
|
||||
<n-input-number v-model:value="formOrder" :min="0" style="width: 100px" />
|
||||
</n-form-item>
|
||||
|
||||
<template v-if="formType === 'mcq'">
|
||||
@@ -451,16 +430,8 @@ function typeTagType(type: ExerciseType) {
|
||||
</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-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)}`"
|
||||
@@ -472,18 +443,14 @@ function typeTagType(type: ExerciseType) {
|
||||
@click="
|
||||
() => {
|
||||
mcqOptions.splice(i, 1)
|
||||
mcqAnswer = mcqAnswer
|
||||
.filter((a) => a !== i)
|
||||
.map((a) => (a > i ? a - 1 : a))
|
||||
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-button size="small" @click="mcqOptions.push('')"> + 添加选项 </n-button>
|
||||
</n-space>
|
||||
</n-form-item>
|
||||
</template>
|
||||
@@ -573,9 +540,7 @@ function typeTagType(type: ExerciseType) {
|
||||
style="font-family: "Monaco""
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item
|
||||
label="正确输出(多个可接受答案之间用单独一行 === 分隔)"
|
||||
>
|
||||
<n-form-item label="正确输出(多个可接受答案之间用单独一行 === 分隔)">
|
||||
<n-input
|
||||
v-model:value="predictAnswer"
|
||||
type="textarea"
|
||||
@@ -606,21 +571,9 @@ function typeTagType(type: ExerciseType) {
|
||||
</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-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>
|
||||
|
||||
@@ -80,18 +80,10 @@ onMounted(init)
|
||||
<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-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-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.isPublic" />
|
||||
|
||||
@@ -54,8 +54,7 @@ const columns: DataTableColumn<TutorialListItem>[] = [
|
||||
title: "操作",
|
||||
key: "actions",
|
||||
width: 140,
|
||||
render: (row) =>
|
||||
h(Actions, { tutorialID: row.id, onDeleted: listTutorials }),
|
||||
render: (row) => h(Actions, { tutorialID: row.id, onDeleted: listTutorials }),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -80,10 +79,7 @@ onMounted(listTutorials)
|
||||
<template>
|
||||
<n-flex align="center" class="titleWrapper">
|
||||
<h2 class="title">教程列表</h2>
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="$router.push({ name: 'admin tutorial create' })"
|
||||
>
|
||||
<n-button type="primary" @click="$router.push({ name: 'admin tutorial create' })">
|
||||
新建
|
||||
</n-button>
|
||||
</n-flex>
|
||||
|
||||
@@ -21,20 +21,10 @@ async function banUser() {
|
||||
</script>
|
||||
<template>
|
||||
<n-flex>
|
||||
<n-button
|
||||
size="small"
|
||||
type="error"
|
||||
secondary
|
||||
@click="$emit('resetPassword', props.user)"
|
||||
>
|
||||
<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 size="small" type="primary" secondary @click="$emit('openEditModal', props.user)">
|
||||
编辑
|
||||
</n-button>
|
||||
<n-button
|
||||
|
||||
@@ -22,8 +22,7 @@ defineEmits<{
|
||||
* TextCopy(渲染成空)。
|
||||
*/
|
||||
const maskable = computed(
|
||||
() =>
|
||||
props.user.adminType !== USER_TYPE.REGULAR_USER && !!props.user.rawPassword,
|
||||
() => props.user.adminType !== USER_TYPE.REGULAR_USER && !!props.user.rawPassword,
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
|
||||
@@ -8,20 +8,12 @@ interface Props {
|
||||
user: AdminUserForm
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const hasRoleTag = computed(
|
||||
() => props.user.adminType !== USER_TYPE.REGULAR_USER,
|
||||
)
|
||||
const hasRoleTag = computed(() => props.user.adminType !== USER_TYPE.REGULAR_USER)
|
||||
</script>
|
||||
<template>
|
||||
<n-flex align="center">
|
||||
<n-tag v-if="props.user.isDisabled" type="error" size="small">
|
||||
封号中
|
||||
</n-tag>
|
||||
<n-tag
|
||||
v-if="hasRoleTag"
|
||||
:type="getUserRole(props.user.adminType).type"
|
||||
size="small"
|
||||
>
|
||||
<n-tag v-if="props.user.isDisabled" type="error" size="small"> 封号中 </n-tag>
|
||||
<n-tag v-if="hasRoleTag" :type="getUserRole(props.user.adminType).type" size="small">
|
||||
{{ getUserRole(props.user.adminType).label }}
|
||||
</n-tag>
|
||||
<n-tag
|
||||
@@ -31,11 +23,7 @@ const hasRoleTag = computed(
|
||||
props.user.adminType === USER_TYPE.TEACHER_ADMIN
|
||||
"
|
||||
>
|
||||
{{
|
||||
props.user.problemPermission === PROBLEM_PERMISSION.ALL
|
||||
? "全部"
|
||||
: "仅自己"
|
||||
}}
|
||||
{{ props.user.problemPermission === PROBLEM_PERMISSION.ALL ? "全部" : "仅自己" }}
|
||||
</n-tag>
|
||||
<TextCopy>{{ props.user.username }}</TextCopy>
|
||||
</n-flex>
|
||||
|
||||
@@ -58,9 +58,7 @@ async function uploadUsers() {
|
||||
message.success("用户已上传成功")
|
||||
// 只导出用户名和密码两列 —— 发给学生的就是这两样,邮箱是占位生成的、
|
||||
// 真名本来就是老师粘进来的那一列,都不用回传
|
||||
const csv = users.value
|
||||
.map(([username, password]) => `${username},${password}`)
|
||||
.join("\n")
|
||||
const csv = users.value.map(([username, password]) => `${username},${password}`).join("\n")
|
||||
const hiddenElement = document.createElement("a")
|
||||
hiddenElement.href = "data:text/csv;charset=utf-8," + encodeURI(csv)
|
||||
hiddenElement.target = "_blank"
|
||||
@@ -102,9 +100,7 @@ async function submit() {
|
||||
placeholder="每行一个用户名"
|
||||
v-model:value="rawInput"
|
||||
/>
|
||||
<n-button type="warning" :loading="loading" @click="submit">
|
||||
确定导入
|
||||
</n-button>
|
||||
<n-button type="warning" :loading="loading" @click="submit"> 确定导入 </n-button>
|
||||
</n-flex>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
@@ -5,13 +5,7 @@ import Pagination from "shared/components/Pagination.vue"
|
||||
import { usePagination } from "shared/composables/pagination"
|
||||
import { parseTime } from "utils/functions"
|
||||
import type { AdminUserForm } from "utils/types"
|
||||
import {
|
||||
deleteUsers,
|
||||
editUser,
|
||||
getUserList,
|
||||
importUsers,
|
||||
resetPassword,
|
||||
} from "../api"
|
||||
import { deleteUsers, editUser, getUserList, importUsers, resetPassword } from "../api"
|
||||
import Actions from "./components/Actions.vue"
|
||||
import UserNameCell from "./components/UserNameCell.vue"
|
||||
import PasswordCell from "./components/PasswordCell.vue"
|
||||
@@ -78,8 +72,7 @@ const columns: DataTableColumn<AdminUserForm>[] = [
|
||||
user: row,
|
||||
revealed: revealedPasswords.value.has(row.id),
|
||||
onToggle: (id: number) => {
|
||||
if (!revealedPasswords.value.delete(id))
|
||||
revealedPasswords.value.add(id)
|
||||
if (!revealedPasswords.value.delete(id)) revealedPasswords.value.add(id)
|
||||
},
|
||||
}),
|
||||
},
|
||||
@@ -87,8 +80,7 @@ const columns: DataTableColumn<AdminUserForm>[] = [
|
||||
title: "创建时间",
|
||||
key: "create_time",
|
||||
width: 200,
|
||||
render: (row) =>
|
||||
row.createTime ? parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss") : "",
|
||||
render: (row) => (row.createTime ? parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss") : ""),
|
||||
},
|
||||
{
|
||||
title: "上次登录",
|
||||
@@ -98,12 +90,8 @@ const columns: DataTableColumn<AdminUserForm>[] = [
|
||||
// 会话能留 7 天。上次登录时间照旧显示,在线的人前面多一个标记
|
||||
render: (row) =>
|
||||
h(NFlex, { align: "center", size: "small" }, () => [
|
||||
row.isOnline
|
||||
? h(NTag, { type: "success", size: "small" }, () => "在线")
|
||||
: null,
|
||||
row.lastLogin
|
||||
? parseTime(row.lastLogin, "YYYY-MM-DD HH:mm:ss")
|
||||
: "从未登录",
|
||||
row.isOnline ? h(NTag, { type: "success", size: "small" }, () => "在线") : null,
|
||||
row.lastLogin ? parseTime(row.lastLogin, "YYYY-MM-DD HH:mm:ss") : "从未登录",
|
||||
]),
|
||||
},
|
||||
{
|
||||
@@ -144,13 +132,7 @@ const problemPermissionOptions: SelectOption[] = [
|
||||
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,
|
||||
)
|
||||
const res = await getUserList(offset, query.limit, query.type, query.keyword, query.orderBy)
|
||||
total.value = res.total
|
||||
users.value = res.results
|
||||
revealedPasswords.value.clear()
|
||||
@@ -274,15 +256,10 @@ watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
|
||||
<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-button @click="$router.push({ name: 'admin user generate' })"> 导入 </n-button>
|
||||
</n-flex>
|
||||
<n-flex>
|
||||
<n-popconfirm
|
||||
v-if="userIDs.length"
|
||||
@positive-click="onDeleteUsers(userIDs)"
|
||||
>
|
||||
<n-popconfirm v-if="userIDs.length" @positive-click="onDeleteUsers(userIDs)">
|
||||
<template #trigger>
|
||||
<n-button type="warning">删除</n-button>
|
||||
</template>
|
||||
@@ -319,11 +296,7 @@ watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
|
||||
:row-key="rowKey"
|
||||
@update:checked-row-keys="chooseUsers"
|
||||
/>
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:limit="query.limit"
|
||||
v-model:page="query.page"
|
||||
/>
|
||||
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
|
||||
<n-modal
|
||||
:mask-closable="false"
|
||||
:show="!!userEditing"
|
||||
@@ -343,11 +316,7 @@ watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
|
||||
<!-- 班级是后端从用户名的 ks 数字前缀推出来的(classNameOf),
|
||||
editUser 也不发这个字段,所以这里只读 —— 以前是个能改、改了没用的输入框 -->
|
||||
<n-form-item-gi v-if="!create" :span="1" label="班级">
|
||||
<n-input
|
||||
:value="userEditing.className"
|
||||
disabled
|
||||
placeholder="由用户名自动推导"
|
||||
/>
|
||||
<n-input :value="userEditing.className" disabled placeholder="由用户名自动推导" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi :span="1" label="邮箱">
|
||||
<n-input v-model:value="userEditing.email" />
|
||||
@@ -355,11 +324,7 @@ watch(() => [query.page, query.limit, query.type, query.orderBy], listUsers)
|
||||
<n-form-item-gi v-if="!create" :span="1" label="类型">
|
||||
<n-select v-model:value="userEditing.adminType" :options="options" />
|
||||
</n-form-item-gi>
|
||||
<n-form-item-gi
|
||||
:span="1"
|
||||
label="密码"
|
||||
label-style="color: red; font-weight: bold"
|
||||
>
|
||||
<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
|
||||
|
||||
@@ -70,9 +70,7 @@ router.beforeEach(async (to, _from, next) => {
|
||||
next("/")
|
||||
return
|
||||
}
|
||||
} else if (
|
||||
to.matched.some((record) => record.meta.requiresProblemPermission)
|
||||
) {
|
||||
} else if (to.matched.some((record) => record.meta.requiresProblemPermission)) {
|
||||
if (!userStore.hasProblemPermission) {
|
||||
next("/")
|
||||
return
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import api from "utils/api"
|
||||
import type {
|
||||
AchievementList,
|
||||
AchievementSummary,
|
||||
PendingAchievement,
|
||||
} from "utils/types"
|
||||
import type { AchievementList, AchievementSummary, PendingAchievement } from "utils/types"
|
||||
|
||||
export function getAchievements(name?: string) {
|
||||
return api.get<AchievementList>("achievements", {
|
||||
|
||||
@@ -12,14 +12,10 @@ const rarityTextColor = useRarityColor()
|
||||
|
||||
// 隐藏且未解锁:后端已把名称/描述/图标和条件三件套都遮成 ??? 和 null,
|
||||
// 这里只负责不要把 null 渲染出来,也不要画出会泄露门槛的进度条
|
||||
const masked = computed(
|
||||
() => props.achievement.hidden && !props.achievement.unlocked,
|
||||
)
|
||||
const masked = computed(() => props.achievement.hidden && !props.achievement.unlocked)
|
||||
|
||||
// 获得率低于 5% 的加稀有闪光边框
|
||||
const isRare = computed(
|
||||
() => props.achievement.unlockRate > 0 && props.achievement.unlockRate < 5,
|
||||
)
|
||||
const isRare = computed(() => props.achievement.unlockRate > 0 && props.achievement.unlockRate < 5)
|
||||
|
||||
// 只有"越多越好"的成就画进度条。lte 类(如最短 AC 代码 ≤ 50 字符)
|
||||
// 画成百分比毫无意义,改成直接显示当前最好成绩
|
||||
@@ -88,9 +84,7 @@ const unlockDate = computed(() => {
|
||||
<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.unlockRate }}% 的人获得
|
||||
</n-text>
|
||||
<n-text depth="3" class="nowrap"> 仅 {{ achievement.unlockRate }}% 的人获得 </n-text>
|
||||
</template>
|
||||
|
||||
<template v-else-if="showProgressBar">
|
||||
@@ -107,17 +101,13 @@ const unlockDate = computed(() => {
|
||||
</template>
|
||||
|
||||
<template v-else-if="showBestSoFar">
|
||||
<n-text depth="3" class="nowrap">
|
||||
目标 ≤ {{ achievement.threshold }}
|
||||
</n-text>
|
||||
<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.unlockRate }}% 的人获得
|
||||
</n-text>
|
||||
<n-text v-else depth="3" class="nowrap"> 仅 {{ achievement.unlockRate }}% 的人获得 </n-text>
|
||||
</n-flex>
|
||||
</n-thing>
|
||||
</n-card>
|
||||
|
||||
@@ -3,12 +3,7 @@ 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,
|
||||
UserBadge,
|
||||
} from "utils/types"
|
||||
import type { Achievement, AchievementRarity, AchievementSummary, UserBadge } from "utils/types"
|
||||
import AchievementCard from "./components/AchievementCard.vue"
|
||||
|
||||
const route = useRoute()
|
||||
@@ -29,9 +24,7 @@ const RARITY_RANK: Record<AchievementRarity, number> = {
|
||||
}
|
||||
|
||||
const rarities = computed(() =>
|
||||
[...(summary.value?.rarity ?? [])].sort(
|
||||
(a, b) => RARITY_RANK[a.rarity] - RARITY_RANK[b.rarity],
|
||||
),
|
||||
[...(summary.value?.rarity ?? [])].sort((a, b) => RARITY_RANK[a.rarity] - RARITY_RANK[b.rarity]),
|
||||
)
|
||||
const badges = ref<UserBadge[]>([])
|
||||
const { isDesktop } = useBreakpoints()
|
||||
@@ -39,10 +32,8 @@ 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)
|
||||
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
|
||||
})
|
||||
|
||||
@@ -73,11 +64,7 @@ watch(name, load)
|
||||
<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-progress type="circle" :percentage="summary.percent" :stroke-width="8">
|
||||
<n-text strong :style="{ fontSize: isDesktop ? '20px' : '14px' }">
|
||||
{{ summary.percent }}%
|
||||
</n-text>
|
||||
@@ -88,13 +75,7 @@ watch(name, load)
|
||||
</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-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>
|
||||
@@ -108,9 +89,7 @@ watch(name, load)
|
||||
:color="rarityColor[r.rarity]"
|
||||
:show-indicator="false"
|
||||
/>
|
||||
<n-text depth="3" class="nowrap">
|
||||
{{ r.unlocked }} / {{ r.total }}
|
||||
</n-text>
|
||||
<n-text depth="3" class="nowrap"> {{ r.unlocked }} / {{ r.total }} </n-text>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
@@ -124,13 +103,7 @@ watch(name, load)
|
||||
</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-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>
|
||||
@@ -140,19 +113,10 @@ watch(name, load)
|
||||
</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-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"
|
||||
>
|
||||
<n-thing :title="b.badge?.name" :description="b.badge?.description">
|
||||
<template #avatar v-if="b.badge?.icon">
|
||||
<n-avatar
|
||||
:size="40"
|
||||
|
||||
@@ -13,11 +13,7 @@
|
||||
@change="onUsernameChange"
|
||||
@clear="onUsernameChange"
|
||||
/>
|
||||
<n-select
|
||||
style="width: 140px"
|
||||
:options="options"
|
||||
v-model:value="urlDuration"
|
||||
/>
|
||||
<n-select style="width: 140px" :options="options" v-model:value="urlDuration" />
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
|
||||
@@ -74,9 +70,7 @@ aiStore.targetUsername = urlUsername.value
|
||||
aiStore.duration = urlDuration.value
|
||||
|
||||
const subOptions = computed<Duration>(
|
||||
() =>
|
||||
durationFromValue(aiStore.duration) ??
|
||||
durationFromValue(DURATION_OPTIONS[0].value)!,
|
||||
() => durationFromValue(aiStore.duration) ?? durationFromValue(DURATION_OPTIONS[0].value)!,
|
||||
)
|
||||
|
||||
const start = computed(() => formatISO(sub(new Date(), subOptions.value)))
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<template>
|
||||
<n-card title="几次做对" size="small" v-if="show">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">
|
||||
通过前提交了几次,看有没有在死磕
|
||||
</n-text>
|
||||
<n-text depth="3" style="font-size: 12px"> 通过前提交了几次,看有没有在死磕 </n-text>
|
||||
</template>
|
||||
<div class="chart">
|
||||
<Bar :key="chartKey" :data="data" :options="options" />
|
||||
@@ -13,13 +11,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChartOptions } from "chart.js"
|
||||
import { Bar } from "vue-chartjs"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Tooltip,
|
||||
} from "chart.js"
|
||||
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Tooltip } from "chart.js"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { useChartTheme } from "shared/composables/chartTheme"
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<template>
|
||||
<n-card title="难度分布" size="small" v-if="show">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">
|
||||
看看简单题和难题各做了多少
|
||||
</n-text>
|
||||
<n-text depth="3" style="font-size: 12px"> 看看简单题和难题各做了多少 </n-text>
|
||||
</template>
|
||||
<div style="height: 300px">
|
||||
<Bar :key="chartKey" :data="data" :options="options" />
|
||||
@@ -14,13 +12,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChartOptions } from "chart.js"
|
||||
import { Bar } from "vue-chartjs"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Tooltip,
|
||||
} from "chart.js"
|
||||
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Tooltip } from "chart.js"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { useChartTheme } from "shared/composables/chartTheme"
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<template>
|
||||
<n-card :title="title" size="small" v-if="show">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">
|
||||
做题量和提交质量的变化
|
||||
</n-text>
|
||||
<n-text depth="3" style="font-size: 12px"> 做题量和提交质量的变化 </n-text>
|
||||
</template>
|
||||
<div class="chart">
|
||||
<Chart type="bar" :key="chartKey" :data="data" :options="options" />
|
||||
@@ -67,10 +65,7 @@ const show = computed(() => aiStore.durationData.length > 0)
|
||||
// 而且逐题的等级在下面的解题表格里本来就有
|
||||
const data = computed<ChartData<"bar" | "line">>(() => ({
|
||||
labels: aiStore.durationData.map((duration) =>
|
||||
[
|
||||
parseTime(duration.start, "M月D日"),
|
||||
parseTime(duration.end, "M月D日"),
|
||||
].join("~"),
|
||||
[parseTime(duration.start, "M月D日"), parseTime(duration.end, "M月D日")].join("~"),
|
||||
),
|
||||
datasets: [
|
||||
{
|
||||
@@ -155,14 +150,11 @@ const options = computed<ChartOptions<"bar" | "line">>(() => ({
|
||||
},
|
||||
footer: (items: TooltipItem<"bar" | "line">[]) => {
|
||||
const index = items[0]?.dataIndex
|
||||
const bucket =
|
||||
index === undefined ? undefined : aiStore.durationData[index]
|
||||
const bucket = index === undefined ? undefined : aiStore.durationData[index]
|
||||
if (!bucket) return ""
|
||||
const lines = [`本期等级: ${bucket.grade || "无"}`]
|
||||
if (bucket.submissionCount > 0) {
|
||||
lines.push(
|
||||
`通过 ${bucket.acceptedCount} 次 / 共提交 ${bucket.submissionCount} 次`,
|
||||
)
|
||||
lines.push(`通过 ${bucket.acceptedCount} 次 / 共提交 ${bucket.submissionCount} 次`)
|
||||
}
|
||||
return lines
|
||||
},
|
||||
|
||||
@@ -11,13 +11,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChartOptions } from "chart.js"
|
||||
import { Bar } from "vue-chartjs"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Tooltip,
|
||||
} from "chart.js"
|
||||
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Tooltip } from "chart.js"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { useChartTheme } from "shared/composables/chartTheme"
|
||||
import { JUDGE_STATUS } from "utils/constants"
|
||||
@@ -75,9 +69,7 @@ const data = computed(() => ({
|
||||
],
|
||||
}))
|
||||
|
||||
const total = computed(() =>
|
||||
grouped.value.reduce((sum, [, item]) => sum + item.count, 0),
|
||||
)
|
||||
const total = computed(() => grouped.value.reduce((sum, [, item]) => sum + item.count, 0))
|
||||
|
||||
const options = computed<ChartOptions<"bar">>(() => ({
|
||||
indexAxis: "y",
|
||||
|
||||
@@ -11,14 +11,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChartOptions } from "chart.js"
|
||||
import { Bar } from "vue-chartjs"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Tooltip,
|
||||
Legend,
|
||||
} from "chart.js"
|
||||
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Tooltip, Legend } from "chart.js"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { useChartTheme } from "shared/composables/chartTheme"
|
||||
|
||||
@@ -31,8 +24,7 @@ const { chartKey } = useChartTheme()
|
||||
// 第二个 tab 里列成表格。这里直接画出来,不用改后端和契约
|
||||
const items = computed(() =>
|
||||
[...aiStore.detailsData.flowcharts].sort(
|
||||
(a, b) =>
|
||||
b.bestScore - a.bestScore || a.problemId.localeCompare(b.problemId),
|
||||
(a, b) => b.bestScore - a.bestScore || a.problemId.localeCompare(b.problemId),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<template>
|
||||
<n-card title="过去一年的提交热力图" size="small">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">
|
||||
每格一周,激励持续学习
|
||||
</n-text>
|
||||
<n-text depth="3" style="font-size: 12px"> 每格一周,激励持续学习 </n-text>
|
||||
</template>
|
||||
<n-spin :show="aiStore.loading.heatmap" :delay="50">
|
||||
<div
|
||||
@@ -154,8 +152,7 @@ const tooltipStyle = computed(() => ({
|
||||
top: `${tooltip.value?.y}px`,
|
||||
}))
|
||||
|
||||
const getTooltipText = (count: number) =>
|
||||
count === 0 ? "这周没有提交" : `这周提交了 ${count} 次`
|
||||
const getTooltipText = (count: number) => (count === 0 ? "这周没有提交" : `这周提交了 ${count} 次`)
|
||||
|
||||
const showTooltip = (e: MouseEvent, cell: Cell) => {
|
||||
const rect = (e.target as HTMLElement).getBoundingClientRect()
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
<template>
|
||||
<n-alert
|
||||
:show-icon="false"
|
||||
type="success"
|
||||
v-if="aiStore.detailsData.solvedCount"
|
||||
>
|
||||
<n-alert :show-icon="false" type="success" v-if="aiStore.detailsData.solvedCount">
|
||||
<span>{{ durationLabel }},</span>
|
||||
<span>你一共解决 </span>
|
||||
<b class="charming"> {{ aiStore.detailsData.solvedCount }} </b>
|
||||
@@ -33,10 +29,7 @@ const durationLabel = computed(() => {
|
||||
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")
|
||||
) {
|
||||
} 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日")} 期间`
|
||||
|
||||
@@ -79,12 +79,7 @@ const columns: DataTableColumn<SolvedProblem>[] = [
|
||||
text: true,
|
||||
onClick: () => {
|
||||
if (row.problem.contestId) {
|
||||
router.push(
|
||||
"/contest/" +
|
||||
row.problem.contestId +
|
||||
"/problem/" +
|
||||
row.problem.displayId,
|
||||
)
|
||||
router.push("/contest/" + row.problem.contestId + "/problem/" + row.problem.displayId)
|
||||
} else {
|
||||
router.push("/problem/" + row.problem.displayId)
|
||||
}
|
||||
@@ -102,8 +97,7 @@ const columns: DataTableColumn<SolvedProblem>[] = [
|
||||
{
|
||||
// 用后端下发的 rankScope,不要看 className 有没有值:班里只有一个人时
|
||||
// 后端会回退到全服排名,那种学生原来看到的是「班级排名」配全服数据
|
||||
title: () =>
|
||||
aiStore.detailsData.rankScope === "class" ? "班级排名" : "全服排名",
|
||||
title: () => (aiStore.detailsData.rankScope === "class" ? "班级排名" : "全服排名"),
|
||||
key: "rank",
|
||||
width: 100,
|
||||
align: "center",
|
||||
@@ -119,12 +113,7 @@ const columns: DataTableColumn<SolvedProblem>[] = [
|
||||
{
|
||||
title: () =>
|
||||
h(NTooltip, null, {
|
||||
trigger: () =>
|
||||
h(
|
||||
"span",
|
||||
{ style: "cursor:help; border-bottom: 1px dashed" },
|
||||
"等级",
|
||||
),
|
||||
trigger: () => h("span", { style: "cursor:help; border-bottom: 1px dashed" }, "等级"),
|
||||
default: () =>
|
||||
h("div", null, [
|
||||
h("div", null, "基于同时段排名的百分位:"),
|
||||
|
||||
@@ -11,13 +11,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { ChartOptions } from "chart.js"
|
||||
import { Bar } from "vue-chartjs"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
Tooltip,
|
||||
} from "chart.js"
|
||||
import { Chart as ChartJS, CategoryScale, LinearScale, BarElement, Tooltip } from "chart.js"
|
||||
import { useAIStore } from "oj/store/ai"
|
||||
import { useChartTheme } from "shared/composables/chartTheme"
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
<template>
|
||||
<n-card title="时间活跃度分析" size="small" v-if="show">
|
||||
<template #header-extra>
|
||||
<n-text depth="3" style="font-size: 12px">
|
||||
基于全部提交,发现做题高峰时段
|
||||
</n-text>
|
||||
<n-text depth="3" style="font-size: 12px"> 基于全部提交,发现做题高峰时段 </n-text>
|
||||
</template>
|
||||
<div style="height: 300px">
|
||||
<Bar :key="chartKey" :data="data" :options="options" />
|
||||
@@ -56,18 +54,14 @@ const activityMatrix = computed(() => {
|
||||
return matrix
|
||||
})
|
||||
|
||||
const show = computed(() =>
|
||||
aiStore.detailsData.activity.some((item) => item.count > 0),
|
||||
)
|
||||
const show = computed(() => aiStore.detailsData.activity.some((item) => item.count > 0))
|
||||
|
||||
// 为每个时间段准备数据集
|
||||
const data = computed(() => {
|
||||
const datasets = TIME_PERIODS.map((period, periodIndex) => {
|
||||
return {
|
||||
label: period.label,
|
||||
data: WEEKDAYS.map(
|
||||
(_, weekday) => activityMatrix.value[weekday][periodIndex],
|
||||
),
|
||||
data: WEEKDAYS.map((_, weekday) => activityMatrix.value[weekday][periodIndex]),
|
||||
backgroundColor: getTimePeriodColor(periodIndex),
|
||||
borderColor: getTimePeriodColor(periodIndex),
|
||||
borderWidth: 1,
|
||||
|
||||
@@ -71,17 +71,8 @@ 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-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"
|
||||
|
||||
+14
-49
@@ -107,11 +107,7 @@ export async function getProblem(problemID: string, contestID: string) {
|
||||
// `problemDetailSchema.parse(v) as ProblemDetail`,`as` 把校验结果又断言回去、
|
||||
// 等于没校验,而 `parse` 抛错会让整个题目页白屏。现在形状不符时记一条控制台
|
||||
// 分歧再放行原始数据。
|
||||
const problem: ProblemDetail = contract(
|
||||
"GET /problems/:id",
|
||||
problemDetailSchema,
|
||||
response,
|
||||
)
|
||||
const problem: ProblemDetail = contract("GET /problems/:id", problemDetailSchema, response)
|
||||
return problem
|
||||
}
|
||||
|
||||
@@ -171,9 +167,7 @@ export function getTodaySubmissionStatistics() {
|
||||
}
|
||||
|
||||
export function adminRejudge(id: string) {
|
||||
return api.post<{ ok: boolean }>(
|
||||
`submissions/${encodeURIComponent(id)}/rejudge`,
|
||||
)
|
||||
return api.post<{ ok: boolean }>(`submissions/${encodeURIComponent(id)}/rejudge`)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -226,21 +220,13 @@ export function getClassRank(grade?: number | null) {
|
||||
return api.get<ClassRankItem[]>("rankings/classes", { params: { grade } })
|
||||
}
|
||||
|
||||
export function getUserClassRank(
|
||||
scope?: "all" | "window",
|
||||
offset?: number,
|
||||
limit?: number,
|
||||
) {
|
||||
export function getUserClassRank(scope?: "all" | "window", offset?: number, limit?: number) {
|
||||
return api.get<ClassUserRank>("me/class-rank", {
|
||||
params: { scope, offset, limit },
|
||||
})
|
||||
}
|
||||
|
||||
export function getClassPK(
|
||||
classNames: string[],
|
||||
startTime?: string,
|
||||
endTime?: string,
|
||||
) {
|
||||
export function getClassPK(classNames: string[], startTime?: string, endTime?: string) {
|
||||
return api.post<ClassComparisonResponse>("classes/comparison", {
|
||||
classNames,
|
||||
...(startTime ? { startTime } : {}),
|
||||
@@ -274,16 +260,11 @@ export function checkContestPassword(contestID: string, password: string) {
|
||||
}
|
||||
|
||||
export async function getContestProblems(contestID: string) {
|
||||
const res = await api.get<ProblemListItem[]>(
|
||||
`contests/${encodeURIComponent(contestID)}/problems`,
|
||||
)
|
||||
const res = await api.get<ProblemListItem[]>(`contests/${encodeURIComponent(contestID)}/problems`)
|
||||
return res.map(toProblemRow)
|
||||
}
|
||||
|
||||
export function getContestRank(
|
||||
contestID: string,
|
||||
query: { limit: number; offset: number },
|
||||
) {
|
||||
export function getContestRank(contestID: string, query: { limit: number; offset: number }) {
|
||||
// submissionInfo 在契约里是 Record<string, unknown>(JSONB 原文),
|
||||
// 前端在这里收窄成 ContestSubmissionInfo,见 utils/types 的 ContestRank
|
||||
return api.get<{ results: ContestRank[]; total: number }>(
|
||||
@@ -305,12 +286,9 @@ export function updateProfile(data: { realName: string; mood: string }) {
|
||||
}
|
||||
|
||||
export function getAnnouncementList(offset = 0, limit = 10) {
|
||||
return api.get<{ results: AnnouncementListItem[]; total: number }>(
|
||||
"announcements",
|
||||
{
|
||||
params: { limit, offset },
|
||||
},
|
||||
)
|
||||
return api.get<{ results: AnnouncementListItem[]; total: number }>("announcements", {
|
||||
params: { limit, offset },
|
||||
})
|
||||
}
|
||||
|
||||
export function getAnnouncement(id: number) {
|
||||
@@ -360,11 +338,7 @@ export function getAISolved(
|
||||
})
|
||||
}
|
||||
|
||||
export function getAIDurationData(
|
||||
end: string,
|
||||
duration: string,
|
||||
username?: string,
|
||||
) {
|
||||
export function getAIDurationData(end: string, duration: string, username?: string) {
|
||||
return api.get<DurationData[]>("ai/duration", {
|
||||
params: { end, duration, username },
|
||||
})
|
||||
@@ -398,9 +372,7 @@ export function getSimilarProblems(problemId: string) {
|
||||
export type { YearlyAc as YearlyACData } from "@oj2/contract"
|
||||
|
||||
export function getProblemYearlyAC(problemId: string) {
|
||||
return api.get<YearlyAc[]>(
|
||||
`problems/${encodeURIComponent(problemId)}/yearly-ac`,
|
||||
)
|
||||
return api.get<YearlyAc[]>(`problems/${encodeURIComponent(problemId)}/yearly-ac`)
|
||||
}
|
||||
|
||||
// ==================== 流程图相关API ====================
|
||||
@@ -440,9 +412,7 @@ export function getFlowchartStatistics(
|
||||
}
|
||||
|
||||
export function retryFlowchartSubmission(submissionId: string) {
|
||||
return api.post<{ status: string }>(
|
||||
`flowcharts/${encodeURIComponent(submissionId)}/retry`,
|
||||
)
|
||||
return api.post<{ status: string }>(`flowcharts/${encodeURIComponent(submissionId)}/retry`)
|
||||
}
|
||||
|
||||
export function getCurrentProblemFlowchartSubmission(problemId: number) {
|
||||
@@ -482,9 +452,7 @@ export function joinProblemSet(problemSetId: number) {
|
||||
}
|
||||
|
||||
export function getUserBadges(username?: string) {
|
||||
return api.get<UserBadge[]>(
|
||||
`users/${encodeURIComponent(username ?? "me")}/badges`,
|
||||
)
|
||||
return api.get<UserBadge[]>(`users/${encodeURIComponent(username ?? "me")}/badges`)
|
||||
}
|
||||
|
||||
export function getProblemSetBadges(problemSetId: number) {
|
||||
@@ -500,10 +468,7 @@ export function getProblemSetUserProgress(
|
||||
completionStatus?: "" | "completed" | "in_progress" | "not_started"
|
||||
},
|
||||
) {
|
||||
return api.get<ProblemSetProgressList>(
|
||||
`problem-sets/${problemSetId}/user-progress`,
|
||||
{ params },
|
||||
)
|
||||
return api.get<ProblemSetProgressList>(`problem-sets/${problemSetId}/user-progress`, { params })
|
||||
}
|
||||
|
||||
export function getExercises(tutorialId: number): Promise<Exercise[]> {
|
||||
|
||||
+32
-150
@@ -67,9 +67,7 @@ const timeRangeOptions: SelectOption[] = [
|
||||
]
|
||||
|
||||
// 「全部时间」的 value 是空串,解不出来就是 null —— 正是不带时间条件的意思
|
||||
const subOptions = computed<Duration | null>(() =>
|
||||
durationFromValue(duration.value),
|
||||
)
|
||||
const subOptions = computed<Duration | null>(() => durationFromValue(duration.value))
|
||||
|
||||
// 根据时间段选项计算开始和结束时间
|
||||
function getTimeRange(): {
|
||||
@@ -121,8 +119,7 @@ async function compare() {
|
||||
|
||||
async function analyzeWithAI() {
|
||||
const timeRangeLabel =
|
||||
timeRangeOptions.find((o) => o.value === duration.value)?.label ??
|
||||
"全部时间"
|
||||
timeRangeOptions.find((o) => o.value === duration.value)?.label ?? "全部时间"
|
||||
|
||||
showAIModal.value = true
|
||||
aiContent.value = ""
|
||||
@@ -208,14 +205,7 @@ const radarChartData = computed(() => {
|
||||
return ((value - min) / (max - min)) * 100
|
||||
}
|
||||
|
||||
const metrics = [
|
||||
"总AC数",
|
||||
"平均AC数",
|
||||
"中位数AC数",
|
||||
"优秀率",
|
||||
"及格率",
|
||||
"参与度",
|
||||
]
|
||||
const metrics = ["总AC数", "平均AC数", "中位数AC数", "优秀率", "及格率", "参与度"]
|
||||
|
||||
// 计算每个指标的最大最小值
|
||||
const maxValues = [
|
||||
@@ -238,14 +228,7 @@ const radarChartData = computed(() => {
|
||||
|
||||
const datasets = comparisons.value.map((c, index) => {
|
||||
const color = getClassColor(index)
|
||||
const rawData = [
|
||||
c.totalAc,
|
||||
c.avgAc,
|
||||
c.medianAc,
|
||||
c.excellentRate,
|
||||
c.passRate,
|
||||
c.activeRate,
|
||||
]
|
||||
const rawData = [c.totalAc, c.avgAc, c.medianAc, c.excellentRate, c.passRate, c.activeRate]
|
||||
return {
|
||||
label: c.className,
|
||||
data: [
|
||||
@@ -360,86 +343,55 @@ const tableColumns: DataTableColumn<ClassComparison>[] = [
|
||||
{
|
||||
title: "班级",
|
||||
key: "class_name",
|
||||
render: (row) =>
|
||||
`${row.className.slice(0, 2)}计算机${row.className.slice(2)}班`,
|
||||
render: (row) => `${row.className.slice(0, 2)}计算机${row.className.slice(2)}班`,
|
||||
width: 160,
|
||||
},
|
||||
{
|
||||
title: "人数",
|
||||
key: "user_count",
|
||||
width: 80,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{ style: { color: "#1890ff", fontWeight: "600" } },
|
||||
row.userCount,
|
||||
),
|
||||
render: (row) => h("span", { style: { color: "#1890ff", fontWeight: "600" } }, row.userCount),
|
||||
},
|
||||
{
|
||||
title: "总AC数",
|
||||
key: "total_ac",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{ style: { color: "#ff4d4f", fontWeight: "600" } },
|
||||
row.totalAc,
|
||||
),
|
||||
render: (row) => h("span", { style: { color: "#ff4d4f", fontWeight: "600" } }, row.totalAc),
|
||||
},
|
||||
{
|
||||
title: "平均AC",
|
||||
key: "avg_ac",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{ style: { color: "#52c41a", fontWeight: "600" } },
|
||||
row.avgAc.toFixed(2),
|
||||
),
|
||||
h("span", { style: { color: "#52c41a", fontWeight: "600" } }, row.avgAc.toFixed(2)),
|
||||
},
|
||||
{
|
||||
title: "中位数AC",
|
||||
key: "median_ac",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{ style: { color: "#fa8c16", fontWeight: "600" } },
|
||||
row.medianAc.toFixed(2),
|
||||
),
|
||||
h("span", { style: { color: "#fa8c16", fontWeight: "600" } }, row.medianAc.toFixed(2)),
|
||||
},
|
||||
{
|
||||
title: "前10%均值",
|
||||
key: "top10_avg",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{ style: { color: "#cf1322", fontWeight: "600" } },
|
||||
row.top10Avg.toFixed(2),
|
||||
),
|
||||
h("span", { style: { color: "#cf1322", fontWeight: "600" } }, row.top10Avg.toFixed(2)),
|
||||
},
|
||||
{
|
||||
title: "中间80%均值",
|
||||
key: "middle80_avg",
|
||||
width: 110,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{ style: { color: "#389e0d", fontWeight: "600" } },
|
||||
row.middle80Avg.toFixed(2),
|
||||
),
|
||||
h("span", { style: { color: "#389e0d", fontWeight: "600" } }, row.middle80Avg.toFixed(2)),
|
||||
},
|
||||
{
|
||||
title: "后10%均值",
|
||||
key: "bottom10_avg",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{ style: { color: "#096dd9", fontWeight: "500" } },
|
||||
row.bottom10Avg.toFixed(2),
|
||||
),
|
||||
h("span", { style: { color: "#096dd9", fontWeight: "500" } }, row.bottom10Avg.toFixed(2)),
|
||||
},
|
||||
{
|
||||
title: "优秀率",
|
||||
@@ -457,11 +409,7 @@ const tableColumns: DataTableColumn<ClassComparison>[] = [
|
||||
key: "pass_rate",
|
||||
width: 100,
|
||||
render: (row) =>
|
||||
h(
|
||||
"span",
|
||||
{ style: { color: "#52c41a", fontWeight: "600" } },
|
||||
row.passRate.toFixed(1) + "%",
|
||||
),
|
||||
h("span", { style: { color: "#52c41a", fontWeight: "600" } }, row.passRate.toFixed(1) + "%"),
|
||||
},
|
||||
{
|
||||
title: "参与度",
|
||||
@@ -527,10 +475,7 @@ const radarChartOptions = {
|
||||
<n-h2 style="margin-bottom: 0">班级PK</n-h2>
|
||||
|
||||
<n-flex :wrap="false" align="flex-start" :size="16">
|
||||
<n-form-item
|
||||
label="选择班级(至少2个)"
|
||||
style="width: 300px; margin-bottom: 0"
|
||||
>
|
||||
<n-form-item label="选择班级(至少2个)" style="width: 300px; margin-bottom: 0">
|
||||
<n-select
|
||||
v-model:value="selectedClasses"
|
||||
:options="classOptions"
|
||||
@@ -539,10 +484,7 @@ const radarChartOptions = {
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-form-item
|
||||
label="时间段(可选)"
|
||||
style="width: 200px; margin-bottom: 0"
|
||||
>
|
||||
<n-form-item label="时间段(可选)" style="width: 200px; margin-bottom: 0">
|
||||
<n-select
|
||||
v-model:value="duration"
|
||||
:options="timeRangeOptions"
|
||||
@@ -552,12 +494,7 @@ const radarChartOptions = {
|
||||
/>
|
||||
</n-form-item>
|
||||
|
||||
<n-button
|
||||
type="primary"
|
||||
@click="compare"
|
||||
:loading="loading"
|
||||
style="margin-top: 26px"
|
||||
>
|
||||
<n-button type="primary" @click="compare" :loading="loading" style="margin-top: 26px">
|
||||
开始PK
|
||||
</n-button>
|
||||
<n-button
|
||||
@@ -644,10 +581,7 @@ const radarChartOptions = {
|
||||
class="stat-avg-ac"
|
||||
>
|
||||
<template #suffix>
|
||||
<Icon
|
||||
icon="streamline-ultimate-color:analytics-pie-2"
|
||||
width="20"
|
||||
/>
|
||||
<Icon icon="streamline-ultimate-color:analytics-pie-2" width="20" />
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-gi>
|
||||
@@ -659,10 +593,7 @@ const radarChartOptions = {
|
||||
class="stat-median-ac"
|
||||
>
|
||||
<template #suffix>
|
||||
<Icon
|
||||
icon="streamline-ultimate-color:cursor-target-1"
|
||||
width="20"
|
||||
/>
|
||||
<Icon icon="streamline-ultimate-color:cursor-target-1" width="20" />
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-gi>
|
||||
@@ -674,10 +605,7 @@ const radarChartOptions = {
|
||||
class="stat-total-submission"
|
||||
>
|
||||
<template #suffix>
|
||||
<Icon
|
||||
icon="streamline-ultimate-color:common-file-text"
|
||||
width="20"
|
||||
/>
|
||||
<Icon icon="streamline-ultimate-color:common-file-text" width="20" />
|
||||
</template>
|
||||
</n-statistic>
|
||||
</n-gi>
|
||||
@@ -698,12 +626,7 @@ const radarChartOptions = {
|
||||
<n-divider style="margin: 12px 0" />
|
||||
|
||||
<!-- 详细统计 - 紧凑布局,统一格式 -->
|
||||
<n-descriptions
|
||||
bordered
|
||||
:column="2"
|
||||
size="small"
|
||||
label-placement="left"
|
||||
>
|
||||
<n-descriptions bordered :column="2" size="small" label-placement="left">
|
||||
<!-- 分位数统计 -->
|
||||
<n-descriptions-item label="第一四分位数(Q1)">
|
||||
<span style="color: #9254de; font-weight: 500">{{
|
||||
@@ -745,19 +668,12 @@ const radarChartOptions = {
|
||||
|
||||
<!-- 人数 -->
|
||||
<n-descriptions-item label="人数">
|
||||
<span style="color: #1890ff; font-weight: 600">{{
|
||||
classData.userCount
|
||||
}}</span>
|
||||
<span style="color: #1890ff; font-weight: 600">{{ classData.userCount }}</span>
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
|
||||
<!-- 比率统计 - 使用进度条图表 -->
|
||||
<n-card
|
||||
size="small"
|
||||
title="比率统计"
|
||||
embedded
|
||||
style="margin-top: 12px"
|
||||
>
|
||||
<n-card size="small" title="比率统计" embedded style="margin-top: 12px">
|
||||
<n-space vertical :size="10">
|
||||
<n-progress
|
||||
type="line"
|
||||
@@ -776,9 +692,7 @@ const radarChartOptions = {
|
||||
:border-radius="4"
|
||||
status="success"
|
||||
>
|
||||
<template #default>
|
||||
及格率: {{ classData.passRate.toFixed(1) }}%
|
||||
</template>
|
||||
<template #default> 及格率: {{ classData.passRate.toFixed(1) }}% </template>
|
||||
</n-progress>
|
||||
<n-progress
|
||||
type="line"
|
||||
@@ -787,17 +701,13 @@ const radarChartOptions = {
|
||||
:border-radius="4"
|
||||
status="info"
|
||||
>
|
||||
<template #default>
|
||||
参与度: {{ classData.activeRate.toFixed(1) }}%
|
||||
</template>
|
||||
<template #default> 参与度: {{ classData.activeRate.toFixed(1) }}% </template>
|
||||
</n-progress>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<!-- 时间段统计(如果有) -->
|
||||
<template
|
||||
v-if="hasTimeRange && classData.recentTotalAc !== undefined"
|
||||
>
|
||||
<template v-if="hasTimeRange && classData.recentTotalAc !== undefined">
|
||||
<n-descriptions
|
||||
bordered
|
||||
:column="2"
|
||||
@@ -855,11 +765,7 @@ const radarChartOptions = {
|
||||
<n-gi>
|
||||
<n-card title="多维度综合对比" style="height: 100%">
|
||||
<div style="height: 380px">
|
||||
<Radar
|
||||
v-if="radarChartData"
|
||||
:data="radarChartData"
|
||||
:options="radarChartOptions"
|
||||
/>
|
||||
<Radar v-if="radarChartData" :data="radarChartData" :options="radarChartOptions" />
|
||||
</div>
|
||||
</n-card>
|
||||
</n-gi>
|
||||
@@ -870,29 +776,17 @@ const radarChartOptions = {
|
||||
<n-grid :cols="3" :x-gap="16" :y-gap="16">
|
||||
<n-gi>
|
||||
<div style="height: 300px">
|
||||
<Bar
|
||||
v-if="totalAcChartData"
|
||||
:data="totalAcChartData"
|
||||
:options="chartOptions"
|
||||
/>
|
||||
<Bar v-if="totalAcChartData" :data="totalAcChartData" :options="chartOptions" />
|
||||
</div>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<div style="height: 300px">
|
||||
<Bar
|
||||
v-if="avgAcChartData"
|
||||
:data="avgAcChartData"
|
||||
:options="chartOptions"
|
||||
/>
|
||||
<Bar v-if="avgAcChartData" :data="avgAcChartData" :options="chartOptions" />
|
||||
</div>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<div style="height: 300px">
|
||||
<Bar
|
||||
v-if="medianAcChartData"
|
||||
:data="medianAcChartData"
|
||||
:options="chartOptions"
|
||||
/>
|
||||
<Bar v-if="medianAcChartData" :data="medianAcChartData" :options="chartOptions" />
|
||||
</div>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
@@ -912,11 +806,7 @@ const radarChartOptions = {
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<div style="height: 300px">
|
||||
<Bar
|
||||
v-if="passRateChartData"
|
||||
:data="passRateChartData"
|
||||
:options="chartOptions"
|
||||
/>
|
||||
<Bar v-if="passRateChartData" :data="passRateChartData" :options="chartOptions" />
|
||||
</div>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
@@ -936,11 +826,7 @@ const radarChartOptions = {
|
||||
<n-grid :cols="3" :x-gap="16" :y-gap="16">
|
||||
<n-gi>
|
||||
<div style="height: 300px">
|
||||
<Bar
|
||||
v-if="top10AvgChartData"
|
||||
:data="top10AvgChartData"
|
||||
:options="chartOptions"
|
||||
/>
|
||||
<Bar v-if="top10AvgChartData" :data="top10AvgChartData" :options="chartOptions" />
|
||||
</div>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
@@ -966,11 +852,7 @@ const radarChartOptions = {
|
||||
</template>
|
||||
|
||||
<!-- 对比表格 -->
|
||||
<n-card
|
||||
v-if="comparisons.length > 0"
|
||||
title="对比表格"
|
||||
style="margin-top: 20px"
|
||||
>
|
||||
<n-card v-if="comparisons.length > 0" title="对比表格" style="margin-top: 20px">
|
||||
<n-data-table :data="comparisons" :columns="tableColumns" />
|
||||
</n-card>
|
||||
</n-flex>
|
||||
|
||||
@@ -8,11 +8,7 @@ const contestStore = useContestStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-popover
|
||||
v-if="contestStore.contest"
|
||||
placement="bottom-end"
|
||||
:show-arrow="false"
|
||||
>
|
||||
<n-popover v-if="contestStore.contest" placement="bottom-end" :show-arrow="false">
|
||||
<template #trigger>
|
||||
<n-button>
|
||||
<template #icon>
|
||||
|
||||
@@ -35,18 +35,11 @@ const options: DropdownOption[] = [
|
||||
<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 :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-button :type="getCurrentType('rank')" @click="goto('rank')"> 比赛排名 </n-button>
|
||||
</n-flex>
|
||||
<n-dropdown v-else :options="options" @select="goto">
|
||||
<n-button>菜单</n-button>
|
||||
|
||||
@@ -17,15 +17,7 @@ import {
|
||||
} from "chart.js"
|
||||
import type { ContestRank } from "utils/types"
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
)
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend)
|
||||
|
||||
interface Props {
|
||||
ranks: ContestRank[]
|
||||
|
||||
@@ -17,10 +17,7 @@ const { isDesktop } = useBreakpoints()
|
||||
const password = ref("")
|
||||
|
||||
async function check() {
|
||||
const error = await contestStore.checkPassword(
|
||||
props.contestID,
|
||||
password.value,
|
||||
)
|
||||
const error = await contestStore.checkPassword(props.contestID, password.value)
|
||||
if (error === "too-many-password-attempts") {
|
||||
message.error("密码错误次数太多,请 10 分钟后再试")
|
||||
} else if (!contestStore.access) {
|
||||
@@ -44,10 +41,7 @@ onMounted(() => {
|
||||
onBeforeUnmount(contestStore.clear)
|
||||
|
||||
const passwordFormVisible = computed(
|
||||
() =>
|
||||
contestStore.isPrivate &&
|
||||
!contestStore.access &&
|
||||
!contestStore.isContestAdmin,
|
||||
() => contestStore.isPrivate && !contestStore.access && !contestStore.isContestAdmin,
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -70,17 +64,9 @@ const passwordFormVisible = computed(
|
||||
<ContestMenu />
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
<n-form
|
||||
:inline="isDesktop"
|
||||
label-placement="left"
|
||||
v-if="passwordFormVisible"
|
||||
>
|
||||
<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-input name="ContestPassword" type="password" v-model:value="password" />
|
||||
</n-form-item>
|
||||
<n-form-item>
|
||||
<n-button @click="check" :disabled="!password">确认</n-button>
|
||||
|
||||
@@ -134,18 +134,10 @@ function rowProps(row: Contest) {
|
||||
<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-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-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>
|
||||
@@ -165,16 +157,7 @@ function rowProps(row: Contest) {
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
</n-space>
|
||||
<n-data-table
|
||||
:bordered="false"
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
:row-props="rowProps"
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
<Pagination v-model:limit="query.limit" v-model:page="query.page" :total="total" />
|
||||
</template>
|
||||
@@ -53,10 +53,7 @@ const columns = ref<DataTableColumn<ContestRank>[]>([
|
||||
render: (_, index) => index + (query.page - 1) * query.limit + 1,
|
||||
},
|
||||
{
|
||||
title: renderTableTitle(
|
||||
"用户",
|
||||
"streamline-emojis:smiling-face-with-sunglasses",
|
||||
),
|
||||
title: renderTableTitle("用户", "streamline-emojis:smiling-face-with-sunglasses"),
|
||||
key: "username",
|
||||
width: 120,
|
||||
fixed: "left",
|
||||
@@ -73,10 +70,7 @@ const columns = ref<DataTableColumn<ContestRank>[]>([
|
||||
),
|
||||
},
|
||||
{
|
||||
title: renderTableTitle(
|
||||
"正确数/总提交",
|
||||
"streamline-ultimate-color:color-palette",
|
||||
),
|
||||
title: renderTableTitle("正确数/总提交", "streamline-ultimate-color:color-palette"),
|
||||
key: "submission",
|
||||
width: 140,
|
||||
align: "center",
|
||||
@@ -147,16 +141,9 @@ async function addColumns() {
|
||||
]
|
||||
}
|
||||
if (status.error_number) {
|
||||
errorNumber = h(
|
||||
"span",
|
||||
{ style: "margin: 0" },
|
||||
`(-${status.error_number})`,
|
||||
)
|
||||
errorNumber = h("span", { style: "margin: 0" }, `(-${status.error_number})`)
|
||||
}
|
||||
return h("div", { class: "oj-time-with-modal" }, [
|
||||
acTime,
|
||||
errorNumber,
|
||||
])
|
||||
return h("div", { class: "oj-time-with-modal" }, [acTime, errorNumber])
|
||||
}
|
||||
},
|
||||
cellProps: (row) => {
|
||||
@@ -244,10 +231,7 @@ async function downloadExcel() {
|
||||
level = "一等奖"
|
||||
} else if (rank1 <= exportForm.first + exportForm.second) {
|
||||
level = "二等奖"
|
||||
} else if (
|
||||
rank1 <=
|
||||
exportForm.first + exportForm.second + exportForm.third
|
||||
) {
|
||||
} else if (rank1 <= exportForm.first + exportForm.second + exportForm.third) {
|
||||
level = "三等奖"
|
||||
} else {
|
||||
level = "参与奖"
|
||||
@@ -255,8 +239,7 @@ async function downloadExcel() {
|
||||
return { 用户名: rank.user.username, 等级: level }
|
||||
})
|
||||
|
||||
const csv =
|
||||
"用户名,等级\n" + rows.map((r) => `${r.用户名},${r.等级}`).join("\n")
|
||||
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")
|
||||
@@ -294,13 +277,7 @@ onMounted(() => {
|
||||
<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-data-table striped :single-line="false" :scroll-x="1200" :columns="columns" :data="data" />
|
||||
<n-space justify="end" align="center">
|
||||
<n-form
|
||||
label-placement="left"
|
||||
@@ -347,9 +324,7 @@ onMounted(() => {
|
||||
</n-form>
|
||||
<template #action>
|
||||
<n-button @click="showExportModal = false">取消</n-button>
|
||||
<n-button type="primary" :loading="exportLoading" @click="downloadExcel">
|
||||
下载 CSV
|
||||
</n-button>
|
||||
<n-button type="primary" :loading="exportLoading" @click="downloadExcel"> 下载 CSV </n-button>
|
||||
</template>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
@@ -102,9 +102,7 @@ function reset() {
|
||||
:style="lineStyle(idx)"
|
||||
@click="toggle(idx)"
|
||||
>
|
||||
<span
|
||||
style="color: #bbb; width: 22px; text-align: right; flex-shrink: 0"
|
||||
>
|
||||
<span style="color: #bbb; width: 22px; text-align: right; flex-shrink: 0">
|
||||
{{ idx + 1 }}
|
||||
</span>
|
||||
<span v-html="lineHtml[idx]" style="white-space: pre" />
|
||||
|
||||
@@ -36,9 +36,7 @@ const segments = computed<Segment[]>(() => {
|
||||
return result
|
||||
})
|
||||
|
||||
const blankCount = computed(
|
||||
() => segments.value.filter((s) => s.type === "blank").length,
|
||||
)
|
||||
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)
|
||||
@@ -134,9 +132,7 @@ function inputWidth(idx: number): string {
|
||||
/>
|
||||
|
||||
<n-space style="margin-top: 12px" :size="8">
|
||||
<n-button type="warning" :disabled="allCorrect" @click="submit">
|
||||
提交
|
||||
</n-button>
|
||||
<n-button type="warning" :disabled="allCorrect" @click="submit"> 提交 </n-button>
|
||||
<n-button @click="reset">重置</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
@@ -25,9 +25,7 @@ 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 allCorrect = computed(() => placement.value.every((p, i) => p === data.value.answer[i]))
|
||||
const locked = computed(() => submitted.value && allCorrect.value)
|
||||
|
||||
function onDragStart(i: number) {
|
||||
@@ -42,9 +40,7 @@ function dropTo(bucket: number) {
|
||||
submitted.value = false
|
||||
}
|
||||
|
||||
const poolItems = computed(() =>
|
||||
order.value.filter((i) => placement.value[i] === -1),
|
||||
)
|
||||
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)
|
||||
}
|
||||
@@ -57,11 +53,7 @@ function itemStatus(i: number): "correct" | "wrong" | "default" {
|
||||
function chipStyle(i: number): Record<string, string> {
|
||||
const status = itemStatus(i)
|
||||
const color =
|
||||
status === "correct"
|
||||
? "#18a058"
|
||||
: status === "wrong"
|
||||
? "#d03050"
|
||||
: "var(--n-border-color)"
|
||||
status === "correct" ? "#18a058" : status === "wrong" ? "#d03050" : "var(--n-border-color)"
|
||||
const plain = color === "var(--n-border-color)"
|
||||
return {
|
||||
padding: "6px 12px",
|
||||
@@ -114,10 +106,7 @@ function reset() {
|
||||
@dragover.prevent
|
||||
@drop="dropTo(-1)"
|
||||
>
|
||||
<span
|
||||
v-if="poolItems.length === 0"
|
||||
style="color: var(--n-text-color-3); font-size: 13px"
|
||||
>
|
||||
<span v-if="poolItems.length === 0" style="color: var(--n-text-color-3); font-size: 13px">
|
||||
(已全部归类)
|
||||
</span>
|
||||
<div
|
||||
@@ -150,14 +139,7 @@ function reset() {
|
||||
@dragover.prevent
|
||||
@drop="dropTo(b)"
|
||||
>
|
||||
<p
|
||||
style="
|
||||
font-weight: 600;
|
||||
margin: 0 0 8px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
"
|
||||
>
|
||||
<p style="font-weight: 600; margin: 0 0 8px; text-align: center; font-size: 14px">
|
||||
{{ bucket }}
|
||||
</p>
|
||||
<n-space :size="8">
|
||||
@@ -182,9 +164,7 @@ function reset() {
|
||||
/>
|
||||
|
||||
<n-space style="margin-top: 12px" :size="8">
|
||||
<n-button type="warning" :disabled="!allPlaced || locked" @click="submit">
|
||||
提交
|
||||
</n-button>
|
||||
<n-button type="warning" :disabled="!allPlaced || locked" @click="submit"> 提交 </n-button>
|
||||
<n-button @click="reset">重置</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
@@ -37,9 +37,7 @@ 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 allCorrect = computed(() => pairs.value.every((p, i) => p === data.value.answer[i]))
|
||||
const locked = computed(() => submitted.value && allCorrect.value)
|
||||
|
||||
function leftOf(rightIdx: number): number {
|
||||
@@ -183,9 +181,7 @@ function dotStyle(color: string): Record<string, string> {
|
||||
/>
|
||||
|
||||
<n-space style="margin-top: 12px" :size="8">
|
||||
<n-button type="primary" :disabled="!allPaired || locked" @click="submit">
|
||||
提交
|
||||
</n-button>
|
||||
<n-button type="primary" :disabled="!allPaired || locked" @click="submit"> 提交 </n-button>
|
||||
<n-button @click="reset">重置</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
@@ -33,8 +33,7 @@ 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))
|
||||
const isEqual = sel.size === answer.size && [...sel].every((v) => answer.has(v))
|
||||
emit("attempt", { correct: isEqual, answer: describe(sel) })
|
||||
if (isEqual) {
|
||||
correct.value = true
|
||||
@@ -105,9 +104,7 @@ function optionType(idx: number): "default" | "primary" | "success" {
|
||||
@click="select(idx)"
|
||||
>
|
||||
<template #icon>
|
||||
<span style="font-weight: 700">{{
|
||||
String.fromCharCode(65 + idx)
|
||||
}}</span>
|
||||
<span style="font-weight: 700">{{ String.fromCharCode(65 + idx) }}</span>
|
||||
</template>
|
||||
{{ opt }}
|
||||
</n-button>
|
||||
@@ -116,18 +113,12 @@ function optionType(idx: number): "default" | "primary" | "success" {
|
||||
<n-alert
|
||||
v-if="correct || wrong || partial"
|
||||
:type="correct ? 'success' : partial ? 'warning' : 'error'"
|
||||
:title="
|
||||
correct ? '正确!' : partial ? '部分正确,请重试' : '选择有误,请重试'
|
||||
"
|
||||
: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 type="primary" :disabled="selected.size === 0 || correct" @click="submit">
|
||||
提交
|
||||
</n-button>
|
||||
<n-button @click="reset">重置</n-button>
|
||||
|
||||
@@ -96,13 +96,7 @@ function reset() {
|
||||
/>
|
||||
|
||||
<n-space style="margin-top: 12px" :size="8">
|
||||
<n-button
|
||||
type="error"
|
||||
:disabled="submitted && allCorrect"
|
||||
@click="submit"
|
||||
>
|
||||
提交
|
||||
</n-button>
|
||||
<n-button type="error" :disabled="submitted && allCorrect" @click="submit"> 提交 </n-button>
|
||||
<n-button @click="reset">重置</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
@@ -17,9 +17,7 @@ const lines = ref<LineItem[]>([])
|
||||
const submitted = ref(false)
|
||||
|
||||
function init() {
|
||||
const shuffled = shuffle(
|
||||
data.value.lines.map((text, idx) => ({ originalIdx: idx, text })),
|
||||
)
|
||||
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) {
|
||||
@@ -53,9 +51,7 @@ function lineStatus(idx: number): "correct" | "wrong" | "default" {
|
||||
return lines.value[idx].originalIdx === idx ? "correct" : "wrong"
|
||||
}
|
||||
|
||||
const allCorrect = computed(() =>
|
||||
lines.value.every((item, i) => item.originalIdx === i),
|
||||
)
|
||||
const allCorrect = computed(() => lines.value.every((item, i) => item.originalIdx === i))
|
||||
|
||||
function submit() {
|
||||
submitted.value = true
|
||||
@@ -70,9 +66,7 @@ function reset() {
|
||||
init()
|
||||
}
|
||||
|
||||
const lineHtml = computed<string[]>(() =>
|
||||
highlightLines(data.value.lines, props.lang),
|
||||
)
|
||||
const lineHtml = computed<string[]>(() => highlightLines(data.value.lines, props.lang))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -130,9 +124,7 @@ const lineHtml = computed<string[]>(() =>
|
||||
/>
|
||||
|
||||
<n-space style="margin-top: 12px" :size="8">
|
||||
<n-button type="info" :disabled="submitted && allCorrect" @click="submit">
|
||||
提交
|
||||
</n-button>
|
||||
<n-button type="info" :disabled="submitted && allCorrect" @click="submit"> 提交 </n-button>
|
||||
<n-button @click="reset">重置</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
@@ -7,9 +7,7 @@ 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 ExercisePredict = defineAsyncComponent(() => import("./ExercisePredict.vue"))
|
||||
const ExerciseDebug = defineAsyncComponent(() => import("./ExerciseDebug.vue"))
|
||||
const ExerciseGroup = defineAsyncComponent(() => import("./ExerciseGroup.vue"))
|
||||
|
||||
@@ -48,11 +46,7 @@ function onAttempt(payload: { correct: boolean; answer?: string }) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ExerciseMcq
|
||||
v-if="exercise.type === 'mcq'"
|
||||
:exercise="exercise"
|
||||
@attempt="onAttempt"
|
||||
/>
|
||||
<ExerciseMcq v-if="exercise.type === 'mcq'" :exercise="exercise" @attempt="onAttempt" />
|
||||
<ExerciseSort
|
||||
v-else-if="exercise.type === 'sort'"
|
||||
:exercise="exercise"
|
||||
@@ -65,11 +59,7 @@ function onAttempt(payload: { correct: boolean; answer?: string }) {
|
||||
:lang="lang"
|
||||
@attempt="onAttempt"
|
||||
/>
|
||||
<ExerciseMatch
|
||||
v-else-if="exercise.type === 'match'"
|
||||
:exercise="exercise"
|
||||
@attempt="onAttempt"
|
||||
/>
|
||||
<ExerciseMatch v-else-if="exercise.type === 'match'" :exercise="exercise" @attempt="onAttempt" />
|
||||
<ExercisePredict
|
||||
v-else-if="exercise.type === 'predict'"
|
||||
:exercise="exercise"
|
||||
@@ -82,9 +72,5 @@ function onAttempt(payload: { correct: boolean; answer?: string }) {
|
||||
:lang="lang"
|
||||
@attempt="onAttempt"
|
||||
/>
|
||||
<ExerciseGroup
|
||||
v-else-if="exercise.type === 'group'"
|
||||
:exercise="exercise"
|
||||
@attempt="onAttempt"
|
||||
/>
|
||||
<ExerciseGroup v-else-if="exercise.type === 'group'" :exercise="exercise" @attempt="onAttempt" />
|
||||
</template>
|
||||
@@ -10,18 +10,14 @@ const props = defineProps<{
|
||||
|
||||
const stats = computed(() => {
|
||||
const rows = props.titles.map((t) => props.progress[t.id])
|
||||
const read = rows.filter(
|
||||
(p) => p && p.totalSeconds >= TUTORIAL_READ_SECONDS,
|
||||
).length
|
||||
const read = rows.filter((p) => p && p.totalSeconds >= TUTORIAL_READ_SECONDS).length
|
||||
const solved = rows.reduce((n, p) => n + (p?.exerciseSolved ?? 0), 0)
|
||||
const total = rows.reduce((n, p) => n + (p?.exerciseTotal ?? 0), 0)
|
||||
return { read, solved, total }
|
||||
})
|
||||
|
||||
const percent = computed(() =>
|
||||
props.titles.length
|
||||
? Math.round((stats.value.read / props.titles.length) * 100)
|
||||
: 0,
|
||||
props.titles.length ? Math.round((stats.value.read / props.titles.length) * 100) : 0,
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -36,9 +32,7 @@ const percent = computed(() =>
|
||||
/>
|
||||
<n-text depth="3" class="numbers">
|
||||
已读 {{ stats.read }}/{{ titles.length }} 课
|
||||
<template v-if="stats.total">
|
||||
· 练一练 {{ stats.solved }}/{{ stats.total }}
|
||||
</template>
|
||||
<template v-if="stats.total"> · 练一练 {{ stats.solved }}/{{ stats.total }} </template>
|
||||
</n-text>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -6,9 +6,7 @@ import type { Segment } from "../composables/useExerciseParse"
|
||||
defineProps<{ segments: Segment[]; lang?: string }>()
|
||||
|
||||
const isDark = useDark()
|
||||
const ExerciseWidget = defineAsyncComponent(
|
||||
() => import("./ExerciseWidget.vue"),
|
||||
)
|
||||
const ExerciseWidget = defineAsyncComponent(() => import("./ExerciseWidget.vue"))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -55,9 +55,7 @@ function hint(id: number) {
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
<n-text v-if="!traced" depth="3" class="login-tip">
|
||||
登录后可以记录学习进度
|
||||
</n-text>
|
||||
<n-text v-if="!traced" depth="3" class="login-tip"> 登录后可以记录学习进度 </n-text>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -8,9 +8,7 @@ const emit = defineEmits<{ go: [lesson: number] }>()
|
||||
|
||||
<template>
|
||||
<nav class="pager" :style="{ background: theme.bodyColor }">
|
||||
<n-button secondary :disabled="step <= 1" @click="emit('go', step - 1)">
|
||||
← 上一课
|
||||
</n-button>
|
||||
<n-button secondary :disabled="step <= 1" @click="emit('go', step - 1)"> ← 上一课 </n-button>
|
||||
<n-text depth="3">{{ step }} / {{ total }}</n-text>
|
||||
<n-button
|
||||
type="primary"
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import type { Exercise } from "utils/types"
|
||||
|
||||
export type Segment =
|
||||
| { type: "md"; content: string }
|
||||
| { type: "exercise"; exercise: Exercise }
|
||||
export type Segment = { type: "md"; content: string } | { type: "exercise"; exercise: Exercise }
|
||||
|
||||
export function parseExercises(
|
||||
content: string,
|
||||
exercises: Exercise[],
|
||||
): Segment[] {
|
||||
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
|
||||
|
||||
@@ -2,17 +2,9 @@
|
||||
<div class="learn-container">
|
||||
<template v-if="tutorial.id">
|
||||
<!-- 桌面端:目录 | 正文(居中限宽) | 可收起的示例代码 -->
|
||||
<div
|
||||
v-if="isDesktop"
|
||||
class="learn-layout"
|
||||
:class="{ 'with-code': codeOpen }"
|
||||
>
|
||||
<div v-if="isDesktop" class="learn-layout" :class="{ 'with-code': codeOpen }">
|
||||
<aside class="rail">
|
||||
<LearnSummary
|
||||
:titles="titles"
|
||||
:progress="progress"
|
||||
:traced="traced"
|
||||
/>
|
||||
<LearnSummary :titles="titles" :progress="progress" :traced="traced" />
|
||||
<LessonList
|
||||
:titles="titles"
|
||||
:step="step"
|
||||
@@ -28,12 +20,7 @@
|
||||
<n-text depth="3">第 {{ step }} / {{ titles.length }} 课</n-text>
|
||||
<n-flex align="center" justify="space-between" :wrap="false">
|
||||
<span />
|
||||
<n-button
|
||||
v-if="tutorial.code"
|
||||
size="small"
|
||||
secondary
|
||||
@click="codeOpen = !codeOpen"
|
||||
>
|
||||
<n-button v-if="tutorial.code" size="small" secondary @click="codeOpen = !codeOpen">
|
||||
{{ codeOpen ? "收起示例代码" : "展开示例代码" }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
@@ -44,11 +31,7 @@
|
||||
</main>
|
||||
|
||||
<aside v-if="tutorial.code && codeOpen" class="code-panel">
|
||||
<CodeEditor
|
||||
:language="editorLanguage"
|
||||
v-model="tutorial.code"
|
||||
height="100%"
|
||||
/>
|
||||
<CodeEditor :language="editorLanguage" v-model="tutorial.code" height="100%" />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -76,27 +59,13 @@
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<n-empty
|
||||
v-if="isEmpty"
|
||||
description="该教程还没有公开"
|
||||
style="margin-top: 80px"
|
||||
/>
|
||||
<n-empty v-if="isEmpty" description="该教程还没有公开" style="margin-top: 80px" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
Tutorial,
|
||||
Exercise,
|
||||
LANGUAGE,
|
||||
TutorialProgress,
|
||||
} from "utils/types"
|
||||
import {
|
||||
getTutorial,
|
||||
getTutorials,
|
||||
getExercises,
|
||||
getLearnProgress,
|
||||
} from "../api"
|
||||
import type { Tutorial, Exercise, LANGUAGE, TutorialProgress } from "utils/types"
|
||||
import { getTutorial, getTutorials, getExercises, getLearnProgress } from "../api"
|
||||
import { parseExercises } from "./composables/useExerciseParse"
|
||||
import { useLearnTrace } from "./composables/useLearnTrace"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
@@ -106,9 +75,7 @@ import LessonList from "./components/LessonList.vue"
|
||||
import LearnSummary from "./components/LearnSummary.vue"
|
||||
import LessonBody from "./components/LessonBody.vue"
|
||||
import PagerBar from "./components/PagerBar.vue"
|
||||
const CodeEditor = defineAsyncComponent(
|
||||
() => import("shared/components/CodeEditor.vue"),
|
||||
)
|
||||
const CodeEditor = defineAsyncComponent(() => import("shared/components/CodeEditor.vue"))
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -125,9 +92,7 @@ const step = computed(() => {
|
||||
return parseInt(value)
|
||||
})
|
||||
|
||||
const type = computed<"python" | "c">(() =>
|
||||
route.params.type === "c" ? "c" : "python",
|
||||
)
|
||||
const type = computed<"python" | "c">(() => (route.params.type === "c" ? "c" : "python"))
|
||||
|
||||
const tutorial = ref<Partial<Tutorial>>({
|
||||
id: 0,
|
||||
@@ -136,9 +101,7 @@ const tutorial = ref<Partial<Tutorial>>({
|
||||
code: "",
|
||||
})
|
||||
|
||||
const editorLanguage = computed<LANGUAGE>(() =>
|
||||
tutorial.value.type === "c" ? "C" : "Python",
|
||||
)
|
||||
const editorLanguage = computed<LANGUAGE>(() => (tutorial.value.type === "c" ? "C" : "Python"))
|
||||
const titles = ref<{ id: number; title: string }[]>([])
|
||||
const progress = ref<Record<number, TutorialProgress>>({})
|
||||
const exercises = ref<Exercise[]>([])
|
||||
@@ -147,9 +110,7 @@ const activeTab = ref("content")
|
||||
const codeOpen = useStorage("oj2:learn-code-open", true)
|
||||
const isEmpty = ref(false)
|
||||
|
||||
const segments = computed(() =>
|
||||
parseExercises(tutorial.value.content ?? "", exercises.value),
|
||||
)
|
||||
const segments = computed(() => parseExercises(tutorial.value.content ?? "", exercises.value))
|
||||
|
||||
// 留痕的计时器。tutorial.id 变了才算换课 —— 用 step 会在内容还没加载好时就上报
|
||||
useLearnTrace(
|
||||
@@ -159,9 +120,7 @@ useLearnTrace(
|
||||
|
||||
function goToLesson(lessonNumber: number) {
|
||||
activeTab.value = "content"
|
||||
router.push(
|
||||
`/learn/${type.value}/${lessonNumber.toString().padStart(2, "0")}`,
|
||||
)
|
||||
router.push(`/learn/${type.value}/${lessonNumber.toString().padStart(2, "0")}`)
|
||||
}
|
||||
/**
|
||||
* 拉自己的自学留痕,给目录打勾。失败就当没有 —— 目录少几个勾不影响上课,
|
||||
@@ -174,9 +133,7 @@ async function loadProgress() {
|
||||
}
|
||||
try {
|
||||
const rows = await getLearnProgress(type.value)
|
||||
progress.value = Object.fromEntries(
|
||||
rows.map((row) => [row.tutorialId, row]),
|
||||
)
|
||||
progress.value = Object.fromEntries(rows.map((row) => [row.tutorialId, row]))
|
||||
} catch {
|
||||
progress.value = {}
|
||||
}
|
||||
@@ -188,10 +145,7 @@ async function init() {
|
||||
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),
|
||||
])
|
||||
const [res2, exs] = await Promise.allSettled([getTutorial(id), getExercises(id)])
|
||||
if (res2.status === "fulfilled") tutorial.value = res2.value
|
||||
exercises.value = exs.status === "fulfilled" ? exs.value : []
|
||||
learnStep.value[type.value] = step.value
|
||||
|
||||
@@ -20,8 +20,7 @@ const { isDesktop } = useBreakpoints()
|
||||
|
||||
const contestID = route.params.contestID || null
|
||||
const storageKey = computed(
|
||||
() =>
|
||||
`problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
|
||||
() => `problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
|
||||
)
|
||||
|
||||
const editorHeight = computed(() =>
|
||||
@@ -35,10 +34,7 @@ onMounted(() => {
|
||||
problem.value!.template[codeStore.code.language] ||
|
||||
SOURCES[codeStore.code.language],
|
||||
)
|
||||
beginEditTrace(
|
||||
`problem_${problem.value!._id}_contest_${contestID}`,
|
||||
codeStore.code.value.length,
|
||||
)
|
||||
beginEditTrace(`problem_${problem.value!._id}_contest_${contestID}`, codeStore.code.value.length)
|
||||
})
|
||||
|
||||
const changeCode = (v: string) => {
|
||||
@@ -50,18 +46,14 @@ const changeLanguage = (v: LANGUAGE) => {
|
||||
codeStore.setCode(
|
||||
savedCode && storageKey.value.split("_").pop() === v
|
||||
? savedCode
|
||||
: problem.value!.template[codeStore.code.language] ||
|
||||
SOURCES[codeStore.code.language],
|
||||
: problem.value!.template[codeStore.code.language] || SOURCES[codeStore.code.language],
|
||||
)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-flex vertical>
|
||||
<EditorToolbar
|
||||
:storage-key="storageKey"
|
||||
@change-language="changeLanguage"
|
||||
/>
|
||||
<EditorToolbar :storage-key="storageKey" @change-language="changeLanguage" />
|
||||
<CodeEditor
|
||||
v-model:value="codeStore.code.value"
|
||||
:language="codeStore.code.language"
|
||||
|
||||
@@ -20,8 +20,7 @@ const { input, output } = storeToRefs(codeStore)
|
||||
const { problem } = storeToRefs(problemStore)
|
||||
|
||||
const storageKey = computed(
|
||||
() =>
|
||||
`problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
|
||||
() => `problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
@@ -29,8 +28,7 @@ onMounted(() => {
|
||||
codeStore.setCode(storage.get(storageKey.value))
|
||||
} else {
|
||||
codeStore.setCode(
|
||||
problem.value!.template[codeStore.code.language] ||
|
||||
SOURCES[codeStore.code.language],
|
||||
problem.value!.template[codeStore.code.language] || SOURCES[codeStore.code.language],
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -40,15 +38,11 @@ function changeCode(v: string) {
|
||||
}
|
||||
|
||||
function changeLanguage(v: string) {
|
||||
if (
|
||||
storage.get(storageKey.value) &&
|
||||
storageKey.value.split("_").pop() === v
|
||||
) {
|
||||
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],
|
||||
problem.value!.template[codeStore.code.language] || SOURCES[codeStore.code.language],
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -60,8 +54,7 @@ const copy = async () => {
|
||||
|
||||
const reset = () => {
|
||||
codeStore.setCode(
|
||||
problem.value!.template[codeStore.code.language] ||
|
||||
SOURCES[codeStore.code.language],
|
||||
problem.value!.template[codeStore.code.language] || SOURCES[codeStore.code.language],
|
||||
)
|
||||
storage.remove(storageKey.value)
|
||||
message.success("代码重置成功")
|
||||
@@ -72,12 +65,10 @@ const runCode = async () => {
|
||||
output.value = res.output
|
||||
}
|
||||
|
||||
const languageOptions: DropdownOption[] = problem.value!.languages.map(
|
||||
(it) => ({
|
||||
label: () => LANGUAGE_SHOW_VALUE[it],
|
||||
value: it,
|
||||
}),
|
||||
)
|
||||
const languageOptions: DropdownOption[] = problem.value!.languages.map((it) => ({
|
||||
label: () => LANGUAGE_SHOW_VALUE[it],
|
||||
value: it,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -94,9 +85,7 @@ const languageOptions: DropdownOption[] = problem.value!.languages.map(
|
||||
/>
|
||||
<n-button @click="copy">复制代码</n-button>
|
||||
<n-button @click="reset">重置代码</n-button>
|
||||
<n-button type="primary" secondary @click="runCode">
|
||||
运行代码
|
||||
</n-button>
|
||||
<n-button type="primary" secondary @click="runCode"> 运行代码 </n-button>
|
||||
</n-flex>
|
||||
<CodeEditor
|
||||
v-model:value="codeStore.code.value"
|
||||
@@ -106,12 +95,7 @@ const languageOptions: DropdownOption[] = problem.value!.languages.map(
|
||||
</n-flex>
|
||||
</template>
|
||||
<template #2>
|
||||
<n-split
|
||||
direction="vertical"
|
||||
:default-size="1 / 3"
|
||||
:min="1 / 5"
|
||||
:max="3 / 5"
|
||||
>
|
||||
<n-split direction="vertical" :default-size="1 / 3" :min="1 / 5" :max="3 / 5">
|
||||
<template #1>
|
||||
<div class="title">输入框</div>
|
||||
<n-input
|
||||
|
||||
@@ -19,14 +19,10 @@ import { Icon } from "@iconify/vue"
|
||||
import { NFlex } from "naive-ui"
|
||||
import SubmitCode from "./SubmitCode.vue"
|
||||
|
||||
const SubmitFlowchart = defineAsyncComponent(
|
||||
() => import("./SubmitFlowchart.vue"),
|
||||
)
|
||||
const SubmitFlowchart = defineAsyncComponent(() => import("./SubmitFlowchart.vue"))
|
||||
// 只有老师看得见(下面的弹框挂了 isTeacherOrAbove),静态 import 的话每个学生
|
||||
// 打开题目都要白拉一份 chart.js(~68KB gzip)
|
||||
const StatisticsPanel = defineAsyncComponent(
|
||||
() => import("shared/components/StatisticsPanel.vue"),
|
||||
)
|
||||
const StatisticsPanel = defineAsyncComponent(() => import("shared/components/StatisticsPanel.vue"))
|
||||
|
||||
interface Props {
|
||||
storageKey: string
|
||||
@@ -74,13 +70,9 @@ const showHelpButton = computed(
|
||||
* 不像弹框那样按一下 Esc 就把协作关掉了。
|
||||
*/
|
||||
const collabHere = computed(
|
||||
() =>
|
||||
collabStore.room !== null &&
|
||||
collabStore.room.problemId === problem.value?._id,
|
||||
)
|
||||
const showCollabBar = computed(
|
||||
() => collabHere.value && userStore.isTeacherOrAbove,
|
||||
() => collabStore.room !== null && collabStore.room.problemId === problem.value?._id,
|
||||
)
|
||||
const showCollabBar = computed(() => collabHere.value && userStore.isTeacherOrAbove)
|
||||
|
||||
/**
|
||||
* 状态全塞进按钮本身。原来旁边还挂一个 n-tag 说明排队情况,一行工具栏
|
||||
@@ -212,8 +204,7 @@ const copy = async () => {
|
||||
|
||||
const reset = () => {
|
||||
codeStore.setCode(
|
||||
problem.value!.template[codeStore.code.language] ||
|
||||
SOURCES[codeStore.code.language],
|
||||
problem.value!.template[codeStore.code.language] || SOURCES[codeStore.code.language],
|
||||
)
|
||||
storage.remove(storageKey)
|
||||
message.success("代码重置成功")
|
||||
@@ -280,11 +271,7 @@ onMounted(() => {
|
||||
|
||||
<SubmitCode v-else />
|
||||
|
||||
<n-button
|
||||
v-if="isDesktop && showGoSubmissionButton"
|
||||
:size="buttonSize"
|
||||
@click="goSubmissions"
|
||||
>
|
||||
<n-button v-if="isDesktop && showGoSubmissionButton" :size="buttonSize" @click="goSubmissions">
|
||||
提交信息
|
||||
</n-button>
|
||||
|
||||
@@ -313,11 +300,7 @@ onMounted(() => {
|
||||
</n-tag>
|
||||
<!-- 显式的「结束」:求助记录一并清掉。跳走页面发的是 leave("left"),
|
||||
那边只是退回排队 —— 见 store 里 leave 的注释 -->
|
||||
<n-button
|
||||
:size="buttonSize"
|
||||
type="primary"
|
||||
@click="collabStore.leave('done')"
|
||||
>
|
||||
<n-button :size="buttonSize" type="primary" @click="collabStore.leave('done')">
|
||||
结束协作
|
||||
</n-button>
|
||||
</template>
|
||||
|
||||
@@ -22,11 +22,7 @@ watch(
|
||||
<n-alert v-if="renderError" type="error" title="渲染失败" size="small">
|
||||
{{ renderError }}
|
||||
</n-alert>
|
||||
<div
|
||||
v-show="!renderError"
|
||||
ref="mermaidContainer"
|
||||
class="flowchart-container"
|
||||
></div>
|
||||
<div v-show="!renderError" ref="mermaidContainer" class="flowchart-container"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -108,9 +108,7 @@ const KIND_TAG_TYPE = {
|
||||
count: "info",
|
||||
} as const
|
||||
|
||||
const astRequirements = computed(() =>
|
||||
Object.entries(problem.value?.astRequirements ?? {}),
|
||||
)
|
||||
const astRequirements = computed(() => Object.entries(problem.value?.astRequirements ?? {}))
|
||||
|
||||
async function test(sample: Sample, index: number) {
|
||||
samples.value = samples.value.map((sample) => {
|
||||
@@ -122,10 +120,7 @@ async function test(sample: Sample, index: number) {
|
||||
const res = await createTestSubmission(codeStore.code, sample.input)
|
||||
samples.value = samples.value.map((sample) => {
|
||||
if (sample.id === index) {
|
||||
const status =
|
||||
res.status === 3 && res.output.trim() === sample.output
|
||||
? "passed"
|
||||
: "failed"
|
||||
const status = res.status === 3 && res.output.trim() === sample.output ? "passed" : "failed"
|
||||
return {
|
||||
...sample,
|
||||
msg: res.output,
|
||||
@@ -267,9 +262,7 @@ function type(status: ProblemStatus) {
|
||||
:total-rows="sqlExpectedQuery.total_rows"
|
||||
:truncated="sqlExpectedQuery.truncated"
|
||||
/>
|
||||
<p v-if="!problem.sqlConfig?.order_sensitive" class="sqlNote">
|
||||
结果顺序不限
|
||||
</p>
|
||||
<p v-if="!problem.sqlConfig?.order_sensitive" class="sqlNote">结果顺序不限</p>
|
||||
</template>
|
||||
<div v-for="t in sqlChangedTables" :key="t.name">
|
||||
<p class="sqlTableName">
|
||||
@@ -313,9 +306,7 @@ function type(status: ProblemStatus) {
|
||||
</p>
|
||||
<n-list bordered style="margin-bottom: 8px">
|
||||
<n-list-item v-for="(rule, i) in rules" :key="i">
|
||||
<n-tag :type="KIND_TAG_TYPE[rule.kind]">{{
|
||||
rule.description
|
||||
}}</n-tag>
|
||||
<n-tag :type="KIND_TAG_TYPE[rule.kind]">{{ rule.description }}</n-tag>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</div>
|
||||
@@ -330,19 +321,11 @@ function type(status: ProblemStatus) {
|
||||
例子 {{ index + 1 }}
|
||||
</n-flex>
|
||||
</p>
|
||||
<n-button
|
||||
size="small"
|
||||
:type="type(sample.status)"
|
||||
@click="test(sample, index)"
|
||||
>
|
||||
<n-button size="small" :type="type(sample.status)" @click="test(sample, index)">
|
||||
{{ label(sample.status, sample.loading) }}
|
||||
</n-button>
|
||||
</n-flex>
|
||||
<n-descriptions
|
||||
bordered
|
||||
:column="2"
|
||||
label-style="width: 50%; min-width: 100px"
|
||||
>
|
||||
<n-descriptions bordered :column="2" label-style="width: 50%; min-width: 100px">
|
||||
<n-descriptions-item>
|
||||
<template #label>
|
||||
<n-flex>
|
||||
|
||||
@@ -31,9 +31,7 @@ const { problem } = storeToRefs(problemStore)
|
||||
* 协作跟着结束」:页面上没有弹框可关,结束协作只有工具栏那个按钮和离开这一页两条路。
|
||||
*/
|
||||
const collabHere = computed(
|
||||
() =>
|
||||
collabStore.room !== null &&
|
||||
collabStore.room.problemId === problem.value?._id,
|
||||
() => collabStore.room !== null && collabStore.room.problemId === problem.value?._id,
|
||||
)
|
||||
|
||||
/** 协作中的教师:编辑器里是学生的代码,不是他自己的 */
|
||||
@@ -66,8 +64,7 @@ watch(
|
||||
const wasHere = previous?.[0] ?? false
|
||||
if (here && language) {
|
||||
if (!wasHere) teacherLanguageBefore = codeStore.code.language
|
||||
if (codeStore.code.language !== language)
|
||||
codeStore.code.language = language
|
||||
if (codeStore.code.language !== language) codeStore.code.language = language
|
||||
return
|
||||
}
|
||||
if (!here && wasHere) {
|
||||
@@ -84,8 +81,7 @@ const { isDesktop } = useBreakpoints()
|
||||
|
||||
const contestID = route.params.contestID || null
|
||||
const storageKey = computed(
|
||||
() =>
|
||||
`problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
|
||||
() => `problem_${problem.value!._id}_contest_${contestID}_lang_${codeStore.code.language}`,
|
||||
)
|
||||
|
||||
const editorHeight = computed(() =>
|
||||
@@ -100,10 +96,7 @@ function loadCode() {
|
||||
SOURCES[codeStore.code.language],
|
||||
)
|
||||
// 换了题才重新计数,同一道题重复 loadCode(协作结束读回草稿)是接着记
|
||||
beginEditTrace(
|
||||
`problem_${problem.value!._id}_contest_${contestID}`,
|
||||
codeStore.code.value.length,
|
||||
)
|
||||
beginEditTrace(`problem_${problem.value!._id}_contest_${contestID}`, codeStore.code.value.length)
|
||||
}
|
||||
|
||||
onMounted(loadCode)
|
||||
@@ -128,8 +121,7 @@ const changeLanguage = (v: LANGUAGE) => {
|
||||
codeStore.setCode(
|
||||
savedCode && storageKey.value.split("_").pop() === v
|
||||
? savedCode
|
||||
: problem.value!.template[codeStore.code.language] ||
|
||||
SOURCES[codeStore.code.language],
|
||||
: problem.value!.template[codeStore.code.language] || SOURCES[codeStore.code.language],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -139,10 +131,7 @@ provide("flowchartEditorRef", flowchartEditorRef)
|
||||
|
||||
<template>
|
||||
<n-flex vertical>
|
||||
<EditorToolbar
|
||||
:storage-key="storageKey"
|
||||
@change-language="changeLanguage"
|
||||
/>
|
||||
<EditorToolbar :storage-key="storageKey" @change-language="changeLanguage" />
|
||||
<!--
|
||||
协作中教师这边不会落到流程图分支:上面那个 watch 已经把他的语言换成了学生的,
|
||||
而求助入口本身就排掉了流程图(EditorToolbar.vue 的 showHelpButton、服务端的
|
||||
@@ -150,10 +139,7 @@ provide("flowchartEditorRef", flowchartEditorRef)
|
||||
学生自己切到流程图就是不写代码了,编辑器卸载、协作正常结束(SyncCodeEditor
|
||||
的 detach),这是原来就有的语义。
|
||||
-->
|
||||
<FlowchartEditor
|
||||
v-if="codeStore.code.language === 'Flowchart'"
|
||||
ref="flowchartEditorRef"
|
||||
/>
|
||||
<FlowchartEditor v-if="codeStore.code.language === 'Flowchart'" ref="flowchartEditorRef" />
|
||||
<SyncCodeEditor
|
||||
v-else
|
||||
v-model:value="codeStore.code.value"
|
||||
|
||||
@@ -9,10 +9,7 @@ const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
|
||||
const { renderError, renderFlowchart } = useMermaid()
|
||||
|
||||
const renderProblemFlowchart = async () => {
|
||||
await renderFlowchart(
|
||||
mermaidContainer.value,
|
||||
problem.value?.mermaidCode ?? "",
|
||||
)
|
||||
await renderFlowchart(mermaidContainer.value, problem.value?.mermaidCode ?? "")
|
||||
}
|
||||
|
||||
onMounted(renderProblemFlowchart)
|
||||
|
||||
@@ -6,14 +6,7 @@ import { DIFFICULTY, JUDGE_STATUS } from "utils/constants"
|
||||
import type { SUBMISSION_RESULT } from "utils/types"
|
||||
import { getACRateNumber, getTagColor, parseTime } from "utils/functions"
|
||||
import { Pie } from "vue-chartjs"
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
ArcElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Colors,
|
||||
} from "chart.js"
|
||||
import { Chart as ChartJS, ArcElement, Title, Tooltip, Legend, Colors } from "chart.js"
|
||||
import { getProblemBeatRate } from "oj/api"
|
||||
import { getProblemYearlyAC, type YearlyACData } from "oj/api"
|
||||
import ProblemYearlyChart from "./ProblemYearlyChart.vue"
|
||||
@@ -106,12 +99,7 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-descriptions
|
||||
bordered
|
||||
label-placement="left"
|
||||
:column="isDesktop ? 3 : 1"
|
||||
v-if="problem"
|
||||
>
|
||||
<n-descriptions bordered label-placement="left" :column="isDesktop ? 3 : 1" v-if="problem">
|
||||
<n-descriptions-item label="编号">
|
||||
{{ problem._id }}
|
||||
</n-descriptions-item>
|
||||
@@ -141,10 +129,7 @@ onMounted(() => {
|
||||
<n-flex vertical align="center">
|
||||
<Icon v-if="isDesktop" :icon="item.icon" width="40" />
|
||||
<n-h2 class="number">
|
||||
<n-number-animation
|
||||
:to="item.title"
|
||||
:precision="item.int ? 0 : 2"
|
||||
/>
|
||||
<n-number-animation :to="item.title" :precision="item.int ? 0 : 2" />
|
||||
<span v-if="item.suffix">{{ item.suffix }}</span>
|
||||
</n-h2>
|
||||
<n-h4 class="number-label">{{ item.content }}</n-h4>
|
||||
|
||||
@@ -9,20 +9,8 @@ defineProps<{
|
||||
<template>
|
||||
<n-flex align="center">
|
||||
<span>{{ problem.title }}</span>
|
||||
<Icon
|
||||
v-if="problem.allowFlowchart"
|
||||
width="18"
|
||||
icon="vscode-icons:file-type-drawio"
|
||||
/>
|
||||
<Icon
|
||||
v-else-if="problem.showFlowchart"
|
||||
width="18"
|
||||
icon="vscode-icons:file-type-graphql"
|
||||
/>
|
||||
<Icon
|
||||
v-if="problem.hasAstRules"
|
||||
width="18"
|
||||
icon="vscode-icons:file-type-light-todo"
|
||||
/>
|
||||
<Icon v-if="problem.allowFlowchart" width="18" icon="vscode-icons:file-type-drawio" />
|
||||
<Icon v-else-if="problem.showFlowchart" width="18" icon="vscode-icons:file-type-graphql" />
|
||||
<Icon v-if="problem.hasAstRules" width="18" icon="vscode-icons:file-type-light-todo" />
|
||||
</n-flex>
|
||||
</template>
|
||||
@@ -54,15 +54,12 @@ function getWheelItemStyle(index: number): CSSProperties {
|
||||
const dividerAngle = index * sliceAngle - sliceAngle / 2
|
||||
const position = pointOnCircle(centerAngle, wheelGeometry.contentRadius)
|
||||
const push = pointOnCircle(centerAngle, wheelGeometry.pushRadius)
|
||||
const arcPoints = Array.from(
|
||||
{ length: wheelGeometry.arcPointCount },
|
||||
(_, pointIndex) => {
|
||||
const progress = pointIndex / (wheelGeometry.arcPointCount - 1)
|
||||
const angle = startAngle + (endAngle - startAngle) * progress
|
||||
const point = pointOnCircle(angle, wheelGeometry.outerRadius)
|
||||
return `${point.x.toFixed(3)}% ${point.y.toFixed(3)}%`
|
||||
},
|
||||
)
|
||||
const arcPoints = Array.from({ length: wheelGeometry.arcPointCount }, (_, pointIndex) => {
|
||||
const progress = pointIndex / (wheelGeometry.arcPointCount - 1)
|
||||
const angle = startAngle + (endAngle - startAngle) * progress
|
||||
const point = pointOnCircle(angle, wheelGeometry.outerRadius)
|
||||
return `${point.x.toFixed(3)}% ${point.y.toFixed(3)}%`
|
||||
})
|
||||
|
||||
return {
|
||||
"--segment-path": `polygon(50% 50%, ${arcPoints.join(", ")})`,
|
||||
@@ -192,9 +189,7 @@ function getPointerIndex(event: PointerEvent | MouseEvent) {
|
||||
|
||||
const angle = (Math.atan2(y, x) * 180) / Math.PI
|
||||
const rawIndex = Math.round((angle - wheelGeometry.startAngle) / sliceAngle)
|
||||
return (
|
||||
((rawIndex % wheelItems.length) + wheelItems.length) % wheelItems.length
|
||||
)
|
||||
return ((rawIndex % wheelItems.length) + wheelItems.length) % wheelItems.length
|
||||
}
|
||||
|
||||
function preview(index: number, fromKeyboard = false) {
|
||||
@@ -225,14 +220,7 @@ function onWheelClick(event: MouseEvent) {
|
||||
}
|
||||
|
||||
async function pick(key: ReactionKey) {
|
||||
if (
|
||||
!problem.value ||
|
||||
!solved.value ||
|
||||
locked.value ||
|
||||
loading.value ||
|
||||
submitting.value
|
||||
)
|
||||
return
|
||||
if (!problem.value || !solved.value || locked.value || loading.value || submitting.value) return
|
||||
|
||||
activeIndex.value = null
|
||||
keyboardActive.value = false
|
||||
@@ -345,18 +333,10 @@ watch(
|
||||
>
|
||||
<span class="option-content">
|
||||
<span class="option-icon" aria-hidden="true">
|
||||
<Icon
|
||||
:icon="
|
||||
submitting === item.key
|
||||
? 'svg-spinners:180-ring-with-bg'
|
||||
: item.icon
|
||||
"
|
||||
/>
|
||||
<Icon :icon="submitting === item.key ? 'svg-spinners:180-ring-with-bg' : item.icon" />
|
||||
</span>
|
||||
<span class="option-label">{{ item.label }}</span>
|
||||
<span v-if="counts" class="option-count">
|
||||
{{ counts[item.key] }} 人
|
||||
</span>
|
||||
<span v-if="counts" class="option-count"> {{ counts[item.key] }} 人 </span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -420,22 +400,13 @@ watch(
|
||||
|
||||
.segment-face:is(.is-active, .is-submitting) {
|
||||
z-index: 2;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--reaction-accent) 10%,
|
||||
var(--reaction-card)
|
||||
);
|
||||
transform: translate(calc(var(--push-x) * 0.7), calc(var(--push-y) * 0.7))
|
||||
scale(1.045);
|
||||
background: color-mix(in srgb, var(--reaction-accent) 10%, var(--reaction-card));
|
||||
transform: translate(calc(var(--push-x) * 0.7), calc(var(--push-y) * 0.7)) scale(1.045);
|
||||
}
|
||||
|
||||
.segment-face.is-selected {
|
||||
z-index: 3;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--reaction-accent) 18%,
|
||||
var(--reaction-card)
|
||||
);
|
||||
background: color-mix(in srgb, var(--reaction-accent) 18%, var(--reaction-card));
|
||||
transform: translate(var(--push-x), var(--push-y)) scale(1.075);
|
||||
}
|
||||
|
||||
@@ -512,14 +483,11 @@ watch(
|
||||
}
|
||||
|
||||
.reaction-option:is(.is-active, .is-submitting) .option-content {
|
||||
transform: translate(calc(var(--push-x) * 0.8), calc(var(--push-y) * 0.8))
|
||||
scale(1.18);
|
||||
transform: translate(calc(var(--push-x) * 0.8), calc(var(--push-y) * 0.8)) scale(1.18);
|
||||
}
|
||||
|
||||
.reaction-option:is(.is-active, .is-submitting):active:not(:disabled)
|
||||
.option-content {
|
||||
transform: translate(calc(var(--push-x) * 0.8), calc(var(--push-y) * 0.8))
|
||||
scale(1.1);
|
||||
.reaction-option:is(.is-active, .is-submitting):active:not(:disabled) .option-content {
|
||||
transform: translate(calc(var(--push-x) * 0.8), calc(var(--push-y) * 0.8)) scale(1.1);
|
||||
}
|
||||
|
||||
.reaction-option.is-selected .option-content {
|
||||
@@ -623,13 +591,11 @@ watch(
|
||||
}
|
||||
|
||||
.segment-face:is(.is-active, .is-submitting) {
|
||||
transform: translate(calc(var(--push-x) * 0.5), calc(var(--push-y) * 0.5))
|
||||
scale(1.03);
|
||||
transform: translate(calc(var(--push-x) * 0.5), calc(var(--push-y) * 0.5)) scale(1.03);
|
||||
}
|
||||
|
||||
.segment-face.is-selected {
|
||||
transform: translate(calc(var(--push-x) * 0.75), calc(var(--push-y) * 0.75))
|
||||
scale(1.05);
|
||||
transform: translate(calc(var(--push-x) * 0.75), calc(var(--push-y) * 0.75)) scale(1.05);
|
||||
}
|
||||
|
||||
.option-content {
|
||||
@@ -637,19 +603,15 @@ watch(
|
||||
}
|
||||
|
||||
.reaction-option:is(.is-active, .is-submitting) .option-content {
|
||||
transform: translate(calc(var(--push-x) * 0.55), calc(var(--push-y) * 0.55))
|
||||
scale(1.12);
|
||||
transform: translate(calc(var(--push-x) * 0.55), calc(var(--push-y) * 0.55)) scale(1.12);
|
||||
}
|
||||
|
||||
.reaction-option:is(.is-active, .is-submitting):active:not(:disabled)
|
||||
.option-content {
|
||||
transform: translate(calc(var(--push-x) * 0.55), calc(var(--push-y) * 0.55))
|
||||
scale(1.06);
|
||||
.reaction-option:is(.is-active, .is-submitting):active:not(:disabled) .option-content {
|
||||
transform: translate(calc(var(--push-x) * 0.55), calc(var(--push-y) * 0.55)) scale(1.06);
|
||||
}
|
||||
|
||||
.reaction-option.is-selected .option-content {
|
||||
transform: translate(calc(var(--push-x) * 0.75), calc(var(--push-y) * 0.75))
|
||||
scale(1.17);
|
||||
transform: translate(calc(var(--push-x) * 0.75), calc(var(--push-y) * 0.75)) scale(1.17);
|
||||
}
|
||||
|
||||
.option-icon {
|
||||
|
||||
@@ -48,10 +48,7 @@ const columns: DataTableColumn<SubmissionListItem>[] = [
|
||||
NTooltip,
|
||||
{},
|
||||
{
|
||||
trigger: () =>
|
||||
h(NButton, { text: true }, () =>
|
||||
h(Icon, { icon: "catppuccin:lock" }),
|
||||
),
|
||||
trigger: () => h(NButton, { text: true }, () => h(Icon, { icon: "catppuccin:lock" })),
|
||||
default: () =>
|
||||
"这道题在你已经加入的题单里,加入之前的提交先藏起来了。在题单中做出此题即可解锁;题单过了截止时间也会解锁。",
|
||||
},
|
||||
@@ -190,12 +187,7 @@ watch(query, listSubmissions)
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-alert>
|
||||
<n-alert
|
||||
class="tip"
|
||||
type="error"
|
||||
:show-icon="false"
|
||||
v-if="rank === -1 && class_ac_count > 0"
|
||||
>
|
||||
<n-alert class="tip" type="error" :show-icon="false" v-if="rank === -1 && class_ac_count > 0">
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
<span>
|
||||
@@ -252,12 +244,7 @@ watch(query, listSubmissions)
|
||||
</n-flex>
|
||||
</template>
|
||||
</n-alert>
|
||||
<n-alert
|
||||
class="tip"
|
||||
type="error"
|
||||
:show-icon="false"
|
||||
v-if="rank === -1 && all_ac_count > 0"
|
||||
>
|
||||
<n-alert class="tip" type="error" :show-icon="false" v-if="rank === -1 && all_ac_count > 0">
|
||||
<template #header>
|
||||
<n-flex align="center">
|
||||
<span>
|
||||
@@ -288,12 +275,7 @@ watch(query, listSubmissions)
|
||||
|
||||
<template v-if="userStore.showSubmissions && userStore.isAuthed">
|
||||
<!-- 错误分布统计 -->
|
||||
<n-flex
|
||||
v-if="statusDistribution.length"
|
||||
class="tip"
|
||||
align="center"
|
||||
:wrap="true"
|
||||
>
|
||||
<n-flex v-if="statusDistribution.length" class="tip" align="center" :wrap="true">
|
||||
<span style="font-weight: bold; font-size: 13px">我的提交统计:</span>
|
||||
<n-tag
|
||||
v-for="item in statusDistribution"
|
||||
@@ -306,17 +288,8 @@ watch(query, listSubmissions)
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
|
||||
<n-data-table
|
||||
v-if="submissions.length > 0"
|
||||
striped
|
||||
:columns="columns"
|
||||
:data="submissions"
|
||||
/>
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:limit="query.limit"
|
||||
v-model:page="query.page"
|
||||
/>
|
||||
<n-data-table v-if="submissions.length > 0" striped :columns="columns" :data="submissions" />
|
||||
<Pagination :total="total" v-model:limit="query.limit" v-model:page="query.page" />
|
||||
</template>
|
||||
|
||||
<!-- 代码详情弹框 -->
|
||||
|
||||
@@ -18,15 +18,7 @@ import {
|
||||
} from "chart.js"
|
||||
import type { YearlyACData } from "oj/api"
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
Filler,
|
||||
LinearScale,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
)
|
||||
ChartJS.register(CategoryScale, Filler, LinearScale, LineElement, PointElement, Title, Tooltip)
|
||||
|
||||
const props = defineProps<{ data: YearlyACData[] }>()
|
||||
|
||||
|
||||
@@ -30,9 +30,7 @@ defineProps<{
|
||||
</tr>
|
||||
</tbody>
|
||||
</n-table>
|
||||
<p v-if="truncated" class="truncNote">
|
||||
共 {{ totalRows }} 行,仅展示前 {{ rows.length }} 行
|
||||
</p>
|
||||
<p v-if="truncated" class="truncNote">共 {{ totalRows }} 行,仅展示前 {{ rows.length }} 行</p>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -52,9 +52,7 @@ const hintFeedbackSending = ref(false)
|
||||
const TYPE_STEP = 3
|
||||
const TYPE_INTERVAL = 24
|
||||
let typingTimer: ReturnType<typeof setInterval> | null = null
|
||||
const hintTyping = computed(
|
||||
() => hintContent.value.length < hintTarget.value.length,
|
||||
)
|
||||
const hintTyping = computed(() => hintContent.value.length < hintTarget.value.length)
|
||||
|
||||
function stopTyping() {
|
||||
if (typingTimer === null) return
|
||||
@@ -69,10 +67,7 @@ function startTyping() {
|
||||
stopTyping()
|
||||
return
|
||||
}
|
||||
hintContent.value = hintTarget.value.slice(
|
||||
0,
|
||||
hintContent.value.length + TYPE_STEP,
|
||||
)
|
||||
hintContent.value = hintTarget.value.slice(0, hintContent.value.length + TYPE_STEP)
|
||||
}, TYPE_INTERVAL)
|
||||
}
|
||||
|
||||
@@ -99,17 +94,13 @@ const msg = computed(() => {
|
||||
// 编译错误或运行时错误时给出提示;
|
||||
// SQL 题的运行错误多半是"查询题里写了增删改"这类被判题拒绝的语句,err_info 已说明原因,不套这句
|
||||
if (
|
||||
(result === SubmissionStatus.compile_error ||
|
||||
result === SubmissionStatus.runtime_error) &&
|
||||
(result === SubmissionStatus.compile_error || result === SubmissionStatus.runtime_error) &&
|
||||
props.submission.language !== "SQL"
|
||||
) {
|
||||
msg += "请仔细检查,看看代码的格式是不是写错了!\n\n"
|
||||
}
|
||||
|
||||
if (
|
||||
result !== SubmissionStatus.ast_check_failed &&
|
||||
props.submission.statisticInfo?.err_info
|
||||
) {
|
||||
if (result !== SubmissionStatus.ast_check_failed && props.submission.statisticInfo?.err_info) {
|
||||
msg += props.submission.statisticInfo.err_info
|
||||
}
|
||||
|
||||
@@ -258,9 +249,7 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
|
||||
</n-alert>
|
||||
<n-flex
|
||||
vertical
|
||||
v-if="
|
||||
msg || infoTable.length || submission.statisticInfo?.ast_results?.length
|
||||
"
|
||||
v-if="msg || infoTable.length || submission.statisticInfo?.ast_results?.length"
|
||||
>
|
||||
<n-card v-if="submission.statisticInfo?.ast_results?.length" embedded>
|
||||
<n-flex vertical :size="8">
|
||||
@@ -270,9 +259,7 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
|
||||
align="center"
|
||||
:size="6"
|
||||
>
|
||||
<n-icon
|
||||
:color="rule.passed ? theme.successColor : theme.errorColor"
|
||||
>
|
||||
<n-icon :color="rule.passed ? theme.successColor : theme.errorColor">
|
||||
<Icon :icon="rule.passed ? 'ph:check-bold' : 'ph:x-bold'" />
|
||||
</n-icon>
|
||||
<span>{{ rule.description }}</span>
|
||||
@@ -287,23 +274,13 @@ const columns: DataTableColumn<JudgeCaseResult>[] = [
|
||||
</n-flex>
|
||||
</n-card>
|
||||
<n-card v-if="msg" embedded class="msg">{{ msg }}</n-card>
|
||||
<n-data-table
|
||||
v-if="infoTable.length"
|
||||
striped
|
||||
:data="infoTable"
|
||||
:columns="columns"
|
||||
/>
|
||||
<n-data-table v-if="infoTable.length" striped :data="infoTable" :columns="columns" />
|
||||
</n-flex>
|
||||
|
||||
<!-- AI 提示区域 -->
|
||||
<template v-if="showAIHint">
|
||||
<n-card size="small" style="margin-top: 12px; max-width: 480px">
|
||||
<n-alert
|
||||
v-if="hintError"
|
||||
type="error"
|
||||
:title="hintError"
|
||||
class="mb-3"
|
||||
/>
|
||||
<n-alert v-if="hintError" type="error" :title="hintError" class="mb-3" />
|
||||
<n-button
|
||||
v-if="!hintTarget && !hintLoading"
|
||||
type="primary"
|
||||
|
||||
@@ -14,20 +14,13 @@ import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useUserStore } from "shared/store/user"
|
||||
import { useCollabStore } from "shared/store/collab"
|
||||
import { restartEditTrace, snapshotEditTrace } from "oj/problem/utils/editTrace"
|
||||
import {
|
||||
checkPythonSyntax,
|
||||
prefetchPythonSyntaxChecker,
|
||||
} from "oj/problem/utils/pythonSyntaxCheck"
|
||||
import { checkPythonSyntax, prefetchPythonSyntaxChecker } from "oj/problem/utils/pythonSyntaxCheck"
|
||||
|
||||
// ==================== 异步组件 ====================
|
||||
const ProblemReaction = defineAsyncComponent(
|
||||
() => import("./ProblemReaction.vue"),
|
||||
)
|
||||
const ProblemReaction = defineAsyncComponent(() => import("./ProblemReaction.vue"))
|
||||
// 结果面板第一次弹出(也就是第一次提交)时才加载:它带着 DataTable,而判题要等
|
||||
// 好几秒,这点下载时间藏得住。进页面就加载的话,只看题不提交的人也要付这笔
|
||||
const SubmissionResult = defineAsyncComponent(
|
||||
() => import("./SubmissionResult.vue"),
|
||||
)
|
||||
const SubmissionResult = defineAsyncComponent(() => import("./SubmissionResult.vue"))
|
||||
|
||||
// ==================== 基础状态 ====================
|
||||
const userStore = useUserStore()
|
||||
@@ -53,8 +46,7 @@ const { isDesktop } = useBreakpoints()
|
||||
const { celebrate } = useFireworks()
|
||||
|
||||
// ==================== 判题监控 ====================
|
||||
const { submission, judging, pending, submitting, startMonitoring } =
|
||||
useSubmissionMonitor()
|
||||
const { submission, judging, pending, submitting, startMonitoring } = useSubmissionMonitor()
|
||||
|
||||
const showResult = ref(false)
|
||||
const isFormatting = ref(false)
|
||||
@@ -68,8 +60,7 @@ watch(
|
||||
() => codeStore.code.language === "Python" && userStore.isAuthed,
|
||||
(needed) => {
|
||||
if (!needed) return
|
||||
if ("requestIdleCallback" in window)
|
||||
requestIdleCallback(prefetchPythonSyntaxChecker)
|
||||
if ("requestIdleCallback" in window) requestIdleCallback(prefetchPythonSyntaxChecker)
|
||||
else setTimeout(prefetchPythonSyntaxChecker, 1000)
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -162,8 +153,7 @@ async function submit() {
|
||||
code: codeStore.code.value,
|
||||
// 编辑过程信号,见 utils/editTrace.ts。协作的判断和 ProblemEditor 的 collabHere 同一个口径
|
||||
trace: snapshotEditTrace(
|
||||
collabStore.room !== null &&
|
||||
collabStore.room.problemId === problem.value!._id,
|
||||
collabStore.room !== null && collabStore.room.problemId === problem.value!._id,
|
||||
),
|
||||
}
|
||||
if (contestID) {
|
||||
@@ -219,11 +209,7 @@ watch(
|
||||
watch(
|
||||
() => submission.value?.result,
|
||||
async (result) => {
|
||||
if (
|
||||
result !== SubmissionStatus.accepted &&
|
||||
result !== SubmissionStatus.ast_check_failed
|
||||
)
|
||||
return
|
||||
if (result !== SubmissionStatus.accepted && result !== SubmissionStatus.ast_check_failed) return
|
||||
|
||||
// 1. 刷新题目状态
|
||||
problem.value!.myStatus = 0
|
||||
|
||||
@@ -10,10 +10,7 @@ import { sortFlowchartCriteria } from "utils/constants"
|
||||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||||
import { useMermaid } from "shared/composables/useMermaid"
|
||||
import { useMermaidConverter } from "../composables/useMermaidConverter"
|
||||
import {
|
||||
useFlowchartWebSocket,
|
||||
type FlowchartEvaluationUpdate,
|
||||
} from "shared/composables/websocket"
|
||||
import { useFlowchartWebSocket, type FlowchartEvaluationUpdate } from "shared/composables/websocket"
|
||||
import { useMyFlowchartStore } from "shared/store/myFlowchart"
|
||||
|
||||
// API 和状态管理
|
||||
@@ -46,8 +43,7 @@ interface FlowchartEditorInstance {
|
||||
}
|
||||
|
||||
// 通过inject获取FlowchartEditor组件的引用
|
||||
const flowchartEditorRef =
|
||||
inject<Ref<FlowchartEditorInstance | null>>("flowchartEditorRef")
|
||||
const flowchartEditorRef = inject<Ref<FlowchartEditorInstance | null>>("flowchartEditorRef")
|
||||
const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
|
||||
|
||||
// 基础组合式函数
|
||||
@@ -77,14 +73,10 @@ const evaluation = ref<Evaluation>({
|
||||
})
|
||||
const page = ref(1)
|
||||
const lastSubmittedMermaidCode = ref("")
|
||||
const suggestionLines = computed(() =>
|
||||
splitSuggestionLines(evaluation.value.suggestions),
|
||||
)
|
||||
const suggestionLines = computed(() => splitSuggestionLines(evaluation.value.suggestions))
|
||||
|
||||
// jsonb 不保留键序,直接遍历会把 40 分的「逻辑正确性」排到最后
|
||||
const sortedCriteria = computed(() =>
|
||||
sortFlowchartCriteria(evaluation.value.criteria_details),
|
||||
)
|
||||
const sortedCriteria = computed(() => sortFlowchartCriteria(evaluation.value.criteria_details))
|
||||
|
||||
function splitSuggestionLines(suggestions?: string | null) {
|
||||
return suggestions
|
||||
@@ -111,9 +103,7 @@ const POLL_INTERVAL = 3000
|
||||
/** 到点还没结果就收手,别无限轮询下去 */
|
||||
const POLL_TIMEOUT = 3 * 60 * 1000
|
||||
|
||||
type Outcome =
|
||||
| { ok: true; score: number; grade: string }
|
||||
| { ok: false; error?: string }
|
||||
type Outcome = { ok: true; score: number; grade: string } | { ok: false; error?: string }
|
||||
|
||||
const { pause: pausePolling, resume: resumePolling } = useIntervalFn(
|
||||
async () => {
|
||||
@@ -175,21 +165,12 @@ function settle(submissionId: string, outcome: Outcome) {
|
||||
loading.value = false
|
||||
|
||||
if (!outcome.ok) {
|
||||
message.error(
|
||||
outcome.error
|
||||
? `流程图评分失败: ${outcome.error}`
|
||||
: "流程图评分失败,请稍后重试",
|
||||
)
|
||||
message.error(outcome.error ? `流程图评分失败: ${outcome.error}` : "流程图评分失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
latestRating.value = { score: outcome.score, grade: outcome.grade }
|
||||
message.success(
|
||||
`流程图评分完成!得分: ${outcome.score}分 (${outcome.grade}级)`,
|
||||
)
|
||||
if (
|
||||
(outcome.grade === "A" || outcome.grade === "S") &&
|
||||
lastSubmittedMermaidCode.value
|
||||
) {
|
||||
message.success(`流程图评分完成!得分: ${outcome.score}分 (${outcome.grade}级)`)
|
||||
if ((outcome.grade === "A" || outcome.grade === "S") && lastSubmittedMermaidCode.value) {
|
||||
myFlowchartStore.show(lastSubmittedMermaidCode.value)
|
||||
}
|
||||
}
|
||||
@@ -208,9 +189,8 @@ const handleWebSocketMessage = (data: FlowchartEvaluationUpdate) => {
|
||||
}
|
||||
|
||||
// 创建 WebSocket 连接
|
||||
const { connect, disconnect, subscribe, unsubscribe } = useFlowchartWebSocket(
|
||||
handleWebSocketMessage,
|
||||
)
|
||||
const { connect, disconnect, subscribe, unsubscribe } =
|
||||
useFlowchartWebSocket(handleWebSocketMessage)
|
||||
|
||||
// 订阅提交更新,同时开启轮询兜底
|
||||
function subscribeToSubmission(submissionId: string) {
|
||||
@@ -292,10 +272,7 @@ async function getCurrentSubmission() {
|
||||
|
||||
async function getSubmission(submissionPage = 0) {
|
||||
if (!problem.value?.id) return
|
||||
const data = await getFlowchartSubmissionDetail(
|
||||
problem.value.id,
|
||||
submissionPage,
|
||||
)
|
||||
const data = await getFlowchartSubmissionDetail(problem.value.id, submissionPage)
|
||||
submissionCount.value = data.count
|
||||
const submission = data.submission
|
||||
// 翻到没有提交的页时后端返回 null(契约里 submission 是 nullable)——
|
||||
@@ -324,8 +301,7 @@ async function getSubmission(submissionPage = 0) {
|
||||
grade: (submission.aiGrade ?? "") as Rating["grade"],
|
||||
feedback: submission.aiFeedback ?? "",
|
||||
suggestions: submission.aiSuggestions ?? "",
|
||||
criteria_details:
|
||||
submission.aiCriteriaDetails as Evaluation["criteria_details"],
|
||||
criteria_details: submission.aiCriteriaDetails as Evaluation["criteria_details"],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,18 +441,12 @@ onUnmounted(() => {
|
||||
<n-alert v-if="renderError" type="error" title="流程图渲染失败">
|
||||
{{ renderError }}
|
||||
</n-alert>
|
||||
<div
|
||||
class="flowchart"
|
||||
v-show="!renderError"
|
||||
ref="mermaidContainer"
|
||||
></div>
|
||||
<div class="flowchart" v-show="!renderError" ref="mermaidContainer"></div>
|
||||
</n-spin>
|
||||
</div>
|
||||
<!-- 加载到编辑器按钮 -->
|
||||
<n-flex style="margin-top: 16px" justify="center">
|
||||
<n-button @click="loadToEditor" type="primary">
|
||||
加载到流程图编辑器
|
||||
</n-button>
|
||||
<n-button @click="loadToEditor" type="primary"> 加载到流程图编辑器 </n-button>
|
||||
</n-flex>
|
||||
</n-gi>
|
||||
|
||||
@@ -511,23 +481,11 @@ onUnmounted(() => {
|
||||
|
||||
<!-- 详细评分 -->
|
||||
<n-card v-if="sortedCriteria.length" size="small" title="详细评分">
|
||||
<div
|
||||
v-for="[key, detail] in sortedCriteria"
|
||||
:key="key"
|
||||
style="margin-bottom: 12px"
|
||||
>
|
||||
<div v-for="[key, detail] in sortedCriteria" :key="key" style="margin-bottom: 12px">
|
||||
<!-- 评分项标题和分数 -->
|
||||
<n-flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
style="margin-bottom: 4px"
|
||||
>
|
||||
<n-flex justify="space-between" align="center" style="margin-bottom: 4px">
|
||||
<n-text strong>{{ key }}</n-text>
|
||||
<n-tag
|
||||
:type="getPercentType(detail.score / detail.max)"
|
||||
size="small"
|
||||
round
|
||||
>
|
||||
<n-tag :type="getPercentType(detail.score / detail.max)" size="small" round>
|
||||
{{ detail.score || 0 }}分 / {{ detail.max }}分
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
@@ -541,16 +499,8 @@ onUnmounted(() => {
|
||||
</n-grid>
|
||||
|
||||
<!-- 分页组件 -->
|
||||
<n-flex
|
||||
justify="center"
|
||||
style="margin-top: 24px"
|
||||
v-if="submissionCount > 1"
|
||||
>
|
||||
<n-pagination
|
||||
v-model:page="page"
|
||||
:page-count="submissionCount"
|
||||
@update-page="updatePage"
|
||||
/>
|
||||
<n-flex justify="center" style="margin-top: 24px" v-if="submissionCount > 1">
|
||||
<n-pagination v-model:page="page" :page-count="submissionCount" @update-page="updatePage" />
|
||||
</n-flex>
|
||||
</n-modal>
|
||||
</n-flex>
|
||||
|
||||
Loaded 100 of 268 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user