完善模板测试弹窗与多文件解析链路

This commit is contained in:
zwt13703
2026-07-02 16:35:50 +08:00
parent a8716aa3c6
commit 8743a02110
6 changed files with 795 additions and 112 deletions
+678 -108
View File
@@ -1,97 +1,226 @@
<template>
<div style="display:flex;height:calc(100vh - 112px)">
<div style="width:220px;border-right:1px solid #f0f0f0;overflow-y:auto;padding:12px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h4 style="margin:0">段落列表</h4>
<a-button type="primary" size="small" @click="saveTemplate">保存</a-button>
</div>
<div
v-for="(p, i) in paragraphs"
:key="p.id"
:class="['para-item', { active: selectedId === p.id }]"
@click="selectPara(p.id)"
>
<span class="idx">{{ i + 1 }}</span>
<span class="title">{{ p.title || '未命名' }}</span>
<a-tag :color="p.edit_mode === 'ai' ? 'blue' : 'orange'" style="margin-left:auto">
{{ p.edit_mode === 'ai' ? 'AI' : '手动' }}
</a-tag>
</div>
<div class="editor-page">
<div class="editor-topbar">
<a class="back-link" @click.prevent="router.push('/templates')">
<arrow-left-outlined />
返回
</a>
<span class="divider">|</span>
<span class="editor-title">{{ templateName }}</span>
<span class="flex-spacer" />
<a-button type="primary" @click="saveTemplate">保存模板</a-button>
</div>
<div style="flex:1;overflow-y:auto;padding:24px;background:#f0f1f3;display:flex;justify-content:center;align-items:flex-start">
<div style="width:794px;background:#fff;padding:80px 72px;min-height:500px;box-shadow:0 2px 8px rgba(0,0,0,.08)" v-html="previewHtml" />
<div class="editor-layout">
<aside class="editor-left">
<div class="left-head">
段落列表
<span class="left-head-tip">(点击定位)</span>
</div>
<div class="left-scroll">
<div
v-for="paragraph in paragraphs"
:key="paragraph.id"
:class="['para-list-item', { active: selectedId === paragraph.id }]"
@click="selectPara(paragraph.id)"
>
<span class="pli-index">{{ paragraph.sort_index }}</span>
<span class="pli-title">{{ paragraph.title || '未命名段落' }}</span>
<span :class="['pli-badge', paragraph.edit_mode === 'ai' ? 'ai' : 'manual']">
{{ paragraph.edit_mode === 'ai' ? 'AI 生成' : '人工编辑' }}
</span>
</div>
</div>
</aside>
<section class="editor-center">
<div class="center-toolbar">
<span class="tb-btn active"><b>B</b></span>
<span class="tb-btn"><i>I</i></span>
<span class="tb-btn"><u>U</u></span>
<span class="tb-divider" />
<span class="tb-btn"><font-size-outlined /></span>
<span class="tb-btn"><ordered-list-outlined /></span>
<span class="tb-btn"><table-outlined /></span>
<span class="tb-divider" />
<span class="tb-btn"><align-left-outlined /></span>
<span class="tb-btn"><align-center-outlined /></span>
<span class="tb-btn"><align-right-outlined /></span>
<span class="toolbar-hint">点击左侧段落或文档中的段落块查看配置</span>
</div>
<div class="center-scroll">
<div class="doc-edit-page">
<div v-for="paragraph in paragraphs" :key="paragraph.id" :id="`paraBlock${paragraph.id}`" :class="['para-block', { selected: selectedId === paragraph.id }]" @click="selectPara(paragraph.id)">
<span :class="['para-tag', paragraph.edit_mode === 'ai' ? 'ai' : 'manual']">
{{ paragraph.edit_mode === 'ai' ? `AI 生成${paragraph.output_format === 'table' ? ' · 表格' : ''}` : '人工编辑' }}
</span>
<div class="sec-title" :style="{ marginTop: paragraph.sort_index === 1 ? '0' : '' }">{{ paragraph.title }}</div>
<p v-if="paragraph.content">{{ paragraph.content }}</p>
<p v-else class="empty-text">点击左侧配置此段落</p>
</div>
</div>
</div>
</section>
<aside class="editor-right">
<div class="right-head">
<span>段落配置</span>
<span class="right-head-name">{{ selectedPara?.title || '' }}</span>
</div>
<div class="right-scroll" v-if="selectedPara">
<div class="config-section">
<div class="config-section-title">生成设置</div>
<a-form layout="vertical">
<a-form-item label="编辑方式">
<a-select v-model:value="selectedPara.edit_mode">
<a-select-option value="ai">AI 生成</a-select-option>
<a-select-option value="manual">人工编辑</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="selectedPara.edit_mode === 'ai'" label="生成模型">
<a-select v-model:value="selectedPara.model_id" allowClear placeholder="使用默认模型">
<a-select-option v-for="model in models" :key="model.id" :value="model.id">{{ model.name }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="输出格式">
<a-select v-model:value="selectedPara.output_format">
<a-select-option value="text">正式报告段落</a-select-option>
<a-select-option value="table">表格形式</a-select-option>
<a-select-option value="mixed">混合内容</a-select-option>
<a-select-option value="chart">图表形式</a-select-option>
</a-select>
</a-form-item>
</a-form>
</div>
<div class="config-section">
<div class="config-section-title">提示词与文件</div>
<div class="toggle-row">
<span>需要提示词</span>
<a-switch v-model:checked="selectedPara.need_prompt" />
</div>
<a-textarea
v-if="selectedPara.need_prompt"
v-model:value="selectedPara.prompt_text"
:rows="4"
placeholder="在此输入提示词..."
/>
<div class="toggle-row file-row">
<span>需要参考文件</span>
<a-switch v-model:checked="selectedPara.need_file" />
</div>
<a-textarea
v-if="selectedPara.need_file"
v-model:value="selectedPara.file_note"
:rows="3"
placeholder="提示用户上传什么文件"
/>
</div>
<a-button v-if="selectedPara.edit_mode === 'ai'" type="primary" block @click="openTestModal">
立即测试
</a-button>
</div>
</aside>
</div>
<div style="width:380px;border-left:1px solid #f0f0f0;overflow-y:auto;padding:16px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<h4 style="margin:0">段落配置</h4>
<a-button @click="saveTemplate">保存配置</a-button>
<a-modal v-model:open="testOpen" title="段落测试" :footer="null" width="720px" @cancel="resetTestModal">
<a-steps :current="testStep" style="margin-bottom:24px">
<a-step title="上传附件" />
<a-step title="处理中" />
<a-step title="查看结果" />
</a-steps>
<div v-if="testStep === 0">
<div class="test-paragraph-card">
<div class="test-para-title">{{ selectedPara?.title }}</div>
<div class="test-para-desc">{{ selectedPara?.file_note || '可上传多个参考文件,系统会解析内容后与提示词一起发送给模型。' }}</div>
<a-upload-dragger :multiple="true" :beforeUpload="beforeTestUpload" :showUploadList="false">
<p class="ant-upload-drag-icon"><upload-outlined /></p>
<p class="ant-upload-text">点击或拖拽上传参考文件</p>
<p class="ant-upload-hint">支持多文件docx / xlsx / xls / csv / pdf / txt / md</p>
</a-upload-dragger>
</div>
<div v-if="testFiles.length" class="uploaded-list">
<div class="uploaded-item" v-for="file in testFiles" :key="file.uid">
<span class="uploaded-name">{{ file.name }}</span>
<a-button type="link" size="small" danger @click="removeTestFile(file.uid)">移除</a-button>
</div>
</div>
<a-button type="primary" block :loading="testing" @click="startTest">开始测试</a-button>
</div>
<div v-if="selectedPara">
<a-form layout="vertical">
<a-form-item label="编辑方式">
<a-select v-model:value="selectedPara.edit_mode">
<a-select-option value="ai">AI 生成</a-select-option>
<a-select-option value="manual">人工编辑</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="selectedPara.edit_mode === 'ai'" label="生成模型">
<a-select v-model:value="selectedPara.model_id" allowClear placeholder="使用默认模型">
<a-select-option v-for="m in models" :key="m.id" :value="m.id">{{ m.name }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="输出格式">
<a-select v-model:value="selectedPara.output_format">
<a-select-option value="text">正式报告段落</a-select-option>
<a-select-option value="table">表格形式</a-select-option>
<a-select-option value="mixed">混合内容</a-select-option>
<a-select-option value="chart">图表形式</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="需要提示词">
<a-switch v-model:checked="selectedPara.need_prompt" />
</a-form-item>
<a-form-item v-if="selectedPara.need_prompt" label="预设提示词">
<a-textarea v-model:value="selectedPara.prompt_text" :rows="4" placeholder="在此输入提示词..." />
</a-form-item>
<a-form-item label="需要参考文件">
<a-switch v-model:checked="selectedPara.need_file" />
</a-form-item>
<a-form-item v-if="selectedPara.need_file" label="备注说明">
<a-textarea v-model:value="selectedPara.file_note" :rows="2" placeholder="提示用户上传什么文件" />
</a-form-item>
</a-form>
<a-button v-if="selectedPara.edit_mode === 'ai'" type="primary" block @click="testPara">立即测试</a-button>
<div v-else-if="testStep === 1" class="test-processing">
<a-spin size="large" />
<p class="processing-text">{{ testStatusText }}</p>
</div>
</div>
<div v-else>
<div class="result-meta">
<span class="success-pill">测试完成</span>
<span class="meta-text">{{ testResultMessage }}</span>
</div>
<div v-if="testFileSummaries.length" class="summary-card">
<div class="summary-title">文件解析摘要</div>
<div class="summary-item" v-for="item in testFileSummaries" :key="item.file_path">
<div class="summary-file">{{ item.file_name }}</div>
<pre class="summary-text">{{ item.summary }}</pre>
</div>
</div>
<div class="result-card" v-html="testResultHtml" />
</div>
</a-modal>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { Modal, message } from 'ant-design-vue'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import {
AlignCenterOutlined,
AlignLeftOutlined,
AlignRightOutlined,
ArrowLeftOutlined,
FontSizeOutlined,
OrderedListOutlined,
TableOutlined,
UploadOutlined,
} from '@ant-design/icons-vue'
import { useTemplateStore } from '@/stores/template'
import { useModelStore } from '@/stores/model'
import { generateApi } from '@/api/generate'
interface LocalUploadFile {
uid: string
name: string
raw: File
}
const route = useRoute()
const router = useRouter()
const store = useTemplateStore()
const modelStore = useModelStore()
const paragraphs = ref<any[]>([])
const models = ref<any[]>([])
const selectedId = ref(0)
const templateName = ref('模板编辑')
const testOpen = ref(false)
const testStep = ref(0)
const testing = ref(false)
const testStatusText = ref('正在解析文件内容并请求 AI 模型...')
const testFiles = ref<LocalUploadFile[]>([])
const testResultHtml = ref('')
const testResultMessage = ref('')
const testFileSummaries = ref<any[]>([])
const selectedPara = computed(() => paragraphs.value.find((item) => item.id === selectedId.value))
const previewHtml = computed(() =>
paragraphs.value
.map(
(item) =>
`<div style="margin:8px 0;padding:8px;border:2px solid transparent;border-radius:4px${
selectedId.value === item.id ? ';border-color:#5b5bd6;background:#f0f0ff' : ''
}"><h3>${item.title || ''}</h3><p>${item.content || '点击左侧配置此段落'}</p></div>`,
)
.join(''),
)
function selectPara(id: number) {
selectedId.value = id
@@ -103,39 +232,109 @@ async function saveTemplate() {
message.success('模板配置已保存')
}
async function testPara() {
if (!selectedPara.value) {
return
}
const templateId = Number(route.params.id)
const response: any = await generateApi.test({
paragraph_id: selectedPara.value.id,
template_id: templateId,
prompt_text: selectedPara.value.prompt_text || '',
model_id: selectedPara.value.model_id || 0,
file_paths: [],
function openTestModal() {
testOpen.value = true
testStep.value = 0
testing.value = false
testFiles.value = []
testResultHtml.value = ''
testResultMessage.value = ''
testFileSummaries.value = []
}
function resetTestModal() {
testOpen.value = false
testStep.value = 0
testing.value = false
testFiles.value = []
testResultHtml.value = ''
testResultMessage.value = ''
testFileSummaries.value = []
}
function beforeTestUpload(file: File) {
testFiles.value.push({
uid: `${Date.now()}-${Math.random()}`,
name: file.name,
raw: file,
})
const blocks = response.data?.content?.content || []
const text = blocks
return false
}
function removeTestFile(uid: string) {
testFiles.value = testFiles.value.filter((item) => item.uid !== uid)
}
function renderTestResult(content: any) {
const blocks = content?.content || []
return blocks
.map((block: any) => {
if (block.type === 'table') {
return `${block.title || '表格'}${(block.rows || []).length}`
const headers = (block.headers || []).map((item: string) => `<th>${item}</th>`).join('')
const rows = (block.rows || [])
.map((row: string[]) => `<tr>${row.map((cell) => `<td>${cell}</td>`).join('')}</tr>`)
.join('')
return `
<div class="result-block">
<div class="result-block-title">${block.title || '表格结果'}</div>
<table class="result-table">
<thead><tr>${headers}</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
`
}
return block.text || ''
return `<p class="result-text">${block.text || ''}</p>`
})
.filter(Boolean)
.join('\n\n')
.join('')
}
Modal.info({
title: '段落测试结果',
width: 640,
content: text || '当前未返回可展示内容。',
})
async function startTest() {
if (!selectedPara.value) return
if (selectedPara.value.need_file && !testFiles.value.length) {
message.warning('请先上传至少一个参考文件')
return
}
testStep.value = 1
testing.value = true
testStatusText.value = '正在上传文件...'
try {
const filePaths: string[] = []
for (const item of testFiles.value) {
const formData = new FormData()
formData.append('file', item.raw)
const uploadRes: any = await generateApi.upload(formData)
filePaths.push(uploadRes.data.file_path)
}
testStatusText.value = '正在解析文件内容并请求 AI 模型...'
const templateId = Number(route.params.id)
const response: any = await generateApi.test({
paragraph_id: selectedPara.value.id,
template_id: templateId,
prompt_text: selectedPara.value.prompt_text || '',
model_id: selectedPara.value.model_id || 0,
file_paths: filePaths,
})
testResultMessage.value = response.data?.message || '测试完成'
testFileSummaries.value = response.data?.file_summaries || []
testResultHtml.value = renderTestResult(response.data?.content)
testStep.value = 2
} catch (error: any) {
message.error(error.message || '段落测试失败')
resetTestModal()
} finally {
testing.value = false
}
}
onMounted(async () => {
const id = Number(route.params.id)
await store.fetchOne(id)
const template = await store.fetchOne(id)
templateName.value = template.name
paragraphs.value = store.paragraphs as any
await modelStore.fetchList()
models.value = modelStore.models as any
@@ -146,32 +345,255 @@ onMounted(async () => {
</script>
<style scoped>
.para-item {
.editor-page {
height: calc(100vh - 52px);
display: flex;
flex-direction: column;
background: #f5f6f8;
}
.editor-topbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-bottom: 1px solid #e0e2e6;
background: #fff;
gap: 12px;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #5b5bd6;
}
.divider {
color: #c4c8cf;
}
.editor-title {
font-size: 13px;
font-weight: 500;
}
.flex-spacer {
flex: 1;
}
.editor-layout {
display: flex;
flex: 1;
overflow: hidden;
}
.editor-left {
width: 220px;
flex-shrink: 0;
border-right: 1px solid #e0e2e6;
background: #fff;
display: flex;
flex-direction: column;
}
.left-head,
.right-head {
padding: 12px 16px;
border-bottom: 1px solid #eaecef;
font-size: 13px;
font-weight: 500;
}
.left-head-tip,
.right-head-name {
font-size: 12px;
font-weight: 400;
color: #9aa1ad;
margin-left: 8px;
}
.left-scroll,
.right-scroll {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.editor-center {
flex: 1;
display: flex;
flex-direction: column;
background: #f5f6f8;
}
.center-toolbar {
display: flex;
align-items: center;
padding: 8px 16px;
border-bottom: 1px solid #e0e2e6;
background: #fff;
gap: 4px;
}
.tb-btn {
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 6px;
cursor: pointer;
margin-bottom: 2px;
color: #5b626e;
}
.tb-btn.active,
.tb-btn:hover {
background: #eeeefb;
color: #5b5bd6;
}
.tb-divider {
width: 1px;
height: 18px;
background: #e0e2e6;
margin: 0 4px;
}
.toolbar-hint {
margin-left: auto;
font-size: 12px;
color: #9aa1ad;
}
.center-scroll {
flex: 1;
overflow-y: auto;
padding: 24px;
display: flex;
justify-content: center;
}
.doc-edit-page {
width: 794px;
background: #fff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
padding: 80px 72px 120px;
min-height: 500px;
}
.para-block {
position: relative;
padding: 8px;
margin: 4px 0;
border-radius: 6px;
border: 2px solid transparent;
cursor: pointer;
transition: all 0.12s;
}
.para-block:hover {
border-color: #e0e2e6;
}
.para-block.selected {
border-color: #5b5bd6;
background: #eeeefb;
}
.para-tag {
position: absolute;
right: 4px;
top: 4px;
font-size: 9px;
padding: 1px 6px;
border-radius: 4px;
}
.para-tag.ai {
background: #eeeefb;
color: #5b5bd6;
}
.para-tag.manual {
background: #fff3e0;
color: #e68a00;
}
.sec-title {
font-size: 16px;
font-weight: 600;
margin-top: 24px;
margin-bottom: 12px;
padding-bottom: 4px;
border-bottom: 2px solid #1a1d24;
}
.empty-text {
color: #9aa1ad;
}
.editor-right {
width: 380px;
flex-shrink: 0;
border-left: 1px solid #e0e2e6;
background: #fff;
display: flex;
flex-direction: column;
}
.config-section {
margin-bottom: 24px;
}
.config-section-title {
font-size: 13px;
font-weight: 600;
color: #1a1d24;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #eaecef;
}
.toggle-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
font-size: 13px;
}
.para-item:hover {
background: #f5f5f5;
.file-row {
margin-top: 16px;
}
.para-item.active {
background: #f0f0ff;
.para-list-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
color: #5b626e;
margin-bottom: 2px;
}
.para-list-item:hover {
background: #f0f1f3;
}
.para-list-item.active {
background: #eeeefb;
color: #5b5bd6;
font-weight: 500;
}
.para-item .idx {
.pli-index {
width: 20px;
height: 20px;
border-radius: 50%;
background: #f0f0f0;
background: #f0f1f3;
display: flex;
align-items: center;
justify-content: center;
@@ -180,15 +602,163 @@ onMounted(async () => {
flex-shrink: 0;
}
.para-item.active .idx {
.para-list-item.active .pli-index {
background: #5b5bd6;
color: #fff;
}
.para-item .title {
.pli-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-overflow: ellipsis;
}
.pli-badge {
font-size: 9px;
padding: 0 6px;
border-radius: 6px;
flex-shrink: 0;
}
.pli-badge.ai {
background: #eeeefb;
color: #5b5bd6;
}
.pli-badge.manual {
background: #fff3e0;
color: #e68a00;
}
.test-paragraph-card {
background: #f0f1f3;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.test-para-title {
font-size: 14px;
font-weight: 500;
margin-bottom: 4px;
}
.test-para-desc {
font-size: 12px;
color: #5b626e;
margin-bottom: 12px;
}
.uploaded-list {
margin-bottom: 16px;
}
.uploaded-item {
display: flex;
justify-content: space-between;
align-items: center;
background: #f5f6f8;
border-radius: 8px;
padding: 8px 12px;
margin-bottom: 8px;
}
.uploaded-name {
font-size: 13px;
}
.test-processing {
text-align: center;
padding: 40px;
}
.processing-text {
margin-top: 16px;
color: #5b626e;
}
.result-meta {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.success-pill {
background: #1a8c4a;
color: #fff;
padding: 2px 10px;
border-radius: 4px;
font-size: 12px;
}
.meta-text {
font-size: 12px;
color: #5b626e;
}
.summary-card {
background: #f5f6f8;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.summary-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 12px;
}
.summary-item {
margin-bottom: 12px;
}
.summary-file {
font-size: 12px;
font-weight: 500;
margin-bottom: 4px;
}
.summary-text {
white-space: pre-wrap;
font-size: 12px;
color: #5b626e;
background: #fff;
padding: 8px;
border-radius: 6px;
max-height: 120px;
overflow: auto;
}
.result-card {
background: #f0f0ff;
padding: 16px;
border-left: 3px solid #5b5bd6;
border-radius: 4px;
}
:deep(.result-text) {
margin: 0 0 12px;
line-height: 1.8;
}
:deep(.result-block-title) {
font-size: 13px;
font-weight: 600;
margin-bottom: 8px;
}
:deep(.result-table) {
width: 100%;
border-collapse: collapse;
}
:deep(.result-table th),
:deep(.result-table td) {
border: 1px solid #d9d9d9;
padding: 6px 8px;
font-size: 12px;
}
</style>