Initial commit: DataTool backend, frontend and Docker
This commit is contained in:
890
docs/00-数据传输助手-开发文档.md
Normal file
890
docs/00-数据传输助手-开发文档.md
Normal file
@@ -0,0 +1,890 @@
|
||||
# 数据传输助手 - 开发文档
|
||||
|
||||
## 拆分文档目录(01/02/03…)
|
||||
- [01 - 整体架构与核心概念](docs/01-整体架构与核心概念.md)
|
||||
- [02 - 房间管理(创建 / 加入 / 退出)](docs/02-房间管理(创建-加入-退出).md)
|
||||
- [03 - WebSocket连接管理(连接 / 订阅 / 心跳 / 重连)](docs/03-WebSocket连接管理(连接-订阅-心跳-重连).md)
|
||||
- [04 - 消息协议与消息分发(统一Message模型)](docs/04-消息协议与消息分发(统一Message模型).md)
|
||||
- [05 - 文本传输(含大文本分片)](docs/05-文本传输(含大文本分片).md)
|
||||
- [06 - 文件传输(拖拽 / 分片 / 进度 / 下载)](docs/06-文件传输(拖拽-分片-进度-下载).md)
|
||||
- [07 - 图片预览(Base64内联显示)](docs/07-图片预览(Base64内联显示).md)
|
||||
- [08 - 剪贴板集成(读取 / 粘贴 / 降级)](docs/08-剪贴板集成(读取-粘贴-降级).md)
|
||||
- [09 - 在线用户列表(房间成员与系统消息)](docs/09-在线用户列表(房间成员与系统消息).md)
|
||||
- [10 - 历史记录(本地存储 / 清空 / 导出)](docs/10-历史记录(本地存储-清空-导出).md)
|
||||
- [11 - 数据库设计(可选:持久化与审计)](docs/11-数据库设计(可选:持久化与审计).md)
|
||||
- [12 - 部署与环境配置(后端 / 前端 / Nginx)](docs/12-部署与环境配置(后端-前端-Nginx).md)
|
||||
- [13 - 安全与风控(基础措施与可选增强)](docs/13-安全与风控(基础措施与可选增强).md)
|
||||
- [14 - 开发计划与里程碑(Phase 1~4)](docs/14-开发计划与里程碑(Phase1-4).md)
|
||||
|
||||
## 一、项目概述
|
||||
|
||||
### 1.1 项目背景
|
||||
在VNC远程桌面、内网环境或安全隔离场景中,由于剪贴板共享被禁用或系统限制,用户无法直接进行复制粘贴操作。本工具提供一个基于浏览器的轻量级数据传输方案,通过WebSocket实现多端实时数据同步。
|
||||
|
||||
### 1.2 应用场景
|
||||
- VNC/远程桌面环境的数据传输
|
||||
- 内外网隔离环境下的文件/文本交换
|
||||
- 临时性的跨设备数据共享
|
||||
- 无法使用U盘或即时通讯工具的场景
|
||||
|
||||
### 1.3 技术栈
|
||||
| 层级 | 技术 | 版本建议 |
|
||||
|------|------|----------|
|
||||
| 后端 | Spring Boot | 2.7.x / 3.x |
|
||||
| 后端 | Java | 11 / 17 |
|
||||
| 实时通信 | WebSocket (STOMP) | - |
|
||||
| 前端 | Vue | 3.x |
|
||||
| UI组件库 | Element Plus | 2.x |
|
||||
| 构建工具 | Vite | 4.x |
|
||||
|
||||
---
|
||||
|
||||
## 二、系统架构
|
||||
|
||||
### 2.1 架构图
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ 发送端浏览器 │◄───►│ WebSocket │◄───►│ 接收端浏览器 │
|
||||
│ (Vue + Element)│ │ 服务器(Java) │ │ (Vue + Element)│
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│ │ │
|
||||
└───────────────────────┴───────────────────────┘
|
||||
同一房间号(room)
|
||||
```
|
||||
|
||||
### 2.2 核心概念
|
||||
- **房间(Room)**:数据传输的隔离空间,通过6位数字房间号标识
|
||||
- **会话(Session)**:WebSocket连接实例
|
||||
- **消息(Message)**:传输的数据单元,支持文本、文件元数据、二进制分片
|
||||
|
||||
---
|
||||
|
||||
## 三、功能设计
|
||||
|
||||
### 3.1 功能模块
|
||||
|
||||
```
|
||||
├── 房间管理
|
||||
│ ├── 创建房间(自动生成6位房间号)
|
||||
│ ├── 加入房间(输入房间号)
|
||||
│ └── 退出房间
|
||||
│
|
||||
├── 数据传输
|
||||
│ ├── 文本传输(支持大文本,自动分片)
|
||||
│ ├── 文件传输(支持拖拽上传、进度显示)
|
||||
│ ├── 图片预览(Base64内联显示)
|
||||
│ └── 剪贴板读取(读取系统剪贴板内容)
|
||||
│
|
||||
├── 连接管理
|
||||
│ ├── 在线用户列表
|
||||
│ ├── 连接状态指示
|
||||
│ └── 心跳保活
|
||||
│
|
||||
└── 历史记录
|
||||
├── 消息历史(本地存储)
|
||||
├── 清空记录
|
||||
└── 导出记录
|
||||
```
|
||||
|
||||
### 3.2 页面布局
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 数据传输助手 [连接状态] │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────┐ ┌───────────────────────────────────────┐ │
|
||||
│ │ 房间信息 │ │ 消息展示区 │ │
|
||||
│ │ ───────── │ │ ┌─────────────────────────────────┐ │ │
|
||||
│ │ 房间号: │ │ │ [系统] xxx 加入了房间 │ │ │
|
||||
│ │ 123456 │ │ │ [我] 这是一段文本消息... │ │ │
|
||||
│ │ │ │ │ [对方] 收到,这是回复... │ │ │
|
||||
│ │ ───────── │ │ │ [文件] 文档.pdf (2.5MB) [下载] │ │ │
|
||||
│ │ 在线用户 │ │ └─────────────────────────────────┘ │ │
|
||||
│ │ ● 用户A │ │ │ │
|
||||
│ │ ○ 用户B │ └───────────────────────────────────────┘ │
|
||||
│ │ │ ┌───────────────────────────────────────┐ │
|
||||
│ │ ───────── │ │ [粘贴/拖拽区域] │ │
|
||||
│ │ 快捷操作 │ │ 支持: 文本粘贴、文件拖拽、剪贴板读取 │ │
|
||||
│ │ [清空] │ └───────────────────────────────────────┘ │
|
||||
│ │ [导出] │ ┌────────────────────┐ ┌──────────────┐ │ │
|
||||
│ └─────────────┘ │ 输入框... │ │ 发送 [▶] │ │ │
|
||||
│ └────────────────────┘ └──────────────┘ │ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、数据库设计
|
||||
|
||||
### 4.1 实体设计(可选,支持消息持久化)
|
||||
|
||||
```sql
|
||||
-- 房间表
|
||||
CREATE TABLE room (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
room_code VARCHAR(6) UNIQUE NOT NULL COMMENT '房间号',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP COMMENT '过期时间',
|
||||
is_active TINYINT DEFAULT 1
|
||||
);
|
||||
|
||||
-- 消息记录表(可选,用于审计或消息回放)
|
||||
CREATE TABLE message (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
room_code VARCHAR(6) NOT NULL,
|
||||
sender_id VARCHAR(64) NOT NULL,
|
||||
sender_name VARCHAR(32),
|
||||
msg_type TINYINT COMMENT '1-文本 2-文件 3-图片',
|
||||
content TEXT COMMENT '文本内容或文件元数据JSON',
|
||||
file_size BIGINT COMMENT '文件大小',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_room_time (room_code, created_at)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、接口设计
|
||||
|
||||
### 5.1 WebSocket 端点
|
||||
|
||||
| 端点 | 说明 |
|
||||
|------|------|
|
||||
| `/ws/data-transfer` | WebSocket连接入口 |
|
||||
| `/topic/room/{roomCode}` | 房间广播频道(订阅) |
|
||||
| `/app/room/{roomCode}/join` | 加入房间(发送) |
|
||||
| `/app/room/{roomCode}/leave` | 离开房间(发送) |
|
||||
| `/app/room/{roomCode}/message` | 发送消息(发送) |
|
||||
| `/app/room/{roomCode}/file/chunk` | 发送文件分片(发送) |
|
||||
|
||||
### 5.2 消息协议
|
||||
|
||||
#### 基础消息格式
|
||||
```json
|
||||
{
|
||||
"type": "TEXT | FILE | IMAGE | SYSTEM | CHUNK",
|
||||
"senderId": "uuid",
|
||||
"senderName": "用户昵称",
|
||||
"timestamp": 1706345600000,
|
||||
"payload": {}
|
||||
}
|
||||
```
|
||||
|
||||
#### 文本消息
|
||||
```json
|
||||
{
|
||||
"type": "TEXT",
|
||||
"senderId": "uuid",
|
||||
"senderName": "用户A",
|
||||
"timestamp": 1706345600000,
|
||||
"payload": {
|
||||
"content": "要传输的文本内容",
|
||||
"isChunk": false,
|
||||
"chunkIndex": 0,
|
||||
"totalChunks": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 文件元数据消息
|
||||
```json
|
||||
{
|
||||
"type": "FILE",
|
||||
"senderId": "uuid",
|
||||
"senderName": "用户A",
|
||||
"timestamp": 1706345600000,
|
||||
"payload": {
|
||||
"fileId": "uuid",
|
||||
"fileName": "document.pdf",
|
||||
"fileSize": 2621440,
|
||||
"mimeType": "application/pdf",
|
||||
"totalChunks": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 文件分片消息
|
||||
```json
|
||||
{
|
||||
"type": "CHUNK",
|
||||
"senderId": "uuid",
|
||||
"payload": {
|
||||
"fileId": "uuid",
|
||||
"chunkIndex": 0,
|
||||
"data": "base64EncodedChunkData"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 系统消息
|
||||
```json
|
||||
{
|
||||
"type": "SYSTEM",
|
||||
"payload": {
|
||||
"event": "USER_JOIN | USER_LEAVE | ERROR",
|
||||
"message": "xxx 加入了房间",
|
||||
"userList": [{"id": "uuid", "name": "用户A"}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、后端实现
|
||||
|
||||
### 6.1 项目结构
|
||||
|
||||
```
|
||||
data-transfer-server/
|
||||
├── src/main/java/com/example/datatransfer/
|
||||
│ ├── config/
|
||||
│ │ ├── WebSocketConfig.java # WebSocket配置
|
||||
│ │ └── CorsConfig.java # 跨域配置
|
||||
│ ├── controller/
|
||||
│ │ └── WebSocketController.java # WebSocket消息处理器
|
||||
│ ├── service/
|
||||
│ │ ├── RoomService.java # 房间管理
|
||||
│ │ ├── MessageService.java # 消息处理
|
||||
│ │ └── FileTransferService.java # 文件传输管理
|
||||
│ ├── model/
|
||||
│ │ ├── Message.java # 消息实体
|
||||
│ │ ├── Room.java # 房间实体
|
||||
│ │ └── FileChunk.java # 文件分片
|
||||
│ ├── handler/
|
||||
│ │ └── CustomHandshakeHandler.java # 握手处理器
|
||||
│ └── DataTransferApplication.java
|
||||
├── src/main/resources/
|
||||
│ └── application.yml
|
||||
└── pom.xml
|
||||
```
|
||||
|
||||
### 6.2 核心代码示例
|
||||
|
||||
#### WebSocketConfig.java
|
||||
```java
|
||||
@Configuration
|
||||
@EnableWebSocketMessageBroker
|
||||
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||
|
||||
@Override
|
||||
public void configureMessageBroker(MessageBrokerRegistry config) {
|
||||
config.enableSimpleBroker("/topic");
|
||||
config.setApplicationDestinationPrefixes("/app");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
||||
registry.addEndpoint("/ws/data-transfer")
|
||||
.setAllowedOriginPatterns("*")
|
||||
.withSockJS();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### WebSocketController.java
|
||||
```java
|
||||
@Controller
|
||||
public class WebSocketController {
|
||||
|
||||
@Autowired
|
||||
private SimpMessagingTemplate messagingTemplate;
|
||||
|
||||
@Autowired
|
||||
private RoomService roomService;
|
||||
|
||||
// 加入房间
|
||||
@MessageMapping("/room/{roomCode}/join")
|
||||
public void joinRoom(@DestinationVariable String roomCode,
|
||||
@Payload JoinRequest request,
|
||||
SimpMessageHeaderAccessor headerAccessor) {
|
||||
String sessionId = headerAccessor.getSessionId();
|
||||
roomService.joinRoom(roomCode, sessionId, request.getUserName());
|
||||
|
||||
// 广播用户加入消息
|
||||
Message systemMsg = Message.builder()
|
||||
.type(MessageType.SYSTEM)
|
||||
.payload(Map.of(
|
||||
"event", "USER_JOIN",
|
||||
"message", request.getUserName() + " 加入了房间",
|
||||
"userList", roomService.getUserList(roomCode)
|
||||
))
|
||||
.build();
|
||||
|
||||
messagingTemplate.convertAndSend("/topic/room/" + roomCode, systemMsg);
|
||||
}
|
||||
|
||||
// 发送文本消息
|
||||
@MessageMapping("/room/{roomCode}/message")
|
||||
public void sendMessage(@DestinationVariable String roomCode,
|
||||
@Payload Message message) {
|
||||
message.setTimestamp(System.currentTimeMillis());
|
||||
messagingTemplate.convertAndSend("/topic/room/" + roomCode, message);
|
||||
}
|
||||
|
||||
// 发送文件分片
|
||||
@MessageMapping("/room/{roomCode}/file/chunk")
|
||||
public void sendFileChunk(@DestinationVariable String roomCode,
|
||||
@Payload FileChunk chunk) {
|
||||
messagingTemplate.convertAndSend(
|
||||
"/topic/room/" + roomCode + "/file/" + chunk.getFileId(),
|
||||
chunk
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### RoomService.java
|
||||
```java
|
||||
@Service
|
||||
public class RoomService {
|
||||
|
||||
// 内存存储,生产环境可改用Redis
|
||||
private ConcurrentHashMap<String, Room> rooms = new ConcurrentHashMap<>();
|
||||
|
||||
public String createRoom() {
|
||||
String roomCode = generateRoomCode();
|
||||
Room room = new Room(roomCode);
|
||||
rooms.put(roomCode, room);
|
||||
return roomCode;
|
||||
}
|
||||
|
||||
public void joinRoom(String roomCode, String sessionId, String userName) {
|
||||
Room room = rooms.computeIfAbsent(roomCode, k -> new Room(k));
|
||||
room.addUser(sessionId, userName);
|
||||
}
|
||||
|
||||
public void leaveRoom(String roomCode, String sessionId) {
|
||||
Room room = rooms.get(roomCode);
|
||||
if (room != null) {
|
||||
room.removeUser(sessionId);
|
||||
if (room.isEmpty()) {
|
||||
rooms.remove(roomCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String generateRoomCode() {
|
||||
// 生成6位数字房间号
|
||||
Random random = new Random();
|
||||
return String.format("%06d", random.nextInt(1000000));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、前端实现
|
||||
|
||||
### 7.1 项目结构
|
||||
|
||||
```
|
||||
data-transfer-web/
|
||||
├── public/
|
||||
├── src/
|
||||
│ ├── api/
|
||||
│ │ └── websocket.js # WebSocket封装
|
||||
│ ├── components/
|
||||
│ │ ├── RoomPanel.vue # 房间信息面板
|
||||
│ │ ├── MessageList.vue # 消息列表
|
||||
│ │ ├── MessageInput.vue # 消息输入
|
||||
│ │ ├── FileDropZone.vue # 文件拖拽区域
|
||||
│ │ └── UserList.vue # 在线用户列表
|
||||
│ ├── views/
|
||||
│ │ ├── HomeView.vue # 首页(创建/加入房间)
|
||||
│ │ └── RoomView.vue # 房间页面
|
||||
│ ├── stores/
|
||||
│ │ └── room.js # Pinia状态管理
|
||||
│ ├── utils/
|
||||
│ │ ├── fileChunker.js # 文件分片工具
|
||||
│ │ └── clipboard.js # 剪贴板工具
|
||||
│ ├── App.vue
|
||||
│ └── main.js
|
||||
├── index.html
|
||||
├── package.json
|
||||
└── vite.config.js
|
||||
```
|
||||
|
||||
### 7.2 核心代码示例
|
||||
|
||||
#### WebSocket封装 (websocket.js)
|
||||
```javascript
|
||||
import SockJS from 'sockjs-client'
|
||||
import { Client } from '@stomp/stompjs'
|
||||
|
||||
class WebSocketService {
|
||||
constructor() {
|
||||
this.client = null
|
||||
this.subscriptions = new Map()
|
||||
}
|
||||
|
||||
connect(url, onConnect, onError) {
|
||||
this.client = new Client({
|
||||
webSocketFactory: () => new SockJS(url),
|
||||
reconnectDelay: 5000,
|
||||
heartbeatIncoming: 4000,
|
||||
heartbeatOutgoing: 4000,
|
||||
})
|
||||
|
||||
this.client.onConnect = onConnect
|
||||
this.client.onStompError = onError
|
||||
this.client.activate()
|
||||
}
|
||||
|
||||
subscribe(destination, callback) {
|
||||
if (this.client && this.client.connected) {
|
||||
const subscription = this.client.subscribe(destination, (message) => {
|
||||
callback(JSON.parse(message.body))
|
||||
})
|
||||
this.subscriptions.set(destination, subscription)
|
||||
}
|
||||
}
|
||||
|
||||
send(destination, body) {
|
||||
if (this.client && this.client.connected) {
|
||||
this.client.publish({
|
||||
destination,
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.subscriptions.forEach(sub => sub.unsubscribe())
|
||||
this.subscriptions.clear()
|
||||
if (this.client) {
|
||||
this.client.deactivate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new WebSocketService()
|
||||
```
|
||||
|
||||
#### 房间页面 (RoomView.vue)
|
||||
```vue
|
||||
<template>
|
||||
<div class="room-container">
|
||||
<el-container>
|
||||
<!-- 左侧边栏 -->
|
||||
<el-aside width="250px">
|
||||
<RoomPanel
|
||||
:roomCode="roomCode"
|
||||
:userList="userList"
|
||||
@leave="handleLeave"
|
||||
/>
|
||||
</el-aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<el-main>
|
||||
<MessageList
|
||||
:messages="messages"
|
||||
ref="messageList"
|
||||
/>
|
||||
|
||||
<FileDropZone
|
||||
@file-selected="handleFileSelect"
|
||||
@text-pasted="handleTextPaste"
|
||||
/>
|
||||
|
||||
<MessageInput
|
||||
v-model="inputText"
|
||||
@send="sendTextMessage"
|
||||
@paste="handlePaste"
|
||||
/>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import WebSocketService from '@/api/websocket'
|
||||
import RoomPanel from '@/components/RoomPanel.vue'
|
||||
import MessageList from '@/components/MessageList.vue'
|
||||
import MessageInput from '@/components/MessageInput.vue'
|
||||
import FileDropZone from '@/components/FileDropZone.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const roomCode = route.params.code
|
||||
|
||||
const messages = ref([])
|
||||
const userList = ref([])
|
||||
const inputText = ref('')
|
||||
const myId = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
connectWebSocket()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
WebSocketService.disconnect()
|
||||
})
|
||||
|
||||
const connectWebSocket = () => {
|
||||
WebSocketService.connect('/ws/data-transfer', () => {
|
||||
// 订阅房间消息
|
||||
WebSocketService.subscribe(`/topic/room/${roomCode}`, (msg) => {
|
||||
handleMessage(msg)
|
||||
})
|
||||
|
||||
// 加入房间
|
||||
WebSocketService.send(`/app/room/${roomCode}/join`, {
|
||||
userName: `用户${Math.random().toString(36).substr(2, 4)}`
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const handleMessage = (msg) => {
|
||||
switch (msg.type) {
|
||||
case 'SYSTEM':
|
||||
if (msg.payload.userList) {
|
||||
userList.value = msg.payload.userList
|
||||
}
|
||||
messages.value.push({
|
||||
type: 'system',
|
||||
content: msg.payload.message
|
||||
})
|
||||
break
|
||||
case 'TEXT':
|
||||
messages.value.push({
|
||||
type: msg.senderId === myId.value ? 'me' : 'other',
|
||||
sender: msg.senderName,
|
||||
content: msg.payload.content,
|
||||
time: msg.timestamp
|
||||
})
|
||||
break
|
||||
case 'FILE':
|
||||
messages.value.push({
|
||||
type: 'file',
|
||||
sender: msg.senderName,
|
||||
fileName: msg.payload.fileName,
|
||||
fileSize: msg.payload.fileSize,
|
||||
fileId: msg.payload.fileId
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const sendTextMessage = () => {
|
||||
if (!inputText.value.trim()) return
|
||||
|
||||
WebSocketService.send(`/app/room/${roomCode}/message`, {
|
||||
type: 'TEXT',
|
||||
senderId: myId.value,
|
||||
payload: {
|
||||
content: inputText.value,
|
||||
isChunk: false
|
||||
}
|
||||
})
|
||||
|
||||
inputText.value = ''
|
||||
}
|
||||
|
||||
const handleFileSelect = async (file) => {
|
||||
// 发送文件元数据
|
||||
const fileId = generateUUID()
|
||||
const chunkSize = 64 * 1024 // 64KB分片
|
||||
const totalChunks = Math.ceil(file.size / chunkSize)
|
||||
|
||||
WebSocketService.send(`/app/room/${roomCode}/message`, {
|
||||
type: 'FILE',
|
||||
senderId: myId.value,
|
||||
payload: {
|
||||
fileId,
|
||||
fileName: file.name,
|
||||
fileSize: file.size,
|
||||
mimeType: file.type,
|
||||
totalChunks
|
||||
}
|
||||
})
|
||||
|
||||
// 发送文件分片
|
||||
for (let i = 0; i < totalChunks; i++) {
|
||||
const chunk = file.slice(i * chunkSize, (i + 1) * chunkSize)
|
||||
const base64 = await fileToBase64(chunk)
|
||||
|
||||
WebSocketService.send(`/app/room/${roomCode}/file/chunk`, {
|
||||
fileId,
|
||||
chunkIndex: i,
|
||||
data: base64.split(',')[1]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleTextPaste = (text) => {
|
||||
inputText.value = text
|
||||
}
|
||||
|
||||
const handleLeave = () => {
|
||||
WebSocketService.send(`/app/room/${roomCode}/leave`, {})
|
||||
router.push('/')
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
#### 文件拖拽组件 (FileDropZone.vue)
|
||||
```vue
|
||||
<template>
|
||||
<div
|
||||
class="file-drop-zone"
|
||||
:class="{ 'drag-over': isDragOver }"
|
||||
@dragover.prevent="isDragOver = true"
|
||||
@dragleave="isDragOver = false"
|
||||
@drop.prevent="handleDrop"
|
||||
@paste="handlePaste"
|
||||
tabindex="0"
|
||||
>
|
||||
<el-icon><Upload /></el-icon>
|
||||
<span>拖拽文件到此处,或按 Ctrl+V 粘贴</span>
|
||||
<el-button @click="readClipboard" size="small" type="primary">
|
||||
读取剪贴板
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { Upload } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const emit = defineEmits(['file-selected', 'text-pasted'])
|
||||
const isDragOver = ref(false)
|
||||
|
||||
const handleDrop = (e) => {
|
||||
isDragOver.value = false
|
||||
const files = e.dataTransfer.files
|
||||
if (files.length > 0) {
|
||||
emit('file-selected', files[0])
|
||||
}
|
||||
}
|
||||
|
||||
const handlePaste = (e) => {
|
||||
const items = e.clipboardData.items
|
||||
for (let item of items) {
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile()
|
||||
emit('file-selected', file)
|
||||
} else if (item.kind === 'string' && item.type === 'text/plain') {
|
||||
item.getAsString(text => emit('text-pasted', text))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const readClipboard = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText()
|
||||
emit('text-pasted', text)
|
||||
ElMessage.success('已读取剪贴板内容')
|
||||
} catch (err) {
|
||||
ElMessage.error('无法读取剪贴板,请手动粘贴')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-drop-zone {
|
||||
border: 2px dashed #dcdfe6;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
transition: all 0.3s;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.file-drop-zone.drag-over {
|
||||
border-color: #409eff;
|
||||
background: #f0f9ff;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、部署配置
|
||||
|
||||
### 8.1 后端部署 (application.yml)
|
||||
|
||||
```yaml
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
spring:
|
||||
websocket:
|
||||
message-buffer-size: 8192
|
||||
|
||||
# 如需消息持久化,配置数据库
|
||||
datasource:
|
||||
url: jdbc:mysql://localhost:3306/data_transfer
|
||||
username: root
|
||||
password: xxx
|
||||
|
||||
# 文件传输配置
|
||||
transfer:
|
||||
chunk-size: 65536 # 64KB分片
|
||||
max-file-size: 104857600 # 100MB
|
||||
room-expire-hours: 24 # 房间过期时间
|
||||
```
|
||||
|
||||
### 8.2 前端部署 (vite.config.js)
|
||||
|
||||
```javascript
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/ws': {
|
||||
target: 'http://localhost:8080',
|
||||
ws: true,
|
||||
changeOrigin: true
|
||||
},
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
assetsDir: 'assets'
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 8.3 Nginx配置(生产环境)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
|
||||
location / {
|
||||
root /path/to/data-transfer-web/dist;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /ws {
|
||||
proxy_pass http://localhost:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location /api {
|
||||
proxy_pass http://localhost:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、安全考虑
|
||||
|
||||
### 9.1 安全措施
|
||||
|
||||
| 风险 | 解决方案 |
|
||||
|------|----------|
|
||||
| 房间号暴力破解 | 6位数字+过期机制,限制尝试频率 |
|
||||
| 大文件攻击 | 限制单文件100MB,限制房间总传输量 |
|
||||
| XSS攻击 | 消息内容HTML转义,使用textContent |
|
||||
| 数据泄露 | 房间自动过期,服务端不持久化敏感内容 |
|
||||
| WebSocket劫持 | 握手时验证Origin,使用WSS加密 |
|
||||
|
||||
### 9.2 可选增强
|
||||
- 添加房间密码
|
||||
- 端到端加密(WebCrypto API)
|
||||
- IP白名单限制
|
||||
- 操作日志审计
|
||||
|
||||
---
|
||||
|
||||
## 十、开发计划
|
||||
|
||||
### 10.1 里程碑
|
||||
|
||||
| 阶段 | 时间 | 交付物 |
|
||||
|------|------|--------|
|
||||
| Phase 1 | 3天 | 基础WebSocket连接、文本传输、房间管理 |
|
||||
| Phase 2 | 3天 | 文件传输(分片)、进度显示、拖拽上传 |
|
||||
| Phase 3 | 2天 | 剪贴板集成、图片预览、历史记录 |
|
||||
| Phase 4 | 2天 | UI优化、响应式适配、测试修复 |
|
||||
|
||||
### 10.2 依赖清单
|
||||
|
||||
**后端 (pom.xml)**
|
||||
```xml
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.webjars</groupId>
|
||||
<artifactId>sockjs-client</artifactId>
|
||||
<version>1.5.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.webjars</groupId>
|
||||
<artifactId>stomp-websocket</artifactId>
|
||||
<version>2.3.4</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
```
|
||||
|
||||
**前端 (package.json)**
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"vue": "^3.3.4",
|
||||
"vue-router": "^4.2.4",
|
||||
"pinia": "^2.1.6",
|
||||
"element-plus": "^2.3.14",
|
||||
"@stomp/stompjs": "^7.0.0",
|
||||
"sockjs-client": "^1.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^4.4.9",
|
||||
"@vitejs/plugin-vue": "^4.3.4"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十一、附录
|
||||
|
||||
### 11.1 参考文档
|
||||
- [Spring WebSocket官方文档](https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#websocket)
|
||||
- [STOMP协议规范](https://stomp.github.io/)
|
||||
- [Element Plus文档](https://element-plus.org/)
|
||||
- [Vue 3文档](https://vuejs.org/)
|
||||
|
||||
### 11.2 常见问题
|
||||
|
||||
**Q: 文件传输大小限制?**
|
||||
A: 默认100MB,可通过配置调整。超大文件建议分片后逐片发送。
|
||||
|
||||
**Q: 支持多文件同时传输吗?**
|
||||
A: 支持,每个文件有独立fileId,可并行传输。
|
||||
|
||||
**Q: 断线重连如何处理?**
|
||||
STOMP客户端内置重连机制,重连后需重新加入房间。
|
||||
|
||||
**Q: 内网环境如何部署?**
|
||||
A: 打包后部署到内网服务器,确保客户端能访问WebSocket端口。
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: v1.0
|
||||
**编写日期**: 2026-01-27
|
||||
**作者**: AI Assistant
|
||||
28
docs/01-整体架构与核心概念.md
Normal file
28
docs/01-整体架构与核心概念.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# 01 - 整体架构与核心概念
|
||||
|
||||
## 目标与范围
|
||||
- **目标**:在 VNC/远程桌面、内网隔离等场景下,提供基于浏览器的轻量数据传输能力,通过 WebSocket 实现多端实时同步。
|
||||
- **范围**:文本、文件(分片)、图片预览、剪贴板读取/粘贴、在线用户列表、历史记录(本地)。
|
||||
|
||||
## 架构概览
|
||||
```
|
||||
发送端浏览器(Vue) <——WebSocket(STOMP/SockJS)——> 服务端(Spring Boot) <——> 接收端浏览器(Vue)
|
||||
│
|
||||
└—— 同一房间号(roomCode)隔离广播
|
||||
```
|
||||
|
||||
## 核心概念
|
||||
- **房间(Room)**:数据传输隔离空间,通过 **6 位数字** `roomCode` 标识。
|
||||
- **会话(Session)**:WebSocket 连接实例,通常以 `sessionId` 识别。
|
||||
- **消息(Message)**:传输数据单元,支持 `TEXT/FILE/IMAGE/SYSTEM/CHUNK`。
|
||||
|
||||
## 关键数据流
|
||||
- **加入房间**:客户端连接成功后发送 `/app/room/{roomCode}/join` → 服务端更新房间用户 → 广播 `SYSTEM(USER_JOIN + userList)` 到 `/topic/room/{roomCode}`。
|
||||
- **发送消息**:客户端发送 `/app/room/{roomCode}/message`(TEXT/FILE/IMAGE/SYSTEM)→ 服务端补齐时间戳并广播到 `/topic/room/{roomCode}`。
|
||||
- **文件分片**:客户端发送 `/app/room/{roomCode}/file/chunk`(CHUNK)→ 服务端转发到 `/topic/room/{roomCode}/file/{fileId}`(或同房间通道内约定字段分发)。
|
||||
|
||||
## 非功能性要求(NFR)
|
||||
- **可用性**:断线自动重连;重连后需重新 join。
|
||||
- **性能**:大文本/大文件分片;限制单文件大小与分片大小;避免 UI 卡顿(增量渲染/节流)。
|
||||
- **安全**:默认不落库;房间过期;XSS 防护;可选 WSS/Origin 校验。
|
||||
|
||||
39
docs/02-房间管理(创建-加入-退出).md
Normal file
39
docs/02-房间管理(创建-加入-退出).md
Normal file
@@ -0,0 +1,39 @@
|
||||
# 02 - 房间管理(创建 / 加入 / 退出)
|
||||
|
||||
## 功能目标
|
||||
- **创建房间**:自动生成 6 位数字房间号 `roomCode`。
|
||||
- **加入房间**:输入房间号加入;服务端维护在线用户列表。
|
||||
- **退出房间**:主动退出或断线退出;房间空则销毁或等待过期。
|
||||
|
||||
## 前端(Vue)
|
||||
- **页面/组件**
|
||||
- `HomeView.vue`:创建房间、加入房间(输入 `roomCode`)。
|
||||
- `RoomView.vue`:展示房间信息与离开操作入口。
|
||||
- `RoomPanel.vue`:显示房间号、在线用户、退出按钮、快捷操作(清空/导出等)。
|
||||
- **交互细节**
|
||||
- 加入房间前校验 `roomCode` 为 6 位数字。
|
||||
- join/leave 成功后,在消息区插入系统提示(“xxx加入/离开”)。
|
||||
|
||||
## 后端(Spring Boot)
|
||||
- **核心服务:`RoomService`**
|
||||
- `createRoom()`:生成 `roomCode` 并创建房间。
|
||||
- `joinRoom(roomCode, sessionId, userName)`:绑定用户到房间。
|
||||
- `leaveRoom(roomCode, sessionId)`:移除用户;若空房间则移除。
|
||||
- (可选)`expireRoom()`:定时清理过期房间(按 `transfer.room-expire-hours`)。
|
||||
- **消息入口(STOMP)**
|
||||
- `/app/room/{roomCode}/join`:加入房间
|
||||
- `/app/room/{roomCode}/leave`:离开房间
|
||||
|
||||
## 协议与数据
|
||||
- **JoinRequest**
|
||||
- `userName`:用户昵称(前端生成或用户输入)
|
||||
- **系统消息(SYSTEM)**
|
||||
- `payload.event`:`USER_JOIN | USER_LEAVE | ERROR`
|
||||
- `payload.message`:提示文本
|
||||
- `payload.userList`:在线用户列表(数组)
|
||||
|
||||
## 边界与注意点
|
||||
- **房间是否需要“先创建后加入”**:文档示例允许 `computeIfAbsent`,即加入时若不存在会自动创建(实现上需确定产品规则)。
|
||||
- **断线离开**:建议监听会话断开事件,同步触发离房逻辑并广播 `USER_LEAVE`。
|
||||
- **防暴力猜测**:限制 join 尝试频率(可选增强)。
|
||||
|
||||
49
docs/03-WebSocket连接管理(连接-订阅-心跳-重连).md
Normal file
49
docs/03-WebSocket连接管理(连接-订阅-心跳-重连).md
Normal file
@@ -0,0 +1,49 @@
|
||||
# 03 - WebSocket连接管理(连接 / 订阅 / 心跳 / 重连)
|
||||
|
||||
## 功能目标
|
||||
- 建立浏览器到服务端的 WebSocket 连接(SockJS + STOMP)。
|
||||
- 订阅房间广播通道,实时接收消息。
|
||||
- 具备心跳保活与自动重连;UI 显示连接状态。
|
||||
|
||||
## 前端(Vue)
|
||||
- **封装层**:`src/api/websocket.js`
|
||||
- `connect(url, onConnect, onError)`:创建 STOMP Client,启用 `reconnectDelay` 与心跳参数。
|
||||
- `subscribe(destination, callback)`:按 destination 保存订阅,便于统一退订。
|
||||
- `send(destination, body)`:publish JSON 消息。
|
||||
- `disconnect()`:取消订阅并关闭连接。
|
||||
- **连接时序(建议)**
|
||||
1. connect 成功
|
||||
2. subscribe `/topic/room/{roomCode}`
|
||||
3. send `/app/room/{roomCode}/join`
|
||||
4. 开始收发消息
|
||||
- **连接状态 UI**
|
||||
- 显示:连接中 / 已连接 / 断开 / 重连中
|
||||
- 重连成功后:自动重新 subscribe + 重新 join
|
||||
|
||||
## 后端(Spring Boot)
|
||||
- **WebSocket 配置**
|
||||
- 端点:`/ws/data-transfer`
|
||||
- broker:`/topic`
|
||||
- 应用前缀:`/app`
|
||||
- SockJS:`.withSockJS()`
|
||||
- **跨域与握手**
|
||||
- `setAllowedOriginPatterns("*")`(开发期)
|
||||
- 生产期建议:限制 Origin + 启用 WSS
|
||||
- **心跳**
|
||||
- 与前端 STOMP 心跳保持一致(如需更严格,改用外部 broker 或自定义心跳策略)
|
||||
|
||||
## 协议与通道
|
||||
- 订阅(接收)
|
||||
- `/topic/room/{roomCode}`:房间内通用消息广播
|
||||
- `/topic/room/{roomCode}/file/{fileId}`:某文件分片通道(可选设计)
|
||||
- 发送(服务端处理)
|
||||
- `/app/room/{roomCode}/join`
|
||||
- `/app/room/{roomCode}/leave`
|
||||
- `/app/room/{roomCode}/message`
|
||||
- `/app/room/{roomCode}/file/chunk`
|
||||
|
||||
## 边界与注意点
|
||||
- **重连后的状态恢复**:需要重新 join 才能恢复在线用户列表与系统消息一致性。
|
||||
- **订阅泄漏**:房间切换、页面卸载必须 disconnect/退订,避免重复订阅造成重复消息。
|
||||
- **代理/反代**:生产 Nginx 需支持 Upgrade,`/ws` 走 ws 反代。
|
||||
|
||||
97
docs/04-消息协议与消息分发(统一Message模型).md
Normal file
97
docs/04-消息协议与消息分发(统一Message模型).md
Normal file
@@ -0,0 +1,97 @@
|
||||
# 04 - 消息协议与消息分发(统一 Message 模型)
|
||||
|
||||
## 功能目标
|
||||
- 定义统一消息格式(便于扩展与兼容)。
|
||||
- 服务端负责房间维度的消息广播/转发。
|
||||
- 前端按 `type` 分发渲染与业务处理。
|
||||
|
||||
## 协议定义
|
||||
### 基础消息格式
|
||||
```json
|
||||
{
|
||||
"type": "TEXT | FILE | IMAGE | SYSTEM | CHUNK",
|
||||
"senderId": "uuid",
|
||||
"senderName": "用户昵称",
|
||||
"timestamp": 1706345600000,
|
||||
"payload": {}
|
||||
}
|
||||
```
|
||||
|
||||
### 文本消息(TEXT)
|
||||
```json
|
||||
{
|
||||
"type": "TEXT",
|
||||
"senderId": "uuid",
|
||||
"senderName": "用户A",
|
||||
"timestamp": 1706345600000,
|
||||
"payload": {
|
||||
"content": "要传输的文本内容",
|
||||
"isChunk": false,
|
||||
"chunkIndex": 0,
|
||||
"totalChunks": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 文件元数据(FILE)
|
||||
```json
|
||||
{
|
||||
"type": "FILE",
|
||||
"senderId": "uuid",
|
||||
"senderName": "用户A",
|
||||
"timestamp": 1706345600000,
|
||||
"payload": {
|
||||
"fileId": "uuid",
|
||||
"fileName": "document.pdf",
|
||||
"fileSize": 2621440,
|
||||
"mimeType": "application/pdf",
|
||||
"totalChunks": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 文件分片(CHUNK)
|
||||
```json
|
||||
{
|
||||
"type": "CHUNK",
|
||||
"senderId": "uuid",
|
||||
"payload": {
|
||||
"fileId": "uuid",
|
||||
"chunkIndex": 0,
|
||||
"data": "base64EncodedChunkData"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 系统消息(SYSTEM)
|
||||
```json
|
||||
{
|
||||
"type": "SYSTEM",
|
||||
"payload": {
|
||||
"event": "USER_JOIN | USER_LEAVE | ERROR",
|
||||
"message": "xxx 加入了房间",
|
||||
"userList": [{"id": "uuid", "name": "用户A"}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 前端分发(Vue)
|
||||
- **入口**:`RoomView` 的 `handleMessage(msg)` 或等价逻辑。
|
||||
- **分发规则(示例)**
|
||||
- `SYSTEM`:更新 `userList`,并向消息列表追加系统提示。
|
||||
- `TEXT`:追加文本消息(区分 me/other)。
|
||||
- `FILE`:追加文件卡片(显示名称、大小、下载按钮、进度)。
|
||||
- `CHUNK`:写入文件缓存并更新进度;完成后合并为 Blob。
|
||||
|
||||
## 后端分发(Spring Boot)
|
||||
- **控制器**:`WebSocketController`
|
||||
- `/message`:广播常规消息到 `/topic/room/{roomCode}`
|
||||
- `/file/chunk`:转发分片到文件通道或同房间通道
|
||||
- **补齐与校验(建议)**
|
||||
- 服务端补齐 `timestamp`,必要时校验 `type/payload` 的字段完整性、大小限制与频率限制。
|
||||
|
||||
## 边界与注意点
|
||||
- **通道设计一致性**:若采用“文件独立 topic”,前端需额外 subscribe;若统一走房间 topic,需在 payload 内携带 fileId 并做路由分发。
|
||||
- **大消息**:避免一次性发送超大 payload;统一走分片策略。
|
||||
- **安全**:前端渲染文本必须转义,禁止把用户内容当 HTML 渲染。
|
||||
|
||||
36
docs/05-文本传输(含大文本分片).md
Normal file
36
docs/05-文本传输(含大文本分片).md
Normal file
@@ -0,0 +1,36 @@
|
||||
# 05 - 文本传输(含大文本分片)
|
||||
|
||||
## 功能目标
|
||||
- 支持在房间内发送/接收文本消息。
|
||||
- 支持大文本自动分片(避免单包过大导致失败或卡顿)。
|
||||
|
||||
## 前端(Vue)
|
||||
- **输入与发送**
|
||||
- `MessageInput.vue`:输入框 + 发送按钮;支持 Ctrl+V 粘贴填充。
|
||||
- 发送前校验:非空、长度上限(建议)。
|
||||
- **大文本分片(建议实现方式)**
|
||||
- 当文本长度超过阈值(如 8KB/32KB)时:
|
||||
- 生成 `messageId`(可复用 `senderId + timestamp` 或 UUID)
|
||||
- 拆分为 `totalChunks` 段
|
||||
- 逐段发送 `TEXT`,携带 `isChunk=true/chunkIndex/totalChunks`,并在 payload 中携带 `messageId`
|
||||
- 接收端按 `messageId` 缓存分片并重组,重组完成后再写入消息列表。
|
||||
- **展示与防护**
|
||||
- 文本内容按纯文本展示(转义),禁止 `v-html`。
|
||||
|
||||
## 后端(Spring Boot)
|
||||
- **处理策略**
|
||||
- 默认透传广播即可:在 `/app/room/{roomCode}/message` 收到后补齐 `timestamp` 并广播。
|
||||
- (可选)限流/长度限制:防刷屏与恶意超大文本。
|
||||
|
||||
## 协议与数据
|
||||
- `type=TEXT`
|
||||
- `payload` 推荐字段:
|
||||
- `content`:文本内容(单片)
|
||||
- `isChunk`:是否为分片
|
||||
- `chunkIndex/totalChunks`:分片序号与总片数
|
||||
- `messageId`:(建议新增)用于重组与去重
|
||||
|
||||
## 边界与注意点
|
||||
- **重组内存**:前端缓存分片需设定超时清理,避免长期占用内存。
|
||||
- **乱序/丢片**:WebSocket 一般有序但重连/异常情况下仍可能丢失;可以在 UI 上提示“分片未完整接收”。
|
||||
|
||||
42
docs/06-文件传输(拖拽-分片-进度-下载).md
Normal file
42
docs/06-文件传输(拖拽-分片-进度-下载).md
Normal file
@@ -0,0 +1,42 @@
|
||||
# 06 - 文件传输(拖拽 / 分片 / 进度 / 下载)
|
||||
|
||||
## 功能目标
|
||||
- 支持拖拽上传、粘贴文件、发送文件元数据与分片内容。
|
||||
- 接收端显示进度并可下载(本地重组 Blob)。
|
||||
|
||||
## 前端(Vue)
|
||||
- **入口组件**
|
||||
- `FileDropZone.vue`:拖拽、粘贴;触发 `file-selected` 事件。
|
||||
- **分片与发送**
|
||||
- `fileChunker.js`:按 `chunkSize` 切片(文档示例:64KB)。
|
||||
- 发送流程:
|
||||
1. 生成 `fileId`
|
||||
2. 发送 `FILE` 元数据到 `/app/room/{roomCode}/message`
|
||||
3. 循环发送 `CHUNK` 到 `/app/room/{roomCode}/file/chunk`
|
||||
- **进度显示(建议)**
|
||||
- 发送端:`sentChunks / totalChunks`
|
||||
- 接收端:`receivedChunks / totalChunks`
|
||||
- 通过消息列表中的文件卡片展示(进度条 + 状态:接收中/完成/失败)。
|
||||
- **接收与下载(建议实现方式)**
|
||||
- 建立 `fileId -> { meta, chunks[] }` 缓存
|
||||
- 收到所有分片后,按顺序拼接为 `Uint8Array`/Blob
|
||||
- 使用 `URL.createObjectURL(blob)` 生成下载链接
|
||||
|
||||
## 后端(Spring Boot)
|
||||
- **转发策略**
|
||||
- 元数据:作为 `FILE` 消息广播到房间 topic。
|
||||
- 分片:作为 `CHUNK` 消息转发(可用独立 topic 或统一 topic)。
|
||||
- **限制与风控(建议)**
|
||||
- 单文件最大值(默认 100MB,可配置)。
|
||||
- 分片大小(默认 64KB,可配置)。
|
||||
- 频率限制(防止刷分片)。
|
||||
|
||||
## 协议与数据
|
||||
- `FILE.payload`:`fileId/fileName/fileSize/mimeType/totalChunks`
|
||||
- `CHUNK.payload`:`fileId/chunkIndex/data(base64)`
|
||||
|
||||
## 边界与注意点
|
||||
- **Base64 膨胀**:Base64 会增加体积(约 33%),大文件会更慢;如需优化可改二进制 WebSocket(后续增强)。
|
||||
- **并行多文件**:以 `fileId` 隔离缓存即可并行;UI 需支持多文件卡片。
|
||||
- **断线续传**:当前协议未定义续传(可选增强:chunk ack / 断点续传)。
|
||||
|
||||
29
docs/07-图片预览(Base64内联显示).md
Normal file
29
docs/07-图片预览(Base64内联显示).md
Normal file
@@ -0,0 +1,29 @@
|
||||
# 07 - 图片预览(Base64 内联显示)
|
||||
|
||||
## 功能目标
|
||||
- 在消息列表中对图片进行缩略图预览与放大查看。
|
||||
- 支持通过“文件传输通道”或“图片专用消息”传输图片数据。
|
||||
|
||||
## 前端(Vue)
|
||||
- **识别规则**
|
||||
- `mimeType` 满足 `image/*` 时按图片渲染。
|
||||
- **展示形态(建议)**
|
||||
- 消息卡片中显示缩略图(限制最大宽高)。
|
||||
- 点击后弹窗预览(Element Plus `el-dialog`/`el-image` 预览)。
|
||||
- **传输策略(两种可选)**
|
||||
- **复用文件分片**(推荐一致性):图片走 `FILE + CHUNK`,接收端完成后生成 Blob 并预览。
|
||||
- **小图直发**:当图片小于阈值(如 200KB)时发送 `IMAGE`,payload 直接携带 base64(需限制大小)。
|
||||
|
||||
## 后端(Spring Boot)
|
||||
- 默认透传(与 FILE/CHUNK 一致);在生产环境建议增加大小限制与频率限制。
|
||||
|
||||
## 协议与数据(建议)
|
||||
- 若使用 `IMAGE`:
|
||||
- `payload.data`:base64(不带 dataURL 前缀或带前缀需约定)
|
||||
- `payload.mimeType`:如 `image/png`
|
||||
- `payload.fileName`:(可选)
|
||||
|
||||
## 边界与注意点
|
||||
- **性能**:图片 base64 可能导致消息体很大,优先走分片。
|
||||
- **安全**:只当作图片二进制展示,不执行任何脚本;避免把 payload 当 HTML 渲染。
|
||||
|
||||
26
docs/08-剪贴板集成(读取-粘贴-降级).md
Normal file
26
docs/08-剪贴板集成(读取-粘贴-降级).md
Normal file
@@ -0,0 +1,26 @@
|
||||
# 08 - 剪贴板集成(读取 / 粘贴 / 降级)
|
||||
|
||||
## 功能目标
|
||||
- 支持 Ctrl+V 粘贴文本/文件到传输区域。
|
||||
- 支持按钮主动读取系统剪贴板文本(在权限允许的情况下)。
|
||||
- 在权限受限或浏览器不支持时提供清晰降级提示。
|
||||
|
||||
## 前端(Vue)
|
||||
- **粘贴事件处理**
|
||||
- 监听 `paste`,遍历 `e.clipboardData.items`:
|
||||
- `kind=file`:提取 File,走文件传输流程
|
||||
- `kind=string && type=text/plain`:提取文本,填充输入框或直接发送
|
||||
- **主动读取剪贴板**
|
||||
- `navigator.clipboard.readText()`:读取文本并 emit `text-pasted`
|
||||
- 失败提示:“无法读取剪贴板,请手动粘贴”(常见于非 HTTPS、权限未授予、浏览器策略限制)
|
||||
- **组件建议**
|
||||
- `FileDropZone.vue`:承载粘贴与读取按钮
|
||||
- `utils/clipboard.js`:封装权限判断与异常处理(便于复用)
|
||||
|
||||
## 后端(Spring Boot)
|
||||
- 无需专门接口支持;剪贴板只影响前端如何生成 `TEXT/FILE` 消息。
|
||||
|
||||
## 边界与注意点
|
||||
- **HTTPS/权限**:`navigator.clipboard` 通常要求安全上下文(HTTPS/localhost)与用户手势。
|
||||
- **可用性**:粘贴是最稳妥的降级方式,读取按钮只是增强能力。
|
||||
|
||||
30
docs/09-在线用户列表(房间成员与系统消息).md
Normal file
30
docs/09-在线用户列表(房间成员与系统消息).md
Normal file
@@ -0,0 +1,30 @@
|
||||
# 09 - 在线用户列表(房间成员与系统消息)
|
||||
|
||||
## 功能目标
|
||||
- 在房间侧边栏展示在线用户列表。
|
||||
- 用户加入/离开时,实时更新列表并显示系统提示。
|
||||
|
||||
## 前端(Vue)
|
||||
- **组件**
|
||||
- `UserList.vue`:渲染 `userList`(在线/离线样式可选)。
|
||||
- `RoomPanel.vue`:承载房间号与用户列表。
|
||||
- **更新机制**
|
||||
- 订阅房间 topic 后,收到 `SYSTEM` 且携带 `payload.userList` 时更新 `userList`。
|
||||
- 在消息区插入 `payload.message` 作为系统提示。
|
||||
|
||||
## 后端(Spring Boot)
|
||||
- **RoomService**
|
||||
- 存储结构(示例):`roomCode -> { sessionId -> userName }`
|
||||
- `getUserList(roomCode)`:返回数组(包含 id/name)。
|
||||
- **广播时机**
|
||||
- join 成功后广播 `SYSTEM(USER_JOIN + userList)`
|
||||
- leave 成功后广播 `SYSTEM(USER_LEAVE + userList)`
|
||||
|
||||
## 协议与数据
|
||||
- `SYSTEM.payload.userList`:
|
||||
- `[{ "id": "uuid/sessionId", "name": "用户A" }, ...]`
|
||||
|
||||
## 边界与注意点
|
||||
- **用户身份**:示例使用 `sessionId` 当 id;如需“跨重连保持身份”,需引入客户端生成的 stableId(可选增强)。
|
||||
- **断线处理**:需要在服务端捕获 disconnect 事件,触发 leave 并广播列表更新。
|
||||
|
||||
26
docs/10-历史记录(本地存储-清空-导出).md
Normal file
26
docs/10-历史记录(本地存储-清空-导出).md
Normal file
@@ -0,0 +1,26 @@
|
||||
# 10 - 历史记录(本地存储 / 清空 / 导出)
|
||||
|
||||
## 功能目标
|
||||
- 保存消息历史(默认本地存储,不依赖服务端)。
|
||||
- 支持清空当前房间记录、导出记录(便于审计或留存)。
|
||||
|
||||
## 前端(Vue)
|
||||
- **本地存储策略(建议)**
|
||||
- 按 `roomCode` 分桶保存:`history:{roomCode}`。
|
||||
- 存储介质:
|
||||
- 少量文本:`localStorage`
|
||||
- 含文件/图片元数据与大量消息:建议 `IndexedDB`(可选)
|
||||
- 存储内容建议只存元数据与文本,不存大块二进制(避免爆仓)。
|
||||
- **功能入口**
|
||||
- `RoomPanel.vue`:提供“清空”“导出”按钮。
|
||||
- **导出格式(建议)**
|
||||
- JSON:包含 `type/sender/timestamp/payload摘要`
|
||||
- 或文本:便于粘贴到工单/邮件
|
||||
|
||||
## 后端(Spring Boot)
|
||||
- 默认无需支持;若开启服务端持久化则由后端提供查询/导出(可选增强)。
|
||||
|
||||
## 边界与注意点
|
||||
- **隐私**:本地存储可能包含敏感信息;需明确提示用户可手动清空。
|
||||
- **容量**:浏览器存储空间有限;建议设定最大条数/最大时间窗与自动淘汰策略。
|
||||
|
||||
299
docs/11-UI设计规范与交互原则.md
Normal file
299
docs/11-UI设计规范与交互原则.md
Normal file
@@ -0,0 +1,299 @@
|
||||
# 15 - UI 设计规范与交互原则(DataTool)
|
||||
|
||||
> 本文作为 DataTool 项目前端的统一 UI 设计规范与交互基线,后续所有功能开发应在不违背本规范的前提下扩展。若有必要变更,请同步更新本文件。
|
||||
|
||||
---
|
||||
|
||||
## 1. 产品定位与使用场景
|
||||
|
||||
- **产品类型**:轻量级、基于浏览器的数据传输工具
|
||||
- **核心场景**:在 VNC / 远程桌面 / 内网隔离等环境中,通过 6 位房间号在多端之间快速传文本、文件、图片和剪贴板内容
|
||||
- **受众角色**:开发 / 运维 / 支持工程师、内网用户
|
||||
- **体验方向**:**稳定、可靠、低干扰、专业、轻量**
|
||||
|
||||
---
|
||||
|
||||
## 2. 整体设计原则
|
||||
|
||||
- **一致性优先**
|
||||
- 所有页面沿用统一的色板、字体、圆角与阴影体系。
|
||||
- 所有按钮、输入框、消息卡片、文件卡片必须复用同一套组件样式,不允许在局部自行定义“新样式”。
|
||||
- **信息层次清晰**
|
||||
- 强调房间号与连接状态,其次是消息内容与文件进度,最后是系统提示和辅助信息。
|
||||
- 使用颜色 / 字号 / 粗细 / 间距来区分重要程度,避免靠纯颜色或纯位置。
|
||||
- **状态可感知**
|
||||
- WebSocket 连接状态、房间加入/退出、文件发送与接收进度必须在 UI 中可见且明确。
|
||||
- 所有异步操作(发送消息、上传文件、读取剪贴板等)都有“进行中 / 成功 / 失败”的视觉反馈。
|
||||
- **轻量与性能**
|
||||
- 大量消息场景下保持滚动流畅,避免复杂阴影和高成本动画。
|
||||
- 组件优先考虑简单、可扩展的布局,预留虚拟列表等性能优化空间。
|
||||
- **可访问与可维护**
|
||||
- 遵守基本对比度、可聚焦与键盘可操作性。
|
||||
- 使用统一的设计 token(颜色/间距/字号),便于全局调整。
|
||||
|
||||
---
|
||||
|
||||
## 3. 视觉设计规范
|
||||
|
||||
### 3.1 色彩体系(建议映射到 CSS 变量 / Tailwind token)
|
||||
|
||||
- **品牌主色(Primary)**
|
||||
- 基准色:`#2563EB`(蓝,类似 Tailwind `blue-600`)
|
||||
- Hover:`#1D4ED8`(`blue-700`)
|
||||
- Active:`#1E40AF`(`blue-800`)
|
||||
- 主要用于:主按钮、主操作高亮、房间号强调、连接状态“已连接”标识。
|
||||
- **强调色(Success / Danger / Warning)**
|
||||
- Success:`#16A34A`(`green-600`)—— 文件传输完成、连接成功、操作成功提示。
|
||||
- Danger:`#DC2626`(`red-600`)—— 错误、连接失败、限制触发。
|
||||
- Warning:`#F97316`(`orange-500`)—— 限制接近阈值、网络不稳定等。
|
||||
- **中性色(背景 / 边框 / 文本)**
|
||||
- 页面背景:`#F8FAFC`(`slate-50`)
|
||||
- 卡片背景:`#FFFFFF`
|
||||
- 边框:`#E2E8F0`(`slate-200`)
|
||||
- 分割线:`#E5E7EB`(`gray-200`)
|
||||
- 主文本:`#0F172A`(`slate-900`)
|
||||
- 次级文本:`#475569`(`slate-600`)
|
||||
- 弱化文本/占位:`#9CA3AF`(`gray-400`)及以上,不使用更浅灰值作为正文。
|
||||
- **系统消息与提示色**
|
||||
- 系统消息背景:`#EFF6FF`(`blue-50`),文字使用 `#1D4ED8`。
|
||||
- 提示条(Banner):背景 `#F1F5F9`(`slate-100`),根据状态加左侧色条(蓝/绿/橙/红)。
|
||||
|
||||
> 所有新页面不得新增“第二主色”,若确有需要,先在本文件补充“辅助色(Secondary)”章节再使用。
|
||||
|
||||
### 3.2 字体与排版
|
||||
|
||||
- **字体族**
|
||||
- 优先:`system-ui, -apple-system, BlinkMacSystemFont, "SF Pro SC", "PingFang SC", "Microsoft YaHei", sans-serif`
|
||||
- **字号层级(桌面端基线)**
|
||||
- 标题 H1:24px / 32px,用于页面主标题(如“房间 123456”)
|
||||
- 标题 H2:20px / 28px,用于分区标题(在线用户、消息区、文件传输)
|
||||
- 标题 H3:16px / 24px,用于小模块标题、卡片标题
|
||||
- 正文:14px / 22px(首选),行高 1.6
|
||||
- 标签/次要信息:12px / 18px,谨慎使用,保证可读性
|
||||
- **文字对齐**
|
||||
- 文本与输入框左对齐,数字型信息(如进度百分比)可以右对齐。
|
||||
- **行宽与段落**
|
||||
- 文本区域最大宽度建议控制在 65–75 个汉字以内,避免极长单行。
|
||||
|
||||
### 3.3 圆角、阴影与描边
|
||||
|
||||
- **圆角**
|
||||
- 输入框/按钮:`4px`
|
||||
- 卡片(消息卡片、文件卡片):`8px`
|
||||
- 悬浮面板 / 弹窗:`12px`
|
||||
- **阴影**
|
||||
- 默认卡片:无阴影,仅边框(`border-slate-200`)
|
||||
- 悬浮/高层级元素(如弹窗、悬浮面板):轻量阴影 `0 10px 25px rgba(15,23,42,0.08)`
|
||||
- **描边**
|
||||
- 主按钮:默认无边框,仅用色块;Hover 时可增加内阴影或细微深色。
|
||||
- 输入框:有 1px 描边,聚焦时描边色切换为主色并增加轻微光晕。
|
||||
|
||||
### 3.4 间距与栅格
|
||||
|
||||
- 页面左右内边距:桌面端 `24px`,窄屏端 `16px`
|
||||
- 区块间距(模块与模块之间):`24px`
|
||||
- 元素垂直间距(标题与内容 / 行与行):`8–12px`
|
||||
- 列表项内边距:上下 `8px` / 左右 `12px`
|
||||
- 使用 4 的倍数作为统一间距刻度:`4 / 8 / 12 / 16 / 24 / 32`
|
||||
|
||||
---
|
||||
|
||||
## 4. 布局与信息架构
|
||||
|
||||
### 4.1 整体布局结构
|
||||
|
||||
- **桌面端(≥ 1024px)**
|
||||
- 顶部:窄高度顶部栏,包含项目 Logo/名称、当前房间号、连接状态指示、小范围操作(帮助、设置)。
|
||||
- 主区:左右双栏布局。
|
||||
- 左侧(约 25–30% 宽度):`RoomPanel`,包含房间信息、在线用户列表、基础操作(退出、清空、导出)。
|
||||
- 右侧(约 70–75% 宽度):消息与文件显示区域(消息列表 + 文件/图片预览),底部为输入与文件操作区域。
|
||||
- **窄屏端(< 1024px)**
|
||||
- 顶部栏保留,主内容上下布局:
|
||||
- 默认展示消息区域,在线用户通过折叠面板或底部抽屉查看。
|
||||
- 拖拽上传在窄屏上使用明显的上传按钮 + 粘贴提示,拖拽仅作为增强能力。
|
||||
|
||||
### 4.2 关键页面说明
|
||||
|
||||
- **首页 `HomeView`**
|
||||
- 中心内容区居中显示:
|
||||
- 左:创建房间卡片(展示自动生成 6 位房间号 + “创建并进入”按钮)
|
||||
- 右:加入房间卡片(房间号输入框 + 加入按钮)
|
||||
- 明确提示“数据不落库 / 仅当前会话可见”等安全特性(简短描述)。
|
||||
- 若已有最近加入的房间记录,可在下方列出“最近使用的房间”列表(历史记录相关)。
|
||||
- **房间页 `RoomView`**
|
||||
- 顶部显示:
|
||||
- 房间号(可点击复制)+ 小标签(如“临时房间”)
|
||||
- 连接状态:圆点 + 文本(连接中/已连接/断开/重连中)
|
||||
- 左侧 `RoomPanel`:
|
||||
- 在线用户列表(昵称 + 状态点),当前用户行高亮。
|
||||
- 简要统计信息:在线人数、当前传输中文件数量等。
|
||||
- 操作按钮:退出房间(弱化但明确)、清空本地历史、导出历史。
|
||||
- 右侧主区:
|
||||
- 上方:消息与系统提示列表(含文本、图片、文件卡片、SYSTEM 消息)。
|
||||
- 下方:输入区域(文本输入 + 发送按钮 + 附件/文件按钮 + 剪贴板按钮)。
|
||||
|
||||
### 4.3 消息列表布局
|
||||
|
||||
- **文本消息**
|
||||
- 根据发送者区分左右对齐(可选),也可统一左对齐,通过头像首字母+昵称区分。
|
||||
- 时间戳放置在右下角,使用次级文字色。
|
||||
- **系统消息**
|
||||
- 居中对齐,使用浅色条带样式,与普通消息明显区分。
|
||||
- 内容如“xxx 加入房间 / 离开房间 / 连接已重连”等。
|
||||
- **文件与图片消息**
|
||||
- 使用卡片样式,包含文件名、大小、进度条、状态图标以及操作按钮(下载/打开)。
|
||||
|
||||
---
|
||||
|
||||
## 5. 核心组件规范
|
||||
|
||||
> 实现时建议将以下元素封装为 Vue 组件,并在使用前优先复用。
|
||||
|
||||
### 5.1 按钮(`Button`)
|
||||
|
||||
- **尺寸**
|
||||
- 大:高度 40–44px(主要操作,如“创建房间”“加入房间”“发送”)
|
||||
- 中:高度 32–36px(列表操作、弹窗确认等)
|
||||
- 小:高度 28–32px(标签型按钮、图标按钮)
|
||||
- **类型**
|
||||
- 主按钮(Primary):填充主色,白色文字,圆角 4px。
|
||||
- 次按钮(Secondary):白底 + 主色描边 + 主色文字。
|
||||
- 危险按钮(Danger):红色填充 + 白色文字,仅用于删除/退出等操作。
|
||||
- 文本按钮(Ghost/Text):透明背景 + 主色文字,用于低权重操作。
|
||||
- **状态**
|
||||
- 默认 / Hover / Active / Disabled 四态。
|
||||
- Hover:亮度 + 边框/阴影轻微变化,不允许大幅缩放导致布局抖动。
|
||||
- Disabled:降低对比度并移除悬浮态,鼠标指针改为默认。
|
||||
|
||||
### 5.2 输入与表单(`Input`, `Textarea`, `FormField`)
|
||||
|
||||
- **样式**
|
||||
- 高度 36–40px,左右有 12–16px 内边距。
|
||||
- 边框颜色:默认 `#E2E8F0`,聚焦 `primary`。
|
||||
- 占位文字使用 `gray-400`,不可与正常正文颜色相同。
|
||||
- **校验**
|
||||
- 房间号输入框限制为 6 位数字,错误时在输入框下方展示红色错误文案,并将边框颜色切换为 `red-500`。
|
||||
- 提交按钮在校验不通过时为禁用状态。
|
||||
|
||||
### 5.3 标签与状态(`Tag`, `Badge`, `StatusDot`)
|
||||
|
||||
- 用于表示连接状态、房间类型、文件状态等。
|
||||
- 状态点尺寸 8–10px,放置在用户名左侧或右上角。
|
||||
- 颜色与状态映射保持统一(如:绿色=在线,灰色=离线,橙色=异常)。
|
||||
|
||||
### 5.4 消息气泡与系统消息(`MessageItem`, `SystemMessage`)
|
||||
|
||||
- 消息气泡:
|
||||
- 背景使用白色或略带浅灰,边框微弱(可选)。
|
||||
- 内部包含:昵称(可选)/ 消息内容 / 时间戳。
|
||||
- 系统消息:
|
||||
- 使用全宽度条带,背景为 `blue-50` 或 `slate-100`,文字居中。
|
||||
- 与气泡在视觉上区分开(不使用“对话气泡”样式)。
|
||||
|
||||
### 5.5 文件卡片与进度条(`FileMessage`, `ProgressBar`)
|
||||
|
||||
- **文件卡片内容**
|
||||
- 文件名(可省略中间,用省略号显示)+ 文件大小 + 文件类型图标。
|
||||
- 进度条(发送端、接收端皆可见)+ 状态标签(传输中 / 已完成 / 失败)。
|
||||
- 操作按钮:下载、打开所在文件夹(浏览器可行范围内)、重新下载(失败时)。
|
||||
- **进度条样式**
|
||||
- 高度 4–6px,圆角 999px。
|
||||
- 背景条颜色 `slate-200`,前景条使用主色或状态色。
|
||||
|
||||
### 5.6 图片预览卡片(`ImageMessage`, `ImagePreviewModal`)
|
||||
|
||||
- 消息列表中展示缩略图(固定宽高比,如 4:3),点击后打开大图预览。
|
||||
- 大图使用居中遮罩弹窗,背景半透明黑色,支持点击空白处关闭或 Esc 关闭。
|
||||
|
||||
### 5.7 Toast 与通知(`Toast`, `AlertBanner`)
|
||||
|
||||
- Toast 适合短暂状态提示(发送成功、复制成功),出现在右上角或右下角,自动消失。
|
||||
- AlertBanner 适合持久的提醒(当前使用 HTTP 协议、传输大小接近上限等),固定在内容区顶部。
|
||||
|
||||
---
|
||||
|
||||
## 6. 关键交互流程规范
|
||||
|
||||
### 6.1 加入 / 创建房间
|
||||
|
||||
- **创建房间**
|
||||
- 点击“创建房间”后立即显示加载状态(按钮禁用 + Loading 图标)。
|
||||
- 创建成功后自动跳转到房间页,并以系统消息提示“你已创建并进入房间 123456”。
|
||||
- **加入房间**
|
||||
- 用户输入 6 位房间号,实时校验;不足 6 位或包含非数字字符时按钮禁用。
|
||||
- 加入失败(房间不存在/连接错误)时:
|
||||
- 在输入框下方显示清晰错误文案。
|
||||
- 可用 Toast 或 Banner 补充说明。
|
||||
|
||||
### 6.2 WebSocket 连接与状态恢复
|
||||
|
||||
- 顶部栏右上区域固定显示连接状态:
|
||||
- 连接中:黄色点 + “连接中…”
|
||||
- 已连接:绿色点 + “已连接”
|
||||
- 重连中:橙色点 + “重连中…”
|
||||
- 已断开:红色点 + “已断开”
|
||||
- 当状态从“断开/重连中”恢复到“已连接”时:
|
||||
- 插入一条 SYSTEM 消息说明“已重新连接,已重新加入房间”。
|
||||
|
||||
### 6.3 消息发送与错误处理
|
||||
|
||||
- 按下 Enter 发送消息,Shift+Enter 换行(在文本框旁明确提示)。
|
||||
- 发送过程中,发送按钮进入 Loading 状态并禁用,避免重复点击。
|
||||
- 若发送失败(网络中断等):
|
||||
- 消息在列表中使用“失败”样式(如左侧红色竖线 + 灰度文字)。
|
||||
- 提供“重试发送”小按钮。
|
||||
|
||||
### 6.4 文件拖拽上传 / 粘贴
|
||||
|
||||
- **拖拽区域**
|
||||
- 在输入区域上方或消息列表顶部提供明显的拖拽提示区域(浅虚线边框 + 上传图标)。
|
||||
- 拖拽文件进入页面时,高亮整个拖拽区域,并在靠顶部位置显示提示条(如“释放鼠标以发送文件到房间 123456”)。
|
||||
- **粘贴文件 / 剪贴板**
|
||||
- 当用户使用 Ctrl+V 粘贴文件或图片时,提示一次确认(可选):“是否将剪贴板中的图片/文件发送到当前房间?”。
|
||||
- 剪贴板权限被浏览器限制时,弹出说明性对话框,引导用户手动拖拽或选择文件。
|
||||
|
||||
### 6.5 历史记录与本地存储
|
||||
|
||||
- 在房间页的 `RoomPanel` 中提供“历史记录”入口,可选择:
|
||||
- 清空当前房间历史(弹出确认对话框)。
|
||||
- 导出当前房间历史(文件命名建议:`DataTool-房间号-时间戳.json`)。
|
||||
- 刷新页面后自动加载当前房间的本地历史,并通过淡入动画呈现。
|
||||
|
||||
---
|
||||
|
||||
## 7. 可访问性与通用 UX 要求
|
||||
|
||||
- **颜色对比度**
|
||||
- 正文文本与背景对比度 ≥ 4.5:1,次级文本不低于 3:1。
|
||||
- **键盘导航**
|
||||
- 所有按钮和可点击卡片必须可通过 Tab 聚焦,按 Enter/Space 触发。
|
||||
- 焦点样式清晰可见(外发光或描边),不依赖颜色变化微弱的 hover 效果。
|
||||
- **ARIA 与语义化**
|
||||
- 图标按钮(仅图标无文字)添加 `aria-label`。
|
||||
- 使用语义标签(`<main>`, `<header>`, `<nav>`, `<section>`)构建布局。
|
||||
- **动画与动效**
|
||||
- 常规动效时长控制在 150–250ms 内。
|
||||
- 支持 `prefers-reduced-motion`,在该设置下减弱或关闭动画。
|
||||
|
||||
---
|
||||
|
||||
## 8. 动画与微交互规则
|
||||
|
||||
- **Hover 与点击**
|
||||
- 按钮、卡片 hover 使用颜色/阴影变化,不使用大幅 scale 动画。
|
||||
- 点击反馈可使用轻微按压效果(阴影变小/颜色略深)。
|
||||
- **加载与进度**
|
||||
- 使用细线进度条或简洁 Spinner,不使用大型覆盖式 Loading 遮罩,避免阻塞操作。
|
||||
- **列表更新**
|
||||
- 新消息出现时可使用轻微淡入效果(不超过 150ms),避免大幅滑入动画。
|
||||
|
||||
---
|
||||
|
||||
## 9. 实现与扩展建议
|
||||
|
||||
- 建议在前端建立统一的设计 token(如 `dt-color-primary`, `dt-radius-card`, `dt-spacing-md` 等),由基础样式文件或 Tailwind 配置统一生成。
|
||||
- 新增页面或组件时:
|
||||
- **必须** 复用现有颜色、字体、间距与组件模式。
|
||||
- 如需突破现有规范(例如新增“标签页导航组件”),请先在本文件新增对应的组件规范小节,再在代码中实现。
|
||||
- 后续 Phase 3/4 中的剪贴板、图片预览、历史记录等功能,应基于本规范中的组件(文件卡片、图片卡片、Toast、Modal 等)组合实现,不单独发明新样式。
|
||||
|
||||
Reference in New Issue
Block a user