feat: “出问题了”三栏说清问题,交作品时写一句反思,外部提示词至少 8 个字
Deploy / deploy (build, debian, 22) (push) Canceled after 0s
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:
1 parent
5bdacc438d
commit
e9127318cb
12 files changed
+351
-63
No files matched your search
@@ -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`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in new issue
Block a user