mirror of
https://github.com/kuaifan/dootask.git
synced 2026-04-15 00:50:07 +00:00
- Added session management capabilities to the AI Assistant, allowing users to create, load, and delete sessions. - Improved modal UI with a new header for session actions and a footer for model selection. - Updated input handling to support dynamic loading of session data and improved response formatting. - Enhanced search functionality in various components to utilize the AI Assistant for generating content based on user input.
701 lines
26 KiB
Vue
Executable File
701 lines
26 KiB
Vue
Executable File
<template>
|
|
<ModalAlive
|
|
v-model="showModal"
|
|
class-name="common-search-box-modal"
|
|
:closable="!isFullscreen"
|
|
:fullscreen="isFullscreen"
|
|
:mask-closable="false"
|
|
:footer-hide="true"
|
|
width="768">
|
|
|
|
<div class="search-header">
|
|
<div class="search-input">
|
|
<div class="search-pre">
|
|
<Loading v-if="loadIng > 0"/>
|
|
<Icon v-else type="ios-search" />
|
|
</div>
|
|
<Form class="search-form" action="javascript:void(0)" @submit.native.prevent="$A.eeuiAppKeyboardHide">
|
|
<Input type="search" ref="searchKey" v-model="searchKey" :placeholder="$L('请输入关键字')"/>
|
|
</Form>
|
|
<div v-if="aiSearchAvailable" class="search-ai" :class="{active: aiSearch}" @click="toggleAiSearch">
|
|
<i class="taskfont"></i>
|
|
<span>{{ $L('AI 搜索') }}</span>
|
|
</div>
|
|
</div>
|
|
<div class="search-close" @click="onHide">
|
|
<i class="taskfont"></i>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="search-body" @touchstart="onTouchstart">
|
|
<div class="search-tags">
|
|
<div
|
|
v-for="tag in tags"
|
|
:key="tag.type"
|
|
class="tag-item"
|
|
:class="{action: tag.type === action}"
|
|
@click="onTag(tag.type, $event)">
|
|
<i class="taskfont" v-html="tag.icon"></i>
|
|
<span>{{$L(tag.name)}}</span>
|
|
<i v-if="tag.type === action" class="taskfont tag-close"></i>
|
|
</div>
|
|
</div>
|
|
<template v-if="total === 0">
|
|
<div v-if="(loadIng + loadPre) > 0 || !searchKey.trim()" class="search-empty">
|
|
<i class="taskfont"></i>
|
|
<span>{{ $L((loadIng + loadPre) > 0 ? '正在拼命搜索...' : '请输入关键字搜索') }}</span>
|
|
</div>
|
|
<div v-else class="search-empty">
|
|
<i class="taskfont"></i>
|
|
<span class="empty-label">{{ $L('暂无相关结果') }}</span>
|
|
<span>{{ $L('未搜到跟「(*)」相关的结果', searchKey) }}</span>
|
|
</div>
|
|
</template>
|
|
<div v-else class="search-list">
|
|
<ul v-for="data in list" :key="data.type">
|
|
<li v-if="!action" class="item-label">{{$L(data.name)}}</li>
|
|
<li v-for="item in data.items" @click="onClick(item)">
|
|
<div class="item-icon">
|
|
<div v-if="item.icons[0]==='file'" :class="`no-dark-content file-icon ${item.icons[1]}`"></div>
|
|
<i v-else-if="item.icons[0]==='department'" class="taskfont icon-avatar department"></i>
|
|
<i v-else-if="item.icons[0]==='project'" class="taskfont icon-avatar project"></i>
|
|
<i v-else-if="item.icons[0]==='task'" class="taskfont icon-avatar task"></i>
|
|
<UserAvatar v-else-if="item.icons[0]==='user'" class="user-avatar" :userid="item.icons[1]" :size="38"/>
|
|
<EAvatar v-else-if="item.icons[0]==='avatar'" class="img-avatar" :src="item.icons[1]" :size="38"/>
|
|
<Icon v-else-if="item.icons[0]==='people'" class="icon-avatar" type="ios-people" />
|
|
<Icon v-else class="icon-avatar" type="md-person" />
|
|
</div>
|
|
<div class="item-content">
|
|
<div class="item-title">
|
|
<div class="title-text" v-html="transformEmojiToHtml(item.title)"></div>
|
|
<div
|
|
v-if="item.activity"
|
|
class="title-activity"
|
|
:title="item.activity">{{activityFormat(item.activity)}}</div>
|
|
</div>
|
|
<div class="item-desc">
|
|
<span
|
|
class="desc-tag"
|
|
v-if="item.tags"
|
|
v-for="tag in item.tags"
|
|
:style="tag.style">{{tag.name}}</span>
|
|
<span class="desc-text" v-html="transformEmojiToHtml(item.desc)"></span>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
</ModalAlive>
|
|
</template>
|
|
|
|
<script>
|
|
import {mapState, mapGetters} from "vuex";
|
|
import emitter from "../store/events";
|
|
import transformEmojiToHtml from "../utils/emoji";
|
|
|
|
export default {
|
|
name: 'SearchBox',
|
|
props: {
|
|
//
|
|
},
|
|
|
|
data() {
|
|
return {
|
|
loadPre: 0,
|
|
loadIng: 0,
|
|
|
|
searchKey: '',
|
|
searchResults: [],
|
|
searchTimer: null,
|
|
|
|
showModal: false,
|
|
|
|
tags: [
|
|
{type: 'task', name: '任务', icon: ''},
|
|
{type: 'project', name: '项目', icon: ''},
|
|
{type: 'message', name: '消息', icon: ''},
|
|
{type: 'contact', name: '联系人', icon: ''},
|
|
{type: 'file', name: '文件', icon: ''},
|
|
],
|
|
action: '',
|
|
|
|
aiSearch: false,
|
|
}
|
|
},
|
|
|
|
mounted() {
|
|
emitter.on('openSearch', this.onShow);
|
|
},
|
|
|
|
beforeDestroy() {
|
|
emitter.off('openSearch', this.onShow);
|
|
},
|
|
|
|
watch: {
|
|
searchKey() {
|
|
this.preSearch()
|
|
},
|
|
|
|
action() {
|
|
this.preSearch()
|
|
},
|
|
|
|
showModal(v) {
|
|
$A.eeuiAppSetScrollDisabled(v)
|
|
}
|
|
},
|
|
|
|
computed: {
|
|
...mapState([
|
|
'themeName',
|
|
'keyboardShow',
|
|
'microAppsIds'
|
|
]),
|
|
|
|
aiSearchAvailable() {
|
|
return this.microAppsIds
|
|
&& this.microAppsIds.includes('seekdb')
|
|
&& this.microAppsIds.includes('ai')
|
|
},
|
|
|
|
isFullscreen({windowWidth}) {
|
|
return windowWidth < 576
|
|
},
|
|
|
|
items({searchKey, searchResults, action}) {
|
|
return searchResults.filter(item => item.key === searchKey && (!action || item.type === action))
|
|
},
|
|
|
|
total() {
|
|
return this.items.length
|
|
},
|
|
|
|
list({action, tags}) {
|
|
const groups = new Map();
|
|
const maxItems = action ? Infinity : 10;
|
|
|
|
for (let i = 0; i < this.items.length; i++) {
|
|
const item = this.items[i];
|
|
const type = item.type;
|
|
if (!groups.has(type)) {
|
|
groups.set(type, []);
|
|
}
|
|
const group = groups.get(type);
|
|
if (group.length < maxItems) {
|
|
group.push(item);
|
|
}
|
|
}
|
|
|
|
return tags.reduce((result, tag) => {
|
|
if (groups.has(tag.type)) {
|
|
result.push({
|
|
...tag,
|
|
items: groups.get(tag.type)
|
|
});
|
|
}
|
|
return result;
|
|
}, []);
|
|
},
|
|
},
|
|
|
|
methods: {
|
|
transformEmojiToHtml,
|
|
activityFormat(date) {
|
|
const local = $A.daytz(),
|
|
time = $A.dayjs(date);
|
|
if (local.format("YYYY/MM/DD") === time.format("YYYY/MM/DD")) {
|
|
return time.format("HH:mm")
|
|
}
|
|
if (local.year() === time.year()) {
|
|
return time.format("MM/DD")
|
|
}
|
|
return time.format("YYYY/MM/DD") || '';
|
|
},
|
|
|
|
onClick(item) {
|
|
switch (item.type) {
|
|
case 'task':
|
|
this.$store.dispatch('openTask', item.rawData)
|
|
this.onHide()
|
|
break;
|
|
|
|
case 'project':
|
|
if (item.rawData.archived_at) {
|
|
$A.modalWarning("项目已归档,无法查看")
|
|
return
|
|
}
|
|
this.goForward({name: 'manage-project', params: {projectId: item.id}})
|
|
this.onHide()
|
|
break;
|
|
|
|
case 'message':
|
|
this.$store.dispatch("openDialog", item.id).then(_ => {
|
|
this.$store.state.dialogSearchMsgId = /^\d+$/.test(item.rawData.search_msg_id) ? item.rawData.search_msg_id : 0
|
|
if (this.routeName === 'manage-messenger') {
|
|
this.onHide()
|
|
}
|
|
}).catch(({msg}) => {
|
|
$A.modalError(msg || this.$L('打开会话失败'))
|
|
})
|
|
break;
|
|
|
|
case 'contact':
|
|
this.$store.dispatch("openDialogUserid", item.id).then(_ => {
|
|
if (this.routeName === 'manage-messenger') {
|
|
this.onHide()
|
|
}
|
|
}).catch(({msg}) => {
|
|
$A.modalError(msg || this.$L('打开会话失败'))
|
|
});
|
|
break;
|
|
|
|
case 'file':
|
|
this.goForward({name: 'manage-file', params: {folderId: item.rawData.pid, fileId: null, shakeId: item.id}})
|
|
this.$store.state.fileShakeId = item.id
|
|
setTimeout(() => {
|
|
this.$store.state.fileShakeId = 0
|
|
}, 600)
|
|
this.onHide()
|
|
break;
|
|
}
|
|
},
|
|
|
|
onTouchstart() {
|
|
$A.eeuiAppKeyboardHide();
|
|
},
|
|
|
|
onTag(type, e) {
|
|
this.action = this.action !== type ? type : '';
|
|
$A.scrollToView(e.target, {
|
|
block: 'nearest',
|
|
inline: 'nearest',
|
|
behavior: 'smooth'
|
|
})
|
|
},
|
|
|
|
onShow() {
|
|
const autoFocus = this.total === 0 || this.showModal || !this.windowTouch // 这几种情况下显示完后自动获取焦点(无展示结果、现在已经显示了、不是触摸设备)
|
|
this.showModal = true
|
|
autoFocus && this.$nextTick(() => {
|
|
const $el = this.$refs.searchKey?.$refs?.input;
|
|
if ($el) {
|
|
$el.style.caretColor = 'transparent';
|
|
$el.focus()
|
|
setTimeout(() => {
|
|
const len = $el.value.length;
|
|
$el.setSelectionRange(len, len);
|
|
$el.style.caretColor = null
|
|
}, 300)
|
|
}
|
|
})
|
|
},
|
|
|
|
onHide() {
|
|
this.showModal = false
|
|
},
|
|
|
|
preSearch() {
|
|
if (!this.searchKey.trim()) {
|
|
return;
|
|
}
|
|
if (this.searchTimer) {
|
|
clearTimeout(this.searchTimer)
|
|
this.searchTimer = null;
|
|
this.loadPre--;
|
|
}
|
|
this.loadPre++;
|
|
this.searchTimer = setTimeout(() => {
|
|
if (this.searchKey.trim()) {
|
|
this.onSearch()
|
|
}
|
|
this.searchTimer = null;
|
|
this.loadPre--;
|
|
}, 500)
|
|
},
|
|
|
|
onSearch() {
|
|
if (this.action) {
|
|
this.distSearch(this.action)
|
|
return;
|
|
}
|
|
this.tags.forEach(({type}) => this.distSearch(type))
|
|
},
|
|
|
|
distSearch(type) {
|
|
const func = this[`search${type.charAt(0).toUpperCase()}${type.slice(1)}`]
|
|
if (typeof func === 'function') {
|
|
func(this.searchKey)
|
|
return true
|
|
}
|
|
return false
|
|
},
|
|
|
|
echoSearch(items, key = 'id') {
|
|
items.forEach(item => {
|
|
const index = this.searchResults.findIndex(result => {
|
|
return result[key] === item[key] && result.type === item.type
|
|
})
|
|
if (index > -1) {
|
|
this.searchResults.splice(index, 1, item)
|
|
} else {
|
|
this.searchResults.push(item)
|
|
}
|
|
})
|
|
},
|
|
|
|
searchTask(key) {
|
|
this.loadIng++;
|
|
// 如果开启了 AI 搜索,使用新的 AI 搜索接口
|
|
const useAiSearch = this.aiSearchAvailable && this.aiSearch;
|
|
const requestConfig = useAiSearch ? {
|
|
url: 'search/task',
|
|
data: {
|
|
key,
|
|
search_type: 'hybrid',
|
|
take: this.action ? 50 : 10,
|
|
},
|
|
} : {
|
|
url: 'project/task/lists',
|
|
data: {
|
|
keys: {name: key},
|
|
archived: 'all',
|
|
scope: 'all_project',
|
|
pagesize: this.action ? 50 : 10,
|
|
},
|
|
};
|
|
this.$store.dispatch("call", requestConfig).then(({data}) => {
|
|
const nowTime = $A.dayjs().unix()
|
|
const rawData = useAiSearch ? data : data.data;
|
|
const items = rawData.map(item => {
|
|
const tags = [];
|
|
// AI 搜索标记
|
|
if (useAiSearch && item.content_preview) {
|
|
tags.push({
|
|
name: 'AI',
|
|
style: 'background-color:#4F46E5',
|
|
})
|
|
}
|
|
if (item.complete_at) {
|
|
tags.push({
|
|
name: this.$L('已完成'),
|
|
style: 'background-color:#0bc037',
|
|
})
|
|
} else if (item.overdue) {
|
|
tags.push({
|
|
name: this.$L('超期'),
|
|
style: 'background-color:#f00',
|
|
})
|
|
} else if (item.end_at && $A.dayjs(item.end_at).unix() - nowTime < 86400) {
|
|
tags.push({
|
|
name: this.$L('即将到期'),
|
|
style: 'background-color:#f80',
|
|
})
|
|
}
|
|
if (item.archived_at) {
|
|
tags.push({
|
|
name: this.$L('已归档'),
|
|
style: 'background-color:#ccc',
|
|
})
|
|
}
|
|
return {
|
|
key,
|
|
type: 'task',
|
|
icons: ['task', null],
|
|
tags,
|
|
|
|
id: item.id,
|
|
title: item.name,
|
|
desc: item.content_preview ? this.truncateContent(item.content_preview) : item.desc,
|
|
activity: item.end_at,
|
|
|
|
rawData: item,
|
|
};
|
|
});
|
|
this.echoSearch(items)
|
|
}).finally(_ => {
|
|
this.loadIng--;
|
|
})
|
|
},
|
|
|
|
searchProject(key) {
|
|
this.loadIng++;
|
|
// 如果开启了 AI 搜索,使用新的 AI 搜索接口
|
|
const useAiSearch = this.aiSearchAvailable && this.aiSearch;
|
|
const requestConfig = useAiSearch ? {
|
|
url: 'search/project',
|
|
data: {
|
|
key,
|
|
search_type: 'hybrid',
|
|
take: this.action ? 50 : 10,
|
|
},
|
|
} : {
|
|
url: 'project/lists',
|
|
data: {
|
|
keys: {
|
|
name: key
|
|
},
|
|
archived: 'all',
|
|
pagesize: this.action ? 50 : 10,
|
|
},
|
|
};
|
|
this.$store.dispatch("call", requestConfig).then(({data}) => {
|
|
const rawData = useAiSearch ? data : data.data;
|
|
const items = rawData.map(item => {
|
|
const tags = [];
|
|
// AI 搜索标记
|
|
if (useAiSearch && item.desc_preview) {
|
|
tags.push({
|
|
name: 'AI',
|
|
style: 'background-color:#4F46E5',
|
|
})
|
|
}
|
|
if (item.owner) {
|
|
tags.push({
|
|
name: this.$L('负责人'),
|
|
style: 'background-color:#0bc037',
|
|
})
|
|
}
|
|
if (item.archived_at) {
|
|
tags.push({
|
|
name: this.$L('已归档'),
|
|
style: 'background-color:#ccc',
|
|
})
|
|
}
|
|
return {
|
|
key,
|
|
type: 'project',
|
|
icons: ['project', null],
|
|
tags,
|
|
|
|
id: item.id,
|
|
title: item.name,
|
|
desc: item.desc_preview ? this.truncateContent(item.desc_preview) : (item.desc || ''),
|
|
activity: item.updated_at,
|
|
|
|
rawData: item,
|
|
};
|
|
})
|
|
this.echoSearch(items)
|
|
}).finally(_ => {
|
|
this.loadIng--;
|
|
})
|
|
},
|
|
|
|
searchMessage(key) {
|
|
this.loadIng++;
|
|
this.$store.dispatch("call", {
|
|
url: 'dialog/msg/search',
|
|
data: {
|
|
key,
|
|
take: this.action ? 50 : 10,
|
|
},
|
|
}).then(({data}) => {
|
|
const items = data.data.map(item => {
|
|
let icon = 'person';
|
|
let desc = null;
|
|
if (item.type == 'group') {
|
|
if (item.avatar) {
|
|
icon = 'avatar';
|
|
desc = item.avatar;
|
|
} else if (item.group_type == 'department') {
|
|
icon = 'department';
|
|
} else if (item.group_type == 'project') {
|
|
icon = 'project';
|
|
} else if (['task', 'okr'].includes(item.group_type)) {
|
|
icon = 'task';
|
|
} else {
|
|
icon = 'people';
|
|
}
|
|
} else if (item.dialog_user) {
|
|
icon = 'user';
|
|
desc = item.dialog_user.userid
|
|
}
|
|
return {
|
|
key,
|
|
type: 'message',
|
|
icons: [icon, desc],
|
|
tags: [],
|
|
|
|
id: item.id,
|
|
title: item.name,
|
|
desc: $A.getMsgSimpleDesc(item.last_msg),
|
|
activity: item.last_at,
|
|
|
|
searchMsgId: item.search_msg_id,
|
|
rawData: item,
|
|
};
|
|
})
|
|
this.echoSearch(items, 'searchMsgId')
|
|
}).finally(_ => {
|
|
this.loadIng--;
|
|
})
|
|
},
|
|
|
|
searchContact(key) {
|
|
this.loadIng++;
|
|
// 如果开启了 AI 搜索,使用新的 AI 搜索接口
|
|
const useAiSearch = this.aiSearchAvailable && this.aiSearch;
|
|
const requestConfig = useAiSearch ? {
|
|
url: 'search/contact',
|
|
data: {
|
|
key,
|
|
search_type: 'hybrid',
|
|
take: this.action ? 50 : 10,
|
|
},
|
|
} : {
|
|
url: 'users/search',
|
|
data: {
|
|
keys: {key},
|
|
pagesize: this.action ? 50 : 10,
|
|
},
|
|
};
|
|
this.$store.dispatch("call", requestConfig).then(({data}) => {
|
|
const items = data.map(item => {
|
|
const tags = [];
|
|
// AI 搜索标记
|
|
if (useAiSearch && item.introduction_preview) {
|
|
tags.push({
|
|
name: 'AI',
|
|
style: 'background-color:#4F46E5',
|
|
})
|
|
}
|
|
return {
|
|
key,
|
|
type: 'contact',
|
|
icons: ['user', item.userid],
|
|
tags,
|
|
|
|
id: item.userid,
|
|
title: item.nickname,
|
|
desc: item.introduction_preview
|
|
? this.truncateContent(item.introduction_preview)
|
|
: (item.profession || ''),
|
|
activity: item.line_at,
|
|
|
|
rawData: item,
|
|
};
|
|
})
|
|
this.echoSearch(items)
|
|
}).finally(_ => {
|
|
this.loadIng--;
|
|
})
|
|
},
|
|
|
|
searchFile(key) {
|
|
this.loadIng++;
|
|
// 如果开启了 AI 搜索,使用统一的 AI 搜索接口
|
|
const useAiSearch = this.aiSearchAvailable && this.aiSearch;
|
|
const requestConfig = useAiSearch ? {
|
|
url: 'search/file',
|
|
data: {
|
|
key,
|
|
search_type: 'hybrid',
|
|
take: this.action ? 50 : 10,
|
|
},
|
|
} : {
|
|
url: 'file/search',
|
|
data: {
|
|
key,
|
|
take: this.action ? 50 : 10,
|
|
},
|
|
};
|
|
this.$store.dispatch("call", requestConfig).then(({data}) => {
|
|
const items = data.map(item => {
|
|
const tags = [];
|
|
if (item.share) {
|
|
tags.push({
|
|
name: this.$L(item.userid == this.userId ? '已共享' : '共享'),
|
|
style: 'background-color:#0bc037',
|
|
})
|
|
}
|
|
// AI 搜索标记
|
|
if (useAiSearch && item.content_preview) {
|
|
tags.push({
|
|
name: 'AI',
|
|
style: 'background-color:#4F46E5',
|
|
})
|
|
}
|
|
return {
|
|
key,
|
|
type: 'file',
|
|
icons: ['file', item.type],
|
|
tags,
|
|
|
|
id: item.id,
|
|
title: item.name,
|
|
desc: item.content_preview
|
|
? this.truncateContent(item.content_preview)
|
|
: (item.type === 'folder' ? '' : $A.bytesToSize(item.size)),
|
|
activity: item.updated_at,
|
|
|
|
rawData: item,
|
|
};
|
|
})
|
|
this.echoSearch(items)
|
|
}).finally(_ => {
|
|
this.loadIng--;
|
|
})
|
|
},
|
|
|
|
truncateContent(content) {
|
|
if (!content) return '';
|
|
const maxLen = 100;
|
|
const text = content.replace(/\s+/g, ' ').trim();
|
|
return text.length > maxLen ? text.substring(0, maxLen) + '...' : text;
|
|
},
|
|
|
|
toggleAiSearch() {
|
|
const keyword = this.searchKey.trim();
|
|
emitter.emit('openAIAssistant', {
|
|
sessionKey: 'ai-search',
|
|
resumeSession: 300,
|
|
title: this.$L('AI 搜索'),
|
|
value: keyword,
|
|
placeholder: this.$L('请描述你想搜索的内容...'),
|
|
loadingText: this.$L('搜索中...'),
|
|
showApplyButton: false,
|
|
autoSubmit: !!keyword,
|
|
onBeforeSend: this.handleAISearchBeforeSend,
|
|
});
|
|
this.onHide();
|
|
},
|
|
|
|
handleAISearchBeforeSend(context = []) {
|
|
const systemPrompt = [
|
|
'你是一个智能搜索助手,负责帮助用户在 DooTask 系统中搜索和整理信息。',
|
|
'你可以使用 intelligent_search 工具来搜索任务、项目、文件和联系人。',
|
|
'',
|
|
'请根据用户的搜索需求:',
|
|
'1. 调用搜索工具获取相关结果',
|
|
'2. 对搜索结果进行分类整理',
|
|
'3. 以清晰的格式呈现给用户',
|
|
'4. 如有需要,可以进行多次搜索以获取更全面的结果',
|
|
'',
|
|
'## 链接格式要求',
|
|
'在返回结果时,请使用以下格式创建可点击的链接:',
|
|
'- 任务: [任务名称](dootask://task/任务ID)',
|
|
'- 项目: [项目名称](dootask://project/项目ID)',
|
|
'- 文件: [文件名称](dootask://file/文件ID)',
|
|
'- 联系人: [联系人名称](dootask://contact/用户ID)',
|
|
'',
|
|
'示例:',
|
|
'- [完成项目报告](dootask://task/123)',
|
|
'- [产品开发项目](dootask://project/456)',
|
|
].join('\n');
|
|
|
|
const prepared = [
|
|
['system', systemPrompt]
|
|
];
|
|
|
|
if (context.length > 0) {
|
|
prepared.push(...context);
|
|
}
|
|
|
|
return prepared;
|
|
}
|
|
}
|
|
};
|
|
</script>
|