支持段落删除与移动排序,修复导出残留与外键约束
- 前端:段落列表/配置区/手动编辑区新增上移、下移、删除按钮,hover 显示 - 前端:移动和删除操作后自动保存到后端,修正 canDeleteBlock 判定逻辑 - 后端:保存段落时级联删除关联的 generation_logs 再删段落 - 后端:导出时清理未被引用的标题段落及其内容,避免已删段落残留在 Word 中 - 后端:删除模板时级联清理 generation_logs/documents/paragraphs
This commit is contained in:
@@ -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 ""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user