refactor: 一批名不副实的命名改掉(只动一端,不碰契约)

后端:
- getUserByToken → authenticateToken:它会删会话、续 TTL、记在线,不是纯读
- todayStart → dayStart(instant):本来就接受任意时刻,不只是「今天」
- objectValue → asRecord,删掉 4 份一模一样的私有拷贝和 `as toObject` 别名
- 报错文案 "does not exists" / "Already be a public problem" 改通顺

前端:
- utils/types 里 Contest / User / Problem 三个别名删掉:Contest 原来指后台
  比赛、契约里同名的却是学生侧。现在 Contest = 学生侧、AdminContest = 后台,
  后台用户表单叫 AdminUserForm,题目详情直接用 ProblemDetail
- SubmissionInfo → ContestSubmissionInfo,免得和 submission.info 撞名
- utoa / atou → compressToBase64 / decompressFromBase64(实际是 zlib + base64),
  encode / decode → base64EncodeUtf8 / base64DecodeUtf8
- filterResult → toProblemRow、toProblemListItem → toAdminProblemRow,
  行字段 submission / rate → submissionCount / acRate
- 组件:Modal → AddFromLibraryModal、Form → EditorToolbar、
  ContestEditor → BasicEditor(题单也在用它)、rank 的 Index → RankMedal、
  Name → UserNameCell、Password → PasswordCell、ContestType → ContestAccessTag

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-27 16:08:21 +08:00
1 parent 572a818d49
commit 5ae830a7f9
62 files changed
+284 -320

No files matched your search

+1 -1
View File
@@ -33,7 +33,7 @@ useMaxKB()
// 真实身份:连着的话这个人会被算进「在线老师」,学生因此拿到 pending 而不是
// no_teacher,排队等一个顶栏里根本没有求助列表的人;反过来他自己看到的求助
// 按钮点下去,服务端回的是「教师不能发起求助」。两头都不对,索性对演示模式
// 关闭这个功能(Form.vue 的按钮同步隐藏)。
// 关闭这个功能(EditorToolbar.vue 的按钮同步隐藏)。
watch(
() => userStore.isAuthed && !userStore.demoMode,
(available) => {
+15 -15
View File
@@ -1,5 +1,5 @@
import api from "utils/api"
import { toProblemListItem } from "admin/transforms"
import { toAdminProblemRow } from "admin/transforms"
import type {
AcTrend,
AdminProblemSetProgress,
@@ -8,7 +8,7 @@ import type {
RenameTagResponse,
SqlTestCaseScript,
AcmHelperItem,
SubmissionInfo,
ContestSubmissionInfo,
AdminAiReport,
AdminAiReportList,
StuckProblem,
@@ -30,13 +30,13 @@ import type {
AdminAnnouncementListItem,
BlankContest,
BlankProblem,
Contest,
AdminContest,
Exercise,
ExerciseType,
SqlDisplay,
UploadTestCaseResponse,
Tutorial,
User,
AdminUserForm,
WebsiteConfig,
AdminProblemSetBadge,
AdminProblemSetList,
@@ -70,7 +70,7 @@ export async function getProblemList(
params: { offset, limit, keyword, author, tagId },
})
return {
results: res.results.map(toProblemListItem),
results: res.results.map(toAdminProblemRow),
total: res.total,
}
}
@@ -165,7 +165,7 @@ export function getUserList(
}
// 编辑用户
export function editUser(user: User) {
export function editUser(user: AdminUserForm) {
return api.put<AdminUser>(`admin/users/${user.id}`, {
username: user.username,
email: user.email,
@@ -297,7 +297,7 @@ export function createContestProblem(problem: BlankProblem) {
}
/** 组件里的比赛对象是 snake_case,出站转成新后端要的 camelCase */
function toContestBody(contest: Contest | BlankContest) {
function toContestBody(contest: AdminContest | BlankContest) {
return {
title: contest.title,
description: contest.description,
@@ -310,22 +310,22 @@ function toContestBody(contest: Contest | BlankContest) {
}
export function createContest(contest: BlankContest) {
return api.post<Contest>("admin/contests", toContestBody(contest))
return api.post<AdminContest>("admin/contests", toContestBody(contest))
}
export function editContest(contest: Contest | BlankContest) {
return api.put<Contest>(
`admin/contests/${(contest as Contest).id}`,
export function editContest(contest: AdminContest | BlankContest) {
return api.put<AdminContest>(
`admin/contests/${(contest as AdminContest).id}`,
toContestBody(contest),
)
}
export function cloneContest(contestId: number) {
return api.post<Contest>(`admin/contests/${contestId}/clone`)
return api.post<AdminContest>(`admin/contests/${contestId}/clone`)
}
export function getContest(id: string) {
return api.get<Contest>(`admin/contests/${id}`)
return api.get<AdminContest>(`admin/contests/${id}`)
}
export function addProblemForContest(
@@ -463,9 +463,9 @@ export function makeProblemPublic(id: number, displayId: string) {
// 比赛辅助检查
export function getACMHelperList(contestId: number) {
// acInfo 在契约里是 Record<string, unknown>(acm_contest_rank 的 JSONB 原文),
// 组件侧按 SubmissionInfo 读,收窄放在这里
// 组件侧按 ContestSubmissionInfo 读,收窄放在这里
return api.get<
Array<Omit<AcmHelperItem, "acInfo"> & { acInfo: SubmissionInfo }>
Array<Omit<AcmHelperItem, "acInfo"> & { acInfo: ContestSubmissionInfo }>
>(`admin/contests/${contestId}/acm-helper`)
}
@@ -1,9 +1,9 @@
<script lang="ts" setup>
import type { Contest } from "utils/types"
import type { AdminContest } from "utils/types"
import { cloneContest } from "../../api"
interface Props {
contest: Contest
contest: AdminContest
}
const props = defineProps<Props>()
const router = useRouter()
+3 -3
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { errorMessage } from "utils/api"
import type { AcmHelperItem, SubmissionInfo } from "utils/types"
import type { AcmHelperItem, ContestSubmissionInfo } from "utils/types"
import { NButton, NCheckbox, NSelect, NTag } from "naive-ui"
import { parseTime } from "utils/functions"
import { getACMHelperList, getContest, updateACMHelperChecked } from "../api"
@@ -14,10 +14,10 @@ interface Props {
/**
* ACM 助手行。`acInfo` 的**内容**是 acm_contest_rank.submission_info 的 JSONB 原文,
* 键名保持 snake_case —— 判题写进去的就是这套键名,见 utils/types.ts 的 SubmissionInfo。
* 键名保持 snake_case —— 判题写进去的就是这套键名,见 utils/types.ts 的 ContestSubmissionInfo。
*/
type HelperItem = Omit<AcmHelperItem, "acInfo"> & {
acInfo: SubmissionInfo
acInfo: ContestSubmissionInfo
}
const props = defineProps<Props>()
+6 -6
View File
@@ -1,15 +1,15 @@
<script setup lang="ts">
import { NSwitch, NTag } from "naive-ui"
import ContestTitle from "shared/components/ContestTitle.vue"
import ContestType from "shared/components/ContestType.vue"
import ContestAccessTag from "shared/components/ContestAccessTag.vue"
import Pagination from "shared/components/Pagination.vue"
import { CONTEST_STATUS } from "utils/constants"
import { parseTime } from "utils/functions"
import type { Contest } from "utils/types"
import type { AdminContest } from "utils/types"
import { editContest, getContestList } from "../api"
import Actions from "./components/Actions.vue"
const contests = ref<Contest[]>([])
const contests = ref<AdminContest[]>([])
const total = ref(0)
const query = reactive({
limit: 10,
@@ -17,12 +17,12 @@ const query = reactive({
keyword: "",
})
function toggleVisible(contest: Contest) {
function toggleVisible(contest: AdminContest) {
contest.visible = !contest.visible
editContest(contest)
}
const columns: DataTableColumn<Contest>[] = [
const columns: DataTableColumn<AdminContest>[] = [
{ title: "ID", key: "id", width: 60 },
{
title: "比赛",
@@ -39,7 +39,7 @@ const columns: DataTableColumn<Contest>[] = [
title: "类型",
key: "contest_type",
width: 100,
render: (row) => h(ContestType, { contest: row, size: "small" }),
render: (row) => h(ContestAccessTag, { contest: row, size: "small" }),
},
{
title: "状态",
@@ -1,7 +1,7 @@
<script lang="ts" setup>
import { getProblemList } from "admin/api"
import Pagination from "shared/components/Pagination.vue"
import type { AdminProblemFiltered } from "utils/types"
import type { AdminProblemRow } from "utils/types"
import AddButton from "./AddButton.vue"
interface Props {
@@ -23,9 +23,9 @@ const query = reactive({
keyword: "",
})
const total = ref(0)
const problems = shallowRef<AdminProblemFiltered[]>([])
const problems = shallowRef<AdminProblemRow[]>([])
const columns: DataTableColumn<AdminProblemFiltered>[] = [
const columns: DataTableColumn<AdminProblemRow>[] = [
{ title: "编号", key: "_id", width: 80 },
{ title: "标题", key: "title" },
{
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { NButton, NTag } from "naive-ui"
import Pagination from "shared/components/Pagination.vue"
import type { AdminProblemFiltered } from "utils/types"
import type { AdminProblemRow } from "utils/types"
import { batchTagProblems, getProblemList } from "admin/api"
interface Props {
@@ -19,13 +19,13 @@ const emit = defineEmits<{
const router = useRouter()
const message = useMessage()
const problems = ref<AdminProblemFiltered[]>([])
const problems = ref<AdminProblemRow[]>([])
const total = ref(0)
const page = ref(1)
const limit = ref(10)
const keyword = ref("")
const columns: DataTableColumn<AdminProblemFiltered>[] = [
const columns: DataTableColumn<AdminProblemRow>[] = [
{ title: "显示编号", key: "_id", width: 100 },
{
title: "标题",
@@ -81,12 +81,12 @@ function close() {
emit("update:show", false)
}
function goEdit(row: AdminProblemFiltered) {
function goEdit(row: AdminProblemRow) {
close()
router.push({ name: "admin problem edit", params: { problemID: row.id } })
}
async function removeTag(row: AdminProblemFiltered) {
async function removeTag(row: AdminProblemRow) {
await batchTagProblems([row.id], [props.tagName], "remove")
message.success(`已移除「${row.title}」的标签`)
emit("changed")
+7 -7
View File
@@ -4,11 +4,11 @@ import { Icon } from "@iconify/vue"
import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination"
import { getTagColor, parseTime } from "utils/functions"
import type { AdminProblemFiltered } from "utils/types"
import type { AdminProblemRow } from "utils/types"
import { DIFFICULTY, REACTIONS } from "utils/constants"
import { getProblemList, toggleProblemVisible } from "../api"
import Actions from "./components/Actions.vue"
import Modal from "./components/Modal.vue"
import AddFromLibraryModal from "./components/AddFromLibraryModal.vue"
import { useRouteQuery } from "@vueuse/router"
import AuthorSelect from "shared/components/AuthorSelect.vue"
import type { DataTableRowKey } from "naive-ui"
@@ -36,7 +36,7 @@ const isContestProblemList = computed(
const [show, toggleShow] = useToggle()
const { count, inc } = useCounter(0)
const total = ref(0)
const problems = ref<AdminProblemFiltered[]>([])
const problems = ref<AdminProblemRow[]>([])
const selectedRowKeys = ref<DataTableRowKey[]>([])
const batchTagAction = ref<"add" | "remove">("add")
@@ -46,7 +46,7 @@ const selectedProblemIds = computed(() =>
selectedRowKeys.value.map((key) => Number(key)),
)
const rowKey = (row: AdminProblemFiltered) => row.id
const rowKey = (row: AdminProblemRow) => row.id
function chooseProblems(rowKeys: DataTableRowKey[]) {
selectedRowKeys.value = rowKeys
@@ -83,7 +83,7 @@ const { query, clearQuery } = usePagination<ProblemQuery>({
author: useRouteQuery("author", "").value,
})
const baseColumns: DataTableColumn<AdminProblemFiltered>[] = [
const baseColumns: DataTableColumn<AdminProblemRow>[] = [
{ title: "ID", key: "id", width: 100 },
{ title: "显示编号", key: "_id", width: 100 },
{ title: "标题", key: "title", minWidth: 200 },
@@ -183,7 +183,7 @@ const baseColumns: DataTableColumn<AdminProblemFiltered>[] = [
]
// 比赛题目接口不返回 top_reaction,这一列只在普通题目列表里显示
const columns = computed<DataTableColumn<AdminProblemFiltered>[]>(() =>
const columns = computed<DataTableColumn<AdminProblemRow>[]>(() =>
isContestProblemList.value
? baseColumns.filter((it) => !("key" in it) || it.key !== "topReaction")
: [{ type: "selection" }, ...baseColumns],
@@ -310,7 +310,7 @@ watch(() => [query.page, query.limit, query.author], listProblems)
v-model:limit="query.limit"
v-model:page="query.page"
/>
<Modal
<AddFromLibraryModal
v-model:show="show"
:count="count"
:next-display-id="nextDisplayID"
+3 -3
View File
@@ -1,9 +1,9 @@
import type { AdminProblemFiltered, AdminProblemListItem } from "utils/types"
import type { AdminProblemRow, AdminProblemListItem } from "utils/types"
// 把后端的列表项塑形成管理端列表行,与请求逻辑解耦。
export function toProblemListItem(
export function toAdminProblemRow(
result: AdminProblemListItem,
): AdminProblemFiltered {
): AdminProblemRow {
return {
id: result.id,
_id: result._id,
@@ -1,16 +1,16 @@
<script lang="ts" setup>
import { editUser } from "admin/api"
import type { User } from "utils/types"
import type { AdminUserForm } from "utils/types"
interface Props {
user: User
user: AdminUserForm
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: "deleteUser", value: number[]): void
(e: "userBanned", value: User): void
(e: "openEditModal", value: User): void
(e: "resetPassword", value: User): void
(e: "userBanned", value: AdminUserForm): void
(e: "openEditModal", value: AdminUserForm): void
(e: "resetPassword", value: AdminUserForm): void
}>()
async function banUser() {
@@ -1,10 +1,10 @@
<script lang="ts" setup>
import TextCopy from "shared/components/TextCopy.vue"
import { USER_TYPE } from "utils/constants"
import type { User } from "utils/types"
import type { AdminUserForm } from "utils/types"
interface Props {
user: User
user: AdminUserForm
revealed: boolean
}
const props = defineProps<Props>()
@@ -1,14 +1,14 @@
<script setup lang="ts">
import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants"
import { getUserRole } from "utils/functions"
import type { User } from "utils/types"
import type { AdminUserForm } from "utils/types"
import TextCopy from "shared/components/TextCopy.vue"
interface Props {
user: User
user: AdminUserForm
}
const props = defineProps<Props>()
const isNotRegularUser = computed(
const hasRoleTag = computed(
() => props.user.adminType !== USER_TYPE.REGULAR_USER,
)
</script>
@@ -18,7 +18,7 @@ const isNotRegularUser = computed(
封号中
</n-tag>
<n-tag
v-if="isNotRegularUser"
v-if="hasRoleTag"
:type="getUserRole(props.user.adminType).type"
size="small"
>
+12 -12
View File
@@ -4,7 +4,7 @@ import { DataTableRowKey, NFlex, NTag, SelectOption } from "naive-ui"
import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination"
import { parseTime } from "utils/functions"
import type { User } from "utils/types"
import type { AdminUserForm } from "utils/types"
import {
deleteUsers,
editUser,
@@ -13,8 +13,8 @@ import {
resetPassword,
} from "../api"
import Actions from "./components/Actions.vue"
import Name from "./components/Name.vue"
import Password from "./components/Password.vue"
import UserNameCell from "./components/UserNameCell.vue"
import PasswordCell from "./components/PasswordCell.vue"
import { PROBLEM_PERMISSION, USER_TYPE } from "utils/constants"
import { useRouteQuery } from "@vueuse/router"
import TextCopy from "shared/components/TextCopy.vue"
@@ -35,8 +35,8 @@ const { query, clearQuery } = usePagination<UserQuery>({
})
const total = ref(0)
const users = ref<User[]>([])
const userEditing = ref<User | null>(null)
const users = ref<AdminUserForm[]>([])
const userEditing = ref<AdminUserForm | null>(null)
const adminOptions = [
{ label: "全部用户", value: "" },
@@ -57,16 +57,16 @@ const password = ref("")
const revealedPasswords = ref(new Set<number>())
const userIDs = ref<DataTableRowKey[]>([])
const rowKey = (row: User) => row.id
const rowKey = (row: AdminUserForm) => row.id
const columns: DataTableColumn<User>[] = [
const columns: DataTableColumn<AdminUserForm>[] = [
{ type: "selection" },
{ title: "ID", key: "id", width: 80 },
{
title: "用户名",
key: "username",
width: 220,
render: (row) => h(Name, { user: row }),
render: (row) => h(UserNameCell, { user: row }),
},
{
// 显示出来的密码后面还跟着「隐藏」按钮,150 挤得下点点、挤不下密码
@@ -74,7 +74,7 @@ const columns: DataTableColumn<User>[] = [
key: "raw_password",
width: 180,
render: (row) =>
h(Password, {
h(PasswordCell, {
user: row,
revealed: revealedPasswords.value.has(row.id),
onToggle: (id: number) => {
@@ -165,7 +165,7 @@ async function onDeleteUsers(userIDs: DataTableRowKey[] | Ref<number[]>) {
listUsers()
}
async function onResetPassword(user: User) {
async function onResetPassword(user: AdminUserForm) {
const res = await resetPassword(user.id)
message.success(`【${user.username}】的密码已重置成【${res}】`)
users.value = users.value.map((it) => {
@@ -176,7 +176,7 @@ async function onResetPassword(user: User) {
})
}
async function onUserBanned(user: User) {
async function onUserBanned(user: AdminUserForm) {
users.value = users.value.map((it) => {
if (it.id === user.id) {
it.isDisabled = user.isDisabled
@@ -209,7 +209,7 @@ function createNewUser() {
password.value = ""
}
function onOpenEditModal(user: User) {
function onOpenEditModal(user: AdminUserForm) {
userEditing.value = user
password.value = ""
}
+10 -10
View File
@@ -1,7 +1,7 @@
import {
type AiAnalysisRecord,
type AiHintFeedbackRequest,
type Contest as OjContest,
type Contest,
type ContestAccess,
type ContestList,
type ActivityRankItem,
@@ -43,7 +43,7 @@ import {
} from "@oj2/contract"
import api from "utils/api"
import { contract } from "utils/contract"
import { filterResult } from "oj/transforms"
import { toProblemRow } from "oj/transforms"
import type {
Announcement,
AnnouncementListItem,
@@ -52,7 +52,7 @@ import type {
Message,
SubmissionListItem,
Exercise,
Problem,
ProblemDetail,
ReactionKey,
ReactionState,
Submission,
@@ -67,11 +67,11 @@ import type {
* 题目详情。走契约的 zod 解析,形状即契约 —— 之前这里手抄了一份 camel→snake 的
* 键名映射,抄漏一个字段就是静默 undefined。
*
* 走 `contract()` 而不是裸 `parse()`:原来是 `problemDetailSchema.parse(v) as Problem`,
* 走 `contract()` 而不是裸 `parse()`:原来是 `problemDetailSchema.parse(v) as ProblemDetail`,
* `as` 把校验结果又断言回去、等于没校验,而 `parse` 抛错会让整个题目页白屏。
* 现在形状不符时记一条控制台分歧再放行原始数据。
*/
function detailProblem(value: unknown): Problem {
function detailProblem(value: unknown): ProblemDetail {
return contract("GET /problems/:id", problemDetailSchema, value)
}
@@ -89,7 +89,7 @@ export async function getProblemList(
params: { paging: true, offset, limit, ...searchParams },
})
return {
results: res.results.map(filterResult),
results: res.results.map(toProblemRow),
total: res.total,
}
}
@@ -250,7 +250,7 @@ export function getContestList(query: {
}
export function getContest(id: string) {
return api.get<OjContest>(`contests/${encodeURIComponent(id)}`)
return api.get<Contest>(`contests/${encodeURIComponent(id)}`)
}
export function getContestAccess(id: string) {
@@ -268,7 +268,7 @@ export async function getContestProblems(contestID: string) {
const res = await api.get<ProblemListItem[]>(
`contests/${encodeURIComponent(contestID)}/problems`,
)
return res.map(filterResult)
return res.map(toProblemRow)
}
export function getContestRank(
@@ -276,7 +276,7 @@ export function getContestRank(
query: { limit: number; offset: number },
) {
// submissionInfo 在契约里是 Record<string, unknown>(JSONB 原文),
// 前端在这里收窄成 SubmissionInfo,见 utils/types 的 ContestRank
// 前端在这里收窄成 ContestSubmissionInfo,见 utils/types 的 ContestRank
return api.get<{ results: ContestRank[]; total: number }>(
`contests/${encodeURIComponent(contestID)}/rank`,
{ params: query },
@@ -383,7 +383,7 @@ export function submitHintFeedback(hintId: number, helpful: boolean) {
export function getSimilarProblems(problemId: string) {
return api
.get<ProblemListItem[]>(`problems/${encodeURIComponent(problemId)}/similar`)
.then((response) => response.map(filterResult))
.then((response) => response.map(toProblemRow))
}
export type { YearlyAc as YearlyACData } from "@oj2/contract"
@@ -2,7 +2,7 @@
import { Icon } from "@iconify/vue"
import { useContestStore } from "oj/store/contest"
import { parseTime } from "utils/functions"
import ContestType from "shared/components/ContestType.vue"
import ContestAccessTag from "shared/components/ContestAccessTag.vue"
const contestStore = useContestStore()
</script>
@@ -30,7 +30,7 @@ const contestStore = useContestStore()
{{ parseTime(contestStore.contest.endTime, "YYYY年M月D日 HH:mm:ss") }}
</n-descriptions-item>
<n-descriptions-item label="比赛类型">
<ContestType :contest="contestStore.contest" />
<ContestAccessTag :contest="contestStore.contest" />
</n-descriptions-item>
<n-descriptions-item label="发起人">
{{ contestStore.contest.createdBy.username }}
+4 -4
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 { OjContest } from "utils/types"
import type { Contest } from "utils/types"
import ContestTitle from "shared/components/ContestTitle.vue"
import Pagination from "shared/components/Pagination.vue"
import { useAuthModalStore } from "shared/store/authModal"
@@ -29,7 +29,7 @@ const { query, clearQuery } = usePagination<ContestQuery>({
tag: useRouteQuery("tag", "").value,
})
const data = ref<OjContest[]>([])
const data = ref<Contest[]>([])
const total = ref(0)
const options: SelectOption[] = [
@@ -46,7 +46,7 @@ const tags: SelectOption[] = [
{ label: "期末", value: "期末" },
]
const columns: DataTableColumn<OjContest>[] = [
const columns: DataTableColumn<Contest>[] = [
{
title: renderTableTitle("状态", "streamline-emojis:collision"),
key: "status",
@@ -116,7 +116,7 @@ watchDebounced(() => query.keyword, listContests, {
// 监听其他查询条件变化
watch(() => [query.page, query.limit, query.status, query.tag], listContests)
function rowProps(row: OjContest) {
function rowProps(row: Contest) {
return {
style: "cursor: pointer",
onClick() {
+5 -5
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { ProblemFiltered } from "utils/types"
import type { ProblemRow } from "utils/types"
import ProblemStatus from "oj/problem/components/ProblemStatus.vue"
import { useContestStore } from "oj/store/contest"
import { renderTableTitle } from "utils/renders"
@@ -8,7 +8,7 @@ const props = defineProps<{ contestID: string }>()
const router = useRouter()
const contestStore = useContestStore()
const problemsColumns: DataTableColumn<ProblemFiltered>[] = [
const problemsColumns: DataTableColumn<ProblemRow>[] = [
{
title: renderTableTitle("状态", "streamline-ultimate-color:music-note-1"),
key: "status",
@@ -27,19 +27,19 @@ const problemsColumns: DataTableColumn<ProblemFiltered>[] = [
},
{
title: renderTableTitle("提交数", "streamline-emojis:clinking-beer-mugs"),
key: "submission",
key: "submissionCount",
align: "center",
width: 120,
},
{
title: renderTableTitle("通过率", "streamline-emojis:clapping-hands-1"),
key: "rate",
key: "acRate",
align: "center",
width: 120,
},
]
function rowProps(row: ProblemFiltered) {
function rowProps(row: ProblemRow) {
return {
style: "cursor: pointer",
onClick() {
+2 -2
View File
@@ -8,7 +8,7 @@ import Pagination from "shared/components/Pagination.vue"
import { usePagination } from "shared/composables/pagination"
import { ContestStatus } from "utils/constants"
import { renderTableTitle } from "utils/renders"
import type { ContestRank, ProblemFiltered } from "utils/types"
import type { ContestRank, ProblemRow } from "utils/types"
import AcAndSubmission from "../components/AcAndSubmission.vue"
import LineChart from "../components/LineChart.vue"
@@ -27,7 +27,7 @@ const contestStore = useContestStore()
const total = ref(0)
const data = ref<ContestRank[]>([])
const chart = ref<ContestRank[]>([])
const problems = ref<ProblemFiltered[]>([])
const problems = ref<ProblemRow[]>([])
const [autoRefresh] = useToggle(true)
const { resume, pause } = useIntervalFn(
() => {
@@ -8,7 +8,7 @@ import { useBreakpoints } from "shared/composables/breakpoints"
import storage from "utils/storage"
import type { LANGUAGE } from "utils/types"
import { beginEditTrace, editTraceExtensions } from "oj/problem/utils/editTrace"
import Form from "./Form.vue"
import EditorToolbar from "./EditorToolbar.vue"
const route = useRoute()
@@ -58,7 +58,10 @@ const changeLanguage = (v: LANGUAGE) => {
<template>
<n-flex vertical>
<Form :storage-key="storageKey" @change-language="changeLanguage" />
<EditorToolbar
:storage-key="storageKey"
@change-language="changeLanguage"
/>
<CodeEditor
v-model:value="codeStore.code.value"
:language="codeStore.code.language"
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { storeToRefs } from "pinia"
import { copyToClipboard, utoa } from "utils/functions"
import { copyToClipboard, compressToBase64 } from "utils/functions"
import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import { useCollabStore } from "shared/store/collab"
@@ -227,7 +227,7 @@ const goTestCat = () => {
code: codeStore.code.value,
input: problemStore.problem?.samples[0].input,
}
const base64 = utoa(JSON.stringify(data))
const base64 = compressToBase64(JSON.stringify(data))
const url = `${import.meta.env.PUBLIC_CODE_URL}?share=${encodeURIComponent(base64)}`
window.open(url, "_blank")
}
@@ -5,7 +5,7 @@ import { storeToRefs } from "pinia"
import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import { createTestSubmission } from "utils/judge"
import type { Problem, ProblemFiltered, ProblemStatus } from "utils/types"
import type { ProblemDetail, ProblemRow, ProblemStatus } from "utils/types"
import Copy from "shared/components/Copy.vue"
import { useDark } from "@vueuse/core"
import { MdPreview } from "md-editor-v3"
@@ -13,7 +13,7 @@ import "md-editor-v3/lib/preview.css"
import { getSimilarProblems } from "oj/api"
import SQLDataTable from "./SQLDataTable.vue"
type Sample = Problem["samples"][number] & {
type Sample = ProblemDetail["samples"][number] & {
id: number
msg: string
status: ProblemStatus
@@ -45,7 +45,7 @@ const sqlChangedTables = computed(() => {
const router = useRouter()
// 相似题目推荐
const similarProblems = ref<ProblemFiltered[]>([])
const similarProblems = ref<ProblemRow[]>([])
const similarLoaded = ref(false)
async function loadSimilarProblems() {
@@ -409,7 +409,7 @@ function type(status: ProblemStatus) {
{{ sp.title }}
</n-button>
</n-flex>
<!-- getSimilarProblems 已经过 filterResult,难度是中文,不是 Low/Mid/High -->
<!-- getSimilarProblems 已经过 toProblemRow,难度是中文,不是 Low/Mid/High -->
<n-tag
v-if="sp.difficulty"
size="small"
@@ -9,7 +9,7 @@ import { useBreakpoints } from "shared/composables/breakpoints"
import storage from "utils/storage"
import type { LANGUAGE } from "utils/types"
import { beginEditTrace, editTraceExtensions } from "oj/problem/utils/editTrace"
import Form from "./Form.vue"
import EditorToolbar from "./EditorToolbar.vue"
const FlowchartEditor = defineAsyncComponent(
() => import("shared/components/FlowchartEditor/index.vue"),
@@ -43,14 +43,14 @@ const teacherCollab = computed(() => collabHere.value && collabStore.isTeacher)
let teacherLanguageBefore: LANGUAGE | null = null
/**
* 协作中**教师的语言选择跟着学生走**(选择器同时禁用,见 Form.vue),学生中途切了
* 协作中**教师的语言选择跟着学生走**(选择器同时禁用,见 EditorToolbar.vue),学生中途切了
* 还会再同步一次。
*
* 写的是 codeStore 而不是只改编辑器的高亮:提交、语法检查、去自测猫读的都是
* `codeStore.code.language` —— 只改高亮的话,老师会拿着自己那档语言提交学生的代码
* (学生写 C、老师选的是 Python,当场 CE),工具栏还可能显示「提交流程图」。
*
* 直接写 store 不会连带重载模板代码:那是 Form 里选择器的 `update:value` 才做的事。
* 直接写 store 不会连带重载模板代码:那是 EditorToolbar 里选择器的 `update:value` 才做的事。
*
* **学生端一个字都不动**(早退出),他的语言本来就是权威那一份 —— 服务端的
* `room_language` 只发给教师,学生本地那份 `room.language` 停在建房那一刻,
@@ -139,10 +139,13 @@ provide("flowchartEditorRef", flowchartEditorRef)
<template>
<n-flex vertical>
<Form :storage-key="storageKey" @change-language="changeLanguage" />
<EditorToolbar
:storage-key="storageKey"
@change-language="changeLanguage"
/>
<!--
协作中教师这边不会落到流程图分支:上面那个 watch 已经把他的语言换成了学生的,
而求助入口本身就排掉了流程图(Form.vue 的 showHelpButton、服务端的
而求助入口本身就排掉了流程图(EditorToolbar.vue 的 showHelpButton、服务端的
COLLAB_LANGUAGES),所以 room.language 不可能是 Flowchart。
学生自己切到流程图就是不写代码了,编辑器卸载、协作正常结束(SyncCodeEditor
的 detach),这是原来就有的语义。
@@ -1,9 +1,9 @@
<script lang="ts" setup>
import type { ProblemFiltered } from "utils/types"
import type { ProblemRow } from "utils/types"
import { Icon } from "@iconify/vue"
defineProps<{
problem: ProblemFiltered
problem: ProblemRow
}>()
</script>
<template>
@@ -3,7 +3,7 @@ import { errorCode } from "utils/api"
import { toRefs } from "vue"
// 工具函数
import { atou, utoa } from "utils/functions"
import { decompressFromBase64, compressToBase64 } from "utils/functions"
import { sortFlowchartCriteria } from "utils/constants"
// 组合式函数
@@ -235,7 +235,7 @@ async function submitFlowchartData() {
const mermaidCode = convertToMermaid(flowchartData)
lastSubmittedMermaidCode.value = mermaidCode
const compressed = utoa(JSON.stringify(flowchartData))
const compressed = compressToBase64(JSON.stringify(flowchartData))
loading.value = true
latestRating.value = { score: 0, grade: "" }
@@ -372,9 +372,9 @@ function closeModal() {
function loadToEditor() {
if (myFlowchartZippedStr.value) {
// 老提交的压缩数据可能是坏的(格式换过、存了一半),
// 不兜住的话 atou/JSON.parse 直接抛,按钮点了毫无反应
// 不兜住的话 decompressFromBase64/JSON.parse 直接抛,按钮点了毫无反应
try {
const json = JSON.parse(atou(myFlowchartZippedStr.value))
const json = JSON.parse(decompressFromBase64(myFlowchartZippedStr.value))
flowchartEditorRef?.value?.setFlowchartData({
nodes: json.nodes || [],
edges: json.edges || [],
+6 -6
View File
@@ -10,9 +10,9 @@ import { useUserStore } from "shared/store/user"
// 抽成具名 loader,便于进页面时与接口并行预取编辑器 chunk
const loadProblemEditor = () => import("./components/ProblemEditor.vue")
const loadContestEditor = () => import("./components/ContestEditor.vue")
const loadContestEditor = () => import("./components/BasicEditor.vue")
const ProblemEditor = defineAsyncComponent(loadProblemEditor)
const ContestEditor = defineAsyncComponent(loadContestEditor)
const BasicEditor = defineAsyncComponent(loadContestEditor)
const EditorForTest = defineAsyncComponent(
() => import("./components/EditorForTest.vue"),
)
@@ -85,7 +85,7 @@ const tabOptions = computed(() => {
const currentTab = ref("content")
const inProblem = computed(() => route.name === "problem")
const isPublicProblemRoute = computed(() => route.name === "problem")
watch(
[() => route.query.tab, () => tabOptions.value],
@@ -117,7 +117,7 @@ async function init() {
screenModeStore.resetScreenMode()
// 并行预取右侧编辑器 chunk(CodeMirror ~370K+),
// 避免等 getProblem 返回后才串行下载,编辑器才迟迟出现
;(inProblem.value ? loadProblemEditor : loadContestEditor)()
;(isPublicProblemRoute.value ? loadProblemEditor : loadContestEditor)()
try {
const res = await getProblem(problemID, contestID)
problem.value = res
@@ -215,7 +215,7 @@ watch(
</n-scrollbar>
</template>
<template #2>
<component :is="inProblem ? ProblemEditor : ContestEditor" />
<component :is="isPublicProblemRoute ? ProblemEditor : BasicEditor" />
</template>
</n-split>
@@ -272,7 +272,7 @@ watch(
<ProblemFlowchart />
</n-tab-pane>
<n-tab-pane name="editor" tab="代码">
<component :is="inProblem ? ProblemEditor : ContestEditor" />
<component :is="isPublicProblemRoute ? ProblemEditor : BasicEditor" />
</n-tab-pane>
<n-tab-pane name="info" tab="统计" :disabled="!!problemSetId">
<ProblemInfo />
+6 -6
View File
@@ -6,7 +6,7 @@ import { getProblemList } from "oj/api"
import { STORAGE_KEY } from "utils/constants"
import storage from "utils/storage"
import { getTagColor } from "utils/functions"
import type { ProblemFiltered, Tag as ContractTag } from "utils/types"
import type { ProblemRow, Tag as ContractTag } from "utils/types"
import { getProblemTagList } from "shared/api"
import Hitokoto from "shared/components/Hitokoto.vue"
import Pagination from "shared/components/Pagination.vue"
@@ -66,7 +66,7 @@ const userStore = useUserStore()
const { isDesktop } = useBreakpoints()
const problems = ref<ProblemFiltered[]>([])
const problems = ref<ProblemRow[]>([])
const total = ref(0)
const tags = ref<Tag[]>([])
const [showTag, toggleShowTag] = useToggle(isDesktop.value)
@@ -165,7 +165,7 @@ onMounted(() => {
listTags()
})
const baseColumns: DataTableColumn<ProblemFiltered>[] = [
const baseColumns: DataTableColumn<ProblemRow>[] = [
{
title: renderTableTitle("状态", "streamline-emojis:high-voltage"),
key: "status",
@@ -213,13 +213,13 @@ const baseColumns: DataTableColumn<ProblemFiltered>[] = [
},
{
title: renderTableTitle("提交数", "streamline-ultimate-color:paper-write"),
key: "submission",
key: "submissionCount",
align: "center",
width: 100,
},
{
title: renderTableTitle("通过率", "streamline-emojis:victory-hand-2"),
key: "rate",
key: "acRate",
width: 100,
align: "center",
},
@@ -231,7 +231,7 @@ const columns = computed(() =>
: baseColumns.filter((c: any) => c.key !== "status"),
)
function rowProps(row: ProblemFiltered) {
function rowProps(row: ProblemRow) {
return {
style: "cursor: pointer",
onClick() {
+1 -1
View File
@@ -5,7 +5,7 @@ import type { SubmissionTrace } from "@oj2/contract"
* 编辑过程信号的采集,随提交一起报给后端(落进 `submission_trace`)。
* 字段含义见契约的 `submissionTraceSchema`。**只数字符数和次数,不留任何按键内容。**
*
* 是模块单例而不是 Pinia store:编辑器(ProblemEditor / ContestEditor 里)和
* 是模块单例而不是 Pinia store:编辑器(ProblemEditor / BasicEditor 里)和
* 提交按钮(Form → SubmitCode)是兄弟组件,得共用一份计数;而 CodeMirror 的
* 扩展对象一旦经过 store 就会被包成响应式代理,facet 靠身份比较,代理过的扩展
* 直接失效。计数本身也不需要响应式。
+4 -4
View File
@@ -24,7 +24,7 @@ import Pagination from "shared/components/Pagination.vue"
import { ChartType, LONG_DURATION_OPTIONS } from "utils/constants"
import { renderTableTitle } from "utils/renders"
import Chart from "./components/Chart.vue"
import Index from "./components/Index.vue"
import RankMedal from "./components/RankMedal.vue"
import { useUserStore } from "shared/store/user"
import { Icon } from "@iconify/vue"
import { MdPreview } from "md-editor-v3"
@@ -162,7 +162,7 @@ const columns: DataTableColumn<Rank>[] = [
width: 100,
align: "center",
render: (_, index) =>
h(Index, { index, page: query.page, limit: query.limit }),
h(RankMedal, { index, page: query.page, limit: query.limit }),
},
{
title: renderTableTitle(
@@ -287,8 +287,8 @@ const weeklyColumns: DataTableColumn<WeeklyRankItem>[] = [
key: "rank",
width: 100,
align: "center",
// rank 是服务端给的周榜名次,不是行号 —— 换算回 Index 要的 0 基下标
render: (row) => h(Index, { index: row.rank - 1, page: 1, limit: 10 }),
// rank 是服务端给的周榜名次,不是行号 —— 换算回 RankMedal 要的 0 基下标
render: (row) => h(RankMedal, { index: row.rank - 1, page: 1, limit: 10 }),
},
{
title: renderTableTitle(
+3 -3
View File
@@ -3,7 +3,7 @@ import { formatISO, getTime, parseISO } from "date-fns"
import { useUserStore } from "shared/store/user"
import { ContestStatus, ContestType } from "utils/constants"
import { duration } from "utils/functions"
import type { OjContest, ProblemFiltered } from "utils/types"
import type { Contest, ProblemRow } from "utils/types"
import {
checkContestPassword,
getContest,
@@ -14,8 +14,8 @@ import {
export const useContestStore = defineStore("contest", () => {
const userStore = useUserStore()
const [access, toggleAccess] = useToggle(false)
const contest = ref<OjContest | null>(null)
const problems = ref<ProblemFiltered[]>([])
const contest = ref<Contest | null>(null)
const problems = ref<ProblemRow[]>([])
const now = ref(0)
let timer = 0
+2 -2
View File
@@ -1,8 +1,8 @@
import { defineStore } from "pinia"
import type { LANGUAGE, Problem } from "utils/types"
import type { LANGUAGE, ProblemDetail } from "utils/types"
export const useProblemStore = defineStore("problem", () => {
const problem = ref<Problem | null>(null)
const problem = ref<ProblemDetail | null>(null)
const route = useRoute()
/**
+2 -2
View File
@@ -12,7 +12,7 @@ import {
submissionMemoryFormat,
submissionResultTitle,
submissionTimeFormat,
utoa,
compressToBase64,
} from "utils/functions"
import type { Submission } from "utils/types"
import SubmissionResultTag from "shared/components/SubmissionResultTag.vue"
@@ -82,7 +82,7 @@ function copyToCat() {
code: submission.value!.code,
input: "",
}
const base64 = utoa(JSON.stringify(data))
const base64 = compressToBase64(JSON.stringify(data))
const url = `${import.meta.env.PUBLIC_CODE_URL}?share=${encodeURIComponent(base64)}`
window.open(url, "_blank")
}
+4 -4
View File
@@ -1,17 +1,17 @@
import { DIFFICULTY } from "utils/constants"
import { getACRate } from "utils/functions"
import type { ProblemFiltered, ProblemListItem } from "utils/types"
import type { ProblemRow, ProblemListItem } from "utils/types"
// 把后端的列表项塑形成列表页需要的形状,与请求逻辑解耦。
export function filterResult(result: ProblemListItem): ProblemFiltered {
export function toProblemRow(result: ProblemListItem): ProblemRow {
return {
id: result.id,
_id: result._id,
title: result.title,
difficulty: result.difficulty ? DIFFICULTY[result.difficulty] : null,
tags: result.tags,
submission: result.submissionNumber,
rate: getACRate(result.acceptedNumber, result.submissionNumber),
submissionCount: result.submissionNumber,
acRate: getACRate(result.acceptedNumber, result.submissionNumber),
// null / undefined 都表示「没做过」
status:
result.myStatus === null || result.myStatus === undefined
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { ContestType } from "utils/constants"
import type { Contest, OjContest } from "utils/types"
import type { AdminContest, Contest } from "utils/types"
interface Props {
contest: Contest | OjContest
contest: AdminContest | Contest
size?: "small"
}
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { Icon } from "@iconify/vue"
import { ContestType } from "utils/constants"
import type { Contest, OjContest } from "utils/types"
import type { AdminContest, Contest } from "utils/types"
defineProps<{ contest: Contest | OjContest }>()
defineProps<{ contest: AdminContest | Contest }>()
</script>
<template>
<n-flex>
@@ -55,7 +55,7 @@ const waited = (createdAt: number) => {
* 那个弹框按一下 Esc 就关、协作跟着结束,上课时太容易误触;题面也只能看不能用。
* 直接跳题目页之后,老师看到的就是学生看到的那一页。
*
* 求助入口在比赛里是关掉的(Form.vue 的 showHelpButton),problemId 一定是公开
* 求助入口在比赛里是关掉的(EditorToolbar.vue 的 showHelpButton),problemId 一定是公开
* 题目的展示 ID,走 /problem/:id 就行。
*/
const handleAccept = (studentId: number, problemId: string, status: string) => {
+6 -6
View File
@@ -1,7 +1,7 @@
import { TIME_ZONE_OFFSET_MINUTES, toAdminType } from "@oj2/contract"
import type { JudgeCaseResult, JudgeInfo } from "@oj2/contract"
import { getTime, intervalToDuration, parseISO, type Duration } from "date-fns"
import { Submission, User } from "./types"
import { AdminUser, Submission } from "./types"
import { JUDGE_STATUS, USER_TYPE } from "./constants"
import {
strFromU8,
@@ -283,7 +283,7 @@ export function debounce<T extends (...args: any[]) => any>(
}
}
export function getUserRole(role: User["adminType"]): {
export function getUserRole(role: AdminUser["adminType"]): {
type: "default" | "info" | "warning" | "error"
label: "普通" | "生管" | "师管" | "超管"
} {
@@ -315,7 +315,7 @@ export function unique<T>(arr: T[]): T[] {
return [...new Set(arr)]
}
export function encode(string?: string): string {
export function base64EncodeUtf8(string?: string): string {
try {
return btoa(String.fromCharCode(...new TextEncoder().encode(string ?? "")))
} catch (error) {
@@ -324,7 +324,7 @@ export function encode(string?: string): string {
}
}
export function decode(bytes?: string): string {
export function base64DecodeUtf8(bytes?: string): string {
try {
if (!bytes) return ""
const latin = atob(bytes)
@@ -339,14 +339,14 @@ export function decode(bytes?: string): string {
}
}
export function utoa(data: string): string {
export function compressToBase64(data: string): string {
const buffer = strToU8(data)
const zipped = zlibSync(buffer, { level: 9 })
const binary = strFromU8(zipped, true)
return btoa(binary)
}
export function atou(base64: string): string {
export function decompressFromBase64(base64: string): string {
const binary = atob(base64)
const buffer = strToU8(binary, true)
const unzipped = unzlibSync(buffer)
+7 -4
View File
@@ -1,5 +1,5 @@
import axios from "axios"
import { decode, encode } from "./functions"
import { base64DecodeUtf8, base64EncodeUtf8 } from "./functions"
import type { Code, LANGUAGE } from "./types"
const http = axios.create({ baseURL: import.meta.env.PUBLIC_JUDGE0_URL })
@@ -16,7 +16,7 @@ const JUDGE0_LANGUAGE_ID: Partial<Record<LANGUAGE, number>> = {
}
export async function createTestSubmission(code: Code, input: string) {
const encodedCode = encode(code.value)
const encodedCode = base64EncodeUtf8(code.value)
const id = JUDGE0_LANGUAGE_ID[code.language]
if (id === undefined) {
return { status: null, output: `${code.language} 不支持在线试运行` }
@@ -26,7 +26,7 @@ export async function createTestSubmission(code: Code, input: string) {
const payload = {
source_code: encodedCode,
language_id: id,
stdin: encode(input),
stdin: base64EncodeUtf8(input),
redirect_stderr_to_stdout: true,
compiler_options: compilerOptions,
}
@@ -36,7 +36,10 @@ export async function createTestSubmission(code: Code, input: string) {
const data = response.data
return {
status: data.status && data.status.id,
output: [decode(data.compile_output), decode(data.stdout)]
output: [
base64DecodeUtf8(data.compile_output),
base64DecodeUtf8(data.stdout),
]
.join("\n")
.trim(),
}
+13 -28
View File
@@ -10,7 +10,6 @@ import type {
EmbeddedSubmission as ContractEmbeddedSubmission,
Message as ContractMessage,
Grade,
ProblemDetail,
ProblemDifficulty,
JudgeStatus,
AstRules,
@@ -41,7 +40,7 @@ export interface AcmProblemsStatus {
* 后台用户管理里的用户。`rawPassword` 是明文密码,只有超管专属接口下发 ——
* 老师要能查学生密码,见契约 adminUserSchema 的注释。
*/
export type User = AdminUser & {
export type AdminUserForm = AdminUser & {
// 编辑表单里临时填的新密码,不在响应里
password?: string
}
@@ -111,20 +110,6 @@ export type { UploadTestCaseResponse } from "@oj2/contract"
*/
export type { ProblemTestCaseScore as Testcase } from "@oj2/contract"
/**
* 题目详情。**直接取契约** —— `languages` / `template` 的收窄已经搬进
* `problemDetailSchema`(`z.array(problemLanguageSchema)` 与
* `z.partialRecord(problemLanguageSchema, …)`),这里原来那份
* `Omit<ProblemDetail, "languages" | "template"> & {...}` 与契约**双向可赋值**,
* 即完全等价,是一层没有内容的重复(已用类型探针验证)。
*
* 原来它还多挂三个可选字段(`hasAstRules` / `visible` / `answers`)。它们在
* 契约里**本来就有**(`hasAstRules` 在题目列表项上、`ProblemDetail` 带的是
* `astRequirements`),少数管理端调用点需要补充时应该就地声明自己的类型,
* 不该让一个全局别名对所有调用方声称这些字段存在。
*/
export type Problem = ProblemDetail
/**
* AST 代码要求。原来这里手抄的那份少了 label / exact / outer / inner ——
* 编辑器写得出 label 和 exact,判题机也认,只有这个类型不认。形状在契约里。
@@ -174,15 +159,15 @@ export type BlankProblem = Omit<
mermaidCode: string
}
export interface ProblemFiltered {
export interface ProblemRow {
_id: string
id: number
title: string
// 比赛进行中难度不下发,见 contract 的 maskedProblemDifficultySchema
difficulty: "简单" | "中等" | "困难" | null
tags: string[]
submission: number
rate: string
submissionCount: number
acRate: string
status: "not_test" | "passed" | "failed"
author: string
allowFlowchart: boolean
@@ -190,7 +175,7 @@ export interface ProblemFiltered {
hasAstRules: boolean
}
export interface AdminProblemFiltered {
export interface AdminProblemRow {
_id: string
id: number
title: string
@@ -314,11 +299,11 @@ export type {
ClassUserRank,
} from "@oj2/contract"
/** 后台比赛。oj 侧的 contestSchema 永远不含 password,后台要能看到(告诉学生) */
export type Contest = AdminContest
/** 学生侧的比赛:不含 password / visible */
export type { Contest as OjContest } from "@oj2/contract"
/**
* 学生侧的比赛:不含 password / visible。后台用 `AdminContest` ——
* oj 侧的 contestSchema 永远不含 password,后台要能看到(告诉学生)
*/
export type { Contest, AdminContest } from "@oj2/contract"
export type BlankContest = Omit<
AdminContest,
@@ -331,10 +316,10 @@ export type BlankContest = Omit<
>
/**
* `acm_contest_rank.submission_info` 的 JSONB 内容。**形状已搬进契约**
* (`contestSubmissionInfoSchema`),这里保留别名给既有调用点。
* `acm_contest_rank.submission_info` 的 JSONB 内容,形状在契约
* (`contestSubmissionInfoSchema`)。别和 `submission.info`(判题结果)混了。
*/
export type { ContestSubmissionInfo as SubmissionInfo } from "@oj2/contract"
export type { ContestSubmissionInfo } from "@oj2/contract"
/**
* 榜单行。`submissionInfo` 的收窄(JSONB 原文的 snake_case 形状)已经搬进