feat: “出问题了”三栏说清问题,交作品时写一句反思,外部提示词至少 8 个字
Deploy / deploy (build, debian, 22) (push) Canceled after 0s

- 对话框加“出问题了”:做了什么 / 想看到什么 / 实际看到了什么拼成一句发给 AI;
  只说“还是不行”这类的提醒一次,可以直接发
- “交这一版”先弹框问最满意的改动和理由(可以不写),存在 submission_reflection,
  批改面板显示为“学生说”,只有老师和作者本人看得到
- 粘贴外部 AI 代码时提示词至少 8 个字,可以把几轮都贴进来

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-27 22:10:48 +08:00
1 parent 5bdacc438d
commit e9127318cb
12 files changed
+351 -63

No files matched your search

+1 -1
View File
@@ -30,7 +30,7 @@ The backend was rewritten from Django in 2026-10 to cut memory use (~25 MB vs. 1
- `src/router.ts` — Vue Router; `meta.auth` needs login, `meta.role` (`staff` = admin+super, `super`) is checked in the guard after `store/session.loadUser()`, `meta.bare` pages render without the top bar - `src/router.ts` — Vue Router; `meta.auth` needs login, `meta.role` (`staff` = admin+super, `super`) is checked in the guard after `store/session.loadUser()`, `meta.bare` pages render without the top bar
- `src/theme.ts` — the only place colors are defined: one palette (light + dark values) becomes both `--wp-*` CSS variables and Naive UI `themeOverrides`; use `var(--wp-...)` in styles, never hard-coded colors (iframes stay white: they show students' pages). No `color-mix()` (Chrome 105). `store/theme.ts` holds the mode (auto / light / dark, toggled in the top bar) and sets `data-theme` on `<html>`; `styles/dark-content.css` is generated from the GitHub dark markdown and highlight.js styles (selectors prefixed with `:root[data-theme="dark"]`); CodeMirror uses One Dark highlighting in dark mode - `src/theme.ts` — the only place colors are defined: one palette (light + dark values) becomes both `--wp-*` CSS variables and Naive UI `themeOverrides`; use `var(--wp-...)` in styles, never hard-coded colors (iframes stay white: they show students' pages). No `color-mix()` (Chrome 105). `store/theme.ts` holds the mode (auto / light / dark, toggled in the top bar) and sets `data-theme` on `<html>`; `styles/dark-content.css` is generated from the GitHub dark markdown and highlight.js styles (selectors prefixed with `:root[data-theme="dark"]`); CodeMirror uses One Dark highlighting in dark mode
- `src/icons.ts` — `<Icon icon="lucide:name" />` renders bundled `lucide-vue-next` icons (works offline); register new names in its map. Fonts are bundled too (`@fontsource/jetbrains-mono`) - `src/icons.ts` — `<Icon icon="lucide:name" />` renders bundled `lucide-vue-next` icons (works offline); register new names in its map. Fonts are bundled too (`@fontsource/jetbrains-mono`)
- `components/workbench/Workbench.vue` — shared challenge / creation page: left = brief + AI chat (`PromptPanel`), right = preview / code (`CodeTabs`) + `VersionBar`. Every assistant message with code is a version; hand edits and pasted external-AI code are saved as versions via `POST /prompt/versions` (`source` `edit` / `manual`, stored as a user+assistant pair). Challenges are submitted only when the student clicks “交这一版” (`POST /submission/` with `message_id`), never automatically. Editor code is per task (`composables/useTaskCode.ts`, localStorage `web-code:<user>:<task>`); the tutorial page edits the shared `store/editors.ts` refs but `Workspace.vue` saves them under the same per-task key and `TutorialContent` restores them (example code only the first time) - `components/workbench/Workbench.vue` — shared challenge / creation page: left = brief + AI chat (`PromptPanel`), right = preview / code (`CodeTabs`) + `VersionBar`. Every assistant message with code is a version; hand edits and pasted external-AI code are saved as versions via `POST /prompt/versions` (`source` `edit` / `manual`, stored as a user+assistant pair). Challenges are submitted only when the student clicks “交这一版” (`POST /submission/` with `message_id`), never automatically; `SubmitModal` first asks an optional one-line reflection (最满意的改动和理由), stored in `submission_reflection` (`ON DELETE CASCADE`, returned by `GET /submission/:id` only to staff and the author, shown as “学生说” in `ReviewPanel`). In `PromptPanel`, “出问题了” is a three-field form (做了什么 / 想看到什么 / 实际看到了什么) joined into one prompt; a short vague complaint (“还是不行”) gets a one-time hint offering it. Pasted external-AI prompts need at least 8 characters Editor code is per task (`composables/useTaskCode.ts`, localStorage `web-code:<user>:<task>`); the tutorial page edits the shared `store/editors.ts` refs but `Workspace.vue` saves them under the same per-task key and `TutorialContent` restores them (example code only the first time)
- `pages/ChallengeHome.vue` — challenge list page; `pages/TaskEditor.vue` (prop `kind`) is the single tutorial / challenge authoring page - `pages/ChallengeHome.vue` — challenge list page; `pages/TaskEditor.vue` (prop `kind`) is the single tutorial / challenge authoring page
- `pages/Review.vue` (“批改”, default teaching page) + `components/review/ReviewPanel.vue` (also opened from the gradebook) — opens as a large preview (wall hidden, Esc returns; “小面板” switches to a side panel, remembered); keys: J/K previous/next student (by key position), 1–5 rate, P present; “全部班级” groups the wall by class; `components/review/PeersDrawer.vue` is the students' “同学作品” (本班 / 全部班级) - `pages/Review.vue` (“批改”, default teaching page) + `components/review/ReviewPanel.vue` (also opened from the gradebook) — opens as a large preview (wall hidden, Esc returns; “小面板” switches to a side panel, remembered); keys: J/K previous/next student (by key position), 1–5 rate, P present; “全部班级” groups the wall by class; `components/review/PeersDrawer.vue` is the students' “同学作品” (本班 / 全部班级)
- `components/layout/AppShell.vue` — top bar for every page; `pages/Dashboard.vue` is the teaching sidebar (“教学”) under `/dashboard` - `components/layout/AppShell.vue` — top bar for every page; `pages/Dashboard.vue` is the teaching sidebar (“教学”) under `/dashboard`
+2
View File
@@ -264,6 +264,7 @@ export const Submission = {
prompt?: string prompt?: string
}, },
messageId?: number, messageId?: number,
reflection?: string,
) { ) {
const { prompt, ...rest } = code const { prompt, ...rest } = code
const data = { const data = {
@@ -271,6 +272,7 @@ export const Submission = {
...rest, ...rest,
prompt: prompt || null, prompt: prompt || null,
message_id: messageId ?? null, message_id: messageId ?? null,
reflection: reflection || null,
} }
const res = await http.post("/submission/", data) const res = await http.post("/submission/", data)
return res.data return res.data
+158 -51
View File
@@ -77,63 +77,105 @@
</div> </div>
<GuidancePanel @generate="onGuidanceGenerate" /> <GuidancePanel @generate="onGuidanceGenerate" />
<div class="input-area"> <div class="input-area">
<n-flex <!-- 只说“不行”“没反应”时提醒一下,可以直接发 -->
class="prompt-input-row" <div v-if="vagueHint" class="vague-hint">
align="flex-start" <span>说清楚哪里不对,AI 更容易一次修好</span>
:wrap="false" <n-flex :size="4" :wrap="false">
:size="8" <n-button size="tiny" type="primary" secondary @click="openProblem">说清楚</n-button>
> <n-button size="tiny" quaternary @click="sendAnyway">直接发</n-button>
<n-input </n-flex>
v-model:value="input" </div>
type="textarea" <div v-if="problemOpen" class="problem">
:autosize="{ minRows: 1, maxRows: 4 }" <div class="problem-title">出问题了?说清楚这三件事</div>
placeholder="用你自己的话说说想做成什么样…" <label>我做了什么</label>
:disabled="streaming" <n-input v-model:value="problem.did" placeholder="比如:点了「开始」按钮" />
style="flex: 1; min-width: 0" <label>想看到什么</label>
@keydown.enter.exact.prevent="send" <n-input v-model:value="problem.want" placeholder="比如:倒计时开始走" />
/> <label>实际看到了什么</label>
<n-tooltip> <n-input v-model:value="problem.saw" placeholder="比如:数字一直停在 60 不动" />
<template #trigger> <n-flex justify="end" :size="8" style="margin-top: 10px">
<n-button <n-button @click="problemOpen = false">取消</n-button>
secondary <n-button
:disabled="!input.trim() || !connected || !currentTaskId || streaming" type="primary"
style="flex-shrink: 0" :disabled="!problem.did.trim() || !problem.saw.trim() || !connected || streaming"
@click="startGuidance" @click="sendProblem"
> >
<template #icon> 发给 AI
<Icon icon="lucide:lightbulb" :width="16" /> </n-button>
</template> </n-flex>
帮我写清楚 </div>
</n-button> <template v-else>
</template> <n-flex
AI 教练会问你几个问题,帮你把想要的效果描述清楚 class="prompt-input-row"
</n-tooltip> align="flex-start"
</n-flex> :wrap="false"
<n-flex justify="flex-end" align="center" style="margin-top: 8px"> :size="8"
<n-select
v-model:value="selectedModel"
:options="modelOptions"
style="width: 150px"
:disabled="streaming"
/>
<n-button v-if="streaming" type="error" @click="stopPrompt">
停止
</n-button>
<n-button
v-else
type="primary"
:disabled="!input.trim() || !connected"
@click="send"
> >
发送 <n-input
</n-button> v-model:value="input"
</n-flex> type="textarea"
:autosize="{ minRows: 1, maxRows: 4 }"
placeholder="用你自己的话说说想做成什么样…"
:disabled="streaming"
style="flex: 1; min-width: 0"
@keydown.enter.exact.prevent="send"
/>
<n-tooltip>
<template #trigger>
<n-button
secondary
:disabled="!input.trim() || !connected || !currentTaskId || streaming"
style="flex-shrink: 0"
@click="startGuidance"
>
<template #icon>
<Icon icon="lucide:lightbulb" :width="16" />
</template>
帮我写清楚
</n-button>
</template>
AI 教练会问你几个问题,帮你把想要的效果描述清楚
</n-tooltip>
</n-flex>
<n-flex justify="space-between" align="center" style="margin-top: 8px">
<n-tooltip>
<template #trigger>
<n-button quaternary :disabled="!hasVersion || streaming" @click="openProblem">
<template #icon>
<Icon icon="lucide:bug" :width="16" />
</template>
出问题了
</n-button>
</template>
网页哪里不对?说清楚做了什么、想看到什么、实际看到了什么
</n-tooltip>
<n-flex align="center" :size="8">
<n-select
v-model:value="selectedModel"
:options="modelOptions"
style="width: 150px"
:disabled="streaming"
/>
<n-button v-if="streaming" type="error" @click="stopPrompt">
停止
</n-button>
<n-button
v-else
type="primary"
:disabled="!input.trim() || !connected"
@click="send"
>
发送
</n-button>
</n-flex>
</n-flex>
</template>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, nextTick, computed, onUnmounted } from "vue" import { ref, reactive, watch, nextTick, computed, onUnmounted } from "vue"
import { useStorage } from "@vueuse/core" import { useStorage } from "@vueuse/core"
import { useMessage } from "naive-ui" import { useMessage } from "naive-ui"
import { Icon } from "../../icons" import { Icon } from "../../icons"
@@ -233,6 +275,46 @@ async function deletePair(assistantMsgId: number) {
async function send() { async function send() {
const text = input.value.trim() const text = input.value.trim()
if (!text || streaming.value) return if (!text || streaming.value) return
// 只说“不行”“没反应”,AI 只能瞎猜;提醒一次,学生选“直接发”以后这次就不再拦
if (!skipVagueCheck && hasVersion.value && [...text].length <= 15 && VAGUE.test(text)) {
vagueHint.value = true
return
}
input.value = ""
await props.beforeSend?.()
sendPrompt(text, selectedModel.value)
}
// “出问题了”:做了什么、想看到什么、实际看到了什么,拼成一句发给 AI
const VAGUE =
/不行|不对|没反应|没用|没效果|用不了|不能用|不能玩|动不了|进不去|打不开|没显示|不显示|显示不出|bug|出错|报错|错了|坏了|白屏|空白|有问题/i
const vagueHint = ref(false)
let skipVagueCheck = false
const problemOpen = ref(false)
const problem = reactive({ did: "", want: "", saw: "" })
watch(input, () => (vagueHint.value = false))
const hasVersion = computed(() => messages.value.some((m) => m.role === "assistant" && hasCode(m)))
function openProblem() {
vagueHint.value = false
problemOpen.value = true
}
function sendAnyway() {
skipVagueCheck = true
vagueHint.value = false
send()
}
async function sendProblem() {
const part = (s: string) => s.trim().replace(/[。,,.!!;;]+$/, "")
const did = part(problem.did).replace(/^我/, "")
const want = part(problem.want)
const text = `我${did},${want ? `想看到${want},` : ""}但实际${part(problem.saw)}。`
Object.assign(problem, { did: "", want: "", saw: "" })
problemOpen.value = false
input.value = "" input.value = ""
await props.beforeSend?.() await props.beforeSend?.()
sendPrompt(text, selectedModel.value) sendPrompt(text, selectedModel.value)
@@ -447,6 +529,31 @@ watch([() => messages.value.length, streamingContent], () => {
animation: pulse 1.5s ease-in-out infinite; animation: pulse 1.5s ease-in-out infinite;
} }
.vague-hint {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
padding: 6px 10px;
border-radius: 6px;
background: var(--wp-brand-soft);
font-size: 13px;
}
.problem-title {
margin-bottom: 4px;
font-size: 13px;
font-weight: 600;
}
.problem label {
display: block;
margin: 8px 0 4px;
font-size: 12px;
color: var(--wp-muted);
}
.input-area { .input-area {
flex-shrink: 0; flex-shrink: 0;
padding: 12px; padding: 12px;
@@ -119,6 +119,11 @@
</n-popconfirm> </n-popconfirm>
</section> </section>
<section v-if="sub.reflection">
<h4>学生说</h4>
<div class="reflection">{{ sub.reflection }}</div>
</section>
<section> <section>
<n-flex justify="space-between" align="center" class="chain-head"> <n-flex justify="space-between" align="center" class="chain-head">
<h4>提示词 <span class="muted" v-if="chainSteps">· {{ chainSteps }} 轮</span></h4> <h4>提示词 <span class="muted" v-if="chainSteps">· {{ chainSteps }} 轮</span></h4>
@@ -479,6 +484,17 @@ h4 {
flex-wrap: wrap; flex-wrap: wrap;
} }
/* 交的时候写的反思 */
.reflection {
padding: 6px 10px;
border-left: 3px solid var(--wp-brand);
background: var(--wp-brand-soft);
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.chain { .chain {
margin: 0; margin: 0;
padding-left: 20px; padding-left: 20px;
@@ -5,12 +5,16 @@
title="粘贴外部 AI 生成的代码" title="粘贴外部 AI 生成的代码"
style="width: 640px; max-width: 94vw" style="width: 640px; max-width: 94vw"
> >
<n-form-item label="你在外部 AI 里用的提示词" :show-feedback="false"> <n-form-item
label="你在外部 AI 里说了什么"
:validation-status="promptTooShort ? 'warning' : undefined"
:feedback="promptTooShort ? `再写具体一点:你让 AI 做了什么、改了什么(至少 ${MIN_PROMPT} 个字)` : ''"
>
<n-input <n-input
v-model:value="prompt" v-model:value="prompt"
type="textarea" type="textarea"
:autosize="{ minRows: 2, maxRows: 6 }" :autosize="{ minRows: 2, maxRows: 6 }"
placeholder="把你发给豆包、DeepSeek 等 AI 的话粘贴到这里" placeholder="把你发给豆包、DeepSeek 等 AI 的话粘贴到这里;说了好几轮的,每一轮都贴进来,一轮一行"
/> />
</n-form-item> </n-form-item>
<n-form-item label="完整的网页代码" :show-feedback="false" style="margin-top: 12px"> <n-form-item label="完整的网页代码" :show-feedback="false" style="margin-top: 12px">
@@ -30,7 +34,7 @@
<n-button @click="show = false">取消</n-button> <n-button @click="show = false">取消</n-button>
<n-button <n-button
type="primary" type="primary"
:disabled="!prompt.trim() || !raw.trim()" :disabled="[...prompt.trim()].length < MIN_PROMPT || !raw.trim()"
:loading="saving" :loading="saving"
@click="save" @click="save"
> >
@@ -41,7 +45,7 @@
</n-modal> </n-modal>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref } from "vue" import { computed, ref } from "vue"
import { splitHtml, type Code } from "../../utils/code" import { splitHtml, type Code } from "../../utils/code"
const show = defineModel<boolean>("show", { required: true }) const show = defineModel<boolean>("show", { required: true })
@@ -50,6 +54,12 @@ const props = defineProps<{
}>() }>()
const prompt = ref("") const prompt = ref("")
// 以前四分之一的外部提示词只有“1”“倒计时”这样几个字,看不出学生想了什么
const MIN_PROMPT = 8
const promptTooShort = computed(() => {
const n = [...prompt.value.trim()].length
return n > 0 && n < MIN_PROMPT
})
const raw = ref("") const raw = ref("")
const saving = ref(false) const saving = ref(false)
@@ -0,0 +1,67 @@
<template>
<n-modal
v-model:show="show"
preset="card"
:title="title"
style="width: 520px; max-width: 94vw"
:mask-closable="!saving"
>
<div class="question">
{{ hasEarlier ? "比起第一版,这一版你最满意的改动是什么?为什么这样改?" : "这一版你最满意的地方是什么?为什么?" }}
</div>
<n-input
v-model:value="reflection"
type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
:maxlength="500"
placeholder="比如:我把按钮从五个减到三个,因为按钮太多了看不出先点哪个"
/>
<n-text depth="3" class="hint">可以不写,直接交。写了的话,老师批改时能看到。</n-text>
<template #footer>
<n-flex justify="end">
<n-button :disabled="saving" @click="show = false">取消</n-button>
<n-button type="primary" :loading="saving" @click="save">交</n-button>
</n-flex>
</template>
</n-modal>
</template>
<script lang="ts" setup>
import { ref } from "vue"
// 交作品前问一句反思:最满意的改动和理由。不拦着交,只是让学生回头看一眼自己改了什么
const show = defineModel<boolean>("show", { required: true })
const props = defineProps<{
title: string
// 之前还有别的版本,就问“比起第一版”
hasEarlier: boolean
onSubmit: (reflection: string) => Promise<void>
}>()
const reflection = ref("")
const saving = ref(false)
async function save() {
saving.value = true
try {
await props.onSubmit(reflection.value.trim())
reflection.value = ""
show.value = false
} finally {
saving.value = false
}
}
</script>
<style scoped>
.question {
margin-bottom: 10px;
font-size: 14px;
line-height: 1.6;
color: var(--wp-ink);
}
.hint {
display: block;
margin-top: 8px;
font-size: 12px;
}
</style>
+39 -6
View File
@@ -103,6 +103,12 @@
</template> </template>
</n-split> </n-split>
<PasteCodeModal v-model:show="showPaste" :on-save="savePasted" /> <PasteCodeModal v-model:show="showPaste" :on-save="savePasted" />
<SubmitModal
v-model:show="showSubmit"
:title="submitTitle"
:has-earlier="submitHasEarlier"
:on-submit="submitChallenge"
/>
<n-drawer v-model:show="showHistory" :width="420" placement="right"> <n-drawer v-model:show="showHistory" :width="420" placement="right">
<n-drawer-content title="全部历史" :native-scrollbar="false" body-content-style="padding: 0"> <n-drawer-content title="全部历史" :native-scrollbar="false" body-content-style="padding: 0">
<PromptHistoryPanel <PromptHistoryPanel
@@ -128,6 +134,7 @@ import Preview from "../editor/Preview.vue"
import CodeTabs from "./CodeTabs.vue" import CodeTabs from "./CodeTabs.vue"
import VersionBar, { type VersionItem } from "./VersionBar.vue" import VersionBar, { type VersionItem } from "./VersionBar.vue"
import PasteCodeModal from "./PasteCodeModal.vue" import PasteCodeModal from "./PasteCodeModal.vue"
import SubmitModal from "./SubmitModal.vue"
import { Prompt, Submission } from "../../api" import { Prompt, Submission } from "../../api"
import { useTaskCode } from "../../composables/useTaskCode" import { useTaskCode } from "../../composables/useTaskCode"
import { import {
@@ -174,6 +181,7 @@ const selectedId = ref<number | null>(null)
const busy = ref(false) const busy = ref(false)
const briefOpen = ref(true) const briefOpen = ref(true)
const showPaste = ref(false) const showPaste = ref(false)
const showSubmit = ref(false)
const showHistory = ref(false) const showHistory = ref(false)
const historyKey = ref(0) const historyKey = ref(0)
@@ -308,21 +316,46 @@ function openHistory() {
} }
async function submit() { async function submit() {
if (props.kind === "challenge") {
showSubmit.value = true
return
}
busy.value = true
try {
await props.saveWork?.({ ...code })
message.success("作品已保存")
} catch (err: any) {
message.error(err.response?.data?.detail ?? "保存失败,请重试")
} finally {
busy.value = false
}
}
const submitTitle = computed(() => {
if (modified.value || !selectedId.value) return "交当前修改"
const n = versions.value.find((v) => v.id === selectedId.value)?.n
return n ? `交 v${n}` : "交这一版"
})
// 要交的这一版之前还有版本(当前修改会存成新的一版)
const submitHasEarlier = computed(() => {
if (modified.value || !selectedId.value) return versions.value.length > 0
return (versions.value.find((v) => v.id === selectedId.value)?.n ?? 1) > 1
})
// 挑战:先在弹框里写一句反思(可以不写)再交;出错时抛出去,弹框不关
async function submitChallenge(reflection: string) {
busy.value = true busy.value = true
try { try {
if (props.kind === "creation") {
await props.saveWork?.({ ...code })
message.success("作品已保存")
return
}
const id = const id =
modified.value || !selectedId.value ? await saveAsVersion() : selectedId.value modified.value || !selectedId.value ? await saveAsVersion() : selectedId.value
await Submission.create(props.taskId, { ...code }, id) await Submission.create(props.taskId, { ...code }, id, reflection)
markMessageSubmitted(id) markMessageSubmitted(id)
const n = versions.value.find((v) => v.id === id)?.n const n = versions.value.find((v) => v.id === id)?.n
message.success(n ? `已提交 v${n}` : "已提交") message.success(n ? `已提交 v${n}` : "已提交")
} catch (err: any) { } catch (err: any) {
message.error(err.response?.data?.detail ?? "提交失败,请重试") message.error(err.response?.data?.detail ?? "提交失败,请重试")
throw err
} finally { } finally {
busy.value = false busy.value = false
} }
+2
View File
@@ -4,6 +4,7 @@ import {
ArrowRight, ArrowRight,
Award, Award,
BookOpen, BookOpen,
Bug,
ChartNoAxesColumn, ChartNoAxesColumn,
Check, Check,
ChevronDown, ChevronDown,
@@ -49,6 +50,7 @@ const registry: Record<string, Component> = {
"arrow-right": ArrowRight, "arrow-right": ArrowRight,
award: Award, award: Award,
"book-open": BookOpen, "book-open": BookOpen,
bug: Bug,
"bar-chart-2": ChartNoAxesColumn, "bar-chart-2": ChartNoAxesColumn,
check: Check, check: Check,
"chevron-down": ChevronDown, "chevron-down": ChevronDown,
+2
View File
@@ -167,6 +167,8 @@ export interface SubmissionAll {
view_count: number view_count: number
created: Date created: Date
modified: Date modified: Date
// 交的时候写的反思,只有老师和作者本人拿得到
reflection?: string | null
} }
export interface UserTag { export interface UserTag {
+3
View File
@@ -51,6 +51,9 @@ CREATE INDEX IF NOT EXISTS "prompt_message_prompt_level_e461b596" ON "prompt_mes
CREATE INDEX IF NOT EXISTS "prompt_message_chain" ON "prompt_message" ("conversation_id", "created", "id", "role", "source", "prompt_level"); CREATE INDEX IF NOT EXISTS "prompt_message_chain" ON "prompt_message" ("conversation_id", "created", "id", "role", "source", "prompt_level");
CREATE INDEX IF NOT EXISTS "prompt_message_submission" ON "prompt_message" ("submission_id", "conversation_id", "created", "source") WHERE "submission_id" IS NOT NULL; CREATE INDEX IF NOT EXISTS "prompt_message_submission" ON "prompt_message" ("submission_id", "conversation_id", "created", "source") WHERE "submission_id" IS NOT NULL;
-- 学生交作品时写的一句反思(最满意的改动和理由);可以不写。删提交时跟着删
CREATE TABLE IF NOT EXISTS "submission_reflection" ("submission_id" char(32) NOT NULL PRIMARY KEY REFERENCES "submission_submission" ("id") ON DELETE CASCADE, "content" text NOT NULL, "modified" datetime NOT NULL);
-- 登录用 JWT:access token 不落库,refresh token 只存 sha256 -- 登录用 JWT:access token 不落库,refresh token 只存 sha256
DROP TABLE IF EXISTS "auth_session"; DROP TABLE IF EXISTS "auth_session";
CREATE TABLE IF NOT EXISTS "auth_refresh_token" ("token_hash" text NOT NULL PRIMARY KEY, "user_id" bigint NOT NULL REFERENCES "account_user" ("id") ON DELETE CASCADE, "expire_at" datetime NOT NULL); CREATE TABLE IF NOT EXISTS "auth_refresh_token" ("token_hash" text NOT NULL PRIMARY KEY, "user_id" bigint NOT NULL REFERENCES "account_user" ("id") ON DELETE CASCADE, "expire_at" datetime NOT NULL);
+19 -1
View File
@@ -120,6 +120,7 @@ router.post("/", async (c) => {
js: z.string().nullable().optional(), js: z.string().nullable().optional(),
prompt: z.string().nullable().optional(), prompt: z.string().nullable().optional(),
message_id: z.number().int().nullable().optional(), // 关联的 assistant message message_id: z.number().int().nullable().optional(), // 关联的 assistant message
reflection: z.string().max(1000).nullable().optional(), // 交的时候写的一句反思,可以不写
}), }),
) )
const task = orNotFound( const task = orNotFound(
@@ -131,6 +132,8 @@ router.post("/", async (c) => {
const code = { html: p.html ?? null, css: p.css ?? null, js: p.js ?? null } const code = { html: p.html ?? null, css: p.css ?? null, js: p.js ?? null }
const reflection = p.reflection?.trim() || null
const { submissionId, newUserMsgId } = tx(() => { const { submissionId, newUserMsgId } = tx(() => {
let manualAsstMsgId: number | null = null let manualAsstMsgId: number | null = null
let linkedMsg: MessageRow | null = null let linkedMsg: MessageRow | null = null
@@ -203,6 +206,15 @@ router.post("/", async (c) => {
return { submissionId: id, newUserMsgId } return { submissionId: id, newUserMsgId }
}) })
// 同一版再交时没写就留着上次写的
if (reflection) {
run(
`INSERT INTO submission_reflection (submission_id, content, modified) VALUES ($id, $content, $ts)
ON CONFLICT (submission_id) DO UPDATE SET content = excluded.content, modified = excluded.modified`,
{ id: submissionId, content: reflection, ts: now() },
)
}
// 只对新增的提示词做层级分类 // 只对新增的提示词做层级分类
if ( if (
newUserMsgId !== null && newUserMsgId !== null &&
@@ -540,7 +552,13 @@ router.get("/:id", (c) => {
const id = uuidParam(c, "id") const id = uuidParam(c, "id")
requireView(user, id) requireView(user, id)
const s = orNotFound(get(`${SUBMISSION_SELECT} ${SUBMISSION_FROM} WHERE s.id = $id`, { me: user.id, id })) const s = orNotFound(get(`${SUBMISSION_SELECT} ${SUBMISSION_FROM} WHERE s.id = $id`, { me: user.id, id }))
const out = submissionOut(s) // 反思只给老师和作者本人看
const reflection =
isStaff(user) || s.user_id === user.id
? (get<{ content: string }>(`SELECT content FROM submission_reflection WHERE submission_id = $id`, { id })
?.content ?? null)
: null
const out = { ...submissionOut(s), reflection }
// 和同学作品一样:学生给同学打分之前不告诉作者和得分(作品展里的本来就署名) // 和同学作品一样:学生给同学打分之前不告诉作者和得分(作品展里的本来就署名)
if (!isStaff(user) && s.user_id !== user.id && !out.my_score && !inShowcase(id)) { if (!isStaff(user) && s.user_id !== user.id && !out.my_score && !inShowcase(id)) {
return c.json({ ...out, userid: null, username: null, score: null }) return c.json({ ...out, userid: null, username: null, score: null })
+28
View File
@@ -276,6 +276,34 @@ describe("提交与删除", () => {
expect(all(`SELECT COUNT(*) AS n FROM submission_submission WHERE id != $id`, { id: otherSub })).toEqual([{ n: 1 }]) expect(all(`SELECT COUNT(*) AS n FROM submission_submission WHERE id != $id`, { id: otherSub })).toEqual([{ n: 1 }])
}) })
test("交的时候写的反思:再交没写就保留,只有老师和作者能看,删提交时一起删", async () => {
makeUser("stu")
makeUser("mate")
makeUser("teacher", "admin")
const task = makeTask("T")
const stu = await client("stu")
const edit = await stu("POST", "/api/prompt/versions", { task_id: task, html: "<p>1", css: "", js: "" })
const sub = await stu("POST", "/api/submission/", {
task_id: task,
html: "<p>1",
message_id: edit.json.message_id,
reflection: " 按钮改成三个,因为五个太乱 ",
})
const id = sub.json.id
await stu("POST", "/api/submission/", { task_id: task, html: "<p>1!", message_id: edit.json.message_id, reflection: "" })
expect((await stu("GET", `/api/submission/${id}`)).json.reflection).toBe("按钮改成三个,因为五个太乱")
expect((await (await client("teacher"))("GET", `/api/submission/${id}`)).json.reflection).toBe("按钮改成三个,因为五个太乱")
const mate = await client("mate")
await mate("POST", "/api/submission/", { task_id: task, html: "<p>m" })
const peer = await mate("GET", `/api/submission/${id}`)
expect(peer.status).toBe(200)
expect(peer.json.reflection).toBeNull()
expect((await stu("DELETE", `/api/submission/${id}`)).status).toBe(200)
expect(all(`SELECT * FROM submission_reflection`)).toEqual([])
})
test("被评分的提交只有超管能删", async () => { test("被评分的提交只有超管能删", async () => {
const author = makeUser("author") const author = makeUser("author")
const other = makeUser("other") const other = makeUser("other")