原来只有 `apps/web` 在 Prettier 下(配置在 `apps/web/.prettierrc.toml`、脚本在 web 的 package.json),后端和契约从来没格式化过 —— 手写在 100 列上下,`db/schema.ts` 还是 drizzle-kit pull 留下的 tab 缩进。两套口径分叉久了,跨端改一处就得记着「这边 什么风格」。 - 配置搬到根目录 `.prettierrc.toml`,内容不变(`semi=false`,其余全默认, printWidth 80 —— 和前端已有的格式一致,不另立一套宽度); - 脚本统一成根目录 `bun run fmt`,覆盖 `apps/*/src`、`apps/web/tests` 和两个构建 配置;web 自己那份 `fmt` 和重复的 prettier 依赖删掉; - `.prettierignore` 挡掉两类不该碰的:drizzle-kit 生成的 `src/db/meta/` 结构快照 (它是 db:generate 的比对输入,只该由 drizzle-kit 写)、unplugin 每次 dev 都会 重写的 `auto-imports.d.ts` / `components.d.ts`; - 全量跑了一遍。纯格式,无行为改动:api typecheck / check:routes / check:ast、 前端 type-check 全过,起 api 打了接口确认正常。前端这 39 个文件的小改动是 prettier 版本漂移(类型断言的换行口径变了),不是新配置带来的。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +0,0 @@
|
||||
semi=false
|
||||
@@ -22,14 +22,15 @@ Vite(Rolldown 内核)、Naive UI、Pinia、Vue Router。
|
||||
bun run dev # 只起前端 dev server(5173),后端得另外起
|
||||
bun run type-check # 类型检查。改完 .vue / .ts 必须跑这个
|
||||
bun run build # 生产构建
|
||||
bun run fmt # Prettier
|
||||
```
|
||||
|
||||
⚠️ **验证只认 `bun run type-check`。** `vue-tsc --noEmit -p tsconfig.json` 会**静默
|
||||
通过**——那个 tsconfig 是 `files: []` + references 的壳,真正的配置在
|
||||
`tsconfig.app.json`(0.2 秒跑完就是没在检查的信号);`vite build` 也不做类型检查。
|
||||
|
||||
不写测试(沿用项目约定),验证靠实跑。lint 只有 Prettier。
|
||||
不写测试(沿用项目约定),验证靠实跑。lint 只有 Prettier,**脚本在仓库根目录**
|
||||
(`cd ../.. && bun run fmt`,一把把后端、契约、前端全格式化)—— 前端这边原来那个
|
||||
只管 `apps/web` 的 `fmt` 已经删掉,配置也收到了根目录的 `.prettierrc.toml`。
|
||||
|
||||
## Architecture
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"build": "vite build",
|
||||
"build:staging": "vite build --mode staging",
|
||||
"build:test": "vite build --mode test",
|
||||
"fmt": "prettier --write src *.ts",
|
||||
"type-check": "vue-tsc --noEmit -p tsconfig.app.json"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -64,7 +63,6 @@
|
||||
"@vitejs/plugin-legacy": "^8.2.3",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"prettier": "^3.9.6",
|
||||
"unplugin-auto-import": "^21.1.0",
|
||||
"unplugin-vue-components": "^32.1.0",
|
||||
"vite": "^8.2.2",
|
||||
|
||||
@@ -94,9 +94,7 @@ export function editProblem(problem: AdminProblem | BlankProblem) {
|
||||
}
|
||||
|
||||
export function toggleProblemVisible(problemID: number) {
|
||||
return api.put<{ visible: boolean }>(
|
||||
`admin/problems/${problemID}/visibility`,
|
||||
)
|
||||
return api.put<{ visible: boolean }>(`admin/problems/${problemID}/visibility`)
|
||||
}
|
||||
|
||||
export function generateFlowchartFromPythonCode(python: string) {
|
||||
@@ -135,7 +133,11 @@ export function batchTagProblems(
|
||||
}
|
||||
|
||||
// 用户排名(后台版,无 100 名上限;公开榜单是 oj/api.ts 的 getRank)
|
||||
export function getAdminUserRank(offset: number, limit: number, keyword: string) {
|
||||
export function getAdminUserRank(
|
||||
offset: number,
|
||||
limit: number,
|
||||
keyword: string,
|
||||
) {
|
||||
return api.get<AdminUserRank>("admin/rankings/users", {
|
||||
params: { offset, limit, keyword },
|
||||
})
|
||||
@@ -236,9 +238,7 @@ export function previewSQLTestcase(data: {
|
||||
|
||||
// 回显已上传的 SQL 测试点脚本内容(按 1.sql, 2.sql... 排序)
|
||||
export function getSQLTestcaseScripts(problemId: number) {
|
||||
return api.get<SqlTestCaseScript[]>(
|
||||
`admin/problems/${problemId}/sql-scripts`,
|
||||
)
|
||||
return api.get<SqlTestCaseScript[]>(`admin/problems/${problemId}/sql-scripts`)
|
||||
}
|
||||
|
||||
// AI 根据标准答案生成一个 SQL 测试点初始化脚本
|
||||
@@ -412,10 +412,7 @@ export function createTutorial(data: Partial<Tutorial>) {
|
||||
}
|
||||
|
||||
export function updateTutorial(data: Partial<Tutorial>) {
|
||||
return api.put<Tutorial>(
|
||||
`admin/tutorials/${data.id}`,
|
||||
toTutorialBody(data),
|
||||
)
|
||||
return api.put<Tutorial>(`admin/tutorials/${data.id}`, toTutorialBody(data))
|
||||
}
|
||||
|
||||
export function deleteTutorial(id: number) {
|
||||
|
||||
@@ -168,10 +168,14 @@ const tutorialColumns = computed<DataTableColumn<LearnTutorialProgress>[]>(
|
||||
|
||||
const exerciseColumns = computed<DataTableColumn<LearnExerciseProgress>[]>(
|
||||
() => [
|
||||
{ type: "expand", renderExpand: (row) => h(ExerciseAttempts, {
|
||||
exerciseId: row.exerciseId,
|
||||
className: className.value.trim(),
|
||||
}) },
|
||||
{
|
||||
type: "expand",
|
||||
renderExpand: (row) =>
|
||||
h(ExerciseAttempts, {
|
||||
exerciseId: row.exerciseId,
|
||||
className: className.value.trim(),
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: "课",
|
||||
key: "tutorialOrder",
|
||||
@@ -289,7 +293,8 @@ onMounted(load)
|
||||
</n-text>
|
||||
<!-- 口径写在表上方,免得老师对着「已读 0 课 / 累计 25 分钟」猜是不是坏了 -->
|
||||
<n-text depth="3" style="font-size: 12px">
|
||||
「已读」按累计停留满 {{ TUTORIAL_READ_SECONDS / 60 }} 分钟算,不足的只计时长
|
||||
「已读」按累计停留满
|
||||
{{ TUTORIAL_READ_SECONDS / 60 }} 分钟算,不足的只计时长
|
||||
</n-text>
|
||||
</n-flex>
|
||||
|
||||
|
||||
@@ -78,7 +78,10 @@ function nodeTargetOptions(lang: string): SelectOption[] {
|
||||
// 逻辑名 and/or/not 在 C 里显示成 && / || / !,存进去的还是逻辑名
|
||||
function operatorTargetOptions(lang: string): SelectOption[] {
|
||||
return Object.entries(AST_OPERATOR_TARGETS_BY_LANGUAGE[lang] ?? {}).map(
|
||||
([value, label]) => ({ label: label === value ? value : `${label}(${value})`, value }),
|
||||
([value, label]) => ({
|
||||
label: label === value ? value : `${label}(${value})`,
|
||||
value,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -173,7 +176,8 @@ function getTargetLabel(
|
||||
engine: string,
|
||||
target: string,
|
||||
): string | undefined {
|
||||
if (isNodeEngine(engine)) return AST_NODE_TARGETS_BY_LANGUAGE[lang]?.[target]?.label
|
||||
if (isNodeEngine(engine))
|
||||
return AST_NODE_TARGETS_BY_LANGUAGE[lang]?.[target]?.label
|
||||
// 运算符不写 label:判题结果的文案按语言翻译(astOperatorLabel),
|
||||
// 存一个固定 label 反而会把 C 的 && 钉死成 and
|
||||
return undefined
|
||||
@@ -252,7 +256,8 @@ watch(supportedLanguages, (langs) => {
|
||||
:bordered="false"
|
||||
style="margin-bottom: 8px"
|
||||
>
|
||||
{{ unsupportedLanguages.join("、") }} 暂不支持代码规则检查,判题机只能检查
|
||||
{{ unsupportedLanguages.join("、") }}
|
||||
暂不支持代码规则检查,判题机只能检查
|
||||
{{ AST_SUPPORTED_LANGUAGES.join(" / ") }}
|
||||
</n-alert>
|
||||
<n-tabs
|
||||
@@ -393,9 +398,7 @@ watch(supportedLanguages, (langs) => {
|
||||
<n-empty
|
||||
v-else
|
||||
:description="
|
||||
languages.length
|
||||
? '当前语言不支持代码规则检查'
|
||||
: '请先选择编程语言'
|
||||
languages.length ? '当前语言不支持代码规则检查' : '请先选择编程语言'
|
||||
"
|
||||
/>
|
||||
</n-collapse-item>
|
||||
|
||||
@@ -59,9 +59,7 @@ async function submit() {
|
||||
props.action,
|
||||
)
|
||||
const verb = props.action === "add" ? "添加" : "移除"
|
||||
message.success(
|
||||
`已为 ${res.problemCount} 道题${verb} ${res.tagCount} 个标签`,
|
||||
)
|
||||
message.success(`已为 ${res.problemCount} 道题${verb} ${res.tagCount} 个标签`)
|
||||
close()
|
||||
emit("done")
|
||||
}
|
||||
|
||||
@@ -179,11 +179,10 @@ async function run() {
|
||||
async function upload() {
|
||||
isUploading.value = true
|
||||
try {
|
||||
const data = uploadable.value
|
||||
.flatMap((f, i) => [
|
||||
{ name: `${i + 1}.in`, content: f.in },
|
||||
{ name: `${i + 1}.out`, content: f.out },
|
||||
])
|
||||
const data = uploadable.value.flatMap((f, i) => [
|
||||
{ name: `${i + 1}.in`, content: f.in },
|
||||
{ name: `${i + 1}.out`, content: f.out },
|
||||
])
|
||||
|
||||
const blob = createZipBlob(data)
|
||||
const file = new File([blob], "testcase.zip", { type: "application/zip" })
|
||||
|
||||
@@ -896,7 +896,11 @@ watch(
|
||||
v-model:value="problem.showFlowchart"
|
||||
:disabled="problem.allowFlowchart"
|
||||
/>
|
||||
<n-text v-if="problem.allowFlowchart" depth="3" style="font-size: 12px">
|
||||
<n-text
|
||||
v-if="problem.allowFlowchart"
|
||||
depth="3"
|
||||
style="font-size: 12px"
|
||||
>
|
||||
让学生自己画图时,标准流程图不会下发给学生,这个开关没有意义
|
||||
</n-text>
|
||||
</n-flex>
|
||||
|
||||
@@ -122,9 +122,7 @@ async function saveTag(tag: AdminTag) {
|
||||
}
|
||||
const res = await renameTag(tag.id, name)
|
||||
if (res.merged) {
|
||||
message.success(
|
||||
`已合并到「${res.name}」,影响 ${res.affectedCount} 道题`,
|
||||
)
|
||||
message.success(`已合并到「${res.name}」,影响 ${res.affectedCount} 道题`)
|
||||
} else {
|
||||
message.success("已重命名")
|
||||
}
|
||||
|
||||
@@ -110,9 +110,7 @@ function startRolling(finalName: string) {
|
||||
|
||||
async function getRandom() {
|
||||
const res = await randomUser10(query.classroom)
|
||||
const names = (res as string[]).map(
|
||||
(name) => name.split(query.classroom)[1],
|
||||
)
|
||||
const names = (res as string[]).map((name) => name.split(query.classroom)[1])
|
||||
rollingNames.value = names
|
||||
const finalName = names[names.length - 1]
|
||||
startRolling(finalName)
|
||||
|
||||
@@ -23,8 +23,7 @@ defineEmits<{
|
||||
*/
|
||||
const maskable = computed(
|
||||
() =>
|
||||
props.user.adminType !== USER_TYPE.REGULAR_USER &&
|
||||
!!props.user.rawPassword,
|
||||
props.user.adminType !== USER_TYPE.REGULAR_USER && !!props.user.rawPassword,
|
||||
)
|
||||
</script>
|
||||
<template>
|
||||
|
||||
@@ -57,7 +57,9 @@ async function uploadUsers() {
|
||||
message.success("用户已上传成功")
|
||||
// 只导出用户名和密码两列 —— 发给学生的就是这两样,邮箱是占位生成的、
|
||||
// 真名本来就是老师粘进来的那一列,都不用回传
|
||||
const csv = users.value.map(([username, password]) => `${username},${password}`).join("\n")
|
||||
const csv = users.value
|
||||
.map(([username, password]) => `${username},${password}`)
|
||||
.join("\n")
|
||||
const hiddenElement = document.createElement("a")
|
||||
hiddenElement.href = "data:text/csv;charset=utf-8," + encodeURI(csv)
|
||||
hiddenElement.target = "_blank"
|
||||
|
||||
@@ -77,7 +77,8 @@ const columns: DataTableColumn<User>[] = [
|
||||
user: row,
|
||||
revealed: revealedPasswords.value.has(row.id),
|
||||
onToggle: (id: number) => {
|
||||
if (!revealedPasswords.value.delete(id)) revealedPasswords.value.add(id)
|
||||
if (!revealedPasswords.value.delete(id))
|
||||
revealedPasswords.value.add(id)
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -74,7 +74,9 @@ aiStore.targetUsername = urlUsername.value
|
||||
aiStore.duration = urlDuration.value
|
||||
|
||||
const subOptions = computed<Duration>(
|
||||
() => durationFromValue(aiStore.duration) ?? durationFromValue(DURATION_OPTIONS[0].value)!,
|
||||
() =>
|
||||
durationFromValue(aiStore.duration) ??
|
||||
durationFromValue(DURATION_OPTIONS[0].value)!,
|
||||
)
|
||||
|
||||
const start = computed(() => formatISO(sub(new Date(), subOptions.value)))
|
||||
|
||||
@@ -31,7 +31,8 @@ const { chartKey } = useChartTheme()
|
||||
// 第二个 tab 里列成表格。这里直接画出来,不用改后端和契约
|
||||
const items = computed(() =>
|
||||
[...aiStore.detailsData.flowcharts].sort(
|
||||
(a, b) => b.bestScore - a.bestScore || a.problemId.localeCompare(b.problemId),
|
||||
(a, b) =>
|
||||
b.bestScore - a.bestScore || a.problemId.localeCompare(b.problemId),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -255,12 +255,9 @@ export function getContestAccess(id: string) {
|
||||
|
||||
// 注意和 GET /access 不一样:这个返回裸 true,密码错是 403 走 catch
|
||||
export function checkContestPassword(contestID: string, password: string) {
|
||||
return api.post<boolean>(
|
||||
`contests/${encodeURIComponent(contestID)}/access`,
|
||||
{
|
||||
password,
|
||||
},
|
||||
)
|
||||
return api.post<boolean>(`contests/${encodeURIComponent(contestID)}/access`, {
|
||||
password,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getContestProblems(contestID: string) {
|
||||
@@ -295,9 +292,12 @@ export function updateProfile(data: { realName: string; mood: string }) {
|
||||
}
|
||||
|
||||
export function getAnnouncementList(offset = 0, limit = 10) {
|
||||
return api.get<{ results: AnnouncementListItem[]; total: number }>("announcements", {
|
||||
params: { limit, offset },
|
||||
})
|
||||
return api.get<{ results: AnnouncementListItem[]; total: number }>(
|
||||
"announcements",
|
||||
{
|
||||
params: { limit, offset },
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export function getAnnouncement(id: number) {
|
||||
@@ -465,7 +465,6 @@ export function joinProblemSet(problemSetId: number) {
|
||||
return api.post("problem-set-progress", { problemSetId })
|
||||
}
|
||||
|
||||
|
||||
export function getUserBadges(username?: string) {
|
||||
return api.get<UserBadge[]>(
|
||||
`users/${encodeURIComponent(username ?? "me")}/badges`,
|
||||
|
||||
@@ -66,7 +66,9 @@ const timeRangeOptions: SelectOption[] = [
|
||||
]
|
||||
|
||||
// 「全部时间」的 value 是空串,解不出来就是 null —— 正是不带时间条件的意思
|
||||
const subOptions = computed<Duration | null>(() => durationFromValue(duration.value))
|
||||
const subOptions = computed<Duration | null>(() =>
|
||||
durationFromValue(duration.value),
|
||||
)
|
||||
|
||||
// 根据时间段选项计算开始和结束时间
|
||||
function getTimeRange(): {
|
||||
|
||||
@@ -55,7 +55,10 @@ function submit() {
|
||||
|
||||
/** 给老师看的一句人话:选项按 A/B/C 报,报下标没人看得懂 */
|
||||
function describe(sel: Set<number>) {
|
||||
return `选了 ${[...sel].sort((a, b) => a - b).map((i) => String.fromCharCode(65 + i)).join("、")}`
|
||||
return `选了 ${[...sel]
|
||||
.sort((a, b) => a - b)
|
||||
.map((i) => String.fromCharCode(65 + i))
|
||||
.join("、")}`
|
||||
}
|
||||
|
||||
function reset() {
|
||||
|
||||
@@ -23,10 +23,7 @@ const IDLE_MS = 10 * 60 * 1000
|
||||
* @param tutorialId 当前这一课,0 表示还没加载好
|
||||
* @param enabled 是否留痕。未登录时为 false:教程本身保持免登录可读,只是不记
|
||||
*/
|
||||
export function useLearnTrace(
|
||||
tutorialId: Ref<number>,
|
||||
enabled: Ref<boolean>,
|
||||
) {
|
||||
export function useLearnTrace(tutorialId: Ref<number>, enabled: Ref<boolean>) {
|
||||
const visibility = useDocumentVisibility()
|
||||
const { idle } = useIdle(IDLE_MS)
|
||||
|
||||
|
||||
@@ -126,7 +126,12 @@
|
||||
<script setup lang="ts">
|
||||
import { MdPreview } from "md-editor-v3"
|
||||
import "md-editor-v3/lib/preview.css"
|
||||
import type { Tutorial, Exercise, LANGUAGE, TutorialProgress } from "utils/types"
|
||||
import type {
|
||||
Tutorial,
|
||||
Exercise,
|
||||
LANGUAGE,
|
||||
TutorialProgress,
|
||||
} from "utils/types"
|
||||
import {
|
||||
getTutorial,
|
||||
getTutorials,
|
||||
@@ -220,7 +225,9 @@ async function loadProgress() {
|
||||
}
|
||||
try {
|
||||
const rows = await getLearnProgress(type.value)
|
||||
progress.value = Object.fromEntries(rows.map((row) => [row.tutorialId, row]))
|
||||
progress.value = Object.fromEntries(
|
||||
rows.map((row) => [row.tutorialId, row]),
|
||||
)
|
||||
} catch {
|
||||
progress.value = {}
|
||||
}
|
||||
|
||||
@@ -311,7 +311,9 @@ function type(status: ProblemStatus) {
|
||||
</p>
|
||||
<n-list bordered style="margin-bottom: 8px">
|
||||
<n-list-item v-for="(rule, i) in rules" :key="i">
|
||||
<n-tag :type="KIND_TAG_TYPE[rule.kind]">{{ rule.description }}</n-tag>
|
||||
<n-tag :type="KIND_TAG_TYPE[rule.kind]">{{
|
||||
rule.description
|
||||
}}</n-tag>
|
||||
</n-list-item>
|
||||
</n-list>
|
||||
</div>
|
||||
|
||||
@@ -111,8 +111,7 @@ const POLL_INTERVAL = 3000
|
||||
const POLL_TIMEOUT = 3 * 60 * 1000
|
||||
|
||||
type Outcome =
|
||||
| { ok: true; score: number; grade: string }
|
||||
| { ok: false; error?: string }
|
||||
{ ok: true; score: number; grade: string } | { ok: false; error?: string }
|
||||
|
||||
const { pause: pausePolling, resume: resumePolling } = useIntervalFn(
|
||||
async () => {
|
||||
@@ -509,11 +508,7 @@ onUnmounted(() => {
|
||||
</n-card>
|
||||
|
||||
<!-- 详细评分 -->
|
||||
<n-card
|
||||
v-if="sortedCriteria.length"
|
||||
size="small"
|
||||
title="详细评分"
|
||||
>
|
||||
<n-card v-if="sortedCriteria.length" size="small" title="详细评分">
|
||||
<div
|
||||
v-for="[key, detail] in sortedCriteria"
|
||||
:key="key"
|
||||
|
||||
@@ -182,11 +182,7 @@ watch(
|
||||
<n-tab-pane name="content" tab="题目描述">
|
||||
<ProblemContent />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
v-if="canShowFlowchart"
|
||||
name="flowchart"
|
||||
tab="流程图表"
|
||||
>
|
||||
<n-tab-pane v-if="canShowFlowchart" name="flowchart" tab="流程图表">
|
||||
<ProblemFlowchart />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="info" tab="题目统计" :disabled="!!problemSetId">
|
||||
@@ -234,11 +230,7 @@ watch(
|
||||
<n-tab-pane name="content" tab="题目描述">
|
||||
<ProblemContent />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane
|
||||
v-if="canShowFlowchart"
|
||||
name="flowchart"
|
||||
tab="流程图表"
|
||||
>
|
||||
<n-tab-pane v-if="canShowFlowchart" name="flowchart" tab="流程图表">
|
||||
<ProblemFlowchart />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="info" tab="题目统计" :disabled="!!problemSetId">
|
||||
|
||||
@@ -42,11 +42,15 @@ function getProgressPercentage() {
|
||||
|
||||
// 有选做题时把「必做 N 题」标出来,否则「共 10 道题目」和「9 / 9」对不上
|
||||
const optionalCount = computed(
|
||||
() => props.problemSet.problemsCount - (props.problemSet.userProgress?.totalCount ?? 0),
|
||||
() =>
|
||||
props.problemSet.problemsCount -
|
||||
(props.problemSet.userProgress?.totalCount ?? 0),
|
||||
)
|
||||
|
||||
const endTimeText = computed(() =>
|
||||
props.problemSet.endTime ? parseTime(props.problemSet.endTime, "YYYY-MM-DD HH:mm") : "",
|
||||
props.problemSet.endTime
|
||||
? parseTime(props.problemSet.endTime, "YYYY-MM-DD HH:mm")
|
||||
: "",
|
||||
)
|
||||
|
||||
function handleJoin() {
|
||||
|
||||
@@ -286,7 +286,9 @@ const options: SelectOption[] = [...LONG_DURATION_OPTIONS]
|
||||
|
||||
// 认不出来退回 options[1](一个月内),和 duration 的初值一致
|
||||
const subOptions = computed<Duration>(
|
||||
() => durationFromValue(duration.value) ?? durationFromValue(LONG_DURATION_OPTIONS[1]!.value)!,
|
||||
() =>
|
||||
durationFromValue(duration.value) ??
|
||||
durationFromValue(LONG_DURATION_OPTIONS[1]!.value)!,
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -69,11 +69,7 @@
|
||||
</n-card>
|
||||
|
||||
<!-- 详细评分 -->
|
||||
<n-card
|
||||
v-if="sortedCriteria.length > 0"
|
||||
size="small"
|
||||
title="详细评分"
|
||||
>
|
||||
<n-card v-if="sortedCriteria.length > 0" size="small" title="详细评分">
|
||||
<div
|
||||
v-for="[key, detail] in sortedCriteria"
|
||||
:key="key"
|
||||
@@ -148,7 +144,9 @@ const criteriaDetails = computed<
|
||||
)
|
||||
})
|
||||
// jsonb 不保留键序,直接遍历会把 40 分的「逻辑正确性」排到最后
|
||||
const sortedCriteria = computed(() => sortFlowchartCriteria(criteriaDetails.value))
|
||||
const sortedCriteria = computed(() =>
|
||||
sortFlowchartCriteria(criteriaDetails.value),
|
||||
)
|
||||
|
||||
const loading = ref(false)
|
||||
const rendering = ref(false)
|
||||
|
||||
@@ -43,7 +43,9 @@ const loading = ref(false)
|
||||
* 测试点明细。`info` 在契约里是「完整形状或空对象」的联合(非管理员拿到的是空对象),
|
||||
* `data` 本身也可能为 null —— 两种情况都由这个访问器归成空数组,模板里不再直接取。
|
||||
*/
|
||||
const caseResults = computed(() => submissionCaseResults(submission.value?.info))
|
||||
const caseResults = computed(() =>
|
||||
submissionCaseResults(submission.value?.info),
|
||||
)
|
||||
|
||||
async function init() {
|
||||
submission.value = props.submission
|
||||
|
||||
@@ -80,14 +80,8 @@ async function init() {
|
||||
const metricsRes = await getMetrics(res.user.id)
|
||||
firstSubmissionAt.value = parseTime(metricsRes.first)
|
||||
latestSubmissionAt.value = parseTime(metricsRes.latest)
|
||||
toLatestAt.value = durationToDays(
|
||||
metricsRes.latest,
|
||||
metricsRes.now,
|
||||
)
|
||||
learnDuration.value = durationToDays(
|
||||
metricsRes.first,
|
||||
metricsRes.latest,
|
||||
)
|
||||
toLatestAt.value = durationToDays(metricsRes.latest, metricsRes.now)
|
||||
learnDuration.value = durationToDays(metricsRes.first, metricsRes.latest)
|
||||
}
|
||||
} finally {
|
||||
toggle(false)
|
||||
|
||||
@@ -41,7 +41,5 @@ export function getHitokoto() {
|
||||
}
|
||||
|
||||
export function getClassUsernames(classroom: string) {
|
||||
return api.get<string[]>(
|
||||
`classes/${encodeURIComponent(classroom)}/usernames`,
|
||||
)
|
||||
return api.get<string[]>(`classes/${encodeURIComponent(classroom)}/usernames`)
|
||||
}
|
||||
|
||||
@@ -158,7 +158,10 @@
|
||||
import { formatISO, sub, type Duration } from "date-fns"
|
||||
import type { FlowchartStatistics } from "@oj2/contract"
|
||||
import { getFlowchartStatistics } from "oj/api"
|
||||
import { PANEL_DURATION_OPTIONS, FLOWCHART_CRITERIA_ORDER } from "utils/constants"
|
||||
import {
|
||||
PANEL_DURATION_OPTIONS,
|
||||
FLOWCHART_CRITERIA_ORDER,
|
||||
} from "utils/constants"
|
||||
import { durationFromValue } from "utils/functions"
|
||||
import { useHiddenStudents } from "../composables/hiddenStudents"
|
||||
import { Doughnut, Radar, Bar } from "vue-chartjs"
|
||||
@@ -471,7 +474,8 @@ function renderWordCloud() {
|
||||
|
||||
const subOptions = computed<Duration>(
|
||||
() =>
|
||||
durationFromValue(query.duration) ?? durationFromValue(PANEL_DURATION_OPTIONS[0].value)!,
|
||||
durationFromValue(query.duration) ??
|
||||
durationFromValue(PANEL_DURATION_OPTIONS[0].value)!,
|
||||
)
|
||||
|
||||
async function handleStatistics() {
|
||||
|
||||
@@ -249,19 +249,23 @@ const ATTEMPT_COLORS: Record<string, string> = {
|
||||
* 已通过的沉底,它们只是「做完了」,不需要再看。
|
||||
*/
|
||||
function groupByProblem(list: SubmissionStatisticsItems["items"]) {
|
||||
const groups = new Map<string, {
|
||||
problem: string
|
||||
problemTitle: string
|
||||
items: SubmissionStatisticsItems["items"]
|
||||
}>()
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
problem: string
|
||||
problemTitle: string
|
||||
items: SubmissionStatisticsItems["items"]
|
||||
}
|
||||
>()
|
||||
for (const item of list) {
|
||||
const group = groups.get(item.problem)
|
||||
if (group) group.items.push(item)
|
||||
else groups.set(item.problem, {
|
||||
problem: item.problem,
|
||||
problemTitle: item.problemTitle,
|
||||
items: [item],
|
||||
})
|
||||
else
|
||||
groups.set(item.problem, {
|
||||
problem: item.problem,
|
||||
problemTitle: item.problemTitle,
|
||||
items: [item],
|
||||
})
|
||||
}
|
||||
return [...groups.values()]
|
||||
.map((group) => ({
|
||||
@@ -276,8 +280,9 @@ function groupByProblem(list: SubmissionStatisticsItems["items"]) {
|
||||
item.result === SubmissionStatus.ast_check_failed,
|
||||
),
|
||||
}))
|
||||
.sort((a, b) =>
|
||||
Number(a.solved) - Number(b.solved) || b.items.length - a.items.length,
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(a.solved) - Number(b.solved) || b.items.length - a.items.length,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -298,9 +303,18 @@ const columns: DataTableColumn<SubmissionStatisticsUser>[] = [
|
||||
h(NFlex, { size: "small", align: "flex-start", wrap: false }, () => [
|
||||
h(
|
||||
NFlex,
|
||||
{ size: 4, align: "center", wrap: false, style: "width: 200px; flex: none" },
|
||||
{
|
||||
size: 4,
|
||||
align: "center",
|
||||
wrap: false,
|
||||
style: "width: 200px; flex: none",
|
||||
},
|
||||
() => [
|
||||
h(NTag, { size: "small", bordered: false }, () => group.problem),
|
||||
h(
|
||||
NTag,
|
||||
{ size: "small", bordered: false },
|
||||
() => group.problem,
|
||||
),
|
||||
h(
|
||||
NText,
|
||||
{
|
||||
@@ -318,39 +332,46 @@ const columns: DataTableColumn<SubmissionStatisticsUser>[] = [
|
||||
depth: 3,
|
||||
style: "width: 104px; flex: none",
|
||||
},
|
||||
() => `${group.items.length} 次 · ${group.solved ? "已通过" : "未通过"}`,
|
||||
() =>
|
||||
`${group.items.length} 次 · ${group.solved ? "已通过" : "未通过"}`,
|
||||
),
|
||||
h(NFlex, { size: 4, wrap: true, style: "flex: 1; min-width: 0" }, () =>
|
||||
group.items.map((item) =>
|
||||
h(
|
||||
NTooltip,
|
||||
{ delay: 200 },
|
||||
{
|
||||
trigger: () =>
|
||||
h("button", {
|
||||
// 内联样式而不是 class:这些方块是 h() 出来、挂在 NDataTable 的
|
||||
// 展开槽里渲染的,<style scoped> 能不能盖到它并不确定
|
||||
style: {
|
||||
width: "14px",
|
||||
height: "14px",
|
||||
padding: "0",
|
||||
border: "none",
|
||||
borderRadius: "3px",
|
||||
cursor: "pointer",
|
||||
background: ATTEMPT_COLORS[JUDGE_STATUS[item.result]?.type ?? "default"],
|
||||
},
|
||||
onClick: (event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
openSubmission(item.id)
|
||||
},
|
||||
}),
|
||||
default: () =>
|
||||
`${JUDGE_STATUS[item.result]?.name ?? item.result} · ` +
|
||||
`${parseTime(item.createTime, "MM-DD HH:mm:ss")} · ` +
|
||||
`${item.id.toString().slice(0, 12)}`,
|
||||
},
|
||||
h(
|
||||
NFlex,
|
||||
{ size: 4, wrap: true, style: "flex: 1; min-width: 0" },
|
||||
() =>
|
||||
group.items.map((item) =>
|
||||
h(
|
||||
NTooltip,
|
||||
{ delay: 200 },
|
||||
{
|
||||
trigger: () =>
|
||||
h("button", {
|
||||
// 内联样式而不是 class:这些方块是 h() 出来、挂在 NDataTable 的
|
||||
// 展开槽里渲染的,<style scoped> 能不能盖到它并不确定
|
||||
style: {
|
||||
width: "14px",
|
||||
height: "14px",
|
||||
padding: "0",
|
||||
border: "none",
|
||||
borderRadius: "3px",
|
||||
cursor: "pointer",
|
||||
background:
|
||||
ATTEMPT_COLORS[
|
||||
JUDGE_STATUS[item.result]?.type ?? "default"
|
||||
],
|
||||
},
|
||||
onClick: (event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
openSubmission(item.id)
|
||||
},
|
||||
}),
|
||||
default: () =>
|
||||
`${JUDGE_STATUS[item.result]?.name ?? item.result} · ` +
|
||||
`${parseTime(item.createTime, "MM-DD HH:mm:ss")} · ` +
|
||||
`${item.id.toString().slice(0, 12)}`,
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
@@ -358,7 +379,8 @@ const columns: DataTableColumn<SubmissionStatisticsUser>[] = [
|
||||
? h(
|
||||
NText,
|
||||
{ depth: 3 },
|
||||
() => `只显示最近 ${loaded.items.length} 条,上面「提交数」才是总数`,
|
||||
() =>
|
||||
`只显示最近 ${loaded.items.length} 条,上面「提交数」才是总数`,
|
||||
)
|
||||
: null,
|
||||
])
|
||||
@@ -372,7 +394,11 @@ const columns: DataTableColumn<SubmissionStatisticsUser>[] = [
|
||||
render: (row) =>
|
||||
h(
|
||||
NTag,
|
||||
{ size: "small", type: row.done ? "success" : "default", bordered: false },
|
||||
{
|
||||
size: "small",
|
||||
type: row.done ? "success" : "default",
|
||||
bordered: false,
|
||||
},
|
||||
() => (row.done ? "已完成" : "未完成"),
|
||||
),
|
||||
},
|
||||
@@ -693,7 +719,9 @@ const completionChartOptions = {
|
||||
const subOptions = computed<Duration>(
|
||||
// 认不出来(含 all)就退回列表第一档,和原来 `?? options[0]` 一致;
|
||||
// all 实际不会走到这里,handleStatistics 先分支掉了
|
||||
() => durationFromValue(query.duration) ?? durationFromValue(PANEL_DURATION_OPTIONS[0].value)!,
|
||||
() =>
|
||||
durationFromValue(query.duration) ??
|
||||
durationFromValue(PANEL_DURATION_OPTIONS[0].value)!,
|
||||
)
|
||||
|
||||
function goSubmissions() {
|
||||
|
||||
@@ -84,7 +84,8 @@ const handleEditorReady = (payload: EditorReadyPayload) => {
|
||||
watch(
|
||||
() => collabStore.room,
|
||||
(room) => {
|
||||
if (room && !collabStore.isTeacher && editorView.value) bind(editorView.value)
|
||||
if (room && !collabStore.isTeacher && editorView.value)
|
||||
bind(editorView.value)
|
||||
else stop()
|
||||
},
|
||||
)
|
||||
|
||||
@@ -47,15 +47,21 @@ export function useCollabDoc() {
|
||||
if (doc) stop()
|
||||
const myGeneration = ++generation
|
||||
|
||||
const [Y, awarenessProtocol, syncProtocol, encoding, decoding, { yCollab }] =
|
||||
await Promise.all([
|
||||
import("yjs"),
|
||||
import("y-protocols/awareness"),
|
||||
import("y-protocols/sync"),
|
||||
import("lib0/encoding"),
|
||||
import("lib0/decoding"),
|
||||
import("y-codemirror.next"),
|
||||
])
|
||||
const [
|
||||
Y,
|
||||
awarenessProtocol,
|
||||
syncProtocol,
|
||||
encoding,
|
||||
decoding,
|
||||
{ yCollab },
|
||||
] = await Promise.all([
|
||||
import("yjs"),
|
||||
import("y-protocols/awareness"),
|
||||
import("y-protocols/sync"),
|
||||
import("lib0/encoding"),
|
||||
import("lib0/decoding"),
|
||||
import("y-codemirror.next"),
|
||||
])
|
||||
|
||||
// 等 chunk 的这段时间里房间关了(或者又开了新的一轮),整个放弃
|
||||
if (myGeneration !== generation) return
|
||||
@@ -106,7 +112,11 @@ export function useCollabDoc() {
|
||||
detachDocUpdate = () => doc?.off("update", onDocUpdate)
|
||||
|
||||
const onAwarenessUpdate = (
|
||||
{ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
|
||||
{
|
||||
added,
|
||||
updated,
|
||||
removed,
|
||||
}: { added: number[]; updated: number[]; removed: number[] },
|
||||
origin: any,
|
||||
) => {
|
||||
if (origin === "remote") return
|
||||
|
||||
@@ -64,7 +64,9 @@ export function useHiddenStudents(storageKey: string) {
|
||||
// 把已经到期的清掉再落一次盘,否则这张表只增不减
|
||||
const now = Date.now()
|
||||
const cleaned = Object.fromEntries(
|
||||
Object.entries(hiddenStudents.value).filter(([, expiresAt]) => expiresAt > now),
|
||||
Object.entries(hiddenStudents.value).filter(
|
||||
([, expiresAt]) => expiresAt > now,
|
||||
),
|
||||
)
|
||||
hiddenStudents.value = cleaned
|
||||
save(cleaned)
|
||||
|
||||
@@ -211,7 +211,9 @@ export class BaseWebSocket<T extends WebSocketMessage = WebSocketMessage> {
|
||||
RECONNECT_MAX_DELAY,
|
||||
)
|
||||
const delay = Math.round(base * (0.5 + Math.random() * 0.5))
|
||||
console.log(`[WebSocket] 将在 ${delay}ms 后重连 (第 ${this.reconnectAttempts} 次)`)
|
||||
console.log(
|
||||
`[WebSocket] 将在 ${delay}ms 后重连 (第 ${this.reconnectAttempts} 次)`,
|
||||
)
|
||||
this.reconnectTimer = window.setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
this.connect()
|
||||
@@ -516,7 +518,9 @@ export interface SubmissionUpdate extends WebSocketMessage {
|
||||
}
|
||||
|
||||
/** 判题进度。subscribe(submissionId) 认领,断线重连会自动补订阅 */
|
||||
export function useSubmissionWebSocket(handler?: MessageHandler<SubmissionUpdate>) {
|
||||
export function useSubmissionWebSocket(
|
||||
handler?: MessageHandler<SubmissionUpdate>,
|
||||
) {
|
||||
return useChannel<SubmissionUpdate>("/ws/submissions", handler)
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { LANGUAGE } from "utils/types"
|
||||
* Java / Golang / JavaScript 没有单独的包,落到 cpp(),是既有行为,不是遗漏。
|
||||
*/
|
||||
export function languageExtension(language: LANGUAGE): Extension {
|
||||
if (language === "SQL") return sql({ dialect: SQLite, upperCaseKeywords: true })
|
||||
if (language === "SQL")
|
||||
return sql({ dialect: SQLite, upperCaseKeywords: true })
|
||||
return ["Python2", "Python3"].includes(language) ? python() : cpp()
|
||||
}
|
||||
|
||||
@@ -60,11 +60,7 @@ const options = computed<MenuOption[]>(() => {
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(
|
||||
RouterLink,
|
||||
{ to: "/admin/ai/reports" },
|
||||
{ default: () => "报告" },
|
||||
),
|
||||
h(RouterLink, { to: "/admin/ai/reports" }, { default: () => "报告" }),
|
||||
key: "admin ai reports",
|
||||
},
|
||||
{
|
||||
@@ -148,11 +144,7 @@ const options = computed<MenuOption[]>(() => {
|
||||
},
|
||||
{
|
||||
label: () =>
|
||||
h(
|
||||
RouterLink,
|
||||
{ to: "/admin/ai/reports" },
|
||||
{ default: () => "报告" },
|
||||
),
|
||||
h(RouterLink, { to: "/admin/ai/reports" }, { default: () => "报告" }),
|
||||
key: "admin ai reports",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -47,8 +47,9 @@ instance.interceptors.response.use(
|
||||
// 这里**故意**不返回 AxiosResponse:把 axios 的外层和后端的 { data } 信封一起
|
||||
// 剥掉,让调用方直接拿到业务数据。类型上和 axios 的拦截器签名对不上(它期望原样
|
||||
// 返回响应),文件末尾的 `as unknown as ApiClient` 就是为了把真实形状交出去。
|
||||
((response: AxiosResponse) =>
|
||||
response.data.data) as unknown as (response: AxiosResponse) => AxiosResponse,
|
||||
((response: AxiosResponse) => response.data.data) as unknown as (
|
||||
response: AxiosResponse,
|
||||
) => AxiosResponse,
|
||||
(error) => {
|
||||
const payload = error.response?.data as ApiError | undefined
|
||||
const code = payload?.error?.code ?? "network-error"
|
||||
|
||||
@@ -98,9 +98,9 @@ export async function consumeJSONEventStream<T = any>(
|
||||
* 「无法解析服务端事件数据: {...}」。后端 error.message 是英文的,按 code 换成中文。
|
||||
*/
|
||||
export async function aiStreamError(response: Response) {
|
||||
const body = (await response.json().catch(() => null)) as
|
||||
| { error?: { code?: string } }
|
||||
| null
|
||||
const body = (await response.json().catch(() => null)) as {
|
||||
error?: { code?: string }
|
||||
} | null
|
||||
switch (body?.error?.code) {
|
||||
case "too-many-requests":
|
||||
return new Error("AI 请求太频繁了,歇一会儿再试")
|
||||
|
||||
@@ -82,7 +82,6 @@ export type SUBMISSION_RESULT = JudgeStatus | 9
|
||||
|
||||
export type ProblemStatus = "passed" | "failed" | "not_test"
|
||||
|
||||
|
||||
/**
|
||||
* 题目标签。用契约的 —— 它比手抄那份多一个 `problemCount`,
|
||||
* shared/api.ts 原来还得用 `Tag & { problemCount: number }` 把它补回来。
|
||||
@@ -403,11 +402,7 @@ export type Message = ContractMessage
|
||||
*
|
||||
* 注意 `ReactionCounts` 是 Partial 的:后端只下发有票的类型,没人投的键不出现。
|
||||
*/
|
||||
export type {
|
||||
ReactionKey,
|
||||
ReactionCounts,
|
||||
ReactionState,
|
||||
} from "@oj2/contract"
|
||||
export type { ReactionKey, ReactionCounts, ReactionState } from "@oj2/contract"
|
||||
import type { ReactionKey } from "@oj2/contract"
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user