支持段落删除与移动排序,修复导出残留与外键约束

- 前端:段落列表/配置区/手动编辑区新增上移、下移、删除按钮,hover 显示
- 前端:移动和删除操作后自动保存到后端,修正 canDeleteBlock 判定逻辑
- 后端:保存段落时级联删除关联的 generation_logs 再删段落
- 后端:导出时清理未被引用的标题段落及其内容,避免已删段落残留在 Word 中
- 后端:删除模板时级联清理 generation_logs/documents/paragraphs
This commit is contained in:
zwt13703
2026-07-03 17:13:02 +08:00
parent a796301ae8
commit 1369d87afb
3 changed files with 208 additions and 7 deletions
+133 -5
View File
@@ -33,6 +33,17 @@
<span :class="['pli-badge', paragraph.edit_mode === 'ai' ? 'ai' : 'manual']">
{{ paragraph.edit_mode === 'ai' ? 'AI 生成' : '人工编辑' }}
</span>
<span class="pli-actions" @click.stop>
<a-button type="text" size="small" :disabled="!canMoveUp(paragraph)" @click="moveUp(paragraph)">
<arrow-up-outlined />
</a-button>
<a-button type="text" size="small" :disabled="!canMoveDown(paragraph)" @click="moveDown(paragraph)">
<arrow-down-outlined />
</a-button>
<a-button type="text" size="small" danger :disabled="!canDeleteBlock(paragraph)" @click="handleDeleteParagraph(paragraph)">
<delete-outlined />
</a-button>
</span>
</div>
</div>
</aside>
@@ -61,6 +72,17 @@
<div class="doc-edit-page">
<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>
<a-button type="text" size="small" :disabled="!canMoveUp(paragraph)" @click="moveUp(paragraph)">
<arrow-up-outlined />
</a-button>
<a-button type="text" size="small" :disabled="!canMoveDown(paragraph)" @click="moveDown(paragraph)">
<arrow-down-outlined />
</a-button>
<a-button type="text" size="small" danger :disabled="!canDeleteBlock(paragraph)" @click="handleDeleteParagraph(paragraph)">
<delete-outlined />
</a-button>
</span>
<span :class="['para-tag', paragraph.edit_mode === 'ai' ? 'ai' : 'manual']">
{{ paragraph.edit_mode === 'ai' ? `AI 生成${paragraph.output_format === 'table' ? ' · 表格' : ''}` : '人工编辑' }}
</span>
@@ -103,7 +125,9 @@
<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" danger :disabled="!canDeleteBlock(paragraph)" @click.stop="removeBlock(paragraph)">删除当前块</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>
</div>
<div class="manual-block-meta">
<span class="meta-item">编辑方式{{ paragraph.edit_mode === 'ai' ? 'AI 生成' : '人工编辑' }}</span>
@@ -258,12 +282,15 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue'
import { message, Modal } from 'ant-design-vue'
import {
AlignCenterOutlined,
AlignLeftOutlined,
AlignRightOutlined,
ArrowDownOutlined,
ArrowLeftOutlined,
ArrowUpOutlined,
DeleteOutlined,
FontSizeOutlined,
OrderedListOutlined,
TableOutlined,
@@ -271,6 +298,7 @@ import {
import { useTemplateStore } from '@/stores/template'
import { useModelStore } from '@/stores/model'
import { generateApi } from '@/api/generate'
import { templateApi } from '@/api/template'
import ReferenceFileSelector from '@/components/ReferenceFileSelector.vue'
const route = useRoute()
@@ -320,8 +348,83 @@ function listTitle(paragraph: any) {
return `${paragraph.title || '未命名块'}(归属 ${paragraph.anchor_title}`
}
function canDeleteBlock(paragraph: any) {
return paragraphs.value.filter((item) => item.anchor_title === paragraph.anchor_title).length > 1
function canDeleteBlock(_paragraph: any) {
return paragraphs.value.length > 1
}
function canMoveUp(paragraph: any) {
const index = paragraphs.value.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
}
function moveUp(paragraph: any) {
const index = paragraphs.value.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
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
normalizeSortIndex()
autoSaveParagraphs()
}
function handleDeleteParagraph(paragraph: any) {
if (!canDeleteBlock(paragraph)) {
message.warning('至少保留一个段落')
return
}
Modal.confirm({
title: '确认删除',
content: `确定要删除段落"${paragraph.title || '未命名'}"吗?`,
okText: '确认删除',
okType: 'danger',
cancelText: '取消',
onOk: () => {
removeBlock(paragraph)
},
})
}
async function autoSaveParagraphs() {
const templateId = Number(route.params.id)
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,
}))
try {
console.log('[autoSave] sending paragraphs:', data.map(p => ({ id: p.id, title: p.title })))
await templateApi.saveParagraphs(templateId, { paragraphs: data })
store.paragraphs = paragraphs.value as any
console.log('[autoSave] save success')
} catch (e: any) {
console.error('[autoSave] save failed:', e)
message.error(e?.message || '自动保存失败')
}
}
function insertBlockAfter(sourceParagraph: any, editMode: 'manual' | 'ai') {
@@ -356,7 +459,7 @@ function insertBlockAfter(sourceParagraph: any, editMode: 'manual' | 'ai') {
function removeBlock(paragraph: any) {
if (!canDeleteBlock(paragraph)) {
message.warning('当前节至少保留一个')
message.warning('至少保留一个段落')
return
}
const index = paragraphs.value.findIndex((item) => item === paragraph)
@@ -365,6 +468,7 @@ function removeBlock(paragraph: any) {
normalizeSortIndex()
const next = paragraphs.value[index] || paragraphs.value[index - 1] || paragraphs.value[0]
selectedId.value = next?.id || 0
autoSaveParagraphs()
}
function contentPlaceholder(paragraph: any) {
@@ -381,6 +485,7 @@ function contentPlaceholder(paragraph: any) {
async function saveTemplate() {
const templateId = Number(route.params.id)
store.paragraphs = paragraphs.value as any
await store.save(templateId)
const template = await store.fetchOne(templateId)
templateName.value = template.name
@@ -732,6 +837,19 @@ onMounted(async () => {
color: #e68a00;
}
.para-block-actions {
position: absolute;
right: 56px;
top: 6px;
display: none;
gap: 2px;
}
.para-block:hover .para-block-actions,
.para-block.selected .para-block-actions {
display: inline-flex;
}
.sec-title {
font-size: 16px;
font-weight: 600;
@@ -906,6 +1024,16 @@ onMounted(async () => {
color: #e68a00;
}
.pli-actions {
display: none;
gap: 2px;
flex-shrink: 0;
}
.para-list-item:hover .pli-actions {
display: inline-flex;
}
.test-paragraph-card {
background: #f0f1f3;
border-radius: 8px;