Files
web/client/src/components/submissions/ChainModal.vue
T
xuyueandClaude Opus 5.5 9c6c01eba1 feat: 改版阶段 2:挑战和自由创作共用工作台,学生自己选版本提交
- 新的 Workbench:左边题目(可收起)+ AI 对话,右边预览 / 代码(可编辑),底部版本条;
  挑战页和自由创作页都用它,原来的“手动提交”“历史对话”标签换成“粘贴外部代码”和“全部历史”
- 不再每轮 AI 回复自动提交:学生点“交这一版”,可交多次,成绩仍取最高分;已交的版本不能重复点
- 手改代码、粘贴外部 AI 代码都存成版本(POST /prompt/versions,source 为 edit / manual),
  在版本条和提示词链里标“手改”“外部 AI”;它们进入 AI 上下文,AI 在手改后的代码上继续改;
  以前手动提交的回复正文为空时用代码补上;edit 的占位提问不参与提示词层级分类
- 互评从全屏弹窗改成对话里的卡片,打分前不显示作者,每个分数写明含义
- 编辑器代码按任务各存一份(useTaskCode),挑战页“清空”不再清掉教程的代码;
  提交列表“复制到编辑器”对挑战写入该任务自己的代码
- 抽出 utils/code.ts(整理代码、拆分整页 HTML);删除 RandomRatingModal、ExternalAIPanel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 23:59:24 +08:00

392 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<n-modal
:show="show"
preset="card"
style="width: 90vw; max-width: 1400px"
@update:show="$emit('update:show', $event)"
>
<template #header>
<n-flex justify="start" align="center">
<span>提示词</span>
<n-tooltip placement="bottom-start">
<template #trigger>
<Icon icon="lucide:info" :width="20" style="color: #aaa" />
</template>
<div style="line-height: 2">
<div>
<span :style="{ color: levelColors[1], fontWeight: 'bold' }">
L1
</span>
— 记忆:复述或识别知识点
</div>
<div>
<span :style="{ color: levelColors[2], fontWeight: 'bold' }">
L2
</span>
— 理解:用自己的话解释概念
</div>
<div>
<span :style="{ color: levelColors[3], fontWeight: 'bold' }">
L3
</span>
— 应用:将知识用于新情境
</div>
<div>
<span :style="{ color: levelColors[4], fontWeight: 'bold' }">
L4
</span>
— 分析:拆解结构、找出规律
</div>
<div>
<span :style="{ color: levelColors[5], fontWeight: 'bold' }">
L5
</span>
— 评价:基于标准作出判断
</div>
<div>
<span :style="{ color: levelColors[6], fontWeight: 'bold' }">
L6
</span>
— 创造:整合信息产出新成果
</div>
</div>
</n-tooltip>
</n-flex>
</template>
<n-spin :show="loading">
<n-empty
v-if="!loading && rounds.length === 0"
description="暂无对话记录"
/>
<div
v-else
style="
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
height: 75vh;
"
>
<div
style="
overflow-y: auto;
padding-right: 8px;
border-right: 1px solid #e0e0e0;
display: flex;
flex-direction: column;
gap: 8px;
"
>
<div
v-for="(round, index) in rounds"
:key="index"
style="
display: flex;
gap: 10px;
align-items: flex-start;
cursor: pointer;
"
@click="selectedRound = index"
>
<div
:style="{
flexShrink: 0,
width: '22px',
height: '22px',
borderRadius: '50%',
background: selectedRound === index ? '#2080f0' : '#c2d5fb',
color: '#fff',
fontSize: '12px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginTop: '2px',
transition: 'background 0.2s',
}"
>
{{ index + 1 }}
</div>
<div
:style="{
flex: 1,
padding: '10px 14px',
borderRadius: '8px',
background: selectedRound === index ? '#e8f0fe' : '#f5f5f5',
border:
selectedRound === index
? '1px solid #2080f0'
: '1px solid #e0e0e0',
fontSize: '13px',
lineHeight: '1.6',
transition: 'all 0.2s',
}"
>
<div
style="
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
"
>
<div
class="prompt-markdown markdown-body"
:class="{
'is-collapsed':
isPromptLong(round.question) && !isExpanded(index),
}"
v-html="renderMarkdown(round.question)"
></div>
<div
style="
display: flex;
flex-direction: row;
align-items: center;
gap: 4px;
flex-shrink: 0;
"
>
<span
v-if="round.source"
:style="{
fontSize: '10px',
padding: '1px 5px',
borderRadius: '4px',
background:
round.source === 'conversation' ? '#e8f0fe' : '#f0f0f0',
color:
round.source === 'conversation' ? '#2060c0' : '#888',
fontWeight: 500,
whiteSpace: 'nowrap',
}"
>{{ sourceLabel(round.source) }}</span
>
<span
v-if="round.prompt_level"
:style="{
fontSize: '11px',
fontWeight: 'bold',
color: levelColors[round.prompt_level],
}"
>L{{ round.prompt_level }}</span
>
<n-button
v-if="isPromptLong(round.question)"
text
size="tiny"
type="primary"
@click.stop="toggleExpanded(index)"
>
<Icon
:icon="
isExpanded(index)
? 'lucide:chevron-up'
: 'lucide:chevron-down'
"
:width="12"
/>
{{ isExpanded(index) ? "收起" : "展开" }}
</n-button>
<n-popconfirm
v-if="round.assistantMsgId && canDelete"
:show-icon="false"
@positive-click="deleteRound(index)"
>
<template #trigger>
<n-button
text
size="tiny"
type="error"
style="margin-left: 2px"
@click.stop
>
<Icon icon="lucide:trash-2" :width="12" />
</n-button>
</template>
确定删除这一轮?
</n-popconfirm>
</div>
</div>
</div>
</div>
</div>
<div style="display: flex; flex-direction: column; gap: 8px">
<div style="font-weight: bold; font-size: 13px; color: #555">
第 {{ selectedRound + 1 }} 轮网页
</div>
<iframe
v-if="selectedPageHtml"
:srcdoc="selectedPageHtml"
:key="selectedRound"
sandbox="allow-scripts"
style="
flex: 1;
border: 1px solid #e0e0e0;
border-radius: 6px;
background: #fff;
"
/>
<n-empty v-else description="该轮无网页代码" style="margin: auto" />
</div>
</div>
</n-spin>
</n-modal>
</template>
<script setup lang="ts">
import { computed, ref, watch } from "vue"
import { NPopconfirm, NButton, useMessage } from "naive-ui"
import { Icon } from "../../icons"
import { renderMarkdown } from "../../utils/markdown"
import { sourceLabel } from "../../utils/helper"
import { storageShim } from "../../utils/previewStorage"
import { Prompt, Submission } from "../../api"
import type { PromptRound } from "../../utils/type"
import { user, roleSuper } from "../../store/user"
const message = useMessage()
const props = defineProps<{
show: boolean
submissionId: string
username?: string
}>()
const canDelete = computed(
() =>
roleSuper.value || (!!props.username && props.username === user.username),
)
defineEmits<{ "update:show": [value: boolean] }>()
const loading = ref(false)
const selectedRound = ref(0)
const expandedRounds = ref<Set<number>>(new Set())
type ChainRound = Omit<PromptRound, "source"> & {
source: string | null
assistantMsgId: number | null
}
const rounds = ref<ChainRound[]>([])
async function deleteRound(index: number) {
const round = rounds.value[index]
if (!round.assistantMsgId) return
try {
await Prompt.deleteMessagePair(round.assistantMsgId)
} catch (error: any) {
message.error(error.response?.data?.detail ?? "删除失败,请重试")
return
}
await loadMessages()
if (selectedRound.value >= rounds.value.length) {
selectedRound.value = Math.max(0, rounds.value.length - 1)
}
}
const levelColors: Record<number, string> = {
1: "#aaa",
2: "#6aab9c",
3: "#4a9ade",
4: "#c47ab8",
5: "#e0a040",
6: "#e05c5c",
}
const selectedPageHtml = computed(() => {
const round = rounds.value[selectedRound.value]
if (!round?.html) return null
const style = round.css ? `<style>${round.css}</style>` : ""
const script = round.js ? `<script>${round.js}<\/script>` : ""
return `<!DOCTYPE html><html><head><meta charset="utf-8">${storageShim()}${style}</head><body>${round.html}${script}</body></html>`
})
function isPromptLong(text: string): boolean {
return text.length > 220 || text.split(/\r?\n/).length > 4
}
function isExpanded(index: number): boolean {
return expandedRounds.value.has(index)
}
function toggleExpanded(index: number) {
const next = new Set(expandedRounds.value)
if (next.has(index)) {
next.delete(index)
} else {
next.add(index)
}
expandedRounds.value = next
}
async function loadMessages() {
if (!props.submissionId) return
loading.value = true
rounds.value = []
selectedRound.value = 0
expandedRounds.value = new Set()
try {
const data = await Submission.getPromptChain(props.submissionId)
rounds.value = data.map((round) => ({
...round,
source: round.source ?? null,
assistantMsgId: round.assistant_msg_id ?? null,
}))
const last = rounds.value.length - 1
if (last >= 0) selectedRound.value = last
} catch {
rounds.value = []
} finally {
loading.value = false
}
}
watch(
() => [props.show, props.submissionId] as const,
([visible]) => {
if (visible) loadMessages()
},
)
</script>
<style scoped>
.prompt-markdown {
flex: 1;
min-width: 0;
background: transparent;
color: inherit;
font-size: 13px;
line-height: 1.6;
}
.prompt-markdown.is-collapsed {
position: relative;
max-height: 126px;
overflow: hidden;
}
.prompt-markdown :deep(p),
.prompt-markdown :deep(ul),
.prompt-markdown :deep(ol),
.prompt-markdown :deep(blockquote),
.prompt-markdown :deep(pre) {
margin-top: 0;
margin-bottom: 6px;
}
.prompt-markdown :deep(:last-child) {
margin-bottom: 0;
}
.prompt-markdown :deep(pre) {
padding: 8px;
overflow-x: auto;
font-size: 12px;
}
.prompt-markdown :deep(code) {
white-space: pre-wrap;
word-break: break-word;
}
</style>