feat: 重新设计提示词链弹框,批改面板也能逐轮看网页

- 左边每轮提问:来源(AI 对话 / 手改 / 外部 AI)、等级写成“L3 应用”,标出交的这一版和没有网页的轮次;
  右边是这一轮生成的网页,↑ ↓ 切换
- 预览改用 buildPreviewDocument:题目素材能加载,暗色下也保持白底
- 批改面板加“逐轮看网页”;弹框开着时接管按键,批改页的 Esc / J / K 和面板的 1–5 / P 不会被触发
- 查看器里不再能删除对话轮次(学生在做题页的对话和历史里删)
- 预留作品展的接口参数(fromShowcase)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-10-27 11:54:44 +08:00
1 parent 6ad9be2b30
commit 3fb23dc3f3
4 files changed
+348 -318

No files matched your search

+24 -2
View File
@@ -120,13 +120,18 @@
</section> </section>
<section> <section>
<n-flex justify="space-between" align="center" class="chain-head">
<h4>提示词 <span class="muted" v-if="chain.length">· {{ chain.length }} 轮</span></h4> <h4>提示词 <span class="muted" v-if="chain.length">· {{ chain.length }} 轮</span></h4>
<n-button v-if="chain.length" size="tiny" quaternary @click="showChain = true">
逐轮看网页
</n-button>
</n-flex>
<div v-if="!chain.length" class="muted">这份提交没有关联的提示词</div> <div v-if="!chain.length" class="muted">这份提交没有关联的提示词</div>
<ol class="chain"> <ol class="chain">
<li v-for="(r, i) in chain" :key="i"> <li v-for="(r, i) in chain" :key="i">
<n-flex :size="6" align="center"> <n-flex :size="6" align="center">
<span class="src">{{ sourceLabel(r.source) }}</span> <span class="src">{{ sourceLabel(r.source) }}</span>
<span v-if="r.prompt_level" class="level">L{{ r.prompt_level }}</span> <span v-if="r.prompt_level" class="level">{{ levelLabel(r.prompt_level) }}</span>
</n-flex> </n-flex>
<div class="q">{{ r.question }}</div> <div class="q">{{ r.question }}</div>
</li> </li>
@@ -135,6 +140,13 @@
</div> </div>
</div> </div>
</n-spin> </n-spin>
<ChainModal
v-if="sub"
v-model:show="showChain"
:submission-id="sub.id"
:title="`${sub.username} · ${sub.task_title}`"
:asset-base-url="`/media/tasks/${sub.task_type}/${sub.task_display}/`"
/>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@@ -143,7 +155,8 @@ import { useMessage } from "naive-ui"
import { Icon } from "../../icons" import { Icon } from "../../icons"
import { Showcase, Submission } from "../../api" import { Showcase, Submission } from "../../api"
import { buildPreviewDocument, PREVIEW_SANDBOX } from "../../utils/previewDocument" import { buildPreviewDocument, PREVIEW_SANDBOX } from "../../utils/previewDocument"
import { parseTime, sourceLabel } from "../../utils/helper" import { levelLabel, parseTime, sourceLabel } from "../../utils/helper"
import ChainModal from "../submissions/ChainModal.vue"
import type { import type {
AwardManageOut, AwardManageOut,
FlagType, FlagType,
@@ -181,6 +194,7 @@ const sub = ref<SubmissionAll | null>(null)
const versions = ref<SubmissionOut[]>([]) const versions = ref<SubmissionOut[]>([])
const chain = ref<PromptRound[]>([]) const chain = ref<PromptRound[]>([])
const rating = ref(false) const rating = ref(false)
const showChain = ref(false)
let awardsCache: Promise<AwardManageOut[]> | null = null let awardsCache: Promise<AwardManageOut[]> | null = null
const awards = ref<AwardManageOut[]>([]) const awards = ref<AwardManageOut[]>([])
@@ -419,6 +433,14 @@ h4 {
font-size: 13px; font-size: 13px;
} }
.chain-head {
margin-bottom: 8px;
}
.chain-head h4 {
margin: 0;
}
.delete { .delete {
margin-top: 8px; margin-top: 8px;
} }
+308 -315
View File
@@ -2,232 +2,81 @@
<n-modal <n-modal
:show="show" :show="show"
preset="card" preset="card"
style="width: 90vw; max-width: 1400px" style="width: 92vw; max-width: 1280px"
@update:show="$emit('update:show', $event)" :bordered="false"
@update:show="emit('update:show', $event)"
> >
<template #header> <template #header>
<n-flex justify="start" align="center"> <div class="head">
<span>提示词</span> <b>提示词</b>
<n-tooltip placement="bottom-start"> <span v-if="rounds.length" class="muted">{{ rounds.length }} 轮</span>
<span v-if="title" class="muted title">{{ title }}</span>
</div>
</template>
<template #header-extra>
<n-popover trigger="click" placement="bottom-end">
<template #trigger> <template #trigger>
<Icon icon="lucide:info" :width="20" style="color: var(--wp-faint)" /> <n-button size="small" quaternary style="margin-right: 8px">
<template #icon><Icon icon="lucide:info" /></template>
L1–L6 是什么
</n-button>
</template> </template>
<div style="line-height: 2"> <div class="levels">
<div> <div class="levels-note">按布鲁姆认知层级给每次提问打的标签,越高越需要思考:</div>
<span :style="{ color: levelColors[1], fontWeight: 'bold' }"> <div v-for="(l, n) in PROMPT_LEVELS" :key="n">
L1 <b>L{{ n }} {{ l.name }}</b>:{{ l.desc }}
</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>
</div> </div>
</n-tooltip> </n-popover>
</n-flex>
</template> </template>
<n-spin :show="loading"> <n-spin :show="loading">
<n-empty <n-empty
v-if="!loading && rounds.length === 0" v-if="!loading && !rounds.length"
description="暂无对话记录" description="这份提交没有关联的提示词"
style="padding: 80px 0"
/> />
<div <div v-else class="body">
v-else <ol class="rounds">
style=" <li
display: grid; v-for="(r, i) in rounds"
grid-template-columns: 1fr 1fr; :key="i"
gap: 16px; :class="['round', { on: i === selected }]"
height: 75vh; @click="selected = i"
"
> >
<div <div class="round-head">
style=" <span class="num">{{ i + 1 }}</span>
overflow-y: auto; <span :class="['src', `src-${r.source || 'conversation'}`]">
padding-right: 8px; {{ sourceLabel(r.source) }}
border-right: 1px solid var(--wp-line); </span>
display: flex; <span v-if="r.prompt_level" class="level">{{ levelLabel(r.prompt_level) }}</span>
flex-direction: column; <span v-if="i === rounds.length - 1" class="final">交的这一版</span>
gap: 8px; <span v-else-if="!r.html" class="muted no-code">没有网页</span>
"
>
<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 ? 'var(--wp-css)' : 'var(--wp-css-soft)',
color: 'var(--wp-surface)',
fontSize: '12px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginTop: '2px',
transition: 'background 0.2s',
}"
>
{{ index + 1 }}
</div> </div>
<div <div
:style="{ :class="['q', 'markdown-body', { clamp: isLong(r.question) && !expanded.has(i) }]"
flex: 1, v-html="renderMarkdown(r.question)"
padding: '10px 14px',
borderRadius: '8px',
background: selectedRound === index ? 'var(--wp-css-soft)' : 'var(--wp-paper)',
border:
selectedRound === index
? '1px solid var(--wp-css)'
: '1px solid var(--wp-line)',
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>
<div <button v-if="isLong(r.question)" class="more" @click.stop="toggle(i)">
style=" {{ expanded.has(i) ? "收起" : "展开全部" }}
display: flex; </button>
flex-direction: row; </li>
align-items: center; </ol>
gap: 4px;
flex-shrink: 0; <div class="stage">
" <div class="stage-head">
> <b>第 {{ selected + 1 }} 轮生成的网页</b>
<span <span class="muted">↑ ↓ 切换</span>
v-if="round.source"
:style="{
fontSize: '10px',
padding: '1px 5px',
borderRadius: '4px',
background:
round.source === 'conversation' ? 'var(--wp-css-soft)' : 'var(--wp-line)',
color:
round.source === 'conversation' ? 'var(--wp-css)' : 'var(--wp-muted)',
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: var(--wp-ink)">
第 {{ selectedRound + 1 }} 轮网页
</div> </div>
<div class="frame">
<iframe <iframe
v-if="selectedPageHtml" v-if="doc"
:srcdoc="selectedPageHtml" :key="selected"
:key="selectedRound" :sandbox="PREVIEW_SANDBOX"
sandbox="allow-scripts" :srcdoc="doc"
style=" ></iframe>
flex: 1; <n-empty v-else description="这一轮没有生成网页" style="margin: auto" />
border: 1px solid var(--wp-line); </div>
border-radius: 6px;
background: var(--wp-surface);
"
/>
<n-empty v-else description="该轮无网页代码" style="margin: auto" />
</div> </div>
</div> </div>
</n-spin> </n-spin>
@@ -235,157 +84,301 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from "vue" import { computed, nextTick, onUnmounted, ref, watch } from "vue"
import { NPopconfirm, NButton, useMessage } from "naive-ui"
import { Icon } from "../../icons" import { Icon } from "../../icons"
import { Showcase, Submission } from "../../api"
import { renderMarkdown } from "../../utils/markdown" import { renderMarkdown } from "../../utils/markdown"
import { sourceLabel } from "../../utils/helper" import { levelLabel, PROMPT_LEVELS, sourceLabel } from "../../utils/helper"
import { storageShim } from "../../utils/previewStorage" import { buildPreviewDocument, PREVIEW_SANDBOX } from "../../utils/previewDocument"
import { Prompt, Submission } from "../../api"
import type { PromptRound } from "../../utils/type" import type { PromptRound } from "../../utils/type"
import { user, roleSuper } from "../../store/user"
const message = useMessage()
// 一份提交是怎么一轮一轮问出来的:左边每轮的提问,右边这一轮生成的网页
const props = defineProps<{ const props = defineProps<{
show: boolean show: boolean
submissionId: string submissionId: string
username?: string // 例如“张三 · 个人名片”
title?: string
// 题目素材的地址,网页里的图片要用
assetBaseUrl?: string
// 作品展里的作品走作品展的接口(不用是老师或作者本人)
fromShowcase?: boolean
}>() }>()
const emit = defineEmits<{ "update:show": [value: boolean] }>()
const canDelete = computed(
() =>
roleSuper.value || (!!props.username && props.username === user.username),
)
defineEmits<{ "update:show": [value: boolean] }>()
const loading = ref(false) const loading = ref(false)
const selectedRound = ref(0) const rounds = ref<PromptRound[]>([])
const expandedRounds = ref<Set<number>>(new Set()) const selected = ref(0)
type ChainRound = Omit<PromptRound, "source"> & { const expanded = ref(new Set<number>())
source: string | null
assistantMsgId: number | null
}
const rounds = ref<ChainRound[]>([])
async function deleteRound(index: number) { const doc = computed(() => {
const round = rounds.value[index] const r = rounds.value[selected.value]
if (!round.assistantMsgId) return if (!r?.html) return ""
try { return buildPreviewDocument({
await Prompt.deleteMessagePair(round.assistantMsgId) html: r.html,
} catch (error: any) { css: r.css ?? "",
message.error(error.response?.data?.detail ?? "删除失败,请重试") js: r.js ?? "",
return assetBaseUrl: props.assetBaseUrl,
} })
await loadMessages()
if (selectedRound.value >= rounds.value.length) {
selectedRound.value = Math.max(0, rounds.value.length - 1)
}
}
const levelColors: Record<number, string> = {
1: "var(--wp-faint)",
2: "var(--wp-brand)",
3: "var(--wp-css)",
4: "#c47ab8",
5: "var(--wp-warn)",
6: "var(--wp-bad)",
}
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 { function isLong(text: string) {
return text.length > 220 || text.split(/\r?\n/).length > 4 return text.length > 200 || text.split(/\r?\n/).length > 4
} }
function isExpanded(index: number): boolean { function toggle(i: number) {
return expandedRounds.value.has(index) const next = new Set(expanded.value)
if (!next.delete(i)) next.add(i)
expanded.value = next
} }
function toggleExpanded(index: number) { let seq = 0
const next = new Set(expandedRounds.value) async function load() {
if (next.has(index)) { const s = ++seq
next.delete(index)
} else {
next.add(index)
}
expandedRounds.value = next
}
async function loadMessages() {
if (!props.submissionId) return
loading.value = true loading.value = true
rounds.value = [] rounds.value = []
selectedRound.value = 0 expanded.value = new Set()
expandedRounds.value = new Set()
try { try {
const data = await Submission.getPromptChain(props.submissionId) const data = props.fromShowcase
rounds.value = data.map((round) => ({ ? await Showcase.getPromptChain(props.submissionId)
...round, : await Submission.getPromptChain(props.submissionId)
source: round.source ?? null, if (s !== seq) return
assistantMsgId: round.assistant_msg_id ?? null, rounds.value = data
})) // 默认看交的那一版
const last = rounds.value.length - 1 selected.value = Math.max(0, data.length - 1)
if (last >= 0) selectedRound.value = last await nextTick()
document.querySelector(".rounds .round.on")?.scrollIntoView({ block: "nearest" })
} catch { } catch {
rounds.value = [] if (s === seq) rounds.value = []
} finally { } finally {
loading.value = false if (s === seq) loading.value = false
} }
} }
// 在捕获阶段接管按键:弹框开着时,批改页的 Esc / J / K、批改面板的 1–5 / P 都不能触发
function onKey(e: KeyboardEvent) {
if (!props.show) return
e.stopPropagation()
if (e.key === "Escape") {
emit("update:show", false)
return
}
const step = e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0
if (!step || !rounds.value.length) return
e.preventDefault()
selected.value = Math.min(rounds.value.length - 1, Math.max(0, selected.value + step))
nextTick(() =>
document.querySelector(".rounds .round.on")?.scrollIntoView({ block: "nearest" }),
)
}
watch( watch(
() => [props.show, props.submissionId] as const, () => [props.show, props.submissionId] as const,
([visible]) => { ([visible]) => {
if (visible) loadMessages() if (visible) {
load()
window.addEventListener("keydown", onKey, true)
} else {
window.removeEventListener("keydown", onKey, true)
}
}, },
{ immediate: true },
) )
onUnmounted(() => window.removeEventListener("keydown", onKey, true))
</script> </script>
<style scoped> <style scoped>
.prompt-markdown { .head {
flex: 1; display: flex;
align-items: baseline;
gap: 10px;
min-width: 0; min-width: 0;
background: transparent;
color: inherit;
font-size: 13px;
line-height: 1.6;
} }
.prompt-markdown.is-collapsed { .title {
position: relative; white-space: nowrap;
max-height: 126px;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis;
} }
.prompt-markdown :deep(p), .muted {
.prompt-markdown :deep(ul), font-size: 12px;
.prompt-markdown :deep(ol), font-weight: 400;
.prompt-markdown :deep(blockquote), color: var(--wp-muted);
.prompt-markdown :deep(pre) {
margin-top: 0;
margin-bottom: 6px;
} }
.prompt-markdown :deep(:last-child) { .levels {
margin-bottom: 0; font-size: 13px;
line-height: 1.9;
} }
.prompt-markdown :deep(pre) { .levels-note {
padding: 8px; color: var(--wp-muted);
overflow-x: auto; }
.body {
display: grid;
grid-template-columns: minmax(300px, 2fr) 3fr;
gap: 16px;
height: 72vh;
}
.rounds {
margin: 0;
padding: 0 6px 0 0;
list-style: none;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 6px;
}
.round {
padding: 8px 10px;
border: 1px solid var(--wp-line);
border-radius: 8px;
background: var(--wp-surface);
cursor: pointer;
}
.round:hover {
border-color: var(--wp-faint);
}
.round.on {
border-color: var(--wp-brand);
background: var(--wp-brand-soft);
}
.round-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
font-size: 12px; font-size: 12px;
} }
.prompt-markdown :deep(code) { .num {
white-space: pre-wrap; min-width: 18px;
font: 600 12px var(--wp-font-mono);
color: var(--wp-muted);
}
.round.on .num {
color: var(--wp-brand);
}
.src,
.level,
.final {
padding: 0 6px;
border-radius: 4px;
line-height: 18px;
white-space: nowrap;
}
.src {
color: var(--wp-css);
background: var(--wp-css-soft);
}
.src-edit {
color: var(--wp-js);
background: var(--wp-js-soft);
}
.src-manual {
color: var(--wp-html);
background: var(--wp-html-soft);
}
.level {
color: var(--wp-ink);
background: var(--wp-paper);
border: 1px solid var(--wp-line);
}
.final {
margin-left: auto;
color: var(--wp-brand);
font-weight: 600;
}
.no-code {
margin-left: auto;
}
.q {
background: transparent;
color: var(--wp-ink);
font-size: 13px;
line-height: 1.6;
word-break: break-word; word-break: break-word;
} }
.q.clamp {
max-height: 84px;
overflow: hidden;
-webkit-mask-image: linear-gradient(#000 55%, transparent);
mask-image: linear-gradient(#000 55%, transparent);
}
.q :deep(p),
.q :deep(ul),
.q :deep(ol),
.q :deep(pre),
.q :deep(blockquote) {
margin: 0 0 6px;
}
.q :deep(:last-child) {
margin-bottom: 0;
}
.q :deep(pre) {
padding: 6px 8px;
font-size: 12px;
}
.q :deep(code) {
white-space: pre-wrap;
}
.more {
margin-top: 4px;
padding: 0;
border: 0;
background: transparent;
color: var(--wp-brand);
font: inherit;
font-size: 12px;
cursor: pointer;
}
.stage {
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.stage-head {
display: flex;
align-items: baseline;
justify-content: space-between;
font-size: 13px;
}
.frame {
flex: 1;
min-height: 0;
display: flex;
border: 1px solid var(--wp-line);
border-radius: 8px;
overflow: hidden;
}
.frame iframe {
flex: 1;
border: 0;
}
</style> </style>
+2 -1
View File
@@ -102,7 +102,8 @@
v-if="current" v-if="current"
v-model:show="showChain" v-model:show="showChain"
:submission-id="current.id" :submission-id="current.id"
:username="current.username" :title="`${current.task_display}. ${current.task_title}`"
:asset-base-url="`/media/tasks/${current.task_type}/${current.task_display}/`"
/> />
</template> </template>
+14
View File
@@ -23,3 +23,17 @@ export function sourceLabel(source: string | null | undefined) {
if (source === "manual") return "外部 AI" if (source === "manual") return "外部 AI"
return "AI 对话" return "AI 对话"
} }
// 提问的 Bloom 等级(services/classifier.ts 后台打的标签)
export const PROMPT_LEVELS: Record<number, { name: string; desc: string }> = {
1: { name: "记忆", desc: "复述或识别知识点" },
2: { name: "理解", desc: "用自己的话解释概念" },
3: { name: "应用", desc: "把知识用到新情境" },
4: { name: "分析", desc: "拆解结构、找出规律" },
5: { name: "评价", desc: "按标准作出判断" },
6: { name: "创造", desc: "整合信息做出新东西" },
}
export function levelLabel(level: number | null | undefined) {
return level ? `L${level} ${PROMPT_LEVELS[level]?.name ?? ""}`.trim() : ""
}