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:
Maofeng
2026-08-08 21:14:31 +08:00
parent 87df34a8ab
commit 6cd7d436ef
6 changed files with 584 additions and 3 deletions
+19
View File
@@ -0,0 +1,19 @@
import type React from "react"
type Props = Omit<React.ComponentProps<"svg">, "role" | "viewBox" | "xmlns">
export function Logo(props: Props) {
return (
<svg
role="img"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
fill="#7AB55C"
aria-hidden="true"
{...props}
>
<title>Shopify</title>
<path d="M15.337 23.979l7.216-1.561s-2.604-17.613-2.625-17.73c-.018-.116-.114-.192-.211-.192s-1.929-.136-1.929-.136-1.275-1.274-1.439-1.411c-.045-.037-.075-.057-.121-.074l-.914 21.104h.023zM11.71 11.305s-.81-.424-1.774-.424c-1.447 0-1.504.906-1.504 1.141 0 1.232 3.24 1.715 3.24 4.629 0 2.295-1.44 3.76-3.406 3.76-2.354 0-3.54-1.465-3.54-1.465l.646-2.086s1.245 1.066 2.28 1.066c.675 0 .975-.545.975-.932 0-1.619-2.654-1.694-2.654-4.359-.034-2.237 1.571-4.416 4.827-4.416 1.257 0 1.875.361 1.875.361l-.945 2.715-.02.01zM11.17.83c.136 0 .271.038.405.135-.984.465-2.064 1.639-2.508 3.992-.656.213-1.293.405-1.889.578C7.697 3.75 8.951.84 11.17.84V.83zm1.235 2.949v.135c-.754.232-1.583.484-2.394.736.466-1.777 1.333-2.645 2.085-2.971.193.501.309 1.176.309 2.1zm.539-2.234c.694.074 1.141.867 1.429 1.755-.349.114-.735.231-1.158.366v-.252c0-.752-.096-1.371-.271-1.871v.002zm2.992 1.289c-.02 0-.06.021-.078.021s-.289.075-.714.21c-.423-1.233-1.176-2.37-2.508-2.37h-.115C12.135.209 11.669 0 11.265 0 8.159 0 6.675 3.877 6.21 5.846c-1.194.365-2.063.636-2.16.674-.675.213-.694.232-.772.87-.075.462-1.83 14.063-1.83 14.063L15.009 24l.927-21.166z" />
</svg>
)
}
+256
View File
@@ -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
}
}
+257
View File
@@ -0,0 +1,257 @@
import React, { useState } from "react"
import { useMutation } from "@tanstack/react-query"
import { Button } from "@workspace/ui/components/button"
import { Input } from "@workspace/ui/components/input"
import { EyeIcon, EyeOffIcon } from "lucide-react"
import { cn } from "@workspace/ui/lib/utils"
import { ThemeToggleButton } from "@workspace/blocks/blocks/appearance"
import { Logo } from "@/components/logo"
export function LoginPage() {
return (
<div className="isolate h-svh min-h-148 w-svw lg:min-h-164.5">
<header className="relative z-10 h-0">
<div className="flex h-14 items-center justify-between px-6">
<div className="size-6 opacity-0 lg:opacity-100">
<Logo />
</div>
<div>
<ThemeToggleButton />
</div>
</div>
</header>
<main className="relative isolate flex size-full before:pointer-events-none before:absolute before:inset-0 before:-z-1 before:bg-[url(https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/background/background-3-blur.webp)] before:bg-cover before:bg-center before:bg-no-repeat before:opacity-20">
<div className="pointer-events-none hidden w-120 flex-col justify-center gap-16 bg-linear-to-r from-secondary/10 to-muted/20 p-6 pt-18 select-none lg:flex">
<div className="text-center">
<h2 className="text-3xl font-bold tracking-tight text-foreground">
👋🏻
</h2>
<p className="mt-4 text-muted-foreground">
</p>
</div>
<img
src="https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/illustrations/illustration-dashboard.webp"
alt=""
className="aspect-4/3 w-full object-cover object-center"
/>
<Features />
</div>
<div className="flex flex-1 flex-col items-start gap-7 bg-sidebar/80 px-6 py-20 lg:justify-center lg:px-8">
<div className="mx-auto mb-10 w-full max-w-md lg:hidden">
<Logo className="size-12" />
</div>
<LoginForm />
<div className="lg:h-24" />
</div>
</main>
</div>
)
}
const features = [
{
label: "Jwt",
img: "https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/icons/platforms/ic-jwt.svg",
},
{
label: "Firebase",
img: "https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/icons/platforms/ic-firebase.svg",
},
{
label: "Amplify",
img: "https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/icons/platforms/ic-amplify.svg",
},
{
label: "Auth0",
img: "https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/icons/platforms/ic-auth0.svg",
},
{
label: "Supabase",
img: "https://pub-c5e31b5cdafb419fb247a8ac2e78df7a.r2.dev/public/assets/icons/platforms/ic-supabase.svg",
},
]
function Features() {
const [index, setIndex] = React.useState(0)
React.useEffect(() => {
const timerId = setInterval(() => {
setIndex((currentIndex) => (currentIndex + 1) % features.length)
}, 1000)
return () => {
clearInterval(timerId)
}
}, [])
return (
<ul className="flex items-center justify-center gap-4">
{features.map((f, i) => (
<li
key={f.label}
className={cn(
"transition-[filter] duration-1000 ease-linear",
i === index ? "grayscale-0" : "grayscale-100"
)}
>
<img className="size-8" alt={f.label} src={f.img} />
</li>
))}
</ul>
)
}
function LoginForm() {
// const navigate = useNavigate()
const [username, setUsername] = useState("")
const [password, setPassword] = useState("")
const mutation = useMutation({
// mutationFn: () => {
// return api.post<AdminAuthResponse>("/api/v1/admin/auth/login", {
// username,
// password,
// })
// },
// onSuccess: () => {
// login()
// Toast("登录成功", "success")
// navigate("/", { replace: true })
// },
// onError: (err: Error) => toast(err.message || "登录失败", "error"),
})
return (
<div className="mx-auto w-full max-w-md">
<h5 className="mb-3 text-2xl font-bold tracking-tight text-foreground">
</h5>
<p className="lg:text-md text-sm text-muted-foreground">
<span className="text-muted-foreground">
</span>
</p>
<form
className="mt-8 space-y-8"
onSubmit={(e) => {
e.preventDefault()
mutation.mutate()
}}
>
<Field
label="用户名"
inputId="username"
Component={UsernameInput}
value={username}
setValue={setUsername}
/>
<Field
label="密码"
inputId="password"
Component={PasswordInput}
value={password}
setValue={setPassword}
/>
<Button
type="submit"
className="mt-2 h-12 w-full rounded-lg"
disabled={mutation.isPending}
>
{mutation.isPending ? "登录中..." : "登录"}
</Button>
</form>
</div>
)
}
type FieldInputProps = {
inputId: string
className?: string
value: string
setValue: React.Dispatch<React.SetStateAction<string>>
}
type FieldInputComponent = React.ComponentType<FieldInputProps>
function Field({
label,
Component,
...props
}: Omit<FieldInputProps, "className"> & {
label: string
Component: FieldInputComponent
}) {
return (
<fieldset className="relative flex h-12 items-center rounded-lg border-2 border-border p-0 focus-within:border-primary focus-within:[&_label]:opacity-100">
<legend className="ml-2 flex h-0 px-0">
<label
className="h-fit -translate-y-1/2 px-1 text-sm opacity-50"
htmlFor={props.inputId}
>
{label}
</label>
</legend>
<Component
className="h-full rounded-none border-none bg-transparent outline-none autofill:bg-transparent focus-visible:border-none focus-visible:ring-0 [&:-webkit-autofill]:[-webkit-background-clip:text] [&:-webkit-autofill]:[-webkit-text-fill-color:var(--foreground)] [&:-webkit-autofill]:[transition:background-color_9999s_ease-out]"
{...props}
/>
</fieldset>
)
}
function UsernameInput({
inputId,
className,
value,
setValue,
}: FieldInputProps) {
return (
<Input
id={inputId}
value={value}
className={className}
onChange={(e) => setValue(e.target.value)}
placeholder="请输入用户名"
required
/>
)
}
function PasswordInput({
inputId,
className,
value,
setValue,
}: FieldInputProps) {
const [isVisible, setVisile] = React.useState(false)
return (
<>
<Input
id={inputId}
type={isVisible ? "text" : "password"}
value={value}
className={cn("relative z-1 pe-24", className)}
onChange={(e) => setValue(e.target.value)}
placeholder="请输入登录密码"
required
/>
<div className="absolute top-0 right-0 z-2 flex h-11 w-11 items-center justify-center">
<Button
type="button"
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
setVisile((v) => !v)
}}
className="rounded-sm"
>
{isVisible ? <EyeIcon /> : <EyeOffIcon />}
</Button>
</div>
</>
)
}
+39 -3
View File
@@ -10,13 +10,20 @@
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as AppRouteImport } from './routes/_app' import { Route as AppRouteImport } from './routes/_app'
import { Route as LoginRouteImport } from './routes/login'
import { Route as AppIndexRouteImport } from './routes/_app.index' import { Route as AppIndexRouteImport } from './routes/_app.index'
import { Route as AppSplatRouteImport } from './routes/_app.$' import { Route as AppSplatRouteImport } from './routes/_app.$'
import { Route as AppChatsRouteImport } from './routes/_app.chats'
const AppRoute = AppRouteImport.update({ const AppRoute = AppRouteImport.update({
id: '/_app', id: '/_app',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
getParentRoute: () => rootRouteImport,
} as any)
const AppIndexRoute = AppIndexRouteImport.update({ const AppIndexRoute = AppIndexRouteImport.update({
id: '/', id: '/',
path: '/', path: '/',
@@ -27,31 +34,43 @@ const AppSplatRoute = AppSplatRouteImport.update({
path: '/$', path: '/$',
getParentRoute: () => AppRoute, getParentRoute: () => AppRoute,
} as any) } as any)
const AppChatsRoute = AppChatsRouteImport.update({
id: '/chats',
path: '/chats',
getParentRoute: () => AppRoute,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/': typeof AppIndexRoute '/': typeof AppIndexRoute
'/login': typeof LoginRoute
'/$': typeof AppSplatRoute '/$': typeof AppSplatRoute
'/chats': typeof AppChatsRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/login': typeof LoginRoute
'/$': typeof AppSplatRoute '/$': typeof AppSplatRoute
'/chats': typeof AppChatsRoute
'/': typeof AppIndexRoute '/': typeof AppIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/_app': typeof AppRouteWithChildren '/_app': typeof AppRouteWithChildren
'/login': typeof LoginRoute
'/_app/$': typeof AppSplatRoute '/_app/$': typeof AppSplatRoute
'/_app/chats': typeof AppChatsRoute
'/_app/': typeof AppIndexRoute '/_app/': typeof AppIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/$' fullPaths: '/' | '/login' | '/$' | '/chats'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/$' | '/' to: '/login' | '/$' | '/chats' | '/'
id: '__root__' | '/_app' | '/_app/$' | '/_app/' id: '__root__' | '/_app' | '/login' | '/_app/$' | '/_app/chats' | '/_app/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
AppRoute: typeof AppRouteWithChildren AppRoute: typeof AppRouteWithChildren
LoginRoute: typeof LoginRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@@ -63,6 +82,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppRouteImport preLoaderRoute: typeof AppRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
parentRoute: typeof rootRouteImport
}
'/_app/': { '/_app/': {
id: '/_app/' id: '/_app/'
path: '/' path: '/'
@@ -77,16 +103,25 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppSplatRouteImport preLoaderRoute: typeof AppSplatRouteImport
parentRoute: typeof AppRoute parentRoute: typeof AppRoute
} }
'/_app/chats': {
id: '/_app/chats'
path: '/chats'
fullPath: '/chats'
preLoaderRoute: typeof AppChatsRouteImport
parentRoute: typeof AppRoute
}
} }
} }
interface AppRouteChildren { interface AppRouteChildren {
AppSplatRoute: typeof AppSplatRoute AppSplatRoute: typeof AppSplatRoute
AppChatsRoute: typeof AppChatsRoute
AppIndexRoute: typeof AppIndexRoute AppIndexRoute: typeof AppIndexRoute
} }
const AppRouteChildren: AppRouteChildren = { const AppRouteChildren: AppRouteChildren = {
AppSplatRoute: AppSplatRoute, AppSplatRoute: AppSplatRoute,
AppChatsRoute: AppChatsRoute,
AppIndexRoute: AppIndexRoute, AppIndexRoute: AppIndexRoute,
} }
@@ -94,6 +129,7 @@ const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren)
const rootRouteChildren: RootRouteChildren = { const rootRouteChildren: RootRouteChildren = {
AppRoute: AppRouteWithChildren, AppRoute: AppRouteWithChildren,
LoginRoute: LoginRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)
+7
View File
@@ -0,0 +1,7 @@
import { createFileRoute } from "@tanstack/react-router"
import { ChatsDemoPage } from "@/pages/chats-demo-page"
export const Route = createFileRoute("/_app/chats")({
component: ChatsDemoPage,
})
+6
View File
@@ -0,0 +1,6 @@
import { LoginPage } from "@/pages/login-page"
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/login")({
component: LoginPage,
})