init repo
This commit is contained in:
commit
f771369e97
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.gemini
|
||||||
2
.env.example
Normal file
2
.env.example
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# Your domain name for Traefik
|
||||||
|
DOMAIN_NAME=wengyeyulu.com
|
||||||
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal 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
15
Dockerfile
Normal 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
93
README.md
Normal 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
149
build.ts
Normal 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
17
bun-env.d.ts
vendored
Normal 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
116
bun.lock
Normal 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
4
bunfig.toml
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
[serve.static]
|
||||||
|
plugins = ["bun-plugin-tailwind"]
|
||||||
|
env = "BUN_PUBLIC_*"
|
||||||
14
docker-compose.yml
Normal file
14
docker-compose.yml
Normal 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
27
package.json
Normal 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
106
src/backend/api.ts
Normal 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
155
src/backend/db.ts
Normal 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
27
src/backend/index.ts
Normal 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
57
src/frontend/App.tsx
Normal 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
5
src/frontend/client.ts
Normal 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
src/frontend/components/Admin.tsx
Normal file
108
src/frontend/components/Admin.tsx
Normal 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
src/frontend/components/ChatList.tsx
Normal file
76
src/frontend/components/ChatList.tsx
Normal 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
src/frontend/components/ChatWindow.tsx
Normal file
267
src/frontend/components/ChatWindow.tsx
Normal 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
src/frontend/components/MessageBubble.tsx
Normal file
61
src/frontend/components/MessageBubble.tsx
Normal 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
src/frontend/components/Sidebar.tsx
Normal file
35
src/frontend/components/Sidebar.tsx
Normal 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
126
src/frontend/data/mock.ts
Normal 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
47
src/frontend/index.css
Normal 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
15
src/frontend/index.html
Normal 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
20
src/frontend/index.tsx
Normal 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
36
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user