mirror of
https://github.com/201206030/novel-front-web.git
synced 2025-04-27 07:50:50 +00:00
feat: 实现AI写作功能
This commit is contained in:
parent
e680672048
commit
a59704ae42
@ -24,6 +24,19 @@ export function publishChapter(bookId,params) {
|
|||||||
return request.post(`/author/book/chapter/${bookId}`, params);
|
return request.post(`/author/book/chapter/${bookId}`, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function aiGenerate(action,params) {
|
||||||
|
const formData = new FormData();
|
||||||
|
Object.keys(params).forEach(key => {
|
||||||
|
formData.append(key, params[key]);
|
||||||
|
});
|
||||||
|
return request.post(`/author/ai/${action}`, formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
timeout: 60000
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function deleteChapter(id) {
|
export function deleteChapter(id) {
|
||||||
return request.delete(`/author/book/chapter/${id}`);
|
return request.delete(`/author/book/chapter/${id}`);
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,7 @@ import App from './App.vue'
|
|||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import '@/assets/styles/base.css'
|
import '@/assets/styles/base.css'
|
||||||
import '@/assets/styles/main.css'
|
import '@/assets/styles/main.css'
|
||||||
|
import { Loading } from '@element-plus/icons-vue'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
@ -13,3 +14,5 @@ app.use(ElementPlus)
|
|||||||
app.use(router)
|
app.use(router)
|
||||||
|
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|
||||||
|
app.component('Loading', Loading)
|
||||||
|
@ -34,21 +34,97 @@
|
|||||||
</li>
|
</li>
|
||||||
<b>章节内容:</b>
|
<b>章节内容:</b>
|
||||||
<li id="contentLi">
|
<li id="contentLi">
|
||||||
|
<div class="ai-toolbar">
|
||||||
|
<el-button
|
||||||
|
v-for="btn in aiButtons"
|
||||||
|
:key="btn.action"
|
||||||
|
:type="btn.type"
|
||||||
|
:disabled="!hasSelection || generating"
|
||||||
|
@click="openDialog(btn.action)"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
{{ btn.label }}
|
||||||
|
<el-icon v-if="generating" class="is-loading">
|
||||||
|
<Loading />
|
||||||
|
</el-icon>
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<!-- 参数输入对话框 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="dialogVisible"
|
||||||
|
:title="dialogTitle"
|
||||||
|
width="30%"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
currentAction === 'expand' ||
|
||||||
|
currentAction === 'condense'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<el-input
|
||||||
|
v-model.number="ratio"
|
||||||
|
type="number"
|
||||||
|
:placeholder="`请输入${
|
||||||
|
currentAction === 'expand' ? '扩写' : '缩写'
|
||||||
|
}比例(1-200%)`"
|
||||||
|
min="1"
|
||||||
|
max="200"
|
||||||
|
>
|
||||||
|
<template #append>%</template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="currentAction === 'continue'">
|
||||||
|
<el-input
|
||||||
|
v-model.number="length"
|
||||||
|
type="number"
|
||||||
|
placeholder="请输入续写长度(50-1000字)"
|
||||||
|
min="50"
|
||||||
|
max="1000"
|
||||||
|
>
|
||||||
|
<template #append>字</template>
|
||||||
|
</el-input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false"
|
||||||
|
>取消</el-button
|
||||||
|
>
|
||||||
|
<el-button type="primary" @click="confirmParams"
|
||||||
|
>确定</el-button
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
|
ref="editor"
|
||||||
v-model="chapter.chapterContent"
|
v-model="chapter.chapterContent"
|
||||||
name="bookContent"
|
name="bookContent"
|
||||||
rows="30"
|
rows="30"
|
||||||
cols="80"
|
cols="80"
|
||||||
id="bookContent"
|
id="bookContent"
|
||||||
class="textarea"
|
class="textarea"
|
||||||
|
@mouseup="checkSelection"
|
||||||
|
@keyup="checkSelection"
|
||||||
></textarea>
|
></textarea>
|
||||||
</li>
|
</li>
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
<b>是否收费:</b>
|
<b>是否收费:</b>
|
||||||
<li>
|
<li>
|
||||||
<input v-model="chapter.isVip" type="radio" name="isVip" value="0" checked="" />免费
|
<input
|
||||||
<input v-model="chapter.isVip" type="radio" name="isVip" value="1" />收费
|
v-model="chapter.isVip"
|
||||||
|
type="radio"
|
||||||
|
name="isVip"
|
||||||
|
value="0"
|
||||||
|
checked=""
|
||||||
|
/>免费
|
||||||
|
<input
|
||||||
|
v-model="chapter.isVip"
|
||||||
|
type="radio"
|
||||||
|
name="isVip"
|
||||||
|
value="1"
|
||||||
|
/>收费
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li style="margin-top: 10px">
|
<li style="margin-top: 10px">
|
||||||
@ -104,12 +180,11 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import "@/assets/styles/book.css";
|
import "@/assets/styles/book.css";
|
||||||
import { reactive, toRefs, onMounted, ref } from "vue";
|
import { reactive, toRefs, computed, ref } from "vue";
|
||||||
import { useRouter, useRoute } from "vue-router";
|
import { useRouter, useRoute } from "vue-router";
|
||||||
import { ElMessage} from "element-plus";
|
import { ElMessage} from "element-plus";
|
||||||
import { publishChapter } from "@/api/author";
|
import { publishChapter, aiGenerate } from "@/api/author";
|
||||||
import AuthorHeader from "@/components/author/Header.vue";
|
import AuthorHeader from "@/components/author/Header.vue";
|
||||||
import picUpload from "@/assets/images/pic_upload.png";
|
|
||||||
export default {
|
export default {
|
||||||
name: "authorChapterAdd",
|
name: "authorChapterAdd",
|
||||||
components: {
|
components: {
|
||||||
@ -118,12 +193,145 @@ export default {
|
|||||||
setup() {
|
setup() {
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const editor = ref(null);
|
||||||
|
|
||||||
const state = reactive({
|
const state = reactive({
|
||||||
bookId: route.query.id,
|
bookId: route.query.id,
|
||||||
chapter: { chapterName: "", chapterContent: "", isVip: 0 },
|
chapter: { chapterName: "", chapterContent: "", isVip: 0 },
|
||||||
|
hasSelection: false,
|
||||||
|
generating: false,
|
||||||
|
selectedText: "",
|
||||||
|
aiButtons: [
|
||||||
|
{ label: "AI扩写", action: "expand", type: "primary" },
|
||||||
|
{ label: "AI缩写", action: "condense", type: "success" },
|
||||||
|
{ label: "AI续写", action: "continue", type: "warning" },
|
||||||
|
{ label: "AI润色", action: "polish", type: "danger" },
|
||||||
|
],
|
||||||
|
dialogVisible: false,
|
||||||
|
currentAction: '',
|
||||||
|
ratio: 30, // 默认扩写/缩写比例
|
||||||
|
length: 200, // 默认续写长度
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const dialogTitle = computed(() => {
|
||||||
|
const map = {
|
||||||
|
expand: '扩写设置',
|
||||||
|
condense: '缩写设置',
|
||||||
|
continue: '续写设置',
|
||||||
|
polish: '润色设置'
|
||||||
|
}
|
||||||
|
return map[state.currentAction]
|
||||||
|
})
|
||||||
|
|
||||||
|
const openDialog = (action) => {
|
||||||
|
state.currentAction = action
|
||||||
|
// 润色不需要参数
|
||||||
|
if (action === 'polish') {
|
||||||
|
handleAI(action)
|
||||||
|
} else {
|
||||||
|
state.dialogVisible = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateParams = () => {
|
||||||
|
if (state.currentAction === 'expand') {
|
||||||
|
if (!state.ratio || state.ratio < 110 || state.ratio > 200) {
|
||||||
|
ElMessage.error('请输入110-200%之间的比例')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.currentAction === 'condense') {
|
||||||
|
if (!state.ratio || state.ratio < 1 || state.ratio > 99) {
|
||||||
|
ElMessage.error('请输入1-99%之间的比例')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.currentAction === 'continue') {
|
||||||
|
if (!state.length || state.length < 50 || state.length > 1000) {
|
||||||
|
ElMessage.error('请输入50-1000字之间的长度')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmParams = async () => {
|
||||||
|
if (!validateParams()) return
|
||||||
|
|
||||||
|
state.dialogVisible = false
|
||||||
|
await handleAI(state.currentAction)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getActionName = (action) => {
|
||||||
|
return {
|
||||||
|
expand: `扩写(${state.ratio}%)`,
|
||||||
|
condense: `缩写(${state.ratio}%)`,
|
||||||
|
continue: `续写(${state.length}字)`,
|
||||||
|
polish: '润色'
|
||||||
|
}[action]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const checkSelection = () => {
|
||||||
|
const textarea = editor.value;
|
||||||
|
if (textarea) {
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
state.hasSelection = start !== end;
|
||||||
|
if (state.hasSelection) {
|
||||||
|
state.selectedText = textarea.value.substring(start, end);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const typewriterEffect = (text) => {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let index = 0;
|
||||||
|
const typing = setInterval(() => {
|
||||||
|
if (index < text.length) {
|
||||||
|
state.chapter.chapterContent += text.charAt(index);
|
||||||
|
index++;
|
||||||
|
// 自动滚动到底部
|
||||||
|
editor.scrollTop = editor.scrollHeight;
|
||||||
|
} else {
|
||||||
|
clearInterval(typing);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}, 20);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAI = async (action) => {
|
||||||
|
|
||||||
|
try {
|
||||||
|
state.generating = true
|
||||||
|
|
||||||
|
const params = {
|
||||||
|
text: state.selectedText
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加参数
|
||||||
|
if (action === 'expand' || action === 'condense') {
|
||||||
|
params.ratio = state.ratio
|
||||||
|
}
|
||||||
|
if (action === 'continue') {
|
||||||
|
params.length = state.length
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await aiGenerate(action,params)
|
||||||
|
|
||||||
|
// 在原有内容后追加生成内容,并实现打字效果
|
||||||
|
const newContent = `\n\n【AI生成内容】${response.data}`;
|
||||||
|
state.hasSelection = false;
|
||||||
|
state.selectedText = '';
|
||||||
|
await typewriterEffect(newContent);
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error("AI生成失败:" + error.message);
|
||||||
|
} finally {
|
||||||
|
state.generating = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const saveChapter = async () => {
|
const saveChapter = async () => {
|
||||||
console.log("sate=========", state.chapter);
|
console.log("sate=========", state.chapter);
|
||||||
if (!state.chapter.chapterName) {
|
if (!state.chapter.chapterName) {
|
||||||
@ -141,12 +349,19 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await publishChapter(state.bookId, state.chapter);
|
await publishChapter(state.bookId, state.chapter);
|
||||||
router.push({ name: "authorChapterList", query:{'id':state.bookId} });
|
router.push({ name: "authorChapterList", query: { id: state.bookId } });
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...toRefs(state),
|
...toRefs(state),
|
||||||
|
editor,
|
||||||
|
checkSelection,
|
||||||
|
handleAI,
|
||||||
saveChapter,
|
saveChapter,
|
||||||
|
dialogTitle,
|
||||||
|
openDialog,
|
||||||
|
confirmParams,
|
||||||
|
getActionName
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -744,4 +959,28 @@ a.redBtn:hover {
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 新增AI工具栏样式 */
|
||||||
|
.ai-toolbar {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
width: 500px;
|
||||||
|
}
|
||||||
|
.ai-toolbar .el-button {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
position: relative;
|
||||||
|
font-family: "Microsoft YaHei", sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-toolbar .el-input {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.el-dialog__body) {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user