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

+2
View File
@@ -264,6 +264,7 @@ export const Submission = {
prompt?: string
},
messageId?: number,
reflection?: string,
) {
const { prompt, ...rest } = code
const data = {
@@ -271,6 +272,7 @@ export const Submission = {
...rest,
prompt: prompt || null,
message_id: messageId ?? null,
reflection: reflection || null,
}
const res = await http.post("/submission/", data)
return res.data
+158 -51
View File
@@ -77,63 +77,105 @@
</div>
<GuidancePanel @generate="onGuidanceGenerate" />
<div class="input-area">
<n-flex
class="prompt-input-row"
align="flex-start"
:wrap="false"
:size="8"
>
<n-input
v-model:value="input"
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="flex-end" align="center" style="margin-top: 8px">
<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"
<!-- 只说“不行”“没反应”时提醒一下,可以直接发 -->
<div v-if="vagueHint" class="vague-hint">
<span>说清楚哪里不对,AI 更容易一次修好</span>
<n-flex :size="4" :wrap="false">
<n-button size="tiny" type="primary" secondary @click="openProblem">说清楚</n-button>
<n-button size="tiny" quaternary @click="sendAnyway">直接发</n-button>
</n-flex>
</div>
<div v-if="problemOpen" class="problem">
<div class="problem-title">出问题了?说清楚这三件事</div>
<label>我做了什么</label>
<n-input v-model:value="problem.did" placeholder="比如:点了「开始」按钮" />
<label>想看到什么</label>
<n-input v-model:value="problem.want" placeholder="比如:倒计时开始走" />
<label>实际看到了什么</label>
<n-input v-model:value="problem.saw" placeholder="比如:数字一直停在 60 不动" />
<n-flex justify="end" :size="8" style="margin-top: 10px">
<n-button @click="problemOpen = false">取消</n-button>
<n-button
type="primary"
:disabled="!problem.did.trim() || !problem.saw.trim() || !connected || streaming"
@click="sendProblem"
>
发给 AI
</n-button>
</n-flex>
</div>
<template v-else>
<n-flex
class="prompt-input-row"
align="flex-start"
:wrap="false"
:size="8"
>
发送
</n-button>
</n-flex>
<n-input
v-model:value="input"
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>
</template>
<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 { useMessage } from "naive-ui"
import { Icon } from "../../icons"
@@ -233,6 +275,46 @@ async function deletePair(assistantMsgId: number) {
async function send() {
const text = input.value.trim()
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 = ""
await props.beforeSend?.()
sendPrompt(text, selectedModel.value)
@@ -447,6 +529,31 @@ watch([() => messages.value.length, streamingContent], () => {
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 {
flex-shrink: 0;
padding: 12px;
@@ -119,6 +119,11 @@
</n-popconfirm>
</section>
<section v-if="sub.reflection">
<h4>学生说</h4>
<div class="reflection">{{ sub.reflection }}</div>
</section>
<section>
<n-flex justify="space-between" align="center" class="chain-head">
<h4>提示词 <span class="muted" v-if="chainSteps">· {{ chainSteps }} 轮</span></h4>
@@ -479,6 +484,17 @@ h4 {
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 {
margin: 0;
padding-left: 20px;
@@ -5,12 +5,16 @@
title="粘贴外部 AI 生成的代码"
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
v-model:value="prompt"
type="textarea"
:autosize="{ minRows: 2, maxRows: 6 }"
placeholder="把你发给豆包、DeepSeek 等 AI 的话粘贴到这里"
placeholder="把你发给豆包、DeepSeek 等 AI 的话粘贴到这里;说了好几轮的,每一轮都贴进来,一轮一行"
/>
</n-form-item>
<n-form-item label="完整的网页代码" :show-feedback="false" style="margin-top: 12px">
@@ -30,7 +34,7 @@
<n-button @click="show = false">取消</n-button>
<n-button
type="primary"
:disabled="!prompt.trim() || !raw.trim()"
:disabled="[...prompt.trim()].length < MIN_PROMPT || !raw.trim()"
:loading="saving"
@click="save"
>
@@ -41,7 +45,7 @@
</n-modal>
</template>
<script lang="ts" setup>
import { ref } from "vue"
import { computed, ref } from "vue"
import { splitHtml, type Code } from "../../utils/code"
const show = defineModel<boolean>("show", { required: true })
@@ -50,6 +54,12 @@ const props = defineProps<{
}>()
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 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>
</n-split>
<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-content title="全部历史" :native-scrollbar="false" body-content-style="padding: 0">
<PromptHistoryPanel
@@ -128,6 +134,7 @@ import Preview from "../editor/Preview.vue"
import CodeTabs from "./CodeTabs.vue"
import VersionBar, { type VersionItem } from "./VersionBar.vue"
import PasteCodeModal from "./PasteCodeModal.vue"
import SubmitModal from "./SubmitModal.vue"
import { Prompt, Submission } from "../../api"
import { useTaskCode } from "../../composables/useTaskCode"
import {
@@ -174,6 +181,7 @@ const selectedId = ref<number | null>(null)
const busy = ref(false)
const briefOpen = ref(true)
const showPaste = ref(false)
const showSubmit = ref(false)
const showHistory = ref(false)
const historyKey = ref(0)
@@ -308,21 +316,46 @@ function openHistory() {
}
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
try {
if (props.kind === "creation") {
await props.saveWork?.({ ...code })
message.success("作品已保存")
return
}
const id =
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)
const n = versions.value.find((v) => v.id === id)?.n
message.success(n ? `已提交 v${n}` : "已提交")
} catch (err: any) {
message.error(err.response?.data?.detail ?? "提交失败,请重试")
throw err
} finally {
busy.value = false
}
+2
View File
@@ -4,6 +4,7 @@ import {
ArrowRight,
Award,
BookOpen,
Bug,
ChartNoAxesColumn,
Check,
ChevronDown,
@@ -49,6 +50,7 @@ const registry: Record<string, Component> = {
"arrow-right": ArrowRight,
award: Award,
"book-open": BookOpen,
bug: Bug,
"bar-chart-2": ChartNoAxesColumn,
check: Check,
"chevron-down": ChevronDown,
+2
View File
@@ -167,6 +167,8 @@ export interface SubmissionAll {
view_count: number
created: Date
modified: Date
// 交的时候写的反思,只有老师和作者本人拿得到
reflection?: string | null
}
export interface UserTag {