好的,我们可以基于前面的思路,逐步构建一个博客实例。我们将使用上述提到的工具和框架,包括 Elysia、HTMX、UnoCSS、TypeScript、React、Drizzle ORM 以及 PostgreSQL,并将应用打包为 Docker 发行文件。

项目结构

my-blog/
├── src/
│   ├── server.ts
│   ├── api.ts
│   ├── db.ts
│   ├── models.ts
│   ├── types.ts
│   └── components/
│       ├── PostEditor.tsx
│       ├── PostList.tsx
│       └── App.tsx
├── public/
│   ├── index.html
│   └── styles.css
├── Dockerfile
├── docker-compose.yml
├── tsconfig.json
├── package.json
├── bunfig.toml
└── vite.config.ts

1. 初始化项目和安装依赖

创建项目目录并初始化 Bun 项目

mkdir my-blog
cd my-blog
bun init

安装依赖

bun add elysia htmx react react-dom @types/react @types/react-dom unocss drizzle-orm pg
bun add -d typescript vite @vitejs/plugin-react

2. 配置 TypeScript

创建 tsconfig.json

bun add --dev typescript
bun x tsc --init

编辑生成的 tsconfig.json 文件:

{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "jsx": "react-jsx",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src"]
}

3. 配置 Vite

创建 Vite 配置文件 vite.config.ts

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
 
export default defineConfig({
  plugins: [react()],
});

4. 服务器端配置

创建 src/db.ts 进行数据库连接配置:

// src/db.ts
import { drizzle, NodePgClient } from 'drizzle-orm';
import { Client } from 'pg';
import { config } from 'dotenv';
 
config(); // Load .env file
 
const client = new Client({
  connectionString: process.env.DATABASE_URL,
});
 
const db = drizzle(new NodePgClient(client));
 
export default db;

创建 src/models.ts 进行数据库表定义:

// src/models.ts
import { defineTable, bigint, text } from 'drizzle-orm';
 
export const postTable = defineTable({
  name: 'posts',
  columns: {
    id: bigint({ primaryKey: true, autoIncrement: true }),
    title: text(),
    content: text(),
  },
});

创建 src/api.ts 进行 CRUD 操作:

// src/api.ts
import { postTable } from './models';
import db from './db';
import { Post } from './types';
 
// Retrieve a post by ID
export async function getPost(id: number): Promise<Post | undefined> {
  const results = await db.select(postTable)
    .where(postTable.id.eq(id))
    .execute();
  
  return results.length ? { id: results[0].id, title: results[0].title, content: results[0].content } : undefined;
}
 
// Retrieve all posts
export async function getAllPosts(): Promise<Post[]> {
  const results = await db.select(postTable).execute();
  return results.map(post => ({ id: post.id, title: post.title, content: post.content }));
}
 
// Create a new post
export async function createPost(title: string, content: string): Promise<Post> {
  const result = await db.insert(postTable).values({ title, content }).execute();
  const id = result.insertId;
  return { id, title, content };
}
 
// Update an existing post
export async function updatePost(id: number, title: string, content: string): Promise<Post | undefined> {
  const targetPost = await getPost(id);
  
  if (targetPost) {
    await db.update(postTable)
      .set({ title, content })
      .where(postTable.id.eq(id))
      .execute();
 
    return { id, title, content };
  }
 
  return undefined;
}

创建 src/server.ts 配置 Elysia 服务器:

// src/server.ts
import { Elysia } from 'elysia';
import { getPost, getAllPosts, createPost, updatePost } from './api';
import { Post } from './types';
 
const app = new Elysia();
 
app.get('/', async (req, res) => {
  res.type('text/html');
  res.sendFile('public/index.html');
});
 
app.get('/posts/:id', async (req, res) => {
  const id = parseInt(req.params.id, 10);
  const post = await getPost(id);
  if (post) {
    res.json(post);
  } else {
    res.status(404).send("Post not found");
  }
});
 
app.get('/posts', async (req, res) => {
  const posts = await getAllPosts();
  res.json(posts);
});
 
app.post('/posts', async (req, res) => {
  const { title, content } = await req.json() as Post;
  const newPost = await createPost(title, content);
  res.json(newPost);
});
 
app.put('/posts/:id', async (req, res) => {
  const id = parseInt(req.params.id, 10);
  const { title, content } = await req.json() as Post;
  const updatedPost = await updatePost(id, title, content);
  if (updatedPost) {
    res.json(updatedPost);
  } else {
    res.status(404).send("Post not found");
  }
});
 
app.listen(3000, () => {
  console.log('Server is running on http://localhost:3000');
});

5. 客户端配置

创建 React 组件 src/components/PostEditor.tsx

// src/components/PostEditor.tsx
import React, { useState, useEffect } from 'react';
import { Post } from '../types';
 
interface PostEditorProps {
  postId?: number;
  onSave: (post: Post) => void;
}
 
const PostEditor: React.FC<PostEditorProps> = ({ postId, onSave }) => {
  const [title, setTitle] = useState('');
  const [content, setContent] = useState('');
 
  useEffect(() => {
    if (postId) {
      const fetchData = async () => {
        const response = await fetch(`/posts/${postId}`);
        const post: Post = await response.json();
        setTitle(post.title);
        setContent(post.content);
      };
      fetchData();
    }
  }, [postId]);
 
  const handleSave = async () => {
    let response;
    if (postId) {
      response = await fetch(`/posts/${postId}`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ title, content }),
      });
    } else {
      response = await fetch('/posts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ title, content }),
      });
    }
    const post: Post = await response.json();
    onSave(post);
  };
 
  return (
    <div className="p-4 border">
      <input
        className="w-full mb-2 p-2 border"
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Title"
      />
      <textarea
        className="w-full h-64 p-2 border"
        value={content}
        onChange={(e) => setContent(e.target.value)}
        placeholder="Content"
      />
      <button onClick={handleSave} className="mt-2 p-2 bg-blue-500 text-white">
        Save
      </button>
    </div>
  );
};
 
export default PostEditor;

创建 React 组件 src/components/PostList.tsx

// src/components/PostList.tsx
import React, { useEffect, useState } from 'react';
import { Post } from '../types';
import PostEditor from './PostEditor';
 
const PostList: React.FC = () => {
  const [posts, setPosts] = useState<Post[]>([]);
  const [editingPostId, setEditingPostId] = useState<number | null>(null);
 
  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch('/posts');
      const posts: Post[] = await response.json();
      setPosts(posts);
    };
    fetchData();
  }, []);
 
  const handleSave = (post: Post) => {
    setPosts((prevPosts) =>
      prevPosts.map((p) => (p.id === post.id ? post : p))
    );
    setEditingPostId(null);
  };
 
  return (
    <div>
      <h1 className="text-2xl font-bold">Blog Posts</h1>
      <button onClick={() => setEditingPostId(null)} className="mt-2 p-2 bg-green-500 text-white">
        Create New Post
      </button>
      {editingPostId === null && <PostEditor onSave={handleSave} />}
      <ul>
        {posts.map((post) => (
          <li key={post.id} className="p-4 border mb-2">
            <h2 className="text-xl font-semibold">{post.title}</h2>
            <p>{post.content}</p>
            <button onClick={() => setEditingPostId(post.id)} className="mt-2 p-2 bg-yellow-500 text-white">
              Edit
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
};
 
export default PostList;

创建 React 应用入口 src/components/App.tsx

// src/components/App.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import PostList from './PostList';
 
const App: React.FC = () => {
  return (
    <div className="app">
      <PostList />
    </div>
  );
};
 
ReactDOM.render(<App />, document.getElementById('root'));

6. 配置静态页面和样式

创建 public/index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Blog</title>
  <script type="module" src="/src/components/App.tsx"></script>
  <link rel="stylesheet" href="https://unpkg.com/@unocss/reset@latest/antfu.css">
  <link rel="stylesheet" href="styles.css">
</head>
<body class="p-4">
  <div id="root"></div>
</body>
</html>

创建 public/styles.css

.text-2xl {
  font-size: 1.5rem;
}
 
.font-bold {
  font-weight: 700;
}
 
.font-semibold {
  font-weight: 600;
}
 
.border {
  border: 1px solid #ddd;
}
 
.p-4 {
  padding: 1rem;
}
 
.mb-2 {
  margin-bottom: 0.5rem;
}
 
.mt-2 {
  margin-top: 0.5rem;
}
 
.p-2 {
  padding: 0.5rem;
}
 
.bg-blue-500 {
  background-color: #3b82f6;
}
 
.bg-green-500 {
  background-color: #22c55e;
}
 
.bg-yellow-500 {
  background-color: #eab308;
}
 
.text-white {
  color: #fff;
}

7. 配置 .env 进行环境变量管理

创建 .env 文件并添加数据库连接字符串:

DATABASE_URL=postgres://username:password@localhost:5432/mydatabase

8. 初始化数据库

确保 PostgreSQL 数据库正在运行,执行数据库初始化脚本来创建表:

CREATE TABLE IF NOT EXISTS posts (
  id SERIAL PRIMARY KEY,
  title TEXT NOT NULL,
  content TEXT NOT NULL
);

通过 pgAdmin 或 DBeaver 执行以上 SQL 脚本。

9. Docker 配置

创建 Dockerfile

# Use an official node image as the base image
FROM node:16
 
# Set the working directory
WORKDIR /app
 
# Copy package.json and bun.lockb files
COPY package.json .
COPY bun.lockb ./
 
# Install Bun
RUN npm install bun -g
 
# Copy the rest of the application
COPY . .
 
# Install dependencies
RUN bun install
 
# Build the application
RUN bun run build
 
# Build the Vite application
RUN bun x vite build
 
# Expose the port the app runs on
EXPOSE 3000
 
# Run the server
CMD ["bun", "run", "src/server.ts"]

创建 Docker Compose 文件

version: '3.8'
 
services:
  db:
    image: postgres:13
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
      POSTGRES_DB: mydatabase
    volumes:
      - pgdata:/var/lib/postgresql/data
    ports:
      - "5432:5432"
 
  app:
    build: .
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgres://user:password@db:5432/mydatabase
    depends_on:
      - db
    volumes:
      - .:/app
      - /app/node_modules
 
volumes:
  pgdata:

10. 启动项目

首先确保环境变量已加载:

source .env

运行 Docker Compose:

docker-compose up --build

访问 http://localhost:3000 ,将可访问到一个博客实例,其中你可以创建、编辑、查看博客文章。

总结

通过本文,我们详细说明了如何基于 Elysia、HTMX、UnoCSS、TypeScript、React、Drizzle ORM 和 PostgreSQL 框架构建一个博客实例,并通过 Docker 打包为一个发行文件。以下是已完成的主要任务:

  1. 服务器端配置:使用 Elysia 创建 API 服务器,结合 Drizzle ORM 管理 PostgreSQL 数据库。
  2. 客户端配置:使用 React 实现博客文章列表和编辑组件。
  3. 样式和页面配置:结合 HTMX 和 UnoCSS 构建前端样式。
  4. Docker 配置:创建 Dockerfile 和 Docker Compose 文件,打包和运行应用。

希望这个示例能够帮助你更好地理解如何结合这些技术创建一个功能丰富的应用程序。如果有任何问题或需要进一步的帮助,请随时提问!