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

- 前端:段落列表/配置区/手动编辑区新增上移、下移、删除按钮,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
+26 -2
View File
@@ -6,11 +6,13 @@ from datetime import datetime
from io import BytesIO
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from sqlalchemy import func, select
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
from database import get_db
from models.document import Document
from models.generation_log import GenerationLog
from models.paragraph import Paragraph
from models.template import Template
from schemas.schemas import Response, TemplateSave
@@ -194,8 +196,14 @@ async def save_template_paragraphs(
paragraph_map = {item.id: item for item in existing_paragraphs}
incoming_ids = {config.id for config in body.paragraphs if config.id}
print(f"[SAVE] template_id={template_id}, incoming_ids={incoming_ids}, existing_ids={[p.id for p in existing_paragraphs]}")
for paragraph in existing_paragraphs:
if paragraph.id not in incoming_ids:
print(f"[SAVE] Deleting paragraph id={paragraph.id} title={paragraph.title}")
await db.execute(
delete(GenerationLog).where(GenerationLog.paragraph_id == paragraph.id)
)
await db.delete(paragraph)
for index, config in enumerate(body.paragraphs, start=1):
@@ -229,7 +237,23 @@ async def delete_template(template_id: int, db: AsyncSession = Depends(get_db)):
raise HTTPException(status_code=404, detail="模板不存在")
result = await db.execute(select(Paragraph).where(Paragraph.template_id == template_id))
for paragraph in result.scalars().all():
paragraphs_to_delete = result.scalars().all()
paragraph_ids = [p.id for p in paragraphs_to_delete]
doc_result = await db.execute(select(Document).where(Document.template_id == template_id))
documents_to_delete = doc_result.scalars().all()
if paragraph_ids:
await db.execute(
delete(GenerationLog).where(GenerationLog.paragraph_id.in_(paragraph_ids))
)
for document in documents_to_delete:
await db.execute(
delete(GenerationLog).where(GenerationLog.document_id == document.id)
)
await db.delete(document)
for paragraph in paragraphs_to_delete:
await db.delete(paragraph)
file_path = template.file_path or ""
+49
View File
@@ -32,6 +32,43 @@ def _delete_block(block):
parent.remove(element)
def _delete_heading_section(heading: Paragraph):
blocks = [heading]
current = heading._element.getnext()
while current is not None:
if isinstance(current, CT_P):
para = Paragraph(current, heading._parent)
if _is_heading(para):
break
blocks.append(para)
elif isinstance(current, CT_Tbl):
blocks.append(Table(current, heading._parent))
current = current.getnext()
for block in blocks:
_delete_block(block)
def _remove_unreferenced_headings(document: DocumentObject, referenced_anchors: set[str]):
headings_to_remove: list[Paragraph] = []
found_first_heading = False
pre_heading_blocks: list = []
print(f"[EXPORT] referenced_anchors: {referenced_anchors}")
for block in _iter_block_items(document):
if isinstance(block, Paragraph) and _is_heading(block):
found_first_heading = True
text = block.text.strip()
if text not in referenced_anchors:
print(f"[EXPORT] Unreferenced heading found, will remove: '{text}'")
headings_to_remove.append(block)
elif not found_first_heading:
pre_heading_blocks.append(block)
for heading in headings_to_remove:
_delete_heading_section(heading)
if not referenced_anchors:
for block in pre_heading_blocks:
_delete_block(block)
def _clear_paragraph(paragraph: Paragraph):
element = paragraph._element
for child in list(element):
@@ -332,10 +369,22 @@ def _replace_section_group(
def export_document_bytes(template_bytes: bytes, logs: list[dict]) -> bytes:
document = Document(BytesIO(template_bytes))
referenced_anchors: set[str] = set()
for item in logs:
for key in ("anchor_title", "title"):
val = (item.get(key) or "").strip()
if val:
referenced_anchors.add(val)
print(f"[EXPORT] logs count={len(logs)}, anchor_titles={[(l.get('anchor_title'), l.get('title')) for l in logs]}")
last_heading_element = None
for group in _group_logs(logs):
last_heading_element = _replace_section_group(document, group, last_heading_element)
_remove_unreferenced_headings(document, referenced_anchors)
output = BytesIO()
document.save(output)
return output.getvalue()
+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;