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

5
.dockerignore Normal file
View File

@ -0,0 +1,5 @@
node_modules
.git
.gitignore
*.md
.gemini

2
.env.example Normal file
View File

@ -0,0 +1,2 @@
# Your domain name for Traefik
DOMAIN_NAME=wengyeyulu.com

36
.gitignore vendored Normal file
View File

@ -0,0 +1,36 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
*.db

15
Dockerfile Normal file
View File

@ -0,0 +1,15 @@
FROM oven/bun:1 AS base
WORKDIR /app
# Install dependencies
COPY package.json bun.lock* ./
RUN bun install --frozen-lockfile --production
# Copy source code
COPY . .
# Expose port
EXPOSE 3000
# Start the application
CMD ["bun", "start"]

93
README.md Normal file
View File

@ -0,0 +1,93 @@
# 翁爷语录 (WengyeYulu) - 聊天记录归档查看器
这是一个基于 **Bun** + **Elysia** + **React** 构建的全栈聊天记录归档与查看应用。它演示了如何使用现代高性能 TypeScript 运行时 Bun 构建端到端类型安全End-to-End Type Safety的 Web 应用。
## 🛠 技术栈
本项目采用以下前沿技术构建:
### 核心运行时
- **[Bun](https://bun.sh)**: 极速的 TypeScript 运行时、打包器和包管理器。
### 后端 (Backend)
- **[ElysiaJS](https://elysiajs.com)**: 基于 Bun 的高性能 Web 框架。
- **[Bun:sqlite](https://bun.sh/docs/api/sqlite)**: Bun 内置的高性能 SQLite 驱动。
- **TypeScript**: 全后端类型支持。
### 前端 (Frontend)
- **[React 19](https://react.dev)**: 用于构建用户界面。
- **[@elysiajs/eden](https://elysiajs.com/eden/overview.html)**: 实现了**端到端类型安全**。前端直接调用后端 API 类型,无需手动编写 Fetch 代码或类型定义。
- **[TailwindCSS](https://tailwindcss.com)**: 实用优先的 CSS 框架。
- **[Lucide React](https://lucide.dev)**: 漂亮的图标库。
### 部署 (Deployment)
- **Docker**: 容器化部署。
- **Docker Compose**: 服务编排。
- **Traefik**: 支持并预配置了 Traefik 标签,方便作为反向代理接入。
## ✨ 功能特性
- **端到端类型安全**: 后端修改 API 定义,前端类型自动更新,开发体验极佳。
- **微信风格 UI**: 仿微信 PC 端界面,简洁熟悉。
- **高性能**: 利用 Bun 和 SQLite 的高性能特性,秒级加载大量聊天记录。
- **全文搜索**: 支持实时搜索聊天内容。
- **数据导入**: 包含管理员后台,支持导入 JSON 格式的聊天记录。
## 🚀 快速开始 (开发模式)
### 前置要求
请确保已安装 [Bun](https://bun.sh/docs/installation)。
### 1. 安装依赖
```bash
bun install
```
### 2. 启动开发服务器
```bash
bun dev
```
服务器将在 `http://localhost:3000` 启动。
* Bun 会自动处理前端资源的 HMR热更新
* 后端 API 变更也会自动重启。
## 🐳 Docker 部署 (生产环境)
本项目提供了完整的 Docker 支持,包含 Traefik 标签配置,适合在生产环境中部署。
### 1. 配置环境变量
复制示例配置文件并设置你的域名:
```bash
cp .env.example .env
```
打开 `.env` 文件,修改 `DOMAIN_NAME` 为你的实际域名(例如 `chat.example.com`。如果不修改Traefik 将默认监听 `example.com`
### 2. 启动服务
使用 Docker Compose 一键启动:
```bash
docker-compose up -d --build
```
### 3. 数据持久化
所有数据SQLite 数据库文件)将保存在项目根目录下的 `./data` 文件夹中,容器重启或删除后数据不会丢失。
## 📂 项目结构
```
src/
├── backend/ # 后端代码
│ ├── api.ts # Elysia API 定义
│ ├── db.ts # 数据库连接与 Schema
│ └── index.ts # 服务端入口
└── frontend/ # 前端代码
├── client.ts # Eden 客户端 (API 类型推断)
├── components/ # React 组件
├── App.tsx # 主应用逻辑
└── index.tsx # 前端入口
```
## 📝 许可证
MIT License

149
build.ts Normal file
View File

@ -0,0 +1,149 @@
#!/usr/bin/env bun
import plugin from "bun-plugin-tailwind";
import { existsSync } from "fs";
import { rm } from "fs/promises";
import path from "path";
if (process.argv.includes("--help") || process.argv.includes("-h")) {
console.log(`
🏗 Bun Build Script
Usage: bun run build.ts [options]
Common Options:
--outdir <path> Output directory (default: "dist")
--minify Enable minification (or --minify.whitespace, --minify.syntax, etc)
--sourcemap <type> Sourcemap type: none|linked|inline|external
--target <target> Build target: browser|bun|node
--format <format> Output format: esm|cjs|iife
--splitting Enable code splitting
--packages <type> Package handling: bundle|external
--public-path <path> Public path for assets
--env <mode> Environment handling: inline|disable|prefix*
--conditions <list> Package.json export conditions (comma separated)
--external <list> External packages (comma separated)
--banner <text> Add banner text to output
--footer <text> Add footer text to output
--define <obj> Define global constants (e.g. --define.VERSION=1.0.0)
--help, -h Show this help message
Example:
bun run build.ts --outdir=dist --minify --sourcemap=linked --external=react,react-dom
`);
process.exit(0);
}
const toCamelCase = (str: string): string => str.replace(/-([a-z])/g, g => g[1].toUpperCase());
const parseValue = (value: string): any => {
if (value === "true") return true;
if (value === "false") return false;
if (/^\d+$/.test(value)) return parseInt(value, 10);
if (/^\d*\.\d+$/.test(value)) return parseFloat(value);
if (value.includes(",")) return value.split(",").map(v => v.trim());
return value;
};
function parseArgs(): Partial<Bun.BuildConfig> {
const config: Partial<Bun.BuildConfig> = {};
const args = process.argv.slice(2);
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === undefined) continue;
if (!arg.startsWith("--")) continue;
if (arg.startsWith("--no-")) {
const key = toCamelCase(arg.slice(5));
config[key] = false;
continue;
}
if (!arg.includes("=") && (i === args.length - 1 || args[i + 1]?.startsWith("--"))) {
const key = toCamelCase(arg.slice(2));
config[key] = true;
continue;
}
let key: string;
let value: string;
if (arg.includes("=")) {
[key, value] = arg.slice(2).split("=", 2) as [string, string];
} else {
key = arg.slice(2);
value = args[++i] ?? "";
}
key = toCamelCase(key);
if (key.includes(".")) {
const [parentKey, childKey] = key.split(".");
config[parentKey] = config[parentKey] || {};
config[parentKey][childKey] = parseValue(value);
} else {
config[key] = parseValue(value);
}
}
return config;
}
const formatFileSize = (bytes: number): string => {
const units = ["B", "KB", "MB", "GB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
};
console.log("\n🚀 Starting build process...\n");
const cliConfig = parseArgs();
const outdir = cliConfig.outdir || path.join(process.cwd(), "dist");
if (existsSync(outdir)) {
console.log(`🗑️ Cleaning previous build at ${outdir}`);
await rm(outdir, { recursive: true, force: true });
}
const start = performance.now();
const entrypoints = [...new Bun.Glob("**.html").scanSync("src")]
.map(a => path.resolve("src", a))
.filter(dir => !dir.includes("node_modules"));
console.log(`📄 Found ${entrypoints.length} HTML ${entrypoints.length === 1 ? "file" : "files"} to process\n`);
const result = await Bun.build({
entrypoints,
outdir,
plugins: [plugin],
minify: true,
target: "browser",
sourcemap: "linked",
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
},
...cliConfig,
});
const end = performance.now();
const outputTable = result.outputs.map(output => ({
File: path.relative(process.cwd(), output.path),
Type: output.kind,
Size: formatFileSize(output.size),
}));
console.table(outputTable);
const buildTime = (end - start).toFixed(2);
console.log(`\n✅ Build completed in ${buildTime}ms\n`);

17
bun-env.d.ts vendored Normal file
View File

@ -0,0 +1,17 @@
// Generated by `bun init`
declare module "*.svg" {
/**
* A path to the SVG file
*/
const path: `${string}.svg`;
export = path;
}
declare module "*.module.css" {
/**
* A record of class names to their corresponding CSS module classes
*/
const classes: { readonly [key: string]: string };
export = classes;
}

116
bun.lock Normal file
View File

@ -0,0 +1,116 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "bun-react-template",
"dependencies": {
"@elysiajs/eden": "^1.4.5",
"bun-plugin-tailwind": "^0.1.2",
"clsx": "^2.1.1",
"elysia": "^1.4.19",
"lucide-react": "^0.561.0",
"react": "^19",
"react-dom": "^19",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.11",
},
"devDependencies": {
"@types/bun": "latest",
"@types/react": "^19",
"@types/react-dom": "^19",
},
},
},
"packages": {
"@borewit/text-codec": ["@borewit/text-codec@0.1.1", "https://mirrors.cloud.tencent.com/npm/@borewit/text-codec/-/text-codec-0.1.1.tgz", {}, "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA=="],
"@elysiajs/eden": ["@elysiajs/eden@1.4.5", "https://mirrors.cloud.tencent.com/npm/@elysiajs/eden/-/eden-1.4.5.tgz", { "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-hIOeH+S5NU/84A7+t8yB1JjxqjmzRkBF9fnLn6y+AH8EcF39KumOAnciMhIOkhhThVZvXZ3d+GsizRc+Fxoi8g=="],
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.4", "https://mirrors.cloud.tencent.com/npm/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.4.tgz", { "os": "darwin", "cpu": "arm64" }, "sha512-2Ie4jDGvNGuPSD+pyyBKL8dJmX+bZfDNYEalwgROImVtwB1XYAatJK20dMaRlPA7jOhjvS9Io+4IZAJu7Js0AA=="],
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.4", "https://mirrors.cloud.tencent.com/npm/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.4.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-4/BJojT8hk5g6Gecjn5yI7y96/+9Mtzsvdp9+2dcy9sTMdlV7jBvDzswqyJPZyQqw0F3HV3Vu9XuMubZwKd9lA=="],
"@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.4", "https://mirrors.cloud.tencent.com/npm/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.4.tgz", { "os": "darwin", "cpu": "x64" }, "sha512-ZYxzIOCDqylTMsnWYERjKMMuK2b4an4qbloBmUZTwLHmVzos00yrhtpitZhJBgH6yB/l4Q5eoJ2W98UKtFFeiQ=="],
"@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.4", "https://mirrors.cloud.tencent.com/npm/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.4.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-8DUIlanftMdFxLGq2FxwKwfrp8O4ZofF/8Oc6lxCyEFmg2hixbHhL04+fPfJIi5D4hZloynxZdwTeDbGv/Kc4A=="],
"@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.4", "https://mirrors.cloud.tencent.com/npm/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.4.tgz", { "os": "linux", "cpu": "arm64" }, "sha512-6UtmM4wXgRKz+gnLZEfddfsuBSVQpJr09K12e5pbdnLzeWgXYlBT5FG8S7SVn1t6cbgBMnigEsFjWwfTuMNoCw=="],
"@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.4", "https://mirrors.cloud.tencent.com/npm/@oven/bun-linux-x64/-/bun-linux-x64-1.3.4.tgz", { "os": "linux", "cpu": "x64" }, "sha512-03iSDMqdrmIFAsvsRptq+A7EGNjkg20dNzPnqxAlXHk5rc1PeIRWIP0eIn0i3nI6mmdj33mimf9AGr0+d0lKMg=="],
"@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.4", "https://mirrors.cloud.tencent.com/npm/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.4.tgz", { "os": "linux", "cpu": "x64" }, "sha512-ZMGPbFPqmG/VYJv61D+Y1V7T23jPK57vYl7yYLakmkTRjG6vcJ0Akhb2qR1iW94rHvfEBjeuVDAZBp8Qp9oyWA=="],
"@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.4", "https://mirrors.cloud.tencent.com/npm/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.4.tgz", { "os": "linux", "cpu": "x64" }, "sha512-xUXPuJHndGhk4K3Cx1FgTyTgDZOn+ki3eWvdXYqKdfi0EaNA9KpUq+/vUtpJbZRjzpHs9L+OJcdDILq5H0LX4g=="],
"@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.4", "https://mirrors.cloud.tencent.com/npm/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.4.tgz", { "os": "linux", "cpu": "x64" }, "sha512-qsGSSlNsxiX8lAayK2uYCfMLtqu776F0nn7qoyzg9Ti7mElM3woNh7RtGClTwQ6qsp5/UvgqT9g4pLaDHmqJFg=="],
"@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.4", "https://mirrors.cloud.tencent.com/npm/@oven/bun-windows-x64/-/bun-windows-x64-1.3.4.tgz", { "os": "win32", "cpu": "x64" }, "sha512-nswsuN6+HZPim6x4tFpDFpMa/qpTKfywbGvCkzxwrbJO9MtpuW/54NA1nFbHhpV14OLU0xuxyBj2PK4FHq4MlA=="],
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.4", "https://mirrors.cloud.tencent.com/npm/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.4.tgz", { "os": "win32", "cpu": "x64" }, "sha512-ZQiSDFfSUdOrPTiL2GvkxlC/kMED4fsJwdZnwJK6S9ylXnk9xY/9ZXfe1615SFLQl2LsVRzJAtjQLeM0BifIKQ=="],
"@sinclair/typebox": ["@sinclair/typebox@0.34.41", "https://mirrors.cloud.tencent.com/npm/@sinclair/typebox/-/typebox-0.34.41.tgz", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="],
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "https://mirrors.cloud.tencent.com/npm/@tokenizer/inflate/-/inflate-0.4.1.tgz", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "https://mirrors.cloud.tencent.com/npm/@tokenizer/token/-/token-0.3.0.tgz", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
"@types/bun": ["@types/bun@1.3.4", "https://mirrors.cloud.tencent.com/npm/@types/bun/-/bun-1.3.4.tgz", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="],
"@types/node": ["@types/node@25.0.1", "https://mirrors.cloud.tencent.com/npm/@types/node/-/node-25.0.1.tgz", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-czWPzKIAXucn9PtsttxmumiQ9N0ok9FrBwgRWrwmVLlp86BrMExzvXRLFYRJ+Ex3g6yqj+KuaxfX1JTgV2lpfg=="],
"@types/react": ["@types/react@19.2.7", "https://mirrors.cloud.tencent.com/npm/@types/react/-/react-19.2.7.tgz", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "https://mirrors.cloud.tencent.com/npm/@types/react-dom/-/react-dom-19.2.3.tgz", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"bun": ["bun@1.3.4", "https://mirrors.cloud.tencent.com/npm/bun/-/bun-1.3.4.tgz", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.4", "@oven/bun-darwin-x64": "1.3.4", "@oven/bun-darwin-x64-baseline": "1.3.4", "@oven/bun-linux-aarch64": "1.3.4", "@oven/bun-linux-aarch64-musl": "1.3.4", "@oven/bun-linux-x64": "1.3.4", "@oven/bun-linux-x64-baseline": "1.3.4", "@oven/bun-linux-x64-musl": "1.3.4", "@oven/bun-linux-x64-musl-baseline": "1.3.4", "@oven/bun-windows-x64": "1.3.4", "@oven/bun-windows-x64-baseline": "1.3.4" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-xV6KgD5ImquuKsoghzbWmYzeCXmmSgN6yJGz444hri2W+NGKNRFUNrEhy9+/rRXbvNA2qF0K0jAwqFNy1/GhBg=="],
"bun-plugin-tailwind": ["bun-plugin-tailwind@0.1.2", "https://mirrors.cloud.tencent.com/npm/bun-plugin-tailwind/-/bun-plugin-tailwind-0.1.2.tgz", { "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-41jNC1tZRSK3s1o7pTNrLuQG8kL/0vR/JgiTmZAJ1eHwe0w5j6HFPKeqEk0WAD13jfrUC7+ULuewFBBCoADPpg=="],
"bun-types": ["bun-types@1.3.4", "https://mirrors.cloud.tencent.com/npm/bun-types/-/bun-types-1.3.4.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="],
"clsx": ["clsx@2.1.1", "https://mirrors.cloud.tencent.com/npm/clsx/-/clsx-2.1.1.tgz", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
"cookie": ["cookie@1.1.1", "https://mirrors.cloud.tencent.com/npm/cookie/-/cookie-1.1.1.tgz", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
"csstype": ["csstype@3.2.3", "https://mirrors.cloud.tencent.com/npm/csstype/-/csstype-3.2.3.tgz", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"debug": ["debug@4.4.3", "https://mirrors.cloud.tencent.com/npm/debug/-/debug-4.4.3.tgz", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"elysia": ["elysia@1.4.19", "https://mirrors.cloud.tencent.com/npm/elysia/-/elysia-1.4.19.tgz", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "0.2.5", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-DZb9y8FnWyX5IuqY44SvqAV0DjJ15NeCWHrLdgXrKgTPDPsl3VNwWHqrEr9bmnOCpg1vh6QUvAX/tcxNj88jLA=="],
"exact-mirror": ["exact-mirror@0.2.5", "https://mirrors.cloud.tencent.com/npm/exact-mirror/-/exact-mirror-0.2.5.tgz", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-u8Wu2lO8nio5lKSJubOydsdNtQmH8ENba5m0nbQYmTvsjksXKYIS1nSShdDlO8Uem+kbo+N6eD5I03cpZ+QsRQ=="],
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "https://mirrors.cloud.tencent.com/npm/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
"file-type": ["file-type@21.1.1", "https://mirrors.cloud.tencent.com/npm/file-type/-/file-type-21.1.1.tgz", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-ifJXo8zUqbQ/bLbl9sFoqHNTNWbnPY1COImFfM6CCy7z+E+jC1eY9YfOKkx0fckIg+VljAy2/87T61fp0+eEkg=="],
"ieee754": ["ieee754@1.2.1", "https://mirrors.cloud.tencent.com/npm/ieee754/-/ieee754-1.2.1.tgz", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"lucide-react": ["lucide-react@0.561.0", "https://mirrors.cloud.tencent.com/npm/lucide-react/-/lucide-react-0.561.0.tgz", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Y59gMY38tl4/i0qewcqohPdEbieBy7SovpBL9IFebhc2mDd8x4PZSOsiFRkpPcOq6bj1r/mjH/Rk73gSlIJP2A=="],
"memoirist": ["memoirist@0.4.0", "https://mirrors.cloud.tencent.com/npm/memoirist/-/memoirist-0.4.0.tgz", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
"ms": ["ms@2.1.3", "https://mirrors.cloud.tencent.com/npm/ms/-/ms-2.1.3.tgz", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"openapi-types": ["openapi-types@12.1.3", "https://mirrors.cloud.tencent.com/npm/openapi-types/-/openapi-types-12.1.3.tgz", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
"react": ["react@19.2.3", "https://mirrors.cloud.tencent.com/npm/react/-/react-19.2.3.tgz", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
"react-dom": ["react-dom@19.2.3", "https://mirrors.cloud.tencent.com/npm/react-dom/-/react-dom-19.2.3.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
"scheduler": ["scheduler@0.27.0", "https://mirrors.cloud.tencent.com/npm/scheduler/-/scheduler-0.27.0.tgz", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"strtok3": ["strtok3@10.3.4", "https://mirrors.cloud.tencent.com/npm/strtok3/-/strtok3-10.3.4.tgz", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
"tailwind-merge": ["tailwind-merge@3.4.0", "https://mirrors.cloud.tencent.com/npm/tailwind-merge/-/tailwind-merge-3.4.0.tgz", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
"tailwindcss": ["tailwindcss@4.1.18", "https://mirrors.cloud.tencent.com/npm/tailwindcss/-/tailwindcss-4.1.18.tgz", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
"token-types": ["token-types@6.1.1", "https://mirrors.cloud.tencent.com/npm/token-types/-/token-types-6.1.1.tgz", { "dependencies": { "@borewit/text-codec": "^0.1.0", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ=="],
"uint8array-extras": ["uint8array-extras@1.5.0", "https://mirrors.cloud.tencent.com/npm/uint8array-extras/-/uint8array-extras-1.5.0.tgz", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
"undici-types": ["undici-types@7.16.0", "https://mirrors.cloud.tencent.com/npm/undici-types/-/undici-types-7.16.0.tgz", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
}
}

4
bunfig.toml Normal file
View File

@ -0,0 +1,4 @@
[serve.static]
plugins = ["bun-plugin-tailwind"]
env = "BUN_PUBLIC_*"

14
docker-compose.yml Normal file
View File

@ -0,0 +1,14 @@
services:
app:
build: .
ports:
- "3000:3000"
volumes:
- ./data:/app/data
environment:
- NODE_ENV=production
labels:
- "traefik.enable=true"
- "traefik.http.routers.wengyeyulu.rule=Host(`${DOMAIN_NAME:-example.com}`)"
- "traefik.http.routers.wengyeyulu.entrypoints=web"
- "traefik.http.services.wengyeyulu.loadbalancer.server.port=3000"

27
package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "bun-react-template",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun --hot src/backend/index.ts",
"start": "NODE_ENV=production bun src/backend/index.ts",
"build": "bun run build.ts"
},
"dependencies": {
"@elysiajs/eden": "^1.4.5",
"bun-plugin-tailwind": "^0.1.2",
"clsx": "^2.1.1",
"elysia": "^1.4.19",
"lucide-react": "^0.561.0",
"react": "^19",
"react-dom": "^19",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.11"
},
"devDependencies": {
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/bun": "latest"
}
}

106
src/backend/api.ts Normal file
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
src/backend/db.ts Normal file
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
src/backend/index.ts Normal file
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
src/frontend/App.tsx Normal file
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
src/frontend/client.ts Normal file
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');

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>
);
}

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>
);
}

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>
);
}

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>
);
}

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
src/frontend/data/mock.ts Normal file
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
src/frontend/index.css Normal file
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
src/frontend/index.html Normal file
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
src/frontend/index.tsx Normal file
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();
}

36
tsconfig.json Normal file
View File

@ -0,0 +1,36 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
},
"exclude": ["dist", "node_modules"]
}