1. 新建文件

1. server/index.mjs

Node HTTP 服务,提供 REST API,各API路由说明,读写 data.json

/**
 * 看板 Node.js 后端服务
 * 使用 data.json 作为持久化存储,提供 REST API 供前端调用
 */
import { createServer } from 'node:http';
import { readFileSync, writeFileSync } from 'node:fs';
import { randomUUID } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const __dirname = dirname(fileURLToPath(import.meta.url));
/** 看板数据文件路径 */
const DATA_FILE = join(__dirname, 'data.json');
/** 云平台会注入 PORT,本地默认 3001 */
const PORT = Number(process.env.PORT) || 3001;
const HOST = process.env.HOST || '0.0.0.0';
/** 允许跨域的前端地址(GitHub Pages / 本地 Vite) */
const ALLOWED_ORIGINS = (
    process.env.ALLOWED_ORIGINS ??
    'http://localhost:5173,https://zhangyuehan321.github.io'
)
    .split(',')
    .map(origin => origin.trim())
    .filter(Boolean);

function setCors(req, res) {
    const origin = req.headers.origin;
    if (origin && ALLOWED_ORIGINS.includes(origin)) {
        res.setHeader('Access-Control-Allow-Origin', origin);
    }
    res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PATCH,OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
}

/** 从 data.json 读取看板数据 */
function readData() {
    return JSON.parse(readFileSync(DATA_FILE, 'utf-8'));
}

/** 将看板数据写回 data.json */
function writeData(data) {
    writeFileSync(DATA_FILE, JSON.stringify(data, null, 2));
}

/** 解析 POST/PATCH 请求体 */
function parseBody(req) {
    return new Promise((resolve, reject) => {
        let body = '';
        req.on('data', chunk => {
            body += chunk;
        });
        req.on('end', () => {
            try {
                resolve(body ? JSON.parse(body) : {});
            } catch (error) {
                reject(error);
            }
        });
    });
}

function sendJson(res, status, data) {
    res.writeHead(status, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify(data));
}

const server = createServer(async (req, res) => {
    setCors(req, res);

    // 浏览器跨域预检请求
    if (req.method === 'OPTIONS') {
        res.writeHead(204);
        res.end();
        return;
    }

    const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
    const { pathname } = url;
    const { method } = req;

    try {
        // GET /api/boards — 获取全部分组及任务
        if (method === 'GET' && pathname === '/api/boards') {
            const data = readData();
            return sendJson(res, 200, data.boards);
        }

        // POST /api/boards — 创建新分组
        if (method === 'POST' && pathname === '/api/boards') {
            const body = await parseBody(req);
            const data = readData();
            const board = {
                groupId: randomUUID(),
                groupName: body.groupName || `分组${data.boards.length + 1}`,
                tasks: []
            };
            data.boards.push(board);
            writeData(data);
            return sendJson(res, 201, board);
        }

        // POST /api/boards/:groupId/tasks — 在指定分组下创建任务
        const taskMatch = pathname.match(/^\/api\/boards\/([^/]+)\/tasks$/);
        if (method === 'POST' && taskMatch) {
            const groupId = taskMatch[1];
            const body = await parseBody(req);
            const data = readData();
            const board = data.boards.find(item => item.groupId === groupId);
            if (!board) {
                return sendJson(res, 404, { message: 'Group not found' });
            }

            const task = {
                id: randomUUID(),
                title: body.title || `新任务${board.tasks.length + 1}`
            };
            board.tasks.push(task);
            writeData(data);
            return sendJson(res, 201, task);
        }

        // PATCH /api/tasks/reorder — 同分组内调整任务顺序
        if (method === 'PATCH' && pathname === '/api/tasks/reorder') {
            const body = await parseBody(req);
            const { groupId, activeTaskId, overTaskId } = body;
            const data = readData();
            const board = data.boards.find(item => item.groupId === groupId);

            if (!board) {
                return sendJson(res, 404, { message: 'Group not found' });
            }

            const activeIndex = board.tasks.findIndex(
                task => String(task.id) === String(activeTaskId)
            );
            if (activeIndex === -1) {
                return sendJson(res, 404, { message: 'Task not found' });
            }

            if (overTaskId) {
                const overIndex = board.tasks.findIndex(
                    task => String(task.id) === String(overTaskId)
                );
                if (overIndex === -1) {
                    return sendJson(res, 404, {
                        message: 'Target task not found'
                    });
                }
                if (activeIndex !== overIndex) {
                    const tasks = board.tasks.slice();
                    const [moved] = tasks.splice(activeIndex, 1);
                    tasks.splice(overIndex, 0, moved);
                    board.tasks = tasks;
                }
            } else {
                const [task] = board.tasks.splice(activeIndex, 1);
                board.tasks.push(task);
            }

            writeData(data);
            return sendJson(res, 200, data.boards);
        }

        // PATCH /api/tasks/move — 跨分组移动任务
        if (method === 'PATCH' && pathname === '/api/tasks/move') {
            const body = await parseBody(req);
            const { taskId, fromGroupId, toGroupId } = body;
            const data = readData();
            const fromBoard = data.boards.find(
                item => item.groupId === fromGroupId
            );
            const toBoard = data.boards.find(
                item => item.groupId === toGroupId
            );

            if (!fromBoard || !toBoard) {
                return sendJson(res, 404, { message: 'Group not found' });
            }

            const taskIndex = fromBoard.tasks.findIndex(
                task => String(task.id) === String(taskId)
            );
            if (taskIndex === -1) {
                return sendJson(res, 404, { message: 'Task not found' });
            }

            const [task] = fromBoard.tasks.splice(taskIndex, 1);
            if (
                !toBoard.tasks.some(item => String(item.id) === String(taskId))
            ) {
                toBoard.tasks.push(task);
            }

            writeData(data);
            return sendJson(res, 200, data.boards);
        }

        // PATCH /api/boards/reorder — 调整分组(列)顺序
        if (method === 'PATCH' && pathname === '/api/boards/reorder') {
            const body = await parseBody(req);
            const { activeGroupId, overGroupId } = body;
            const data = readData();
            const fromIndex = data.boards.findIndex(
                item => item.groupId === activeGroupId
            );
            const toIndex = data.boards.findIndex(
                item => item.groupId === overGroupId
            );

            if (fromIndex === -1 || toIndex === -1) {
                return sendJson(res, 404, { message: 'Group not found' });
            }

            const [moved] = data.boards.splice(fromIndex, 1);
            data.boards.splice(toIndex, 0, moved);
            writeData(data);
            return sendJson(res, 200, data.boards);
        }

        return sendJson(res, 404, { message: 'Not found' });
    } catch (error) {
        const message =
            error instanceof Error ? error.message : 'Internal server error';
        return sendJson(res, 500, { message });
    }
});

server.listen(PORT, HOST, () => {
    console.log(`Kanban API server running at http://${HOST}:${PORT}`);
});

2. server/data.json

项目中需要的数据

{
    "boards": [
        {
            "groupId": "group-1",
            "groupName": "待办",
            "tasks": [
                {
                    "id": "7b87a3fe-104d-42db-8a39-7d05d5524024",
                    "title": "新任务2"
                },
                {
                    "id": "6d0f5499-7d82-4070-b17e-25ad64862063",
                    "title": "新任务2"
                }
            ]
        },
        {
            "groupId": "group-2",
            "groupName": "进行中",
            "tasks": [
                {
                    "id": "task-3",
                    "title": "开发后端接口"
                },
                {
                    "id": "task-2",
                    "title": "接入拖拽功能"
                },
                {
                    "id": "386dabd4-dd89-4840-8c8b-3888afa1adb5",
                    "title": "新任务3"
                }
            ]
        },
        {
            "groupId": "group-3",
            "groupName": "已完成",
            "tasks": [
                {
                    "id": "task-1",
                    "title": "设计看板原型"
                }
            ]
        }
    ]
}

3. src/api/kanban.ts

前端API封装,fetch调用。接口用途与本地/线上差异

GET    /api/boards              获取全部分组
POST   /api/boards              创建分组
POST   /api/boards/:id/tasks    创建任务
PATCH  /api/tasks/move          跨分组移动任务
PATCH  /api/boards/reorder      调整分组顺序

/**
 * 看板后端 API 封装
 * 本地开发:请求 /api/*,由 Vite 代理到 server/index.mjs
 * 线上部署:通过 VITE_API_URL 指向 Render 等平台的 API 地址
 */
import type { Board, Task } from '@/types/kanban';

// 生产环境 API 根地址,未配置时使用相对路径(走本地代理)
const API_BASE = (import.meta.env.VITE_API_URL ?? '').replace(/\/$/, '');

const apiUrl = (path: string) => `${API_BASE}${path}`;

/** 统一 fetch 封装,处理 JSON 与错误 */
async function request<T>(url: string, options?: RequestInit): Promise<T> {
    const response = await fetch(url, {
        headers: { 'Content-Type': 'application/json' },
        ...options
    });

    if (!response.ok) {
        const error = await response.json().catch(() => ({}));
        throw new Error(error.message || `Request failed: ${response.status}`);
    }

    return response.json() as Promise<T>;
}

export const kanbanApi = {
    /** 获取全部分组及任务(页面初始加载) */
    getBoards: () => request<Board[]>(apiUrl('/api/boards')),

    /** 创建新分组 */
    createBoard: (groupName: string) =>
        request<Board>(apiUrl('/api/boards'), {
            method: 'POST',
            body: JSON.stringify({ groupName })
        }),

    /** 在指定分组下创建任务 */
    createTask: (groupId: string, title?: string) =>
        request<Task>(apiUrl(`/api/boards/${groupId}/tasks`), {
            method: 'POST',
            body: JSON.stringify({ title })
        }),

    /** 跨分组移动任务 */
    moveTask: (taskId: string, fromGroupId: string, toGroupId: string) =>
        request<Board[]>(apiUrl('/api/tasks/move'), {
            method: 'PATCH',
            body: JSON.stringify({ taskId, fromGroupId, toGroupId })
        }),

    /** 同分组内调整任务顺序 */
    reorderTask: (groupId: string, activeTaskId: string, overTaskId?: string) =>
        request<Board[]>(apiUrl('/api/tasks/reorder'), {
            method: 'PATCH',
            body: JSON.stringify({ groupId, activeTaskId, overTaskId })
        }),

    /** 调整分组(列)顺序 */
    reorderGroups: (activeGroupId: string, overGroupId: string) =>
        request<Board[]>(apiUrl('/api/boards/reorder'), {
            method: 'PATCH',
            body: JSON.stringify({ activeGroupId, overGroupId })
        })
};

4. src/types/kanban.ts

Board, Task类型定义

/** 看板任务 */
export type Task = {
    id: string;
    title: string;
};

/** 看板分组(列),包含一组任务 */
export type Board = {
    groupId: string;
    groupName: string;
    tasks: Task[];
};

2. 重构

1. src/stores/useKanBan.ts

从纯 Zustand 本地改异步调 API;新增 fetchBoardsloadingerror。与后端同步的状态逻辑

/**
 * 看板状态管理(Zustand)
 * 改造后:读写操作均通过 kanbanApi 调用后端,不再纯前端内存维护
 */
import { create } from 'zustand';
import { kanbanApi } from '@/api/kanban';
import type { Board } from '@/types/kanban';

export const useKanBan = create<{
    boards: Board[];
    loading: boolean;
    error: string | null;
    fetchBoards: () => Promise<void>;
    createBoard: (groupName: string) => Promise<void>;
    createTask: (groupId: string) => Promise<void>;
    moveTask: (
        taskId: string,
        fromGroupId: string,
        toGroupId: string
    ) => Promise<void>;
    reorderTask: (
        groupId: string,
        activeTaskId: string,
        overTaskId?: string
    ) => Promise<void>;
    moveGroup: (activeGroupId: string, overGroupId: string) => Promise<void>;
}>(set => ({
    boards: [],
    loading: false,
    error: null,

    /** 从后端拉取初始看板数据 */
    fetchBoards: async () => {
        set({ loading: true, error: null });
        try {
            const boards = await kanbanApi.getBoards();
            set({ boards, loading: false });
        } catch (error) {
            set({
                loading: false,
                error: error instanceof Error ? error.message : '加载看板失败'
            });
        }
    },

    /** 创建分组并同步到后端 */
    createBoard: async groupName => {
        const board = await kanbanApi.createBoard(groupName);
        set(state => ({ boards: [...state.boards, board] }));
    },

    /** 创建任务并同步到后端 */
    createTask: async groupId => {
        const task = await kanbanApi.createTask(groupId);
        set(state => ({
            boards: state.boards.map(board =>
                board.groupId === groupId
                    ? { ...board, tasks: [...board.tasks, task] }
                    : board
            )
        }));
    },

    /** 跨分组移动任务,用后端返回的最新列表更新状态 */
    moveTask: async (taskId, fromGroupId, toGroupId) => {
        const boards = await kanbanApi.moveTask(taskId, fromGroupId, toGroupId);
        set({ boards });
    },

    /** 同分组内排序任务 */
    reorderTask: async (groupId, activeTaskId, overTaskId) => {
        const boards = await kanbanApi.reorderTask(
            groupId,
            activeTaskId,
            overTaskId
        );
        set({ boards });
    },

    /** 调整分组顺序 */
    moveGroup: async (activeGroupId, overGroupId) => {
        const boards = await kanbanApi.reorderGroups(
            activeGroupId,
            overGroupId
        );
        set({ boards });
    }
}));

2. src/pages/Borad/index.tsx

进入页面 fetchBoards();加载中 / 错误 / 重试 UI。初始加载逻辑

import { useEffect } from 'react';
import { useKanBan } from '@/stores/useKanBan';
import { Button } from '@/components/ui/button';
import { Board } from '@/components/Board';

export const BoardPage = () => {
    const { boards, loading, error, fetchBoards, createBoard } = useKanBan();

    // 进入页面时从后端接口加载看板数据
    useEffect(() => {
        fetchBoards();
    }, [fetchBoards]);

    if (loading) {
        return <div className="p-4">加载中...</div>;
    }

    if (error) {
        return (
            <div className="p-4">
                <p className="mb-2 text-red-500">{error}</p>
                <Button onClick={() => fetchBoards()}>重试</Button>
            </div>
        );
    }

    return (
        <div className="flex flex-row flex-wrap gap-4 p-4">
            <Button onClick={() => createBoard(`分组${boards.length + 1}`)}>
                创建分组
            </Button>
            <Board />
        </div>
    );
};

3. 修改

1. vite.config.ts

开发代理 /api → localhost:3001。开发代理说明

/// <reference types="vitest/config" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import path from 'path';
// https://vite.dev/config/
export default defineConfig({
    base: process.env.VITE_BASE_PATH || '/',
    resolve: {
        alias: {
            // '@': '/src',
            '@': path.resolve(__dirname, 'src')
        }
    },
    server: {
        // 开发环境:将 /api 请求代理到本地 Node 后端(server/index.mjs)
        proxy: {
            '/api': {
                target: 'http://localhost:3001',
                changeOrigin: true
            }
        }
    },
    plugins: [react(), tailwindcss()],
    test: {
        environment: 'jsdom',
        setupFiles: './src/test/setup.ts',
        include: ['src/**/*.{test,spec}.{ts,tsx}']
    }
});

2. package.json

新增 serverdev:all 脚本

    "scripts": {
        "dev": "vite",
        "server": "node server/index.mjs",
        "start": "node server/index.mjs",
        "dev:all": "node server/index.mjs & vite",
        "build": "tsc -b && vite build",
        "typecheck": "tsc -b --noEmit",
        "lint": "eslint .",
        "format": "prettier --write .",
        "format:check": "prettier --check .",
        "test": "vitest run",
        "test:watch": "vitest",
        "test:coverage": "vitest run --coverage",
        "preview": "vite preview",
        "prepare": "husky"
    },

4. 架构变化

之前:Zustand 内存 → 刷新丢失
之后:页面 load → GET /api/boards → 操作 → PATCH/POST → 写入 data.json

更多推荐