feat: integrate customer service chat
This commit is contained in:
@@ -32,6 +32,11 @@ const reasonMessages: Record<string, string> = {
|
||||
RECHARGE_CHANNEL_UNAVAILABLE: '当前没有可用的储入通道',
|
||||
RECHARGE_VOUCHER_INVALID: '储入凭证无效',
|
||||
SERVICE_UNAVAILABLE: '服务暂不可用,请稍后重试',
|
||||
CUSTOMER_SERVICE_NOT_FOUND: '客服已下线或不存在',
|
||||
CUSTOMER_SERVICE_NOT_BOUND: '客服账号尚未配置',
|
||||
IM_RELATION_PENDING: '好友申请已发送,请等待客服确认',
|
||||
IM_RELATION_NOT_ALLOWED: '当前无法与该客服建立会话',
|
||||
IM_SERVICE_UNAVAILABLE: '客服系统暂不可用,请稍后重试',
|
||||
INTERNAL: '服务异常,请稍后重试',
|
||||
};
|
||||
|
||||
@@ -134,6 +139,10 @@ function getDeviceID() {
|
||||
return value;
|
||||
}
|
||||
|
||||
export function deviceID() {
|
||||
return getDeviceID();
|
||||
}
|
||||
|
||||
export function saveSession(token: string) {
|
||||
sessionStorage.setItem(TOKEN_KEY, token);
|
||||
sessionStorage.setItem(LOGIN_NOTICE_PENDING_KEY, '1');
|
||||
|
||||
@@ -31,7 +31,6 @@ import processSimple from './assets/process-simple.png';
|
||||
import processMaterial from './assets/process-material.png';
|
||||
import processTime from './assets/process-time.png';
|
||||
import processReturn from './assets/process-return.png';
|
||||
import chatVoice from './assets/chat-voice.png';
|
||||
import chatMore from './assets/chat-more.png';
|
||||
import companyPlatform from './assets/company-platform.png';
|
||||
import companyRules from './assets/company-rules.png';
|
||||
@@ -108,7 +107,6 @@ export const mobileAssets = {
|
||||
processMaterial,
|
||||
processTime,
|
||||
processReturn,
|
||||
chatVoice,
|
||||
chatMore,
|
||||
companyPlatform,
|
||||
companyRules,
|
||||
|
||||
@@ -76,9 +76,10 @@ export class CustomerServiceChat {
|
||||
const ref = await this.upload(file, onProgress);
|
||||
const descriptor = {
|
||||
mime: file.type,
|
||||
// Tinode returns a relative file path. Publish an absolute reference so
|
||||
// other clients do not resolve it against their own web-app origin.
|
||||
refurl: new URL(ref, this.session.api_base_url).toString(),
|
||||
// Keep the server-issued relative reference. Tinode only includes
|
||||
// relative references in extra.attachments, which lets the server bind
|
||||
// the uploaded file to this message and authorize the recipient.
|
||||
refurl: ref,
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
width: dimensions.width,
|
||||
@@ -187,8 +188,8 @@ export class CustomerServiceChat {
|
||||
if (!ref) return '';
|
||||
const apiBase = new URL(this.session.api_base_url);
|
||||
const resolved = new URL(ref, apiBase);
|
||||
// The SDK only signs relative file URLs. Convert our absolute IM file
|
||||
// reference back to a relative path for signing when rendering locally.
|
||||
// The SDK signs relative file URLs. Also tolerate historical absolute IM
|
||||
// references by converting same-origin URLs back to a signable path.
|
||||
const signable = resolved.origin === apiBase.origin
|
||||
? `${resolved.pathname}${resolved.search}`
|
||||
: ref;
|
||||
|
||||
+319
-20
@@ -1386,18 +1386,24 @@ body {
|
||||
background: #c86d45;
|
||||
}
|
||||
.m-chat {
|
||||
min-height: 100dvh;
|
||||
height: 100dvh;
|
||||
background: #f4f5f6;
|
||||
padding-bottom: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.m-chat .m-page-header {
|
||||
flex: 0 0 auto;
|
||||
padding-inline: 20px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.m-chat-time {
|
||||
flex: 0 0 auto;
|
||||
text-align: center;
|
||||
color: #92969b;
|
||||
padding: 8px;
|
||||
padding: 9px 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.m-chat-empty {
|
||||
min-height: 530px;
|
||||
@@ -1422,34 +1428,327 @@ body {
|
||||
width: min(280px, 100%);
|
||||
margin-top: 16px;
|
||||
}
|
||||
.m-chat-input {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 0;
|
||||
transform: translateX(-50%);
|
||||
width: min(100%, 750px);
|
||||
height: 72px;
|
||||
.m-chat-composer {
|
||||
flex: 0 0 auto;
|
||||
width: 100%;
|
||||
background: #f1f1f1;
|
||||
padding: 10px 15px calc(10px + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid #e8e8e8;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
.m-chat-input {
|
||||
min-height: 68px;
|
||||
padding: 10px 14px;
|
||||
display: grid;
|
||||
grid-template-columns: 44px 1fr 44px;
|
||||
gap: 8px;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.m-chat-input button {
|
||||
.m-chat-input .m-chat-send {
|
||||
min-width: 66px;
|
||||
height: 44px;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 27px;
|
||||
border-radius: 7px;
|
||||
background: linear-gradient(135deg, #e2a25f, #d18437);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 3px 9px rgb(177 105 37 / 20%);
|
||||
}
|
||||
.m-chat-input button img {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
.m-chat-input .m-chat-send:disabled {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.m-chat-tool {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
padding: 6px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.m-chat-tool img {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.m-chat-input input {
|
||||
min-width: 0;
|
||||
height: 46px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
padding: 0 12px;
|
||||
padding: 0 14px;
|
||||
outline: none;
|
||||
color: var(--m-ink);
|
||||
box-shadow: inset 0 0 0 1px transparent;
|
||||
}
|
||||
.m-chat-input input:focus {
|
||||
box-shadow: inset 0 0 0 1px #d99a55;
|
||||
}
|
||||
.m-chat-tools {
|
||||
min-height: 142px;
|
||||
padding: 10px 30px 18px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 82px);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
.m-chat-tools button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #7a7d80;
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.m-chat-tools button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.m-chat-file-input {
|
||||
display: none;
|
||||
}
|
||||
.m-chat-tools .n-icon {
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #555;
|
||||
font-size: 31px;
|
||||
}
|
||||
.m-chat-emoji-panel {
|
||||
min-height: 248px;
|
||||
padding: 12px 18px 20px;
|
||||
background: #fff;
|
||||
border-radius: 18px 18px 0 0;
|
||||
}
|
||||
.m-chat-emoji-tabs {
|
||||
min-height: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.m-chat-emoji-tabs button {
|
||||
min-width: 54px;
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: #8b929d;
|
||||
font-size: 15px;
|
||||
}
|
||||
.m-chat-emoji-tabs button.is-active {
|
||||
background: #e3efff;
|
||||
color: #1683ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.m-chat-emojis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
gap: 12px 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.m-chat-emojis button {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
}
|
||||
.m-chat-emojis button:active {
|
||||
background: #eef4fb;
|
||||
transform: scale(0.94);
|
||||
}
|
||||
.m-chat-emoji-empty {
|
||||
min-height: 142px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #a1a7b0;
|
||||
font-size: 14px;
|
||||
}
|
||||
.m-chat-uploading {
|
||||
height: 34px;
|
||||
padding: 0 18px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #8a633d;
|
||||
font-size: 12px;
|
||||
}
|
||||
.m-chat-uploading progress {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
accent-color: #d58d2b;
|
||||
}
|
||||
.m-chat-uploading b {
|
||||
flex: 0 0 auto;
|
||||
font-weight: 500;
|
||||
}
|
||||
.m-chat-messages {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 12px 20px 24px;
|
||||
}
|
||||
.m-chat-welcome {
|
||||
width: max-content;
|
||||
max-width: 90%;
|
||||
margin: 12px auto 20px;
|
||||
padding: 7px 12px;
|
||||
border-radius: 14px;
|
||||
background: #e5e7e9;
|
||||
color: #74797e;
|
||||
font-size: 13px;
|
||||
}
|
||||
.m-chat-history-divider {
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #9a9ea4;
|
||||
font-size: 12px;
|
||||
}
|
||||
.m-chat-history-divider::before,
|
||||
.m-chat-history-divider::after {
|
||||
content: '';
|
||||
height: 1px;
|
||||
flex: 1;
|
||||
background: #e4e6e9;
|
||||
}
|
||||
.m-chat-session-tip {
|
||||
width: max-content;
|
||||
max-width: 90%;
|
||||
margin: 12px auto 20px;
|
||||
padding: 7px 13px;
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
color: #647188;
|
||||
font-size: 13px;
|
||||
box-shadow: 0 2px 7px rgb(0 0 0 / 4%);
|
||||
}
|
||||
.m-chat-system-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
.m-chat-avatar--agent span,
|
||||
.m-chat-avatar > span {
|
||||
font-size: 23px;
|
||||
line-height: 1;
|
||||
}
|
||||
.m-chat-system-message {
|
||||
max-width: calc(100% - 52px);
|
||||
}
|
||||
.m-chat-system-message small {
|
||||
display: block;
|
||||
margin: 0 0 6px 2px;
|
||||
color: #68768d;
|
||||
font-size: 12px;
|
||||
}
|
||||
.m-chat-system-message p {
|
||||
margin: 0;
|
||||
padding: 13px 15px;
|
||||
border-radius: 4px 14px 14px;
|
||||
background: #fff;
|
||||
color: #e75b9f;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.65;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 5%);
|
||||
}
|
||||
.m-chat-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
margin: 14px 0;
|
||||
}
|
||||
.m-chat-row.is-mine {
|
||||
flex-direction: row-reverse;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.m-chat-avatar {
|
||||
flex: 0 0 auto;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: #f3c883;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
}
|
||||
.m-chat-row.is-mine .m-chat-avatar {
|
||||
background: #62a7e8;
|
||||
}
|
||||
.m-chat-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.m-chat-avatar .n-icon {
|
||||
font-size: 26px;
|
||||
}
|
||||
.m-chat-message {
|
||||
max-width: calc(78% - 52px);
|
||||
}
|
||||
.m-chat-row.is-mine .m-chat-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.m-chat-bubble {
|
||||
position: relative;
|
||||
padding: 11px 14px;
|
||||
border-radius: 4px 14px 14px;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 5%);
|
||||
}
|
||||
.m-chat-row.is-mine .m-chat-bubble {
|
||||
border-radius: 14px 4px 14px 14px;
|
||||
background: #dfa05f;
|
||||
}
|
||||
.m-chat-bubble p {
|
||||
margin: 0;
|
||||
color: var(--m-ink);
|
||||
line-height: 1.55;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.m-chat-row.is-mine .m-chat-bubble p {
|
||||
color: #fff;
|
||||
}
|
||||
.m-chat-bubble.is-media {
|
||||
padding: 4px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
.m-chat-media {
|
||||
display: block;
|
||||
width: auto;
|
||||
max-width: min(280px, 100%);
|
||||
max-height: 320px;
|
||||
border-radius: 10px;
|
||||
object-fit: contain;
|
||||
background: #111;
|
||||
}
|
||||
.m-chat-message > small {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: #92969b;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.m-subpage {
|
||||
|
||||
@@ -37,64 +37,198 @@
|
||||
|
||||
<section v-else class="m-chat">
|
||||
<PageHeader :title="current?.name || '在线客服'" />
|
||||
<div class="m-chat-time">会话内容由客服系统提供</div>
|
||||
<div class="m-chat-empty">
|
||||
<div class="m-chat-time">会话内容已安全加密,请勿发送密码或验证码</div>
|
||||
<div v-if="chatLoading" class="m-chat-empty">
|
||||
<n-icon :component="ChatbubbleEllipsesOutline" />
|
||||
<h2>正在连接客服</h2>
|
||||
<p>{{ current?.privacy_notice || '请勿在聊天中发送登录密码或支付密码' }}</p>
|
||||
<a
|
||||
v-if="safeServiceUrl"
|
||||
:href="safeServiceUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="m-primary-btn m-btn-link"
|
||||
>进入客服会话</a
|
||||
>
|
||||
<p v-else class="m-form-error">客服会话地址尚未配置</p>
|
||||
<p>首次连接会自动建立好友关系</p>
|
||||
</div>
|
||||
<div v-else-if="chatError" class="m-chat-empty">
|
||||
<n-icon :component="ChatbubbleEllipsesOutline" />
|
||||
<h2>连接失败</h2>
|
||||
<p class="m-form-error">{{ chatError }}</p>
|
||||
<button class="m-primary-btn" @click="connectChat">重新连接</button>
|
||||
</div>
|
||||
<div v-else ref="messageList" class="m-chat-messages">
|
||||
<div v-for="message in historicalMessages" :key="`history-${message.seq}-${message.from}`" :class="['m-chat-row', { 'is-mine': message.mine }]">
|
||||
<div class="m-chat-avatar" aria-hidden="true">
|
||||
<img v-if="!message.mine && safeImage(current?.avatar_url)" :src="safeImage(current?.avatar_url)" alt="" />
|
||||
<img v-else-if="message.mine && safeImage(me?.avatar_url)" :src="safeImage(me?.avatar_url)" alt="" />
|
||||
<span v-else-if="message.mine">👨🏻💼</span>
|
||||
<span v-else>👩🏻💼</span>
|
||||
</div>
|
||||
<div class="m-chat-message">
|
||||
<div :class="['m-chat-bubble', { 'is-media': message.type !== 'text' }]">
|
||||
<img v-if="message.type === 'image' && message.media_url" class="m-chat-media" :src="message.media_url" :alt="message.file_name || '聊天照片'" />
|
||||
<video v-else-if="message.type === 'video' && message.media_url" class="m-chat-media" :src="message.media_url" :poster="message.preview_url" controls playsinline></video>
|
||||
<p v-if="message.text">{{ message.text }}</p>
|
||||
</div>
|
||||
<small>
|
||||
<span v-if="message.mine && !message.sending && !message.failed">已发送 · </span>{{ messageTime(message.sent_at) }}
|
||||
<span v-if="message.sending"> · 发送中</span><span v-if="message.failed"> · 发送失败</span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="historicalMessages.length" class="m-chat-history-divider"><span>以上是历史会话</span></div>
|
||||
<div class="m-chat-session-tip">[ {{ current?.name || '在线客服' }} ] 与您建立了会话</div>
|
||||
<div class="m-chat-system-row">
|
||||
<div class="m-chat-avatar m-chat-avatar--agent" aria-hidden="true">
|
||||
<img v-if="safeImage(current?.avatar_url)" :src="safeImage(current?.avatar_url)" alt="" />
|
||||
<span v-else>👩🏻💼</span>
|
||||
</div>
|
||||
<div class="m-chat-system-message">
|
||||
<small>{{ current?.name || '在线客服' }} · {{ messageDateTime(sessionStartedAt) }}</small>
|
||||
<p><b>【系统消息】</b>尊敬的用户您好,我是{{ current?.name || '在线客服' }},当前已为您接通在线客服,请问有什么可以帮您?</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-for="message in liveMessages" :key="`live-${message.seq}-${message.from}`" :class="['m-chat-row', { 'is-mine': message.mine }]">
|
||||
<div class="m-chat-avatar" aria-hidden="true">
|
||||
<img v-if="!message.mine && safeImage(current?.avatar_url)" :src="safeImage(current?.avatar_url)" alt="" />
|
||||
<img v-else-if="message.mine && safeImage(me?.avatar_url)" :src="safeImage(me?.avatar_url)" alt="" />
|
||||
<span v-else-if="message.mine">👨🏻💼</span>
|
||||
<span v-else>👩🏻💼</span>
|
||||
</div>
|
||||
<div class="m-chat-message">
|
||||
<div :class="['m-chat-bubble', { 'is-media': message.type !== 'text' }]">
|
||||
<img v-if="message.type === 'image' && message.media_url" class="m-chat-media" :src="message.media_url" :alt="message.file_name || '聊天照片'" />
|
||||
<video v-else-if="message.type === 'video' && message.media_url" class="m-chat-media" :src="message.media_url" :poster="message.preview_url" controls playsinline></video>
|
||||
<p v-if="message.text">{{ message.text }}</p>
|
||||
</div>
|
||||
<small>
|
||||
<span v-if="message.mine && !message.sending && !message.failed">已发送 · </span>{{ messageTime(message.sent_at) }}
|
||||
<span v-if="message.sending"> · 发送中</span><span v-if="message.failed"> · 发送失败</span>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="m-chat-composer" :class="{ 'is-expanded': toolsOpen }">
|
||||
<form class="m-chat-input" @submit.prevent="sendMessage">
|
||||
<input v-model="draft" :disabled="chatLoading || Boolean(chatError) || sending || mediaSending" maxlength="1000" placeholder="请输入消息" />
|
||||
<button
|
||||
v-if="draft.trim()"
|
||||
class="m-chat-send"
|
||||
type="submit"
|
||||
:disabled="chatLoading || Boolean(chatError) || sending || mediaSending"
|
||||
>{{ sending ? '发送中' : '发送' }}</button>
|
||||
<button v-else class="m-chat-tool" type="button" aria-label="更多" @click="toggleTools">
|
||||
<img :src="mobileAssets.chatMore" alt="" />
|
||||
</button>
|
||||
</form>
|
||||
<div v-if="mediaSending" class="m-chat-uploading"><progress max="100" :value="uploadProgress"></progress><b>正在上传 {{ uploadProgress }}%</b></div>
|
||||
<div v-if="toolsOpen && !emojiOpen" class="m-chat-tools">
|
||||
<button type="button" @click="emojiOpen = true"><n-icon :component="HappyOutline" /><span>表情</span></button>
|
||||
<button type="button" :disabled="mediaSending" @click="photoInput?.click()"><n-icon :component="ImageOutline" /><span>照片</span></button>
|
||||
<button type="button" :disabled="mediaSending" @click="videoInput?.click()"><n-icon :component="VideocamOutline" /><span>视频</span></button>
|
||||
<input ref="photoInput" class="m-chat-file-input" type="file" accept="image/jpeg,image/png,image/webp,image/gif" @change="sendMedia('image', $event)" />
|
||||
<input ref="videoInput" class="m-chat-file-input" type="file" accept="video/mp4,video/webm,video/quicktime" @change="sendMedia('video', $event)" />
|
||||
</div>
|
||||
<div v-if="toolsOpen && emojiOpen" class="m-chat-emoji-panel">
|
||||
<div class="m-chat-emoji-tabs" role="tablist" aria-label="表情分类">
|
||||
<button
|
||||
v-for="category in emojiCategories"
|
||||
:key="category.key"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="activeEmojiCategory === category.key"
|
||||
:class="{ 'is-active': activeEmojiCategory === category.key }"
|
||||
@click="activeEmojiCategory = category.key"
|
||||
>{{ category.label }}</button>
|
||||
</div>
|
||||
<div class="m-chat-emojis">
|
||||
<button v-for="emoji in visibleEmojis" :key="emoji" type="button" @click="insertEmoji(emoji)">{{ emoji }}</button>
|
||||
</div>
|
||||
<div v-if="!visibleEmojis.length" class="m-chat-emoji-empty">暂无最近使用的表情</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="m-chat-input"
|
||||
><button aria-label="语音"><img :src="mobileAssets.chatVoice" alt="" /></button
|
||||
><input disabled placeholder="请先连接客服会话" /><button aria-label="更多"
|
||||
><img :src="mobileAssets.chatMore" alt="" /></button
|
||||
></div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { ChatbubbleEllipsesOutline, HeadsetOutline } from '@vicons/ionicons5';
|
||||
import {
|
||||
ChatbubbleEllipsesOutline,
|
||||
HappyOutline,
|
||||
HeadsetOutline,
|
||||
ImageOutline,
|
||||
VideocamOutline,
|
||||
} from '@vicons/ionicons5';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
import AsyncState from '../components/AsyncState.vue';
|
||||
import { errorText, request, safeImage } from '../api';
|
||||
import { deviceID, errorText, request, safeImage } from '../api';
|
||||
import { mobileAssets } from '../assets';
|
||||
import { CustomerServiceChat, type ChatMessage, type CustomerServiceSession } from '../im';
|
||||
const route = useRoute();
|
||||
const isChat = computed(() => Boolean(route.params.id));
|
||||
const loading = ref(true);
|
||||
const error = ref('');
|
||||
const services = ref<any[]>([]);
|
||||
const me = ref<any>();
|
||||
const current = computed(
|
||||
() =>
|
||||
services.value.find((item) => String(item.id) === String(route.params.id)) ||
|
||||
services.value[0]
|
||||
);
|
||||
const safeServiceUrl = computed(() => {
|
||||
const value = current.value?.service_url;
|
||||
if (!value) return '';
|
||||
try {
|
||||
const u = new URL(value);
|
||||
return u.protocol === 'https:' ? u.href : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
const chatLoading = ref(false);
|
||||
const chatError = ref('');
|
||||
const messages = ref<ChatMessage[]>([]);
|
||||
const historyBoundarySeq = ref(0);
|
||||
const historicalMessages = computed(() => messages.value.filter((message) => message.seq <= historyBoundarySeq.value));
|
||||
const liveMessages = computed(() => messages.value.filter((message) => message.seq > historyBoundarySeq.value));
|
||||
const draft = ref('');
|
||||
const sending = ref(false);
|
||||
const mediaSending = ref(false);
|
||||
const uploadProgress = ref(0);
|
||||
const photoInput = ref<HTMLInputElement | null>(null);
|
||||
const videoInput = ref<HTMLInputElement | null>(null);
|
||||
const toolsOpen = ref(false);
|
||||
const emojiOpen = ref(false);
|
||||
type EmojiCategory = 'recent' | 'face' | 'gesture' | 'object';
|
||||
const emojiCategories: Array<{ key: EmojiCategory; label: string }> = [
|
||||
{ key: 'recent', label: '最近' },
|
||||
{ key: 'face', label: '表情' },
|
||||
{ key: 'gesture', label: '手势' },
|
||||
{ key: 'object', label: '物品' },
|
||||
];
|
||||
const emojiGroups: Record<Exclude<EmojiCategory, 'recent'>, string[]> = {
|
||||
face: [
|
||||
'😀', '😁', '😂', '🤣', '😊', '😍', '😘', '😜', '🤔', '😐', '😴', '😭',
|
||||
'😡', '🥳', '😱', '🤯', '🥰', '😇', '😮', '😉', '😆', '😅', '☺️', '😋',
|
||||
'😝', '🤗', '🤩', '🥺', '😢', '😤', '🤬', '🥵', '🥶', '😵', '🤢', '😷',
|
||||
'🤧', '😏', '😪', '🙄',
|
||||
],
|
||||
gesture: [
|
||||
'👍', '👎', '👌', '✌️', '🤞', '🤟', '🤘', '👏', '🙌', '👐', '🤲', '🙏',
|
||||
'👋', '🤝', '💪', '✍️', '☝️', '👆', '👇', '👉', '👈', '🫶', '💅', '🤙',
|
||||
],
|
||||
object: [
|
||||
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍', '🌹', '🌸', '🎉', '🎁',
|
||||
'⭐', '✨', '🔥', '💯', '🎂', '🍰', '☕', '🍻', '⚽', '🏆', '🚀', '💡',
|
||||
],
|
||||
};
|
||||
const recentEmojis = ref<string[]>([]);
|
||||
const activeEmojiCategory = ref<EmojiCategory>('face');
|
||||
const visibleEmojis = computed(() =>
|
||||
activeEmojiCategory.value === 'recent' ? recentEmojis.value : emojiGroups[activeEmojiCategory.value]
|
||||
);
|
||||
const sessionStartedAt = ref(new Date().toISOString());
|
||||
const messageList = ref<HTMLElement | null>(null);
|
||||
let chat: CustomerServiceChat | null = null;
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
services.value = await request<any[]>({ url: '/v1/customer-services' });
|
||||
const [serviceRows, profile] = await Promise.all([
|
||||
request<any[]>({ url: '/v1/customer-services' }),
|
||||
request<any>({ url: '/v1/me' }).catch(() => null),
|
||||
]);
|
||||
services.value = serviceRows;
|
||||
me.value = profile;
|
||||
if (isChat.value) await connectChat();
|
||||
} catch (err) {
|
||||
error.value = errorText(err);
|
||||
const message = errorText(err);
|
||||
error.value = message;
|
||||
if (isChat.value) chatError.value = message;
|
||||
services.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -105,5 +239,113 @@
|
||||
if (status === 'busy') return '忙碌';
|
||||
return '离线留言';
|
||||
}
|
||||
async function connectChat() {
|
||||
const serviceID = Number(route.params.id);
|
||||
if (!serviceID || chatLoading.value) return;
|
||||
chat?.disconnect();
|
||||
chat = null;
|
||||
chatLoading.value = true;
|
||||
chatError.value = '';
|
||||
messages.value = [];
|
||||
historyBoundarySeq.value = 0;
|
||||
try {
|
||||
const session = await request<CustomerServiceSession>({
|
||||
url: `/v1/customer-services/${serviceID}/session`,
|
||||
method: 'POST',
|
||||
data: { device_id: deviceID(), platform: 'web', app_version: '1.0.0' },
|
||||
});
|
||||
chat = new CustomerServiceChat(session, (rows) => {
|
||||
messages.value = rows;
|
||||
void scrollToBottom();
|
||||
});
|
||||
await chat.connect(session.token);
|
||||
// Tinode delivers cached topic history just after subscribe resolves.
|
||||
// Give that initial batch a brief window so it can be separated from the new session.
|
||||
await new Promise<void>((resolve) => window.setTimeout(resolve, 250));
|
||||
historyBoundarySeq.value = messages.value.reduce((max, message) => Math.max(max, message.seq), 0);
|
||||
sessionStartedAt.value = new Date().toISOString();
|
||||
} catch (err) {
|
||||
chat?.disconnect();
|
||||
chat = null;
|
||||
chatError.value = errorText(err);
|
||||
} finally {
|
||||
chatLoading.value = false;
|
||||
}
|
||||
}
|
||||
async function sendMessage() {
|
||||
if (!chat || sending.value || !draft.value.trim()) return;
|
||||
const value = draft.value;
|
||||
draft.value = '';
|
||||
toolsOpen.value = false;
|
||||
emojiOpen.value = false;
|
||||
sending.value = true;
|
||||
try {
|
||||
await chat.send(value);
|
||||
await scrollToBottom();
|
||||
} catch (err) {
|
||||
draft.value = value;
|
||||
chatError.value = errorText(err);
|
||||
} finally {
|
||||
sending.value = false;
|
||||
}
|
||||
}
|
||||
async function sendMedia(type: 'image' | 'video', event: Event) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
input.value = '';
|
||||
if (!file || !chat || mediaSending.value) return;
|
||||
const maxSize = type === 'image' ? 10 * 1024 * 1024 : 50 * 1024 * 1024;
|
||||
if (!file.type.startsWith(`${type}/`)) {
|
||||
window.$message?.error(type === 'image' ? '请选择照片文件' : '请选择视频文件');
|
||||
return;
|
||||
}
|
||||
if (file.size > maxSize) {
|
||||
window.$message?.error(type === 'image' ? '照片不能超过 10MB' : '视频不能超过 50MB');
|
||||
return;
|
||||
}
|
||||
toolsOpen.value = false;
|
||||
emojiOpen.value = false;
|
||||
mediaSending.value = true;
|
||||
uploadProgress.value = 0;
|
||||
try {
|
||||
await chat.sendMedia(file, type, (progress) => {
|
||||
uploadProgress.value = Math.min(99, Math.round(progress * 100));
|
||||
});
|
||||
uploadProgress.value = 100;
|
||||
await scrollToBottom();
|
||||
} catch (err) {
|
||||
window.$message?.error(errorText(err));
|
||||
} finally {
|
||||
mediaSending.value = false;
|
||||
uploadProgress.value = 0;
|
||||
}
|
||||
}
|
||||
function toggleTools() {
|
||||
toolsOpen.value = !toolsOpen.value;
|
||||
if (!toolsOpen.value) emojiOpen.value = false;
|
||||
}
|
||||
function insertEmoji(emoji: string) {
|
||||
draft.value += emoji;
|
||||
recentEmojis.value = [emoji, ...recentEmojis.value.filter((item) => item !== emoji)].slice(0, 24);
|
||||
}
|
||||
async function scrollToBottom() {
|
||||
await nextTick();
|
||||
if (messageList.value) messageList.value.scrollTop = messageList.value.scrollHeight;
|
||||
}
|
||||
function messageTime(value: string) {
|
||||
if (!value) return '';
|
||||
return new Intl.DateTimeFormat('zh-CN', { hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(value));
|
||||
}
|
||||
function messageDateTime(value: string) {
|
||||
if (!value) return '';
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(new Date(value));
|
||||
}
|
||||
onMounted(load);
|
||||
onBeforeUnmount(() => chat?.disconnect());
|
||||
</script>
|
||||
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module 'tinode-sdk' {
|
||||
export const Drafty: any;
|
||||
export const Tinode: any;
|
||||
}
|
||||
Reference in New Issue
Block a user