diff --git a/apps/web/src/components/logo.tsx b/apps/web/src/components/logo.tsx new file mode 100644 index 0000000..0513c9e --- /dev/null +++ b/apps/web/src/components/logo.tsx @@ -0,0 +1,19 @@ +import type React from "react" + +type Props = Omit, "role" | "viewBox" | "xmlns"> + +export function Logo(props: Props) { + return ( + + ) +} diff --git a/apps/web/src/pages/chats-demo-page.tsx b/apps/web/src/pages/chats-demo-page.tsx new file mode 100644 index 0000000..5a6ee61 --- /dev/null +++ b/apps/web/src/pages/chats-demo-page.tsx @@ -0,0 +1,256 @@ +import * as React from "react" +import { + ChatWorkspace, + type ChatConversation, + type ChatMessage, +} from "@workspace/blocks/chats" + +const mockMediaUrls: Record = { + "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()) + + 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["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 = {} + 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 ( +
+
+

聊天模拟器

+

+ 所有数据仅保存在浏览器内,可模拟微信小程序客服的文本、图片、图文链接和小程序卡片消息。 +

+
+ + setActiveConversationId(conversation.id) + } + onLoadEarlierMessages={handleLoadEarlierMessages} + onSend={handleSend} + resolveMediaUrl={(mediaId) => mediaUrls[mediaId]} + /> +
+ ) +} + +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 + } +} diff --git a/apps/web/src/pages/login-page.tsx b/apps/web/src/pages/login-page.tsx new file mode 100644 index 0000000..9e63d6a --- /dev/null +++ b/apps/web/src/pages/login-page.tsx @@ -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 ( +
+
+
+
+ +
+
+ +
+
+
+
+
+
+

+ 欢迎回来 👋🏻 +

+

+ 让商品、订单与运营管理更简单高效。 +

+
+ + +
+
+
+ +
+ +
+
+
+
+ ) +} + +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 ( +
    + {features.map((f, i) => ( +
  • + {f.label} +
  • + ))} +
+ ) +} + +function LoginForm() { + // const navigate = useNavigate() + const [username, setUsername] = useState("") + const [password, setPassword] = useState("") + + const mutation = useMutation({ + // mutationFn: () => { + // return api.post("/api/v1/admin/auth/login", { + // username, + // password, + // }) + // }, + // onSuccess: () => { + // login() + // Toast("登录成功", "success") + // navigate("/", { replace: true }) + // }, + // onError: (err: Error) => toast(err.message || "登录失败", "error"), + }) + + return ( +
+
+ 账号登录 +
+

+ + 请输入您的帐户信息以开始管理您的系统 + +

+
{ + e.preventDefault() + mutation.mutate() + }} + > + + + + +
+ ) +} + +type FieldInputProps = { + inputId: string + className?: string + value: string + setValue: React.Dispatch> +} + +type FieldInputComponent = React.ComponentType + +function Field({ + label, + Component, + ...props +}: Omit & { + label: string + Component: FieldInputComponent +}) { + return ( +
+ + + + +
+ ) +} + +function UsernameInput({ + inputId, + className, + value, + setValue, +}: FieldInputProps) { + return ( + setValue(e.target.value)} + placeholder="请输入用户名" + required + /> + ) +} + +function PasswordInput({ + inputId, + className, + value, + setValue, +}: FieldInputProps) { + const [isVisible, setVisile] = React.useState(false) + + return ( + <> + setValue(e.target.value)} + placeholder="请输入登录密码" + required + /> +
+ +
+ + ) +} diff --git a/apps/web/src/route-tree.gen.ts b/apps/web/src/route-tree.gen.ts index e0e762c..c854a87 100644 --- a/apps/web/src/route-tree.gen.ts +++ b/apps/web/src/route-tree.gen.ts @@ -10,13 +10,20 @@ import { Route as rootRouteImport } from './routes/__root' 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 AppSplatRouteImport } from './routes/_app.$' +import { Route as AppChatsRouteImport } from './routes/_app.chats' const AppRoute = AppRouteImport.update({ id: '/_app', getParentRoute: () => rootRouteImport, } as any) +const LoginRoute = LoginRouteImport.update({ + id: '/login', + path: '/login', + getParentRoute: () => rootRouteImport, +} as any) const AppIndexRoute = AppIndexRouteImport.update({ id: '/', path: '/', @@ -27,31 +34,43 @@ const AppSplatRoute = AppSplatRouteImport.update({ path: '/$', getParentRoute: () => AppRoute, } as any) +const AppChatsRoute = AppChatsRouteImport.update({ + id: '/chats', + path: '/chats', + getParentRoute: () => AppRoute, +} as any) export interface FileRoutesByFullPath { '/': typeof AppIndexRoute + '/login': typeof LoginRoute '/$': typeof AppSplatRoute + '/chats': typeof AppChatsRoute } export interface FileRoutesByTo { + '/login': typeof LoginRoute '/$': typeof AppSplatRoute + '/chats': typeof AppChatsRoute '/': typeof AppIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/_app': typeof AppRouteWithChildren + '/login': typeof LoginRoute '/_app/$': typeof AppSplatRoute + '/_app/chats': typeof AppChatsRoute '/_app/': typeof AppIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/$' + fullPaths: '/' | '/login' | '/$' | '/chats' fileRoutesByTo: FileRoutesByTo - to: '/$' | '/' - id: '__root__' | '/_app' | '/_app/$' | '/_app/' + to: '/login' | '/$' | '/chats' | '/' + id: '__root__' | '/_app' | '/login' | '/_app/$' | '/_app/chats' | '/_app/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { AppRoute: typeof AppRouteWithChildren + LoginRoute: typeof LoginRoute } declare module '@tanstack/react-router' { @@ -63,6 +82,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppRouteImport parentRoute: typeof rootRouteImport } + '/login': { + id: '/login' + path: '/login' + fullPath: '/login' + preLoaderRoute: typeof LoginRouteImport + parentRoute: typeof rootRouteImport + } '/_app/': { id: '/_app/' path: '/' @@ -77,16 +103,25 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AppSplatRouteImport parentRoute: typeof AppRoute } + '/_app/chats': { + id: '/_app/chats' + path: '/chats' + fullPath: '/chats' + preLoaderRoute: typeof AppChatsRouteImport + parentRoute: typeof AppRoute + } } } interface AppRouteChildren { AppSplatRoute: typeof AppSplatRoute + AppChatsRoute: typeof AppChatsRoute AppIndexRoute: typeof AppIndexRoute } const AppRouteChildren: AppRouteChildren = { AppSplatRoute: AppSplatRoute, + AppChatsRoute: AppChatsRoute, AppIndexRoute: AppIndexRoute, } @@ -94,6 +129,7 @@ const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren) const rootRouteChildren: RootRouteChildren = { AppRoute: AppRouteWithChildren, + LoginRoute: LoginRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/_app.chats.tsx b/apps/web/src/routes/_app.chats.tsx new file mode 100644 index 0000000..c69ba04 --- /dev/null +++ b/apps/web/src/routes/_app.chats.tsx @@ -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, +}) diff --git a/apps/web/src/routes/login.tsx b/apps/web/src/routes/login.tsx new file mode 100644 index 0000000..1aa95a4 --- /dev/null +++ b/apps/web/src/routes/login.tsx @@ -0,0 +1,6 @@ +import { LoginPage } from "@/pages/login-page" +import { createFileRoute } from "@tanstack/react-router" + +export const Route = createFileRoute("/login")({ + component: LoginPage, +})