chore: 提交剩余开发改动
包含商务通消息映射、会话时间与未读状态、前端消息展示、数据库修复迁移、测试覆盖备份及 Captain 设计文档。
This commit is contained in:
@@ -3,6 +3,17 @@ const MINUTE_IN_MILLI_SECONDS = 60000;
|
||||
const HOUR_IN_MILLI_SECONDS = MINUTE_IN_MILLI_SECONDS * 60;
|
||||
const DAY_IN_MILLI_SECONDS = HOUR_IN_MILLI_SECONDS * 24;
|
||||
|
||||
const chineseShortTimestamp = timestamp => {
|
||||
if (timestamp === 'now') return '刚刚';
|
||||
return timestamp
|
||||
.replace(/^(\d+)mo$/, '$1个月')
|
||||
.replace(/^(\d+)m$/, '$1分钟')
|
||||
.replace(/^(\d+)h$/, '$1小时')
|
||||
.replace(/^(\d+)d$/, '$1天')
|
||||
.replace(/^(\d+)y$/, '$1年');
|
||||
};
|
||||
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
import {
|
||||
dynamicTime,
|
||||
dateFormat,
|
||||
@@ -37,34 +48,49 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isChineseLocale() {
|
||||
return this.$i18n.locale === 'zh_CN';
|
||||
},
|
||||
lastActivityTime() {
|
||||
return shortTimestamp(this.lastActivityAtTimeAgo);
|
||||
const timestamp = shortTimestamp(this.lastActivityAtTimeAgo);
|
||||
return this.isChineseLocale ? chineseShortTimestamp(timestamp) : timestamp;
|
||||
},
|
||||
createdAtTime() {
|
||||
return shortTimestamp(this.createdAtTimeAgo);
|
||||
const timestamp = shortTimestamp(this.createdAtTimeAgo);
|
||||
return this.isChineseLocale ? chineseShortTimestamp(timestamp) : timestamp;
|
||||
},
|
||||
createdAt() {
|
||||
const createdTimeDiff = Date.now() - this.createdAtTimestamp * 1000;
|
||||
const isBeforeAMonth = createdTimeDiff > DAY_IN_MILLI_SECONDS * 30;
|
||||
const relativeTime = this.isChineseLocale
|
||||
? dynamicTime(this.createdAtTimestamp, { locale: zhCN })
|
||||
: this.createdAtTimeAgo;
|
||||
const absoluteTime = this.isChineseLocale
|
||||
? dateFormat(this.createdAtTimestamp, 'yyyy年M月d日', { locale: zhCN })
|
||||
: dateFormat(this.createdAtTimestamp);
|
||||
return !isBeforeAMonth
|
||||
? `${this.$t('CHAT_LIST.CHAT_TIME_STAMP.CREATED.LATEST')} ${
|
||||
this.createdAtTimeAgo
|
||||
relativeTime
|
||||
}`
|
||||
: `${this.$t('CHAT_LIST.CHAT_TIME_STAMP.CREATED.OLDEST')} ${dateFormat(
|
||||
this.createdAtTimestamp
|
||||
)}`;
|
||||
: `${this.$t('CHAT_LIST.CHAT_TIME_STAMP.CREATED.OLDEST')} ${absoluteTime}`;
|
||||
},
|
||||
lastActivity() {
|
||||
const lastActivityTimeDiff =
|
||||
Date.now() - this.lastActivityTimestamp * 1000;
|
||||
const isNotActive = lastActivityTimeDiff > DAY_IN_MILLI_SECONDS * 30;
|
||||
const relativeTime = this.isChineseLocale
|
||||
? dynamicTime(this.lastActivityTimestamp, { locale: zhCN })
|
||||
: this.lastActivityAtTimeAgo;
|
||||
const absoluteTime = this.isChineseLocale
|
||||
? dateFormat(this.lastActivityTimestamp, 'yyyy年M月d日', { locale: zhCN })
|
||||
: dateFormat(this.lastActivityTimestamp);
|
||||
return !isNotActive
|
||||
? `${this.$t('CHAT_LIST.CHAT_TIME_STAMP.LAST_ACTIVITY.ACTIVE')} ${
|
||||
this.lastActivityAtTimeAgo
|
||||
relativeTime
|
||||
}`
|
||||
: `${this.$t(
|
||||
'CHAT_LIST.CHAT_TIME_STAMP.LAST_ACTIVITY.NOT_ACTIVE'
|
||||
)} ${dateFormat(this.lastActivityTimestamp)}`;
|
||||
)} ${absoluteTime}`;
|
||||
},
|
||||
tooltipText() {
|
||||
return `${this.createdAt}
|
||||
|
||||
+185
-176
@@ -13,23 +13,23 @@ import VoiceCallStatus from './VoiceCallStatus.vue';
|
||||
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
|
||||
|
||||
const props = defineProps({
|
||||
chat: { type: Object, required: true },
|
||||
currentContact: { type: Object, required: true },
|
||||
assignee: { type: Object, default: () => ({}) },
|
||||
inbox: { type: Object, default: () => ({}) },
|
||||
selected: { type: Boolean, default: false },
|
||||
isActiveChat: { type: Boolean, default: false },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
showInboxName: { type: Boolean, default: false },
|
||||
hideThumbnail: { type: Boolean, default: false },
|
||||
compact: { type: Boolean, default: false },
|
||||
chat: { type: Object, required: true },
|
||||
currentContact: { type: Object, required: true },
|
||||
assignee: { type: Object, default: () => ({}) },
|
||||
inbox: { type: Object, default: () => ({}) },
|
||||
selected: { type: Boolean, default: false },
|
||||
isActiveChat: { type: Boolean, default: false },
|
||||
showAssignee: { type: Boolean, default: false },
|
||||
showInboxName: { type: Boolean, default: false },
|
||||
hideThumbnail: { type: Boolean, default: false },
|
||||
compact: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'click',
|
||||
'contextmenu',
|
||||
'selectConversation',
|
||||
'deSelectConversation',
|
||||
'click',
|
||||
'contextmenu',
|
||||
'selectConversation',
|
||||
'deSelectConversation',
|
||||
]);
|
||||
|
||||
const hovered = ref(false);
|
||||
@@ -39,196 +39,205 @@ const hasUnread = computed(() => unreadCount.value > 0);
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
|
||||
const voiceCallData = computed(() => {
|
||||
const last = lastMessageInChat.value;
|
||||
if (last?.content_type !== 'voice_call' || !last.call) {
|
||||
return { status: null, direction: null };
|
||||
}
|
||||
return {
|
||||
status: last.call.status,
|
||||
direction: last.call.direction === 'outgoing' ? 'outbound' : 'inbound',
|
||||
};
|
||||
const last = lastMessageInChat.value;
|
||||
if (last?.content_type !== 'voice_call' || !last.call) {
|
||||
return { status: null, direction: null };
|
||||
}
|
||||
return {
|
||||
status: last.call.status,
|
||||
direction: last.call.direction === 'outgoing' ? 'outbound' : 'inbound',
|
||||
};
|
||||
});
|
||||
|
||||
const showMetaSection = computed(() => {
|
||||
return (
|
||||
props.showInboxName ||
|
||||
(props.showAssignee && props.assignee.name) ||
|
||||
props.chat.priority
|
||||
);
|
||||
return (
|
||||
props.showInboxName ||
|
||||
(props.showAssignee && props.assignee.name) ||
|
||||
props.chat.priority
|
||||
);
|
||||
});
|
||||
|
||||
const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id);
|
||||
|
||||
const showLabelsSection = computed(() => {
|
||||
return props.chat.labels?.length > 0 || hasSlaPolicyId.value;
|
||||
return props.chat.labels?.length > 0 || hasSlaPolicyId.value;
|
||||
});
|
||||
|
||||
const messagePreviewClass = computed(() => {
|
||||
return [
|
||||
hasUnread.value ? 'font-medium text-n-slate-12' : 'text-n-slate-11',
|
||||
!props.compact && hasUnread.value ? 'ltr:pr-4 rtl:pl-4' : '',
|
||||
props.compact && hasUnread.value ? 'ltr:pr-6 rtl:pl-6' : '',
|
||||
];
|
||||
return [
|
||||
hasUnread.value ? 'font-medium text-n-slate-12' : 'text-n-slate-11',
|
||||
!props.compact && hasUnread.value ? 'ltr:pr-4 rtl:pl-4' : '',
|
||||
props.compact && hasUnread.value ? 'ltr:pr-6 rtl:pl-6' : '',
|
||||
];
|
||||
});
|
||||
|
||||
const onThumbnailHover = () => {
|
||||
hovered.value = !props.hideThumbnail;
|
||||
hovered.value = !props.hideThumbnail;
|
||||
};
|
||||
|
||||
const onThumbnailLeave = () => {
|
||||
hovered.value = false;
|
||||
hovered.value = false;
|
||||
};
|
||||
|
||||
const onSelectConversation = checked => {
|
||||
if (checked) {
|
||||
emit('selectConversation', props.chat.id, props.inbox.id);
|
||||
} else {
|
||||
emit('deSelectConversation', props.chat.id, props.inbox.id);
|
||||
}
|
||||
if (checked) {
|
||||
emit('selectConversation', props.chat.id, props.inbox.id);
|
||||
} else {
|
||||
emit('deSelectConversation', props.chat.id, props.inbox.id);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedModel = computed({
|
||||
get: () => props.selected,
|
||||
set: value => onSelectConversation(value),
|
||||
get: () => props.selected,
|
||||
set: value => onSelectConversation(value),
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.chat.id,
|
||||
() => {
|
||||
hovered.value = false;
|
||||
}
|
||||
() => props.chat.id,
|
||||
() => {
|
||||
hovered.value = false;
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full py-0 cursor-pointer conversation border-b border-n-slate-3 hover:border-n-surface-1 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group hover:z-[1] before:content-[none] before:absolute before:-top-px before:inset-x-0 before:h-px before:bg-n-surface-1 before:pointer-events-none hover:before:content-['']"
|
||||
:class="{
|
||||
'active animate-card-select bg-n-background !border-n-surface-1':
|
||||
isActiveChat,
|
||||
'selected bg-n-slate-2 !border-n-surface-1': selected,
|
||||
'px-0': compact,
|
||||
'px-3': !compact,
|
||||
}"
|
||||
@click="$emit('click', $event)"
|
||||
@contextmenu="$emit('contextmenu', $event)"
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
@mouseenter="onThumbnailHover"
|
||||
@mouseleave="onThumbnailLeave"
|
||||
>
|
||||
<Avatar
|
||||
v-if="!hideThumbnail"
|
||||
:name="currentContact.name"
|
||||
:src="currentContact.thumbnail"
|
||||
:size="32"
|
||||
:status="currentContact.availability_status"
|
||||
:class="!showInboxName ? 'mt-4' : 'mt-8'"
|
||||
hide-offline-status
|
||||
>
|
||||
<template #overlay="{ size }">
|
||||
<label
|
||||
v-if="hovered || selected"
|
||||
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px]"
|
||||
:style="{ width: `${size}px`, height: `${size}px` }"
|
||||
@click.stop
|
||||
>
|
||||
<Checkbox v-model="selectedModel" />
|
||||
</label>
|
||||
</template>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div class="px-0 py-3 flex-1 min-w-0 border-line">
|
||||
<div
|
||||
v-if="showMetaSection"
|
||||
class="flex items-center min-w-0 gap-1"
|
||||
:class="{
|
||||
'ltr:ml-2 rtl:mr-2': !compact,
|
||||
'mx-2': compact,
|
||||
}"
|
||||
>
|
||||
<InboxName v-if="showInboxName" :inbox="inbox" class="flex-1 min-w-0" />
|
||||
<div
|
||||
class="flex items-baseline gap-2 flex-shrink-0"
|
||||
:class="{
|
||||
'flex-1 justify-between': !showInboxName,
|
||||
}"
|
||||
>
|
||||
<span
|
||||
v-if="showAssignee && assignee.name"
|
||||
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex items-center truncate"
|
||||
>
|
||||
<fluent-icon icon="person" size="12" class="text-n-slate-11" />
|
||||
{{ assignee.name }}
|
||||
</span>
|
||||
<CardPriorityIcon
|
||||
:priority="chat.priority"
|
||||
class="flex-shrink-0 !size-3.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h4
|
||||
class="conversation--user text-sm my-0 mx-2 capitalize pt-0.5 text-ellipsis overflow-hidden whitespace-nowrap flex-1 min-w-0 ltr:pr-16 rtl:pl-16 text-n-slate-12"
|
||||
:class="hasUnread ? 'font-semibold' : 'font-medium'"
|
||||
>
|
||||
{{ currentContact.name }}
|
||||
</h4>
|
||||
<VoiceCallStatus
|
||||
v-if="voiceCallData.status"
|
||||
key="voice-status-row"
|
||||
:status="voiceCallData.status"
|
||||
:direction="voiceCallData.direction"
|
||||
:message-preview-class="messagePreviewClass"
|
||||
/>
|
||||
<MessagePreview
|
||||
v-else-if="lastMessageInChat"
|
||||
key="message-preview"
|
||||
:message="lastMessageInChat"
|
||||
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm"
|
||||
:class="messagePreviewClass"
|
||||
/>
|
||||
<p
|
||||
v-else
|
||||
key="no-messages"
|
||||
class="text-n-slate-11 text-sm my-0 mx-2 leading-6 h-6 flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
:class="messagePreviewClass"
|
||||
>
|
||||
<fluent-icon
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle inline-block text-n-slate-10"
|
||||
icon="info"
|
||||
/>
|
||||
<span class="mx-0.5">
|
||||
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
|
||||
</span>
|
||||
</p>
|
||||
<div
|
||||
class="absolute flex flex-col ltr:right-3 rtl:left-3"
|
||||
:class="showMetaSection ? 'top-8' : 'top-4'"
|
||||
>
|
||||
<span class="ml-auto font-normal leading-4 text-xxs">
|
||||
<TimeAgo
|
||||
:last-activity-timestamp="chat.timestamp"
|
||||
:created-at-timestamp="chat.created_at"
|
||||
:conversation-id="chat.id"
|
||||
/>
|
||||
</span>
|
||||
<UnreadBadge
|
||||
v-if="hasUnread"
|
||||
:count="unreadCount"
|
||||
class="ltr:ml-auto rtl:mr-auto mt-1"
|
||||
/>
|
||||
</div>
|
||||
<CardLabels
|
||||
v-if="showLabelsSection"
|
||||
:conversation-labels="chat.labels"
|
||||
class="mt-0.5 mx-2 mb-0"
|
||||
>
|
||||
<template v-if="hasSlaPolicyId" #before>
|
||||
<SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
|
||||
</template>
|
||||
</CardLabels>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="relative flex items-start flex-grow-0 flex-shrink-0 w-auto max-w-full py-0 cursor-pointer conversation border-b border-n-slate-3 hover:border-n-surface-1 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3 group hover:z-[1] before:content-[none] before:absolute before:-top-px before:inset-x-0 before:h-px before:bg-n-surface-1 before:pointer-events-none hover:before:content-['']"
|
||||
:class="{
|
||||
'active animate-card-select bg-n-background !border-n-surface-1':
|
||||
isActiveChat,
|
||||
'selected bg-n-slate-2 !border-n-surface-1': selected,
|
||||
'px-0': compact,
|
||||
'px-3': !compact,
|
||||
}"
|
||||
@click="$emit('click', $event)"
|
||||
@contextmenu="$emit('contextmenu', $event)"
|
||||
>
|
||||
<div
|
||||
class="relative"
|
||||
@mouseenter="onThumbnailHover"
|
||||
@mouseleave="onThumbnailLeave"
|
||||
>
|
||||
<Avatar
|
||||
v-if="!hideThumbnail"
|
||||
:name="currentContact.name"
|
||||
:src="currentContact.thumbnail"
|
||||
:size="32"
|
||||
:status="currentContact.availability_status"
|
||||
:class="!showInboxName ? 'mt-4' : 'mt-8'"
|
||||
hide-offline-status
|
||||
>
|
||||
<template #overlay="{ size }">
|
||||
<label
|
||||
v-if="hovered || selected"
|
||||
class="flex items-center justify-center rounded-full cursor-pointer absolute inset-0 z-10 backdrop-blur-[2px]"
|
||||
:style="{ width: `${size}px`, height: `${size}px` }"
|
||||
@click.stop
|
||||
>
|
||||
<Checkbox v-model="selectedModel" />
|
||||
</label>
|
||||
</template>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div class="px-0 py-3 flex-1 min-w-0 border-line">
|
||||
<div
|
||||
v-if="showMetaSection"
|
||||
class="flex items-center min-w-0 gap-1"
|
||||
:class="{
|
||||
'ltr:ml-2 rtl:mr-2': !compact,
|
||||
'mx-2': compact,
|
||||
}"
|
||||
>
|
||||
<InboxName
|
||||
v-if="showInboxName"
|
||||
:inbox="inbox"
|
||||
class="flex-1 min-w-0"
|
||||
/>
|
||||
<div
|
||||
class="flex items-baseline gap-2 flex-shrink-0"
|
||||
:class="{
|
||||
'flex-1 justify-between': !showInboxName,
|
||||
}"
|
||||
>
|
||||
<span
|
||||
v-if="showAssignee && assignee.name"
|
||||
class="text-n-slate-11 text-xs font-medium leading-3 py-0.5 px-0 inline-flex items-center truncate"
|
||||
>
|
||||
<fluent-icon
|
||||
icon="person"
|
||||
size="12"
|
||||
class="text-n-slate-11"
|
||||
/>
|
||||
{{ assignee.name }}
|
||||
</span>
|
||||
<CardPriorityIcon
|
||||
:priority="chat.priority"
|
||||
class="flex-shrink-0 !size-3.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex min-w-0 items-center gap-2 mx-2 pt-0.5">
|
||||
<h4
|
||||
class="conversation--user text-sm my-0 min-w-0 flex-1 capitalize text-ellipsis overflow-hidden whitespace-nowrap text-n-slate-12"
|
||||
:class="hasUnread ? 'font-semibold' : 'font-medium'"
|
||||
>
|
||||
{{ currentContact.name }}
|
||||
</h4>
|
||||
<TimeAgo
|
||||
:last-activity-timestamp="chat.timestamp"
|
||||
:created-at-timestamp="chat.created_at"
|
||||
:conversation-id="chat.id"
|
||||
class="w-[4.375rem] flex-shrink-0 text-end font-normal"
|
||||
/>
|
||||
</div>
|
||||
<VoiceCallStatus
|
||||
v-if="voiceCallData.status"
|
||||
key="voice-status-row"
|
||||
:status="voiceCallData.status"
|
||||
:direction="voiceCallData.direction"
|
||||
:message-preview-class="messagePreviewClass"
|
||||
/>
|
||||
<MessagePreview
|
||||
v-else-if="lastMessageInChat"
|
||||
key="message-preview"
|
||||
:message="lastMessageInChat"
|
||||
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm"
|
||||
:class="messagePreviewClass"
|
||||
/>
|
||||
<p
|
||||
v-else
|
||||
key="no-messages"
|
||||
class="text-n-slate-11 text-sm my-0 mx-2 leading-6 h-6 flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
:class="messagePreviewClass"
|
||||
>
|
||||
<fluent-icon
|
||||
size="16"
|
||||
class="-mt-0.5 align-middle inline-block text-n-slate-10"
|
||||
icon="info"
|
||||
/>
|
||||
<span class="mx-0.5">
|
||||
{{ $t(`CHAT_LIST.NO_MESSAGES`) }}
|
||||
</span>
|
||||
</p>
|
||||
<div
|
||||
v-if="hasUnread"
|
||||
class="absolute flex flex-col ltr:right-3 rtl:left-3"
|
||||
:class="showMetaSection ? 'top-8' : 'top-4'"
|
||||
>
|
||||
<UnreadBadge
|
||||
:count="unreadCount"
|
||||
class="ltr:ml-auto rtl:mr-auto mt-1"
|
||||
/>
|
||||
</div>
|
||||
<CardLabels
|
||||
v-if="showLabelsSection"
|
||||
:conversation-labels="chat.labels"
|
||||
class="mt-0.5 mx-2 mb-0"
|
||||
>
|
||||
<template v-if="hasSlaPolicyId" #before>
|
||||
<SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
|
||||
</template>
|
||||
</CardLabels>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+498
-469
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user