feat: integrate customer service chat

This commit is contained in:
richjohnrambo
2026-10-14 16:37:12 +08:00
parent f3f59fe7c3
commit 48b3564ac9
22 changed files with 1788 additions and 64 deletions
+9
View File
@@ -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');
-2
View File
@@ -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,
+6 -5
View File
@@ -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
View File
@@ -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 {
+274 -32
View File
@@ -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>
+4
View File
@@ -0,0 +1,4 @@
declare module 'tinode-sdk' {
export const Drafty: any;
export const Tinode: any;
}