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,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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { LoginPage } from "@/pages/login-page"
|
||||||
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/login")({
|
||||||
|
component: LoginPage,
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user