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