refactor(前端): 拆掉 camelCase→snake_case 转换层,契约成为唯一真相

utils/legacy.ts 是迁移期的临时层:新后端一律 camelCase,而组件读的还是
旧 Django 的 snake_case,于是在 api 层做一次递归键名重写。它自己的注释就
写了「迁移完成后这一层应当整体拆掉」。现在拆了。

代价不只是那 96 处包装:每个响应都要递归遍历整个对象重写一遍键名,而且
utils/types.ts 和 packages/contract 是两份真相 —— 手抄的那份还抄歪了好几处。

做法是按域推进,每域都用 vue-tsc 相对基线做差,确认零新增错误后再往下走。
前端的类型现在一律以契约为准,只在必要处窄化(比如 languages/template 的键
窄化成 LANGUAGE),删掉的重复定义包括 WebsiteConfig、LoginSummary、
AchievementSummary、ProblemSet、Contest、User、Profile、AdminTag、
StuckProblem 等等,其中 ClassComparison 有两个组件各手抄了一份。

## 顺带修掉的真 bug

- 管理端公告列表的「可见」开关每次都 400:列表响应被契约 omit 掉了 content,
  而更新接口要求 content 必填,toggleVisible 把列表行原样回传。而且是乐观
  翻转、不 await 不 catch,管理员看到开关动了、实际没存也没有提示。
  改成先 GET 整条再 PUT,加失败提示。

- 删有提交的题时只显示笼统的「删除失败」:前端还在 match 旧 Django 的英文
  文案,而后端返回的是 problem-has-submissions + 中文。连同另外 8 处同类
  匹配一起改成判错误码 —— 文案是后端随时能改的,match 文案改一个字就静默失效。

- SubmissionStatus.time_limit_exceeded 写成 `1 | 2`,TS 按位或算成 3,和
  memory_limit_exceeded 撞了同一个值。后端 judge/status.ts 里这是分开的
  两个码,按后端拆成 cpu_/real_ 两项。当前没有代码读这两个成员,但
  CLAUDE.md 明确要求判题状态码三处同步。

- 流程图历史翻到没有提交的那一页会直接抛:契约里 submission 是 nullable,
  被 any 掩盖成看起来非空。补了 null 分支。

## 契约里被逼出来的三处不诚实

- grade 写成 z.string(),但 averageGrade() 在没有可用数据时返回空串,
  前端三张图表拿它查 Record<Grade,...> 会查出 undefined。按实际收紧成
  z.enum([...,""]),四个查表点都补了「无评级」分支。

- difficulty 写成 z.string()。核对过生产库 dump:956 道题只有
  Low/Mid/High 三个值(761/149/46)。收紧成枚举。

- topReaction 写成 z.string(),既对不上前端渲染的 {type,count},也对不上
  旧后端 get_top_reactions 下发的形状。改成正确形状并注明当前恒传 null。

## 明确保留 snake_case 的 54 处

判题沙箱原始输出(cpu_time/exit_code/output_md5/compile_output)、
statistic_info 内容(err_info/time_cost/ast_results)、submission_info
JSONB(is_ac/ac_time/error_number,回滚时旧后端还要读)、SQL 判题引擎的
total_rows/order_sensitive/changed_tables、WebSocket 的 submission_id、
以及数据库选项键 enable_maxkb。每一处都在类型定义旁写了为什么不能改。

language 没有跟着收紧契约 —— 它是配置项、随时可能加语言,收紧会让新语言
在后端 parse 时直接抛。改在 api 边界一处窄化。

## 另外

- utils/http.ts 整个模块已是死代码(四处引用全是 import type),删除。
- profile 的 blog/github/school/major/language 五个字段全链路空转,没有
  任何组件读,从契约到类型一并摘除(数据库列不动)。
- admin/account.ts 往 user_profile 塞的 totalScore 是 OI 模式遗留,表里
  没这一列。Drizzle 按表定义拼列名会把它静默丢弃,所以没出过错,是死代码。

验证:vue-tsc 143 → 54 条且无新增,apps/api tsc、check:routes、web build
全通过;各域响应形状逐条打接口核对过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 13:44:24 -06:00
parent b5edd6faf5
commit c2a81209d7
125 changed files with 1651 additions and 2253 deletions

View File

@@ -1,26 +1,14 @@
import api2 from "utils/api2"
import type {
Achievement,
AchievementList,
AchievementSummary,
PendingAchievement,
} from "utils/types"
export function getAchievements(name?: string) {
return api2
.get<any>("achievements", { params: name ? { username: name } : {} })
.then((response) => ({
...response,
data: {
username: response.data.username,
achievements: response.data.achievements.map(
(item: any): Achievement => ({
...item,
unlock_time: item.unlockTime,
unlock_rate: item.unlockRate,
}),
),
},
}))
return api2.get<AchievementList>("achievements", {
params: name ? { username: name } : {},
})
}
export function getAchievementSummary(name?: string) {

View File

@@ -17,7 +17,7 @@ const masked = computed(
// 获得率低于 5% 的加稀有闪光边框
const isRare = computed(
() => props.achievement.unlock_rate > 0 && props.achievement.unlock_rate < 5,
() => props.achievement.unlockRate > 0 && props.achievement.unlockRate < 5,
)
// 只有"越多越好"的成就画进度条。lte 类(如最短 AC 代码 ≤ 50 字符)
@@ -45,10 +45,10 @@ const percent = computed(() => {
})
const unlockDate = computed(() => {
const { unlock_time, backfilled } = props.achievement
const { unlockTime, backfilled } = props.achievement
// 补发的记录不显示具体日期:一次补发会给几百人盖上同一个时间戳
if (backfilled || !unlock_time) return "已获得"
return `${new Date(unlock_time).toLocaleDateString()} 获得`
if (backfilled || !unlockTime) return "已获得"
return `${new Date(unlockTime).toLocaleDateString()} 获得`
})
</script>
@@ -86,7 +86,7 @@ const unlockDate = computed(() => {
<template v-if="achievement.unlocked">
<n-text depth="3" class="nowrap">{{ unlockDate }}</n-text>
<n-text depth="3" class="nowrap">
{{ achievement.unlock_rate }}% 的人获得
{{ achievement.unlockRate }}% 的人获得
</n-text>
</template>
@@ -113,7 +113,7 @@ const unlockDate = computed(() => {
</template>
<n-text v-else depth="3" class="nowrap">
{{ achievement.unlock_rate }}% 的人获得
{{ achievement.unlockRate }}% 的人获得
</n-text>
</n-flex>
</n-thing>

View File

@@ -7,25 +7,10 @@ import type {
Achievement,
AchievementRarity,
AchievementSummary,
UserBadge,
} from "utils/types"
import AchievementCard from "./components/AchievementCard.vue"
interface UserBadge {
id: number
earned_time: string
badge: {
id: number
name: string
description: string
icon: string
}
// 奖章来自哪个题单,接口在 UserBadgeSerializer 里带出来
problemset: {
id: number
title: string
} | null
}
const route = useRoute()
const name = computed(() => (route.query.name as string) || undefined)
@@ -72,7 +57,7 @@ async function load() {
// http 客户端返回 ApiResponse<T>,真实载荷在 .data 里
achievements.value = list.data.achievements
summary.value = sum.data
badges.value = (badgeRes.data ?? []) as UserBadge[]
badges.value = badgeRes.data ?? []
} finally {
loading.value = false
}

View File

@@ -83,6 +83,7 @@ const data = computed(() => {
// 根据等级返回对应的颜色
function getGradeColor(grade: Grade): string {
const colors: { [key in Grade]: string } = {
"": "#C9CDD4", // 无评级:后端在没有可用数据时下发空串
S: "#FF6384",
A: "#FFCE56",
B: "#36A2EB",

View File

@@ -73,14 +73,14 @@ const data = computed<ChartData<"bar" | "line">>(() => {
{
type: "bar",
label: "完成题目数",
data: aiStore.durationData.map((duration) => duration.problem_count),
data: aiStore.durationData.map((duration) => duration.problemCount),
yAxisID: "y",
order: 2,
},
{
type: "bar",
label: "总提交次数",
data: aiStore.durationData.map((duration) => duration.submission_count),
data: aiStore.durationData.map((duration) => duration.submissionCount),
yAxisID: "y",
order: 2,
},

View File

@@ -59,8 +59,8 @@ const show = computed(() => {
// 计算提交效率数据
const efficiencyData = computed(() => {
return aiStore.durationData.map((duration) => {
const problemCount = duration.problem_count || 0
const submissionCount = duration.submission_count || 0
const problemCount = duration.problemCount || 0
const submissionCount = duration.submissionCount || 0
// 计算效率:提交次数/完成题目数
// 值越接近1说明一次AC率越高

View File

@@ -28,8 +28,10 @@
</div>
</template>
<script setup lang="ts">
import type { Grade } from "utils/types"
const props = defineProps<{
grade: "S" | "A" | "B" | "C"
// 空串是「无评级」,四张图都不渲染 —— 后端没有可用数据时会下发它
grade: Grade
}>()
</script>
<style scoped>

View File

@@ -8,9 +8,9 @@
<span>你一共解决 </span>
<b class="charming"> {{ aiStore.detailsData.solved.length }} </b>
<span> 道题</span>
<span v-if="aiStore.detailsData.contest_count > 0">
<span v-if="aiStore.detailsData.contestCount > 0">
并且参加
<b class="charming"> {{ aiStore.detailsData.contest_count }} </b>
<b class="charming"> {{ aiStore.detailsData.contestCount }} </b>
次比赛
</span>
<span>综合评价给到</span>
@@ -49,6 +49,7 @@ const durationLabel = computed(() => {
const greeting = computed(() => {
return {
"": "还没有足够的数据来评级",
S: "要不试试高难度题目?",
A: "你很棒,继续保持!",
B: "请再接再厉!",

View File

@@ -44,6 +44,7 @@ const aiStore = useAIStore()
const gradeOrder = ["C", "B", "A", "S"] as const
const gradeColors: Record<Grade, string> = {
"": "#C9CDD4", // 无评级:后端在没有可用数据时下发空串
C: "#95F204",
B: "#36A2EB",
A: "#FFCE56",
@@ -74,7 +75,7 @@ const progressData = computed(() => {
let totalProblems = 0 // 累计题目总数
return aiStore.durationData.map((duration) => {
const problemCount = duration.problem_count || 0
const problemCount = duration.problemCount || 0
cumulativeCount += problemCount
// 计算本期等级的权重值

View File

@@ -38,8 +38,8 @@ const rankDistribution = computed(() => {
}))
aiStore.detailsData.solved.forEach((item) => {
const rank = item.period_rank
const acCount = item.period_ac_count
const rank = item.periodRank
const acCount = item.periodAcCount
if (rank && acCount && acCount > 0) {
const percentile = (rank / acCount) * 100
@@ -52,7 +52,7 @@ const rankDistribution = computed(() => {
if (rangeIndex !== -1) {
distribution[rangeIndex].count++
distribution[rangeIndex].problems.push(
`${item.problem.display_id}: ${item.problem.title}`,
`${item.problem.displayId}: ${item.problem.title}`,
)
}
}

View File

@@ -58,40 +58,40 @@ const columns: DataTableColumn<SolvedProblem>[] = [
{
text: true,
onClick: () => {
if (row.problem.contest_id) {
if (row.problem.contestId) {
router.push(
"/contest/" +
row.problem.contest_id +
row.problem.contestId +
"/problem/" +
row.problem.display_id,
row.problem.displayId,
)
} else {
router.push("/problem/" + row.problem.display_id)
router.push("/problem/" + row.problem.displayId)
}
},
},
() => {
if (row.problem.contest_id) {
if (row.problem.contestId) {
return h(TagTitle, { problem: row.problem })
} else {
return row.problem.display_id + " " + row.problem.title
return row.problem.displayId + " " + row.problem.title
}
},
),
},
{
title: () => (aiStore.detailsData.class_name ? "班级排名" : "全服排名"),
title: () => (aiStore.detailsData.className ? "班级排名" : "全服排名"),
key: "rank",
width: 100,
align: "center",
render: (row) => row.rank + " / " + row.ac_count,
render: (row) => row.rank + " / " + row.acCount,
},
{
title: "同期排名",
key: "period_rank",
width: 100,
align: "center",
render: (row) => row.period_rank + " / " + row.period_ac_count,
render: (row) => row.periodRank + " / " + row.periodAcCount,
},
{
title: () =>
@@ -128,10 +128,10 @@ const flowchartsColumns: DataTableColumn<FlowchartSummary>[] = [
{
text: true,
onClick: () => {
router.push("/problem/" + row.problem__id)
router.push("/problem/" + row.problemId)
},
},
() => `${row.problem__id} ${row.problem_title}`,
() => `${row.problemId} ${row.problemTitle}`,
),
},
{ title: "提交次数", key: "submission_count", width: 100, align: "center" },
@@ -140,14 +140,14 @@ const flowchartsColumns: DataTableColumn<FlowchartSummary>[] = [
key: "best",
width: 100,
align: "center",
render: (row) => `${row.best_score} (${row.best_grade})`,
render: (row) => `${row.bestScore} (${row.bestGrade})`,
},
{
title: "最新提交时间",
key: "latest_submission_time",
width: 200,
align: "center",
render: (row) => parseTime(row.latest_submission_time),
render: (row) => parseTime(row.latestSubmissionTime),
},
{ title: "平均分", key: "avg_score", width: 100, align: "center" },
]

View File

@@ -2,19 +2,15 @@
<n-flex vertical align="start">
<n-flex align="center">
<n-tag type="info" size="small" :bordered="false">比赛</n-tag>
<span>{{ problem.contest_title }}</span>
<span>{{ problem.contestTitle }}</span>
</n-flex>
<span>{{ problem.display_id }} {{ problem.title }}</span>
<span>{{ problem.displayId }} {{ problem.title }}</span>
</n-flex>
</template>
<script setup lang="ts">
import type { SolvedProblem } from "utils/types"
interface Props {
problem: {
title: string
display_id: string
contest_title: string
contest_id: number
}
problem: SolvedProblem["problem"]
}
const props = defineProps<Props>()

View File

@@ -50,7 +50,7 @@ const activityMatrix = computed(() => {
// 统计数据
aiStore.detailsData.solved.forEach((item) => {
const date = new Date(item.ac_time)
const date = new Date(item.acTime)
const weekday = date.getDay() // 0-60是周日
const hour = date.getHours() // 0-23

View File

@@ -33,15 +33,15 @@ const columns: DataTableColumn<Announcement>[] = [
render: (row) => h(NTag, () => row.tag || "公告"),
},
{
key: "create_time",
key: "createTime",
title: renderTableTitle("发布时间", "fluent-emoji-flat:eight-oclock"),
render: (row) => parseTime(row.create_time),
render: (row) => parseTime(row.createTime),
width: 180,
},
{
key: "username",
title: renderTableTitle("发布人", "streamline-emojis:ghost"),
render: (row) => row.created_by.username,
render: (row) => row.createdBy.username,
width: 120,
},
]

View File

@@ -1,15 +1,46 @@
import {
createSubmissionResponseSchema,
type AiAnalysisRecord,
type Contest as OjContest,
type ContestList,
type ActivityRankItem,
type ClassComparisonResponse,
type ClassRankItem,
type ClassUserRank,
type ContestRank,
type UserRank,
type ProblemRank,
type CreateSubmissionResponse,
type ProblemAuthor,
type ProblemListItem,
type YearlyAc,
type ProblemList,
type CreateFlowchartResponse,
type FlowchartCurrent,
type FlowchartDetail,
type FlowchartList,
type FlowchartSubmission,
type AiDetail,
type DurationData,
type HeatmapItem,
type LoginSummary,
type ProblemSet,
type ProblemSetBadge,
type ProblemSetList,
type ProblemSetProblem,
type ProblemSetProgressList,
type UserBadge,
problemDetailSchema,
submissionDetailSchema,
type FlowchartStatistics,
type SubmissionStatistics,
} from "@oj2/contract"
import api2 from "utils/api2"
import { legacyResponse, toLegacy } from "utils/legacy"
import type { ApiResponse } from "utils/http"
import { filterResult } from "oj/transforms"
import type {
Announcement,
Profile,
Message,
SubmissionListItem,
Exercise,
Problem,
ReactionKey,
@@ -18,97 +49,39 @@ import type {
SubmissionListPayload,
SubmitCodePayload,
WebsiteConfig,
Tutorial,
} from "utils/types"
function listProblem(value: any): Problem {
return {
id: value.id,
_id: value._id,
title: value.title,
difficulty: value.difficulty,
submission_number: value.submissionNumber,
accepted_number: value.acceptedNumber,
created_by: toLegacy(value.createdBy),
tags: value.tags,
contest: value.contestId,
allow_flowchart: value.allowFlowchart,
show_flowchart: value.showFlowchart,
has_ast_rules: value.hasAstRules,
my_status: value.myStatus,
} as Problem
}
/**
* 题目详情。走契约的 zod 解析,形状即契约 —— 之前这里手抄了一份 camel→snake 的
* 键名映射,抄漏一个字段就是静默 undefined。
*/
function detailProblem(value: unknown): Problem {
const problem = problemDetailSchema.parse(value)
return {
id: problem.id,
_id: problem._id,
title: problem.title,
description: problem.description,
input_description: problem.inputDescription,
output_description: problem.outputDescription,
samples: problem.samples,
hint: problem.hint ?? "",
languages: problem.languages,
template: problem.template,
create_time: problem.createTime,
last_update_time: problem.lastUpdateTime,
time_limit: problem.timeLimit,
memory_limit: problem.memoryLimit,
difficulty: problem.difficulty,
source: problem.source ?? "",
prompt: problem.prompt ?? "",
answers: [],
submission_number: problem.submissionNumber,
accepted_number: problem.acceptedNumber,
statistic_info: problem.statisticInfo,
share_submission: problem.shareSubmission,
contest: problem.contestId,
tags: problem.tags,
created_by: {
id: problem.createdBy.id,
username: problem.createdBy.username,
real_name: problem.createdBy.realName,
},
my_status: problem.myStatus,
my_failed_count: problem.myFailedCount,
visible: true,
allow_flowchart: problem.allowFlowchart,
show_flowchart: problem.showFlowchart,
mermaid_code: problem.mermaidCode ?? undefined,
flowchart_data: problem.flowchartData ?? undefined,
flowchart_hint: problem.flowchartHint ?? undefined,
sql_config: problem.sqlConfig as Problem["sql_config"],
sql_display: problem.sqlDisplay as Problem["sql_display"],
} as Problem
return problemDetailSchema.parse(value) as Problem
}
export function getWebsiteConfig() {
return legacyResponse<WebsiteConfig>(api2.get("site"))
return api2.get<WebsiteConfig>("site")
}
export async function getProblemList(
offset = 0,
limit = 10,
searchParams: any = {},
searchParams: Record<string, unknown> = {},
) {
const res = await api2.get<{ results: any[]; total: number }>("problems", {
const res = await api2.get<ProblemList>("problems", {
params: { paging: true, offset, limit, ...searchParams },
})
return {
results: res.data.results.map(listProblem).map(filterResult),
results: res.data.results.map(filterResult),
total: res.data.total,
}
}
export function getAuthors(all = false) {
return legacyResponse(
api2.get("problem-authors", {
params: {
all: all ? "1" : "0",
},
}),
)
return api2.get<ProblemAuthor[]>("problem-authors", {
params: { all: all ? "1" : "0" },
})
}
export function getRandomProblemID() {
@@ -131,41 +104,14 @@ export async function getSubmission(id: string) {
const response = await api2.get<unknown>(
`submissions/${encodeURIComponent(id)}`,
)
const submission = submissionDetailSchema.parse(response.data)
return {
error: null,
data: {
id: submission.id,
create_time: submission.createTime,
user_id: submission.userId,
username: submission.username,
code: submission.code,
result: submission.result,
info: submission.info,
language: submission.language,
shared: submission.shared,
show_link: submission.showLink,
statistic_info: submission.statisticInfo,
ip: submission.ip,
contest: submission.contestId,
problem: submission.problemId,
can_unshare: submission.canUnshare,
} as Submission,
data: submissionDetailSchema.parse(response.data) as Submission,
}
}
export async function submitCode(data: SubmitCodePayload) {
const response = await api2.post<unknown>("submissions", {
problemId: data.problem_id,
language: data.language,
code: data.code,
contestId: data.contest_id,
})
const created = createSubmissionResponseSchema.parse(response.data)
return {
error: null,
data: { submission_id: created.submissionId },
}
export function submitCode(data: SubmitCodePayload) {
return api2.post<CreateSubmissionResponse>("submissions", data)
}
export function formatCode(data: { code: string; language: string }) {
@@ -182,30 +128,23 @@ export function formatCode(data: { code: string; language: string }) {
}
export function getSubmissions(params: Partial<SubmissionListPayload>) {
const endpoint = params.contest_id
? `contests/${encodeURIComponent(params.contest_id)}/submissions`
const endpoint = params.contestId
? `contests/${encodeURIComponent(params.contestId)}/submissions`
: "submissions"
return legacyResponse(
api2.get(endpoint, {
params: {
...params,
problemId: params.problem_id,
contest_id: undefined,
problem_id: undefined,
page: undefined,
},
}),
)
// 契约里 language 是 z.string()(语言是配置项,随时可能加,收紧成枚举会让
// 新加的语言在后端 parse 时直接抛),前端在这一处收窄成 LANGUAGE
return api2.get<{ results: SubmissionListItem[]; total: number }>(endpoint, {
// contestId 走的是路径page 只有前端分页器用
params: { ...params, contestId: undefined, page: undefined },
})
}
export function getRankOfProblem(problem_id: string) {
return legacyResponse(
api2.get(`problems/${encodeURIComponent(problem_id)}/rank`),
)
export function getRankOfProblem(problemId: string) {
return api2.get<ProblemRank>(`problems/${encodeURIComponent(problemId)}/rank`)
}
export function getTodaySubmissionCount(language?: string) {
return api2.get("submissions/today-count", { params: { language } })
return api2.get<number>("submissions/today-count", { params: { language } })
}
export function adminRejudge(id: string) {
@@ -228,25 +167,19 @@ export function getRank(
n: number,
username?: string,
) {
return legacyResponse(
api2.get("rankings/users", {
params: { offset, limit, username, top: n },
}),
)
return api2.get<UserRank>("rankings/users", {
params: { offset, limit, username, top: n },
})
}
export function getActivityRank(start: string) {
return api2.get("rankings/activity", {
return api2.get<ActivityRankItem[]>("rankings/activity", {
params: { start },
})
}
export function getClassRank(grade?: number | null) {
return legacyResponse(
api2.get("rankings/classes", {
params: { grade },
}),
)
return api2.get<ClassRankItem[]>("rankings/classes", { params: { grade } })
}
export function getUserClassRank(
@@ -254,9 +187,9 @@ export function getUserClassRank(
offset?: number,
limit?: number,
) {
return legacyResponse(
api2.get("me/class-rank", { params: { scope, offset, limit } }),
)
return api2.get<ClassUserRank>("me/class-rank", {
params: { scope, offset, limit },
})
}
export function getClassPK(
@@ -264,16 +197,11 @@ export function getClassPK(
startTime?: string,
endTime?: string,
) {
const payload: any = {
return api2.post<ClassComparisonResponse>("classes/comparison", {
classNames,
}
if (startTime) {
payload.startTime = startTime
}
if (endTime) {
payload.endTime = endTime
}
return legacyResponse(api2.post("classes/comparison", payload))
...(startTime ? { startTime } : {}),
...(endTime ? { endTime } : {}),
})
}
export function getContestList(query: {
@@ -283,11 +211,11 @@ export function getContestList(query: {
status: string
tag: string
}) {
return legacyResponse(api2.get("contests", { params: query }))
return api2.get<ContestList>("contests", { params: query })
}
export function getContest(id: string) {
return legacyResponse(api2.get(`contests/${encodeURIComponent(id)}`))
return api2.get<OjContest>(`contests/${encodeURIComponent(id)}`)
}
export function getContestAccess(id: string) {
@@ -301,30 +229,20 @@ export function checkContestPassword(contestID: string, password: string) {
}
export async function getContestProblems(contestID: string) {
const res = await api2.get<any[]>(
const res = await api2.get<ProblemListItem[]>(
`contests/${encodeURIComponent(contestID)}/problems`,
)
return res.data.map(listProblem).map(filterResult)
return res.data.map(filterResult)
}
export function getContestRank(
contestID: string,
query: { limit: number; offset: number },
) {
return legacyResponse<any>(
api2.get(`contests/${encodeURIComponent(contestID)}/rank`, {
params: query,
}),
).then((response) => ({
...response,
data: {
...response.data,
results: response.data.results.map((item: any) => ({
...item,
contest: item.contest_id,
})),
},
}))
return api2.get<ContestRank>(
`contests/${encodeURIComponent(contestID)}/rank`,
{ params: query },
)
}
export function uploadAvatar(file: File) {
@@ -335,23 +253,18 @@ export function uploadAvatar(file: File) {
})
}
export function updateProfile(data: { real_name: string; mood: string }) {
return legacyResponse(
api2.put("me/profile", {
realName: data.real_name,
mood: data.mood,
}),
)
export function updateProfile(data: { realName: string; mood: string }) {
return api2.put<Profile>("me/profile", data)
}
export function getAnnouncementList(offset = 0, limit = 10) {
return legacyResponse(
api2.get("announcements", { params: { limit, offset } }),
)
return api2.get<{ results: Announcement[]; total: number }>("announcements", {
params: { limit, offset },
})
}
export function getAnnouncement(id: number) {
return legacyResponse(api2.get(`announcements/${id}`))
return api2.get<Announcement>(`announcements/${id}`)
}
export function createMessage(data: {
@@ -367,7 +280,10 @@ export function createMessage(data: {
}
export function getMessageList(offset = 0, limit = 10) {
return legacyResponse(api2.get("messages", { params: { limit, offset } }))
// language 的收窄同 getSubmissions见那里的说明
return api2.get<{ results: Message[]; total: number }>("messages", {
params: { limit, offset },
})
}
export function getReaction(problemID: number) {
@@ -388,7 +304,7 @@ export function getMetrics(userid: number) {
}
export function getTutorial(id: number) {
return legacyResponse(api2.get(`tutorials/${id}`))
return api2.get<Tutorial>(`tutorials/${id}`)
}
export function getTutorials(type: "python" | "c") {
@@ -396,19 +312,7 @@ export function getTutorials(type: "python" | "c") {
}
export function getAIDetailData(start: string, end: string, username?: string) {
return legacyResponse<any>(
api2.get("ai/detail", { params: { start, end, username } }),
).then((response) => ({
...response,
data: {
...response.data,
flowcharts:
response.data.flowcharts?.map((item: any) => ({
...item,
problem__id: item.problem_id,
})) ?? [],
},
}))
return api2.get<AiDetail>("ai/detail", { params: { start, end, username } })
}
export function getAIDurationData(
@@ -416,95 +320,68 @@ export function getAIDurationData(
duration: string,
username?: string,
) {
return legacyResponse(
api2.get("ai/duration", { params: { end, duration, username } }),
)
return api2.get<DurationData[]>("ai/duration", {
params: { end, duration, username },
})
}
export function getAIHeatmapData(username?: string) {
return api2.get("ai/heatmap", { params: username ? { username } : {} })
return api2.get<HeatmapItem[]>("ai/heatmap", {
params: username ? { username } : {},
})
}
export function getAILoginSummary() {
return legacyResponse(api2.get("ai/login-summary"))
return api2.get<LoginSummary>("ai/login-summary")
}
export function getAIPinnedReport() {
return legacyResponse(api2.get("ai/pinned"))
return api2.get<AiAnalysisRecord | null>("ai/pinned")
}
// ==================== 相似题目推荐 ====================
export function getSimilarProblems(problemId: string) {
return api2
.get<any[]>(`problems/${encodeURIComponent(problemId)}/similar`)
.get<ProblemListItem[]>(`problems/${encodeURIComponent(problemId)}/similar`)
.then((response) => ({
...response,
data: response.data.map(listProblem).map(filterResult),
data: response.data.map(filterResult),
}))
}
export interface YearlyACData {
year: number
total: number
accepted: number
ac_rate: number
}
export type { YearlyAc as YearlyACData } from "@oj2/contract"
export function getProblemYearlyAC(problemId: string) {
return legacyResponse<YearlyACData[]>(
api2.get(`problems/${encodeURIComponent(problemId)}/yearly-ac`),
return api2.get<YearlyAc[]>(
`problems/${encodeURIComponent(problemId)}/yearly-ac`,
)
}
// ==================== 流程图相关API ====================
export function submitFlowchart(data: {
problem_id: number
mermaid_code: string
flowchart_data: any // 这个是压缩之后的,元数据太长了
problemId: number
mermaidCode: string
flowchartData: Record<string, unknown> // 压缩之后的,元数据太长了
}) {
return legacyResponse(
api2.post("flowcharts", {
problemId: data.problem_id,
mermaidCode: data.mermaid_code,
flowchartData: data.flowchart_data,
}),
)
return api2.post<CreateFlowchartResponse>("flowcharts", data)
}
function legacyFlowchart(value: unknown) {
const item = toLegacy<any>(value)
return {
...item,
user: item.user_id ?? 0,
problem: item.problem_id,
}
}
export async function getFlowchartSubmission(id: string) {
const response = await api2.get(`flowcharts/${encodeURIComponent(id)}`)
return { ...response, data: legacyFlowchart(response.data) }
export function getFlowchartSubmission(id: string) {
return api2.get<FlowchartSubmission>(`flowcharts/${encodeURIComponent(id)}`)
}
export function getFlowchartSubmissions(params: {
username?: string
problem_id?: string
problemId?: string
myself?: string
offset?: number
limit?: number
today?: string
grade?: string
}) {
return legacyResponse<any>(
api2.get("flowcharts", {
params: {
...params,
problemId: params.problem_id,
problem_id: undefined,
},
}),
)
return api2.get<FlowchartList>("flowcharts", { params })
}
export function getFlowchartStatistics(
@@ -518,32 +395,19 @@ export function getFlowchartStatistics(
}
export function retryFlowchartSubmission(submissionId: string) {
return legacyResponse(
api2.post(`flowcharts/${encodeURIComponent(submissionId)}/retry`),
return api2.post<{ status: string }>(
`flowcharts/${encodeURIComponent(submissionId)}/retry`,
)
}
export function getCurrentProblemFlowchartSubmission(problemId: number) {
return api2.get(`problems/${problemId}/flowchart/current`)
return api2.get<FlowchartCurrent>(`problems/${problemId}/flowchart/current`)
}
export async function getFlowchartSubmissionDetail(
problemId: number,
page = 0,
) {
const response = await api2.get<any>(
`problems/${problemId}/flowchart/history`,
{ params: { page } },
)
return {
...response,
data: {
...response.data,
submission: response.data.submission
? legacyFlowchart(response.data.submission)
: null,
},
}
export function getFlowchartSubmissionDetail(problemId: number, page = 0) {
return api2.get<FlowchartDetail>(`problems/${problemId}/flowchart/history`, {
params: { page },
})
}
// ==================== 题单相关API ====================
@@ -555,64 +419,17 @@ export function getProblemSetList(
difficulty = "",
status = "",
) {
return legacyResponse<any>(
api2.get("problem-sets", {
params: {
offset,
limit,
keyword,
difficulty,
status,
},
}),
).then(mapProblemSetResponse)
return api2.get<ProblemSetList>("problem-sets", {
params: { offset, limit, keyword, difficulty, status },
})
}
export function getProblemSetDetail(id: number) {
return legacyResponse<any>(api2.get(`problem-sets/${id}`)).then(
(response) => ({
...response,
data: legacyProblemSet(response.data),
}),
)
return api2.get<ProblemSet>(`problem-sets/${id}`)
}
function legacyBadge(value: any) {
return { ...value, problemset: value.problemset_id }
}
function legacyProblemSet(value: any) {
return {
...value,
badges: value.badges?.map(legacyBadge),
}
}
function mapProblemSetResponse(response: ApiResponse<any>) {
return {
...response,
data: {
...response.data,
results: response.data.results.map(legacyProblemSet),
},
}
}
export async function getProblemSetProblems(problemSetId: number) {
const response = await legacyResponse<any[]>(
api2.get(`problem-sets/${problemSetId}/problems`),
)
return {
...response,
data: response.data.map((item) => ({
...item,
problemset: item.problemset_id,
problem: {
...item.problem,
contest: item.problem.contest_id,
},
})),
}
export function getProblemSetProblems(problemSetId: number) {
return api2.get<ProblemSetProblem[]>(`problem-sets/${problemSetId}/problems`)
}
export function joinProblemSet(problemSetId: number) {
@@ -624,57 +441,35 @@ export function updateProblemSetProgress(
problemId: number,
submissionId: string,
) {
return legacyResponse(
api2.put("problem-set-progress", {
problemSetId,
problemId,
submissionId,
}),
return api2.put("problem-set-progress", {
problemSetId,
problemId,
submissionId,
})
}
export function getUserBadges(username?: string) {
return api2.get<UserBadge[]>(
`users/${encodeURIComponent(username ?? "me")}/badges`,
)
}
// 获取用户徽章列表
export async function getUserBadges(username?: string) {
const response = await legacyResponse<any[]>(
api2.get(`users/${encodeURIComponent(username ?? "me")}/badges`),
)
return {
...response,
data: response.data.map((item) => ({
...item,
user: item.user_id,
badge: legacyBadge(item.badge),
})),
}
export function getProblemSetBadges(problemSetId: number) {
return api2.get<ProblemSetBadge[]>(`problem-sets/${problemSetId}/badges`)
}
// 获取题单徽章列表
export async function getProblemSetBadges(problemSetId: number) {
const response = await legacyResponse<any[]>(
api2.get(`problem-sets/${problemSetId}/badges`),
)
return { ...response, data: response.data.map(legacyBadge) }
}
// 获取题单用户进度列表
export function getProblemSetUserProgress(
problemSetId: number,
params?: {
limit?: number
offset?: number
class_name?: string
completion_status?: "" | "completed" | "in_progress" | "not_started"
className?: string
completionStatus?: "" | "completed" | "in_progress" | "not_started"
},
) {
return legacyResponse(
api2.get(`problem-sets/${problemSetId}/user-progress`, {
params: {
limit: params?.limit,
offset: params?.offset,
className: params?.class_name,
completionStatus: params?.completion_status,
},
}),
return api2.get<ProblemSetProgressList>(
`problem-sets/${problemSetId}/user-progress`,
{ params },
)
}

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import type { ClassComparison } from "utils/types"
import { h } from "vue"
import { formatISO, sub, type Duration } from "date-fns"
import { getClassPK } from "oj/api"
@@ -46,32 +47,6 @@ const { isTeacherOrAbove } = useUserStore()
const message = useMessage()
const { isDesktop } = useBreakpoints()
interface ClassComparison {
class_name: string
user_count: number
total_ac: number
total_submission: number
avg_ac: number
median_ac: number
q1_ac: number
q3_ac: number
iqr: number
std_dev: number
top10_avg: number
middle80_avg: number
bottom10_avg: number
excellent_rate: number
pass_rate: number
active_rate: number
ac_rate: number
composite_score: number
recent_total_ac?: number
recent_avg_ac?: number
recent_median_ac?: number
recent_top10_avg?: number
recent_active_count?: number
}
const selectedClasses = ref<string[]>([])
const comparisons = ref<ClassComparison[]>([])
const duration = ref<string>("")
@@ -129,7 +104,7 @@ function getTimeRange(): {
const classOptions = computed(() => {
return (
configStore.config?.class_list.map((item) => ({
configStore.config?.classList.map((item) => ({
label: `${item.slice(0, 2)}计算机${item.slice(2)}`,
value: item,
})) ?? []
@@ -148,7 +123,7 @@ async function compare() {
const res = await getClassPK(selectedClasses.value, startTime, endTime)
comparisons.value = res.data.comparisons
hasTimeRange.value = res.data.has_time_range || false
hasTimeRange.value = res.data.hasTimeRange || false
} catch (error) {
message.error("获取数据失败")
} finally {
@@ -252,11 +227,11 @@ function getClassColor(index: number) {
const compositeScoreChartData = computed(() => {
if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.class_name)
const labels = comparisons.value.map((c) => c.className)
const datasets = [
{
label: "综合分",
data: comparisons.value.map((c) => c.composite_score),
data: comparisons.value.map((c) => c.compositeScore),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
@@ -270,11 +245,11 @@ const compositeScoreChartData = computed(() => {
const totalAcChartData = computed(() => {
if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.class_name)
const labels = comparisons.value.map((c) => c.className)
const datasets = [
{
label: "总AC数",
data: comparisons.value.map((c) => c.total_ac),
data: comparisons.value.map((c) => c.totalAc),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
@@ -288,11 +263,11 @@ const totalAcChartData = computed(() => {
const avgAcChartData = computed(() => {
if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.class_name)
const labels = comparisons.value.map((c) => c.className)
const datasets = [
{
label: "平均AC数",
data: comparisons.value.map((c) => c.avg_ac),
data: comparisons.value.map((c) => c.avgAc),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
@@ -306,11 +281,11 @@ const avgAcChartData = computed(() => {
const medianAcChartData = computed(() => {
if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.class_name)
const labels = comparisons.value.map((c) => c.className)
const datasets = [
{
label: "中位数AC数",
data: comparisons.value.map((c) => c.median_ac),
data: comparisons.value.map((c) => c.medianAc),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
@@ -324,11 +299,11 @@ const medianAcChartData = computed(() => {
const excellentRateChartData = computed(() => {
if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.class_name)
const labels = comparisons.value.map((c) => c.className)
const datasets = [
{
label: "优秀率",
data: comparisons.value.map((c) => c.excellent_rate),
data: comparisons.value.map((c) => c.excellentRate),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
@@ -342,11 +317,11 @@ const excellentRateChartData = computed(() => {
const passRateChartData = computed(() => {
if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.class_name)
const labels = comparisons.value.map((c) => c.className)
const datasets = [
{
label: "及格率",
data: comparisons.value.map((c) => c.pass_rate),
data: comparisons.value.map((c) => c.passRate),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
@@ -360,11 +335,11 @@ const passRateChartData = computed(() => {
const activeRateChartData = computed(() => {
if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.class_name)
const labels = comparisons.value.map((c) => c.className)
const datasets = [
{
label: "参与度",
data: comparisons.value.map((c) => c.active_rate),
data: comparisons.value.map((c) => c.activeRate),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
@@ -378,11 +353,11 @@ const activeRateChartData = computed(() => {
const top10AvgChartData = computed(() => {
if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.class_name)
const labels = comparisons.value.map((c) => c.className)
const datasets = [
{
label: "前10%平均",
data: comparisons.value.map((c) => c.top10_avg),
data: comparisons.value.map((c) => c.top10Avg),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
@@ -396,11 +371,11 @@ const top10AvgChartData = computed(() => {
const bottom10AvgChartData = computed(() => {
if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.class_name)
const labels = comparisons.value.map((c) => c.className)
const datasets = [
{
label: "后10%平均",
data: comparisons.value.map((c) => c.bottom10_avg),
data: comparisons.value.map((c) => c.bottom10Avg),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
@@ -414,11 +389,11 @@ const bottom10AvgChartData = computed(() => {
const middle80AvgChartData = computed(() => {
if (comparisons.value.length === 0) return null
const labels = comparisons.value.map((c) => c.class_name)
const labels = comparisons.value.map((c) => c.className)
const datasets = [
{
label: "中间80%均值",
data: comparisons.value.map((c) => c.middle80_avg),
data: comparisons.value.map((c) => c.middle80Avg),
backgroundColor: comparisons.value.map((_, i) => getClassColor(i).bg),
borderColor: comparisons.value.map((_, i) => getClassColor(i).border),
borderWidth: 2,
@@ -449,18 +424,18 @@ const radarChartData = computed(() => {
// 计算每个指标的最大最小值
const maxValues = [
Math.max(...comparisons.value.map((c) => c.total_ac)),
Math.max(...comparisons.value.map((c) => c.avg_ac)),
Math.max(...comparisons.value.map((c) => c.median_ac)),
Math.max(...comparisons.value.map((c) => c.totalAc)),
Math.max(...comparisons.value.map((c) => c.avgAc)),
Math.max(...comparisons.value.map((c) => c.medianAc)),
100, // 优秀率最大值
100, // 及格率最大值
100, // 参与度最大值
]
const minValues = [
Math.min(...comparisons.value.map((c) => c.total_ac)),
Math.min(...comparisons.value.map((c) => c.avg_ac)),
Math.min(...comparisons.value.map((c) => c.median_ac)),
Math.min(...comparisons.value.map((c) => c.totalAc)),
Math.min(...comparisons.value.map((c) => c.avgAc)),
Math.min(...comparisons.value.map((c) => c.medianAc)),
0,
0,
0,
@@ -469,22 +444,22 @@ const radarChartData = computed(() => {
const datasets = comparisons.value.map((c, index) => {
const color = getClassColor(index)
const rawData = [
c.total_ac,
c.avg_ac,
c.median_ac,
c.excellent_rate,
c.pass_rate,
c.active_rate,
c.totalAc,
c.avgAc,
c.medianAc,
c.excellentRate,
c.passRate,
c.activeRate,
]
return {
label: c.class_name,
label: c.className,
data: [
normalize(c.total_ac, maxValues[0], minValues[0]),
normalize(c.avg_ac, maxValues[1], minValues[1]),
normalize(c.median_ac, maxValues[2], minValues[2]),
c.excellent_rate,
c.pass_rate,
c.active_rate,
normalize(c.totalAc, maxValues[0], minValues[0]),
normalize(c.avgAc, maxValues[1], minValues[1]),
normalize(c.medianAc, maxValues[2], minValues[2]),
c.excellentRate,
c.passRate,
c.activeRate,
],
rawData,
backgroundColor: color.bg,
@@ -584,14 +559,14 @@ const tableColumns: DataTableColumn<ClassComparison>[] = [
fontSize: "15px",
},
},
row.composite_score.toFixed(1),
row.compositeScore.toFixed(1),
),
},
{
title: "班级",
key: "class_name",
render: (row) =>
`${row.class_name.slice(0, 2)}计算机${row.class_name.slice(2)}`,
`${row.className.slice(0, 2)}计算机${row.className.slice(2)}`,
width: 160,
},
{
@@ -602,7 +577,7 @@ const tableColumns: DataTableColumn<ClassComparison>[] = [
h(
"span",
{ style: { color: "#1890ff", fontWeight: "600" } },
row.user_count,
row.userCount,
),
},
{
@@ -613,7 +588,7 @@ const tableColumns: DataTableColumn<ClassComparison>[] = [
h(
"span",
{ style: { color: "#ff4d4f", fontWeight: "600" } },
row.total_ac,
row.totalAc,
),
},
{
@@ -624,7 +599,7 @@ const tableColumns: DataTableColumn<ClassComparison>[] = [
h(
"span",
{ style: { color: "#52c41a", fontWeight: "600" } },
row.avg_ac.toFixed(2),
row.avgAc.toFixed(2),
),
},
{
@@ -635,7 +610,7 @@ const tableColumns: DataTableColumn<ClassComparison>[] = [
h(
"span",
{ style: { color: "#fa8c16", fontWeight: "600" } },
row.median_ac.toFixed(2),
row.medianAc.toFixed(2),
),
},
{
@@ -646,7 +621,7 @@ const tableColumns: DataTableColumn<ClassComparison>[] = [
h(
"span",
{ style: { color: "#cf1322", fontWeight: "600" } },
row.top10_avg.toFixed(2),
row.top10Avg.toFixed(2),
),
},
{
@@ -657,7 +632,7 @@ const tableColumns: DataTableColumn<ClassComparison>[] = [
h(
"span",
{ style: { color: "#389e0d", fontWeight: "600" } },
row.middle80_avg.toFixed(2),
row.middle80Avg.toFixed(2),
),
},
{
@@ -668,7 +643,7 @@ const tableColumns: DataTableColumn<ClassComparison>[] = [
h(
"span",
{ style: { color: "#096dd9", fontWeight: "500" } },
row.bottom10_avg.toFixed(2),
row.bottom10Avg.toFixed(2),
),
},
{
@@ -679,7 +654,7 @@ const tableColumns: DataTableColumn<ClassComparison>[] = [
h(
"span",
{ style: { color: "#faad14", fontWeight: "600" } },
row.excellent_rate.toFixed(1) + "%",
row.excellentRate.toFixed(1) + "%",
),
},
{
@@ -690,7 +665,7 @@ const tableColumns: DataTableColumn<ClassComparison>[] = [
h(
"span",
{ style: { color: "#52c41a", fontWeight: "600" } },
row.pass_rate.toFixed(1) + "%",
row.passRate.toFixed(1) + "%",
),
},
{
@@ -701,7 +676,7 @@ const tableColumns: DataTableColumn<ClassComparison>[] = [
h(
"span",
{ style: { color: "#1890ff", fontWeight: "600" } },
row.active_rate.toFixed(1) + "%",
row.activeRate.toFixed(1) + "%",
),
},
]
@@ -827,11 +802,11 @@ const radarChartOptions = {
<n-grid v-if="comparisons.length > 0" :cols="2" :x-gap="16" :y-gap="16">
<n-gi
v-for="(classData, index) in comparisons"
:key="classData.class_name"
:key="classData.className"
:span="isDesktop ? 1 : 2"
>
<n-card
:title="`${classData.class_name.slice(0, 2)}计算机${classData.class_name.slice(2)}班`"
:title="`${classData.className.slice(0, 2)}计算机${classData.className.slice(2)}班`"
:bordered="true"
hoverable
:style="{
@@ -842,7 +817,7 @@ const radarChartOptions = {
<n-tag :type="getRankColor(index).type" size="large">
#{{ getRankColor(index).text }}
<span style="margin-left: 6px; font-size: 12px; opacity: 0.85">
{{ classData.composite_score }}
{{ classData.compositeScore }}
</span>
</n-tag>
</template>
@@ -854,7 +829,7 @@ const radarChartOptions = {
<n-gi>
<n-statistic
label="总AC数"
:value="classData.total_ac"
:value="classData.totalAc"
size="large"
class="stat-total-ac"
>
@@ -866,7 +841,7 @@ const radarChartOptions = {
<n-gi>
<n-statistic
label="平均AC数"
:value="classData.avg_ac.toFixed(2)"
:value="classData.avgAc.toFixed(2)"
size="large"
class="stat-avg-ac"
>
@@ -881,7 +856,7 @@ const radarChartOptions = {
<n-gi>
<n-statistic
label="中位数AC数"
:value="classData.median_ac.toFixed(2)"
:value="classData.medianAc.toFixed(2)"
size="large"
class="stat-median-ac"
>
@@ -896,7 +871,7 @@ const radarChartOptions = {
<n-gi>
<n-statistic
label="总提交数"
:value="classData.total_submission"
:value="classData.totalSubmission"
size="large"
class="stat-total-submission"
>
@@ -911,7 +886,7 @@ const radarChartOptions = {
<n-gi>
<n-statistic
label="AC率"
:value="classData.ac_rate.toFixed(1) + '%'"
:value="classData.acRate.toFixed(1) + '%'"
size="large"
class="stat-ac-rate"
>
@@ -934,12 +909,12 @@ const radarChartOptions = {
<!-- 分位数统计 -->
<n-descriptions-item label="第一四分位数(Q1)">
<span style="color: #9254de; font-weight: 500">{{
classData.q1_ac.toFixed(2)
classData.q1Ac.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="第三四分位数(Q3)">
<span style="color: #f759ab; font-weight: 500">{{
classData.q3_ac.toFixed(2)
classData.q3Ac.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="四分位距(IQR)">
@@ -949,31 +924,31 @@ const radarChartOptions = {
</n-descriptions-item>
<n-descriptions-item label="标准差">
<span style="color: #fa8c16; font-weight: 500">{{
classData.std_dev.toFixed(2)
classData.stdDev.toFixed(2)
}}</span>
</n-descriptions-item>
<!-- 分层统计 -->
<n-descriptions-item label="前10%均值">
<span style="color: #cf1322; font-weight: 600">{{
classData.top10_avg.toFixed(2)
classData.top10Avg.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="中间80%均值">
<span style="color: #389e0d; font-weight: 600">{{
classData.middle80_avg.toFixed(2)
classData.middle80Avg.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="后10%均值">
<span style="color: #096dd9; font-weight: 500">{{
classData.bottom10_avg.toFixed(2)
classData.bottom10Avg.toFixed(2)
}}</span>
</n-descriptions-item>
<!-- 人数 -->
<n-descriptions-item label="人数">
<span style="color: #1890ff; font-weight: 600">{{
classData.user_count
classData.userCount
}}</span>
</n-descriptions-item>
</n-descriptions>
@@ -988,34 +963,34 @@ const radarChartOptions = {
<n-space vertical :size="10">
<n-progress
type="line"
:percentage="classData.excellent_rate"
:percentage="classData.excellentRate"
:show-indicator="true"
:border-radius="4"
>
<template #default>
优秀率: {{ classData.excellent_rate.toFixed(1) }}%
优秀率: {{ classData.excellentRate.toFixed(1) }}%
</template>
</n-progress>
<n-progress
type="line"
:percentage="classData.pass_rate"
:percentage="classData.passRate"
:show-indicator="true"
:border-radius="4"
status="success"
>
<template #default>
及格率: {{ classData.pass_rate.toFixed(1) }}%
及格率: {{ classData.passRate.toFixed(1) }}%
</template>
</n-progress>
<n-progress
type="line"
:percentage="classData.active_rate"
:percentage="classData.activeRate"
:show-indicator="true"
:border-radius="4"
status="info"
>
<template #default>
参与度: {{ classData.active_rate.toFixed(1) }}%
参与度: {{ classData.activeRate.toFixed(1) }}%
</template>
</n-progress>
</n-space>
@@ -1023,7 +998,7 @@ const radarChartOptions = {
<!-- 时间段统计如果有 -->
<template
v-if="hasTimeRange && classData.recent_total_ac !== undefined"
v-if="hasTimeRange && classData.recentTotalAc !== undefined"
>
<n-descriptions
bordered
@@ -1034,27 +1009,27 @@ const radarChartOptions = {
>
<n-descriptions-item label="时间段总AC">
<span style="color: #ff7875; font-weight: 600">{{
classData.recent_total_ac
classData.recentTotalAc
}}</span>
</n-descriptions-item>
<n-descriptions-item label="时间段平均AC">
<span style="color: #73d13d; font-weight: 600">{{
classData.recent_avg_ac?.toFixed(2)
classData.recentAvgAc?.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="时间段中位数AC">
<span style="color: #ffc53d; font-weight: 600">{{
classData.recent_median_ac?.toFixed(2)
classData.recentMedianAc?.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="时间段前10名平均">
<span style="color: #ff4d4f; font-weight: 600">{{
classData.recent_top10_avg?.toFixed(2)
classData.recentTop10Avg?.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="活跃学生数" :span="2">
<span style="color: #1890ff; font-weight: 600">{{
classData.recent_active_count
classData.recentActiveCount
}}</span>
</n-descriptions-item>
</n-descriptions>

View File

@@ -16,9 +16,9 @@ function goto() {
</script>
<template>
{{ rank.accepted_number }} /
{{ rank.acceptedNumber }} /
<n-button text type="primary" @click="goto">
{{ rank.submission_number }}
{{ rank.submissionNumber }}
</n-button>
</template>

View File

@@ -24,18 +24,16 @@ const contestStore = useContestStore()
<div v-html="contestStore.contest.description"></div>
<n-descriptions bordered label-placement="left" :column="1">
<n-descriptions-item label="开始时间">
{{
parseTime(contestStore.contest.start_time, "YYYY年M月D日 HH:mm:ss")
}}
{{ parseTime(contestStore.contest.startTime, "YYYY年M月D日 HH:mm:ss") }}
</n-descriptions-item>
<n-descriptions-item label="结束时间">
{{ parseTime(contestStore.contest.end_time, "YYYY年M月D日 HH:mm:ss") }}
{{ parseTime(contestStore.contest.endTime, "YYYY年M月D日 HH:mm:ss") }}
</n-descriptions-item>
<n-descriptions-item label="比赛类型">
<ContestType :contest="contestStore.contest" />
</n-descriptions-item>
<n-descriptions-item label="发起人">
{{ contestStore.contest.created_by.username }}
{{ contestStore.contest.createdBy.username }}
</n-descriptions-item>
</n-descriptions>
</n-popover>

View File

@@ -78,7 +78,7 @@ const chartData = computed(() => {
// 收集所有AC事件并按时间排序
const events: AcEvent[] = []
topUsers.forEach((rank, userIndex) => {
Object.entries(rank.submission_info).forEach(([problemId, info]) => {
Object.entries(rank.submissionInfo).forEach(([problemId, info]) => {
if (info.is_ac) {
events.push({ time: info.ac_time, userIndex, problemId })
}
@@ -100,7 +100,7 @@ const chartData = computed(() => {
// 用于记录每个用户每道题的错误次数
const userErrors: Map<string, number>[] = topUsers.map(() => new Map())
topUsers.forEach((rank, i) => {
Object.entries(rank.submission_info).forEach(([problemId, info]) => {
Object.entries(rank.submissionInfo).forEach(([problemId, info]) => {
if (info.error_number > 0) {
userErrors[i].set(problemId, info.error_number)
}

View File

@@ -3,7 +3,7 @@ import { useRouteQuery } from "@vueuse/router"
import { NTag } from "naive-ui"
import { getContestList } from "oj/api"
import { duration, parseTime } from "utils/functions"
import type { Contest } from "utils/types"
import type { OjContest } from "utils/types"
import ContestTitle from "shared/components/ContestTitle.vue"
import Pagination from "shared/components/Pagination.vue"
import { useAuthModalStore } from "shared/store/authModal"
@@ -29,7 +29,7 @@ const { query, clearQuery } = usePagination<ContestQuery>({
tag: useRouteQuery("tag", "").value,
})
const data = ref<Contest[]>([])
const data = ref<OjContest[]>([])
const total = ref(0)
const options: SelectOption[] = [
@@ -46,7 +46,7 @@ const tags: SelectOption[] = [
{ label: "期末", value: "期末" },
]
const columns: DataTableColumn<Contest>[] = [
const columns: DataTableColumn<OjContest>[] = [
{
title: renderTableTitle("状态", "streamline-emojis:collision"),
key: "status",
@@ -74,13 +74,13 @@ const columns: DataTableColumn<Contest>[] = [
title: renderTableTitle("开始时间", "fluent-emoji-flat:eleven-thirty"),
key: "start_time",
width: 180,
render: (row) => parseTime(row.start_time),
render: (row) => parseTime(row.startTime),
},
{
title: renderTableTitle("比赛时长", "streamline-emojis:fishing-pole"),
key: "duration",
width: 180,
render: (row) => duration(row.start_time, row.end_time),
render: (row) => duration(row.startTime, row.endTime),
},
]
@@ -116,11 +116,11 @@ watchDebounced(() => query.keyword, listContests, {
// 监听其他查询条件变化
watch(() => [query.page, query.limit, query.status, query.tag], listContests)
function rowProps(row: Contest) {
function rowProps(row: OjContest) {
return {
style: "cursor: pointer",
onClick() {
if (!userStore.isAuthed && row.contest_type === ContestType.private) {
if (!userStore.isAuthed && row.contestType === ContestType.private) {
authStore.openLoginModal()
} else {
router.push("/contest/" + row.id)

View File

@@ -87,7 +87,7 @@ const columns = ref<DataTableColumn<ContestRank>[]>([
key: "total_time",
width: 120,
align: "center",
render: (row) => secondsToDuration(row.total_time),
render: (row) => secondsToDuration(row.totalTime),
},
])
@@ -129,8 +129,8 @@ async function addColumns() {
() => problem.title,
),
render: (row) => {
if (row.submission_info[problem.id]) {
const status = row.submission_info[problem.id]
if (row.submissionInfo[problem.id]) {
const status = row.submissionInfo[problem.id]
let acTime
let errorNumber
if (status.is_ac) {
@@ -162,8 +162,8 @@ async function addColumns() {
cellProps: (row) => {
let backgroundColor = ""
let color = theme.value.textColorBase
if (row.submission_info[problem.id]) {
const status = row.submission_info[problem.id]
if (row.submissionInfo[problem.id]) {
const status = row.submissionInfo[problem.id]
if (status.is_first_ac) {
backgroundColor = theme.value.primaryColor
color = theme.value.baseColor

View File

@@ -185,8 +185,8 @@ const goSubmissions = () => {
}
const goEdit = () => {
const url = problem.value!.contest
? `/admin/contest/${problem.value!.contest}/problem/edit/${problem.value!.id}`
const url = problem.value!.contestId
? `/admin/contest/${problem.value!.contestId}/problem/edit/${problem.value!.id}`
: `/admin/problem/edit/${problem.value!.id}`
window.open(router.resolve(url).href, "_blank")
}

View File

@@ -32,8 +32,8 @@ const { problem } = storeToRefs(problemStore)
const problemSetId = computed(() => route.params.problemSetId)
// SQL 题:隐藏输入/输出/例子,改为渲染数据表与期望结果
const isSQL = computed(() => !!problem.value?.sql_config)
const sqlDisplay = computed(() => problem.value?.sql_display ?? null)
const isSQL = computed(() => !!problem.value?.sqlConfig)
const sqlDisplay = computed(() => problem.value?.sqlDisplay ?? null)
const sqlExpectedQuery = computed(() => {
const exp = sqlDisplay.value?.expected
return exp && "columns" in exp ? exp : null
@@ -71,7 +71,7 @@ watch(
// AC 或失败次数 >= 3 时加载推荐
watch(
() => [problem.value?._id, problem.value?.my_status, problemStore.failCount],
() => [problem.value?._id, problem.value?.myStatus, problemStore.failCount],
([, status, failCount]) => {
if (status === 0 || (failCount as number) >= 3) {
loadSimilarProblems()
@@ -82,9 +82,9 @@ watch(
const hasTriedButNotPassed = computed(() => {
return (
problem.value?.my_status !== undefined &&
problem.value?.my_status !== null &&
problem.value?.my_status !== 0
problem.value?.myStatus !== undefined &&
problem.value?.myStatus !== null &&
problem.value?.myStatus !== 0
)
})
@@ -177,8 +177,8 @@ function ruleTagType(engine: string): "error" | "success" | "info" {
}
const astRulesForDisplay = computed(() => {
if (!problem.value?.ast_rules) return []
return Object.entries(problem.value.ast_rules).filter(
if (!problem.value?.astRules) return []
return Object.entries(problem.value.astRules).filter(
([, rules]) => rules.length > 0,
)
})
@@ -249,7 +249,7 @@ function type(status: ProblemStatus) {
<!-- 已通过 -->
<n-alert
class="status-alert"
v-if="problem.my_status === 0"
v-if="problem.myStatus === 0"
type="success"
title="🎉 本 题 已 经 被 你 解 决 啦"
>
@@ -291,7 +291,7 @@ function type(status: ProblemStatus) {
</p>
<MdPreview
preview-theme="vuepress"
:model-value="problem.input_description"
:model-value="problem.inputDescription"
:theme="isDark ? 'dark' : 'light'"
/>
@@ -303,7 +303,7 @@ function type(status: ProblemStatus) {
</p>
<MdPreview
preview-theme="vuepress"
:model-value="problem.output_description"
:model-value="problem.outputDescription"
:theme="isDark ? 'dark' : 'light'"
/>
</template>
@@ -338,7 +338,7 @@ function type(status: ProblemStatus) {
:total-rows="sqlExpectedQuery.total_rows"
:truncated="sqlExpectedQuery.truncated"
/>
<p v-if="!problem.sql_config?.order_sensitive" class="sqlNote">
<p v-if="!problem.sqlConfig?.order_sensitive" class="sqlNote">
结果顺序不限
</p>
</template>

View File

@@ -11,13 +11,13 @@ const { renderError, renderFlowchart } = useMermaid()
const renderProblemFlowchart = async () => {
await renderFlowchart(
mermaidContainer.value,
problem.value?.mermaid_code ?? "",
problem.value?.mermaidCode ?? "",
)
}
onMounted(renderProblemFlowchart)
watch(() => problem.value?.mermaid_code, renderProblemFlowchart)
watch(() => problem.value?.mermaidCode, renderProblemFlowchart)
</script>
<template>

View File

@@ -30,7 +30,7 @@ const beatRate = ref("0")
const yearlyACData = ref<YearlyACData[]>([])
const data = computed(() => {
const status = problem.value!.statistic_info
const status = problem.value!.statisticInfo
const labels = []
for (let i in status) {
if (status[i] !== 0) {
@@ -50,14 +50,14 @@ const numbers = computed(() => {
return [
{
icon: "streamline-ultimate-color:checklist",
title: problem.value?.submission_number ?? 0,
title: problem.value?.submissionNumber ?? 0,
content: "总提交",
int: true,
suffix: "",
},
{
icon: "streamline-emojis:woman-raising-hand-2",
title: problem.value?.accepted_number ?? 0,
title: problem.value?.acceptedNumber ?? 0,
content: "通过数",
int: true,
suffix: "",
@@ -65,8 +65,8 @@ const numbers = computed(() => {
{
icon: "fluent-emoji:chart-increasing",
title: getACRateNumber(
problem.value?.accepted_number ?? 0,
problem.value?.submission_number ?? 0,
problem.value?.acceptedNumber ?? 0,
problem.value?.submissionNumber ?? 0,
),
content: "通过率",
int: false,
@@ -115,10 +115,10 @@ onMounted(() => {
{{ problem._id }}
</n-descriptions-item>
<n-descriptions-item label="出题人">
{{ problem.created_by.username }}
{{ problem.createdBy.username }}
</n-descriptions-item>
<n-descriptions-item label="创建时间">
{{ parseTime(problem.create_time) }}
{{ parseTime(problem.createTime) }}
</n-descriptions-item>
<n-descriptions-item label="难度">
<n-tag :type="getTagColor(problem.difficulty)">
@@ -150,7 +150,7 @@ onMounted(() => {
</n-card>
</n-gi>
</n-grid>
<div class="pie" v-if="problem && problem.submission_number > 0">
<div class="pie" v-if="problem && problem.submissionNumber > 0">
<Pie :data="data" :options="options" />
</div>
<ProblemYearlyChart :data="yearlyACData" />

View File

@@ -10,17 +10,17 @@ defineProps<{
<n-flex align="center">
<span>{{ problem.title }}</span>
<Icon
v-if="problem.allow_flowchart"
v-if="problem.allowFlowchart"
width="18"
icon="vscode-icons:file-type-drawio"
/>
<Icon
v-else-if="problem.show_flowchart"
v-else-if="problem.showFlowchart"
width="18"
icon="vscode-icons:file-type-graphql"
/>
<Icon
v-if="problem.has_ast_rules"
v-if="problem.hasAstRules"
width="18"
icon="vscode-icons:file-type-light-todo"
/>

View File

@@ -80,7 +80,7 @@ const wheelItems = REACTIONS.map((item, index) => ({
style: getWheelItemStyle(index),
}))
const solved = computed(() => problem.value?.my_status === 0)
const solved = computed(() => problem.value?.myStatus === 0)
const locked = computed(() => mine.value !== null)
const canInteract = computed(
() =>

View File

@@ -8,7 +8,7 @@ import { useUserStore } from "shared/store/user"
import { JUDGE_STATUS, LANGUAGE_SHOW_VALUE } from "utils/constants"
import { parseTime } from "utils/functions"
import { renderTableTitle } from "utils/renders"
import type { Submission } from "utils/types"
import type { SubmissionListItem } from "utils/types"
import SubmissionDetail from "oj/submission/detail.vue"
import { useBreakpoints } from "shared/composables/breakpoints"
@@ -29,19 +29,19 @@ function showCodePanel(id: string, problem: string) {
toggleCodePanel(true)
}
const columns: DataTableColumn<Submission>[] = [
const columns: DataTableColumn<SubmissionListItem>[] = [
{
title: renderTableTitle("提交时间", "fluent-emoji:seven-oclock"),
key: "create_time",
width: 200,
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss"),
},
{
title: renderTableTitle("编号", "fluent-emoji-flat:input-numbers"),
key: "id",
minWidth: 160,
render: (row) => {
if (!row.show_link)
if (!row.showLink)
return h(NFlex, { align: "center" }, () => [
h("span", row.id.slice(0, 12)),
h(
@@ -90,7 +90,7 @@ const class_ac_count = ref(0)
const all_ac_count = ref(0)
const loading = ref(false)
const submissions = ref<Submission[]>([])
const submissions = ref<SubmissionListItem[]>([])
const total = ref(0)
const query = reactive({
limit: 10,
@@ -126,8 +126,8 @@ async function listSubmissions() {
...query,
myself: "1",
offset,
problem_id: (route.params.problemID as string) ?? "",
contest_id: (route.params.contestID as string) ?? "",
problemId: (route.params.problemID as string) ?? "",
contestId: (route.params.contestID as string) ?? "",
})
submissions.value = res.data.results
total.value = res.data.total
@@ -138,10 +138,10 @@ async function getRankOfThisProblem() {
const res = await getRankOfProblem((route.params.problemID as string) ?? "")
loading.value = false
class_name.value = res.data.class_name
class_name.value = res.data.className
rank.value = res.data.rank
class_ac_count.value = res.data.class_ac_count
all_ac_count.value = res.data.all_ac_count
class_ac_count.value = res.data.classAcCount
all_ac_count.value = res.data.allAcCount
}
onMounted(() => {

View File

@@ -35,7 +35,7 @@ const chartData = computed(() => ({
datasets: [
{
label: "AC 率",
data: props.data.map((d) => d.ac_rate),
data: props.data.map((d) => d.acRate),
fill: true,
tension: 0.3,
backgroundColor: "rgba(99, 179, 237, 0.2)",
@@ -58,7 +58,7 @@ const chartOptions = computed(() => ({
callbacks: {
label: (context: any) => {
const d = props.data[context.dataIndex]
return [`AC 率: ${d.ac_rate}%`, `通过: ${d.accepted} / ${d.total}`]
return [`AC 率: ${d.acRate}%`, `通过: ${d.accepted} / ${d.total}`]
},
},
},

View File

@@ -47,9 +47,9 @@ const msg = computed(() => {
if (
result !== SubmissionStatus.ast_check_failed &&
props.submission.statistic_info?.err_info
props.submission.statisticInfo?.err_info
) {
msg += props.submission.statistic_info.err_info
msg += props.submission.statisticInfo.err_info
}
return msg
@@ -161,15 +161,13 @@ const columns: DataTableColumn<Submission["info"]["data"][number]>[] = [
<n-flex
vertical
v-if="
msg ||
infoTable.length ||
submission.statistic_info?.ast_results?.length
msg || infoTable.length || submission.statisticInfo?.ast_results?.length
"
>
<n-card v-if="submission.statistic_info?.ast_results?.length" embedded>
<n-card v-if="submission.statisticInfo?.ast_results?.length" embedded>
<n-flex vertical :size="8">
<n-flex
v-for="(rule, i) in submission.statistic_info.ast_results"
v-for="(rule, i) in submission.statisticInfo.ast_results"
:key="i"
align="center"
:size="6"

View File

@@ -130,22 +130,22 @@ async function submit() {
// 1. 构建提交数据
const data: SubmitCodePayload = {
problem_id: problem.value!.id,
problemId: problem.value!.id,
language: codeStore.code.language,
code: codeStore.code.value,
}
if (contestID) {
data.contest_id = parseInt(contestID)
data.contestId = parseInt(contestID)
}
// 2. 提交代码到后端
isSubmittingRequest.value = true
try {
const res = await submitCode(data)
console.log(`[Submit] 代码已提交: ID=${res.data.submission_id}`)
console.log(`[Submit] 代码已提交: ID=${res.data.submissionId}`)
// 3. 启动冷却 + 监控
startCooldown()
startMonitoring(res.data.submission_id)
startMonitoring(res.data.submissionId)
showResult.value = true
} finally {
isSubmittingRequest.value = false
@@ -183,7 +183,7 @@ watch(
return
// 1. 刷新题目状态
problem.value!.my_status = 0
problem.value!.myStatus = 0
// 2. 创建ProblemSetSubmission记录更新题单进度
if (problemSetId) {

View File

@@ -135,16 +135,16 @@ async function submitFlowchartData() {
try {
const response = await submitFlowchart({
problem_id: problem.value!.id,
mermaid_code: mermaidCode,
flowchart_data: {
problemId: problem.value!.id,
mermaidCode,
flowchartData: {
compressed: true,
data: compressed,
},
})
// 获取提交ID并订阅更新
const submissionId = response.data.submission_id
const submissionId = response.data.submissionId
if (submissionId) {
subscribeToSubmission(submissionId)
@@ -183,18 +183,34 @@ async function getSubmission(submissionPage = 0) {
)
submissionCount.value = data.count
const submission = data.submission
myFlowchartZippedStr.value = submission.flowchart_data.data
myMermaidCode.value = submission.mermaid_code || ""
// 翻到没有提交的页时后端返回 null契约里 submission 是 nullable——
// 原来的 any 让这里看起来非空,真翻到那一页会直接抛
if (!submission) {
myFlowchartZippedStr.value = ""
myMermaidCode.value = ""
modalRating.value = { score: 0, grade: "" }
evaluation.value = {
score: 0,
grade: "",
feedback: "",
suggestions: "",
criteria_details: {},
}
return
}
myFlowchartZippedStr.value = String(submission.flowchartData.data ?? "")
myMermaidCode.value = submission.mermaidCode || ""
modalRating.value = {
score: submission.ai_score,
grade: submission.ai_grade,
score: submission.aiScore ?? 0,
grade: (submission.aiGrade ?? "") as Rating["grade"],
}
evaluation.value = {
score: submission.ai_score,
grade: submission.ai_grade,
feedback: submission.ai_feedback,
suggestions: submission.ai_suggestions,
criteria_details: submission.ai_criteria_details,
score: submission.aiScore ?? 0,
grade: (submission.aiGrade ?? "") as Rating["grade"],
feedback: submission.aiFeedback ?? "",
suggestions: submission.aiSuggestions ?? "",
criteria_details:
submission.aiCriteriaDetails as Evaluation["criteria_details"],
}
}

View File

@@ -55,7 +55,7 @@ const { isMobile, isDesktop } = useBreakpoints()
const tabOptions = computed(() => {
const options: string[] = ["content"]
if (problem.value?.show_flowchart) {
if (problem.value?.showFlowchart) {
options.push("flowchart")
}
@@ -113,7 +113,7 @@ async function init() {
problem.value = res.data
} catch (err: any) {
problem.value = null
if (err.data === "Contest has not started yet.") {
if (err.error === "contest-not-started") {
errMsg.value = "比赛还没有开始"
}
}
@@ -159,7 +159,7 @@ watch(
<ProblemContent />
</n-tab-pane>
<n-tab-pane
v-if="problem.show_flowchart && problem.mermaid_code"
v-if="problem.showFlowchart && problem.mermaidCode"
name="flowchart"
tab="流程图表"
>
@@ -211,7 +211,7 @@ watch(
<ProblemContent />
</n-tab-pane>
<n-tab-pane
v-if="problem.show_flowchart && problem.mermaid_code"
v-if="problem.showFlowchart && problem.mermaidCode"
name="flowchart"
tab="流程图表"
>
@@ -251,7 +251,7 @@ watch(
<n-tab-pane name="content" tab="描述">
<ProblemContent />
</n-tab-pane>
<n-tab-pane v-if="problem.show_flowchart" name="flowchart" tab="流程">
<n-tab-pane v-if="problem.showFlowchart" name="flowchart" tab="流程">
<ProblemFlowchart />
</n-tab-pane>
<n-tab-pane name="editor" tab="代码">

View File

@@ -35,7 +35,8 @@ function getDifficultyTag(difficulty: string) {
function getProgressPercentage() {
if (!props.problemSet) return 0
return Math.round(
(props.problemSet.completed_count / props.problemSet.problems_count) * 100,
((props.problemSet.completedCount ?? 0) / props.problemSet.problemsCount) *
100,
)
}
@@ -78,7 +79,7 @@ function handleJoin() {
<n-flex align="center" v-if="isJoined">
<n-text strong>完成进度</n-text>
<n-text>
{{ problemSet.completed_count }} / {{ problemSet.problems_count }}
{{ problemSet.completedCount }} / {{ problemSet.problemsCount }}
</n-text>
</n-flex>
<n-progress

View File

@@ -41,7 +41,7 @@ function handleProblemClick(problemId: string) {
style="margin-right: 10px"
width="48"
icon="fluent-emoji:check-mark-button"
v-if="problemSetProblem.is_completed"
v-if="problemSetProblem.isCompleted"
/>
<n-flex vertical style="flex: 1">
@@ -60,7 +60,7 @@ function handleProblemClick(problemId: string) {
{{ DIFFICULTY[problemSetProblem.problem.difficulty] }}
</n-tag>
<n-text type="info">分数{{ problemSetProblem.score }}</n-text>
<n-text v-if="!problemSetProblem.is_required">选做</n-text>
<n-text v-if="!problemSetProblem.isRequired">选做</n-text>
</n-flex>
</n-flex>
</n-flex>

View File

@@ -16,7 +16,7 @@ const total = ref(0)
const statistics = ref<{
total: number
completed: number
avg_progress: number
avgProgress: number
} | null>(null)
const classFilter = ref<string>("")
const completionFilter = ref<"" | "completed" | "in_progress" | "not_started">(
@@ -42,17 +42,17 @@ async function loadUserProgress() {
const params: {
limit?: number
offset?: number
class_name?: string
completion_status?: "" | "completed" | "in_progress" | "not_started"
className?: string
completionStatus?: "" | "completed" | "in_progress" | "not_started"
} = {
limit: query.limit,
offset,
}
if (classFilter.value.trim()) {
params.class_name = classFilter.value.trim()
params.className = classFilter.value.trim()
}
if (completionFilter.value) {
params.completion_status = completionFilter.value
params.completionStatus = completionFilter.value
}
const res = await getProblemSetUserProgress(problemSetId.value, params)
@@ -92,7 +92,7 @@ const stats = computed(() => {
return {
total: statistics.value.total,
completed: statistics.value.completed,
avgProgress: Math.round(statistics.value.avg_progress),
avgProgress: Math.round(statistics.value.avgProgress),
}
}
// 如果后端还没有返回统计数据,使用默认值
@@ -128,7 +128,7 @@ const progressColumns = [
key: "join_time",
width: 180,
render: (row: ProblemSetProgress) =>
parseTime(row.join_time, "YYYY-MM-DD HH:mm:ss"),
parseTime(row.joinTime, "YYYY-MM-DD HH:mm:ss"),
},
{
title: "已完成数量",
@@ -140,12 +140,12 @@ const progressColumns = [
key: "completed_problems",
width: 300,
render: (row: ProblemSetProgress) => {
if (row.progress_percentage === 100) {
if (row.progressPercentage === 100) {
return "全部题目已完成"
}
if (row.progress_percentage > 50 && row.progress_percentage < 100) {
if (row.progressPercentage > 50 && row.progressPercentage < 100) {
const completedProblemIds = new Set(
row.completed_problems.map((p: any) => p.id),
row.completedProblems.map((p: any) => p.id),
)
const incompleteProblems = allProblems.value.filter(
(p) => !completedProblemIds.has(p.id),
@@ -164,7 +164,7 @@ const progressColumns = [
}
return h("div", { style: "max-height: 120px; overflow-y: auto" }, [
h(NFlex, {}, () =>
row.completed_problems.map((problem: any) =>
row.completedProblems.map((problem: any) =>
h(
NTag,
{
@@ -184,7 +184,7 @@ const progressColumns = [
key: "progress_percentage",
width: 120,
render: (row: ProblemSetProgress) => {
return `${row.progress_percentage.toFixed(0)}%`
return `${row.progressPercentage.toFixed(0)}%`
},
},
{
@@ -192,7 +192,7 @@ const progressColumns = [
key: "is_completed",
width: 100,
render: (row: ProblemSetProgress) => {
if (row.is_completed) {
if (row.isCompleted) {
return h(NTag, { type: "success" }, () => "已完成")
} else {
return h(NTag, { type: "warning" }, () => "进行中")

View File

@@ -35,7 +35,7 @@ const activeTab = ref("problems")
async function loadProblemSetDetail() {
const res = await getProblemSetDetail(problemSetId.value)
problemSet.value = res.data
isJoined.value = res.data.user_progress?.is_joined || false
isJoined.value = res.data.userProgress?.isJoined || false
}
async function loadProblems() {
@@ -48,14 +48,14 @@ async function loadUserBadges() {
const res = await getUserBadges()
userBadges.value = res.data.filter(
(badge: UserBadgeType) => badge.badge.problemset === problemSetId.value,
(badge: UserBadgeType) => badge.badge.problemsetId === problemSetId.value,
)
}
async function init() {
await Promise.all([loadProblemSetDetail(), loadProblems()])
if (isJoined.value) {
if (problemSet.value?.user_progress?.is_completed) {
if (problemSet.value?.userProgress?.isCompleted) {
celebrate()
}
loadUserBadges()
@@ -100,7 +100,7 @@ async function handleJoinProblemSet() {
const showTabs = computed(
() =>
userStore.isSuperAdmin ||
(isJoined.value && problemSet.value?.user_progress?.is_completed),
(isJoined.value && problemSet.value?.userProgress?.isCompleted),
)
onMounted(init)

View File

@@ -3,7 +3,7 @@ import { Icon } from "@iconify/vue"
import { useRouteQuery } from "@vueuse/router"
import { getProblemSetList } from "../api"
import { parseTime } from "utils/functions"
import type { ProblemSetList } from "utils/types"
import type { ProblemSet } from "utils/types"
import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination"
import { useBreakpoints } from "shared/composables/breakpoints"
@@ -12,7 +12,7 @@ const router = useRouter()
const { isDesktop } = useBreakpoints()
const total = ref(0)
const problemSets = ref<ProblemSetList[]>([])
const problemSets = ref<ProblemSet[]>([])
interface ProblemSetQuery {
keyword: string
@@ -164,27 +164,25 @@ watch(
<n-flex justify="space-between" align="center">
<n-flex>
<Icon width="20" icon="streamline-emojis:blossom" />
<n-text>{{ problemSet.problems_count }} 道题目</n-text>
<n-text>{{ problemSet.problemsCount }} 道题目</n-text>
</n-flex>
<n-flex align="center" style="height: 28px">
<!-- 用户进度显示 -->
<n-progress
v-if="
problemSet.user_progress?.is_joined &&
!problemSet.user_progress?.is_completed
problemSet.userProgress?.isJoined &&
!problemSet.userProgress?.isCompleted
"
type="line"
:percentage="
Math.round(problemSet.user_progress.progress_percentage)
Math.round(problemSet.userProgress.progressPercentage)
"
:height="4"
:border-radius="2"
style="width: 100px"
:color="
getProgressColor(
problemSet.user_progress.progress_percentage,
)
getProgressColor(problemSet.userProgress.progressPercentage)
"
/>
<n-tag type="warning" v-if="problemSet.status === 'archived'">
@@ -192,17 +190,14 @@ watch(
</n-tag>
<n-tag
v-if="
problemSet.user_progress?.is_joined &&
!problemSet.user_progress?.is_completed
problemSet.userProgress?.isJoined &&
!problemSet.userProgress?.isCompleted
"
type="warning"
>
已加入
</n-tag>
<n-tag
v-if="problemSet.user_progress?.is_completed"
type="error"
>
<n-tag v-if="problemSet.userProgress?.isCompleted" type="error">
已完成
</n-tag>
</n-flex>
@@ -212,7 +207,7 @@ watch(
<n-flex align="center" justify="space-between">
<n-text depth="3">
创建于
{{ parseTime(problemSet.create_time, "YYYY-MM-DD") }}
{{ parseTime(problemSet.createTime, "YYYY-MM-DD") }}
</n-text>
<n-flex>
<n-tooltip
@@ -227,7 +222,7 @@ watch(
width="24"
height="24"
object-fit="cover"
:class="{ 'earned-badge': badge.is_earned }"
:class="{ 'earned-badge': badge.isEarned }"
/>
</template>
<n-flex vertical size="small">
@@ -238,12 +233,12 @@ watch(
获取条件:
{{
getConditionText(
badge.condition_type,
badge.condition_value,
badge.conditionType,
badge.conditionValue,
)
}}
</span>
<n-text type="primary" v-if="badge.is_earned">
<n-text type="primary" v-if="badge.isEarned">
✓ 已获得
</n-text>
</n-flex>

View File

@@ -31,7 +31,7 @@ const data = computed(() => {
const datasets: any[] = [
{
label: props.type === ChartType.Rank ? "已解决" : "做题数",
data: props.rankData.map((rank) => rank.accepted_number),
data: props.rankData.map((rank) => rank.acceptedNumber),
backgroundColor: [
"rgba(255, 99, 132, 0.2)",
"rgba(255, 159, 64, 0.2)",
@@ -87,7 +87,7 @@ const data = computed(() => {
if (props.type === ChartType.Rank) {
datasets.push({
label: "总提交数",
data: props.rankData.map((rank) => rank.submission_number),
data: props.rankData.map((rank) => rank.submissionNumber),
hidden: true,
})
}

View File

@@ -1,4 +1,10 @@
<script setup lang="ts">
import type {
ClassComparison,
ClassRankItem as ClassRank,
ClassUserRank,
Rank,
} from "utils/types"
import { formatISO, sub, type Duration } from "date-fns"
import { NButton, NFlex } from "naive-ui"
import {
@@ -10,7 +16,6 @@ import {
} from "oj/api"
import { useBreakpoints } from "shared/composables/breakpoints"
import { getACRate, getCSRFToken } from "utils/functions"
import type { Rank } from "utils/types"
import Pagination from "shared/components/Pagination.vue"
import { ChartType } from "utils/constants"
import { renderTableTitle } from "utils/renders"
@@ -39,6 +44,7 @@ const query = reactive({
limit: 10,
page: 1,
})
const message = useMessage()
const rankChart = ref<Rank[]>([])
const activityChart = ref<Rank[]>([])
const duration = ref("months:1")
@@ -46,7 +52,7 @@ const classData = ref<ClassRank[]>([])
const classQuery = reactive({
grade: gradeOptions[0].value,
})
const myClassData = ref<UserRank[]>([])
const myClassData = ref<ClassUserRank["ranks"]>([])
const myRank = ref(-1)
const myClassName = ref("")
const myClassScope = ref<"window" | "all">("window")
@@ -137,44 +143,6 @@ async function analyzeSingleClassWithAI() {
}
}
interface ClassRank {
rank: number
class_name: string
user_count: number
total_ac: number
total_submission: number
avg_ac: number
ac_rate: number
}
interface ClassComparison {
class_name: string
user_count: number
total_ac: number
total_submission: number
avg_ac: number
median_ac: number
q1_ac: number
q3_ac: number
iqr: number
std_dev: number
top10_avg: number
middle80_avg: number
bottom10_avg: number
excellent_rate: number
pass_rate: number
active_rate: number
ac_rate: number
composite_score: number
}
interface UserRank {
rank: number
username: string
accepted_number: number
submission_number: number
}
async function init() {
const offset = (query.page - 1) * query.limit
const res = await getRank(offset, query.limit, 100)
@@ -251,7 +219,7 @@ const columns: DataTableColumn<Rank>[] = [
key: "rate",
width: 120,
align: "center",
render: (row) => getACRate(row.accepted_number, row.submission_number),
render: (row) => getACRate(row.acceptedNumber, row.submissionNumber),
},
]
@@ -269,15 +237,14 @@ async function listActivity() {
const current = Date.now()
const start = formatISO(sub(current, subOptions.value))
const res = await getActivityRank(start)
activityChart.value = res.data.map(
(d: { username: string; count: number }) => ({
user: {
username: d.username,
},
accepted_number: d.count,
submission_number: 0,
}),
)
// 活动榜只有「用户名 + 做题数」,塞进榜单图表复用的 Rank 形状里
activityChart.value = res.data.map((d, index) => ({
id: index,
user: { id: index, username: d.username, realName: null },
acceptedNumber: d.count,
submissionNumber: 0,
mood: null,
}))
}
async function listRank() {
@@ -321,7 +288,7 @@ const classColumns: DataTableColumn<ClassRank>[] = [
title: "班级",
key: "class_name",
render: (row) =>
`${row.class_name.slice(0, 2)}计算机${row.class_name.slice(2)}`,
`${row.className.slice(0, 2)}计算机${row.className.slice(2)}`,
minWidth: 120,
titleAlign: "center",
align: "center",
@@ -360,7 +327,7 @@ const classColumns: DataTableColumn<ClassRank>[] = [
width: 90,
titleAlign: "center",
align: "center",
render: (row) => `${row.ac_rate}%`,
render: (row) => `${row.acRate}%`,
},
{
title: "详情",
@@ -374,14 +341,14 @@ const classColumns: DataTableColumn<ClassRank>[] = [
{
text: true,
type: "info",
onClick: () => loadClassDetail(row.class_name),
onClick: () => loadClassDetail(row.className),
},
() => "查看",
),
},
]
const myClassColumns: DataTableColumn<UserRank>[] = [
const myClassColumns: DataTableColumn<ClassUserRank["ranks"][number]>[] = [
{
title: "排名",
key: "rank",
@@ -448,7 +415,7 @@ async function listClassRank() {
if (!userStore.user) {
await userStore.getMyProfile()
}
const className = userStore.user?.class_name
const className = userStore.user?.className
if (className) {
classQuery.grade = parseInt(className.slice(0, 2))
}
@@ -464,8 +431,8 @@ async function listMyClassRank() {
: 0
const limit = myClassScope.value === "all" ? myClassQuery.limit : undefined
const res = await getUserClassRank(myClassScope.value, offset, limit)
myRank.value = res.data.my_rank
myClassName.value = res.data.class_name
myRank.value = res.data.myRank
myClassName.value = res.data.className
myClassData.value = res.data.ranks
myClassTotal.value = res.data.total ?? res.data.ranks.length
if (myClassScope.value === "window") {
@@ -610,7 +577,7 @@ watch(
preset="card"
:title="
classDetailData
? `${classDetailData.class_name.slice(0, 2)}计算机${classDetailData.class_name.slice(2)}班`
? `${classDetailData.className.slice(0, 2)}计算机${classDetailData.className.slice(2)}班`
: '班级详情'
"
:style="{ width: '700px', maxWidth: '95vw' }"
@@ -621,7 +588,7 @@ watch(
<n-gi>
<n-statistic
label="总AC数"
:value="classDetailData.total_ac"
:value="classDetailData.totalAc"
size="large"
class="stat-total-ac"
>
@@ -633,7 +600,7 @@ watch(
<n-gi>
<n-statistic
label="平均AC数"
:value="classDetailData.avg_ac.toFixed(2)"
:value="classDetailData.avgAc.toFixed(2)"
size="large"
class="stat-avg-ac"
>
@@ -648,7 +615,7 @@ watch(
<n-gi>
<n-statistic
label="中位数AC数"
:value="classDetailData.median_ac.toFixed(2)"
:value="classDetailData.medianAc.toFixed(2)"
size="large"
class="stat-median-ac"
>
@@ -663,7 +630,7 @@ watch(
<n-gi>
<n-statistic
label="总提交数"
:value="classDetailData.total_submission"
:value="classDetailData.totalSubmission"
size="large"
class="stat-total-submission"
>
@@ -678,7 +645,7 @@ watch(
<n-gi>
<n-statistic
label="AC率"
:value="classDetailData.ac_rate.toFixed(1) + '%'"
:value="classDetailData.acRate.toFixed(1) + '%'"
size="large"
class="stat-ac-rate"
>
@@ -699,12 +666,12 @@ watch(
>
<n-descriptions-item label="第一四分位数(Q1)">
<span style="color: #9254de; font-weight: 500">{{
classDetailData.q1_ac.toFixed(2)
classDetailData.q1Ac.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="第三四分位数(Q3)">
<span style="color: #f759ab; font-weight: 500">{{
classDetailData.q3_ac.toFixed(2)
classDetailData.q3Ac.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="四分位距(IQR)">
@@ -714,27 +681,27 @@ watch(
</n-descriptions-item>
<n-descriptions-item label="标准差">
<span style="color: #fa8c16; font-weight: 500">{{
classDetailData.std_dev.toFixed(2)
classDetailData.stdDev.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="前10%均值">
<span style="color: #cf1322; font-weight: 600">{{
classDetailData.top10_avg.toFixed(2)
classDetailData.top10Avg.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="中间80%均值">
<span style="color: #389e0d; font-weight: 600">{{
classDetailData.middle80_avg.toFixed(2)
classDetailData.middle80Avg.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="后10%均值">
<span style="color: #096dd9; font-weight: 500">{{
classDetailData.bottom10_avg.toFixed(2)
classDetailData.bottom10Avg.toFixed(2)
}}</span>
</n-descriptions-item>
<n-descriptions-item label="人数">
<span style="color: #1890ff; font-weight: 600">{{
classDetailData.user_count
classDetailData.userCount
}}</span>
</n-descriptions-item>
</n-descriptions>
@@ -743,35 +710,35 @@ watch(
<n-space vertical :size="10">
<n-progress
type="line"
:percentage="classDetailData.excellent_rate"
:percentage="classDetailData.excellentRate"
:show-indicator="true"
:border-radius="4"
>
<template #default
>优秀率:
{{ classDetailData.excellent_rate.toFixed(1) }}%</template
{{ classDetailData.excellentRate.toFixed(1) }}%</template
>
</n-progress>
<n-progress
type="line"
:percentage="classDetailData.pass_rate"
:percentage="classDetailData.passRate"
:show-indicator="true"
:border-radius="4"
status="success"
>
<template #default
>及格率: {{ classDetailData.pass_rate.toFixed(1) }}%</template
>及格率: {{ classDetailData.passRate.toFixed(1) }}%</template
>
</n-progress>
<n-progress
type="line"
:percentage="classDetailData.active_rate"
:percentage="classDetailData.activeRate"
:show-indicator="true"
:border-radius="4"
status="info"
>
<template #default
>参与度: {{ classDetailData.active_rate.toFixed(1) }}%</template
>参与度: {{ classDetailData.activeRate.toFixed(1) }}%</template
>
</n-progress>
</n-space>
@@ -784,7 +751,7 @@ watch(
style="margin-top: 12px"
>
<n-tag type="success" size="large">
综合分: {{ classDetailData.composite_score.toFixed(1) }}
综合分: {{ classDetailData.compositeScore.toFixed(1) }}
</n-tag>
<n-button
type="info"

View File

@@ -13,13 +13,14 @@ export const useAIStore = defineStore("ai", () => {
const targetUsername = ref("")
const durationData = ref<DurationData[]>([])
const detailsData = reactive<DetailsData>({
user: "",
start: "",
end: "",
grade: "B",
class_name: "",
grade: "",
className: null,
tags: {},
difficulty: {},
contest_count: 0,
contestCount: 0,
solved: [],
flowcharts: [],
})
@@ -44,10 +45,10 @@ export const useAIStore = defineStore("ai", () => {
detailsData.end = res.data.end
detailsData.solved = res.data.solved
detailsData.grade = res.data.grade
detailsData.class_name = res.data.class_name
detailsData.className = res.data.className
detailsData.tags = res.data.tags
detailsData.difficulty = res.data.difficulty
detailsData.contest_count = res.data.contest_count
detailsData.contestCount = res.data.contestCount
detailsData.flowcharts = res.data.flowcharts
}

View File

@@ -21,8 +21,8 @@ export const useContestStore = defineStore("contest", () => {
const contestStatus = computed<ContestStatus>(() => {
if (!contest.value) return ContestStatus.initial
const start = getTime(parseISO(contest.value.start_time.toString()))
const end = getTime(parseISO(contest.value.end_time.toString()))
const start = getTime(parseISO(contest.value.startTime.toString()))
const end = getTime(parseISO(contest.value.endTime.toString()))
if (start > now.value) {
return ContestStatus.not_started
} else if (end < now.value) {
@@ -36,10 +36,10 @@ export const useContestStore = defineStore("contest", () => {
if (contestStatus.value === ContestStatus.finished) {
return "已结束"
} else if (contestStatus.value === ContestStatus.not_started) {
const d = duration(formatISO(now.value), contest.value!.start_time, true)
const d = duration(formatISO(now.value), contest.value!.startTime, true)
return "距离比赛开始 " + d
} else {
const d = duration(formatISO(now.value), contest.value!.end_time, true)
const d = duration(formatISO(now.value), contest.value!.endTime, true)
return "距离比赛结束 " + d
}
})
@@ -48,11 +48,11 @@ export const useContestStore = defineStore("contest", () => {
() =>
userStore.isSuperAdmin ||
(userStore.isAuthed &&
contest.value?.created_by.id === userStore.user!.id),
contest.value?.createdBy.id === userStore.user!.id),
)
const isPrivate = computed(
() => contest.value!.contest_type === ContestType.private,
() => contest.value!.contestType === ContestType.private,
)
async function init(contestID: string) {
@@ -65,7 +65,7 @@ export const useContestStore = defineStore("contest", () => {
now.value = now.value + 1000
}, 1000)
}
if (contest.value?.contest_type === ContestType.private) {
if (contest.value?.contestType === ContestType.private) {
const res = await getContestAccess(contestID)
toggleAccess(res.data.access)
}

View File

@@ -8,7 +8,7 @@ export const useProblemStore = defineStore("problem", () => {
const failCount = ref(0)
const languages = computed<LANGUAGE[]>(() => {
if (route.name === "problem" && problem.value?.allow_flowchart) {
if (route.name === "problem" && problem.value?.allowFlowchart) {
return ["Flowchart", ...problem.value?.languages]
}
return problem.value?.languages ?? []

View File

@@ -5,7 +5,7 @@
<n-card title="流程图预览">
<template #header-extra>
<n-button
v-if="!renderError && submission?.mermaid_code"
v-if="!renderError && submission?.mermaidCode"
quaternary
size="small"
@click="showLargeImage = true"
@@ -42,12 +42,12 @@
<n-gi :span="2">
<!-- AI反馈 -->
<n-card
v-if="submission.ai_feedback"
v-if="submission.aiFeedback"
size="small"
title="AI反馈"
style="margin-bottom: 16px"
>
<n-text>{{ submission.ai_feedback }}</n-text>
<n-text>{{ submission.aiFeedback }}</n-text>
</n-card>
<!-- 改进建议 -->
@@ -69,15 +69,12 @@
<!-- 详细评分 -->
<n-card
v-if="
submission.ai_criteria_details &&
Object.keys(submission.ai_criteria_details).length > 0
"
v-if="Object.keys(criteriaDetails).length > 0"
size="small"
title="详细评分"
>
<div
v-for="(detail, key) in submission.ai_criteria_details"
v-for="(detail, key) in criteriaDetails"
:key="key"
style="margin-bottom: 12px"
>
@@ -121,11 +118,38 @@ const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
const { renderError, renderFlowchart } = useMermaid()
const submission = ref<FlowchartSubmission | null>(null)
/**
* 评分项明细。契约里是 `Record<string, unknown>` —— 内容是 AI 模型原样吐出的 JSON
* 后端不校验形状,所以这里只能按约定断言,字段缺失时用 0 / 空串兜底。
*/
const criteriaDetails = computed<
Record<string, { score: number; max: number; comment: string }>
>(() => {
const raw = submission.value?.aiCriteriaDetails ?? {}
return Object.fromEntries(
Object.entries(raw).map(([key, value]) => {
const item = (value ?? {}) as Partial<{
score: number
max: number
comment: string
}>
return [
key,
{
score: item.score ?? 0,
max: item.max ?? 0,
comment: item.comment ?? "",
},
]
}),
)
})
const loading = ref(false)
const rendering = ref(false)
const showLargeImage = ref(false)
const suggestionLines = computed(() =>
splitSuggestionLines(submission.value?.ai_suggestions),
splitSuggestionLines(submission.value?.aiSuggestions),
)
function splitSuggestionLines(suggestions?: string | null) {
@@ -154,12 +178,12 @@ async function loadSubmission() {
submission.value = res.data
// 渲染流程图
if (submission.value?.mermaid_code) {
if (submission.value?.mermaidCode) {
rendering.value = true
await nextTick()
await renderFlowchart(
mermaidContainer.value,
submission.value.mermaid_code,
submission.value.mermaidCode,
)
rendering.value = false
}

View File

@@ -14,6 +14,7 @@ defineProps<{
function gradeType(grade: Grade) {
return (
{
"": "default",
S: "success",
A: "info",
B: "warning",

View File

@@ -1,5 +1,5 @@
<template>
<n-flex v-if="props.submission.show_link" align="center">
<n-flex v-if="props.submission.showLink" align="center">
<n-button text type="info" @click="$emit('showCode')">
{{ props.submission.id.slice(0, 12) }}
</n-button>
@@ -43,7 +43,7 @@ defineEmits(["showCode"])
const userStore = useUserStore()
const isOwnSubmission = computed(
() => userStore.profile?.user?.id === props.submission.user_id,
() => userStore.profile?.user?.id === props.submission.userId,
)
function goto() {

View File

@@ -77,10 +77,10 @@ function copyToCat() {
}
function copyToProblem() {
const { code, language, contest } = submission.value!
const { code, language, contestId } = submission.value!
// 编辑器的 storageKey 用 display idproblem._id等于 props.problemID
// 而非 submission.problem内部数字 id
const contestIDForKey = contest || null
const contestIDForKey = contestId || null
const storageKey = `problem_${props.problemID}_contest_${contestIDForKey}_lang_${language}`
storage.set(storageKey, code)
// 设置语言 + 代码localStorage 覆盖全新挂载的编辑器,
@@ -89,10 +89,10 @@ function copyToProblem() {
codeStore.setCode(code)
const problemSetId = (route.params.problemSetId as string) ?? ""
if (contest) {
if (contestId) {
router.push({
name: "contest problem",
params: { contestID: String(contest), problemID: props.problemID },
params: { contestID: String(contestId), problemID: props.problemID },
})
} else if (problemSetId) {
router.push({
@@ -121,7 +121,7 @@ onMounted(init)
:title="JUDGE_STATUS[submission.result]['title']"
>
<n-flex>
<span>提交时间{{ parseTime(submission.create_time) }}</span>
<span>提交时间{{ parseTime(submission.createTime) }}</span>
<span>编程语言{{ LANGUAGE_SHOW_VALUE[submission.language] }}</span>
<span>用户{{ submission.username }}</span>
</n-flex>

View File

@@ -9,6 +9,7 @@ import {
retryFlowchartSubmission,
} from "oj/api"
import { parseTime } from "utils/functions"
import type { Grade as GradeValue } from "utils/types"
import type {
FlowchartSubmissionListItem,
LANGUAGE,
@@ -101,7 +102,7 @@ async function listSubmissions() {
if (query.language === "Flowchart") {
const res = await getFlowchartSubmissions({
username: query.username,
problem_id: query.problem,
problemId: query.problem,
myself: query.myself,
offset,
limit: query.limit,
@@ -114,8 +115,8 @@ async function listSubmissions() {
const res = await getSubmissions({
...query,
offset,
problem_id: query.problem,
contest_id: (route.params.contestID as string) ?? "",
problemId: query.problem,
contestId: (route.params.contestID as string) ?? "",
language: query.language,
today: query.today,
})
@@ -233,7 +234,7 @@ const columns = computed(() => {
title: renderTableTitle("提交时间", "fluent-emoji:seven-oclock"),
key: "create_time",
minWidth: 200,
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss"),
},
{
title: renderTableTitle("提交编号", "fluent-emoji-flat:input-numbers"),
@@ -263,7 +264,7 @@ const columns = computed(() => {
onClick: () => problemClicked(row),
onSearch: () => (query.problem = row.problem),
},
() => `${row.problem} ${row.problem_title}`,
() => `${row.problem} ${row.problemTitle}`,
),
},
{
@@ -318,7 +319,7 @@ const flowchartColumns = computed(() => {
{
title: renderTableTitle("提交时间", "fluent-emoji:seven-oclock"),
key: "create_time",
render: (row) => parseTime(row.create_time, "YYYY-MM-DD HH:mm:ss"),
render: (row) => parseTime(row.createTime, "YYYY-MM-DD HH:mm:ss"),
},
{
title: renderTableTitle("提交编号", "fluent-emoji-flat:input-numbers"),
@@ -340,7 +341,7 @@ const flowchartColumns = computed(() => {
onClick: () => problemClicked(row),
onSearch: () => (query.problem = row.problem),
},
() => `${row.problem} ${row.problem_title}`,
() => `${row.problem} ${row.problemTitle}`,
),
},
{
@@ -349,7 +350,11 @@ const flowchartColumns = computed(() => {
"streamline-ultimate-color:analytics-bars-3d",
),
key: "ai_score",
render: (row) => h(Grade, { score: row.ai_score, grade: row.ai_grade }),
render: (row) =>
h(Grade, {
score: row.aiScore ?? 0,
grade: (row.aiGrade ?? "") as GradeValue,
}),
},
{
title: renderTableTitle(
@@ -545,9 +550,9 @@ const flowchartColumns = computed(() => {
<n-text>流程图评分详情</n-text>
<n-text
v-if="selectedFlowchart"
:type="getGradeType(selectedFlowchart.ai_grade)"
:type="getGradeType(selectedFlowchart.aiGrade ?? '')"
>
{{ selectedFlowchart.ai_score }} {{ selectedFlowchart.ai_grade }}
{{ selectedFlowchart.aiScore }} {{ selectedFlowchart.aiGrade }}
</n-text>
</n-flex>
</template>

View File

@@ -1,29 +1,27 @@
import { DIFFICULTY } from "utils/constants"
import { getACRate } from "utils/functions"
import type { Problem } from "utils/types"
import type { ProblemFiltered, ProblemListItem } from "utils/types"
// 把后端的 Problem 塑形成列表需要的形状,与请求逻辑解耦。
export function filterResult(result: Problem) {
const newResult = {
// 把后端的列表项塑形成列表需要的形状,与请求逻辑解耦。
export function filterResult(result: ProblemListItem): ProblemFiltered {
return {
id: result.id,
_id: result._id,
title: result.title,
difficulty: DIFFICULTY[result.difficulty],
tags: result.tags,
submission: result.submission_number,
rate: getACRate(result.accepted_number, result.submission_number),
status: "",
author: result.created_by.username,
allow_flowchart: result.allow_flowchart,
show_flowchart: result.show_flowchart,
has_ast_rules: result.has_ast_rules,
submission: result.submissionNumber,
rate: getACRate(result.acceptedNumber, result.submissionNumber),
// null / undefined 都表示「没做过」
status:
result.myStatus === null || result.myStatus === undefined
? "not_test"
: result.myStatus === 0
? "passed"
: "failed",
author: result.createdBy.username,
allowFlowchart: result.allowFlowchart,
showFlowchart: result.showFlowchart,
hasAstRules: result.hasAstRules,
}
if (result.my_status === null || result.my_status === undefined) {
newResult.status = "not_test"
} else if (result.my_status === 0) {
newResult.status = "passed"
} else {
newResult.status = "failed"
}
return newResult
}

View File

@@ -64,7 +64,7 @@ async function init() {
try {
const res = await getProfile(route.query.name as string)
profile.value = res.data
const acm = res.data.acm_problems_status.problems || {}
const acm = res.data!.acmProblemsStatus.problems || {}
const ac: string[] = []
Object.keys(acm).forEach((id) => {
if (acm[id]["status"] === 0) {
@@ -74,7 +74,7 @@ async function init() {
ac.sort()
problems.value = ac
if (profile.value.submission_number > 0) {
if (profile.value.submissionNumber > 0) {
const metricsRes = await getMetrics(profile.value.user.id)
firstSubmissionAt.value = parseTime(metricsRes.data.first)
latestSubmissionAt.value = parseTime(metricsRes.data.latest)
@@ -130,13 +130,13 @@ const metrics = computed(() => {
},
{
icon: "fluent-emoji:candy",
title: profile.value?.accepted_number ?? 0,
title: profile.value?.acceptedNumber ?? 0,
content: "已解决的题目数量",
animate: true,
},
{
icon: "fluent-emoji:thinking-face",
title: profile.value?.submission_number ?? 0,
title: profile.value?.submissionNumber ?? 0,
content: "总提交数量",
animate: true,
},
@@ -186,7 +186,7 @@ onMounted(() => {
</n-flex>
<n-grid
v-if="profile && profile.submission_number > 0"
v-if="profile && profile.submissionNumber > 0"
class="wrapper"
:cols="isDesktop ? 2 : 1"
:x-gap="10"

View File

@@ -8,7 +8,7 @@
<n-flex size="large" vertical>
<n-flex align="center">
<div>发送时间</div>
<div>{{ parseTime(item.create_time, "YYYY年M月D日 HH:mm:ss") }}</div>
<div>{{ parseTime(item.createTime, "YYYY年M月D日 HH:mm:ss") }}</div>
<div>发送者</div>
<div>{{ item.sender.username }}</div>
</n-flex>

View File

@@ -30,7 +30,7 @@ async function upload({ file }: UploadCustomRequestOptions) {
async function saveProfile() {
try {
await updateProfile({
real_name: userStore.profile?.real_name ?? "",
realName: userStore.profile?.realName ?? "",
mood: userStore.profile?.mood ?? "",
})
message.success("更改成功")
@@ -55,7 +55,7 @@ async function saveProfile() {
</n-upload>
</n-form-item>
<!-- <n-form-item label="真名">
<n-input v-model:value="userStore.profile.real_name" />
<n-input v-model:value="userStore.profile.realName" />
</n-form-item> -->
<n-form-item label="个性签名">
<n-input v-model:value="userStore.profile.mood" />