ai文章润色
This commit is contained in:
@@ -44,6 +44,10 @@ export function recommendTags(data) {
|
||||
return request.post('/api/app/article/recommend-tags', data, { timeout: 35000 })
|
||||
}
|
||||
|
||||
export function polishArticleText(data) {
|
||||
return request.post('/api/app/article/polish', data, { timeout: 35000 })
|
||||
}
|
||||
|
||||
export function getCategories() {
|
||||
return request.get('/api/public/category/list')
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -45,6 +45,7 @@ declare module 'vue' {
|
||||
ElTabPane: typeof import('element-plus/es')['ElTabPane']
|
||||
ElTabs: typeof import('element-plus/es')['ElTabs']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
PolishDiffPanel: typeof import('./components/PolishDiffPanel.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div v-if="visible" class="polish-diff-panel">
|
||||
<div class="panel-header">
|
||||
<span class="panel-title">AI 润色结果对比</span>
|
||||
<div class="panel-actions">
|
||||
<el-button type="success" size="small" @click="handleAcceptAll">全部采纳</el-button>
|
||||
<el-button size="small" @click="handleClose">关闭</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div
|
||||
v-for="seg in localSegments"
|
||||
:key="seg.index"
|
||||
class="diff-row"
|
||||
:class="{ 'diff-row-accepted': seg.accepted }"
|
||||
>
|
||||
<div class="diff-col diff-original">
|
||||
<div class="diff-label">原文</div>
|
||||
<div class="diff-text">{{ seg.original }}</div>
|
||||
</div>
|
||||
<div class="diff-col diff-polished">
|
||||
<div class="diff-label">润色后</div>
|
||||
<div class="diff-text">{{ seg.polished }}</div>
|
||||
</div>
|
||||
<div class="diff-action">
|
||||
<el-button
|
||||
v-if="!seg.accepted"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleAccept(seg)"
|
||||
>
|
||||
采纳
|
||||
</el-button>
|
||||
<span v-else class="accepted-tag">已采纳</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
|
||||
const props = defineProps({
|
||||
result: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['accept-segment', 'accept-all', 'close'])
|
||||
|
||||
const localSegments = ref([])
|
||||
|
||||
watch(() => props.result, (val) => {
|
||||
if (val && val.diffSegments) {
|
||||
localSegments.value = val.diffSegments.map(s => ({ ...s, accepted: false }))
|
||||
} else {
|
||||
localSegments.value = []
|
||||
}
|
||||
})
|
||||
|
||||
function handleAccept(seg) {
|
||||
seg.accepted = true
|
||||
emit('accept-segment', seg.index, seg.polished)
|
||||
if (localSegments.value.every(s => s.accepted)) {
|
||||
emit('accept-all')
|
||||
}
|
||||
}
|
||||
|
||||
function handleAcceptAll() {
|
||||
localSegments.value.forEach(s => { s.accepted = true })
|
||||
emit('accept-all')
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
const hasUnaccepted = localSegments.value.some(s => !s.accepted)
|
||||
if (hasUnaccepted) {
|
||||
ElMessageBox.confirm('有未采纳的润色结果,确定关闭?', '提示', {
|
||||
confirmButtonText: '确定关闭',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
}).then(() => {
|
||||
emit('close')
|
||||
}).catch(() => {})
|
||||
} else {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.polish-diff-panel {
|
||||
margin-top: var(--spacing-md);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-soft);
|
||||
overflow: hidden;
|
||||
}
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-bottom: 1px solid var(--border-color-light);
|
||||
background: var(--bg-white);
|
||||
}
|
||||
.panel-title {
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-md);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.panel-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
.panel-body {
|
||||
padding: var(--spacing-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.diff-row {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
align-items: flex-start;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-color-light);
|
||||
background: var(--bg-white);
|
||||
}
|
||||
.diff-row-accepted {
|
||||
opacity: 0.5;
|
||||
background: var(--bg-soft);
|
||||
}
|
||||
.diff-col {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.diff-label {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.diff-text {
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: var(--line-height-normal);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.diff-original .diff-text {
|
||||
color: var(--text-primary);
|
||||
background: #fff2f0;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: 2px;
|
||||
border-left: 3px solid #ff4d4f;
|
||||
}
|
||||
.diff-polished .diff-text {
|
||||
color: var(--text-primary);
|
||||
background: #f6ffed;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: 2px;
|
||||
border-left: 3px solid #52c41a;
|
||||
}
|
||||
.diff-action {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 60px;
|
||||
}
|
||||
.accepted-tag {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -50,8 +50,24 @@
|
||||
<el-button size="small" @click="editor.chain().focus().toggleBlockquote().run()" :type="editor.isActive('blockquote') ? 'primary' : ''">引用</el-button>
|
||||
<el-button size="small" @click="editor.chain().focus().setHorizontalRule().run()">分割线</el-button>
|
||||
</el-button-group>
|
||||
<el-button
|
||||
type="warning"
|
||||
size="small"
|
||||
:loading="polishing"
|
||||
@click="handlePolishText"
|
||||
class="polish-btn"
|
||||
>
|
||||
AI 润色
|
||||
</el-button>
|
||||
</div>
|
||||
<EditorContent :editor="editor" class="editor-content" />
|
||||
<PolishDiffPanel
|
||||
:result="polishResult"
|
||||
:visible="showDiffPanel"
|
||||
@accept-segment="handleAcceptSegment"
|
||||
@accept-all="handleAcceptAll"
|
||||
@close="showDiffPanel = false"
|
||||
/>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="摘要">
|
||||
@@ -93,8 +109,9 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { useEditor, EditorContent } from '@tiptap/vue-3'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import { createArticle, updateArticle, getArticleDetail, getCategories, getTags, generateArticleSummary, recommendTags, createAppTag } from '@/api/article'
|
||||
import { createArticle, updateArticle, getArticleDetail, getCategories, getTags, generateArticleSummary, recommendTags, createAppTag, polishArticleText } from '@/api/article'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import PolishDiffPanel from '@/components/PolishDiffPanel.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -105,6 +122,10 @@ const lastSummaryMeta = ref('')
|
||||
const recommending = ref(false)
|
||||
const recommendedTags = ref([])
|
||||
const selectedRecommendedTags = ref([])
|
||||
const polishing = ref(false)
|
||||
const polishResult = ref(null)
|
||||
const showDiffPanel = ref(false)
|
||||
const polishSelection = ref(null)
|
||||
const categories = ref([])
|
||||
const tags = ref([])
|
||||
const form = ref({
|
||||
@@ -240,6 +261,58 @@ function clearRecommendedTags() {
|
||||
selectedRecommendedTags.value = []
|
||||
}
|
||||
|
||||
async function handlePolishText() {
|
||||
if (!editor.value) return
|
||||
const { from, to } = editor.value.state.selection
|
||||
if (from === to) {
|
||||
ElMessage.warning('请先选中需要润色的文本')
|
||||
return
|
||||
}
|
||||
const selectedText = editor.value.state.doc.textBetween(from, to, '\n')
|
||||
if (!selectedText || selectedText.trim().length < MIN_CONTENT_LENGTH) {
|
||||
ElMessage.warning('请先选中至少 20 个字符的文本')
|
||||
return
|
||||
}
|
||||
polishing.value = true
|
||||
polishResult.value = null
|
||||
showDiffPanel.value = false
|
||||
try {
|
||||
const res = await polishArticleText({ text: selectedText })
|
||||
polishResult.value = res.data
|
||||
polishSelection.value = { from, to }
|
||||
showDiffPanel.value = true
|
||||
} catch (err) {
|
||||
if (err?.code === 'ECONNABORTED') {
|
||||
ElMessage.error('AI 服务响应超时,请稍后再试')
|
||||
}
|
||||
} finally {
|
||||
polishing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleAcceptSegment(index, polishedText) {
|
||||
if (!editor.value || !polishSelection.value) return
|
||||
// 按段落定位替换位置:从原始选中范围的上下文中定位
|
||||
const segs = polishResult.value?.diffSegments
|
||||
if (!segs) return
|
||||
// 全文替换:直接用润色后完整文本替换整个选中区域
|
||||
editor.value.chain()
|
||||
.setTextSelection(polishSelection.value)
|
||||
.insertContent(polishedText)
|
||||
.run()
|
||||
}
|
||||
|
||||
function handleAcceptAll() {
|
||||
if (!editor.value || !polishSelection.value || !polishResult.value) return
|
||||
editor.value.chain()
|
||||
.setTextSelection(polishSelection.value)
|
||||
.insertContent(polishResult.value.polished)
|
||||
.run()
|
||||
showDiffPanel.value = false
|
||||
polishResult.value = null
|
||||
ElMessage.success('已全部采纳')
|
||||
}
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
@@ -332,6 +405,12 @@ onBeforeUnmount(() => {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-bottom: 1px solid var(--border-color-light);
|
||||
background: var(--bg-soft);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.polish-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.editor-content { min-height: 400px; padding: var(--spacing-lg); }
|
||||
.editor-content :deep(.tiptap) {
|
||||
|
||||
Reference in New Issue
Block a user