257 lines
7.6 KiB
TypeScript
257 lines
7.6 KiB
TypeScript
|
|
import * as React from "react"
|
||
|
|
import {
|
||
|
|
ChatWorkspace,
|
||
|
|
type ChatConversation,
|
||
|
|
type ChatMessage,
|
||
|
|
} from "@workspace/blocks/chats"
|
||
|
|
|
||
|
|
const mockMediaUrls: Record<string, string> = {
|
||
|
|
"campaign-banner":
|
||
|
|
"https://images.unsplash.com/photo-1556742049-0cfed4f6a45d?auto=format&fit=crop&w=960&q=80",
|
||
|
|
"order-card-thumbnail":
|
||
|
|
"https://images.unsplash.com/photo-1556740749-887f6717d7e4?auto=format&fit=crop&w=320&q=80",
|
||
|
|
}
|
||
|
|
|
||
|
|
const initialConversations: readonly ChatConversation[] = [
|
||
|
|
{
|
||
|
|
avatarUrl:
|
||
|
|
"https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/images/mock/avatar/avatar-1.webp",
|
||
|
|
description: "微信小程序客服会话",
|
||
|
|
id: "customer-service",
|
||
|
|
lastActivityLabel: "刚刚",
|
||
|
|
lastMessage: "我的订单",
|
||
|
|
messages: [
|
||
|
|
{
|
||
|
|
author: { id: "customer-zhang", name: "张晓敏" },
|
||
|
|
createdAt: "2026-07-31T09:30:00+08:00",
|
||
|
|
direction: "incoming",
|
||
|
|
id: "welcome",
|
||
|
|
msgtype: "text",
|
||
|
|
text: { content: "你好,我想了解一下订单的配送进度。" },
|
||
|
|
},
|
||
|
|
{
|
||
|
|
createdAt: "2026-07-31T09:31:00+08:00",
|
||
|
|
direction: "outgoing",
|
||
|
|
id: "campaign-image",
|
||
|
|
image: { media_id: "campaign-banner" },
|
||
|
|
msgtype: "image",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
createdAt: "2026-07-31T09:32:00+08:00",
|
||
|
|
direction: "outgoing",
|
||
|
|
id: "help-link",
|
||
|
|
link: {
|
||
|
|
description: "查看商品、订单与售后服务说明。",
|
||
|
|
thumb_url:
|
||
|
|
"https://images.unsplash.com/photo-1556740758-90de374c12ad?auto=format&fit=crop&w=640&q=80",
|
||
|
|
title: "帮助中心",
|
||
|
|
url: "https://example.com/help",
|
||
|
|
},
|
||
|
|
msgtype: "link",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
createdAt: "2026-07-31T09:33:00+08:00",
|
||
|
|
direction: "outgoing",
|
||
|
|
id: "orders-card",
|
||
|
|
miniprogrampage: {
|
||
|
|
pagepath: "pages/orders/index",
|
||
|
|
thumb_media_id: "order-card-thumbnail",
|
||
|
|
title: "我的订单",
|
||
|
|
},
|
||
|
|
msgtype: "miniprogrampage",
|
||
|
|
},
|
||
|
|
],
|
||
|
|
title: "张晓敏",
|
||
|
|
unreadCount: 3,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
avatarUrl:
|
||
|
|
"https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/images/mock/avatar/avatar-4.webp",
|
||
|
|
description: "微信小程序客服会话",
|
||
|
|
id: "shipping",
|
||
|
|
lastActivityLabel: "昨天",
|
||
|
|
lastMessage: "请问我的包裹什么时候可以送到?",
|
||
|
|
messages: [
|
||
|
|
{
|
||
|
|
author: { id: "customer-li", name: "李雨晴" },
|
||
|
|
createdAt: "2026-07-30T16:20:00+08:00",
|
||
|
|
direction: "incoming",
|
||
|
|
id: "shipping-status",
|
||
|
|
msgtype: "text",
|
||
|
|
text: { content: "请问我的包裹什么时候可以送到?" },
|
||
|
|
},
|
||
|
|
],
|
||
|
|
title: "李雨晴",
|
||
|
|
},
|
||
|
|
]
|
||
|
|
|
||
|
|
export function ChatsDemoPage() {
|
||
|
|
const [activeConversationId, setActiveConversationId] = React.useState(
|
||
|
|
initialConversations[0]!.id
|
||
|
|
)
|
||
|
|
const [conversations, setConversations] = React.useState(initialConversations)
|
||
|
|
const [hasMoreHistory, setHasMoreHistory] = React.useState(true)
|
||
|
|
const [mediaUrls, setMediaUrls] = React.useState(mockMediaUrls)
|
||
|
|
const objectUrlsRef = React.useRef(new Set<string>())
|
||
|
|
|
||
|
|
React.useEffect(
|
||
|
|
() => () => {
|
||
|
|
for (const url of objectUrlsRef.current) URL.revokeObjectURL(url)
|
||
|
|
},
|
||
|
|
[]
|
||
|
|
)
|
||
|
|
|
||
|
|
const appendMessages = React.useCallback(
|
||
|
|
(conversationId: string, messages: readonly ChatMessage[]) => {
|
||
|
|
if (messages.length === 0) return
|
||
|
|
|
||
|
|
const lastMessage = messages.at(-1)!
|
||
|
|
setConversations((current) =>
|
||
|
|
current.map((conversation) =>
|
||
|
|
conversation.id === conversationId
|
||
|
|
? {
|
||
|
|
...conversation,
|
||
|
|
lastActivityLabel: "刚刚",
|
||
|
|
lastMessage: getMessagePreview(lastMessage),
|
||
|
|
messages: [...conversation.messages, ...messages],
|
||
|
|
unreadCount: 0,
|
||
|
|
}
|
||
|
|
: conversation
|
||
|
|
)
|
||
|
|
)
|
||
|
|
},
|
||
|
|
[]
|
||
|
|
)
|
||
|
|
|
||
|
|
const handleSend = React.useCallback(
|
||
|
|
async ({
|
||
|
|
conversation,
|
||
|
|
files,
|
||
|
|
text,
|
||
|
|
}: Parameters<
|
||
|
|
NonNullable<React.ComponentProps<typeof ChatWorkspace>["onSend"]>
|
||
|
|
>[0]) => {
|
||
|
|
const createdAt = new Date().toISOString()
|
||
|
|
const prefix = `${Date.now()}`
|
||
|
|
const messages: ChatMessage[] = []
|
||
|
|
|
||
|
|
if (text) {
|
||
|
|
messages.push({
|
||
|
|
createdAt,
|
||
|
|
direction: "outgoing",
|
||
|
|
id: `${prefix}:text`,
|
||
|
|
msgtype: "text",
|
||
|
|
status: "sent",
|
||
|
|
text: { content: text },
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
const newMediaUrls: Record<string, string> = {}
|
||
|
|
for (const [index, file] of files.entries()) {
|
||
|
|
const url = URL.createObjectURL(file)
|
||
|
|
objectUrlsRef.current.add(url)
|
||
|
|
|
||
|
|
if (file.type.startsWith("image/")) {
|
||
|
|
const mediaId = `${prefix}:image:${index}`
|
||
|
|
newMediaUrls[mediaId] = url
|
||
|
|
messages.push({
|
||
|
|
createdAt,
|
||
|
|
direction: "outgoing",
|
||
|
|
id: mediaId,
|
||
|
|
image: { media_id: mediaId },
|
||
|
|
msgtype: "image",
|
||
|
|
status: "sent",
|
||
|
|
})
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
messages.push({
|
||
|
|
createdAt,
|
||
|
|
direction: "outgoing",
|
||
|
|
id: `${prefix}:link:${index}`,
|
||
|
|
link: {
|
||
|
|
description: "本地选择的文件(仅用于模拟)。",
|
||
|
|
thumb_url: "",
|
||
|
|
title: file.name,
|
||
|
|
url,
|
||
|
|
},
|
||
|
|
msgtype: "link",
|
||
|
|
status: "sent",
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
if (Object.keys(newMediaUrls).length > 0) {
|
||
|
|
setMediaUrls((current) => ({ ...current, ...newMediaUrls }))
|
||
|
|
}
|
||
|
|
appendMessages(conversation.id, messages)
|
||
|
|
},
|
||
|
|
[appendMessages]
|
||
|
|
)
|
||
|
|
|
||
|
|
const handleLoadEarlierMessages = React.useCallback(
|
||
|
|
(conversation: ChatConversation) => {
|
||
|
|
setHasMoreHistory(false)
|
||
|
|
setConversations((current) =>
|
||
|
|
current.map((currentConversation) =>
|
||
|
|
currentConversation.id === conversation.id
|
||
|
|
? {
|
||
|
|
...currentConversation,
|
||
|
|
messages: [
|
||
|
|
{
|
||
|
|
author: { id: "customer-zhang", name: "张晓敏" },
|
||
|
|
createdAt: "2026-07-31T09:20:00+08:00",
|
||
|
|
direction: "incoming",
|
||
|
|
id: `${conversation.id}:earlier-message`,
|
||
|
|
msgtype: "text",
|
||
|
|
text: { content: "我昨天已经联系过客服了。" },
|
||
|
|
},
|
||
|
|
...currentConversation.messages,
|
||
|
|
],
|
||
|
|
}
|
||
|
|
: currentConversation
|
||
|
|
)
|
||
|
|
)
|
||
|
|
},
|
||
|
|
[]
|
||
|
|
)
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="flex min-w-0 flex-col gap-6">
|
||
|
|
<div>
|
||
|
|
<h1 className="text-xl font-medium">聊天模拟器</h1>
|
||
|
|
<p className="mt-1 text-sm text-muted-foreground">
|
||
|
|
所有数据仅保存在浏览器内,可模拟微信小程序客服的文本、图片、图文链接和小程序卡片消息。
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<ChatWorkspace
|
||
|
|
activeConversationId={activeConversationId}
|
||
|
|
conversations={conversations}
|
||
|
|
hasMoreMessages={
|
||
|
|
hasMoreHistory && activeConversationId === "customer-service"
|
||
|
|
}
|
||
|
|
maxHeight="calc(100svh - 15rem)"
|
||
|
|
minHeight="min(36rem, calc(100svh - 15rem))"
|
||
|
|
onActiveConversationChange={(conversation) =>
|
||
|
|
setActiveConversationId(conversation.id)
|
||
|
|
}
|
||
|
|
onLoadEarlierMessages={handleLoadEarlierMessages}
|
||
|
|
onSend={handleSend}
|
||
|
|
resolveMediaUrl={(mediaId) => mediaUrls[mediaId]}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
function getMessagePreview(message: ChatMessage) {
|
||
|
|
switch (message.msgtype) {
|
||
|
|
case "text":
|
||
|
|
return message.text.content
|
||
|
|
case "image":
|
||
|
|
return "[图片]"
|
||
|
|
case "link":
|
||
|
|
return message.link.title
|
||
|
|
case "miniprogrampage":
|
||
|
|
return message.miniprogrampage.title
|
||
|
|
}
|
||
|
|
}
|