ai文章润色

This commit is contained in:
nojava
2026-05-23 18:48:35 +08:00
parent 9a43c611b9
commit 8b23d02531
13 changed files with 1755 additions and 1 deletions
+4
View File
@@ -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')
}
+1
View File
@@ -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>
+80 -1
View File
@@ -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) {