chore: 将前端移动到 client/ 目录,准备合并为单仓库
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1f6935e31e
commit
d1c6b8c201
79 files changed
No files matched your search
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div class="external-panel">
|
||||
<div class="content-area">
|
||||
<div class="field-label">提示词</div>
|
||||
<n-input
|
||||
v-model:value="promptText"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 3, maxRows: 8 }"
|
||||
placeholder="粘贴你发给外部 AI 的提示词..."
|
||||
/>
|
||||
<div class="code-field">
|
||||
<div class="field-label" style="margin-top: 12px">完整的代码</div>
|
||||
<n-input
|
||||
v-model:value="rawCode"
|
||||
type="textarea"
|
||||
class="code-input"
|
||||
placeholder="粘贴完整的前端代码..."
|
||||
/>
|
||||
</div>
|
||||
<div v-if="splitResult" class="split-result">
|
||||
<n-tag size="small" type="success"
|
||||
>HTML · {{ splitResult.html.length }} 字符</n-tag
|
||||
>
|
||||
<n-tag size="small" type="info"
|
||||
>CSS · {{ splitResult.css.length }} 字符</n-tag
|
||||
>
|
||||
<n-tag size="small" type="warning"
|
||||
>JS · {{ splitResult.js.length }} 字符</n-tag
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="action-bar">
|
||||
<n-button :disabled="!rawCode.trim()" @click="applyPreview"
|
||||
>应用预览</n-button
|
||||
>
|
||||
<n-button
|
||||
type="primary"
|
||||
:disabled="!splitResult || !promptText.trim()"
|
||||
:loading="submitting"
|
||||
@click="submit"
|
||||
>
|
||||
提交
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from "vue"
|
||||
import { useMessage } from "naive-ui"
|
||||
import { html, css, js } from "../../store/editors"
|
||||
import { Submission } from "../../api"
|
||||
|
||||
const props = defineProps<{ taskId: number }>()
|
||||
const emit = defineEmits<{
|
||||
submitted: []
|
||||
}>()
|
||||
const message = useMessage()
|
||||
|
||||
const promptText = ref("")
|
||||
const rawCode = ref("")
|
||||
const splitResult = ref<{ html: string; css: string; js: string } | null>(null)
|
||||
const submitting = ref(false)
|
||||
|
||||
watch(rawCode, () => {
|
||||
splitResult.value = null
|
||||
})
|
||||
|
||||
function splitHtml(raw: string): { html: string; css: string; js: string } {
|
||||
let result = raw
|
||||
const cssBlocks: string[] = []
|
||||
const jsBlocks: string[] = []
|
||||
|
||||
result = result.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (_, content) => {
|
||||
cssBlocks.push(content.trim())
|
||||
return ""
|
||||
})
|
||||
result = result.replace(
|
||||
/<script(?![^>]*\bsrc\b)[^>]*>([\s\S]*?)<\/script>/gi,
|
||||
(_, content) => {
|
||||
jsBlocks.push(content.trim())
|
||||
return ""
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
html: result.trim(),
|
||||
css: cssBlocks.join("\n\n"),
|
||||
js: jsBlocks.join("\n\n"),
|
||||
}
|
||||
}
|
||||
|
||||
function applyPreview() {
|
||||
const result = splitHtml(rawCode.value)
|
||||
splitResult.value = result
|
||||
html.value = result.html
|
||||
css.value = result.css
|
||||
js.value = result.js
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!splitResult.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await Submission.create(props.taskId, {
|
||||
html: splitResult.value.html,
|
||||
css: splitResult.value.css,
|
||||
js: splitResult.value.js,
|
||||
prompt: promptText.value.trim(),
|
||||
})
|
||||
emit("submitted")
|
||||
message.success("提交成功")
|
||||
promptText.value = ""
|
||||
rawCode.value = ""
|
||||
splitResult.value = null
|
||||
} catch {
|
||||
message.error("提交失败,请重试")
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.external-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.code-field {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.code-input {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.code-input :deep(.n-input__textarea) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.code-input :deep(.n-input__textarea-el) {
|
||||
height: 100%;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: #666;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.split-result {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
padding: 12px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<n-modal
|
||||
:show="isOpen"
|
||||
preset="card"
|
||||
title="调教提示词"
|
||||
style="width: min(560px, 92vw)"
|
||||
:mask-closable="!streaming"
|
||||
@update:show="onModalClose"
|
||||
>
|
||||
<div class="guidance-body">
|
||||
<div class="guidance-messages" ref="messagesRef">
|
||||
<div
|
||||
v-for="(msg, i) in messages"
|
||||
:key="msg.id ?? i"
|
||||
class="guidance-msg"
|
||||
:class="msg.role"
|
||||
>
|
||||
<div class="msg-role">{{ msg.role === "user" ? "你" : "AI" }}</div>
|
||||
<div class="msg-content" v-html="renderMarkdown(msg.content)"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="streaming" class="guidance-msg assistant">
|
||||
<div class="msg-role">AI</div>
|
||||
<div v-if="!displayStreamingContent" class="typing-indicator">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="msg-content"
|
||||
v-html="renderMarkdown(displayStreamingContent)"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isReady" class="ready-hint">
|
||||
<Icon icon="lucide:check-circle" :width="16" />
|
||||
提示词已优化,可以生成代码了!
|
||||
</div>
|
||||
|
||||
<div class="guidance-input">
|
||||
<n-input
|
||||
v-model:value="draftPrompt"
|
||||
type="textarea"
|
||||
:autosize="{ minRows: 2, maxRows: 5 }"
|
||||
placeholder="修改你的提示词后发送..."
|
||||
:disabled="streaming"
|
||||
@keydown.enter.exact.prevent="send"
|
||||
/>
|
||||
<n-flex justify="space-between" align="center" style="margin-top: 8px">
|
||||
<div>
|
||||
<n-button
|
||||
v-if="streaming"
|
||||
type="error"
|
||||
size="small"
|
||||
@click="stopGuidance"
|
||||
>
|
||||
停止
|
||||
</n-button>
|
||||
<n-button
|
||||
v-else
|
||||
size="small"
|
||||
:disabled="!draftPrompt.trim() || !connected"
|
||||
@click="send"
|
||||
>
|
||||
发送
|
||||
</n-button>
|
||||
</div>
|
||||
<n-button
|
||||
:type="isReady ? 'primary' : 'default'"
|
||||
size="small"
|
||||
:disabled="!draftPrompt.trim()"
|
||||
@click="generate"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="lucide:play" :width="14" />
|
||||
</template>
|
||||
生成代码
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</div>
|
||||
</div>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick } from "vue"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { renderMarkdown } from "../../utils/markdown"
|
||||
import {
|
||||
messages,
|
||||
streaming,
|
||||
streamingContent,
|
||||
isReady,
|
||||
isOpen,
|
||||
connected,
|
||||
initialPrompt,
|
||||
sendGuidance,
|
||||
stopGuidance,
|
||||
closeGuidance,
|
||||
} from "../../store/guidance"
|
||||
|
||||
const emit = defineEmits<{
|
||||
generate: [prompt: string]
|
||||
}>()
|
||||
|
||||
const draftPrompt = ref("")
|
||||
const messagesRef = ref<HTMLElement>()
|
||||
|
||||
const displayStreamingContent = computed(() =>
|
||||
streamingContent.value.replace(/^\[READY\]\n?/, "")
|
||||
)
|
||||
|
||||
function send() {
|
||||
const text = draftPrompt.value.trim()
|
||||
if (!text || streaming.value) return
|
||||
sendGuidance(text)
|
||||
}
|
||||
|
||||
function generate() {
|
||||
const text = draftPrompt.value.trim()
|
||||
if (!text) return
|
||||
emit("generate", text)
|
||||
closeGuidance()
|
||||
}
|
||||
|
||||
function onModalClose(show: boolean) {
|
||||
if (!show && !streaming.value) closeGuidance()
|
||||
}
|
||||
|
||||
watch(isOpen, (val) => {
|
||||
if (val) draftPrompt.value = initialPrompt.value
|
||||
})
|
||||
|
||||
watch([() => messages.value.length, streamingContent], () => {
|
||||
nextTick(() => {
|
||||
if (messagesRef.value) {
|
||||
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.guidance-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.guidance-messages {
|
||||
min-height: 160px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.guidance-msg .msg-role {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.guidance-msg.user .msg-role {
|
||||
color: #2080f0;
|
||||
}
|
||||
|
||||
.guidance-msg.assistant .msg-role {
|
||||
color: #18a058;
|
||||
}
|
||||
|
||||
.guidance-msg.assistant .msg-content {
|
||||
background: #f0f7ff;
|
||||
border-left: 3px solid #2080f0;
|
||||
padding: 8px 12px;
|
||||
border-radius: 0 6px 6px 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.guidance-msg.user .msg-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.msg-content :deep(p) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ready-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #18a058;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 6px 10px;
|
||||
background: #f0fff4;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #b8e8cc;
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.typing-indicator span {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: #2080f0;
|
||||
animation: bounce 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.4;
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-5px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,367 @@
|
||||
<template>
|
||||
<div class="history-panel">
|
||||
<n-flex
|
||||
class="history-toolbar"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
:wrap="false"
|
||||
>
|
||||
<n-text depth="3">共 {{ items.length }} 条历史对话</n-text>
|
||||
<n-tooltip>
|
||||
<template #trigger>
|
||||
<n-button
|
||||
quaternary
|
||||
circle
|
||||
size="small"
|
||||
:loading="loading"
|
||||
aria-label="刷新历史对话"
|
||||
@click="load(true)"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="lucide:refresh-cw" />
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
刷新历史对话
|
||||
</n-tooltip>
|
||||
</n-flex>
|
||||
|
||||
<n-spin v-if="loading" class="state" />
|
||||
<n-empty
|
||||
v-else-if="!items.length"
|
||||
class="state"
|
||||
description="暂无历史对话"
|
||||
/>
|
||||
<n-scrollbar v-else class="history-scrollbar">
|
||||
<n-flex vertical :size="12" class="history-list">
|
||||
<n-card
|
||||
v-for="(item, index) in items"
|
||||
:key="item.assistant_message_id"
|
||||
class="history-card"
|
||||
:class="{
|
||||
'is-selected':
|
||||
selectedAssistantMessageId === item.assistant_message_id,
|
||||
}"
|
||||
size="small"
|
||||
:bordered="true"
|
||||
hoverable
|
||||
:embedded="selectedAssistantMessageId === item.assistant_message_id"
|
||||
:content-style="{ padding: 0 }"
|
||||
@click="selectItem(item)"
|
||||
>
|
||||
<n-flex
|
||||
class="history-main"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
:wrap="false"
|
||||
>
|
||||
<n-flex align="center" :wrap="false" :size="6">
|
||||
<n-tag
|
||||
round
|
||||
size="small"
|
||||
:bordered="false"
|
||||
:type="
|
||||
selectedAssistantMessageId === item.assistant_message_id
|
||||
? 'success'
|
||||
: 'default'
|
||||
"
|
||||
>
|
||||
#{{ index + 1 }}
|
||||
</n-tag>
|
||||
<n-tag
|
||||
size="small"
|
||||
:type="item.source === 'manual' ? 'info' : 'success'"
|
||||
>
|
||||
{{ item.source === "manual" ? "手动提交" : "AI 对话" }}
|
||||
</n-tag>
|
||||
</n-flex>
|
||||
<n-flex align="center" :wrap="false" :size="4">
|
||||
<n-tag
|
||||
v-if="selectedAssistantMessageId === item.assistant_message_id"
|
||||
size="small"
|
||||
type="success"
|
||||
:bordered="false"
|
||||
>
|
||||
正在预览
|
||||
</n-tag>
|
||||
<n-text depth="3">
|
||||
{{ parseTime(item.created, "YYYY-MM-DD HH:mm") }}
|
||||
</n-text>
|
||||
<n-tooltip placement="top">
|
||||
<template #trigger>
|
||||
<n-button
|
||||
quaternary
|
||||
circle
|
||||
size="small"
|
||||
:loading="deletingId === item.assistant_message_id"
|
||||
:disabled="deletingId !== null"
|
||||
@click="deleteItem(item, $event)"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="lucide:trash-2" />
|
||||
</template>
|
||||
</n-button>
|
||||
</template>
|
||||
删除这条历史对话
|
||||
</n-tooltip>
|
||||
</n-flex>
|
||||
</n-flex>
|
||||
<div
|
||||
class="prompt-markdown markdown-body"
|
||||
v-html="renderMarkdown(item.prompt)"
|
||||
/>
|
||||
<div class="thumbnail" aria-label="页面缩略图">
|
||||
<iframe
|
||||
v-if="item.hasPage"
|
||||
title="页面缩略图"
|
||||
loading="lazy"
|
||||
sandbox="allow-scripts"
|
||||
referrerpolicy="no-referrer"
|
||||
tabindex="-1"
|
||||
:srcdoc="item.previewDoc"
|
||||
/>
|
||||
<n-empty v-else size="small" description="未生成页面" />
|
||||
</div>
|
||||
</n-card>
|
||||
</n-flex>
|
||||
</n-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from "vue"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import { useMessage } from "naive-ui"
|
||||
import { renderMarkdown } from "../../utils/markdown"
|
||||
import { Prompt } from "../../api"
|
||||
import type { PromptHistoryItem } from "../../utils/type"
|
||||
import { parseTime } from "../../utils/helper"
|
||||
import { buildPreviewDocument } from "../../utils/previewDocument"
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: number
|
||||
active: boolean
|
||||
assetBaseUrl?: string
|
||||
refreshKey?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [code: { html: string; css: string; js: string }]
|
||||
deleted: [assistantMessageId: number]
|
||||
}>()
|
||||
|
||||
type HistoryViewItem = PromptHistoryItem & {
|
||||
hasPage: boolean
|
||||
previewDoc: string
|
||||
}
|
||||
|
||||
const items = ref<HistoryViewItem[]>([])
|
||||
const loading = ref(false)
|
||||
const selectedAssistantMessageId = ref<number | null>(null)
|
||||
const deletingId = ref<number | null>(null)
|
||||
let loadedTaskId = 0
|
||||
let pendingRefresh = false
|
||||
|
||||
const naiveMessage = useMessage()
|
||||
|
||||
function toViewItem(item: PromptHistoryItem): HistoryViewItem {
|
||||
const html = item.code_html ?? ""
|
||||
const css = item.code_css ?? ""
|
||||
const js = item.code_js ?? ""
|
||||
return {
|
||||
...item,
|
||||
hasPage: !!(html.trim() || css.trim() || js.trim()),
|
||||
previewDoc: buildPreviewDocument({
|
||||
html,
|
||||
css,
|
||||
js,
|
||||
assetBaseUrl: props.assetBaseUrl,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteItem(item: HistoryViewItem, e: Event) {
|
||||
e.stopPropagation()
|
||||
if (deletingId.value !== null) return
|
||||
deletingId.value = item.assistant_message_id
|
||||
try {
|
||||
await Prompt.deleteMessagePair(item.assistant_message_id)
|
||||
items.value = items.value.filter(
|
||||
(i) => i.assistant_message_id !== item.assistant_message_id,
|
||||
)
|
||||
if (selectedAssistantMessageId.value === item.assistant_message_id) {
|
||||
selectedAssistantMessageId.value = null
|
||||
}
|
||||
emit("deleted", item.assistant_message_id)
|
||||
naiveMessage.success("已删除")
|
||||
} catch (error: any) {
|
||||
naiveMessage.error(error.response?.data?.detail ?? "删除失败,请重试")
|
||||
} finally {
|
||||
deletingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function selectItem(item: HistoryViewItem) {
|
||||
selectedAssistantMessageId.value = item.assistant_message_id
|
||||
emit("select", {
|
||||
html: item.code_html ?? "",
|
||||
css: item.code_css ?? "",
|
||||
js: item.code_js ?? "",
|
||||
})
|
||||
}
|
||||
|
||||
async function load(force = true) {
|
||||
if (!props.taskId || loading.value) return
|
||||
if (!force && loadedTaskId === props.taskId) return
|
||||
loading.value = true
|
||||
try {
|
||||
const taskChanged = loadedTaskId !== props.taskId
|
||||
const data = await Prompt.listHistory(props.taskId)
|
||||
items.value = data.map(toViewItem)
|
||||
if (
|
||||
taskChanged ||
|
||||
!items.value.some(
|
||||
(item) =>
|
||||
item.assistant_message_id === selectedAssistantMessageId.value,
|
||||
)
|
||||
) {
|
||||
selectedAssistantMessageId.value = null
|
||||
}
|
||||
loadedTaskId = props.taskId
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.active, props.taskId] as const,
|
||||
([active]) => {
|
||||
if (active) {
|
||||
if (pendingRefresh) {
|
||||
pendingRefresh = false
|
||||
load(true)
|
||||
} else {
|
||||
load(false)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.refreshKey,
|
||||
() => {
|
||||
if (props.active) load(true)
|
||||
else pendingRefresh = true
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (props.active) load(false)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.history-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.history-toolbar {
|
||||
height: 42px;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--n-border-color, #efeff5);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.state {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.history-scrollbar {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.history-list {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.history-card {
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
.history-card.is-selected {
|
||||
--n-color: #f7fffa;
|
||||
box-shadow: 0 10px 24px rgba(24, 160, 88, 0.14);
|
||||
}
|
||||
|
||||
.history-card.is-selected .history-main {
|
||||
background: #eefaf3;
|
||||
}
|
||||
|
||||
.history-main {
|
||||
padding: 10px 12px;
|
||||
transition: background-color 0.18s ease;
|
||||
}
|
||||
|
||||
.prompt-markdown {
|
||||
margin: 0;
|
||||
padding: 0 12px 12px;
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
height: 160px;
|
||||
border-top: 1px solid var(--n-border-color, #efeff5);
|
||||
background: #f7f7f9;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.history-card.is-selected .thumbnail {
|
||||
border-top-color: #d8f1e2;
|
||||
background: #f4fbf6;
|
||||
}
|
||||
|
||||
.thumbnail iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,402 @@
|
||||
<template>
|
||||
<div class="prompt-panel">
|
||||
<div class="messages" ref="messagesRef">
|
||||
<div
|
||||
v-for="(pair, pi) in pairs"
|
||||
:key="pair.assistantMsg?.id ?? 'user-' + pair.index"
|
||||
class="message-pair"
|
||||
:class="{ 'has-delete': pair.assistantMsg?.id && !streaming }"
|
||||
>
|
||||
<!-- Delete button: only shown on hover when pair has assistant id and not streaming -->
|
||||
<button
|
||||
v-if="pair.assistantMsg?.id && !streaming"
|
||||
class="pair-delete-btn"
|
||||
@click="deletePair(pair.assistantMsg!.id!)"
|
||||
title="删除这轮对话及关联提交"
|
||||
>
|
||||
<Icon icon="lucide:trash-2" :width="14" />
|
||||
</button>
|
||||
|
||||
<!-- User message -->
|
||||
<div class="message user">
|
||||
<div class="message-role">我</div>
|
||||
<div
|
||||
class="message-content"
|
||||
v-html="renderContent(pair.userMsg)"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Assistant message -->
|
||||
<div v-if="pair.assistantMsg" class="message assistant">
|
||||
<div class="message-role">AI</div>
|
||||
<div
|
||||
class="message-content"
|
||||
v-html="renderContent(pair.assistantMsg)"
|
||||
></div>
|
||||
<div
|
||||
v-if="allowSubmit && hasCode(pair.assistantMsg)"
|
||||
class="message-actions"
|
||||
>
|
||||
<n-button
|
||||
size="tiny"
|
||||
:disabled="pair.assistantMsg.submitted"
|
||||
:loading="submittingId === pair.assistantMsg.id"
|
||||
@click="submitVersion(pair.assistantMsg)"
|
||||
>
|
||||
{{ pair.assistantMsg.submitted ? "已提交" : "提交此版本" }}
|
||||
</n-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Streaming indicator -->
|
||||
<div v-if="streaming" class="message assistant">
|
||||
<div class="message-role">AI</div>
|
||||
<div v-if="!streamingContent" class="typing-indicator">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="message-content"
|
||||
v-html="renderMarkdown(streamingContent)"
|
||||
></div>
|
||||
<div class="streaming-hint">AI 正在生成中…</div>
|
||||
</div>
|
||||
</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-button
|
||||
secondary
|
||||
:disabled="!input.trim() || !connected || !currentTaskId || streaming"
|
||||
style="flex-shrink: 0"
|
||||
title="调教提示词"
|
||||
aria-label="调教提示词"
|
||||
@click="startGuidance"
|
||||
>
|
||||
<template #icon>
|
||||
<Icon icon="lucide:lightbulb" :width="16" />
|
||||
</template>
|
||||
</n-button>
|
||||
</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"
|
||||
>
|
||||
发送
|
||||
</n-button>
|
||||
</n-flex>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, computed } from "vue"
|
||||
import { useStorage } from "@vueuse/core"
|
||||
import { useMessage } from "naive-ui"
|
||||
import { Icon } from "@iconify/vue"
|
||||
import GuidancePanel from "./GuidancePanel.vue"
|
||||
import { openGuidance } from "../../store/guidance"
|
||||
import {
|
||||
messages,
|
||||
streaming,
|
||||
streamingContent,
|
||||
connected,
|
||||
sendPrompt,
|
||||
stopPrompt,
|
||||
currentTaskId,
|
||||
removeMessagePair,
|
||||
markMessageSubmitted,
|
||||
} from "../../store/prompt"
|
||||
import { Prompt, Submission } from "../../api"
|
||||
import { renderMarkdown } from "../../utils/markdown"
|
||||
|
||||
const { allowSubmit = true } = defineProps<{ allowSubmit?: boolean }>()
|
||||
|
||||
const emit = defineEmits<{ deleted: []; submitted: [] }>()
|
||||
|
||||
const input = ref("")
|
||||
const messagesRef = ref<HTMLElement>()
|
||||
const naiveMessage = useMessage()
|
||||
|
||||
const modelOptions = [
|
||||
{ label: "豆包", value: "doubao-seed-2-0-lite-260215" },
|
||||
{ label: "DeepSeek", value: "deepseek-v4-flash" },
|
||||
{ label: "DeepSeek(思考)", value: "deepseek-v4-flash-thinking" },
|
||||
]
|
||||
const selectedModel = useStorage("prompt-model", "deepseek-v4-flash")
|
||||
|
||||
// Group messages into user+assistant pairs
|
||||
const submittingId = ref<number | null>(null)
|
||||
|
||||
const pairs = computed(() => {
|
||||
const result: Array<{
|
||||
userMsg: { role: string; content: string; id?: number }
|
||||
assistantMsg: {
|
||||
role: string
|
||||
content: string
|
||||
id?: number
|
||||
code?: any
|
||||
submitted?: boolean
|
||||
} | null
|
||||
index: number
|
||||
}> = []
|
||||
const msgs = messages.value
|
||||
let i = 0
|
||||
while (i < msgs.length) {
|
||||
if (msgs[i].role === "user") {
|
||||
const assistantMsg =
|
||||
msgs[i + 1]?.role === "assistant" ? msgs[i + 1] : null
|
||||
result.push({ userMsg: msgs[i], assistantMsg, index: i })
|
||||
i += assistantMsg ? 2 : 1
|
||||
} else {
|
||||
i++
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
function hasCode(msg: { code?: any } | null): boolean {
|
||||
if (!msg?.code) return false
|
||||
return !!(msg.code.html || msg.code.css || msg.code.js)
|
||||
}
|
||||
|
||||
async function submitVersion(msg: {
|
||||
id?: number
|
||||
code?: { html: string | null; css: string | null; js: string | null }
|
||||
}) {
|
||||
if (!msg.id || !msg.code || !currentTaskId.value) return
|
||||
submittingId.value = msg.id
|
||||
try {
|
||||
await Submission.create(
|
||||
currentTaskId.value,
|
||||
{
|
||||
html: msg.code.html ?? "",
|
||||
css: msg.code.css ?? "",
|
||||
js: msg.code.js ?? "",
|
||||
},
|
||||
msg.id,
|
||||
)
|
||||
markMessageSubmitted(msg.id)
|
||||
naiveMessage.success("提交成功")
|
||||
emit("submitted")
|
||||
} catch {
|
||||
naiveMessage.error("提交失败,请重试")
|
||||
} finally {
|
||||
submittingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePair(assistantMsgId: number) {
|
||||
try {
|
||||
await Prompt.deleteMessagePair(assistantMsgId)
|
||||
removeMessagePair(assistantMsgId)
|
||||
naiveMessage.success("已删除")
|
||||
emit("deleted")
|
||||
} catch (error: any) {
|
||||
naiveMessage.error(error.response?.data?.detail ?? "删除失败,请重试")
|
||||
}
|
||||
}
|
||||
|
||||
function send() {
|
||||
const text = input.value.trim()
|
||||
if (!text || streaming.value) return
|
||||
sendPrompt(text, selectedModel.value)
|
||||
input.value = ""
|
||||
}
|
||||
|
||||
function startGuidance() {
|
||||
const text = input.value.trim()
|
||||
if (!text || !currentTaskId.value) return
|
||||
openGuidance(currentTaskId.value, text)
|
||||
}
|
||||
|
||||
function onGuidanceGenerate(finalPrompt: string) {
|
||||
sendPrompt(finalPrompt, selectedModel.value)
|
||||
input.value = ""
|
||||
}
|
||||
|
||||
function renderContent(msg: { role: string; content: string }): string {
|
||||
return renderMarkdown(msg.content)
|
||||
}
|
||||
|
||||
watch([() => messages.value.length, streamingContent], () => {
|
||||
nextTick(() => {
|
||||
if (messagesRef.value) {
|
||||
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.prompt-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.message-role {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.message.user .message-role {
|
||||
color: #2080f0;
|
||||
}
|
||||
|
||||
.message.assistant .message-role {
|
||||
color: #18a058;
|
||||
}
|
||||
|
||||
.message-content {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.message-content :deep(pre) {
|
||||
background: #f5f5f5;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.message-actions {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.typing-indicator span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #18a058;
|
||||
animation: bounce 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.4;
|
||||
}
|
||||
30% {
|
||||
transform: translateY(-6px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.streaming-hint {
|
||||
font-size: 11px;
|
||||
color: #aaa;
|
||||
margin-top: 4px;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.input-area {
|
||||
flex-shrink: 0;
|
||||
padding: 12px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
.message-pair {
|
||||
position: relative;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.message-pair .message {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pair-delete-btn {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
background: none;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
padding: 3px 6px;
|
||||
cursor: pointer;
|
||||
color: #bbb;
|
||||
line-height: 1;
|
||||
transition:
|
||||
color 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
|
||||
.pair-delete-btn:hover {
|
||||
color: #e03e3e;
|
||||
border-color: #e03e3e;
|
||||
}
|
||||
|
||||
.message-pair.has-delete:hover .pair-delete-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<n-modal
|
||||
preset="card"
|
||||
:show="show"
|
||||
title="等待 AI 回复时,给同学的作品打个分吧"
|
||||
style="width: 90vw; max-width: 960px"
|
||||
@update:show="onUpdateShow"
|
||||
>
|
||||
<n-text
|
||||
v-if="current"
|
||||
depth="3"
|
||||
style="font-size: 12px; display: block; margin-bottom: 8px"
|
||||
>
|
||||
{{ current.task_title }} · 提交者 {{ current.username }}
|
||||
</n-text>
|
||||
<div class="preview-wrapper">
|
||||
<iframe class="preview-iframe" :srcdoc="previewContent"></iframe>
|
||||
</div>
|
||||
<div class="rate-row">
|
||||
<n-rate
|
||||
v-if="current"
|
||||
:key="current.submission_id"
|
||||
:size="32"
|
||||
@update:value="onRate"
|
||||
/>
|
||||
</div>
|
||||
<n-text
|
||||
depth="3"
|
||||
style="font-size: 11px; display: block; text-align: center; margin-top: 8px"
|
||||
>
|
||||
打分后自动换下一个作品;AI 回复完成后弹窗会自动关闭
|
||||
</n-text>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onUnmounted } from "vue"
|
||||
import { useMessage } from "naive-ui"
|
||||
import { Submission } from "../../api"
|
||||
import { streaming } from "../../store/prompt"
|
||||
import { buildPreviewDocument } from "../../utils/previewDocument"
|
||||
import type { RandomRatingItem } from "../../utils/type"
|
||||
|
||||
const message = useMessage()
|
||||
|
||||
const show = ref(false)
|
||||
const current = ref<RandomRatingItem | null>(null)
|
||||
const previewContent = ref("")
|
||||
let reshowTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function clearReshowTimer() {
|
||||
if (reshowTimer !== null) {
|
||||
clearTimeout(reshowTimer)
|
||||
reshowTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAndShow() {
|
||||
try {
|
||||
const item = await Submission.randomForRating(current.value?.submission_id)
|
||||
if (item) {
|
||||
current.value = item
|
||||
previewContent.value = buildPreviewDocument({
|
||||
html: item.html ?? "",
|
||||
css: item.css ?? "",
|
||||
js: item.js ?? "",
|
||||
})
|
||||
show.value = true
|
||||
} else {
|
||||
show.value = false
|
||||
}
|
||||
} catch (err: any) {
|
||||
message.error(err.response?.data?.detail ?? "获取作品失败,请重试")
|
||||
}
|
||||
}
|
||||
|
||||
async function onRate(score: number) {
|
||||
if (!current.value) return
|
||||
try {
|
||||
await Submission.updateScore(current.value.submission_id, score)
|
||||
message.success("感谢评分!")
|
||||
await fetchAndShow()
|
||||
} catch (err: any) {
|
||||
message.error(err.response?.data?.detail ?? "打分失败,请重试")
|
||||
}
|
||||
}
|
||||
|
||||
function onUpdateShow(value: boolean) {
|
||||
show.value = value
|
||||
if (!value) {
|
||||
clearReshowTimer()
|
||||
if (streaming.value) {
|
||||
reshowTimer = setTimeout(fetchAndShow, 30_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
streaming,
|
||||
(val) => {
|
||||
clearReshowTimer()
|
||||
if (val) {
|
||||
fetchAndShow()
|
||||
} else {
|
||||
show.value = false
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onUnmounted(() => {
|
||||
clearReshowTimer()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.preview-wrapper {
|
||||
height: 70vh;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.rate-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px 0;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user