Deploy / deploy (push) Canceled after 0s
浏览器实测(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>
348 lines
10 KiB
Vue
348 lines
10 KiB
Vue
<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>
|