AI 评阅:DeepSeek Flash 给老师评分项出建议分和评语,老师采纳后生效
- 学生提交时浏览器给每个窗体截图(运行时同一套 FormWindow 在屏幕外渲染),随提交保存 - 自动评分后进入 AI 评阅队列:按书上评价表给老师评分项打建议分、写评语(JSON 输出,服务器再校验、限制分数); 发出的内容不含姓名学号;学生代码里像指令的文字一律当作品内容;学生又交了新的只评最新的 - 提示词按教材命名习惯校准("缩写_含义"、书上规定的控件名、界面上不用的标签保留默认名不扣分): 教材参考答案 28/30,默认控件名且没有注释 15/30 - 评阅页:界面截图标签页、AI 建议卡片(采纳、重新评阅)、全部采纳(不覆盖老师已评的分)、评阅后又提交的提醒; 成绩表显示"AI 建议,待确认";学生在老师确认前看不到 AI 的建议 - 规范提示移到 shared,服务端组装提示词时也用 - 配置 DEEPSEEK_API_KEY、AI_MODEL;部署说明提醒 NPM 调大请求体上限 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
302eae4f13
commit
14a82a3e24
25 files changed
+700
-44
No files matched your search
@@ -0,0 +1,49 @@
|
||||
// 提交作业时给每个窗体截一张图:用设计器的预览树和运行时同一套 FormWindow 在屏幕外渲染,再转成 PNG。
|
||||
// 截图随提交保存,老师在评阅页直接看界面,AI 评阅也据此评"窗体界面"。截图失败不影响提交。
|
||||
import { createApp, h, nextTick } from 'vue'
|
||||
import { domToPng } from 'modern-screenshot'
|
||||
import type { Screenshot, SourceFile } from '@iot/shared'
|
||||
import { parseDesigner } from '../designer/document'
|
||||
import { previewTree } from '../designer/preview'
|
||||
import FormWindow from '../winforms/FormWindow.vue'
|
||||
|
||||
const MAX_FORMS = 6
|
||||
/** 和服务器的限制一致:base64 超过这个长度的截图不提交 */
|
||||
const MAX_PNG_LENGTH = 700_000
|
||||
|
||||
export async function captureForms(files: SourceFile[]): Promise<Screenshot[]> {
|
||||
const designers = files.filter((f) => f.path.endsWith('.Designer.cs')).slice(0, MAX_FORMS)
|
||||
if (designers.length === 0) return []
|
||||
const host = document.createElement('div')
|
||||
host.style.cssText = 'position: fixed; left: -10000px; top: 0; width: 2000px; height: 2000px; pointer-events: none;'
|
||||
document.body.append(host)
|
||||
const shots: Screenshot[] = []
|
||||
try {
|
||||
for (const file of designers) {
|
||||
let form
|
||||
try {
|
||||
form = previewTree(parseDesigner(file.content))
|
||||
} catch {
|
||||
continue // Designer.cs 被改得解析不了:这个窗体不截图
|
||||
}
|
||||
const holder = document.createElement('div')
|
||||
host.append(holder)
|
||||
const app = createApp({ render: () => h(FormWindow, { form, modal: false, offset: 0, act: () => {} }) })
|
||||
app.mount(holder)
|
||||
await nextTick()
|
||||
const frame = holder.querySelector<HTMLElement>('.wf-window')
|
||||
try {
|
||||
if (frame) {
|
||||
const png = (await domToPng(frame, { scale: 1 })).replace(/^data:image\/png;base64,/, '')
|
||||
if (png.length <= MAX_PNG_LENGTH) shots.push({ form: file.path.replace(/\.Designer\.cs$/, ''), png })
|
||||
}
|
||||
} finally {
|
||||
app.unmount()
|
||||
holder.remove()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
host.remove()
|
||||
}
|
||||
return shots
|
||||
}
|
||||
+199
-12
@@ -1,15 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
// 评阅一个作业:左边是全班学生(提交状态、自动评分、老师评分),右边是选中学生的代码、自动评分结果、
|
||||
// 规范提示和老师评分。"运行他的程序"在新标签页的实验页里打开学生的代码。
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
// 评阅一个作业:左边是全班学生(提交状态、自动评分、老师评分),右边是选中学生的代码和界面截图、自动评分结果、
|
||||
// 规范提示、AI 建议和老师评分。"运行他的程序"在新标签页的实验页里打开学生的代码。
|
||||
// AI 建议只是参考:老师采纳(或修改后保存)才成为老师评分,学生才能看到。
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { NButton, NEmpty, NInput, NInputNumber, NTab, NTabs, NTag, useMessage } from 'naive-ui'
|
||||
import type { Assignment, Review, SourceFile, Submission } from '@iot/shared'
|
||||
import { NButton, NEmpty, NInput, NInputNumber, NSpin, NTab, NTabs, NTag, useDialog, useMessage } from 'naive-ui'
|
||||
import type { AiReview, Assignment, Review, Screenshot, SourceFile, Submission } from '@iot/shared'
|
||||
import { api } from '../api'
|
||||
import { currentUser, logout } from '../auth'
|
||||
import CodeEditor from '../editor/CodeEditor.vue'
|
||||
import GradeResultView from '../grading/GradeResultView.vue'
|
||||
import { conventionHints } from '../teacher/conventions'
|
||||
import { conventionHints } from '@iot/shared'
|
||||
import { downloadCsv } from '../teacher/csv'
|
||||
|
||||
interface Row {
|
||||
@@ -20,6 +21,9 @@ interface Row {
|
||||
autoScore: number | null
|
||||
manualScore: number | null
|
||||
review: Review | null
|
||||
submissionId: number | null
|
||||
aiStatus: AiReview['status'] | null
|
||||
aiManual: number | null
|
||||
submittedAt: number | null
|
||||
count: number
|
||||
}
|
||||
@@ -29,14 +33,20 @@ interface Detail {
|
||||
student: { id: number; username: string; name: string }
|
||||
submission: Submission | null
|
||||
files: SourceFile[] | null
|
||||
screenshots: Screenshot[]
|
||||
aiReview: AiReview | null
|
||||
review: Review | null
|
||||
submissionCount: number
|
||||
}
|
||||
|
||||
/** 代码区"界面截图"标签页的名字(不会和文件名冲突) */
|
||||
const SCREENS = '界面截图'
|
||||
|
||||
const props = defineProps<{ assignmentId: string }>()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const message = useMessage()
|
||||
const dialog = useDialog()
|
||||
|
||||
const assignment = ref<Assignment | null>(null)
|
||||
const rows = ref<Row[]>([])
|
||||
@@ -87,8 +97,71 @@ async function select(userId: number | undefined) {
|
||||
const manual = assignment.value?.scoring.manual ?? []
|
||||
scores.value = manual.map((_, i) => detail.value?.review?.scores[i] ?? null)
|
||||
comment.value = detail.value?.review?.comment ?? ''
|
||||
// 默认显示第一个窗体的代码
|
||||
activeFile.value = codeFiles.value.find((f) => !f.path.endsWith('.Designer.cs') && f.path !== 'Program.cs')?.path ?? codeFiles.value[0]?.path
|
||||
// 默认先看界面截图,没有截图时看第一个窗体的代码
|
||||
activeFile.value = detail.value?.screenshots.length
|
||||
? SCREENS
|
||||
: (codeFiles.value.find((f) => !f.path.endsWith('.Designer.cs') && f.path !== 'Program.cs')?.path ?? codeFiles.value[0]?.path)
|
||||
pollAi()
|
||||
}
|
||||
|
||||
// ---------- AI 建议 ----------
|
||||
let aiTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
/** AI 评阅还在排队或进行中时,每 3 秒刷新一次这个学生 */
|
||||
function pollAi() {
|
||||
clearTimeout(aiTimer)
|
||||
if (detail.value?.aiReview?.status !== 'pending') return
|
||||
const userId = detail.value.student.id
|
||||
aiTimer = setTimeout(async () => {
|
||||
const latest = await api<Detail>('GET', `/api/teacher/assignments/${props.assignmentId}/students/${userId}`).catch(() => null)
|
||||
if (latest && selectedId.value === userId && detail.value) {
|
||||
detail.value.aiReview = latest.aiReview
|
||||
if (latest.aiReview?.status !== 'pending') await loadRows()
|
||||
}
|
||||
pollAi()
|
||||
}, 3000)
|
||||
}
|
||||
onBeforeUnmount(() => clearTimeout(aiTimer))
|
||||
|
||||
/** 把 AI 的建议分和评语填进老师评分(还要点保存才生效) */
|
||||
function adoptAi() {
|
||||
const ai = detail.value?.aiReview
|
||||
if (ai?.status !== 'done') return
|
||||
scores.value = ai.scores.map((s) => s.score)
|
||||
comment.value = ai.comment
|
||||
}
|
||||
|
||||
async function rerunAi() {
|
||||
const id = detail.value?.submission?.id
|
||||
if (!id || !detail.value) return
|
||||
try {
|
||||
await api('POST', `/api/teacher/submissions/${id}/ai-review`)
|
||||
detail.value.aiReview = { status: 'pending', scores: [], comment: '' }
|
||||
pollAi()
|
||||
} catch (e) {
|
||||
message.error((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
/** 全部采纳:老师还没评阅、已有 AI 建议的学生,一次采纳 */
|
||||
function adoptAll() {
|
||||
const count = rows.value.filter((r) => !r.review && r.aiStatus === 'done').length
|
||||
if (count === 0) {
|
||||
message.info('没有待采纳的 AI 建议(已评阅的学生不会被覆盖)')
|
||||
return
|
||||
}
|
||||
dialog.info({
|
||||
title: '全部采纳 AI 建议',
|
||||
content: `将为 ${count} 个还没评阅的学生采纳 AI 的建议分和评语,学生随即能看到。已经评阅过的学生不受影响。确定吗?`,
|
||||
positiveText: '采纳',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: async () => {
|
||||
const { adopted } = await api<{ adopted: number }>('POST', `/api/teacher/assignments/${props.assignmentId}/adopt-ai`)
|
||||
message.success(`已采纳 ${adopted} 个学生的 AI 建议`)
|
||||
await loadRows()
|
||||
await select(selectedId.value)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function save(next = false) {
|
||||
@@ -130,11 +203,21 @@ watch(selectedId, (_, old) => {
|
||||
const formatTime = (ms: number) =>
|
||||
new Date(ms).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
|
||||
function statusTag(row: Row): { type: 'default' | 'warning' | 'error' | 'success'; text: string } {
|
||||
function statusTag(row: Row): { type: 'default' | 'warning' | 'error' | 'success' | 'info'; text: string } {
|
||||
if (!row.status) return { type: 'default', text: '未提交' }
|
||||
if (row.status === 'pending') return { type: 'warning', text: '评分中' }
|
||||
if (row.status === 'error') return { type: 'error', text: '评分出错' }
|
||||
return { type: row.review ? 'success' : 'default', text: row.review ? '已评阅' : '待评阅' }
|
||||
// 老师评阅的是之前的版本,学生之后又交了新的
|
||||
if (row.review && row.submittedAt && row.submittedAt > row.review.updatedAt) return { type: 'warning', text: '评阅后又提交' }
|
||||
if (row.review) return { type: 'success', text: '已评阅' }
|
||||
if (row.aiStatus === 'done') return { type: 'info', text: 'AI 已评' }
|
||||
return { type: 'default', text: '待评阅' }
|
||||
}
|
||||
|
||||
/** 列表里的分数:已评阅显示总分;只有 AI 建议时显示含 AI 建议的总分 */
|
||||
function rowScore(row: Row) {
|
||||
if (row.status !== 'done') return null
|
||||
return (row.autoScore ?? 0) + (row.manualScore ?? row.aiManual ?? 0)
|
||||
}
|
||||
|
||||
/** 导出这个作业的成绩:每个学生的自动评分、老师评分各项、总分和评语 */
|
||||
@@ -168,6 +251,7 @@ async function signOut() {
|
||||
<strong>{{ assignment?.title }}</strong>
|
||||
<span class="muted">{{ assignment?.className }}</span>
|
||||
<span class="muted">已提交 {{ rows.filter((r) => r.status).length }} / {{ rows.length }} 人</span>
|
||||
<NButton size="small" @click="adoptAll">全部采纳 AI 建议</NButton>
|
||||
<NButton size="small" @click="exportCsv">导出成绩</NButton>
|
||||
<span class="spacer" />
|
||||
<span class="user">{{ currentUser?.name }}</span>
|
||||
@@ -182,7 +266,7 @@ async function signOut() {
|
||||
<span class="username">{{ r.username }}</span>
|
||||
</span>
|
||||
<span class="right">
|
||||
<span v-if="r.status === 'done'" class="score">{{ (r.autoScore ?? 0) + (r.manualScore ?? 0) }}</span>
|
||||
<span v-if="rowScore(r) !== null" class="score" :class="{ suggested: !r.review }">{{ rowScore(r) }}</span>
|
||||
<NTag size="tiny" :type="statusTag(r).type">{{ statusTag(r).text }}</NTag>
|
||||
</span>
|
||||
</button>
|
||||
@@ -203,9 +287,16 @@ async function signOut() {
|
||||
<div v-else class="review-body">
|
||||
<div class="code">
|
||||
<NTabs :value="activeFile" type="card" size="small" @update:value="(v: string) => (activeFile = v)">
|
||||
<NTab v-if="detail.screenshots.length" :name="SCREENS">{{ SCREENS }}</NTab>
|
||||
<NTab v-for="f in codeFiles" :key="f.path" :name="f.path">{{ f.path }}</NTab>
|
||||
</NTabs>
|
||||
<CodeEditor class="editor" :files="codeFiles" :active-file="activeFile" :diagnostics="[]" read-only />
|
||||
<div v-if="activeFile === SCREENS" class="screens">
|
||||
<figure v-for="s in detail.screenshots" :key="s.form">
|
||||
<img :src="`data:image/png;base64,${s.png}`" :alt="s.form" />
|
||||
<figcaption>{{ s.form }}</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<CodeEditor v-show="activeFile !== SCREENS" class="editor" :files="codeFiles" :active-file="activeFile" :diagnostics="[]" read-only />
|
||||
</div>
|
||||
|
||||
<div class="side">
|
||||
@@ -221,8 +312,31 @@ async function signOut() {
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section v-if="detail.aiReview && assignment.scoring.manual.length" class="card ai">
|
||||
<h3>AI 建议</h3>
|
||||
<div v-if="detail.aiReview.status === 'pending'" class="ai-pending"><NSpin size="small" /> AI 评阅中…</div>
|
||||
<template v-else-if="detail.aiReview.status === 'done'">
|
||||
<div v-for="(m, i) in assignment.scoring.manual" :key="i" class="ai-item">
|
||||
<span class="score-title">{{ m.title }}</span>
|
||||
<b>{{ detail.aiReview.scores[i]?.score }}</b><span class="muted"> / {{ m.points }}</span>
|
||||
<div class="ai-reason">{{ detail.aiReview.scores[i]?.reason }}</div>
|
||||
</div>
|
||||
<div v-if="detail.aiReview.comment" class="ai-comment">评语:{{ detail.aiReview.comment }}</div>
|
||||
</template>
|
||||
<div v-else-if="detail.aiReview.status === 'error'" class="ai-error">AI 评阅出错:{{ detail.aiReview.error }}</div>
|
||||
<div v-else class="muted">学生又提交了新的版本,这一份没有 AI 评阅。</div>
|
||||
<div class="save-row">
|
||||
<span class="spacer" />
|
||||
<NButton size="small" quaternary :disabled="detail.aiReview.status === 'pending'" @click="rerunAi">重新评阅</NButton>
|
||||
<NButton size="small" type="primary" secondary :disabled="detail.aiReview.status !== 'done'" @click="adoptAi">采纳 AI 建议</NButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h3>老师评分</h3>
|
||||
<div v-if="detail.review && detail.submission.createdAt > detail.review.updatedAt" class="stale">
|
||||
学生在你评阅之后又提交了新版本,下面是之前的评分,请看过新版本后重新保存。
|
||||
</div>
|
||||
<div v-for="(m, i) in assignment.scoring.manual" :key="i" class="score-row">
|
||||
<span class="score-title">{{ m.title }}</span>
|
||||
<NInputNumber v-model:value="scores[i]" :min="0" :max="m.points" size="small" placeholder="未打分" class="score-input" />
|
||||
@@ -343,6 +457,79 @@ async function signOut() {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 还没评阅、只是含 AI 建议的分数 */
|
||||
.score.suggested {
|
||||
color: #6b7280;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.screens {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-content: flex-start;
|
||||
gap: 16px;
|
||||
padding: 12px;
|
||||
overflow: auto;
|
||||
background: #2d6aa3;
|
||||
}
|
||||
|
||||
.screens figure {
|
||||
margin: 0;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.screens img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.ai {
|
||||
border-color: #c7d2fe;
|
||||
background: #f8faff;
|
||||
}
|
||||
|
||||
.ai-pending {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ai-item {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ai-item .score-title {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.ai-reason {
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.ai-comment {
|
||||
padding: 6px 8px;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.stale {
|
||||
padding: 6px 8px;
|
||||
background: #fffbeb;
|
||||
color: #b45309;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ai-error {
|
||||
color: #dc2626;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.review {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -15,6 +15,7 @@ import { currentUser, logout } from '../auth'
|
||||
import { deleteWork, loadWork, sampleWorkKey, saveWork } from '../editor/works'
|
||||
import { createRunSession } from '../run/session'
|
||||
import GradeResultView from '../grading/GradeResultView.vue'
|
||||
import { captureForms } from '../grading/screenshots'
|
||||
import TrainingBoard from '../scene/TrainingBoard.vue'
|
||||
import FormWindow from '../winforms/FormWindow.vue'
|
||||
import MessageBoxDialog from '../winforms/MessageBoxDialog.vue'
|
||||
@@ -189,7 +190,9 @@ async function submit() {
|
||||
submitting.value = true
|
||||
try {
|
||||
await flushSave()
|
||||
submission.value = await api<Submission>('POST', `/api/assignments/${assignment.value.id}/submit`, { files: files.value })
|
||||
// 每个窗体截一张图,老师评阅和 AI 评"窗体界面"时用;截图失败也照常提交
|
||||
const screenshots = await captureForms(files.value).catch(() => [])
|
||||
submission.value = await api<Submission>('POST', `/api/assignments/${assignment.value.id}/submit`, { files: files.value, screenshots })
|
||||
resultOpen.value = true
|
||||
pollSubmission()
|
||||
} catch (e) {
|
||||
|
||||
@@ -10,6 +10,8 @@ interface Cell {
|
||||
submitted: boolean
|
||||
auto: number | null
|
||||
manual: number | null
|
||||
/** 老师还没评阅时 AI 的建议分(老师评分项之和) */
|
||||
aiManual: number | null
|
||||
total: number | null
|
||||
}
|
||||
|
||||
@@ -39,12 +41,14 @@ watch(
|
||||
const sum = (row: Row) => book.value.assignments.reduce((a, x) => a + (row.scores[x.id]?.total ?? 0), 0)
|
||||
const fullMarks = computed(() => book.value.assignments.reduce((a, x) => a + x.total, 0))
|
||||
|
||||
/** 单元格:已提交显示总分(老师还没评阅时标"待评"),未提交显示"未交" */
|
||||
/** 单元格:已提交显示总分(老师还没评阅时:有 AI 建议显示含建议的总分并标"待确认",否则标"待评"),未提交显示"未交" */
|
||||
function cell(row: Row, assignmentId: number) {
|
||||
const c = row.scores[assignmentId]
|
||||
const open = () => router.push({ path: `/teacher/assignments/${assignmentId}`, query: { student: row.userId } })
|
||||
if (!c?.submitted) return h('span', { class: 'missing' }, '未交')
|
||||
return h(NButton, { text: true, type: 'primary', onClick: open }, () => (c.manual === null ? `${c.total}(待评)` : String(c.total)))
|
||||
const text =
|
||||
c.manual !== null ? String(c.total) : c.aiManual !== null ? `${(c.auto ?? 0) + c.aiManual}(AI 建议,待确认)` : `${c.total}(待评)`
|
||||
return h(NButton, { text: true, type: 'primary', onClick: open }, () => text)
|
||||
}
|
||||
|
||||
const columns = computed<DataTableColumns<Row>>(() => [
|
||||
@@ -75,7 +79,7 @@ function exportCsv() {
|
||||
<template>
|
||||
<div>
|
||||
<div class="head">
|
||||
<span class="tip">总分 = 最近一次提交的自动评分 + 老师评分;"待评"表示老师还没评阅,只含自动评分。</span>
|
||||
<span class="tip">总分 = 最近一次提交的自动评分 + 老师评分;"AI 建议,待确认"是含 AI 建议分的总分,老师采纳后才算数;"待评"只含自动评分。</span>
|
||||
<NButton size="small" :disabled="book.students.length === 0" @click="exportCsv">导出成绩表(CSV)</NButton>
|
||||
</div>
|
||||
<NDataTable
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/// <reference types="bun" />
|
||||
import { expect, test } from 'bun:test'
|
||||
import { conventionHints } from './conventions'
|
||||
import { conventionHints } from '@iot/shared'
|
||||
|
||||
const designer = (fields: string) => ({ path: 'Form1.Designer.cs', content: `partial class Form1\n{\n${fields}\n}` })
|
||||
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
// 规范提示:给老师评"注释、命名"时参考,不计分。
|
||||
// - 控件命名:在代码里用到、却还是默认名字(button1、textBox1……)的控件。书上要求"控件缩写 + 有意义的英文",
|
||||
// 如 btn_Open;只放在界面上、代码里不用的标签、分组框可以保留默认名字(书上也是这样),
|
||||
// 教材自带的设备控件(lamp1、alarm1)书上就这样命名,也不提示。
|
||||
// - 注释:学生自己写的代码文件(不含 Designer.cs、Program.cs)里有多少行注释。
|
||||
import type { SourceFile } from '@iot/shared'
|
||||
|
||||
export interface ConventionHint {
|
||||
level: 'warn' | 'info'
|
||||
text: string
|
||||
}
|
||||
|
||||
const DEFAULT_NAME = /^(button|label|textBox|comboBox|checkBox|radioButton|listBox)\d+$/
|
||||
|
||||
const studentCode = (files: SourceFile[]) => files.filter((f) => f.path.endsWith('.cs') && !f.path.endsWith('.Designer.cs') && f.path !== 'Program.cs')
|
||||
|
||||
export function conventionHints(files: SourceFile[]): ConventionHint[] {
|
||||
const hints: ConventionHint[] = []
|
||||
const code = studentCode(files)
|
||||
const codeText = code.map((f) => f.content).join('\n')
|
||||
|
||||
const designers = files.filter((f) => f.path.endsWith('.Designer.cs')).map((f) => f.content)
|
||||
const controls = designers.flatMap((text) => [...text.matchAll(/^\s*private\s+[\w.]+\s+(\w+);/gm)].map((m) => m[1]))
|
||||
// 绑定了事件(this.button1.Click += ...)或在代码里用到,就算"用到"
|
||||
const withEvents = new Set(designers.flatMap((text) => [...text.matchAll(/this\.(\w+)\.\w+\s*\+=/g)].map((m) => m[1])))
|
||||
const usedInCode = (name: string) => new RegExp(`(?<![\\w])${name}(?![\\w])`).test(codeText)
|
||||
const defaults = controls.filter((name) => DEFAULT_NAME.test(name) && (withEvents.has(name) || usedInCode(name)))
|
||||
if (defaults.length) {
|
||||
hints.push({ level: 'warn', text: `代码里用到的控件还是默认名字:${defaults.join('、')}(书上要求"控件缩写 + 有意义的英文",如 btn_Open)` })
|
||||
}
|
||||
|
||||
let comments = 0
|
||||
let lines = 0
|
||||
for (const line of codeText.split('\n').map((l) => l.trim())) {
|
||||
if (!line || line === '{' || line === '}' || line.startsWith('using ')) continue
|
||||
if (line.startsWith('//') || line.startsWith('/*') || line.startsWith('*')) comments++
|
||||
else {
|
||||
lines++
|
||||
if (line.includes('//')) comments++
|
||||
}
|
||||
}
|
||||
if (lines > 0) {
|
||||
hints.push(comments === 0 ? { level: 'warn', text: `没有写注释(代码 ${lines} 行)` } : { level: 'info', text: `注释 ${comments} 行,代码 ${lines} 行` })
|
||||
}
|
||||
return hints
|
||||
}
|
||||
Reference in new issue
Block a user