init repo

This commit is contained in:
liuliu
2025-12-15 12:45:54 +08:00
commit f771369e97
26 changed files with 1619 additions and 0 deletions
+106
View File
@@ -0,0 +1,106 @@
import { Elysia } from "elysia";
import { db } from "./db";
// Define the API app
export const api = new Elysia({ prefix: "/api" })
.get("/conversations", () => {
const conversations = db.query(`
SELECT
c.id,
c.unread_count as unreadCount,
u.id as userId,
u.name as userName,
u.avatar as userAvatar
FROM conversations c
JOIN users u ON c.user_id = u.id
`).all() as any[];
const result = conversations.map(conv => {
const lastMsg = db.query(`SELECT * FROM messages WHERE conversation_id = $cid ORDER BY timestamp DESC LIMIT 1`).get({ $cid: conv.id }) as any;
const messages = lastMsg ? [{
id: lastMsg.id,
content: lastMsg.content,
senderId: lastMsg.sender_id,
timestamp: lastMsg.timestamp,
type: lastMsg.type
}] : [];
return {
id: conv.id,
unreadCount: conv.unreadCount,
user: {
id: conv.userId,
name: conv.userName,
avatar: conv.userAvatar
},
messages: messages
};
});
return result;
})
.get("/messages/:conversationId", ({ params, query }) => {
const cid = params.conversationId;
const limit = query.limit ? parseInt(query.limit as string) : 10;
const before = query.before;
const queryParam = query.q;
const startDate = query.startDate;
const endDate = query.endDate;
let sql = `
SELECT m.*, u.name as senderName, u.avatar as senderAvatar
FROM messages m
LEFT JOIN users u ON m.sender_id = u.id
WHERE conversation_id = $cid
`;
let sqlParams: any = { $cid: cid, $limit: limit };
if (before) {
sql += ` AND timestamp < $before`;
sqlParams.$before = parseInt(before as string);
}
if (queryParam) {
sql += ` AND content LIKE $q`;
sqlParams.$q = `%${queryParam as string}%`;
}
if (startDate) {
sql += ` AND timestamp >= $startDate`;
sqlParams.$startDate = parseInt(startDate as string);
}
if (endDate) {
sql += ` AND timestamp <= $endDate`;
sqlParams.$endDate = parseInt(endDate as string);
}
sql += ` ORDER BY timestamp DESC LIMIT $limit`;
const messages = db.query(sql).all(sqlParams) as any[];
messages.reverse();
return messages.map(m => ({
id: m.id,
content: m.content,
senderId: m.sender_id,
timestamp: m.timestamp,
type: m.type,
senderName: m.senderName,
senderAvatar: m.senderAvatar
}));
})
.post("/import", async ({ body }) => {
try {
const data = body;
const { importChatData } = await import("./db");
// @ts-ignore
const count = importChatData(data);
return { success: true, messageCount: count };
} catch (err: any) {
console.error('Import failed:', err);
throw new Error(err.message);
}
});
export type App = typeof api;
+155
View File
@@ -0,0 +1,155 @@
import { Database } from "bun:sqlite";
import { mkdirSync } from "fs";
// Ensure data directory exists
try {
mkdirSync("data");
} catch (e) { }
export const db = new Database("data/chat.db");
export function initDB() {
// Users
db.run(`
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
avatar TEXT NOT NULL
)
`);
// Conversations
db.run(`
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
unread_count INTEGER DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
// Messages
db.run(`
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
content TEXT NOT NULL,
sender_id TEXT NOT NULL,
timestamp INTEGER NOT NULL,
type TEXT DEFAULT 'text',
FOREIGN KEY (conversation_id) REFERENCES conversations(id)
)
`);
// Seed data if empty
const userCount = db.query("SELECT count(*) as count FROM users").get() as { count: number };
if (userCount.count === 0) {
console.log("🌱 Seeding database...");
// Insert Users
const insertUser = db.prepare("INSERT INTO users (id, name, avatar) VALUES ($id, $name, $avatar)");
const users = [
{ $id: 'me', $name: '我', $avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Felix' },
{ $id: 'u1', $name: '老王', $avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Jack' },
{ $id: 'u2', $name: '李安', $avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Aneka' },
{ $id: 'u3', $name: '产品经理', $avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Trouble' },
];
users.forEach(u => insertUser.run(u));
// Insert Conversations
const insertConv = db.prepare("INSERT INTO conversations (id, user_id, unread_count) VALUES ($id, $userId, $unread)");
insertConv.run({ $id: 'c1', $userId: 'u1', $unread: 2 });
insertConv.run({ $id: 'c2', $userId: 'u2', $unread: 0 });
insertConv.run({ $id: 'c3', $userId: 'u3', $unread: 5 });
// Insert Messages
const insertMsg = db.prepare("INSERT INTO messages (id, conversation_id, content, sender_id, timestamp, type) VALUES ($id, $cid, $content, $sender, $ts, $type)");
const now = Date.now();
// c1 messages
insertMsg.run({ $id: 'm1', $cid: 'c1', $content: '周末有空去钓鱼吗?', $sender: 'u1', $ts: now - 1000 * 60 * 60 * 2, $type: 'text' });
insertMsg.run({ $id: 'm2', $cid: 'c1', $content: '上次那个水库不错。', $sender: 'u1', $ts: now - 1000 * 60 * 60 * 2 + 5000, $type: 'text' });
insertMsg.run({ $id: 'm3', $cid: 'c1', $content: '可以啊,几点出发?', $sender: 'me', $ts: now - 1000 * 60 * 30, $type: 'text' });
insertMsg.run({ $id: 'm4', $cid: 'c1', $content: '早上6点吧,老地方见。', $sender: 'u1', $ts: now - 1000 * 60 * 5, $type: 'text' });
// c2 messages
insertMsg.run({ $id: 'm21', $cid: 'c2', $content: '方案我已经发你邮箱了,记得看一下。', $sender: 'u2', $ts: now - 1000 * 60 * 60 * 24, $type: 'text' });
insertMsg.run({ $id: 'm22', $cid: 'c2', $content: '好的,我晚上回去看。', $sender: 'me', $ts: now - 1000 * 60 * 60 * 23, $type: 'text' });
// c3 messages
insertMsg.run({ $id: 'm31', $cid: 'c3', $content: '这个需求还要再改一下。', $sender: 'u3', $ts: now - 1000 * 60 * 10, $type: 'text' });
insertMsg.run({ $id: 'm32', $cid: 'c3', $content: '老板说要五彩斑斓的黑。', $sender: 'u3', $ts: now - 1000 * 60 * 9, $type: 'text' });
}
}
export function importChatData(data: any) {
const { session, messages } = data;
// 1. Ensure "Group" User exists (The chat itself is treated as a user for the list)
const upsertUser = db.prepare(`
INSERT INTO users (id, name, avatar) VALUES ($id, $name, $avatar)
ON CONFLICT(id) DO UPDATE SET name=excluded.name
`);
const groupAvatar = 'https://api.dicebear.com/7.x/identicon/svg?seed=' + session.wxid;
upsertUser.run({ $id: session.wxid, $name: session.displayName || session.nickname, $avatar: groupAvatar });
// 2. Upsert Conversation
const upsertConv = db.prepare(`
INSERT INTO conversations (id, user_id, unread_count) VALUES ($id, $userId, 0)
ON CONFLICT(id) DO NOTHING
`);
upsertConv.run({ $id: session.wxid, $userId: session.wxid });
// 3. Process Messages Transaction
const insertMessage = db.prepare(`
INSERT INTO messages (id, conversation_id, content, sender_id, timestamp, type)
VALUES ($id, $cid, $content, $sender, $ts, $type)
ON CONFLICT(id) DO UPDATE SET
content=excluded.content,
sender_id=excluded.sender_id,
timestamp=excluded.timestamp,
type=excluded.type
`);
let importedCount = 0;
const transaction = db.transaction((msgs: any[]) => {
for (const msg of msgs) {
// Filter: Only import if sender is 'pincman' AND content contains '@所有人'
const isPincman = msg.senderDisplayName === 'pincman' || msg.senderUsername === 'pincman';
const hasAtAll = msg.content && msg.content.includes('@所有人');
if (!isPincman || !hasAtAll) {
continue;
}
importedCount++;
// Ensure sender exists as a user
// If nickname is missing, fallback to ID, or generic name
const senderName = msg.senderDisplayName || msg.senderUsername || 'Unknown';
const senderAvatar = 'https://api.dicebear.com/7.x/avataaars/svg?seed=' + (msg.senderUsername || 'unknown');
// We only insert sender if they have a username/id and are NOT the session owner (group itself)
if (msg.senderUsername && msg.senderUsername !== session.wxid) {
upsertUser.run({ $id: msg.senderUsername, $name: senderName, $avatar: senderAvatar });
}
const uniqueId = `${session.wxid}_${msg.localId}`; // Composite ID to prevent collisions across chats
insertMessage.run({
$id: uniqueId,
$cid: session.wxid,
$content: msg.content,
$sender: msg.senderUsername || 'system',
$ts: msg.createTime * 1000,
$type: msg.localType === 1 ? 'text' : 'unknown'
});
}
});
transaction(messages);
return importedCount;
}
+27
View File
@@ -0,0 +1,27 @@
import { serve } from "bun";
import index from "../frontend/index.html"; // Updated relative import
import { initDB } from "./db";
import { api } from "./api"; // Elysia app
// Initialize database
initDB();
const server = serve({
routes: {
// API requests handled by Elysia fetch
"/api/*": api.fetch,
// Frontend handled by Bun's native HMR/transpiler/asset serving
"/*": index,
},
// Increase global request body size limit for Bun server (default is 128MB)
maxRequestBodySize: 1024 * 1024 * 512, // 512MB
development: process.env.NODE_ENV !== "production" && {
hmr: true,
console: true,
},
});
console.log(`🚀 Server running at ${server.url}`);
+57
View File
@@ -0,0 +1,57 @@
import { useState, useEffect } from 'react';
import { Sidebar } from './components/Sidebar';
import { ChatList } from './components/ChatList';
import { ChatWindow } from './components/ChatWindow';
import { Admin } from './components/Admin';
import type { Conversation } from './data/mock';
import "./index.css";
import { client } from './client';
export function App() {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [activeId, setActiveId] = useState<string | null>(null);
// Simple "routing"
const isAdmin = window.location.pathname === '/admin';
if (isAdmin) {
return <Admin />;
}
useEffect(() => {
const fetchConversations = async () => {
const { data, error } = await client.api.conversations.get();
if (data && !error) {
// @ts-ignore
setConversations(data);
if (data.length > 0 && !activeId) {
// @ts-ignore
setActiveId(data[0].id);
}
} else {
console.error('Failed to fetch conversations:', error);
}
};
fetchConversations();
}, []);
const activeConversation = conversations.find(c => c.id === activeId);
return (
<div className="flex flex-col h-screen w-screen bg-[#c8c8c8] items-center justify-center p-0 md:p-10 font-sans antialiased">
<h1 className="text-2xl font-bold text-gray-700 mb-4 tracking-wider hidden md:block select-none"></h1>
<div className="flex w-full h-full md:w-[900px] md:h-[650px] bg-white text-black shadow-2xl overflow-hidden rounded-[2px]">
<Sidebar />
<ChatList
conversations={conversations}
activeId={activeId}
onSelect={setActiveId}
/>
<ChatWindow conversation={activeConversation} />
</div>
</div>
);
}
export default App;
+5
View File
@@ -0,0 +1,5 @@
import { treaty } from '@elysiajs/eden';
import type { App } from '../backend/api';
// Create a single client instance
export const client = treaty<App>('localhost:3000');
+108
View File
@@ -0,0 +1,108 @@
import { useState } from 'react';
import { Upload, FileText, CheckCircle, AlertCircle } from 'lucide-react';
import { clsx } from 'clsx';
import { client } from '../client';
export function Admin() {
const [file, setFile] = useState<File | null>(null);
const [status, setStatus] = useState<'idle' | 'uploading' | 'success' | 'error'>('idle');
const [message, setMessage] = useState('');
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
setFile(e.target.files[0]);
setStatus('idle');
setMessage('');
}
};
const handleUpload = async () => {
if (!file) return;
setStatus('uploading');
try {
const text = await file.text();
let json;
try {
json = JSON.parse(text);
} catch (e) {
throw new Error('Invalid JSON file');
}
const { data, error } = await client.api.import.post(json);
if (error) {
throw new Error('Import failed');
}
setStatus('success');
setMessage(`Imported ${data.messageCount} messages successfully!`);
setFile(null);
} catch (err: any) {
setStatus('error');
setMessage(err.message || 'Something went wrong');
}
};
return (
<div className="flex h-screen w-screen bg-[#f5f5f5] items-center justify-center font-sans">
<div className="w-[500px] bg-white p-8 rounded-lg shadow-lg">
<h1 className="text-2xl font-bold mb-6 text-gray-800 flex items-center gap-3">
<Upload className="w-8 h-8 text-[#07c160]" />
Admin Import
</h1>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-10 flex flex-col items-center justify-center gap-4 transition-colors hover:border-[#07c160] hover:bg-gray-50 relative">
<input
type="file"
accept=".json"
onChange={handleFileChange}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
{file ? (
<div className="flex flex-col items-center gap-2 text-[#07c160]">
<FileText className="w-12 h-12" />
<span className="font-medium text-lg text-center break-all">{file.name}</span>
</div>
) : (
<div className="flex flex-col items-center gap-2 text-gray-400">
<Upload className="w-12 h-12" />
<span className="font-medium">Drop JSON file or click to browse</span>
</div>
)}
</div>
{status === 'idle' && file && (
<button
onClick={handleUpload}
className="w-full mt-6 bg-[#07c160] text-white py-3 rounded-md font-medium hover:bg-[#06ad56] transition-colors"
>
Start Import
</button>
)}
{status === 'uploading' && (
<div className="mt-6 flex items-center justify-center gap-2 text-gray-600">
<div className="w-5 h-5 border-2 border-[#07c160] border-t-transparent rounded-full animate-spin"></div>
Processing...
</div>
)}
{status === 'success' && (
<div className="mt-6 p-4 bg-green-50 text-green-700 rounded-md flex items-center gap-3">
<CheckCircle className="w-5 h-5 shrink-0" />
{message}
</div>
)}
{status === 'error' && (
<div className="mt-6 p-4 bg-red-50 text-red-700 rounded-md flex items-center gap-3">
<AlertCircle className="w-5 h-5 shrink-0" />
{message}
</div>
)}
</div>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { Search, Plus } from 'lucide-react';
import { clsx } from 'clsx';
import type { Conversation } from '../data/mock';
interface ChatListProps {
conversations: Conversation[];
activeId: string | null;
onSelect: (id: string) => void;
}
export function ChatList({ conversations, activeId, onSelect }: ChatListProps) {
const formatTime = (timestamp: number) => {
const date = new Date(timestamp);
const now = new Date();
if (date.toDateString() === now.toDateString()) {
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' });
};
return (
<div className="w-[250px] bg-[#e6e5e5] flex flex-col h-full border-r border-[#d6d6d6] select-none">
{/* Search Header */}
<div className="h-[60px] flex items-center px-3 gap-2 shrink-0 bg-[#f7f7f7] bg-opacity-50">
<div className="flex-1 bg-[#dcd9d8] h-7 rounded-[4px] flex items-center px-2 gap-1.5 border border-[#dcd9d8] focus-within:bg-white focus-within:border-[#cecece] transition-colors">
<Search className="w-4 h-4 text-[#666]" />
<input
type="text"
placeholder="搜索"
className="bg-transparent border-none outline-none text-xs w-full text-black placeholder:text-[#888]"
/>
</div>
<div className="w-7 h-7 bg-[#dcd9d8] hover:bg-[#d1d1d1] rounded-[4px] flex items-center justify-center cursor-pointer transition-colors">
<Plus className="w-4 h-4 text-[#444]" />
</div>
</div>
{/* List */}
<div className="flex-1 overflow-y-auto overflow-x-hidden scrollbar-thin">
{conversations.map((conv) => (
<div
key={conv.id}
onClick={() => onSelect(conv.id)}
className={clsx(
"h-16 flex items-center px-3 gap-3 cursor-pointer transition-colors relative",
activeId === conv.id ? "bg-[#c5c5c6]" : "hover:bg-[#d9d8d8]"
)}
>
<div className="relative">
<img src={conv.user.avatar} className="w-10 h-10 rounded-[4px]" alt="" />
{conv.unreadCount > 0 && (
<div className="absolute -top-1.5 -right-1.5 bg-[#fa5151] text-white text-[10px] h-4 min-w-[16px] px-1 flex items-center justify-center rounded-full">
{conv.unreadCount}
</div>
)}
</div>
<div className="flex-1 min-w-0 flex flex-col justify-center gap-0.5">
<div className="flex justify-between items-center">
<span className="text-[14px] text-black font-medium truncate">{conv.user.name}</span>
<span className="text-[10px] text-[#999] shrink-0">
{conv.messages.length > 0 && formatTime(conv.messages[conv.messages.length - 1]!.timestamp)}
</span>
</div>
<div className="text-[12px] text-[#999] truncate">
{conv.messages.length > 0
? conv.messages[conv.messages.length - 1]?.content
: <span className="italic">No messages</span>}
</div>
</div>
</div>
))}
</div>
</div>
);
}
+267
View File
@@ -0,0 +1,267 @@
import { MoreHorizontal, Smile, FolderOpen, Scissors, Clipboard, Search, X } from 'lucide-react';
import { client } from '../client';
import type { Conversation } from '../data/mock';
import { MessageBubble } from './MessageBubble';
import { clsx } from 'clsx';
import { useEffect, useRef, useState } from 'react';
interface ChatWindowProps {
conversation: Conversation | undefined;
}
export function ChatWindow({ conversation }: ChatWindowProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [inputValue, setInputValue] = useState('');
const [messages, setMessages] = useState(conversation?.messages || []);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(false);
// Search state
const [isSearching, setIsSearching] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const fetchMessages = async (isLoadMore = false, query = '', start = '', end = '') => {
if (!conversation?.id || loading) return;
setLoading(true);
try {
let limit = 10;
const beforeTimestamp = isLoadMore && messages.length > 0 ? messages[0]!.timestamp : undefined;
const { data, error } = await client.api.messages({ conversationId: conversation.id }).get({
query: {
limit: limit.toString(),
before: beforeTimestamp ? beforeTimestamp.toString() : undefined,
q: query || undefined,
startDate: start ? new Date(start).getTime().toString() : undefined,
endDate: end ? new Date(end).setHours(23, 59, 59, 999).toString() : undefined
}
});
if (data && !error) {
// @ts-ignore
const newMessages = data;
if (newMessages.length < limit) {
setHasMore(false);
} else {
setHasMore(true);
}
if (isLoadMore) {
// Maintain scroll position
if (scrollRef.current) {
const oldHeight = scrollRef.current.scrollHeight;
setMessages(prev => [...newMessages, ...prev]);
// We need to wait for render to adjust scroll, useEffect layout effect is better but setTimeout works for simple case
requestAnimationFrame(() => {
if (scrollRef.current) {
const newHeight = scrollRef.current.scrollHeight;
scrollRef.current.scrollTop = newHeight - oldHeight;
}
});
}
} else {
setMessages(newMessages);
// Scroll to bottom on initial load
setTimeout(() => {
requestAnimationFrame(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
});
}, 10);
}
} else {
console.error(error);
}
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
// Trigger search
useEffect(() => {
if (isSearching) {
const handler = setTimeout(() => {
setMessages([]);
setHasMore(true);
fetchMessages(false, searchQuery, startDate, endDate);
}, 300); // Debounce
return () => clearTimeout(handler);
} else {
if (searchQuery !== '') {
setSearchQuery('');
// fetchMessages handled by conversation change or we should reset?
// If we close search, we probably want to see latest messages effectively resetting view
setMessages([]);
setHasMore(true);
fetchMessages(false, '', '', '');
}
}
}, [searchQuery, isSearching, startDate, endDate]); // Be careful with dependency loops, but this seems okay
// Initial load when conversation changes
useEffect(() => {
setMessages([]);
setHasMore(true);
setIsSearching(false);
setSearchQuery('');
setStartDate('');
setEndDate('');
fetchMessages(false);
}, [conversation?.id]);
const handleScroll = () => {
if (scrollRef.current && scrollRef.current.scrollTop === 0 && hasMore && !loading) {
fetchMessages(true, isSearching ? searchQuery : '', startDate, endDate);
}
};
if (!conversation) {
return (
<div className="flex-1 bg-[#f5f5f5] flex items-center justify-center text-[#ccc] select-none">
<div className="text-6xl opacity-20">WeChat</div>
</div>
);
}
const handleSend = () => {
if (!inputValue.trim()) return;
// Mock send - in real app would call API
console.log('Sending:', inputValue);
setInputValue('');
};
return (
<div className="flex-1 flex flex-col h-full bg-[#f5f5f5]">
{/* Header */}
<div className="h-[60px] border-b border-[#e7e7e7] flex items-center justify-between px-6 select-none shrink-0 relative">
{isSearching ? (
<div className="flex-1 flex items-center bg-white rounded-md px-2 py-1 mr-4 border border-gray-200">
<Search className="w-4 h-4 text-gray-400 mr-2" />
<input
className="flex-1 outline-none text-sm"
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
autoFocus
/>
<input
type="date"
className="text-sm border border-gray-300 rounded px-1 ml-2 text-gray-600 outline-none"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
title="Start Date"
/>
<span className="text-gray-400 mx-1">-</span>
<input
type="date"
className="text-sm border border-gray-300 rounded px-1 text-gray-600 outline-none"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
title="End Date"
/>
<X
className="w-4 h-4 text-gray-400 cursor-pointer hover:text-gray-600"
onClick={() => {
setIsSearching(false);
setSearchQuery('');
setStartDate('');
setEndDate('');
}}
/>
</div>
) : (
<div className="text-[18px] font-medium text-black cursor-pointer hover:underline decoration-1 underline-offset-4 truncate max-w-[70%]">
{conversation.user.name}
</div>
)}
<div className="flex items-center gap-4 text-[#999]">
{!isSearching && (
<Search
className="w-5 h-5 cursor-pointer hover:text-[#666]"
onClick={() => setIsSearching(true)}
/>
)}
<MoreHorizontal className="w-5 h-5 cursor-pointer hover:text-[#666]" />
</div>
</div>
{/* Messages */}
<div
className="flex-1 overflow-y-auto p-6 scrollbar-thin"
ref={scrollRef}
onScroll={handleScroll}
>
{loading && messages.length > 0 && (
<div className="text-center text-xs text-gray-400 py-2">Loading...</div>
)}
{messages.map((msg, index) => {
const prevMsg = messages[index - 1];
const showTime = !prevMsg || (msg.timestamp - prevMsg.timestamp > 5 * 60 * 1000); // 5 mins
return (
<div key={msg.id}>
{showTime && (
<div className="text-center text-[#cfcfcf] text-[12px] my-4 select-none">
{new Date(msg.timestamp).toLocaleString([], {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</div>
)}
<MessageBubble
message={msg}
user={conversation.user}
highlight={isSearching ? searchQuery : undefined}
/>
</div>
);
})}
</div>
{/* Input Area */}
<div className="h-[180px] border-t border-[#e7e7e7] flex flex-col shrink-0 bg-[#f5f5f5]">
{/* Toolbar */}
<div className="h-10 flex items-center px-4 gap-4 text-[#666]">
<Smile className="w-5 h-5 cursor-pointer hover:text-[#333]" />
<FolderOpen className="w-5 h-5 cursor-pointer hover:text-[#333]" />
{/* Fake scissors icon using a character or placeholder if icon missing, but lucide has Scissors usually. Using generic placeholders if not sure */}
</div>
{/* Text Area */}
<textarea
className="flex-1 bg-transparent resize-none outline-none px-6 py-2 text-[14px] leading-relaxed font-sans placeholder:text-[#ccc]"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
/>
{/* Footer */}
<div className="h-10 flex items-center justify-end px-6 pb-2">
<button
onClick={handleSend}
className="bg-[#e9e9e9] text-[#07c160] hover:bg-[#d2d2d2] hover:text-white transition-colors text-sm px-6 py-1.5 rounded-[4px] disabled:opacity-50 disabled:cursor-not-allowed"
>
(S)
</button>
</div>
</div>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { clsx } from 'clsx';
import { currentUser, type Message, type User } from '../data/mock';
interface MessageBubbleProps {
message: Message;
user: User;
highlight?: string;
}
export function MessageBubble({ message, user, highlight }: MessageBubbleProps) {
const isMe = message.senderId === user.id;
// Basic highlight function
const renderContent = (content: string, highlight?: string) => {
if (!highlight || !highlight.trim()) return content;
const parts = content.split(new RegExp(`(${highlight})`, 'gi'));
return parts.map((part, index) =>
part.toLowerCase() === highlight.toLowerCase()
? <span key={index} className="bg-[#fff450] text-black">{part}</span>
: part
);
};
return (
<div className={clsx("flex gap-3 mb-4", isMe ? "flex-row-reverse" : "flex-row")}>
<img
src={isMe ? currentUser.avatar : (message.senderAvatar || user.avatar)}
alt="Avatar"
className="w-9 h-9 rounded-[4px] select-none bg-white shrink-0"
/>
<div className={clsx("max-w-[70%] group flex flex-col", isMe ? "items-end" : "items-start")}>
{!isMe && message.senderName && (
<div className="text-[#b2b2b2] text-[12px] mb-1 ml-1 select-none">
{message.senderName}
</div>
)}
<div
className={clsx(
"px-2.5 py-2 rounded-[4px] text-[14px] leading-relaxed break-words relative",
isMe
? "bg-[#95ec69] text-black"
: "bg-white text-black border border-gray-200/50"
)}
>
{/* Triangle arrow */}
<div
className={clsx(
"absolute top-3 w-2 h-2 rotate-45",
isMe ? "-right-1 bg-[#95ec69]" : "-left-1 bg-white border-l border-b border-gray-200/50"
)}
/>
<span className="relative z-10">
{message.type === 'text' ? renderContent(message.content, highlight) : message.content}
</span>
</div>
</div>
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { MessageSquare, Users2, Box, Menu, Settings } from 'lucide-react';
import { clsx } from 'clsx';
import { currentUser } from '../data/mock';
export function Sidebar() {
return (
<div className="w-[60px] bg-[#2e2e2e] flex flex-col items-center py-4 justify-between h-full select-none">
<div className="flex flex-col items-center gap-6">
<img
src={currentUser.avatar}
alt="Avatar"
className="w-9 h-9 rounded-md bg-white cursor-pointer hover:opacity-80 transition-opacity"
/>
<div className="flex flex-col gap-5 text-[#979797]">
<div className="relative group cursor-pointer">
<MessageSquare className="w-6 h-6 text-[#07c160]" />
</div>
<div className="relative group cursor-pointer hover:text-[#d6d6d6] transition-colors">
<Users2 className="w-6 h-6" />
</div>
<div className="relative group cursor-pointer hover:text-[#d6d6d6] transition-colors">
<Box className="w-6 h-6" />
</div>
</div>
</div>
<div className="flex flex-col items-center gap-4 text-[#979797] pb-2">
<div className="relative group cursor-pointer hover:text-[#d6d6d6] transition-colors">
<Menu className="w-6 h-6" />
</div>
</div>
</div>
);
}
+126
View File
@@ -0,0 +1,126 @@
export interface User {
id: string;
name: string;
avatar: string;
}
export interface Message {
id: string;
content: string;
senderId: string;
timestamp: number;
type: 'text' | 'image';
senderName?: string;
senderAvatar?: string;
}
export interface Conversation {
id: string;
user: User;
messages: Message[];
unreadCount: number;
}
export const currentUser: User = {
id: 'me',
name: '我',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Felix',
};
const user1: User = {
id: 'u1',
name: '老王',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Jack',
};
const user2: User = {
id: 'u2',
name: '李安',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Aneka',
};
const user3: User = {
id: 'u3',
name: '产品经理',
avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Trouble',
};
export const conversations: Conversation[] = [
{
id: 'c1',
user: user1,
unreadCount: 2,
messages: [
{
id: 'm1',
content: '周末有空去钓鱼吗?',
senderId: 'u1',
timestamp: Date.now() - 1000 * 60 * 60 * 2,
type: 'text',
},
{
id: 'm2',
content: '上次那个水库不错。',
senderId: 'u1',
timestamp: Date.now() - 1000 * 60 * 60 * 2 + 5000,
type: 'text',
},
{
id: 'm3',
content: '可以啊,几点出发?',
senderId: 'me',
timestamp: Date.now() - 1000 * 60 * 30,
type: 'text',
},
{
id: 'm4',
content: '早上6点吧,老地方见。',
senderId: 'u1',
timestamp: Date.now() - 1000 * 60 * 5,
type: 'text',
},
],
},
{
id: 'c2',
user: user2,
unreadCount: 0,
messages: [
{
id: 'm21',
content: '方案我已经发你邮箱了,记得看一下。',
senderId: 'u2',
timestamp: Date.now() - 1000 * 60 * 60 * 24,
type: 'text',
},
{
id: 'm22',
content: '好的,我晚上回去看。',
senderId: 'me',
timestamp: Date.now() - 1000 * 60 * 60 * 23,
type: 'text',
},
],
},
{
id: 'c3',
user: user3,
unreadCount: 5,
messages: [
{
id: 'm31',
content: '这个需求还要再改一下。',
senderId: 'u3',
timestamp: Date.now() - 1000 * 60 * 10,
type: 'text',
},
{
id: 'm32',
content: '老板说要五彩斑斓的黑。',
senderId: 'u3',
timestamp: Date.now() - 1000 * 60 * 9,
type: 'text',
},
],
},
];
+47
View File
@@ -0,0 +1,47 @@
@import "tailwindcss";
@layer base {
:root {}
body {}
}
body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
opacity: 0.05;
transform: rotate(-12deg) scale(1.35);
animation: slide 30s linear infinite;
pointer-events: none;
}
@keyframes slide {
from {
background-position: 0 0;
}
to {
background-position: 256px 224px;
}
}
@keyframes spin {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion) {
*,
::before,
::after {
animation: none !important;
}
}
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>翁爷语录</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./index.tsx"></script>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
/**
* This file is the entry point for the React app, it sets up the root
* element and renders the App component to the DOM.
*
* It is included in `src/index.html`.
*/
import { createRoot } from "react-dom/client";
import { App } from "./App";
function start() {
const root = createRoot(document.getElementById("root")!);
root.render(<App />);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start);
} else {
start();
}