首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >零基础产品设计与实践:从需求到代码构建实时协作看板

零基础产品设计与实践:从需求到代码构建实时协作看板

原创
作者头像
搜weiranit.fun
发布2026-08-11 15:09:03
发布2026-08-11 15:09:03
1200
举报

零基础产品设计与实践:从需求到代码构建实时协作看板

本文不空谈概念,全程以“实时协作看板”为实战载体,完整呈现从产品需求分析、架构设计、数据库建模,到 Spring Boot + WebSocket + Vue 3 的详细代码实现,并附带性能优化与部署要点。所有代码均可直接运行,适合零基础开发者快速上手。

1. 为什么写这篇文章

很多“零基础产品设计”教程止步于 Axure 原型或 PRD 文档,而“技术实践”文章又默认你已具备完整工程能力。真正的“零基础”应该是:你有一个想法,我能带你从画流程图开始,一路写到可部署的代码,并理解每一行背后的设计权衡。

本文选择 实时协作看板 作为目标产品,因为它:

  • 涉及多端交互(Web、移动适配)
  • 需要 WebSocket 长连接
  • 有数据持久化与缓存
  • 具备典型的 CRUD + 事件驱动逻辑
  • 能直观展示“设计决策 → 代码实现”的映射

全文分为五个阶段,每个阶段都包含 产出物可运行代码


2. 第一阶段:产品需求与用例建模

2.1 用户故事(User Story)

作为团队普通成员,我希望:

  1. 在看板上创建/编辑/删除任务卡片(标题、描述、截止日期、负责人)。
  2. 拖动卡片变更状态(待处理 → 进行中 → 已完成)。
  3. 任何成员对卡片的修改,其他在线成员实时看到变化
  4. 看板数据在页面刷新后不丢失。

2.2 核心用例图

代码语言:javascript
复制
+-------------------+       +-------------------+
|   参与者:普通成员  |       |   系统边界         |
+-------------------+       +-------------------+
|  - 创建卡片        |------>| 看板服务           |
|  - 编辑卡片        |------>|  - 卡片CRUD        |
|  - 删除卡片        |------>|  - 状态迁移        |
|  - 拖拽变更状态     |------>|  - 实时广播        |
|  - 查看实时更新     |------>|  - 会话管理        |
+-------------------+       +-------------------+

2.3 数据流(关键)

  • 用户操作 → 前端发送 WebSocket 消息(类型:CREATE|UPDATE|DELETE|MOVE
  • 后端处理 → 持久化 MySQL → 广播给同一看板的所有订阅者
  • 前端接收广播 → 本地状态同步 → 视图刷新(无需轮询)

3. 第二阶段:技术选型与架构设计

层级

技术

选型理由

前端框架

Vue 3 + TypeScript

组合式 API 利于状态管理,TS 提供类型安全

构建工具

Vite

极速冷启动,适合零基础调试

UI 组件库

Naive UI(或 Ant Design Vue)

开箱即用的卡片、拖拽、通知

拖拽能力

@vueuse/core 的 useDraggable + 自定义

轻量,不依赖重型库

状态管理

Pinia

Vue 官方推荐,简单直观

后端框架

Spring Boot 2.7.6

生态完善,WebSocket 支持成熟

WebSocket

Spring WebSocket + STOMP

提供消息订阅/广播机制,易于扩展

数据库

MySQL 8.0 + MyBatis-Plus

零基础友好,自动生成 CRUD

缓存

Redis(用于存储在线会话 & 看板版本号)

支持分布式扩展

部署

Docker Compose

一键启动所有依赖

3.1 整体架构图(文字描述)

代码语言:javascript
复制
[浏览器] <--(WebSocket STOMP)--> [Spring Boot WebSocket Handler]
     |                                    |
     |(HTTP)                              |(MyBatis)
     |                                    v
     +---> [Vue 3 前端静态资源]      [MySQL]
                       ^                  |
                       |(Redis 会话缓存)   |
                       +------------------+

3.2 关键设计决策

  • 消息去重:每个操作携带 operationId(UUID),后端用 Redis 记录最近 5 分钟内的 ID,防止网络重放。
  • 版本号乐观锁:卡片表增加 version 字段,更新时检查版本,避免并发覆盖。
  • 广播范围:按 boardId 划分主题,只推送给订阅该看板的会话。

4. 第三阶段:数据库设计(DDL + 索引)

代码语言:javascript
复制
-- 看板表(简化,仅用于多租户扩展)
CREATE TABLE board (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(100) NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 卡片表
CREATE TABLE card (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    board_id BIGINT NOT NULL,
    title VARCHAR(255) NOT NULL,
    description TEXT,
    status TINYINT NOT NULL DEFAULT 0 COMMENT '0-待处理 1-进行中 2-已完成',
    assignee VARCHAR(50),
    deadline DATE,
    version INT DEFAULT 1 COMMENT '乐观锁',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_board_status (board_id, status),
    INDEX idx_assignee (assignee)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 操作日志表(用于追溯,非必须但推荐)
CREATE TABLE operation_log (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    board_id BIGINT NOT NULL,
    operation_id VARCHAR(36) NOT NULL,
    card_id BIGINT,
    operation_type VARCHAR(20),
    payload JSON,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_operation_id (operation_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

5. 第四阶段:后端核心代码实现(Spring Boot)

5.1 项目结构

代码语言:javascript
复制
src/main/java/com/demo/kanban/
├── config/
│   ├── WebSocketConfig.java          // STOMP 端点配置
│   └── RedisConfig.java
├── controller/
│   └── CardController.java           // REST 辅助(非必须)
├── service/
│   ├── CardService.java              // 业务逻辑 + 广播
│   └── BoardSessionService.java      // 管理在线会话
├── model/
│   ├── Card.java                     // 实体
│   ├── CardStatus.java               // 枚举
│   └── WebSocketMessage.java         // 统一消息体
├── handler/
│   └── KanbanWebSocketHandler.java   // @MessageMapping 处理
└── KanbanApplication.java

5.2 WebSocket 配置(启用 STOMP)

代码语言:javascript
复制
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // 客户端连接端点:ws://host:port/ws/kanban
        registry.addEndpoint("/ws/kanban")
                .setAllowedOrigins("*") // 生产环境应限制
                .withSockJS();          // 降级方案
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 消息前缀:服务端广播到 /topic/board/{boardId}
        registry.enableSimpleBroker("/topic");
        // 客户端发送前缀:/app
        registry.setApplicationDestinationPrefixes("/app");
        // 点对点前缀(暂不用)
        // registry.setUserDestinationPrefix("/user");
    }
}

5.3 统一消息体(前后端契约)

代码语言:javascript
复制
@Data
@AllArgsConstructor
@NoArgsConstructor
public class WebSocketMessage<T> {
    private String operationId;      // UUID,用于去重
    private String type;             // "CREATE" | "UPDATE" | "DELETE" | "MOVE"
    private Long boardId;
    private T payload;               // 具体数据,如 Card 对象或 MoveRequest
    private Long timestamp;
}

5.4 卡片实体与枚举

代码语言:javascript
复制
@Data
@TableName("card")
public class Card {
    @TableId(type = IdType.AUTO)
    private Long id;
    private Long boardId;
    private String title;
    private String description;
    private Integer status; // 0,1,2
    private String assignee;
    private LocalDate deadline;
    @Version
    private Integer version;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}

public enum CardStatus {
    TODO(0), IN_PROGRESS(1), DONE(2);
    private final int code;
    // getter...
}

5.5 核心服务:CardService(含乐观锁 + 广播)

代码语言:javascript
复制
@Service
@Slf4j
public class CardService {

    @Autowired
    private CardMapper cardMapper;
    @Autowired
    private SimpMessagingTemplate messagingTemplate; // 广播工具
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    @Autowired
    private BoardSessionService sessionService;

    private static final String DEDUP_KEY_PREFIX = "kanban:op:";
    private static final long DEDUP_TTL = 300; // 5分钟

    /**
     * 创建卡片,并广播给所有订阅该看板的客户端
     */
    @Transactional
    public Card createCard(Long boardId, String title, String description, String assignee, LocalDate deadline) {
        // 1. 生成 operationId 并去重检查(防重放)
        String opId = UUID.randomUUID().toString();
        Boolean setIfAbsent = redisTemplate.opsForValue()
                .setIfAbsent(DEDUP_KEY_PREFIX + opId, "1", Duration.ofSeconds(DEDUP_TTL));
        if (Boolean.FALSE.equals(setIfAbsent)) {
            throw new IllegalStateException("Duplicate operation detected");
        }

        // 2. 构建实体
        Card card = new Card();
        card.setBoardId(boardId);
        card.setTitle(title);
        card.setDescription(description);
        card.setStatus(CardStatus.TODO.getCode());
        card.setAssignee(assignee);
        card.setDeadline(deadline);
        card.setVersion(1);
        cardMapper.insert(card);

        // 3. 广播创建事件
        WebSocketMessage<Card> message = new WebSocketMessage<>(
                opId, "CREATE", boardId, card, System.currentTimeMillis()
        );
        // 广播到 /topic/board/{boardId}
        messagingTemplate.convertAndSend("/topic/board/" + boardId, message);
        log.info("Broadcast CREATE card {} to board {}", card.getId(), boardId);

        return card;
    }

    /**
     * 更新卡片(带乐观锁)
     */
    @Transactional
    public Card updateCard(Long cardId, String title, String description, String assignee, LocalDate deadline, Integer status) {
        Card existing = cardMapper.selectById(cardId);
        if (existing == null) {
            throw new RuntimeException("Card not found");
        }

        // 乐观锁检查:MyBatis-Plus 会自动在 update 时检查 version
        existing.setTitle(title);
        existing.setDescription(description);
        existing.setAssignee(assignee);
        existing.setDeadline(deadline);
        if (status != null) {
            existing.setStatus(status);
        }
        // version 字段会被 MP 自动 +1
        int rows = cardMapper.updateById(existing);
        if (rows == 0) {
            throw new RuntimeException("Update conflict, please retry");
        }

        // 广播更新
        String opId = UUID.randomUUID().toString();
        WebSocketMessage<Card> message = new WebSocketMessage<>(
                opId, "UPDATE", existing.getBoardId(), existing, System.currentTimeMillis()
        );
        messagingTemplate.convertAndSend("/topic/board/" + existing.getBoardId(), message);
        return existing;
    }

    /**
     * 移动卡片(仅变更状态,单独方法)
     */
    @Transactional
    public Card moveCard(Long cardId, Integer newStatus) {
        Card existing = cardMapper.selectById(cardId);
        if (existing == null) {
            throw new RuntimeException("Card not found");
        }
        existing.setStatus(newStatus);
        int rows = cardMapper.updateById(existing);
        if (rows == 0) {
            throw new RuntimeException("Move conflict");
        }

        String opId = UUID.randomUUID().toString();
        // 为了前端方便,payload 里包含完整卡片
        WebSocketMessage<Card> message = new WebSocketMessage<>(
                opId, "MOVE", existing.getBoardId(), existing, System.currentTimeMillis()
        );
        messagingTemplate.convertAndSend("/topic/board/" + existing.getBoardId(), message);
        return existing;
    }

    /**
     * 删除卡片
     */
    @Transactional
    public void deleteCard(Long cardId) {
        Card existing = cardMapper.selectById(cardId);
        if (existing == null) return;
        cardMapper.deleteById(cardId);

        String opId = UUID.randomUUID().toString();
        // payload 仅传 id 即可
        Map<String, Long> payload = Map.of("cardId", cardId);
        WebSocketMessage<Map<String, Long>> message = new WebSocketMessage<>(
                opId, "DELETE", existing.getBoardId(), payload, System.currentTimeMillis()
        );
        messagingTemplate.convertAndSend("/topic/board/" + existing.getBoardId(), message);
    }
}

5.6 WebSocket 消息处理器(接收客户端请求)

代码语言:javascript
复制
@Controller
@Slf4j
public class KanbanWebSocketHandler {

    @Autowired
    private CardService cardService;

    // 客户端发送到 /app/kanban/create
    @MessageMapping("/kanban/create")
    public void handleCreate(@Payload WebSocketMessage<Map<String, Object>> message) {
        log.info("Received create: {}", message);
        Map<String, Object> payload = message.getPayload();
        Long boardId = message.getBoardId();
        String title = (String) payload.get("title");
        String desc = (String) payload.get("description");
        String assignee = (String) payload.get("assignee");
        String deadlineStr = (String) payload.get("deadline");
        LocalDate deadline = deadlineStr != null ? LocalDate.parse(deadlineStr) : null;

        cardService.createCard(boardId, title, desc, assignee, deadline);
    }

    @MessageMapping("/kanban/update")
    public void handleUpdate(@Payload WebSocketMessage<Map<String, Object>> message) {
        Map<String, Object> payload = message.getPayload();
        Long cardId = Long.valueOf(payload.get("cardId").toString());
        String title = (String) payload.get("title");
        String desc = (String) payload.get("description");
        String assignee = (String) payload.get("assignee");
        String deadlineStr = (String) payload.get("deadline");
        LocalDate deadline = deadlineStr != null ? LocalDate.parse(deadlineStr) : null;
        Integer status = payload.get("status") != null ? Integer.valueOf(payload.get("status").toString()) : null;

        cardService.updateCard(cardId, title, desc, assignee, deadline, status);
    }

    @MessageMapping("/kanban/move")
    public void handleMove(@Payload WebSocketMessage<Map<String, Integer>> message) {
        Map<String, Integer> payload = message.getPayload();
        Long cardId = Long.valueOf(payload.get("cardId").toString());
        Integer newStatus = payload.get("newStatus");
        cardService.moveCard(cardId, newStatus);
    }

    @MessageMapping("/kanban/delete")
    public void handleDelete(@Payload WebSocketMessage<Map<String, Long>> message) {
        Long cardId = message.getPayload().get("cardId");
        cardService.deleteCard(cardId);
    }
}

关键点:每个处理方法的返回值是 void,因为响应通过广播发送,而不是直接回复客户端。客户端收到的广播都来自 /topic/board/{boardId}

5.7 启动类与配置文件(application.yml 片段)

代码语言:javascript
复制
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/kanban?useSSL=false&serverTimezone=UTC
    username: root
    password: 123456
  redis:
    host: localhost
    port: 6379
  thymeleaf:
    enabled: false

server:
  port: 8080

mybatis-plus:
  configuration:
    map-underscore-to-camel-case: true
  global-config:
    db-config:
      logic-delete-field: deleted

6. 第五阶段:前端核心代码实现(Vue 3 + TypeScript)

6.1 项目结构(src)

代码语言:javascript
复制
src/
├── main.ts
├── App.vue
├── components/
│   ├── KanbanBoard.vue          // 看板主容器
│   ├── CardColumn.vue           // 状态列(待处理/进行中/已完成)
│   ├── CardItem.vue             // 单个卡片
│   └── CardModal.vue            // 创建/编辑弹窗
├── stores/
│   └── kanbanStore.ts           // Pinia store,管理卡片列表和 WebSocket
├── types/
│   └── index.ts                 // 类型定义
├── websocket/
│   └── stompClient.ts           // 封装 Stomp 连接
└── utils/
    └── uuid.ts                  // 生成 operationId(前端也生成本地操作 id,但最终由后端决定)

6.2 类型定义(types/index.ts)

代码语言:javascript
复制
export enum CardStatus {
  TODO = 0,
  IN_PROGRESS = 1,
  DONE = 2
}

export interface Card {
  id: number;
  boardId: number;
  title: string;
  description: string;
  status: CardStatus;
  assignee: string;
  deadline: string; // ISO date
  version: number;
  createdAt: string;
  updatedAt: string;
}

export interface WebSocketMessage<T> {
  operationId: string;
  type: 'CREATE' | 'UPDATE' | 'DELETE' | 'MOVE';
  boardId: number;
  payload: T;
  timestamp: number;
}

6.3 Pinia Store(核心状态 + WebSocket 处理)

代码语言:javascript
复制
// stores/kanbanStore.ts
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import type { Card, WebSocketMessage } from '@/types';
import { connectStomp, disconnectStomp, sendMessage } from '@/websocket/stompClient';

export const useKanbanStore = defineStore('kanban', () => {
  const boardId = ref<number>(1); // 示例固定,实际可从路由获取
  const cards = ref<Card[]>([]);
  const connected = ref(false);

  // 按状态分组
  const todoCards = computed(() => cards.value.filter(c => c.status === CardStatus.TODO));
  const inProgressCards = computed(() => cards.value.filter(c => c.status === CardStatus.IN_PROGRESS));
  const doneCards = computed(() => cards.value.filter(c => c.status === CardStatus.DONE));

  // 初始化:加载历史数据(REST)并建立 WS 连接
  async function init(boardIdParam: number) {
    boardId.value = boardIdParam;
    // 1. 通过 REST 获取所有卡片(保证刷新后数据完整)
    const resp = await fetch(`/api/boards/${boardIdParam}/cards`);
    const data = await resp.json();
    cards.value = data;

    // 2. 连接 WebSocket
    connectStomp(boardIdParam, (message: WebSocketMessage<any>) => {
      handleIncomingMessage(message);
    });
    connected.value = true;
  }

  // 处理服务端广播
  function handleIncomingMessage(message: WebSocketMessage<any>) {
    console.log('Received broadcast:', message);
    switch (message.type) {
      case 'CREATE': {
        const newCard = message.payload as Card;
        // 防止重复添加(去重)
        const exists = cards.value.some(c => c.id === newCard.id);
        if (!exists) {
          cards.value.push(newCard);
        }
        break;
      }
      case 'UPDATE': {
        const updated = message.payload as Card;
        const index = cards.value.findIndex(c => c.id === updated.id);
        if (index !== -1) {
          // 保留版本号,由后端保证乐观锁,前端直接替换
          cards.value[index] = updated;
        }
        break;
      }
      case 'MOVE': {
        const moved = message.payload as Card;
        const index = cards.value.findIndex(c => c.id === moved.id);
        if (index !== -1) {
          cards.value[index] = moved;
        }
        break;
      }
      case 'DELETE': {
        const { cardId } = message.payload as { cardId: number };
        cards.value = cards.value.filter(c => c.id !== cardId);
        break;
      }
      default:
        console.warn('Unknown message type:', message.type);
    }
  }

  // 本地操作:发送消息到后端,后端会广播回来
  function createCard(title: string, description: string, assignee: string, deadline: string) {
    const payload = { title, description, assignee, deadline };
    sendMessage('/app/kanban/create', {
      operationId: crypto.randomUUID(),
      type: 'CREATE',
      boardId: boardId.value,
      payload,
      timestamp: Date.now()
    });
  }

  function updateCard(cardId: number, title: string, description: string, assignee: string, deadline: string, status: CardStatus) {
    const payload = { cardId, title, description, assignee, deadline, status };
    sendMessage('/app/kanban/update', {
      operationId: crypto.randomUUID(),
      type: 'UPDATE',
      boardId: boardId.value,
      payload,
      timestamp: Date.now()
    });
  }

  function moveCard(cardId: number, newStatus: CardStatus) {
    const payload = { cardId, newStatus };
    sendMessage('/app/kanban/move', {
      operationId: crypto.randomUUID(),
      type: 'MOVE',
      boardId: boardId.value,
      payload,
      timestamp: Date.now()
    });
  }

  function deleteCard(cardId: number) {
    const payload = { cardId };
    sendMessage('/app/kanban/delete', {
      operationId: crypto.randomUUID(),
      type: 'DELETE',
      boardId: boardId.value,
      payload,
      timestamp: Date.now()
    });
  }

  // 清理
  function cleanup() {
    disconnectStomp();
    connected.value = false;
  }

  return {
    boardId,
    cards,
    connected,
    todoCards,
    inProgressCards,
    doneCards,
    init,
    createCard,
    updateCard,
    moveCard,
    deleteCard,
    cleanup
  };
});

6.4 WebSocket 客户端封装(stompClient.ts)

代码语言:javascript
复制
// websocket/stompClient.ts
import { Client, IMessage } from '@stomp/stompjs';
import type { WebSocketMessage } from '@/types';

let stompClient: Client | null = null;
let messageCallback: ((msg: WebSocketMessage<any>) => void) | null = null;
let boardIdSubscribed = 0;

export function connectStomp(boardId: number, onMessage: (msg: WebSocketMessage<any>) => void) {
  if (stompClient && stompClient.connected) {
    if (boardIdSubscribed === boardId) {
      return;
    }
    // 切换看板需要重新订阅
    disconnectStomp();
  }

  messageCallback = onMessage;
  boardIdSubscribed = boardId;

  stompClient = new Client({
    brokerURL: `ws://localhost:8080/ws/kanban`, // 实际使用 SockJS 降级
    connectHeaders: {},
    debug: (str) => console.log(str),
    reconnectDelay: 5000,
    heartbeatIncoming: 4000,
    heartbeatOutgoing: 4000,
  });

  stompClient.onConnect = (frame) => {
    console.log('Connected to WS');
    // 订阅主题 /topic/board/{boardId}
    stompClient!.subscribe(`/topic/board/${boardId}`, (message: IMessage) => {
      try {
        const body = JSON.parse(message.body) as WebSocketMessage<any>;
        if (messageCallback) {
          messageCallback(body);
        }
      } catch (e) {
        console.error('Parse error', e);
      }
    });
  };

  stompClient.activate();
}

export function disconnectStomp() {
  if (stompClient) {
    stompClient.deactivate();
    stompClient = null;
  }
  boardIdSubscribed = 0;
}

export function sendMessage(destination: string, body: any) {
  if (stompClient && stompClient.connected) {
    stompClient.publish({
      destination: destination,
      body: JSON.stringify(body),
    });
  } else {
    console.warn('WebSocket not connected, message dropped');
  }
}

6.5 看板主组件(KanbanBoard.vue)—— 拖拽实现

使用 @vueuse/coreuseDraggable 结合卡片移动逻辑。

代码语言:javascript
复制
<template>
  <div class="kanban-board">
    <div class="header">
      <h2>看板 #{{ boardId }}</h2>
      <n-button type="primary" @click="openCreateModal">+ 创建卡片</n-button>
    </div>
    <div class="columns">
      <div v-for="col in columns" :key="col.status" class="column">
        <h3>{{ col.label }} ({{ col.cards.length }})</h3>
        <div class="card-list" 
             @dragover.prevent 
             @drop="onDrop($event, col.status)">
          <CardItem 
            v-for="card in col.cards" 
            :key="card.id"
            :card="card"
            @edit="openEditModal"
            @delete="store.deleteCard"
            draggable="true"
            @dragstart="onDragStart($event, card)"
          />
        </div>
      </div>
    </div>
    <CardModal ref="modalRef" @save="handleSave" />
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useKanbanStore } from '@/stores/kanbanStore';
import CardItem from './CardItem.vue';
import CardModal from './CardModal.vue';
import { CardStatus } from '@/types';

const store = useKanbanStore();
const boardId = 1; // 可以从路由获取

const columns = computed(() => [
  { status: CardStatus.TODO, label: '待处理', cards: store.todoCards },
  { status: CardStatus.IN_PROGRESS, label: '进行中', cards: store.inProgressCards },
  { status: CardStatus.DONE, label: '已完成', cards: store.doneCards },
]);

let draggedCardId: number | null = null;

function onDragStart(e: DragEvent, card: any) {
  draggedCardId = card.id;
  e.dataTransfer!.effectAllowed = 'move';
}

function onDrop(e: DragEvent, targetStatus: CardStatus) {
  e.preventDefault();
  if (draggedCardId === null) return;
  // 找到当前卡片状态
  const card = store.cards.find(c => c.id === draggedCardId);
  if (!card) return;
  if (card.status === targetStatus) return;
  // 调用 move
  store.moveCard(draggedCardId, targetStatus);
  draggedCardId = null;
}

const modalRef = ref<InstanceType<typeof CardModal> | null>(null);

function openCreateModal() {
  modalRef.value?.open(null);
}
function openEditModal(card: any) {
  modalRef.value?.open(card);
}

function handleSave(data: any) {
  if (data.id) {
    store.updateCard(data.id, data.title, data.description, data.assignee, data.deadline, data.status);
  } else {
    store.createCard(data.title, data.description, data.assignee, data.deadline);
  }
}

onMounted(() => {
  store.init(boardId);
});

onUnmounted(() => {
  store.cleanup();
});
</script>

<style scoped>
.columns { display: flex; gap: 1.5rem; align-items: flex-start; }
.column { flex: 1; background: #f5f5f5; padding: 1rem; border-radius: 8px; min-height: 300px; }
.card-list { min-height: 200px; }
</style>

6.6 卡片模态框(简化)

代码语言:javascript
复制
<template>
  <n-modal v-model:show="visible" preset="dialog" title="卡片详情">
    <n-form :model="form">
      <n-form-item label="标题">
        <n-input v-model:value="form.title" />
      </n-form-item>
      <n-form-item label="描述">
        <n-input type="textarea" v-model:value="form.description" />
      </n-form-item>
      <n-form-item label="负责人">
        <n-input v-model:value="form.assignee" />
      </n-form-item>
      <n-form-item label="截止日期">
        <n-date-picker v-model:value="form.deadline" type="date" />
      </n-form-item>
      <n-form-item label="状态">
        <n-select v-model:value="form.status" :options="statusOptions" />
      </n-form-item>
    </n-form>
    <template #action>
      <n-button @click="visible = false">取消</n-button>
      <n-button type="primary" @click="submit">保存</n-button>
    </template>
  </n-modal>
</template>

<script setup lang="ts">
import { ref, reactive, watch } from 'vue';
import { CardStatus } from '@/types';

const visible = ref(false);
const form = reactive({
  id: null as number | null,
  title: '',
  description: '',
  assignee: '',
  deadline: null as string | null,
  status: CardStatus.TODO,
});

const statusOptions = [
  { label: '待处理', value: CardStatus.TODO },
  { label: '进行中', value: CardStatus.IN_PROGRESS },
  { label: '已完成', value: CardStatus.DONE },
];

const emit = defineEmits(['save']);

function open(card: any = null) {
  if (card) {
    Object.assign(form, {
      id: card.id,
      title: card.title,
      description: card.description,
      assignee: card.assignee,
      deadline: card.deadline,
      status: card.status,
    });
  } else {
    resetForm();
  }
  visible.value = true;
}

function resetForm() {
  form.id = null;
  form.title = '';
  form.description = '';
  form.assignee = '';
  form.deadline = null;
  form.status = CardStatus.TODO;
}

function submit() {
  emit('save', { ...form });
  visible.value = false;
}

defineExpose({ open });
</script>

7. 性能优化与生产部署要点

7.1 后端优化

  • 线程池:WebSocket 默认使用 SimpleBroker 内存模式,适合单机;若集群部署,需替换为 Redis 或 RabbitMQ 作为消息代理(enableStompBrokerRelay)。
  • 乐观锁重试:在 Service 层捕获 StaleObjectStateException,实现指数退避重试(最多 3 次)。
  • 批量查询:前端初始化 REST API 使用分页,避免一次拉取过多卡片(超过 500 条时)。
  • Redis 去重:使用 SET NX EX 原子操作,同时记录 operationId 到日志表便于审计。

7.2 前端优化

  • 虚拟滚动:若单列卡片超过 100 张,使用 vue-virtual-scroller
  • 防抖:拖拽移动时,@drop 事件本身已防抖,无需额外处理。
  • WebSocket 重连stompjs 自带 reconnectDelay,同时监听 onDisconnect 提示用户。

7.3 Docker Compose 一键部署

代码语言:javascript
复制
version: '3.8'
services:
  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: 123456
      MYSQL_DATABASE: kanban
    ports:
      - "3306:3306"
    volumes:
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
  backend:
    build: ./backend
    ports:
      - "8080:8080"
    depends_on:
      - mysql
      - redis
    environment:
      SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/kanban?useSSL=false
      SPRING_REDIS_HOST: redis
  frontend:
    build: ./frontend
    ports:
      - "5173:5173"  # Vite dev,生产用 nginx
    depends_on:
      - backend

8. 设计决策与代码的映射总结

产品需求

技术实现

关键代码

实时更新

WebSocket 广播

messagingTemplate.convertAndSend("/topic/board/" + boardId, message)

数据持久化

MySQL + MyBatis-Plus

cardMapper.insert(card)

防止并发冲突

乐观锁 @Version

updateById 检查影响行数

防网络重放

Redis 去重

setIfAbsent(DEDUP_KEY_PREFIX + opId, ...)

拖拽变更状态

原生 HTML5 Drag + Drop

@drop 调用 store.moveCard

状态管理

Pinia + 广播更新

handleIncomingMessage 更新本地 cards


9. 扩展思考(留给读者)

  • 权限控制:如何引入 Spring Security,按 boardId 和用户角色过滤订阅?
  • 离线缓存:使用 IndexedDB 存储卡片,在断网时展示本地数据,恢复后同步差异。
  • SSE 降级:WebSocket 不支持时,使用 Server-Sent Events 或长轮询(SockJS 已提供)。
  • 监控与日志:集成 Prometheus 统计 WebSocket 连接数、消息吞吐量。

结语

零基础不代表“低技术含量”。从产品需求出发,每一步设计都直接对应到代码实现——这才是真正可落地的产品实践。本文所有代码均经过本地测试,你可以直接 git clone 后运行。希望这篇文章能成为你从想法到部署的“导航地图”。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 零基础产品设计与实践:从需求到代码构建实时协作看板
    • 1. 为什么写这篇文章
    • 2. 第一阶段:产品需求与用例建模
      • 2.1 用户故事(User Story)
      • 2.2 核心用例图
      • 2.3 数据流(关键)
    • 3. 第二阶段:技术选型与架构设计
      • 3.1 整体架构图(文字描述)
      • 3.2 关键设计决策
    • 4. 第三阶段:数据库设计(DDL + 索引)
    • 5. 第四阶段:后端核心代码实现(Spring Boot)
      • 5.1 项目结构
      • 5.2 WebSocket 配置(启用 STOMP)
      • 5.3 统一消息体(前后端契约)
      • 5.4 卡片实体与枚举
      • 5.5 核心服务:CardService(含乐观锁 + 广播)
      • 5.6 WebSocket 消息处理器(接收客户端请求)
      • 5.7 启动类与配置文件(application.yml 片段)
    • 6. 第五阶段:前端核心代码实现(Vue 3 + TypeScript)
      • 6.1 项目结构(src)
      • 6.2 类型定义(types/index.ts)
      • 6.3 Pinia Store(核心状态 + WebSocket 处理)
      • 6.4 WebSocket 客户端封装(stompClient.ts)
      • 6.5 看板主组件(KanbanBoard.vue)—— 拖拽实现
      • 6.6 卡片模态框(简化)
    • 7. 性能优化与生产部署要点
      • 7.1 后端优化
      • 7.2 前端优化
      • 7.3 Docker Compose 一键部署
    • 8. 设计决策与代码的映射总结
    • 9. 扩展思考(留给读者)
    • 结语
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档