Deploy / deploy (push) Has been cancelled
信封是 Django 时代的形状:拦截器手工造一个**恒为 null** 的 error 字段,再把
真正的载荷塞进 data。后端 http.ts 的 success 其实只返回 { data },那个 error
从头到尾没人用 —— 全站成功路径读 res.error 的只有 admin/api.ts 的
resetPassword 一处,而它自己就是个把信封拆开再重新包一遍的 shim。
代价是每个调用点都要 .data 一次:47 个组件、3 个 api 层文件、200 多处。
现在拦截器直接返回 response.data.data,ApiResponse<T> 退化成 T,文件末尾那句
`as unknown as Api2Client` 的类型谎言也少了一层。失败路径不动,仍然 reject
`{ error: 错误码, data: 文案 }` —— 和成功路径不对称是故意的,成功没有错误码
可言,接口注释里写清楚了。
顺带把 api2 改回 api:utils/ 下早就没有 api.ts 了,"2" 是迁移期用来和旧
client 区分的,现在只剩下让人多想一秒的作用。
## 怎么改的
**没有全局 sed。** 先把客户端的返回类型从 Promise<ApiResponse<T>> 改成
Promise<T>,让 vue-tsc 把每一处报出来(210 条),再按它给的 file:line:col
精确删 `.data`(192 处),剩下的手工处理:
- 6 处 `const { data } = await ...` 解构 → `const data = await ...`
- 3 个 api 层函数(getProfile / getProblem / getSubmission)自己手工造信封,
改成直接返回值;getProfile 的返回类型跟着从 ApiResponse<Profile|null>
变成 Profile|null
**类型检查抓不到的,人工把剩下的每一处 `.data` 过了一遍** —— 载荷本身带
data 字段、或者载荷是索引签名时,`res.data` 照样过类型。这一遍捞出三条真 bug:
- `getTutorialList` 的载荷是 `{ [key: string]: TutorialListItem[] }`(按
python / c 分组)。索引签名让 `res.data` 编译通过、运行时是 undefined ——
改完信封之后教程列表会**两个 tab 全空且不报错**。实跑确认过修好了。
- `createExercise` / `updateExercise` 返回 `res.data`,而 Exercise 自己有
data 字段(练习内容)。两个调用方都不看返回值,所以类型和运行时都不响。
- `getSimilarProblems` 的 `.then(r => ({ ...r, data: r.data.map(...) }))`
删掉 .data 之后变成往对象里摊一个数组,能跑但形状是错的。
另外两处是**对的**,加了注释免得下次被"顺手清理"掉:
StatisticsPanel 的 `res.data` 是契约 submissionStatisticsSchema 自己的 data
字段(每个学生一行);download.ts 是独立 axios 实例,`res.data` 是 axios 的
响应体(zip 二进制,不走信封)。
## 验证
tsc(apps/api) 0 error、check:routes 168 条无遮蔽、vue-tsc 0 error、vite build
通过。**因为这改动碰的是每一个请求,静态检查不够,起了全套服务用浏览器实跑:**
- oj 侧 12 个页面 + 后台 13 个页面逐个打开,断言没有重定向、console 无报错。
- 关键页面进一步断言渲染出了真数据(后台用户列表 3 行、题目列表 10 行、
站点配置表单三个输入框有值、教程列表分组正确)。
- 三条写路径实打:重置密码(库里 student123 → 531554,表格当场刷新)、
公告可见性开关(走 getAnnouncement + editAnnouncement,就是手改解构那处,
库里 visible t → f)、提交代码(POST → 判题机真跑出 -2 → 提交列表和详情页
都正确渲染状态、语言、代码)。
- /rank 有一条 `{error: "class-missing"}` 的未捕获 reject,stash 掉本次改动
复现同样报错,**是既有问题**,不在本次范围内。
本地 dev 库为了打通后台测试改了三处,都只影响本机:devadmin 补了 email 和
user_profile 行(原来缺这两样,getProfile 报 profile-not-found,AUTHED 存不
进去,所有 /admin 路由被守卫弹回首页)、密码重置成 devpass123。冒烟用的教程/
公告/提交三条测试数据已删干净,题目和用户的提交计数也回滚了。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
439 lines
12 KiB
Vue
439 lines
12 KiB
Vue
<script lang="ts" setup>
|
||
import { toRefs } from "vue"
|
||
|
||
// 工具函数
|
||
import { atou, utoa } from "utils/functions"
|
||
|
||
// 组合式函数
|
||
import { useBreakpoints } from "shared/composables/breakpoints"
|
||
import { useMermaid } from "shared/composables/useMermaid"
|
||
import { useMermaidConverter } from "../composables/useMermaidConverter"
|
||
import {
|
||
useFlowchartWebSocket,
|
||
type FlowchartEvaluationUpdate,
|
||
} from "shared/composables/websocket"
|
||
import { useMyFlowchartStore } from "shared/store/myFlowchart"
|
||
|
||
// API 和状态管理
|
||
import {
|
||
getCurrentProblemFlowchartSubmission,
|
||
getFlowchartSubmissionDetail,
|
||
submitFlowchart,
|
||
} from "oj/api"
|
||
import { useProblemStore } from "oj/store/problem"
|
||
|
||
// ==================== 类型定义 ====================
|
||
interface Rating {
|
||
score: number
|
||
grade: string
|
||
}
|
||
|
||
interface Evaluation extends Rating {
|
||
feedback: string
|
||
suggestions: string
|
||
criteria_details: {
|
||
[key: string]: { score: number; max: number; comment: string }
|
||
}
|
||
}
|
||
|
||
// ==================== 组合式函数和响应式变量 ====================
|
||
interface FlowchartEditorInstance {
|
||
getFlowchartData: () => { nodes: unknown[]; edges: unknown[] }
|
||
setFlowchartData: (data: { nodes: unknown[]; edges: unknown[] }) => void
|
||
}
|
||
|
||
// 通过inject获取FlowchartEditor组件的引用
|
||
const flowchartEditorRef =
|
||
inject<Ref<FlowchartEditorInstance | null>>("flowchartEditorRef")
|
||
const mermaidContainer = useTemplateRef<HTMLElement>("mermaidContainer")
|
||
|
||
// 基础组合式函数
|
||
const message = useMessage()
|
||
const problemStore = useProblemStore()
|
||
const { problem } = toRefs(problemStore)
|
||
const { isDesktop } = useBreakpoints()
|
||
const myFlowchartStore = useMyFlowchartStore()
|
||
const { convertToMermaid } = useMermaidConverter()
|
||
const { renderError, renderFlowchart } = useMermaid()
|
||
|
||
// 状态管理
|
||
const rendering = ref(false)
|
||
const loading = ref(false)
|
||
const latestRating = ref<Rating>({ score: 0, grade: "" })
|
||
const modalRating = ref<Rating>({ score: 0, grade: "" })
|
||
const submissionCount = ref(0)
|
||
const myFlowchartZippedStr = ref("")
|
||
const myMermaidCode = ref("")
|
||
const showDetailModal = ref(false)
|
||
const evaluation = ref<Evaluation>({
|
||
score: 0,
|
||
grade: "",
|
||
feedback: "",
|
||
suggestions: "",
|
||
criteria_details: {},
|
||
})
|
||
const page = ref(1)
|
||
const lastSubmittedMermaidCode = ref("")
|
||
const suggestionLines = computed(() =>
|
||
splitSuggestionLines(evaluation.value.suggestions),
|
||
)
|
||
|
||
function splitSuggestionLines(suggestions?: string | null) {
|
||
return suggestions
|
||
? suggestions
|
||
.split("\n")
|
||
.map((suggestion) => suggestion.trim())
|
||
.filter(Boolean)
|
||
: []
|
||
}
|
||
|
||
// ==================== WebSocket 相关函数 ====================
|
||
const handleWebSocketMessage = (data: FlowchartEvaluationUpdate) => {
|
||
if (data.type === "flowchart_evaluation_completed") {
|
||
loading.value = false
|
||
const grade = data.grade || ""
|
||
latestRating.value = { score: data.score || 0, grade }
|
||
message.success(`流程图评分完成!得分: ${data.score}分 (${grade}级)`)
|
||
if ((grade === "A" || grade === "S") && lastSubmittedMermaidCode.value) {
|
||
myFlowchartStore.show(lastSubmittedMermaidCode.value)
|
||
}
|
||
} else if (data.type === "flowchart_evaluation_failed") {
|
||
loading.value = false
|
||
message.error(`流程图评分失败: ${data.error}`)
|
||
}
|
||
}
|
||
|
||
// 创建 WebSocket 连接
|
||
const { connect, disconnect, subscribe } = useFlowchartWebSocket(
|
||
handleWebSocketMessage,
|
||
)
|
||
|
||
// 订阅提交更新
|
||
function subscribeToSubmission(submissionId: string) {
|
||
subscribe(submissionId)
|
||
}
|
||
|
||
// ==================== 提交相关函数 ====================
|
||
// 提交流程图
|
||
async function submitFlowchartData() {
|
||
if (!flowchartEditorRef?.value) return
|
||
|
||
// 获取流程图的JSON数据
|
||
const flowchartData = flowchartEditorRef.value.getFlowchartData()
|
||
|
||
if (!flowchartData?.nodes?.length || !flowchartData?.edges?.length) {
|
||
message.error("流程图节点或边不能为空")
|
||
return
|
||
}
|
||
|
||
const mermaidCode = convertToMermaid(flowchartData)
|
||
lastSubmittedMermaidCode.value = mermaidCode
|
||
const compressed = utoa(JSON.stringify(flowchartData))
|
||
|
||
loading.value = true
|
||
latestRating.value = { score: 0, grade: "" }
|
||
|
||
try {
|
||
const response = await submitFlowchart({
|
||
problemId: problem.value!.id,
|
||
mermaidCode,
|
||
flowchartData: {
|
||
compressed: true,
|
||
data: compressed,
|
||
},
|
||
})
|
||
|
||
// 获取提交ID并订阅更新
|
||
const submissionId = response.submissionId
|
||
|
||
if (submissionId) {
|
||
subscribeToSubmission(submissionId)
|
||
}
|
||
|
||
message.success("流程图已提交,请耐心等待评分")
|
||
} catch (error) {
|
||
loading.value = false
|
||
message.error("流程图提交失败")
|
||
console.error("提交流程图失败:", error)
|
||
}
|
||
}
|
||
|
||
// 提交函数
|
||
function submit() {
|
||
submitFlowchartData()
|
||
}
|
||
|
||
// ==================== 数据获取和处理函数 ====================
|
||
|
||
async function getCurrentSubmission() {
|
||
if (!problem.value?.id) return
|
||
const data = await getCurrentProblemFlowchartSubmission(problem.value.id)
|
||
submissionCount.value = data.count
|
||
latestRating.value = {
|
||
score: data.score,
|
||
grade: data.grade,
|
||
}
|
||
}
|
||
|
||
async function getSubmission(submissionPage = 0) {
|
||
if (!problem.value?.id) return
|
||
const data = await getFlowchartSubmissionDetail(
|
||
problem.value.id,
|
||
submissionPage,
|
||
)
|
||
submissionCount.value = data.count
|
||
const submission = data.submission
|
||
// 翻到没有提交的页时后端返回 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.aiScore ?? 0,
|
||
grade: (submission.aiGrade ?? "") as Rating["grade"],
|
||
}
|
||
evaluation.value = {
|
||
score: submission.aiScore ?? 0,
|
||
grade: (submission.aiGrade ?? "") as Rating["grade"],
|
||
feedback: submission.aiFeedback ?? "",
|
||
suggestions: submission.aiSuggestions ?? "",
|
||
criteria_details:
|
||
submission.aiCriteriaDetails as Evaluation["criteria_details"],
|
||
}
|
||
}
|
||
|
||
async function updatePage(val: number) {
|
||
page.value = val
|
||
rendering.value = true
|
||
await getSubmission(val)
|
||
// 等待 DOM 更新
|
||
await nextTick()
|
||
await renderFlowchart(mermaidContainer.value, myMermaidCode.value)
|
||
rendering.value = false
|
||
}
|
||
|
||
// ==================== 模态框相关函数 ====================
|
||
async function openDetailModal() {
|
||
showDetailModal.value = true
|
||
rendering.value = true
|
||
await getSubmission()
|
||
page.value = submissionCount.value
|
||
// 等待 DOM 更新,确保弹框已经渲染
|
||
await nextTick()
|
||
await renderFlowchart(mermaidContainer.value, myMermaidCode.value)
|
||
rendering.value = false
|
||
}
|
||
|
||
function closeModal() {
|
||
showDetailModal.value = false
|
||
}
|
||
|
||
function loadToEditor() {
|
||
if (myFlowchartZippedStr.value) {
|
||
const str = atou(myFlowchartZippedStr.value)
|
||
const json = JSON.parse(str)
|
||
const processedData = {
|
||
nodes: json.nodes || [],
|
||
edges: json.edges || [],
|
||
}
|
||
if (flowchartEditorRef?.value) {
|
||
flowchartEditorRef.value.setFlowchartData(processedData)
|
||
}
|
||
}
|
||
closeModal()
|
||
}
|
||
|
||
// ==================== 工具函数 ====================
|
||
const getGradeType = (grade: string) => {
|
||
if (grade === "S") return "primary"
|
||
if (grade === "A") return "info"
|
||
if (grade === "B") return "warning"
|
||
return "error"
|
||
}
|
||
|
||
const getPercentType = (percent: number) => {
|
||
if (percent >= 0.8) return "primary"
|
||
else if (percent >= 0.6) return "info"
|
||
else if (percent >= 0.4) return "warning"
|
||
return "error"
|
||
}
|
||
|
||
// ==================== 生命周期钩子 ====================
|
||
onMounted(async () => {
|
||
connect()
|
||
await getCurrentSubmission()
|
||
page.value = submissionCount.value
|
||
const grade = latestRating.value.grade
|
||
if ((grade === "A" || grade === "S") && submissionCount.value > 0) {
|
||
await getSubmission(submissionCount.value)
|
||
if (myMermaidCode.value) {
|
||
myFlowchartStore.show(myMermaidCode.value)
|
||
}
|
||
}
|
||
})
|
||
|
||
// 组件卸载时断开连接
|
||
onUnmounted(() => {
|
||
disconnect()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<!-- 主要操作区域 -->
|
||
<n-flex align="center">
|
||
<!-- 提交按钮 -->
|
||
<n-button
|
||
:size="isDesktop ? 'medium' : 'small'"
|
||
type="primary"
|
||
:loading="loading"
|
||
:disabled="loading"
|
||
@click="submit"
|
||
>
|
||
{{ loading ? "AI 点评中..." : "提交流程图" }}
|
||
</n-button>
|
||
|
||
<!-- 评分结果按钮 -->
|
||
<n-button
|
||
secondary
|
||
v-if="latestRating.grade"
|
||
@click="openDetailModal"
|
||
:type="getGradeType(latestRating.grade)"
|
||
>
|
||
{{ latestRating.score }}分 {{ latestRating.grade }}级
|
||
</n-button>
|
||
|
||
<!-- 流程图评分详情模态框 -->
|
||
<n-modal v-model:show="showDetailModal" preset="card" style="width: 1000px">
|
||
<template #header>
|
||
<n-flex align="center">
|
||
<n-text>流程图评分详情</n-text>
|
||
<n-text :type="getGradeType(modalRating.grade)">
|
||
{{ modalRating.score }}分 {{ modalRating.grade }}级
|
||
</n-text>
|
||
</n-flex>
|
||
</template>
|
||
<n-grid :cols="5" :x-gap="16">
|
||
<!-- 左侧:流程图预览区域 -->
|
||
<n-gi :span="3">
|
||
<div class="flowchart">
|
||
<n-spin :show="rendering">
|
||
<n-alert v-if="renderError" type="error" title="流程图渲染失败">
|
||
{{ renderError }}
|
||
</n-alert>
|
||
<div class="flowchart" v-else ref="mermaidContainer"></div>
|
||
</n-spin>
|
||
</div>
|
||
<!-- 加载到编辑器按钮 -->
|
||
<n-flex style="margin-top: 16px" justify="center">
|
||
<n-button @click="loadToEditor" type="primary">
|
||
加载到流程图编辑器
|
||
</n-button>
|
||
</n-flex>
|
||
</n-gi>
|
||
|
||
<!-- 右侧:评分详情区域 -->
|
||
<n-gi :span="2" style="max-height: 550px; overflow: auto">
|
||
<!-- AI反馈 -->
|
||
<n-card
|
||
v-if="evaluation.feedback"
|
||
size="small"
|
||
title="AI反馈"
|
||
style="margin-bottom: 16px"
|
||
>
|
||
<n-text>{{ evaluation.feedback }}</n-text>
|
||
</n-card>
|
||
|
||
<!-- 改进建议 -->
|
||
<n-card
|
||
v-if="suggestionLines.length"
|
||
size="small"
|
||
title="改进建议"
|
||
style="margin-bottom: 16px"
|
||
>
|
||
<n-flex vertical :size="6">
|
||
<n-text
|
||
v-for="(suggestion, index) in suggestionLines"
|
||
:key="`${index}-${suggestion}`"
|
||
>
|
||
{{ suggestion }}
|
||
</n-text>
|
||
</n-flex>
|
||
</n-card>
|
||
|
||
<!-- 详细评分 -->
|
||
<n-card
|
||
v-if="evaluation.criteria_details"
|
||
size="small"
|
||
title="详细评分"
|
||
>
|
||
<div
|
||
v-for="(detail, key) in evaluation.criteria_details"
|
||
:key="key"
|
||
style="margin-bottom: 12px"
|
||
>
|
||
<!-- 评分项标题和分数 -->
|
||
<n-flex
|
||
justify="space-between"
|
||
align="center"
|
||
style="margin-bottom: 4px"
|
||
>
|
||
<n-text strong>{{ key }}</n-text>
|
||
<n-tag
|
||
:type="getPercentType(detail.score / detail.max)"
|
||
size="small"
|
||
round
|
||
>
|
||
{{ detail.score || 0 }}分 / {{ detail.max }}分
|
||
</n-tag>
|
||
</n-flex>
|
||
<!-- 评分项详细说明 -->
|
||
<n-text v-if="detail.comment" depth="3" style="font-size: 12px">
|
||
{{ detail.comment }}
|
||
</n-text>
|
||
</div>
|
||
</n-card>
|
||
</n-gi>
|
||
</n-grid>
|
||
|
||
<!-- 分页组件 -->
|
||
<n-flex
|
||
justify="center"
|
||
style="margin-top: 24px"
|
||
v-if="submissionCount > 1"
|
||
>
|
||
<n-pagination
|
||
v-model:page="page"
|
||
:page-count="submissionCount"
|
||
@update-page="updatePage"
|
||
/>
|
||
</n-flex>
|
||
</n-modal>
|
||
</n-flex>
|
||
</template>
|
||
<style scoped>
|
||
/* ==================== 流程图样式 ==================== */
|
||
.flowchart {
|
||
height: 500px;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
}
|
||
|
||
/* 确保 SVG 图表占满容器 */
|
||
:deep(.flowchart > svg) {
|
||
height: 100%;
|
||
}
|
||
</style>
|