支持段落删除与移动排序,修复导出残留与外键约束
- 前端:段落列表/配置区/手动编辑区新增上移、下移、删除按钮,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 io import BytesIO
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from config import settings
|
from config import settings
|
||||||
from database import get_db
|
from database import get_db
|
||||||
|
from models.document import Document
|
||||||
|
from models.generation_log import GenerationLog
|
||||||
from models.paragraph import Paragraph
|
from models.paragraph import Paragraph
|
||||||
from models.template import Template
|
from models.template import Template
|
||||||
from schemas.schemas import Response, TemplateSave
|
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}
|
paragraph_map = {item.id: item for item in existing_paragraphs}
|
||||||
incoming_ids = {config.id for config in body.paragraphs if config.id}
|
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:
|
for paragraph in existing_paragraphs:
|
||||||
if paragraph.id not in incoming_ids:
|
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)
|
await db.delete(paragraph)
|
||||||
|
|
||||||
for index, config in enumerate(body.paragraphs, start=1):
|
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="模板不存在")
|
raise HTTPException(status_code=404, detail="模板不存在")
|
||||||
|
|
||||||
result = await db.execute(select(Paragraph).where(Paragraph.template_id == template_id))
|
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)
|
await db.delete(paragraph)
|
||||||
|
|
||||||
file_path = template.file_path or ""
|
file_path = template.file_path or ""
|
||||||
|
|||||||
@@ -32,6 +32,43 @@ def _delete_block(block):
|
|||||||
parent.remove(element)
|
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):
|
def _clear_paragraph(paragraph: Paragraph):
|
||||||
element = paragraph._element
|
element = paragraph._element
|
||||||
for child in list(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:
|
def export_document_bytes(template_bytes: bytes, logs: list[dict]) -> bytes:
|
||||||
document = Document(BytesIO(template_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
|
last_heading_element = None
|
||||||
for group in _group_logs(logs):
|
for group in _group_logs(logs):
|
||||||
last_heading_element = _replace_section_group(document, group, last_heading_element)
|
last_heading_element = _replace_section_group(document, group, last_heading_element)
|
||||||
|
|
||||||
|
_remove_unreferenced_headings(document, referenced_anchors)
|
||||||
|
|
||||||
output = BytesIO()
|
output = BytesIO()
|
||||||
document.save(output)
|
document.save(output)
|
||||||
return output.getvalue()
|
return output.getvalue()
|
||||||
|
|||||||
@@ -33,6 +33,17 @@
|
|||||||
<span :class="['pli-badge', paragraph.edit_mode === 'ai' ? 'ai' : 'manual']">
|
<span :class="['pli-badge', paragraph.edit_mode === 'ai' ? 'ai' : 'manual']">
|
||||||
{{ paragraph.edit_mode === 'ai' ? 'AI 生成' : '人工编辑' }}
|
{{ paragraph.edit_mode === 'ai' ? 'AI 生成' : '人工编辑' }}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -61,6 +72,17 @@
|
|||||||
<div class="doc-edit-page">
|
<div class="doc-edit-page">
|
||||||
<template v-if="editorMode === 'paragraph'">
|
<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)">
|
<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']">
|
<span :class="['para-tag', paragraph.edit_mode === 'ai' ? 'ai' : 'manual']">
|
||||||
{{ paragraph.edit_mode === 'ai' ? `AI 生成${paragraph.output_format === 'table' ? ' · 表格' : ''}` : '人工编辑' }}
|
{{ paragraph.edit_mode === 'ai' ? `AI 生成${paragraph.output_format === 'table' ? ' · 表格' : ''}` : '人工编辑' }}
|
||||||
</span>
|
</span>
|
||||||
@@ -103,7 +125,9 @@
|
|||||||
<div class="manual-block-actions">
|
<div class="manual-block-actions">
|
||||||
<a-button size="small" @click.stop="insertBlockAfter(paragraph, 'manual')">在后面新增固定块</a-button>
|
<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" 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>
|
||||||
<div class="manual-block-meta">
|
<div class="manual-block-meta">
|
||||||
<span class="meta-item">编辑方式:{{ paragraph.edit_mode === 'ai' ? 'AI 生成' : '人工编辑' }}</span>
|
<span class="meta-item">编辑方式:{{ paragraph.edit_mode === 'ai' ? 'AI 生成' : '人工编辑' }}</span>
|
||||||
@@ -258,12 +282,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { message } from 'ant-design-vue'
|
import { message, Modal } from 'ant-design-vue'
|
||||||
import {
|
import {
|
||||||
AlignCenterOutlined,
|
AlignCenterOutlined,
|
||||||
AlignLeftOutlined,
|
AlignLeftOutlined,
|
||||||
AlignRightOutlined,
|
AlignRightOutlined,
|
||||||
|
ArrowDownOutlined,
|
||||||
ArrowLeftOutlined,
|
ArrowLeftOutlined,
|
||||||
|
ArrowUpOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
FontSizeOutlined,
|
FontSizeOutlined,
|
||||||
OrderedListOutlined,
|
OrderedListOutlined,
|
||||||
TableOutlined,
|
TableOutlined,
|
||||||
@@ -271,6 +298,7 @@ import {
|
|||||||
import { useTemplateStore } from '@/stores/template'
|
import { useTemplateStore } from '@/stores/template'
|
||||||
import { useModelStore } from '@/stores/model'
|
import { useModelStore } from '@/stores/model'
|
||||||
import { generateApi } from '@/api/generate'
|
import { generateApi } from '@/api/generate'
|
||||||
|
import { templateApi } from '@/api/template'
|
||||||
import ReferenceFileSelector from '@/components/ReferenceFileSelector.vue'
|
import ReferenceFileSelector from '@/components/ReferenceFileSelector.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -320,8 +348,83 @@ function listTitle(paragraph: any) {
|
|||||||
return `${paragraph.title || '未命名块'}(归属 ${paragraph.anchor_title})`
|
return `${paragraph.title || '未命名块'}(归属 ${paragraph.anchor_title})`
|
||||||
}
|
}
|
||||||
|
|
||||||
function canDeleteBlock(paragraph: any) {
|
function canDeleteBlock(_paragraph: any) {
|
||||||
return paragraphs.value.filter((item) => item.anchor_title === paragraph.anchor_title).length > 1
|
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') {
|
function insertBlockAfter(sourceParagraph: any, editMode: 'manual' | 'ai') {
|
||||||
@@ -356,7 +459,7 @@ function insertBlockAfter(sourceParagraph: any, editMode: 'manual' | 'ai') {
|
|||||||
|
|
||||||
function removeBlock(paragraph: any) {
|
function removeBlock(paragraph: any) {
|
||||||
if (!canDeleteBlock(paragraph)) {
|
if (!canDeleteBlock(paragraph)) {
|
||||||
message.warning('当前节至少保留一个块')
|
message.warning('至少保留一个段落')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const index = paragraphs.value.findIndex((item) => item === paragraph)
|
const index = paragraphs.value.findIndex((item) => item === paragraph)
|
||||||
@@ -365,6 +468,7 @@ function removeBlock(paragraph: any) {
|
|||||||
normalizeSortIndex()
|
normalizeSortIndex()
|
||||||
const next = paragraphs.value[index] || paragraphs.value[index - 1] || paragraphs.value[0]
|
const next = paragraphs.value[index] || paragraphs.value[index - 1] || paragraphs.value[0]
|
||||||
selectedId.value = next?.id || 0
|
selectedId.value = next?.id || 0
|
||||||
|
autoSaveParagraphs()
|
||||||
}
|
}
|
||||||
|
|
||||||
function contentPlaceholder(paragraph: any) {
|
function contentPlaceholder(paragraph: any) {
|
||||||
@@ -381,6 +485,7 @@ function contentPlaceholder(paragraph: any) {
|
|||||||
|
|
||||||
async function saveTemplate() {
|
async function saveTemplate() {
|
||||||
const templateId = Number(route.params.id)
|
const templateId = Number(route.params.id)
|
||||||
|
store.paragraphs = paragraphs.value as any
|
||||||
await store.save(templateId)
|
await store.save(templateId)
|
||||||
const template = await store.fetchOne(templateId)
|
const template = await store.fetchOne(templateId)
|
||||||
templateName.value = template.name
|
templateName.value = template.name
|
||||||
@@ -732,6 +837,19 @@ onMounted(async () => {
|
|||||||
color: #e68a00;
|
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 {
|
.sec-title {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -906,6 +1024,16 @@ onMounted(async () => {
|
|||||||
color: #e68a00;
|
color: #e68a00;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pli-actions {
|
||||||
|
display: none;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.para-list-item:hover .pli-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
.test-paragraph-card {
|
.test-paragraph-card {
|
||||||
background: #f0f1f3;
|
background: #f0f1f3;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
|
|||||||
Reference in New Issue
Block a user