模板在线编辑与导出链路重构
This commit is contained in:
@@ -1,19 +1,45 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { templateApi } from '@/api/template'
|
||||
import type { Template, Paragraph } from '@/types'
|
||||
import type { Template, Paragraph, TemplateBlock } from '@/types'
|
||||
|
||||
export const useTemplateStore = defineStore('template', () => {
|
||||
const templates = ref<Template[]>([])
|
||||
const currentTemplate = ref<Template | null>(null)
|
||||
const paragraphs = ref<Paragraph[]>([])
|
||||
const blocks = ref<TemplateBlock[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchList() { loading.value = true; try { const r: any = await templateApi.list(); templates.value = r.data?.items || r.data || [] } finally { loading.value = false } }
|
||||
async function fetchOne(id: number) { const r: any = await templateApi.get(id); currentTemplate.value = r.data; paragraphs.value = r.data?.paragraphs || []; return r.data }
|
||||
async function upload(file: File) { const fd = new FormData(); fd.append('file', file); const r: any = await templateApi.upload(fd); currentTemplate.value = r.data; paragraphs.value = r.data?.paragraphs || []; return r.data }
|
||||
async function save(id: number) { const data = paragraphs.value.map((p, index) => ({ id: p.id, sort_index: index + 1, anchor_title: p.anchor_title, title: p.title, content: p.content, edit_mode: p.edit_mode, write_mode: p.write_mode, model_id: p.model_id, need_prompt: p.need_prompt, prompt_text: p.prompt_text, need_file: p.need_file, file_note: p.file_note, output_format: p.output_format })); await templateApi.saveParagraphs(id, { paragraphs: data }) }
|
||||
async function fetchOne(id: number) { const r: any = await templateApi.get(id); currentTemplate.value = r.data; paragraphs.value = r.data?.paragraphs || []; blocks.value = r.data?.blocks || []; return r.data }
|
||||
async function upload(file: File) { const fd = new FormData(); fd.append('file', file); const r: any = await templateApi.upload(fd); currentTemplate.value = r.data; paragraphs.value = r.data?.paragraphs || []; blocks.value = r.data?.blocks || []; return r.data }
|
||||
async function save(id: number) {
|
||||
const data = paragraphs.value.map((p, index) => ({ id: p.id, sort_index: index + 1, anchor_title: p.anchor_title, title: p.title, content: p.content, edit_mode: p.edit_mode, write_mode: p.write_mode, model_id: p.model_id, need_prompt: p.need_prompt, prompt_text: p.prompt_text, need_file: p.need_file, file_note: p.file_note, output_format: p.output_format }))
|
||||
const blockData = blocks.value.map((block, index) => ({
|
||||
id: block.id,
|
||||
source_paragraph_id: block.source_paragraph_id,
|
||||
parent_block_id: block.parent_block_id,
|
||||
sort_index: index + 1,
|
||||
block_type: block.block_type,
|
||||
anchor_ref: block.anchor_ref,
|
||||
title: block.title,
|
||||
content_json: block.content_json,
|
||||
style_json: block.style_json,
|
||||
edit_mode: block.edit_mode,
|
||||
placeholder_key: block.placeholder_key,
|
||||
variable_key: block.variable_key,
|
||||
default_value: block.default_value,
|
||||
model_id: block.model_id,
|
||||
need_prompt: block.need_prompt,
|
||||
prompt_text: block.prompt_text,
|
||||
need_file: block.need_file,
|
||||
file_note: block.file_note,
|
||||
output_format: block.output_format,
|
||||
}))
|
||||
const r: any = await templateApi.saveParagraphs(id, { save_mode: 'manual', paragraphs: data, blocks: blockData })
|
||||
blocks.value = r.data?.blocks || blocks.value
|
||||
}
|
||||
async function remove(id: number) { await templateApi.delete(id); await fetchList() }
|
||||
|
||||
return { templates, currentTemplate, paragraphs, loading, fetchList, fetchOne, upload, save, remove }
|
||||
return { templates, currentTemplate, paragraphs, blocks, loading, fetchList, fetchOne, upload, save, remove }
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface Template {
|
||||
id: number; name: string; description: string; file_path: string
|
||||
paragraph_count: number; status: string; created_at: string; updated_at: string
|
||||
blocks?: TemplateBlock[]
|
||||
}
|
||||
|
||||
export interface Paragraph {
|
||||
@@ -13,6 +14,29 @@ export interface Paragraph {
|
||||
output_format: 'text' | 'table' | 'mixed' | 'chart'
|
||||
}
|
||||
|
||||
export interface TemplateBlock {
|
||||
id: number
|
||||
template_id?: number
|
||||
source_paragraph_id?: number | null
|
||||
parent_block_id?: number | null
|
||||
sort_index: number
|
||||
block_type: 'heading' | 'text' | 'table' | 'ai_slot' | 'variable'
|
||||
anchor_ref: string
|
||||
title: string
|
||||
content_json: Record<string, any>
|
||||
style_json: string
|
||||
edit_mode: 'manual' | 'ai'
|
||||
placeholder_key: string
|
||||
variable_key: string
|
||||
default_value: string
|
||||
model_id: number | null
|
||||
need_prompt: boolean
|
||||
prompt_text: string
|
||||
need_file: boolean
|
||||
file_note: string
|
||||
output_format: 'text' | 'table' | 'mixed' | 'chart'
|
||||
}
|
||||
|
||||
export interface AiModel {
|
||||
id: number; name: string; provider: string; api_format: 'anthropic' | 'openai'
|
||||
api_endpoint: string; api_key_preview: string; supports_streaming: boolean; enable_reasoning: boolean; status: 'enabled' | 'disabled'
|
||||
|
||||
+426
-108
@@ -18,29 +18,29 @@
|
||||
<div class="editor-layout">
|
||||
<aside class="editor-left">
|
||||
<div class="left-head">
|
||||
段落列表
|
||||
{{ editorMode === 'paragraph' ? '段落列表' : '内容块列表' }}
|
||||
<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)"
|
||||
v-for="item in currentItems"
|
||||
:key="item.id"
|
||||
:class="['para-list-item', { active: selectedId === item.id }]"
|
||||
@click="selectPara(item.id)"
|
||||
>
|
||||
<span class="pli-index">{{ paragraph.sort_index }}</span>
|
||||
<span class="pli-title">{{ listTitle(paragraph) }}</span>
|
||||
<span :class="['pli-badge', paragraph.edit_mode === 'ai' ? 'ai' : 'manual']">
|
||||
{{ paragraph.edit_mode === 'ai' ? 'AI 生成' : '人工编辑' }}
|
||||
<span class="pli-index">{{ item.sort_index }}</span>
|
||||
<span class="pli-title">{{ listTitle(item) }}</span>
|
||||
<span :class="['pli-badge', editorMode === 'manual' ? `block-${item.block_type || 'text'}` : (item.edit_mode === 'ai' ? 'ai' : 'manual')]">
|
||||
{{ editorMode === 'manual' ? blockTypeLabel(item.block_type) : (item.edit_mode === 'ai' ? 'AI 生成' : '人工编辑') }}
|
||||
</span>
|
||||
<span class="pli-actions" @click.stop>
|
||||
<a-button type="text" size="small" :disabled="!canMoveUp(paragraph)" @click="moveUp(paragraph)">
|
||||
<a-button type="text" size="small" :disabled="!canMoveUp(item)" @click="moveUp(item)">
|
||||
<arrow-up-outlined />
|
||||
</a-button>
|
||||
<a-button type="text" size="small" :disabled="!canMoveDown(paragraph)" @click="moveDown(paragraph)">
|
||||
<a-button type="text" size="small" :disabled="!canMoveDown(item)" @click="moveDown(item)">
|
||||
<arrow-down-outlined />
|
||||
</a-button>
|
||||
<a-button type="text" size="small" danger :disabled="!canDeleteBlock(paragraph)" @click="handleDeleteParagraph(paragraph)">
|
||||
<a-button type="text" size="small" danger :disabled="!canDeleteBlock(item)" @click="handleDeleteParagraph(item)">
|
||||
<delete-outlined />
|
||||
</a-button>
|
||||
</span>
|
||||
@@ -63,13 +63,20 @@
|
||||
<span class="tb-btn"><align-center-outlined /></span>
|
||||
<span class="tb-btn"><align-right-outlined /></span>
|
||||
</template>
|
||||
<span class="toolbar-hint">
|
||||
<span class="toolbar-hint">
|
||||
{{ editorMode === 'paragraph' ? '点击左侧段落或文档中的段落块查看配置' : '可直接编辑标题、正文,并手动拆块插入 AI 内容' }}
|
||||
</span>
|
||||
<template v-if="editorMode === 'manual'">
|
||||
<span class="tb-divider" />
|
||||
<a-button size="small" @click="appendBlock('heading')">新增标题块</a-button>
|
||||
<a-button size="small" @click="appendBlock('text')">新增正文块</a-button>
|
||||
<a-button size="small" type="primary" ghost @click="appendBlock('ai_slot')">新增 AI 块</a-button>
|
||||
<a-button size="small" @click="appendBlock('variable')">新增变量块</a-button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="center-scroll">
|
||||
<div class="doc-edit-page">
|
||||
<div :class="['doc-edit-page', { 'doc-edit-page-manual': editorMode === 'manual' }]">
|
||||
<template v-if="editorMode === 'paragraph'">
|
||||
<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-block-actions" @click.stop>
|
||||
@@ -93,45 +100,52 @@
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="paragraph in paragraphs"
|
||||
:key="paragraph.id"
|
||||
:class="['para-block', 'manual-block', { selected: selectedId === paragraph.id }]"
|
||||
@click="selectPara(paragraph.id)"
|
||||
v-for="block in blocks"
|
||||
:key="block.id"
|
||||
:class="['para-block', 'manual-block', blockCardClass(block), { selected: selectedId === block.id }]"
|
||||
@click="selectPara(block.id)"
|
||||
>
|
||||
<span :class="['para-tag', paragraph.edit_mode === 'ai' ? 'ai' : 'manual']">
|
||||
{{ paragraph.edit_mode === 'ai' ? 'AI 段落' : '手动段落' }}
|
||||
<span :class="['para-tag', block.edit_mode === 'ai' ? 'ai' : 'manual']">
|
||||
{{ blockTagLabel(block) }}
|
||||
</span>
|
||||
<div class="manual-block-head">
|
||||
<span class="manual-block-index">段落 {{ paragraph.sort_index }}</span>
|
||||
<span class="manual-block-anchor" v-if="paragraph.anchor_title && paragraph.anchor_title !== paragraph.title">
|
||||
原标题:{{ paragraph.anchor_title }}
|
||||
<span class="manual-block-index">块 {{ block.sort_index }}</span>
|
||||
<span class="manual-block-anchor" v-if="block.anchor_ref && block.anchor_ref !== block.title">
|
||||
锚点:{{ block.anchor_ref }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="block-hero">
|
||||
<div class="block-hero-type">{{ blockTypeLabel(block.block_type) }}</div>
|
||||
<div class="block-hero-key" v-if="block.block_type === 'ai_slot' && block.placeholder_key">{{ renderBlockKey(block.placeholder_key) }}</div>
|
||||
<div class="block-hero-key" v-else-if="block.block_type === 'variable' && block.variable_key">{{ renderBlockKey(block.variable_key) }}</div>
|
||||
</div>
|
||||
<div class="field-label">标题</div>
|
||||
<a-input
|
||||
v-model:value="paragraph.title"
|
||||
v-model:value="block.title"
|
||||
class="manual-title-input"
|
||||
placeholder="输入导出时使用的标题"
|
||||
:placeholder="block.block_type === 'heading' ? '输入标题文本' : '输入导出时使用的标题'"
|
||||
@click.stop
|
||||
/>
|
||||
<div class="field-label">正文</div>
|
||||
<div class="field-label">{{ block.block_type === 'variable' ? '变量默认值' : '正文' }}</div>
|
||||
<a-textarea
|
||||
v-model:value="paragraph.content"
|
||||
v-model:value="block.content_json.text"
|
||||
class="manual-content-input"
|
||||
:rows="paragraph.write_mode === 'replace_heading_only' ? 3 : 6"
|
||||
:placeholder="contentPlaceholder(paragraph)"
|
||||
:rows="block.block_type === 'heading' ? 3 : 6"
|
||||
:placeholder="contentPlaceholder(block)"
|
||||
@click.stop
|
||||
/>
|
||||
<div class="manual-block-actions">
|
||||
<a-button size="small" @click.stop="insertBlockAfter(paragraph, 'manual')">在后面新增固定块</a-button>
|
||||
<a-button size="small" type="primary" ghost @click.stop="insertBlockAfter(paragraph, 'ai')">在后面新增 AI 块</a-button>
|
||||
<a-button size="small" :disabled="!canMoveUp(paragraph)" @click.stop="moveUp(paragraph)">上移</a-button>
|
||||
<a-button size="small" :disabled="!canMoveDown(paragraph)" @click.stop="moveDown(paragraph)">下移</a-button>
|
||||
<a-button size="small" danger :disabled="!canDeleteBlock(paragraph)" @click.stop="handleDeleteParagraph(paragraph)">删除当前块</a-button>
|
||||
<a-button size="small" @click.stop="insertSpecificBlockAfter(block, 'text')">后插正文块</a-button>
|
||||
<a-button size="small" @click.stop="insertSpecificBlockAfter(block, 'heading')">后插标题块</a-button>
|
||||
<a-button size="small" type="primary" ghost @click.stop="insertSpecificBlockAfter(block, 'ai_slot')">后插 AI 块</a-button>
|
||||
<a-button size="small" @click.stop="insertSpecificBlockAfter(block, 'variable')">后插变量块</a-button>
|
||||
<a-button size="small" :disabled="!canMoveUp(block)" @click.stop="moveUp(block)">上移</a-button>
|
||||
<a-button size="small" :disabled="!canMoveDown(block)" @click.stop="moveDown(block)">下移</a-button>
|
||||
<a-button size="small" danger :disabled="!canDeleteBlock(block)" @click.stop="handleDeleteParagraph(block)">删除当前块</a-button>
|
||||
</div>
|
||||
<div class="manual-block-meta">
|
||||
<span class="meta-item">编辑方式:{{ paragraph.edit_mode === 'ai' ? 'AI 生成' : '人工编辑' }}</span>
|
||||
<span class="meta-item">写入方式:{{ writeModeLabel(paragraph.write_mode) }}</span>
|
||||
<span class="meta-item">编辑方式:{{ block.edit_mode === 'ai' ? 'AI 生成' : '人工编辑' }}</span>
|
||||
<span class="meta-item">块类型:{{ blockTypeLabel(block.block_type) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -142,32 +156,41 @@
|
||||
<aside class="editor-right">
|
||||
<div class="right-head">
|
||||
<span>段落配置</span>
|
||||
<span class="right-head-name">{{ selectedPara?.title || '' }}</span>
|
||||
<span class="right-head-name">{{ selectedConfigItem?.title || '' }}</span>
|
||||
</div>
|
||||
<div class="right-scroll" v-if="selectedPara">
|
||||
<div class="right-scroll" v-if="selectedConfigItem">
|
||||
<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 v-model:value="selectedConfigItem.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 label="写入方式">
|
||||
<a-select v-model:value="selectedPara.write_mode">
|
||||
<a-form-item v-if="editorMode === 'paragraph'" label="写入方式">
|
||||
<a-select v-model:value="selectedConfigItem.write_mode">
|
||||
<a-select-option value="replace_section">替换标题下整段</a-select-option>
|
||||
<a-select-option value="append_after_heading">标题下插入内容</a-select-option>
|
||||
<a-select-option value="replace_heading_only">仅替换标题</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-form-item v-else label="块类型">
|
||||
<a-select v-model:value="selectedConfigItem.block_type">
|
||||
<a-select-option value="heading">标题块</a-select-option>
|
||||
<a-select-option value="text">正文块</a-select-option>
|
||||
<a-select-option value="table">表格块</a-select-option>
|
||||
<a-select-option value="ai_slot">AI 块</a-select-option>
|
||||
<a-select-option value="variable">变量块</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="selectedConfigItem.edit_mode === 'ai'" label="生成模型">
|
||||
<a-select v-model:value="selectedConfigItem.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 v-model:value="selectedConfigItem.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>
|
||||
@@ -186,19 +209,25 @@
|
||||
class="template-alert"
|
||||
/>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="原标题锚点">
|
||||
<a-input :value="selectedPara.anchor_title || selectedPara.title" disabled />
|
||||
<a-form-item :label="editorMode === 'paragraph' ? '原标题锚点' : '锚点引用'">
|
||||
<a-input :value="currentAnchorValue(selectedConfigItem)" disabled />
|
||||
</a-form-item>
|
||||
<a-form-item label="导出标题">
|
||||
<a-input v-model:value="selectedPara.title" placeholder="输入导出时使用的标题" />
|
||||
<a-input v-model:value="selectedConfigItem.title" placeholder="输入导出时使用的标题" />
|
||||
</a-form-item>
|
||||
<a-form-item label="模板正文">
|
||||
<a-textarea
|
||||
v-model:value="selectedPara.content"
|
||||
:rows="selectedPara.write_mode === 'replace_heading_only' ? 4 : 8"
|
||||
:placeholder="contentPlaceholder(selectedPara)"
|
||||
v-model:value="contentProxy"
|
||||
:rows="editorMode === 'paragraph' && selectedConfigItem.write_mode === 'replace_heading_only' ? 4 : 8"
|
||||
:placeholder="contentPlaceholder(selectedConfigItem)"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="editorMode === 'manual' && selectedConfigItem.block_type === 'ai_slot'" label="AI 占位键">
|
||||
<a-input v-model:value="selectedConfigItem.placeholder_key" placeholder="例如 opening_summary" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="editorMode === 'manual' && selectedConfigItem.block_type === 'variable'" label="变量键">
|
||||
<a-input v-model:value="selectedConfigItem.variable_key" placeholder="例如 report_date" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
|
||||
@@ -206,28 +235,28 @@
|
||||
<div class="config-section-title">提示词与文件</div>
|
||||
<div class="toggle-row">
|
||||
<span>需要提示词</span>
|
||||
<a-switch v-model:checked="selectedPara.need_prompt" />
|
||||
<a-switch v-model:checked="selectedConfigItem.need_prompt" />
|
||||
</div>
|
||||
<a-textarea
|
||||
v-if="selectedPara.need_prompt"
|
||||
v-model:value="selectedPara.prompt_text"
|
||||
v-if="selectedConfigItem.need_prompt"
|
||||
v-model:value="selectedConfigItem.prompt_text"
|
||||
:rows="4"
|
||||
placeholder="在此输入提示词..."
|
||||
/>
|
||||
|
||||
<div class="toggle-row file-row">
|
||||
<span>需要参考文件</span>
|
||||
<a-switch v-model:checked="selectedPara.need_file" />
|
||||
<a-switch v-model:checked="selectedConfigItem.need_file" />
|
||||
</div>
|
||||
<a-textarea
|
||||
v-if="selectedPara.need_file"
|
||||
v-model:value="selectedPara.file_note"
|
||||
v-if="selectedConfigItem.need_file"
|
||||
v-model:value="selectedConfigItem.file_note"
|
||||
:rows="3"
|
||||
placeholder="提示用户上传什么文件"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<a-button v-if="selectedPara.edit_mode === 'ai'" type="primary" block @click="openTestModal">
|
||||
<a-button v-if="selectedConfigItem.edit_mode === 'ai'" type="primary" block @click="openTestModal">
|
||||
立即测试
|
||||
</a-button>
|
||||
</div>
|
||||
@@ -244,8 +273,8 @@
|
||||
<div v-if="testStep === 0">
|
||||
<ReferenceFileSelector
|
||||
v-model="testSelectedFiles"
|
||||
:title="selectedPara?.title || ''"
|
||||
:description="selectedPara?.file_note || '可上传多个参考文件,系统会解析内容后与提示词一起发送给模型。'"
|
||||
:title="selectedConfigItem?.title || ''"
|
||||
:description="selectedConfigItem?.file_note || '可上传多个参考文件,系统会解析内容后与提示词一起发送给模型。'"
|
||||
variant="full"
|
||||
/>
|
||||
|
||||
@@ -280,7 +309,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
@@ -307,6 +336,7 @@ const store = useTemplateStore()
|
||||
const modelStore = useModelStore()
|
||||
|
||||
const paragraphs = ref<any[]>([])
|
||||
const blocks = ref<any[]>([])
|
||||
const models = ref<any[]>([])
|
||||
const selectedId = ref(0)
|
||||
const templateName = ref('模板编辑')
|
||||
@@ -323,16 +353,48 @@ const testFileSummaries = ref<any[]>([])
|
||||
const streamedText = ref('')
|
||||
|
||||
const selectedPara = computed(() => paragraphs.value.find((item) => item.id === selectedId.value))
|
||||
const selectedBlock = computed(() => blocks.value.find((item) => item.id === selectedId.value))
|
||||
const currentItems = computed(() => (editorMode.value === 'paragraph' ? paragraphs.value : blocks.value))
|
||||
const selectedConfigItem = computed(() => (editorMode.value === 'paragraph' ? selectedPara.value : selectedBlock.value))
|
||||
const contentProxy = computed({
|
||||
get() {
|
||||
const current = selectedConfigItem.value
|
||||
if (!current) return ''
|
||||
return editorMode.value === 'paragraph' ? current.content || '' : current.content_json?.text || ''
|
||||
},
|
||||
set(value: string) {
|
||||
const current = selectedConfigItem.value
|
||||
if (!current) return
|
||||
if (editorMode.value === 'paragraph') {
|
||||
current.content = value
|
||||
return
|
||||
}
|
||||
current.content_json = {
|
||||
...(current.content_json || {}),
|
||||
text: value,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function selectPara(id: number) {
|
||||
selectedId.value = id
|
||||
}
|
||||
|
||||
function activeCollection() {
|
||||
return editorMode.value === 'paragraph' ? paragraphs.value : blocks.value
|
||||
}
|
||||
|
||||
function normalizeSortIndex() {
|
||||
paragraphs.value = paragraphs.value.map((item, index) => ({
|
||||
const items = activeCollection()
|
||||
const normalized = items.map((item, index) => ({
|
||||
...item,
|
||||
sort_index: index + 1,
|
||||
}))
|
||||
if (editorMode.value === 'paragraph') {
|
||||
paragraphs.value = normalized
|
||||
} else {
|
||||
blocks.value = normalized
|
||||
}
|
||||
}
|
||||
|
||||
function writeModeLabel(mode: string) {
|
||||
@@ -341,43 +403,133 @@ function writeModeLabel(mode: string) {
|
||||
return '替换标题下整段'
|
||||
}
|
||||
|
||||
function listTitle(paragraph: any) {
|
||||
if (!paragraph?.anchor_title || paragraph.anchor_title === paragraph.title) {
|
||||
return paragraph?.title || '未命名段落'
|
||||
function currentAnchorValue(item: any) {
|
||||
if (!item) return ''
|
||||
return editorMode.value === 'paragraph' ? item.anchor_title || item.title : item.anchor_ref || item.title
|
||||
}
|
||||
|
||||
function listTitle(item: any) {
|
||||
const anchorValue = editorMode.value === 'paragraph' ? item?.anchor_title : item?.anchor_ref
|
||||
if (!anchorValue || anchorValue === item?.title) {
|
||||
return item?.title || '未命名段落'
|
||||
}
|
||||
return `${paragraph.title || '未命名块'}(归属 ${paragraph.anchor_title})`
|
||||
return `${item.title || '未命名块'}(归属 ${anchorValue})`
|
||||
}
|
||||
|
||||
function blockTypeLabel(type: string) {
|
||||
if (type === 'heading') return '标题块'
|
||||
if (type === 'table') return '表格块'
|
||||
if (type === 'ai_slot') return 'AI 块'
|
||||
if (type === 'variable') return '变量块'
|
||||
return '正文块'
|
||||
}
|
||||
|
||||
function blockTagLabel(block: any) {
|
||||
if (block.block_type === 'heading') return '标题块'
|
||||
if (block.block_type === 'table') return '表格块'
|
||||
if (block.block_type === 'variable') return '变量块'
|
||||
return block.edit_mode === 'ai' || block.block_type === 'ai_slot' ? 'AI 块' : '手动块'
|
||||
}
|
||||
|
||||
function renderBlockKey(key: string) {
|
||||
return `{{ ${key} }}`
|
||||
}
|
||||
|
||||
function blockCardClass(block: any) {
|
||||
if (block.block_type === 'heading') return 'manual-block-heading'
|
||||
if (block.block_type === 'ai_slot') return 'manual-block-ai'
|
||||
if (block.block_type === 'variable') return 'manual-block-variable'
|
||||
if (block.block_type === 'table') return 'manual-block-table'
|
||||
return 'manual-block-text'
|
||||
}
|
||||
|
||||
function createVisualBlock(type: 'heading' | 'text' | 'ai_slot' | 'variable') {
|
||||
const now = Date.now() + Math.floor(Math.random() * 1000)
|
||||
const titleMap: Record<string, string> = {
|
||||
heading: '新标题块',
|
||||
text: '新正文块',
|
||||
ai_slot: '新 AI 块',
|
||||
variable: '新变量块',
|
||||
}
|
||||
return {
|
||||
id: -now,
|
||||
template_id: Number(route.params.id),
|
||||
source_paragraph_id: null,
|
||||
parent_block_id: null,
|
||||
sort_index: blocks.value.length + 1,
|
||||
block_type: type,
|
||||
anchor_ref: '',
|
||||
title: titleMap[type],
|
||||
content_json: { text: type === 'ai_slot' ? '请在这里描述要由 AI 生成的内容。' : '' },
|
||||
style_json: '{}',
|
||||
edit_mode: type === 'ai_slot' ? 'ai' : 'manual',
|
||||
placeholder_key: type === 'ai_slot' ? `ai_block_${Math.abs(now)}` : '',
|
||||
variable_key: type === 'variable' ? `variable_${Math.abs(now)}` : '',
|
||||
default_value: '',
|
||||
model_id: null,
|
||||
need_prompt: type === 'ai_slot',
|
||||
prompt_text: '',
|
||||
need_file: false,
|
||||
file_note: '',
|
||||
output_format: 'text',
|
||||
}
|
||||
}
|
||||
|
||||
function appendBlock(type: 'heading' | 'text' | 'ai_slot' | 'variable') {
|
||||
const block = createVisualBlock(type)
|
||||
blocks.value.push(block)
|
||||
editorMode.value = 'manual'
|
||||
normalizeSortIndex()
|
||||
nextTick(() => {
|
||||
selectedId.value = block.id
|
||||
})
|
||||
}
|
||||
|
||||
function insertSpecificBlockAfter(sourceBlock: any, type: 'heading' | 'text' | 'ai_slot' | 'variable') {
|
||||
const index = blocks.value.findIndex((item) => item === sourceBlock)
|
||||
if (index < 0) return
|
||||
const block = createVisualBlock(type)
|
||||
block.anchor_ref = sourceBlock.anchor_ref || sourceBlock.title || ''
|
||||
blocks.value.splice(index + 1, 0, block)
|
||||
normalizeSortIndex()
|
||||
nextTick(() => {
|
||||
selectedId.value = block.id
|
||||
})
|
||||
}
|
||||
|
||||
function canDeleteBlock(_paragraph: any) {
|
||||
return paragraphs.value.length > 1
|
||||
return activeCollection().length > 1
|
||||
}
|
||||
|
||||
function canMoveUp(paragraph: any) {
|
||||
const index = paragraphs.value.findIndex((item) => item === paragraph)
|
||||
const index = activeCollection().findIndex((item) => item === paragraph)
|
||||
return index > 0
|
||||
}
|
||||
|
||||
function canMoveDown(paragraph: any) {
|
||||
const index = paragraphs.value.findIndex((item) => item === paragraph)
|
||||
return index >= 0 && index < paragraphs.value.length - 1
|
||||
const items = activeCollection()
|
||||
const index = items.findIndex((item) => item === paragraph)
|
||||
return index >= 0 && index < items.length - 1
|
||||
}
|
||||
|
||||
function moveUp(paragraph: any) {
|
||||
const index = paragraphs.value.findIndex((item) => item === paragraph)
|
||||
const items = activeCollection()
|
||||
const index = items.findIndex((item) => item === paragraph)
|
||||
if (index <= 0) return
|
||||
const temp = paragraphs.value[index]
|
||||
paragraphs.value[index] = paragraphs.value[index - 1]
|
||||
paragraphs.value[index - 1] = temp
|
||||
const temp = items[index]
|
||||
items[index] = items[index - 1]
|
||||
items[index - 1] = temp
|
||||
normalizeSortIndex()
|
||||
autoSaveParagraphs()
|
||||
}
|
||||
|
||||
function moveDown(paragraph: any) {
|
||||
const index = paragraphs.value.findIndex((item) => item === paragraph)
|
||||
if (index < 0 || index >= paragraphs.value.length - 1) return
|
||||
const temp = paragraphs.value[index]
|
||||
paragraphs.value[index] = paragraphs.value[index + 1]
|
||||
paragraphs.value[index + 1] = temp
|
||||
const items = activeCollection()
|
||||
const index = items.findIndex((item) => item === paragraph)
|
||||
if (index < 0 || index >= items.length - 1) return
|
||||
const temp = items[index]
|
||||
items[index] = items[index + 1]
|
||||
items[index + 1] = temp
|
||||
normalizeSortIndex()
|
||||
autoSaveParagraphs()
|
||||
}
|
||||
@@ -416,10 +568,37 @@ async function autoSaveParagraphs() {
|
||||
file_note: p.file_note,
|
||||
output_format: p.output_format,
|
||||
}))
|
||||
const blockData = blocks.value.map((block, index) => ({
|
||||
id: block.id,
|
||||
source_paragraph_id: block.source_paragraph_id,
|
||||
parent_block_id: block.parent_block_id,
|
||||
sort_index: index + 1,
|
||||
block_type: block.block_type,
|
||||
anchor_ref: block.anchor_ref,
|
||||
title: block.title,
|
||||
content_json: block.content_json,
|
||||
style_json: block.style_json,
|
||||
edit_mode: block.edit_mode,
|
||||
placeholder_key: block.placeholder_key,
|
||||
variable_key: block.variable_key,
|
||||
default_value: block.default_value,
|
||||
model_id: block.model_id,
|
||||
need_prompt: block.need_prompt,
|
||||
prompt_text: block.prompt_text,
|
||||
need_file: block.need_file,
|
||||
file_note: block.file_note,
|
||||
output_format: block.output_format,
|
||||
}))
|
||||
try {
|
||||
console.log('[autoSave] sending paragraphs:', data.map(p => ({ id: p.id, title: p.title })))
|
||||
await templateApi.saveParagraphs(templateId, { paragraphs: data })
|
||||
const response: any = await templateApi.saveParagraphs(templateId, {
|
||||
save_mode: editorMode.value,
|
||||
paragraphs: data,
|
||||
blocks: editorMode.value === 'manual' ? blockData : [],
|
||||
})
|
||||
store.paragraphs = paragraphs.value as any
|
||||
store.blocks = response.data?.blocks || blocks.value
|
||||
blocks.value = store.blocks as any
|
||||
console.log('[autoSave] save success')
|
||||
} catch (e: any) {
|
||||
console.error('[autoSave] save failed:', e)
|
||||
@@ -428,21 +607,54 @@ async function autoSaveParagraphs() {
|
||||
}
|
||||
|
||||
function insertBlockAfter(sourceParagraph: any, editMode: 'manual' | 'ai') {
|
||||
const index = paragraphs.value.findIndex((item) => item === sourceParagraph)
|
||||
if (editorMode.value === 'paragraph') {
|
||||
const index = paragraphs.value.findIndex((item) => item === sourceParagraph)
|
||||
if (index < 0) return
|
||||
const blockTitle = sourceParagraph.title || sourceParagraph.anchor_title || '未命名段落'
|
||||
const newBlock = {
|
||||
id: -Date.now() - Math.floor(Math.random() * 1000),
|
||||
template_id: sourceParagraph.template_id,
|
||||
sort_index: sourceParagraph.sort_index + 1,
|
||||
anchor_title: sourceParagraph.anchor_title || blockTitle,
|
||||
title: blockTitle,
|
||||
content: '',
|
||||
style_json: sourceParagraph.style_json || '{}',
|
||||
is_table: false,
|
||||
table_json: '{}',
|
||||
edit_mode: editMode,
|
||||
write_mode: 'replace_section',
|
||||
model_id: editMode === 'ai' ? sourceParagraph.model_id ?? null : null,
|
||||
need_prompt: editMode === 'ai',
|
||||
prompt_text: editMode === 'ai' ? sourceParagraph.prompt_text || '' : '',
|
||||
need_file: false,
|
||||
file_note: '',
|
||||
output_format: 'text',
|
||||
}
|
||||
paragraphs.value.splice(index + 1, 0, newBlock)
|
||||
normalizeSortIndex()
|
||||
nextTick(() => {
|
||||
selectedId.value = newBlock.id
|
||||
})
|
||||
return
|
||||
}
|
||||
const index = blocks.value.findIndex((item) => item === sourceParagraph)
|
||||
if (index < 0) return
|
||||
const blockTitle = sourceParagraph.title || sourceParagraph.anchor_title || '未命名段落'
|
||||
const blockTitle = sourceParagraph.title || sourceParagraph.anchor_ref || '未命名块'
|
||||
const newBlock = {
|
||||
id: -Date.now() - Math.floor(Math.random() * 1000),
|
||||
template_id: sourceParagraph.template_id,
|
||||
template_id: Number(route.params.id),
|
||||
source_paragraph_id: sourceParagraph.source_paragraph_id || null,
|
||||
parent_block_id: null,
|
||||
sort_index: sourceParagraph.sort_index + 1,
|
||||
anchor_title: sourceParagraph.anchor_title || blockTitle,
|
||||
block_type: editMode === 'ai' ? 'ai_slot' : 'text',
|
||||
anchor_ref: sourceParagraph.anchor_ref || blockTitle,
|
||||
title: blockTitle,
|
||||
content: '',
|
||||
content_json: { text: '' },
|
||||
style_json: sourceParagraph.style_json || '{}',
|
||||
is_table: false,
|
||||
table_json: '{}',
|
||||
edit_mode: editMode,
|
||||
write_mode: 'replace_section',
|
||||
placeholder_key: '',
|
||||
variable_key: '',
|
||||
default_value: '',
|
||||
model_id: editMode === 'ai' ? sourceParagraph.model_id ?? null : null,
|
||||
need_prompt: editMode === 'ai',
|
||||
prompt_text: editMode === 'ai' ? sourceParagraph.prompt_text || '' : '',
|
||||
@@ -450,7 +662,7 @@ function insertBlockAfter(sourceParagraph: any, editMode: 'manual' | 'ai') {
|
||||
file_note: '',
|
||||
output_format: 'text',
|
||||
}
|
||||
paragraphs.value.splice(index + 1, 0, newBlock)
|
||||
blocks.value.splice(index + 1, 0, newBlock)
|
||||
normalizeSortIndex()
|
||||
nextTick(() => {
|
||||
selectedId.value = newBlock.id
|
||||
@@ -462,16 +674,22 @@ function removeBlock(paragraph: any) {
|
||||
message.warning('至少保留一个段落')
|
||||
return
|
||||
}
|
||||
const index = paragraphs.value.findIndex((item) => item === paragraph)
|
||||
const items = activeCollection()
|
||||
const index = items.findIndex((item) => item === paragraph)
|
||||
if (index < 0) return
|
||||
paragraphs.value.splice(index, 1)
|
||||
items.splice(index, 1)
|
||||
normalizeSortIndex()
|
||||
const next = paragraphs.value[index] || paragraphs.value[index - 1] || paragraphs.value[0]
|
||||
const next = items[index] || items[index - 1] || items[0]
|
||||
selectedId.value = next?.id || 0
|
||||
autoSaveParagraphs()
|
||||
}
|
||||
|
||||
function contentPlaceholder(paragraph: any) {
|
||||
if (editorMode.value === 'manual') {
|
||||
if (paragraph?.block_type === 'heading') return '标题块通常只维护标题文本。'
|
||||
if (paragraph?.block_type === 'variable') return '这里可以填写变量默认值或展示文本。'
|
||||
if (paragraph?.block_type === 'ai_slot') return '这里可填写 AI 块的上下文说明或默认内容。'
|
||||
}
|
||||
if (paragraph?.edit_mode === 'manual') {
|
||||
return paragraph?.write_mode === 'replace_heading_only'
|
||||
? '仅替换标题时,这里的正文仅作为备注保留,不会覆盖原文。'
|
||||
@@ -486,13 +704,13 @@ function contentPlaceholder(paragraph: any) {
|
||||
async function saveTemplate() {
|
||||
const templateId = Number(route.params.id)
|
||||
store.paragraphs = paragraphs.value as any
|
||||
store.blocks = blocks.value as any
|
||||
await store.save(templateId)
|
||||
const template = await store.fetchOne(templateId)
|
||||
templateName.value = template.name
|
||||
paragraphs.value = store.paragraphs as any
|
||||
if (selectedId.value <= 0 && paragraphs.value.length) {
|
||||
selectedId.value = paragraphs.value[0].id
|
||||
}
|
||||
blocks.value = store.blocks as any
|
||||
syncSelectionForMode()
|
||||
message.success('模板配置已保存')
|
||||
}
|
||||
|
||||
@@ -542,8 +760,13 @@ function renderTestResult(content: any) {
|
||||
}
|
||||
|
||||
async function startTest() {
|
||||
if (!selectedPara.value) return
|
||||
if (selectedPara.value.need_file && !testSelectedFiles.value.length) {
|
||||
if (!selectedConfigItem.value) return
|
||||
const paragraphId = editorMode.value === 'paragraph' ? selectedPara.value?.id : selectedBlock.value?.source_paragraph_id
|
||||
if (!paragraphId) {
|
||||
message.warning('当前块还没有绑定可测试的原始段落,请先保存模板后再测试')
|
||||
return
|
||||
}
|
||||
if (selectedConfigItem.value.need_file && !testSelectedFiles.value.length) {
|
||||
message.warning('请先上传至少一个参考文件')
|
||||
return
|
||||
}
|
||||
@@ -557,17 +780,17 @@ async function startTest() {
|
||||
|
||||
testStatusText.value = '正在解析文件内容并请求 AI 模型...'
|
||||
const templateId = Number(route.params.id)
|
||||
const currentModel = models.value.find((item) => item.id === selectedPara.value.model_id)
|
||||
const currentModel = models.value.find((item) => item.id === selectedConfigItem.value.model_id)
|
||||
if (currentModel?.supports_streaming) {
|
||||
testStatusText.value = '正在流式接收模型返回内容...'
|
||||
streamedText.value = ''
|
||||
await runStreamingTest(templateId, filePaths)
|
||||
await runStreamingTest(templateId, filePaths, paragraphId)
|
||||
} else {
|
||||
const response: any = await generateApi.test({
|
||||
paragraph_id: selectedPara.value.id,
|
||||
paragraph_id: paragraphId,
|
||||
template_id: templateId,
|
||||
prompt_text: selectedPara.value.prompt_text || '',
|
||||
model_id: selectedPara.value.model_id || 0,
|
||||
prompt_text: selectedConfigItem.value.prompt_text || '',
|
||||
model_id: selectedConfigItem.value.model_id || 0,
|
||||
file_paths: filePaths,
|
||||
})
|
||||
|
||||
@@ -584,15 +807,15 @@ async function startTest() {
|
||||
}
|
||||
}
|
||||
|
||||
async function runStreamingTest(templateId: number, filePaths: string[]) {
|
||||
async function runStreamingTest(templateId: number, filePaths: string[], paragraphId: number) {
|
||||
const response = await fetch(generateApi.testStream(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
paragraph_id: selectedPara.value.id,
|
||||
paragraph_id: paragraphId,
|
||||
template_id: templateId,
|
||||
prompt_text: selectedPara.value.prompt_text || '',
|
||||
model_id: selectedPara.value.model_id || 0,
|
||||
prompt_text: selectedConfigItem.value.prompt_text || '',
|
||||
model_id: selectedConfigItem.value.model_id || 0,
|
||||
file_paths: filePaths,
|
||||
}),
|
||||
})
|
||||
@@ -646,16 +869,30 @@ async function runStreamingTest(templateId: number, filePaths: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function syncSelectionForMode() {
|
||||
const items = currentItems.value
|
||||
if (!items.length) {
|
||||
selectedId.value = 0
|
||||
return
|
||||
}
|
||||
if (!items.some((item) => item.id === selectedId.value)) {
|
||||
selectedId.value = items[0].id
|
||||
}
|
||||
}
|
||||
|
||||
watch(editorMode, () => {
|
||||
syncSelectionForMode()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const id = Number(route.params.id)
|
||||
const template = await store.fetchOne(id)
|
||||
templateName.value = template.name
|
||||
paragraphs.value = store.paragraphs as any
|
||||
blocks.value = store.blocks as any
|
||||
await modelStore.fetchList()
|
||||
models.value = modelStore.models as any
|
||||
if (paragraphs.value.length) {
|
||||
selectedId.value = paragraphs.value[0].id
|
||||
}
|
||||
syncSelectionForMode()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -799,6 +1036,12 @@ onMounted(async () => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.doc-edit-page-manual {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(252, 248, 239, 0.7), rgba(255, 255, 255, 0) 120px),
|
||||
#fff;
|
||||
}
|
||||
|
||||
.para-block {
|
||||
position: relative;
|
||||
padding: 8px;
|
||||
@@ -868,6 +1111,31 @@ onMounted(async () => {
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid #e7e9ee;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 249, 252, 0.98));
|
||||
}
|
||||
|
||||
.manual-block-heading {
|
||||
border-color: #1f2937;
|
||||
background: linear-gradient(180deg, #f8fafc, #ffffff 42%);
|
||||
}
|
||||
|
||||
.manual-block-text {
|
||||
border-color: #d7dde7;
|
||||
}
|
||||
|
||||
.manual-block-ai {
|
||||
border-color: #5b5bd6;
|
||||
background: linear-gradient(180deg, rgba(238, 238, 251, 0.95), rgba(255, 255, 255, 0.98));
|
||||
}
|
||||
|
||||
.manual-block-variable {
|
||||
border-color: #0f766e;
|
||||
background: linear-gradient(180deg, rgba(236, 253, 245, 0.96), rgba(255, 255, 255, 0.98));
|
||||
}
|
||||
|
||||
.manual-block-table {
|
||||
border-color: #a16207;
|
||||
background: linear-gradient(180deg, rgba(255, 251, 235, 0.96), rgba(255, 255, 255, 0.98));
|
||||
}
|
||||
|
||||
.manual-block-head {
|
||||
@@ -889,6 +1157,31 @@ onMounted(async () => {
|
||||
color: #7b8190;
|
||||
}
|
||||
|
||||
.block-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.block-hero-type {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.block-hero-key {
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.06);
|
||||
font-size: 12px;
|
||||
color: #334155;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
@@ -1024,6 +1317,31 @@ onMounted(async () => {
|
||||
color: #e68a00;
|
||||
}
|
||||
|
||||
.pli-badge.block-heading {
|
||||
background: #e5e7eb;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.pli-badge.block-text {
|
||||
background: #eef2f7;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.pli-badge.block-ai_slot {
|
||||
background: #eeeefb;
|
||||
color: #5b5bd6;
|
||||
}
|
||||
|
||||
.pli-badge.block-variable {
|
||||
background: #dcfce7;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.pli-badge.block-table {
|
||||
background: #fef3c7;
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
.pli-actions {
|
||||
display: none;
|
||||
gap: 2px;
|
||||
|
||||
Reference in New Issue
Block a user