kuaifan 8ddc507bd5 feat: 添加 AI 助手生成任务功能
- 在 ProjectController 中新增 ai_generate 接口,支持根据用户输入生成任务标题和详细描述
- 在 AI 模块中实现 generateTask 方法,处理任务生成逻辑
- 更新前端 TaskAdd 组件,添加 AI 生成按钮,集成任务生成请求
- 优化 TEditor 和 TEditorTask 组件,支持设置内容格式
- 增强样式以提升用户体验
2025-09-23 13:11:33 +08:00

350 lines
11 KiB
Vue
Executable File

<template>
<div class="task-editor" @click="onClickWrap" @touchstart="onTouchstart">
<TEditor
ref="desc"
v-model="content"
:plugins="plugins"
:options="options"
:option-full="optionFull"
:placeholder="placeholder"
:placeholderFull="placeholderFull"
:readOnly="windowTouch"
:readOnlyFull="false"
:readOnlyImagePreview="false"
@on-blur="onBlur"
@on-editor-init="onEditorInit"
@on-transfer-change="onTransferChange"
inline/>
<div class="task-editor-operate" :style="operateStyles" v-show="operateVisible">
<Dropdown
trigger="custom"
:visible="operateVisible"
placement="bottom-start"
@on-clickoutside="operateVisible = false"
transfer>
<div :style="{userSelect:operateVisible ? 'none' : 'auto', height: operateStyles.height}"></div>
<DropdownMenu slot="list">
<DropdownItem v-if="operateMenu.checked" @click.native="onLiPreview">{{ $L(operateMenu.checked === 'checked' ? '标记未选' : '标记已选') }}</DropdownItem>
<DropdownItem v-if="operateMenu.link" @click.native="onLinkPreview">{{ $L('打开链接') }}</DropdownItem>
<DropdownItem v-if="operateMenu.img" @click.native="onImagePreview">{{ $L('查看图片') }}</DropdownItem>
<DropdownItem @click.native="onEditing">{{ $L('编辑描述') }}</DropdownItem>
<DropdownItem v-if="operateMenu.history" @click.native="onHistory">{{ $L('历史记录') }}</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
</div>
</template>
<style lang="scss" scoped>
.task-editor {
position: relative;
word-break: break-all;
::v-deep .mce-content-body,
::v-deep .task-editor-content {
line-height: 1.6;
}
::v-deep p {
margin: 0.3em 0;
}
::v-deep blockquote,
::v-deep pre,
::v-deep ul,
::v-deep ol {
margin: 1em 0;
}
::v-deep ul,
::v-deep ol {
margin-left: 1.5em;
padding-left: 1.5em;
}
::v-deep li {
margin: 0.25em 0;
}
::v-deep h1 {margin: 0.67em 0;}
::v-deep h2 {margin: 0.83em 0;}
::v-deep h3 {margin: 1em 0;}
::v-deep h4 {margin: 1.33em 0;}
::v-deep h5 {margin: 1.67em 0;}
::v-deep h6 {margin: 2.33em 0;}
.task-editor-operate {
position: absolute;
top: 0;
left: 0;
width: 1px;
opacity: 0;
visibility: hidden;
pointer-events: none;
}
}
</style>
<script>
import TEditor from "./TEditor.vue";
export default {
name: 'TEditorTask',
components: {TEditor},
props: {
value: {
default: ''
},
placeholder: {
default: ''
},
placeholderFull: {
default: ''
},
},
data() {
return {
content: this.value,
plugins: [
'advlist autolink lists checklist link image charmap print preview hr anchor pagebreak',
'searchreplace visualblocks visualchars code',
'insertdatetime media nonbreaking save table directionality',
'emoticons paste codesample',
'autoresize'
],
options: {
statusbar: false,
menubar: false,
autoresize_bottom_margin: 2,
min_height: 200,
max_height: 380,
contextmenu: 'checklist | bold italic underline forecolor backcolor | link | uploadImages imagePreview | history screenload',
valid_elements: 'a[href|title|target=_blank],em,strong/b,div[align],span[style],a,br,p,h1,h2,h3,h4,h5,h6,img[src|alt|width],pre[class],code,ol[class],ul[class],li[class]',
extended_valid_elements: 'a[href|title|target=_blank]',
toolbar: false
},
optionFull: {
menubar: 'file edit view',
removed_menuitems: 'preview,print',
contextmenu: 'checklist | bold italic underline forecolor backcolor | link | uploadImages imagePreview | screenload',
valid_elements: 'a[href|title|target=_blank],em,strong/b,div[align],span[style],a,br,p,h1,h2,h3,h4,h5,h6,img[src|alt|width],pre[class],code,ol[class],ul[class],li[class]',
extended_valid_elements: 'a[href|title|target=_blank]',
toolbar: 'uploadImages | checklist | bullist numlist | formatselect | bold italic underline | forecolor backcolor',
mobile: {
menubar: 'file edit view',
},
},
operateStyles: {},
operateVisible: false,
operateHiddenTime: 0,
operateMenu: {
target: null,
checked: null,
link: null,
img: null,
history: true
},
listener: null,
};
},
mounted() {
const containsName = this.windowPortrait ? "task-detail" : "ivu-modal-wrap";
let parent = this.$parent.$el.parentNode;
while (parent) {
if (parent.classList?.contains(containsName)) {
this.listener = parent;
this.listener.addEventListener("scroll", this.onTouchstart);
break;
}
parent = parent.parentNode;
}
this.operateMenu.history = typeof this.$listeners['on-history'] === "function";
},
beforeDestroy() {
this.listener?.removeEventListener("scroll", this.onTouchstart);
},
computed: {
editor() {
return this.$refs.desc.editor;
},
},
watch: {
value(val) {
this.content = val;
},
content(val) {
this.$emit('input', val);
},
operateVisible(val) {
if (!val) {
this.operateHiddenTime = Date.now();
}
},
},
methods: {
getContent() {
return this.$refs.desc.getContent();
},
updateContent(html) {
this.content = html
},
setContent(html, args = {}) {
this.$refs.desc.setContent(html, args);
},
onEditing() {
this.$refs.desc.onFull()
},
onHistory() {
this.$emit('on-history');
},
onBlur() {
this.$emit('on-blur');
},
onEditorInit(editor) {
this.updateTouchContent();
this.updateHistoryContent(editor);
this.$emit('on-editor-init', editor);
},
onTransferChange(visible) {
if (visible) {
return
}
if (!this.windowTouch) {
return
}
setTimeout(_ => {
this.updateTouchContent();
this.onBlur();
}, 100);
},
onClickWrap(event) {
if (!this.windowTouch) {
return
}
if (Date.now() - this.operateHiddenTime < 350) {
return;
}
event.stopPropagation()
this.operateVisible = false;
this.operateMenu.target = event.target;
this.operateMenu.checked = null;
if (event.target.tagName === "LI" && event.target.parentNode.classList.contains("tox-checklist")) {
this.operateMenu.checked = event.target.classList.contains("tox-checklist--checked") ? 'checked' : 'unchecked';
}
this.operateMenu.link = event.target.tagName === "A" ? event.target.href : null;
this.operateMenu.img = event.target.tagName === "IMG" ? event.target.src : null;
this.$nextTick(() => {
const rect = this.$el.getBoundingClientRect();
this.operateStyles = {
left: `${event.clientX - rect.left}px`,
top: `${event.clientY - rect.top}px`,
}
this.operateVisible = true;
})
},
onTouchstart() {
if (!this.windowTouch) {
return
}
this.operateVisible = false;
},
updateTouchContent() {
if (!this.windowTouch) {
return
}
this.$nextTick(_ => {
if (!this.editor) {
return;
}
if (this.content) {
this.editor.bodyElement.removeAttribute("data-mce-placeholder");
this.editor.bodyElement.removeAttribute("aria-placeholder");
} else {
this.editor.bodyElement.setAttribute("data-mce-placeholder", this.placeholder);
this.editor.bodyElement.setAttribute("aria-placeholder", this.placeholder);
}
this.updateTouchLink(0);
})
},
updateTouchLink(timeout) {
if (!this.windowTouch) {
return
}
setTimeout(_ => {
if (!this.editor) {
return;
}
this.editor.bodyElement.querySelectorAll("a").forEach(item => {
if (item.__dataMceClick !== true) {
item.__dataMceClick = true;
item.addEventListener("click", event => {
event.preventDefault();
event.stopPropagation();
this.onClickWrap(event);
})
}
})
if (timeout < 300) {
this.updateTouchLink(timeout + 100);
}
}, timeout)
},
updateHistoryContent(editor) {
editor.ui.registry.addMenuItem('history', {
icon: 'insert-time',
text: this.$L('历史记录'),
onAction: () => {
this.onHistory();
}
});
},
onLiPreview() {
if (!this.operateMenu.checked) {
return;
}
if (this.operateMenu.checked === 'checked') {
this.operateMenu.target.classList.remove("tox-checklist--checked");
} else {
this.operateMenu.target.classList.add("tox-checklist--checked");
}
this.$emit('on-blur', 'force');
},
onLinkPreview() {
if (this.operateMenu.link) {
window.open(this.operateMenu.link);
}
},
onImagePreview() {
const array = this.$refs.desc.getValueImages();
if (array.length === 0) {
$A.messageWarning("没有可预览的图片")
return;
}
this.$store.dispatch("previewImage", {index: this.operateMenu.img, list: array})
},
}
}
</script>