feat: 调整UI/RTC逻辑

This commit is contained in:
MatrixSeven
2025-08-04 21:35:50 +08:00
parent 324408f6b2
commit ef02e88ee9
22 changed files with 1042 additions and 285 deletions

View File

@@ -148,7 +148,7 @@ build_frontend() {
# 构建 # 构建
print_verbose "执行 SSG 构建..." print_verbose "执行 SSG 构建..."
if ! NEXT_EXPORT=true yarn build > build.log 2>&1; then if ! NEXT_EXPORT=true NODE_ENV=production NEXT_PUBLIC_BACKEND_URL= NEXT_PUBLIC_WS_URL= NEXT_PUBLIC_API_BASE_URL= yarn build > build.log 2>&1; then
print_error "前端构建失败,查看 $FRONTEND_DIR/build.log" print_error "前端构建失败,查看 $FRONTEND_DIR/build.log"
cat build.log cat build.log
# 恢复 API 目录后再退出 # 恢复 API 目录后再退出

View File

@@ -228,10 +228,10 @@ set_build_env() {
NEXT_EXPORT=true NEXT_EXPORT=true
NODE_ENV=production NODE_ENV=production
# 后端连接配置(用于静态模式) # 后端连接配置(用于静态模式 - 使用相对路径
NEXT_PUBLIC_GO_BACKEND_URL=http://localhost:8080 NEXT_PUBLIC_GO_BACKEND_URL=
NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws NEXT_PUBLIC_WS_URL=
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080 NEXT_PUBLIC_API_BASE_URL=
EOF EOF
print_verbose "已创建 .env.ssg 文件" print_verbose "已创建 .env.ssg 文件"
@@ -252,9 +252,9 @@ run_static_build() {
# 设置环境变量并执行构建 # 设置环境变量并执行构建
if [ "$VERBOSE" = true ]; then if [ "$VERBOSE" = true ]; then
NEXT_EXPORT=true NODE_ENV=production NEXT_PUBLIC_BACKEND_URL=http://localhost:8080 yarn build NEXT_EXPORT=true NODE_ENV=production NEXT_PUBLIC_BACKEND_URL= yarn build
else else
NEXT_EXPORT=true NODE_ENV=production NEXT_PUBLIC_BACKEND_URL=http://localhost:8080 yarn build > build.log 2>&1 NEXT_EXPORT=true NODE_ENV=production NEXT_PUBLIC_BACKEND_URL= yarn build > build.log 2>&1
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
print_error "构建失败,查看 build.log 获取详细信息" print_error "构建失败,查看 build.log 获取详细信息"
cat build.log cat build.log

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import React from 'react'; import React, { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Upload, MessageSquare, Monitor, Github, ExternalLink } from 'lucide-react'; import { Upload, MessageSquare, Monitor, Github, ExternalLink } from 'lucide-react';
import Hero from '@/components/Hero'; import Hero from '@/components/Hero';
@@ -10,6 +11,21 @@ import { WebRTCFileTransfer } from '@/components/WebRTCFileTransfer';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
export default function HomePage() { export default function HomePage() {
const searchParams = useSearchParams();
const [activeTab, setActiveTab] = useState('webrtc');
const [hasInitialized, setHasInitialized] = useState(false);
// 根据URL参数设置初始标签仅首次加载时
useEffect(() => {
if (!hasInitialized) {
const urlType = searchParams.get('type');
if (urlType && ['webrtc', 'text', 'desktop'].includes(urlType)) {
setActiveTab(urlType);
}
setHasInitialized(true);
}
}, [searchParams, hasInitialized]);
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50"> <div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50">
<div className="container mx-auto px-4 py-4 sm:py-6 md:py-8"> <div className="container mx-auto px-4 py-4 sm:py-6 md:py-8">
@@ -20,7 +36,7 @@ export default function HomePage() {
{/* Main Content */} {/* Main Content */}
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
<Tabs defaultValue="webrtc" className="w-full"> <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
{/* Tabs Navigation - 横向布局 */} {/* Tabs Navigation - 横向布局 */}
<div className="mb-6"> <div className="mb-6">
<TabsList className="grid w-full grid-cols-3 max-w-lg mx-auto h-auto bg-white/90 backdrop-blur-sm shadow-lg rounded-xl p-2 border border-slate-200"> <TabsList className="grid w-full grid-cols-3 max-w-lg mx-auto h-auto bg-white/90 backdrop-blur-sm shadow-lg rounded-xl p-2 border border-slate-200">

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server';
const GO_BACKEND_URL = process.env.GO_BACKEND_URL || 'http://localhost:8080';
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const code = searchParams.get('code');
if (!code) {
return NextResponse.json(
{ success: false, message: '取件码不能为空' },
{ status: 400 }
);
}
console.log('API Route: Getting WebRTC room status, proxying to:', `${GO_BACKEND_URL}/api/webrtc-room-status?code=${code}`);
const response = await fetch(`${GO_BACKEND_URL}/api/webrtc-room-status?code=${code}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const data = await response.json();
console.log('Backend response:', response.status, data);
return NextResponse.json(data, { status: response.status });
} catch (error) {
console.error('API Route Error:', error);
return NextResponse.json(
{ success: false, message: '获取房间状态失败', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
);
}
}

View File

@@ -36,6 +36,7 @@ export const WebRTCFileTransfer: React.FC = () => {
// 房间状态 // 房间状态
const [pickupCode, setPickupCode] = useState(''); const [pickupCode, setPickupCode] = useState('');
const [mode, setMode] = useState<'send' | 'receive'>('send'); const [mode, setMode] = useState<'send' | 'receive'>('send');
const [hasProcessedInitialUrl, setHasProcessedInitialUrl] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const { const {
@@ -54,27 +55,43 @@ export const WebRTCFileTransfer: React.FC = () => {
onFileProgress onFileProgress
} = useWebRTCTransfer(); } = useWebRTCTransfer();
// 从URL参数中获取初始模式 // 从URL参数中获取初始模式(仅在首次加载时处理)
useEffect(() => { useEffect(() => {
const urlMode = searchParams.get('mode') as 'send' | 'receive'; const urlMode = searchParams.get('mode') as 'send' | 'receive';
const type = searchParams.get('type'); const type = searchParams.get('type');
const code = searchParams.get('code'); const code = searchParams.get('code');
if (type === 'webrtc' && urlMode && ['send', 'receive'].includes(urlMode)) { // 只在首次加载且URL中有webrtc类型时处理
if (!hasProcessedInitialUrl && type === 'webrtc' && urlMode && ['send', 'receive'].includes(urlMode)) {
console.log('=== 处理初始URL参数 ===');
console.log('URL模式:', urlMode, '类型:', type, '取件码:', code);
setMode(urlMode); setMode(urlMode);
setHasProcessedInitialUrl(true);
if (code && urlMode === 'receive') { if (code && urlMode === 'receive') {
// 自动加入房间 // 自动加入房间,使用房间状态检查
console.log('URL中有取件码自动加入房间');
joinRoom(code); joinRoom(code);
} }
} }
}, [searchParams]); }, [searchParams, hasProcessedInitialUrl]);
// 更新URL参数 // 更新URL参数
const updateMode = useCallback((newMode: 'send' | 'receive') => { const updateMode = useCallback((newMode: 'send' | 'receive') => {
console.log('=== 手动切换模式 ===');
console.log('新模式:', newMode);
setMode(newMode); setMode(newMode);
const params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParams.toString());
params.set('type', 'webrtc'); params.set('type', 'webrtc');
params.set('mode', newMode); params.set('mode', newMode);
// 如果切换到发送模式移除code参数
if (newMode === 'send') {
params.delete('code');
}
router.push(`?${params.toString()}`, { scroll: false }); router.push(`?${params.toString()}`, { scroll: false });
}, [searchParams, router]); }, [searchParams, router]);
@@ -164,14 +181,46 @@ export const WebRTCFileTransfer: React.FC = () => {
}; };
// 加入房间 (接收模式) // 加入房间 (接收模式)
const joinRoom = (code: string) => { const joinRoom = async (code: string) => {
console.log('=== 加入房间 ==='); console.log('=== 加入房间 ===');
console.log('取件码:', code); console.log('取件码:', code);
setPickupCode(code.trim()); const trimmedCode = code.trim();
connect(code.trim(), 'receiver');
showToast(`正在连接到房间: ${code}`, "info"); // 检查取件码格式
if (!trimmedCode || trimmedCode.length !== 6) {
showToast('请输入正确的6位取件码', "error");
return;
}
try {
// 先检查房间状态
console.log('检查房间状态...');
showToast('正在检查房间状态...', "info");
const response = await fetch(`/api/webrtc-room-status?code=${trimmedCode}`);
const result = await response.json();
if (!result.success) {
showToast(result.message || '房间不存在或已过期', "error");
return;
}
// 检查发送方是否在线
if (!result.sender_online) {
showToast('发送方不在线,请确认取件码是否正确', "error");
return;
}
console.log('房间状态检查通过,开始连接...');
setPickupCode(trimmedCode);
connect(trimmedCode, 'receiver');
showToast(`正在连接到房间: ${trimmedCode}`, "info");
} catch (error) {
console.error('检查房间状态失败:', error);
showToast('检查房间状态失败,请重试', "error");
}
}; };
// 重置连接状态 (用于连接失败后重新输入) // 重置连接状态 (用于连接失败后重新输入)

View File

@@ -1,5 +1,5 @@
import { useState, useCallback, useRef } from 'react'; import { useState, useCallback } from 'react';
import { FileInfo, TransferProgress } from '@/types'; import { TransferProgress } from '@/types';
import { useToast } from '@/components/ui/toast-simple'; import { useToast } from '@/components/ui/toast-simple';
interface FileTransferData { interface FileTransferData {

View File

@@ -20,9 +20,9 @@ export function useWebRTCTransfer() {
// 设置数据通道消息处理 // 设置数据通道消息处理
useEffect(() => { useEffect(() => {
const dataChannel = connection.getDataChannel(); const dataChannel = connection.localDataChannel || connection.remoteDataChannel;
if (dataChannel && dataChannel.readyState === 'open') { if (dataChannel && dataChannel.readyState === 'open') {
console.log('设置数据通道消息处理器'); console.log('设置数据通道消息处理器, 通道类型:', connection.localDataChannel ? '本地' : '远程');
// 扩展消息处理以包含文件列表 // 扩展消息处理以包含文件列表
const originalHandler = fileTransfer.handleMessage; const originalHandler = fileTransfer.handleMessage;
@@ -50,7 +50,7 @@ export function useWebRTCTransfer() {
originalHandler(event); originalHandler(event);
}; };
} }
}, [connection.isConnected, connection.getDataChannel, fileTransfer.handleMessage]); }, [connection.localDataChannel, connection.remoteDataChannel, fileTransfer.handleMessage]);
// 发送文件 // 发送文件
const sendFile = useCallback((file: File, fileId?: string) => { const sendFile = useCallback((file: File, fileId?: string) => {

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
* 用于在静态导出模式下直接与 Go 后端通信 * 用于在静态导出模式下直接与 Go 后端通信
*/ */
import { config, getApiUrl, getDirectBackendUrl } from './config'; import { config } from './config';
interface ApiResponse { interface ApiResponse {
success: boolean; success: boolean;

View File

@@ -27,11 +27,16 @@ const getCurrentBaseUrl = () => {
// 动态获取 WebSocket URL // 动态获取 WebSocket URL
const getCurrentWsUrl = () => { const getCurrentWsUrl = () => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
// 在开发模式下始终使用后端的WebSocket地址 // 检查是否是 Next.js 开发服务器(端口 3000 或 3001
if (window.location.hostname === 'localhost' && (window.location.port === '3000' || window.location.port === '3001')) { const isNextDevServer = window.location.hostname === 'localhost' &&
(window.location.port === '3000' || window.location.port === '3001');
if (isNextDevServer) {
// 开发模式:通过 Next.js 开发服务器访问,连接到后端 WebSocket
return 'ws://localhost:8080/ws/p2p'; return 'ws://localhost:8080/ws/p2p';
} }
// 在生产模式下,使用当前域名
// 生产模式或通过 Go 服务器访问:使用当前域名和端口
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/ws/p2p`; return `${protocol}//${window.location.host}/ws/p2p`;
} }

View File

@@ -27,7 +27,9 @@ export const apiRoutes = [
// 客户端API配置用于静态导出时的客户端请求 // 客户端API配置用于静态导出时的客户端请求
export const clientApiConfig = { export const clientApiConfig = {
// 直接连接到 Go 后端 // 直接连接到 Go 后端 - 动态获取当前域名
baseUrl: 'http://localhost:8080', // 构建时可通过环境变量替换 baseUrl: typeof window !== 'undefined' ? window.location.origin : '',
wsUrl: 'ws://localhost:8080/ws', // 构建时可通过环境变量替换 wsUrl: typeof window !== 'undefined'
? `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/ws`
: '',
} }

View File

@@ -2,6 +2,8 @@ package main
import ( import (
"context" "context"
"flag"
"fmt"
"log" "log"
"net/http" "net/http"
"os" "os"
@@ -19,6 +21,19 @@ import (
) )
func main() { func main() {
// 定义命令行参数
var port = flag.Int("port", 8080, "服务器监听端口")
var help = flag.Bool("help", false, "显示帮助信息")
flag.Parse()
// 显示帮助信息
if *help {
fmt.Println("文件传输服务器")
fmt.Println("用法:")
flag.PrintDefaults()
os.Exit(0)
}
// 初始化服务 // 初始化服务
p2pService := services.NewP2PService() p2pService := services.NewP2PService()
@@ -62,9 +77,15 @@ func main() {
r.Post("/api/create-room", h.CreateRoomHandler) r.Post("/api/create-room", h.CreateRoomHandler)
r.Get("/api/room-info", h.RoomInfoHandler) r.Get("/api/room-info", h.RoomInfoHandler)
// WebRTC API路由
r.Get("/api/webrtc-room-status", h.WebRTCRoomStatusHandler)
// 构建服务器地址
addr := fmt.Sprintf(":%d", *port)
// 启动服务器 // 启动服务器
srv := &http.Server{ srv := &http.Server{
Addr: ":8080", Addr: addr,
Handler: r, Handler: r,
ReadTimeout: 30 * time.Second, ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second, WriteTimeout: 30 * time.Second,
@@ -73,7 +94,7 @@ func main() {
// 优雅关闭 // 优雅关闭
go func() { go func() {
log.Printf("服务器启动在端口 :8080") log.Printf("服务器启动在端口 %s", addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("服务器启动失败: %v", err) log.Fatalf("服务器启动失败: %v", err)
} }

View File

@@ -279,3 +279,52 @@ func (h *Handler) RoomInfoHandler(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(response) json.NewEncoder(w).Encode(response)
} }
// WebRTCRoomStatusHandler 获取WebRTC房间状态API
func (h *Handler) WebRTCRoomStatusHandler(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": "方法不允许",
})
return
}
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位房间码",
})
return
}
// 获取WebRTC房间状态
status := h.webrtcService.GetRoomStatus(code)
if !status["exists"].(bool) {
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(map[string]interface{}{
"success": false,
"message": "房间不存在",
})
return
}
// 构建响应
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"],
}
json.NewEncoder(w).Encode(response)
}

View File

@@ -53,6 +53,8 @@ type WebRTCMessage struct {
// HandleWebSocket 处理WebRTC信令WebSocket连接 // HandleWebSocket 处理WebRTC信令WebSocket连接
func (ws *WebRTCService) HandleWebSocket(w http.ResponseWriter, r *http.Request) { func (ws *WebRTCService) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
log.Printf("收到WebRTC WebSocket连接请求: %s", r.URL.String())
conn, err := ws.upgrader.Upgrade(w, r, nil) conn, err := ws.upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
log.Printf("WebRTC WebSocket升级失败: %v", err) log.Printf("WebRTC WebSocket升级失败: %v", err)
@@ -64,6 +66,8 @@ func (ws *WebRTCService) HandleWebSocket(w http.ResponseWriter, r *http.Request)
code := r.URL.Query().Get("code") code := r.URL.Query().Get("code")
role := r.URL.Query().Get("role") role := r.URL.Query().Get("role")
log.Printf("WebRTC连接参数: code=%s, role=%s", code, role)
if code == "" || (role != "sender" && role != "receiver") { if code == "" || (role != "sender" && role != "receiver") {
log.Printf("WebRTC连接参数无效: code=%s, role=%s", code, role) log.Printf("WebRTC连接参数无效: code=%s, role=%s", code, role)
return return
@@ -78,6 +82,8 @@ func (ws *WebRTCService) HandleWebSocket(w http.ResponseWriter, r *http.Request)
Room: code, Room: code,
} }
log.Printf("WebRTC客户端已创建: ID=%s, Role=%s, Room=%s", clientID, role, code)
// 添加客户端到房间 // 添加客户端到房间
ws.addClientToRoom(code, client) ws.addClientToRoom(code, client)
log.Printf("WebRTC %s连接到房间: %s (客户端ID: %s)", role, code, clientID) log.Printf("WebRTC %s连接到房间: %s (客户端ID: %s)", role, code, clientID)
@@ -86,6 +92,9 @@ func (ws *WebRTCService) HandleWebSocket(w http.ResponseWriter, r *http.Request)
defer func() { defer func() {
ws.removeClientFromRoom(code, clientID) ws.removeClientFromRoom(code, clientID)
log.Printf("WebRTC客户端断开连接: %s (房间: %s)", clientID, code) log.Printf("WebRTC客户端断开连接: %s (房间: %s)", clientID, code)
// 通知房间内其他客户端对方已断开连接
ws.notifyRoomDisconnection(code, clientID, client.Role)
}() }()
// 处理消息 // 处理消息
@@ -201,7 +210,45 @@ func (ws *WebRTCService) generateClientID() string {
return fmt.Sprintf("webrtc_client_%d", rand.Int63()) return fmt.Sprintf("webrtc_client_%d", rand.Int63())
} }
// 获取房间状态 // 通知房间内客户端有人断开连接
func (ws *WebRTCService) notifyRoomDisconnection(roomCode string, disconnectedClientID string, disconnectedRole string) {
ws.roomsMux.Lock()
defer ws.roomsMux.Unlock()
room := ws.rooms[roomCode]
if room == nil {
return
}
// 构建断开连接通知消息
disconnectionMsg := &WebRTCMessage{
Type: "disconnection",
From: disconnectedClientID,
Payload: map[string]interface{}{
"role": disconnectedRole,
"message": "对方已停止传输",
},
}
// 通知房间内其他客户端
if room.Sender != nil && room.Sender.ID != disconnectedClientID {
err := room.Sender.Connection.WriteJSON(disconnectionMsg)
if err != nil {
log.Printf("通知发送方断开连接失败: %v", err)
} else {
log.Printf("已通知发送方: 对方已断开连接")
}
}
if room.Receiver != nil && room.Receiver.ID != disconnectedClientID {
err := room.Receiver.Connection.WriteJSON(disconnectionMsg)
if err != nil {
log.Printf("通知接收方断开连接失败: %v", err)
} else {
log.Printf("已通知接收方: 对方已断开连接")
}
}
}
func (ws *WebRTCService) GetRoomStatus(code string) map[string]interface{} { func (ws *WebRTCService) GetRoomStatus(code string) map[string]interface{} {
ws.roomsMux.RLock() ws.roomsMux.RLock()
defer ws.roomsMux.RUnlock() defer ws.roomsMux.RUnlock()

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

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,7 @@
2:I[6801,["177","static/chunks/app/layout-d0f2a5cbcfca20f5.js"],"ToastProvider"] 2:I[6801,["177","static/chunks/app/layout-d0f2a5cbcfca20f5.js"],"ToastProvider"]
3:I[7555,[],""] 3:I[7555,[],""]
4:I[1295,[],""] 4:I[1295,[],""]
5:I[1287,["423","static/chunks/423-3db9dd818e8fd852.js","974","static/chunks/app/page-6f704b6eb3095813.js"],"default"] 5:I[7443,["423","static/chunks/423-3db9dd818e8fd852.js","974","static/chunks/app/page-c710cf440dafbd05.js"],"default"]
6:I[9665,[],"OutletBoundary"] 6:I[9665,[],"OutletBoundary"]
8:I[4911,[],"AsyncMetadataOutlet"] 8:I[4911,[],"AsyncMetadataOutlet"]
a:I[9665,[],"ViewportBoundary"] a:I[9665,[],"ViewportBoundary"]
@@ -12,7 +12,7 @@ f:I[8393,[],""]
:HL["/_next/static/media/569ce4b8f30dc480-s.p.woff2","font",{"crossOrigin":"","type":"font/woff2"}] :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/media/93f479601ee12b01-s.p.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
:HL["/_next/static/css/f6f47fde0030ec04.css","style"] :HL["/_next/static/css/f6f47fde0030ec04.css","style"]
0:{"P":null,"b":"8bbWAyBmnmNj0Jk5ryXl4","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} 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}
b:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]] b:[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]
7:null 7:null
10:I[8175,[],"IconMark"] 10:I[8175,[],"IconMark"]