用 bun fmt 格式化全部代码和文档
CI/CD / test (push) Canceled after 0s
CI/CD / deploy (push) Canceled after 0s

- bunfig.toml 的 [fmt]:不写分号、单引号、一行最长 160,和原来的写法一致
- .prettierignore:docs/教材摘要/ 是书上的摘录,不格式化
- 模板里一行写两条语句的事件处理(@keydown.delete、@dblclick)改成调用方法:去掉分号后拆成两行,Vue 编译不过

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-26 14:02:33 +08:00
1 parent d4b8bf9241
commit de277d3e9d
102 files changed
+1971 -1059

No files matched your search

+11 -7
View File
@@ -64,7 +64,10 @@ function userPrompt(input: {
? input.result.results.map((r) => `- ${r.passed ? '通过' : '未通过'}:${r.title}${r.message ? `(${r.message})` : ''}`).join('\n')
: '编译没有通过。'
: '(还没有自动评分结果)'
const hints = conventionHints(input.files).map((h) => `- ${h.text}`).join('\n') || '- 无'
const hints =
conventionHints(input.files)
.map((h) => `- ${h.text}`)
.join('\n') || '- 无'
const code = input.files
.filter((f) => f.path.endsWith('.cs'))
.map((f) => `===== ${f.path} =====\n${f.content}`)
@@ -75,8 +78,12 @@ function userPrompt(input: {
${items}
${BOOK_CRITERIA}
${input.bookNames.length ? `- 本作业书上表格规定的控件名(学生照着用是规范的,不扣分):${input.bookNames.join('、')}
` : ''}
${
input.bookNames.length
? `- 本作业书上表格规定的控件名(学生照着用是规范的,不扣分):${input.bookNames.join('、')}
`
: ''
}
平台自动评分的结果(功能,供参考,不用再评):
${auto}
@@ -101,10 +108,7 @@ async function callModel(system: string, user: string, screenshots: Screenshot[]
{ role: 'system', content: system },
{
role: 'user',
content: [
{ type: 'text', text: user },
...screenshots.map((s) => ({ type: 'image_url', image_url: { url: `data:image/png;base64,${s.png}` } })),
],
content: [{ type: 'text', text: user }, ...screenshots.map((s) => ({ type: 'image_url', image_url: { url: `data:image/png;base64,${s.png}` } }))],
},
],
}),
+27 -4
View File
@@ -259,7 +259,6 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
await next()
})
app.get('/api/teacher/classes', (c) => c.json(listClasses(db)))
app.post('/api/teacher/classes', async (c) => {
@@ -308,7 +307,16 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
)
app.post('/api/teacher/assignments', async (c) => {
const a = await body<{ classId: number; sampleId: string; title: string; startMode: StartMode; template: unknown; scoring: Scoring; deadline: number | null; task: string | null }>(c)
const a = await body<{
classId: number
sampleId: string
title: string
startMode: StartMode
template: unknown
scoring: Scoring
deadline: number | null
task: string | null
}>(c)
if (!listClasses(db).some((cls) => cls.id === a.classId)) return c.json({ message: '班级不存在' }, 404)
const checks = typeof a.sampleId === 'string' ? await loadChecks(a.sampleId) : null
if (!checks) return c.json({ message: '这个实验还没有评分规则' }, 400)
@@ -352,7 +360,13 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
classSubmissions(db, id(c)).map((s) => {
const review = getReview(db, id(c), s.userId)
const ai = s.submissionId ? getAiReview(db, s.submissionId) : null
return { ...s, manualScore: manualScore(review), review, aiStatus: ai?.status ?? null, aiManual: ai?.status === 'done' ? ai.scores.reduce((a, x) => a + x.score, 0) : null }
return {
...s,
manualScore: manualScore(review),
review,
aiStatus: ai?.status ?? null,
aiManual: ai?.status === 'done' ? ai.scores.reduce((a, x) => a + x.score, 0) : null,
}
}),
),
)
@@ -378,7 +392,16 @@ export function createApp(db: Database, gradingQueue: GradingQueue, aiQueue: AiQ
if (!s.submissionId || getReview(db, id(c), s.userId)) continue
const ai = getAiReview(db, s.submissionId)
if (ai?.status !== 'done') continue
if (!saveReview(db, id(c), s.userId, ai.scores.map((x) => x.score), ai.comment).error) adopted++
if (
!saveReview(
db,
id(c),
s.userId,
ai.scores.map((x) => x.score),
ai.comment,
).error
)
adopted++
}
return c.json({ adopted })
})
+17 -3
View File
@@ -121,7 +121,9 @@ export function updateAssignment(
id,
)
// 分值变了,已评分的提交重新算分
for (const s of db.query<{ id: number; result: string }, [number]>(`SELECT id, result FROM submissions WHERE assignment_id = ? AND status = 'done'`).all(id)) {
for (const s of db
.query<{ id: number; result: string }, [number]>(`SELECT id, result FROM submissions WHERE assignment_id = ? AND status = 'done'`)
.all(id)) {
db.query('UPDATE submissions SET auto_score = ? WHERE id = ?').run(autoScore(JSON.parse(s.result), changes.scoring), s.id)
}
return {}
@@ -180,7 +182,10 @@ export function createSubmission(db: Database, assignmentId: number, userId: num
}
export function pendingSubmissionIds(db: Database): number[] {
return db.query<{ id: number }, []>(`SELECT id FROM submissions WHERE status = 'pending' ORDER BY id`).all().map((r) => r.id)
return db
.query<{ id: number }, []>(`SELECT id FROM submissions WHERE status = 'pending' ORDER BY id`)
.all()
.map((r) => r.id)
}
export function submissionForGrading(db: Database, id: number) {
@@ -211,7 +216,16 @@ export function saveGrade(db: Database, id: number, result: GradeResult | null)
export function classSubmissions(db: Database, assignmentId: number) {
return db
.query<
{ user_id: number; username: string; name: string; submission_id: number | null; status: Submission['status'] | null; auto_score: number | null; created_at: number | null; count: number },
{
user_id: number
username: string
name: string
submission_id: number | null
status: Submission['status'] | null
auto_score: number | null
created_at: number | null
count: number
},
[number]
>(
`SELECT u.id AS user_id, u.username, u.name, s.id AS submission_id, s.status, s.auto_score, s.created_at,
+12 -3
View File
@@ -67,7 +67,8 @@ export class Board {
* 恢复真实时间是 clock = null。
*/
setClock(time: string | undefined, speed: number | undefined) {
if (time !== undefined && (typeof time !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(time) || Number.isNaN(Date.parse(`${time}Z`)))) return false
if (time !== undefined && (typeof time !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(time) || Number.isNaN(Date.parse(`${time}Z`))))
return false
if (speed !== undefined && (typeof speed !== 'number' || !Number.isFinite(speed) || speed < 1 || speed > 3600)) return false
const now = Date.now()
this.state.clock = {
@@ -124,6 +125,14 @@ export class Board {
export function wallNow(now = new Date()) {
return new Intl.DateTimeFormat('sv-SE', {
timeZone: config.timeZone,
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23',
}).format(now).replace(' ', 'T')
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
})
.format(now)
.replace(' ', 'T')
}
+1 -3
View File
@@ -68,9 +68,7 @@ export function deleteClass(db: Database, id: number): { error?: string } {
}
export function listStudents(db: Database, classId: number): Student[] {
return db
.query<Student, [number]>(`SELECT id, username, name, password FROM users WHERE class_id = ? AND role = 'student' ORDER BY username`)
.all(classId)
return db.query<Student, [number]>(`SELECT id, username, name, password FROM users WHERE class_id = ? AND role = 'student' ORDER BY username`).all(classId)
}
/**
+15 -3
View File
@@ -61,7 +61,15 @@ export type Expectation =
*/
const DEFAULT_WITHIN = 5000
const PROP_KEYS: Record<string, string> = { text: 'Text', ledText: 'LedText', enabled: 'Enabled', checked: 'Checked', visible: 'Visible', on: 'IsOn', value: 'Value' }
const PROP_KEYS: Record<string, string> = {
text: 'Text',
ledText: 'LedText',
enabled: 'Enabled',
checked: 'Checked',
visible: 'Visible',
on: 'IsOn',
value: 'Value',
}
const STRING_PROPS = new Set(['Text', 'LedText'])
const NUMBER_PROPS = new Set(['Value'])
@@ -96,7 +104,8 @@ function parseExpectation(raw: Record<string, unknown>, where: string): Expectat
if ('media' in raw) return { kind: 'media', name: str(raw.media, where, '播放的文件名') }
if ('savedImage' in raw) return { kind: 'savedImage', path: str(raw.savedImage, where, '保存的图片路径应包含的文字') }
if ('relay' in raw) return { kind: 'relay', index: Number(raw.relay), on: bool(raw.on, where, 'on') }
if ('window' in raw) return { kind: 'window', name: str(raw.window, where, '窗体名 window'), open: raw.open === undefined ? true : bool(raw.open, where, 'open') }
if ('window' in raw)
return { kind: 'window', name: str(raw.window, where, '窗体名 window'), open: raw.open === undefined ? true : bool(raw.open, where, 'open') }
if ('exited' in raw) {
if (raw.exited !== true) throw new Error(`${where}:exited 只能写 true`)
return { kind: 'exited' }
@@ -122,7 +131,10 @@ function parseExpectation(raw: Record<string, unknown>, where: string): Expectat
return { kind: 'row', control, cells, present: 'row' in raw }
}
// image: true 或 image: 资源名;background: 资源名
for (const [key, prop] of [['image', 'Image'], ['background', 'BackgroundImage']] as const) {
for (const [key, prop] of [
['image', 'Image'],
['background', 'BackgroundImage'],
] as const) {
if (!(key in raw)) continue
const value = raw[key]
if (value === true) return { kind: 'image', control, prop }
+25 -8
View File
@@ -135,7 +135,8 @@ async function runStep(driver: ProgramDriver, step: Step) {
const index = items.indexOf(step.item)
if (index < 0) {
// 串口列表是空的:最常见的原因是没有填充
const hint = items.length === 0 && /^COM\d+$/.test(step.item) ? '。串口列表要在窗体的 Load 事件或下拉框的 Click 事件里用 SerialPort.GetPortNames() 填充' : ''
const hint =
items.length === 0 && /^COM\d+$/.test(step.item) ? '。串口列表要在窗体的 Load 事件或下拉框的 Click 事件里用 SerialPort.GetPortNames() 填充' : ''
throw new Failure(`下拉框 ${step.control} 里没有"${step.item}"(现在的选项:${items.length ? items.join('、') : '空'})${hint}`)
}
driver.act(node, { action: 'select', index })
@@ -151,7 +152,14 @@ async function runStep(driver: ProgramDriver, step: Step) {
const rows = gridRows(node)
if (step.row >= rows.length) throw new Failure(`数据表格 ${step.control} 里没有第 ${step.row + 1} 行(现在有 ${rows.length} 行数据)`)
const column = typeof step.column === 'number' ? step.column : gridColumns(node).findIndex((c) => c.header === step.column || c.name === step.column)
if (column < 0) throw new Failure(`数据表格 ${step.control} 里没有"${step.column}"列(现在的列:${gridColumns(node).map((c) => c.header).join('、') || '没有'})`)
if (column < 0)
throw new Failure(
`数据表格 ${step.control} 里没有"${step.column}"列(现在的列:${
gridColumns(node)
.map((c) => c.header)
.join('、') || '没有'
})`,
)
driver.act(node, { action: 'cellClick', row: step.row, column })
if (step.kind === 'edit') driver.act(node, { action: 'cellEdit', row: step.row, column, value: step.value })
await driver.settle()
@@ -302,8 +310,10 @@ function describeFailure(driver: ProgramDriver, e: Exclude<Expectation, { kind:
case 'prop': {
const actual = need(driver, e.control).props[e.prop]
if (e.prop === 'Text' || e.prop === 'LedText') return `期望${prefix} ${e.control} ${desc ?? `显示"${e.value}"`},实际是${quote(actual)}`
if (e.prop === 'Enabled') return `期望${prefix} ${e.control} ${desc ?? (e.value ? '可用' : '变成灰色不可用')},实际${actual === false ? '不可用' : '可用'}`
if (e.prop === 'Visible') return `期望${prefix} ${e.control} ${desc ?? (e.value ? '显示出来' : '隐藏')},实际${driver.isVisible(need(driver, e.control)) ? '显示着' : '是隐藏的'}`
if (e.prop === 'Enabled')
return `期望${prefix} ${e.control} ${desc ?? (e.value ? '可用' : '变成灰色不可用')},实际${actual === false ? '不可用' : '可用'}`
if (e.prop === 'Visible')
return `期望${prefix} ${e.control} ${desc ?? (e.value ? '显示出来' : '隐藏')},实际${driver.isVisible(need(driver, e.control)) ? '显示着' : '是隐藏的'}`
if (e.prop === 'Checked') return `期望${prefix} ${e.control} ${desc ?? (e.value ? '被选中' : '没有被选中')},实际${actual ? '被选中' : '没有被选中'}`
if (e.prop === 'IsOn') return `期望${prefix}窗体上的 ${e.control} ${desc ?? (e.value ? '亮' : '灭')},实际是${actual ? '亮' : '灭'}的`
if (e.prop === 'Value') return `期望${prefix} ${e.control} ${desc ?? `的值是 ${String(e.value)}`},实际是 ${String(actual)}`
@@ -312,7 +322,8 @@ function describeFailure(driver: ProgramDriver, e: Exclude<Expectation, { kind:
case 'contains': {
const node = need(driver, e.control)
const items = listItems(node)
if (items) return `期望${prefix} ${e.control} ${desc ?? `的列表项包含"${e.text}"`},实际${items.length ? `列表里是 ${items.map((i) => `"${i}"`).join('、')}` : '列表是空的'}`
if (items)
return `期望${prefix} ${e.control} ${desc ?? `的列表项包含"${e.text}"`},实际${items.length ? `列表里是 ${items.map((i) => `"${i}"`).join('、')}` : '列表是空的'}`
return `期望${prefix} ${e.control} ${desc ?? `的文字包含"${e.text}"`},实际是${quote(node.props.Text)}`
}
case 'matches':
@@ -354,12 +365,18 @@ function describeFailure(driver: ProgramDriver, e: Exclude<Expectation, { kind:
return `期望${prefix}${desc ?? `播放 ${e.name}`},${driver.played.length ? `实际播放的是 ${driver.played.join('、')}` : '实际没有播放声音'}`
case 'row': {
const node = need(driver, e.control)
const wanted = Object.entries(e.cells).map(([k, v]) => `${k}=${v}`).join(',')
const wanted = Object.entries(e.cells)
.map(([k, v]) => `${k}=${v}`)
.join(',')
const columns = gridColumns(node)
const missing = Object.keys(e.cells).filter((key) => !columns.some((c) => c.header === key || c.name === key))
if (missing.length) return `期望${prefix}数据表格 ${e.control} 里有"${missing.join('、')}"列,实际的列是 ${columns.map((c) => c.header).join('、') || '(没有数据)'}`
if (missing.length)
return `期望${prefix}数据表格 ${e.control} 里有"${missing.join('、')}"列,实际的列是 ${columns.map((c) => c.header).join('、') || '(没有数据)'}`
const rows = gridRows(node)
const shown = rows.slice(0, 5).map((r) => r.join(' | ')).join(';')
const shown = rows
.slice(0, 5)
.map((r) => r.join(' | '))
.join(';')
return `期望${prefix}数据表格 ${e.control} ${desc ?? (e.present ? `有一行 ${wanted}` : `没有 ${wanted} 的行`)},实际表格里${rows.length ? `是:${shown}${rows.length > 5 ? ' ……' : ''}` : '没有数据'}`
}
}
-1
View File
@@ -9,7 +9,6 @@ import { config } from '../config'
import { safeJoin } from '../run-session'
import { grade } from './grader'
/** 把作品写进一个干净的工程目录(评分和自测用) */
export async function writeProject(dir: string, files: SourceFile[]) {
await rm(dir, { recursive: true, force: true })
+9 -2
View File
@@ -30,7 +30,11 @@ if (sandboxError) console.error(sandboxError)
const db = openDatabase()
ensureTeacher(db)
const aiQueue = createAiQueue(db)
const app = createApp(db, createGradingQueue(db, (id) => aiQueue.enqueue(id)), aiQueue)
const app = createApp(
db,
createGradingQueue(db, (id) => aiQueue.enqueue(id)),
aiQueue,
)
interface SocketData {
userId?: number
@@ -58,7 +62,10 @@ const server = Bun.serve({
sendPings: true,
open(ws) {
const userId = ws.data.userId!
ws.data.session = new RunSession((message) => ws.send(JSON.stringify(message)), () => userDatabaseDir(userId))
ws.data.session = new RunSession(
(message) => ws.send(JSON.stringify(message)),
() => userDatabaseDir(userId),
)
},
async message(ws, raw) {
let message: ClientMessage
+17 -5
View File
@@ -39,7 +39,8 @@ export function saveReview(db: Database, assignmentId: number, userId: number, s
if (!Array.isArray(scores) || scores.length !== manual.length) return { error: `老师评分项应有 ${manual.length} 项` }
for (const [i, s] of scores.entries()) {
if (s === null) continue
if (typeof s !== 'number' || !Number.isFinite(s) || s < 0 || s > manual[i].points) return { error: `"${manual[i].title}"的得分要在 0 到 ${manual[i].points} 之间` }
if (typeof s !== 'number' || !Number.isFinite(s) || s < 0 || s > manual[i].points)
return { error: `"${manual[i].title}"的得分要在 0 到 ${manual[i].points} 之间` }
}
db.query(
`INSERT INTO reviews (assignment_id, user_id, scores, comment, updated_at) VALUES (?, ?, ?, ?, ?)
@@ -53,10 +54,13 @@ export function studentDetail(db: Database, assignmentId: number, userId: number
const student = db.query<{ id: number; username: string; name: string }, [number]>('SELECT id, username, name FROM users WHERE id = ?').get(userId)
if (!student) return null
const latest = latestSubmission(db, assignmentId, userId)
const row = latest && db.query<{ files: string; screenshots: string | null }, [number]>('SELECT files, screenshots FROM submissions WHERE id = ?').get(latest.id)
const row =
latest && db.query<{ files: string; screenshots: string | null }, [number]>('SELECT files, screenshots FROM submissions WHERE id = ?').get(latest.id)
const files = row ? (JSON.parse(row.files) as SourceFile[]) : null
const screenshots = row?.screenshots ? (JSON.parse(row.screenshots) as Screenshot[]) : []
const count = db.query<{ n: number }, [number, number]>('SELECT COUNT(*) AS n FROM submissions WHERE assignment_id = ? AND user_id = ?').get(assignmentId, userId)!.n
const count = db
.query<{ n: number }, [number, number]>('SELECT COUNT(*) AS n FROM submissions WHERE assignment_id = ? AND user_id = ?')
.get(assignmentId, userId)!.n
return {
student,
submission: latest,
@@ -77,7 +81,13 @@ export function submissionFiles(db: Database, submissionId: number) {
)
.get(submissionId)
if (!row) return null
return { files: JSON.parse(row.files) as SourceFile[], assignmentId: row.assignment_id, assignmentTitle: row.title, studentName: row.name, username: row.username }
return {
files: JSON.parse(row.files) as SourceFile[],
assignmentId: row.assignment_id,
assignmentTitle: row.title,
studentName: row.name,
username: row.username,
}
}
/** 全班成绩表:每个学生在每个作业上的自动评分、老师评分和总分 */
@@ -91,7 +101,9 @@ export function gradebook(db: Database, classId: number) {
return { id: a.id, title: a.title, scoring, total: sum(scoring.auto) + sum(scoring.manual.map((m) => m.points)) }
})
const students = db
.query<{ id: number; username: string; name: string }, [number]>(`SELECT id, username, name FROM users WHERE class_id = ? AND role = 'student' ORDER BY username`)
.query<{ id: number; username: string; name: string }, [number]>(
`SELECT id, username, name FROM users WHERE class_id = ? AND role = 'student' ORDER BY username`,
)
.all(classId)
return {
assignments: assignments.map(({ id, title, total }) => ({ id, title, total })),
+30 -11
View File
@@ -75,13 +75,20 @@ export function runCommand(projectDir: string, extraEnv: Record<string, string>,
const env = runnerEnv({ ...extraEnv, ...(dbDir ? { SIM_DB_DIR: DB_DIR } : {}) })
const cmd = [
// 学生程序写出死循环时,降低它的优先级,网页服务和编译服务仍然流畅
'nice', '-n', '10',
'nice',
'-n',
'10',
// CPU 时间用完直接 SIGKILL 结束(软硬上限相同)。软上限更小时内核先发 SIGXCPU,它默认生成 core dump:
// 学生每写一次死循环就留下一个转储,本机桌面还会弹出"程序崩溃"
'prlimit', `--cpu=${config.cpuLimitSeconds}:${config.cpuLimitSeconds}`, '--core=0',
'prlimit',
`--cpu=${config.cpuLimitSeconds}:${config.cpuLimitSeconds}`,
'--core=0',
...bwrapArgs(projectDir),
...(dbDir ? ['--bind', dbDir, DB_DIR] : []),
'dotnet', `${RUNNER_DIR}/${config.runnerDll.split('/').pop()}`, 'run', APP_DIR,
'dotnet',
`${RUNNER_DIR}/${config.runnerDll.split('/').pop()}`,
'run',
APP_DIR,
]
return { cmd, env }
}
@@ -105,15 +112,27 @@ function bwrapArgs(projectDir: string) {
'--unshare-all',
'--die-with-parent',
'--new-session',
'--ro-bind', '/usr', '/usr',
'--ro-bind',
'/usr',
'/usr',
...rootLinks(),
'--ro-bind-try', '/etc/localtime', '/etc/localtime',
'--ro-bind-try',
'/etc/localtime',
'/etc/localtime',
...dotnetMounts(),
'--proc', '/proc',
'--dev', '/dev',
'--tmpfs', '/tmp',
'--ro-bind', dirname(config.runnerDll), RUNNER_DIR,
'--ro-bind', projectDir, APP_DIR,
'--chdir', APP_DIR,
'--proc',
'/proc',
'--dev',
'/dev',
'--tmpfs',
'/tmp',
'--ro-bind',
dirname(config.runnerDll),
RUNNER_DIR,
'--ro-bind',
projectDir,
APP_DIR,
'--chdir',
APP_DIR,
]
}
+8 -4
View File
@@ -25,9 +25,7 @@ export interface SqlColumnInfo {
}
export type SqlRequest =
| { type: 'exec'; dir: string; database: string; sql: string }
| { type: 'script'; dir: string; sql: string }
| { type: 'explorer'; dir: string }
{ type: 'exec'; dir: string; database: string; sql: string } | { type: 'script'; dir: string; sql: string } | { type: 'explorer'; dir: string }
/** SQL 报错(编号和信息与 SQL Server 一致) */
export class SqlError extends Error {
@@ -78,7 +76,13 @@ class SqlService {
stdin: 'pipe',
stdout: 'pipe',
stderr: 'inherit',
env: { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: process.env.HOME ?? '/tmp', DOTNET_CLI_TELEMETRY_OPTOUT: '1', DOTNET_NOLOGO: '1', TZ: config.timeZone },
env: {
PATH: process.env.PATH ?? '/usr/bin:/bin',
HOME: process.env.HOME ?? '/tmp',
DOTNET_CLI_TELEMETRY_OPTOUT: '1',
DOTNET_NOLOGO: '1',
TZ: config.timeZone,
},
})
this.proc = proc
this.ready = new Promise<void>((resolveReady) => {