模板在线编辑与导出链路重构

This commit is contained in:
zwt13703
2026-07-05 23:35:00 +08:00
parent 1369d87afb
commit c84ec6aa71
15 changed files with 1434 additions and 166 deletions
+31 -5
View File
@@ -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 }
})
+24
View File
@@ -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
View File
@@ -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;