Files
OJ2/apps/web/src/oj/problem/components/EditorToolbar.vue
T
xuyueandClaude Opus 5.5 a698ebf585
Deploy / deploy (push) Canceled after 0s
perf(前端): 首页和题目详情页按需加载,首页 477 → 380KB、详情页 1031 → 682KB(传输量)
浏览器实测(preview 构建,生产规模数据):首页解压后 1428 → 1112KB,
未登录的题目详情页 3317 → 2021KB。后端接口 6–8ms,不是瓶颈,没动。

首页:
- 登录速报弹框挂在默认布局上,静态 import 了 md-editor-v3(~160KB JS + 76KB CSS),
  而 AI 分析只在登录后、提交满 3 次才有。MdPreview 改成异步组件,目标是本地包装
  LoginSummaryPreview.vue —— 直接 import("md-editor-v3") 会让 rolldown 把它和
  CodeMirror 语言包并成一个 467KB 的 chunk,排名页反而涨了 560KB(实测)
- 题目列表经 oj/api.ts 还带着 zod(~70KB):那个文件静态 import 了题目详情、提交详情
  两个 schema。改成和请求并行动态加载(单独的 oj/detailSchemas.ts,理由同
  profileSchema.ts),闸门仍是同步的 —— submissionDetailSchema 有 .catch(),
  不能改 contractDeferred

题目详情页:
- skulpt(~226KB gzip)原来一选 Python 就立刻预取。改成登录后才取(没登录交不了),
  且等 requestIdleCallback,不和题面、编辑器抢带宽
- StatisticsPanel 只有老师可见,却被 EditorToolbar 静态 import,每个学生都白拉一份
  chart.js(~68KB gzip)。改成异步组件,打开课堂统计时才加载
- SubmissionResult(带 DataTable)改成异步组件,第一次提交弹出结果面板时才加载

浏览器核过:首页正常;详情页未登录不再加载 skulpt / chart.js / DataTable;学生登录后
skulpt 空闲预取、提交后结果面板正常弹出;老师点课堂统计按需加载图表并正常渲染。
登录速报的 AI 分析与语法错误拦截路径本机没能触发,未在浏览器里核。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:34:32 +08:00

348 lines
10 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { storeToRefs } from "pinia"
import { copyToClipboard, compressToBase64 } from "utils/functions"
import { useCodeStore } from "oj/store/code"
import { useProblemStore } from "oj/store/problem"
import { useCollabStore } from "shared/store/collab"
import {
ICON_SET,
LANGUAGE_FORMAT_VALUE,
LANGUAGE_SHOW_VALUE,
SOURCES,
STORAGE_KEY,
} from "utils/constants"
import { useBreakpoints } from "shared/composables/breakpoints"
import { useUserStore } from "shared/store/user"
import storage from "utils/storage"
import type { LANGUAGE } from "utils/types"
import { Icon } from "@iconify/vue"
import { NFlex } from "naive-ui"
import SubmitCode from "./SubmitCode.vue"
const SubmitFlowchart = defineAsyncComponent(
() => import("./SubmitFlowchart.vue"),
)
// 只有老师看得见(下面的弹框挂了 isTeacherOrAbove),静态 import 的话每个学生
// 打开题目都要白拉一份 chart.js(~68KB gzip)
const StatisticsPanel = defineAsyncComponent(
() => import("shared/components/StatisticsPanel.vue"),
)
interface Props {
storageKey: string
}
const { storageKey } = defineProps<Props>()
const collabStore = useCollabStore()
const emit = defineEmits<{
changeLanguage: [v: LANGUAGE]
}>()
const message = useMessage()
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const codeStore = useCodeStore()
const problemStore = useProblemStore()
const { problem, languages } = storeToRefs(problemStore)
const { isDesktop } = useBreakpoints()
const statisticPanel = ref(false)
// 计算属性
const isContestMode = computed(() => route.name === "contest problem")
const buttonSize = computed(() => (isDesktop.value ? "medium" : "small"))
// 可见条件沿用原来的 showSyncFeature,再加上「不是教师」——
// 教师端的入口在顶栏,不在题目页
const showHelpButton = computed(
() =>
isDesktop.value &&
userStore.isAuthed &&
!userStore.isTeacherOrAbove &&
// 演示模式下协作通道是断开的(见 App.vue),按钮点了也没人收
!userStore.demoMode &&
codeStore.code.language !== "Flowchart" &&
!isContestMode.value,
)
/**
* 教师端:协作就开在这道题上。接单后直接跳到题目页协作(原来是 CollabModal 弹框),
* 所以状态和「结束协作」得摆在题目页的工具栏上 —— 也只有这一个按钮能结束,
* 不像弹框那样按一下 Esc 就把协作关掉了。
*/
const collabHere = computed(
() =>
collabStore.room !== null &&
collabStore.room.problemId === problem.value?._id,
)
const showCollabBar = computed(
() => collabHere.value && userStore.isTeacherOrAbove,
)
/**
* 状态全塞进按钮本身。原来旁边还挂一个 n-tag 说明排队情况,一行工具栏
* (语言 / 提交 / 提交信息 / 课堂统计 / 更多操作 / 求助)在 1280 的机房屏上放不下。
*/
const helpButtonText = computed(() => {
if (collabStore.helpStatus === "active") {
const name = collabStore.teacherName
return name ? `${name} 老师帮你中` : "老师正在帮你"
}
if (collabStore.helpStatus === "pending") {
return collabStore.queueAhead > 0
? `已求助 · 前面 ${collabStore.queueAhead} 人`
: "已求助 · 待接入"
}
return "求助"
})
const helpButtonType = computed(() => {
if (collabStore.helpStatus === "active") return "success"
if (collabStore.helpStatus === "pending") return "warning"
return "default"
})
// 排队中点一下就是取消,这句话再挤进 label 就太长了,挂在原生 title 上。
// active 时按钮是 disabled,浏览器不会给 disabled 元素显示 title,所以不放
const helpButtonTitle = computed(() =>
collabStore.helpStatus === "pending" ? "点击取消求助" : undefined,
)
const toggleHelp = () => {
if (collabStore.helpStatus === "pending") collabStore.cancelHelp()
else if (collabStore.helpStatus === "idle")
collabStore.requestHelp(problem.value!._id, codeStore.code.language)
}
const showGoSubmissionButton = computed(() => {
if (isContestMode.value) return true
else if (userStore.isAdminRole) return true
else if (userStore.showSubmissions) return true
else return false
})
const menuOptions = computed<DropdownOption[]>(() => {
const options: DropdownOption[] = []
// 移动端额外收纳桌面端常驻的两项
if (!isDesktop.value) {
if (showGoSubmissionButton.value) {
options.push({
label: "提交信息",
key: "submissions",
})
}
if (userStore.isTeacherOrAbove) {
options.push({
label: "课堂统计",
key: "statistics",
})
}
}
if (codeStore.code.language !== "Flowchart") {
if (codeStore.code.language !== "SQL") {
options.push({
label: "去自测猫",
key: "testcat",
})
}
options.push({
label: "复制代码",
key: "copy",
})
// 协作中的教师不给「重置代码」:那会儿编辑器里是**学生的**代码,而 v-model
// 一写回去就顺着 Yjs 同步过去,等于一键清空学生的作业,他还没法撤回
if (!showCollabBar.value) {
options.push({
label: "重置代码",
key: "reset",
})
}
}
if (isDesktop.value && userStore.isSuperAdmin) {
options.push({
label: "编辑题目",
key: "edit",
})
}
return options
})
const handleMenuSelect = (key: string) => {
switch (key) {
case "submissions":
goSubmissions()
break
case "statistics":
statisticPanel.value = true
break
case "testcat":
goTestCat()
break
case "copy":
copy()
break
case "reset":
reset()
break
case "edit":
goEdit()
break
}
}
const languageOptions: DropdownOption[] = languages.value.map((it) => ({
label: () =>
h(NFlex, { align: "center" }, () => [
h(Icon, {
icon: ICON_SET[it],
width: 16,
}),
LANGUAGE_SHOW_VALUE[it],
]),
value: it,
}))
const copy = async () => {
const success = await copyToClipboard(codeStore.code.value)
message[success ? "success" : "error"](`代码复制${success ? "成功" : "失败"}`)
}
const reset = () => {
codeStore.setCode(
problem.value!.template[codeStore.code.language] ||
SOURCES[codeStore.code.language],
)
storage.remove(storageKey)
message.success("代码重置成功")
}
const changeLanguage = (v: LANGUAGE) => {
storage.set(STORAGE_KEY.LANGUAGE, v)
emit("changeLanguage", v)
}
const goTestCat = () => {
const lang = LANGUAGE_FORMAT_VALUE[codeStore.code.language]
const data = {
lang,
code: codeStore.code.value,
input: problemStore.problem?.samples[0].input,
}
const base64 = compressToBase64(JSON.stringify(data))
const url = `${import.meta.env.PUBLIC_CODE_URL}?share=${encodeURIComponent(base64)}`
window.open(url, "_blank")
}
const goSubmissions = () => {
const name = route.params.contestID ? "contest submissions" : "submissions"
const target = { name, query: { problem: problem.value!._id } }
// 协作中走新标签:教师端「页面即协作现场」,跳走这一页协作就结束了
// (求助会退回排队,但老师还得再接一次)。而「看看这学生都交了什么」恰好是
// 协作时最常点的一个按钮 —— 这是整条工具栏上唯一会跳路由的按钮
if (showCollabBar.value) {
window.open(router.resolve(target).href, "_blank")
return
}
router.push(target)
}
const goEdit = () => {
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")
}
onMounted(() => {
if (!languages.value.includes(codeStore.code.language)) {
// 回退到题目支持的第一种语言(如 SQL 题只有 "SQL",硬编码 Python 会被后端拒绝)
codeStore.code.language = languages.value[0] ?? "Python"
}
})
</script>
<template>
<n-flex align="center">
<!-- 协作中编辑器的语言跟着学生走,这个选择器改了也不会生效,索性禁掉 -->
<n-select
v-model:value="codeStore.code.language"
style="width: 120px"
:size="buttonSize"
:options="languageOptions"
:disabled="showCollabBar"
@update:value="changeLanguage"
/>
<SubmitFlowchart v-if="codeStore.code.language === 'Flowchart'" />
<SubmitCode v-else />
<n-button
v-if="isDesktop && showGoSubmissionButton"
:size="buttonSize"
@click="goSubmissions"
>
提交信息
</n-button>
<n-button
v-if="isDesktop && userStore.isTeacherOrAbove"
:size="buttonSize"
@click="statisticPanel = true"
>
课堂统计
</n-button>
<!-- 自测猫 / 复制代码 / 重置代码 / 编辑题目 收进下拉菜单;移动端再加上提交信息 / 课堂统计 -->
<n-dropdown
v-if="menuOptions.length"
trigger="click"
:options="menuOptions"
@select="handleMenuSelect"
>
<n-button :size="buttonSize">更多操作</n-button>
</n-dropdown>
<template v-if="showCollabBar">
<n-tag type="success" :size="buttonSize">
正在帮 {{ collabStore.room!.peerName }} ·
{{ LANGUAGE_SHOW_VALUE[collabStore.room!.language] }}
</n-tag>
<!-- 显式的「结束」:求助记录一并清掉。跳走页面发的是 leave("left"),
那边只是退回排队 —— 见 store 里 leave 的注释 -->
<n-button
:size="buttonSize"
type="primary"
@click="collabStore.leave('done')"
>
结束协作
</n-button>
</template>
<n-button
v-if="showHelpButton"
:size="buttonSize"
:type="helpButtonType"
:disabled="collabStore.helpStatus === 'active'"
:title="helpButtonTitle"
@click="toggleHelp"
>
{{ helpButtonText }}
</n-button>
</n-flex>
<n-modal
v-if="userStore.isTeacherOrAbove"
v-model:show="statisticPanel"
preset="card"
title="提交记录的统计"
:style="{ maxWidth: isDesktop && '800px', maxHeight: '80vh' }"
:content-style="{ overflow: 'auto' }"
>
<StatisticsPanel :problem="problem!._id" username="" />
</n-modal>
</template>