mirror of
https://github.com/MatrixSeven/file-transfer-go.git
synced 2026-02-04 03:25:03 +08:00
feat:统一连接层,精简前后端
This commit is contained in:
@@ -2,140 +2,19 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"chuan/internal/models"
|
||||
"chuan/internal/services"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
p2pService *services.P2PService
|
||||
webrtcService *services.WebRTCService
|
||||
templates map[string]*template.Template
|
||||
}
|
||||
|
||||
func NewHandler(p2pService *services.P2PService) *Handler {
|
||||
h := &Handler{
|
||||
p2pService: p2pService,
|
||||
func NewHandler() *Handler {
|
||||
return &Handler{
|
||||
webrtcService: services.NewWebRTCService(),
|
||||
templates: make(map[string]*template.Template),
|
||||
}
|
||||
|
||||
// 加载模板
|
||||
// h.loadTemplates()
|
||||
return h
|
||||
}
|
||||
|
||||
// 加载模板
|
||||
func (h *Handler) loadTemplates() {
|
||||
templateDir := "web/templates"
|
||||
|
||||
// 加载基础模板
|
||||
baseTemplate := filepath.Join(templateDir, "base.html")
|
||||
|
||||
// 加载各个页面模板
|
||||
templates := []string{"index.html"}
|
||||
|
||||
for _, tmplName := range templates {
|
||||
tmplPath := filepath.Join(templateDir, tmplName)
|
||||
tmpl, err := template.ParseFiles(baseTemplate, tmplPath)
|
||||
if err != nil {
|
||||
panic("加载模板失败: " + err.Error())
|
||||
}
|
||||
h.templates[tmplName] = tmpl
|
||||
println("模板加载成功:", tmplName)
|
||||
}
|
||||
}
|
||||
|
||||
// IndexHandler 首页处理器
|
||||
func (h *Handler) IndexHandler(w http.ResponseWriter, r *http.Request) {
|
||||
tmpl, exists := h.templates["index.html"]
|
||||
if !exists {
|
||||
http.Error(w, "模板不存在", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"Title": "P2P文件传输",
|
||||
}
|
||||
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
http.Error(w, "渲染模板失败: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// CreateTextRoomHandler 创建文字传输房间API
|
||||
func (h *Handler) CreateTextRoomHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "方法不允许", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "解析请求失败", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Text == "" {
|
||||
http.Error(w, "文本内容不能为空", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Text) > 50000 {
|
||||
http.Error(w, "文本内容过长,最大支持50,000字符", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 创建文字传输房间
|
||||
code := h.p2pService.CreateTextRoom(req.Text)
|
||||
|
||||
response := map[string]interface{}{
|
||||
"success": true,
|
||||
"code": code,
|
||||
"message": "文字传输房间创建成功",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// GetTextContentHandler 获取文字内容API
|
||||
func (h *Handler) GetTextContentHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "方法不允许", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" || len(code) != 6 {
|
||||
http.Error(w, "请提供正确的6位房间码", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// 获取文字内容
|
||||
text, exists := h.p2pService.GetTextContent(code)
|
||||
if !exists {
|
||||
http.Error(w, "房间不存在或已过期", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"success": true,
|
||||
"text": text,
|
||||
"message": "文字内容获取成功",
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// HandleWebRTCWebSocket 处理WebRTC信令WebSocket连接
|
||||
@@ -143,13 +22,12 @@ func (h *Handler) HandleWebRTCWebSocket(w http.ResponseWriter, r *http.Request)
|
||||
h.webrtcService.HandleWebSocket(w, r)
|
||||
}
|
||||
|
||||
// CreateRoomHandler 创建文件传输房间API
|
||||
// CreateRoomHandler 创建房间API
|
||||
func (h *Handler) CreateRoomHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// 设置响应为JSON格式
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": false,
|
||||
"message": "方法不允许",
|
||||
@@ -157,136 +35,25 @@ func (h *Handler) CreateRoomHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Files []struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Type string `json:"type"`
|
||||
LastModified int64 `json:"lastModified"`
|
||||
} `json:"files"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": false,
|
||||
"message": "解析请求失败",
|
||||
"error": err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Files) == 0 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": false,
|
||||
"message": "至少需要选择一个文件",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 验证文件信息
|
||||
for _, file := range req.Files {
|
||||
if file.Name == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": false,
|
||||
"message": "文件名不能为空",
|
||||
})
|
||||
return
|
||||
}
|
||||
if file.Size <= 0 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": false,
|
||||
"message": "文件大小必须大于0",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 转换文件信息
|
||||
var fileInfos []models.FileTransferInfo
|
||||
for i, file := range req.Files {
|
||||
fileInfos = append(fileInfos, models.FileTransferInfo{
|
||||
ID: fmt.Sprintf("file_%d_%d", time.Now().Unix(), i),
|
||||
Name: file.Name,
|
||||
Size: file.Size,
|
||||
Type: file.Type,
|
||||
LastModified: file.LastModified,
|
||||
})
|
||||
}
|
||||
|
||||
// 创建文件传输房间
|
||||
code := h.p2pService.CreateRoom(fileInfos)
|
||||
|
||||
response := map[string]interface{}{
|
||||
"success": true,
|
||||
"code": code,
|
||||
"message": "文件传输房间创建成功",
|
||||
"files": fileInfos,
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// RoomInfoHandler 获取房间信息API
|
||||
func (h *Handler) RoomInfoHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// 设置响应为JSON格式
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if r.Method != http.MethodGet {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": false,
|
||||
"message": "方法不允许",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" || len(code) != 6 {
|
||||
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": false,
|
||||
"message": "请提供正确的6位房间码",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 获取房间信息
|
||||
room, exists := h.p2pService.GetRoomByCode(code)
|
||||
if !exists {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": false,
|
||||
"message": "房间不存在或已过期",
|
||||
})
|
||||
return
|
||||
}
|
||||
// 创建新房间
|
||||
code := h.webrtcService.CreateNewRoom()
|
||||
|
||||
// 构建响应
|
||||
response := map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "房间信息获取成功",
|
||||
"room": map[string]interface{}{
|
||||
"code": room.Code,
|
||||
"files": room.Files,
|
||||
"file_count": len(room.Files),
|
||||
"is_text_room": room.IsTextRoom,
|
||||
"created_at": room.CreatedAt,
|
||||
},
|
||||
"code": code,
|
||||
"message": "房间创建成功",
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// WebRTCRoomStatusHandler 获取WebRTC房间状态API
|
||||
func (h *Handler) WebRTCRoomStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// RoomStatusHandler 获取WebRTC房间状态API
|
||||
func (h *Handler) RoomStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// 设置响应为JSON格式
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": false,
|
||||
"message": "方法不允许",
|
||||
@@ -296,7 +63,6 @@ func (h *Handler) WebRTCRoomStatusHandler(w http.ResponseWriter, r *http.Request
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" || len(code) != 6 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": false,
|
||||
"message": "请提供正确的6位房间码",
|
||||
@@ -305,10 +71,10 @@ func (h *Handler) WebRTCRoomStatusHandler(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
|
||||
// 获取WebRTC房间状态
|
||||
status := h.webrtcService.GetRoomStatus(code)
|
||||
webrtcStatus := h.webrtcService.GetRoomStatus(code)
|
||||
|
||||
if !status["exists"].(bool) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
// 如果房间不存在,返回不存在状态
|
||||
if !webrtcStatus["exists"].(bool) {
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": false,
|
||||
"message": "房间不存在",
|
||||
@@ -320,10 +86,9 @@ func (h *Handler) WebRTCRoomStatusHandler(w http.ResponseWriter, r *http.Request
|
||||
response := map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "房间状态获取成功",
|
||||
"exists": status["exists"],
|
||||
"sender_online": status["sender_online"],
|
||||
"receiver_online": status["receiver_online"],
|
||||
"created_at": status["created_at"],
|
||||
"sender_online": webrtcStatus["sender_online"],
|
||||
"receiver_online": webrtcStatus["receiver_online"],
|
||||
"created_at": webrtcStatus["created_at"],
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(response)
|
||||
|
||||
@@ -6,28 +6,6 @@ import (
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// FileInfo 文件信息结构
|
||||
type FileInfo struct {
|
||||
ID string `json:"id"`
|
||||
FileName string `json:"filename"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
ContentType string `json:"content_type"`
|
||||
Code string `json:"code"`
|
||||
UploadTime time.Time `json:"upload_time"`
|
||||
ExpiryTime time.Time `json:"expiry_time"`
|
||||
DownloadURL string `json:"download_url"`
|
||||
FilePath string `json:"file_path"`
|
||||
}
|
||||
|
||||
// UploadResponse 上传响应结构
|
||||
type UploadResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Code string `json:"code,omitempty"`
|
||||
FileInfo FileInfo `json:"file_info,omitempty"`
|
||||
DownloadURL string `json:"download_url,omitempty"`
|
||||
}
|
||||
|
||||
// WebRTCOffer WebRTC offer 结构
|
||||
type WebRTCOffer struct {
|
||||
SDP string `json:"sdp"`
|
||||
@@ -53,15 +31,6 @@ type VideoMessage struct {
|
||||
Payload interface{} `json:"payload"`
|
||||
}
|
||||
|
||||
// FileTransferInfo P2P文件传输信息
|
||||
type FileTransferInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Type string `json:"type"`
|
||||
LastModified int64 `json:"lastModified"`
|
||||
}
|
||||
|
||||
// ClientInfo 客户端连接信息
|
||||
type ClientInfo struct {
|
||||
ID string `json:"id"` // 客户端唯一标识
|
||||
@@ -73,12 +42,10 @@ type ClientInfo struct {
|
||||
|
||||
// RoomStatus 房间状态信息
|
||||
type RoomStatus struct {
|
||||
Code string `json:"code"`
|
||||
FileCount int `json:"file_count"`
|
||||
SenderCount int `json:"sender_count"`
|
||||
ReceiverCount int `json:"receiver_count"`
|
||||
Clients []ClientInfo `json:"clients"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Code string `json:"code"`
|
||||
SenderOnline bool `json:"sender_online"`
|
||||
ReceiverOnline bool `json:"receiver_online"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ErrorResponse 错误响应结构
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"chuan/internal/models"
|
||||
)
|
||||
|
||||
// 内存存储(生产环境应使用Redis)
|
||||
type MemoryStore struct {
|
||||
files map[string]*models.FileInfo
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
var globalStore = &MemoryStore{
|
||||
files: make(map[string]*models.FileInfo),
|
||||
}
|
||||
|
||||
// StoreFileInfo 存储文件信息
|
||||
func (ms *MemoryStore) StoreFileInfo(fileInfo *models.FileInfo) error {
|
||||
ms.mutex.Lock()
|
||||
defer ms.mutex.Unlock()
|
||||
|
||||
ms.files[fileInfo.Code] = fileInfo
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFileInfo 获取文件信息
|
||||
func (ms *MemoryStore) GetFileInfo(code string) (*models.FileInfo, error) {
|
||||
ms.mutex.RLock()
|
||||
defer ms.mutex.RUnlock()
|
||||
|
||||
fileInfo, exists := ms.files[code]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("文件不存在或已过期")
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if time.Now().After(fileInfo.ExpiryTime) {
|
||||
delete(ms.files, code)
|
||||
return nil, fmt.Errorf("文件已过期")
|
||||
}
|
||||
|
||||
return fileInfo, nil
|
||||
}
|
||||
|
||||
// DeleteFileInfo 删除文件信息
|
||||
func (ms *MemoryStore) DeleteFileInfo(code string) error {
|
||||
ms.mutex.Lock()
|
||||
defer ms.mutex.Unlock()
|
||||
|
||||
delete(ms.files, code)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetStore 获取全局存储实例
|
||||
func GetStore() *MemoryStore {
|
||||
return globalStore
|
||||
}
|
||||
@@ -1,676 +0,0 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"log"
|
||||
mathrand "math/rand"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"chuan/internal/models"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type FileTransferRoom struct {
|
||||
ID string
|
||||
Code string // 取件码
|
||||
Files []models.FileTransferInfo // 待传输文件信息
|
||||
Clients map[string]*models.ClientInfo // 所有连接的客户端 (客户端ID -> ClientInfo)
|
||||
CreatedAt time.Time // 创建时间
|
||||
TextContent string // 文字内容
|
||||
IsTextRoom bool // 是否是文字传输房间
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
type P2PService struct {
|
||||
rooms map[string]*FileTransferRoom // 使用取件码作为key
|
||||
roomsMux sync.RWMutex
|
||||
upgrader websocket.Upgrader
|
||||
}
|
||||
|
||||
func NewP2PService() *P2PService {
|
||||
service := &P2PService{
|
||||
rooms: make(map[string]*FileTransferRoom),
|
||||
roomsMux: sync.RWMutex{},
|
||||
upgrader: websocket.Upgrader{
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // 允许所有来源,生产环境应当限制
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// 启动房间清理任务
|
||||
go service.cleanupExpiredRooms()
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
// CreateRoom 创建新房间并返回取件码
|
||||
func (p *P2PService) CreateRoom(files []models.FileTransferInfo) string {
|
||||
code := generatePickupCode()
|
||||
|
||||
p.roomsMux.Lock()
|
||||
defer p.roomsMux.Unlock()
|
||||
|
||||
room := &FileTransferRoom{
|
||||
ID: "room_" + code,
|
||||
Code: code,
|
||||
Files: files,
|
||||
Clients: make(map[string]*models.ClientInfo),
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
p.rooms[code] = room
|
||||
log.Printf("创建房间,取件码: %s,文件数量: %d", code, len(files))
|
||||
|
||||
return code
|
||||
}
|
||||
|
||||
// GetRoomByCode 根据取件码获取房间
|
||||
func (p *P2PService) GetRoomByCode(code string) (*FileTransferRoom, bool) {
|
||||
p.roomsMux.RLock()
|
||||
defer p.roomsMux.RUnlock()
|
||||
|
||||
room, exists := p.rooms[code]
|
||||
return room, exists
|
||||
}
|
||||
|
||||
// CreateTextRoom 创建文字传输房间并返回取件码
|
||||
func (p *P2PService) CreateTextRoom(text string) string {
|
||||
code := generatePickupCode()
|
||||
|
||||
p.roomsMux.Lock()
|
||||
defer p.roomsMux.Unlock()
|
||||
|
||||
room := &FileTransferRoom{
|
||||
ID: "text_room_" + code,
|
||||
Code: code,
|
||||
Files: []models.FileTransferInfo{}, // 文字房间不需要文件
|
||||
Clients: make(map[string]*models.ClientInfo),
|
||||
CreatedAt: time.Now(),
|
||||
TextContent: text,
|
||||
IsTextRoom: true,
|
||||
}
|
||||
|
||||
p.rooms[code] = room
|
||||
log.Printf("创建文字传输房间,取件码: %s,文字长度: %d", code, len(text))
|
||||
|
||||
return code
|
||||
}
|
||||
|
||||
// GetTextContent 根据取件码获取文字内容
|
||||
func (p *P2PService) GetTextContent(code string) (string, bool) {
|
||||
p.roomsMux.RLock()
|
||||
defer p.roomsMux.RUnlock()
|
||||
|
||||
room, exists := p.rooms[code]
|
||||
if !exists || !room.IsTextRoom {
|
||||
return "", false
|
||||
}
|
||||
|
||||
return room.TextContent, true
|
||||
}
|
||||
|
||||
// generateClientID 生成客户端唯一标识
|
||||
func generateClientID() string {
|
||||
b := make([]byte, 8)
|
||||
rand.Read(b)
|
||||
return fmt.Sprintf("client_%x", b)
|
||||
}
|
||||
|
||||
// HandleWebSocket 处理WebSocket连接
|
||||
func (p *P2PService) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := p.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("WebSocket升级失败: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// 获取取件码和角色
|
||||
code := r.URL.Query().Get("code")
|
||||
role := r.URL.Query().Get("role") // "sender" or "receiver"
|
||||
|
||||
if code == "" || (role != "sender" && role != "receiver") {
|
||||
log.Printf("缺少取件码或角色参数")
|
||||
return
|
||||
}
|
||||
|
||||
// 获取房间
|
||||
room, exists := p.GetRoomByCode(code)
|
||||
if !exists {
|
||||
log.Printf("房间不存在: %s", code)
|
||||
return
|
||||
}
|
||||
|
||||
// 生成客户端ID并创建客户端信息
|
||||
clientID := generateClientID()
|
||||
client := &models.ClientInfo{
|
||||
ID: clientID,
|
||||
Role: role,
|
||||
Connection: conn,
|
||||
JoinedAt: time.Now(),
|
||||
UserAgent: r.Header.Get("User-Agent"),
|
||||
}
|
||||
|
||||
// 将客户端加入房间
|
||||
room.mutex.Lock()
|
||||
room.Clients[clientID] = client
|
||||
log.Printf("%s连接到房间: %s (客户端ID: %s)", role, code, clientID)
|
||||
|
||||
// 如果是接收方,发送相应内容
|
||||
if role == "receiver" {
|
||||
// 如果是文字房间,发送文字内容
|
||||
if room.IsTextRoom {
|
||||
textMsg := models.VideoMessage{
|
||||
Type: "text-content",
|
||||
Payload: map[string]interface{}{
|
||||
"text": room.TextContent,
|
||||
"is_text_room": true,
|
||||
},
|
||||
}
|
||||
if err := conn.WriteJSON(textMsg); err != nil {
|
||||
log.Printf("发送文字内容失败: %v", err)
|
||||
}
|
||||
} else {
|
||||
// 如果是文件房间,发送文件列表
|
||||
filesMsg := models.VideoMessage{
|
||||
Type: "file-list",
|
||||
Payload: map[string]interface{}{"files": room.Files},
|
||||
}
|
||||
if err := conn.WriteJSON(filesMsg); err != nil {
|
||||
log.Printf("发送文件列表失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 通知所有发送方有新的接收方加入
|
||||
p.notifyClients(room, "sender", models.VideoMessage{
|
||||
Type: "new-receiver",
|
||||
Payload: map[string]interface{}{
|
||||
"client_id": clientID,
|
||||
"joined_at": client.JoinedAt,
|
||||
},
|
||||
})
|
||||
} else if role == "sender" {
|
||||
// 如果是文字房间且发送方连接,也发送当前文字内容用于同步
|
||||
if room.IsTextRoom {
|
||||
textMsg := models.VideoMessage{
|
||||
Type: "text-content",
|
||||
Payload: map[string]interface{}{
|
||||
"text": room.TextContent,
|
||||
"is_text_room": true,
|
||||
},
|
||||
}
|
||||
if err := conn.WriteJSON(textMsg); err != nil {
|
||||
log.Printf("发送文字内容失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 通知所有接收方有新的发送方加入
|
||||
p.notifyClients(room, "receiver", models.VideoMessage{
|
||||
Type: "new-sender",
|
||||
Payload: map[string]interface{}{
|
||||
"client_id": clientID,
|
||||
"joined_at": client.JoinedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// 发送房间状态给所有客户端
|
||||
p.broadcastRoomStatus(room)
|
||||
room.mutex.Unlock()
|
||||
|
||||
// 连接关闭时清理
|
||||
defer func() {
|
||||
room.mutex.Lock()
|
||||
delete(room.Clients, clientID)
|
||||
log.Printf("客户端断开连接: %s (房间: %s)", clientID, code)
|
||||
|
||||
// 通知其他客户端有人离开
|
||||
p.notifyClients(room, "", models.VideoMessage{
|
||||
Type: "client-left",
|
||||
Payload: map[string]interface{}{
|
||||
"client_id": clientID,
|
||||
"role": role,
|
||||
},
|
||||
})
|
||||
|
||||
// 发送更新后的房间状态
|
||||
p.broadcastRoomStatus(room)
|
||||
room.mutex.Unlock()
|
||||
|
||||
// 如果房间没有客户端了,清理房间
|
||||
p.cleanupRoom(code)
|
||||
}()
|
||||
|
||||
// 处理消息
|
||||
for {
|
||||
var msg models.VideoMessage
|
||||
err := conn.ReadJSON(&msg)
|
||||
if err != nil {
|
||||
log.Printf("读取WebSocket消息失败: %v", err)
|
||||
break
|
||||
}
|
||||
|
||||
log.Printf("收到WebSocket消息: 类型=%s, 来自=%s, 房间=%s", msg.Type, clientID, code)
|
||||
|
||||
// 处理特殊消息类型
|
||||
switch msg.Type {
|
||||
case "update-file-list":
|
||||
// 处理文件列表更新
|
||||
p.handleFileListUpdate(room, clientID, msg)
|
||||
case "file-request":
|
||||
// 处理文件请求
|
||||
p.handleFileRequest(room, clientID, msg)
|
||||
case "file-info", "file-chunk", "file-complete":
|
||||
// 处理文件传输相关消息,直接转发给接收方
|
||||
p.forwardMessage(room, clientID, msg)
|
||||
case "text-update":
|
||||
// 处理实时文字更新
|
||||
p.handleTextUpdate(room, clientID, msg)
|
||||
case "text-send":
|
||||
// 处理文字发送
|
||||
p.handleTextSend(room, clientID, msg)
|
||||
case "image-send":
|
||||
// 处理图片发送
|
||||
p.handleImageSend(room, clientID, msg)
|
||||
default:
|
||||
// 转发消息到对应的客户端
|
||||
p.forwardMessage(room, clientID, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// notifyClients 通知指定角色的客户端
|
||||
func (p *P2PService) notifyClients(room *FileTransferRoom, role string, msg models.VideoMessage) {
|
||||
for _, client := range room.Clients {
|
||||
if role == "" || client.Role == role {
|
||||
if err := client.Connection.WriteJSON(msg); err != nil {
|
||||
log.Printf("发送消息到客户端失败 %s: %v", client.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// broadcastRoomStatus 广播房间状态给所有客户端
|
||||
func (p *P2PService) broadcastRoomStatus(room *FileTransferRoom) {
|
||||
status := p.getRoomStatus(room)
|
||||
statusMsg := models.VideoMessage{
|
||||
Type: "room-status",
|
||||
Payload: status,
|
||||
}
|
||||
|
||||
for _, client := range room.Clients {
|
||||
if err := client.Connection.WriteJSON(statusMsg); err != nil {
|
||||
log.Printf("发送房间状态失败 %s: %v", client.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getRoomStatus 获取房间状态
|
||||
func (p *P2PService) getRoomStatus(room *FileTransferRoom) models.RoomStatus {
|
||||
senderCount := 0
|
||||
receiverCount := 0
|
||||
clients := make([]models.ClientInfo, 0, len(room.Clients))
|
||||
|
||||
for _, client := range room.Clients {
|
||||
// 创建不包含连接的客户端信息副本
|
||||
clientCopy := models.ClientInfo{
|
||||
ID: client.ID,
|
||||
Role: client.Role,
|
||||
JoinedAt: client.JoinedAt,
|
||||
UserAgent: client.UserAgent,
|
||||
}
|
||||
clients = append(clients, clientCopy)
|
||||
|
||||
if client.Role == "sender" {
|
||||
senderCount++
|
||||
} else if client.Role == "receiver" {
|
||||
receiverCount++
|
||||
}
|
||||
}
|
||||
|
||||
return models.RoomStatus{
|
||||
Code: room.Code,
|
||||
FileCount: len(room.Files),
|
||||
SenderCount: senderCount,
|
||||
ReceiverCount: receiverCount,
|
||||
Clients: clients,
|
||||
CreatedAt: room.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// handleFileListUpdate 处理文件列表更新
|
||||
func (p *P2PService) handleFileListUpdate(room *FileTransferRoom, clientID string, msg models.VideoMessage) {
|
||||
// 获取文件列表
|
||||
payload, ok := msg.Payload.(map[string]interface{})
|
||||
if !ok {
|
||||
log.Printf("无效的文件列表更新消息格式")
|
||||
return
|
||||
}
|
||||
|
||||
filesData, ok := payload["files"].([]interface{})
|
||||
if !ok {
|
||||
log.Printf("缺少文件列表数据")
|
||||
return
|
||||
}
|
||||
|
||||
// 转换文件列表格式
|
||||
var files []models.FileTransferInfo
|
||||
for _, fileData := range filesData {
|
||||
if fileMap, ok := fileData.(map[string]interface{}); ok {
|
||||
file := models.FileTransferInfo{
|
||||
ID: getString(fileMap, "id"),
|
||||
Name: getString(fileMap, "name"),
|
||||
Size: getInt64(fileMap, "size"),
|
||||
Type: getString(fileMap, "type"),
|
||||
LastModified: getInt64(fileMap, "lastModified"),
|
||||
}
|
||||
files = append(files, file)
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("收到文件列表更新请求,共 %d 个文件", len(files))
|
||||
|
||||
// 更新房间文件列表
|
||||
room.mutex.Lock()
|
||||
room.Files = files
|
||||
room.mutex.Unlock()
|
||||
|
||||
log.Printf("房间 %s 文件列表已更新,共 %d 个文件", room.Code, len(files))
|
||||
|
||||
// 通知所有接收方客户端文件列表已更新
|
||||
room.mutex.RLock()
|
||||
for _, client := range room.Clients {
|
||||
if client.Role == "receiver" {
|
||||
message := models.VideoMessage{
|
||||
Type: "file-list-updated",
|
||||
Payload: map[string]interface{}{
|
||||
"files": files,
|
||||
},
|
||||
}
|
||||
|
||||
if err := client.Connection.WriteJSON(message); err != nil {
|
||||
log.Printf("发送文件列表更新消息失败: %v", err)
|
||||
} else {
|
||||
log.Printf("已向接收方 %s 发送文件列表更新消息", client.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
room.mutex.RUnlock()
|
||||
}
|
||||
|
||||
// 辅助函数:从map中获取字符串值
|
||||
func getString(m map[string]interface{}, key string) string {
|
||||
if val, ok := m[key].(string); ok {
|
||||
return val
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 辅助函数:从map中获取int64值
|
||||
func getInt64(m map[string]interface{}, key string) int64 {
|
||||
if val, ok := m[key].(float64); ok {
|
||||
return int64(val)
|
||||
}
|
||||
if val, ok := m[key].(int64); ok {
|
||||
return val
|
||||
}
|
||||
if val, ok := m[key].(int); ok {
|
||||
return int64(val)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// handleFileRequest 处理文件请求
|
||||
func (p *P2PService) handleFileRequest(room *FileTransferRoom, clientID string, msg models.VideoMessage) {
|
||||
// 获取请求的文件ID
|
||||
payload, ok := msg.Payload.(map[string]interface{})
|
||||
if !ok {
|
||||
log.Printf("无效的文件请求消息格式")
|
||||
return
|
||||
}
|
||||
|
||||
fileID, ok := payload["file_id"].(string)
|
||||
if !ok {
|
||||
log.Printf("缺少文件ID")
|
||||
return
|
||||
}
|
||||
|
||||
// 转发文件请求给所有发送方
|
||||
requestMsg := models.VideoMessage{
|
||||
Type: "file-request",
|
||||
Payload: map[string]interface{}{
|
||||
"file_id": fileID,
|
||||
"requester": clientID,
|
||||
"request_id": payload["request_id"],
|
||||
},
|
||||
}
|
||||
|
||||
p.notifyClients(room, "sender", requestMsg)
|
||||
}
|
||||
|
||||
// forwardMessage 转发消息到指定客户端或所有对应角色的客户端
|
||||
func (p *P2PService) forwardMessage(room *FileTransferRoom, senderClientID string, msg models.VideoMessage) {
|
||||
room.mutex.RLock()
|
||||
defer room.mutex.RUnlock()
|
||||
|
||||
senderClient, exists := room.Clients[senderClientID]
|
||||
if !exists {
|
||||
log.Printf("发送方客户端不存在: %s", senderClientID)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查消息是否指定了目标客户端
|
||||
if payload, ok := msg.Payload.(map[string]interface{}); ok {
|
||||
if targetID, hasTarget := payload["target_client"].(string); hasTarget {
|
||||
// 发送给指定客户端
|
||||
if targetClient, exists := room.Clients[targetID]; exists {
|
||||
log.Printf("转发消息: 类型=%s, 从%s到%s", msg.Type, senderClientID, targetID)
|
||||
if err := targetClient.Connection.WriteJSON(msg); err != nil {
|
||||
log.Printf("转发消息失败: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 否则根据角色转发给对应的客户端
|
||||
targetRole := ""
|
||||
if senderClient.Role == "sender" {
|
||||
targetRole = "receiver"
|
||||
} else if senderClient.Role == "receiver" {
|
||||
targetRole = "sender"
|
||||
}
|
||||
|
||||
if targetRole != "" {
|
||||
log.Printf("广播消息: 类型=%s, 从%s到所有%s", msg.Type, senderClient.Role, targetRole)
|
||||
p.notifyClients(room, targetRole, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupRoom 清理房间
|
||||
func (p *P2PService) cleanupRoom(code string) {
|
||||
p.roomsMux.Lock()
|
||||
defer p.roomsMux.Unlock()
|
||||
|
||||
if room, exists := p.rooms[code]; exists {
|
||||
room.mutex.RLock()
|
||||
noClients := len(room.Clients) == 0
|
||||
room.mutex.RUnlock()
|
||||
|
||||
if noClients {
|
||||
delete(p.rooms, code)
|
||||
log.Printf("清理房间: %s", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupExpiredRooms 定期清理过期房间
|
||||
func (p *P2PService) cleanupExpiredRooms() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
p.roomsMux.Lock()
|
||||
now := time.Now()
|
||||
for code, room := range p.rooms {
|
||||
// 房间存在超过1小时则删除
|
||||
if now.Sub(room.CreatedAt) > time.Hour {
|
||||
delete(p.rooms, code)
|
||||
log.Printf("清理过期房间: %s", code)
|
||||
}
|
||||
}
|
||||
p.roomsMux.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// generatePickupCode 生成6位取件码
|
||||
func generatePickupCode() string {
|
||||
mathrand.Seed(time.Now().UnixNano())
|
||||
code := mathrand.Intn(900000) + 100000
|
||||
return strconv.Itoa(code)
|
||||
}
|
||||
|
||||
// GetRoomStatusByCode 根据取件码获取房间状态
|
||||
func (p *P2PService) GetRoomStatusByCode(code string) (models.RoomStatus, bool) {
|
||||
p.roomsMux.RLock()
|
||||
defer p.roomsMux.RUnlock()
|
||||
|
||||
room, exists := p.rooms[code]
|
||||
if !exists {
|
||||
return models.RoomStatus{}, false
|
||||
}
|
||||
|
||||
room.mutex.RLock()
|
||||
status := p.getRoomStatus(room)
|
||||
room.mutex.RUnlock()
|
||||
|
||||
return status, true
|
||||
}
|
||||
func (p *P2PService) GetRoomStats() map[string]interface{} {
|
||||
p.roomsMux.RLock()
|
||||
defer p.roomsMux.RUnlock()
|
||||
|
||||
stats := map[string]interface{}{
|
||||
"total_rooms": len(p.rooms),
|
||||
"rooms": make([]map[string]interface{}, 0),
|
||||
}
|
||||
|
||||
for code, room := range p.rooms {
|
||||
room.mutex.RLock()
|
||||
status := p.getRoomStatus(room)
|
||||
roomInfo := map[string]interface{}{
|
||||
"code": code,
|
||||
"file_count": len(room.Files),
|
||||
"sender_count": status.SenderCount,
|
||||
"receiver_count": status.ReceiverCount,
|
||||
"total_clients": len(room.Clients),
|
||||
"created_at": room.CreatedAt,
|
||||
}
|
||||
room.mutex.RUnlock()
|
||||
stats["rooms"] = append(stats["rooms"].([]map[string]interface{}), roomInfo)
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
// UpdateRoomFiles 更新房间文件列表
|
||||
func (p *P2PService) UpdateRoomFiles(code string, files []models.FileTransferInfo) bool {
|
||||
p.roomsMux.RLock()
|
||||
room, exists := p.rooms[code]
|
||||
p.roomsMux.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
room.mutex.Lock()
|
||||
room.Files = files
|
||||
room.mutex.Unlock()
|
||||
|
||||
log.Printf("房间 %s 文件列表已更新,共 %d 个文件", code, len(files))
|
||||
|
||||
// 通知所有连接的客户端文件列表已更新
|
||||
room.mutex.RLock()
|
||||
for _, client := range room.Clients {
|
||||
if client.Role == "receiver" {
|
||||
message := models.VideoMessage{
|
||||
Type: "file-list-updated",
|
||||
Payload: map[string]interface{}{
|
||||
"files": files,
|
||||
},
|
||||
}
|
||||
|
||||
if err := client.Connection.WriteJSON(message); err != nil {
|
||||
log.Printf("发送文件列表更新消息失败: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
room.mutex.RUnlock()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// handleTextUpdate 处理实时文字更新
|
||||
func (p *P2PService) handleTextUpdate(room *FileTransferRoom, senderID string, msg models.VideoMessage) {
|
||||
log.Printf("处理文字更新: 来自客户端 %s", senderID)
|
||||
|
||||
// 更新房间的文字内容
|
||||
if payload, ok := msg.Payload.(map[string]interface{}); ok {
|
||||
if textContent, exists := payload["text"].(string); exists {
|
||||
room.mutex.Lock()
|
||||
room.TextContent = textContent
|
||||
room.mutex.Unlock()
|
||||
log.Printf("房间 %s 文字内容已更新", room.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// 转发文字更新给房间内其他所有客户端
|
||||
room.mutex.RLock()
|
||||
defer room.mutex.RUnlock()
|
||||
|
||||
for clientID, client := range room.Clients {
|
||||
if clientID != senderID { // 不发送给发送者自己
|
||||
if err := client.Connection.WriteJSON(msg); err != nil {
|
||||
log.Printf("转发文字更新失败 %s: %v", clientID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleTextSend 处理文字发送
|
||||
func (p *P2PService) handleTextSend(room *FileTransferRoom, senderID string, msg models.VideoMessage) {
|
||||
log.Printf("处理文字发送: 来自客户端 %s", senderID)
|
||||
|
||||
// 转发文字发送给房间内所有客户端
|
||||
room.mutex.RLock()
|
||||
defer room.mutex.RUnlock()
|
||||
|
||||
for _, client := range room.Clients {
|
||||
if err := client.Connection.WriteJSON(msg); err != nil {
|
||||
log.Printf("转发文字发送失败 %s: %v", client.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleImageSend 处理图片发送
|
||||
func (p *P2PService) handleImageSend(room *FileTransferRoom, senderID string, msg models.VideoMessage) {
|
||||
log.Printf("处理图片发送: 来自客户端 %s", senderID)
|
||||
|
||||
// 转发图片发送给房间内其他客户端(不包括发送者)
|
||||
room.mutex.RLock()
|
||||
defer room.mutex.RUnlock()
|
||||
|
||||
for clientID, client := range room.Clients {
|
||||
if clientID != senderID { // 不发送给发送者自己
|
||||
if err := client.Connection.WriteJSON(msg); err != nil {
|
||||
log.Printf("转发图片发送失败 %s: %v", clientID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ type WebRTCRoom struct {
|
||||
Sender *WebRTCClient
|
||||
Receiver *WebRTCClient
|
||||
CreatedAt time.Time
|
||||
ExpiresAt time.Time // 添加过期时间
|
||||
LastOffer *WebRTCMessage // 保存最后的offer消息
|
||||
}
|
||||
|
||||
@@ -33,7 +34,7 @@ type WebRTCClient struct {
|
||||
}
|
||||
|
||||
func NewWebRTCService() *WebRTCService {
|
||||
return &WebRTCService{
|
||||
service := &WebRTCService{
|
||||
rooms: make(map[string]*WebRTCRoom),
|
||||
roomsMux: sync.RWMutex{},
|
||||
upgrader: websocket.Upgrader{
|
||||
@@ -42,6 +43,11 @@ func NewWebRTCService() *WebRTCService {
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// 启动房间清理任务
|
||||
go service.cleanupExpiredRooms()
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
type WebRTCMessage struct {
|
||||
@@ -124,8 +130,10 @@ func (ws *WebRTCService) addClientToRoom(code string, client *WebRTCClient) {
|
||||
room = &WebRTCRoom{
|
||||
Code: code,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(time.Hour), // 1小时后过期
|
||||
}
|
||||
ws.rooms[code] = room
|
||||
log.Printf("自动创建WebRTC房间: %s", code)
|
||||
}
|
||||
|
||||
if client.Role == "sender" {
|
||||
@@ -205,7 +213,55 @@ func (ws *WebRTCService) forwardMessage(roomCode string, fromClientID string, ms
|
||||
}
|
||||
}
|
||||
|
||||
// 生成客户端ID
|
||||
// CreateRoom 创建或获取房间
|
||||
func (ws *WebRTCService) CreateRoom(code string) {
|
||||
ws.roomsMux.Lock()
|
||||
defer ws.roomsMux.Unlock()
|
||||
|
||||
if _, exists := ws.rooms[code]; !exists {
|
||||
ws.rooms[code] = &WebRTCRoom{
|
||||
Code: code,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(time.Hour), // 1小时后过期
|
||||
}
|
||||
log.Printf("创建WebRTC房间: %s", code)
|
||||
}
|
||||
}
|
||||
|
||||
// CreateNewRoom 创建新房间并返回房间码
|
||||
func (ws *WebRTCService) CreateNewRoom() string {
|
||||
code := ws.generatePickupCode()
|
||||
ws.CreateRoom(code)
|
||||
return code
|
||||
}
|
||||
|
||||
// generatePickupCode 生成6位取件码
|
||||
func (ws *WebRTCService) generatePickupCode() string {
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
code := rand.Intn(900000) + 100000
|
||||
return fmt.Sprintf("%d", code)
|
||||
}
|
||||
|
||||
// cleanupExpiredRooms 定期清理过期房间
|
||||
func (ws *WebRTCService) cleanupExpiredRooms() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
ws.roomsMux.Lock()
|
||||
now := time.Now()
|
||||
for code, room := range ws.rooms {
|
||||
// 房间过期或无客户端连接则删除
|
||||
if now.After(room.ExpiresAt) || (room.Sender == nil && room.Receiver == nil) {
|
||||
delete(ws.rooms, code)
|
||||
log.Printf("清理过期WebRTC房间: %s", code)
|
||||
}
|
||||
}
|
||||
ws.roomsMux.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// generateClientID 生成客户端ID
|
||||
func (ws *WebRTCService) generateClientID() string {
|
||||
return fmt.Sprintf("webrtc_client_%d", rand.Int63())
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[492],{3303:(e,t,l)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"default",{enumerable:!0,get:function(){return o}});let r=l(5155),n=l(6395),o=function(){return(0,r.jsx)("html",{children:(0,r.jsx)("body",{children:(0,r.jsx)(n.HTTPAccessErrorFallback,{status:404,message:"This page could not be found."})})})};("function"==typeof t.default||"object"==typeof t.default&&null!==t.default)&&void 0===t.default.__esModule&&(Object.defineProperty(t.default,"__esModule",{value:!0}),Object.assign(t.default,t),e.exports=t.default)},4372:(e,t,l)=>{(window.__NEXT_P=window.__NEXT_P||[]).push(["/_not-found/page",function(){return l(3303)}])},4502:(e,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"styles",{enumerable:!0,get:function(){return l}});let l={error:{fontFamily:'system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"',height:"100vh",textAlign:"center",display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center"},desc:{display:"inline-block"},h1:{display:"inline-block",margin:"0 20px 0 0",padding:"0 23px 0 0",fontSize:24,fontWeight:500,verticalAlign:"top",lineHeight:"49px"},h2:{fontSize:14,fontWeight:400,lineHeight:"49px",margin:0}};("function"==typeof t.default||"object"==typeof t.default&&null!==t.default)&&void 0===t.default.__esModule&&(Object.defineProperty(t.default,"__esModule",{value:!0}),Object.assign(t.default,t),e.exports=t.default)},6395:(e,t,l)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"HTTPAccessErrorFallback",{enumerable:!0,get:function(){return o}});let r=l(5155),n=l(4502);function o(e){let{status:t,message:l}=e;return(0,r.jsxs)(r.Fragment,{children:[(0,r.jsx)("title",{children:t+": "+l}),(0,r.jsx)("div",{style:n.styles.error,children:(0,r.jsxs)("div",{children:[(0,r.jsx)("style",{dangerouslySetInnerHTML:{__html:"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}),(0,r.jsx)("h1",{className:"next-error-h1",style:n.styles.h1,children:t}),(0,r.jsx)("div",{style:n.styles.desc,children:(0,r.jsx)("h2",{style:n.styles.h2,children:l})})]})})]})}("function"==typeof t.default||"object"==typeof t.default&&null!==t.default)&&void 0===t.default.__esModule&&(Object.defineProperty(t.default,"__esModule",{value:!0}),Object.assign(t.default,t),e.exports=t.default)}},e=>{e.O(0,[441,964,358],()=>e(e.s=4372)),_N_E=e.O()}]);
|
||||
@@ -0,0 +1 @@
|
||||
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[492],{3303:(e,t,l)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"default",{enumerable:!0,get:function(){return o}});let r=l(5155),n=l(6395),o=function(){return(0,r.jsx)("html",{children:(0,r.jsx)("body",{children:(0,r.jsx)(n.HTTPAccessErrorFallback,{status:404,message:"This page could not be found."})})})};("function"==typeof t.default||"object"==typeof t.default&&null!==t.default)&&void 0===t.default.__esModule&&(Object.defineProperty(t.default,"__esModule",{value:!0}),Object.assign(t.default,t),e.exports=t.default)},4502:(e,t)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"styles",{enumerable:!0,get:function(){return l}});let l={error:{fontFamily:'system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"',height:"100vh",textAlign:"center",display:"flex",flexDirection:"column",alignItems:"center",justifyContent:"center"},desc:{display:"inline-block"},h1:{display:"inline-block",margin:"0 20px 0 0",padding:"0 23px 0 0",fontSize:24,fontWeight:500,verticalAlign:"top",lineHeight:"49px"},h2:{fontSize:14,fontWeight:400,lineHeight:"49px",margin:0}};("function"==typeof t.default||"object"==typeof t.default&&null!==t.default)&&void 0===t.default.__esModule&&(Object.defineProperty(t.default,"__esModule",{value:!0}),Object.assign(t.default,t),e.exports=t.default)},6395:(e,t,l)=>{"use strict";Object.defineProperty(t,"__esModule",{value:!0}),Object.defineProperty(t,"HTTPAccessErrorFallback",{enumerable:!0,get:function(){return o}});let r=l(5155),n=l(4502);function o(e){let{status:t,message:l}=e;return(0,r.jsxs)(r.Fragment,{children:[(0,r.jsx)("title",{children:t+": "+l}),(0,r.jsx)("div",{style:n.styles.error,children:(0,r.jsxs)("div",{children:[(0,r.jsx)("style",{dangerouslySetInnerHTML:{__html:"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}),(0,r.jsx)("h1",{className:"next-error-h1",style:n.styles.h1,children:t}),(0,r.jsx)("div",{style:n.styles.desc,children:(0,r.jsx)("h2",{style:n.styles.h2,children:l})})]})})]})}("function"==typeof t.default||"object"==typeof t.default&&null!==t.default)&&void 0===t.default.__esModule&&(Object.defineProperty(t.default,"__esModule",{value:!0}),Object.assign(t.default,t),e.exports=t.default)},8511:(e,t,l)=>{(window.__NEXT_P=window.__NEXT_P||[]).push(["/_not-found/page",function(){return l(3303)}])}},e=>{e.O(0,[441,964,358],()=>e(e.s=8511)),_N_E=e.O()}]);
|
||||
@@ -0,0 +1 @@
|
||||
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[177],{347:()=>{},4147:e=>{e.exports={style:{fontFamily:"'Geist', 'Geist Fallback'",fontStyle:"normal"},className:"__className_5cfdac",variable:"__variable_5cfdac"}},6801:(e,l,s)=>{"use strict";s.d(l,{ToastProvider:()=>n,d:()=>i});var t=s(5155),a=s(2115);let r=(0,a.createContext)(void 0),i=()=>{let e=(0,a.useContext)(r);if(!e)throw Error("useToast must be used within a ToastProvider");return e},n=e=>{let{children:l}=e,[s,i]=(0,a.useState)([]),n=(0,a.useCallback)(function(e){let l=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"success",s=Date.now().toString(),t={id:s,message:e,type:l};i(e=>[...e,t]),setTimeout(()=>{i(e=>e.filter(e=>e.id!==s))},3e3)},[]),d=(0,a.useCallback)(e=>{i(l=>l.filter(l=>l.id!==e))},[]);return(0,t.jsxs)(r.Provider,{value:{showToast:n},children:[l,(0,t.jsx)("div",{className:"fixed top-4 left-1/2 transform -translate-x-1/2 z-50 space-y-2",children:s.map(e=>(0,t.jsx)("div",{className:"\n max-w-sm p-4 rounded-xl shadow-lg backdrop-blur-sm transform transition-all duration-300 ease-in-out\n ".concat("success"===e.type?"bg-emerald-50/90 border border-emerald-200 text-emerald-800":"","\n ").concat("error"===e.type?"bg-red-50/90 border border-red-200 text-red-800":"","\n ").concat("info"===e.type?"bg-blue-50/90 border border-blue-200 text-blue-800":"","\n animate-slide-in-down\n "),onClick:()=>d(e.id),children:(0,t.jsxs)("div",{className:"flex items-center space-x-3",children:[(0,t.jsxs)("div",{className:"flex-shrink-0",children:["success"===e.type&&(0,t.jsx)("div",{className:"w-6 h-6 bg-emerald-500 rounded-full flex items-center justify-center",children:(0,t.jsx)("svg",{className:"w-4 h-4 text-white",fill:"currentColor",viewBox:"0 0 20 20",children:(0,t.jsx)("path",{fillRule:"evenodd",d:"M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z",clipRule:"evenodd"})})}),"error"===e.type&&(0,t.jsx)("div",{className:"w-6 h-6 bg-red-500 rounded-full flex items-center justify-center",children:(0,t.jsx)("svg",{className:"w-4 h-4 text-white",fill:"currentColor",viewBox:"0 0 20 20",children:(0,t.jsx)("path",{fillRule:"evenodd",d:"M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z",clipRule:"evenodd"})})}),"info"===e.type&&(0,t.jsx)("div",{className:"w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center",children:(0,t.jsx)("svg",{className:"w-4 h-4 text-white",fill:"currentColor",viewBox:"0 0 20 20",children:(0,t.jsx)("path",{fillRule:"evenodd",d:"M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z",clipRule:"evenodd"})})})]}),(0,t.jsx)("p",{className:"text-sm font-medium",children:e.message})]})},e.id))})]})}},8489:e=>{e.exports={style:{fontFamily:"'Geist Mono', 'Geist Mono Fallback'",fontStyle:"normal"},className:"__className_9a8899",variable:"__variable_9a8899"}},8647:(e,l,s)=>{Promise.resolve().then(s.t.bind(s,4147,23)),Promise.resolve().then(s.t.bind(s,8489,23)),Promise.resolve().then(s.t.bind(s,347,23)),Promise.resolve().then(s.bind(s,6801))}},e=>{e.O(0,[896,441,964,358],()=>e(e.s=8647)),_N_E=e.O()}]);
|
||||
@@ -1 +0,0 @@
|
||||
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[177],{347:()=>{},4147:e=>{e.exports={style:{fontFamily:"'Geist', 'Geist Fallback'",fontStyle:"normal"},className:"__className_5cfdac",variable:"__variable_5cfdac"}},4883:(e,l,s)=>{Promise.resolve().then(s.t.bind(s,4147,23)),Promise.resolve().then(s.t.bind(s,8489,23)),Promise.resolve().then(s.t.bind(s,347,23)),Promise.resolve().then(s.bind(s,6801))},6801:(e,l,s)=>{"use strict";s.d(l,{ToastProvider:()=>n,d:()=>i});var t=s(5155),a=s(2115);let r=(0,a.createContext)(void 0),i=()=>{let e=(0,a.useContext)(r);if(!e)throw Error("useToast must be used within a ToastProvider");return e},n=e=>{let{children:l}=e,[s,i]=(0,a.useState)([]),n=(0,a.useCallback)(function(e){let l=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"success",s=Date.now().toString(),t={id:s,message:e,type:l};i(e=>[...e,t]),setTimeout(()=>{i(e=>e.filter(e=>e.id!==s))},3e3)},[]),d=(0,a.useCallback)(e=>{i(l=>l.filter(l=>l.id!==e))},[]);return(0,t.jsxs)(r.Provider,{value:{showToast:n},children:[l,(0,t.jsx)("div",{className:"fixed top-4 left-1/2 transform -translate-x-1/2 z-50 space-y-2",children:s.map(e=>(0,t.jsx)("div",{className:"\n max-w-sm p-4 rounded-xl shadow-lg backdrop-blur-sm transform transition-all duration-300 ease-in-out\n ".concat("success"===e.type?"bg-emerald-50/90 border border-emerald-200 text-emerald-800":"","\n ").concat("error"===e.type?"bg-red-50/90 border border-red-200 text-red-800":"","\n ").concat("info"===e.type?"bg-blue-50/90 border border-blue-200 text-blue-800":"","\n animate-slide-in-down\n "),onClick:()=>d(e.id),children:(0,t.jsxs)("div",{className:"flex items-center space-x-3",children:[(0,t.jsxs)("div",{className:"flex-shrink-0",children:["success"===e.type&&(0,t.jsx)("div",{className:"w-6 h-6 bg-emerald-500 rounded-full flex items-center justify-center",children:(0,t.jsx)("svg",{className:"w-4 h-4 text-white",fill:"currentColor",viewBox:"0 0 20 20",children:(0,t.jsx)("path",{fillRule:"evenodd",d:"M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z",clipRule:"evenodd"})})}),"error"===e.type&&(0,t.jsx)("div",{className:"w-6 h-6 bg-red-500 rounded-full flex items-center justify-center",children:(0,t.jsx)("svg",{className:"w-4 h-4 text-white",fill:"currentColor",viewBox:"0 0 20 20",children:(0,t.jsx)("path",{fillRule:"evenodd",d:"M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z",clipRule:"evenodd"})})}),"info"===e.type&&(0,t.jsx)("div",{className:"w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center",children:(0,t.jsx)("svg",{className:"w-4 h-4 text-white",fill:"currentColor",viewBox:"0 0 20 20",children:(0,t.jsx)("path",{fillRule:"evenodd",d:"M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z",clipRule:"evenodd"})})})]}),(0,t.jsx)("p",{className:"text-sm font-medium",children:e.message})]})},e.id))})]})}},8489:e=>{e.exports={style:{fontFamily:"'Geist Mono', 'Geist Mono Fallback'",fontStyle:"normal"},className:"__className_9a8899",variable:"__variable_9a8899"}}},e=>{e.O(0,[896,441,964,358],()=>e(e.s=4883)),_N_E=e.O()}]);
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[358],{5785:(e,s,n)=>{Promise.resolve().then(n.t.bind(n,8393,23)),Promise.resolve().then(n.t.bind(n,894,23)),Promise.resolve().then(n.t.bind(n,4970,23)),Promise.resolve().then(n.t.bind(n,6975,23)),Promise.resolve().then(n.t.bind(n,7555,23)),Promise.resolve().then(n.t.bind(n,4911,23)),Promise.resolve().then(n.t.bind(n,9665,23)),Promise.resolve().then(n.t.bind(n,1295,23)),Promise.resolve().then(n.bind(n,8175))},9393:()=>{}},e=>{var s=s=>e(e.s=s);e.O(0,[441,964],()=>(s(5415),s(5785))),_N_E=e.O()}]);
|
||||
(self.webpackChunk_N_E=self.webpackChunk_N_E||[]).push([[358],{7554:(e,s,n)=>{Promise.resolve().then(n.t.bind(n,8393,23)),Promise.resolve().then(n.t.bind(n,894,23)),Promise.resolve().then(n.t.bind(n,4970,23)),Promise.resolve().then(n.t.bind(n,6975,23)),Promise.resolve().then(n.t.bind(n,7555,23)),Promise.resolve().then(n.t.bind(n,4911,23)),Promise.resolve().then(n.t.bind(n,9665,23)),Promise.resolve().then(n.t.bind(n,1295,23)),Promise.resolve().then(n.bind(n,8175))},9393:()=>{}},e=>{var s=s=>e(e.s=s);e.O(0,[441,964],()=>(s(5415),s(7554))),_N_E=e.O()}]);
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,8 +1,8 @@
|
||||
1:"$Sreact.fragment"
|
||||
2:I[6801,["177","static/chunks/app/layout-d0f2a5cbcfca20f5.js"],"ToastProvider"]
|
||||
2:I[6801,["177","static/chunks/app/layout-289c3a516c6cc23e.js"],"ToastProvider"]
|
||||
3:I[7555,[],""]
|
||||
4:I[1295,[],""]
|
||||
5:I[7443,["423","static/chunks/423-3db9dd818e8fd852.js","974","static/chunks/app/page-c710cf440dafbd05.js"],"default"]
|
||||
5:I[2176,["103","static/chunks/103-a63863fe5831e6e4.js","974","static/chunks/app/page-42faad915a8fb9b0.js"],"default"]
|
||||
6:I[9665,[],"OutletBoundary"]
|
||||
8:I[4911,[],"AsyncMetadataOutlet"]
|
||||
a:I[9665,[],"ViewportBoundary"]
|
||||
@@ -11,8 +11,8 @@ d:"$Sreact.suspense"
|
||||
f:I[8393,[],""]
|
||||
:HL["/_next/static/media/569ce4b8f30dc480-s.p.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
|
||||
:HL["/_next/static/media/93f479601ee12b01-s.p.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
|
||||
:HL["/_next/static/css/f6f47fde0030ec04.css","style"]
|
||||
0:{"P":null,"b":"8y8TXPJV_4IIplGqW3zUm","p":"","c":["",""],"i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/f6f47fde0030ec04.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]],["$","html",null,{"lang":"zh-CN","children":["$","body",null,{"className":"__variable_5cfdac __variable_9a8899 antialiased","children":["$","$L2",null,{"children":["$","$L3",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]}]]}],{"children":["__PAGE__",["$","$1","c",{"children":[["$","$L5",null,{}],null,["$","$L6",null,{"children":["$L7",["$","$L8",null,{"promise":"$@9"}]]}]]}],{},null,false]},null,false],["$","$1","h",{"children":[null,[["$","$La",null,{"children":"$Lb"}],["$","meta",null,{"name":"next-size-adjust","content":""}]],["$","$Lc",null,{"children":["$","div",null,{"hidden":true,"children":["$","$d",null,{"fallback":null,"children":"$Le"}]}]}]]}],false]],"m":"$undefined","G":["$f",[]],"s":false,"S":true}
|
||||
:HL["/_next/static/css/bce9572ecc710ffa.css","style"]
|
||||
0:{"P":null,"b":"lMIiMP0cOGZeNgFtTB-Kw","p":"","c":["",""],"i":false,"f":[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/_next/static/css/bce9572ecc710ffa.css","precedence":"next","crossOrigin":"$undefined","nonce":"$undefined"}]],["$","html",null,{"lang":"zh-CN","children":["$","body",null,{"className":"__variable_5cfdac __variable_9a8899 antialiased","children":["$","$L2",null,{"children":["$","$L3",null,{"parallelRouterKey":"children","error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]],"forbidden":"$undefined","unauthorized":"$undefined"}]}]}]}]]}],{"children":["__PAGE__",["$","$1","c",{"children":[["$","$L5",null,{}],null,["$","$L6",null,{"children":["$L7",["$","$L8",null,{"promise":"$@9"}]]}]]}],{},null,false]},null,false],["$","$1","h",{"children":[null,[["$","$La",null,{"children":"$Lb"}],["$","meta",null,{"name":"next-size-adjust","content":""}]],["$","$Lc",null,{"children":["$","div",null,{"hidden":true,"children":["$","$d",null,{"fallback":null,"children":"$Le"}]}]}]]}],false]],"m":"$undefined","G":["$f",[]],"s":false,"S":true}
|
||||
b:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
|
||||
7:null
|
||||
10:I[8175,[],"IconMark"]
|
||||
|
||||
Reference in New Issue
Block a user