feat(web): add login and customer chat demos
Add a responsive login screen using the shared appearance controls and a browser-only customer-service simulator for the chat workspace. Register both routes and include mock media resolution, message sending, and earlier-history loading for interactive verification.
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user