feat(frontend): add compact anonymous conversation labels
Build and publish Docker images / Build and publish images (push) Successful in 2m48s

This commit is contained in:
2026-09-15 13:30:49 +08:00
parent 4d684a71eb
commit f79c80519d
6 changed files with 136 additions and 6 deletions
@@ -4,6 +4,7 @@ import { getInboxIconByType } from 'dashboard/helper/inbox';
import { useRouter, useRoute } from 'vue-router';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper.js';
import { dynamicTime, shortTimestamp } from 'shared/helpers/timeHelper';
import { getConversationContactDisplayName } from 'dashboard/helper/conversationHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
@@ -37,7 +38,12 @@ const cardMessagePreviewWithMetaRef = ref(null);
const currentContact = computed(() => props.contact);
const currentContactName = computed(() => currentContact.value?.name);
const currentContactName = computed(() =>
getConversationContactDisplayName(
currentContact.value,
props.conversation?.id
)
);
const currentContactThumbnail = computed(() => currentContact.value?.thumbnail);
const currentContactStatus = computed(
() => currentContact.value?.availabilityStatus
@@ -1,6 +1,9 @@
<script setup>
import { computed, useTemplateRef } from 'vue';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import {
getConversationContactDisplayName,
getLastMessage,
} from 'dashboard/helper/conversationHelper';
import CardAvatar from './CardAvatar.vue';
import CardContent from './CardContent.vue';
import CardLabels from './CardLabelsV5.vue';
@@ -33,6 +36,9 @@ const emit = defineEmits([
]);
const lastMessageInChat = computed(() => getLastMessage(props.chat));
const currentContactName = computed(() =>
getConversationContactDisplayName(props.currentContact, props.chat.id)
);
const showLabelsSection = computed(() => props.chat.labels?.length > 0);
const voiceCallData = computed(() => {
@@ -155,7 +161,7 @@ const selectedModel = computed({
<h4
class="text-heading-3 my-0 min-w-0 flex-1 capitalize truncate text-n-slate-12 font-medium"
>
{{ currentContact.name }}
{{ currentContactName }}
</h4>
<TimeAgo
:conversation-id="chat.id"
@@ -1,6 +1,9 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { getLastMessage } from 'dashboard/helper/conversationHelper';
import {
getConversationContactDisplayName,
getLastMessage,
} from 'dashboard/helper/conversationHelper';
import Avatar from 'next/avatar/Avatar.vue';
import MessagePreview from './MessagePreview.vue';
import InboxName from '../InboxName.vue';
@@ -34,6 +37,10 @@ const emit = defineEmits([
const hovered = ref(false);
const currentContactName = computed(() =>
getConversationContactDisplayName(props.currentContact, props.chat.id)
);
const unreadCount = computed(() => props.chat.unread_count);
const hasUnread = computed(() => unreadCount.value > 0);
const lastMessageInChat = computed(() => getLastMessage(props.chat));
@@ -174,7 +181,7 @@ watch(
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 }}
{{ currentContactName }}
</h4>
<TimeAgo
:last-activity-timestamp="chat.timestamp"
@@ -10,6 +10,7 @@ import Avatar from 'next/avatar/Avatar.vue';
import SLACardLabel from './components/SLACardLabel.vue';
import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { getConversationContactDisplayName } from 'dashboard/helper/conversationHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { useInbox } from 'dashboard/composables/useInbox';
import { useI18n } from 'vue-i18n';
@@ -71,6 +72,10 @@ const currentContact = computed(() =>
store.getters['contacts/getContact'](props.chat?.meta?.sender?.id)
);
const currentContactName = computed(() =>
getConversationContactDisplayName(currentContact.value, props.chat?.id)
);
const isSnoozed = computed(
() => currentChat.value.status === wootConstants.STATUS_TYPE.SNOOZED
);
@@ -132,7 +137,7 @@ const copyConversationId = async () => {
<span
class="text-sm font-medium truncate leading-tight text-n-slate-12"
>
{{ currentContact.name }}
{{ currentContactName }}
</span>
<fluent-icon
v-if="!isHMACVerified"
@@ -69,6 +69,81 @@ export const getLastMessage = m => {
);
};
const anonymousContactNames = new Set([
'',
'匿名',
'匿名客户',
'Anonymous Visitor',
'商务通访客',
]);
const firstAttribute = (attributes, ...keys) => {
const value = keys
.map(key => attributes?.[key])
.find(item => typeof item === 'string' && item.trim());
return value?.trim() || '';
};
const contactAttributes = contact => ({
...(contact?.additional_attributes || {}),
...(contact?.additionalAttributes || {}),
...(contact?.custom_attributes || {}),
...(contact?.customAttributes || {}),
});
const visitorLocation = attributes => {
const province = firstAttribute(attributes, 'visitor_province');
const city = firstAttribute(attributes, 'visitor_city');
if (province || city) {
if (!city || !province || city === province || city.startsWith(province)) {
return city || province;
}
return `${province}${city}`;
}
return firstAttribute(attributes, 'swt_ip_location');
};
const shortConversationCode = conversationId => {
const value = String(conversationId ?? '').trim();
if (!/^[1-9]\d*$/.test(value)) {
return '';
}
const numericValue = Number(value);
return Number.isSafeInteger(numericValue)
? numericValue.toString(36).toUpperCase()
: '';
};
/**
* Adds a stable, compact session code only to generated anonymous names.
* The stored contact name remains unchanged.
*/
export const getConversationContactDisplayName = (contact, conversationId) => {
const name = String(contact?.name || '').trim();
const attributes = contactAttributes(contact);
const source = firstAttribute(attributes, 'visitor_name_source');
const isGeneratedName =
anonymousContactNames.has(name) ||
source === 'ip_geolocation' ||
source === 'anonymous_fallback';
if (!isGeneratedName) {
return name;
}
const code = shortConversationCode(conversationId);
if (!code) {
return name || '匿名';
}
const location =
visitorLocation(attributes) ||
(source === 'ip_geolocation' && name.endsWith('客户')
? name.slice(0, -2)
: '');
return location ? `${location}·${code}` : `匿名${code}`;
};
/**
* Filters messages that have been read by the agent.
* @param {Array} messages - The array of messages to filter.
@@ -1,5 +1,6 @@
import {
filterDuplicateSourceMessages,
getConversationContactDisplayName,
getLastMessage,
getReadMessages,
getUnreadMessages,
@@ -37,6 +38,36 @@ describe('conversationHelper', () => {
});
});
describe('#getConversationContactDisplayName', () => {
it('formats a geolocated anonymous visitor with a base36 session code', () => {
expect(
getConversationContactDisplayName(
{
name: '河北邯郸客户',
additional_attributes: {
visitor_name_source: 'ip_geolocation',
visitor_province: '河北',
visitor_city: '邯郸',
},
},
1296
)
).toBe('河北邯郸·100');
});
it('uses the anonymous label when location is unavailable', () => {
expect(
getConversationContactDisplayName({ name: '商务通访客' }, 36)
).toBe('匿名10');
});
it('keeps a real contact name unchanged', () => {
expect(getConversationContactDisplayName({ name: '张三' }, 1296)).toBe(
'张三'
);
});
});
describe('#readMessages', () => {
it('should return read messages if conversation is passed', () => {
expect(