init repo
This commit is contained in:
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user