update
Deploy / deploy (push) Canceled after 0s

This commit is contained in:
xuyue committed 2026-09-27 19:36:28 +08:00
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>
+5 -22
View File
@@ -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>
+4 -16
View File
@@ -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>
+2 -11
View File
@@ -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>
+3 -11
View File
@@ -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
View File
@@ -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>
+5 -26
View File
@@ -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>
+7 -30
View File
@@ -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
+2 -9
View File
@@ -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
View File
@@ -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
+2 -13
View File
@@ -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" />
+30 -124
View File
@@ -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
+8 -30
View File
@@ -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"
+5 -18
View File
@@ -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: "是否完成",
+1 -4
View File
@@ -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>
+2 -9
View File
@@ -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
}
+5 -23
View File
@@ -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>
+10 -41
View File
@@ -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>
+3 -12
View File
@@ -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 -3
View File
@@ -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: &quot;Monaco&quot;"
/>
</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>
+2 -10
View File
@@ -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" />
+2 -6
View File
@@ -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>
+2 -12
View File
@@ -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>
+2 -6
View File
@@ -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>
+11 -46
View File
@@ -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
+1 -3
View File
@@ -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 -5
View File
@@ -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>
+10 -46
View File
@@ -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"
+2 -8
View File
@@ -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),
),
)
+2 -5
View File
@@ -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()
+2 -9
View File
@@ -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日")} 期间`
+3 -14
View File
@@ -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, "基于同时段排名的百分位:"),
+1 -7
View File
@@ -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,
+2 -11
View File
@@ -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
View File
@@ -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
View File
@@ -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[]
+4 -18
View File
@@ -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>
+4 -21
View File
@@ -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>
+8 -33
View File
@@ -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
+14 -60
View File
@@ -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