fix(ui): 移除网络诊断与缓存刷新入口

This commit is contained in:
chenpei 2026-07-30 22:48:46 +08:00
parent 69c2c62be3
commit 88c98cc081
10 changed files with 91 additions and 4318 deletions

View File

@ -9,7 +9,7 @@
- 基于 Next.js 14 App Router 的素材协作与分发系统
- 默认以新素材流程为主,不再把“新老素材兼容”作为日常主路径
- 同时使用 PostgreSQL、Prisma、S3/MinIO、本地 `uploads` 目录,以及可选 Redis 上传会话
- 已覆盖首页浏览、素材上传、分享下载、后台管理、外部接口、网络诊断、健康检查等完整链路
- 已覆盖首页浏览、素材上传、分享下载、后台管理、外部接口、内外网状态识别、健康检查等完整链路
- 当前项目已经在线上运行,涉及权限、下载、上传、转码、批量删除等改动时需要先验证再发布
## 当前核心能力
@ -53,7 +53,7 @@
### 5. 运行与诊断
- 网络诊断页面与接口
- 内外网状态识别、健康检查与受控运维监控
- 健康检查接口与内存检查接口
- 快速性能检查接口
- 内外网 URL 重写与下载链路适配

View File

@ -1,127 +0,0 @@
// 网络诊断API - 用于测试上传/下载速度
import { NextRequest, NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
export const maxDuration = 60; // 最长60秒
// 生成测试数据
function generateTestData(size = 10 * 1024 * 1024): Uint8Array {
const data = new Uint8Array(size);
for (let i = 0; i < data.length; i++) {
data[i] = i % 251; // 使用简单的模式填充
}
return data;
}
// GET - 下载测试(返回测试数据)
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const mode = searchParams.get('mode') || 'download';
// 下载速度测试
if (mode === 'download') {
const testData = generateTestData();
return new Response(testData as any, {
status: 200,
headers: {
'Content-Type': 'application/octet-stream',
'Content-Length': testData.byteLength.toString(),
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
},
});
}
// S3存储测试
if (mode === 'storage') {
const s3Url = getS3PublicUrl();
const startTime = Date.now();
try {
const response = await fetch(s3Url, {
method: 'GET',
redirect: 'manual',
cache: 'no-store',
});
const duration = Date.now() - startTime;
return NextResponse.json({
ok: response.ok,
status: response.status,
durationMs: duration,
url: s3Url,
contentLength: response.headers.get('content-length'),
location: response.headers.get('location'),
});
} catch (error: any) {
return NextResponse.json(
{
ok: false,
error: error?.message || String(error),
url: s3Url,
},
{ status: 500 }
);
}
}
// Ping测试
if (mode === 'ping') {
return NextResponse.json({
ok: true,
message: 'pong',
timestamp: new Date().toISOString(),
});
}
// 默认返回测试数据
const testData = generateTestData();
return new Response(testData as any, {
status: 200,
headers: {
'Content-Type': 'application/octet-stream',
'Content-Length': testData.byteLength.toString(),
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
'Pragma': 'no-cache',
'Expires': '0',
},
});
}
// POST - 上传测试(接收测试数据)
export async function POST(request: NextRequest) {
try {
const data = await request.arrayBuffer();
return NextResponse.json({
ok: true,
receivedBytes: data.byteLength,
timestamp: new Date().toISOString(),
});
} catch (error: any) {
return NextResponse.json(
{
ok: false,
error: error?.message || String(error),
},
{ status: 500 }
);
}
}
// 获取S3公网URL
function getS3PublicUrl(): string {
const s3Domain = process.env.S3_PUBLIC_DOMAIN || process.env.APP_PUBLIC_URL || process.env.NEXTAUTH_URL;
if (!s3Domain) {
return 'https://s3.jiansun.vip';
}
// 确保URL格式正确
if (/^https?:\/\//i.test(s3Domain)) {
return s3Domain.replace(/\/$/, '');
}
return `https://${s3Domain.replace(/\/$/, '')}`;
}

View File

@ -1,69 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { hasSystemAdminPermission } from '@/lib/department-permission'
const MAX_TEST_SIZE_BYTES = 5 * 1024 * 1024
const RATE_LIMIT_WINDOW_MS = 60 * 1000
const RATE_LIMIT_MAX_REQUESTS = 10
const requestCounts = new Map<string, { count: number; resetAt: number }>()
function consumeRateLimit(userId: string): number | null {
const now = Date.now()
const current = requestCounts.get(userId)
if (!current || current.resetAt <= now) {
requestCounts.set(userId, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS })
return null
}
if (current.count >= RATE_LIMIT_MAX_REQUESTS) {
return Math.max(1, Math.ceil((current.resetAt - now) / 1000))
}
current.count += 1
return null
}
// 下载测速API
export async function POST(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
const user = session.user as any
if (!hasSystemAdminPermission(user.role, Boolean(user.isSuperAdmin))) {
return NextResponse.json({ error: '仅系统管理员可执行下载测速' }, { status: 403 })
}
const retryAfter = consumeRateLimit(String(user.id || 'unknown'))
if (retryAfter) {
return NextResponse.json(
{ error: '请求过于频繁,请稍后再试' },
{ status: 429, headers: { 'Retry-After': String(retryAfter) } },
)
}
const body = await request.json()
const size = body?.size === undefined ? 1024 * 1024 : Number(body.size)
if (!Number.isInteger(size) || size < 1 || size > MAX_TEST_SIZE_BYTES) {
return NextResponse.json(
{ error: `size 必须是 1 到 ${MAX_TEST_SIZE_BYTES} 之间的整数` },
{ status: 400 },
)
}
// 生成测试数据
const buffer = Buffer.alloc(size, 0)
return new NextResponse(buffer, {
headers: {
'Content-Type': 'application/octet-stream',
'Content-Length': size.toString(),
'Cache-Control': 'no-store',
}
})
} catch (error) {
console.error('下载测速失败:', error)
return NextResponse.json({ error: '下载测速失败' }, { status: 500 })
}
}

View File

@ -1,28 +0,0 @@
import { NextResponse } from 'next/server'
import { testS3Connection } from '@/lib/s3'
// 存储连通性测试API
export async function GET() {
try {
// 测试S3连接
const s3Connected = await testS3Connection()
if (s3Connected) {
return NextResponse.json({
success: true,
message: 'S3存储服务连接正常'
})
} else {
return NextResponse.json({
success: false,
message: 'S3存储服务连接失败'
}, { status: 500 })
}
} catch (error) {
console.error('存储连通性测试失败:', error)
return NextResponse.json({
success: false,
message: '存储服务连接失败'
}, { status: 500 })
}
}

View File

@ -1,25 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
// 上传测速API
export async function POST(request: NextRequest) {
try {
const formData = await request.formData()
const file = formData.get('file') as File
if (!file) {
return NextResponse.json({ error: '缺少文件' }, { status: 400 })
}
// 读取文件内容(模拟上传处理)
await file.arrayBuffer()
return NextResponse.json({
success: true,
size: file.size,
message: '上传测试完成'
})
} catch (error) {
console.error('上传测速失败:', error)
return NextResponse.json({ error: '上传测速失败' }, { status: 500 })
}
}

View File

@ -1,708 +0,0 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { S3Client, GetObjectCommand, PutObjectCommand, ListObjectsV2Command, DeleteObjectCommand, ListObjectsV2CommandOutput } from '@aws-sdk/client-s3'
import { randomBytes } from 'crypto'
import { detectIsIntranet } from '@/lib/network-detection'
import { authOptions } from '@/lib/auth'
import { hasSystemAdminPermission } from '@/lib/department-permission'
export const dynamic = 'force-dynamic'
const ALLOWED_TEST_TYPES = new Set(['full', 'concurrent', 'mixed', 'quick'])
const MAX_CONCURRENCY = 10
const MAX_REQUEST_COUNT = 50
const MAX_MIXED_DURATION_MS = 10_000
const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000
const RATE_LIMIT_MAX_REQUESTS = 2
const requestCounts = new Map<string, { count: number; resetAt: number }>()
function consumeRateLimit(userId: string): number | null {
const now = Date.now()
const current = requestCounts.get(userId)
if (!current || current.resetAt <= now) {
requestCounts.set(userId, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS })
return null
}
if (current.count >= RATE_LIMIT_MAX_REQUESTS) {
return Math.max(1, Math.ceil((current.resetAt - now) / 1000))
}
current.count += 1
return null
}
// 创建S3客户端
const s3Client = new S3Client({
endpoint: process.env.S3_ENDPOINT || '',
region: process.env.S3_REGION || 'us-east-1',
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
},
forcePathStyle: true,
requestHandler: {
requestTimeout: 60000, // 增加到60秒以支持大文件
connectionTimeout: 10000,
},
})
// 计算统计信息
function calculateStats(durations: number[]) {
if (durations.length === 0) {
return {
min: 0,
max: 0,
avg: 0,
p50: 0,
p90: 0,
p95: 0,
p99: 0,
total: 0,
}
}
const sorted = [...durations].sort((a, b) => a - b)
return {
min: sorted[0],
max: sorted[sorted.length - 1],
avg: durations.reduce((a, b) => a + b, 0) / durations.length,
p50: sorted[Math.floor(sorted.length * 0.5)],
p90: sorted[Math.floor(sorted.length * 0.9)],
p95: sorted[Math.floor(sorted.length * 0.95)],
p99: sorted[Math.floor(sorted.length * 0.99)],
total: durations.reduce((a, b) => a + b, 0),
}
}
// 测试连接速度
async function testConnectionSpeed(): Promise<number> {
const startTime = Date.now()
try {
const command = new ListObjectsV2Command({
Bucket: process.env.S3_BUCKET!,
MaxKeys: 1,
})
await s3Client.send(command)
return Date.now() - startTime
} catch (error: any) {
return Date.now() - startTime
}
}
// 测试上传速度(单个)
async function testUploadSpeed(sizeKB: number, testKey?: string): Promise<{ duration: number; speed: number; success: boolean; error?: string; key?: string }> {
const key = testKey || `speed-test-${Date.now()}-${Math.random().toString(36).substring(7)}.bin`
const testData = randomBytes(sizeKB * 1024)
const startTime = Date.now()
try {
const command = new PutObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
Body: testData,
ContentType: 'application/octet-stream',
})
await s3Client.send(command)
const duration = Date.now() - startTime
const speed = (sizeKB / duration) * 1000 // KB/s
return { duration, speed, success: true, key }
} catch (error: any) {
const duration = Date.now() - startTime
return { duration, speed: 0, success: false, error: error.message, key }
}
}
// 测试下载速度(通过HTTP)
async function testDownloadSpeed(url: string): Promise<{ duration: number; speed: number; ttfb: number; success: boolean; error?: string }> {
const startTime = Date.now()
let ttfb = 0
try {
const response = await fetch(url, {
method: 'HEAD',
signal: AbortSignal.timeout(30000),
})
ttfb = Date.now() - startTime
if (response.ok) {
const contentLength = response.headers.get('content-length')
if (contentLength) {
const sizeKB = parseInt(contentLength) / 1024
// 如果文件较小,尝试完整下载测试
if (sizeKB < 5000) { // 增加到5MB
const downloadStart = Date.now()
const downloadResponse = await fetch(url, {
signal: AbortSignal.timeout(60000),
})
if (downloadResponse.ok) {
await downloadResponse.arrayBuffer()
const downloadDuration = Date.now() - downloadStart
const speed = (sizeKB / downloadDuration) * 1000 // KB/s
return { duration: downloadDuration, speed, ttfb, success: true }
}
}
return { duration: ttfb, speed: 0, ttfb, success: true }
}
return { duration: ttfb, speed: 0, ttfb, success: true }
} else {
return { duration: ttfb, speed: 0, ttfb, success: false, error: `HTTP ${response.status}` }
}
} catch (error: any) {
const duration = Date.now() - startTime
return { duration, speed: 0, ttfb: duration, success: false, error: error.message }
}
}
// 并发上传测试
async function testConcurrentUploads(
concurrency: number,
sizeKB: number,
count: number
): Promise<{
total: number
success: number
failed: number
durations: number[]
speeds: number[]
stats: ReturnType<typeof calculateStats>
throughput: number // 总吞吐量 MB/s
}> {
const durations: number[] = []
const speeds: number[] = []
let success = 0
let failed = 0
const keys: string[] = []
const startTime = Date.now()
// 分批执行,每批concurrency个
for (let i = 0; i < count; i += concurrency) {
const batch = []
for (let j = 0; j < concurrency && i + j < count; j++) {
batch.push(testUploadSpeed(sizeKB))
}
const results = await Promise.allSettled(batch)
for (const result of results) {
if (result.status === 'fulfilled') {
const r = result.value
if (r.success) {
durations.push(r.duration)
speeds.push(r.speed)
success++
if (r.key) keys.push(r.key)
} else {
failed++
}
} else {
failed++
}
}
}
const totalTime = Date.now() - startTime
const totalSizeMB = (success * sizeKB) / 1024
const throughput = totalTime > 0 ? (totalSizeMB / totalTime) * 1000 : 0 // MB/s
// 清理测试文件
if (keys.length > 0) {
await Promise.allSettled(
keys.map(key =>
s3Client.send(
new DeleteObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: key,
})
)
)
)
}
return {
total: count,
success,
failed,
durations,
speeds,
stats: calculateStats(durations),
throughput,
}
}
// 并发下载测试
async function testConcurrentDownloads(
concurrency: number,
urls: string[],
count: number
): Promise<{
total: number
success: number
failed: number
durations: number[]
speeds: number[]
ttfb: number[]
stats: ReturnType<typeof calculateStats>
ttfbStats: ReturnType<typeof calculateStats>
throughput: number // 总吞吐量 MB/s
}> {
const durations: number[] = []
const speeds: number[] = []
const ttfb: number[] = []
let success = 0
let failed = 0
const startTime = Date.now()
// 循环使用URLs
for (let i = 0; i < count; i += concurrency) {
const batch = []
for (let j = 0; j < concurrency && i + j < count; j++) {
const url = urls[(i + j) % urls.length]
batch.push(testDownloadSpeed(url))
}
const results = await Promise.allSettled(batch)
for (const result of results) {
if (result.status === 'fulfilled') {
const r = result.value
if (r.success) {
durations.push(r.duration)
speeds.push(r.speed)
ttfb.push(r.ttfb)
success++
} else {
failed++
}
} else {
failed++
}
}
}
const totalTime = Date.now() - startTime
// 估算总大小(假设每个文件平均大小)
const avgSizeKB = speeds.length > 0 ? (speeds.reduce((a, b) => a + b, 0) / speeds.length) * (durations.reduce((a, b) => a + b, 0) / durations.length) / 1000 : 0
const totalSizeMB = (success * avgSizeKB) / 1024
const throughput = totalTime > 0 ? (totalSizeMB / totalTime) * 1000 : 0 // MB/s
return {
total: count,
success,
failed,
durations,
speeds,
ttfb,
stats: calculateStats(durations),
ttfbStats: calculateStats(ttfb),
throughput,
}
}
// 混合负载测试(同时上传和下载)
async function testMixedLoad(
uploadConcurrency: number,
downloadConcurrency: number,
uploadSizeKB: number,
downloadUrls: string[],
duration: number // 测试持续时间(毫秒)
): Promise<{
upload: {
total: number
success: number
failed: number
throughput: number
}
download: {
total: number
success: number
failed: number
throughput: number
}
}> {
let uploadTotal = 0
let uploadSuccess = 0
let uploadFailed = 0
let uploadTotalSizeMB = 0
let downloadTotal = 0
let downloadSuccess = 0
let downloadFailed = 0
let downloadTotalSizeMB = 0
const startTime = Date.now()
const endTime = startTime + duration
// 启动上传任务
const uploadTask = (async () => {
while (Date.now() < endTime) {
const batch = []
for (let i = 0; i < uploadConcurrency; i++) {
batch.push(testUploadSpeed(uploadSizeKB))
}
const results = await Promise.allSettled(batch)
for (const result of results) {
uploadTotal++
if (result.status === 'fulfilled') {
const r = result.value
if (r.success) {
uploadSuccess++
uploadTotalSizeMB += uploadSizeKB / 1024
// 清理
if (r.key) {
s3Client.send(
new DeleteObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: r.key,
})
).catch(() => {})
}
} else {
uploadFailed++
}
} else {
uploadFailed++
}
}
}
})()
// 启动下载任务
const downloadTask = (async () => {
while (Date.now() < endTime) {
const batch = []
for (let i = 0; i < downloadConcurrency; i++) {
const url = downloadUrls[i % downloadUrls.length]
if (url) {
batch.push(testDownloadSpeed(url))
}
}
const results = await Promise.allSettled(batch)
for (const result of results) {
downloadTotal++
if (result.status === 'fulfilled') {
const r = result.value
if (r.success) {
downloadSuccess++
// 估算大小
if (r.speed > 0) {
downloadTotalSizeMB += (r.speed * r.duration) / (1024 * 1000)
}
} else {
downloadFailed++
}
} else {
downloadFailed++
}
}
}
})()
await Promise.all([uploadTask, downloadTask])
const actualDuration = Date.now() - startTime
return {
upload: {
total: uploadTotal,
success: uploadSuccess,
failed: uploadFailed,
throughput: actualDuration > 0 ? (uploadTotalSizeMB / actualDuration) * 1000 : 0,
},
download: {
total: downloadTotal,
success: downloadSuccess,
failed: downloadFailed,
throughput: actualDuration > 0 ? (downloadTotalSizeMB / actualDuration) * 1000 : 0,
},
}
}
// 获取测试文件URLs
async function getTestFileUrls(count: number = 10): Promise<string[]> {
const urls: string[] = []
try {
const command = new ListObjectsV2Command({
Bucket: process.env.S3_BUCKET!,
MaxKeys: count,
})
const response = await s3Client.send(command)
if (response.Contents && response.Contents.length > 0) {
for (const file of response.Contents) {
const key = file.Key || ''
// 跳过测试文件
if (key && !key.includes('speed-test-')) {
urls.push(`${process.env.S3_PUBLIC_DOMAIN}/${key}`)
}
}
}
} catch (error) {
// ignore
}
return urls
}
// 清理所有测试文件(以防有遗漏)
async function cleanupAllTestFiles(): Promise<{ deleted: number; failed: number }> {
let deleted = 0
let failed = 0
let continuationToken: string | undefined = undefined
try {
do {
const listCommand: ListObjectsV2Command = new ListObjectsV2Command({
Bucket: process.env.S3_BUCKET!,
Prefix: 'speed-test-',
ContinuationToken: continuationToken,
MaxKeys: 1000,
})
const listResponse = await s3Client.send(listCommand) as ListObjectsV2CommandOutput
if (listResponse.Contents && listResponse.Contents.length > 0) {
const deletePromises = listResponse.Contents.map((file) => {
if (file.Key) {
return s3Client.send(
new DeleteObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: file.Key,
})
).then(() => {
deleted++
}).catch(() => {
failed++
})
}
return Promise.resolve()
})
await Promise.allSettled(deletePromises)
}
continuationToken = listResponse.NextContinuationToken
} while (continuationToken)
} catch (error) {
console.warn('[S3 Speed Test] Cleanup error:', error)
}
return { deleted, failed }
}
export async function GET(request: NextRequest) {
try {
const session = await getServerSession(authOptions)
if (!session?.user) {
return NextResponse.json({ error: '请先登录' }, { status: 401 })
}
const user = session.user as any
if (!hasSystemAdminPermission(user.role, Boolean(user.isSuperAdmin))) {
return NextResponse.json({ error: '仅系统管理员可执行 S3 测速' }, { status: 403 })
}
const retryAfter = consumeRateLimit(String(user.id || 'unknown'))
if (retryAfter) {
return NextResponse.json(
{ error: 'S3 测速请求过于频繁,请稍后再试' },
{ status: 429, headers: { 'Retry-After': String(retryAfter) } },
)
}
const searchParams = request.nextUrl.searchParams
const testTypeRaw = searchParams.get('type') || 'quick'
const concurrency = Number(searchParams.get('concurrency') || '5')
const count = Number(searchParams.get('count') || '20')
const mixedDurationMs = Number(searchParams.get('durationMs') || '5000')
if (!ALLOWED_TEST_TYPES.has(testTypeRaw)) {
return NextResponse.json({ error: 'type 参数无效' }, { status: 400 })
}
if (!Number.isInteger(concurrency) || concurrency < 1 || concurrency > MAX_CONCURRENCY) {
return NextResponse.json(
{ error: `concurrency 必须是 1 到 ${MAX_CONCURRENCY} 之间的整数` },
{ status: 400 },
)
}
if (!Number.isInteger(count) || count < 1 || count > MAX_REQUEST_COUNT) {
return NextResponse.json(
{ error: `count 必须是 1 到 ${MAX_REQUEST_COUNT} 之间的整数` },
{ status: 400 },
)
}
if (!Number.isInteger(mixedDurationMs) || mixedDurationMs < 1000 || mixedDurationMs > MAX_MIXED_DURATION_MS) {
return NextResponse.json(
{ error: `durationMs 必须是 1000 到 ${MAX_MIXED_DURATION_MS} 之间的整数` },
{ status: 400 },
)
}
const testType = testTypeRaw as 'full' | 'concurrent' | 'mixed' | 'quick'
const isIntranet = detectIsIntranet(request)
console.log('[S3 Speed Test] Starting comprehensive tests...')
console.log(`[S3 Speed Test] Type: ${testType}, Concurrency: ${concurrency}, Count: ${count}`)
console.log(`[S3 Speed Test] Is Intranet: ${isIntranet}`)
const result: any = {
success: true,
isIntranet,
config: {
s3Endpoint: process.env.S3_ENDPOINT,
s3PublicDomain: process.env.S3_PUBLIC_DOMAIN,
serverIp: process.env.SERVER_IP,
minioPort: process.env.MINIO_API_PORT || '9000',
},
timestamp: new Date().toISOString(),
}
// 1. 基础连接测试
console.log('[S3 Speed Test] 1. Testing connection...')
const connectionTime = await testConnectionSpeed()
result.connection = { duration: connectionTime }
// 2. 单文件上传测试(不同大小)
if (testType === 'full' || testType === 'quick') {
console.log('[S3 Speed Test] 2. Testing single file uploads (various sizes)...')
const uploadSizes: number[] = [10, 100, 1024]
result.upload = {}
for (const sizeKB of uploadSizes) {
const test = await testUploadSpeed(sizeKB)
result.upload[`${sizeKB}KB`] = {
sizeKB,
duration: test.duration,
speedKBps: test.speed,
speedMBps: test.speed / 1024,
success: test.success,
error: test.error,
}
// 清理
if (test.key) {
s3Client.send(
new DeleteObjectCommand({
Bucket: process.env.S3_BUCKET!,
Key: test.key,
})
).catch(() => {})
}
}
}
// 3. 并发上传测试
if (testType === 'full' || testType === 'concurrent' || testType === 'mixed' || testType === 'quick') {
console.log('[S3 Speed Test] 3. Testing concurrent uploads...')
const concurrentSizes = testType === 'quick'
? [{ size: 100, concurrency: Math.min(5, concurrency), count: Math.min(20, count) }]
: [
{ size: 10, concurrency, count },
{ size: 100, concurrency, count },
{ size: 1024, concurrency: Math.min(5, concurrency), count: Math.min(20, count) },
]
result.concurrentUpload = {}
for (const config of concurrentSizes) {
const test = await testConcurrentUploads(config.concurrency, config.size, config.count)
result.concurrentUpload[`${config.size}KB_${config.concurrency}并发_${config.count}次`] = {
...test,
config,
}
}
}
// 4. 下载测试
if (testType === 'full' || testType === 'quick' || testType === 'concurrent' || testType === 'mixed') {
console.log('[S3 Speed Test] 4. Testing downloads...')
const testFileUrls = await getTestFileUrls(20)
if (testFileUrls.length > 0) {
// 公网下载测试
const publicDownload = await testDownloadSpeed(testFileUrls[0])
result.download = {
public: {
url: testFileUrls[0],
...publicDownload,
speedMBps: publicDownload.speed / 1024,
},
}
// 内网下载测试
if (isIntranet && process.env.SERVER_IP) {
try {
const url = new URL(testFileUrls[0])
const intranetUrl = `http://${process.env.SERVER_IP}:${process.env.MINIO_API_PORT || '9000'}${url.pathname}`
const intranetDownload = await testDownloadSpeed(intranetUrl)
result.download.intranet = {
url: intranetUrl,
...intranetDownload,
speedMBps: intranetDownload.speed / 1024,
speedup: publicDownload.speed > 0 ? (intranetDownload.speed / publicDownload.speed) : 0,
}
} catch (error) {
console.warn('[S3 Speed Test] Cannot test intranet download')
}
}
// 并发下载测试
if (testType === 'full' || testType === 'concurrent' || testType === 'mixed' || testType === 'quick') {
console.log('[S3 Speed Test] 5. Testing concurrent downloads...')
const concurrentConfigs: Array<{ concurrency: number; count: number }> = testType === 'quick'
? [{ concurrency: Math.min(5, concurrency), count: Math.min(20, count) }]
: [
{ concurrency, count },
]
result.concurrentDownload = {}
for (const config of concurrentConfigs) {
const test = await testConcurrentDownloads(config.concurrency, testFileUrls, config.count)
result.concurrentDownload[`${config.concurrency}并发_${config.count}次`] = {
...test,
config,
}
}
}
}
}
// 6. 混合负载测试
if (testType === 'full' || testType === 'mixed') {
console.log('[S3 Speed Test] 6. Testing mixed load (upload + download)...')
const testFileUrls = await getTestFileUrls(10)
if (testFileUrls.length > 0) {
const mixedTest = await testMixedLoad(
concurrency,
concurrency,
100, // 100KB上传
testFileUrls,
mixedDurationMs
)
result.mixedLoad = mixedTest
}
}
// 7. 压力测试(高并发)
if (testType === 'full') {
console.log('[S3 Speed Test] 7. Testing stress (high concurrency)...')
const stressTest = await testConcurrentUploads(concurrency, 50, count)
result.stressTest = stressTest
}
// 8. 最终清理:确保所有测试文件都被删除
console.log('[S3 Speed Test] 8. Cleaning up all test files...')
const cleanupResult = await cleanupAllTestFiles()
result.cleanup = cleanupResult
console.log(`[S3 Speed Test] Cleanup: ${cleanupResult.deleted} deleted, ${cleanupResult.failed} failed`)
console.log('[S3 Speed Test] All tests completed')
return NextResponse.json(result)
} catch (error: any) {
console.error('[S3 Speed Test] Error:', error)
return NextResponse.json(
{
success: false,
error: error.message || '测试失败',
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined,
},
{ status: 500 }
)
}
}

View File

@ -1,324 +0,0 @@
'use client'
import { useState, useEffect } from 'react'
import { initializeNetwork, clearNetworkCache } from '@/lib/network'
interface NetworkDebugInfo {
success: boolean
isIntranet: boolean
isPublicDomain: boolean
clientIp: string
clientIpSource: string
hostname: string
debug: {
forwardedIpList: string[]
hasInternalIpInForwarded: boolean
realIp: string | null
cfConnectingIp: string | null
xClientIp: string | null
clientIpIsInternal: boolean | null
}
timestamp: string
}
export default function NetworkDiagnosePage() {
const [loading, setLoading] = useState(true)
const [networkInfo, setNetworkInfo] = useState<NetworkDebugInfo | null>(null)
const [frontendStatus, setFrontendStatus] = useState<{ isInternal: boolean; host: string } | null>(null)
const [error, setError] = useState<string | null>(null)
const [testResults, setTestResults] = useState<{
internalMinIO: { success: boolean; time: number; error?: string } | null
publicS3: { success: boolean; time: number; error?: string } | null
}>({ internalMinIO: null, publicS3: null })
const fetchNetworkStatus = async () => {
setLoading(true)
setError(null)
try {
// 清除前端缓存,强制重新检测
clearNetworkCache()
// 获取前端网络状态
const frontendResult = await initializeNetwork(true)
setFrontendStatus(frontendResult)
// 获取后端网络状态(带诊断信息)
const response = await fetch('/api/network-status', { cache: 'no-store' })
if (response.ok) {
const data = await response.json()
setNetworkInfo(data)
} else {
setError('获取网络状态失败')
}
} catch (err) {
setError(err instanceof Error ? err.message : '未知错误')
} finally {
setLoading(false)
}
}
// 测试内网MinIO连接
const testInternalMinIO = async () => {
setTestResults(prev => ({ ...prev, internalMinIO: null }))
const startTime = Date.now()
try {
// 尝试访问内网MinIO的一个测试图片
const testUrl = 'http://192.168.58.20:9000/gongxiangpan/由壹点AI/test-connection.txt'
const response = await fetch(testUrl, {
method: 'HEAD',
mode: 'no-cors', // 跨域请求
cache: 'no-store'
})
const time = Date.now() - startTime
// no-cors 模式下无法读取响应状态,但如果没有抛出错误,说明网络可达
setTestResults(prev => ({
...prev,
internalMinIO: { success: true, time, error: '网络可达(无法确认文件是否存在)' }
}))
} catch (err) {
const time = Date.now() - startTime
setTestResults(prev => ({
...prev,
internalMinIO: {
success: false,
time,
error: err instanceof Error ? err.message : '连接失败'
}
}))
}
}
// 测试公网S3连接
const testPublicS3 = async () => {
setTestResults(prev => ({ ...prev, publicS3: null }))
const startTime = Date.now()
try {
// 尝试访问公网S3的一个测试图片
const testUrl = 'https://s3.jiansun.vip/gongxiangpan/由壹点AI/test-connection.txt'
const response = await fetch(testUrl, {
method: 'HEAD',
mode: 'no-cors',
cache: 'no-store'
})
const time = Date.now() - startTime
setTestResults(prev => ({
...prev,
publicS3: { success: true, time, error: '网络可达(无法确认文件是否存在)' }
}))
} catch (err) {
const time = Date.now() - startTime
setTestResults(prev => ({
...prev,
publicS3: {
success: false,
time,
error: err instanceof Error ? err.message : '连接失败'
}
}))
}
}
useEffect(() => {
fetchNetworkStatus()
}, [])
return (
<div className="min-h-screen bg-gray-100 py-8">
<div className="max-w-4xl mx-auto px-4">
<h1 className="text-2xl font-bold text-gray-800 mb-6">网络诊断</h1>
{/* 刷新按钮 */}
<div className="mb-6">
<button
onClick={fetchNetworkStatus}
disabled={loading}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
>
{loading ? '检测中...' : '重新检测'}
</button>
</div>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6">
{error}
</div>
)}
{/* 检测结果 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
{/* 前端检测结果 */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4">前端检测结果</h2>
{frontendStatus ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-600">网络类型:</span>
<span className={`font-medium ${frontendStatus.isInternal ? 'text-green-600' : 'text-blue-600'}`}>
{frontendStatus.isInternal ? '✅ 内网' : '🌐 外网'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">当前Host:</span>
<span className="font-mono text-sm">{frontendStatus.host}</span>
</div>
</div>
) : (
<div className="text-gray-500">加载中...</div>
)}
</div>
{/* 后端检测结果 */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4">后端检测结果</h2>
{networkInfo ? (
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-gray-600">网络类型:</span>
<span className={`font-medium ${networkInfo.isIntranet ? 'text-green-600' : 'text-blue-600'}`}>
{networkInfo.isIntranet ? '✅ 内网' : '🌐 外网'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">客户端IP:</span>
<span className="font-mono text-sm">{networkInfo.clientIp}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">IP来源:</span>
<span className="font-mono text-sm">{networkInfo.clientIpSource}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Hostname:</span>
<span className="font-mono text-sm">{networkInfo.hostname}</span>
</div>
</div>
) : (
<div className="text-gray-500">加载中...</div>
)}
</div>
</div>
{/* 详细诊断信息 */}
{networkInfo?.debug && (
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">详细诊断信息</h2>
<div className="space-y-3">
<div>
<span className="text-gray-600">X-Forwarded-For IP列表:</span>
<div className="font-mono text-sm bg-gray-100 p-2 rounded mt-1">
{networkInfo.debug.forwardedIpList.length > 0
? networkInfo.debug.forwardedIpList.join(' → ')
: '(空)'}
</div>
</div>
<div className="flex justify-between">
<span className="text-gray-600">X-Forwarded-For中有内网IP:</span>
<span className={networkInfo.debug.hasInternalIpInForwarded ? 'text-green-600' : 'text-gray-500'}>
{networkInfo.debug.hasInternalIpInForwarded ? '是' : '否'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">X-Real-IP:</span>
<span className="font-mono text-sm">{networkInfo.debug.realIp || '(空)'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">CF-Connecting-IP:</span>
<span className="font-mono text-sm">{networkInfo.debug.cfConnectingIp || '(空)'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">X-Client-IP:</span>
<span className="font-mono text-sm">{networkInfo.debug.xClientIp || '(空)'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">客户端IP是内网:</span>
<span className={networkInfo.debug.clientIpIsInternal ? 'text-green-600' : 'text-blue-600'}>
{networkInfo.debug.clientIpIsInternal === null ? '未知' : networkInfo.debug.clientIpIsInternal ? '是' : '否'}
</span>
</div>
</div>
</div>
)}
{/* 连接测试 */}
<div className="bg-white rounded-lg shadow p-6 mb-6">
<h2 className="text-lg font-semibold mb-4">连接测试</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 内网MinIO测试 */}
<div className="border rounded p-4">
<h3 className="font-medium mb-2">内网MinIO (192.168.58.20:9000)</h3>
<button
onClick={testInternalMinIO}
className="px-3 py-1 bg-gray-200 rounded hover:bg-gray-300 text-sm mb-2"
>
测试连接
</button>
{testResults.internalMinIO && (
<div className={`text-sm ${testResults.internalMinIO.success ? 'text-green-600' : 'text-red-600'}`}>
{testResults.internalMinIO.success ? '✅' : '❌'} {testResults.internalMinIO.time}ms
{testResults.internalMinIO.error && (
<div className="text-gray-500 text-xs mt-1">{testResults.internalMinIO.error}</div>
)}
</div>
)}
</div>
{/* 公网S3测试 */}
<div className="border rounded p-4">
<h3 className="font-medium mb-2">公网S3 (s3.jiansun.vip)</h3>
<button
onClick={testPublicS3}
className="px-3 py-1 bg-gray-200 rounded hover:bg-gray-300 text-sm mb-2"
>
测试连接
</button>
{testResults.publicS3 && (
<div className={`text-sm ${testResults.publicS3.success ? 'text-green-600' : 'text-red-600'}`}>
{testResults.publicS3.success ? '✅' : '❌'} {testResults.publicS3.time}ms
{testResults.publicS3.error && (
<div className="text-gray-500 text-xs mt-1">{testResults.publicS3.error}</div>
)}
</div>
)}
</div>
</div>
</div>
{/* 浏览器信息 */}
<div className="bg-white rounded-lg shadow p-6">
<h2 className="text-lg font-semibold mb-4">浏览器信息</h2>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">User Agent:</span>
<span className="font-mono text-xs max-w-md truncate" title={typeof navigator !== 'undefined' ? navigator.userAgent : ''}>
{typeof navigator !== 'undefined' ? navigator.userAgent : 'N/A'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">当前URL:</span>
<span className="font-mono text-xs">{typeof window !== 'undefined' ? window.location.href : 'N/A'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">检测时间:</span>
<span className="font-mono text-xs">{networkInfo?.timestamp || 'N/A'}</span>
</div>
</div>
</div>
{/* 说明 */}
<div className="mt-6 text-sm text-gray-500">
<p>💡 提示:如果在内网但检测为外网,可能是以下原因:</p>
<ul className="list-disc list-inside mt-2 space-y-1">
<li>代理/VPN 导致 IP 地址被识别为公网</li>
<li>浏览器扩展修改了请求头</li>
<li>DNS 解析到了公网地址而非内网地址</li>
<li>X-Forwarded-For 头中没有内网IP</li>
</ul>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,87 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Header from '@/components/Header'
jest.mock('next-auth/react', () => ({
signOut: jest.fn(),
}))
jest.mock('next/navigation', () => ({
useRouter: () => ({
push: jest.fn(),
refresh: jest.fn(),
}),
}))
jest.mock('@/lib/network', () => ({
initializeNetwork: jest.fn().mockResolvedValue({ isInternal: true }),
setupNetworkChangeDetection: jest.fn(),
addNetworkChangeListener: jest.fn(() => jest.fn()),
}))
jest.mock('@/components/UpdateLogModal', () => ({
__esModule: true,
default: () => null,
showUpdateLog: jest.fn(),
useUpdateLog: () => ({
showModal: false,
currentLog: null,
showAllHistory: false,
handleClose: jest.fn(),
}),
}))
jest.mock('@/components/MaintenanceBanner', () => ({
__esModule: true,
default: () => null,
}))
jest.mock('@/components/HeaderTaobaoSearch', () => ({
__esModule: true,
default: () => null,
}))
describe('Header 用户菜单', () => {
beforeEach(() => {
jest.clearAllMocks()
global.fetch = jest.fn().mockResolvedValue({
ok: true,
json: async () => ({
headerBgColor: '#1f2937',
headerLogoUrl: '/api/favicon',
siteName: '由壹点3D素材',
}),
}) as jest.Mock
})
test('保留常用入口并移除网络诊断和清除缓存', () => {
render(
<Header
session={
{
user: {
id: 'user-1',
name: '测试用户',
email: 'user@example.com',
role: 'USER',
},
} as any
}
/>,
)
const menuButton = screen.getByRole('button', { name: '打开用户菜单' })
expect(menuButton).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(menuButton)
expect(menuButton).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByRole('link', { name: '个人中心' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: '考核调研' })).toBeInTheDocument()
expect(screen.getByRole('link', { name: '作品过稿看板' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '更新记录' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: '退出登录' })).toBeInTheDocument()
expect(screen.queryByText('网络诊断')).not.toBeInTheDocument()
expect(screen.queryByText('清除缓存并刷新')).not.toBeInTheDocument()
})
})

View File

@ -8,7 +8,6 @@ import { Session } from 'next-auth'
import { useState, useRef, useEffect, type MouseEvent as ReactMouseEvent } from 'react'
import { initializeNetwork, setupNetworkChangeDetection, addNetworkChangeListener } from '@/lib/network'
import UpdateLogModal, { useUpdateLog, showUpdateLog } from '@/components/UpdateLogModal'
import NetworkDiagnoseModal from '@/components/NetworkDiagnoseModal'
import MaintenanceBanner from '@/components/MaintenanceBanner'
import HeaderTaobaoSearch from '@/components/HeaderTaobaoSearch'
import { formatHumanSpeed, formatTransferProgressSummary } from '@/lib/human-readable-transfer'
@ -18,51 +17,6 @@ interface HeaderProps {
}
const DEFAULT_HEADER_LOGO_URL = '/api/favicon'
const MATERIAL_LOCAL_STORAGE_KEYS = [
'searchHistory',
'ranking_panel_state',
'shadowIndicatorPosition',
]
const MATERIAL_LOCAL_STORAGE_PREFIXES = ['update_log_viewed_version']
const MATERIAL_SESSION_STORAGE_KEYS = [
'home_scroll_position',
'home_materials_cache',
]
const MATERIAL_SESSION_STORAGE_PREFIXES = ['maintenance_dismissed_']
const MATERIAL_INDEXED_DB_NAMES = ['chunk-upload-db']
function removeMaterialStorageItems(
storage: Storage,
exactKeys: string[],
prefixKeys: string[] = [],
) {
const keysToRemove: string[] = []
for (let i = 0; i < storage.length; i++) {
const key = storage.key(i)
if (!key) continue
if (exactKeys.includes(key) || prefixKeys.some((prefix) => key.startsWith(prefix))) {
keysToRemove.push(key)
}
}
keysToRemove.forEach((key) => storage.removeItem(key))
return keysToRemove
}
function deleteIndexedDatabase(name: string) {
return new Promise<string>((resolve) => {
const request = indexedDB.deleteDatabase(name)
request.onsuccess = () => resolve(name)
request.onerror = () => resolve(name)
request.onblocked = () => resolve(name)
})
}
function clearMaterialVersionCookie() {
document.cookie = 'app-version=; Max-Age=0; path=/; SameSite=Lax'
}
export default function Header({ session }: HeaderProps) {
const router = useRouter()
@ -74,7 +28,6 @@ export default function Header({ session }: HeaderProps) {
const [networkChecked, setNetworkChecked] = useState(false)
// 网络切换横幅已移除:主站统一使用 https://sc.jiansun.vip,旧域名 3d.yydd.ai 仅保留跳转
const [isAccessingFromExternal, setIsAccessingFromExternal] = useState(false)
const [showNetworkDiagnose, setShowNetworkDiagnose] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
// 性能监控悬停卡片状态
@ -532,6 +485,8 @@ export default function Header({ session }: HeaderProps) {
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setShowDropdown(!showDropdown)}
aria-label="打开用户菜单"
aria-expanded={showDropdown}
className="flex items-center p-1.5 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-900 transition-all"
>
{/* 圆形头像 */}
@ -657,119 +612,6 @@ export default function Header({ session }: HeaderProps) {
</Link>
)}
{/* 网络诊断 */}
<button
onClick={() => {
setShowDropdown(false)
setShowNetworkDiagnose(true)
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all flex items-center space-x-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<span>网络诊断</span>
</button>
{/* 清除缓存(仅清除素材网相关缓存) */}
<button
onClick={async () => {
setShowDropdown(false)
try {
const currentOrigin = window.location.origin
// 1. 清除当前素材库域名下的 Cache Storage。
// 浏览器同源策略会限制 caches.keys() 只能看到当前域名,不会影响其它网站。
if ('caches' in window) {
const cacheNames = await caches.keys()
await Promise.all(cacheNames.map(name => caches.delete(name)))
console.log(`已清除 ${cacheNames.length} 个当前域名 Cache Storage:`, cacheNames)
}
// 2. 注销当前素材库域名下的 Service Worker
if ('serviceWorker' in navigator) {
const registrations = await navigator.serviceWorker.getRegistrations()
const materialRegistrations = registrations.filter(reg => {
return reg.scope.startsWith(currentOrigin)
})
await Promise.all(materialRegistrations.map(reg => reg.unregister()))
console.log(`已清除 ${materialRegistrations.length} 个Service Worker`)
}
// 3. 精准清除素材库写入的 localStorage,不清其它无关 key。
try {
const keysToRemove = removeMaterialStorageItems(
localStorage,
MATERIAL_LOCAL_STORAGE_KEYS,
MATERIAL_LOCAL_STORAGE_PREFIXES,
)
console.log(`已清除 ${keysToRemove.length} 个localStorage项:`, keysToRemove)
} catch (e) {
console.warn('清除localStorage失败:', e)
}
// 4. 精准清除素材库写入的 sessionStorage,包含首页筛选/滚动缓存。
try {
const keysToRemove = removeMaterialStorageItems(
sessionStorage,
MATERIAL_SESSION_STORAGE_KEYS,
MATERIAL_SESSION_STORAGE_PREFIXES,
)
console.log(`已清除 ${keysToRemove.length} 个sessionStorage项:`, keysToRemove)
} catch (e) {
console.warn('清除sessionStorage失败:', e)
}
// 5. 清除素材库断点上传 IndexedDB
try {
if ('indexedDB' in window) {
const deletedDatabases = await Promise.all(
MATERIAL_INDEXED_DB_NAMES.map((name) => deleteIndexedDatabase(name))
)
console.log(`已清除 ${deletedDatabases.length} 个IndexedDB数据库:`, deletedDatabases)
}
} catch (e) {
console.warn('清除IndexedDB失败:', e)
}
// 6. 清除素材库版本 cookie,但保留登录 cookie,避免用户被登出。
try {
clearMaterialVersionCookie()
console.log('已清除素材库版本 cookie: app-version')
} catch (e) {
console.warn('清除版本 cookie 失败:', e)
}
// 显示提示信息
console.log('✅ 素材网缓存已清除,正在强制刷新页面...')
// 延迟一小段时间让用户看到提示,然后强制刷新
setTimeout(() => {
// 使用硬刷新(Ctrl+F5 效果)
// 方法1:通过修改 URL 添加时间戳参数强制绕过缓存
const url = new URL(window.location.href)
// 移除旧的缓存参数
url.searchParams.delete('_t')
url.searchParams.delete('_clear_cache')
// 添加新的时间戳参数
url.searchParams.set('_t', Date.now().toString())
// 使用 location.replace 强制刷新(不会在历史记录中留下记录)
window.location.replace(url.toString())
}, 300)
} catch (error) {
console.error('清除缓存失败:', error)
alert('清除缓存失败,请手动刷新页面(Ctrl+F5 或 Cmd+Shift+R)')
}
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-all flex items-center space-x-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span>清除缓存并刷新</span>
</button>
{/* 退出登录 */}
<button
onClick={async () => {
@ -823,12 +665,6 @@ export default function Header({ session }: HeaderProps) {
updateLog={currentLog || undefined}
showAllHistory={showAllHistory}
/>
{/* 网络诊断弹窗 */}
<NetworkDiagnoseModal
isOpen={showNetworkDiagnose}
onClose={() => setShowNetworkDiagnose(false)}
/>
</>
)
}

File diff suppressed because it is too large Load Diff